CoolFace
Apppublic

GROOT9/GI_Analysis_App

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
app.py385 linesDownload Raw Back to root
1"""2GI Bowel Sound Classifier — HuggingFace Spaces / Streamlit3Fixed: NMF zero-matrix crash, empty-audio crash, parselmouth path bug,4       MFCC shape bug, scaler dimension mismatch guard, deprecated API calls.5"""6 7import streamlit as st8import os9 10# ── Constants ──────────────────────────────────────────────────────────────11REQUIRED_FILES = [12    "fold_1_best.keras",13    "fold_1_trad_scaler.pkl",14    "fold_1_nmf_scaler.pkl",15    "trad_feature_names.json",16    "global_threshold.json",17]18 19IMG_SIZE = 12820N_COMPONENTS = 421MIN_AUDIO_SAMPLES = 2048   # guard against empty / near-empty WAV files22 23REGION_LABELS = [24    "Right Upper Quadrant (RUQ)", "Epigastric", "Left Upper Quadrant (LUQ)",25    "Right Lateral", "Umbilical", "Left Lateral",26    "Right Iliac Fossa (RIF)", "Hypogastric", "Left Iliac Fossa (LIF)",27]28 29BASELINES = {30    "spectral_centroid_hz": {"healthy": 145.0, "unhealthy": 225.0},31    "hnr_db": {"healthy": 4.5, "unhealthy": 8.0},32}33 34 35# ── File presence check ────────────────────────────────────────────────────36def check_files() -> None:37    missing = [f for f in REQUIRED_FILES if not os.path.exists(f)]38    if missing:39        st.error(f"❌ Missing model files in the app root directory:\n{missing}\n\n"40                 "Re-upload them via the HuggingFace Files tab.")41        st.stop()42 43 44# ── Load model + scalers (cached across requests) ─────────────────────────45@st.cache_resource(show_spinner="Loading model artifacts…")46def load_assets():47    import json48    import joblib49    import tensorflow as tf50 51    os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"52 53    # Custom layer must be registered before load_model54    @tf.keras.utils.register_keras_serializable(package="Custom")55    class MaskGate(tf.keras.layers.Layer):56        def call(self, inputs):57            feat, mask = inputs58            return feat * mask59 60    model = tf.keras.models.load_model(61        "fold_1_best.keras",62        custom_objects={"MaskGate": MaskGate},63        compile=False,64    )65    trad_scaler = joblib.load("fold_1_trad_scaler.pkl")66    nmf_scaler  = joblib.load("fold_1_nmf_scaler.pkl")67 68    with open("trad_feature_names.json", "r") as fh:69        feat_names = json.load(fh)70 71    with open("global_threshold.json", "r") as fh:72        threshold = float(json.load(fh)["threshold"])73 74    return model, trad_scaler, nmf_scaler, feat_names, threshold75 76 77# ── Mel spectrogram → PIL image + normalised numpy array ──────────────────78def get_mel_spectrogram(y, sr):79    import io80    import numpy as np81    import matplotlib82    matplotlib.use("Agg")           # non-interactive backend — required in Docker83    import matplotlib.pyplot as plt84    import librosa85    import librosa.display86    from PIL import Image87 88    S    = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=128)89    S_db = librosa.power_to_db(S, ref=np.max)90 91    fig, ax = plt.subplots(figsize=(2, 2), dpi=64)92    librosa.display.specshow(S_db, sr=sr, ax=ax, cmap="magma")93    ax.axis("off")94    fig.tight_layout(pad=0)95 96    buf = io.BytesIO()97    plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0)98    plt.close(fig)99    buf.seek(0)100 101    img     = Image.open(buf).convert("RGB").resize((IMG_SIZE, IMG_SIZE))102    img_arr = np.array(img, dtype="float32") / 255.0103    return img, img_arr104 105 106# ── Feature extraction ─────────────────────────────────────────────────────107def extract_features(y, sr, wav_path: str, feat_names: list):108    import numpy as np109    import librosa110    from sklearn.decomposition import NMF111 112    # Zero-crossing rate113    zcr = float(np.mean(librosa.feature.zero_crossing_rate(y)))114 115    # Spectral centroid116    spectral_centroid = float(117        np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))118    )119 120    # MFCC — guard against zero-length frame arrays121    mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)122    if mfccs.shape[1] == 0:123        mfcc_mean = np.zeros(13, dtype="float32")124    else:125        mfcc_mean = np.mean(mfccs, axis=1)   # shape: (13,)126 127    # Voice quality features via praat-parselmouth128    jitter, shimmer, hnr = 0.0, 0.0, 0.0129    try:130        import parselmouth131        from parselmouth.praat import call132 133        # wav_path must still exist — safe inside the tempdir context134        snd         = parselmouth.Sound(wav_path)135        pitch       = call(snd, "To Pitch", 0.0, 75, 600)136        point_proc  = call([snd, pitch], "To PointProcess (cc)")137        jitter      = float(138            call(point_proc, "Get jitter (local)", 0, 0, 0.0001, 0.02, 1.3)139        )140        shimmer     = float(141            call([snd, point_proc], "Get shimmer (local)", 0, 0, 0.0001, 0.02, 1.3, 1.6)142        )143        harmonicity = call(snd, "To Harmonicity (cc)", 0.01, 75, 0.1, 1.0)144        hnr         = float(call(harmonicity, "Get mean", 0, 0))145    except Exception:146        pass   # parselmouth unavailable or audio too short — zeros are safe defaults147 148    # NMF on STFT magnitude149    # CRITICAL FIX: add epsilon so NMF never receives an all-zero matrix.150    # A silent / near-silent WAV produces an all-zero STFT which causes NMF to151    # diverge or raise a ConvergenceWarning / ValueError.152    stft    = np.abs(librosa.stft(y)) + 1e-8153    nmf_mdl = NMF(n_components=N_COMPONENTS, random_state=42, max_iter=300)154    W       = nmf_mdl.fit_transform(stft)           # (freq_bins, N_COMPONENTS)155    nmf_vec = np.mean(W, axis=0)                    # (N_COMPONENTS,)156 157    # Build feature map matching training feature names exactly158    feat_map: dict = {159        "zcr":                  zcr,160        "spectral_centroid_hz": spectral_centroid,161        "jitter_local":         jitter,162        "shimmer_local":        shimmer,163        "hnr":                  hnr,164    }165    for i, v in enumerate(mfcc_mean):166        feat_map[f"mfcc_{i+1}"] = float(v)167 168    # trad_vec must preserve the exact column order from training169    trad_vec = [float(feat_map.get(name, 0.0)) for name in feat_names]170 171    return trad_vec, nmf_vec, feat_map172 173 174# ── Collect WAV paths from an extracted ZIP folder ────────────────────────175def collect_wavs(folder: str) -> list:176    wavs = []177    for root, _, files in os.walk(folder):178        for f in files:179            if f.lower().endswith(".wav"):180                wavs.append(os.path.join(root, f))181    # Sort by filename (not full path) so ordering is consistent182    return sorted(wavs, key=lambda x: os.path.basename(x).lower())183 184 185# ── Main Streamlit app ─────────────────────────────────────────────────────186def main():187    import numpy as np188    import pandas as pd189    import librosa190    import zipfile191    import tempfile192 193    st.set_page_config(194        page_title="GI Bowel Sound Classifier",195        page_icon="🩺",196        layout="wide",197    )198 199    st.title("🩺 GI Bowel Sound Classifier")200    st.caption(201        "Upload a **ZIP** containing exactly **9 `.wav` files** — "202        "one per abdominal region — for full-patient analysis."203    )204 205    # Check model files before anything else206    check_files()207 208    uploaded_file = st.file_uploader(209        "📁 Upload Patient ZIP (9 .wav files)", type="zip"210    )211    if not uploaded_file:212        st.info("Waiting for upload…")213        st.stop()214 215    # Load model artifacts (cached after first run)216    try:217        model, trad_scaler, nmf_scaler, feat_names, threshold = load_assets()218    except Exception as exc:219        st.error(f"❌ Failed to load model artifacts:\n\n`{exc}`")220        st.stop()221 222    # Extract ZIP into a temporary directory223    with tempfile.TemporaryDirectory() as tmpdir:224 225        try:226            with zipfile.ZipFile(uploaded_file, "r") as zf:227                zf.extractall(tmpdir)228        except zipfile.BadZipFile as exc:229            st.error(f"❌ Could not open ZIP file: `{exc}`")230            st.stop()231 232        files = collect_wavs(tmpdir)233 234        if len(files) == 0:235            st.error("No `.wav` files found inside the ZIP. "236                     "Make sure the WAV files are at the root or in a single sub-folder.")237            st.stop()238 239        if len(files) != 9:240            st.error(241                f"Expected exactly **9** `.wav` files, found **{len(files)}**.\n\n"242                f"Files detected: {[os.path.basename(f) for f in files]}"243            )244            st.stop()245 246        st.success("✅ ZIP loaded — 9 regions detected. Analysing…")247 248        all_imgs, all_trad, all_nmf, all_maps = [], [], [], []249        cols = st.columns(3)250 251        with st.status("Analysing patient regions…", expanded=True) as status:252            for i, wav_path in enumerate(files):253                region_label = REGION_LABELS[i]254                st.write(f"Processing R{i+1}: {region_label}…")255 256                try:257                    # CRITICAL FIX: always resample to 22050 Hz for consistency.258                    # sr=None can return wildly different sample rates (8k–96k)259                    # which breaks MFCC and NMF dimension expectations.260                    y, sr = librosa.load(wav_path, sr=22050, mono=True)261 262                    # Guard: reject audio that is too short for feature extraction263                    if len(y) < MIN_AUDIO_SAMPLES:264                        st.error(265                            f"❌ File '{os.path.basename(wav_path)}' is too short "266                            f"({len(y)} samples). Minimum required: {MIN_AUDIO_SAMPLES}."267                        )268                        st.stop()269 270                    img_pil, img_arr = get_mel_spectrogram(y, sr)271                    trad, nmf_vec, f_map = extract_features(y, sr, wav_path, feat_names)272 273                    all_imgs.append(img_arr)274                    all_trad.append(trad)275                    all_nmf.append(nmf_vec)276                    all_maps.append(f_map)277 278                    with cols[i % 3]:279                        st.image(280                            img_pil,281                            caption=f"R{i+1}: {region_label}",282                            use_container_width=True,283                        )284 285                except Exception as exc:286                    st.error(287                        f"❌ Error processing `{os.path.basename(wav_path)}`: `{exc}`"288                    )289                    st.stop()290 291            # ── Predict ───────────────────────────────────────────────────292            try:293                X_img  = np.expand_dims(np.array(all_imgs, dtype="float32"), axis=0)294                # shape: (1, 9, 128, 128, 3)295 296                trad_arr = np.array(all_trad, dtype="float32")    # (9, n_trad_feats)297                nmf_arr  = np.array(all_nmf,  dtype="float32")    # (9, N_COMPONENTS)298 299                # CRITICAL FIX: guard against scaler dimension mismatch300                if trad_arr.shape[1] != len(feat_names):301                    st.error(302                        f"Feature dimension mismatch: got {trad_arr.shape[1]} "303                        f"features but scaler expects {len(feat_names)}."304                    )305                    st.stop()306 307                X_trad = np.expand_dims(trad_scaler.transform(trad_arr), axis=0)308                # (1, 9, n_trad_feats)309 310                X_nmf  = np.expand_dims(nmf_scaler.transform(nmf_arr), axis=0)311                # (1, 9, N_COMPONENTS)312 313                raw_pred = model.predict([X_img, X_trad, X_nmf], verbose=0)314                prediction = float(raw_pred[0][0])315 316                status.update(label="✅ Analysis Complete", state="complete", expanded=False)317 318            except Exception as exc:319                st.error(f"❌ Prediction failed: `{exc}`")320                st.stop()321 322        # ── Results ───────────────────────────────────────────────────────323        st.divider()324 325        is_unhealthy = prediction >= threshold326        label        = "UNHEALTHY" if is_unhealthy else "HEALTHY"327        color        = "#ff4b4b"   if is_unhealthy else "#21c55d"328 329        res_col1, res_col2 = st.columns([1, 1])330 331        with res_col1:332            st.markdown(333                f"<h1 style='text-align:center;color:{color};font-size:3rem;'>"334                f"{label}</h1>",335                unsafe_allow_html=True,336            )337            st.metric(338                label="P(Unhealthy) Confidence",339                value=f"{prediction * 100:.2f}%",340                delta=f"Threshold: {threshold * 100:.1f}%",341                delta_color="off",342            )343 344        with res_col2:345            st.subheader("Clinical Explanation")346 347            # Safe mean — all_maps is guaranteed non-empty here348            avg_sc = float(349                np.mean([m["spectral_centroid_hz"] for m in all_maps])350            )351            baseline_unhealthy = BASELINES["spectral_centroid_hz"]["unhealthy"]352 353            if avg_sc > baseline_unhealthy:354                st.warning(355                    f"⚠️ **High Frequency Shift:** Average spectral centroid "356                    f"({avg_sc:.1f} Hz) exceeds the healthy baseline "357                    f"({baseline_unhealthy:.0f} Hz). Elevated high-frequency energy "358                    "may indicate hyperactive or obstructed bowel motility."359                )360            else:361                st.success(362                    f"✅ **Normal Frequency Envelope:** Average spectral centroid "363                    f"({avg_sc:.1f} Hz) is within the normal range "364                    f"(< {baseline_unhealthy:.0f} Hz)."365                )366 367        st.divider()368        st.subheader("📊 Extracted Feature Summary (mean across 9 regions)")369        df_feats = (370            pd.DataFrame(all_maps)371            .mean()372            .round(5)373            .to_frame(name="Mean Value")374            .T375        )376        st.dataframe(df_feats, use_container_width=True)377 378        # Per-region breakdown379        with st.expander("📋 Per-Region Feature Detail"):380            df_detail = pd.DataFrame(all_maps, index=REGION_LABELS).round(5)381            st.dataframe(df_detail, use_container_width=True)382 383 384if __name__ == "__main__":385    main()