feat: implement image composer with Pillow

- compose(): scales print to TransformState.width/height, rotates
  clockwise by TransformState.rotation (negated for Pillow), pastes
  onto a transparent layer via alpha_composite, saves PNG or JPG
- Rotation centre fixed at print bounding-box centre; paste_x/y
  computed from centre - rotated_size/2 per design doc §13.3
- JPG: flattens RGBA onto white RGB background before saving
- Out-of-bounds print clips silently via PIL paste; width/height<=0
  returns ComposeResult(success=False); file errors caught, never raise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:28:02 +08:00
co-authored by Claude Sonnet 4.6
parent a6a8504ea8
commit 4b008a0dc8
2 changed files with 129 additions and 16 deletions
+115 -2
View File
@@ -1,2 +1,115 @@
def compose_image(*args, **kwargs):
raise NotImplementedError("Image composition is not implemented yet.")
import logging
from pathlib import Path
from typing import Union
from PIL import Image
from core.models import ComposeResult, ExportOptions, TransformState
logger = logging.getLogger(__name__)
def compose(
garment_path: Union[str, Path],
print_path: Union[str, Path],
transform: TransformState,
options: ExportOptions,
output_path: Union[str, Path],
) -> ComposeResult:
"""Composite a print image onto a garment image and save the result.
The print is scaled to (transform.width, transform.height), rotated by
transform.rotation degrees clockwise around its centre, then composited
onto the garment using alpha blending.
Caller is responsible for generating a unique output_path (e.g. via
file_service.make_safe_output_path) to avoid silent overwrites.
Returns ComposeResult with success=True on success, or success=False
with an error description on any failure. Never raises.
"""
garment_path = Path(garment_path)
print_path = Path(print_path)
output_path = Path(output_path)
# --- validate dimensions ------------------------------------------------
if transform.width <= 0 or transform.height <= 0:
msg = "Invalid dimensions: width={}, height={}".format(
transform.width, transform.height
)
logger.error("compose: %s", msg)
return ComposeResult(
success=False, garment_path=garment_path,
print_path=print_path, error=msg,
)
try:
garment = Image.open(str(garment_path)).convert("RGBA")
print_img = Image.open(str(print_path)).convert("RGBA")
# --- scale ----------------------------------------------------------
target_w = max(1, round(transform.width))
target_h = max(1, round(transform.height))
print_img = print_img.resize((target_w, target_h), Image.LANCZOS)
# --- rotate ---------------------------------------------------------
# Positive rotation = clockwise on screen.
# Pillow rotate() is counter-clockwise, so negate.
if transform.rotation % 360 != 0:
print_img = print_img.rotate(
-transform.rotation, expand=True, resample=Image.BICUBIC
)
# --- paste position -------------------------------------------------
# Keep the rotation centre (= centre of the unrotated bounding box)
# fixed in garment pixel coordinates.
centre_x = transform.x + transform.width / 2.0
centre_y = transform.y + transform.height / 2.0
paste_x = round(centre_x - print_img.width / 2.0)
paste_y = round(centre_y - print_img.height / 2.0)
# --- composite ------------------------------------------------------
# Paint print onto a transparent layer the same size as the garment,
# then alpha-composite the layer over the garment.
# PIL's paste() clips the source automatically when box is out-of-bounds.
layer = Image.new("RGBA", garment.size, (0, 0, 0, 0))
layer.paste(print_img, (paste_x, paste_y), print_img)
result = Image.alpha_composite(garment, layer)
# --- save -----------------------------------------------------------
output_path.parent.mkdir(parents=True, exist_ok=True)
fmt = options.output_format.upper()
if fmt in ("JPG", "JPEG"):
# JPEG does not support alpha: flatten onto a white background.
rgb = Image.new("RGB", result.size, (255, 255, 255))
rgb.paste(result, mask=result.split()[3])
rgb.save(str(output_path), format="JPEG", quality=options.quality)
else:
result.save(str(output_path), format="PNG")
logger.info(
"Composed %s + %s -> %s",
garment_path.name, print_path.name, output_path,
)
return ComposeResult(
success=True,
garment_path=garment_path,
print_path=print_path,
output_path=output_path,
)
except FileNotFoundError as exc:
msg = "File not found: {}".format(exc)
logger.error("compose failed: %s", msg)
return ComposeResult(
success=False, garment_path=garment_path,
print_path=print_path, error=msg,
)
except Exception as exc:
msg = str(exc)
logger.exception("compose failed (%s + %s)", garment_path.name, print_path.name)
return ComposeResult(
success=False, garment_path=garment_path,
print_path=print_path, error=msg,
)
+14 -14
View File
@@ -210,23 +210,23 @@
任务:
- [ ] 先读取 `src/core/composer.py` 现有内容
- [ ] 完善 `src/core/composer.py`
- [ ] 使用 Pillow 读取衣服底图和印花图
- [ ] 支持透明 PNG alpha 合成
- [ ] 支持按 `TransformState` 缩放印花
- [ ] 支持按 `TransformState` 旋转印花
- [ ] 支持旋转后按中心点对齐粘贴
- [ ] 支持 PNG 导出
- [ ] 支持 JPG 导出
- [ ] 输出文件名避免默认覆盖
- [x] 先读取 `src/core/composer.py` 现有内容
- [x] 完善 `src/core/composer.py`
- [x] 使用 Pillow 读取衣服底图和印花图
- [x] 支持透明 PNG alpha 合成
- [x] 支持按 `TransformState` 缩放印花
- [x] 支持按 `TransformState` 旋转印花
- [x] 支持旋转后按中心点对齐粘贴
- [x] 支持 PNG 导出
- [x] 支持 JPG 导出
- [x] 输出文件名避免默认覆盖
验收:
- [ ] 合成逻辑不依赖 PySide6 UI 控件
- [ ] 输出尺寸默认与衣服底图一致
- [ ] 透明 PNG 不出现黑底或白底
- [ ] 印花部分超出画布时不会报错
- [x] 合成逻辑不依赖 PySide6 UI 控件
- [x] 输出尺寸默认与衣服底图一致
- [x] 透明 PNG 不出现黑底或白底
- [x] 印花部分超出画布时不会报错
## 6. 批量任务核心