Semantic segmentation in mcap

Planning document · 2026-10-10 · for mcap after 0.0.10

In short

1. Scope

Each opted-in image gets a panoptic segmentation. Every pixel gets a class ("person", "dog", "sky", "wall"), and countable things are split into instances: two people are two segments. The results serve three purposes:

What counts as an image. Imported photos and screenshots, Snagit captures and animated GIFs (their first frame) qualify. Videos are skipped even in opted-in folders: a video's poster frame is an image technically, but frames are the later project. A capture is a video when its catalog entry's media is VIDEO, which comes from VideoCodec in its import.json.

2. The model

Which Mask2Former

Checkpoint (Hugging Face)ClassesSizeUse
facebook/mask2former-swin-base-coco-panoptic133 COCO panoptic: 80 things, 53 stuff~107MDefault. Things are counted as instances, and the stuff classes (sky, wall, grass, road) cover the rest.
facebook/mask2former-swin-large-coco-panopticsame~216MIf base misses too much and the GPU can take it.
facebook/mask2former-swin-tiny-coco-panopticsame~47MThe CPU fallback, when CUDA is unavailable.
facebook/mask2former-swin-base-ade-semantic150 ADE20K, no instances~107MThe alternative, if indoor scenes and objects matter more than counting people. See open questions.

COCO panoptic is the default because a photo library's questions are mostly about things ("photos with the dog", "group shots"), and only the panoptic models separate instances.

Export to ONNX

There is no official ONNX release, so phase 1 exports one with a Python script kept in the repo (tools/export_mask2former.py). It runs once on a developer machine and is never shipped.

Phase 1 result (2026-10-10): passed. Script: tools/export_mask2former.py.
Risk. The export is the one part of this plan that may fail outright. If grid_sample export misbehaves, fall back to the ADE20K semantic SegFormer, which exports trivially, to build phases 2 to 6. Then return to Mask2Former, since everything after phase 1 is keyed by model name.

Packaging, as Labels and OCR are

3. Per-folder opt-in

A library folder is a top-level folder of the library, one per place imported from (SnagitLibraryWork, Phone, Photos 2014). Segmentation runs only in the folders you turn it on for. A new folder starts with it off, so adding a 40,000-photo archive never starts an hours-long GPU job by surprise.

Where the setting lives

It goes in a new file in the library root, .library-folders, next to .import-index and .import-sources. Not in %APPDATA%\mcap\settings.config, because the choice belongs to the library, not to the machine. It follows the library when it is opened elsewhere, or with -library FOLDER. The format follows the Ex4 style used in the rest of mcap, so later per-folder options (OCR off for a photos folder, say) go in the same file:

@version 1

[folder "Photos 2014"]
segment: true

[folder "SnagitLibraryWork"]
segment: false

Where you set it

4. The segmentation scan

.library-folders segment: true Items images not yet done Decode, segment batches on the scan thread mcap_store segs front, mask back catalog_load → viewer.catalog_updates → search and Stats
Only the scan's choice of captures is new. Batching, pause and cancel, storing and the catalog hand-off are the OCR scan's.

5. Storage in .mcap

No file version bump. Version 3 added tagged, model-keyed chunk lists, and the format README already names segmentation masks as what back chunks are for. A reader that does not know a chunk keeps it on rewrite. Each new chunk carries its own internal version, as labl does.

segs, front chunk, keyed by model name

This is what search and Stats need, small enough for the front that every library browse reads. It is text in the style of labl:

@version 1
engine:  "onnxruntime 1.30.0, CUDA"
date:    2026-10-12T09:30:00Z
image:   rendered 4032 3024
classes: 5c1e9a0d77b2f413
segment: 1 0.981 0.2140 1210 380 900 2210 "person"
segment: 2 0.955 0.1022 2300 900 700 1400 "person"
segment: 3 0.912 0.0811 400 1900 800 700 "dog"
segment: 4 0.993 0.3105 0 0 4032 1100 "sky"
KeyValue
engine, dateAs in OCR text.
imageWhich image was read (rendered or original) and its size. Boxes are in its pixels.
classesA 64-bit FNV-1a hash of the model's class list, so a model with new classes is seen as needing a new scan.
segmentThe segment's id in the mask, its score, its area (as a fraction of the image), its box (x y w h), and its class, quoted. Largest first. Stuff classes have one segment per class.

A busy photo has 10 to 30 segments, about 1 to 2 KB.

mask, back chunk, keyed by model name

This is the segment-id map, for drawing only. It is binary:

u8   format        1: run-length encoded, row-major
u16  width         the map's size: the image scaled so its longest side is at most 1024
u16  height
u8   id_bits       8 (up to 255 segments), or 16 later if needed
then runs:         (id, length) pairs over the whole map, row by row, wrapping across rows
                   id as u8; length as a LEB128 varint
                   id 0: no segment (below threshold, or "no object")

Code changes

has: and label: answer different questions. Labels (SigLIP 2) describe the whole image against your own list ("Settings", "Installer", "Beach"). Segments say which COCO things are in it, and where. Both stay, and they can be combined: label:beach has:dog.

7. Capture window overlay

8. Phases

Each phase ends with something to run and check, and is a commit (0.0.11 onwards).

Phase 1: Export and check the model Done

tools/export_mask2former.py exports Swin-B COCO panoptic. It is checked against transformers on 20 or more photos, with the IoU target above, and the timings and peak GPU memory are recorded at batch sizes 1, 2 and 4 on your GPU.

Done when: an .onnx that loads in onnxruntime 1.30 with CUDA and passes the IoU check, plus a note of the numbers.

Phase 2: modules/Segment and the probe Done

The module (create, segment, panoptic merge, cancel), models.config, the copy step in first.jai, and tools/segment_probe.jai. fetch_models.jai waits on where the model will be hosted (open question 2); until then tools/export_mask2former.py makes it.

Done when: segment-probe on the phase 1 photos writes masks that match phase 1's, and runs on both CPU and CUDA.

Result (2026-10-10).
  • Agreement: 99.8% of pixels have the same class as PyTorch's, on both CUDA and the CPU. Every segment PyTorch scores 0.6 or more is found. The test set is the same 25 images.
  • Differences: two people at 0.56 and 0.58 are missed, and there are two extra segments. These are borderline cases, tipped by the input being a step or two (out of 255) off PyTorch's, because stb_image decodes JPEGs slightly differently from libjpeg.
  • Release build, RTX 5080: 57 ms per image at batch 4: preparing 3 ms, the model 21 ms, making segments 30 ms. Peak GPU memory is 2.4 GB. Batch 8 is no faster; batch 1 is 38 ms for the model. On the CPU, about 670 ms per image.

Things learned on the way:

  • The score threshold. transformers 5.19's default is 0.5, not the 0.8 this plan assumed, and the check compares with that default. It's score_threshold in models.config, to raise if weak segments prove noisy in search.
  • Stuff isn't merged per class by default, so two patches of sky can be two segments.
  • Making segments now takes longer than the model. Spreading it across threads can roughly double throughput if the scan needs it.
  • Overlay colours. The overlay colours by class, so neighbouring people share a colour. Phase 6 should vary the shade per instance.

Phase 3: Chunks and storing

segment_chunks.jai (text, RLE), mcap_store with back chunks, the README section, and tests:

Done when: the tests pass and a probe run can store its results into a scratch library's files.

Phase 4: Per-folder opt-in and the scan

The pieces:

Done when: on a scratch library with two folders, only the opted-in one is segmented. Videos in it are skipped. Cancelling mid-batch stores nothing. Erase per folder works.

Phase 5: Catalog, has: and Stats

Done when: has:person finds the right captures in the scratch library, catalog tests cover the new field, and Stats shows the top classes, each clickable.

Phase 6: The overlay

The mask texture on a worker, the legend, hiding classes, opacity and hover.

Done when: masks line up with the image at every fit and zoom, including on Snagit captures (rendered image) and on photos turned by their EXIF orientation.

9. Open questions

  1. COCO panoptic or ADE20K? COCO counts things (people, pets, cars); ADE20K knows more indoor objects and scene parts, but has no instances. The default is COCO. Revisit after phase 1 by trying both on a sample of your photo libraries.
  2. Where to host the exported .onnx for fetch_models.jai: a private Hugging Face repo, a release asset, or committing it as the FFmpeg DLLs are. At about 400 MB for Swin-B, committing it is probably too much.
  3. The mask resolution cap. 1024 on the long side is a guess. Phase 3 should measure chunk sizes on real photos at 512, 1024 and 2048.
  4. Snagit and screenshot folders. They stay off by default, and COCO classes mean little on UI. Turning one on is allowed, but the Segmentation window could warn that most of the folder is screenshots, judged from Format and AppName.
  5. Re-scanning on a model change. A new model name means a new key, so old results stay until erased. Should the scan offer to erase other models' masks to save space? Probably yes, as an explicit button rather than automatically.

10. Later: video frames Shelved

Treat strategically sampled frames of a video as images, and run the same analysis (segmentation, and OCR and labels too) on them. Notes for when it comes back: