Private
Public Access
kamera geht
This commit is contained in:
1 parent
bfecfd0640
commit
d370f992ce
19 files changed
+1365
-1128
No files matched your search
@@ -169,19 +169,38 @@ Versorgung und Sicherheit im Plan.
|
|||||||
`run.py` und systemd bleiben unverändert bei Loopback.
|
`run.py` und systemd bleiben unverändert bei Loopback.
|
||||||
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
|
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
|
||||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||||
- Moduswahl und simulierte Sequenz sind implementiert. GPIO, automatische
|
- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit
|
||||||
Personenauslösung und Clip-Speicherung sind noch offen.
|
gerichteter Linienüberquerung sind implementiert. Erkennung benötigt optional
|
||||||
|
OpenCV und ausdrücklich installiertes SHA-256-geprüftes Modell unter data/.
|
||||||
|
Am Pi fertige Systempakete python3-opencv/python3-numpy in einer Umgebung
|
||||||
|
mit --system-site-packages verwenden; OpenCV mindestens 4.10 prüfen.
|
||||||
|
requirements-detection.txt erlaubt nur Wheels, keinen impliziten Quellbuild.
|
||||||
|
Erkennung startet deaktiviert; Linie/Bereich/Richtung in der UI einstellen.
|
||||||
|
Gruppen verlangen neuen freien Bereich und Sperrzeit; Erkennungsfehler bei
|
||||||
|
aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und
|
||||||
|
Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile.
|
||||||
|
Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen.
|
||||||
- SQLite speichert Testereignisse und Profilkennungen unter `data/`;
|
- SQLite speichert Testereignisse und Profilkennungen unter `data/`;
|
||||||
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
|
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
|
||||||
Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera
|
Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera
|
||||||
schreibt derzeit keine solchen Datensätze.
|
schreibt derzeit keine solchen Datensätze.
|
||||||
- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben.
|
- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben.
|
||||||
|
- Nutzer meldet am 14. Oktober 2026 die Pi-Kamera als angeschlossen;
|
||||||
|
Ausgabe von `rpicam-hello --list-cameras` bestätigt `imx708_noir`
|
||||||
|
mit 4608×2592 Pixeln. Nutzer bestätigt anschließend echtes Livebild
|
||||||
|
in der WebUI nach Einrichtung von Picamera2.
|
||||||
- Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als
|
- Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als
|
||||||
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
||||||
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
|
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
|
||||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||||
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
||||||
|
|
||||||
|
- Nutzer-Ausgaben bestätigen Raspbian GNU/Linux 13 (trixie), Python 3.13.5
|
||||||
|
und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft.
|
||||||
|
Nach Netzteilwechsel SSH wieder erreichbar, get_throttled=0x0;
|
||||||
|
keine Diagnose des vorherigen Ausfalls daraus ableiten. Kein OpenCV-Build
|
||||||
|
in der übermittelten Prozessliste; Dell-Netzteilwerte bleiben ungeprüft.
|
||||||
|
|
||||||
## Prüfung
|
## Prüfung
|
||||||
|
|
||||||
- Softwareprüfungen, Hardwaretests und Außentests getrennt berichten.
|
- Softwareprüfungen, Hardwaretests und Außentests getrennt berichten.
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ Weboberfläche und geplantem Effektablauf.
|
|||||||
|
|
||||||
Die erste Softwareversion bietet ein Picamera2-Livebild, einen Demo-Feed,
|
Die erste Softwareversion bietet ein Picamera2-Livebild, einen Demo-Feed,
|
||||||
gespeicherte Moduswahl, einen simulierten Effektablauf sowie eine lokale
|
gespeicherte Moduswahl, einen simulierten Effektablauf sowie eine lokale
|
||||||
SQLite-Datenbank für Testereignisse und manuell zugeordnete Profile.
|
SQLite-Datenbank für Ereignisse und manuell zugeordnete Profile. Optional
|
||||||
GPIO-Steuerung, automatische Personenerkennung und Aufnahme von Clips
|
erkennt NanoDet lokal Personen und löst beim Überqueren einer einstellbaren
|
||||||
sind noch nicht umgesetzt. Die echte Kamera und die Aktoren sind noch
|
Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und
|
||||||
nicht praktisch geprüft.
|
Aktivierung stehen in [SOFTWARE.md](SOFTWARE.md#personenerkennung-und-linienauslösung).
|
||||||
|
Das echte Kamera-Livebild ist vom Nutzer bestätigt. Personenerkennung am Pi,
|
||||||
|
GPIO-Steuerung, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen.
|
||||||
|
|
||||||
## Lokal ohne Hardware starten
|
## Lokal ohne Hardware starten
|
||||||
|
|
||||||
|
|||||||
+106
-2
@@ -4,8 +4,9 @@ Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
|||||||
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
||||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
||||||
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
||||||
Testereignisse und Profilkennungen. Automatische Personenerkennung und
|
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
|
||||||
Profilzuordnung per Kamera, Clips und Maskierung sind noch nicht umgesetzt.
|
am Pi installiert und kalibriert werden. Profilzuordnung per Kamera, Clips
|
||||||
|
und Maskierung sind noch nicht umgesetzt.
|
||||||
Die 30-Tage-Löschung ist für künftige Auto-Profile vorbereitet. Daher vorerst nur
|
Die 30-Tage-Löschung ist für künftige Auto-Profile vorbereitet. Daher vorerst nur
|
||||||
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
|
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
|
||||||
richten.
|
richten.
|
||||||
@@ -126,3 +127,106 @@ SSH-Tunnel und GPIOs müssen am echten Pi bzw. Aufstellort geprüft werden.
|
|||||||
|
|
||||||
Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf),
|
Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf),
|
||||||
[FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/).
|
[FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/).
|
||||||
|
|
||||||
|
## Personenerkennung und Linienauslösung
|
||||||
|
|
||||||
|
Nach Übertragung des aktuellen Codes im Projektordner auf dem Pi installieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/halloween
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3-opencv python3-numpy
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)"
|
||||||
|
.venv/bin/python tools/install_detection_model.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript lädt ausschließlich das unquantisierte Modell
|
||||||
|
`object_detection_nanodet_2022nov.onnx` des offiziellen OpenCV Zoo.
|
||||||
|
Version, URL, Größe und SHA-256 stehen in `halloween/nanodet-model.json`.
|
||||||
|
Das Modell liegt unter `data/models/`; ein bestehendes korrektes Modell wird
|
||||||
|
nicht erneut geladen. Download erfolgt nur auf diesen ausdrücklichen Aufruf,
|
||||||
|
HTTPS-Zertifikatsprüfung bleibt aktiv. Der Server lädt nichts aus dem Internet.
|
||||||
|
Bei abweichendem `HALLOWEEN_STATE_DIR` diesen beim Installer ebenfalls setzen
|
||||||
|
oder `--state-dir` angeben. Modellgewichte bleiben unversioniert und werden
|
||||||
|
nicht vom Upload überschrieben. Auf dem Pi das fertige Systempaket verwenden;
|
||||||
|
Picamera2 bleibt ebenfalls das Raspberry-Pi-Systempaket. OpenCV muss mindestens
|
||||||
|
4.10 sein; die tatsächliche installierte Version und Inferenz am Pi prüfen.
|
||||||
|
Für andere Entwicklungsrechner kann `requirements-detection.txt` genutzt werden:
|
||||||
|
Es erlaubt ausschließlich fertige Wheels und bricht ohne passende Pakete ab.
|
||||||
|
Ein vorheriger pip-Installationsversuch kann lokale Pakete hinterlassen, die
|
||||||
|
Systempakete überdecken; bei Importfehlern die ausgegebenen Pfade prüfen.
|
||||||
|
|
||||||
|
Server anschließend wie oben starten. In der Liveansicht:
|
||||||
|
|
||||||
|
1. Orange Linienendpunkte und die zwei grünen Bereichsecken ziehen. Die Linie
|
||||||
|
muss vollständig innerhalb des Rechtecks liegen. Außerhalb des privaten
|
||||||
|
Testbereichs darf die Auslösung nicht eingerichtet werden.
|
||||||
|
2. Mit „Richtung umkehren“ den Pfeil zum Grab ausrichten.
|
||||||
|
3. „Automatisch auslösen“ auswählen und „Linie und Bereich speichern“ drücken.
|
||||||
|
4. Mindestens zwei Sekunden den gesamten Bereich freihalten. Danach auf der
|
||||||
|
Ankunftsseite beginnen und über die Linie zum Grab gehen.
|
||||||
|
|
||||||
|
Zunächst bleibt die automatische Auslösung aus. Gespeicherte Konfiguration
|
||||||
|
und Aktivierung bleiben nach normalem Neustart erhalten, aber die
|
||||||
|
Bereichsfreigabe und Tracking-IDs werden immer neu aufgebaut. Bei ungültiger
|
||||||
|
Konfiguration bleibt die Automatik aus; der Fehler wird angezeigt.
|
||||||
|
|
||||||
|
Es werden nur Personen ausgewertet (Startschwelle 0,6). Mindestens drei
|
||||||
|
aufeinanderfolgende Erkennungen bestätigen einen Track. Der Fußpunkt ist die
|
||||||
|
Mitte der unteren Rahmenkante. Er muss von der Ankunftsseite über das endliche
|
||||||
|
Liniensegment auf die Grabseite wechseln, jeweils mindestens 3 % der Bildhöhe
|
||||||
|
von der Linie entfernt. Kurz auftauchende Personen hinter der Linie,
|
||||||
|
Rückwege, Bewegung entlang der Linie und Überquerungen außerhalb des
|
||||||
|
Segments lösen nichts aus. Verdeckung oder zu schnelles Überqueren kann
|
||||||
|
zur Nichterkennung führen; die Startwerte sind praktisch zu kalibrieren.
|
||||||
|
|
||||||
|
Eine Gruppe erhält einen Effekt: Nach Auslösung sind zehn Sekunden Sperrzeit
|
||||||
|
und mindestens zwei Sekunden freier Auslösebereich nötig. Auch unbestätigte
|
||||||
|
Personenrahmen im Rechteck verhindern die Freigabe. Überquerungen während
|
||||||
|
Sperre werden verworfen. Start, Moduswechsel, Rücksetzen und Änderungen der
|
||||||
|
Konfiguration verlangen erneut einen freien Bereich. Stopp bleibt wirksam;
|
||||||
|
ein manueller Testbutton umgeht keine Fehlersperre.
|
||||||
|
|
||||||
|
Kamera-/Modellfehler und mehr als zwei Sekunden ohne gültige Auswertung
|
||||||
|
sperren die aktivierte Automatik und stoppen den simulierten Ablauf. Nach
|
||||||
|
Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich
|
||||||
|
freihalten. Modellprobleme bei deaktivierter Automatik verhindern keine
|
||||||
|
manuellen Tests. Demo braucht weder OpenCV noch Modell; ihre synthetischen
|
||||||
|
Bilder sind kein Test der echten Personenerkennung.
|
||||||
|
|
||||||
|
Die Auswertung arbeitet im Hintergrund mit dem jeweils neuesten Bild, Ziel
|
||||||
|
fünf Auswertungen/s. Die UI zeigt die gemessene Verarbeitungsrate. Bei
|
||||||
|
Erkennungsergebnissen zeigt sie das zugehörige Bild statt des unabhängigen
|
||||||
|
MJPEG-Bildes, damit Rahmen und Bild zusammenpassen. Nach zwei Sekunden sind
|
||||||
|
Ergebnisse veraltet und die Rahmen verschwinden. Ohne Erkennungsbild bleibt
|
||||||
|
der normale MJPEG-Stream verfügbar. `/api/detection` liefert Frame-Nummer,
|
||||||
|
JPEG als Base64, normierte Personenrahmen, flüchtige Tracking-IDs und Status;
|
||||||
|
`GET/POST /api/detection/config` liest/speichert Aktivierung, Linie, Richtung
|
||||||
|
und Rechteck. `POST` benötigt wie andere Schreibaufrufe `X-Halloween-UI: 1`.
|
||||||
|
`/api/status` enthält zusätzlich `detection`. Der Header ersetzt keinen Login.
|
||||||
|
|
||||||
|
Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der
|
||||||
|
bestehenden Datenbank gespeichert. Weder Kamerabilder noch Tracking-IDs werden
|
||||||
|
automatisch gespeichert; Profil-Wiedererkennung und Altersentscheidung fehlen.
|
||||||
|
Aktoren bleiben ausschließlich simuliert.
|
||||||
|
|
||||||
|
### Abnahme am echten Pi (noch offen)
|
||||||
|
|
||||||
|
- Angezeigte Rate und Reaktionsverzögerung prüfen; Livebild und Stopp müssen
|
||||||
|
bedienbar bleiben. Zehn Annäherungen und zehn Rückwege ausprobieren.
|
||||||
|
- Gruppe, Verdeckung, Stehenbleiben, Linie entlanggehen, Neustart mit Person
|
||||||
|
im Bereich sowie Kamera-/Erkennungsausfall und bewusstes Rücksetzen testen.
|
||||||
|
- Danach Kostüme, tatsächliche Dunkelheit und IR-Beleuchtung am Aufstellort
|
||||||
|
testen. Nachtbetrieb und Zuverlässigkeit sind noch nicht bestätigt.
|
||||||
|
|
||||||
|
Modellgrundlage: [OpenCV Zoo NanoDet](https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet).
|
||||||
|
|
||||||
|
Optionaler echter Modell-Smoke-Test auf einem Rechner mit OpenCV und Modell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HALLOWEEN_TEST_MODEL="$PWD/data/models/object_detection_nanodet_2022nov.onnx" .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne diese Variable wird nur der optionale Modell-Smoke-Test übersprungen;
|
||||||
|
die Linien-/API-/Installer-Tests benötigen weder Modell noch OpenCV.
|
||||||
@@ -71,3 +71,21 @@ Projektdateien auf dem Pi: unter anderem `run.py`, `halloween/`, `tools/`,
|
|||||||
Übertragung sowie die Übereinstimmung mit dem aktuellen lokalen Stand sind
|
Übertragung sowie die Übereinstimmung mit dem aktuellen lokalen Stand sind
|
||||||
offen. Installation der Python-Pakete, WebUI-Start, Kamera, SSH-Tunnel und
|
offen. Installation der Python-Pakete, WebUI-Start, Kamera, SSH-Tunnel und
|
||||||
systemd-Autostart sind damit noch nicht bestätigt.
|
systemd-Autostart sind damit noch nicht bestätigt.
|
||||||
|
|
||||||
|
## Personenerkennung nach dem Upload einrichten
|
||||||
|
|
||||||
|
Die Modellgewichte und Erkennungseinstellungen liegen unter `data/` und bleiben
|
||||||
|
beim Upload erhalten. Bei der ersten Einrichtung nach Code-Übertragung auf dem
|
||||||
|
Pi die zusätzlichen Pakete und das geprüfte Modell ausdrücklich installieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/halloween
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3-opencv python3-numpy
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)"
|
||||||
|
.venv/bin/python tools/install_detection_model.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach den Server neu starten und Linie, Richtung, Bereich und Aktivierung in
|
||||||
|
der WebUI speichern. Details und Fehlerbehandlung in `SOFTWARE.md`.
|
||||||
@@ -99,6 +99,10 @@ class CameraStream:
|
|||||||
with self._condition:
|
with self._condition:
|
||||||
return self._number, self._frame
|
return self._number, self._frame
|
||||||
|
|
||||||
|
def latest_timed(self):
|
||||||
|
with self._condition:
|
||||||
|
return self._number, self._frame, self._time
|
||||||
|
|
||||||
def wait_next(self, number: int, timeout: float = 10):
|
def wait_next(self, number: int, timeout: float = 10):
|
||||||
with self._condition:
|
with self._condition:
|
||||||
self._condition.wait_for(
|
self._condition.wait_for(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Controller:
|
|||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.cooldown_until = 0.0
|
self.cooldown_until = 0.0
|
||||||
self.last_event = None
|
self.last_event = None
|
||||||
|
self.generation = 0
|
||||||
|
|
||||||
def _load_mode(self):
|
def _load_mode(self):
|
||||||
if not self._path.exists():
|
if not self._path.exists():
|
||||||
@@ -59,6 +60,7 @@ class Controller:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._save_mode(mode)
|
self._save_mode(mode)
|
||||||
if mode != self.mode:
|
if mode != self.mode:
|
||||||
|
self.generation += 1
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.zone_clear_seen = False
|
self.zone_clear_seen = False
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -94,12 +96,14 @@ class Controller:
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.stopped = True
|
self.stopped = True
|
||||||
|
self.generation += 1
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.zone_clear_seen = False
|
self.zone_clear_seen = False
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
|
self.generation += 1
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.zone_clear_seen = False
|
self.zone_clear_seen = False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""Lokale Personendetektion und zeitlich begrenzte Linienverfolgung."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {"enabled": False, "line": [[0.2, 0.55], [0.8, 0.55]],
|
||||||
|
"direction": 1, "region": [0.1, 0.15, 0.9, 0.95]}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(value):
|
||||||
|
if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool:
|
||||||
|
raise ValueError("Ungültige Erkennungseinstellungen")
|
||||||
|
if type(value["direction"]) is not int or value["direction"] not in (-1, 1):
|
||||||
|
raise ValueError("Richtung muss -1 oder 1 sein")
|
||||||
|
line, region = value["line"], value["region"]
|
||||||
|
if len(line) != 2 or any(len(p) != 2 for p in line) or len(region) != 4:
|
||||||
|
raise ValueError("Linie und Bereich benötigen gültige Koordinaten")
|
||||||
|
coords = [*line[0], *line[1], *region]
|
||||||
|
if any(type(x) not in (int, float) or not math.isfinite(x) or not 0 <= x <= 1 for x in coords):
|
||||||
|
raise ValueError("Koordinaten müssen zwischen 0 und 1 liegen")
|
||||||
|
x1, y1, x2, y2 = region
|
||||||
|
if x2 - x1 < 0.05 or y2 - y1 < 0.05:
|
||||||
|
raise ValueError("Auslösebereich ist zu klein")
|
||||||
|
if math.dist(*line) < 0.05 or any(not (x1 <= x <= x2 and y1 <= y <= y2) for x, y in line):
|
||||||
|
raise ValueError("Linie muss im Auslösebereich liegen und ausreichend lang sein")
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
class NanoDet:
|
||||||
|
"""ONNX-Decoder für OpenCV Zoo NanoDet-m-plus-1.5x_416 (November 2022)."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
manifest = json.loads((Path(__file__).parent / "nanodet-model.json").read_text())
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError("NanoDet fehlt; tools/install_detection_model.py ausführen")
|
||||||
|
if hashlib.sha256(path.read_bytes()).hexdigest() != manifest["sha256"]:
|
||||||
|
raise ValueError("NanoDet-Prüfsumme stimmt nicht")
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
self.cv, self.np = cv2, np
|
||||||
|
self.net = cv2.dnn.readNetFromONNX(str(path))
|
||||||
|
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
|
||||||
|
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
|
||||||
|
|
||||||
|
def detect(self, jpeg):
|
||||||
|
cv, np = self.cv, self.np
|
||||||
|
rgb = np.asarray(Image.open(BytesIO(jpeg)).convert("RGB"))
|
||||||
|
h, w = rgb.shape[:2]
|
||||||
|
scale = min(416 / w, 416 / h)
|
||||||
|
nw, nh = round(w * scale), round(h * scale)
|
||||||
|
left, top = (416 - nw) // 2, (416 - nh) // 2
|
||||||
|
image = np.zeros((416, 416, 3), dtype=np.float32)
|
||||||
|
image[:] = [103.53, 116.28, 123.675]
|
||||||
|
image[top:top+nh, left:left+nw] = cv.resize(rgb[:, :, ::-1], (nw, nh))
|
||||||
|
image = (image - np.array([103.53, 116.28, 123.675], np.float32)) / np.array([57.375, 57.12, 58.395], np.float32)
|
||||||
|
self.net.setInput(cv.dnn.blobFromImage(image))
|
||||||
|
outputs = self.net.forward(self.net.getUnconnectedOutLayersNames())
|
||||||
|
if len(outputs) != 6:
|
||||||
|
raise ValueError("Unerwartete NanoDet-Ausgänge")
|
||||||
|
boxes, scores = [], []
|
||||||
|
for level, stride in enumerate((8, 16, 32)):
|
||||||
|
classes = outputs[2 * level].reshape(-1, 80)
|
||||||
|
logits = outputs[2 * level + 1].reshape(-1, 4, 8)
|
||||||
|
side = 416 // stride
|
||||||
|
if len(classes) != side * side or len(logits) != len(classes):
|
||||||
|
raise ValueError("Unerwartete NanoDet-Dimensionen")
|
||||||
|
candidates = np.flatnonzero((classes[:, 0] >= 0.6) & (classes.argmax(axis=1) == 0))
|
||||||
|
for index in candidates:
|
||||||
|
values = logits[index]
|
||||||
|
weights = np.exp(values - values.max(axis=1, keepdims=True))
|
||||||
|
distances = (weights / weights.sum(axis=1, keepdims=True)) @ np.arange(8) * stride
|
||||||
|
cx = (index % side) * stride + (stride - 1) / 2
|
||||||
|
cy = (index // side) * stride + (stride - 1) / 2
|
||||||
|
x1 = max(0., min(1., (cx - distances[0] - left) / (scale * w)))
|
||||||
|
y1 = max(0., min(1., (cy - distances[1] - top) / (scale * h)))
|
||||||
|
x2 = max(0., min(1., (cx + distances[2] - left) / (scale * w)))
|
||||||
|
y2 = max(0., min(1., (cy + distances[3] - top) / (scale * h)))
|
||||||
|
if x2 > x1 and y2 > y1:
|
||||||
|
boxes.append([float(x1), float(y1), float(x2 - x1), float(y2 - y1)])
|
||||||
|
scores.append(float(classes[index, 0]))
|
||||||
|
indices = cv.dnn.NMSBoxes(boxes, scores, 0.6, 0.6)
|
||||||
|
return [{"box": [boxes[i][0], boxes[i][1], boxes[i][0]+boxes[i][2], boxes[i][1]+boxes[i][3]],
|
||||||
|
"score": scores[i]} for i in np.asarray(indices).reshape(-1)]
|
||||||
|
|
||||||
|
|
||||||
|
class LineTracker:
|
||||||
|
def __init__(self):
|
||||||
|
self.tracks = {}
|
||||||
|
self.next_id = 1
|
||||||
|
|
||||||
|
def update(self, people, config, now, aspect=4/3):
|
||||||
|
# Fehlende Frames unterbrechen die Bestätigung; kurze Lücken behalten
|
||||||
|
# lediglich die räumliche Identität, niemals einen alten Linienübergang.
|
||||||
|
old = {k: v for k, v in self.tracks.items() if now - v["seen"] <= 0.8}
|
||||||
|
available = set(old)
|
||||||
|
result, crossed = [], False
|
||||||
|
a, b = config["line"]
|
||||||
|
ax, ay, bx, by = a[0]*aspect, a[1], b[0]*aspect, b[1]
|
||||||
|
dx, dy = bx-ax, by-ay
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
for person in people:
|
||||||
|
x1, y1, x2, y2 = person["box"]
|
||||||
|
point = ((x1+x2)/2, y2)
|
||||||
|
def distance(key):
|
||||||
|
p = old[key]["point"]
|
||||||
|
return math.hypot((point[0]-p[0])*aspect, point[1]-p[1])
|
||||||
|
key = min(available, key=distance) if available else None
|
||||||
|
if key is None or distance(key) > 0.25:
|
||||||
|
key = self.next_id
|
||||||
|
self.next_id += 1
|
||||||
|
track = {"count": 0, "side": None, "point": point}
|
||||||
|
else:
|
||||||
|
available.remove(key)
|
||||||
|
track = old[key]
|
||||||
|
previous = track["point"]
|
||||||
|
track["count"] += 1
|
||||||
|
px, py = point[0]*aspect, point[1]
|
||||||
|
signed = ((dx*(py-ay)-dy*(px-ax))/length) * config["direction"]
|
||||||
|
side = -1 if signed < -0.03 else (1 if signed > 0.03 else 0)
|
||||||
|
rx1, ry1, rx2, ry2 = config["region"]
|
||||||
|
inside = rx1 <= point[0] <= rx2 and ry1 <= point[1] <= ry2
|
||||||
|
if track["count"] >= 3 and inside:
|
||||||
|
if side == 1 and track["side"] == -1:
|
||||||
|
# Schnitt der Bewegung mit dem endlichen Liniensegment.
|
||||||
|
start = track.get("arrival", previous)
|
||||||
|
sx, sy = start[0]*aspect, start[1]
|
||||||
|
vx, vy = px-sx, py-sy
|
||||||
|
den = vx*dy-vy*dx
|
||||||
|
if abs(den) > 1e-9:
|
||||||
|
t = ((ax-sx)*dy-(ay-sy)*dx)/den
|
||||||
|
u = ((ax-sx)*vy-(ay-sy)*vx)/den
|
||||||
|
crossed |= 0 <= t <= 1 and 0 <= u <= 1
|
||||||
|
if side:
|
||||||
|
track["side"] = side
|
||||||
|
if side == -1:
|
||||||
|
track["arrival"] = point
|
||||||
|
elif not inside:
|
||||||
|
track["side"] = None
|
||||||
|
track.update(point=point, seen=now)
|
||||||
|
result.append({**person, "track_id": key, "confirmed": track["count"] >= 3})
|
||||||
|
old[key] = track
|
||||||
|
active = {p["track_id"] for p in result}
|
||||||
|
for key in set(old)-active:
|
||||||
|
old[key].update(count=0, side=None)
|
||||||
|
self.tracks = old
|
||||||
|
return result, crossed
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionService:
|
||||||
|
def __init__(self, camera, controller, events, directory, detector_factory=NanoDet, clock=time.monotonic):
|
||||||
|
self.camera, self.controller, self.events = camera, controller, events
|
||||||
|
self.clock, self.factory = clock, detector_factory
|
||||||
|
self.path = directory / "detection.json"
|
||||||
|
self.model_path = directory / "models" / "object_detection_nanodet_2022nov.onnx"
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
self.thread = None
|
||||||
|
self.error = None
|
||||||
|
self.fault = False
|
||||||
|
self.config_error = None
|
||||||
|
self.config = json.loads(json.dumps(DEFAULT_CONFIG))
|
||||||
|
try:
|
||||||
|
if self.path.exists():
|
||||||
|
self.config = validate_config(json.loads(self.path.read_text()))
|
||||||
|
except (ValueError, TypeError, KeyError, OSError) as exc:
|
||||||
|
self.config_error = f"Erkennungseinstellungen beschädigt: {exc}"
|
||||||
|
self.revision = 0
|
||||||
|
self.reset_state()
|
||||||
|
self.last_valid = None
|
||||||
|
self.started = self.clock()
|
||||||
|
self.fps = 0.
|
||||||
|
self.result = {"frame_number": 0, "people": [], "image_base64": None}
|
||||||
|
|
||||||
|
def reset_state(self):
|
||||||
|
self.revision += 1
|
||||||
|
self.tracker = LineTracker()
|
||||||
|
self.free_since = None
|
||||||
|
self.armed = False
|
||||||
|
self.generation = self.controller.generation
|
||||||
|
|
||||||
|
def configure(self, value):
|
||||||
|
config = validate_config(value)
|
||||||
|
with self.lock:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", dir=self.path.parent, delete=False) as file:
|
||||||
|
temp = Path(file.name)
|
||||||
|
try:
|
||||||
|
json.dump(config, file)
|
||||||
|
file.flush()
|
||||||
|
os.fsync(file.fileno())
|
||||||
|
except Exception:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
os.replace(temp, self.path)
|
||||||
|
finally:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
self.config = config
|
||||||
|
self.config_error = None
|
||||||
|
self.reset_state()
|
||||||
|
self.started = self.clock()
|
||||||
|
self.last_valid = None
|
||||||
|
self.result = {"frame_number": 0, "people": [], "image_base64": None}
|
||||||
|
return self.get_config()
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
with self.lock:
|
||||||
|
return json.loads(json.dumps(self.config))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
with self.lock:
|
||||||
|
self.fault = False
|
||||||
|
self.error = None
|
||||||
|
self.reset_state()
|
||||||
|
self.started = self.clock()
|
||||||
|
self.last_valid = None
|
||||||
|
|
||||||
|
def fail(self, message):
|
||||||
|
with self.lock:
|
||||||
|
self.error = message
|
||||||
|
self.armed = False
|
||||||
|
self.free_since = None
|
||||||
|
if self.config["enabled"]:
|
||||||
|
self.fault = True
|
||||||
|
self.controller.stop()
|
||||||
|
|
||||||
|
def watchdog(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.config["enabled"] and not self.fault:
|
||||||
|
if self.camera.error:
|
||||||
|
self.fail(self.camera.error)
|
||||||
|
elif self.clock() - (self.last_valid if self.last_valid is not None else self.started) > 2:
|
||||||
|
self.fail("Keine gültige Erkennung seit mehr als zwei Sekunden; zurücksetzen")
|
||||||
|
|
||||||
|
def process(self, people, number, jpeg, captured_at, version):
|
||||||
|
event_mode = None
|
||||||
|
with self.lock:
|
||||||
|
now = self.clock()
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
return
|
||||||
|
if self.controller.generation != self.generation:
|
||||||
|
self.reset_state()
|
||||||
|
return
|
||||||
|
if version != self.revision:
|
||||||
|
return
|
||||||
|
if now - captured_at > 2:
|
||||||
|
self.fail("Erkennungsbild ist veraltet; zurücksetzen")
|
||||||
|
return
|
||||||
|
self.last_valid = now
|
||||||
|
if not self.fault:
|
||||||
|
self.error = None
|
||||||
|
image = Image.open(BytesIO(jpeg))
|
||||||
|
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height)
|
||||||
|
self.result = {"frame_number": number, "people": people,
|
||||||
|
"image_base64": base64.b64encode(jpeg).decode("ascii")}
|
||||||
|
if not self.config["enabled"] or self.fault or self.controller.stopped:
|
||||||
|
self.armed = False
|
||||||
|
self.free_since = None
|
||||||
|
return
|
||||||
|
rx1, ry1, rx2, ry2 = self.config["region"]
|
||||||
|
# Auch unbestätigte Personenrahmen verhindern eine falsche Freigabe.
|
||||||
|
occupied = any(p["box"][0] <= rx2 and p["box"][2] >= rx1 and
|
||||||
|
p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people)
|
||||||
|
if occupied:
|
||||||
|
self.free_since = None
|
||||||
|
else:
|
||||||
|
if self.free_since is None:
|
||||||
|
self.free_since = now
|
||||||
|
if now-self.free_since >= 2 and self.controller.status()["cooldown_seconds"] == 0:
|
||||||
|
self.armed = True
|
||||||
|
if crossing and self.armed:
|
||||||
|
# Controller bleibt die gemeinsame letzte Schranke, unabhängig
|
||||||
|
# von manueller Bereichsfreigabe oder parallelem Browser-Stopp.
|
||||||
|
self.controller.zone_clear()
|
||||||
|
accepted, _ = self.controller.trigger()
|
||||||
|
self.armed = False
|
||||||
|
self.free_since = None
|
||||||
|
if accepted:
|
||||||
|
event_mode = self.controller.last_event["mode"]
|
||||||
|
# SQLite darf UI und unabhängigen Watchdog nicht blockieren.
|
||||||
|
if event_mode is not None:
|
||||||
|
try:
|
||||||
|
self.events.record_detection_trigger(event_mode)
|
||||||
|
except Exception as exc:
|
||||||
|
self.fail(f"Ereignis nicht gespeichert: {exc}")
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self.lock:
|
||||||
|
age = None if self.last_valid is None else self.clock()-self.last_valid
|
||||||
|
return {"enabled": self.config["enabled"], "ready": age is not None and age <= 2 and not self.error,
|
||||||
|
"error": self.config_error or self.error, "reset_required": self.fault,
|
||||||
|
"armed": self.armed and not self.fault and not self.controller.stopped
|
||||||
|
and self.controller.generation == self.generation
|
||||||
|
and self.controller.status()["cooldown_seconds"] == 0,
|
||||||
|
"fps": round(self.fps, 2), "result_age_seconds": age}
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
with self.lock:
|
||||||
|
status = self.status()
|
||||||
|
fresh = status["ready"]
|
||||||
|
return {**self.result, "people": self.result["people"] if fresh else [],
|
||||||
|
"image_base64": self.result["image_base64"] if fresh else None,
|
||||||
|
"status": status}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.started = self.clock()
|
||||||
|
self.thread = threading.Thread(target=self._run, name="person-detection", daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stop_event.set()
|
||||||
|
self.controller.stop()
|
||||||
|
if self.thread:
|
||||||
|
self.thread.join(timeout=3)
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
detector, last_number, last_completed = None, 0, None
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
began = self.clock()
|
||||||
|
try:
|
||||||
|
if detector is None:
|
||||||
|
detector = self.factory(self.model_path)
|
||||||
|
with self.lock:
|
||||||
|
if self.controller.generation != self.generation:
|
||||||
|
self.reset_state()
|
||||||
|
version = self.revision
|
||||||
|
number, jpeg, captured_at = self.camera.latest_timed()
|
||||||
|
if jpeg is not None and number != last_number:
|
||||||
|
last_number = number
|
||||||
|
people = detector.detect(jpeg)
|
||||||
|
self.process(people, number, jpeg, captured_at, version)
|
||||||
|
completed = self.clock()
|
||||||
|
with self.lock:
|
||||||
|
self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed)
|
||||||
|
last_completed = completed
|
||||||
|
except Exception as exc:
|
||||||
|
self.fail(f"Personenerkennung nicht verfügbar: {exc}")
|
||||||
|
detector = None
|
||||||
|
self.stop_event.wait(1)
|
||||||
|
self.stop_event.wait(max(0, 0.2-(self.clock()-began)))
|
||||||
@@ -96,12 +96,18 @@ class EventStore:
|
|||||||
return connection
|
return connection
|
||||||
|
|
||||||
def record_test_trigger(self, mode: str) -> int:
|
def record_test_trigger(self, mode: str) -> int:
|
||||||
|
return self._record_trigger(mode, "test_trigger")
|
||||||
|
|
||||||
|
def record_detection_trigger(self, mode: str) -> int:
|
||||||
|
return self._record_trigger(mode, "person_line_crossing")
|
||||||
|
|
||||||
|
def _record_trigger(self, mode: str, kind: str) -> int:
|
||||||
occurred_at = _timestamp(_utc_now())
|
occurred_at = _timestamp(_utc_now())
|
||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
with connection:
|
with connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
|
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
|
||||||
(occurred_at, "test_trigger", mode),
|
(occurred_at, kind, mode),
|
||||||
)
|
)
|
||||||
return cursor.lastrowid
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|||||||
+123
-8
@@ -15,6 +15,9 @@
|
|||||||
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
|
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
|
||||||
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
||||||
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
||||||
|
.camera-wrap { position: relative; aspect-ratio: 4/3; }
|
||||||
|
.camera-wrap .camera { position: absolute; inset: 0; height: 100%; }
|
||||||
|
#detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
|
||||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
|
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
|
||||||
button, select, a.download { cursor: pointer; }
|
button, select, a.download { cursor: pointer; }
|
||||||
@@ -46,10 +49,21 @@
|
|||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<h2>Livebild</h2>
|
<h2>Livebild</h2>
|
||||||
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
<div class="camera-wrap">
|
||||||
|
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||||||
|
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
|
||||||
|
</div>
|
||||||
|
<h2 style="margin-top:1rem">Personenerkennung</h2>
|
||||||
|
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
|
||||||
|
<div class="row">
|
||||||
|
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
|
||||||
|
<button id="reverse-direction">Richtung umkehren</button>
|
||||||
|
<button id="save-detection">Linie und Bereich speichern</button>
|
||||||
|
</div>
|
||||||
|
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
|
||||||
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
|
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
|
||||||
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
|
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
|
||||||
<p class="notice">Aufnahmen und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
|
<p class="notice">Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Betrieb</h2>
|
<h2>Betrieb</h2>
|
||||||
@@ -57,7 +71,7 @@
|
|||||||
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
||||||
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
|
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
|
||||||
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
|
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
|
||||||
<dt>Letzter Test</dt><dd id="last-event">–</dd>
|
<dt>Letzte Auslösung</dt><dd id="last-event">–</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<label for="mode">Modus wählen</label>
|
<label for="mode">Modus wählen</label>
|
||||||
<div class="row" style="margin: .5rem 0 1rem">
|
<div class="row" style="margin: .5rem 0 1rem">
|
||||||
@@ -69,7 +83,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button id="save-mode">Übernehmen</button>
|
<button id="save-mode">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Es gibt noch keine automatische Personenauslösung.</p>
|
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="zone-clear">Bereich frei (Test)</button>
|
<button id="zone-clear">Bereich frei (Test)</button>
|
||||||
<button id="test-trigger">Testauslösung</button>
|
<button id="test-trigger">Testauslösung</button>
|
||||||
@@ -81,8 +95,8 @@
|
|||||||
<p id="message" role="status" aria-live="polite"></p>
|
<p id="message" role="status" aria-live="polite"></p>
|
||||||
</section>
|
</section>
|
||||||
<section class="events">
|
<section class="events">
|
||||||
<h2>Gespeicherte Testereignisse</h2>
|
<h2>Gespeicherte Ereignisse</h2>
|
||||||
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||||
<ul id="events-list"><li>Lädt …</li></ul>
|
<ul id="events-list"><li>Lädt …</li></ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="events">
|
<section class="events">
|
||||||
@@ -171,13 +185,13 @@
|
|||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!data.events.length) {
|
if (!data.events.length) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.textContent = 'Noch keine Testauslösung gespeichert';
|
item.textContent = 'Noch keine Auslösung gespeichert';
|
||||||
list.append(item);
|
list.append(item);
|
||||||
}
|
}
|
||||||
for (const event of data.events) {
|
for (const event of data.events) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
const summary = document.createElement('span');
|
const summary = document.createElement('span');
|
||||||
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
|
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
|
||||||
const tools = document.createElement('div');
|
const tools = document.createElement('div');
|
||||||
tools.className = 'row event-tools';
|
tools.className = 'row event-tools';
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
@@ -220,6 +234,107 @@
|
|||||||
await refreshDb();
|
await refreshDb();
|
||||||
} catch (error) { $('message').textContent = error.message; }
|
} catch (error) { $('message').textContent = error.message; }
|
||||||
};
|
};
|
||||||
|
let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0;
|
||||||
|
let detectionEpoch = 0;
|
||||||
|
const overlay = $('detection-overlay'), context = overlay.getContext('2d');
|
||||||
|
function drawDetection() {
|
||||||
|
context.clearRect(0, 0, 640, 480);
|
||||||
|
if (!detectionConfig) return;
|
||||||
|
const c = detectionConfig, [x1,y1,x2,y2] = c.region;
|
||||||
|
context.lineWidth = 3; context.strokeStyle = '#78d9a2';
|
||||||
|
context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480);
|
||||||
|
const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480;
|
||||||
|
context.strokeStyle = '#ffb44f'; context.beginPath();
|
||||||
|
context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke();
|
||||||
|
const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240;
|
||||||
|
const nx = -dy/length*c.direction, ny = dx/length*c.direction;
|
||||||
|
context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40);
|
||||||
|
context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40);
|
||||||
|
context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke();
|
||||||
|
for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) {
|
||||||
|
context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill();
|
||||||
|
}
|
||||||
|
if (performance.now()-lastImageAt > 2000) return;
|
||||||
|
for (const person of people) {
|
||||||
|
const [l,t,r,bottom] = person.box;
|
||||||
|
context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6';
|
||||||
|
context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480);
|
||||||
|
context.fillStyle = context.strokeStyle; context.font = '16px sans-serif';
|
||||||
|
context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pointer(event) {
|
||||||
|
const rect = overlay.getBoundingClientRect();
|
||||||
|
return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),
|
||||||
|
Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))];
|
||||||
|
}
|
||||||
|
overlay.onpointerdown = event => {
|
||||||
|
if (!detectionConfig) return;
|
||||||
|
const p = pointer(event), c = detectionConfig;
|
||||||
|
const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)];
|
||||||
|
const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480));
|
||||||
|
const index = distances.indexOf(Math.min(...distances));
|
||||||
|
if (distances[index] > 30) return;
|
||||||
|
drag = index; overlay.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
overlay.onpointermove = event => {
|
||||||
|
if (drag === null) return;
|
||||||
|
const p = pointer(event), c = detectionConfig;
|
||||||
|
if (drag < 2) c.line[drag] = p;
|
||||||
|
else c.region.splice((drag-2)*2,2,...p);
|
||||||
|
drawDetection();
|
||||||
|
};
|
||||||
|
overlay.onpointerup = overlay.onpointercancel = () => { drag = null; };
|
||||||
|
$('reverse-direction').onclick = () => {
|
||||||
|
if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); }
|
||||||
|
};
|
||||||
|
$('save-detection').onclick = async () => {
|
||||||
|
if (!detectionConfig) return;
|
||||||
|
try {
|
||||||
|
detectionConfig.enabled = $('detection-enabled').checked;
|
||||||
|
detectionConfig = await api('/api/detection/config', detectionConfig);
|
||||||
|
detectionEpoch++; people = []; drawDetection();
|
||||||
|
$('message').textContent = 'Gespeichert; freien Auslösebereich abwarten';
|
||||||
|
} catch (error) { $('message').textContent = error.message; }
|
||||||
|
};
|
||||||
|
async function refreshDetection() {
|
||||||
|
const epoch = detectionEpoch, requestedAt = performance.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/detection', {cache:'no-store'});
|
||||||
|
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
|
||||||
|
const data = await response.json(), status = data.status;
|
||||||
|
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
|
||||||
|
$('detection-status').textContent = status.error ||
|
||||||
|
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') +
|
||||||
|
' · '+status.fps+' Auswertungen/s';
|
||||||
|
if (data.image_base64) {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = 'data:image/jpeg;base64,'+data.image_base64;
|
||||||
|
await image.decode();
|
||||||
|
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
|
||||||
|
$('live-image').src = image.src;
|
||||||
|
await $('live-image').decode();
|
||||||
|
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
|
||||||
|
processed = true; people = data.people;
|
||||||
|
// Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten.
|
||||||
|
lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000;
|
||||||
|
} else {
|
||||||
|
people = [];
|
||||||
|
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
|
||||||
|
}
|
||||||
|
drawDetection();
|
||||||
|
} catch (error) {
|
||||||
|
people = []; drawDetection();
|
||||||
|
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
|
||||||
|
$('detection-status').textContent = error.message;
|
||||||
|
}
|
||||||
|
setTimeout(refreshDetection, 200);
|
||||||
|
}
|
||||||
|
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
|
||||||
|
detectionConfig = c; $('detection-enabled').checked = c.enabled; drawDetection();
|
||||||
|
}).catch(error => { $('detection-status').textContent = error.message; });
|
||||||
|
refreshDetection();
|
||||||
|
setInterval(drawDetection, 250);
|
||||||
refresh();
|
refresh();
|
||||||
refreshDb();
|
refreshDb();
|
||||||
setInterval(refresh, 1000);
|
setInterval(refresh, 1000);
|
||||||
|
|||||||
@@ -35,3 +35,21 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
|
|||||||
|
|
||||||
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
|
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
|
||||||
unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
|
unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
|
||||||
|
|
||||||
|
## Personendetektion
|
||||||
|
|
||||||
|
- `detection.py` wertet den gemeinsamen Kamerastream mit OpenCV DNN/NanoDet aus.
|
||||||
|
Optionales Modell und OpenCV fehlen im Demo-Grundsetup bewusst. Kein Download
|
||||||
|
im Server; geprüfte Modellmetadaten in `nanodet-model.json`.
|
||||||
|
- Nur neue, zeitlich gültige Frames auswerten. Hintergrundinferenz hält keine
|
||||||
|
UI-/Zustandssperre; separater Watchdog sperrt aktivierte Automatik nach zwei
|
||||||
|
Sekunden ohne gültiges Ergebnis. Fehler verlangen bewusstes Rücksetzen.
|
||||||
|
- Linienüberquerung nutzt bestätigte flüchtige Tracks und Fußpunkte, Richtung,
|
||||||
|
endliches Segment und 3-%-Hysterese. Gruppen verlangen freien Bereich und
|
||||||
|
Sperrzeit; Konfigurations-/Controllerwechsel verwerfen alte Beobachtungen.
|
||||||
|
- `/api/detection` liefert synchrones Bild und Rahmen; `/api/detection/config`
|
||||||
|
speichert normierte Geometrie und Aktivierung unter `data/`. Erste Aktivierung
|
||||||
|
ist bewusst, nach Neustart immer neue Bereichsfreigabe. UI zeichnet/editiert
|
||||||
|
mit Canvas; keine Tracking-ID als Besucherprofil verwenden.
|
||||||
|
- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung.
|
||||||
|
Die vorhandene Steuerung bleibt ausschließlich simuliert.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "NanoDet-m-plus-1.5x_416",
|
||||||
|
"file": "object_detection_nanodet_2022nov.onnx",
|
||||||
|
"revision": "47534e27c9851bb1128ccc0102f1145e27f23f98",
|
||||||
|
"sha256": "4b82da9944b88577175ee23a459dce2e26e6e4be573def65b1055dc2d9720186",
|
||||||
|
"size": 3800954,
|
||||||
|
"url": "https://media.githubusercontent.com/media/opencv/opencv_zoo/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet/object_detection_nanodet_2022nov.onnx",
|
||||||
|
"source": "https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet"
|
||||||
|
}
|
||||||
+35
-1
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
|
|||||||
from .camera import CameraStream
|
from .camera import CameraStream
|
||||||
from .controller import Controller, MODES
|
from .controller import Controller, MODES
|
||||||
from .event_store import EventStore
|
from .event_store import EventStore
|
||||||
|
from .detection import DetectionService
|
||||||
|
|
||||||
|
|
||||||
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||||
@@ -43,6 +44,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
camera = CameraStream(demo=demo)
|
camera = CameraStream(demo=demo)
|
||||||
controller = Controller(directory)
|
controller = Controller(directory)
|
||||||
events = EventStore(directory)
|
events = EventStore(directory)
|
||||||
|
detection = DetectionService(camera, controller, events, directory)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -54,11 +56,23 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden")
|
LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden")
|
||||||
|
|
||||||
|
async def detection_watchdog():
|
||||||
|
while True:
|
||||||
|
detection.watchdog()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
camera.start()
|
camera.start()
|
||||||
|
detection.start()
|
||||||
|
watchdog_task = asyncio.create_task(detection_watchdog())
|
||||||
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
controller.stop()
|
||||||
|
watchdog_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await watchdog_task
|
||||||
|
detection.stop()
|
||||||
cleanup_task.cancel()
|
cleanup_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await cleanup_task
|
await cleanup_task
|
||||||
@@ -68,6 +82,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
app.state.camera = camera
|
app.state.camera = camera
|
||||||
app.state.controller = controller
|
app.state.controller = controller
|
||||||
app.state.events = events
|
app.state.events = events
|
||||||
|
app.state.detection = detection
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers(request: Request, call_next):
|
async def security_headers(request: Request, call_next):
|
||||||
@@ -84,7 +99,25 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
async def status():
|
async def status():
|
||||||
return {"controller": controller.status(), "camera": camera.status()}
|
return {"controller": controller.status(), "camera": camera.status(),
|
||||||
|
"detection": detection.status()}
|
||||||
|
|
||||||
|
@app.get("/api/detection")
|
||||||
|
async def detection_result():
|
||||||
|
return detection.snapshot()
|
||||||
|
|
||||||
|
@app.get("/api/detection/config")
|
||||||
|
async def detection_config():
|
||||||
|
return detection.get_config()
|
||||||
|
|
||||||
|
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
|
||||||
|
async def configure_detection(payload: dict):
|
||||||
|
try:
|
||||||
|
return detection.configure(payload)
|
||||||
|
except (ValueError, TypeError, KeyError, IndexError) as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def recent_events(limit: int = Query(20, ge=1, le=100)):
|
async def recent_events(limit: int = Query(20, ge=1, le=100)):
|
||||||
@@ -165,6 +198,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
@app.post("/api/reset", dependencies=[Depends(require_ui)])
|
@app.post("/api/reset", dependencies=[Depends(require_ui)])
|
||||||
async def reset():
|
async def reset():
|
||||||
controller.reset()
|
controller.reset()
|
||||||
|
detection.reset()
|
||||||
return controller.status()
|
return controller.status()
|
||||||
|
|
||||||
@app.get("/snapshot.jpg")
|
@app.get("/snapshot.jpg")
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Separat installieren; Demo und manuelle Tests benötigen kein OpenCV.
|
||||||
|
# Desktop-Wheels vermeiden, damit Picamera2/Qt nicht beeinflusst wird.
|
||||||
|
# Ohne passende Wheels abbrechen statt auf dem Pi aus Quellen zu kompilieren.
|
||||||
|
--only-binary=:all:
|
||||||
|
opencv-python-headless>=4.10,<5
|
||||||
|
numpy>=1.26,<3
|
||||||
@@ -14,3 +14,13 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
|
|||||||
aus einem grünen Host-Test ableiten; diese Prüfungen getrennt berichten.
|
aus einem grünen Host-Test ableiten; diese Prüfungen getrennt berichten.
|
||||||
- Tests für neue Sicherheits- oder Zustandsregeln an beobachtbarem Verhalten
|
- Tests für neue Sicherheits- oder Zustandsregeln an beobachtbarem Verhalten
|
||||||
ausrichten, nicht bloß interne Implementierung spiegeln.
|
ausrichten, nicht bloß interne Implementierung spiegeln.
|
||||||
|
|
||||||
|
- `test_detection.py` prüft Linienrichtung, Bestätigung, Flattern, Segmentgrenzen,
|
||||||
|
Gruppenfreigabe, Sperrzeit, Neustart/Änderungen, Fehler-Watchdog, synchrone
|
||||||
|
Frames und Konfigurations-API mit kontrollierter Uhr ohne Modell/Pi.
|
||||||
|
- Echte NanoDet-Inferenz zusätzlich mit ausdrücklich installiertem Modell
|
||||||
|
prüfen; ein grüner Geometrietest bestätigt keine Erkennungsqualität.
|
||||||
|
|
||||||
|
- `test_detection_model.py` prüft atomare/identische Installation und Ablehnung
|
||||||
|
falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine
|
||||||
|
geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import asyncio
|
||||||
|
from io import BytesIO
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from halloween.controller import Controller
|
||||||
|
from halloween.detection import DEFAULT_CONFIG, DetectionService, LineTracker, NanoDet, validate_config
|
||||||
|
from halloween.event_store import EventStore
|
||||||
|
from halloween.web import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def person(x=0.5, y=0.4):
|
||||||
|
return {"box": [x-0.03, y-0.1, x+0.03, y], "score": 0.9}
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tracker = LineTracker()
|
||||||
|
self.config = json.loads(json.dumps(DEFAULT_CONFIG))
|
||||||
|
self.time = 0
|
||||||
|
|
||||||
|
def step(self, people):
|
||||||
|
self.time += 0.2
|
||||||
|
return self.tracker.update(people, self.config, self.time)[1]
|
||||||
|
|
||||||
|
def test_forward_and_reverse(self):
|
||||||
|
for _ in range(3):
|
||||||
|
self.assertFalse(self.step([person()]))
|
||||||
|
self.assertTrue(self.step([person(y=0.65)]))
|
||||||
|
self.assertFalse(self.step([person(y=0.4)]))
|
||||||
|
self.assertFalse(self.step([person(y=0.4)]))
|
||||||
|
|
||||||
|
def test_reverse_direction_configuration(self):
|
||||||
|
self.config['direction'] = -1
|
||||||
|
for _ in range(3):
|
||||||
|
self.assertFalse(self.step([person(y=0.65)]))
|
||||||
|
self.assertTrue(self.step([person(y=0.4)]))
|
||||||
|
|
||||||
|
def test_appearance_behind_line(self):
|
||||||
|
for _ in range(5):
|
||||||
|
self.assertFalse(self.step([person(y=0.65)]))
|
||||||
|
|
||||||
|
def test_jitter_and_parallel_motion(self):
|
||||||
|
for y in [0.54, 0.56, 0.54, 0.56, 0.54, 0.56]:
|
||||||
|
self.assertFalse(self.step([person(y=y)]))
|
||||||
|
for x in [0.45, 0.5, 0.55, 0.6]:
|
||||||
|
self.assertFalse(self.step([person(x=x, y=0.4)]))
|
||||||
|
|
||||||
|
def test_flicker_and_outside_segment(self):
|
||||||
|
for _ in range(3):
|
||||||
|
self.step([person()])
|
||||||
|
self.step([])
|
||||||
|
self.assertFalse(self.step([person(y=0.65)]))
|
||||||
|
self.setUp()
|
||||||
|
for _ in range(3):
|
||||||
|
self.step([person(x=0.15)])
|
||||||
|
self.assertFalse(self.step([person(x=0.15, y=0.65)]))
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.folder = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.folder.cleanup)
|
||||||
|
self.directory = Path(self.folder.name)
|
||||||
|
self.time = 0
|
||||||
|
self.controller = Controller(self.directory, clock=lambda: self.time)
|
||||||
|
self.events = EventStore(self.directory)
|
||||||
|
self.camera = type('Camera', (), {'error': None})()
|
||||||
|
self.service = DetectionService(self.camera, self.controller, self.events,
|
||||||
|
self.directory, clock=lambda: self.time)
|
||||||
|
self.service.configure({**DEFAULT_CONFIG, 'enabled': True})
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.new('RGB', (640, 480)).save(buffer, format='JPEG')
|
||||||
|
self.jpeg = buffer.getvalue()
|
||||||
|
self.number = 0
|
||||||
|
|
||||||
|
def step(self, people=(), dt=0.2):
|
||||||
|
self.time += dt
|
||||||
|
self.number += 1
|
||||||
|
self.service.process(list(people), self.number, self.jpeg, self.time, self.service.revision)
|
||||||
|
|
||||||
|
def free(self):
|
||||||
|
for _ in range(12):
|
||||||
|
self.step()
|
||||||
|
|
||||||
|
def cross(self, x=0.5):
|
||||||
|
for _ in range(3):
|
||||||
|
self.step([person(x=x)])
|
||||||
|
self.step([person(x=x, y=0.65)])
|
||||||
|
|
||||||
|
def test_group_cooldown_and_new_group(self):
|
||||||
|
self.free()
|
||||||
|
self.assertTrue(self.service.status()['armed'])
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
event = self.events.recent()[0]
|
||||||
|
self.assertEqual(event['kind'], 'person_line_crossing')
|
||||||
|
self.assertIsNone(event['profile_id'])
|
||||||
|
self.cross(x=0.65)
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
for _ in range(60):
|
||||||
|
self.step([person(y=0.65)])
|
||||||
|
self.cross(x=0.65)
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
self.free()
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(len(self.events.recent()), 2)
|
||||||
|
|
||||||
|
def test_no_delayed_crossing(self):
|
||||||
|
self.free()
|
||||||
|
self.cross()
|
||||||
|
self.free()
|
||||||
|
self.cross(x=0.65)
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
for _ in range(60):
|
||||||
|
self.step([person(x=0.65, y=0.65)])
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
|
||||||
|
def test_start_stop_reset_and_configuration(self):
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(self.events.recent(), [])
|
||||||
|
self.free()
|
||||||
|
self.controller.stop()
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(self.events.recent(), [])
|
||||||
|
self.controller.reset()
|
||||||
|
self.service.reset()
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(self.events.recent(), [])
|
||||||
|
self.free()
|
||||||
|
old_revision = self.service.revision
|
||||||
|
self.service.configure(self.service.get_config())
|
||||||
|
self.service.process([], 99, self.jpeg, self.time, old_revision)
|
||||||
|
self.assertIsNone(self.service.last_valid)
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(self.events.recent(), [])
|
||||||
|
self.free()
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
|
||||||
|
def test_watchdog_and_manual_bypass(self):
|
||||||
|
self.free()
|
||||||
|
self.time += 2.01
|
||||||
|
self.service.watchdog()
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
self.assertTrue(self.service.status()['reset_required'])
|
||||||
|
self.controller.zone_clear()
|
||||||
|
self.assertFalse(self.controller.trigger()[0])
|
||||||
|
self.free()
|
||||||
|
self.assertTrue(self.service.status()['reset_required'])
|
||||||
|
self.controller.reset()
|
||||||
|
self.service.reset()
|
||||||
|
self.free()
|
||||||
|
self.cross()
|
||||||
|
self.assertEqual(len(self.events.recent()), 1)
|
||||||
|
|
||||||
|
def test_camera_error_and_stale_results(self):
|
||||||
|
self.free()
|
||||||
|
self.camera.error = 'camera disconnected'
|
||||||
|
self.service.watchdog()
|
||||||
|
self.assertTrue(self.service.fault)
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
self.time += 3
|
||||||
|
result = self.service.snapshot()
|
||||||
|
self.assertEqual(result['people'], [])
|
||||||
|
self.assertIsNone(result['image_base64'])
|
||||||
|
|
||||||
|
def test_disabled_detection_does_not_stop_manual_tests(self):
|
||||||
|
self.service.configure(DEFAULT_CONFIG)
|
||||||
|
self.service.fail('missing model')
|
||||||
|
self.assertFalse(self.controller.stopped)
|
||||||
|
self.controller.zone_clear()
|
||||||
|
self.assertTrue(self.controller.trigger()[0])
|
||||||
|
|
||||||
|
def test_inference_stall_is_stopped_by_independent_watchdog(self):
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
class BlockingDetector:
|
||||||
|
def detect(inner, jpeg):
|
||||||
|
entered.set()
|
||||||
|
release.wait(3)
|
||||||
|
return []
|
||||||
|
self.camera.latest_timed = lambda: (1, self.jpeg, self.time)
|
||||||
|
self.service.factory = lambda path: BlockingDetector()
|
||||||
|
self.service.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(entered.wait(1))
|
||||||
|
self.time += 2.1
|
||||||
|
self.service.watchdog()
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
self.assertTrue(self.service.fault)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
self.service.stop()
|
||||||
|
|
||||||
|
def test_database_stall_does_not_block_watchdog(self):
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
def blocked_record(mode):
|
||||||
|
entered.set()
|
||||||
|
release.wait(3)
|
||||||
|
self.events.record_detection_trigger = blocked_record
|
||||||
|
self.free()
|
||||||
|
for _ in range(3):
|
||||||
|
self.step([person()])
|
||||||
|
worker = threading.Thread(target=lambda: self.step([person(y=0.65)]))
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(entered.wait(1))
|
||||||
|
self.time += 2.1
|
||||||
|
self.service.watchdog()
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
worker.join(timeout=1)
|
||||||
|
self.assertFalse(worker.is_alive())
|
||||||
|
|
||||||
|
def test_database_failure_stops_simulation(self):
|
||||||
|
def broken_record(mode):
|
||||||
|
raise OSError('disk full')
|
||||||
|
self.events.record_detection_trigger = broken_record
|
||||||
|
self.free()
|
||||||
|
self.cross()
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
self.assertTrue(self.service.fault)
|
||||||
|
self.assertIn('disk full', self.service.status()['error'])
|
||||||
|
|
||||||
|
def test_shutdown_drops_inflight_frame(self):
|
||||||
|
self.free()
|
||||||
|
version = self.service.revision
|
||||||
|
self.service.stop()
|
||||||
|
self.service.process([], 99, self.jpeg, self.time, version)
|
||||||
|
self.assertTrue(self.controller.stopped)
|
||||||
|
self.assertEqual(self.events.recent(), [])
|
||||||
|
|
||||||
|
def test_persistence_and_corrupt_settings(self):
|
||||||
|
other = DetectionService(self.camera, self.controller, self.events, self.directory)
|
||||||
|
self.assertTrue(other.get_config()['enabled'])
|
||||||
|
self.assertFalse(other.status()['armed'])
|
||||||
|
self.service.path.write_text('{')
|
||||||
|
other = DetectionService(self.camera, self.controller, self.events, self.directory)
|
||||||
|
self.assertFalse(other.get_config()['enabled'])
|
||||||
|
self.assertIsNotNone(other.status()['error'])
|
||||||
|
|
||||||
|
def test_unconfirmed_person_prevents_clear(self):
|
||||||
|
for _ in range(30):
|
||||||
|
self.step([person()])
|
||||||
|
self.assertFalse(self.service.status()['armed'])
|
||||||
|
|
||||||
|
def test_snapshot_matches_number_and_image(self):
|
||||||
|
self.step([person()])
|
||||||
|
snapshot = self.service.snapshot()
|
||||||
|
self.assertEqual(snapshot['frame_number'], self.number)
|
||||||
|
self.assertTrue(snapshot['image_base64'])
|
||||||
|
self.assertEqual(len(snapshot['people']), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTest(unittest.TestCase):
|
||||||
|
def test_config_validation_persistence_and_missing_model(self):
|
||||||
|
async def scenario():
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
app = create_app(demo=True, state_dir=Path(folder))
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
|
||||||
|
headers = {'X-Halloween-UI': '1'}
|
||||||
|
config = (await client.get('/api/detection/config')).json()
|
||||||
|
self.assertFalse(config['enabled'])
|
||||||
|
self.assertEqual((await client.post('/api/detection/config', json=config)).status_code, 403)
|
||||||
|
bad = {**config, 'region': [0.8,0.2,0.3,0.9]}
|
||||||
|
self.assertEqual((await client.post('/api/detection/config', json=bad, headers=headers)).status_code, 422)
|
||||||
|
config['enabled'] = True
|
||||||
|
self.assertEqual((await client.post('/api/detection/config', json=config, headers=headers)).status_code, 200)
|
||||||
|
for _ in range(30):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
status = (await client.get('/api/status')).json()
|
||||||
|
if status['detection']['reset_required']:
|
||||||
|
break
|
||||||
|
self.assertTrue(status['detection']['reset_required'])
|
||||||
|
self.assertTrue(status['controller']['stopped'])
|
||||||
|
self.assertIn('NanoDet fehlt', status['detection']['error'])
|
||||||
|
result = (await client.get('/api/detection')).json()
|
||||||
|
self.assertIsNone(result['image_base64'])
|
||||||
|
self.assertTrue(create_app(demo=True, state_dir=Path(folder)).state.detection.get_config()['enabled'])
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
def test_invalid_values(self):
|
||||||
|
for config in [ {**DEFAULT_CONFIG, 'direction': True},
|
||||||
|
{**DEFAULT_CONFIG, 'line': [[0.5,0.5],[0.5,0.5]]},
|
||||||
|
{**DEFAULT_CONFIG, 'region': [0,0,float('nan'),1]},
|
||||||
|
{**DEFAULT_CONFIG, 'enabled': 'true'}]:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Installer ohne Netzwerk; optionaler Smoke-Test mit echten Modellgewichten."""
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from halloween.detection import NanoDet
|
||||||
|
|
||||||
|
|
||||||
|
class InstallerTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
spec = importlib.util.spec_from_file_location('model_installer', Path(__file__).parents[1] / 'tools/install_detection_model.py')
|
||||||
|
self.installer = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(self.installer)
|
||||||
|
self.folder = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.folder.cleanup)
|
||||||
|
self.data = b'test-model'
|
||||||
|
self.manifest = {'file': 'model.onnx', 'sha256': hashlib.sha256(self.data).hexdigest(),
|
||||||
|
'size': len(self.data), 'url': 'https://example.invalid/model'}
|
||||||
|
self.target = Path(self.folder.name) / 'models/model.onnx'
|
||||||
|
|
||||||
|
def install(self, data):
|
||||||
|
with patch.object(self.installer.json, 'loads', return_value=self.manifest), \
|
||||||
|
patch.object(self.installer.urllib.request, 'urlopen', return_value=BytesIO(data)) as download, \
|
||||||
|
patch('sys.argv', ['installer', '--state-dir', self.folder.name]), redirect_stdout(StringIO()):
|
||||||
|
self.installer.main()
|
||||||
|
return download
|
||||||
|
|
||||||
|
def test_verified_download_is_atomic_and_idempotent(self):
|
||||||
|
self.assertEqual(self.install(self.data).call_count, 1)
|
||||||
|
self.assertEqual(self.target.read_bytes(), self.data)
|
||||||
|
self.assertEqual(self.install(self.data).call_count, 0)
|
||||||
|
self.assertEqual(list(self.target.parent.iterdir()), [self.target])
|
||||||
|
|
||||||
|
def test_bad_download_keeps_existing_file(self):
|
||||||
|
self.target.parent.mkdir()
|
||||||
|
self.target.write_bytes(b'old-corrupt-model')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.install(b'bad-model!')
|
||||||
|
self.assertEqual(self.target.read_bytes(), b'old-corrupt-model')
|
||||||
|
self.assertEqual(list(self.target.parent.iterdir()), [self.target])
|
||||||
|
|
||||||
|
def test_oversized_download_is_rejected_and_cleaned(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.install(self.data+b'extra')
|
||||||
|
self.assertEqual(list(self.target.parent.iterdir()), [])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get('HALLOWEEN_TEST_MODEL'), 'Optionales Modell nicht angegeben')
|
||||||
|
class ModelSmokeTest(unittest.TestCase):
|
||||||
|
def test_real_model_outputs_and_empty_frame(self):
|
||||||
|
model = NanoDet(Path(os.environ['HALLOWEEN_TEST_MODEL']))
|
||||||
|
frame = BytesIO()
|
||||||
|
Image.new('RGB', (640,480)).save(frame, format='JPEG')
|
||||||
|
self.assertEqual(model.detect(frame.getvalue()), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Expliziter, SHA-256-geprüfter Download des lokalen Erkennungsmodells."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state-dir", type=Path, default=Path(os.environ.get("HALLOWEEN_STATE_DIR", root / "data")))
|
||||||
|
args = parser.parse_args()
|
||||||
|
manifest = json.loads((root / "halloween/nanodet-model.json").read_text())
|
||||||
|
target = args.state_dir / "models" / manifest["file"]
|
||||||
|
if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == manifest["sha256"]:
|
||||||
|
print(f"Modell bereits geprüft: {target}")
|
||||||
|
return
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as output:
|
||||||
|
temp = Path(output.name)
|
||||||
|
digest, size = hashlib.sha256(), 0
|
||||||
|
with urllib.request.urlopen(manifest["url"], timeout=30) as response:
|
||||||
|
while block := response.read(65536):
|
||||||
|
size += len(block)
|
||||||
|
if size > manifest["size"]:
|
||||||
|
raise ValueError("Modell größer als erwartet")
|
||||||
|
digest.update(block)
|
||||||
|
output.write(block)
|
||||||
|
if size != manifest["size"] or digest.hexdigest() != manifest["sha256"]:
|
||||||
|
raise ValueError("Modell-Prüfsumme oder Größe stimmt nicht")
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
os.replace(temp, target)
|
||||||
|
print(f"Modell installiert und SHA-256 geprüft: {target}")
|
||||||
|
finally:
|
||||||
|
if temp is not None:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,3 +23,8 @@ unter `preview/` dokumentieren.
|
|||||||
Fiktive Scan-Videos nutzen nur synthetische Avatare und Demo-IDs.
|
Fiktive Scan-Videos nutzen nur synthetische Avatare und Demo-IDs.
|
||||||
Kegelprojektion, Zeichnen der Bildsequenz und Videoschnitt sind getrennte
|
Kegelprojektion, Zeichnen der Bildsequenz und Videoschnitt sind getrennte
|
||||||
Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren.
|
Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren.
|
||||||
|
|
||||||
|
- `install_detection_model.py` lädt auf ausdrücklichen Aufruf das im Manifest
|
||||||
|
fixierte NanoDet-Modell unter `data/models/`. Größe und SHA-256 vor atomarem
|
||||||
|
Ersetzen prüfen, TLS-Prüfung beibehalten. `--state-dir` bzw.
|
||||||
|
`HALLOWEEN_STATE_DIR` muss zum Server passen. Keine Modellgewichte hochladen.
|
||||||
Reference in new issue
Block a user