feat(brain): add single-stream visual prototype (T-017)
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-11 11:02:37 +08:00
parent 2e04737922
commit a2790c1f5e
28 changed files with 1688 additions and 21 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Single-stream Brain engineering prototype.
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
that contract and its delivery semantics.
"""
__version__ = "0.1.0"
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import argparse
import sys
from .runtime import DemoEngine
from .server import parse_bind, serve
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
require_opencv()
parse_bind(args.bind)
if args.source == "stream":
if not args.stream_url_file:
raise ValueError("--stream-url-file is required for stream mode")
source = StreamSource(read_stream_url(args.stream_url_file))
else:
if args.stream_url_file:
raise ValueError("--stream-url-file is only valid for stream mode")
source = SyntheticSource()
engine = DemoEngine(source, fps=args.fps)
except (RuntimeError, ValueError) as exc:
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
return 2
print(f"Brain demo listening on http://{args.bind}/brain-demo")
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
try:
serve(engine, args.bind)
except KeyboardInterrupt:
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Iterable, Sequence
from uuid import uuid4
@dataclass(frozen=True)
class Point:
x: float
y: float
def __post_init__(self) -> None:
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
):
raise ValueError("point coordinates must be within [0, 1]")
@dataclass(frozen=True)
class Box:
x1: float
y1: float
x2: float
y2: float
def __post_init__(self) -> None:
values = (self.x1, self.y1, self.x2, self.y2)
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
raise ValueError("box coordinates must be within [0, 1]")
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("box must have positive area")
@property
def center(self) -> Point:
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
@dataclass(frozen=True)
class Detection:
track_id: str
class_name: str
box: Box
detector_score: float | None = None
def __post_init__(self) -> None:
if not self.track_id or len(self.track_id) > 128:
raise ValueError("track_id must contain 1 to 128 characters")
if self.class_name != "person":
raise ValueError("T-017 only supports anonymous person detections")
if self.detector_score is not None and not math.isfinite(self.detector_score):
raise ValueError("detector_score must be finite")
@dataclass(frozen=True)
class Zone:
zone_id: str
name: str
version: int
points: tuple[Point, ...]
def __post_init__(self) -> None:
if not self.zone_id or len(self.zone_id) > 128:
raise ValueError("zone_id must contain 1 to 128 characters")
if not self.name.strip() or len(self.name) > 80:
raise ValueError("zone name must contain 1 to 80 characters")
if self.version < 1:
raise ValueError("zone version must be positive")
if not 3 <= len(self.points) <= 32:
raise ValueError("zone must contain 3 to 32 points")
@dataclass(frozen=True)
class EventCandidate:
source_event_id: str
kind: str
source_ref: str
track_id: str
zone_id: str
zone_version: int
occurred_at: str
confidence: None
fixture: bool
def as_dict(self) -> dict[str, object]:
# Bell owns the platform `id`; it is deliberately absent here.
return {
"candidate_version": "brain-demo-v1",
"source_event_id": self.source_event_id,
"kind": self.kind,
"source_ref": self.source_ref,
"track_id": self.track_id,
"zone_id": self.zone_id,
"zone_version": self.zone_version,
"occurred_at": self.occurred_at,
"confidence": self.confidence,
"fixture": self.fixture,
}
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
if abs(cross) > epsilon:
return False
return (
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
)
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
if len(polygon) < 3:
return False
inside = False
previous = polygon[-1]
for current in polygon:
if _on_segment(point, previous, current):
return True
crosses = (current.y > point.y) != (previous.y > point.y)
if crosses:
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
if point.x < boundary_x:
inside = not inside
previous = current
return inside
class ZoneEntryEvaluator:
def __init__(
self,
source_ref: str,
fixture: bool,
track_ttl_frames: int = 8,
event_id_factory: Callable[[], str] | None = None,
) -> None:
if track_ttl_frames < 1:
raise ValueError("track_ttl_frames must be positive")
self._source_ref = source_ref
self._fixture = fixture
self._track_ttl_frames = track_ttl_frames
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
self._inside: dict[str, bool] = {}
self._last_seen: dict[str, int] = {}
def reset(self) -> None:
self._inside.clear()
self._last_seen.clear()
def evaluate(
self,
sequence: int,
occurred_at: datetime,
zone: Zone,
detections: Iterable[Detection],
) -> tuple[list[EventCandidate], dict[str, bool]]:
if sequence < 0:
raise ValueError("sequence cannot be negative")
if occurred_at.tzinfo is None:
raise ValueError("occurred_at must be timezone-aware")
expired = [
track_id
for track_id, last_seen in self._last_seen.items()
if sequence - last_seen > self._track_ttl_frames
]
for track_id in expired:
self._inside.pop(track_id, None)
self._last_seen.pop(track_id, None)
events: list[EventCandidate] = []
states: dict[str, bool] = {}
for detection in detections:
inside = point_in_polygon(detection.box.center, zone.points)
previous = self._inside.get(detection.track_id)
if previous is False and inside:
events.append(
EventCandidate(
source_event_id=self._event_id_factory(),
kind="zone_entry",
source_ref=self._source_ref,
track_id=detection.track_id,
zone_id=zone.zone_id,
zone_version=zone.version,
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=self._fixture,
)
)
self._inside[detection.track_id] = inside
self._last_seen[detection.track_id] = sequence
states[detection.track_id] = inside
return events, states
def zone_from_payload(payload: object, current: Zone) -> Zone:
if not isinstance(payload, dict):
raise ValueError("request body must be an object")
if set(payload) - {"name", "points"}:
raise ValueError("unknown zone fields are not allowed")
name = payload.get("name", current.name)
raw_points = payload.get("points")
if not isinstance(name, str):
raise ValueError("zone name must be a string")
if not isinstance(raw_points, list):
raise ValueError("zone points must be an array")
points: list[Point] = []
for raw_point in raw_points:
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
raise ValueError("each point must contain only x and y")
x = raw_point["x"]
y = raw_point["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
raise ValueError("point coordinates must be numbers")
points.append(Point(float(x), float(y)))
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
+164
View File
@@ -0,0 +1,164 @@
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)),
)
class DemoEngine:
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100) -> None:
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)
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
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
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()
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)
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:
self._events.appendleft(event.as_dict())
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
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),
"last_error_code": self._last_error_code,
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import hmac
import json
import secrets
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .domain import Zone
MAX_BODY_BYTES = 64 * 1024
def parse_bind(value: str) -> tuple[str, int]:
parsed = urlsplit(f"//{value}")
host = parsed.hostname
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid demo bind address") from exc
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
return host, port
def index_path() -> Path:
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class DemoHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
self.engine = engine
self.demo_token = token
self.html = html
super().__init__(address, DemoHandler)
class DemoHandler(BaseHTTPRequestHandler):
server: DemoHTTPServer
def log_message(self, _format: str, *_args: object) -> None:
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
return
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self._security_headers()
self.end_headers()
self.wfile.write(body)
def _send_json(self, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send_bytes(status, "application/json; charset=utf-8", body)
def do_GET(self) -> None: # noqa: N802
path = urlsplit(self.path).path
if path in {"/", "/brain-demo"}:
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
return
if path == "/healthz":
self._send_json(HTTPStatus.OK, {"status": "ok"})
return
if path == "/api/v1/state":
self._send_json(HTTPStatus.OK, self.server.engine.state())
return
if path == "/api/v1/frame.jpg":
frame = self.server.engine.frame_jpeg()
if frame is None:
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
return
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
return
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
def do_PUT(self) -> None: # noqa: N802
if urlsplit(self.path).path != "/api/v1/zones/active":
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
return
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "-1")
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
return
body = self.rfile.read(length)
supplied = self.headers.get("X-Brain-Demo-Token", "")
if not hmac.compare_digest(supplied, self.server.demo_token):
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
return
try:
payload = json.loads(body.decode("utf-8"))
zone: Zone = self.server.engine.update_zone(payload)
except (UnicodeError, json.JSONDecodeError, ValueError):
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
return
self._send_json(
HTTPStatus.OK,
{
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
)
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
html = index_path().read_text(encoding="utf-8")
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
def serve(engine: Any, bind: str) -> None:
server = build_server(engine, bind)
engine.start()
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()
engine.stop()
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlsplit
from .domain import Box, Detection
try:
import cv2 # type: ignore
import numpy as np # type: ignore
except ImportError: # pragma: no cover - exercised by the startup failure path
cv2 = None
np = None
@dataclass(frozen=True)
class FramePacket:
frame: Any
captured_at: datetime
scripted_detections: tuple[Detection, ...] | None
def require_opencv() -> None:
if cv2 is None or np is None:
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
def read_stream_url(path_value: str) -> str:
path = Path(path_value)
if not path.is_absolute():
raise ValueError("stream URL file must be an absolute external path")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ValueError("stream URL file does not exist") from exc
if not resolved.is_file():
raise ValueError("stream URL file does not exist")
repository_root = Path(__file__).resolve().parents[2]
try:
resolved.relative_to(repository_root)
except ValueError:
pass
else:
raise ValueError("stream URL file must be outside the repository")
try:
with resolved.open("rb") as stream:
raw = stream.read(4097)
except OSError as exc:
raise ValueError("stream URL file cannot be read") from exc
if len(raw) > 4096:
raise ValueError("stream URL file exceeds 4096 bytes")
try:
lines = raw.decode("utf-8").splitlines()
except UnicodeError as exc:
raise ValueError("stream URL file must be UTF-8") from exc
values = [line.strip() for line in lines if line.strip()]
if len(values) != 1:
raise ValueError("stream URL file must contain exactly one non-empty line")
parsed = urlsplit(values[0])
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
raise ValueError("stream URL must be an RTSP URL")
return values[0]
class SyntheticSource:
mode = "synthetic"
label = "合成回放"
fixture = True
source_ref = "demo-camera-01"
def __init__(self, width: int = 960, height: int = 540) -> None:
require_opencv()
self.width = width
self.height = height
self._sequence = 0
def read(self) -> FramePacket:
self._sequence += 1
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
frame[:] = (20, 28, 42)
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
for x in range(0, self.width, 80):
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
phase = ((self._sequence - 1) % 160) / 159.0
center_x = -0.04 + phase * 1.08
x1 = max(0.0, center_x - 0.04)
x2 = min(1.0, center_x + 0.04)
detections: tuple[Detection, ...] = ()
if x2 - x1 > 0.01:
box = Box(x1, 0.34, x2, 0.82)
detections = (Detection("P-DEMO-001", "person", box, None),)
px = int(center_x * self.width)
head_y = int(self.height * 0.40)
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
return FramePacket(frame, datetime.now(timezone.utc), detections)
def close(self) -> None:
return
class StreamSource:
mode = "stream"
label = "外部 MediaMTX / RTSP"
fixture = False
source_ref = "configured-video-source"
def __init__(self, stream_url: str) -> None:
require_opencv()
self._stream_url = stream_url
self._capture: Any = None
def _open(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = cv2.VideoCapture(self._stream_url)
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
def read(self) -> FramePacket:
if self._capture is None or not self._capture.isOpened():
self._open()
ok, frame = self._capture.read()
if not ok or frame is None:
self._open()
raise RuntimeError("stream frame unavailable")
return FramePacket(frame, datetime.now(timezone.utc), None)
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class HOGPersonDetector:
name = "opencv_hog_person_demo"
production_ready = False
def __init__(self) -> None:
require_opencv()
self._hog = cv2.HOGDescriptor()
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
def detect(self, frame: Any) -> list[tuple[Box, float]]:
height, width = frame.shape[:2]
scale = min(1.0, 960.0 / max(width, 1))
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
result: list[tuple[Box, float]] = []
work_height, work_width = working.shape[:2]
for raw_box, weight in zip(boxes, weights):
x, y, box_width, box_height = (int(value) for value in raw_box)
x1 = max(0.0, min(1.0, x / work_width))
y1 = max(0.0, min(1.0, y / work_height))
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
if x2 > x1 and y2 > y1:
result.append((Box(x1, y1, x2, y2), float(weight)))
return result
class CentroidTracker:
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
self._max_distance = max_distance
self._ttl_frames = ttl_frames
self._next_id = 1
self._tracks: dict[str, tuple[Box, int]] = {}
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
incoming = list(boxes)
available = set(self._tracks)
detections: list[Detection] = []
for box, score in incoming:
center = box.center
selected: str | None = None
selected_distance = self._max_distance
for track_id in available:
old_center = self._tracks[track_id][0].center
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
if distance < selected_distance:
selected = track_id
selected_distance = distance
if selected is None:
selected = f"P-{self._next_id:04d}"
self._next_id += 1
else:
available.remove(selected)
self._tracks[selected] = (box, sequence)
detections.append(Detection(selected, "person", box, score))
for track_id, (_, last_seen) in list(self._tracks.items()):
if sequence - last_seen > self._ttl_frames:
del self._tracks[track_id]
return detections