Image Segmentation
Transformers.js
ONNX
BiRefNet
swin
background-removal
dichotomous-image-segmentation
webgpu
onnxruntime-web
Instructions to use CardAnvilModels/BiRefNet-lite-1024-WebGPU with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers.js
How to use CardAnvilModels/BiRefNet-lite-1024-WebGPU with Transformers.js:
// npm i @huggingface/transformers import { pipeline } from '@huggingface/transformers'; // Allocate pipeline const pipe = await pipeline('image-segmentation', 'CardAnvilModels/BiRefNet-lite-1024-WebGPU'); - BiRefNet
How to use CardAnvilModels/BiRefNet-lite-1024-WebGPU with BiRefNet:
# Option 1: use with transformers from transformers import AutoModelForImageSegmentation birefnet = AutoModelForImageSegmentation.from_pretrained("CardAnvilModels/BiRefNet-lite-1024-WebGPU", trust_remote_code=True)# Option 2: use with BiRefNet # Install from https://github.com/ZhengPeng7/BiRefNet from models.birefnet import BiRefNet model = BiRefNet.from_pretrained("CardAnvilModels/BiRefNet-lite-1024-WebGPU") - Notebooks
- Google Colab
- Kaggle
Keep the source's other files, unchanged, for later
Browse files- README.md +33 -33
- SOURCE_README.md +184 -0
- scripts/patch_deform.py +258 -0
- scripts/patch_split.py +159 -0
- scripts/verify_patch.py +84 -0
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 |
-
|
| 21 |
-
|
| 22 |
-
|
|
| 23 |
-
| --- | --- |
|
| 24 |
-
| `config.json`
|
| 25 |
-
| `
|
| 26 |
-
| `
|
| 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())
|