Initial commit: CRT photobooth
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
124
README.md
Normal 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
BIN
assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
163
camera.py
Normal file
163
camera.py
Normal 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
68
config.toml
Normal 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
248
contact_sheet.py
Normal 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
112
falling_photos.py
Normal 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
BIN
fish/placeholder_fish.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
302
photobooth.py
Normal file
302
photobooth.py
Normal 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
106
screensaver.py
Normal 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
239
test_contact_sheet.py
Normal 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()
|
||||
Reference in New Issue
Block a user