Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b166c5af | ||
|
|
e46f175551 | ||
|
|
2ca51aa2bc | ||
|
|
19a3233f11 | ||
|
|
a2790c1f5e |
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
artifacts/
|
||||
*.local.json
|
||||
*.url
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# YoVision Brain 单路工程原型
|
||||
|
||||
T-017 提供一条可见、可重复的工程链路:单路 frame source → 匿名人员检测/fixture → track → 归一化多边形区域 → `zone_entry` 候选事实。它不是生产模型、不是 NVR,也没有 Brain→Bell transport。
|
||||
|
||||
## 环境
|
||||
|
||||
- Python `3.10.11`
|
||||
- NumPy `1.26.4`
|
||||
- OpenCV `4.9.0.80`
|
||||
|
||||
本任务复用本机已验证版本;新环境显式安装:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r Brain/requirements-demo.txt
|
||||
```
|
||||
|
||||
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
|
||||
|
||||
## 启动
|
||||
|
||||
无需摄像头的确定性合成回放:
|
||||
|
||||
```powershell
|
||||
python -m Brain.yovision_brain --source synthetic
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
|
||||
|
||||
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
|
||||
|
||||
```powershell
|
||||
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
|
||||
```
|
||||
|
||||
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
|
||||
|
||||
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s Brain/tests -p "test_*.py" -v
|
||||
python -m compileall -q Brain
|
||||
```
|
||||
|
||||
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
|
||||
|
||||
## 边界
|
||||
|
||||
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
|
||||
- 事件只保留在最多 100 项的内存环中;重启即丢失是刻意边界。
|
||||
- T-018 冻结并实现身份映射、候选契约、持久 Outbox、HMAC、幂等和 Bell ingress。
|
||||
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
|
||||
@@ -0,0 +1 @@
|
||||
"""YoVision Brain package root."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# T-017 engineering prototype only. Do not infer the production Brain stack.
|
||||
numpy==1.26.4
|
||||
opencv-python==4.9.0.80
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from Brain.yovision_brain.domain import (
|
||||
Box,
|
||||
Detection,
|
||||
Point,
|
||||
Zone,
|
||||
ZoneEntryEvaluator,
|
||||
point_in_polygon,
|
||||
zone_from_payload,
|
||||
)
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.zone = Zone(
|
||||
"zone-1",
|
||||
"危险区域",
|
||||
1,
|
||||
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
|
||||
)
|
||||
|
||||
def test_point_in_polygon_includes_boundary(self) -> None:
|
||||
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
|
||||
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
|
||||
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
|
||||
|
||||
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "3 to 32"):
|
||||
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Point(1.1, 0.5)
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Point(float("nan"), 0.5)
|
||||
with self.assertRaisesRegex(ValueError, "within"):
|
||||
Box(0.1, 0.1, float("inf"), 0.9)
|
||||
|
||||
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
|
||||
updated = zone_from_payload(
|
||||
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
|
||||
self.zone,
|
||||
)
|
||||
self.assertEqual(updated.version, 2)
|
||||
self.assertEqual(updated.name, "新区域")
|
||||
with self.assertRaisesRegex(ValueError, "unknown"):
|
||||
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
|
||||
|
||||
|
||||
class ZoneEntryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.zone = Zone(
|
||||
"zone-1",
|
||||
"危险区域",
|
||||
1,
|
||||
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
|
||||
)
|
||||
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
|
||||
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
|
||||
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def detection(track: str, center_x: float) -> Detection:
|
||||
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
|
||||
|
||||
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
|
||||
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.assertEqual(events, [])
|
||||
self.assertTrue(states["P-1"])
|
||||
|
||||
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
|
||||
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
|
||||
|
||||
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
|
||||
self.assertEqual(repeated, [])
|
||||
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
|
||||
payload = events[0].as_dict()
|
||||
self.assertNotIn("id", payload)
|
||||
self.assertEqual(payload["confidence"], None)
|
||||
self.assertTrue(payload["fixture"])
|
||||
|
||||
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
|
||||
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
|
||||
self.evaluator.evaluate(4, self.now, self.zone, [])
|
||||
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
|
||||
self.assertEqual(events, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from Brain.yovision_brain.runtime import DemoEngine
|
||||
from Brain.yovision_brain.source import SyntheticSource, cv2
|
||||
|
||||
|
||||
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
|
||||
class RuntimeTests(unittest.TestCase):
|
||||
def test_rejects_non_finite_fps(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "fps"):
|
||||
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
|
||||
|
||||
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
|
||||
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
|
||||
engine.step()
|
||||
state = engine.state()
|
||||
self.assertTrue(state["source"]["fixture"])
|
||||
self.assertEqual(state["detector"]["name"], "scripted_fixture")
|
||||
self.assertEqual(state["frame"]["width"], 320)
|
||||
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
|
||||
self.assertNotIn("url", state["source"])
|
||||
|
||||
def test_zone_update_increments_version(self) -> None:
|
||||
engine = DemoEngine(SyntheticSource(width=320, height=180))
|
||||
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||
self.assertEqual(updated.version, 2)
|
||||
self.assertEqual(engine.state()["zone"]["name"], "新区域")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
|
||||
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
def __init__(self) -> None:
|
||||
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
|
||||
|
||||
def state(self) -> dict[str, object]:
|
||||
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
|
||||
|
||||
def frame_jpeg(self) -> bytes:
|
||||
return b"\xff\xd8safe-jpeg\xff\xd9"
|
||||
|
||||
def update_zone(self, payload: object) -> Zone:
|
||||
self.zone = zone_from_payload(payload, self.zone)
|
||||
return self.zone
|
||||
|
||||
|
||||
class BindTests(unittest.TestCase):
|
||||
def test_only_explicit_loopback_is_allowed(self) -> None:
|
||||
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
|
||||
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
|
||||
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||
parse_bind("0.0.0.0:8090")
|
||||
with self.assertRaisesRegex(ValueError, "loopback"):
|
||||
parse_bind("192.168.1.10:8090")
|
||||
|
||||
|
||||
class HTTPTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
html = index_path().read_text(encoding="utf-8")
|
||||
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
|
||||
self.connection.request("GET", "/brain-demo")
|
||||
response = self.connection.getresponse()
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn('content="test-token"', body)
|
||||
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
|
||||
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
|
||||
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
|
||||
|
||||
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
|
||||
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
|
||||
forbidden = self.connection.getresponse()
|
||||
forbidden.read()
|
||||
self.assertEqual(forbidden.status, 403)
|
||||
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||
accepted = self.connection.getresponse()
|
||||
payload = json.loads(accepted.read())
|
||||
self.assertEqual(accepted.status, 200)
|
||||
self.assertEqual(payload["version"], 2)
|
||||
|
||||
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
|
||||
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
|
||||
rejected = self.connection.getresponse()
|
||||
self.assertEqual(rejected.status, 400)
|
||||
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from Brain.yovision_brain.domain import Box
|
||||
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
|
||||
|
||||
|
||||
class StreamURLTests(unittest.TestCase):
|
||||
def test_requires_absolute_single_rtsp_url_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "stream.url"
|
||||
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
|
||||
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
|
||||
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
|
||||
read_stream_url(str(path))
|
||||
self.assertNotIn("127.0.0.1", str(caught.exception))
|
||||
|
||||
def test_rejects_relative_path_without_echoing_input(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "absolute"):
|
||||
read_stream_url("camera-secret.url")
|
||||
|
||||
def test_rejects_url_files_inside_repository(self) -> None:
|
||||
repository_file = Path(__file__).resolve()
|
||||
with self.assertRaisesRegex(ValueError, "outside the repository"):
|
||||
read_stream_url(str(repository_file))
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
|
||||
tracker = CentroidTracker(max_distance=0.2)
|
||||
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
|
||||
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
|
||||
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
|
||||
self.assertEqual(first[0].track_id, nearby[0].track_id)
|
||||
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||||
|
||||
|
||||
class UIContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = HTML_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
|
||||
self.assertIn("合成回放不等于模型效果", self.html)
|
||||
self.assertIn("离线 HTML 原型数据", self.html)
|
||||
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
|
||||
self.assertNotIn("@import url", self.html)
|
||||
|
||||
def test_accessibility_and_responsive_guards_are_present(self) -> None:
|
||||
required = (
|
||||
'name="viewport"',
|
||||
'class="skip-link"',
|
||||
'aria-live="polite"',
|
||||
':focus-visible',
|
||||
'prefers-reduced-motion',
|
||||
'min-height: 44px',
|
||||
'@media (max-width: 420px)',
|
||||
'.toolbar { display: grid; grid-template-columns: 1fr; }',
|
||||
'键盘用户可使用右侧坐标表单',
|
||||
)
|
||||
for marker in required:
|
||||
with self.subTest(marker=marker):
|
||||
self.assertIn(marker, self.html)
|
||||
|
||||
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
|
||||
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
|
||||
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
|
||||
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Single-stream Brain engineering prototype.
|
||||
|
||||
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
|
||||
that contract and its delivery semantics.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .runtime import DemoEngine
|
||||
from .server import parse_bind, serve
|
||||
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
|
||||
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
|
||||
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
|
||||
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
|
||||
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
require_opencv()
|
||||
parse_bind(args.bind)
|
||||
if args.source == "stream":
|
||||
if not args.stream_url_file:
|
||||
raise ValueError("--stream-url-file is required for stream mode")
|
||||
source = StreamSource(read_stream_url(args.stream_url_file))
|
||||
else:
|
||||
if args.stream_url_file:
|
||||
raise ValueError("--stream-url-file is only valid for stream mode")
|
||||
source = SyntheticSource()
|
||||
engine = DemoEngine(source, fps=args.fps)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Brain demo listening on http://{args.bind}/brain-demo")
|
||||
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
|
||||
try:
|
||||
serve(engine, args.bind)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Iterable, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Point:
|
||||
x: float
|
||||
y: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
|
||||
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
|
||||
):
|
||||
raise ValueError("point coordinates must be within [0, 1]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Box:
|
||||
x1: float
|
||||
y1: float
|
||||
x2: float
|
||||
y2: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
values = (self.x1, self.y1, self.x2, self.y2)
|
||||
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
|
||||
raise ValueError("box coordinates must be within [0, 1]")
|
||||
if self.x1 >= self.x2 or self.y1 >= self.y2:
|
||||
raise ValueError("box must have positive area")
|
||||
|
||||
@property
|
||||
def center(self) -> Point:
|
||||
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Detection:
|
||||
track_id: str
|
||||
class_name: str
|
||||
box: Box
|
||||
detector_score: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.track_id or len(self.track_id) > 128:
|
||||
raise ValueError("track_id must contain 1 to 128 characters")
|
||||
if self.class_name != "person":
|
||||
raise ValueError("T-017 only supports anonymous person detections")
|
||||
if self.detector_score is not None and not math.isfinite(self.detector_score):
|
||||
raise ValueError("detector_score must be finite")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zone:
|
||||
zone_id: str
|
||||
name: str
|
||||
version: int
|
||||
points: tuple[Point, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.zone_id or len(self.zone_id) > 128:
|
||||
raise ValueError("zone_id must contain 1 to 128 characters")
|
||||
if not self.name.strip() or len(self.name) > 80:
|
||||
raise ValueError("zone name must contain 1 to 80 characters")
|
||||
if self.version < 1:
|
||||
raise ValueError("zone version must be positive")
|
||||
if not 3 <= len(self.points) <= 32:
|
||||
raise ValueError("zone must contain 3 to 32 points")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventCandidate:
|
||||
source_event_id: str
|
||||
kind: str
|
||||
source_ref: str
|
||||
track_id: str
|
||||
zone_id: str
|
||||
zone_version: int
|
||||
occurred_at: str
|
||||
confidence: None
|
||||
fixture: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
# Bell owns the platform `id`; it is deliberately absent here.
|
||||
return {
|
||||
"candidate_version": "brain-demo-v1",
|
||||
"source_event_id": self.source_event_id,
|
||||
"kind": self.kind,
|
||||
"source_ref": self.source_ref,
|
||||
"track_id": self.track_id,
|
||||
"zone_id": self.zone_id,
|
||||
"zone_version": self.zone_version,
|
||||
"occurred_at": self.occurred_at,
|
||||
"confidence": self.confidence,
|
||||
"fixture": self.fixture,
|
||||
}
|
||||
|
||||
|
||||
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
|
||||
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
|
||||
if abs(cross) > epsilon:
|
||||
return False
|
||||
return (
|
||||
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
|
||||
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
|
||||
)
|
||||
|
||||
|
||||
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
|
||||
if len(polygon) < 3:
|
||||
return False
|
||||
inside = False
|
||||
previous = polygon[-1]
|
||||
for current in polygon:
|
||||
if _on_segment(point, previous, current):
|
||||
return True
|
||||
crosses = (current.y > point.y) != (previous.y > point.y)
|
||||
if crosses:
|
||||
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
|
||||
if point.x < boundary_x:
|
||||
inside = not inside
|
||||
previous = current
|
||||
return inside
|
||||
|
||||
|
||||
class ZoneEntryEvaluator:
|
||||
def __init__(
|
||||
self,
|
||||
source_ref: str,
|
||||
fixture: bool,
|
||||
track_ttl_frames: int = 8,
|
||||
event_id_factory: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
if track_ttl_frames < 1:
|
||||
raise ValueError("track_ttl_frames must be positive")
|
||||
self._source_ref = source_ref
|
||||
self._fixture = fixture
|
||||
self._track_ttl_frames = track_ttl_frames
|
||||
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
|
||||
self._inside: dict[str, bool] = {}
|
||||
self._last_seen: dict[str, int] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._inside.clear()
|
||||
self._last_seen.clear()
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
sequence: int,
|
||||
occurred_at: datetime,
|
||||
zone: Zone,
|
||||
detections: Iterable[Detection],
|
||||
) -> tuple[list[EventCandidate], dict[str, bool]]:
|
||||
if sequence < 0:
|
||||
raise ValueError("sequence cannot be negative")
|
||||
if occurred_at.tzinfo is None:
|
||||
raise ValueError("occurred_at must be timezone-aware")
|
||||
|
||||
expired = [
|
||||
track_id
|
||||
for track_id, last_seen in self._last_seen.items()
|
||||
if sequence - last_seen > self._track_ttl_frames
|
||||
]
|
||||
for track_id in expired:
|
||||
self._inside.pop(track_id, None)
|
||||
self._last_seen.pop(track_id, None)
|
||||
|
||||
events: list[EventCandidate] = []
|
||||
states: dict[str, bool] = {}
|
||||
for detection in detections:
|
||||
inside = point_in_polygon(detection.box.center, zone.points)
|
||||
previous = self._inside.get(detection.track_id)
|
||||
if previous is False and inside:
|
||||
events.append(
|
||||
EventCandidate(
|
||||
source_event_id=self._event_id_factory(),
|
||||
kind="zone_entry",
|
||||
source_ref=self._source_ref,
|
||||
track_id=detection.track_id,
|
||||
zone_id=zone.zone_id,
|
||||
zone_version=zone.version,
|
||||
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
confidence=None,
|
||||
fixture=self._fixture,
|
||||
)
|
||||
)
|
||||
self._inside[detection.track_id] = inside
|
||||
self._last_seen[detection.track_id] = sequence
|
||||
states[detection.track_id] = inside
|
||||
return events, states
|
||||
|
||||
|
||||
def zone_from_payload(payload: object, current: Zone) -> Zone:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be an object")
|
||||
if set(payload) - {"name", "points"}:
|
||||
raise ValueError("unknown zone fields are not allowed")
|
||||
name = payload.get("name", current.name)
|
||||
raw_points = payload.get("points")
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("zone name must be a string")
|
||||
if not isinstance(raw_points, list):
|
||||
raise ValueError("zone points must be an array")
|
||||
points: list[Point] = []
|
||||
for raw_point in raw_points:
|
||||
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
|
||||
raise ValueError("each point must contain only x and y")
|
||||
x = raw_point["x"]
|
||||
y = raw_point["y"]
|
||||
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
|
||||
raise ValueError("point coordinates must be numbers")
|
||||
points.append(Point(float(x), float(y)))
|
||||
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
|
||||
from .source import CentroidTracker, HOGPersonDetector, require_opencv
|
||||
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
cv2 = None
|
||||
|
||||
|
||||
DEFAULT_ZONE = Zone(
|
||||
"zone-demo-01",
|
||||
"楼梯口危险区",
|
||||
1,
|
||||
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
|
||||
)
|
||||
|
||||
|
||||
class DemoEngine:
|
||||
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100) -> None:
|
||||
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
|
||||
raise ValueError("fps must be within (0, 30]")
|
||||
if not 1 <= event_limit <= 100:
|
||||
raise ValueError("event_limit must be within [1, 100]")
|
||||
require_opencv()
|
||||
self._source = source
|
||||
self._fps = fps
|
||||
self._detector = None if source.fixture else HOGPersonDetector()
|
||||
self._tracker = CentroidTracker()
|
||||
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
|
||||
self._zone = DEFAULT_ZONE
|
||||
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._sequence = 0
|
||||
self._frame_jpeg: bytes | None = None
|
||||
self._frame_width = 0
|
||||
self._frame_height = 0
|
||||
self._captured_at: str | None = None
|
||||
self._detections: list[dict[str, object]] = []
|
||||
self._latency_ms: float | None = None
|
||||
self._connected = False
|
||||
self._last_error_code: str | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
self._source.close()
|
||||
|
||||
def _run(self) -> None:
|
||||
interval = 1.0 / self._fps
|
||||
while not self._stop.is_set():
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
self.step()
|
||||
except RuntimeError:
|
||||
with self._lock:
|
||||
self._connected = False
|
||||
self._last_error_code = "source_unavailable"
|
||||
elapsed = time.perf_counter() - started
|
||||
self._stop.wait(max(0.0, interval - elapsed))
|
||||
|
||||
def step(self) -> None:
|
||||
started = time.perf_counter()
|
||||
packet = self._source.read()
|
||||
self._sequence += 1
|
||||
if packet.scripted_detections is not None:
|
||||
detections = list(packet.scripted_detections)
|
||||
else:
|
||||
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
|
||||
detections = self._tracker.update(raw_boxes, self._sequence)
|
||||
with self._lock:
|
||||
zone = self._zone
|
||||
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
|
||||
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
|
||||
if not ok:
|
||||
raise RuntimeError("frame encoding failed")
|
||||
height, width = packet.frame.shape[:2]
|
||||
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
|
||||
with self._lock:
|
||||
for event in new_events:
|
||||
self._events.appendleft(event.as_dict())
|
||||
self._frame_jpeg = encoded.tobytes()
|
||||
self._frame_width = int(width)
|
||||
self._frame_height = int(height)
|
||||
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
self._detections = serialized_detections
|
||||
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
|
||||
self._connected = True
|
||||
self._last_error_code = None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
|
||||
return {
|
||||
"track_id": detection.track_id,
|
||||
"class": detection.class_name,
|
||||
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
|
||||
"detector_score": detection.detector_score,
|
||||
"inside_zone": inside,
|
||||
}
|
||||
|
||||
def update_zone(self, payload: object) -> Zone:
|
||||
with self._lock:
|
||||
updated = zone_from_payload(payload, self._zone)
|
||||
self._zone = updated
|
||||
self._evaluator.reset()
|
||||
return updated
|
||||
|
||||
def frame_jpeg(self) -> bytes | None:
|
||||
with self._lock:
|
||||
return self._frame_jpeg
|
||||
|
||||
def state(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
zone = self._zone
|
||||
return {
|
||||
"prototype": True,
|
||||
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
|
||||
"source": {
|
||||
"mode": self._source.mode,
|
||||
"label": self._source.label,
|
||||
"fixture": self._source.fixture,
|
||||
"connected": self._connected,
|
||||
"ref": self._source.source_ref,
|
||||
},
|
||||
"detector": {
|
||||
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
|
||||
"production_ready": False,
|
||||
},
|
||||
"frame": {
|
||||
"sequence": self._sequence,
|
||||
"width": self._frame_width,
|
||||
"height": self._frame_height,
|
||||
"captured_at": self._captured_at,
|
||||
},
|
||||
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
|
||||
"zone": {
|
||||
"id": zone.zone_id,
|
||||
"name": zone.name,
|
||||
"version": zone.version,
|
||||
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
||||
},
|
||||
"detections": list(self._detections),
|
||||
"events": list(self._events),
|
||||
"last_error_code": self._last_error_code,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .domain import Zone
|
||||
|
||||
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def parse_bind(value: str) -> tuple[str, int]:
|
||||
parsed = urlsplit(f"//{value}")
|
||||
host = parsed.hostname
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid demo bind address") from exc
|
||||
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
|
||||
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
|
||||
return host, port
|
||||
|
||||
|
||||
def index_path() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||||
|
||||
|
||||
class DemoHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
|
||||
self.engine = engine
|
||||
self.demo_token = token
|
||||
self.html = html
|
||||
super().__init__(address, DemoHandler)
|
||||
|
||||
|
||||
class DemoHandler(BaseHTTPRequestHandler):
|
||||
server: DemoHTTPServer
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
|
||||
return
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
|
||||
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_json(self, status: int, payload: object) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
self._send_bytes(status, "application/json; charset=utf-8", body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlsplit(self.path).path
|
||||
if path in {"/", "/brain-demo"}:
|
||||
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
|
||||
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||
return
|
||||
if path == "/healthz":
|
||||
self._send_json(HTTPStatus.OK, {"status": "ok"})
|
||||
return
|
||||
if path == "/api/v1/state":
|
||||
self._send_json(HTTPStatus.OK, self.server.engine.state())
|
||||
return
|
||||
if path == "/api/v1/frame.jpg":
|
||||
frame = self.server.engine.frame_jpeg()
|
||||
if frame is None:
|
||||
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
|
||||
return
|
||||
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
|
||||
return
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||||
|
||||
def do_PUT(self) -> None: # noqa: N802
|
||||
if urlsplit(self.path).path != "/api/v1/zones/active":
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||||
return
|
||||
raw_length = self.headers.get("Content-Length")
|
||||
try:
|
||||
length = int(raw_length or "-1")
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length < 0 or length > MAX_BODY_BYTES:
|
||||
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
|
||||
return
|
||||
body = self.rfile.read(length)
|
||||
supplied = self.headers.get("X-Brain-Demo-Token", "")
|
||||
if not hmac.compare_digest(supplied, self.server.demo_token):
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
|
||||
return
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
zone: Zone = self.server.engine.update_zone(payload)
|
||||
except (UnicodeError, json.JSONDecodeError, ValueError):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
|
||||
return
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"id": zone.zone_id,
|
||||
"name": zone.name,
|
||||
"version": zone.version,
|
||||
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
|
||||
html = index_path().read_text(encoding="utf-8")
|
||||
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
|
||||
|
||||
|
||||
def serve(engine: Any, bind: str) -> None:
|
||||
server = build_server(engine, bind)
|
||||
engine.start()
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
finally:
|
||||
server.server_close()
|
||||
engine.stop()
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .domain import Box, Detection
|
||||
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
import numpy as np # type: ignore
|
||||
except ImportError: # pragma: no cover - exercised by the startup failure path
|
||||
cv2 = None
|
||||
np = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FramePacket:
|
||||
frame: Any
|
||||
captured_at: datetime
|
||||
scripted_detections: tuple[Detection, ...] | None
|
||||
|
||||
|
||||
def require_opencv() -> None:
|
||||
if cv2 is None or np is None:
|
||||
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
|
||||
|
||||
|
||||
def read_stream_url(path_value: str) -> str:
|
||||
path = Path(path_value)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("stream URL file must be an absolute external path")
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ValueError("stream URL file does not exist") from exc
|
||||
if not resolved.is_file():
|
||||
raise ValueError("stream URL file does not exist")
|
||||
repository_root = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
resolved.relative_to(repository_root)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise ValueError("stream URL file must be outside the repository")
|
||||
try:
|
||||
with resolved.open("rb") as stream:
|
||||
raw = stream.read(4097)
|
||||
except OSError as exc:
|
||||
raise ValueError("stream URL file cannot be read") from exc
|
||||
if len(raw) > 4096:
|
||||
raise ValueError("stream URL file exceeds 4096 bytes")
|
||||
try:
|
||||
lines = raw.decode("utf-8").splitlines()
|
||||
except UnicodeError as exc:
|
||||
raise ValueError("stream URL file must be UTF-8") from exc
|
||||
values = [line.strip() for line in lines if line.strip()]
|
||||
if len(values) != 1:
|
||||
raise ValueError("stream URL file must contain exactly one non-empty line")
|
||||
parsed = urlsplit(values[0])
|
||||
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
|
||||
raise ValueError("stream URL must be an RTSP URL")
|
||||
return values[0]
|
||||
|
||||
|
||||
class SyntheticSource:
|
||||
mode = "synthetic"
|
||||
label = "合成回放"
|
||||
fixture = True
|
||||
source_ref = "demo-camera-01"
|
||||
|
||||
def __init__(self, width: int = 960, height: int = 540) -> None:
|
||||
require_opencv()
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._sequence = 0
|
||||
|
||||
def read(self) -> FramePacket:
|
||||
self._sequence += 1
|
||||
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
||||
frame[:] = (20, 28, 42)
|
||||
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
|
||||
for x in range(0, self.width, 80):
|
||||
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
|
||||
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
|
||||
|
||||
phase = ((self._sequence - 1) % 160) / 159.0
|
||||
center_x = -0.04 + phase * 1.08
|
||||
x1 = max(0.0, center_x - 0.04)
|
||||
x2 = min(1.0, center_x + 0.04)
|
||||
detections: tuple[Detection, ...] = ()
|
||||
if x2 - x1 > 0.01:
|
||||
box = Box(x1, 0.34, x2, 0.82)
|
||||
detections = (Detection("P-DEMO-001", "person", box, None),)
|
||||
px = int(center_x * self.width)
|
||||
head_y = int(self.height * 0.40)
|
||||
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
|
||||
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
|
||||
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
|
||||
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
|
||||
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
|
||||
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
|
||||
return FramePacket(frame, datetime.now(timezone.utc), detections)
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class StreamSource:
|
||||
mode = "stream"
|
||||
label = "外部 MediaMTX / RTSP"
|
||||
fixture = False
|
||||
source_ref = "configured-video-source"
|
||||
|
||||
def __init__(self, stream_url: str) -> None:
|
||||
require_opencv()
|
||||
self._stream_url = stream_url
|
||||
self._capture: Any = None
|
||||
|
||||
def _open(self) -> None:
|
||||
if self._capture is not None:
|
||||
self._capture.release()
|
||||
self._capture = cv2.VideoCapture(self._stream_url)
|
||||
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
def read(self) -> FramePacket:
|
||||
if self._capture is None or not self._capture.isOpened():
|
||||
self._open()
|
||||
ok, frame = self._capture.read()
|
||||
if not ok or frame is None:
|
||||
self._open()
|
||||
raise RuntimeError("stream frame unavailable")
|
||||
return FramePacket(frame, datetime.now(timezone.utc), None)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._capture is not None:
|
||||
self._capture.release()
|
||||
self._capture = None
|
||||
|
||||
|
||||
class HOGPersonDetector:
|
||||
name = "opencv_hog_person_demo"
|
||||
production_ready = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
require_opencv()
|
||||
self._hog = cv2.HOGDescriptor()
|
||||
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
|
||||
|
||||
def detect(self, frame: Any) -> list[tuple[Box, float]]:
|
||||
height, width = frame.shape[:2]
|
||||
scale = min(1.0, 960.0 / max(width, 1))
|
||||
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
|
||||
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
|
||||
result: list[tuple[Box, float]] = []
|
||||
work_height, work_width = working.shape[:2]
|
||||
for raw_box, weight in zip(boxes, weights):
|
||||
x, y, box_width, box_height = (int(value) for value in raw_box)
|
||||
x1 = max(0.0, min(1.0, x / work_width))
|
||||
y1 = max(0.0, min(1.0, y / work_height))
|
||||
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
|
||||
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
|
||||
if x2 > x1 and y2 > y1:
|
||||
result.append((Box(x1, y1, x2, y2), float(weight)))
|
||||
return result
|
||||
|
||||
|
||||
class CentroidTracker:
|
||||
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
|
||||
self._max_distance = max_distance
|
||||
self._ttl_frames = ttl_frames
|
||||
self._next_id = 1
|
||||
self._tracks: dict[str, tuple[Box, int]] = {}
|
||||
|
||||
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
|
||||
incoming = list(boxes)
|
||||
available = set(self._tracks)
|
||||
detections: list[Detection] = []
|
||||
for box, score in incoming:
|
||||
center = box.center
|
||||
selected: str | None = None
|
||||
selected_distance = self._max_distance
|
||||
for track_id in available:
|
||||
old_center = self._tracks[track_id][0].center
|
||||
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
|
||||
if distance < selected_distance:
|
||||
selected = track_id
|
||||
selected_distance = distance
|
||||
if selected is None:
|
||||
selected = f"P-{self._next_id:04d}"
|
||||
self._next_id += 1
|
||||
else:
|
||||
available.remove(selected)
|
||||
self._tracks[selected] = (box, sequence)
|
||||
detections.append(Detection(selected, "person", box, score))
|
||||
for track_id, (_, last_seen) in list(self._tracks.items()):
|
||||
if sequence - last_seen > self._ttl_frames:
|
||||
del self._tracks[track_id]
|
||||
return detections
|
||||
@@ -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)。
|
||||
|
||||
+19
-1
@@ -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、网络与存储必须使用目标环境分别验收 |
|
||||
|
||||
|
||||
@@ -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 项内存事件环只服务单路工程演示,重启可丢失且不等同于上述投递队列;Brain→Bell 后续建议 T-019 必须另行实现有界持久 Outbox、身份映射、认证和幂等确认,不能把 demo 内存状态升级为生产 transport。T-018 先实现 Sense 回环 NVR 管理面纵切,不改变该事件链边界。
|
||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
||||
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||
@@ -118,14 +119,14 @@ 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. 开发顺序
|
||||
|
||||
- M0 不写生产代码。
|
||||
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用;按项目负责人调整,T-017 后先补 Sense 回环 NVR 管理面纵切,再继续 Brain→Bell 事件 ingress。
|
||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||
|
||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
- 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 宣称为生产模型或效果证据。
|
||||
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
|
||||
- 后续建议 T-019:独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不得把 T-017 内存事件环当作生产投递。
|
||||
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
||||
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||
- 现场误报基线和反馈队列。
|
||||
|
||||
@@ -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,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 全局状态
|
||||
|
||||
|
||||
+15
-5
@@ -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 之后先做 T-018 Sense NVR 管理面设备与实时监看纵切,复用现有 Control API/MediaMTX,并限定为默认关闭的回环工程控制台、最多 4 路按需预览;不实现录像/回放或非回环生产认证。Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress 顺延为后续建议 T-019。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
|
||||
|
||||
## 已知风险
|
||||
|
||||
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
|
||||
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
|
||||
- 未经真实硬件压测不能给出 GPU 路数承诺。
|
||||
- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。
|
||||
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>YoVision Brain · 单路区域事件工程原型</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1120;
|
||||
--surface: #111a2b;
|
||||
--surface-2: #172236;
|
||||
--surface-3: #1e2c43;
|
||||
--text: #f5f7fb;
|
||||
--muted: #aebbd0;
|
||||
--subtle: #8291a9;
|
||||
--line: #2b3a52;
|
||||
--primary: #8b7cf6;
|
||||
--primary-strong: #7567e8;
|
||||
--cyan: #38bdf8;
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #fb7185;
|
||||
--focus: #b8e5ff;
|
||||
--radius: 14px;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: var(--bg); }
|
||||
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
|
||||
.skip-link:focus { transform: none; }
|
||||
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
|
||||
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
|
||||
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
|
||||
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
|
||||
.brand strong, .brand span { display: block; }
|
||||
.brand strong { font-size: 16px; }
|
||||
.brand span { color: var(--muted); font-size: 12px; }
|
||||
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
|
||||
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
|
||||
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
|
||||
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
|
||||
main { padding-top: 18px; }
|
||||
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
|
||||
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
|
||||
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
|
||||
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
|
||||
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
|
||||
.card-head h2 { margin: 0; font-size: 15px; }
|
||||
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
|
||||
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
|
||||
.btn:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
|
||||
.btn-quiet { background: transparent; }
|
||||
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
|
||||
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.video-stage img { object-fit: contain; background: #050912; }
|
||||
.video-stage canvas { touch-action: manipulation; }
|
||||
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
|
||||
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
||||
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
|
||||
.legend .zone-key { background: var(--warning); }
|
||||
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
|
||||
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.metric span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||
.side-stack { display: grid; align-content: start; gap: 14px; }
|
||||
.form-body { padding: 14px; display: grid; gap: 12px; }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
|
||||
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
|
||||
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
|
||||
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
|
||||
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
|
||||
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.event strong { font-size: 13px; }
|
||||
.event time, .event code { color: var(--muted); font-size: 12px; }
|
||||
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
|
||||
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
|
||||
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
|
||||
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
|
||||
.architecture-note strong { color: var(--text); }
|
||||
.architecture-note p { margin: 2px 0 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 980px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.app-shell { padding-inline: 12px; }
|
||||
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
|
||||
.top-context { margin-left: 0; justify-content: flex-start; }
|
||||
.page-head { flex-direction: column; }
|
||||
.mode-badge { white-space: normal; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.side-stack { grid-template-columns: 1fr; }
|
||||
.card-head { align-items: flex-start; flex-direction: column; }
|
||||
.toolbar { width: 100%; }
|
||||
.toolbar .btn { flex: 1; }
|
||||
.video-footer { align-items: flex-start; flex-direction: column; }
|
||||
.legend { margin-left: 0; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.prototype-banner { letter-spacing: 0; }
|
||||
.chip { width: 100%; justify-content: flex-start; }
|
||||
.toolbar { display: grid; grid-template-columns: 1fr; }
|
||||
.toolbar .btn { width: 100%; min-width: 0; }
|
||||
.metrics { grid-template-columns: 1fr 1fr; }
|
||||
.metric strong { font-size: 17px; }
|
||||
.form-actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<div class="prototype-banner">
|
||||
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand" aria-label="YoVision Brain">
|
||||
<div class="brand-mark" aria-hidden="true">Y</div>
|
||||
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
|
||||
</div>
|
||||
<div class="top-context" aria-label="运行上下文">
|
||||
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
|
||||
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
|
||||
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" tabindex="-1">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>单路检测与区域判定</h1>
|
||||
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。</p>
|
||||
</div>
|
||||
<div id="mode-badge" class="mode-badge">加载运行状态</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card" aria-labelledby="preview-title">
|
||||
<div class="card-head">
|
||||
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
|
||||
<div class="toolbar">
|
||||
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||
<span>暂停刷新</span>
|
||||
</button>
|
||||
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
|
||||
<span>画布加点</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-stage">
|
||||
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
|
||||
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
|
||||
<div id="video-label" class="video-label">等待首帧</div>
|
||||
</div>
|
||||
<div class="video-footer">
|
||||
<span>帧序号 <strong id="sequence">—</strong></span>
|
||||
<span>采集时间 <strong id="captured-at">—</strong></span>
|
||||
<div class="legend" aria-label="画面图例">
|
||||
<span><i aria-hidden="true"></i>匿名人员框</span>
|
||||
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics" aria-label="推理摘要">
|
||||
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
|
||||
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
|
||||
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
|
||||
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side-stack" aria-label="区域配置与事件候选">
|
||||
<section class="card" aria-labelledby="zone-title">
|
||||
<div class="card-head">
|
||||
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
|
||||
</div>
|
||||
<form id="zone-form" class="form-body">
|
||||
<label for="zone-name">区域名称
|
||||
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
|
||||
</label>
|
||||
<label for="zone-points">顶点坐标(每行 x,y)
|
||||
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30 0.90,0.30 0.90,0.88 0.55,0.88</textarea>
|
||||
</label>
|
||||
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
|
||||
<div class="form-actions">
|
||||
<button id="undo-point" class="btn" type="button">撤销一点</button>
|
||||
<button id="clear-points" class="btn" type="button">清空顶点</button>
|
||||
</div>
|
||||
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
|
||||
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="event-title">
|
||||
<div class="card-head">
|
||||
<div><h2 id="event-title">最近候选事实</h2><p>只产生 source_event_id;Bell 才生成平台 ULID</p></div>
|
||||
<span id="event-count" class="chip"><strong>0</strong> 项</span>
|
||||
</div>
|
||||
<div id="event-list" class="event-list" aria-live="polite">
|
||||
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="architecture-note" aria-label="系统边界说明">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
|
||||
</section>
|
||||
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const frame = $("video-frame");
|
||||
const canvas = $("overlay");
|
||||
const context = canvas.getContext("2d");
|
||||
const token = document.querySelector('meta[name="brain-demo-token"]').content;
|
||||
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
|
||||
let paused = false;
|
||||
let editMode = false;
|
||||
let state = null;
|
||||
let offlineSequence = 0;
|
||||
let offlineTriggered = false;
|
||||
|
||||
const offlineState = () => {
|
||||
offlineSequence += 1;
|
||||
const phase = ((offlineSequence - 1) % 160) / 159;
|
||||
const center = -0.04 + phase * 1.08;
|
||||
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
|
||||
const inside = center >= .55 && center <= .90;
|
||||
const events = state && state.events ? state.events : [];
|
||||
if (inside && !offlineTriggered) {
|
||||
offlineTriggered = true;
|
||||
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
|
||||
}
|
||||
if (!inside && center < .50) offlineTriggered = false;
|
||||
return {
|
||||
prototype: true,
|
||||
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
|
||||
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
|
||||
detector: {name: "scripted_prototype", production_ready: false},
|
||||
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
|
||||
inference: {target_fps: 2, latency_ms: 0},
|
||||
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
|
||||
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
|
||||
events,
|
||||
last_error_code: null
|
||||
};
|
||||
};
|
||||
|
||||
function parsePoints(showError = true) {
|
||||
try {
|
||||
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
|
||||
const parts = line.split(",").map(value => Number(value.trim()));
|
||||
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
|
||||
return {x: parts[0], y: parts[1]};
|
||||
});
|
||||
if (points.length < 3 || points.length > 32) throw new Error();
|
||||
return points;
|
||||
} catch (_) {
|
||||
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setPoints(points) {
|
||||
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
|
||||
}
|
||||
|
||||
function setFormState(message, error = false) {
|
||||
const target = $("form-state");
|
||||
target.textContent = message;
|
||||
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
|
||||
}
|
||||
|
||||
function drawOfflineBackground() {
|
||||
context.fillStyle = "#111a2b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#1e2c43";
|
||||
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
|
||||
context.strokeStyle = "#2b3a52";
|
||||
for (let x = 0; x < canvas.width; x += 80) {
|
||||
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
|
||||
}
|
||||
context.fillStyle = "#7dd3fc";
|
||||
context.font = "700 18px Segoe UI";
|
||||
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
|
||||
}
|
||||
|
||||
function drawOverlay() {
|
||||
if (!state) return;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!runtimeAvailable) drawOfflineBackground();
|
||||
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
|
||||
if (points.length >= 3) {
|
||||
context.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const x = point.x * canvas.width, y = point.y * canvas.height;
|
||||
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
context.fillStyle = "rgba(251, 191, 36, .10)";
|
||||
context.fill();
|
||||
context.strokeStyle = "#fbbf24";
|
||||
context.lineWidth = 3;
|
||||
context.stroke();
|
||||
points.forEach((point, index) => {
|
||||
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
|
||||
context.fillStyle = "#fbbf24"; context.fill();
|
||||
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
|
||||
});
|
||||
}
|
||||
state.detections.forEach(item => {
|
||||
const [x1, y1, x2, y2] = item.bbox;
|
||||
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
|
||||
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(x, y, width, height);
|
||||
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
|
||||
context.font = "700 14px Segoe UI";
|
||||
const labelWidth = context.measureText(label).width + 14;
|
||||
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
|
||||
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
|
||||
context.fillStyle = "#fff";
|
||||
context.fillText(label, x + 7, Math.max(17, y - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
|
||||
if (!events.length) {
|
||||
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
|
||||
return;
|
||||
}
|
||||
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
|
||||
const article = document.createElement("article"); article.className = "event";
|
||||
const head = document.createElement("div"); head.className = "event-head";
|
||||
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
|
||||
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
|
||||
head.append(title, tag);
|
||||
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
|
||||
const code = document.createElement("code"); code.textContent = event.source_event_id;
|
||||
article.append(head, time, code); return article;
|
||||
}));
|
||||
}
|
||||
|
||||
function render(next) {
|
||||
state = next;
|
||||
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
|
||||
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
|
||||
$("source-label").textContent = next.source.label;
|
||||
$("zone-version").textContent = `v${next.zone.version}`;
|
||||
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
|
||||
$("sequence").textContent = String(next.frame.sequence);
|
||||
$("captured-at").textContent = formatTime(next.frame.captured_at);
|
||||
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
|
||||
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
|
||||
$("person-count").textContent = String(next.detections.length);
|
||||
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
|
||||
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
|
||||
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
|
||||
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
|
||||
}
|
||||
renderEvents(next.events);
|
||||
drawOverlay();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (paused) return;
|
||||
if (runtimeAvailable) {
|
||||
try {
|
||||
const response = await fetch("/api/v1/state", {cache: "no-store"});
|
||||
if (!response.ok) throw new Error();
|
||||
const next = await response.json();
|
||||
frame.hidden = false;
|
||||
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
|
||||
render(next);
|
||||
return;
|
||||
} catch (_) {
|
||||
runtimeAvailable = false;
|
||||
frame.hidden = true;
|
||||
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
|
||||
}
|
||||
}
|
||||
render(offlineState());
|
||||
}
|
||||
|
||||
frame.addEventListener("load", drawOverlay);
|
||||
$("toggle-preview").addEventListener("click", event => {
|
||||
paused = !paused;
|
||||
event.currentTarget.setAttribute("aria-pressed", String(paused));
|
||||
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
|
||||
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
|
||||
if (!paused) poll();
|
||||
});
|
||||
$("edit-zone").addEventListener("click", event => {
|
||||
editMode = !editMode;
|
||||
event.currentTarget.setAttribute("aria-pressed", String(editMode));
|
||||
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
|
||||
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
|
||||
});
|
||||
canvas.addEventListener("pointerdown", event => {
|
||||
if (!editMode) return;
|
||||
const points = parsePoints(false);
|
||||
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
|
||||
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
|
||||
});
|
||||
$("undo-point").addEventListener("click", () => {
|
||||
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
|
||||
});
|
||||
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
|
||||
$("zone-points").addEventListener("input", drawOverlay);
|
||||
$("zone-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const points = parsePoints(true); if (!points.length) return;
|
||||
const name = $("zone-name").value.trim();
|
||||
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
|
||||
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
|
||||
if (runtimeAvailable) {
|
||||
try {
|
||||
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
|
||||
if (!response.ok) throw new Error();
|
||||
const zone = await response.json();
|
||||
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
|
||||
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
|
||||
} else {
|
||||
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
|
||||
}
|
||||
button.disabled = false; button.textContent = "保存区域版本";
|
||||
});
|
||||
|
||||
poll();
|
||||
window.setInterval(poll, 500);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+10
-1
@@ -20,6 +20,15 @@
|
||||
|
||||
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
|
||||
|
||||
## 工程演示路由
|
||||
|
||||
| 路由 | 所有者 | 关键约束 |
|
||||
| --- | --- | --- |
|
||||
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
|
||||
| `/sense-console/` | Sense T-018 回环工程服务 | 默认关闭;复用 Control API v1,设备分页默认 16 项,最多 4 路按需 WebRTC 预览。只允许 Sense 与 MediaMTX 播放端均为显式回环地址;不是录像/回放或 Bell 公共业务路由 |
|
||||
|
||||
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页或 T-018 Sense 回环控制台暴露到非可信网络或嵌入客户公共系统。
|
||||
|
||||
## App 候选导航
|
||||
|
||||
- 预警:待确认与升级中的事件。
|
||||
@@ -44,6 +53,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
|
||||
|
||||
## 组件归属
|
||||
|
||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。
|
||||
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。
|
||||
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
|
||||
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
|
||||
|
||||
+9
-5
@@ -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 验收。
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: T-018
|
||||
title: 建立 Sense NVR 管理面设备与实时监看纵切
|
||||
phase: 3
|
||||
deps: [T-004, T-014]
|
||||
status: TODO
|
||||
created: 2026-08-11
|
||||
issue: 63
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
write_paths:
|
||||
- docs/tasks/T-018.md
|
||||
- Sense/cmd/sense-api/
|
||||
- Sense/internal/config/
|
||||
- Sense/internal/console/
|
||||
- Sense/README.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/06-tasks.md
|
||||
- docs/routes.md
|
||||
- docs/current-state.md
|
||||
- tests/test_sense_console_contract.py
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探活和本地 16 路容量证据,但客户目前只能通过 API、脚本或静态原型理解这些能力。T-004 已确认 Sense 的五入口信息架构;本任务把其中最能证明 NVR 管理面价值的“设备 + 实时监看”落成可运行纵切,并保留总览、接入任务和运维中心的诚实入口。
|
||||
|
||||
本任务不是完整 NVR:常态录像仍由客户现有 NVR 承担,YoVision 暂不实现录像计划、录像索引或回放;MediaMTX 继续作为独立数据面,Sense 只提供管理面和受限播放入口。为避免在 JWT/OIDC、反向代理和 MediaMTX 外部认证尚未冻结时暴露视频,本版控制台只允许回环工程环境启用。
|
||||
|
||||
## 关联需求与交互(如适用)
|
||||
|
||||
- 用户故事:US-001、US-002、US-008、US-009;本任务只完成其中设备查询、状态理解和按需监看的首个纵切,不宣称批量导入、完整能力探测或全量运维 API 已完成。
|
||||
- 交互清单:IX-002~IX-004、IX-013~IX-015、IX-019、IX-020,以及“桌面与大屏”中的按需预览和 128 路分页约束。
|
||||
- 相关页面 / 路由:已确认原型 `docs/design/sense/index.html`;新增工程路由 `/sense-console/`,不冻结为 Bell 客户公共路由。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 `Sense/internal/console/` 增加由 Go `embed` 提供的自包含 HTML/CSS/JavaScript 控制台,不引入前端框架、CDN、字体或第三方播放器依赖;外观沿用 T-004 已确认的深色接入运维台,不重新设计信息架构。
|
||||
2. 控制台复用同源 Sense Control API v1。操作者输入 Site ID 和 Bearer token 后加载设备页;token 只保存在当前 JavaScript 内存中,刷新即丢失,不进入 URL、DOM 可见文本、`localStorage`、`sessionStorage`、cookie 或日志。
|
||||
3. 设备列表默认每页 16 项,使用服务端 cursor,支持模态、期望态和实际态筛选;显示配额、期望态/实际态、收敛、失败码、重试时间和策略投影版本。页面只把当前页统计标为“当前页”,不伪造站点总量。
|
||||
4. 实时监看只允许从当前已授权设备中选择具有 `video_capture` 能力的设备,最多同时打开 4 路;默认不自动播放。播放使用 MediaMTX v1.19.3 官方浏览器 WebRTC 页面,地址由仓库外环境配置提供,UI 不显示 RTSP/ONVIF、凭据或完整播放地址。
|
||||
5. `/sense-console/` 默认关闭。启用时 Sense HTTP 监听与 MediaMTX WebRTC 基地址都必须是显式回环地址;配置含 userinfo、查询、fragment、非 HTTP(S) 或非回环主机时启动失败。页面添加 CSP、`frame-ancestors 'none'`、`nosniff`、`no-store` 和严格 Referrer Policy。
|
||||
6. 总览、接入任务和运维中心继续保留为五入口结构:总览只汇总已加载事实;接入任务明确标识本纵切尚未实现批量导入;运维中心显示当前页未收敛项并提供受限 `/metrics` 入口,不伪造分片、隧道或告警数据。
|
||||
7. 添加 Go handler/config/UI 契约测试;在 Edge 以 1440×900、375×812 和 812×375 检查焦点、无横向溢出、44px 触控目标、空态/鉴权失败/依赖失败和 `prefers-reduced-motion`。实流人工验收可继续使用一台已准入海康相机,不要求新增摄像头。
|
||||
|
||||
## 不可变约束
|
||||
|
||||
- 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。
|
||||
- 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=running + converged=true` 的设备可启动预览。
|
||||
- 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。
|
||||
- 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest tests.test_sense_console_contract -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。
|
||||
- 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。
|
||||
- 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。
|
||||
- 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。
|
||||
|
||||
## 边界(不改什么)
|
||||
|
||||
不实现录像计划、常态录像、回放、证据切片、批量 CSV 导入、Site/Area/RBAC CRUD、Bell 业务预警、Brain 推理、非回环部署或生产会话认证;不修改 `_reference/`,不复制 MiBeeNvr 或 MediaMTX UI/播放器源码,不改已确认的 `docs/design/sense/index.html`。
|
||||
|
||||
## 协作约束
|
||||
|
||||
- 责任 Agent:codex。
|
||||
- 唯一写入者:codex。
|
||||
- 委派:不启用。
|
||||
- Gitea:主 Issue 为 #63;领取时填写 `context_ref`、`claims/T-018` 与 `agent/codex/T-018`。
|
||||
|
||||
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-11 任务定义
|
||||
|
||||
- 项目负责人将开发优先级从 Brain/Bell 后续链调整为先做 Sense;T-018 因此定义为 Sense NVR 管理面首个可运行纵切,原 Brain→Bell ingress 顺延为后续建议任务。
|
||||
- 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。
|
||||
- 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。
|
||||
- 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user