【Demo】✋ 数字手势识别 Html

【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>
相关推荐
HelloReader2 小时前
Leptos + Tauri 2 前端配置Trunk + SSG + 移动端热重载一次打通(Leptos 0.6 口径)
前端
HelloReader2 小时前
Next.js + Tauri 2 用 Static Export 把 React 元框架装进桌面/移动端
前端
Wect2 小时前
从输入URL到页面显示的完整技术流程
前端·面试·浏览器
没有bug.的程序员2 小时前
自动化测试之魂:Selenium 与 TestNG 深度集成内核、Page Object 模型实战与 Web UI 交付质量指南
前端·自动化测试·selenium·ui·testng·page·object
夕除3 小时前
js--22
前端·javascript·python
南雨北斗3 小时前
TypeScript 配置文件 `tsconfig.json`
前端
木斯佳3 小时前
前端八股文面经大全:万兴科技前端实习一面(2026-2-3)·面经深度解析
前端·科技
yuki_uix3 小时前
别让 AI 骗了:这些状态管理工具真的适合你吗?
前端·ai编程
日月云棠3 小时前
UE5 打包后 EXE 程序单实例的两种实现方法
前端·c++