This folder is the conversion toolchain that turns the upstream PyTorch
MatAnyone2 single-object matting model into the six Core ML
programs that ship inside ../Sources/MatAnyoneKitCoreML and run
real-time on the Apple Neural Engine (stable 30 fps on an iPhone 16 / A18).
To our knowledge this is the first Core ML/ANE port of MatAnyone2. The notes below document what had to change to make a research video-matting model compile for, and stay resident on, the ANE — and the Swift-side work that got it from "runs" to "runs at 30 fps".
MatAnyone2 is a recurrent, memory-based matting network. We export it as six stateless Core ML programs cut at clean stage boundaries, and keep the stateful glue (the memory bank, key/affinity matching, top-k softmax readout, sensory state) in Swift. Per-frame data flows:
┌─────────── per frame ───────────┐
camera frame ──▶ encoder ──▶ (key/shrinkage/selection, pix_feat, f16..f1)
│
├─▶ readout (memory_readout from obj_memory + affinity readout)
├─▶ decoder (logits → alpha, new_sensory)
├─▶ uncert (per-frame uncertainty prob)
└─▶ maskencoder ──▶ objsummary (only on memory frames)
└────────── every memEvery frames ──────────┘
| model | role | runs | inputs (key shapes) | ~ms (Mac M2 ANE) |
|---|---|---|---|---|
encoder |
image → multi-scale feats + key/shrinkage | every frame | image [1,3,512,288] |
9.0 |
readout |
obj-memory + affinity → memory readout | every frame | pix_feat, pixel, sensory, … |
2.92 |
decoder |
feats + readout → alpha logits + new sensory | every frame | f16..f1, memory_readout, sensory |
7.81 |
uncert |
temporal uncertainty | every frame | last/cur_pix_feat, last_mask, … |
0.55 |
maskencoder |
image + mask → mask value (memory write) | every memEvery | image, pix_feat, sensory, masks |
2.0 |
objsummary |
object summarizer (rank-5 matmul/reduce) | every memEvery | masks, mask_value |
0.54 |
Working resolution is portrait 288×512 (manifest.json: working_w/working_h). The matte comes
back as [H*W] and is framed identically to the camera frame (no letterbox), so a compositor samples
it with the same aspect-fill UVs.
The A18 ANE compiler (and the MIL→EIR translator) reject several patterns that a research model emits
freely. Each of these was found by loading MLComputePlan on device and logging exactly which ops
fell off the ANE, then patching the export graph (export.py) until the per-frame models were ANE-
resident. In rough order of impact:
CAResBlockECA attention: rank-3Conv1d→ rank-4Conv2d— the keyANECompile() FAILED (11)fix. The efficient-channel-attention block reshaped to rank-3 and ran aConv1d; the ANE compiler rejected that whole subgraph (and dragged ~8relus off the ANE with it).export.pyrewrites the block to keep everything rank-4 with an equivalentConv2d, which mademaskencoderfully ANE-eligible andreadoutalmost fully.- SDPA decomposition — at
minimum_deployment_target >= iOS18, coremltools mapsnn.MultiheadAttentionto the fusedios18.scaled_dot_product_attentionMIL op, which the device ANE backend has no EIR kernel for, soreadoutfailed MIL→EIR translation and wouldn't even load. Thedecomposed_sdpa()context manager monkeypatches attention into primitive matmul/softmax (numerically identical), keeping iOS18 while staying ANE-eligible. - Negative-index slices → static positive indices —
obj_summaries[..., :-1]/[..., -1:]style slices crash the ANE/GPU compilers (generic_general_slice: Invalid values 0). Rewritten to static positive ranges. - Data-dependent
torch.where→ static elementwise —aux_mask[torch.where(...)] = Falsecompiles to a dynamic nonzero/scatter the ANE can't take; rewritten toaux_mask = aux_mask & ~all_masked. - Splitting
objsummaryoff the per-frame path — the object summarizer is rank-5 with matmul/reduce that brokemaskencoder's ANE compile. It became its own model, pinned to the GPU (unitOverrides["objsummary"] = .cpuAndGPU) and only run on memory frames, so the conv-onlymaskencoderhits the ANE every frame. aten::prodregistration —coreml_prod_op.pyregisters anaten::prod→ MILreduce_prodtranslation that coremltools was missing for this graph.
A leftover identity×1 op on decoder is benign — it's a terminal op that doesn't split the ANE
graph and doesn't cost anything.
The model time was only ~half the budget; the stateful glue had to keep up too:
- Parallelized
topKSoftmaxacross independent columns withDispatchQueue.concurrentPerform(~11 ms → ~2.5 ms). - SIMD fp16 readback —
MLMultiArrayfp16→fp32 viavImageConvert_Planar16FtoPlanarFplus bulkmemcpyfor contiguous trailing runs, instead of per-elementNSNumbersubscripting. - Vectorized
blendwithvDSP_vsub/vDSP_vma. - Back-to-back streamer — keep only the freshest pending camera frame and run matte passes
back-to-back instead of on the camera tick, which broke the 33 ms
alwaysDiscardsLateVideoFramescliff that pinned throughput at 15 fps. (App-side, in your own capture loop.)
Dependencies are managed with uv. From this directory (scripts/):
uv sync # creates ./.venv (Python 3.11) with torch, coremltools, omegaconf, numpyuv run python … then runs inside that venv. Versions are pinned in pyproject.toml / uv.lock
(notably torch==2.7.0, the version coremltools 9 was tested against). The .venv is gitignored.
The upstream MatAnyone2 PyTorch repo + weights are not pip-installable — clone them to
/tmp/matanyone2 and put them on PYTHONPATH. coreml_prod_op is a local module, so . must also
be on PYTHONPATH.
Run everything from this directory (scripts/):
# 1. Export the 6 .mlpackage programs + manifest.json into ./models/
PYTHONPATH=/tmp/matanyone2:. uv run python export.py
# 2. Compile each to .mlmodelc and stage them in the package (precompiled = no launch-time compile)
DST=../Sources/MatAnyoneKitCoreML/Resources/MatAnyone
cp models/manifest.json "$DST/"
for m in encoder uncert readout decoder maskencoder objsummary; do
xcrun coremlcompiler compile "models/$m.mlpackage" "$DST/"
doneThe package loader prefers a sibling .mlmodelc over the .mlpackage, so the bundled precompiled
models load with no compile step at launch.
End-to-end numerical parity against the PyTorch reference — this replays the reference step pattern through the exported models and the Swift memory math, so it covers the memory bank / affinity / top-k softmax readout too:
PYTHONPATH=/tmp/matanyone2:. uv run python dump_e2e_ref.py
swiftc -O -parse-as-library e2e_validate.swift \
../Sources/MatAnyoneKitCoreML/MemoryBank.swift \
../Sources/MatAnyoneKitCoreML/MatAnyoneCoreML.swift \
../Sources/MatAnyoneKitCoreML/MatAnyoneCoreMLEngine.swift \
-o /tmp/e2evalidate && /tmp/e2evalidate| file | purpose |
|---|---|
pyproject.toml / uv.lock |
pinned Python deps (torch/coremltools/omegaconf/numpy) |
export.py |
the converter — model split, ANE graph rewrites, .mlpackage export |
coreml_prod_op.py |
registers aten::prod → MIL reduce_prod (export-time dep) |
harness.py |
Python reference harness for the exported models |
dump_e2e_ref.py |
dumps the end-to-end PyTorch reference (alphas + step pattern) |
e2e_validate.swift |
Swift end-to-end parity harness |