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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user