Spaces:
Sleeping
Sleeping
| """ | |
| 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(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| html, body, [class*="css"] { | |
| font-family: 'Inter', sans-serif; | |
| } | |
| /* Dark background */ | |
| .stApp { background: #09090f; color: #e8e4ff; } | |
| [data-testid="stSidebar"] { background: #111120 !important; border-right: 1px solid rgba(120,100,255,.18); } | |
| [data-testid="stSidebar"] .stMarkdown { color: #e8e4ff; } | |
| /* Tabs */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| background: #141430; | |
| border-radius: 12px; | |
| padding: 4px; | |
| gap: 4px; | |
| border: 1px solid rgba(120,100,255,.2); | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background: transparent !important; | |
| color: #7a76a8 !important; | |
| border-radius: 8px !important; | |
| font-weight: 600; | |
| font-size: 0.88rem; | |
| padding: 8px 16px !important; | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: linear-gradient(135deg,#7c5cfc,#c550ff) !important; | |
| color: #fff !important; | |
| } | |
| /* Metrics */ | |
| [data-testid="metric-container"] { | |
| background: #141430; | |
| border: 1px solid rgba(120,100,255,.18); | |
| border-radius: 12px; | |
| padding: 16px !important; | |
| } | |
| [data-testid="stMetricValue"] { color: #e8e4ff !important; font-weight: 800; } | |
| [data-testid="stMetricLabel"] { color: #7a76a8 !important; font-size: 0.78rem !important; } | |
| /* Buttons */ | |
| .stButton > button { | |
| background: linear-gradient(135deg, #7c5cfc, #c550ff) !important; | |
| color: #fff !important; | |
| border: none !important; | |
| border-radius: 10px !important; | |
| font-weight: 700 !important; | |
| font-size: 1rem !important; | |
| padding: 12px 32px !important; | |
| transition: all 0.2s !important; | |
| width: 100%; | |
| } | |
| .stButton > button:hover { opacity: 0.88; transform: translateY(-1px); box-shadow: 0 8px 24px rgba(124,92,252,.35); } | |
| /* File uploader */ | |
| [data-testid="stFileUploader"] { | |
| background: #141430; | |
| border: 2px dashed rgba(124,92,252,.4); | |
| border-radius: 16px; | |
| padding: 20px; | |
| } | |
| /* Expander */ | |
| .streamlit-expanderHeader { | |
| background: #141430 !important; | |
| border-radius: 10px !important; | |
| color: #a89aff !important; | |
| font-weight: 600; | |
| } | |
| /* Info / warning / success boxes */ | |
| .stAlert { border-radius: 10px !important; } | |
| /* Divider */ | |
| hr { border-color: rgba(120,100,255,.15) !important; } | |
| /* Code block override */ | |
| code { background: #0d0d1e !important; color: #c3e88d !important; } | |
| /* Custom card */ | |
| .ds-card { | |
| background: #141430; | |
| border: 1px solid rgba(120,100,255,.18); | |
| border-radius: 16px; | |
| padding: 20px 22px; | |
| margin-bottom: 14px; | |
| } | |
| .ds-card h4 { color: #a89aff; margin: 0 0 10px 0; font-size: 0.9rem; letter-spacing: 0.06em; text-transform: uppercase; } | |
| /* Risk badge */ | |
| .risk-badge { | |
| display: inline-block; | |
| padding: 6px 18px; | |
| border-radius: 24px; | |
| font-weight: 800; | |
| font-size: 0.85rem; | |
| letter-spacing: 0.05em; | |
| } | |
| /* Color swatch */ | |
| .swatch { | |
| display: inline-block; | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 6px; | |
| border: 1px solid rgba(255,255,255,.15); | |
| margin-right: 6px; | |
| vertical-align: middle; | |
| } | |
| /* Progress bar override */ | |
| .stProgress > div > div > div { background: linear-gradient(90deg,#7c5cfc,#c550ff) !important; border-radius: 4px; } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-thumb { background: rgba(124,92,252,.3); border-radius: 4px; } | |
| /* Sidebar section */ | |
| .sidebar-section { | |
| background: rgba(124,92,252,.08); | |
| border: 1px solid rgba(124,92,252,.2); | |
| border-radius: 12px; | |
| padding: 14px; | |
| margin-bottom: 14px; | |
| } | |
| /* Image caption override */ | |
| [data-testid="caption"] { color: #7a76a8 !important; font-size: 0.8rem !important; text-align: center; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helper: small HTML cards | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def html_card(title: str, content: str) -> str: | |
| return f"""<div class="ds-card"><h4>{title}</h4>{content}</div>""" | |
| def pill(text: str, color: str) -> str: | |
| return f'<span style="background:{color}22;color:{color};padding:3px 10px;border-radius:12px;font-size:.8rem;font-weight:700;border:1px solid {color}55;">{text}</span>' | |
| def risk_pill(level: str, color: str) -> str: | |
| return f'<span class="risk-badge" style="background:{color}22;color:{color};border:1px solid {color}55;">{level}</span>' | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Model Loading (cached) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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(""" | |
| <div style="text-align:center;padding:12px 0 20px;"> | |
| <div style="font-size:2.4rem;">π¬</div> | |
| <div style="font-size:1.3rem;font-weight:800;background:linear-gradient(135deg,#7c5cfc,#c550ff); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;"> | |
| DermaScan AI | |
| </div> | |
| <div style="color:#7a76a8;font-size:.78rem;margin-top:4px;"> | |
| Clinical Skin Lesion Analysis | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(f""" | |
| <div class="sidebar-section"> | |
| <div style="font-size:.75rem;color:#7a76a8;text-transform:uppercase; | |
| letter-spacing:.08em;font-weight:700;margin-bottom:6px;">Model Status</div> | |
| <div style="font-size:.85rem;color:#a89aff;">{model_status}</div> | |
| <div style="font-size:.75rem;color:#7a76a8;margin-top:4px;">Device: {DEVICE}</div> | |
| </div> | |
| """, 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(""" | |
| <div style="font-size:.72rem;color:#7a76a8;line-height:1.6;"> | |
| β οΈ <strong style="color:#f59e0b;">Disclaimer</strong><br> | |
| DermaScan AI is a computer-aided | |
| <em>screening tool</em>, not a medical | |
| device. Always consult a qualified | |
| dermatologist for clinical evaluation. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Header | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown(""" | |
| <div style="padding:8px 0 28px;"> | |
| <h1 style="font-size:2.2rem;font-weight:900;margin:0;letter-spacing:-.02em;"> | |
| π¬ DermaScan <span style="background:linear-gradient(135deg,#7c5cfc,#c550ff); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;">AI</span> | |
| </h1> | |
| <p style="color:#7a76a8;margin:6px 0 0;font-size:1rem;"> | |
| Upload a dermoscopy image for automated ABCDE analysis, clinical measurements, | |
| risk scoring, and explainability heatmaps β powered by a trained U-Net. | |
| </p> | |
| </div> | |
| """, 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""" | |
| <div class="ds-card"> | |
| <h4>Image Quality Check</h4> | |
| <div style="font-size:2rem;font-weight:900;color:{q_color};">{quality['score']}<span style="font-size:1rem;color:#7a76a8;font-weight:400;"> / 100</span></div> | |
| <div style="color:{q_color};font-weight:700;margin-bottom:10px;">{q_icon} {"Good to analyze" if quality['ok'] else "Proceed with caution"}</div> | |
| """, 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("</div>", 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""" | |
| <div style="text-align:center;padding:10px 0 4px;"> | |
| <div style="font-size:.8rem;color:#7a76a8;letter-spacing:.08em; | |
| text-transform:uppercase;font-weight:700;margin-bottom:4px;"> | |
| Risk Score | |
| </div> | |
| <div style="font-size:3rem;font-weight:900;color:{gauge_color};line-height:1;"> | |
| {risk['score']} | |
| <span style="font-size:1.1rem;color:#7a76a8;font-weight:400;">/ 10</span> | |
| </div> | |
| <div style="margin-top:6px;"> | |
| <span style="background:{gauge_color}22;color:{gauge_color}; | |
| padding:4px 16px;border-radius:20px;font-weight:800; | |
| font-size:.85rem;border:1px solid {gauge_color}55;"> | |
| {risk['level']} | |
| </span> | |
| </div> | |
| <div style="color:#7a76a8;font-size:.82rem;margin-top:6px;">{risk['label']}</div> | |
| </div> | |
| """, 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""" | |
| <div class="ds-card" style="margin-top:4px;padding:14px 18px;"> | |
| <h4>Quick Stats</h4> | |
| <table style="width:100%;font-size:.85rem;border-collapse:collapse;"> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.1);"> | |
| <td style="color:#7a76a8;padding:5px 0;">Area</td> | |
| <td style="text-align:right;color:#a89aff;font-weight:700;">{meas['area_mm2']} mmΒ²</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.1);"> | |
| <td style="color:#7a76a8;padding:5px 0;">Perimeter</td> | |
| <td style="text-align:right;color:#a89aff;font-weight:700;">{meas['perimeter_mm']} mm</td> | |
| </tr> | |
| <tr> | |
| <td style="color:#7a76a8;padding:5px 0;">Coverage</td> | |
| <td style="text-align:right;color:#a89aff;font-weight:700;">{meas['coverage_pct']}%</td> | |
| </tr> | |
| </table> | |
| </div> | |
| """, 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""" | |
| <div class="ds-card" style="border-left:4px solid {color};"> | |
| <div style="display:flex;justify-content:space-between;align-items:flex-start;"> | |
| <div> | |
| <span style="font-size:1.6rem;font-weight:900;color:{color};">{letter}</span> | |
| <span style="font-size:1rem;font-weight:700;color:#a89aff;margin-left:8px;">{name}</span> | |
| </div> | |
| <div style="text-align:right;"> | |
| <div style="font-size:1.4rem;font-weight:800;color:#e8e4ff;">{value_str}</div> | |
| <div style="font-size:.8rem;color:{color};font-weight:700;">{icon} {label}</div> | |
| </div> | |
| </div> | |
| <div style="color:#7a76a8;font-size:.85rem;margin-top:10px;line-height:1.6;">{detail}</div> | |
| </div> | |
| """, 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: <strong>{A['axis_h']:.3f}</strong> " | |
| f"Vertical axis asymmetry: <strong>{A['axis_v']:.3f}</strong><br>" | |
| "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"<br>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: <strong>{B['circularity']:.3f}</strong> " | |
| 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: <strong>{D['area_mm2']} mmΒ²</strong> ({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'<span class="swatch" style="background:{hx};" title="{hx}"></span>' | |
| f'<code style="font-size:.8rem;margin-right:10px;">{hx}</code>' | |
| 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""" | |
| <div class="ds-card"> | |
| <h4>Full Measurement Table</h4> | |
| <table style="width:100%;border-collapse:collapse;font-size:.88rem;"> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Area (pixels)</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{meas['area_px']:,}</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Area (mmΒ²)</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{meas['area_mm2']}</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Perimeter (px)</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{meas['perimeter_px']}</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Perimeter (mm)</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{meas['perimeter_mm']}</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Image Coverage</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{meas['coverage_pct']} %</td> | |
| </tr> | |
| <tr style="border-bottom:1px solid rgba(120,100,255,.12);"> | |
| <td style="padding:8px 4px;color:#7a76a8;">Mean Color (RGB)</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{tuple(meas['mean_color_rgb'])}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding:8px 4px;color:#7a76a8;">Color Std Dev</td> | |
| <td style="padding:8px 4px;color:#e8e4ff;font-weight:700;text-align:right;">{tuple(meas['std_color_rgb'])}</td> | |
| </tr> | |
| </table> | |
| </div> | |
| """, 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""" | |
| <div style="display:flex;align-items:center;gap:12px;margin-top:12px;"> | |
| <div style="width:50px;height:50px;border-radius:10px;background:{mc};border:1px solid rgba(255,255,255,.2);"></div> | |
| <div> | |
| <div style="font-weight:700;color:#e8e4ff;">{mc.upper()}</div> | |
| <div style="color:#7a76a8;font-size:.8rem;">Mean lesion color</div> | |
| </div> | |
| </div> | |
| """, 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""" | |
| <div class="ds-card"> | |
| <h4>How to Read This</h4> | |
| <p style="color:#a89aff;font-size:.88rem;line-height:1.7;"> | |
| π΄ <strong>Red / Yellow</strong> β Regions the encoder focused on most intensely.<br> | |
| π΅ <strong>Blue / Dark</strong> β Low activation regions, less important to the prediction.<br><br> | |
| 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. | |
| </p> | |
| </div> | |
| """, 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""" | |
| <div class="ds-card"> | |
| <h4>Report Preview</h4> | |
| <div style="font-family:'JetBrains Mono',monospace;font-size:.82rem;line-height:2;color:#a89aff;"> | |
| βββββββββββββββββββββββββββββββββββββββββββββββ<br> | |
| DERMASCAN AI β CLINICAL LESION REPORT<br> | |
| Generated: {timestamp}<br> | |
| βββββββββββββββββββββββββββββββββββββββββββββββ<br><br> | |
| <span style="color:#7a76a8;">IMAGE QUALITY</span><br> | |
| Score: {res['quality']['score']} / 100 β {"OK" if res['quality']['ok'] else "Issues detected"}<br><br> | |
| <span style="color:#7a76a8;">ABCDE ANALYSIS</span><br> | |
| A Asymmetry: {A['score']} / 2 β {A['label']}<br> | |
| B Border: {B['score']:.3f} β {B['label']}<br> | |
| C Color: {C['count']} clusters β {C['label']}<br> | |
| D Diameter: {D['diameter_mm']} mm β {D['label']}<br><br> | |
| <span style="color:#7a76a8;">CLINICAL MEASUREMENTS</span><br> | |
| Area: {meas.get('area_mm2','β')} mmΒ²<br> | |
| Perimeter: {meas.get('perimeter_mm','β')} mm<br> | |
| Coverage: {meas.get('coverage_pct','β')} %<br><br> | |
| <span style="color:#7a76a8;">RISK ASSESSMENT</span><br> | |
| <span style="color:{risk['color']};font-weight:700;"> | |
| Score: {risk['score']} / 10.0 β {risk['level']}<br> | |
| {risk['label']}<br></span><br> | |
| βββββββββββββββββββββββββββββββββββββββββββββββ<br> | |
| <span style="color:#555;font-size:.75rem;">DISCLAIMER: AI screening tool β NOT a medical diagnosis.</span><br> | |
| βββββββββββββββββββββββββββββββββββββββββββββββ | |
| </div> | |
| </div> | |
| """, 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, | |
| ) | |