446 lines
15 KiB
Python
446 lines
15 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import threading
|
||
|
|
from concurrent.futures import ThreadPoolExecutor
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
import cmvr_edge_ai.detection.video as video_module
|
||
|
|
from cmvr_edge_ai.contracts import CameraIntrinsics, ImageFrame
|
||
|
|
from cmvr_edge_ai.core import ComponentContext, Emission, Envelope
|
||
|
|
from cmvr_edge_ai.detection.video import (
|
||
|
|
DecodedVideoFrame,
|
||
|
|
VideoDecodeError,
|
||
|
|
VideoDecoderOperator,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class _BackendLog:
|
||
|
|
created_codecs: list[str] = field(default_factory=list)
|
||
|
|
decoded_packets: list[bytes] = field(default_factory=list)
|
||
|
|
worker_threads: list[int] = field(default_factory=list)
|
||
|
|
close_count: int = 0
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeBackend:
|
||
|
|
def __init__(self, codec: str, log: _BackendLog) -> None:
|
||
|
|
self._log = log
|
||
|
|
self._closed = False
|
||
|
|
log.created_codecs.append(codec)
|
||
|
|
log.worker_threads.append(threading.get_ident())
|
||
|
|
|
||
|
|
def decode(self, data: bytes) -> tuple[DecodedVideoFrame, ...]:
|
||
|
|
if self._closed:
|
||
|
|
raise RuntimeError("fake decoder is closed")
|
||
|
|
self._log.decoded_packets.append(data)
|
||
|
|
self._log.worker_threads.append(threading.get_ident())
|
||
|
|
if data == b"decode-error":
|
||
|
|
raise VideoDecodeError("synthetic packet discontinuity")
|
||
|
|
if data == b"buffered":
|
||
|
|
return ()
|
||
|
|
if data == b"two-frames":
|
||
|
|
return (
|
||
|
|
DecodedVideoFrame(b"first", 2, 2),
|
||
|
|
DecodedVideoFrame(b"second", 3, 2),
|
||
|
|
)
|
||
|
|
return (DecodedVideoFrame(b"decoded:" + data, 2, 2),)
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
if not self._closed:
|
||
|
|
self._closed = True
|
||
|
|
self._log.close_count += 1
|
||
|
|
self._log.worker_threads.append(threading.get_ident())
|
||
|
|
|
||
|
|
|
||
|
|
def _factory(log: _BackendLog): # type: ignore[no-untyped-def]
|
||
|
|
return lambda codec: _FakeBackend(codec, log)
|
||
|
|
|
||
|
|
|
||
|
|
def _image(
|
||
|
|
*,
|
||
|
|
codec: str = "H264",
|
||
|
|
key_frame: bool = False,
|
||
|
|
width: int = 640,
|
||
|
|
height: int = 480,
|
||
|
|
data: bytes = b"packet",
|
||
|
|
intrinsics: CameraIntrinsics | None = None,
|
||
|
|
) -> ImageFrame:
|
||
|
|
return ImageFrame(
|
||
|
|
data=data,
|
||
|
|
width=width,
|
||
|
|
height=height,
|
||
|
|
pixel_format="encoded",
|
||
|
|
codec=codec,
|
||
|
|
is_key_frame=key_frame,
|
||
|
|
intrinsics=intrinsics,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _envelope(
|
||
|
|
image: Any,
|
||
|
|
*,
|
||
|
|
source_id: str = "camera-1",
|
||
|
|
session_id: str | None = "session-1",
|
||
|
|
sequence: int = 0,
|
||
|
|
) -> Envelope[Any]:
|
||
|
|
return Envelope(
|
||
|
|
payload=image,
|
||
|
|
schema_name="ImageFrame",
|
||
|
|
schema_version=1,
|
||
|
|
source_id=source_id,
|
||
|
|
session_id=session_id,
|
||
|
|
sequence=sequence,
|
||
|
|
captured_at_ns=123,
|
||
|
|
received_at_ns=456,
|
||
|
|
deadline_ns=999,
|
||
|
|
trace_id="trace-1",
|
||
|
|
attributes={"transport": "test"},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _setup(operator: VideoDecoderOperator, executor: Any = None) -> None:
|
||
|
|
metadata = {} if executor is None else {"thread_executor": executor}
|
||
|
|
await operator.setup(
|
||
|
|
ComponentContext(
|
||
|
|
pipeline_id="test-pipeline",
|
||
|
|
node_id="decoder",
|
||
|
|
shutdown_event=asyncio.Event(),
|
||
|
|
metadata=metadata,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _keep_restricted_event_loop_responsive() -> None:
|
||
|
|
"""Provide wakeups where sandboxed self-pipe notifications are unavailable."""
|
||
|
|
|
||
|
|
while True:
|
||
|
|
await asyncio.sleep(0.01)
|
||
|
|
|
||
|
|
|
||
|
|
def _run_with_ticker(coroutine: Any) -> Any:
|
||
|
|
async def runner() -> Any:
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
host_executor = ThreadPoolExecutor(max_workers=2)
|
||
|
|
loop.set_default_executor(host_executor)
|
||
|
|
ticker = asyncio.create_task(_keep_restricted_event_loop_responsive())
|
||
|
|
try:
|
||
|
|
return await coroutine
|
||
|
|
finally:
|
||
|
|
ticker.cancel()
|
||
|
|
await asyncio.gather(ticker, return_exceptions=True)
|
||
|
|
host_executor.shutdown(wait=True, cancel_futures=True)
|
||
|
|
# Avoid a second shutdown by asyncio.run() after ownership above.
|
||
|
|
loop._default_executor = None # type: ignore[attr-defined]
|
||
|
|
|
||
|
|
return asyncio.run(runner())
|
||
|
|
|
||
|
|
|
||
|
|
def test_raw_frame_is_passed_through_without_loading_a_backend() -> None:
|
||
|
|
factory_calls: list[str] = []
|
||
|
|
|
||
|
|
def forbidden_factory(codec: str): # type: ignore[no-untyped-def]
|
||
|
|
factory_calls.append(codec)
|
||
|
|
raise AssertionError("raw frames must not construct a decoder")
|
||
|
|
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=forbidden_factory)
|
||
|
|
envelope = _envelope(
|
||
|
|
ImageFrame(
|
||
|
|
data=b"raw-bgr",
|
||
|
|
width=2,
|
||
|
|
height=2,
|
||
|
|
pixel_format="BGR8",
|
||
|
|
codec="none",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
async def exercise() -> Emission:
|
||
|
|
await _setup(operator)
|
||
|
|
result = await operator.process(envelope)
|
||
|
|
await operator.stop()
|
||
|
|
assert isinstance(result, Emission)
|
||
|
|
return result
|
||
|
|
|
||
|
|
emission = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert emission.port == "frames"
|
||
|
|
assert emission.envelope is envelope
|
||
|
|
assert factory_calls == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_waits_for_keyframe_decodes_off_loop_and_preserves_envelope_metadata() -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
intrinsics = CameraIntrinsics(100.0, 101.0, 50.0, 51.0)
|
||
|
|
event_loop_thread = threading.get_ident()
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
|
|
await _setup(operator, executor)
|
||
|
|
dropped = await operator.process(
|
||
|
|
_envelope(_image(key_frame=False), sequence=10)
|
||
|
|
)
|
||
|
|
key_envelope = _envelope(
|
||
|
|
_image(
|
||
|
|
codec=".H264",
|
||
|
|
key_frame=True,
|
||
|
|
data=b"key",
|
||
|
|
intrinsics=intrinsics,
|
||
|
|
),
|
||
|
|
sequence=11,
|
||
|
|
)
|
||
|
|
decoded = await operator.process(key_envelope)
|
||
|
|
health = await operator.health()
|
||
|
|
await operator.stop()
|
||
|
|
return dropped, key_envelope, decoded, health
|
||
|
|
|
||
|
|
dropped, original, result, health = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert dropped is None
|
||
|
|
assert isinstance(result, tuple) and len(result) == 1
|
||
|
|
emission = result[0]
|
||
|
|
assert emission.port == "frames"
|
||
|
|
assert isinstance(emission.envelope.payload, ImageFrame)
|
||
|
|
assert emission.envelope.payload == ImageFrame(
|
||
|
|
data=b"decoded:key",
|
||
|
|
width=2,
|
||
|
|
height=2,
|
||
|
|
pixel_format="BGR8",
|
||
|
|
codec="none",
|
||
|
|
is_key_frame=False,
|
||
|
|
intrinsics=intrinsics,
|
||
|
|
)
|
||
|
|
assert emission.envelope.source_id == original.source_id
|
||
|
|
assert emission.envelope.session_id == original.session_id
|
||
|
|
assert emission.envelope.sequence == original.sequence
|
||
|
|
assert emission.envelope.trace_id == original.trace_id
|
||
|
|
assert emission.envelope.captured_at_ns == original.captured_at_ns
|
||
|
|
assert emission.envelope.received_at_ns == original.received_at_ns
|
||
|
|
assert emission.envelope.attributes == original.attributes
|
||
|
|
assert log.created_codecs == ["h264"]
|
||
|
|
assert log.decoded_packets == [b"key"]
|
||
|
|
assert log.close_count == 1
|
||
|
|
assert log.worker_threads
|
||
|
|
assert all(thread_id != event_loop_thread for thread_id in log.worker_threads)
|
||
|
|
assert "decoded_frames=1" in health.detail
|
||
|
|
assert "dropped_until_keyframe=1" in health.detail
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("change", "expected_codec"),
|
||
|
|
[
|
||
|
|
({"source_id": "camera-2"}, "h264"),
|
||
|
|
({"session_id": "session-2"}, "h264"),
|
||
|
|
({"codec": "H265"}, "hevc"),
|
||
|
|
({"width": 800}, "h264"),
|
||
|
|
({"height": 600}, "h264"),
|
||
|
|
({"sequence": 4}, "h264"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_stream_identity_change_or_sequence_gap_resets_and_waits_for_keyframe(
|
||
|
|
change: dict[str, Any],
|
||
|
|
expected_codec: str,
|
||
|
|
) -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
await _setup(operator)
|
||
|
|
first = await operator.process(
|
||
|
|
_envelope(_image(key_frame=True, data=b"initial"), sequence=1)
|
||
|
|
)
|
||
|
|
|
||
|
|
source_id = str(change.get("source_id", "camera-1"))
|
||
|
|
session_id = change.get("session_id", "session-1")
|
||
|
|
sequence = int(change.get("sequence", 2))
|
||
|
|
codec = str(change.get("codec", "H264"))
|
||
|
|
width = int(change.get("width", 640))
|
||
|
|
height = int(change.get("height", 480))
|
||
|
|
changed_image = _image(
|
||
|
|
codec=codec,
|
||
|
|
key_frame=False,
|
||
|
|
width=width,
|
||
|
|
height=height,
|
||
|
|
data=b"must-drop",
|
||
|
|
)
|
||
|
|
dropped = await operator.process(
|
||
|
|
_envelope(
|
||
|
|
changed_image,
|
||
|
|
source_id=source_id,
|
||
|
|
session_id=session_id,
|
||
|
|
sequence=sequence,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
recovered = await operator.process(
|
||
|
|
_envelope(
|
||
|
|
_image(
|
||
|
|
codec=codec,
|
||
|
|
key_frame=True,
|
||
|
|
width=width,
|
||
|
|
height=height,
|
||
|
|
data=b"recovered",
|
||
|
|
),
|
||
|
|
source_id=source_id,
|
||
|
|
session_id=session_id,
|
||
|
|
sequence=sequence + 1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await operator.stop()
|
||
|
|
return first, dropped, recovered
|
||
|
|
|
||
|
|
first, dropped, recovered = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert isinstance(first, tuple)
|
||
|
|
assert dropped is None
|
||
|
|
assert isinstance(recovered, tuple)
|
||
|
|
assert log.created_codecs == ["h264", expected_codec]
|
||
|
|
assert log.decoded_packets == [b"initial", b"recovered"]
|
||
|
|
assert log.close_count == 2
|
||
|
|
|
||
|
|
|
||
|
|
def test_recoverable_decode_error_resets_and_suppresses_until_next_keyframe() -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
await _setup(operator)
|
||
|
|
failed = await operator.process(
|
||
|
|
_envelope(
|
||
|
|
_image(key_frame=True, data=b"decode-error"),
|
||
|
|
sequence=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
suppressed = await operator.process(
|
||
|
|
_envelope(_image(key_frame=False, data=b"p-frame"), sequence=2)
|
||
|
|
)
|
||
|
|
recovered = await operator.process(
|
||
|
|
_envelope(_image(key_frame=True, data=b"new-key"), sequence=3)
|
||
|
|
)
|
||
|
|
health = await operator.health()
|
||
|
|
await operator.stop()
|
||
|
|
await operator.stop()
|
||
|
|
return failed, suppressed, recovered, health
|
||
|
|
|
||
|
|
failed, suppressed, recovered, health = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert failed is None
|
||
|
|
assert suppressed is None
|
||
|
|
assert isinstance(recovered, tuple)
|
||
|
|
assert log.created_codecs == ["h264", "h264"]
|
||
|
|
assert log.decoded_packets == [b"decode-error", b"new-key"]
|
||
|
|
assert log.close_count == 2
|
||
|
|
assert "decode_errors=1" in health.detail
|
||
|
|
assert "resets=1" in health.detail
|
||
|
|
|
||
|
|
|
||
|
|
def test_backend_can_emit_zero_or_multiple_decoded_frames() -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
await _setup(operator)
|
||
|
|
buffered = await operator.process(
|
||
|
|
_envelope(_image(key_frame=True, data=b"buffered"), sequence=1)
|
||
|
|
)
|
||
|
|
multiple = await operator.process(
|
||
|
|
_envelope(_image(key_frame=False, data=b"two-frames"), sequence=2)
|
||
|
|
)
|
||
|
|
await operator.stop()
|
||
|
|
return buffered, multiple
|
||
|
|
|
||
|
|
buffered, multiple = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert buffered is None
|
||
|
|
assert isinstance(multiple, tuple) and len(multiple) == 2
|
||
|
|
assert [item.envelope.payload.data for item in multiple] == [b"first", b"second"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_raw_transition_closes_an_existing_encoded_decoder() -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
raw_envelope = _envelope(
|
||
|
|
ImageFrame(b"raw", 1, 1, "BGR8", codec="raw"),
|
||
|
|
sequence=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
||
|
|
await _setup(operator)
|
||
|
|
await operator.process(
|
||
|
|
_envelope(_image(key_frame=True, data=b"key"), sequence=1)
|
||
|
|
)
|
||
|
|
raw = await operator.process(raw_envelope)
|
||
|
|
await operator.stop()
|
||
|
|
return raw
|
||
|
|
|
||
|
|
result = _run_with_ticker(exercise())
|
||
|
|
|
||
|
|
assert isinstance(result, Emission)
|
||
|
|
assert result.envelope is raw_envelope
|
||
|
|
assert log.close_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_rejects_wrong_payload_unsupported_codec_and_invalid_dimensions() -> None:
|
||
|
|
log = _BackendLog()
|
||
|
|
operator = VideoDecoderOperator("decoder", {}, backend_factory=_factory(log))
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
with pytest.raises(TypeError, match="expected ImageFrame"):
|
||
|
|
await operator.process(_envelope("not-an-image"))
|
||
|
|
with pytest.raises(ValueError, match="unsupported encoded video codec"):
|
||
|
|
await operator.process(
|
||
|
|
_envelope(_image(codec="vp9", key_frame=True), sequence=1)
|
||
|
|
)
|
||
|
|
with pytest.raises(ValueError, match="dimensions must be positive"):
|
||
|
|
await operator.process(
|
||
|
|
_envelope(_image(width=0, key_frame=True), sequence=1)
|
||
|
|
)
|
||
|
|
|
||
|
|
_run_with_ticker(exercise())
|
||
|
|
assert log.created_codecs == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_default_backend_imports_pyav_only_for_an_encoded_keyframe(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
imported: list[str] = []
|
||
|
|
|
||
|
|
def missing_import(name: str): # type: ignore[no-untyped-def]
|
||
|
|
imported.append(name)
|
||
|
|
raise ImportError("synthetic missing dependency")
|
||
|
|
|
||
|
|
monkeypatch.setattr(video_module, "import_module", missing_import)
|
||
|
|
operator = VideoDecoderOperator("decoder", {})
|
||
|
|
raw = _envelope(ImageFrame(b"raw", 1, 1, "BGR8", codec="none"))
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
assert isinstance(await operator.process(raw), Emission)
|
||
|
|
assert imported == []
|
||
|
|
assert (
|
||
|
|
await operator.process(_envelope(_image(key_frame=False), sequence=1))
|
||
|
|
is None
|
||
|
|
)
|
||
|
|
assert imported == []
|
||
|
|
with pytest.raises(RuntimeError, match="optional video dependencies"):
|
||
|
|
await operator.process(_envelope(_image(key_frame=True), sequence=2))
|
||
|
|
|
||
|
|
_run_with_ticker(exercise())
|
||
|
|
assert imported == ["av"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_setup_rejects_a_non_executor_thread_executor() -> None:
|
||
|
|
operator = VideoDecoderOperator("decoder", {})
|
||
|
|
|
||
|
|
async def exercise() -> None:
|
||
|
|
with pytest.raises(TypeError, match="must be an Executor"):
|
||
|
|
await _setup(operator, object())
|
||
|
|
|
||
|
|
_run_with_ticker(exercise())
|
||
|
|
|
||
|
|
|
||
|
|
def test_constructor_rejects_unknown_parameters() -> None:
|
||
|
|
with pytest.raises(ValueError, match="does not support parameters: typo"):
|
||
|
|
VideoDecoderOperator("decoder", {"typo": True})
|