fix: 按复位请求目标确认参考版本并隔离复位状态

防止在途旧帧提前触发归零,并保留旧 reset 用法。SDK 101 项针对性回归与 lint 通过。
This commit is contained in:
ykcheungab
2026-10-20 20:33:23 +08:00
parent ee830d8505
commit c256870ee2
8 changed files with 477 additions and 60 deletions
+10
View File
@@ -65,6 +65,16 @@ session = ForceSession(
基线未完成、无效帧、窗口不足或已取消时 N 为 `NaN`、状态显式;窗口不足不越界补帧。 基线未完成、无效帧、窗口不足或已取消时 N 为 `NaN`、状态显式;窗口不足不越界补帧。
录像重算用 `replay_window=(start, end)` 在声明区间执行同一操作。 录像重算用 `replay_window=(start, end)` 在声明区间执行同一操作。
三级流水线已有同帧测量时,用 `process_measurement(row, stamp, tracker_reset_seq=...)`
传入快照自带的参考版本,不读流水线的实时计数。外部归零先调用
`target = pipeline.request_reset()`,再调用
`request_zero_external_reset(reference_id=..., declared_unloaded=True, target_reference_version=target)`。
`target` 为正整数,帧版本达到它才开始采样;较早复位后已在途的帧不能确认本次请求。
未被 flow 领取的请求合并为同一目标,领取后的新请求另分配目标;共用目标的复位与归零只确认一次。
该接缝尚未发布,本轮将原 `tracker_reset_seq` 请求参数改为明确的目标版本参数;
逐帧输入仍用 `tracker_reset_seq`。已发布 `Sensor.reset()` / `StreamPipeline.reset()` 用法不变。
实现见 `pipeline.py`、`force_session.py`,回归见 `tests/test_pipeline.py`、`tests/test_force_session.py`。
## 后处理与有效设置 ## 后处理与有效设置
`OutputSettings(sample_rate_hz, median_window=1, lowpass_hz=0.0, lowpass_order=2, `OutputSettings(sample_rate_hz, median_window=1, lowpass_hz=0.0, lowpass_order=2,
+13 -3
View File
@@ -165,6 +165,16 @@ compute the same numbers.
## Resetting the reference ## Resetting the reference
Both backends process externally supplied frames through the same profile. After a gap in the Both backends process externally supplied frames through the same profile. After a gap in the
input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stage that input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stages that
owns the tracker, so the reference frame is rebased on the frame about to be computed rather than own the sensor state, so the reference frame is rebased on the frame about to be computed rather
on a buffer the pipeline is still reading. than on a buffer the pipeline is still reading: the flow stage resets the image/tracker state and
bumps the reference version, and the HHD stage resets the force/readout state (filters, contact
flags, calibration session) on the first frame carrying the new version, before decomposing it.
`FrameSnapshot.tracker_reset_seq` is the version that frame was measured with.
When driving `StreamPipeline` directly, call `target = pipeline.request_reset()` to obtain the
future reference version reserved by that request. Confirm it only when a snapshot carries
`tracker_reset_seq >= target`; the current output frame can trail an earlier reset and cannot
identify a new request. Requests share a target until flow claims it; a later request gets a new
target even while the earlier reset is still being applied. The existing `reset()` returns `None`.
Regression coverage lives in `tests/test_pipeline.py` (request coalescing and reset-in-progress).
+25 -2
View File
@@ -374,14 +374,37 @@ class ForceReadout:
) )
return filter_changed return filter_changed
def reset(self) -> None: def reset_flow_ema(self) -> None:
"""Drop the optical-flow EMA history (image side).
The EMA smooths the flow field before HHD, so it belongs to the stage
that computes flow. A streaming pipeline resets this half on its flow
thread, separately from the readout history the HHD stage owns.
"""
self._ema_flow = None
def reset_readout(self) -> None:
"""Drop the force-filter history and the last reading (force side).
The median/low-pass memory and the last reading belong to the stage that
filters each measurement; this half does not touch the flow EMA.
"""
self._force_filter.reset() self._force_filter.reset()
self._force_median.reset() self._force_median.reset()
self._ema_flow = None
self.filtered_raw = None self.filtered_raw = None
self.raw_unfiltered = None self.raw_unfiltered = None
self.unfiltered_newton = None self.unfiltered_newton = None
def reset(self) -> None:
"""Reset both halves at once (single-threaded or frame-boundary use).
A streaming pipeline applies :meth:`reset_flow_ema` on the flow thread
and :meth:`reset_readout` on the HHD thread; calling this keeps the
historical behaviour for callers that own the whole sensor.
"""
self.reset_readout()
self.reset_flow_ema()
def state(self, *, dis_mode=None) -> dict: def state(self, *, dis_mode=None) -> dict:
"""Effective filter chain, for records and audits.""" """Effective filter chain, for records and audits."""
return { return {
+13 -14
View File
@@ -359,9 +359,8 @@ class ForceSession:
) )
self._pending_reset: dict | None = None self._pending_reset: dict | None = None
#: A queued zero whose tracker reset is performed by an external stage #: A queued zero whose tracker reset is performed by an external stage
#: (the App delegates it to ``StreamPipeline.reset``). The value is the #: (the App delegates it to ``StreamPipeline.request_reset``). The value
#: pipeline reset serial current when the request was made; the first #: is the reserved target version, not an observed frame/live counter.
#: processed frame with a newer serial begins the baseline window.
self._pending_external_reset: int | None = None self._pending_external_reset: int | None = None
self._zero_samples: list[tuple[float, float, float]] = [] self._zero_samples: list[tuple[float, float, float]] = []
self._replay_window: tuple[int, int] | None = None self._replay_window: tuple[int, int] | None = None
@@ -757,16 +756,16 @@ class ForceSession:
reference_id: str, reference_id: str,
declared_unloaded: bool, declared_unloaded: bool,
replay_window: tuple[int, int] | None = None, replay_window: tuple[int, int] | None = None,
tracker_reset_seq: int, target_reference_version: int,
) -> ZeroProgress: ) -> ZeroProgress:
"""Request the one unloaded zero when another stage performs the tracker reset. """Request the one unloaded zero when another stage performs the tracker reset.
Additive variant of :meth:`request_zero` for a consumer that drives its Additive variant of :meth:`request_zero` for a consumer that drives its
measurement through a concurrent pipeline: the caller queues the tracker measurement through a concurrent pipeline: the caller queues the tracker
reset on the pipeline's flow stage (``StreamPipeline.reset``) and passes reset with ``StreamPipeline.request_reset`` and passes its returned target
the pipeline's reset serial at request time. Frames published with that version. Older versions cannot start the 30-frame window, including frames
same serial were measured before the reset and are never baseline frames; from an earlier reset already in flight when this request was queued.
the first processed frame with a newer serial begins the 30-frame window. Collection begins at the first frame whose version reaches the target.
Generation, the declared-unloaded requirement and the filter reset are Generation, the declared-unloaded requirement and the filter reset are
identical to :meth:`request_zero`. identical to :meth:`request_zero`.
""" """
@@ -776,13 +775,13 @@ class ForceSession:
) )
if not isinstance(reference_id, str) or not reference_id.strip(): if not isinstance(reference_id, str) or not reference_id.strip():
raise ValueError("reference_id must be a non-empty string") raise ValueError("reference_id must be a non-empty string")
if type(tracker_reset_seq) is not int or tracker_reset_seq < 0: if type(target_reference_version) is not int or target_reference_version <= 0:
raise ValueError("tracker_reset_seq must be a non-negative integer") raise ValueError("target_reference_version must be a positive integer")
window = _coerce_replay_window(replay_window) window = _coerce_replay_window(replay_window)
self._generation += 1 self._generation += 1
self._clear_frame_caches() self._clear_frame_caches()
self._pending_reset = None self._pending_reset = None
self._pending_external_reset = int(tracker_reset_seq) self._pending_external_reset = target_reference_version
self._zero_state = "resetting" self._zero_state = "resetting"
self._zero_samples = [] self._zero_samples = []
self._replay_window = window self._replay_window = window
@@ -1277,8 +1276,8 @@ class ForceSession:
``tracker_reset_seq`` is the publishing pipeline's reset serial ``tracker_reset_seq`` is the publishing pipeline's reset serial
(:attr:`~orisys.pipeline.FrameSnapshot.tracker_reset_seq`). It is only (:attr:`~orisys.pipeline.FrameSnapshot.tracker_reset_seq`). It is only
consulted by a zero requested through consulted by a zero requested through
:meth:`request_zero_external_reset`: frames with the recorded serial (or an :meth:`request_zero_external_reset`: versions below its reserved target
older one) were measured before the reset and are not baseline frames. were measured before that reset and are not baseline frames.
""" """
if not isinstance(row, FrameMeasurement): if not isinstance(row, FrameMeasurement):
raise TypeError("row must be a FrameMeasurement") raise TypeError("row must be a FrameMeasurement")
@@ -1304,7 +1303,7 @@ class ForceSession:
raise ValueError( raise ValueError(
"tracker_reset_seq is required while an external-reset zero is queued" "tracker_reset_seq is required while an external-reset zero is queued"
) )
if int(tracker_reset_seq) <= external: if int(tracker_reset_seq) < external:
# Measured before the externally queued tracker reset: not a # Measured before the externally queued tracker reset: not a
# baseline frame, and the session-owned reset path is not involved. # baseline frame, and the session-owned reset path is not involved.
normal_reset_after = False normal_reset_after = False
+86 -34
View File
@@ -157,11 +157,13 @@ class FrameSnapshot:
force_units: tuple[str, str, str] | None = None force_units: tuple[str, str, str] | None = None
calibration_status: dict[str, str] | None = None calibration_status: dict[str, str] | None = None
contact_found: bool | None = None contact_found: bool | None = None
#: Monotonic count of tracker resets the flow stage has applied. A consumer #: Reference version this frame was measured with: the count of image-side
#: that delegates a frame-boundary reset to :meth:`StreamPipeline.reset` can #: tracker resets the flow stage had applied when it computed this frame's
#: use this to tell frames measured before that reset from frames measured #: flow. A consumer that delegates a frame-boundary reset to
#: after it (the reset is queued, not synchronous). Additive field: producers #: :meth:`StreamPipeline.request_reset` compares it with the returned target to
#: that do not use it leave it at 0. #: tell frames measured before that reset from frames measured after it (the
#: reset is queued, not synchronous). Additive field: producers that do not
#: use it leave it at 0.
tracker_reset_seq: int = 0 tracker_reset_seq: int = 0
@@ -234,13 +236,19 @@ class StreamPipeline:
self._threads: list[threading.Thread] = [] self._threads: list[threading.Thread] = []
self._error: BaseException | None = None self._error: BaseException | None = None
self._running = False self._running = False
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state #: Request allocation and flow-stage claiming share a lock. A request
#: never races the stage that owns that state. #: already claimed by flow cannot acknowledge another request made later.
self._reset_pending = threading.Event() self._reset_lock = threading.Lock()
#: How many tracker resets the flow stage has actually applied. Published on self._requested_reset_version = 0
#: every snapshot so a consumer can tell a frame measured before a queued self._pending_reset_version: int | None = None
#: reset from one measured after it. #: How many image-side resets the flow stage has actually applied. A frame
#: carries the value in effect when its flow was computed (through the flow
#: queue); consumers read :attr:`FrameSnapshot.tracker_reset_seq`, not this.
self._tracker_reset_seq = 0 self._tracker_reset_seq = 0
#: The reference version whose force-side reset the HHD stage has applied.
#: The flow stage bumps the version; the HHD stage owns the force state and
#: applies its half on the first frame that carries a newer version.
self._force_reset_applied_version = 0
#: Pause state. ``_pause_level`` is what a pause would hold when armed; #: Pause state. ``_pause_level`` is what a pause would hold when armed;
#: ``_currently_capturing`` records whether the producer is still #: ``_currently_capturing`` records whether the producer is still
#: reading, which decides whether a resume has to rebase the tracker. #: reading, which decides whether a resume has to rebase the tracker.
@@ -330,24 +338,46 @@ class StreamPipeline:
def tracker_reset_seq(self) -> int: def tracker_reset_seq(self) -> int:
"""Tracker resets the flow stage has applied so far (queued, not immediate). """Tracker resets the flow stage has applied so far (queued, not immediate).
A consumer that asked for a reset through :meth:`reset` uses this (and This is the live diagnostic count, not a frame's identity: a reset queued
:attr:`FrameSnapshot.tracker_reset_seq`) to identify the first frame whose now is only applied before a later frame is measured. Read
measurement was produced after the reset. :attr:`FrameSnapshot.tracker_reset_seq` for the version a frame carries.
""" """
return int(self._tracker_reset_seq) return int(self._tracker_reset_seq)
def reset(self) -> None: def reset(self) -> None:
"""Reset the sensor's tracker and force-filter state, safely. """Request a reference reset, applied at frame boundaries by its owning stage.
Call this from the consumer thread (after a key press, on a drift alarm). The reset is Call this from the consumer thread (after a key press, on a drift alarm). The reset is
queued and applied by the flow stage before its next frame, never inline: ``Sensor.reset`` queued, never inline: ``Sensor.reset_image_state`` rebases ``initial_img`` on the current
rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is reading that ``Sensor.img``, and the flow stage is reading that buffer concurrently, so resetting from
buffer concurrently, so resetting from the caller's thread would capture a half-written the caller's thread would capture a half-written reference frame.
reference frame.
Idempotent: a second call before the next frame does not queue twice. The two halves are applied by the threads that own their state. The flow stage resets the
image/tracker state and bumps the reference version before its next frame; the HHD stage
resets the force/contact state on the first frame that carries the new version, before
decomposing it. A sensor that implements only ``reset()`` keeps the historical whole reset
on the flow stage, and then no force-side reset is applied by the HHD stage.
Requests coalesce until the flow stage claims them. Use :meth:`request_reset`
when the caller needs a target version to confirm completion.
""" """
self._reset_pending.set() self.request_reset()
def request_reset(self) -> int:
"""Queue a reset and return the reference version that will confirm it.
Requests coalesce only until the flow stage claims them. A request made
while an earlier reset is being applied gets a new target, even though
:attr:`tracker_reset_seq` still reports the previous applied version.
Wait for a snapshot with ``tracker_reset_seq >= target``; neither the
current snapshot nor a separately read live counter identifies a request.
Sensor state is always reset by its owning stages, outside this lock.
"""
with self._reset_lock:
if self._pending_reset_version is None:
self._requested_reset_version += 1
self._pending_reset_version = self._requested_reset_version
return self._pending_reset_version
@property @property
def paused(self) -> bool: def paused(self) -> bool:
@@ -399,7 +429,7 @@ class StreamPipeline:
# Frames read during the pause were never computed, so the sensor's # Frames read during the pause were never computed, so the sensor's
# reference frame is stale by however long the hold lasted. Ask for a # reference frame is stale by however long the hold lasted. Ask for a
# rebase; the flow stage applies it before its next frame. # rebase; the flow stage applies it before its next frame.
self._reset_pending.set() self.reset()
# Arm capture *before* lifting the hold. The producer stops as soon as # Arm capture *before* lifting the hold. The producer stops as soon as
# the pause flag is set and only returns once it is clear, so a level-1 # the pause flag is set and only returns once it is clear, so a level-1
# resume must have `_currently_capturing` true by then or the producer # resume must have `_currently_capturing` true by then or the producer
@@ -585,14 +615,23 @@ class StreamPipeline:
self._drain(self._q1) self._drain(self._q1)
self._drain(self._q2) self._drain(self._q2)
continue continue
# Applied here, on the thread that owns the sensor's flow state, before this frame is # Applied here, on the thread that owns the sensor's image/flow state, before this
# computed: see reset() for why this must not run on the caller's thread. # frame is computed: see reset() for why this must not run on the caller's thread.
if self._reset_pending.is_set(): with self._reset_lock:
self._reset_pending.clear() reset_version = self._pending_reset_version
self._pending_reset_version = None
if reset_version is not None:
reset_image_state = getattr(self.sensor, "reset_image_state", None)
if reset_image_state is not None:
reset_image_state()
else:
# A sensor without the split seam keeps the historical whole reset on this
# thread; its force state is then not reset by the HHD stage.
self.sensor.reset() self.sensor.reset()
# Published on this frame's snapshot: this is the first frame whose self._tracker_reset_seq = reset_version
# measurement (flow) was produced after the reset was applied. # The version travels with the frame: the HHD stage publishes the snapshot later and
self._tracker_reset_seq += 1 # must not read the live counter at that point.
reference_version = self._tracker_reset_seq
frame, t_cap, produce_s, source_metadata = item frame, t_cap, produce_s, source_metadata = item
t0 = time.perf_counter() t0 = time.perf_counter()
# The frame is passed explicitly: the SDK's self.img slot is shared # The frame is passed explicitly: the SDK's self.img slot is shared
@@ -605,7 +644,8 @@ class StreamPipeline:
flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None
self._put( self._put(
self._q2, self._q2,
(frame, flow_copy, t_cap, produce_s, flow_s, source_metadata), (frame, flow_copy, t_cap, produce_s, flow_s, source_metadata,
reference_version),
) )
self._put(self._q2, None) self._put(self._q2, None)
@@ -621,7 +661,15 @@ class StreamPipeline:
if not self._wait_out_pause(): if not self._wait_out_pause():
break break
continue continue
frame, flow, t_cap, produce_s, flow_s, source_metadata = item frame, flow, t_cap, produce_s, flow_s, source_metadata, reference_version = item
# The force/contact state belongs to this stage, so its half of a
# queued reset is applied here, on the first frame of the new version,
# before the decomposition that would consume the stale model state.
if reference_version > self._force_reset_applied_version:
self._force_reset_applied_version = reference_version
reset_force_state = getattr(self.sensor, "reset_force_state", None)
if reset_force_state is not None:
reset_force_state()
t0 = time.perf_counter() t0 = time.perf_counter()
# Explicit flow: compute_hhd reads self.flow, which the flow stage # Explicit flow: compute_hhd reads self.flow, which the flow stage
# overwrites for the next frame, so it cannot be the transport. # overwrites for the next frame, so it cannot be the transport.
@@ -630,7 +678,8 @@ class StreamPipeline:
self.sensor.compute_contact() self.sensor.compute_contact()
hhd_s = time.perf_counter() - t0 hhd_s = time.perf_counter() - t0
snap = self._build( snap = self._build(
frame, flow, produce_s, flow_s, hhd_s, t_cap, source_metadata frame, flow, produce_s, flow_s, hhd_s, t_cap, source_metadata,
reference_version,
) )
self._publish(snap) self._publish(snap)
if self._on_frame is not None: if self._on_frame is not None:
@@ -652,7 +701,8 @@ class StreamPipeline:
def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float, def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float,
flow_s: float, hhd_s: float, t_cap: float, flow_s: float, hhd_s: float, t_cap: float,
source_metadata: Any = None) -> FrameSnapshot: source_metadata: Any = None,
reference_version: int = 0) -> FrameSnapshot:
sensor = self.sensor sensor = self.sensor
info = sensor.info info = sensor.info
include = self._include include = self._include
@@ -697,7 +747,9 @@ class StreamPipeline:
hhd_ms=hhd_s * 1000.0, hhd_ms=hhd_s * 1000.0,
image_size_raw=image_size_raw, image_size_raw=image_size_raw,
source_metadata=source_metadata, source_metadata=source_metadata,
tracker_reset_seq=int(self._tracker_reset_seq), # This frame's own measurement version, carried through the flow queue:
# never the live counter, which a later frame may already have bumped.
tracker_reset_seq=int(reference_version),
) )
def _fps(self) -> float: def _fps(self) -> float:
+44 -6
View File
@@ -1138,15 +1138,53 @@ class Sensor:
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info)) return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
def reset(self): def reset_image_state(self) -> None:
"""Reset optical flow tracker and force-filter state.""" """Reset the image/flow half of the pipeline state (flow thread only).
self.reset_tracker()
# The unfiltered pair belongs to the frame it was computed for: a new Rebases the optical-flow tracker on the current ``img`` and drops the flow
# session must not report the previous one's reading. EMA. :class:`StreamPipeline` calls this on its flow thread before computing
self._reset_calibration_session() the first frame of a new reference version, because that thread owns the
image buffers. The force half belongs to the HHD thread and is reset
separately by :meth:`reset_force_state`.
"""
self.__tracker.reset()
self._force_readout.reset_flow_ema()
if self.img is not None: if self.img is not None:
self.initial_img = self.img.copy() self.initial_img = self.img.copy()
def reset_force_state(self) -> None:
"""Reset the force/contact half of the pipeline state (HHD thread only).
Drops the readout filters and the last reading, the contact flags and the
calibration session (model zero/reference), so the first frame of a new
reference version is measured from a clean force state. The image half
belongs to the flow thread and is reset by :meth:`reset_image_state`.
"""
self._force_readout.reset_readout()
# The unfiltered pair belongs to the frame it was computed for: a new
# session must not report the previous one's reading.
self._force_unfiltered = None
self._force_raw_unfiltered = None
self._force_filtered_raw = None
self.compute_deformation_valid = False
self.compute_contact_valid = False
self.centroid_found = False
self._reset_calibration_session()
def reset(self):
"""Reset the image and force state together (single-threaded use).
Kept for callers that own the whole sensor without the streaming
pipeline, and for a third-party sensor/stage that has no split seam: the
final state is unchanged from before the split. :class:`StreamPipeline`
applies the two halves on the threads that own them instead -
:meth:`reset_image_state` on the flow stage and
:meth:`reset_force_state` on the HHD stage, before the first frame of the
new reference version.
"""
self.reset_image_state()
self.reset_force_state()
def disconnect(self): def disconnect(self):
"""Release resources and stop async threads.""" """Release resources and stop async threads."""
+63
View File
@@ -155,6 +155,33 @@ def collect_baseline(session, source, *, start=1, count=30, reference_id="ref-1"
] ]
def test_external_zero_waits_for_the_requested_target_reference_version():
source = FakeMeasurementSource(raw_for=lambda _: (10.0, 20.0, 30.0))
session = make_session(source)
session.request_zero_external_reset(
reference_id="second-reset", declared_unloaded=True, target_reference_version=2
)
outputs = []
for frame_id, version in enumerate([0, 1, 1, 2, 2]):
row = source.read_measurement_frame(None, frame_id=frame_id)
outputs.append(session.process_measurement(row, stamp(frame_id), tracker_reset_seq=version))
assert [o.measurement.zero.phase for o in outputs] == [
"resetting", "resetting", "resetting", "collecting", "collecting"
]
assert outputs[-1].measurement.zero.collected_frames == 2
assert outputs[-1].measurement.zero.first_frame_id == 3
@pytest.mark.parametrize("target", [0, -1, True])
def test_external_zero_rejects_invalid_target_reference_version(target):
session = make_session(FakeMeasurementSource())
with pytest.raises(ValueError, match="target_reference_version"):
session.request_zero_external_reset(
reference_id="invalid-target", declared_unloaded=True,
target_reference_version=target,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# T1: executable fake chain, one model call, cached reads, missing is not zero # T1: executable fake chain, one model call, cached reads, missing is not zero
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1210,3 +1237,39 @@ def test_old_readout_entry_keeps_filter_then_convert_order() -> None:
# is exactly the recorded historical behaviour. # is exactly the recorded historical behaviour.
assert outputs[-1] == pytest.approx(4.0) assert outputs[-1] == pytest.approx(4.0)
assert readout.raw_unfiltered == (250.0, 0.0, 0.0) assert readout.raw_unfiltered == (250.0, 0.0, 0.0)
def test_readout_reset_halves_clear_only_their_own_state() -> None:
"""The flow EMA is image-side state; the readout history is force-side state."""
from orisys.force_readout import ForceReadout
flow = np.zeros((2, 2, 2), dtype=np.float32)
readout = ForceReadout({"flow_ema_alpha": 0.5, "force_median_window": 3},
config_fps=30.0)
readout.apply_flow_ema(flow)
readout.apply((10.0, 1.0, 2.0))
filtered = readout.filtered_raw
raw_unfiltered = readout.raw_unfiltered
unfiltered_newton = readout.unfiltered_newton
readout.reset_flow_ema()
assert readout._ema_flow is None
# The force-side history survives a reset that only rebases the image stage.
assert readout.filtered_raw == filtered
assert readout.raw_unfiltered == raw_unfiltered
assert readout.unfiltered_newton == unfiltered_newton
readout = ForceReadout({"flow_ema_alpha": 0.5, "force_median_window": 3},
config_fps=30.0)
readout.apply_flow_ema(flow)
readout.apply((10.0, 1.0, 2.0))
readout.reset_readout()
assert readout._ema_flow is not None
assert readout.filtered_raw is None
assert readout.raw_unfiltered is None
assert readout.unfiltered_newton is None
readout.reset()
assert readout._ema_flow is None
assert readout.filtered_raw is None
+222
View File
@@ -429,3 +429,225 @@ def test_snapshot_raw_capture_size_is_none_when_unreported():
time.sleep(0.001) time.sleep(0.001)
pipe.stop() pipe.stop()
assert frames and frames[0].image_size_raw is None assert frames and frames[0].image_size_raw is None
class SplitResetFakeSensor(FakeSensor):
"""A fake with the split reset seam and event-gated flow/HHD stages.
The two halves of a reset belong to different pipeline threads, and the
version a frame is stamped with depends on which half has already run. The
events pin both stages to a known frame so the regression orders the race
instead of hoping for it: the flow stage parks at the end of its second
frame and the HHD stage parks inside its first decomposition.
"""
FLOW_GATE_CALL = 2
def __init__(self) -> None:
super().__init__()
self.flow_gate_reached = threading.Event()
self.release_flow_gate = threading.Event()
self.hhd_gate_reached = threading.Event()
self.release_hhd_gate = threading.Event()
self.hhd_calls = 0
self.image_reset_threads: list[str] = []
self.force_reset_threads: list[str] = []
self.force_reset_at_hhd_calls: list[int] = []
def compute_flow(self, img=None, **kwargs) -> None:
super().compute_flow(img, **kwargs)
# Park after frame 2's flow. The next frame that reaches the flow stage
# is then provably the first one that can observe a reset queued now.
if self._flow_calls == self.FLOW_GATE_CALL:
self.flow_gate_reached.set()
self.release_flow_gate.wait(timeout=5.0)
def compute_hhd(self, flow=None, **kwargs) -> None:
self.hhd_calls += 1
if self.hhd_calls == 1:
self.hhd_gate_reached.set()
self.release_hhd_gate.wait(timeout=5.0)
super().compute_hhd(flow, **kwargs)
def reset_image_state(self) -> None:
self.image_reset_threads.append(threading.current_thread().name)
def reset_force_state(self) -> None:
self.force_reset_threads.append(threading.current_thread().name)
self.force_reset_at_hhd_calls.append(self.hhd_calls)
def reset(self) -> None:
# Mirrors Sensor.reset(): both halves, immediately, on the caller's thread.
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_image_state()
self.reset_force_state()
class LegacyResetFakeSensor(FakeSensor):
"""A stand-in with only the historical ``reset()``, like a third-party sensor."""
def __init__(self) -> None:
super().__init__()
self.reset_threads: list[str] = []
def reset(self) -> None:
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_threads.append(threading.current_thread().name)
def test_reset_request_target_is_shared_until_flow_claims_it():
sensor = FakeSensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
assert pipe.reset() is None # The published entry keeps its return contract.
assert pipe.request_reset() == 1
assert pipe.tracker_reset_seq == 0
pipe.start()
try:
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 1]
assert sensor.reset_calls == 1
assert pipe.request_reset() == 2
finally:
pipe.stop()
def test_reset_requested_while_previous_reset_is_running_gets_a_new_target():
entered = threading.Event()
release = threading.Event()
class Sensor(LegacyResetFakeSensor):
def reset(self):
super().reset()
if self.reset_calls == 1:
entered.set()
assert release.wait(timeout=5.0)
sensor = Sensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
pipe.start()
try:
assert entered.wait(timeout=5.0)
# Flow has claimed request 1, but has not finished applying it. The live
# counter is still 0; request 2 must not be merged into request 1.
assert pipe.tracker_reset_seq == 0
assert pipe.request_reset() == 2
assert pipe.request_reset() == 2
release.set()
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 2]
assert sensor.reset_calls == 2
finally:
release.set()
pipe.stop()
def _wait_until(predicate, timeout: float = 5.0) -> bool:
"""Poll a predicate; the events, not this poll, order the race."""
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
if predicate():
return True
time.sleep(0.001)
return predicate()
def _run_gated_reset(sensor: SplitResetFakeSensor, frames: list, count: int = 3) -> None:
"""Drive two pre-reset frames and one post-reset frame through the real loops."""
pipe = StreamPipeline(sensor, make_producer(sensor, count),
on_frame=frames.append, depth=2)
pipe.start()
try:
assert sensor.flow_gate_reached.wait(timeout=5.0), "flow stage never reached its gate"
assert sensor.hhd_gate_reached.wait(timeout=5.0), "HHD stage never reached its gate"
# Both stages are provably parked: no frame has been published, and the
# frame whose flow would observe this reset has not been computed yet.
pipe.reset()
sensor.release_flow_gate.set()
assert _wait_until(lambda: sensor._flow_calls >= 3), (
"flow stage did not compute the first post-reset frame"
)
sensor.release_hhd_gate.set()
deadline = time.perf_counter() + 5.0
while len(frames) < count and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
sensor.release_flow_gate.set()
sensor.release_hhd_gate.set()
pipe.stop()
assert len(frames) == count, f"expected {count} published frames, got {len(frames)}"
def test_frames_keep_the_reference_version_they_were_measured_with():
"""A reset queued after a frame's flow must not relabel that frame.
Frames 0 and 1 are measured while the HHD stage is held on the first
snapshot. The caller then queues a reset, and frame 2 is measured with the
new version. Each snapshot must carry the version its own flow was computed
with; reading the live counter at publish time would stamp the two older
frames with the newer version.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
versions = [snap.tracker_reset_seq for snap in frames]
assert versions == [0, 0, 1], f"expected per-frame versions [0, 0, 1], got {versions}"
def test_reset_halves_run_on_the_threads_that_own_their_state():
"""Image state resets on the flow stage, force state on the HHD stage.
The force half must land on the HHD stage before it decomposes the first
frame of the new version. Resetting it from the flow thread - the old
behaviour - mutates model/zero state that the HHD stage is using.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
# Two decompositions (frames 0 and 1) had completed when the force reset ran,
# so it preceded the third one - the first frame of the new version.
assert sensor.force_reset_at_hhd_calls == [2]
assert sensor.hhd_calls == 3
assert [snap.tracker_reset_seq for snap in frames] == [0, 0, 1]
def test_legacy_sensor_with_only_reset_is_reset_on_the_flow_stage():
"""A sensor without the split methods keeps the historical fallback."""
sensor = LegacyResetFakeSensor()
ready = threading.Event()
release = threading.Event()
state = {"produced": 0}
def gated_produce():
ready.set()
release.wait(timeout=5.0)
if state["produced"]:
return None
state["produced"] = 1
return sensor.make_frame(0)
frames: list = []
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
pipe.start()
try:
assert ready.wait(timeout=5.0)
pipe.reset()
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
release.set()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
release.set()
pipe.stop()
assert len(frames) == 1
assert sensor.reset_calls == 1
assert sensor.reset_threads == ["orisys-pipeline-flow"]
assert sensor.reset_at_frame == [0], "the fallback reset must precede compute_flow"