Download export/sam2-hiera-tiny.py from executorch-community/SAM2-Hiera-Tiny-ExecuTorch-WebGPU: direct link, hf CLI and curl.
- Browser
- Download file 41.5 kB
-
https://huggingface.co/executorch-community/SAM2-Hiera-Tiny-ExecuTorch-WebGPU/resolve/main/export/sam2-hiera-tiny.py
- Command line
-
hf download hf://executorch-community/SAM2-Hiera-Tiny-ExecuTorch-WebGPU/export/sam2-hiera-tiny.py
-
curl -L -o sam2-hiera-tiny.py https://huggingface.co/executorch-community/SAM2-Hiera-Tiny-ExecuTorch-WebGPU/resolve/main/export/sam2-hiera-tiny.py
41.5 kB
| #!/usr/bin/env python3 | |
| """SAM 2.1 Hiera Tiny -> two ExecuTorch WebGPU programs + prompt tables, 512x512 input. | |
| Same split and I/O contract as the playground's previous export (sam2_encoder.pte, sam2_decoder.pte, | |
| prompt_*.bin); the prompt encoder runs in JS (src/runtime/sam.js) from the tables. | |
| sam2_encoder.pte forward(pixels[1,3,512,512] f32: RGB, square resize, /255, ImageNet mean/std) | |
| -> (image_embeddings[1,256,32,32], hr0[1,256,128,128], hr1[1,256,64,64]) | |
| hr0/hr1 are the FPN levels *before* conv_s0/conv_s1 (the decoder applies them). | |
| sam2_decoder.pte forward(image_embeddings[1,256,32,32], image_pe[1,256,32,32], sparse[1,1,3,256], | |
| dense[1,256,32,32], hr0[1,256,128,128], hr1[1,256,64,64]) | |
| -> (masks[1,3,128,128] logits, iou_scores[1,1,3], object_score_logits[1,1,1]) | |
| multimask output (mask tokens 1..3), three prompt tokens (--prompt-tokens): points, | |
| box corners, one not-a-point pad, then pads; pads after the first are masked, so | |
| clicks, click+exclude and boxes decode exactly as upstream. | |
| prompt_gaussian.bin [2,128] f32 random-Fourier matrix (shared_image_embedding) | |
| prompt_point_embed_bg.bin [256] f32 point_embed[0] (negative click) | |
| prompt_point_embed_fg.bin [256] f32 point_embed[1] (positive click) | |
| prompt_box_tl.bin / _br.bin [256] f32 point_embed[2] / [3] (box corners) | |
| prompt_not_a_point.bin [256] f32 not_a_point_embed (padding token) | |
| prompt_img_pos.bin [1,256,32,32] f32 get_image_wide_positional_embeddings() at 512 | |
| prompt_dense.bin [1,256,32,32] f32 no_mask_embed broadcast (no mask prompt) | |
| Upstream: the HF transformers port (Apache-2.0) of facebook/sam2.1-hiera-tiny, which is what the | |
| previous export was built from (its decoder weights and all prompt tables are bit-identical to it). | |
| `--weights sam2` uses facebook/sam2-hiera-tiny (SAM 2.0) instead. | |
| Graph changes, all output-preserving: attention goes through the public fused op | |
| torch.ops.et_vk.sdpa (registered as a transformers attention backend); the Hiera windowed absolute | |
| position embedding (bicubic resize of pos_embed + tiled window embedding) for the fixed input size is | |
| computed once in eager and stored as a constant; Hiera's q-pooling of windowed tensors runs as one | |
| batch-1 max_pool2d over the windows stacked along H (exact; keeps every pool in the delegate). Unlike the previous export, the encoder adds | |
| `no_memory_embedding` to the 32x32 embedding, as upstream does for single images | |
| (transformers Sam2Model.forward; `directly_add_no_mem_embed: true` in the official yaml). | |
| `--no-memory-embedding omit` reproduces the previous behaviour. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import types | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| import _common # noqa: E402 | |
| WEIGHTS = { | |
| "sam2.1": { | |
| "repo": "facebook/sam2.1-hiera-tiny", | |
| "revision": "de431c4043854a71d8101e17995dfe596bf101a5", | |
| "files": { | |
| "model.safetensors": "48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7", | |
| }, | |
| }, | |
| "sam2": { | |
| "repo": "facebook/sam2-hiera-tiny", | |
| "revision": "7c218beaf0bb87874785f32b582f640134fc1c09", | |
| "files": { | |
| "model.safetensors": "1449035ba6ba2ed524c646da989e5cb3a9b93b97f1c1b2d474e598c3b748d99f", | |
| }, | |
| }, | |
| } | |
| CONFIG_FILES = ("config.json", "preprocessor_config.json", "processor_config.json") | |
| # Validation images. Prompts are in original-image pixels, without padding; site_tokens() turns | |
| # them into the decoder's tokens the way planPrompt() in sam.js does (points, box corners, one | |
| # not-a-point pad, then pads up to the decoder's token count). | |
| IMAGE = { | |
| "name": "sam-car.png", | |
| "repo": "huggingface/documentation-images", | |
| "repo_type": "dataset", | |
| "revision": "79244b5169b24b4e1444393d9b87349c9182cfa4", | |
| "file": "transformers/model_doc/sam-car.png", | |
| "sha256": "c6ae6440e3862dd9768cd9f7098b783ac980629dd7bc1fe69c10b39fca5a84cd", | |
| "prompts": { | |
| "door_click": [("pos", 1680, 840)], | |
| "wheel_click": [("pos", 832, 1088)], | |
| "door_plus_exclude": [("pos", 1680, 840), ("neg", 1000, 200)], | |
| "car_box": [("tl", 120, 400), ("br", 2528, 1248)], | |
| "door_and_wheel_clicks": [("pos", 1680, 840), ("pos", 832, 1088)], | |
| }, | |
| } | |
| # Optional: extra sample images for validation (`--assets-dir`). | |
| # Not public inputs; pinned by sha256 so the numbers are reproducible. | |
| ASSET_IMAGES = { | |
| "real_pets.jpg": { | |
| "sha256": "85e4f2f27368a203c30eac68a71e4dde40edac6417b4a8c9df80af5d30b191b2", | |
| "prompts": { | |
| "pug_face_click": [("pos", 505, 150)], | |
| "blanket_click": [("pos", 500, 500)], | |
| "blanket_minus_face": [("pos", 500, 500), ("neg", 505, 150)], | |
| "pug_box": [("tl", 165, 65), ("br", 850, 720)], | |
| }, | |
| }, | |
| "yolo_people.jpg": { | |
| "sha256": "16d73869e3267a7d4ed00de8e860833bd1657c1b252e94c0c348277adc7b6edb", | |
| "prompts": { | |
| "left_man_head_click": [("pos", 570, 260)], | |
| "right_man_face_click": [("pos", 980, 180)], | |
| "left_jacket_minus_right": [("pos", 300, 520), ("neg", 880, 520)], | |
| "right_man_box": [("tl", 750, 40), ("br", 1150, 720)], | |
| }, | |
| }, | |
| } | |
| SIZE = 512 | |
| MEAN = (0.485, 0.456, 0.406) | |
| STD = (0.229, 0.224, 0.225) | |
| INPUT_NAMES = ["image_embeddings", "image_pe", "sparse", "dense", "hr0", "hr1"] | |
| # Additive key bias [1, 1, 1, tokens] set by Decoder.forward while the mask decoder runs: it hides | |
| # extra padding tokens (see Decoder.pad_bias). None everywhere else (encoder, references). | |
| _TOKEN_KEY_BIAS = None | |
| def _key_bias(query, key, attention_mask): | |
| bias = _TOKEN_KEY_BIAS | |
| if bias is None or key.shape[-2] != bias.shape[-1]: | |
| return attention_mask | |
| full = bias.expand(query.shape[0], query.shape[1], query.shape[2], key.shape[-2]) | |
| return full if attention_mask is None else attention_mask + full | |
| def et_vk_attention( | |
| module, query, key, value, attention_mask, scaling=None, dropout=0.0, **kwargs | |
| ): | |
| """transformers attention backend emitting the fused et_vk.sdpa op (public ExecuTorch Vulkan custom op, | |
| registered by the WebGPU runtime). q/k/v are [B, H, S, D]; returns [B, S, H, D] like the built-in backends.""" | |
| import executorch.backends.vulkan.custom_ops_lib # noqa: F401 (defines torch.ops.et_vk.*) | |
| mask = _key_bias(query, key, attention_mask) | |
| out = torch.ops.et_vk.sdpa(query, key, value, mask, scaling) | |
| return out.transpose(1, 2).contiguous(), None | |
| def torch_attention( | |
| module, query, key, value, attention_mask, scaling=None, dropout=0.0, **kwargs | |
| ): | |
| """Same as et_vk_attention with torch SDPA (for the CPU-only portable decoder).""" | |
| mask = _key_bias(query, key, attention_mask) | |
| out = F.scaled_dot_product_attention(query, key, value, attn_mask=mask, scale=scaling) | |
| return out.transpose(1, 2).contiguous(), None | |
| def register_attention(): | |
| from transformers import AttentionInterface | |
| AttentionInterface.register("et_vk_sdpa", et_vk_attention) | |
| AttentionInterface.register("torch_sdpa_padmask", torch_attention) | |
| def site_tokens(prompt, n_tokens): | |
| """planPrompt() in sam.js for a prompt that fits one decode: points (positive first), box corners, | |
| one not-a-point pad (SAM 2's own padding), then pads up to the decoder's token count.""" | |
| pos = [t for t in prompt if t[0] == "pos"] | |
| neg = [t for t in prompt if t[0] == "neg"] | |
| corners = [t for t in prompt if t[0] in ("tl", "br")] | |
| native = pos + neg + corners + [("pad",)] | |
| if len(native) > n_tokens: | |
| sys.exit(f"prompt {prompt} needs {len(native)} tokens; the decoder takes {n_tokens}") | |
| return native + [("pad",)] * (n_tokens - len(native)) | |
| def snapshot(weights): | |
| """Pinned upstream snapshot: weights (sha256-checked) + configs at the same revision.""" | |
| w = WEIGHTS[weights] | |
| path, digest = _common.hf_file( | |
| w["repo"], "model.safetensors", w["revision"], w["files"]["model.safetensors"] | |
| ) | |
| for f in CONFIG_FILES: | |
| _common.hf_file(w["repo"], f, w["revision"]) | |
| return path.parent, digest | |
| def load_model(snap, size, attn="sdpa"): | |
| from transformers import Sam2Config, Sam2Model | |
| cfg = Sam2Config.from_pretrained(snap) | |
| if size != cfg.image_size: | |
| f = size // cfg.prompt_encoder_config.patch_size | |
| cfg.image_size = size | |
| cfg.prompt_encoder_config.image_size = size | |
| cfg.vision_config.backbone_config.image_size = [size, size] | |
| cfg.vision_config.backbone_feature_sizes = [ | |
| [4 * f, 4 * f], | |
| [2 * f, 2 * f], | |
| [f, f], | |
| ] | |
| model, info = Sam2Model.from_pretrained( | |
| snap, config=cfg, attn_implementation=attn, output_loading_info=True | |
| ) | |
| if info["missing_keys"] or info["mismatched_keys"] or info["unexpected_keys"]: | |
| sys.exit(f"weights did not load cleanly: {info}") | |
| return model.eval() | |
| def pool_windows_as_one_image(): | |
| """Hiera's q-pooling (2x2 max pool, stride 2) on windowed tensors [nW, h, w, C] is rewritten to pool | |
| the windows stacked along H as a single [1, C, nW*h, w] image. h is a multiple of the stride, so no | |
| pooling cell straddles two windows and the result is identical; batch-1 pooling is what the Vulkan | |
| partitioner can represent (a [256, 384, 4, 4] pool is left on CPU otherwise).""" | |
| from transformers.models.sam2 import modeling_sam2 | |
| original = getattr(modeling_sam2, "_upstream_do_pool", modeling_sam2.do_pool) | |
| modeling_sam2._upstream_do_pool = original | |
| def do_pool(x, query_stride=None): | |
| if query_stride is None or x.shape[0] == 1: | |
| return original(x, query_stride) | |
| b, h, w, c = x.shape | |
| sh, sw = (query_stride, query_stride) if isinstance(query_stride, int) else tuple(query_stride) | |
| if h % sh or w % sw: | |
| return original(x, query_stride) | |
| y = x.reshape(1, b * h, w, c).permute(0, 3, 1, 2) | |
| y = F.max_pool2d(y, kernel_size=query_stride, stride=query_stride, ceil_mode=False) | |
| return y.permute(0, 2, 3, 1).reshape(b, h // sh, w // sw, c) | |
| modeling_sam2.do_pool = do_pool | |
| def upstream_pool(): | |
| """Undo pool_windows_as_one_image() (upstream code path, for references).""" | |
| from transformers.models.sam2 import modeling_sam2 | |
| modeling_sam2.do_pool = getattr(modeling_sam2, "_upstream_do_pool", modeling_sam2.do_pool) | |
| def freeze_hiera_pos_embed(model, size): | |
| bb = model.vision_encoder.backbone | |
| with torch.no_grad(): | |
| frozen = bb._get_pos_embed((size // 4, size // 4)).detach().contiguous().clone() | |
| bb.register_buffer("frozen_pos_embed", frozen, persistent=False) | |
| bb._get_pos_embed = types.MethodType(lambda self, _hw: self.frozen_pos_embed, bb) | |
| return model | |
| class Encoder(torch.nn.Module): | |
| def __init__(self, model, add_no_memory=True): | |
| super().__init__() | |
| self.vision = model.vision_encoder | |
| self.add_no_memory = add_no_memory | |
| self.register_buffer( | |
| "no_mem", | |
| model.no_memory_embedding.detach().reshape(1, -1, 1, 1).clone(), | |
| persistent=False, | |
| ) | |
| def forward(self, pixels): | |
| hr0, hr1, emb = self.vision(pixels).fpn_hidden_states | |
| if self.add_no_memory: | |
| emb = emb + self.no_mem | |
| return emb, hr0, hr1 | |
| class Decoder(torch.nn.Module): | |
| """Mask decoder on precomputed prompt embeddings. | |
| A pad (not-a-point) token that directly follows another pad is hidden from every attention as a | |
| key (additive -1e9, which underflows to an exact 0 weight). Nothing reads such a token's own | |
| output, so a fixed-size decode of [pos, pad, pad] equals upstream's [pos, pad], and [tl, br, pad] | |
| equals upstream's box prompt. The first pad is kept: it is SAM 2's own padding token.""" | |
| def __init__(self, model): | |
| super().__init__() | |
| self.md = model.mask_decoder | |
| self.n_out = self.md.obj_score_token.weight.shape[0] + 1 + self.md.num_mask_tokens | |
| self.register_buffer( | |
| "not_a_point", | |
| model.prompt_encoder.not_a_point_embed.weight[0].detach().clone(), | |
| persistent=False, | |
| ) | |
| def pad_bias(self, sparse): | |
| d = (sparse - self.not_a_point).abs().sum(-1) # [1, 1, N]; 0 only for an exact pad token | |
| is_pad = torch.relu(1.0 - d * 1e6) # 1.0 for a pad, 0.0 otherwise | |
| repeated = is_pad[..., 1:] * is_pad[..., :-1] | |
| keep = torch.zeros(1, 1, self.n_out + 1, dtype=sparse.dtype) | |
| return (torch.cat([keep, repeated], dim=-1) * -1e9).unsqueeze(2) # [1, 1, 1, n_out + N] | |
| def forward(self, emb, image_pe, sparse, dense, hr0, hr1): | |
| global _TOKEN_KEY_BIAS | |
| _TOKEN_KEY_BIAS = self.pad_bias(sparse) if sparse.shape[2] > 1 else None | |
| try: | |
| return self._decode(emb, image_pe, sparse, dense, hr0, hr1) | |
| finally: | |
| _TOKEN_KEY_BIAS = None | |
| def _decode(self, emb, image_pe, sparse, dense, hr0, hr1): | |
| masks, iou, _, obj = self.md( | |
| image_embeddings=emb, | |
| image_positional_embeddings=image_pe, | |
| sparse_prompt_embeddings=sparse, | |
| dense_prompt_embeddings=dense, | |
| multimask_output=True, | |
| high_resolution_features=[self.md.conv_s0(hr0), self.md.conv_s1(hr1)], | |
| ) | |
| # [B, point_batch=1, 3, H, W] -> [B, 3, H, W], as the previous export. | |
| return masks[:, 0], iou, obj | |
| def prompt_tables(model): | |
| pe = model.prompt_encoder | |
| with torch.no_grad(): | |
| dense = ( | |
| pe.no_mask_embed.weight.reshape(1, -1, 1, 1) | |
| .expand(1, -1, *pe.image_embedding_size) | |
| .contiguous() | |
| ) | |
| tabs = { | |
| "gaussian": model.shared_image_embedding.positional_embedding, | |
| "point_embed_bg": pe.point_embed.weight[0], | |
| "point_embed_fg": pe.point_embed.weight[1], | |
| "box_tl": pe.point_embed.weight[2], | |
| "box_br": pe.point_embed.weight[3], | |
| "not_a_point": pe.not_a_point_embed.weight[0], | |
| "img_pos": model.get_image_wide_positional_embeddings(), | |
| "dense": dense, | |
| } | |
| return { | |
| k: v.detach().to(torch.float32).contiguous().numpy() for k, v in tabs.items() | |
| } | |
| def encode_tokens(tokens, width, height, tabs, size=SIZE): | |
| """Python port of the playground's browser-side prompt encoding (float64 math, f32 out).""" | |
| g = tabs["gaussian"].reshape(-1).astype(np.float64) | |
| hidden = tabs["not_a_point"].size | |
| D = hidden // 2 | |
| kind = { | |
| "neg": tabs["point_embed_bg"], | |
| "pos": tabs["point_embed_fg"], | |
| "tl": tabs["box_tl"], | |
| "br": tabs["box_br"], | |
| } | |
| out = np.zeros((len(tokens), hidden), np.float64) | |
| for r, t in enumerate(tokens): | |
| if t[0] == "pad": | |
| out[r] = tabs["not_a_point"] | |
| continue | |
| x, y = round(t[1] * 10) / 10, round(t[2] * 10) / 10 | |
| cx = (2 * (x * (size / width) + 0.5)) / size - 1 | |
| cy = (2 * (y * (size / height) + 0.5)) / size - 1 | |
| a = 2 * np.pi * (cx * g[:D] + cy * g[D:]) | |
| out[r, :D] = np.sin(a) + kind[t[0]][:D] | |
| out[r, D:] = np.cos(a) + kind[t[0]][D:] | |
| return torch.from_numpy(out.astype(np.float32)).reshape(1, 1, len(tokens), hidden) | |
| def load_image(path, size): | |
| from PIL import Image | |
| img = Image.open(path).convert("RGB") | |
| a = np.asarray(img.resize((size, size), Image.BILINEAR), dtype=np.float32) / 255.0 | |
| a = (a - np.array(MEAN, np.float32)) / np.array(STD, np.float32) | |
| return torch.from_numpy(a.transpose(2, 0, 1).copy()).unsqueeze(0), img | |
| def iou(a, b): | |
| a, b = np.asarray(a) > 0, np.asarray(b) > 0 | |
| u = np.logical_or(a, b).sum() | |
| return round(float(np.logical_and(a, b).sum() / u), 4) if u else 1.0 | |
| def upsample(masks, w, h): | |
| """Bilinear, align_corners=False: the same mapping as upsampleMask() in sam.js.""" | |
| t = torch.from_numpy(np.ascontiguousarray(masks, np.float32)).reshape( | |
| 1, -1, *np.asarray(masks).shape[-2:] | |
| ) | |
| return F.interpolate(t, size=(h, w), mode="bilinear", align_corners=False)[ | |
| 0 | |
| ].numpy() | |
| def upstream_inputs(tokens): | |
| """The same prompt in transformers' native form (Sam2PromptEncoder pads points with a not-a-point | |
| token and always adds one after box corners, like the original SAM 2 predictor).""" | |
| pts = [t for t in tokens if t[0] in ("pos", "neg")] | |
| corners = [t for t in tokens if t[0] in ("tl", "br")] | |
| kw = {} | |
| if corners: | |
| (_, x0, y0), (_, x1, y1) = corners | |
| kw["input_boxes"] = [[[x0, y0, x1, y1]]] | |
| if pts: | |
| kw["input_points"] = [[[[t[1], t[2]] for t in pts]]] | |
| kw["input_labels"] = [[[1 if t[0] == "pos" else 0 for t in pts]]] | |
| return kw | |
| def upstream_reference(model, processor, img, tokens): | |
| """Upstream transformers pipeline end to end (Sam2Processor -> Sam2Model -> post_process_masks): | |
| mask logits at the original image size, and the IoU head's scores.""" | |
| inputs = processor(images=img, return_tensors="pt", **upstream_inputs(tokens)) | |
| with torch.no_grad(): | |
| o = model( | |
| pixel_values=inputs["pixel_values"], | |
| input_points=inputs.get("input_points"), | |
| input_labels=inputs.get("input_labels"), | |
| input_boxes=inputs.get("input_boxes"), | |
| multimask_output=True, | |
| ) | |
| m = processor.post_process_masks( | |
| o.pred_masks, inputs["original_sizes"], binarize=False | |
| )[0] | |
| return m[0].numpy(), o.iou_scores[0, 0].numpy() | |
| def upstream_512_same_pixels(model, pixels, tokens, w, h): | |
| """Upstream Sam2Model.forward (512 config) on exactly the pixels the export sees.""" | |
| s = model.prompt_encoder.input_image_size | |
| kw = upstream_inputs(tokens) | |
| sx, sy = s / w, s / h | |
| args = {} | |
| if "input_boxes" in kw: | |
| x0, y0, x1, y1 = kw["input_boxes"][0][0] | |
| args["input_boxes"] = torch.tensor( | |
| [[[x0 * sx, y0 * sy, x1 * sx, y1 * sy]]], dtype=torch.float32 | |
| ) | |
| if "input_points" in kw: | |
| args["input_points"] = torch.tensor( | |
| [[[[x * sx, y * sy] for x, y in kw["input_points"][0][0]]]], | |
| dtype=torch.float32, | |
| ) | |
| args["input_labels"] = torch.tensor(kw["input_labels"], dtype=torch.int64) | |
| with torch.no_grad(): | |
| o = model(pixel_values=pixels, multimask_output=True, **args) | |
| return o.pred_masks[0, 0].numpy(), o.iou_scores[0, 0].numpy() | |
| CONTRACT = { | |
| "sam2_encoder": ( | |
| [[1, 3, SIZE, SIZE]], | |
| [[1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 4, SIZE // 4], [1, 256, SIZE // 8, SIZE // 8]], | |
| ), | |
| "sam2_decoder": ( | |
| [[1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 16, SIZE // 16], [1, 1, "N", 256], | |
| [1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 4, SIZE // 4], [1, 256, SIZE // 8, SIZE // 8]], | |
| [[1, 3, SIZE // 4, SIZE // 4], [1, 1, 3], [1, 1, 1]], | |
| ), | |
| } | |
| def program_io(pte_bytes): | |
| """Method names and forward() I/O (shape, dtype, dim order) as serialized in the program.""" | |
| from executorch.exir._serialize._program import deserialize_pte_binary | |
| pte = deserialize_pte_binary(pte_bytes) | |
| prog = pte.program if hasattr(pte, "program") else pte | |
| def desc(plan, i): | |
| v = plan.values[i].val | |
| return {"shape": list(v.sizes), "dtype": v.scalar_type.name, "dim_order": list(v.dim_order)} | |
| plan = {p.name: p for p in prog.execution_plan} | |
| f = plan["forward"] | |
| return { | |
| "methods": sorted(plan), | |
| "inputs": [desc(f, i) for i in f.inputs], | |
| "outputs": [desc(f, i) for i in f.outputs], | |
| } | |
| def check_contract(name, io, size, n_tokens): | |
| """The playground's programs: one `forward`, fp32, contiguous NCHW, these shapes.""" | |
| if size != SIZE: | |
| return "skipped (non-default size)" | |
| want_in, want_out = CONTRACT[name] | |
| want_in = [[n_tokens if d == "N" else d for d in s] for s in want_in] | |
| ok = ( | |
| io["methods"] == ["forward"] | |
| and [t["shape"] for t in io["inputs"]] == want_in | |
| and [t["shape"] for t in io["outputs"]] == want_out | |
| and all(t["dtype"] == "FLOAT" and t["dim_order"] == list(range(len(t["shape"]))) for t in io["inputs"] + io["outputs"]) | |
| ) | |
| if not ok: | |
| sys.exit(f"{name}: I/O does not match the previous contract: {json.dumps(io)}") | |
| return "matches the previous export (shapes, fp32, contiguous dim order, single forward method)" | |
| def write_if_changed(path, data): | |
| """Leave an identical file alone; otherwise replace it with a new inode, so hard links made to an | |
| earlier output elsewhere keep their content.""" | |
| data = bytes(data) | |
| if path.exists(): | |
| if path.read_bytes() == data: | |
| return | |
| path.unlink() | |
| path.write_bytes(data) | |
| def export_programs(enc, dec, enc_std, dec_std, pixels, dec_inputs): | |
| with torch.no_grad(): | |
| ep_enc = torch.export.export(enc, (pixels,), strict=False) | |
| ep_dec = torch.export.export(dec, dec_inputs, strict=False) | |
| ep_enc_std = torch.export.export(enc_std, (pixels,), strict=False) | |
| ep_dec_std = torch.export.export(dec_std, dec_inputs, strict=False) | |
| return { | |
| "sam2_encoder": ( | |
| ep_enc, | |
| _common.lower_webgpu(ep_enc), | |
| _common.lower_portable(ep_enc_std), | |
| ), | |
| "sam2_decoder": ( | |
| ep_dec, | |
| _common.lower_webgpu(ep_dec), | |
| _common.lower_portable(ep_dec_std), | |
| ), | |
| } | |
| def validation_images(args): | |
| from huggingface_hub import hf_hub_download | |
| out = [] | |
| p = Path( | |
| hf_hub_download( | |
| IMAGE["repo"], | |
| IMAGE["file"], | |
| repo_type=IMAGE["repo_type"], | |
| revision=IMAGE["revision"], | |
| ) | |
| ) | |
| digest = _common.sha256_file(p) | |
| if digest != IMAGE["sha256"]: | |
| sys.exit(f"{IMAGE['file']}: sha256 {digest} != {IMAGE['sha256']}") | |
| out.append( | |
| ( | |
| IMAGE["name"], | |
| p, | |
| digest, | |
| IMAGE["prompts"], | |
| f"hf://datasets/{IMAGE['repo']}@{IMAGE['revision']}/{IMAGE['file']}", | |
| ) | |
| ) | |
| if args.assets_dir: | |
| for name, spec in ASSET_IMAGES.items(): | |
| ap = Path(args.assets_dir) / name | |
| d = _common.sha256_file(ap) | |
| if spec["sha256"] and d != spec["sha256"]: | |
| sys.exit(f"{ap}: sha256 {d} != {spec['sha256']}") | |
| out.append( | |
| ( | |
| name, | |
| ap, | |
| d, | |
| spec["prompts"], | |
| f"sample image {name}", | |
| ) | |
| ) | |
| return out | |
| def validate(args, snap, model, tabs, progs, enc, dec, enc_std): | |
| from transformers import Sam2Processor | |
| val = { | |
| "cpu_runtime": "executorch pybindings (portable_lib) from the executorch 1.5.1 wheel" | |
| } | |
| ep_enc, wg_enc, port_enc = progs["sam2_encoder"] | |
| ep_dec, wg_dec, port_dec = progs["sam2_decoder"] | |
| img_pe = torch.from_numpy(tabs["img_pos"]) | |
| dense = torch.from_numpy(tabs["dense"]) | |
| ref512 = load_model(snap, args.size) | |
| ref1024 = load_model(snap, 1024) | |
| processor = Sam2Processor.from_pretrained(snap) | |
| other = Encoder(model, add_no_memory=not enc.add_no_memory).eval() | |
| # Everything below runs the upstream do_pool; the export used the rewrite (checked to be exact). | |
| upstream_pool() | |
| for p_name, ep, wg in (("encoder", ep_enc, wg_enc), ("decoder", ep_dec, wg_dec)): | |
| _, err = _common.try_run_webgpu_pte_on_cpu(wg.buffer, ep.example_inputs[0]) | |
| val[f"webgpu_{p_name}_pte_on_cpu"] = ( | |
| f"not runnable on CPU pybindings: {err}" if err else "ran" | |
| ) | |
| val["images"] = {} | |
| refs = Path(args.refs_dir) if args.refs_dir else None | |
| refs_meta = {"prompt_tokens": args.prompt_tokens, "prompts": {}} | |
| for name, path, digest, prompts, src in validation_images(args): | |
| pixels, img = load_image(path, args.size) | |
| W, H = img.size | |
| with torch.no_grad(): | |
| ref = enc(pixels) | |
| pool_windows_as_one_image() | |
| rewritten = enc(pixels) | |
| upstream_pool() | |
| exported = ep_enc.module()(pixels) | |
| std = enc_std(pixels) | |
| emb_other = other(pixels)[0] | |
| port = _common.run_pte(port_enc.buffer, (pixels,)) | |
| rec = { | |
| "source": src, | |
| "sha256": digest, | |
| "size": [W, H], | |
| "encoder": {}, | |
| "prompts": {}, | |
| } | |
| for i, n in enumerate(["image_embeddings", "hr0", "hr1"]): | |
| rec["encoder"][n] = { | |
| "exported_vs_eager": _common.compare( | |
| exported[i].numpy(), ref[i].numpy() | |
| ), | |
| "pool_rewrite_vs_upstream_code_max_abs": float((rewritten[i] - ref[i]).abs().max()), | |
| "eager_torch_sdpa_vs_eager_et_vk_sdpa": _common.compare( | |
| std[i].numpy(), ref[i].numpy() | |
| ), | |
| "portable_pte_vs_eager": _common.compare(port[i], ref[i].numpy()), | |
| } | |
| emb, hr0, hr1 = (t.contiguous() for t in ref) | |
| if refs is not None and name == IMAGE["name"]: | |
| refs.mkdir(parents=True, exist_ok=True) | |
| refs.joinpath("pixels.f32").write_bytes(pixels.numpy().tobytes()) | |
| for i, t in enumerate((emb, hr0, hr1)): | |
| refs.joinpath(f"enc_out{i}.f32").write_bytes(t.numpy().tobytes()) | |
| refs_meta.update(image=src, image_sha256=digest, size=[W, H]) | |
| for pname, toks in prompts.items(): | |
| dtoks = site_tokens(toks, args.prompt_tokens) | |
| sparse = encode_tokens(dtoks, W, H, tabs, args.size) | |
| ins = (emb, img_pe, sparse, dense, hr0, hr1) | |
| with torch.no_grad(): | |
| eager = [t.numpy() for t in dec(*ins)] | |
| exp = [t.numpy() for t in ep_dec.module()(*ins)] | |
| alt_out = dec(emb_other, img_pe, sparse, dense, hr0, hr1) | |
| alt, alt_iou = alt_out[0].numpy(), alt_out[1].numpy() | |
| # End to end through both portable programs (encoder output -> decoder input). | |
| pd = _common.run_pte( | |
| port_dec.buffer, | |
| ( | |
| torch.from_numpy(np.ascontiguousarray(port[0])), | |
| img_pe, | |
| sparse, | |
| dense, | |
| torch.from_numpy(np.ascontiguousarray(port[1])), | |
| torch.from_numpy(np.ascontiguousarray(port[2])), | |
| ), | |
| ) | |
| m_pte = pd[0][0] | |
| u512_m, u512_iou = upstream_512_same_pixels(ref512, pixels, toks, W, H) | |
| u_m, u_iou = upstream_reference(ref1024, processor, img, toks) | |
| up = upsample(m_pte, W, H) | |
| up_alt = upsample(alt[0], W, H) | |
| best = int(np.argmax(u_iou)) | |
| own = int(np.argmax(np.asarray(pd[1]).reshape(-1))) | |
| alt_own = int(np.argmax(alt_iou.reshape(-1))) | |
| if refs is not None and name == IMAGE["name"]: | |
| for i, t in enumerate(eager): | |
| refs.joinpath(f"dec_{pname}_out{i}.f32").write_bytes(np.ascontiguousarray(t, np.float32).tobytes()) | |
| refs.joinpath(f"sparse_{pname}.f32").write_bytes(sparse.numpy().tobytes()) | |
| refs.joinpath(f"up512_{pname}_masks.f32").write_bytes(np.ascontiguousarray(u512_m, np.float32).tobytes()) | |
| refs_meta["prompts"][pname] = {"prompt": [list(t) for t in toks], "tokens": [list(t) for t in dtoks]} | |
| rec["prompts"][pname] = { | |
| "prompt": [list(t) for t in toks], | |
| "decoder_tokens": [t[0] for t in dtoks], | |
| "eager_vs_upstream_512_masks": _common.compare(eager[0][0], u512_m), | |
| "eager_vs_upstream_512_iou_scores": _common.compare(eager[1], u512_iou), | |
| "exported_vs_eager_masks": _common.compare(exp[0], eager[0]), | |
| "portable_e2e_vs_eager_masks": _common.compare(pd[0], eager[0]), | |
| "portable_e2e_vs_eager_iou_scores": _common.compare(pd[1], eager[1]), | |
| "portable_e2e_vs_eager_object_score": _common.compare(pd[2], eager[2]), | |
| "mask_iou_portable_vs_eager": [ | |
| iou(m_pte[i], eager[0][0][i]) for i in range(3) | |
| ], | |
| "mask_iou_portable_vs_upstream_512_same_pixels": [ | |
| iou(m_pte[i], u512_m[i]) for i in range(3) | |
| ], | |
| "mask_iou_portable_vs_upstream_1024_pipeline_at_image_res": [ | |
| iou(up[i], u_m[i]) for i in range(3) | |
| ], | |
| "portable_iou_scores": np.asarray(pd[1]).reshape(-1).round(4).tolist(), | |
| "upstream_512_iou_scores": u512_iou.round(4).tolist(), | |
| "upstream_1024_iou_scores": u_iou.round(4).tolist(), | |
| "upstream_1024_best_mask": best, | |
| "best_mask_iou_portable_vs_upstream_1024": iou(up[best], u_m[best]), | |
| "portable_object_score_logit": round( | |
| float(np.asarray(pd[2]).reshape(-1)[0]), 4 | |
| ), | |
| "mask_area_fraction_portable": [ | |
| round(float((m_pte[i] > 0).mean()), 4) for i in range(3) | |
| ], | |
| "upstream_1024_best_mask_vs_closest_export_mask": max(iou(up[i], u_m[best]) for i in range(3)), | |
| "export_best_by_own_iou_head_vs_upstream_1024_best": iou(up[own], u_m[best]), | |
| "variant_%s_no_memory_embedding" % ("without" if enc.add_no_memory else "with"): { | |
| "mask_iou_vs_this_export": [iou(alt[0][i], eager[0][0][i]) for i in range(3)], | |
| "mask_iou_vs_upstream_1024_pipeline_at_image_res": [iou(up_alt[i], u_m[i]) for i in range(3)], | |
| "upstream_1024_best_mask_vs_closest_mask": max(iou(up_alt[i], u_m[best]) for i in range(3)), | |
| "best_by_own_iou_head_vs_upstream_1024_best": iou(up_alt[alt_own], u_m[best]), | |
| }, | |
| } | |
| val["images"][name] = rec | |
| print(json.dumps({name: rec}, indent=1), flush=True) | |
| if refs is not None: | |
| refs.joinpath("meta.json").write_text(json.dumps(refs_meta, indent=1) + "\n") | |
| if args.internal_dir: | |
| d = Path(args.internal_dir) | |
| val["prompt_tables_vs_internal"] = { | |
| f"prompt_{k}.bin": "bit-identical" | |
| if (d / f"prompt_{k}.bin").read_bytes() == v.astype(np.float32).tobytes() | |
| else "max abs %.3g" | |
| % float( | |
| np.abs( | |
| np.fromfile(d / f"prompt_{k}.bin", dtype=np.float32) - v.reshape(-1) | |
| ).max() | |
| ) | |
| for k, v in tabs.items() | |
| } | |
| return val | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--weights", choices=sorted(WEIGHTS), default="sam2.1") | |
| ap.add_argument( | |
| "--size", type=int, default=SIZE, help="square input size (multiple of 32)" | |
| ) | |
| ap.add_argument( | |
| "--no-memory-embedding", | |
| choices=["add", "omit"], | |
| default="add", | |
| help="add (upstream single-image behaviour, default) or omit (as the previous export)", | |
| ) | |
| ap.add_argument( | |
| "--assets-dir", | |
| help="optional: directory of sample images for extra validation", | |
| ) | |
| ap.add_argument( | |
| "--internal-dir", | |
| help="optional: compare prompt tables with an existing prompt_*.bin set", | |
| ) | |
| ap.add_argument( | |
| "--prompt-tokens", | |
| type=int, | |
| default=3, | |
| help="sparse prompt tokens per decode (trailing extra pads are masked, so any prompt that fits matches upstream)", | |
| ) | |
| ap.add_argument( | |
| "--refs-dir", | |
| help="optional: write CPU reference tensors for the browser harness (sam-car.png prompts)", | |
| ) | |
| ap.add_argument( | |
| "--skip-validation", | |
| action="store_true", | |
| help="export only (for the byte-identical re-run check)", | |
| ) | |
| args = ap.parse_args() | |
| out = Path(args.out) | |
| out.mkdir(parents=True, exist_ok=True) | |
| torch.manual_seed(0) | |
| snap, digest = snapshot(args.weights) | |
| register_attention() | |
| pool_windows_as_one_image() | |
| add_nm = args.no_memory_embedding == "add" | |
| model = freeze_hiera_pos_embed( | |
| load_model(snap, args.size, attn="et_vk_sdpa"), args.size | |
| ) | |
| enc, dec = Encoder(model, add_nm).eval(), Decoder(model).eval() | |
| tabs = prompt_tables(model) | |
| # Same weights with torch SDPA: only for the CPU portable .pte (et_vk.sdpa has no portable kernel). | |
| model_std = freeze_hiera_pos_embed( | |
| load_model(snap, args.size, attn="sdpa"), args.size | |
| ) | |
| model_std_dec = load_model(snap, args.size, attn="torch_sdpa_padmask") | |
| enc_std, dec_std = Encoder(model_std, add_nm).eval(), Decoder(model_std_dec).eval() | |
| # Example inputs for export: a fixed-seed tensor (the graph does not depend on values). | |
| gen = torch.Generator().manual_seed(0) | |
| pixels = torch.randn(1, 3, args.size, args.size, generator=gen) | |
| with torch.no_grad(): | |
| # Contiguous example inputs: torch.export records each input's dim order. | |
| emb, hr0, hr1 = (t.contiguous() for t in enc(pixels)) | |
| sparse = encode_tokens( | |
| site_tokens([("pos", args.size / 2, args.size / 2)], args.prompt_tokens), | |
| args.size, | |
| args.size, | |
| tabs, | |
| args.size, | |
| ) | |
| dec_inputs = ( | |
| emb, | |
| torch.from_numpy(tabs["img_pos"]), | |
| sparse, | |
| torch.from_numpy(tabs["dense"]), | |
| hr0, | |
| hr1, | |
| ) | |
| progs = export_programs(enc, dec, enc_std, dec_std, pixels, dec_inputs) | |
| files, graphs = {}, {} | |
| for name, (_, wg, port) in progs.items(): | |
| write_if_changed(out / f"{name}.pte", wg.buffer) | |
| write_if_changed(out / f"{name}_portable.pte", port.buffer) | |
| graphs[name] = _common.delegate_summary(wg.buffer) | |
| graphs[name]["io"] = program_io(wg.buffer) | |
| graphs[name]["io_contract"] = check_contract(name, graphs[name]["io"], args.size, args.prompt_tokens) | |
| if graphs[name]["non_delegated_kernels"] or len(graphs[name]["delegates"]) != 1: | |
| sys.exit(f"{name}: expected one delegate and no CPU kernels: {json.dumps(graphs[name])[:2000]}") | |
| for k, v in tabs.items(): | |
| write_if_changed(out / f"prompt_{k}.bin", v.astype(np.float32).tobytes()) | |
| for p in sorted(out.iterdir()): | |
| if p.suffix in (".pte", ".bin"): | |
| files[p.name] = { | |
| "bytes": p.stat().st_size, | |
| "sha256": _common.sha256_file(p), | |
| } | |
| if p.name.endswith("_portable.pte"): | |
| files[p.name]["note"] = ( | |
| "CPU-only validation artifact (portable kernels, no delegate; torch SDPA instead of et_vk.sdpa); not for release" | |
| ) | |
| w = WEIGHTS[args.weights] | |
| q = args.size // 4 | |
| recipe = { | |
| "model": f"{w['repo'].split('/')[1]} ({args.size} encoder/decoder split)", | |
| "upstream": { | |
| "model": w["repo"], | |
| "revision": w["revision"], | |
| "official": True, | |
| "license": "Apache-2.0", | |
| "files": {"model.safetensors": {"sha256": digest}}, | |
| "note": "transformers Sam2Model port published by the SAM 2 authors on the Hub", | |
| }, | |
| "versions": _common.versions(), | |
| "export": { | |
| "api": "torch.export.export(strict=False) -> to_edge_transform_and_lower(partitioner=[WebGPUPartitioner()]) -> to_executorch(), one program per module", | |
| "partitioner": "executorch.backends.webgpu.partitioner.WebGPUPartitioner() (default compile options; wraps VulkanPartitioner)", | |
| "model_code": "transformers Sam2Model; attention through torch.ops.et_vk.sdpa (transformers attention backend, recipes/sam2-hiera-tiny.py:et_vk_attention); config image_size overridden 1024 -> %d" | |
| % args.size, | |
| "programs": { | |
| "sam2_encoder.pte": { | |
| "method": "forward", | |
| "inputs": [ | |
| { | |
| "name": "pixels", | |
| "shape": [1, 3, args.size, args.size], | |
| "dtype": "float32", | |
| } | |
| ], | |
| "outputs": [ | |
| { | |
| "name": "image_embeddings", | |
| "shape": list(emb.shape), | |
| "dtype": "float32", | |
| "note": "no_memory_embedding %s" | |
| % ("added" if add_nm else "not added"), | |
| }, | |
| { | |
| "name": "hr0", | |
| "shape": list(hr0.shape), | |
| "dtype": "float32", | |
| "note": "before mask_decoder.conv_s0", | |
| }, | |
| { | |
| "name": "hr1", | |
| "shape": list(hr1.shape), | |
| "dtype": "float32", | |
| "note": "before mask_decoder.conv_s1", | |
| }, | |
| ], | |
| }, | |
| "sam2_decoder.pte": { | |
| "method": "forward", | |
| "prompt_tokens": args.prompt_tokens, | |
| "inputs": [ | |
| {"name": n, "shape": list(t.shape), "dtype": "float32"} | |
| for n, t in zip(INPUT_NAMES, dec_inputs) | |
| ], | |
| "outputs": [ | |
| { | |
| "name": "masks", | |
| "shape": [1, 3, q, q], | |
| "dtype": "float32", | |
| "note": "logits, mask tokens 1..3", | |
| }, | |
| {"name": "iou_scores", "shape": [1, 1, 3], "dtype": "float32"}, | |
| { | |
| "name": "object_score_logits", | |
| "shape": [1, 1, 1], | |
| "dtype": "float32", | |
| }, | |
| ], | |
| }, | |
| }, | |
| "prompt_tables": { | |
| f"prompt_{k}.bin": { | |
| "shape": list(v.shape), | |
| "dtype": "float32 little-endian", | |
| } | |
| for k, v in tabs.items() | |
| }, | |
| "prompt_table_sources": { | |
| "prompt_gaussian.bin": "shared_image_embedding.positional_embedding", | |
| "prompt_point_embed_bg.bin": "prompt_encoder.point_embed.weight[0]", | |
| "prompt_point_embed_fg.bin": "prompt_encoder.point_embed.weight[1]", | |
| "prompt_box_tl.bin": "prompt_encoder.point_embed.weight[2]", | |
| "prompt_box_br.bin": "prompt_encoder.point_embed.weight[3]", | |
| "prompt_not_a_point.bin": "prompt_encoder.not_a_point_embed.weight[0]", | |
| "prompt_img_pos.bin": "Sam2Model.get_image_wide_positional_embeddings() with image_size=%d" | |
| % args.size, | |
| "prompt_dense.bin": "prompt_encoder.no_mask_embed.weight broadcast to [1,256,%d,%d]" | |
| % (args.size // 16, args.size // 16), | |
| }, | |
| "graph_rewrites": [ | |
| "Hiera q-pooling of windowed tensors [nW,h,w,C] done as one batch-1 max_pool2d over the windows stacked along H (exact: h is a multiple of the stride); keeps all pools in the delegate", | |
| "attention via torch.ops.et_vk.sdpa (same math as torch SDPA; checked in validation.json)", | |
| "decoder: a not-a-point token directly after another one gets an additive -1e9 key bias in every attention (computed in-graph from the sparse input), so padding up to the fixed token count does not change the result", | |
| "Hiera position embedding (bicubic pos_embed resize + tiled window embedding) for the fixed input size computed in eager and stored as a constant", | |
| ], | |
| "differences_from_upstream_pipeline": [ | |
| "input %dx%d instead of 1024x1024 (masks %dx%d instead of 256x256)" | |
| % (args.size, args.size, q, q), | |
| "fixed %d prompt tokens per decode: points, box corners, one not-a-point pad, then padding pads that the decoder masks out (exactly upstream's prompt for any prompt that fits; larger prompts are composed by the site)" % args.prompt_tokens, | |
| "no mask prompt input (dense = no_mask_embed)", | |
| ], | |
| "preprocessing_outside_graph": "RGB, resize to %dx%d without keeping aspect, /255, ImageNet mean/std" | |
| % (args.size, args.size), | |
| "dtype": "float32", | |
| "quantization": "none (fp32)", | |
| }, | |
| "files": files, | |
| "graph": graphs, | |
| "command": _common.command_line(out), | |
| } | |
| (out / "recipe.json").write_text(json.dumps(recipe, indent=2) + "\n") | |
| print(json.dumps({"files": files, "graph": graphs}, indent=1), flush=True) | |
| if args.skip_validation: | |
| return | |
| val = validate(args, snap, model, tabs, progs, enc, dec, enc_std) | |
| (out / "validation.json").write_text(json.dumps(val, indent=2) + "\n") | |
| print(json.dumps(val, indent=1)) | |
| if __name__ == "__main__": | |
| main() | |