feat: integrate basin force estimator (pre-force) for 7x12 sensor

This commit is contained in:
lenn
2026-05-11 17:04:47 +08:00
parent 83832139a8
commit 0833694e1b
29 changed files with 33875 additions and 7 deletions

View File

@@ -0,0 +1,2 @@
[registries.kellnr]
index = "sparse+http://crates.huangyanjie.com/api/v1/crates/"

49
src-tauri/Cargo.lock generated
View File

@@ -14,6 +14,7 @@ dependencies = [
"crc",
"csv",
"dirs",
"eskin-finger-sdk",
"fern",
"futures-util",
"humantime",
@@ -1152,6 +1153,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "eskin-finger-sdk"
version = "0.1.0"
source = "sparse+http://crates.huangyanjie.com/api/v1/crates/"
checksum = "341d54dbc70a0fb7cdd04162cdda6ab5735f9a4f717b1921b42c00e8afc37bb9"
dependencies = [
"chrono",
"crc",
"crossbeam-channel",
"fern",
"libc",
"log",
"serde",
"serde_json",
"serialport",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -2314,9 +2334,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
@@ -2340,6 +2360,26 @@ dependencies = [
"redox_syscall 0.7.4",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -4263,6 +4303,7 @@ dependencies = [
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix 0.26.4",
"scopeguard",
@@ -5565,9 +5606,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.22.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",

View File

@@ -49,10 +49,11 @@ crc = "3.4.0"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
futures-util = "0.3"
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
ndarray = { version = "0.15", optional = true }
eskin-finger-sdk = { version = "0.1.0", registry = "kellnr" }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

1021
src-tauri/nsis/installer.nsi Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
{
"scaler_mean": [
1748.7541486595198,
1292.5704664084863,
669.8700117864961,
1617.8798712839798,
2104.589811228976,
3267.658809002638,
3366.4000112252343,
2660.981740285495,
2656.615909898786,
1747.1196048717518,
3093.4178032216423,
3107.599371386878,
4138.929019101607,
3778.3928270752654,
3495.851920450506,
3110.5580063983834,
2310.8518456156107,
2899.8918261585377,
3286.6881442816784,
3601.237076948981,
2590.9553048586554,
2555.2781425978933,
2004.8764850049579,
1333.8961665824775,
2090.217507623805,
0.363302046990876,
0.2506597877765041,
0.12741811820991292,
0.32195020821212794,
0.43317540002685884,
0.7725988160553472,
0.791227193907261,
0.5957799875116326,
0.5873844015441929,
0.35855586659016336,
0.7267512979672636,
0.7214172326166498,
1.0,
0.9089476753706724,
0.8226695360434777,
0.7208819781157673,
0.5152795489332506,
0.6711736481838434,
0.7782925265622518,
0.8648282061576593,
0.5787625095682526,
0.5752349727514727,
0.43456864805018935,
0.27668525082454587,
0.47414670304783574,
4138.929019101607,
64531.08183195824,
175620.92531477427,
22.847729696357412,
14.671691561018095,
0.07533558084489102,
12446.865764906175,
47945.287047950456,
2.8973185436828195,
10.774373017335268,
3.472192991899253,
-0.013941562889309035,
0.09672681097411825,
0.5067195499928454,
0.755407246398865,
0.03711810817384146,
11.154421806888552,
64500.8986854629
],
"scaler_scale": [
1458.5456651154973,
1319.8585484401115,
798.8535944732339,
1467.8233720347457,
1637.8964913406842,
1330.3349975112737,
1391.430499849884,
1444.166940848846,
1630.948040054198,
1406.2203759964518,
1289.9699402243327,
1442.0533616965101,
1437.7214049715994,
1393.522474091575,
1468.6421185157626,
1449.3479990930084,
1293.2464048717598,
1331.2560392843097,
1326.1289536453178,
1357.3405110533047,
1452.4854193036483,
1348.4425883366337,
1318.1429721243371,
1059.93845215709,
1114.1647557935548,
0.2395898634701691,
0.21706962815914935,
0.13523106483202163,
0.23880331588910964,
0.24830003478347082,
0.1464527498295455,
0.15391677914992113,
0.18125664726966026,
0.2326879002599809,
0.23502163992653513,
0.13026800431597335,
0.15563022147466685,
1.0,
0.09922737602626737,
0.18291931318098986,
0.15401181704844932,
0.2143892844194339,
0.16856049162074294,
0.15902500893917185,
0.18285009098439925,
0.17264751056304276,
0.21090366624550771,
0.16802111677577075,
0.19264329284433157,
0.19589977001187556,
1437.7214049715994,
32602.413979370118,
95845.11969895993,
3.426376344472427,
3.408382770733738,
0.033353666248921464,
5505.629576226806,
25703.01200969283,
0.4599551450527747,
2.978321440052941,
0.3916581766443181,
0.06096090153067211,
0.07864618660494935,
0.0344984508436715,
0.17668176728315207,
0.18905119470509504,
5352.30503788098,
32297.31796957845
],
"ridge_coef": [
7.4424310127566695,
13.345966730219576,
2.351840055857306,
6.088230738742203,
-10.030964629299273,
3.876136979406362,
-11.251608537526174,
16.84502390958064,
-2.093552796584439,
-5.784923711493545,
-6.67830546424787,
-4.654052249161928,
6.038218458133514,
9.82412450487401,
-6.200667839175651,
-0.3133364534713342,
-8.75036029102127,
12.785901861589027,
-3.7296377182327123,
6.546167384121816,
-4.984129287282208,
8.311396481777527,
-0.6248790895663127,
2.69008779623183,
12.996047839696784,
-2.2609944767610504,
-5.131537716982507,
0.3988922195665723,
-5.197736884253156,
4.556854888903703,
-0.8642438099006351,
6.327731485629085,
-5.157281763422745,
0.10691827520622764,
4.656962972053113,
3.2628870750114887,
4.033159141354671,
0.0,
-2.9206404009765268,
1.8683691849941264,
2.408006875407745,
7.250310827671452,
-3.97015207422554,
0.7316093212194048,
-3.459346094204882,
2.4407660203169255,
-2.872982666400644,
1.8797071977799857,
-1.3374700235689694,
-7.9533345474852295,
6.038063637368508,
1.615806581558555,
95785.62883805836,
0.12233606167692031,
-0.1515900264871255,
2.2023033069961873,
8.776787743985668,
-0.16714060634667535,
-2.751671223554021,
0.2511944267079865,
6.13561607395193,
2.85703108671782,
-0.11255626089468472,
-0.9017242341101542,
-0.627291200283328,
3.4664885582435883,
0.02591345630626686,
0.5530407299425606
],
"ridge_intercept": 175620.9253147744,
"n_features": 68,
"noise_threshold": 15.0,
"contact_threshold": 20.0,
"ema_alpha": 0.9
}

View File

@@ -0,0 +1,397 @@
//! 7×12 柔性压力点阵力估计 - Rust 实现
//!
//! 与 Python `basin_feature_extractor.py` 完全对齐。
//! 内嵌 `model_params.json`,对每帧 7×12 传感器数据提取 68 维特征并用
//! StandardScaler + Ridge 回归估计法向力 Fz。
use serde::Deserialize;
// ───────────────── 常量 ─────────────────
const ROWS: usize = 7;
const COLS: usize = 12;
const ROI_RADIUS: usize = 2;
const ROI_SIZE: usize = 2 * ROI_RADIUS + 1; // 5
const N_FEATURES: usize = 68; // 25 + 25 + 18
// ───────────────── 模型参数 JSON编译时嵌入─────────────────
const MODEL_PARAMS_JSON: &str = include_str!("../../resources/model_params.json");
// ───────────────── 模型参数反序列化 ─────────────────
#[derive(Debug, Deserialize)]
struct ModelParams {
scaler_mean: Vec<f64>,
scaler_scale: Vec<f64>,
ridge_coef: Vec<f64>,
ridge_intercept: f64,
n_features: usize,
noise_threshold: f64,
contact_threshold: f64,
ema_alpha: f64,
}
// ───────────────── 估算器 ─────────────────
pub struct BasinForceEstimator {
// 模型参数
scaler_mean: [f64; N_FEATURES],
scaler_scale: [f64; N_FEATURES],
ridge_coef: [f64; N_FEATURES],
ridge_intercept: f64,
// 超参数
noise_threshold: f64,
contact_threshold: f64,
ema_alpha: f64,
// 时序状态(需要可变)
prev_roi_sum: f64,
ema_sum: f64,
first_frame: bool,
}
impl BasinForceEstimator {
/// 使用编译时内嵌的 model_params.json 创建估算器
pub fn new() -> Self {
Self::from_json_str(MODEL_PARAMS_JSON)
.expect("内嵌 model_params.json 加载失败")
}
pub fn from_json_str(json: &str) -> Result<Self, Box<dyn std::error::Error>> {
let p: ModelParams = serde_json::from_str(json)?;
if p.n_features != N_FEATURES {
return Err(format!(
"模型特征维度不匹配: 期望 {}, 实际 {}",
N_FEATURES, p.n_features
)
.into());
}
let mut scaler_mean = [0.0; N_FEATURES];
let mut scaler_scale = [0.0; N_FEATURES];
let mut ridge_coef = [0.0; N_FEATURES];
scaler_mean.copy_from_slice(&p.scaler_mean);
scaler_scale.copy_from_slice(&p.scaler_scale);
ridge_coef.copy_from_slice(&p.ridge_coef);
Ok(Self {
scaler_mean,
scaler_scale,
ridge_coef,
ridge_intercept: p.ridge_intercept,
noise_threshold: p.noise_threshold,
contact_threshold: p.contact_threshold,
ema_alpha: p.ema_alpha,
prev_roi_sum: 0.0,
ema_sum: 0.0,
first_frame: true,
})
}
pub fn reset(&mut self) {
self.prev_roi_sum = 0.0;
self.ema_sum = 0.0;
self.first_frame = true;
}
pub fn predict_frame(&mut self, frame: &[f64; 84]) -> f64 {
let features = self.extract_features(frame);
self.ridge_predict(&features)
}
// ───────────── 特征提取 ─────────────
fn extract_features(&mut self, raw: &[f64; 84]) -> [f64; N_FEATURES] {
let mut x = [[0.0f64; COLS]; ROWS];
let mut max_value = 0.0f64;
for r in 0..ROWS {
for c in 0..COLS {
let v = raw[r * COLS + c].max(0.0);
x[r][c] = v;
if v > max_value {
max_value = v;
}
}
}
if max_value < self.contact_threshold {
self.update_temporal(0.0);
return [0.0; N_FEATURES];
}
let mut peak_row = 0usize;
let mut peak_col = 0usize;
for r in 0..ROWS {
for c in 0..COLS {
if x[r][c] >= x[peak_row][peak_col] {
peak_row = r;
peak_col = c;
}
}
}
let roi = self.extract_roi(&x, peak_row, peak_col);
self.compute_features(&x, &roi, max_value, peak_row, peak_col)
}
fn extract_roi(
&self,
x: &[[f64; COLS]; ROWS],
pr: usize,
pc: usize,
) -> [[f64; ROI_SIZE]; ROI_SIZE] {
let r = ROI_RADIUS as isize;
let mut roi = [[0.0f64; ROI_SIZE]; ROI_SIZE];
let r_start = (pr as isize - r).max(0) as usize;
let r_end = (pr + ROI_RADIUS + 1).min(ROWS);
let c_start = (pc as isize - r).max(0) as usize;
let c_end = (pc + ROI_RADIUS + 1).min(COLS);
let roi_r_start = (r_start as isize - (pr as isize - r)).max(0) as usize;
let roi_c_start = (c_start as isize - (pc as isize - r)).max(0) as usize;
for (i, ri) in (r_start..r_end).enumerate() {
for (j, ci) in (c_start..c_end).enumerate() {
roi[roi_r_start + i][roi_c_start + j] = x[ri][ci];
}
}
roi
}
fn compute_features(
&mut self,
x: &[[f64; COLS]; ROWS],
roi: &[[f64; ROI_SIZE]; ROI_SIZE],
max_value: f64,
peak_row: usize,
peak_col: usize,
) -> [f64; N_FEATURES] {
let center = ROI_RADIUS;
let mut feat = [0.0f64; N_FEATURES];
let mut idx = 0;
// ROI 原始值 (25维)
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
feat[idx] = roi[r][c];
idx += 1;
}
}
// ROI 归一化形状 (25维)
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
feat[idx] = if max_value > 0.0 {
roi[r][c] / max_value
} else {
0.0
};
idx += 1;
}
}
// roi_sum, global_sum
let mut roi_sum = 0.0f64;
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
roi_sum += roi[r][c];
}
}
let mut global_sum = 0.0f64;
for r in 0..ROWS {
for c in 0..COLS {
global_sum += x[r][c];
}
}
// active_area
let thr = self.noise_threshold.max(0.05 * max_value);
let mut active_area = 0.0f64;
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
if roi[r][c] > thr {
active_area += 1.0;
}
}
}
let participation = if max_value > 0.0 {
roi_sum / max_value
} else {
0.0
};
let concentration = if roi_sum > 0.0 {
max_value / roi_sum
} else {
0.0
};
// ring1_sum (上下左右4点)
let ring1_positions = [
(center - 1, center),
(center + 1, center),
(center, center - 1),
(center, center + 1),
];
let ring1_sum: f64 = ring1_positions.iter().map(|&(r, c)| roi[r][c]).sum();
// ring2_sum (除中心和ring1外)
let mut ring2_sum = 0.0f64;
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
if (r, c) == (center, center) {
continue;
}
if ring1_positions.contains(&(r, c)) {
continue;
}
ring2_sum += roi[r][c];
}
}
let ring1_ratio = if max_value > 0.0 {
ring1_sum / max_value
} else {
0.0
};
let ring2_ratio = if max_value > 0.0 {
ring2_sum / max_value
} else {
0.0
};
// spread
let spread = if roi_sum > 0.0 {
let mut s = 0.0f64;
for r in 0..ROI_SIZE {
for c in 0..ROI_SIZE {
let dr = r as f64 - center as f64;
let dc = c as f64 - center as f64;
s += (dr * dr + dc * dc) * roi[r][c];
}
}
s / roi_sum
} else {
0.0
};
// asym_x
let mut left_sum = 0.0f64;
let mut right_sum = 0.0f64;
for r in 0..ROI_SIZE {
for c in 0..center {
left_sum += roi[r][c];
}
for c in (center + 1)..ROI_SIZE {
right_sum += roi[r][c];
}
}
let asym_x = if roi_sum > 0.0 {
(right_sum - left_sum) / roi_sum
} else {
0.0
};
// asym_y
let mut up_sum = 0.0f64;
let mut down_sum = 0.0f64;
for r in 0..center {
for c in 0..ROI_SIZE {
up_sum += roi[r][c];
}
}
for r in (center + 1)..ROI_SIZE {
for c in 0..ROI_SIZE {
down_sum += roi[r][c];
}
}
let asym_y = if roi_sum > 0.0 {
(down_sum - up_sum) / roi_sum
} else {
0.0
};
// 位置
let peak_row_norm = peak_row as f64 / (ROWS - 1) as f64;
let peak_col_norm = peak_col as f64 / (COLS - 1) as f64;
// near_edge
let r = ROI_RADIUS as isize;
let near_edge = if (peak_row as isize) < r
|| peak_row >= ROWS - ROI_RADIUS
|| (peak_col as isize) < r
|| peak_col >= COLS - ROI_RADIUS
{
1.0
} else {
0.0
};
// 时序特征
let delta_sum = roi_sum - self.prev_roi_sum;
if self.first_frame {
self.ema_sum = roi_sum;
self.first_frame = false;
} else {
self.ema_sum = self.ema_alpha * self.ema_sum + (1.0 - self.ema_alpha) * roi_sum;
}
self.prev_roi_sum = roi_sum;
let scalars = [
max_value,
roi_sum,
global_sum,
active_area,
participation,
concentration,
ring1_sum,
ring2_sum,
ring1_ratio,
ring2_ratio,
spread,
asym_x,
asym_y,
peak_row_norm,
peak_col_norm,
near_edge,
delta_sum,
self.ema_sum,
];
for &v in &scalars {
feat[idx] = v;
idx += 1;
}
debug_assert_eq!(idx, N_FEATURES);
feat
}
fn update_temporal(&mut self, roi_sum: f64) {
self.prev_roi_sum = roi_sum;
if self.first_frame {
self.ema_sum = roi_sum;
self.first_frame = false;
} else {
self.ema_sum = self.ema_alpha * self.ema_sum + (1.0 - self.ema_alpha) * roi_sum;
}
}
// ───────────── 推理 ─────────────
fn ridge_predict(&self, features: &[f64; N_FEATURES]) -> f64 {
let mut scaled = [0.0f64; N_FEATURES];
for i in 0..N_FEATURES {
let s = self.scaler_scale[i];
scaled[i] = if s.abs() > 1e-12 {
(features[i] - self.scaler_mean[i]) / s
} else {
0.0
};
}
let mut y = self.ridge_intercept;
for i in 0..N_FEATURES {
y += self.ridge_coef[i] * scaled[i];
}
y
}
}

View File

@@ -12,7 +12,7 @@ use async_trait::async_trait;
use csv::StringRecord;
use anyhow::anyhow;
use std::io::Read;
use log::debug;
use log::{debug, info};
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
@@ -226,6 +226,7 @@ impl Codec<TactileAFrame> for TactileACodec {
req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
let checksum = calc_crc8_itu(req_bytes.as_slice());
req_bytes.push(checksum);
info!("send: {:02X?}", req_bytes);
Ok(req_bytes)
}
_ => {

View File

@@ -13,6 +13,7 @@ pub mod record;
pub mod utils;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;
pub mod basin_force_estimator;
pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;

View File

@@ -1,3 +1,4 @@
use crate::serial_core::basin_force_estimator::BasinForceEstimator;
use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
@@ -233,6 +234,7 @@ where
let mut prune_interval = time::interval(Duration::from_millis(450));
#[cfg(feature = "multi-dim")]
let mut pzt_processor = PztProcessor::new();
let mut force_estimator = BasinForceEstimator::new();
let mut pending_sub_frame: Option<PendingSubFrame<F>> = None;
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
@@ -309,6 +311,16 @@ where
drop(record);
if let Some(vals) = decode_res {
// Basin force estimation (pre-force)
if vals.len() == 84 {
let mut frame_f64 = [0.0f64; 84];
for (i, v) in vals.iter().enumerate() {
frame_f64[i] = *v as f64;
}
let pre_force = force_estimator.predict_frame(&frame_f64);
debug!("pre-force: {:.2}", pre_force);
}
#[cfg(feature = "multi-dim")]
{
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();

View File

@@ -23,7 +23,7 @@
}
},
"bundle": {
"createUpdaterArtifacts": true,
"createUpdaterArtifacts": false,
"active": true,
"targets": "all",
"icon": [