Skip to content

Semantic SegmentationΒΆ

Open in Colab Β· Download notebook

Semantic segmentation labels every point of a scene. Models read a packed batch and return per-point logits \((N, \text{num\_classes})\).

Room input, prediction, ground truth

Run a pretrained checkpointΒΆ

The example reads one room of the ScanNet20 validation set, with RGB, mesh normals and benchmark labels. ScanNet requires a signed agreement: request it on its download page and extract it under data/ScanNet/raw/.

Pass return_info=True to also get the transform registered with the checkpoint, which voxelizes the scene.

import torch

import torch_pointcloud as tp
from torch_pointcloud.datasets import ScanNet20
from torch_pointcloud.utils.data import collate

# Load the pretrained model
model, info = tp.create_model(
    "spunet-v1m1.scannet20.pointcept",
    task="semantic-segmentation",
    pretrained=True,
    return_info=True,
)
model = model.cuda().eval()

# Get associated transform
transform = info["transform"]

# Load one room: the transform voxelizes it in place and keeps the source cloud under `origin_*`
dataset = ScanNet20(root="data", split="val", transform=transform)
sample = dataset[0]

data = collate([sample])
print(f"Data keys: {data.keys()}")

with torch.no_grad():
    x = data["x"].cuda()
    pos_grid = data["pos_grid"].cuda()
    batch = data["batch"].cuda()
    inverse = data["inverse"].cuda()
    logits = model(x, pos_grid, batch)

# Get predictions at full resolution
preds = logits[inverse].argmax(dim=-1).cpu()
print(f"Logits shape: {tuple(logits.shape)}")
print(f"Predictions shape: {tuple(preds.shape)}")

# Data keys: dict_keys(['pos', 'color', 'normal', 'scene', 'segment', 'instance', 'x', 'origin_pos', 'origin_segment', 'pos_grid', 'inverse', 'batch'])
# Logits shape: (164614, 20)
# Predictions shape: (237360,)

Because the pipeline voxelizes the input scene, the model sees one point per 2 cm voxel. The inverse tensor maps the predictions back to the original points, whose labels are kept under origin_segment.

Inputs and outputsΒΆ

Argument Shape Description
x \((N, C)\) or None Per-point features, usually color, normals and height
pos / pos_grid \((N, 3)\) Coordinates (float) or integer voxel-grid coordinates (depends on the model)
batch \((N,)\) Index tensor associating each point with its point cloud
returns \((N, \text{num\_classes})\) Per-point logits

Full-resolution predictionsΒΆ

Voxelization is part of the checkpoint, so raw logits are per voxel. Voxelize records the mapping under dst_inverse_key, and logits[inverse] scatters predictions back to every original point. Registered pipelines set it and keep the source cloud under origin_pos / origin_segment, so full-resolution scoring is logits[inverse] against origin_segment.

import torch
import torch_pointcloud.transforms as T

torch.manual_seed(0)

transform = T.Compose([
    T.Shift(keys="pos", method="min"),
    T.CopyItems(keys=["pos", "segment"], dst_keys=["origin_pos", "origin_segment"]),
    T.Voxelize(
        pos_key="pos",
        pos_reduce="grid",
        size=0.02,
        keys=["color", "segment"],
        dst_inverse_key="inverse",
    ),
])

sample = {
    "pos": torch.rand(20_000, 3) * 4.0,
    "color": torch.rand(20_000, 3) * 255,
    "segment": torch.randint(0, 20, (20_000,)),
}
sample = transform(sample)
print(f"Sample keys: {sample.keys()}")
print(f"Pos shape: {tuple(sample['pos'].shape)}")
print(f"Inverse shape: {tuple(sample['inverse'].shape)}")

# Sample keys: dict_keys(['pos', 'color', 'segment', 'origin_pos', 'origin_segment', 'inverse'])
# Pos shape: (19975, 3)
# Inverse shape: (20000,)

For rooms too large for one forward pass, an inferer tiles or sub-samples the scene and stitches the partial predictions back into one \((N, C)\) output.

Evaluate on a sceneΒΆ

Score the predictions with the helpers in torch_pointcloud.metrics.

from torch_pointcloud.metrics import accuracy, confusion_matrix, intersection_over_union

# `preds` is at full resolution, so score it against the labels of the source cloud
cm = confusion_matrix(
    preds,
    data["origin_segment"],
    num_classes=model.num_classes,
    ignore_index=-1,
)
iou = intersection_over_union(cm, average="none")
present = cm.sum(1) > 0

print(f"accuracy {accuracy(cm):.4f}")
print(f"mIoU     {iou[present].mean():.4f}")
# accuracy 0.8856
# mIoU     0.7073

Ignore the unlabeled points

ignore_index=-1 drops the unlabeled points from the confusion matrix.

Train from scratchΒΆ

The loop below trains pointnext-sm from scratch on the ShapeNetPart airplanes, for a single demo epoch.

from tqdm.auto import tqdm
from torch.nn import functional as F
from torch.utils.data import DataLoader

import torch
import torch_pointcloud as tp
import torch_pointcloud.transforms as T
from torch_pointcloud.datasets import ShapeNetPart
from torch_pointcloud.utils.data import collate
from torch_pointcloud.config import DATA_DIR

device = "cuda"

# Setup the dataset and dataloader
train_dataset = ShapeNetPart(
    DATA_DIR,
    split="train",
    categories="Airplane",
    transform=T.Rescale(keys="pos"),
)
train_dataloader = DataLoader(
    train_dataset,
    batch_size=16,
    shuffle=True,
    num_workers=4,
    collate_fn=collate,
)

# Create the desired model and optimizer
model = tp.create_model(
    "pointnext-sm",
    task="semantic-segmentation",
    in_channels=0,
    num_classes=50,
).to(device)
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-4,
)

# Training loop
num_epochs = 1  # a demo pass, raise it to actually train

model.train()
for epoch in range(num_epochs):
    total_loss = 0.0
    pbar = tqdm(enumerate(train_dataloader), total=len(train_dataloader), desc=f"Epoch {epoch}")
    for i, data in pbar:
        pos = data["pos"].to(device)
        target = data["segment"].to(device)
        batch = data["batch"].to(device)

        optimizer.zero_grad()
        logits = model(None, pos, batch)
        logits = F.log_softmax(logits, dim=1)
        loss = F.nll_loss(logits, target)

        loss.backward()
        optimizer.step()
        total_loss += loss.item()
        if (i + 1) % 10 == 0:
            loss_step = loss.item()
            metrics = {"train/loss_step": f"{loss_step:.3f}"}
            pbar.set_postfix(metrics)

    loss_epoch = total_loss / len(train_dataloader)
    print(f"Loss epoch {epoch}: {loss_epoch:.3f}")


# Loading: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 1958/1958 [00:00<00:00, 29210.61it/s]
# Epoch 0: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.25it/s, train/loss_step=0.275]
# Loss epoch 0: 0.673
# Epoch 1: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.30it/s, train/loss_step=0.220]
# Loss epoch 1: 0.244
# Epoch 2: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.30it/s, train/loss_step=0.220]
# Loss epoch 2: 0.228
# Epoch 3: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 44.70it/s, train/loss_step=0.223]
# Loss epoch 3: 0.221
# Epoch 4: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.24it/s, train/loss_step=0.190]
# Loss epoch 4: 0.215
# Epoch 5: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.52it/s, train/loss_step=0.231]
# Loss epoch 5: 0.211
# Epoch 6: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.86it/s, train/loss_step=0.170]
# Loss epoch 6: 0.208
# Epoch 7: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.59it/s, train/loss_step=0.168]
# Loss epoch 7: 0.205
# Epoch 8: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 45.94it/s, train/loss_step=0.195]
# Loss epoch 8: 0.199
# Epoch 9: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 123/123 [00:02<00:00, 44.63it/s, train/loss_step=0.161]
# Loss epoch 9: 0.198

Outdoor LiDARΒΆ

The same steps run a LiDAR segmentation model on a sample scan. The spvcnn-119gmacs.semantickitti.mit-han-lab model takes intensity rather than color and voxelizes at 5 cm.

LiDAR scan and predicted classes

Read a scan with intensity and ground truth labels from SemanticKITTI, after getting the dataset from its download page and extracting it under data/SemanticKITTI/raw/.

CheckpointsΒΆ

3030404050506060707080809090Β±1 pointReference scoreMeasured score
CheckpointParams (M)Ref.MeasuredΞ”License
dales mIoU
ptv3.dales.jayakumar-pujar103.869.2968.70βˆ’0.59MIT
parislille3d mIoU
kpfcnn-deform.parislille3d.open3d14.976.7 91.83+15.13MIT
randlanet.parislille3d.open3d5.070β€ˆβ€‡β€‡83.05+13.05MIT
S3DIS Area 1 mIoU
pointnext-xl.s3dis-area1.openpoints41.6–79.59MIT
pointnext-lg.s3dis-area1.openpoints7.1–78.98MIT
pointnext-base.s3dis-area1.openpoints3.8–77.73MIT
pointnet2.s3dis-area1.openpoints0.97–74.96MIT
pointnext-sm.s3dis-area1.openpoints0.79–74.46MIT
dgcnn.s3dis-area1.an-tao0.99–69.19MIT
S3DIS Area 2 mIoU
pointnext-xl.s3dis-area2.openpoints41.6–63.16MIT
pointnext-lg.s3dis-area2.openpoints7.1–61.81MIT
pointnext-base.s3dis-area2.openpoints3.8–58.60MIT
pointnet2.s3dis-area2.openpoints0.97–48.31MIT
pointnext-sm.s3dis-area2.openpoints0.79–47.63MIT
dgcnn.s3dis-area2.an-tao0.99–43.50MIT
S3DIS Area 3 mIoU
pointnext-xl.s3dis-area3.openpoints41.6–84.95MIT
pointnext-lg.s3dis-area3.openpoints7.1–84.06MIT
pointnext-base.s3dis-area3.openpoints3.8–84.00MIT
pointnet2.s3dis-area3.openpoints0.97–76.36MIT
pointnext-sm.s3dis-area3.openpoints0.79–75.94MIT
randlanet.s3dis-area3.open3d5.070.9 70.43βˆ’0.47MIT
dgcnn.s3dis-area3.an-tao0.99–68.73MIT
kpfcnn.s3dis-area3.open3d18.360β€ˆβ€‡β€‡60.85+0.85MIT
S3DIS Area 4 mIoU
pointnext-lg.s3dis-area4.openpoints7.1–65.09MIT
pointnext-xl.s3dis-area4.openpoints41.6–64.78MIT
pointnext-base.s3dis-area4.openpoints3.8–62.63MIT
pointnet2.s3dis-area4.openpoints0.97–60.05MIT
pointnext-sm.s3dis-area4.openpoints0.79–59.94MIT
dgcnn.s3dis-area4.an-tao0.99–50.68MIT
S3DIS Area 5 mIoU
ptv3-base.s3dis-area5.pointcept46.273.6 72.03βˆ’1.57MIT
pointnext-xl.s3dis-area5.openpoints41.671.1 71.24+0.14MIT
pointnext-lg.s3dis-area5.openpoints7.169.3 69.29βˆ’0.01MIT
point-transformer.s3dis-area5.open3d7.869.2 67.72βˆ’1.48MIT
pointnext-base.s3dis-area5.openpoints3.867.5 67.51+0.01MIT
kpfcnn-base-deform.s3dis-area5.hugues-thomas25.967.3 67.05βˆ’0.25MIT
kpfcnn-base.s3dis-area5.hugues-thomas24.466.4 66.60+0.20MIT
kpfcnn-base-sm-deform.s3dis-area5.hugues-thomas25.666.7 66.01βˆ’0.69MIT
kpfcnn-base-sm.s3dis-area5.hugues-thomas23.965.4 65.27βˆ’0.13MIT
pointnext-sm.s3dis-area5.openpoints0.7964.2 64.30+0.10MIT
pointnet2.s3dis-area5.openpoints0.9763.6 63.67+0.07MIT
pvcnn2.s3dis-area5.mit-han-lab13.759.0259.95+0.93MIT
pvcnn2-c0p5.s3dis-area5.mit-han-lab3.458.3059.83+1.53MIT
pvcnn.s3dis-area5.mit-han-lab2.656.6457.51+0.87MIT
pointnet2.s3dis-area5.xu-yan0.9753.5 54.83+1.33MIT
pvcnn-c0p25.s3dis-area5.mit-han-lab0.1753.0853.31+0.23MIT
dgcnn.s3dis-area5.an-tao0.99–50.30MIT
pvcnn-c0p125.s3dis-area5.mit-han-lab0.0448.7549.25+0.50MIT
S3DIS Area 6 mIoU
pointnext-lg.s3dis-area6.openpoints7.1–85.90MIT
pointnext-base.s3dis-area6.openpoints3.8–84.62MIT
pointnext-sm.s3dis-area6.openpoints0.79–83.23MIT
pointnet2.s3dis-area6.openpoints0.97–82.46MIT
dgcnn.s3dis-area6.an-tao0.99–75.60MIT
ScanNet20 mIoU
concerto-large-lp.scannet20.pointcept207.777.5 78.59+1.09CC BY-NC 4.0
utonia-base-lp.scannet20.pointcept137.377.7 77.700.00CC BY-NC 4.0
ptv3-base.scannet20.pointcept46.277.6 77.42βˆ’0.18MIT
spunet-v1m1.scannet20.pointcept39.275.6775.670.00MIT
octformer-base.scannet20.octree-nn44.074.8 74.78βˆ’0.02MIT
sonata-base-lp.scannet20.fair108.572.5 72.60+0.10CC BY-NC 4.0
spformer-unet.scannet20.open3d17.368β€ˆβ€‡β€‡67.71βˆ’0.29MIT
dgcnn.scannet20.an-tao0.9949.6 52.89+3.29MIT
ScanNet200 mIoU
ptv3-base.scannet200.pointcept46.235.3 35.12βˆ’0.18MIT
octformer-base.scannet200.octree-nn44.131.7 31.71+0.01MIT
semantic3d mIoU
randlanet.semantic3d.open3d5.076β€ˆβ€‡β€‡90.34+14.34MIT
SemanticKITTI mIoU
spvcnn-119gmacs.semantickitti.mit-han-lab21.863.8 63.74βˆ’0.06MIT
spvcnn-47gmacs.semantickitti.mit-han-lab8.861.4 61.59+0.19MIT
spvcnn-30gmacs.semantickitti.mit-han-lab5.560.7 60.700.00MIT
randlanet.semantickitti.tsung-han-wu1.252.9 55.39+2.49MIT
randlanet.semantickitti.open3d1.252.8 54.76+1.96MIT
kpfcnn.semantickitti.open3d18.358β€ˆβ€‡β€‡51.04βˆ’6.96MIT
toronto3d mIoU
randlanet.toronto3d.open3d5.074β€ˆβ€‡β€‡73.88βˆ’0.12MIT
kpfcnn.toronto3d.open3d14.165.6 62.73βˆ’2.87MIT