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

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