mmarquezsa commited on
Commit
f08ab5a
Β·
verified Β·
1 Parent(s): 7b081d1

Wire integrated dashboard renderer (src/integrated_report.py)

Browse files
Files changed (1) hide show
  1. src/integrated_report.py +598 -0
src/integrated_report.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integrated DFU Clinical Assessment Report β€” single-image dashboard.
2
+
3
+ Renders a unified clinical dashboard PNG that nurses and clinicians can review
4
+ at a glance. Combines:
5
+
6
+ - Original photograph
7
+ - Binary ulcer mask
8
+ - Multi-class tissue map (background / foot / perilesion / ulcer)
9
+ - Class area distribution (horizontal bars)
10
+ - Fitzpatrick / ITA estimation with color-coded badge
11
+ - PWAT items 3-8 table (raw vs adjusted, delta, severity bars)
12
+ - Total raw / total adjusted + clinical interpretation by range
13
+ - Lighting-quality warnings when applicable
14
+
15
+ Designed for clinical staff: clean typography, clear hierarchy, consistent
16
+ palette, fixed 1920x1200 px layout.
17
+
18
+ Usage:
19
+ from src.integrated_report import render_integrated_report
20
+ dashboard = render_integrated_report(rgb, binary_overlay, multi_overlay, result)
21
+ # dashboard: np.ndarray RGB (1200, 1920, 3) uint8
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from datetime import datetime
26
+
27
+ import cv2
28
+ import numpy as np
29
+ from PIL import Image, ImageDraw, ImageFont
30
+
31
+ from src.pwat_estimator import ITEM_NAMES
32
+
33
+
34
+ # ── Palette and design constants ────────────────────────────────────────────
35
+
36
+ DASH_W, DASH_H = 1920, 1200
37
+
38
+ COL_BG = (248, 250, 252) # slate-50
39
+ COL_CARD = (255, 255, 255)
40
+ COL_CARD_BORDER = (226, 232, 240) # slate-200
41
+ COL_INK = (15, 23, 42) # slate-900
42
+ COL_INK_SOFT = (71, 85, 105) # slate-600
43
+ COL_INK_MUTE = (148, 163, 184) # slate-400
44
+ COL_HEADER_BG = (15, 23, 42)
45
+ COL_HEADER_FG = (248, 250, 252)
46
+ COL_ACCENT = (14, 116, 144) # cyan-700
47
+ COL_DANGER = (220, 38, 38) # red-600
48
+ COL_WARN = (217, 119, 6) # amber-600
49
+ COL_OK = (5, 150, 105) # emerald-600
50
+ COL_INFO = (37, 99, 235) # blue-600
51
+
52
+ CLASS_COLOR = {
53
+ "foot": (34, 197, 94), # green-500
54
+ "perilesion": (249, 115, 22), # orange-500
55
+ "ulcer": (239, 68, 68), # red-500
56
+ "background": (107, 114, 128), # gray-500
57
+ }
58
+
59
+ CLASS_LABEL_EN = {
60
+ "foot": "Healthy foot",
61
+ "perilesion": "Perilesional",
62
+ "ulcer": "Ulcer",
63
+ "background": "Background",
64
+ }
65
+
66
+ FITZ_RGB = {
67
+ "I": (254, 243, 199),
68
+ "II": (253, 224, 138),
69
+ "III": (245, 158, 11),
70
+ "IV": (180, 83, 9),
71
+ "V": (120, 53, 15),
72
+ "VI": (45, 16, 4),
73
+ }
74
+ FITZ_TEXT_RGB = {
75
+ "I": (31, 41, 55), "II": (31, 41, 55), "III": (31, 41, 55),
76
+ "IV": (255, 255, 255), "V": (255, 255, 255), "VI": (255, 255, 255),
77
+ }
78
+
79
+ PWAT_INTERPRETATION = [
80
+ (0, 6, "Healing well", COL_OK),
81
+ (7, 12, "Moderate compromise β€” clinical follow-up", COL_WARN),
82
+ (13, 18, "Severe compromise β€” adjust treatment", COL_DANGER),
83
+ (19, 24, "Critical β€” urgent reassessment", (153, 27, 27)),
84
+ ]
85
+
86
+ SEV_LABEL = {0: "Normal", 1: "Mild", 2: "Moderate", 3: "Severe", 4: "Extreme"}
87
+
88
+
89
+ # ── Font helpers ─────────────────────────────────────────────────────────────
90
+
91
+ _FONT_BOLD_CANDIDATES = [
92
+ "DejaVuSans-Bold.ttf",
93
+ "C:/Windows/Fonts/segoeuib.ttf",
94
+ "C:/Windows/Fonts/arialbd.ttf",
95
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
96
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
97
+ ]
98
+ _FONT_REG_CANDIDATES = [
99
+ "DejaVuSans.ttf",
100
+ "C:/Windows/Fonts/segoeui.ttf",
101
+ "C:/Windows/Fonts/arial.ttf",
102
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
103
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
104
+ ]
105
+
106
+
107
+ def _load_font(candidates, size):
108
+ for path in candidates:
109
+ try:
110
+ return ImageFont.truetype(path, size)
111
+ except (OSError, IOError):
112
+ continue
113
+ return ImageFont.load_default()
114
+
115
+
116
+ def _font_b(size: int):
117
+ return _load_font(_FONT_BOLD_CANDIDATES, size)
118
+
119
+
120
+ def _font_r(size: int):
121
+ return _load_font(_FONT_REG_CANDIDATES, size)
122
+
123
+
124
+ def _text_size(draw: ImageDraw.ImageDraw, text: str, font) -> tuple[int, int]:
125
+ box = draw.textbbox((0, 0), text, font=font)
126
+ return box[2] - box[0], box[3] - box[1]
127
+
128
+
129
+ # ── Drawing helpers ──────────────────────────────────────────────────────────
130
+
131
+ def _rounded_card(draw: ImageDraw.ImageDraw, xy, radius=14,
132
+ fill=COL_CARD, border=COL_CARD_BORDER, border_w=1,
133
+ shadow=True):
134
+ """Rounded card with subtle drop shadow."""
135
+ x1, y1, x2, y2 = xy
136
+ if shadow:
137
+ for off, alpha in [(2, 18), (4, 8)]:
138
+ shadow_color = (15, 23, 42, alpha)
139
+ draw.rounded_rectangle(
140
+ (x1 + off, y1 + off, x2 + off, y2 + off),
141
+ radius=radius, fill=shadow_color,
142
+ )
143
+ draw.rounded_rectangle((x1, y1, x2, y2), radius=radius,
144
+ fill=fill, outline=border, width=border_w)
145
+
146
+
147
+ def _fit_image_into(img_rgb: np.ndarray, w: int, h: int) -> np.ndarray:
148
+ """Letterbox an image into a w x h canvas preserving aspect ratio."""
149
+ if img_rgb is None or img_rgb.size == 0:
150
+ return np.full((h, w, 3), 240, dtype=np.uint8)
151
+ src_h, src_w = img_rgb.shape[:2]
152
+ scale = min(w / src_w, h / src_h)
153
+ new_w, new_h = int(src_w * scale), int(src_h * scale)
154
+ resized = cv2.resize(img_rgb, (new_w, new_h), interpolation=cv2.INTER_AREA)
155
+ canvas = np.full((h, w, 3), 245, dtype=np.uint8)
156
+ off_x = (w - new_w) // 2
157
+ off_y = (h - new_h) // 2
158
+ canvas[off_y:off_y + new_h, off_x:off_x + new_w] = resized
159
+ return canvas
160
+
161
+
162
+ def _paste_image(base_img: Image.Image, np_rgb: np.ndarray, box: tuple[int, int, int, int]):
163
+ x1, y1, x2, y2 = box
164
+ fitted = _fit_image_into(np_rgb, x2 - x1, y2 - y1)
165
+ base_img.paste(Image.fromarray(fitted), (x1, y1))
166
+
167
+
168
+ def _hbar(draw, x, y, w, h, value, vmax, color, bg=(229, 231, 235), radius=4):
169
+ """Rounded horizontal bar with proportional fill."""
170
+ draw.rounded_rectangle((x, y, x + w, y + h), radius=radius, fill=bg)
171
+ if vmax > 0 and value > 0:
172
+ fill_w = max(2, int(w * value / vmax))
173
+ draw.rounded_rectangle((x, y, x + fill_w, y + h), radius=radius, fill=color)
174
+
175
+
176
+ def _interpretation_for(total: float) -> tuple[str, tuple]:
177
+ """Map a (possibly fractional) PWAT total to its clinical band.
178
+
179
+ Rounds to nearest integer first so that 6.7 falls into the 7-12 band
180
+ (matches how clinicians read these cutoffs).
181
+ """
182
+ rounded = int(round(max(0.0, min(24.0, total))))
183
+ for lo, hi, label, color in PWAT_INTERPRETATION:
184
+ if lo <= rounded <= hi:
185
+ return label, color
186
+ return PWAT_INTERPRETATION[-1][2], PWAT_INTERPRETATION[-1][3]
187
+
188
+
189
+ def _draw_wrapped_text(draw, text, xy, max_w, font, fill, max_lines=3):
190
+ """Simple word-wrap by pixel width."""
191
+ if not text:
192
+ return
193
+ words = text.split()
194
+ lines, cur = [], ""
195
+ for w in words:
196
+ trial = (cur + " " + w).strip()
197
+ tw, _ = _text_size(draw, trial, font)
198
+ if tw <= max_w:
199
+ cur = trial
200
+ else:
201
+ if cur:
202
+ lines.append(cur)
203
+ cur = w
204
+ if len(lines) >= max_lines:
205
+ break
206
+ if cur and len(lines) < max_lines:
207
+ lines.append(cur)
208
+
209
+ if len(lines) == max_lines and len(" ".join(lines)) < len(text):
210
+ last = lines[-1]
211
+ while last and _text_size(draw, last + "...", font)[0] > max_w:
212
+ last = last[:-1]
213
+ lines[-1] = last + "..."
214
+
215
+ x, y = xy
216
+ line_h = font.size + 4 if hasattr(font, "size") else 18
217
+ for ln in lines:
218
+ draw.text((x, y), ln, fill=fill, font=font)
219
+ y += line_h
220
+
221
+
222
+ # ── Dashboard blocks ─────────────────────────────────────────────────────────
223
+
224
+ def _draw_header(img: Image.Image, draw: ImageDraw.ImageDraw):
225
+ H = 90
226
+ draw.rectangle((0, 0, DASH_W, H), fill=COL_HEADER_BG)
227
+
228
+ # Logo accent
229
+ dot_x, dot_y = 36, 32
230
+ draw.ellipse((dot_x, dot_y, dot_x + 26, dot_y + 26), fill=(34, 197, 94))
231
+ draw.ellipse((dot_x + 6, dot_y + 6, dot_x + 20, dot_y + 20), fill=COL_HEADER_BG)
232
+
233
+ title = "Integrated Diabetic Foot Ulcer Assessment Report"
234
+ subtitle = ("WoundNetB7 - EfficientNet-B7 + ASPP + CBAM + CoordAttention + TAM "
235
+ " - Ulcer Dice 0.927")
236
+ draw.text((78, 18), title, fill=COL_HEADER_FG, font=_font_b(28))
237
+ draw.text((78, 56), subtitle, fill=(148, 163, 184), font=_font_r(15))
238
+
239
+ # Right-side timestamp + advisory
240
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
241
+ ts_w, _ = _text_size(draw, ts, _font_r(15))
242
+ draw.text((DASH_W - 36 - ts_w, 22), ts, fill=(148, 163, 184), font=_font_r(15))
243
+ advisory = "Research use only β€” not a clinical diagnosis"
244
+ adv_w, _ = _text_size(draw, advisory, _font_r(13))
245
+ draw.text((DASH_W - 36 - adv_w, 46), advisory, fill=(251, 191, 36), font=_font_r(13))
246
+
247
+
248
+ def _draw_image_strip(img: Image.Image, draw: ImageDraw.ImageDraw,
249
+ original_rgb, binary_rgb, multi_rgb, class_distribution: dict):
250
+ """Top row: original + binary + multiclass + class distribution."""
251
+ y0 = 110
252
+ y1 = y0 + 360
253
+ pad = 20
254
+ card_w = (DASH_W - pad * 5) // 4
255
+
256
+ titles = ["Original Image", "Binary Ulcer Mask",
257
+ "Multi-Class Segmentation", "Class Area Distribution"]
258
+ images = [original_rgb, binary_rgb, multi_rgb, None]
259
+
260
+ for i, (t, im) in enumerate(zip(titles, images)):
261
+ x1 = pad + i * (card_w + pad)
262
+ x2 = x1 + card_w
263
+ _rounded_card(draw, (x1, y0, x2, y1))
264
+
265
+ draw.text((x1 + 16, y0 + 12), t, fill=COL_INK, font=_font_b(15))
266
+
267
+ cx1, cy1, cx2, cy2 = x1 + 14, y0 + 42, x2 - 14, y1 - 14
268
+ if im is not None:
269
+ _paste_image(img, im, (cx1, cy1, cx2, cy2))
270
+ else:
271
+ _draw_class_distribution(draw, class_distribution, (cx1, cy1, cx2, cy2))
272
+
273
+
274
+ def _draw_class_distribution(draw, dist: dict, box: tuple[int, int, int, int]):
275
+ x1, y1, x2, y2 = box
276
+ inner_w = x2 - x1
277
+
278
+ items = [("ulcer", dist.get("ulcer", 0)),
279
+ ("perilesion", dist.get("perilesion", 0)),
280
+ ("foot", dist.get("foot", 0)),
281
+ ("background", dist.get("background", 0))]
282
+
283
+ row_h = 56
284
+ cur_y = y1 + 6
285
+ for cls, pct in items:
286
+ color = CLASS_COLOR[cls]
287
+ label = CLASS_LABEL_EN[cls]
288
+
289
+ draw.rounded_rectangle((x1, cur_y, x1 + 14, cur_y + 14), radius=3, fill=color)
290
+
291
+ draw.text((x1 + 24, cur_y - 2), label, fill=COL_INK, font=_font_b(15))
292
+ pct_text = f"{pct:.1f} %"
293
+ pw, _ = _text_size(draw, pct_text, _font_b(15))
294
+ draw.text((x2 - pw, cur_y - 2), pct_text, fill=COL_INK, font=_font_b(15))
295
+
296
+ _hbar(draw, x1, cur_y + 22, inner_w, 10,
297
+ value=min(pct, 100), vmax=100, color=color,
298
+ bg=(241, 245, 249), radius=5)
299
+
300
+ cur_y += row_h
301
+
302
+
303
+ def _draw_fitzpatrick_card(draw: ImageDraw.ImageDraw, fitz, x1: int, y1: int, x2: int, y2: int):
304
+ """Fitzpatrick / ITA card."""
305
+ _rounded_card(draw, (x1, y1, x2, y2))
306
+
307
+ draw.text((x1 + 22, y1 + 14),
308
+ "Fitzpatrick / ITA Skin Type Estimation",
309
+ fill=COL_INK, font=_font_b(18))
310
+ draw.text((x1 + 22, y1 + 42),
311
+ "Calibrated on 61 DFU images β€” 86.9% exact match β€” r=0.975",
312
+ fill=COL_INK_SOFT, font=_font_r(13))
313
+
314
+ if fitz is None or getattr(fitz, "confidence", 0) == 0:
315
+ draw.text((x1 + 22, y1 + 80),
316
+ "Not estimable (insufficient healthy-skin pixels).",
317
+ fill=COL_INK_MUTE, font=_font_r(15))
318
+ return
319
+
320
+ ftype = fitz.fitzpatrick_type
321
+ bg_c = FITZ_RGB.get(ftype, (229, 231, 235))
322
+ fg_c = FITZ_TEXT_RGB.get(ftype, COL_INK)
323
+
324
+ light_q = getattr(fitz, "lighting_quality", "good")
325
+ light_warn = getattr(fitz, "lighting_warning", "")
326
+ banner_y = y1 + 70
327
+ banner_h = 0
328
+ if light_q == "insufficient":
329
+ banner_h = 56
330
+ draw.rounded_rectangle((x1 + 22, banner_y, x2 - 22, banner_y + banner_h),
331
+ radius=8, fill=(254, 242, 242),
332
+ outline=(252, 165, 165), width=1)
333
+ draw.text((x1 + 36, banner_y + 8), "Insufficient lighting",
334
+ fill=(185, 28, 28), font=_font_b(14))
335
+ _draw_wrapped_text(draw, light_warn,
336
+ (x1 + 36, banner_y + 28), x2 - 22 - 36,
337
+ _font_r(12), (153, 27, 27), max_lines=2)
338
+ elif light_q == "low":
339
+ banner_h = 56
340
+ draw.rounded_rectangle((x1 + 22, banner_y, x2 - 22, banner_y + banner_h),
341
+ radius=8, fill=(255, 251, 235),
342
+ outline=(252, 211, 77), width=1)
343
+ draw.text((x1 + 36, banner_y + 8), "Suboptimal lighting",
344
+ fill=(180, 83, 9), font=_font_b(14))
345
+ _draw_wrapped_text(draw, light_warn,
346
+ (x1 + 36, banner_y + 28), x2 - 22 - 36,
347
+ _font_r(12), (146, 64, 14), max_lines=2)
348
+
349
+ body_y = y1 + 82 + banner_h
350
+
351
+ # Skin-tone badge
352
+ badge_w, badge_h = 220, 220
353
+ bx1, by1 = x1 + 22, body_y
354
+ bx2, by2 = bx1 + badge_w, by1 + badge_h
355
+ draw.rounded_rectangle((bx1, by1, bx2, by2), radius=18,
356
+ fill=bg_c, outline=(180, 180, 180), width=2)
357
+ draw.text((bx1 + 24, by1 + 32), "TYPE", fill=fg_c, font=_font_b(20))
358
+ big = _font_b(110)
359
+ bw, _ = _text_size(draw, ftype, big)
360
+ draw.text((bx1 + (badge_w - bw) // 2, by1 + 60), ftype, fill=fg_c, font=big)
361
+ label_font = _font_r(18)
362
+ lw, _ = _text_size(draw, fitz.fitzpatrick_label, label_font)
363
+ draw.text((bx1 + (badge_w - lw) // 2, by2 - 36),
364
+ fitz.fitzpatrick_label, fill=fg_c, font=label_font)
365
+
366
+ # Detail rows on the right
367
+ dx = bx2 + 30
368
+ dy = by1 + 6
369
+ rows = [
370
+ ("ITA", f"{fitz.ita_angle:.1f} +/- {fitz.ita_std:.1f} deg"),
371
+ ("L* healthy skin", f"{fitz.l_skin_mean:.1f}"),
372
+ ("L* scene (global)", f"{getattr(fitz, 'l_scene_mean', 0):.1f}"),
373
+ ("b* healthy skin", f"{fitz.b_skin_mean:.1f}"),
374
+ ("Healthy pixels", f"{fitz.healthy_pixels:,}"),
375
+ ("Confidence", f"{fitz.confidence:.0%}"),
376
+ ]
377
+ for k, v in rows:
378
+ draw.text((dx, dy), k + ":", fill=COL_INK_SOFT, font=_font_r(14))
379
+ draw.text((dx + 170, dy), v, fill=COL_INK, font=_font_b(14))
380
+ dy += 28
381
+
382
+ # Confidence bar
383
+ cy = by2 - 22
384
+ _hbar(draw, dx, cy, x2 - 30 - dx, 8,
385
+ value=fitz.confidence, vmax=1.0,
386
+ color=COL_OK if fitz.confidence > 0.6
387
+ else (COL_WARN if fitz.confidence > 0.3 else COL_DANGER),
388
+ bg=(241, 245, 249))
389
+
390
+
391
+ def _draw_pwat_card(draw: ImageDraw.ImageDraw, pwat, x1: int, y1: int, x2: int, y2: int):
392
+ """PWAT card: raw vs adjusted table + totals + interpretation."""
393
+ _rounded_card(draw, (x1, y1, x2, y2))
394
+
395
+ draw.text((x1 + 22, y1 + 14),
396
+ "PWAT Score (raw vs Fitzpatrick-adjusted)",
397
+ fill=COL_INK, font=_font_b(18))
398
+ fitz_label = pwat.fitzpatrick_type if pwat else "-"
399
+ sub = (f"Items 3-8 β€” 0-4 ordinal scale per item β€” "
400
+ f"calibrated correction for Fitzpatrick {fitz_label}")
401
+ draw.text((x1 + 22, y1 + 42), sub, fill=COL_INK_SOFT, font=_font_r(13))
402
+
403
+ if pwat is None or not pwat.scores_raw:
404
+ draw.text((x1 + 22, y1 + 90),
405
+ "Not estimable (ulcer not detected or area too small).",
406
+ fill=COL_INK_MUTE, font=_font_r(15))
407
+ return
408
+
409
+ table_x = x1 + 22
410
+ table_w = (x2 - x1) - 44
411
+ table_y = y1 + 78
412
+
413
+ col_label_w = 200
414
+ col_raw_w = 90
415
+ col_adj_w = 110
416
+ col_delta_w = 80
417
+ col_bar_w = 100
418
+ col_sev_w = table_w - col_label_w - col_raw_w - col_adj_w - col_delta_w - col_bar_w
419
+
420
+ # Header row
421
+ draw.rectangle((table_x, table_y, table_x + table_w, table_y + 28),
422
+ fill=(241, 245, 249))
423
+ cx = table_x + 12
424
+ headers = [
425
+ ("PWAT Item", col_label_w),
426
+ ("Raw", col_raw_w),
427
+ ("Adjusted", col_adj_w),
428
+ ("Delta", col_delta_w),
429
+ ("Severity", col_bar_w),
430
+ ("Interpretation", col_sev_w),
431
+ ]
432
+ for h, w in headers:
433
+ draw.text((cx, table_y + 6), h, fill=COL_INK_SOFT, font=_font_b(13))
434
+ cx += w
435
+
436
+ row_y = table_y + 32
437
+ row_h = 38
438
+ for item in [3, 4, 5, 6, 7, 8]:
439
+ name = ITEM_NAMES.get(item, f"Item {item}")
440
+ raw = pwat.scores_raw.get(item, 0)
441
+ adj = pwat.scores_adjusted.get(item, 0.0)
442
+ delta = adj - raw
443
+ cx = table_x + 12
444
+
445
+ if (item % 2) == 0:
446
+ draw.rectangle((table_x, row_y - 4, table_x + table_w, row_y + row_h - 4),
447
+ fill=(248, 250, 252))
448
+
449
+ draw.text((cx, row_y + 4), name, fill=COL_INK, font=_font_b(14))
450
+ cx += col_label_w
451
+
452
+ draw.text((cx, row_y + 4), str(raw), fill=COL_INK, font=_font_b(15))
453
+ cx += col_raw_w
454
+
455
+ draw.text((cx, row_y + 4), f"{adj:.1f}", fill=COL_ACCENT, font=_font_b(15))
456
+ cx += col_adj_w
457
+
458
+ if abs(delta) < 0.05:
459
+ d_txt = "0.0"
460
+ d_col = COL_INK_SOFT
461
+ else:
462
+ d_txt = f"{delta:+.1f}"
463
+ d_col = COL_OK if delta < 0 else COL_DANGER
464
+ draw.text((cx, row_y + 4), d_txt, fill=d_col, font=_font_b(15))
465
+ cx += col_delta_w
466
+
467
+ _hbar(draw, cx, row_y + 10, col_bar_w - 14, 12,
468
+ value=raw, vmax=4, color=COL_DANGER, bg=(229, 231, 235), radius=6)
469
+ cx += col_bar_w
470
+
471
+ draw.text((cx, row_y + 4), SEV_LABEL.get(raw, ""),
472
+ fill=COL_INK_SOFT, font=_font_r(13))
473
+
474
+ draw.line((table_x, row_y + row_h - 4, table_x + table_w, row_y + row_h - 4),
475
+ fill=(241, 245, 249), width=1)
476
+ row_y += row_h
477
+
478
+ # Totals block
479
+ totals_y = row_y + 10
480
+ totals_h = 110
481
+
482
+ block_x1 = table_x
483
+ block_x2 = table_x + table_w
484
+ draw.rounded_rectangle((block_x1, totals_y, block_x2, totals_y + totals_h),
485
+ radius=10, fill=(15, 23, 42))
486
+
487
+ label, color = _interpretation_for(pwat.total_adjusted)
488
+
489
+ # Total raw
490
+ draw.text((block_x1 + 24, totals_y + 14),
491
+ "Total Raw", fill=(148, 163, 184), font=_font_r(13))
492
+ draw.text((block_x1 + 24, totals_y + 32),
493
+ str(pwat.total_raw), fill=COL_HEADER_FG, font=_font_b(36))
494
+ draw.text((block_x1 + 24, totals_y + 76),
495
+ "/ 24", fill=(100, 116, 139), font=_font_r(13))
496
+
497
+ # Total adjusted
498
+ draw.text((block_x1 + 180, totals_y + 14),
499
+ "Total Adjusted", fill=(148, 163, 184), font=_font_r(13))
500
+ draw.text((block_x1 + 180, totals_y + 30),
501
+ f"{pwat.total_adjusted:.1f}", fill=color, font=_font_b(44))
502
+ delta_total = pwat.total_adjusted - pwat.total_raw
503
+ if abs(delta_total) >= 0.05:
504
+ draw.text((block_x1 + 180, totals_y + 84),
505
+ f"({delta_total:+.1f} bias correction)",
506
+ fill=(148, 163, 184), font=_font_r(13))
507
+
508
+ # Clinical interpretation
509
+ interp_x = block_x1 + 410
510
+ draw.text((interp_x, totals_y + 14),
511
+ "Clinical Interpretation", fill=(148, 163, 184), font=_font_r(13))
512
+ draw.text((interp_x, totals_y + 32),
513
+ label, fill=color, font=_font_b(20))
514
+
515
+ # Mini gradient bar with marker
516
+ seg_x = interp_x
517
+ seg_y = totals_y + 70
518
+ seg_w = block_x2 - seg_x - 24
519
+ seg_h = 14
520
+ draw.rounded_rectangle((seg_x, seg_y, seg_x + seg_w, seg_y + seg_h),
521
+ radius=4, fill=(30, 41, 59))
522
+ seg_total = 24
523
+ cur_x = seg_x
524
+ for lo, hi, _, c in PWAT_INTERPRETATION:
525
+ seg_len = int(seg_w * (hi - lo + 1) / seg_total)
526
+ draw.rectangle((cur_x, seg_y, cur_x + seg_len, seg_y + seg_h), fill=c)
527
+ cur_x += seg_len
528
+
529
+ pos = seg_x + int(seg_w * pwat.total_adjusted / seg_total)
530
+ draw.polygon([
531
+ (pos - 6, seg_y - 4),
532
+ (pos + 6, seg_y - 4),
533
+ (pos, seg_y + 2),
534
+ ], fill=COL_HEADER_FG)
535
+ tick_font = _font_r(11)
536
+ draw.text((seg_x, seg_y + seg_h + 4), "0",
537
+ fill=(148, 163, 184), font=tick_font)
538
+ draw.text((seg_x + seg_w - 14, seg_y + seg_h + 4), "24",
539
+ fill=(148, 163, 184), font=tick_font)
540
+
541
+
542
+ def _draw_footer(draw: ImageDraw.ImageDraw):
543
+ y = DASH_H - 32
544
+ draw.line((0, y - 8, DASH_W, y - 8), fill=(226, 232, 240), width=1)
545
+ txt = ("WoundNetB7 β€” Doctoral Thesis β€” Marcelo Marquez-Murillo | "
546
+ "Dice 0.927 (95% CI 0.917-0.936) | "
547
+ "Debiasing 46.6% gap reduction (p < 1e-55) | "
548
+ "PWAT items: 3=Necrotic Type, 4=Necrotic Amount, "
549
+ "5=Granulation Type, 6=Granulation Amount, "
550
+ "7=Edges, 8=Periulcer Skin")
551
+ draw.text((24, y - 1), txt, fill=COL_INK_MUTE, font=_font_r(11))
552
+
553
+
554
+ # ── Public API ───────────────────────────────────────────────────────────────
555
+
556
+ def render_integrated_report(
557
+ original_rgb: np.ndarray,
558
+ binary_overlay_rgb: np.ndarray,
559
+ multiclass_overlay_rgb: np.ndarray,
560
+ result,
561
+ ) -> np.ndarray:
562
+ """Render the integrated dashboard as a single image.
563
+
564
+ Args:
565
+ original_rgb: (H, W, 3) RGB uint8 β€” uploaded image
566
+ binary_overlay_rgb: (H, W, 3) RGB uint8 β€” binary mask overlay
567
+ multiclass_overlay_rgb: (H, W, 3) RGB uint8 β€” multiclass overlay
568
+ result: AnalysisResult with class_distribution, fitzpatrick, pwat
569
+
570
+ Returns:
571
+ np.ndarray RGB uint8 of shape (1200, 1920, 3)
572
+ """
573
+ canvas = Image.new("RGB", (DASH_W, DASH_H), COL_BG)
574
+ draw = ImageDraw.Draw(canvas, "RGBA")
575
+
576
+ _draw_header(canvas, draw)
577
+
578
+ _draw_image_strip(canvas, draw,
579
+ original_rgb, binary_overlay_rgb, multiclass_overlay_rgb,
580
+ result.class_distribution)
581
+
582
+ pad = 20
583
+ row_y0 = 490
584
+ row_y1 = DASH_H - 56
585
+ fitz_w = 720
586
+ fitz_x1 = pad
587
+ fitz_x2 = fitz_x1 + fitz_w
588
+ pwat_x1 = fitz_x2 + pad
589
+ pwat_x2 = DASH_W - pad
590
+
591
+ _draw_fitzpatrick_card(draw, result.fitzpatrick,
592
+ fitz_x1, row_y0, fitz_x2, row_y1)
593
+ _draw_pwat_card(draw, result.pwat,
594
+ pwat_x1, row_y0, pwat_x2, row_y1)
595
+
596
+ _draw_footer(draw)
597
+
598
+ return np.array(canvas)