included low pass filter in package and add to plot_data example

This commit is contained in:
ykcheungab
2026-08-17 22:11:17 +08:00
parent 9a69f01278
commit 2e09c9360e
50 changed files with 1055 additions and 1873 deletions
+6 -2
View File
@@ -5,13 +5,14 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition.
"""
__version__ = "0.4.2"
__version__ = "0.4.3"
__author__ = "Orisys"
from .sensor import Sensor
from . import util
from . import configs
from . import finger
from . import signal_processing
from .frame_processors import FFCFrameProcessor
CompoundEyeSensor = Sensor
@@ -33,9 +34,12 @@ __all__ = [
# finger runtime helpers
'finger',
# signal helpers
'signal_processing',
# configs utilities functions
'list_config',
'export_config'
'export_config',
# test
'FFCFrameProcessor'
+3 -2
View File
@@ -2,7 +2,8 @@ import json
from pathlib import Path
# Product-facing names that map to on-disk config stems.
# ORY-FINGER currently ships as dd02-ov.json; keep "dd02" as the stable alias.
# Public product stems are ``box.json`` and ``finger.json``.
# Keep older lab names working: ``dd02`` still resolves to ``dd02-ov``.
CONFIG_ALIASES = {
"dd02": "dd02-ov",
}
@@ -55,7 +56,7 @@ def load_config(config_name_or_path):
Config lookup order:
1. Exact file path (absolute or relative)
2. File stem under the config directory (e.g. ``ddjx01`` -> ``config/ddjx01.json``)
2. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
"""
config_path = Path(config_name_or_path)
+2 -2
View File
@@ -146,7 +146,7 @@ class FingerGeometry:
def resolve_finger_geometry(
*,
export_dir,
config="./config/dd02-ov.json",
config="finger",
map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None,
calib=None,
@@ -223,7 +223,7 @@ class FingerRuntime:
cls,
*,
export_dir=DEFAULT_FINGER_EXPORT_DIR,
config="./config/dd02-ov.json",
config="finger",
map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None,
calib=None,
+106 -3
View File
@@ -23,6 +23,7 @@ from . import configs
from .get_depth import Contact3D
from .backend_availability import (check_cupy_available, check_opencl_available)
from .frame_processors import create_frame_processor
from .signal_processing import DEFAULT_SAMPLE_RATE_HZ, LowpassFilter
class Sensor:
"""
@@ -46,6 +47,9 @@ class Sensor:
FNORMAL = auto()
FSHEARX = auto()
FSHEARY = auto()
FNORMAL_RAW = auto()
FSHEARX_RAW = auto()
FSHEARY_RAW = auto()
CENTROID = auto()
CONTOUR = auto()
DEPTH = auto()
@@ -126,6 +130,17 @@ class Sensor:
self.vshear = self.vtemplate
self.n_boxes = None
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
self._force_raw = (0.0, 0.0, 0.0)
self._lowpass_enabled = False
self._lowpass_cutoff_hz = 0.0
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
self._lowpass_order = 2
self._force_filter = LowpassFilter(
cutoff_hz=self._lowpass_cutoff_hz,
sample_rate_hz=self._lowpass_sample_rate_hz,
order=self._lowpass_order,
n_channels=3,
)
# Contact Data Variables (public)
self.centroid = (0, 0)
@@ -529,7 +544,10 @@ class Sensor:
self._hhd_grid_shape = ds_shape
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
self.fnormal, self.fshearx, self.fsheary, self.ftor = forces
fn, fx, fy, ftor = forces
self._force_raw = (float(fn), float(fx), float(fy))
self.ftor = float(ftor)
self._apply_force_filter()
vnormal_ds = self.__hhd.get_normal()
vshear_ds = self.__hhd.get_shear()
@@ -631,6 +649,9 @@ class Sensor:
self.info.FNORMAL: lambda: self.fnormal,
self.info.FSHEARX: lambda: self.fshearx,
self.info.FSHEARY: lambda: self.fsheary,
self.info.FNORMAL_RAW: lambda: self._force_raw[0],
self.info.FSHEARX_RAW: lambda: self._force_raw[1],
self.info.FSHEARY_RAW: lambda: self._force_raw[2],
self.info.FPS: lambda: self._fps,
self.info.TIMESTAMP: lambda: time.strftime('%H:%M:%S'),
# Return copies of arrays to prevent external modification
@@ -649,8 +670,9 @@ class Sensor:
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
def reset(self):
"""Reset optical flow tracker."""
"""Reset optical flow tracker and force-filter state."""
self.__tracker.reset()
self._force_filter.reset()
if self.img is not None:
self.initial_img = self.img.copy()
@@ -679,7 +701,88 @@ class Sensor:
# =========================================================================
# Public API - Configuration & Calibration
# =========================================================================
def configure(
self,
*,
lowpass_cutoff_hz=None,
lowpass_sample_rate_hz=None,
lowpass_order=None,
lowpass_enabled=None,
hhd_downsample=None,
):
"""Update runtime-safe settings without reconnecting the camera.
Parameters
----------
lowpass_cutoff_hz : float, optional
Force low-pass cutoff in Hz. ``0`` disables filtering. A positive
value enables filtering unless ``lowpass_enabled=False``.
lowpass_sample_rate_hz : float, optional
Assumed force sample rate for the Butterworth design (default 30).
lowpass_order : int, optional
Butterworth order (default 2).
lowpass_enabled : bool, optional
Explicit on/off for the causal force filter.
hhd_downsample : int, optional
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
"""
filter_changed = False
if lowpass_cutoff_hz is not None:
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
filter_changed = True
if lowpass_enabled is None:
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
if lowpass_sample_rate_hz is not None:
rate = float(lowpass_sample_rate_hz)
if rate <= 0:
raise ValueError("lowpass_sample_rate_hz must be > 0")
self._lowpass_sample_rate_hz = rate
filter_changed = True
if lowpass_order is not None:
order = int(lowpass_order)
if order < 1:
raise ValueError("lowpass_order must be >= 1")
self._lowpass_order = order
filter_changed = True
if lowpass_enabled is not None:
self._lowpass_enabled = bool(lowpass_enabled)
filter_changed = True
if filter_changed:
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
self._force_filter.configure(
cutoff_hz=cutoff,
sample_rate_hz=self._lowpass_sample_rate_hz,
order=self._lowpass_order,
)
if self.compute_deformation_valid:
self._apply_force_filter()
if hhd_downsample is not None:
try:
k = max(1, int(hhd_downsample))
except (TypeError, ValueError) as exc:
raise ValueError("hhd_downsample must be an integer >= 1") from exc
if k != self._hhd_downsample:
self._hhd_downsample = k
self.__hhd = None
self._hhd_grid_shape = None
self._update_hhd_mask_cache()
return self
def _apply_force_filter(self):
raw = np.asarray(self._force_raw, dtype=np.float64)
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
filtered = self._force_filter.process(raw)
self.fnormal, self.fshearx, self.fsheary = (float(v) for v in filtered)
else:
self.fnormal, self.fshearx, self.fsheary = (
float(raw[0]),
float(raw[1]),
float(raw[2]),
)
# def export_config(self, path="./config/exported_config.json"):
# """Return current configuration dict."""
# configs.export_config(self._config, path)
+139
View File
@@ -0,0 +1,139 @@
"""Signal helpers for force filtering and normal-force calibration.
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
"""
from __future__ import annotations
import numpy as np
from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi
# Linear calibration from raw normal-force units to Newtons (ORY-BOX / ORY-MINI).
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
DEFAULT_SAMPLE_RATE_HZ = 30.0
DEFAULT_LOWPASS_ORDER = 2
def calibrate_normal_force(raw_value: float) -> float:
"""Convert a raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
def _nyquist_hz(sample_rate_hz: float) -> float:
return 0.5 * float(sample_rate_hz)
def _validate_cutoff(cutoff_hz: float, sample_rate_hz: float) -> None:
nyquist = _nyquist_hz(sample_rate_hz)
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
def lowpass_filter(
data,
cutoff_hz: float = 5.0,
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
order: int = DEFAULT_LOWPASS_ORDER,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data.
Matches the example/viewer chart filter: ``cutoff_hz == 0`` is a no-op.
"""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
_validate_cutoff(cutoff_hz, sample_rate_hz)
b, a = butter(int(order), cutoff_hz / _nyquist_hz(sample_rate_hz), btype="low")
padlen = 3 * (max(len(a), len(b)) - 1)
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()
class LowpassFilter:
"""Causal Butterworth low-pass with persistent SOS state (one sample at a time)."""
def __init__(
self,
cutoff_hz: float = 0.0,
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
order: int = DEFAULT_LOWPASS_ORDER,
n_channels: int = 3,
):
self.n_channels = int(n_channels)
self._sos = None
self._zi = None
self.configure(
cutoff_hz=cutoff_hz,
sample_rate_hz=sample_rate_hz,
order=order,
)
@property
def enabled(self) -> bool:
return self._sos is not None
def configure(
self,
*,
cutoff_hz: float | None = None,
sample_rate_hz: float | None = None,
order: int | None = None,
) -> None:
if cutoff_hz is not None:
self.cutoff_hz = float(cutoff_hz)
if sample_rate_hz is not None:
self.sample_rate_hz = float(sample_rate_hz)
if order is not None:
self.order = int(order)
if self.cutoff_hz <= 0:
self._sos = None
self._zi = None
return
_validate_cutoff(self.cutoff_hz, self.sample_rate_hz)
self._sos = butter(
self.order,
self.cutoff_hz / _nyquist_hz(self.sample_rate_hz),
btype="low",
output="sos",
)
self._zi = None
def reset(self, x0=None) -> None:
"""Clear filter memory. Optional ``x0`` initializes to a steady-state DC."""
if self._sos is None:
self._zi = None
return
if x0 is None:
self._zi = None
return
x = np.asarray(x0, dtype=np.float64).reshape(-1)
if x.size != self.n_channels:
raise ValueError(f"x0 must have {self.n_channels} channels, got {x.size}")
zi0 = sosfilt_zi(self._sos)
self._zi = zi0[:, :, None] * x[None, None, :]
def process(self, values) -> np.ndarray:
"""Filter one multi-channel sample. Returns float64 array of shape (n_channels,)."""
x = np.asarray(values, dtype=np.float64).reshape(-1)
if x.size != self.n_channels:
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
if self._sos is None:
return x.copy()
if self._zi is None:
self.reset(x)
y, self._zi = sosfilt(self._sos, x[None, :], zi=self._zi, axis=0)
return np.asarray(y[0], dtype=np.float64)