AyedaOk commited on
Commit
ade3024
·
verified ·
1 Parent(s): 2121da0

Upload export_sam3.py

Browse files
Files changed (1) hide show
  1. export_sam3.py +675 -0
export_sam3.py ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.12"
4
+ # dependencies = [
5
+ # "torch>=2.7",
6
+ # "torchvision",
7
+ # "onnx",
8
+ # "onnxruntime",
9
+ # "onnxsim",
10
+ # "timm",
11
+ # "ftfy==6.1.1",
12
+ # "regex",
13
+ # "iopath",
14
+ # "huggingface_hub",
15
+ # "einops",
16
+ # "pycocotools",
17
+ # "psutil",
18
+ # ]
19
+ # ///
20
+ """Export SAM3 tracker ONNX files and package a darktable .dtmodel.
21
+
22
+ This script exports the SAM3 interactive tracker path behind a SAM2-shaped ONNX
23
+ interface for darktable compatibility. It expects a user-downloaded sam3.pt in
24
+ the repo root, clones the SAM3 source repo if needed, pins it to the tested
25
+ commit, writes encoder.onnx and decoder.onnx into mask-object-sam3/, then
26
+ packages mask-object-sam3.dtmodel.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import contextlib
33
+ import os
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import warnings
38
+ import zipfile
39
+ from pathlib import Path
40
+
41
+ import torch
42
+ import torch.nn as nn
43
+ import torch.nn.functional as F
44
+
45
+
46
+ SAM3_REPO_URL = "https://github.com/facebookresearch/sam3.git"
47
+ SAM3_COMMIT = "8e451d5eb43c817b64ae7577fb7b9ae223db88a9"
48
+ MODEL_ID = "mask-object-sam3"
49
+
50
+
51
+ try:
52
+ import onnxruntime # type: ignore
53
+
54
+ ONNXRUNTIME_EXISTS = True
55
+ except ImportError:
56
+ ONNXRUNTIME_EXISTS = False
57
+
58
+
59
+ try:
60
+ import onnxsim # type: ignore
61
+
62
+ ONNXSIM_EXISTS = True
63
+ except ImportError:
64
+ ONNXSIM_EXISTS = False
65
+
66
+
67
+ class SAM3TrackerEncoderOnnxModel(nn.Module):
68
+ """Wrap SAM3 tracker image encoder with a SAM2-shaped ONNX interface."""
69
+
70
+ SAM3_IMAGE_SIZE = 1008
71
+
72
+ def __init__(self, image_model: nn.Module, tracker_model: nn.Module):
73
+ super().__init__()
74
+ self.image_model = image_model
75
+ self.tracker_model = tracker_model
76
+ self.feat_sizes = [(288, 288), (144, 144), (72, 72)]
77
+
78
+ @torch.no_grad()
79
+ def forward(self, image: torch.Tensor):
80
+ image = F.interpolate(
81
+ image,
82
+ size=(self.SAM3_IMAGE_SIZE, self.SAM3_IMAGE_SIZE),
83
+ mode="bilinear",
84
+ align_corners=False,
85
+ )
86
+ backbone_out = self.image_model.backbone.forward_image(image)[
87
+ "sam2_backbone_out"
88
+ ]
89
+ backbone_out["backbone_fpn"][0] = self.tracker_model.sam_mask_decoder.conv_s0(
90
+ backbone_out["backbone_fpn"][0]
91
+ )
92
+ backbone_out["backbone_fpn"][1] = self.tracker_model.sam_mask_decoder.conv_s1(
93
+ backbone_out["backbone_fpn"][1]
94
+ )
95
+ _, vision_feats, _, _ = self.tracker_model._prepare_backbone_features(
96
+ backbone_out
97
+ )
98
+
99
+ # Match SAM3InteractiveImagePredictor image embedding path.
100
+ vision_feats[-1] = vision_feats[-1] + self.tracker_model.no_mem_embed
101
+
102
+ feats = [
103
+ feat.permute(1, 2, 0).view(1, -1, *feat_size)
104
+ for feat, feat_size in zip(vision_feats[::-1], self.feat_sizes[::-1])
105
+ ][::-1]
106
+
107
+ high_res_feats_0 = F.interpolate(
108
+ feats[0], size=(256, 256), mode="bilinear", align_corners=False
109
+ )
110
+ high_res_feats_1 = F.interpolate(
111
+ feats[1], size=(128, 128), mode="bilinear", align_corners=False
112
+ )
113
+ image_embed = F.interpolate(
114
+ feats[2], size=(64, 64), mode="bilinear", align_corners=False
115
+ )
116
+
117
+ return high_res_feats_0, high_res_feats_1, image_embed
118
+
119
+
120
+ class SAM3TrackerDecoderOnnxModel(nn.Module):
121
+ """Wrap SAM3 tracker decoder with a SAM2-shaped ONNX interface."""
122
+
123
+ SAM2_IMAGE_SIZE = 1024.0
124
+ SAM3_IMAGE_SIZE = 1008.0
125
+
126
+ def __init__(self, tracker_model: nn.Module, multimask_output: bool):
127
+ super().__init__()
128
+ self.prompt_encoder = tracker_model.sam_prompt_encoder
129
+ self.mask_decoder = tracker_model.sam_mask_decoder
130
+ self.image_size = tracker_model.image_size
131
+ self.multimask_output = multimask_output
132
+
133
+ @torch.no_grad()
134
+ def forward(
135
+ self,
136
+ image_embed: torch.Tensor,
137
+ high_res_feats_0: torch.Tensor,
138
+ high_res_feats_1: torch.Tensor,
139
+ point_coords: torch.Tensor,
140
+ point_labels: torch.Tensor,
141
+ mask_input: torch.Tensor,
142
+ has_mask_input: torch.Tensor,
143
+ ):
144
+ image_embed = F.interpolate(
145
+ image_embed, size=(72, 72), mode="bilinear", align_corners=False
146
+ )
147
+ high_res_feats_0 = F.interpolate(
148
+ high_res_feats_0, size=(288, 288), mode="bilinear", align_corners=False
149
+ )
150
+ high_res_feats_1 = F.interpolate(
151
+ high_res_feats_1, size=(144, 144), mode="bilinear", align_corners=False
152
+ )
153
+ mask_input = F.interpolate(
154
+ mask_input, size=(288, 288), mode="bilinear", align_corners=False
155
+ )
156
+ point_coords = point_coords * (self.SAM3_IMAGE_SIZE / self.SAM2_IMAGE_SIZE)
157
+
158
+ sparse_embedding = self._embed_points(point_coords, point_labels)
159
+ dense_embedding = self._embed_masks(mask_input, has_mask_input)
160
+
161
+ masks, iou_predictions, _, _ = self.mask_decoder(
162
+ image_embeddings=image_embed,
163
+ image_pe=self.prompt_encoder.get_dense_pe(),
164
+ sparse_prompt_embeddings=sparse_embedding,
165
+ dense_prompt_embeddings=dense_embedding,
166
+ multimask_output=self.multimask_output,
167
+ repeat_image=False,
168
+ high_res_features=[high_res_feats_0, high_res_feats_1],
169
+ )
170
+
171
+ masks = torch.clamp(masks, -32.0, 32.0)
172
+ masks = F.interpolate(
173
+ masks, size=(256, 256), mode="bilinear", align_corners=False
174
+ )
175
+ return masks, iou_predictions
176
+
177
+ def _embed_points(
178
+ self, point_coords: torch.Tensor, point_labels: torch.Tensor
179
+ ) -> torch.Tensor:
180
+ point_coords = point_coords + 0.5
181
+
182
+ padding_point = torch.zeros(
183
+ (point_coords.shape[0], 1, 2), device=point_coords.device
184
+ )
185
+ padding_label = -torch.ones(
186
+ (point_labels.shape[0], 1),
187
+ dtype=point_labels.dtype,
188
+ device=point_labels.device,
189
+ )
190
+ point_coords = torch.cat([point_coords, padding_point], dim=1)
191
+ point_labels = torch.cat([point_labels, padding_label], dim=1)
192
+
193
+ point_coords[:, :, 0] = point_coords[:, :, 0] / self.image_size
194
+ point_coords[:, :, 1] = point_coords[:, :, 1] / self.image_size
195
+
196
+ point_embedding = self.prompt_encoder.pe_layer._pe_encoding(point_coords)
197
+ point_labels = point_labels.unsqueeze(-1).expand_as(point_embedding)
198
+
199
+ point_embedding = point_embedding * (point_labels != -1)
200
+ point_embedding = (
201
+ point_embedding
202
+ + self.prompt_encoder.not_a_point_embed.weight
203
+ * (point_labels == -1)
204
+ )
205
+
206
+ for i in range(self.prompt_encoder.num_point_embeddings):
207
+ point_embedding = (
208
+ point_embedding
209
+ + self.prompt_encoder.point_embeddings[i].weight
210
+ * (point_labels == i)
211
+ )
212
+
213
+ return point_embedding
214
+
215
+ def _embed_masks(
216
+ self, mask_input: torch.Tensor, has_mask_input: torch.Tensor
217
+ ) -> torch.Tensor:
218
+ target_size = self.prompt_encoder.mask_input_size
219
+ if mask_input.shape[-2:] != target_size:
220
+ mask_input = F.interpolate(
221
+ mask_input.float(),
222
+ size=target_size,
223
+ mode="bilinear",
224
+ align_corners=False,
225
+ antialias=True,
226
+ )
227
+
228
+ mask_embedding = self.prompt_encoder.mask_downscaling(mask_input)
229
+ no_mask_embedding = self.prompt_encoder.no_mask_embed.weight.reshape(
230
+ 1, -1, 1, 1
231
+ )
232
+
233
+ return has_mask_input * mask_embedding + (1 - has_mask_input) * no_mask_embedding
234
+
235
+
236
+ def to_numpy(tensor: torch.Tensor):
237
+ return tensor.detach().cpu().numpy()
238
+
239
+
240
+ def run(command: list[str], cwd: Path | None = None) -> None:
241
+ print(" ".join(command))
242
+ subprocess.run(command, cwd=cwd, check=True)
243
+
244
+
245
+ def ensure_sam3_repo(sam3_root: Path) -> None:
246
+ if shutil.which("git") is None:
247
+ raise RuntimeError("git was not found. Install git and run this script again.")
248
+
249
+ if not sam3_root.exists():
250
+ print(f"Cloning SAM3 into {sam3_root}...")
251
+ run(["git", "clone", SAM3_REPO_URL, str(sam3_root)])
252
+ elif not (sam3_root / ".git").is_dir():
253
+ raise RuntimeError(f"{sam3_root} exists but is not a git checkout")
254
+
255
+ print(f"Pinning SAM3 to {SAM3_COMMIT}...")
256
+ try:
257
+ run(["git", "checkout", SAM3_COMMIT], cwd=sam3_root)
258
+ except subprocess.CalledProcessError:
259
+ run(["git", "fetch", "origin", SAM3_COMMIT], cwd=sam3_root)
260
+ run(["git", "checkout", SAM3_COMMIT], cwd=sam3_root)
261
+
262
+ if not (sam3_root / "sam3").is_dir():
263
+ raise RuntimeError(f"SAM3 package folder not found under {sam3_root}")
264
+
265
+
266
+ def add_sam3_to_path(sam3_root: Path) -> None:
267
+ sam3_root = sam3_root.resolve()
268
+ sam3_root_text = str(sam3_root)
269
+ if sam3_root_text not in sys.path:
270
+ sys.path.insert(0, sam3_root_text)
271
+
272
+
273
+ @contextlib.contextmanager
274
+ def redirect_cuda_allocations_to_cpu(enabled: bool):
275
+ """Redirect SAM3 construction-time CUDA cache tensors to CPU."""
276
+
277
+ if not enabled:
278
+ yield
279
+ return
280
+
281
+ original_zeros = torch.zeros
282
+ original_arange = torch.arange
283
+
284
+ def rewrite_cuda_device(kwargs: dict) -> dict:
285
+ if kwargs.get("device") == "cuda":
286
+ kwargs = dict(kwargs)
287
+ kwargs["device"] = "cpu"
288
+ return kwargs
289
+
290
+ def zeros_wrapper(*args, **kwargs):
291
+ return original_zeros(*args, **rewrite_cuda_device(kwargs))
292
+
293
+ def arange_wrapper(*args, **kwargs):
294
+ return original_arange(*args, **rewrite_cuda_device(kwargs))
295
+
296
+ torch.zeros = zeros_wrapper
297
+ torch.arange = arange_wrapper
298
+ try:
299
+ yield
300
+ finally:
301
+ torch.zeros = original_zeros
302
+ torch.arange = original_arange
303
+
304
+
305
+ def build_models(sam3_root: Path, checkpoint: Path, device: str):
306
+ add_sam3_to_path(sam3_root)
307
+
308
+ import sam3.model_builder as model_builder
309
+
310
+ force_real_rope(model_builder)
311
+ disable_fused_addmm_activation()
312
+
313
+ print("Loading SAM3 image model with interactive tracker...")
314
+ with redirect_cuda_allocations_to_cpu(enabled=device == "cpu"):
315
+ image_model = model_builder.build_sam3_image_model(
316
+ checkpoint_path=str(checkpoint),
317
+ load_from_HF=False,
318
+ enable_inst_interactivity=True,
319
+ device=device,
320
+ eval_mode=True,
321
+ )
322
+
323
+ if image_model.inst_interactive_predictor is None:
324
+ raise RuntimeError("SAM3 image model did not create inst_interactive_predictor")
325
+
326
+ tracker_model = image_model.inst_interactive_predictor.model
327
+ image_model.eval()
328
+ tracker_model.eval()
329
+ image_model.to(device)
330
+ tracker_model.to(device)
331
+ image_model.float()
332
+ tracker_model.float()
333
+ if device == "cpu":
334
+ add_parameter_dtype_input_hooks(image_model)
335
+ add_parameter_dtype_input_hooks(tracker_model)
336
+ return image_model, tracker_model
337
+
338
+
339
+ def force_real_rope(model_builder_module) -> None:
340
+ """Force SAM3 ViT construction to avoid complex-valued RoPE ops."""
341
+
342
+ original_create_vit_backbone = model_builder_module._create_vit_backbone
343
+
344
+ def create_vit_backbone_real_rope(
345
+ compile_mode=None,
346
+ use_fa3=False,
347
+ use_rope_real=False,
348
+ ):
349
+ return original_create_vit_backbone(
350
+ compile_mode=compile_mode,
351
+ use_fa3=use_fa3,
352
+ use_rope_real=True,
353
+ )
354
+
355
+ model_builder_module._create_vit_backbone = create_vit_backbone_real_rope
356
+
357
+
358
+ def disable_fused_addmm_activation() -> None:
359
+ """Replace SAM3's fused addmm+activation with ONNX-friendly ops."""
360
+
361
+ import torch.nn.functional as functional
362
+ import sam3.model.vitdet as vitdet
363
+ import sam3.perflib.fused as fused
364
+
365
+ def export_friendly_addmm_act(activation, linear, mat1):
366
+ output = functional.linear(mat1.float(), linear.weight.float(), linear.bias.float())
367
+ if activation in [functional.relu, torch.nn.ReLU]:
368
+ return functional.relu(output)
369
+ if activation in [functional.gelu, torch.nn.GELU]:
370
+ return functional.gelu(output)
371
+ raise ValueError(f"Unexpected activation {activation}")
372
+
373
+ fused.addmm_act = export_friendly_addmm_act
374
+ vitdet.addmm_act = export_friendly_addmm_act
375
+
376
+
377
+ def add_parameter_dtype_input_hooks(model: nn.Module) -> None:
378
+ """Cast floating inputs to module parameter dtype during CPU export."""
379
+
380
+ def first_parameter_dtype(module: nn.Module):
381
+ for parameter in module.parameters(recurse=False):
382
+ return parameter.dtype
383
+ return None
384
+
385
+ def cast_value(value, dtype):
386
+ if torch.is_tensor(value) and value.is_floating_point() and value.dtype != dtype:
387
+ return value.to(dtype=dtype)
388
+ if isinstance(value, tuple):
389
+ return tuple(cast_value(item, dtype) for item in value)
390
+ if isinstance(value, list):
391
+ return [cast_value(item, dtype) for item in value]
392
+ if isinstance(value, dict):
393
+ return {key: cast_value(item, dtype) for key, item in value.items()}
394
+ return value
395
+
396
+ def hook(module, args):
397
+ dtype = first_parameter_dtype(module)
398
+ if dtype is None:
399
+ return args
400
+ return tuple(cast_value(arg, dtype) for arg in args)
401
+
402
+ hook_types = (
403
+ nn.Conv2d,
404
+ nn.ConvTranspose2d,
405
+ nn.LayerNorm,
406
+ nn.Linear,
407
+ nn.MultiheadAttention,
408
+ )
409
+ for module in model.modules():
410
+ if isinstance(module, hook_types):
411
+ module.register_forward_pre_hook(hook)
412
+
413
+
414
+ def export_encoder(
415
+ image_model: nn.Module, tracker_model: nn.Module, output: Path, opset: int
416
+ ):
417
+ encoder = SAM3TrackerEncoderOnnxModel(image_model, tracker_model)
418
+ encoder.eval()
419
+
420
+ device = tracker_model.device
421
+ dummy_image = torch.randn(1, 3, 1024, 1024, dtype=torch.float32, device=device)
422
+ high_res_feats_0, high_res_feats_1, image_embed = encoder(dummy_image)
423
+
424
+ print("Encoder output shapes:")
425
+ print(f" high_res_feats_0: {tuple(high_res_feats_0.shape)}")
426
+ print(f" high_res_feats_1: {tuple(high_res_feats_1.shape)}")
427
+ print(f" image_embed: {tuple(image_embed.shape)}")
428
+
429
+ with warnings.catch_warnings():
430
+ warnings.filterwarnings("ignore", category=torch.jit.TracerWarning)
431
+ warnings.filterwarnings("ignore", category=UserWarning)
432
+ print(f"Exporting encoder to {output}...")
433
+ torch.onnx.export(
434
+ encoder,
435
+ dummy_image,
436
+ str(output),
437
+ export_params=True,
438
+ verbose=False,
439
+ opset_version=opset,
440
+ do_constant_folding=True,
441
+ input_names=["image"],
442
+ output_names=["high_res_feats_0", "high_res_feats_1", "image_embed"],
443
+ dynamo=False,
444
+ )
445
+
446
+ simplify_model(output)
447
+ verify_model(output, {"image": to_numpy(dummy_image)}, "Encoder")
448
+ return high_res_feats_0, high_res_feats_1, image_embed
449
+
450
+
451
+ def export_decoder(
452
+ tracker_model: nn.Module,
453
+ output: Path,
454
+ opset: int,
455
+ high_res_feats_0: torch.Tensor,
456
+ high_res_feats_1: torch.Tensor,
457
+ image_embed: torch.Tensor,
458
+ multimask_output: bool,
459
+ ):
460
+ decoder = SAM3TrackerDecoderOnnxModel(
461
+ tracker_model, multimask_output=multimask_output
462
+ )
463
+ decoder.eval()
464
+
465
+ device = tracker_model.device
466
+ dummy_inputs = {
467
+ "image_embed": image_embed,
468
+ "high_res_feats_0": high_res_feats_0,
469
+ "high_res_feats_1": high_res_feats_1,
470
+ "point_coords": torch.randint(
471
+ low=0, high=1024, size=(1, 5, 2), dtype=torch.float32, device=device
472
+ ),
473
+ "point_labels": torch.randint(
474
+ low=0, high=2, size=(1, 5), dtype=torch.float32, device=device
475
+ ),
476
+ "mask_input": torch.randn(1, 1, 256, 256, dtype=torch.float32, device=device),
477
+ "has_mask_input": torch.tensor([1], dtype=torch.float32, device=device),
478
+ }
479
+
480
+ masks, iou_predictions = decoder(**dummy_inputs)
481
+ print("Decoder output shapes:")
482
+ print(f" masks: {tuple(masks.shape)}")
483
+ print(f" iou_predictions: {tuple(iou_predictions.shape)}")
484
+
485
+ dynamic_axes = {
486
+ "point_coords": {1: "num_points"},
487
+ "point_labels": {1: "num_points"},
488
+ }
489
+
490
+ with warnings.catch_warnings():
491
+ warnings.filterwarnings("ignore", category=torch.jit.TracerWarning)
492
+ warnings.filterwarnings("ignore", category=UserWarning)
493
+ print(f"Exporting decoder to {output}...")
494
+ torch.onnx.export(
495
+ decoder,
496
+ tuple(dummy_inputs.values()),
497
+ str(output),
498
+ export_params=True,
499
+ verbose=False,
500
+ opset_version=opset,
501
+ do_constant_folding=True,
502
+ input_names=list(dummy_inputs.keys()),
503
+ output_names=["masks", "iou_predictions"],
504
+ dynamic_axes=dynamic_axes,
505
+ dynamo=False,
506
+ )
507
+
508
+ simplify_model(output)
509
+ verify_model(output, {k: to_numpy(v) for k, v in dummy_inputs.items()}, "Decoder")
510
+
511
+
512
+ def simplify_model(model_path: Path) -> None:
513
+ if not ONNXSIM_EXISTS:
514
+ print("Warning: onnx-simplifier not installed, skipping simplification.")
515
+ return
516
+
517
+ import onnx
518
+
519
+ print(f"Simplifying {model_path.name}...")
520
+ onnx_model = onnx.load(str(model_path))
521
+ model_simp, check = onnxsim.simplify(onnx_model)
522
+ if not check:
523
+ print("Warning: simplified model failed validation, keeping original.")
524
+ return
525
+
526
+ to_remove = [
527
+ vi
528
+ for vi in model_simp.graph.value_info
529
+ if len(vi.type.tensor_type.shape.dim) == 0
530
+ ]
531
+ for vi in to_remove:
532
+ model_simp.graph.value_info.remove(vi)
533
+ if to_remove:
534
+ print(f"Removed {len(to_remove)} empty shape annotations.")
535
+
536
+ onnx.save(model_simp, str(model_path))
537
+ print("Graph simplification passed.")
538
+
539
+
540
+ def verify_model(model_path: Path, dummy_inputs: dict, name: str) -> None:
541
+ if not ONNXRUNTIME_EXISTS:
542
+ print(f"Warning: onnxruntime not installed, skipping {name} validation.")
543
+ return
544
+
545
+ providers = ["CPUExecutionProvider"]
546
+ session = onnxruntime.InferenceSession(str(model_path), providers=providers)
547
+ outputs = session.run(None, dummy_inputs)
548
+ print(f"{name} ran successfully with ONNXRuntime.")
549
+ for output_meta, output_value in zip(session.get_outputs(), outputs):
550
+ print(f" {output_meta.name}: {tuple(output_value.shape)}")
551
+
552
+
553
+ def package_dtmodel(output_dir: Path, package_path: Path) -> None:
554
+ required_files = ["config.json", "encoder.onnx", "decoder.onnx"]
555
+ missing = [name for name in required_files if not (output_dir / name).is_file()]
556
+ if missing:
557
+ raise FileNotFoundError(
558
+ f"Missing required file(s) in {output_dir}: {', '.join(missing)}"
559
+ )
560
+
561
+ if package_path.exists():
562
+ package_path.unlink()
563
+
564
+ print(f"Packaging {package_path}...")
565
+ with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED) as zf:
566
+ for name in required_files:
567
+ file_path = output_dir / name
568
+ zf.write(file_path, f"{MODEL_ID}/{name}")
569
+ print(f"Created: {package_path}")
570
+
571
+
572
+ def export(
573
+ sam3_root: Path,
574
+ checkpoint: Path,
575
+ output_dir: Path,
576
+ package_path: Path,
577
+ opset: int,
578
+ device: str,
579
+ multimask_output: bool,
580
+ ) -> None:
581
+ if device == "cuda" and not torch.cuda.is_available():
582
+ raise RuntimeError("CUDA requested but torch.cuda.is_available() is false")
583
+
584
+ ensure_sam3_repo(sam3_root)
585
+
586
+ if not checkpoint.is_file():
587
+ raise FileNotFoundError(
588
+ f"sam3.pt not found: {checkpoint}\n"
589
+ "Download it from https://huggingface.co/facebook/sam3 after "
590
+ "requesting access, then place it next to export_sam3.py."
591
+ )
592
+
593
+ output_dir.mkdir(parents=True, exist_ok=True)
594
+ image_model, tracker_model = build_models(sam3_root, checkpoint, device)
595
+
596
+ encoder_path = output_dir / "encoder.onnx"
597
+ decoder_path = output_dir / "decoder.onnx"
598
+
599
+ high_res_feats_0, high_res_feats_1, image_embed = export_encoder(
600
+ image_model,
601
+ tracker_model,
602
+ encoder_path,
603
+ opset,
604
+ )
605
+ export_decoder(
606
+ tracker_model,
607
+ decoder_path,
608
+ opset,
609
+ high_res_feats_0,
610
+ high_res_feats_1,
611
+ image_embed,
612
+ multimask_output,
613
+ )
614
+ package_dtmodel(output_dir, package_path)
615
+
616
+ print("Done.")
617
+ print(f"Encoder: {encoder_path}")
618
+ print(f"Decoder: {decoder_path}")
619
+ print(f"Package: {package_path}")
620
+
621
+
622
+ def main() -> None:
623
+ repo_root = Path(__file__).resolve().parent
624
+
625
+ parser = argparse.ArgumentParser(
626
+ description="Export SAM3 tracker encoder.onnx/decoder.onnx and package a darktable .dtmodel."
627
+ )
628
+ parser.add_argument(
629
+ "--sam3-root",
630
+ type=Path,
631
+ default=repo_root / "sam3",
632
+ help="Path where the facebookresearch/sam3 repo will be cloned or reused.",
633
+ )
634
+ parser.add_argument(
635
+ "--checkpoint",
636
+ type=Path,
637
+ default=repo_root / "sam3.pt",
638
+ help="Path to the user-downloaded sam3.pt checkpoint.",
639
+ )
640
+ parser.add_argument(
641
+ "--output-dir",
642
+ type=Path,
643
+ default=repo_root / MODEL_ID,
644
+ help="Output directory containing config.json and generated ONNX files.",
645
+ )
646
+ parser.add_argument(
647
+ "--package",
648
+ type=Path,
649
+ default=repo_root / f"{MODEL_ID}.dtmodel",
650
+ help="Output .dtmodel package path.",
651
+ )
652
+ parser.add_argument("--opset", type=int, default=20)
653
+ parser.add_argument("--device", choices=["cpu", "cuda"], default="cpu")
654
+ parser.add_argument(
655
+ "--single-mask",
656
+ action="store_true",
657
+ help="Export decoder with a single mask output instead of three masks.",
658
+ )
659
+ args = parser.parse_args()
660
+
661
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
662
+
663
+ export(
664
+ sam3_root=args.sam3_root,
665
+ checkpoint=args.checkpoint,
666
+ output_dir=args.output_dir,
667
+ package_path=args.package,
668
+ opset=args.opset,
669
+ device=args.device,
670
+ multimask_output=not args.single_mask,
671
+ )
672
+
673
+
674
+ if __name__ == "__main__":
675
+ main()