Files
2026-10-14 17:40:31 +02:00

391 lines
18 KiB
Python

"""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
from .motion import MotionDetector
DEFAULT_CONFIG = {"enabled": False, "method": "person_line", "line": [[0.2, 0.55], [0.8, 0.55]],
"direction": 1, "region": [0.1, 0.15, 0.9, 0.95]}
def validate_config(value):
# Migrate previous saved configurations and old clients without changing
# their enabled setting or requiring a new geometry setup.
if isinstance(value, dict) and 'method' not in value:
value = {**value, 'method': 'person_line'}
if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool:
raise ValueError("Ungültige Erkennungseinstellungen")
if value['method'] not in ('person_line', 'motion_test'):
raise ValueError('Unbekannte Erkennungsmethode')
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, "motion": 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, "motion": 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, motion=None):
event_mode = None
event_method = None
event_region = 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))
if self.config['method'] == 'motion_test':
if motion is None:
self.fail('Bewegungs-Test liefert kein Ergebnis; zurücksetzen')
return
people = []
crossing = motion['trigger']
else:
motion = None
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height)
self.result = {"frame_number": number, "people": people, "motion": motion,
"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"]
# In motion test mode, a quiet image is NOT proof of no visitors.
occupied = (not motion['ready'] or motion['moving']) if motion is not None else 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"]
event_method = self.config['method']
event_region = list(self.config['region'])
# SQLite darf UI und unabhängigen Watchdog nicht blockieren.
if event_mode is not None:
try:
if event_method == 'motion_test':
self.events.record_motion_trigger(event_mode, jpeg=jpeg, region=event_region)
else:
self.events.record_detection_trigger(event_mode, jpeg=jpeg, region=event_region)
except Exception as exc:
self.fail(f"Ereignis/Bild 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"], "method": self.config["method"], "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, "motion": self.result["motion"] if fresh else None, "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, detector_key, last_number, last_completed = None, None, 0, None
while not self.stop_event.is_set():
began = self.clock()
version = None
try:
with self.lock:
if self.controller.generation != self.generation:
self.reset_state()
version = self.revision
config = self.get_config()
# A fresh motion baseline is required for every reconfiguration,
# controller change and reset. Never reuse pre-change optical flow.
key = (config['method'], version)
if detector is None or detector_key != key:
detector = MotionDetector() if config['method'] == 'motion_test' else self.factory(self.model_path)
detector_key = key
last_completed = None
number, jpeg, captured_at = self.camera.latest_timed()
if jpeg is not None and number != last_number:
last_number = number
if config['method'] == 'motion_test':
motion = detector.detect(jpeg, config, captured_at)
self.process([], number, jpeg, captured_at, version, motion=motion)
else:
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:
# An old in-flight model failure must not fault a newly selected
# method. A current failure still uses the usual reset latch.
with self.lock:
if version == self.revision and self.controller.generation == self.generation:
self.fail(f"Erkennung nicht verfügbar: {exc}")
detector = None
self.stop_event.wait(1)
self.stop_event.wait(max(0, 0.2-(self.clock()-began)))