kaveh commited on
Commit
79c3a5f
·
1 Parent(s): 699f6c2

improved report and region measurement

Browse files
Files changed (3) hide show
  1. app.py +2 -2
  2. ui/components.py +108 -17
  3. utils/report.py +126 -21
app.py CHANGED
@@ -206,7 +206,7 @@ with st.sidebar:
206
  st.markdown('<p style="font-size: 0.95rem; font-weight: 500; margin-bottom: 0.5rem;">Conditions</p>', unsafe_allow_html=True)
207
  conditions_source = st.radio(
208
  "Conditions",
209
- ["Manually", "From config"],
210
  horizontal=True,
211
  label_visibility="collapsed",
212
  )
@@ -245,7 +245,7 @@ with st.sidebar:
245
 
246
  auto_cell_boundary = st.checkbox(
247
  "Auto boundary",
248
- value=True,
249
  help="When on: estimate cell region from force map and use it for metrics (red contour). When off: use entire map.",
250
  )
251
 
 
206
  st.markdown('<p style="font-size: 0.95rem; font-weight: 500; margin-bottom: 0.5rem;">Conditions</p>', unsafe_allow_html=True)
207
  conditions_source = st.radio(
208
  "Conditions",
209
+ ["From config", "Manually"],
210
  horizontal=True,
211
  label_visibility="collapsed",
212
  )
 
245
 
246
  auto_cell_boundary = st.checkbox(
247
  "Auto boundary",
248
+ value=False,
249
  help="When on: estimate cell region from force map and use it for metrics (red contour). When off: use entire map.",
250
  )
251
 
ui/components.py CHANGED
@@ -1,5 +1,6 @@
1
  """UI components for S2F App."""
2
  import csv
 
3
  import io
4
  import os
5
 
@@ -17,7 +18,13 @@ from config.constants import (
17
  TOOL_LABELS,
18
  )
19
  from utils.display import apply_display_scale, cv_colormap_to_plotly_colorscale
20
- from utils.report import heatmap_to_rgb, heatmap_to_rgb_with_contour, heatmap_to_png_bytes, create_pdf_report
 
 
 
 
 
 
21
  from utils.segmentation import estimate_cell_mask
22
 
23
  try:
@@ -30,6 +37,19 @@ except (ImportError, AttributeError):
30
  ST_DIALOG = getattr(st, "dialog", None) or getattr(st, "experimental_dialog", None)
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def make_annotated_heatmap(heatmap_rgb, mask, fill_alpha=0.3, stroke_color=(255, 102, 0), stroke_width=2):
34
  """Composite heatmap with drawn region overlay."""
35
  annotated = heatmap_rgb.copy()
@@ -45,6 +65,40 @@ def make_annotated_heatmap(heatmap_rgb, mask, fill_alpha=0.3, stroke_color=(255,
45
  return annotated
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def _obj_to_pts(obj, scale_x, scale_y, heatmap_w, heatmap_h):
49
  """Convert a single canvas object to polygon points in heatmap coords. Returns None if invalid."""
50
  obj_type = obj.get("type", "")
@@ -169,9 +223,13 @@ def compute_region_metrics(raw_heatmap, mask, original_vals=None):
169
  }
170
 
171
 
172
- def render_region_metrics_and_downloads(metrics_list, heatmap_rgb, combined_mask, input_filename, key_suffix, has_original_vals,
173
- first_region_label=None):
174
- """Render per-shape metrics table and download buttons. first_region_label: custom label for first row (e.g. 'Auto boundary')."""
 
 
 
 
175
  base_name = os.path.splitext(input_filename or "image")[0]
176
  st.markdown("**Regions (each selection = one row)**")
177
  if has_original_vals:
@@ -193,21 +251,56 @@ def render_region_metrics_and_downloads(metrics_list, heatmap_rgb, combined_mask
193
  csv_rows.append([base_name, region_label, metrics["area_px"], f"{metrics['force_sum']:.4f}",
194
  f"{metrics['mean']:.6f}"])
195
  table_rows.append(row)
196
- st.table(table_rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  buf_csv = io.StringIO()
198
  csv.writer(buf_csv).writerows(csv_rows)
 
 
 
 
199
  buf_img = io.BytesIO()
200
- Image.fromarray(make_annotated_heatmap(heatmap_rgb, combined_mask)).save(buf_img, format="PNG")
201
  buf_img.seek(0)
202
- dl_col1, dl_col2 = st.columns(2)
203
- with dl_col1:
 
 
 
 
 
204
  st.download_button("Download all regions", data=buf_csv.getvalue(),
205
  file_name=f"{base_name}_all_regions.csv", mime="text/csv",
206
  key=f"download_all_regions_{key_suffix}", icon=":material/download:")
207
- with dl_col2:
208
- st.download_button("Download annotated heatmap", data=buf_img.getvalue(),
209
  file_name=f"{base_name}_annotated_heatmap.png", mime="image/png",
210
  key=f"download_annotated_{key_suffix}", icon=":material/image:")
 
 
 
 
 
211
 
212
 
213
  def _draw_contour_on_image(img_rgb, mask, stroke_color=(255, 0, 0), stroke_width=2):
@@ -298,12 +391,10 @@ def render_region_canvas(display_heatmap, raw_heatmap=None, bf_img=None, origina
298
  if cell_mask is not None and np.any(cell_mask > 0):
299
  cell_metrics = compute_region_metrics(raw_heatmap, cell_mask, original_vals)
300
  metrics_list = [cell_metrics] + metrics_list
301
- combined_mask = masks[0].copy()
302
- for m in masks[1:]:
303
- combined_mask = np.maximum(combined_mask, m)
304
  render_region_metrics_and_downloads(
305
- metrics_list, heatmap_rgb, combined_mask, input_filename, key_suffix, original_vals is not None,
306
  first_region_label="Auto boundary" if (cell_mask is not None and np.any(cell_mask > 0)) else None,
 
307
  )
308
 
309
 
@@ -355,7 +446,7 @@ def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, k
355
  base_name = os.path.splitext(key_img or "image")[0]
356
  if use_cell_metrics:
357
  main_csv_rows = [
358
- ["image", "Cell sum", "Cell force (scaled)", "Heatmap max", "Cell mean"],
359
  [base_name, f"{cell_pixel_sum:.2f}", f"{cell_force:.2f}",
360
  f"{np.max(raw_heatmap):.4f}", f"{cell_mean:.4f}"],
361
  ]
@@ -401,7 +492,7 @@ def render_result_display(img, raw_heatmap, display_heatmap, pixel_sum, force, k
401
  with col3:
402
  st.metric("Heatmap max", f"{np.max(raw_heatmap):.4f}", help="Peak force intensity in the map")
403
  with col4:
404
- st.metric("Cell mean", f"{cell_mean:.4f}", help="Mean force over estimated cell area")
405
  else:
406
  with col1:
407
  st.metric("Sum of all pixels", f"{pixel_sum:.2f}", help="Raw sum of all pixel values in the force map")
@@ -428,7 +519,7 @@ This is the raw image you provided—it shows cell shape but not forces.
428
  - **Cell sum:** Sum over estimated cell area (background excluded)
429
  - **Cell force (scaled):** Total traction force in physical units
430
  - **Heatmap max:** Peak force intensity in the map
431
- - **Cell mean:** Mean force over the estimated cell area
432
  """)
433
  else:
434
  st.markdown("""
 
1
  """UI components for S2F App."""
2
  import csv
3
+ import html
4
  import io
5
  import os
6
 
 
18
  TOOL_LABELS,
19
  )
20
  from utils.display import apply_display_scale, cv_colormap_to_plotly_colorscale
21
+ from utils.report import (
22
+ heatmap_to_rgb,
23
+ heatmap_to_rgb_with_contour,
24
+ heatmap_to_png_bytes,
25
+ create_pdf_report,
26
+ create_measure_pdf_report,
27
+ )
28
  from utils.segmentation import estimate_cell_mask
29
 
30
  try:
 
37
  ST_DIALOG = getattr(st, "dialog", None) or getattr(st, "experimental_dialog", None)
38
 
39
 
40
+ # Distinct colors for each region (RGB - heatmap_rgb is RGB)
41
+ _REGION_COLORS = [
42
+ (255, 102, 0), # orange
43
+ (255, 165, 0), # orange-red
44
+ (255, 255, 0), # yellow
45
+ (255, 0, 255), # magenta
46
+ (0, 255, 127), # spring green
47
+ (0, 128, 255), # blue
48
+ (203, 192, 255), # lavender
49
+ (255, 215, 0), # gold
50
+ ]
51
+
52
+
53
  def make_annotated_heatmap(heatmap_rgb, mask, fill_alpha=0.3, stroke_color=(255, 102, 0), stroke_width=2):
54
  """Composite heatmap with drawn region overlay."""
55
  annotated = heatmap_rgb.copy()
 
65
  return annotated
66
 
67
 
68
+ def make_annotated_heatmap_multi_regions(heatmap_rgb, masks, labels, cell_mask=None, fill_alpha=0.3):
69
+ """Draw each region separately with distinct color and label (R1, R2, ...). No merging."""
70
+ annotated = heatmap_rgb.copy()
71
+ if cell_mask is not None and np.any(cell_mask > 0):
72
+ contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
73
+ cv2.drawContours(annotated, contours, -1, (255, 0, 0), 2)
74
+ for i, mask in enumerate(masks):
75
+ color = _REGION_COLORS[i % len(_REGION_COLORS)]
76
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
77
+ overlay = annotated.copy()
78
+ cv2.fillPoly(overlay, contours, color)
79
+ mask_3d = np.stack([mask] * 3, axis=-1).astype(bool)
80
+ annotated[mask_3d] = (
81
+ (1 - fill_alpha) * annotated[mask_3d].astype(np.float32)
82
+ + fill_alpha * overlay[mask_3d].astype(np.float32)
83
+ ).astype(np.uint8)
84
+ cv2.drawContours(annotated, contours, -1, color, 2)
85
+ # Label at centroid
86
+ M = cv2.moments(mask)
87
+ if M["m00"] > 0:
88
+ cx = int(M["m10"] / M["m00"])
89
+ cy = int(M["m01"] / M["m00"])
90
+ label = labels[i] if i < len(labels) else f"R{i + 1}"
91
+ cv2.putText(
92
+ annotated, label, (cx - 12, cy + 5),
93
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA
94
+ )
95
+ cv2.putText(
96
+ annotated, label, (cx - 12, cy + 5),
97
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 1, cv2.LINE_AA
98
+ )
99
+ return annotated
100
+
101
+
102
  def _obj_to_pts(obj, scale_x, scale_y, heatmap_w, heatmap_h):
103
  """Convert a single canvas object to polygon points in heatmap coords. Returns None if invalid."""
104
  obj_type = obj.get("type", "")
 
223
  }
224
 
225
 
226
+ def render_region_metrics_and_downloads(metrics_list, masks, heatmap_rgb, input_filename, key_suffix, has_original_vals,
227
+ first_region_label=None, bf_img=None, cell_mask=None, colormap_name="Jet"):
228
+ """
229
+ Render per-shape metrics table and download buttons.
230
+ first_region_label: custom label for first row (e.g. 'Auto boundary').
231
+ masks: list of region masks (user-drawn only; used for labeled heatmap with R1, R2...).
232
+ """
233
  base_name = os.path.splitext(input_filename or "image")[0]
234
  st.markdown("**Regions (each selection = one row)**")
235
  if has_original_vals:
 
251
  csv_rows.append([base_name, region_label, metrics["area_px"], f"{metrics['force_sum']:.4f}",
252
  f"{metrics['mean']:.6f}"])
253
  table_rows.append(row)
254
+ # Render as HTML table to avoid Streamlit's default row/column indices
255
+ header = table_rows[0]
256
+ body = table_rows[1:]
257
+ th_cells = "".join(
258
+ f'<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">{html.escape(str(h))}</th>'
259
+ for h in header
260
+ )
261
+ rows_html = [
262
+ "<tr>"
263
+ + "".join(
264
+ f'<td style="border: 1px solid #ddd; padding: 8px;">{html.escape(str(c))}</td>'
265
+ for c in row
266
+ )
267
+ + "</tr>"
268
+ for row in body
269
+ ]
270
+ table_html = (
271
+ f'<table style="border-collapse: collapse; width: 100%;">'
272
+ f"<thead><tr>{th_cells}</tr></thead>"
273
+ f"<tbody>{''.join(rows_html)}</tbody></table>"
274
+ )
275
+ st.markdown(table_html, unsafe_allow_html=True)
276
  buf_csv = io.StringIO()
277
  csv.writer(buf_csv).writerows(csv_rows)
278
+ # Annotated heatmap: each region separate with R1, R2 labels (no merging)
279
+ # heatmap_rgb already has cell contour if applicable
280
+ region_labels = [f"R{i + 1}" for i in range(len(masks))]
281
+ heatmap_labeled = make_annotated_heatmap_multi_regions(heatmap_rgb.copy(), masks, region_labels, cell_mask=None)
282
  buf_img = io.BytesIO()
283
+ Image.fromarray(heatmap_labeled).save(buf_img, format="PNG")
284
  buf_img.seek(0)
285
+ # PDF report (requires bf_img)
286
+ pdf_bytes = None
287
+ if bf_img is not None:
288
+ pdf_bytes = create_measure_pdf_report(bf_img, heatmap_labeled, table_rows, base_name)
289
+ n_cols = 3 if pdf_bytes is not None else 2
290
+ dl_cols = st.columns(n_cols)
291
+ with dl_cols[0]:
292
  st.download_button("Download all regions", data=buf_csv.getvalue(),
293
  file_name=f"{base_name}_all_regions.csv", mime="text/csv",
294
  key=f"download_all_regions_{key_suffix}", icon=":material/download:")
295
+ with dl_cols[1]:
296
+ st.download_button("Download heatmap", data=buf_img.getvalue(),
297
  file_name=f"{base_name}_annotated_heatmap.png", mime="image/png",
298
  key=f"download_annotated_{key_suffix}", icon=":material/image:")
299
+ if pdf_bytes is not None:
300
+ with dl_cols[2]:
301
+ st.download_button("Download report", data=pdf_bytes,
302
+ file_name=f"{base_name}_measure_report.pdf", mime="application/pdf",
303
+ key=f"download_measure_pdf_{key_suffix}", icon=":material/picture_as_pdf:")
304
 
305
 
306
  def _draw_contour_on_image(img_rgb, mask, stroke_color=(255, 0, 0), stroke_width=2):
 
391
  if cell_mask is not None and np.any(cell_mask > 0):
392
  cell_metrics = compute_region_metrics(raw_heatmap, cell_mask, original_vals)
393
  metrics_list = [cell_metrics] + metrics_list
 
 
 
394
  render_region_metrics_and_downloads(
395
+ metrics_list, masks, heatmap_rgb, input_filename, key_suffix, original_vals is not None,
396
  first_region_label="Auto boundary" if (cell_mask is not None and np.any(cell_mask > 0)) else None,
397
+ bf_img=bf_img, cell_mask=cell_mask, colormap_name=colormap_name,
398
  )
399
 
400
 
 
446
  base_name = os.path.splitext(key_img or "image")[0]
447
  if use_cell_metrics:
448
  main_csv_rows = [
449
+ ["image", "Cell sum", "Cell force (scaled)", "Heatmap max", "Heatmap mean"],
450
  [base_name, f"{cell_pixel_sum:.2f}", f"{cell_force:.2f}",
451
  f"{np.max(raw_heatmap):.4f}", f"{cell_mean:.4f}"],
452
  ]
 
492
  with col3:
493
  st.metric("Heatmap max", f"{np.max(raw_heatmap):.4f}", help="Peak force intensity in the map")
494
  with col4:
495
+ st.metric("Heatmap mean", f"{cell_mean:.4f}", help="Mean force over estimated cell area")
496
  else:
497
  with col1:
498
  st.metric("Sum of all pixels", f"{pixel_sum:.2f}", help="Raw sum of all pixel values in the force map")
 
519
  - **Cell sum:** Sum over estimated cell area (background excluded)
520
  - **Cell force (scaled):** Total traction force in physical units
521
  - **Heatmap max:** Peak force intensity in the map
522
+ - **Heatmap mean:** Mean force over the estimated cell area
523
  """)
524
  else:
525
  st.markdown("""
utils/report.py CHANGED
@@ -47,50 +47,66 @@ def create_pdf_report(img, display_heatmap, raw_heatmap, pixel_sum, force, base_
47
  c = canvas.Canvas(buf, pagesize=A4)
48
  c.setTitle("Shape2Force")
49
  c.setAuthor("Angione-Lab")
50
- h = A4[1]
51
- img_w, img_h = 2.5 * inch, 2.5 * inch
 
 
 
 
 
 
 
 
 
52
 
53
  footer_y = 40
54
  c.setFont("Helvetica", 8)
55
  c.setFillColorRGB(0.4, 0.4, 0.4)
56
  gen_date = datetime.now().strftime("%Y-%m-%d %H:%M")
57
- c.drawString(72, footer_y, f"Generated by Shape2Force (S2F) on {gen_date}")
58
- c.drawString(72, footer_y - 12, "Model: https://huggingface.co/Angione-Lab/Shape2Force")
59
- c.drawString(72, footer_y - 24, "Web app: https://huggingface.co/spaces/Angione-Lab/Shape2force")
60
  c.setFillColorRGB(0, 0, 0)
61
 
62
- img_top = h - 70
 
 
 
 
 
 
 
63
  img_pil = Image.fromarray(img) if img.ndim == 2 else Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
64
  img_buf = io.BytesIO()
65
  img_pil.save(img_buf, format="PNG")
66
  img_buf.seek(0)
67
- c.drawImage(ImageReader(img_buf), 72, img_top - img_h, width=img_w, height=img_h, preserveAspectRatio=True)
68
  c.setFont("Helvetica", 9)
69
- c.drawString(72, img_top - img_h - 12, "Input: Bright-field")
 
70
 
71
  heatmap_rgb = heatmap_to_rgb_with_contour(display_heatmap, colormap_name, cell_mask)
72
  hm_buf = io.BytesIO()
73
  Image.fromarray(heatmap_rgb).save(hm_buf, format="PNG")
74
  hm_buf.seek(0)
75
- c.drawImage(ImageReader(hm_buf), 72 + img_w + 20, img_top - img_h, width=img_w, height=img_h, preserveAspectRatio=True)
76
- c.drawString(72 + img_w + 20, img_top - img_h - 12, "Output: Force map (red = estimated cell)")
77
-
78
- c.setFont("Helvetica-Bold", 16)
79
- c.drawString(72, img_top + 25, "Shape2Force (S2F) - Prediction Report")
80
- c.setFont("Helvetica", 10)
81
- c.drawString(72, img_top + 8, f"Image: {base_name}")
82
-
83
- y = img_top - img_h - 45
84
  c.setFont("Helvetica-Bold", 10)
85
- c.drawString(72, y, "Metrics")
86
  c.setFont("Helvetica", 9)
87
  y -= 18
88
  if cell_pixel_sum is not None and cell_force is not None and cell_mean is not None:
89
  metrics = [
90
- ("Cell sum (estimated cell area)", f"{cell_pixel_sum:.2f}"),
91
  ("Cell force (scaled)", f"{cell_force:.2f}"),
92
  ("Heatmap max", f"{np.max(raw_heatmap):.4f}"),
93
- ("Cell mean (estimated cell area)", f"{cell_mean:.4f}"),
94
  ]
95
  else:
96
  metrics = [
@@ -100,9 +116,98 @@ def create_pdf_report(img, display_heatmap, raw_heatmap, pixel_sum, force, base_
100
  ("Heatmap mean", f"{np.mean(raw_heatmap):.4f}"),
101
  ]
102
  for label, val in metrics:
103
- c.drawString(72, y, f"{label}: {val}")
104
  y -= 16
105
 
106
  c.save()
107
  buf.seek(0)
108
  return buf.getvalue()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  c = canvas.Canvas(buf, pagesize=A4)
48
  c.setTitle("Shape2Force")
49
  c.setAuthor("Angione-Lab")
50
+ page_w_pt = A4[0]
51
+ page_h_pt = A4[1]
52
+ margin = 72
53
+ img_w, img_h = 2.8 * inch, 2.8 * inch
54
+ img_gap = 20
55
+
56
+ # Center images
57
+ total_img_width = 2 * img_w + img_gap
58
+ img_left = margin + (page_w_pt - 2 * margin - total_img_width) / 2
59
+ bf_x = img_left
60
+ hm_x = img_left + img_w + img_gap
61
 
62
  footer_y = 40
63
  c.setFont("Helvetica", 8)
64
  c.setFillColorRGB(0.4, 0.4, 0.4)
65
  gen_date = datetime.now().strftime("%Y-%m-%d %H:%M")
66
+ c.drawString(margin, footer_y, f"Generated by Shape2Force (S2F) on {gen_date}")
67
+ c.drawString(margin, footer_y - 12, "Model: https://huggingface.co/Angione-Lab/Shape2Force")
68
+ c.drawString(margin, footer_y - 24, "Web app: https://huggingface.co/spaces/Angione-Lab/Shape2force")
69
  c.setFillColorRGB(0, 0, 0)
70
 
71
+ y_top = page_h_pt - 50
72
+ c.setFont("Helvetica-Bold", 16)
73
+ c.drawString(margin, y_top, "Shape2Force (S2F) - Prediction Report")
74
+ c.setFont("Helvetica", 10)
75
+ c.drawString(margin, y_top - 14, f"Image: {base_name}")
76
+ y_top -= 35
77
+
78
+ img_bottom = y_top - img_h
79
  img_pil = Image.fromarray(img) if img.ndim == 2 else Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
80
  img_buf = io.BytesIO()
81
  img_pil.save(img_buf, format="PNG")
82
  img_buf.seek(0)
83
+ c.drawImage(ImageReader(img_buf), bf_x, img_bottom, width=img_w, height=img_h, preserveAspectRatio=True)
84
  c.setFont("Helvetica", 9)
85
+ bf_label_w = c.stringWidth("Bright-field", "Helvetica", 9)
86
+ c.drawString(bf_x + (img_w - bf_label_w) / 2, img_bottom - 14, "Bright-field")
87
 
88
  heatmap_rgb = heatmap_to_rgb_with_contour(display_heatmap, colormap_name, cell_mask)
89
  hm_buf = io.BytesIO()
90
  Image.fromarray(heatmap_rgb).save(hm_buf, format="PNG")
91
  hm_buf.seek(0)
92
+ c.drawImage(ImageReader(hm_buf), hm_x, img_bottom, width=img_w, height=img_h, preserveAspectRatio=True)
93
+ hm_label = "Force map (red = estimated boundary)" if cell_mask is not None and np.any(cell_mask > 0) else "Force map"
94
+ hm_label_w = c.stringWidth(hm_label, "Helvetica", 9)
95
+ c.drawString(hm_x + (img_w - hm_label_w) / 2, img_bottom - 14, hm_label)
96
+
97
+ # Metrics section with spacing
98
+ row_height = 14
99
+ y = img_bottom - 14 - row_height
 
100
  c.setFont("Helvetica-Bold", 10)
101
+ c.drawString(margin, y, "Metrics")
102
  c.setFont("Helvetica", 9)
103
  y -= 18
104
  if cell_pixel_sum is not None and cell_force is not None and cell_mean is not None:
105
  metrics = [
106
+ ("Cell sum (estimated boundary)", f"{cell_pixel_sum:.2f}"),
107
  ("Cell force (scaled)", f"{cell_force:.2f}"),
108
  ("Heatmap max", f"{np.max(raw_heatmap):.4f}"),
109
+ ("Heatmap mean (estimated boundary)", f"{cell_mean:.4f}"),
110
  ]
111
  else:
112
  metrics = [
 
116
  ("Heatmap mean", f"{np.mean(raw_heatmap):.4f}"),
117
  ]
118
  for label, val in metrics:
119
+ c.drawString(margin, y, f"{label}: {val}")
120
  y -= 16
121
 
122
  c.save()
123
  buf.seek(0)
124
  return buf.getvalue()
125
+
126
+
127
+ def create_measure_pdf_report(bf_img, heatmap_labeled_rgb, table_rows, base_name):
128
+ """
129
+ Create PDF report for measure tool.
130
+ Contents: bright-field, heatmap with region labels (R1, R2...), table.
131
+ """
132
+ buf = io.BytesIO()
133
+ c = canvas.Canvas(buf, pagesize=A4)
134
+ c.setTitle("Shape2Force - Region Measurement")
135
+ c.setAuthor("Angione-Lab")
136
+ page_w_pt = A4[0]
137
+ page_h_pt = A4[1]
138
+ margin = 72
139
+ img_w = 2.8 * inch
140
+ img_h = 2.8 * inch
141
+ img_gap = 20
142
+
143
+ # Center images: two images side by side
144
+ total_img_width = 2 * img_w + img_gap
145
+ img_left = margin + (page_w_pt - 2 * margin - total_img_width) / 2
146
+ bf_x = img_left
147
+ hm_x = img_left + img_w + img_gap
148
+
149
+ footer_y = 40
150
+ c.setFont("Helvetica", 8)
151
+ c.setFillColorRGB(0.4, 0.4, 0.4)
152
+ gen_date = datetime.now().strftime("%Y-%m-%d %H:%M")
153
+ c.drawString(margin, footer_y, f"Generated by Shape2Force (S2F) on {gen_date}")
154
+ c.drawString(margin, footer_y - 12, "Model: https://huggingface.co/Angione-Lab/Shape2Force")
155
+ c.setFillColorRGB(0, 0, 0)
156
+
157
+ y_top = page_h_pt - 50
158
+ c.setFont("Helvetica-Bold", 14)
159
+ c.drawString(margin, y_top, "Region Measurement Report")
160
+ c.setFont("Helvetica", 10)
161
+ c.drawString(margin, y_top - 14, f"Image: {base_name}")
162
+ y_top -= 35
163
+
164
+ # Images (centered)
165
+ img_bottom = y_top - img_h
166
+ bf_pil = Image.fromarray(bf_img) if bf_img.ndim == 2 else Image.fromarray(
167
+ cv2.cvtColor(bf_img, cv2.COLOR_BGR2RGB)
168
+ )
169
+ bf_buf = io.BytesIO()
170
+ bf_pil.save(bf_buf, format="PNG")
171
+ bf_buf.seek(0)
172
+ c.drawImage(ImageReader(bf_buf), bf_x, img_bottom, width=img_w, height=img_h, preserveAspectRatio=True)
173
+ c.setFont("Helvetica", 9)
174
+ bf_label_w = c.stringWidth("Bright-field", "Helvetica", 9)
175
+ c.drawString(bf_x + (img_w - bf_label_w) / 2, img_bottom - 14, "Bright-field")
176
+
177
+ hm_labeled_buf = io.BytesIO()
178
+ Image.fromarray(heatmap_labeled_rgb).save(hm_labeled_buf, format="PNG")
179
+ hm_labeled_buf.seek(0)
180
+ c.drawImage(ImageReader(hm_labeled_buf), hm_x, img_bottom, width=img_w, height=img_h, preserveAspectRatio=True)
181
+ hm_label_w = c.stringWidth("Force map", "Helvetica", 9)
182
+ c.drawString(hm_x + (img_w - hm_label_w) / 2, img_bottom - 14, "Force map")
183
+
184
+ # Spacing: one line between images/labels and measurements section
185
+ row_height = 14
186
+ y_table_top = img_bottom - 14 - row_height # label + one line space
187
+
188
+ # Table with borders
189
+ n_cols = len(table_rows[0]) if table_rows else 0
190
+ table_width = page_w_pt - 2 * margin
191
+ col_w = table_width / n_cols if n_cols else 1
192
+ cell_pad = 4
193
+
194
+ c.setFont("Helvetica-Bold", 9)
195
+ c.drawString(margin, y_table_top, "Measurements")
196
+ y_table_top -= row_height + 8 # extra space before table
197
+
198
+ for ri, row in enumerate(table_rows):
199
+ y_cell_bottom = y_table_top - (ri + 1) * row_height
200
+ for ci, cell in enumerate(row):
201
+ x_left = margin + ci * col_w
202
+ # Draw cell border
203
+ c.rect(x_left, y_cell_bottom, col_w, row_height, stroke=1, fill=0)
204
+ # Draw text (with padding)
205
+ if ri == 0:
206
+ c.setFont("Helvetica-Bold", 8)
207
+ c.drawString(x_left + cell_pad, y_cell_bottom + 4, str(cell)[:22])
208
+ if ri == 0:
209
+ c.setFont("Helvetica", 8)
210
+
211
+ c.save()
212
+ buf.seek(0)
213
+ return buf.getvalue()