魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中"点亮"世界名画

2026-10-0418

手持一根遥控器,指向哪里,哪里就能从黑暗中浮现出绚丽的艺术画作;按一下确认键,还能把眼前的画面"定格"成新的“名画”。

魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

一、项目简介

你有没有幻想过,手持一根魔法棒,指向哪里,哪里就能从黑暗中浮现出绚丽的画面?

这个项目把这个幻想变成了现实。我们使用 LattePanda Alpha 800s 作为主控,搭配 15.6" 1920×1080 IPS 电容触摸屏 和 普通 USB 摄像头,通过 OpenCV 的 ArUco 标记识别技术,将一枚小小的 ArUco 标记贴在小米蓝牙遥控器顶端,遥控器就变成了真正的"魔法棒"。

当你举起遥控器对准屏幕,标记在摄像头画面中的位置会被实时解算,屏幕对应位置就会"亮"起一团柔和的圆形光圈,光圈内呈现出世界名画的内容——就像用一支手电筒在黑暗中照亮了艺术殿堂。

遥控器上的按键也没有闲着:

  • Home 键 → 切换到下一张名画
  • 语音键 → 切换到上一张名画
  • 确认键 → 摄像头拍下一张照片,追加为最后一张名画
  • 菜单键 → 退出程序

指向、切换、拍照、退出,全部用一根遥控器搞定,整根遥控器就是一台完整的"魔法装置"。

整个系统由 LattePanda Alpha & Delta UPS 扩展板供电,断电时自动切换电池,续航最长可达 8 小时,随时随地都能开启这场光影魔法。

二、硬件清单

硬件 型号/规格 数量 说明
开发板 LattePanda Alpha 800s 1 Intel m3-8100Y 双核,8GB 内存
显示屏 DFRobot 15.6" 1920×1080 IPS 电容触摸屏 1 Type-C/HDMI 双接口,十点触控
摄像头 普通 USB 摄像头(UVC 免驱) 1 支持 Windows/Linux 即插即用
UPS 扩展板 LattePanda Alpha & Delta UPS Hat 1 3 节 18650 电池供电,HID-UPS 协议
锂电池 3.7V 18650 锂离子电池 3 UPS 不含电池,需自备
蓝牙遥控器 小米蓝牙遥控器 1 需与 Windows 蓝牙配对
标记 ArUco 标记(DICT_4X4_50,ID 0) 1 打印后贴在遥控器顶端

魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

软件清单:

  • Windows 10 Pro(或 Ubuntu Linux)
  • Python 3.11+
  • OpenCV(opencv-contrib-python,必须为 contrib 版本)
  • NumPy
  • keyboard

三、制作过程

3.1 安装 UPS 扩展板

UPS Hat 的安装是整个项目的基础。安装前请务必关闭 UPS 电源开关,先不要安装锂电池,等 UPS Hat 完全插到位后再装电池。

将 UPS Hat 的电源接口一侧与 LattePanda 的 Type-C/RJ45/HDMI 接口一侧对齐,然后垂直向下插入排针。插好后仔细检查每一根针脚是否完全插入,没有浮起或弯曲。确认无误后,再安装 3 节 18650 锂电池,注意正负极方向。

安装完成后,使用 UPS Hat 上的 Type-C PD 接口为整个系统供电(电压 15V,功率 ≥30W),或者使用 5.5×2.1mm DC 圆口(12~24V)供电。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

3.2 连接显示屏和摄像头

15.6 寸触摸屏支持全功能 Type-C 或 HDMI 信号输入。推荐使用 Type-C 连接——一根线同时完成视频传输、音频传输、触控信号和反向 PD 供电,支持盲插,无需区分数据口和供电口。

如果使用 HDMI 连接,则需要额外用 USB Type-C 线连接触摸接口。

USB 摄像头直接插入 LattePanda 的 USB 3.0 接口即可。LattePanda 兼容各类 UVC 免驱摄像头,在 Windows 和 Linux 下都无需安装驱动。

摄像头摆放建议:将摄像头放在屏幕正下方或正上方中央位置,让摄像头正对使用者。这样遥控器在手中左右移动时,标记在摄像头画面中的位移方向与屏幕方向一致,操作最直观。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

3.3 制作"魔法棒"——把标记贴上遥控器

这是本项目的关键步骤。ArUco 标记必须平整、清晰、无遮挡地贴在遥控器顶端。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

打印标记:用下面的代码生成 ArUco 标记,打印在哑光不干胶纸上(不要用光面纸,避免反光):

import cv2

aruco = cv2.aruco
dictionary = aruco.getPredefinedDictionary(aruco.DICT_4X4_50)

# 生成 ID 为 0 的标记,尺寸 600×600
marker = aruco.generateImageMarker(dictionary, 0, 600)

# 加上白色静区边框
marker = cv2.copyMakeBorder(marker, 100, 100, 100, 100,
                            cv2.BORDER_CONSTANT, value=255)

cv2.imwrite("my_marker.png", marker)
print("已生成 my_marker.png")

魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

裁剪与粘贴:

  1. 打印后沿标记外沿裁剪,保留四周至少 5mm 的白色静区——静区是 ArUco 识别的一部分,裁剪掉会导致识别失败。
  2. 将标记贴在遥控器顶端的正面(朝向摄像头的一面),而不是端面(朝向前方的一面)。
  3. 用手指握住遥控器侧面时,确保不会遮挡标记。

为什么要贴在正面? 因为你手持遥控器指向屏幕时,遥控器的正面自然朝向摄像头,标记也就正对镜头,识别率最高。如果贴在端面,手腕稍微转动,标记就偏离摄像头视野,识别容易丢失。

防反光小技巧:不要用透明胶带覆盖标记表面。如果必须固定,用透明胶带只贴四角,避开标记主体区域。

3.4 配对蓝牙遥控器

在 Windows 中打开"设置 → 蓝牙和其他设备 → 添加设备",按住小米遥控器上的按键进入配对模式,在列表中选择遥控器完成配对。

配对成功后,Windows 会将遥控器识别为一个键盘设备。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

3.5 确认遥控器按键映射

不同型号的小米遥控器在 Windows 下的按键映射可能不同。运行下面的测试代码,确认每个按键实际触发的键名:

import keyboard
import time

print("请在 20 秒内依次按一次 Home 键、语音键、确认键、菜单键,观察输出...")
start = time.time()
while time.time() - start < 20:
    event = keyboard.read_event()
    print(f"{time.time()-start:6.3f}s  {event.event_type:5}  {repr(event.name)}")

注意这里用 repr(event.name) 打印,目的是区分字符串 "None" 和 Python 的 None。菜单键通常会显示为 None(无引号)。

魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

实测参考映射:

遥控器按键 event.name 建议功能
Home 键 'f22' 切换下一张
语音键 'menu' 切换上一张
确认键 'right' 拍照存为最后一张
菜单键 None 退出程序

根据输出结果,修改下方程序配置区中的 KEY_NEXT、KEY_PREV、KEY_SNAP、KEY_QUIT。

重要提示:小米遥控器的语音键和部分其他按键在 Windows 下会被识别为长按重复触发的按键——按下后持续发送 down 事件,直到松开才发一次 up。测试中可以看到约 500ms 后每隔 30~45ms 就有一个 down,一次按下可能触发十几个事件。程序已内置处理逻辑,无需担心。

3.6 配置系统环境

在 LattePanda 上打开命令行,安装项目所需的 Python 库:

pip install opencv-contrib-python numpy keyboard

注意:必须安装 opencv-contrib-python 而非普通的 opencv-python,因为 ArUco 标记检测属于 OpenCV 的 contrib 扩展模块。

验证安装:

python -c "import cv2; print(cv2.__version__); print(hasattr(cv2, 'aruco'))"

如果输出 True,说明 ArUco 模块可用。

3.7 准备名画图片

将想要展示的世界名画图片与程序放在同一目录下,命名为 painting1.jpg、painting2.jpg、painting3.jpg …… 程序会自动扫描所有 painting*.jpg 文件,并按文件名字母顺序排序加载。

支持任意数量的名画,程序会在内存中预加载全部图片,切换时零延迟。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

四、完整程序代码

# -*- coding: utf-8 -*-
"""
MagicTorch —— 手持 ArUco 标记"照亮"屏幕上的世界名画
支持蓝牙遥控器切换名画、拍照存为最后一张、菜单键退出

硬件:
    LattePanda Alpha 800s + 15.6" IPS 触摸屏 + USB 摄像头 + UPS Hat
    小米蓝牙遥控器(连接 Windows 蓝牙)

依赖:
    pip install opencv-contrib-python numpy keyboard

运行(需管理员权限):
    python mh.py
"""

import numpy as np
import cv2
import ctypes
import keyboard
import os
import glob
import time

# ==================== 配置区 ====================
CAM_INDEX     = 0                    # 摄像头编号
CAM_W, CAM_H  = 1280, 720            # 摄像头采集分辨率
PAINTING_DIR  = "."                  # 名画图片所在目录
PAINTING_GLOB = "painting*.jpg"      # 名画文件匹配模式
MIRROR        = True                 # 左右镜像(前置摄像头建议 True)
FORCE_SCREEN  = None                 # 手动指定分辨率,如 (1920, 1080);None = 自动

RADIUS_GAIN   = 5200.0               # 光斑半径 ≈ RADIUS_GAIN * 标记边长 / 画面宽度
RADIUS_MIN    = 100                  # 最小半径(px)
RADIUS_MAX    = 900                  # 最大半径(px)
SMOOTH        = 0.30                 # 跟随平滑 0~1,越大越跟手
FADE          = 0.80                 # 丢失标记后光斑每帧衰减系数

# ---- 遥控器按键映射 ----
KEY_NEXT      = "f22"                # Home键 → 切换下一张
KEY_PREV      = "menu"               # 语音键 → 切换上一张
KEY_SNAP      = "right"              # 确认键 → 拍照存为最后一张
KEY_QUIT      = "none"               # 菜单键 → 退出程序(键名为 None,用 "none" 匹配)
PRESS_TIMEOUT = 2.0                  # 兜底:若 up 事件丢失,超过此时长允许再次触发(秒)
# ==============================================


def get_screen_size():
    """自动获取屏幕分辨率"""
    if FORCE_SCREEN is not None:
        return FORCE_SCREEN
    try:
        import tkinter as tk
        root = tk.Tk()
        root.withdraw()
        w, h = root.winfo_screenwidth(), root.winfo_screenheight()
        root.destroy()
        return w, h
    except Exception:
        return 1920, 1080


def cover_resize(img, w, h):
    """等比缩放并居中裁剪,铺满 w×h,不变形"""
    ih, iw = img.shape[:2]
    s = max(w / iw, h / ih)
    nw, nh = max(1, int(round(iw * s))), max(1, int(round(ih * s)))
    interp = cv2.INTER_AREA if s < 1 else cv2.INTER_CUBIC
    img = cv2.resize(img, (nw, nh), interpolation=interp)
    x, y = (nw - w) // 2, (nh - h) // 2
    return img[y:y + h, x:x + w].copy()


def make_glow(n=128):
    """预生成一张 n×n 的圆形柔光贴图:中心 1,边缘平滑衰减到 0"""
    y, x = np.mgrid[0:n, 0:n].astype(np.float32)
    c = (n - 1) / 2.0
    d = np.sqrt((x - c) ** 2 + (y - c) ** 2) / c
    t = np.clip((1.0 - d) / 0.4, 0.0, 1.0)
    return (t * t * (3.0 - 2.0 * t)).astype(np.float32)


def norm_name(name):
    """把 event.name 归一化为小写字符串。
    keyboard 库对无法识别的按键会返回 Python 的 None,
    这里统一转成 'none',便于与配置匹配。"""
    if name is None:
        return "none"
    return str(name).strip().lower()


def load_all_paintings(sw, sh):
    """扫描目录,加载所有名画并统一缩放为屏幕尺寸"""
    pattern = os.path.join(PAINTING_DIR, PAINTING_GLOB)
    files = sorted(glob.glob(pattern))

    if not files:
        raise SystemExit(f"未找到任何名画图片,请确认 {PAINTING_GLOB} 文件存在")

    paintings, valid_files = [], []
    for f in files:
        img = cv2.imread(f, cv2.IMREAD_COLOR)
        if img is None:
            print(f"[警告] 无法读取: {f},已跳过")
            continue
        paintings.append(cover_resize(img, sw, sh))
        valid_files.append(os.path.basename(f))
        print(f"[加载] {os.path.basename(f)}  ({img.shape[1]}×{img.shape[0]})")

    if not paintings:
        raise SystemExit("所有名画图片均无法读取")

    print(f"\n共加载 {len(paintings)} 张名画\n")
    return paintings, valid_files


def main():
    SW, SH = get_screen_size()
    print(f"屏幕分辨率: {SW} x {SH}")

    paintings, painting_names = load_all_paintings(SW, SH)

    out = np.zeros((SH, SW, 3), np.uint8)
    prev_box = None
    GLOW = make_glow(128)

    # ---- ArUco 检测器 ----
    aruco = cv2.aruco
    dictionary = aruco.getPredefinedDictionary(aruco.DICT_4X4_50)

    if hasattr(aruco, "ArucoDetector"):
        _det = aruco.ArucoDetector(dictionary, aruco.DetectorParameters())

        def detect(gray):
            c, i, _ = _det.detectMarkers(gray)
            return c, i
    else:
        _params = aruco.DetectorParameters_create()

        def detect(gray):
            c, i, _ = aruco.detectMarkers(gray, dictionary, parameters=_params)
            return c, i

    # ---- 摄像头 ----
    cap = cv2.VideoCapture(CAM_INDEX)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAM_W)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAM_H)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
    if not cap.isOpened():
        raise SystemExit("打不开摄像头")

    # ---- 全屏窗口 ----
    win = "MagicTorch"
    cv2.namedWindow(win, cv2.WINDOW_NORMAL)
    cv2.setWindowProperty(win, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)

    try:
        ctypes.windll.user32.ShowCursor(False)
        _hide_cursor = True
    except Exception:
        _hide_cursor = False

    # ==================== 遥控器键盘监听 ====================
    state = {
        "idx": 0,
        "dirty": True,
        "snap": False,
        "quit": False,
    }

    def on_next():
        state["idx"] = (state["idx"] + 1) % len(paintings)
        state["dirty"] = True
        print(f"[遥控器] 下一张 → {painting_names[state['idx']]}")

    def on_prev():
        state["idx"] = (state["idx"] - 1) % len(paintings)
        state["dirty"] = True
        print(f"[遥控器] 上一张 → {painting_names[state['idx']]}")

    def on_snap():
        state["snap"] = True

    def on_quit():
        state["quit"] = True
        print("[遥控器] 退出程序")

    # ---- 事件处理:首次 down 触发,直到 up 才解锁 ----
    def make_handler(action):
        ctx = {"pressed": False, "last_time": 0.0}

        def handler(event):
            now = time.time()
            if event.event_type == 'down':
                if ctx["pressed"]:
                    if now - ctx["last_time"] > PRESS_TIMEOUT:
                        ctx["pressed"] = False
                    else:
                        return   # 自动重复事件,忽略
                ctx["pressed"] = True
                ctx["last_time"] = now
                action()
            elif event.event_type == 'up':
                ctx["pressed"] = False

        return handler

    key_map = {}

    def register(k, action, label):
        if k is None:
            print(f"  [跳过]  {label}按键未配置")
            return False
        s = str(k).strip().lower()
        if s == "":
            print(f"  [跳过]  {label}按键未配置")
            return False
        key_map[s] = make_handler(action)
        print(f"  {s:>6}  → {label}  ✓")
        return True

    print("遥控器按键配置:")
    try:
        register(KEY_NEXT, on_next, "下一张")
        register(KEY_PREV, on_prev, "上一张")
        register(KEY_SNAP, on_snap, "拍照")
        register(KEY_QUIT, on_quit, "退出程序")

        def global_hook(event):
            name = norm_name(event.name)
            h = key_map.get(name)
            if h is not None:
                h(event)

        keyboard.hook(global_hook, suppress=False)
    except Exception as e:
        print(f"[警告] 遥控器监听启动失败: {e}")
        print("请尝试以管理员身份运行程序")

    if not key_map:
        print("\n[提示] 当前没有启用任何遥控器按键,仅 ESC 可用。\n")
    else:
        print()
    # =========================================================

    sm_x = sm_y = sm_r = None

    while not state["quit"]:
        ok, frame = cap.read()
        if not ok:
            break

        # ---------- 0. 处理拍照请求 ----------
        if state["snap"]:
            state["snap"] = False
            snap_frame = frame
            if MIRROR:
                snap_frame = cv2.flip(snap_frame, 1)

            new_painting = cover_resize(snap_frame, SW, SH)
            paintings.append(new_painting)
            snap_name = f"snapshot_{len(paintings):02d}.jpg"
            painting_names.append(snap_name)
            state["idx"] = len(paintings) - 1
            state["dirty"] = True

            if prev_box is not None:
                x0, y0, x1, y1 = prev_box
                out[y0:y1, x0:x1] = 0
                prev_box = None

            print(f"[遥控器] 拍照 → {snap_name}(共 {len(paintings)} 张)")
            try:
                cv2.imwrite(snap_name, snap_frame)
            except Exception as e:
                print(f"[警告] 照片保存失败: {e}")

        # ---------- 切换名画 ----------
        if state["dirty"]:
            state["dirty"] = False
            if prev_box is not None:
                x0, y0, x1, y1 = prev_box
                out[y0:y1, x0:x1] = 0
                prev_box = None

        painting = paintings[state["idx"]]

        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        corners, ids = detect(gray)
        fh, fw = frame.shape[:2]

        # ---------- 1. 解算目标位置与半径 ----------
        if ids is not None and len(ids) > 0:
            pts = corners[0].reshape(4, 2).astype(np.float32)
            cx_cam, cy_cam = pts.mean(axis=0)

            nxt = np.roll(pts, -1, axis=0)
            side = float(np.mean(np.linalg.norm(pts - nxt, axis=1)))

            u = cx_cam / fw
            v = cy_cam / fh
            if MIRROR:
                u = 1.0 - u

            tx = u * SW
            ty = v * SH
            tr = float(np.clip(RADIUS_GAIN * side / fw, RADIUS_MIN, RADIUS_MAX))

            if sm_x is None:
                sm_x, sm_y, sm_r = tx, ty, tr
            else:
                sm_x += SMOOTH * (tx - sm_x)
                sm_y += SMOOTH * (ty - sm_y)
                sm_r += SMOOTH * (tr - sm_r)
        else:
            if sm_r is not None:
                sm_r *= FADE
                if sm_r < 3:
                    sm_x = sm_y = sm_r = None

        # ---------- 2. 渲染 ----------
        if prev_box is not None:
            x0, y0, x1, y1 = prev_box
            out[y0:y1, x0:x1] = 0
            prev_box = None

        if sm_r is not None and sm_r >= 2:
            cx, cy, r = int(round(sm_x)), int(round(sm_y)), int(round(sm_r))

            bx0, by0, bx1, by1 = cx - r, cy - r, cx + r, cy + r
            sx0, sy0 = max(0, bx0), max(0, by0)
            sx1, sy1 = min(SW, bx1), min(SH, by1)

            if sx1 > sx0 and sy1 > sy0:
                g = cv2.resize(GLOW, (2 * r, 2 * r),
                               interpolation=cv2.INTER_LINEAR)
                gx0, gy0 = sx0 - bx0, sy0 - by0
                g = g[gy0:gy0 + (sy1 - sy0), gx0:gx0 + (sx1 - sx0)]

                roi = painting[sy0:sy1, sx0:sx1].astype(np.float32)
                roi *= g[..., None]
                out[sy0:sy1, sx0:sx1] = roi.astype(np.uint8)

                prev_box = (sx0, sy0, sx1, sy1)

        # ---------- 3. 显示 ----------
        cv2.imshow(win, out)

        if _hide_cursor:
            ctypes.windll.user32.ShowCursor(False)

        key = cv2.waitKey(1) & 0xFF
        if key == 27:
            break
        try:
            if cv2.getWindowProperty(win, cv2.WND_PROP_VISIBLE) < 1:
                break
        except cv2.error:
            break

    # ---- 清理 ----
    try:
        keyboard.unhook_all()
    except Exception:
        pass

    if _hide_cursor:
        ctypes.windll.user32.ShowCursor(True)

    cap.release()
    cv2.destroyAllWindows()


if __name__ == "__main__":
    main()

五、运行与调试

以管理员身份运行程序(keyboard 库需要管理员权限才能捕获全局按键):

python mh.py

程序会自动检测屏幕分辨率,创建全屏窗口。举着遥控器对准摄像头,屏幕上就会出现跟着你手移动的光圈。
魔法手电筒:用 LattePanda + ArUco 遥控器,在黑暗中

按键功能一览

遥控器按键 键名 功能
Home 键 f22 切换下一张名画
语音键 menu 切换上一张名画
确认键 right 拍照,追加为最后一张
菜单键 None 退出程序
ESC — 退出程序(备用)

典型交互流程

  1. 程序启动,光圈跟随遥控器移动,照亮 painting1.jpg 的局部
  2. 按 Home 键 → 切到 painting2.jpg
  3. 按 语音键 → 切回 painting1.jpg
  4. 对准屏幕某处按 确认键 → 摄像头抓拍一张照片,作为 snapshot_04.jpg 追加到末尾,立即显示
  5. 按 菜单键 → 程序退出,鼠标光标恢复

调试要点

现象 原因 调整方法
光圈太小 遥控器上的标记尺寸小 调大 RADIUS_GAIN(默认 5200)
光圈太大 标记离摄像头太近 调小 RADIUS_MAX(默认 900)
跟随延迟明显 平滑系数太小 调大 SMOOTH(如 0.5)
标记识别不稳定 手指遮挡或反光 手指下移、避免反光、留足静区
按一下切换多次 键盘自动重复 已内置处理逻辑,无需调整
按键无反应 键名不匹配 用 3.5 节的测试代码确认键名
菜单键不退出 键名不是 None 用测试代码确认,改 KEY_QUIT
画面全黑 图片路径错误 检查 painting*.jpg 是否存在

参数速查

CAM_INDEX     = 0            # 多摄像头时切换编号
MIRROR        = True         # 前置摄像头建议 True;后置摄像头指向操作建议 False
RADIUS_GAIN   = 5200.0       # 标记较小时需调大
RADIUS_MAX    = 900          # 光圈最大半径上限
SMOOTH        = 0.30         # 平滑度:0.1 顺滑,0.6 灵敏
FADE          = 0.80         # 衰减:越小熄灭越快
KEY_NEXT      = "f22"        # Home键的实际键名
KEY_PREV      = "menu"       # 语音键的实际键名
KEY_SNAP      = "right"      # 确认键的实际键名
KEY_QUIT      = "none"       # 菜单键的实际键名

六、技术原理

6.1 ArUco 标记识别

ArUco 是一种二进制方形基准标记,由宽黑边框和内部二进制矩阵组成。本项目中使用的 DICT_4X4_50 字典表示内部为 4×4 网格,共支持 50 个不同 ID 的标记。OpenCV 通过检测标记的四边形轮廓、提取内部二进制编码并与字典匹配来识别标记,同时返回标记四个角点的亚像素级坐标。

6.2 位置与半径映射

利用角点坐标可以计算标记的中心位置和边长:

  • 中心位置 决定光圈在屏幕上的位置
  • 平均边长 反映标记与摄像头的距离——标记离摄像头越近,画面中越大,光圈半径也相应增大

位置映射公式:屏幕坐标 = 归一化坐标 × 屏幕分辨率

由于标记直接贴在遥控器上,尺寸比 PVC 管上的 A4 打印标记小得多,因此 RADIUS_GAIN 需要相应调大(从 2600 调至 5200),让同样距离下光圈大小保持一致。

6.3 柔光渲染

光圈效果通过一张预生成的圆形渐变贴图实现。贴图中心为 1(全亮),边缘平滑衰减到 0(全黑)。渲染时:

  1. 将贴图缩放到光圈直径大小
  2. 与名画图片对应区域做逐元素乘法
  3. 写入全黑的输出缓冲区

由于使用了"先擦除上一帧光圈区域"的策略,每帧只需处理光圈覆盖的矩形区域,计算量极小,LattePanda Alpha 800s 的性能足以流畅运行。

6.4 遥控器按键去抖

小米遥控器的语音键和部分按键在 Windows 下有一个特殊行为:按下时会持续发送 down 事件,直到松开才发一次 up,中间间隔约 30~45ms。如果直接监听 down 事件,按一下就会触发十几次。

本程序使用 keyboard.hook() 监听所有按键的 down 和 up 事件,通过状态跟踪实现去抖:

第一次 down → 触发动作,标记为"已按下"
后续 down  → 忽略(因为已处于"已按下"状态)
松开 up    → 解除"已按下"状态,允许下次触发

这个逻辑从根本上区分了"自动重复"和"真实按键",既不会误触发,也不会误过滤快速连按。同时增加 2 秒超时兜底,避免 up 事件丢失导致按键锁死。

6.5 菜单键 None 的特殊处理

keyboard 库对无法识别的物理按键,event.name 会返回 Python 的 None。这导致不能直接用 keyboard.hook_key("none", ...) 注册——库会找不到这个键。

解决方案是改用全局 keyboard.hook(),在回调里用 norm_name() 把 None 归一化为字符串 "none",再按字典分发到对应的处理函数。这样菜单键就能像普通按键一样注册,触发退出逻辑。

6.6 拍照的线程安全

按键回调运行在 keyboard 库的独立线程中,不能直接调用 cap.read()——OpenCV 的 VideoCapture 不是线程安全的,跨线程读取会导致崩溃或帧错乱。

因此拍照功能采用"标志位 + 主循环处理"的方式:回调只设置 state["snap"] = True,主循环下一帧读取该标志,用当前帧作为照片抓取。这样既保证了线程安全,又避免了额外的摄像头读取开销。

七、注意事项

关于 UPS 电池:UPS Hat 不含 18650 电池,需要自行购买 3 节 3.7V 18650 锂电池。建议选择 3400mAh 以上容量的品牌电池,续航时间更长。装电池前务必确认 UPS Hat 已完全插到位。

关于散热:LattePanda Alpha 800s 自带主动散热风扇,正常使用无需额外散热。但如果长时间高负载运行,建议确保通风良好。

关于标记识别:标记贴在遥控器上后,手指遮挡和表面反光是两个最主要的识别失败原因。建议:标记贴在遥控器正面而非端面;握持时手指尽量靠下;使用哑光不干胶纸打印;不要用透明胶带覆盖标记主体。

关于按键映射:不同型号的小米遥控器按键映射可能不同。首次使用请务必运行 3.5 节的测试代码确认所有按键的实际键名,再填入配置区。

关于程序权限:keyboard 库需要管理员权限才能捕获全局按键。如果启动时提示"遥控器监听启动失败",请以管理员身份运行命令行或 Python 脚本。

关于照片累加:连续按确认键,每次都追加一张新照片到列表末尾,并同时在程序目录下保存 snapshot_NN.jpg 文件。8GB 内存下,即使累积几十张 1080p 照片也毫无压力(每张约 6MB)。如果不需要保存到磁盘,可以删除代码中 cv2.imwrite(snap_name, snap_frame) 那一行。

关于鼠标恢复:程序正常退出(ESC 或菜单键)会自动恢复鼠标光标。如果因异常导致鼠标消失,按 Ctrl+Alt+Del 打开任务管理器再关闭,或注销一次即可恢复。

八、效果展示

完整交互过程

九、拓展思路

  • 多标记模式:同时识别多个 ArUco 标记,实现"多支魔法棒"合作点亮不同区域
  • 多幅名画分区:检测到不同 ID 的标记时,切换显示不同的名画
  • 手势交互:结合 MediaPipe 检测手部关键点,让手掌直接成为"魔法棒"
  • 音效反馈:检测到标记时播放环境音,丢失时渐弱淡出
  • 壁画模式:结合投影仪,将光圈投射到墙面,打造沉浸式艺术空间
  • 触摸屏联动:结合 15.6" 触摸屏的触控能力,在光圈内直接做手势缩放或拖动
  • 云端分享:把拍下的快照上传到云端或社交平台,一键分享创作

十、相关附件

文件 说明
mh.py 主程序(含遥控器切换、拍照、退出功能)
painting1.jpg ~ paintingN.jpg 名画图片(自行替换)
my_marker.png 生成的 ArUco 标记

本项目首发于 DF 创客社区。如果你也动手制作了属于自己的"魔法手电筒",欢迎在评论区分享你的作品照片和创意改进!

创作许可协议

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

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