mcap/tools/export_mask2former_coreml.py

174 lines
9.7 KiB
Python

# Converts Mask2Former (facebook/mask2former-swin-base-coco-panoptic) to a Core ML model for
# modules/Segment on macOS, then checks it against the ONNX model on a folder of images, segment by
# segment. Run once, on a Mac, after tools/export_mask2former.py; mcap only uses the .mlpackage it writes.
#
# python3 -m venv ~/mcap-coreml
# ~/mcap-coreml/bin/pip install torch==2.7.0 transformers coremltools==9.0 onnxruntime pillow scipy
# ~/mcap-coreml/bin/pip install --no-deps timm
# ~/mcap-coreml/bin/python tools/export_mask2former_coreml.py --check FOLDER_OF_IMAGES
#
# The model as tools/export_mask2former.py exports it (pixel_values [1, 3, 384, 384] to
# class_queries_logits [1, 100, 134] and masks_queries_logits [1, 100, 96, 96]), as an ML Program in
# float32, one image a run (a batch of 4 did not convert). Three things coremltools 9.0 cannot take
# are worked around for the conversion only, none changing what the model computes:
# - it casts a one-element tensor with int(array), which NumPy 2 refuses;
# - its meshgrid refuses the inputs Mask2Former's traced linspaces come out as;
# - Core ML takes tensors of at most 5 dimensions, and the multi-scale deformable attention makes 6
# (batch, queries, heads, levels, points, xy): it is run a level at a time instead, which gives
# the same output in PyTorch (checked below) with at most 5.
# Measured on an M5 Pro: 42 ms an image on the GPU against 166 ms for onnxruntime on the CPU, with
# the same segments (IoU 1.0) on every test image.
import argparse, glob, os, sys, time
import numpy as np
import torch
from torch import nn
from PIL import Image
import coremltools as ct
from coremltools.converters.mil import Builder as mb
from coremltools.converters.mil.frontend.torch import ops as ct_ops
from coremltools.converters.mil.frontend.torch.torch_op_registry import register_torch_op
from transformers import AutoImageProcessor, Mask2FormerForUniversalSegmentation
from transformers.utils import logging
import transformers.models.mask2former.modeling_mask2former as modeling
logging.set_verbosity_error()
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from export_mask2former import Logits
CHECKPOINT = "facebook/mask2former-swin-base-coco-panoptic"
MODELS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "modules", "Segment", "models")
ONNX = os.path.join(MODELS, "mask2former-swin-base-coco-panoptic.onnx")
OUTPUT = os.path.join(MODELS, "mask2former-swin-base-coco-panoptic.mlpackage")
SIZE = 384
_original_cast = ct_ops._cast
def _cast_one_element(context, node, dtype, dtype_name):
x = ct_ops._get_inputs(context, node, expected=1)[0]
if x.can_be_folded_to_const() and np.ndim(x.val) > 0 and np.size(x.val) == 1:
context.add(ct_ops.mb.const(val=dtype(np.asarray(x.val).item()), name=node.name), node.name)
return
_original_cast(context, node, dtype, dtype_name)
ct_ops._cast = _cast_one_element
@register_torch_op(override=True)
def meshgrid(context, node):
"""torch.meshgrid, from its inputs flattened to 1-d first."""
inputs = ct_ops._get_inputs(context, node, expected=[1, 2])
indexing = inputs[1].val if len(inputs) > 1 else "ij"
indexing = ct_ops._get_kwinputs(context, node, "indexing", default=[indexing])[0]
if hasattr(indexing, "val"):
indexing = indexing.val
tensors = [mb.reshape(x=t, shape=[-1], name=f"{node.name}_flat_{i}") for i, t in enumerate(inputs[0])]
sizes = [t.shape[0] for t in tensors]
grids = []
for i, t in enumerate(tensors):
view = [1] * len(tensors); view[i] = -1
reps = list(sizes); reps[i] = 1
grid = mb.tile(x=mb.reshape(x=t, shape=view, name=f"{node.name}_view_{i}"), reps=reps, name=f"{node.name}_grid_{i}")
if indexing == "xy":
grid = mb.transpose(x=grid, perm=[1, 0] + list(range(2, len(tensors))), name=f"{node.name}_xy_{i}")
grids.append(grid)
context.add(tuple(grids), node.name)
def deformable_attention_rank5(self, hidden_states, attention_mask=None, encoder_hidden_states=None, encoder_attention_mask=None,
position_embeddings=None, reference_points=None, spatial_shapes_list=None, level_start_index=None,
output_attentions=False):
"""Mask2FormerPixelDecoderEncoderMultiscaleDeformableAttention.forward, a level at a time."""
if position_embeddings is not None:
hidden_states = self.with_pos_embed(hidden_states, position_embeddings)
if reference_points.shape[-1] != 2:
raise ValueError("only 2-d reference points are rewritten")
B, Q, _ = hidden_states.shape
_, S, _ = encoder_hidden_states.shape
H, L, P, D = self.n_heads, self.n_levels, self.n_points, self.d_model // self.n_heads
value = self.value_proj(encoder_hidden_states)
if attention_mask is not None:
value = value.masked_fill(attention_mask[..., None], float(0))
value = value.view(B, S, H, D)
offsets = self.sampling_offsets(hidden_states).view(B, Q, H, L, P * 2)
weights = nn.functional.softmax(self.attention_weights(hidden_states).view(B, Q, H, L * P), -1)
weights = weights.transpose(1, 2).reshape(B * H, 1, Q, L * P)
values = value.split([h * w for h, w in spatial_shapes_list], dim=1)
sampled = []
for level, (h, w) in enumerate(spatial_shapes_list):
value_l = values[level].flatten(2).transpose(1, 2).reshape(B * H, D, h, w)
offset_l = offsets[:, :, :, level].reshape(B, Q, H, P, 2)
location = reference_points[:, :, None, level, None, :] + offset_l / torch.tensor([w, h], dtype=offset_l.dtype)
grid = (2 * location - 1).transpose(1, 2).flatten(0, 1)
sampled.append(nn.functional.grid_sample(value_l, grid, mode="bilinear", padding_mode="zeros", align_corners=False))
output = (torch.stack(sampled, dim=-2).flatten(-2) * weights).sum(-1).view(B, H * D, Q).transpose(1, 2).contiguous()
return self.output_proj(output), None
def segments_of(processor, class_logits, mask_logits):
holder = type("Outputs", (), {"class_queries_logits": torch.from_numpy(class_logits), "masks_queries_logits": torch.from_numpy(mask_logits)})()
return processor.post_process_panoptic_segmentation(holder, target_sizes=[(SIZE, SIZE)])[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--check", metavar="FOLDER", help="images to check the Core ML model against the ONNX one on")
args = parser.parse_args()
processor = AutoImageProcessor.from_pretrained(CHECKPOINT)
model = Mask2FormerForUniversalSegmentation.from_pretrained(CHECKPOINT).eval()
example = torch.zeros(1, 3, SIZE, SIZE)
with torch.no_grad():
original = Logits(model)(torch.randn(1, 3, SIZE, SIZE, generator=torch.Generator().manual_seed(0)))
modeling.Mask2FormerPixelDecoderEncoderMultiscaleDeformableAttention.forward = deformable_attention_rank5
rewritten = Logits(model)(torch.randn(1, 3, SIZE, SIZE, generator=torch.Generator().manual_seed(0)))
difference = max((a - b).abs().max().item() for a, b in zip(original, rewritten))
print(f"The attention a level at a time, against the original in PyTorch: max difference {difference:.2e}")
if difference > 1e-4:
sys.exit("The rewritten attention does not give the same output.")
traced = torch.jit.trace(Logits(model).eval(), example)
start = time.perf_counter()
ml = ct.convert(traced, inputs=[ct.TensorType(name="pixel_values", shape=(1, 3, SIZE, SIZE))],
outputs=[ct.TensorType(name="class_queries_logits"), ct.TensorType(name="masks_queries_logits")],
convert_to="mlprogram", compute_precision=ct.precision.FLOAT32, minimum_deployment_target=ct.target.macOS14)
ml.short_description = f"{CHECKPOINT}, class and mask logits, float32, for mcap"
ml.save(OUTPUT)
print(f"Wrote {OUTPUT} in {time.perf_counter() - start:.0f} s.")
if not args.check:
return
import onnxruntime as ort
paths = sorted(p for p in glob.glob(os.path.join(args.check, "**", "*"), recursive=True)
if p.lower().endswith((".png", ".jpg", ".jpeg", ".webp")))
if not paths:
sys.exit(f"No images in {args.check}.")
session = ort.InferenceSession(ONNX, providers=["CPUExecutionProvider"])
coreml = ct.models.MLModel(OUTPUT, compute_units=ct.ComputeUnit.CPU_AND_GPU)
total, worst_iou, unmatched, times = 0, 1.0, 0, []
for path in paths:
pixels = processor(images=Image.open(path).convert("RGB"), return_tensors="np")["pixel_values"].astype(np.float32)
ref = session.run(["class_queries_logits", "masks_queries_logits"], {"pixel_values": pixels})
start = time.perf_counter()
got = coreml.predict({"pixel_values": pixels})
times.append(time.perf_counter() - start)
a = segments_of(processor, ref[0], ref[1])
b = segments_of(processor, got["class_queries_logits"].astype(np.float32), got["masks_queries_logits"].astype(np.float32))
for sa in a["segments_info"]:
total += 1
best = 0.0
for sb in b["segments_info"]:
if sb["label_id"] == sa["label_id"]:
ma, mbk = (a["segmentation"] == sa["id"]).numpy(), (b["segmentation"] == sb["id"]).numpy()
best = max(best, (ma & mbk).sum() / max((ma | mbk).sum(), 1))
if best == 0:
unmatched += 1
else:
worst_iou = min(worst_iou, best)
print(f"{len(paths)} images, {total} segments: worst IoU against the ONNX model {worst_iou:.4f}, unmatched {unmatched}; "
f"{np.median(times[1:] or times) * 1000:.0f} ms an image on the GPU")
if unmatched or worst_iou < 0.98:
sys.exit("The Core ML model segments differently from the ONNX one.")
if __name__ == "__main__":
main()