commit 8af59ac11f62a0f29cd01a299f7107cc1d348068 Author: trav Date: Sat Jul 18 15:17:37 2026 -0400 Initial commit: CRT photobooth diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2b94b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python +__pycache__/ +*.pyc + +# Runtime output (recreated by the program as needed) +photos/ +contact_sheets/ +oldphoto/ + +# Editor backups +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee5701d --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# CRT Photobooth + +A fullscreen photobooth for a Linux laptop mirrored to a 4:3 CRT. Guests press +the button (any mouse button) to take a 3-photo shoot with countdowns and +flashes. After 4 shoots, all 12 photos are printed on one contact sheet. +When idle for 5 minutes, a fish-tank screensaver protects the CRT from burn-in. + +## Setup on a new laptop + +1. **Install Python 3.11 or newer** (check with `python3 --version`). + +2. **Install the dependencies:** + + ```bash + pip install pygame-ce opencv-python pillow + ``` + + Note: it's `pygame-ce`, not `pygame` — the community edition has wheels + for newer Python versions. If pip refuses because the system Python is + "externally managed", either use `pip install --user ...` or create a + virtual environment: + + ```bash + python3 -m venv ~/photobooth-venv + ~/photobooth-venv/bin/pip install pygame-ce opencv-python pillow + # then run with: ~/photobooth-venv/bin/python photobooth.py + ``` + +3. **Plug in the Macally webcam** and confirm Linux sees it: + + ```bash + ls /dev/v4l/by-id/ + ``` + + You should see something like `usb-SolidYear_Macally_USB2.0Camera-video-index0`. + The default config finds it automatically by name. + +4. **Set up the printer.** The booth prints via CUPS with the `lp` command. + Check what's configured and set a default: + + ```bash + lpstat -p # list printers + lpoptions -d NAME # set the default printer + lpstat -p -d # verify: should show a default destination + ``` + + Print a test page from the printer settings GUI to make sure it works + before an event. + +5. **Copy this whole folder** to the laptop and run it: + + ```bash + python3 photobooth.py + ``` + + Add `--windowed` to test in a window instead of fullscreen. + **Press Esc or Q to quit.** + +## Customizing + +- **All settings** live in `config.toml` — camera choice, screen text, + countdown lengths, screensaver timeout, printer options. Comments in the + file explain each one. +- **Idle screen background:** replace `assets/background.png` (any size, + it gets scaled to the screen — 4:3 looks best, e.g. 800×600). +- **Fish:** drop your fish drawings into the `fish/` folder as PNGs with + transparent backgrounds. Draw them **facing right** — the program flips + them automatically when they swim left. Bubbles come out of the front + (mouth) end. Delete `placeholder_fish.png` once you have real fish. + +## Switching cameras + +In `config.toml`, `device = "auto-macally"` finds the Macally cam by name. +To use a different camera: + +- `device = "auto-integrated"` — match another camera by (partial) name + from `ls /dev/v4l/by-id/`, case-insensitive +- `device = "/dev/video2"` — an exact device path +- `device = "0"` — a plain index + +## Where things go + +- `photos/` — individual shots for the current print cycle. Deleted + automatically after a successful print. (If the program is restarted + mid-cycle, photos here are counted so no progress is lost.) +- `contact_sheets/` — every printed sheet, kept as a backup in case of + printer trouble. Delete them manually now and then, or set + `delete_contact_sheets = true` in the config to remove each one right + after it prints. + +## If printing fails + +The booth doesn't lose anything: the contact sheet is saved in +`contact_sheets/`, an error shows on the idle screen, and you can print the +sheet by hand with: + +```bash +lp -o media=letter -o fit-to-page contact_sheets/sheet_XXXX.jpg +``` + +Common causes: printer off or unplugged (`lpstat -p` says "disabled" — +re-enable with `cupsenable PRINTER_NAME`), or no default printer set. + +## Troubleshooting the camera + +Test the camera on its own (uses the device from `config.toml`): + +```bash +python3 camera.py +``` + +**If it reports black frames** (or the live preview shows a warning): the +Macally cam's chipset sometimes wedges and streams pure black until it is +power-cycled. **Unplug the camera, plug it back in, and restart the +program.** Worth doing a quick test shoot at the start of an event. + +## Heads-up + +- The Brother HL-5470DW is a **monochrome laser** — contact sheets print + in black & white. +- The live preview is mirrored (like a mirror) so posing feels natural, but + saved photos are not mirrored. Set `mirror_preview = false` to change that. +- CRT overscan may crop the very edges of the screen; once the CRT is + connected, adjust text positions/sizes in code or the CRT's own controls. diff --git a/assets/background.png b/assets/background.png new file mode 100644 index 0000000..ff57979 Binary files /dev/null and b/assets/background.png differ diff --git a/camera.py b/camera.py new file mode 100644 index 0000000..43de183 --- /dev/null +++ b/camera.py @@ -0,0 +1,163 @@ +"""Threaded webcam capture with a consistent 4:3 center crop. + +The reader thread continuously grabs frames so the UI never blocks on the +camera. Every frame is center-cropped to 4:3 before anyone sees it, so the +live preview and the saved photos are guaranteed to show the same framing. +""" + +import glob +import os +import threading +import time + +import cv2 +import numpy as np + + +def resolve_device(setting, wait_seconds=15): + """Turn the config's camera device setting into something cv2 accepts. + + Accepts an integer index, an explicit "/dev/videoN" path, or + "auto-" which scans /dev/v4l/by-id/ for a device whose stable + name contains (case-insensitive), e.g. "auto-macally". + + A freshly plugged-in USB camera takes a few seconds to enumerate, so + the auto- form keeps rescanning for up to wait_seconds before failing. + """ + if isinstance(setting, int): + return setting + setting = str(setting) + if setting.startswith("/dev/"): + return setting + if setting.startswith("auto-"): + needle = setting[len("auto-"):].lower() + deadline = time.monotonic() + wait_seconds + waited = False + while True: + candidates = sorted(glob.glob("/dev/v4l/by-id/*-video-index0")) + for path in candidates: + if needle in os.path.basename(path).lower(): + return os.path.realpath(path) + if time.monotonic() > deadline: + names = [os.path.basename(p) for p in candidates] + raise RuntimeError( + f"No camera matching '{needle}' found. " + f"Available cameras: {names or 'none'}" + ) + if not waited: + waited = True + print(f"Waiting for '{needle}' camera to show up " + f"(just plugged in? give it a moment)...") + time.sleep(1) + return int(setting) + + +def crop_4x3(frame): + """Center-crop a BGR frame to 4:3.""" + h, w = frame.shape[:2] + if w * 3 > h * 4: # too wide: trim sides + new_w = h * 4 // 3 + x = (w - new_w) // 2 + return frame[:, x:x + new_w] + else: # too tall: trim top/bottom + new_h = w * 3 // 4 + y = (h - new_h) // 2 + return frame[y:y + new_h, :] + + +class Camera: + def __init__(self, device_setting, width, height): + device = resolve_device(device_setting) + self.cap = cv2.VideoCapture(device, cv2.CAP_V4L2) + if not self.cap.isOpened(): + raise RuntimeError(f"Could not open camera {device!r}") + # MJPG lets most USB cams deliver full frame rates at higher resolutions + self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + + self._lock = threading.Lock() + self._frame = None + self._brightness = 0.0 + self._running = True + self._thread = threading.Thread(target=self._reader, daemon=True) + self._thread.start() + + def _reader(self): + while self._running: + ok, frame = self.cap.read() + if not ok: + continue + frame = crop_4x3(frame) + with self._lock: + self._frame = frame + self._brightness = float(frame.mean()) + + def looks_black(self): + """True when the camera is delivering pure black frames. + + The Macally cam (Z-Star chip) sometimes wedges and streams all-zero + frames until it is physically unplugged and replugged; this lets the + UI warn the operator instead of silently taking black photos. + """ + with self._lock: + return self._frame is not None and self._brightness < 0.5 + + def get_frame(self): + """Latest 4:3 BGR frame, or None if the camera hasn't warmed up yet.""" + with self._lock: + return self._frame + + def save_photo(self, path): + """Write the latest frame as a JPEG. Returns True on success.""" + frame = self.get_frame() + if frame is None: + return False + return cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, 95]) + + def close(self): + self._running = False + self._thread.join(timeout=2) + self.cap.release() + + +def frame_to_surface(frame, size, mirror=False): + """Convert a BGR frame to a pygame surface scaled to `size` (w, h).""" + import pygame + + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + if mirror: + rgb = np.ascontiguousarray(rgb[:, ::-1]) + surf = pygame.image.frombuffer(rgb.tobytes(), (rgb.shape[1], rgb.shape[0]), "RGB") + return pygame.transform.smoothscale(surf, size) + + +if __name__ == "__main__": + # Quick standalone check: python3 camera.py [device] + # Grabs a frame from the configured camera and reports on it. + import sys + import time + import tomllib + from pathlib import Path + + if len(sys.argv) > 1: + device = sys.argv[1] + else: + with open(Path(__file__).parent / "config.toml", "rb") as f: + device = tomllib.load(f)["camera"]["device"] + + print(f"Opening camera {device!r}...") + c = Camera(device, 640, 480) + time.sleep(2) + frame = c.get_frame() + if frame is None: + print("PROBLEM: no frames arriving from the camera.") + elif c.looks_black(): + print("PROBLEM: camera is streaming black frames. " + "Unplug it, plug it back in, and try again.") + else: + h, w = frame.shape[:2] + out = "/tmp/camera_test.jpg" + c.save_photo(out) + print(f"OK: {w}x{h} frames, test photo saved to {out}") + c.close() diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0896ef8 --- /dev/null +++ b/config.toml @@ -0,0 +1,68 @@ +# Photobooth configuration + +[camera] +# "auto-macally" scans /dev/v4l/by-id/ for a camera whose name contains +# "macally". You can also use "auto-" for a different camera, +# an explicit device path like "/dev/video2", or a plain index like "0". +device = "auto-macally" +# Requested capture size; frames are center-cropped to 4:3 regardless. +# The Macally cam maxes out at 640x480 (it ignores higher requests) - +# raise this if you switch to a sharper camera. +capture_width = 640 +capture_height = 480 +# Mirror the live preview (like a mirror) - saved photos are NOT mirrored. +mirror_preview = true + +[display] +# Fullscreen mode, should be 4:3 to match the CRT. +width = 800 +height = 600 + +[text] +idle_title = "PHOTO BOOTH" +idle_subtitle = "Press the button to start!" +# {n} is replaced with the number of shoots remaining. +shoots_left_format = "{n} shoots until print!" +get_ready = "Get ready! Press button again to begin" + +[booth] +photos_per_shoot = 3 +shoots_per_print = 3 +first_countdown_seconds = 5 +next_countdown_seconds = 3 +flash_duration_ms = 180 + +[screensaver] +# Seconds without a button press before the fish tank starts. +timeout_seconds = 30 +# Fish on screen at once (images are picked randomly from the fish folder). +num_fish = 6 + +[contact_sheet] +# Page edge margin (in 300-DPI pixels). +margin_px = 0 +# Gap between photos within a strip, and between the last photo and its footer. +photo_gutter_px = 30 +# Gap between strips (columns). +strip_gutter_px = 100 +# Whether to auto-rotate the page to portrait/landscape based on how the +# strips best fit. Leave true unless you want to force portrait always. +auto_orient = true + +[footer] +# Footer drawn at the bottom of each 3-photo strip on the contact sheet. +line1 = "Ramble" +line2 = "at the Bindle" +# Colors as hex strings. +background_color = "#000000" +text_color = "#FFFFFF" + +[printing] +# Printer name as known to CUPS (see `lpstat -p`). Empty = system default. +printer = "HP-LaserJet-P2055d" +# Set to false while testing: contact sheets are still composed and saved, +# but nothing is sent to the printer (and photos are still cleaned up). +enabled = true +# true = delete each contact sheet immediately after it prints. +# false = keep them in contact_sheets/ in case something goes wrong. +delete_contact_sheets = false diff --git a/contact_sheet.py b/contact_sheet.py new file mode 100644 index 0000000..a646546 --- /dev/null +++ b/contact_sheet.py @@ -0,0 +1,248 @@ +"""Contact sheet composition and printing. + +Source photos are always 4:3 landscape (e.g. 800x600). A "strip" is one +shoot's `photos_per_shoot` photos arranged as a VERTICAL COLUMN with a +footer block beneath the column (footer is half a photo tall). The sheet +holds `shoots_per_print` strips side by side. + +`compute_layout` picks, per (photos_per_shoot, shoots_per_print), the +combination of *rotation mode*, *page orientation*, and *strip columns* +that maximizes each printed photo's area on a fixed 8.5x11 letter sheet +(2550x3300 at 300 DPI). The two rotation modes are: + + - ``portrait_ccw`` : each photo rotated 90 deg counter-clockwise so the + cell is portrait 3:4. Best when strips are tall + and narrow (e.g. 3 photos per shoot, 3 shoots). + - ``landscape_none``: photos kept landscape 4:3 (no rotation). Best when + a strip has few photos and a wide page fills better + (e.g. 1 photo per shoot, or one big photo). + +The date is printed only inside the footer. +""" + +import subprocess +from datetime import datetime + +from PIL import Image, ImageDraw, ImageFont + +# US letter at 300 DPI, both orientations. +PAGE_W_PORTRAIT, PAGE_H_PORTRAIT = 2550, 3300 +PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE = 3300, 2550 + +FOOTER_H_RATIO = 0.5 # footer height = half one photo's height + +# Rotation modes: (name, cell aspect W:H, PIL transpose used in build_sheet). +# ROTATE_90 = 90 deg CCW ; ROTATE_270 = 90 deg CW ; None = no rotation. +ROTATION_MODES = [ + ("portrait_ccw", 3, 4, Image.Transpose.ROTATE_90), + ("landscape_none", 4, 3, None), +] + + +def _divisors(n): + out = [] + for d in range(1, n + 1): + if n % d == 0: + out.append(d) + return out + + +def _hex_color(s, default=(0, 0, 0)): + s = (s or "").strip().lstrip("#") + if len(s) == 6: + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)) + except ValueError: + pass + return default + + +def _load_font(size): + try: + return ImageFont.truetype("DejaVuSans-Bold.ttf", size) + except OSError: + try: + return ImageFont.truetype("DejaVuSans.ttf", size) + except OSError: + return ImageFont.load_default(size) + + +def compute_layout(photos_per_shoot, shoots_per_print, margin, + photo_gutter, strip_gutter, auto_orient=True): + """Return the optimal sheet layout as a dict. + + A strip is always a vertical column of `photos_per_shoot` photos with + a footer beneath it. We try every rotation mode, every column count + that divides `shoots_per_print` (so the grid of strips has no empty + cells), and (when auto_orient) both page orientations, then choose the + layout with the largest printed photo cell area. + """ + pps = max(1, int(photos_per_shoot)) + spp = max(1, int(shoots_per_print)) + + orientations = [(PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, "portrait"), + (PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE, "landscape")] + if not auto_orient: + orientations = orientations[:1] + + best = None # (photo_area, layout_dict) + + for mode_name, aw, ah, transpose in ROTATION_MODES: + for cols in _divisors(spp): + rows = spp // cols + for page_w, page_h, orient_name in orientations: + avail_w = page_w - 2 * margin + avail_h = page_h - 2 * margin + if avail_w <= 0 or avail_h <= 0: + continue + + # Width budget: cols*cell_w + (cols-1)*strip_gutter <= avail_w + cell_w_from_w = (avail_w - (cols - 1) * strip_gutter) // cols + + # Height budget. One strip of `pps` stacked photos + a + # footer half a photo tall: + # strip_h = pps*cell_h + (pps-1)*photo_gutter + photo_gutter + footer_h + # = cell_h*(pps + FOOTER_H_RATIO) + pps*photo_gutter + # Grid height: rows*strip_h + (rows-1)*strip_gutter <= avail_h + # => cell_h <= (avail_h - (rows-1)*strip_gutter - pps*photo_gutter) + # / (rows*(pps + FOOTER_H_RATIO)) + strip_gap_total_h = (rows - 1) * strip_gutter + photo_gaps_total_h = pps * photo_gutter + avail_for_cells_h = avail_h - strip_gap_total_h - photo_gaps_total_h + denom_h = rows * (pps + FOOTER_H_RATIO) + cell_h_from_h = int(avail_for_cells_h / denom_h) if denom_h > 0 else 0 + + # Convert between cell_w and cell_h using this mode's aspect. + cell_w_from_h = cell_h_from_h * aw // ah + cell_h_from_w = cell_w_from_w * ah // aw + + cell_h = min(cell_h_from_h, cell_h_from_w) + if cell_h <= 0: + continue + cell_w = cell_h * aw // ah + if cell_w <= 0: + continue + cell_w = min(cell_w, cell_w_from_w) + cell_h = cell_w * ah // aw # keep aspect exact after clamp + if cell_h <= 0: + continue + + footer_h = int(cell_h * FOOTER_H_RATIO) + strip_h = (pps * cell_h + (pps - 1) * photo_gutter + + photo_gutter + footer_h) + grid_w = cols * cell_w + (cols - 1) * strip_gutter + grid_h = rows * strip_h + (rows - 1) * strip_gutter + if grid_w > avail_w or grid_h > avail_h: + continue + + x0 = margin + (avail_w - grid_w) // 2 + y0 = margin + (avail_h - grid_h) // 2 + + photo_rects = [] + footer_rects = [] + for strip_i in range(spp): + scol = strip_i // rows + srow = strip_i % rows + sx = x0 + scol * (cell_w + strip_gutter) + sy = y0 + srow * (strip_h + strip_gutter) + for pi in range(pps): + py = sy + pi * (cell_h + photo_gutter) + photo_rects.append((sx, py, cell_w, cell_h)) + fy = sy + pps * cell_h + (pps - 1) * photo_gutter + photo_gutter + footer_rects.append((sx, fy, cell_w, footer_h)) + + photo_area = cell_w * cell_h + cand = { + "page_w": page_w, "page_h": page_h, + "orientation": orient_name, + "mode": mode_name, + "transpose": transpose, + "cols": cols, "rows": rows, + "cell_w": cell_w, "cell_h": cell_h, + "footer_h": footer_h, + "strip_h": strip_h, "grid_w": grid_w, "grid_h": grid_h, + "x0": x0, "y0": y0, + "photo_rects": photo_rects, + "footer_rects": footer_rects, + "photo_area": photo_area, + } + if best is None or photo_area > best[0]: + best = (photo_area, cand) + + if best is None: + raise ValueError( + f"No contact sheet layout fits photos_per_shoot={pps}, " + f"shoots_per_print={spp} on a letter page.") + return best[1] + + +def _render_footer(width, height, footer_cfg): + """Build the footer image: bg fill, line1, line2, then date at bottom.""" + line1 = (footer_cfg or {}).get("line1", "") + line2 = (footer_cfg or {}).get("line2", "") + bg = _hex_color((footer_cfg or {}).get("background_color"), (0, 0, 0)) + fg = _hex_color((footer_cfg or {}).get("text_color"), (255, 255, 255)) + + img = Image.new("RGB", (width, height), bg) + draw = ImageDraw.Draw(img) + pad = max(6, height // 12) + title_font = _load_font(max(24, height // 5)) + date_font = _load_font(max(18, height // 7)) + + y = pad + for line in (line1, line2): + if not line: + continue + tw = draw.textlength(line, font=title_font) + draw.text(((width - tw) // 2, y), line, fill=fg, font=title_font) + y += title_font.getbbox(line)[3] + pad // 2 + + stamp = datetime.now().strftime("%B %d, %Y") + dw = draw.textlength(stamp, font=date_font) + db = date_font.getbbox(stamp) + draw.text(((width - dw) // 2, height - db[3] - pad // 2), + stamp, fill=fg, font=date_font) + return img + + +def build_sheet(photo_paths, out_path, footer_cfg=None, + photos_per_shoot=3, shoots_per_print=3, + margin=120, photo_gutter=30, strip_gutter=30, + auto_orient=True): + """Compose photos onto a letter page and save it to out_path.""" + layout = compute_layout(photos_per_shoot, shoots_per_print, + margin, photo_gutter, strip_gutter, auto_orient) + sheet = Image.new("RGB", (layout["page_w"], layout["page_h"]), "white") + + photos = list(photo_paths)[:len(layout["photo_rects"])] + footer_img = _render_footer(layout["cell_w"], layout["footer_h"], footer_cfg) + + transpose = layout["transpose"] + for i, path in enumerate(photos): + x, y, w, h = layout["photo_rects"][i] + photo = Image.open(path) + if transpose is not None: + photo = photo.transpose(transpose) + photo = photo.resize((w, h), Image.Resampling.LANCZOS) + sheet.paste(photo, (x, y)) + + for (x, y, w, h) in layout["footer_rects"]: + sheet.paste(footer_img, (x, y)) + + sheet.save(out_path, quality=95) + return out_path + + +def print_sheet(path, printer=""): + """Send the sheet to the printer via lp. Returns (ok, message).""" + cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"] + if printer: + cmd += ["-d", printer] + cmd.append(str(path)) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except (OSError, subprocess.TimeoutExpired) as e: + return False, f"lp failed to run: {e}" + if result.returncode != 0: + return False, result.stderr.strip() or "lp returned an error" + return True, result.stdout.strip() \ No newline at end of file diff --git a/falling_photos.py b/falling_photos.py new file mode 100644 index 0000000..3c18522 --- /dev/null +++ b/falling_photos.py @@ -0,0 +1,112 @@ +"""Falling photos backdrop for the idle/title screen. + +Saved photos from the photos/ folder drift down from the top of the +screen with a small white border, looping forever. They are drawn behind +the title text. Thumbnails are loaded once and cached by file path; the +cache is pruned when photos are deleted (after a print cycle). +""" + +import math +import random + +import pygame + +THUMB_H = 140 +BORDER = 6 +NUM_PHOTOS = 10 +MIN_SPEED, MAX_SPEED = 30, 90 # px/sec downward +DRIFT_AMP = 40 +ROT_MIN, ROT_MAX = -12.5, 12.5 # degrees (halved) +SPIN_MIN, SPIN_MAX = -10, 10 # deg/sec (halved) + + +def _color_with_border(src): + """Scale src to THUMB_H tall, add a white BORDER, return (surf, w, h).""" + w0, h0 = src.get_size() + new_w = max(1, int(w0 * THUMB_H / h0)) + scaled = pygame.transform.smoothscale(src, (new_w, THUMB_H)) + bw, bh = new_w + 2 * BORDER, THUMB_H + 2 * BORDER + surf = pygame.Surface((bw, bh), pygame.SRCALPHA) + surf.fill((255, 255, 255, 255)) + surf.blit(scaled, (BORDER, BORDER)) + return surf, bw, bh + + +class _Sprite: + def __init__(self, screen_size, picker): + self.screen_w, self.screen_h = screen_size + self.picker = picker + self._respawn(top=True) + + def _respawn(self, top=False): + self.image, self.w, self.h = self.picker() + self.angle = random.uniform(ROT_MIN, ROT_MAX) + self.spin = random.uniform(SPIN_MIN, SPIN_MAX) + self.speed = random.uniform(MIN_SPEED, MAX_SPEED) + self.drift_amp = random.uniform(0, DRIFT_AMP) + self.drift_phase = random.uniform(0, math.tau) + self.drift_speed = random.uniform(0.4, 1.2) + if top: + self.x = random.uniform(-self.w, self.screen_w) + self.y = random.uniform(-self.h * 3, -self.h) + else: + self.x = random.uniform(0, self.screen_w - self.w) + self.y = -self.h + + def update(self, dt): + self.y += self.speed * dt + self.drift_phase += self.drift_speed * dt + self.angle += self.spin * dt + if self.y > self.screen_h + self.h: + self._respawn() + + def draw(self, screen): + ox = math.sin(self.drift_phase) * self.drift_amp + rotated = pygame.transform.rotate(self.image, self.angle) + rect = rotated.get_rect( + center=(self.x + self.w / 2 + ox, self.y + self.h / 2)) + screen.blit(rotated, rect) + + +class FallingPhotos: + def __init__(self, screen_size, photos_dir): + self.screen_size = screen_size + self.photos_dir = photos_dir + self._cache = {} # path -> (bordered_surf, bw, bh) + self._paths = [] + self._scan() + self.sprites = [_Sprite(screen_size, self._pick) for _ in range(NUM_PHOTOS)] + + def _scan(self): + self._paths = (sorted(self.photos_dir.glob("*.jpg")) + if self.photos_dir.exists() else []) + self._cache = {p: v for p, v in self._cache.items() if p in self._paths} + + def reload(self): + """Re-scan the photos folder (call after photos are deleted).""" + self._scan() + + def _pick(self): + if not self._paths: + surf = pygame.Surface((THUMB_H, THUMB_H), pygame.SRCALPHA) + surf.fill((0, 0, 0, 0)) + return surf, THUMB_H, THUMB_H + path = random.choice(self._paths) + if path not in self._cache: + try: + src = pygame.image.load(str(path)).convert_alpha() + except (pygame.error, OSError): + self._paths = [p for p in self._paths if p != path] + return self._pick() + self._cache[path] = _color_with_border(src) + return self._cache[path] + + def update(self, dt): + if not self._paths: + self._scan() + for s in self.sprites: + s.update(dt) + + def draw(self, screen): + for s in self.sprites: + s.draw(screen) \ No newline at end of file diff --git a/fish/placeholder_fish.png b/fish/placeholder_fish.png new file mode 100644 index 0000000..2bfaef4 Binary files /dev/null and b/fish/placeholder_fish.png differ diff --git a/photobooth.py b/photobooth.py new file mode 100644 index 0000000..86c5d68 --- /dev/null +++ b/photobooth.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""CRT photobooth. + +States: IDLE (background + text) -> PREVIEW (live feed, "get ready") -> +CAPTURING (countdowns, flashes, 3 photos per shoot). Every 4th shoot the +12 photos are composed onto a contact sheet and printed. Five minutes of +inactivity brings up the fish tank screensaver. + +Run with --windowed for testing without taking over the display. +Esc or Q quits. +""" + +import sys +import time +import tomllib +from pathlib import Path + +import pygame + +import camera as cam +import contact_sheet +import falling_photos +import screensaver + +ROOT = Path(__file__).resolve().parent + +IDLE, PREVIEW, COUNTDOWN, FLASH, SCREENSAVER = range(5) + + +def load_config(): + with open(ROOT / "config.toml", "rb") as f: + return tomllib.load(f) + + +def load_fish_images(fish_dir): + images = [] + for path in sorted(fish_dir.iterdir()): + if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".gif", ".bmp"): + images.append(pygame.image.load(path).convert_alpha()) + return images + + +class Booth: + def __init__(self, windowed=False): + self.cfg = load_config() + disp = self.cfg["display"] + self.size = (disp["width"], disp["height"]) + + pygame.init() + flags = 0 if windowed else pygame.FULLSCREEN + self.screen = pygame.display.set_mode(self.size, flags) + pygame.display.set_caption("Photobooth") + pygame.mouse.set_visible(False) + self.clock = pygame.time.Clock() + + h = self.size[1] + self.font_big = pygame.font.Font(None, h // 3) # countdown digits + self.font_title = pygame.font.Font(None, h // 6) + self.font_med = pygame.font.Font(None, h // 12) + self.font_small = pygame.font.Font(None, h // 18) + + bg_path = ROOT / "assets" / "background.png" + self.background = pygame.transform.smoothscale( + pygame.image.load(bg_path).convert(), self.size) + + self.photos_dir = ROOT / "photos" + self.sheets_dir = ROOT / "contact_sheets" + self.photos_dir.mkdir(exist_ok=True) + self.sheets_dir.mkdir(exist_ok=True) + + c = self.cfg["camera"] + self.camera = cam.Camera(c["device"], c["capture_width"], c["capture_height"]) + self.mirror = c.get("mirror_preview", True) + + self.fish_images = load_fish_images(ROOT / "fish") + + self.falling = falling_photos.FallingPhotos(self.size, self.photos_dir) + + booth = self.cfg["booth"] + self.photos_per_shoot = booth["photos_per_shoot"] + self.shoots_per_print = booth["shoots_per_print"] + + self.state = IDLE + self.last_activity = time.monotonic() + self.tank = None + self.notice = None # (message, expiry_time) + self.photos_in_shoot = 0 + self.countdown_end = 0.0 + self.flash_end = 0.0 + + # Resume mid-cycle after a crash: existing photos count toward the + # current print cycle. + self.shoots_done = len(self.saved_photos()) // self.photos_per_shoot + + # ---------- helpers ---------- + + def saved_photos(self): + return sorted(self.photos_dir.glob("*.jpg")) + + def set_notice(self, message, seconds=6): + self.notice = (message, time.monotonic() + seconds) + + def draw_text(self, text, font, center, color=(255, 255, 255)): + shadow = font.render(text, True, (0, 0, 0)) + label = font.render(text, True, color) + rect = label.get_rect(center=center) + offset = max(2, font.get_height() // 20) + self.screen.blit(shadow, rect.move(offset, offset)) + self.screen.blit(label, rect) + + def draw_camera_feed(self): + frame = self.camera.get_frame() + if frame is None: + self.screen.fill((20, 20, 20)) + self.draw_text("Warming up camera...", self.font_med, + (self.size[0] // 2, self.size[1] // 2)) + else: + self.screen.blit(cam.frame_to_surface(frame, self.size, self.mirror), (0, 0)) + if self.camera.looks_black(): + self.draw_text("Camera problem: unplug it and plug it back in!", + self.font_small, + (self.size[0] // 2, self.size[1] // 8), + (255, 120, 120)) + + # ---------- states ---------- + + def press(self): + """Handle a button (mouse) press in the current state.""" + self.last_activity = time.monotonic() + if self.state == SCREENSAVER: + self.state = IDLE + self.tank = None + elif self.state == IDLE: + self.state = PREVIEW + elif self.state == PREVIEW: + self.photos_in_shoot = 0 + self.start_countdown(self.cfg["booth"]["first_countdown_seconds"]) + # COUNTDOWN / FLASH ignore presses + + def start_countdown(self, seconds): + self.state = COUNTDOWN + self.countdown_end = time.monotonic() + seconds + + def take_photo(self): + stamp = time.strftime("%Y%m%d_%H%M%S") + path = self.photos_dir / ( + f"shoot{self.shoots_done + 1}_photo{self.photos_in_shoot + 1}_{stamp}.jpg") + if self.camera.save_photo(str(path)): + self.photos_in_shoot += 1 + else: + self.set_notice("Camera error - photo not saved!") + self.state = FLASH + self.flash_end = time.monotonic() + self.cfg["booth"]["flash_duration_ms"] / 1000 + + def finish_shoot(self): + self.shoots_done += 1 + if self.shoots_done >= self.shoots_per_print: + self.print_cycle() + self.state = IDLE + self.last_activity = time.monotonic() + + def print_cycle(self): + # Blocking is fine here: composing + queueing takes a second or two, + # and the "Printing..." screen explains the pause. + self.screen.fill((0, 0, 40)) + self.draw_text("Printing...", self.font_title, + (self.size[0] // 2, self.size[1] // 2)) + pygame.display.flip() + + photos = self.saved_photos() + stamp = time.strftime("%Y%m%d_%H%M%S") + sheet_path = self.sheets_dir / f"sheet_{stamp}.jpg" + cs = self.cfg.get("contact_sheet", {}) + contact_sheet.build_sheet( + photos, sheet_path, + footer_cfg=self.cfg.get("footer"), + photos_per_shoot=self.photos_per_shoot, + shoots_per_print=self.shoots_per_print, + margin=cs.get("margin_px", 120), + photo_gutter=cs.get("photo_gutter_px", 30), + strip_gutter=cs.get("strip_gutter_px", 30), + auto_orient=cs.get("auto_orient", True), + ) + + printing = self.cfg["printing"] + if printing["enabled"]: + ok, message = contact_sheet.print_sheet(sheet_path, printing["printer"]) + else: + ok, message = True, "printing disabled in config" + + if ok: + for photo in photos: + photo.unlink() + self.falling.reload() + if printing["delete_contact_sheets"]: + sheet_path.unlink() + self.shoots_done = 0 + self.set_notice("Sent to printer!") + else: + # Keep everything so nothing is lost; the sheet can be printed + # by hand and the counter stays full until it succeeds. + self.shoots_done = 0 + self.set_notice(f"Print failed: {message}", seconds=15) + print(f"PRINT FAILED ({message}). Sheet saved at {sheet_path}", + file=sys.stderr) + + # ---------- drawing ---------- + + def draw_idle(self): + self.screen.blit(self.background, (0, 0)) + self.falling.draw(self.screen) + text = self.cfg["text"] + w, h = self.size + self.draw_text(text["idle_title"], self.font_title, (w // 2, h // 4)) + self.draw_text(text["idle_subtitle"], self.font_med, (w // 2, h // 2)) + left = self.shoots_per_print - self.shoots_done + self.draw_text(text["shoots_left_format"].format(n=left), + self.font_med, (w // 2, h * 3 // 4), (255, 230, 120)) + if self.notice: + message, expiry = self.notice + if time.monotonic() > expiry: + self.notice = None + else: + self.draw_text(message, self.font_small, (w // 2, h * 7 // 8), + (255, 150, 150)) + + def draw_preview(self): + self.draw_camera_feed() + self.draw_text(self.cfg["text"]["get_ready"], self.font_med, + (self.size[0] // 2, self.size[1] * 7 // 8)) + + def draw_countdown(self): + self.draw_camera_feed() + remaining = self.countdown_end - time.monotonic() + if remaining <= 0: + self.take_photo() + return + self.draw_text(str(int(remaining) + 1), self.font_big, + (self.size[0] // 2, self.size[1] // 2)) + + def draw_flash(self): + self.screen.fill((255, 255, 255)) + if time.monotonic() >= self.flash_end: + if self.photos_in_shoot >= self.photos_per_shoot: + self.finish_shoot() + else: + self.start_countdown(self.cfg["booth"]["next_countdown_seconds"]) + + # ---------- main loop ---------- + + def run(self): + timeout = self.cfg["screensaver"]["timeout_seconds"] + while True: + dt = self.clock.tick(30) / 1000 + for event in pygame.event.get(): + if event.type == pygame.QUIT: + return + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_q): + return + self.last_activity = time.monotonic() + if event.type == pygame.MOUSEBUTTONDOWN: + self.press() + + if (self.state in (IDLE, PREVIEW) + and time.monotonic() - self.last_activity > timeout): + self.state = SCREENSAVER + self.tank = screensaver.FishTank( + self.size, self.fish_images, + self.cfg["screensaver"]["num_fish"]) + + if self.state == IDLE: + self.falling.update(dt) + self.draw_idle() + elif self.state == PREVIEW: + self.draw_preview() + elif self.state == COUNTDOWN: + self.draw_countdown() + elif self.state == FLASH: + self.draw_flash() + elif self.state == SCREENSAVER: + self.tank.update(dt) + self.tank.draw(self.screen) + + pygame.display.flip() + + def close(self): + self.camera.close() + pygame.quit() + + +def main(): + windowed = "--windowed" in sys.argv + booth = Booth(windowed=windowed) + try: + booth.run() + finally: + booth.close() + + +if __name__ == "__main__": + main() diff --git a/screensaver.py b/screensaver.py new file mode 100644 index 0000000..d480409 --- /dev/null +++ b/screensaver.py @@ -0,0 +1,106 @@ +"""Fish tank screensaver. + +Burn-in safe for the CRT: the background is pure black and every drawn +element is always in motion. Fish images are loaded from the fish folder +and are assumed to face right; a fish swimming left gets flipped +horizontally. Bubbles rise from each fish's mouth. +""" + +import math +import random + +import pygame + +FISH_MIN_H, FISH_MAX_H = 60, 140 +FISH_MIN_SPEED, FISH_MAX_SPEED = 40, 130 # px/sec +BUBBLE_COLOR = (150, 200, 255) + + +class Bubble: + def __init__(self, x, y): + self.x = x + self.y = y + self.radius = random.uniform(2, 6) + self.speed = random.uniform(30, 70) + self.wobble_phase = random.uniform(0, math.tau) + self.wobble_amp = random.uniform(2, 8) + + def update(self, dt): + self.y -= self.speed * dt + self.wobble_phase += dt * 3 + + def draw(self, screen): + x = self.x + math.sin(self.wobble_phase) * self.wobble_amp + pygame.draw.circle(screen, BUBBLE_COLOR, (int(x), int(self.y)), + int(self.radius), width=1) + + +class Fish: + def __init__(self, image, screen_size): + self.screen_w, self.screen_h = screen_size + height = random.randint(FISH_MIN_H, FISH_MAX_H) + width = int(image.get_width() * height / image.get_height()) + self.image_right = pygame.transform.smoothscale(image, (width, height)) + self.image_left = pygame.transform.flip(self.image_right, True, False) + self.w, self.h = width, height + self.speed = random.uniform(FISH_MIN_SPEED, FISH_MAX_SPEED) + self.direction = random.choice([-1, 1]) + self.x = random.uniform(0, self.screen_w - width) + self.base_y = random.uniform(0, self.screen_h - height) + self.bob_phase = random.uniform(0, math.tau) + self.bob_amp = random.uniform(5, 20) + self.bubble_timer = random.uniform(1, 4) + + @property + def y(self): + return self.base_y + math.sin(self.bob_phase) * self.bob_amp + + def mouth_pos(self): + mouth_x = self.x + self.w if self.direction > 0 else self.x + return mouth_x, self.y + self.h * 0.45 + + def update(self, dt, bubbles): + self.x += self.speed * self.direction * dt + self.bob_phase += dt * random.uniform(0.8, 1.2) + + # Wrap around: swim fully off one edge, re-enter from the other + # at a fresh depth so fish never sit still or trace fixed lines. + if self.direction > 0 and self.x > self.screen_w: + self.x = -self.w + self.base_y = random.uniform(0, self.screen_h - self.h) + elif self.direction < 0 and self.x < -self.w: + self.x = self.screen_w + self.base_y = random.uniform(0, self.screen_h - self.h) + + self.bubble_timer -= dt + if self.bubble_timer <= 0: + self.bubble_timer = random.uniform(1.5, 5) + mx, my = self.mouth_pos() + for _ in range(random.randint(1, 3)): + bubbles.append(Bubble(mx + random.uniform(-4, 4), + my + random.uniform(-4, 4))) + + def draw(self, screen): + image = self.image_right if self.direction > 0 else self.image_left + screen.blit(image, (int(self.x), int(self.y))) + + +class FishTank: + def __init__(self, screen_size, fish_images, num_fish): + self.fish = [Fish(random.choice(fish_images), screen_size) + for _ in range(num_fish)] + self.bubbles = [] + + def update(self, dt): + for fish in self.fish: + fish.update(dt, self.bubbles) + for bubble in self.bubbles: + bubble.update(dt) + self.bubbles = [b for b in self.bubbles if b.y > -10] + + def draw(self, screen): + screen.fill((0, 0, 0)) + for bubble in self.bubbles: + bubble.draw(screen) + for fish in self.fish: + fish.draw(screen) diff --git a/test_contact_sheet.py b/test_contact_sheet.py new file mode 100644 index 0000000..ce6c52d --- /dev/null +++ b/test_contact_sheet.py @@ -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() \ No newline at end of file