244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""Self-contained tests for contact_sheet layout + footer rendering.
|
|
|
|
Run: python3 test_contact_sheet.py
|
|
|
|
No printer, no GUI, no pytest. Verifies layout by feeding solid distinct
|
|
colors as "photos" and sampling the resulting sheet's pixels at the
|
|
computed rect centers, plus a few structural checks. Also checks the
|
|
falling_photos rotation constants were halved.
|
|
|
|
The rotation-direction check uses a 4-quadrant asymmetric "photo"; the
|
|
quadrant that lands in the cell's top-left corner tells us whether the
|
|
applied transpose is CCW (correct) or CW (the bug we fixed).
|
|
"""
|
|
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
import contact_sheet as cs
|
|
import falling_photos as fp
|
|
|
|
|
|
def solid_photo(path, rgb):
|
|
# PNG so the solid color survives losslessly for exact pixel checks.
|
|
Image.new("RGB", (800, 600), rgb).save(path, format="PNG")
|
|
|
|
|
|
def quadrant_photo(path, tl, tr, bl, br):
|
|
"""800x600 photo split into 4 solid quadrants (origin top-left)."""
|
|
img = Image.new("RGB", (800, 600), tl)
|
|
px = img.load()
|
|
half_w, half_h = 400, 300
|
|
for x in range(half_w, 800):
|
|
for y in range(0, half_h):
|
|
px[x, y] = tr
|
|
for x in range(0, half_w):
|
|
for y in range(half_h, 600):
|
|
px[x, y] = bl
|
|
for x in range(half_w, 800):
|
|
for y in range(half_h, 600):
|
|
px[x, y] = br
|
|
img.save(path, format="PNG")
|
|
|
|
|
|
def distinct_colors(n):
|
|
cols = []
|
|
for i in range(n):
|
|
cols.append(((i * 47) % 256, (i * 113 + 80) % 256, (i * 197 + 160) % 256))
|
|
return cols
|
|
|
|
|
|
def rect_center(r):
|
|
x, y, w, h = r
|
|
return (x + w // 2, y + h // 2)
|
|
|
|
|
|
def assert_close(a, b, tol=2, msg=""):
|
|
if abs(a - b) > tol:
|
|
raise AssertionError(f"{msg}: {a} != ~{b} (tol {tol})")
|
|
|
|
|
|
def check_layout_combos():
|
|
print("== layout combos ==")
|
|
footer_cfg = {"line1": "TEST LINE 1", "line2": "TEST LINE 2",
|
|
"background_color": "#0000AA", "text_color": "#FFFF00"}
|
|
|
|
for pps, spp in [(3, 3), (1, 4), (4, 1), (2, 2), (2, 3), (3, 4),
|
|
(1, 1), (2, 4), (1, 6), (4, 4), (5, 2), (1, 10)]:
|
|
n_photos = pps * spp
|
|
layout = cs.compute_layout(pps, spp, 120, 30, 30, auto_orient=True)
|
|
assert len(layout["photo_rects"]) == n_photos
|
|
assert len(layout["footer_rects"]) == spp
|
|
assert layout["mode"] in ("portrait_ccw", "landscape_none")
|
|
|
|
page_w, page_h = layout["page_w"], layout["page_h"]
|
|
for (x, y, w, h) in layout["photo_rects"] + layout["footer_rects"]:
|
|
assert 0 <= x and x + w <= page_w, "rect off page (x)"
|
|
assert 0 <= y and y + h <= page_h, "rect off page (y)"
|
|
assert w > 0 and h > 0, "empty rect"
|
|
|
|
# Verticality: each strip is a COLUMN. Photo rects in a strip share x
|
|
# and strictly increase in y (never a horizontal row).
|
|
for strip_i in range(spp):
|
|
base = strip_i * pps
|
|
rects = layout["photo_rects"][base:base + pps]
|
|
xs = {r[0] for r in rects}
|
|
assert len(xs) == 1, f"pps={pps} spp={spp} strip {strip_i}: photos not aligned in x (strip must be a column)"
|
|
ys = [r[1] for r in rects]
|
|
assert ys == sorted(ys), f"pps={pps} spp={spp} strip {strip_i}: not top-to-bottom"
|
|
for a, b in zip(rects, rects[1:]):
|
|
assert b[1] >= a[1] + a[3], "photos overlap vertically"
|
|
# Cell aspect must be exactly 4:3 (3:4 when rotated) to match
|
|
# the captured photos, within integer rounding of the layout.
|
|
cw, ch = rects[0][2], rects[0][3]
|
|
want = 3 / 4 if layout["mode"] == "portrait_ccw" else 4 / 3
|
|
assert abs(cw / ch - want) < 0.02, (
|
|
f"pps={pps} spp={spp} {layout['mode']}: cell {cw}x{ch} "
|
|
f"aspect {cw / ch:.4f} != ~{want:.4f}")
|
|
|
|
# Footer sits below the column, aligned in x, same width.
|
|
for strip_i in range(spp):
|
|
last = layout["photo_rects"][strip_i * pps + (pps - 1)]
|
|
foot = layout["footer_rects"][strip_i]
|
|
assert foot[1] >= last[1] + last[3], "footer not below last photo"
|
|
assert foot[0] == last[0], "footer not aligned with strip x"
|
|
assert foot[2] == last[2], "footer width != photo width"
|
|
|
|
# Build a real sheet and pixel-sample.
|
|
colors = distinct_colors(n_photos)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
paths = []
|
|
for i, c in enumerate(colors):
|
|
p = Path(d) / f"p{i}.png"
|
|
solid_photo(p, c)
|
|
paths.append(p)
|
|
out = Path(d) / "sheet.png"
|
|
cs.build_sheet(paths, out, footer_cfg=footer_cfg,
|
|
photos_per_shoot=pps, shoots_per_print=spp,
|
|
margin=120, photo_gutter=30, strip_gutter=30,
|
|
auto_orient=True)
|
|
from PIL import Image as _I
|
|
with _I.open(out) as opened:
|
|
dpi = opened.info.get("dpi", (0, 0))
|
|
assert abs(dpi[0] - 300) <= 1 and abs(dpi[1] - 300) <= 1, \
|
|
f"sheet dpi metadata wrong: {dpi} (want ~300)"
|
|
sheet = opened.convert("RGB")
|
|
assert sheet.size == (page_w, page_h)
|
|
|
|
for i, r in enumerate(layout["photo_rects"]):
|
|
got = sheet.getpixel(rect_center(r))
|
|
exp = colors[i]
|
|
assert max(abs(g - e) for g, e in zip(got, exp)) <= 2, (
|
|
f"pps={pps} spp={spp} photo {i}: center {got} != ~{exp}")
|
|
|
|
for r in layout["footer_rects"]:
|
|
assert sheet.getpixel((r[0] + 5, r[1] + 5)) == (0, 0, 170), \
|
|
"footer bg wrong"
|
|
band = [sheet.getpixel((x, y))
|
|
for x in range(r[0], r[0] + r[2], 7)
|
|
for y in range(r[1] + r[3] - 60, r[1] + r[3], 7)]
|
|
yellow = sum(1 for p in band
|
|
if all(abs(a - b) <= 25 for a, b in zip(p, (255, 255, 0))))
|
|
assert yellow > 0, f"pps={pps} spp={spp}: no date text in footer"
|
|
|
|
print(f" pps={pps} spp={spp} {layout['mode']} {layout['orientation']} "
|
|
f"cols={layout['cols']} rows={layout['rows']} "
|
|
f"cell={layout['cell_w']}x{layout['cell_h']} OK")
|
|
|
|
|
|
def check_rotation_mode_expectations():
|
|
print("== rotation mode picks ==")
|
|
cases = [
|
|
(3, 3, "portrait_ccw", "portrait", 3), # tall strips -> rotate CCW
|
|
(1, 1, "landscape_none", "portrait", 1), # single big landscape photo
|
|
(1, 4, "landscape_none", "portrait", 2), # 2x2 grid of landscape photos
|
|
]
|
|
for pps, spp, want_mode, want_orient, want_cols in cases:
|
|
lay = cs.compute_layout(pps, spp, 120, 30, 30, auto_orient=True)
|
|
assert lay["mode"] == want_mode, f"{pps}x{spp}: mode {lay['mode']} != {want_mode}"
|
|
assert lay["cols"] == want_cols, f"{pps}x{spp}: cols {lay['cols']} != {want_cols}"
|
|
if want_orient is not None:
|
|
assert lay["orientation"] == want_orient
|
|
print(" OK")
|
|
|
|
|
|
def check_rotation_direction():
|
|
print("== rotation direction (CCW regression guard) ==")
|
|
# Source quadrants: TL=red, TR=green, BL=blue, BR=yellow.
|
|
# Under ROTATE_90 (90 deg CCW), the cell's TOP-LEFT corner samples the
|
|
# source's TOP-RIGHT quadrant (green). If it were CW, it would be blue.
|
|
tl, tr, bl, br = (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
src = Path(d) / "quad.png"
|
|
quadrant_photo(src, tl, tr, bl, br)
|
|
out = Path(d) / "sheet.png"
|
|
cs.build_sheet([src], out,
|
|
footer_cfg={"line1": "", "line2": "",
|
|
"background_color": "#000000", "text_color": "#FFFFFF"},
|
|
photos_per_shoot=1, shoots_per_print=1,
|
|
margin=120, photo_gutter=30, strip_gutter=30,
|
|
auto_orient=True)
|
|
# 1x1 picks landscape_none (no rotation): cell TL must be src TL (red).
|
|
lay = cs.compute_layout(1, 1, 120, 30, 30, auto_orient=True)
|
|
assert lay["mode"] == "landscape_none"
|
|
r = lay["photo_rects"][0]
|
|
sheet = Image.open(out).convert("RGB")
|
|
tl_px = sheet.getpixel((r[0] + r[2] // 10, r[1] + r[3] // 10))
|
|
assert max(abs(a - b) for a, b in zip(tl_px, tl)) <= 4, \
|
|
f"landscape_none TL should be red(src TL), got {tl_px}"
|
|
print(" landscape_none: no-rotate preserves TL quadrant OK")
|
|
|
|
# Now force the portrait_ccw path with a 3x3 (3 strips of 3 -> rotates CCW).
|
|
with tempfile.TemporaryDirectory() as d:
|
|
src = Path(d) / "quad.png"
|
|
quadrant_photo(src, tl, tr, bl, br)
|
|
out = Path(d) / "sheet.png"
|
|
cs.build_sheet([src] * 9, out,
|
|
footer_cfg={"line1": "", "line2": "",
|
|
"background_color": "#000000", "text_color": "#FFFFFF"},
|
|
photos_per_shoot=3, shoots_per_print=3,
|
|
margin=120, photo_gutter=30, strip_gutter=30,
|
|
auto_orient=True)
|
|
lay = cs.compute_layout(3, 3, 120, 30, 30, auto_orient=True)
|
|
assert lay["mode"] == "portrait_ccw", lay["mode"]
|
|
r = lay["photo_rects"][0]
|
|
sheet = Image.open(out).convert("RGB")
|
|
# CCW: cell TL <- src TR (green). CW would be <- src BL (blue).
|
|
tl_px = sheet.getpixel((r[0] + r[2] // 10, r[1] + r[3] // 10))
|
|
assert max(abs(a - b) for a, b in zip(tl_px, tr)) <= 4, \
|
|
f"portrait_ccw TL should be green (src TR under CCW), got {tl_px} -- rotation direction is WRONG (likely CW)"
|
|
print(" portrait_ccw: TL corner = source TR (green) => 90 deg CCW OK")
|
|
|
|
|
|
def check_default_config_shape():
|
|
print("== default config (3x3) ==")
|
|
lay = cs.compute_layout(3, 3, 120, 30, 30, auto_orient=True)
|
|
assert lay["orientation"] == "portrait"
|
|
assert lay["cols"] == 3
|
|
assert lay["mode"] == "portrait_ccw"
|
|
print(f" {lay['mode']} {lay['orientation']} cols={lay['cols']} "
|
|
f"cell={lay['cell_w']}x{lay['cell_h']} OK")
|
|
|
|
|
|
def check_falling_photos_rotation_halved():
|
|
print("== falling_photos rotation constants ==")
|
|
assert fp.ROT_MIN == -12.5 and fp.ROT_MAX == 12.5
|
|
assert fp.SPIN_MIN == -10 and fp.SPIN_MAX == 10
|
|
print(f" ROT=±{fp.ROT_MAX} SPIN=±{fp.SPIN_MAX} OK")
|
|
|
|
|
|
def main():
|
|
check_layout_combos()
|
|
check_rotation_mode_expectations()
|
|
check_rotation_direction()
|
|
check_default_config_shape()
|
|
check_falling_photos_rotation_halved()
|
|
print("\nAll tests passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |