GROOT9/GI_Analysis_App
0
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()