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
+25 -2
View File
@@ -374,14 +374,37 @@ class ForceReadout:
)
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_median.reset()
self._ema_flow = None
self.filtered_raw = None
self.raw_unfiltered = 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:
"""Effective filter chain, for records and audits."""
return {
+13 -14
View File
@@ -359,9 +359,8 @@ class ForceSession:
)
self._pending_reset: dict | None = None
#: A queued zero whose tracker reset is performed by an external stage
#: (the App delegates it to ``StreamPipeline.reset``). The value is the
#: pipeline reset serial current when the request was made; the first
#: processed frame with a newer serial begins the baseline window.
#: (the App delegates it to ``StreamPipeline.request_reset``). The value
#: is the reserved target version, not an observed frame/live counter.
self._pending_external_reset: int | None = None
self._zero_samples: list[tuple[float, float, float]] = []
self._replay_window: tuple[int, int] | None = None
@@ -757,16 +756,16 @@ class ForceSession:
reference_id: str,
declared_unloaded: bool,
replay_window: tuple[int, int] | None = None,
tracker_reset_seq: int,
target_reference_version: int,
) -> ZeroProgress:
"""Request the one unloaded zero when another stage performs the tracker reset.
Additive variant of :meth:`request_zero` for a consumer that drives its
measurement through a concurrent pipeline: the caller queues the tracker
reset on the pipeline's flow stage (``StreamPipeline.reset``) and passes
the pipeline's reset serial at request time. Frames published with that
same serial were measured before the reset and are never baseline frames;
the first processed frame with a newer serial begins the 30-frame window.
reset with ``StreamPipeline.request_reset`` and passes its returned target
version. Older versions cannot start the 30-frame window, including frames
from an earlier reset already in flight when this request was queued.
Collection begins at the first frame whose version reaches the target.
Generation, the declared-unloaded requirement and the filter reset are
identical to :meth:`request_zero`.
"""
@@ -776,13 +775,13 @@ class ForceSession:
)
if not isinstance(reference_id, str) or not reference_id.strip():
raise ValueError("reference_id must be a non-empty string")
if type(tracker_reset_seq) is not int or tracker_reset_seq < 0:
raise ValueError("tracker_reset_seq must be a non-negative integer")
if type(target_reference_version) is not int or target_reference_version <= 0:
raise ValueError("target_reference_version must be a positive integer")
window = _coerce_replay_window(replay_window)
self._generation += 1
self._clear_frame_caches()
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_samples = []
self._replay_window = window
@@ -1277,8 +1276,8 @@ class ForceSession:
``tracker_reset_seq`` is the publishing pipeline's reset serial
(:attr:`~orisys.pipeline.FrameSnapshot.tracker_reset_seq`). It is only
consulted by a zero requested through
:meth:`request_zero_external_reset`: frames with the recorded serial (or an
older one) were measured before the reset and are not baseline frames.
:meth:`request_zero_external_reset`: versions below its reserved target
were measured before that reset and are not baseline frames.
"""
if not isinstance(row, FrameMeasurement):
raise TypeError("row must be a FrameMeasurement")
@@ -1304,7 +1303,7 @@ class ForceSession:
raise ValueError(
"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
# baseline frame, and the session-owned reset path is not involved.
normal_reset_after = False
+87 -35
View File
@@ -157,11 +157,13 @@ class FrameSnapshot:
force_units: tuple[str, str, str] | None = None
calibration_status: dict[str, str] | None = None
contact_found: bool | None = None
#: Monotonic count of tracker resets the flow stage has applied. A consumer
#: that delegates a frame-boundary reset to :meth:`StreamPipeline.reset` can
#: use this to 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.
#: Reference version this frame was measured with: the count of image-side
#: tracker resets the flow stage had applied when it computed this frame's
#: flow. A consumer that delegates a frame-boundary reset to
#: :meth:`StreamPipeline.request_reset` compares it with the returned target to
#: 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
@@ -234,13 +236,19 @@ class StreamPipeline:
self._threads: list[threading.Thread] = []
self._error: BaseException | None = None
self._running = False
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
#: never races the stage that owns that state.
self._reset_pending = threading.Event()
#: How many tracker resets the flow stage has actually applied. Published on
#: every snapshot so a consumer can tell a frame measured before a queued
#: reset from one measured after it.
#: Request allocation and flow-stage claiming share a lock. A request
#: already claimed by flow cannot acknowledge another request made later.
self._reset_lock = threading.Lock()
self._requested_reset_version = 0
self._pending_reset_version: int | None = None
#: 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
#: 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;
#: ``_currently_capturing`` records whether the producer is still
#: reading, which decides whether a resume has to rebase the tracker.
@@ -330,24 +338,46 @@ class StreamPipeline:
def tracker_reset_seq(self) -> int:
"""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
:attr:`FrameSnapshot.tracker_reset_seq`) to identify the first frame whose
measurement was produced after the reset.
This is the live diagnostic count, not a frame's identity: a reset queued
now is only applied before a later frame is measured. Read
:attr:`FrameSnapshot.tracker_reset_seq` for the version a frame carries.
"""
return int(self._tracker_reset_seq)
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
queued and applied by the flow stage before its next frame, never inline: ``Sensor.reset``
rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is reading that
buffer concurrently, so resetting from the caller's thread would capture a half-written
reference frame.
queued, never inline: ``Sensor.reset_image_state`` rebases ``initial_img`` on the current
``Sensor.img``, and the flow stage is reading that buffer concurrently, so resetting from
the caller's thread would capture a half-written 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
def paused(self) -> bool:
@@ -399,7 +429,7 @@ class StreamPipeline:
# 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
# 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
# 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
@@ -585,14 +615,23 @@ class StreamPipeline:
self._drain(self._q1)
self._drain(self._q2)
continue
# Applied here, on the thread that owns the sensor's flow state, before this frame is
# computed: see reset() for why this must not run on the caller's thread.
if self._reset_pending.is_set():
self._reset_pending.clear()
self.sensor.reset()
# Published on this frame's snapshot: this is the first frame whose
# measurement (flow) was produced after the reset was applied.
self._tracker_reset_seq += 1
# Applied here, on the thread that owns the sensor's image/flow state, before this
# frame is computed: see reset() for why this must not run on the caller's thread.
with self._reset_lock:
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._tracker_reset_seq = reset_version
# The version travels with the frame: the HHD stage publishes the snapshot later and
# must not read the live counter at that point.
reference_version = self._tracker_reset_seq
frame, t_cap, produce_s, source_metadata = item
t0 = time.perf_counter()
# 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
self._put(
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)
@@ -621,7 +661,15 @@ class StreamPipeline:
if not self._wait_out_pause():
break
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()
# Explicit flow: compute_hhd reads self.flow, which the flow stage
# overwrites for the next frame, so it cannot be the transport.
@@ -630,7 +678,8 @@ class StreamPipeline:
self.sensor.compute_contact()
hhd_s = time.perf_counter() - t0
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)
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,
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
info = sensor.info
include = self._include
@@ -697,7 +747,9 @@ class StreamPipeline:
hhd_ms=hhd_s * 1000.0,
image_size_raw=image_size_raw,
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:
+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))
def reset(self):
"""Reset optical flow tracker and force-filter state."""
self.reset_tracker()
# The unfiltered pair belongs to the frame it was computed for: a new
# session must not report the previous one's reading.
self._reset_calibration_session()
def reset_image_state(self) -> None:
"""Reset the image/flow half of the pipeline state (flow thread only).
Rebases the optical-flow tracker on the current ``img`` and drops the flow
EMA. :class:`StreamPipeline` calls this on its flow thread before computing
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:
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):
"""Release resources and stop async threads."""