From 79faa67dd816a0922a567f10a2ecccbbbf91b2e8 Mon Sep 17 00:00:00 2001 From: lenn Date: Tue, 2 Jun 2026 16:24:15 +0800 Subject: [PATCH] update serial and spatial force components --- .../__pycache__/sensor_server.cpython-313.pyc | Bin 41954 -> 61431 bytes devkit/sensor_server.py | 379 +++++++++++++++++- src/lib/components/CenterStage.svelte | 53 +-- src/lib/components/SpatialForcePanel.svelte | 181 ++++----- src/routes/+page.svelte | 17 +- 5 files changed, 458 insertions(+), 172 deletions(-) diff --git a/devkit/__pycache__/sensor_server.cpython-313.pyc b/devkit/__pycache__/sensor_server.cpython-313.pyc index b94a2bb4343c87a69f9cc6ef5fc1fe7a2908680e..35e9c368e0c05808cf06a109259b9f64b211b8e3 100644 GIT binary patch delta 19723 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 CKgG`g delta 3515 zcmaJ@3vg7`8Q!xux!JsyNH!P}9w97-%>x1kApuDM2_P|nTq7jAc`V72goGuYYkOgMS?#y@p z^FPk{pXc6xf3BT--)g^}m>6eKKlj;j>;4t&x2KAM{;Zx=C5E16Sr;YmGZq*zOpP`c z8aAeqCs^+M8;gusmN<+@j5wxJjKxMgQ)7%$BY~;=jV1jzYsFHk5ue5{GbS^$*m#uY zS&ytL&$P^fAL=J7jG1W`xx#o*;Ywq$fpJ2) zJ4Hb_D$v^Q3+rC#^*5`h^mX{W0XZJ>iIiI5j86lZ4wyhoDi-M(ATw!qMOxxyMTVnW zwsdR=NGJWWV!W6{f2sJdC=LC2#Z65V(kG8Mh;o`)wMXH5RkMT<`mU;1#FxTOYxM`D zL4B)wMMEflwMQgn^C18iU~SbD(yM^QunaZlAf z5Y^JCwp39}B_5}W)aRM2`@#0p>z*~j4~tM{?QEe;YU=VF&1S(IC1G2jMfy5h>f4&> z<+=gUOhxrC=}Tc+M&bJGhE9+lptz?18?oF3*bLYL=mu;BJO$_h1OeLtI{>|a?*VoK zo(4PvD5Lpbr0QKDyQsI}GttGa2D}cd3S(E}QvF#a3p>dhOb=aa>>f@;D@k9Kj`XX% zpYnC<{m_~QE!y`=a%k(??x?g45O)Bc1W3RgxtiMcLHu5DUj!7=cWr4!oVD*`J!d57 z8Z6(z5_NHnlE*>2!TlAe`JjraqCI=K7NKB!N>ou6*hzr<0C#Pg1It`M3ZzpNxT1!4 zrjI(N=s#1OuuFwBE|l`*RcoEgS}qfyHOg#{-7tF|fcZ1G=knOWiyS@{(mcfrYn_26 zvv||MLj_$i=8?rzADAy*3k?LkBKrjh1fO!qIxJVLPOGxY$dJh}ejOWTvv-R(ek^jh zKU)=34gmH8IO35OxfR_}D&CwY4u;lm_KJ*%87e4`sjbm*mf=(0pp$)*LZiCttgG)z z9|t)NHq!x|=6p~{q~w?A6a4=>fG?1@S|TPmI)pM=X*>K7ud7ca`!TjdzG!B7gUOvB0d(_KhW~i|=x3 z8P0fg>Rf+`(V}^7Lh7N zO3c;eIpm3qB#TqY<>MUlTp*w2F01?jcI#508jJ4`_>Z?wpiBCLoFb()s1$C*`8%fh zo4itbw;9W_NI5BDY~hvZ^?NmPD3fOE9~jFl&kYbXXrW=1x*rr z-hMuFQOiTRk-|lgUGXBm6Gu1KCH{URW>ssON00=%>?Xo%&hD?$<~^*P?#`jb{}%jedA8 zPCu`fdo>z3moJ*=<8y9nm6djMI_UVth17FC7t2Y#4qAR`43l{&!b&T99ax+^;aE{+ zt(>5Oxa(J7cUt8Ais?#{eBLn}dO|VeZvgjxHzjgC3YFp^O}eRMXm;F1Wxr2Tv+xY< z7|JTOKX2b<|IGG(9UE(zu zj#==?qWZGM<>vcvBwzI9OlQJKD&z%0CoYZEe}OFY(&5GVV_Fsp{!`^|%q=-r5;(uWjGp4b+-b!cv?0!nlSMKu=x= zh0>T)I7olEQlztZkmA2g3o^M?$-_}it&IV5t7x@W1GyJw-GCPX7*kXJ49vG2h z1Ib*gv_K6Pq}qWC1jo!9j46b2BRSC3(e87vtw#X48D<>HU?q%Mt#X9o*#C(Qt+(9_ ze+AosKK$Wt!~!fI0?Y!;2FwA>1r!026@;V8HZ-=$(O9Pe z(gEWD;{g)^*#N$OGeAuOWCA7!flfdw0H5))6i^N@01bdO3c~SC-hj8U-P_se>y(Y) zMEU$(>tz#2J+P&t)!!_AP+tr93eW<;)n;B&e5G(#;uewT`C|hE$wMKp2)cB2j{36B k__`o2MtvkHiKVkGRQL7YMC`X~Y!>^}KP9?o>)*Hi4|HQpi~s-t diff --git a/devkit/sensor_server.py b/devkit/sensor_server.py index e138e2f..cff62d1 100644 --- a/devkit/sensor_server.py +++ b/devkit/sensor_server.py @@ -906,8 +906,361 @@ class PressureDirectionEstimator: return angle, mag +@dataclass +class LocalForceResult: + angle: float + magnitude: float + planar_x: float + planar_y: float + confidence: float + contact_active: bool + reportable: bool + total_pressure: float + peak: float + cop_x: float + cop_y: float + threshold: float -_estimator = PressureDirectionEstimator() + +class LocalTangentialForceEstimator: + CONTACT_ENTER_TOTAL_THRESHOLD = 520.0 + CONTACT_ENTER_PEAK_THRESHOLD = 50.0 + CONTACT_EXIT_TOTAL_THRESHOLD = 260.0 + CONTACT_EXIT_PEAK_THRESHOLD = 28.0 + CONTACT_ENTER_FRAMES_REQUIRED = 2 + CONTACT_EXIT_FRAMES_REQUIRED = 8 + + BASELINE_IDLE_ALPHA = 0.035 + BASELINE_BOOTSTRAP_ALPHA = 1.0 + BASELINE_NOISE_FLOOR = 5.0 + + ACTIVE_CELL_MIN_VALUE = 18.0 + ACTIVE_CELL_PEAK_RATIO = 0.14 + MIN_ACTIVE_CELLS = 3 + + VECTOR_SMOOTHING_ALPHA = 0.16 + REPORT_MAGNITUDE_ENTER = 0.12 + REPORT_MAGNITUDE_EXIT = 0.045 + REPORT_CONFIDENCE_ENTER = 0.14 + REPORT_CONFIDENCE_EXIT = 0.06 + REPORT_HOLD_FRAMES = 10 + + ASYMMETRY_WEIGHT = 1.1 + DRIFT_WEIGHT = 0.65 + MOTION_WEIGHT = 0.25 + EDGE_ASYMMETRY_DAMPING = 0.35 + EDGE_INWARD_ROLLING_BIAS = 0.55 + EDGE_START_COP_THRESHOLD = 0.45 + EDGE_START_BIAS_WEIGHT = 1.1 + ROLLING_FRICTION_ALPHA = 0.68 + ROLLING_FRICTION_MIN_MAGNITUDE = 0.05 + + def __init__(self): + self.reset_all() + + def reset_all(self): + self.baseline_frame = None + self.reset_contact_state() + + def reset_contact_state(self): + self.contact_active = False + self.contact_enter_counter = 0 + self.contact_exit_counter = 0 + self.anchor_cop_x = None + self.anchor_cop_y = None + self.last_cop_x = None + self.last_cop_y = None + self.edge_start_bias_x = 0.0 + self.edge_start_bias_y = 0.0 + self.smoothed_x = 0.0 + self.smoothed_y = 0.0 + self.report_active = False + self.report_hold_counter = 0 + self.held_report = None + + def update(self, adc_data, timestamp_ms: float) -> LocalForceResult: + raw = np.asarray(adc_data, dtype=np.float32).flatten() + if len(raw) != ADC_LEN: + raise ValueError(f"ADC data length must be {ADC_LEN}") + + baseline_subtracted = self._subtract_baseline(raw) + if not self._update_contact_state(raw, baseline_subtracted): + return self._inactive_result(float(np.sum(baseline_subtracted)), float(np.max(baseline_subtracted, initial=0.0))) + + stats = self._compute_contact_stats(baseline_subtracted) + if stats is None: + return self._stabilize_report(self._weak_contact_result(float(np.sum(baseline_subtracted)), float(np.max(baseline_subtracted, initial=0.0)))) + + if self.anchor_cop_x is None: + self.anchor_cop_x = stats["cop_x"] + self.anchor_cop_y = stats["cop_y"] + self.last_cop_x = stats["cop_x"] + self.last_cop_y = stats["cop_y"] + self.edge_start_bias_x, self.edge_start_bias_y = self._edge_start_bias(stats) + return self._stabilize_report(self._weak_contact_result(stats["total"], stats["peak"], stats["cop_x"], stats["cop_y"])) + + anchor_x = self.anchor_cop_x + anchor_y = self.anchor_cop_y if self.anchor_cop_y is not None else stats["cop_y"] + last_x = self.last_cop_x if self.last_cop_x is not None else stats["cop_x"] + last_y = self.last_cop_y if self.last_cop_y is not None else stats["cop_y"] + + drift_x = stats["cop_x"] - anchor_x + drift_y = stats["cop_y"] - anchor_y + motion_x = stats["cop_x"] - last_x + motion_y = stats["cop_y"] - last_y + + kinematic_x = drift_x * self.DRIFT_WEIGHT + motion_x * self.MOTION_WEIGHT + kinematic_y = drift_y * self.DRIFT_WEIGHT + motion_y * self.MOTION_WEIGHT + asymmetry_x, asymmetry_y = self._damp_edge_asymmetry( + stats, + kinematic_x + self.edge_start_bias_x, + kinematic_y + self.edge_start_bias_y, + ) + + combined_x = asymmetry_x + kinematic_x + self.edge_start_bias_x + combined_y = asymmetry_y + kinematic_y + self.edge_start_bias_y + combined_x, combined_y = self._apply_rolling_friction( + self.smoothed_x, + self.smoothed_y, + combined_x, + combined_y, + ) + + self.smoothed_x += (combined_x - self.smoothed_x) * self.VECTOR_SMOOTHING_ALPHA + self.smoothed_y += (combined_y - self.smoothed_y) * self.VECTOR_SMOOTHING_ALPHA + self.last_cop_x = stats["cop_x"] + self.last_cop_y = stats["cop_y"] + + planar_x = self.smoothed_x + planar_y = -self.smoothed_y + angle, magnitude = self.compute_vector_angle(planar_x, planar_y) + + active_span_rows = (stats["max_row"] - stats["min_row"] + 1) / SENSOR_ROWS + active_span_cols = (stats["max_col"] - stats["min_col"] + 1) / SENSOR_COLS + activity = min(max(stats["active_cells"] / ADC_LEN, 0.0), 1.0) + span = min(max((active_span_rows + active_span_cols) * 0.5, 0.0), 1.0) + pressure_ratio = min(max(stats["active_total"] / max(stats["total"], 1.0), 0.0), 1.0) + peak_ratio = min(max(stats["peak"] / (stats["total"] / stats["active_cells"] + 1.0), 0.0), 1.0) + confidence = min(max(activity * 0.35 + span * 0.2 + pressure_ratio * 0.3 + peak_ratio * 0.15, 0.0), 1.0) + + return self._stabilize_report(LocalForceResult( + angle=angle, + magnitude=magnitude, + planar_x=planar_x, + planar_y=planar_y, + confidence=confidence, + contact_active=True, + reportable=False, + total_pressure=stats["total"], + peak=stats["peak"], + cop_x=stats["cop_x"], + cop_y=stats["cop_y"], + threshold=self.CONTACT_ENTER_TOTAL_THRESHOLD, + )) + + def _update_idle_baseline(self, raw_frame, alpha: float): + if self.baseline_frame is None: + self.baseline_frame = np.array(raw_frame, dtype=np.float32).copy() + return + self.baseline_frame += (raw_frame - self.baseline_frame) * alpha + + def _subtract_baseline(self, raw_frame): + if self.baseline_frame is None: + self._update_idle_baseline(raw_frame, self.BASELINE_BOOTSTRAP_ALPHA) + diff = raw_frame - self.baseline_frame - self.BASELINE_NOISE_FLOOR + return np.clip(diff, 0, None) + + def _pressure_metrics(self, frame): + return float(np.sum(frame)), float(np.max(frame, initial=0.0)) + + def _update_contact_state(self, raw_frame, frame) -> bool: + total, peak = self._pressure_metrics(frame) + enter = total >= self.CONTACT_ENTER_TOTAL_THRESHOLD and peak >= self.CONTACT_ENTER_PEAK_THRESHOLD + exit_frame = total <= self.CONTACT_EXIT_TOTAL_THRESHOLD or peak <= self.CONTACT_EXIT_PEAK_THRESHOLD + + if self.contact_active: + if exit_frame: + self.contact_exit_counter += 1 + if self.contact_exit_counter >= self.CONTACT_EXIT_FRAMES_REQUIRED: + self._update_idle_baseline(raw_frame, self.BASELINE_IDLE_ALPHA) + self.reset_contact_state() + return False + else: + self.contact_exit_counter = 0 + return True + + if enter: + self.contact_enter_counter += 1 + if self.contact_enter_counter >= self.CONTACT_ENTER_FRAMES_REQUIRED: + self.contact_active = True + self.contact_enter_counter = 0 + self.contact_exit_counter = 0 + return True + return False + + self.contact_enter_counter = 0 + self._update_idle_baseline(raw_frame, self.BASELINE_IDLE_ALPHA) + return False + + def _compute_contact_stats(self, frame): + total, peak = self._pressure_metrics(frame) + if total <= 0.0 or peak <= 0.0: + return None + + active_threshold = max(peak * self.ACTIVE_CELL_PEAK_RATIO, self.ACTIVE_CELL_MIN_VALUE) + frame2d = np.asarray(frame, dtype=np.float32).reshape(SENSOR_ROWS, SENSOR_COLS) + active_mask = frame2d >= active_threshold + active_cells = int(np.count_nonzero(active_mask)) + if active_cells < self.MIN_ACTIVE_CELLS: + return None + + active_values = frame2d[active_mask] + active_total = float(np.sum(active_values)) + if active_total <= 0.0: + return None + + rows, cols = np.nonzero(active_mask) + cop_x = float(np.sum(active_values * cols) / active_total) + cop_y = float(np.sum(active_values * rows) / active_total) + min_row, max_row = int(np.min(rows)), int(np.max(rows)) + min_col, max_col = int(np.min(cols)), int(np.max(cols)) + bbox_center_x = (min_col + max_col) * 0.5 + bbox_center_y = (min_row + max_row) * 0.5 + half_width = max(max_col - min_col, 1) * 0.5 + half_height = max(max_row - min_row, 1) * 0.5 + asymmetry_x = float(np.sum(active_values * ((cols - bbox_center_x) / half_width)) / active_total) + asymmetry_y = float(np.sum(active_values * ((rows - bbox_center_y) / half_height)) / active_total) + + return { + "total": total, + "peak": peak, + "active_total": active_total, + "active_cells": active_cells, + "min_row": min_row, + "max_row": max_row, + "min_col": min_col, + "max_col": max_col, + "cop_x": cop_x, + "cop_y": cop_y, + "asymmetry_x": asymmetry_x, + "asymmetry_y": asymmetry_y, + } + + def _contact_touches_edge(self, stats) -> bool: + return ( + stats["min_row"] == 0 + or stats["max_row"] == SENSOR_ROWS - 1 + or stats["min_col"] == 0 + or stats["max_col"] == SENSOR_COLS - 1 + ) + + def _damp_edge_asymmetry(self, stats, kinematic_x: float, kinematic_y: float): + asymmetry_x = stats["asymmetry_x"] * self.ASYMMETRY_WEIGHT + asymmetry_y = stats["asymmetry_y"] * self.ASYMMETRY_WEIGHT + + if stats["min_col"] == 0 and asymmetry_x < 0.0: + asymmetry_x = -asymmetry_x * self.EDGE_INWARD_ROLLING_BIAS + if stats["max_col"] == SENSOR_COLS - 1 and asymmetry_x > 0.0: + asymmetry_x = -asymmetry_x * self.EDGE_INWARD_ROLLING_BIAS + if stats["min_row"] == 0 and asymmetry_y < 0.0: + asymmetry_y = -asymmetry_y * self.EDGE_INWARD_ROLLING_BIAS + if stats["max_row"] == SENSOR_ROWS - 1 and asymmetry_y > 0.0: + asymmetry_y = -asymmetry_y * self.EDGE_INWARD_ROLLING_BIAS + + opposing_dot = asymmetry_x * kinematic_x + asymmetry_y * kinematic_y + kinematic_mag = float(np.hypot(kinematic_x, kinematic_y)) + if self._contact_touches_edge(stats) and opposing_dot < 0.0 and kinematic_mag >= self.ROLLING_FRICTION_MIN_MAGNITUDE: + asymmetry_x *= self.EDGE_ASYMMETRY_DAMPING + asymmetry_y *= self.EDGE_ASYMMETRY_DAMPING + + return asymmetry_x, asymmetry_y + + def _edge_start_bias(self, stats): + center_x = (SENSOR_COLS - 1) * 0.5 + center_y = (SENSOR_ROWS - 1) * 0.5 + normalized_x = min(max((stats["cop_x"] - center_x) / max(center_x, 1.0), -1.0), 1.0) + normalized_y = min(max((stats["cop_y"] - center_y) / max(center_y, 1.0), -1.0), 1.0) + bias_x = self._edge_start_axis_bias(normalized_x) if stats["min_col"] == 0 or stats["max_col"] == SENSOR_COLS - 1 else 0.0 + bias_y = self._edge_start_axis_bias(normalized_y) if stats["min_row"] == 0 or stats["max_row"] == SENSOR_ROWS - 1 else 0.0 + return bias_x, bias_y + + def _edge_start_axis_bias(self, normalized_axis: float) -> float: + distance = abs(normalized_axis) + if distance <= self.EDGE_START_COP_THRESHOLD: + return 0.0 + strength = min(max((distance - self.EDGE_START_COP_THRESHOLD) / (1.0 - self.EDGE_START_COP_THRESHOLD), 0.0), 1.0) + return -np.sign(normalized_axis) * strength * self.EDGE_START_BIAS_WEIGHT + + def _apply_rolling_friction(self, previous_x: float, previous_y: float, current_x: float, current_y: float): + previous_mag = float(np.hypot(previous_x, previous_y)) + current_mag = float(np.hypot(current_x, current_y)) + if previous_mag < self.ROLLING_FRICTION_MIN_MAGNITUDE or current_mag < self.ROLLING_FRICTION_MIN_MAGNITUDE: + return current_x, current_y + + dot = previous_x * current_x + previous_y * current_y + if dot >= 0.0: + return current_x, current_y + + mixed_x = current_x * (1.0 - self.ROLLING_FRICTION_ALPHA) + previous_x * self.ROLLING_FRICTION_ALPHA + mixed_y = current_y * (1.0 - self.ROLLING_FRICTION_ALPHA) + previous_y * self.ROLLING_FRICTION_ALPHA + if mixed_x * previous_x + mixed_y * previous_y >= 0.0: + return mixed_x, mixed_y + + keep_mag = min(previous_mag, current_mag) * 0.5 + return previous_x / previous_mag * keep_mag, previous_y / previous_mag * keep_mag + + def _inactive_result(self, total_pressure=0.0, peak=0.0): + return LocalForceResult(0.0, 0.0, 0.0, 0.0, 0.0, False, False, total_pressure, peak, 0.0, 0.0, self.CONTACT_ENTER_TOTAL_THRESHOLD) + + def _weak_contact_result(self, total_pressure=0.0, peak=0.0, cop_x=0.0, cop_y=0.0): + return LocalForceResult(0.0, 0.0, 0.0, 0.0, 0.0, True, False, total_pressure, peak, cop_x, cop_y, self.CONTACT_ENTER_TOTAL_THRESHOLD) + + def _store_report(self, result: LocalForceResult): + result.reportable = True + self.report_active = True + self.report_hold_counter = 0 + self.held_report = result + return result + + def _hold_or_drop_report(self): + if self.report_active and self.report_hold_counter < self.REPORT_HOLD_FRAMES and self.held_report is not None: + self.report_hold_counter += 1 + held = self.held_report + held.reportable = True + return held + self.report_active = False + self.report_hold_counter = 0 + self.held_report = None + return self._weak_contact_result() + + def _stabilize_report(self, result: LocalForceResult): + if not result.contact_active: + self.report_active = False + self.report_hold_counter = 0 + self.held_report = None + return result + + can_enter = result.magnitude >= self.REPORT_MAGNITUDE_ENTER and result.confidence >= self.REPORT_CONFIDENCE_ENTER + can_stay = result.magnitude >= self.REPORT_MAGNITUDE_EXIT and result.confidence >= self.REPORT_CONFIDENCE_EXIT + if self.report_active: + if can_stay: + return self._store_report(result) + return self._hold_or_drop_report() + if can_enter: + return self._store_report(result) + return result + + def compute_vector_angle(self, x: float, y: float) -> Tuple[float, float]: + magnitude = float(np.hypot(x, y)) + if magnitude <= np.finfo(np.float32).eps: + return 0.0, 0.0 + angle = float(np.degrees(np.arctan2(y, x))) + if angle < 0.0: + angle += 360.0 + return angle, magnitude + + +_estimator = LocalTangentialForceEstimator() def reset_cop_state(): @@ -924,16 +1277,16 @@ def compute_pressure_direction(adc_data, timestamp_ms: float): return ( result.cop_x, result.cop_y, - result.row_min, - result.row_max, - result.col_min, - result.col_max, - result.dx, - result.dy, - result.base_x, - result.base_y, + 0, + SENSOR_ROWS - 1, + 0, + SENSOR_COLS - 1, + result.planar_x, + result.planar_y, + 0.0, + 0.0, result.magnitude, - result.state, + 1 if result.reportable else 0, result.total_pressure, result.threshold, ) @@ -956,11 +1309,11 @@ def get_pzt_angle(adc_data, timestamp_ms: float): return ( result.angle, result.magnitude, - result.state, + 1 if result.reportable else 0, result.cop_x, result.cop_y, - result.base_x, - result.base_y, + 0.0, + 0.0, result.total_pressure, result.threshold, ) diff --git a/src/lib/components/CenterStage.svelte b/src/lib/components/CenterStage.svelte index 2a2df49..285c49c 100644 --- a/src/lib/components/CenterStage.svelte +++ b/src/lib/components/CenterStage.svelte @@ -29,7 +29,6 @@ export let summary: HudSummary; export let pressureMatrix: number[] | null = null; export let spatialForce: HudSpatialForce | null = null; - export let devkitSpatialForce: HudSpatialForce | null = null; export let showConfigPanel = false; export let configPanelTitle = ""; export let configPanelHint = ""; @@ -278,41 +277,25 @@ {/each} -
- -
-
- -
+ {#if spatialForce} +
+ +
+ {/if} {#if summaryCurveVisible && summarySide === "right"}
-
-
-
-

{panelCode}

-

{resolvedTitle}

-
+{#if hasData} +
+
+
+

{panelCode}

+

{resolvedTitle}

+
- -
+ {#if resolvedBadgeLabel} + + {/if} +
-
-
-
-
-
-
-
- {#if hasData} +
+
+
+
+
+
+
- {/if} -
- 90 - 0 - 270 - 180 -
- - {#if !hasData} -
- {i18n.waiting} +
+ 90 + 0 + 270 + 180
- {/if} +
- -
-

{i18n.heading}

- {#if showMetrics} -

{i18n.strength}: {formatValue(magnitude, 2)}

-

{i18n.confidence}: {hasData ? `${formatValue(confidence, 0)}%` : "--"}

- {:else} -

{resolvedCompactMetaText}

-

{hasData ? (locale === "zh-CN" ? "实时对比中" : "Live comparison") : "--"}

- {/if} -
-
-
- + +{/if}