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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user