Initial commit: CRT photobooth
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user