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
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
artifacts/
*.local.json
*.url
-1
View File
@@ -1 +0,0 @@
+53
View File
@@ -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` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
+1
View File
@@ -0,0 +1 @@
"""YoVision Brain package root."""
+3
View File
@@ -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
+96
View File
@@ -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()
+33
View File
@@ -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()
+82
View File
@@ -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()
+43
View File
@@ -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()
+45
View File
@@ -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()
+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