先放效果
Link: cygra.github.io/hand-gestur...
Github: github.com/Cygra/hand-...
欢迎大家到 github 上点星 ⭐️,谢谢支持!
以下正文:
项目准备
最近在做一个和 tldraw 有关的项目,主要的需求就是多人协作白板。用鼠标画图感觉很不方便,当然现在用 iPad + Pencil 的人也已经很多了,很少有人还会用鼠标这么古老的东西去屏幕上戳。
但是在项目开发的过程中,还是灵光一现,是不是可以用摄像头实时识别手势来画图呢。于是去搜索了一下,最现成最方便的技术栈应该就是 Google AI 旗下的 Mediapipe。大概看了一眼官网上的 demo,不得不说,谷歌官网的文档是真难懂,和前端老哥们熟悉的 React、Nextjs 等等的官网根本不是一种风格。
调包还不容易吗,说干就干。
先用 nextjs 的脚手架生成一个模板项目:
bash
npx create-next-app@latest
然后把 mediapipe 引入进来,Mediapipe 的 Web 官方包是 www.npmjs.com/package/@me... ,直接安装,开箱即用。
bash
npm i @mediapipe/tasks-vision
下面正式开始调包。
要实现上图的效果,主要会用到三个 html 标签:
- video 标签,用于开启摄像头,获取视频流
- canvas 标签,渲染手势点、连接线
- canvas 标签,作为画板,绘制画的线
这里用两个 canvas 标签分别画线是因为,手势点在每次重算手势的时候都会清空重画,需要调用一次清空方法。而画板上的内容是一直保持的,所以另用一个独立的 canvas。
读取视频流
这里主要做的是通过调用系统摄像头,拿到图像。都是标准的浏览器 api,比较简单。
process
是正式开始调用 Mediapipe 的方法,所以放在 video
标签的 loadeddata
事件里。
tsx
const prepareVideoStream = async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.addEventListener("loadeddata", () => {
process();
});
}
};
初始化 Mediapipe
在 process
方法中进行 Mediapipe 的初始化,这一块可以看官网 ai.google.dev/edge/mediap...
tsx
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
);
const gestureRecognizer = await GestureRecognizer.createFromOptions(
vision,
{
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task",
delegate: "GPU",
},
numHands: 1,
runningMode: "VIDEO",
}
);
识别和渲染
因为要获取实时的结果,并绘制到画面上,所以下面两个方法都是在 requestAnimationFrame
中调用的。
大体的结构可以表示为:
tsx
const process = async () => {
// 初始化 Mediapipe
const vision = await FilesetResolver.forVisionTasks(...);
const gestureRecognizer = await GestureRecognizer.createFromOptions(...);
const renderLoop = () => {
// 获取手势识别结果
const result = gestureRecognizer.recognizeForVideo(video, startTimeMs);
// 渲染手指、手掌等
drawLandmarks(landmarks);
// 渲染画板上的画线
drawLine(strokeCanvasCtx, x, y);
// 通过 requestAnimationFrame 进行下一次调用
requestAnimationFrame(() => {
renderLoop();
});
};
// 手动触发第一次调用
renderLoop();
};
识别
调用 recognizeForVideo
方法,传入 video 和时间戳,来获取 result
手势识别结果。
video 即是 videoRef.current
,也就是 video html 标签,因为在 react 组件中,所以这里通过 ref 调用。
这个方法需要传入一个时间戳,来指明读取视频流中的帧。
返回的 result
即是识别的结果,包含了 gesture
(预置的模型识别出的手掌姿势,如手掌张开 🖐🏻、胜利✌🏻、向上指等等)、landmarks
(手掌上每个关节的坐标点)等等。
tsx
let lastWebcamTime = -1;
...
lastWebcamTime = video.currentTime;
const startTimeMs = performance.now();
const result = gestureRecognizer.recognizeForVideo(video, startTimeMs);
渲染
Mediapipe 是支持识别多只手的,所以返回的 landmarks 结果是一个二维数组,第一维是每一只不同的手掌。
我这里希望只用一只手来画图,所以前面初始化的时候已经配置了 numHands: 1
,所以第一维的长度一定是 1。
第二维是手掌上每个点的坐标,这里将整个手掌按关节分为 20 个点,所以会输出 20 个坐标。每个坐标用一个 {x: 1, y: 1}
这样的对象来表示。
下面看具体的代码。
Mediapipe 输出的坐标点其实是归一化的,也就是说数值是在 [0-1]
这样的区间,因此要先获取 canvas 的宽和高,再乘以归一化的坐标值,才能获取到映射到 canvas 上的真正的坐标值。
Mediapipe 官方的 gesture 中没有提供 👌🏻 这个手势的模型,所以这里事实上没有使用到 gesture 的能力,只使用了 landmark。我看到官网上有关于自定义模型的内容,准备以后再做尝试。
这里我暂时通过一种比较简单粗暴的方式来识别是否满足 👌🏻 的条件,即拇指指尖和食指指尖的坐标点足够相近。如果 △x 和 △y 都足够小,则认为两个指尖连在了一起,构成了 👌🏻 的手势。这里的 50 是在屏幕上试出来一个比较适当的距离。
如果计算认为构成了 👌🏻,则按食指指尖的坐标点在 canvas 上画线。因为 video 读入的视频流是镜像的,所以 x 坐标会取 1-x
来镜像。(video 标签可以通过 css 样式来镜像)
画线调用的 drawLine
方法和画手掌的 drawLandmarks
方法在后文叙述。
tsx
if (result.landmarks) {
const width = landmarkCanvas.width;
const height = landmarkCanvas.height;
if (result.landmarks.length === 0) {
landmarkCanvasCtx.clearRect(0, 0, width, height);
} else {
result.landmarks.forEach((landmarks) => {
const thumbTip = landmarks[THUMB_TIP_INDEX];
const indexFingerTip = landmarks[INDEX_FINGER_TIP_INDEX];
const dx = (thumbTip.x - indexFingerTip.x) * width;
const dy = (thumbTip.y - indexFingerTip.y) * height;
const connected = dx < 50 && dy < 50;
if (connected) {
const x = (1 - indexFingerTip.x) * width;
const y = indexFingerTip.y * height;
drawLine(strokeCanvasCtx, x, y);
} else {
prevX = prevY = 0;
}
drawLandmarks(
landmarks,
landmarkCanvasCtx,
width,
height,
connected
);
});
}
}
画线
这里为了让画出的线更平滑,所以引入了一个 SMOOTHING_FACTOR
。
tsx
const SMOOTHING_FACTOR = 0.3;
const drawLine = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
if (!prevX || !prevY) {
prevX = x;
prevY = y;
}
const smoothedX = prevX + SMOOTHING_FACTOR * (x - prevX);
const smoothedY = prevY + SMOOTHING_FACTOR * (y - prevY);
ctx.lineWidth = 5;
ctx.moveTo(prevX, prevY);
ctx.lineTo(smoothedX, smoothedY);
ctx.strokeStyle = "white";
ctx.stroke();
ctx.save();
prevX = smoothedX;
prevY = smoothedY;
};
画手掌
Mediapipe 提供了官方的 DrawingUtils 类,直接调用即可。
tsx
const drawLandmarks = (
landmarks: NormalizedLandmark[],
ctx: CanvasRenderingContext2D,
width: number,
height: number,
connected: boolean
) => {
const drawingUtils = new DrawingUtils(ctx);
// 先清空画布
ctx.clearRect(0, 0, width, height);
// 画关节点之间的连接线
drawingUtils.drawConnectors(landmarks, GestureRecognizer.HAND_CONNECTIONS, {
color: "#00FF00",
lineWidth: connected ? 5 : 2,
});
// 画关节点、指尖
drawingUtils.drawLandmarks(landmarks, {
color: "#FF0000",
lineWidth: 1,
});
};
其他还有一些监听窗口尺寸、定义 ref、页面布局相关的代码,这里就不再赘述了。
到这里就结束啦,感谢您的观看,如果能去 github 上点个小星星 ⭐️ 就更好啦 ~