CardAnvilModels commited on
Commit
6c2cf70
·
verified ·
1 Parent(s): 90abcec

Keep the source's other files, unchanged, for later

Browse files
README.md CHANGED
@@ -1,33 +1,33 @@
1
- ---
2
- license: mit
3
- library_name: transformers.js
4
- base_model: ZhengPeng7/BiRefNet_lite
5
- pipeline_tag: image-segmentation
6
- tags:
7
- - onnx
8
- - birefnet
9
- - background-removal
10
- - dichotomous-image-segmentation
11
- - webgpu
12
- - transformers.js
13
- - onnxruntime-web
14
- ---
15
-
16
- # BiRefNet-lite 1024 (ONNX, WebGPU-runnable)
17
-
18
- BiRefNet-lite at 1024 × 1024 as an fp16 ONNX export whose graph is patched to run on WebGPU in the browser; the weights are unchanged. [Card Anvil](https://github.com/Card-Anvil) uses it with [Transformers.js](https://github.com/huggingface/transformers.js) to select the main subject of card art.
19
-
20
- This is an unchanged copy of the files Card Anvil loads from [`jiabins0303/birefnet-lite-1024-webgpu`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu) at commit [`1ad01ce`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu/tree/1ad01cef0f4101a285c5a3e0bd7f15597d93403f), kept here so the app loads files that can't change under it. See the source repo for how the graph was patched and verified.
21
-
22
- | File | Source path |
23
- | --- | --- |
24
- | `config.json` | `config.json` |
25
- | `preprocessor_config.json` | `preprocessor_config.json` |
26
- | `onnx/model_fp16.onnx` | `onnx/model_fp16.onnx` |
27
-
28
- ## Credits and licence
29
-
30
- - Model: [BiRefNet](https://github.com/ZhengPeng7/BiRefNet) by Peng Zheng et al. ([`ZhengPeng7/BiRefNet_lite`](https://huggingface.co/ZhengPeng7/BiRefNet_lite)), from *Bilateral Reference for High-Resolution Dichotomous Image Segmentation* (CAAI AIR 2024).
31
- - WebGPU-runnable export: [`jiabins0303/birefnet-lite-1024-webgpu`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu).
32
-
33
- Licensed under the MIT License, as the original model is. See [`LICENSE`](LICENSE).
 
1
+ ---
2
+ license: mit
3
+ library_name: transformers.js
4
+ base_model: ZhengPeng7/BiRefNet_lite
5
+ pipeline_tag: image-segmentation
6
+ tags:
7
+ - onnx
8
+ - birefnet
9
+ - background-removal
10
+ - dichotomous-image-segmentation
11
+ - webgpu
12
+ - transformers.js
13
+ - onnxruntime-web
14
+ ---
15
+
16
+ # BiRefNet-lite 1024 (ONNX, WebGPU-runnable)
17
+
18
+ BiRefNet-lite at 1024 × 1024 as an fp16 ONNX export whose graph is patched to run on WebGPU in the browser; the weights are unchanged. [Card Anvil](https://github.com/Card-Anvil) uses it with [Transformers.js](https://github.com/huggingface/transformers.js) to select the main subject of card art.
19
+
20
+ These are unchanged copies of the files in [`jiabins0303/birefnet-lite-1024-webgpu`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu) at commit [`1ad01ce`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu/tree/1ad01cef0f4101a285c5a3e0bd7f15597d93403f), at the same paths, kept here so the app loads files that can't change under it.
21
+
22
+ | Files | What they are | Loaded by Card Anvil |
23
+ | --- | --- | --- |
24
+ | `config.json`, `preprocessor_config.json`, `onnx/model_fp16.onnx` | The model | Yes |
25
+ | `scripts/patch_split.py`, `scripts/patch_deform.py`, `scripts/verify_patch.py` | The scripts that rewrote the graph to run on WebGPU, and the check that the result matches the original | No, kept for later |
26
+ | `SOURCE_README.md` | The source repo's README: how and why the graph was patched, and how it was verified | No |
27
+
28
+ ## Credits and licence
29
+
30
+ - Model: [BiRefNet](https://github.com/ZhengPeng7/BiRefNet) by Peng Zheng et al. ([`ZhengPeng7/BiRefNet_lite`](https://huggingface.co/ZhengPeng7/BiRefNet_lite)), from *Bilateral Reference for High-Resolution Dichotomous Image Segmentation* (CAAI AIR 2024).
31
+ - WebGPU-runnable export: [`jiabins0303/birefnet-lite-1024-webgpu`](https://huggingface.co/jiabins0303/birefnet-lite-1024-webgpu).
32
+
33
+ Licensed under the MIT License, as the original model is. See [`LICENSE`](LICENSE).
SOURCE_README.md ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: transformers.js
4
+ pipeline_tag: image-segmentation
5
+ base_model: ZhengPeng7/BiRefNet_lite
6
+ tags:
7
+ - onnx
8
+ - background-removal
9
+ - matting
10
+ - alpha-matting
11
+ - image-matting
12
+ - webgpu
13
+ - client-side
14
+ - in-browser
15
+ - transformers.js
16
+ - fp16
17
+ ---
18
+
19
+ # BiRefNet-lite 1024 (ONNX, WebGPU-runnable)
20
+
21
+ A structurally patched ONNX export of **BiRefNet-lite at 1024x1024** that runs on
22
+ **onnxruntime-web's WebGPU execution provider**. The upstream export does not: it
23
+ falls back to the CPU EP on most of its graph and dies of `std::bad_alloc`.
24
+
25
+ **If you have tried to run BiRefNet at 1024 in a browser and hit an
26
+ out-of-memory error, this repo is the fix, and the section below explains what
27
+ was actually wrong.** The usual workaround is to drop to a 512 export. That
28
+ works and it is a reasonable trade — but the OOM is not caused by the
29
+ resolution, and you do not have to pay it.
30
+
31
+ The weights are unchanged. Only the graph topology was rewritten, and both
32
+ rewrites are proved **bit-identical** to the original on the CPU EP
33
+ (`max|diff| = 0.000e+00` over the test set).
34
+
35
+ ## Production use
36
+
37
+ This graph is the `best` quality tier in **[Backgroundless](https://backgroundless.io)**,
38
+ a background remover where the model runs entirely in the visitor's browser —
39
+ no upload, no server round-trip, no account. Both tiers are free to use, so the
40
+ 1024 graph is served to anyone whose adapter can run it.
41
+
42
+ ## Quick start (transformers.js)
43
+
44
+ ```js
45
+ import { AutoModel } from '@huggingface/transformers';
46
+
47
+ const model = await AutoModel.from_pretrained('jiabins0303/birefnet-lite-1024-webgpu', {
48
+ device: 'webgpu',
49
+ dtype: 'fp32', // the file is ALREADY fp16; this stops the
50
+ model_file_name: 'model_fp16', // library appending a dtype suffix
51
+ });
52
+ ```
53
+
54
+ Requires an adapter reporting `maxStorageBuffersPerShaderStage >= 8`. The graph
55
+ emits **logits**, not an image — apply sigmoid, do not read it as 0-255.
56
+
57
+ ## Why the upstream export cannot run on WebGPU
58
+
59
+ Two independent blockers, which have to be fixed in this order because the first
60
+ one masks the second:
61
+
62
+ 1. **59 `Split` nodes with 32 outputs each.** WebGPU's
63
+ `maxStorageBuffersPerShaderStage` is 8 by spec (10 on many adapters), so these
64
+ never compile. Rewritten into trees of <=6 outputs.
65
+ 2. **80 `GatherND` nodes.** `deform_conv2d` has no ONNX operator, so the exporter
66
+ emulates it with `GatherND`, and in the onnxruntime-web build we tested
67
+ (August 2026) the WebGPU EP did not take those nodes: all 80 ran on the CPU
68
+ EP (the verbose log adds a `MemcpyFromHost` after each), which meant materialising a
69
+ `[1,1,64,49,256,256]` fp16 im2col buffer — **392MB**, several live at once — on
70
+ ORT Web's **32-bit wasm heap**. That is the `std::bad_alloc`; the resolution
71
+ itself was never the problem. Rewritten to `Gather` plus int32 index
72
+ arithmetic, and variadic `Sum` to binary `Add`.
73
+
74
+ Host<->device copies drop from 100 to 80 and the graph runs end to end on WebGPU.
75
+
76
+ ## Reproduce it: the patch scripts
77
+
78
+ The three scripts that produced this graph are in [`scripts/`](./scripts) (MIT, like
79
+ the rest of this repo). They are topology-only rewrites and should work on other
80
+ BiRefNet exports that hit the same two blockers.
81
+
82
+ ```bash
83
+ pip install onnx onnxruntime numpy pillow
84
+ python scripts/patch_split.py model.onnx step1.onnx # Split trees, <= 6 outputs
85
+ python scripts/patch_deform.py step1.onnx patched.onnx # GatherND -> Gather, Sum -> Add chain
86
+ python scripts/verify_patch.py model.onnx patched.onnx a.jpg b.jpg # must report EXACT
87
+ ```
88
+
89
+ `patch_deform.py` only rewrites the exact deform-conv `GatherND` pattern and refuses
90
+ to rewrite one unless it can see a clamp upstream, so an index cannot silently wrap.
91
+ `verify_patch.py` runs both graphs on the CPU EP; a structural rewrite should be
92
+ bit-identical, and it says so if it is not.
93
+
94
+ Two things that are easy to get wrong if you redo this:
95
+
96
+ - **Cast to int32 LATE.** Doing the index arithmetic in int32 doubles the
97
+ CPU<->GPU crossings (180 copies / 9.1s vs 80 / 3.4s). Do the maths in int64 and
98
+ cast once, immediately before the `Gather`.
99
+ - **Use a sequential `Add` chain, not a balanced tree.** fp16 addition is not
100
+ associative; a balanced tree changes the summation order and breaks
101
+ bit-identity with the original graph.
102
+
103
+ ## Measurements
104
+
105
+ Against BiRefNet-lite-512 on the same 30-image set, same compositor, same metric
106
+ (`edgeSharpness().gradient`):
107
+
108
+ | | all-30 gradient | products | interiorSoft | browser time (WebGPU) |
109
+ |---|---|---|---|---|
110
+ | BiRefNet-lite-512 | 0.0989 | 0.1095 | 0.00296 | 0.85s |
111
+ | **this (1024)** | **0.1436** | **0.1754** | **0.00043** | **3.4s** |
112
+
113
+ +45% edge gradient for ~4x the time. It also recovers subjects 512 misses
114
+ entirely — one test image returns an empty matte at 512 and 18.4% foreground here.
115
+
116
+ ⚠️ **Read the provenance before quoting these.** The **quality** columns and the
117
+ **timing** column come from different runs. The 1024 gradient figures were measured
118
+ in Node on the **CPU EP**, not on the WebGPU path the timings describe; graph
119
+ bit-identity was proven CPU-vs-CPU for the *rewrite*, which is not the same as
120
+ proving CPU and WebGPU produce identical output. And one flag is still open: this
121
+ graph's product `softEdgeRatio` is 0.01030 against a 0.01574 control — 35% lower —
122
+ which under an anti-aliasing guard means part of the gradient win **could be a
123
+ harder, less anti-aliased edge rather than a better one**. Band width (9.15) and the
124
+ crops both support a genuinely tighter edge, but it is not fully cleared. Treat +45%
125
+ as a strong indication, not a settled result, and re-measure on your own path.
126
+
127
+ ## Which one should you use
128
+
129
+ Not always this one.
130
+
131
+ - **512** is the better default for interactive, one-image-at-a-time UIs, for
132
+ batch jobs where latency multiplies by the file count, and for wide device
133
+ support. 0.85s is a different product feel from 3.4s.
134
+ - **1024 (this)** is worth it when edge quality is the point — hair, fur, fabric,
135
+ fine product edges — or when 512 misses the subject entirely.
136
+
137
+ Shipping both and letting the user choose is a reasonable design; that is what
138
+ Backgroundless does. Only one graph should be resident at a time — switching
139
+ disposes and recompiles.
140
+
141
+ ## Licensing
142
+
143
+ MIT — and unusually for a model, that covers the **weights**, not just the surrounding
144
+ code. No non-commercial clause, no separate model agreement to accept, and nothing in
145
+ it places any condition on the images you produce with it.
146
+
147
+ Worth checking on whatever you end up picking: licensing on browser matting models
148
+ varies a lot, and the gap between a project's *code* licence and its *weights* licence
149
+ is where people get caught. BRIA's RMBG-1.4 card, for example, states the model is
150
+ "available as a source-available model for non-commercial use" — and an MIT-licensed
151
+ wrapper repo around it does not change that, because the wrapper's licence covers the
152
+ wrapper. Read the weights licence, not the repo badge.
153
+
154
+ ⚠️ **One upstream caveat, in fairness.** BiRefNet_lite lists **DIS5K** among its
155
+ training sets, and DIS5K's own terms restrict commercial use of the dataset. That is an
156
+ open question for every model trained on it, this one included, and it is not something
157
+ an MIT grant downstream can resolve. What MIT does give you here is a clean, explicit
158
+ grant from the model's author covering the weights themselves — which is more than
159
+ several alternatives offer, but it is not a warranty about the training data.
160
+
161
+ ## Credits and licence
162
+
163
+ - Original model: **[ZhengPeng7/BiRefNet](https://github.com/ZhengPeng7/BiRefNet)** (MIT) — Peng Zheng et al.
164
+ - ONNX export this was derived from: **onnx-community/BiRefNet_lite-ONNX**
165
+ - **[studioludens/birefnet-lite-512](https://huggingface.co/studioludens/birefnet-lite-512)** —
166
+ Studio Ludens' 512 re-export, and the export method (PyTorch 2.0.1 + Kazuhito00's patched
167
+ deform-conv exporter) that made a working browser BiRefNet possible at all. Their model card is
168
+ also where the "1024-way concatenations" problem is documented, which is what sent us looking at
169
+ the decoder in the first place. **Backgroundless ships their 512 export as its default tier** —
170
+ this 1024 graph is the opt-in one.
171
+ - Kazuhito00's **deform-conv2d-onnx-exporter**, via the above.
172
+ - Graph surgery: this repo. MIT, same as upstream.
173
+
174
+ If you use this, cite the BiRefNet authors — the model is theirs. The only thing
175
+ contributed here is the graph rewrite.
176
+
177
+ ## Provenance
178
+
179
+ `onnx/model_fp16.onnx`
180
+ sha256 `4059896039dfccb0f15b9080ff06d11d90e499449bb045e797055eb8901cf5f4`
181
+
182
+ Reproduce with `patch_split.py` then `patch_deform.py`, and verify with
183
+ `verify_patch.py` (correlation gate plus max-abs-diff against the unpatched
184
+ graph on the CPU EP).
scripts/patch_deform.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Move BiRefNet's deformable-convolution nodes off the CPU execution provider.
3
+
4
+ WHY. `patch_split.py` fixed the FIRST ceiling (WebGPU's storage-buffer limit).
5
+ The graph then still died, and ORT's own verbose log named the second one
6
+ exactly:
7
+
8
+ transformer_memcpy.cc:340 AddCopyNode] Add MemcpyFromHost after
9
+ /decoder/decoder_block1/dec_att/aspp_deforms.2/atrous_conv/GatherND_output_0
10
+ for WebGpuExecutionProvider
11
+
12
+ 100 such copies at 1024, and NOT ONE of them outside `atrous_conv`: 80 GatherND
13
+ plus 20 Sum, across the 20 deformable-conv blocks (decoder_block1-4 and
14
+ squeeze_module, four ASPP branches each). `deform_conv2d` has no ONNX op, so the
15
+ exporter decomposes it into bilinear sampling built from GatherND - and in the
16
+ onnxruntime-web build we tested (August 2026) the WebGPU EP did not take those
17
+ nodes, so they ran on the CPU EP. CPU tensors
18
+ live on the 32-bit wasm heap, and at 1024 each GatherND output is
19
+
20
+ [1, 1, 64, 49, 256, 256] fp16 = 392MB
21
+
22
+ with five live at once. That is the `std::bad_alloc`. At 512 the same tensors
23
+ are 98MB and it fits, which is the entire reason 512 ships and 1024 does not.
24
+
25
+ WHAT THIS DOES. Rewrites each GatherND into ops the WebGPU EP demonstrably has -
26
+ plain `Gather` is used 72 times elsewhere in this graph and never appears in the
27
+ memcpy list, so it is known-supported here rather than assumed:
28
+
29
+ GatherND(data[1,1,H,W,C], idx[1,1,P,2], batch_dims=2) -> [1,1,P,C]
30
+ becomes
31
+ flat = Cast(Reshape(idx,[P,2]), int32) # WebGPU has no int64 maths
32
+ y,x = Slice(flat,0:1,axis1), Slice(flat,1:2,axis1)
33
+ lin = Reshape(Add(Mul(y, W), x), [P])
34
+ out = Reshape(Gather(Reshape(data,[H*W,C]), lin, axis=0), [1,1,P,C])
35
+
36
+ and each variadic `Sum` into a left-to-right chain of binary `Add`, for the
37
+ same reason (see --sum-mode for why a chain and not a tree).
38
+
39
+ `Mul(y, W) + x` is only equal to the original gather if the indices are already
40
+ in range, so this refuses to rewrite unless it can SEE a clamp upstream. A
41
+ silent wrap on a negative index would corrupt one pixel in a way no aggregate
42
+ metric would ever show.
43
+
44
+ Verify with `verify_patch.py` - this is a structural rewrite and must be
45
+ bit-identical, not merely close.
46
+
47
+ python patch_deform.py in.onnx out.onnx
48
+ """
49
+
50
+ import argparse
51
+ import collections
52
+ import sys
53
+
54
+ import onnx
55
+ from onnx import TensorProto, helper, numpy_helper
56
+
57
+ # Ops that establish an index is already in range. If none of these appears
58
+ # between the index arithmetic and the gather, the rewrite is refused.
59
+ CLAMP_OPS = {"Clip", "Min", "Max"}
60
+
61
+
62
+ def shapes_of(model):
63
+ inferred = onnx.shape_inference.infer_shapes(model, strict_mode=False, data_prop=True)
64
+ out = {}
65
+ for vi in list(inferred.graph.value_info) + list(inferred.graph.output) + list(inferred.graph.input):
66
+ dims = []
67
+ ok = True
68
+ for d in vi.type.tensor_type.shape.dim:
69
+ if d.HasField("dim_value"):
70
+ dims.append(d.dim_value)
71
+ else:
72
+ ok = False
73
+ break
74
+ if ok:
75
+ out[vi.name] = dims
76
+ return out
77
+
78
+
79
+ def clamped_upstream(name, producer, depth=12):
80
+ """Walk back from `name` looking for a clamp, stopping at the first fan-in."""
81
+ seen = set()
82
+ stack = [(name, 0)]
83
+ while stack:
84
+ n, d = stack.pop()
85
+ if d > depth or n in seen:
86
+ continue
87
+ seen.add(n)
88
+ node = producer.get(n)
89
+ if node is None:
90
+ continue
91
+ if node.op_type in CLAMP_OPS:
92
+ return True
93
+ for inp in node.input:
94
+ if inp:
95
+ stack.append((inp, d + 1))
96
+ return False
97
+
98
+
99
+ def main():
100
+ ap = argparse.ArgumentParser()
101
+ ap.add_argument("src")
102
+ ap.add_argument("dst")
103
+ ap.add_argument("--allow-unclamped", action="store_true",
104
+ help="rewrite even when no clamp is visible upstream (unsafe; prints which)")
105
+ ap.add_argument("--sum-mode", choices=["chain", "tree", "none"], default="chain",
106
+ help="how to decompose variadic Sum. 'chain' is left-to-right and is the "
107
+ "DEFAULT because floating-point addition is not associative: a balanced "
108
+ "'tree' computes (a+b)+(c+d) where ORT's Sum accumulates ((a+b)+c)+d, "
109
+ "and in fp16 that alone moved the output by 4.9e-03 - measured, and the "
110
+ "sole reason a structural rewrite failed its bit-identity gate. "
111
+ "'none' leaves Sum alone, to isolate the GatherND rewrite.")
112
+ args = ap.parse_args()
113
+
114
+ model = onnx.load(args.src)
115
+ graph = model.graph
116
+ shapes = shapes_of(model)
117
+ producer = {o: n for n in graph.node for o in n.output}
118
+
119
+ new_nodes = []
120
+ consts = []
121
+ stats = collections.Counter()
122
+ skipped = []
123
+ uid = 0
124
+
125
+ def const_i64(vals, tag):
126
+ nonlocal uid
127
+ uid += 1
128
+ name = f"deformpatch_{tag}_{uid}"
129
+ consts.append(numpy_helper.from_array(
130
+ __import__("numpy").array(vals, dtype="int64"), name))
131
+ return name
132
+
133
+ def const_i32(vals, tag):
134
+ nonlocal uid
135
+ uid += 1
136
+ name = f"deformpatch_{tag}_{uid}"
137
+ consts.append(numpy_helper.from_array(
138
+ __import__("numpy").array(vals, dtype="int32"), name))
139
+ return name
140
+
141
+ for node in graph.node:
142
+ if node.op_type == "Sum" and len(node.input) > 2 and args.sum_mode != "none":
143
+ # Variadic Sum has no WebGPU kernel; Add does.
144
+ if args.sum_mode == "chain":
145
+ # Left-to-right, matching ORT's own accumulation order so the
146
+ # rewrite stays bit-identical in fp16. See --sum-mode.
147
+ acc = node.input[0]
148
+ for i in range(1, len(node.input)):
149
+ last = i == len(node.input) - 1
150
+ out = node.output[0] if last else f"{node.name}_acc{i}"
151
+ new_nodes.append(helper.make_node(
152
+ "Add", [acc, node.input[i]], [out], name=f"{node.name}_acc{i}"))
153
+ acc = out
154
+ stats["Sum->Add chain"] += 1
155
+ else:
156
+ level = list(node.input)
157
+ tier = 0
158
+ while len(level) > 1:
159
+ nxt = []
160
+ for i in range(0, len(level), 2):
161
+ if i + 1 == len(level):
162
+ nxt.append(level[i])
163
+ continue
164
+ last = len(level) <= 2
165
+ out = node.output[0] if last else f"{node.name}_add{tier}_{i}"
166
+ new_nodes.append(helper.make_node(
167
+ "Add", [level[i], level[i + 1]], [out],
168
+ name=f"{node.name}_add{tier}_{i}"))
169
+ nxt.append(out)
170
+ level = nxt
171
+ tier += 1
172
+ stats["Sum->Add tree"] += 1
173
+ continue
174
+
175
+ if node.op_type != "GatherND":
176
+ new_nodes.append(node)
177
+ continue
178
+
179
+ attrs = {a.name: helper.get_attribute_value(a) for a in node.attribute}
180
+ batch_dims = attrs.get("batch_dims", 0)
181
+ data_s = shapes.get(node.input[0])
182
+ idx_s = shapes.get(node.input[1])
183
+
184
+ # Only the exact deform-conv pattern is rewritten. Anything else keeps
185
+ # its GatherND rather than being guessed at.
186
+ if not (batch_dims == 2 and data_s and idx_s
187
+ and len(data_s) == 5 and len(idx_s) == 4
188
+ and data_s[0] == 1 and data_s[1] == 1
189
+ and idx_s[0] == 1 and idx_s[1] == 1 and idx_s[3] == 2):
190
+ skipped.append((node.name, f"batch_dims={batch_dims} data={data_s} idx={idx_s}"))
191
+ new_nodes.append(node)
192
+ stats["GatherND kept (pattern)"] += 1
193
+ continue
194
+
195
+ if not clamped_upstream(node.input[1], producer) and not args.allow_unclamped:
196
+ skipped.append((node.name, "no clamp visible upstream"))
197
+ new_nodes.append(node)
198
+ stats["GatherND kept (unclamped)"] += 1
199
+ continue
200
+
201
+ _, _, H, W, C = data_s
202
+ P = idx_s[2]
203
+ b = node.name.replace("/", "_")
204
+
205
+ n_idx2 = f"{b}__idx2"
206
+ n_i32 = f"{b}__i32"
207
+ n_y = f"{b}__y"
208
+ n_x = f"{b}__x"
209
+ n_yw = f"{b}__yw"
210
+ n_lin2 = f"{b}__lin2"
211
+ n_lin = f"{b}__lin"
212
+ n_data2 = f"{b}__data2"
213
+ n_gat = f"{b}__gat"
214
+
215
+ # ORDER MATTERS, and it is measured. The index chain arrives as int64 and
216
+ # WebGPU has no int64, so everything up to the Cast necessarily runs on
217
+ # the CPU EP. Casting FIRST (the obvious reading) then doing the
218
+ # arithmetic on the GPU means both [P,1] halves cross the boundary:
219
+ # 160 copies of 12.8MB each at 1024. Doing the arithmetic in int64 while
220
+ # it is already on the CPU and casting ONCE means a single [P] tensor
221
+ # crosses per node - half the transfers and half the bytes.
222
+ new_nodes += [
223
+ helper.make_node("Reshape", [node.input[1], const_i64([P, 2], "s")], [n_idx2], name=n_idx2),
224
+ helper.make_node("Slice", [n_idx2, const_i64([0], "st"), const_i64([1], "en"),
225
+ const_i64([1], "ax")], [n_y], name=n_y),
226
+ helper.make_node("Slice", [n_idx2, const_i64([1], "st"), const_i64([2], "en"),
227
+ const_i64([1], "ax")], [n_x], name=n_x),
228
+ helper.make_node("Mul", [n_y, const_i64([W], "w")], [n_yw], name=n_yw),
229
+ helper.make_node("Add", [n_yw, n_x], [n_lin2], name=n_lin2),
230
+ helper.make_node("Reshape", [n_lin2, const_i64([P], "s")], [n_i32], name=n_i32),
231
+ helper.make_node("Cast", [n_i32], [n_lin], to=TensorProto.INT32, name=n_lin),
232
+ helper.make_node("Reshape", [node.input[0], const_i64([H * W, C], "s")], [n_data2], name=n_data2),
233
+ helper.make_node("Gather", [n_data2, n_lin], [n_gat], axis=0, name=n_gat),
234
+ helper.make_node("Reshape", [n_gat, const_i64([1, 1, P, C], "s")], [node.output[0]], name=f"{b}__out"),
235
+ ]
236
+ stats["GatherND->Gather"] += 1
237
+
238
+ del graph.node[:]
239
+ graph.node.extend(new_nodes)
240
+ graph.initializer.extend(consts)
241
+
242
+ onnx.checker.check_model(model, full_check=False)
243
+ onnx.save(model, args.dst)
244
+
245
+ print(f"{args.src} -> {args.dst}")
246
+ for k, v in stats.most_common():
247
+ print(f" {k}: {v}")
248
+ if skipped:
249
+ print(f"\n {len(skipped)} node(s) left as-is:")
250
+ for name, why in skipped[:10]:
251
+ print(f" {name}: {why}")
252
+ print(f"\n nodes: {len(new_nodes)} (+{len(consts)} new initializers)")
253
+ print("\nNow run verify_patch.py - a structural rewrite must be bit-identical.")
254
+ return 0
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())
scripts/patch_split.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rewrite wide `Split` nodes into trees of narrow ones, so BiRefNet runs on WebGPU.
3
+
4
+ THE PROBLEM. ORT's WebGPU backend binds one storage buffer per shader variable
5
+ and throws above `maxStorageBuffersPerShaderStage` (8 by WebGPU spec default;
6
+ some adapters report 10). It chunks `Concat` inputs to stay under that limit but does NOT
7
+ chunk `Split` outputs. BiRefNet_lite@1024's decoder contains 59 Splits with 32
8
+ outputs each - 33 buffers apiece - so the graph cannot execute at all.
9
+ Full diagnosis in the model card (README.md).
10
+
11
+ WHY THIS IS SAFE. Splitting [7,7,6,6,6] and then splitting each group is exactly
12
+ equivalent to splitting all 32 at once: same axis, same sizes, same order, same
13
+ output names. It is a structural rewrite with no numerical content, which is why
14
+ the acceptance gate is bit-level correlation against the original rather than a
15
+ quality metric.
16
+
17
+ WHY NOT PATCH `Concat` TOO. The same graph has a 1024-input Concat, but ORT
18
+ already chunks Concat, and the 512 export proves that path works. Leave it.
19
+
20
+ python patch_split.py in.onnx out.onnx [--max-outputs 6]
21
+ """
22
+
23
+ import argparse
24
+ import sys
25
+
26
+ import numpy as np
27
+ import onnx
28
+ from onnx import helper, numpy_helper
29
+
30
+ # 6, not 7, and the extra margin is deliberate.
31
+ #
32
+ # A Split shader almost certainly binds one buffer for the data input plus one
33
+ # per output - the `split` sizes tensor has to be read on the host to compute
34
+ # output shapes, so it should not consume a binding. That would make 7 outputs
35
+ # (8 buffers) fit the WebGPU spec default of 8.
36
+ #
37
+ # But "almost certainly" is the wrong confidence level for the thing that decides
38
+ # whether the model runs on a stranger's GPU, and being wrong costs one extra
39
+ # node per group. 6 outputs is 8 buffers even under the pessimistic accounting
40
+ # where the sizes input IS bound.
41
+ DEFAULT_MAX_OUTPUTS = 6
42
+
43
+
44
+ def group_sizes(sizes, max_outputs):
45
+ """Partition `sizes` into as few near-equal runs as possible, each <= max_outputs."""
46
+ n = len(sizes)
47
+ chunks = -(-n // max_outputs) # ceil
48
+ base, rem = divmod(n, chunks)
49
+ groups, i = [], 0
50
+ for g in range(chunks):
51
+ take = base + (1 if g < rem else 0)
52
+ groups.append(sizes[i : i + take])
53
+ i += take
54
+ return groups
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser()
59
+ ap.add_argument("src")
60
+ ap.add_argument("dst")
61
+ ap.add_argument("--max-outputs", type=int, default=DEFAULT_MAX_OUTPUTS)
62
+ args = ap.parse_args()
63
+
64
+ model = onnx.load(args.src)
65
+ graph = model.graph
66
+
67
+ # Split sizes can arrive as an initializer or as a Constant node's output.
68
+ consts = {init.name: numpy_helper.to_array(init) for init in graph.initializer}
69
+ for node in graph.node:
70
+ if node.op_type == "Constant" and node.output:
71
+ for attr in node.attribute:
72
+ if attr.name == "value":
73
+ consts[node.output[0]] = numpy_helper.to_array(attr.t)
74
+
75
+ new_nodes, added_inits, patched, skipped = [], [], 0, []
76
+
77
+ for node in graph.node:
78
+ if node.op_type != "Split" or len(node.output) <= args.max_outputs:
79
+ new_nodes.append(node)
80
+ continue
81
+
82
+ axis = next((a.i for a in node.attribute if a.name == "axis"), 0)
83
+
84
+ sizes = None
85
+ if len(node.input) > 1 and node.input[1] in consts:
86
+ sizes = [int(v) for v in consts[node.input[1]]]
87
+ else:
88
+ attr = next((a for a in node.attribute if a.name == "split"), None)
89
+ if attr is not None:
90
+ sizes = [int(v) for v in attr.ints]
91
+
92
+ # Without explicit sizes the split is equal-division by output count, and
93
+ # rewriting it would require the input shape - which is not reliably
94
+ # known here. Leave it and report it as unfixed rather than emit a
95
+ # graph that is subtly wrong.
96
+ if sizes is None or len(sizes) != len(node.output):
97
+ skipped.append(node.name or "(unnamed)")
98
+ new_nodes.append(node)
99
+ continue
100
+
101
+ groups = group_sizes(sizes, args.max_outputs)
102
+ stem = (node.name or f"split_{patched}").replace("/", "_")
103
+
104
+ # Level 1: one output per group, each carrying that group's total width.
105
+ l1_sizes = np.array([sum(g) for g in groups], dtype=np.int64)
106
+ l1_name = f"{stem}__l1_sizes"
107
+ added_inits.append(numpy_helper.from_array(l1_sizes, l1_name))
108
+ l1_outputs = [f"{stem}__g{i}" for i in range(len(groups))]
109
+ new_nodes.append(
110
+ helper.make_node(
111
+ "Split",
112
+ inputs=[node.input[0], l1_name],
113
+ outputs=l1_outputs,
114
+ name=f"{stem}__l1",
115
+ axis=axis,
116
+ )
117
+ )
118
+
119
+ # Level 2: each group back to the ORIGINAL output names, in order, so
120
+ # every downstream consumer is untouched.
121
+ cursor = 0
122
+ for gi, group in enumerate(groups):
123
+ outs = list(node.output[cursor : cursor + len(group)])
124
+ cursor += len(group)
125
+ if len(group) == 1:
126
+ # A one-way Split is illegal; alias instead.
127
+ new_nodes.append(
128
+ helper.make_node("Identity", [l1_outputs[gi]], outs, name=f"{stem}__g{gi}_id")
129
+ )
130
+ continue
131
+ g_name = f"{stem}__g{gi}_sizes"
132
+ added_inits.append(numpy_helper.from_array(np.array(group, dtype=np.int64), g_name))
133
+ new_nodes.append(
134
+ helper.make_node(
135
+ "Split",
136
+ inputs=[l1_outputs[gi], g_name],
137
+ outputs=outs,
138
+ name=f"{stem}__g{gi}",
139
+ axis=axis,
140
+ )
141
+ )
142
+ patched += 1
143
+
144
+ del graph.node[:]
145
+ graph.node.extend(new_nodes)
146
+ graph.initializer.extend(added_inits)
147
+
148
+ onnx.checker.check_model(model, full_check=False)
149
+ onnx.save(model, args.dst, save_as_external_data=False)
150
+
151
+ print(f"patched {patched} Split nodes -> max {args.max_outputs} outputs each")
152
+ if skipped:
153
+ print(f"SKIPPED {len(skipped)} (no explicit sizes): {skipped[:5]}")
154
+ print(f"nodes {len(model.graph.node)}, wrote {args.dst}")
155
+ return 1 if skipped else 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
scripts/verify_patch.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prove the patched graph is numerically identical to the original.
3
+
4
+ This is the ONLY thing standing between a subtly wrong graph and production: the
5
+ patched ONNX is our own artifact, no upstream validates it, and a Split tree that
6
+ reorders or mis-sizes a group would still produce a plausible-looking matte.
7
+ A quality metric cannot catch that. Bit-level agreement can.
8
+
9
+ `patch_split.py` and `patch_deform.py` perform structural rewrites with no numerical content, so the
10
+ correct expectation is EXACT equality, not "close enough". The 0.9999 gate exists
11
+ only to absorb non-determinism in CPU kernel scheduling.
12
+
13
+ python verify_patch.py orig.onnx patched.onnx img1.jpg img2.jpg ...
14
+ """
15
+
16
+ import argparse
17
+ import sys
18
+
19
+ import numpy as np
20
+ import onnxruntime as ort
21
+ from PIL import Image
22
+
23
+ SIZE = 1024
24
+ MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
25
+ STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
26
+
27
+
28
+ def preprocess(path):
29
+ # fit='fill' - the aspect ratio is squashed, matching what the browser does
30
+ # by drawing into a square canvas. A different framing is a different input.
31
+ img = Image.open(path).convert("RGB").resize((SIZE, SIZE), Image.BILINEAR)
32
+ x = np.asarray(img, dtype=np.float32) / 255.0
33
+ x = (x - MEAN) / STD
34
+ return x.transpose(2, 0, 1)[None].astype(np.float32)
35
+
36
+
37
+ def main() -> int:
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("orig")
40
+ ap.add_argument("patched")
41
+ ap.add_argument("images", nargs="+")
42
+ ap.add_argument("--min-correlation", type=float, default=0.9999)
43
+ args = ap.parse_args()
44
+
45
+ opts = ort.SessionOptions()
46
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
47
+ a = ort.InferenceSession(args.orig, opts, providers=["CPUExecutionProvider"])
48
+ b = ort.InferenceSession(args.patched, opts, providers=["CPUExecutionProvider"])
49
+
50
+ name_a = a.get_inputs()[0].name
51
+ name_b = b.get_inputs()[0].name
52
+ print(f"inputs: {name_a} / {name_b}")
53
+
54
+ worst_corr, worst_absdiff, failed = 1.0, 0.0, False
55
+ for path in args.images:
56
+ x = preprocess(path)
57
+ oa = np.asarray(a.run(None, {name_a: x})[0], dtype=np.float64).ravel()
58
+ ob = np.asarray(b.run(None, {name_b: x})[0], dtype=np.float64).ravel()
59
+
60
+ if oa.shape != ob.shape:
61
+ print(f"FAIL {path}: shape {oa.shape} vs {ob.shape}")
62
+ failed = True
63
+ continue
64
+
65
+ absdiff = float(np.max(np.abs(oa - ob)))
66
+ corr = 1.0 if absdiff == 0.0 else float(np.corrcoef(oa, ob)[0, 1])
67
+ worst_corr = min(worst_corr, corr)
68
+ worst_absdiff = max(worst_absdiff, absdiff)
69
+ exact = "EXACT" if absdiff == 0.0 else f"max|diff| {absdiff:.3e}"
70
+ print(f" {path.split('/')[-1]:50s} corr {corr:.8f} {exact}")
71
+
72
+ print(f"\nworst correlation {worst_corr:.8f}, worst max|diff| {worst_absdiff:.3e}")
73
+ if failed or worst_corr < args.min_correlation:
74
+ print("REJECTED — do not ship this graph.")
75
+ return 1
76
+ if worst_absdiff == 0.0:
77
+ print("ACCEPTED — bit-identical, as a structural rewrite should be.")
78
+ else:
79
+ print("ACCEPTED — within tolerance, but NOT bit-identical; investigate before shipping.")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())