From 7964d61cabfeb6aa937829d74793a8125bc15fab Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 22:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Brain=20=E5=90=88?= =?UTF-8?q?=E6=88=90=E4=B8=8E=E6=9C=AC=E5=9C=B0=E8=A7=86=E9=A2=91=E8=BE=93?= =?UTF-8?q?=E5=85=A5=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Brain/src/yovision_brain/config/__init__.py | 27 +++ Brain/src/yovision_brain/config/models.py | 206 +++++++++++++++++++ Brain/src/yovision_brain/input/__init__.py | 16 ++ Brain/src/yovision_brain/input/factory.py | 17 ++ Brain/src/yovision_brain/input/local_file.py | 54 +++++ Brain/src/yovision_brain/input/models.py | 43 ++++ Brain/src/yovision_brain/input/synthetic.py | 38 ++++ Brain/tests/config/test_input_config.py | 72 +++++++ Brain/tests/fixtures/input/README.md | 7 + Brain/tests/input/test_sources.py | 83 ++++++++ 10 files changed, 563 insertions(+) create mode 100644 Brain/src/yovision_brain/config/__init__.py create mode 100644 Brain/src/yovision_brain/config/models.py create mode 100644 Brain/src/yovision_brain/input/__init__.py create mode 100644 Brain/src/yovision_brain/input/factory.py create mode 100644 Brain/src/yovision_brain/input/local_file.py create mode 100644 Brain/src/yovision_brain/input/models.py create mode 100644 Brain/src/yovision_brain/input/synthetic.py create mode 100644 Brain/tests/config/test_input_config.py create mode 100644 Brain/tests/fixtures/input/README.md create mode 100644 Brain/tests/input/test_sources.py diff --git a/Brain/src/yovision_brain/config/__init__.py b/Brain/src/yovision_brain/config/__init__.py new file mode 100644 index 0000000..f23cebb --- /dev/null +++ b/Brain/src/yovision_brain/config/__init__.py @@ -0,0 +1,27 @@ +"""Brain-internal configuration models. + +These types are deliberately not a cross-project contract. Adapters for a +future versioned Sense/Brain contract belong in a coordination task. +""" + +from .models import ( + AreaRule, + BrainInputConfig, + ConfigError, + DirectionalLineRule, + Point, + SourceConfig, + VideoProfile, + parse_input_config, +) + +__all__ = [ + "AreaRule", + "BrainInputConfig", + "ConfigError", + "DirectionalLineRule", + "Point", + "SourceConfig", + "VideoProfile", + "parse_input_config", +] diff --git a/Brain/src/yovision_brain/config/models.py b/Brain/src/yovision_brain/config/models.py new file mode 100644 index 0000000..fd7066b --- /dev/null +++ b/Brain/src/yovision_brain/config/models.py @@ -0,0 +1,206 @@ +"""Validated project-internal input configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + +INTERNAL_SCHEMA = "brain.internal.input/v1" +_SECRET_KEYS = {"credential", "password", "secret", "token", "username"} + + +class ConfigError(ValueError): + """Raised when Brain's project-internal test/runtime config is invalid.""" + + +@dataclass(frozen=True, slots=True) +class Point: + x: float + y: float + + +@dataclass(frozen=True, slots=True) +class AreaRule: + rule_id: str + points: tuple[Point, ...] + + +@dataclass(frozen=True, slots=True) +class DirectionalLineRule: + rule_id: str + start: Point + end: Point + trigger_direction: str + + +@dataclass(frozen=True, slots=True) +class VideoProfile: + profile_id: str + width: int + height: int + fps: float + + +@dataclass(frozen=True, slots=True) +class SourceConfig: + kind: str + seed: int | None = None + frame_count: int | None = None + path: Path | None = None + chunk_size: int = 64 * 1024 + + +@dataclass(frozen=True, slots=True) +class BrainInputConfig: + schema: str + logical_device_id: str + profile: VideoProfile + source: SourceConfig + areas: tuple[AreaRule, ...] = () + directional_lines: tuple[DirectionalLineRule, ...] = () + + +def _mapping(value: object, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ConfigError(f"{field} must be an object") + return value + + +def _text(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{field} must be a non-empty string") + return value.strip() + + +def _integer(value: object, field: str, *, minimum: int = 1) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise ConfigError(f"{field} must be an integer >= {minimum}") + return value + + +def _number(value: object, field: str, *, minimum: float = 0.0) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError(f"{field} must be a number") + result = float(value) + if result <= minimum: + raise ConfigError(f"{field} must be greater than {minimum}") + return result + + +def _reject_secrets(value: object, field: str = "config") -> None: + if isinstance(value, Mapping): + for key, child in value.items(): + normalized = str(key).strip().lower() + if normalized in _SECRET_KEYS or any( + marker in normalized for marker in ("password", "secret", "token") + ): + raise ConfigError(f"{field} must not contain credential field {key!r}") + _reject_secrets(child, f"{field}.{key}") + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for index, child in enumerate(value): + _reject_secrets(child, f"{field}[{index}]") + + +def _point(value: object, field: str) -> Point: + if ( + not isinstance(value, Sequence) + or isinstance(value, (str, bytes, bytearray)) + or len(value) != 2 + ): + raise ConfigError(f"{field} must be [x, y]") + x, y = value + if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + raise ConfigError(f"{field} coordinates must be numbers") + point = Point(float(x), float(y)) + if not 0.0 <= point.x <= 1.0 or not 0.0 <= point.y <= 1.0: + raise ConfigError(f"{field} coordinates must be normalized to 0..1") + return point + + +def parse_input_config(raw: Mapping[str, Any], *, base_dir: Path | None = None) -> BrainInputConfig: + """Parse the explicitly versioned Brain-internal input configuration.""" + _reject_secrets(raw) + schema = _text(raw.get("schema"), "schema") + if schema != INTERNAL_SCHEMA: + raise ConfigError(f"schema must be {INTERNAL_SCHEMA!r}") + + profile_raw = _mapping(raw.get("profile"), "profile") + profile = VideoProfile( + profile_id=_text(profile_raw.get("id"), "profile.id"), + width=_integer(profile_raw.get("width"), "profile.width"), + height=_integer(profile_raw.get("height"), "profile.height"), + fps=_number(profile_raw.get("fps"), "profile.fps"), + ) + + source_raw = _mapping(raw.get("source"), "source") + kind = _text(source_raw.get("kind"), "source.kind") + if kind == "synthetic": + seed = source_raw.get("seed", 0) + if isinstance(seed, bool) or not isinstance(seed, int): + raise ConfigError("source.seed must be an integer") + source = SourceConfig( + kind=kind, + seed=seed, + frame_count=_integer(source_raw.get("frame_count"), "source.frame_count"), + ) + elif kind == "local_file": + configured_path = Path(_text(source_raw.get("path"), "source.path")) + if not configured_path.is_absolute() and base_dir is not None: + configured_path = base_dir / configured_path + source = SourceConfig( + kind=kind, + path=configured_path, + chunk_size=_integer(source_raw.get("chunk_size", 64 * 1024), "source.chunk_size"), + ) + else: + raise ConfigError("source.kind must be 'synthetic' or 'local_file'") + + areas_raw = raw.get("areas", []) + if not isinstance(areas_raw, list): + raise ConfigError("areas must be an array") + areas: list[AreaRule] = [] + for index, item in enumerate(areas_raw): + area = _mapping(item, f"areas[{index}]") + points_raw = area.get("points") + if not isinstance(points_raw, list) or len(points_raw) < 3: + raise ConfigError(f"areas[{index}].points must contain at least three points") + areas.append( + AreaRule( + rule_id=_text(area.get("id"), f"areas[{index}].id"), + points=tuple(_point(point, f"areas[{index}].points[{point_index}]") for point_index, point in enumerate(points_raw)), + ) + ) + + lines_raw = raw.get("directional_lines", []) + if not isinstance(lines_raw, list): + raise ConfigError("directional_lines must be an array") + lines: list[DirectionalLineRule] = [] + for index, item in enumerate(lines_raw): + line = _mapping(item, f"directional_lines[{index}]") + direction = _text(line.get("trigger_direction"), f"directional_lines[{index}].trigger_direction") + if direction not in {"left_to_right", "right_to_left"}: + raise ConfigError( + f"directional_lines[{index}].trigger_direction must be left_to_right or right_to_left" + ) + lines.append( + DirectionalLineRule( + rule_id=_text(line.get("id"), f"directional_lines[{index}].id"), + start=_point(line.get("start"), f"directional_lines[{index}].start"), + end=_point(line.get("end"), f"directional_lines[{index}].end"), + trigger_direction=direction, + ) + ) + + identifiers = [area.rule_id for area in areas] + [line.rule_id for line in lines] + if len(set(identifiers)) != len(identifiers): + raise ConfigError("rule ids must be unique") + + return BrainInputConfig( + schema=schema, + logical_device_id=_text(raw.get("logical_device_id"), "logical_device_id"), + profile=profile, + source=source, + areas=tuple(areas), + directional_lines=tuple(lines), + ) diff --git a/Brain/src/yovision_brain/input/__init__.py b/Brain/src/yovision_brain/input/__init__.py new file mode 100644 index 0000000..f6b5fa0 --- /dev/null +++ b/Brain/src/yovision_brain/input/__init__.py @@ -0,0 +1,16 @@ +"""Brain-internal video input adapters.""" + +from .factory import build_input_source +from .local_file import LocalFileInput +from .models import CancellationToken, InputError, InputPacket, InputSource +from .synthetic import SyntheticInput + +__all__ = [ + "CancellationToken", + "InputError", + "InputPacket", + "InputSource", + "LocalFileInput", + "SyntheticInput", + "build_input_source", +] diff --git a/Brain/src/yovision_brain/input/factory.py b/Brain/src/yovision_brain/input/factory.py new file mode 100644 index 0000000..bd9f870 --- /dev/null +++ b/Brain/src/yovision_brain/input/factory.py @@ -0,0 +1,17 @@ +"""Construct the configured Brain-internal input adapter.""" + +from __future__ import annotations + +from yovision_brain.config import BrainInputConfig + +from .local_file import LocalFileInput +from .models import InputSource +from .synthetic import SyntheticInput + + +def build_input_source(config: BrainInputConfig) -> InputSource: + if config.source.kind == "synthetic": + return SyntheticInput(config) + if config.source.kind == "local_file": + return LocalFileInput(config) + raise ValueError(f"unsupported Brain input source kind: {config.source.kind}") diff --git a/Brain/src/yovision_brain/input/local_file.py b/Brain/src/yovision_brain/input/local_file.py new file mode 100644 index 0000000..bf845b8 --- /dev/null +++ b/Brain/src/yovision_brain/input/local_file.py @@ -0,0 +1,54 @@ +"""Explicit local-file input adapter with safe error reporting.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from yovision_brain.config import BrainInputConfig + +from .models import CancellationToken, InputError, InputPacket + + +class LocalFileInput: + def __init__(self, config: BrainInputConfig) -> None: + if config.source.kind != "local_file" or config.source.path is None: + raise ValueError("LocalFileInput requires a local_file source config") + self._config = config + self._path = config.source.path + + @property + def source_label(self) -> str: + """Return a safe label rather than exposing the internal absolute path.""" + return self._path.name + + def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]: + profile = self._config.profile + source = self._config.source + try: + stream = self._path.open("rb") + except FileNotFoundError as exc: + raise InputError(f"local video source not found: {self.source_label}") from exc + except OSError as exc: + raise InputError(f"local video source cannot be opened: {self.source_label}: {exc.strerror}") from exc + + with stream: + sequence = 0 + while cancellation is None or not cancellation.cancelled: + try: + payload = stream.read(source.chunk_size) + except OSError as exc: + raise InputError(f"local video source read failed: {self.source_label}: {exc.strerror}") from exc + if not payload: + return + yield InputPacket( + sequence=sequence, + timestamp_ns=None, + logical_device_id=self._config.logical_device_id, + profile_id=profile.profile_id, + width=profile.width, + height=profile.height, + media_format="container-bytes", + payload=payload, + ) + sequence += 1 diff --git a/Brain/src/yovision_brain/input/models.py b/Brain/src/yovision_brain/input/models.py new file mode 100644 index 0000000..ff5e9c2 --- /dev/null +++ b/Brain/src/yovision_brain/input/models.py @@ -0,0 +1,43 @@ +"""Common project-internal input types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from threading import Event +from typing import Iterator, Protocol + + +class InputError(RuntimeError): + """A safe, actionable input adapter error.""" + + +class CancellationToken: + """Thread-safe cooperative cancellation without platform dependencies.""" + + def __init__(self) -> None: + self._event = Event() + + def cancel(self) -> None: + self._event.set() + + @property + def cancelled(self) -> bool: + return self._event.is_set() + + +@dataclass(frozen=True, slots=True) +class InputPacket: + sequence: int + timestamp_ns: int | None + logical_device_id: str + profile_id: str + width: int + height: int + media_format: str + payload: bytes + + +class InputSource(Protocol): + """Replaceable source boundary consumed by the future decode layer.""" + + def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]: ... diff --git a/Brain/src/yovision_brain/input/synthetic.py b/Brain/src/yovision_brain/input/synthetic.py new file mode 100644 index 0000000..5462a84 --- /dev/null +++ b/Brain/src/yovision_brain/input/synthetic.py @@ -0,0 +1,38 @@ +"""Deterministic synthetic RGB input for isolated tests and smoke runs.""" + +from __future__ import annotations + +import random +from collections.abc import Iterator + +from yovision_brain.config import BrainInputConfig + +from .models import CancellationToken, InputPacket + + +class SyntheticInput: + def __init__(self, config: BrainInputConfig) -> None: + if config.source.kind != "synthetic": + raise ValueError("SyntheticInput requires a synthetic source config") + self._config = config + + def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]: + source = self._config.source + assert source.seed is not None and source.frame_count is not None + randomizer = random.Random(source.seed) + profile = self._config.profile + frame_size = profile.width * profile.height * 3 + interval_ns = round(1_000_000_000 / profile.fps) + for sequence in range(source.frame_count): + if cancellation is not None and cancellation.cancelled: + return + yield InputPacket( + sequence=sequence, + timestamp_ns=sequence * interval_ns, + logical_device_id=self._config.logical_device_id, + profile_id=profile.profile_id, + width=profile.width, + height=profile.height, + media_format="rgb24", + payload=randomizer.randbytes(frame_size), + ) diff --git a/Brain/tests/config/test_input_config.py b/Brain/tests/config/test_input_config.py new file mode 100644 index 0000000..8d828f9 --- /dev/null +++ b/Brain/tests/config/test_input_config.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yovision_brain.config import ConfigError, parse_input_config + + +def valid_config() -> dict[str, object]: + return { + "schema": "brain.internal.input/v1", + "logical_device_id": "synthetic-camera-01", + "profile": {"id": "main", "width": 4, "height": 3, "fps": 5}, + "source": {"kind": "synthetic", "seed": 17, "frame_count": 3}, + "areas": [{"id": "danger-yard", "points": [[0.1, 0.1], [0.9, 0.1], [0.5, 0.8]]}], + "directional_lines": [ + { + "id": "gate-line", + "start": [0.2, 0.5], + "end": [0.8, 0.5], + "trigger_direction": "left_to_right", + } + ], + } + + +def test_parse_versioned_internal_config() -> None: + parsed = parse_input_config(valid_config()) + assert parsed.schema == "brain.internal.input/v1" + assert parsed.logical_device_id == "synthetic-camera-01" + assert parsed.profile.width == 4 + assert parsed.areas[0].rule_id == "danger-yard" + assert parsed.directional_lines[0].trigger_direction == "left_to_right" + + +def test_relative_local_path_is_bound_to_explicit_base(tmp_path: Path) -> None: + raw = valid_config() + raw["source"] = {"kind": "local_file", "path": "fixture.bin", "chunk_size": 8} + parsed = parse_input_config(raw, base_dir=tmp_path) + assert parsed.source.path == tmp_path / "fixture.bin" + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"schema": "shared.source/v1"}, "schema must be"), + ({"logical_device_id": ""}, "logical_device_id"), + ({"source": {"kind": "synthetic", "seed": 1, "frame_count": 0}}, "frame_count"), + ({"password": "must-not-be-accepted"}, "credential field"), + ], +) +def test_invalid_or_secret_config_is_rejected(change: dict[str, object], message: str) -> None: + raw = valid_config() + raw.update(change) + with pytest.raises(ConfigError, match=message): + parse_input_config(raw) + + +def test_coordinates_and_rule_ids_are_validated() -> None: + raw = valid_config() + raw["areas"] = [{"id": "same", "points": [[0, 0], [2, 0], [0, 1]]}] + with pytest.raises(ConfigError, match="normalized"): + parse_input_config(raw) + + raw = valid_config() + raw["areas"] = [{"id": "same", "points": [[0, 0], [1, 0], [0, 1]]}] + raw["directional_lines"] = [ + {"id": "same", "start": [0, 0], "end": [1, 1], "trigger_direction": "left_to_right"} + ] + with pytest.raises(ConfigError, match="unique"): + parse_input_config(raw) diff --git a/Brain/tests/fixtures/input/README.md b/Brain/tests/fixtures/input/README.md new file mode 100644 index 0000000..c546244 --- /dev/null +++ b/Brain/tests/fixtures/input/README.md @@ -0,0 +1,7 @@ +# Brain input fixtures + +This directory may contain only synthetic or anonymous fixtures. Do not add +customer video, camera credentials, personal data, or machine-specific paths. + +The current tests generate their tiny local-file payload at runtime so the +repository does not carry a file that could be mistaken for customer media. diff --git a/Brain/tests/input/test_sources.py b/Brain/tests/input/test_sources.py new file mode 100644 index 0000000..230fa26 --- /dev/null +++ b/Brain/tests/input/test_sources.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yovision_brain.config import parse_input_config +from yovision_brain.input import CancellationToken, InputError, LocalFileInput, SyntheticInput, build_input_source + + +def synthetic_config(seed: int = 9, frame_count: int = 3): + return parse_input_config( + { + "schema": "brain.internal.input/v1", + "logical_device_id": "camera-lab-01", + "profile": {"id": "main", "width": 3, "height": 2, "fps": 4}, + "source": {"kind": "synthetic", "seed": seed, "frame_count": frame_count}, + } + ) + + +def local_config(path: Path, *, chunk_size: int = 4): + return parse_input_config( + { + "schema": "brain.internal.input/v1", + "logical_device_id": "local-video-01", + "profile": {"id": "archive", "width": 1920, "height": 1080, "fps": 25}, + "source": {"kind": "local_file", "path": str(path), "chunk_size": chunk_size}, + } + ) + + +def test_synthetic_input_is_deterministic_and_carries_metadata() -> None: + first = list(SyntheticInput(synthetic_config()).packets()) + second = list(build_input_source(synthetic_config()).packets()) + different = list(SyntheticInput(synthetic_config(seed=10)).packets()) + + assert first == second + assert [packet.sequence for packet in first] == [0, 1, 2] + assert [packet.timestamp_ns for packet in first] == [0, 250_000_000, 500_000_000] + assert first[0].logical_device_id == "camera-lab-01" + assert first[0].profile_id == "main" + assert first[0].media_format == "rgb24" + assert len(first[0].payload) == 3 * 2 * 3 + assert first[0].payload != different[0].payload + + +def test_synthetic_input_honors_cancellation() -> None: + token = CancellationToken() + packets = SyntheticInput(synthetic_config(frame_count=10)).packets(token) + assert next(packets).sequence == 0 + token.cancel() + assert list(packets) == [] + + +def test_local_file_input_reads_chunks_and_finishes_at_eof(tmp_path: Path) -> None: + video = tmp_path / "anonymous-fixture.bin" + video.write_bytes(b"abcdefghij") + source = LocalFileInput(local_config(video)) + packets = list(source.packets()) + + assert source.source_label == "anonymous-fixture.bin" + assert [packet.payload for packet in packets] == [b"abcd", b"efgh", b"ij"] + assert [packet.sequence for packet in packets] == [0, 1, 2] + assert all(packet.timestamp_ns is None for packet in packets) + assert all(packet.media_format == "container-bytes" for packet in packets) + + +def test_local_file_input_honors_cancellation(tmp_path: Path) -> None: + video = tmp_path / "anonymous-fixture.bin" + video.write_bytes(b"abcdefghij") + token = CancellationToken() + packets = LocalFileInput(local_config(video)).packets(token) + assert next(packets).payload == b"abcd" + token.cancel() + assert list(packets) == [] + + +def test_missing_file_error_is_actionable_without_absolute_path(tmp_path: Path) -> None: + missing = tmp_path / "missing-video.mp4" + with pytest.raises(InputError, match="local video source not found: missing-video.mp4") as caught: + list(LocalFileInput(local_config(missing)).packets()) + assert str(tmp_path) not in str(caught.value) -- 2.34.1