2026-08-11 11:02:37 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import math
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from collections import deque
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
|
|
|
|
|
from .source import CentroidTracker, HOGPersonDetector, require_opencv
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import cv2 # type: ignore
|
|
|
|
|
except ImportError: # pragma: no cover
|
|
|
|
|
cv2 = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_ZONE = Zone(
|
|
|
|
|
"zone-demo-01",
|
|
|
|
|
"楼梯口危险区",
|
|
|
|
|
1,
|
|
|
|
|
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 15:41:07 +08:00
|
|
|
class EventIngressUnavailable(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 11:02:37 +08:00
|
|
|
class DemoEngine:
|
2026-08-11 15:41:07 +08:00
|
|
|
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100, event_ingress: Any | None = None) -> None:
|
2026-08-11 11:02:37 +08:00
|
|
|
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
|
|
|
|
|
raise ValueError("fps must be within (0, 30]")
|
|
|
|
|
if not 1 <= event_limit <= 100:
|
|
|
|
|
raise ValueError("event_limit must be within [1, 100]")
|
|
|
|
|
require_opencv()
|
|
|
|
|
self._source = source
|
|
|
|
|
self._fps = fps
|
|
|
|
|
self._detector = None if source.fixture else HOGPersonDetector()
|
|
|
|
|
self._tracker = CentroidTracker()
|
|
|
|
|
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
|
2026-08-11 15:41:07 +08:00
|
|
|
self._event_ingress = event_ingress
|
2026-08-11 11:02:37 +08:00
|
|
|
self._zone = DEFAULT_ZONE
|
|
|
|
|
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
|
|
|
|
|
self._lock = threading.RLock()
|
|
|
|
|
self._stop = threading.Event()
|
|
|
|
|
self._thread: threading.Thread | None = None
|
|
|
|
|
self._sequence = 0
|
|
|
|
|
self._frame_jpeg: bytes | None = None
|
|
|
|
|
self._frame_width = 0
|
|
|
|
|
self._frame_height = 0
|
|
|
|
|
self._captured_at: str | None = None
|
|
|
|
|
self._detections: list[dict[str, object]] = []
|
|
|
|
|
self._latency_ms: float | None = None
|
|
|
|
|
self._connected = False
|
|
|
|
|
self._last_error_code: str | None = None
|
|
|
|
|
|
|
|
|
|
def start(self) -> None:
|
|
|
|
|
if self._thread is not None:
|
|
|
|
|
return
|
2026-08-11 15:41:07 +08:00
|
|
|
if self._event_ingress is not None:
|
|
|
|
|
self._event_ingress.start()
|
2026-08-11 11:02:37 +08:00
|
|
|
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
|
|
|
|
|
self._thread.start()
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
self._stop.set()
|
|
|
|
|
if self._thread is not None:
|
|
|
|
|
self._thread.join(timeout=3.0)
|
|
|
|
|
self._thread = None
|
2026-08-11 15:41:07 +08:00
|
|
|
if self._event_ingress is not None:
|
|
|
|
|
self._event_ingress.stop()
|
2026-08-11 11:02:37 +08:00
|
|
|
self._source.close()
|
|
|
|
|
|
|
|
|
|
def _run(self) -> None:
|
|
|
|
|
interval = 1.0 / self._fps
|
|
|
|
|
while not self._stop.is_set():
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
try:
|
|
|
|
|
self.step()
|
2026-08-11 15:41:07 +08:00
|
|
|
except EventIngressUnavailable:
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._last_error_code = "event_outbox_unavailable"
|
2026-08-11 11:02:37 +08:00
|
|
|
except RuntimeError:
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._connected = False
|
|
|
|
|
self._last_error_code = "source_unavailable"
|
|
|
|
|
elapsed = time.perf_counter() - started
|
|
|
|
|
self._stop.wait(max(0.0, interval - elapsed))
|
|
|
|
|
|
|
|
|
|
def step(self) -> None:
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
packet = self._source.read()
|
|
|
|
|
self._sequence += 1
|
|
|
|
|
if packet.scripted_detections is not None:
|
|
|
|
|
detections = list(packet.scripted_detections)
|
|
|
|
|
else:
|
|
|
|
|
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
|
|
|
|
|
detections = self._tracker.update(raw_boxes, self._sequence)
|
|
|
|
|
with self._lock:
|
|
|
|
|
zone = self._zone
|
|
|
|
|
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
|
2026-08-11 15:41:07 +08:00
|
|
|
if self._event_ingress is not None:
|
|
|
|
|
try:
|
|
|
|
|
for item in new_events:
|
|
|
|
|
self._event_ingress.submit(item)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise EventIngressUnavailable("persist event candidate") from exc
|
2026-08-11 11:02:37 +08:00
|
|
|
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
|
|
|
|
|
if not ok:
|
|
|
|
|
raise RuntimeError("frame encoding failed")
|
|
|
|
|
height, width = packet.frame.shape[:2]
|
|
|
|
|
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
|
|
|
|
|
with self._lock:
|
|
|
|
|
for event in new_events:
|
2026-08-11 15:41:07 +08:00
|
|
|
serialized_event = event.as_dict()
|
|
|
|
|
if self._event_ingress is not None:
|
|
|
|
|
# This is an immutable handoff fact, not a live delivery
|
|
|
|
|
# status. Current counts live under event_ingress.
|
|
|
|
|
serialized_event["delivery_status"] = "outbox_persisted"
|
|
|
|
|
self._events.appendleft(serialized_event)
|
2026-08-11 11:02:37 +08:00
|
|
|
self._frame_jpeg = encoded.tobytes()
|
|
|
|
|
self._frame_width = int(width)
|
|
|
|
|
self._frame_height = int(height)
|
|
|
|
|
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
self._detections = serialized_detections
|
|
|
|
|
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
|
|
|
|
|
self._connected = True
|
|
|
|
|
self._last_error_code = None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"track_id": detection.track_id,
|
|
|
|
|
"class": detection.class_name,
|
|
|
|
|
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
|
|
|
|
|
"detector_score": detection.detector_score,
|
|
|
|
|
"inside_zone": inside,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def update_zone(self, payload: object) -> Zone:
|
|
|
|
|
with self._lock:
|
|
|
|
|
updated = zone_from_payload(payload, self._zone)
|
|
|
|
|
self._zone = updated
|
|
|
|
|
self._evaluator.reset()
|
|
|
|
|
return updated
|
|
|
|
|
|
|
|
|
|
def frame_jpeg(self) -> bytes | None:
|
|
|
|
|
with self._lock:
|
|
|
|
|
return self._frame_jpeg
|
|
|
|
|
|
|
|
|
|
def state(self) -> dict[str, object]:
|
|
|
|
|
with self._lock:
|
|
|
|
|
zone = self._zone
|
2026-08-11 15:41:07 +08:00
|
|
|
if self._event_ingress is None:
|
|
|
|
|
ingress_status: dict[str, object] = {"enabled": False}
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
ingress_status = self._event_ingress.status()
|
|
|
|
|
except Exception:
|
|
|
|
|
ingress_status = {"enabled": True, "last_error_code": "event_outbox_unavailable"}
|
2026-08-11 11:02:37 +08:00
|
|
|
return {
|
|
|
|
|
"prototype": True,
|
|
|
|
|
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
|
|
|
|
|
"source": {
|
|
|
|
|
"mode": self._source.mode,
|
|
|
|
|
"label": self._source.label,
|
|
|
|
|
"fixture": self._source.fixture,
|
|
|
|
|
"connected": self._connected,
|
|
|
|
|
"ref": self._source.source_ref,
|
|
|
|
|
},
|
|
|
|
|
"detector": {
|
|
|
|
|
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
|
|
|
|
|
"production_ready": False,
|
|
|
|
|
},
|
|
|
|
|
"frame": {
|
|
|
|
|
"sequence": self._sequence,
|
|
|
|
|
"width": self._frame_width,
|
|
|
|
|
"height": self._frame_height,
|
|
|
|
|
"captured_at": self._captured_at,
|
|
|
|
|
},
|
|
|
|
|
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
|
|
|
|
|
"zone": {
|
|
|
|
|
"id": zone.zone_id,
|
|
|
|
|
"name": zone.name,
|
|
|
|
|
"version": zone.version,
|
|
|
|
|
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
|
|
|
|
},
|
|
|
|
|
"detections": list(self._detections),
|
|
|
|
|
"events": list(self._events),
|
2026-08-11 15:41:07 +08:00
|
|
|
"event_ingress": ingress_status,
|
2026-08-11 11:02:37 +08:00
|
|
|
"last_error_code": self._last_error_code,
|
|
|
|
|
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
|
|
|
}
|