ee830d8505
把同帧测量、空载归零、模型推断与共用后处理收进一个 SDK 会话,Studio 显示与记录使用同一样本;旧 Sensor 读数接口保持兼容。以 force session 与受影响 SDK 套件、lint 与文档门禁验证,硬件与真实载荷精度未验证。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
559 lines
20 KiB
Python
559 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from ..capture import (
|
|
CaptureConfig,
|
|
CaptureSource,
|
|
ReconnectPolicy,
|
|
is_live_camera,
|
|
normalize_source,
|
|
open_capture,
|
|
)
|
|
from ..configs import load_config
|
|
from ..field_calibration import FIELD_IDENTITY, FIELD_PROFILE
|
|
from ..frame_processors import FrameUnwarper
|
|
from ..sensor import Sensor
|
|
from ..unroll import (
|
|
build_or_load_domain,
|
|
load_calib_json,
|
|
resolve_uv_runtime_sizes,
|
|
scale_camera_geometry,
|
|
)
|
|
from .exports import (
|
|
DEFAULT_FINGER_EXPORT_DIR,
|
|
DEFAULT_FINGER_MAP_CACHE,
|
|
FingerExportPaths,
|
|
load_finger_export_bundle,
|
|
)
|
|
|
|
|
|
def normalize_video_source(video):
|
|
"""Accept camera index, numeric string, or filesystem video path."""
|
|
return normalize_source(video)
|
|
|
|
|
|
def open_source(video, config, *, auto_reconnect: bool = False, verbose: bool = False):
|
|
"""Open a capture source for finger pipelines (compat: returns ``.cap``-like handle).
|
|
|
|
Historically returned a bare ``cv2.VideoCapture``. Callers that need the raw
|
|
OpenCV handle can use ``source.cap``; prefer ``source.read()``.
|
|
"""
|
|
capture_config = CaptureConfig.from_camera_dict(
|
|
normalize_video_source(video),
|
|
config.get("camera", {}),
|
|
reconnect=ReconnectPolicy(enabled=auto_reconnect),
|
|
verbose=verbose,
|
|
background=False,
|
|
)
|
|
source = open_capture(capture_config, start_background=False)
|
|
cap = source.cap
|
|
cap._orisys_worker = source # type: ignore[attr-defined]
|
|
return cap
|
|
|
|
|
|
class TactileProcessor:
|
|
"""Narrow tactile-analysis wrapper around ``Sensor`` external-frame mode."""
|
|
|
|
def __init__(self, sensor: Sensor, *, default_valid_mask=None):
|
|
self._sensor = sensor
|
|
self._default_valid_mask = default_valid_mask
|
|
|
|
@property
|
|
def raw_sensor(self) -> Sensor:
|
|
"""Escape hatch for legacy callers that still need the full ``Sensor``."""
|
|
return self._sensor
|
|
|
|
@property
|
|
def info(self):
|
|
return self._sensor.info
|
|
|
|
@property
|
|
def frame(self):
|
|
return self._sensor.frame
|
|
|
|
@property
|
|
def img(self):
|
|
return self._sensor.img
|
|
|
|
@property
|
|
def flow(self):
|
|
return self._sensor.flow
|
|
|
|
@property
|
|
def vnormal(self):
|
|
return self._sensor.vnormal
|
|
|
|
@property
|
|
def vshear(self):
|
|
return self._sensor.vshear
|
|
|
|
@property
|
|
def depth_map(self):
|
|
return self._sensor.depth_map
|
|
|
|
@property
|
|
def mask_stats(self):
|
|
return self._sensor.mask_stats
|
|
|
|
@property
|
|
def valid_mask(self):
|
|
return self._sensor.valid_mask
|
|
|
|
@property
|
|
def force_units(self) -> tuple[str, str, str]:
|
|
return self._sensor.force_units
|
|
|
|
@property
|
|
def calibration_status(self):
|
|
return self._sensor.calibration_status
|
|
|
|
@property
|
|
def calibration_result(self):
|
|
return self._sensor.calibration_result
|
|
|
|
def set_calibration_reference(self, raw, *, reference_id):
|
|
return self._sensor.set_calibration_reference(raw, reference_id=reference_id)
|
|
|
|
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
|
"""Feed one processed tactile frame into the analysis pipeline."""
|
|
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
|
return self._sensor.get_img(
|
|
frame=frame,
|
|
already_processed=True,
|
|
already_gray=already_gray,
|
|
valid_mask=mask,
|
|
)
|
|
|
|
def warmup(self):
|
|
return self._sensor.warmup_deformation()
|
|
|
|
def pipeline(self, produce, **kwargs):
|
|
"""Start a :class:`~orisys.pipeline.StreamPipeline` over this processor's own Sensor.
|
|
|
|
``produce`` is the frame source: a zero-argument callable returning the next unrolled
|
|
frame, or ``None`` at end of input. :class:`FingerRuntime` supplies it through
|
|
:meth:`FingerRuntime.pipeline`, so call this directly only when you are producing frames
|
|
yourself.
|
|
|
|
Any keyword argument is forwarded to ``StreamPipeline``, so ``contact=True`` and
|
|
``include=(...)`` work exactly as they do there.
|
|
"""
|
|
from orisys.pipeline import StreamPipeline
|
|
|
|
return StreamPipeline(self._sensor, produce, **kwargs)
|
|
|
|
def compute_deformation(self, *args, **kwargs):
|
|
return self._sensor.compute_deformation(*args, **kwargs)
|
|
|
|
def compute_contact(self, *args, **kwargs):
|
|
return self._sensor.compute_contact(*args, **kwargs)
|
|
|
|
def read_info(self, *args):
|
|
return self._sensor.read_info(*args)
|
|
|
|
def reset(self):
|
|
return self._sensor.reset()
|
|
|
|
def disconnect(self):
|
|
return self._sensor.disconnect()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FingerGeometry:
|
|
"""Resolved one-time geometry/bootstrap state for a finger UV runtime."""
|
|
|
|
config: dict
|
|
camera_config: dict
|
|
export_paths: FingerExportPaths
|
|
data: dict
|
|
source_K: np.ndarray
|
|
dist_coeffs: np.ndarray | None
|
|
native_width: int
|
|
native_height: int
|
|
source_width: int
|
|
source_height: int
|
|
out_width: int
|
|
out_height: int
|
|
source_scale: float
|
|
map_cache: str
|
|
edge_expand: float
|
|
|
|
|
|
def resolve_finger_geometry(
|
|
*,
|
|
export_dir,
|
|
config="finger",
|
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
|
source_scale=None,
|
|
calib=None,
|
|
) -> FingerGeometry:
|
|
"""Resolve config/export/camera geometry for a UV finger runtime."""
|
|
config = load_config(config)
|
|
camera_config = config.get("camera", {})
|
|
out_width, out_height, source_scale = resolve_uv_runtime_sizes(
|
|
camera_config,
|
|
source_scale=source_scale,
|
|
)
|
|
bundle = load_finger_export_bundle(
|
|
export_dir,
|
|
map_cache=map_cache,
|
|
source_scale=source_scale,
|
|
)
|
|
|
|
if calib:
|
|
source_K, dist_coeffs, img_size = load_calib_json(calib)
|
|
native_width, native_height = img_size
|
|
else:
|
|
source_K = bundle.data["K"]
|
|
dist_coeffs = None
|
|
native_width = int(bundle.data["width"])
|
|
native_height = int(bundle.data["height"])
|
|
|
|
source_K, source_width, source_height = scale_camera_geometry(
|
|
source_K,
|
|
native_width,
|
|
native_height,
|
|
source_scale,
|
|
)
|
|
|
|
return FingerGeometry(
|
|
config=config,
|
|
camera_config=camera_config,
|
|
export_paths=bundle.export_paths,
|
|
data=bundle.data,
|
|
source_K=source_K,
|
|
dist_coeffs=dist_coeffs,
|
|
native_width=native_width,
|
|
native_height=native_height,
|
|
source_width=source_width,
|
|
source_height=source_height,
|
|
out_width=out_width,
|
|
out_height=out_height,
|
|
source_scale=float(source_scale),
|
|
map_cache=bundle.map_cache,
|
|
edge_expand=bundle.edge_expand,
|
|
)
|
|
|
|
|
|
class FingerRuntime:
|
|
"""Reusable UV finger runtime composed from capture, UV remap, and tactile analysis."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
geometry: FingerGeometry,
|
|
domain,
|
|
tactile: TactileProcessor,
|
|
frame_processor: FrameUnwarper,
|
|
capture,
|
|
capture_source: CaptureSource | None = None,
|
|
):
|
|
self.geometry = geometry
|
|
self.domain = domain
|
|
self.tactile = tactile
|
|
self.sensor = tactile
|
|
self.frame_processor = frame_processor
|
|
self.capture = capture
|
|
self.capture_source = capture_source
|
|
# Deprecated alias.
|
|
self.capture_worker = capture_source
|
|
|
|
@classmethod
|
|
def open(
|
|
cls,
|
|
*,
|
|
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
|
config="finger",
|
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
|
source_scale=None,
|
|
calib=None,
|
|
cal_path=None,
|
|
calibration_dir=None,
|
|
device_id=None,
|
|
video=0,
|
|
camera_id=None,
|
|
flip_source_vertical=True,
|
|
verbose=False,
|
|
mirror=False,
|
|
auto_reconnect: bool = False,
|
|
capture_mode: str = "latest",
|
|
) -> FingerRuntime:
|
|
"""Open a live finger runtime from export bundle, config, and video source."""
|
|
del capture_mode # deprecated; latest-frame capture only
|
|
if camera_id is not None:
|
|
video = camera_id
|
|
video = normalize_video_source(video)
|
|
geometry = resolve_finger_geometry(
|
|
export_dir=export_dir,
|
|
config=config,
|
|
map_cache=map_cache,
|
|
source_scale=source_scale,
|
|
calib=calib,
|
|
)
|
|
|
|
mesh_path, intrinsics_path, pose_path = geometry.export_paths.as_tuple()
|
|
domain = build_or_load_domain(
|
|
mesh_path,
|
|
intrinsics_path,
|
|
pose_path,
|
|
geometry.map_cache,
|
|
geometry.source_K,
|
|
geometry.source_width,
|
|
geometry.source_height,
|
|
geometry.out_width,
|
|
geometry.out_height,
|
|
edge_expand=geometry.edge_expand,
|
|
include_surface=True,
|
|
verbose=verbose,
|
|
source_scale=geometry.source_scale,
|
|
)
|
|
domain.remap.ensure_fast_maps()
|
|
|
|
sensor = Sensor(
|
|
"external", config_name=config, cal_path=cal_path,
|
|
calibration_dir=calibration_dir, device_id=device_id,
|
|
verbose=verbose, mirror=mirror,
|
|
)
|
|
if getattr(getattr(sensor, "calibration", None), "schema_version", None) == 3:
|
|
from ..calibration_context import (
|
|
build_context,
|
|
domain_fingerprint,
|
|
sha256_file,
|
|
)
|
|
|
|
model = sensor.calibration
|
|
uv_map = getattr(model, "coordinate_map", None)
|
|
if model.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE):
|
|
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
|
|
raise ValueError("calibration coordinate map does not match finger output shape")
|
|
field_identity = FIELD_IDENTITY if model.input_profile == FIELD_PROFILE else None
|
|
sensor.bind_calibration_context(build_context(
|
|
sensor._config, sensor.filters_state(),
|
|
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
|
coordinates=model.pipeline.get("coordinates"),
|
|
output_domain_sha256=domain_fingerprint(domain),
|
|
processing_backend="uv_unroll",
|
|
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
|
field=field_identity,
|
|
))
|
|
|
|
capture_config = CaptureConfig.from_camera_dict(
|
|
video,
|
|
geometry.config.get("camera", {}),
|
|
reconnect=ReconnectPolicy(enabled=auto_reconnect),
|
|
verbose=verbose,
|
|
background=False,
|
|
)
|
|
capture_source = open_capture(capture_config, start_background=False)
|
|
if not capture_source.is_open():
|
|
raise RuntimeError("Cannot open camera/video source")
|
|
if is_live_camera(video):
|
|
capture_source.start_background()
|
|
capture = capture_source.cap
|
|
|
|
if not is_live_camera(video):
|
|
# File playback: wall-clock cadence is not signal time, so pin the
|
|
# force-filter design rate to the configured camera fps.
|
|
try:
|
|
fps = float(geometry.config.get("camera", {}).get("fps", 0.0) or 0.0)
|
|
except (TypeError, ValueError):
|
|
fps = 0.0
|
|
if fps > 0:
|
|
sensor.configure(lowpass_sample_rate_hz=fps)
|
|
frame_processor = FrameUnwarper(
|
|
domain.remap,
|
|
camera_matrix=geometry.source_K if geometry.dist_coeffs is not None else None,
|
|
dist_coeffs=geometry.dist_coeffs,
|
|
source_width=geometry.source_width,
|
|
source_height=geometry.source_height,
|
|
flip_source_vertical=flip_source_vertical,
|
|
)
|
|
tactile = TactileProcessor(sensor, default_valid_mask=frame_processor.valid_mask)
|
|
return cls(
|
|
geometry=geometry,
|
|
domain=domain,
|
|
tactile=tactile,
|
|
frame_processor=frame_processor,
|
|
capture=capture,
|
|
capture_source=capture_source,
|
|
)
|
|
|
|
@property
|
|
def valid_mask(self):
|
|
return self.frame_processor.valid_mask
|
|
|
|
def set_calibration_reference(self, raw, *, reference_id):
|
|
"""Set an explicitly measured unloaded reference for this finger stream."""
|
|
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id)
|
|
|
|
def set_calibration(self, calibration, *, device_id=None):
|
|
"""Replace the model at a caller-owned frame boundary, validating actual geometry."""
|
|
from .. import calibration as cal_mod
|
|
from ..calibration_context import build_context, domain_fingerprint, sha256_file
|
|
|
|
sensor = self.tactile.raw_sensor
|
|
selected = cal_mod.load(calibration, strict=True) if isinstance(calibration, (str, Path)) else calibration
|
|
serial = device_id or sensor.device_id
|
|
if selected is not None and serial:
|
|
selected = cal_mod.validate_for_device(selected, "finger", serial)
|
|
context = None
|
|
if getattr(selected, "schema_version", None) == 3:
|
|
if selected.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE):
|
|
uv_map = selected.coordinate_map
|
|
if uv_map is None or uv_map.shape[:2] != (self.geometry.out_height, self.geometry.out_width):
|
|
raise ValueError("calibration coordinate map does not match finger output shape")
|
|
field_identity = FIELD_IDENTITY if selected.input_profile == FIELD_PROFILE else None
|
|
context = build_context(
|
|
self.geometry.config, sensor.filters_state(),
|
|
geometry_sha256=sha256_file(self.geometry.export_paths.mesh),
|
|
source_scale=self.geometry.source_scale, coordinates=selected.pipeline.get("coordinates"),
|
|
output_domain_sha256=domain_fingerprint(self.domain), processing_backend="uv_unroll",
|
|
preprocessing={"flip_source_vertical": self.frame_processor.flip_source_vertical,
|
|
"mirror": sensor.mirror},
|
|
field=field_identity)
|
|
sensor.set_calibration(selected, context=context)
|
|
|
|
def declare_unloaded_reference(self, reference_id):
|
|
"""Declare the unloaded window for a field model; the SDK medians 30 weighted frames."""
|
|
self.tactile.raw_sensor.declare_unloaded_reference(reference_id=reference_id)
|
|
|
|
@property
|
|
def calibration_status(self):
|
|
"""Copy the current per-axis calibration validity state."""
|
|
return dict(self.tactile.raw_sensor.calibration_status)
|
|
|
|
def read_source(self):
|
|
if self.capture_source is not None and self.capture_source.running:
|
|
return self.capture_source.read(copy=True, wait_s=1.0)
|
|
if self.capture_source is not None:
|
|
return self.capture_source.read_raw()
|
|
return self.capture.read()
|
|
|
|
def read_source_stamped(self):
|
|
"""Read the source image with its capture-thread identity and host clocks."""
|
|
if self.capture_source is None:
|
|
return None
|
|
if self.capture_source.running:
|
|
return self.capture_source.read_latest_stamped(copy=True, wait_s=1.0)
|
|
return self.capture_source.read_raw_stamped()
|
|
|
|
def process_frame(self, frame, *, color=False):
|
|
"""Prepare, unroll, and feed one source frame into the tactile processor."""
|
|
if color:
|
|
prepared = self.frame_processor.prepare_source(frame)
|
|
unrolled = self.frame_processor.flatten_prepared(prepared)
|
|
self.tactile.push_frame(unrolled)
|
|
return unrolled
|
|
|
|
prepared_gray = self.frame_processor.prepare_source_gray(frame)
|
|
unrolled = self.frame_processor.flatten_prepared_gray(prepared_gray)
|
|
self.tactile.push_frame(unrolled, already_gray=True)
|
|
return unrolled
|
|
|
|
# ------------------------------------------- session measurement seam
|
|
def enable_measurement_session(self) -> None:
|
|
"""Hand force readout to a ``ForceSession`` (the explicit new entry)."""
|
|
self.tactile.raw_sensor.enable_measurement_session()
|
|
|
|
def disable_measurement_session(self) -> None:
|
|
"""Return to the historical automatic model/readout path."""
|
|
self.tactile.raw_sensor.disable_measurement_session()
|
|
|
|
def reset_tracker(self) -> None:
|
|
"""Reset the tactile tracker at a frame boundary (session zeroing half)."""
|
|
self.tactile.raw_sensor.reset_tracker()
|
|
|
|
def read_measurement_frame(
|
|
self,
|
|
source_frame,
|
|
*,
|
|
frame_id: int,
|
|
timestamp_s: float | None = None,
|
|
want_field: bool = False,
|
|
want_contact: bool = False,
|
|
):
|
|
"""Unroll one source frame and measure it once, without calibration."""
|
|
del timestamp_s # signal time is carried by the caller's FrameStamp
|
|
self.process_frame(source_frame)
|
|
return self.tactile.raw_sensor.measure_current_frame(
|
|
frame_id=frame_id,
|
|
want_field=want_field,
|
|
want_contact=want_contact,
|
|
)
|
|
|
|
def current_measurement(
|
|
self, *, want_field: bool = False, want_contact: bool = False
|
|
):
|
|
"""Same-frame measurement features already produced by the pipeline (no recompute).
|
|
|
|
Additive: forwards to the tactile sensor's session seam so a pipeline
|
|
consumer can hand the frame to ``ForceSession`` without a second
|
|
unroll/flow/HHD pass.
|
|
"""
|
|
return self.tactile.raw_sensor.current_measurement(
|
|
want_field=want_field, want_contact=want_contact
|
|
)
|
|
|
|
@property
|
|
def calibration_context(self):
|
|
"""The pipeline context the open model was validated against, if any."""
|
|
return self.tactile.raw_sensor.calibration_context
|
|
|
|
@property
|
|
def calibration(self):
|
|
"""The force calibration the open runtime holds (additive read-only accessor)."""
|
|
return getattr(self.tactile.raw_sensor, "calibration", None)
|
|
|
|
def grab_and_process(self, *, color=False):
|
|
ok, frame = self.read_source()
|
|
if not ok:
|
|
return False, None
|
|
return True, self.process_frame(frame, color=color)
|
|
|
|
def get_img(self, *, color=False):
|
|
"""Sensor-style alias: capture, unroll, and return the tactile frame or None."""
|
|
ok, unrolled = self.grab_and_process(color=color)
|
|
return unrolled if ok else None
|
|
|
|
def warmup(self, *, color=False):
|
|
ok, unrolled = self.grab_and_process(color=color)
|
|
if ok:
|
|
self.tactile.warmup()
|
|
return ok, unrolled
|
|
|
|
def pipeline(self, **kwargs):
|
|
"""Return a :class:`~orisys.pipeline.StreamPipeline` for this runtime.
|
|
|
|
The recommended way to run a finger sensor. Each finished frame arrives as a single
|
|
:class:`~orisys.pipeline.FrameSnapshot`, so there are no stage calls to sequence and no
|
|
tuple to unpack::
|
|
|
|
with runtime.pipeline(contact=True, include=("image", "depth")) as pipe:
|
|
while True:
|
|
snap = pipe.wait()
|
|
if snap is None:
|
|
break
|
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
|
|
|
Any keyword argument is forwarded to ``StreamPipeline``. Use ``contact=True`` to run
|
|
contact analysis, and ``include=(...)`` to select which arrays each snapshot carries.
|
|
"""
|
|
from orisys.pipeline import StreamPipeline
|
|
|
|
return StreamPipeline.for_finger_runtime(self, **kwargs)
|
|
|
|
def close(self):
|
|
if self.capture_source is not None:
|
|
self.capture_source.close()
|
|
self.capture_source = None
|
|
self.capture_worker = None
|
|
self.capture = None
|
|
elif self.capture is not None:
|
|
self.capture.release()
|
|
self.capture = None
|
|
self.tactile.disconnect()
|