File size: 14,804 Bytes
582bc39 46ddfed 582bc39 50f147b 582bc39 1f30310 8efed88 582bc39 50f147b 46ddfed 50f147b 46ddfed 50f147b 1f30310 8efed88 50f147b 6c48780 1f30310 50f147b 1f30310 46ddfed 028c8ae 582bc39 46ddfed 50f147b 028c8ae 8efed88 028c8ae 582bc39 028c8ae 8efed88 582bc39 46ddfed 582bc39 46ddfed 582bc39 8efed88 0167383 8efed88 46ddfed 8efed88 028c8ae 582bc39 8efed88 582bc39 028c8ae 582bc39 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 46ddfed 50f147b 582bc39 50f147b 582bc39 46ddfed 8efed88 50f147b 8efed88 46ddfed 8efed88 46ddfed 028c8ae 50f147b 33f9765 50f147b 33f9765 582bc39 8efed88 50f147b 8efed88 50f147b 9163e1b 582bc39 50f147b 582bc39 46ddfed 582bc39 50f147b 582bc39 50f147b 582bc39 46ddfed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | """
model_loader.py
Downloads weights from HF_WEIGHTS_REPO once, loads all models into memory.
Key guarantees
ββββββββββββββ
1. PYEOF removed (was a SyntaxError that silently broke everything).
2. Sentinel file (.load_ok) written after first successful download.
On container restart the sentinel + file-size checks bypass the
download entirely so only the torch.load / model.eval() work remains.
3. calibration.pkl is unpickled via three independent strategies so it
never raises even when saved from a different Python __main__ context.
4. Thread-safe: only one goroutine ever calls load_all_models().
"""
import logging, os, pickle, sys, threading, types
from pathlib import Path
from typing import Optional, Tuple
import torch
from huggingface_hub import hf_hub_download
import model_classes
from model_classes import CalibrationBundle, DRModel, FundusValidator, NUM_CLASSES
logger = logging.getLogger("model_loader")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Patch sys.modules["__main__"] at import time β must happen before any pickle
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _patch_main_now():
main = sys.modules.get("__main__")
if main is not None:
for _name in ["CalibrationBundle", "GeM", "CoralHead", "DRHead",
"DRModel", "FundusValidator"]:
if not hasattr(main, _name):
setattr(main, _name, getattr(model_classes, _name, None))
shim = types.ModuleType("__main__")
for _name in dir(model_classes):
setattr(shim, _name, getattr(model_classes, _name))
cur = sys.modules.get("__main__")
if cur is not None and not hasattr(cur, "CalibrationBundle"):
sys.modules["__main__"] = shim
_patch_main_now() # runs at import time
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Custom Unpickler β triple-checks every find_class call
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_CLASS_MAP = {
"CalibrationBundle": model_classes.CalibrationBundle,
"GeM": model_classes.GeM,
"CoralHead": model_classes.CoralHead,
"DRHead": model_classes.DRHead,
"DRModel": model_classes.DRModel,
"FundusValidator": model_classes.FundusValidator,
}
class _Unpickler(pickle.Unpickler):
def find_class(self, module, name):
if name in _CLASS_MAP:
return _CLASS_MAP[name]
if module in ("__main__", "__mp_main__"):
cls = getattr(model_classes, name, None)
if cls is not None:
return cls
return super().find_class(module, name)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Config
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
HF_WEIGHTS_REPO: str = os.environ.get("HF_WEIGHTS_REPO", "Gokul-G1/dr-grading-weights")
HF_TOKEN: Optional[str] = os.environ.get("HF_TOKEN", None)
# /data is HF Persistent Storage (survives container restarts).
# Fall back to /tmp only when no persistent storage is attached.
_STORAGE_ROOT: Path = (
Path("/data/dr_models") if Path("/data").exists() else Path("/tmp/dr_models")
)
_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
# Minimum file sizes (bytes) β reject partial/corrupt downloads
_MIN_SIZES = {
"final_complete_model.pt": 50 * 1024 * 1024, # >50 MB
"fundus_validator.pt": 5 * 1024 * 1024, # >5 MB
"calibration.pkl": 1 * 1024, # >1 KB
}
_SENTINEL = _STORAGE_ROOT / ".load_ok"
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Singletons
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_dr_model: Optional[DRModel] = None
_fundus_val: Optional[FundusValidator] = None
_calibration: Optional[CalibrationBundle] = None
_device: str = "cpu"
_lock = threading.Lock()
_ready: bool = False
def _pick_device() -> str:
if torch.cuda.is_available():
logger.info(f"[loader] GPU: {torch.cuda.get_device_name(0)}")
return "cuda"
logger.info("[loader] No GPU β using CPU")
return "cpu"
def _file_ok(name: str) -> bool:
"""True if file exists on disk and passes minimum-size check."""
p = _STORAGE_ROOT / name
if not p.exists():
return False
size = p.stat().st_size
min_size = _MIN_SIZES.get(name, 1)
if size < min_size:
logger.warning(f"[cache] {name} too small ({size} B < {min_size} B) β will re-download")
p.unlink(missing_ok=True)
return False
return True
def _all_files_ok() -> bool:
return all(_file_ok(n) for n in _MIN_SIZES)
def _download(filename: str) -> Path:
"""Download only if not already cached on disk."""
if _file_ok(filename):
local = _STORAGE_ROOT / filename
logger.info(f"[cache] {filename} ({local.stat().st_size/1e6:.1f} MB) β using cached copy")
return local
logger.info(f"[download] {filename} from {HF_WEIGHTS_REPO} β¦")
# FIX: local_dir_use_symlinks may be removed in newer huggingface_hub β graceful fallback
try:
path = hf_hub_download(
repo_id=HF_WEIGHTS_REPO, filename=filename,
local_dir=str(_STORAGE_ROOT), token=HF_TOKEN,
local_dir_use_symlinks=False,
)
except TypeError:
path = hf_hub_download(
repo_id=HF_WEIGHTS_REPO, filename=filename,
local_dir=str(_STORAGE_ROOT), token=HF_TOKEN,
)
local = Path(path)
if not local.parent.samefile(_STORAGE_ROOT):
# hf_hub_download may have placed it in a subdir β copy flat
dest = _STORAGE_ROOT / filename
dest.write_bytes(local.read_bytes())
local = dest
logger.info(f"[download] done β {local} ({local.stat().st_size/1e6:.1f} MB)")
return local
def _torch_load(path: Path, map_location="cpu"):
try:
return torch.load(str(path), map_location=map_location, weights_only=False)
except TypeError:
return torch.load(str(path), map_location=map_location)
def _load_calibration(cal_path: Path) -> Optional[CalibrationBundle]:
"""
Try every known strategy to load calibration.pkl.
Returns None if all fail β inference continues with raw softmax.
"""
_patch_main_now() # belt and braces
# Strategy 1: custom _Unpickler (handles __main__ pickle artifacts)
try:
with open(cal_path, "rb") as f:
obj = _Unpickler(f).load()
logger.info(f"[loader] calibration loaded via _Unpickler type={type(obj).__name__}")
return obj
except Exception as e1:
logger.warning(f"[loader] _Unpickler failed: {e1}")
# Strategy 2: torch.load (notebook may have used torch.save on a plain dict)
try:
obj = _torch_load(cal_path, map_location="cpu")
logger.info(f"[loader] calibration loaded via torch.load type={type(obj).__name__}")
return obj
except Exception as e2:
logger.warning(f"[loader] torch.load failed: {e2}")
# Strategy 3: plain pickle with force-patched __main__
try:
cur = sys.modules.get("__main__")
if cur is not None:
cur.CalibrationBundle = model_classes.CalibrationBundle
with open(cal_path, "rb") as f:
obj = pickle.load(f)
logger.info(f"[loader] calibration loaded via plain pickle type={type(obj).__name__}")
return obj
except Exception as e3:
logger.warning(f"[loader] plain pickle failed: {e3}")
logger.error("[loader] All calibration strategies failed β inference runs without calibration")
return None
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Main loader β idempotent, thread-safe
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def load_all_models() -> None:
global _dr_model, _fundus_val, _calibration, _device, _ready
with _lock:
if _ready:
return
device = _pick_device()
_device = device
logger.info(f"[loader] device={device} repo={HF_WEIGHTS_REPO} storage={_STORAGE_ROOT}")
# Check sentinel: if it exists all files are already on disk, skip network
if _SENTINEL.exists() and _all_files_ok():
logger.info("[loader] Sentinel present + all files OK β skipping download step")
else:
logger.info("[loader] Downloading model artefacts β¦")
_SENTINEL.unlink(missing_ok=True) # invalidate stale sentinel
# ββ Grading model βββββββββββββββββββββββββββββββββββββββββββββββββββββ
dr_path = _download("final_complete_model.pt")
ckpt = _torch_load(dr_path, map_location="cpu")
if isinstance(ckpt, dict):
backbone = ckpt.get("backbone", "tf_efficientnetv2_m")
state = (ckpt.get("model_state")
or ckpt.get("state_dict")
or ckpt.get("ema_state_dict")
or ckpt)
else:
backbone = "tf_efficientnetv2_m"
state = ckpt
model = DRModel(backbone=backbone, num_classes=NUM_CLASSES, pretrained=False)
miss, unexpected = model.load_state_dict(state, strict=False)
if miss:
# Filter out known non-critical keys
real_miss = [k for k in miss if not k.startswith("_")]
logger.warning(f"[loader] missing keys ({len(real_miss)}): {real_miss[:8]}")
if unexpected:
logger.warning(f"[loader] unexpected keys ({len(unexpected)}): {unexpected[:5]}")
model.eval().to(device)
_dr_model = model
n = sum(p.numel() for p in model.parameters()) / 1e6
logger.info(f"[loader] DRModel ready {n:.1f}M params backbone={backbone}")
# ββ Fundus validator ββββββββββββββββββββββββββββββββββββββββββββββββββ
try:
fv_path = _download("fundus_validator.pt")
fv_ckpt = _torch_load(fv_path, map_location="cpu")
# Robustly extract state dict regardless of how it was saved
if isinstance(fv_ckpt, dict):
# Try every common wrapper key in order
fv_state = (
fv_ckpt.get("model_state")
or fv_ckpt.get("state_dict")
or fv_ckpt.get("model")
or fv_ckpt.get("net")
or None
)
# If none of the wrapper keys matched, the dict IS the state dict
# β but only if it has parameter-shaped values (tensors)
if fv_state is None:
first_val = next(iter(fv_ckpt.values()), None)
if isinstance(first_val, torch.Tensor):
fv_state = fv_ckpt
else:
raise ValueError(
f"Cannot find state dict in fundus_validator.pt "
f"(top-level keys: {list(fv_ckpt.keys())[:8]})"
)
else:
# checkpoint IS the state dict (OrderedDict of tensors)
fv_state = fv_ckpt
fv = FundusValidator(pretrained=False)
missing, unexpected = fv.load_state_dict(fv_state, strict=False)
if missing:
logger.warning(f"[loader] FundusValidator missing keys ({len(missing)}): "
f"{missing[:5]}")
if unexpected:
logger.warning(f"[loader] FundusValidator unexpected keys ({len(unexpected)}): "
f"{unexpected[:5]}")
fv.eval().to(device)
_fundus_val = fv
logger.info("[loader] FundusValidator ready")
except Exception as fv_err:
logger.error(f"[loader] FundusValidator load FAILED: {fv_err} "
"β heuristic-only validation will be used", exc_info=True)
_fundus_val = None # inference.py handles None gracefully
# ββ Calibration bundle ββββββββββββββββββββββββββββββββββββββββββββββββ
cal_path = _download("calibration.pkl")
_calibration = _load_calibration(cal_path) # never raises
# Write sentinel so next restart skips download
_SENTINEL.write_text("ok")
_ready = True
cal_status = type(_calibration).__name__ if _calibration else "SKIPPED (None)"
logger.info(f"[loader] β All models ready calibration={cal_status}")
def get_models() -> Tuple[DRModel, FundusValidator, Optional[CalibrationBundle], str]:
if not _ready:
load_all_models()
return _dr_model, _fundus_val, _calibration, _device |