看了下题目感觉大部分都是 cv 相关的,还有几个是纯粹的数据处理相关的,这个更不感兴趣一点,所以就想在 cv 相关的里面选一个。
看了一圈感觉对这个题目更感兴趣一点,所以就打算做这个了。
然而我是 cv 低手来着,python 用的也不太熟练。
先上网搜一下 “手部关键点检测算法”,结果没想到 google colab 有现成能用的轮子。简单看了一下打算先就用这个写一个能用的出来了,如果原理比较简单的话再考虑造个轮子出来。
然后在准备阶段遇到了一下麻烦。我是在 wsl 下进行开发的,但是 wsl 在 usb 这块做得有点烂,没法与 windows 进行直接的 usb 设备共享。也就是说我没法在 wsl 下直接调用我的 usb 摄像头。
搜了一下搜到了一个叫 usbipd-win 的项目,疑似是通过 windows 与 wsl 的内网通信来实现的 usb 设备共享。遂使用之。然而配置好后发现摄像头仍然无法使用,排查了 v4l2 与 pipewire 之类的问题后最后发现视频设备默认是在 video 用户组里面的,然而 wsl 只有一个 root 用户,而 root 默认不在 video 用户组里,加进去之后就立刻能使用了。
研究了一下,打算先把 gui 的部分写出来,就是把摄像头的画面显示在图片上并且标注出手部关节的线条。正好这部分在 google colab 的官网上给出了一份样例代码,所以随便改改就能用了!
然而虽然 opencv 里面有现成能用的 gui 相关的函数 cv_imshow,但是过于原始了(基于 gtk 的,并且 gui 线程跟逻辑线程放在一块的话容易阻塞影响性能)。所以最终还是选择使用更为成熟的 gui 库 PyQt5 提供 gui。
但是我不会用 PyQt 来着😂,只能现学了。
摸索了两三个小时终于研究明白了大概如何使用,然后确定了一下项目的结构,把整个定成了以 qt 为整体框架的、gui 与逻辑线程分离的架构,并且做了比较高程度的抽象。首先就是把 cv 读到的摄像头数据包装成流的样式方便使用(source),再声明一个 processor 负责所有的图像处理与变换,最后再注册一个工作线程 CameraWorker 接受 source 与 processor 作为参数,并把这个工作线程作为所有逻辑运算的线程挂到 gui 线程上,将所有的线程与信号量管理交给 qt 来做。
又写了俩小时终于写完了。然而 PyQt 在 linux 下默认用的是 x11,但是 x11 跟 wsl 的兼容性并不好,直接跑没跑通,于是就研究了一下,发现设置一个环境变量 export QT_QPA_PLATFORM=wayland 把 gui 默认放到 wayland 下跑就行了。然后 debug 了一下改了点小错误就跑通了!debug 的过程还是很不熟练啊,果然我还是不太喜欢弱类型语言。
Update On 5:17am, 12.14: 这个识别算法除了支持图片之外还支持 VIDEO 与 LIVE_STREAM 两种类型,这两种类型会通过记录前后帧的数据来优化识别速度降低延迟。其中 LIVE_STREAM 模式需要提供一个异步的回调函数从而能够保证不会阻塞线程,但是我们的算法已经解决了这个问题,而用异步会进一步复杂化处理逻辑,所以我改用了视频模式,这个模式只需要为每一帧提供一个时间戳就能完成优化。
至于鼠标相关的操作,选用 pyautogui 来操作是很自然的想法。但是有一个致命的问题:这个包在 linux 相关的实现是调用 xserver 的,而 wsl 下的 x11 实现显然不完全,导致在 wsl 下这个包没有办法正常使用。这个疑似没啥办法规避啊,只能考虑把开发环境移到 windows 下了。
搬到 windows 下后立刻出了问题。import mediapipe 的时候提示动态链接失败了。查了一下,发现是比较新的 mediapipe 的问题,降级到 0.10.14 的版本后就能正常跑通了。
稍微调试一下并查阅文档后能够发现,每个关节在解析出的数组中的下标是一定的,并且关节点的坐标是由浮点的百分数确定的。那这样的话关于位置解析的逻辑就很好写了,确定一下屏幕尺寸以及摄像头的分辨率就能很轻松的算出一一对应的关系。至于食指与中指捏合的动作很好模拟,简单计算一下欧氏距离即可。但是比起文档中模拟点击的逻辑,感觉闭合时按下鼠标,分开时松开鼠标的逻辑更加符合直觉,所以稍微修改了一下,然后再把鼠标相关的操作抽象出来包装了一个 MouseSimulator,这样就基本写完了。至于计算坐标相关的逻辑,由于不太好抽象出来,所以就基本放在工作线程里了。
到这个时候整体的逻辑基本就写完了。但是感觉运行的有点慢,只有 7 fps 左右。又研究了一下发现 mediapipe 在 linux 与 macos 上是支持 GPU 加速的,但是目前我只能在 windows 下跑啊😰
问了一下 ai 该怎么优化,得到了一个每隔几帧再检测一次手部动作的思路。也就是每三帧选取一个关键帧检测手部动作,别的情况都直接渲染上一帧的画面。然而 debug 一下发现最慢的部分在于计算并且更改鼠标的位置!不得不把这部分的逻辑抽象出来包装成一个新的工作线程了。并且为了实现 CameraWorker 与 MouseWorker 的解耦,添加了一个操作队列,来存储所有的鼠标移动操作。并且加入了一个只有当移动幅度到达一定程度时才会 push 进队列的优化。
进一步挖掘,发现 pyautogui 慢的原因在于它是通过模拟鼠标操作来进行的操作,如果能调用原生 api 的话应该会快不少,所以在 LLM 指导下切换成了 pynput 来进行移动与点击的操作,实测下来确实快了不少!
题目中还有对平滑轨迹的要求,所以在 MouseWorker 中新增了有关加权平均的逻辑,用一个双向队列存储最近的五个坐标,越新的点权重越高,就能实现一个简单的平滑功能。
看了一下手部识别算法的论文发现完全看不懂!完全没学过 AI 是这样的😭,那看来造轮子的工作只能暂时放弃了。
发给有 macbook 的学长测试了一下,结果发现跑不起来,查阅问题发现是 metal 只接受带有 alpha 通道的颜色格式,导致 gpu 加速的时候出现了图像帧格式不兼容的问题,遂加上颜色格式特判(SRGB -> SRGBA),然后就能正常跑起来了。
学长在使用过程中提出了手指在摄像头边缘时不好操作的问题,那么就有一个朴素的想法是将屏幕映射的区域从整个摄像头改为摄像头中的一部份区域,从而规避掉手指边缘检测的问题。问了一下 LLM,得知这个东西是有名字的,叫做 ROI(Region of interest,感兴趣区域),在 Copilot 的帮助下写了下这部分的代码,并且把摄像头识别的区域设定为了全屏的 80%,就基本能够规避边缘检测的问题了。
写完这部分之后正好来完成一下一些早就该做的东西,实现一下在屏幕上渲染出实时 FPS、ROI 边界以及鼠标按下状态的相关内容。将这些内容与之前抄的绘制手部关键点的 draw_handmarks 综合起来抽象一下,全部包进 DrawUtils 里面实现一个综合的渲染管线。其实这个管线可以写成流的形式,不过为了图方便我就没有做进一步的优化。
到这一步也就基本写完了,已经基本实现了题目中的所有要求。我的猪脑也想不到有啥可以优化的了😇
感觉整个开发过程还是挺愉悦的!之前从来没用过 Copilot 来着,这次也是好好地感受了一波 Copilot 的强大啊。
比较意外的点就是 google 提供了现成的轮子可用,简化了最困难的一部分工作。真是前人栽树后人乘凉啊。
还有就是 AI 是真的完全不会😭,这方面的学习也必须提上日程了。