魔法手电筒:用 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 | 打印后贴在遥控器顶端 |

软件清单:
- 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)供电。


3.2 连接显示屏和摄像头
15.6 寸触摸屏支持全功能 Type-C 或 HDMI 信号输入。推荐使用 Type-C 连接——一根线同时完成视频传输、音频传输、触控信号和反向 PD 供电,支持盲插,无需区分数据口和供电口。
如果使用 HDMI 连接,则需要额外用 USB Type-C 线连接触摸接口。
USB 摄像头直接插入 LattePanda 的 USB 3.0 接口即可。LattePanda 兼容各类 UVC 免驱摄像头,在 Windows 和 Linux 下都无需安装驱动。
摄像头摆放建议:将摄像头放在屏幕正下方或正上方中央位置,让摄像头正对使用者。这样遥控器在手中左右移动时,标记在摄像头画面中的位移方向与屏幕方向一致,操作最直观。


3.3 制作"魔法棒"——把标记贴上遥控器
这是本项目的关键步骤。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")

裁剪与粘贴:
- 打印后沿标记外沿裁剪,保留四周至少 5mm 的白色静区——静区是 ArUco 识别的一部分,裁剪掉会导致识别失败。
- 将标记贴在遥控器顶端的正面(朝向摄像头的一面),而不是端面(朝向前方的一面)。
- 用手指握住遥控器侧面时,确保不会遮挡标记。
为什么要贴在正面? 因为你手持遥控器指向屏幕时,遥控器的正面自然朝向摄像头,标记也就正对镜头,识别率最高。如果贴在端面,手腕稍微转动,标记就偏离摄像头视野,识别容易丢失。
防反光小技巧:不要用透明胶带覆盖标记表面。如果必须固定,用透明胶带只贴四角,避开标记主体区域。
3.4 配对蓝牙遥控器
在 Windows 中打开"设置 → 蓝牙和其他设备 → 添加设备",按住小米遥控器上的按键进入配对模式,在列表中选择遥控器完成配对。
配对成功后,Windows 会将遥控器识别为一个键盘设备。

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(无引号)。


实测参考映射:
| 遥控器按键 | 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 文件,并按文件名字母顺序排序加载。
支持任意数量的名画,程序会在内存中预加载全部图片,切换时零延迟。

四、完整程序代码
# -*- 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
程序会自动检测屏幕分辨率,创建全屏窗口。举着遥控器对准摄像头,屏幕上就会出现跟着你手移动的光圈。

按键功能一览
| 遥控器按键 | 键名 | 功能 |
|---|---|---|
| Home 键 | f22 |
切换下一张名画 |
| 语音键 | menu |
切换上一张名画 |
| 确认键 | right |
拍照,追加为最后一张 |
| 菜单键 | None |
退出程序 |
| ESC | — | 退出程序(备用) |
典型交互流程
- 程序启动,光圈跟随遥控器移动,照亮
painting1.jpg的局部 - 按 Home 键 → 切到
painting2.jpg - 按 语音键 → 切回
painting1.jpg - 对准屏幕某处按 确认键 → 摄像头抓拍一张照片,作为
snapshot_04.jpg追加到末尾,立即显示 - 按 菜单键 → 程序退出,鼠标光标恢复
调试要点
| 现象 | 原因 | 调整方法 |
|---|---|---|
| 光圈太小 | 遥控器上的标记尺寸小 | 调大 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(全黑)。渲染时:
- 将贴图缩放到光圈直径大小
- 与名画图片对应区域做逐元素乘法
- 写入全黑的输出缓冲区
由于使用了"先擦除上一帧光圈区域"的策略,每帧只需处理光圈覆盖的矩形区域,计算量极小,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 创客社区。如果你也动手制作了属于自己的"魔法手电筒",欢迎在评论区分享你的作品照片和创意改进!



