AndreaNotes1/fgh-dermatology
0
1"""2utils/model_loader.py3======================4Singleton model loader for the FGH-Dermatology inference pipeline.5 6The trained `.h5` Keras model is loaded exactly once at process startup and7cached in a module-level variable, avoiding the cost of re-deserialising the8network on every incoming request.9 10Model sourcing11---------------12Two sourcing strategies are supported, resolved in this order:13 141. HUGGING FACE HUB — if `Config.HF_MODEL_REPO_ID` is set, the `.h5` file is15 downloaded (or read from local cache, on subsequent restarts) from the16 given Hugging Face model repository using `huggingface_hub.hf_hub_download`.17 This is the recommended path for deployment targets with small repo/slug18 size limits (e.g. Render, Heroku) where a multi-hundred-MB model file19 cannot be committed to the Git repository that gets deployed.20 212. LOCAL FILE PATH — if `Config.HF_MODEL_REPO_ID` is not set, the loader22 falls back to reading a local `.h5` file at `Config.MODEL_PATH`. This23 suits local development or any environment without a strict size limit.24 25Demo mode26---------27If neither a Hugging Face repo is configured nor a local file is found28(e.g. a fresh checkout before a clinician has wired up the trained model),29the loader transparently builds and persists a small, randomly-initialised30CNN with the correct architecture shape (a `Conv2D` backbone feeding a31softmax head over `Config.CLASS_LABELS`). This keeps the full application —32UI, upload flow, Grad-CAM, NLP reporting — runnable end-to-end for33evaluation, onboarding, and QA, while making it obvious in the UI that34predictions are not yet backed by a clinically validated model.35"""36 37import logging38import os39 40import numpy as np41import tensorflow as tf42 43from config import Config44 45logger = logging.getLogger("fgh_dermatology.model_loader")46 47_model = None48_is_demo_model = False49_load_error = None50 51 52def _resolve_model_path() -> str:53 """54 Determine the local filesystem path to the `.h5` model, downloading it55 from Hugging Face Hub first if `Config.HF_MODEL_REPO_ID` is configured.56 57 Returns58 -------59 str60 Path to a local `.h5` file, or an empty string if no model could be61 resolved (triggering demo mode in `get_model`).62 """63 if Config.HF_MODEL_REPO_ID:64 try:65 from huggingface_hub import hf_hub_download66 from huggingface_hub.utils import HfHubHTTPError, LocalEntryNotFoundError67 except ImportError as exc:68 raise RuntimeError(69 "HF_MODEL_REPO_ID is set, but the 'huggingface_hub' package "70 "is not installed. Add 'huggingface_hub' to requirements.txt "71 "and redeploy."72 ) from exc73 74 logger.info(75 "Resolving model '%s' from Hugging Face Hub repo '%s' (revision: %s)...",76 Config.HF_MODEL_FILENAME,77 Config.HF_MODEL_REPO_ID,78 Config.HF_MODEL_REVISION,79 )80 os.makedirs(Config.HF_CACHE_DIR, exist_ok=True)81 try:82 local_path = hf_hub_download(83 repo_id=Config.HF_MODEL_REPO_ID,84 filename=Config.HF_MODEL_FILENAME,85 revision=Config.HF_MODEL_REVISION,86 cache_dir=Config.HF_CACHE_DIR,87 token=Config.HF_TOKEN,88 )89 logger.info("Model resolved from Hugging Face Hub: %s", local_path)90 return local_path91 except HfHubHTTPError as exc:92 status = getattr(getattr(exc, "response", None), "status_code", None)93 if status == 401 or status == 403:94 raise RuntimeError(95 f"Authentication/permission error while accessing Hugging "96 f"Face repo '{Config.HF_MODEL_REPO_ID}'. If this is a "97 "private repo, verify HF_TOKEN is set correctly and has "98 "at least 'Read' access. If it is public, double-check "99 "the repo ID for typos."100 ) from exc101 if status == 404:102 raise RuntimeError(103 f"File '{Config.HF_MODEL_FILENAME}' was not found in "104 f"Hugging Face repo '{Config.HF_MODEL_REPO_ID}' at "105 f"revision '{Config.HF_MODEL_REVISION}'. Verify the "106 "repo ID, filename (case-sensitive), and revision are "107 "exactly correct."108 ) from exc109 raise RuntimeError(110 f"Failed to download '{Config.HF_MODEL_FILENAME}' from Hugging "111 f"Face repo '{Config.HF_MODEL_REPO_ID}' (HTTP status: {status}). "112 "Check that the repo ID and filename are correct, that the "113 "repo is public (or HF_TOKEN is set correctly for a private "114 "repo), and that the deployment environment has outbound "115 "internet access."116 ) from exc117 except LocalEntryNotFoundError as exc:118 raise RuntimeError(119 f"Could not reach Hugging Face Hub to download "120 f"'{Config.HF_MODEL_FILENAME}' from repo "121 f"'{Config.HF_MODEL_REPO_ID}', and no cached copy was found "122 f"locally under '{Config.HF_CACHE_DIR}'. This usually means "123 "the deployment environment has no outbound internet access, "124 "or huggingface.co is unreachable/blocked on this network. "125 "Verify the service has outbound internet access enabled."126 ) from exc127 except Exception as exc: # noqa: BLE001128 raise RuntimeError(129 f"Unexpected error while downloading the model from Hugging "130 f"Face Hub repo '{Config.HF_MODEL_REPO_ID}': {exc}"131 ) from exc132 133 # No Hugging Face repo configured — fall back to a local file path.134 if os.path.exists(Config.MODEL_PATH):135 return Config.MODEL_PATH136 137 return ""138 139 140def _build_demo_model(num_classes: int, input_size: int) -> tf.keras.Model:141 """142 Construct a lightweight CNN with a clear final Conv2D layer (for143 Grad-CAM) and a softmax classification head. Used only when no trained144 `.h5` model is available at `Config.MODEL_PATH`.145 """146 inputs = tf.keras.Input(shape=(input_size, input_size, 3), name="input_image")147 x = tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu", name="conv_block1")(inputs)148 x = tf.keras.layers.MaxPooling2D()(x)149 x = tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu", name="conv_block2")(x)150 x = tf.keras.layers.MaxPooling2D()(x)151 x = tf.keras.layers.Conv2D(128, 3, padding="same", activation="relu", name="conv_block3_final")(x)152 pooled = tf.keras.layers.GlobalAveragePooling2D()(x)153 dense = tf.keras.layers.Dense(64, activation="relu")(pooled)154 outputs = tf.keras.layers.Dense(num_classes, activation="softmax", name="predictions")(dense)155 model = tf.keras.Model(inputs, outputs, name="fgh_dermatology_demo_model")156 return model157 158 159def get_model():160 """161 Return the cached singleton model instance.162 163 Resolution order: Hugging Face Hub -> local file path -> demo fallback.164 165 Design note: any failure while resolving or loading a configured model166 (network failure, bad repo ID, auth error, corrupted file, etc.) is167 caught here and causes a graceful fallback to the untrained demo model,168 rather than raising and taking down every route in the app (landing169 page, health check, etc.). The failure reason is logged and recorded via170 `get_load_error()` so it can be surfaced in the UI/health endpoint171 without crashing the platform for clinicians trying to use it.172 """173 global _model, _is_demo_model, _load_error174 175 if _model is not None:176 return _model177 178 input_size = Config.MODEL_INPUT_SIZE179 num_classes = len(Config.CLASS_LABELS)180 181 try:182 model_path = _resolve_model_path()183 except RuntimeError as exc:184 logger.exception("Model resolution failed: %s", exc)185 _load_error = str(exc)186 model_path = ""187 else:188 _load_error = None189 190 if model_path:191 try:192 logger.info("Loading trained model from %s", model_path)193 _model = tf.keras.models.load_model(model_path, compile=False)194 _is_demo_model = False195 _load_error = None196 logger.info("Model loaded successfully.")197 return _model198 except Exception as exc: # noqa: BLE001199 logger.exception("Failed to load model at %s: %s", model_path, exc)200 _load_error = (201 f"Unable to load the model file at '{model_path}'. The file "202 "may be corrupted, truncated (check the Hugging Face upload "203 "completed fully), or incompatible with the installed "204 "TensorFlow/Keras version."205 )206 207 source_hint = (208 f"Hugging Face repo '{Config.HF_MODEL_REPO_ID}'"209 if Config.HF_MODEL_REPO_ID210 else f"local path '{Config.MODEL_PATH}'"211 )212 logger.warning(213 "Falling back to DEMO MODE with an untrained placeholder network "214 "(source attempted: %s). Predictions are NOT clinically valid. "215 "Configure HF_MODEL_REPO_ID (recommended) or MODEL_PATH correctly "216 "to enable live inference.",217 source_hint,218 )219 _model = _build_demo_model(num_classes, input_size)220 _is_demo_model = True221 return _model222 223 224def is_demo_model() -> bool:225 """Return True if the active model is the untrained demo placeholder."""226 # Ensure model has been initialised so the flag is accurate.227 get_model()228 return _is_demo_model229 230 231def get_load_error():232 """233 Return a human-readable string describing why the configured model234 (Hugging Face or local) could not be loaded, or None if the active235 model loaded successfully (including the case where demo mode was236 entered intentionally because no model was configured at all).237 """238 get_model()239 return _load_error240 241 242def get_input_size() -> int:243 """244 Infer the expected square input resolution from the loaded model when245 possible, otherwise fall back to Config.MODEL_INPUT_SIZE.246 """247 model = get_model()248 try:249 shape = model.input_shape250 if isinstance(shape, list):251 shape = shape[0]252 height = shape[1]253 if isinstance(height, int):254 return height255 except Exception: # noqa: BLE001256 pass257 return Config.MODEL_INPUT_SIZE258 259 260def predict(preprocessed_image: np.ndarray):261 """262 Run inference on a preprocessed image batch.263 264 Parameters265 ----------266 preprocessed_image : np.ndarray267 Array of shape (1, H, W, 3), values normalised as expected by the model.268 269 Returns270 -------271 dict with:272 predicted_class : str — top-1 class label273 confidence : float — top-1 softmax probability (0-1)274 top_k : list[tuple[str, float]] — full ranked differential,275 most likely first. With this platform's 4-class276 taxonomy, all 4 classes are returned so clinicians277 see the complete probability distribution rather278 than an arbitrary subset.279 raw_probs : np.ndarray — full softmax vector280 """281 model = get_model()282 raw_probs = model.predict(preprocessed_image, verbose=0)[0]283 284 labels = Config.CLASS_LABELS285 # Guard against a custom/uploaded model with a different class count.286 n = min(len(labels), len(raw_probs))287 288 ranked_idx = np.argsort(raw_probs[:n])[::-1]289 top_k = [(labels[i], float(raw_probs[i])) for i in ranked_idx]290 291 predicted_class, confidence = top_k[0]292 293 return {294 "predicted_class": predicted_class,295 "confidence": confidence,296 "top_k": top_k,297 "raw_probs": raw_probs,298 }299 