feat(v1): latest-frame-only reader thread for low-latency RTSP
Adds _FrameGrabber which decodes in a background thread and keeps only the newest frame; VideoSource drop_stale (STREAM only) returns the latest frame so a slow consumer never builds a backlog. app enables it for streams. REPLAY is unchanged. 82 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from v1.video_source import SourceMode, SourceStatus, VideoSource
|
||||
from v1.video_source import SourceMode, SourceStatus, VideoSource, _FrameGrabber
|
||||
|
||||
|
||||
def _write_sample_video(path):
|
||||
@@ -135,6 +137,72 @@ def test_stream_recovers_after_mid_stream_drop():
|
||||
assert recovered.image is not None
|
||||
|
||||
|
||||
class _ScriptedCapture:
|
||||
def __init__(self, frames, fail_after=False):
|
||||
self._frames = list(frames)
|
||||
self._fail_after = fail_after
|
||||
|
||||
def isOpened(self):
|
||||
return True
|
||||
|
||||
def read(self):
|
||||
if self._frames:
|
||||
return True, self._frames.pop(0)
|
||||
return False, None
|
||||
|
||||
def get(self, _property_id):
|
||||
return 0.0
|
||||
|
||||
def release(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_frame_grabber_keeps_only_the_latest_frame():
|
||||
frame_a = np.full((4, 4, 3), 1, dtype=np.uint8)
|
||||
frame_b = np.full((4, 4, 3), 2, dtype=np.uint8)
|
||||
frame_c = np.full((4, 4, 3), 3, dtype=np.uint8)
|
||||
grabber = _FrameGrabber(_ScriptedCapture([frame_a, frame_b, frame_c]))
|
||||
|
||||
assert grabber._pump() is True
|
||||
assert grabber._pump() is True
|
||||
assert grabber._pump() is True
|
||||
image, failed = grabber.take_latest()
|
||||
|
||||
assert failed is False
|
||||
assert image is frame_c # older frames dropped
|
||||
|
||||
|
||||
def test_frame_grabber_marks_failed_on_read_failure():
|
||||
grabber = _FrameGrabber(_ScriptedCapture([np.zeros((4, 4, 3), dtype=np.uint8)]))
|
||||
|
||||
assert grabber._pump() is True
|
||||
assert grabber._pump() is False
|
||||
_, failed = grabber.take_latest()
|
||||
|
||||
assert failed is True
|
||||
|
||||
|
||||
def test_stream_with_drop_stale_returns_a_connected_frame():
|
||||
source = VideoSource(
|
||||
"rtsp://demo",
|
||||
mode=SourceMode.STREAM,
|
||||
drop_stale=True,
|
||||
capture_factory=lambda _s: _OpenCapture(),
|
||||
)
|
||||
try:
|
||||
packet = None
|
||||
for _ in range(40):
|
||||
packet = source.read()
|
||||
if packet.status is SourceStatus.CONNECTED and packet.image is not None:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert packet is not None
|
||||
assert packet.status is SourceStatus.CONNECTED
|
||||
assert packet.image is not None
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
|
||||
class _NegativeFirstTimestampCapture:
|
||||
def __init__(self):
|
||||
self._read_count = 0
|
||||
|
||||
Reference in New Issue
Block a user