feat(brain): add single-stream visual prototype (T-017)
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
artifacts/
|
||||
*.local.json
|
||||
*.url
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# YoVision Brain 单路工程原型
|
||||
|
||||
T-017 提供一条可见、可重复的工程链路:单路 frame source → 匿名人员检测/fixture → track → 归一化多边形区域 → `zone_entry` 候选事实。它不是生产模型、不是 NVR,也没有 Brain→Bell transport。
|
||||
|
||||
## 环境
|
||||
|
||||
- Python `3.10.11`
|
||||
- NumPy `1.26.4`
|
||||
- OpenCV `4.9.0.80`
|
||||
|
||||
本任务复用本机已验证版本;新环境显式安装:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r Brain/requirements-demo.txt
|
||||
```
|
||||
|
||||
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
|
||||
|
||||
## 启动
|
||||
|
||||
无需摄像头的确定性合成回放:
|
||||
|
||||
```powershell
|
||||
python -m Brain.yovision_brain --source synthetic
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
|
||||
|
||||
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
|
||||
|
||||
```powershell
|
||||
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
|
||||
```
|
||||
|
||||
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
|
||||
|
||||
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||
python -m compileall -q Brain
|
||||
```
|
||||
|
||||
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
|
||||
|
||||
## 边界
|
||||
|
||||
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
|
||||
- 事件只保留在最多 100 项的内存环中;重启即丢失是刻意边界。
|
||||
- T-018 冻结并实现身份映射、候选契约、持久 Outbox、HMAC、幂等和 Bell ingress。
|
||||
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
|
||||
@@ -0,0 +1 @@
|
||||
"""YoVision Brain package root."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# T-017 engineering prototype only. Do not infer the production Brain stack.
|
||||
numpy==1.26.4
|
||||
opencv-python==4.9.0.80
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from Brain.yovision_brain.domain import (
|
||||
Box,
|
||||
Detection,
|
||||
Point,
|
||||
Zone,
|
||||
ZoneEntryEvaluator,
|
||||
point_in_polygon,
|
||||
zone_from_payload,
|
||||
)
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.zone = Zone(
|
||||
"zone-1",
|
||||
"危险区域",
|
||||
1,
|
||||
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
|
||||
)
|
||||
|
||||
def test_point_in_polygon_includes_boundary(self) -> None:
|
||||
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
|
||||
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
|
||||
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
|
||||
|
||||
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "3 to 32"):
|
||||
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Point(1.1, 0.5)
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Point(float("nan"), 0.5)
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Box(0.1, 0.1, float("inf"), 0.9)
|
||||
|
||||
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
|
||||
updated = zone_from_payload(
|
||||
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
|
||||
self.zone,
|
||||
)
|
||||
self.assertEqual(updated.version, 2)
|
||||
self.assertEqual(updated.name, "新区域")
|
||||
with self.assertRaisesRegex(ValueError, "unknown"):
|
||||
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
|
||||
|
||||
|
||||
class ZoneEntryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.zone = Zone(
|
||||
"zone-1",
|
||||
"危险区域",
|
||||
1,
|
||||
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
|
||||
)
|
||||
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
|
||||
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
|
||||
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def detection(track: str, center_x: float) -> Detection:
|
||||
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
|
||||
|
||||
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
|
||||
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.assertEqual(events, [])
|
||||
self.assertTrue(states["P-1"])
|
||||
|
||||
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
|
||||
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||
|
||||
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
|
||||
self.assertEqual(repeated, [])
|
||||
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
|
||||
payload = events[0].as_dict()
|
||||
self.assertNotIn("id", payload)
|
||||
self.assertEqual(payload["confidence"], None)
|
||||
self.assertTrue(payload["fixture"])
|
||||
|
||||
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
|
||||
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
self.evaluator.evaluate(4, self.now, self.zone, [])
|
||||
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.assertEqual(events, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from Brain.yovision_brain.runtime import DemoEngine
|
||||
from Brain.yovision_brain.source import SyntheticSource, cv2
|
||||
|
||||
|
||||
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
|
||||
class RuntimeTests(unittest.TestCase):
|
||||
def test_rejects_non_finite_fps(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "fps"):
|
||||
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
|
||||
|
||||
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
|
||||
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
|
||||
engine.step()
|
||||
state = engine.state()
|
||||
self.assertTrue(state["source"]["fixture"])
|
||||
self.assertEqual(state["detector"]["name"], "scripted_fixture")
|
||||
self.assertEqual(state["frame"]["width"], 320)
|
||||
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
|
||||
self.assertNotIn("url", state["source"])
|
||||
|
||||
def test_zone_update_increments_version(self) -> None:
|
||||
engine = DemoEngine(SyntheticSource(width=320, height=180))
|
||||
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||
self.assertEqual(updated.version, 2)
|
||||
self.assertEqual(engine.state()["zone"]["name"], "新区域")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
|
||||
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
def __init__(self) -> None:
|
||||
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
|
||||
|
||||
def state(self) -> dict[str, object]:
|
||||
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
|
||||
|
||||
def frame_jpeg(self) -> bytes:
|
||||
return b"\xff\xd8safe-jpeg\xff\xd9"
|
||||
|
||||
def update_zone(self, payload: object) -> Zone:
|
||||
self.zone = zone_from_payload(payload, self.zone)
|
||||
return self.zone
|
||||
|
||||
|
||||
class BindTests(unittest.TestCase):
|
||||
def test_only_explicit_loopback_is_allowed(self) -> None:
|
||||
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
|
||||
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
|
||||
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||
parse_bind("0.0.0.0:8090")
|
||||
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||
parse_bind("192.168.1.10:8090")
|
||||
|
||||
|
||||
class HTTPTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
html = index_path().read_text(encoding="utf-8")
|
||||
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
|
||||
self.connection.request("GET", "/brain-demo")
|
||||
response = self.connection.getresponse()
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn('content="test-token"', body)
|
||||
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
|
||||
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
|
||||
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
|
||||
|
||||
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
|
||||
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
|
||||
forbidden = self.connection.getresponse()
|
||||
forbidden.read()
|
||||
self.assertEqual(forbidden.status, 403)
|
||||
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||
accepted = self.connection.getresponse()
|
||||
payload = json.loads(accepted.read())
|
||||
self.assertEqual(accepted.status, 200)
|
||||
self.assertEqual(payload["version"], 2)
|
||||
|
||||
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||
rejected = self.connection.getresponse()
|
||||
self.assertEqual(rejected.status, 400)
|
||||
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from Brain.yovision_brain.domain import Box
|
||||
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
|
||||
|
||||
|
||||
class StreamURLTests(unittest.TestCase):
|
||||
def test_requires_absolute_single_rtsp_url_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "stream.url"
|
||||
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
|
||||
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
|
||||
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
|
||||
read_stream_url(str(path))
|
||||
self.assertNotIn("127.0.0.1", str(caught.exception))
|
||||
|
||||
def test_rejects_relative_path_without_echoing_input(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "absolute"):
|
||||
read_stream_url("camera-secret.url")
|
||||
|
||||
def test_rejects_url_files_inside_repository(self) -> None:
|
||||
repository_file = Path(__file__).resolve()
|
||||
with self.assertRaisesRegex(ValueError, "outside the repository"):
|
||||
read_stream_url(str(repository_file))
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
|
||||
tracker = CentroidTracker(max_distance=0.2)
|
||||
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
|
||||
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
|
||||
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
|
||||
self.assertEqual(first[0].track_id, nearby[0].track_id)
|
||||
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||||
|
||||
|
||||
class UIContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = HTML_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
|
||||
self.assertIn("合成回放不等于模型效果", self.html)
|
||||
self.assertIn("离线 HTML 原型数据", self.html)
|
||||
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
|
||||
self.assertNotIn("@import url", self.html)
|
||||
|
||||
def test_accessibility_and_responsive_guards_are_present(self) -> None:
|
||||
required = (
|
||||
'name="viewport"',
|
||||
'class="skip-link"',
|
||||
'aria-live="polite"',
|
||||
':focus-visible',
|
||||
'prefers-reduced-motion',
|
||||
'min-height: 44px',
|
||||
'@media (max-width: 420px)',
|
||||
'.toolbar { display: grid; grid-template-columns: 1fr; }',
|
||||
'键盘用户可使用右侧坐标表单',
|
||||
)
|
||||
for marker in required:
|
||||
with self.subTest(marker=marker):
|
||||
self.assertIn(marker, self.html)
|
||||
|
||||
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
|
||||
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
|
||||
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
|
||||
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user