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