"""Stichproben aus dem laufenden Bildstrom. Ein Detektor, der nur bei Erfolg speichert, hinterlässt nach zwei Wochen ohne Sichtung genau nichts, woran man sehen könnte, woran es lag. Waren keine Vögel da? Saßen sie im Bild und waren zu klein? Das Protokoll beantwortet die zweite Frage, die erste beantwortet nur ein Blick auf echte Bilder. Deshalb legt ``Stichprobe`` in festem Abstand ein Bild ab — unabhängig davon, ob etwas erkannt wurde. Der Strom läuft ohnehin, es kostet keinen zweiten Zuschauer am Stream (der Pi Zero verträgt keinen). Der Ordner ist selbstbegrenzend: über ``max_bilder`` hinaus fliegt jeweils das älteste Bild raus. Ein Dauerläufer darf keine Platte volllaufen lassen. """ from __future__ import annotations import io from datetime import datetime from pathlib import Path from PIL import Image, ImageDraw JPEG_QUALITY = 80 MAX_WIDTH = 1280 def _encode(img: Image.Image, max_width: int = MAX_WIDTH) -> bytes: if img.width > max_width: h = round(img.height * max_width / img.width) img = img.resize((max_width, h), Image.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) return buf.getvalue() class Stichprobe: """Legt in festem Abstand ein Bild aus dem Strom ab.""" def __init__( self, ordner: str | Path, alle_sekunden: float = 300.0, max_bilder: int = 500, ): self.ordner = Path(ordner) self.alle_sekunden = alle_sekunden self.max_bilder = max_bilder self.ordner.mkdir(parents=True, exist_ok=True) self._letzte: float | None = None def bild(self, frame: Image.Image, jetzt: float) -> Path | None: """Speichert, wenn das Intervall abgelaufen ist. Gibt den Pfad zurück, sonst ``None``. ``jetzt`` ist eine monotone Uhr (``time.monotonic``) — der Abstand darf nicht davon abhängen, ob jemand die Systemzeit stellt. """ if self._letzte is not None and jetzt - self._letzte < self.alle_sekunden: return None self._letzte = jetzt stamp = datetime.now().strftime("%Y%m%d-%H%M%S") ziel = self.ordner / f"{stamp}.jpg" ziel.write_bytes(_encode(frame)) self._aufraeumen() return ziel def _aufraeumen(self) -> None: bilder = sorted(self.ordner.glob("*.jpg")) # Die Namen sind Zeitstempel, sortieren heisst also chronologisch. for alt in bilder[: max(0, len(bilder) - self.max_bilder)]: alt.unlink(missing_ok=True) def kontaktbogen( ordner: str | Path, ziel: str | Path, spalten: int = 5, zeilen: int = 6, kachel_breite: int = 400, seite: int = 1, ) -> tuple[Path, int, int]: """Montiert die Stichproben zu einem Blatt, neueste zuerst. Ein Ordner mit 120 Bildern wird auf dem Handy nicht durchgesehen, ein einzelnes Blatt schon. Rückgabe: Zielpfad, Anzahl montierter Bilder und Gesamtzahl der Seiten. """ ordner = Path(ordner) ziel = Path(ziel) bilder = sorted(ordner.glob("*.jpg"), reverse=True) if not bilder: raise FileNotFoundError(f"Keine Stichproben in {ordner}") pro_seite = spalten * zeilen seiten = (len(bilder) + pro_seite - 1) // pro_seite seite = max(1, min(seite, seiten)) auswahl = bilder[(seite - 1) * pro_seite : seite * pro_seite] with Image.open(auswahl[0]) as probe: verhaeltnis = probe.height / probe.width kh = round(kachel_breite * verhaeltnis) beschriftung = 22 zeilen_echt = (len(auswahl) + spalten - 1) // spalten blatt = Image.new( "RGB", (spalten * kachel_breite, zeilen_echt * (kh + beschriftung)), (18, 18, 18), ) mal = ImageDraw.Draw(blatt) for i, pfad in enumerate(auswahl): x = (i % spalten) * kachel_breite y = (i // spalten) * (kh + beschriftung) with Image.open(pfad) as im: blatt.paste(im.convert("RGB").resize((kachel_breite, kh), Image.LANCZOS), (x, y)) # Aus "20260912-143005.jpg" wird "12.09. 14:30" — die Sekunde hilft # beim Durchsehen nicht, das Datum schon. name = pfad.stem text = f"{name[6:8]}.{name[4:6]}. {name[9:11]}:{name[11:13]}" mal.text((x + 6, y + kh + 4), text, fill=(210, 210, 210)) ziel.parent.mkdir(parents=True, exist_ok=True) blatt.save(ziel, format="JPEG", quality=85, optimize=True) return ziel, len(auswahl), seiten