
1. 从需求到方案先把整体思路理清楚最近评论区好几个读者都在问同一个问题能不能拿普通摄像头加一支彩色笔直接在空中写东西画到屏幕上这个需求放到 OpenCV 里就是很典型的虚拟画笔项目。我前前后后做过好几个版本从最早只能画歪歪扭扭的线到后来加入橡皮擦、优化断线手感踩了不少坑也积累了一些比较好用的做法。这篇算是这个系列的进阶篇专门讲用 OpenCV 实现虚拟钢笔和橡皮擦的完整思路重点放在颜色空间处理、轨迹连接逻辑和擦除方案这几个环节上。适合已经会基本读帧、显示图像的读者想拿它练手也好想扩展成更有意思的交互应用也好都能从里面找到可以复用的代码和调参经验。1.1 摄像头画图这件事本质是什么虚拟钢笔听起来玄乎核心其实就三步在画面里找到笔尖位置把连续帧之间笔尖移动的路径记录下来再把路径绘制到一张独立的画布上。摄像头本身不区分谁是笔谁是背景它只看到像素颜色变化所以我们得做一个约定拿一支颜色醒目的物体当笔比如蓝色笔帽或者绿色指套只要这些物体的颜色在画面中出现那些像素区域就是笔尖标记。橡皮擦的逻辑也类似只是目标换成另一个颜色。我习惯把笔设计成蓝色橡皮擦设计成绿色这样两个颜色在 HSV 空间里相隔比较远不容易互相干扰。整个项目的实现不依赖任何深度学习模型纯 OpenCV 就能跑通摄像头加一个彩色物体就是全部硬件门槛非常低。这套方案的思想其实和很多交互式投影应用的原理是相通的本质都是颜色标记追踪加实时绘制。把基础逻辑理清楚以后将来换成手势或者别的标记物也只是换一层检测方式而已。1.2 为什么选 HSV 颜色空间而不是直接用 BGR新手最容易踩的坑就是直接用 BGR 区间做颜色过滤。BGR 的三个通道中亮度信息是混在一起的同一个蓝色物体在阳光直射、阴影区域、屏幕反光下测出来的 BGR 值差异非常大固定区间要么放掉目标要么把反光点也一起放进来。HSV 把色调 H、饱和度 S、明度 V 分开其中 H 通道对亮度变化的鲁棒性明显更好更适合做这种可控光照条件下的目标检测。这里有个细节必须提醒OpenCV 的 HSV 是由 BGR 转出来的H 的范围是 0 到 179不是常规角度里的 0 到 360。很多刚接触的人会直接套用网上的数值导致阈值怎么调都不对。另外一个坑是 OpenCV 里颜色通道顺序是 BGR写cv2.inRange的时候下限和上限要和转换后的顺序保持一致别把 B 和 R 写反。1.3 方案路线图从一帧到一整套流程整个方案的流程可以拆成四段第一步是取背景因为要支持橡皮擦就得有一个能恢复的底层画板第二步是对每一帧做 HSV 过滤和形态学处理得到干净的 mask第三步是在 mask 上找轮廓、提取笔尖坐标再通过帧间连线把轨迹画到画布上第四步是检测到绿色橡皮擦目标时把画布上对应区域恢复成背景像素。整个流程用单个主循环就能跑完实时性很稳。2. 关键技术点拆解背景、颜色阈值与形态学2.1 静态背景的处理累加平均法做参考背景为什么非要有一个干净的背景因为橡皮擦的本质是“恢复”把当前画布上的笔迹替换成背景对应位置的像素。如果背景本身就是花花的取一帧容易受传感器噪声干扰我一般用累加平均法启动摄像头后先保持画面静止几秒连续读 30 帧左右把每一帧都转成浮点型累加起来最后取平均得到一张噪声更少的背景图。这样比单帧稳定很多尤其是在暗光环境下摄像头噪点会比较明显。def build_background(cap, frame_count30): acc None count 0 while count frame_count: ok, frame cap.read() if not ok: continue frame cv2.flip(frame, 1) if acc is None: acc np.float32(frame) else: acc np.float32(frame) count 1 return (acc / frame_count).astype(np.uint8)用的时候注意几个细节采集背景的几秒内人最好离开画面手上也别举着笔环境光照最好保持稳定如果拍到一半窗口突然射进强光背景就失效了需要重新采集。另外主循环里读到的每一帧都要做同样的cv2.flip(frame, 1)镜像处理不然坐标方向会左右颠倒画出来的字是反的。2.2 HSV 颜色阈值怎么调才最省时间颜色阈值是整个项目成败的关键。我的做法是在主程序调试阶段先弹一个调参窗口出来用cv2.createTrackbar把 H、S、V 的上下限全部做成滑块实时看着右下角的二值图效果来调比闭着眼睛猜数值高效太多。def nothing(x): pass cv2.namedWindow(ColorTuning) cv2.createTrackbar(H_min, ColorTuning, 90, 179, nothing) cv2.createTrackbar(H_max, ColorTuning, 130, 179, nothing) cv2.createTrackbar(S_min, ColorTuning, 80, 255, nothing) cv2.createTrackbar(V_min, ColorTuning, 80, 255, nothing)我常用的蓝色笔帽阈值大概是 H 在 90 到 130S 下限 80V 下限 80绿色橡皮擦阈值大概是 H 在 40 到 80S 和 V 下限也放在 80 左右。S 和 V 的下限设太低会把灰色、暗部甚至白色反光点都纳进来设太高又会漏掉目标。还有一个容易忽略的点H 范围是环形的如果目标颜色刚好接近 0 和 179 交界比如红色需要拆成两段分别做 mask 再合并否则阈值区间会横跨整个色环。2.3 形态学开运算加上最小面积过滤只用inRange得到的 mask 通常很脏目标物体边缘会有细碎噪声地板上偶尔的反光点也会被当成目标。直接用这种 mask 找轮廓笔尖坐标会在几个点之间乱跳。我的处理流程是先做一次开运算也就是先腐蚀再膨胀能去掉独立的小噪点同时保持主体区域大小基本不变。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) pen_mask cv2.inRange(hsv, lower_pen, upper_pen) pen_mask cv2.morphologyEx(pen_mask, cv2.MORPH_OPEN, kernel)开运算之后可能还有一些稍大的干扰块所以找轮廓时不能全信要加最小面积过滤。在 640x480 的画面里我一般把笔的最小面积设在 500 像素左右橡皮擦的设在 400 像素左右面积比这个小的区域一律视为噪声。这里有个经验如果开运算之后边缘还是不够完整可以再补一次cv2.dilate轮廓会闭合得更好求出来的质心也更稳定。3. 核心代码实现从画线到橡皮擦3.1 摄像头、画布和颜色参数初始化视频源我用笔记本自带摄像头分辨率设置在 640x480 就够了太高反而增加处理负担对实时性没有好处。画布直接用np.zeros((480, 640, 3), dtypenp.uint8)创建尺寸必须和帧一致否则后面画线时坐标会错位。画布在这里也可以理解成“记忆层”它不直接显示给用户只负责累积笔迹最后再和实时视频合成。import cv2 import numpy as np lower_pen np.array([90, 80, 80]) upper_pen np.array([130, 255, 255]) lower_eraser np.array([40, 80, 80]) upper_eraser np.array([80, 255, 255]) pen_min_area 500 eraser_min_area 400 draw_gap 30 eraser_radius 20 canvas np.zeros((480, 640, 3), dtypenp.uint8) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)3.2 从 mask 到笔尖坐标质心提取找轮廓之后我强烈建议用质心来定位而不是外接矩形中心或者轮廓左上角。外接矩形的中心对轮廓形状变化很敏感笔帽稍微倾斜一点中心点就偏了。质心通过一阶矩计算即使目标形状不是标准圆抗干扰能力也会好很多。def locate_object(mask, min_area): contours, _ cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None largest max(contours, keycv2.contourArea) if cv2.contourArea(largest) min_area: return None M cv2.moments(largest) if M[m00] 0: return None return (int(M[m10] / M[m00]), int(M[m01] / M[m00]))这里返回None非常关键它代表当前帧没有检测到笔也就是说笔被抬起来了逻辑上要断开连线。3.3 轨迹连线与断线逻辑连续两帧之间的笔尖位置我直接通过cv2.line画在画布上而不是点圆点。原因是摄像头帧率再高笔尖快速移动时帧间距离也很大用小圆点连接会出现明显断点而画线本身能够天然补全轨迹。画出线的同时要处理断笔逻辑如果两帧之间的距离超过了draw_gap说明笔尖可能离开了画面、被大面积遮挡或者上一帧误检丢了这时不能连线要把上一帧位置清空。if pen_pos is not None: cur_x, cur_y pen_pos if pen_prev is not None: dist np.hypot(cur_x - pen_prev[0], cur_y - pen_prev[1]) if dist draw_gap: cv2.line(canvas, pen_prev, pen_pos, (255, 0, 0), 3) else: pen_prev pen_pos pen_prev pen_pos else: pen_prev None在 640x480 分辨率下draw_gap取 30 像素比较合适。太小的话快速画线时会断成一截一截太大的话笔抬起再落下容易拖出一条尾巴。这个参数和帧率、手速都有关系可以自己微调。3.4 橡皮擦的三种实现方案和推荐选型橡皮擦的实现方案我试过好几种效果差别很大。第一种最简单的就是把擦除位置画成黑色cv2.line(canvas, prev, cur, (0, 0, 0), thickness)好处是逻辑简单坏处是擦完留一块黑色区域和视频画面完全对不上看起来很突兀。第二种也是我现在推荐的做法是恢复背景像素。思路是先生成一张擦除用的单通道掩膜把橡皮擦移动路径以粗线形式画在掩膜上然后把画布中掩膜覆盖的区域替换成背景图对应位置的像素效果就像真的把纸上的笔迹擦掉露出底下的桌面。erase_mask np.zeros(canvas.shape[:2], dtypenp.uint8) cv2.line(erase_mask, eraser_prev, cur, 255, eraser_radius * 2) canvas[erase_mask 0] background[erase_mask 0]第三种是在第二种基础上做半透明处理把画布和背景按权重混合擦过的地方变成半透明的磨砂效果。这个适合做特效日常画板用不上。橡皮擦和画笔的优先级也要处理同一帧里两个目标同时出现时橡皮擦优先而且画笔线应该强制断开。因为橡皮擦出现通常意味着用户想擦除这时候如果画笔轨迹还在连线容易画出不该出现的线条。3.5 主循环综合显示与完整代码逻辑主循环里需要把前面所有模块串起来。读取一帧后先翻转、转 HSV、生成两个 mask分别定位笔和橡皮擦然后先处理橡皮擦逻辑再处理画笔逻辑最后把画布和实时画面合成显示。我习惯用cv2.addWeighted把画布做半透明叠加这样既能看到画的内容也能看到笔尖和实际环境的相对位置。background build_background(cap) pen_prev None eraser_prev None while True: ok, frame cap.read() if not ok: break frame cv2.flip(frame, 1) hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) pen_mask cv2.inRange(hsv, lower_pen, upper_pen) pen_mask cv2.morphologyEx(pen_mask, cv2.MORPH_OPEN, kernel) eraser_mask cv2.inRange(hsv, lower_eraser, upper_eraser) eraser_mask cv2.morphologyEx(eraser_mask, cv2.MORPH_OPEN, kernel) pen_pos locate_object(pen_mask, pen_min_area) eraser_pos locate_object(eraser_mask, eraser_min_area) if eraser_pos is not None: cur eraser_pos if eraser_prev is not None: erase_mask np.zeros(canvas.shape[:2], dtypenp.uint8) cv2.line(erase_mask, eraser_prev, cur, 255, eraser_radius * 2) canvas[erase_mask 0] background[erase_mask 0] eraser_prev cur pen_prev None else: eraser_prev None if pen_pos is not None and eraser_pos is None: cur pen_pos if pen_prev is not None: dist np.hypot(cur[0] - pen_prev[0], cur[1] - pen_prev[1]) if dist draw_gap: cv2.line(canvas, pen_prev, cur, (255, 0, 0), 3) else: pen_prev cur pen_prev cur else: pen_prev None display cv2.addWeighted(canvas, 0.35, frame, 0.65, 0) cv2.imshow(Virtual Pen and Eraser, display) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()画布层我会用 0.35、视频层用 0.65 来合成这样画的内容清晰可见底下的实时画面也不会被完全遮掉。如果你想要画布完全覆盖背景也可以直接显示canvas。还可以顺手加一个快捷键按c清空画布按s保存当前画布为图片代码量也不大。4. 常见问题、调参心得与扩展玩法4.1 高频问题速查表整个项目看着简单实际调起来问题还挺集中的。我把碰到过的情况整理成了一张表方便你排查。现象可能原因解决办法画面里杂斑闪烁画线乱跳背景反光点被识别为目标收窄 HSV 范围调大min_area加大开运算核笔画到一半断掉断点很多draw_gap太小或者帧率太低把draw_gap从 30 调到 40降低分辨率提升帧率橡皮擦没反应绿色阈值没调准或面积过滤太大单独显示绿色 mask 确认目标是否稳定出现擦得不干净留下残影背景本身不干净或光照变化重新采集背景保持环境光稳定画的内容和实际画面位置错位画布尺寸和帧尺寸不一致确认canvas和frame宽高一致坐标才能对应笔一进画面线就开始乱画衣服、桌面颜色和笔颜色接近换更独特的笔颜色或把阈值范围进一步收紧4.2 让手感更顺滑的几个环境细节这个项目对光照非常敏感环境因素决定了天花板。我实际用下来有几个特别管用的细节摄像头尽量固定不要用手扶着画面抖动会让笔尖坐标跟着颤如果摄像头支持手动曝光尽量锁住曝光不要让自动曝光在画面明暗变化时上下浮动否则 HSV 里的 V 分量会跳阈值就失效了。笔帽的选择也有讲究。我用的是普通签字笔的蓝色笔帽后来换成哑光的蓝色胶带识别稳定度明显提升因为反光减少了。白色反光点一旦出现经常会被 S 和 V 的下限误收进来。另外手在移动的时候尽量慢一点特别是刚接触这个项目的读者先低速画直线确认轨迹稳定以后再加速这样能很快验证断笔逻辑是否正常。4.3 给后来人的扩展建议这个项目想往下玩延展空间非常大。最简单的是加多支笔把不同颜色的目标映射到不同画笔画不同颜色画笔粗细也可以通过目标面积来控制离摄像头近一点面积大线条就粗一些。还可以在画面里指定一块固定区域作为清空按钮检测到笔出现在该区域时就重置画布等于做了一个实体控制面板。我自己的经验是真正把它做成一个能长期用的画板道具最值得投入的是记录和回放功能。用VideoWriter把绘画过程存成视频或者在退出前把canvas保存成图片都能让这个玩具变得很实用。如果以后想脱离彩色物体那就要上手势检测或者手指关键点识别了但核心的轨迹平滑、断笔判断和画布叠加逻辑依然是同一套思路。结尾把这套流程调顺之后我最大的体会是这类项目的难点其实不在 OpenCV API 调用而在逻辑状态的管理。什么时候断线、什么时候恢复背景、什么时候切换工具这些状态转换想清楚了代码本身没几行。如果你第一次跑的时候效果不理想别急着换算法先花十分钟慢慢抠一遍 HSV 阈值很多问题最后都出在 mask 不干净上。另外建议在调试阶段保留调参窗口后面做功能扩展时也能快速定位问题。希望这篇能帮你少走几段弯路如果你在调试中踩到什么新坑也欢迎来和我交流。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。