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

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Python
__pycache__/
*.pyc
# Runtime output (recreated by the program as needed)
photos/
contact_sheets/
oldphoto/
# Editor backups
*~

124
README.md Normal file
View File

@ -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.

BIN
assets/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

163
camera.py Normal file
View File

@ -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-<name>" which scans /dev/v4l/by-id/ for a device whose stable
name contains <name> (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()

68
config.toml Normal file
View File

@ -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-<anything>" 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

248
contact_sheet.py Normal file
View File

@ -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()

112
falling_photos.py Normal file
View File

@ -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)

BIN
fish/placeholder_fish.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

302
photobooth.py Normal file
View File

@ -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()

106
screensaver.py Normal file
View File

@ -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)

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()