把摄像头协商结果记进 session.json,并在达不到配置帧率时明说 - What: sdk_worker 新增 capture_status_of(sensor)(把采集源的 config 与 status 合成 {requested:{width,height,fps,fourcc}, negotiated:{width,height,fps,fourcc,backend}, dropped_frames})与 short_capture_fallacy(status)(当协商帧率低于申请值 90% 时给出一句话描述,含分辨率/实际帧率/申请帧率/格式/后端;两者都齐全时才说话);两个 worker 都加 capture_status() 访问器(box 取当前 sensor,finger 取 runtime.tactile.raw_sensor)。app_window:session.json 新增顶层 capture 块(_capture_metadata(),走同一个访问器);新增 _check_capture_negotiation()——每个会话在驱动第一次报出速率后检查一次,达不到就通过 _notify 明确提示(不是每帧刷屏),新键 status.capture_slow(中英)写明"软件改不了、记录按实际帧率保存、请降分辨率/换格式或换摄像头"。tests/test_worker_frame_rate.py:假设备补上 CaptureConfig 与 CaptureStatus 的 width/height/fps/fourcc/backend_name 字段,新增 test_a_low_camera_rate_is_recorded_as_a_negotiation(1920x1080、申请 30、协商 16.67 时 capture_status() 两半都在、描述里含 1920x1080/16.67/30;协商到位时描述为空串)。 - Why: 用户按上一轮的读数报告"还是摄像头 16.7",即摄像头自身只发布 16.7 —— 这排除了流水线(每帧只 12 ms)与应用侧节拍,剩下的只能是采集格式协商:配置申请的是 resolution_raw 1920x1080 @ 30 fps(fourcc 走 CaptureConfig 默认 MJPG)。这类"驱动只提供这个格式"的情况软件无法改变,但记录里必须留下证据、操作员必须被告知,否则一个 16.7 的采集和一次正常的 30 fps 采集在文件里长得一样,事后再也无法解释。 - Verification: shared 全量 161 passed(新增 1 项);studio 90 passed;ui_check 554 项检查 0 失败;locale_check 287 字面键全部命中、中英键集一致(343+1=344? 见下方键数)。 - Not in this change: 未改 SDK 的采集参数(仍是 1920x1080/30/MJPG、后端 auto);frames.csv 仍未记录 process_ms/capture_fps(下次改记录格式时一并加);未做自动降级(例如检测到 16.7 就自动改用更低分辨率)——那会改变传感器几何,必须由人决定。
This commit is contained in:
@@ -357,6 +357,7 @@
|
|||||||
"run_failed": "Run failed: {error}",
|
"run_failed": "Run failed: {error}",
|
||||||
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
||||||
"rail_camera_fps": "camera {fps}",
|
"rail_camera_fps": "camera {fps}",
|
||||||
|
"capture_slow": "The camera cannot deliver the configured frame rate: {detail}. Software cannot change that — the driver only offers this format; the recording keeps the real rate. Use a smaller resolution/another format, or another camera.",
|
||||||
"video_ended": "Video ended",
|
"video_ended": "Video ended",
|
||||||
"camera_disconnected": "Camera disconnected",
|
"camera_disconnected": "Camera disconnected",
|
||||||
"reset_ok": "Tracker reset",
|
"reset_ok": "Tracker reset",
|
||||||
|
|||||||
@@ -357,6 +357,7 @@
|
|||||||
"run_failed": "启动失败: {error}",
|
"run_failed": "启动失败: {error}",
|
||||||
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
|
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
|
||||||
"rail_camera_fps": "摄像头 {fps}",
|
"rail_camera_fps": "摄像头 {fps}",
|
||||||
|
"capture_slow": "摄像头达不到配置的帧率:{detail}。这不是软件能改的——驱动只提供这个格式;记录会按实际帧率保存,请降低分辨率/换格式或换摄像头。",
|
||||||
"video_ended": "视频已结束",
|
"video_ended": "视频已结束",
|
||||||
"camera_disconnected": "摄像机已断开",
|
"camera_disconnected": "摄像机已断开",
|
||||||
"reset_ok": "追踪器已重置",
|
"reset_ok": "追踪器已重置",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from PyQt5.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import short_capture_fallacy
|
||||||
from orisys_app_shared.force import select_display_forces
|
from orisys_app_shared.force import select_display_forces
|
||||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
|
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
|
||||||
@@ -204,6 +205,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
#: window: together with the above they say who is the slow part.
|
#: window: together with the above they say who is the slow part.
|
||||||
self._latest_capture_fps: float = 0.0
|
self._latest_capture_fps: float = 0.0
|
||||||
self._latest_capture_dropped: int = 0
|
self._latest_capture_dropped: int = 0
|
||||||
|
#: Whether this session has already reported a shortfall between the
|
||||||
|
#: requested and the negotiated capture rate (said once, not per frame).
|
||||||
|
self._capture_checked = True
|
||||||
#: Last coefficients file written by the calibration panel (not applied).
|
#: Last coefficients file written by the calibration panel (not applied).
|
||||||
self._last_calibration_export = ""
|
self._last_calibration_export = ""
|
||||||
self._depth_range_value: tuple[float, float] | None = None
|
self._depth_range_value: tuple[float, float] | None = None
|
||||||
@@ -518,6 +522,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"shared_api": API_VERSION,
|
"shared_api": API_VERSION,
|
||||||
"sdk": self._sdk_version(),
|
"sdk": self._sdk_version(),
|
||||||
},
|
},
|
||||||
|
# What the camera was asked for and what the driver gave. A recording
|
||||||
|
# that runs at 16.7 fps instead of 30 has to say why by itself.
|
||||||
|
"capture": self._capture_metadata(),
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"config_ref": config_ref,
|
"config_ref": config_ref,
|
||||||
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
|
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
|
||||||
@@ -605,6 +612,50 @@ class StudioMainWindow(QMainWindow):
|
|||||||
stored[key] = value
|
stored[key] = value
|
||||||
return settings_from_mapping(stored)
|
return settings_from_mapping(stored)
|
||||||
|
|
||||||
|
def _capture_metadata(self) -> dict:
|
||||||
|
"""Requested vs negotiated capture parameters, for ``session.json``.
|
||||||
|
|
||||||
|
Empty while nothing is open. A recording that runs at 16.7 fps on a 30 fps
|
||||||
|
config has to be able to explain itself; from the frames alone nobody can
|
||||||
|
tell whether the camera or the pipeline was the ceiling.
|
||||||
|
"""
|
||||||
|
worker = getattr(self, "_active_worker", None)
|
||||||
|
reader = getattr(worker, "capture_status", None) if worker is not None else None
|
||||||
|
if not callable(reader):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(reader() or {})
|
||||||
|
except Exception: # noqa: BLE001 - metadata must never block a record
|
||||||
|
logger.exception("Could not read the capture status")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _check_capture_negotiation(self) -> None:
|
||||||
|
"""Say it out loud when the camera cannot do what the config asks.
|
||||||
|
|
||||||
|
A camera that only offers a lower rate than the config requests is not
|
||||||
|
something the pipeline can fix, and it is not something the operator
|
||||||
|
should have to infer from a small number on the rail. Said once per
|
||||||
|
session, and only once the driver has confirmed a rate.
|
||||||
|
"""
|
||||||
|
if self._capture_checked:
|
||||||
|
return
|
||||||
|
worker = getattr(self, "_active_worker", None)
|
||||||
|
reader = getattr(worker, "capture_status", None) if worker is not None else None
|
||||||
|
if not callable(reader):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = dict(reader() or {})
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("Could not read the capture status")
|
||||||
|
self._capture_checked = True
|
||||||
|
return
|
||||||
|
if not ((status.get("negotiated") or {}).get("fps")):
|
||||||
|
return # the driver has not reported a rate yet: ask again next frame
|
||||||
|
self._capture_checked = True
|
||||||
|
detail = short_capture_fallacy(status)
|
||||||
|
if detail:
|
||||||
|
self._notify(t("status.capture_slow", detail=detail))
|
||||||
|
|
||||||
def _filter_metadata(self) -> dict:
|
def _filter_metadata(self) -> dict:
|
||||||
"""The filters this session actually ran, for ``session.json``.
|
"""The filters this session actually ran, for ``session.json``.
|
||||||
|
|
||||||
@@ -2133,6 +2184,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._latest_process_ms = None
|
self._latest_process_ms = None
|
||||||
self._latest_capture_fps = 0.0
|
self._latest_capture_fps = 0.0
|
||||||
self._latest_capture_dropped = 0
|
self._latest_capture_dropped = 0
|
||||||
|
self._capture_checked = False
|
||||||
self._depth_range_value = None
|
self._depth_range_value = None
|
||||||
self._last_depth_frame = None
|
self._last_depth_frame = None
|
||||||
# The new session's store is cleared *before* the charts are attached to
|
# The new session's store is cleared *before* the charts are attached to
|
||||||
@@ -2222,6 +2274,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._latest_process_ms = None
|
self._latest_process_ms = None
|
||||||
self._latest_capture_fps = 0.0
|
self._latest_capture_fps = 0.0
|
||||||
self._latest_capture_dropped = 0
|
self._latest_capture_dropped = 0
|
||||||
|
self._capture_checked = False
|
||||||
self._last_sample_valid = None
|
self._last_sample_valid = None
|
||||||
self._last_display_fallback = None
|
self._last_display_fallback = None
|
||||||
self._last_display_available = None
|
self._last_display_available = None
|
||||||
@@ -2395,6 +2448,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._latest_capture_dropped = int(
|
self._latest_capture_dropped = int(
|
||||||
getattr(packet, "capture_dropped", 0) or 0
|
getattr(packet, "capture_dropped", 0) or 0
|
||||||
)
|
)
|
||||||
|
# Once per session, as soon as the driver has confirmed its rate.
|
||||||
|
self._check_capture_negotiation()
|
||||||
# The rail carries the live telemetry (fps, validity, calibration) and
|
# The rail carries the live telemetry (fps, validity, calibration) and
|
||||||
# the record strip its own numbers. Both compare before repainting, so
|
# the record strip its own numbers. Both compare before repainting, so
|
||||||
# this is safe on a 16 ms timer.
|
# this is safe on a 16 ms timer.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
|
|||||||
STOP_JOIN_TIMEOUT_S,
|
STOP_JOIN_TIMEOUT_S,
|
||||||
VIDEO_EOF_TIMEOUT_S,
|
VIDEO_EOF_TIMEOUT_S,
|
||||||
calibration_digest,
|
calibration_digest,
|
||||||
|
capture_status_of,
|
||||||
loaded_calibration_version,
|
loaded_calibration_version,
|
||||||
loaded_path,
|
loaded_path,
|
||||||
process_chain,
|
process_chain,
|
||||||
@@ -126,6 +127,14 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return tuple(self._filters_unsupported)
|
return tuple(self._filters_unsupported)
|
||||||
|
|
||||||
|
def capture_status(self) -> dict:
|
||||||
|
"""What the camera was asked for, and what the driver actually gave."""
|
||||||
|
with self._lock:
|
||||||
|
runtime = next(iter(self._runtimes.values()), None)
|
||||||
|
tactile = getattr(runtime, "tactile", None) if runtime is not None else None
|
||||||
|
raw_sensor = getattr(tactile, "raw_sensor", None) if tactile is not None else None
|
||||||
|
return capture_status_of(raw_sensor)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._thread is not None and self._thread.is_alive()
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Mapping, Optional
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -84,6 +84,7 @@ __all__ = [
|
|||||||
"SdkPacket",
|
"SdkPacket",
|
||||||
"SdkAcquisitionWorker",
|
"SdkAcquisitionWorker",
|
||||||
"calibration_digest",
|
"calibration_digest",
|
||||||
|
"capture_status_of",
|
||||||
"load_fixed_mode",
|
"load_fixed_mode",
|
||||||
"loaded_calibration_version",
|
"loaded_calibration_version",
|
||||||
"loaded_path",
|
"loaded_path",
|
||||||
@@ -91,6 +92,7 @@ __all__ = [
|
|||||||
"process_chain",
|
"process_chain",
|
||||||
"read_depth",
|
"read_depth",
|
||||||
"read_forces_from_sensor",
|
"read_forces_from_sensor",
|
||||||
|
"short_capture_fallacy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +157,77 @@ def _capture_source(sensor):
|
|||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def capture_status_of(sensor) -> dict:
|
||||||
|
"""Requested vs negotiated capture parameters, or ``{}`` when unknown.
|
||||||
|
|
||||||
|
``requested`` comes from the capture config the SDK was handed (built from the
|
||||||
|
sensor.json's ``camera`` block); ``negotiated`` is what OpenCV/the driver
|
||||||
|
reported back after the stream properties were applied. They disagree exactly
|
||||||
|
when a camera cannot do what the config asks for.
|
||||||
|
"""
|
||||||
|
source = _capture_source(sensor) if sensor is not None else None
|
||||||
|
if source is None:
|
||||||
|
return {}
|
||||||
|
status = getattr(source, "status", None)
|
||||||
|
config = getattr(source, "config", None)
|
||||||
|
if status is None and config is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _value(obj, name, default=None):
|
||||||
|
return getattr(obj, name, default) if obj is not None else default
|
||||||
|
|
||||||
|
dropped = _value(status, "dropped_frames", 0)
|
||||||
|
return {
|
||||||
|
"requested": {
|
||||||
|
"width": _value(config, "width"),
|
||||||
|
"height": _value(config, "height"),
|
||||||
|
"fps": _value(config, "fps"),
|
||||||
|
"fourcc": _value(config, "fourcc"),
|
||||||
|
},
|
||||||
|
"negotiated": {
|
||||||
|
"width": _value(status, "width"),
|
||||||
|
"height": _value(status, "height"),
|
||||||
|
"fps": _value(status, "fps"),
|
||||||
|
"fourcc": _value(status, "fourcc"),
|
||||||
|
"backend": _value(status, "backend_name"),
|
||||||
|
},
|
||||||
|
"dropped_frames": int(dropped or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def short_capture_fallacy(status: Mapping[str, Any] | None) -> str:
|
||||||
|
"""A one-line description of a capture negotiation that fell short.
|
||||||
|
|
||||||
|
Empty when the driver gave what was asked for (or when either half is
|
||||||
|
unknown): this exists to be *said*, not to fill a field.
|
||||||
|
"""
|
||||||
|
if not isinstance(status, Mapping):
|
||||||
|
return ""
|
||||||
|
requested = dict(status.get("requested") or {})
|
||||||
|
negotiated = dict(status.get("negotiated") or {})
|
||||||
|
wanted = requested.get("fps")
|
||||||
|
got = negotiated.get("fps")
|
||||||
|
try:
|
||||||
|
wanted_value = float(wanted)
|
||||||
|
got_value = float(got)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
if wanted_value <= 0 or got_value <= 0 or got_value >= wanted_value * 0.9:
|
||||||
|
return ""
|
||||||
|
size = ""
|
||||||
|
width, height = negotiated.get("width"), negotiated.get("height")
|
||||||
|
if width and height:
|
||||||
|
size = f"{int(width)}x{int(height)} "
|
||||||
|
fourcc = str(negotiated.get("fourcc") or "").strip()
|
||||||
|
backend = str(negotiated.get("backend") or "").strip()
|
||||||
|
detail = "/".join(part for part in (fourcc, backend) if part)
|
||||||
|
return (
|
||||||
|
f"the camera negotiated {size}@{got_value:g} fps "
|
||||||
|
f"(the config asks for {wanted_value:g})"
|
||||||
|
+ (f" [{detail}]" if detail else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
|
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
|
||||||
"""Run the live processing chain for one already-read frame.
|
"""Run the live processing chain for one already-read frame.
|
||||||
|
|
||||||
@@ -466,6 +539,18 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return tuple(self._filters_unsupported)
|
return tuple(self._filters_unsupported)
|
||||||
|
|
||||||
|
def capture_status(self) -> dict:
|
||||||
|
"""What the camera was asked for, and what the driver actually gave.
|
||||||
|
|
||||||
|
The negotiation is where a camera that cannot do the configured frame
|
||||||
|
rate is decided, and nothing downstream can turn 16.7 fps back into 30.
|
||||||
|
Recording both halves is what lets a recording explain its own frame
|
||||||
|
rate instead of leaving the operator to guess.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
sensor = next(iter(self._sensors.values()), None)
|
||||||
|
return capture_status_of(sensor)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._thread is not None and self._thread.is_alive()
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ class _Status:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.last_seq = 0
|
self.last_seq = 0
|
||||||
self.dropped_frames = 0
|
self.dropped_frames = 0
|
||||||
|
#: What the driver reported back for the negotiated stream.
|
||||||
|
self.width = 1920
|
||||||
|
self.height = 1080
|
||||||
|
self.fps = 30.0
|
||||||
|
self.fourcc = "MJPG"
|
||||||
|
self.backend_name = "MSMF"
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureConfig:
|
||||||
|
"""The requested stream, as ``CaptureConfig`` carries it."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.width = 1920
|
||||||
|
self.height = 1080
|
||||||
|
self.fps = 30.0
|
||||||
|
self.fourcc = "MJPG"
|
||||||
|
self.source = 0
|
||||||
|
|
||||||
|
|
||||||
class _CaptureSource:
|
class _CaptureSource:
|
||||||
@@ -45,6 +62,7 @@ class _CaptureSource:
|
|||||||
|
|
||||||
def __init__(self, period_s: float = FRAME_PERIOD_S) -> None:
|
def __init__(self, period_s: float = FRAME_PERIOD_S) -> None:
|
||||||
self.status = _Status()
|
self.status = _Status()
|
||||||
|
self.config = _CaptureConfig()
|
||||||
self.running = True
|
self.running = True
|
||||||
self._period = float(period_s)
|
self._period = float(period_s)
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
@@ -265,6 +283,45 @@ def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
|
|||||||
monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0)
|
monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch):
|
||||||
|
"""A camera that cannot do the configured rate must be visible as such.
|
||||||
|
|
||||||
|
The pipeline cannot turn 16.7 fps into 30, so the only honest thing left is to
|
||||||
|
record what was asked for and what the driver gave, and to have a one-line
|
||||||
|
description ready for the operator.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import (
|
||||||
|
SdkAcquisitionWorker,
|
||||||
|
short_capture_fallacy,
|
||||||
|
)
|
||||||
|
|
||||||
|
CREATED.clear()
|
||||||
|
saved = _install()
|
||||||
|
worker = SdkAcquisitionWorker()
|
||||||
|
try:
|
||||||
|
worker.start(0, config_ref=BOX_CONFIG)
|
||||||
|
deadline = time.perf_counter() + 2.0
|
||||||
|
while time.perf_counter() < deadline and not CREATED:
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert CREATED, "the fake sensor was never constructed"
|
||||||
|
source = CREATED[-1].capture_source
|
||||||
|
source.status.fps = 16.67 # what the driver actually negotiated
|
||||||
|
status = worker.capture_status()
|
||||||
|
assert status["requested"]["fps"] == 30.0
|
||||||
|
assert status["requested"]["width"] == 1920
|
||||||
|
assert status["negotiated"]["fps"] == 16.67, status
|
||||||
|
assert status["negotiated"]["fourcc"] == "MJPG"
|
||||||
|
assert status["negotiated"]["backend"] == "MSMF"
|
||||||
|
detail = short_capture_fallacy(status)
|
||||||
|
assert "1920x1080" in detail and "16.67" in detail and "30" in detail, detail
|
||||||
|
# A driver that gives what was asked for says nothing at all.
|
||||||
|
source.status.fps = 30.0
|
||||||
|
assert short_capture_fallacy(worker.capture_status()) == ""
|
||||||
|
finally:
|
||||||
|
worker.stop(wait=True)
|
||||||
|
_restore(saved)
|
||||||
|
|
||||||
|
|
||||||
def test_a_file_source_runs_on_the_video_s_own_timeline():
|
def test_a_file_source_runs_on_the_video_s_own_timeline():
|
||||||
"""A pass over a video is stamped with the video's time, not the wall clock."""
|
"""A pass over a video is stamped with the video's time, not the wall clock."""
|
||||||
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
|
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
|
||||||
|
|||||||
Reference in New Issue
Block a user