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.
|
||||
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
|
||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||
- Moduswahl und simulierte Sequenz sind implementiert. GPIO, automatische
|
||||
Personenauslösung und Clip-Speicherung sind noch offen.
|
||||
- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit
|
||||
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/`;
|
||||
automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits
|
||||
Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera
|
||||
schreibt derzeit keine solchen Datensätze.
|
||||
- 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
|
||||
`marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`.
|
||||
Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen.
|
||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||
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
|
||||
|
||||
- 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,
|
||||
gespeicherte Moduswahl, einen simulierten Effektablauf sowie eine lokale
|
||||
SQLite-Datenbank für Testereignisse und manuell zugeordnete Profile.
|
||||
GPIO-Steuerung, automatische Personenerkennung und Aufnahme von Clips
|
||||
sind noch nicht umgesetzt. Die echte Kamera und die Aktoren sind noch
|
||||
nicht praktisch geprüft.
|
||||
SQLite-Datenbank für Ereignisse und manuell zugeordnete Profile. Optional
|
||||
erkennt NanoDet lokal Personen und löst beim Überqueren einer einstellbaren
|
||||
Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und
|
||||
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
|
||||
|
||||
|
||||
+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
|
||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
||||
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
||||
Testereignisse und Profilkennungen. Automatische Personenerkennung und
|
||||
Profilzuordnung per Kamera, Clips und Maskierung sind noch nicht umgesetzt.
|
||||
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
|
||||
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
|
||||
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
|
||||
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),
|
||||
[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
|
||||
offen. Installation der Python-Pakete, WebUI-Start, Kamera, SSH-Tunnel und
|
||||
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:
|
||||
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):
|
||||
with self._condition:
|
||||
self._condition.wait_for(
|
||||
|
||||
@@ -25,6 +25,7 @@ class Controller:
|
||||
self.started_at = None
|
||||
self.cooldown_until = 0.0
|
||||
self.last_event = None
|
||||
self.generation = 0
|
||||
|
||||
def _load_mode(self):
|
||||
if not self._path.exists():
|
||||
@@ -59,6 +60,7 @@ class Controller:
|
||||
with self._lock:
|
||||
self._save_mode(mode)
|
||||
if mode != self.mode:
|
||||
self.generation += 1
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
self.mode = mode
|
||||
@@ -94,12 +96,14 @@ class Controller:
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
self.stopped = True
|
||||
self.generation += 1
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self.stopped = False
|
||||
self.generation += 1
|
||||
self.started_at = None
|
||||
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
|
||||
|
||||
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())
|
||||
with closing(self._connect()) as connection:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
|
||||
(occurred_at, "test_trigger", mode),
|
||||
(occurred_at, kind, mode),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
+123
-8
@@ -15,6 +15,9 @@
|
||||
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 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-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; }
|
||||
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; }
|
||||
@@ -46,10 +49,21 @@
|
||||
<main>
|
||||
<section>
|
||||
<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>
|
||||
<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>
|
||||
<h2>Betrieb</h2>
|
||||
@@ -57,7 +71,7 @@
|
||||
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
||||
<dt>Zustand</dt><dd id="phase">Lädt …</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>
|
||||
<label for="mode">Modus wählen</label>
|
||||
<div class="row" style="margin: .5rem 0 1rem">
|
||||
@@ -69,7 +83,7 @@
|
||||
</select>
|
||||
<button id="save-mode">Übernehmen</button>
|
||||
</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">
|
||||
<button id="zone-clear">Bereich frei (Test)</button>
|
||||
<button id="test-trigger">Testauslösung</button>
|
||||
@@ -81,8 +95,8 @@
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<section class="events">
|
||||
<h2>Gespeicherte Testereignisse</h2>
|
||||
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||
<h2>Gespeicherte Ereignisse</h2>
|
||||
<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>
|
||||
</section>
|
||||
<section class="events">
|
||||
@@ -171,13 +185,13 @@
|
||||
list.replaceChildren();
|
||||
if (!data.events.length) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'Noch keine Testauslösung gespeichert';
|
||||
item.textContent = 'Noch keine Auslösung gespeichert';
|
||||
list.append(item);
|
||||
}
|
||||
for (const event of data.events) {
|
||||
const item = document.createElement('li');
|
||||
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');
|
||||
tools.className = 'row event-tools';
|
||||
const select = document.createElement('select');
|
||||
@@ -220,6 +234,107 @@
|
||||
await refreshDb();
|
||||
} 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();
|
||||
refreshDb();
|
||||
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
|
||||
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 .controller import Controller, MODES
|
||||
from .event_store import EventStore
|
||||
from .detection import DetectionService
|
||||
|
||||
|
||||
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)
|
||||
controller = Controller(directory)
|
||||
events = EventStore(directory)
|
||||
detection = DetectionService(camera, controller, events, directory)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -54,11 +56,23 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
except sqlite3.Error:
|
||||
LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden")
|
||||
|
||||
async def detection_watchdog():
|
||||
while True:
|
||||
detection.watchdog()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
camera.start()
|
||||
detection.start()
|
||||
watchdog_task = asyncio.create_task(detection_watchdog())
|
||||
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
controller.stop()
|
||||
watchdog_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await watchdog_task
|
||||
detection.stop()
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
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.controller = controller
|
||||
app.state.events = events
|
||||
app.state.detection = detection
|
||||
|
||||
@app.middleware("http")
|
||||
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")
|
||||
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")
|
||||
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)])
|
||||
async def reset():
|
||||
controller.reset()
|
||||
detection.reset()
|
||||
return controller.status()
|
||||
|
||||
@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.
|
||||
- Tests für neue Sicherheits- oder Zustandsregeln an beobachtbarem Verhalten
|
||||
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.
|
||||
Kegelprojektion, Zeichnen der Bildsequenz und Videoschnitt sind getrennte
|
||||
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