mmarquezsa commited on
Commit
e08048e
Β·
verified Β·
1 Parent(s): 1b5f5d8

feat: add downloadable clinical report (PNG 300 DPI + JSON)

Browse files
Files changed (1) hide show
  1. app.py +346 -38
app.py CHANGED
@@ -5,6 +5,7 @@ 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
 
9
  Launch locally: python app.py
10
  Deploy to HF: push this repo to a Hugging Face Space (GPU recommended).
@@ -13,7 +14,12 @@ import gradio as gr
13
  import numpy as np
14
  import cv2
15
  import json
 
 
 
 
16
  from pipeline import WoundNetB7Pipeline
 
17
 
18
  pipe = WoundNetB7Pipeline(models_dir="models", use_tta=True)
19
 
@@ -27,9 +33,327 @@ FITZ_TEXT_COLORS = {
27
  "IV": "#ffffff", "V": "#ffffff", "VI": "#ffffff",
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def build_fitz_html(fitz):
32
- """Build Fitzpatrick/ITA HTML card."""
33
  if fitz is None or fitz.confidence == 0:
34
  return "<p style='color:#6b7280;'>No se pudo estimar (insuficientes pixeles de piel sana).</p>"
35
 
@@ -55,12 +379,9 @@ def build_fitz_html(fitz):
55
 
56
 
57
  def build_pwat_html(pwat):
58
- """Build PWAT scores comparison table (raw vs adjusted)."""
59
  if pwat is None or not pwat.scores_raw:
60
  return "<p style='color:#6b7280;'>No se pudo estimar PWAT (ulcera no detectada o muy pequena).</p>"
61
 
62
- from src.pwat_estimator import ITEM_NAMES
63
-
64
  rows = ""
65
  for item in [3, 4, 5, 6, 7, 8]:
66
  name = ITEM_NAMES.get(item, f"Item {item}")
@@ -68,11 +389,9 @@ def build_pwat_html(pwat):
68
  adj = pwat.scores_adjusted.get(item, 0.0)
69
  diff = adj - raw
70
 
71
- # Color code: green if adjusted lower (debiased), neutral otherwise
72
  diff_color = "#059669" if diff < -0.05 else "#6b7280"
73
  diff_str = f"{diff:+.1f}" if abs(diff) > 0.01 else "0.0"
74
 
75
- # Bar visualization (0-4 scale)
76
  raw_pct = raw / 4 * 100
77
  adj_pct = adj / 4 * 100
78
 
@@ -134,7 +453,6 @@ def build_pwat_html(pwat):
134
 
135
 
136
  def build_seg_stats_html(result):
137
- """Build segmentation statistics HTML."""
138
  dist = result.class_distribution
139
  colors = {"background": "#374151", "foot": "#22c55e", "perilesion": "#f97316", "ulcer": "#ef4444"}
140
 
@@ -164,40 +482,9 @@ def build_seg_stats_html(result):
164
  """
165
 
166
 
167
- def analyze_image(image):
168
- """Main analysis function called by Gradio."""
169
- if image is None:
170
- empty = np.zeros((100, 100, 3), dtype=np.uint8)
171
- return empty, empty, "", "", "", "{}"
172
-
173
- img_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
174
-
175
- result = pipe.analyze(img_bgr, use_tta=True)
176
-
177
- # Visualizations
178
- binary_overlay = pipe.visualize_binary(img_bgr, result)
179
- multiclass_overlay = pipe.visualize_multiclass(img_bgr, result)
180
-
181
- # HTML outputs
182
- seg_stats = build_seg_stats_html(result)
183
- fitz_html = build_fitz_html(result.fitzpatrick)
184
- pwat_html = build_pwat_html(result.pwat)
185
-
186
- # JSON
187
- json_out = json.dumps(result.to_dict(), indent=2, ensure_ascii=False)
188
-
189
- return binary_overlay, multiclass_overlay, seg_stats, fitz_html, pwat_html, json_out
190
-
191
-
192
  # ── Gradio UI ────────────────────────────────────────────────────────────────
193
 
194
  css = """
195
- .pipeline-step {
196
- border: 1px solid #e5e7eb;
197
- border-radius: 12px;
198
- padding: 16px;
199
- margin-bottom: 8px;
200
- }
201
  .step-header {
202
  display: flex;
203
  align-items: center;
@@ -321,10 +608,25 @@ with gr.Blocks(
321
  """)
322
  output_pwat = gr.HTML()
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  # ── JSON (collapsible) ──
325
  with gr.Accordion("JSON completo (para integracion)", open=False):
326
  output_json = gr.Code(label="JSON Output", language="json")
327
 
 
328
  analyze_btn.click(
329
  fn=analyze_image,
330
  inputs=[input_image],
@@ -338,6 +640,12 @@ with gr.Blocks(
338
  ],
339
  )
340
 
 
 
 
 
 
 
341
  gr.HTML("""
342
  <div style="text-align:center; padding:16px 0; font-size:0.82em; color:#9ca3af; border-top:1px solid #e5e7eb; margin-top:20px;">
343
  WoundNetB7 &bull; Tesis Doctoral &bull; Marcelo Marquez-Murillo &bull;
 
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).
 
14
  import numpy as np
15
  import cv2
16
  import json
17
+ 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
 
24
  pipe = WoundNetB7Pipeline(models_dir="models", use_tta=True)
25
 
 
33
  "IV": "#ffffff", "V": "#ffffff", "VI": "#ffffff",
34
  }
35
 
36
+ FITZ_RGB = {
37
+ "I": (254, 243, 199), "II": (253, 230, 138), "III": (251, 191, 36),
38
+ "IV": (180, 83, 9), "V": (120, 53, 15), "VI": (69, 26, 3),
39
+ }
40
+
41
+ FITZ_TEXT_RGB = {
42
+ "I": (31, 41, 55), "II": (31, 41, 55), "III": (31, 41, 55),
43
+ "IV": (255, 255, 255), "V": (255, 255, 255), "VI": (255, 255, 255),
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)
54
+ except (OSError, IOError):
55
+ continue
56
+ return ImageFont.load_default()
57
+
58
+
59
+ def _get_font_regular(size):
60
+ for name in ["DejaVuSans.ttf", "arial.ttf", "LiberationSans-Regular.ttf"]:
61
+ try:
62
+ return ImageFont.truetype(name, size)
63
+ except (OSError, IOError):
64
+ continue
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()
294
+ report_data["report_metadata"] = {
295
+ "generated_at": datetime.now().isoformat(),
296
+ "model": "WoundNetB7 (EfficientNet-B7 + ASPP + CBAM + CoordAttention + TAM)",
297
+ "ulcer_dice": 0.927,
298
+ "dice_ci_95": [0.917, 0.936],
299
+ "tta_folds": 6,
300
+ "debiasing": "Fitzpatrick-calibrated ITA (86.9% accuracy, r=0.975)",
301
+ }
302
+ json_path = os.path.join(tmpdir, "WoundNetB7_Informe_DFU.json")
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
+
315
+ def analyze_image(image):
316
+ """Main analysis function called by Gradio."""
317
+ if image is None:
318
+ empty = np.zeros((100, 100, 3), dtype=np.uint8)
319
+ _last_analysis.clear()
320
+ return empty, empty, "", "", "", "{}"
321
+
322
+ img_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
323
+ result = pipe.analyze(img_bgr, use_tta=True)
324
+
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
332
+ _last_analysis["result"] = result
333
+
334
+ seg_stats = build_seg_stats_html(result)
335
+ fitz_html = build_fitz_html(result.fitzpatrick)
336
+ pwat_html = build_pwat_html(result.pwat)
337
+ json_out = json.dumps(result.to_dict(), indent=2, ensure_ascii=False)
338
+
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(
347
+ _last_analysis["image_rgb"],
348
+ _last_analysis["binary"],
349
+ _last_analysis["multiclass"],
350
+ _last_analysis["result"],
351
+ )
352
+
353
+
354
+ # ── HTML builders ─────────────────────────────────────────────────────────────
355
 
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
 
 
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}")
 
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
 
 
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
 
 
482
  """
483
 
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  # ── Gradio UI ────────────────────────────────────────────────────────────────
486
 
487
  css = """
 
 
 
 
 
 
488
  .step-header {
489
  display: flex;
490
  align-items: center;
 
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],
 
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;