手势识别

识别手势

适配设备:Pro

作者:rikka

下载量:39 · 点赞:3

可信源码风险提示:保存或下载前请阅读源码和权限声明。
SHA-2569eaef0f3e33c8ba1de122d36c55ae17221e84007089a7e5dbdfa394162709372
const N = 20;
const BAND = 4;
const MAX_PER = 6;
const MAX_LABELS = 8;
const KEY = 'gestureai.v1';
const ON_K = 0.18, OFF_K = 0.08;
const QUIET_MS = 280, MAX_MS = 2200, MIN_MS = 250, COOLDOWN_MS = 700;
const tick = ui.signal(0);
const touch = () => tick.update(n => n + 1);
const box = (title, msg) => dialog.alert({ title, message: String(msg) });
const buzz = mode => { try { system.vibration.vibrate({ mode }); } catch (e) {} };
const can = n => typeof script.canUse === 'function' && script.canUse(n);
const M = { mode: 'idle', status: '就绪', detail: '先训练一个手势来开始', label: null, remaining: 0, log: [], db: {}, thr: {} };
const S = { on: false, g: 0, act: 0, cap: null, pre: [], cooldown: 0 };
function preprocess(buf) {
  const n = buf.length;
  const mean = ['x', 'y', 'z'].map(k => buf.reduce((a, s) => a + s[k], 0) / n);
  let pw = 0;
  const c = buf.map(s => {
    const v = [s.x - mean[0], s.y - mean[1], s.z - mean[2]];
    pw += v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
    return v;
  });
  const rms = Math.sqrt(pw / n) || 1;
  const t0 = buf[0].t, span = (buf[n - 1].t - t0) || 1;
  const out = [];
  for (let i = 0; i < N; i++) {
    const pos = t0 + (i / (N - 1)) * span;
    let j = 1;
    while (j < n - 1 && buf[j].t < pos) j++;
    const a = buf[j - 1], b = buf[j];
    const f = b.t === a.t ? 0 : Math.min(1, Math.max(0, (pos - a.t) / (b.t - a.t)));
    for (let k = 0; k < 3; k++) {
      out.push(Math.round(((c[j - 1][k] + (c[j][k] - c[j - 1][k]) * f) / rms) * 100) / 100);
    }
  }
  return out;
}
function dtw(a, b) {
  const INF = 1e9;
  let prev = new Array(N + 1).fill(INF), cur;
  prev[0] = 0;
  for (let i = 1; i <= N; i++) {
    cur = new Array(N + 1).fill(INF);
    for (let j = Math.max(1, i - BAND); j <= Math.min(N, i + BAND); j++) {
      const ia = (i - 1) * 3, ib = (j - 1) * 3;
      const dx = a[ia] - b[ib], dy = a[ia + 1] - b[ib + 1], dz = a[ia + 2] - b[ib + 2];
      cur[j] = dx * dx + dy * dy + dz * dz + Math.min(prev[j], prev[j - 1], cur[j - 1]);
    }
    prev = cur;
  }
  return Math.sqrt(prev[N] / (2 * N));
}
function rank(seq, db, skip) {
  const res = [];
  for (const label of Object.keys(db)) {
    let best = Infinity;
    db[label].forEach((t, i) => {
      if (skip && skip.label === label && skip.i === i) return;
      const d = dtw(seq, t);
      if (d < best) best = d;
    });
    if (best < Infinity) res.push({ label, d: best });
  }
  return res.sort((a, b) => a.d - b.d);
}
function threshold(label) {
  if (M.thr[label] !== undefined) return M.thr[label];
  const ts = M.db[label];
  let t = 0.9;
  if (ts.length >= 2) {
    let s = 0, c = 0;
    for (let i = 0; i < ts.length; i++) for (let j = i + 1; j < ts.length; j++) { s += dtw(ts[i], ts[j]); c++; }
    t = Math.max(0.45, (1.8 * s) / c);
  }
  return (M.thr[label] = t);
}
function classify(seq) {
  const r = rank(seq, M.db);
  if (!r.length) return null;
  const best = r[0], second = r[1];
  const thr = threshold(best.label);
  const conf = second ? (second.d - best.d) / second.d : Math.max(0, 1 - best.d / thr);
  return { label: best.d <= thr ? best.label : null, d: best.d, conf: Math.max(0, Math.min(1, conf)), thr };
}
function addTemplate(label, seq) {
  const list = (M.db[label] = M.db[label] || []);
  list.push(seq);
  if (list.length > MAX_PER) list.shift();
  M.thr = {};
}
function selfTest() {
  let ok = 0, total = 0;
  const miss = {};
  for (const label of Object.keys(M.db)) {
    if (M.db[label].length < 2) continue;
    M.db[label].forEach((seq, i) => {
      const r = rank(seq, M.db, { label, i });
      if (!r.length) return;
      total++;
      if (r[0].label === label) ok++; else miss[label + '>' + r[0].label] = (miss[label + '>' + r[0].label] || 0) + 1;
    });
  }
  return { ok, total, miss };
}
async function load() {
  try { M.db = JSON.parse(await script.data.get(KEY, '{}')) || {}; } catch (e) { M.db = {}; }
  M.thr = {};
}
const save = () => script.data.set(KEY, JSON.stringify(M.db));
function onSample(d) {
  const now = Date.now();
  const m = Math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
  S.g = S.g ? S.g + 0.05 * (m - S.g) : m;
  S.act += 0.4 * (Math.abs(m - S.g) - S.act);
  const s = { t: now, x: d.x, y: d.y, z: d.z };
  if (!S.cap) {
    S.pre.push(s); if (S.pre.length > 4) S.pre.shift();
    if (now > S.cooldown && S.act > ON_K * S.g) S.cap = { buf: S.pre.slice(), start: now, last: now };
    return;
  }
  S.cap.buf.push(s);
  if (S.act > OFF_K * S.g) S.cap.last = now;
  if (now - S.cap.last > QUIET_MS || now - S.cap.start > MAX_MS) {
    const cap = S.cap;
    S.cap = null; S.pre = []; S.cooldown = now + COOLDOWN_MS; S.act = 0;
    finish(cap);
  }
}
function finish(cap) {
  const buf = cap.buf.filter(s => s.t <= cap.last + 80);
  if (buf.length < 8 || buf[buf.length - 1].t - buf[0].t < MIN_MS) return;
  handle(preprocess(buf)).catch(e => console.error('处理手势出错', e));
}
async function handle(seq) {
  if (M.mode === 'train') {
    addTemplate(M.label, seq);
    M.remaining--;
    buzz('short');
    if (M.remaining <= 0) {
      await save(); stopSensor(); M.mode = 'idle';
      M.status = '已学会 ' + M.label; M.detail = M.db[M.label].length + ' 组样本已保存';
      buzz('long');
    } else {
      M.status = `${M.label}: 还需 ${M.remaining} 次`; M.detail = '重复做出这个手势';
    }
  } else if (M.mode === 'listen') {
    const r = classify(seq);
    if (!r) { M.status = '尚未训练任何手势'; }
    else if (r.label) {
      M.status = r.label; M.detail = `${Math.round(r.conf * 100)}% 置信度 距离=${r.d.toFixed(2)}`;
      M.log.unshift(r.label); buzz('short');
    } else {
      M.status = '未知动作'; M.detail = `距离=${r.d.toFixed(2)} > 阈值${r.thr.toFixed(2)}`;
      M.log.unshift('?'); buzz('long');
    }
    M.log = M.log.slice(0, 4);
  }
  touch();
}
function startSensor() {
  if (S.on) return true;
  if (!can('@system.sensor.subscribeAccelerometer')) { box('手势AI', '设备不支持加速度传感器。'); return false; }
  Object.assign(S, { g: 0, act: 0, cap: null, pre: [], cooldown: Date.now() + 400 });
  system.sensors.subscribeAccelerometer({
    interval: 'ui', callback: onSample,
    fail: (msg, code) => console.error('加速度传感器异常', code, msg),
  });
  S.on = true;
  return true;
}
function stopSensor() {
  if (!S.on) return;
  system.sensors.unsubscribeAccelerometer();
  S.on = false; S.cap = null;
}
async function teach() {
  if (!can('@system.sensor.subscribeAccelerometer')) return box('手势AI', '设备不支持加速度传感器。');
  const t = await dialog.text({ title: '手势名称', message: '例如:画圈、摇晃、弹腕', maxLength: 12 });
  const label = t && t.action !== 'cancel' ? String(t.value || '').trim() : '';
  if (!label) return;
  if (!M.db[label] && Object.keys(M.db).length >= MAX_LABELS) return box('手势AI', `最多保存${MAX_LABELS}个手势,请先删除一个。`);
  const n = await dialog.number({ title: '训练次数', value: 3, min: 1, max: MAX_PER, decimals: 0 });
  if (!n || n.action === 'cancel') return;
  if (!startSensor()) return;
  M.mode = 'train'; M.label = label; M.remaining = Math.round(n.value);
  M.status = `${label}: 剩余 ${M.remaining} 次`; M.detail = '做出手势,然后保持静止';
  touch();
}
function listen() {
  if (!Object.keys(M.db).length) return box('手势AI', '请先至少训练一个手势。');
  if (!startSensor()) return;
  M.mode = 'listen'; M.status = '正在监听'; M.detail = '做一个训练过的手势'; touch();
}
function stop() {
  stopSensor(); M.mode = 'idle'; M.status = '就绪'; M.detail = ''; touch();
}
async function test() {
  const r = selfTest();
  if (!r.total) return box('自测', '手势需要至少2条样本才能执行自测。');
  const pct = Math.round((r.ok / r.total) * 100);
  const errs = Object.keys(r.miss).map(k => `${k} ×${r.miss[k]}`).join('\n');
  await box('自测结果', `${r.ok}/${r.total} 识别正确 (${pct}%)` + (errs ? '\n混淆情况:\n' + errs : ''));
}
async function manage() {
  const labels = Object.keys(M.db);
  if (!labels.length) return box('手势管理', '还没有训练任何手势。');
  const r = await dialog.select({ title: '删除哪一个?', items: labels.map(l => ({ label: `${l} (${M.db[l].length}样本)`, value: l })) });
  const pick = r && r.action !== 'cancel' ? (Array.isArray(r.value) ? r.value[0] : r.value) : null;
  if (!pick || !M.db[pick]) return;
  const c = await dialog.confirm({ title: '确认删除', message: `确定删除 "${pick}" 吗?` });
  if (c === true || (c && c.action && c.action !== 'cancel' && c.action !== 'dismiss')) {
    delete M.db[pick]; M.thr = {}; await save(); touch();
  }
}
function screen() {
  tick.get();
  const names = Object.keys(M.db).map(l => `${l}(${M.db[l].length})`).join(' ') || '暂无';
  const nodes = [
    ui.heading(M.status, { id: 'status' }),
    ui.text(M.detail || ' ', { id: 'detail', lines: 1 }),
    ui.text('已存手势: ' + names, { id: 'known', lines: 2 }),
  ];
  if (M.log.length) nodes.push(ui.text('最近识别: ' + M.log.join(' '), { id: 'log', lines: 1 }));
  if (M.mode === 'idle') {
    nodes.push(ui.button('训练手势', teach, { id: 'teach', tone: 'primary' }));
    nodes.push(ui.button('开始识别', listen, { id: 'listen', tone: 'success' }));
    nodes.push(ui.button('自测精度', test, { id: 'test' }));
    nodes.push(ui.button('管理手势', manage, { id: 'manage' }));
  } else {
    nodes.push(ui.button('停止', stop, { id: 'stop', tone: 'danger' }));
  }
  return nodes;
}
if (typeof __test !== 'undefined') __test.api = { preprocess, dtw, classify, addTemplate, selfTest, M, onSample, S, handle, startSensor };
(async function main() {
  ui.setTitle('手势AI');
  ui.render(screen);
  await load();
  if (Object.keys(M.db).length) M.detail = '点击开始识别来使用';
  touch();
})();