简介:这份资源是面向高校毕业设计与项目实战学习者的多模态机械臂控制完整源码包,聚焦自然语言处理、计算机视觉与执行机构控制三者的交叉融合。项目要求机械臂既能解析人类语言指令并转化为动作序列,又能借助摄像头等视觉传感器完成图像识别、物体检测与抓取策略选择,最终通过电机、伺服系统等执行机构协调关节完成精确运动规划。压缩包共349个文件、约14.6MB,包含41个C头文件与40个C源文件支撑底层驱动与控制逻辑,19个Python脚本与2个ipynb用于深度学习语义解析和视觉算法验证,另有37个CRF、23张PNG及多份md说明文档,覆盖模型配置、实验记录与工程说明。已有143人学习关注。读者可获得从语言理解、视觉感知到执行控制的完整工程链路,参考其中的目录组织、算法实现与调试思路,快速搭建自己的多模态控制实验环境,适合作为毕业设计选题与综合技能训练的实践案例。

1. 多模态控制机械臂:从一句“把红色方块放到左边”到真实抓取

“自然语言+视觉+执行机构”这套组合,第一次听像三个独立模块硬拼在一起,真做起来才发现它们是一条闭环链路:语言负责给意图,视觉负责给坐标,执行机构负责把坐标变成关节角。毕业设计里选这个题,最大的价值不是炫技,而是它逼你把感知、决策、控制三层都跑通一遍。我见过太多同学卡在“模型能识别、机械臂不动”这一步,问题往往不在算法,而在坐标系没对齐、指令没落到可执行的动作原语上。这篇笔记就按我实际搭过的一套最小系统来讲:用一台桌面级六轴机械臂、一个固定视角的 RGB 相机、一段自然语言指令,走完从文本解析到抓取放置的全过程。适合正在做毕设、想复现多模态控制链路、又不想被仿真环境绕晕的开发者。

2. 先定架构:语言、视觉、执行机构各自该干什么

2.1 三层职责划分与最小闭环

多模态控制机械臂最容易翻车的地方,是三个模块都想要“主导权”。语言模型想直接输出关节角,视觉想直接给抓取位姿,执行机构又等着一个干净的笛卡尔目标点。我的做法是强制分层:语言层只输出结构化动作原语,视觉层只输出物体位姿,执行层只接收目标位姿并做逆运动学求解。

具体来说,语言层拿到“把红色方块放到左边”后,不直接生成坐标,而是解析成 {"action": "pick_and_place", "target": "red_cube", "destination": "left_region"} 。视觉层根据 target 找到红色方块在相机坐标系下的三维位置,再根据 destination 算出放置区域的中心点。执行层拿到两个笛卡尔坐标,先做手眼标定转换到机械臂基坐标系,再调用逆运动学解算关节角,最后走轨迹规划。

这个划分的好处是每一层都能单独测试。语言层可以用纯文本用例验证解析正确率,视觉层可以用静态图片验证检测框和深度估计,执行层可以用示教点位验证运动精度。三层都过了,再串起来联调,问题定位会快很多。

提示:不要一上来就端到端训练一个大模型直接输出关节角。毕设周期短,数据量不够,端到端方案在真实机械臂上几乎不可复现。

2.2 为什么选动作原语而不是直接生成轨迹

直接让语言模型生成轨迹点,听起来很酷,但实际做的时候你会发现两个致命问题。第一,语言模型输出的数值精度不够,它可能给你 [0.1, 0.2, 0.3] 这样的坐标,但机械臂需要的是毫米级精度,而且不同机械臂的基坐标系原点完全不同。第二,轨迹点之间的连续性无法保证,语言模型不知道关节限位、奇异点、碰撞这些约束。

动作原语的好处是把“做什么”和“怎么做”解耦。语言层只负责“做什么”,比如 pick 、 place 、 push 、 home 这几个原语。执行层负责“怎么做”,每个原语对应一段预定义的轨迹模板,比如 pick 就是“移动到物体上方 5cm → 垂直下降到抓取高度 → 闭合夹爪 → 抬升到安全高度”。这样语言层的输出空间被压缩到有限集合,解析准确率会高很多,而且执行层的轨迹是经过验证的,不会出现奇异点。

我一般会定义 6 到 8 个动作原语,覆盖抓取、放置、推、拉、复位这些基本操作。每个原语对应一个 Python 函数,输入是目标位姿和参数,输出是关节角序列。语言层只需要输出原语名称和参数,执行层调用对应函数即可。

2.3 手眼标定:把相机坐标变成机械臂坐标

视觉层输出的坐标是在相机坐标系下的,机械臂需要的是基坐标系下的坐标。这两个坐标系之间的变换矩阵就是手眼标定要解决的问题。常见做法有两种:眼在手外(eye-to-hand)和眼在手上(eye-in-hand)。毕设里我推荐眼在手外,相机固定安装在工作台上方,标定一次就行,不用每次移动都重新算。

标定步骤用 OpenCV 的 calibrateHandEye 函数,需要采集至少 10 组数据,每组包含机械臂末端在基坐标系下的位姿和标定板在相机坐标系下的位姿。采集的时候要让机械臂带着标定板走到不同位置和姿态,覆盖工作空间的大部分区域。

import cv2
import numpy as np

# 采集到的数据:机械臂末端位姿(基坐标系)和标定板位姿(相机坐标系)
# R_gripper2base, t_gripper2base: 机械臂末端旋转矩阵和平移向量
# R_target2cam, t_target2cam: 标定板在相机下的旋转矩阵和平移向量

R_gripper2base = []
t_gripper2base = []
R_target2cam = []
t_target2cam = []

# 假设已经通过示教器读取和相机检测填充了上述列表
# 调用手眼标定函数,返回相机到基坐标系的变换
R_cam2base, t_cam2base = cv2.calibrateHandEye(
    R_gripper2base, t_gripper2base,
    R_target2cam, t_target2cam,
    method=cv2.CALIB_HAND_EYE_TSAI
)

# 构造 4x4 变换矩阵
T_cam2base = np.eye(4)
T_cam2base[:3, :3] = R_cam2base
T_cam2base[:3, 3] = t_cam2base.flatten()

print("相机到基坐标系的变换矩阵:")
print(T_cam2base)

这段代码的关键参数是 method ,OpenCV 提供了 TSAI 、 PARK 、 HORAUD 、 ANDREFF 和 DANIILIDIS 五种方法。我一般先用 TSAI 跑一遍,再用 PARK 验证,如果两个方法算出来的平移向量差异超过 5mm,说明采集的数据有问题,通常是标定板位姿检测不准或者机械臂位姿读取有误。

标定完成后,视觉层输出的相机坐标 P_cam 通过 P_base = T_cam2base @ P_cam 转换到基坐标系。这个变换矩阵只需要算一次,保存成 .npy 文件,后续每次启动系统时加载即可。

3. 语言解析:把自然语言变成结构化动作原语

3.1 用规则模板做第一版解析器

毕设里不建议一上来就微调大模型,先用规则模板把链路跑通。规则模板的核心是“关键词匹配 + 槽位填充”。比如“把红色方块放到左边”这句话,先匹配到“把…放到…”这个模式,然后提取“红色方块”作为目标物体,“左边”作为目标区域。

import re

# 定义动作原语模板
PATTERNS = [
    {
        "regex": r"把(.+?)放到(.+?)(?:里|上|中)?$",
        "action": "pick_and_place",
        "slots": ["target", "destination"]
    },
    {
        "regex": r"抓取(.+?)$",
        "action": "pick",
        "slots": ["target"]
    },
    {
        "regex": r"推到(.+?)$",
        "action": "push",
        "slots": ["target"]
    },
    {
        "regex": r"复位|回家|回到原点",
        "action": "home",
        "slots": []
    }
]

def parse_instruction(text):
    text = text.strip()
    for pattern in PATTERNS:
        match = re.search(pattern["regex"], text)
        if match:
            result = {"action": pattern["action"]}
            for i, slot in enumerate(pattern["slots"]):
                result[slot] = match.group(i + 1).strip()
            return result
    return {"action": "unknown", "raw": text}

# 测试
test_cases = [
    "把红色方块放到左边",
    "抓取蓝色圆柱",
    "推到绿色小球",
    "复位"
]

for case in test_cases:
    print(case, "->", parse_instruction(case))

这段代码的逻辑是先定义正则模板,每个模板包含匹配模式、动作名称和槽位列表。 parse_instruction 函数遍历所有模板,匹配成功后提取槽位值。测试用例覆盖了抓取放置、纯抓取、推动和复位四种情况。

参数说明: regex 里的 (.+?) 是非贪婪匹配,避免把“放到”后面的内容也吞进目标物体里。 (?:里|上|中)? 是非捕获组,匹配可选的方位词但不作为槽位值。实际使用的时候,目标物体和区域的名称需要和视觉层的标签体系对齐,比如视觉层检测到的标签是 red_cube ,那语言层解析出来的 target 也要映射到 red_cube 。

3.2 引入轻量语言模型做语义泛化

规则模板能覆盖 80% 的固定句式,但遇到“请帮我把那个红色的方块挪到左边区域”这种带礼貌用语和指代词的句子,正则就匹配不上了。这时候可以引入一个轻量语言模型做语义解析,比如用 BERT 做意图分类和槽位填充。

具体做法是:先用规则模板生成一批标注数据,格式是 (句子, 意图, 槽位) ,然后用 BERT 做序列标注。意图分类用 [CLS] 位置的输出接一个全连接层,槽位填充用每个 token 的输出接一个分类层,标签体系用 BIO 标注。

import torch
import torch.nn as nn
from transformers import BertModel, BertTokenizer

class IntentSlotModel(nn.Module):
    def __init__(self, num_intents, num_slots, model_name='bert-base-chinese'):
        super().__init__()
        self.bert = BertModel.from_pretrained(model_name)
        hidden_size = self.bert.config.hidden_size
        # 意图分类头
        self.intent_classifier = nn.Linear(hidden_size, num_intents)
        # 槽位填充头
        self.slot_classifier = nn.Linear(hidden_size, num_slots)
        self.dropout = nn.Dropout(0.1)

    def forward(self, input_ids, attention_mask):
        outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
        sequence_output = outputs.last_hidden_state
        pooled_output = outputs.pooler_output

        # 意图分类
        intent_logits = self.intent_classifier(self.dropout(pooled_output))
        # 槽位填充
        slot_logits = self.slot_classifier(self.dropout(sequence_output))

        return intent_logits, slot_logits

# 初始化模型
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = IntentSlotModel(num_intents=4, num_slots=9)  # 4种意图,9种槽位标签(BIO)

# 推理示例
text = "请帮我把那个红色的方块挪到左边区域"
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
with torch.no_grad():
    intent_logits, slot_logits = model(**inputs)
    intent_id = torch.argmax(intent_logits, dim=-1).item()
    slot_ids = torch.argmax(slot_logits, dim=-1).squeeze().tolist()

print("意图ID:", intent_id)
print("槽位标签ID:", slot_ids)

这段代码定义了一个多任务模型,同时做意图分类和槽位填充。 num_intents 是意图数量,比如 pick_and_place 、 pick 、 push 、 home 四种。 num_slots 是槽位标签数量,用 BIO 标注的话,每个槽位类型对应 B- 和 I- 两个标签,再加上 O 标签,所以是 2 * 槽位类型数 + 1 。

训练数据不需要太多,每类意图 50 到 100 条句子就够。数据增强可以用同义词替换和句式变换,比如“把…放到…”可以变成“将…移动到…”、“请把…放在…”。训练的时候用 AdamW 优化器,学习率设 2e-5 ,batch size 设 16,跑 10 个 epoch 左右就能收敛。

注意:轻量语言模型只负责解析意图和槽位,不要让它输出坐标。坐标必须由视觉层提供,否则精度无法保证。

3.3 槽位到视觉标签的映射表

语言层解析出来的槽位值是自然语言描述,比如“红色方块”、“左边区域”,视觉层需要的是具体的标签和坐标。中间需要一个映射表,把自然语言描述映射到视觉层的标签体系。

自然语言描述 视觉标签 类型
红色方块 red_cube 物体
蓝色圆柱 blue_cylinder 物体
绿色小球 green_ball 物体
左边区域 left_region 区域
右边区域 right_region 区域
中间区域 center_region 区域

这个映射表可以硬编码,也可以用简单的字符串匹配。如果视觉层检测到的标签是 red_cube ,语言层解析出“红色方块”,通过映射表找到 red_cube ,然后从视觉层的检测结果里取出对应的三维坐标。区域的话,视觉层预先标定好每个区域的中心点坐标,语言层解析出“左边区域”,直接取 left_region 的中心点作为放置目标。

映射表的好处是语言层和视觉层解耦,换一套视觉标签体系只需要改映射表,不用改语言解析代码。实际做的时候,映射表可以存成 JSON 文件,方便修改和扩展。

4. 视觉定位:从 RGB 图像到三维抓取位姿

4.1 用 YOLO 做目标检测与类别过滤

视觉层的第一步是检测物体。毕设里我推荐用 YOLOv8,因为它的训练和部署都很简单,而且对小目标检测效果不错。训练数据用自己采集的桌面场景图片,标注红色方块、蓝色圆柱、绿色小球三类物体,每类 100 到 200 张图片就够。

from ultralytics import YOLO
import cv2
import numpy as np

# 加载训练好的模型
model = YOLO('best.pt')

# 读取图像
image = cv2.imread('scene.jpg')
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

# 推理
results = model(image_rgb, conf=0.5, iou=0.45)

# 提取检测结果
detections = []
for result in results:
    boxes = result.boxes
    for box in boxes:
        x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
        conf = box.conf[0].cpu().numpy()
        cls_id = int(box.cls[0].cpu().numpy())
        cls_name = model.names[cls_id]
        detections.append({
            'class': cls_name,
            'bbox': [x1, y1, x2, y2],
            'confidence': float(conf)
        })

print("检测结果:")
for det in detections:
    print(det)

这段代码加载训练好的 YOLOv8 模型,对输入图像做推理。 conf=0.5 是置信度阈值,低于 0.5 的检测框会被过滤掉。 iou=0.45 是 NMS 的 IoU 阈值,用来去除重叠的检测框。检测结果包含类别名称、边界框坐标和置信度。

参数调整建议:如果漏检比较多,把 conf 降到 0.3;如果误检比较多,把 conf 提到 0.6。 iou 一般设 0.45 到 0.5 之间,太高会导致重叠物体只检测到一个,太低会保留太多重复框。

4.2 从边界框到三维坐标:深度估计的两种做法

YOLO 输出的是二维边界框,机械臂需要的是三维坐标。获取深度有两种常见做法:一是用 RGB-D 相机直接读取深度值,二是用单目深度估计模型从 RGB 图像预测深度。

如果用 RGB-D 相机,比如 Intel RealSense 或 Orbbec,可以直接读取对齐后的深度图。取边界框中心点的深度值,结合相机内参,就能算出三维坐标。

import pyrealsense2 as rs
import numpy as np

# 初始化相机管道
pipeline = rs.pipeline()
config = rs.config()
config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30)
config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30)

# 启动相机
profile = pipeline.start(config)

# 获取相机内参
intrinsics = profile.get_stream(rs.stream.color).as_video_stream_profile().get_intrinsics()
fx, fy = intrinsics.fx, intrinsics.fy
cx, cy = intrinsics.ppx, intrinsics.ppy

# 等待一帧
frames = pipeline.wait_for_frames()
color_frame = frames.get_color_frame()
depth_frame = frames.get_depth_frame()

# 转换为 numpy 数组
color_image = np.asanyarray(color_frame.get_data())
depth_image = np.asanyarray(depth_frame.get_data())

# 假设检测框中心点为 (u, v)
u, v = 320, 240
depth = depth_frame.get_distance(u, v)

# 反投影到三维坐标(相机坐标系)
x = (u - cx) * depth / fx
y = (v - cy) * depth / fy
z = depth

print(f"三维坐标:({x:.3f}, {y:.3f}, {z:.3f}) 米")

这段代码用 RealSense 相机读取对齐后的彩色图和深度图,然后取检测框中心点的深度值,通过相机内参反投影到三维坐标。 fx 、 fy 是焦距, cx 、 cy 是主点坐标,这些参数从相机内参里直接读取。

如果用单目深度估计,可以用 MiDaS 或 Depth Anything 模型从 RGB 图像预测相对深度,再通过标定把相对深度转换成绝对深度。单目深度估计的精度不如 RGB-D 相机,但成本低,适合预算有限的毕设。

提示:深度图在物体边缘和反光表面容易产生噪声,取深度值的时候不要只取一个点,取边界框中心 5x5 区域的中值深度,稳定性会好很多。

4.3 抓取位姿生成:从物体坐标到夹爪姿态

拿到物体的三维坐标后,还需要确定夹爪的抓取姿态。对于桌面上的方块和圆柱,最简单的做法是让夹爪垂直向下抓取,姿态固定为 [0, 0, 0] (绕 Z 轴旋转 0 度)。如果物体有朝向,比如长方体,需要根据物体的长轴方向调整夹爪的旋转角。

import numpy as np

def compute_grasp_pose(object_center, object_size, grasp_type='top_down'):
    """
    计算抓取位姿
    object_center: 物体中心三维坐标 [x, y, z]
    object_size: 物体尺寸 [length, width, height]
    grasp_type: 抓取类型,'top_down' 或 'side'
    返回:抓取位置 [x, y, z] 和抓取姿态 [rx, ry, rz]
    """
    x, y, z = object_center
    length, width, height = object_size

    if grasp_type == 'top_down':
        # 从上方垂直抓取,抓取点位于物体顶部中心
        grasp_position = [x, y, z + height / 2 + 0.01]  # 加 1cm 安全距离
        grasp_orientation = [0, 0, 0]  # 垂直向下
    elif grasp_type == 'side':
        # 从侧面抓取,抓取点位于物体侧面中心
        grasp_position = [x + length / 2 + 0.01, y, z]
        grasp_orientation = [0, np.pi / 2, 0]  # 绕 Y 轴旋转 90 度
    else:
        raise ValueError(f"不支持的抓取类型:{grasp_type}")

    return grasp_position, grasp_orientation

# 示例
object_center = [0.3, 0.1, 0.05]  # 物体中心在基坐标系下的坐标
object_size = [0.04, 0.04, 0.04]  # 4cm 立方体
grasp_pos, grasp_ori = compute_grasp_pose(object_center, object_size, 'top_down')
print(f"抓取位置:{grasp_pos}")
print(f"抓取姿态:{grasp_ori}")

这段代码根据物体中心和尺寸计算抓取位姿。 top_down 模式适用于方块和圆柱,抓取点在物体顶部中心上方 1cm 处,姿态垂直向下。 side 模式适用于需要从侧面抓取的物体,抓取点在物体侧面中心,姿态绕 Y 轴旋转 90 度。

参数说明: object_size 可以从 YOLO 的边界框和深度值估算,也可以用已知的物体尺寸硬编码。 0.01 是安全距离,避免夹爪直接撞到物体。实际使用的时候,抓取姿态还需要考虑夹爪的开口方向和物体的对称性,比如圆柱体可以任意旋转,长方体需要对齐长轴。

5. 执行机构:逆运动学求解与轨迹规划

5.1 六轴机械臂的逆运动学求解

拿到抓取位姿后,需要解算机械臂的关节角。六轴机械臂的逆运动学求解有解析法和数值法两种。解析法速度快,但只适用于特定构型的机械臂;数值法通用性好,但可能陷入局部最优。

毕设里我推荐用 ikpy 或 PyBullet 做数值求解,因为它们封装好了逆运动学算法,不用自己推导公式。

import numpy as np
from ikpy.chain import Chain
from ikpy.link import OriginLink, URDFLink

# 定义机械臂的 DH 参数(示例,实际参数需要根据机械臂型号填写)
# 这里用简化的 6 轴机械臂模型
arm_chain = Chain(name='arm', links=[
    OriginLink(),
    URDFLink(name="joint_1", origin_translation=[0, 0, 0.1], origin_orientation=[0, 0, 0], rotation=[0, 0, 1]),
    URDFLink(name="joint_2", origin_translation=[0, 0, 0.2], origin_orientation=[0, 0, 0], rotation=[0, 1, 0]),
    URDFLink(name="joint_3", origin_translation=[0, 0, 0.2], origin_orientation=[0, 0, 0], rotation=[0, 1, 0]),
    URDFLink(name="joint_4", origin_translation=[0, 0, 0.1], origin_orientation=[0, 0, 0], rotation=[1, 0, 0]),
    URDFLink(name="joint_5", origin_translation=[0, 0, 0.1], origin_orientation=[0, 0, 0], rotation=[0, 1, 0]),
    URDFLink(name="joint_6", origin_translation=[0, 0, 0.05], origin_orientation=[0, 0, 0], rotation=[1, 0, 0]),
])

# 目标位姿(4x4 变换矩阵)
target_position = [0.3, 0.1, 0.2]
target_orientation = np.eye(3)  # 单位旋转矩阵,表示垂直向下

# 构造目标变换矩阵
target_matrix = np.eye(4)
target_matrix[:3, :3] = target_orientation
target_matrix[:3, 3] = target_position

# 逆运动学求解
joint_angles = arm_chain.inverse_kinematics(target_matrix)
print("关节角(弧度):", joint_angles)
print("关节角(度):", np.degrees(joint_angles))

这段代码用 ikpy 定义了一个简化的六轴机械臂模型,然后调用 inverse_kinematics 求解关节角。 target_matrix 是目标位姿的 4x4 变换矩阵,包含旋转和平移。 joint_angles 是求解出的关节角,单位是弧度。

参数说明: origin_translation 是相邻关节之间的平移向量, origin_orientation 是关节的初始姿态, rotation 是关节的旋转轴。这些参数需要根据实际机械臂的 DH 参数填写,填错了会导致逆运动学求解失败或者关节角错误。

注意:逆运动学求解可能失败,比如目标点超出工作空间或者接近奇异点。求解失败时 ikpy 会返回 None ,代码里需要加判断,失败时让机械臂回到安全位置。

5.2 轨迹规划:从关节角到平滑运动

逆运动学求解出的是目标关节角,机械臂不能直接从当前关节角跳到目标关节角,需要做轨迹规划,生成一系列中间点,让关节平滑运动。

import numpy as np

def trapezoidal_trajectory(start, end, max_vel, max_acc, dt=0.01):
    """
    梯形速度轨迹规划
    start: 起始关节角
    end: 目标关节角
    max_vel: 最大速度(弧度/秒)
    max_acc: 最大加速度(弧度/秒^2)
    dt: 时间步长
    返回:时间序列和关节角序列
    """
    distance = np.abs(end - start)
    direction = np.sign(end - start)

    # 计算加速段、匀速段和减速段的时间
    t_acc = max_vel / max_acc
    d_acc = 0.5 * max_acc * t_acc ** 2

    if 2 * d_acc >= distance:
        # 三角形速度曲线,没有匀速段
        t_acc = np.sqrt(distance / max_acc)
        t_total = 2 * t_acc
        t_flat = 0
    else:
        # 梯形速度曲线
        d_flat = distance - 2 * d_acc
        t_flat = d_flat / max_vel
        t_total = 2 * t_acc + t_flat

    # 生成时间序列
    t = np.arange(0, t_total, dt)
    positions = np.zeros_like(t)

    for i, ti in enumerate(t):
        if ti < t_acc:
            # 加速段
            positions[i] = start + direction * 0.5 * max_acc * ti ** 2
        elif ti < t_acc + t_flat:
            # 匀速段
            positions[i] = start + direction * (d_acc + max_vel * (ti - t_acc))
        else:
            # 减速段
            td = ti - t_acc - t_flat
            positions[i] = end - direction * 0.5 * max_acc * (t_acc - td) ** 2

    return t, positions

# 示例
start_angle = np.array([0, 0, 0, 0, 0, 0])
end_angle = np.array([0.5, -0.3, 0.8, 0, 0.5, 0])
t, positions = trapezoidal_trajectory(start_angle, end_angle, max_vel=0.5, max_acc=1.0)

print(f"轨迹点数:{len(t)}")
print(f"总时间:{t[-1]:.3f} 秒")

这段代码实现了梯形速度轨迹规划,输入起始关节角、目标关节角、最大速度和最大加速度,输出时间序列和关节角序列。 t_acc 是加速段时间, t_flat 是匀速段时间, t_total 是总时间。如果距离太短,加速段和减速段会重叠,变成三角形速度曲线。

参数说明: max_vel 一般设 0.5 到 1.0 弧度/秒, max_acc 设 1.0 到 2.0 弧度/秒^2。 dt 是控制周期,一般设 0.01 秒(100Hz)。实际使用的时候,每个关节单独做轨迹规划,然后取所有关节里最长的总时间作为同步时间,保证所有关节同时到达目标。

5.3 夹爪控制与抓取力调节

夹爪控制相对简单,一般是开环控制,发送开合指令就行。但抓取力需要调节,力太小抓不住,力太大可能损坏物体。

import serial
import time

class GripperController:
    def __init__(self, port, baudrate=115200):
        self.ser = serial.Serial(port, baudrate, timeout=1)
        time.sleep(2)  # 等待串口初始化

    def set_force(self, force_percent):
        """设置抓取力,0-100"""
        force_percent = max(0, min(100, force_percent))
        command = f"SET_FORCE {force_percent}\n"
        self.ser.write(command.encode())
        time.sleep(0.1)

    def open(self):
        """打开夹爪"""
        self.ser.write(b"OPEN\n")
        time.sleep(0.5)

    def close(self):
        """闭合夹爪"""
        self.ser.write(b"CLOSE\n")
        time.sleep(0.5)

    def get_position(self):
        """读取夹爪位置"""
        self.ser.write(b"GET_POS\n")
        time.sleep(0.1)
        response = self.ser.readline().decode().strip()
        return float(response)

# 使用示例
gripper = GripperController('/dev/ttyUSB0')
gripper.set_force(30)  # 设置 30% 抓取力
gripper.open()
time.sleep(1)
gripper.close()
time.sleep(1)
print("夹爪位置:", gripper.get_position())

这段代码封装了夹爪控制类,包含设置抓取力、打开、闭合和读取位置四个方法。 set_force 方法发送力控指令, open 和 close 发送开合指令, get_position 读取夹爪当前位置。

参数说明: force_percent 一般设 20 到 50 之间,具体值需要根据物体重量和材质调整。轻质物体(比如泡沫块)用 20% 就够了,重质物体(比如金属块)需要 50% 以上。 port 是串口设备名,Linux 下一般是 /dev/ttyUSB0 或 /dev/ttyACM0 ,Windows 下是 COM3 之类的。

提示:夹爪闭合后不要立即移动机械臂,等 0.5 秒让夹爪稳定。如果夹爪有位置反馈,可以判断位置是否在预期范围内,确认抓取成功后再移动。

6. 避坑与排查:多模态控制机械臂最常见的 5 个翻车点

6.1 坐标系没对齐,机械臂总是抓偏

现象:视觉层检测到的物体位置看起来是对的,但机械臂抓取时总是偏几厘米,有时候直接撞到物体。

原因:手眼标定矩阵算错了,或者相机坐标系和机械臂基坐标系的单位不一致。常见的是相机输出的是米,机械臂用的是毫米,差了 1000 倍。

解决:先验证手眼标定矩阵。拿一个已知位置的标定板,用视觉层检测它的位置,然后通过手眼标定矩阵转换到基坐标系,和机械臂示教器上读到的位置对比。如果误差超过 5mm,重新标定。单位问题的话,在转换前统一成米,机械臂控制接口如果需要毫米,在最后一步乘 1000。

6.2 逆运动学求解失败,机械臂不动

现象:目标位姿看起来在工作空间内,但逆运动学求解返回 None ,机械臂不动。

原因:目标点接近奇异点,或者关节限位约束导致无解。六轴机械臂的奇异点一般出现在手腕关节对齐的时候,比如第 4 轴和第 6 轴平行。

解决:在逆运动学求解前先检查目标点是否在工作空间内,可以用正运动学算一下机械臂的最大 reach。如果接近奇异点,稍微调整目标姿态,比如绕 Z 轴旋转 5 度。 ikpy 支持设置关节限位,在 URDFLink 里加 bounds 参数,求解时会自动避开限位。

6.3 语言解析歧义,目标物体识别错

现象:说“把红色方块放到左边”,机械臂却抓了蓝色圆柱。

原因:语言层的映射表没对齐,或者视觉层检测到了多个同类物体,取了置信度最高的但位置不对。

解决:映射表要严格对齐,语言层解析出的“红色方块”必须映射到视觉层的 red_cube 标签。如果视觉层检测到多个 red_cube ,取距离机械臂最近的那个,或者取置信度最高的。可以在语言层加一个确认机制,解析出目标后先返回给用户确认,确认后再执行。

6.4 抓取力设置不当,物体滑落或变形

现象:夹爪闭合后物体滑落,或者物体被夹变形。

原因:抓取力太小抓不住,或者太大压坏物体。不同材质的物体需要的抓取力不同,硬编码一个值不够。

解决:根据物体材质和重量动态调整抓取力。轻质软物体用 20% 力,重质硬物体用 50% 力。如果有力传感器,可以做力闭环控制,夹爪闭合时检测力反馈,达到目标力就停止。没有力传感器的话,夹爪闭合后检测位置反馈,如果位置在预期范围内说明抓取成功。

6.5 轨迹规划不平滑,机械臂抖动

现象:机械臂运动过程中抖动明显,有时候触发过流保护。

原因:轨迹规划的速度或加速度设得太大,或者轨迹点之间的间隔不均匀。

解决:降低 max_vel 和 max_acc ,先设 max_vel=0.3 、 max_acc=0.5 跑一遍,确认不抖动后再逐步提高。轨迹点间隔要均匀, dt 设 0.01 秒,不要用变步长。如果机械臂支持,开启关节空间的平滑滤波,对轨迹做低通滤波。

7. 进阶技巧:用状态机管理多步任务与失败重试

单步抓取跑通后,下一步是支持多步任务,比如“把红色方块放到左边,然后把蓝色圆柱放到右边”。这时候需要引入状态机来管理任务序列和失败重试。

我一般用 transitions 库做状态机,定义 IDLE 、 PARSING 、 DETECTING 、 MOVING 、 GRASPING 、 PLACING 、 ERROR 这几个状态。每个状态对应一个处理函数,状态之间的转换由事件触发。

from transitions import Machine
import time

class ArmTaskStateMachine:
    states = ['IDLE', 'PARSING', 'DETECTING', 'MOVING', 'GRASPING', 'PLACING', 'ERROR']

    def __init__(self):
        self.machine = Machine(model=self, states=ArmTaskStateMachine.states, initial='IDLE')
        self.task_queue = []
        self.current_task = None
        self.retry_count = 0
        self.max_retries = 3

        # 定义状态转换
        self.machine.add_transition('start', 'IDLE', 'PARSING')
        self.machine.add_transition('parsed', 'PARSING', 'DETECTING')
        self.machine.add_transition('detected', 'DETECTING', 'MOVING')
        self.machine.add_transition('moved', 'MOVING', 'GRASPING')
        self.machine.add_transition('grasped', 'GRASPING', 'PLACING')
        self.machine.add_transition('placed', 'PLACING', 'IDLE')
        self.machine.add_transition('fail', '*', 'ERROR')
        self.machine.add_transition('retry', 'ERROR', 'DETECTING')
        self.machine.add_transition('reset', 'ERROR', 'IDLE')

    def on_enter_PARSING(self):
        """解析任务指令"""
        if not self.task_queue:
            self.fail()
            return
        self.current_task = self.task_queue.pop(0)
        print(f"解析任务:{self.current_task}")
        # 这里调用语言解析函数
        self.parsed()

    def on_enter_DETECTING(self):
        """检测目标物体"""
        print(f"检测目标:{self.current_task.get('target')}")
        # 这里调用视觉检测函数
        # 如果检测失败,触发 fail
        self.detected()

    def on_enter_MOVING(self):
        """移动到抓取位置"""
        print("移动到抓取位置")
        # 这里调用逆运动学和轨迹规划
        self.moved()

    def on_enter_GRASPING(self):
        """执行抓取"""
        print("执行抓取")
        # 这里调用夹爪控制
        self.grasped()

    def on_enter_PLACING(self):
        """执行放置"""
        print("执行放置")
        # 这里调用放置逻辑
        self.placed()

    def on_enter_ERROR(self):
        """错误处理"""
        print(f"任务失败,重试次数:{self.retry_count}")
        if self.retry_count < self.max_retries:
            self.retry_count += 1
            time.sleep(1)
            self.retry()
        else:
            print("达到最大重试次数,任务终止")
            self.retry_count = 0
            self.reset()

# 使用示例
sm = ArmTaskStateMachine()
sm.task_queue = [
    {"action": "pick_and_place", "target": "red_cube", "destination": "left_region"},
    {"action": "pick_and_place", "target": "blue_cylinder", "destination": "right_region"}
]
sm.start()

这段代码定义了一个状态机,管理多步任务的执行流程。 task_queue 存储待执行的任务列表, current_task 是当前正在执行的任务。每个状态对应一个 on_enter_ 方法,在里面调用具体的处理函数。如果某个步骤失败,触发 fail 转换到 ERROR 状态, ERROR 状态里判断重试次数,没超过上限就重试,超过了就终止任务。

参数说明: max_retries 设 3 次,每次重试前等 1 秒,避免频繁重试导致机械臂过热。 task_queue 里的任务格式和语言层解析出的格式一致,包含 action 、 target 、 destination 三个字段。

实际使用的时候,每个 on_enter_ 方法里要加异常捕获,比如视觉检测没找到目标、逆运动学求解失败、夹爪通信超时,这些异常都要触发 fail 转换。重试的时候可以调整参数,比如降低抓取速度、增大抓取力、换个角度检测,提高成功率。

我自己的习惯是每次跑多步任务前,先把单步任务跑 10 遍,确认成功率在 90% 以上再上多步。多步任务里最容易出问题的是第二步,因为第一步抓取后物体位置变了,第二步的视觉检测需要重新做,不能复用第一步的结果。另外,任务队列不要排太长,3 到 5 步就够了,太长了中间出错概率高,调试也麻烦。

希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐