可信源码风险提示:保存或下载前请阅读源码和权限声明。
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();
})();