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,
)