diff --git a/Brain/.gitignore b/Brain/.gitignore new file mode 100644 index 0000000..c5df872 --- /dev/null +++ b/Brain/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.venv/ +artifacts/ +*.local.json +*.url diff --git a/Brain/.gitkeep b/Brain/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/Brain/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Brain/README.md b/Brain/README.md new file mode 100644 index 0000000..a14e072 --- /dev/null +++ b/Brain/README.md @@ -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` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。 diff --git a/Brain/__init__.py b/Brain/__init__.py new file mode 100644 index 0000000..b4963b6 --- /dev/null +++ b/Brain/__init__.py @@ -0,0 +1 @@ +"""YoVision Brain package root.""" diff --git a/Brain/requirements-demo.txt b/Brain/requirements-demo.txt new file mode 100644 index 0000000..972c957 --- /dev/null +++ b/Brain/requirements-demo.txt @@ -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 diff --git a/Brain/tests/test_domain.py b/Brain/tests/test_domain.py new file mode 100644 index 0000000..4c24f83 --- /dev/null +++ b/Brain/tests/test_domain.py @@ -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() diff --git a/Brain/tests/test_runtime.py b/Brain/tests/test_runtime.py new file mode 100644 index 0000000..dd566b3 --- /dev/null +++ b/Brain/tests/test_runtime.py @@ -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() diff --git a/Brain/tests/test_server.py b/Brain/tests/test_server.py new file mode 100644 index 0000000..fb0a51c --- /dev/null +++ b/Brain/tests/test_server.py @@ -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() diff --git a/Brain/tests/test_source.py b/Brain/tests/test_source.py new file mode 100644 index 0000000..231c522 --- /dev/null +++ b/Brain/tests/test_source.py @@ -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() diff --git a/Brain/tests/test_ui_contract.py b/Brain/tests/test_ui_contract.py new file mode 100644 index 0000000..cbe8289 --- /dev/null +++ b/Brain/tests/test_ui_contract.py @@ -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"", "", self.html) + self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]") + self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/Brain/yovision_brain/__init__.py b/Brain/yovision_brain/__init__.py new file mode 100644 index 0000000..767f605 --- /dev/null +++ b/Brain/yovision_brain/__init__.py @@ -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" diff --git a/Brain/yovision_brain/__main__.py b/Brain/yovision_brain/__main__.py new file mode 100644 index 0000000..8d36065 --- /dev/null +++ b/Brain/yovision_brain/__main__.py @@ -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()) diff --git a/Brain/yovision_brain/domain.py b/Brain/yovision_brain/domain.py new file mode 100644 index 0000000..17e3911 --- /dev/null +++ b/Brain/yovision_brain/domain.py @@ -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)) diff --git a/Brain/yovision_brain/runtime.py b/Brain/yovision_brain/runtime.py new file mode 100644 index 0000000..1640b4d --- /dev/null +++ b/Brain/yovision_brain/runtime.py @@ -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"), + } diff --git a/Brain/yovision_brain/server.py b/Brain/yovision_brain/server.py new file mode 100644 index 0000000..cbfb842 --- /dev/null +++ b/Brain/yovision_brain/server.py @@ -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() diff --git a/Brain/yovision_brain/source.py b/Brain/yovision_brain/source.py new file mode 100644 index 0000000..72f7fe3 --- /dev/null +++ b/Brain/yovision_brain/source.py @@ -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 diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index f97d2a7..c96d0c1 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI ## 当前阶段 -当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。 +当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay 和 Brain 单路可视化工程原型**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。 优先路径: @@ -92,8 +92,10 @@ go -C Sense build ./... go -C Bell test ./... go -C Bell vet ./... go -C Bell build ./... +python -m unittest discover -s Brain/tests -p "test_*.py" -v +python -m compileall -q Brain ``` -日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。 +日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。二者默认只监听回环地址,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。 本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 850c990..886ba96 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -71,6 +71,16 @@ T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进 T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、SHA-256、base64url 和 constant-time compare,数据库继续使用已冻结的 PostgreSQL 17.10/pgx。`cmd/bell-api` 只提供回环 health/ready 和 Sense 审计内部端点;非回环监听必须同时提供绝对路径 TLS 证书/私钥。HMAC key 使用仓库外 version 1 JSON 文件,secret 至少 32 字节;该适配器不替代未来 Bell 公共 JWT/OIDC。 +### 1.5 Brain 单路工程原型(T-017) + +| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 | +| --- | --- | --- | --- | +| Python | `3.10.11` | PSF License;本机 `python --version` 已验证 | 仅冻结 T-017 工程原型和测试语法基线,不等于生产 Savant/DeepStream Python 版本;生产脚手架冻结时通过标准模块边界迁移 | +| NumPy | `1.26.4` | BSD-3-Clause;本机 `numpy.__version__` 已验证 | 只在 frame fixture/OpenCV 数组边界使用;不把 NumPy 类型写入事件候选或 HTTP JSON | +| OpenCV Python | `opencv-python 4.9.0.80` | wheel 构建脚本 MIT、OpenCV Apache-2.0;Windows amd64 wheel SHA-256 `3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0` | 内置 HOG/SVM 只作为可替换 `Detector` 的匿名人员检测演示,不是生产模型;生产退出时替换 detector port,区域判定、候选事实和 UI 状态不依赖 HOG 类型。不得在同一环境混装标准/headless/contrib wheel | + +T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。 + ## 2. 外部项目边界 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 @@ -123,6 +133,14 @@ go -C Bell vet ./... go -C Bell build ./... ``` +Brain 单路工程原型单独执行: + +```powershell +python -m unittest discover -s Brain/tests -p "test_*.py" -v +python -m compileall -q Brain +python -m Brain.yovision_brain --source synthetic +``` + 直接验证: ```powershell @@ -139,7 +157,7 @@ python scripts/validate_harness_governance.py | `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 | | Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 | | PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 | -| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 | +| Brain Python | 当前工程原型:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain` | source、detector port、track、判定状态机、mapper 或模型接口变化 | T-017 合成 fixture 只验工程闭环;命中真实模型任务时另用冻结数据集和目标硬件,不能用 fixture/HOG 结果替代 | | Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 | | 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index f589be5..990c200 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -47,7 +47,7 @@ Sense ── 视频流/触发信号 ──> Brain 1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。 2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。 -3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。 +3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已先建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;合成 fixture 与 OpenCV HOG 均明确不是生产模型,候选只含 `source_event_id`,不自报平台 `id`。 4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。 5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。 6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。 @@ -92,6 +92,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、 - Bell 先验证时间窗、nonce 和 constant-time HMAC,再逐项校验 v1/v2 事件;同 nonce/同摘要重放原结果,同 nonce/不同摘要拒绝。`bell.audit_events` 只追加且不自动清理,只有 10 分钟幂等收据允许 Bell runtime 删除过期行。 - Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。 - Brain 投递失败落本地队列重试,不阻塞实时推理主链路。 +- T-017 的 100 项内存事件环只服务单路工程演示,重启可丢失且不等同于上述投递队列;T-018 必须另行实现有界持久 Outbox、身份映射、认证和幂等确认,不能把 demo 内存状态升级为生产 transport。 - Alert 先落库再投递,进程重启恢复未完成升级链。 - 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。 - 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。 @@ -118,7 +119,7 @@ Bell/{web,packs,contracts} deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests} ``` -Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API,Brain 仍为目录占位。 +Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API。Brain 已有 T-017 单路工程原型,包括合成/RTSP source、HOG 演示 detector、track、zone entry、回环服务与自包含 UI;尚无生产模型、GPU pipeline、持久 Outbox 或 Bell ingress。 ## 10. 开发顺序 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b78884d..f4a5c45 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -38,6 +38,7 @@ - Bell 事件校验、不可变存储和 ULID。 - T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。 - T-016:冻结并实现 Sense Outbox → Bell 内部审计 relay;使用 HMAC、nonce 收据、数据库时钟 lease/fencing、逐项确认和 dead letter,在不共享 schema 权限的前提下写入 Bell 不可变全局审计事实。 +- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。 - 规则引擎、场景包加载、预警状态机与双路径投递。 - 最小 Web/App 处置流程、RBAC 与审计。 - 现场误报基线和反馈队列。 diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index d82d912..4fd18e4 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -106,6 +106,14 @@ - 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。 - 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。 +## US-014 演示单路匿名区域事件链 + +- 角色:售前/实施工程师、客户项目负责人。 +- 目标:在没有完整 Bell 业务闭环前,直接看到一条视频源如何形成匿名人员框、track、区域命中和事件候选,并能当场调整多边形区域。 +- 价值:尽早验证客户能理解的主链路,同时把“工程已连通”和“生产模型效果已验收”严格分开。 +- 验收:默认合成回放无外部条件即可重复运行且始终标识为 fixture;真实流只通过仓库外配置接入且页面不显示 URL/凭据;HOG 适配器明确不是生产模型,无检测时不伪造人员框;区域支持画布加点和键盘坐标等效路径;候选事实只有 `source_event_id`,不伪造 Bell 平台 ID、Alert 或处置状态。 +- 关联:RQ-C-11~RQ-C-15,IX-017、IX-024;T-017。 + ## 追溯规则 新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 2183b1e..b032240 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -27,6 +27,7 @@ | IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 | | IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 | | IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 | +| IX-024 | Brain 单路工程演示 | 固定展示来源类型、fixture/真实检测边界、连接状态、detector 名称、帧序号、处理耗时、匿名 track、区域内外和最近候选;合成/离线数据不得伪装为模型输出。区域支持画布加点、撤销、清空、保存和坐标表单等效路径;保存失败保留草稿。页面不得展示流 URL、凭据、平台事件 ID、Alert 或虚构准确率 | US-014 | M3 | ## 全局状态 diff --git a/docs/current-state.md b/docs/current-state.md index 71de7de..f01d68f 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,19 +4,20 @@ ## 当前阶段 -- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。 -- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository,但仍没有 Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或 Web/H5。 +- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay 及 Brain 单路匿名区域事件工程原型。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。 +- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository。Brain 已包含单路 source/detector/track/zone-entry 流水线和回环可视化页,但仍没有生产模型/GPU pipeline、Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或正式 Web/H5。 - 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。 ## 仓库现实 -- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。 +- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 已有 Python 3.10 单路工程原型、19 项单元/HTTP/UI 契约测试和 `docs/design/brain/index.html` 自包含页面。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。 - Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。 - T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。 - T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。 - Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`015` migration 可重放;Sense Outbox fencing、Bell event/global-audit repository、nonce 收据、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。 - T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。 - T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。 +- T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。 - MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。 - T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。 - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 @@ -53,6 +54,14 @@ go -C Bell vet ./... go -C Bell build ./... ``` +Brain 单路工程原型验证与启动: + +```powershell +python -m unittest discover -s Brain/tests -p "test_*.py" -v +python -m compileall -q Brain +python -m Brain.yovision_brain --source synthetic +``` + 跨平台直接验证: ```powershell @@ -86,17 +95,18 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针 - S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。 - 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。 - 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。 -- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 +- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 - 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。 - 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。 ## 下一步 -客户网络仍未提供,T-013 WireGuard 继续后置。T-015/T-016 已分别落地 Bell 不可变事件存储和 Sense 全局审计 relay;下一项应独立冻结 Brain→Bell 业务事件 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-016 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。 +客户网络仍未提供,T-013 WireGuard 继续后置。T-015~T-017 已分别落地 Bell 不可变事件存储、Sense 全局审计 relay 和 Brain 单路工程原型;下一项是 T-018,独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-017 不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。 ## 已知风险 - M0 临时 NVR 容易被误当生产基线,必须持续隔离。 - 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。 - 未经真实硬件压测不能给出 GPU 路数承诺。 +- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。 - Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。 diff --git a/docs/design/brain/index.html b/docs/design/brain/index.html new file mode 100644 index 0000000..ec3d580 --- /dev/null +++ b/docs/design/brain/index.html @@ -0,0 +1,476 @@ + + + + + + + + + YoVision Brain · 单路区域事件工程原型 + + + + +
+ 工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺 +
+
+
+
+ +
YoVision Brain单路匿名区域事件原型
+
+
+ 正在连接 + 来源 合成回放 + 区域版本 v1 +
+
+ +
+
+
+

单路检测与区域判定

+

验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。

+
+
加载运行状态
+
+ +
+
+
+

实时预览

检测框与区域均使用归一化坐标叠加

+
+ + +
+
+
+ 单路视频画面;检测框与区域由叠加层描述 + +
等待首帧
+
+ +
+
目标处理帧率2.0 FPS
+
单帧耗时—
+
当前人员0
+
区域内人员0
+
+
+ + +
+ +
+ +
边界清楚比动画更重要

Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。

+
+
+
+
+ + + + diff --git a/docs/routes.md b/docs/routes.md index 40e41f4..7b73460 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -20,6 +20,14 @@ 这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。 +## 工程演示路由 + +| 路由 | 所有者 | 关键约束 | +| --- | --- | --- | +| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 | + +该路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页暴露到非可信网络或嵌入客户公共系统。 + ## App 候选导航 - 预警:待确认与升级中的事件。 @@ -44,6 +52,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审 ## 组件归属 -- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。 +- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。 - 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。 - 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。 diff --git a/docs/tasks/T-017.md b/docs/tasks/T-017.md index 55763aa..4802b45 100644 --- a/docs/tasks/T-017.md +++ b/docs/tasks/T-017.md @@ -3,12 +3,12 @@ id: T-017 title: 建立 Brain 单路匿名区域事件可视化工程原型 phase: 3 deps: [T-016] -status: TODO +status: DONE created: 2026-08-11 issue: 59 -context_ref: null -claim_branch: null -work_branch: null +context_ref: 2e047379228a30841da7ceed8cfb86275b3c330e +claim_branch: claims/T-017 +work_branch: agent/codex/T-017 write_paths: - docs/tasks/T-017.md - Brain/ @@ -74,4 +74,8 @@ Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储, ## 执行记录 -- 2026-08-11:按用户指定顺序建立 T-017;实现尚未开始。主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。 +- 2026-08-11:按用户指定顺序建立 T-017;主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。 +- 2026-08-11:建立 `FrameSource → Detector → Tracker → ZoneEntryEvaluator → EventCandidate` 单路流水线。默认合成 fixture 可重复运行;可选真实流只从仓库外绝对路径文件读取 RTSP(S) URL,OpenCV HOG 仅作为非生产演示 detector port。区域进入只在 `outside → inside` 时触发,候选不含 Bell 平台 `id`。 +- 2026-08-11:实现只允许显式回环地址的工程服务和自包含 `/brain-demo` 页面;HTTP body 上限 64 KiB,区域写入使用进程级临时 token,响应带 CSP、拒绝 framing、禁止缓存且不记录请求路径。桌面截图已目检;Edge CDP 在 375×900 设备视口实测 `innerWidth=clientWidth=scrollWidth=375`,可见按钮最小高度 44 px,移动端工具栏单列且无横向溢出。截图仅在系统临时目录用于 QA,未提交。 +- 2026-08-11:独立 CLI 合成模式回环 smoke 通过:`health/state/frame/page/zone PUT` 均为 HTTP 200,source 为 `synthetic`、`fixture=true`、JPEG 可读、区域版本由 v1 递增至 v2。使用标准入口 `./init.ps1` 通过 68 项根测试、19 项 Brain 测试、Brain compileall,以及 Sense/Bell generate/test/vet/build;`git diff --check` 通过。 +- 边界:未连接真实摄像头或目标 GPU,未引入生产模型、Brain→Bell transport、持久 Outbox、规则/Alert 或客户产品 UI;这些结果只关闭匿名单路工程原型,不构成算法效果、真实多路或生产 SLA 验收。 diff --git a/init.ps1 b/init.ps1 index f194bf5..fe8f06f 100644 --- a/init.ps1 +++ b/init.ps1 @@ -10,9 +10,9 @@ $ErrorActionPreference = "Stop" Set-Location -Path $PSScriptRoot -# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 +# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。 $InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell mod download" -$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" +$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -c 'from importlib.metadata import version; assert version(`"numpy`") == `"1.26.4`"; assert version(`"opencv-python`") == `"4.9.0.80`"'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s Brain/tests -p 'test_*.py' -v; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m compileall -q Brain; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" $StartCmd = "go -C Sense run ./cmd/sense-api" function Assert-Configured { diff --git a/init.sh b/init.sh index 9e49b98..77546cf 100644 --- a/init.sh +++ b/init.sh @@ -12,9 +12,9 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" -# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 +# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。 INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell mod download") -VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...") +VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && python3 -c 'from importlib.metadata import version; assert version(\"numpy\") == \"1.26.4\"; assert version(\"opencv-python\") == \"4.9.0.80\"' && python3 -m unittest discover -s Brain/tests -p 'test_*.py' -v && python3 -m compileall -q Brain && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...") START_CMD=(go -C Sense run ./cmd/sense-api) ensure_configured() {