From 4b008a0dc82ef0c766f8f8b6a2f5b89b16e09240 Mon Sep 17 00:00:00 2001 From: ila Date: Mon, 15 Jun 2026 17:28:02 +0800 Subject: [PATCH] feat: implement image composer with Pillow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/core/composer.py | 117 ++++++++++++++++++++++++++++++++++++++++++- tasks.md | 28 +++++------ 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/core/composer.py b/src/core/composer.py index 97c25ce..b5bf8c0 100644 --- a/src/core/composer.py +++ b/src/core/composer.py @@ -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, + ) diff --git a/tasks.md b/tasks.md index 1e27426..35dfa8c 100644 --- a/tasks.md +++ b/tasks.md @@ -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. 批量任务核心