mcap/tools/export_mask2former.py
Musa Mahmood cb21b73460 [0.0.11] Segmentation phases 1-2: Mask2Former exported to ONNX, and modules/Segment to run it
- segmentation-plan.html: the plan for panoptic segmentation of images, per library folder, stored
  in .mcap chunks (no file version bump), in six phases.
- tools/export_mask2former.py: exports facebook/mask2former-swin-base-coco-panoptic to ONNX (opset 17,
  dynamic batch, 384 x 384 input) and checks it against PyTorch: on 25 images, all 180 segments
  identical (IoU 1.000). Writes the class list with names for people ("sky-other-merged" is "sky"),
  and PyTorch's own segmentations for segment-probe -check.
- modules/Segment: runs the model with onnxruntime (CUDA, else the CPU) and makes segments of its
  outputs as transformers' post_process_panoptic_segmentation does: a segment id map (longest side at
  most 1024) and each segment's class, score, area and bounds. Matches PyTorch on 99.8% of pixels,
  every segment scored 0.6 or more found. 57 ms an image at batch 4 on an RTX 5080 laptop.
- tools/segment_probe.jai (jai first.jai - segment_probe): segments image files; -out draws them,
  -check compares with PyTorch, -dump-input writes the model's input.
- modules/onnxruntime: ort_run_float_outputs, for a model with several outputs.
- first.jai copies the model to bin/segment-models. The model itself is not in git
  (modules/Segment/models, 462 MB); tools/export_mask2former.py makes it.
2026-10-10 03:39:28 +01:00

205 lines
9.5 KiB
Python

# Exports a Mask2Former checkpoint from Hugging Face to ONNX for modules/Segment, then checks the ONNX
# model against PyTorch on a folder of images: the raw logits, and the panoptic segmentation made from
# them, segment by segment. Run once, on a developer machine; mcap only ships the .onnx it writes.
#
# conda create -n mcap-seg python=3.11
# conda run -n mcap-seg pip install torch --index-url https://download.pytorch.org/whl/cpu
# conda run -n mcap-seg pip install transformers onnx onnxruntime pillow scipy timm
# conda run -n mcap-seg python tools/export_mask2former.py --check FOLDER_OF_IMAGES [--reference OUT]
#
# --reference also writes PyTorch's own segmentation of each image, which bin/segment-probe -check
# compares modules/Segment's against.
#
# The model takes pixel_values [batch, 3, 384, 384] (RGB, resized to 384 x 384 without keeping the
# aspect ratio, as the checkpoint's processor does; scaled to 0..1, then ImageNet mean and std) and
# gives class_queries_logits [batch, 100, 134] (133 classes and "no object") and masks_queries_logits
# [batch, 100, 96, 96]. Making segments of those (the panoptic merge) is mcap's, in Jai.
import argparse, json, os, sys, time
import numpy as np
import torch
from PIL import Image
from transformers import AutoImageProcessor, Mask2FormerForUniversalSegmentation
from transformers.utils import logging
logging.set_verbosity_error()
class Logits(torch.nn.Module):
"""The model, from pixel values to the two logit tensors, as the ONNX graph has it."""
def __init__(self, model):
super().__init__()
self.model = model
def forward(self, pixel_values):
out = self.model(pixel_values=pixel_values)
return out.class_queries_logits, out.masks_queries_logits
def export(model, path, size, opset):
wrapper = Logits(model).eval()
dummy = torch.randn(1, 3, size, size)
start = time.time()
torch.onnx.export(
wrapper, (dummy,), path,
input_names=["pixel_values"],
output_names=["class_queries_logits", "masks_queries_logits"],
dynamic_axes={"pixel_values": {0: "batch"}, "class_queries_logits": {0: "batch"}, "masks_queries_logits": {0: "batch"}},
opset_version=opset,
do_constant_folding=True,
dynamo=False,
)
print(f"exported {path} ({os.path.getsize(path) / 1e6:.0f} MB) in {time.time() - start:.0f} s")
class Outputs:
"""What post_process_panoptic_segmentation reads, from ONNX's arrays."""
def __init__(self, class_logits, mask_logits):
self.class_queries_logits = torch.from_numpy(class_logits)
self.masks_queries_logits = torch.from_numpy(mask_logits)
def segments(processor, outputs, size):
"""[(label, mask)] of the panoptic segmentation, at the image's size."""
result = processor.post_process_panoptic_segmentation(outputs, target_sizes=[size])[0]
seg = result["segmentation"]
return [(s["label_id"], (seg == s["id"]).numpy(), s["score"]) for s in result["segments_info"]]
def iou(a, b):
union = np.logical_or(a, b).sum()
return 1.0 if union == 0 else np.logical_and(a, b).sum() / union
def check(model, processor, path, folder, names, providers):
import onnxruntime as ort
session = ort.InferenceSession(path, providers=providers)
print(f"onnxruntime {ort.__version__} on {session.get_providers()[0]}")
files = sorted(f for f in os.listdir(folder) if f.lower().endswith((".jpg", ".jpeg", ".png")))
worst_logit, ious, unmatched, torch_s, onnx_s = 0.0, [], 0, 0.0, 0.0
for name in files:
image = Image.open(os.path.join(folder, name)).convert("RGB")
inputs = processor(images=image, return_tensors="pt")
start = time.time()
with torch.no_grad():
reference = model(pixel_values=inputs["pixel_values"])
torch_s += time.time() - start
start = time.time()
class_logits, mask_logits = session.run(None, {"pixel_values": inputs["pixel_values"].numpy()})
onnx_s += time.time() - start
diff = max(np.abs(class_logits - reference.class_queries_logits.numpy()).max(),
np.abs(mask_logits - reference.masks_queries_logits.numpy()).max())
worst_logit = max(worst_logit, diff)
size = image.size[::-1]
ref = segments(processor, reference, size)
got = segments(processor, Outputs(class_logits, mask_logits), size)
# Each reference segment against the best ONNX segment of the same class.
line = []
for label, mask, score in ref:
best = max((iou(mask, m) for l, m, _ in got if l == label), default=0.0)
ious.append(best)
if best < 0.5:
unmatched += 1
line.append(f"{names[label]} {best:.3f}")
extra = len(got) - len(ref)
print(f" {name}: logits max diff {diff:.1e}; {len(ref)} segments{f' ({extra:+d} in ONNX)' if extra else ''}: " + ", ".join(line[:6]) + (" ..." if len(line) > 6 else ""))
ious = np.array(ious)
print(f"\n{len(files)} images, {len(ious)} segments")
print(f" logits: worst max abs diff {worst_logit:.2e}")
print(f" segment IoU, ONNX vs PyTorch: min {ious.min():.4f}, mean {ious.mean():.4f}, below 0.98: {(ious < 0.98).sum()}, unmatched: {unmatched}")
print(f" time per image: PyTorch {torch_s / len(files) * 1000:.0f} ms, ONNX {onnx_s / len(files) * 1000:.0f} ms (CPU, batch 1)")
return ious.min() >= 0.98 and unmatched == 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", default="facebook/mask2former-swin-base-coco-panoptic")
parser.add_argument("--out", default="modules/Segment/models")
parser.add_argument("--opset", type=int, default=17)
parser.add_argument("--check", help="a folder of images to check the export on")
parser.add_argument("--skip-export", action="store_true", help="check an .onnx already written")
parser.add_argument("--reference", help="write PyTorch's segmentation of the --check images here, for segment-probe -check")
args = parser.parse_args()
processor = AutoImageProcessor.from_pretrained(args.model)
model = Mask2FormerForUniversalSegmentation.from_pretrained(args.model).eval()
size = processor.size["height"]
assert processor.size["width"] == size, "the export assumes a square input"
names = model.config.id2label
os.makedirs(args.out, exist_ok=True)
base = args.model.split("/")[-1]
path = os.path.join(args.out, base + ".onnx")
if not args.skip_export:
export(model, path, size, args.opset)
# The class list, for models.config: index, whether it is a countable thing, the model's name, and
# the name people see and search (has:): COCO's "wall-other-merged" is "wall".
with open(os.path.join(args.out, base + ".classes.txt"), "w", encoding="utf-8") as f:
f.write("@version 1\n")
for i in range(len(names)):
thing = "thing" if i < 80 else "stuff" # COCO panoptic: 80 things, then 53 stuff
f.write(f'class: {i} {thing} "{names[i]}" "{display_name(names[i])}"\n')
print(f"wrote {base}.classes.txt ({len(names)} classes)")
if args.reference:
if not args.check:
sys.exit("--reference needs --check FOLDER, the images to make it of")
write_reference(model, processor, args.check, args.reference)
if args.check:
ok = check(model, processor, path, args.check, names, ["CPUExecutionProvider"])
print("PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
def display_name(name):
"""COCO panoptic's class names for people: no -merged, -other or -stuff, spaces for dashes."""
for suffix in ("-merged", "-other", "-stuff"):
name = name.replace(suffix, "")
return name.replace("-", " ").strip()
MASK_MAX_SIDE = 1024 # mcap's mask map: the image scaled so its longest side is at most this
def map_size(width, height):
"""The mask map's size for an image, as modules/Segment works it out (rounding half up)."""
scale = min(1.0, MASK_MAX_SIDE / max(width, height))
return max(1, int(width * scale + 0.5)), max(1, int(height * scale + 0.5))
def write_reference(model, processor, folder, out):
"""PyTorch's panoptic segmentation of each image, at the mask map's size, for segment-probe -check:
NAME.ref (b"M2FR", u16 width, u16 height, then a u8 segment id per pixel, row by row) and
NAME.ref.txt (a line per segment: id, class index, score)."""
import struct
from PIL import ImageOps
os.makedirs(out, exist_ok=True)
files = sorted(f for f in os.listdir(folder) if f.lower().endswith((".jpg", ".jpeg", ".png")))
for name in files:
image = ImageOps.exif_transpose(Image.open(os.path.join(folder, name))).convert("RGB") # turned, as mcap shows it
w, h = map_size(*image.size)
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
outputs = model(pixel_values=inputs["pixel_values"])
result = processor.post_process_panoptic_segmentation(outputs, target_sizes=[(h, w)])[0]
seg = result["segmentation"].numpy().astype(np.int64)
seg[seg < 0] = 0 # no segment
assert seg.max() < 256
with open(os.path.join(out, name + ".ref"), "wb") as f:
f.write(b"M2FR" + struct.pack("<HH", w, h) + seg.astype(np.uint8).tobytes())
with open(os.path.join(out, name + ".ref.txt"), "w", encoding="utf-8") as f:
for s in result["segments_info"]:
f.write(f"{s['id']} {s['label_id']} {s['score']:.6f}\n")
print(f"wrote references for {len(files)} images to {out}")
if __name__ == "__main__":
main()