Prerequisites

This notebook reuses tables created by other example notebooks. Run them first:

Fine-tune Mask2Former on Pascal VOC and collect metrics¶

Fine-tune a Mask2Former model for semantic segmentation on the Pascal VOC 2012 tables, and collect per-sample predictions and IoU back into a 3LC run.

image1

We fine-tune Mask2Former on the tables from create-pascal-voc-semseg-table. Mask2Former is a universal segmentation model — it predicts a set of binary masks plus a class for each. We use it in semantic mode: starting from an ADE20k-semantic checkpoint (its Swin backbone and decoders already speak segmentation), we swap in a 21-class head for VOC and fine-tune, then collapse the mask set back to a dense per-pixel label map with the processor’s post_process_semantic_segmentation. Only the classification head is reinitialized, so we fine-tune it at a higher learning rate than the pretrained weights. The void boundary (id 255) is excluded from both the loss and the metrics. We track train/val loss every epoch and, after training, collect per sample on both splits: the predicted segmentation (as RLE), mean IoU, the per-image confusion matrix (via the core metrics helper), and a pooled decoder embedding (reduced to 2D with PaCMAP).

Project setup¶

[ ]:
# Parameters
PROJECT_NAME = "3LC Tutorials - Pascal VOC 2012"
DATASET_NAME = "pascal-voc-2012"
RUN_NAME = "Fine-tune Mask2Former"
RUN_DESCRIPTION = "Mask2Former (semantic mode) fine-tuned on Pascal VOC 2012"
CHECKPOINT = "facebook/mask2former-swin-tiny-ade-semantic"
EPOCHS = 10
BATCH_SIZE = 4
LR = 5e-5  # learning rate for the pretrained weights (backbone + decoders)
HEAD_LR_MULT = 10  # the reinitialized classification head trains at LR * HEAD_LR_MULT
IMAGE_SIZE = 384
NUM_WORKERS = 0  # 0 is safest in notebooks: notebook-defined transforms can't be pickled to
# spawned DataLoader workers. Bump it only when running as an importable module.
SEED = 42
INSTALL_DEPENDENCIES = True

Install dependencies¶

[ ]:
if INSTALL_DEPENDENCIES:
    %pip install -q 3lc transformers torch torchvision tqdm pacmap

Imports¶

[ ]:
import random

import numpy as np
import tlc
import torch
from tlc.data_types import SemanticSegmentation
from tlc.integration.torch.samplers import create_sampler
from tlc.metrics.semantic_segmentation import semantic_segmentation_metrics
from tlc.schemas import SemanticSegmentationRleSchema
from torch.utils.data import DataLoader
from tqdm.auto import tqdm
from transformers import AutoImageProcessor, Mask2FormerForUniversalSegmentation

Class universe¶

VOC has 21 classes (background + 20 objects); void (id 255) is the ignore boundary. background (id 0) is not a value-map class — it rides in the column schema’s metadata — so both the GT and predicted value maps show the 20 objects, with the GT map additionally carrying the tagged void class. id2label / label2id are what we hand to the model’s reinitialized classification head (the model still predicts background as id 0).

[ ]:
VOC_CLASS_NAMES = [
    "background",
    "aeroplane",
    "bicycle",
    "bird",
    "boat",
    "bottle",
    "bus",
    "car",
    "cat",
    "chair",
    "cow",
    "diningtable",
    "dog",
    "horse",
    "motorbike",
    "person",
    "pottedplant",
    "sheep",
    "sofa",
    "train",
    "tvmonitor",
]
BACKGROUND_ID = 0
VOID_ID = 255
NUM_CLASSES = len(VOC_CLASS_NAMES)  # 21

GT_CLASSES = {**{i: n for i, n in enumerate(VOC_CLASS_NAMES)}, VOID_ID: "void"}
PREDICTED_CLASSES = {i: n for i, n in enumerate(VOC_CLASS_NAMES)}

id2label = {i: n for i, n in enumerate(VOC_CLASS_NAMES)}
label2id = {n: i for i, n in enumerate(VOC_CLASS_NAMES)}

Load the processor and model¶

The processor turns images (and, for training, segmentation maps) into the mask-classification targets Mask2Former expects, and post-processes predictions back into dense label maps. We set ignore_index to the VOC void id and keep do_reduce_labels=False (VOC’s background is a real class, not an ignore label).

The model loads from an ADE20k-semantic checkpoint; num_labels=21 with ignore_mismatched_sizes=True reinitializes the classification head for VOC while keeping the pretrained backbone and pixel decoder.

[ ]:
processor = AutoImageProcessor.from_pretrained(
    CHECKPOINT,
    size={"height": IMAGE_SIZE, "width": IMAGE_SIZE},
    ignore_index=VOID_ID,
    do_reduce_labels=False,
)

model = Mask2FormerForUniversalSegmentation.from_pretrained(
    CHECKPOINT,
    num_labels=NUM_CLASSES,
    id2label=id2label,
    label2id=label2id,
    ignore_mismatched_sizes=True,
)

Sample transform¶

We feed the table to the DataLoader via Table.with_transform rather than a custom Dataset. The transform returns a (PIL image, dense label map) pair per row; the Mask2Former processor handles resizing and target construction in the collate function. Curation is handled by the sampler (create_sampler drops zero-weight rows), so Dashboard exclusions take effect on the next run.

[ ]:
def voc_sample_transform(sample: dict) -> tuple:
    seg: SemanticSegmentation = sample["mask"]
    return sample["image"].convert("RGB"), seg.mask.astype(np.int64)


def collate_fn(batch: list) -> dict:
    images = [image for image, _ in batch]
    masks = [mask for _, mask in batch]
    return processor(images=images, segmentation_maps=masks, return_tensors="pt")

Metrics collection¶

For each sample we predict at the original image size, then derive the dense label map with post_process_semantic_segmentation. We store the predicted segmentation (as RLE), the mean IoU and the per-image confusion matrix (both from the core helper, void excluded), and a pooled embedding from the transformer decoder’s per-query hidden states.

[ ]:
def collect_metrics(run: tlc.Run, table: tlc.Table, model, device: torch.device, epoch: int) -> float:
    predictions: list[np.ndarray] = []
    ious: list[float] = []
    confusion_matrices: list[list[int]] = []
    embeddings: list[np.ndarray] = []

    model.eval()
    with torch.no_grad():
        for idx in tqdm(range(len(table)), desc="collect", leave=False):
            row = table[idx]
            image = row["image"].convert("RGB")
            seg: SemanticSegmentation = row["mask"]
            width, height = image.size

            inputs = processor(images=image, return_tensors="pt").to(device)
            outputs = model(**inputs)

            pred_map = (
                processor.post_process_semantic_segmentation(outputs, target_sizes=[(height, width)])[0]
                .cpu()
                .numpy()
                .astype(np.int32)
            )

            # Pooled decoder embedding: mean over the object queries -> one vector per image.
            decoder_hidden = outputs.transformer_decoder_last_hidden_state  # (1, num_queries, hidden)
            embeddings.append(decoder_hidden.mean(dim=1).squeeze(0).cpu().numpy().astype(np.float32))

            # Bare label map; the metrics writer serializes it via the predicted_segmentation
            # schema below (which records background id 0 in its metadata), so no wrapper is needed.
            predictions.append(pred_map)

            m = semantic_segmentation_metrics(
                pred_map, seg.mask, GT_CLASSES, background=BACKGROUND_ID, void=VOID_ID, include_background=True
            )
            ious.append(m["mean_iou"])
            confusion_matrices.append([int(x) for cm_row in m["confusion_matrix"] for x in cm_row])

    run.add_metrics(
        {
            "predicted_segmentation": predictions,
            "iou": ious,
            "confusion_matrix": confusion_matrices,
            "embedding": embeddings,
            "epoch": [epoch] * len(ious),
        },
        schema={
            # Background (id 0) rides in the schema metadata, not the value map.
            "predicted_segmentation": SemanticSegmentationRleSchema(
                classes=PREDICTED_CLASSES, background=BACKGROUND_ID
            ),
            "embedding": tlc.schemas.EmbeddingSchema(shape=len(embeddings[0])),
        },
        foreign_table_url=table.url,
    )
    # Average only over images with a defined IoU; a degenerate image yields nan, and a nan headline
    # would be logged into the run object as an invalid JSON token.
    finite = [v for v in ious if np.isfinite(v)]
    return float(np.mean(finite)) if finite else float("nan")

Reproducibility helpers¶

[ ]:
def seed_everything(seed: int) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)

Load the tables and initialize the Run¶

.latest() picks up the newest revision of each table, so retraining consumes any Dashboard curation without code changes.

[ ]:
seed_everything(SEED)

device = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
print(f"Using device: {device}")
model = model.to(device)

train_table = tlc.Table.from_names(table_name="train", dataset_name=DATASET_NAME, project_name=PROJECT_NAME).latest()
val_table = tlc.Table.from_names(table_name="val", dataset_name=DATASET_NAME, project_name=PROJECT_NAME).latest()

print(f"Using train table {train_table.url}")
print(f"Using val table {val_table.url}")

# with_transform returns a map-style view — pass it straight to a DataLoader. create_sampler reads the
# weight column: zero-weight rows (excluded in the Dashboard) are dropped, train is shuffled, val is
# sequential.
train_view = train_table.with_transform(voc_sample_transform)
val_view = val_table.with_transform(voc_sample_transform)
train_sampler = create_sampler(train_table, weighted=False, shuffle=True)
val_sampler = create_sampler(val_table, weighted=False, shuffle=False)
print(f"Train: {len(train_sampler)} of {len(train_table)} rows after weight filtering | Val: {len(val_sampler)}")

run = tlc.init(
    PROJECT_NAME,
    run_name=RUN_NAME,
    description=RUN_DESCRIPTION,
    parameters={
        "checkpoint": CHECKPOINT,
        "epochs": EPOCHS,
        "batch_size": BATCH_SIZE,
        "lr": LR,
        "image_size": IMAGE_SIZE,
        "seed": SEED,
    },
)

train_loader = DataLoader(
    train_view,
    batch_size=BATCH_SIZE,
    sampler=train_sampler,
    num_workers=NUM_WORKERS,
    collate_fn=collate_fn,
)
val_loader = DataLoader(
    val_view,
    batch_size=BATCH_SIZE,
    sampler=val_sampler,
    num_workers=NUM_WORKERS,
    collate_fn=collate_fn,
)

Train¶

Two parameter groups give a discriminative learning rate: the pretrained backbone and decoders update gently at LR, while the freshly-initialized classification head trains at LR * HEAD_LR_MULT so it can catch up to weights that already learned good segmentation features on ADE20k. Each epoch logs train and val loss; the full per-sample metrics collection runs once after the loop.

[ ]:
# Discriminative LR: the reinitialized class head learns faster than the pretrained backbone/decoders.
head_params = list(model.class_predictor.parameters())
head_param_ids = {id(p) for p in head_params}
pretrained_params = [p for p in model.parameters() if id(p) not in head_param_ids]
optimizer = torch.optim.AdamW(
    [
        {"params": pretrained_params, "lr": LR},  # pretrained backbone + pixel/transformer decoders
        {"params": head_params, "lr": LR * HEAD_LR_MULT},  # freshly initialized for VOC's 21 classes
    ]
)

for epoch in range(EPOCHS):
    model.train()
    train_loss = 0.0
    n_batches = 0
    for batch in tqdm(train_loader, desc=f"epoch {epoch}", leave=False):
        optimizer.zero_grad()
        outputs = model(
            pixel_values=batch["pixel_values"].to(device),
            mask_labels=[m.to(device) for m in batch["mask_labels"]],
            class_labels=[c.to(device) for c in batch["class_labels"]],
        )
        loss = outputs.loss
        loss.backward()
        optimizer.step()
        train_loss += loss.item()
        n_batches += 1
    train_loss /= max(n_batches, 1)

    # Cheap val-loss pass every epoch (same loss the model trains on); the expensive per-sample metrics
    # collection runs once after training.
    model.eval()
    val_loss = 0.0
    n_val_batches = 0
    with torch.no_grad():
        for batch in val_loader:
            outputs = model(
                pixel_values=batch["pixel_values"].to(device),
                mask_labels=[m.to(device) for m in batch["mask_labels"]],
                class_labels=[c.to(device) for c in batch["class_labels"]],
            )
            val_loss += outputs.loss.item()
            n_val_batches += 1
    val_loss /= max(n_val_batches, 1)

    log_entry = {"epoch": epoch, "train_loss": train_loss, "val_loss": val_loss}
    tlc.log(log_entry)
    print("  ".join(f"{k}={v:.4f}" if k != "epoch" else f"epoch {v}" for k, v in log_entry.items()))

Collect metrics and reduce embeddings¶

With training finished, we run the full per-sample collection once on both the val and train splits, then fit one PaCMAP model and apply it across every metrics table so they share a single 2D space.

[ ]:
print("Collecting per-sample metrics on val and train...")
collect_metrics(run, val_table, model, device, epoch=EPOCHS - 1)
collect_metrics(run, train_table, model, device, epoch=EPOCHS - 1)

print("Reducing embeddings (PaCMAP)...")
run.reduce_embeddings_by_foreign_table_url(val_table.url, method="pacmap", delete_source_tables=True)

run.set_status_completed()
print(f"Run: {run.url}")

Next steps¶

Open the Run in the 3LC Dashboard to overlay predictions on the images, sort by IoU to find the hardest classes and images, and use the per-image confusion matrix to see which VOC classes get confused. Exclude or relabel samples in the Dashboard, then re-run — .latest() picks up the curated revision automatically.