mmarquezsa commited on
Commit
a7ab819
Β·
verified Β·
1 Parent(s): 8405da6

feat: PDF report + guided camera capture with foot silhouette

Browse files
Files changed (1) hide show
  1. app.py +626 -344
app.py CHANGED
@@ -5,7 +5,8 @@ Pipeline visualization:
5
  2. Multiclass segmentation (background / foot / perilesion / ulcer)
6
  3. Fitzpatrick/ITA skin type estimation
7
  4. PWAT scores (raw) + PWAT adjusted by Fitzpatrick debiasing
8
- 5. Downloadable clinical report (PNG composite + JSON)
 
9
 
10
  Launch locally: python app.py
11
  Deploy to HF: push this repo to a Hugging Face Space (GPU recommended).
@@ -18,6 +19,7 @@ import tempfile
18
  import os
19
  from datetime import datetime
20
  from PIL import Image, ImageDraw, ImageFont
 
21
  from pipeline import WoundNetB7Pipeline
22
  from src.pwat_estimator import ITEM_NAMES
23
 
@@ -44,10 +46,164 @@ FITZ_TEXT_RGB = {
44
  }
45
 
46
 
47
- # ── Report Generation ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  def _get_font(size):
50
- """Get a font, falling back to default if custom fonts unavailable."""
51
  for name in ["DejaVuSans-Bold.ttf", "DejaVuSans.ttf", "arial.ttf", "LiberationSans-Bold.ttf"]:
52
  try:
53
  return ImageFont.truetype(name, size)
@@ -65,229 +221,339 @@ def _get_font_regular(size):
65
  return ImageFont.load_default()
66
 
67
 
68
- def _draw_text_block(draw, x, y, lines, font, fill=(50, 50, 50), line_spacing=6):
69
- """Draw multiple lines of text, return y after last line."""
70
- for line in lines:
71
- draw.text((x, y), line, fill=fill, font=font)
72
- bbox = font.getbbox(line)
73
- y += (bbox[3] - bbox[1]) + line_spacing
74
- return y
75
-
76
-
77
- def generate_report_image(image_rgb, binary_overlay, multiclass_overlay, result):
78
- """Generate a composite clinical report image (PNG, 300 DPI quality)."""
79
- # Layout: 2400 x 3200 px (portrait, ~8x10.7 inches at 300 DPI)
80
- W, H = 2400, 3200
81
- BG = (255, 255, 255)
82
- report = Image.new("RGB", (W, H), BG)
83
- draw = ImageDraw.Draw(report)
84
-
85
- font_title = _get_font(42)
86
- font_subtitle = _get_font(28)
87
- font_body = _get_font_regular(24)
88
- font_small = _get_font_regular(20)
89
- font_label = _get_font(22)
90
- font_big = _get_font(52)
91
-
92
- MARGIN = 60
93
- COL_W = (W - 3 * MARGIN) // 2
94
- IMG_H = 550
95
-
96
- # ── Header ──
97
- draw.rectangle([(0, 0), (W, 120)], fill=(31, 41, 55))
98
- draw.text((MARGIN, 28), "WoundNetB7 β€” Informe de Analisis DFU", fill=(255, 255, 255), font=font_title)
99
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
100
- draw.text((W - MARGIN - 300, 35), timestamp, fill=(156, 163, 175), font=font_body)
101
- draw.text((MARGIN, 78), "EfficientNet-B7 + ASPP + CBAM + CoordAttention + TAM | Ulcer Dice: 0.927",
102
- fill=(156, 163, 175), font=font_small)
103
-
104
- y = 145
105
-
106
- # ── Row 1: Original + Binary Segmentation ──
107
- draw.text((MARGIN, y), "1. Imagen Original", fill=(31, 41, 55), font=font_subtitle)
108
- draw.text((MARGIN + COL_W + MARGIN, y), "2. Segmentacion Binaria (Ulcera)", fill=(31, 41, 55), font=font_subtitle)
109
- y += 40
110
-
111
- # Resize and paste images
112
- orig_pil = Image.fromarray(image_rgb).resize((COL_W, IMG_H), Image.LANCZOS)
113
- binary_pil = Image.fromarray(binary_overlay).resize((COL_W, IMG_H), Image.LANCZOS)
114
- report.paste(orig_pil, (MARGIN, y))
115
- report.paste(binary_pil, (MARGIN + COL_W + MARGIN, y))
116
-
117
- # Border
118
- for bx in [MARGIN, MARGIN + COL_W + MARGIN]:
119
- draw.rectangle([(bx, y), (bx + COL_W, y + IMG_H)], outline=(209, 213, 219), width=2)
120
-
121
- y += IMG_H + 20
122
-
123
- # Image info
124
- h_img, w_img = result.image_size
125
- ulcer_pct = result.class_distribution.get("ulcer", 0)
126
- draw.text((MARGIN, y), f"Resolucion: {w_img}x{h_img} px", fill=(107, 114, 128), font=font_small)
127
- draw.text((MARGIN + COL_W + MARGIN, y),
128
- f"Area ulcera: {ulcer_pct:.1f}% de la imagen", fill=(107, 114, 128), font=font_small)
129
- y += 35
130
-
131
- # ── Row 2: Multiclass + Seg Stats ──
132
- draw.line([(MARGIN, y), (W - MARGIN, y)], fill=(229, 231, 235), width=2)
133
- y += 15
134
- draw.text((MARGIN, y), "3. Segmentacion Multiclase (4 clases)", fill=(31, 41, 55), font=font_subtitle)
135
- draw.text((MARGIN + COL_W + MARGIN, y), "Distribucion de Clases", fill=(31, 41, 55), font=font_subtitle)
136
- y += 40
137
-
138
- multi_pil = Image.fromarray(multiclass_overlay).resize((COL_W, IMG_H), Image.LANCZOS)
139
- report.paste(multi_pil, (MARGIN, y))
140
- draw.rectangle([(MARGIN, y), (MARGIN + COL_W, y + IMG_H)], outline=(209, 213, 219), width=2)
141
-
142
- # Seg stats panel
143
- stats_x = MARGIN + COL_W + MARGIN
144
- stats_y = y + 20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  class_info = [
146
  ("Pie", result.class_distribution.get("foot", 0), (34, 197, 94)),
147
  ("Perilesion", result.class_distribution.get("perilesion", 0), (249, 115, 22)),
148
  ("Ulcera", result.class_distribution.get("ulcer", 0), (239, 68, 68)),
149
  ("Fondo", result.class_distribution.get("background", 0), (107, 114, 128)),
150
  ]
151
- bar_w = COL_W - 140
152
- for cls_name, pct, color in class_info:
153
- draw.text((stats_x, stats_y), f"{cls_name}", fill=color, font=font_label)
154
- draw.text((stats_x + 160, stats_y), f"{pct:.1f}%", fill=(50, 50, 50), font=font_body)
155
- stats_y += 32
156
- # Bar background
157
- draw.rectangle([(stats_x, stats_y), (stats_x + bar_w, stats_y + 18)],
158
- fill=(229, 231, 235), outline=None)
159
- # Bar fill
160
- fill_w = max(1, int(bar_w * pct / 100))
161
- draw.rectangle([(stats_x, stats_y), (stats_x + fill_w, stats_y + 18)],
162
- fill=color, outline=None)
163
- stats_y += 35
164
-
165
- # Legend
166
- stats_y += 10
167
- legend_items = [
168
- ((34, 197, 94), "Pie: tejido sano"),
169
- ((249, 115, 22), "Perilesion: zona periulceral"),
170
- ((239, 68, 68), "Ulcera: lecho de la herida"),
171
- ]
172
- for color, text in legend_items:
173
- draw.rectangle([(stats_x, stats_y + 2), (stats_x + 16, stats_y + 18)], fill=color)
174
- draw.text((stats_x + 24, stats_y), text, fill=(50, 50, 50), font=font_small)
175
- stats_y += 28
176
-
177
- y += IMG_H + 20
178
 
179
- # ── Row 3: Fitzpatrick + PWAT ──
180
- draw.line([(MARGIN, y), (W - MARGIN, y)], fill=(229, 231, 235), width=2)
181
- y += 15
182
- draw.text((MARGIN, y), "4. Fitzpatrick / ITA", fill=(31, 41, 55), font=font_subtitle)
183
- draw.text((MARGIN + COL_W + MARGIN, y), "5. PWAT Scores (Raw vs Ajustado)", fill=(31, 41, 55), font=font_subtitle)
184
- y += 45
185
-
186
- # Fitzpatrick panel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  fitz = result.fitzpatrick
188
- fitz_x = MARGIN
189
  if fitz and fitz.confidence > 0:
190
- # Colored badge
191
  ftype = fitz.fitzpatrick_type
192
- badge_bg = FITZ_RGB.get(ftype, (229, 231, 235))
193
- badge_fg = FITZ_TEXT_RGB.get(ftype, (50, 50, 50))
194
- badge_w, badge_h = 220, 120
195
- draw.rounded_rectangle(
196
- [(fitz_x, y), (fitz_x + badge_w, y + badge_h)],
197
- radius=16, fill=badge_bg, outline=(180, 180, 180), width=2
198
- )
199
- draw.text((fitz_x + 30, y + 15), f"Tipo {ftype}", fill=badge_fg, font=font_big)
200
- draw.text((fitz_x + 30, y + 78), fitz.fitzpatrick_label, fill=badge_fg, font=font_small)
201
-
202
- # Details
203
- det_x = fitz_x + badge_w + 30
204
- det_lines = [
205
- f"ITA: {fitz.ita_angle:.1f} +/- {fitz.ita_std:.1f} grados",
206
- f"L* medio (piel sana): {fitz.l_skin_mean:.1f}",
207
- f"b* medio (piel sana): {fitz.b_skin_mean:.1f}",
208
- f"Pixeles sanos: {fitz.healthy_pixels:,}",
209
- f"Ratio piel sana: {fitz.healthy_ratio:.1%}",
210
- f"Confianza: {fitz.confidence:.0%}",
 
 
 
 
 
 
 
 
 
 
211
  ]
212
- _draw_text_block(draw, det_x, y, det_lines, font_body, line_spacing=8)
 
 
 
 
 
 
 
 
213
  else:
214
- draw.text((fitz_x, y), "No estimable (insuficiente piel sana)", fill=(107, 114, 128), font=font_body)
 
 
 
215
 
216
- # PWAT panel
 
217
  pwat = result.pwat
218
- pwat_x = MARGIN + COL_W + MARGIN
219
  if pwat and pwat.scores_raw:
220
  ftype_str = pwat.fitzpatrick_type or "III"
221
 
222
  # Table header
223
- col_positions = [pwat_x, pwat_x + 260, pwat_x + 420, pwat_x + 600, pwat_x + 740]
224
- headers = ["Item", "Raw", "Ajustado", "Delta"]
225
- for i, (hx, htext) in enumerate(zip(col_positions, headers)):
226
- draw.text((hx, y), htext, fill=(55, 65, 81), font=font_label)
227
- py = y + 35
228
- draw.line([(pwat_x, py), (pwat_x + COL_W - 40, py)], fill=(55, 65, 81), width=2)
229
- py += 8
230
-
 
 
231
  for item in [3, 4, 5, 6, 7, 8]:
232
  name = ITEM_NAMES.get(item, f"Item {item}")
233
  raw = pwat.scores_raw.get(item, 0)
234
  adj = pwat.scores_adjusted.get(item, 0.0)
235
  diff = adj - raw
236
  diff_str = f"{diff:+.1f}" if abs(diff) > 0.01 else "0.0"
237
- diff_color = (5, 150, 105) if diff < -0.05 else (107, 114, 128)
238
-
239
- draw.text((col_positions[0], py), name, fill=(50, 50, 50), font=font_body)
240
- draw.text((col_positions[1], py), str(raw), fill=(50, 50, 50), font=font_body)
241
- draw.text((col_positions[2], py), f"{adj:.1f}", fill=(50, 50, 50), font=font_body)
242
- draw.text((col_positions[3], py), diff_str, fill=diff_color, font=font_label)
243
-
244
- # Mini bar (raw)
245
- bar_x = col_positions[1] + 40
246
- bar_y = py + 6
247
- bar_total = 120
248
- draw.rectangle([(bar_x, bar_y), (bar_x + bar_total, bar_y + 10)], fill=(229, 231, 235))
249
- draw.rectangle([(bar_x, bar_y), (bar_x + int(bar_total * raw / 4), bar_y + 10)], fill=(239, 68, 68))
250
 
251
- py += 38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
  # Total row
254
- draw.line([(pwat_x, py), (pwat_x + COL_W - 40, py)], fill=(55, 65, 81), width=2)
255
- py += 8
256
- draw.text((col_positions[0], py), "TOTAL", fill=(31, 41, 55), font=font_label)
257
- draw.text((col_positions[1], py), str(pwat.total_raw), fill=(31, 41, 55), font=font_label)
258
- draw.text((col_positions[2], py), f"{pwat.total_adjusted:.1f}", fill=(31, 41, 55), font=font_label)
 
259
  total_diff = pwat.total_adjusted - pwat.total_raw
260
  total_diff_str = f"{total_diff:+.1f}" if abs(total_diff) > 0.01 else "0.0"
261
- total_color = (5, 150, 105) if total_diff < -0.05 else (107, 114, 128)
262
- draw.text((col_positions[3], py), total_diff_str, fill=total_color, font=font_label)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
- py += 40
265
- draw.text((pwat_x, py), f"Correccion aplicada: Fitzpatrick tipo {ftype_str}",
266
- fill=(107, 114, 128), font=font_small)
267
  else:
268
- draw.text((pwat_x, y), "No estimable (ulcera no detectada)", fill=(107, 114, 128), font=font_body)
269
-
270
- # ── Footer ──
271
- draw.rectangle([(0, H - 90), (W, H)], fill=(249, 250, 251))
272
- draw.line([(0, H - 90), (W, H - 90)], fill=(209, 213, 219), width=1)
273
- footer_lines = [
274
- "WoundNetB7 | Tesis Doctoral | Marcelo Marquez-Murillo",
275
- "Ulcer Dice: 0.927 (CI 95%: [0.917, 0.936]) | Debiasing: 46.6% max group gap reduction (p < 1e-55)",
276
- ]
277
- draw.text((MARGIN, H - 80), footer_lines[0], fill=(107, 114, 128), font=font_small)
278
- draw.text((MARGIN, H - 52), footer_lines[1], fill=(156, 163, 175), font=font_small)
279
 
280
- return report
 
 
 
 
 
 
 
 
 
 
 
281
 
282
 
283
  def generate_report_files(image_rgb, binary_overlay, multiclass_overlay, result):
284
- """Generate downloadable report files (PNG + JSON)."""
285
  tmpdir = tempfile.mkdtemp(prefix="woundnetb7_report_")
286
 
287
- # PNG report
288
- report_img = generate_report_image(image_rgb, binary_overlay, multiclass_overlay, result)
289
- png_path = os.path.join(tmpdir, "WoundNetB7_Informe_DFU.png")
290
- report_img.save(png_path, "PNG", dpi=(300, 300))
291
 
292
  # JSON report
293
  report_data = result.to_dict()
@@ -303,12 +569,11 @@ def generate_report_files(image_rgb, binary_overlay, multiclass_overlay, result)
303
  with open(json_path, "w", encoding="utf-8") as f:
304
  json.dump(report_data, f, indent=2, ensure_ascii=False)
305
 
306
- return [png_path, json_path]
307
 
308
 
309
  # ── Gradio callbacks ─────────────────────────────────────────────────────────
310
 
311
- # Store last analysis result for report generation
312
  _last_analysis = {}
313
 
314
 
@@ -325,7 +590,6 @@ def analyze_image(image):
325
  binary_overlay = pipe.visualize_binary(img_bgr, result)
326
  multiclass_overlay = pipe.visualize_multiclass(img_bgr, result)
327
 
328
- # Cache for report
329
  _last_analysis["image_rgb"] = image
330
  _last_analysis["binary"] = binary_overlay
331
  _last_analysis["multiclass"] = multiclass_overlay
@@ -339,8 +603,12 @@ def analyze_image(image):
339
  return binary_overlay, multiclass_overlay, seg_stats, fitz_html, pwat_html, json_out
340
 
341
 
 
 
 
 
 
342
  def download_report():
343
- """Generate and return downloadable report files."""
344
  if not _last_analysis:
345
  return None
346
  return generate_report_files(
@@ -356,10 +624,8 @@ def download_report():
356
  def build_fitz_html(fitz):
357
  if fitz is None or fitz.confidence == 0:
358
  return "<p style='color:#6b7280;'>No se pudo estimar (insuficientes pixeles de piel sana).</p>"
359
-
360
  bg = FITZ_COLORS.get(fitz.fitzpatrick_type, "#e5e7eb")
361
  fg = FITZ_TEXT_COLORS.get(fitz.fitzpatrick_type, "#1f2937")
362
-
363
  return f"""
364
  <div style="display:flex; gap:16px; align-items:center; flex-wrap:wrap;">
365
  <div style="background:{bg}; color:{fg}; border-radius:12px; padding:18px 28px;
@@ -374,27 +640,22 @@ def build_fitz_html(fitz):
374
  <b>Pixeles sanos:</b> {fitz.healthy_pixels:,}<br>
375
  <b>Confianza:</b> {fitz.confidence:.0%}
376
  </div>
377
- </div>
378
- """
379
 
380
 
381
  def build_pwat_html(pwat):
382
  if pwat is None or not pwat.scores_raw:
383
  return "<p style='color:#6b7280;'>No se pudo estimar PWAT (ulcera no detectada o muy pequena).</p>"
384
-
385
  rows = ""
386
  for item in [3, 4, 5, 6, 7, 8]:
387
  name = ITEM_NAMES.get(item, f"Item {item}")
388
  raw = pwat.scores_raw.get(item, 0)
389
  adj = pwat.scores_adjusted.get(item, 0.0)
390
  diff = adj - raw
391
-
392
  diff_color = "#059669" if diff < -0.05 else "#6b7280"
393
  diff_str = f"{diff:+.1f}" if abs(diff) > 0.01 else "0.0"
394
-
395
  raw_pct = raw / 4 * 100
396
  adj_pct = adj / 4 * 100
397
-
398
  rows += f"""
399
  <tr>
400
  <td style="padding:8px 12px; font-weight:500;">{name}</td>
@@ -414,15 +675,11 @@ def build_pwat_html(pwat):
414
  <span style="font-weight:600; min-width:30px;">{adj:.1f}</span>
415
  </div>
416
  </td>
417
- <td style="padding:8px 12px; text-align:center; color:{diff_color}; font-weight:600;">
418
- {diff_str}
419
- </td>
420
  </tr>"""
421
-
422
  total_diff = pwat.total_adjusted - pwat.total_raw
423
  total_color = "#059669" if total_diff < -0.05 else "#6b7280"
424
  total_diff_str = f"{total_diff:+.1f}" if abs(total_diff) > 0.01 else "0.0"
425
-
426
  return f"""
427
  <table style="width:100%; border-collapse:collapse; font-size:0.92em;">
428
  <thead>
@@ -433,8 +690,7 @@ def build_pwat_html(pwat):
433
  <th style="padding:10px 12px; text-align:center;">&Delta;</th>
434
  </tr>
435
  </thead>
436
- <tbody>
437
- {rows}
438
  <tr style="border-top:2px solid #374151; font-weight:700; font-size:1.05em;">
439
  <td style="padding:10px 12px;">TOTAL</td>
440
  <td style="padding:10px 12px; text-align:center;">{pwat.total_raw}</td>
@@ -444,18 +700,16 @@ def build_pwat_html(pwat):
444
  </tbody>
445
  </table>
446
  <p style="font-size:0.82em; color:#6b7280; margin-top:8px;">
447
- Escala: 0 (mejor) &mdash; 4 (peor) por item &bull;
448
- Correccion Fitzpatrick tipo {pwat.fitzpatrick_type} aplicada &bull;
449
  Items: 3=Tipo necrotico, 4=Cantidad necrotica, 5=Tipo granulacion,
450
  6=Cantidad granulacion, 7=Bordes, 8=Piel periulceral
451
- </p>
452
- """
453
 
454
 
455
  def build_seg_stats_html(result):
456
  dist = result.class_distribution
457
  colors = {"background": "#374151", "foot": "#22c55e", "perilesion": "#f97316", "ulcer": "#ef4444"}
458
-
459
  bars = ""
460
  for cls_name in ["foot", "perilesion", "ulcer"]:
461
  pct = dist.get(cls_name, 0)
@@ -471,43 +725,27 @@ def build_seg_stats_html(result):
471
  <div style="background:{color}; height:100%; width:{pct}%; border-radius:4px;"></div>
472
  </div>
473
  </div>"""
474
-
475
  return f"""
476
  <div style="padding:4px 0;">
477
  <p style="font-size:0.85em; color:#6b7280; margin-bottom:10px;">
478
- Imagen: {result.image_size[1]}x{result.image_size[0]} &bull; Device: {result.device}
479
  </p>
480
  {bars}
481
- </div>
482
- """
483
 
484
 
485
  # ── Gradio UI ────────────────────────────────────────────────────────────────
486
 
487
  css = """
488
  .step-header {
489
- display: flex;
490
- align-items: center;
491
- gap: 10px;
492
- margin-bottom: 12px;
493
  }
494
  .step-number {
495
- background: #1f2937;
496
- color: white;
497
- border-radius: 50%;
498
- width: 30px;
499
- height: 30px;
500
- display: flex;
501
- align-items: center;
502
- justify-content: center;
503
- font-weight: 700;
504
- font-size: 0.9em;
505
- flex-shrink: 0;
506
- }
507
- .step-title {
508
- font-weight: 600;
509
- font-size: 1.1em;
510
  }
 
511
  """
512
 
513
  with gr.Blocks(
@@ -525,129 +763,173 @@ with gr.Blocks(
525
  </div>
526
  """)
527
 
528
- with gr.Row():
529
- with gr.Column(scale=1):
530
- input_image = gr.Image(label="Imagen DFU", type="numpy")
531
- analyze_btn = gr.Button("Analizar", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  gr.HTML("""
533
- <div style="font-size:0.82em; color:#6b7280; margin-top:8px; line-height:1.6;">
534
- <b>Pipeline:</b> La imagen pasa por 4 etapas secuenciales.<br>
535
- <b>Modelo:</b> WoundNetB7 entrenado con Combo Loss + Small Object Loss
536
- para ulceras pequenas. Mecanismos de atencion: CBAM (canal+espacial),
537
- CoordAttention (posicional), TAM (topologico con dimension fractal
538
- y caracteristica de Euler).<br>
539
- <b>TTA:</b> 6 augmentaciones en inferencia (flips + rotaciones).
540
  </div>
541
  """)
542
 
543
- # ── Step 1: Binary Segmentation ──
544
- gr.HTML("""
545
- <div class="step-header">
546
- <div class="step-number">1</div>
547
- <div class="step-title">Segmentacion Binaria de la Ulcera</div>
548
- </div>
549
- """)
550
- with gr.Row():
551
- with gr.Column(scale=1):
552
- output_binary = gr.Image(label="Mascara Binaria Ulcera (WoundNetB7)")
553
- with gr.Column(scale=1):
554
- output_seg_stats = gr.HTML(label="Estadisticas de Segmentacion")
 
 
 
555
 
556
- # ── Step 2: Multiclass Segmentation ──
557
- gr.HTML("""
558
- <div class="step-header" style="margin-top:12px;">
559
- <div class="step-number">2</div>
560
- <div class="step-title">Segmentacion Multiclase (4 clases)</div>
561
- </div>
562
- """)
563
- with gr.Row():
564
- with gr.Column(scale=1):
565
- output_multiclass = gr.Image(label="Overlay Multiclase")
566
- with gr.Column(scale=1):
567
  gr.HTML("""
568
- <div style="padding:12px;">
569
- <p style="font-weight:600; margin-bottom:10px;">Leyenda de clases:</p>
570
- <div style="display:flex; flex-direction:column; gap:8px;">
571
- <div style="display:flex; align-items:center; gap:8px;">
572
- <div style="width:20px; height:20px; background:#22c55e; border-radius:4px;"></div>
573
- <span><b>Pie</b> &mdash; tejido sano del pie</span>
574
- </div>
575
- <div style="display:flex; align-items:center; gap:8px;">
576
- <div style="width:20px; height:20px; background:#f97316; border-radius:4px;"></div>
577
- <span><b>Perilesion</b> &mdash; zona periulceral</span>
578
- </div>
579
- <div style="display:flex; align-items:center; gap:8px;">
580
- <div style="width:20px; height:20px; background:#ef4444; border-radius:4px;"></div>
581
- <span><b>Ulcera</b> &mdash; lecho de la herida</span>
582
- </div>
583
- </div>
584
- <p style="font-size:0.82em; color:#6b7280; margin-top:12px;">
585
- Modelo multiclase con Combo Loss (Dice + CE ponderado) +
586
- Small Object Focal Loss para deteccion de ulceras pequenas.
587
- Arquitectura con skip connections y ASPP (rates 6, 12, 18)
588
- para capturar contexto multi-escala.
589
  </p>
 
 
 
 
 
 
 
 
 
 
590
  </div>
591
  """)
592
 
593
- # ── Step 3: Fitzpatrick/ITA ──
594
- gr.HTML("""
595
- <div class="step-header" style="margin-top:12px;">
596
- <div class="step-number">3</div>
597
- <div class="step-title">Estimacion Fitzpatrick / ITA</div>
598
- </div>
599
- """)
600
- output_fitz = gr.HTML()
601
 
602
- # ── Step 4: PWAT ──
603
- gr.HTML("""
604
- <div class="step-header" style="margin-top:12px;">
605
- <div class="step-number">4</div>
606
- <div class="step-title">PWAT &mdash; Scores Raw vs Ajustados por Fitzpatrick</div>
607
- </div>
608
- """)
609
- output_pwat = gr.HTML()
610
 
611
- # ── Download Report ──
612
- gr.HTML("""
613
- <div class="step-header" style="margin-top:16px;">
614
- <div class="step-number" style="background:#059669;">&#8681;</div>
615
- <div class="step-title">Descargar Informe Clinico</div>
616
- </div>
617
- <p style="font-size:0.88em; color:#6b7280; margin-bottom:8px;">
618
- Genera un informe compuesto (PNG 300 DPI) con todas las visualizaciones
619
- y un archivo JSON con los datos estructurados. Primero analiza una imagen.
620
- </p>
621
- """)
622
- download_btn = gr.Button("Descargar Informe", variant="secondary", size="lg")
623
- output_files = gr.File(label="Archivos del Informe", file_count="multiple")
624
-
625
- # ── JSON (collapsible) ──
626
- with gr.Accordion("JSON completo (para integracion)", open=False):
627
- output_json = gr.Code(label="JSON Output", language="json")
628
-
629
- # ── Wire events ──
630
- analyze_btn.click(
631
- fn=analyze_image,
632
- inputs=[input_image],
633
- outputs=[
634
- output_binary,
635
- output_multiclass,
636
- output_seg_stats,
637
- output_fitz,
638
- output_pwat,
639
- output_json,
640
- ],
641
- )
642
 
643
- download_btn.click(
644
- fn=download_report,
645
- inputs=[],
646
- outputs=[output_files],
647
- )
 
 
 
 
 
 
 
 
648
 
649
  gr.HTML("""
650
- <div style="text-align:center; padding:16px 0; font-size:0.82em; color:#9ca3af; border-top:1px solid #e5e7eb; margin-top:20px;">
 
651
  WoundNetB7 &bull; Tesis Doctoral &bull; Marcelo Marquez-Murillo &bull;
652
  Ulcer Dice 0.927 (CI 95%: [0.917, 0.936]) &bull;
653
  Debiasing: 46.6% max group gap reduction (p &lt; 10<sup>-55</sup>)
 
5
  2. Multiclass segmentation (background / foot / perilesion / ulcer)
6
  3. Fitzpatrick/ITA skin type estimation
7
  4. PWAT scores (raw) + PWAT adjusted by Fitzpatrick debiasing
8
+ 5. Downloadable clinical report (PDF + JSON)
9
+ 6. Guided camera capture with foot silhouette overlay
10
 
11
  Launch locally: python app.py
12
  Deploy to HF: push this repo to a Hugging Face Space (GPU recommended).
 
19
  import os
20
  from datetime import datetime
21
  from PIL import Image, ImageDraw, ImageFont
22
+ from fpdf import FPDF
23
  from pipeline import WoundNetB7Pipeline
24
  from src.pwat_estimator import ITEM_NAMES
25
 
 
46
  }
47
 
48
 
49
+ # ── Foot Guide Overlay ───────────────────────────────────────────────────────
50
+
51
+ def generate_foot_guide(width=640, height=480):
52
+ """Generate a semi-transparent foot silhouette guide overlay for camera capture."""
53
+ guide = np.zeros((height, width, 4), dtype=np.uint8)
54
+
55
+ cx, cy = width // 2, height // 2
56
+ scale_x = width / 640
57
+ scale_y = height / 480
58
+
59
+ # Foot outline points (plantar view, normalized for 640x480)
60
+ foot_points = np.array([
61
+ # Right side (lateral)
62
+ (370, 420), (380, 380), (385, 340), (385, 300), (382, 260),
63
+ (378, 220), (370, 180), (360, 150), (348, 120), (335, 100),
64
+ (325, 85), (318, 72),
65
+ # Toes (right to left)
66
+ (320, 60), (325, 48), (318, 38), (305, 42), # 5th toe
67
+ (305, 35), (310, 22), (300, 18), (290, 28), # 4th toe
68
+ (288, 20), (292, 8), (280, 5), (272, 18), # 3rd toe
69
+ (268, 12), (270, -2), (258, -5), (250, 10), # 2nd toe
70
+ (245, 5), (242, -10), (228, -8), (230, 12), # Big toe
71
+ # Left side (medial)
72
+ (225, 30), (218, 55), (215, 80), (218, 110),
73
+ (222, 140), (228, 180), (235, 220), (240, 260),
74
+ (245, 300), (248, 340), (250, 380), (255, 420),
75
+ # Heel
76
+ (270, 445), (300, 455), (330, 450), (355, 435),
77
+ ], dtype=np.float32)
78
+
79
+ # Center and scale
80
+ foot_center = foot_points.mean(axis=0)
81
+ foot_points -= foot_center
82
+ foot_points[:, 0] *= scale_x * 0.85
83
+ foot_points[:, 1] *= scale_y * 0.85
84
+ foot_points += [cx, cy]
85
+ foot_pts = foot_points.astype(np.int32)
86
+
87
+ # Draw filled semi-transparent foot area
88
+ foot_mask = np.zeros((height, width), dtype=np.uint8)
89
+ cv2.fillPoly(foot_mask, [foot_pts], 255)
90
+
91
+ # Semi-transparent green fill
92
+ guide[foot_mask > 0] = [0, 200, 100, 35]
93
+
94
+ # Foot outline (bright green, dashed effect via thick line)
95
+ cv2.polylines(guide, [foot_pts], True, (0, 220, 120, 200), 3, cv2.LINE_AA)
96
+
97
+ # Center crosshair
98
+ cross_len = 20
99
+ cv2.line(guide, (cx - cross_len, cy), (cx + cross_len, cy), (255, 255, 255, 150), 1)
100
+ cv2.line(guide, (cx, cy - cross_len), (cx, cy + cross_len), (255, 255, 255, 150), 1)
101
+
102
+ # Corner brackets for framing
103
+ bracket_len = 40
104
+ bracket_color = (0, 220, 120, 200)
105
+ bw = 2
106
+ margin = 30
107
+ corners = [
108
+ (margin, margin),
109
+ (width - margin, margin),
110
+ (margin, height - margin),
111
+ (width - margin, height - margin),
112
+ ]
113
+ for (x, y) in corners:
114
+ dx = bracket_len if x < width // 2 else -bracket_len
115
+ dy = bracket_len if y < height // 2 else -bracket_len
116
+ cv2.line(guide, (x, y), (x + dx, y), bracket_color, bw)
117
+ cv2.line(guide, (x, y), (x, y + dy), bracket_color, bw)
118
+
119
+ return guide
120
+
121
+
122
+ def apply_foot_guide(frame):
123
+ """Apply the foot guide overlay to a camera frame."""
124
+ if frame is None:
125
+ return None
126
+
127
+ h, w = frame.shape[:2]
128
+ guide = generate_foot_guide(w, h)
129
+
130
+ # Composite RGBA guide over RGB frame
131
+ frame_rgba = cv2.cvtColor(frame, cv2.COLOR_RGB2RGBA)
132
+ alpha = guide[:, :, 3:4].astype(np.float32) / 255.0
133
+ blended = frame_rgba.astype(np.float32)
134
+ overlay = guide.astype(np.float32)
135
+ blended[:, :, :3] = blended[:, :, :3] * (1 - alpha) + overlay[:, :, :3] * alpha
136
+ blended[:, :, 3] = 255
137
+
138
+ result = blended[:, :, :3].astype(np.uint8)
139
+
140
+ # Add instruction text at top
141
+ cv2.putText(result, "Posicione el pie dentro de la guia",
142
+ (w // 2 - 200, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 220, 120), 2, cv2.LINE_AA)
143
+ cv2.putText(result, "Distancia: 30-40 cm | Iluminacion uniforme",
144
+ (w // 2 - 230, h - 15), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1, cv2.LINE_AA)
145
+
146
+ return result
147
+
148
+
149
+ def generate_static_guide():
150
+ """Generate a static reference guide image with instructions."""
151
+ W, H = 500, 700
152
+ img = Image.new("RGB", (W, H), (248, 250, 252))
153
+ draw = ImageDraw.Draw(img)
154
+
155
+ font_title = _get_font(24)
156
+ font_body = _get_font_regular(16)
157
+ font_small = _get_font_regular(14)
158
+
159
+ # Title
160
+ draw.text((W // 2 - 130, 15), "Guia de Captura DFU", fill=(31, 41, 55), font=font_title)
161
+
162
+ # Draw foot silhouette (simplified)
163
+ foot_guide_rgba = generate_foot_guide(400, 350)
164
+ foot_rgb = foot_guide_rgba[:, :, :3]
165
+ # Make non-zero areas visible on white background
166
+ mask = foot_guide_rgba[:, :, 3] > 0
167
+ bg_section = np.full((350, 400, 3), 245, dtype=np.uint8)
168
+ bg_section[mask] = foot_rgb[mask]
169
+ # Draw the outline more visibly
170
+ foot_pil = Image.fromarray(bg_section)
171
+ img.paste(foot_pil, (50, 55))
172
+
173
+ # Border around foot area
174
+ draw.rectangle([(48, 53), (452, 407)], outline=(209, 213, 219), width=2)
175
+
176
+ # Instructions
177
+ y = 425
178
+ instructions = [
179
+ ("1.", "Planta del pie hacia la camara (vista plantar)"),
180
+ ("2.", "Distancia: 30-40 cm del lente"),
181
+ ("3.", "Iluminacion uniforme, sin sombras directas"),
182
+ ("4.", "Fondo neutro (sabana blanca o azul)"),
183
+ ("5.", "Incluir toda la ulcera + 3-5 cm de piel sana"),
184
+ ("6.", "Sin flash directo (causa reflejos)"),
185
+ ("7.", "Pie centrado dentro de la silueta verde"),
186
+ ]
187
+ for num, text in instructions:
188
+ draw.text((30, y), num, fill=(5, 150, 105), font=font_title)
189
+ draw.text((60, y + 2), text, fill=(55, 65, 81), font=font_body)
190
+ y += 30
191
+
192
+ # Bottom note
193
+ draw.line([(30, y + 5), (W - 30, y + 5)], fill=(229, 231, 235), width=1)
194
+ draw.text((30, y + 12),
195
+ "Tip: Para mejor resultado, capture con luz natural",
196
+ fill=(107, 114, 128), font=font_small)
197
+ draw.text((30, y + 32),
198
+ "difusa. Evite luces cenitales que generan sombra.",
199
+ fill=(107, 114, 128), font=font_small)
200
+
201
+ return np.array(img)
202
+
203
+
204
+ # ── PDF Report Generation ────────────────────────────────────────────────────
205
 
206
  def _get_font(size):
 
207
  for name in ["DejaVuSans-Bold.ttf", "DejaVuSans.ttf", "arial.ttf", "LiberationSans-Bold.ttf"]:
208
  try:
209
  return ImageFont.truetype(name, size)
 
221
  return ImageFont.load_default()
222
 
223
 
224
+ class DFUReport(FPDF):
225
+ """Custom PDF report for DFU analysis results."""
226
+
227
+ def __init__(self):
228
+ super().__init__(orientation="P", unit="mm", format="A4")
229
+ self.set_auto_page_break(auto=True, margin=15)
230
+ self._setup_fonts()
231
+
232
+ def _setup_fonts(self):
233
+ """Register Unicode font if available, otherwise use built-in."""
234
+ font_paths = [
235
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
236
+ "/usr/share/fonts/TTF/DejaVuSans.ttf",
237
+ "C:/Windows/Fonts/arial.ttf",
238
+ ]
239
+ self._has_unicode = False
240
+ for fp in font_paths:
241
+ if os.path.exists(fp):
242
+ try:
243
+ self.add_font("CustomFont", "", fp, uni=True)
244
+ bold_fp = fp.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf").replace("arial.ttf", "arialbd.ttf")
245
+ if os.path.exists(bold_fp):
246
+ self.add_font("CustomFont", "B", bold_fp, uni=True)
247
+ else:
248
+ self.add_font("CustomFont", "B", fp, uni=True)
249
+ self._has_unicode = True
250
+ break
251
+ except Exception:
252
+ continue
253
+
254
+ def _font(self, style="", size=10):
255
+ if self._has_unicode:
256
+ self.set_font("CustomFont", style, size)
257
+ else:
258
+ self.set_font("Helvetica", style, size)
259
+
260
+ def header(self):
261
+ self.set_fill_color(31, 41, 55)
262
+ self.rect(0, 0, 210, 22, "F")
263
+ self._font("B", 14)
264
+ self.set_text_color(255, 255, 255)
265
+ self.set_xy(10, 4)
266
+ self.cell(0, 8, "WoundNetB7 - Informe de Analisis DFU", 0, 0, "L")
267
+ self._font("", 8)
268
+ self.set_text_color(156, 163, 175)
269
+ self.set_xy(10, 13)
270
+ self.cell(0, 6, "EfficientNet-B7 + ASPP + CBAM + CoordAttention + TAM | Ulcer Dice: 0.927", 0, 0, "L")
271
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
272
+ self.set_xy(160, 4)
273
+ self.cell(40, 8, timestamp, 0, 0, "R")
274
+ self.ln(20)
275
+
276
+ def footer(self):
277
+ self.set_y(-12)
278
+ self._font("", 7)
279
+ self.set_text_color(156, 163, 175)
280
+ self.cell(0, 5, "WoundNetB7 | Tesis Doctoral | Marcelo Marquez-Murillo | "
281
+ "Dice 0.927 (CI 95%: [0.917, 0.936]) | Debiasing: 46.6% gap reduction (p < 1e-55)", 0, 0, "C")
282
+ self.cell(0, 5, f"Pag {self.page_no()}/{{nb}}", 0, 0, "R")
283
+
284
+ def section_title(self, number, title):
285
+ self._font("B", 11)
286
+ self.set_text_color(31, 41, 55)
287
+ self.set_fill_color(243, 244, 246)
288
+ self.cell(8, 7, str(number), 0, 0, "C", fill=False)
289
+ self.cell(0, 7, f" {title}", 0, 1, "L")
290
+ self.ln(2)
291
+
292
+ def add_image_pair(self, img1_path, label1, img2_path, label2):
293
+ """Add two images side by side with labels."""
294
+ self._font("", 8)
295
+ self.set_text_color(107, 114, 128)
296
+ x = self.get_x()
297
+ y = self.get_y()
298
+ img_w = 90
299
+ img_h = 60
300
+
301
+ self.cell(img_w, 4, label1, 0, 0, "C")
302
+ self.cell(5, 4, "", 0, 0)
303
+ self.cell(img_w, 4, label2, 0, 1, "C")
304
+
305
+ self.image(img1_path, x=x, y=self.get_y(), w=img_w, h=img_h)
306
+ self.image(img2_path, x=x + img_w + 5, y=self.get_y(), w=img_w, h=img_h)
307
+ self.ln(img_h + 3)
308
+
309
+
310
+ def generate_pdf_report(image_rgb, binary_overlay, multiclass_overlay, result):
311
+ """Generate a clinical PDF report with all analysis results."""
312
+ tmpdir = tempfile.mkdtemp(prefix="woundnetb7_report_")
313
+
314
+ # Save temp images for embedding in PDF
315
+ orig_path = os.path.join(tmpdir, "_orig.png")
316
+ binary_path = os.path.join(tmpdir, "_binary.png")
317
+ multi_path = os.path.join(tmpdir, "_multi.png")
318
+
319
+ Image.fromarray(image_rgb).save(orig_path)
320
+ Image.fromarray(binary_overlay).save(binary_path)
321
+ Image.fromarray(multiclass_overlay).save(multi_path)
322
+
323
+ pdf = DFUReport()
324
+ pdf.alias_nb_pages()
325
+ pdf.add_page()
326
+
327
+ # ── Section 1: Images ──
328
+ pdf.section_title(1, "Segmentacion")
329
+ pdf.add_image_pair(orig_path, "Imagen Original", binary_path, "Segmentacion Binaria (Ulcera)")
330
+ pdf.ln(2)
331
+
332
+ # Multiclass + legend
333
+ pdf._font("", 8)
334
+ pdf.set_text_color(107, 114, 128)
335
+ x_start = pdf.get_x()
336
+ y_start = pdf.get_y()
337
+ pdf.cell(90, 4, "Segmentacion Multiclase", 0, 0, "C")
338
+ pdf.cell(5, 4, "", 0, 0)
339
+ pdf.cell(90, 4, "Distribucion de Clases", 0, 1, "C")
340
+
341
+ pdf.image(multi_path, x=x_start, y=pdf.get_y(), w=90, h=60)
342
+
343
+ # Class distribution on the right
344
+ legend_x = x_start + 95 + 5
345
+ legend_y = pdf.get_y() + 5
346
+
347
  class_info = [
348
  ("Pie", result.class_distribution.get("foot", 0), (34, 197, 94)),
349
  ("Perilesion", result.class_distribution.get("perilesion", 0), (249, 115, 22)),
350
  ("Ulcera", result.class_distribution.get("ulcer", 0), (239, 68, 68)),
351
  ("Fondo", result.class_distribution.get("background", 0), (107, 114, 128)),
352
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
+ for cls_name, pct, (r, g, b) in class_info:
355
+ pdf.set_xy(legend_x, legend_y)
356
+ pdf.set_fill_color(r, g, b)
357
+ pdf.rect(legend_x, legend_y + 1, 4, 4, "F")
358
+ pdf._font("B", 9)
359
+ pdf.set_text_color(r, g, b)
360
+ pdf.set_xy(legend_x + 6, legend_y)
361
+ pdf.cell(30, 5, cls_name, 0, 0)
362
+ pdf._font("", 9)
363
+ pdf.set_text_color(50, 50, 50)
364
+ pdf.cell(20, 5, f"{pct:.1f}%", 0, 0)
365
+ # Mini bar
366
+ bar_x = legend_x + 56
367
+ bar_w = 30
368
+ pdf.set_fill_color(229, 231, 235)
369
+ pdf.rect(bar_x, legend_y + 1, bar_w, 4, "F")
370
+ pdf.set_fill_color(r, g, b)
371
+ pdf.rect(bar_x, legend_y + 1, max(0.5, bar_w * pct / 100), 4, "F")
372
+ legend_y += 10
373
+
374
+ pdf.ln(62)
375
+
376
+ # Image metadata
377
+ h_img, w_img = result.image_size
378
+ pdf._font("", 8)
379
+ pdf.set_text_color(107, 114, 128)
380
+ pdf.cell(0, 4, f"Resolucion: {w_img}x{h_img} px | Device: {result.device} | "
381
+ f"Area ulcera: {result.class_distribution.get('ulcer', 0):.1f}%", 0, 1)
382
+ pdf.ln(4)
383
+
384
+ # ── Section 2: Fitzpatrick ──
385
+ pdf.section_title(2, "Estimacion Fitzpatrick / ITA")
386
  fitz = result.fitzpatrick
 
387
  if fitz and fitz.confidence > 0:
 
388
  ftype = fitz.fitzpatrick_type
389
+ bg = FITZ_RGB.get(ftype, (229, 231, 235))
390
+ fg = FITZ_TEXT_RGB.get(ftype, (50, 50, 50))
391
+
392
+ # Badge
393
+ x_badge = pdf.get_x()
394
+ y_badge = pdf.get_y()
395
+ pdf.set_fill_color(*bg)
396
+ pdf.set_draw_color(180, 180, 180)
397
+ pdf.rect(x_badge, y_badge, 35, 20, "DF")
398
+ pdf._font("B", 16)
399
+ pdf.set_text_color(*fg)
400
+ pdf.set_xy(x_badge, y_badge + 2)
401
+ pdf.cell(35, 9, f"Tipo {ftype}", 0, 0, "C")
402
+ pdf._font("", 8)
403
+ pdf.set_xy(x_badge, y_badge + 12)
404
+ pdf.cell(35, 6, fitz.fitzpatrick_label, 0, 0, "C")
405
+
406
+ # Details table
407
+ pdf.set_text_color(50, 50, 50)
408
+ pdf._font("", 9)
409
+ det_x = x_badge + 40
410
+ det_y = y_badge
411
+ details = [
412
+ ("ITA", f"{fitz.ita_angle:.1f} +/- {fitz.ita_std:.1f} grados"),
413
+ ("L* medio (piel sana)", f"{fitz.l_skin_mean:.1f}"),
414
+ ("b* medio (piel sana)", f"{fitz.b_skin_mean:.1f}"),
415
+ ("Pixeles sanos", f"{fitz.healthy_pixels:,}"),
416
+ ("Ratio piel sana", f"{fitz.healthy_ratio:.1%}"),
417
+ ("Confianza", f"{fitz.confidence:.0%}"),
418
  ]
419
+ for label, value in details:
420
+ pdf.set_xy(det_x, det_y)
421
+ pdf._font("B", 8)
422
+ pdf.cell(42, 4, f"{label}:", 0, 0)
423
+ pdf._font("", 8)
424
+ pdf.cell(50, 4, value, 0, 0)
425
+ det_y += 4.5
426
+
427
+ pdf.set_y(y_badge + 22)
428
  else:
429
+ pdf._font("", 9)
430
+ pdf.set_text_color(107, 114, 128)
431
+ pdf.cell(0, 5, "No estimable (insuficientes pixeles de piel sana).", 0, 1)
432
+ pdf.ln(4)
433
 
434
+ # ── Section 3: PWAT ──
435
+ pdf.section_title(3, "PWAT - Scores Raw vs Ajustados por Fitzpatrick")
436
  pwat = result.pwat
 
437
  if pwat and pwat.scores_raw:
438
  ftype_str = pwat.fitzpatrick_type or "III"
439
 
440
  # Table header
441
+ pdf.set_fill_color(243, 244, 246)
442
+ pdf._font("B", 9)
443
+ pdf.set_text_color(55, 65, 81)
444
+ col_widths = [55, 25, 25, 25, 20, 35]
445
+ headers = ["Item PWAT", "Raw", "Ajust.", "Delta", "Escala", ""]
446
+ for w, h in zip(col_widths, headers):
447
+ pdf.cell(w, 6, h, 1, 0, "C", fill=True)
448
+ pdf.ln()
449
+
450
+ # Table rows
451
  for item in [3, 4, 5, 6, 7, 8]:
452
  name = ITEM_NAMES.get(item, f"Item {item}")
453
  raw = pwat.scores_raw.get(item, 0)
454
  adj = pwat.scores_adjusted.get(item, 0.0)
455
  diff = adj - raw
456
  diff_str = f"{diff:+.1f}" if abs(diff) > 0.01 else "0.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
457
 
458
+ pdf._font("", 9)
459
+ pdf.set_text_color(50, 50, 50)
460
+ pdf.cell(col_widths[0], 6, name, "LB", 0, "L")
461
+ pdf.cell(col_widths[1], 6, str(raw), "B", 0, "C")
462
+ pdf.cell(col_widths[2], 6, f"{adj:.1f}", "B", 0, "C")
463
+
464
+ if diff < -0.05:
465
+ pdf.set_text_color(5, 150, 105)
466
+ else:
467
+ pdf.set_text_color(107, 114, 128)
468
+ pdf._font("B", 9)
469
+ pdf.cell(col_widths[3], 6, diff_str, "B", 0, "C")
470
+
471
+ # Visual bar
472
+ pdf.set_text_color(50, 50, 50)
473
+ pdf._font("", 7)
474
+ bar_x = pdf.get_x() + 2
475
+ bar_y = pdf.get_y() + 1.5
476
+ pdf.set_fill_color(229, 231, 235)
477
+ pdf.rect(bar_x, bar_y, col_widths[4] - 4, 3, "F")
478
+ pdf.set_fill_color(239, 68, 68)
479
+ pdf.rect(bar_x, bar_y, max(0.3, (col_widths[4] - 4) * raw / 4), 3, "F")
480
+ pdf.cell(col_widths[4], 6, "", "B", 0)
481
+
482
+ # Severity label
483
+ pdf._font("", 7)
484
+ sev_labels = {0: "Normal", 1: "Leve", 2: "Moderado", 3: "Severo", 4: "Extremo"}
485
+ pdf.set_text_color(107, 114, 128)
486
+ pdf.cell(col_widths[5], 6, sev_labels.get(raw, ""), "RB", 0, "L")
487
+ pdf.ln()
488
 
489
  # Total row
490
+ pdf.set_fill_color(31, 41, 55)
491
+ pdf._font("B", 10)
492
+ pdf.set_text_color(255, 255, 255)
493
+ pdf.cell(col_widths[0], 7, "TOTAL", 1, 0, "L", fill=True)
494
+ pdf.cell(col_widths[1], 7, str(pwat.total_raw), 1, 0, "C", fill=True)
495
+ pdf.cell(col_widths[2], 7, f"{pwat.total_adjusted:.1f}", 1, 0, "C", fill=True)
496
  total_diff = pwat.total_adjusted - pwat.total_raw
497
  total_diff_str = f"{total_diff:+.1f}" if abs(total_diff) > 0.01 else "0.0"
498
+ pdf.cell(col_widths[3], 7, total_diff_str, 1, 0, "C", fill=True)
499
+ pdf.cell(col_widths[4] + col_widths[5], 7, f"Fitzpatrick {ftype_str}", 1, 0, "C", fill=True)
500
+ pdf.ln(10)
501
+
502
+ # Score interpretation
503
+ pdf._font("", 8)
504
+ pdf.set_text_color(107, 114, 128)
505
+ pdf.cell(0, 4, "Escala: 0 (normal) - 4 (extremo) por item. Total: 0-24.", 0, 1)
506
+ pdf.cell(0, 4, f"Correccion de sesgo aplicada segun Fitzpatrick tipo {ftype_str} "
507
+ "(calibrada en 61 imagenes, r=0.975).", 0, 1)
508
+
509
+ # Interpretation ranges
510
+ pdf.ln(2)
511
+ pdf._font("B", 8)
512
+ pdf.set_text_color(55, 65, 81)
513
+ pdf.cell(0, 4, "Interpretacion del puntaje total:", 0, 1)
514
+ pdf._font("", 8)
515
+ ranges = [
516
+ ("0-6:", "Herida en buen estado de cicatrizacion", (34, 197, 94)),
517
+ ("7-12:", "Herida con compromiso moderado, requiere seguimiento", (249, 115, 22)),
518
+ ("13-18:", "Herida con compromiso severo, ajustar tratamiento", (239, 68, 68)),
519
+ ("19-24:", "Herida critica, evaluacion urgente", (180, 30, 30)),
520
+ ]
521
+ for label, desc, (r, g, b) in ranges:
522
+ pdf.set_fill_color(r, g, b)
523
+ pdf.rect(pdf.get_x(), pdf.get_y() + 0.5, 3, 3, "F")
524
+ pdf._font("B", 8)
525
+ pdf.set_text_color(r, g, b)
526
+ pdf.set_x(pdf.get_x() + 5)
527
+ pdf.cell(15, 4, label, 0, 0)
528
+ pdf._font("", 8)
529
+ pdf.set_text_color(80, 80, 80)
530
+ pdf.cell(0, 4, desc, 0, 1)
531
 
 
 
 
532
  else:
533
+ pdf._font("", 9)
534
+ pdf.set_text_color(107, 114, 128)
535
+ pdf.cell(0, 5, "No estimable (ulcera no detectada o area insuficiente).", 0, 1)
 
 
 
 
 
 
 
 
536
 
537
+ # Save PDF
538
+ pdf_path = os.path.join(tmpdir, "WoundNetB7_Informe_DFU.pdf")
539
+ pdf.output(pdf_path)
540
+
541
+ # Cleanup temp images
542
+ for p in [orig_path, binary_path, multi_path]:
543
+ try:
544
+ os.remove(p)
545
+ except OSError:
546
+ pass
547
+
548
+ return pdf_path
549
 
550
 
551
  def generate_report_files(image_rgb, binary_overlay, multiclass_overlay, result):
552
+ """Generate downloadable report files (PDF + JSON)."""
553
  tmpdir = tempfile.mkdtemp(prefix="woundnetb7_report_")
554
 
555
+ # PDF report
556
+ pdf_path = generate_pdf_report(image_rgb, binary_overlay, multiclass_overlay, result)
 
 
557
 
558
  # JSON report
559
  report_data = result.to_dict()
 
569
  with open(json_path, "w", encoding="utf-8") as f:
570
  json.dump(report_data, f, indent=2, ensure_ascii=False)
571
 
572
+ return [pdf_path, json_path]
573
 
574
 
575
  # ── Gradio callbacks ─────────────────────────────────────────────────────────
576
 
 
577
  _last_analysis = {}
578
 
579
 
 
590
  binary_overlay = pipe.visualize_binary(img_bgr, result)
591
  multiclass_overlay = pipe.visualize_multiclass(img_bgr, result)
592
 
 
593
  _last_analysis["image_rgb"] = image
594
  _last_analysis["binary"] = binary_overlay
595
  _last_analysis["multiclass"] = multiclass_overlay
 
603
  return binary_overlay, multiclass_overlay, seg_stats, fitz_html, pwat_html, json_out
604
 
605
 
606
+ def analyze_from_camera(image):
607
+ """Same analysis but from camera capture (routes to same pipeline)."""
608
+ return analyze_image(image)
609
+
610
+
611
  def download_report():
 
612
  if not _last_analysis:
613
  return None
614
  return generate_report_files(
 
624
  def build_fitz_html(fitz):
625
  if fitz is None or fitz.confidence == 0:
626
  return "<p style='color:#6b7280;'>No se pudo estimar (insuficientes pixeles de piel sana).</p>"
 
627
  bg = FITZ_COLORS.get(fitz.fitzpatrick_type, "#e5e7eb")
628
  fg = FITZ_TEXT_COLORS.get(fitz.fitzpatrick_type, "#1f2937")
 
629
  return f"""
630
  <div style="display:flex; gap:16px; align-items:center; flex-wrap:wrap;">
631
  <div style="background:{bg}; color:{fg}; border-radius:12px; padding:18px 28px;
 
640
  <b>Pixeles sanos:</b> {fitz.healthy_pixels:,}<br>
641
  <b>Confianza:</b> {fitz.confidence:.0%}
642
  </div>
643
+ </div>"""
 
644
 
645
 
646
  def build_pwat_html(pwat):
647
  if pwat is None or not pwat.scores_raw:
648
  return "<p style='color:#6b7280;'>No se pudo estimar PWAT (ulcera no detectada o muy pequena).</p>"
 
649
  rows = ""
650
  for item in [3, 4, 5, 6, 7, 8]:
651
  name = ITEM_NAMES.get(item, f"Item {item}")
652
  raw = pwat.scores_raw.get(item, 0)
653
  adj = pwat.scores_adjusted.get(item, 0.0)
654
  diff = adj - raw
 
655
  diff_color = "#059669" if diff < -0.05 else "#6b7280"
656
  diff_str = f"{diff:+.1f}" if abs(diff) > 0.01 else "0.0"
 
657
  raw_pct = raw / 4 * 100
658
  adj_pct = adj / 4 * 100
 
659
  rows += f"""
660
  <tr>
661
  <td style="padding:8px 12px; font-weight:500;">{name}</td>
 
675
  <span style="font-weight:600; min-width:30px;">{adj:.1f}</span>
676
  </div>
677
  </td>
678
+ <td style="padding:8px 12px; text-align:center; color:{diff_color}; font-weight:600;">{diff_str}</td>
 
 
679
  </tr>"""
 
680
  total_diff = pwat.total_adjusted - pwat.total_raw
681
  total_color = "#059669" if total_diff < -0.05 else "#6b7280"
682
  total_diff_str = f"{total_diff:+.1f}" if abs(total_diff) > 0.01 else "0.0"
 
683
  return f"""
684
  <table style="width:100%; border-collapse:collapse; font-size:0.92em;">
685
  <thead>
 
690
  <th style="padding:10px 12px; text-align:center;">&Delta;</th>
691
  </tr>
692
  </thead>
693
+ <tbody>{rows}
 
694
  <tr style="border-top:2px solid #374151; font-weight:700; font-size:1.05em;">
695
  <td style="padding:10px 12px;">TOTAL</td>
696
  <td style="padding:10px 12px; text-align:center;">{pwat.total_raw}</td>
 
700
  </tbody>
701
  </table>
702
  <p style="font-size:0.82em; color:#6b7280; margin-top:8px;">
703
+ Escala: 0 (mejor) - 4 (peor) por item |
704
+ Correccion Fitzpatrick tipo {pwat.fitzpatrick_type} aplicada |
705
  Items: 3=Tipo necrotico, 4=Cantidad necrotica, 5=Tipo granulacion,
706
  6=Cantidad granulacion, 7=Bordes, 8=Piel periulceral
707
+ </p>"""
 
708
 
709
 
710
  def build_seg_stats_html(result):
711
  dist = result.class_distribution
712
  colors = {"background": "#374151", "foot": "#22c55e", "perilesion": "#f97316", "ulcer": "#ef4444"}
 
713
  bars = ""
714
  for cls_name in ["foot", "perilesion", "ulcer"]:
715
  pct = dist.get(cls_name, 0)
 
725
  <div style="background:{color}; height:100%; width:{pct}%; border-radius:4px;"></div>
726
  </div>
727
  </div>"""
 
728
  return f"""
729
  <div style="padding:4px 0;">
730
  <p style="font-size:0.85em; color:#6b7280; margin-bottom:10px;">
731
+ Imagen: {result.image_size[1]}x{result.image_size[0]} | Device: {result.device}
732
  </p>
733
  {bars}
734
+ </div>"""
 
735
 
736
 
737
  # ── Gradio UI ────────────────────────────────────────────────────────────────
738
 
739
  css = """
740
  .step-header {
741
+ display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
 
 
 
742
  }
743
  .step-number {
744
+ background: #1f2937; color: white; border-radius: 50%;
745
+ width: 30px; height: 30px; display: flex; align-items: center;
746
+ justify-content: center; font-weight: 700; font-size: 0.9em; flex-shrink: 0;
 
 
 
 
 
 
 
 
 
 
 
 
747
  }
748
+ .step-title { font-weight: 600; font-size: 1.1em; }
749
  """
750
 
751
  with gr.Blocks(
 
763
  </div>
764
  """)
765
 
766
+ with gr.Tabs():
767
+ # ══════════════════════════════════════════════════════════════════════
768
+ # TAB 1: Analisis (upload o galeria)
769
+ # ══════════════════════════════════════════════════════════════════════
770
+ with gr.Tab("Analisis DFU"):
771
+ with gr.Row():
772
+ with gr.Column(scale=1):
773
+ input_image = gr.Image(label="Imagen DFU", type="numpy",
774
+ sources=["upload", "clipboard"])
775
+ analyze_btn = gr.Button("Analizar", variant="primary", size="lg")
776
+ gr.HTML("""
777
+ <div style="font-size:0.82em; color:#6b7280; margin-top:8px; line-height:1.6;">
778
+ <b>Pipeline:</b> La imagen pasa por 4 etapas secuenciales.<br>
779
+ <b>Modelo:</b> WoundNetB7 con Combo Loss + Small Object Loss.
780
+ Atencion: CBAM, CoordAttention, TAM (fractal + Euler).<br>
781
+ <b>TTA:</b> 6 augmentaciones en inferencia.
782
+ </div>
783
+ """)
784
+
785
+ # Step 1
786
+ gr.HTML("""<div class="step-header"><div class="step-number">1</div>
787
+ <div class="step-title">Segmentacion Binaria de la Ulcera</div></div>""")
788
+ with gr.Row():
789
+ with gr.Column(scale=1):
790
+ output_binary = gr.Image(label="Mascara Binaria Ulcera (WoundNetB7)")
791
+ with gr.Column(scale=1):
792
+ output_seg_stats = gr.HTML(label="Estadisticas de Segmentacion")
793
+
794
+ # Step 2
795
+ gr.HTML("""<div class="step-header" style="margin-top:12px;"><div class="step-number">2</div>
796
+ <div class="step-title">Segmentacion Multiclase (4 clases)</div></div>""")
797
+ with gr.Row():
798
+ with gr.Column(scale=1):
799
+ output_multiclass = gr.Image(label="Overlay Multiclase")
800
+ with gr.Column(scale=1):
801
+ gr.HTML("""
802
+ <div style="padding:12px;">
803
+ <p style="font-weight:600; margin-bottom:10px;">Leyenda de clases:</p>
804
+ <div style="display:flex; flex-direction:column; gap:8px;">
805
+ <div style="display:flex; align-items:center; gap:8px;">
806
+ <div style="width:20px; height:20px; background:#22c55e; border-radius:4px;"></div>
807
+ <span><b>Pie</b> β€” tejido sano</span>
808
+ </div>
809
+ <div style="display:flex; align-items:center; gap:8px;">
810
+ <div style="width:20px; height:20px; background:#f97316; border-radius:4px;"></div>
811
+ <span><b>Perilesion</b> β€” zona periulceral</span>
812
+ </div>
813
+ <div style="display:flex; align-items:center; gap:8px;">
814
+ <div style="width:20px; height:20px; background:#ef4444; border-radius:4px;"></div>
815
+ <span><b>Ulcera</b> β€” lecho de la herida</span>
816
+ </div>
817
+ </div>
818
+ </div>""")
819
+
820
+ # Step 3
821
+ gr.HTML("""<div class="step-header" style="margin-top:12px;"><div class="step-number">3</div>
822
+ <div class="step-title">Estimacion Fitzpatrick / ITA</div></div>""")
823
+ output_fitz = gr.HTML()
824
+
825
+ # Step 4
826
+ gr.HTML("""<div class="step-header" style="margin-top:12px;"><div class="step-number">4</div>
827
+ <div class="step-title">PWAT β€” Scores Raw vs Ajustados por Fitzpatrick</div></div>""")
828
+ output_pwat = gr.HTML()
829
+
830
+ # Download
831
+ gr.HTML("""<div class="step-header" style="margin-top:16px;">
832
+ <div class="step-number" style="background:#059669;">&#8681;</div>
833
+ <div class="step-title">Descargar Informe Clinico</div></div>
834
+ <p style="font-size:0.88em; color:#6b7280; margin-bottom:8px;">
835
+ Genera un informe PDF con todas las visualizaciones y datos estructurados.
836
+ Primero analiza una imagen.</p>""")
837
+ download_btn = gr.Button("Descargar Informe PDF", variant="secondary", size="lg")
838
+ output_files = gr.File(label="Archivos del Informe (PDF + JSON)", file_count="multiple")
839
+
840
+ with gr.Accordion("JSON completo (para integracion)", open=False):
841
+ output_json = gr.Code(label="JSON Output", language="json")
842
+
843
+ analyze_btn.click(
844
+ fn=analyze_image,
845
+ inputs=[input_image],
846
+ outputs=[output_binary, output_multiclass, output_seg_stats,
847
+ output_fitz, output_pwat, output_json],
848
+ )
849
+ download_btn.click(fn=download_report, inputs=[], outputs=[output_files])
850
+
851
+ # ══════════════════════════════════════════════════════════════════════
852
+ # TAB 2: Captura Guiada (webcam con guia de pie)
853
+ # ══════════════════════════════════════════════════════════════════════
854
+ with gr.Tab("Captura Guiada"):
855
  gr.HTML("""
856
+ <div style="padding:16px 0;">
857
+ <h2 style="font-size:1.4em; margin:0 0 8px;">Captura Guiada para Personal Sanitario</h2>
858
+ <p style="color:#6b7280; line-height:1.6;">
859
+ Use la camara del dispositivo para capturar una imagen del pie diabetico.
860
+ La silueta verde guia la posicion correcta del pie para un analisis optimo.
861
+ </p>
 
862
  </div>
863
  """)
864
 
865
+ with gr.Row():
866
+ with gr.Column(scale=3):
867
+ camera_input = gr.Image(
868
+ label="Camara β€” Posicione el pie dentro de la guia",
869
+ type="numpy",
870
+ sources=["webcam"],
871
+ )
872
+ camera_analyze_btn = gr.Button("Capturar y Analizar", variant="primary", size="lg")
873
+
874
+ with gr.Column(scale=2):
875
+ guide_image = gr.Image(
876
+ label="Guia de Posicionamiento",
877
+ value=generate_static_guide(),
878
+ interactive=False,
879
+ )
880
 
 
 
 
 
 
 
 
 
 
 
 
881
  gr.HTML("""
882
+ <div style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:10px;
883
+ padding:16px; margin:12px 0;">
884
+ <p style="font-weight:700; color:#166534; margin:0 0 8px;">
885
+ Instrucciones para el personal sanitario:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
  </p>
887
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px 24px; font-size:0.92em; color:#15803d;">
888
+ <div>1. Planta del pie mirando a la camara</div>
889
+ <div>2. Distancia: 30-40 cm del lente</div>
890
+ <div>3. Iluminacion uniforme, sin sombras</div>
891
+ <div>4. Fondo neutro (sabana blanca/azul)</div>
892
+ <div>5. Incluir toda la ulcera + piel sana periferica</div>
893
+ <div>6. Evitar flash directo (causa reflejos)</div>
894
+ <div>7. Mantener el dispositivo estable</div>
895
+ <div>8. Limpiar el lente antes de capturar</div>
896
+ </div>
897
  </div>
898
  """)
899
 
900
+ # Results from camera capture
901
+ gr.HTML("""<div class="step-header" style="margin-top:16px;">
902
+ <div class="step-number">1</div>
903
+ <div class="step-title">Resultado de Segmentacion</div></div>""")
904
+ with gr.Row():
905
+ cam_binary = gr.Image(label="Mascara Binaria Ulcera")
906
+ cam_multiclass = gr.Image(label="Overlay Multiclase")
 
907
 
908
+ with gr.Row():
909
+ cam_seg_stats = gr.HTML()
 
 
 
 
 
 
910
 
911
+ gr.HTML("""<div class="step-header"><div class="step-number">2</div>
912
+ <div class="step-title">Fitzpatrick + PWAT</div></div>""")
913
+ cam_fitz = gr.HTML()
914
+ cam_pwat = gr.HTML()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
 
916
+ cam_download_btn = gr.Button("Descargar Informe PDF", variant="secondary", size="lg")
917
+ cam_files = gr.File(label="Archivos del Informe", file_count="multiple")
918
+
919
+ with gr.Accordion("JSON completo", open=False):
920
+ cam_json = gr.Code(label="JSON Output", language="json")
921
+
922
+ camera_analyze_btn.click(
923
+ fn=analyze_from_camera,
924
+ inputs=[camera_input],
925
+ outputs=[cam_binary, cam_multiclass, cam_seg_stats,
926
+ cam_fitz, cam_pwat, cam_json],
927
+ )
928
+ cam_download_btn.click(fn=download_report, inputs=[], outputs=[cam_files])
929
 
930
  gr.HTML("""
931
+ <div style="text-align:center; padding:16px 0; font-size:0.82em; color:#9ca3af;
932
+ border-top:1px solid #e5e7eb; margin-top:20px;">
933
  WoundNetB7 &bull; Tesis Doctoral &bull; Marcelo Marquez-Murillo &bull;
934
  Ulcer Dice 0.927 (CI 95%: [0.917, 0.936]) &bull;
935
  Debiasing: 46.6% max group gap reduction (p &lt; 10<sup>-55</sup>)