fix(perception): isolate recorded decode from realtime admission

This commit is contained in:
DCCONSTRUCTIONS
2026-08-25 21:58:12 +03:00
parent bab0bb2df2
commit fb5bf943c9
4 changed files with 416 additions and 15 deletions
@@ -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:
+227 -1
View File
@@ -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],