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:
+115
-2
@@ -1,2 +1,115 @@
|
|||||||
def compose_image(*args, **kwargs):
|
import logging
|
||||||
raise NotImplementedError("Image composition is not implemented yet.")
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -210,23 +210,23 @@
|
|||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [ ] 先读取 `src/core/composer.py` 现有内容
|
- [x] 先读取 `src/core/composer.py` 现有内容
|
||||||
- [ ] 完善 `src/core/composer.py`
|
- [x] 完善 `src/core/composer.py`
|
||||||
- [ ] 使用 Pillow 读取衣服底图和印花图
|
- [x] 使用 Pillow 读取衣服底图和印花图
|
||||||
- [ ] 支持透明 PNG alpha 合成
|
- [x] 支持透明 PNG alpha 合成
|
||||||
- [ ] 支持按 `TransformState` 缩放印花
|
- [x] 支持按 `TransformState` 缩放印花
|
||||||
- [ ] 支持按 `TransformState` 旋转印花
|
- [x] 支持按 `TransformState` 旋转印花
|
||||||
- [ ] 支持旋转后按中心点对齐粘贴
|
- [x] 支持旋转后按中心点对齐粘贴
|
||||||
- [ ] 支持 PNG 导出
|
- [x] 支持 PNG 导出
|
||||||
- [ ] 支持 JPG 导出
|
- [x] 支持 JPG 导出
|
||||||
- [ ] 输出文件名避免默认覆盖
|
- [x] 输出文件名避免默认覆盖
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- [ ] 合成逻辑不依赖 PySide6 UI 控件
|
- [x] 合成逻辑不依赖 PySide6 UI 控件
|
||||||
- [ ] 输出尺寸默认与衣服底图一致
|
- [x] 输出尺寸默认与衣服底图一致
|
||||||
- [ ] 透明 PNG 不出现黑底或白底
|
- [x] 透明 PNG 不出现黑底或白底
|
||||||
- [ ] 印花部分超出画布时不会报错
|
- [x] 印花部分超出画布时不会报错
|
||||||
|
|
||||||
## 6. 批量任务核心
|
## 6. 批量任务核心
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user