Quiet
  • 主页
  • 归档
  • 分类
  • 标签
  • 链接
  • 关于我

bajiu

  • 主页
  • 归档
  • 分类
  • 标签
  • 链接
  • 关于我
Quiet主题
  • AI

浏览器摄像头实时举手识别

bajiu
前端

2025-05-28 20:10:00

业务流程如下:

流程图:
1

有了流程提之后,开始整理代码,要把大象装冰箱,总共分八步:

1. ONNX Runtime Web 环境配置

设置 ONNX Runtime Web 的 wasm 路径、线程数、SIMD 支持和日志级别,确保模型推理高效且能找到 wasm 文件。

import * as ort from 'onnxruntime-web'

// 1. 配置 ONNX Runtime Web 环境
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/' // 指定 wasm 文件路径
ort.env.wasm.numThreads = navigator.hardwareConcurrency ?? 4 // 设置线程数
ort.env.wasm.simd = true // 启用 SIMD 加速
ort.env.logLevel = 'error' // 只输出错误日志

2. 常量定义

SIZE 是输入图片的尺寸,THR 是置信度阈值,COLORS 是关键点颜色,LINE_PAIRS 是骨架连线对。

// 2. 常量定义
const SIZE = 192 // 输入图片缩放尺寸
const THR = 0.4  // 关键点置信度阈值
const COLORS = ['#ff3838','#ff9c34','#48f90a','#dc00ff'] // 关键点颜色
const LINE_PAIRS = [[5,7],[7,9],[6,8],[8,10],[5,6]] // 骨架连线对(关键点索引对)

3. 获取 DOM 元素

获取视频、画布、状态栏等元素,并获取画布的 2D 上下文。

// 3. 获取 DOM 元素并断言类型
const video  = document.getElementById('video') as HTMLVideoElement // 摄像头视频
const canvas = document.getElementById('canvas') as HTMLCanvasElement // 绘图画布
const ctx    = canvas.getContext('2d')! // 2D 绘图上下文
const status = document.getElementById('status') as HTMLElement // 状态栏

4. 摄像头初始化

通过 getUserMedia 获取摄像头流,赋值给 video 元素。

// 4. 摄像头初始化
try {
  // 请求摄像头权限并获取视频流
  const stream = await navigator.mediaDevices.getUserMedia({ video:{ width:640, height:480 } })
  video.srcObject = stream // 将视频流赋值给 video 元素
} catch (e: any) {
  // 权限被拒绝时提示
  status.textContent = '📷 摄像头权限被拒绝: ' + e.message
  throw e
}

5. 加载 ONNX 模型

异步加载 movenet.onnx 姿态识别模型,创建推理会话。在这里我们使用了MoveNet模型,模型中文简介如下:https://tensorflow.google.cn/hub/tutorials/movenet?hl=zh-cn 链接:https://huggingface.co/Xenova/movenet-singlepose-lightning/blob/main/onnx/model.onnx

// 5. 加载 ONNX 姿态识别模型
const session = await ort.InferenceSession.create('/movenet.onnx', {
  executionProviders: ['wasm'], // 使用 wasm 推理
  graphOptimizationLevel: 'all' // 启用所有图优化
})
const OUTPUT_NAME = session.outputNames[0] // 获取输出张量名称

6. 主循环(推理与绘制)

每帧从视频流抓取一帧,缩放到指定尺寸,转换为 int32 输入张量,送入模型推理,得到关键点坐标,调用绘制和举手检测。

// 6. 主循环:每帧推理与绘制
const inputBuf = new Int32Array(SIZE*SIZE*3) // 输入张量缓冲区
const off      = new OffscreenCanvas(SIZE,SIZE) // 离屏画布用于缩放视频帧
const offCtx   = off.getContext('2d')! // 离屏画布上下文
let running    = false // 推理锁,防止并发

async function loop(){
  // 检查视频就绪且未在推理
  if(video.readyState>=2 && !running){
    running = true
    try{
      // 6-1. 抓取视频帧并缩放到模型输入尺寸
      offCtx.drawImage(video,0,0,SIZE,SIZE)
      const rgba = offCtx.getImageData(0,0,SIZE,SIZE).data // 获取像素数据
      // 6-2. 转换为 int32 输入张量(去除 alpha 通道)
      for(let i=0,p=0;i<rgba.length;i+=4){
        inputBuf[p++] = rgba[i]     // R
        inputBuf[p++] = rgba[i+1]   // G
        inputBuf[p++] = rgba[i+2]   // B
      }
      const feeds = { input: new ort.Tensor('int32', inputBuf, [1,SIZE,SIZE,3]) }
      // 6-3. 推理
      const results = await session.run(feeds)
      const kpTensor = results[OUTPUT_NAME]
      if(!kpTensor){ throw new Error('Output tensor not found: '+OUTPUT_NAME) }
      const kp = kpTensor.data // 关键点数据
      drawPose(kp)      // 绘制关键点和骨架
      detectRaise(kp)   // 检测举手
    }catch(err){
      // 推理出错时提示
      console.error('ORT run error', err)
      status.textContent = '❌ 推理出错 – 控制台查看详情'
      status.style.color = '#f00'
    }finally{
      running = false
    }
  }
  requestAnimationFrame(loop) // 下一帧继续
}
loop()

7. 绘制关键点和骨架

drawPose 函数根据关键点置信度绘制圆点和连线。

// 7. 绘制关键点和骨架
function drawPose(kp: number[]){
  ctx.clearRect(0,0,canvas.width,canvas.height) // 清空画布
  for(let i=0;i<17;i++){
    const conf = kp[i*3+2] // 置信度
    if(conf<THR) continue // 低置信度跳过
    const x = kp[i*3+1]*canvas.width  // 归一化坐标转为像素
    const y = kp[i*3]*canvas.height
    ctx.fillStyle = COLORS[i%COLORS.length] // 设置颜色
    ctx.beginPath(); ctx.arc(x,y,4,0,Math.PI*2); ctx.fill() // 绘制关键点圆
  }
  ctx.strokeStyle = '#00e7ff'; ctx.lineWidth = 2 // 骨架线样式
  LINE_PAIRS.forEach(([a,b])=>{
    // 两端关键点置信度都高才画线
    if(kp[a*3+2]>THR && kp[b*3+2]>THR){
      ctx.beginPath()
      ctx.moveTo(kp[a*3+1]*canvas.width, kp[a*3]*canvas.height)
      ctx.lineTo(kp[b*3+1]*canvas.width, kp[b*3]*canvas.height)
      ctx.stroke()
    }
  })
}

8. 举手检测

detectRaise 检查左右手腕是否高于对应肩膀,更新状态栏。

// 8. 检测举手
function detectRaise(kp: number[]){
  // 关键点索引:左肩5 右肩6 左腕9 右腕10
  const L_SH=5,R_SH=6,L_WR=9,R_WR=10
  // 判断左/右手腕是否高于对应肩膀(y值更小)
  const raised = (
    (kp[L_WR*3+2]>THR && kp[L_SH*3+2]>THR && kp[L_WR*3]<kp[L_SH*3]) ||
    (kp[R_WR*3+2]>THR && kp[R_SH*3+2]>THR && kp[R_WR*3]<kp[R_SH*3])
  )
  // 更新状态栏
  status.textContent = raised ? '🙋 检测到举手' : '🤚 未检测到举手'
  status.style.color = raised ? '#0c0' : '#666'
}

没有肩膀的时候单独举手无法实别:
3

加上肩膀:
2

附 html 如下:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hand-Raise Demo</title>
    <style>
      body{margin:0;display:flex;flex-direction:column;align-items:center;font-family:sans-serif}
      #video,#canvas{width:640px;height:480px}
      #status{margin-top:8px;font-size:1.2rem;font-weight:bold}
    </style>
  </head>
  <body>
  <video id="video" autoplay muted playsinline></video>
  <canvas id="canvas" width="640" height="480"></canvas>
  <div id="status">等待摄像头…</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
上一篇

ONNX简介

下一篇

Verilog基础语法地图(二)

©2025 By bajiu.