| """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 |
|
|
|
|
| |
|
|
| DASH_W, DASH_H = 1920, 1200 |
|
|
| COL_BG = (248, 250, 252) |
| COL_CARD = (255, 255, 255) |
| COL_CARD_BORDER = (226, 232, 240) |
| COL_INK = (15, 23, 42) |
| COL_INK_SOFT = (71, 85, 105) |
| COL_INK_MUTE = (148, 163, 184) |
| COL_HEADER_BG = (15, 23, 42) |
| COL_HEADER_FG = (248, 250, 252) |
| COL_ACCENT = (14, 116, 144) |
| COL_DANGER = (220, 38, 38) |
| COL_WARN = (217, 119, 6) |
| COL_OK = (5, 150, 105) |
| COL_INFO = (37, 99, 235) |
|
|
| CLASS_COLOR = { |
| "foot": (34, 197, 94), |
| "perilesion": (249, 115, 22), |
| "ulcer": (239, 68, 68), |
| "background": (107, 114, 128), |
| } |
|
|
| 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_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] |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| def _draw_header(img: Image.Image, draw: ImageDraw.ImageDraw): |
| H = 90 |
| draw.rectangle((0, 0, DASH_W, H), fill=COL_HEADER_BG) |
|
|
| |
| 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)) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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_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) |
|
|
| |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| 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)) |
|
|
|
|
| |
|
|
| 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) |
|
|