【项目背景】
你是否遇到过这样的场景:想在黑暗的环境里控制灯光,却找不到开关;想用手势与机器互动,摄像头却因为光线太差而“瞎了”。传统的摄像头方案依赖可见光,一旦环境变暗或逆光,识别率就会大打折扣。
最近在DFRobot社区看到一款64×8矩阵DTOF激光雷达(SEN0682),它采用直接飞行时间(DTOF)技术,主动发射激光,不依赖环境光。这让我产生了一个想法:能不能借助Mind+2.0图像分类工具训练模型,用它来做“隔空手势识别”,并且完全不受光线影响?进一步地,识别到的手势能否用来生成酷炫的粒子动画,或者通过物联网控制家里的灯带?
经过几周的折腾,我实现了这个项目:当手在传感器前变化手指个数,电脑屏幕上会实时汇聚出对应的数字粒子,同时通过MQTT协议无线控制ESP32驱动的RGB灯带。整个过程在完全黑暗的房间里也能稳定工作。


【硬件准备】
| 部件 | 型号/说明 | 作用 |
|---|---|---|
| DTOF激光雷达 | DFRobot SEN0682,64×8矩阵,120°×20°视场角,UART接口 | 获取前方512个点的距离与强度数据 |
| 主控(训练端) | 笔记本电脑(Windows) | 运行Python脚本,采集数据、训练模型、执行推理 |
| 主控(执行端) | 掌控板/ESP32-E | 接收MQTT指令,控制灯带 |
| RGB灯带 | WS2812,12颗灯珠 | 呈现不同灯光效果 |
| USB转TTL模块 | CH340/FT232 | 连接传感器与电脑 |
| 5V电源 | USB充电器(2A) | 为传感器和ESP32供电 |
接线提醒:传感器必须使用5V供电,逻辑电平为3.3V,请确保USB转TTL模块支持3.3V输出,或使用电平转换电路。
步骤1 从点云到图像——发现数据中的“灰度图”
拿到传感器后,我首先按照官方教程连接电脑,打开上位机软件 wy3DViewer.exe。软件界面实时显示传感器前方的点云图,并且可以切换显示深度图和灰度图。

深度图反映的是距离(近亮远暗),灰度图反映的是反射强度(强反射亮,弱反射暗)。我突然想到:灰度图不就是一个64×8像素的“图像”吗?手的不同姿势会在这个“图像”上留下不同的纹理。如果把每一帧数据保存成图片,不就可以用Mind+的AI图像分类功能进行训练,从而识别出手指个数?
于是,整个项目的技术路线清晰了:
用Python读取传感器的强度数据(I值),转换为224×224的灰度图。
采集大量手势图片,按手指个数(1~5指+背景)分类。
在Mind+中训练图像分类模型。
用训练好的模型实时推理,根据结果控制粒子动画或MQTT灯带。
步骤2 用Python把传感器数据变成图像
官方提供了树莓派Python库,我稍作修改,使其在Windows上也能运行。核心函数 intensity_to_image_adaptive() 负责将512个强度值转换为一张图像。
def intensity_to_image_adaptive(i_list, left_crop=12, right_crop=12):
# 重塑为8行64列
grid = np.array(i_list).reshape(8, 64).astype(np.float32)
# 左右各切除12列(去掉边缘无效区域)
grid = grid[:, left_crop:64-right_crop]
# 自适应拉伸到0~255
min_val = grid.min()
max_val = grid.max()
img_data = ((grid - min_val) / (max_val - min_val) * 255).astype(np.uint8)
# 转换为PIL图像,并放大到224×224(Mind+输入尺寸)
img = Image.fromarray(img_data, mode='L')
img = img.resize((224, 224), Image.Resampling.BICUBIC)
return img采集数据:运行一个循环,每按一次数字键(1-5),就保存一帧图像到对应的文件夹。每个手势采集了约100张,包括不同位置、不同角度。





步骤3 Mind+训练手势分类模型
在Mind+ 2.0中新建“图像分类”项目,将采集的图片文件夹导入(自动按文件夹名分为5类+背景类)。训练过程非常快,几分钟后模型准确率就达到了95%以上。

导出训练好的模型(onnx格式)和yaml标签文件,就可以在Python中调用了。

步骤4 创意应用①——数字粒子效果
粒子效果往往能带来很强的视觉冲击。我希望当识别到手指个数时,屏幕上的粒子能汇聚成对应的数字形状。
实现思路
用OpenCV提取数字图像的边缘点集(如数字“1”的轮廓点)。
初始化200个粒子,随机分布在屏幕上。
每个粒子分配一个目标点(来自当前数字的边缘点集)。
每帧向目标点移动,同时保留一点随机抖动,看起来像“飞向”数字。
关键代码片段:
# 生成数字边缘点集
def get_digit_points_edge(digit, max_points=200):
# 用PIL绘制数字,再用Canny提取边缘
img = draw_digit_image(digit)
edges = cv2.Canny(np.array(img), 50, 150)
points = np.column_stack(np.where(edges > 0))
points = [(int(p[1]), int(p[0])) for p in points]
# 采样控制数量
if len(points) > max_points:
step = len(points) // max_points
points = points[::step]
return points
# 粒子更新
def update(self, dt):
dx = self.target_x - self.x
dy = self.target_y - self.y
dist = math.hypot(dx, dy)
if dist > 1:
move = self.speed * dt
self.x += dx / dist * move
self.y += dy / dist * move当识别结果从“2”变为“3”时,只需为每个粒子重新分配新的目标点,粒子就会自动改变运动方向,汇聚成新的数字。


这个效果在屏幕上非常惊艳,就像有一群萤火虫在空中拼出数字。
步骤5 创意应用②——MQTT无线控制灯带
光有屏幕上的动画还不够,我想让它能控制现实中的灯光。于是我用手头的掌控板(ESP32)和WS2812灯带做了一个接收端。
发送端(Python)
通过paho-mqtt库连接到本地SIoT服务器,当识别到不同手指个数时,向主题 siot/ai 发布不同的字符串指令。
command_map = {
'1': "ON",
'2': "OFF",
'3': "TOGGLE",
'4': "RAINBOW",
'5': "CLEAR",
}
if confidence > 0.5 and command != last_command:
mqtt_client.publish("siot/ai", command)接收端(掌控板/Arduino)
使用DFRobot IoT库订阅同一个主题,根据收到的字符串执行对应的灯效。例如收到“ON”时显示彩虹流光,“OFF”时熄灭,“RAINBOW”时进入旋转模式。
void whensiot47aiReceived0() { neoPixel_P0.showRainbow(0, 11, 1, 360); }
void whensiot47aiReceived1() { neoPixel_P0.clear(); }
void whensiot47aiReceived3() { neoPixel_P0.rotate(1); }这样,用手在传感器前比划不同的手指个数,灯带就会实时响应。而且由于DTOF不依赖可见光,哪怕在完全黑暗的房间里,识别依然精准稳定。

【项目亮点】
真正“无光”识别:DTOF主动发射激光,环境光不影响性能,夜间使用体验远超摄像头。
从数据到模型的全流程:用上位机发现灰度图,用Python转图像,用Mind+训练,过程清晰,便于复现。
双模态输出:既有屏幕上的视觉粒子反馈,也有物理世界的灯光控制,可玩性高。
低门槛扩展:MQTT协议的加入让手势信号可以轻易接入HomeAssistant、Node-RED等智能家居平台。
【总结与展望】
这个项目将DTOF激光雷达、AI图像分类、粒子动画和物联网控制融合在一起,验证了“深度/强度数据 → 图像化 → 视觉识别”这一路线的可行性。相比摄像头方案,DTOF传感器在黑暗环境、复杂背景下的稳定性优势非常明显。
【完整代码】
1.电脑端Python代码,手势控数字粒子效果
# -*- coding: UTF-8 -*-
import os
import sys
import time
import random
import math
import numpy as np
import pygame
import cv2
from PIL import Image, ImageDraw, ImageFont
# 导入传感器和模型模块
sys.path.append("../")
from DFRobot_64x8DTOF import DFRobot_64x8DTOF
from model_mp_core import ImageClassificationInference
# ------------------------- 传感器初始化 -------------------------
sensor = DFRobot_64x8DTOF(port="COM64", baudrate=921600)
if not sensor.begin():
print("Sensor init failed")
sys.exit(1)
sensor.config_frame_mode(sensor.FRAME_MODE_SINGLE)
sensor.config_measure_mode()
time.sleep(2)
# ------------------------- 模型初始化 -------------------------
model_path = "best.onnx"
yaml_path = "data.yaml"
inference = ImageClassificationInference(model_path, yaml_path)
# ------------------------- 图像生成函数 -------------------------
def intensity_to_image_adaptive(i_list, rows=8, cols=64, left_crop=12, right_crop=12, flip=False, min_intensity=0):
grid = np.array(i_list).reshape(rows, cols).astype(np.float32)
if left_crop > 0 or right_crop > 0:
grid = grid[:, left_crop:cols-right_crop]
grid[grid < min_intensity] = 0
valid_vals = grid[grid > 0]
if len(valid_vals) > 0:
min_val = valid_vals.min()
max_val = valid_vals.max()
if max_val > min_val:
img_data = ((grid - min_val) / (max_val - min_val) * 255).astype(np.uint8)
else:
img_data = np.zeros_like(grid, dtype=np.uint8)
else:
img_data = np.zeros_like(grid, dtype=np.uint8)
img = Image.fromarray(img_data, mode='L')
if flip:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
img = img.resize((224, 224), Image.Resampling.BICUBIC)
return img
# ------------------------- 数字边缘点集生成(使用OpenCV边缘检测)-------------------------
def get_digit_points_edge(digit, width=800, height=600, font_size=300, max_points=200):
"""
生成数字的边缘点集,用于粒子系统。
:param digit: 数字字符(1-5 或 背景)
:param width: 最终画布宽度
:param height: 最终画布高度
:param font_size: 字体大小
:param max_points: 最大粒子数量(采样控制)
:return: 点坐标列表 [(x,y), ...]
"""
if digit == '背景':
return [] # 背景时无目标点
# 创建大临时图像,避免裁剪
temp_width = width * 2
temp_height = height * 2
img = Image.new('L', (temp_width, temp_height), 0)
draw = ImageDraw.Draw(img)
# 字体路径尝试
font_paths = [
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/consola.ttf",
"C:/Windows/Fonts/simhei.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/System/Library/Fonts/Helvetica.ttc"
]
font = None
for path in font_paths:
try:
font = ImageFont.truetype(path, font_size)
break
except:
continue
if font is None:
font = ImageFont.load_default()
# 在临时画布中心绘制数字
draw.text((temp_width//2, temp_height//2), str(digit), fill=255, font=font, anchor='mm')
# 裁剪到目标尺寸(居中裁剪)
crop_left = (temp_width - width) // 2
crop_top = (temp_height - height) // 2
img = img.crop((crop_left, crop_top, crop_left+width, crop_top+height))
# 转换为numpy数组,并提取边缘
img_np = np.array(img)
# 使用Canny边缘检测
edges = cv2.Canny(img_np, 50, 150)
# 获取边缘点的坐标(注意OpenCV坐标是(row,col))
points = np.column_stack(np.where(edges > 0))
# 转换为 (x,y) 格式
points = [(int(p[1]), int(p[0])) for p in points]
# 如果边缘点太多,均匀采样
if len(points) > max_points:
step = len(points) // (max_points-360)
points = points[::step]
return points
# 预生成数字1~5的边缘点集
digit_points = {}
for d in ['1', '2', '3', '4', '5']:
digit_points[d] = get_digit_points_edge(d, max_points=200)
digit_points['背景'] = []
# 颜色风格(可选)
digit_color = {
'1': (255, 100, 100),
'2': (100, 255, 100),
'3': (100, 100, 255),
'4': (255, 255, 100),
'5': (255, 100, 255),
'背景': (128, 128, 128)
}
# ------------------------- 粒子类 -------------------------
class Particle:
def __init__(self, x, y, target_pos=None):
self.x = x
self.y = y
self.target_x, self.target_y = target_pos if target_pos else (x, y)
self.vx = 0
self.vy = 0
self.color = (255, 255, 255)
self.size = random.randint(2, 4)
self.speed = 80 # 像素/秒,可调节
def update(self, dt):
dx = self.target_x - self.x
dy = self.target_y - self.y
dist = math.hypot(dx, dy)
if dist > 1:
move = self.speed * dt
if move > dist:
self.x = self.target_x
self.y = self.target_y
else:
self.x += dx / dist * move
self.y += dy / dist * move
# 轻微抖动,增加生动感
self.x += random.uniform(-0.5, 0.5)
self.y += random.uniform(-0.5, 0.5)
def draw(self, screen):
pygame.draw.circle(screen, self.color, (int(self.x), int(self.y)), self.size)
# ------------------------- 粒子系统 -------------------------
class ParticleSystem:
def __init__(self, width, height, num_particles=200):
self.width = width
self.height = height
self.num_particles = num_particles
self.particles = []
self.current_digit = None
self.target_points = []
# 随机初始化粒子位置
for _ in range(num_particles):
x = random.randint(0, width)
y = random.randint(0, height)
self.particles.append(Particle(x, y))
def set_digit(self, digit_name):
if digit_name == self.current_digit:
return
self.current_digit = digit_name
points = digit_points.get(digit_name, [])
if not points:
# 背景:随机漫游
self.target_points = [(random.randint(0, self.width), random.randint(0, self.height)) for _ in range(self.num_particles)]
else:
# 使目标点数量与粒子数量一致
if len(points) != self.num_particles:
if len(points) < self.num_particles:
repeat_times = self.num_particles // len(points) + 1
points = points * repeat_times
self.target_points = points[:self.num_particles]
else:
self.target_points = points
# 更新每个粒子的目标
for i, p in enumerate(self.particles):
p.target_x, p.target_y = self.target_points[i]
p.color = digit_color.get(digit_name, (255,255,255))
def update(self, dt):
for p in self.particles:
p.update(dt)
def draw(self, screen):
for p in self.particles:
p.draw(screen)
# ------------------------- 主程序 -------------------------
def main():
pygame.init()
screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("数字粒子效果 - 手指个数控制")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 36)
particle_sys = ParticleSystem(800, 600, num_particles=200)
running = True
last_label = None
confidence = 0.0
while running:
dt = clock.tick(60) / 1000.0
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 从传感器获取数据并推理
x, y, z, intensity = sensor.get_data(timeout_ms=200)
if len(z) == 512:
img = intensity_to_image_adaptive(intensity, left_crop=12, right_crop=12, flip=False, min_intensity=0)
img = img.convert('RGB')
img_np = np.array(img)
result = inference.inference(img_np)
# 解析结果
label = 'unknown'
confidence = 0.0
if isinstance(result, dict) and 'result' in result:
results_list = result['result']
if results_list:
top = results_list[0]
label = top['class_name']
confidence = top['score']
else:
label = str(result)
# 如果置信度较高才更新数字,避免频繁抖动
if confidence > 0.5 and label != last_label:
print(f"切换数字: {label} (置信度: {confidence:.2f})")
particle_sys.set_digit(label)
last_label = label
# 更新粒子
particle_sys.update(dt)
# 绘制
screen.fill((0, 0, 0))
particle_sys.draw(screen)
# 显示当前识别的文字
text_surface = font.render(f"Fingers: {last_label if last_label else 'none'} (conf: {confidence:.2f})", True, (255, 255, 255))
screen.blit(text_surface, (10, 10))
pygame.display.flip()
sensor.close()
pygame.quit()
sys.exit()
if __name__ == "__main__":
main()2.电脑端Python代码,手势控灯
# -*- coding: UTF-8 -*-
import os
import sys
import time
import random
import numpy as np
import pygame
import paho.mqtt.client as mqtt
from PIL import Image
# 导入传感器和模型模块
sys.path.append("../")
from DFRobot_64x8DTOF import DFRobot_64x8DTOF
from model_mp_core import ImageClassificationInference
# ------------------------- 传感器初始化 -------------------------
sensor = DFRobot_64x8DTOF(port="COM64", baudrate=921600)
if not sensor.begin():
print("Sensor init failed")
sys.exit(1)
sensor.config_frame_mode(sensor.FRAME_MODE_SINGLE)
sensor.config_measure_mode()
time.sleep(2)
# ------------------------- 模型初始化 -------------------------
model_path = "best.onnx"
yaml_path = "data.yaml"
inference = ImageClassificationInference(model_path, yaml_path)
# ------------------------- 图像生成函数 -------------------------
def intensity_to_image_adaptive(i_list, rows=8, cols=64, left_crop=12, right_crop=12, flip=False, min_intensity=0):
grid = np.array(i_list).reshape(rows, cols).astype(np.float32)
if left_crop > 0 or right_crop > 0:
grid = grid[:, left_crop:cols-right_crop]
grid[grid < min_intensity] = 0
valid_vals = grid[grid > 0]
if len(valid_vals) > 0:
min_val = valid_vals.min()
max_val = valid_vals.max()
if max_val > min_val:
img_data = ((grid - min_val) / (max_val - min_val) * 255).astype(np.uint8)
else:
img_data = np.zeros_like(grid, dtype=np.uint8)
else:
img_data = np.zeros_like(grid, dtype=np.uint8)
img = Image.fromarray(img_data, mode='L')
if flip:
img = img.transpose(Image.FLIP_TOP_BOTTOM)
img = img.resize((224, 224), Image.Resampling.BICUBIC)
return img
# ------------------------- MQTT 配置 -------------------------
MQTT_SERVER = "127.0.0.1" # SIoT 服务器地址(本地)
MQTT_PORT = 1883 # 默认端口
MQTT_TOPIC = "siot/ai" # 发布主题
MQTT_USER = "siot" # 用户名(如需要)
MQTT_PASS = "dfrobot" # 密码
# 手指个数 → MQTT 消息映射(根据您的需求修改)
command_map = {
'1': "ON", # 识别为 1 时开灯
'2': "OFF", # 识别为 2 时关灯
'3': "TOGGLE", # 识别为 3 时切换状态
'4': "BRIGHT_UP",
'5': "BRIGHT_DOWN",
'背景': "NONE" # 背景不发送指令
}
def on_connect(client, userdata, flags, rc):
print(f"Connected to MQTT broker with result code {rc}")
def on_publish(client, userdata, mid):
print(f"Message published (mid: {mid})")
# 创建 MQTT 客户端
mqtt_client = mqtt.Client()
mqtt_client.on_connect = on_connect
mqtt_client.on_publish = on_publish
# 如果需要用户名密码
if MQTT_USER:
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
# 连接(使用非阻塞方式,在循环中处理网络)
mqtt_client.connect(MQTT_SERVER, MQTT_PORT, 60)
mqtt_client.loop_start() # 启动网络循环
# ------------------------- Pygame 显示(可选)-------------------------
pygame.init()
screen = pygame.display.set_mode((400, 200))
pygame.display.set_caption("手势控制台灯")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 36)
# ------------------------- 主循环 -------------------------
last_command = None
print("开始实时识别,按 Ctrl+C 退出")
try:
while True:
# 处理 pygame 事件(仅用于退出)
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
# 获取传感器数据
x, y, z, intensity = sensor.get_data(timeout_ms=200)
if len(z) != 512:
time.sleep(0.1)
continue
# 生成图像并推理
img = intensity_to_image_adaptive(intensity, left_crop=12, right_crop=12, flip=False, min_intensity=0)
img = img.convert('RGB')
img_np = np.array(img)
result = inference.inference(img_np)
# 解析结果
label = 'unknown'
confidence = 0.0
if isinstance(result, dict) and 'result' in result:
results_list = result['result']
if results_list:
top = results_list[0]
label = top['class_name']
confidence = top['score']
else:
label = str(result)
# 如果置信度足够高,且结果与上次不同,则发送 MQTT 指令
if confidence > 0.5:
command = command_map.get(label, None)
if command and command != last_command:
# 发布消息
mqtt_client.publish(MQTT_TOPIC, command)
print(f"发送指令: {command} (手指个数: {label}, 置信度: {confidence:.2f})")
last_command = command
# 在屏幕上显示当前状态
screen.fill((0, 0, 0))
text_surface = font.render(f"Fingers: {label}", True, (255, 255, 255))
screen.blit(text_surface, (20, 50))
cmd_surface = font.render(f"Last Cmd: {last_command if last_command else 'none'}", True, (200, 200, 200))
screen.blit(cmd_surface, (20, 100))
pygame.display.flip()
time.sleep(0.2) # 控制帧率
except KeyboardInterrupt:
print("\n程序退出")
finally:
mqtt_client.loop_stop()
mqtt_client.disconnect()
sensor.close()
pygame.quit()
sys.exit()
2.掌控板程序

/*!
* MindPlus
* mpython
*
*/
#include <MPython.h>
#include <DFRobot_Iot.h>
#include <DFRobot_NeoPixel.h>
// 动态变量
volatile float mind_n_ZhuangTai;
// 函数声明
void whensiot47aiReceived0();
void whensiot47aiReceived1();
void whensiot47aiReceived2();
void whensiot47aiReceived3();
void whensiot47aiReceived4();
// 静态常量
const String topics[5] = {"","","","",""};
// 创建对象
DFRobot_NeoPixel neoPixel_P0;
DFRobot_Iot myIot;
// 主程序开始
void setup() {
mPython.begin();
myIot.setCustomMqttCallback("siot/ai", "ON", whensiot47aiReceived0);
myIot.setCustomMqttCallback("siot/ai", "TOGGLE", whensiot47aiReceived1);
myIot.setCustomMqttCallback("siot/ai", "BRIGHT_UP", whensiot47aiReceived2);
myIot.setCustomMqttCallback("siot/ai", "BRIGHT_DOWN", whensiot47aiReceived3);
myIot.setCustomMqttCallback("siot/ai", "OFF", whensiot47aiReceived4);
neoPixel_P0.begin(P0, 12);
neoPixel_P0.clear();
neoPixel_P0.setBrightness(50);
rgb.write(-1, 0xFF0000);
myIot.wifiConnect("sxs", "smj080823");
while (!myIot.wifiStatus()) {yield();}
rgb.write(-1, 0x0000FF);
myIot.init("192.168.31.11","siot","7977834249786735","dfrobot", topics, 1883);
myIot.connect();
while (!myIot.connected()) {yield();}
myIot.subscribeTopic("siot/ai");
rgb.write(-1, 0x00FF00);
neoPixel_P0.showRainbow(0, 11, 1, 360);
delay(1000);
rgb.write(-1, 0x000000);
neoPixel_P0.clear();
mind_n_ZhuangTai = 0;
}
void loop() {
if ((mind_n_ZhuangTai==2)) {
neoPixel_P0.rotate(1);
delay(200);
}
}
// 事件回调函数
void whensiot47aiReceived0() {
neoPixel_P0.showRainbow(0, 11, 1, 360);
mind_n_ZhuangTai = 1;
}
void whensiot47aiReceived1() {
if ((mind_n_ZhuangTai==0)) {
neoPixel_P0.showRainbow(0, 11, 1, 360);
mind_n_ZhuangTai = 1;
}
else if ((mind_n_ZhuangTai==1)) {
neoPixel_P0.clear();
mind_n_ZhuangTai = 0;
}
}
void whensiot47aiReceived2() {
neoPixel_P0.showRainbow(0, 11, 1, 360);
mind_n_ZhuangTai = 2;
}
void whensiot47aiReceived3() {
neoPixel_P0.clear();
mind_n_ZhuangTai = 0;
}
void whensiot47aiReceived4() {
neoPixel_P0.clear();
mind_n_ZhuangTai = 0;
}

返回首页
回到顶部

云天2026.03.29
cd /d C:\Users\lenovo\AppData\Local\mind+\python-block\local-1\Scripts python -m pip uninstall numpy -y python -m pip install numpy==1.26.4