【Demo】✋ 数字手势识别 Html


html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>✋ 数字手势识别</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tensorflow/4.10.0/tf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Rajdhani:wght@300;400;600&display=swap" rel="stylesheet">
<style>
:root {
--cyan: #00f5ff;
--magenta: #ff00aa;
--yellow: #ffee00;
--dark: #020510;
--panel: rgba(0,20,40,0.85);
--glow-cyan: 0 0 20px #00f5ff, 0 0 60px rgba(0,245,255,0.4);
--glow-magenta: 0 0 20px #ff00aa, 0 0 60px rgba(255,0,170,0.4);
--glow-yellow: 0 0 20px #ffee00, 0 0 60px rgba(255,238,0,0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--dark);
font-family: 'Rajdhani', sans-serif;
color: #fff;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Animated grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background:
linear-gradient(rgba(0,245,255,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,255,0.05) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
z-index: 0;
}
body::after {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at 20% 50%, rgba(255,0,170,0.08) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(0,245,255,0.08) 0%, transparent 60%);
z-index: 0;
pointer-events: none;
}
@keyframes gridMove {
0% { transform: translateY(0); }
100% { transform: translateY(50px); }
}
.wrapper {
position: relative;
z-index: 1;
display: flex;
gap: 24px;
align-items: flex-start;
justify-content: center;
width: 100%;
max-width: 1200px;
padding: 16px;
}
/* ─── HEADER ─── */
.header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 10;
text-align: center;
padding: 12px;
background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
}
.header h1 {
font-family: 'Orbitron', monospace;
font-size: clamp(16px, 2.5vw, 26px);
font-weight: 900;
letter-spacing: 6px;
background: linear-gradient(90deg, var(--cyan), var(--magenta), var(--yellow), var(--cyan));
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradientShift 3s linear infinite;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* ─── VIDEO PANEL ─── */
.video-panel {
position: relative;
flex-shrink: 0;
}
.video-wrapper {
position: relative;
width: 520px;
height: 390px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(0,245,255,0.3);
box-shadow: var(--glow-cyan), inset 0 0 40px rgba(0,245,255,0.05);
}
.video-wrapper::before, .video-wrapper::after {
content: '';
position: absolute;
z-index: 10;
pointer-events: none;
}
/* Corner brackets */
.corner { position: absolute; width: 20px; height: 20px; z-index: 12; }
.corner-tl { top: 8px; left: 8px; border-top: 2px solid var(--cyan); border-left: 2px solid var(--cyan); }
.corner-tr { top: 8px; right: 8px; border-top: 2px solid var(--cyan); border-right: 2px solid var(--cyan); }
.corner-bl { bottom: 8px; left: 8px; border-bottom: 2px solid var(--cyan); border-left: 2px solid var(--cyan); }
.corner-br { bottom: 8px; right: 8px; border-bottom: 2px solid var(--cyan); border-right: 2px solid var(--cyan); }
#video {
width: 100%; height: 100%;
object-fit: cover;
transform: scaleX(-1);
filter: brightness(0.9) contrast(1.1) saturate(0.8);
}
#canvas {
position: absolute;
inset: 0;
width: 100%; height: 100%;
pointer-events: none;
}
/* Scan line effect */
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.15) 2px,
rgba(0,0,0,0.15) 4px
);
pointer-events: none;
z-index: 11;
border-radius: 16px;
}
/* Scan animation */
.scan-line {
position: absolute;
left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
opacity: 0.6;
z-index: 12;
animation: scanDown 4s linear infinite;
box-shadow: 0 0 8px var(--cyan);
}
@keyframes scanDown {
0% { top: -2px; opacity: 0; }
5% { opacity: 0.6; }
95% { opacity: 0.6; }
100% { top: 100%; opacity: 0; }
}
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
margin-top: 8px;
background: var(--panel);
border-radius: 6px;
border: 1px solid rgba(0,245,255,0.2);
font-size: 12px;
letter-spacing: 2px;
font-family: 'Orbitron', monospace;
color: var(--cyan);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--cyan);
box-shadow: var(--glow-cyan);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.6); }
}
/* ─── RIGHT PANEL ─── */
.right-panel {
display: flex;
flex-direction: column;
gap: 16px;
width: 260px;
}
/* Big digit display */
.digit-display {
background: var(--panel);
border: 1px solid rgba(255,0,170,0.4);
border-radius: 16px;
padding: 20px;
text-align: center;
box-shadow: var(--glow-magenta), inset 0 0 30px rgba(255,0,170,0.05);
position: relative;
overflow: hidden;
}
.digit-display::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(255,0,170,0.1), transparent 70%);
animation: radialPulse 2s ease-in-out infinite;
}
@keyframes radialPulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
.digit-label {
font-family: 'Orbitron', monospace;
font-size: 10px;
letter-spacing: 4px;
color: var(--magenta);
margin-bottom: 8px;
position: relative;
}
#digit-value {
font-family: 'Orbitron', monospace;
font-size: 96px;
font-weight: 900;
line-height: 1;
background: linear-gradient(135deg, #fff 20%, var(--magenta), var(--cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 20px var(--magenta));
position: relative;
transition: all 0.15s ease;
animation: none;
}
#digit-value.flash {
animation: digitFlash 0.3s ease;
}
@keyframes digitFlash {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
#confidence-bar-wrap {
margin-top: 10px;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
overflow: hidden;
position: relative;
}
#confidence-bar {
height: 100%;
background: linear-gradient(90deg, var(--magenta), var(--cyan));
border-radius: 2px;
transition: width 0.2s ease;
box-shadow: 0 0 8px var(--cyan);
width: 0%;
}
/* Gesture name */
#gesture-name {
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
letter-spacing: 3px;
color: rgba(255,255,255,0.5);
margin-top: 6px;
position: relative;
min-height: 18px;
}
/* ─── LANDMARK MINI MAP ─── */
.mini-section {
background: var(--panel);
border: 1px solid rgba(0,245,255,0.2);
border-radius: 12px;
padding: 14px;
}
.section-title {
font-family: 'Orbitron', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--cyan);
margin-bottom: 10px;
opacity: 0.7;
}
/* Number history */
.history-strip {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.history-chip {
font-family: 'Orbitron', monospace;
font-size: 18px;
font-weight: 700;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid rgba(0,245,255,0.2);
color: rgba(255,255,255,0.4);
transition: all 0.3s ease;
}
.history-chip.active {
color: #fff;
border-color: var(--cyan);
background: rgba(0,245,255,0.1);
box-shadow: var(--glow-cyan);
}
/* Finger indicators */
.fingers {
display: flex;
gap: 8px;
justify-content: center;
}
.finger-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.finger-bar-wrap {
width: 20px;
height: 50px;
background: rgba(255,255,255,0.05);
border-radius: 10px;
overflow: hidden;
position: relative;
border: 1px solid rgba(0,245,255,0.15);
}
.finger-bar {
position: absolute;
bottom: 0; left: 0; right: 0;
background: linear-gradient(to top, var(--magenta), var(--cyan));
border-radius: 10px;
transition: height 0.15s ease;
height: 0%;
box-shadow: 0 0 8px var(--cyan);
}
.finger-label {
font-family: 'Orbitron', monospace;
font-size: 7px;
color: rgba(255,255,255,0.3);
letter-spacing: 1px;
}
/* ─── PARTICLES ─── */
#particles-canvas {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
/* Number reference grid */
.ref-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.ref-cell {
aspect-ratio: 1;
border-radius: 6px;
border: 1px solid rgba(0,245,255,0.1);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Orbitron', monospace;
font-size: 14px;
color: rgba(255,255,255,0.25);
transition: all 0.3s ease;
cursor: default;
}
.ref-cell.highlight {
border-color: var(--cyan);
color: var(--cyan);
background: rgba(0,245,255,0.1);
box-shadow: var(--glow-cyan);
transform: scale(1.05);
}
/* ─── START OVERLAY ─── */
#start-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(2,5,16,0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
backdrop-filter: blur(10px);
}
#start-overlay h2 {
font-family: 'Orbitron', monospace;
font-size: 32px;
font-weight: 900;
letter-spacing: 4px;
background: linear-gradient(90deg, var(--cyan), var(--magenta));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#start-overlay p {
color: rgba(255,255,255,0.5);
letter-spacing: 2px;
font-size: 14px;
}
#start-btn {
padding: 16px 48px;
background: transparent;
border: 2px solid var(--cyan);
border-radius: 4px;
color: var(--cyan);
font-family: 'Orbitron', monospace;
font-size: 14px;
letter-spacing: 4px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
box-shadow: var(--glow-cyan);
}
#start-btn::before {
content: '';
position: absolute;
inset: 0;
background: var(--cyan);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: -1;
}
#start-btn:hover::before { transform: translateX(0); }
#start-btn:hover { color: var(--dark); box-shadow: var(--glow-cyan), inset 0 0 20px rgba(0,245,255,0.3); }
.loading-text {
font-family: 'Orbitron', monospace;
font-size: 11px;
letter-spacing: 3px;
color: rgba(0,245,255,0.6);
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
.blink { animation: blink 1s infinite; }
</style>
</head>
<body>
<canvas id="particles-canvas"></canvas>
<div class="header">
<h1>✋ GESTURE --- VISION --- DECODE</h1>
</div>
<!-- Start overlay -->
<div id="start-overlay">
<h2>手势数字识别</h2>
<p>GESTURE DIGIT RECOGNITION · NEURAL VISION SYSTEM</p>
<button id="start-btn" onclick="startCamera()">▶ 启动系统</button>
<p class="loading-text" id="load-status">请允许摄像头访问权限</p>
</div>
<div class="wrapper">
<!-- VIDEO PANEL -->
<div class="video-panel">
<div class="video-wrapper">
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<video id="video" playsinline autoplay muted></video>
<canvas id="canvas"></canvas>
<div class="scanlines"></div>
<div class="scan-line"></div>
</div>
<div class="status-bar">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">INITIALIZING...</span>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="right-panel">
<!-- DIGIT DISPLAY -->
<div class="digit-display">
<div class="digit-label">RECOGNIZED DIGIT</div>
<div id="digit-value">---</div>
<div id="confidence-bar-wrap">
<div id="confidence-bar"></div>
</div>
<div id="gesture-name">等待手势...</div>
</div>
<!-- FINGER STATUS -->
<div class="mini-section">
<div class="section-title">▸ FINGER STATE ANALYSIS</div>
<div class="fingers" id="finger-indicators">
<div class="finger-indicator">
<div class="finger-bar-wrap"><div class="finger-bar" id="f-thumb"></div></div>
<div class="finger-label">拇</div>
</div>
<div class="finger-indicator">
<div class="finger-bar-wrap"><div class="finger-bar" id="f-index"></div></div>
<div class="finger-label">食</div>
</div>
<div class="finger-indicator">
<div class="finger-bar-wrap"><div class="finger-bar" id="f-middle"></div></div>
<div class="finger-label">中</div>
</div>
<div class="finger-indicator">
<div class="finger-bar-wrap"><div class="finger-bar" id="f-ring"></div></div>
<div class="finger-label">环</div>
</div>
<div class="finger-indicator">
<div class="finger-bar-wrap"><div class="finger-bar" id="f-pinky"></div></div>
<div class="finger-label">小</div>
</div>
</div>
</div>
<!-- NUMBER GRID -->
<div class="mini-section">
<div class="section-title">▸ NUMBER MATRIX</div>
<div class="ref-grid" id="ref-grid">
<div class="ref-cell" id="cell-0">0</div>
<div class="ref-cell" id="cell-1">1</div>
<div class="ref-cell" id="cell-2">2</div>
<div class="ref-cell" id="cell-3">3</div>
<div class="ref-cell" id="cell-4">4</div>
<div class="ref-cell" id="cell-5">5</div>
<div class="ref-cell" id="cell-6">6</div>
<div class="ref-cell" id="cell-7">7</div>
<div class="ref-cell" id="cell-8">8</div>
<div class="ref-cell" id="cell-9">9</div>
</div>
</div>
<!-- HISTORY -->
<div class="mini-section">
<div class="section-title">▸ SESSION LOG</div>
<div class="history-strip" id="history"></div>
</div>
</div>
</div>
<script>
// ─── PARTICLES ───────────────────────────────────────────
const pCanvas = document.getElementById('particles-canvas');
const pCtx = pCanvas.getContext('2d');
let particles = [];
let W, H;
function resizeParticles() {
W = pCanvas.width = window.innerWidth;
H = pCanvas.height = window.innerHeight;
}
resizeParticles();
window.addEventListener('resize', resizeParticles);
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W;
this.y = Math.random() * H;
this.vx = (Math.random() - 0.5) * 0.4;
this.vy = (Math.random() - 0.5) * 0.4;
this.r = Math.random() * 1.5 + 0.3;
this.life = Math.random();
this.color = Math.random() > 0.5 ? '#00f5ff' : '#ff00aa';
}
update() {
this.x += this.vx; this.y += this.vy;
if (this.x < 0 || this.x > W || this.y < 0 || this.y > H) this.reset();
}
draw() {
pCtx.beginPath();
pCtx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
pCtx.fillStyle = this.color;
pCtx.globalAlpha = 0.4 * this.life + 0.1;
pCtx.fill();
pCtx.globalAlpha = 1;
}
}
for (let i = 0; i < 80; i++) particles.push(new Particle());
function animParticles() {
pCtx.clearRect(0, 0, W, H);
// Draw connections
for (let i = 0; i < particles.length; i++) {
for (let j = i+1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 80) {
pCtx.beginPath();
pCtx.moveTo(particles[i].x, particles[i].y);
pCtx.lineTo(particles[j].x, particles[j].y);
pCtx.strokeStyle = '#00f5ff';
pCtx.globalAlpha = (1 - dist/80) * 0.08;
pCtx.lineWidth = 0.5;
pCtx.stroke();
pCtx.globalAlpha = 1;
}
}
particles[i].update();
particles[i].draw();
}
requestAnimationFrame(animParticles);
}
animParticles();
// ─── HAND TRACKING ───────────────────────────────────────
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let hands = null;
let lastDigit = -1;
let history = [];
const MAX_HISTORY = 10;
const FINGER_TIPS = [4, 8, 12, 16, 20];
const FINGER_PIPS = [3, 6, 10, 14, 18];
const FINGER_MCPS = [2, 5, 9, 13, 17];
// Gesture name map
const GESTURE_NAMES = {
0: '拳头 · FIST',
1: '一指禅 · ONE',
2: '剪刀 · PEACE',
3: '三指 · THREE',
4: '四指 · FOUR',
5: '张开 · FIVE',
6: '六 · SIX',
7: '七 · SEVEN',
8: '八 · EIGHT',
9: '九 · NINE',
};
function startCamera() {
document.getElementById('load-status').textContent = '正在加载模型...';
const btn = document.getElementById('start-btn');
btn.disabled = true;
btn.textContent = '加载中...';
navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' } })
.then(stream => {
video.srcObject = stream;
video.play();
initHands();
})
.catch(err => {
document.getElementById('load-status').textContent = '摄像头访问失败: ' + err.message;
});
}
function initHands() {
hands = new Hands({
locateFile: file => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 1,
modelComplexity: 1,
minDetectionConfidence: 0.7,
minTrackingConfidence: 0.5
});
hands.onResults(onResults);
document.getElementById('load-status').textContent = '模型加载中...';
const camera = new Camera(video, {
onFrame: async () => {
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 480;
await hands.send({ image: video });
},
width: 640,
height: 480
});
camera.start().then(() => {
document.getElementById('start-overlay').style.display = 'none';
document.getElementById('status-text').textContent = 'LIVE · SCANNING';
document.getElementById('status-dot').style.background = '#00ff88';
document.getElementById('status-dot').style.boxShadow = '0 0 20px #00ff88';
});
}
function onResults(results) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!results.multiHandLandmarks || results.multiHandLandmarks.length === 0) {
updateUI(-1, 0, null);
return;
}
const landmarks = results.multiHandLandmarks[0];
// Draw skeleton
drawSkeleton(landmarks);
// Recognize gesture
const { digit, confidence, fingerStates } = recognizeDigit(landmarks);
updateUI(digit, confidence, fingerStates);
}
function drawSkeleton(landmarks) {
const W = canvas.width, H = canvas.height;
const connections = [
[0,1],[1,2],[2,3],[3,4],
[0,5],[5,6],[6,7],[7,8],
[0,9],[9,10],[10,11],[11,12],
[0,13],[13,14],[14,15],[15,16],
[0,17],[17,18],[18,19],[19,20],
[5,9],[9,13],[13,17]
];
// Glow connections
connections.forEach(([a, b]) => {
const ax = (1 - landmarks[a].x) * W;
const ay = landmarks[a].y * H;
const bx = (1 - landmarks[b].x) * W;
const by = landmarks[b].y * H;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
const grad = ctx.createLinearGradient(ax, ay, bx, by);
grad.addColorStop(0, 'rgba(0,245,255,0.9)');
grad.addColorStop(1, 'rgba(255,0,170,0.9)');
ctx.strokeStyle = grad;
ctx.lineWidth = 2.5;
ctx.shadowColor = '#00f5ff';
ctx.shadowBlur = 10;
ctx.stroke();
ctx.shadowBlur = 0;
});
// Draw landmarks
landmarks.forEach((pt, i) => {
const x = (1 - pt.x) * W;
const y = pt.y * H;
const isTip = FINGER_TIPS.includes(i);
const r = isTip ? 7 : 4;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = isTip ? '#ffee00' : '#00f5ff';
ctx.shadowColor = isTip ? '#ffee00' : '#00f5ff';
ctx.shadowBlur = 15;
ctx.fill();
ctx.shadowBlur = 0;
if (isTip) {
ctx.beginPath();
ctx.arc(x, y, r + 4, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,238,0,0.4)';
ctx.lineWidth = 1;
ctx.stroke();
}
});
}
function isFingerExtended(landmarks, fingerTip, fingerPip, fingerMcp) {
const tip = landmarks[fingerTip];
const pip = landmarks[fingerPip];
const mcp = landmarks[fingerMcp];
return tip.y < pip.y - 0.02 && tip.y < mcp.y;
}
function isThumbExtended(landmarks) {
const tip = landmarks[4];
const ip = landmarks[3];
const mcp = landmarks[2];
const base = landmarks[1];
// Compare horizontal distance from base
const dx = Math.abs(tip.x - base.x);
const dy = Math.abs(tip.y - base.y);
return dx > 0.06 || (Math.abs(tip.x - ip.x) > 0.04);
}
function recognizeDigit(landmarks) {
const thumbExt = isThumbExtended(landmarks);
const indexExt = isFingerExtended(landmarks, 8, 6, 5);
const middleExt = isFingerExtended(landmarks, 12, 10, 9);
const ringExt = isFingerExtended(landmarks, 16, 14, 13);
const pinkyExt = isFingerExtended(landmarks, 20, 18, 17);
const fingerStates = [thumbExt, indexExt, middleExt, ringExt, pinkyExt];
const extCount = fingerStates.filter(Boolean).length;
let digit = -1;
let confidence = 0.7;
const fingers = [thumbExt, indexExt, middleExt, ringExt, pinkyExt];
const f = fingers.map(v => v ? 1 : 0);
// Recognition logic
if (f[0]===0 && f[1]===0 && f[2]===0 && f[3]===0 && f[4]===0) { digit = 0; confidence = 0.92; }
else if (f[1]===1 && f[2]===0 && f[3]===0 && f[4]===0) { digit = 1; confidence = 0.90; }
else if (f[1]===1 && f[2]===1 && f[3]===0 && f[4]===0) { digit = 2; confidence = 0.88; }
else if (f[1]===1 && f[2]===1 && f[3]===1 && f[4]===0) { digit = 3; confidence = 0.87; }
else if (f[1]===1 && f[2]===1 && f[3]===1 && f[4]===1 && f[0]===0) { digit = 4; confidence = 0.88; }
else if (f[0]===1 && f[1]===1 && f[2]===1 && f[3]===1 && f[4]===1) { digit = 5; confidence = 0.92; }
else if (f[0]===1 && f[4]===1 && f[1]===0 && f[2]===0 && f[3]===0) { digit = 6; confidence = 0.82; }
else if (f[0]===1 && f[1]===1 && f[2]===1 && f[3]===0 && f[4]===0) { digit = 7; confidence = 0.82; }
else if (f[0]===1 && f[1]===1 && f[2]===1 && f[3]===1 && f[4]===0) { digit = 8; confidence = 0.85; }
else if (f[0]===0 && f[1]===1 && f[2]===1 && f[3]===1 && f[4]===1) { digit = 9; confidence = 0.85; }
return { digit, confidence, fingerStates };
}
let prevHighlight = -1;
let historyBuffer = [];
let stableCount = 0;
let stableDigit = -1;
function updateUI(digit, confidence, fingerStates) {
// Stable recognition - require 8 frames
if (digit === stableDigit) {
stableCount++;
} else {
stableDigit = digit;
stableCount = 0;
}
const stable = stableCount >= 8;
const displayDigit = stable ? digit : lastDigit;
// Update digit display
if (displayDigit !== lastDigit && stable && digit >= 0) {
const el = document.getElementById('digit-value');
el.textContent = digit;
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
lastDigit = digit;
// History
historyBuffer.unshift(digit);
if (historyBuffer.length > MAX_HISTORY) historyBuffer.pop();
renderHistory();
} else if (digit < 0 && stableCount > 20) {
document.getElementById('digit-value').textContent = '---';
document.getElementById('gesture-name').textContent = '等待手势...';
lastDigit = -1;
}
// Confidence bar
const confBar = document.getElementById('confidence-bar');
confBar.style.width = (digit >= 0 ? confidence * 100 : 0) + '%';
// Gesture name
if (digit >= 0) {
document.getElementById('gesture-name').textContent = GESTURE_NAMES[digit] || '';
}
// Highlight cell
if (prevHighlight >= 0) document.getElementById('cell-' + prevHighlight)?.classList.remove('highlight');
if (digit >= 0 && stable) {
document.getElementById('cell-' + digit)?.classList.add('highlight');
prevHighlight = digit;
} else {
prevHighlight = -1;
}
// Finger bars
if (fingerStates) {
const ids = ['f-thumb', 'f-index', 'f-middle', 'f-ring', 'f-pinky'];
fingerStates.forEach((ext, i) => {
const el = document.getElementById(ids[i]);
if (el) el.style.height = ext ? '90%' : '15%';
});
}
}
function renderHistory() {
const container = document.getElementById('history');
container.innerHTML = '';
historyBuffer.forEach((d, i) => {
const chip = document.createElement('div');
chip.className = 'history-chip' + (i === 0 ? ' active' : '');
chip.textContent = d;
container.appendChild(chip);
});
}
</script>
</body>
</html>