Part Segmentation¶
Open in Colab ยท Download notebook
Part segmentation labels every point of a single object with one of its parts: a chair's legs, back and seat. Models read a packed batch and the object category, and return per-point logits.

ShapeNetPart, the standard benchmark, spreads 50 part ids over 16 categories.
Run a pretrained checkpoint¶
Download the sample_2048.ply (48 kB) to get started. This is a ShapeNetPart airplane with normals, at the 2048 points the checkpoint samples.
The registered transform associated with the checkpoint (with return_info=True) provides the preprocessing used for evaluation and benchmarking on the associated dataset.
import numpy as np
import torch
from plyfile import PlyData
import torch_pointcloud as tp
from torch_pointcloud.datasets import ShapeNetPart
from torch_pointcloud.utils.data import collate
# Load the pretrained model
model, info = tp.create_model(
"pointnext-sm.shapenetpart.openpoints",
task="segmentation",
pretrained=True,
return_info=True,
)
model = model.eval()
# Get associated transform
transform = info["transform"]
# Load the sample point cloud
ply = PlyData.read("sample_2048.ply")["vertex"]
pos = np.stack([ply["x"], ply["y"], ply["z"]], 1).astype("float32")
normal = np.stack([ply["nx"], ply["ny"], ply["nz"]], 1).astype("float32")
sample = {
"pos": torch.from_numpy(pos),
"normal": torch.from_numpy(normal),
"category": torch.tensor(list(ShapeNetPart.category_ids).index("Airplane")),
"segment": torch.zeros(len(pos), dtype=torch.long), # placeholder, subsampled along
}
sample = transform(sample)
# Collate the sample into a batch
data = collate([sample])
print(f"Data keys: {data.keys()}")
# Inference pass, the category enters as a fourth argument
with torch.no_grad():
logits = model(data.get("x"), data["pos"], data["batch"], data["category"])
# Get predictions inside the category's own part ids
part_ids = ShapeNetPart.seg_ids["Airplane"]
preds = logits[:, part_ids].argmax(dim=-1)
print(f"Logits shape: {tuple(logits.shape)}")
print(f"Predicted parts: {preds.unique(return_counts=True)}")
# Data keys: dict_keys(['pos', 'normal', 'category', 'segment', 'height', 'x', 'batch'])
# Logits shape: (2048, 50)
# Predicted parts: (tensor([0, 1, 2, 3]), tensor([886, 648, 358, 156]))
The head scores all 50 part ids at once. The reporting protocol argmaxes inside the four ids the airplane owns, so preds counts its body, wing, tail and engine points rather than indexing the global 50.
Pass a placeholder segment
The transform subsamples the part labels alongside the points, so it expects a segment key even at inference. Any tensor of the right length does.
Inputs and outputs¶
| Argument | Shape | Description |
|---|---|---|
x |
\((N, C)\) or None |
Per-point features usually normals, height. |
pos |
\((N, 3)\) | Coordinates, all objects in the batch concatenated |
batch |
\((N,)\) | Index tensor associating each point to its object |
category |
\((B, 16)\) | One-hot object category, one row per object |
| returns | \((N, 50)\) | Per-point logits over the 50 part ids |
category is per object, not per point: collate stacks it to \((B, 16)\) while pos is concatenated to \((N, 3)\).
Category ids and part ids¶
from torch_pointcloud.datasets import ShapeNetPart
print(len(ShapeNetPart.category_ids), list(ShapeNetPart.category_ids)[:4])
print(ShapeNetPart.seg_ids["Chair"], ShapeNetPart.seg_ids["Table"])
# 16 ['Airplane', 'Bag', 'Cap', 'Car']
# [12, 13, 14, 15] [47, 48, 49]
category_ids is ordered, so list(ShapeNetPart.category_ids).index(name) is the integer the one-hot encodes, and seg_ids[name] is the slice of the 50 outputs that category owns.
Evaluate on a dataset¶
The benchmark metric is instance mIoU: per object, the mean IoU over its own parts, averaged over objects. A part absent from both the prediction and the target counts as 1.0. Class mIoU averages per category first.
You will find several utilities in torch_pointcloud.utils.metrics to score the predictions.
from collections import defaultdict
import numpy as np
import torch
from tqdm.auto import tqdm
import torch_pointcloud as tp
from torch_pointcloud.datasets import ShapeNetPart
from torch_pointcloud.utils.data import PointCloudDataLoader
from torch_pointcloud.utils.metrics import compute_intersection_union
from torch_pointcloud.utils.ops import safe_divide
from torch_pointcloud.config import DATA_DIR
model, info = tp.create_model(
"pointnext-sm.shapenetpart.openpoints",
task="segmentation",
pretrained=True,
return_info=True,
)
model = model.cuda().eval()
dataset = ShapeNetPart(root=DATA_DIR, split="test", transform=info["transform"])
dataloader = PointCloudDataLoader(dataset, batch_size=16, num_workers=6)
names = list(ShapeNetPart.category_ids)
shape_ious = defaultdict(list)
with torch.no_grad():
for data in tqdm(dataloader, desc="Evaluating"):
category = data["category"].cuda()
batch = data["batch"].cuda()
logits = model(data["x"].cuda(), data["pos"].cuda(), batch, category)
preds = logits.argmax(dim=-1)
intersection, union = compute_intersection_union(
preds,
data["segment"].cuda(),
num_classes=50,
batch=batch,
)
for b in range(intersection.shape[0]):
name = names[int(category[b].argmax())]
part_ids = ShapeNetPart.seg_ids[name]
iou = safe_divide(intersection[b, part_ids], union[b, part_ids], default=1.0)
shape_ious[name].append(iou.mean().item())
instance_miou = np.mean([iou for ious in shape_ious.values() for iou in ious])
class_miou = np.mean([np.mean(ious) for ious in shape_ious.values()])
print(f"instance mIoU {instance_miou:.4f} | class mIoU {class_miou:.4f}")
# instance mIoU 0.8587 | class mIoU 0.8348
Train from scratch¶
from tqdm.auto import tqdm
from torch.nn import functional as F
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 PointCloudDataLoader
from torch_pointcloud.config import DATA_DIR
device = "cuda"
# Setup the dataset and dataloader
train_dataset = ShapeNetPart(
DATA_DIR,
split="train",
transform=T.Compose([
T.FarthestPointSample(keys=["pos", "normal", "segment"], pos_key="pos", num_samples=2048),
T.Rescale(keys="pos", method="centroid"),
T.Cat(keys=["pos", "normal"], dst_key="x"),
T.OneHot(keys="category", num_classes=16),
]),
)
train_dataloader = PointCloudDataLoader(
train_dataset,
batch_size=16,
shuffle=True,
num_workers=4,
)
# Create the desired model and optimizer
model = tp.create_model(
"pointnext-sm.shapenetpart.openpoints",
task="segmentation",
in_channels=6,
num_classes=50,
num_categories=16,
).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:
x = data["x"].to(device)
pos = data["pos"].to(device)
target = data["segment"].to(device)
batch = data["batch"].to(device)
category = data["category"].to(device)
optimizer.zero_grad()
logits = model(x, pos, batch, category)
loss = F.cross_entropy(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}")