Skip to content

idol: BiRefNet background removal and preprocessing on Metal, so tf-idol-generate takes a raw photo (stacked on #1780) - #1787

Draft
jayleaton wants to merge 24 commits into
ashhart:mainfrom
jayleaton:idol-birefnet
Draft

jayleaton wants to merge 24 commits into
ashhart:mainfrom
jayleaton:idol-birefnet

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1780 (and through it #1773, #1774, #1775, #1766). Its own change is the last two commits (ee8343a4a, b0642cb1d). Review after #1780.

What this changes

Idol's background removal (BiRefNet) and its image preprocessing natively on Metal, so tf-idol-generate takes a raw photo. For #1757: with this, the whole IdolPipeline runs natively on M5 Metal, from a photo to a 3DGS PLY. Details: the "Background removal and preprocessing" part of docs/recipes/idol.md.

  • BiRefNet (Swin-L backbone with window attention and relative position bias, ASPP with deformable convolution, the decoder) in zig/kernels/metal/idol_birefnet.metal and birefnet*.zig, with tf-idol-birefnet and an idol_birefnet_case.py case. These are Idol's own kernels; no shared kernel changes.

  • Preprocessing on the host (preprocess.zig, photo.zig), as idol/preprocess.py does it:

    • Pillow LANCZOS on the short side;
    • BiRefNet's alpha, unless the photo has real alpha;
    • MinFilter(3) erosion;
    • the square crop at the mode's margin, LANCZOS to 1,024, pasted on black.

    Pillow's fixed-point LANCZOS is matched byte for byte. Photos are decoded by core/picture. No EXIF orientation is applied, matching the SDK.

Weights and licence

birefnet.safetensors in the speridlabs/idol snapshot (Apache 2.0, ungated). It is ZhengPeng7/BiRefNet at e2bf8e4460fc8fa32bba5ea4d94b3233d367b0e4 (MIT, ungated) in fp16: all 742 of its tensors equal the upstream checkpoint's byte for byte, and it leaves out 12 side-output tensors (decoder.conv_ms_spvn_*) that only training uses.

CLI additions to tf-idol-generate (#1780)

Additive only: every existing argument, flag, default and output is unchanged, and a safetensors PICTURE gives the same JSON keys as before.

  • PICTURE may be a .png, .jpg, .jpeg or .webp photo (any case of extension), preprocessed natively; BiRefNet's weights come from SNAPSHOT_DIR/birefnet.safetensors, which the snapshot already holds.
  • --mode synthetic|real: the SDK's mode; default synthetic (crop margin 1.2), real is 1.7.
  • --crop-margin X overrides the mode's margin.
  • A photo also writes OUT_DIR/preprocessed.png, the 1,024 x 1,024 RGB the encoders saw.
  • A photo's tf-idol-generate.json adds preprocess_s and birefnet_s; for a photo, load_s includes BiRefNet's load. stderr gains a preprocess: line.

Precision

The SDK runs BiRefNet in fp16. This port computes in fp32 on the fp16 weights. br_conv adds its sums in blocks of 256 terms: one running sum over the squeeze's 51,840 terms put it at twice torch's distance. BiRefNet's input matches torch's CPU bilinear fma(t0, w0, t1 * w1) per axis, so it is byte-identical to the SDK's. Attention keeps fp32 scores and sums and rounds P itself before P V (#1772).

Kernel contract

zig/tests/contract/metal_idol_birefnet*.zig: 24 kernels, 21 controls.

SUMMARY 24 kernels: 24 pass gates 1-4, 0 fail, 0 known findings; 21 controls: 21 red as planted, 0 missed; 0 limit problems

All of Idol's together (--only "metal idol"):

SUMMARY 81 kernels: 81 pass gates 1-4, 0 fail, 0 known findings; 78 controls: 78 red as planted, 0 missed; 0 limit problems

Two limits:

  • br_window, br_gate and br_head take gate 4's stated-reason path. Simulated fp16 sums would miss by only 61x, 84x and 3x, so gate 4 does not itself prove that those three need fp32 sums.
  • The contract's harness checks that every output value is written, so it cannot cover strided outputs (br_conv's yoff/row0, br_norm's and br_resize's ldy/yoff). The end-to-end gates exercise them.

Receipt

  • Environment: Mac Studio M5 Ultra 256 GB, macOS 27; this branch on idol: tf-idol-generate, one picture to a 3DGS PLY end to end on Metal (stacked on #1773, #1774, #1775) #1780's 192c4e902; idol-sdk 4702a7f0 on torch 2.13.0 (MPS).

  • Photos: the avocado input, a WebP, and an opaque photo in synthetic and real mode (BiRefNet runs), plus an RGBA photo with real alpha (BiRefNet skipped).

  • Exactness:

    • Host steps: the resized photo, BiRefNet's input, and the alpha from the SDK's own logits are byte-identical to the SDK's on every photo. The picture built from the SDK's alpha is byte-identical too.
    • BiRefNet's logits against a float64 ruler, worst row ours / torch: avocado 3.84e-6 / 4.15e-6; WebP 8.15e-6 / 1.14e-5; opaque 1.90e-5 / 1.65e-5. 1 - cos is about 1e-12 for both. The gate is twice torch's distance, worst row and cosine.
    • Each of 24 stages on torch's own input passes, at 0.2 to 1.13 times torch's worst row.
    • The uint8 alpha against the ruler's: 13 / 5 / 6 pixels off for ours, against torch's 11 / 6 / 5, each by 1.
    • The preprocessed picture from our alpha against the SDK's: 0 to 3 pixels off, at most 2 levels. The gate allows twice torch's own maximum here, because one alpha level moves a resized pixel by up to 2, and the SDK's own opaque picture is 2 off the ruler's.
    • Repeats are byte-identical. Planted mismatches fail the unit tests.
  • End to end from the raw photo (seed 42, 50 steps, 262,144 Gaussians), against the SDK's IdolPipeline from the same photo:

    stage ours, opaque SDK, opaque ours, WebP SDK, WebP
    load 1.36 s 2.10 s 1.35 s 2.16 s
    preprocess, BiRefNet included 0.295 s 0.519 s 0.264 s 0.481 s
    DINOv3 0.155 s 0.276 s 0.156 s 0.271 s
    VAE 0.244 s 0.117 s 0.244 s 0.107 s
    denoise 27.49 s 93.94 s 27.51 s 93.92 s
    octree 0.139 s 0.750 s 0.142 s 0.600 s
    Gaussian decoder 0.222 s 0.491 s 0.223 s 0.492 s
    total 30.07 s 101.27 s 30.04 s 100.69 s
    peak footprint 2.77 GB 12.88 GB 2.76 GB 12.92 GB
  • Tests: zig build test test-golden -Dcpu=apple_m1: 585 of 585 steps, 8,968 of 9,021 tests pass, 53 skipped, 0 fail; goldens 55 of 55 and 322 of 335 equal, 0 unexpected differences. zig fmt --check build.zig zig: clean. lean_check.py: 424, the same as main.

  • Not run: CUDA (none in this PR); Metal M1 to M4. JPEG: see below.

JPEG

core/picture decodes JPEG only through libjpeg-turbo at exactly the pinned 3.1.4.1. To measure JPEG input I built that release from its official tarball (sha256 ecae8008…a022, equal to GitHub's digest for the release asset) into a private prefix, and pointed only TensorFold's runs at it with TENSORFOLD_LIBTURBOJPEG. The build reports libjpeg-turbo version 3.1.4.1, with the arm64 NEON SIMD path. The system's and Homebrew's libraries were not touched.

The test photo is the opaque avocado photo saved by Pillow as a quality-92 JPEG, 1,280 x 960. The SDK decodes it with Pillow 13.0.0, whose bundled libjpeg-turbo is 3.2.0.

  • tf-idol-birefnet: all gates pass.
    • The resized photo and BiRefNet's input are byte-identical to the SDK's (0 values differ), so our 3.1.4.1 decode equals Pillow's 3.2.0 decode on this photo.
    • Logits worst row 8.44e-6 (torch 1.20e-5), 1 - cos 9.0e-13 (torch 1.3e-12).
    • The alpha and the preprocessed picture are byte-identical to the SDK's. Against the ruler, 11 and 22 pixels are off, by at most 1 and 2, the same counts as torch's.
    • Three more forwards give identical bytes.
  • tf-idol-generate from the JPEG, against the SDK's IdolPipeline from the same file:
    • 29.90 s total against 100.62 s: preprocess 0.257 s (BiRefNet 0.213 s) against 0.477 s, denoise 27.48 s against 93.86 s;
    • peak footprint 2.76 GB against 12.93 GB;
    • preprocessed.png equal to the SDK's input picture, 0 pixels different;
    • with the SDK's VAE and starting noise injected: sampled NN 0.00935 (baseline 0.00911), full-cloud 0.00177, sample cosine 0.99868, camera yaw/pitch 1.6866/0.4974 against 1.6888/0.4930;
    • with native noise the PLYs differ, as idol: tf-idol-generate, one picture to a 3DGS PLY end to end on Metal (stacked on #1773, #1774, #1775) #1780 explains.
  • core/picture's own goldens test ("every picture 0.6.6 decoded ...") ran its JPEG pixel cases with the library and passed. Without the library it prints that they were skipped.
  • zig build test test-golden -Dcpu=apple_m1 with the variable set: 585 of 585 steps; goldens 55 of 55 and 322 of 335 equal, 0 unexpected differences.

A question for review, about the pin: the check refuses any libjpeg-turbo whose built-in version string is not exactly 3.1.4.1. Homebrew's current 3.2.1 is refused by name, so a stock Mac decodes no JPEG until someone builds 3.1.4.1 by hand. On this photo, 3.1.4.1 gave the same pixels as Pillow 13's bundled 3.2.0. Should the pin stay an exact match, or accept a set of releases whose decode is verified against the goldens (for example 3.1.4.1 and 3.2.x)? I have not changed the pin.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. (No token path.)
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (n/a.)
  • No precision traded for speed. If the bits change, the description says which sums change and why.
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's SUMMARY line is in the receipt.
  • Prompt processing is no slower than the last release. (Not touched.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

Every pipeline of idol.metal and idol_attention.metal is registered: id_linear's eight forms (plain and GELU),
id_split's two, id_gemm32, id_norm (plain, affine, modulated), id_norm32 (affine, modulated), id_norm_split,
id_repo, id_qk, id_gate_add, id_embed, id_cond, id_bias32 and the attention's four forms. Each output is checked
at idol-sdk's fp16 points as the range its fp32 steps allow; the NAX projections against the tensor unit's
16-deep step bound, the attention against attention_ref's formula with P at the tensor unit's fp32. Inputs at the
large served shapes repeat at periods prime to every tile, so float64 stays cheap and every output is checked.

19 controls: rounding points dropped or added (gate 3), fp16 sums in id_norm32, id_repo, id_qk, id_embed and
id_gemm32, and the NAX fp32 path in id_gemm32's place (gate 4), and the attention's tail keys unmasked.
Gate 5 holds the 24 launches, each reserved by its source.

Known finding: id_qk gives a head holding a NaN +-inf in its other outputs where F.normalize gives NaN, as
Metal's max drops the NaN norm that clamp_min keeps.
…f-idol-vae and its case

Builds the denoiser's feature2 [4101, 128] from the preprocessed picture as idol-sdk's ImageEncoder does: the
encoder, quant_conv, the posterior's sample with the SDK's noise as an input, the 2 x 2 patches, the batch norm's
statistics and five zero rows. fp32 throughout on the SDK's bf16 weights (FLUX.2-klein-4B, or FLUX.2-dev rounded):
Winograd 3x3s, implicit-GEMM downsamples and 1x1s, two-pass GroupNorm, single-head attention.

idol_case.py vae records the SDK's MPS rows, its noise and a float64 ruler; tf-idol-vae gates worst row and
1 - cosine within twice the SDK's distance from float64 (ours 5.1e-5 and 5.0e-11 against 2.7e-2 and 5.1e-5), and
repeat bytes. All eight kernels register with the contract: 13 forms pass gates 1-4, 12 controls red.
The octree decoder (4 blocks) and Gaussian decoder (16 blocks) run natively on
Metal from the released checkpoint. Host-side pieces: systematic resampling with
a pluggable RNG (native PRNG, or the SDK's recorded draws), compaction, leaves
and jitter, activations, the PLY writer and the camera JSON. tf-idol-decode
decodes a latent and gates it against an idol_case.py `decode` case.

New kernels live in idol_decoder.metal: the point embedding, K/V and head
packing, an fp32 attention for <= 8 query rows, and a copy of the idol
attention whose P.V uses an fp16 hi/lo split. This removes the -2^-12 per-pass
bias that fp32 P picks up on the tensor unit.

Gates are measured against fp32, allowing twice the worst of: the SDK's own
distance, the fp16 floor, and seeded nudged SDK reruns. Further gates:
per-block teacher-forced checks, counts identical given the SDK's
probabilities and draws, activated fields, the PLY's columns, the camera, and
byte-identical repeats. The comparators are covered by plant-a-mismatch unit
tests.

idol_case.py gains `decode`, which pins decoder.py, gaussian.py and camera.py by
sha256 and records levels, draws, counts, fields, the PLY and timings.
metal_idol_decoder*.zig registers dec_embed, dec_half, dec_kv, dec_heads,
dec_attention_few and dec_attention_{00,01,10,11} as "metal idol decoder
...", against float64 at idol-sdk's fp32 and fp16 points. Fifteen controls
each go red at their gate: an added or dropped rounding point, fp16 sums,
P's lo half dropped (caught on heads built so O is P's fp16 remainder
alone), keys past Lk unmasked, an input written, and the two NaN rules
below undone. Gate-5 records are in idol_records.zig; the common Lib is now
generic over a source and its launchers.

Gate 1 found two real rule breaks, fixed with finite outputs unchanged
(the 262k and 32k decode cases give byte-identical PLYs and gate lines):
dec_heads clamped its norm by max, dropping a NaN; and the tiled attention
gave NaN for an infinite value, because an fp16 lo of 0 or of the opposite
sign met the infinity. The lo half now meets the values clamped to fp16's
finite range (about 2% at 262k, 7% at 1M).

docs/recipes/idol.md gains a Decoders part with the precision, gates,
run-a-case commands, measurements and the contract SUMMARY.
…idol_case.py dino

The denoiser's feature1 [4101, 1280] from the SDK's 1024 x 1024 image, as idol-sdk's ImageEncoder makes it:
transformers' DINOv3ViTModel in bf16, its last_hidden_state, then F.layer_norm in fp32. Weights from the ungated
timm/vit_huge_plus_patch16_dinov3.lvd1689m (DINOv3 License), rounded to bf16 at load. Patch conv as one GEMM, class
token and four registers, 2D RoPE on the patches (transformers' fp32 periods, tables bit-equal to torch's), 32 blocks of
20 heads of 64 with LayerScale and SwiGLU, rounded where torch on MPS rounds (measured): one rounding a linear or norm,
two for SiLU times up and LayerScale with the residual, three for the rotary. The attention rounds P to bf16 itself.

idol_case.py dino pins idol-sdk's conditioning.py, transformers 5.22.1's DINOv3 files and the weights by sha256, and
writes the SDK's feature1 on MPS, a float32 CPU ruler, every block's rows and each block in float32. With --official it
proves timm's weights Meta's: 550 tensors byte for byte, feature1 bit for bit.

On the avocado: feature1 worst row against float32 0.1353 (torch 0.1686), 1 - cos 5.37e-5 (5.57e-5); every block on
torch's own input within the 2x gate; repeat byte-identical; 0.120 s an encode against the SDK's 0.261 s on MPS.
…ngelog line

Every pipeline of idol_dino.metal is registered: dn_linear_0 and dn_linear_1, dn_norm, dn_final, dn_heads,
dn_scale_add, dn_swiglu and dn_attention, each checked at transformers' bf16 points. 11 controls: a bf16 rounding
added or dropped (gate 3), fp16 sums in dn_norm and dn_final and P V rounded to bf16 each key block (gate 4), the
attention's tail keys unmasked. Gate 5 holds the 8 launches, each reserved by its source.

SUMMARY 8 kernels: 8 pass gates 1-4, 0 fail, 0 known findings; 11 controls: 11 red as planted, 0 missed; 0 limit problems
# Conflicts:
#	CHANGELOG.md
#	docs/kernel-contract.md
#	docs/recipes/idol.md
#	tools/zig/idol_case.py
#	zig/build/metal_targets.zig
#	zig/kernels/metal/sources.zig
#	zig/src/core/contract/idol_records.zig
#	zig/src/families/idol/idol.zig
#	zig/tests/contract/metal_limits.zig
#	zig/tests/contract/metal_main.zig
# Conflicts:
#	CHANGELOG.md
#	docs/kernel-contract.md
#	docs/recipes/idol.md
#	tools/zig/idol_case.py
#	zig/build/metal_targets.zig
#	zig/kernels/metal/sources.zig
#	zig/src/families/idol/idol.zig
#	zig/tests/contract/metal_limits.zig
#	zig/tests/contract/metal_main.zig
…erged case tool stays under 600 lines

The records file's header comment is one line again.
…oders, the denoiser and the decoders to a PLY

Each stage is timed and each model freed after it. The starting noise is torch's CPU-seeded randn, reimplemented;
the VAE's posterior noise and the starting noise can be given for parity with the SDK's MPS draws. The recipe's
End to end section has the stage timings, memory and PLY comparison against the SDK's own IdolPipeline run.
BiRefNet (Swin-L, ASPP-deformable decoder) in fp32 on Metal from the Idol
snapshot's birefnet.safetensors, the SDK's preprocess on the host byte for
byte (Pillow LANCZOS, erosion, crop, paste; torch's CPU bilinear input), the
tf-idol-birefnet gate tool, its case tool tools/zig/idol_birefnet_case.py
and the kernel contract for every idol_birefnet.metal kernel.
PICTURE may be a .png, .jpg, .jpeg or .webp photo, preprocessed natively
with BiRefNet; --mode synthetic|real and --crop-margin X; a photo writes
OUT_DIR/preprocessed.png and adds preprocess_s and birefnet_s to the JSON.
Recipe section and CHANGELOG line.
@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @jayleaton. The Pillow-exact LANCZOS and the byte-identical BiRefNet input are good to see. On the pin: let's keep the exact 3.1.4.1 match in this PR. Accepting a verified set of releases would be its own small PR, with the picture goldens run on each version it admits. This one waits on #1780; please rebase after it lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants