Wir wollen heiraten. Im kleinen Kreis - und möglichst ohne allzu viel Tam-Tam. Aber eins, fanden wir, darf auf keinen Fall fehlen:
Eine Fotobox.
Und wenn du die horrenden Preise auf diversen Onlineportalen ebenso schmerzhaft findest wie wir, dann darfst du dich gern ein bisschen umsehen. Vielleicht findest du sogar wie ich den Mut, dir so ein Ding einfach selbst zu bauen.
Ganz ehrlich: Das Teil ist so individuell gewachsen, dass ich beim Schreiben dieses Blogs selbst mehrfach nachschauen musste, was ich da eigentlich zusammengedönert habe - und warum es überhaupt funktioniert.
Daher ein kleiner Disclaimer vorweg: Manche der hier verwendeten Skripte bieten beste Voraussetzungen, um sich großkalibrig in den Fuß zu schießen, besonders, wenn das Teil nicht autark läuft.
Deshalb mein Rat: Auch wenn ich viele Vorlagen direkt auf dem Silbertablett serviere, kopiere bitte nicht alles stumpf 1:1 und erwarte sofortige Funktionalität.
Weiterhin verzichte ich in diesem Blog auf Erklärungen von ein paar Grundbefehlen, da diese eigentlich zu den Voraussetzungen gehören sollten, damit man sich sowas auch bauen kann.
So, los geht's.
Das Konzept lässt sich in wenigen Worten beschreiben: Die Fotobox soll tragbar sein und über ein kompaktes Design verfügen. Ein Touchdisplay sorgt für eine einfache Bedienung. Es gibt ein Vorschaubild vor der Aufnahme sowie die Möglichkeit, zwischen Einzelfotos und Collagen zu wählen. Bereits getätigte Aufnahmen können in einer Galerie angesehen werden. Außerdem lässt sich jedes Foto entweder direkt drucken oder per QR-Code bequem an ein Smartphone übertragen.
Als Gehäuse dient ein ausrangierter Macintosh Classic - nicht nur, weil er hübsch ist, sondern weil ich zufällig günstig einen fand. Gerade groß genug, dass ein 8 Zoll Touchdisplay von Waveshare hineinpasst.
Das Display bietet eine praktische Halterung, an der ich einen Raspberry Pi 5 befestigt habe. Die Grundinstallation hierfür ist in diesem Blog nicht beschrieben, sollte aber in jedem Fall mit aktiviertem SSH-Zugriff erfolgen.
Für die Druckfunktion kommt ein Canon SELPHY CP1500 zum Einsatz - handlich, zuverlässig und per USB ansprechbar. Schön wäre es natürlich gewesen, hätte der auch im Gehäuse des
Macintosh Platz gefunden. Hätte die Sache aber wahrscheinlich unnötig kompliziert gemacht, vor allem beim Wechseln des Farbbandes oder des Fotopapiers.
Damit der großzügige Innenraum des Gehäuses nicht ungenutzt bleibt (und die originalen Öffnungen für serielle Ports nicht traurig leer bleiben), habe ich ihn zusätzlich mit einer USB-Verlängerung samt passender Buchse sowie einem RJ45-Kabel mit Einbaubuchse ausgestattet.
Der Einbau des Displays ist eigentlich nahezu selbsterklärend, die Kabel sind alle mitgeliefert und können direkt am Pi angeschlossen werden. Eine Schwierigkeit war allerdings, dass das Bild hochkant, also im Portraitmodus dargestellt wurde. Ich brauchte es aber
im Landscape-Modus, also um 90 Grad gedreht. Und das möglichst bei jedem Boot ganz automatisch. Der Hersteller kam mit folgendem Code, der in der /boot/firmware/cmdline.txt ergänzt werden musste:
video=HDMI-A-1:768x1024@60,rotate=90 console=serial0,115200
console=tty1 root=PARTUUID=34834c29-02
rootfstype=ext4 fsck.repair=yes rootwait
Problem dabei ist, dass der Screen dabei beim Startup gedreht ist, aber sobald der User sich am System einloggt, der Desktop wieder im Portraitmodus, also wieder „hochkant“ dargestellt wird.
Das liegt daran, dass die Rotationseinstellungen in der /boot/firmware/cmdline.txt zwar den Kernel-Framebuffer (KMS) betreffen,
diese jedoch nicht automatisch auf die Desktop-Umgebung (X11 oder Wayland) angewendet werden. GDM3 (GNOME Display Manager) und die Desktop-Umgebung haben separate Konfigurationsmechanismen für die Anzeige.
Um das Problem zu lösen, musste ich zunächst sicherstellen, dass GDM3 nicht Wayland verwendet, sondern X11 (wird auch später nochmal wichtig). Dazu wird in der Datei /etc/gdm3/daemon.conf der Eintrag WaylandEnable=false aktiviert:
[daemon]
WaylandEnable=false
Danach war ein Neustart nötig, damit diese Änderung greift. Allerdings reicht das allein nicht aus - man muss zusätzlich die Drehung für die Desktop-Session konfigurieren.
Das geht am einfachsten mit xrandr, einem Werkzeug zum Einstellen von Bildschirmparametern unter X11.
Per SSH kann man dann testen, ob die Rotation wie gewünscht funktioniert. Dazu muss DISPLAY=:0 exportiert und anschließend xrandr aufgerufen werden:
export DISPLAY=:0
xrandr --output HDMI-1 --rotate right
Wenn das klappt, kann man die Rotation dauerhaft einrichten. Dazu habe ich ein kleines Skript erstellt:
#!/bin/bash
xrandr --output HDMI-1 --rotate right
Das Skript wird am besten als /usr/local/bin/rotate_display.sh abgelegt und ausführbar gemacht:
sudo chmod +x /usr/local/bin/rotate_display.sh
Anschließend richtet man einen Autostart-Eintrag ein, damit das Skript bei jedem Login automatisch ausgeführt wird. Dazu wird eine neue Datei unter
/etc/xdg/autostart/rotate_display.desktop erstellt:
[Desktop Entry]
Type=Application
Exec=/usr/local/bin/rotate_display.sh
Hidden=false
X-GNOME-Autostart-enabled=true
Name=Rotate Display
Comment=Rotate the HDMI Display
Mit dieser Lösung dreht sich das Display nun zuverlässig bei jedem Login automatisch ins Querformat und die Darstellung passt endlich so, wie ich sie brauche.
Bitte fragt nicht, wie viele Stunden meines Lebens mich die Recherche und Konfiguration dafür gekostet hat. Völlig geisteskrank.
Bei der Kamera bin ich am Ende bei einer Logitech C920 gelandet. Das ist keine exotische Spezialkamera, sondern einfach eine solide USB-Webcam, die unter Linux als UVC-Gerät auftaucht und sich mit OpenCV ansprechen lässt. Genau das war für dieses Projekt wichtig: Es soll nach dem Einschalten funktionieren, ohne dass ich erst noch an einer Treiberhölle herumopern muss.
Der erste naive Ansatz war: Kamera öffnen, Frame lesen, JPEG daraus machen und an den Browser streamen. Funktioniert. Also theoretisch. Praktisch sah die Live-Vorschau damit aber stellenweise aus, als würde der Raspberry Pi über jeden einzelnen Frame erst einmal in Ruhe nachdenken. Das Problem war dabei nicht in erster Linie zu wenig RAM oder eine zu schwache Grafikkarte, sondern die Art, wie der Stream gebaut war.
Wenn bei jedem Aufruf von /video_feed direkt aus der Kamera gelesen und für jeden Client neu encodiert wird,
entsteht unnötige Last. Besser funktioniert bei mir der Ansatz, die Kamera einmal zentral in einem eigenen Thread laufen
zu lassen. Dieser Thread holt kontinuierlich Frames, speichert das letzte Rohbild und zusätzlich ein bereits encodiertes
JPEG. Der Browser bekommt dann immer nur das zuletzt fertige JPEG. Das klingt unspektakulär, macht die Vorschau aber
deutlich angenehmer.
Wichtig war außerdem, die Kamera auf MJPEG zu setzen. Die C920 kann das, und damit muss der Pi nicht jeden Frame erst mühsam aus einem unkomprimierten Format zusammenkauen.
CAM_INDEX = 0
CAM_WIDTH = 1280
CAM_HEIGHT = 720
CAM_FPS = 30
camera = None
last_frame = None
last_jpeg = None
frame_lock = threading.Lock()
stream_fps = 15
jpeg_quality = 80
def open_camera_once(index=CAM_INDEX, width=CAM_WIDTH, height=CAM_HEIGHT, fps=CAM_FPS):
cam = cv2.VideoCapture(index, cv2.CAP_V4L2)
if not cam or not cam.isOpened():
return None
cam.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
cam.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cam.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
cam.set(cv2.CAP_PROP_FPS, fps)
return cam
Das eigentliche Lesen der Kamera passiert dann nicht mehr direkt in der Route, sondern dauerhaft im Hintergrund. Für die Vorschau wird das letzte fertige JPEG ausgeliefert. Für ein echtes Foto wird dagegen das letzte Rohbild genommen, auf die gewünschte Größe gebracht und temporär gespeichert.
def capture_loop():
global last_frame, last_jpeg, camera
target_period = 1.0 / max(1, stream_fps)
while True:
started = time.time()
if camera is None:
if not try_open_camera():
time.sleep(1.0)
else:
ok, frame = False, None
try:
ok, frame = camera.read()
except Exception:
ok = False
if ok and frame is not None:
ok2, buf = cv2.imencode(
".jpg",
frame,
[int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality]
)
if ok2:
with frame_lock:
last_frame = frame
last_jpeg = buf.tobytes()
else:
try:
camera.release()
except Exception:
pass
camera = None
time.sleep(0.5)
elapsed = time.time() - started
if elapsed < target_period:
time.sleep(target_period - elapsed)
def mjpeg_generator():
boundary = b"--frame
Content-Type: image/jpeg
"
interval = 1.0 / max(1, stream_fps)
while True:
started = time.time()
with frame_lock:
payload = last_jpeg
if payload is not None:
yield boundary + payload + b"
"
else:
time.sleep(0.2)
elapsed = time.time() - started
if elapsed < interval:
time.sleep(interval - elapsed)
Damit wird die Vorschau nicht plötzlich perfekt wie eine native Kamera-App, aber sie ist für eine Fotobox absolut brauchbar. Und vor allem: Sie ist stabil genug, dass man davorsteht, sieht was passiert und nicht das Gefühl hat, die Kamera hängt drei Sekunden hinter der Realität her.
Jetzt kommt der Punkt, an dem es auch eigentlich richtig spannend wird. Also mehr oder weniger.
Ich bin ehrlich: Die hier dargestellten Programmierungskünste (Python, HTML und JavaScript) übersteigen meine Qualifikationen als Systemintegrator. Die künstliche Intelligenz meines Vertrauens hat mich hierbei sehr unterstützt.
Heißt natürlich nicht, dass immer alles auf Anhieb funktioniert hat und meine eigenen Gehirnzellen nicht auch strapaziert worden sind.
Zunächst einmal brauchen wir neben dem bereits installierten Raspberry Pi OS noch ein paar Werkzeuge: Apache als Webserver, Python 3 mit pip sowie ein paar wichtige Bibliotheken wie Flask, OpenCV und Pillow. Und jetzt kommt der Teil, bei dem PEP 668 ins Spiel kommt:
PEP 668 sorgt in neueren Python-Installationen dafür, dass pip keine Systempakete mehr direkt verändern darf.
Das ist gut gemeint (Schutz vor kaputten Systembibliotheken), sorgt aber dafür, dass der klassische pip install …-Ansatz direkt ins Leere läuft.
Die Lösung: Wir arbeiten in einer virtuellen Umgebung (venv), die komplett isoliert ist.
Das schützt das System - und macht das ganze Setup sauberer.
Und all das machen wir in einem eigenen Ordner im Home-Verzeichnis, damit wir es schnell wiederfinden.
sudo apt update
cd ~
mkdir fotobox
cd fotobox
sudo apt install python3 python3-pip python3-venv apache2
python3 -m venv .venv
source .venv/bin/activate
pip install flask opencv-python-headless pillow qrcode[pil] gunicorn
Jetzt können wir fleißig drauf los programmieren und in unserem fotobox-Ordner die eigentliche Webanwendung app.py schreiben, das HTML-Frontend unter templates/index.html,
sowie das passende Stylesheet unter static/styles.css.
Als Zusammenfassung: Damit Flask auf alle Dateien zugreifen kann, sollte die Verzeichnisstruktur wie folgt aussehen:
fotobox/
├── app.py
├── static/
│ └── styles.css
└── templates/
└── index.html
Und damit wäre der erste Schritt auch schon getan und wir können entsprechende Rechte setzen, damit unsere Fotos auch gespeichert werden können - ohne „777“:
sudo mkdir -p /var/www/html/fotobox/fotos
sudo mkdir -p /var/www/html/fotobox/tmp
sudo chown -R fb:www-data /var/www/html/fotobox
sudo chmod -R 750 /var/www/html/fotobox
sudo chmod 770 /var/www/html/fotobox/fotos
sudo chmod 770 /var/www/html/fotobox/tmp
Was wir vor allem im Betrieb wollen: Anschalten und los. Ohne SSH, ohne Terminal, ohne Maus und Tastatur. Dazu richten wir drei Dinge ein: Systemdienst, Kiosk-Modus im Browser und „Always On“ (kein Screensaver, Cursor aus).
Statt den Flask-Dev-Server zu nutzen, starten wir die App stabil über gunicorn und lassen sie von systemd beaufsichtigen. Bei einer einzelnen Kamera ist mir wichtig: nur ein Worker, dafür mehrere Threads. Sonst versucht am Ende mehr als ein Prozess, dieselbe Kamera zu öffnen - und genau solche Fehler möchte man bei einer Fotobox nicht haben:
sudo nano /etc/systemd/system/fotobox.service
[Unit]
Description=Fotobox (Gunicorn)
After=network-online.target
Wants=network-online.target
[Service]
User=fb
WorkingDirectory=/home/fb/fotobox
Environment=PATH=/home/fb/fotobox/.venv/bin
ExecStart=/home/fb/fotobox/.venv/bin/gunicorn -w 1 --threads 8 -b 127.0.0.1:5000 app:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable fotobox.service
sudo systemctl start fotobox.service
sudo systemctl status fotobox.service
Damit wir unsere Fotobox-Webapp nicht immer über http://localhost:5000 starten müssen, richten wir
Apache als Reverse Proxy ein. Apache nimmt die Anfragen auf Port 80 entgegen und leitet sie an Gunicorn weiter.
Zusätzlich erlauben wir den Zugriff auf den Foto-Ordner, damit die generierten Bilder über QR-Codes ausgeliefert werden können.
sudo a2enmod proxy
sudo a2enmod proxy_http
sudo systemctl restart apache2
sudo nano /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
ServerName fotobox.local
ProxyPass / http://127.0.0.1:5000/
ProxyPassReverse / http://127.0.0.1:5000/
<Directory "/var/www/html/fotobox/fotos">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/fotobox_error.log
CustomLog ${APACHE_LOG_DIR}/fotobox_access.log combined
</VirtualHost>
sudo systemctl reload apache2
Ab jetzt erreichst du die Fotobox bequem über http://fotobox.local oder die IP des Raspberry Pi.
Wir installieren Tools und starten Chromium im Kiosk-Modus. Cursor weg, Display bleibt wach.
sudo apt update
sudo apt install chromium-browser unclutter x11-xserver-utils avahi-daemon
mkdir -p ~/.local/bin
nano ~/.local/bin/fotobox-session.sh
#!/usr/bin/env bash
export DISPLAY=:0
# Bildschirm wach halten
xset s off
xset -dpms
xset s noblank
# Mauszeiger ausblenden
unclutter -idle 0.2 -root &
# Warten bis App lauscht
sleep 2
# Chromium im Kiosk
chromium-browser --noerrdialogs --disable-session-crashed-bubble \
--disable-infobars --incognito --kiosk --app=http://localhost:5000
chmod +x ~/.local/bin/fotobox-session.sh
Auto-Login auf Desktop aktivieren (raspi-config) und dann Autostart-Eintrag setzen:
mkdir -p ~/.config/autostart
nano ~/.config/autostart/fotobox-kiosk.desktop
[Desktop Entry]
Type=Application
Name=Fotobox Kiosk
Comment=Startet Chromium im Kiosk-Modus
Exec=/home/fb/.local/bin/fotobox-session.sh
X-GNOME-Autostart-enabled=true
Wenn ein Foto entsteht, soll es am Ende nicht nur auf dem Display hübsch aussehen, sondern am besten auch als kleines Andenken in der Hand landen. Dafür nutze ich einen Canon SELPHY CP1500, der per USB am Raspberry Pi hängt. Der entscheidende Punkt war hier weniger „Python kann drucken“, sondern: Linux muss den Drucker sauber kennen.
Genau dafür ist CUPS zuständig. In meinem Setup heißt der Drucker in CUPS FotoboxDrucker.
Das ist wichtig, weil der Name später im Python-Code verwendet wird. Ob der Drucker wirklich so heißt, prüfst du mit
lpstat -p -d. Wenn dein Drucker anders heißt, musst du den Namen entsprechend anpassen.
sudo apt install cups cups-client printer-driver-gutenprint ipp-usb
sudo usermod -aG lpadmin $USER
sudo systemctl enable --now cups
lpstat -p -d
Anfangs war mein Gedanke: Nach dem Klick auf „Drucken“ einfach lp ausführen und fertig. In der Praxis ist das
aber etwas zu optimistisch. Ein Fotodrucker ist langsam, Papier oder Kassette können fehlen, CUPS kann den Drucker kennen,
obwohl er gerade nicht wirklich bereit ist, und ein Gast kann natürlich auch mehrfach hektisch auf den Button drücken.
Deshalb prüft die App inzwischen vor dem Druck den Zustand des Druckers und blockiert weitere Druckaufträge kurzzeitig. Das ist nicht deshalb drin, weil es besonders elegant aussieht, sondern weil es in der Praxis verhindert, dass die Box mehrere Druckjobs übereinander wirft oder einfach kommentarlos ins Leere läuft.
PRINTER_DISPLAY_NAME = "FotoboxDrucker"
print_lock = threading.Lock()
print_in_progress = False
print_started_at = 0
PRINT_LOCK_SECONDS = 45
def get_printer_name():
code, out, _ = run_cmd(["lpstat", "-d"])
if code == 0 and ":" in out:
return out.split(":", 1)[1].strip()
return PRINTER_DISPLAY_NAME
Der eigentliche Druck läuft dann über lp. Wenn gerade schon ein Druck läuft oder der Drucker in einem harten
Fehlerzustand ist, bekommt das Frontend eine passende Rückmeldung und kann ein Hinweisfenster anzeigen.
def submit_print(save_path):
global print_in_progress, print_started_at
if print_in_progress:
return {
"success": False,
"error": "Es läuft bereits ein Druckauftrag. Bitte warten.",
"printer": get_printer_status(),
}, 409
printer_status = get_printer_status()
if printer_status["state"] in {"disconnected", "offline", "paper_empty", "paused"}:
return {
"success": False,
"printer": printer_status,
"error": printer_status["message"],
}, 503
with print_lock:
print_in_progress = True
print_started_at = time.time()
printer = printer_status.get("printer") or PRINTER_DISPLAY_NAME
code, out, err = run_cmd(["lp", "-d", printer, save_path])
if code != 0:
print_in_progress = False
return {
"success": False,
"error": err or out or "Druck fehlgeschlagen.",
"printer": get_printer_status(),
}, 503
return {
"success": True,
"message": "Druckauftrag wurde gestartet.",
"printer": get_printer_status(),
}, 200
Das war einer der Punkte, die erst im echten Testbetrieb relevant wurden. Solange alles angeschlossen ist und Papier drin liegt, wirkt so eine Statuslogik übertrieben. Sobald aber jemand vor der Box steht und der Drucker gerade nicht will, ist eine verständliche Fehlermeldung Gold wert.
Nicht jeder will ein Foto direkt ausdrucken. Viele wollen es einfach auf dem Smartphone haben. Dafür bekommt jedes gespeicherte Foto einen QR-Code. Wichtig ist dabei: Der QR-Code ist bei mir kein WLAN-QR-Code, sondern ein echter Download-Link zum Bild.
Das klingt nach einem kleinen Unterschied, ist aber entscheidend für die Bedienung. Die Fotobox startet für den Transfer
ein eigenes temporäres WLAN. Der Gast verbindet sich mit diesem WLAN und scannt danach den QR-Code. Der QR-Code zeigt dann
auf die lokale Adresse der Fotobox, in meinem Fall auf http://192.168.4.1/fotobox/fotos/.... Sobald der Gast
fertig ist, wird das temporäre WLAN wieder beendet.
Der angenehme Nebeneffekt: Die Box ist nicht darauf angewiesen, dass am Veranstaltungsort irgendein brauchbares WLAN existiert. Das Smartphone muss nur kurz in das WLAN der Fotobox wechseln und kann das Bild direkt aus der Box laden.
STATIC_IP = "192.168.4.1"
TEMP_DIR = "/var/www/html/fotobox/tmp"
SAVE_DIR = "/var/www/html/fotobox/fotos"
FRAME_PATH = "/var/www/html/fotobox/rahmen.png"
@app.route('/keep_photo/<filename>', methods=['POST'])
def keep_photo(filename):
temp_path = os.path.join(TEMP_DIR, filename)
if os.path.exists(temp_path):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
save_path = os.path.join(SAVE_DIR, f'{timestamp}.jpg')
photo = Image.open(temp_path)
frame_img = Image.open(FRAME_PATH)
photo_with_frame = ImageOps.fit(photo, frame_img.size)
photo_with_frame.paste(frame_img, (0, 0), frame_img)
photo_with_frame.save(save_path)
url = f'http://{STATIC_IP}/fotobox/fotos/{timestamp}.jpg'
qr = qrcode.make(url)
name_without_ext = os.path.splitext(filename)[0]
qr_path = os.path.join(TEMP_DIR, f'qr_{name_without_ext}.png')
qr.save(qr_path)
os.remove(temp_path)
return jsonify({
'photo_url': f'/saved_photo/{timestamp}.jpg',
'qr_code_url': f'/temp_photo/qr_{name_without_ext}.png'
})
return jsonify({'error': 'Photo not found'}), 404
Das temporäre WLAN selbst starte und stoppe ich nicht direkt im Python-Code, sondern über zwei Shellskripte. Flask ruft diese Skripte nur auf. Das ist für mich übersichtlicher, weil die WLAN-Konfiguration damit sauber außerhalb der Weblogik liegt.
@app.route('/start_temp_wifi', methods=['POST'])
def start_temp_wifi():
try:
result = subprocess.run(
["sudo", "/usr/local/bin/fotobox_wlan_start.sh"],
capture_output=True,
text=True
)
ssid = result.stdout.strip()
return jsonify({"ssid": ssid})
except Exception as e:
return jsonify({"error": f"Failed to start WiFi: {e}"}), 500
@app.route('/stop_temp_wifi', methods=['POST'])
def stop_temp_wifi():
try:
subprocess.run(["sudo", "/usr/local/bin/fotobox_wlan_stop.sh"])
return "WLAN deaktiviert"
except Exception as e:
return jsonify({"error": f"Failed to stop WiFi: {e}"}), 500
Auf der Oberfläche steht dann sinngemäß: Erst mit dem WLAN verbinden, dann den QR-Code scannen, Bild speichern und danach auf „Alles klar, bin fertig!“ drücken. Das ist nicht ganz so magisch wie AirDrop, aber für eine selbstgebaute Fotobox erstaunlich robust.
app.py & index.html
Jetzt bringen wir die Bausteine zusammen: Routen in app.py, ein Touch-optimiertes
Frontend in templates/index.html und ein paar JS-Helferlein.
Ziel: Ein Klick → Countdown → Foto → Vorschau → behalten oder verwerfen → optional drucken oder per QR aufs Handy.
Die aktuelle App ist inzwischen kein winziges Beispielskript mehr. Sie besteht aus mehreren Teilen: Kamera-Thread, Fotoaufnahme, temporäre Dateien, Rahmen, Collagen, QR-Code, temporäres WLAN, Druckerstatus und Galerie. Ich packe deshalb hier nicht stumpf die komplette Datei in den Artikel, weil das nur eine riesige Codewand wäre. Die wichtigsten Stellen sind aber genau die folgenden.
Ein Foto wird nach dem Countdown nicht sofort dauerhaft abgelegt. Es landet zuerst im temporären Ordner. Danach sieht der Gast das Ergebnis und entscheidet: behalten oder verwerfen. Das klingt nach einer kleinen Komfortfunktion, macht die Box aber viel angenehmer, weil verwackelte oder blöde Fotos nicht direkt in der finalen Galerie landen.
TEMP_DIR = "/var/www/html/fotobox/tmp"
SAVE_DIR = "/var/www/html/fotobox/fotos"
FRAME_PATH = "/var/www/html/fotobox/rahmen.png"
def save_temp_photo():
start_wait = time.time()
while True:
with frame_lock:
frame = None if last_frame is None else last_frame.copy()
if frame is not None or (time.time() - start_wait) > 2.0:
break
time.sleep(0.01)
if frame is None:
print("Kamera hat kein Bild geliefert (last_frame ist None).")
return None
frame = cv2.resize(frame, (1772, 1181))
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
temp_path = os.path.join(TEMP_DIR, f'temp_{timestamp}.jpg')
cv2.imwrite(temp_path, frame)
return f'temp_{timestamp}.jpg'
Erst beim Klick auf „Behalten“ wird aus dem temporären Foto ein richtiges Foto im finalen Ordner. Dabei wird der Rahmen darübergelegt und anschließend ein QR-Code für genau dieses Bild erzeugt. Das war mir wichtig, weil Druck und QR-Code immer mit derselben finalen Datei arbeiten sollen.
@app.route('/keep_photo/<filename>', methods=['POST'])
def keep_photo(filename):
temp_path = os.path.join(TEMP_DIR, filename)
if os.path.exists(temp_path):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
save_path = os.path.join(SAVE_DIR, f'{timestamp}.jpg')
photo = Image.open(temp_path)
frame_img = Image.open(FRAME_PATH)
photo_with_frame = ImageOps.fit(photo, frame_img.size)
photo_with_frame.paste(frame_img, (0, 0), frame_img)
photo_with_frame.save(save_path)
url = f'http://{STATIC_IP}/fotobox/fotos/{timestamp}.jpg'
qr = qrcode.make(url)
name_without_ext = os.path.splitext(filename)[0]
qr_path = os.path.join(TEMP_DIR, f'qr_{name_without_ext}.png')
qr.save(qr_path)
os.remove(temp_path)
return jsonify({
'photo_url': f'/saved_photo/{timestamp}.jpg',
'qr_code_url': f'/temp_photo/qr_{name_without_ext}.png'
})
return jsonify({'error': 'Photo not found'}), 404
Die Oberfläche ist nicht für Maus und Tastatur gedacht, sondern für ein Touchdisplay in einem alten Macintosh-Gehäuse. Deshalb sind die Buttons groß, die Wege kurz und die Dialoge eindeutig. Nach dem Foto fragt die Box erst einmal ganz simpel: Gefällt dir das Foto?
<div class="button-container button-container-main">
<button class="capture-button" onclick="startCapture()">Einzelfoto</button>
<button class="collage-button" onclick="startCollage()">Collage</button>
<button class="gallery-button" onclick="showGallery()">Galerie</button>
<button class="help-button" onclick="showHelp()">Anleitung</button>
</div>
<div id="photo-popup" class="photo-popup wide-popup" style="display: none;">
<div class="popup-layout popup-layout-photo">
<div class="popup-visual-column">
<p id="photo-loading-text" class="loading-text">Foto lädt...</p>
<img id="captured-photo" src="" alt="Aufgenommenes Foto" onload="photoLoaded()">
</div>
<div class="popup-action-column">
<h2>Gefällt dir das Foto?</h2>
<p>Du kannst es behalten oder direkt wieder verwerfen.</p>
<div class="popup-buttons popup-buttons-stack">
<button id="keep-button" onclick="showOptions()" class="button-keep" disabled>Behalten</button>
<button id="discard-button" onclick="discardPhoto()" class="button-discard" disabled>Verwerfen</button>
</div>
</div>
</div>
</div>
Die Galerie war so ein typischer Punkt, der in der Theorie schnell klingt und in der Praxis nervt. Wenn Collagen ein anderes Präfix bekommen als Einzelfotos, ist eine reine alphabetische Sortierung Quatsch. Entscheidend ist nicht der Dateiname, sondern: Was wurde zuletzt gespeichert?
Deshalb sortiert die Galerie inzwischen nach Änderungszeit. Ob das Bild collage_... heißt oder nicht, ist
dadurch egal. Oben bzw. zuerst erscheint das neueste Bild.
@app.route('/get_photos', methods=['GET'])
def get_photos():
files = [
filename for filename in os.listdir(SAVE_DIR)
if filename.lower().endswith(('.jpg', '.jpeg', '.png'))
]
files.sort(
key=lambda fn: os.path.getmtime(os.path.join(SAVE_DIR, fn)),
reverse=True
)
files = files[:24]
photos = [f'/saved_photo/{filename}' for filename in files]
return jsonify(photos)
Im Frontend wird diese Liste geladen und jedes Bild als anklickbares Thumbnail dargestellt. Beim Antippen öffnet sich das Foto groß im Popup.
function loadGalleryPhotos() {
const galleryContainer = document.getElementById('gallery-thumbnails');
galleryContainer.innerHTML = '<p class="gallery-loading">Galerie lädt...</p>';
return fetch('/get_photos')
.then(response => response.json())
.then(data => {
galleryPhotos = data;
updateGallery();
})
.catch(error => {
console.error('Error loading gallery photos:', error);
galleryContainer.innerHTML = '<p class="gallery-loading">Galerie konnte nicht geladen werden.</p>';
});
}
function updateGallery() {
const galleryContainer = document.getElementById('gallery-thumbnails');
galleryContainer.innerHTML = '';
galleryPhotos.forEach(photoUrl => {
const img = document.createElement('img');
img.src = `${photoUrl}?t=${Date.now()}`;
img.alt = 'Foto Vorschau';
img.loading = 'lazy';
img.decoding = 'async';
img.onclick = () => openFullscreenPhoto(photoUrl);
galleryContainer.appendChild(img);
});
}
Wenn man es nun noch hübsch macht, kommt am Ende vielleicht ein UI raus, das ungefähr so wie meins aussieht:
-w 1 meist sinnvoller als mehrere Worker, weil sonst mehrere Prozesse an dieselbe Kamera wollen. Lieber mit Threads arbeiten.lpstat -p -d prüfen und in der App als FotoboxDrucker oder passend zu deinem Setup setzen./var/www/html/fotobox/tmp und /var/www/html/fotobox/fotos schreiben können... oder Slash gehört verworfen.Wer bis hierhin durchgehalten hat: Respekt. Du hast jetzt den groben Bauplan, um eine eigene Fotobox ins Leben zu rufen - von der Hardware über die Software bis hin zu Browser-Kiosk und QR-Code-Trickserei. Klingt nach „einfach zusammenstecken“, fühlt sich in der Praxis aber eher an wie ein kleiner Hindernislauf mit Stolpersteinen aus Python-Umgebungen, Apache-Configs und nicht ganz kooperativen Druckertreibern.
Wichtig ist: Dieser Blog liefert Inspiration und Beispiele, keine Komplettlösung. Nicht, weil ich gemein bin - sondern weil jedes Setup eigene Macken hat: andere Kamera, andere Pi-Version, andere Rechte, andere Ordner.
Mein Rat: Nimm die Schnipsel, probier sie aus, lerne an den Fehlern (die werden kommen) und bau dir die Fotobox so, wie sie zu deinem Event passt. Am Ende zählt nicht, dass es „out of the box“ lief, sondern dass du irgendwann vor deinem DIY-Kasten stehst, den Auslöser drückst und die Leute lachen, wenn ihr Foto erscheint.
Und ob du das Ding jetzt erweitern willst, steht dir völlig frei.
Ich persönlich habe die Fotobox noch erweitert mit einer kleinen LED Anzeige, die darstellt, ob alle Systemjobs laufen und einem kleinen physischen Resetknopf, der diese zurücksetzt,
wenn irgendwas gegen die Wand gefahren ist.
Des Weiteren ziert eine magnetische Halterung die Rückseite des Macs, auf welcher wiederum die Kamera gemountet ist.
Das Schöne an so einem DIY Projekt ist ja immer, das "nach oben hin" keine Grenzen gesetzt sind.
Also: Vielleicht stolperst du, vielleicht fluchst du, vielleicht lernst du dabei mehr über Linux und Webserver als dir lieb ist. Aber wenn es dann läuft… dann lohnt es sich. Versprochen :)