跳到主要内容

上位机教程

Web 页面在上位机中的作用

功能区域上位机侧作用
状态信息区汇总 CPU、RAM、温度、FPS、电压、文件占用等状态
摄像头画面区显示实时画面和识别结果
媒体快捷操作区处理拍照、录像、缩放、WebRTC 等媒体功能
文件管理区查看照片和视频保存结果
视觉与自动功能区触发识别、追踪、巡线、MediaPipe 等功能
Web 命令行区输入调试命令或二次开发命令
JupyterLab 入口打开 Notebook、Python 示例和开发环境

Web 控制端入口

Web 控制端运行在 Jetson Orin Nano / Jetson Orin NX 上,上位机项目目录为 ~/ugv_jetson

正常使用时,在浏览器中打开 Web 页面即可完成底盘、云台、灯光、摄像头、拍照录像和视觉功能操作。只有在排查服务启动、修改页面功能或二次开发时,才需要查看以下文件和接口。

开发者补充:Web 控制端文件和接口

Web 控制端相关文件如下:

文件作用
app.pyWeb 主程序入口,创建 Flask 应用和 SocketIO 服务,监听 0.0.0.0:5000
templates/index.htmlWeb 控制端主页面,包含视频画面、底盘控制、云台控制、视觉功能、拍照录像、文件列表和设置入口。
templates/control.js读取 /config,按 config.yaml 中的命令编号发送控制消息。
templates/main.jsWebRTC 相关前端逻辑。
config.yaml保存机器人类型、速度参数、命令编号、反馈字段编号、视频参数和视觉参数。
base_ctrl.py封装串口 JSON 指令发送、灯光、OLED、云台和底盘控制。
cv_ctrl.py处理摄像头、拍照、录像、OpenCV、MediaPipe、目标位置偏移和巡线等视觉逻辑。
audio_ctrl.py处理提示音、上传音频、播放音频和 TTS 相关功能。

app.py 提供以下接口:

接口调用方式用途
/浏览器访问打开 templates/index.html
/configGET返回 config.yaml,供前端读取命令编号和状态字段。
/video_feedGET返回 MJPEG 视频流,页面中通过 <img src="/video_feed"> 显示画面。
/send_commandPOST 表单字段 commandWeb 命令行入口,将字符串交给 cmdline_ctrl() 解析。
SocketIO /jsonemit('json', jsonData)发送 JSON 控制指令到 base.base_json_ctrl()
SocketIO /ctrlsend(JSON.stringify({A,B,C}))发送页面功能按钮命令,例如拍照、录像、视觉模式和灯光控制。

Web 主程序不会自动在电脑上打开浏览器页面。确认 Web 服务可用时,应先在电脑浏览器访问:

http://<机器人IP>:5000
提示

页面无法访问,或需要在终端查看启动日志时,可手动启动 Web 主程序。在 ~/ugv_jetson 目录执行:

cd ~/ugv_jetson
source ugv-env/bin/activate
python app.py

如果终端输出 Address already in usePort 5000 is in use by another program,表示 5000 端口已经被其它进程占用。常见情况是 Web 主程序已经在后台运行,此时不要重复启动 app.py,直接在浏览器访问 http://<机器人IP>:5000 检查页面。

如果浏览器仍然打不开页面,优先检查:

  1. 电脑是否连接到 UGV 热点、同一路由器 WiFi。 或是否通过网线 / USB 数据线连接到 Jetson。
  2. 浏览器中填写的 IP 是否与 OLED 屏幕显示一致。
  3. URL 是否包含端口 :5000
  4. 终端是否仍显示 Port 5000 is in use。 若显示该信息,先按“已有 Web 服务运行”处理;只有在调试端口占用问题时,才进一步检查或停止后台进程。
开发者补充:串口配置与开机启动

app.py 中默认使用 Jetson Orin Nano 串口:

base = BaseController('/dev/ttyTHS0', 115200)

Jetson Orin NX 可使用以下串口配置:

# base = BaseController('/dev/ttyTHS1', 115200)

这里展示的是 Web 主程序的示例配置。Notebook 中准备执行真实底盘动作时,仍应按后文 底盘串口确认与占用排查 逐个验证候选串口,不要直接照抄这里的串口值。

autorun.sh 会向当前账号 crontab 添加 Web 主程序开机任务,路径为 ~/ugv_jetson/app.py。不要用 sudo ./autorun.sh 执行该脚本;脚本内已经检查并拒绝 sudo 执行。JupyterLab 通过 create_jupyter_service.py 生成 ugv_jupyter.service 后交给 systemd 管理。

Python 与 JupyterLab 基础

JupyterLab 界面速览

下图为 JupyterLab 首页。左侧是文件浏览器,右侧是 Launcher 启动页。

JupyterLab 首页整体界面

区域作用
文件浏览器浏览 Jetson 上位机中的项目目录、脚本、配置文件和媒体资源。
Launcher创建 Notebook、Console、Terminal、文本文件或 Python 文件。
Notebook按单元格保存和运行 Python 控制实验,便于记录过程和复现实验。
Console临时执行 Python 语句,用于快速验证参数、变量和函数。
Terminal执行系统命令、查看文件路径、运行脚本和查看日志。
Kernel 选择窗口为 Notebook 或 Console 选择实际运行代码的 Python 环境。
Notebook 工具栏保存文件、添加单元格、运行代码、停止执行、重启 Kernel 和切换单元格类型。
菜单 / Kernel管理文件、运行单元,并在 Notebook 或 Console 卡住时中断或重启内核。

JupyterLab 基础流程

打开 JupyterLab

JupyterLab 的访问方式见入门教程:

新建第一个 Notebook

  1. 在 Notebook 区域双击 Python 3 (ipykernel)

  2. 在左侧文件浏览器中选中新建的 Notebook。 右键重命名为:

    ugv_jupyterlab_control_test.ipynb

    rename

  3. 确认 Notebook 工具栏右侧显示 Python 3 (ipykernel)

    备注

    Notebook 工具栏介绍

    Notebook 打开后,页面上方会显示工具栏。常用按钮如下图所示:

    Notebook 工具栏

    按钮 / 区域作用
    保存 保存保存当前 Notebook。
    添加代码 新增单元格在当前位置新增一个单元格。
    剪切复制粘贴 剪切 / 复制 / 粘贴剪切、复制或粘贴当前单元格。
    运行 运行运行当前选中的单元格。
    停止 停止停止正在执行的单元格。
    重启 Kernel 重启 Kernel重启当前 Kernel,适合代码卡住或变量状态混乱时使用。
    重启并运行 重启并运行重启 Kernel 后继续运行后续单元格,运行前请确认代码内容安全。
    单元格类型 Code 下拉框切换单元格类型,例如 Python 代码单元或 Markdown 文本单元。
    Kernel 状态 Python 3 (ipykernel)显示当前 Notebook 连接的 Kernel。
  4. 在 Cell 1 中输入:

    print("Hello UGV Rover!")
  5. Shift + Enter 运行 Cell 1。

  6. 测试结束后保存 Notebook。 如果单元格长时间运行,可点击工具栏中的停止按钮,或在菜单中选择 Kernel > Interrupt Kernel

    运行成功后,Cell 1 下方会显示:

    Hello UGV Rover!

    如果单元格左侧出现类似 In [1]: 的编号,且下方输出内容与上面一致,说明 Notebook 已经连接到 Python Kernel,并能正常执行代码。

使用 Console 快速验证 Python

注意

Console 适合临时验证变量、参数和小段代码,例如 LED PWM、云台角度等;不适合保存完整实验流程,也不要在 Console 中直接发送硬件控制指令。正式测试请回到 Notebook 中运行带安全保护的代码。

在 Launcher 的 Console 区域双击 Python 3 (ipykernel) 打开 Console。

Console

打开后,在图示位置粘贴代码:

Console 输入位置

粘贴以下内容后,按 Shift + Enter 执行。

LED_PWM = 64
GIMBAL_STEP = 20

print("LED_PWM =", LED_PWM)
print("GIMBAL_STEP =", GIMBAL_STEP)

执行结束后,可关闭 Console 标签页;如果 Console 长时间没有返回输入提示,可通过 JupyterLab 菜单中断对应 Kernel。

如果能看到新的 In [ ]: 输入提示,说明 Console 内核工作正常。

console2

开发者补充:项目目录和控制文件检查
提示

这一步用于确认后续章节需要用到的上位机项目文件已经存在。实际开发时,如果项目路径不正确,后续 LED、云台、底盘等控制示例也无法继续运行。

在 Notebook 的 Cell 2 中输入以下代码,确认当前 Python 环境和上位机项目目录是否存在。上位机项目目录为 ~/ugv_jetson

import sys
from pathlib import Path

print("Python 版本:")
print(sys.version)

PROJECT_DIR = Path("~/ugv_jetson").expanduser()

print("\n项目目录:", PROJECT_DIR)
print("项目目录是否存在:", PROJECT_DIR.exists())

上述输出应显示 ~/ugv_jetson 存在。

项目目录: /home/<当前账号>/ugv_jetson
项目目录是否存在: True

如需进一步确认下位机控制封装文件是否存在,可在 Notebook 中检查 base_ctrl.py

control_file = PROJECT_DIR / "base_ctrl.py"
print(control_file, "是否存在:", control_file.exists())

预演打印待发送控制动作

提示

dry-run 只显示计划,不会点亮 LED、不会转动云台,也不会让底盘运动。本节只用于熟悉 JupyterLab 和 Python 的基本操作;真实硬件控制放到后续章节分开讲解。

在正式控制硬件前,先用 dry-run 打印准备执行的动作,确认参数含义。

DRY_RUN = True

control_plan = [
{"name": "低亮度点亮 LED", "action": "lights_ctrl", "args": [64, 64]},
{"name": "云台回中", "action": "gimbal_base_ctrl", "args": [2, 2, 0]},
{"name": "云台小角度转动", "action": "gimbal_ctrl", "args": [20, 0, 0, 0]},
]

for index, step in enumerate(control_plan, start=1):
print(f"{index}. {step['name']}")
print(f" 函数:{step['action']}")
print(f" 参数:{step['args']}")

输出会列出 3 个待执行动作,包括 LED 低亮度点亮、云台回中和云台小角度转动。看到这些输出只说明控制计划已生成,还没有真正向下位机发送控制指令。

摄像头与视觉处理

摄像头资源释放与恢复

机器人开机后会自动启动 Web 主程序。Web 页面、摄像头画面和底盘控制都可能由这个程序管理。如果本节 Notebook 要直接读取摄像头画面,或要直接发送底盘运动指令,需要先临时停止 Web 主程序,避免摄像头或底盘串口被占用。

注意

本小节用于处理摄像头画面无法打开、cap.read() 失败、OpenCV 或 MediaPipe 示例无法读取画面的情况。如果 Notebook 可以运行,但底盘不动,这通常不是摄像头资源问题,请继续查看 底盘串口确认与占用排查

  1. 在 JupyterLab 页面上方点击 + 打开一个新的 Launcher。

  2. Other 区域点击 Terminal。 打开终端窗口。

  3. 临时释放 Python 资源。 输入以下命令,临时结束正在运行的 Python 程序。

    sudo killall -9 python

    这条命令的含义:

    • killall:按进程名结束程序;
    • -9:强制结束信号;
    • python:结束进程名为 python 的程序。

    -9 不是第 9 个 Python 程序。该命令用于测试阶段快速释放 Web 主程序、摄像头占用和可能残留的 Python 资源,不建议作为常规关闭方式。

    执行后,JupyterLab 页面或 Notebook 内核可能断开。如果页面断开,重启设备后重新进入 JupyterLab。执行完成后,再运行本节中的摄像头、OpenCV 或 MediaPipe 示例。

    测试完成后,可以重启设备恢复默认 Web 服务:

    sudo reboot

    重启后,按入门教程中的 打开 Web 控制页面 方法重新访问 Web 页面。

提示

JupyterLab 与 Web 主程序是两个不同服务。临时结束 Web 主程序后,如果 JupyterLab 页面仍可访问,说明开发环境未受影响;如果页面不可访问,优先重启 UGV 后重新进入。

开发者补充

autorun.sh 会向当前账号 crontab 添加 Web 主程序 @reboot 启动项。该脚本拒绝 sudo 执行,需使用 ./autorun.sh

Web 主程序启动项为:

@reboot sleep 1 && whoami && pulseaudio --start && sleep 1 && XDG_RUNTIME_DIR=/run/user/$(id -u) ~/ugv_jetson/ugv-env/bin/python ~/ugv_jetson/app.py >> ~/ugv.log 2>&1

JupyterLab systemd 服务通过以下命令创建和启用:

python3 create_jupyter_service.py
sudo mv ugv_jupyter.service /etc/systemd/system/ugv_jupyter.service
sudo systemctl enable ugv_jupyter
sudo systemctl start ugv_jupyter

如需单独启动 JupyterLab 服务,可执行:

sudo systemctl start ugv_jupyter

start_jupyter.sh 会进入 ~/ugv_jetson/,启用 ugv-env,并运行:

jupyter lab --ip=0.0.0.0 --port=8888 --no-browser

create_jupyter_service.py 生成的 systemd 服务未显式写 --port=8888。OLED 状态说明按 :8888 访问 JupyterLab。若服务访问异常,需在实机上确认实际监听端口。

Web 端视觉功能区

功能区按钮上位机侧作用风险
Simple Detection TypeNone / Motion / Faces选择关闭识别、运动检测或人脸检测;部分页面中可能出现 Movition 拼写,含义仍按运动检测理解。
Simple Detection ReactionNone / Capture / Record设置识别到目标后无动作、自动拍照或自动录像。低到中
Advance CV CtrlLOCK / UNLOCK / AUTODRIVE运动目标锁定、解除锁定或自动驾驶 / 自动控制相关模式。
Advance CV FuncsOBJECTS / COLOR / HAND GS选择物体识别、颜色识别或手势识别。低到中,若联动底盘则为高
MediaPipe FuncsMP FACE / MP POSE选择 MediaPipe 人脸或姿态相关功能。低到中

Web 命令输入框补充

Web 页面底部的 Enter Command 可用于输入辅助命令。有些功能没有独立按钮,或虽然有按钮,但高级参数需要通过命令输入框调整。

低风险辅助命令
命令作用建议
audio -s hello语音播报可直接体验
audio -v 60调整音量可直接体验
audio -p xxx.wav播放指定音频文件需确认文件存在
base -r on / base -r off显示 / 隐藏底盘反馈信息可直接体验
video -q 80 / v -q 80调整视频质量参数可直接体验
视觉调参命令
命令作用建议位置
cv -s red / cv -s green / cv -s blue选择预设颜色目标OpenCV 颜色识别补充
cv -r [H,S,V] [H,S,V]设置颜色目标 HSV 范围HSV 标定后使用
line -r [H,S,V] [H,S,V]设置 AUTODRIVE 线条颜色阈值黄色线条采样判断补充
line -s ...设置采样线、转向影响、速度影响等巡线参数开发者补充,不作为主流程
track -c ... / track -f ... / track -s ... / track -a ...调整颜色目标或人脸跟随参数云台跟随进阶说明

Web 页面中的 COLOR 颜色识别效果依赖 HSV 阈值、光照、背景和目标颜色。稳定识别前,请先完成后文 OpenCV 颜色识别和 HSV 标定,再按需要使用 cv -scv -r

AUTODRIVE 按钮用于进入 Web 端视觉巡线联动。line -rline -s 属于命令输入框调参功能,用于调整线条 HSV 阈值、采样线位置和控制参数,普通体验不需要修改这些参数。

track 命令用于调整颜色目标或人脸跟随的迭代量、速度倍率和加速度倍率。参数调大可能让云台动作更明显,也可能导致抖动或过快移动。建议先完成 Notebook dry-run 和安全版云台跟随,再按需要调整。

谨慎使用命令
命令风险说明
base -c '{"T":1,"L":0,"R":0}'直接控制底盘只放开发者补充,必须先确认停止方式
timelapse -s speed move_time interval loops会联动底盘移动、停止、补光和拍照不作为默认实操
timelapse -e / timelapse --stop停止延时摄影任务timelapse -s 配套使用
send ...设备配对、广播或分组消息不放入主教程
p ... / s ...设置产品型号或配置不建议普通操作中使用
test调试命令不写入正式操作流程

当前 Web 页面没有单独的 Timelapse 按钮。timelapse 属于命令输入框功能,可通过底部 Enter Command 输入命令触发,例如:

timelapse -s 0.05 1 3 5

其中 0.05 表示底盘移动速度,1 表示每次移动时间,3 表示拍摄间隔,5 表示循环次数。该命令会联动底盘移动、停止、补光和拍照,不建议作为入门默认体验。后文 Notebook 延时摄影小节只演示固定机位定时拍照和视频合成,不复现 Web 端移动拍摄流程。

在 JupyterLab 中读取单帧画面

单帧读取用于确认 JupyterLab 中的 Python 环境可以访问摄像头源。

备注

摄像头本身是广角视角,近距离识别时画面中容易包含较多背景。FRAME_WIDTHFRAME_HEIGHT 只设置输出画面的宽高,CAMERA_CANDIDATES 只用于依次尝试摄像头设备编号,它们不会改变摄像头的物理视角。中心裁剪用于保留画面中心区域,便于观察目标,但会损失边缘画面,注意把要拍摄的物体放在画面中间。

  1. 打开 JupyterLab。 新建 Notebook,选择 Python 3 (ipykernel)

  2. Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    运行后没有文本输出属于正常现象,说明导入库、参数定义和函数定义已经完成。

  3. Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    运行成功后会输出当前可用的摄像头源,例如:

    CAMERA_SOURCE = -1

    CAMERA_SOURCE 的值不固定,可能是 -10 或其它可用编号。后续 Cell 直接使用 Notebook 输出的 CAMERA_SOURCE

  4. Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  5. 观察 Notebook 输出的图像。 画面会显示中心裁剪后的摄像头画面。

    提示

    如果画面大面积发白、偏绿或颜色明显异常,通常与强背光、室内灯光、镜头朝向窗户或摄像头刚打开时自动曝光 / 自动白平衡尚未稳定有关。先避开强光背景,将摄像头对准白纸、灰色桌面或光照稳定的场景,再重新运行摄像头读取和显示单元格。

  6. 如果单元格长时间运行。 可点击工具栏中的停止按钮,或在菜单中选择 Kernel > Interrupt Kernel

    运行成功时,Notebook 应先输出当前可用的 CAMERA_SOURCE,再显示一帧摄像头画面。如果读取失败,检查摄像头连接、权限、是否被 Web 服务占用,以及摄像头编号。

    备注

    ZOOM 用于控制中心裁剪倍率。数值越大,画面中心区域占比越高,边缘画面保留越少;数值为 1.0 时显示完整原始画面。可结合测试画面中的目标大小、背景干扰和边缘损失情况调整该值。

OpenCV 功能

UGV 的 OpenCV 示例主要用于验证摄像头采集、图像处理和视觉识别流程。程序会从摄像头读取画面,对图像进行预处理,然后按不同任务选择对应的识别方法。

常见任务包括:

  • 颜色识别:将图像转换到 HSV 色彩空间,通过阈值分割提取指定颜色区域。
  • 运动检测:比较连续帧之间的差异,判断画面中是否存在明显运动。
  • 物体识别:调用预训练模型对画面中的常见物体进行检测。
  • 人脸检测:使用人脸检测模型或分类器定位画面中的人脸区域。
  • 目标位置偏移:计算目标中心与画面中心之间的偏移量。
  • 视觉巡线:识别地面线条位置,为小车运动控制提供方向参考。

这些功能的重点不是单独展示图像效果,而是理解“摄像头输入 → 图像处理 → 目标识别 → 控制决策”的完整视觉应用流程。

备注

Notebook 中的安全底盘联动规则

在 Notebook 中把视觉结果联动到底盘前,先保持 ENABLE_MOTION = False,只打印动作计划。确认识别结果稳定、底盘已架空或处于空旷低速测试环境,并确认停止方式后,才允许改为 True。所有短时动作都应使用 try/finally,确保异常时也会发送停止指令。

开发者补充:OpenCV 相关文件

OpenCV 相关逻辑位于 cv_ctrl.py,包括摄像头帧处理、颜色阈值、目标识别、运动检测、视觉巡线和目标位置偏移等内容;相关配置位于 config.yaml 中的 cv / video / color 配置项。字段名称和路径按机器人本机文件使用。

OpenCV 颜色识别

  1. 确认 hsv_config.json 已存在。 如果使用上方参考 HSV 范围,请按同样格式手动填写 hsv_ranges;如果已经运行 HSV 获取程序,不要再用其它参考值覆盖该文件。

    {
    "camera_source": -1,
    "frame_width": 640,
    "frame_height": 480,
    "hsv_ranges": [
    {"lower": [35, 80, 80], "upper": [85, 255, 255]}
    ],
    "min_area": 300
    }

    上面的示例是绿色参考范围,只适合绿色目标。标定得到其它颜色范围后,不要再运行绿色参考配置。

  2. Cell 1:读取配置并准备 Mask 函数。

    import cv2
    import json
    import matplotlib.pyplot as plt
    import numpy as np

    def build_mask(frame, hsv_ranges):
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    mask = np.zeros(hsv.shape[:2], dtype=np.uint8)

    for item in hsv_ranges:
    lower = np.array(item["lower"], dtype=np.uint8)
    upper = np.array(item["upper"], dtype=np.uint8)
    mask |= cv2.inRange(hsv, lower, upper)

    kernel = np.ones((5, 5), np.uint8)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    return mask

    with open("hsv_config.json", "r", encoding="utf-8") as f:
    config = json.load(f)

    camera_source = config.get("camera_source", -1)
    frame_width = config.get("frame_width", 640)
    frame_height = config.get("frame_height", 480)
    hsv_ranges = config["hsv_ranges"]
    min_area = config.get("min_area", 300)

    print("CAMERA_SOURCE =", camera_source)
    print("HSV 范围数量:", len(hsv_ranges))
    print("MIN_AREA =", min_area)

    运行成功后,Notebook 会输出摄像头源、HSV 范围数量和最小面积参数,例如:

    CAMERA_SOURCE = -1
    HSV 范围数量: 1
    MIN_AREA = 300

    摄像头源、HSV 范围数量和最小面积会随 hsv_config.json 内容变化。

  3. Cell 2:按配置采集一帧画面。

    cap = cv2.VideoCapture(camera_source)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_width)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, frame_height)

    ok, frame = cap.read()
    cap.release()

    if not ok:
    raise RuntimeError("无法读取摄像头画面,请确认主程序已停止且摄像头未被占用。")

    print("图像尺寸:", frame.shape[1], "x", frame.shape[0])

    运行成功后,Notebook 会输出图像尺寸,例如:

    图像尺寸: 640 x 480

    图像尺寸会随摄像头配置变化。若这里失败,先检查摄像头是否被 Web 主程序或其它 Notebook 占用。

  4. Cell 3:生成 Mask、查找最大颜色区域,并显示检测结果。

    mask = build_mask(frame, hsv_ranges)
    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    preview = frame.copy()
    target_valid = False
    area = 0
    center = None

    if contours:
    contour = max(contours, key=cv2.contourArea)
    area = cv2.contourArea(contour)
    if area >= min_area:
    x, y, w, h = cv2.boundingRect(contour)
    moments = cv2.moments(contour)
    if moments["m00"]:
    cx = int(moments["m10"] / moments["m00"])
    cy = int(moments["m01"] / moments["m00"])
    center = (cx, cy)
    target_valid = True
    cv2.rectangle(preview, (x, y), (x + w, y + h), (0, 255, 0), 2)
    cv2.circle(preview, (cx, cy), 5, (0, 0, 255), -1)
    print(f"目标面积: {area:.0f}, 中心坐标: ({cx}, {cy})")
    else:
    print(f"最大颜色区域面积过小: {area:.0f}")
    else:
    print("未找到目标颜色区域")

    plt.figure(figsize=(10, 4))
    plt.subplot(1, 2, 1)
    plt.title("Detection")
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")

    plt.subplot(1, 2, 2)
    plt.title("Mask")
    plt.imshow(mask, cmap="gray")
    plt.axis("off")
    plt.show()

    运行成功后,Notebook 会显示 DetectionMask 两个结果图。识别到目标时会输出目标面积和中心坐标,例如:

    目标面积: 1842, 中心坐标: (420, 230)

    如果当前画面没有符合阈值的目标颜色区域,会输出:

    未找到目标颜色区域
    注意

    颜色识别的目标是使用 HSV 阈值筛出目标颜色区域,并输出最大目标区域的检测框和中心点。它不会判断颜色名称,也不会区分同色物体的类别。

    识别正常时,Mask 中目标物主体应为主要白色区域,背景、反光、阴影和桌面大部分应为黑色;Detection 中的检测框应落在目标主体上,中心点不应明显偏离目标。重新拍摄相近画面时,mask 结果应基本稳定。

开发者补充:颜色识别相关文件

颜色识别相关逻辑位于 cv_ctrl.py,包括颜色阈值、默认颜色和摄像头帧处理逻辑;相关配置位于 config.yaml 中的 cv / video / color 配置项。字段名称和路径按机器人本机文件使用。

应用:黄色线条采样判断(巡线功能)

黄色胶带也可以看作一种颜色目标。AUTODRIVE 的基本思路是:先分割黄色线条,再用画面下半部分的两条采样线计算线条中心、底部偏移量和斜率,最后判断线条偏左、居中或偏右。

本节只在 Notebook 中拆解这部分图像判断过程,不发送底盘控制指令,也不实现完整自动巡线。完整体验请在入门教程的 Web 端 AUTODRIVE 功能中完成。

备注

车载摄像头默认朝前时,不一定能看到地面上的黄色胶带。建议把黄色胶带贴在白纸或纸板上,并让线条从画面下方延伸到画面中部;横向胶带只能验证颜色分割,不适合观察斜率判断。

  1. 确认 CAMERA_SOURCE 可用。 后续 Cell 会直接复用前面单帧读取测试得到的摄像头编号。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. 准备黄色线条。 在一张白纸或纸板上贴一条黄色胶带。胶带不要横着贴,建议从纸张下方向上方贴成一条竖线,或稍微倾斜的竖线。

    把白纸或纸板放到摄像头画面下半部分。后面的程序会用画面中的两条横线判断黄线位置。黄线最好同时穿过两条横线,这样才能判断偏左、居中、偏右和大致方向。

  3. Cell 1:导入库和黄色线条参数。

    import cv2
    import matplotlib.pyplot as plt
    import numpy as np

    LINE_LOWER = np.array([14, 207, 122])
    LINE_UPPER = np.array([31, 255, 217])

    SAMPLE_Y_RATIO_1 = 0.60
    SAMPLE_Y_RATIO_2 = 0.90
    OFFSET_THRESHOLD = 30

    def get_line_center(mask, sample_y):
    sample_row = mask[sample_y, :]
    line_pixels = np.where(sample_row > 0)[0]

    if len(line_pixels) == 0:
    return None, None, None

    left = int(line_pixels[0])
    right = int(line_pixels[-1])
    center = int((left + right) / 2)

    return left, right, center

    print("黄色线条 HSV 下限:", LINE_LOWER)
    print("黄色线条 HSV 上限:", LINE_UPPER)
    print("采样线位置:", SAMPLE_Y_RATIO_1, SAMPLE_Y_RATIO_2)

    Notebook 会输出当前黄色阈值和两条采样线位置,例如 0.60.9。该单元格只准备参数,不会打开摄像头。

  4. Cell 2:读取当前黄色线条画面并确认 Mask。

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    ok, frame = cap.read()
    cap.release()

    if not ok:
    raise RuntimeError("无法读取摄像头画面")

    h, w = frame.shape[:2]
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    mask = cv2.inRange(hsv, LINE_LOWER, LINE_UPPER)

    kernel = np.ones((5, 5), np.uint8)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)

    plt.figure(figsize=(12, 5))

    plt.subplot(1, 2, 1)
    plt.imshow(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    plt.title("Current Frame")
    plt.axis("off")

    plt.subplot(1, 2, 2)
    plt.imshow(mask, cmap="gray")
    plt.title("Yellow Line Mask")
    plt.axis("off")

    plt.show()

    print("图像尺寸:", w, "x", h)

    Notebook 会同时显示原始画面和 Mask,并输出图像尺寸,例如 640 x 480。黄色胶带应在 Mask 中显示为清晰的白色区域,并尽量穿过后续两条采样线。若黄色胶带没有变白,或背景大面积变白,先调整光照、位置和背景。仍然不准时,回到 获取 HSV 值 重新标定。

  5. Cell 3:两条采样线和方向判断。

    sample_y_1 = int(h * SAMPLE_Y_RATIO_1)
    sample_y_2 = int(h * SAMPLE_Y_RATIO_2)

    left_1, right_1, center_1 = get_line_center(mask, sample_y_1)
    left_2, right_2, center_2 = get_line_center(mask, sample_y_2)

    preview = frame.copy()
    frame_center_x = w // 2

    cv2.line(preview, (0, sample_y_1), (w, sample_y_1), (255, 0, 0), 2)
    cv2.line(preview, (0, sample_y_2), (w, sample_y_2), (255, 0, 0), 2)
    cv2.line(preview, (frame_center_x, 0), (frame_center_x, h), (0, 255, 255), 2)

    direction = "未检测到线条"
    offset = None
    line_slope = None

    if center_1 is not None:
    cv2.line(preview, (left_1, sample_y_1 - 15), (left_1, sample_y_1 + 15), (0, 255, 0), 2)
    cv2.line(preview, (right_1, sample_y_1 - 15), (right_1, sample_y_1 + 15), (0, 255, 0), 2)
    cv2.circle(preview, (center_1, sample_y_1), 6, (0, 0, 255), -1)

    if center_2 is not None:
    cv2.line(preview, (left_2, sample_y_2 - 15), (left_2, sample_y_2 + 15), (0, 255, 0), 2)
    cv2.line(preview, (right_2, sample_y_2 - 15), (right_2, sample_y_2 + 15), (0, 255, 0), 2)
    cv2.circle(preview, (center_2, sample_y_2), 6, (0, 0, 255), -1)

    if center_1 is not None and center_2 is not None:
    cv2.line(preview, (center_1, sample_y_1), (center_2, sample_y_2), (255, 0, 255), 2)

    offset = center_2 - frame_center_x
    line_slope = (center_1 - center_2) / abs(sample_y_1 - sample_y_2)

    if abs(offset) <= OFFSET_THRESHOLD:
    direction = "居中"
    elif offset < 0:
    direction = "偏左"
    else:
    direction = "偏右"

    elif center_2 is not None:
    offset = center_2 - frame_center_x

    if abs(offset) <= OFFSET_THRESHOLD:
    direction = "底部采样线居中"
    elif offset < 0:
    direction = "底部采样线偏左"
    else:
    direction = "底部采样线偏右"

    elif center_1 is not None:
    direction = "只在上方采样线检测到线条"

    plt.figure(figsize=(8, 5))
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.title("Yellow Line Sampling")
    plt.axis("off")
    plt.show()

    print("采样线 1 y:", sample_y_1)
    print("采样线 2 y:", sample_y_2)
    print("采样线 1 是否检测到线条:", center_1 is not None)
    print("采样线 2 是否检测到线条:", center_2 is not None)
    print("采样线 1 中心:", center_1)
    print("采样线 2 中心:", center_2)
    print("画面中心 x:", frame_center_x)
    print("底部偏移量:", offset)
    print("线条斜率:", line_slope)
    print("方向建议:", direction)

    if center_1 is not None and center_2 is not None:
    print("AUTODRIVE 理解:两条采样线都检测到线条,可以同时判断底部偏移和线条延伸方向。")
    elif center_2 is not None:
    print("AUTODRIVE 理解:只检测到底部采样线,只能判断线条底部位置,不能完整判断线条方向。")
    elif center_1 is not None:
    print("AUTODRIVE 理解:只检测到上方采样线,线条没有进入画面底部采样区域,方向判断不完整。")
    else:
    print("AUTODRIVE 理解:两条采样线都没有检测到线条,请调整线条位置、摄像头视角或 HSV 阈值。")

    图中会显示两条蓝色采样线、黄色线条的左右边界、线条中心点和画面中心线。当前记录中,两条采样线都检测到线条,底部偏移量约为 -157,方向建议为 偏左,说明黄色线条在画面下方明显偏左。

    提示

    如果只检测到一条采样线,只能做部分判断;如果两条采样线都检测不到,先确认 Mask 中黄色线条是否清晰,再调整线条位置、摄像头视角或 HSV 阈值。

应用: 颜色目标云台跟随

颜色目标云台跟随是在 OpenCV 颜色识别基础上的联动示例。前面的三色小球识别负责找出红、绿、蓝小球区域,本节继续计算小球中心与画面中心的偏移量,并把偏移量转换为云台角度调整。

Web 页面中的目标跟随 / 云台跟随可以理解为:识别目标后,持续计算目标中心和画面中心的偏移,再把偏移量转换为云台控制指令。本节用 Notebook 拆开这个过程,只演示基于材料包三色小球和 HSV 阈值的云台跟随。

  1. Cell 1:读取三色小球配置和云台安全参数。 先完成前面的 三色小球一次性 HSV 标定 和预览,确认 hsv_config.json 中已有 targets 列表,并且 Mask 能稳定锁定红、绿、蓝小球。该 Cell 不打开摄像头,也不发送云台指令。

    import cv2
    import json
    import math
    import time
    from pathlib import Path

    import matplotlib.pyplot as plt
    import numpy as np

    try:
    import serial
    except ImportError:
    serial = None

    CONFIG_PATH = Path("hsv_config.json")

    if not CONFIG_PATH.exists():
    raise RuntimeError("未找到 hsv_config.json,请先完成三色小球一次性 HSV 标定。")

    with CONFIG_PATH.open("r", encoding="utf-8") as f:
    config = json.load(f)

    CAMERA_SOURCE = config.get("camera_source", -1)
    FRAME_WIDTH = config.get("frame_width", 640)
    FRAME_HEIGHT = config.get("frame_height", 480)
    TARGETS = config.get("targets", [])
    if not TARGETS:
    raise RuntimeError("hsv_config.json 中缺少 targets,请先完成三色小球一次性 HSV 标定。")

    DEFAULT_MIN_AREA = config.get("min_area", 300)

    ENABLE_GIMBAL = False

    # 开启真实云台动作前,请改成已经确认可控制云台的串口。
    SERIAL_PORT = "/dev/ttyTHS1"
    BAUDRATE = 115200

    RUN_SECONDS = 10
    FRAME_INTERVAL = 0.12

    # 云台跟随参数。先用保守参数,确认方向正确后再微调。
    TRACK_ITERATE = 0.023
    AIMED_ERROR = 8

    PAN_MIN = -180
    PAN_MAX = 180
    TILT_MIN = -30
    TILT_MAX = 90

    SPD_MIN = 1
    SPD_MAX = 600
    ACC_MIN = 1
    ACC_MAX = 80

    TRACK_SPD_RATE = 10
    TRACK_ACC_RATE = 0.2

    REQUIRE_SINGLE_TARGET = True
    REQUIRE_STABLE_FRAMES = 3
    MAX_CENTER_JUMP = 60

    # 初始云台角度和回中指令。真实跟随前会先让云台回到中间附近。
    pan_angle = 0.0
    tilt_angle = 0.0
    GIMBAL_CENTER_SPEED = 80
    GIMBAL_CENTER_ACC = 20

    def clamp(value, min_value, max_value):
    return max(min_value, min(max_value, value))

    def build_mask(frame, hsv_ranges):
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    mask = np.zeros(hsv.shape[:2], dtype=np.uint8)

    for item in hsv_ranges:
    lower = np.array(item["lower"], dtype=np.uint8)
    upper = np.array(item["upper"], dtype=np.uint8)
    mask |= cv2.inRange(hsv, lower, upper)

    kernel = np.ones((5, 5), np.uint8)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    return mask

    def find_ball_targets(frame, targets, default_min_area):
    mask_preview = np.zeros(frame.shape[:2], dtype=np.uint8)
    detected_targets = []
    max_area = 0

    for target in targets:
    mask = build_mask(frame, target["hsv_ranges"])
    mask_preview |= mask

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
    continue

    contour = max(contours, key=cv2.contourArea)
    area = cv2.contourArea(contour)
    min_area = target.get("min_area", default_min_area)
    max_area = max(max_area, area)

    if area < min_area:
    continue

    moments = cv2.moments(contour)
    if moments["m00"] == 0:
    continue

    center = (
    int(moments["m10"] / moments["m00"]),
    int(moments["m01"] / moments["m00"]),
    )

    detected_targets.append(
    {
    "name": target["name"],
    "label": target.get("label", target["name"]),
    "display": target.get("display", target["name"]),
    "draw_color": tuple(target.get("draw_color", (0, 255, 0))),
    "contour": contour,
    "center": center,
    "area": area,
    }
    )

    detected_targets.sort(key=lambda item: item["area"], reverse=True)
    return mask_preview, detected_targets, max_area

    def make_gimbal_command(frame_center, target_center, current_pan, current_tilt):
    offset_x = target_center[0] - frame_center[0]
    offset_y = target_center[1] - frame_center[1]
    distance = math.sqrt(offset_x ** 2 + offset_y ** 2)

    if distance <= AIMED_ERROR:
    return None, current_pan, current_tilt, offset_x, offset_y, distance

    next_pan = current_pan + offset_x * TRACK_ITERATE
    next_tilt = current_tilt - offset_y * TRACK_ITERATE

    next_pan = clamp(next_pan, PAN_MIN, PAN_MAX)
    next_tilt = clamp(next_tilt, TILT_MIN, TILT_MAX)

    speed = clamp(int(distance * TRACK_SPD_RATE), SPD_MIN, SPD_MAX)
    acc = clamp(int(distance * TRACK_ACC_RATE), ACC_MIN, ACC_MAX)

    command = {
    "T": 133,
    "X": round(next_pan, 2),
    "Y": round(next_tilt, 2),
    "SPD": int(speed),
    "ACC": int(acc),
    }

    return command, next_pan, next_tilt, offset_x, offset_y, distance

    print("CAMERA_SOURCE =", CAMERA_SOURCE)
    print("小球目标数量:", len(TARGETS))
    print("小球目标:", [target.get("label", target["name"]) for target in TARGETS])
    print("DEFAULT_MIN_AREA =", DEFAULT_MIN_AREA)
    print("ENABLE_GIMBAL =", ENABLE_GIMBAL)
    print("SERIAL_PORT =", SERIAL_PORT)
    print("AIMED_ERROR =", AIMED_ERROR)
    print("SPD_MAX =", SPD_MAX)
    print("ACC_MAX =", ACC_MAX)
    print("GIMBAL_CENTER_SPEED =", GIMBAL_CENTER_SPEED)

    运行成功后,Notebook 会输出摄像头源、小球目标数量、最小面积参数、云台控制开关和限幅参数。此时不会打开摄像头,也不会发送任何云台指令。

  2. Cell 2:dry-run 观察三色小球偏移和云台建议。 一次只让一个小球进入画面。该 Cell 会打开摄像头,持续显示 DetectionMask,但默认不打开串口,也不累计云台角度。

    from IPython.display import clear_output

    cap = cv2.VideoCapture(CAMERA_SOURCE)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    last_command = None

    try:
    start_time = time.time()

    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()
    if not ok:
    raise RuntimeError("无法读取摄像头画面")

    frame_h, frame_w = frame.shape[:2]
    frame_center = (frame_w // 2, frame_h // 2)

    mask, detected_targets, max_area = find_ball_targets(frame, TARGETS, DEFAULT_MIN_AREA)
    preview = frame.copy()

    status = "未检测到有效小球目标"
    target_text = "无"
    command_text = "无"
    offset_text = "无"
    distance_text = "无"

    cv2.drawMarker(
    preview,
    frame_center,
    (255, 0, 0),
    markerType=cv2.MARKER_CROSS,
    markerSize=18,
    thickness=2,
    )

    for item in detected_targets:
    x, y, w, h = cv2.boundingRect(item["contour"])
    cv2.rectangle(preview, (x, y), (x + w, y + h), item["draw_color"], 2)
    cv2.circle(preview, item["center"], 6, (0, 0, 255), -1)
    cv2.putText(
    preview,
    item["display"],
    (x, max(20, y - 8)),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.6,
    item["draw_color"],
    2,
    )

    if len(detected_targets) > 1:
    status = "检测到多个小球目标:云台保持不动"
    elif len(detected_targets) == 1:
    selected = detected_targets[0]
    target_center = selected["center"]
    cv2.line(preview, frame_center, target_center, (255, 0, 255), 2)

    command, _, _, offset_x, offset_y, distance = make_gimbal_command(
    frame_center,
    target_center,
    pan_angle,
    tilt_angle,
    )

    target_text = f"{selected['label']} {selected['center']}"
    offset_text = (offset_x, offset_y)
    distance_text = round(distance, 1)

    if command is None:
    status = "目标接近画面中心:云台保持"
    else:
    status = "检测到单个小球目标:已生成一步云台控制建议"
    command_text = command
    last_command = command

    clear_output(wait=True)
    print("运行时间:", round(time.time() - start_time, 1), "/", RUN_SECONDS, "秒")
    print("当前状态:", status)
    print("有效小球数量:", len(detected_targets))
    print("当前小球:", target_text)
    print("最大目标面积:", int(max_area))
    print("偏移量 offset_x, offset_y:", offset_text)
    print("距离:", distance_text)
    print("云台 JSON 建议:", command_text)
    print("DRY-RUN:未发送真实云台指令。")

    plt.figure(figsize=(10, 4))

    plt.subplot(1, 2, 1)
    plt.title("Detection")
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")

    plt.subplot(1, 2, 2)
    plt.title("Mask")
    plt.imshow(mask, cmap="gray")
    plt.axis("off")

    plt.show()

    time.sleep(FRAME_INTERVAL)

    except KeyboardInterrupt:
    print("已手动停止。")

    finally:
    cap.release()
    print("摄像头已释放。")

    运行后,Notebook 会持续显示 DetectionMask。只检测到一个小球时,会输出小球名称、中心点、偏移量、距离和一条云台 JSON 建议,但默认不会发送真实指令。目标接近画面中心时,Notebook 会显示云台保持。

    如果 Mask 不稳定、检测到多个小球,或目标框跳动明显,不要开启真实云台动作。请回到三色小球标定或预览步骤重新调整 ROI 和 HSV 阈值。

    注意

    真实云台跟随会发送云台控制指令。运行前请确认:

    • 已临时停止 Web 主程序,避免串口被占用;
    • SERIAL_PORT 是已经确认可控制云台的串口;
    • Mask 能稳定锁定单个小球目标;
    • 目标识别没有明显跳动;
    • RUN_SECONDS 保持较短;
    • 云台周围没有线缆、手指或其它障碍物;
    • 云台回中动作不会碰到线缆、支架或其它物体;
    • dry-run 中的方向建议与实际云台方向一致。
  3. Cell 3:开启真实云台跟随。 默认仍是安全模式,必须手动确认后再改为 True

    ENABLE_GIMBAL = False # 确认目标稳定、串口正确、云台方向正确后,才手动改为 True。

    if not ENABLE_GIMBAL:
    raise RuntimeError("当前仍是安全模式:ENABLE_GIMBAL = False。确认无误后,手动改为 True 再运行。")

    if serial is None:
    raise RuntimeError("未安装 pyserial,无法发送串口指令。")

    ser = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1)

    center_command = {
    "T": 133,
    "X": pan_angle,
    "Y": tilt_angle,
    "SPD": GIMBAL_CENTER_SPEED,
    "ACC": GIMBAL_CENTER_ACC,
    }

    ser.write((json.dumps(center_command) + "\n").encode("utf-8"))
    print("已发送云台回中指令:", center_command)
    time.sleep(1.0)

    cap = cv2.VideoCapture(CAMERA_SOURCE)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    if not cap.isOpened():
    ser.close()
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    stable_count = 0
    last_center = None
    current_pan = pan_angle
    current_tilt = tilt_angle
    sent_count = 0
    skip_count = 0

    try:
    start_time = time.time()

    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()
    if not ok:
    raise RuntimeError("无法读取摄像头画面")

    frame_h, frame_w = frame.shape[:2]
    frame_center = (frame_w // 2, frame_h // 2)

    mask, detected_targets, max_area = find_ball_targets(frame, TARGETS, DEFAULT_MIN_AREA)
    preview = frame.copy()

    status = "未检测到有效小球目标,云台保持"
    command = None

    cv2.drawMarker(
    preview,
    frame_center,
    (255, 0, 0),
    markerType=cv2.MARKER_CROSS,
    markerSize=18,
    thickness=2,
    )

    for item in detected_targets:
    x, y, w, h = cv2.boundingRect(item["contour"])
    cv2.rectangle(preview, (x, y), (x + w, y + h), item["draw_color"], 2)
    cv2.circle(preview, item["center"], 6, (0, 0, 255), -1)
    cv2.putText(
    preview,
    item["display"],
    (x, max(20, y - 8)),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.6,
    item["draw_color"],
    2,
    )

    if len(detected_targets) > 1:
    stable_count = 0
    last_center = None
    status = "检测到多个小球目标,云台保持"
    skip_count += 1
    elif len(detected_targets) == 1:
    selected = detected_targets[0]
    target_center = selected["center"]
    cv2.line(preview, frame_center, target_center, (255, 0, 255), 2)

    command, pan_angle_next, tilt_angle_next, offset_x, offset_y, distance = make_gimbal_command(
    frame_center,
    target_center,
    current_pan,
    current_tilt,
    )

    if last_center is None:
    stable_count = 1
    else:
    jump = math.sqrt(
    (target_center[0] - last_center[0]) ** 2 +
    (target_center[1] - last_center[1]) ** 2
    )

    if jump <= MAX_CENTER_JUMP:
    stable_count += 1
    else:
    stable_count = 1

    last_center = target_center

    if command is None:
    status = "目标接近画面中心,云台保持"
    skip_count += 1
    elif stable_count < REQUIRE_STABLE_FRAMES:
    status = "目标未稳定,暂不发送云台指令"
    skip_count += 1
    else:
    ser.write((json.dumps(command) + "\n").encode("utf-8"))
    current_pan = pan_angle_next
    current_tilt = tilt_angle_next
    sent_count += 1
    status = f"已发送云台跟随指令:{selected['label']}"
    else:
    stable_count = 0
    last_center = None
    skip_count += 1

    clear_output(wait=True)
    print("运行时间:", round(time.time() - start_time, 1), "/", RUN_SECONDS, "秒")
    print("当前状态:", status)
    print("有效小球数量:", len(detected_targets))
    print("稳定帧数:", stable_count, "/", REQUIRE_STABLE_FRAMES)
    print("最大目标面积:", int(max_area))
    print("当前云台角度:", round(current_pan, 2), round(current_tilt, 2))
    print("已发送指令次数:", sent_count)
    print("跳过发送次数:", skip_count)
    print("发送指令:", command if status.startswith("已发送云台跟随指令") else "无")

    plt.figure(figsize=(8, 5))
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")
    plt.show()

    time.sleep(FRAME_INTERVAL)

    except KeyboardInterrupt:
    print("已手动停止。")

    finally:
    cap.release()
    ser.close()
    print("摄像头和串口已释放。")
    print("本次发送云台指令次数:", sent_count)
    print("本次跳过发送次数:", skip_count)

    运行真实云台控制 Cell 后,云台会在检测到单个稳定颜色目标时尝试小幅调整,让目标靠近画面中心。由于本节主流程优先保证安全,程序加入了目标稳定确认、死区、速度限幅和较短运行时间,因此云台动作可能表现为短时、小幅、间歇调整,而不是连续流畅跟随。

    成功时,应能看到以下现象:

    • Detection 中能看到目标框、目标中心、画面中心和连线;
    • 终端会输出目标数量、稳定帧数、当前云台角度和发送指令;
    • 当目标稳定且偏离画面中心时,云台会尝试小幅调整;
    • 当目标接近中心、目标丢失、出现多个目标或目标跳动过大时,程序不会继续发送新的跟随指令;
    • 本节主流程以安全验证为主,动作可能不连续,不作为最终连续跟随效果。
    提示

    如果云台只偶尔动作,先检查 Mask 是否稳定、是否只有一个目标,以及目标中心是否跳动明显。需要更连贯的跟随效果时,可继续完成后面的“任务:连续跟随”。

    如果云台运动方向与预期相反,应立即停止运行,回到 dry-run 模式检查方向,不要继续提高速度或延长运行时间。

OpenCV 运动检测

运动检测通过比较连续画面的变化,判断画面中是否出现明显运动区域。它适合演示“有人或物体经过画面时触发提示、截图或录像”的效果,但不会识别这个物体是什么。

Web 页面中的 Motion / Capture 可以理解为把运动检测和拍照联动起来:当画面出现明显变化时,触发截图或记录。这里的 Notebook 示例用于拆开观察检测过程,方便看到检测框、运动区域面积和截图保存条件。

  1. 确认 CAMERA_SOURCE 可用。 本节直接复用前面单帧读取测试得到的摄像头编号,不再重复摄像头候选扫描流程。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. Cell 1:运动检测参数和辅助函数。 准备有限时运行、面积过滤、背景更新和运动区域画框逻辑。

    RUN_SECONDS 表示本次检测运行时长。为避免摄像头长时间占用,本示例使用有限时循环。MIN_AREA 用于过滤很小的变化区域;数值越小,越容易触发检测,但也更容易误报;数值越大,越不容易误报,但可能漏掉小动作。

    import cv2
    import time
    from pathlib import Path

    import matplotlib.pyplot as plt
    from IPython.display import clear_output

    RUN_SECONDS = 10
    MIN_AREA = 2000
    FRAME_INTERVAL = 0.05
    SAVE_PATH = Path("motion_detected.jpg")

    BACKGROUND_WEIGHT = 0.5
    THRESHOLD_VALUE = 5
    DILATE_ITERATIONS = 2
    BLUR_SIZE = (21, 21)

    def detect_motion(frame, avg, min_area=MIN_AREA):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    gray = cv2.GaussianBlur(gray, BLUR_SIZE, 0)

    if avg is None:
    avg = gray.copy().astype("float")
    return frame.copy(), avg, [], 0

    cv2.accumulateWeighted(gray, avg, BACKGROUND_WEIGHT)
    frame_delta = cv2.absdiff(gray, cv2.convertScaleAbs(avg))

    thresh = cv2.threshold(frame_delta, THRESHOLD_VALUE, 255, cv2.THRESH_BINARY)[1]
    thresh = cv2.dilate(thresh, None, iterations=DILATE_ITERATIONS)

    contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    preview = frame.copy()
    motion_boxes = []
    max_area = 0

    for contour in contours:
    area = cv2.contourArea(contour)
    if area < min_area:
    continue

    x, y, w, h = cv2.boundingRect(contour)
    cv2.rectangle(preview, (x, y), (x + w, y + h), (0, 255, 0), 2)

    motion_boxes.append((x, y, w, h, area))
    max_area = max(max_area, area)

    return preview, avg, motion_boxes, max_area

    运行成功后,Notebook 不会立即打开摄像头,只完成运动检测参数和辅助函数准备。

  3. Cell 2:实时运动检测并自动保存截图。 复用前面单帧读取测试确认的 CAMERA_SOURCE,在限定时间内持续刷新画面。

    if "CAMERA_SOURCE" not in globals():
    if "camera_source" in globals() and camera_source is not None:
    CAMERA_SOURCE = camera_source
    else:
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    avg = None
    saved = False
    last_status = "未检测到明显运动"
    last_max_area = 0
    last_motion_count = 0

    try:
    start_time = time.time()

    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()

    if not ok:
    raise RuntimeError("无法读取摄像头画面")

    preview, avg, motion_boxes, max_area = detect_motion(frame, avg, MIN_AREA)

    motion_count = len(motion_boxes)
    if motion_count > 0:
    status = "检测到明显运动"

    if not saved:
    cv2.imwrite(str(SAVE_PATH), preview)
    saved = True
    else:
    status = "未检测到明显运动"

    last_status = status
    last_max_area = max_area
    last_motion_count = motion_count

    clear_output(wait=True)
    print("运行时间:", round(time.time() - start_time, 1), "/", RUN_SECONDS, "秒")
    print("当前状态:", status)
    print("运动区域数量:", motion_count)
    print("最大运动区域面积:", int(max_area))
    print("截图保存:", str(SAVE_PATH) if saved else "尚未保存")

    plt.figure(figsize=(8, 5))
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")
    plt.show()

    time.sleep(FRAME_INTERVAL)

    except KeyboardInterrupt:
    print("已手动停止。")

    finally:
    cap.release()
    print("摄像头已释放。")

    运行后,Notebook 会在指定时间内持续刷新画面。移动手掌、小球或其它物体经过镜头时,应看到绿色检测框。检测到明显运动时,输出中会显示 当前状态:检测到明显运动、运动区域数量、最大运动区域面积,并保存 motion_detected.jpg

    典型输出如下:

    运行时间: 10.0 / 10 秒
    当前状态: 检测到明显运动
    运动区域数量: 3
    最大运动区域面积: 118290
    截图保存: motion_detected.jpg
    摄像头已释放。

    运动区域数量和最大运动区域面积会随画面变化而变化,不需要和示例数值一致。如果一直没有生成截图,可能是画面中没有明显变化,或 MIN_AREA 设置过大。

  4. Cell 3:查看保存的运动截图。 只查看保存结果,不重新检测,不重新打开摄像头。

    if SAVE_PATH.exists():
    image = cv2.imread(str(SAVE_PATH))
    image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

    plt.figure(figsize=(8, 5))
    plt.title(str(SAVE_PATH))
    plt.imshow(image_rgb)
    plt.axis("off")
    plt.show()

    print("已找到运动截图:", SAVE_PATH)
    else:
    print("没有找到运动截图:", SAVE_PATH)
    print("可以重新运行实时检测 Cell,并在摄像头前移动手掌或小球。")
    print("如果仍未触发,可以适当降低 MIN_AREA。")

    如果显示截图并输出 已找到运动截图: motion_detected.jpg,说明前一个 Cell 已经触发保存。这个 Cell 只读取已保存的文件,不会重新打开摄像头,也不会重新执行运动检测。如果没有截图,可以重新运行实时检测 Cell,并在画面中制造更明显的移动;也可以适当降低 MIN_AREA

OpenCV 物体识别

本例选择一个瓶子作为测试目标。将瓶子放在画面中央,背景尽量简单,光照保持稳定。

  1. 确认 CAMERA_SOURCE 可用。 继续使用前面单帧读取测试得到的摄像头编号。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. 将一个瓶子放入摄像头画面中。

  3. Cell 1:导入库、模型路径、类别列表和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt
    from pathlib import Path

    MODEL_DIR = Path("~/ugv_jetson/models").expanduser()
    PROTOTXT = MODEL_DIR / "deploy.prototxt"
    CAFFEMODEL = MODEL_DIR / "mobilenet_iter_73000.caffemodel"
    ZOOM = 1.5

    CLASSES = [
    "background", "aeroplane", "bicycle", "bird", "boat", "bottle",
    "bus", "car", "cat", "chair", "cow", "diningtable", "dog",
    "horse", "motorbike", "person", "pottedplant", "sheep", "sofa",
    "train", "tvmonitor",
    ]

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    运行后没有文本输出属于正常现象,说明模型路径、类别列表和中心裁剪函数已经准备完成。

  4. Cell 2:检查模型文件是否存在。

    print("模型目录:", MODEL_DIR)
    print("网络结构文件:", PROTOTXT, PROTOTXT.exists())
    print("权重文件:", CAFFEMODEL, CAFFEMODEL.exists())

    if not PROTOTXT.exists() or not CAFFEMODEL.exists():
    raise FileNotFoundError("未找到 MobileNet-SSD 模型文件,请检查 ~/ugv_jetson/models/。")

    运行成功时,网络结构文件权重文件 后面都应显示 True。如果其中一个为 False,先确认 ~/ugv_jetson/models/ 中是否存在对应文件,不要继续运行推理 Cell。

  5. Cell 3:加载 MobileNet-SSD 模型,并读取一帧摄像头画面。 直接复用前面单帧读取测试确认的 CAMERA_SOURCE,不再重复候选摄像头扫描。

    if "CAMERA_SOURCE" not in globals():
    if "camera_source" in globals() and camera_source is not None:
    CAMERA_SOURCE = camera_source
    else:
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    net = cv2.dnn.readNetFromCaffe(str(PROTOTXT), str(CAFFEMODEL))

    cap = cv2.VideoCapture(CAMERA_SOURCE)
    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    ok, frame = cap.read()
    if not ok:
    cap.release()
    raise RuntimeError("无法读取摄像头画面")

    frame = center_crop_zoom(frame, ZOOM)
    print("CAMERA_SOURCE =", CAMERA_SOURCE)
    print("图像尺寸:", frame.shape[1], "x", frame.shape[0])

    运行成功后会输出摄像头编号和图像尺寸。该 Cell 只完成模型加载和单帧读取,还不会输出检测类别。

  6. Cell 4:执行 net.forward() 推理,筛选检测结果并绘制检测框。

    CONFIDENCE_THRESHOLD = 0.4
    h, w = frame.shape[:2]

    blob = cv2.dnn.blobFromImage(
    cv2.resize(frame, (300, 300)),
    scalefactor=0.007843,
    size=(300, 300),
    mean=127.5,
    )

    net.setInput(blob)
    detections = net.forward()

    result = frame.copy()
    detected_items = []

    for i in range(detections.shape[2]):
    confidence = float(detections[0, 0, i, 2])
    if confidence < CONFIDENCE_THRESHOLD:
    continue

    class_id = int(detections[0, 0, i, 1])
    if class_id >= len(CLASSES):
    continue

    box = detections[0, 0, i, 3:7] * [w, h, w, h]
    start_x, start_y, end_x, end_y = box.astype("int")

    label = f"{CLASSES[class_id]}: {confidence:.2f}"
    detected_items.append(label)

    cv2.rectangle(result, (start_x, start_y), (end_x, end_y), (0, 255, 0), 2)
    cv2.putText(
    result,
    label,
    (start_x, max(start_y - 8, 20)),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.6,
    (0, 255, 0),
    2,
    )

    try:
    if detected_items:
    print("检测结果:")
    for item in detected_items:
    print("-", item)
    else:
    print("未检测到置信度高于阈值的目标。")

    plt.imshow(cv2.cvtColor(result, cv2.COLOR_BGR2RGB))
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()

    运行成功后,Notebook 会释放摄像头,并显示一张推理结果图。识别到模型支持类别且置信度高于阈值时,会输出 检测结果:、类别名称和置信度,并在图像中绘制绿色检测框;没有满足阈值的结果时,会输出 未检测到置信度高于阈值的目标。

    识别到瓶子时,常见输出示例如下:

    检测结果:
    - bottle: 0.71

    如果输出 未检测到置信度高于阈值的目标。,说明本次画面没有得到满足阈值的固定类别结果,不代表摄像头或模型文件一定异常。

    提示

    如果输出“未检测到置信度高于阈值的目标”,优先检查瓶子是否在画面中央、目标是否太小、画面是否过暗、背景是否杂乱。可将 ZOOM1.5 调整为 2.0,让模型优先处理画面中心区域;也可将 CONFIDENCE_THRESHOLD0.4 降到 0.3 做对比,但阈值过低会增加误检。

OpenCV Haar Cascade 人脸检测

OpenCV Haar Cascade 人脸检测用于演示传统人脸检测方法。它会在画面中寻找类似正脸的人脸区域并画出检测框,速度快、代码简单,但对光照、角度、遮挡和背景纹理比较敏感,书本封面、海报、屏幕画面等带有类似五官纹理的区域可能被误框,人脸侧转、过远、背光或被遮挡时也可能漏检;本小节只观察检测框如何出现、消失,以及参数如何影响误检和漏检,不做身份识别。

Web 页面中的 Faces 可以理解为人脸检测入口:上位机读取摄像头画面后,在图像中标出可能的人脸区域。本节 Notebook 示例用于拆开观察检测框、检测数量、连续确认和失败场景。

  1. 确认 CAMERA_SOURCE 可用。 继续使用前面单帧读取测试得到的摄像头编号。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. 让人脸位于摄像头画面中间。 尽量正对摄像头,保持光照均匀,背景不要出现明显人脸海报或屏幕画面。

  3. Cell 1:导入库、加载模型并设置检测参数。

    import cv2
    import time
    import matplotlib.pyplot as plt
    from pathlib import Path
    from IPython.display import clear_output

    MODEL_PATH = Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml"

    RUN_SECONDS = 10
    FRAME_INTERVAL = 0.08

    SCALE_FACTOR = 1.1
    MIN_NEIGHBORS = 7
    MIN_SIZE = (60, 60)

    CENTER_CROP_RATIO = 0.85
    REQUIRE_STABLE_FRAMES = 3

    FACE_SNAPSHOT = Path("opencv_face_detected.jpg")

    if not MODEL_PATH.exists():
    raise FileNotFoundError(f"未找到人脸检测模型:{MODEL_PATH}")

    face_cascade = cv2.CascadeClassifier(str(MODEL_PATH))
    if face_cascade.empty():
    raise RuntimeError(f"无法加载人脸检测模型:{MODEL_PATH}")

    print("模型路径:", MODEL_PATH)
    print("检测参数:", SCALE_FACTOR, MIN_NEIGHBORS, MIN_SIZE)

    运行成功后会打印模型路径和检测参数;如果没有报错,说明 Haar Cascade 模型已加载。MIN_NEIGHBORS 越大,误检通常越少,但也可能漏检;MIN_SIZE 用于忽略太小的疑似人脸区域;REQUIRE_STABLE_FRAMES 用于避免单帧误判。

  4. Cell 2:有限时连续检测并保存截图。 复用前面单帧读取测试确认的 CAMERA_SOURCE,在限定时间内刷新画面。

    if "CAMERA_SOURCE" not in globals():
    if "camera_source" in globals() and camera_source is not None:
    CAMERA_SOURCE = camera_source
    else:
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    stable_count = 0
    saved = False
    last_face_count = 0
    last_status = "未检测到稳定人脸区域"

    try:
    start_time = time.time()

    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()

    if not ok:
    raise RuntimeError("无法读取摄像头画面")

    preview = frame.copy()
    height, width = frame.shape[:2]

    crop_w = int(width * CENTER_CROP_RATIO)
    crop_h = int(height * CENTER_CROP_RATIO)
    x0 = (width - crop_w) // 2
    y0 = (height - crop_h) // 2
    roi = frame[y0:y0 + crop_h, x0:x0 + crop_w]

    gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
    gray = cv2.equalizeHist(gray)

    faces = face_cascade.detectMultiScale(
    gray,
    scaleFactor=SCALE_FACTOR,
    minNeighbors=MIN_NEIGHBORS,
    minSize=MIN_SIZE,
    )

    filtered_faces = []

    for (x, y, w, h) in faces:
    ratio = w / float(h)

    if ratio < 0.65 or ratio > 1.45:
    continue

    fx = x + x0
    fy = y + y0
    filtered_faces.append((fx, fy, w, h))

    if filtered_faces:
    stable_count += 1
    else:
    stable_count = 0

    for (x, y, w, h) in filtered_faces:
    cv2.rectangle(preview, (x, y), (x + w, y + h), (0, 255, 0), 2)

    cv2.rectangle(preview, (x0, y0), (x0 + crop_w, y0 + crop_h), (255, 200, 0), 1)

    if stable_count >= REQUIRE_STABLE_FRAMES:
    status = "检测到稳定人脸区域"

    if not saved:
    cv2.imwrite(str(FACE_SNAPSHOT), preview)
    saved = True
    elif filtered_faces:
    status = "疑似人脸区域,继续观察"
    else:
    status = "未检测到人脸区域"

    last_face_count = len(filtered_faces)
    last_status = status

    clear_output(wait=True)
    print("运行时间:", round(time.time() - start_time, 1), "/", RUN_SECONDS, "秒")
    print("当前状态:", status)
    print("当前检测框数量:", len(filtered_faces))
    print("连续确认帧数:", stable_count, "/", REQUIRE_STABLE_FRAMES)
    print("截图保存:", str(FACE_SNAPSHOT) if saved else "尚未保存")

    plt.figure(figsize=(8, 5))
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")
    plt.show()

    time.sleep(FRAME_INTERVAL)

    except KeyboardInterrupt:
    print("已手动停止。")

    finally:
    cap.release()
    print("摄像头已释放。")

    运行时请让人脸尽量位于画面中间,正对摄像头,并保持光照均匀。绿色框表示 OpenCV 检测到的疑似人脸区域,浅色框表示本示例主要观察的中心区域。

    如果出现误检,可以尝试增大 MIN_NEIGHBORSMIN_SIZE;如果经常漏检,可以略微减小 MIN_NEIGHBORS,并让人脸离摄像头更近、背景更简单。

  5. Cell 3:查看保存的人脸检测截图。 只查看保存结果,不重新检测,不重新打开摄像头。

    if FACE_SNAPSHOT.exists():
    image = cv2.imread(str(FACE_SNAPSHOT))
    image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

    plt.figure(figsize=(8, 5))
    plt.title(str(FACE_SNAPSHOT))
    plt.imshow(image_rgb)
    plt.axis("off")
    plt.show()

    print("已找到截图:", FACE_SNAPSHOT)
    else:
    print("没有找到截图:", FACE_SNAPSHOT)
    print("可以重新运行检测 Cell,并让人脸靠近画面中心。")

    如果没有生成截图,说明连续帧确认条件没有满足。可以调整拍摄位置、光照或参数后重新运行检测 Cell。

    备注

    OpenCV Haar Cascade 适合快速体验传统检测方法,但它对环境比较敏感。后面的 MediaPipe Face Detection 示例会使用 MediaPipe 的人脸检测能力,更适合观察人脸区域和部分关键点信息。两者都属于人脸检测,不用于身份识别。

开发者补充:为什么要用多线程处理视觉画面

单线程程序会按顺序读取摄像头、处理图像、显示结果。任意一步耗时过长,画面刷新和状态输出都会卡顿。多线程视觉处理把画面读取和图像处理拆开,减少互相等待。它不是一种视觉识别功能,而是性能优化方法;普通使用可以跳过。

下面的示例只对比处理帧数,不联动底盘。

  1. 确认 CAMERA_SOURCE 可用。 继续使用前面单帧读取测试得到的摄像头编号。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. Cell 1:导入库、队列和帧数参数。

    import cv2
    import queue
    import threading

    CAMERA_CANDIDATES = [-1, 0]
    MAX_FRAMES = 60
    FRAME_QUEUE = queue.Queue(maxsize=5)
  3. Cell 2:检测可用摄像头。

    cap = None
    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    ok, _ = test_cap.read()
    if ok:
    cap = test_cap
    print("CAMERA_SOURCE =", source)
    break
    test_cap.release()

    if cap is None:
    raise RuntimeError("无法读取摄像头画面")
  4. Cell 3:定义读取线程。

    def reader():
    try:
    for _ in range(MAX_FRAMES):
    ok, frame = cap.read()
    if not ok:
    break
    if not FRAME_QUEUE.full():
    FRAME_QUEUE.put(frame)
    finally:
    FRAME_QUEUE.put(None)
  5. Cell 4:启动线程并在主线程中处理队列数据。

    processed = 0
    thread = threading.Thread(target=reader)
    thread.start()

    try:
    while True:
    frame = FRAME_QUEUE.get()
    if frame is None:
    break
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    processed += 1
    if processed % 10 == 0:
    print("已处理帧数:", processed, "灰度均值:", round(float(gray.mean()), 2))
    finally:
    thread.join()
    cap.release()

    print("处理完成,总帧数:", processed)
  6. 运行代码。 观察读取线程和处理线程输出。

  7. 代码只读取有限帧数并自动退出。

  8. 本节代码只统计帧数。 不控制云台或底盘。

  9. 测试结束后确认 Notebook 返回可输入状态。

  10. 如果单元格长时间运行。 点击工具栏停止按钮,或选择 Kernel > Interrupt Kernel

结果

成功时,Notebook 应按固定间隔输出已处理帧数和灰度均值,最后输出总帧数。若帧数很少或读取失败,先检查摄像头是否被 Web 服务占用、相机编号是否正确、系统负载是否过高。该示例只用于理解画面读取和处理分离的思路,不要求修改主程序。

视觉处理中的事件、线程、画面读取、识别处理和结果输出逻辑位于 cv_ctrl.py。线程结构和状态变量按机器人本机文件使用;不建议直接在主程序中修改线程逻辑。

MediaPipe 功能

MediaPipe 功能用于人脸、手部和人体姿态等关键点或区域检测。它与 OpenCV 摄像头读取、图像显示可以配合使用,但 MediaPipe 不是 OpenCV 内置功能。

本节只说明 Web 端和 Notebook 中常见的 MediaPipe 检测入口。涉及底盘、云台或灯光联动时,应先观察检测结果,再进入 dry-run 或低速短时测试。

MediaPipe 人脸检测

前面的 Haar Cascade 示例用于体验传统 OpenCV 人脸区域检测。本节使用 MediaPipe Face Detection 观察人脸检测框和部分关键点信息;两者都不是身份识别,也不用于权限认证。

提示

MediaPipe 启动时可能打印运行日志或警告。只要 Notebook 能继续执行并显示图像,通常可以继续下一步;如果出现 ModuleNotFoundError、摄像头无法读取、没有图像输出或 Cell 中断,再按错误信息排查。

Web 页面中的 MP FACE 对应 MediaPipe 人脸检测。本节只观察检测框和关键点信息,不保存人脸图像,也不用于身份识别。

  1. Cell 1:导入库并初始化 MediaPipe Face Detection。

    import cv2
    import matplotlib.pyplot as plt
    import mediapipe as mp

    CAMERA_CANDIDATES = [-1, 0]
    MIN_DETECTION_CONFIDENCE = 0.5

    mp_face_detection = mp.solutions.face_detection
    mp_drawing = mp.solutions.drawing_utils

    face_detection = mp_face_detection.FaceDetection(
    model_selection=0,
    min_detection_confidence=MIN_DETECTION_CONFIDENCE,
    )
    print("MediaPipe Face Detection 已初始化")

    运行成功后会输出初始化提示;如果提示找不到 mediapipe,先检查当前 Python 环境是否已安装 MediaPipe。出现启动日志时,先确认后续图像是否还能正常显示。

  2. Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    ok, test_frame = test_cap.read()
    if ok:
    cap = test_cap
    frame = test_frame
    print("CAMERA_SOURCE =", source)
    break
    test_cap.release()

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    运行成功后会输出当前可用摄像头编号。如果读取失败,先检查摄像头是否被 Web 主程序或其它 Notebook 占用。

  3. Cell 3:执行 MediaPipe 人脸检测并绘制结果。

    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = face_detection.process(frame_rgb)
    preview = frame.copy()

    if not results.detections:
    print("未检测到人脸")
    else:
    print("检测到人脸数量:", len(results.detections))
    for detection in results.detections:
    mp_drawing.draw_detection(preview, detection)

    检测到人脸时会输出数量,并在 preview 中绘制检测框和关键点;未检测到时会输出 未检测到人脸

  4. Cell 4:显示结果并释放资源。

    try:
    plt.imshow(cv2.cvtColor(preview, cv2.COLOR_BGR2RGB))
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
    face_detection.close()

    运行成功后会显示一张检测结果图;检测到人脸时,画面中会显示 MediaPipe 绘制的检测框和关键点。显示完成后会释放摄像头和 MediaPipe 检测对象。

MediaPipe 手势识别

MediaPipe 手势识别用于检测手部关键点,并基于关键点状态判断手势。Web 页面中的 HAND GS 是手部 / 手势检测入口,可用于灯光控制、拍照触发或云台联动;HAND GS + UNLOCK 不等于底盘手势控制。

本小节在 Notebook 中观察实时画面、手部关键点、检测状态和稳定手势,再输出动作建议和 dry-run 结果。Notebook 中的底盘动作是教程扩展示例,真实动作默认关闭,只有在底盘架空并确认串口后才可低速、短时执行。

  1. Cell 1:实时预览手部关键点。 确认摄像头和 MediaPipe 环境可用;背景尽量简单,画面内只保留正在测试的一只手。该 Cell 会显示实时画面、绘制手部关键点和连线,用于确认关键点是否落在正确的手上;不判断具体手势,也不控制底盘。

    import time
    import cv2
    from IPython.display import display, clear_output
    from PIL import Image
    import mediapipe as mp

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    RUN_SECONDS = 10
    DISPLAY_WIDTH = 640

    mp_hands = mp.solutions.hands
    mp_drawing = mp.solutions.drawing_utils

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请检查 CAMERA_SOURCE 或摄像头是否被占用。")

    start_time = time.time()

    try:
    with mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=1,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
    ) as hands:
    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()

    if not ok:
    print("无法读取摄像头画面。")
    break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    result = hands.process(rgb)

    hand_detected = result.multi_hand_landmarks is not None

    if hand_detected:
    hand_landmarks = result.multi_hand_landmarks[0]
    mp_drawing.draw_landmarks(
    frame,
    hand_landmarks,
    mp_hands.HAND_CONNECTIONS,
    )
    line1 = "Hand: True"
    else:
    line1 = "Hand: False"
    cv2.putText(
    frame,
    "No hand detected",
    (20, 80),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.8,
    (0, 255, 0),
    2,
    )

    cv2.putText(
    frame,
    line1,
    (20, 40),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.8,
    (0, 255, 0),
    2,
    )

    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    image = Image.fromarray(frame_rgb)

    if image.width > DISPLAY_WIDTH:
    scale = DISPLAY_WIDTH / image.width
    image = image.resize((DISPLAY_WIDTH, int(image.height * scale)))

    clear_output(wait=True)
    display(image)

    time.sleep(0.05)

    finally:
    cap.release()
    print("预览结束。")

    如果画面中能看到手部关键点,并且关键点落在正确的手上,再继续做具体手势判断。若关键点画在背景、脸部或错误位置,请先调整距离、光照和背景。

  2. Cell 2:准备单手势调试函数。 该 Cell 只定义手势判断函数和预览函数,不打开摄像头,不控制底盘。后面 4 个 Cell 会分别检测动作映射中用到的 4 个手部动作。

    import time
    import cv2
    from IPython.display import display, clear_output
    from PIL import Image
    import mediapipe as mp

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    RUN_SECONDS = 10
    DISPLAY_WIDTH = 640
    REQUIRED_STABLE_FRAMES = 5

    mp_hands = mp.solutions.hands
    mp_drawing = mp.solutions.drawing_utils

    def infer_gesture_from_landmarks(hand_landmarks):
    """
    示例规则:根据手部关键点粗略判断手势。
    如果只返回 hand_detected,说明还没有完成具体手势分类。
    """
    points = hand_landmarks.landmark

    wrist = points[0]
    thumb_tip = points[4]
    thumb_ip = points[3]
    index_tip = points[8]
    index_pip = points[6]
    middle_tip = points[12]
    middle_pip = points[10]
    ring_tip = points[16]
    ring_pip = points[14]
    pinky_tip = points[20]
    pinky_pip = points[18]

    fingers_up = [
    index_tip.y < index_pip.y,
    middle_tip.y < middle_pip.y,
    ring_tip.y < ring_pip.y,
    pinky_tip.y < pinky_pip.y,
    ]
    extended_count = sum(fingers_up)

    thumb_up = thumb_tip.y < thumb_ip.y
    other_fingers_folded = all([
    index_tip.y > index_pip.y,
    middle_tip.y > middle_pip.y,
    ring_tip.y > ring_pip.y,
    pinky_tip.y > pinky_pip.y,
    ])

    index_dx = index_tip.x - wrist.x
    index_dy = index_tip.y - wrist.y
    index_horizontal = abs(index_dx) > abs(index_dy)
    index_only = fingers_up[0] and not any(fingers_up[1:])

    if extended_count >= 4:
    return "open_palm"
    if thumb_up and other_fingers_folded:
    return "thumb_up"
    if index_only and index_horizontal and index_dx < -0.15:
    return "left"
    if index_only and index_horizontal and index_dx > 0.15:
    return "right"
    return "hand_detected"

    confirmed_gesture = None

    def debug_target_gesture(target_gesture, hand_action, run_seconds=RUN_SECONDS):
    global confirmed_gesture

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请检查 CAMERA_SOURCE 或摄像头是否被占用。")

    last_gesture = None
    stable_frames = 0
    confirmed_gesture = None
    start_time = time.time()

    try:
    with mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=1,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
    ) as hands:
    while time.time() - start_time < run_seconds:
    ok, frame = cap.read()

    if not ok:
    print("无法读取摄像头画面。")
    break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    result = hands.process(rgb)

    detected_gesture = "no_hand"

    if result.multi_hand_landmarks:
    hand_landmarks = result.multi_hand_landmarks[0]
    mp_drawing.draw_landmarks(
    frame,
    hand_landmarks,
    mp_hands.HAND_CONNECTIONS,
    )
    detected_gesture = infer_gesture_from_landmarks(hand_landmarks)

    if detected_gesture == last_gesture:
    stable_frames += 1
    else:
    stable_frames = 1
    last_gesture = detected_gesture

    if detected_gesture == target_gesture and stable_frames >= REQUIRED_STABLE_FRAMES:
    confirmed_gesture = detected_gesture
    elif detected_gesture != target_gesture:
    confirmed_gesture = None

    match = confirmed_gesture == target_gesture
    lines = [
    f"Target: {target_gesture}",
    f"Gesture: {detected_gesture}",
    f"Stable: {stable_frames}/{REQUIRED_STABLE_FRAMES}",
    f"Confirmed: {confirmed_gesture}",
    f"Match: {match}",
    ]

    y = 35
    for line in lines:
    cv2.putText(
    frame,
    line,
    (20, y),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.7,
    (0, 255, 0),
    2,
    )
    y += 30

    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    image = Image.fromarray(frame_rgb)

    if image.width > DISPLAY_WIDTH:
    scale = DISPLAY_WIDTH / image.width
    image = image.resize((DISPLAY_WIDTH, int(image.height * scale)))

    clear_output(wait=True)
    print("当前手部动作:", hand_action)
    display(image)

    time.sleep(0.05)

    finally:
    cap.release()
    print("调试结束。")
    print("最后确认手势:", confirmed_gesture)

    运行后不会立即打开摄像头,只会准备 debug_target_gesture()。如果后续检测时 Gesture 一直显示 hand_detected,说明当前代码只完成了“检测到手”,还没有完成具体手势分类。此时不要进入真实动作步骤,需要先补充或修正 infer_gesture_from_landmarks()

  3. Cell 3:检测 open_palm。 张开手掌,手心朝向摄像头,五指尽量伸直并分开,指尖大致朝上;让整只手位于画面中部,不要贴近镜头。该手势用于映射“停止”。

    debug_target_gesture(
    target_gesture="open_palm",
    hand_action="张开手掌,手心朝向摄像头,五指伸直并分开",
    )
  4. Cell 4:检测 thumb_up。 握拳后竖起拇指,拇指指尖朝上,其余四指收拢;让拇指和手腕都进入画面,避免其它手指明显伸出。该手势用于映射“前进”。

    debug_target_gesture(
    target_gesture="thumb_up",
    hand_action="握拳并竖起拇指,拇指指尖朝上,其余手指收拢",
    )
  5. Cell 5:检测 left。 只伸出食指,其余手指收拢;让食指接近水平,指尖朝 Notebook 预览画面左侧,手腕保持在画面中部。该手势用于映射“左转”。不要只把整只手移动到画面左边。

    debug_target_gesture(
    target_gesture="left",
    hand_action="只伸出食指,食指接近水平,指尖朝 Notebook 预览画面左侧",
    )
  6. Cell 6:检测 right。 只伸出食指,其余手指收拢;让食指接近水平,指尖朝 Notebook 预览画面右侧,手腕保持在画面中部。该手势用于映射“右转”。不要只把整只手移动到画面右边。

    debug_target_gesture(
    target_gesture="right",
    hand_action="只伸出食指,食指接近水平,指尖朝 Notebook 预览画面右侧",
    )
  7. Cell 7:实时识别当前手势并运行 dry-run。 该 Cell 会重新打开摄像头,实时识别当前画面中的手势,显示当前手势、稳定帧数、确认手势和动作建议;得到新的 confirmed_gesture 后,再输出左右轮速度。默认不发送底盘运动指令。下表为本任务使用的手势与动作建议映射。

    open_palm:手心朝向摄像头,五指伸直并分开,停止
    thumb_up:握拳并竖起拇指,前进
    left:只伸出食指,指尖朝 Notebook 预览画面左侧,左转
    right:只伸出食指,指尖朝 Notebook 预览画面右侧,右转
    hand_detected:仅检测到手,保持停止
    no_hand:未检测到手,保持停止
    import time
    import cv2
    from IPython.display import display, clear_output
    from PIL import Image
    import mediapipe as mp

    ENABLE_MOTION = False # 默认只做 dry-run,不发送真实底盘运动指令
    SERIAL_PORT = "/dev/ttyTHS1" # 改成已通过底盘串口确认与低速动作验证的串口
    BAUDRATE = 115200

    DETECT_SECONDS = 5
    DISPLAY_WIDTH = 640
    REQUIRED_STABLE_FRAMES = 5

    ACTION_DURATION = 0.8
    FORWARD_SPEED = 0.04
    TURN_SPEED = 0.04

    mp_hands = mp.solutions.hands
    mp_drawing = mp.solutions.drawing_utils

    GESTURE_ACTIONS = {
    "open_palm": ("停止", (0.0, 0.0)),
    "thumb_up": ("前进", (FORWARD_SPEED, FORWARD_SPEED)),
    "left": ("左转", (-TURN_SPEED, TURN_SPEED)),
    "right": ("右转", (TURN_SPEED, -TURN_SPEED)),
    "hand_detected": ("停止", (0.0, 0.0)),
    "no_hand": ("停止", (0.0, 0.0)),
    None: ("停止", (0.0, 0.0)),
    }

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    if "infer_gesture_from_landmarks" not in globals():
    raise RuntimeError("请先运行 Cell 2,准备 infer_gesture_from_landmarks()。")

    confirmed_gesture = None
    last_gesture = None
    stable_frames = 0

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请检查 CAMERA_SOURCE 或摄像头是否被占用。")

    start_time = time.time()

    try:
    with mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=1,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
    ) as hands:
    while time.time() - start_time < DETECT_SECONDS:
    ok, frame = cap.read()

    if not ok:
    print("无法读取摄像头画面。")
    break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    result = hands.process(rgb)

    detected_gesture = "no_hand"

    if result.multi_hand_landmarks:
    hand_landmarks = result.multi_hand_landmarks[0]
    mp_drawing.draw_landmarks(
    frame,
    hand_landmarks,
    mp_hands.HAND_CONNECTIONS,
    )
    detected_gesture = infer_gesture_from_landmarks(hand_landmarks)

    if detected_gesture == last_gesture:
    stable_frames += 1
    else:
    stable_frames = 1
    last_gesture = detected_gesture

    if (
    detected_gesture not in ("hand_detected", "no_hand")
    and stable_frames >= REQUIRED_STABLE_FRAMES
    ):
    confirmed_gesture = detected_gesture
    elif detected_gesture in ("hand_detected", "no_hand"):
    confirmed_gesture = None

    action_label, speed = GESTURE_ACTIONS.get(
    confirmed_gesture,
    ("停止", (0.0, 0.0)),
    )

    lines = [
    f"Gesture: {detected_gesture}",
    f"Stable: {stable_frames}/{REQUIRED_STABLE_FRAMES}",
    f"Confirmed: {confirmed_gesture}",
    f"Action: {action_label}",
    ]

    y = 35
    for line in lines:
    cv2.putText(
    frame,
    line,
    (20, y),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.7,
    (0, 255, 0),
    2,
    )
    y += 30

    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    image = Image.fromarray(frame_rgb)

    if image.width > DISPLAY_WIDTH:
    scale = DISPLAY_WIDTH / image.width
    image = image.resize((DISPLAY_WIDTH, int(image.height * scale)))

    clear_output(wait=True)
    display(image)

    time.sleep(0.05)

    finally:
    cap.release()

    gesture_name = confirmed_gesture
    action_label, speed = GESTURE_ACTIONS.get(gesture_name, ("停止", (0.0, 0.0)))

    print("确认手势:", gesture_name)
    print("动作建议:", action_label)
    print("左右轮速度:", speed)
    print("真实动作:", "开启" if ENABLE_MOTION else "关闭")

    base = None

    try:
    if ENABLE_MOTION:
    from base_ctrl import BaseController

    print("真实动作模式:开启")
    print("请确认底盘已架空,串口已验证,周围无人员、线缆和障碍物。")

    base = BaseController(SERIAL_PORT, BAUDRATE)
    base.base_speed_ctrl(*speed)
    time.sleep(ACTION_DURATION)
    else:
    print("DRY-RUN:未发送底盘运动指令。")

    finally:
    if base is not None:
    base.base_speed_ctrl(0, 0)
    time.sleep(0.1)
    print("已发送停止指令。")

    运行后应先看到实时画面、当前手势、稳定帧数、确认手势和动作建议,再看到左右轮速度和真实动作开关状态。未定义手势、hand_detectedno_handNone 都会映射为停止。

  8. 可选执行短时动作。 先确认 Cell 7 中能看到实时画面、手部关键点和稳定的 confirmed_gesture,再查看速度建议;确认底盘已架空,并完成 底盘串口确认与占用排查 后,再手动设置 ENABLE_MOTION = True。真实动作只基于当次稳定确认的 confirmed_gesture,未定义手势、hand_detectedno_handNone 一律按停止处理。测试结束后,将 ENABLE_MOTION 改回 False

    提示
    • 如果没有实时画面或关键点,请检查 CAMERA_SOURCE、摄像头占用、光照、背景和手部距离,不要使用 cv2.imshow()
    • 如果关键点落在错误目标上,只保留正在测试的一只手。
    • 若手势一直是 hand_detected 或跳动明显,先修正 infer_gesture_from_landmarks() 并增加稳定帧确认,不要开启真实动作。串口打不开或底盘方向不符合预期时,保持 ENABLE_MOTION = False,回到底盘串口和低速动作验证步骤。

MediaPipe 姿态检测

MediaPipe 姿态检测用于检测人体姿态关键点,并在画面中绘制人体骨架连接关系。该功能用于观察人体是否完整进入画面、关键点是否稳定,以及调试姿态关键点显示效果;它本身不判断具体动作,也不会直接控制底盘、云台或灯光。

Web 端对应功能

Web 页面中的 MP POSE 可理解为 MediaPipe 姿态关键点检测入口。开启后,画面会显示人体姿态关键点和骨架连线,用于观察姿态检测效果。

Notebook 示例用于拆解 MP POSE 的核心流程:读取摄像头画面、调用 MediaPipe Pose、绘制姿态关键点和骨架连线,并显示检测状态。它不复现 Web 主程序的完整视频流处理流程。

在 Notebook 中拆解姿态检测
  1. 确认 CAMERA_SOURCE 可用。 本小节直接复用前面单帧读取测试得到的摄像头编号,不重新扫描摄像头。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. Cell:实时预览姿态关键点。 运行固定时间,显示摄像头画面、姿态关键点、骨架连线、Pose 检测状态和可见关键点数量,并保存一张检测结果截图。该 Cell 不控制底盘、云台或灯光。

    import time
    import cv2
    from IPython.display import display, clear_output
    from PIL import Image
    import mediapipe as mp

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    RUN_SECONDS = 10
    DISPLAY_WIDTH = 640
    OUTPUT_IMAGE = "mediapipe_pose_preview.jpg"

    mp_pose = mp.solutions.pose
    mp_drawing = mp.solutions.drawing_utils

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请检查 CAMERA_SOURCE 或摄像头是否被占用。")

    start_time = time.time()
    saved_image = False

    try:
    with mp_pose.Pose(
    static_image_mode=False,
    model_complexity=1,
    smooth_landmarks=True,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
    ) as pose:
    while time.time() - start_time < RUN_SECONDS:
    ok, frame = cap.read()

    if not ok:
    print("无法读取摄像头画面。")
    break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    result = pose.process(rgb)

    pose_detected = result.pose_landmarks is not None
    visible_count = 0
    total_count = 33

    if pose_detected:
    mp_drawing.draw_landmarks(
    frame,
    result.pose_landmarks,
    mp_pose.POSE_CONNECTIONS,
    )

    visible_count = sum(
    1 for lm in result.pose_landmarks.landmark
    if lm.visibility >= 0.5
    )

    status_lines = [
    "Pose: True",
    f"Visible landmarks: {visible_count} / {total_count}",
    ]

    if not saved_image:
    cv2.imwrite(OUTPUT_IMAGE, frame)
    saved_image = True
    else:
    status_lines = [
    "Pose: False",
    "Visible landmarks: 0 / 33",
    ]
    cv2.putText(
    frame,
    "No pose detected",
    (20, 110),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.8,
    (0, 255, 0),
    2,
    )

    y = 40
    for line in status_lines:
    cv2.putText(
    frame,
    line,
    (20, y),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.8,
    (0, 255, 0),
    2,
    )
    y += 32

    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    image = Image.fromarray(frame_rgb)

    if image.width > DISPLAY_WIDTH:
    scale = DISPLAY_WIDTH / image.width
    image = image.resize((DISPLAY_WIDTH, int(image.height * scale)))

    clear_output(wait=True)
    display(image)

    time.sleep(0.05)

    finally:
    cap.release()
    print("姿态检测预览结束。")
    if saved_image:
    print("已保存结果截图:", OUTPUT_IMAGE)
    else:
    print("未保存截图:运行期间没有检测到人体姿态。")

    运行成功后,Notebook 会显示摄像头画面。检测到人体时,画面中会出现姿态关键点和骨架连线,并显示 Pose: TrueVisible landmarks: x / 33;未检测到人体时,会显示 No pose detectedPose: False

    如果生成了 mediapipe_pose_preview.jpg,说明程序已保存一张检测结果图。该截图只用于确认关键点显示效果,不代表已经完成动作识别。

    提示

    没有实时画面时,检查 CAMERA_SOURCE 和摄像头占用。画面有但没有姿态关键点时,让人体完整进入画面,并调整距离、光照和背景。关键点较少或跳动明显时,先只观察检测结果,不要直接用于动作联动。

:::

数据记录与可视化调试

读取项目文件

只做查看,不直接修改。

开发者补充:常见项目文件
文件 / 目录作用
app.pyWeb 主程序和页面接口。
base_ctrl.py下位机通信和底盘、云台、灯光封装。
cv_ctrl.py相机取流和视觉处理。
config.yaml产品型号、模块和运行参数。
sounds/音频文件目录。
  1. 在 JupyterLab 文件浏览器中进入上位机项目目录。
  2. 打开需要查看的文件或目录。
  3. 只读取内容。 不直接修改控制脚本或配置文件。
  4. 查看结束后关闭文件标签页。 JupyterLab 中应能打开对应文件或目录。若文件不存在,应先确认项目路径和当前镜像版本。
开发者补充:查看 config.yaml 配置

config.yaml 用于保存命令编号、视频分辨率、颜色阈值、速度参数和产品运行参数。排查配置是否存在或字段是否变化时,可以只读查看,不要求在基础流程中执行。

  1. Cell 1:读取配置文件。
  2. 粘贴以下代码。 按实机确认 CONFIG_PATH
  3. 运行代码。 查看配置顶层字段。
  4. 查看结束后关闭文件标签页或停止当前单元格。 不要在未备份时写回配置。
from pathlib import Path
import yaml

CONFIG_PATH = Path("~/ugv_jetson/config.yaml").expanduser()

if not CONFIG_PATH.exists():
raise FileNotFoundError(CONFIG_PATH)

with CONFIG_PATH.open("r", encoding="utf-8") as f:
config = yaml.safe_load(f)

print("配置顶层字段:", list(config.keys()))

Notebook 应输出配置文件的顶层字段列表。若提示文件不存在,应先确认项目目录和配置文件路径。

注意

修改 config.yaml 前先复制备份。不了解字段含义时,只查看,不写回。

延时摄影

延时摄影可以拆成四个环节:定时拍照、保存图片序列、合成视频、验证视频。本节只使用固定摄像头完成安全版流程,不控制底盘,不控制灯光,也不做移动拍摄。

Web 端的 timelapse 可以包含底盘移动、停止、补光和拍照等联动。Notebook 本节只拆解数据流程:固定摄像头,定时保存多张图片,再合成 timelapse.mp4

  1. 确认 CAMERA_SOURCE 可用。 继续使用前面单帧读取测试得到的摄像头编号。

    展开查看确认方法

    如果还没有完成单帧读取测试,请运行下面代码,确认能读取并显示一帧画面。确认成功后,继续使用同一个 CAMERA_SOURCE

    Cell 1:导入库、参数配置和中心裁剪函数。

    import cv2
    import matplotlib.pyplot as plt

    CAMERA_CANDIDATES = [-1, 0]
    FRAME_WIDTH = 640
    FRAME_HEIGHT = 480
    WARMUP_FRAMES = 20
    ZOOM = 1.5

    def center_crop_zoom(frame, zoom=1.0):
    if zoom <= 1.0:
    return frame

    height, width = frame.shape[:2]
    crop_width = int(width / zoom)
    crop_height = int(height / zoom)
    start_x = (width - crop_width) // 2
    start_y = (height - crop_height) // 2

    cropped = frame[start_y:start_y + crop_height, start_x:start_x + crop_width]
    return cv2.resize(cropped, (width, height), interpolation=cv2.INTER_LINEAR)

    Cell 2:检测可用摄像头并读取一帧画面。

    cap = None
    frame = None
    camera_source = None

    for source in CAMERA_CANDIDATES:
    test_cap = cv2.VideoCapture(source)
    test_cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    test_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

    ok, test_frame = test_cap.read()
    if not ok:
    test_cap.release()
    continue

    cap = test_cap
    frame = test_frame
    camera_source = source
    break

    if frame is None:
    raise RuntimeError("无法读取摄像头画面")

    for _ in range(WARMUP_FRAMES):
    ok, warmup_frame = cap.read()
    if ok:
    frame = warmup_frame

    CAMERA_SOURCE = camera_source
    print("CAMERA_SOURCE =", CAMERA_SOURCE)

    Cell 3:执行中心裁剪、显示图像,并释放摄像头资源。

    try:
    display_frame = center_crop_zoom(frame, ZOOM)
    frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
    plt.imshow(frame_rgb)
    plt.axis("off")
    plt.show()
    finally:
    if cap is not None:
    cap.release()
  2. Cell 1:导入库和参数。

    import cv2
    import time
    from pathlib import Path

    from IPython.display import Video, display

    FRAME_DIR = Path("timelapse_frames")
    VIDEO_PATH = Path("timelapse.mp4")

    FRAME_COUNT = 5
    CAPTURE_INTERVAL = 1.0
    FPS = 2

    FRAME_DIR.mkdir(exist_ok=True)

    print("图片保存目录:", FRAME_DIR.resolve())
    print("视频输出文件:", VIDEO_PATH.resolve())
    print("拍摄张数:", FRAME_COUNT)
    print("拍摄间隔:", CAPTURE_INTERVAL, "秒")
    print("视频 FPS:", FPS)

    运行成功后,Notebook 会输出图片保存目录、视频文件路径、拍摄张数和视频帧率。此时不会打开摄像头。

  3. Cell 2:使用已确认的 CAMERA_SOURCE 定时拍摄图片。

    if "CAMERA_SOURCE" not in globals():
    raise RuntimeError("请先完成前面的单帧读取测试,确认 CAMERA_SOURCE 可用。")

    cap = cv2.VideoCapture(CAMERA_SOURCE)

    if not cap.isOpened():
    raise RuntimeError("无法打开摄像头,请先检查摄像头资源释放与恢复。")

    saved_files = []

    try:
    for index in range(FRAME_COUNT):
    ok, frame = cap.read()

    if not ok:
    print("第", index, "张读取失败,跳过。")
    time.sleep(CAPTURE_INTERVAL)
    continue

    image_path = FRAME_DIR / f"frame_{index:03d}.jpg"
    cv2.imwrite(str(image_path), frame)
    saved_files.append(image_path)

    print("保存:", image_path)
    time.sleep(CAPTURE_INTERVAL)

    finally:
    cap.release()
    print("摄像头已释放。")
    print("保存数量:", len(saved_files))

    运行成功后,timelapse_frames 目录中应生成多张 frame_000.jpgframe_001.jpg 这样的图片。拍摄过程中可以缓慢移动画面中的物体,或让场景自然变化。

  4. Cell 3:查看图片序列。

    image_files = sorted(FRAME_DIR.glob("frame_*.jpg"))

    print("图片数量:", len(image_files))

    if not image_files:
    raise RuntimeError("没有找到延时摄影图片,请先运行拍摄 Cell。")

    preview_count = min(5, len(image_files))

    for image_path in image_files[:preview_count]:
    print(image_path)

    运行成功后,Notebook 会列出已经保存的图片文件。如果图片数量为 0,说明前面的拍摄步骤没有成功。

  5. Cell 4:合成视频并验证 VideoWriter。

    image_files = sorted(FRAME_DIR.glob("frame_*.jpg"))

    if not image_files:
    raise RuntimeError("没有可用于合成视频的图片。")

    first_frame = cv2.imread(str(image_files[0]))

    if first_frame is None:
    raise RuntimeError("无法读取第一张图片。")

    height, width = first_frame.shape[:2]

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    writer = cv2.VideoWriter(str(VIDEO_PATH), fourcc, FPS, (width, height))

    if not writer.isOpened():
    raise RuntimeError("无法创建视频文件,请检查 OpenCV 视频编码支持。")

    written_count = 0

    try:
    for image_path in image_files:
    frame = cv2.imread(str(image_path))

    if frame is None:
    print("跳过无法读取的图片:", image_path)
    continue

    if frame.shape[:2] != (height, width):
    frame = cv2.resize(frame, (width, height))

    writer.write(frame)
    written_count += 1

    finally:
    writer.release()

    print("写入视频帧数:", written_count)
    print("视频文件:", VIDEO_PATH.resolve())
    print("视频是否存在:", VIDEO_PATH.exists())
    print("视频大小:", VIDEO_PATH.stat().st_size if VIDEO_PATH.exists() else 0, "bytes")

    运行成功后,应生成 timelapse.mp4。如果视频大小为 0,或 writer.isOpened() 报错,说明当前环境的视频编码支持需要检查。

  6. Cell 5:验证并预览视频。

    if not VIDEO_PATH.exists():
    raise RuntimeError("视频文件不存在,请先运行视频合成 Cell。")

    print("视频文件:", VIDEO_PATH.resolve())
    print("视频大小:", VIDEO_PATH.stat().st_size, "bytes")

    video_cap = cv2.VideoCapture(str(VIDEO_PATH))

    print("视频是否可打开:", video_cap.isOpened())
    print("视频帧数:", int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT)))
    print("视频 FPS:", video_cap.get(cv2.CAP_PROP_FPS))

    ok, frame = video_cap.read()
    print("能否读取第一帧:", ok)
    print("第一帧尺寸:", None if frame is None else frame.shape)

    video_cap.release()

    display(Video(str(VIDEO_PATH), embed=True))

    如果 视频是否可打开True视频帧数 大于 0,并且能读取第一帧,说明视频文件基本正常。

    如果 JupyterLab 文件浏览器直接打开 timelapse.mp4 时提示 not UTF-8 encoded,不要按文本方式打开它。请使用本 Cell 预览,或将视频下载到电脑后用视频播放器打开。

音频与启动自动化

音频功能

audio_ctrl.py 负责播放提示音、随机播放目录中的音频、设置音量、TTS 语音播报和停止播放。

常见检查点:

  • 播放一段短提示音;
  • 播放目录中的随机音频;
  • 调整音量后再测试;
  • 停止正在播放的音频。

音频测试不应触发底盘、云台或灯光动作。

启动自动化

启动自动化主要包括 Web 主程序开机启动和 JupyterLab 服务管理。优先确认服务是否已启动,再判断是否需要恢复默认状态。

开发者补充:查看启动状态
  1. 查看 Web 主程序状态。
  2. 查看 JupyterLab 服务状态。
  3. 确认是否有重复启动项。
  4. 恢复默认状态时,先保留可登录入口,再停止临时测试脚本。
crontab -l

启动自动化不建议加入底盘前进、转向、巡线或连续动作脚本。需要时只保留 Web、自启动和提示音这类低风险任务。