120b8c2cf6
采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
519 lines
15 KiB
Python
519 lines
15 KiB
Python
"""Unit tests for orisys.capture with fake VideoCapture objects (no hardware)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys.capture import (
|
|
MAX_SIMULTANEOUS_CAPTURES,
|
|
CameraFilter,
|
|
CameraInfo,
|
|
CaptureConfig,
|
|
CaptureHealth,
|
|
CaptureSource,
|
|
ReconnectPolicy,
|
|
list_cameras,
|
|
normalize_source,
|
|
open_capture,
|
|
pick_default_camera,
|
|
)
|
|
from orisys.capture.discovery import DEFAULT_LAPTOP_WEBCAM_PATTERNS
|
|
from orisys.capture.source import _apply_stream_props, _warmup_capture
|
|
|
|
MJPG = cv2.VideoWriter_fourcc(*"MJPG")
|
|
YUY2 = cv2.VideoWriter_fourcc(*"YUY2")
|
|
|
|
|
|
class FakeCap:
|
|
"""Deterministic stand-in for cv2.VideoCapture."""
|
|
|
|
def __init__(
|
|
self,
|
|
frames=None,
|
|
*,
|
|
fail_after=None,
|
|
fail_reads=0,
|
|
open_ok=True,
|
|
reset_fourcc_on_resize=False,
|
|
):
|
|
self._frames = list(frames or [])
|
|
self._idx = 0
|
|
self._open = bool(open_ok)
|
|
self._fail_after = fail_after
|
|
self._fail_reads_remaining = int(fail_reads)
|
|
self.read_count = 0
|
|
self.grab_count = 0
|
|
self.release_count = 0
|
|
self.width = 4
|
|
self.height = 3
|
|
self.fps = 30.0
|
|
self.fourcc = 0
|
|
self.reset_fourcc_on_resize = reset_fourcc_on_resize
|
|
self.set_calls: list[tuple[int, float]] = []
|
|
|
|
def isOpened(self):
|
|
return self._open
|
|
|
|
def read(self):
|
|
self.read_count += 1
|
|
if not self._open:
|
|
return False, None
|
|
if self._fail_reads_remaining > 0:
|
|
self._fail_reads_remaining -= 1
|
|
return False, None
|
|
if self._fail_after is not None and self.read_count > self._fail_after:
|
|
self._open = False
|
|
return False, None
|
|
if self._idx >= len(self._frames):
|
|
if not self._frames:
|
|
return False, None
|
|
frame = self._frames[-1]
|
|
else:
|
|
frame = self._frames[self._idx]
|
|
self._idx += 1
|
|
return True, frame.copy()
|
|
|
|
def grab(self):
|
|
self.grab_count += 1
|
|
if not self._open:
|
|
return False
|
|
ok, _ = self.read()
|
|
return ok
|
|
|
|
def release(self):
|
|
self.release_count += 1
|
|
self._open = False
|
|
|
|
def get(self, prop_id):
|
|
if prop_id in (3,): # CAP_PROP_FRAME_WIDTH
|
|
return float(self.width)
|
|
if prop_id in (4,): # CAP_PROP_FRAME_HEIGHT
|
|
return float(self.height)
|
|
if prop_id in (5,): # CAP_PROP_FPS
|
|
return float(self.fps)
|
|
if prop_id in (6,): # CAP_PROP_FOURCC
|
|
return float(self.fourcc)
|
|
return 0.0
|
|
|
|
def set(self, prop_id, value):
|
|
self.set_calls.append((prop_id, float(value)))
|
|
if prop_id in (3,): # CAP_PROP_FRAME_WIDTH
|
|
self.width = int(value)
|
|
if self.reset_fourcc_on_resize:
|
|
self.fourcc = YUY2
|
|
if prop_id in (4,): # CAP_PROP_FRAME_HEIGHT
|
|
self.height = int(value)
|
|
if self.reset_fourcc_on_resize:
|
|
self.fourcc = YUY2
|
|
if prop_id in (5,): # CAP_PROP_FPS
|
|
self.fps = float(value)
|
|
if prop_id in (6,): # CAP_PROP_FOURCC
|
|
self.fourcc = int(value)
|
|
return True
|
|
|
|
|
|
def _frame(val: int) -> np.ndarray:
|
|
return np.full((3, 4, 3), val, dtype=np.uint8)
|
|
|
|
|
|
def _factory_from_caps(caps):
|
|
lock = threading.Lock()
|
|
state = {"i": 0}
|
|
|
|
def factory(_config):
|
|
with lock:
|
|
if state["i"] >= len(caps):
|
|
raise RuntimeError("No more fake captures")
|
|
cap = caps[state["i"]]
|
|
state["i"] += 1
|
|
return cap, "FAKE"
|
|
|
|
return factory
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"video,expected",
|
|
[
|
|
(None, 0),
|
|
("0", 0),
|
|
(" 1 ", 1),
|
|
(2, 2),
|
|
("/dev/video0", "/dev/video0"),
|
|
(r".\clip.mp4" + "\\", r".\clip.mp4"),
|
|
],
|
|
)
|
|
def test_normalize_source(video, expected):
|
|
assert normalize_source(video) == expected
|
|
|
|
|
|
def test_capture_config_from_camera_dict_ignores_deprecated_kwargs():
|
|
cfg = CaptureConfig.from_camera_dict(
|
|
0,
|
|
{"resolution_raw": [1920, 1080], "fps": 30},
|
|
mode="queue",
|
|
queue_size=5,
|
|
overflow="drop_newest",
|
|
start_worker=False,
|
|
)
|
|
assert cfg.width == 1920
|
|
assert cfg.height == 1080
|
|
assert cfg.background is False
|
|
|
|
|
|
def test_apply_stream_props_sets_size_before_fourcc():
|
|
cap = FakeCap([_frame(1)], reset_fourcc_on_resize=True)
|
|
_apply_stream_props(cap, width=1920, height=1080, fps=30, fourcc="MJPG")
|
|
assert cap.fourcc == MJPG
|
|
width_idx = [p for p, _ in cap.set_calls if p == 3]
|
|
fourcc_calls = [v for p, v in cap.set_calls if p == 6]
|
|
assert width_idx
|
|
assert fourcc_calls
|
|
assert cap.set_calls.index((3, 1920.0)) < cap.set_calls.index((6, float(MJPG)))
|
|
|
|
|
|
def test_warmup_capture_grabs():
|
|
cap = FakeCap([_frame(1)])
|
|
_warmup_capture(cap, grabs=3)
|
|
assert cap.grab_count == 3
|
|
|
|
|
|
def test_capture_source_read_and_status():
|
|
frames = [_frame(7)]
|
|
cap = FakeCap(frames)
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0, reconnect=ReconnectPolicy(enabled=False)),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
assert src.is_open()
|
|
assert src.status.health == CaptureHealth.CONNECTED
|
|
ok, frame = src.read_raw()
|
|
assert ok and int(frame[0, 0, 0]) == 7
|
|
src.release()
|
|
assert src.status.health == CaptureHealth.CLOSED
|
|
assert cap.release_count == 1
|
|
|
|
|
|
def test_file_read_binds_frame_sequence_and_host_clocks():
|
|
cap = FakeCap([_frame(7), _frame(8)])
|
|
src = CaptureSource(
|
|
CaptureConfig(source="sample.mp4", reconnect=ReconnectPolicy(enabled=False)),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
before = time.perf_counter_ns()
|
|
first = src.read_raw_stamped()
|
|
second = src.read_raw_stamped()
|
|
after = time.perf_counter_ns()
|
|
assert first is not None and second is not None
|
|
assert (first.sequence, second.sequence) == (1, 2)
|
|
assert (int(first.image[0, 0, 0]), int(second.image[0, 0, 0])) == (7, 8)
|
|
assert before <= first.host_mono_ns <= second.host_mono_ns <= after
|
|
assert first.host_utc_ns > 0
|
|
assert first.media_time_ms == 0.0
|
|
src.close()
|
|
|
|
|
|
def test_background_read_keeps_its_own_stamp_when_newer_frame_arrives():
|
|
cap = FakeCap([_frame(i) for i in range(10)])
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0, reconnect=ReconnectPolicy(enabled=False)),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
src.start_background()
|
|
try:
|
|
first = src.read_latest_stamped(wait_s=1.0)
|
|
assert first is not None
|
|
deadline = time.perf_counter() + 1.0
|
|
second = first
|
|
while second.sequence == first.sequence and time.perf_counter() < deadline:
|
|
second = src.read_latest_stamped(wait_s=0.01)
|
|
assert second is not None and second.sequence > first.sequence
|
|
assert int(first.image[0, 0, 0]) == min(first.sequence - 1, 9)
|
|
assert int(second.image[0, 0, 0]) == min(second.sequence - 1, 9)
|
|
assert first.host_mono_ns <= second.host_mono_ns
|
|
assert src.status.last_seq >= 2
|
|
finally:
|
|
src.close()
|
|
|
|
|
|
def test_background_drop_count_only_increases_for_unread_replacements():
|
|
class StepCap(FakeCap):
|
|
def __init__(self):
|
|
super().__init__([_frame(i) for i in range(4)])
|
|
self.allow = threading.Semaphore(0)
|
|
|
|
def read(self):
|
|
if not self.allow.acquire(timeout=0.1):
|
|
return False, None
|
|
return super().read()
|
|
|
|
cap = StepCap()
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0, reconnect=ReconnectPolicy(enabled=False)),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
src.start_background()
|
|
|
|
def wait_seq(seq):
|
|
deadline = time.perf_counter() + 2.0
|
|
while src.status.last_seq < seq and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
assert src.status.last_seq >= seq
|
|
|
|
try:
|
|
cap.allow.release()
|
|
wait_seq(1)
|
|
assert src.read_latest_stamped().sequence == 1
|
|
cap.allow.release()
|
|
wait_seq(2)
|
|
assert src.status.dropped_frames == 0
|
|
cap.allow.release()
|
|
wait_seq(3)
|
|
assert src.status.dropped_frames == 1
|
|
finally:
|
|
src.close()
|
|
|
|
|
|
def test_auto_reconnect_after_failed_reads():
|
|
first = FakeCap([_frame(1)], fail_after=1)
|
|
second = FakeCap([_frame(9)])
|
|
factory = _factory_from_caps([first, second])
|
|
policy = ReconnectPolicy(
|
|
enabled=True,
|
|
fail_threshold=1,
|
|
initial_backoff_s=0.01,
|
|
max_backoff_s=0.01,
|
|
stale_timeout_s=None,
|
|
max_attempts=3,
|
|
)
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0, reconnect=policy, verbose=False),
|
|
factory=factory,
|
|
)
|
|
src.open()
|
|
ok, _ = src.read_raw()
|
|
assert ok
|
|
ok, _ = src.read_raw()
|
|
assert not ok
|
|
assert src.should_reconnect()
|
|
assert src.reconnect()
|
|
ok, frame = src.read_raw()
|
|
assert ok and int(frame[0, 0, 0]) == 9
|
|
assert src.status.health == CaptureHealth.CONNECTED
|
|
assert src.status.reconnect_attempts >= 1
|
|
|
|
|
|
def test_reconnect_disabled_does_not_reopen():
|
|
cap = FakeCap([_frame(1)], fail_after=1)
|
|
src = CaptureSource(
|
|
CaptureConfig(
|
|
source=0,
|
|
reconnect=ReconnectPolicy(enabled=False, fail_threshold=1),
|
|
),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
src.read_raw()
|
|
src.read_raw()
|
|
assert not src.should_reconnect()
|
|
|
|
|
|
def test_reconnect_attempts_exhausted():
|
|
good = FakeCap([_frame(1)], fail_after=1)
|
|
state = {"n": 0}
|
|
|
|
def factory(_cfg):
|
|
state["n"] += 1
|
|
if state["n"] == 1:
|
|
return good, "FAKE"
|
|
raise RuntimeError("camera missing")
|
|
|
|
policy = ReconnectPolicy(
|
|
enabled=True,
|
|
fail_threshold=1,
|
|
initial_backoff_s=0.01,
|
|
max_backoff_s=0.01,
|
|
stale_timeout_s=None,
|
|
max_attempts=2,
|
|
)
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0, reconnect=policy),
|
|
factory=factory,
|
|
)
|
|
src.open()
|
|
src.read_raw()
|
|
src.read_raw()
|
|
assert src.should_reconnect()
|
|
assert not src.reconnect()
|
|
assert not src.reconnect()
|
|
assert src.status.health == CaptureHealth.FAILED
|
|
|
|
|
|
def test_background_thread_publishes_latest_frame():
|
|
caps = [FakeCap([_frame(i) for i in range(5)])]
|
|
src = open_capture(
|
|
CaptureConfig(
|
|
source=0,
|
|
background=True,
|
|
reconnect=ReconnectPolicy(enabled=False),
|
|
),
|
|
factory=_factory_from_caps(caps),
|
|
start_background=True,
|
|
)
|
|
try:
|
|
deadline = time.perf_counter() + 2.0
|
|
ok = False
|
|
frame = None
|
|
while time.perf_counter() < deadline:
|
|
ok, frame = src.read(wait_s=0.05)
|
|
if ok:
|
|
break
|
|
assert ok and frame is not None
|
|
assert src.running
|
|
finally:
|
|
src.close()
|
|
|
|
|
|
def test_background_reconnect():
|
|
first = FakeCap([_frame(1)], fail_after=2)
|
|
second = FakeCap([_frame(42) for _ in range(20)])
|
|
policy = ReconnectPolicy(
|
|
enabled=True,
|
|
fail_threshold=1,
|
|
initial_backoff_s=0.01,
|
|
max_backoff_s=0.02,
|
|
stale_timeout_s=None,
|
|
)
|
|
src = open_capture(
|
|
CaptureConfig(source=0, reconnect=policy, background=True),
|
|
factory=_factory_from_caps([first, second]),
|
|
start_background=True,
|
|
)
|
|
try:
|
|
saw_reconnect = False
|
|
saw_new = False
|
|
deadline = time.perf_counter() + 3.0
|
|
while time.perf_counter() < deadline:
|
|
if src.status.reconnect_attempts > 0:
|
|
saw_reconnect = True
|
|
ok, frame = src.read(wait_s=0.05)
|
|
if ok and frame is not None and int(frame[0, 0, 0]) == 42:
|
|
saw_new = True
|
|
break
|
|
time.sleep(0.01)
|
|
assert saw_reconnect
|
|
assert saw_new
|
|
finally:
|
|
src.close()
|
|
|
|
|
|
def test_warmup_frames_discards_reads():
|
|
cap = FakeCap([_frame(i) for i in range(10)])
|
|
src = CaptureSource(
|
|
CaptureConfig(source=0),
|
|
factory=lambda cfg: (cap, "FAKE"),
|
|
)
|
|
src.open()
|
|
src.warmup_frames(3)
|
|
ok, frame = src.read_raw()
|
|
assert ok and int(frame[0, 0, 0]) == 3
|
|
|
|
|
|
def test_camera_filter_excludes_laptop_webcams():
|
|
cams = [
|
|
CameraInfo(0, "DSHOW", 0, 640, 480, name="Integrated Webcam"),
|
|
CameraInfo(1, "DSHOW", 0, 1920, 1080, name="Orisys Tactile Cam"),
|
|
CameraInfo(2, "DSHOW", 0, 1280, 720, name="USB Camera"),
|
|
]
|
|
filt = CameraFilter(exclude_laptop_webcams=True)
|
|
kept = [c for c in cams if filt.matches(c)]
|
|
assert [c.index for c in kept] == [1, 2]
|
|
|
|
|
|
def test_camera_filter_name_include_and_vid_pid():
|
|
cams = [
|
|
CameraInfo(0, "V4L2", 0, 640, 480, name="foo", vendor_id=0x1234, product_id=0x5678),
|
|
CameraInfo(1, "V4L2", 0, 640, 480, name="orisys-finger", vendor_id=0xAAAA, product_id=0xBBBB),
|
|
]
|
|
filt = CameraFilter(name_include=[r"orisys"], vid_pid_allowlist=[(0xAAAA, 0xBBBB)])
|
|
kept = [c for c in cams if filt.matches(c)]
|
|
assert len(kept) == 1 and kept[0].index == 1
|
|
|
|
|
|
def test_camera_filter_min_resolution():
|
|
cams = [
|
|
CameraInfo(0, "DSHOW", 0, 320, 240, name="tiny"),
|
|
CameraInfo(1, "DSHOW", 0, 1280, 720, name="hd"),
|
|
]
|
|
filt = CameraFilter(min_width=640, min_height=480)
|
|
kept = [c for c in cams if filt.matches(c)]
|
|
assert [c.index for c in kept] == [1]
|
|
|
|
|
|
def test_pick_default_camera():
|
|
cams = [
|
|
CameraInfo(1, "DSHOW", 0, 640, 480),
|
|
CameraInfo(2, "DSHOW", 0, 640, 480),
|
|
]
|
|
assert pick_default_camera(cams, preferred="2") == "2"
|
|
assert pick_default_camera(cams, preferred=None) == "1"
|
|
assert pick_default_camera([], preferred="0") is None
|
|
|
|
|
|
def test_laptop_patterns_nonempty():
|
|
assert len(DEFAULT_LAPTOP_WEBCAM_PATTERNS) >= 5
|
|
|
|
|
|
def test_camera_info_label_includes_name():
|
|
cam = CameraInfo(3, "DSHOW", 700, 1280, 720, name="Orisys")
|
|
assert "Orisys" in cam.label()
|
|
assert cam.source == "3"
|
|
|
|
|
|
def test_list_cameras_is_callable():
|
|
assert callable(list_cameras)
|
|
|
|
|
|
def test_max_simultaneous_captures_constant():
|
|
assert MAX_SIMULTANEOUS_CAPTURES == 10
|
|
|
|
|
|
def test_sensor_external_mode_still_works():
|
|
from orisys import Sensor
|
|
|
|
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
|
|
try:
|
|
assert sensor._external_only
|
|
assert sensor.capture_status is None
|
|
assert sensor._capture_source is None
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
def test_capture_source_open_applies_stream_props_via_default_factory(monkeypatch):
|
|
cap = FakeCap([_frame(1)], reset_fourcc_on_resize=True)
|
|
|
|
def fake_open(*args, **kwargs):
|
|
_apply_stream_props(
|
|
cap,
|
|
width=kwargs.get("width"),
|
|
height=kwargs.get("height"),
|
|
fps=kwargs.get("fps"),
|
|
fourcc=kwargs.get("fourcc"),
|
|
)
|
|
_warmup_capture(cap)
|
|
return cap, "FAKE"
|
|
|
|
monkeypatch.setattr("orisys.capture.source.open_opencv_capture", fake_open)
|
|
src = CaptureSource(CaptureConfig(source=0, width=1920, height=1080, fourcc="MJPG"))
|
|
src.open()
|
|
assert cap.fourcc == MJPG
|