File size: 5,859 Bytes
1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 5ff8f6e 1f71164 | 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 | from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
import coremltools as ct
import numpy as np
import timm
import torch
from PIL import Image
DEFAULT_MODEL = "vit_base_patch16_dinov3.lvd1689m"
BASE_MODEL = "facebook/dinov3-vitb16-pretrain-lvd1689m"
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
class DINOv3Encoder(torch.nn.Module):
def __init__(self, backbone: torch.nn.Module) -> None:
super().__init__()
self.backbone = backbone
self.num_prefix_tokens = backbone.num_prefix_tokens
self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1))
self.register_buffer("std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1))
def forward(self, image: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
pixels = (image / 255.0 - self.mean) / self.std
features = self.backbone.forward_features(pixels)
cls = features[:, 0]
norm = torch.sqrt(torch.sum(cls * cls, dim=-1, keepdim=True).clamp_min(1e-12))
# The prefix is one CLS token plus four register tokens. Registers absorb
# high-norm artifacts that would otherwise pollute the patch tokens; they
# are not features and nothing downstream uses them.
patches = features[:, self.num_prefix_tokens:]
# Patches stay unnormalized because dense heads generally want the
# magnitude. Callers doing cosine can normalize per token themselves.
return cls / norm, patches
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert DINOv3 to a Core ML model returning one CLS embedding."
)
parser.add_argument("--size", type=int, default=448)
parser.add_argument("--precision", choices=("fp16", "fp32"), default="fp32")
parser.add_argument("--output", type=Path)
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def synthetic_image(size: int) -> Image.Image:
y, x = np.mgrid[0:size, 0:size]
rgb = np.stack(
((x * 255 // size), (y * 255 // size), ((x + y) * 255 // (size * 2))),
axis=-1,
).astype(np.uint8)
return Image.fromarray(rgb, mode="RGB")
def main() -> None:
args = parse_args()
if args.size <= 0 or args.size % 16:
raise SystemExit("--size must be a positive multiple of 16")
if args.output is None:
precision_name = args.precision.upper()
args.output = Path(f"models/DINOv3ViTB16-{precision_name}-{args.size}.mlpackage")
if args.output.exists():
if not args.force:
raise SystemExit(f"{args.output} already exists; pass --force to replace it")
shutil.rmtree(args.output)
example = torch.zeros(1, 3, args.size, args.size, dtype=torch.float32)
backbone = timm.create_model(
DEFAULT_MODEL,
pretrained=True,
img_size=args.size,
num_classes=0,
).eval()
model = DINOv3Encoder(backbone).eval()
with torch.inference_mode():
exported = torch.export.export(model, (example,)).run_decompositions({})
precision = ct.precision.FLOAT16 if args.precision == "fp16" else ct.precision.FLOAT32
coreml_model = ct.convert(
exported,
convert_to="mlprogram",
inputs=[
ct.ImageType(
name="image",
shape=example.shape,
color_layout=ct.colorlayout.RGB,
)
],
outputs=[ct.TensorType(name="embedding"), ct.TensorType(name="patch_embeddings")],
minimum_deployment_target=ct.target.macOS14,
compute_precision=precision,
)
coreml_model.author = "dinov3-coreml; base model by Meta"
coreml_model.license = "DINOv3 License"
coreml_model.short_description = "DINOv3 ViT-B/16 CLS embedding and patch tokens"
coreml_model.user_defined_metadata["base_model"] = BASE_MODEL
coreml_model.input_description["image"] = f"RGB image resized to {args.size}x{args.size}"
coreml_model.output_description["embedding"] = "L2-normalized 768-value CLS embedding"
coreml_model.output_description["patch_embeddings"] = "Unnormalized patch tokens, one per 16x16 patch"
args.output.parent.mkdir(parents=True, exist_ok=True)
coreml_model.save(args.output)
image = synthetic_image(args.size)
array = np.asarray(image, dtype=np.float32).transpose(2, 0, 1)[None, ...]
with torch.inference_mode():
torch_cls, torch_patches = (t.numpy()[0] for t in model(torch.from_numpy(array)))
prediction = coreml_model.predict({"image": image})
coreml_output = np.asarray(prediction["embedding"])[0]
coreml_patches = np.asarray(prediction["patch_embeddings"])[0]
def cosine_of(a: np.ndarray, b: np.ndarray) -> float:
a, b = a.reshape(-1), b.reshape(-1)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
cosine = cosine_of(torch_cls, coreml_output)
patch_cosine = cosine_of(torch_patches, coreml_patches)
report = {
"model": DEFAULT_MODEL,
"base_model": BASE_MODEL,
"input_size": args.size,
"precision": args.precision,
"output_shape": list(coreml_output.shape),
"patch_output_shape": list(coreml_patches.shape),
"pytorch_coreml_cosine_similarity": cosine,
"pytorch_coreml_patch_cosine_similarity": patch_cosine,
"coreml_output_l2_norm": float(np.linalg.norm(coreml_output)),
}
report_path = args.output.with_suffix(".validation.json")
report_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({"output": str(args.output), **report}, indent=2))
if min(cosine, patch_cosine) < 0.999:
raise SystemExit("Core ML parity check failed: cosine similarity is below 0.999")
if __name__ == "__main__":
main()
|