""" Severity model for DermaConnect. This module exposes a single function, `assess_severity(image_path)`, that any part of the app can call. Today it runs a lightweight, on-device heuristic that needs no API key, no GPU, and nothing heavier than Pillow, so the whole app is runnable out of the box. The return shape is the contract every caller depends on, so you can swap the guts for a real model (a trained CNN, or a hosted vision LLM) without touching the backend. Return contract: { "score": float, # 0-100, higher = more severe "category": str, # one of CATEGORIES "confidence":float, # 0-1, model's own confidence "signals": dict, # raw features, for debugging / clinician review "model": str, # model identifier, stored with every reading } --- How to swap in a real model ------------------------------------------------- 1. Trained CNN (recommended for production): - Train / fine-tune on a labelled derm dataset (e.g. severity grades for eczema (EASI), psoriasis (PASI), acne (IGA)). Keep one model per condition. - Replace `_heuristic_assess` with a function that loads your weights once (module-level, not per call) and returns the same dict. 2. Hosted vision LLM (fastest to wire up, good for a demo): - Send the image to a multimodal model and ask for a 0-100 severity grade plus a one-line rationale. Map its answer into the dict below. - Keep the `model` field accurate so readings stay auditable. Either way, do NOT change the return shape. The backend, the dashboards, and the stored history all rely on it. """ from __future__ import annotations import os from typing import Dict CATEGORIES = ["Clear", "Mild", "Moderate", "Severe"] MODEL_ID = "heuristic-erythema-v0" def _category_for_score(score: float) -> str: if score < 20: return "Clear" if score < 45: return "Mild" if score < 70: return "Moderate" return "Severe" def _heuristic_assess(image_path: str) -> Dict: """ A transparent, dependency-light stand-in for a real severity model. It estimates inflammation from three image signals that loosely track what a clinician eyeballs: redness (erythema), how much of the frame looks inflamed (extent), and texture irregularity (a rough proxy for scaling / lesions). Implemented with Pillow only, so it runs anywhere Python runs. This is NOT a medical device and is NOT diagnostic. It exists so the app runs end-to-end today. Replace it with a validated model before any real use. """ from PIL import Image, ImageFilter, ImageStat img = Image.open(image_path).convert("RGB") img.thumbnail((200, 200)) pixels = list(img.getdata()) n = len(pixels) or 1 # Per-pixel "red dominance". Healthy skin is ALREADY red-dominant, so the raw # value is useless on its own. The signal that matters is how much a pixel's # redness exceeds this patient's own healthy-skin baseline (local contrast). reds = [] for r, g, b in pixels: brightness = (r + g + b) / 3.0 + 1e-6 reds.append((r - (g + b) / 2.0) / brightness) # Baseline = median redness across the frame (the skin the lesion sits on). baseline = sorted(reds)[n // 2] margin = 0.06 # ignore small natural variation excess_sum = 0.0 inflamed = 0 for v in reds: excess = v - baseline - margin if excess > 0: excess_sum += excess if excess > 0.05: inflamed += 1 erythema = excess_sum / n # avg redness ABOVE healthy baseline extent = inflamed / n # fraction clearly inflamed vs. own skin # Texture: mean edge energy via Pillow's edge filter (scaling / lesion proxy) edges = img.convert("L").filter(ImageFilter.FIND_EDGES) texture = ImageStat.Stat(edges).mean[0] / 255.0 # Blend the signals into a 0-100 score. Weights are deliberately simple and # documented so a clinician can reason about them; a real model learns these. # Erythema dominates; extent and texture add lift. Calibrated so faint redness # lands Clear/Mild and dense inflammation lands Severe across the 0-100 band. raw = 1.7 * erythema + 0.5 * extent + 0.4 * texture score = float(max(0.0, min(100.0, raw * 100.0))) confidence = round(0.4 + 0.3 * min(1.0, extent * 3), 2) return { "score": round(score, 1), "category": _category_for_score(score), "confidence": confidence, "signals": { "erythema": round(erythema, 4), "extent": round(extent, 4), "texture": round(texture, 4), "baseline": round(baseline, 4), }, "model": MODEL_ID, } def assess_severity(image_path: str) -> Dict: """Public entry point. Swap the body to change models; keep the return shape.""" if not os.path.exists(image_path): raise FileNotFoundError(image_path) return _heuristic_assess(image_path)