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