FabianGroeger commited on
Commit
4fc0ad6
·
0 Parent(s):

SkinMap: 12-teacher ensemble + predict_meta (validated release)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +8 -0
  2. README.md +97 -0
  3. config.json +13 -0
  4. configuration_skinmap.py +30 -0
  5. meta_predictor.py +102 -0
  6. modeling_skinmap.py +301 -0
  7. probes/label_encoder_body_region.pkl +0 -0
  8. probes/label_encoder_fitzpatrick.pkl +0 -0
  9. probes/label_encoder_fitzpatrick_modspecific.pkl +0 -0
  10. probes/label_encoder_gender.pkl +0 -0
  11. probes/label_encoder_laterality.pkl +0 -0
  12. probes/label_encoder_modality.pkl +0 -0
  13. probes/label_encoder_origin.pkl +0 -0
  14. probes/manifest.json +100 -0
  15. probes/model_age.pkl +0 -0
  16. probes/model_body_region.pkl +0 -0
  17. probes/model_fitzpatrick.pkl +0 -0
  18. probes/model_fitzpatrick__clinical.pkl +0 -0
  19. probes/model_fitzpatrick__derm.pkl +0 -0
  20. probes/model_gender.pkl +0 -0
  21. probes/model_laterality.pkl +0 -0
  22. probes/model_modality.pkl +0 -0
  23. probes/model_origin.pkl +3 -0
  24. requirements.txt +13 -0
  25. skinmap_runtime/__init__.py +0 -0
  26. skinmap_runtime/_compat.py +153 -0
  27. skinmap_runtime/combined_embedder.py +926 -0
  28. skinmap_runtime/core/__init__.py +0 -0
  29. skinmap_runtime/core/models/__init__.py +0 -0
  30. skinmap_runtime/core/models/byol/__init__.py +0 -0
  31. skinmap_runtime/core/models/byol/model.py +42 -0
  32. skinmap_runtime/core/models/byol/predictor.py +15 -0
  33. skinmap_runtime/core/models/colorme/__init__.py +0 -0
  34. skinmap_runtime/core/models/colorme/model.py +49 -0
  35. skinmap_runtime/core/models/dino/__init__.py +0 -0
  36. skinmap_runtime/core/models/dino/head.py +52 -0
  37. skinmap_runtime/core/models/dino/multi_crop_wrapper.py +70 -0
  38. skinmap_runtime/core/models/encoders/__init__.py +1 -0
  39. skinmap_runtime/core/models/encoders/swin_transformer.py +1050 -0
  40. skinmap_runtime/core/models/encoders/utils.py +34 -0
  41. skinmap_runtime/core/models/encoders/vision_transformer.py +374 -0
  42. skinmap_runtime/core/models/fine_tuning/__init__.py +1 -0
  43. skinmap_runtime/core/models/fine_tuning/classifiers.py +69 -0
  44. skinmap_runtime/core/models/ibot/__init__.py +0 -0
  45. skinmap_runtime/core/models/ibot/head.py +80 -0
  46. skinmap_runtime/core/models/mae/__init__.py +0 -0
  47. skinmap_runtime/core/models/mae/model.py +297 -0
  48. skinmap_runtime/core/models/mae/utils.py +58 -0
  49. skinmap_runtime/core/models/simclr/__init__.py +1 -0
  50. skinmap_runtime/core/models/simclr/model.py +33 -0
.gitattributes ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
2
+ *.bin filter=lfs diff=lfs merge=lfs -text
3
+ *.pth filter=lfs diff=lfs merge=lfs -text
4
+ *.npz filter=lfs diff=lfs merge=lfs -text
5
+ *.npy filter=lfs diff=lfs merge=lfs -text
6
+ *.faiss filter=lfs diff=lfs merge=lfs -text
7
+ *.bin.index filter=lfs diff=lfs merge=lfs -text
8
+ analysis/**/*.bin filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ library_name: transformers
4
+ tags:
5
+ - dermatology
6
+ - medical-imaging
7
+ - embeddings
8
+ - image-feature-extraction
9
+ - clip
10
+ pipeline_tag: image-feature-extraction
11
+ ---
12
+
13
+ # A Global Atlas of Digital Dermatology to Map Innovation and Disparities
14
+
15
+ ![SkinMap method](https://fabiangroeger96.github.io/assets/img/projects/skinmap/Method.svg)
16
+
17
+ SkinMap is a dermatology image/text embedding model. It fuses **12 frozen teacher
18
+ encoders** (multi-modal and self-supervised models) through a trained projector
19
+ into a single **1024-d shared image/text space**, that can be used to infer
20
+ missing metadata, solve downstream tasks, and get relational information about the
21
+ samples.
22
+
23
+ **Compute.** This is the faithful full ensemble: 12 backbones load into memory at
24
+ once and need roughly **16 GB of GPU VRAM**. It runs on CPU but slowly.
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from transformers import AutoModel
30
+
31
+ model = AutoModel.from_pretrained(
32
+ "Digital-Dermatology/SkinMap",
33
+ trust_remote_code=True,
34
+ device="cuda", # "cpu" also works
35
+ )
36
+
37
+ # 1) Encode an image into the 1024-d SkinMap space (L2-normalized)
38
+ img_emb = model.encode_image("lesion.jpg") # np.ndarray, shape (1024,)
39
+
40
+ # 2) Encode a text query into the same space
41
+ txt_emb = model.encode_text("melanoma on the back") # np.ndarray, shape (1024,)
42
+
43
+ # 3) Predict clinical metadata from the image
44
+ meta = model.predict_meta("lesion.jpg")
45
+ # {'fitzpatrick': 3, 'fitzpatrick_grouped': '3-4', 'age': 42.1,
46
+ # 'gender': 'female', 'origin': ..., 'body_region': ..., ...}
47
+
48
+ import numpy as np
49
+ sim = float(np.dot(img_emb, txt_emb)) # cosine in the shared space
50
+ ```
51
+
52
+ `encode_image` accepts a file path, raw bytes, or a `PIL.Image`. `encode_text`
53
+ expands the query into clinical prompt templates and averages the results; pass
54
+ `templates=["{}"]` to encode the raw text as-is.
55
+
56
+ ### Metadata prediction
57
+
58
+ `predict_meta` applies bundled linear probes to the SkinMap embedding to estimate
59
+ Fitzpatrick skin type, age, sex, geographic origin, body region and more. By
60
+ default it uses **global** probes, which reproduce the demographic estimates
61
+ reported in the paper. If you know the imaging modality, pass it to switch the
62
+ Fitzpatrick prediction to a **modality-specific** probe (more accurate when the
63
+ modality is known); the other attributes stay global:
64
+
65
+ ```python
66
+ meta = model.predict_meta("lesion.jpg", modality="dermoscopy") # clinical | dermoscopy | TBP
67
+ ```
68
+
69
+ `predict_meta` also accepts a precomputed 1024-d vector instead of an image, and a
70
+ subset via `attributes=[...]`.
71
+
72
+ ### Nearest-neighbor search (optional)
73
+
74
+ If this repo was built with an atlas index bundled, you can retrieve neighbors
75
+ directly:
76
+
77
+ ```python
78
+ indices, distances = model.search("lesion.jpg", k=10)
79
+ ```
80
+
81
+ Without a bundled index, `search()` raises a clear error. Encoding still works.
82
+
83
+ ## What's inside
84
+
85
+ | Component | Role |
86
+ |---|---|
87
+ | 9 multi-modal teachers (CLIP & SigLIP; ViT-L/14 & ViT-B/32) | image + text features |
88
+ | 3 self-supervised teachers (DINO, iBOT, MAE; ViT-B/16) | dermatology image features |
89
+ | Per-teacher whitening (`whitening_stats.npz`) | decorrelate before fusion |
90
+ | Trained projector (`projector_model.pth`) | fuse to 1024-d image/text heads |
91
+ | Metadata probes (`probes/`) | linear probes for `predict_meta` |
92
+
93
+ ## Licensing
94
+
95
+ SkinMap is released under the Creative Commons Attribution-NonCommercial 4.0
96
+ International license, for research use only. **Not a medical device. Not for
97
+ clinical decision-making.**
config.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "skinmap",
3
+ "architectures": [
4
+ "SkinMapModel"
5
+ ],
6
+ "auto_map": {
7
+ "AutoConfig": "configuration_skinmap.SkinMapConfig",
8
+ "AutoModel": "modeling_skinmap.SkinMapModel"
9
+ },
10
+ "embedding_dim": 1024,
11
+ "pipeline_config": "weights/embedding_pipeline_config.json",
12
+ "probes_dir": "probes"
13
+ }
configuration_skinmap.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace config for the packaged SkinMap multi-teacher embedding model."""
2
+
3
+ from transformers import PretrainedConfig
4
+
5
+
6
+ class SkinMapConfig(PretrainedConfig):
7
+ """Minimal config for SkinMap.
8
+
9
+ SkinMap is not a single set of HF weights but an ensemble of teacher encoders
10
+ plus a trained projector, whose loading is driven by a bundled pipeline-config
11
+ JSON. This config only points at that JSON and records the output
12
+ dimensionality. The heavy lifting happens in `SkinMapModel`.
13
+ """
14
+
15
+ model_type = "skinmap"
16
+
17
+ def __init__(
18
+ self,
19
+ embedding_dim: int = 1024,
20
+ pipeline_config: str = "weights/embedding_pipeline_config.json",
21
+ probes_dir: str = "probes",
22
+ **kwargs,
23
+ ):
24
+ self.embedding_dim = embedding_dim
25
+ # Paths are relative to the repo snapshot root.
26
+ self.pipeline_config = pipeline_config
27
+ # Directory of bundled metadata probes (enables predict_meta); may be
28
+ # absent in an embedding-only package.
29
+ self.probes_dir = probes_dir
30
+ super().__init__(**kwargs)
meta_predictor.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metadata prediction from SkinMap embeddings via bundled linear probes.
2
+
3
+ Default uses the global probes, which reproduce the demographic estimates reported
4
+ in the paper (e.g. Fitzpatrick V--VI ~ 11%). Passing ``modality`` switches the
5
+ Fitzpatrick prediction to a modality-specific probe (clinical vs. dermoscopy/TBP),
6
+ which is more accurate when the imaging modality is known.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import pickle
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Union
14
+
15
+ import numpy as np
16
+
17
+ # imaging modality -> probe group used for the modality-specific Fitzpatrick model
18
+ _MODALITY_GROUP = {"clinical": "clinical", "dermoscopy": "derm", "tbp": "derm"}
19
+ _FST_GROUP = {1.0: "1-2", 2.0: "1-2", 3.0: "3-4", 4.0: "3-4", 5.0: "5-6", 6.0: "5-6"}
20
+
21
+
22
+ class MetaPredictor:
23
+ """Loads a bundled probe directory and predicts metadata from embeddings."""
24
+
25
+ def __init__(self, probes_dir: Union[str, Path]):
26
+ self.dir = Path(probes_dir)
27
+ manifest = self.dir / "manifest.json"
28
+ if not manifest.exists():
29
+ raise FileNotFoundError(f"probe manifest not found at {manifest}")
30
+ self.manifest = json.loads(manifest.read_text())
31
+ self._cache: Dict[str, object] = {}
32
+
33
+ def _load(self, name: str):
34
+ if name not in self._cache:
35
+ with open(self.dir / f"{name}.pkl", "rb") as f:
36
+ self._cache[name] = pickle.load(f)
37
+ return self._cache[name]
38
+
39
+ @property
40
+ def attributes(self) -> List[str]:
41
+ return list(self.manifest["attributes"].keys())
42
+
43
+ def predict(
44
+ self,
45
+ embedding: np.ndarray,
46
+ modality: Optional[str] = None,
47
+ attributes: Optional[List[str]] = None,
48
+ ) -> Dict[str, object]:
49
+ """Predict metadata for one embedding (1024,) or a batch (N, 1024).
50
+
51
+ Args:
52
+ embedding: L2-normalized SkinMap embedding(s).
53
+ modality: optional {'clinical','dermoscopy','TBP'}; switches Fitzpatrick
54
+ to the modality-specific probe. Other attributes stay global.
55
+ attributes: subset to predict (default: all available).
56
+ Returns:
57
+ dict attribute -> scalar (single input) or np.ndarray (batch).
58
+ """
59
+ emb = np.asarray(embedding, dtype=np.float32)
60
+ single = emb.ndim == 1
61
+ if single:
62
+ emb = emb[None, :]
63
+ grp = _MODALITY_GROUP.get(str(modality).lower()) if modality else None
64
+ attrs = attributes or self.attributes
65
+ out: Dict[str, object] = {}
66
+ for a in attrs:
67
+ spec = self.manifest["attributes"].get(a)
68
+ if spec is None:
69
+ continue
70
+ if spec["task"] == "regression":
71
+ pred = self._load(f"model_{a}").predict(emb).astype(float)
72
+ clip = spec.get("clip")
73
+ if clip:
74
+ pred = np.clip(pred, clip[0], clip[1])
75
+ vals = pred
76
+ else:
77
+ if a == "fitzpatrick" and grp and spec.get("modality_specific"):
78
+ clf = self._load(f"model_fitzpatrick__{grp}")
79
+ le = self._load("label_encoder_fitzpatrick_modspecific")
80
+ else:
81
+ clf = self._load(f"model_{a}")
82
+ le = self._load(f"label_encoder_{a}")
83
+ vals = le.inverse_transform(clf.predict(emb))
84
+ try:
85
+ vals = vals.astype(float)
86
+ except (ValueError, TypeError):
87
+ pass
88
+ out[a] = vals
89
+
90
+ # derived: grouped Fitzpatrick (I-II / III-IV / V-VI)
91
+ if "fitzpatrick" in out:
92
+ fst = np.asarray(out["fitzpatrick"], dtype=float)
93
+ out["fitzpatrick_grouped"] = np.array(
94
+ [_FST_GROUP.get(round(v), "unknown") for v in np.atleast_1d(fst)]
95
+ )
96
+
97
+ if single:
98
+ out = {
99
+ k: (v[0].item() if hasattr(v[0], "item") else v[0])
100
+ for k, v in out.items()
101
+ }
102
+ return out
modeling_skinmap.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HuggingFace wrapper for the packaged SkinMap multi-teacher embedding model.
2
+
3
+ SkinMap fuses ~12 frozen teacher encoders (CLIP fine-tunes plus dermatology SSL
4
+ backbones) through a trained projector into a single 1024-d image/text space.
5
+ The public API is ``encode_image``, ``encode_text`` and ``search``.
6
+
7
+ SkinMap is not a single state-dict, so the standard PreTrainedModel weight
8
+ loading is not used. ``from_pretrained`` resolves the repo snapshot, puts the
9
+ bundled ``skinmap_runtime`` package on ``sys.path``, and builds the underlying
10
+ ``CombinedEmbeddingPipeline`` from the bundled pipeline-config JSON. Top-level
11
+ imports stay light (stdlib, torch, transformers, hub) because the snapshot
12
+ location, and therefore ``skinmap_runtime``, is only known at call time.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import sys
19
+ import warnings
20
+ from pathlib import Path
21
+ from typing import List, Optional, Tuple, Union
22
+
23
+ import numpy as np
24
+ import torch
25
+ from transformers import PreTrainedModel
26
+
27
+ from .configuration_skinmap import SkinMapConfig
28
+ from .meta_predictor import MetaPredictor
29
+
30
+ ImageInput = Union[str, "Path", bytes, "object"] # path | bytes | PIL.Image.Image
31
+
32
+
33
+ class SkinMapModel(PreTrainedModel):
34
+ config_class = SkinMapConfig
35
+ # SkinMap manages its own weights from the bundled files. There is no HF
36
+ # state-dict to load or split for device_map / low-cpu-mem paths.
37
+ _supports_device_map = False
38
+
39
+ def __init__(self, config: SkinMapConfig):
40
+ super().__init__(config)
41
+ # Keep a trivial buffer so .device / .to() behave on a Module with no
42
+ # registered parameters of its own.
43
+ self.register_buffer("_anchor", torch.zeros(1), persistent=False)
44
+ self._pipeline = None
45
+ self._predictor = None
46
+ self._skinmap_device = torch.device("cpu")
47
+
48
+ # ------------------------------------------------------------------ load
49
+ @classmethod
50
+ def from_pretrained(
51
+ cls,
52
+ pretrained_model_name_or_path,
53
+ *model_args,
54
+ device: Optional[str] = None,
55
+ **kwargs,
56
+ ):
57
+ """Resolve the repo snapshot and build the SkinMap pipeline.
58
+
59
+ Accepts a local directory or a (private) hub repo id. Hub/auth kwargs
60
+ (`token`, `revision`, `cache_dir`) are forwarded to `snapshot_download`.
61
+ Standard weight-loading kwargs that SkinMap cannot honor (`torch_dtype`,
62
+ `device_map`, `low_cpu_mem_usage`) are warned about rather than silently
63
+ ignored. Use `device=...` to choose the device.
64
+ """
65
+ token = kwargs.pop("token", kwargs.pop("use_auth_token", None))
66
+ revision = kwargs.pop("revision", None)
67
+ cache_dir = kwargs.pop("cache_dir", None)
68
+ config = kwargs.pop("config", None)
69
+
70
+ # SkinMap loads its own weights from the bundled files, so the standard
71
+ # dtype/placement/low-mem machinery does not apply. Warn instead of
72
+ # silently dropping these. A user passing torch_dtype=fp16 to fit the
73
+ # ensemble in limited VRAM would otherwise get fp32 and OOM with no signal.
74
+ for unsupported in ("torch_dtype", "device_map", "low_cpu_mem_usage"):
75
+ if kwargs.pop(unsupported, None) is not None:
76
+ warnings.warn(
77
+ f"SkinMapModel.from_pretrained ignores `{unsupported}`: the "
78
+ "ensemble runs in fp32 on a single device. Pass `device=...` "
79
+ "to choose the device. fp16/sharded loading is unsupported.",
80
+ stacklevel=2,
81
+ )
82
+
83
+ path = Path(pretrained_model_name_or_path)
84
+ if path.is_dir():
85
+ snapshot = path
86
+ else:
87
+ from huggingface_hub import snapshot_download
88
+
89
+ snapshot = Path(
90
+ snapshot_download(
91
+ repo_id=str(pretrained_model_name_or_path),
92
+ revision=revision,
93
+ cache_dir=cache_dir,
94
+ token=token,
95
+ )
96
+ )
97
+
98
+ if config is None:
99
+ config = SkinMapConfig.from_pretrained(snapshot)
100
+
101
+ model = cls(config)
102
+ model._load_pipeline(snapshot, device=device)
103
+ return model
104
+
105
+ def _load_pipeline(self, snapshot, device: Optional[str] = None):
106
+ snapshot = Path(snapshot).resolve()
107
+ if str(snapshot) not in sys.path:
108
+ sys.path.insert(0, str(snapshot))
109
+
110
+ # `skinmap_runtime` is a fixed top-level package name resolved off sys.path,
111
+ # so only one snapshot's runtime can be live per process. If a different
112
+ # snapshot already imported it, that one is reused, so warn rather than
113
+ # silently run model B on model A's code.
114
+ already = sys.modules.get("skinmap_runtime")
115
+ if already is not None and getattr(already, "__path__", None):
116
+ loaded_from = Path(list(already.__path__)[0]).resolve().parent
117
+ if loaded_from != snapshot:
118
+ warnings.warn(
119
+ "A different SkinMap snapshot's runtime is already loaded in "
120
+ f"this process (from {loaded_from}). Reusing it. Loading two "
121
+ "different SkinMap revisions in one process is unsupported.",
122
+ stacklevel=2,
123
+ )
124
+
125
+ # Import the bundled runtime via importlib (NOT `from skinmap_runtime ...`):
126
+ # transformers' check_imports statically scans this file for import
127
+ # statements and would otherwise treat `skinmap_runtime` as a missing
128
+ # PyPI dependency, breaking from_pretrained for every user.
129
+ import importlib
130
+
131
+ CombinedEmbeddingPipeline = importlib.import_module(
132
+ "skinmap_runtime.combined_embedder"
133
+ ).CombinedEmbeddingPipeline
134
+
135
+ cfg_path = snapshot / self.config.pipeline_config
136
+ if not cfg_path.exists():
137
+ raise FileNotFoundError(
138
+ f"SkinMap pipeline config not found at {cfg_path}. "
139
+ "The repo snapshot may be incomplete."
140
+ )
141
+
142
+ resolved = device or ("cuda" if torch.cuda.is_available() else "cpu")
143
+ self._pipeline = CombinedEmbeddingPipeline.from_config(
144
+ cfg_path, device=resolved
145
+ )
146
+ self._skinmap_device = torch.device(resolved)
147
+ # Keep the module's own buffer on the resolved device so standard
148
+ # introspection (next(model.buffers()).device) agrees with .device.
149
+ self._anchor = self._anchor.to(self._skinmap_device)
150
+ # Bundled metadata probes (optional): enables predict_meta(). Absent in
151
+ # embedding-only snapshots, so load lazily and stay quiet if not present.
152
+ probes_path = snapshot / getattr(self.config, "probes_dir", "probes")
153
+ if (probes_path / "manifest.json").exists():
154
+ self._predictor = MetaPredictor(probes_path)
155
+ return self
156
+
157
+ def _require_pipeline(self):
158
+ if self._pipeline is None:
159
+ raise RuntimeError(
160
+ "SkinMap pipeline is not loaded. Use "
161
+ "SkinMapModel.from_pretrained(...) rather than constructing the "
162
+ "model directly."
163
+ )
164
+ return self._pipeline
165
+
166
+ # ------------------------------------------------------------- device
167
+ @property
168
+ def device(self) -> torch.device: # type: ignore[override]
169
+ return self._skinmap_device
170
+
171
+ def to(self, *args, **kwargs): # type: ignore[override]
172
+ """Move every teacher + the projector onto the requested device."""
173
+ device = None
174
+ if args and (isinstance(args[0], (str, torch.device)) or args[0] is None):
175
+ device = args[0]
176
+ device = kwargs.get("device", device)
177
+ if device is None:
178
+ return self
179
+ device = torch.device(device)
180
+ super().to(device)
181
+ self._skinmap_device = device
182
+
183
+ pipe = self._pipeline
184
+ if pipe is not None:
185
+ pipe.device = device
186
+ for wrapper in getattr(pipe, "clip_models", []):
187
+ wrapper.model.to(device)
188
+ for wrapper in getattr(pipe, "ssl_models", []):
189
+ wrapper.model.to(device)
190
+ if getattr(pipe, "projector_model", None) is not None:
191
+ pipe.projector_model.to(device)
192
+ return self
193
+
194
+ def cuda(self, device=None): # type: ignore[override]
195
+ return self.to(f"cuda:{device}" if isinstance(device, int) else "cuda")
196
+
197
+ def cpu(self): # type: ignore[override]
198
+ return self.to("cpu")
199
+
200
+ # ------------------------------------------------------------- encode
201
+ @staticmethod
202
+ def _as_pil(image: ImageInput):
203
+ from PIL import Image
204
+
205
+ if isinstance(image, Image.Image):
206
+ return image.convert("RGB")
207
+ if isinstance(image, (bytes, bytearray)):
208
+ return Image.open(io.BytesIO(image)).convert("RGB")
209
+ if isinstance(image, (str, Path)):
210
+ return Image.open(image).convert("RGB")
211
+ raise TypeError(
212
+ f"Unsupported image input type: {type(image).__name__}. "
213
+ "Pass a file path, raw bytes, or a PIL.Image."
214
+ )
215
+
216
+ @torch.inference_mode()
217
+ def encode_image(self, image: ImageInput) -> np.ndarray:
218
+ """Encode one image into the 1024-d SkinMap space (L2-normalized)."""
219
+ pipe = self._require_pipeline()
220
+ return pipe.embed_image(self._as_pil(image))
221
+
222
+ @torch.inference_mode()
223
+ def encode_text(
224
+ self, text: str, templates: Optional[List[str]] = None
225
+ ) -> np.ndarray:
226
+ """Encode a text query into the shared 1024-d space.
227
+
228
+ By default the query is expanded into clinical prompt templates and the
229
+ results averaged. Pass ``templates=["{}"]`` to encode the raw text.
230
+ """
231
+ pipe = self._require_pipeline()
232
+ return pipe.encode_text(text, templates=templates)
233
+
234
+ # ------------------------------------------------------------- predict_meta
235
+ def _require_predictor(self):
236
+ if self._predictor is None:
237
+ raise RuntimeError(
238
+ "No metadata probes are bundled with this SkinMap repo, so "
239
+ "predict_meta() is unavailable. Rebuild the package with the "
240
+ "probes/ directory to enable metadata prediction."
241
+ )
242
+ return self._predictor
243
+
244
+ @torch.inference_mode()
245
+ def predict_meta(
246
+ self,
247
+ image: Union[ImageInput, np.ndarray],
248
+ modality: Optional[str] = None,
249
+ attributes: Optional[List[str]] = None,
250
+ ) -> dict:
251
+ """Predict metadata from an image or a precomputed 1024-d SkinMap vector.
252
+
253
+ Returns a dict of attributes (Fitzpatrick skin type, age, sex, geographic
254
+ origin, body region, ...). By default the global probes are used, which
255
+ reproduce the demographic estimates reported in the paper. Pass
256
+ ``modality`` (``"clinical"``, ``"dermoscopy"`` or ``"TBP"``) to use the
257
+ modality-specific Fitzpatrick probe, which is more accurate when the
258
+ imaging modality is known; the other attributes stay global.
259
+ """
260
+ predictor = self._require_predictor()
261
+ emb = image if isinstance(image, np.ndarray) else self.encode_image(image)
262
+ return predictor.predict(emb, modality=modality, attributes=attributes)
263
+
264
+ def forward(self, image: Optional[ImageInput] = None, **kwargs) -> torch.Tensor:
265
+ """Convenience: ``forward(image)`` returns ``encode_image(image)`` as a tensor.
266
+
267
+ SkinMap is an encoder, not a trainable head, so prefer the explicit
268
+ ``encode_image`` / ``encode_text``. HF-style tensor calls such as
269
+ ``model(pixel_values=...)`` raise a clear error instead of crashing
270
+ cryptically.
271
+ """
272
+ if image is None:
273
+ raise TypeError(
274
+ "SkinMapModel is an encoder: call model.encode_image(img) or "
275
+ "model.encode_text(txt). forward() accepts a single image "
276
+ f"(path/bytes/PIL), got keyword args {sorted(kwargs)} instead."
277
+ )
278
+ return torch.from_numpy(np.ascontiguousarray(self.encode_image(image)))
279
+
280
+ # ------------------------------------------------------------- search
281
+ def search(
282
+ self, query: ImageInput, k: int = 10
283
+ ) -> Tuple[List[int], List[float]]:
284
+ """Nearest-neighbor search against the bundled atlas index.
285
+
286
+ `query` may be an image (path/bytes/PIL) or a precomputed 1024-d vector.
287
+ Raises a clear error if no atlas/index was bundled with this repo.
288
+ """
289
+ pipe = self._require_pipeline()
290
+ if isinstance(query, np.ndarray):
291
+ vector = query
292
+ else:
293
+ vector = self.encode_image(query)
294
+ try:
295
+ return pipe.find_nearest_neighbors(np.asarray(vector), k=k)
296
+ except RuntimeError as exc:
297
+ raise RuntimeError(
298
+ "Nearest-neighbor search needs a bundled atlas index, which is "
299
+ "not present in this repo. Rebuild the package with --with-atlas "
300
+ "to enable search()."
301
+ ) from exc
probes/label_encoder_body_region.pkl ADDED
Binary file (359 Bytes). View file
 
probes/label_encoder_fitzpatrick.pkl ADDED
Binary file (289 Bytes). View file
 
probes/label_encoder_fitzpatrick_modspecific.pkl ADDED
Binary file (278 Bytes). View file
 
probes/label_encoder_gender.pkl ADDED
Binary file (258 Bytes). View file
 
probes/label_encoder_laterality.pkl ADDED
Binary file (257 Bytes). View file
 
probes/label_encoder_modality.pkl ADDED
Binary file (272 Bytes). View file
 
probes/label_encoder_origin.pkl ADDED
Binary file (439 Bytes). View file
 
probes/manifest.json ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "attributes": {
3
+ "fitzpatrick": {
4
+ "task": "classification",
5
+ "n_train": 33082,
6
+ "classes": [
7
+ "1.0",
8
+ "2.0",
9
+ "3.0",
10
+ "4.0",
11
+ "5.0",
12
+ "6.0"
13
+ ],
14
+ "modality_specific": true,
15
+ "modality_groups": {
16
+ "clinical": "clinical",
17
+ "dermoscopy": "derm",
18
+ "TBP": "derm"
19
+ }
20
+ },
21
+ "gender": {
22
+ "task": "classification",
23
+ "n_train": 523878,
24
+ "classes": [
25
+ "female",
26
+ "male"
27
+ ],
28
+ "modality_specific": false
29
+ },
30
+ "origin": {
31
+ "task": "classification",
32
+ "n_train": 518420,
33
+ "classes": [
34
+ "Argentina",
35
+ "Australia",
36
+ "Austria",
37
+ "Brazil",
38
+ "Germany",
39
+ "Greece",
40
+ "Guinea",
41
+ "India",
42
+ "Italy",
43
+ "Madagascar",
44
+ "Malawi",
45
+ "Netherlands",
46
+ "New Zealand",
47
+ "Portugal",
48
+ "Spain",
49
+ "Switzerland",
50
+ "Tanzania",
51
+ "United States"
52
+ ],
53
+ "modality_specific": false
54
+ },
55
+ "body_region": {
56
+ "task": "classification",
57
+ "n_train": 613385,
58
+ "classes": [
59
+ "acral",
60
+ "gluteal_buttocks",
61
+ "groin_genital_perineum",
62
+ "head_neck",
63
+ "lower_limb",
64
+ "trunk_back",
65
+ "trunk_front",
66
+ "upper_limb"
67
+ ],
68
+ "modality_specific": false
69
+ },
70
+ "laterality": {
71
+ "task": "classification",
72
+ "n_train": 173585,
73
+ "classes": [
74
+ "left",
75
+ "right"
76
+ ],
77
+ "modality_specific": false
78
+ },
79
+ "modality": {
80
+ "task": "classification",
81
+ "n_train": 596759,
82
+ "classes": [
83
+ "TBP",
84
+ "clinical",
85
+ "dermoscopy"
86
+ ],
87
+ "modality_specific": false
88
+ },
89
+ "age": {
90
+ "task": "regression",
91
+ "n_train": 534789,
92
+ "clip": [
93
+ 0,
94
+ 100
95
+ ],
96
+ "modality_specific": false
97
+ }
98
+ },
99
+ "note": "Global probes are the original frozen Jun-17 (paper) prediction models that produced the atlas *_pred imputations; modality-specific Fitzpatrick probes are fit on the frozen atlas. Input = 1024-d L2-normalized SkinMap embedding."
100
+ }
probes/model_age.pkl ADDED
Binary file (4.52 kB). View file
 
probes/model_body_region.pkl ADDED
Binary file (66.4 kB). View file
 
probes/model_fitzpatrick.pkl ADDED
Binary file (49.9 kB). View file
 
probes/model_fitzpatrick__clinical.pkl ADDED
Binary file (50 kB). View file
 
probes/model_fitzpatrick__derm.pkl ADDED
Binary file (33.5 kB). View file
 
probes/model_gender.pkl ADDED
Binary file (8.91 kB). View file
 
probes/model_laterality.pkl ADDED
Binary file (8.91 kB). View file
 
probes/model_modality.pkl ADDED
Binary file (25.3 kB). View file
 
probes/model_origin.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6402dd67061dc178ee158a64842e426a57f62f95f201e3d1142488dabe112e7d
3
+ size 148444
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Inference dependencies for the packaged SkinMap model.
2
+ # (Training-only deps — wandb, umap, matplotlib, plotly, glasbey — are intentionally absent.)
3
+ torch>=2.0
4
+ torchvision>=0.15
5
+ transformers>=4.40
6
+ huggingface_hub>=0.23
7
+ numpy>=1.24
8
+ scipy>=1.10
9
+ tqdm>=4.65
10
+ loguru>=0.7
11
+ faiss-cpu>=1.7
12
+ joblib>=1.3
13
+ Pillow>=9.0
skinmap_runtime/__init__.py ADDED
File without changes
skinmap_runtime/_compat.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained inference-only shims for the packaged SkinMap model.
2
+
3
+ The runtime `combined_embedder.py` normally pulls three helpers out of the
4
+ training code base (`src.create_skinmap`, `src.train_clip`). Those modules carry
5
+ heavy, training-only top-level imports (wandb, umap, matplotlib, plotly, DDP,
6
+ torch.compile) that have no place in a portable inference package. This module
7
+ re-implements exactly the three symbols the runtime needs, with nothing else.
8
+
9
+ Keep this file dependency-light: stdlib + torch + transformers only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from loguru import logger
17
+ from torchvision import transforms
18
+ from torchvision.transforms import InterpolationMode
19
+
20
+ # Mirrors src/skinmap/models/loaders.py: names the SSL teacher checkpoints are
21
+ # known by. Only the first three are used by the shipped SkinMap ensemble.
22
+ SSL_MODEL_NAMES = [
23
+ "dino_qderma",
24
+ "ibot_qderma",
25
+ "mae_qderma",
26
+ "simclr_qderma",
27
+ "byol_qderma",
28
+ "colorme_qderma",
29
+ "panderm_base",
30
+ "panderm_large",
31
+ ]
32
+
33
+
34
+ def get_imagenet_transform():
35
+ """Standard ImageNet preprocessing for the SSL teachers.
36
+
37
+ Verbatim copy of src/skinmap/data/transforms.py:get_imagenet_transform so the
38
+ packaged pipeline preprocesses SSL inputs identically to training.
39
+ """
40
+ return transforms.Compose(
41
+ [
42
+ transforms.Resize(256, interpolation=InterpolationMode.BICUBIC),
43
+ transforms.CenterCrop(224),
44
+ transforms.ToTensor(),
45
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
46
+ ]
47
+ )
48
+
49
+
50
+ def _processor_name_fallback(model_name: str) -> str:
51
+ """Infer the upstream processor repo for a local CLIP checkpoint name.
52
+
53
+ Only a fallback: the shipped CLIP checkpoints are self-contained HF dirs, so
54
+ the processor loads locally first. Match on the architecture substring so it
55
+ stays robust to clean teacher directory names (e.g. ``siglip_vit-base-patch32``).
56
+ """
57
+ if "monet" in model_name:
58
+ return "suinleelab/monet"
59
+ if "vit-base-patch32" in model_name:
60
+ return "openai/clip-vit-base-patch32"
61
+ if "vit-large-patch14" in model_name:
62
+ return "openai/clip-vit-large-patch14"
63
+ return model_name
64
+
65
+
66
+ def load_model_and_processor(model_name, device, *args, **kwargs):
67
+ """Load a CLIP teacher (model + processor) for inference.
68
+
69
+ A trimmed equivalent of src/train_clip.py:load_model_and_processor: no
70
+ DDP wrapping, no torch.compile, no random-init / siglip-bias training paths.
71
+ The shipped CLIP checkpoints are self-contained HF directories (config +
72
+ safetensors + tokenizer + preprocessor), so we load them locally first and
73
+ only reach out to the hub if a processor artifact is somehow missing.
74
+ """
75
+ from transformers import (
76
+ CLIPImageProcessor,
77
+ CLIPModel,
78
+ CLIPProcessor,
79
+ CLIPTokenizer,
80
+ )
81
+
82
+ is_local = Path(model_name).exists()
83
+
84
+ # Candidate processor sources, local checkpoint dir first.
85
+ candidates = [model_name]
86
+ fallback = _processor_name_fallback(model_name)
87
+ if fallback != model_name:
88
+ candidates.append(fallback)
89
+
90
+ def _load_processor(candidate: str, local_only: bool):
91
+ try:
92
+ return CLIPProcessor.from_pretrained(
93
+ candidate, local_files_only=local_only
94
+ )
95
+ except Exception as base_exc:
96
+ # Some checkpoints ship tokenizer + image processor separately.
97
+ try:
98
+ tok = CLIPTokenizer.from_pretrained(
99
+ candidate, local_files_only=local_only
100
+ )
101
+ img = CLIPImageProcessor.from_pretrained(
102
+ candidate, local_files_only=local_only
103
+ )
104
+ return CLIPProcessor(tokenizer=tok, image_processor=img)
105
+ except Exception:
106
+ raise base_exc
107
+
108
+ processor = None
109
+ errors = []
110
+ for candidate in candidates:
111
+ local_only = Path(candidate).exists()
112
+ try:
113
+ processor = _load_processor(candidate, local_only=local_only)
114
+ break
115
+ except Exception as exc: # pragma: no cover - runtime safeguard
116
+ errors.append(exc)
117
+ logger.warning(f"Could not load CLIP processor from {candidate}: {exc}")
118
+ if processor is None:
119
+ raise RuntimeError(
120
+ f"Failed to load a CLIP processor for {model_name}: {errors[-1]}"
121
+ ) from errors[-1]
122
+
123
+ model = CLIPModel.from_pretrained(model_name, local_files_only=is_local)
124
+ model.to(device)
125
+ model.eval()
126
+ return model, processor
127
+
128
+
129
+ def load_ssl_local(ckpt_path, n_head_layers: int = 0):
130
+ """Load an SSL teacher from a bundled local checkpoint.
131
+
132
+ `Embedder.load_pretrained` always downloads from the upstream vm02 URL, so we
133
+ bypass it: the per-family loader functions (`load_dino`/`load_ibot`/
134
+ `load_mae`) accept a local `ckp_path` and read their architecture config out
135
+ of the checkpoint itself. The teacher family (dino/ibot/mae) is inferred from
136
+ the file stem, robustly to clean names (``mae_vit-base-patch16.pth`` as well as
137
+ ``mae_qderma.pth``); the family key maps to the same loader either way.
138
+ """
139
+ from skinmap_runtime.core.pkg.embedder import Embedder
140
+
141
+ stem = Path(ckpt_path).stem
142
+ parts = set(stem.split("-")) | set(stem.split("_")) | {stem}
143
+ family = next((f for f in ("dino", "ibot", "mae") if f in parts), None)
144
+ # Map the family to its dermatology-pretrained registry key
145
+ # (dino_qderma/ibot_qderma/mae_qderma); all three exist and select
146
+ # load_dino/load_ibot/load_mae, which read the architecture from the checkpoint.
147
+ key = f"{family}_qderma" if family else stem
148
+ loader_func = Embedder.get_model_func(key)
149
+ return loader_func(
150
+ ckp_path=str(ckpt_path),
151
+ return_info=True,
152
+ n_head_layers=n_head_layers,
153
+ )
skinmap_runtime/combined_embedder.py ADDED
@@ -0,0 +1,926 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import logging
4
+ import os
5
+ import sys
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Iterable, List, Optional, Sequence, Tuple
10
+
11
+ import faiss # type: ignore
12
+ import numpy as np
13
+ import torch
14
+ from PIL import Image
15
+ from torch import nn
16
+
17
+ from skinmap_runtime.core.pkg.embedder import Embedder
18
+ from skinmap_runtime._compat import SSL_MODEL_NAMES, get_imagenet_transform
19
+ from skinmap_runtime._compat import load_model_and_processor
20
+
21
+ try:
22
+ import joblib
23
+ except ImportError: # pragma: no cover - fallback for older sklearn versions
24
+ from sklearn.externals import joblib # type: ignore
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _coerce_clip_features(feats, embeds_attr: str):
31
+ """Return the projected feature tensor from a CLIP ``get_*_features`` call.
32
+
33
+ Some transformers versions return a ``BaseModelOutputWithPooling`` rather
34
+ than a tensor. In that case the projected embedding lives in the ``*_embeds``
35
+ field, or in ``pooler_output`` (which holds the projected vector for the
36
+ shipped CLIP checkpoints). ``last_hidden_state`` is not used as a fallback:
37
+ its CLS token has the encoder hidden dim, not the projection dim, so it would
38
+ silently corrupt the embedding. Shared by the image and text paths.
39
+ """
40
+ if isinstance(feats, torch.Tensor):
41
+ return feats
42
+ for attr in (embeds_attr, "pooler_output"):
43
+ val = getattr(feats, attr, None)
44
+ if val is not None:
45
+ return val
46
+ kind = embeds_attr.split("_")[0]
47
+ raise TypeError(
48
+ f"get_{kind}_features returned {type(feats).__name__} with neither "
49
+ f"'{embeds_attr}' nor 'pooler_output'; cannot extract the projected "
50
+ f"embedding (unsupported transformers version?)."
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class ClipModelWrapper:
56
+ name: str
57
+ model: nn.Module
58
+ processor: Any # CLIPProcessor
59
+
60
+ def embed(self, image: Image.Image, device: torch.device) -> np.ndarray:
61
+ prepared = image.copy().convert("RGB")
62
+ prepared.thumbnail((512, 512), Image.Resampling.LANCZOS)
63
+ inputs = self.processor(images=[prepared], return_tensors="pt", padding=True)
64
+ inputs = {k: v.to(device) for k, v in inputs.items()}
65
+ self.model.eval()
66
+ with torch.inference_mode():
67
+ feats = _coerce_clip_features(
68
+ self.model.get_image_features(**inputs), "image_embeds"
69
+ )
70
+ # Normalize to match training pipeline
71
+ feats = feats / feats.norm(p=2, dim=-1, keepdim=True)
72
+ return feats.detach().cpu().numpy()
73
+
74
+
75
+ @dataclass
76
+ class SSLModelWrapper:
77
+ name: str
78
+ model: nn.Module
79
+ transform: Any # torchvision.transforms.Compose
80
+
81
+ def embed(self, image: Image.Image, device: torch.device) -> np.ndarray:
82
+ prepared = image.copy().convert("RGB")
83
+ tensor = self.transform(prepared).unsqueeze(0).to(device)
84
+ self.model.eval()
85
+ with torch.inference_mode():
86
+ feats = self.model(tensor)
87
+ feats = torch.nn.functional.normalize(feats, dim=-1, p=2)
88
+ return feats.detach().cpu().numpy()
89
+
90
+
91
+ class CombinedEmbeddingPipeline:
92
+ """
93
+ Runtime helper that mirrors the embedding combination used to build SkinMap.
94
+
95
+ It loads the individual models, the saved SVD projection (if present),
96
+ and the FAISS index so that uploaded images can be embedded and searched
97
+ in the same latent space.
98
+ """
99
+
100
+ def __init__(self, config: dict, config_path: Path, device: Optional[str] = None):
101
+ self.config = config
102
+ self.config_path = config_path.resolve()
103
+ self.config_dir = self.config_path.parent.resolve()
104
+ self.device = torch.device(device or "cpu")
105
+ self._faiss_lock = threading.Lock()
106
+
107
+ self.skinmap_root_path = self._resolve_skinmap_root(config.get("skinmap_root"))
108
+ self.output_dir = self._resolve_output_dir(config.get("output_dir"))
109
+ self._model_search_roots = self._build_model_search_roots()
110
+
111
+ self.clip_models: List[ClipModelWrapper] = []
112
+ self.ssl_models: List[SSLModelWrapper] = []
113
+ self.models_in_order: List[Tuple[str, Any]] = (
114
+ []
115
+ ) # (type, wrapper) preserving config order
116
+
117
+ self._load_models()
118
+
119
+ # Load SVD (legacy pipeline) or projector (new pipeline)
120
+ self.svd_image = self._load_joblib(config.get("svd", {}).get("image"))
121
+ self.projector_model = None
122
+ self.whitening_stats = None
123
+ self._load_projector_if_available()
124
+
125
+ artifacts = config.get("artifacts", {})
126
+ self._embeddings_npz_path = self._resolve_artifact(
127
+ artifacts.get("embeddings_npz")
128
+ )
129
+ self._faiss_index_path = self._resolve_artifact(artifacts.get("faiss_index"))
130
+ self.faiss_index = self._load_faiss(self._faiss_index_path)
131
+ self.vector_dim = config.get("vector", {}).get("dimension")
132
+ self.index_metric = "cosine" # Matches build_faiss_index default
133
+ self.umap_model = self._load_umap(config.get("umap", {}).get("model"))
134
+
135
+ @classmethod
136
+ def from_config(cls, path: str | os.PathLike, device: Optional[str] = None):
137
+ path = Path(path)
138
+ with open(path, "r") as f:
139
+ config = json.load(f)
140
+ return cls(config=config, config_path=path, device=device)
141
+
142
+ def _resolve_artifact(self, rel_path: Optional[str]) -> Optional[Path]:
143
+ if rel_path is None:
144
+ return None
145
+ return (self.output_dir / rel_path).resolve()
146
+
147
+ def _resolve_skinmap_root(self, raw_root: Optional[str]) -> Optional[Path]:
148
+ candidate_roots: List[Path] = []
149
+
150
+ def _add_candidate(path_like: Optional[str | os.PathLike]):
151
+ if not path_like:
152
+ return
153
+ path_obj = Path(path_like)
154
+ if not path_obj.is_absolute():
155
+ path_obj = (self.config_dir / path_obj).resolve()
156
+ else:
157
+ path_obj = path_obj.resolve()
158
+ candidate_roots.append(path_obj)
159
+
160
+ if raw_root:
161
+ _add_candidate(raw_root)
162
+ raw_path = Path(raw_root)
163
+ if not raw_path.is_absolute():
164
+ _add_candidate(self.config_dir.parent / raw_path)
165
+
166
+ # Walk up the config directory hierarchy to find the project root.
167
+ for ancestor in self.config_dir.parents:
168
+ candidate_roots.append(ancestor.resolve())
169
+
170
+ seen: set[str] = set()
171
+ for candidate in candidate_roots:
172
+ key = str(candidate)
173
+ if key in seen:
174
+ continue
175
+ seen.add(key)
176
+ if not candidate.exists():
177
+ continue
178
+ # Heuristic: the SkinMap repo root contains src/combined_embedder.py
179
+ if (candidate / "src" / "combined_embedder.py").exists():
180
+ if key not in sys.path:
181
+ sys.path.append(key)
182
+ return candidate
183
+
184
+ logger.warning(
185
+ "Unable to resolve SkinMap project root from config %s; "
186
+ "nearest-neighbor upload search may fail.",
187
+ self.config_path,
188
+ )
189
+ return None
190
+
191
+ def _resolve_with_bases(
192
+ self,
193
+ path_value: str | os.PathLike,
194
+ bases: Iterable[Optional[Path]],
195
+ *,
196
+ must_exist: bool = False,
197
+ ) -> Optional[Path]:
198
+ path_obj = Path(path_value)
199
+ candidates: List[Path] = []
200
+
201
+ if path_obj.is_absolute():
202
+ candidates.append(path_obj)
203
+ else:
204
+ candidates.append((Path.cwd() / path_obj).resolve())
205
+ for base in bases:
206
+ if base is None:
207
+ continue
208
+ candidates.append((base / path_obj).resolve())
209
+
210
+ for candidate in candidates:
211
+ if candidate.exists():
212
+ return candidate
213
+ if must_exist:
214
+ return None
215
+ return candidates[0] if candidates else None
216
+
217
+ def _resolve_output_dir(self, raw_output: Optional[str]) -> Path:
218
+ if not raw_output:
219
+ return self.config_dir
220
+
221
+ bases: List[Optional[Path]] = [
222
+ self.skinmap_root_path,
223
+ self.config_dir,
224
+ (
225
+ self.config_dir.parent
226
+ if self.config_dir.parent != self.config_dir
227
+ else None
228
+ ),
229
+ ]
230
+ resolved = self._resolve_with_bases(raw_output, bases, must_exist=True)
231
+ if resolved is None or not resolved.exists():
232
+ logger.warning(
233
+ "Configured output_dir '%s' could not be resolved relative to %s; "
234
+ "falling back to %s",
235
+ raw_output,
236
+ self.config_path,
237
+ self.config_dir,
238
+ )
239
+ return self.config_dir
240
+ return resolved
241
+
242
+ def _build_model_search_roots(self) -> List[Path]:
243
+ roots: List[Path] = []
244
+ for candidate in (
245
+ self.output_dir,
246
+ self.output_dir.parent,
247
+ self.skinmap_root_path,
248
+ self.skinmap_root_path / "assets" if self.skinmap_root_path else None,
249
+ self.config_dir,
250
+ ):
251
+ if candidate is None:
252
+ continue
253
+ resolved = candidate.resolve()
254
+ if resolved not in roots:
255
+ roots.append(resolved)
256
+ return roots
257
+
258
+ def _resolve_model_path(self, source: str) -> Optional[Path]:
259
+ resolved = self._resolve_with_bases(
260
+ source, self._model_search_roots, must_exist=False
261
+ )
262
+ if resolved is not None and resolved.exists():
263
+ return resolved
264
+ return None
265
+
266
+ def _load_models(self):
267
+ models_config: Sequence[dict] = self.config.get("models", [])
268
+ if not models_config:
269
+ raise ValueError("No model definitions found in configuration.")
270
+
271
+ ssl_transform = get_imagenet_transform()
272
+ for entry in models_config:
273
+ source = entry.get("source")
274
+ if source is None:
275
+ continue
276
+ resolved_path = self._resolve_model_path(source)
277
+ resolved_source = str(resolved_path) if resolved_path else source
278
+ model_type = entry.get("type") or (
279
+ "ssl" if source in SSL_MODEL_NAMES else "clip"
280
+ )
281
+ if model_type == "ssl":
282
+ if resolved_path is not None and entry.get("is_local"):
283
+ from skinmap_runtime._compat import load_ssl_local
284
+
285
+ model, _info, _cfg = load_ssl_local(
286
+ str(resolved_path), n_head_layers=0
287
+ )
288
+ else:
289
+ model, _info, _cfg = Embedder.load_pretrained(
290
+ source, return_info=True, n_head_layers=0
291
+ )
292
+ model.to(self.device)
293
+ wrapper = SSLModelWrapper(
294
+ name=source, model=model, transform=ssl_transform
295
+ )
296
+ self.ssl_models.append(wrapper)
297
+ self.models_in_order.append(("ssl", wrapper))
298
+ else:
299
+ model, processor = load_model_and_processor(
300
+ resolved_source, self.device
301
+ )
302
+ wrapper = ClipModelWrapper(
303
+ name=source, model=model, processor=processor
304
+ )
305
+ self.clip_models.append(wrapper)
306
+ self.models_in_order.append(("clip", wrapper))
307
+
308
+ def _load_joblib(self, rel_path: Optional[str]):
309
+ resolved = self._resolve_artifact(rel_path)
310
+ if resolved is None or not resolved.exists():
311
+ return None
312
+ try:
313
+ logger.info("Loading model from %s", resolved)
314
+ return joblib.load(resolved)
315
+ except Exception as exc:
316
+ logger.warning("Failed to load model from %s: %s", resolved, exc)
317
+ return None
318
+
319
+ def _load_umap(self, rel_path: Optional[str]):
320
+ model = self._load_joblib(rel_path)
321
+ if model is None:
322
+ # Retry with NNDescent monkey-patch for pynndescent version mismatch.
323
+ # Newer pynndescent adds a `quantization` attribute that older versions
324
+ # lack, causing joblib.load to fail.
325
+ resolved = self._resolve_artifact(rel_path)
326
+ if resolved is not None and resolved.exists():
327
+ try:
328
+ os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
329
+ import pynndescent
330
+
331
+ NNDescent = pynndescent.NNDescent
332
+ if not hasattr(NNDescent, "quantization"):
333
+ NNDescent.quantization = None # type: ignore[attr-defined]
334
+ logger.info(
335
+ "Patched NNDescent with missing 'quantization' attribute"
336
+ )
337
+ if not hasattr(NNDescent, "_min_distance"):
338
+ NNDescent._min_distance = None # type: ignore[attr-defined]
339
+ logger.info(
340
+ "Patched NNDescent with missing '_min_distance' attribute"
341
+ )
342
+ model = joblib.load(resolved)
343
+ except Exception as exc:
344
+ logger.warning(
345
+ "Failed to load UMAP model even after NNDescent patch: %s", exc
346
+ )
347
+ model = None
348
+ if model is not None:
349
+ logger.info("UMAP model loaded for upload projections")
350
+ return model
351
+
352
+ def _load_projector_if_available(self):
353
+ """Load trained projector model if available in config."""
354
+ projector_cfg = self.config.get("projector", {})
355
+ if not projector_cfg.get("used", False):
356
+ logger.info("No trained projector in config; using legacy SVD pipeline")
357
+ return
358
+
359
+ # Load projector config
360
+ projector_config_path = self._resolve_artifact(projector_cfg.get("config"))
361
+ if projector_config_path is None or not projector_config_path.exists():
362
+ logger.warning("Projector config not found; falling back to SVD pipeline")
363
+ return
364
+
365
+ try:
366
+ with open(projector_config_path, "r") as f:
367
+ proj_config = json.load(f)
368
+ except Exception as exc:
369
+ logger.warning(f"Failed to load projector config: {exc}")
370
+ return
371
+
372
+ # Load whitening stats
373
+ whitening_path = self._resolve_artifact(projector_cfg.get("whitening_stats"))
374
+ if whitening_path is None or not whitening_path.exists():
375
+ logger.warning("Whitening stats not found; falling back to SVD pipeline")
376
+ return
377
+
378
+ try:
379
+ with np.load(whitening_path, allow_pickle=True) as data:
380
+ files = set(data.files)
381
+ dims = list(data["dims"])
382
+ n_models = int(data["n_models"]) if "n_models" in files else len(dims)
383
+
384
+ # Load mu/W arrays if they exist (skip_whitening=False case)
385
+ mu_list = []
386
+ W_list = []
387
+ if n_models > 0:
388
+ if "mu" in files:
389
+ mu_list = list(data["mu"])
390
+ else:
391
+ # Try to load individual mu_i files
392
+ try:
393
+ mu_list = [data[f"mu_{i}"] for i in range(n_models)]
394
+ except KeyError:
395
+ logger.warning(
396
+ "No mu whitening parameters found (skip_whitening=True?)"
397
+ )
398
+
399
+ if "W" in files:
400
+ W_list = list(data["W"])
401
+ else:
402
+ # Try to load individual W_i files
403
+ try:
404
+ W_list = [data[f"W_{i}"] for i in range(n_models)]
405
+ except KeyError:
406
+ logger.warning(
407
+ "No W whitening parameters found (skip_whitening=True?)"
408
+ )
409
+
410
+ clip_indices = (
411
+ list(data["clip_indices"]) if "clip_indices" in files else []
412
+ )
413
+
414
+ # Load text whitening if available
415
+ text_mu_list = []
416
+ text_W_list = []
417
+ text_dims = []
418
+ if "text_mu" in files and "text_W" in files:
419
+ text_mu_list = list(data["text_mu"])
420
+ text_W_list = list(data["text_W"])
421
+ text_dims = (
422
+ list(data["text_dims"])
423
+ if "text_dims" in files
424
+ else [w.shape[0] for w in text_W_list]
425
+ )
426
+ elif "n_text_models" in files:
427
+ n_text = int(data["n_text_models"])
428
+ if n_text > 0:
429
+ try:
430
+ text_mu_list = [data[f"text_mu_{i}"] for i in range(n_text)]
431
+ text_W_list = [data[f"text_W_{i}"] for i in range(n_text)]
432
+ text_dims = (
433
+ list(data["text_dims"])
434
+ if "text_dims" in files
435
+ else [w.shape[0] for w in text_W_list]
436
+ )
437
+ except KeyError:
438
+ logger.warning("No text whitening parameters found")
439
+
440
+ self.whitening_stats = {
441
+ "mu": mu_list,
442
+ "W": W_list,
443
+ "dims": dims,
444
+ "clip_indices": clip_indices,
445
+ }
446
+
447
+ if text_mu_list and text_W_list:
448
+ self.whitening_stats["text_whitening"] = {
449
+ "mu": text_mu_list,
450
+ "W": text_W_list,
451
+ "dims": text_dims,
452
+ }
453
+ logger.info(
454
+ f"Loaded text whitening stats for {len(text_mu_list)} CLIP models"
455
+ )
456
+ else:
457
+ self.whitening_stats["text_whitening"] = None
458
+
459
+ if len(mu_list) > 0:
460
+ logger.info(
461
+ f"Loaded whitening stats for {len(self.whitening_stats['mu'])} teachers"
462
+ )
463
+ else:
464
+ logger.info("Whitening was skipped during training (--skip_whitening)")
465
+ logger.info(
466
+ f"CLIP model indices for text: {self.whitening_stats['clip_indices']}"
467
+ )
468
+ except Exception as exc:
469
+ logger.warning(f"Failed to load whitening stats: {exc}")
470
+ return
471
+
472
+ # Load projector model
473
+ projector_model_path = self._resolve_artifact(projector_cfg.get("model"))
474
+ if projector_model_path is None or not projector_model_path.exists():
475
+ logger.warning("Projector model not found; falling back to SVD pipeline")
476
+ return
477
+
478
+ try:
479
+ from skinmap_runtime.embedding_fusion import BuildSpec, build_model
480
+
481
+ # Calculate actual text input dimension from CLIP models
482
+ # text_dim in config is the OUTPUT dimension, but we need INPUT dimension
483
+ clip_indices = self.whitening_stats.get("clip_indices", [])
484
+ if clip_indices and "text_dims" in self.whitening_stats:
485
+ # Use text_dims from whitening stats (these are the actual input dims)
486
+ text_input_dim = sum(self.whitening_stats["text_dims"])
487
+ logger.info(f"Calculated text input dimension: {text_input_dim}")
488
+ else:
489
+ # Fallback: calculate from teacher_dims at clip_indices
490
+ teacher_dims = proj_config["teacher_dims"]
491
+ text_input_dim = (
492
+ sum(teacher_dims[i] for i in clip_indices)
493
+ if clip_indices
494
+ else teacher_dims[0]
495
+ )
496
+ logger.info(
497
+ f"Calculated text input dimension from teacher_dims: {text_input_dim}"
498
+ )
499
+
500
+ spec = BuildSpec(
501
+ teacher_names=proj_config.get("model_paths", []),
502
+ teacher_dims=proj_config["teacher_dims"],
503
+ text_dim=text_input_dim, # Use calculated INPUT dimension, not output
504
+ out_dim=proj_config["projector_dim"],
505
+ pca_image=None,
506
+ pca_text=None,
507
+ kind=proj_config["projector_type"],
508
+ )
509
+
510
+ self.projector_model = build_model(spec)
511
+ self.projector_model.load_state_dict(
512
+ torch.load(
513
+ projector_model_path, map_location=self.device, weights_only=False
514
+ )
515
+ )
516
+ self.projector_model.to(self.device)
517
+ self.projector_model.eval()
518
+ logger.info(
519
+ f"Loaded trained projector ({proj_config['projector_type']}, dim={proj_config['projector_dim']})"
520
+ )
521
+ except Exception as exc:
522
+ logger.warning(f"Failed to load projector model: {exc}")
523
+ self.projector_model = None
524
+ self.whitening_stats = None
525
+
526
+ def _load_faiss(self, path: Optional[Path]):
527
+ if path is None or not path.exists():
528
+ return None
529
+ try:
530
+ logger.info("Loading FAISS index from %s", path)
531
+ return faiss.read_index(str(path))
532
+ except Exception as exc:
533
+ logger.warning("Failed to load FAISS index from %s: %s", path, exc)
534
+ return None
535
+
536
+ def _ensure_faiss_index(self) -> faiss.Index:
537
+ index = self.faiss_index
538
+ if index is not None:
539
+ return index
540
+ if self._embeddings_npz_path is None or not self._embeddings_npz_path.exists():
541
+ raise RuntimeError(
542
+ "FAISS index not available and embeddings_npz artifact is missing."
543
+ )
544
+ with self._faiss_lock:
545
+ if self.faiss_index is not None:
546
+ return self.faiss_index
547
+ self.faiss_index = self._build_faiss_from_embeddings(
548
+ self._embeddings_npz_path
549
+ )
550
+ return self.faiss_index
551
+
552
+ def _build_faiss_from_embeddings(self, npz_path: Path) -> faiss.Index:
553
+ logger.info(
554
+ "Building FAISS index on the fly from %s; this may take several minutes.",
555
+ npz_path,
556
+ )
557
+ try:
558
+ data = np.load(npz_path, mmap_mode="r")
559
+ except Exception as exc:
560
+ raise RuntimeError(
561
+ f"Failed to load embeddings NPZ from {npz_path}: {exc}"
562
+ ) from exc
563
+
564
+ if "image_embeddings" not in data:
565
+ data.close()
566
+ raise RuntimeError(
567
+ f"Embeddings NPZ at {npz_path} does not contain 'image_embeddings'."
568
+ )
569
+
570
+ embeddings = data["image_embeddings"]
571
+ if embeddings.ndim != 2:
572
+ data.close()
573
+ raise RuntimeError("Embeddings array must be 2-dimensional.")
574
+
575
+ dim = embeddings.shape[1]
576
+ if self.vector_dim is not None and self.vector_dim != dim:
577
+ logger.warning(
578
+ "Configured vector dimension (%s) does not match embeddings (%s); using %s.",
579
+ self.vector_dim,
580
+ dim,
581
+ dim,
582
+ )
583
+
584
+ index = faiss.IndexFlatIP(dim)
585
+ batch_size = 8192
586
+ total = embeddings.shape[0]
587
+ try:
588
+ for start in range(0, total, batch_size):
589
+ end = min(start + batch_size, total)
590
+ batch = np.array(embeddings[start:end], dtype=np.float32, copy=False)
591
+ norms = np.linalg.norm(batch, axis=1, keepdims=True)
592
+ norms[norms == 0] = 1.0
593
+ batch = batch / norms
594
+ index.add(batch)
595
+ if (start // batch_size) % 50 == 0:
596
+ logger.info(
597
+ "FAISS build progress: %d/%d vectors (%.1f%%)",
598
+ end,
599
+ total,
600
+ (end / total) * 100.0,
601
+ )
602
+ finally:
603
+ data.close()
604
+ logger.info("FAISS index build complete (%d vectors).", total)
605
+
606
+ target_path = self._faiss_index_path
607
+ if target_path is not None:
608
+ try:
609
+ target_path.parent.mkdir(parents=True, exist_ok=True)
610
+ faiss.write_index(index, str(target_path))
611
+ logger.info("Cached FAISS index to %s", target_path)
612
+ self._faiss_index_path = target_path
613
+ except Exception as exc:
614
+ logger.warning(
615
+ "Failed to write FAISS index to %s: %s. Continuing without persistence.",
616
+ target_path,
617
+ exc,
618
+ )
619
+ return index
620
+
621
+ def embed_image(self, image: Image.Image) -> np.ndarray:
622
+ """
623
+ Embed a PIL image into the combined latent space.
624
+
625
+ Uses trained projector if available, otherwise falls back to SVD.
626
+ """
627
+ if not self.models_in_order:
628
+ raise RuntimeError("No models loaded for embedding.")
629
+
630
+ features: List[np.ndarray] = []
631
+
632
+ # Iterate models in the same order as in config (critical for projector)
633
+ for model_type, model_wrapper in self.models_in_order:
634
+ if model_type == "clip":
635
+ features.append(model_wrapper.embed(image, self.device))
636
+ else: # ssl
637
+ # SSL transforms expect tensor input; ensure consistent preprocessing
638
+ prepared = image.copy().convert("RGB")
639
+ features.append(model_wrapper.embed(prepared, self.device))
640
+
641
+ # Now apply either projector or SVD based on what's available
642
+ if self.projector_model is not None and self.whitening_stats is not None:
643
+ # Use trained projector pipeline
644
+ combined = self._apply_projector_pipeline(features)
645
+ else:
646
+ # Legacy SVD pipeline
647
+ combined = np.concatenate(
648
+ [feat.astype(np.float32) for feat in features], axis=1
649
+ )
650
+
651
+ if self.svd_image is not None:
652
+ combined = self.svd_image.transform(combined)
653
+
654
+ if combined.ndim == 2:
655
+ return combined[0]
656
+ return combined
657
+
658
+ def _apply_projector_pipeline(self, features: List[np.ndarray]) -> np.ndarray:
659
+ """Apply whitening + projector to per-model features."""
660
+ from skinmap_runtime.embedding_fusion import apply_whiten, l2_normalize
661
+
662
+ # Check if whitening was used (mu/W arrays present)
663
+ has_whitening = (
664
+ self.whitening_stats is not None
665
+ and "mu" in self.whitening_stats
666
+ and "W" in self.whitening_stats
667
+ and len(self.whitening_stats["mu"]) > 0
668
+ and len(self.whitening_stats["W"]) > 0
669
+ )
670
+
671
+ # Step 1: L2 normalize and optionally whiten each model's embeddings
672
+ processed = []
673
+ for i, feat in enumerate(features):
674
+ # L2 normalize
675
+ feat_norm = l2_normalize(feat, axis=1)
676
+
677
+ # Whiten if whitening stats are available
678
+ if has_whitening:
679
+ feat_processed = apply_whiten(
680
+ feat_norm,
681
+ self.whitening_stats["mu"][i],
682
+ self.whitening_stats["W"][i],
683
+ )
684
+ else:
685
+ # No whitening - projector was trained on raw L2-normalized embeddings
686
+ feat_processed = feat_norm
687
+
688
+ processed.append(feat_processed)
689
+
690
+ # Step 2: Concatenate
691
+ z_cat = np.concatenate(processed, axis=1).astype(np.float32)
692
+
693
+ # Step 3: L2-normalize each teacher block (matches training pipeline)
694
+ dims = self.whitening_stats["dims"]
695
+ start = 0
696
+ for dim in dims:
697
+ block = z_cat[:, start : start + dim]
698
+ norms = np.linalg.norm(block, axis=1, keepdims=True)
699
+ norms[norms == 0] = 1.0
700
+ z_cat[:, start : start + dim] = block / norms
701
+ start += dim
702
+
703
+ # Step 4: Project through trained model
704
+ z_cat_tensor = torch.from_numpy(z_cat).to(self.device)
705
+ with torch.inference_mode():
706
+ projected = self.projector_model.img_head(z_cat_tensor)
707
+
708
+ return projected.detach().cpu().numpy()
709
+
710
+ def embed_bytes(self, data: bytes) -> np.ndarray:
711
+ image = Image.open(io.BytesIO(data)).convert("RGB")
712
+ return self.embed_image(image)
713
+
714
+ # Default prompt templates for text search. CLIP models are trained on
715
+ # image-caption pairs so bare keywords ("melanoma") live in a very
716
+ # different part of the embedding space than descriptive captions.
717
+ # Wrapping the query in templates and averaging the resulting embeddings
718
+ # brings the query much closer to the image distribution.
719
+ DEFAULT_TEXT_TEMPLATES: List[str] = [
720
+ "This image shows {}",
721
+ "a clinical photograph of {}",
722
+ "a dermatoscopic image of {}",
723
+ "a medical image showing {}",
724
+ "a close-up photograph of {} on skin",
725
+ ]
726
+
727
+ def encode_text(
728
+ self,
729
+ text: str,
730
+ templates: Optional[List[str]] = None,
731
+ ) -> np.ndarray:
732
+ """Encode a text query into the shared embedding space.
733
+
734
+ When *templates* is provided (or defaults are used), the query is
735
+ formatted into each template, encoded separately, and the resulting
736
+ embeddings are averaged and re-normalized. Pass ``templates=["{}"]``
737
+ to encode the raw text without any prompt engineering.
738
+
739
+ For projector pipeline: extracts text embeddings from CLIP models only,
740
+ concatenates them, and projects through the text head.
741
+
742
+ For SVD pipeline: uses first CLIP model only.
743
+ """
744
+ if not self.clip_models:
745
+ raise RuntimeError("No CLIP models loaded. Cannot encode text.")
746
+
747
+ if templates is None:
748
+ templates = self.DEFAULT_TEXT_TEMPLATES
749
+
750
+ # Expand the query into all templates
751
+ prompts = [t.format(text) for t in templates]
752
+
753
+ # Encode all prompts and average. With a trained projector, all prompts
754
+ # go through each CLIP teacher in one batched forward (C forwards, not
755
+ # N*C). The legacy pipeline falls back to per-prompt encoding.
756
+ if self.projector_model is not None and self.whitening_stats is not None:
757
+ per_prompt = self._encode_texts_projector(prompts)
758
+ else:
759
+ per_prompt = np.stack(
760
+ [self._encode_single_text(p) for p in prompts], axis=0
761
+ )
762
+ avg = per_prompt.mean(axis=0)
763
+ avg = avg / (np.linalg.norm(avg) + 1e-8)
764
+ return avg.astype(np.float32)
765
+
766
+ def _encode_texts_projector(self, texts: List[str]) -> np.ndarray:
767
+ """Batched projector-path text encoding.
768
+
769
+ Returns an ``(N, d_out)`` array of projected, L2-normalized text
770
+ embeddings, one per input string. All prompts pass through each CLIP
771
+ teacher in a single padded forward. CLIP's EOS-token pooling is
772
+ right-padding invariant, so this matches per-prompt encoding.
773
+ """
774
+ from skinmap_runtime.embedding_fusion import apply_whiten, l2_normalize
775
+
776
+ clip_indices = self.whitening_stats.get("clip_indices", [])
777
+ if not clip_indices:
778
+ clip_indices = list(range(len(self.clip_models)))
779
+ clip_index_set = set(clip_indices)
780
+ text_whitening = self.whitening_stats.get("text_whitening")
781
+
782
+ per_model = [] # each (N, d_i)
783
+ clip_idx_counter = 0
784
+ for i, (_model_type, model_wrapper) in enumerate(self.models_in_order):
785
+ if i not in clip_index_set:
786
+ continue
787
+ inputs = model_wrapper.processor(
788
+ text=list(texts), return_tensors="pt", padding=True
789
+ )
790
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
791
+ with torch.inference_mode():
792
+ text_emb = _coerce_clip_features(
793
+ model_wrapper.model.get_text_features(**inputs), "text_embeds"
794
+ )
795
+ text_emb = text_emb / text_emb.norm(p=2, dim=-1, keepdim=True)
796
+ feat = text_emb.cpu().numpy() # (N, d_i)
797
+ if text_whitening is not None:
798
+ feat = apply_whiten(
799
+ l2_normalize(feat, axis=1),
800
+ text_whitening["mu"][clip_idx_counter],
801
+ text_whitening["W"][clip_idx_counter],
802
+ )
803
+ per_model.append(feat)
804
+ clip_idx_counter += 1
805
+
806
+ # Concatenate per-teacher blocks, L2-normalize each row, project.
807
+ text_concat = np.concatenate(per_model, axis=1).astype(np.float32)
808
+ norms = np.linalg.norm(text_concat, axis=1, keepdims=True)
809
+ norms[norms == 0] = 1.0
810
+ text_concat = text_concat / norms
811
+ text_tensor = torch.from_numpy(text_concat).to(self.device)
812
+ with torch.inference_mode():
813
+ projected = self.projector_model.txt_head(text_tensor)
814
+ return projected.cpu().numpy() # (N, d_out)
815
+
816
+ def _encode_single_text(self, text: str) -> np.ndarray:
817
+ """Encode a single text string (no template expansion)."""
818
+ if self.projector_model is not None and self.whitening_stats is not None:
819
+ # Projector pipeline: concatenate text from CLIP models only
820
+ from skinmap_runtime.embedding_fusion import apply_whiten, l2_normalize
821
+
822
+ clip_indices = self.whitening_stats.get("clip_indices", [])
823
+ if not clip_indices:
824
+ # Fallback: use all CLIP models if indices not available
825
+ clip_indices = list(range(len(self.clip_models)))
826
+
827
+ text_features = []
828
+ clip_idx_counter = 0
829
+ for i, (model_type, model_wrapper) in enumerate(self.models_in_order):
830
+ if i in clip_indices:
831
+ # This is a CLIP model, extract text embedding
832
+ inputs = model_wrapper.processor(text=[text], return_tensors="pt")
833
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
834
+ with torch.inference_mode():
835
+ text_emb = _coerce_clip_features(
836
+ model_wrapper.model.get_text_features(**inputs),
837
+ "text_embeds",
838
+ )
839
+ # Normalize
840
+ text_emb = text_emb / text_emb.norm(p=2, dim=-1, keepdim=True)
841
+ text_feat = text_emb.cpu().numpy()
842
+
843
+ # Apply text whitening if available
844
+ text_whitening = self.whitening_stats.get("text_whitening")
845
+ if text_whitening is not None:
846
+ text_feat_normed = l2_normalize(text_feat, axis=1)
847
+ text_feat_whitened = apply_whiten(
848
+ text_feat_normed,
849
+ text_whitening["mu"][clip_idx_counter],
850
+ text_whitening["W"][clip_idx_counter],
851
+ )
852
+ text_features.append(text_feat_whitened[0])
853
+ else:
854
+ text_features.append(text_feat[0])
855
+
856
+ clip_idx_counter += 1
857
+
858
+ # Concatenate text features from all CLIP models
859
+ text_concat = np.concatenate(text_features).astype(np.float32)
860
+
861
+ # L2-normalize the concatenated text vector (matches training pipeline)
862
+ norm = np.linalg.norm(text_concat)
863
+ if norm > 0:
864
+ text_concat = text_concat / norm
865
+
866
+ # Project through text head
867
+ text_tensor = torch.from_numpy(text_concat).unsqueeze(0).to(self.device)
868
+ with torch.inference_mode():
869
+ projected = self.projector_model.txt_head(text_tensor)
870
+
871
+ return projected.cpu().numpy()[0]
872
+ else:
873
+ # Legacy SVD pipeline or no projector: use first CLIP model
874
+ clip_model = self.clip_models[0]
875
+ inputs = clip_model.processor(text=[text], return_tensors="pt")
876
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
877
+ with torch.inference_mode():
878
+ text_emb = _coerce_clip_features(
879
+ clip_model.model.get_text_features(**inputs), "text_embeds"
880
+ )
881
+ text_emb = text_emb / text_emb.norm(p=2, dim=-1, keepdim=True)
882
+
883
+ # If SVD available, apply it
884
+ text_feat = text_emb.cpu().numpy()
885
+ if self.svd_image is not None:
886
+ # Note: this assumes SVD was fitted on concatenated features
887
+ # For single model, this might not work well
888
+ pass
889
+
890
+ return text_feat[0]
891
+
892
+ def find_nearest_neighbors(
893
+ self, vector: np.ndarray, k: int = 16
894
+ ) -> Tuple[List[int], List[float]]:
895
+ index = self._ensure_faiss_index()
896
+ vec = vector.astype(np.float32)
897
+ norm = np.linalg.norm(vec)
898
+ if norm > 0:
899
+ vec = vec / norm
900
+ similarities, indices = index.search(vec.reshape(1, -1), k)
901
+ flat_indices = indices.reshape(-1).tolist()
902
+ flat_distances = (1.0 - similarities.reshape(-1)).tolist()
903
+ return flat_indices, flat_distances
904
+
905
+ def project_vector(self, vector: np.ndarray) -> Optional[np.ndarray]:
906
+ if self.umap_model is None:
907
+ logger.warning(
908
+ "Upload embedding pipeline has no UMAP reducer; skipping projection. "
909
+ "Ensure the embedding pipeline config includes a saved UMAP model."
910
+ )
911
+ return None
912
+ try:
913
+ projected = self.umap_model.transform(vector.reshape(1, -1))
914
+ if projected.ndim == 2 and projected.shape[1] >= 2:
915
+ return projected[0, :2].astype(float)
916
+ except Exception as exc:
917
+ logger.warning("Failed to project vector with UMAP: %s", exc)
918
+ return None
919
+
920
+ def search_image(
921
+ self, image_bytes: bytes, k: int = 16
922
+ ) -> Tuple[List[int], List[float], Optional[np.ndarray]]:
923
+ vector = self.embed_bytes(image_bytes)
924
+ indices, distances = self.find_nearest_neighbors(vector, k=k)
925
+ coords = self.project_vector(vector)
926
+ return indices, distances, coords
skinmap_runtime/core/__init__.py ADDED
File without changes
skinmap_runtime/core/models/__init__.py ADDED
File without changes
skinmap_runtime/core/models/byol/__init__.py ADDED
File without changes
skinmap_runtime/core/models/byol/model.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+ from ..encoders.utils import get_encoder_class
4
+ from ..utils import ModelType
5
+ from .predictor import MLP
6
+
7
+
8
+ class BYOLModel(nn.Module):
9
+ def __init__(
10
+ self,
11
+ base_model: str,
12
+ projection_size=256,
13
+ projection_hidden_size=4096,
14
+ **kwargs,
15
+ ):
16
+ super(BYOLModel, self).__init__()
17
+ encoder_cls, model_type = get_encoder_class(base_model)
18
+ if model_type is ModelType.VIT:
19
+ self.backbone = encoder_cls(**kwargs)
20
+ n_feat = self.backbone.embed_dim
21
+ elif model_type is ModelType.CNN:
22
+ encoder = encoder_cls(**kwargs)
23
+ n_feat = encoder.fc.in_features
24
+ self.backbone = nn.Sequential(*list(encoder.children())[:-1])
25
+ else:
26
+ raise ValueError(f"Unknown model type: {model_type}")
27
+ # projection head
28
+ self.projection = MLP(
29
+ in_channels=n_feat,
30
+ projection_size=projection_size,
31
+ hidden_size=projection_hidden_size,
32
+ )
33
+
34
+ def forward(self, x, return_embedding=False):
35
+ # embedding
36
+ e = self.backbone(x)
37
+ e = e.squeeze()
38
+ if return_embedding:
39
+ return e
40
+ # project
41
+ z = self.projection(e)
42
+ return z
skinmap_runtime/core/models/byol/predictor.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+
4
+ class MLP(nn.Module):
5
+ def __init__(self, in_channels, hidden_size=256, projection_size=4096):
6
+ super(MLP, self).__init__()
7
+ self.net = nn.Sequential(
8
+ nn.Linear(in_channels, hidden_size),
9
+ nn.BatchNorm1d(hidden_size),
10
+ nn.ReLU(inplace=True),
11
+ nn.Linear(hidden_size, projection_size),
12
+ )
13
+
14
+ def forward(self, x):
15
+ return self.net(x)
skinmap_runtime/core/models/colorme/__init__.py ADDED
File without changes
skinmap_runtime/core/models/colorme/model.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import segmentation_models_pytorch as smp
2
+ from torch import nn
3
+
4
+
5
+ class MLPHead(nn.Module):
6
+ def __init__(self, out_features: int = 512):
7
+ super(MLPHead, self).__init__()
8
+ self.head = nn.Sequential(
9
+ nn.Linear(out_features, 10),
10
+ nn.Softmax(dim=-1),
11
+ )
12
+
13
+ def forward(self, x):
14
+ return self.head(x)
15
+
16
+
17
+ class ColorMeModel(nn.Module):
18
+ def __init__(self, num_classes=2, encoder_name="resnet18"):
19
+ super(ColorMeModel, self).__init__()
20
+ # create our encoder decoder model
21
+ self.enc_dec_model = smp.Unet(
22
+ encoder_name=encoder_name,
23
+ encoder_weights="imagenet",
24
+ in_channels=1,
25
+ classes=num_classes,
26
+ )
27
+
28
+ # color distribution MLP head
29
+ if encoder_name == "resnet18":
30
+ self.color_dist_mlp = MLPHead(out_features=512)
31
+ elif encoder_name == "resnet50":
32
+ self.color_dist_mlp = MLPHead(out_features=2048)
33
+ else:
34
+ raise ValueError("Unrecognized encoder")
35
+
36
+ def forward(self, x, return_embedding=False):
37
+ # color reconstruction
38
+ rec = self.enc_dec_model(x)
39
+ # retreive embedding
40
+ emb = self.enc_dec_model.encoder(x)[-1]
41
+ emb = nn.AdaptiveAvgPool2d((1, 1))(emb)
42
+ emb = emb.squeeze()
43
+ # return the embedding if needed
44
+ if return_embedding:
45
+ return emb
46
+ # color distribution
47
+ dist = self.color_dist_mlp(emb)
48
+
49
+ return rec, dist
skinmap_runtime/core/models/dino/__init__.py ADDED
File without changes
skinmap_runtime/core/models/dino/head.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn as nn
2
+
3
+ from ...models.utils import trunc_normal_
4
+
5
+
6
+ class DINOHead(nn.Module):
7
+ def __init__(
8
+ self,
9
+ in_dim,
10
+ out_dim,
11
+ use_bn=False,
12
+ norm_last_layer=True,
13
+ n_layers=3,
14
+ hidden_dim=2048,
15
+ bottleneck_dim=256,
16
+ ):
17
+ super().__init__()
18
+
19
+ n_layers = max(n_layers, 1)
20
+ if n_layers == 1:
21
+ self.mlp = nn.Linear(in_dim, bottleneck_dim)
22
+ else:
23
+ layers = [nn.Linear(in_dim, hidden_dim)]
24
+ if use_bn:
25
+ layers.append(nn.BatchNorm1d(hidden_dim))
26
+ layers.append(nn.GELU())
27
+ for _ in range(n_layers - 2):
28
+ layers.append(nn.Linear(hidden_dim, hidden_dim))
29
+ if use_bn:
30
+ layers.append(nn.BatchNorm1d(hidden_dim))
31
+ layers.append(nn.GELU())
32
+ layers.append(nn.Linear(hidden_dim, bottleneck_dim))
33
+ self.mlp = nn.Sequential(*layers)
34
+ self.apply(self._init_weights)
35
+ self.last_layer = nn.utils.weight_norm(
36
+ nn.Linear(bottleneck_dim, out_dim, bias=False)
37
+ )
38
+ self.last_layer.weight_g.data.fill_(1)
39
+ if norm_last_layer:
40
+ self.last_layer.weight_g.requires_grad = False
41
+
42
+ def _init_weights(self, m):
43
+ if isinstance(m, nn.Linear):
44
+ trunc_normal_(m.weight, std=0.02)
45
+ if isinstance(m, nn.Linear) and m.bias is not None:
46
+ nn.init.constant_(m.bias, 0)
47
+
48
+ def forward(self, x):
49
+ x = self.mlp(x)
50
+ x = nn.functional.normalize(x, dim=-1, p=2)
51
+ x = self.last_layer(x)
52
+ return x
skinmap_runtime/core/models/dino/multi_crop_wrapper.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class MultiCropWrapper(nn.Module):
6
+ """
7
+ Perform forward pass separately on each resolution input.
8
+ The inputs corresponding to a single resolution are clubbed and single
9
+ forward is run on the same resolution inputs. Hence we do several
10
+ forward passes = number of different resolutions used. We then
11
+ concatenate all the output features and run the head forward on these
12
+ concatenated features.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ backbone: torch.nn.Module,
18
+ head: torch.nn.Module,
19
+ apply_l2_norm: float = True,
20
+ ):
21
+ super(MultiCropWrapper, self).__init__()
22
+ self.apply_l2_norm = apply_l2_norm
23
+ # disable layers dedicated to ImageNet labels classification
24
+ backbone.fc, backbone.head = nn.Identity(), nn.Identity()
25
+ self.backbone = backbone
26
+ if head is None:
27
+ self.head = nn.Identity()
28
+ else:
29
+ self.head = head
30
+
31
+ def forward(self, x, mask=None, return_backbone_feat=False, **kwargs):
32
+ # convert to list
33
+ if not isinstance(x, list):
34
+ x = [x]
35
+ mask = [mask] if mask is not None else None
36
+ idx_crops = torch.cumsum(
37
+ torch.unique_consecutive(
38
+ torch.tensor([inp.shape[-1] for inp in x]),
39
+ return_counts=True,
40
+ )[1],
41
+ 0,
42
+ )
43
+
44
+ # start concatenating the features
45
+ start_idx = 0
46
+ output = torch.empty(0).to(x[0].device)
47
+ for end_idx in idx_crops:
48
+ inp_x = torch.cat(x[start_idx:end_idx])
49
+
50
+ if mask is not None:
51
+ inp_m = torch.cat(mask[start_idx:end_idx])
52
+ kwargs.update(dict(mask=inp_m))
53
+
54
+ _out = self.backbone(inp_x, **kwargs)
55
+ if start_idx == 0:
56
+ output = _out
57
+ else:
58
+ output = torch.cat((output, _out))
59
+ start_idx = end_idx
60
+
61
+ # SelfClean: apply L2 norm if requested
62
+ if self.apply_l2_norm:
63
+ output = torch.nn.functional.normalize(output, dim=-1, p=2)
64
+
65
+ # run the head forward on the concatenated features
66
+ output_ = self.head(output)
67
+ if return_backbone_feat:
68
+ return output, output_
69
+
70
+ return output_
skinmap_runtime/core/models/encoders/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
skinmap_runtime/core/models/encoders/swin_transformer.py ADDED
@@ -0,0 +1,1050 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mostly copy-paste from Swin-Transformer library:
3
+ https://github.com/facebookresearch/dino
4
+ https://github.com/microsoft/Swin-Transformer/blob/main/models/swin_transformer.py
5
+ """
6
+
7
+ import os
8
+ from functools import partial
9
+ from math import sqrt
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.distributed as dist
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+ from loguru import logger
17
+
18
+ from ..utils import DropPath, to_2tuple, trunc_normal_
19
+
20
+
21
+ class Mlp(nn.Module):
22
+ def __init__(
23
+ self,
24
+ in_features,
25
+ hidden_features=None,
26
+ out_features=None,
27
+ act_layer=nn.GELU,
28
+ drop=0.0,
29
+ ):
30
+ super(Mlp, self).__init__()
31
+ out_features = out_features or in_features
32
+ hidden_features = hidden_features or in_features
33
+ self.fc1 = nn.Linear(in_features, hidden_features)
34
+ self.act = act_layer()
35
+ self.fc2 = nn.Linear(hidden_features, out_features)
36
+ self.drop = nn.Dropout(drop)
37
+
38
+ def forward(self, x):
39
+ x = self.fc1(x)
40
+ x = self.act(x)
41
+ x = self.drop(x)
42
+ x = self.fc2(x)
43
+ x = self.drop(x)
44
+ return x
45
+
46
+
47
+ def window_partition(x, window_size):
48
+ """
49
+ Args:
50
+ x: (B, H, W, C)
51
+ window_size (int): window size
52
+ Returns:
53
+ windows: (num_windows*B, window_size, window_size, C)
54
+ """
55
+ B, H, W, C = x.shape
56
+ x = x.view(B, H // window_size, window_size, W // window_size, window_size, C)
57
+ windows = (
58
+ x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size, window_size, C)
59
+ )
60
+ return windows
61
+
62
+
63
+ def window_reverse(windows, window_size, H, W):
64
+ """
65
+ Args:
66
+ windows: (num_windows*B, window_size, window_size, C)
67
+ window_size (int): Window size
68
+ H (int): Height of image
69
+ W (int): Width of image
70
+ Returns:
71
+ x: (B, H, W, C)
72
+ """
73
+ B = int(windows.shape[0] / (H * W / window_size / window_size))
74
+ x = windows.view(
75
+ B, H // window_size, W // window_size, window_size, window_size, -1
76
+ )
77
+ x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(B, H, W, -1)
78
+ return x
79
+
80
+
81
+ class WindowAttention(nn.Module):
82
+ r"""Window based multi-head self attention (W-MSA) module with relative position bias.
83
+ It supports both of shifted and non-shifted window.
84
+ Args:
85
+ dim (int): Number of input channels.
86
+ window_size (tuple[int]): The height and width of the window.
87
+ num_heads (int): Number of attention heads.
88
+ qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True
89
+ qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set
90
+ attn_drop (float, optional): Dropout ratio of attention weight. Default: 0.0
91
+ proj_drop (float, optional): Dropout ratio of output. Default: 0.0
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ dim,
97
+ window_size,
98
+ num_heads,
99
+ qkv_bias=True,
100
+ qk_scale=None,
101
+ attn_drop=0.0,
102
+ proj_drop=0.0,
103
+ ):
104
+ super(WindowAttention, self).__init__()
105
+ self.dim = dim
106
+ self.window_size = window_size # Wh, Ww
107
+ self.num_heads = num_heads
108
+ head_dim = dim // num_heads
109
+ self.scale = qk_scale or head_dim**-0.5
110
+
111
+ # define a parameter table of relative position bias
112
+ self.relative_position_bias_table = nn.Parameter(
113
+ torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads)
114
+ ) # 2*Wh-1 * 2*Ww-1, nH
115
+
116
+ # get pair-wise relative position index for each token inside the window
117
+ coords_h = torch.arange(self.window_size[0])
118
+ coords_w = torch.arange(self.window_size[1])
119
+ coords = torch.stack(torch.meshgrid([coords_h, coords_w])) # 2, Wh, Ww
120
+ coords_flatten = torch.flatten(coords, 1) # 2 Wh*Ww
121
+ relative_coords = (
122
+ coords_flatten[:, :, None] - coords_flatten[:, None, :]
123
+ ) # 2, Wh*Ww, Wh*Ww
124
+ relative_coords = relative_coords.permute(
125
+ 1, 2, 0
126
+ ).contiguous() # Wh*Ww, Wh*Ww, 2
127
+ relative_coords[:, :, 0] += self.window_size[0] - 1 # shift to start from 0
128
+ relative_coords[:, :, 1] += self.window_size[1] - 1
129
+ relative_coords[:, :, 0] *= 2 * self.window_size[1] - 1
130
+ relative_position_index = relative_coords.sum(-1) # Wh*Ww, Wh*Ww
131
+ self.register_buffer("relative_position_index", relative_position_index)
132
+
133
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
134
+ self.attn_drop = nn.Dropout(attn_drop)
135
+ self.proj = nn.Linear(dim, dim)
136
+ self.proj_drop = nn.Dropout(proj_drop)
137
+
138
+ trunc_normal_(self.relative_position_bias_table, std=0.02)
139
+ self.softmax = nn.Softmax(dim=-1)
140
+
141
+ def forward(self, x, mask=None):
142
+ """
143
+ Args:
144
+ x: input features with shape of (num_windows*B, N, C)
145
+ mask: (0/-inf) mask with shape of (num_windows, Wh*Ww, Wh*Ww) or None
146
+ """
147
+ B_, N, C = x.shape
148
+ qkv = (
149
+ self.qkv(x)
150
+ .reshape(B_, N, 3, self.num_heads, C // self.num_heads)
151
+ .permute(2, 0, 3, 1, 4)
152
+ )
153
+ q, k, v = (
154
+ qkv[0],
155
+ qkv[1],
156
+ qkv[2],
157
+ ) # make torchscript happy (cannot use tensor as tuple)
158
+
159
+ q = q * self.scale
160
+ attn = q @ k.transpose(-2, -1)
161
+
162
+ relative_position_bias = self.relative_position_bias_table[
163
+ self.relative_position_index.view(-1)
164
+ ].view(
165
+ self.window_size[0] * self.window_size[1],
166
+ self.window_size[0] * self.window_size[1],
167
+ -1,
168
+ ) # Wh*Ww,Wh*Ww,nH
169
+ relative_position_bias = relative_position_bias.permute(
170
+ 2, 0, 1
171
+ ).contiguous() # nH, Wh*Ww, Wh*Ww
172
+ attn = attn + relative_position_bias.unsqueeze(0)
173
+
174
+ if mask is not None:
175
+ nW = mask.shape[0]
176
+ attn = attn.view(B_ // nW, nW, self.num_heads, N, N) + mask.unsqueeze(
177
+ 1
178
+ ).unsqueeze(0)
179
+ attn = attn.view(-1, self.num_heads, N, N)
180
+ attn = self.softmax(attn)
181
+ else:
182
+ attn = self.softmax(attn)
183
+
184
+ attn_out = attn
185
+ attn = self.attn_drop(attn)
186
+
187
+ x = (attn @ v).transpose(1, 2).reshape(B_, N, C)
188
+ x = self.proj(x)
189
+ x = self.proj_drop(x)
190
+ return x, attn_out
191
+
192
+ def extra_repr(self) -> str:
193
+ return f"dim={self.dim}, window_size={self.window_size}, num_heads={self.num_heads}"
194
+
195
+ def flops(self, N):
196
+ # calculate flops for 1 window with token length of N
197
+ flops = 0
198
+ # qkv = self.qkv(x)
199
+ flops += N * self.dim * 3 * self.dim
200
+ # attn = (q @ k.transpose(-2, -1))
201
+ flops += self.num_heads * N * (self.dim // self.num_heads) * N
202
+ # x = (attn @ v)
203
+ flops += self.num_heads * N * N * (self.dim // self.num_heads)
204
+ # x = self.proj(x)
205
+ flops += N * self.dim * self.dim
206
+ return flops
207
+
208
+ @staticmethod
209
+ def compute_macs(module, input, output):
210
+ B, N, C = input[0].shape
211
+
212
+ module.__flops__ += module.flops(N) * B
213
+
214
+
215
+ class SwinTransformerBlock(nn.Module):
216
+ r"""Swin Transformer Block.
217
+ Args:
218
+ dim (int): Number of input channels.
219
+ input_resolution (tuple[int]): Input resolution.
220
+ num_heads (int): Number of attention heads.
221
+ window_size (int): Window size.
222
+ shift_size (int): Shift size for SW-MSA.
223
+ mlp_ratio (float): Ratio of mlp hidden dim to embedding dim.
224
+ qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True
225
+ qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set.
226
+ drop (float, optional): Dropout rate. Default: 0.0
227
+ attn_drop (float, optional): Attention dropout rate. Default: 0.0
228
+ drop_path (float, optional): Stochastic depth rate. Default: 0.0
229
+ act_layer (nn.Module, optional): Activation layer. Default: nn.GELU
230
+ norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm
231
+ """
232
+
233
+ def __init__(
234
+ self,
235
+ dim,
236
+ input_resolution,
237
+ num_heads,
238
+ window_size=7,
239
+ shift_size=0,
240
+ mlp_ratio=4.0,
241
+ qkv_bias=True,
242
+ qk_scale=None,
243
+ drop=0.0,
244
+ attn_drop=0.0,
245
+ drop_path=0.0,
246
+ act_layer=nn.GELU,
247
+ norm_layer=nn.LayerNorm,
248
+ ):
249
+ super().__init__()
250
+ self.dim = dim
251
+ self.input_resolution = input_resolution
252
+ self.num_heads = num_heads
253
+ self.window_size = window_size
254
+ self.shift_size = shift_size
255
+ self.mlp_ratio = mlp_ratio
256
+ if min(self.input_resolution) <= self.window_size:
257
+ # if window size is larger than input resolution, we don't partition windows
258
+ self.shift_size = 0
259
+ self.window_size = min(self.input_resolution)
260
+ assert (
261
+ 0 <= self.shift_size < self.window_size
262
+ ), "shift_size must in 0-window_size"
263
+
264
+ self.norm1 = norm_layer(dim)
265
+ self.attn = WindowAttention(
266
+ dim,
267
+ window_size=to_2tuple(self.window_size),
268
+ num_heads=num_heads,
269
+ qkv_bias=qkv_bias,
270
+ qk_scale=qk_scale,
271
+ attn_drop=attn_drop,
272
+ proj_drop=drop,
273
+ )
274
+
275
+ self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
276
+ self.norm2 = norm_layer(dim)
277
+ mlp_hidden_dim = int(dim * mlp_ratio)
278
+ self.mlp = Mlp(
279
+ in_features=dim,
280
+ hidden_features=mlp_hidden_dim,
281
+ act_layer=act_layer,
282
+ drop=drop,
283
+ )
284
+
285
+ self.H = input_resolution[0]
286
+ self.W = input_resolution[1]
287
+
288
+ self.attn_mask_dict = {} # {self.H: self.create_attn_mask(self.H, self.W)}
289
+
290
+ def create_attn_mask(self, H, W):
291
+ # calculate attention mask for SW-MSA
292
+
293
+ Hp = int(np.ceil(H / self.window_size)) * self.window_size
294
+ Wp = int(np.ceil(W / self.window_size)) * self.window_size
295
+ img_mask = torch.zeros((1, Hp, Wp, 1)) # 1 Hp Wp 1
296
+ h_slices = (
297
+ slice(0, -self.window_size),
298
+ slice(-self.window_size, -self.shift_size),
299
+ slice(-self.shift_size, None),
300
+ )
301
+ w_slices = (
302
+ slice(0, -self.window_size),
303
+ slice(-self.window_size, -self.shift_size),
304
+ slice(-self.shift_size, None),
305
+ )
306
+ cnt = 0
307
+ for h in h_slices:
308
+ for w in w_slices:
309
+ img_mask[:, h, w, :] = cnt
310
+ cnt += 1
311
+
312
+ mask_windows = window_partition(
313
+ img_mask, self.window_size
314
+ ) # nW, window_size, window_size, 1
315
+ mask_windows = mask_windows.view(-1, self.window_size * self.window_size)
316
+ attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2)
317
+ attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0)).masked_fill(
318
+ attn_mask == 0, float(0.0)
319
+ )
320
+
321
+ return attn_mask
322
+
323
+ def forward(self, x):
324
+ B, L, C = x.shape
325
+ H = int(sqrt(L))
326
+ W = H
327
+
328
+ shortcut = x
329
+ x = self.norm1(x)
330
+ x = x.view(B, H, W, C)
331
+
332
+ # pad feature maps to multiples of window size
333
+ pad_l = pad_t = 0
334
+ pad_r = (self.window_size - W % self.window_size) % self.window_size
335
+ pad_b = (self.window_size - H % self.window_size) % self.window_size
336
+ x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b))
337
+ _, Hp, Wp, _ = x.shape
338
+
339
+ # cyclic shift
340
+ if self.shift_size > 0:
341
+ shifted_x = torch.roll(
342
+ x,
343
+ shifts=(-self.shift_size, -self.shift_size),
344
+ dims=(1, 2),
345
+ )
346
+
347
+ if H is self.attn_mask_dict.keys():
348
+ attn_mask = self.attn_mask_dict[H]
349
+ else:
350
+ self.attn_mask_dict[H] = self.create_attn_mask(H, W).to(x.device)
351
+ attn_mask = self.attn_mask_dict[H]
352
+
353
+ else:
354
+ shifted_x = x
355
+ attn_mask = None
356
+
357
+ # partition windows
358
+ x_windows = window_partition(
359
+ shifted_x, self.window_size
360
+ ) # nW*B, window_size, window_size, C
361
+ x_windows = x_windows.view(
362
+ -1, self.window_size * self.window_size, C
363
+ ) # nW*B, window_size*window_size, C
364
+
365
+ # W-MSA/SW-MSA
366
+ attn_windows, attn = self.attn(
367
+ x_windows, attn_mask
368
+ ) # nW*B, window_size*window_size, C
369
+
370
+ # merge windows
371
+ attn_windows = attn_windows.view(-1, self.window_size, self.window_size, C)
372
+ shifted_x = window_reverse(attn_windows, self.window_size, Hp, Wp) # B H' W' C
373
+
374
+ # reverse cyclic shift
375
+ if self.shift_size > 0:
376
+ x = torch.roll(
377
+ shifted_x,
378
+ shifts=(self.shift_size, self.shift_size),
379
+ dims=(1, 2),
380
+ )
381
+ else:
382
+ x = shifted_x
383
+
384
+ if pad_r > 0 or pad_b > 0:
385
+ x = x[:, :H, :W, :].contiguous()
386
+
387
+ x = x.view(B, H * W, C)
388
+
389
+ # FFN
390
+ x = shortcut + self.drop_path(x)
391
+ x = x + self.drop_path(self.mlp(self.norm2(x)))
392
+
393
+ return x, attn
394
+
395
+ def extra_repr(self) -> str:
396
+ return (
397
+ f"dim={self.dim}, input_resolution={self.input_resolution}, num_heads={self.num_heads}, "
398
+ f"window_size={self.window_size}, shift_size={self.shift_size} mlp_ratio={self.mlp_ratio}"
399
+ )
400
+
401
+ def flops(self):
402
+ flops = 0
403
+ H, W = self.input_resolution
404
+ # norm1
405
+ flops += self.dim * H * W
406
+ # W-MSA/SW-MSA
407
+ nW = H * W / self.window_size / self.window_size
408
+ flops += nW * self.attn.flops(self.window_size * self.window_size)
409
+ # mlp
410
+ flops += 2 * H * W * self.dim * self.dim * self.mlp_ratio
411
+ # norm2
412
+ flops += self.dim * H * W
413
+ return flops
414
+
415
+
416
+ class PatchMerging(nn.Module):
417
+ r"""Patch Merging Layer.
418
+ Args:
419
+ input_resolution (tuple[int]): Resolution of input feature.
420
+ dim (int): Number of input channels.
421
+ norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm
422
+ """
423
+
424
+ def __init__(self, input_resolution, dim, norm_layer=nn.LayerNorm):
425
+ super().__init__()
426
+ self.input_resolution = input_resolution
427
+ self.dim = dim
428
+ self.reduction = nn.Linear(4 * dim, 2 * dim, bias=False)
429
+ self.norm = norm_layer(4 * dim)
430
+
431
+ def forward(self, x):
432
+ """Forward function.
433
+ Args:
434
+ x: Input feature, tensor size (B, H*W, C).
435
+ H, W: Spatial resolution of the input feature.
436
+ """
437
+ B, L, C = x.shape
438
+ H = int(sqrt(L))
439
+ W = H
440
+
441
+ x = x.view(B, H, W, C)
442
+
443
+ # padding
444
+ pad_input = (H % 2 == 1) or (W % 2 == 1)
445
+ if pad_input:
446
+ x = F.pad(x, (0, 0, 0, W % 2, 0, H % 2))
447
+
448
+ x0 = x[:, 0::2, 0::2, :] # B H/2 W/2 C
449
+ x1 = x[:, 1::2, 0::2, :] # B H/2 W/2 C
450
+ x2 = x[:, 0::2, 1::2, :] # B H/2 W/2 C
451
+ x3 = x[:, 1::2, 1::2, :] # B H/2 W/2 C
452
+ x = torch.cat([x0, x1, x2, x3], -1) # B H/2 W/2 4*C
453
+ x = x.view(B, -1, 4 * C) # B H/2*W/2 4*C
454
+
455
+ x = self.norm(x)
456
+ x = self.reduction(x)
457
+
458
+ return x
459
+
460
+ def extra_repr(self) -> str:
461
+ return f"input_resolution={self.input_resolution}, dim={self.dim}"
462
+
463
+ def flops(self):
464
+ H, W = self.input_resolution
465
+ flops = H * W * self.dim
466
+ flops += (H // 2) * (W // 2) * 4 * self.dim * 2 * self.dim
467
+ return flops
468
+
469
+
470
+ class BasicLayer(nn.Module):
471
+ """A basic Swin Transformer layer for one stage.
472
+ Args:
473
+ dim (int): Number of input channels.
474
+ input_resolution (tuple[int]): Input resolution.
475
+ depth (int): Number of blocks.
476
+ num_heads (int): Number of attention heads.
477
+ window_size (int): Window size.
478
+ mlp_ratio (float): Ratio of mlp hidden dim to embedding dim.
479
+ qkv_bias (bool, optional): If True, add a learnable bias to query, key, value. Default: True
480
+ qk_scale (float | None, optional): Override default qk scale of head_dim ** -0.5 if set.
481
+ drop (float, optional): Dropout rate. Default: 0.0
482
+ attn_drop (float, optional): Attention dropout rate. Default: 0.0
483
+ drop_path (float | tuple[float], optional): Stochastic depth rate. Default: 0.0
484
+ norm_layer (nn.Module, optional): Normalization layer. Default: nn.LayerNorm
485
+ downsample (nn.Module | None, optional): Downsample layer at the end of the layer. Default: None
486
+ """
487
+
488
+ def __init__(
489
+ self,
490
+ dim,
491
+ input_resolution,
492
+ depth,
493
+ num_heads,
494
+ window_size,
495
+ mlp_ratio=4.0,
496
+ qkv_bias=True,
497
+ qk_scale=None,
498
+ drop=0.0,
499
+ attn_drop=0.0,
500
+ drop_path=0.0,
501
+ norm_layer=nn.LayerNorm,
502
+ downsample=None,
503
+ ):
504
+ super().__init__()
505
+ self.dim = dim
506
+ self.input_resolution = input_resolution
507
+ self.depth = depth
508
+
509
+ self.blocks = nn.ModuleList(
510
+ [
511
+ SwinTransformerBlock(
512
+ dim=dim,
513
+ input_resolution=input_resolution,
514
+ num_heads=num_heads,
515
+ window_size=window_size,
516
+ shift_size=0 if (i % 2 == 0) else window_size // 2,
517
+ mlp_ratio=mlp_ratio,
518
+ qkv_bias=qkv_bias,
519
+ qk_scale=qk_scale,
520
+ drop=drop,
521
+ attn_drop=attn_drop,
522
+ drop_path=(
523
+ drop_path[i] if isinstance(drop_path, list) else drop_path
524
+ ),
525
+ norm_layer=norm_layer,
526
+ )
527
+ for i in range(depth)
528
+ ]
529
+ )
530
+ if downsample is not None:
531
+ self.downsample = downsample(
532
+ input_resolution,
533
+ dim=dim,
534
+ norm_layer=norm_layer,
535
+ )
536
+ else:
537
+ self.downsample = None
538
+
539
+ def forward(self, x):
540
+ for blk in self.blocks:
541
+ x, _ = blk(x)
542
+ if self.downsample is not None:
543
+ x = self.downsample(x)
544
+ return x
545
+
546
+ def forward_with_features(self, x):
547
+ fea = []
548
+ for blk in self.blocks:
549
+ x, _ = blk(x)
550
+ fea.append(x)
551
+ if self.downsample is not None:
552
+ x = self.downsample(x)
553
+ return x, fea
554
+
555
+ def forward_with_attention(self, x):
556
+ attns = []
557
+ for blk in self.blocks:
558
+ x, attn = blk(x)
559
+ attns.append(attn)
560
+ if self.downsample is not None:
561
+ x = self.downsample(x)
562
+ return x, attns
563
+
564
+ def extra_repr(self) -> str:
565
+ return f"dim={self.dim}, input_resolution={self.input_resolution}, depth={self.depth}"
566
+
567
+ def flops(self):
568
+ flops = 0
569
+ for blk in self.blocks:
570
+ flops += blk.flops()
571
+ if self.downsample is not None:
572
+ flops += self.downsample.flops()
573
+ return flops
574
+
575
+
576
+ class PatchEmbed(nn.Module):
577
+ """Image to Patch Embedding"""
578
+
579
+ def __init__(
580
+ self,
581
+ img_size=224,
582
+ patch_size=16,
583
+ in_chans=3,
584
+ embed_dim=768,
585
+ norm_layer=None,
586
+ ):
587
+ super().__init__()
588
+ img_size = to_2tuple(img_size)
589
+ patch_size = to_2tuple(patch_size)
590
+ patches_resolution = [
591
+ img_size[0] // patch_size[0],
592
+ img_size[1] // patch_size[1],
593
+ ]
594
+ self.img_size = img_size
595
+ self.patch_size = patch_size
596
+ self.patches_resolution = patches_resolution
597
+ self.num_patches = patches_resolution[0] * patches_resolution[1]
598
+
599
+ self.in_chans = in_chans
600
+ self.embed_dim = embed_dim
601
+
602
+ self.proj = nn.Conv2d(
603
+ in_chans,
604
+ embed_dim,
605
+ kernel_size=patch_size,
606
+ stride=patch_size,
607
+ )
608
+ if norm_layer is not None:
609
+ self.norm = norm_layer(embed_dim)
610
+ else:
611
+ self.norm = None
612
+
613
+ def forward(self, x):
614
+ # # FIXME look at relaxing size constraints
615
+ # assert H == self.img_size[0] and W == self.img_size[1], \
616
+ # f"Input image size ({H}*{W}) doesn't match model ({self.img_size[0]}*{self.img_size[1]})."
617
+
618
+ x = self.proj(x)
619
+ B, C, H, W = x.shape
620
+ x = x.flatten(2).transpose(1, 2) # B Ph*Pw C
621
+ if self.norm is not None:
622
+ x = self.norm(x)
623
+ return x.transpose(1, 2).reshape(B, C, H, W)
624
+
625
+ def flops(self):
626
+ Ho, Wo = self.patches_resolution
627
+ flops = (
628
+ Ho
629
+ * Wo
630
+ * self.embed_dim
631
+ * self.in_chans
632
+ * (self.patch_size[0] * self.patch_size[1])
633
+ )
634
+ if self.norm is not None:
635
+ flops += Ho * Wo * self.embed_dim
636
+ return flops
637
+
638
+
639
+ class SwinTransformer(nn.Module):
640
+ r"""Swin Transformer
641
+ A PyTorch impl of : `Swin Transformer: Hierarchical Vision Transformer using Shifted Windows` -
642
+ https://arxiv.org/pdf/2103.14030
643
+ Args:
644
+ img_size (int | tuple(int)): Input image size.
645
+ patch_size (int | tuple(int)): Patch size.
646
+ in_chans (int): Number of input channels.
647
+ num_classes (int): Number of classes for classification head.
648
+ embed_dim (int): Embedding dimension.
649
+ depths (tuple(int)): Depth of Swin Transformer layers.
650
+ num_heads (tuple(int)): Number of attention heads in different layers.
651
+ window_size (int): Window size.
652
+ mlp_ratio (float): Ratio of mlp hidden dim to embedding dim.
653
+ qkv_bias (bool): If True, add a learnable bias to query, key, value. Default: True
654
+ qk_scale (float): Override default qk scale of head_dim ** -0.5 if set.
655
+ drop_rate (float): Dropout rate.
656
+ attn_drop_rate (float): Attention dropout rate.
657
+ drop_path_rate (float): Stochastic depth rate.
658
+ norm_layer (nn.Module): normalization layer.
659
+ ape (bool): If True, add absolute position embedding to the patch embedding.
660
+ patch_norm (bool): If True, add normalization after patch embedding.
661
+ """
662
+
663
+ def __init__(
664
+ self,
665
+ img_size=224,
666
+ patch_size=4,
667
+ in_chans=3,
668
+ num_classes=1000,
669
+ embed_dim=96,
670
+ depths=[2, 2, 6, 2],
671
+ num_heads=[3, 6, 12, 24],
672
+ window_size=7,
673
+ mlp_ratio=4.0,
674
+ qkv_bias=True,
675
+ qk_scale=None,
676
+ drop_rate=0.0,
677
+ attn_drop_rate=0.0,
678
+ drop_path_rate=0.0,
679
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
680
+ ape=False,
681
+ patch_norm=True,
682
+ return_all_tokens=False,
683
+ use_mean_pooling=True,
684
+ masked_im_modeling=False,
685
+ ):
686
+ super().__init__()
687
+
688
+ self.num_classes = num_classes
689
+ self.depths = depths
690
+ self.num_layers = len(depths)
691
+ self.embed_dim = embed_dim
692
+ self.ape = ape
693
+ self.patch_norm = patch_norm
694
+ self.num_features = int(embed_dim * 2 ** (self.num_layers - 1))
695
+ self.mlp_ratio = mlp_ratio
696
+ self.return_all_tokens = return_all_tokens
697
+
698
+ self.patch_embed = PatchEmbed(
699
+ img_size=img_size,
700
+ patch_size=patch_size,
701
+ in_chans=in_chans,
702
+ embed_dim=embed_dim,
703
+ norm_layer=norm_layer if self.patch_norm else None,
704
+ )
705
+ num_patches = self.patch_embed.num_patches
706
+ patches_resolution = self.patch_embed.patches_resolution
707
+ self.patches_resolution = patches_resolution
708
+
709
+ if self.ape:
710
+ self.absolute_pos_embed = nn.Parameter(
711
+ torch.zeros(1, num_patches, embed_dim)
712
+ )
713
+ trunc_normal_(self.absolute_pos_embed, std=0.02)
714
+
715
+ self.pos_drop = nn.Dropout(p=drop_rate)
716
+
717
+ dpr = [
718
+ x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))
719
+ ] # stochastic depth decay rule
720
+ self.layers = nn.ModuleList()
721
+ for i_layer in range(self.num_layers):
722
+ layer = BasicLayer(
723
+ dim=int(embed_dim * 2**i_layer),
724
+ input_resolution=(
725
+ patches_resolution[0] // (2**i_layer),
726
+ patches_resolution[1] // (2**i_layer),
727
+ ),
728
+ depth=depths[i_layer],
729
+ num_heads=num_heads[i_layer],
730
+ window_size=window_size,
731
+ mlp_ratio=self.mlp_ratio,
732
+ qkv_bias=qkv_bias,
733
+ qk_scale=qk_scale,
734
+ drop=drop_rate,
735
+ attn_drop=attn_drop_rate,
736
+ drop_path=dpr[sum(depths[:i_layer]) : sum(depths[: i_layer + 1])],
737
+ norm_layer=norm_layer,
738
+ downsample=PatchMerging if (i_layer < self.num_layers - 1) else None,
739
+ )
740
+ self.layers.append(layer)
741
+
742
+ self.norm = norm_layer(self.num_features)
743
+ self.avgpool = nn.AdaptiveAvgPool1d(1)
744
+ self.head = (
745
+ nn.Linear(self.num_features, num_classes)
746
+ if num_classes > 0
747
+ else nn.Identity()
748
+ )
749
+
750
+ self.apply(self._init_weights)
751
+
752
+ # masked image modeling
753
+ self.masked_im_modeling = masked_im_modeling
754
+ if masked_im_modeling:
755
+ self.masked_embed = nn.Parameter(torch.zeros(1, embed_dim))
756
+
757
+ def _init_weights(self, m):
758
+ if isinstance(m, nn.Linear):
759
+ trunc_normal_(m.weight, std=0.02)
760
+ if isinstance(m, nn.Linear) and m.bias is not None:
761
+ nn.init.constant_(m.bias, 0)
762
+ elif isinstance(m, nn.LayerNorm):
763
+ nn.init.constant_(m.bias, 0)
764
+ nn.init.constant_(m.weight, 1.0)
765
+
766
+ @torch.jit.ignore
767
+ def no_weight_decay(self):
768
+ return {"absolute_pos_embed"}
769
+
770
+ @torch.jit.ignore
771
+ def no_weight_decay_keywords(self):
772
+ # todo: to be implemented
773
+ return {"relative_position_bias_table"}
774
+
775
+ def forward(self, x, return_all_tokens=None, mask=None):
776
+ # patch linear embedding
777
+ x = self.patch_embed(x)
778
+ # mask image modeling
779
+ if mask is not None:
780
+ x = self.mask_model(x, mask)
781
+ x = x.flatten(2).transpose(1, 2)
782
+
783
+ if self.ape:
784
+ x = x + self.absolute_pos_embed
785
+ x = self.pos_drop(x)
786
+
787
+ for layer in self.layers:
788
+ x = layer(x)
789
+
790
+ x_region = self.norm(x) # B L C
791
+ x = self.avgpool(x_region.transpose(1, 2)) # B C 1
792
+ x = torch.flatten(x, 1)
793
+
794
+ return_all_tokens = (
795
+ self.return_all_tokens if return_all_tokens is None else return_all_tokens
796
+ )
797
+ if return_all_tokens:
798
+ return torch.cat([x.unsqueeze(1), x_region], dim=1)
799
+ return x
800
+
801
+ def prepare_tokens(self, x):
802
+ x = self.patch_embed(x)
803
+ x = x.flatten(2).transpose(1, 2)
804
+ if self.ape:
805
+ x = x + self.absolute_pos_embed
806
+ return self.pos_drop(x)
807
+
808
+ def get_last_selfattention(self, x):
809
+ x = self.prepare_tokens(x)
810
+ for i, layer in enumerate(self.layers):
811
+ if i < len(self.layers) - 1:
812
+ x = layer(x)
813
+ else:
814
+ x, attns = layer.forward_with_attention(x)
815
+ return attns[-1]
816
+
817
+ def get_all_selfattention(self, x):
818
+ x = self.prepare_tokens(x)
819
+ attn_out = []
820
+ for layer in self.layers:
821
+ x, attns = layer.forward_with_attention(x)
822
+ attn_out += attns
823
+ return attn_out
824
+
825
+ def get_intermediate_layers(self, x, n=1, return_patch_avgpool=False):
826
+ num_blks = sum(self.depths)
827
+ start_idx = num_blks - n
828
+
829
+ sum_cur = 0
830
+ for i, d in enumerate(self.depths):
831
+ sum_cur_new = sum_cur + d
832
+ if start_idx >= sum_cur and start_idx < sum_cur_new:
833
+ start_stage = i
834
+ start_blk = start_idx - sum_cur
835
+ sum_cur = sum_cur_new
836
+
837
+ x = self.patch_embed(x)
838
+ x = x.flatten(2).transpose(1, 2)
839
+ if self.ape:
840
+ x = x + self.absolute_pos_embed
841
+ x = self.pos_drop(x)
842
+
843
+ # we will return the averaged token features from the `n` last blocks
844
+ # note: there is no [CLS] token in Swin Transformer
845
+ output = []
846
+ s = 0
847
+ for i, layer in enumerate(self.layers):
848
+ x, fea = layer.forward_with_features(x)
849
+
850
+ if i >= start_stage:
851
+ for x_ in fea[start_blk:]:
852
+ if i == len(self.layers) - 1: # use the norm in the last stage
853
+ x_ = self.norm(x_)
854
+
855
+ x_avg = torch.flatten(self.avgpool(x_.transpose(1, 2)), 1) # B C
856
+ if return_patch_avgpool:
857
+ x_o = x_avg
858
+ else:
859
+ x_o = torch.cat((x_avg.unsqueeze(1), x_), dim=1)
860
+ logger.debug(f"Stage {i}, x_o {x_o.shape}")
861
+ output.append(x_o)
862
+
863
+ start_blk = 0
864
+
865
+ # return torch.cat(output, dim=-1)
866
+ return output
867
+
868
+ def flops(self):
869
+ flops = 0
870
+ flops += self.patch_embed.flops()
871
+ for i, layer in enumerate(self.layers):
872
+ flops += layer.flops()
873
+ if dist.get_rank() == 0:
874
+ logger.debug(f"GFLOPs layer_{i}: {layer.flops() / 1e9}")
875
+ flops += (
876
+ self.num_features
877
+ * self.patches_resolution[0]
878
+ * self.patches_resolution[1]
879
+ // (2**self.num_layers)
880
+ )
881
+ flops += self.num_features * self.num_classes
882
+ return flops
883
+
884
+ def init_weights(self, pretrained="", pretrained_layers=[], verbose=True):
885
+ if os.path.isfile(pretrained):
886
+ pretrained_dict = torch.load(pretrained, map_location="cpu")
887
+ logger.info(f"=> loading pretrained model {pretrained}")
888
+ model_dict = self.state_dict()
889
+ pretrained_dict = {
890
+ k: v for k, v in pretrained_dict.items() if k in model_dict.keys()
891
+ }
892
+ need_init_state_dict = {}
893
+ for k, v in pretrained_dict.items():
894
+ need_init = (
895
+ k.split(".")[0] in pretrained_layers
896
+ or pretrained_layers[0] == "*"
897
+ or "relative_position_index" not in k
898
+ or "attn_mask" not in k
899
+ )
900
+
901
+ if need_init:
902
+ logger.info(f"=> init {k} from {pretrained}")
903
+ if (
904
+ "relative_position_bias_table" in k
905
+ and v.size() != model_dict[k].size()
906
+ ):
907
+ relative_position_bias_table_pretrained = v
908
+ relative_position_bias_table_current = model_dict[k]
909
+ L1, nH1 = relative_position_bias_table_pretrained.size()
910
+ L2, nH2 = relative_position_bias_table_current.size()
911
+ if nH1 != nH2:
912
+ logger.info(f"Error in loading {k}, passing")
913
+ else:
914
+ if L1 != L2:
915
+ logger.info(
916
+ "=> load_pretrained: resized variant: {} to {}".format(
917
+ (L1, nH1), (L2, nH2)
918
+ )
919
+ )
920
+ S1 = int(L1**0.5)
921
+ S2 = int(L2**0.5)
922
+ relative_position_bias_table_pretrained_resized = (
923
+ torch.nn.functional.interpolate(
924
+ relative_position_bias_table_pretrained.permute(
925
+ 1, 0
926
+ ).view(1, nH1, S1, S1),
927
+ size=(S2, S2),
928
+ mode="bicubic",
929
+ )
930
+ )
931
+ v = relative_position_bias_table_pretrained_resized.view(
932
+ nH2, L2
933
+ ).permute(
934
+ 1, 0
935
+ )
936
+
937
+ if "absolute_pos_embed" in k and v.size() != model_dict[k].size():
938
+ absolute_pos_embed_pretrained = v
939
+ absolute_pos_embed_current = model_dict[k]
940
+ _, L1, C1 = absolute_pos_embed_pretrained.size()
941
+ _, L2, C2 = absolute_pos_embed_current.size()
942
+ if C1 != C1:
943
+ logger.info(f"Error in loading {k}, passing")
944
+ else:
945
+ if L1 != L2:
946
+ logger.info(
947
+ "=> load_pretrained: resized variant: {} to {}".format(
948
+ (1, L1, C1), (1, L2, C2)
949
+ )
950
+ )
951
+ S1 = int(L1**0.5)
952
+ S2 = int(L2**0.5)
953
+ absolute_pos_embed_pretrained = (
954
+ absolute_pos_embed_pretrained.reshape(
955
+ -1, S1, S1, C1
956
+ )
957
+ )
958
+ absolute_pos_embed_pretrained = (
959
+ absolute_pos_embed_pretrained.permute(0, 3, 1, 2)
960
+ )
961
+ absolute_pos_embed_pretrained_resized = (
962
+ torch.nn.functional.interpolate(
963
+ absolute_pos_embed_pretrained,
964
+ size=(S2, S2),
965
+ mode="bicubic",
966
+ )
967
+ )
968
+ v = absolute_pos_embed_pretrained_resized.permute(
969
+ 0, 2, 3, 1
970
+ ).flatten(1, 2)
971
+
972
+ need_init_state_dict[k] = v
973
+ self.load_state_dict(need_init_state_dict, strict=False)
974
+
975
+ def freeze_pretrained_layers(self, frozen_layers=[]):
976
+ for name, module in self.named_modules():
977
+ if (
978
+ name.split(".")[0] in frozen_layers
979
+ or ".".join(name.split(".")[0:2]) in frozen_layers
980
+ or (len(frozen_layers) > 0 and frozen_layers[0] == "*")
981
+ ):
982
+ for _name, param in module.named_parameters():
983
+ param.requires_grad = False
984
+ logger.info("=> set param {} requires grad to False".format(name))
985
+ for name, param in self.named_parameters():
986
+ if (
987
+ name.split(".")[0] in frozen_layers
988
+ or (len(frozen_layers) > 0 and frozen_layers[0] == "*")
989
+ and param.requires_grad is True
990
+ ):
991
+ param.requires_grad = False
992
+ logger.info("=> set param {} requires grad to False".format(name))
993
+ return self
994
+
995
+ def get_num_layers(self):
996
+ # return len(self.layers)
997
+ return sum(self.depths)
998
+
999
+ def mask_model(self, x, mask):
1000
+ # extend mask for hierarchical features
1001
+ if x.shape[-2:] != mask.shape[-2:]:
1002
+ htimes, wtimes = np.array(x.shape[-2:]) // np.array(mask.shape[-2:])
1003
+ mask = mask.repeat_interleave(htimes, -2).repeat_interleave(wtimes, -1)
1004
+
1005
+ # mask embed
1006
+ x.permute(0, 2, 3, 1)[mask, :] = self.masked_embed.to(x.dtype)
1007
+
1008
+ return x
1009
+
1010
+
1011
+ def swin_tiny(window_size=7, **kwargs):
1012
+ model = SwinTransformer(
1013
+ window_size=window_size,
1014
+ embed_dim=96,
1015
+ depths=[2, 2, 6, 2],
1016
+ num_heads=[3, 6, 12, 24],
1017
+ mlp_ratio=4,
1018
+ qkv_bias=True,
1019
+ drop_path_rate=kwargs.pop("drop_path_rate", 0.1),
1020
+ **kwargs,
1021
+ )
1022
+ return model
1023
+
1024
+
1025
+ def swin_small(window_size=7, **kwargs):
1026
+ model = SwinTransformer(
1027
+ window_size=window_size,
1028
+ embed_dim=96,
1029
+ depths=[2, 2, 18, 2],
1030
+ num_heads=[3, 6, 12, 24],
1031
+ mlp_ratio=4,
1032
+ qkv_bias=True,
1033
+ drop_path_rate=kwargs.pop("drop_path_rate", 0.2),
1034
+ **kwargs,
1035
+ )
1036
+ return model
1037
+
1038
+
1039
+ def swin_base(window_size=7, **kwargs):
1040
+ model = SwinTransformer(
1041
+ window_size=window_size,
1042
+ embed_dim=128,
1043
+ depths=[2, 2, 18, 2],
1044
+ num_heads=[4, 8, 16, 32],
1045
+ mlp_ratio=4,
1046
+ qkv_bias=True,
1047
+ drop_path_rate=kwargs.pop("drop_path_rate", 0.2),
1048
+ **kwargs,
1049
+ )
1050
+ return model
skinmap_runtime/core/models/encoders/utils.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Callable, Tuple
2
+
3
+ from torchvision import models as torchvision_models
4
+
5
+ from ....src.models.encoders.swin_transformer import swin_base, swin_small, swin_tiny
6
+ from ....src.models.encoders.vision_transformer import (
7
+ vit_base,
8
+ vit_large,
9
+ vit_small,
10
+ vit_tiny,
11
+ )
12
+ from ....src.models.utils import ModelType
13
+
14
+ VIT_DICT = {
15
+ "vit_tiny": vit_tiny,
16
+ "vit_small": vit_small,
17
+ "vit_base": vit_base,
18
+ "vit_large": vit_large,
19
+ "swin_tiny": swin_tiny,
20
+ "swin_small": swin_small,
21
+ "swin_base": swin_base,
22
+ }
23
+
24
+
25
+ def get_encoder_class(base_model_name: str) -> Tuple[Callable, ModelType]:
26
+ encoder_cls = VIT_DICT.get(base_model_name, None)
27
+ model_type = ModelType.VIT
28
+ if encoder_cls is None:
29
+ if base_model_name in torchvision_models.__dict__.keys():
30
+ encoder_cls = torchvision_models.__dict__[base_model_name]
31
+ model_type = ModelType.CNN
32
+ else:
33
+ raise ValueError(f"Invalid base model name: {base_model_name}")
34
+ return encoder_cls, model_type
skinmap_runtime/core/models/encoders/vision_transformer.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mostly copy-paste from timm library.
3
+ https://github.com/rwightman/pytorch-image-models/blob/master/timm/models/vision_transformer.py
4
+ """
5
+
6
+ import math
7
+ from functools import partial
8
+ from typing import List
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+ from ..utils import DropPath, trunc_normal_
14
+
15
+
16
+ class Mlp(nn.Module):
17
+ def __init__(
18
+ self,
19
+ in_features,
20
+ hidden_features=None,
21
+ out_features=None,
22
+ act_layer=nn.GELU,
23
+ drop=0.0,
24
+ ):
25
+ super().__init__()
26
+ out_features = out_features or in_features
27
+ hidden_features = hidden_features or in_features
28
+ self.fc1 = nn.Linear(in_features, hidden_features)
29
+ self.act = act_layer()
30
+ self.fc2 = nn.Linear(hidden_features, out_features)
31
+ self.drop = nn.Dropout(drop)
32
+
33
+ def forward(self, x):
34
+ x = self.fc1(x)
35
+ x = self.act(x)
36
+ x = self.drop(x)
37
+ x = self.fc2(x)
38
+ x = self.drop(x)
39
+ return x
40
+
41
+
42
+ class Attention(nn.Module):
43
+ def __init__(
44
+ self,
45
+ dim,
46
+ num_heads=8,
47
+ qkv_bias=False,
48
+ qk_scale=None,
49
+ attn_drop=0.0,
50
+ proj_drop=0.0,
51
+ ):
52
+ super().__init__()
53
+ self.num_heads = num_heads
54
+ head_dim = dim // num_heads
55
+ self.scale = qk_scale or head_dim**-0.5
56
+
57
+ self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
58
+ self.attn_drop = nn.Dropout(attn_drop)
59
+ self.proj = nn.Linear(dim, dim)
60
+ self.proj_drop = nn.Dropout(proj_drop)
61
+
62
+ def forward(self, x):
63
+ B, N, C = x.shape
64
+ qkv = (
65
+ self.qkv(x)
66
+ .reshape(B, N, 3, self.num_heads, C // self.num_heads)
67
+ .permute(2, 0, 3, 1, 4)
68
+ )
69
+ q, k, v = qkv[0], qkv[1], qkv[2]
70
+
71
+ attn = (q @ k.transpose(-2, -1)) * self.scale
72
+ attn = attn.softmax(dim=-1)
73
+ attn = self.attn_drop(attn)
74
+
75
+ x = (attn @ v).transpose(1, 2).reshape(B, N, C)
76
+ x = self.proj(x)
77
+ x = self.proj_drop(x)
78
+ return x, attn
79
+
80
+
81
+ class Block(nn.Module):
82
+ def __init__(
83
+ self,
84
+ dim,
85
+ num_heads,
86
+ mlp_ratio=4.0,
87
+ qkv_bias=False,
88
+ qk_scale=None,
89
+ drop=0.0,
90
+ attn_drop=0.0,
91
+ drop_path=0.0,
92
+ act_layer=nn.GELU,
93
+ norm_layer=nn.LayerNorm,
94
+ ):
95
+ super().__init__()
96
+ self.norm1 = norm_layer(dim)
97
+ self.attn = Attention(
98
+ dim,
99
+ num_heads=num_heads,
100
+ qkv_bias=qkv_bias,
101
+ qk_scale=qk_scale,
102
+ attn_drop=attn_drop,
103
+ proj_drop=drop,
104
+ )
105
+ self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
106
+ self.norm2 = norm_layer(dim)
107
+ mlp_hidden_dim = int(dim * mlp_ratio)
108
+ self.mlp = Mlp(
109
+ in_features=dim,
110
+ hidden_features=mlp_hidden_dim,
111
+ act_layer=act_layer,
112
+ drop=drop,
113
+ )
114
+
115
+ def forward(self, x, return_attention=False):
116
+ y, attn = self.attn(self.norm1(x))
117
+ if return_attention:
118
+ return attn
119
+ x = x + self.drop_path(y)
120
+ x = x + self.drop_path(self.mlp(self.norm2(x)))
121
+ return x
122
+
123
+
124
+ class PatchEmbed(nn.Module):
125
+ """Image to Patch Embedding"""
126
+
127
+ def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
128
+ super().__init__()
129
+ num_patches = (img_size // patch_size) * (img_size // patch_size)
130
+ self.img_size = img_size
131
+ self.patch_size = patch_size
132
+ self.num_patches = num_patches
133
+
134
+ self.proj = nn.Conv2d(
135
+ in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
136
+ )
137
+
138
+ def forward(self, x):
139
+ B, C, H, W = x.shape
140
+ return self.proj(x)
141
+
142
+
143
+ class VisionTransformer(nn.Module):
144
+ """Vision Transformer"""
145
+
146
+ def __init__(
147
+ self,
148
+ img_size: List[int] = [224],
149
+ patch_size: int = 16,
150
+ in_channels: int = 3,
151
+ num_classes: int = 0,
152
+ embed_dim: int = 768,
153
+ depth: int = 12,
154
+ num_heads: int = 12,
155
+ mlp_ratio: float = 4.0,
156
+ qkv_bias: bool = False,
157
+ qk_scale=None,
158
+ drop_rate: float = 0.0,
159
+ attn_drop_rate: float = 0.0,
160
+ drop_path_rate: float = 0.0,
161
+ norm_layer: nn.Module = nn.LayerNorm,
162
+ return_all_tokens: bool = False,
163
+ masked_im_modeling: bool = False,
164
+ **kwargs
165
+ ):
166
+ super().__init__()
167
+ self.num_features = self.embed_dim = embed_dim
168
+ self.return_all_tokens = return_all_tokens
169
+
170
+ self.patch_embed = PatchEmbed(
171
+ img_size=img_size[0],
172
+ patch_size=patch_size,
173
+ in_channels=in_channels,
174
+ embed_dim=embed_dim,
175
+ )
176
+ num_patches = self.patch_embed.num_patches
177
+
178
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
179
+ self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
180
+ self.pos_drop = nn.Dropout(p=drop_rate)
181
+ # stochastic depth decay rule
182
+ dpr = [x.item() for x in torch.linspace(0, drop_path_rate, depth)]
183
+ self.blocks = nn.ModuleList(
184
+ [
185
+ Block(
186
+ dim=embed_dim,
187
+ num_heads=num_heads,
188
+ mlp_ratio=mlp_ratio,
189
+ qkv_bias=qkv_bias,
190
+ qk_scale=qk_scale,
191
+ drop=drop_rate,
192
+ attn_drop=attn_drop_rate,
193
+ drop_path=dpr[i],
194
+ norm_layer=norm_layer,
195
+ )
196
+ for i in range(depth)
197
+ ]
198
+ )
199
+ self.norm = norm_layer(embed_dim)
200
+
201
+ # Classifier head
202
+ if num_classes > 0:
203
+ self.head = nn.Linear(embed_dim, num_classes)
204
+ else:
205
+ self.head = nn.Identity()
206
+
207
+ trunc_normal_(self.pos_embed, std=0.02)
208
+ trunc_normal_(self.cls_token, std=0.02)
209
+ self.apply(self._init_weights)
210
+
211
+ # Masked image modeling (MIM)
212
+ self.masked_im_modeling = masked_im_modeling
213
+ if masked_im_modeling:
214
+ self.masked_embed = nn.Parameter(torch.zeros(1, embed_dim))
215
+
216
+ def _init_weights(self, m):
217
+ if isinstance(m, nn.Linear):
218
+ trunc_normal_(m.weight, std=0.02)
219
+ if isinstance(m, nn.Linear) and m.bias is not None:
220
+ nn.init.constant_(m.bias, 0)
221
+ elif isinstance(m, nn.LayerNorm):
222
+ nn.init.constant_(m.bias, 0)
223
+ nn.init.constant_(m.weight, 1.0)
224
+
225
+ def interpolate_pos_encoding(self, x, w, h):
226
+ n_patch = x.shape[1] - 1
227
+ N = self.pos_embed.shape[1] - 1
228
+ if n_patch == N and w == h:
229
+ return self.pos_embed
230
+ class_pos_embed = self.pos_embed[:, 0]
231
+ patch_pos_embed = self.pos_embed[:, 1:]
232
+ dim = x.shape[-1]
233
+ w0 = w // self.patch_embed.patch_size
234
+ h0 = h // self.patch_embed.patch_size
235
+ # we add a small number to avoid floating point error in the interpolation
236
+ # see discussion at https://github.com/facebookresearch/dino/issues/8
237
+ w0, h0 = w0 + 0.1, h0 + 0.1
238
+ patch_pos_embed = nn.functional.interpolate(
239
+ patch_pos_embed.reshape(
240
+ 1, int(math.sqrt(N)), int(math.sqrt(N)), dim
241
+ ).permute(0, 3, 1, 2),
242
+ scale_factor=(w0 / math.sqrt(N), h0 / math.sqrt(N)),
243
+ mode="bicubic",
244
+ )
245
+ assert (
246
+ int(w0) == patch_pos_embed.shape[-2]
247
+ and int(h0) == patch_pos_embed.shape[-1]
248
+ )
249
+ patch_pos_embed = patch_pos_embed.permute(0, 2, 3, 1).view(1, -1, dim)
250
+ return torch.cat((class_pos_embed.unsqueeze(0), patch_pos_embed), dim=1)
251
+
252
+ def prepare_tokens(self, x, mask=None):
253
+ B, nc, w, h = x.shape
254
+ # patch linear embedding
255
+ x = self.patch_embed(x)
256
+
257
+ # MIM
258
+ if mask is not None:
259
+ x = self.mask_model(x, mask)
260
+ x = x.flatten(2).transpose(1, 2)
261
+
262
+ # add the [CLS] token to the embed patch tokens
263
+ cls_tokens = self.cls_token.expand(B, -1, -1)
264
+ x = torch.cat((cls_tokens, x), dim=1)
265
+
266
+ # add positional encoding to each token
267
+ x = x + self.interpolate_pos_encoding(x, w, h)
268
+
269
+ return self.pos_drop(x)
270
+
271
+ def forward(self, x: torch.Tensor, return_all_tokens=None, mask=None, **kwargs):
272
+ # MIM
273
+ if self.masked_im_modeling:
274
+ assert mask is not None
275
+ x = self.prepare_tokens(x, mask=mask)
276
+ else:
277
+ x = self.prepare_tokens(x)
278
+ for blk in self.blocks:
279
+ x = blk(x)
280
+ x = self.norm(x)
281
+ return_all_tokens = (
282
+ self.return_all_tokens if return_all_tokens is None else return_all_tokens
283
+ )
284
+ if return_all_tokens:
285
+ return x
286
+
287
+ return x[:, 0]
288
+
289
+ def get_last_selfattention(self, x: torch.Tensor):
290
+ x = self.prepare_tokens(x)
291
+ for i, blk in enumerate(self.blocks):
292
+ if i < len(self.blocks) - 1:
293
+ x = blk(x)
294
+ else:
295
+ # return attention of the last block
296
+ return blk(x, return_attention=True)
297
+
298
+ def get_all_selfattention(self, x: torch.Tensor):
299
+ x = self.prepare_tokens(x)
300
+ attn_out = []
301
+ for blk in self.blocks:
302
+ attn_out.append(blk(x, return_attention=True))
303
+ x = blk(x)
304
+ return attn_out
305
+
306
+ def get_intermediate_layers(self, x: torch.Tensor, n: int = 1):
307
+ x = self.prepare_tokens(x)
308
+ # we return the output tokens from the `n` last blocks
309
+ output = []
310
+ for i, blk in enumerate(self.blocks):
311
+ x = blk(x)
312
+ if len(self.blocks) - i <= n:
313
+ output.append(self.norm(x))
314
+ return output
315
+
316
+ def mask_model(self, x, mask):
317
+ x.permute(0, 2, 3, 1)[mask, :] = self.masked_embed.to(x.dtype)
318
+ return x
319
+
320
+
321
+ def vit_tiny(patch_size: int = 16, **kwargs):
322
+ model = VisionTransformer(
323
+ patch_size=patch_size,
324
+ embed_dim=192,
325
+ depth=12,
326
+ num_heads=3,
327
+ mlp_ratio=4,
328
+ qkv_bias=True,
329
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
330
+ **kwargs
331
+ )
332
+ return model
333
+
334
+
335
+ def vit_small(patch_size: int = 16, **kwargs):
336
+ model = VisionTransformer(
337
+ patch_size=patch_size,
338
+ embed_dim=384,
339
+ depth=12,
340
+ num_heads=6,
341
+ mlp_ratio=4,
342
+ qkv_bias=True,
343
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
344
+ **kwargs
345
+ )
346
+ return model
347
+
348
+
349
+ def vit_base(patch_size: int = 16, **kwargs):
350
+ model = VisionTransformer(
351
+ patch_size=patch_size,
352
+ embed_dim=768,
353
+ depth=12,
354
+ num_heads=12,
355
+ mlp_ratio=4,
356
+ qkv_bias=True,
357
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
358
+ **kwargs
359
+ )
360
+ return model
361
+
362
+
363
+ def vit_large(patch_size: int = 16, **kwargs):
364
+ model = VisionTransformer(
365
+ patch_size=patch_size,
366
+ embed_dim=1024,
367
+ depth=24,
368
+ num_heads=16,
369
+ mlp_ratio=4,
370
+ qkv_bias=True,
371
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
372
+ **kwargs
373
+ )
374
+ return model
skinmap_runtime/core/models/fine_tuning/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
skinmap_runtime/core/models/fine_tuning/classifiers.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+
4
+ class LinearClassifier(nn.Module):
5
+ """Linear layer to train on top of frozen features"""
6
+
7
+ def __init__(
8
+ self,
9
+ dim: int,
10
+ num_labels: int = 1000,
11
+ use_dropout_in_head: bool = False,
12
+ dropout_rate: float = 0.3,
13
+ large_head: bool = True,
14
+ use_bn: bool = False,
15
+ log_softmax: bool = False,
16
+ ):
17
+ super(LinearClassifier, self).__init__()
18
+ self.num_labels = num_labels
19
+ self.large_head = large_head
20
+ self.use_bn = use_bn
21
+ self.log_softmax = log_softmax
22
+ self.use_dropout_in_head = use_dropout_in_head
23
+
24
+ if self.use_dropout_in_head:
25
+ self.dropout = nn.Dropout(dropout_rate)
26
+ if self.use_bn:
27
+ self.bn = nn.BatchNorm1d(dim)
28
+
29
+ if self.large_head:
30
+ self.linear = nn.Linear(dim, 128)
31
+ self.linear.weight.data.normal_(mean=0.0, std=0.01)
32
+ self.linear.bias.data.zero_()
33
+ self.relu = nn.ReLU()
34
+
35
+ self.dropout2 = nn.Dropout(dropout_rate)
36
+ if self.use_bn:
37
+ self.bn2 = nn.BatchNorm1d(128)
38
+
39
+ self.linear2 = nn.Linear(128, num_labels)
40
+ self.linear2.weight.data.normal_(mean=0.0, std=0.01)
41
+ self.linear2.bias.data.zero_()
42
+ else:
43
+ self.linear = nn.Linear(dim, num_labels)
44
+ self.linear.weight.data.normal_(mean=0.0, std=0.01)
45
+ self.linear.bias.data.zero_()
46
+
47
+ def forward(self, x):
48
+ # flatten
49
+ x = x.view(x.size(0), -1)
50
+ # dropout
51
+ if self.use_dropout_in_head:
52
+ x = self.dropout(x)
53
+ if self.use_bn:
54
+ x = self.bn(x)
55
+ # 1. linear layer
56
+ x = self.linear(x)
57
+ # smaller version of head
58
+ if self.large_head:
59
+ x = self.relu(x)
60
+ x = self.dropout2(x)
61
+ if self.use_bn:
62
+ x = self.bn2(x)
63
+ # 2. linear layer
64
+ x = self.linear2(x)
65
+ # output
66
+ if self.log_softmax:
67
+ return nn.LogSoftmax(dim=1)(x)
68
+ else:
69
+ return x
skinmap_runtime/core/models/ibot/__init__.py ADDED
File without changes
skinmap_runtime/core/models/ibot/head.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn as nn
2
+
3
+ from ...models.dino.head import DINOHead
4
+
5
+
6
+ class iBOTHead(DINOHead):
7
+ def __init__(
8
+ self,
9
+ *args,
10
+ patch_out_dim=8192,
11
+ n_layers=3,
12
+ hidden_dim=2048,
13
+ bottleneck_dim=256,
14
+ norm_last_layer=True,
15
+ shared_head=False,
16
+ **kwargs
17
+ ):
18
+ super(iBOTHead, self).__init__(
19
+ *args,
20
+ n_layers=n_layers,
21
+ hidden_dim=hidden_dim,
22
+ bottleneck_dim=bottleneck_dim,
23
+ norm_last_layer=norm_last_layer,
24
+ **kwargs
25
+ )
26
+
27
+ if not shared_head:
28
+ if bottleneck_dim > 0:
29
+ self.last_layer2 = nn.utils.weight_norm(
30
+ nn.Linear(bottleneck_dim, patch_out_dim, bias=False)
31
+ )
32
+ self.last_layer2.weight_g.data.fill_(1)
33
+ if norm_last_layer:
34
+ self.last_layer2.weight_g.requires_grad = False
35
+ else:
36
+ self.mlp2 = nn.Linear(hidden_dim, patch_out_dim)
37
+ self.last_layer2 = None
38
+
39
+ else:
40
+ if bottleneck_dim > 0:
41
+ self.last_layer2 = self.last_layer
42
+ else:
43
+ self.mlp2 = self.mlp[-1]
44
+ self.last_layer2 = None
45
+
46
+ def forward(self, x):
47
+ if len(x.shape) == 2:
48
+ return super(iBOTHead, self).forward(x)
49
+
50
+ if self.last_layer is not None:
51
+ x = self.mlp(x)
52
+ x = nn.functional.normalize(x, dim=-1, p=2)
53
+ x1 = self.last_layer(x[:, 0])
54
+ x2 = self.last_layer2(x[:, 1:])
55
+ else:
56
+ x = self.mlp[:-1](x)
57
+ x1 = self.mlp[-1](x[:, 0])
58
+ x2 = self.mlp2(x[:, 1:])
59
+
60
+ return x1, x2
61
+
62
+ def _build_norm(self, norm, hidden_dim, **kwargs):
63
+ if norm == "bn":
64
+ norm = nn.BatchNorm1d(hidden_dim, **kwargs)
65
+ elif norm == "syncbn":
66
+ norm = nn.SyncBatchNorm(hidden_dim, **kwargs)
67
+ elif norm == "ln":
68
+ norm = nn.LayerNorm(hidden_dim, **kwargs)
69
+ else:
70
+ assert norm is None, "unknown norm type {}".format(norm)
71
+ return norm
72
+
73
+ def _build_act(self, act):
74
+ if act == "relu":
75
+ act = nn.ReLU()
76
+ elif act == "gelu":
77
+ act = nn.GELU()
78
+ else:
79
+ assert False, "unknown act type {}".format(act)
80
+ return act
skinmap_runtime/core/models/mae/__init__.py ADDED
File without changes
skinmap_runtime/core/models/mae/model.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+ from typing import List
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+
7
+ from ..encoders.vision_transformer import Block, VisionTransformer
8
+ from ..utils import get_2d_sincos_pos_embed
9
+
10
+
11
+ class MaskedAutoencoderViT(VisionTransformer):
12
+ """Masked Autoencoder with VisionTransformer backbone."""
13
+
14
+ def __init__(
15
+ self,
16
+ img_size: List[int] = [224],
17
+ patch_size: int = 16,
18
+ in_channels: int = 3,
19
+ num_classes: int = 0,
20
+ embed_dim: int = 768,
21
+ depth: int = 12,
22
+ num_heads: int = 12,
23
+ mlp_ratio: float = 4.0,
24
+ qkv_bias: bool = False,
25
+ qk_scale=None,
26
+ drop_rate: float = 0.0,
27
+ attn_drop_rate: float = 0.0,
28
+ drop_path_rate: float = 0.0,
29
+ norm_layer: nn.Module = nn.LayerNorm,
30
+ return_all_tokens: bool = False,
31
+ masked_im_modeling: bool = False,
32
+ decoder_embed_dim: int = 512,
33
+ decoder_depth: int = 8,
34
+ decoder_num_heads: int = 16,
35
+ ):
36
+ super().__init__(
37
+ img_size=img_size,
38
+ patch_size=patch_size,
39
+ in_channels=in_channels,
40
+ num_classes=num_classes,
41
+ embed_dim=embed_dim,
42
+ depth=depth,
43
+ num_heads=num_heads,
44
+ mlp_ratio=mlp_ratio,
45
+ qkv_bias=qkv_bias,
46
+ qk_scale=qk_scale,
47
+ drop_rate=drop_rate,
48
+ attn_drop_rate=attn_drop_rate,
49
+ drop_path_rate=drop_path_rate,
50
+ norm_layer=norm_layer,
51
+ return_all_tokens=return_all_tokens,
52
+ masked_im_modeling=masked_im_modeling,
53
+ )
54
+ num_patches = self.patch_embed.num_patches
55
+ # fixed sin-cos embedding
56
+ self.pos_embed.requires_grad = False
57
+
58
+ # MAE decoder specifics
59
+ self.decoder_embed = nn.Linear(embed_dim, decoder_embed_dim, bias=True)
60
+ self.mask_token = nn.Parameter(torch.zeros(1, 1, decoder_embed_dim))
61
+ # fixed sin-cos embedding
62
+ self.decoder_pos_embed = nn.Parameter(
63
+ data=torch.zeros(1, num_patches + 1, decoder_embed_dim),
64
+ requires_grad=False,
65
+ )
66
+ self.decoder_blocks = nn.ModuleList(
67
+ [
68
+ Block(
69
+ dim=decoder_embed_dim,
70
+ num_heads=decoder_num_heads,
71
+ mlp_ratio=mlp_ratio,
72
+ qkv_bias=True,
73
+ qk_scale=None,
74
+ norm_layer=norm_layer,
75
+ )
76
+ for _ in range(decoder_depth)
77
+ ]
78
+ )
79
+ # decoder to patch
80
+ self.decoder_norm = norm_layer(decoder_embed_dim)
81
+ self.decoder_pred = nn.Linear(
82
+ decoder_embed_dim,
83
+ patch_size**2 * in_channels,
84
+ bias=True,
85
+ )
86
+ self.initialize_weights()
87
+
88
+ def initialize_weights(self):
89
+ # initialize (and freeze) pos_embed by sin-cos embedding
90
+ pos_embed = get_2d_sincos_pos_embed(
91
+ self.pos_embed.shape[-1],
92
+ int(self.patch_embed.num_patches**0.5),
93
+ cls_token=True,
94
+ )
95
+ self.pos_embed.data.copy_(torch.from_numpy(pos_embed).float().unsqueeze(0))
96
+
97
+ decoder_pos_embed = get_2d_sincos_pos_embed(
98
+ self.decoder_pos_embed.shape[-1],
99
+ int(self.patch_embed.num_patches**0.5),
100
+ cls_token=True,
101
+ )
102
+ self.decoder_pos_embed.data.copy_(
103
+ torch.from_numpy(decoder_pos_embed).float().unsqueeze(0)
104
+ )
105
+
106
+ # initialize patch_embed like nn.Linear (instead of nn.Conv2d)
107
+ w = self.patch_embed.proj.weight.data
108
+ torch.nn.init.xavier_uniform_(w.view([w.shape[0], -1]))
109
+
110
+ # timm's trunc_normal_(std=.02) is effectively normal_(std=0.02) as cutoff is too big (2.)
111
+ torch.nn.init.normal_(self.cls_token, std=0.02)
112
+ torch.nn.init.normal_(self.mask_token, std=0.02)
113
+
114
+ # initialize nn.Linear and nn.LayerNorm
115
+ self.apply(self._init_weights)
116
+
117
+ def _init_weights(self, m):
118
+ if isinstance(m, nn.Linear):
119
+ # we use xavier_uniform following official JAX ViT:
120
+ torch.nn.init.xavier_uniform_(m.weight)
121
+ if isinstance(m, nn.Linear) and m.bias is not None:
122
+ nn.init.constant_(m.bias, 0)
123
+ elif isinstance(m, nn.LayerNorm):
124
+ nn.init.constant_(m.bias, 0)
125
+ nn.init.constant_(m.weight, 1.0)
126
+
127
+ def prepare_tokens(
128
+ self,
129
+ x: torch.Tensor,
130
+ mask_ratio: float = 0.0,
131
+ return_helpers: bool = False,
132
+ **kwargs,
133
+ ):
134
+ # embed patches
135
+ x = self.patch_embed(x)
136
+ x = x.flatten(2).transpose(1, 2)
137
+
138
+ # add pos embed w/o cls token
139
+ x = x + self.pos_embed[:, 1:, :]
140
+
141
+ # Perform per-sample random masking by per-sample shuffling.
142
+ # Per-sample shuffling is done by argsort random noise.
143
+ N, N_PATCHES, D = x.shape
144
+ len_keep = int(N_PATCHES * (1 - mask_ratio))
145
+
146
+ # generate random noise for the selection (in [0, 1])
147
+ noise = torch.rand(N, N_PATCHES, device=x.device)
148
+ # sort noise for each sample (ascend: small is keep, large is remove)
149
+ ids_shuffle = torch.argsort(noise, dim=1)
150
+ # IDs to restore the mask (used for the decoder)
151
+ ids_restore = torch.argsort(ids_shuffle, dim=1)
152
+
153
+ # keep the first subset (random ids to keep from every sample)
154
+ ids_keep = ids_shuffle[:, :len_keep]
155
+ # reshape for alignment with "x"
156
+ ids_keep = ids_keep.unsqueeze(-1).repeat(1, 1, D)
157
+ # masked input
158
+ x_masked = torch.gather(x, dim=1, index=ids_keep)
159
+
160
+ # generate the binary mask: 0 is keep, 1 is remove
161
+ mask = torch.ones([N, N_PATCHES], device=x.device)
162
+ mask[:, :len_keep] = 0
163
+ # unshuffle to get the binary mask
164
+ # binary mask used to create "x_masked"
165
+ mask = torch.gather(mask, dim=1, index=ids_restore)
166
+
167
+ # append cls token
168
+ cls_token = self.cls_token + self.pos_embed[:, :1, :]
169
+ cls_tokens = cls_token.expand(x_masked.shape[0], -1, -1)
170
+ x_masked = torch.cat((cls_tokens, x_masked), dim=1)
171
+
172
+ if return_helpers:
173
+ return x_masked, mask, ids_restore
174
+ else:
175
+ return x_masked
176
+
177
+ def forward_decoder(self, x: torch.Tensor, ids_restore: torch.Tensor):
178
+ # embed tokens
179
+ x = self.decoder_embed(x)
180
+
181
+ # append mask tokens to sequence
182
+ mask_tokens = self.mask_token.repeat(
183
+ x.shape[0], ids_restore.shape[1] + 1 - x.shape[1], 1
184
+ )
185
+ x_ = torch.cat([x[:, 1:, :], mask_tokens], dim=1) # no cls token
186
+ x_ = torch.gather(
187
+ x_, dim=1, index=ids_restore.unsqueeze(-1).repeat(1, 1, x.shape[2])
188
+ ) # unshuffle
189
+ x = torch.cat([x[:, :1, :], x_], dim=1) # append cls token
190
+
191
+ # add pos embed
192
+ x = x + self.decoder_pos_embed
193
+
194
+ # apply Transformer blocks
195
+ for blk in self.decoder_blocks:
196
+ x = blk(x)
197
+ x = self.decoder_norm(x)
198
+
199
+ # predictor projection
200
+ x = self.decoder_pred(x)
201
+
202
+ # remove cls token
203
+ x = x[:, 1:, :]
204
+
205
+ return x
206
+
207
+ def forward(
208
+ self,
209
+ imgs: torch.Tensor,
210
+ mask_ratio: float = 0.75,
211
+ return_all_tokens=None,
212
+ **kwargs,
213
+ ):
214
+ x, mask, ids_restore = self.prepare_tokens(
215
+ x=imgs,
216
+ mask_ratio=mask_ratio,
217
+ return_helpers=True,
218
+ )
219
+ for blk in self.blocks:
220
+ x = blk(x)
221
+ latent = self.norm(x)
222
+ # [N, N_PATCHES, p*p*3]
223
+ pred = self.forward_decoder(x=latent, ids_restore=ids_restore)
224
+ return_all_tokens = (
225
+ self.return_all_tokens if return_all_tokens is None else return_all_tokens
226
+ )
227
+ if return_all_tokens:
228
+ return imgs, pred, mask, latent
229
+ return imgs, pred, mask, latent[:, 0]
230
+
231
+
232
+ def masked_vit_tiny(patch_size: int = 16, **kwargs):
233
+ model = MaskedAutoencoderViT(
234
+ patch_size=patch_size,
235
+ embed_dim=192,
236
+ depth=12,
237
+ num_heads=3,
238
+ mlp_ratio=4,
239
+ qkv_bias=True,
240
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
241
+ decoder_embed_dim=512,
242
+ decoder_depth=8,
243
+ decoder_num_heads=16,
244
+ **kwargs,
245
+ )
246
+ return model
247
+
248
+
249
+ def masked_vit_small(patch_size: int = 16, **kwargs):
250
+ model = MaskedAutoencoderViT(
251
+ patch_size=patch_size,
252
+ embed_dim=384,
253
+ depth=12,
254
+ num_heads=6,
255
+ mlp_ratio=4,
256
+ qkv_bias=True,
257
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
258
+ decoder_embed_dim=512,
259
+ decoder_depth=8,
260
+ decoder_num_heads=16,
261
+ **kwargs,
262
+ )
263
+ return model
264
+
265
+
266
+ def masked_vit_base(patch_size: int = 16, **kwargs):
267
+ model = MaskedAutoencoderViT(
268
+ patch_size=patch_size,
269
+ embed_dim=768,
270
+ depth=12,
271
+ num_heads=12,
272
+ mlp_ratio=4,
273
+ qkv_bias=True,
274
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
275
+ decoder_embed_dim=512,
276
+ decoder_depth=8,
277
+ decoder_num_heads=16,
278
+ **kwargs,
279
+ )
280
+ return model
281
+
282
+
283
+ def masked_vit_large(patch_size: int = 16, **kwargs):
284
+ model = MaskedAutoencoderViT(
285
+ patch_size=patch_size,
286
+ embed_dim=1024,
287
+ depth=24,
288
+ num_heads=16,
289
+ mlp_ratio=4,
290
+ qkv_bias=True,
291
+ norm_layer=partial(nn.LayerNorm, eps=1e-6),
292
+ decoder_embed_dim=512,
293
+ decoder_depth=8,
294
+ decoder_num_heads=16,
295
+ **kwargs,
296
+ )
297
+ return model
skinmap_runtime/core/models/mae/utils.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Callable, Tuple
2
+
3
+ import torch
4
+
5
+ from ....src.models.utils import ModelType
6
+ from .model import masked_vit_base, masked_vit_large, masked_vit_small, masked_vit_tiny
7
+
8
+
9
+ def patch_images(images: torch.Tensor, patch_size: int):
10
+ """
11
+ Transforms images into patched images.
12
+
13
+ imgs: (N, C, H, W)
14
+ x: (N, #patches, patch_size**2 * C)
15
+ """
16
+ # make sure the image properties are correct
17
+ img_is_square = images.shape[2] == images.shape[3]
18
+ img_is_patchable = images.shape[2] % patch_size == 0
19
+ assert img_is_square and img_is_patchable
20
+
21
+ channels = images.shape[1]
22
+ h = w = images.shape[2] // patch_size
23
+ x = images.reshape(shape=(images.shape[0], channels, h, patch_size, w, patch_size))
24
+ x = torch.einsum("nchpwq->nhwpqc", x)
25
+ x = x.reshape(shape=(images.shape[0], h * w, patch_size**2 * channels))
26
+ return x
27
+
28
+
29
+ def unpatch_images(x: torch.Tensor, patch_size: int):
30
+ """
31
+ Transforms patched images into images.
32
+
33
+ x: (N, #patches, patch_size**2 * C)
34
+ imgs: (N, C, H, W)
35
+ """
36
+ h = w = int(x.shape[1] ** 0.5)
37
+ assert h * w == x.shape[1]
38
+
39
+ x = x.reshape(shape=(x.shape[0], h, w, patch_size, patch_size, 3))
40
+ x = torch.einsum("nhwpqc->nchpwq", x)
41
+ imgs = x.reshape(shape=(x.shape[0], 3, h * patch_size, h * patch_size))
42
+ return imgs
43
+
44
+
45
+ MASKED_VIT_DICT = {
46
+ "masked_vit_tiny": masked_vit_tiny,
47
+ "masked_vit_small": masked_vit_small,
48
+ "masked_vit_base": masked_vit_base,
49
+ "masked_vit_large": masked_vit_large,
50
+ }
51
+
52
+
53
+ def get_model_class(base_model_name: str) -> Tuple[Callable, ModelType]:
54
+ encoder_cls = MASKED_VIT_DICT.get(base_model_name, None)
55
+ model_type = ModelType.VIT
56
+ if encoder_cls is None:
57
+ raise ValueError(f"Invalid base model name: {base_model_name}")
58
+ return encoder_cls, model_type
skinmap_runtime/core/models/simclr/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
skinmap_runtime/core/models/simclr/model.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn as nn
2
+ import torch.nn.functional as F
3
+
4
+ from ..encoders.utils import get_encoder_class
5
+ from ..utils import ModelType
6
+
7
+
8
+ class ResNetSimCLR(nn.Module):
9
+ def __init__(self, base_model: str, out_dim: int, **kwargs):
10
+ super(ResNetSimCLR, self).__init__()
11
+ encoder_cls, model_type = get_encoder_class(base_model)
12
+ if model_type is ModelType.VIT:
13
+ self.backbone = encoder_cls(**kwargs)
14
+ n_feat = self.backbone.embed_dim
15
+ elif model_type is ModelType.CNN:
16
+ encoder = encoder_cls(**kwargs)
17
+ n_feat = encoder.fc.in_features
18
+ self.backbone = nn.Sequential(*list(encoder.children())[:-1])
19
+ else:
20
+ raise ValueError(f"Unknown model type: {model_type}")
21
+ # projection MLP
22
+ self.dense1 = nn.Linear(n_feat, n_feat)
23
+ self.dense2 = nn.Linear(n_feat, out_dim)
24
+
25
+ def forward(self, z):
26
+ # embed
27
+ e = self.backbone(z)
28
+ e = e.squeeze()
29
+ # project
30
+ z = self.dense1(e)
31
+ z = F.relu(z)
32
+ z = self.dense2(z)
33
+ return e, z