fix: load hash-pinned DINOv2 weights without network
This commit is contained in:
parent
f0e2baaf3d
commit
0e85c399bb
3 changed files with 111 additions and 8 deletions
1
recognization/.gitignore
vendored
1
recognization/.gitignore
vendored
|
|
@ -19,6 +19,7 @@ ENV/
|
||||||
|
|
||||||
# Temporary weights
|
# Temporary weights
|
||||||
runs/detect/train/weights/last.pt
|
runs/detect/train/weights/last.pt
|
||||||
|
weights/dinov2_vits14_lvd142m.safetensors
|
||||||
*.log
|
*.log
|
||||||
stock-*/identity_head.pt
|
stock-*/identity_head.pt
|
||||||
.demo-token
|
.demo-token
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,78 @@ DEFAULT_THRESHOLD = 0.55
|
||||||
# The same line for a fine-tuned head, whose similarity scale is its own.
|
# The same line for a fine-tuned head, whose similarity scale is its own.
|
||||||
DEFAULT_HEAD_THRESHOLD = 0.40
|
DEFAULT_HEAD_THRESHOLD = 0.40
|
||||||
|
|
||||||
|
# DINOv2 ViT-S/14, LVD-142M. timm's default source is the Hugging Face copy of
|
||||||
|
# these weights (Apache-2.0); pretrained=True fetches it even when YOLO_OFFLINE=1.
|
||||||
|
# The pin is the sha256 of that model.safetensors (timm hub snapshot 4610ca14).
|
||||||
|
# Not in Git (~84 MB). Place it at weights/dinov2_vits14_lvd142m.safetensors, or
|
||||||
|
# point DORAYAKI_DINOV2_WEIGHTS at a copy. See README "DINOv2 weights".
|
||||||
|
DINOV2_MODEL = "vit_small_patch14_dinov2.lvd142m"
|
||||||
|
DINOV2_SHA256 = "04d27f3400d059fc0cfd7d17dd1909a75bf3ea8fb3eeb48b97cb99e57ee20081"
|
||||||
|
DINOV2_FILENAME = "dinov2_vits14_lvd142m.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def dinov2_weights_path() -> str:
|
||||||
|
override = os.environ.get("DORAYAKI_DINOV2_WEIGHTS")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
return os.path.join(os.path.dirname(here), "weights", DINOV2_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_dinov2(image_size: int) -> Any:
|
||||||
|
"""Build the gallery backbone from the pinned local file. Never downloads.
|
||||||
|
|
||||||
|
A missing or altered file raises FileNotFoundError naming the path and the
|
||||||
|
expected sha256. The head in finetune_identity.py is a Linear(384, 128) on
|
||||||
|
this model's features; a different backbone will not load that head.
|
||||||
|
"""
|
||||||
|
import timm
|
||||||
|
from safetensors.torch import load_file
|
||||||
|
from timm.models.vision_transformer import checkpoint_filter_fn
|
||||||
|
|
||||||
|
path = dinov2_weights_path()
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"DINOv2 weights not found at {path}. "
|
||||||
|
f"Copy model.safetensors (sha256 {DINOV2_SHA256}) from the local "
|
||||||
|
f"Hugging Face cache of timm/{DINOV2_MODEL}, or set DORAYAKI_DINOV2_WEIGHTS. "
|
||||||
|
f"The gallery does not download weights."
|
||||||
|
)
|
||||||
|
digest = _sha256(path)
|
||||||
|
if digest != DINOV2_SHA256:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"DINOv2 weights at {path} are sha256 {digest}, expected {DINOV2_SHA256}."
|
||||||
|
)
|
||||||
|
model = timm.create_model(
|
||||||
|
DINOV2_MODEL,
|
||||||
|
pretrained=False,
|
||||||
|
num_classes=0,
|
||||||
|
img_size=int(image_size),
|
||||||
|
dynamic_img_size=True,
|
||||||
|
)
|
||||||
|
# The file is the 518px checkpoint. timm's own filter resizes pos_embed when
|
||||||
|
# the caller asks for another size; pretrained=True would do this and also
|
||||||
|
# download, so it stays off.
|
||||||
|
state = checkpoint_filter_fn(load_file(path), model)
|
||||||
|
missing, unexpected = model.load_state_dict(state, strict=False)
|
||||||
|
unexpected = [key for key in unexpected if not key.startswith("head.")]
|
||||||
|
if missing or unexpected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"DINOv2 weights at {path} do not match {DINOV2_MODEL} "
|
||||||
|
f"(missing={missing}, unexpected={unexpected})."
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GalleryEntry:
|
class GalleryEntry:
|
||||||
|
|
@ -138,13 +210,7 @@ class ProductGallery:
|
||||||
device = self.device
|
device = self.device
|
||||||
if device is None:
|
if device is None:
|
||||||
device = "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu"
|
device = "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu"
|
||||||
model = timm.create_model(
|
model = load_dinov2(self.image_size)
|
||||||
"vit_small_patch14_dinov2.lvd142m",
|
|
||||||
pretrained=True,
|
|
||||||
num_classes=0,
|
|
||||||
img_size=self.image_size,
|
|
||||||
dynamic_img_size=True,
|
|
||||||
)
|
|
||||||
model = model.eval().to(device)
|
model = model.eval().to(device)
|
||||||
self.device = device
|
self.device = device
|
||||||
self._model = model
|
self._model = model
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,13 @@ if os.path.exists(os.path.join(project_root, "stock")):
|
||||||
if current_dir not in sys.path:
|
if current_dir not in sys.path:
|
||||||
sys.path.insert(0, current_dir)
|
sys.path.insert(0, current_dir)
|
||||||
|
|
||||||
from gallery import GalleryEntry, ProductGallery # noqa: E402
|
from gallery import ( # noqa: E402
|
||||||
|
DINOV2_SHA256,
|
||||||
|
GalleryEntry,
|
||||||
|
ProductGallery,
|
||||||
|
dinov2_weights_path,
|
||||||
|
load_dinov2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def a_gallery(views, threshold=0.55):
|
def a_gallery(views, threshold=0.55):
|
||||||
|
|
@ -108,5 +114,35 @@ class EnrollmentTests(unittest.TestCase):
|
||||||
self.assertEqual(len(gallery._views_for("sku_ab")), 1)
|
self.assertEqual(len(gallery._views_for("sku_ab")), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineWeightsTests(unittest.TestCase):
|
||||||
|
def test_a_missing_weights_file_names_the_path_and_does_not_fetch(self):
|
||||||
|
previous = os.environ.get("DORAYAKI_DINOV2_WEIGHTS")
|
||||||
|
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = os.path.join(
|
||||||
|
"does-not-exist", "dinov2_vits14_lvd142m.safetensors"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(FileNotFoundError) as raised:
|
||||||
|
load_dinov2(224)
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop("DORAYAKI_DINOV2_WEIGHTS", None)
|
||||||
|
else:
|
||||||
|
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = previous
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertIn("does-not-exist", message)
|
||||||
|
self.assertIn(DINOV2_SHA256, message)
|
||||||
|
self.assertIn("does not download", message)
|
||||||
|
|
||||||
|
def test_the_default_path_stays_inside_the_repo(self):
|
||||||
|
previous = os.environ.pop("DORAYAKI_DINOV2_WEIGHTS", None)
|
||||||
|
try:
|
||||||
|
self.assertTrue(dinov2_weights_path().endswith(
|
||||||
|
os.path.join("weights", "dinov2_vits14_lvd142m.safetensors")
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
if previous is not None:
|
||||||
|
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = previous
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue