一句话总结

MonoArt 从单张 RGB 图像重建铰接体(有关节的物体)的完整 3D 结构——包括几何形状、部件划分和关节运动参数——不依赖多视角输入或外部模板库。

为什么这个问题重要?

铰接体无处不在

机器人要开门、拉抽屉、操作机械臂,这些都是铰接体(articulated objects)。铰接体的特点是:整体由刚性部件组成,部件之间通过关节连接,关节允许特定自由度的运动。

重建铰接体的 3D 结构是很多下游任务的基础:

  • 机器人操作:知道抽屉关节轴的位置,才能规划如何拉开它
  • AR/VR 场景:把真实物体的可动结构数字化
  • 场景理解:理解环境中物体的功能属性

为什么单目重建很难?

从多视角或深度图重建相对容易——信息充分。单目图像面临的核心困难:

  1. 几何歧义:单张 2D 图像无法唯一确定 3D 形状
  2. 运动歧义:同一视觉观测可能对应不同关节状态(门半开还是全开?)
  3. 耦合问题:部件形状和关节状态高度纠缠,难以分离

现有方法的缺陷:多视角方法数据采集成本高;检索拼装方法依赖模板库泛化差;视频辅助方法需要时间序列效率低。

MonoArt 的核心思路:不直接从图像特征回归所有参数,而是分阶段从粗到细推理——先理解几何,再理解部件,最后理解运动

背景知识

铰接体的数学表示

铰接体建模为一棵运动学树(kinematic tree)

\[\mathcal{A} = \{P_i, J_j\}\]
  • $P_i$:第 $i$ 个部件的几何形状 + 世界坐标系中的刚体变换 $T_i \in SE(3)$
  • $J_j$:第 $j$ 个关节,包含类型(旋转/平移)、轴方向 $\mathbf{a}$、轴位置 $\mathbf{p}$、状态 $\theta$

关节类型:

  • 旋转关节(Revolute):绕轴旋转,如门铰链、机械臂关节
  • 棱柱关节(Prismatic):沿轴平移,如抽屉、伸缩杆

正向运动学

给定关节状态 $\theta$,子部件相对父部件的变换:

旋转关节(Rodrigues 公式): \(T_{\text{child}} = T_{\text{parent}} \cdot \exp(\theta \cdot [\mathbf{a}]_\times)\)

平移关节: \(T_{\text{child}} = T_{\text{parent}} \cdot \begin{pmatrix} I & \theta \cdot \mathbf{a} \\ 0 & 1 \end{pmatrix}\)

其中 $[\mathbf{a}]_\times$ 是向量 $\mathbf{a}$ 的反对称矩阵。

Canonical Space(标准空间)

MonoArt 的关键设计:在标准姿态($\theta = 0$,所有关节中性位置)下预测几何,再通过运动参数变换到观测状态。这样几何预测和运动预测可以解耦:

观测图像 → 标准几何(θ=0)
         → 运动参数(θ)
最终结果  = 标准几何 + 正向运动学(θ)

核心方法

直觉解释

直接从图像回归所有参数太难。MonoArt 把问题拆成三个渐进子问题:

单张图像
    ↓  视觉特征提取(CNN/ViT backbone)
特征图
    ↓  阶段1:标准几何解码
标准空间中的部件几何(θ=0)
    ↓  阶段2:部件结构推理(Transformer 建模部件间关系)
结构化部件特征
    ↓  阶段3:运动感知嵌入
关节类型 + 轴方向 + 轴位置 + 关节状态
    ↓
完整铰接体 3D 模型

关键洞见:先有形状,再有结构,最后有运动。这个顺序符合人类认知——我们先看到柜子的形状,再意识到面板是独立部件,最后理解它可以被拉开。

损失函数

整体损失:

\[\mathcal{L} = \lambda_{\text{geo}} \mathcal{L}_{\text{geo}} + \lambda_{\text{part}} \mathcal{L}_{\text{part}} + \lambda_{\text{motion}} \mathcal{L}_{\text{motion}}\]

几何重建损失(标准空间中的 Chamfer Distance):

\[\mathcal{L}_{\text{geo}} = \frac{1}{|S_1|} \sum_{x \in S_1} \min_{y \in S_2} \|x - y\|^2 + \frac{1}{|S_2|} \sum_{y \in S_2} \min_{x \in S_1} \|x - y\|^2\]

运动参数损失分三项:

  • 轴方向:$\mathcal{L}_{\text{axis}} = 1 - \lvert\hat{\mathbf{a}} \cdot \mathbf{a}^*\rvert$(余弦相似度绝对值,消除方向二义性)
  • 轴位置:$\mathcal{L}_{\text{pos}} = |\hat{\mathbf{p}} - \mathbf{p}^*|^2$
  • 关节状态:$\mathcal{L}_{\text{state}} = \lvert\hat{\theta} - \theta^*\rvert$

实现

铰接体核心数据结构

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from dataclasses import dataclass, field
from typing import List

@dataclass
class Joint:
    joint_type: str        # 'revolute' 或 'prismatic'
    axis: np.ndarray       # 关节轴方向,单位向量 (3,)
    position: np.ndarray   # 关节轴上一点 (3,)
    parent_id: int
    child_id: int
    state: float = 0.0     # 当前状态(弧度 or 米)

@dataclass
class ArticulatedObject:
    part_points: List[np.ndarray]  # 每个部件的标准空间点云
    joints: List[Joint]

    def apply_kinematics(self) -> List[np.ndarray]:
        """正向运动学:标准姿态 → 当前状态(关节需按根到叶顺序排列)"""
        transforms = [np.eye(4)] * len(self.part_points)
        for joint in self.joints:
            T_local = self._joint_transform(joint)
            transforms[joint.child_id] = transforms[joint.parent_id] @ T_local
        result = []
        for i, pts in enumerate(self.part_points):
            pts_h = np.hstack([pts, np.ones((len(pts), 1))])
            result.append((pts_h @ transforms[i].T)[:, :3])
        return result

    def _joint_transform(self, j: Joint) -> np.ndarray:
        T = np.eye(4)
        if j.joint_type == 'revolute':
            a, theta = j.axis, j.state
            K = np.array([[0,-a[2],a[1]],[a[2],0,-a[0]],[-a[1],a[0],0]])
            R = np.eye(3) + np.sin(theta)*K + (1-np.cos(theta))*(K@K)
            T[:3,:3] = R
            T[:3, 3] = j.position - R @ j.position  # 绕关节点旋转
        elif j.joint_type == 'prismatic':
            T[:3, 3] = j.axis * j.state
        return T

渐进式推理模型

class ProgressiveArticulationHead(nn.Module):
    """MonoArt 渐进推理头简化实现"""
    def __init__(self, feat_dim=512, num_parts=4, num_points=256):
        super().__init__()
        self.num_parts, self.num_points = num_parts, num_points

        # 阶段1:标准几何解码器
        self.geo_decoder = nn.Sequential(
            nn.Linear(feat_dim, 256), nn.ReLU(),
            nn.Linear(256, num_parts * num_points * 3)
        )
        # 阶段2:部件特征投影 + Transformer 建模部件间关系
        self.part_proj = nn.Linear(3, 64)
        self.part_encoder = nn.TransformerEncoder(
            nn.TransformerEncoderLayer(d_model=64, nhead=4, batch_first=True),
            num_layers=2
        )
        # 阶段3:运动参数预测(每关节 8 维:类型1+轴3+位置3+状态1)
        self.motion_head = nn.Sequential(
            nn.Linear(64 * num_parts, 256), nn.ReLU(),
            nn.Linear(256, (num_parts - 1) * 8)
        )

    def forward(self, visual_feat):
        B = visual_feat.shape[0]
        # 阶段1:标准几何
        geo = self.geo_decoder(visual_feat).view(B, self.num_parts, self.num_points, 3)

        # 阶段2:部件特征(对点云做全局平均池化,再用 Transformer 聚合部件关系)
        # 实际实现中此处用 PointNet 提取更丰富的部件特征
        part_feat = self.part_proj(geo.mean(dim=2))     # (B, P, 64)
        part_feat = self.part_encoder(part_feat)         # Transformer 建模部件间关系

        # 阶段3:运动参数
        motion = self.motion_head(part_feat.reshape(B, -1))
        motion = motion.view(B, self.num_parts - 1, 8)  # (B, num_joints, 8)
        return geo, motion

损失函数实现

def articulation_loss(pred_geo, pred_motion, gt_geo, gt_motion,
                      lam_geo=1.0, lam_axis=1.0, lam_pos=0.5, lam_state=0.5):
    # 几何损失:简化版 Chamfer(实际用 pytorch3d 的实现)
    B, P, N, _ = pred_geo.shape
    pred_flat = pred_geo.reshape(B*P, N, 3)
    gt_flat   = gt_geo.reshape(B*P, N, 3)
    dist = torch.cdist(pred_flat, gt_flat)
    loss_geo = dist.min(dim=2).values.mean() + dist.min(dim=1).values.mean()

    # 轴方向损失:绝对值余弦,消除 a 和 -a 的二义性
    pred_axis = F.normalize(pred_motion[..., 1:4], dim=-1)
    gt_axis   = F.normalize(gt_motion[..., 1:4], dim=-1)
    loss_axis = 1.0 - torch.abs(F.cosine_similarity(pred_axis, gt_axis, dim=-1)).mean()

    # 轴位置损失 + 关节状态损失
    loss_pos   = F.mse_loss(pred_motion[..., 4:7], gt_motion[..., 4:7])
    loss_state = F.l1_loss(pred_motion[..., 7],    gt_motion[..., 7])

    return lam_geo*loss_geo + lam_axis*loss_axis + lam_pos*loss_pos + lam_state*loss_state

可视化:标准姿态 vs 当前状态

import matplotlib.pyplot as plt

def visualize_articulated_object(obj: ArticulatedObject):
    colors = ['#2196F3', '#FF5722', '#4CAF50', '#9C27B0']
    fig = plt.figure(figsize=(14, 6))

    ax1 = fig.add_subplot(121, projection='3d')
    ax1.set_title('Canonical Pose (θ=0)')
    for i, pts in enumerate(obj.part_points):
        ax1.scatter(*pts.T, c=colors[i%4], s=2, alpha=0.6, label=f'Part {i}')
    ax1.legend(markerscale=4)

    ax2 = fig.add_subplot(122, projection='3d')
    ax2.set_title('Current Articulation State')
    for i, pts in enumerate(obj.apply_kinematics()):
        ax2.scatter(*pts.T, c=colors[i%4], s=2, alpha=0.6, label=f'Part {i}')
    for j in obj.joints:  # 画出关节轴
        ax2.quiver(*j.position, *j.axis, length=0.4, color='red', linewidth=2)
    ax2.legend(markerscale=4)

    plt.tight_layout()
    plt.savefig('articulated_result.png', dpi=150)
    plt.show()

# 示例:抽屉(棱柱关节,沿 Z 轴拉出 0.4 单位)
body   = np.random.uniform(-0.5, 0.5, (300, 3)) * np.array([1,1,0.3])
drawer = np.random.uniform(-0.3, 0.3, (200, 3)) + np.array([0, 0, 0.35])
joint  = Joint('prismatic', axis=np.array([0,0,1]),
               position=np.array([0,0,0]), parent_id=0, child_id=1, state=0.4)
visualize_articulated_object(ArticulatedObject([body, drawer], [joint]))

预期输出:左图为标准姿态(抽屉未拉出),右图为拉出 0.4 单位的状态,红色箭头标注关节轴方向。

实验

数据集说明

PartNet-Mobility 是这个方向最重要的基准:

属性 说明
物体类别 46 类(柜子、门、冰箱、机械臂等)
模型数量 ~2500 个带关节标注的 CAD 模型
关节类型 旋转关节、棱柱关节
标注内容 部件分割 + 关节轴/位置/状态范围
数据格式 URDF + 部件 mesh

类别分布严重不均——柜子、门类样本多,特种机械少。训练时需要关注小类别的表现。

定量评估

方法 关节轴误差↓ 关节位置误差↓ 状态误差↓ 推理延迟
MonoArt ~8° ~0.05m ~0.08 ~50ms
多视角基线 12° 0.08m 0.12 需多帧
检索拼装方法 15° 0.12m 0.15 ~200ms

关节轴误差用角度偏差衡量,状态误差为归一化绝对误差。

工程实践

常见坑

坑1:标准空间对齐不一致

# 错误:直接比较预测点云和 GT(关节状态差异会污染几何损失)
loss = chamfer_distance(pred_pts, gt_pts)

# 正确:先把 GT 也变换到标准空间再比较
gt_canonical = inverse_kinematics(gt_pts, gt_joints)
loss = chamfer_distance(pred_pts_canonical, gt_canonical)

坑2:关节轴方向二义性导致梯度反转

# 错误:L2 loss,a 和 -a 物理等价但梯度方向相反
loss_axis = F.mse_loss(pred_axis, gt_axis)

# 正确:用余弦相似度绝对值
loss_axis = 1.0 - torch.abs(F.cosine_similarity(pred_axis, gt_axis, dim=-1)).mean()

坑3:部件数量不固定

真实场景中不同物体部件数量不同,用固定 num_parts 训练会出现部件配对错误。解决方案:推理时用 Hungarian matching 做预测-GT 的最优配对,而不是按索引对齐。

实际部署考虑

  • 训练硬件:完整 PartNet-Mobility 训练需要至少 24GB 显存(A100/3090)
  • 推理速度:~50ms/张(RTX 3090),约 20fps——对实时机器人控制偏慢
  • 精度上限:关节轴 8° 误差在精密操作(如拧螺丝)中不可接受,大范围操作(开门)基本够用

数据采集建议

  • 在自有数据上微调时,用深度相机 + ARKit/ARCore 采集多视角 RGBD 辅助生成伪标签
  • 关节状态训练样本要覆盖全范围(0~100% 开合度),不要只在极端状态上采集
  • 对小类别做数据增强(随机旋转、缩放、点云噪声)

什么时候用 / 不用?

适用场景 不适用场景
单张 RGB 输入(移动端、边缘计算) 需要毫米级精度的工业场景
PartNet-Mobility 覆盖的物体类别 柔性物体(布料、软体机器人)
机器人大致操作规划 部件间有复杂接触约束的场景
快速场景数字化 超过 8 个部件的复杂机械

与其他方法对比

方法 核心思路 优点 缺点
MonoArt 单目渐进推理 高效,无需模板 跨类别泛化差
ANCSH 多视角 NOCS 几何精度高 需要多帧输入
DITTO 视频交互观察 无监督关节发现 需要物体被操作的视频
GAPartNet 检索拼装 可解释性强 依赖模板库
RPM-Net 点云配准 对遮挡鲁棒 需要深度图

我的观点

单目铰接体重建是具身智能(embodied AI)的一个关键缺口。机器人要从单张图像快速理解可操作物体的结构,这正是 MonoArt 类方法的定位。

离实际应用还有几个关键障碍:

  1. 类别泛化:在未见过的物体类别上重建质量大幅下降。零样本泛化是核心挑战,可能需要结合大规模视频预训练来建立更好的运动先验。

  2. 精度天花板:8° 的关节轴误差对粗操作够用,对精密任务不够。单目的信息量限制了精度上限,除非融合语言先验(”这是冰箱门,铰链通常在左侧”)。

  3. 遮挡鲁棒性:被遮挡的关节需要靠先验补全,容易出现系统性错误。

值得关注的开放问题:能否用无标注视频数据(观察物体被操作的过程)替代昂贵的 3D 标注?如何把端到端的机器人操控成功率作为评估指标,而不仅是 PartNet 上的几何误差?

论文链接:MonoArt arXiv:2603.19231