支持传感器独立校准文件与批量配置启动

保留逐台添加,并通过显式配置一次连接全组;失败时撤销本批新增连接。通过 35 项回归测试、真实 SDK 校准与 1–4 路验证、Ruff 和文档检查。
This commit is contained in:
ykcheungab
2026-10-14 16:11:15 +08:00
parent 133a98e418
commit a723e96dcc
7 changed files with 460 additions and 24 deletions
+78 -3
View File
@@ -7,6 +7,7 @@ SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration units, not assumed Newtons.
from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass
@@ -51,6 +52,7 @@ class FingerSensorBank:
self.sources = {}
self.configs = {}
self.runtimes = {}
self.calibration_paths = {}
self.verbose = verbose
@staticmethod
@@ -61,7 +63,7 @@ class FingerSensorBank:
return f"camera:{int(source[len('/dev/video'):])}"
return str(Path(source).resolve())
def add(self, source: Source, *, config: str | None = None) -> int:
def add(self, source: Source, *, config: str | None = None, cal_path: str | None = None) -> int:
"""Open/prepare one source. Failure preserves existing sensors and IDs."""
source = parse_source(str(source))
if len(self.runtimes) >= 4:
@@ -73,12 +75,22 @@ class FingerSensorBank:
raise ValueError("source already added")
if is_video_file(source) and not Path(source).is_file():
raise ValueError(f"file not found: {source}")
if cal_path is not None:
if not str(cal_path).strip():
raise ValueError("empty calibration path")
calibration = Path(cal_path).expanduser().resolve()
if not calibration.is_file():
raise ValueError(f"calibration file not found: {calibration}")
cal_path = str(calibration)
from orisys.finger import FingerRuntime
config = self.default_config if config is None else config
runtime = None
try:
runtime = FingerRuntime.open(config=config, video=source, verbose=self.verbose)
calibration_options = {"cal_path": cal_path} if cal_path is not None else {}
runtime = FingerRuntime.open(
config=config, video=source, verbose=self.verbose, **calibration_options,
)
ok, _ = runtime.warmup(color=False)
if not ok:
raise RuntimeError("warmup could not read a frame")
@@ -93,8 +105,64 @@ class FingerSensorBank:
self.sources[sensor_id] = source
self.configs[sensor_id] = config
self.runtimes[sensor_id] = runtime
self.calibration_paths[sensor_id] = cal_path
return sensor_id
def add_setup(self, path: str | Path) -> tuple[int, ...]:
"""Add an explicit JSON setup; rollback only this batch on failure.
Relative file paths are based on the setup file, not the process cwd.
Schema/identity validation precedes opening any camera.
"""
path = Path(path).expanduser().resolve()
document = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(document, dict) or set(document) != {"sensors"}:
raise ValueError("setup must contain only a sensors list")
sensors = document["sensors"]
if not isinstance(sensors, list) or not 1 <= len(sensors) <= 4:
raise ValueError("setup must contain 1 to 4 sensors")
if len(self.runtimes) + len(sensors) > 4:
raise ValueError("maximum 4 sensors including those already added")
prepared = []
identities = {self._source_identity(source) for source in self.sources.values()}
for sensor in sensors:
if (not isinstance(sensor, dict) or "source" not in sensor
or set(sensor) - {"source", "config", "calibration"}):
raise ValueError("sensor fields: source, optional config and calibration")
if type(sensor["source"]) not in (int, str):
raise ValueError("source must be a camera index or path")
source = parse_source(str(sensor["source"]))
if isinstance(source, int) and source < 0:
raise ValueError("camera index must be non-negative")
if is_video_file(source):
source = str((path.parent / Path(source).expanduser()).resolve())
identity = self._source_identity(source)
if identity in identities:
raise ValueError("source already added or repeated in setup")
identities.add(identity)
config = sensor.get("config")
calibration = sensor.get("calibration")
for name, value in (("config", config), ("calibration", calibration)):
if value is not None and (not isinstance(value, str) or not value.strip()):
raise ValueError(f"{name} must be a non-empty string or null")
if config is not None and (Path(config).suffix.lower() == ".json" or "/" in config or "\\" in config):
config = str((path.parent / Path(config).expanduser()).resolve())
if calibration is not None:
calibration = str((path.parent / Path(calibration).expanduser()).resolve())
prepared.append({"source": source, "config": config, "cal_path": calibration})
added = []
try:
for sensor in prepared:
added.append(self.add(**sensor))
except BaseException:
for sensor_id in added:
try:
self._close_one(sensor_id)
except Exception:
logger.exception("Setup rollback failed for sensor %s", sensor_id)
raise
return tuple(added)
def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
"""Read a complete round. EOF/failure raises; no partial round is returned.
@@ -138,11 +206,18 @@ class FingerSensorBank:
units = getattr(tactile, "force_units", ("unknown", "unknown", "unknown"))
return ForceReading(sensor_id, *forces, tuple(units), image)
def _close_one(self, sensor_id):
runtime = self.runtimes.pop(sensor_id)
self.sources.pop(sensor_id)
self.configs.pop(sensor_id)
self.calibration_paths.pop(sensor_id)
runtime.close()
def close(self) -> None:
first_error = None
for sensor_id in list(self.runtimes):
try:
self.runtimes.pop(sensor_id).close()
self._close_one(sensor_id)
except Exception as exc:
logger.exception("Sensor %s cleanup failed", sensor_id)
first_error = first_error or exc