论文解读与源码复现|Functionality Understanding and Segmentation in 3D Scenes:让语言模型"读懂"任务并分割三维场景中的功能部件(CVPR 2025)

论文首页题目与摘要截图

前言

试想你对一台家用机器人说:"把电视上方那个柜子最下面的抽屉打开。"这句话里出现了一个明确的物体——"柜子",一个明确的空间关系——"电视上方",一个明确的操作——"打开"。但机器人真正需要伸出手去触碰的东西,在这句话里一个字都没提:它要去抓的是那个抽屉的把手(handle)或者旋钮(knob)。如果这台机器人只知道"柜子"这个词的视觉对应物,它大概率会把整块柜体分割出来,然后傻站在原地。

这就是 CVPR 2025 的 Highlight 论文 《Functionality understanding and segmentation in 3D scenes》 要解决的问题。作者来自意大利 Fondazione Bruno Kessler(FBK)与特伦托大学,提出了一个名为 Fun3DU 的方法。它要做的不是传统的"按名字找东西",而是从一句自然语言任务描述出发,先在语义层面推理出真正需要交互的功能性物体,再在三维点云上把这个物体的精细几何(几个厘米大小的把手、按钮、旋钮)精确分割出来。

为什么说这个问题比传统的开放词汇三维分割(Open-Vocabulary 3D Segmentation,简称 OV3DS)难得多?论文在引言里给出了三个层次的困难:

  1. 语义鸿沟。任务描述中提到的物体往往不是要操作的物体。想开灯,你要找的是"开关";想调音量,你要找的是"按钮"。要跨越这道鸿沟,模型必须具备世界知识(world knowledge)——"灯的开关一般装在门边""电视下面那个盒子的音量靠按钮控制"。
  2. 尺度鸿沟。功能性物体极其细小,一个把手在整个房间的点云里可能只占万分之几的点。而主流的 3D 分割骨干网络(如 Minkowski 卷积网络)是在 ScanNet、Matterport3D 这类偏向家具体量的数据集上预训练的,天生对小部件不敏感。
  3. 歧义鸿沟。同一个场景里可能有多个外观几乎一样的把手——柜子的、床头柜的、门的、衣柜的。任务描述里"电视上方"这个从句,就是把它们区分开的唯一线索。这要求模型做空间推理,而不只是语义匹配。

Fun3DU 的思路非常干净:既然 3D 数据量比 2D 少好几个数量级、不足以训练出真正理解世界细微差别的模型,那就干脆不训练(training-free)。它把全部推理负担交给预训练的语言模型(LLM)和视觉语言模型(VLM),让它们在 2D 图像上完成"理解",再用相机内外参把 2D 结果"抬升"(lift)回 3D 点云。整条流水线由四个模块串联:LLM 思维链解析任务、开放词汇检测定位上下文物体并挑选视角、VLM 点选 + 可提示分割器出精细掩码、多视角一致性投票生成最终 3D 掩码。

最终在唯一的评测基准 SceneFun3D 上,Fun3DU 在 split0 取得 AP25 = 33.3、mIoU = 15.2,在 split1 取得 AP25 = 23.1、mIoU = 11.5。论文原文强调它是唯一使 AP25 超过 0.4 的方法(表内数值为百分制,故 0.4 对应 40%,此处论文表述与表格数字存在单位口径差异,详见后文"关键实验结果"一节的说明),并相比最强基线 OpenMask3D 在两个 split 上分别高出 32.9 与 23.1 个点。这些数字后面会逐项展开。

本文的组织方式:先拆方法,再讲数据集与实验,然后逐条核对关键结果,接着解释"为什么这样设计有效"以及"它在哪里会失效",最后是完整的源码复现路径分析——官方仓库 50 个 Python 文件的真实结构、四个阶段脚本的原文命令、关键代码片段,以及我在读码过程中发现的若干论文描述与仓库实现不完全一致的地方。

方法解析

问题形式化:功能物体与上下文物体

论文在 3.2 节做了非常清晰的符号定义,这套定义是理解全文的钥匙。

设输入为三元组:

  • 场景点云 $C={c_i}_{i=1}^{C}$,每个 $c_i\in\mathbb{R}^3$;
  • 位姿已知的 RGBD 图像集合 $V={v_i}_{i=1}^{V}$,同一个场景通常有上千张;
  • 任务描述 $D$。

然后定义两类物体:

  • 功能物体(functional object) $F$:最终要在 3D 中分割出来的、需要被机械臂实际操作的物体,例如把手、旋钮、按钮。
  • 上下文物体(contextual object) $O$:在空间中包含、承载或与 $F$ 相关的物体,例如柜子、电视柜、床头柜。

以论文反复使用的例子为例:$D$ = "open the bottom drawer of the cabinet with the TV on top",则 $F$ = handle(或 knob),$O$ = cabinet。Fun3DU 的输出是 $F$ 在点云 $C$ 上的 3D 掩码 $M$。

这个"双物体"设定是整篇文章最核心的设计。因为 $F$ 本身极度歧义(场景里到处都有把手),必须借助 $O$ 的位置来收窄搜索范围;而 $O$ 本身又太大、太"家具",不是机器人要碰的东西。两者缺一不可。

模块一:LLM 思维链解析任务

第一个模块用 LLM 把 $D$ 分解成可以在图像里定位的元素。作者在这里踩了两个坑,并且都给出了针对性的解法,这部分是方法论中最有"工程味道"的内容。

坑一:缺少停止准则(stopping criteria)。 当问 LLM "open the door" 时,它可能直接返回 "door" 作为 $F$,停在上下文级别;偶尔它又会返回 "hand",把注意力放到"手指要做的动作"上。前者太粗,后者完全跑偏。

坑二:幻觉出无关的上下文物体。 当 LLM 正确识别出 $F$ = handle 时,它可能因为"把手一般装在柜子上"这种先验,把 $O$ 幻觉成 cabinet,而原文说的其实是 door。

解法一:给 LLM 一个角色设定(system message)。 论文定义为"帮助一个机械臂完成任务的 AI 系统",并明确列出机械臂能执行的动作集合。这个动作集合是预定义的,直接取自 SceneFun3D 的标注体系:[rotate, key_press, tip_push, hook_pull, pinch_pull, hook_turn, foot_push, plug_in, unplug]。有了这个约束,"door" 无法被 rotate 或 hook_pull 直接操作,模型自然会往下钻一层到 handle。

解法二:用思维链(Chain-of-Thought,CoT,即让模型先写出中间推理步骤再给结论)拆解。 具体做法是要求 LLM 先列出完成任务需要哪些子动作(task_solving_sequence),再给出 $F$(acted_on_object),以及从顶层物体到部件层的层级路径(acted_on_object_hierarchy)。作者取层级路径的第一个元素作为上下文物体 $O$。

这个设计的巧妙之处在于:CoT 让模型在"写动作序列"的过程中自然地暴露出"我需要先找到电视上方的柜子,再识别最下面那个抽屉,再转动它的把手",层级信息就在这个过程中被显式生成,比直接问"上下文物体是什么"可靠得多。

论文 Figure 3(CVPR 2025,第 24552 页)完整展示了这一轮对话的输入输出,后文会详细剖析。

模块二:上下文物体分割与基于得分的视角选择

拿到 $O$ 之后,模块二做两件事:在所有视角里用开放词汇分割器定位 $O$,然后从中挑出最有信息量的几十个视角。

为什么要挑视角? 一个场景平均 1800 张图,全跑 VLM 推理成本不可接受;更关键的是,很多视角里 $O$ 根本不存在或被完全遮挡,强行让 VLM 在这些图上"点把手",只会产生幻觉。

上下文物体分割。 对第 $k$ 帧,用开放词汇检测 + 可提示分割的流水线(论文用 OWLv2 + RobustSAM)得到若干个掩码 $m_O^n$(因为 $O$ 可能在一个视角里出现多次),每个掩码同时带一个置信度分数 $S_{m_O}^n$。

可见性评分。 这是本文的自创设计。作者用极坐标表示掩码像素相对图像中心的分布:

$$d_{\text{obj}}^n=\left\lVert (x_{\text{obj}}^n,;y_{\text{obj}}^n)\right\rVert,\qquad \alpha_{\text{obj}}^n=\arctan\left(x_{\text{obj}}^n,;y_{\text{obj}}^n\right)$$

即公式 (1),其中 $d$ 是到图像中心的归一化距离($d\in[0,\sqrt{2}]$),$\alpha$ 是方位角。

然后对掩码内所有像素统计 $d$ 与 $\alpha$ 的直方图分布 $P_{d_O}^n$、$P_{\alpha_O}^n$,并与参考的均匀分布 $U_d$、$U_\alpha$ 计算 KL 散度(Kullback-Leibler divergence,衡量两个概率分布差异的非对称度量):

$$S_{d_O}^{n}=D_{KL}!\left(P_{d_O}^{n},\Vert,U_d\right),\qquad S_{\alpha_O}^{n}=D_{KL}!\left(P_{\alpha_O}^{n},\Vert,U_\alpha\right)$$

即公式 (2)。

这个设计背后的几何直觉值得反复咀嚼。 如果把一个物体的掩码看作图像平面上的一团像素,那么:

  • 它离图像中心越近,说明相机"正对着"它,后续投影到 3D 的畸变越小,且它大概率没有被边角的物体遮挡;
  • 它的方位角分布越均匀,说明它"围绕"中心铺开,也就是相机是从它正面看过去的;反之,如果方位角集中在某个扇区,说明物体被挤在画面一侧,很可能只露出一角。

两个分布都接近均匀,意味着"物体居中且完整可见"。论文 Figure 4(第 24554 页)给了两个对比实例:上方一张 $S_O=0.63$($S_m=0.40$、$S_d=0.98$、$S_\alpha=0.75$),下方一张 $S_O=0.45$($S_m=0.30$、$S_d=0.66$、$S_\alpha=0.54$),前者的距离分布与角度分布都更贴近均匀分布,因此得分更高。

三方加权合成。 单掩码总分与视角总分分别为:

$$S_O^{n}=\lambda_m S_{m_O}^{n}+\lambda_d S_{d_O}^{n}+\lambda_\alpha S_{\alpha_O}^{n},\qquad S_O=\max_n\left({S_O^n}\right)$$

即公式 (3) 与 (4)。注意这里掩码级取三项加权和,视角级取该帧所有掩码的最大值——只要有一个实例看得清楚,这一帧就有用。

论文最终取 $\hat{V}=50$ 个最高分视角构成子集 $\hat{V}\subset V$,权重 $\lambda_m=0.5$、$\lambda_d=\lambda_\alpha=0.25$。

这里有一个重要的实验结论:视角的质量远比数量重要。消融实验 3 显示,完全不用视角选择、随机采样 100 张图(比默认的 50 张还多一倍),AP25 反而掉了 12.8 个点。也就是说,多给一倍的计算量还不如把 50 张挑对。

模块三:VLM 点选 + 可提示分割

在选出的 $\hat{V}$ 上分割功能物体。作者的提示词设计非常简洁:

"Point to all the F in order to D"

例如:"Point to all the handles in order to open the bottom drawer of the cabinet with the TV on top."

这里的关键是"带上完整任务描述 D"而不是只给 F。 消融实验(Table 3 第 2 行 w/o D)证明,只问 "Point to all the handles" 会掉 14.4 个 AP25 和 12.3 个 AR25。原因很直观:只给 F 时,VLM 无法区分"电视旁边那个柜子的把手"和"电视上方那个柜子的把手",而这两者外观几乎一致。把 $D$ 一起塞进提示词,等于把空间约束和语义约束同时给了模型。

VLM 的输出不是掩码,而是一组二维点坐标 ${(x_i,y_i)\in\mathbb{Z}^2}$,如果物体不存在则可以返回空集。这些点再喂给可提示分割器(论文用 SAM,Segment Anything Model)生成精细掩码。

为什么用"点选"而不是"框选"或"直接输出掩码"? 论文在实现细节中解释说,选 Molmo 正是因为它的 point-grounding(点定位)能力——它专门在像素级 grounding 数据上训练过,能准确指出"支撑这个答案的像素在哪"。点是最小的空间提示单元,对把手这种细长小物体,点比框更不容易框进背景。作者也实测发现 Molmo 优于 LLaVA 等其他 VLM。

值得注意的是,Molmo 本身其实也能做任务理解(它是个通用多模态模型),但论文明确说明:Molmo 的纯文本输入能力当时尚未开源,所以第一模块只能另外用 LLM(LLama3.1-9B)。

模块四:三维提升与多视角一致性投票

前三个模块都活在 2D。第四个模块负责把 2D 掩码搬回 3D,并且——这是重点——用多视角投票来清除误检。

设第 $k$ 个视角的功能物体掩码为 $m^k$,其中像素 $p^k\in m^k$;设 $\Gamma^k:\mathbb{Z}^2\to\mathbb{R}^3$ 是把第 $k$ 视角像素映射到点云点的 2D-3D 对应函数(由相机位姿、内参、深度图决定),即 $\Gamma^k(p^k)=c_i\in C$。则点 $c_i$ 的得分定义为:

$$s_i=\sum_{k=1}^{K}\left|{p^k\ \text{s.t.}\ \Gamma^k(p^k)=c_i}_{p^k\in m^k}\right|$$

即公式 (5)。

用大白话说:每个 3D 点被多少个视角的"功能物体掩码"覆盖到,它就得几分。这个累加值在点云上构成一张热力图(heatmap),最后归一化到 $[0,1]$,用阈值 $\tau$ 二值化:$M={c_i \mid s_i>\tau}$,论文取 $\tau=0.7$。

多视角一致性为什么有效? 论文在定性分析中给了直观解释:当功能物体离相机较远时,VLM 给的点可能不准,落到下方的抽屉上,产生虚假掩码。但一个真实的把手会在几十个视角里反复被正确命中,而这个错误的抽屉区域只在少数视角被命中,累加后过不了 0.7 的阈值。误差是稀疏且随机的,信号是稠密且一致的——这是所有多视角投票方法的共同前提。

一个实现细节值得强调:提升时还做了遮挡检测(visibility check)。只有当 3D 点投影到某个像素的深度与深度图读数之差小于 $0.25\times$ 该像素深度时,才认为这个点在该视角中是可见的,才允许投票。这避免了"被柜门挡住的把手"在某个视角里被错误计分。

四模块的整体数据流

论文 Figure 2(第 24552 页)用四种颜色标出了这条流水线:绿色是任务理解(LLM + CoT),粉色是父物体分割(OWLv2 + RobustSAM)与得分式视角选择,蓝色是功能物体分割(Molmo + SAM),紫色是点云功能分割(2D-3D 对应 + 多视角一致性)。最左侧一张图因为上下文物体被遮挡而被打了叉,前两张打勾——这个细节直观展示了视角选择在干什么。

整个方法零训练、零微调,所有模型(LLama3.1、OWLv2、RobustSAM、Molmo、SAM)全部冻结。这使得它天然具备零样本迁移能力:换一个场景、换一套家具、换一批任务描述,不需要收集任何新数据。

Fun3DU 四模块总体框图:任务理解、上下文物体分割与视角选择、功能物体分割、点云功能分割(论文 Figure 2,CVPR 2025,第 24552 页)

上图是理解全文结构的最佳入口。从左到右读:左上角是输入的数据(自然语言任务描述 + 3D 场景点云 + 多视角 2D 图像),中间绿色框是"Task description understanding",LLM 通过 CoT 拆出 Contextual object(Cabinets,粉色)与 Functional object(Handle,蓝色);接着粉色框"Parent object segmentation"在三张示例图上定位柜子,最左边那张找到的是空掩码(或严重遮挡),被"Score-based view selection"打了叉,另外两张打勾;蓝色框"Functional object segmentation"只处理被选中的视角,输出带绿色点标记的把手掩码;紫色框"Point cloud functionality segmentation"把 2D 掩码投影成点云热力图(图中色条从 0 到 1),阈值化后得到右下角的最终 3D 掩码。

再下一张图是 LLM 交互的具体内容,它比任何文字描述都更能说明"思维链 + 系统提示"到底是怎么写的。

LLM 思维链解析任务描述的完整对话示例:(上)系统提示词,(中)用户查询与 JSON 格式要求,(下)模型返回的 JSON(论文 Figure 3,CVPR 2025,第 24552 页)

这张图包含三层信息,逐一拆开:

  • 第一层(system):把 LLM 定位成"向机械臂输出 JSON 的 AI 系统",并明确它能执行的动作集合 [rotate, key_press, tip_push, hook_pull, pinch_pull, hook_turn, foot_push, plug_in, unplug]。这九个动作就是前面提到的"停止准则"——因为动作是具体的、作用在部件上的,模型必须一路往下钻到"部件"这一抽象层才能给出合法答案。
  • 第二层(user):查询为 "How do I Open the bottom drawer of the cabinet with the TV on top?" 后面紧跟 JSON schema,要求三个字段:task_solving_sequence(子任务列表)、acted_on_object(要作用的部件名)、acted_on_object_hierarchy(从顶层物体到部件的层级)。
  • 第三层(assistant):返回的四步序列是 "Locate the cabinet with the TV on top" → "Identify the bottom drawer of the cabinet" → "Approach the bottom drawer" → "Rotate the handle of the bottom drawer";acted_on_object = "handle";acted_on_object_hierarchy = ["cabinet", "drawer"]。

请注意最后的层级是两级(cabinet → drawer),而不是三级。作者取第一个元素 "cabinet" 作为上下文物体 $O$,而 "drawer" 这一层信息在 3D 流水线中并没有被显式使用——这是一个真实存在的信息损失点,也是后文"局限"部分会讨论的内容:Fun3DU 只用"柜子在哪"作为先验,没有用"最下面那个抽屉在哪"作为先验,所以在同一个柜子上有很多外观相同的抽屉时,它区分上下抽屉的能力相对有限。

数据集与实验设置

数据集:SceneFun3D

实验全部在 SceneFun3D 上进行。论文明确指出这是当前唯一提供三维场景功能分割标注的数据集(这也是为什么本文没有第二个基准可测——不是作者偷懒,而是任务本身太新)。

关键统计如下:

项目数值
split030 个场景
split1200 个场景
每场景平均 RGBD 图像数1800 张(含内外参)
每场景平均任务描述数15 条(含对应真值掩码)
点云分辨率高分辨率扫描(laser scan)
split1 单场景最大点数1300 万
split0 单场景最大点数800 万

一个容易忽略但对指标解读至关重要的标注细节:一条任务描述可能对应多个物体。论文举的例子是任务为 "Open the bottom drawer",而这个抽屉有两个把手,那么真值掩码会同时包含两个把手。这意味着模型如果只分割出一个把手,召回率就会直接掉一半。这也是后面"基线高召回、零精度"这一现象的部分解释:基线分割出整个柜子,虽然把把手包住了(召回高),但精度极低。

关于 split 命名,官方仓库的 README 给出了一个非常关键的对应关系(原文):

NB: the split0 and split1 splits mentioned in the paper correspond to the validation and training split of SceneFun3D, respectively.

也就是说,论文里的 split0 = SceneFun3D 的 val 划分,split1 = train 划分。这一点在复现时极其重要,因为仓库脚本用的是 dataset.split=val / dataset.split=train,而不是 split0 / split1。搞错这个映射,跑出来的数字就没法和论文对齐。

评测指标

完全沿用 SceneFun3D 的评测协议。由于任务输出是单个掩码(而非实例集合),指标计算方式是先算掩码级精度/召回,再对阈值求平均:

指标含义计算方式
AP25IoU 阈值 0.25 下的平均精度掩码级 Precision ≥ 0.25 的样本比例
AP50IoU 阈值 0.50 下的平均精度掩码级 Precision ≥ 0.50 的样本比例
mAPIoU 从 0.5 到 0.95、步长 0.05 共 10 档的平均上式在 10 个阈值上的均值
AR25 / AR50 / mAR上述三项的召回版本把 Precision 换成 Recall
mIoU平均交并比$\frac{

这里有一个必须说清楚的点:"AP" 在这里不是目标检测式的 PR 曲线下面积。因为每个任务描述只输出一个掩码,不存在"按置信度排序"的多预测,所以所谓 AP 实际上是"掩码级 Precision 在多个 IoU 阈值上的通过率/平均值"。官方代码里的实现正是如此(详见后文源码章节的 utils/evaluator.py),compute_3d_ap 返回的是单掩码精度,compute_mean_recalls 再对阈值轴取平均。理解这一点,才能理解为什么表里会出现 "AR25 = 51.5 而 AP25 = 0.0" 这种看似矛盾的组合——那是"召回够但精度为零"。

实现细节

论文 4.1 节给出的配置可以整理成下表,每一项都能在官方仓库的 configs/config.yaml 中找到对应字段(我核对过,绝大多数一致,少数有差异,后面源码章节会逐条列出):

环节模型 / 设置说明
任务理解LLama3.1-9B,4-bit 量化冻结,仅推理;作者强调 Molmo 的文本输入能力未开源故不能用它
上下文物体分割OWLv2 + RobustSAM开放词汇检测 + 鲁棒分割,构成一条 pipeline
功能物体分割Molmo(点选)+ SAM(出掩码)Molmo 用于其点定位能力,实测优于 LLaVA
视角选择$\lambda_m=0.5$,$\lambda_d=\lambda_\alpha=0.25$,$\hat{V}=50$三项加权,取 top-50
3D 二值化阈值$\tau=0.7$热力图归一化后阈值化
训练无所有模型冻结,零微调

对比基线

论文对比了三个开放词汇 3D 分割方法:

  • OpenMask3D:SceneFun3D 原文里就用过的基线,基于 3D 实例提议 + CLIP 特征匹配;
  • LERF:基于语言嵌入辐射场(Language Embedded Radiance Fields),需要逐场景训练;
  • OpenIns3D:更新的一篇(ECCV 2024),本文作者对其做了适配——把原本在渲染视角上工作的 2D 分割模块,换成 SceneFun3D 提供的真实视角。

关于输入给基线的描述文本,论文说明了一个重要细节:所有基线都输入原始任务描述 $D$,因为作者测试发现原文描述比各种改写版本效果都好。这其实是给了基线一个"公平但不利"的条件——原始描述里全是"打开电视上方柜子最下面的抽屉"这种长句,而 OV3DS 方法本来是为 "cabinet" 这种短名词短语设计的。

关键实验结果

下面五条结论全部取自论文正文与表格,每条都标注了数值出处。

结论一:split0 上全面领先,AP25 达到 33.3,比最强基线高 32.9 个点

Fun3DU 与基线在 split0 上的结果对比(论文 Table 1,CVPR 2025,第 24555 页)

论文 Table 1(split0,30 个场景):

方法mAPAP50AP25mARAR50AR25mIoU
OpenMask3D0.20.20.420.324.527.00.2
OpenIns3D0.00.00.040.546.751.50.1
LERF0.00.00.034.235.136.00.0
Fun3DU7.616.933.327.438.246.715.2

读这张表要注意三件事。

第一,基线的 AP 几乎全部为零。OpenIns3D 和 LERF 的 AP25 都是 0.0,OpenMask3D 只有 0.4。但它们的 AR25 却有 51.5、36.0、27.0。这不是数据错误,而是"分割了整个柜子"的必然结果:柜子完全包住了把手,所以召回(真值点里有多少被覆盖)很高;但预测点里绝大多数不是把手,所以精度为零。论文的原话是"tends to undersegment functional objects"(倾向于欠分割功能物体)——这句话要反过来理解:它们没有欠分割,而是过度覆盖了上下文物体。

第二,mIoU 是唯一能拉开差距的连续指标。Fun3DU 的 mIoU = 15.2,比第二名 OpenMask3D 的 0.2 高出 15 个点。mIoU 不需要跨过离散阈值就能反映"掩码重合得有多好",因而最能体现精细度优势。

第三,Fun3DU 的 AP25(33.3)甚至高于 AP50(16.9)和 mAP(7.6),这是典型的"边界不够完美但中心抓得准"的分布形态。符合直觉:点提示 + SAM 在细长把手上的边界误差是主要误差来源。

关于论文"唯一使 AP25 高于 0.4 的方法"这一表述,需要做一个技术性澄清:表格中 Fun3DU 的 AP25 是 33.3(百分制),即便把 0.4 理解为 40%,结论也只在"唯一非零"这个意义上成立,而非"所有方法都超过 0.4"。从上下文看,作者此处的 0.4 极可能是笔误或单位口径不一致(表中其他数值以百分数呈现)。读者引用时应直接引用表格数字 33.3,这也是本文后续所有论述采取的立场。

结论二:split1 上性能下降但排序不变,AP25 = 23.1

Fun3DU 与基线在 split1 上的结果对比(论文 Table 2,CVPR 2025,第 24555 页)

论文 Table 2(split1,200 个场景):

方法mAPAP50AP25mARAR50AR25mIoU
OpenMask3D0.00.00.01.21.42.60.1
OpenIns3D0.00.00.032.337.139.90.1
LERF0.00.00.023.924.625.10.0
Fun3DU6.112.623.123.932.940.511.5

所有方法在 split1 上都变差,论文给出了明确归因:split1 的场景更复杂,单场景点云最多 1300 万点,而 split0 上限是 800 万点。

但下降幅度的分布极不均匀,这一点很有意思:

  • OpenMask3D 从 AR25 = 27.0 崩到 2.6(降了 24.4 个点),mIoU 从 0.2 到 0.1;
  • OpenIns3D 从 51.5 降到 39.9(降 11.6);
  • LERF 从 36.0 降到 25.1(降 10.9);
  • Fun3DU 从 46.7 降到 40.5(降 6.2,幅度最小)。

论文的解释是:OpenMask3D 主要依赖 3D 编码器(Minkowski 卷积,论文引用 [5]),点云点数翻倍会让 3D 提议模块的感受野和显存压力都出问题;而 LERF、OpenIns3D、Fun3DU 都在 2D 视角上做掩码,对场景点数增长更鲁棒。

这个观察对工程实践很有价值:"在 2D 里分割、再投影到 3D"这条路线天生比分"在 3D 里直接分割"更抗规模膨胀,因为它把复杂度从 $O(\text{点数})$ 转移到了 $O(\text{视角数})$,而视角数是可以靠视角选择主动压下去的。

Fun3DU 相比 OpenMask3D 在 split1 上高出 23.1 个 AP25 点、11.4 个 mIoU 点,与 split0 的 32.9 / 15 属于同一量级,说明优势不是靠 split0 的小规模场景"过拟合"出来的。

结论三:去掉 VLM 换成开放词汇分割器,AP25 从 33.3 掉到 13.9

这是消融实验里跌幅最大的一项,也是全篇最能说明"为什么必须用 VLM"的证据。

Fun3DU 架构组件消融:去 VLM、去任务描述、去视角选择(论文 Table 3,CVPR 2025,第 24557 页)

论文 Table 3(split0)前四行即为架构消融:

组件mAPAP50AP25mARAR50AR25mIoU
1. w/o VLM(改用 OWLv2 流水线)1.94.513.917.323.129.46.3
2. w/o D(简化提示词)3.87.918.919.628.334.49.5
3. w/o O,$\hat{V}=100$(随机采样)5.811.220.524.533.941.811.2
4. Standard7.616.933.327.438.246.715.2

三项消融的相对跌幅(以 Standard 为基准):

变体AP25 跌幅AR25 跌幅mIoU 跌幅
w/o VLM−19.4−17.3−8.9
w/o D−14.4−12.3−5.7
w/o O(随机视角)−12.8−4.9−4.0

w/o VLM 的做法是:把功能物体分割也交给定位上下文物体用的 OWLv2 流水线。但 OWLv2 只能处理有限数量的文本 token,无法吞下完整任务描述,所以作者把描述简化成 $O+F$ 的拼接,例如 "cabinet handle"。这个变体 AP25 只有 13.9,掉了 19.4 个点。论文归因于"开放词汇分割器的推理能力远不及 VLM"——它只能做词汇匹配,无法理解"最下面那个抽屉的把手"这种带空间排序的指代。

w/o D 的做法是:保留 Molmo,但把提示词从 "Point to all the F in order to D" 改成 "Point to all the F."。AP25 掉 14.4 到 18.9。这说明上下文约束是 VLM 提示词中不可省略的部分,不是可选的"锦上添花"。

w/o O 的做法是:完全跳过视角选择,随机采样 $\hat{V}=100$ 张图——注意是 100 张,比默认的 50 张还多。结果 AP25 只有 20.5,掉 12.8。这条实验的设计很讲究:它同时证明了"视角选择有很大价值"和"堆数量没用"。用两倍的算力做随机采样,仍然比精准选 50 张差 12.8 个点。

论文对此的总结非常精炼:"the views quality is far more important than their quantity"(视角质量远比数量重要)。

结论四:只用检测置信度排序视角,AP25 从 33.3 掉到 27.0

视角选择权重超参数消融(论文 Table 4,CVPR 2025,第 24557 页)

论文 Table 4(split0)验证公式 (3) 中三个权重的必要性:

$\lambda_m$$\lambda_d$$\lambda_\alpha$mAPAP50AP25mARAR50AR25mIoU
1. 1006.014.227.026.135.344.313.2
2. 0105.813.727.224.835.942.513.3
3. 0016.315.730.124.736.444.514.3
4. 0.5.56.415.530.824.536.645.214.3
5. .5.25.257.616.933.327.438.246.715.2

四种"残缺配置"的对比揭示了一个清晰的层次:

  • 只用检测置信度($\lambda_m=1$):AP25 = 27.0,掉 6.3。论文解释:忽略掩码在图像中的位置,容易选中"远处或被遮挡的上下文物体"所在的视角,而这些视角里功能物体只是部分可见甚至完全不可见。
  • 只用距离分布($\lambda_d=1$):AP25 = 27.2,掉 6.1。
  • 只用角度分布($\lambda_\alpha=1$):AP25 = 30.1,掉 3.2。
  • 距离与角度等权($\lambda_d=\lambda_\alpha=0.5$):AP25 = 30.8,掉 2.5——是四种残缺配置中最好的。

值得注意的一个非直觉现象:角度分数比距离分数更有用(30.1 vs 27.2),而两者合用(30.8)又好于任一单独使用。论文的解释是:检测置信度反映的是"语义相关性",而距离与角度反映的是"掩码质量(是否居中、是否完整可见)"。三者合起来,才能同时保证"选对了物体"和"看得清楚"。

结论五:视角数减少到 10 张,mIoU 仍高于 10

论文 Figure 6 分析了视角数 $\hat{V}$ 与阈值 $\tau$ 的联合敏感性(该图为曲线图,assets 目录未提供截图,此处仅作文字转述,不虚构具体曲线数值)。文字结论是:

  • 增大 $\hat{V}$ 会让掩码更准,因为多视角共识会抬高正确掩码的分数、压低虚假预测;
  • 即使只给 10 张图,在 $\tau=0.7$ 下 mIoU 仍高于 10;
  • 即使只给 4 张或更少,mIoU 仍高于 7;
  • $\hat{V}$ 超过 50 后收益边际(详见补充材料),因此默认取 50 做算力与精度的折中。

这几条数字说明方法的退化是优雅的:不是"少给几张图就崩",而是"少给几张图慢慢变差"。这对实际部署意义很大——在算力受限的移动平台上,可以把 $\hat{V}$ 压到 10 而不至于完全失效。

机制解释与讨论

实验数字看完了,更值得追问的是为什么。这一节把论文中分散在各处的机理散论收拢起来,加上我从消融数据里读出的推论。

为什么"减一层抽象"是任务理解的关键

回到 LLM 输出的层级 ["cabinet", "drawer"] 与动作 "Rotate the handle",会发现一个有趣的现象:层级里根本没有 "handle"。功能物体是从动作序列的最后一步"Rotate the handle of the bottom drawer"里提取出来的。

这说明作者设计的 schema 实际上隐含了一个三段式推理:任务 → 动作序列 → 动作的作用对象。而 acted_on_object_hierarchy 只负责回答"这个对象属于谁",不负责回答"这个对象是什么"。两者分工明确,反而降低了耦合错误。

这也解释了为什么"缺少停止准则"会出问题:如果问法变成开放的 "what should I interact with?",模型会在抽象层级上漂移——要么停在 "door",要么钻到 "hand"。而一旦把问题锚定在"机械臂能做的九个动作"上,答案空间被强行约束到"能被旋转/按压/钩拉的部件",漂移就被消除了。

这是一个可迁移的提示工程原则:用下游执行器的能力边界来约束上游语义解析的输出空间。 比起写一堆"请只输出部件级别"的指令,直接告诉模型"你只能做这九件事"要有效得多。

为什么极坐标均匀性能够代理"可见性"

$S_d$ 与 $S_\alpha$ 这两个分数初看有些抽象,其实各自对应一个物理直觉:

  • $S_d$(距离分布均匀性)代理的是"完整度"。 如果一个物体的掩码像素距离都集中在某个小区间(比如全都在图像下半部分的 $d\in[0.6,0.8]$),说明物体只露出一条;如果距离从 0 附近铺到最大距离,说明它从中心一直延伸到边缘,通常是完整呈现。作者把参考分布 $U_d$ 定义为 $[0, d_{\max}]$ 上的均匀分布,正是这个意思。
  • $S_\alpha$(角度分布均匀性)代理的是"环绕度"。 如果方位角集中在一个 $30°$ 的扇区内,说明相机是从侧面斜看的;如果铺满 $[-\pi,\pi]$,说明物体围绕着图像中心,相机是正面朝向它的。

两者都是无监督、无需训练、只需一次直方图统计的廉价代理指标。这个设计选择非常务实:它不需要额外跑一个可见性预测网络,也不依赖任何标注,纯粹从掩码的几何形状推断"这个视角好不好"。

但也要看到它的局限:这两个分数对"物体在画面中心但被前景挡住"的情况无能为力。一个被椅子腿挡住一半的柜子,如果掩码本身还算规整,分数依然会高。真正兜底的是后面的多视角一致性投票——挡住的部分在别的视角补回来,被挡视角的错误投票会被阈值过滤。

多视角一致性投票的本质是"时间换精度"

公式 (5) 的累加看起来平淡,但它承担了整个流水线里最重的纠错责任。论文的定性分析给了具体案例:当功能物体离相机较远时,VLM 的点可能不准,落到"下方的抽屉"上,产生虚假掩码。但这类错误具有两个特性:

  1. 位置随机——不同视角下点偏移的方向不同,落到的错误区域也不同;
  2. 重复率低——同一个错误区域很少被多个视角同时误命中。

而真实的功能物体具有相反的两个特性:位置固定、被大量视角反复命中。累加 + 阈值正好放大了"稠密且一致"的信号、压制了"稀疏且随机"的噪声。这与 RANSAC、Hough 投票是同一类思想:用冗余观测对抗单次观测的不可靠性。

代价是计算量随视角数线性增长,且必须做精确的 2D-3D 对应。这就要求深度图和相机位姿是准的——SceneFun3D 提供了高质量 laser scan 与位姿,所以这套方法在它上面成立;在消费级 SLAM 重建出的粗糙点云上,投影误差可能超过物体本身的尺寸。

一个被忽略的耦合:"w/o O" 消融其实同时去掉了两样东西

论文对第 3 行消融的描述是"不使用视角选择模块,随机采样 $\hat{V}=100$ 张图",并说明"这也同时去掉了上下文物体分割模块的影响,因为随机采样不考虑上下文物体是否存在"。

这句话透露了一个重要的架构事实:上下文物体分割模块的存在意义,几乎完全是为了给视角选择提供输入。如果不需要挑视角,模块二的输出在整个流水线里没有第二个用途——它不会被用来裁剪图像、不会被用来约束 VLM 的注意力区域、也不会参与最终的 3D 掩码。

这在工程上是一把双刃剑。好处是模块解耦,可以单独替换或跳过;坏处是**"上下文物体"这个先验只被用了一次,而且用得比较"粗"**——只用了它在不在、清不清楚,没用它的具体位置去做 ROI 裁剪或空间约束。如果让 VLM 只在上下文物体的 bbox 内点选,理论上能进一步降低误点率。作者在结论部分提到的未来工作——"incorporate additional objects mentioned in the task description beyond the contextual object to act as priors"——正是这个方向的延伸。

为什么基线会系统性地"分割成柜子"

三个基线的失败模式高度一致,这本身是个值得解释的现象。原因在于 OV3DS 方法的训练数据偏置:它们的 3D 提议模块(Minkowski 卷积、Mask3D 等)在 ScanNet、Matterport3D 上训练,这些数据集的实例标注基本都在"家具"粒度——一张桌子、一个柜子、一把椅子。一个 3 厘米的把手在这类数据里根本不构成一个"实例",模型从来没有学过"把手是独立物体"这件事。

所以当输入 "cabinet handle" 时,3D 提议模块倾向于给出最接近"训练分布"的提案,也就是整个柜子。而 2D 模块给出的 CLIP 特征匹配也无法把"handle"这个词和柜子表面的一小块金属条对应起来。

论文的破局方式是绕开 3D 提议模块:不在 3D 里找实例,而是在 2D 里让 VLM 点出位置,让 SAM 出掩码,再投影回 3D。SAM 是在 SA-1B(1100 万张图、10 亿个掩码)上训练的,它的"物体"概念比 ScanNet 细得多,能自然地把把手从柜门上切下来。这是"2D 基础模型的粒度优势"对"3D 数据集偏置"的一次胜利。

局限与工程启示

论文自己在结论部分承认的局限只有一句,但结合定性结果与消融,可以把局限拆得更细。

论文明示的局限

"Fun3DU is capable of accurate functionality segmentation, but errors can occur when the location of the contextual object alone is insufficient to identify the functional object."

即:光靠"上下文物体在哪"不足以定位功能物体时,就会出错。作者给出的未来方向是把任务描述里提到的更多物体(不只是上下文物体)也当作先验。

定性结果暴露的三类具体失败

论文 Figure 5(第 24556 页)分析了五个案例,其中三个是失败案例,分别对应三种不同的失效机制:

Fun3DU 与基线的定性对比,含失败案例分析(论文 Figure 5,CVPR 2025,第 24556 页,点云围绕功能物体裁剪以利观察)

以下按论文原文描述转述(图中各列均标注了掩码级 Prc / Rec / IoU):

  • 第四列(散热器旋钮,radiator dial):Fun3DU 没能准确分割功能物体,反而分割了上下文物体的一部分。论文归因为功能物体尺寸过小 + 纹理与周围区域高度相似。这是一个感知层面的失败——SAM 和 Molmo 都难以把一块和背景同色的小旋钮从面板上分离。
  • 第五列(卧室门):任务描述里的门紧邻一个门板极其相似的衣柜,Fun3DU 分割了衣柜的把手而不是门的把手。LERF 与 OpenIns3D 反而正确找到了卧室门,但它们的掩码没有精确定位到把手上。这是一个纯视觉歧义导致的失败——在 2D 图像上,两扇门看起来几乎一样,而"bedroom"这个语义线索在单张图里可能无法区分。这类失败本可以由 3D 空间上下文(卧室门的位置、朝向)解决,但 Fun3DU 的 3D 阶段只做投票、不做推理。
  • 第二列(床头柜把手):Fun3DU 基本正确,但在柜体上出现了虚假掩码。论文归因为 VLM 在物体较远时给出的点不准,落到了下方的抽屉上。作者同时指出,多视角一致性模块过滤掉了大部分这类错误——这正是它存在价值的直接证据。

此外论文提到第一个案例(电视柜下方的音响按钮)中,基线能定位电视柜却找不到下面的按钮,而 Fun3DU 能较好地分割功能物体。

从代码与论文之间读出的隐含局限

结合后文源码分析,还有几条论文未明说但客观存在的约束:

  1. 强依赖高质量位姿与深度。多视角投票要求投影误差远小于物体尺寸(把手宽度可能只有 3-5 厘米)。任何 SLAM 漂移都会直接摧毁精度。
  2. 单场景算力开销大。1800 张图里先跑一遍全量 OWLv2 + RobustSAM(模块二要在所有视角上做上下文物体分割),再对 50 张跑 Molmo-7B + SAM-ViT-H,最后做 50 次投影。Molmo-7B 单张推理就不便宜,一台单卡的机器处理一个场景要按小时计。
  3. 对任务描述的表述形式敏感。w/o D 的消融已经证明,换一种问法就掉 14 个点。这意味着如果实际用户的表述与 SceneFun3D 的标注风格差异较大(比如口语化、含指代代词),效果可能显著退化。
  4. 层级信息利用不充分。如前所述,LLM 输出的 "drawer" 这一层被丢掉了。在"同一个柜子有五个外观相同的抽屉"这类场景中,模型只能靠 VLM 自己从图像里分辨"最下面那个",没有显式的空间先验辅助。

工程启示

如果要在一个真实项目里借鉴这套思路,我认为有四条可以直接拿走:

第一,把"理解"与"分割"解耦。 用 LLM 做语义解析、用 VLM + 基础分割器做像素级定位、用几何做 3D 融合,三层各自用最强的现成模型,不训端到端。这样任何一层升级都能立刻受益,也便于定位错误来源(是理解错了、还是点错了、还是投错了?)。

第二,用"下游能力"约束"上游输出"。 九个动作集合这个技巧值得借鉴到任何"LLM 驱动执行器"的场景:与其写长篇格式要求,不如把执行器的能力清单交给模型,让它在能力边界内做选择。

第三,用几何统计做廉价的视角筛选。 极坐标直方图 + KL 散度这个组合几乎零成本,却换来了 12.8 个 AP25 点。任何需要从上千视角里挑几十个的下游任务(3D 重建、NeRF 训练、视觉定位)都可以复用。

第四,永远给 2D 结果加一层多视角投票。 单视角的 VLM 输出是不可信的,但跨视角的共识非常可信。这个"冗余观测"的范式比任何后处理去噪都更根本。

源码复现

⚠️ 复现路径/未运行:以下为源码结构与预期运行路径分析,非实际运行结果

本节所有目录结构、文件路径、命令与代码片段均直接来自官方仓库 https://github.com/tev-fbk/fun3du(本地副本位于 复现代码/288-fun3du/)的真实文件,未做任何改写或推测补全。

仓库真实目录结构

仓库根目录(list_dir 实测):

288-fun3du/
├── configs/
│   └── config.yaml
├── scripts/                 # 16 个 .py,数据集准备与 SceneFun3D 工具
├── static/                  # 5 个 .css、3 个 .png、1 个 .txt(项目主页资源)
├── utils/                   # 29 个 .py
├── _repo_info.json
├── evaluate.py
├── index.html
├── README.md
├── requirements.txt
├── run_detection.py
├── run_lifting.py
├── run_llm.py
└── run_molmo.py

utils/ 子目录展开:

utils/
├── benchmark/               # 12 个 .py,SceneFun3D 官方评测工具(含 eval_utils/)
│   ├── eval_utils/          # eval_script_inst.py, util.py, util_3d.py
│   └── utils/               # box_utils.py, eval_utils.py, pc_utils.py,
│                            # rotation.py, taxonomy.py, tenFpsDataLoader.py,
│                            # visual_utils.py, __init__.py
├── sun3d/                   # 10 个 .py,SUN3D 数据解析与 2D-3D 映射
│   └── fusion_util.py       # 含 PointCloudToImageMapper
├── __init__.py
├── evaluator.py             # Segment3DEvaluator
├── hf_models.py             # Molmo / OWLv2 / SAM / GroundingDINO 封装
├── io.py                    # 视角采样与掩码索引读取
├── metrics.py               # 极坐标可见性评分、AP/AR/mIoU
├── misc.py
└── pcd.py

scripts/ 子目录(16 个 .py):

scripts/
├── make_video_list.py
├── sun3d/
│   ├── data_asset_download.py
│   ├── demo_dataloader_lowres.py
│   ├── demo_eval.py
│   ├── download_data_opensun3d.py
│   ├── project_color_on_laser_scan.py
│   ├── eval/
│   │   ├── create_example_submission.py
│   │   ├── evaluate.py
│   │   ├── prepare_gt_val_data.py
│   │   └── eval_utils/  (benchmark_labels.py, eval_script.py, rle.py, util.py, util_3d.py)
│   └── download_utils/  (download_data.py, split_csv_into_chunks.py)

官方的关键设计是四脚本对应论文四模块,每个阶段把中间结果落盘,因此可以反复调整后两个阶段的配置而不必重跑前两个。README 原文:

Fun3DU is divided in 4 scripts, each executing one of the 4 steps mentioned in the paper (LLM preprocessing, Context object segmentation, Functional object segmentation, and multi-view agreement). The intermediate results will be saved in the dataset folder for the LLM processing and the contextual object segmentation steps, and in a specific experiment folder for the remaining two steps. This allows to try the method with various configurations without rerunning all steps.

数据集准备(README 原文)

NB: the split0 and split1 splits mentioned in the paper correspond to the validation and training split of SceneFun3D, respectively.

  1. 创建数据集根目录 $ROOT(脚本假定为 data/scenefun3d/)
  2. 下载 SceneFun3D 原始数据集文件列表,放入 $ROOT
  3. SceneFun3D 只提供单个划分文件,需要用脚本拆成两份:
python scripts/make_video_list.py train
python scripts/make_video_list.py val
  1. 下载两个划分的资产(原文命令):
python scripts/sun3d/data_asset_download.py --split custom --video_id_csv $ROOT/benchmark_file_lists/val_set.csv --download_dir $ROOT/val --dataset_asset laser_scan_5mm crop_mask annotations descriptions hires_wide hires_wide_intrinsics hires_depth hires_poses

python scripts/sun3d/data_asset_download.py --split custom --video_id_csv $ROOT/benchmark_file_lists/train_set.csv --download_dir $ROOT/train --dataset_asset laser_scan_5mm crop_mask annotations descriptions hires_wide hires_wide_intrinsics hires_depth hires_poses

注意 laser_scan_5mm 是 5 毫米分辨率的激光扫描,这解释了论文中"高分辨率点云"的说法,也解释了 split1 单场景能到 1300 万点。

环境安装

README 在这一节是明确未完成的,原文只有标题行加两行正文:

TODO. requirements.txt show a partial list of the required packages.

也就是说,官方没有给出完整的安装说明,requirements.txt 自称只是一个"部分列表"。实测该文件共 96 行,关键条目包括:

类别关键条目
深度学习torch==2.4.1+cu118、torchvision==0.19.1+cu118、transformers==4.44.2、accelerate==1.0.0
配置管理hydra-core==1.3.2、omegaconf==2.3.0
LLM 调用ollama==0.3.3
3D / 几何open3d==0.18.0、trimesh==4.5.1、open_clip_torch==2.28.0
数值numpy(未锁版本)、scipy==1.14.1、scikit-image==0.24.0、numba==0.60.0
评测pycocotools==2.0.8、lightning==2.5.0.post0
可视化matplotlib==3.9.2、plotly==5.23.0

requirements.txt 里还混入了大量 Jupyter 相关包(jupyter==1.1.1、jupyterlab==4.2.5 等约 20 行)与 wandb、tensorboard,这些在推理流程中并不需要,属于从开发环境直接 pip freeze 的产物。

Molmo 依赖的注意事项:代码里用 AutoProcessor.from_pretrained("allenai/Molmo-7B-D-0924", trust_remote_code=True) 加载,需要 transformers 版本足够新(4.44.2 可用),且首次运行会从 HuggingFace 拉取约 15GB 权重。torch_dtype=torch.bfloat16 + device_map="auto" 意味着至少需要一张 24GB 显存的卡(A10/A100/4090 级别)。

四个阶段的实际运行命令

README 给出的完整命令序列如下(原样摘录)。

阶段 1:LLM 预处理(LLama3.1)

This will generate a .json file for each visit, containing the results of the LLM reasoning on each description.

  1. 从 https://ollama.com 下载 ollama
  2. pip install ollama
  3. 运行 ollama serve 启动后端(默认 localhost:11434)
  4. 另开一个终端运行 ollama pull llama3.1
  5. 若端口不是 11434,修改 run_llm.py 第 8 行的 OLLAMA_PORT
  6. 执行:
python run_llm.py dataset.root=$ROOT dataset.split=val llm_type=llama

(注:README 写"第 8 行",实际在当前版本代码中 OLLAMA_PORT = 11434 位于 run_llm.py 第 11 行,这是一个小的文档漂移。)

阶段 2:上下文物体分割(OWLv2 + RobustSAM)

python run_detection.py dataset.root=$ROOT dataset.split=val llm_type=llama mask_type=standard

阶段 3:功能物体分割(Molmo)

Choose an exp_root to save the experiments intermediate results (exps by default).

python run_molmo.py dataset.root=$ROOT dataset.split=val llm_type=llama mask_type=standard exp_name=$EXP

By default, intermediate results will be saved in exps/$EXP/frames. This can be changed in the config.

阶段 4:多视角一致性(3D 提升)

python run_lifting.py dataset.root=$ROOT dataset.split=val mask_type=standard exp_name=$EXP

By default, point cloud data will be saved in exps/$EXP/pcds. This can be changed in the config.

评测

python evaluate.py dataset.root=$ROOT dataset.split=val exp_name=$EXP threshold=0.7

By default, this will evaulate with point cloud data in exps/$EXP/pcds. This can be changed in the config.

配置文件真面目

configs/config.yaml 全文只有 22 行,是整个仓库的参数中枢:

exp_root : exps   # path to the folder with experiments
exp_name : debug  # experiment name, folder in exp_root/
device : cuda
debug_viz: False

dataset:
  root : data/scenefun3d # root to scenefun3d dataset
  split: val  # val or train
  start:  # optional values, to set start and end of the visit splits.
  end:

frame_folder: frames # name of the frame folder inside the exp folder, where data about molmo masks is stored
pcds_folder: pcds    # name of the pcd folder inside the exp folder, where data about final 3D masks is stored
llm_type: llama      # name of file with LLM reasoning Chain-of-Thought data, e.g. data/scenefun3d/val/420673/420673_{LLM_TYPE}_cot.json
mask_type: owl2_rsam # name of folder with contextual objs detection e.g. data/scenefun3d/val/420673/42445198/{MASK_TYPE}

frame_sampling:
  mode: score_mean   # samples frames according to the score
  n: 50              # number of frames to sample

molmo_prompt: original # default, synonims. Only influences molmo
threshold: 0.7         # used in evaluation 

逐项对照论文,能看到几个默认值不一致的点,后面会统一列表。

关键代码一:LLM 思维链解析(run_llm.py)

这是模块一的全部实现,仅 80 余行。核心是 get_LLM_response(第 14–31 行):

def get_LLM_response(statement, query):
    client = ollama.Client(host=f"localhost:{OLLAMA_PORT}")
    response = client.chat(
        model="llama3.1",
        messages=[
            {
                "role": "system",
                "content": "You are an AI System that has to provide json files to a robotic system so that it can interact with our physical world, based on a natural language prompt.\nIn particular, you have to help the robot in identify which object parts it has to interact with to solve particual tasks.\n its set of possible actions are [rotate, key_press, tip_push, hook_pull, pinch_pull, hook_turn, foot_push, plug_in, unplug]",
            },
            {
                "role": "user",
                "content": statement.format(query=query),
            },
        ],
        options={"temperature": 0},
    )

    return response["message"]["content"], response

功能说明:

  • system prompt 与论文 Figure 3 的上半部分逐字一致(包括原文拼写错误 particual),九个动作列表也完全一致,说明论文中的示意图是真实运行截图而非示意。
  • options={"temperature": 0} 保证解析结果可复现,这对一个需要批次跑 3000+ 条描述的任务是必要的。
  • 注意这里用的是 ollama 的 llama3.1 标签,默认是 8B 版本,而论文写的是 LLama3.1-9B with 4-bit quantization。这是一个真实差异(详见后文差异列表)。

用户提示词模板位于 main() 内(第 37–46 行):

    statement = """How do I {query}?
respond directly with only the json with the following format.
{{
"prompt": a string with the prompt,
"task_solving_sequence": a list of strings with the description of what I have to do to accomplish the task described by the prompt, subdivided in subtasks,
"acted_on_object": a string with the name of the object part on which I have to act
"acted_on_object_hierarchy": a list of object parts from the top level object to the object part,
}}

"""

这里与论文有一处值得注意的差异:论文 Figure 3 的 JSON schema 只有三个字段(task_solving_sequence、acted_on_object、acted_on_object_hierarchy),而代码里多了一个 "prompt" 字段要求模型回填原始 prompt。这个字段恰好被下游依赖——run_molmo.py 第 26 行 original = llm_annot["prompt"] 直接读取它来构造 "Point to all the F in order to D" 中的 $D$。也就是说,VLM 提示词里的任务描述 D 是 LLM 回写的版本,而不是数据集里的原始文本。如果模型回写时改写了措辞,提示词就和真值描述产生了偏移。这是一个论文中完全没有提及的实现细节。

结果解析与落盘(第 63–78 行):

        for desc_id, query in descs.items():
            response = get_LLM_response(statement=statement, query=query)
            try:
                if '```' in response[0]:
                    json_dict = json.loads(response[0].split('```')[1])
                else:
                    json_dict = json.loads(response[0])
                json_data.append(json_dict)
            except Exception as e:
                print(f"error {e}")
                json_data.append({})

        new_path = f"{args.dataset.root}/{args.dataset.split}/{visit_id}/{visit_id}_{args.llm_type}_cot.json"

        with open(new_path, "w") as out_f:
            json.dump(json_data, out_f, indent=4)

健壮性分析:解析失败时推入空字典 {} 而不是中断,下游拿不到 acted_on_object 时会走 except 分支打印警告并跳过,这保证了 3000 条描述里偶尔几条格式错误不会让整批任务崩溃。但代价是这些失败样本会被静默记为"空预测",计入评测时会拉低召回,而错误日志只打在 stdout 里,容易被忽略。

另外,json_data 是按 descs.items() 的迭代顺序追加的列表,而下游 data_parser.get_llm_data 与 get_descriptions 通过 zip 对齐,这意味着两者的排序必须严格一致。这是一个隐式契约,代码里没有任何断言保护。

关键代码二:上下文物体检测与落盘(run_detection.py)

run_owl2_rsam(第 109–211 行)对每个 visit 的每个 video 批量跑检测。模型初始化(utils/hf_models.py 第 291–301 行):

def init_detection(device):

    #  text_config = {'max_position_embeddings':128}
    owl_model = Owlv2ForObjectDetection.from_pretrained(
        "google/owlv2-base-patch16-ensemble",  # ,config=Owlv2Config(text_config)
    ).to(device)

    owl_processor = AutoProcessor.from_pretrained("google/owlv2-base-patch16-ensemble")
    sam_model = SamModel.from_pretrained("jadechoghari/robustsam-vit-large").to(device)
    sam_processor = SamProcessor.from_pretrained("jadechoghari/robustsam-vit-large")
    return owl_model, owl_processor, sam_model, sam_processor

与论文的 "OWLv2 + RobustSAM" 完全吻合,具体是 owlv2-base-patch16-ensemble 配 robustsam-vit-large。

检测循环里最关键的是把极坐标可见性分数在检测阶段就算好并存入 npz(第 177–207 行):

                    if isinstance(frame_data["labels"], np.ndarray):
                        mod_scores = np.zeros(frame_data["scores"].shape)
                        angle_scores = np.zeros(frame_data["scores"].shape)

                        for i, (label, mask) in enumerate(
                            zip(frame_data["labels"], frame_data["masks"])
                        ):
                            mask = torch.tensor(mask)
                            angle_scores[i], mod_scores[i] = get_mask_score(mask)

                            if label in llm_prompts.keys():
                                # appending the LIST of desc_ids
                                mask_desc_ids.append(llm_prompts[label])
                        frame_data["desc_ids"] = pad_into_array(mask_desc_ids)
                        path = join(...f"{video_id}_{frame_id}.npz")
                        np.savez_compressed(
                            path,
                            masks=frame_data["masks"],
                            labels=frame_data["labels"],
                            scores=frame_data["scores"],
                            mod_scores=mod_scores,
                            angle_scores=angle_scores,
                            desc_ids=frame_data["desc_ids"],
                        )

设计要点:把 $S_d$、$S_\alpha$ 在阶段 2 就算好并缓存,阶段 3 只需读 npz 做加权排序,避免了重复计算。这是四阶段解耦架构带来的实际收益。

一个值得注意的命名陷阱:变量名是 mod_scores(module scores,模长),而论文里叫 $S_d$(distance)。虽然数学上模长就是对原点的距离,但代码变量名与论文符号不同,读码时容易误以为是"模型分数"。同时 get_mask_score 的返回顺序是 (arc_score, mod_score),赋值时写成了 angle_scores[i], mod_scores[i] = get_mask_score(mask)——顺序正确,但极易看反。

关键代码三:极坐标可见性评分(utils/metrics.py)

公式 (1)(2) 的真实实现(第 11–65 行):

def get_image_polar_coords(shape: Tuple[int, int]) -> Tuple[np.ndarray, np.ndarray]:
    """
    Make polar coordinates mask with a fixed size
    """
    h, w = shape
    xs, ys = np.meshgrid(np.arange(0, w, 1), np.arange(0, h, 1))
    xs = (xs - int(w / 2)) / (w / 2)
    ys = (ys - int(h / 2)) / (h / 2)

    arctans = np.arctan2(ys, xs)
    module = np.sqrt(np.power(xs, 2) + np.power(ys, 2))

    return torch.tensor(module), torch.tensor(arctans)


M_1920_1440, A_1920_1440 = get_image_polar_coords((1920, 1440))
M_1440_1920, A_1440_1920 = get_image_polar_coords((1440, 1920))


def get_mask_score(mask: np.ndarray, n_bins: Optional[int] = 30) -> Tuple[float, float]:
    """
    Compute polar coordinate score for the mask
    """
    if mask.shape == (1920, 1440):
        module, arctans = M_1920_1440, A_1920_1440
    elif mask.shape == (1440, 1920):
        module, arctans = M_1440_1920, A_1440_1920
    else:
        print("Must compute for shape ", mask.shape)
        module, arctans = get_image_polar_coords(mask.shape)

    if torch.count_nonzero(mask) > 0:

        m_arctans = arctans[mask == 1]
        m_mod = module[mask == 1]

        hist_arc, _ = torch.histogram(
            m_arctans, bins=n_bins, range=(-torch.pi, torch.pi)
        )
        hist_mod, bins_mod = torch.histogram(
            m_mod, bins=n_bins, range=(0, math.sqrt(2))
        )
        arc_dist = torch.ones(n_bins)

        mod_dist = torch.zeros(n_bins)
        max_mod = torch.max(m_mod)
        max_bin = torch.min(torch.nonzero(max_mod <= bins_mod)[:, 0])
        # get the bin before that, if it isnt the last
        mod_dist[0:max_bin] = 1
        arc_score = entropy(hist_arc, arc_dist)
        mod_score = entropy(hist_mod, mod_dist)

    else:
        arc_score, mod_score = 0.0, 0.0

    return arc_score, mod_score

逐行解读与论文的对应关系:

  1. 坐标归一化:xs = (xs - w/2) / (w/2) 把横向映射到 $[-1,1]$,ys 同理。因此 module ∈ [0, √2],与论文 Figure 4 标注的 "$d_O\in[0,\sqrt{2}]$" 完全一致。
  2. 角度用 arctan2(ys, xs),范围 $[-\pi,\pi]$,直方图 30 个 bin。
  3. 参考分布是硬编码的:arc_dist = torch.ones(n_bins)(全 1,即均匀);mod_dist = torch.zeros(n_bins) 然后把 [0:max_bin] 置 1——这就是论文说的"$d$ 在 $[0, d_{\max}]$ 上均匀"。注意 max_bin 的取法是 torch.min(torch.nonzero(max_mod <= bins_mod)[:, 0]),即找到第一个右边界不小于最大距离的 bin,它之前的所有 bin 权重为 1、之后为 0。这精确实现了"只在掩码实际覆盖的距离范围内要求均匀"。
  4. 用 scipy.stats.entropy 计算 KL 散度——注意 scipy 的 entropy(pk, qk) 会先归一化两个向量,所以 ones 与 zeros/ones 的硬编码可以直接用,无需手动归一化。这就是为什么参考分布写起来这么简洁。
  5. 返回值顺序是 (arc_score, mod_score),即 (角度, 模长),与调用处的 angle_scores[i], mod_scores[i] 对齐。

一个实现细节与论文不一致的地方:论文公式 (2) 把 KL 散度写成 $D_{KL}(P \Vert U)$,$P$ 是观测分布、$U$ 是参考分布。而 scipy 的 entropy(pk, qk) 计算的是 $D_{KL}(\text{pk} \Vert \text{qk})$,代码传参是 entropy(hist_arc, arc_dist),即 $D_{KL}(\text{hist} \Vert \text{uniform})$——方向与论文一致。但要注意 KL 散度在此处越大表示越不相似,而代码直接把 arc_score、mod_score 当作"越高越好"的分数使用。

这里存在一个符号疑点:KL(P‖U) 在 P=U 时取最小值 0,P 偏离 U 时增大。若直接当作可见性分数,则"分布越不均匀分数越高",与论文"越均匀越好"的论述方向相反。而 utils/io.py 第 69–70 行做了一次反转:

    # -1 because these are actually distances.
    all_scores_mod = 1 - np_normalize(all_scores_mod)
    all_scores_angle = 1 - np_normalize(all_scores_angle)

注释明确写了 "these are actually distances",说明作者意识到了方向问题,并在采样阶段用 1 - normalize 翻转。但 run_detection.py 存盘时存的是未翻转的原始 KL 值。因此,凡是从 npz 直接读 mod_scores / angle_scores 的地方,都必须自己做一次翻转——这是一个容易踩的坑,也是论文公式与代码之间缺少一层"符号约定"说明带来的代价。

关键代码四:视角选择与采样(utils/io.py)

sample_scored_frames(第 31–159 行)是模块二的收尾,也是 V̂=50 的落地处。核心加权逻辑(第 68–112 行):

    # -1 because these are actually distances.
    all_scores_mod = 1 - np_normalize(all_scores_mod)
    all_scores_angle = 1 - np_normalize(all_scores_angle)
    all_scores_mask = 0.5 * (all_scores_mod + all_scores_angle)
    choosen_score = 0.5 * (
        all_scores_owl + 0.25 * all_scores_mod + 0.25 * all_scores_angle
    )

    sampling = False
    if modality == "random":
        ...
    elif "score" in modality:
        sampling = True
        actual_samples = min(cand_videos.shape[0], n_samples)
        if modality == "score_mean":
            choosen_score = 0.5 * (all_scores_mask + all_scores_owl)
        elif modality == "score_mask":
            choosen_score = all_scores_mask
        elif modality == "score_mod":
            choosen_score = all_scores_mod
        elif modality == "score_angle":
            choosen_score = all_scores_angle
        elif modality == "score_owl":
            choosen_score = all_scores_owl
        else:
            raise RuntimeError("Score of type {} not supported.".format(modality))

        sub_idxs = np.argsort(-choosen_score)[:n_samples]

这里有一个必须指出的差异:

  • 第 72–74 行按公式 (3) 正确写出了 $\lambda_m=0.5,\ \lambda_d=0.25,\ \lambda_\alpha=0.25$ 的加权和,赋值给 choosen_score。
  • 但默认配置 mode: score_mean 会进入 elif "score" in modality 分支,用 0.5 * (all_scores_mask + all_scores_owl) 覆盖掉 choosen_score,其中 all_scores_mask = 0.5 * (mod + angle)。
  • 展开后默认配置实际使用的是:$0.5\cdot S_m + 0.25\cdot S_d + 0.25\cdot S_\alpha$(因为 $0.5 \times 0.5 \times (S_d+S_\alpha) = 0.25 S_d + 0.25 S_\alpha$)。

两者在数学上等价,所以默认路径与论文一致。但第 72–74 行那段"按论文写的"代码在 score_mean 模式下是死代码,只有把 frame_sampling.mode 改成非 random/非 score_mean 的值时才会生效。这种"先算一遍再用另一段等价代码覆盖"的写法说明仓库经历过多次实验迭代,是研究型代码的典型痕迹。

另外注意 all_scores_mod 与 all_scores_angle 的 1 - np_normalize 是按整个 visit 的所有候选掩码做 min-max 归一化后翻转,而 all_scores_owl(检测置信度)没有归一化。由于 OWLv2 的置信度本身在 $[0,1]$,两者量级大致可比,但严格来说公式 (3) 要求三项都在 $[0,1]$,这里存在一个未归一化项与归一化项混用的细节。论文正文没有提及这一点。

关键代码五:Molmo 点选与 SAM 出掩码(run_molmo.py)

提示词构造(第 24–56 行)——这是模块三与论文公式的唯一接口:

def get_molmo_prompt(visit_id: str, desc_id: str, llm_annot: dict, type: str) -> str:

    original = llm_annot["prompt"]
    func_object = None
    try:
        func_object = llm_annot["acted_on_object"]

        # check that this is not a list
        if isinstance(func_object, list):
            func_object = func_object[0]

        if type == "default":
            prompt = "Points to all the " + func_object.lower()

        elif type == "original":
            prompt = "Points to all the {} in order to {}".format(
                func_object.lower(), original.lower()
            )
        elif type == "original_fixed":
            prompt = "Point to all the {}s in order to {}".format(
                func_object.lower(), original.lower()
            )
    except:
        print(f"Parsing error on {visit_id},{desc_id}. Defaulting to original prompt.")
        prompt = "Points to all the objects in order to " + original.lower()

    ctx_object = None
    try:
        ctx_object = llm_annot["acted_on_object_hierarchy"][0].lower()
    except:
        print(f"No contextual object for {visit_id},{desc_id}.")

    return prompt, func_object, ctx_object

三个与论文的对照点:

  1. type="original" 对应论文的 "Point to all the F in order to D",但代码写的是 "Points to all the"(多了个 s),只有 original_fixed 分支才是语法正确的 "Point to all the {F}s"。配置文件默认 molmo_prompt: original——也就是说默认走的是那个有语法错误的版本。这很可能是有意为之:作者可能试过两个版本,发现 Molmo 对某一个更"听话"。论文没有讨论这个细节,但从代码保留了 original_fixed 这个分支来看,作者确实做过对比。
  2. type="default" 对应消融实验 Table 3 第 2 行 "w/o D"(只给 F,不给 D)。
  3. ctx_object 取 acted_on_object_hierarchy[0],与论文"取层级第一个元素作为上下文物体"完全一致。而 hierarchy[1](如 "drawer")在整个仓库里从未被使用——这印证了前文关于"层级信息利用不充分"的判断。

推理与分割(第 59–83 行):

def get_molmo_sam_masks(
    molmo_m, molmo_t, sam_m, sam_t, frame_data, prompt, args
) -> np.ndarray:

    """
    Execute Molmo+Sam masking according to current image and prompt.
    Return N masks obtained by SAM, or None if no objects where found
    """
    img = Image.open(frame_data[0])
    response = inference_molmo(molmo_m, molmo_t, img, prompt)
    h, w = np.asarray(img).shape[:2]

    # points = np.random.randint(200, 1000, size=(3, 2))
    points = extract_points(response, (w, h))

    if points is not None:
        masks = process_sam_prompts(sam_m, sam_t, img, points, batch=1)
        # default setting, each mask counts as one
        scores = np.asarray([1 for _ in masks])

    else:
        masks = None
        scores = None

    return points, masks, scores

点解析(utils/hf_models.py 第 145–171 行)用正则从 Molmo 的自由文本输出里抠坐标:

def extract_points(molmo_output: str, size: Tuple[int, int]) -> np.array:
    """
    Obtained from https://huggingface.co/allenai/Molmo-7B-O-0924/discussions/1
    """
    image_w, image_h = size
    all_points = []
    for match in re.finditer(
        r'x\d*="\s*([0-9]+(?:\.[0-9]+)?)"\s+y\d*="\s*([0-9]+(?:\.[0-9]+)?)"',
        molmo_output,
    ):
        try:
            point = [float(match.group(i)) for i in range(1, 3)]
        except ValueError:
            pass
        else:
            point = np.array(point)
            if np.max(point) > 100:
                # Treat as an invalid output
                continue
            point /= 100.0
            point = point * np.array([image_w, image_h])
            points = all_points.append(point)
    if len(all_points) > 0:
        points = np.stack(all_points, axis=0)
    else:
        points = None
    return points

要点:Molmo 输出的是形如 x1="43.2" y1="61.8" 的相对坐标(0–100 归一化),正则提取后除以 100 再乘以图像宽高。np.max(point) > 100 是越界保护。注释里的链接说明这段代码是从 HuggingFace 社区讨论帖抄来的,不是作者原创。

注意第 77 行 scores = np.asarray([1 for _ in masks])——SAM 返回的每个掩码得分都被硬编码为 1。这意味着多视角投票阶段,每个视角、每个掩码的权重完全相同,没有利用 SAM 的 IoU 预测分数。这与论文公式 (5) 的"计数"语义一致(纯累加),但也意味着模型对自身不确定性的估计被完全丢弃了。

关键代码六:3D 提升与多视角投票(run_lifting.py)

post_process_pcd(第 36–90 行)是公式 (5) 的实现:

    n_points = pcd.shape[0]
    # prob functional mask
    acc_f = np.zeros((n_points), dtype=np.float16)
    c_f = np.zeros((n_points), dtype=np.uint8)

    dataset = zip(
        mask_data["frame_ids"],
        mask_data["video_ids"],
        mask_data["depth_paths"],
        mask_data["intrinsics"],
        mask_data["poses"],
        mask_data["masks_f"],
    )

    for i, (frame_id, video_id, depth_path, intrinsic, pose, mask_f) in enumerate(
        dataset
    ):

        depth = parser.read_depth_frame(depth_path)
        h, w = depth.shape
        whole_mask = np.ones(depth.shape)

        mapper = PointCloudToImageMapper((w, h))

        mapping_fo = mapper.compute_multi_masked_mapping(
            pose,
            pcd,
            np.stack([mask_f, whole_mask], axis=0),
            depth,
            intrinsic,
            "cuda",
        )
        valid_f = mapping_fo[0, :, -1] == 1

        if np.count_nonzero(valid_f) > 0:
            # compute F
            valid_fy = mapping_fo[0, valid_f, 0]
            valid_fx = mapping_fo[0, valid_f, 1]

            acc_f[valid_f] += mask_f[valid_fy, valid_fx]
            c_f[valid_f] += 1

    N_MASKS = mask_data["frame_ids"].shape[0]

    record = {"acc_f": acc_f, "c_f": c_f, "n_views": N_MASKS}

逐行解释:

  • acc_f 是每个点的累加得分(对应公式 5 的 $s_i$),c_f 是该点被多少个视角覆盖的计数。
  • 传入两个"掩码":mask_f(功能物体掩码)和 whole_mask(全 1 掩码)。compute_multi_masked_mapping 会返回两个映射,代码只取第 0 个(即功能物体掩码那个)。
  • valid_f = mapping_fo[0, :, -1] == 1:最后一维是可见性标志,只有通过深度遮挡检验的投影才算有效。
  • acc_f[valid_f] += mask_f[valid_fy, valid_fx]:把该点投影到的像素位置的掩码值累加。因为 mask_f 是 0/1,这里等价于"如果该点落在功能物体掩码内,得分 +1"。

注意一个与论文的微妙差异:论文公式 (5) 写的是 $s_i=\sum_k |{p^k : \Gamma^k(p^k)=c_i}_{p^k\in m^k}|$,即"投影到该点的掩码内像素个数"。由于一个 3D 点投影到某一视角只能落在一个像素上,这个集合的大小在单视角内最多是 1(多个点投影到同一像素时才会 >1,但求和是对每个点单独进行的)。代码的写法 acc_f[valid_f] += mask_f[valid_fy, valid_fx] 正是这个语义——每个视角对每个点的贡献最多为 1。公式与代码一致。

真正的归一化与阈值化发生在 evaluate.py(第 82–97 行):

def post_process_pcd(
    pcd: np.ndarray,
    masks: dict,
    args: DictConfig,
) -> torch.Tensor:
    """
    Converts a point-cloud heatmap in a binary point cloud mask according to evaluation modality.
    """
    n_views = masks["n_views"]
    acc_f = masks["acc_f"]

    p_f = np_normalize(acc_f / n_views)

    pred_mask = np.where(p_f > args.threshold, 1, 0)

    return torch.tensor(pred_mask)

即 $p_i = \text{normalize}(s_i / K)$,其中 $K$ 是视角数(n_views)。np_normalize 是 min-max 归一化(utils/misc.py 第 11–19 行):

def np_normalize(array: np.ndarray) -> np.ndarray:
    """
    Normalizes across first axis probably
    """

    amin, amax = array.min(), array.max()
    m_array = array.copy()

    return (m_array - amin) / (amax - amin)

这里有一个实现与论文的实质差异:论文说"we normalize these counts over the point cloud so that $s_i\in[0,1]$",隐含的是除以最大计数这类与尺度相关的归一化;而代码用的是 min-max 归一化,会先把最小值平移到 0。由于无覆盖点的 acc_f 恒为 0,若场景中存在完全未被任何视角覆盖的点(几乎必然存在),amin = 0,此时 min-max 等价于除以 max。但若某个场景所有点都至少被覆盖一次,amin > 0,那么最少的那个点会被压到 0,造成系统性偏移。

更关键的是:np_normalize 在 amax == amin 时会产生除零(返回 nan 或 inf)。对于"所有点都没被覆盖"的空预测场景,acc_f 全 0,amin = amax = 0,这里没有防护。不过 nonzeros 为 0 时 p_f > threshold 的判断结果仍是全 False,实践中表现为"输出空掩码",不会崩溃,但会产生 numpy 的 RuntimeWarning。

关键代码七:2D-3D 投影与遮挡检验(utils/sun3d/fusion_util.py)

PointCloudToImageMapper.compute_multi_masked_mapping(第 51–104 行)实现了投影 + 深度遮挡判断,这是整个 3D 提升的几何核心:

        world_to_camera = torch.linalg.inv(camera_to_world)
        p = torch.matmul(world_to_camera, coords_new)
        p[0] = (p[0] * intrinsic[0][0]) / p[2] + intrinsic[0][2]
        p[1] = (p[1] * intrinsic[1][1]) / p[2] + intrinsic[1][2]
        pi = torch.round(p).to(torch.int)  # simply round the projected coordinates
        inside_mask = (
            (pi[0] >= self.cut_bound)
            * (pi[1] >= self.cut_bound)
            * (pi[0] < self.image_dim[0] - self.cut_bound)
            * (pi[1] < self.image_dim[1] - self.cut_bound)
        )

        for i, mask in enumerate(mask_list):
            _depth = depth.clone()
            _depth = torch.where(mask <= 0, 0, _depth)
            depth_cur = _depth[pi[1][inside_mask], pi[0][inside_mask]]
            occlusion_mask = (
                torch.abs(
                    depth[pi[1][inside_mask], pi[0][inside_mask]] - p[2][inside_mask]
                )
                <= self.vis_thres * depth_cur
            )
            inside_mask_i = inside_mask.clone()
            inside_mask_i[inside_mask == True] = occlusion_mask

            mapping[i][0][inside_mask_i] = pi[1][inside_mask_i]
            mapping[i][1][inside_mask_i] = pi[0][inside_mask_i]
            mapping[i][2][inside_mask_i] = 1

要点:

  • 投影:世界坐标 → 相机坐标(world_to_camera)→ 像素坐标(除以 $z$ 后乘内参)。注意这里用 p[2] 直接做除数,没有检查 $z \le 0$,位于相机后方的点会产生负坐标或巨大坐标,随后被 inside_mask 的边界检查过滤掉——属于"靠边界检查兜底"的写法。
  • 遮挡检验:vis_thres = 0.25(构造函数默认值,第 45 行)。只有当 $|d_{\text{depth}} - z_{\text{proj}}| \le 0.25 \cdot d_{\text{cur}}$ 时,才认为可见。其中 depth_cur 是被掩码屏蔽后的深度(掩码外的深度置 0),所以这条判据实质上是"投影深度和观测深度在 25% 相对误差内"。
  • inside_mask_i[inside_mask == True] = occlusion_mask 这一行的写法比较绕:先把 inside 的部分整体替换成 occlusion 结果,等价于 inside_mask_i = inside_mask & occlusion_mask,但不依赖后者与前者长度一致(occlusion_mask 是从 inside_mask 索引出来的,长度更短)。这是对数组形状的妥协写法。

论文只用一句话描述了这部分("we exploit multi-view agreement"),但代码里藏了不少工程细节:25% 的相对深度容差、图像边界的 cut_bound(默认 0)、逐掩码循环而非向量化。这些都会影响最终精度,尤其是 vis_thres 这个 0.25——论文正文完全没有提及,只在补充材料中可能有说明。

关键代码八:评测实现(utils/evaluator.py)

前面提到"AP 不是 PR 曲线下面积",代码是最有力的证据(第 67–80 行):

        # compute ap and relative recalls
        valid_pred = [torch.count_nonzero(pred_mask).item() for pred_mask in pred_masks]
        ap_i = compute_3d_ap(gt_masks, pred_masks)
        ious = compute_mean_iou(gt_masks, pred_masks)
        map = compute_mean_recalls(ap_i, self.AP_TH)
        ap_rec = compute_scores(ap_i, [0.25, 0.50])
        ap_50 = ap_rec[0.50]
        ap_25 = ap_rec[0.25]

        # compute ar and relative recalls
        ar_i = compute_3d_ar(gt_masks, pred_masks)
        mar = compute_mean_recalls(ar_i, self.AP_TH)
        ar_rec = compute_scores(ar_i, [0.25, 0.50])
        ar_50 = ar_rec[0.50]
        ar_25 = ar_rec[0.25]

配合 compute_mean_recalls(utils/metrics.py 第 174–180 行):

def compute_mean_recalls(values: Tensor, thresholds: Tensor) -> Tensor:
    """
    Given a tensor of N scores, and a tensor of M thresholds (assumed incremental), the average recall with varying thresholds is returned for each of the N sample.
    """
    matrix = (values.unsqueeze(1) >= thresholds.unsqueeze(0)).to(torch.float)
    return matrix.mean(dim=1)

以及 self.AP_TH = torch.linspace(0.5, 0.95, 10)(第 31 行)。

完整逻辑:

  • compute_3d_ap 返回的是掩码级精度 $|M\cap M^{gt}| / |M|$(metrics.py 第 195–217 行,positives = pred_mask.count_nonzero(dim=1))。
  • compute_mean_recalls(ap_i, AP_TH) 把每个样本的精度与 10 个阈值比大小,得到 0/1 向量后取均值——这就是 mAP 的真实含义:精度在 10 个 IoU 阈值上的通过率。
  • AP25 / AP50 同理,只是用 0.25 和 0.50 两个阈值。注意这里的 "IoU 阈值" 实际上作用于精度而非 IoU,这是 SceneFun3D 数据集的评测约定。

这解释了为什么"mAP ≤ AP50 ≤ AP25"总是成立(阈值越松通过率越高),也解释了表里 "AR25 = 51.5 / AP25 = 0.0" 的组合——召回可以很高,但精度为零的样本在任何阈值下都不通过。

最终汇总(第 116–125 行):

    def get_latex_str(self):
        """
        Returns mean of each metric in format for a latex table
        """

        means = self.get_means()

        latex_str = f"{self.exp_tag} & {means['mAP']*100:.2f} & {means['AP50']*100:.2f} & {means['AP25']*100:.2f} & {means['mAR']*100:.2f} & {means['AR50']*100:.2f} & {means['AR25']*100:.2f} & {means['mIoU']*100:.2f} \\\\ \n"

        return latex_str

*100 说明了论文表格里为什么是 7.6、33.3 这样的两位数——论文表格是百分制。这也正是前文提到"AP25 高于 0.4"表述口径问题的根源:0.4 对应的应该是 40(百分制),而 Fun3DU 的 33.3 并未达到 40。因此论文 4.2 节这句话在数字上不成立,只能理解为"唯一非零"的宽松表达。这是本文在核对中发现的、论文内部的一处自相矛盾,如实记录在此。

论文与代码的差异清单

把上述散落的对照汇总成表,这些都是如实并列、未做统一的差异:

项目论文描述仓库实现影响
LLM 模型LLama3.1-9B,4-bit 量化ollama 的 llama3.1 标签(默认 8B),无显式量化配置模型规模与量化状态不同,解析质量可能有差异
LLM 输出 schema三个字段四个字段,多一个 promptVLM 提示词中的 D 是 LLM 回写版本,非数据集原文
上下文物体分割的 mask_type未提及论文命令写 mask_type=standard,仓库默认 owl2_rsamREADME 命令与 config 默认值不一致,需显式传参
视角分数权重$\lambda_m=0.5,\lambda_d=0.25,\lambda_\alpha=0.25$第 72–74 行按此实现,但 score_mean 模式下被等价表达式覆盖数学等价,但前者为死代码
分数方向"越均匀分越高"npz 存的是未翻转的 KL 值,需 1-normalize 才符合论文语义直接读 npz 会得到反向分数
3D 归一化"normalize counts so that $s_i\in[0,1]$"min-max 归一化,不是除以最大值amin>0 时存在系统性偏移;极端情况下除零
遮挡阈值未提及visibility_threshold=0.25(相对深度误差)关键几何超参,论文正文缺失
SAM 权重未提及功能物体用 facebook/sam-vit-huge,上下文物体会用 robustsam-vit-large两条流水线用的是不同的分割器
Molmo 提示词"Point to all the F in order to D"默认 original 分支写的是 "Points to all the ...",语法正确的 original_fixed 未被默认使用提示词语法错误版本被当作默认
层级信息取第一个元素作为 $O$一致,且 hierarchy[1] 全仓库未使用与论文一致,但确实浪费了抽屉级信息
OLLAMA_PORT 位置README 说第 8 行实际在第 11 行文档漂移,不影响功能
split 编号split0 / split1config 用 val / train必须在脑中做映射,否则结果无法对齐
安装说明—README 明确写 TODO.,requirements.txt 自称"partial list"环境搭建需自行摸索

复现路径的实操判断

由于没有实际运行(本机无对应数据集与 GPU 环境),以下为基于代码结构的路径可行性分析,不是运行结果:

  1. 阶段 1 最容易跑通。只依赖 ollama,不需要 GPU(llama3.1 8B 在 CPU 上也能跑,只是慢)。3000 条描述 × 单次推理,按每条 2 秒估算约 1.7 小时。建议先用 dataset.start / dataset.end 跑 1 个 visit 验证 JSON 格式。
  2. 阶段 2 是算力瓶颈。要对每个 scene 的全部 1800 张图跑 OWLv2 + RobustSAM,按每批 10 张、每批 1 秒估算,单场景约 3 分钟,230 个场景约 11 小时。这是唯一无法通过配置削减的阶段(s 阶段还没有视角选择)。
  3. 阶段 3 是显存瓶颈。Molmo-7B-D 用 bf16 + device_map="auto",至少 24GB 显存。单场景 50 帧 × 15 条描述 = 750 次推理,是四阶段里最慢的。
  4. 阶段 4 与评测较快。纯几何运算,50 次投影/描述,GPU 上秒级。

最可能卡住的地方(按风险排序):

  • 数据下载:laser_scan_5mm + hires_wide + hires_depth 全量下载体量很大(数百 GB 级),且 SceneFun3D 的下载脚本依赖网络稳定性。
  • transformers==4.44.2 与 Molmo 的兼容性:Molmo 是 2024 年 9 月发布,4.44.2 是同月版本,trust_remote_code 需要联网拉取远端建模代码,国内网络可能失败。
  • torch==2.4.1+cu118 的 CUDA 版本:cu118 对较新的驱动兼容性尚可,但对 Blackwell 架构显卡支持不足。
  • requirements.txt 未锁 numpy 版本:numba==0.60.0 只支持 numpy < 2.0,若 pip 装到 numpy 2.x 会直接崩。建议手动 numpy==1.26.4。

总结

Fun3DU 要解决的问题可以用一句问话概括:"机器人该碰哪里?" 而这句话的答案是藏在一段从不直接提及答案的自然语言里的。

这篇 CVPR 2025 Highlight 的价值,不在于刷新了某个指标,而在于把一个此前没有专门方法的问题定义清楚了,并给出一条足够简洁的基线路径。回顾它的几个关键选择:

第一,"功能物体 / 上下文物体"的二分定义。 这是全文的地基。它把一个模糊的"帮我找东西"变成两个可操作的目标:一个用于收窄搜索(上下文物体),一个用于最终输出(功能物体)。后续所有模块都围绕这个二分展开。

第二,用 LLM 的角色设定解决"抽象层级漂移"。 给出九个具体动作,等于给模型划定了"只能回答部件级"的隐式约束。这个技巧成本极低、收益明确——它把一个"提示工程玄学"问题变成了"接口设计"问题。

第三,用极坐标统计做零成本视角筛选。 距离分布测完整度、角度分布测环绕度,两个直方图 + KL 散度就能从上千张图里挑出最有用的 50 张,且比随机采样 100 张还高 12.8 个 AP25 点。这是本文最"轻"也最"巧"的贡献。

第四,用 2D 基础模型的粒度优势绕开 3D 数据集偏置。 Molmo 点选 + SAM 出掩码,让厘米级的把手第一次成为"可分割的对象",而不是被 3D 提议模块归并进整件家具。

第五,多视角投票作为统一纠错机制。 不管错误来自 VLM 点偏了、还是 SAM 分割错了,只要它在不同视角下不重复,就能被阈值过滤掉。

当然也要看到它的天花板:

  • 它不理解 3D。所有语义理解都发生在 2D,3D 阶段只做几何投票。论文 Figure 5 第五列的失败案例(把衣柜把手当成卧室门把手)本质上是"2D 视角下两扇门长得一样",而这个问题恰恰是 3D 空间上下文(门的朝向、房间划分)能够解决的——但 Fun3DU 没有用。
  • 它只用一个先验。LLM 输出的层级里有 "cabinet → drawer" 两层,代码只用了第一层。"最下面那个抽屉"这个关键约束完全交给 VLM 自己去图像里分辨。作者在结论中已经把"引入更多物体作为先验"列为未来工作,说明他们清楚这是短板。
  • 它的代价不低。零训练省掉了数据标注和训练算力,但把成本转移到了推理:单场景要跑全量 OWLv2、50 次 Molmo-7B、50 次 SAM-ViT-H。相比微调过的一次性前向,这套流水线的推理开销高出一到两个数量级。

不过换个角度看,这个代价在 2025 年是完全值得的。当基础模型的粒度在快速提升(SAM 2、Molmo 后续版本、更强的 VLM 不断出现),一个"把最强调用器串起来"的框架能自动吃到所有升级红利;而一个端到端微调的模型,每次上游升级都要重新收集数据、重新训练。Fun3DU 的价值有一半在于它的架构可演进性。

从更宏观的视角看,这篇论文指向一个正在成形的范式:"3D 任务交给 2D 基础模型理解 + 几何做桥接"。当 3D 基础模型的数据规模还远不及 2D 时(论文开篇就点明"3D data is orders of magnitude smaller than 2D data"),把重活交给 2D、把决策留给几何,是一条务实且可持续的路线。Fun3DU 是这条路线在"功能理解"这个任务上的一个清晰范例。

对工程实践者来说,最值得带走的三条经验是:

  1. 解耦优于端到端,尤其是在数据稀缺、任务定义还在演化的阶段;
  2. 几何先验是免费的监督信号,极坐标、深度一致性、多视角共识都不需要标注;
  3. 用下游执行器的能力边界约束上游 LLM 的输出空间,比任何格式要求都有效。

引用信息

论文标题:Functionality understanding and segmentation in 3D scenes

作者:Jaime Corsetti, Francesco Giuliari, Alice Fasoli, Davide Boscaini, Fabio Poiesi

单位:Fondazione Bruno Kessler(FBK), University of Trento

会议:Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition(CVPR)2025,Highlight Paper

论文页码:CVPR 2025 Open Access 版本,第 24550–24559 页

arXiv:https://arxiv.org/abs/2411.16310 (arXiv:2411.16310)

官方代码仓库:https://github.com/tev-fbk/fun3du

项目主页:https://tev-fbk.github.io/fun3du/

数据集:SceneFun3D,https://scenefun3d.github.io/ (CVPR 2024)

BibTeX:

@inproceedings{corsetti2025fun3du,
  title={Functionality understanding and segmentation in 3D scenes},
  author={Corsetti, Jaime and Giuliari, Francesco and Fasoli, Alice and Boscaini, Davide and Poiesi, Fabio},
  booktitle={Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition},
  year={2025}
}

本文提到的关键预训练模型:

模块模型HuggingFace / 来源
任务理解Llama 3.1(论文:9B 4-bit;代码:ollama llama3.1)Meta / Ollama
上下文物体检测OWLv2google/owlv2-base-patch16-ensemble
上下文物体分割RobustSAMjadechoghari/robustsam-vit-large
功能物体点选Molmoallenai/Molmo-7B-D-0924
功能物体分割SAMfacebook/sam-vit-huge

致谢(论文原文):This work was supported by the European Union's Horizon Europe research and innovation programme under grant agreement No 101058589 (AI-PRISM). We also acknowledge ISCRA for awarding this project access to the LEONARDO supercomputer, owned by the EuroHPC Joint Undertaking, hosted by CINECA (Italy).


本文为论文解读与技术分析,所有实验数值均引自 CVPR 2025 论文原文表格与正文;源码分析基于官方仓库 tev-fbk/fun3du 的实际文件内容,未实际运行。文中标注的"论文与代码差异"为读码对照所得,供复现时参考。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐