Convert RF-DETR (Roboflow) object detectors to Core AI
.aimodel assets for efficient inference on macOS and iOS.
This first milestone converts RF-DETR-Nano (384×384, pretrained COCO, 90 classes) and verifies the Core AI model numerically against the PyTorch source. The pipeline is parameterized to extend to the other variants (Small / Medium / Base / Large).
Follows Apple's coreai-models recipe pattern (models/yolo/export.py):
RFDETRNano() ─► deploy nn.Module (.export()) ─► [coreai-opt compression] ─► torch.export.export
─► run_decompositions ─► TorchConverter.to_coreai ─► optimize ─► save_asset(.aimodel)
The optional compression step (quantization / palettization) is selected per optimization
profile — see Optimization profiles. The asset is then optionally
AOT-compiled for the target platform with coreai-build — see Compile.
- The deploy model takes one image tensor
(1, 3, 384, 384)(ImageNet-normalised, NCHW) and returnsdets(1, 300, 4)(normalisedcxcywhboxes) andlabels(1, 300, 91)(class logits incl. a no-object slot). It is NMS-free; decoding is per-class sigmoid → threshold →cxcywh→xyxy(seedecode.py). - The logit column index equals the COCO category id (e.g. column 17 →
cat).
RF-DETR's deploy model exports fine through the legacy torch.onnx.export, but the stricter
torch.export.export (required by coreai-torch) needs two tweaks, both localized to a single
context manager in export_compat.py:
- Data-dependent assert —
MSDeformAttn.forwardcallstorch._assert(sum(H_l·W_l) == flat_len). The left side is tensor-derived, sotorch.exporttreats it as an unbacked symbol and raisesGuardOnDataDependentSymNode. It's pure input validation, sotorch._assertis no-op'd during export (it's the only one in the model path). F.grid_sample→aten.grid_sampler_2d— RF-DETR's deformable attention samples withF.grid_sampleon CPU/CUDA (only its hand-written gather fallback runs on MPS).coreai-torchhas no lowering foraten.grid_sampler_2d, soF.grid_sampleis swapped for the same gather-based bilinear implementation. It's numerically identical (0.0 max-abs diff on the deploy outputs).
Requires uv and Python 3.12.
uv syncThis installs rfdetr, coreai-torch, coreai-core, and friends into .venv.
Dependency note (no quirk):
coreai-torch==0.4.0pinstorch>=2.8,<=2.11andrfdetr>=1.8pinstorch>=2.2;uvresolves both totorch==2.11.0with no conflict, so the plannedpipfallback was not needed. If a future version bump reintroduces a conflict, install withpipforcing coreai-torch's torch version and document it here.
--platform tailors the asset to the target compute unit and sets a sensible default profile:
| Platform | Compute unit | Default profile | Shapes | Goal |
|---|---|---|---|---|
ios |
Neural Engine | small (8-bit palett) |
static, batch 1 | energy / battery |
macos |
GPU | balanced (fp16) |
fixed batch (--batch) |
throughput |
The preferred compute unit is recorded in asset metadata and applied at load time via
SpecializationOptions.from_preferred_compute_unit_kind(...) — there's no build-time "prefer ANE"
flag, so the consumer follows this contract (see verify.py). verify
loads each asset with its platform's preferred unit automatically.
Dynamic batch: RF-DETR's deploy graph bakes the batch size at trace time (the deformable decoder's tensor-derived
value.split(...)), and CoreAI's enumerated-shapes path crashes on it — so true 1..N batch isn't reachable without decoder surgery. Each asset is traced at one fixed batch (--batch, default 1); raise it for macOS/GPU throughput.
Perf caveat: the macOS-26 Python runtime is a ~1.3 s/inference reference path, so this repo verifies authoring correctness (detection agreement), not the ANE/GPU speed/energy payoff — confirm that on macOS 27 hardware. Deeper ANE authoring (BC1S / Conv2d rewrite of the ViT) is deferred for the same reason (see Roadmap).
--profile overrides the accuracy↔size tradeoff independently of platform
(profiles.py):
| Profile | dtype + compression | Asset size | Box drift | When to use |
|---|---|---|---|---|
max-accuracy |
fp32, none | ~103 MB | 0.00 px | reference / debugging |
balanced (macOS default) |
fp16, none | ~52 MB | 0.70 px | best all-round quality |
small (iOS default) |
8-bit palettized | ~27 MB | 0.75 px | near-lossless, smaller |
tiny |
6-bit palettized | ~22 MB | 1.02 px | size-critical on device |
Convert (writes to exports/rfdetr-<variant>-<platform>[-<profile>][-b<N>].aimodel):
uv run rfdetr-convert --variant nano --platform ios # -> rfdetr-nano-ios-small.aimodel (~27 MB, ANE)
uv run rfdetr-convert --variant nano --platform macos # -> rfdetr-nano-macos.aimodel (~52 MB, GPU)
uv run rfdetr-convert --variant nano --platform macos --batch 8 # -> rfdetr-nano-macos-b8.aimodel (GPU throughput)
uv run rfdetr-convert --variant nano --platform ios --profile tiny # -> rfdetr-nano-ios-tiny.aimodel (~22 MB)Other flags: --variant {nano,small,medium,base,large}, --weights <custom.pth>,
--num-classes N, --resolution R, --output-dir DIR, --overwrite.
Verify against the PyTorch source (downloads a COCO sample image on first run; loads with the platform's preferred compute unit):
uv run rfdetr-verify --variant nano --platform ios # ANE-preferred
uv run rfdetr-verify --variant nano --platform macos --batch 8 # GPU-preferred, batch 8
uv run rfdetr-verify --variant nano --asset path/to.aimodel --image my.jpgrfdetr-compile wraps xcrun coreai-build compile, which produces architecture-specific
.aimodelc assets that cut on-device first-run specialization time. It maps --platform to
coreai-build's --platform/--preferred-compute (iOS→Neural Engine, macOS→GPU):
uv run rfdetr-compile --variant nano --platform ios # -> coreai-build ... --platform iOS
uv run rfdetr-compile --variant nano --platform macos --batch 8 # -> ... --platform macOS
uv run rfdetr-compile --variant nano --platform ios --architecture h18p --dry-runcoreai-build ships with a recent Xcode (macOS/iOS 27 SDK) and isn't present in this Xcode, so
the command prints the exact invocation it would run and exits cleanly (deferred) — same pattern as
the runtime checks. It runs for real once the Core AI command-line tools are installed. Example
deferred output:
[COMPILE deferred: coreai-build not found — needs Xcode with the Core AI tools (macOS 27)]
Would run:
xcrun coreai-build compile exports/rfdetr-nano-ios-small.aimodel --platform iOS \
--preferred-compute neural-engine --output compiled
Every asset detects the same 5 objects as the fp32 PyTorch model — 2 cats, 2 remotes, 1 couch — classes matching. Detection agreement (the primary gate) per platform/profile:
| Asset | Compute unit | Detections | Max box diff | Result |
|---|---|---|---|---|
rfdetr-nano-ios-small (8-bit palett, b1) |
Neural Engine | 5 = 5 | 0.75 px | PASS |
rfdetr-nano-macos (fp16, b1) |
GPU | 5 = 5 | 0.70 px | PASS |
rfdetr-nano-macos-b4 (fp16, b4) |
GPU | 5 = 5 | 0.70 px | PASS |
Earlier profile sweep (compression only, batch 1): max-accuracy 103 MB / 0.00 px · balanced
52 MB / 0.70 px · small 27 MB / 0.75 px · tiny 22 MB / 1.02 px — all PASS.
On metrics: raw-tensor PSNR is informational only for this model. The dets/labels tensors
hold 300 queries but only ~5 are real detections — low-bit rounding on the ~295 unused queries tanks
the PSNR while leaving the actual detections essentially unchanged. The primary gate is therefore
decoded-detection agreement (count, class, sub-pixel box, score), which all profiles pass.
The profile ladder comes from a coreai-opt weight-compression sweep
(tools/explore_compression.py, using the
model-compression-exploration skill's tested helpers). Results:
docs/compression_exploration.md ·
scatter.
Key findings on RF-DETR-Nano: palettization beats quantization at equal size (8-bit palett =
0.75 px drift vs 8-bit quant = 3.77 px); 4-bit breaks detection entirely (wrong classes, boxes
off by ~360 px) — so the usable frontier is fp16 → 8-bit → 6-bit palettization. Re-run with
uv run python tools/explore_compression.py.
A common rescue for 4-bit is to compress only the large backbone hard and keep the sensitive decoder
at higher precision. The backbone is 76 % of RF-DETR-Nano's params, so this should capture most
of the size win. We tested it (palett_mixed_enc4_dec8): backbone → 4-bit, transformer/decoder/heads
→ 8-bit. It still breaks (wrong classes, ~390 px drift), and finer group sizes (gs8, gs4) don't
fix it — the DINOv2 backbone itself can't tolerate 4-bit (only 16 LUT centroids), independent of
the decoder. So for this model the encoder is 4-bit-sensitive too, and 6-bit is the practical floor.
Pinpointing which individual layers (if any) tolerate 4-bit needs the per-layer activation-PSNR
sensitivity analysis (see Roadmap) — a blanket encoder/decoder split is too coarse.
Runtime note: Core AI assets declare
minimum_os = v27. This machine runs macOS 26.5, yet thecoreai.runtimePython runtime loaded and executed the assets here, so verification ran for real (it was expected to be deferred). Full on-device behaviour (Neural Engine / GPU placement, AOT compilation) should still be confirmed on macOS 27 hardware.
src/rfdetr_coreai/
convert.py # rfdetr-convert — RF-DETR → .aimodel (platform + profile + batch)
compile.py # rfdetr-compile — AOT compile via coreai-build (COMPILE step)
verify.py # rfdetr-verify — PyTorch vs Core AI, per-platform compute unit
profiles.py # profiles, platform defaults, platform→compute-unit mapping
decode.py # detection decode (per-class sigmoid, cxcywh→xyxy)
export_compat.py # torch.export shims (assert no-op + manual grid_sample)
tools/explore_compression.py # reproducible compression sweep
docs/compression_exploration.{md,png,jsonl} # sweep results
exports/ # generated .aimodel assets (gitignored)
samples/ # downloaded test images (gitignored)
- Deeper ANE authoring (needs macOS 27 to measure the payoff): BC1S layout +
Conv2d(1×1)rewrite of the DINOv2 ViT (157Linearlayers); the deformable-attention gather stays GPU/CPU. - Run AOT compile for real:
rfdetr-compileis wired butcoreai-buildis absent in this Xcode; execute it once the Core AI command-line tools (macOS 27 SDK) are installed. - True dynamic batch: surgery in the deformable decoder's tensor-derived
value.split(...)so the batch dim stays symbolic (currently each asset is a fixed batch size). - Deeper compression: per-layer activation-PSNR sensitivity to find any 4-bit-tolerant layers (the coarse encoder/decoder split didn't work — see Compression exploration).
- Other variants (Small/Medium/Base/Large) and the segmentation/keypoint heads.
- A minimal Swift on-device demo.