feat: SensorStream 支持文件完整模式、源时间与 field 输出
T08b 补齐文件/debug 完整处理与背压、源帧号和时间基、同帧 field 快照,输入结束时未完成归零明确报错。独立验收见 docs/workflow/sdk-sensorstream-implementation-plan.zh.md 第 44 轮。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,13 @@ import time
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.capture import CapturedFrame
|
||||
from orisys.force_stream_session import ForceStreamSession
|
||||
from orisys.image_pipeline import ImageMeasurement
|
||||
from orisys.pipeline import StreamPipeline
|
||||
from orisys.sensor import Sensor
|
||||
from orisys.sensor_stream import SensorStream, SensorStreamError
|
||||
from orisys.stream_contract import FrameStamp, MeasurementFrame
|
||||
|
||||
SIZE = 365
|
||||
|
||||
@@ -281,3 +286,727 @@ def test_failed_start_restores_the_borrowed_sensor_session(monkeypatch) -> None:
|
||||
with pytest.raises(RuntimeError, match="pipeline start failed"):
|
||||
stream.start()
|
||||
assert sensor._measurement_session is False, "a failed start must restore the mode"
|
||||
|
||||
|
||||
# ------------------------------------------------- T08b: complete file/debug mode
|
||||
def test_file_mode_processes_every_input_in_source_order() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=2, nominal_fps=30.0)
|
||||
stream.start()
|
||||
try:
|
||||
for index in range(8):
|
||||
assert stream.push_frame(
|
||||
_frame(40.0 + index),
|
||||
source_frame_id=index,
|
||||
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
||||
)
|
||||
stream.end_input()
|
||||
rows = stream.wait_outputs(8, timeout=10.0)
|
||||
assert len(rows) == 8, "complete mode must not drop frames"
|
||||
assert [row.frame.stamp.source_frame_id for row in rows] == list(range(8))
|
||||
assert [row.frame.stamp.frame_id for row in rows] == list(range(8))
|
||||
assert stream.overwritten_frames == 0
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_file_mode_backpressure_is_bounded_and_observable() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=2, nominal_fps=30.0)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
original = stream.pipeline._on_frame
|
||||
|
||||
def gated(snapshot) -> None:
|
||||
entered.set()
|
||||
assert release.wait(timeout=5.0)
|
||||
original(snapshot)
|
||||
|
||||
stream.pipeline._on_frame = gated
|
||||
stream.start()
|
||||
pushed: list[int] = []
|
||||
|
||||
def pusher() -> None:
|
||||
for index in range(30):
|
||||
stream.push_frame(
|
||||
_frame(40.0 + index),
|
||||
source_frame_id=index,
|
||||
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
||||
)
|
||||
pushed.append(index)
|
||||
|
||||
worker = threading.Thread(target=pusher)
|
||||
worker.start()
|
||||
try:
|
||||
assert entered.wait(timeout=5.0), "the HHD stage never reached the gate"
|
||||
time.sleep(0.3)
|
||||
assert stream.input_queue_maxsize == 2
|
||||
assert stream.input_queue_depth <= 2
|
||||
assert stream.backpressure_waits > 0, (
|
||||
"the producer must wait on the bounded queue"
|
||||
)
|
||||
assert worker.is_alive(), "a full bounded queue must make the producer wait"
|
||||
release.set()
|
||||
worker.join(timeout=10.0)
|
||||
assert not worker.is_alive()
|
||||
assert len(pushed) == 30
|
||||
stream.end_input()
|
||||
rows = stream.wait_outputs(30, timeout=15.0)
|
||||
assert len(rows) == 30
|
||||
assert [row.frame.stamp.source_frame_id for row in rows] == list(range(30))
|
||||
finally:
|
||||
release.set()
|
||||
stream.stop()
|
||||
|
||||
|
||||
# ------------------------------------------------------- T08b: time provenance
|
||||
def test_time_bases_fake_sources_and_same_pts_frames() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=10.0)
|
||||
stream.start()
|
||||
try:
|
||||
frames = [
|
||||
dict(
|
||||
source_pts=123456, capture_time_ns=1_000_000_000, time_basis="video_pts"
|
||||
),
|
||||
dict(
|
||||
source_pts=123456, capture_time_ns=1_000_000_000, time_basis="video_pts"
|
||||
),
|
||||
dict(capture_time_ns=1_100_000_000, time_basis="recorded_signal"),
|
||||
dict(time_basis="estimated_source", media_time_ms=12.5),
|
||||
dict(time_basis=None),
|
||||
]
|
||||
source_ids = [0, 1, 2, 12, 13]
|
||||
for index, (source_id, timing) in enumerate(zip(source_ids, frames)):
|
||||
stream.push_frame(_frame(40.0 + index), source_frame_id=source_id, **timing)
|
||||
stream.end_input()
|
||||
rows = stream.wait_outputs(len(frames), timeout=10.0)
|
||||
assert len(rows) == len(frames)
|
||||
stamps = [row.frame.stamp for row in rows]
|
||||
assert [stamp.capture_time_basis for stamp in stamps] == [
|
||||
"video_pts",
|
||||
"video_pts",
|
||||
"recorded_signal",
|
||||
"estimated_source",
|
||||
"estimated_source",
|
||||
]
|
||||
# Same PTS, different source frames: two frames, not a duplicate.
|
||||
assert stamps[0].source_pts == 123456
|
||||
assert stamps[1].source_pts == 123456
|
||||
assert stamps[0].source_frame_id != stamps[1].source_frame_id
|
||||
# Estimated source time is frame id / nominal fps; POS_MSEC is diagnostics only.
|
||||
assert stamps[3].capture_time_ns == round(12 / 10.0 * 1e9)
|
||||
assert stamps[4].capture_time_ns == round(13 / 10.0 * 1e9)
|
||||
diag3 = dict(rows[3].diagnostics)
|
||||
diag4 = dict(rows[4].diagnostics)
|
||||
assert diag3["pos_msec"] == 12.5
|
||||
assert "pos_msec" not in diag4
|
||||
assert diag3["source_time_estimated"] == "true"
|
||||
assert dict(rows[0].diagnostics)["source_time_estimated"] == "false"
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
def _fake_builtin_sensor(frames, *, media_times=None):
|
||||
sensor = _sensor()
|
||||
original = sensor.get_img
|
||||
index = {"next": 0}
|
||||
captures = []
|
||||
|
||||
def fake_get_img(
|
||||
frame=None, already_processed=False, valid_mask=None, already_gray=False
|
||||
):
|
||||
if index["next"] >= len(frames):
|
||||
return None
|
||||
position = index["next"]
|
||||
index["next"] += 1
|
||||
captured = CapturedFrame(
|
||||
frames[position],
|
||||
position + 1,
|
||||
1_000_000_000 + position * 33_000_000,
|
||||
2_000_000_000 + position * 33_000_000,
|
||||
None if media_times is None else media_times[position],
|
||||
)
|
||||
captures.append(captured)
|
||||
sensor._last_captured_frame = captured
|
||||
return original(
|
||||
frame=frames[position], already_processed=True, already_gray=True
|
||||
)
|
||||
|
||||
sensor.get_img = fake_get_img
|
||||
return sensor, captures
|
||||
|
||||
|
||||
def test_builtin_capture_uses_captured_frame_host_clocks_and_sequence() -> None:
|
||||
frames = [_frame(40.0 + index) for index in range(5)]
|
||||
sensor, captures = _fake_builtin_sensor(frames)
|
||||
stream = _stream(sensor, realtime=True, external_input=False)
|
||||
stream.start()
|
||||
try:
|
||||
rows = stream.wait_outputs(5, timeout=10.0)
|
||||
assert len(rows) == 5
|
||||
for row, captured in zip(rows, captures):
|
||||
stamp = row.frame.stamp
|
||||
assert stamp.capture_time_basis == "capture_monotonic"
|
||||
assert stamp.capture_time_ns == captured.host_mono_ns
|
||||
assert stamp.source_frame_id == captured.sequence
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_builtin_file_mode_estimates_time_and_reports_pos_msec() -> None:
|
||||
frames = [_frame(40.0 + index) for index in range(5)]
|
||||
media = [0.0, 33.0, 66.0, 99.0, 132.0]
|
||||
sensor, captures = _fake_builtin_sensor(frames, media_times=media)
|
||||
stream = _stream(
|
||||
sensor,
|
||||
realtime=False,
|
||||
nominal_fps=30.0,
|
||||
external_input=False,
|
||||
)
|
||||
stream.start()
|
||||
try:
|
||||
rows = stream.wait_outputs(5, timeout=10.0)
|
||||
assert len(rows) == 5
|
||||
for row, captured in zip(rows, captures):
|
||||
stamp = row.frame.stamp
|
||||
assert stamp.capture_time_basis == "estimated_source"
|
||||
assert stamp.source_pts is None
|
||||
assert stamp.source_frame_id == captured.sequence
|
||||
assert stamp.capture_time_ns == round(captured.sequence / 30.0 * 1e9)
|
||||
assert dict(row.diagnostics)["pos_msec"] == captured.media_time_ms
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
# ------------------------------------------------- T08b: optional field include
|
||||
def _field_equal(left, right) -> None:
|
||||
assert left is not None and right is not None
|
||||
assert set(left) == set(right)
|
||||
for key in left:
|
||||
a = left[key]
|
||||
b = right[key]
|
||||
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
|
||||
assert np.array_equal(np.asarray(a), np.asarray(b), equal_nan=True), key
|
||||
else:
|
||||
assert a == b, key
|
||||
|
||||
|
||||
def _run_pipeline_capture(include):
|
||||
sensor = _sensor()
|
||||
frames = [
|
||||
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 3)[None, :], (SIZE, 1))
|
||||
for index in range(6)
|
||||
]
|
||||
index = {"next": 0}
|
||||
records = []
|
||||
|
||||
def produce():
|
||||
if index["next"] >= len(frames):
|
||||
return None
|
||||
position = index["next"]
|
||||
index["next"] += 1
|
||||
return sensor.get_img(
|
||||
frame=frames[position], already_processed=True, already_gray=True
|
||||
)
|
||||
|
||||
def on_frame(snapshot) -> None:
|
||||
expected = sensor.current_field()
|
||||
field_copy = snapshot.field
|
||||
records.append((snapshot, field_copy, expected))
|
||||
|
||||
pipeline = StreamPipeline(sensor, produce, include=include, on_frame=on_frame)
|
||||
pipeline.start()
|
||||
deadline = time.perf_counter() + 10.0
|
||||
while len(records) < len(frames) and time.perf_counter() < deadline:
|
||||
time.sleep(0.01)
|
||||
pipeline.stop(timeout=5.0)
|
||||
return records
|
||||
|
||||
|
||||
def test_pipeline_field_include_is_opt_in_and_same_frame() -> None:
|
||||
default_records = _run_pipeline_capture(())
|
||||
assert len(default_records) == 6
|
||||
assert all(snapshot.field is None for snapshot, _, _ in default_records)
|
||||
|
||||
field_records = _run_pipeline_capture(("field",))
|
||||
assert len(field_records) == 6
|
||||
first_copy = None
|
||||
for snapshot, recorded_field, expected in field_records:
|
||||
assert snapshot.field is not None
|
||||
_field_equal(snapshot.field, expected)
|
||||
if first_copy is None:
|
||||
first_copy = {
|
||||
key: value.copy() if isinstance(value, np.ndarray) else value
|
||||
for key, value in snapshot.field.items()
|
||||
}
|
||||
# Later frames must not rewrite the field payload published with frame 0.
|
||||
_field_equal(field_records[0][1], first_copy)
|
||||
|
||||
|
||||
# ------------------------------------------------- T08b: end-to-end and in-flight
|
||||
def _feed_complete(stream, frames, *, start=0, sleep_s=0.002, stop=None):
|
||||
def feeder() -> None:
|
||||
for source_id in range(start, len(frames)):
|
||||
if stop is not None and stop.is_set():
|
||||
return
|
||||
stream.push_frame(
|
||||
frames[source_id],
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
||||
)
|
||||
time.sleep(sleep_s)
|
||||
|
||||
thread = threading.Thread(target=feeder)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 20.0) -> None:
|
||||
"""Wait for a state condition; never used to tune frame ordering by delay."""
|
||||
deadline = time.perf_counter() + timeout
|
||||
while time.perf_counter() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError("condition was not reached before the timeout")
|
||||
|
||||
|
||||
def test_end_to_end_matches_manual_sensor_image_pipeline_session() -> None:
|
||||
count = 90
|
||||
initial = 10
|
||||
frames = [
|
||||
np.tile(
|
||||
((np.arange(SIZE, dtype=np.uint16) + index * 3) % 256).astype(np.uint8)[
|
||||
None, :
|
||||
],
|
||||
(SIZE, 1),
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
stop = threading.Event()
|
||||
pressed_initial = threading.Event()
|
||||
|
||||
def feeder() -> None:
|
||||
for source_id in range(count):
|
||||
if stop.is_set():
|
||||
return
|
||||
stream.push_frame(
|
||||
frames[source_id],
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
||||
)
|
||||
if source_id + 1 == initial:
|
||||
pressed_initial.set()
|
||||
pressed_initial.set()
|
||||
|
||||
feeder_thread = threading.Thread(target=feeder)
|
||||
feeder_thread.start()
|
||||
try:
|
||||
assert pressed_initial.wait(timeout=20.0)
|
||||
# The reset is requested only after every initial frame was published, so
|
||||
# the number of new-version frames left for the baseline is deterministic.
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
||||
zero = stream.reset_and_zero(reference_id="eq", timeout=20.0)
|
||||
assert zero.phase == "ready"
|
||||
feeder_thread.join(timeout=20.0)
|
||||
assert not feeder_thread.is_alive()
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= count)
|
||||
rows = stream.drain_outputs()
|
||||
finally:
|
||||
stop.set()
|
||||
stream.stop()
|
||||
assert len(rows) == count
|
||||
|
||||
first_version = next(
|
||||
row.reference_generation for row in rows if row.reference_generation > 0
|
||||
)
|
||||
first_source_id = next(
|
||||
row.frame.stamp.source_frame_id
|
||||
for row in rows
|
||||
if row.reference_generation == first_version
|
||||
)
|
||||
|
||||
manual = _sensor()
|
||||
session = ForceStreamSession(
|
||||
sensor_family="box",
|
||||
calibration=manual.calibration,
|
||||
device_id=None,
|
||||
)
|
||||
session.request_zero(
|
||||
reference_id="eq",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=first_version,
|
||||
)
|
||||
manual_outputs = {}
|
||||
for source_id in range(first_source_id, count):
|
||||
manual.get_img(
|
||||
frame=frames[source_id], already_processed=True, already_gray=True
|
||||
)
|
||||
manual.compute_flow()
|
||||
manual.compute_hhd()
|
||||
stamp = FrameStamp(
|
||||
sensor_id="BOX-1",
|
||||
session_id="run-1",
|
||||
frame_id=source_id,
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
||||
clock_id="host",
|
||||
capture_time_basis="capture_monotonic",
|
||||
)
|
||||
measurement = MeasurementFrame(
|
||||
stamp=stamp, source_dt_s=None, reference_generation=first_version
|
||||
)
|
||||
manual_outputs[source_id] = session.process(
|
||||
ImageMeasurement(
|
||||
frame=measurement,
|
||||
force_raw=(
|
||||
float(manual._force_raw[0]),
|
||||
float(manual._force_raw[1]),
|
||||
float(manual._force_raw[2]),
|
||||
),
|
||||
torque=float(manual.ftor),
|
||||
flow=None,
|
||||
)
|
||||
)
|
||||
compared = 0
|
||||
for row in rows:
|
||||
if row.reference_generation != first_version or row.zero.phase != "ready":
|
||||
continue
|
||||
expected = manual_outputs.get(row.frame.stamp.source_frame_id)
|
||||
if expected is None:
|
||||
continue
|
||||
compared += 1
|
||||
assert np.array_equal(
|
||||
np.asarray(row.raw), np.asarray(expected.raw), equal_nan=True
|
||||
)
|
||||
assert np.array_equal(
|
||||
np.asarray(row.newton), np.asarray(expected.newton), equal_nan=True
|
||||
)
|
||||
assert row.raw_status == expected.raw_status
|
||||
assert row.newton_status == expected.newton_status
|
||||
assert compared >= 5, "facade and manual chain were not compared frame by frame"
|
||||
|
||||
|
||||
def test_inflight_old_version_frames_do_not_enter_the_new_baseline() -> None:
|
||||
initial = 5
|
||||
count = 50
|
||||
frames = [
|
||||
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1))
|
||||
for index in range(count)
|
||||
]
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
stop = threading.Event()
|
||||
pressed_initial = threading.Event()
|
||||
|
||||
def feeder() -> None:
|
||||
for source_id in range(count):
|
||||
if stop.is_set():
|
||||
return
|
||||
stream.push_frame(
|
||||
frames[source_id],
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
||||
)
|
||||
if source_id + 1 == initial:
|
||||
pressed_initial.set()
|
||||
|
||||
feeder_thread = threading.Thread(target=feeder)
|
||||
feeder_thread.start()
|
||||
try:
|
||||
assert pressed_initial.wait(timeout=20.0)
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
||||
zero = stream.reset_and_zero(reference_id="v1", timeout=20.0)
|
||||
feeder_thread.join(timeout=20.0)
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= count)
|
||||
rows = stream.drain_outputs()
|
||||
finally:
|
||||
stop.set()
|
||||
stream.stop()
|
||||
assert zero.phase == "ready"
|
||||
assert zero.collected_frames == 30
|
||||
old_rows = [row for row in rows if row.reference_generation == 0]
|
||||
version_one = [row for row in rows if row.reference_generation == 1]
|
||||
assert old_rows, "the test needs in-flight old-version frames"
|
||||
assert version_one, "no new-version frame was processed"
|
||||
assert all(row.zero.phase != "ready" for row in old_rows)
|
||||
assert len(version_one) >= 31
|
||||
assert all(row.zero.phase != "ready" for row in version_one[:30])
|
||||
assert version_one[30].zero.phase == "ready"
|
||||
|
||||
|
||||
def test_stage_error_is_visible_to_the_caller(monkeypatch) -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor)
|
||||
stream.start()
|
||||
try:
|
||||
|
||||
def fail(_measurement):
|
||||
raise RuntimeError("force stage failure")
|
||||
|
||||
monkeypatch.setattr(stream.session, "process", fail)
|
||||
stream.push_frame(_frame(), source_frame_id=0)
|
||||
time.sleep(0.2)
|
||||
with pytest.raises(SensorStreamError, match="force stage failure"):
|
||||
stream.stop()
|
||||
finally:
|
||||
if stream.running:
|
||||
stream.stop()
|
||||
|
||||
|
||||
# --------------------------------------------- T08b: duplicate/late and diagnostics
|
||||
def test_duplicate_and_late_inputs_are_counted_not_fed() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
try:
|
||||
assert stream.push_frame(
|
||||
_frame(40.0), source_frame_id=0, capture_time_ns=1_000_000_000
|
||||
)
|
||||
assert not stream.push_frame(
|
||||
_frame(41.0), source_frame_id=0, capture_time_ns=1_100_000_000
|
||||
)
|
||||
assert stream.push_frame(
|
||||
_frame(42.0), source_frame_id=5, capture_time_ns=1_200_000_000
|
||||
)
|
||||
assert not stream.push_frame(
|
||||
_frame(43.0), source_frame_id=3, capture_time_ns=1_300_000_000
|
||||
)
|
||||
assert stream.duplicate_inputs == 1
|
||||
assert stream.late_inputs == 1
|
||||
stream.end_input()
|
||||
rows = stream.wait_outputs(2, timeout=10.0)
|
||||
assert [row.frame.stamp.source_frame_id for row in rows] == [0, 5]
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_source_dt_and_long_gap_diagnostics() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0, long_gap_s=0.5)
|
||||
stream.start()
|
||||
try:
|
||||
times = [1_000_000_000, 2_000_000_000, 2_033_000_000]
|
||||
for index, when in enumerate(times):
|
||||
stream.push_frame(
|
||||
_frame(40.0 + index), source_frame_id=index, capture_time_ns=when
|
||||
)
|
||||
stream.end_input()
|
||||
rows = stream.wait_outputs(3, timeout=10.0)
|
||||
assert dict(rows[0].diagnostics)["long_gap"] == "false"
|
||||
assert dict(rows[1].diagnostics)["long_gap"] == "true"
|
||||
assert dict(rows[1].diagnostics)["source_dt_s"] == 1.0
|
||||
assert dict(rows[2].diagnostics)["long_gap"] == "false"
|
||||
assert dict(rows[2].diagnostics)["source_dt_s"] == 0.033
|
||||
assert stream.diagnostics()["last_long_gap"] is False
|
||||
finally:
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_duplicate_after_ready_does_not_invalidate_the_zero() -> None:
|
||||
initial = 40
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
stop = threading.Event()
|
||||
pressed_initial = threading.Event()
|
||||
|
||||
def frame(source_id: int) -> np.ndarray:
|
||||
return np.tile(
|
||||
(np.arange(SIZE, dtype=np.uint8) + source_id * 2)[None, :], (SIZE, 1)
|
||||
)
|
||||
|
||||
def feeder() -> None:
|
||||
source_id = 0
|
||||
while not stop.is_set():
|
||||
stream.push_frame(
|
||||
frame(source_id),
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
||||
)
|
||||
source_id += 1
|
||||
if source_id >= initial:
|
||||
pressed_initial.set()
|
||||
|
||||
feeder_thread = threading.Thread(target=feeder)
|
||||
feeder_thread.start()
|
||||
try:
|
||||
assert pressed_initial.wait(timeout=20.0)
|
||||
# Every initial source id is published before the reset request, exactly
|
||||
# the condition the reviewer's probe used to force the old flake.
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
||||
zero = stream.reset_and_zero(reference_id="dup-zero", timeout=20.0)
|
||||
stop.set()
|
||||
feeder_thread.join(timeout=10.0)
|
||||
assert not feeder_thread.is_alive()
|
||||
assert (
|
||||
stream.push_frame(
|
||||
frame(0), source_frame_id=0, capture_time_ns=1_000_000_000
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert stream.duplicate_inputs >= 1
|
||||
assert stream.session.zero.phase == "ready"
|
||||
assert stream.session.zero.reference_id == zero.reference_id
|
||||
finally:
|
||||
stop.set()
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_input_end_fails_an_incomplete_reset_and_zero() -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
for source_id in range(5):
|
||||
stream.push_frame(
|
||||
_frame(40.0 + source_id),
|
||||
source_frame_id=source_id,
|
||||
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
||||
)
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
||||
outcome: dict[str, BaseException] = {}
|
||||
|
||||
def run_zero() -> None:
|
||||
try:
|
||||
stream.reset_and_zero(reference_id="short-input", timeout=10.0)
|
||||
except BaseException as exc: # asserted below
|
||||
outcome["error"] = exc
|
||||
|
||||
worker = threading.Thread(target=run_zero)
|
||||
worker.start()
|
||||
try:
|
||||
_wait_until(
|
||||
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
||||
)
|
||||
stream.end_input()
|
||||
worker.join(timeout=5.0)
|
||||
assert not worker.is_alive(), "input end must complete the zero command"
|
||||
error = outcome.get("error")
|
||||
assert isinstance(error, SensorStreamError)
|
||||
assert "input ended" in str(error)
|
||||
finally:
|
||||
if worker.is_alive():
|
||||
stream.stop()
|
||||
else:
|
||||
stream.stop()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [31, 33, 36])
|
||||
def test_input_end_after_enough_frames_still_completes_zero(extra: int) -> None:
|
||||
sensor = _sensor()
|
||||
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
||||
stream.start()
|
||||
|
||||
def frame(index: int) -> np.ndarray:
|
||||
return np.tile(
|
||||
(np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1)
|
||||
)
|
||||
|
||||
for index in range(5):
|
||||
stream.push_frame(
|
||||
frame(index),
|
||||
source_frame_id=index,
|
||||
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
||||
)
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
||||
outcome: dict[str, object] = {}
|
||||
|
||||
def run_zero() -> None:
|
||||
try:
|
||||
outcome["progress"] = stream.reset_and_zero(
|
||||
reference_id="tight", timeout=20.0
|
||||
)
|
||||
except BaseException as exc: # asserted below
|
||||
outcome["error"] = exc
|
||||
|
||||
worker = threading.Thread(target=run_zero)
|
||||
worker.start()
|
||||
try:
|
||||
_wait_until(
|
||||
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
||||
)
|
||||
for index in range(5, 5 + extra):
|
||||
stream.push_frame(
|
||||
frame(index),
|
||||
source_frame_id=index,
|
||||
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
||||
)
|
||||
stream.end_input()
|
||||
worker.join(timeout=25.0)
|
||||
assert not worker.is_alive()
|
||||
assert "error" not in outcome, outcome.get("error")
|
||||
progress = outcome.get("progress")
|
||||
assert getattr(progress, "phase", None) == "ready"
|
||||
assert stream.session.zero.phase == "ready"
|
||||
assert stream.session.zero.reference_id == "tight"
|
||||
finally:
|
||||
if worker.is_alive():
|
||||
stream.end_input()
|
||||
stream.stop()
|
||||
|
||||
|
||||
def test_builtin_input_end_after_enough_frames_completes_zero() -> None:
|
||||
prepared = [
|
||||
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1))
|
||||
for index in range(5 + 31)
|
||||
]
|
||||
sensor = _sensor()
|
||||
original = sensor.get_img
|
||||
state = {"next": 0}
|
||||
gate = threading.Event()
|
||||
|
||||
def fake_get_img(
|
||||
frame=None, already_processed=False, valid_mask=None, already_gray=False
|
||||
):
|
||||
position = state["next"]
|
||||
if position == 5:
|
||||
assert gate.wait(timeout=20.0)
|
||||
if position >= len(prepared):
|
||||
return None
|
||||
state["next"] += 1
|
||||
captured = CapturedFrame(
|
||||
prepared[position],
|
||||
position + 1,
|
||||
1_000_000_000 + position * 33_000_000,
|
||||
2_000_000_000 + position * 33_000_000,
|
||||
None,
|
||||
)
|
||||
sensor._last_captured_frame = captured
|
||||
return original(
|
||||
frame=prepared[position], already_processed=True, already_gray=True
|
||||
)
|
||||
|
||||
sensor.get_img = fake_get_img
|
||||
stream = _stream(sensor, realtime=False, nominal_fps=30.0, external_input=False)
|
||||
stream.start()
|
||||
outcome: dict[str, object] = {}
|
||||
|
||||
def run_zero() -> None:
|
||||
try:
|
||||
outcome["progress"] = stream.reset_and_zero(
|
||||
reference_id="builtin-tight", timeout=20.0
|
||||
)
|
||||
except BaseException as exc: # asserted below
|
||||
outcome["error"] = exc
|
||||
|
||||
try:
|
||||
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
||||
worker = threading.Thread(target=run_zero)
|
||||
worker.start()
|
||||
_wait_until(
|
||||
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
||||
)
|
||||
gate.set()
|
||||
worker.join(timeout=20.0)
|
||||
assert not worker.is_alive()
|
||||
assert "error" not in outcome, outcome.get("error")
|
||||
assert getattr(outcome.get("progress"), "phase", None) == "ready"
|
||||
assert stream.session.zero.phase == "ready"
|
||||
finally:
|
||||
gate.set()
|
||||
stream.stop()
|
||||
|
||||
Reference in New Issue
Block a user