"""Integrated DFU Clinical Assessment Report — single-image dashboard. Renders a unified clinical dashboard PNG that nurses and clinicians can review at a glance. Combines: - Original photograph - Binary ulcer mask - Multi-class tissue map (background / foot / perilesion / ulcer) - Class area distribution (horizontal bars) - Fitzpatrick / ITA estimation with color-coded badge - PWAT items 3-8 table (raw vs adjusted, delta, severity bars) - Total raw / total adjusted + clinical interpretation by range - Lighting-quality warnings when applicable Designed for clinical staff: clean typography, clear hierarchy, consistent palette, fixed 1920x1200 px layout. Usage: from src.integrated_report import render_integrated_report dashboard = render_integrated_report(rgb, binary_overlay, multi_overlay, result) # dashboard: np.ndarray RGB (1200, 1920, 3) uint8 """ from __future__ import annotations from datetime import datetime import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont from src.pwat_estimator import ITEM_NAMES # ── Palette and design constants ──────────────────────────────────────────── DASH_W, DASH_H = 1920, 1200 COL_BG = (248, 250, 252) # slate-50 COL_CARD = (255, 255, 255) COL_CARD_BORDER = (226, 232, 240) # slate-200 COL_INK = (15, 23, 42) # slate-900 COL_INK_SOFT = (71, 85, 105) # slate-600 COL_INK_MUTE = (148, 163, 184) # slate-400 COL_HEADER_BG = (15, 23, 42) COL_HEADER_FG = (248, 250, 252) COL_ACCENT = (14, 116, 144) # cyan-700 COL_DANGER = (220, 38, 38) # red-600 COL_WARN = (217, 119, 6) # amber-600 COL_OK = (5, 150, 105) # emerald-600 COL_INFO = (37, 99, 235) # blue-600 CLASS_COLOR = { "foot": (34, 197, 94), # green-500 "perilesion": (249, 115, 22), # orange-500 "ulcer": (239, 68, 68), # red-500 "background": (107, 114, 128), # gray-500 } CLASS_LABEL_EN = { "foot": "Healthy foot", "perilesion": "Perilesional", "ulcer": "Ulcer", "background": "Background", } FITZ_RGB = { "I": (254, 243, 199), "II": (253, 224, 138), "III": (245, 158, 11), "IV": (180, 83, 9), "V": (120, 53, 15), "VI": (45, 16, 4), } FITZ_TEXT_RGB = { "I": (31, 41, 55), "II": (31, 41, 55), "III": (31, 41, 55), "IV": (255, 255, 255), "V": (255, 255, 255), "VI": (255, 255, 255), } PWAT_INTERPRETATION = [ (0, 6, "Healing well", COL_OK), (7, 12, "Moderate compromise — clinical follow-up", COL_WARN), (13, 18, "Severe compromise — adjust treatment", COL_DANGER), (19, 24, "Critical — urgent reassessment", (153, 27, 27)), ] SEV_LABEL = {0: "Normal", 1: "Mild", 2: "Moderate", 3: "Severe", 4: "Extreme"} # ── Font helpers ───────────────────────────────────────────────────────────── _FONT_BOLD_CANDIDATES = [ "DejaVuSans-Bold.ttf", "C:/Windows/Fonts/segoeuib.ttf", "C:/Windows/Fonts/arialbd.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf", ] _FONT_REG_CANDIDATES = [ "DejaVuSans.ttf", "C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", ] def _load_font(candidates, size): for path in candidates: try: return ImageFont.truetype(path, size) except (OSError, IOError): continue return ImageFont.load_default() def _font_b(size: int): return _load_font(_FONT_BOLD_CANDIDATES, size) def _font_r(size: int): return _load_font(_FONT_REG_CANDIDATES, size) def _text_size(draw: ImageDraw.ImageDraw, text: str, font) -> tuple[int, int]: box = draw.textbbox((0, 0), text, font=font) return box[2] - box[0], box[3] - box[1] # ── Drawing helpers ────────────────────────────────────────────────────────── def _rounded_card(draw: ImageDraw.ImageDraw, xy, radius=14, fill=COL_CARD, border=COL_CARD_BORDER, border_w=1, shadow=True): """Rounded card with subtle drop shadow.""" x1, y1, x2, y2 = xy if shadow: for off, alpha in [(2, 18), (4, 8)]: shadow_color = (15, 23, 42, alpha) draw.rounded_rectangle( (x1 + off, y1 + off, x2 + off, y2 + off), radius=radius, fill=shadow_color, ) draw.rounded_rectangle((x1, y1, x2, y2), radius=radius, fill=fill, outline=border, width=border_w) def _fit_image_into(img_rgb: np.ndarray, w: int, h: int) -> np.ndarray: """Letterbox an image into a w x h canvas preserving aspect ratio.""" if img_rgb is None or img_rgb.size == 0: return np.full((h, w, 3), 240, dtype=np.uint8) src_h, src_w = img_rgb.shape[:2] scale = min(w / src_w, h / src_h) new_w, new_h = int(src_w * scale), int(src_h * scale) resized = cv2.resize(img_rgb, (new_w, new_h), interpolation=cv2.INTER_AREA) canvas = np.full((h, w, 3), 245, dtype=np.uint8) off_x = (w - new_w) // 2 off_y = (h - new_h) // 2 canvas[off_y:off_y + new_h, off_x:off_x + new_w] = resized return canvas def _paste_image(base_img: Image.Image, np_rgb: np.ndarray, box: tuple[int, int, int, int]): x1, y1, x2, y2 = box fitted = _fit_image_into(np_rgb, x2 - x1, y2 - y1) base_img.paste(Image.fromarray(fitted), (x1, y1)) def _hbar(draw, x, y, w, h, value, vmax, color, bg=(229, 231, 235), radius=4): """Rounded horizontal bar with proportional fill.""" draw.rounded_rectangle((x, y, x + w, y + h), radius=radius, fill=bg) if vmax > 0 and value > 0: fill_w = max(2, int(w * value / vmax)) draw.rounded_rectangle((x, y, x + fill_w, y + h), radius=radius, fill=color) def _interpretation_for(total: float) -> tuple[str, tuple]: """Map a (possibly fractional) PWAT total to its clinical band. Rounds to nearest integer first so that 6.7 falls into the 7-12 band (matches how clinicians read these cutoffs). """ rounded = int(round(max(0.0, min(24.0, total)))) for lo, hi, label, color in PWAT_INTERPRETATION: if lo <= rounded <= hi: return label, color return PWAT_INTERPRETATION[-1][2], PWAT_INTERPRETATION[-1][3] def _draw_wrapped_text(draw, text, xy, max_w, font, fill, max_lines=3): """Simple word-wrap by pixel width.""" if not text: return words = text.split() lines, cur = [], "" for w in words: trial = (cur + " " + w).strip() tw, _ = _text_size(draw, trial, font) if tw <= max_w: cur = trial else: if cur: lines.append(cur) cur = w if len(lines) >= max_lines: break if cur and len(lines) < max_lines: lines.append(cur) if len(lines) == max_lines and len(" ".join(lines)) < len(text): last = lines[-1] while last and _text_size(draw, last + "...", font)[0] > max_w: last = last[:-1] lines[-1] = last + "..." x, y = xy line_h = font.size + 4 if hasattr(font, "size") else 18 for ln in lines: draw.text((x, y), ln, fill=fill, font=font) y += line_h # ── Dashboard blocks ───────────────────────────────────────────────────────── def _draw_header(img: Image.Image, draw: ImageDraw.ImageDraw): H = 90 draw.rectangle((0, 0, DASH_W, H), fill=COL_HEADER_BG) # Logo accent dot_x, dot_y = 36, 32 draw.ellipse((dot_x, dot_y, dot_x + 26, dot_y + 26), fill=(34, 197, 94)) draw.ellipse((dot_x + 6, dot_y + 6, dot_x + 20, dot_y + 20), fill=COL_HEADER_BG) title = "Integrated Diabetic Foot Ulcer Assessment Report" subtitle = ("WoundNetB7 - EfficientNet-B7 + ASPP + CBAM + CoordAttention + TAM " " - Ulcer Dice 0.927") draw.text((78, 18), title, fill=COL_HEADER_FG, font=_font_b(28)) draw.text((78, 56), subtitle, fill=(148, 163, 184), font=_font_r(15)) # Right-side timestamp + advisory ts = datetime.now().strftime("%Y-%m-%d %H:%M") ts_w, _ = _text_size(draw, ts, _font_r(15)) draw.text((DASH_W - 36 - ts_w, 22), ts, fill=(148, 163, 184), font=_font_r(15)) advisory = "Research use only — not a clinical diagnosis" adv_w, _ = _text_size(draw, advisory, _font_r(13)) draw.text((DASH_W - 36 - adv_w, 46), advisory, fill=(251, 191, 36), font=_font_r(13)) def _draw_image_strip(img: Image.Image, draw: ImageDraw.ImageDraw, original_rgb, binary_rgb, multi_rgb, class_distribution: dict): """Top row: original + binary + multiclass + class distribution.""" y0 = 110 y1 = y0 + 360 pad = 20 card_w = (DASH_W - pad * 5) // 4 titles = ["Original Image", "Binary Ulcer Mask", "Multi-Class Segmentation", "Class Area Distribution"] images = [original_rgb, binary_rgb, multi_rgb, None] for i, (t, im) in enumerate(zip(titles, images)): x1 = pad + i * (card_w + pad) x2 = x1 + card_w _rounded_card(draw, (x1, y0, x2, y1)) draw.text((x1 + 16, y0 + 12), t, fill=COL_INK, font=_font_b(15)) cx1, cy1, cx2, cy2 = x1 + 14, y0 + 42, x2 - 14, y1 - 14 if im is not None: _paste_image(img, im, (cx1, cy1, cx2, cy2)) else: _draw_class_distribution(draw, class_distribution, (cx1, cy1, cx2, cy2)) def _draw_class_distribution(draw, dist: dict, box: tuple[int, int, int, int]): x1, y1, x2, y2 = box inner_w = x2 - x1 items = [("ulcer", dist.get("ulcer", 0)), ("perilesion", dist.get("perilesion", 0)), ("foot", dist.get("foot", 0)), ("background", dist.get("background", 0))] row_h = 56 cur_y = y1 + 6 for cls, pct in items: color = CLASS_COLOR[cls] label = CLASS_LABEL_EN[cls] draw.rounded_rectangle((x1, cur_y, x1 + 14, cur_y + 14), radius=3, fill=color) draw.text((x1 + 24, cur_y - 2), label, fill=COL_INK, font=_font_b(15)) pct_text = f"{pct:.1f} %" pw, _ = _text_size(draw, pct_text, _font_b(15)) draw.text((x2 - pw, cur_y - 2), pct_text, fill=COL_INK, font=_font_b(15)) _hbar(draw, x1, cur_y + 22, inner_w, 10, value=min(pct, 100), vmax=100, color=color, bg=(241, 245, 249), radius=5) cur_y += row_h def _draw_fitzpatrick_card(draw: ImageDraw.ImageDraw, fitz, x1: int, y1: int, x2: int, y2: int): """Fitzpatrick / ITA card.""" _rounded_card(draw, (x1, y1, x2, y2)) draw.text((x1 + 22, y1 + 14), "Fitzpatrick / ITA Skin Type Estimation", fill=COL_INK, font=_font_b(18)) draw.text((x1 + 22, y1 + 42), "Calibrated on 61 DFU images — 86.9% exact match — r=0.975", fill=COL_INK_SOFT, font=_font_r(13)) if fitz is None or getattr(fitz, "confidence", 0) == 0: draw.text((x1 + 22, y1 + 80), "Not estimable (insufficient healthy-skin pixels).", fill=COL_INK_MUTE, font=_font_r(15)) return ftype = fitz.fitzpatrick_type bg_c = FITZ_RGB.get(ftype, (229, 231, 235)) fg_c = FITZ_TEXT_RGB.get(ftype, COL_INK) light_q = getattr(fitz, "lighting_quality", "good") light_warn = getattr(fitz, "lighting_warning", "") banner_y = y1 + 70 banner_h = 0 if light_q == "insufficient": banner_h = 56 draw.rounded_rectangle((x1 + 22, banner_y, x2 - 22, banner_y + banner_h), radius=8, fill=(254, 242, 242), outline=(252, 165, 165), width=1) draw.text((x1 + 36, banner_y + 8), "Insufficient lighting", fill=(185, 28, 28), font=_font_b(14)) _draw_wrapped_text(draw, light_warn, (x1 + 36, banner_y + 28), x2 - 22 - 36, _font_r(12), (153, 27, 27), max_lines=2) elif light_q == "low": banner_h = 56 draw.rounded_rectangle((x1 + 22, banner_y, x2 - 22, banner_y + banner_h), radius=8, fill=(255, 251, 235), outline=(252, 211, 77), width=1) draw.text((x1 + 36, banner_y + 8), "Suboptimal lighting", fill=(180, 83, 9), font=_font_b(14)) _draw_wrapped_text(draw, light_warn, (x1 + 36, banner_y + 28), x2 - 22 - 36, _font_r(12), (146, 64, 14), max_lines=2) body_y = y1 + 82 + banner_h # Skin-tone badge badge_w, badge_h = 220, 220 bx1, by1 = x1 + 22, body_y bx2, by2 = bx1 + badge_w, by1 + badge_h draw.rounded_rectangle((bx1, by1, bx2, by2), radius=18, fill=bg_c, outline=(180, 180, 180), width=2) draw.text((bx1 + 24, by1 + 32), "TYPE", fill=fg_c, font=_font_b(20)) big = _font_b(110) bw, _ = _text_size(draw, ftype, big) draw.text((bx1 + (badge_w - bw) // 2, by1 + 60), ftype, fill=fg_c, font=big) label_font = _font_r(18) lw, _ = _text_size(draw, fitz.fitzpatrick_label, label_font) draw.text((bx1 + (badge_w - lw) // 2, by2 - 36), fitz.fitzpatrick_label, fill=fg_c, font=label_font) # Detail rows on the right dx = bx2 + 30 dy = by1 + 6 rows = [ ("ITA", f"{fitz.ita_angle:.1f} +/- {fitz.ita_std:.1f} deg"), ("L* healthy skin", f"{fitz.l_skin_mean:.1f}"), ("L* scene (global)", f"{getattr(fitz, 'l_scene_mean', 0):.1f}"), ("b* healthy skin", f"{fitz.b_skin_mean:.1f}"), ("Healthy pixels", f"{fitz.healthy_pixels:,}"), ("Confidence", f"{fitz.confidence:.0%}"), ] for k, v in rows: draw.text((dx, dy), k + ":", fill=COL_INK_SOFT, font=_font_r(14)) draw.text((dx + 170, dy), v, fill=COL_INK, font=_font_b(14)) dy += 28 # Confidence bar cy = by2 - 22 _hbar(draw, dx, cy, x2 - 30 - dx, 8, value=fitz.confidence, vmax=1.0, color=COL_OK if fitz.confidence > 0.6 else (COL_WARN if fitz.confidence > 0.3 else COL_DANGER), bg=(241, 245, 249)) def _draw_pwat_card(draw: ImageDraw.ImageDraw, pwat, x1: int, y1: int, x2: int, y2: int): """PWAT card: raw vs adjusted table + totals + interpretation.""" _rounded_card(draw, (x1, y1, x2, y2)) draw.text((x1 + 22, y1 + 14), "PWAT Score (raw vs Fitzpatrick-adjusted)", fill=COL_INK, font=_font_b(18)) fitz_label = pwat.fitzpatrick_type if pwat else "-" sub = (f"Items 3-8 — 0-4 ordinal scale per item — " f"calibrated correction for Fitzpatrick {fitz_label}") draw.text((x1 + 22, y1 + 42), sub, fill=COL_INK_SOFT, font=_font_r(13)) if pwat is None or not pwat.scores_raw: draw.text((x1 + 22, y1 + 90), "Not estimable (ulcer not detected or area too small).", fill=COL_INK_MUTE, font=_font_r(15)) return table_x = x1 + 22 table_w = (x2 - x1) - 44 table_y = y1 + 78 col_label_w = 200 col_raw_w = 90 col_adj_w = 110 col_delta_w = 80 col_bar_w = 100 col_sev_w = table_w - col_label_w - col_raw_w - col_adj_w - col_delta_w - col_bar_w # Header row draw.rectangle((table_x, table_y, table_x + table_w, table_y + 28), fill=(241, 245, 249)) cx = table_x + 12 headers = [ ("PWAT Item", col_label_w), ("Raw", col_raw_w), ("Adjusted", col_adj_w), ("Delta", col_delta_w), ("Severity", col_bar_w), ("Interpretation", col_sev_w), ] for h, w in headers: draw.text((cx, table_y + 6), h, fill=COL_INK_SOFT, font=_font_b(13)) cx += w row_y = table_y + 32 row_h = 38 for item in [3, 4, 5, 6, 7, 8]: name = ITEM_NAMES.get(item, f"Item {item}") raw = pwat.scores_raw.get(item, 0) adj = pwat.scores_adjusted.get(item, 0.0) delta = adj - raw cx = table_x + 12 if (item % 2) == 0: draw.rectangle((table_x, row_y - 4, table_x + table_w, row_y + row_h - 4), fill=(248, 250, 252)) draw.text((cx, row_y + 4), name, fill=COL_INK, font=_font_b(14)) cx += col_label_w draw.text((cx, row_y + 4), str(raw), fill=COL_INK, font=_font_b(15)) cx += col_raw_w draw.text((cx, row_y + 4), f"{adj:.1f}", fill=COL_ACCENT, font=_font_b(15)) cx += col_adj_w if abs(delta) < 0.05: d_txt = "0.0" d_col = COL_INK_SOFT else: d_txt = f"{delta:+.1f}" d_col = COL_OK if delta < 0 else COL_DANGER draw.text((cx, row_y + 4), d_txt, fill=d_col, font=_font_b(15)) cx += col_delta_w _hbar(draw, cx, row_y + 10, col_bar_w - 14, 12, value=raw, vmax=4, color=COL_DANGER, bg=(229, 231, 235), radius=6) cx += col_bar_w draw.text((cx, row_y + 4), SEV_LABEL.get(raw, ""), fill=COL_INK_SOFT, font=_font_r(13)) draw.line((table_x, row_y + row_h - 4, table_x + table_w, row_y + row_h - 4), fill=(241, 245, 249), width=1) row_y += row_h # Totals block totals_y = row_y + 10 totals_h = 110 block_x1 = table_x block_x2 = table_x + table_w draw.rounded_rectangle((block_x1, totals_y, block_x2, totals_y + totals_h), radius=10, fill=(15, 23, 42)) label, color = _interpretation_for(pwat.total_adjusted) # Total raw draw.text((block_x1 + 24, totals_y + 14), "Total Raw", fill=(148, 163, 184), font=_font_r(13)) draw.text((block_x1 + 24, totals_y + 32), str(pwat.total_raw), fill=COL_HEADER_FG, font=_font_b(36)) draw.text((block_x1 + 24, totals_y + 76), "/ 24", fill=(100, 116, 139), font=_font_r(13)) # Total adjusted draw.text((block_x1 + 180, totals_y + 14), "Total Adjusted", fill=(148, 163, 184), font=_font_r(13)) draw.text((block_x1 + 180, totals_y + 30), f"{pwat.total_adjusted:.1f}", fill=color, font=_font_b(44)) delta_total = pwat.total_adjusted - pwat.total_raw if abs(delta_total) >= 0.05: draw.text((block_x1 + 180, totals_y + 84), f"({delta_total:+.1f} bias correction)", fill=(148, 163, 184), font=_font_r(13)) # Clinical interpretation interp_x = block_x1 + 410 draw.text((interp_x, totals_y + 14), "Clinical Interpretation", fill=(148, 163, 184), font=_font_r(13)) draw.text((interp_x, totals_y + 32), label, fill=color, font=_font_b(20)) # Mini gradient bar with marker seg_x = interp_x seg_y = totals_y + 70 seg_w = block_x2 - seg_x - 24 seg_h = 14 draw.rounded_rectangle((seg_x, seg_y, seg_x + seg_w, seg_y + seg_h), radius=4, fill=(30, 41, 59)) seg_total = 24 cur_x = seg_x for lo, hi, _, c in PWAT_INTERPRETATION: seg_len = int(seg_w * (hi - lo + 1) / seg_total) draw.rectangle((cur_x, seg_y, cur_x + seg_len, seg_y + seg_h), fill=c) cur_x += seg_len pos = seg_x + int(seg_w * pwat.total_adjusted / seg_total) draw.polygon([ (pos - 6, seg_y - 4), (pos + 6, seg_y - 4), (pos, seg_y + 2), ], fill=COL_HEADER_FG) tick_font = _font_r(11) draw.text((seg_x, seg_y + seg_h + 4), "0", fill=(148, 163, 184), font=tick_font) draw.text((seg_x + seg_w - 14, seg_y + seg_h + 4), "24", fill=(148, 163, 184), font=tick_font) def _draw_footer(draw: ImageDraw.ImageDraw): y = DASH_H - 32 draw.line((0, y - 8, DASH_W, y - 8), fill=(226, 232, 240), width=1) txt = ("WoundNetB7 — Doctoral Thesis — Marcelo Marquez-Murillo | " "Dice 0.927 (95% CI 0.917-0.936) | " "Debiasing 46.6% gap reduction (p < 1e-55) | " "PWAT items: 3=Necrotic Type, 4=Necrotic Amount, " "5=Granulation Type, 6=Granulation Amount, " "7=Edges, 8=Periulcer Skin") draw.text((24, y - 1), txt, fill=COL_INK_MUTE, font=_font_r(11)) # ── Public API ─────────────────────────────────────────────────────────────── def render_integrated_report( original_rgb: np.ndarray, binary_overlay_rgb: np.ndarray, multiclass_overlay_rgb: np.ndarray, result, ) -> np.ndarray: """Render the integrated dashboard as a single image. Args: original_rgb: (H, W, 3) RGB uint8 — uploaded image binary_overlay_rgb: (H, W, 3) RGB uint8 — binary mask overlay multiclass_overlay_rgb: (H, W, 3) RGB uint8 — multiclass overlay result: AnalysisResult with class_distribution, fitzpatrick, pwat Returns: np.ndarray RGB uint8 of shape (1200, 1920, 3) """ canvas = Image.new("RGB", (DASH_W, DASH_H), COL_BG) draw = ImageDraw.Draw(canvas, "RGBA") _draw_header(canvas, draw) _draw_image_strip(canvas, draw, original_rgb, binary_overlay_rgb, multiclass_overlay_rgb, result.class_distribution) pad = 20 row_y0 = 490 row_y1 = DASH_H - 56 fitz_w = 720 fitz_x1 = pad fitz_x2 = fitz_x1 + fitz_w pwat_x1 = fitz_x2 + pad pwat_x2 = DASH_W - pad _draw_fitzpatrick_card(draw, result.fitzpatrick, fitz_x1, row_y0, fitz_x2, row_y1) _draw_pwat_card(draw, result.pwat, pwat_x1, row_y0, pwat_x2, row_y1) _draw_footer(draw) return np.array(canvas)