Decision Log

Architektur- und Design-Entscheidungen des Rust-Rewrites. Jede Entscheidung nennt Begründung und Widerspruch (wie ein Mensch die Entscheidung umkehren/anpassen kann). Neueste Einträge oben.

Format: YYYY-MM-DD — getroffen von Claude (Opus 4.8), sofern nicht anders vermerkt.


2026-06-13 — ONNX-Runtime-Library beschafft (NICHT über AUR) + Config-Robustheit

Kontext: ort mit load-dynamic braucht libonnxruntime.so zur Laufzeit; sie fehlte. Der Nutzer warnte vor dem laufenden AUR-Supply-Chain-Angriff „Atomic Arch" (seit 2026-06-11, von Sonatype entdeckt): übernommene/verwaiste AUR-Pakete mit manipulierten PKGBUILDs laden beim Build via yay/paru bösartige npm-Pakete (atomic-lockfile, js-digest) → Credential-Stealer + eBPF-Rootkit, gezielt auf Dev-Workstations. Quellen: Sonatype (https://www.sonatype.com/blog/atomic-arch-npm-campaign-adds-malicious-dependency), The Hacker News (https://thehackernews.com/2026/06/over-400-arch-linux-aur-packages.html), BleepingComputer (https://www.bleepingcomputer.com/news/security/over-400-arch-linux-packages-compromised-to-push-rootkit-infostealer/).

Entscheidung: AUR komplett vermieden. onnxruntime ist auch nicht in den offiziellen (signierten) Arch-Repos. Daher direkt das offizielle Microsoft-GitHub-Release geladen: onnxruntime-linux-x64-1.23.2.tgz. Eine vorgefertigte Binary OHNE PKGBUILD/npm-preinstall — der Atomic-Arch-Vektor greift hier also nicht.

Versionswahl 1.23.2: ort 2.0.0-rc.11 → ONNX Runtime 1.23.2; rc.12 ist neuer (1.23.x). Die ONNX-Runtime-C-API ist abwärtskompatibel, d.h. 1.23.2 deckt jede von ort angeforderte API-Version ab. Quelle: pykeio/ort Releases.

Validierung (vor Nutzung):

  • Quelle: github.com/microsoft/onnxruntime Release über HTTPS (offizielle Org).

  • sha256 des Downloads: 1fa4dcaef22f6f7d5cd81b28c2800414350c10116f5fdd46a2160082551c5f9b.

  • ELF 64-bit x86-64 shared object; exportiertes Symbol OrtGetApiBase@@VERS_1.23.2 vorhanden.

  • End-to-End-Test: App mit ORT_DYLIB_PATH=runtime/libonnxruntime.so.1.23.2 gestartet → Log ONNX-Modell geladen model=models/yolov8n-pose.onnx (gesamte Kette ort→.so→Modell ok).

Ablage: runtime/libonnxruntime.so* (gitignored, arch-spezifisch x86_64). Start mit ORT_DYLIB_PATH=$PWD/runtime/libonnxruntime.so.1.23.2.

Begleitfix: Config.cameras bekam #[serde(default)] — eine frische Config ohne lädt jetzt (Kameras werden per Dashboard ergänzt). Vorher: harter Parse-Fehler.

Widerspruch / Anpassung:

  • Für den Pi 5 (aarch64) die passende onnxruntime-linux-aarch64-1.23.2.tgz verwenden (die x86_64-.so funktioniert dort NICHT).

  • Wer kein manuelles .so-Handling will: in Cargo.toml ort-Feature load-dynamic durch download-binaries ersetzen (ort lädt die passende Runtime beim Build automatisch).

  • System-weite Installation gewünscht: .so nach /usr/local/lib + ldconfig, dann ist ORT_DYLIB_PATH unnötig — aber bewusst NICHT gemacht (kein Eingriff ins System).


2026-06-13 — Pose-Modell models/yolov8n-pose.onnx erzeugt (nicht eingecheckt)

Herkunft (reproduzierbar): Offizielles Ultralytics-Gewicht yolov8n-pose.pt (GitHub ultralytics/assets release v8.4.0), exportiert nach ONNX mit ultralytics 8.4.66 in einer wegwerfbaren uv-venv (Python 3.12, CPU-torch 2.12). Export-Parameter: format=onnx, imgsz=640, opset=17, simplify=True, dynamic=False.

Verifiziert: Lädt in onnxruntime, Input images [1,3,640,640], Output output0 [1,56,8400] (= 4 bbox + 1 conf + 17·3 Keypoints) — exakt wie pipeline.rs erwartet. Dummy-Inferenz erfolgreich. sha256 der ONNX-Datei: c26f8e17a3965a0e47b359a6eb873fdef16d82f32f067fd2cbd8d3940a03604e.

Nicht in Git: .gitignore schließt *.onnx/models/ bewusst aus (13,5 MB Binary). Das Modell muss auf jedem Zielsystem separat bereitgestellt werden.

Begründung opset 17: universell von ONNX Runtime 1.18 (via ort-Feature api-18) unterstützt; vermeidet Inkompatibilitäten neuerer opsets.

Offener Laufzeit-Blocker (nicht Teil dieser Aufgabe): Das ort-Feature load-dynamic erfordert libonnxruntime.so zur Laufzeit (via ORT_DYLIB_PATH oder Systempfad). Auf dieser Maschine NICHT vorhanden → der App-Start würde die Session nicht laden können. Außerdem ist die .so architekturspezifisch (Dev = x86_64, Pi 5 = aarch64).

Widerspruch / Anpassung: Anderes Modell (z.B. yolov8s-pose für mehr Genauigkeit) analog exportieren und detection.model_path anpassen. Soll das Modell doch versioniert werden, .gitignore-Einträge entfernen und Git LFS verwenden.


2026-06-13 — ffmpeg-Eingabe quellenadaptiv (RTSP / V4L2 / Datei) für OBS-Tests

Kontext: Der Capture-Pfad hatte -rtsp_transport tcp fest verdrahtet. Der geplante Test mit OBS Virtual Cam liefert aber ein V4L2-Gerät (/dev/videoN), kein RTSP — damit wäre kein Test möglich gewesen.

Entscheidung: camera::ffmpeg_input_args(url) wählt die Eingabe nach Präfix: rtsp://→RTSP/TCP, /dev/video*-f v4l2 (OBS), sonst→lokale Datei mit -stream_loop -1 (Testvideo läuft in Schleife). Geteilt von Capture- und Segment-Recorder (DRY), reine Funktion (unit-getestet).

Begründung: Ermöglicht den OBS-Test ohne Code-Änderung (Kamera-URL = /dev/video0) und sogar direktes Testen mit heruntergeladenen Sturz-Videos (URL = Dateipfad, loopt).

Unsicherheit / Caveat: Bei V4L2-Rohvideo schlägt die Segment-Aufzeichnung (-c copy → mpegts) fehl und loggt alle ~5 s eine Warnung; die Erkennung läuft davon unberührt. Für Datei-Quellen (mp4) funktioniert -c copy. OBS Virtual Cam liefert je nach Setup YUYV/MJPEG — ich konnte das exakte Pixelformat hier nicht verifizieren; ffmpeg skaliert/konvertiert im Capture-Pfad ohnehin nach rgb24, das sollte tragen.

Widerspruch / Anpassung: Wird die Datei-Schleife beim Testen nicht gewünscht, -stream_loop -1 im else-Zweig entfernen. Soll V4L2 auch aufgezeichnet werden, im Segment-Recorder -c copy durch einen Encoder (z.B. -c:v libx264) ersetzen.


2026-06-13 — Sturz-Dynamik + Per-Person-Tracking (detection/dynamics.rs)

Kontext: Der bisherige TemporalTracker erkannte nur einen Zustand ("7 von 10 Frames liegend") pro Kamera und konnte einen Sturz nicht von einer Person unterscheiden, die sich absichtlich hinlegt/hinsetzt, oder von einem knienden Helfer. Auch wurde bei mehreren Personen nur "irgendwer liegt" geodert (keine Identität).

Was als Sturz gilt — und wie ich es hergeleitet habe

Recherche (nicht aus dem Bauch): mehrere CV-Fall-Detection-Arbeiten beschreiben dasselbe Grundprinzip — „ein Sturz = liegende/gebeugte Pose UND Geschwindigkeit über einer Schwelle", und konkret: „fällt jemand schnell, ist es kein Vorsatz; fällt jemand langsam, legt er sich absichtlich hin". Genutzte Standard-Merkmale: Abstiegsgeschwindig- keit des Hüft-/Körperzentrums, Körperachsen-Winkel, bbox-Seitenverhältnis. Quellen:

Definition (umgesetzt): Ein Sturz wird pro Person ausgelöst, wenn ALLE gelten:

  1. Schneller Abstieg — Geschwindigkeit des bbox-Zentrums (normierte y/s, y nach unten) überschreitet fall_velocity_threshold innerhalb von velocity_window_seconds.

  2. Liegende Pose — classify_fall (Aspect-Ratio Signal A ODER Torso-Winkel Signal B).

  3. Aufprall-Persistenz — Pose bleibt ≥ min_ground_seconds liegend.

  4. der Abstieg lag ≤ descent_window_seconds vor der bestätigten Liege-Phase,

  5. und die Person ist nicht im cooldown_seconds-Fenster.

So wird der Sturz (schnell zu Boden + bleibt liegen) vom absichtlichen Hinlegen (langsam) und von kurzem Bücken/Springen (keine Persistenz) getrennt.

Per-Person-Tracking

KISS-Tracker (SORT-artig, ohne Kalman/Hungarian): Greedy-IoU-Zuordnung Detektion→Track, plus ein Center-Distanz-Fallback (MAX_CENTER_DIST = 0.25). Der Fallback ist nötig und nicht optional: beim Übergang stehend→liegend ändert sich die bbox-Form so stark, dass die IoU zwischen zwei Frames unter die Schwelle fällt — ohne Fallback würde der Track genau im Sturz abreißen und die Abstiegs-Historie verlieren (Sturz würde NICHT erkannt). Das fiel beim Durchrechnen der Tests auf (IoU stehend↔liegend ≈ 0.10—​0.14 < 0.2).

Cooldown ist pro Person (nicht pro Kamera): so kann jede Person einmal pro Cooldown auslösen; ein zweiter, gleichzeitiger Sturz einer anderen Person geht nicht verloren.

Konkrete Default-Parameter — UNSICHER, per OBS zu kalibrieren

Diese Werte sind begründete Startwerte, keine validierten Konstanten (kameraabhängig: Blickwinkel, Abstand, Auflösung, Bildrate). Alle in [detection] konfigurierbar:

Parameter Default Unsicherheit / Annahme

fall_velocity_threshold

0.35 /s

Hoch unsicher. Annahme: ein Sturz überquert ~0.3—​0.6 der Bildhöhe in <1 s. Reiterstürze (vom Pferd, teilverdeckt) habe ich NICHT messen können.

velocity_window_seconds

0.5

Kurz genug für den Peak-Abstieg, lang genug gegen Jitter — nicht empirisch belegt.

descent_window_seconds

1.5

Spanne Abstieg→bestätigtes Liegen. Geschätzt.

min_ground_seconds

0.4

Debounce gegen Flacker/kurzes Bücken. Geschätzt.

track_iou_threshold

0.2

Üblicher SORT-Bereich (0.2—​0.3); bewusst niedrig wegen Formänderung.

track_max_age_seconds

2.0

Wie lange ein verdeckter Track überlebt. Geschätzt.

MAX_CENTER_DIST (const)

0.25

Pro-Frame-Zentrumssprung bei ~10 FPS; bei sehr schnellen/nahen Stürzen evtl. zu klein.

Weitere offene Unsicherheiten:

  • Maß = bbox-Zentrum-y. Robust und immer verfügbar, aber der Kopf (Box-Oberkante) fällt bei einem Sturz stärker als das Zentrum → ggf. weniger empfindlich. Alternative (Kopf-/ Nasen-Keypoint) wäre empfindlicher, aber rauschanfälliger/verdeckungsanfällig. Bewusst konservativ gewählt; bei zu vielen verpassten Stürzen im OBS-Test umstellen.

  • FPS-Abhängigkeit: Geschwindigkeit/Zeiten sind Wall-Clock-basiert (FPS-robust). Aber bei sehr niedriger realer FPS (<~3) kann das velocity_window zu wenige Samples enthalten.

  • Reiter-Spezifik nicht modelliert: Pferd-Verdeckung, Reiter-auf-Pferd vs. gestürzt — das Pose-Modell ist generisch (Person). Kann zu Fehlklassifikation der Pose führen.

Warum kein VLM (mistral.rs) — bestätigt

Wie zuvor entschieden: Echtzeit-Detektor bleibt YOLO-Pose + Geometrie/Dynamik. mistral.rs wäre auf dem Pi 5 zu langsam und nicht-deterministisch. Diese Dynamik-Lösung ist klein, deterministisch und vollständig unit-getestet (9 Tests).

Begründung der KISS/DRY-Entscheidungen:

  • iou und classify_fall aus pipeline.rs wiederverwendet (DRY) statt neu implementiert.

  • TemporalTracker ersatzlos entfernt (kein toter Code) — die Persistenz steckt jetzt in min_ground (zeitbasiert, FPS-robust), die Glättung in der Geschwindigkeits-Mittelung.

  • MAX_CENTER_DIST als Konstante statt Config, um die OBS-relevanten Knöpfe übersichtlich zu halten (nur Dynamik-Parameter sind dort).

  • now: Instant injiziert → der gesamte Tracker ist deterministisch testbar (kein sleep).

Widerspruch / Anpassung:

  • Zu viele Fehlalarme im OBS-Test → fall_velocity_threshold erhöhen und/oder min_ground_seconds verlängern.

  • Zu viele verpasste Stürze → fall_velocity_threshold senken; als nächsten Schritt das Vertikalmaß von bbox-Zentrum auf bbox-Oberkante (Kopf) umstellen (center_y in dynamics.rs::update).

  • Personen "verschmelzen"/Tracks springen → track_iou_threshold senken oder MAX_CENTER_DIST anpassen.

  • Wer das alte Verhalten (reiner Liege-Zustand ohne Dynamik) zurück will: dynamics.rs durch eine Variante ersetzen, die Bedingung 1 (Abstieg) weglässt — dann ist es wieder ein reiner Zustands-Detektor.

  • Gewünschter Daten-getriebener Ersatz: kleines temporales Keypoint-Modell (GRU/TCN über die 17 COCO-Keypoints), trainiert mit den aufgezeichneten Clips — ersetzt die Heuristik 1—​3, behält Tracking + Cooldown.


2026-06-13 — Web-Interface: optionale HTTP-Basic-Auth (opt-in, Default aus)

Kontext: Das Dashboard lief auf 0.0.0.0:8080 ohne Authentifizierung und liefert über /api/config die RTSP-Zugangsdaten im Klartext aus bzw. erlaubt das Überschreiben der config.toml. In einem offenen Turnier-WLAN ist das angreifbar.

Entscheidung: Basic-Auth über eine axum-Middleware (src/web/auth.rs), aktiv nur wenn web.username UND web.password gesetzt sind. Default (leer) = kein Schutz. Neue Dependency base64 = "0.22" (MIT OR Apache-2.0, ~1,24 Mrd. Downloads, ausgereift).

Begründung:

  • Opt-in, weil der HDMI-Kiosk (chromium --kiosk http://localhost:8080, siehe start_kiosk.sh) bei erzwungener Auth einen Login-Dialog zeigen würde → Default aus hält Kiosk und bisheriges Verhalten unverändert (KISS, nicht-brechend).

  • Eigene Mini-Middleware statt tower-http-Auth-Feature: nur ~30 Zeilen, eine Dependency weniger, volle Kontrolle (DRY: eine reine check_basic_auth-Funktion, die isoliert getestet wird).

  • Kein TLS, kein constant-time-Vergleich: Ohne HTTPS ist Timing-Härtung Theater; der reale Schutz für untrusted Netze ist ein TLS-Reverse-Proxy. Bewusst weggelassen (KISS) und in der config.toml.example dokumentiert.

Widerspruch / Anpassung:

  • Auth global erzwingen: web.username/web.password in der Auslieferungs-Config setzen — dann aber Kiosk-URL auf http://user:pass@localhost:8080 umstellen.

  • Nur API/Config schützen, Dashboard/MJPEG offen lassen: die .layer(...) aus web/mod.rs entfernen und stattdessen route_layer selektiv auf die /api/*- und /config-Routen anwenden.

  • Echte Sicherheit für offene Netze gewünscht: TLS-Reverse-Proxy (Caddy/nginx) vorschalten statt App-seitiger Auth; dann diese Middleware ggf. ganz entfernen.

  • Dependency unerwünscht: base64 durch eine ~20-Zeilen-Eigenimplementierung ersetzen (lohnt sich i.d.R. nicht — DRY).


2026-06-13 — Cooldown zeitbasiert statt Frame-gezählt

Entscheidung: TemporalTracker speichert eine Duration + Instant des letzten Alarms; update/is_in_cooldown bekommen now: Instant injiziert.

Begründung: cooldown_frames = cooldown_seconds * 10 nahm fest 10 FPS an. Der Pi 5 erreicht real weniger → Cooldown wurde unkontrolliert zu lang. Wall-Clock entkoppelt die Sperre von der FPS-Rate. now als Parameter hält den Tracker deterministisch testbar (kein echtes sleep in Tests).

Widerspruch / Anpassung: Soll der Cooldown bewusst frame-basiert sein (z.B. exakt N Auswertungen), die alte Zähl-Logik wiederherstellen. Das gleitende Fenster (window/min_hits) ist absichtlich frame-basiert geblieben — das ist ein Glättungs- filter über aufeinanderfolgende Auswertungen, kein Wall-Clock-Konzept.


2026-06-13 — Modellpfad konfigurierbar + Fail-fast

Entscheidung: detection.model_path in der Config (serde(default) = models/yolov8n-pose.onnx); load_session prüft Existenz vorab.

Begründung: Hardcodierter Pfad; fehlendes Modell führte zu stillem Dauer-Sleep, das System „lief" ohne Erkennung. Jetzt klare, handlungsweisende Meldung + sichtbares „INAKTIV"-Log. serde(default) hält bestehende Configs ohne das Feld lauffähig.

Widerspruch / Anpassung: Soll ein fehlendes Modell den Prozess hart beenden (statt weiterlaufen mit aktivem Web-UI), den Sleep-Loop in detection/mod.rs::start durch std::process::exit(1) ersetzen.


2026-06-13 — Alert: Beep von TTS entkoppeln

Entscheidung: prepare_beep() (Pflicht, reines Rust) getrennt von prepare_voice() (best-effort, Piper+ffmpeg). Fehlt TTS, wird nur der Beep gefunkt.

Begründung: Bisher deaktivierte fehlendes Piper die GESAMTE Alarmierung — ein erkannter Sturz wäre nicht gefunkt worden (sicherheitskritisch). Der Beep allein ist ein gültiger Notalarm.

Widerspruch / Anpassung: Soll ohne funktionierende Sprachansage GAR nicht alarmiert werden (z.B. weil nur die gesprochene Warnung akzeptiert wird), in AlertManager::run auch prepare_voice-Fehler zum return führen lassen.


2026-06-13 — Sturzsignal B: Torso-Winkel statt absoluter Y-Vergleiche

Entscheidung: Signal B bewertet die Neigung der Schulter-Hüfte-Achse gegen die Bild-Vertikale (atan2, Schwelle TORSO_FALL_ANGLE_DEG = 45°); Nase nicht mehr nötig.

Begründung: Die alte Logik (shoulder_y > hip_y*0.8) verglich absolute Bild- koordinaten → positionsabhängig, und verlangte eine sichtbare Nase → verpasste Stürze mit verdecktem Gesicht (gefährliche False-Negatives). Der Winkel ist positions- und letterbox-invariant.

Widerspruch / Anpassung: Schwelle zu empfindlich/zu träge → TORSO_FALL_ANGLE_DEG anpassen (kleiner = empfindlicher). Bei Bedarf in die DetectionConfig heben (analog fall_ratio_threshold), falls Feldjustierung ohne Neu-Build gewünscht ist.


Offen / bewusst aufgeschoben (brauchen menschliche Design-Entscheidung)

  • Sturz-Dynamik + Per-Person-Tracking: ERLEDIGT (siehe Eintrag oben, detection/dynamics.rs). Offen bleibt die empirische Kalibrierung der Parameter per OBS-Test und optional ein daten-getriebenes temporales Keypoint-Modell (GRU/TCN) als Ersatz der Heuristik.

  • mistral.rs wurde als Echtzeit-Detektor-Ersatz verworfen (Latenz auf Pi 5, generativer Text statt Geometrie, Halluzinationsrisiko); bestenfalls asynchroner Off-Device-Verifikations-Layer pro Event.

  • Discovery probiert nur 1 Credential/Pfad und liefert eine geratene URL.

  • Doppelter JPEG-Roundtrip Capture→Detection (perf; braucht Messung vor Umbau).