From 0beb36a1af46d575b3346a4eda95973e575aa262 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 10:35:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BB=A4=E6=B3=A2=E9=A2=84=E8=AE=BE=E4=B8=8E?= =?UTF-8?q?=E7=94=9F=E6=95=88=E5=80=BC=E5=87=BA=E5=8F=A3=EF=BC=9AFILTER=5F?= =?UTF-8?q?PRESETS=20+=20filters=5Fstate()=20+=20force=5Ftriplets()=20-=20?= =?UTF-8?q?What:=20=E6=96=B0=E5=A2=9E=20FILTER=5FPRESETS=EF=BC=88off/low?= =?UTF-8?q?=5Flatency/balanced/low=5Fnoise=EF=BC=8C=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E5=8F=96=E8=87=AA=20=5Fperf=20=E7=9A=84=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E8=A1=A8=EF=BC=8Clow=5Fnoise=20=E5=90=AB=2010=20Hz=20=E4=BD=8E?= =?UTF-8?q?=E9=80=9A=EF=BC=89=E4=B8=8E=20available=5Ffilter=5Fpresets()=20?= =?UTF-8?q?=E4=BE=9B=20UI=20=E5=8F=96=E7=94=A8=EF=BC=9B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=AF=E5=86=99=20filters.preset=20=E4=B8=94=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E9=94=AE=E4=BC=98=E5=85=88=E4=BA=8E=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E5=80=BC=EF=BC=9B=E6=96=B0=E5=A2=9E=20Sensor.filters=5Fstate()?= =?UTF-8?q?=EF=BC=88preset=20+=20measurement=20=E7=BB=84=20flow=5Fema=5Fal?= =?UTF-8?q?pha/flow=5Fema=5Fapplied/hhd=5Fdownsample/dis=5Fmode=20+=20read?= =?UTF-8?q?out=20=E7=BB=84=E4=B8=AD=E5=80=BC=E4=B8=8E=E4=BD=8E=E9=80=9A?= =?UTF-8?q?=E7=9A=84=E7=AA=97=E5=8F=A3/=E6=88=AA=E6=AD=A2/=E9=98=B6?= =?UTF-8?q?=E6=95=B0/=E9=87=87=E6=A0=B7=E7=8E=87/=E6=98=AF=E5=90=A6=20auto?= =?UTF-8?q?/=E6=98=AF=E5=90=A6=E7=94=9F=E6=95=88=20+=20sample=5Frate=5Fsou?= =?UTF-8?q?rce=20+=20any=5Fstage=5Fapplied=20+=20source=3D"sensor.filters?= =?UTF-8?q?=5Fstate"=EF=BC=89=E4=BD=9C=E4=B8=BA=E5=94=AF=E4=B8=80"?= =?UTF-8?q?=E7=94=9F=E6=95=88=E5=80=BC"=E5=87=BA=E5=8F=A3=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Sensor.force=5Ftriplets()=20=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E8=BF=94=E5=9B=9E=20{filtered:{raw,newton},=20unfilte?= =?UTF-8?q?red:{raw,newton}}=EF=BC=8C=E5=B9=B6=E5=9C=A8=20=5Fapply=5Fforce?= =?UTF-8?q?=5Ffilter=20=E4=B8=AD=E4=BA=8E=E9=93=BE=E5=90=8E=E3=80=81?= =?UTF-8?q?=E6=A0=87=E5=AE=9A=E5=89=8D=E4=BF=9D=E5=AD=98=20=5Fforce=5Ffilt?= =?UTF-8?q?ered=5Fraw=EF=BC=88=E4=BF=AE=E6=AD=A3=EF=BC=9A=E6=AD=A4?= =?UTF-8?q?=E5=89=8D=E6=BB=A4=E6=B3=A2=E5=90=8E=E7=9A=84=20raw=20=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E6=B2=A1=E6=9C=89=E4=BF=9D=E5=AD=98=EF=BC=8Cforce=5Ft?= =?UTF-8?q?riplets=20=E7=9A=84=20filtered.raw=20=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=9A=84=E6=98=AF=E8=BE=93=E5=85=A5=EF=BC=89?= =?UTF-8?q?=EF=BC=9Breset()=20=E6=B8=85=E7=A9=BA=E6=9C=AA=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E7=BC=93=E5=AD=98=E3=80=82=20-=20Why:=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A6=81=E6=B1=82=E9=85=8D=E7=BD=AE=E8=87=AA=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=EF=BC=88A=EF=BC=89=E5=B9=B6=E6=8A=8A"=E7=94=9F?= =?UTF-8?q?=E6=95=88=E5=80=BC"=E4=BD=9C=E4=B8=BA=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E4=BE=9D=E6=8D=AE=EF=BC=88B=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E8=A6=81=E4=BF=9D=E7=95=99=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E5=89=8D=E6=95=B0=E6=8D=AE=E4=BB=A5=E4=BE=BF"?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20buffer=E3=80=81=E4=B8=A4=E4=B8=AA=E5=8D=95?= =?UTF-8?q?=E4=BD=8D=E9=83=BD=E4=BF=9D=E7=95=99"=EF=BC=9B=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E8=AF=81=E5=AE=9E=E5=B9=B6=E4=BF=AE=E6=8E=89=E4=BA=86?= =?UTF-8?q?=20filtered.raw=20=E6=8C=87=E5=90=91=E8=BE=93=E5=85=A5=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E6=A0=87=E7=AD=BE=E9=94=99=E8=AF=AF=E3=80=82=20-=20Ve?= =?UTF-8?q?rification:=20external-frame=20=E6=A8=A1=E5=BC=8F=E9=80=90?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E6=9E=84=E9=80=A0=E7=9C=9F=E5=AE=9E=20Sensor?= =?UTF-8?q?=20=E6=89=93=E5=8D=B0=20filters=5Fstate=EF=BC=9Aoff/low=5Flaten?= =?UTF-8?q?cy/balanced/low=5Fnoise=20=E5=88=86=E5=88=AB=E4=B8=BA=20(ema=20?= =?UTF-8?q?1.0=20off,=20median=201=20off,=20lp=200.0=20off)=20/=20(1.0=20o?= =?UTF-8?q?ff,=203=20on,=200.0=20off)=20/=20(0.5=20on,=203=20on,=200.0=20o?= =?UTF-8?q?ff)=20/=20(0.3=20on,=203=20on,=2010.0=20on)=EF=BC=9B=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=2010,10,10,999,10,10=20=E8=A7=82=E5=AF=9F=E5=8A=9B?= =?UTF-8?q?=E4=B8=89=E5=85=83=E7=BB=84=EF=BC=8Cfiltered.raw=20=E5=9C=A8?= =?UTF-8?q?=E5=B0=96=E5=B3=B0=E5=B8=A7=E4=B8=BA=2010.0=E3=80=81unfiltered.?= =?UTF-8?q?raw=20=E4=B8=BA=20999.0=EF=BC=8C=E8=AF=81=E6=98=8E=E4=B8=AD?= =?UTF-8?q?=E5=80=BC=E7=94=9F=E6=95=88=E4=B8=94=E6=9C=AA=E6=BB=A4=E6=B3=A2?= =?UTF-8?q?=20buffer=20=E5=AE=8C=E6=95=B4=EF=BC=9Bcompileall=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E3=80=82=20-=20Not=20in=20this=20change:=20calibratio?= =?UTF-8?q?n.py=20=E6=8C=87=E7=BA=B9=E5=B0=9A=E6=9C=AA=E6=94=B9=E8=AF=BB?= =?UTF-8?q?=20filters=5Fstate()=EF=BC=8Cexamples/filters=20=E4=B8=8E=20doc?= =?UTF-8?q?s/filters.md=20=E6=9C=AA=E5=8A=A0=EF=BC=8Cvenv=20=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20SDK=20=E6=9C=AA=E5=8D=87=E7=BA=A7=EF=BC=88app=20?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E4=B8=8D=E5=8F=98=EF=BC=8C=E4=BB=8D=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=2010=20Hz=20=E4=BD=8E=E9=80=9A=EF=BC=89?= =?UTF-8?q?=EF=BC=8Capp=20=E4=BE=A7=20wrapper/=E9=BD=BF=E8=BD=AE/CSV=2017?= =?UTF-8?q?=20=E5=88=97=E6=9C=AA=E5=8A=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/orisys/sensor.py | 146 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 3 deletions(-) diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index 1c901be..28751cd 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -42,6 +42,48 @@ from .signal_processing import ( DEFAULT_FORCE_LOWPASS_HZ = 0.0 DEFAULT_FORCE_MEDIAN_WINDOW = 3 DEFAULT_FLOW_EMA_ALPHA = 0.5 + +#: Named filter presets, so a recommendation is *executable* instead of a +#: comment in a JSON file (``json.load`` cannot carry comments). A config may +#: write ``"filters": {"preset": "balanced"}`` and override any single key on +#: top of it; the resolved values are what ``Sensor.filters_state()`` reports. +#: The latency/noise trade-offs behind these numbers are measured in +#: ``_perf/`` and tabulated in ``docs/filters.md``. +FILTER_PRESETS = { + # Everything off: the file says what runs, and the raw reading is the record. + "off": { + "flow_ema_alpha": 1.0, + "force_median_window": 1, + "force_lowpass_cutoff_hz": 0.0, + }, + # One frame of latency @30 fps: median only. The EMA changes the + # measurement, so it stays off when latency matters more than jitter. + "low_latency": { + "flow_ema_alpha": 1.0, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + }, + # Two frames @30 fps: the EMA removes the spatially-coherent flow jitter + # that no force-level filter can reach, the median removes its spikes. + "balanced": { + "flow_ema_alpha": 0.5, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + }, + # Three frames @30 fps and the quietest readout: as ``balanced`` plus the + # force low-pass on top of the median. + "low_noise": { + "flow_ema_alpha": 0.3, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 10.0, + }, +} + + +def available_filter_presets() -> dict: + """The presets, for UIs that want to offer them (the studio's gear dialog).""" + return {name: dict(values) for name, values in FILTER_PRESETS.items()} + from .capture import ( CaptureConfig, CaptureSource, @@ -188,12 +230,17 @@ class Sensor: if not isinstance(filters_cfg, dict): filters_cfg = {} legacy_off = filters_cfg.get("enabled", None) is False + preset_name = filters_cfg.get("preset", None) + preset_values = FILTER_PRESETS.get(str(preset_name), {}) if preset_name else {} + self._filter_preset = str(preset_name) if preset_name in FILTER_PRESETS else None def _cfg(key, default): - """The configured value, or the documented default when unset.""" + """The configured value: an explicit key, else the preset, else default.""" if legacy_off: return default value = filters_cfg.get(key, None) + if value is None: + value = preset_values.get(key, None) return default if value is None else value self._lowpass_order = 2 @@ -1014,6 +1061,10 @@ class Sensor: if getattr(self, "_force_median", None) is not None: self._force_median.reset() self._ema_flow = None + # The unfiltered pair belongs to the frame it was computed for: a new + # session must not report the previous one's reading. + self._force_unfiltered = None + self._force_raw_unfiltered = None if self.img is not None: self.initial_img = self.img.copy() @@ -1233,7 +1284,8 @@ class Sensor: self.calibration = cal_mod.load(cal, strict=True) def _apply_force_filter(self): - raw = np.asarray(self._force_raw, dtype=np.float64) + pre_filter = np.asarray(self._force_raw, dtype=np.float64) + raw = pre_filter # 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) @@ -1241,7 +1293,15 @@ class Sensor: raw = median.process(raw) if self._lowpass_enabled and self._lowpass_cutoff_hz > 0: raw = self._force_filter.process(raw) - fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2]) + # The chain's own output, before calibration: the "filtered raw counts". + # Without this the raw axis would have no filtered/unfiltered pair and + # ``force_triplets`` could only offer the device counts twice. + self._force_filtered_raw = ( + float(raw[0]), + float(raw[1]), + float(raw[2]), + ) + fn, fx, fy = self._force_filtered_raw # Apply loaded force_calibration.json when present; otherwise keep raw. if self.calibration is not None: @@ -1251,6 +1311,86 @@ class Sensor: self.fnormal, self.fshearx, self.fsheary = fn, fx, fy + # The same reading with no readout filtering, so a caller can keep both + # (the app stores the filtered values as the record and the unfiltered + # ones beside them). Calibrated with the same coefficients, so the two + # only differ by the filter chain. + ufn, ufx, ufy = (float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2])) + if self.calibration is not None: + ufn, ufx, ufy = self.calibration.convert(ufn, ufx, ufy) + ufx, ufy, ufn = float(ufx), float(ufy), float(ufn) + self._force_raw_unfiltered = ( + float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2]) + ) + self._force_unfiltered = (ufn, ufx, ufy) + + # ------------------------------------------------------- filter contract + def filters_state(self) -> dict: + """What the filter chain is **actually** doing, for records and audits. + + Read this instead of the config file: a config can be overridden at + runtime (``configure``) and a value that is present is never silently + replaced, so the effective state is the only honest answer. The two + groups are kept apart on purpose: + + * ``measurement`` — stages upstream of the HHD/force computation + (``flow_ema_alpha``): turning them on changes *what is measured*, so + two recordings with different values are not comparable; + * ``readout`` — stages that only smooth the force triple on its way out + (median, low-pass): recorded, but they do not change the physical + claim. + """ + median_applied = bool(getattr(self._force_median, "enabled", False)) + tracker_cfg = self._config.get("tracker") or {} + return { + "preset": getattr(self, "_filter_preset", None), + "measurement": { + "flow_ema_alpha": float(self._flow_ema_alpha), + "flow_ema_applied": bool(self._flow_ema_alpha < 1.0), + "hhd_downsample": int(getattr(self, "hhd_downsample", 0) or 0), + "dis_mode": tracker_cfg.get("dis_mode"), + }, + "readout": { + "force_median_window": int(self._force_median_window), + "force_median_applied": median_applied, + "force_lowpass_cutoff_hz": float(self._lowpass_cutoff_hz), + "force_lowpass_order": int(self._lowpass_order), + "force_lowpass_sample_rate_hz": float(self._lowpass_sample_rate_hz), + "force_lowpass_rate_auto": bool(self._lowpass_rate_auto), + "force_lowpass_applied": bool(self._lowpass_enabled), + "sample_rate_source": str(getattr(self, "_force_rate_source", "config")), + }, + "any_stage_applied": bool(self._filters_enabled), + "unfiltered_available": True, + "source": "sensor.filters_state", + } + + def force_triplets(self) -> dict: + """The filtered and unfiltered force triples of the current frame. + + One call, so a sampling thread reads a single consistent frame: the + filtered pair is what ``read_info`` reports and what a recording stores + as its primary columns, the unfiltered pair is the same reading with the + readout stages bypassed. + """ + raw_unfiltered = getattr(self, "_force_raw_unfiltered", None) + unfiltered = getattr(self, "_force_unfiltered", None) + filtered_raw = getattr(self, "_force_filtered_raw", None) + return { + "filtered": { + "raw": tuple(filtered_raw) if filtered_raw is not None else None, + "newton": ( + float(self.fnormal), + float(self.fshearx), + float(self.fsheary), + ), + }, + "unfiltered": { + "raw": tuple(raw_unfiltered) if raw_unfiltered is not None else None, + "newton": tuple(unfiltered) if unfiltered is not None else None, + }, + } + # def export_config(self, path="./config/exported_config.json"): # """Return current configuration dict.""" # configs.export_config(self._config, path)