Initial commit: CRT photobooth

This commit is contained in:
2026-07-18 15:17:37 -04:00
commit 8af59ac11f
11 changed files with 1373 additions and 0 deletions

239
test_contact_sheet.py Normal file
View File

@ -0,0 +1,239 @@
"""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 match the chosen mode.
cw, ch = rects[0][2], rects[0][3]
if layout["mode"] == "portrait_ccw":
assert ch >= cw, f"portrait_ccw cell not taller than wide"
else:
assert cw >= ch, f"landscape_none cell not wider than tall"
# 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
sheet = _I.open(out).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()