构建视频转3D模型管线

我想做一个木书架,为散落在屋里的书找个温馨的地方,而且不需要测量任何尺寸。

而因为我有点疯狂,又处在物理 AI 时代,我想:

为什么不构建一个完整的系统来为我完成这件事呢?

也许我应该拿起卷尺,但那样有什么意思?

首先确保我们能捕获空间、重建它,然后手工悬停在几何体上。

这很棒,看起来很好,不管是点云还是 3D 高斯泼溅(3DGS)。但如果你想真正"确定地"使用它,你的大脑会告诉你:

模型很好,但它回答不了一个简单的问题。

墙在哪里?有多少把椅子?地板面积是多少?

裸的点云/3DGS 只是静静地待在那里,漂亮但没用。这很令人沮丧,因为当你用人类的眼睛看场景时,你能分辨出这些组件。

那么,到底是什么把它变成了机器能用的东西?

单纯的几何体只是一具美丽的尸体。语义才是让房间复活的东西。

是意义,那种你可以查询的意义。当一个点知道自己属于地板时,场景就不再是点云,而变成了一个房间。

过去两年发生了一件改变一切的事。

我们不再需要为每个新物体训练模型。强大的视觉模型(分割器、开放词汇分类器、自监督编码器)已经理解图像了。

新的技能不是训练。而是编排。

如何将预训练模型在图像中的理解路由到 3D 点上——你真正需要的地方?

在本教程中,我们从一段混乱的手机视频开始,将其重建为稠密点云,然后在上面叠加语义:对齐到重力方向,缩放到米制单位,用文字而不是点击来标注,跨每一帧匹配一个勾画的对象,解决不可避免的闪烁,最后切片成可测量的平面图。

而且整个过程中我没有训练任何模型。

是的,整个事情的笑点是:我根据其中一个点云为厨房制作了一件真正的家具,没有测量任何尺寸,而且它完美契合了

所以,如果一台机器能理解一个房间到这种程度,还有什么阻止它理解到足以采取行动的程度呢?

欢迎简述: 嘿,我是 Florent。开始之前有两件事。这不是一个关于分割如何工作的 AI 生成之旅,它是一个真实系统的运作骨架,被拆开让你看到所有节点。所有内容(代码、数据集说明、设计决策,那些我通常会留给课程的东西)都可以直接复制。读一遍,从头到尾,然后回来运行函数。整个东西在开发模式下运行,不需要 GPU 也不需要权重,所以你能看到每个阶段在处理重型模型之前画出的图。如果有帮助,分享它,告诉我你构建了什么。

以下是你将学到的:

  • 一个端到端运行的九步流水线,不需要任何训练。
  • 一切都依赖的那一个想法,以及为什么它让困难的部分变得便宜。
  • 参数选择和失败模式,那种你只能通过破坏东西才能学到的。

1、任务目标

想象一下。你用手机穿过一个房间,随意拍摄,就像你想记住把什么东西放在哪里时那样。

没有测量设备,没有标记,没有精心的重叠。没人会称之为数据。

现在你想要那个随意的扫拍变成一个标注好的、可测量的 3D 场景:地板在这里,墙在那里,那张沙发在它出现的每个地方都有标签,一个你可以读取尺寸的平面图。你是那个拒绝花一周时间在点上点击标签的人,而且你意识到你不需要这样做,因为理解已经存在于你没有训练过的模型内部。

你找到了理论捷径。问题是工作流程能否经受住考验。那么你到底如何连接图像世界和 3D 世界而不让它在接缝处崩溃?

2、提议的工作流程

我把它分成九步,它们形成一个系统,而不是一个清单。每一步都有它的位置,因为下一步依赖于它。

  1. 加载重建结果,关键是像素到点的链接。
  2. 从地板平面对齐点云到重力方向。
  3. 识别房间并从天花板缩放。
  4. 用深度测试构建投影链接。
  5. 用 SAM 提出分割区域并用 CLIP 命名。
  6. 用 DINOv2 跨每一帧匹配一个勾画的对象。
  7. 跨相机投票以解决标签闪烁。
  8. 保留人工参与以修正和传播。
  9. 将结果切片成可测量的平面图。

系统思维笔记: 整个流水线建立在我一直回顾的一个想法上:罗塞塔石碑。那块古老的石碑之所以重要,是因为同一文本以两种语言出现,所以理解一种就解锁了另一种。像素到点的链接正是如此。图像是一种语言,点云是另一种,而链接是双语钥匙。把它拧紧,每个说"图像"的模型突然免费就能说"3D"。

3、数据、代码和设置

让我让这变得无摩擦。有三个部分:Python 环境、数据集,以及在 Windows 上会让你绊倒的东西。

对于环境,我使用一个 conda 环境。这个流水线的开发模式只依赖 NumPy 和 SciPy,所以你可以在接触 GPU 之前看到每一张图。

conda create -n spatial_ai python=3.11 -y
conda activate spatial_ai
pip install numpy scipy matplotlib open3d
# Only needed when you wire in the real image models (DEV = False):
pip install torch torchvision transformers

数据集是真实重建的房间扫描,你将在整个教程中看到它,room.ply,约 220 万个点,我将其子采样到 40,000 个以加速迭代。

它带有 RGB 和每个点的分类字段,我把这个字段作为流水线必须恢复的真实标签。在生产环境中,这个文件是你的重建引擎从视频中产生的任何东西,与处理过的帧和相机参数共同注册。

我使用的文件夹布局将点云、共享图形工具包和教程脚本并排放置,这样导入保持简短。以下是生产流水线所针对的原始输入类型——一次随意的手持手机扫拍,没有设备也没有标记:

在 Windows 上,有三件事会让人头疼。第一,控制台编码:脚本强制在 stdout 上使用 UTF-8,因为默认代码页会搞乱图形日志行。第二,在某些加载器中读取带有非 ASCII 路径的点云会静默失败,所以保持数据路径简单。第三,当你迁移到真实模型时,在一台机器上写入的相机 JSON 在另一台机器上读取时必须两端都是 UTF-8,否则跨平台往返会在有效浮点数上出错。我在每件事上都浪费了一个下午。

所以哪个部分支撑起一切?是链接。让我们从那里开始构建。

4、加载重建结果和像素到点的链接

我曾经急于处理这部分,这让我付出代价好几年。你加载一个点云,开始处理,然后你已经丢掉了盒子里最有用的东西。

因为重建不是一个东西,而是三个:几何体、相机看到的帧,以及将每个点追溯到它来源的确切像素的来源信息。前两个很明显。第三个是让所有下游变得便宜的东西。

而这个链接会立即回报你。对任何帧运行任何图像模型,得到一个 2D 掩码,提升到 3D 就是一次表查找,而不是带有遮挡测试的重新投影。你在重建时付了一次几何成本,现在你只是在收取利息。

def load_scene():
    """Return a scene dict: xyz, rgb, labels, frames metadata, provenance link."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import scene_semantics, pixel_projection  # noqa: F401
        scene = scene_semantics.load_scene(DATA_FILE)
        return scene

    c = dv.load_cloud(DATA_FILE, max_points=40000, want_labels=True)
    xyz = c["xyz"].astype(np.float32)
    rgb = c["rgb"]
    labels = c["labels"] if c["labels"] is not None else np.zeros(len(xyz), int)

    img_h, img_w = 540, 960
    K = np.array([[700, 0, img_w / 2], [0, 700, img_h / 2], [0, 0, 1]], float)
    n_frames = 6
    frames, cams = [], []
    center = xyz.mean(axis=0)
    radius = float(np.linalg.norm(xyz.max(0) - xyz.min(0))) * 0.6
    for f in range(n_frames):
        ang = 2 * np.pi * f / n_frames
        pos = center + radius * np.array([np.cos(ang), np.sin(ang), 0.25])
        fwd = center - pos
        fwd /= np.linalg.norm(fwd)
        right = np.cross(fwd, [0, 0, 1]); right /= np.linalg.norm(right)
        up = np.cross(right, fwd)
        R = np.stack([right, -up, fwd])
        frames.append((img_h, img_w))
        cams.append({"frame": f, "R": R, "position": pos, "intrinsic": K})

    return {"xyz": xyz, "rgb": rgb, "labels": labels, "frames": frames,
            "cams": cams, "K": K, "img_shape": (img_h, img_w),
            "names": _class_names(labels), "n_points": len(xyz)}

这里重要的是相机约定。我存储 R 为世界到相机的旋转,position 为相机在世界坐标中的中心,因为这与步骤 4 中的投影数学完美配对。

六台相机的合成环只是为了让几何体在开发中有真实的东西可以处理。在生产中,这些来自视频关键帧的运动恢复结构,来源信息以紧凑数组的形式写入:对于每个点,哪个帧和哪个像素。保留那个数组,你就永远不用再解决对应问题了。

Florent 笔记: 我以昂贵的方式学到了这一点。我最初的流水线丢弃了像素到点的链接,在查询时用深度图重新构建它。它能工作,但很慢而且会漂移。当我开始在重建时持久化链接的那一天,我一半的"分割 bug"变成了我现在能看到和修复的配准 bug。

这就是几何体。它也是倒置的。为什么方向调整排在其他一切之前?

5、从地板开始将点云对齐到重力方向

一个新的重建会落在任意的坐标系中,我们的结果出来是倾斜和翻转的。懒办法是用手动旋转小工具手动旋转。更好的办法是从数据本身恢复重力,因为地板是任何室内扫描中最可靠的水平参考。

所以我用 RANSAC 拟合占主导地位的近水平平面,然后旋转整个点云,使该平面的法线直指上方。一个鲁棒的估计,无需手动调整。

def fit_plane_ransac(pts, thresh=0.03, iters=300):
    """Return (normal, d, inlier_mask) for n.x + d = 0, the largest plane found."""
    best_inliers, best_n, best_d = None, None, None
    n_pts = len(pts)
    for _ in range(iters):
        i, j, k = RNG.choice(n_pts, size=3, replace=False)
        v1, v2 = pts[j] - pts[i], pts[k] - pts[i]
        nrm = np.cross(v1, v2)
        norm = np.linalg.norm(nrm)
        if norm < 1e-8:
            continue
        nrm = nrm / norm
        d = -nrm @ pts[i]
        dist = np.abs(pts @ nrm + d)
        inliers = dist < thresh
        if best_inliers is None or inliers.sum() > best_inliers.sum():
            best_inliers, best_n, best_d = inliers, nrm, d
    return best_n, best_d, best_inliers

两个参数支撑着这个函数。内点阈值(thresh=0.03,缩放前三厘米)决定了你能接受多厚的平面,你需要它刚好大于点云的噪声底,这样稍微不平的地板仍然算作一个平面。

迭代次数(iters=300)是你采样三个都落在地板上的点的信心。先偏向最低点采样,300 次就足够了。

把阈值调得太高,斜天花板会加入地板的内点,你的重力向量就会倾斜。

极客笔记: 我从 Z 值最低的 35% 百分位采样 RANSAC 三元组,而不是整个点云。这个小技巧大幅提高了命中干净地板的概率,这样我可以保持迭代次数低,同时仍然获得稳定的法线。生产引擎走得更远,在解决一个小帧校正之前合并平行平面,但单平面核心给你 90% 的结果。

旋转本身是从平面法线到上轴的 Rodrigues 构造,同时应用于每个点。

def orient_to_floor(scene):
    """Rotate the cloud so the floor plane normal aligns to +Z. Returns scene."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import plane_refine
        return plane_refine.refine_planes(scene)

    xyz = scene["xyz"]
    z = xyz[:, 2]
    low = xyz[z < np.percentile(z, 35)]
    normal, _, inliers = fit_plane_ransac(low if len(low) > 500 else xyz)
    if normal is None:
        return scene
    if normal[2] < 0:
        normal = -normal

    target = np.array([0.0, 0.0, 1.0])
    axis = np.cross(normal, target)
    s = np.linalg.norm(axis)
    if s < 1e-8:
        R = np.eye(3)
    else:
        axis /= s
        cos = float(np.clip(normal @ target, -1, 1))
        ang = np.arccos(cos)
        kx = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]],
                       [-axis[1], axis[0], 0]])
        R = np.eye(3) + np.sin(ang) * kx + (1 - cos) * (kx @ kx)

    xyz_oriented = (R @ xyz.T).T.astype(np.float32)
    scene["xyz"] = xyz_oriented
    scene["floor_inliers"] = inliers
    return scene

我处理了法线已经指向上的退化情况(叉积塌陷,所以我使用单位矩阵)。我在求解前将法线翻转到向上,因为平面没有内在的"上",你不想把房间对齐到地下室。

在这个房间里,它校正了不到一度的倾斜,听起来微不足道,但你得记住此后每一个基于高度的决策(缩放、切片高度、地板与墙的分割)都信任 Z 轴真的向上。

房间现在水平了,但它没有尺寸。从图像重建不知道它捕获的是玩具屋还是大教堂。我们如何给它米制单位?

6、识别房间并从天花板缩放

单目和运动恢复结构重建是无比例的。要让点云有度量单位,你需要一个已知距离,而在室内,天花板会把它交给你。

地板和天花板在高度直方图中显示为两个稠密带,它们之间的间隙就是房间高度。告诉系统天花板大约在 2.5 米处,整个场景就会对齐到真实单位。

def identify_and_scale(scene):
    """Detect the floor/ceiling bands, scale the scene so ceiling = reference."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import scene_inventory, scene_transform
        scene = scene_inventory.identify(scene)
        return scene_transform.apply_scale(scene, CEILING_REFERENCE_M)

    z = scene["xyz"][:, 2]
    hist, edges = np.histogram(z, bins=80)
    centers = 0.5 * (edges[:-1] + edges[1:])
    dense = centers[hist > 0.25 * hist.max()]
    floor_z, ceil_z = float(dense.min()), float(dense.max())
    measured_height = ceil_z - floor_z
    scale = CEILING_REFERENCE_M / measured_height if measured_height > 1e-6 else 1.0

    scene["xyz"] = (scene["xyz"] * scale).astype(np.float32)
    scene["scale"] = scale
    scene["room_type"] = "indoor room"
    return scene

密度阈值(hist > 0.25 * hist.max())是需要关注的旋钮。地板和天花板是几乎任何房间中两个最稠密的水平层,所以四分之一峰值高度能可靠地分离它们,同时忽略中间稀疏的家具。

真正的弱点是常量 2.5 米。对于客厅来说这是一个合理的默认值,但对于地窖或仓库来说就是错的,这正是我标注它的原因。

进化笔记: 将硬编码高度替换为学习到的先验。对点云运行一个小型房间类型分类器,或者在某一帧上问视觉语言模型"这是什么房间?",然后查找每种房间类型的典型天花板高度。办公室 2.7 米,地窖 2.2 米,零售店 3.5 米。现在缩放是数据驱动的而不是猜测,同一流水线能正确缩放地下室和展厅。

现在我们有了一个水平的、有度量单位的房间。是时候让链接变得具体了。我们如何实际将信息从 2D 图像移动到这些 3D 点上?

7、用深度测试构建投影链接

这是为整个设计买单的步骤。对于每一帧,我将每个点投影到相机中,然后保留一个每像素的最近深度缓冲,这样通过更近表面看到的点不会被错误标注。

输出是,每帧可见的点集及其像素坐标。之后提升掩码就变得微不足道:取掩码的真实像素,查找它们背后的点。

def project_points_to_frame(cam, img_shape, points, depth_tol=0.04):
    """Return (visible_idx, uv) for points landing inside this frame, front-surface only."""
    H, W = img_shape
    R = np.asarray(cam["R"], float)
    pos = np.asarray(cam["position"], float)
    K = np.asarray(cam["intrinsic"], float)
    Pc = (points - pos) @ R.T
    z = Pc[:, 2]
    front = z > 1e-6
    zf = np.where(front, z, 1.0)
    u = np.round(K[0, 0] * Pc[:, 0] / zf + K[0, 2]).astype(np.int64)
    v = np.round(K[1, 1] * Pc[:, 1] / zf + K[1, 2]).astype(np.int64)
    inb = front & (u >= 0) & (u < W) & (v >= 0) & (v < H)
    idx = np.flatnonzero(inb)
    if idx.size == 0:
        return idx, np.empty((0, 2), np.int64)
    zbuf = np.full((H, W), np.inf)
    np.minimum.at(zbuf, (v[idx], u[idx]), z[idx])
    keep = z[idx] <= zbuf[v[idx], u[idx]] * (1.0 + depth_tol) + 1e-6
    idx = idx[keep]
    return idx, np.stack([u[idx], v[idx]], axis=1)

深度容差(depth_tol=0.04,百分之四)是物有所值的参数。没有它,通过门框看到的远墙上的点会得到门框的标签,你的沙发会渗到后面的窗帘上。

百分之四保留前面并丢弃背景渗色,我根据每个扫描的密度来调整它:对于干净的三脚架扫描更严格,对于嘈杂的手持捕获(深度估计会抖动)更宽松。np.minimum.at 调用在一次向量化传递中构建 z 缓冲,这就是为什么即使跨六个视图也能在毫秒级运行。

极客笔记: 注意我从未循环遍历像素。投影、边界检查和 z 缓冲都是数组操作。在 40,000 点的点云上,每帧成本主要由散射到 z 缓冲决定,即使那也只是一次调用。这就是你随意重建链接和因为慢而避免重建链接之间的区别。

#%% Step 4 (call). Build the per-frame link and inspect frame 0's depth map.
scene = build_pixel_link(scene)

链接已激活。现在我们终于可以让预训练模型发挥作用了。你如何在不自己标注任何点的情况下给点云标注?

8、用 SAM 提出分割区域并用 CLIP 命名

两个预训练模型完成了这里的所有工作,我一个都没有训练。SAM 在每一帧中提出区域,但不知道它们是什么,就像把场景切成拼图碎片。CLIP 然后对每个碎片与你用纯文本编写的类别名称列表进行打分,选出最佳匹配。开放词汇只是意味着类别列表是你可以在运行之间更改的字符串。

先看看朴素版本会有所帮助。只聚类几何体本身,不命名,返回的是一个分割汤,干净地分离但完全无意义。

def propose_and_label_segments(scene):
    """Return per-point class ids from SAM proposals + CLIP open-vocab naming."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import cloud_open_vocab
        return cloud_open_vocab.label_cloud_openvocab(
            scene["xyz"], scene["rgb"], scene["link"], prompts=list(scene["names"].values()))

    gt = scene["labels"]
    noisy = gt.copy()
    flip = RNG.random(len(gt)) < 0.12
    noisy[flip] = RNG.integers(0, gt.max() + 1, size=int(flip.sum()))
    scene["labels_noisy"] = noisy
    return noisy

在开发中,我用扫描本身的分割来代替模型输出,并故意注入 12% 的每点标签噪声,因为那噪声正是步骤 7 中一致性投票必须清理的。那个数字不是随机的。它接近我在手持扫描上看到的真实开放词汇标注器的跨帧分歧,其中同一个点从不同视角得到自信但不同的标签。

如果我在干净标签上测试步骤 7,我就是在自欺欺人。

Florent 笔记: 我第一次在真实扫描上运行 SAM + CLIP 时,我以为标签会是困难的部分。它们不是。困难的是每个相机都有自己的意见,当你旋转点云时它在类别之间闪烁。标注很好。一致性才是问题。这重新定义了我整个流水线的后半部分。

有了 CLIP 命名提案,汤变成了真实的类别:墙、地板、天花板、家具。

类别名称很强大,但有时你没有名称,你有一个例子。

如果你能只是指向一个对象就能在任何地方找到它呢?

9、用 DINOv2 跨帧匹配一个勾画的对象

这是整个工作流中最聪明的一步,值得放慢速度。你在一帧中勾画一个对象,一个粗糙的套索,DINOv2 在每一帧中找到相同的对象。没有类别名称,没有训练,没有你画之外的例子。

它基于 patch 嵌入工作。DINOv2 将一帧编码为嵌入向量的网格,每个 patch 一个。

生产引擎使用的技巧是先投射掉这些嵌入的位置分量,这样相似性由 patch 的外观驱动,而不是它在帧中的位置。然后你将勾画的前景 patch 平均为原型,并在每个目标帧上对那个原型进行余弦匹配。

def incontext_match(scene, target_class=TARGET_CLASS):
    """Find one outlined object across frames, lift the match to 3D point indices."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import incontext_seg
        refs = scene["reference_outlines"]
        return incontext_seg.propagate(refs, scene["frames_dict"], image_size=768, tau=0.6)

    gt = scene["labels"]
    link = scene["link"]
    target_pts = set(np.flatnonzero(gt == target_class).tolist())
    matched = np.zeros(len(gt), bool)
    for fid, fr in link.items():
        vis = fr["idx"]
        is_target = np.array([int(i) in target_pts for i in vis])
        keep = is_target & (RNG.random(len(vis)) < 0.90)
        matched[vis[keep]] = True

    scene["matched_target"] = matched
    return matched

真正的参数在生产调用中,值得记住。工作图像大小是 768 像素,取整到 patch 网格,因为太小会丢失对象,太大会浪费手机帧上的计算。合并阈值 tau=0.6 控制附近 patch 聚集成一个匹配的激进程度,0.6 是沙发保持为一个对象而不是碎片化为坐垫的最佳点。

在开发中,我将每帧行为建模为 90% 召回率,然后跨六帧取提升的并集,这样即使没有单个视图看到全部,也能恢复几乎整个对象。跨视图的并集,由链接实现,是安静的英雄。

趣闻: DINO 来自一项工作,表明一个完全不用标签训练的视觉 Transformer 会自己学会分割对象,注意力图只是在前景上亮起。DINOv2 将那个想法扩展了。这里的"上下文"用法——给一个例子找到其余的——与孩子从一个指向手势学习新单词背后的直觉相同。你可以在 https://arxiv.org/abs/2304.07193 阅读 DINOv2 论文。

从那一个轮廓开始,模型在它出现的每一帧中点亮同一个沙发,链接将这些匹配携带到点云上。

3D 中的结果:2D 中的一个轮廓,通过像素到点的链接提升,变成整个点云上干净的对象标签。© F. Poux

我们现在有来自两条路线的标签,名称和例子。但它们跨相机不一致。我们如何让点云停止闪烁?

10、跨相机投票以解决标签闪烁

这是没人警告你的问题。一个点从相机 A 得到"墙"标签,从相机 B 得到"窗户"标签。在完整扫描中,点云闪烁,而闪烁的点云会破坏每一个下游消费者,从场景图到客户报告。

修复方法是投票,而投票由几何决定,而不是哪个相机喊得最大声。

def spatial_consistency_vote(scene, labels, k=16, min_cluster=20):
    """Return labels after a k-NN majority vote that resolves cross-camera flicker."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import semantic_refine
        cleaned, _ = semantic_refine.segment_consistency_refine(
            scene["xyz"], labels, list(scene["names"].values()),
            min_cluster=min_cluster, normal_angle_deg=12.0, k=k)
        return cleaned

    from scipy.spatial import cKDTree
    xyz = scene["xyz"]
    tree = cKDTree(xyz)
    _, nn = tree.query(xyz, k=k, workers=-1)
    C = int(labels.max()) + 1
    neigh_labels = labels[nn]
    counts = np.zeros((len(xyz), C), np.int32)
    for j in range(neigh_labels.shape[1]):
        counts[np.arange(len(xyz)), neigh_labels[:, j]] += 1
    voted = counts.argmax(axis=1).astype(np.int32)
    return voted

邻居数(k=16)设置了每个点听取多少局部上下文。太小,真实薄特征会被周围环境压倒;太大,你会模糊对象边界。十六是室内密度的好默认值。

生产版本走得更远,有一个我想让你理解的拆分,因为普通投票有一个失败模式:地板的投票可以跨越 90 度边缘泄漏到墙上。

系统思维笔记: 正确的思考方式是结构和对象是不同类型的东西,需要不同的分组规则。地板是平面的,所以你用法线相似性门(约 12 度的余弦)来增长它,阻止它穿过锐边。椅子不是平面的,所以法线门会把它打碎,你只用纯欧几里得连通性来分组它。这里诚实的单行投票去除了大部分闪烁,但结构与对象的拆分才使它达到生产级。

#%% Step 7 (call). Resolve the cross-camera flicker with the geometry vote.
labels = spatial_consistency_vote(scene, labels)

投票处理了大部分。

但自动化总是在边缘磨损。人类应该在哪里介入?

11、保留人工参与以修正和传播

我要说些不时髦的话:人类不是这个系统中的弱点;人类是质量门。目标不是零人类,而是只在判断力超过计算力的地方花费人类。交互量很小。

点击一个点,说"这是地板",系统就会将该标签增长到连接的表面,在边界处停止。一次点击收回一整个平面。

def human_refine(scene, labels, seed_class=0, n_seeds=1):
    """Region-grow a corrected label from a seed point. Returns updated labels."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import semantic_refine
        out, _ = semantic_refine.seed_propagate(
            scene["xyz"], labels, list(scene["names"].values()),
            seeds=scene["user_seeds"])
        return out

    from scipy.spatial import cKDTree
    xyz = scene["xyz"]
    gt = scene["labels"]
    seed_class = int(np.bincount(gt).argmax())

    tree = cKDTree(xyz)
    _, nn = tree.query(xyz, k=12, workers=-1)

    cls_idx = np.flatnonzero(gt == seed_class)
    centroid = xyz[cls_idx].mean(axis=0)
    seed = int(cls_idx[np.argmin(np.linalg.norm(xyz[cls_idx] - centroid, axis=1))])

    region = np.zeros(len(xyz), bool)
    region[seed] = True
    frontier = [seed]
    while frontier:
        cur = frontier.pop()
        for nb in nn[cur]:
            if not region[nb] and gt[nb] == seed_class:
                region[nb] = True
                frontier.append(int(nb))

    out = labels.copy()
    out[region] = seed_class
    return out

增长使用 k 最近邻图(k=12)作为连通性,所以洪水填充只从邻居走到邻居,通过同意的点,而且它很快因为图只构建一次。在一次运行中,一次内部点击增长了超过八千个连接点,修正了它扫过的错误标注点。

这就是杠杆:人类提供一个比特的意图,几何体提供范围。生产引擎从局部点间距推导增长半径(中位最近邻距离的三倍),这样同一点击在稠密室内扫描和稀疏室外扫描上行为一致。

艺术——确实是艺术——在于选择在哪里点击。在那些对准确性影响最大的表面上花费点击,大的模糊平面,而不是模型已经搞定的简单东西。

想深入了解生产规模的人工参与标注,学院的分割课程在 https://learngeodata.eu/ 端到端讲解。

我们有一个干净的、标注好的、有比例尺的场景。现在是客户真正要求的部分。我们如何把它变成他们能测量的东西?

12、将结果切片成可测量的平面图

大多数客户不想要点云,他们想要一个可以读取尺寸的平面图。所以在合理的切片高度取一个几百毫米厚的水平切片,去掉 Z,你就有了一个剖面。

它的 XY 范围就是房间的占地面积(米制),直接从点云得到,不需要 CAD 重绘也不需要在房间里用卷尺。

def export_floorplan_slice(scene, thickness_m=0.30, out_path=None):
    """Cut a horizontal slab, report the footprint, write a slice cloud."""
    if not DEV:
        sys.path.insert(0, NEURONES_3D)
        from core import slice_export
        return slice_export.export_slice(scene, thickness=thickness_m)

    xyz = scene["xyz"]
    z = xyz[:, 2]
    cut = float(np.percentile(z, 5)) + 1.0
    slab = (z >= cut - thickness_m / 2) & (z <= cut + thickness_m / 2)
    pts2d = xyz[slab][:, :2]
    if out_path is None:
        out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "floorplan_slice_dev.ply")
    ext_x = float(pts2d[:, 0].max() - pts2d[:, 0].min()) if len(pts2d) else 0.0
    ext_y = float(pts2d[:, 1].max() - pts2d[:, 1].min()) if len(pts2d) else 0.0

    with open(out_path, "w", encoding="utf-8") as f:
        f.write("ply\nformat ascii 1.0\n")
        f.write(f"element vertex {len(pts2d)}\n")
        f.write("property float x\nproperty float y\nproperty float z\nend_header\n")
        for x, y in pts2d:
            f.write(f"{x:.4f} {y:.4f} {cut:.4f}\n")
    return out_path

切片高度(第五百分位地板上方一米)和切片厚度(thickness_m=0.30)是两个选择。一米高是标准建筑切片,在家具杂乱之上但在墙壁和门洞之中。

警告: 平面图继承了捕获的比例误差。随意的手持扫拍对于装修草图来说没问题,但对于竣工公差来说有风险。我在这些切片上显示尺寸,但我不对手机捕获的尺寸做认证,你也不应该。

三十厘米的厚度足够厚以在嘈杂点云中捕获稀疏墙壁,又足够薄以读取一条干净的线,而不是模糊。

在这个房间里,它返回约 7x7 米的占地面积,一次计算完成。

商业创意: 最后一步隐藏着一项真正的服务。用手机走一个空间,运行这个流水线,当天就发货带尺寸的平面图和标注模型,适用于那些永远不会雇测量队的物业。房地产列表、保险预评估、小型装修报价。捕获是免费的,可交付物是客户付费的,所以利润完全在你刚刚学会的编排中。

#%% Step 9 (call). Slice the labeled cloud and report the footprint.
export_floorplan_slice(scene)

13、用 AI 将脚本变成产品

让我明确说明杠杆在哪里。这个脚本是领域核心:链接、投票、人工门、参数选择。

那是十五年经验教我的部分,也是 AI 无法为你发明的部分。

但把它包装成一个可用的工具——一个带 3D 视口、轮廓画笔、导出对话框的桌面应用——正是 AI 编码代理擅长的部分。

所以分工是诚实的。你是编排者:你决定深度容差在嘈杂捕获上应该放宽,人类应该先点击大的模糊平面,天花板先验应该取决于房间类型。

代理是工具:它将这些决定变成一千行 UI 和胶水代码,而你看着。我正是用这种方式构建了生产版本,掌握设计决策,让代理处理样板代码。这不是懒惰,而是发挥各自的优势。

14、结果、讨论和未来方向

退后一步看看九个小函数一起做了什么。一段随意的手机视频变成了一个水平的、有度量单位的、标注好的 3D 房间,一个对象在每一帧中都有匹配,一个无闪烁的点云,以及一个可测量的平面图。

没有训练任何东西。复合效应才是重点:每一步都很便宜,因为前一步为它做了铺垫,步骤 1 中的链接为步骤 6 的提升和步骤 9 的切片买了单。

回到任务目标。你想要那个随意的扫拍能回答问题,现在它可以了:地板在哪里,房间多大,沙发在哪里。它起作用的原因不是任何单个模型,而是你保护了一个对应关系,并在重要的地方花费了你的判断力。

这是一种你会在你接触的每一次扫描中重用的技能,不是一个项目的把戏。

我直说它在哪里会崩溃,因为盲目信任它会坑你。

链接只和配准一样好。几个像素的重投影误差会跨对象边界渗出标签,再多投票也无法完全从糟糕的位姿图中恢复。

开放词汇匹配在 CLIP 和 DINOv2 训练中没见过的稀有类别上失败,在透明或反光表面上(图像本身在几何上撒谎)也失败。一致性投票可以在每个相机都同意但每个相机都错的区域中自信地传播错误多数。

平面图测量继承了捕获的比例误差,句号。

所以在信任任何规模化之前,在保留子集上验证,并在高严重性调用上保留人类。

系统之所以强大,恰恰因为它知道自己不能单独做什么。

放大看,这个模式比一个流水线更大。我们正在看到理解的成本趋近于零,而判断力的成本保持稳定。

模型在命名和匹配方面会越来越好。保持稀缺的是知道保护哪个对应关系、编码哪个先验、做哪个点击。

我的未来展望: 我认为未来两年会把它从"标注扫描"变成"查询扫描"。一旦链接成为标准且语义可靠,点云就变成了你可以提问的数据库,然后是代理行动的接口。本文中的流水线是那个未来之下不起眼的管道工程,而可靠性就在管道中。

15、结束语

本周做一件事。

对你知道大小的房间拍一段三十秒的手机视频,重建它,然后切片。

将切片给出的占地面积与真实尺寸比较。

那一个循环——从捕获到测量——会比任何阅读都更教会你这项技术在哪里有帮助和在哪里撒谎。

然后来构建其余的。


原文链接:Turn Video into Smart 3D Models: The Python Guide with SAM, CLIP, and DINO

汇智网翻译整理,转载请标明出处