Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7964d61cab |
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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]: ...
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
Vendored
+7
@@ -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.
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user