File size: 41,544 Bytes
1c8d800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
#!/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()