SAM 3 text-prompted segmentation for WebGPU: Standard (fp16) and Compact (8-bit weights)
7eee2d7 verified Download export_sam3.py from CardAnvilModels/sam3-ONNX: direct link, hf CLI and curl.
- Browser
- Download file 5.71 kB
-
https://huggingface.co/CardAnvilModels/sam3-ONNX/resolve/main/export_sam3.py
- Command line
-
hf download hf://CardAnvilModels/sam3-ONNX/export_sam3.py
-
curl -L -o export_sam3.py https://huggingface.co/CardAnvilModels/sam3-ONNX/resolve/main/export_sam3.py
5.71 kB
| """Exports SAM 3 (facebook/sam3) to ONNX for in-browser inference with ONNX Runtime Web on WebGPU. | |
| Three graphs (see `sam3_graphs.py`): `vision` (once per image), `text` and `detector` (once per | |
| phrase), each at a fixed shape: a 1008 × 1008 image, a 32-token phrase, batch 1. | |
| Files, in `<out>/onnx/`, named by graph and precision: | |
| - `*_fp16.onnx`: fp16 weights and activations, inputs and outputs float32. Standard is the three | |
| of these. | |
| - `vision_q8f16.onnx`, `text_q8f16.onnx`: as fp16, but every MatMul weight stored at 8 bits | |
| (`MatMulNBits`, symmetric, block size 128) and expanded on the GPU. Compact is these two and | |
| `detector_fp16.onnx`, which is small and left at fp16. | |
| Weight-only quantisation keeps every node on WebGPU: the usual dynamic "int8" export runs | |
| `MatMulInteger` and `DynamicQuantizeLinear`, which ONNX Runtime Web's WebGPU backend lacks. | |
| Usage: | |
| python export_sam3.py --weights <facebook/sam3 checkout> --out <dir> | |
| Requires torch, transformers (with `Sam3Model`), onnx, onnxscript, onnxconverter-common and | |
| onnxruntime. Runs on the CPU. The fp32 graphs go to `<out>/fp32/`, with external data, and | |
| `--skip-fp32` reuses them. | |
| """ | |
| import argparse | |
| from pathlib import Path | |
| import onnx | |
| import torch | |
| from onnxconverter_common import float16 | |
| from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer | |
| from transformers import Sam3Model | |
| from sam3_graphs import IMAGE_SIZE, TEXT_TOKENS, Sam3Detector, Sam3Text, Sam3Vision, position_encoding_72 | |
| OPSET = 18 | |
| GRAPHS = ("vision", "text", "detector") | |
| QUANTISED = ("vision", "text") | |
| def export_fp32(model, out: Path) -> None: | |
| out.mkdir(parents=True, exist_ok=True) | |
| pixels = torch.zeros(1, 3, IMAGE_SIZE, IMAGE_SIZE) | |
| ids = torch.zeros(1, TEXT_TOKENS, dtype=torch.long) | |
| mask = torch.ones(1, TEXT_TOKENS, dtype=torch.long) | |
| vision = Sam3Vision(model).eval() | |
| with torch.no_grad(): | |
| levels = vision(pixels) | |
| text = Sam3Text(model).eval() | |
| with torch.no_grad(): | |
| features = text(ids, mask) | |
| detector = Sam3Detector(model, position_encoding_72(model)).eval() | |
| jobs = { | |
| "vision": (vision, (pixels,), ["pixel_values"], ["features_288", "features_144", "features_72"]), | |
| "text": (text, (ids, mask), ["input_ids", "attention_mask"], ["text_features"]), | |
| "detector": ( | |
| detector, | |
| (*levels, features, mask), | |
| ["features_288", "features_144", "features_72", "text_features", "text_mask"], | |
| ["pred_logits", "pred_boxes", "presence_logits", "pred_masks", "semantic_seg"], | |
| ), | |
| } | |
| for name, (module, args, inputs, outputs) in jobs.items(): | |
| print(f"exporting {name}") | |
| with torch.no_grad(): | |
| torch.onnx.export( | |
| module, | |
| args, | |
| str(out / f"{name}.onnx"), | |
| input_names=inputs, | |
| output_names=outputs, | |
| opset_version=OPSET, | |
| dynamo=True, | |
| external_data=True, | |
| optimize=True, | |
| ) | |
| def fix_casts(model: onnx.ModelProto) -> int: | |
| """Points each Cast to float32 whose output the converter declared float16 at float16. | |
| `convert_float_to_float16` retypes a Cast's output but leaves its `to`, so a Cast from a | |
| non-float source (the text mask, cast from bool for pooling) still says float32 and the | |
| graph doesn't load. Returns how many it fixed. | |
| """ | |
| declared = {vi.name: vi.type.tensor_type.elem_type for vi in model.graph.value_info} | |
| fixed = 0 | |
| for node in model.graph.node: | |
| if node.op_type != "Cast" or declared.get(node.output[0]) != onnx.TensorProto.FLOAT16: | |
| continue | |
| for attribute in node.attribute: | |
| if attribute.name == "to" and attribute.i == onnx.TensorProto.FLOAT: | |
| attribute.i = onnx.TensorProto.FLOAT16 | |
| fixed += 1 | |
| return fixed | |
| def to_fp16(src: Path, dst: Path) -> None: | |
| dst.mkdir(parents=True, exist_ok=True) | |
| for name in GRAPHS: | |
| model = onnx.load(str(src / f"{name}.onnx")) | |
| # Stale annotations from the export would fight the converter's own inference. | |
| del model.graph.value_info[:] | |
| half = float16.convert_float_to_float16(model, keep_io_types=True, disable_shape_infer=False) | |
| print(f"fp16 {name}: {fix_casts(half)} casts fixed") | |
| onnx.save(half, str(dst / f"{name}_fp16.onnx")) | |
| def to_q8f16(onnx_dir: Path, bits: int, block_size: int) -> None: | |
| for name in QUANTISED: | |
| print(f"{bits}-bit {name}") | |
| quantiser = MatMulNBitsQuantizer( | |
| onnx.load(str(onnx_dir / f"{name}_fp16.onnx")), | |
| bits=bits, | |
| block_size=block_size, | |
| is_symmetric=True, | |
| ) | |
| quantiser.process() | |
| onnx.save(quantiser.model.model, str(onnx_dir / f"{name}_q{bits}f16.onnx")) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| parser.add_argument("--weights", type=Path, required=True) | |
| parser.add_argument("--out", type=Path, required=True) | |
| parser.add_argument("--bits", type=int, default=8) | |
| parser.add_argument("--block-size", type=int, default=128) | |
| parser.add_argument("--skip-fp32", action="store_true", help="reuse an earlier fp32 export") | |
| args = parser.parse_args() | |
| if not args.skip_fp32: | |
| model = Sam3Model.from_pretrained(args.weights, attn_implementation="eager").eval() | |
| export_fp32(model, args.out / "fp32") | |
| del model | |
| to_fp16(args.out / "fp32", args.out / "onnx") | |
| to_q8f16(args.out / "onnx", args.bits, args.block_size) | |
| if __name__ == "__main__": | |
| main() | |