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:
ykcheungab
2026-10-21 20:52:11 +08:00
parent d31503330d
commit f5738375fd
5 changed files with 1234 additions and 103 deletions
+729
View File
@@ -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()