feat(perception): trace M4.8S pipeline latency

This commit is contained in:
DCCONSTRUCTIONS
2026-08-25 18:21:57 +03:00
parent 5b07372791
commit a0152f371e
7 changed files with 453 additions and 9 deletions
+61 -1
View File
@@ -62,6 +62,45 @@ class DetectorProviderSnapshot:
core_duration_ns: int
@dataclass(frozen=True, slots=True)
class DetectorFrameTiming:
sequence: int
preprocess_duration_ns: int
inference_transport_duration_ns: int
postprocess_duration_ns: int
total_duration_ns: int
def __post_init__(self) -> None:
values = (
self.sequence,
self.preprocess_duration_ns,
self.inference_transport_duration_ns,
self.postprocess_duration_ns,
self.total_duration_ns,
)
if any(value < 0 for value in values):
raise DetectorProviderError("detector frame timing must be nonnegative")
if (
self.preprocess_duration_ns
+ self.inference_transport_duration_ns
+ self.postprocess_duration_ns
!= self.total_duration_ns
):
raise DetectorProviderError("detector frame timing does not close")
def to_dict(self) -> dict[str, int]:
return {
"sequence": self.sequence,
"preprocess_duration_ns": self.preprocess_duration_ns,
"inference_transport_duration_ns": self.inference_transport_duration_ns,
"postprocess_duration_ns": self.postprocess_duration_ns,
"total_duration_ns": self.total_duration_ns,
}
DetectorTimingObserver = Callable[[DetectorFrameTiming], None]
class FrozenYoloxDetectorProvider:
"""One image payload produces one frozen inference request and proposal tuple."""
@@ -202,6 +241,7 @@ class RfDetrShadowDetectorProvider:
resizer: ImageResizer | None = None,
config: RfDetrConfig = RF_DETR_CONFIG,
clock_ns: Callable[[], int] = time.perf_counter_ns,
timing_observer: DetectorTimingObserver | None = None,
) -> None:
if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask):
raise DetectorProviderError("RF-DETR valid-FOV mask is incompatible")
@@ -210,6 +250,7 @@ class RfDetrShadowDetectorProvider:
self.resizer = resizer
self.config = config
self._clock_ns = clock_ns
self.timing_observer = timing_observer
self._lock = Lock()
self._input_frames = 0
self._completed_frames = 0
@@ -236,7 +277,13 @@ class RfDetrShadowDetectorProvider:
config=self.config,
resizer=self.resizer,
)
preprocessed_ns = (
int(self._clock_ns()) if self.timing_observer is not None else started_ns
)
output = self.backend.infer(tensor)
inferred_ns = (
int(self._clock_ns()) if self.timing_observer is not None else preprocessed_ns
)
postprocessed = postprocess_rf_detr(output, self.mask, config=self.config)
proposals = proposals_from_rf_detr_detections(packet, postprocessed.detections)
except Exception:
@@ -244,12 +291,23 @@ class RfDetrShadowDetectorProvider:
self._failed_frames += 1
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
raise
completed_ns = int(self._clock_ns())
with self._lock:
self._completed_frames += 1
self._proposal_count += len(proposals)
self._zero_proposal_frames += not proposals
self._rejected.update(dict(postprocessed.rejected))
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
self._core_duration_ns += max(0, completed_ns - started_ns)
if self.timing_observer is not None:
self.timing_observer(
DetectorFrameTiming(
sequence=packet.envelope.sequence,
preprocess_duration_ns=max(0, preprocessed_ns - started_ns),
inference_transport_duration_ns=max(0, inferred_ns - preprocessed_ns),
postprocess_duration_ns=max(0, completed_ns - inferred_ns),
total_duration_ns=max(0, completed_ns - started_ns),
)
)
return proposals
def snapshot(self) -> DetectorProviderSnapshot:
@@ -297,6 +355,8 @@ __all__ = [
"RF_DETR_SHADOW_PROVIDER_ID",
"DetectorProviderError",
"DetectorProviderSnapshot",
"DetectorFrameTiming",
"DetectorTimingObserver",
"AllCocoYoloxDetectorProvider",
"FrozenYoloxDetectorProvider",
"RfDetrShadowDetectorProvider",
@@ -10,7 +10,11 @@ from pathlib import Path
from threading import Event
from .baseline import load_m4_baseline
from .detector import RF_DETR_SHADOW_PROVIDER_ID, RfDetrShadowDetectorProvider
from .detector import (
RF_DETR_SHADOW_PROVIDER_ID,
DetectorTimingObserver,
RfDetrShadowDetectorProvider,
)
from .geometry import (
Ravnoves00GeometryAssociationProvider,
RecordedGeometryStore,
@@ -27,6 +31,7 @@ from .providers import (
)
from .recorded_source import (
DecodedRecordedSource,
DecodeTimingObserver,
PyAvRecordedImageDecoder,
RecordedRavnoves00Source,
ReplayPacing,
@@ -77,6 +82,8 @@ def build_m48s_reference_graph_runtime(
run_mode: GraphRunMode,
delivery_observer: Callable[[DeliveredFrame, int], None] | None = None,
delivery_evidence_observer: DeliveryEvidenceObserver | None = None,
decode_timing_observer: DecodeTimingObserver | None = None,
detector_timing_observer: DetectorTimingObserver | None = None,
maximum_frames: int | None = None,
) -> M48sReferenceGraphRuntime:
"""Instantiate the complete graph with only its detector pin replaced."""
@@ -113,6 +120,7 @@ def build_m48s_reference_graph_runtime(
),
),
decoder=PyAvRecordedImageDecoder(paths.video),
timing_observer=decode_timing_observer,
)
if maximum_frames is not None:
source = _LimitedSource(source, maximum_frames)
@@ -133,6 +141,7 @@ def build_m48s_reference_graph_runtime(
detector=RfDetrShadowDetectorProvider(
mask=load_valid_fov_mask(paths.valid_fov_mask),
backend=backend,
timing_observer=detector_timing_observer,
),
geometry=Ravnoves00GeometryAssociationProvider(store=store),
temporal=BoundedSpatialTemporalProvider(
+39 -2
View File
@@ -85,6 +85,19 @@ class RecordedImageDecoder(Protocol):
WaitFunction = Callable[[Event, float], bool]
@dataclass(frozen=True, slots=True)
class DecodedFrameTiming:
sequence: int
duration_ns: int
def __post_init__(self) -> None:
if self.sequence < 0 or self.duration_ns < 0:
raise RecordedSourceError("decoded frame timing must be nonnegative")
DecodeTimingObserver = Callable[[DecodedFrameTiming], None]
class RecordedRavnoves00Source:
"""Emit the admitted synchronized source timeline at 1.0x or uncapped speed."""
@@ -182,14 +195,18 @@ class DecodedRecordedSource:
*,
source: RecordedRavnoves00Source,
decoder: RecordedImageDecoder,
timing_observer: DecodeTimingObserver | None = None,
clock_ns: Callable[[], int] = time.perf_counter_ns,
) -> None:
self.source = source
self.decoder = decoder
self.timing_observer = timing_observer
self._clock_ns = clock_ns
def packets(self, stop_event: Event) -> Iterator[SourcePacket]:
images = self.decoder.frames(stop_event)
try:
image: NDArray[np.uint8] | None = next(images)
image: NDArray[np.uint8] | None = self._next_image(images, 0)
except StopIteration as exc:
if stop_event.is_set():
return
@@ -203,7 +220,7 @@ class DecodedRecordedSource:
raise RecordedSourceError("decoded image raster is incompatible")
yield replace(packet, image_payload=image)
try:
image = next(images)
image = self._next_image(images, packet.envelope.sequence + 1)
except StopIteration:
image = None
if not stop_event.is_set():
@@ -211,6 +228,24 @@ class DecodedRecordedSource:
return
raise RecordedSourceError("decoded image stream exceeds source timeline")
def _next_image(
self,
images: Iterator[NDArray[np.uint8]],
sequence: int,
) -> NDArray[np.uint8]:
observer = self.timing_observer
if observer is None:
return next(images)
started_ns = int(self._clock_ns())
image = next(images)
observer(
DecodedFrameTiming(
sequence=sequence,
duration_ns=max(0, int(self._clock_ns()) - started_ns),
)
)
return image
class PyAvRecordedImageDecoder:
"""Sequential full-video decoder used by the Worker 006 recorded source adapter."""
@@ -394,6 +429,8 @@ def _event_wait(stop_event: Event, timeout_seconds: float) -> bool:
__all__ = [
"BASELINE_PROFILE_ID",
"DecodeTimingObserver",
"DecodedFrameTiming",
"LiveSourceAdapter",
"DecodedRecordedSource",
"PyAvRecordedImageDecoder",