fixed studio dd02 live cannot work problem
This commit is contained in:
@@ -75,7 +75,7 @@ class _FingerProducer:
|
|||||||
"""The ``StreamPipeline`` produce callable for a ``FingerRuntime``.
|
"""The ``StreamPipeline`` produce callable for a ``FingerRuntime``.
|
||||||
|
|
||||||
The finger pipeline's producer is ``runtime.grab_and_process`` — read the
|
The finger pipeline's producer is ``runtime.grab_and_process`` — read the
|
||||||
source and UV-unroll it — so this wrapper adds the two things that have to
|
source and UV-unroll it — so this wrapper adds the things that have to
|
||||||
happen on the producer thread:
|
happen on the producer thread:
|
||||||
|
|
||||||
* **One published frame per delivered frame.** ``CaptureSource.read_latest``
|
* **One published frame per delivered frame.** ``CaptureSource.read_latest``
|
||||||
@@ -88,6 +88,10 @@ class _FingerProducer:
|
|||||||
frame to the tactile sensor and drops the source frame; the record channel's
|
frame to the tactile sensor and drops the source frame; the record channel's
|
||||||
pre-roll history wants the source frame, so it is copied here while it is
|
pre-roll history wants the source frame, so it is copied here while it is
|
||||||
still this frame's, and handed on by sequence.
|
still this frame's, and handed on by sequence.
|
||||||
|
* **The camera's own rate.** The consumer reads ``capture_fps`` from this
|
||||||
|
producer, the same way the box path reads it from ``_FrameProducer``: the
|
||||||
|
source's delivered-frame counter, anchored to the wall clock of the wait
|
||||||
|
that observed it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CACHE = 8
|
CACHE = 8
|
||||||
@@ -104,6 +108,11 @@ class _FingerProducer:
|
|||||||
self._cache: deque = deque(maxlen=self.CACHE)
|
self._cache: deque = deque(maxlen=self.CACHE)
|
||||||
self._times: deque = deque()
|
self._times: deque = deque()
|
||||||
self._last_read_at: float | None = None
|
self._last_read_at: float | None = None
|
||||||
|
#: ``(clock, seconds, delivered frames)`` samples behind ``capture_fps``.
|
||||||
|
self._rate_samples: deque = deque()
|
||||||
|
self._capture_mark: tuple[float, int] | None = None
|
||||||
|
#: The wait's own anchor into the counter, consumed by ``capture_fps``.
|
||||||
|
self._repeat_check: tuple[float, int] | None = None
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
self._wait_for_a_new_frame()
|
self._wait_for_a_new_frame()
|
||||||
@@ -134,6 +143,10 @@ class _FingerProducer:
|
|||||||
def _wait_for_a_new_frame(self) -> None:
|
def _wait_for_a_new_frame(self) -> None:
|
||||||
if self.is_file or self.capture_seq is None:
|
if self.is_file or self.capture_seq is None:
|
||||||
return
|
return
|
||||||
|
#: When the wait started, and what the counter said then: the two together
|
||||||
|
#: are the only honest basis for the camera's own rate when this loop is
|
||||||
|
#: slower than the camera and skips a frame entirely.
|
||||||
|
self._repeat_check = (time.perf_counter(), int(self.capture_seq))
|
||||||
deadline = time.perf_counter() + CAMERA_DISCONNECT_TIMEOUT_S
|
deadline = time.perf_counter() + CAMERA_DISCONNECT_TIMEOUT_S
|
||||||
while True:
|
while True:
|
||||||
current = _capture_sequence(self.capture_source)
|
current = _capture_sequence(self.capture_source)
|
||||||
@@ -168,6 +181,63 @@ class _FingerProducer:
|
|||||||
while self._times and self._times[0][1] < cutoff:
|
while self._times and self._times[0][1] < cutoff:
|
||||||
self._times.popleft()
|
self._times.popleft()
|
||||||
|
|
||||||
|
def capture_window(self, previous: tuple[float, int] | None) -> tuple[float, int]:
|
||||||
|
"""``(seconds, delivered frames)`` over the interval since ``previous``.
|
||||||
|
|
||||||
|
Both anchors have to be read together, and on this thread: the source's
|
||||||
|
counter advances on the capture thread whether or not a frame is taken, so
|
||||||
|
pairing a stale time with the current counter would measure the interval
|
||||||
|
between two *publishes* while crediting every frame delivered since an
|
||||||
|
earlier read — a rate that comes out at roughly double the camera's.
|
||||||
|
"""
|
||||||
|
if previous is None or self.capture_seq is None:
|
||||||
|
return 0.0, 0
|
||||||
|
t0, delivered0 = previous
|
||||||
|
span = time.perf_counter() - t0
|
||||||
|
delivered = int(self.capture_seq) - int(delivered0)
|
||||||
|
return (span, delivered) if span > 0.0 else (0.0, 0)
|
||||||
|
|
||||||
|
def capture_fps(self) -> float:
|
||||||
|
"""The capture source's own published rate, as a rate over a window.
|
||||||
|
|
||||||
|
Counted from the source's delivered-frame counter between two of this
|
||||||
|
producer's reads, against the wall-clock between them, and summed over the
|
||||||
|
last second so one scheduler-jittered interval does not become the reported
|
||||||
|
rate. Only a source with that counter can answer — a live camera. A video
|
||||||
|
file decodes a new frame per call, so it has no "the camera had it and we did
|
||||||
|
not" to measure.
|
||||||
|
"""
|
||||||
|
if self.capture_seq is None:
|
||||||
|
return 0.0
|
||||||
|
now = time.perf_counter()
|
||||||
|
span, delivered = self.capture_window(self._capture_mark)
|
||||||
|
self._capture_mark = (now, int(self.capture_seq))
|
||||||
|
if span <= 0.0 or delivered <= 0:
|
||||||
|
# Credit what the repeat wait already observed: when this loop cannot
|
||||||
|
# keep up, no frame is read at all until the counter moves again, and a
|
||||||
|
# read-anchored window would then credit a whole interval's frames to a
|
||||||
|
# fraction of it.
|
||||||
|
span, delivered = self._repeat_window()
|
||||||
|
if span > 0.0 and delivered > 0:
|
||||||
|
self._rate_samples.append((now, span, delivered))
|
||||||
|
cutoff = now - FPS_WINDOW_S
|
||||||
|
while len(self._rate_samples) > 1 and self._rate_samples[0][0] < cutoff:
|
||||||
|
self._rate_samples.popleft()
|
||||||
|
total_span = sum(sample[1] for sample in self._rate_samples)
|
||||||
|
total_frames = sum(sample[2] for sample in self._rate_samples)
|
||||||
|
return max(0.0, total_frames / total_span) if total_span > 0.0 else 0.0
|
||||||
|
|
||||||
|
def _repeat_window(self) -> tuple[float, int]:
|
||||||
|
"""``(seconds, frames)`` the repeat wait saw, and then forget it."""
|
||||||
|
previous = self._repeat_check
|
||||||
|
self._repeat_check = None
|
||||||
|
if previous is None or self._capture_mark is None or self.capture_seq is None:
|
||||||
|
return 0.0, 0
|
||||||
|
t0, seq0 = previous
|
||||||
|
span = self._capture_mark[0] - t0
|
||||||
|
frames = int(self.capture_seq) - int(seq0)
|
||||||
|
return (span, frames) if span > 0.0 and frames > 0 else (0.0, 0)
|
||||||
|
|
||||||
|
|
||||||
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
||||||
"""Return local ``assets/30fps-0703`` when present and complete.
|
"""Return local ``assets/30fps-0703`` when present and complete.
|
||||||
@@ -441,22 +511,34 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
raise RuntimeError("Finger runtime is not running")
|
raise RuntimeError("Finger runtime is not running")
|
||||||
self._pending_reset = True
|
self._pending_reset = True
|
||||||
|
|
||||||
def _consume_pending_reset(self, runtime, frame_id: int, mono_t: float) -> None:
|
def _consume_pending_reset(
|
||||||
"""Apply a queued reset and report the frame it actually took effect at."""
|
self, runtime, frame_id: int, mono_t: float, *, pipeline=None
|
||||||
|
) -> None:
|
||||||
|
"""Apply a queued reset and report the frame it actually took effect at.
|
||||||
|
|
||||||
|
A running stream pipeline owns the tracker's frame queue on its flow
|
||||||
|
thread. Resetting from this thread empties that queue while the flow
|
||||||
|
stage is indexing it (``list index out of range``) and the session ends.
|
||||||
|
``pipeline.reset`` queues the same ``Sensor.reset`` for the flow stage,
|
||||||
|
which applies it before the next frame.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
pending = self._pending_reset
|
pending = self._pending_reset
|
||||||
self._pending_reset = False
|
self._pending_reset = False
|
||||||
session = self._session_id
|
session = self._session_id
|
||||||
if not pending or runtime is None:
|
if not pending or runtime is None:
|
||||||
return
|
return
|
||||||
tactile = getattr(runtime, "tactile", None)
|
if pipeline is not None:
|
||||||
target = tactile if callable(getattr(tactile, "reset", None)) else None
|
pipeline.reset()
|
||||||
if target is None:
|
else:
|
||||||
sensor = getattr(tactile, "raw_sensor", None)
|
tactile = getattr(runtime, "tactile", None)
|
||||||
target = sensor if callable(getattr(sensor, "reset", None)) else None
|
target = tactile if callable(getattr(tactile, "reset", None)) else None
|
||||||
if target is None:
|
if target is None:
|
||||||
raise RuntimeError("runtime does not support reset")
|
sensor = getattr(tactile, "raw_sensor", None)
|
||||||
target.reset()
|
target = sensor if callable(getattr(sensor, "reset", None)) else None
|
||||||
|
if target is None:
|
||||||
|
raise RuntimeError("runtime does not support reset")
|
||||||
|
target.reset()
|
||||||
if self._on_reset_applied is not None:
|
if self._on_reset_applied is not None:
|
||||||
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
|
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
|
||||||
|
|
||||||
@@ -754,9 +836,10 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
# it was built: see the box worker. A paused worker publishes
|
# it was built: see the box worker. A paused worker publishes
|
||||||
# nothing, including frames already in flight.
|
# nothing, including frames already in flight.
|
||||||
continue
|
continue
|
||||||
# A zeroing and new coefficients are applied by this thread at a
|
# Coefficients are applied by this thread at a frame boundary.
|
||||||
# frame boundary, then reported with the frame and time they took
|
# A zeroing is queued onto the pipeline's flow thread, which
|
||||||
# effect at — the same confirmation rule as the serial loop.
|
# owns the tracker, and reported with the frame and time it
|
||||||
|
# takes effect — the same confirmation rule as the serial loop.
|
||||||
try:
|
try:
|
||||||
self._consume_pending_calibration(
|
self._consume_pending_calibration(
|
||||||
tactile, frame_id + 1, float(packet.mono_t or 0.0)
|
tactile, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
@@ -771,7 +854,10 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._on_error(errors[-1])
|
self._on_error(errors[-1])
|
||||||
try:
|
try:
|
||||||
self._consume_pending_reset(
|
self._consume_pending_reset(
|
||||||
runtime, frame_id + 1, float(packet.mono_t or 0.0)
|
runtime,
|
||||||
|
frame_id + 1,
|
||||||
|
float(packet.mono_t or 0.0),
|
||||||
|
pipeline=pipe,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
message = f"reset failed: {type(exc).__name__}: {exc}"
|
message = f"reset failed: {type(exc).__name__}: {exc}"
|
||||||
|
|||||||
@@ -959,18 +959,31 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
raise RuntimeError("Sensor is not running")
|
raise RuntimeError("Sensor is not running")
|
||||||
self._pending_reset = True
|
self._pending_reset = True
|
||||||
|
|
||||||
def _consume_pending_reset(self, sensor, frame_id: int, mono_t: float) -> None:
|
def _consume_pending_reset(
|
||||||
"""Apply a queued reset and report the frame it actually took effect at."""
|
self, sensor, frame_id: int, mono_t: float, *, pipeline=None
|
||||||
|
) -> None:
|
||||||
|
"""Apply a queued reset and report the frame it actually took effect at.
|
||||||
|
|
||||||
|
A running stream pipeline owns the tracker's frame queue on its flow
|
||||||
|
thread. ``Sensor.reset`` from this thread empties that queue while the
|
||||||
|
flow stage is indexing it (``list index out of range``) and the session
|
||||||
|
ends. ``pipeline.reset`` queues the reset for the flow stage, which
|
||||||
|
applies it before the next frame. The serial loop has no other thread
|
||||||
|
in the tracker, so it still resets inline.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
pending = self._pending_reset
|
pending = self._pending_reset
|
||||||
self._pending_reset = False
|
self._pending_reset = False
|
||||||
session = self._session_id
|
session = self._session_id
|
||||||
if not pending or sensor is None:
|
if not pending or sensor is None:
|
||||||
return
|
return
|
||||||
reset = getattr(sensor, "reset", None)
|
if pipeline is not None:
|
||||||
if not callable(reset):
|
pipeline.reset()
|
||||||
raise RuntimeError("sensor does not support reset")
|
else:
|
||||||
reset()
|
reset = getattr(sensor, "reset", None)
|
||||||
|
if not callable(reset):
|
||||||
|
raise RuntimeError("sensor does not support reset")
|
||||||
|
reset()
|
||||||
if self._on_reset_applied is not None:
|
if self._on_reset_applied is not None:
|
||||||
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
|
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
|
||||||
|
|
||||||
@@ -1277,9 +1290,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
# includes not handing the operator a packet they paused
|
# includes not handing the operator a packet they paused
|
||||||
# before, so the in-flight frame is dropped here.
|
# before, so the in-flight frame is dropped here.
|
||||||
continue
|
continue
|
||||||
# A zeroing and new coefficients are applied by this thread at a
|
# Coefficients are applied by this thread at a frame boundary.
|
||||||
# frame boundary, then reported with the frame and time they took
|
# A zeroing is queued onto the pipeline's flow thread, which
|
||||||
# effect at — the same confirmation rule as the serial loop.
|
# owns the tracker, and reported with the frame and time it
|
||||||
|
# takes effect — the same confirmation rule as the serial loop.
|
||||||
try:
|
try:
|
||||||
self._consume_pending_calibration(
|
self._consume_pending_calibration(
|
||||||
sensor, frame_id + 1, float(packet.mono_t or 0.0)
|
sensor, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
@@ -1296,7 +1310,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._on_error(errors[-1])
|
self._on_error(errors[-1])
|
||||||
try:
|
try:
|
||||||
self._consume_pending_reset(
|
self._consume_pending_reset(
|
||||||
sensor, frame_id + 1, float(packet.mono_t or 0.0)
|
sensor,
|
||||||
|
frame_id + 1,
|
||||||
|
float(packet.mono_t or 0.0),
|
||||||
|
pipeline=pipe,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
message = f"reset failed: {type(exc).__name__}: {exc}"
|
message = f"reset failed: {type(exc).__name__}: {exc}"
|
||||||
|
|||||||
@@ -129,6 +129,61 @@ def test_a_zeroing_is_applied_by_the_worker_and_reported_with_its_frame():
|
|||||||
assert events == [(42, 1.4, 2)]
|
assert events == [(42, 1.4, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_live_zeroing_is_queued_on_the_pipeline():
|
||||||
|
"""The stream path must not reset the tracker on the consumer thread.
|
||||||
|
|
||||||
|
The flow stage is indexing the tracker's frame queue at the same time.
|
||||||
|
Clearing it here is the ``list index out of range`` that ends the session.
|
||||||
|
"""
|
||||||
|
events: list[tuple] = []
|
||||||
|
worker = _worker(on_reset_applied=lambda *args: events.append(args))
|
||||||
|
sensor = _Sensor()
|
||||||
|
queued: list[int] = []
|
||||||
|
|
||||||
|
class _Pipeline:
|
||||||
|
def reset(self) -> None:
|
||||||
|
queued.append(sensor.resets)
|
||||||
|
|
||||||
|
worker._session_id = 2
|
||||||
|
worker.reset()
|
||||||
|
worker._consume_pending_reset(
|
||||||
|
sensor, frame_id=42, mono_t=1.4, pipeline=_Pipeline()
|
||||||
|
)
|
||||||
|
assert sensor.resets == 0, "the live zeroing ran on the consumer thread"
|
||||||
|
assert queued == [0]
|
||||||
|
assert events == [(42, 1.4, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_live_finger_zeroing_is_queued_on_the_pipeline():
|
||||||
|
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
|
||||||
|
|
||||||
|
events: list[tuple] = []
|
||||||
|
worker = FingerAcquisitionWorker(on_reset_applied=lambda *args: events.append(args))
|
||||||
|
tactile_resets: list[int] = []
|
||||||
|
|
||||||
|
class _Tactile:
|
||||||
|
def reset(self) -> None:
|
||||||
|
tactile_resets.append(1)
|
||||||
|
|
||||||
|
class _Runtime:
|
||||||
|
tactile = _Tactile()
|
||||||
|
|
||||||
|
queued: list[int] = []
|
||||||
|
|
||||||
|
class _Pipeline:
|
||||||
|
def reset(self) -> None:
|
||||||
|
queued.append(len(tactile_resets))
|
||||||
|
|
||||||
|
worker._session_id = 3
|
||||||
|
worker.reset()
|
||||||
|
worker._consume_pending_reset(
|
||||||
|
_Runtime(), frame_id=7, mono_t=0.2, pipeline=_Pipeline()
|
||||||
|
)
|
||||||
|
assert tactile_resets == [], "the finger zeroing ran on the consumer thread"
|
||||||
|
assert queued == [0]
|
||||||
|
assert events == [(7, 0.2, 3)]
|
||||||
|
|
||||||
|
|
||||||
def test_a_started_session_drops_a_stale_zeroing_request():
|
def test_a_started_session_drops_a_stale_zeroing_request():
|
||||||
root = _root()
|
root = _root()
|
||||||
events: list[tuple] = []
|
events: list[tuple] = []
|
||||||
|
|||||||
@@ -416,3 +416,32 @@ def test_a_failed_chain_marks_the_frame_rather_than_claiming_its_forces():
|
|||||||
assert packet.contact.centroid_uv_xy is None
|
assert packet.contact.centroid_uv_xy is None
|
||||||
finally:
|
finally:
|
||||||
_restore(saved)
|
_restore(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_finger_producer_reports_the_camera_rate(monkeypatch):
|
||||||
|
"""The first live frame asks the producer for ``capture_fps``.
|
||||||
|
|
||||||
|
That call is what ended a finger session with
|
||||||
|
``'_FingerProducer' object has no attribute 'capture_fps'``. A file has no
|
||||||
|
delivered-frame counter, so it reports nothing; a live source reports the
|
||||||
|
frames its counter advanced over the wall-clock between two reads.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.acquisition import finger_worker
|
||||||
|
|
||||||
|
clock = {"t": 1000.0}
|
||||||
|
monkeypatch.setattr(finger_worker.time, "perf_counter", lambda: clock["t"])
|
||||||
|
|
||||||
|
file_source = types.SimpleNamespace(running=False, status=types.SimpleNamespace(last_seq=0))
|
||||||
|
file_runtime = types.SimpleNamespace(capture_source=file_source)
|
||||||
|
file_producer = finger_worker._FingerProducer(file_runtime, is_file=True)
|
||||||
|
assert file_producer.capture_fps() == 0.0
|
||||||
|
|
||||||
|
status = types.SimpleNamespace(last_seq=10, dropped_frames=0)
|
||||||
|
live = types.SimpleNamespace(running=True, status=status)
|
||||||
|
producer = finger_worker._FingerProducer(
|
||||||
|
types.SimpleNamespace(capture_source=live), is_file=False
|
||||||
|
)
|
||||||
|
assert producer.capture_fps() == 0.0
|
||||||
|
clock["t"] = 1001.0
|
||||||
|
producer.capture_seq = 40
|
||||||
|
assert producer.capture_fps() == pytest.approx(30.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user