业务流程如下:
流程图:
有了流程提之后,开始整理代码,要把大象装冰箱,总共分八步:
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'
}
没有肩膀的时候单独举手无法实别:
加上肩膀:
附 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>