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):
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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. 批量任务核心
|
||||
|
||||
|
||||
Reference in New Issue
Block a user