机器人看见物体怎么算抓取角度?纯视觉6DoF位姿估计管道全拆解
导读:机器人物体抓取任务中,"找到物体在哪里"只是第一步——更重要的是算出用什么角度去抓。下面拆解完整纯视觉方案:实例分割、3D重建、PnP位姿求解三阶段串联,附完整代码和开源仓库参考。
一、问题本质从看到到算准
位姿估计两个层次
6DoF位姿估计(Six Degrees of Freedom Pose Estimation)是机器人抓取的核心技术,它要回答的问题是:物体在哪里(3D平移),以及它朝向哪边(3D旋转)。
| 层次 | 描述 | 例子 |
|---|---|---|
| 实例分割 | 像素级定位:哪些像素属于哪个物体 | SAM输出掩码 |
| 6DoF位姿 | 空间定位:物体相对相机的完整变换 | $(t_x, t_y, t_z, R_x, R_y, R_z)$ |
端到端与解耦方案对比
端到端方法(如GDRN、PVNet)直接输入RGB图像、输出6DoF位姿,但需要知道物体在哪里先验——这正是实例分割要解决的问题。
工程实践中将"知道物体在哪里"和"算6DoF位姿"解耦成两个步骤:
二、实例分割找出物体在哪里
彩色掩码对比二值掩码
纯视觉位姿估计方案中选择的实例分割算法,输出格式从彩色掩码(每类不同颜色)转换为二值掩码(物体=1,背景=0)——这是因为后续PnP算法只需要"哪些像素属于这个物体",不需要语义类别。
多物体优先级选择策略
当场景中多个同类物体时(如桌子上有3个水杯),传统方法会选择所有物体,但机器人一次只能抓一个。工程实践提出优先级选择策略:
| 优先级 | 策略 | 依据 |
|---|---|---|
| 1 | 距离最近 | 优先抓最近的 |
| 2 | 可见面积最大 | 最容易被成功抓取 |
| 3 | 遮挡最少 | 抓取点最清晰 |
专用模型与基础大模型对比
纯视觉方案对比了实例分割和大模型(如SAM):
| 方法 | 优点 | 缺点 | 适用性 |
|---|---|---|---|
| 专用分割模型 | 速度快(实时)、轻量 | 需要针对类别微调 | 固定场景首选 |
| SAM | 零样本泛化 | 计算量大、输出掩码多需后处理 | 开放场景 |
| Grounding-DINO | 开集检测+分割 | 需要描述文本 | 文本引导任务 |
Grounding-DINO加SAM组合实现
# 版本: Python 3.10+
# 依赖: torch, opencv-python, grounding-dino, segment-anything
import torch
import cv2
import numpy as np
from typing import Optional
try:
from groundingdino.util.inference import load_model as load_dino, predict as dino_predict
from segment_anything import sam_model_registry, SamPredictor
except ImportError:
print("请安装: pip install grounding-dino segment-anything")
class GroundingSAMPipeline:
"""Grounding-DINO + SAM 实例分割管道"""
def __init__(
self,
dino_model: str = "GroundingDINO/groundingdinov5-x.yaml",
sam_model: str = "vit_b",
sam_ckpt: str = "sam_vit_b.pth",
device: str = "cuda"
):
self.device = device
self.dino = load_dino(dino_model, dino_model.replace("yaml", "pth"))
self.dino.to(device)
self.sam = sam_model_registry[sam_model](checkpoint=sam_ckpt)
self.sam.to(device)
self.sam_predictor = SamPredictor(self.sam)
def segment(
self,
image: np.ndarray,
text_prompt: str,
box_threshold: float = 0.35,
text_threshold: float = 0.25
) -> tuple:
"""语义引导实例分割
参数:
image: BGR图像 [H, W, 3]
text_prompt: 文本描述,如 "cup. bottle. bowl."
box_threshold: 检测框置信度阈值
text_threshold: 文本匹配阈值
返回:
masks: 二值掩码列表 [mask1, mask2, ...] 每个shape [H, W]
boxes: 检测框列表 [[x1,y1,x2,y2], ...]
"""
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
boxes, logits, phrases = dino_predict(
self.dino,
image,
text_prompt,
box_threshold,
text_threshold
)
self.sam_predictor.set_image(image_rgb)
masks = []
for box in boxes:
x1, y1, x2, y2 = box
input_box = np.array([x1, y1, x2, y2])
mask, score, _ = self.sam_predictor.predict(
point_coords=None,
point_labels=None,
box=input_box[None, :],
multimask_output=False
)
masks.append(mask[0].astype(np.uint8))
return masks, boxes.cpu().numpy()
def select_best_mask(
self,
masks: list,
priority: str = "largest_visible"
) -> int:
"""多物体选择策略
priority: "nearest" | "largest_visible" | "least_occluded"
"""
if len(masks) == 0:
return -1
if len(masks) == 1:
return 0
if priority == "largest_visible":
areas = [np.sum(m > 0) for m in masks]
return int(np.argmax(areas))
elif priority == "nearest":
centroids_y = [np.mean(np.where(m > 0)[0]) for m in masks]
return int(np.argmin(centroids_y))
else:
return 0
# 使用示例
pipeline = GroundingSAMPipeline()
image = cv2.imread("scene.jpg")
masks, boxes = pipeline.segment(image, text_prompt="cup. bottle.")
best_idx = pipeline.select_best_mask(masks, priority="largest_visible")
best_mask = masks[best_idx]
print(f"检测到 {len(masks)} 个物体,选择第 {best_idx} 个(面积最大)")
print(f"掩码非零像素数: {np.sum(best_mask > 0)}")
三、3D重建从2D长出3D
为何需要3D模型
PnP算法需要物体3D点云作为输入(至少4个非共面的3D-2D对应点对)。因此在估计位姿之前,必须先重建物体的3D模型。
重建路线横向对比
| 方法 | 代表技术 | 优点 | 缺点 |
|---|---|---|---|
| 多视图几何 | SfM / COLMAP | 精度高、无需深度相机 | 速度慢、需要多视角 |
| 深度估计+融合 | MiDaS + TSDF | 单目可用、速度快 | 深度精度有限 |
| 神经网络重建 | PointNet / NeRF | 端到端 | 需要训练数据 |
| 3DGS | 3D Gaussian Splatting | 实时渲染 | 计算资源高 |
工程上选用深度估计+点云融合方案,结合质心校准和去对称性处理两个关键后处理步骤。COLMAP和hloc是离线建图阶段的工业级参考实现,能为高精度场景提供SfM基础。
深度估计加点云融合实现
# 版本: Python 3.10+
# 依赖: numpy, opencv-python, torch
import numpy as np
import cv2
import torch
try:
from transformers import DPTForDepthEstimation, DPTImageProcessor
except ImportError:
print("请安装: pip install transformers")
class DepthEstimator:
"""单目深度估计 + 点云融合,使用MiDaS/DPT深度估计网络"""
def __init__(self, model_name: str = "Intel/dpt-hybrid-midas"):
self.processor = DPTImageProcessor.from_pretrained(model_name)
self.model = DPTForDepthEstimation.from_pretrained(model_name)
def estimate_depth(self, image: np.ndarray) -> np.ndarray:
inputs = self.processor(images=image, return_tensors="pt")
with torch.no_grad():
outputs = self.model(**inputs)
depth = outputs.predicted_depth
depth = torch.nn.functional.interpolate(
depth.unsqueeze(1),
size=image.shape[:2],
mode="bicubic",
align_corners=False
).squeeze().numpy()
depth = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
return depth
def depth_to_pointcloud(
self,
rgb: np.ndarray,
depth: np.ndarray,
K: np.ndarray,
T_cam_world: Optional[np.ndarray] = None
) -> np.ndarray:
"""深度图 → 点云
参数:
rgb: RGB图像 [H, W, 3]
depth: 深度图 [H, W] 归一化到0~1
K: 相机内参 [3, 3]
T_cam_world: 相机外参,默认单位阵
返回:
points: 点云 [N, 3] (x,y,z)
"""
H, W = depth.shape
if T_cam_world is None:
T_cam_world = np.eye(4)
depth_real = depth * 5.0 # 假设深度范围0~5m
u_coords, v_coords = np.meshgrid(np.arange(W), np.arange(H))
z = depth_real[v_coords.flatten(), u_coords.flatten()]
x = (u_coords.flatten() - K[0, 2]) * z / K[0, 0]
y = (v_coords.flatten() - K[1, 2]) * z / K[1, 1]
points = np.stack([x, y, z], axis=1)
mask = z > 0.01
points = points[mask]
return points
def mask_pointcloud(
self,
points: np.ndarray,
mask: np.ndarray,
rgb: np.ndarray,
K: np.ndarray
) -> tuple:
"""根据二值掩码提取物体点云"""
H, W = mask.shape
u_coords, v_coords = np.meshgrid(np.arange(W), np.arange(H))
valid_mask = mask.flatten() > 0
depth = np.zeros(H * W)
depth[valid_mask] = 1.0
x = (u_coords.flatten() - K[0, 2]) * depth / K[0, 0]
y = (v_coords.flatten() - K[1, 2]) * depth / K[1, 1]
z = depth
obj_points_raw = np.stack([x, y, z], axis=1)
obj_points = obj_points_raw[valid_mask]
obj_rgb = rgb.transpose(2, 0, 1).reshape(3, -1)
obj_colors = obj_rgb[:, valid_mask].T
return obj_points, obj_colors
# 使用示例
estimator = DepthEstimator()
rgb = cv2.imread("scene.jpg")
depth = estimator.estimate_depth(rgb)
K = np.array([[535.4, 0, 320.1],
[0, 539.2, 240.1],
[0, 0, 1]])
points = estimator.depth_to_pointcloud(rgb, depth, K)
print(f"场景点云: {points.shape}")
质心校准与去对称性处理
import numpy as np
from scipy.spatial.transform import Rotation as R
class PoseModelRefiner:
"""3D重建模型修正
1. 质心校准: 点云质心→物体几何中心
2. 去对称性处理: 对称物体(如圆柱体)需要先验对齐
"""
def calibrate_centroid(self, point_cloud: np.ndarray) -> tuple:
"""质心校准
问题: 深度估计的零点通常在相机位置,
导致重建的点云质心≠物体几何中心
解决: 假设物体在某个高度范围内(桌面上),
校准质心z坐标
"""
centroid = np.mean(point_cloud, axis=0)
z_min = point_cloud[:, 2].min()
z_max = point_cloud[:, 2].max()
z_geo_center = (z_min + z_max) / 2
calibrated = point_cloud.copy()
calibrated[:, 2] += (z_geo_center - centroid[2])
T_calib = np.eye(4)
T_calib[:3, 3] = centroid
return calibrated, T_calib
def remove_symmetry(
self,
point_cloud: np.ndarray,
symmetry_type: str = "bilateral_z"
) -> tuple:
"""去对称性处理
对称物体(如杯子、瓶子)绕某轴旋转时视觉外观不变,
需要额外的先验(如物体朝向)来消歧
参数:
symmetry_type: "bilateral_z"(绕Z轴对称) |
"planar"(平面对称) |
"none"(无对称)
"""
if symmetry_type == "none":
return point_cloud, np.eye(4)
if symmetry_type == "bilateral_z":
T_align = np.eye(4)
T_align[2, 3] = 0.0
return point_cloud, T_align
return point_cloud, np.eye(4)
def refine_model(
self,
point_cloud: np.ndarray,
symmetry_type: str = "bilateral_z"
) -> tuple:
refined_cloud, T1 = self.calibrate_centroid(point_cloud)
refined_cloud, T2 = self.remove_symmetry(refined_cloud, symmetry_type)
return refined_cloud, T1 @ T2
四、6DoF位姿估计PnP求解
PnP算法原理
PnP(Perspective-n-Point)的任务是:已知n个3D点在物体坐标系下的坐标,以及它们在2D图像上对应的像素坐标,求解相机的外参(旋转+平移)。
经典EPnP算法(用4个控制点代替n个点求解):
# 版本: Python 3.10+
# 依赖: numpy, opencv-python, scipy
import numpy as np
import cv2
from scipy.spatial.transform import Rotation as R
class PoseEstimator:
"""EPnP + RANSAC 6DoF位姿估计
输入: 2D图像点(物体掩码边缘/关键点) + 3D模型点(重建的物体点云)
输出: 旋转矩阵R[3x3] + 平移向量t[3,] (物体相对相机)
"""
def __init__(self, K: np.ndarray):
self.K = K # 相机内参
def extract_2d_points_from_mask(
self,
mask: np.ndarray,
num_points: int = 100
) -> np.ndarray:
"""从二值掩码中提取2D关键点,使用掩码边缘采样"""
edges = cv2.Canny((mask * 255).astype(np.uint8), 100, 200)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
all_points = []
for contour in contours:
all_points.extend(contour.squeeze().tolist())
all_points = np.array(all_points)
if len(all_points) == 0:
y_coords, x_coords = np.where(mask > 0)
all_points = np.stack([x_coords, y_coords], axis=1)
if len(all_points) > num_points:
indices = np.random.choice(len(all_points), num_points, replace=False)
all_points = all_points[indices]
return all_points.astype(np.float32)
def estimate_pose_ransac(
self,
points_3d: np.ndarray,
points_2d: np.ndarray,
ransac_threshold: float = 3.0
) -> tuple:
"""RANSAC + EPnP 鲁棒位姿估计
参数:
points_3d: 3D点云 [N, 3] (物体坐标系)
points_2d: 对应2D像素坐标 [N, 2]
ransac_threshold: RANSAC重投影误差阈值(像素)
返回:
R: 旋转矩阵 [3, 3]
t: 平移向量 [3,]
inliers: 内点数量
"""
if len(points_3d) < 4:
raise ValueError("至少需要4个对应点")
success, rvec, t, inliers = cv2.solvePnPRansac(
objectPoints=points_3d.astype(np.float32),
imagePoints=points_2d.astype(np.float32),
cameraMatrix=self.K,
distCoeffs=None,
reprojectionError=ransac_threshold,
confidence=0.99,
iterationsCount=1000
)
if not success:
raise RuntimeError("PnP求解失败")
R_mat = cv2.Rodrigues(rvec)[0]
return R_mat, t.squeeze(), len(inliers)
def compute_reprojection_error(
self,
points_3d: np.ndarray,
R: np.ndarray,
t: np.ndarray,
points_2d: np.ndarray
) -> float:
"""计算重投影误差"""
points_3d_h = np.concatenate([points_3d, np.ones((len(points_3d), 1))], axis=1).T
proj = self.K @ (R @ points_3d[:3].T + t.reshape(3, 1))
proj_2d = (proj[:2] / proj[2:]).T
errors = np.linalg.norm(proj_2d - points_2d, axis=1)
return float(np.mean(errors))
def pose_to_transform(self, R: np.ndarray, t: np.ndarray) -> np.ndarray:
"""R,t → 4x4齐次变换矩阵"""
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = t
return T
# 使用示例
K = np.array([[535.4, 0, 320.1],
[0, 539.2, 240.1],
[0, 0, 1]])
estimator = PoseEstimator(K)
points_3d = np.random.randn(500, 3) * 0.1
points_2d = estimator.extract_2d_points_from_mask(best_mask, num_points=100)
R, t, inliers = estimator.estimate_pose_ransac(points_3d, points_2d, ransac_threshold=3.0)
T_cam_obj = estimator.pose_to_transform(R, t)
# 物体相对相机坐标系下的位置
print(f"物体位置(相机坐标系): t={t}")
print(f"物体朝向(欧拉角): {R.as_euler('xyz', degrees=True)}")
print(f"内点比例: {inliers/len(points_2d)*100:.1f}%")
五、整体管道端到端串联
六、真机实验三项任务验证
实机实验包含三项任务:
| 任务 | 描述 | 性能指标 |
|---|---|---|
| 抓水瓶 | 识别+定位水瓶,抓取并移动 | 成功率、位姿误差 |
| 抓水杯 | 同上,水杯为对称物体 | 去对称性处理验证 |
| 倒水 | 抓杯→移到水源上方→倾斜 | 6DoF轨迹精度 |
七、开源仓库参考实现
实例分割
| 仓库 | 内容 |
|---|---|
| IDEA-Research/GroundingDINO | IDEA Research开源,文本引导开放集检测 |
| facebookresearch/segment-anything | Meta"分割一切"模型 |
| JiehongLin/SAM-6D | SAM+姿态估计,零样本6DoF |
| IDEA-Research/Grounded-SAM | Grounding-DINO + SAM组合 |
深度估计与3D重建
| 仓库 | 内容 |
|---|---|
| isl-org/MiDaS | Intel单目深度估计 |
| graphdeco-inria/gaussian-splatting | 3DGS官方实现,实时渲染 |
| colmap/colmap | 工业级SfM/多视图重建 |
| cerberus-cn/hloc | 视觉定位与特征匹配工具链 |
位姿估计与SLAM参考
| 仓库 | 内容 |
|---|---|
| NVlabs/Deep_Object_Pose | NVIDIA 6DoF位姿估计DOPE |
| THU-DA-robotics/GDRNPP | 精细化6DoF位姿估计GDRNPP |
| thohemp/6DRepNet | 单目6DoF旋转回归 |
| UZ-SLAMLab/ORB_SLAM3 | 视觉SLAM参考实现 |
| ApolloAuto/apollo | 百度Apollo感知参考 |
八、核心回顾与选型建议
| 模块 | 要点 |
|---|---|
| 实例分割 | Grounding-DINO文本引导检测+SAM分割,二值掩码输出 |
| 3D重建 | DPT单目深度估计+点云融合,质心校准去偏差 |
| PnP求解 | EPnP+RANSAC鲁棒估计,重投影误差验证 |
| 真机验证 | 抓水瓶/抓杯/倒水三项任务,接近实时位姿发布 |
选型建议:
| 场景 | 推荐 |
|---|---|
| 固定场景、实时性要求高 | 专用分割+深度融合 |
| 开放场景、泛化性要求高 | Grounding-DINO+SAM+端到端位姿(SAM-6D路线) |
| 高精度需求 | 多视图COLMAP+精细化点云 |
参考文献
| 序号 | 文献 |
|---|---|
| 1 | Lin et al.·CVPR 2024·SAM-6D: Segment Anything Meets Zero-Shot 6D Pose Estimation |
| 2 | Kirillov et al.·Meta AI·Segment Anything Model (SAM) |
| 3 | IDEA Research·Grounding DINO |
| 4 | NVIDIA·Deep Object Pose Estimation (DOPE) |
| 5 | Intel·MiDaS: Monocular Depth Estimation |
| 6 | Schonberger et al.·CVPR 2016·COLMAP |
代码参考开源仓库实现
版本:v1.1 | 2026-06-02