从图像到语义3D高斯散点

使用 Python 和 Depth Anything V3 构建交互式 3D 语义扫描仪。在几毫秒内将 2D 图像/视频转换为带标签的高斯散点。无需激光雷达。

从图像到语义3D高斯散点

如果您曾经尝试在密集的 3D 点云中分割特定对象(例如路牌或汽车轮胎),您一定深有体会。

您旋转视口,眯着眼睛盯着屏幕,试图用套索工具捕捉一团看起来像一团模糊汤的点。

感觉就像戴着拳击手套做显微外科手术一样。

不过,我从未做过显微外科手术,也从未日常佩戴拳击手套,所以我的比喻可能略显局限。

然而,标注二维图像却轻而易举。一个孩子就能在照片中画一个圈圈出汽车。

那么,既然我们可以利用像素,为什么还要与几何形状作斗争呢?

用于语义投影的标注过程(手动模式)的节选

我们将迎来空间人工智能的巨大变革:从被动重建(仅仅捕捉几何形状并祈祷它看起来不错)转向主动语义注入。

我们需要的不仅仅是点,而是意义。

虽然像“Map Anything”这样的工具专注于高级拓扑布局,但Depth Anything V3 (DA3) 更进一步,仅需一台摄像机即可生成密集的度量几何图形。

它使我们能够将简单的视频流视为高保真深度扫描仪。

使用Depth Anything V3将简单的视频流转换为高保真深度扫描仪

在本教程中,我们将构建一座美丽的缺失桥梁。

让我们使用Python中的“魔棒”工具开发一个3D重建引擎,该工具允许您在简单的2D图像帧上绘制,并立即将该意义投影到3D空间。

这意味着我们可以生成一个多类别、语义分割的3D高斯喷溅场景,而无需手动操作任何3D点。 🦚

这种转变至关重要,因为目前的 3D 标注方法速度极慢、成本高昂且无法扩展,严重制约了空间人工智能系统在各行业(例如自动驾驶或机器人)的部署。通过利用 Depth Anything V3 等模型将 2D 语义标签注入到高保真 3D 重建模型中,我们可以显著提高 3D 数据标注的速度和质量。

我热情高涨,你也跃跃欲试,我们开始吧?

1、使命宣言

想象一下这样的场景。

您是一位高端汽车数字孪生项目的首席空间工程师。您的客户刚刚发给您一段画面抖动、未经校准的视频,视频中一辆罕见的古董车在车库里绕着它走动。

一段带有视觉瑕疵的古董车视频,用于 3D 重建。

他们的要求简单却令人担忧:“我们需要一个 3D 模型,还需要轮胎,车窗和底盘将成为我们配置器中可供选择的独立3D对象。”

你没有激光雷达扫描数据,也没有CAD模型,只有一个15秒的MP4视频文件。

大多数工程师会慌张不已。他们会运行一个基本的摄影测量流程,得到一个杂乱的网格,然后花费数小时在Blender中手动抠出轮胎。

但你不一样。

你微笑着打开终端,运行semantic_scanner.py。你点击视频第一帧中的轮胎。系统瞬间识别出深度,将你的点击投影到三维空间,并在整个视频序列中传播,最终导出一个完美分割、标注清晰的三维场景。

在客户完成后续邮件之前,你就交付了模型。

你不仅解决了一个问题,还构建了一个将二维交互转化为三维模型的流程。

但这种“魔法”背后的原理究竟是什么呢?

2、拟议的工作流程

我们将简化流程,构建一个线性且稳健的系统。

我们将重点关注…… “人机协作”架构。虽然我们可以使用人工智能(通过 SAM)来猜测标签,但我们追求的是绝对的精确度。我们希望由艺术家来定义语义。

以下是我们即将部署的五步系统:

图示展示了五步工作流程:提取、推理、交互、投影和融合。
  • 提取:我们将视频输入分割成帧。
  • 推理:我们将这些帧输入到 Depth Anything V3 中,以获得最先进的度量深度图和相机姿态。
  • 交互:我们打开一个自定义的 2D 绘画界面,您可以在其中定义类别(例如,红色代表轮胎,蓝色代表玻璃)。
  • 投影:这是核心数学运算。我们使用“逆投影”算法将 2D 标签投影到 3D 空间。相机的固有属性。
  • 融合:我们将这些带标签的视角合并成一个单一的、连贯的高斯散射文件(.ply),可用于训练或可视化。

这个系统的精妙之处不在于人工智能,而在于投影。当你意识到一个带有深度值的像素实际上是一个等待解锁的 3D 坐标向量时,你不再将图像视为平面,而是开始将它们视为门户。

那么,我们需要哪些工具来构建这个门户呢?

3、数据、代码和设置

为了实现这一点,我们需要一个严谨的环境。摩擦是创新的敌人,所以我们现在就消除它。

使用必要的 Python 库和 Depth Anything V3 设置开发环境。

我们依赖于Depth Anything V3 功能强大,但需要 GPU 才能高效运行。如果您使用的是 CPU,虽然也能运行,但帧与帧之间最好去喝杯咖啡。

3.1 工具库

以下是 requirements.txt 的逻辑。我们需要 PyTorch 来进行繁重的计算,Open3D 来进行地理空间处理,以及 OpenCV 来进行图像处理。

# create a clean environment first
conda create -n da3_env python=3.10
conda activate da3_env

环境创建完成后,只需安装必要的库:

# The Essentials
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
pip install opencv-python numpy matplotlib
pip install open3d

# The Star of the Show
# We install directly from the source to get the latest V3 API
pip install git+https://github.com/LiheYoung/Depth-Anything

# Optional: get an IDE
pip install spyder

然后,我建议您在 DAv3 文件夹的根目录下创建一个新脚本(或者查看文章末尾的资源),例如:

Depth Anything V3 项目建议的文件结构,以确保代码执行有序。

这样,从脚本文件中调用 depth_anything_3 就能正确处理文件夹中的所有内容。

太好了,现在我们的代码环境已经设置好了,我们可以开始处理数据集了。

3.2 数据

您不需要复杂的数据集。我使用了一组简单的照片,照片内容是我用手机拍摄的汽车(以及如下所示的各种物体)。

用作数据集对象的《Python 3D 数据科学》书籍照片
一张具有挑战性的测试图像在恶劣天气条件下拍摄的汽车图像,用于测试模型的鲁棒性。
用于 3D 扫描测试的彩色网球鞋特写照片
用作数据集的 AI 生成的老爷车图像

您可以在文章末尾的共享课程中找到所有这些资源

一般来说,需要两样东西:

  • 输入:任何图像文件夹(如果您有视频,只需提取关键帧)。
  • 条件:良好的光照条件会有帮助,但 DA3 的效果也出奇地好。它对雨水和反射具有很强的鲁棒性(这也是它除了传统摄影测量之外的独特之处)。

这里一个常见的陷阱是 CUDA 版本不匹配。DA3 依赖于一些特定的 PyTorch 操作。如果您遇到“找不到设备”错误,99% 的情况下是因为您不小心安装了 CPU 版本的 PyTorch。在开始调试代码逻辑之前,请务必检查 torch.cuda.is_available() 是否可用。

现在我们的环境已经准备就绪,那么我们如何将输入转换为度量点云呢?

工作流程采用模块化设计。您可以替换输入(视频或图像)、智能体(DA3 或 ZoeDepth)或输出(PLY 或 OBJ)。但系统的核心——从 2D 到 3D 的转换——保持不变。

4、引擎:初始化与数据导入

大多数教程会直接给你一个脚本,然后说“运行它”。我们不会这样做。我们将深入剖析逆向图形的逻辑。

我们需要理解如何让机器接收一个平面的像素网格,注入人类的理解(标签),并将其扩展成一个度量化的三维现实。

可视化逆向图形的逻辑,将二维像素转换为三维度量化的现实

在处理几何图形之前,我们需要一个强大的数据处理流程。首先,让我们导入必要的库:

import glob
import os
import torch
import numpy as np
import cv2
import open3d as o3d
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from depth_anything_3.api import DepthAnything3
from sklearn.neighbors import KDTree

很好,现在,让我们绕过一个障碍:使用硬编码路径和混乱的文件管理。让我们来解决这个问题。

def setup_paths(data_folder="SAMPLE_SCENE"):
    """Create project paths for data, results, and models"""
    paths = {
        'data': f"../../DATA/{data_folder}",
        'results': f"../../RESULTS/{data_folder}",
        'masks': f"../../RESULTS/{data_folder}/masks"
    }
    os.makedirs(paths['results'], exist_ok=True)
    os.makedirs(paths['masks'], exist_ok=True)
    return paths

此函数根据提供的项目名称,构建一个标准化的目录结构,用于组织输入数据、处理结果和分割掩码。

请注意 setup_paths 函数。它看似简单,但现在创建一个专用的掩码文件夹至关重要。之后,当我们生成数百个分割掩码时,自动整理这些掩码可以避免“文件未找到”的错误,从而防止流程状态中断。

如果文件系统中不存在必要的输出文件夹,它会自动创建这些文件夹,确保后续步骤不会因缺少目录而失败。

接下来,我们来构建可视化函数:

def visualize_depth_and_confidence(images, depths, confidences, sample_idx=0):
    """Show RGB image, depth map, and confidence map side by side"""
    fig, axes = plt.subplots(1, 3, figsize=(15, 5))
    
    axes[0].imshow(images[sample_idx])
    axes[0].set_title('RGB Image')
    axes[0].axis('off')
    
    axes[1].imshow(depths[sample_idx], cmap='turbo')
    axes[1].set_title('Depth Map')
    axes[1].axis('off')
    
    axes[2].imshow(confidences[sample_idx], cmap='viridis')
    axes[2].set_title('Confidence Map')
    axes[2].axis('off')
    
    plt.tight_layout()
    plt.show()

def visualize_point_cloud_open3d(points, colors=None, window_name="Point Cloud"):
    """Display 3D point cloud with Open3D viewer"""
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    
    if colors is not None:
        pcd.colors = o3d.utility.Vector3dVector(colors)
    
    o3d.visualization.draw_geometries([pcd], window_name=window_name)

# Florent's Note: These visualization functions are reusable across all steps

visualize_depth_and_confidence 将用于创建一个并排比较可视化图,其中包含原始 RGB 图像、其对应的深度图以及相关的置信度评分图。然后,visualize_point_cloud_open3d 函数初始化一个 Open3D 点云几何对象,并用 3D 空间坐标列表填充它。如果提供了颜色数据,它会将 RGB 值映射到每个点,然后启动一个交互式 3D 查看器窗口。

至此,一切准备就绪!那么 Depth Anything V3 呢?

5、Depth Anything V3 设置

我们需要以一种能够最大化利用 GPU 显存 (VRAM) 而不导致 GPU 崩溃的方式来实例化 Depth Anything V3 模型。

将 Depth Anything V3 模型加载到 GPU 上以进行高效推理。

我们首先定义项目结构并加载核心组件:vitl(Vision Transformer Large)编码器。这是操作的核心:

def load_da3_model(model_name="depth-anything/DA3NESTED-GIANT-LARGE"):
    """Initialize Depth-Anything-3 model on available device"""
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Using device: {device}")
    
    model = DepthAnything3.from_pretrained(model_name)
    model = model.to(device=device)
    
    return model, device

# Time to test step 1: Load the DA3 model
model, device = load_da3_model()
print("DA3 model loaded successfully")

我们明确选择了 depth-anything/DA3NESTED-GIANT-LARGE 模型。

为什么是“Giant”?因为在深度估计中,尺寸很重要。较小的模型(例如 Vit-B 或 Vit-S)速度更快,但它们往往会“平滑”掉尖锐的边缘。如果您正在扫描一辆汽车,则需要车门和车架之间的缝隙。为了获得清晰的图像,而不是模糊的斜坡。大型模型保留了这些高频细节,这对后续的点云生成至关重要。不过,请务必查看模型的许可协议,选择符合您目标的模型。

接下来,我们可以定义图像加载函数:

def load_images_from_folder(data_path, extensions=['*.jpg', '*.png', '*.jpeg']):
    """Scan folder and load all images with supported extensions"""
    image_files = []
    for ext in extensions:
        image_files.extend(sorted(glob.glob(os.path.join(data_path, ext))))
    
    print(f"Found {len(image_files)} images in {data_path}")
    return image_files

一切就绪了吗?是的!但我们现在需要实际加载图像:

paths = setup_paths("CAR")
print(f"Project paths created: {paths}")
image_files = load_images_from_folder(paths['data'])

这样,image_files 现在就包含了我们所有的图像。这意味着我们可以继续进行下一步了!

在步骤 1 中,我们使用了 device=”cuda”。如果您在配备 M1/M2/M3 芯片的 Mac 上运行此程序,PyTorch 理论上支持 mps(Metal Performance Shaders),但 DA3 中的许多高级张量运算可能尚未针对 mps 进行完全优化。如果您在 Mac 上看到奇怪的瑕疵,请回退到 CPU——虽然速度会比较慢,但从数学角度来说是正确的。

现在,引擎已经运行起来了。接下来,我们如何让它“感知”深度呢?

6、度量深度估计

Depth Anything V3 不仅仅是猜测;它能够精确地进行深度感知。

它使用 DINOv2 主干网络来理解语义特征(它知道什么是深度)。 (例如“汽车”)以及一个回归头来预测每个像素的 Z 轴距离。但关键在于:原始深度图通常是“相对”的(0 到 1)。

DA3 提供度量深度,这意味着这些值对应于真实世界的单位(米)。

相对深度与度量深度在 3D 精度方面的视觉比较

这彻底改变了游戏规则。相对深度会产生一种扭曲的“哈哈镜”效果。而度量深度则能提供一些我们可以实际用于工程级几何的参考值。

因此,要运行推理,您可以设计以下函数:

# %% Step 3: Run DA3 Inference for Depth and Poses
def run_da3_inference(model, image_files, process_res_method="upper_bound_resize"):
    """Run Depth-Anything-3 to get depth maps, camera poses, and intrinsics"""
    prediction = model.inference(
        image=image_files,
        infer_gs=True,
        process_res_method=process_res_method
    )
    
    print(f"Depth maps shape: {prediction.depth.shape}")
    print(f"Extrinsics shape: {prediction.extrinsics.shape}")
    print(f"Intrinsics shape: {prediction.intrinsics.shape}")
    print(f"Confidence shape: {prediction.conf.shape}")
    
    return prediction

请仔细查看 process_res_method=”upper_bound_resize” 参数。这至关重要。神经网络处理的是固定大小的图像块(通常为 14x14 像素)。如果您的图像是 1920x1080,则无法完美地适应这些图像块。因此,您可以选择以下两种方式之一:

  • 标准调整大小:压缩图像,破坏宽高比和几何真实性。
  • 上限调整大小:放大图像,使其最短边与图像块大小相匹配,然后进行裁剪或填充。这样可以保持宽高比。

如果您更改此参数,您的 3D 点云看起来会“拉伸”或压缩。我们强制模型遵循传感器的物理比例。

要运行,只需使用:

prediction = run_da3_inference(model, image_files)
visualize_depth_and_confidence(
    prediction.processed_images, 
    prediction.depth, 
    prediction.conf, 
    sample_idx=0
)

您将得到类似以下内容:

可视化 RGB 图像及其生成的深度图和置信度得分图。

我记得几年前运行我的第一个深度估计模型(MonoDepth2)。结果一团糟。DA3 除了深度信息外,还会返回一个置信度图。这可是你的好帮手。在可视化函数中,查看黄色区域——那是模型确定的区域。蓝色区域是……猜测。我们稍后会用这张深度图来过滤掉“幻觉”。

现在我们有了深度图,如何将灰度图像转换为 3D 世界呢?

7、逆投影(2D 到 3D)

这是本教程中最重要的算法。相机将 3D 世界投影到 2D 传感器上。我们需要逆向进行投影。我们使用针孔相机模型。图像上的每个像素 (u, v) 都位于穿过相机中心的射线上。由于 DA3 提供了沿射线的距离 Z(深度),我们可以计算出它在空间中的 X 和 Y 坐标。

针孔相机模型示意图,展示了逆投影的数学原理

公式简洁优雅:

X=(u−cx)⋅Z/fx X = (u - c_x) \cdot Z / f_x X=(u−cx​)⋅Z/fx​
Y=(v−cy)⋅Z/fy Y = (v - c_y) \cdot Z / f_y Y=(v−cy​)⋅Z/fy​

其中 (cx,cy)(c_x, c_y) 为光心,(fx,fy)(f_x, f_y) 为焦距。

那么,如何用高效的 Python 代码来实现呢?好,我们来定义 depth_to_point_cloud 函数,它将深度图“反投影”成点云:

# %% Step 4: Generate 3D Point Cloud from Depth Maps
def depth_to_point_cloud(depth_map, rgb_image, intrinsics, extrinsics, conf_map=None, conf_thresh=0.5):
    """Back-project depth map to 3D points using camera parameters"""
    h, w = depth_map.shape
    fx, fy = intrinsics[0, 0], intrinsics[1, 1]
    cx, cy = intrinsics[0, 2], intrinsics[1, 2]
    
    # Create pixel grid
    u, v = np.meshgrid(np.arange(w), np.arange(h))
    
    # Filter by confidence if provided
    if conf_map is not None:
        valid_mask = conf_map > conf_thresh
        u, v, depth_map, rgb_image = u[valid_mask], v[valid_mask], depth_map[valid_mask], rgb_image[valid_mask]
    else:
        u, v, depth_map = u.flatten(), v.flatten(), depth_map.flatten()
        rgb_image = rgb_image.reshape(-1, 3)
    
    # Back-project to camera coordinates
    x = (u - cx) * depth_map / fx
    y = (v - cy) * depth_map / fy
    z = depth_map
    
    points_cam = np.stack([x, y, z], axis=-1)
    
    # Transform to world coordinates using extrinsics (w2c format)
    R = extrinsics[:3, :3]
    t = extrinsics[:3, 3]
    points_world = (points_cam - t) @ R  # Inverse transform
    
    colors = rgb_image.astype(np.float32) / 255.0
    
    return points_world, colors

注意,我们没有使用 for 循环遍历像素。那样会耗费数分钟。相反,我们使用 np.meshgrid 创建网格坐标,并一次性对整个数组进行计算。这就是向量化。它将操作从 Python 循环(速度慢)转换为 C 优化的矩阵运算(速度快)。

以下是单个帧的其中一个结果:

由单帧深度图生成的旋转 3D 点云。

但是如何整合不同的视角呢?您可以使用以下函数:

def merge_point_clouds(prediction, conf_thresh=0.5):
    """Combine all frames into single point cloud"""
    all_points = []
    all_colors = []
    
    n_frames = len(prediction.depth)
    
    for i in range(n_frames):
        points, colors = depth_to_point_cloud(
            prediction.depth[i],
            prediction.processed_images[i],
            prediction.intrinsics[i],
            prediction.extrinsics[i],
            prediction.conf[i],
            conf_thresh
        )
        all_points.append(points)
        all_colors.append(colors)
    
    merged_points = np.vstack(all_points)
    merged_colors = np.vstack(all_colors)
    
    print(f"Merged point cloud: {len(merged_points)} points")
    return merged_points, merged_colors

现在,只需调用以下两行代码即可使用:

# Time to Generate 3D point cloud
points_3d, colors_3d = merge_point_clouds(prediction, conf_thresh=0.4)
visualize_point_cloud_open3d(points_3d, colors_3d, window_name="Full Scene Point Cloud")

您将得到类似这样的结果:

由多个原始帧合并而成的密集且噪声较大的 3D 点云。

或者这样(更干净,来自真实图像):

应用统计异常值去除 (SOR) 去除噪声后的清理点云

检查 conf_thresh=0.5 参数。我们毫不留情地丢弃了 50% 的数据。为什么?因为置信度低的点通常是天空、反射或透明表面。如果保留它们,它们看起来就像“飞像素”——难看的噪声漂浮在 3D 场景的中间。干净的数据 > 更多数据。

看起来不太好,但噪声点很多。此步骤的结果是一个 .ply 文件。但它有很多噪声。到处都是“灰尘”。怎么会这样?我们该清理它吗?

8、策略:统计异常值去除 (SOR)

原始深度图在物体边界处(前景汽车和背景墙壁之间的“跳跃”)存在噪声。这会产生“条纹”伪影。

我们使用统计滤波器。对于每个点,我们计算其到相邻点的平均距离。如果该距离显著大于全局平均值(加上一些标准差),则该点为孤立点,即噪声。我们将其去除。

我们实现了两个版本:一个使用 Open3D(C++ 速度快),另一个使用 SciPy(纯 Python 回退方案)。

def clean_point_cloud_open3d(points_3d, colors_3d, nb_neighbors=20, std_ratio=2.0):
    """
    Cleans a point cloud using Statistical Outlier Removal (SOR) via Open3D.
    """
    try:
        import open3d as o3d
    except ImportError:
        raise ImportError("Open3D is not installed. Run `pip install open3d` or use the scipy version.")

    # 1. Convert Numpy to Open3D PointCloud
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points_3d)
    
    # Open3D expects colors in float [0, 1]. If inputs are int [0, 255], normalize them.
    if colors_3d.max() > 1.0:
        pcd.colors = o3d.utility.Vector3dVector(colors_3d / 255.0)
    else:
        pcd.colors = o3d.utility.Vector3dVector(colors_3d)

    # 2. Run SOR (Implemented in optimized C++)
    # cl: The cleaned point cloud object
    # ind: The indices of the points that remain
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=nb_neighbors,
                                             std_ratio=std_ratio)

    # 3. Use the indices to filter the original numpy arrays 
    # (This ensures we preserve exact original data types/values if needed)
    inlier_mask = np.asarray(ind)
    
    cleaned_points = points_3d[inlier_mask]
    cleaned_colors = colors_3d[inlier_mask]

    return cleaned_points, cleaned_colors

def clean_point_cloud_scipy(points_3d, colors_3d, nb_neighbors=20, std_ratio=2.0):
    """
    Cleans a point cloud using SOR via Scipy cKDTree.
    """
    from scipy.spatial import cKDTree

    # 1. Build KD-Tree
    tree = cKDTree(points_3d)

    # 2. Query neighbors
    # k needs to be nb_neighbors + 1 because the point itself is included in results
    distances, _ = tree.query(points_3d, k=nb_neighbors + 1, workers=-1) 
    
    # Exclude the first column (distance to self, which is 0)
    mean_distances = np.mean(distances[:, 1:], axis=1)

    # 3. Calculate statistics
    global_mean = np.mean(mean_distances)
    global_std = np.std(mean_distances)

    # 4. Generate Mask
    distance_threshold = global_mean + (std_ratio * global_std)
    mask = mean_distances < distance_threshold

    return points_3d[mask], colors_3d[mask]

# --- Demo Usage ---
if __name__ == "__main__":

    # Try Open3D method
    try:
        start = time.time()
        clean_pts, clean_cols = clean_point_cloud_open3d(points_3d, colors_3d)
        end = time.time()
        print(f"\n[Open3D] Cleaned shape: {clean_pts.shape}")
        print(f"[Open3D] Time taken: {end - start:.4f} seconds")
    except ImportError:
        print("\n[Open3D] Skipped (library not found)")

    # Try Scipy method
    start = time.time()
    clean_pts_sci, clean_cols_sci = clean_point_cloud_scipy(points_3d, colors_3d)
    end = time.time()
    print(f"\n[Scipy] Cleaned shape: {clean_pts_sci.shape}")
    print(f"[Scipy] Time taken: {end - start:.4f} seconds")

要进行微调,可以使用以下参数:

  • nb_neighbors=20:我们查看每个点的 20 个最近邻点。
  • std_ratio=2.0:这是清理的程度。数值越低,清理的程度越高(但可能会删除像天线这样的细小细节)。数值越高,保留的噪声就越多。

对于 DA3 数据,2.0 是一个最佳值。它既能去除天空中的杂散像素,又不会影响车身细节。

如果您使用可视化函数(使用 scipy 的结果):

visualize_point_cloud_open3d(clean_pts_sci, clean_cols_sci, window_name="Full Scene Point Cloud")

您将得到以下结果:

应用统计异常值去除滤波后的 3D 点云。

效果好多了?现在我们有了干净的几何体。但这只是简单的几何体。它不知道“轮胎”是什么。让我们给它加个“脑子”。

9、交互式工具:绘制语义

自动分割(例如 SAM)固然出色,但对于特定的工程需求而言,其精度往往不够高。而且,这又是另一个话题了。

然而,有时您需要将某个特定的螺栓定义为“1 类”,其余部分定义为“0 类”。以下是一张图片(以 BGR 格式显示):

用于语义分割的汽车车轮原始输入图像。

由此,我们使用 OpenCV 构建一个轻量级的用户界面,允许我们直接在图像上绘制,就像这样:

交互式工具的屏幕截图,图中显示了在车轮上手动绘制的遮罩

要使用此功能,您可以创建一个名为 MultiClassMaskPainter 的类来处理鼠标事件:

# %% Interactive Masking Tool (Click-to-Paint + SAM)
class MultiClassMaskPainter:
    """Interactive tool for painting multi-class masks on images"""
    
    def __init__(self, image, num_classes=5):
        self.image = image.copy()
        self.mask = np.zeros(image.shape[:2], dtype=np.uint8)
        self.current_class = 1
        self.brush_size = 20
        self.drawing = False
        self.num_classes = num_classes
        
        # Color palette for classes (0=background)
        self.class_colors = [
            (0, 0, 0),       # 0: Background
            (255, 0, 0),     # 1: Red
            (0, 255, 0),     # 2: Green
            (0, 0, 255),     # 3: Blue
            (255, 255, 0),   # 4: Yellow
            (255, 0, 255),   # 5: Magenta
        ]
    
    def mouse_callback(self, event, x, y, flags, param):
        if event == cv2.EVENT_LBUTTONDOWN:
            self.drawing = True
            self.paint_mask(x, y)
        elif event == cv2.EVENT_MOUSEMOVE and self.drawing:
            self.paint_mask(x, y)
        elif event == cv2.EVENT_LBUTTONUP:
            self.drawing = False
    
    def paint_mask(self, x, y):
        cv2.circle(self.mask, (x, y), self.brush_size, self.current_class, -1)
    
    def get_overlay(self):
        overlay = self.image.copy()
        for class_id in range(1, self.num_classes + 1):
            mask_class = (self.mask == class_id)
            overlay[mask_class] = (0.6 * self.image[mask_class] + 
                                   0.4 * np.array(self.class_colors[class_id]))
        return overlay.astype(np.uint8)
    
    def run(self, window_name="Mask Painter"):
        cv2.namedWindow(window_name)
        cv2.setMouseCallback(window_name, self.mouse_callback)
        
        print("=== Mask Painting Tool ===")
        print("Keys: 1-5 = Select class | +/- = Brush size | 's' = Save | 'q' = Quit")
        
        while True:
            overlay = self.get_overlay()
            
            # Add UI info
            info_text = f"Class: {self.current_class} | Brush: {self.brush_size}"
            cv2.putText(overlay, info_text, (10, 30), 
                       cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
            
            cv2.imshow(window_name, overlay)
            
            key = cv2.waitKey(1) & 0xFF
            
            if key == ord('q'):
                break
            elif key == ord('s'):
                cv2.destroyAllWindows()
                return self.mask
            elif key in [ord('1'), ord('2'), ord('3'), ord('4'), ord('5')]:
                self.current_class = int(chr(key))
            elif key == ord('+') or key == ord('='):
                self.brush_size = min(50, self.brush_size + 5)
            elif key == ord('-') or key == ord('_'):
                self.brush_size = max(5, self.brush_size - 5)
        
        cv2.destroyAllWindows()
        return self.mask

def paint_mask_on_image(image, num_classes=5):
    """Launch interactive painting tool for single image"""
    painter = MultiClassMaskPainter(image, num_classes)
    mask = painter.run()
    return mask

def sam_segment_image(image, use_sam=False):
    """Apply SAM (Segment Anything) for automatic segmentation"""
    if not use_sam:
        print("SAM segmentation disabled, use manual painting instead")
        return None
    
    # Florent's Note: Add SAM integration here if needed
    # from segment_anything import sam_model_registry, SamAutomaticMaskGenerator
    # This requires SAM checkpoint download and initialization
    
    print("SAM segmentation not yet implemented")
    return None

# Paint a mask on the first image
sample_image = prediction.processed_images[0]
sample_mask = paint_mask_on_image(sample_image, num_classes=5)
print(f"Mask created with {len(np.unique(sample_mask))} classes")...

那么,它是如何工作的呢?get_overlay 函数纯粹是用户体验设计。它使用 cv2.addWeighted 将用户绘制的掩码与原始图像混合。

这种透明度至关重要。你需要看到你正在标记的像素。

我们还将按键(1-5)映射到类别。这使得键盘变成了调色板切换器。

  • 1:轮胎(红色)
  • 2:车窗(绿色)
  • 3:车身(蓝色)
  • 4:…

这个工具虽然简单,但功能强大,因为它在图像空间中运行。在二维照片上描绘轮胎比在三维空间中选择点阵要容易得多。我们利用用户对二维界面的熟悉程度来解决三维问题。

我们已经绘制了像素。现在,如何将这些“颜料”应用到三维模型上呢?

10、 语义投影与融合

这又是“逆投影”,但这次是针对……labels。

我们取刚刚绘制的掩码,并将其与深度图对齐。如果一个像素 (u,v) 被标记为“轮胎”,且深度为 2 米,则该位置的 3D 点即为“轮胎点”。

但我们有多张图像。帧 1 中出现的点也出现在帧 2 中。

我们将所有帧的掩码投影到公共的 3D 世界坐标系中(使用外参——姿态矩阵)。

我们将步骤 4 中的几何图形与步骤 5 中的标签结合起来。

# %% Apply Masks to 3D Point Cloud
def project_mask_to_3d(mask, depth_map, intrinsics, extrinsics, conf_map=None, conf_thresh=0.5):
    """Project 2D mask to 3D points using camera geometry"""
    h, w = depth_map.shape
    fx, fy = intrinsics[0, 0], intrinsics[1, 1]
    cx, cy = intrinsics[0, 2], intrinsics[1, 2]
    
    u, v = np.meshgrid(np.arange(w), np.arange(h))
    
    # Filter by confidence
    if conf_map is not None:
        valid_mask = conf_map > conf_thresh
        u, v, depth_map, mask = u[valid_mask], v[valid_mask], depth_map[valid_mask], mask[valid_mask]
    else:
        u, v, depth_map, mask = u.flatten(), v.flatten(), depth_map.flatten(), mask.flatten()
    
    # Back-project to camera coordinates
    x = (u - cx) * depth_map / fx
    y = (v - cy) * depth_map / fy
    z = depth_map
    
    points_cam = np.stack([x, y, z], axis=-1)
    
    # Transform to world coordinates
    R = extrinsics[:3, :3]
    t = extrinsics[:3, 3]
    points_world = (points_cam - t) @ R
    
    return points_world, mask

def filter_point_cloud_by_mask(points_3d, colors_3d, mask_labels, target_classes=None):
    """Keep only points belonging to specified mask classes"""
    if target_classes is None:
        target_classes = [1, 2, 3, 4, 5]  # All non-background
    
    valid_mask = np.isin(mask_labels, target_classes)
    filtered_points = points_3d[valid_mask]
    filtered_colors = colors_3d[valid_mask]
    filtered_labels = mask_labels[valid_mask]
    
    print(f"Filtered to {len(filtered_points)} points from classes {target_classes}")
    return filtered_points, filtered_colors, filtered_labels

def visualize_masked_point_cloud(points, colors, labels, num_classes=5):
    """Show point cloud with different colors per mask class"""
    # Create class-based color map
    class_colormap = np.array([
        [0.5, 0.5, 0.5],  # 0: Gray (background)
        [1.0, 0.0, 0.0],  # 1: Red
        [0.0, 1.0, 0.0],  # 2: Green
        [0.0, 0.0, 1.0],  # 3: Blue
        [1.0, 1.0, 0.0],  # 4: Yellow
        [1.0, 0.0, 1.0],  # 5: Magenta
    ])
    
    # Assign colors based on labels
    label_colors = class_colormap[labels]
    
    visualize_point_cloud_open3d(points, label_colors, window_name="Masked Point Cloud")

def visualize_masked_with_context(masked_points, masked_labels, full_points, full_colors, num_classes=5):
    """Show masked points (colored by class) together with all other points (dimmed)"""
    class_colormap = np.array([
        [0.5, 0.5, 0.5],  # 0: Gray (background)
        [1.0, 0.0, 0.0],  # 1: Red
        [0.0, 1.0, 0.0],  # 2: Green
        [0.0, 0.0, 1.0],  # 3: Blue
        [1.0, 1.0, 0.0],  # 4: Yellow
        [1.0, 0.0, 1.0],  # 5: Magenta
    ])
    
    # Create combined point cloud
    pcd_masked = o3d.geometry.PointCloud()
    pcd_masked.points = o3d.utility.Vector3dVector(masked_points)
    pcd_masked.colors = o3d.utility.Vector3dVector(class_colormap[masked_labels])
    
    # Dim the full scene points (multiply by 0.3 for darker appearance)
    pcd_full = o3d.geometry.PointCloud()
    pcd_full.points = o3d.utility.Vector3dVector(full_points)
    pcd_full.colors = o3d.utility.Vector3dVector(full_colors * 0.3)
    
    o3d.visualization.draw_geometries([pcd_masked, pcd_full], 
                                     window_name="Masked Points with Context")

当我们融合来自多个帧的点时,会得到一个密集的点云。某些点可能会重叠。在生产流程中,您需要检查一致性(例如,“帧 1 显示为 Tire,帧 2 显示为 Body -> Vote”)。

最终的掩膜点云可视化效果,突出显示了场景中的特定语义类别。

这里,我们只是简单地堆叠了这些点。由于 DA3 具有度量一致性,因此来自第 1 帧和第 10 帧的点在 3D 空间中自然对齐,从而为每个对象创建了一个密集的多色簇。

使用方法:

# Project mask to 3D
masked_points_3d, mask_labels_3d = project_mask_to_3d(
    sample_mask,
    prediction.depth[0],
    prediction.intrinsics[0],
    prediction.extrinsics[0],
    prediction.conf[0],
    conf_thresh=0.4
)

filtered_points, filtered_colors, filtered_labels = filter_point_cloud_by_mask(
    masked_points_3d, 
    colors_3d[:len(masked_points_3d)],  # Match size
    mask_labels_3d,
    target_classes=[1, 2, 3]  # Only keep these classes
)

# Visualize masked points only
visualize_masked_point_cloud(filtered_points, filtered_colors, filtered_labels)

# Visualize masked points with full scene context
visualize_masked_with_context(filtered_points, filtered_labels, points_3d, colors_3d)

结果如下:

最终的掩膜点云可视化图,突出显示场景中的特定语义类别。

现在您拥有一个点云,其中每个点都有一个 (X, Y, Z) 位置、(R, G, B) 颜色和一个 Segment_Label 整数。那么我们如何将其映射到 3D 高斯斑点呢?

11、导出带标签的高斯斑点

标准的 .ply 文件存储点。高斯斑点 (GS) 存储“斑点”(椭球体)。您可以在下方查看结果:

将 3D 高斯散射可视化为椭球体,这是用于高质量渲染的数据格式。

为了使我们的数据与 GS 查看器或训练流程兼容,我们需要添加特定属性:缩放比例、旋转角度和不透明度。

我们手动编写 PLY 文件的二进制头文件,以包含我们自定义的 segment_label 字段。

# %% Export Masked Gaussian Splatting PLY
def create_gaussian_splatting_ply(points, colors, output_path, labels=None, scale=0.01, opacity=1.0):
    """Create simplified Gaussian Splatting PLY with position, color, scale, opacity, and optional labels"""
    n_points = len(points)
    
    # Initialize Gaussian parameters
    scales = np.full((n_points, 3), scale, dtype=np.float32)
    rotations = np.zeros((n_points, 4), dtype=np.float32)
    rotations[:, 0] = 1.0  # Quaternion identity
    opacities = np.full((n_points, 1), opacity, dtype=np.float32)
    
    # Create PLY header with optional segment_label
    label_property = "property int segment_label\n" if labels is not None else ""
    
    header = f"""ply
format binary_little_endian 1.0
element vertex {n_points}
property float x
property float y
property float z
property float nx
property float ny
property float nz
property uchar red
property uchar green
property uchar blue
property float scale_0
property float scale_1
property float scale_2
property float rot_0
property float rot_1
property float rot_2
property float rot_3
property float opacity
{label_property}end_header
"""
    
    # Prepare data (simplified normals)
    normals = np.zeros((n_points, 3), dtype=np.float32)
    colors_uint8 = (colors * 255).astype(np.uint8)
    
    # Write binary PLY
    with open(output_path, 'wb') as f:
        f.write(header.encode('ascii'))
        
        for i in range(n_points):
            f.write(points[i].astype(np.float32).tobytes())
            f.write(normals[i].tobytes())
            f.write(colors_uint8[i].tobytes())
            f.write(scales[i].tobytes())
            f.write(rotations[i].tobytes())
            f.write(opacities[i].tobytes())
            if labels is not None:
                f.write(np.array([labels[i]], dtype=np.int32).tobytes())
    
    print(f"Saved Gaussian Splatting PLY to: {output_path}")

# Florent's Note: For production Gaussian Splatting, use DA3's infer_gs=True
# This simplified version is for educational purposes and custom masking

output_gs_ply = os.path.join(paths['results'], "masked_gaussian_splatting.ply")
create_gaussian_splatting_ply(filtered_points, filtered_colors, output_gs_ply, labels=filtered_labels)

# Export complete point cloud with all labels (masked + background=0)
all_labels_3d = np.zeros(len(points_3d), dtype=np.int32)
all_labels_3d[:len(mask_labels_3d)] = mask_labels_3d  # First frame labels

output_complete_ply = os.path.join(paths['results'], "complete_labeled_scene.ply")
save_point_cloud_as_ply(points_3d, colors_3d, output_complete_ply, labels=all_labels_3d)

像 Open3D 这样的标准库通常不太支持像 segment_label 这样的自定义标量字段。因此,我们使用 Python 的结构体打包或 NumPy 的 tobytes() 函数手动写入文件。

我们将 scales 初始化为一个较小的值 (0.01),将 opacity 初始化为 1.0。这会将点云视为一组微小的不透明球体——这是 3D 高斯散射优化的理想初始化状态。

特殊情况和限制

  • 遮挡:如果在任何帧中没有绘制汽车后部,则不会在 3D 中对其进行标记。输出质量取决于您的覆盖范围。
  • 动态物体:如果汽车在视频中移动,“反向投影”将失败,因为 3D 世界假设被打破。点会在空间中拖影。

我们已经构建了工具,标记了数据,并导出了文件。但最终结果究竟是什么样子,它真的可用吗?

12、完整结果

双击 .ply 文件,见证奇迹的时刻到来。

您看到的不仅仅是点,而是一个语义数字孪生。在 Open3D 或 CloudCompare 中,您现在可以根据标量字段 segment_label 筛选场景。

最终的语义数字孪生模型,用户可以按类别切换对象的可见性。

交互式 3D 语义数字孪生,显示不同功能的切换已标记的对象。

  • 点击“1”:仅显示轮胎。
  • 点击“2”:仅显示玻璃。

最初未经校准的平面视频,如今已转化为结构化的、可查询的 3D 数据库。原本杂乱无章的点云已被整理成有意义的对象。

可视化遮罩后的 3D 点云,突出显示分割后的轮胎对象。

此结果证明,创建智能空间数据无需昂贵的硬件或繁琐的 3D 手动操作。

您只需了解整个流程。只需在几帧 2D 图像上进行少量绘制(可能只需 30 秒),几何引擎即可自动标记数千个 3D 点。

您已有效地将人类的智能“传播”到 3D 空间中。

13、局限性

坦诚地说,这套系统只是一个原型,并非万能灵药。

主要有三点需要考虑:

  • 动态物体的“重影”:反向投影的假设是世界是静态的。如果有人走过或汽车移动,DA3 仍然会投影深度,但相机姿态估计会产生冲突。你会看到“重影”现象,即不同时间戳的点在 3D 空间中发生碰撞。
  • “隐藏”几何:该系统严格基于视线。如果你没有拍摄到汽车的车顶,那么点云中就不会有车顶。与可能“臆想”缺失车顶的生成式 AI 不同,这套流程基于几何基础。它只会重建它所看到的内容。
  • 手动操作的瓶颈:即使我们提供了友好的用户界面,绘制帧仍然需要手动操作。对于 10 秒的视频来说,这不成问题。对于城市规模的扫描,这种方法无法扩展。因此,在生产工作流程中集成 Segment Anything (SAM) 就显得至关重要。

但是,它允许重建传统摄影测量难以重建的场景。例如,以下是使用 RealityScan 和传统摄影测量流程得到的结果:

使用 RealityScan 的标准摄影测量重建,在处理复杂表面时表现不佳。

更进一步,我尝试了其他物体,结果更加令人印象深刻,以下是 DA v3 的结果:

使用 Depth Anything V3 获得的结果,展示了细节和几何形状。

以下是使用 RealityScan 进行摄影测量的结果(输入相同):

RealityScan 生成的噪声较大的 3D 重建结果,用于对比

现在,您应该能更好地了解这种新解决方案将如何在您的 3D 重建项目中发挥作用了吧?

14、未来展望

我们正站在地理空间工程新时代的门槛上。

过去十年,我们专注于分辨率。“我们可以采集多少个点?”“激光的精度有多高?”

未来几年,我们将关注语义。“这些点是什么?”“它们之间有什么关系?”

我相信我们正在迈向“文本到现实”的时代。不久的将来,您将不再需要绘制像素。您只需输入“标记所有轮胎”,像 CLIP 或 Grounding DINO 这样的视觉语言模型 (VLM) 就能在视频中找到轮胎,并将该掩膜信息传递给 DA3,从而自动生成带标签的 3D 场景。地理空间工程师的角色将从“数据采集员”转变为“空间提示工程师”。

还有什么比基于这个概念创办一家初创公司更好的呢?这里有一个例子。


原文链接:From Images to Semantic 3D Gaussian Splatting with Python

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