CoolFace
Apppublic

AndreaNotes1/fgh-dermatology

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
model_loader.py299 linesDownload Raw Back to utils
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