fix(perception): isolate recorded decode from realtime admission
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
@@ -32,10 +32,13 @@ from .providers import (
|
||||
)
|
||||
from .recorded_source import (
|
||||
DecodedRecordedSource,
|
||||
DecodePrefetchSnapshot,
|
||||
DecodeTimingObserver,
|
||||
PrefetchedRecordedImageDecoder,
|
||||
PyAvRecordedImageDecoder,
|
||||
RecordedRavnoves00Source,
|
||||
ReplayPacing,
|
||||
SourcePacingObserver,
|
||||
)
|
||||
from .reference_graph_runtime import ReferenceGraphRuntimePaths
|
||||
from .rf_detr_object_detector import (
|
||||
@@ -64,6 +67,8 @@ class M48sReferenceGraphRuntime:
|
||||
|
||||
graph: ReferencePerceptionGraphV2
|
||||
inference_backend: TritonRfDetrHttpInferenceBackend
|
||||
source_prefetch: PrefetchedRecordedImageDecoder
|
||||
_preparation_stop_event: Event = field(default_factory=Event)
|
||||
|
||||
def warm_up_detector(self) -> DetectorWarmupSnapshot:
|
||||
detector = self.graph.detector
|
||||
@@ -71,8 +76,18 @@ class M48sReferenceGraphRuntime:
|
||||
raise M48sReferenceGraphRuntimeError("RF-DETR runtime detector changed before warmup")
|
||||
return detector.warm_up()
|
||||
|
||||
def prepare_source(self) -> DecodePrefetchSnapshot:
|
||||
return self.source_prefetch.prepare(self._preparation_stop_event)
|
||||
|
||||
def mark_source_admission_started(self) -> None:
|
||||
self.source_prefetch.mark_admission_started()
|
||||
|
||||
def close(self) -> None:
|
||||
self.inference_backend.close()
|
||||
self._preparation_stop_event.set()
|
||||
try:
|
||||
self.source_prefetch.close()
|
||||
finally:
|
||||
self.inference_backend.close()
|
||||
|
||||
def __enter__(self) -> M48sReferenceGraphRuntime:
|
||||
return self
|
||||
@@ -90,9 +105,12 @@ def build_m48s_reference_graph_runtime(
|
||||
delivery_observer: Callable[[DeliveredFrame, int], None] | None = None,
|
||||
delivery_evidence_observer: DeliveryEvidenceObserver | None = None,
|
||||
decode_timing_observer: DecodeTimingObserver | None = None,
|
||||
source_pacing_observer: SourcePacingObserver | None = None,
|
||||
detector_timing_observer: DetectorTimingObserver | None = None,
|
||||
maximum_frames: int | None = None,
|
||||
source_rate_hz: float | None = None,
|
||||
source_prefetch_capacity_frames: int = 64,
|
||||
source_prefetch_ready_frames: int = 64,
|
||||
) -> M48sReferenceGraphRuntime:
|
||||
"""Instantiate the complete graph with only its detector pin replaced."""
|
||||
|
||||
@@ -117,6 +135,12 @@ def build_m48s_reference_graph_runtime(
|
||||
|
||||
if maximum_frames is not None and maximum_frames < 1:
|
||||
raise M48sReferenceGraphRuntimeError("maximum frame count must be positive")
|
||||
prefetch = PrefetchedRecordedImageDecoder(
|
||||
PyAvRecordedImageDecoder(paths.video),
|
||||
capacity_frames=source_prefetch_capacity_frames,
|
||||
ready_frames=source_prefetch_ready_frames,
|
||||
timing_observer=decode_timing_observer,
|
||||
)
|
||||
source: SourceProvider = DecodedRecordedSource(
|
||||
source=RecordedRavnoves00Source(
|
||||
camera_index_path=paths.camera_index,
|
||||
@@ -131,9 +155,9 @@ def build_m48s_reference_graph_runtime(
|
||||
if run_mode is GraphRunMode.SOURCE_PACED_LATEST_WINS
|
||||
else None
|
||||
),
|
||||
pacing_observer=source_pacing_observer,
|
||||
),
|
||||
decoder=PyAvRecordedImageDecoder(paths.video),
|
||||
timing_observer=decode_timing_observer,
|
||||
decoder=prefetch,
|
||||
)
|
||||
if maximum_frames is not None:
|
||||
source = _LimitedSource(source, maximum_frames)
|
||||
@@ -175,9 +199,16 @@ def build_m48s_reference_graph_runtime(
|
||||
delivery_evidence_observer=delivery_evidence_observer,
|
||||
)
|
||||
except Exception:
|
||||
backend.close()
|
||||
try:
|
||||
prefetch.close()
|
||||
finally:
|
||||
backend.close()
|
||||
raise
|
||||
return M48sReferenceGraphRuntime(graph=graph, inference_backend=backend)
|
||||
return M48sReferenceGraphRuntime(
|
||||
graph=graph,
|
||||
inference_backend=backend,
|
||||
source_prefetch=prefetch,
|
||||
)
|
||||
|
||||
|
||||
class _LimitedSource:
|
||||
|
||||
@@ -10,7 +10,8 @@ from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from queue import Empty, Full, Queue
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Any, Final, Protocol, cast
|
||||
|
||||
import numpy as np
|
||||
@@ -53,6 +54,11 @@ class ReplayPacing(StrEnum):
|
||||
UNCAPPED = "uncapped"
|
||||
|
||||
|
||||
class DecodePhase(StrEnum):
|
||||
PREADMISSION = "preadmission"
|
||||
HOT_LOOP = "hot-loop"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedFrameReference:
|
||||
"""Opaque reference passed to a provider without decoding sensor data."""
|
||||
@@ -89,6 +95,7 @@ WaitFunction = Callable[[Event, float], bool]
|
||||
class DecodedFrameTiming:
|
||||
sequence: int
|
||||
duration_ns: int
|
||||
phase: DecodePhase = DecodePhase.HOT_LOOP
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sequence < 0 or self.duration_ns < 0:
|
||||
@@ -98,6 +105,43 @@ class DecodedFrameTiming:
|
||||
DecodeTimingObserver = Callable[[DecodedFrameTiming], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePacingTiming:
|
||||
sequence: int
|
||||
scheduled_monotonic_ns: int
|
||||
emitted_monotonic_ns: int
|
||||
lateness_ns: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.sequence < 0
|
||||
or self.scheduled_monotonic_ns < 0
|
||||
or self.emitted_monotonic_ns < 0
|
||||
or self.lateness_ns < 0
|
||||
):
|
||||
raise RecordedSourceError("source pacing timing must be nonnegative")
|
||||
|
||||
|
||||
SourcePacingObserver = Callable[[SourcePacingTiming], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodePrefetchSnapshot:
|
||||
capacity_frames: int
|
||||
ready_frames: int
|
||||
buffered_frames: int
|
||||
preparation_duration_ns: int
|
||||
producer_alive: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DecodeFailure:
|
||||
error: BaseException
|
||||
|
||||
|
||||
_DECODE_END: Final = object()
|
||||
|
||||
|
||||
class RecordedRavnoves00Source:
|
||||
"""Emit the admitted timeline at its recorded or an explicit replay rate.
|
||||
|
||||
@@ -116,6 +160,7 @@ class RecordedRavnoves00Source:
|
||||
expected_frame_count: int = DEFAULT_FRAME_COUNT,
|
||||
expected_source_pack_sha256: str | None = RECORDED_SOURCE_PACK_SHA256,
|
||||
target_rate_hz: float | None = None,
|
||||
pacing_observer: SourcePacingObserver | None = None,
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
wait: WaitFunction | None = None,
|
||||
) -> None:
|
||||
@@ -133,6 +178,7 @@ class RecordedRavnoves00Source:
|
||||
self.expected_frame_count = expected_frame_count
|
||||
self.expected_source_pack_sha256 = expected_source_pack_sha256
|
||||
self.target_rate_hz = target_rate_hz
|
||||
self.pacing_observer = pacing_observer
|
||||
self._clock_ns = clock_ns
|
||||
self._wait = wait or _event_wait
|
||||
|
||||
@@ -189,6 +235,16 @@ class RecordedRavnoves00Source:
|
||||
target_ns = started_ns + round(source_elapsed_ns * pacing_scale)
|
||||
if not self._pace_until(stop_event, target_ns):
|
||||
return
|
||||
emitted_ns = int(self._clock_ns())
|
||||
if self.pacing_observer is not None:
|
||||
self.pacing_observer(
|
||||
SourcePacingTiming(
|
||||
sequence=packet.envelope.sequence,
|
||||
scheduled_monotonic_ns=target_ns,
|
||||
emitted_monotonic_ns=emitted_ns,
|
||||
lateness_ns=max(0, emitted_ns - target_ns),
|
||||
)
|
||||
)
|
||||
yield packet
|
||||
|
||||
def _pace_until(self, stop_event: Event, target_ns: int) -> bool:
|
||||
@@ -295,6 +351,176 @@ class PyAvRecordedImageDecoder:
|
||||
container.close()
|
||||
|
||||
|
||||
class PrefetchedRecordedImageDecoder:
|
||||
"""Decode into a bounded queue before source admission and during replay.
|
||||
|
||||
The queue absorbs ordinary storage/codec jitter without retaining the whole
|
||||
recording in RAM. Decode work remains single-pass and source order remains
|
||||
exact. The explicit phase boundary keeps cold codec initialization out of
|
||||
hot-loop latency attribution.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
decoder: RecordedImageDecoder,
|
||||
*,
|
||||
capacity_frames: int = 64,
|
||||
ready_frames: int = 64,
|
||||
timing_observer: DecodeTimingObserver | None = None,
|
||||
clock_ns: Callable[[], int] = time.perf_counter_ns,
|
||||
) -> None:
|
||||
if capacity_frames < 1:
|
||||
raise RecordedSourceError("decode prefetch capacity must be positive")
|
||||
if ready_frames < 1 or ready_frames > capacity_frames:
|
||||
raise RecordedSourceError("decode prefetch readiness must fit its capacity")
|
||||
self.decoder = decoder
|
||||
self.capacity_frames = capacity_frames
|
||||
self.ready_frames = ready_frames
|
||||
self.timing_observer = timing_observer
|
||||
self._clock_ns = clock_ns
|
||||
self._queue: Queue[NDArray[np.uint8] | _DecodeFailure | object] = Queue(
|
||||
capacity_frames
|
||||
)
|
||||
self._stop = Event()
|
||||
self._ready = Event()
|
||||
self._guard = Lock()
|
||||
self._thread: Thread | None = None
|
||||
self._phase = DecodePhase.PREADMISSION
|
||||
self._produced = 0
|
||||
self._started_ns: int | None = None
|
||||
self._ready_ns: int | None = None
|
||||
self._closed = False
|
||||
|
||||
def prepare(self, stop_event: Event) -> DecodePrefetchSnapshot:
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
raise RecordedSourceError("decode prefetch is closed")
|
||||
if self._thread is None:
|
||||
self._started_ns = int(self._clock_ns())
|
||||
self._thread = Thread(
|
||||
target=self._produce,
|
||||
name="m48s-recorded-decode-prefetch",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
while not self._ready.wait(0.05):
|
||||
if stop_event.is_set():
|
||||
raise RecordedSourceError("decode prefetch preparation was cancelled")
|
||||
with self._guard:
|
||||
started_ns = self._started_ns
|
||||
ready_ns = self._ready_ns
|
||||
thread = self._thread
|
||||
produced = self._produced
|
||||
if started_ns is None or ready_ns is None or thread is None:
|
||||
raise RecordedSourceError("decode prefetch readiness is incomplete")
|
||||
first = self._peek()
|
||||
if isinstance(first, _DecodeFailure):
|
||||
raise RecordedSourceError("recorded camera prefetch failed") from first.error
|
||||
return DecodePrefetchSnapshot(
|
||||
capacity_frames=self.capacity_frames,
|
||||
ready_frames=self.ready_frames,
|
||||
buffered_frames=min(produced, self.capacity_frames),
|
||||
preparation_duration_ns=max(0, ready_ns - started_ns),
|
||||
producer_alive=thread.is_alive(),
|
||||
)
|
||||
|
||||
def mark_admission_started(self) -> None:
|
||||
with self._guard:
|
||||
if not self._ready.is_set():
|
||||
raise RecordedSourceError("source admission started before decode prefetch")
|
||||
self._phase = DecodePhase.HOT_LOOP
|
||||
|
||||
def frames(self, stop_event: Event) -> Iterator[NDArray[np.uint8]]:
|
||||
self.prepare(stop_event)
|
||||
while not stop_event.is_set() and not self._stop.is_set():
|
||||
try:
|
||||
item = self._queue.get(timeout=0.05)
|
||||
except Empty:
|
||||
continue
|
||||
try:
|
||||
if item is _DECODE_END:
|
||||
return
|
||||
if isinstance(item, _DecodeFailure):
|
||||
raise RecordedSourceError("recorded camera decode failed") from item.error
|
||||
yield cast(NDArray[np.uint8], item)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def close(self, *, timeout_seconds: float = 5.0) -> None:
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=max(0.0, timeout_seconds))
|
||||
if thread.is_alive():
|
||||
raise RecordedSourceError("decode prefetch worker did not stop")
|
||||
|
||||
def _produce(self) -> None:
|
||||
sequence = 0
|
||||
images = self.decoder.frames(self._stop)
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
while self._queue.full() and not self._stop.wait(0.01):
|
||||
pass
|
||||
if self._stop.is_set():
|
||||
return
|
||||
started_ns = int(self._clock_ns())
|
||||
try:
|
||||
image = next(images)
|
||||
except StopIteration:
|
||||
self._signal_ready()
|
||||
self._put(_DECODE_END)
|
||||
return
|
||||
completed_ns = int(self._clock_ns())
|
||||
with self._guard:
|
||||
phase = self._phase
|
||||
if self.timing_observer is not None:
|
||||
self.timing_observer(
|
||||
DecodedFrameTiming(
|
||||
sequence=sequence,
|
||||
duration_ns=max(0, completed_ns - started_ns),
|
||||
phase=phase,
|
||||
)
|
||||
)
|
||||
if not self._put(np.asarray(image, dtype=np.uint8)):
|
||||
return
|
||||
sequence += 1
|
||||
with self._guard:
|
||||
self._produced = sequence
|
||||
ready = sequence >= self.ready_frames
|
||||
if ready:
|
||||
self._signal_ready()
|
||||
except BaseException as exc:
|
||||
self._put(_DecodeFailure(exc))
|
||||
self._signal_ready()
|
||||
finally:
|
||||
close = getattr(images, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
def _put(self, item: NDArray[np.uint8] | _DecodeFailure | object) -> bool:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._queue.put(item, timeout=0.05)
|
||||
return True
|
||||
except Full:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _signal_ready(self) -> None:
|
||||
with self._guard:
|
||||
if self._ready_ns is None:
|
||||
self._ready_ns = int(self._clock_ns())
|
||||
self._ready.set()
|
||||
|
||||
def _peek(self) -> NDArray[np.uint8] | _DecodeFailure | object | None:
|
||||
with self._queue.mutex:
|
||||
return self._queue.queue[0] if self._queue.queue else None
|
||||
|
||||
|
||||
def _packet(
|
||||
frame_index: int,
|
||||
camera: dict[str, object],
|
||||
|
||||
Reference in New Issue
Block a user