fix stop button disabled during connection

This commit is contained in:
ykcheungab
2026-09-12 20:27:34 +08:00
parent 904c27125f
commit 85d700bc06
15 changed files with 750 additions and 65 deletions
@@ -15,6 +15,8 @@ from orisys_app_shared.acquisition.constants import (
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
HANDOVER_TIMEOUT_S,
STOP_JOIN_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
FixedCameraMode,
SdkPacket,
@@ -67,7 +69,9 @@ class FingerAcquisitionWorker:
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._runtime = None
# Keyed by session so a session that is still winding down can only
# ever close its own runtime, never the runtime of a newer session.
self._runtimes: dict[int, object] = {}
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._export_dir: Optional[Path] = None
@@ -100,7 +104,10 @@ class FingerAcquisitionWorker:
*,
config_ref: str | Path,
) -> FixedCameraMode:
# Non-blocking: a previous session that is stuck opening must never hold
# the caller (the GUI thread) hostage.
self.stop()
previous = self._thread
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
@@ -119,26 +126,41 @@ class FingerAcquisitionWorker:
target=self._run,
name=f"FingerAcquisition-{session}",
daemon=True,
args=(session,),
args=(session, previous),
)
self._thread.start()
return mode
def stop(self) -> None:
def stop(self, *, wait: bool = False) -> None:
"""Ask the acquisition thread to stop.
The default is deliberately **non-blocking**: an open that never returns
must not freeze the caller, and the worker thread owns its own teardown.
Use ``wait=True`` only on shutdown paths that must not leave the runtime
behind.
"""
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=8.0)
if wait and thread is not None and thread.is_alive():
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
if thread is None or not thread.is_alive():
self._settle(owner=thread)
# Otherwise the thread settles itself on the way out.
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
"""Release worker state once no thread owns the runtime any more."""
if owner is not None and self._thread is not owner:
return # a newer session owns the worker now
self._thread = None
self._close_runtime()
self._close_all_runtimes()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
runtime = self._runtime
runtime = self._runtimes.get(self._session_id)
if runtime is None:
raise RuntimeError("Finger runtime is not running")
runtime.tactile.reset()
@@ -166,9 +188,10 @@ class FingerAcquisitionWorker:
with self._lock:
self._phase = phase
def _close_runtime(self) -> None:
runtime = self._runtime
self._runtime = None
def _close_runtime(self, session: int) -> None:
"""Close the runtime opened by ``session`` (no-op if it owns none)."""
with self._lock:
runtime = self._runtimes.pop(session, None)
if runtime is None:
return
try:
@@ -176,13 +199,38 @@ class FingerAcquisitionWorker:
except Exception:
pass
def _close_all_runtimes(self) -> None:
with self._lock:
sessions = list(self._runtimes)
for session in sessions:
self._close_runtime(session)
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
try:
self._run_session(session, previous)
finally:
# The finishing thread owns the teardown — this is what lets
# ``stop()`` return immediately without leaking the runtime.
self._close_runtime(session)
if self._thread is threading.current_thread():
self._thread = None
with self._lock:
self._latest = None
self._phase = "idle"
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
from orisys.finger import FingerRuntime
if previous is not None and previous.is_alive():
# Wait here rather than on the GUI thread, so the runtime is never
# opened twice at the same time.
self._status("Waiting for the previous session to finish…")
previous.join(timeout=HANDOVER_TIMEOUT_S)
mode = self._mode
source = self._video_source
if mode is None or source is None:
@@ -197,15 +245,17 @@ class FingerAcquisitionWorker:
}
if self._export_dir is not None:
open_kw["export_dir"] = str(self._export_dir)
self._runtime = FingerRuntime.open(**open_kw)
runtime = FingerRuntime.open(**open_kw)
with self._lock:
self._runtimes[session] = runtime
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
self._close_runtime(session)
return
self._runtime.warmup(color=False)
runtime.warmup(color=False)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
self._close_runtime(session)
return
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
raw_sensor = getattr(runtime.tactile, "raw_sensor", None)
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
try:
raw_sensor.configure(
@@ -226,7 +276,6 @@ class FingerAcquisitionWorker:
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
runtime = self._runtime
while not self._stop.is_set() and session == self._session_id and runtime is not None:
try:
@@ -305,7 +354,7 @@ class FingerAcquisitionWorker:
self._on_error(str(exc))
break
self._close_runtime()
self._close_runtime(session)
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
@@ -19,6 +19,11 @@ from orisys_app_shared.acquisition.constants import (
VIDEO_EOF_TIMEOUT_S = 0.5
CAMERA_DISCONNECT_TIMEOUT_S = 1.5
#: How long a *blocking* ``stop(wait=True)`` may hold the caller.
STOP_JOIN_TIMEOUT_S = 5.0
#: How long a new session waits (in its own thread, never on the GUI) for a
#: previous session that is still winding down before opening the device.
HANDOVER_TIMEOUT_S = 60.0
@dataclass(frozen=True)
@@ -223,7 +228,9 @@ class SdkAcquisitionWorker:
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._sensor = None
# Keyed by session so a session that is still winding down can only
# ever close its own device, never the device of a newer session.
self._sensors: dict[int, object] = {}
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._end_reason: Optional[str] = None
@@ -255,7 +262,10 @@ class SdkAcquisitionWorker:
*,
config_ref: str | Path,
) -> FixedCameraMode:
# Non-blocking: a previous session that is stuck opening the device must
# never hold the caller (the GUI thread) hostage.
self.stop()
previous = self._thread
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
@@ -269,30 +279,47 @@ class SdkAcquisitionWorker:
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
thread = threading.Thread(
target=self._run,
name=f"SdkAcquisition-{session}",
daemon=True,
args=(session,),
args=(session, previous),
)
self._thread.start()
self._thread = thread
thread.start()
return mode
def stop(self) -> None:
def stop(self, *, wait: bool = False) -> None:
"""Ask the acquisition thread to stop.
The default is deliberately **non-blocking**: a device open that never
returns must not freeze the caller, and the worker thread owns its own
teardown. Use ``wait=True`` only on shutdown paths that must not leave a
device handle behind.
"""
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=5.0)
if wait and thread is not None and thread.is_alive():
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
if thread is None or not thread.is_alive():
self._settle(owner=thread)
# Otherwise the thread settles itself on the way out. Leaving
# ``self._thread`` set is what stops the next ``start`` from racing it.
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
"""Release worker state once no thread owns the device any more."""
if owner is not None and self._thread is not owner:
return # a newer session owns the worker now
self._thread = None
self._close_sensor()
self._close_all_sensors()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
sensor = self._sensor
sensor = self._sensors.get(self._session_id)
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
@@ -320,9 +347,10 @@ class SdkAcquisitionWorker:
with self._lock:
self._phase = phase
def _close_sensor(self) -> None:
sensor = self._sensor
self._sensor = None
def _close_sensor(self, session: int) -> None:
"""Disconnect the device opened by ``session`` (no-op if it owns none)."""
with self._lock:
sensor = self._sensors.pop(session, None)
if sensor is None:
return
try:
@@ -330,13 +358,38 @@ class SdkAcquisitionWorker:
except Exception:
pass
def _close_all_sensors(self) -> None:
with self._lock:
sessions = list(self._sensors)
for session in sessions:
self._close_sensor(session)
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
try:
self._run_session(session, previous)
finally:
# The finishing thread owns the teardown — this is what lets
# ``stop()`` return immediately without leaking the device.
self._close_sensor(session)
if self._thread is threading.current_thread():
self._thread = None
with self._lock:
self._latest = None
self._phase = "idle"
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
import orisys
if previous is not None and previous.is_alive():
# Wait here rather than on the GUI thread, so the device is never
# opened twice at the same time.
self._status("Waiting for the previous session to finish…")
previous.join(timeout=HANDOVER_TIMEOUT_S)
mode = self._mode
source = self._video_source
if mode is None or source is None:
@@ -344,16 +397,18 @@ class SdkAcquisitionWorker:
is_file = _is_file_source(source)
try:
self._status(f"Opening Orisys Sensor ({mode.label()})…")
self._sensor = orisys.Sensor(
sensor = orisys.Sensor(
source,
config_name=mode.config_ref,
verbose=False,
backend="auto",
)
with self._lock:
self._sensors[session] = sensor
if self._stop.is_set() or session != self._session_id:
self._close_sensor()
self._close_sensor(session)
return
self._sensor.configure(
sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
@@ -386,13 +441,13 @@ class SdkAcquisitionWorker:
break
t0 = time.perf_counter()
processed = self._sensor.get_img()
processed = sensor.get_img()
if processed is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
raw = getattr(self._sensor, "frame", None)
raw = getattr(sensor, "frame", None)
if raw is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
@@ -401,9 +456,9 @@ class SdkAcquisitionWorker:
none_streak_start = None
try:
self._consume_pending_calibration(self._sensor)
self._sensor.compute_deformation(decompose=True, check_motion=False)
self._sensor.compute_contact()
self._consume_pending_calibration(sensor)
sensor.compute_deformation(decompose=True, check_motion=False)
sensor.compute_contact()
except Exception:
pass
t1 = time.perf_counter()
@@ -411,7 +466,7 @@ class SdkAcquisitionWorker:
cutoff = t1 - 1.0
frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0
forces = read_forces_from_sensor(self._sensor, config_ref=mode.config_ref)
forces = read_forces_from_sensor(sensor, config_ref=mode.config_ref)
packet = SdkPacket(
raw_frame=_ensure_bgr(raw),
processed_frame=_ensure_bgr(processed),
@@ -441,7 +496,7 @@ class SdkAcquisitionWorker:
self._on_error(str(exc))
break
self._close_sensor()
self._close_sensor(session)
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
@@ -24,6 +24,11 @@ _DEFAULTS: dict[str, Any] = {
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
# Raw (uncalibrated) HHD units. Sized so the idle jitter is sub-pixel on
# both raw-mode products while never clipping: measured peaks are ~447k
# (mini, 365x365) and ~41k (finger, 280x300); idle sigma is ~80 (finger).
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"show_fps": False,