""" dermascan_app.py ---------------- DermaScan AI — Clinical Skin Lesion Analysis A feature-rich Streamlit app built on top of the ISIC 2018 U-Net pipeline. Run: streamlit run dermascan_app.py """ import os import sys import io import warnings from pathlib import Path from datetime import datetime import cv2 import numpy as np import torch from PIL import Image import streamlit as st import plotly.graph_objects as go warnings.filterwarnings("ignore") # ── Path setup ──────────────────────────────────────────────────────────────── PROJECT_ROOT = Path(__file__).parent sys.path.insert(0, str(PROJECT_ROOT)) from model import UNet import app_analysis as ana # ── Inline constants (no config.py needed in Space) ─────────────────────────── MODEL_REPO = "pavanpraneeth/isic-unet" MODEL_FILE = "best_model.pth" IMAGE_CHANNELS = 3 MASK_CHANNELS = 1 DEVICE = ( torch.device("cuda") if torch.cuda.is_available() else torch.device("mps") if torch.backends.mps.is_available() else torch.device("cpu") ) # ───────────────────────────────────────────────────────────────────────────── # Page Config # ───────────────────────────────────────────────────────────────────────────── st.set_page_config( page_title="DermaScan AI", page_icon="🔬", layout="wide", initial_sidebar_state="expanded", ) # ───────────────────────────────────────────────────────────────────────────── # Premium CSS # ───────────────────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ───────────────────────────────────────────────────────────────────────────── # Helper: small HTML cards # ───────────────────────────────────────────────────────────────────────────── def html_card(title: str, content: str) -> str: return f"""

{title}

{content}
""" def pill(text: str, color: str) -> str: return f'{text}' def risk_pill(level: str, color: str) -> str: return f'{level}' # ───────────────────────────────────────────────────────────────────────────── # Model Loading (cached) # ───────────────────────────────────────────────────────────────────────────── @st.cache_resource(show_spinner="Loading U-Net model…") def load_model(): from huggingface_hub import hf_hub_download model = UNet(in_channels=IMAGE_CHANNELS, out_channels=MASK_CHANNELS) try: ckpt_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE) state = torch.load(ckpt_path, map_location=DEVICE) model.load_state_dict(state["model_state_dict"]) best_dice = state.get("best_val_dice", 0.0) status = f"Loaded | Val Dice {best_dice:.4f}" except Exception as e: status = f"Warning: could not load checkpoint ({e})" model.eval().to(DEVICE) return model, DEVICE, status model, DEVICE, model_status = load_model() # ───────────────────────────────────────────────────────────────────────────── # Sidebar # ───────────────────────────────────────────────────────────────────────────── with st.sidebar: # Branding st.markdown("""
🔬
DermaScan AI
Clinical Skin Lesion Analysis
""", unsafe_allow_html=True) st.markdown(f""" """, unsafe_allow_html=True) st.divider() # ── Demographics ── st.markdown("#### 👤 Patient Context *(optional)*") st.caption("Risk score is adjusted if details are provided.") age_over_50 = st.checkbox("Age > 50", value=False) fair_skin = st.checkbox("Fair skin / Fitzpatrick I–II", value=False) family_history = st.checkbox("Family history of melanoma", value=False) prev_melanoma = st.checkbox("Previous melanoma", value=False) high_sun = st.checkbox("High sun exposure history", value=False) demographics = { "age_over_50": age_over_50, "fair_skin": fair_skin, "family_history": family_history, "prev_melanoma": prev_melanoma, "high_sun_exposure":high_sun, } st.divider() # ── Lesion Tracker ── st.markdown("#### 📅 Evolution Tracker") st.caption("Upload a previous scan to detect lesion growth.") prev_upload = st.file_uploader( "Previous scan (optional)", type=["jpg","jpeg","png"], key="prev_scan", label_visibility="collapsed" ) st.divider() st.markdown("""
⚠️ Disclaimer
DermaScan AI is a computer-aided screening tool, not a medical device. Always consult a qualified dermatologist for clinical evaluation.
""", unsafe_allow_html=True) # ───────────────────────────────────────────────────────────────────────────── # Header # ───────────────────────────────────────────────────────────────────────────── st.markdown("""

🔬 DermaScan AI

Upload a dermoscopy image for automated ABCDE analysis, clinical measurements, risk scoring, and explainability heatmaps — powered by a trained U-Net.

""", unsafe_allow_html=True) # ───────────────────────────────────────────────────────────────────────────── # Upload Section # ───────────────────────────────────────────────────────────────────────────── col_up, col_pre = st.columns([1, 1], gap="large") with col_up: uploaded = st.file_uploader( "Upload dermoscopy image", type=["jpg","jpeg","png"], label_visibility="collapsed", key="main_upload", ) if uploaded: pil_img = Image.open(uploaded).convert("RGB") image_rgb = np.array(pil_img) st.image(image_rgb, caption="Uploaded Image", use_container_width=True) with col_pre: if uploaded: quality = ana.check_image_quality(image_rgb) q_color = "#22c55e" if quality["ok"] else ("#f59e0b" if len(quality["issues"]) == 1 else "#ef4444") q_icon = "✅" if quality["ok"] else ("⚠️" if len(quality["issues"]) == 1 else "❌") st.markdown(f"""

Image Quality Check

{quality['score']} / 100
{q_icon} {"Good to analyze" if quality['ok'] else "Proceed with caution"}
""", unsafe_allow_html=True) cq1, cq2, cq3 = st.columns(3) cq1.metric("Blur Score", quality["blur"]) cq2.metric("Brightness", quality["brightness"]) cq3.metric("Contrast", quality["contrast"]) if quality["issues"]: for issue in quality["issues"]: st.warning(issue) st.markdown("
", unsafe_allow_html=True) if not uploaded: st.info("👆 Upload a dermoscopy image to begin analysis.") st.stop() # ───────────────────────────────────────────────────────────────────────────── # Run Analysis # ───────────────────────────────────────────────────────────────────────────── run_btn = st.button("🚀 Run DermaScan Analysis", use_container_width=False) if not run_btn and "results" not in st.session_state: st.stop() if run_btn: with st.spinner("Running segmentation and clinical analysis…"): # ── Segmentation ── mask, prob_map = ana.run_segmentation(model, DEVICE, image_rgb) # ── ABCDE ── A = ana.abcde_asymmetry(mask) B = ana.abcde_border(mask) C = ana.abcde_color(image_rgb, mask) D = ana.abcde_diameter(mask) # ── Risk ── risk = ana.compute_risk(A, B, C, D, demographics) # ── Measurements ── meas = ana.compute_measurements(image_rgb, mask) # ── Visuals ── overlay = ana.make_overlay(image_rgb, mask) gradcam = ana.make_gradcam(model, DEVICE, image_rgb) # ── Evolution (if prev scan provided) ── evo = None if prev_upload: prev_img = np.array(Image.open(prev_upload).convert("RGB")) prev_mask, _ = ana.run_segmentation(model, DEVICE, prev_img) evo = ana.compare_masks(prev_mask, mask) # Store in session st.session_state["results"] = dict( mask=mask, prob_map=prob_map, overlay=overlay, gradcam=gradcam, A=A, B=B, C=C, D=D, risk=risk, meas=meas, evo=evo, quality=quality, ) # Pull from session res = st.session_state["results"] mask, prob_map = res["mask"], res["prob_map"] overlay, gradcam = res["overlay"], res["gradcam"] A, B, C, D = res["A"], res["B"], res["C"], res["D"] risk, meas, evo = res["risk"], res["meas"], res["evo"] st.divider() # ───────────────────────────────────────────────────────────────────────────── # --- TOP SUMMARY ROW --- # ───────────────────────────────────────────────────────────────────────────── s1, s2, s3, s4, s5 = st.columns(5) s1.metric("Risk Level", risk["level"]) s2.metric("A — Asymmetry", f"{A['score']} / 2", delta=A["label"]) s3.metric("B — Border", f"{B['score']:.2f}", delta=B["label"]) s4.metric("C — Colors", C["count"], delta=C["label"]) s5.metric("Diameter Est.", f"{D['diameter_mm']} mm", delta=D["label"]) st.divider() # ───────────────────────────────────────────────────────────────────────────── # --- TABS --- # ───────────────────────────────────────────────────────────────────────────── tabs = st.tabs([ "🎯 Overview", "🔬 ABCDE Analysis", "📐 Measurements", "🧠 Explainability", "📅 Evolution", "📄 Report", ]) # ══════════════════════════════════════════════════════════════════════════════ # TAB 1 — OVERVIEW # ══════════════════════════════════════════════════════════════════════════════ with tabs[0]: ov1, ov2, ov3 = st.columns([1.1, 1.05, 0.95]) with ov1: st.markdown("**Segmentation Overlay**") st.image(overlay, use_container_width=True) st.markdown("**Predicted Mask**") mask_vis = (mask.astype(np.uint8) * 255) st.image(mask_vis, use_container_width=True, clamp=True) with ov2: gauge_color = risk["color"] # ── Score number displayed cleanly above gauge ── st.markdown(f"""
Risk Score
{risk['score']} / 10
{risk['level']}
{risk['label']}
""", unsafe_allow_html=True) # ── Gauge (number hidden — shown above instead) ── fig = go.Figure(go.Indicator( mode="gauge", value=risk["score"], gauge={ "axis": { "range": [0, 10], "tickvals": [0, 2.5, 5, 7.5, 10], "ticktext": ["0", "2.5", "5", "7.5", "10"], "tickcolor": "#7a76a8", "tickfont": {"color": "#7a76a8", "size": 11}, }, "bar": {"color": gauge_color, "thickness": 0.3}, "bgcolor": "#1a1a36", "borderwidth": 1, "bordercolor": "rgba(120,100,255,.3)", "steps": [ {"range": [0, 2.5], "color": "#0d2b12"}, {"range": [2.5, 5.0], "color": "#2b2100"}, {"range": [5.0, 10], "color": "#2b0a0a"}, ], "threshold": { "line": {"color": "white", "width": 3}, "thickness": 0.85, "value": risk["score"], }, }, )) fig.update_layout( height=200, margin=dict(l=16, r=16, t=8, b=8), paper_bgcolor="#09090f", font={"family": "Inter"}, ) st.plotly_chart(fig, use_container_width=True) with ov3: # ABCDE bar chart criterion_labels = ["A Asymmetry", "B Border", "C Color Var.", "D Diameter"] raw_vals = [ A["score"] / 2.0, B["score"], min(C["count"] / 6.0, 1.0), float(D["risk"]), ] bar_colors = [ "#ef4444" if v > 0.5 else "#f59e0b" if v > 0.2 else "#22c55e" for v in raw_vals ] fig2 = go.Figure(go.Bar( x=raw_vals, y=criterion_labels, orientation="h", marker=dict(color=bar_colors, line=dict(color="rgba(0,0,0,0)")), text=[f"{v:.0%}" for v in raw_vals], textposition="outside", textfont=dict(color="#e8e4ff", size=11, family="Inter"), )) fig2.update_layout( title=dict(text="ABCD Criteria", font={"color": "#e8e4ff", "size": 13}), xaxis=dict(range=[0, 1.25], showgrid=False, visible=False), yaxis=dict(tickfont={"color": "#a89aff", "size": 12}), plot_bgcolor="#141430", paper_bgcolor="#09090f", margin=dict(l=8, r=40, t=36, b=8), height=210, ) st.plotly_chart(fig2, use_container_width=True) if meas: st.markdown(f"""

Quick Stats

Area {meas['area_mm2']} mm²
Perimeter {meas['perimeter_mm']} mm
Coverage {meas['coverage_pct']}%
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TAB 2 — ABCDE ANALYSIS # ══════════════════════════════════════════════════════════════════════════════ with tabs[1]: st.markdown("### ABCDE Clinical Criteria") st.caption("The ABCDE rule is the standard dermatological checklist for melanoma screening. " "Each criterion is computed directly from the segmentation mask and the original image.") def abcde_card(letter, name, value_str, label, risk_flag, detail): icon = "🔴" if risk_flag else "🟢" color = "#ef4444" if risk_flag else "#22c55e" st.markdown(f"""
{letter} {name}
{value_str}
{icon} {label}
{detail}
""", unsafe_allow_html=True) c1, c2 = st.columns(2) with c1: abcde_card( "A", "Asymmetry", f"{A['score']} / 2", A["label"], A["risk"], f"Horizontal axis asymmetry: {A['axis_h']:.3f}  " f"Vertical axis asymmetry: {A['axis_v']:.3f}
" "Score of 1 = asymmetric on one axis. Score 2 = both axes. " "Threshold per axis: 0.18 overlap mismatch ratio." ) abcde_card( "C", "Color Variegation", f"{C['count']} clusters", C["label"], C["risk"], f"K-Means clustering (k≤6) applied to lesion pixels. " f"
Clusters with >3% of lesion pixel population are counted as significant. " f"≥3 distinct clusters raises concern." ) with c2: abcde_card( "B", "Border Irregularity", f"{B['score']:.3f}", B["label"], B["risk"], f"Irregularity = 1 − (4π·Area / Perimeter²). " f"Circularity index: {B['circularity']:.3f} " f"(1.0 = perfect circle). " "Score > 0.25: irregular. > 0.50: highly irregular." ) abcde_card( "D", "Diameter", f"{D['diameter_mm']} mm", D["label"], D["risk"], f"Estimated from pixel area assuming {ana.PIXELS_PER_MM:.0f} px/mm (standard dermoscope). " f"Area: {D['area_mm2']} mm² ({D['area_px']} px). " "Clinical threshold: > 6 mm → warning." ) # Colour palette for C if C["hex_colors"]: st.markdown("---") st.markdown("**Detected Lesion Color Clusters**") swatches = "".join( f'' f'{hx}' for hx in C["hex_colors"] ) st.markdown(swatches, unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TAB 3 — MEASUREMENTS # ══════════════════════════════════════════════════════════════════════════════ with tabs[2]: st.markdown("### Clinical Measurements") st.caption(f"All real-world estimates assume {ana.PIXELS_PER_MM:.0f} px/mm dermoscope calibration.") if not meas: st.warning("No lesion detected in the mask — check segmentation threshold.") st.stop() m1, m2, m3, m4 = st.columns(4) m1.metric("Area", f"{meas['area_mm2']} mm²", delta=f"{meas['area_px']} px") m2.metric("Perimeter", f"{meas['perimeter_mm']} mm") m3.metric("Image Coverage", f"{meas['coverage_pct']} %") m4.metric("Mean Lesion Color", meas["mean_color_hex"].upper()) st.markdown("---") c_det, c_vis = st.columns([1, 1]) with c_det: st.markdown(f"""

Full Measurement Table

Area (pixels) {meas['area_px']:,}
Area (mm²) {meas['area_mm2']}
Perimeter (px) {meas['perimeter_px']}
Perimeter (mm) {meas['perimeter_mm']}
Image Coverage {meas['coverage_pct']} %
Mean Color (RGB) {tuple(meas['mean_color_rgb'])}
Color Std Dev {tuple(meas['std_color_rgb'])}
""", unsafe_allow_html=True) with c_vis: # Bounding-box visualization bbox_img = image_rgb.copy() x, y, bw, bh = meas["bbox"] cv2.rectangle(bbox_img, (x, y), (x+bw, y+bh), (124, 92, 252), 2) # Centroid cx_m = x + bw // 2 cy_m = y + bh // 2 cv2.drawMarker(bbox_img, (cx_m, cy_m), (0, 229, 163), cv2.MARKER_CROSS, 16, 2) st.image(bbox_img, caption="Bounding box + lesion centroid", use_container_width=True) # Mean color swatch mc = meas["mean_color_hex"] st.markdown(f"""
{mc.upper()}
Mean lesion color
""", unsafe_allow_html=True) # Probability heatmap st.markdown("**Segmentation Confidence Map**") import matplotlib.pyplot as plt import matplotlib.cm as cm fig3, ax = plt.subplots(figsize=(5, 4)) fig3.patch.set_facecolor("#09090f") ax.set_facecolor("#09090f") im = ax.imshow(prob_map, cmap="magma", vmin=0, vmax=1) cbar = fig3.colorbar(im, ax=ax) cbar.ax.yaxis.set_tick_params(color="white") plt.setp(cbar.ax.yaxis.get_ticklabels(), color="white") ax.set_title("P(lesion) per pixel", color="#a89aff", fontsize=11) ax.axis("off") st.pyplot(fig3, use_container_width=True) plt.close(fig3) # ══════════════════════════════════════════════════════════════════════════════ # TAB 4 — EXPLAINABILITY # ══════════════════════════════════════════════════════════════════════════════ with tabs[3]: st.markdown("### Model Explainability — Grad-CAM") st.caption( "Gradient-weighted Class Activation Mapping (Grad-CAM) reveals which image regions " "activated the model's last encoder layer most strongly during prediction. " "Hot regions (red/yellow) had the greatest influence on the segmentation output." ) e1, e2, e3 = st.columns(3) with e1: st.image(image_rgb, caption="Original", use_container_width=True) with e2: st.image(gradcam, caption="Grad-CAM Heatmap", use_container_width=True) with e3: st.image(overlay, caption="Segmentation", use_container_width=True) st.markdown("---") st.markdown(f"""

How to Read This

🔴 Red / Yellow — Regions the encoder focused on most intensely.
🔵 Blue / Dark — Low activation regions, less important to the prediction.

Ideally, the hottest regions should overlap with the visible lesion. If activation appears in the background, it may indicate the model is relying on surrounding skin texture — a sign the pretrained encoder would be beneficial.

""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TAB 5 — EVOLUTION # ══════════════════════════════════════════════════════════════════════════════ with tabs[4]: st.markdown("### Lesion Evolution Tracker") if evo is None: st.info( "📅 Upload a **previous scan** in the sidebar to compare this lesion " "against an earlier timepoint and detect growth." ) else: growth_color = "#ef4444" if evo["warning"] else "#22c55e" ev1, ev2, ev3, ev4 = st.columns(4) ev1.metric("Previous Area", f"{evo['area_old_mm2']} mm²") ev2.metric("Current Area", f"{evo['area_new_mm2']} mm²") ev3.metric("Growth", f"{evo['growth_pct']} %", delta=("⚠ Growth detected" if evo["warning"] else "Stable")) ev4.metric("Shape Overlap (IoU)", f"{evo['iou']:.3f}") if evo["warning"]: st.error("⚠️ **Significant change detected.** Growth > 20% or shape overlap < 0.70. Please consult a dermatologist.") else: st.success("✅ Lesion appears stable compared to the previous scan.") st.markdown("---") st.markdown("**Change Map**") st.image(evo["change_image"], use_container_width=False, width=400, caption="🟢 Stable | 🔴 New growth | 🔵 Regression") st.caption("Pixel-level comparison between previous and current segmentation masks.") # ══════════════════════════════════════════════════════════════════════════════ # TAB 6 — REPORT # ══════════════════════════════════════════════════════════════════════════════ with tabs[5]: st.markdown("### Clinical Report") # Preview timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") st.markdown(f"""

Report Preview

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  DERMASCAN AI — CLINICAL LESION REPORT
  Generated: {timestamp}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IMAGE QUALITY
  Score: {res['quality']['score']} / 100 — {"OK" if res['quality']['ok'] else "Issues detected"}

ABCDE ANALYSIS
  A Asymmetry: {A['score']} / 2 — {A['label']}
  B Border: {B['score']:.3f} — {B['label']}
  C Color: {C['count']} clusters — {C['label']}
  D Diameter: {D['diameter_mm']} mm — {D['label']}

CLINICAL MEASUREMENTS
  Area: {meas.get('area_mm2','—')} mm²
  Perimeter: {meas.get('perimeter_mm','—')} mm
  Coverage: {meas.get('coverage_pct','—')} %

RISK ASSESSMENT
  Score: {risk['score']} / 10.0 — {risk['level']}
  {risk['label']}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DISCLAIMER: AI screening tool — NOT a medical diagnosis.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
""", unsafe_allow_html=True) # PDF Download st.markdown("---") col_dl1, col_dl2 = st.columns(2) with col_dl1: pdf_bytes = ana.generate_pdf( image_rgb, mask, res["quality"], {"A": A, "B": B, "C": C, "D": D}, risk, meas, ) if pdf_bytes: st.download_button( label="⬇️ Download PDF Report", data=pdf_bytes, file_name=f"dermascan_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf", mime="application/pdf", use_container_width=True, ) else: st.info("Install `fpdf2` for PDF export: `pip install fpdf2`") with col_dl2: # Plain-text download (always works) txt_report = f"""DERMASCAN AI — CLINICAL LESION REPORT Generated: {timestamp} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ IMAGE QUALITY Score: {res['quality']['score']} / 100 Blur: {res['quality']['blur']} Brightness: {res['quality']['brightness']} Contrast: {res['quality']['contrast']} ABCDE ANALYSIS A Asymmetry: {A['score']} / 2 — {A['label']} (H={A['axis_h']}, V={A['axis_v']}) B Border: {B['score']:.3f} — {B['label']} C Color: {C['count']} clusters — {C['label']} D Diameter: {D['diameter_mm']} mm — {D['label']} (Area: {D['area_mm2']} mm²) CLINICAL MEASUREMENTS Area: {meas.get('area_mm2','—')} mm² ({meas.get('area_px','—')} px) Perimeter: {meas.get('perimeter_mm','—')} mm Coverage: {meas.get('coverage_pct','—')} % of image Mean Color: {meas.get('mean_color_hex','—').upper()} RISK ASSESSMENT Score: {risk['score']} / 10.0 Level: {risk['level']} Label: {risk['label']} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ DISCLAIMER: This report is generated by an AI screening tool and does NOT constitute a medical diagnosis. Always consult a qualified dermatologist for clinical evaluation. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ """ st.download_button( label="⬇️ Download Text Report", data=txt_report, file_name=f"dermascan_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt", mime="text/plain", use_container_width=True, )