Files
cmbot/tests/test_composer.py
adminandClaude Opus 4.8 f2b08fecb3 fix: per-item transform in batch export (correct placement across sizes)
QueuePanel reused one pixel TransformState for all non-fine-tuned items,
mis-placing garments/prints of other sizes. Store the ratio-based template
instead and recompute each item from its own garment/print size.

- add core.composer.image_size() and resolve_transform() (pure, tested)
- queue: set_template() replaces set_transform(); per-item resolve + size cache
- template_panel: expose current_template(); main_window pushes it to the queue
- tests: +4 covering fine-tune precedence, per-size recompute, empty inputs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:42:20 +08:00

252 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for core.composer — no GUI dependency."""
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from core.composer import compose, resolve_transform
from core.models import ExportOptions, Template, TransformState
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _rgba(w, h, color=(200, 200, 200, 255)):
return Image.new("RGBA", (w, h), color)
class _TmpDir:
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _save(self, img: Image.Image, name: str) -> Path:
p = self.tmp / name
img.save(str(p))
return p
def _compose(self, garment, print_img, state, fmt="PNG", quality=90):
ext = ".jpg" if fmt.upper() in ("JPG", "JPEG") else ".png"
g = self._save(garment, "garment.png") # garment always PNG
p = self._save(print_img, "print.png")
out = self.tmp / ("out" + ext)
opts = ExportOptions(output_format=fmt, quality=quality)
return compose(g, p, state, opts, out), out
# ---------------------------------------------------------------------------
# Transparency tests
# ---------------------------------------------------------------------------
class TestTransparency(_TmpDir, unittest.TestCase):
def test_fully_transparent_print_leaves_garment_unchanged(self):
"""An all-alpha-0 print must not modify the garment pixels."""
garment = _rgba(100, 100, (200, 200, 200, 255))
print_img = Image.new("RGBA", (40, 40), (255, 0, 0, 0)) # fully transparent
state = TransformState(x=30, y=30, width=40, height=40)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path)).convert("RGBA")
r, g, b, _ = out.getpixel((50, 50))
self.assertEqual((r, g, b), (200, 200, 200))
def test_opaque_print_overwrites_garment_pixels(self):
"""Fully opaque blue print should cover garment in the print area."""
garment = _rgba(100, 100, (200, 200, 200, 255))
print_img = _rgba(20, 20, (0, 0, 255, 255))
state = TransformState(x=40, y=40, width=20, height=20)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path)).convert("RGBA")
r, g, b, _ = out.getpixel((50, 50)) # center of print area
self.assertEqual((r, g, b), (0, 0, 255))
# ---------------------------------------------------------------------------
# Scaling tests
# ---------------------------------------------------------------------------
class TestScaling(_TmpDir, unittest.TestCase):
def test_print_is_scaled_to_state_dimensions(self):
"""A 10×10 print scaled to 60×80 should fill that region."""
garment = _rgba(200, 200)
print_img = _rgba(10, 10, (255, 0, 0, 255))
state = TransformState(x=70, y=60, width=60, height=80)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path)).convert("RGBA")
# Inside print area → red
r, g, b, _ = out.getpixel((100, 100))
self.assertEqual((r, g, b), (255, 0, 0))
# Outside print area → garment gray
r2, g2, b2, _ = out.getpixel((10, 10))
self.assertEqual((r2, g2, b2), (200, 200, 200))
def test_output_dimensions_match_garment(self):
"""Output size must always equal the garment size, regardless of print size."""
gw, gh = 320, 480
garment = _rgba(gw, gh)
print_img = _rgba(10, 10, (0, 255, 0, 255))
state = TransformState(x=100, y=100, width=80, height=80)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path))
self.assertEqual(out.size, (gw, gh))
# ---------------------------------------------------------------------------
# Rotation centre tests
# ---------------------------------------------------------------------------
class TestRotation(_TmpDir, unittest.TestCase):
def test_rotation_centre_fixed_for_square(self):
"""After 90° CW rotation of a square print, its centre stays at (cx, cy)."""
garment = _rgba(200, 200)
print_img = _rgba(20, 20, (255, 0, 0, 255))
# Centre = (90+10, 90+10) = (100, 100)
state = TransformState(x=90, y=90, width=20, height=20, rotation=90)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path)).convert("RGBA")
r, g, b, _ = out.getpixel((100, 100))
self.assertEqual((r, g, b), (255, 0, 0), "Centre pixel must be from the print")
def test_zero_rotation_same_as_no_rotation(self):
"""rotation=0 should produce the same result as not specifying rotation."""
garment = _rgba(100, 100)
print_img = _rgba(20, 20, (0, 0, 255, 255))
state = TransformState(x=40, y=40, width=20, height=20, rotation=0)
result, out_path = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
out = Image.open(str(out_path)).convert("RGBA")
r, g, b, _ = out.getpixel((50, 50))
self.assertEqual((r, g, b), (0, 0, 255))
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases(_TmpDir, unittest.TestCase):
def test_print_partially_outside_canvas_no_error(self):
"""Print whose bounding box extends beyond the canvas should not raise."""
garment = _rgba(50, 50)
print_img = _rgba(20, 20, (255, 0, 0, 255))
state = TransformState(x=45, y=45, width=20, height=20)
result, _ = self._compose(garment, print_img, state)
self.assertTrue(result.success, result.error)
def test_invalid_zero_dimensions_returns_failure(self):
"""width=0 or height=0 must return a ComposeResult with success=False."""
garment = _rgba(100, 100)
print_img = _rgba(10, 10)
g = self._save(garment, "g.png")
p = self._save(print_img, "p.png")
out = self.tmp / "out.png"
state = TransformState(x=0, y=0, width=0, height=0)
result = compose(g, p, state, ExportOptions(), out)
self.assertFalse(result.success)
self.assertTrue(result.error)
def test_missing_garment_file_returns_failure(self):
"""Non-existent garment path must return failure without raising."""
p = self._save(_rgba(10, 10), "p.png")
out = self.tmp / "out.png"
state = TransformState(x=0, y=0, width=10, height=10)
result = compose(self.tmp / "no_such_file.png", p, state, ExportOptions(), out)
self.assertFalse(result.success)
# ---------------------------------------------------------------------------
# JPG output
# ---------------------------------------------------------------------------
class TestJpgOutput(_TmpDir, unittest.TestCase):
def test_jpg_export_creates_file(self):
garment = _rgba(100, 100)
print_img = _rgba(30, 30, (0, 200, 0, 255))
state = TransformState(x=35, y=35, width=30, height=30)
result, out_path = self._compose(garment, print_img, state, fmt="JPG")
self.assertTrue(result.success, result.error)
self.assertTrue(out_path.exists())
def test_jpg_export_no_alpha_error(self):
"""JPG export must not raise due to alpha channel handling."""
garment = _rgba(80, 80, (255, 255, 255, 255))
print_img = Image.new("RGBA", (20, 20), (255, 0, 0, 128)) # semi-transparent
state = TransformState(x=30, y=30, width=20, height=20)
result, _ = self._compose(garment, print_img, state, fmt="JPG")
self.assertTrue(result.success, result.error)
class TestResolveTransform(unittest.TestCase):
"""resolve_transform: per-item placement for batch export."""
def _square_template(self):
# 25% box centred at (0.40,0.40)-(0.65,0.65); square print fills the box.
return Template(name="sq", x_ratio=0.40, y_ratio=0.40,
width_ratio=0.25, height_ratio=0.25)
def test_fine_tuned_transform_wins(self):
own = TransformState(x=1, y=2, width=3, height=4)
out = resolve_transform(self._square_template(), own, (1000, 1000), (50, 50))
self.assertIs(out, own)
def test_differently_sized_garments_get_different_pixels(self):
"""The core bug: one shared pixel transform mis-places other sizes.
Each garment must be laid out from its own dimensions."""
tpl = self._square_template()
small = resolve_transform(tpl, None, (1000, 1000), (100, 100))
large = resolve_transform(tpl, None, (2000, 3000), (100, 100))
# 1000px garment: 25% box = 250px, square print fills it, centred at 0.525.
self.assertAlmostEqual(small.width, 250.0)
self.assertAlmostEqual(small.x, 400.0)
# 2000x3000 garment: box is 500x750, square print contain-fits to 500,
# and x stays proportional — proving per-item recomputation.
self.assertAlmostEqual(large.width, 500.0)
self.assertAlmostEqual(large.x, 800.0)
self.assertNotAlmostEqual(small.x, large.x)
def test_none_template_returns_none(self):
self.assertIsNone(resolve_transform(None, None, (100, 100), (10, 10)))
def test_missing_sizes_return_none(self):
tpl = self._square_template()
self.assertIsNone(resolve_transform(tpl, None, None, (10, 10)))
self.assertIsNone(resolve_transform(tpl, None, (100, 100), None))
self.assertIsNone(resolve_transform(tpl, None, (0, 0), (10, 10)))
if __name__ == "__main__":
unittest.main()