kamera geht

This commit is contained in:
marcinator_2 committed 2026-10-14 05:07:07 +02:00
1 parent bfecfd0640
commit d370f992ce
19 files changed
+1365 -1128

No files matched your search

+21 -2
View File
@@ -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.
+229 -1110
View File
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -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
View File
@@ -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.
+18
View File
@@ -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`.
+4
View File
@@ -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(
+4
View File
@@ -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
+351
View File
@@ -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)))
+7 -1
View File
@@ -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
View File
@@ -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);
+18
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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")
+6
View File
@@ -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
+10
View File
@@ -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.
+300
View File
@@ -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()
+66
View File
@@ -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()
+47
View File
@@ -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()
+5
View File
@@ -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.