Spaces:
Sleeping
Sleeping
| """ | |
| app_analysis.py | |
| --------------- | |
| Clinical analysis functions for DermaScan AI. | |
| All functions operate on numpy arrays. No Streamlit imports here β | |
| this module is pure logic, importable and testable independently. | |
| """ | |
| import io | |
| import cv2 | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| # ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) | |
| IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) | |
| IMAGE_SIZE = 256 | |
| THRESHOLD = 0.5 | |
| PIXELS_PER_MM = 25.0 # typical dermoscope calibration | |
| # ββ Preprocessing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def preprocess(image_rgb: np.ndarray) -> torch.Tensor: | |
| """HΓWΓ3 uint8 numpy array β 1Γ3Γ256Γ256 float32 tensor (ImageNet-normalised).""" | |
| img = cv2.resize(image_rgb, (IMAGE_SIZE, IMAGE_SIZE)).astype(np.float32) / 255.0 | |
| img = (img - IMAGENET_MEAN) / IMAGENET_STD | |
| tensor = torch.from_numpy(img.transpose(2, 0, 1)).unsqueeze(0).float() | |
| return tensor | |
| # ββ Segmentation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_segmentation(model, device, image_rgb: np.ndarray): | |
| """ | |
| Run U-Net inference on a single RGB image. | |
| Returns | |
| ------- | |
| mask_orig : np.ndarray bool HΓW mask in original image resolution | |
| prob_256 : np.ndarray float32 probability map at 256Γ256 | |
| """ | |
| model.eval() | |
| tensor = preprocess(image_rgb).to(device) | |
| prob = model(tensor).squeeze().cpu().numpy() # (256, 256) float32 | |
| mask_256 = (prob > THRESHOLD).astype(np.uint8) | |
| h, w = image_rgb.shape[:2] | |
| mask_orig = cv2.resize(mask_256, (w, h), | |
| interpolation=cv2.INTER_NEAREST).astype(bool) | |
| return mask_orig, prob | |
| # ββ Image Quality Check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def check_image_quality(image_rgb: np.ndarray) -> dict: | |
| """ | |
| Assess uploaded image quality before running the model. | |
| Returns a dict with 'ok' bool, numeric scores, and a list of issues. | |
| """ | |
| gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) | |
| blur = float(cv2.Laplacian(gray, cv2.CV_64F).var()) | |
| brightness = float(gray.mean()) | |
| contrast = float(gray.std()) | |
| issues = [] | |
| if blur < 80: | |
| issues.append("Image may be blurry β results may be unreliable.") | |
| if brightness < 40: | |
| issues.append("Image is too dark.") | |
| if brightness > 230: | |
| issues.append("Image is overexposed.") | |
| if contrast < 15: | |
| issues.append("Very low contrast β lesion boundaries may be invisible.") | |
| quality_score = min(100.0, | |
| (min(blur, 500) / 500) * 40 + | |
| (contrast / 80) * 40 + | |
| (1 - abs(brightness - 128) / 128) * 20 | |
| ) | |
| return { | |
| "ok": len(issues) == 0, | |
| "score": round(quality_score, 1), | |
| "blur": round(blur, 1), | |
| "brightness": round(brightness, 1), | |
| "contrast": round(contrast, 1), | |
| "issues": issues, | |
| } | |
| # ββ ABCDE: A β Asymmetry βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def abcde_asymmetry(mask: np.ndarray) -> dict: | |
| """ | |
| A criterion: measure asymmetry on the two principal lesion axes. | |
| Score 0 (symmetric) | 1 (one axis) | 2 (both axes asymmetric). | |
| """ | |
| m = mask.astype(np.uint8) | |
| if m.sum() == 0: | |
| return {"score": 0, "axis_h": 0.0, "axis_v": 0.0, "label": "Symmetric", "risk": False} | |
| # Find centroid | |
| M = cv2.moments(m) | |
| cy = int(M["m01"] / (M["m00"] + 1e-6)) | |
| cx = int(M["m10"] / (M["m00"] + 1e-6)) | |
| def _half_overlap(a, b): | |
| """Overlap ratio of two halves (flipped to align).""" | |
| min_r = min(a.shape[0], b.shape[0]) | |
| min_c = min(a.shape[1], b.shape[1]) | |
| a, b = a[:min_r, :min_c], b[:min_r, :min_c] | |
| union = (a | b).sum() | |
| inter = (a & b).sum() | |
| return float(inter) / float(union + 1e-6) | |
| # Horizontal split (top vs bottom) | |
| top = m[:cy, :] | |
| bottom = np.flipud(m[cy:, :]) | |
| asym_h = 1.0 - _half_overlap(top.astype(bool), bottom.astype(bool)) | |
| # Vertical split (left vs right) | |
| left = m[:, :cx] | |
| right = np.fliplr(m[:, cx:]) | |
| asym_v = 1.0 - _half_overlap(left.astype(bool), right.astype(bool)) | |
| thr = 0.18 | |
| score = int(asym_h > thr) + int(asym_v > thr) | |
| labels = {0: "Symmetric", 1: "Mildly Asymmetric", 2: "Highly Asymmetric"} | |
| return { | |
| "score": score, | |
| "axis_h": round(asym_h, 3), | |
| "axis_v": round(asym_v, 3), | |
| "label": labels[score], | |
| "risk": score >= 1, | |
| } | |
| # ββ ABCDE: B β Border βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def abcde_border(mask: np.ndarray) -> dict: | |
| """ | |
| B criterion: border irregularity via the circularity index. | |
| irregularity = 1 β (4ΟΒ·Area / PerimeterΒ²) | |
| 0 = perfect circle (smooth border, low risk) | |
| 1 = maximally irregular (high risk) | |
| """ | |
| m = mask.astype(np.uint8) | |
| contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| if not contours or m.sum() == 0: | |
| return {"score": 0.0, "circularity": 1.0, "label": "Regular", "risk": False} | |
| cnt = max(contours, key=cv2.contourArea) | |
| area = cv2.contourArea(cnt) | |
| peri = cv2.arcLength(cnt, True) | |
| if peri < 1 or area < 1: | |
| return {"score": 0.0, "circularity": 1.0, "label": "Regular", "risk": False} | |
| circularity = (4 * np.pi * area) / (peri ** 2) | |
| irregularity = round(float(np.clip(1 - circularity, 0, 1)), 3) | |
| if irregularity < 0.25: | |
| label, risk = "Regular", False | |
| elif irregularity < 0.50: | |
| label, risk = "Mildly Irregular", True | |
| else: | |
| label, risk = "Irregular", True | |
| return { | |
| "score": irregularity, | |
| "circularity": round(float(circularity), 3), | |
| "label": label, | |
| "risk": risk, | |
| } | |
| # ββ ABCDE: C β Color ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def abcde_color(image_rgb: np.ndarray, mask: np.ndarray) -> dict: | |
| """ | |
| C criterion: count distinct color clusters inside the lesion via k-Means. | |
| β₯ 3 distinct clusters = higher clinical concern. | |
| """ | |
| from sklearn.cluster import KMeans # lazy import | |
| pixels = image_rgb[mask > 0] | |
| if len(pixels) < 20: | |
| return {"count": 1, "label": "Single Color", "hex_colors": ["#888888"], "risk": False} | |
| n_k = min(6, max(2, len(pixels) // 100)) | |
| km = KMeans(n_clusters=n_k, n_init=5, random_state=42) | |
| km.fit(pixels) | |
| labels_arr, counts = np.unique(km.labels_, return_counts=True) | |
| min_pop = 0.03 * len(pixels) # cluster must hold β₯ 3 % of pixels | |
| order = counts.argsort()[::-1] | |
| hex_colors = [] | |
| significant = 0 | |
| for i in order: | |
| if counts[i] >= min_pop: | |
| r, g, b = km.cluster_centers_[i].astype(int) | |
| hex_colors.append(f"#{int(r):02x}{int(g):02x}{int(b):02x}") | |
| significant += 1 | |
| if significant <= 2: | |
| label, risk = "Uniform Color", False | |
| elif significant == 3: | |
| label, risk = "Moderate Variation", True | |
| else: | |
| label, risk = "High Variation", True | |
| return { | |
| "count": significant, | |
| "label": label, | |
| "hex_colors": hex_colors, | |
| "risk": risk, | |
| } | |
| # ββ ABCDE: D β Diameter βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def abcde_diameter(mask: np.ndarray) -> dict: | |
| """ | |
| D criterion: estimate real-world diameter from pixel area. | |
| Clinical threshold: > 6 mm is a warning sign. | |
| """ | |
| area_px = int(mask.astype(bool).sum()) | |
| if area_px == 0: | |
| return {"area_px": 0, "area_mm2": 0.0, "diameter_mm": 0.0, | |
| "label": "No lesion", "risk": False, "coverage_pct": 0.0} | |
| area_mm2 = area_px / (PIXELS_PER_MM ** 2) | |
| diameter_mm = 2 * np.sqrt(area_mm2 / np.pi) | |
| total_px = mask.shape[0] * mask.shape[1] | |
| coverage = 100.0 * area_px / total_px | |
| flag = diameter_mm > 6.0 | |
| return { | |
| "area_px": area_px, | |
| "area_mm2": round(area_mm2, 2), | |
| "diameter_mm": round(diameter_mm, 2), | |
| "coverage_pct": round(coverage, 1), | |
| "label": "Large (>6 mm)" if flag else "Small (<6 mm)", | |
| "risk": flag, | |
| } | |
| # ββ Risk Score ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_risk(A: dict, B: dict, C: dict, D: dict, | |
| demographics: dict | None = None) -> dict: | |
| """ | |
| Combined ABCD weighted risk score (0β10 scale). | |
| Demographic multipliers optionally applied. | |
| """ | |
| raw = (A["score"] * 1.3 + | |
| B["score"] * 2.0 + | |
| C["count"] * 0.4 + | |
| float(D["risk"]) * 1.0) | |
| if demographics: | |
| mod = 1.0 | |
| if demographics.get("age_over_50"): mod *= 1.2 | |
| if demographics.get("fair_skin"): mod *= 1.2 | |
| if demographics.get("family_history"): mod *= 1.4 | |
| if demographics.get("prev_melanoma"): mod *= 1.8 | |
| if demographics.get("high_sun_exposure"):mod *= 1.1 | |
| raw = min(raw * mod, 10.0) | |
| score = round(float(np.clip(raw, 0, 10)), 2) | |
| if score < 2.5: | |
| level, label, color = "LOW", "Likely Benign", "#22c55e" | |
| elif score < 5.0: | |
| level, label, color = "MEDIUM", "Monitor β Seek Advice", "#f59e0b" | |
| else: | |
| level, label, color = "HIGH", "Consult Dermatologist", "#ef4444" | |
| return { | |
| "score": score, | |
| "level": level, | |
| "label": label, | |
| "color": color, | |
| } | |
| # ββ Clinical Measurements βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_measurements(image_rgb: np.ndarray, mask: np.ndarray) -> dict: | |
| """Full set of clinical measurements derived from the mask and image.""" | |
| m = mask.astype(np.uint8) | |
| area_px = int(m.sum()) | |
| if area_px == 0: | |
| return {} | |
| contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| cnt = max(contours, key=cv2.contourArea) if contours else None | |
| peri_px = float(cv2.arcLength(cnt, True)) if cnt is not None else 0.0 | |
| # Bounding box | |
| x, y, bw, bh = cv2.boundingRect(cnt) if cnt is not None else (0, 0, 0, 0) | |
| # Mean & std color inside lesion | |
| lesion_px = image_rgb[mask > 0].astype(float) | |
| mean_rgb = lesion_px.mean(axis=0).astype(int) if len(lesion_px) else np.array([0, 0, 0]) | |
| std_rgb = lesion_px.std(axis=0) if len(lesion_px) else np.array([0, 0, 0]) | |
| mean_hex = f"#{int(mean_rgb[0]):02x}{int(mean_rgb[1]):02x}{int(mean_rgb[2]):02x}" | |
| return { | |
| "area_px": area_px, | |
| "area_mm2": round(area_px / PIXELS_PER_MM**2, 2), | |
| "perimeter_px": round(peri_px, 1), | |
| "perimeter_mm": round(peri_px / PIXELS_PER_MM, 2), | |
| "bbox": (x, y, bw, bh), | |
| "bbox_mm": (round(x/PIXELS_PER_MM,1), round(y/PIXELS_PER_MM,1), | |
| round(bw/PIXELS_PER_MM,1), round(bh/PIXELS_PER_MM,1)), | |
| "mean_color_rgb": mean_rgb.tolist(), | |
| "mean_color_hex": mean_hex, | |
| "std_color_rgb": [round(v, 1) for v in std_rgb.tolist()], | |
| "coverage_pct": round(100 * area_px / (mask.shape[0] * mask.shape[1]), 2), | |
| } | |
| # ββ Overlay βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_overlay(image_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray: | |
| """ | |
| Returns a copy of image_rgb with: | |
| - semi-transparent green tint over the lesion | |
| - bright red contour on the lesion boundary | |
| """ | |
| overlay = image_rgb.copy().astype(np.float32) | |
| m = mask.astype(bool) | |
| # Green tint inside lesion | |
| tint = np.array([0, 220, 150], dtype=np.float32) | |
| overlay[m] = overlay[m] * 0.55 + tint * 0.45 | |
| result = np.clip(overlay, 0, 255).astype(np.uint8) | |
| # Red contour | |
| cnt_mask = mask.astype(np.uint8) | |
| contours, _ = cv2.findContours(cnt_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| cv2.drawContours(result, contours, -1, (255, 70, 70), 2) | |
| return result | |
| # ββ Grad-CAM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_gradcam(model, device, image_rgb: np.ndarray) -> np.ndarray: | |
| """ | |
| Manual Grad-CAM targeting the last encoder DoubleConv block. | |
| Returns an HΓWΓ3 uint8 heatmap blended with the original image. | |
| """ | |
| model.eval() | |
| tensor = preprocess(image_rgb).to(device) | |
| activations: dict = {} | |
| gradients: dict = {} | |
| def _fwd(module, inp, out): | |
| activations["v"] = out | |
| def _bwd(module, gin, gout): | |
| gradients["v"] = gout[0] | |
| target = model.encoders[-1] | |
| fh = target.register_forward_hook(_fwd) | |
| bh = target.register_full_backward_hook(_bwd) | |
| output = model(tensor) | |
| model.zero_grad() | |
| output.mean().backward() | |
| fh.remove() | |
| bh.remove() | |
| act = activations["v"].detach().squeeze(0) # CΓHΓW | |
| grad = gradients["v"].detach().squeeze(0) # CΓHΓW | |
| w = grad.mean(dim=(1, 2), keepdim=True) | |
| cam = F.relu((w * act).sum(dim=0)).cpu().numpy() # HΓW | |
| if cam.max() > 0: | |
| cam = cam / cam.max() | |
| h, w_img = image_rgb.shape[:2] | |
| cam_up = cv2.resize(cam, (w_img, h)) | |
| heatmap = cv2.applyColorMap((cam_up * 255).astype(np.uint8), cv2.COLORMAP_JET) | |
| heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) | |
| blended = (0.55 * image_rgb.astype(np.float32) + | |
| 0.45 * heatmap.astype(np.float32)).clip(0, 255).astype(np.uint8) | |
| return blended | |
| # ββ Lesion Change Map βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compare_masks(mask_old: np.ndarray, mask_new: np.ndarray) -> dict: | |
| """ | |
| Compare two binary masks from different timepoints. | |
| Returns growth stats and a three-channel change map (RGB). | |
| """ | |
| # Resize new mask to match old | |
| if mask_old.shape != mask_new.shape: | |
| mask_new = cv2.resize( | |
| mask_new.astype(np.uint8), (mask_old.shape[1], mask_old.shape[0]), | |
| interpolation=cv2.INTER_NEAREST | |
| ).astype(bool) | |
| old = mask_old.astype(bool) | |
| new = mask_new.astype(bool) | |
| area_old = int(old.sum()) | |
| area_new = int(new.sum()) | |
| growth = round((area_new - area_old) / (area_old + 1e-6) * 100, 1) | |
| iou = float((old & new).sum()) / float((old | new).sum() + 1e-6) | |
| # Change map image | |
| h, w = old.shape | |
| change_img = np.ones((h, w, 3), dtype=np.uint8) * 30 # dark background | |
| # Stable (both masks) | |
| change_img[old & new] = [100, 200, 120] # green | |
| # New growth | |
| change_img[~old & new] = [255, 80, 80] # red | |
| # Regression | |
| change_img[old & ~new] = [80, 130, 255] # blue | |
| return { | |
| "area_old_mm2": round(area_old / PIXELS_PER_MM**2, 2), | |
| "area_new_mm2": round(area_new / PIXELS_PER_MM**2, 2), | |
| "growth_pct": growth, | |
| "iou": round(iou, 3), | |
| "warning": growth > 20 or iou < 0.70, | |
| "change_image": change_img, | |
| } | |
| # ββ PDF Report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_pdf(image_rgb: np.ndarray, mask: np.ndarray, | |
| quality: dict, abcde: dict, | |
| risk: dict, meas: dict) -> bytes: | |
| """ | |
| Generate a PDF clinical report and return as bytes. | |
| Requires: pip install fpdf2 | |
| NOTE: Helvetica is Latin-1 only. All text passes through safe() first. | |
| """ | |
| try: | |
| from fpdf import FPDF | |
| except ImportError: | |
| return b"" | |
| # ββ Sanitise any non-Latin-1 characters to ASCII equivalents ββββββββββββββ | |
| def safe(text: str) -> str: | |
| return ( | |
| str(text) | |
| .replace("\u2014", " - ") # em dash | |
| .replace("\u2013", " - ") # en dash | |
| .replace("\u00b2", "2") # superscript 2 (mmΒ²) | |
| .replace("\u00b0", " deg") # degree sign | |
| .replace("\u2265", ">=") # >= | |
| .replace("\u2264", "<=") # <= | |
| .replace("\u00d7", "x") # multiplication sign | |
| .replace("\u03c0", "pi") # Greek pi | |
| .replace("\u2192", "->") # right arrow | |
| .encode("latin-1", errors="replace").decode("latin-1") | |
| ) | |
| pdf = FPDF() | |
| pdf.add_page() | |
| pdf.set_auto_page_break(auto=True, margin=15) | |
| # ββ Header ββ | |
| pdf.set_fill_color(20, 20, 50) | |
| pdf.rect(0, 0, 210, 28, "F") | |
| pdf.set_text_color(200, 180, 255) | |
| pdf.set_font("Helvetica", "B", 18) | |
| pdf.set_y(8) | |
| pdf.cell(0, 12, safe("DermaScan AI - Clinical Skin Lesion Report"), align="C") | |
| pdf.set_text_color(0, 0, 0) | |
| pdf.set_y(34) | |
| # ββ Images ββ | |
| def _np_to_tmpfile(arr, suffix=".jpg"): | |
| import tempfile | |
| tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) | |
| Image.fromarray(arr).save(tmp.name) | |
| return tmp.name | |
| orig_path = _np_to_tmpfile(image_rgb) | |
| overlay_path = _np_to_tmpfile(make_overlay(image_rgb, mask)) | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(95, 8, "Original Image", align="C") | |
| pdf.cell(95, 8, "Segmentation Overlay", align="C") | |
| pdf.ln(2) | |
| pdf.image(orig_path, x=10, y=pdf.get_y(), w=88, h=66) | |
| pdf.image(overlay_path, x=112, y=pdf.get_y(), w=88, h=66) | |
| pdf.ln(70) | |
| # ββ Body helper ββ | |
| def section(title): | |
| pdf.set_fill_color(230, 225, 255) | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(0, 8, safe(f" {title}"), fill=True, ln=True) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.ln(1) | |
| def row(label, value, indent=8): | |
| pdf.set_x(indent) | |
| pdf.cell(72, 6, safe(str(label))) | |
| pdf.cell(0, 6, safe(str(value)), ln=True) | |
| # ββ Quality ββ | |
| section("Image Quality") | |
| row("Quality Score", f"{quality['score']} / 100") | |
| row("Blur Score", quality['blur']) | |
| row("Brightness", quality['brightness']) | |
| row("Contrast", quality['contrast']) | |
| pdf.ln(3) | |
| # ββ ABCDE ββ | |
| section("ABCDE Clinical Analysis") | |
| A, B, C, D = abcde["A"], abcde["B"], abcde["C"], abcde["D"] | |
| row("A Asymmetry", f"{A['score']} / 2 - {A['label']}") | |
| row("B Border", f"{B['score']:.2f} irregularity - {B['label']}") | |
| row("C Color", f"{C['count']} distinct clusters - {C['label']}") | |
| row("D Diameter", f"{D['diameter_mm']:.1f} mm - {D['label']}") | |
| pdf.ln(3) | |
| # ββ Measurements ββ | |
| section("Clinical Measurements") | |
| if meas: | |
| row("Area", f"{meas['area_mm2']} mm2 ({meas['area_px']} px)") | |
| row("Perimeter", f"{meas['perimeter_mm']} mm") | |
| row("Coverage", f"{meas['coverage_pct']} % of image") | |
| row("Mean Color", meas['mean_color_hex'].upper()) | |
| pdf.ln(3) | |
| # ββ Risk Score ββ | |
| section("Risk Assessment") | |
| pdf.set_font("Helvetica", "B", 14) | |
| pdf.set_text_color(*_hex_to_rgb(risk["color"])) | |
| pdf.cell(0, 10, safe(f" {risk['level']} Score: {risk['score']} / 10.0"), ln=True) | |
| pdf.cell(0, 8, safe(f" {risk['label']}"), ln=True) | |
| pdf.set_text_color(0, 0, 0) | |
| pdf.set_font("Helvetica", "I", 9) | |
| pdf.ln(2) | |
| pdf.multi_cell(0, 6, safe( | |
| "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." | |
| )) | |
| buf = io.BytesIO() | |
| pdf.output(buf) | |
| return buf.getvalue() | |
| def _hex_to_rgb(hex_color: str): | |
| hex_color = hex_color.lstrip("#") | |
| return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |