update filter, flow ema 0.5 + med 3
This commit is contained in:
+367
-40
@@ -26,8 +26,22 @@ 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,
|
||||
CausalMedianFilter,
|
||||
LowpassFilter,
|
||||
SampleRateEstimator,
|
||||
)
|
||||
|
||||
# Default signal-conditioning settings (config: top-level "filters" block).
|
||||
# Measured on dd02-0703 at 280x300 (see the noise analysis in the perf notes):
|
||||
# flow EMA 0.5 + median 3 costs ~2 frames at 30 fps, cuts steady-press force
|
||||
# jitter by roughly half and removes ~85% of the heavy-tailed spikes, while also
|
||||
# stabilising vnormal/contact (which a force-level filter cannot do).
|
||||
# The force low-pass is OFF by default: on top of the EMA it added no measurable
|
||||
# noise benefit at the same latency, so it is kept as an opt-in stage for sites
|
||||
# with different noise characteristics.
|
||||
DEFAULT_FORCE_LOWPASS_HZ = 0.0
|
||||
DEFAULT_FORCE_MEDIAN_WINDOW = 3
|
||||
DEFAULT_FLOW_EMA_ALPHA = 0.5
|
||||
from .capture import (
|
||||
CaptureConfig,
|
||||
CaptureSource,
|
||||
@@ -154,16 +168,92 @@ class Sensor:
|
||||
self.calibration: Optional[cal_mod.Calibration] = None
|
||||
self._force_cal_sensor_name: Optional[str] = None
|
||||
self._init_force_calibration()
|
||||
self._lowpass_enabled = False
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Signal conditioning (config: top-level "filters" block)
|
||||
# ---------------------------------------------------------------
|
||||
# These settings change the reported force values, so they are part of
|
||||
# the measurement-pipeline identity and are recorded in the calibration
|
||||
# metadata / mismatch check alongside dis_mode and hhd_downsample.
|
||||
# Filtering is ON by default; set filters.enabled=false (or a per-key
|
||||
# zero) to get the unfiltered chain.
|
||||
filters_cfg = self._config.get("filters")
|
||||
if not isinstance(filters_cfg, dict):
|
||||
filters_cfg = {}
|
||||
self._filters_enabled = bool(filters_cfg.get("enabled", True))
|
||||
|
||||
self._lowpass_order = 2
|
||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
self._lowpass_cutoff_hz = 0.0
|
||||
self._lowpass_rate_auto = True
|
||||
self._config_fps = 0.0
|
||||
try:
|
||||
self._config_fps = float(self.__config_cam.get("fps", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
self._config_fps = 0.0
|
||||
if self._config_fps <= 0:
|
||||
self._config_fps = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
try:
|
||||
self._lowpass_order = max(1, int(filters_cfg.get("force_lowpass_order", 2)))
|
||||
rate_cfg = filters_cfg.get("force_lowpass_sample_rate_hz", "auto")
|
||||
if isinstance(rate_cfg, str):
|
||||
self._lowpass_rate_auto = rate_cfg.strip().lower() in ("auto", "", "measured")
|
||||
else:
|
||||
rate = float(rate_cfg) if rate_cfg is not None else 0.0
|
||||
self._lowpass_rate_auto = rate <= 0.0
|
||||
if not self._lowpass_rate_auto:
|
||||
self._lowpass_sample_rate_hz = rate
|
||||
if self._filters_enabled:
|
||||
self._lowpass_cutoff_hz = float(
|
||||
filters_cfg.get("force_lowpass_cutoff_hz", DEFAULT_FORCE_LOWPASS_HZ)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._lowpass_order = 2
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
self._lowpass_rate_auto = True
|
||||
self._lowpass_cutoff_hz = (
|
||||
DEFAULT_FORCE_LOWPASS_HZ if self._filters_enabled else 0.0
|
||||
)
|
||||
if self._lowpass_sample_rate_hz <= 0:
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
if not (0.0 <= self._lowpass_cutoff_hz < 0.5 * self._lowpass_sample_rate_hz):
|
||||
self._lowpass_cutoff_hz = (
|
||||
DEFAULT_FORCE_LOWPASS_HZ
|
||||
if (self._filters_enabled and 0.0 < DEFAULT_FORCE_LOWPASS_HZ < 0.5 * self._lowpass_sample_rate_hz)
|
||||
else 0.0
|
||||
)
|
||||
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
||||
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,
|
||||
)
|
||||
# Rate tracking: only meaningful while the force low-pass is in use.
|
||||
self._force_rate = SampleRateEstimator(initial_hz=self._config_fps)
|
||||
self._force_rate_source = "config"
|
||||
|
||||
self._force_median_window = 1
|
||||
if self._filters_enabled:
|
||||
try:
|
||||
self._force_median_window = max(
|
||||
1, int(filters_cfg.get("force_median_window", DEFAULT_FORCE_MEDIAN_WINDOW))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._force_median_window = DEFAULT_FORCE_MEDIAN_WINDOW
|
||||
self._force_median = CausalMedianFilter(window=self._force_median_window, n_channels=3)
|
||||
|
||||
self._flow_ema_alpha = 1.0
|
||||
if self._filters_enabled:
|
||||
try:
|
||||
alpha = float(filters_cfg.get("flow_ema_alpha", DEFAULT_FLOW_EMA_ALPHA))
|
||||
except (TypeError, ValueError):
|
||||
alpha = DEFAULT_FLOW_EMA_ALPHA
|
||||
if 0.0 < alpha < 1.0:
|
||||
self._flow_ema_alpha = alpha
|
||||
self._ema_flow = None
|
||||
|
||||
if self.verbose:
|
||||
if self.calibration is not None and self.calibration.source_path is not None:
|
||||
print(f"Force calibration: {self.calibration.source_path}")
|
||||
@@ -182,6 +272,13 @@ class Sensor:
|
||||
self.valid_mask_raw = None
|
||||
self.valid_mask = None
|
||||
self.valid_mask_hhd = None
|
||||
# Scratch buffers reused by _compute_flow_means / compute_hhd.
|
||||
self._flow_mag_buf = None
|
||||
self._flow_mask_u8 = None
|
||||
self._hhd_vn_buf = None
|
||||
self._hhd_vs_buf = None
|
||||
self._hhd_flow_ds_buf = None
|
||||
self._hhd_mask_f32 = None
|
||||
self.mask_stats = {
|
||||
"coverage": 0.0,
|
||||
"flow_mean_valid": 0.0,
|
||||
@@ -194,10 +291,16 @@ class Sensor:
|
||||
self._last_capture_time = 0.0
|
||||
self._last_processing_time = 0.0
|
||||
self._last_stage_times = {
|
||||
"extract_tiles": 0.0,
|
||||
"stitch": 0.0,
|
||||
"resize": 0.0,
|
||||
"mirror": 0.0,
|
||||
# Frame-post-processing roll-ups (seconds).
|
||||
"extract_tiles": 0.0, # sensor units + tile extraction
|
||||
"stitch": 0.0, # feature/feather stitch of the extracted tiles
|
||||
"resize": 0.0, # BGR->gray + resize to resolution_stitch + mirror
|
||||
# Finer breakdown.
|
||||
"store": 0.0, # frame reference/copy
|
||||
"units": 0.0, # compound-eye unit (sub-image) extraction
|
||||
"gray": 0.0, # BGR->gray conversion
|
||||
"mirror": 0.0, # horizontal flip
|
||||
"mask": 0.0, # valid-mask resize/cache update
|
||||
"astype": 0.0,
|
||||
}
|
||||
|
||||
@@ -218,6 +321,7 @@ class Sensor:
|
||||
except (TypeError, ValueError):
|
||||
self._hhd_downsample = 1
|
||||
self._hhd_grid_shape = None
|
||||
self._hhd_ref_shape = None
|
||||
cfg_backend = self.__config_tracker.get("backend", None)
|
||||
self.poisson_backend = cfg_backend if cfg_backend is not None else backend
|
||||
self.masked_poisson_solver = self.__config_tracker.get("masked_poisson_solver", "sparse")
|
||||
@@ -244,6 +348,15 @@ class Sensor:
|
||||
print("Sensor initialized in external-frame mode.")
|
||||
else:
|
||||
self._open_video_source(override, box)
|
||||
if not self._is_live_camera:
|
||||
# A file source is replayed at whatever speed the CPU manages,
|
||||
# so wall-clock intervals do not reflect signal time: keep the
|
||||
# configured camera fps for the force low-pass design.
|
||||
self._lowpass_rate_auto = False
|
||||
self._force_rate_source = "config (file)"
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
if getattr(self, "_force_rate", None) is not None:
|
||||
self._force_rate.reset(self._config_fps)
|
||||
|
||||
if self.verbose:
|
||||
print(f" Video source: {vid_src}")
|
||||
@@ -253,6 +366,14 @@ class Sensor:
|
||||
# print(f" CUDA acceleration: {self.cuda}")
|
||||
print(f" CuPy available: {self.CUPY_AVAILABLE}")
|
||||
print(f" OpenCL available: {self.OPENCL_AVAILABLE}")
|
||||
print(
|
||||
" Signal filters: "
|
||||
f"ema_alpha={self._flow_ema_alpha} "
|
||||
f"median={self._force_median_window} "
|
||||
f"lowpass={self._lowpass_cutoff_hz}Hz@"
|
||||
f"{self._lowpass_sample_rate_hz:.1f}Hz "
|
||||
f"({self.force_sample_rate_source})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -313,17 +434,31 @@ class Sensor:
|
||||
def _compute_flow_means(self, flow):
|
||||
if flow is None:
|
||||
return
|
||||
mag = np.linalg.norm(flow, axis=-1)
|
||||
shape = flow.shape[:2]
|
||||
mag = self._flow_mag_buf
|
||||
if mag is None or mag.shape != shape or mag.dtype != np.float32:
|
||||
mag = np.empty(shape, dtype=np.float32)
|
||||
self._flow_mag_buf = mag
|
||||
# cv2.magnitude + cv2.mean(mask=...) avoid the (H, W) temporaries and the
|
||||
# boolean fancy-index gather that np.linalg.norm + mag[mask].mean() need.
|
||||
cv2.magnitude(flow[:, :, 0], flow[:, :, 1], mag)
|
||||
|
||||
mask = self.valid_mask
|
||||
if mask is None or mask.shape != mag.shape:
|
||||
self.mask_stats["flow_mean_valid"] = float(mag.mean()) if mag.size else 0.0
|
||||
if mask is None or mask.shape != shape:
|
||||
self.mask_stats["flow_mean_valid"] = float(cv2.mean(mag)[0]) if mag.size else 0.0
|
||||
self.mask_stats["flow_mean_invalid"] = 0.0
|
||||
return
|
||||
|
||||
valid = mask.astype(bool)
|
||||
invalid = ~valid
|
||||
self.mask_stats["flow_mean_valid"] = float(mag[valid].mean()) if np.any(valid) else 0.0
|
||||
self.mask_stats["flow_mean_invalid"] = float(mag[invalid].mean()) if np.any(invalid) else 0.0
|
||||
mask_u8 = self._flow_mask_u8
|
||||
if mask_u8 is None or mask_u8.shape != shape or mask_u8.dtype != np.uint8:
|
||||
mask_u8 = np.empty(shape, dtype=np.uint8)
|
||||
self._flow_mask_u8 = mask_u8
|
||||
np.copyto(mask_u8, mask, casting="unsafe")
|
||||
np.multiply(mask_u8, 255, out=mask_u8)
|
||||
|
||||
self.mask_stats["flow_mean_valid"] = float(cv2.mean(mag, mask=mask_u8)[0])
|
||||
np.subtract(255, mask_u8, out=mask_u8)
|
||||
self.mask_stats["flow_mean_invalid"] = float(cv2.mean(mag, mask=mask_u8)[0])
|
||||
|
||||
def get_img(self, frame=None, already_processed=False, valid_mask=None, already_gray=False):
|
||||
"""
|
||||
@@ -362,6 +497,7 @@ class Sensor:
|
||||
self.frame = frame if (already_processed and isinstance(frame, np.ndarray)) else (
|
||||
frame.copy() if isinstance(frame, np.ndarray) else frame
|
||||
)
|
||||
t_after_store = time.perf_counter()
|
||||
if already_processed:
|
||||
img_bgr = frame
|
||||
gray = None
|
||||
@@ -383,8 +519,6 @@ class Sensor:
|
||||
gray = None
|
||||
t_after_extract = time.perf_counter()
|
||||
|
||||
t_after_stitch = time.perf_counter()
|
||||
|
||||
if gray is None:
|
||||
self.img_size_raw = img_bgr.shape
|
||||
self._img_bgr_raw = img_bgr
|
||||
@@ -392,6 +526,8 @@ class Sensor:
|
||||
else:
|
||||
self.img_size_raw = gray.shape
|
||||
self._img_bgr_raw = None
|
||||
t_after_gray = time.perf_counter()
|
||||
|
||||
res = tuple(self._config_resolution_stitch)
|
||||
if self._img_gray_buf is None or self._img_gray_buf.shape != (res[1], res[0]):
|
||||
self._img_gray_buf = np.empty((res[1], res[0]), dtype=np.uint8)
|
||||
@@ -400,15 +536,22 @@ class Sensor:
|
||||
|
||||
if self.mirror:
|
||||
cv2.flip(self._img_gray_buf, 1, dst=self._img_gray_buf)
|
||||
t_after_mirror = time.perf_counter()
|
||||
|
||||
self.img = self._img_gray_buf
|
||||
self._prepare_valid_mask(valid_mask, gray.shape, self.img.shape)
|
||||
|
||||
t_processing_end = time.perf_counter()
|
||||
|
||||
self._last_processing_time = t_processing_end - t_processing_start
|
||||
self._last_stage_times["extract_tiles"] = t_after_extract - t_processing_start
|
||||
self._last_stage_times["stitch"] = t_after_stitch - t_after_extract
|
||||
self._last_stage_times["resize"] = t_processing_end - t_after_stitch
|
||||
self._record_stage_times(
|
||||
t_processing_start=t_processing_start,
|
||||
t_after_store=t_after_store,
|
||||
t_after_extract=t_after_extract,
|
||||
t_after_gray=t_after_gray,
|
||||
t_after_resize=t_after_resize,
|
||||
t_after_mirror=t_after_mirror,
|
||||
t_processing_end=t_processing_end,
|
||||
)
|
||||
|
||||
# FPS calculation
|
||||
self._fps = 1.0 / (time.perf_counter() - self.last_log_time)
|
||||
@@ -416,6 +559,48 @@ class Sensor:
|
||||
|
||||
return self.img.copy()
|
||||
|
||||
def _record_stage_times(
|
||||
self,
|
||||
*,
|
||||
t_processing_start,
|
||||
t_after_store,
|
||||
t_after_extract,
|
||||
t_after_gray,
|
||||
t_after_resize,
|
||||
t_after_mirror,
|
||||
t_processing_end,
|
||||
):
|
||||
"""Record per-stage frame-post-processing timings (seconds).
|
||||
|
||||
``extract_tiles``/``stitch`` come from the frame processor, which times
|
||||
its own sub-steps; the remaining keys are measured here. The roll-ups
|
||||
keep the historical key names while no longer collapsing ``stitch`` to
|
||||
zero: previously ``t_after_stitch`` was sampled immediately after
|
||||
``t_after_extract``, so ``stitch`` was always ~0 s and ``resize``
|
||||
silently absorbed the whole stitch cost.
|
||||
"""
|
||||
stage = self._last_stage_times
|
||||
processor_timings = getattr(self.frame_processor, "last_timings", None) or {}
|
||||
units_s = float(processor_timings.get("units", 0.0))
|
||||
extract_s = float(processor_timings.get("extract_tiles", 0.0))
|
||||
stitch_s = float(processor_timings.get("stitch", 0.0))
|
||||
processor_total_s = t_after_extract - t_after_store
|
||||
|
||||
stage["store"] = t_after_store - t_processing_start
|
||||
stage["units"] = units_s
|
||||
stage["gray"] = t_after_gray - t_after_extract
|
||||
stage["resize"] = t_after_resize - t_after_gray
|
||||
stage["mirror"] = t_after_mirror - t_after_resize
|
||||
stage["mask"] = t_processing_end - t_after_mirror
|
||||
|
||||
if processor_timings:
|
||||
stage["extract_tiles"] = units_s + extract_s
|
||||
stage["stitch"] = stitch_s
|
||||
else:
|
||||
# Non-compound-eye processors are a single opaque step.
|
||||
stage["extract_tiles"] = processor_total_s
|
||||
stage["stitch"] = 0.0
|
||||
|
||||
def _get_img_bgr(self):
|
||||
"""Produce a resized+mirrored BGR image on demand (for display)."""
|
||||
if self._img_bgr_raw is None:
|
||||
@@ -512,9 +697,73 @@ class Sensor:
|
||||
# flow = self.__tracker.track_raft(self.img)
|
||||
if hasattr(flow, 'download'):
|
||||
flow = flow.download()
|
||||
flow = self._apply_flow_ema(flow)
|
||||
self.flow = flow
|
||||
self._compute_flow_means(self.flow)
|
||||
|
||||
|
||||
def _apply_flow_ema(self, flow):
|
||||
"""Temporally smooth the optical-flow field before HHD.
|
||||
|
||||
The frame-to-frame jitter of the estimated flow is spatially coherent
|
||||
(a near-global shift: it survives ~40x less averaging than per-pixel
|
||||
noise would, see the noise analysis), so it cannot be removed by
|
||||
spatial smoothing, a coarser HHD grid or a different DIS setting - only
|
||||
by averaging along time. Applying it here also stabilises
|
||||
``vnormal``/contact, which a force-level filter cannot reach.
|
||||
|
||||
``flow_ema_alpha >= 1`` disables the stage.
|
||||
"""
|
||||
alpha = getattr(self, "_flow_ema_alpha", 1.0)
|
||||
if flow is None or alpha >= 1.0:
|
||||
return flow
|
||||
prev = self._ema_flow
|
||||
if prev is None or prev.shape != flow.shape or prev.dtype != flow.dtype:
|
||||
self._ema_flow = np.array(flow, copy=True)
|
||||
return flow
|
||||
prev += alpha * (flow - prev)
|
||||
return prev
|
||||
|
||||
def _update_force_sample_rate(self):
|
||||
"""Track the achieved force update rate for the low-pass design.
|
||||
|
||||
A Butterworth designed for 30 Hz stops meaning the same thing at 60 Hz,
|
||||
so when ``force_lowpass_sample_rate_hz`` is ``"auto"`` the rate actually
|
||||
achieved is used. For sources whose wall-clock cadence does not equal
|
||||
signal time (video files, offline replay) ``_lowpass_rate_auto`` is
|
||||
turned off by whoever knows the source - ``__init__`` for a capture the
|
||||
Sensor opened itself, and ``FingerRuntime.open`` for external frames -
|
||||
and the configured camera fps is used instead.
|
||||
"""
|
||||
if not getattr(self, "_lowpass_rate_auto", False):
|
||||
return
|
||||
estimator = getattr(self, "_force_rate", None)
|
||||
if estimator is None:
|
||||
return
|
||||
hz = estimator.tick()
|
||||
if not (1.0 <= hz <= 240.0):
|
||||
return
|
||||
self._force_rate_source = "measured"
|
||||
used = self._lowpass_sample_rate_hz
|
||||
if used > 0 and abs(hz - used) <= 0.1 * used:
|
||||
return
|
||||
self._lowpass_sample_rate_hz = hz
|
||||
if self._lowpass_enabled:
|
||||
self._force_filter.configure(
|
||||
cutoff_hz=self._lowpass_cutoff_hz,
|
||||
sample_rate_hz=hz,
|
||||
order=self._lowpass_order,
|
||||
)
|
||||
|
||||
@property
|
||||
def force_sample_rate_hz(self) -> float:
|
||||
"""Sample rate currently used to design the force low-pass (Hz)."""
|
||||
return float(self._lowpass_sample_rate_hz)
|
||||
|
||||
@property
|
||||
def force_sample_rate_source(self) -> str:
|
||||
"""``config``, ``measured`` or ``config (replay)``."""
|
||||
return str(getattr(self, "_force_rate_source", "config"))
|
||||
|
||||
def compute_hhd(self):
|
||||
|
||||
flow = self.flow
|
||||
@@ -543,7 +792,11 @@ class Sensor:
|
||||
if k > 1:
|
||||
ds_w = max(1, orig_w // k)
|
||||
ds_h = max(1, orig_h // k)
|
||||
flow_ds = cv2.resize(flow, (ds_w, ds_h), interpolation=cv2.INTER_AREA)
|
||||
flow_ds = self._hhd_flow_ds_buf
|
||||
if flow_ds is None or flow_ds.shape != (ds_h, ds_w, 2) or flow_ds.dtype != np.float32:
|
||||
flow_ds = np.empty((ds_h, ds_w, 2), dtype=np.float32)
|
||||
self._hhd_flow_ds_buf = flow_ds
|
||||
cv2.resize(flow, (ds_w, ds_h), dst=flow_ds, interpolation=cv2.INTER_AREA)
|
||||
if mask_ds is not None and (
|
||||
mask_ds.shape[1] != ds_w or mask_ds.shape[0] != ds_h
|
||||
):
|
||||
@@ -552,8 +805,13 @@ class Sensor:
|
||||
flow_ds = flow
|
||||
|
||||
ds_shape = flow_ds.shape[:2]
|
||||
ref_shape = (orig_h, orig_w)
|
||||
self.valid_mask_hhd = mask_ds
|
||||
if self.__hhd is None or self._hhd_grid_shape != ds_shape:
|
||||
if (
|
||||
self.__hhd is None
|
||||
or self._hhd_grid_shape != ds_shape
|
||||
or self._hhd_ref_shape != ref_shape
|
||||
):
|
||||
spacings = (18 * k, 18 * k)
|
||||
self.__hhd = nHHD(
|
||||
cuda=self.cuda,
|
||||
@@ -561,6 +819,9 @@ class Sensor:
|
||||
spacings=spacings,
|
||||
poisson_backend=self.poisson_backend,
|
||||
debug=self.debug,
|
||||
# Rescale grid-summed force aggregates back onto the full-resolution
|
||||
# flow grid so Sn/Stx/Sty/Stor stay comparable across hhd_downsample.
|
||||
force_reference_grid=ref_shape,
|
||||
masked_poisson_solver=self.masked_poisson_solver,
|
||||
masked_poisson_max_boundary=self.masked_poisson_max_boundary,
|
||||
masked_poisson_boundary_stride=self.masked_poisson_boundary_stride,
|
||||
@@ -569,29 +830,46 @@ class Sensor:
|
||||
masked_poisson_reg=self.masked_poisson_reg,
|
||||
)
|
||||
self._hhd_grid_shape = ds_shape
|
||||
self._hhd_ref_shape = ref_shape
|
||||
|
||||
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
|
||||
fn, fx, fy, ftor = forces
|
||||
self._force_raw = (float(fn), float(fx), float(fy))
|
||||
self.ftor = float(ftor)
|
||||
self._update_force_sample_rate()
|
||||
self._apply_force_filter()
|
||||
|
||||
vnormal_ds = self.__hhd.get_normal()
|
||||
vshear_ds = self.__hhd.get_shear()
|
||||
|
||||
if k > 1:
|
||||
self.vnormal = cv2.resize(vnormal_ds, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR)
|
||||
self.vshear = cv2.resize(vshear_ds, (orig_w, orig_h), interpolation=cv2.INTER_LINEAR)
|
||||
# Upsample into reusable buffers; the solver keeps owning its own
|
||||
# arrays, so nothing downstream can alias solver state.
|
||||
vn = self._hhd_vn_buf
|
||||
vs = self._hhd_vs_buf
|
||||
if vn is None or vn.shape != (orig_h, orig_w, 2) or vn.dtype != np.float32:
|
||||
vn = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
vs = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
self._hhd_vn_buf = vn
|
||||
self._hhd_vs_buf = vs
|
||||
cv2.resize(vnormal_ds, (orig_w, orig_h), dst=vn, interpolation=cv2.INTER_LINEAR)
|
||||
cv2.resize(vshear_ds, (orig_w, orig_h), dst=vs, interpolation=cv2.INTER_LINEAR)
|
||||
self.vnormal = vn
|
||||
self.vshear = vs
|
||||
else:
|
||||
self.vnormal = vnormal_ds
|
||||
self.vshear = vshear_ds
|
||||
self.vnormal = vnormal_ds.copy()
|
||||
self.vshear = vshear_ds.copy()
|
||||
|
||||
if self.valid_mask is not None:
|
||||
active = self.valid_mask.astype(bool)
|
||||
self.vnormal = self.vnormal.copy()
|
||||
self.vshear = self.vshear.copy()
|
||||
self.vnormal[~active] = 0
|
||||
self.vshear[~active] = 0
|
||||
# In-place multiply by a 0/1 float mask instead of
|
||||
# ``x.copy(); x[~active] = 0`` (copy + boolean gather/scatter).
|
||||
m3 = self._hhd_mask_f32
|
||||
if m3 is None or m3.shape != (orig_h, orig_w, 1) or m3.dtype != np.float32:
|
||||
m3 = np.empty((orig_h, orig_w, 1), dtype=np.float32)
|
||||
self._hhd_mask_f32 = m3
|
||||
np.copyto(m3[:, :, 0], self.valid_mask, casting="unsafe")
|
||||
np.multiply(self.vnormal, m3, out=self.vnormal)
|
||||
np.multiply(self.vshear, m3, out=self.vshear)
|
||||
|
||||
self.compute_deformation_valid = True
|
||||
|
||||
@@ -700,6 +978,9 @@ class Sensor:
|
||||
"""Reset optical flow tracker and force-filter state."""
|
||||
self.__tracker.reset()
|
||||
self._force_filter.reset()
|
||||
if getattr(self, "_force_median", None) is not None:
|
||||
self._force_median.reset()
|
||||
self._ema_flow = None
|
||||
if self.img is not None:
|
||||
self.initial_img = self.img.copy()
|
||||
|
||||
@@ -743,6 +1024,8 @@ class Sensor:
|
||||
lowpass_sample_rate_hz=None,
|
||||
lowpass_order=None,
|
||||
lowpass_enabled=None,
|
||||
flow_ema_alpha=None,
|
||||
force_median_window=None,
|
||||
hhd_downsample=None,
|
||||
):
|
||||
"""Update runtime-safe settings without reconnecting the camera.
|
||||
@@ -754,10 +1037,18 @@ class Sensor:
|
||||
value enables filtering unless ``lowpass_enabled=False``.
|
||||
lowpass_sample_rate_hz : float, optional
|
||||
Assumed force sample rate for the Butterworth design (default 30).
|
||||
Set this to the actual output rate of the deployment.
|
||||
lowpass_order : int, optional
|
||||
Butterworth order (default 2).
|
||||
lowpass_enabled : bool, optional
|
||||
Explicit on/off for the causal force filter.
|
||||
flow_ema_alpha : float, optional
|
||||
EMA coefficient applied to the optical-flow field before HHD.
|
||||
``1.0`` disables it; smaller values smooth more (0.5 is the default
|
||||
and costs about one frame of delay at 30 fps).
|
||||
force_median_window : int, optional
|
||||
Causal sliding-median width applied to the raw force triple before
|
||||
the low-pass. ``<= 1`` disables it; 3 is the default.
|
||||
hhd_downsample : int, optional
|
||||
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
|
||||
"""
|
||||
@@ -768,10 +1059,26 @@ class Sensor:
|
||||
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
|
||||
# Accept a positive number to pin the rate, or "auto"/<=0 to track
|
||||
# the measured output rate (see _update_force_sample_rate).
|
||||
auto = False
|
||||
rate = 0.0
|
||||
if isinstance(lowpass_sample_rate_hz, str):
|
||||
auto = lowpass_sample_rate_hz.strip().lower() in ("auto", "", "measured")
|
||||
else:
|
||||
try:
|
||||
rate = float(lowpass_sample_rate_hz)
|
||||
except (TypeError, ValueError):
|
||||
auto = True
|
||||
else:
|
||||
auto = rate <= 0.0
|
||||
self._lowpass_rate_auto = auto
|
||||
if not auto:
|
||||
self._lowpass_sample_rate_hz = rate
|
||||
elif self._lowpass_sample_rate_hz <= 0:
|
||||
self._lowpass_sample_rate_hz = self._config_fps
|
||||
if getattr(self, "_force_rate", None) is not None:
|
||||
self._force_rate.reset(self._config_fps)
|
||||
filter_changed = True
|
||||
if lowpass_order is not None:
|
||||
order = int(lowpass_order)
|
||||
@@ -783,6 +1090,22 @@ class Sensor:
|
||||
self._lowpass_enabled = bool(lowpass_enabled)
|
||||
filter_changed = True
|
||||
|
||||
if flow_ema_alpha is not None:
|
||||
alpha = float(flow_ema_alpha)
|
||||
if not (0.0 < alpha <= 1.0):
|
||||
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
||||
self._flow_ema_alpha = alpha
|
||||
self._ema_flow = None
|
||||
|
||||
if force_median_window is not None:
|
||||
try:
|
||||
window = max(1, int(force_median_window))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("force_median_window must be an integer >= 1") from exc
|
||||
self._force_median_window = window
|
||||
self._force_median.configure(window)
|
||||
filter_changed = True
|
||||
|
||||
if filter_changed:
|
||||
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
|
||||
self._force_filter.configure(
|
||||
@@ -802,6 +1125,7 @@ class Sensor:
|
||||
self._hhd_downsample = k
|
||||
self.__hhd = None
|
||||
self._hhd_grid_shape = None
|
||||
self._hhd_ref_shape = None
|
||||
self._update_hhd_mask_cache()
|
||||
return self
|
||||
|
||||
@@ -877,11 +1201,14 @@ class Sensor:
|
||||
|
||||
def _apply_force_filter(self):
|
||||
raw = np.asarray(self._force_raw, dtype=np.float64)
|
||||
# Median first: it deletes the heavy-tailed spikes that the flow chain
|
||||
# produces, so the low-pass does not have to spread them over time.
|
||||
median = getattr(self, "_force_median", None)
|
||||
if median is not None and median.enabled:
|
||||
raw = median.process(raw)
|
||||
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
|
||||
filtered = self._force_filter.process(raw)
|
||||
fn, fx, fy = (float(v) for v in filtered)
|
||||
else:
|
||||
fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2])
|
||||
raw = self._force_filter.process(raw)
|
||||
fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2])
|
||||
|
||||
# Apply loaded force_calibration.json when present; otherwise keep raw.
|
||||
if self.calibration is not None:
|
||||
|
||||
Reference in New Issue
Block a user