fix(detection): preserve offline frames in the runner
Add an explicit source delivery policy to the detection pipeline so offline and realtime sources can be handled differently without splitting the runner. Blocking sources now backpressure ingestion until their pending frame is drained, which preserves every offline video frame even when inference is slower than decode. Latest-only sources keep the previous overwrite behavior for realtime feeds such as cvmmap. The tests now cover both policies: offline sources retain ordered frame delivery under slow inference, while latest-only sources still drop intermediate frames as intended.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
import anyio
|
||||
import numpy as np
|
||||
@@ -17,7 +19,8 @@ from pose_tracking_exp.detection.runner import (
|
||||
store_latest_frame,
|
||||
take_pending_batch,
|
||||
)
|
||||
from pose_tracking_exp.schema.detection import PoseDetections, SourceFrame
|
||||
from pose_tracking_exp.detection.protocols import FrameSource
|
||||
from pose_tracking_exp.schema.detection import PoseDetections, SourceDeliveryPolicy, SourceFrame
|
||||
|
||||
|
||||
def test_load_detection_runner_config_from_toml_and_env(
|
||||
@@ -61,7 +64,7 @@ def test_resolve_instances_falls_back_to_config_values() -> None:
|
||||
|
||||
|
||||
def test_store_latest_frame_overwrites_pending_frame() -> None:
|
||||
slot = SourceSlot(source_name="front_left")
|
||||
slot = SourceSlot(source_name="front_left", delivery_policy="latest_only")
|
||||
first = SourceFrame(
|
||||
source_name="front_left",
|
||||
image_bgr=np.zeros((1, 1, 3), dtype=np.uint8),
|
||||
@@ -88,6 +91,7 @@ def test_take_pending_batch_collects_at_most_one_frame_per_source() -> None:
|
||||
slots = {
|
||||
"front_left": SourceSlot(
|
||||
source_name="front_left",
|
||||
delivery_policy="latest_only",
|
||||
pending_frame=PendingFrame(
|
||||
source_name="front_left",
|
||||
frame=SourceFrame(
|
||||
@@ -100,6 +104,7 @@ def test_take_pending_batch_collects_at_most_one_frame_per_source() -> None:
|
||||
),
|
||||
"front_right": SourceSlot(
|
||||
source_name="front_right",
|
||||
delivery_policy="latest_only",
|
||||
pending_frame=PendingFrame(
|
||||
source_name="front_right",
|
||||
frame=SourceFrame(
|
||||
@@ -112,6 +117,7 @@ def test_take_pending_batch_collects_at_most_one_frame_per_source() -> None:
|
||||
),
|
||||
"rear": SourceSlot(
|
||||
source_name="rear",
|
||||
delivery_policy="latest_only",
|
||||
pending_frame=PendingFrame(
|
||||
source_name="rear",
|
||||
frame=SourceFrame(
|
||||
@@ -133,9 +139,16 @@ def test_take_pending_batch_collects_at_most_one_frame_per_source() -> None:
|
||||
|
||||
|
||||
class StubSource:
|
||||
def __init__(self, source_name: str, frames: tuple[SourceFrame, ...]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
source_name: str,
|
||||
frames: tuple[SourceFrame, ...],
|
||||
*,
|
||||
delivery_policy: SourceDeliveryPolicy = "latest_only",
|
||||
) -> None:
|
||||
self.source_name = source_name
|
||||
self._frames = frames
|
||||
self.delivery_policy = delivery_policy
|
||||
|
||||
async def frames(self) -> AsyncIterator[SourceFrame]:
|
||||
for frame in self._frames:
|
||||
@@ -143,7 +156,12 @@ class StubSource:
|
||||
|
||||
|
||||
class StubPoseShim:
|
||||
def __init__(self, delay_seconds: float = 0.0) -> None:
|
||||
self._delay_seconds = delay_seconds
|
||||
|
||||
def process_many(self, frames: Sequence[SourceFrame]) -> list[PoseDetections]:
|
||||
if self._delay_seconds > 0.0:
|
||||
time.sleep(self._delay_seconds)
|
||||
detections: list[PoseDetections] = []
|
||||
for frame in frames:
|
||||
detections.append(
|
||||
@@ -187,6 +205,7 @@ def test_run_detection_runner_publishes_payloads() -> None:
|
||||
timestamp_unix_ns=100,
|
||||
),
|
||||
),
|
||||
delivery_policy="block",
|
||||
),
|
||||
StubSource(
|
||||
"cam1",
|
||||
@@ -198,6 +217,7 @@ def test_run_detection_runner_publishes_payloads() -> None:
|
||||
timestamp_unix_ns=200,
|
||||
),
|
||||
),
|
||||
delivery_policy="block",
|
||||
),
|
||||
)
|
||||
config = DetectionRunnerConfig(
|
||||
@@ -210,7 +230,7 @@ def test_run_detection_runner_publishes_payloads() -> None:
|
||||
|
||||
anyio.run(
|
||||
run_detection_runner,
|
||||
sources,
|
||||
cast(tuple[FrameSource, ...], sources),
|
||||
StubPoseShim(),
|
||||
sink,
|
||||
config,
|
||||
@@ -221,3 +241,76 @@ def test_run_detection_runner_publishes_payloads() -> None:
|
||||
("cam0", 1, 100),
|
||||
("cam1", 2, 200),
|
||||
]
|
||||
|
||||
|
||||
def test_run_detection_runner_blocks_to_preserve_offline_frames() -> None:
|
||||
sink = StubSink()
|
||||
source = StubSource(
|
||||
"cam0",
|
||||
tuple(
|
||||
SourceFrame(
|
||||
source_name="cam0",
|
||||
image_bgr=np.zeros((2, 3, 3), dtype=np.uint8),
|
||||
frame_index=frame_index,
|
||||
timestamp_unix_ns=frame_index * 100,
|
||||
)
|
||||
for frame_index in range(3)
|
||||
),
|
||||
delivery_policy="block",
|
||||
)
|
||||
config = DetectionRunnerConfig(
|
||||
instances=("cam0",),
|
||||
pose_config_path=Path(__file__),
|
||||
yolo_checkpoint=Path(__file__),
|
||||
pose_checkpoint=Path(__file__),
|
||||
max_batch_frames=1,
|
||||
max_batch_wait_ms=0,
|
||||
)
|
||||
|
||||
anyio.run(
|
||||
run_detection_runner,
|
||||
cast(tuple[FrameSource, ...], (source,)),
|
||||
StubPoseShim(delay_seconds=0.01),
|
||||
sink,
|
||||
config,
|
||||
)
|
||||
|
||||
assert [item.frame_index for item in sink.messages] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_run_detection_runner_drops_intermediate_latest_only_frames() -> None:
|
||||
sink = StubSink()
|
||||
source = StubSource(
|
||||
"cam0",
|
||||
tuple(
|
||||
SourceFrame(
|
||||
source_name="cam0",
|
||||
image_bgr=np.zeros((2, 3, 3), dtype=np.uint8),
|
||||
frame_index=frame_index,
|
||||
timestamp_unix_ns=frame_index * 100,
|
||||
)
|
||||
for frame_index in range(3)
|
||||
),
|
||||
delivery_policy="latest_only",
|
||||
)
|
||||
config = DetectionRunnerConfig(
|
||||
instances=("cam0",),
|
||||
pose_config_path=Path(__file__),
|
||||
yolo_checkpoint=Path(__file__),
|
||||
pose_checkpoint=Path(__file__),
|
||||
max_batch_frames=1,
|
||||
max_batch_wait_ms=0,
|
||||
)
|
||||
|
||||
anyio.run(
|
||||
run_detection_runner,
|
||||
cast(tuple[FrameSource, ...], (source,)),
|
||||
StubPoseShim(delay_seconds=0.01),
|
||||
sink,
|
||||
config,
|
||||
)
|
||||
|
||||
processed = [item.frame_index for item in sink.messages]
|
||||
assert processed[-1] == 2
|
||||
assert processed != [0, 1, 2]
|
||||
assert 0 not in processed
|
||||
|
||||
Reference in New Issue
Block a user