File size: 8,728 Bytes
99ca48f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
#!/usr/bin/env python
"""Browser-tuned ONNX export of BiRefNet_lite (ZhengPeng7/BiRefNet_lite, MIT).

Why a custom export exists: the community export (onnx-community/BiRefNet_lite-ONNX)
is a dynamic-shape trace whose deformable convolutions decompose into
GatherND/ScatterND/Clip chains. Those ops fall back to CPU on the ONNX Runtime
Web WebGPU EP and materialize im2col tensors of hundreds of MB, which overflows
the 32-bit wasm heap at 1024x1024 (OrtRun std::bad_alloc on every EP).

This export:
  * pins the source checkpoint by commit sha and loads it with
    transformers' trust_remote_code path (the model code ships in the repo);
  * replaces DeformableConv2d.forward with a numerically identical per-tap
    GridSample decomposition (one bilinear GridSample + 1x1 Conv per kernel
    tap, accumulated) - peak extra memory is one [1,C,H,W] tensor per tap
    instead of one [1,C,K,H,W] im2col blob, and the graph uses only ops the
    WebGPU EP implements natively (no GatherND, no ScatterND, no Clip);
  * traces with a static input shape (TorchScript exporter, opset 17, fp32,
    constant folding) so all Shape/Where dynamism folds away;
  * validates the patched module against torchvision.ops.deform_conv2d and the
    final ONNX against the unpatched PyTorch reference.

Usage: python export_birefnet_lite.py [--size 1024] [--out birefnet_lite.onnx]
"""
import argparse
import sys

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

REPO = 'ZhengPeng7/BiRefNet_lite'
REVISION = '7838f1c3472f827cd8ce13ab5ccc2ce48077360f'  # pinned 2026-07-18


def grid_sample_deform_forward(self, x):
    """Drop-in for DeformableConv2d.forward (birefnet.py) built on GridSample.

    Equivalent to torchvision.ops.deform_conv2d(input, offset, weight, bias,
    stride=1, padding=p, mask=modulator): for kernel tap k at (i, j) the op
    samples x at (y + i - p + dy_k, x + j - p + dx_k) bilinearly with zero
    padding, scales by the modulation mask, and accumulates W[:, :, i, j] as a
    1x1 conv. Offsets are interleaved (dy, dx) per tap, row-major over the
    kernel, matching torchvision's layout.
    """
    offset = self.offset_conv(x)                       # [B, 2K, H, W]
    modulator = 2.0 * torch.sigmoid(self.modulator_conv(x))  # [B, K, H, W]
    B, C, H, W = x.shape
    kh, kw = self.regular_conv.kernel_size
    pad = self.padding
    weight = self.regular_conv.weight                  # [Cout, Cin, kh, kw]

    ys = torch.arange(H, dtype=x.dtype, device=x.device)
    xs = torch.arange(W, dtype=x.dtype, device=x.device)
    base_y, base_x = torch.meshgrid(ys, xs, indexing='ij')  # [H, W] constants
    # pre-normalized base grids (grid_sample align_corners=False convention);
    # keeping the per-tap shift on the dynamic offset lets constant folding
    # share ONE [H, W] grid pair per resolution instead of one per kernel tap
    norm_y = (2 * base_y + 1) / H - 1
    norm_x = (2 * base_x + 1) / W - 1

    out = None
    for k in range(kh * kw):
        i, j = divmod(k, kw)
        py = norm_y + (offset[:, 2 * k] + (i - pad)) * (2.0 / H)   # [B, H, W]
        px = norm_x + (offset[:, 2 * k + 1] + (j - pad)) * (2.0 / W)
        grid = torch.stack([px, py], dim=-1)
        sampled = F.grid_sample(x, grid, mode='bilinear', padding_mode='zeros',
                                align_corners=False)
        sampled = sampled * modulator[:, k:k + 1]
        contrib = F.conv2d(sampled, weight[:, :, i, j].unsqueeze(-1).unsqueeze(-1))
        out = contrib if out is None else out + contrib
    if self.regular_conv.bias is not None:
        out = out + self.regular_conv.bias.view(1, -1, 1, 1)
    return out


def verify_deform_patch(deform_cls):
    """Random-weight equivalence check: patched forward vs torchvision op."""
    from torchvision.ops import deform_conv2d
    torch.manual_seed(0)
    worst = 0.0
    for ksize, pad in [(1, 0), (3, 1), (7, 3)]:
        m = deform_cls(8, 16, kernel_size=ksize, padding=pad, bias=False)
        for p in m.parameters():  # zero-init offsets would hide layout bugs
            nn.init.normal_(p, std=0.3)
        x = torch.randn(1, 8, 20, 24)
        with torch.no_grad():
            offset = m.offset_conv(x)
            modulator = 2.0 * torch.sigmoid(m.modulator_conv(x))
            ref = deform_conv2d(x, offset, m.regular_conv.weight,
                                m.regular_conv.bias, padding=pad,
                                mask=modulator, stride=m.stride)
            got = grid_sample_deform_forward(m, x)
        worst = max(worst, (ref - got).abs().max().item())
    return worst


class LogitsWrapper(nn.Module):
    """BiRefNet eval-forward returns a list of multi-scale preds; the last one
    is the full-resolution logits map the demo pipeline consumes."""

    def __init__(self, net):
        super().__init__()
        self.net = net

    def forward(self, input_image):
        return self.net(input_image)[-1]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--size', type=int, default=1024)
    ap.add_argument('--out', default='birefnet_lite.onnx')
    args = ap.parse_args()

    from transformers import AutoModelForImageSegmentation
    torch.set_grad_enabled(False)
    model = AutoModelForImageSegmentation.from_pretrained(
        REPO, revision=REVISION, trust_remote_code=True)
    model.eval().float()
    birefnet_mod = sys.modules[type(model).__module__]

    # reference logits from the unpatched model (torchvision deform_conv2d)
    torch.manual_seed(1)
    probe = torch.rand(1, 3, args.size, args.size)
    ref_logits = LogitsWrapper(model)(probe)

    deform_err = verify_deform_patch(birefnet_mod.DeformableConv2d)
    print(f'deform patch vs torchvision: max abs dev {deform_err:.3e}')
    assert deform_err < 1e-4, 'GridSample decomposition diverges from torchvision'

    birefnet_mod.DeformableConv2d.forward = grid_sample_deform_forward
    patched_logits = LogitsWrapper(model)(probe)
    patch_err = (ref_logits - patched_logits).abs().max().item()
    print(f'patched model vs reference: max abs logits dev {patch_err:.3e}')
    assert patch_err < 1e-3

    torch.onnx.export(
        LogitsWrapper(model),
        (probe,),
        args.out,
        input_names=['input_image'],
        output_names=['output_image'],
        opset_version=17,
        do_constant_folding=True,
        dynamo=False,
    )

    # The TorchScript trace leaves the (all-constant, static-shape) Swin
    # attention-mask construction as live ScatterND/Where/Shape chains;
    # onnxslim folds them into initializers.
    # FusionGemm is skipped so the shared (deduped) MatMul weights are not
    # re-split into per-call-site transposed Gemm copies.
    import onnxslim
    onnxslim.slim(args.out, output_model=args.out,
                  skip_fusion_patterns=['FusionGemm', 'FusionGemmMul', 'FusionGemmAdd'])

    # The backbone is traced twice (multi-scale 'cat' input), and the
    # TorchScript exporter emits one initializer per call site - every Swin
    # weight lands in the file twice (+116 MB). Dedupe by content hash.
    import hashlib
    import onnx
    m = onnx.load(args.out)
    canonical, rename = {}, {}
    keep = []
    for init in m.graph.initializer:
        arr = onnx.numpy_helper.to_array(init)
        h = (str(arr.dtype), arr.shape,
             hashlib.sha256(arr.tobytes()).hexdigest())
        if h in canonical:
            rename[init.name] = canonical[h]
        else:
            canonical[h] = init.name
            keep.append(init)
    del m.graph.initializer[:]
    m.graph.initializer.extend(keep)
    for node in m.graph.node:
        for idx, name in enumerate(node.input):
            if name in rename:
                node.input[idx] = rename[name]
    print(f'deduped {len(rename)} duplicate initializers')
    onnx.save(m, args.out)

    import onnx
    from collections import Counter
    m = onnx.load(args.out, load_external_data=False)
    ops = Counter(n.op_type for n in m.graph.node)
    print('ops:', sorted(ops.items(), key=lambda kv: -kv[1]))
    for banned in ('GatherND', 'ScatterND', 'Clip'):
        assert banned not in ops, f'{banned} in graph - not browser-clean'

    import onnxruntime as ort_rt
    sess = ort_rt.InferenceSession(args.out, providers=['CPUExecutionProvider'])
    onnx_logits = sess.run(None, {'input_image': probe.numpy()})[0]
    onnx_err = np.abs(ref_logits.numpy() - onnx_logits).max()
    alpha_err = np.abs(torch.sigmoid(ref_logits).numpy()
                       - 1 / (1 + np.exp(-onnx_logits))).max()
    print(f'onnx vs torch reference: max abs logits dev {onnx_err:.3e}, '
          f'max sigmoid dev {alpha_err:.3e}')


if __name__ == '__main__':
    main()