"""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)))