Private
Public Access
391 lines
18 KiB
Python
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)))
|