用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子

2026-10-315
> 本文项目代码已在行空板 M10 + DFRobot 800万 USB 自动对焦摄像头上实测跑通,包含完整的环境搭建、踩坑记录和核心实现思路。代码完整版见文末附件说明。

一、项目缘起

一直想在行空板上做一个「不碰屏幕就能画画」的小玩意。最初的想法很简单:摄像头识别手指,食指当笔,在屏幕上画线。但真做起来发现,光是“画出来”太单薄了——画完就消失,没有编辑,没有保存,交互也没层次。

于是我把需求一点点加厚,最终变成了这样一个东西:

  • 食指画线,笔尖带电焊火星粒子效果,颜色从亮黄到橙红逐渐冷却
  • 食指+中指可以拖动图形
  • 拇指+食指捏合可以缩放图形
  • 三指取消当前图形
  • 握拳确认锁定,图形永久保留
  • 五指张开清空所有图形
  • 可以不断累积新的图形,画好的不会消失

整个项目在行空板 M10 上全屏运行,摄像头 320×240,实测帧率在 5–10 FPS 之间(取决于手部姿态),交互流畅度基本够用。

用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子_image_1.webp


二、硬件与软件清单

硬件

名称 数量 说明
行空板 M10 1 主控,自带 320×240 彩色屏幕
DFRobot 800万 USB 自动对焦摄像头(FIT0729) 1 索尼 IMX179,UVC 免驱,支持 Linux
USB 数据线(摄像头自带 1 米线) 1 必须是数据线,不是充电线

为什么选这颗摄像头?

行空板 M10 的 USB 口对摄像头的兼容性有一定要求,UVC(USB Video Class)免驱协议是硬门槛。这颗 FIT0729 明确标注支持 UVC,兼容 Linux(内核 2.6.26 以上),在行空板的 Debian 系统下理论上可以即插即用。
用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子_image_2.webp

它的几个特性对这个项目特别有用:

  • 自动对焦:手在屏幕前 20–50cm 移动时,画面始终清晰,不会像定焦摄像头那样“近了糊、远了也糊”
  • 76° 视角:单手完整进入画面不需要把手伸得太远
  • 640×480 @ 30fps:本项目实际用的就是这个分辨率,匹配行空板的处理能力
  • USB2.0 High Speed:带宽足够,不会因为传输瓶颈卡帧

⚠️ 注意:这颗摄像头工作电流 160–260mA,行空板 USB 口供电能力有限。如果出现摄像头时好时坏、lsusb 偶尔能识别偶尔不能的情况,优先怀疑供电——换一根短一点、粗一点的数据线,或者加一个带独立供电的 USB Hub。

软件

名称 版本 说明
Python 3.7.x 行空板自带
opencv-python 4.8.x 支持 V4L2 后端
mediapipe 0.10.21 新版移除了 mp.solutions,必须降级
numpy < 2 MediaPipe 0.10.21 强制要求
pillow 9.x 用于中文绘制

三、交互设计

手势设计是这个项目里最花心思的部分。手指伸出/收起的组合必须区分度足够大,同时又要单手可完成。最终确定的手势表如下:

手势 状态 效果
只伸食指 IDLE → DRAWING 开始绘画,笔尖电焊粒子
收回食指 DRAWING → EDIT 结束当前笔画,进入编辑
食指+中指 EDIT 拖动图形
拇指+食指(中指收起) EDIT 缩放图形(按距离比例)
食指+中指+无名指 EDIT 取消当前图形
握拳(持续 5 帧) EDIT 确认锁定图形
五指张开(持续 3 帧) 任意 清空所有图形

所有手势都加了帧数防抖(5 帧 ≈ 0.16 秒,3 帧 ≈ 0.1 秒),避免手抖误触。


用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子_image_3.webp
用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子_image_4.webp

四、制作过程

4.1 环境搭建:MediaPipe 是最大门槛

行空板自带 Python 3.7,安装 MediaPipe 有两条路:

路线 A:DFRobot 官方脚本

在 DF 社区下载「行空板 AI 案例代码包」,里面有一个 1-Install_dependency.py 脚本,一键安装 MediaPipe 和 OpenCV。这是最省事的方式,但版本可能不是最新的,如果你的项目需要特定 API,得自己手动调整。

路线 B:pip 手动安装

pip install "mediapipe==0.10.21" "opencv-python<4.12" "numpy<2" pillow

为什么必须锁 0.10.21?
MediaPipe 从 0.10.15 开始弃用 mp.solutions 接口,0.10.31 直接移除。而我们用的 mp.solutions.hands 在 0.10.21 里还能用,新版直接 AttributeError: module 'mediapipe' has no attribute 'solutions'。

装完后快速验证:

python3 -c "import mediapipe as mp; print(mp.__version__, hasattr(mp, 'solutions'))"

期望输出:0.10.21 True。

4.2 摄像头:UVC 免驱 + V4L2 后端 + 多索引尝试

先确认摄像头被系统识别:

lsusb

插上 FIT0729 后,lsusb 列表里应该出现一个包含厂商名称的 USB 设备条目。如果只有几个 root hub,说明物理层就没连上——检查线缆是不是数据线、USB 口是否插紧,或者供电是否足够。

然后看视频节点:

ls /dev/video*

UVC 摄像头通常会生成 video0 和 video1 两个节点。但 video0 不一定是视频流节点,有时是元数据节点。所以打开摄像头时要做多索引、多后端尝试:

def open_camera():
    for idx in [0, 1, 2]:
        for backend in [cv2.CAP_V4L2, cv2.CAP_ANY]:
            cap = cv2.VideoCapture(idx, backend)
            if cap.isOpened():
                cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)
                cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)
                cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)  # 降低延迟
                return cap
            cap.release()
    return None

CAP_PROP_BUFFERSIZE = 1 是关键优化——OpenCV 默认会缓存几帧,导致画面延迟明显。设为 1 后画面跟手很多。

分辨率怎么选?

FIT0729 支持从 640×480@30fps 到 3264×2448@15fps 的多种分辨率。行空板 M10 的算力有限,640×480 是性价比最高的选择:MediaPipe 的手部检测在这个分辨率下已经足够准确,再高只会拖慢帧率。如果实测帧率仍然偏低(< 5 FPS),可以降到 320×240,手势识别精度不会有明显下降。

4.3 手部关键点与手势判断

MediaPipe 输出 21 个手部关键点。判断手指“是否伸出”的规则:

  • 食/中/无名/小指:指尖 y 坐标 小于 PIP 关节 y 坐标 → 伸出
  • 拇指:需要判断左右手,右手时 thumb_tip.x < thumb_ip.x 表示伸出,左手相反
def fingers_up(landmarks, handedness_label):
    tips = [4, 8, 12, 16, 20]
    pips = [3, 6, 10, 14, 18]
    up = []
    if handedness_label == "Right":
        up.append(landmarks[4].x < landmarks[3].x)
    else:
        up.append(landmarks[4].x > landmarks[3].x)
    for tip, pip in zip(tips[1:], pips[1:]):
        up.append(landmarks[tip].y < landmarks[pip].y)
    return up

返回的 up 是 [拇指, 食指, 中指, 无名指, 小指] 的布尔数组,后续判断组合手势就很方便:

only_index  = up[1] and not up[0] and not up[2] and not up[3] and not up[4]
two_finger  = up[1] and up[2] and not up[3] and not up[4]
thumb_index = up[0] and up[1] and not up[2] and not up[3]
three_finger = up[1] and up[2] and up[3] and not up[4]
fist = not any(up)

4.4 电焊粒子:短寿命 + 重力 + 拖尾

电焊火星的视觉特征:亮白黄色核心、橙红色外围、寿命短、向下坠落、带短拖尾。每个粒子维护 6 个变量:

class WeldParticle:
    __slots__ = ("x", "y", "vx", "vy", "life", "max_life", "size")
    def __init__(self, x, y):
        self.x, self.y = x, y
        angle = random.uniform(-math.pi, 0)  # 主要向上飞溅
        speed = random.uniform(1.2, 4.5)
        self.vx = math.cos(angle) * speed
        self.vy = math.sin(angle) * speed
        self.max_life = random.randint(6, 18)
        self.life = self.max_life
        self.size = random.choice([1, 1, 1, 2])

每帧更新时加上重力(vy += 0.25)和空气阻力(vx *= 0.98),颜色根据 life / max_life 从白黄渐变到橙红:

t = p.life / p.max_life
if t > 0.7:
    color = (200, 255, 255)   # 亮白黄
elif t > 0.35:
    color = (0, 220, 255)     # 金黄
else:
    color = (0, 80, 220)      # 橙红

用 cv2.line 画一小段拖尾(从当前位置到 x - vx*2, y - vy*2),比单纯画圆更有“火星”感。

性能注意:粒子上限设成 200,超过就停止发射新粒子,否则长时间画线会拖慢帧率。

4.5 绘画画布

用一个独立的 canvas 数组记录笔画,只在绘画状态叠加到画面上:

canvas = np.zeros((h, w, 3), dtype=np.uint8)

if prev_point is None:
    prev_point = tip
elif math.hypot(tip[0] - prev_point[0], tip[1] - prev_point[1]) > 3:
    cv2.line(canvas, prev_point, tip, (0, 255, 255), 2, cv2.LINE_AA)
    prev_point = tip

gray = cv2.cvtColor(canvas, cv2.COLOR_BGR2GRAY)
_, mask = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
frame[mask > 0] = canvas[mask > 0]

笔尖平滑用 EMA,避免 MediaPipe 关键点抖动导致笔画毛糙:

smooth_tip = (int(0.6 * ix + 0.4 * smooth_tip[0]),
              int(0.6 * iy + 0.4 * smooth_tip[1]))

4.6 图形提取与编辑

画完一笔后,从画布中提取出一个独立的图形对象:

def extract_shape(canvas, pad=20):
    gray = cv2.cvtColor(canvas, cv2.COLOR_BGR2GRAY)
    _, mask = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
    if cv2.countNonZero(mask) == 0:
        return None
    x, y, w, h = cv2.boundingRect(mask)
    x0 = max(0, x - pad);  y0 = max(0, y - pad)
    x1 = min(W, x + w + pad); y1 = min(H, y + h + pad)
    return {
        "bgr": canvas[y0:y1, x0:x1].copy(),
        "mask": mask[y0:y1, x0:x1].copy(),
        "w": x1 - x0, "h": y1 - y0,
        "cx": (x0 + x1) / 2.0,
        "cy": (y0 + y1) / 2.0,
        "scale": 1.0,
    }

移动时只修改 cx/cy,缩放时只修改 scale,绘制时以 cx/cy 为中心放置:

new_w = int(shape["w"] * shape["scale"])
new_h = int(shape["h"] * shape["scale"])
ox = int(round(shape["cx"] - new_w / 2.0))
oy = int(round(shape["cy"] - new_h / 2.0))

这样无论怎么缩放,图形中心点都不会漂移。

4.7 全屏显示

行空板的 OpenCV 窗口默认带标题栏,320×240 的屏幕留不住。DF 论坛有现成的全屏方法:

cv2.namedWindow("Weld Draw", cv2.WND_PROP_FULLSCREEN)
cv2.setWindowProperty("Weld Draw", cv2.WND_PROP_FULLSCREEN,
                      cv2.WINDOW_FULLSCREEN)

注意:不要旋转摄像头画面。MediaPipe 对正立的手部识别最准,旋转 90° 后手势识别率会明显下降。


用行空板 M10 做一支「AI 电焊笔」:手势绘画 + 电焊粒子_image_5.webp

五、踩坑记录

坑 1:AttributeError: module 'mediapipe' has no attribute 'solutions'

原因:MediaPipe 版本太新。0.10.31+ 已经移除 mp.solutions。

解决:pip install "mediapipe==0.10.21"

坑 2:ImportError: numpy.core.multiarray failed to import

原因:numpy 2.x 与 mediapipe 0.10.21 的 ABI 不兼容。

解决:pip install "numpy<2"

坑 3:math.dist 不存在

原因:math.dist 是 Python 3.8 才加入的,行空板是 3.7。

解决:用 math.hypot(dx, dy) 替代。

坑 4:中文显示为 ?????? 或 getbbox 报错

原因:cv2.putText 不支持中文;或者系统字体 fallback 到了 bitmap 字体,没有 getbbox。

解决:行空板自带中文字体,路径是:

  • /usr/share/fonts/truetype/HYQH/HYQiHei_50S.ttf(汉仪旗黑,推荐)
  • /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc(文泉驿正黑)

用 Pillow 渲染文字为 RGBA 数组贴回画面,并缓存渲染结果,避免每帧重建文字图片拖慢帧率。

坑 5:摄像头打开失败或时好时坏

针对 FIT0729 的排查顺序:

  1. lsusb 是否有设备条目:如果只有 root hub,检查数据线和 USB 口
  2. 供电是否足够:FIT0729 工作电流 160–260mA,行空板 USB 口可能供电不足。换短粗线,或加带独立供电的 USB Hub
  3. v4l2-ctl --list-devices:确认哪个 /dev/video* 是真正的视频流节点
  4. fuser -v /dev/video*:检查是否被其他进程占用

坑 6:拖动/缩放误触发

原因:手部关键点每帧都有微小抖动,手势判定会在两个状态之间来回切换。

解决:给每个手势判定加帧数防抖:

  • 握拳确认:连续 5 帧
  • 五指清空:连续 3 帧
  • 双指拖动丢失容忍:8 帧
  • 绘画结束判定:4 帧

同时限制手势判定顺序(三指 > 握拳 > 双指移动 > 拇指食指缩放),避免多手势同时满足时误判。


六、效果展示

演示视频


七、后续改进方向

  1. 多颜色切换:用不同手势切换画笔颜色
  2. 撤销/重做:维护操作栈,用特定手势回退
  3. 图形旋转:加入双指旋转手势
  4. 导出作品:把 confirmed_shapes 合成成图片保存
  5. 利用 FIT0729 的麦克风:这颗摄像头自带数字麦克风,可以采集环境音,后续做“语音指令切换画笔颜色”
  6. 手部追踪加速:把 MediaPipe 从 hands 换成 tasks.HandLandmarker(新版 API,性能更好,但代码要重写)

八、代码下载

完整代码已在附件中提供,文件名 weld_draw_unihiker.py。

运行前请确认:

python3 --version             # 应为 3.7.x
python3 -c "import cv2, mediapipe, PIL, numpy; \
print('cv2', cv2.__version__); \
print('mp', mediapipe.__version__); \
print('PIL', PIL.__version__); \
print('np', numpy.__version__)"
# 应为:cv2 4.8.x / mp 0.10.21 / PIL 9.x / np 1.x
ls /dev/video*
fc-list :lang=zh

4 项都通过后运行:

python3 weld_draw_unihiker.py

启动后控制台会打印两行:

[font] using /usr/share/fonts/truetype/HYQH/HYQiHei_50S.ttf
[cam] opened index=0 backend=V4L2

两行都出现,说明一切就绪。用 FIT0729 对准右手,食指在空中画一笔试试吧。


结语

这个项目从“画一条线”到“完整的手势绘画编辑器”,前后迭代了十几版。最大的体会是:行空板这类 ARM 开发板跑 MediaPipe 是有性能边界的,手势识别能跑,但帧率不会太高,所以交互设计上要尽量“少让用户做快速的连续动作”,多用“持续手势 + 防抖确认”来降低对帧率的依赖。

另一个体会是:摄像头的选择比想象中重要。FIT0729 的 UVC 免驱协议和 Linux 兼容性让环境搭建省了很多事,自动对焦也避免了手在远近移动时画面模糊导致的关键点丢失。如果你的摄像头在行空板上识别不稳定,优先排查供电和 UVC 兼容性,这两件事解决了,剩下的就是手势逻辑和视觉效果的打磨。


完整代码

# weld_draw_unihiker.py
# 行空板 M10 全屏手势绘画
# 依赖:opencv-python, mediapipe(0.10.x), pillow, numpy<2

import os
import math
import random
import time

import cv2
import numpy as np
import mediapipe as mp
from PIL import Image, ImageDraw, ImageFont


# ================== 屏幕与窗口 ==================
SCREEN_W = 320
SCREEN_H = 240
WINDOW_NAME = "Weld Draw"

# 为手势识别准确,建议保持 False(不旋转摄像头画面)
screen_rotation = False


def init_window():
    """DFRobot 论坛全屏方法"""
    cv2.namedWindow(WINDOW_NAME, cv2.WND_PROP_FULLSCREEN)
    cv2.setWindowProperty(WINDOW_NAME, cv2.WND_PROP_FULLSCREEN,
                          cv2.WINDOW_FULLSCREEN)


# ================== 中文绘制 ==================
_font_cache = {}
_text_img_cache = {}


def _find_cn_font(size):
    key = f"f{size}"
    if key in _font_cache:
        return _font_cache[key]

    candidates = [
        "/usr/share/fonts/truetype/HYQH/HYQiHei_50S.ttf",
        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
        "./msyh.ttc", "./simhei.ttf",
        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "C:/Windows/Fonts/msyh.ttc",
        "C:/Windows/Fonts/simhei.ttf",
        "/System/Library/Fonts/PingFang.ttc",
    ]
    font = None
    for p in candidates:
        if os.path.exists(p):
            try:
                font = ImageFont.truetype(p, size)
                print(f"[font] using {p}")
                break
            except Exception:
                continue
    _font_cache[key] = font
    return font


def _text_size(font, text):
    """兼容 Pillow 7/8/9/10"""
    if hasattr(font, "getbbox"):
        try:
            b = font.getbbox(text)
            return b[2] - b[0], b[3] - b[1], b[0], b[1]
        except Exception:
            pass
    try:
        w, h = font.getsize(text)
    except Exception:
        w, h = len(text) * 20, 24
    try:
        ox, oy = font.getoffset(text)
    except Exception:
        ox, oy = 0, 0
    return w, h, ox, oy


def _render_text_img(text, size, color_bgr, stroke):
    key = (text, size, tuple(color_bgr), stroke)
    if key in _text_img_cache:
        return _text_img_cache[key]

    font = _find_cn_font(size)
    if font is None:
        return None

    tw, th, ox, oy = _text_size(font, text)
    if tw <= 0 or th <= 0:
        return None

    pad = max(2, stroke + 2)
    img = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    color_rgba = (color_bgr[2], color_bgr[1], color_bgr[0], 255)
    try:
        draw.text((pad - ox, pad - oy), text, font=font,
                  fill=color_rgba, stroke_width=stroke,
                  stroke_fill=(0, 0, 0, 255))
    except TypeError:
        draw.text((pad - ox, pad - oy), text, font=font, fill=color_rgba)

    arr = np.array(img)
    _text_img_cache[key] = arr
    if len(_text_img_cache) > 64:
        _text_img_cache.clear()
    return arr


def put_text_cn(frame, text, pos, size=22, color_bgr=(0, 255, 255), stroke=1):
    if not text:
        return frame
    arr = _render_text_img(text, size, color_bgr, stroke)
    if arr is None:
        return frame

    h_t, w_t = arr.shape[:2]
    x, y = pos
    fh, fw = frame.shape[:2]
    x0, y0 = max(0, x), max(0, y)
    x1 = min(fw, x + w_t)
    y1 = min(fh, y + h_t)
    if x1 <= x0 or y1 <= y0:
        return frame

    sx0, sy0 = x0 - x, y0 - y
    sub = arr[sy0:sy0 + (y1 - y0), sx0:sx0 + (x1 - x0)]
    alpha = sub[:, :, 3:4].astype(np.float32) / 255.0
    rgb_bgr = sub[:, :, 2::-1].astype(np.float32)
    roi = frame[y0:y1, x0:x1].astype(np.float32)
    frame[y0:y1, x0:x1] = (rgb_bgr * alpha + roi * (1 - alpha)).astype(np.uint8)
    return frame


# ================== MediaPipe ==================
mp_hands = mp.solutions.hands
INDEX_TIP = 8
MIDDLE_TIP = 12
THUMB_TIP = 4
THUMB_IP = 3


def fingers_up(landmarks, handedness_label):
    tips = [4, 8, 12, 16, 20]
    pips = [3, 6, 10, 14, 18]
    up = []
    if handedness_label == "Right":
        up.append(landmarks[THUMB_TIP].x < landmarks[THUMB_IP].x)
    else:
        up.append(landmarks[THUMB_TIP].x > landmarks[THUMB_IP].x)
    for tip, pip in zip(tips[1:], pips[1:]):
        up.append(landmarks[tip].y < landmarks[pip].y)
    return up


# ================== 电焊粒子 ==================
MAX_PARTICLES = 200
EMIT_PER_FRAME = 4


class WeldParticle:
    __slots__ = ("x", "y", "vx", "vy", "life", "max_life", "size")

    def __init__(self, x, y):
        self.x, self.y = x, y
        angle = random.uniform(-math.pi, 0)
        speed = random.uniform(1.2, 4.5)
        self.vx = math.cos(angle) * speed
        self.vy = math.sin(angle) * speed
        self.max_life = random.randint(6, 18)
        self.life = self.max_life
        self.size = random.choice([1, 1, 1, 2])


particles = []


def emit_weld_particles(x, y, count=EMIT_PER_FRAME):
    if len(particles) >= MAX_PARTICLES:
        return
    for _ in range(count):
        particles.append(WeldParticle(x, y))


def update_and_draw_particles(frame):
    global particles
    alive = []
    for p in particles:
        p.vy += 0.25
        p.vx *= 0.98
        p.x += p.vx
        p.y += p.vy
        p.life -= 1
        if p.life <= 0:
            continue
        t = p.life / p.max_life
        if t > 0.7:
            color = (200, 255, 255)
        elif t > 0.35:
            color = (0, 220, 255)
        else:
            color = (0, 80, 220)
        cv2.line(frame, (int(p.x), int(p.y)),
                 (int(p.x - p.vx * 2), int(p.y - p.vy * 2)),
                 color, max(1, int(p.size * t)), cv2.LINE_AA)
        alive.append(p)
    particles = alive


# ================== 图形提取与合成 ==================
def extract_shape(canvas, pad=20):
    gray = cv2.cvtColor(canvas, cv2.COLOR_BGR2GRAY)
    _, mask = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
    if cv2.countNonZero(mask) == 0:
        return None
    x, y, w, h = cv2.boundingRect(mask)
    H, W = canvas.shape[:2]
    x0 = max(0, x - pad)
    y0 = max(0, y - pad)
    x1 = min(W, x + w + pad)
    y1 = min(H, y + h + pad)
    return {
        "bgr": canvas[y0:y1, x0:x1].copy(),
        "mask": mask[y0:y1, x0:x1].copy(),
        "w": x1 - x0,
        "h": y1 - y0,
        "cx": (x0 + x1) / 2.0,
        "cy": (y0 + y1) / 2.0,
        "scale": 1.0,
    }


def composite_shape(frame, shape):
    if shape is None:
        return frame

    new_w = max(4, int(shape["w"] * shape["scale"]))
    new_h = max(4, int(shape["h"] * shape["scale"]))
    bgr = cv2.resize(shape["bgr"], (new_w, new_h), interpolation=cv2.INTER_LINEAR)
    mask = cv2.resize(shape["mask"], (new_w, new_h), interpolation=cv2.INTER_LINEAR)

    ox = int(round(shape["cx"] - new_w / 2.0))
    oy = int(round(shape["cy"] - new_h / 2.0))

    fh, fw = frame.shape[:2]
    x0 = max(0, ox)
    y0 = max(0, oy)
    x1 = min(fw, ox + new_w)
    y1 = min(fh, oy + new_h)
    if x1 <= x0 or y1 <= y0:
        return frame

    sx0 = x0 - ox
    sy0 = y0 - oy
    sx1 = sx0 + (x1 - x0)
    sy1 = sy0 + (y1 - y0)

    sub_bgr = bgr[sy0:sy1, sx0:sx1]
    sub_mask = mask[sy0:sy1, sx0:sx1]
    roi = frame[y0:y1, x0:x1]

    m = (sub_mask.astype(np.float32) / 255.0)[..., None]
    frame[y0:y1, x0:x1] = (
        sub_bgr.astype(np.float32) * m + roi.astype(np.float32) * (1 - m)
    ).astype(np.uint8)
    return frame


# ================== 摄像头 ==================
CAM_INDEX_LIST = [0, 1, 2]


def open_camera():
    backends = [("V4L2", cv2.CAP_V4L2), ("ANY", cv2.CAP_ANY)]
    for idx in CAM_INDEX_LIST:
        for name, backend in backends:
            try:
                cap = cv2.VideoCapture(idx, backend)
            except Exception:
                continue
            if cap is not None and cap.isOpened():
                cap.set(cv2.CAP_PROP_FRAME_WIDTH, SCREEN_W)
                cap.set(cv2.CAP_PROP_FRAME_HEIGHT, SCREEN_H)
                try:
                    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
                except Exception:
                    pass
                print(f"[cam] opened index={idx} backend={name}")
                return cap
            if cap is not None:
                cap.release()
    return None


# ================== 主程序 ==================
def main():
    init_window()

    cap = open_camera()
    if cap is None:
        print("无法打开摄像头")
        return

    hands = mp_hands.Hands(max_num_hands=1,
                           min_detection_confidence=0.6,
                           min_tracking_confidence=0.5)

    # ---- 状态 ----
    state = "IDLE"
    canvas = None
    current_shape = None
    confirmed_shapes = []

    # ---- 绘画 ----
    prev_point = None
    smooth_tip = None
    draw_miss_frames = 0
    DRAW_END_FRAMES = 4

    # ---- 拖动 ----
    smooth_drag = None
    drag_active = False
    drag_anchor = None
    drag_miss_frames = 0
    DRAG_MISS_TOLERANCE = 8

    # ---- 缩放 ----
    scale_base_dist = None
    scale_base_scale = 1.0

    # ---- 确认 / 清空 ----
    fist_frames = 0
    FIST_CONFIRM_FRAMES = 5
    five_frames = 0
    FIVE_CONFIRM_FRAMES = 3

    # ---- 手离开 ----
    no_hand_frames = 0
    NO_HAND_FRAMES = 20

    def reset_all():
        nonlocal state, current_shape, canvas
        nonlocal prev_point, smooth_tip, draw_miss_frames
        nonlocal smooth_drag, drag_active, drag_anchor, drag_miss_frames
        nonlocal scale_base_dist, scale_base_scale
        nonlocal fist_frames, five_frames

        confirmed_shapes.clear()
        current_shape = None
        canvas[:] = 0
        state = "IDLE"
        prev_point = None
        smooth_tip = None
        draw_miss_frames = 0
        smooth_drag = None
        drag_active = False
        drag_anchor = None
        drag_miss_frames = 0
        scale_base_dist = None
        scale_base_scale = 1.0
        fist_frames = 0
        five_frames = 0

    fps_t0 = time.time()
    fps_count = 0
    fps = 0.0

    while True:
        ret, frame = cap.read()
        if not ret:
            print("读帧失败")
            break

        if frame.shape[1] != SCREEN_W or frame.shape[0] != SCREEN_H:
            frame = cv2.resize(frame, (SCREEN_W, SCREEN_H))

        if screen_rotation:
            frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)

        frame = cv2.flip(frame, 1)
        h, w = frame.shape[:2]
        if canvas is None:
            canvas = np.zeros((h, w, 3), dtype=np.uint8)

        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        results = hands.process(rgb)

        # ================== 手势与状态机 ==================
        if results.multi_hand_landmarks:
            no_hand_frames = 0
            lm = results.multi_hand_landmarks[0].landmark
            label = results.multi_handedness[0].classification[0].label
            up = fingers_up(lm, label)

            five_open = all(up)
            only_index = up[1] and not up[0] and not up[2] and not up[3] and not up[4]
            thumb_index = up[0] and up[1] and not up[2] and not up[3]
            two_finger = up[1] and up[2] and not up[3] and not up[4]
            three_finger = up[1] and up[2] and up[3] and not up[4]
            fist = not any(up)

            # 五指清空(最高优先级)
            if five_open:
                five_frames += 1
                if five_frames >= FIVE_CONFIRM_FRAMES:
                    reset_all()
            else:
                five_frames = 0

            # ---------- IDLE ----------
            if state == "IDLE":
                if only_index and five_frames == 0:
                    canvas[:] = 0
                    current_shape = None
                    prev_point = None
                    smooth_tip = None
                    smooth_drag = None
                    drag_active = False
                    drag_anchor = None
                    drag_miss_frames = 0
                    scale_base_dist = None
                    scale_base_scale = 1.0
                    fist_frames = 0
                    draw_miss_frames = 0
                    state = "DRAWING"

            # ---------- DRAWING ----------
            elif state == "DRAWING":
                if only_index:
                    draw_miss_frames = 0
                    ix = int(lm[INDEX_TIP].x * w)
                    iy = int(lm[INDEX_TIP].y * h)
                    if smooth_tip is None:
                        smooth_tip = (ix, iy)
                    else:
                        smooth_tip = (int(0.6 * ix + 0.4 * smooth_tip[0]),
                                      int(0.6 * iy + 0.4 * smooth_tip[1]))
                    tip = smooth_tip

                    emit_weld_particles(tip[0], tip[1])
                    cv2.circle(frame, tip, 5, (180, 255, 255), -1, cv2.LINE_AA)
                    cv2.circle(frame, tip, 2, (255, 255, 255), -1, cv2.LINE_AA)

                    if prev_point is None:
                        prev_point = tip
                    elif math.hypot(tip[0] - prev_point[0],
                                    tip[1] - prev_point[1]) > 3:
                        cv2.line(canvas, prev_point, tip,
                                 (0, 255, 255), 2, cv2.LINE_AA)
                        prev_point = tip
                else:
                    draw_miss_frames += 1
                    if draw_miss_frames >= DRAW_END_FRAMES:
                        current_shape = extract_shape(canvas)
                        if current_shape is not None:
                            if fist:
                                confirmed_shapes.append(current_shape)
                                current_shape = None
                                state = "IDLE"
                            else:
                                state = "EDIT"
                        else:
                            state = "IDLE"

                        prev_point = None
                        smooth_tip = None
                        smooth_drag = None
                        drag_active = False
                        drag_anchor = None
                        drag_miss_frames = 0
                        scale_base_dist = None
                        scale_base_scale = 1.0
                        fist_frames = 0
                        draw_miss_frames = 0

            # ---------- EDIT ----------
            elif state == "EDIT" and current_shape is not None:
                if three_finger:
                    # 三指取消
                    current_shape = None
                    state = "IDLE"
                    smooth_drag = None
                    drag_active = False
                    drag_anchor = None
                    drag_miss_frames = 0
                    scale_base_dist = None
                    scale_base_scale = 1.0
                    fist_frames = 0

                elif fist:
                    # 握拳确认
                    fist_frames += 1
                    if fist_frames >= FIST_CONFIRM_FRAMES:
                        confirmed_shapes.append(current_shape)
                        current_shape = None
                        state = "IDLE"
                        smooth_drag = None
                        drag_active = False
                        drag_anchor = None
                        drag_miss_frames = 0
                        scale_base_dist = None
                        scale_base_scale = 1.0
                        fist_frames = 0

                else:
                    fist_frames = 0

                    if two_finger:
                        # 双指移动
                        x1 = lm[INDEX_TIP].x * w
                        y1 = lm[INDEX_TIP].y * h
                        x2 = lm[MIDDLE_TIP].x * w
                        y2 = lm[MIDDLE_TIP].y * h
                        cx = (x1 + x2) * 0.5
                        cy = (y1 + y2) * 0.5
                        if smooth_drag is None:
                            smooth_drag = (cx, cy)
                        else:
                            smooth_drag = (0.6 * cx + 0.4 * smooth_drag[0],
                                           0.6 * cy + 0.4 * smooth_drag[1])
                        scx, scy = smooth_drag

                        drag_miss_frames = 0
                        if not drag_active:
                            drag_active = True
                            drag_anchor = (scx, scy,
                                           current_shape["cx"],
                                           current_shape["cy"])
                        else:
                            ax, ay, ox, oy = drag_anchor
                            current_shape["cx"] = ox + (scx - ax)
                            current_shape["cy"] = oy + (scy - ay)
                        scale_base_dist = None

                    elif thumb_index:
                        # 拇指+食指缩放
                        tx = lm[THUMB_TIP].x * w
                        ty = lm[THUMB_TIP].y * h
                        ix = lm[INDEX_TIP].x * w
                        iy = lm[INDEX_TIP].y * h
                        d = math.hypot(tx - ix, ty - iy)

                        if scale_base_dist is None:
                            scale_base_dist = d
                            scale_base_scale = current_shape["scale"]
                        else:
                            ratio = (d / scale_base_dist
                                     if scale_base_dist > 1e-6 else 1.0)
                            current_shape["scale"] = max(
                                0.2, min(6.0, scale_base_scale * ratio))

                        smooth_drag = None
                        drag_active = False
                        drag_anchor = None

                    else:
                        # 其它手势:保持不动,容忍短时丢失
                        if drag_active:
                            drag_miss_frames += 1
                            if drag_miss_frames > DRAG_MISS_TOLERANCE:
                                drag_active = False
                                drag_anchor = None
                                drag_miss_frames = 0
                                smooth_drag = None
                        scale_base_dist = None

        else:
            # 手离开画面
            no_hand_frames += 1
            if no_hand_frames == 1:
                fist_frames = 0
                five_frames = 0
                drag_miss_frames = 0
                drag_active = False
                drag_anchor = None
                smooth_drag = None
                scale_base_dist = None

            if no_hand_frames >= NO_HAND_FRAMES and state == "DRAWING":
                current_shape = extract_shape(canvas)
                state = "EDIT" if current_shape is not None else "IDLE"
                prev_point = None
                smooth_tip = None
                draw_miss_frames = 0

        # ================== 画面合成 ==================
        if state == "DRAWING":
            gray = cv2.cvtColor(canvas, cv2.COLOR_BGR2GRAY)
            _, mk = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
            frame[mk > 0] = canvas[mk > 0]

        for s in confirmed_shapes:
            frame = composite_shape(frame, s)
        if current_shape is not None:
            frame = composite_shape(frame, current_shape)

        update_and_draw_particles(frame)

        # FPS
        fps_count += 1
        if time.time() - fps_t0 >= 1.0:
            fps = fps_count / (time.time() - fps_t0)
            fps_t0 = time.time()
            fps_count = 0

        # 顶部 HUD
        info = f"{state} {fps:.1f}fps"
        if state == "EDIT" and current_shape is not None:
            info += f" s:{current_shape['scale']:.2f}"
        if confirmed_shapes:
            info += f" L:{len(confirmed_shapes)}"
        cv2.putText(frame, info, (6, 18),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 0), 1, cv2.LINE_AA)

        # 底部中文提示
        tips = {
            "IDLE": "伸食指绘画 / 五指清空",
            "DRAWING": "绘画中,收指结束",
            "EDIT": "拇指+食指缩放 / 双指移动 / 三指取消 / 握拳锁定",
        }
        frame = put_text_cn(frame, tips.get(state, ""), (6, h - 22),
                            size=14, color_bgr=(0, 255, 255), stroke=1)

        cv2.imshow(WINDOW_NAME, frame)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break

    cap.release()
    cv2.destroyAllWindows()
    hands.close()


if __name__ == "__main__":
    main()

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -