nmariotto commited on
Commit
2c892fb
·
verified ·
1 Parent(s): c4e5010

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +189 -135
  2. requirements.txt +1 -2
app.py CHANGED
@@ -2,7 +2,6 @@ import streamlit as st
2
  import pandas as pd
3
  import matplotlib.pyplot as plt
4
  import zipfile
5
- from shapely.geometry import Polygon
6
  from PIL import Image
7
  from io import BytesIO
8
  from concurrent.futures import ThreadPoolExecutor
@@ -17,39 +16,51 @@ import time
17
 
18
  st.set_page_config(page_title="Scratch Assay Segmentation", layout="wide")
19
 
20
- APP_VERSION = "3.0"
21
  DEFAULT_IMGSZ = 640
22
 
23
- # Model nomenclature aligned with the companion manuscript (Mariotto et al.,
24
- # Cytometry Part A 2026): Model 2 = Roboflow 3.0 Instance Segmentation Extra
25
- # Large with black-edge padding (Roboflow project version 24); Model 6 =
26
- # YOLOv11 Instance Segmentation Accurate variant with white-edge padding
27
- # (Roboflow project version 37). The .pt filenames are kept as the upstream
28
- # Roboflow version identifiers for traceability with the private model repo.
 
 
 
 
 
 
29
  MODEL_OPTIONS = {
30
- "Model 2": "24.pt",
31
- "Model 6": "37.pt",
32
  }
33
 
34
- # Stable, filesystem-safe key used for Drive folder names and Sheet logging.
35
- # Decoupled from the user-visible label so future relabeling does not affect
36
- # stored data.
37
  MODEL_STORAGE_KEY = {
38
- "Model 2": "Model_2",
39
- "Model 6": "Model_6",
 
 
 
 
 
 
 
 
40
  }
41
 
42
 
43
  # =========================
44
- # Local model init (Hugging Face private repo)
45
  # =========================
46
  @st.cache_resource
47
  def load_model(model_filename):
48
  local_model_path = hf_hub_download(
49
- repo_id=st.secrets["HF_MODEL_REPO"],
50
  filename=model_filename,
51
  repo_type="model",
52
- token=st.secrets["HF_TOKEN"],
53
  )
54
  return YOLO(local_model_path)
55
 
@@ -74,26 +85,54 @@ sheet = sheets_client.open_by_url(st.secrets["feedback_sheet_url"]).sheet1
74
  # =========================
75
  # Helpers
76
  # =========================
77
- def calculate_polygon_area(points):
78
- polygon = Polygon([(p["x"], p["y"]) for p in points])
79
- return polygon.area
80
-
81
-
82
  def safe_predict(model, image_array, conf_threshold):
 
 
83
  for _ in range(3):
84
  try:
85
- results = model.predict(
86
  source=image_array,
87
  imgsz=DEFAULT_IMGSZ,
88
  conf=conf_threshold,
 
89
  verbose=False,
90
  )
91
- return results
92
  except Exception:
93
  time.sleep(1)
94
  return None
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def resize_image(image):
98
  return image.resize((640, 640))
99
 
@@ -137,12 +176,26 @@ def get_image_bytes(image):
137
  return buf
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def process_image(uploaded_file, model, model_confidence, fov_um=None, pixel_size_um=None):
141
  try:
142
  safe_name = uploaded_file.name.replace(" ", "_")
143
  image = Image.open(uploaded_file).convert("RGB")
144
  image_np = np.array(image)
145
-
146
  width_px, height_px = image.size
147
 
148
  effective_pixel_size_um = None
@@ -151,92 +204,58 @@ def process_image(uploaded_file, model, model_confidence, fov_um=None, pixel_siz
151
  elif fov_um is not None and fov_um > 0:
152
  effective_pixel_size_um = fov_um / float(width_px)
153
 
154
- conf_threshold = model_confidence / 100.0
155
- results = safe_predict(model, image_np, conf_threshold)
156
-
157
  if not results or len(results) == 0:
158
- return {
159
- "Imagem": safe_name,
160
- "Área Segmentada (px²)": None,
161
- "Área Segmentada (µm²)": None,
162
- "SemSegmentacao": True,
163
- "Exibir": image,
164
- "Original": get_image_bytes(image),
165
- "Segmentada": None,
166
- "Poligono": None,
167
- }
168
 
169
  result = results[0]
170
-
171
- if result.masks is None or len(result.masks.xyn) == 0:
172
- return {
173
- "Imagem": safe_name,
174
- "Área Segmentada (px²)": None,
175
- "Área Segmentada (µm²)": None,
176
- "SemSegmentacao": True,
177
- "Exibir": image,
178
- "Original": get_image_bytes(image),
179
- "Segmentada": None,
180
- "Poligono": None,
181
- }
182
-
183
- best_idx = 0
184
- if result.boxes is not None and result.boxes.conf is not None and len(result.boxes.conf) > 0:
185
- best_idx = int(result.boxes.conf.argmax().item())
186
-
187
- contour_norm = result.masks.xyn[best_idx]
188
- if contour_norm is None or len(contour_norm) < 3:
189
- return {
190
- "Imagem": safe_name,
191
- "Área Segmentada (px²)": None,
192
- "Área Segmentada (µm²)": None,
193
- "SemSegmentacao": True,
194
- "Exibir": image,
195
- "Original": get_image_bytes(image),
196
- "Segmentada": None,
197
- "Poligono": None,
198
- }
199
-
200
- points = [
201
- {"x": float(x * width_px), "y": float(y * height_px)}
202
- for x, y in contour_norm
203
- ]
204
-
205
- area_px2 = calculate_polygon_area(points)
206
 
207
  area_um2 = None
208
  if effective_pixel_size_um is not None:
209
  area_um2 = area_px2 * (effective_pixel_size_um ** 2)
210
 
211
- x = [p["x"] for p in points] + [points[0]["x"]]
212
- y = [p["y"] for p in points] + [points[0]["y"]]
213
-
214
- original_buffer = get_image_bytes(image)
 
 
 
 
 
 
 
215
 
216
  segmented_buffer = BytesIO()
217
  fig, ax = plt.subplots(figsize=(6, 6), dpi=300)
218
  ax.imshow(image)
219
- ax.plot(x, y, color="red", linewidth=2)
 
220
  ax.axis("off")
221
  plt.savefig(segmented_buffer, format="png", bbox_inches="tight", pad_inches=0)
222
- plt.close()
223
 
224
- polygon_buffer = BytesIO()
225
- fig2, ax2 = plt.subplots(figsize=(6, 6), dpi=300)
226
- ax2.plot(x, y, "r-", linewidth=2)
227
- ax2.scatter(x, y, color="red", s=5)
228
- ax2.set_title("Polygon contour")
229
- ax2.grid(True)
230
- plt.savefig(polygon_buffer, format="png", bbox_inches="tight")
231
- plt.close()
232
 
233
  return {
234
  "Imagem": safe_name,
235
  "Área Segmentada (px²)": area_px2,
236
  "Área Segmentada (µm²)": area_um2,
237
- "Original": original_buffer,
 
 
238
  "Segmentada": segmented_buffer,
239
- "Poligono": polygon_buffer,
240
  "Exibir": image,
241
  "SemSegmentacao": False,
242
  }
@@ -250,12 +269,8 @@ def save_feedback(result, avaliacao, observacao, selected_model_label):
250
  image_base_name = image_name.rsplit(".", 1)[0]
251
  storage_key = MODEL_STORAGE_KEY[selected_model_label]
252
 
253
- # 1) Sheet - store the stable storage key (Model_2 / Model_6) rather than
254
- # the user-visible label, so the spreadsheet stays clean across future
255
- # label tweaks.
256
  sheet.append_row([image_name, avaliacao, observacao, storage_key, APP_VERSION])
257
 
258
- # 2) Drive curation
259
  if avaliacao in ["Acceptable", "Bad", "No segmentation"]:
260
  sufixo = (
261
  "aceitavel" if avaliacao == "Acceptable"
@@ -273,30 +288,36 @@ def save_feedback(result, avaliacao, observacao, selected_model_label):
273
  buf.seek(0)
274
  upload_to_drive(buf, f"original_{storage_key}_v{APP_VERSION}_{sufixo}.png", subfolder)
275
 
276
- if avaliacao != "No segmentation" and result.get("Segmentada") and result.get("Poligono"):
277
  resized_segmented = resize_image(Image.open(BytesIO(result["Segmentada"].getvalue())))
278
- resized_polygon = resize_image(Image.open(BytesIO(result["Poligono"].getvalue())))
279
-
280
- for img_obj, nome in zip([resized_segmented, resized_polygon], ["segmentada", "poligono"]):
281
- buf = BytesIO()
282
- img_obj.save(buf, format="PNG")
283
- buf.seek(0)
284
- upload_to_drive(
285
- buf,
286
- f"{nome}_{storage_key}_v{APP_VERSION}_{sufixo}.png",
287
- subfolder,
288
- )
289
 
290
 
291
  def render_metrics(result):
292
  area_px2 = result["Área Segmentada (px²)"]
293
  area_um2 = result["Área Segmentada (µm²)"]
 
294
 
295
  st.markdown("**Segmented area**")
296
  if area_px2 is not None:
297
- st.markdown(f"- {area_px2:.2f} px²")
298
  if area_um2 is not None:
299
- st.markdown(f"- {area_um2:.2f} µm²")
 
 
 
 
 
 
 
 
 
 
300
 
301
 
302
  def render_feedback_block(result, selected_model_label, prefix_key=""):
@@ -336,11 +357,14 @@ with col_input_2:
336
  selected_model_label = st.selectbox("Segmentation model", list(MODEL_OPTIONS.keys()), index=0)
337
 
338
  model = load_model(MODEL_OPTIONS[selected_model_label])
339
-
340
- st.caption(f"Selected model: {selected_model_label}")
341
 
342
  with st.expander("⚙️ Advanced Settings", expanded=False):
343
  model_confidence = st.slider("Model confidence (%)", 20, 100, 80)
 
 
 
 
344
  st.markdown(
345
  "### Physical calibration (optional)\n"
346
  "Provide the physical scale for conversion from pixel area to physical units (µm²). "
@@ -368,17 +392,31 @@ with st.sidebar:
368
  st.markdown("## Info")
369
  with st.expander("About / Citation", expanded=False):
370
  st.markdown(
371
- """
372
- This tool was developed by the **Medical Physics Laboratory** of the Department of **Biophysics and Pharmacology – IBB, UNESP**.
 
373
  **FAPESP Process:** 2024/01849-4.
374
  **Coordination:** Prof. Allan Alves.
375
  **Development:** Nycolas Mariotto.
376
 
377
- Model nomenclature in this interface follows the companion manuscript
378
- (Mariotto et al., *Cytometry Part A*, 2026):
379
- - **Model 2**: Roboflow 3.0 Instance Segmentation Extra Large with black-edge padding (Roboflow project version 24).
380
- - **Model 6**: YOLOv11 Instance Segmentation Accurate variant with white-edge padding (Roboflow project version 37).
 
 
 
 
 
 
381
 
 
 
 
 
 
 
 
382
  Companion archive: Zenodo DOI [10.5281/zenodo.20298129](https://doi.org/10.5281/zenodo.20298129).
383
  """
384
  )
@@ -403,30 +441,38 @@ if upload_option == "Single image":
403
 
404
  if result:
405
  results.append(result)
406
-
407
  st.markdown(f"#### {result['Imagem']}")
408
 
409
  if result["SemSegmentacao"]:
410
  st.image(result["Exibir"], caption="Original", use_container_width=True)
411
  st.warning("No segmentation was detected for this image.")
412
  else:
413
- col1, col2, col3 = st.columns(3)
414
  with col1:
415
  st.image(result["Exibir"], caption="Original", use_container_width=True)
416
  with col2:
417
  st.image(result["Segmentada"], caption="Segmentation", use_container_width=True)
418
- with col3:
419
- st.image(result["Poligono"], caption="Polygon", use_container_width=True)
420
 
421
  render_metrics(result)
422
 
423
  st.markdown("### Export")
424
- st.download_button(
425
- "Download segmented overlay (PNG)",
426
- data=result["Segmentada"],
427
- file_name=f"segmented_{result['Imagem']}.png",
428
- mime="image/png",
429
- )
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  st.markdown("---")
432
  render_feedback_block(result, selected_model_label, prefix_key="single_")
@@ -478,18 +524,20 @@ elif upload_option == "Image folder":
478
  st.image(result["Exibir"], caption="Original", use_container_width=True)
479
  st.warning("No segmentation was detected for this image.")
480
  else:
481
- col1, col2, col3 = st.columns(3)
482
  with col1:
483
  st.image(result["Exibir"], caption="Original", use_container_width=True)
484
  with col2:
485
  st.image(result["Segmentada"], caption="Segmentation", use_container_width=True)
486
- with col3:
487
- st.image(result["Poligono"], caption="Polygon", use_container_width=True)
488
 
489
  render_metrics(result)
490
 
491
- zip_file.writestr(f"segmentada_{result['Imagem']}.png", result["Segmentada"].getvalue())
492
- zip_file.writestr(f"poligono_{result['Imagem']}.png", result["Poligono"].getvalue())
 
 
 
 
493
 
494
  render_feedback_block(result, selected_model_label, prefix_key="folder_")
495
 
@@ -504,7 +552,7 @@ elif upload_option == "Image folder":
504
  {
505
  "Image": r["Imagem"],
506
  "Segmented Area (px²)": (
507
- r["Área Segmentada (px²)"]
508
  if (not r["SemSegmentacao"] and r["Área Segmentada (px²)"] is not None)
509
  else "No Segmentation"
510
  ),
@@ -513,6 +561,12 @@ elif upload_option == "Image folder":
513
  if (not r["SemSegmentacao"] and r["Área Segmentada (µm²)"] is not None)
514
  else ""
515
  ),
 
 
 
 
 
 
516
  }
517
  for r in results
518
  ]
@@ -536,9 +590,9 @@ elif upload_option == "Image folder":
536
  )
537
  with c2:
538
  st.download_button(
539
- "Download segmented images (ZIP)",
540
  data=zip_images_buffer,
541
  file_name="segmented_images.zip",
542
  mime="application/zip",
543
  use_container_width=True,
544
- )
 
2
  import pandas as pd
3
  import matplotlib.pyplot as plt
4
  import zipfile
 
5
  from PIL import Image
6
  from io import BytesIO
7
  from concurrent.futures import ThreadPoolExecutor
 
16
 
17
  st.set_page_config(page_title="Scratch Assay Segmentation", layout="wide")
18
 
19
+ APP_VERSION = "4.0"
20
  DEFAULT_IMGSZ = 640
21
 
22
+ # Public model repository. The weights are AGPL-3.0, as they derive from
23
+ # Ultralytics YOLO11, and are downloadable without a token the manuscript
24
+ # claims that the exact file behind any prediction can be inspected and
25
+ # redeployed independently, and a token-gated repository would make that false.
26
+ HF_MODEL_REPO = "nmariotto/scratch-assay-segmentation"
27
+
28
+ # Configurations M and S of the companion manuscript (Mariotto et al.,
29
+ # Cytometry Part A). They differ ONLY in model scale; initialisation (COCO),
30
+ # padding colour (black) and training schedule are identical. The five
31
+ # configurations evaluated are not distinguishable in mean Average Precision,
32
+ # so neither of these is "the accurate one": the choice is latency against
33
+ # recall, and the labels say so.
34
  MODEL_OPTIONS = {
35
+ "M — default (22.4 M parameters)": "M.pt",
36
+ "S — fast mode (10.1 M parameters)": "S.pt",
37
  }
38
 
39
+ # Stable, filesystem-safe key for Drive folders and Sheet logging, decoupled
40
+ # from the user-visible label so relabeling does not fragment stored data.
 
41
  MODEL_STORAGE_KEY = {
42
+ "M — default (22.4 M parameters)": "M",
43
+ "S — fast mode (10.1 M parameters)": "S",
44
+ }
45
+
46
+ # Measured on the held-out test set (n = 234), mean ± SD over five seeds;
47
+ # latency is the median over 40 images on 16 CPU cores. Shown in the interface
48
+ # so the trade-off is stated rather than discovered.
49
+ MODEL_INFO = {
50
+ "M": "mAP@50 93.4 ± 1.1% · recall 78.3 ± 3.0% · ~345 ms per image on CPU",
51
+ "S": "mAP@50 94.0 ± 0.7% · recall 74.3 ± 2.3% · ~174 ms per image on CPU",
52
  }
53
 
54
 
55
  # =========================
56
+ # Model init — public Hugging Face repository
57
  # =========================
58
  @st.cache_resource
59
  def load_model(model_filename):
60
  local_model_path = hf_hub_download(
61
+ repo_id=HF_MODEL_REPO,
62
  filename=model_filename,
63
  repo_type="model",
 
64
  )
65
  return YOLO(local_model_path)
66
 
 
85
  # =========================
86
  # Helpers
87
  # =========================
 
 
 
 
 
88
  def safe_predict(model, image_array, conf_threshold):
89
+ """Same call as the evaluation pipeline: retina_masks gives masks at the
90
+ original resolution instead of the model's internal 160 x 160 grid."""
91
  for _ in range(3):
92
  try:
93
+ return model.predict(
94
  source=image_array,
95
  imgsz=DEFAULT_IMGSZ,
96
  conf=conf_threshold,
97
+ retina_masks=True,
98
  verbose=False,
99
  )
 
100
  except Exception:
101
  time.sleep(1)
102
  return None
103
 
104
 
105
+ def mask_area_px(result, height, width):
106
+ """Wound area in pixels, computed exactly as in the manuscript.
107
+
108
+ The published figures come from `etapa3/predict_areas.py`, which counts the
109
+ pixels of the UNION of every predicted mask. Two earlier choices in this app
110
+ made it disagree with them:
111
+
112
+ · it kept only the highest-confidence mask, so an image whose wound is
113
+ split into two non-contiguous regions was under-reported. Rare (2 of the
114
+ 234 test images) but silent;
115
+ · it took the shapely area of the mask POLYGON rather than counting mask
116
+ pixels. Measured against the pipeline over 14 test images, that
117
+ under-reported by 0.6% at the median and 2.3% at worst, and the error
118
+ grew as the wound shrank — the polygon cuts corners, and the smaller the
119
+ wound the larger the share of it that is boundary. It biased exactly the
120
+ regime the manuscript already identifies as least reliable.
121
+
122
+ Returns (area_px, n_masks, union_mask) with union_mask at (height, width).
123
+ """
124
+ if result.masks is None or len(result.masks) == 0:
125
+ return 0, 0, None
126
+ md = result.masks.data.cpu().numpy() > 0.5
127
+ union = np.any(md, axis=0)
128
+ if union.shape != (height, width):
129
+ import cv2
130
+ union = cv2.resize(
131
+ union.astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST
132
+ ).astype(bool)
133
+ return int(union.sum()), int(md.shape[0]), union
134
+
135
+
136
  def resize_image(image):
137
  return image.resize((640, 640))
138
 
 
176
  return buf
177
 
178
 
179
+ def sem_segmentacao(safe_name, image):
180
+ return {
181
+ "Imagem": safe_name,
182
+ "Área Segmentada (px²)": None,
183
+ "Área Segmentada (µm²)": None,
184
+ "Área do campo (%)": None,
185
+ "Regiões": 0,
186
+ "SemSegmentacao": True,
187
+ "Exibir": image,
188
+ "Original": get_image_bytes(image),
189
+ "Segmentada": None,
190
+ "Contorno": None,
191
+ }
192
+
193
+
194
  def process_image(uploaded_file, model, model_confidence, fov_um=None, pixel_size_um=None):
195
  try:
196
  safe_name = uploaded_file.name.replace(" ", "_")
197
  image = Image.open(uploaded_file).convert("RGB")
198
  image_np = np.array(image)
 
199
  width_px, height_px = image.size
200
 
201
  effective_pixel_size_um = None
 
204
  elif fov_um is not None and fov_um > 0:
205
  effective_pixel_size_um = fov_um / float(width_px)
206
 
207
+ results = safe_predict(model, image_np, model_confidence / 100.0)
 
 
208
  if not results or len(results) == 0:
209
+ return sem_segmentacao(safe_name, image)
 
 
 
 
 
 
 
 
 
210
 
211
  result = results[0]
212
+ area_px2, n_masks, union = mask_area_px(result, height_px, width_px)
213
+ if area_px2 == 0 or union is None:
214
+ return sem_segmentacao(safe_name, image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  area_um2 = None
217
  if effective_pixel_size_um is not None:
218
  area_um2 = area_px2 * (effective_pixel_size_um ** 2)
219
 
220
+ # Overlay: every contour is drawn, so what the user sees is what was
221
+ # counted. The contours are for display only; the number above comes
222
+ # from the mask raster.
223
+ contornos = []
224
+ if result.masks is not None and result.masks.xyn is not None:
225
+ for c in result.masks.xyn:
226
+ if c is not None and len(c) >= 3:
227
+ contornos.append(
228
+ [[float(x * width_px) for x, _ in c] + [float(c[0][0] * width_px)],
229
+ [float(y * height_px) for _, y in c] + [float(c[0][1] * height_px)]]
230
+ )
231
 
232
  segmented_buffer = BytesIO()
233
  fig, ax = plt.subplots(figsize=(6, 6), dpi=300)
234
  ax.imshow(image)
235
+ for xs, ys in contornos:
236
+ ax.plot(xs, ys, color="red", linewidth=2)
237
  ax.axis("off")
238
  plt.savefig(segmented_buffer, format="png", bbox_inches="tight", pad_inches=0)
239
+ plt.close(fig)
240
 
241
+ # Contour coordinates as data, in place of the polygon picture that used
242
+ # to be shown. A CSV of vertices can be re-plotted or re-measured; a PNG
243
+ # of the same polygon cannot.
244
+ linhas = ["region,vertex,x_px,y_px"]
245
+ for i, (xs, ys) in enumerate(contornos, start=1):
246
+ for j, (x, y) in enumerate(zip(xs[:-1], ys[:-1]), start=1):
247
+ linhas.append(f"{i},{j},{x:.2f},{y:.2f}")
248
+ contorno_csv = BytesIO("\n".join(linhas).encode("utf-8"))
249
 
250
  return {
251
  "Imagem": safe_name,
252
  "Área Segmentada (px²)": area_px2,
253
  "Área Segmentada (µm²)": area_um2,
254
+ "Área do campo (%)": 100.0 * area_px2 / float(width_px * height_px),
255
+ "Regiões": n_masks,
256
+ "Original": get_image_bytes(image),
257
  "Segmentada": segmented_buffer,
258
+ "Contorno": contorno_csv,
259
  "Exibir": image,
260
  "SemSegmentacao": False,
261
  }
 
269
  image_base_name = image_name.rsplit(".", 1)[0]
270
  storage_key = MODEL_STORAGE_KEY[selected_model_label]
271
 
 
 
 
272
  sheet.append_row([image_name, avaliacao, observacao, storage_key, APP_VERSION])
273
 
 
274
  if avaliacao in ["Acceptable", "Bad", "No segmentation"]:
275
  sufixo = (
276
  "aceitavel" if avaliacao == "Acceptable"
 
288
  buf.seek(0)
289
  upload_to_drive(buf, f"original_{storage_key}_v{APP_VERSION}_{sufixo}.png", subfolder)
290
 
291
+ if avaliacao != "No segmentation" and result.get("Segmentada"):
292
  resized_segmented = resize_image(Image.open(BytesIO(result["Segmentada"].getvalue())))
293
+ buf = BytesIO()
294
+ resized_segmented.save(buf, format="PNG")
295
+ buf.seek(0)
296
+ upload_to_drive(
297
+ buf, f"segmentada_{storage_key}_v{APP_VERSION}_{sufixo}.png", subfolder
298
+ )
 
 
 
 
 
299
 
300
 
301
  def render_metrics(result):
302
  area_px2 = result["Área Segmentada (px²)"]
303
  area_um2 = result["Área Segmentada (µm²)"]
304
+ area_pct = result["Área do campo (%)"]
305
 
306
  st.markdown("**Segmented area**")
307
  if area_px2 is not None:
308
+ st.markdown(f"- {area_px2:,.0f} px²")
309
  if area_um2 is not None:
310
+ st.markdown(f"- {area_um2:,.2f} µm²")
311
+ if area_pct is not None:
312
+ st.markdown(f"- {area_pct:.2f}% of the field")
313
+ if area_pct < 5.0:
314
+ st.warning(
315
+ "The remaining gap is below 5% of the field. In the validation "
316
+ "study, agreement with manual measurement degrades in this "
317
+ "regime; treat this value as the least reliable point of a series."
318
+ )
319
+ if result.get("Regiões", 0) > 1:
320
+ st.caption(f"{result['Regiões']} disconnected regions; the area is their union.")
321
 
322
 
323
  def render_feedback_block(result, selected_model_label, prefix_key=""):
 
357
  selected_model_label = st.selectbox("Segmentation model", list(MODEL_OPTIONS.keys()), index=0)
358
 
359
  model = load_model(MODEL_OPTIONS[selected_model_label])
360
+ st.caption(MODEL_INFO[MODEL_STORAGE_KEY[selected_model_label]])
 
361
 
362
  with st.expander("⚙️ Advanced Settings", expanded=False):
363
  model_confidence = st.slider("Model confidence (%)", 20, 100, 80)
364
+ st.caption(
365
+ "80% is the operating point at which the reported precision and recall "
366
+ "were measured."
367
+ )
368
  st.markdown(
369
  "### Physical calibration (optional)\n"
370
  "Provide the physical scale for conversion from pixel area to physical units (µm²). "
 
392
  st.markdown("## Info")
393
  with st.expander("About / Citation", expanded=False):
394
  st.markdown(
395
+ f"""
396
+ This tool was developed by the **Medical Physics Laboratory** of the Department of
397
+ **Biophysics and Pharmacology – IBB, UNESP**.
398
  **FAPESP Process:** 2024/01849-4.
399
  **Coordination:** Prof. Allan Alves.
400
  **Development:** Nycolas Mariotto.
401
 
402
+ The two configurations offered here are those of the companion manuscript
403
+ (Mariotto et al., *Cytometry Part A*). They differ **only in model scale**:
404
+ initialisation, padding and training schedule are identical.
405
+
406
+ - **M** — default. {MODEL_INFO['M']}
407
+ - **S** — fast mode. {MODEL_INFO['S']}
408
+
409
+ Neither is the more accurate: across the five configurations evaluated, mean
410
+ mAP@50 spans 93.3–94.0% and no pairwise difference is distinguishable. The choice
411
+ is latency against recall.
412
 
413
+ **What this tool is for.** Comparing conditions across many wells. Agreement with
414
+ a careful manual measurement has 95% limits of agreement of about ±0.3 in closure
415
+ fraction, so a single automated measurement is **not** a substitute for a single
416
+ manual one.
417
+
418
+ Weights: [{HF_MODEL_REPO}](https://huggingface.co/{HF_MODEL_REPO}) — AGPL-3.0,
419
+ derived from Ultralytics YOLO11.
420
  Companion archive: Zenodo DOI [10.5281/zenodo.20298129](https://doi.org/10.5281/zenodo.20298129).
421
  """
422
  )
 
441
 
442
  if result:
443
  results.append(result)
 
444
  st.markdown(f"#### {result['Imagem']}")
445
 
446
  if result["SemSegmentacao"]:
447
  st.image(result["Exibir"], caption="Original", use_container_width=True)
448
  st.warning("No segmentation was detected for this image.")
449
  else:
450
+ col1, col2 = st.columns(2)
451
  with col1:
452
  st.image(result["Exibir"], caption="Original", use_container_width=True)
453
  with col2:
454
  st.image(result["Segmentada"], caption="Segmentation", use_container_width=True)
 
 
455
 
456
  render_metrics(result)
457
 
458
  st.markdown("### Export")
459
+ e1, e2 = st.columns(2)
460
+ with e1:
461
+ st.download_button(
462
+ "Download segmented overlay (PNG)",
463
+ data=result["Segmentada"],
464
+ file_name=f"segmented_{result['Imagem']}.png",
465
+ mime="image/png",
466
+ use_container_width=True,
467
+ )
468
+ with e2:
469
+ st.download_button(
470
+ "Download contour coordinates (CSV)",
471
+ data=result["Contorno"],
472
+ file_name=f"contour_{result['Imagem']}.csv",
473
+ mime="text/csv",
474
+ use_container_width=True,
475
+ )
476
 
477
  st.markdown("---")
478
  render_feedback_block(result, selected_model_label, prefix_key="single_")
 
524
  st.image(result["Exibir"], caption="Original", use_container_width=True)
525
  st.warning("No segmentation was detected for this image.")
526
  else:
527
+ col1, col2 = st.columns(2)
528
  with col1:
529
  st.image(result["Exibir"], caption="Original", use_container_width=True)
530
  with col2:
531
  st.image(result["Segmentada"], caption="Segmentation", use_container_width=True)
 
 
532
 
533
  render_metrics(result)
534
 
535
+ zip_file.writestr(
536
+ f"segmentada_{result['Imagem']}.png", result["Segmentada"].getvalue()
537
+ )
538
+ zip_file.writestr(
539
+ f"contorno_{result['Imagem']}.csv", result["Contorno"].getvalue()
540
+ )
541
 
542
  render_feedback_block(result, selected_model_label, prefix_key="folder_")
543
 
 
552
  {
553
  "Image": r["Imagem"],
554
  "Segmented Area (px²)": (
555
+ f"{r['Área Segmentada (px²)']:.0f}"
556
  if (not r["SemSegmentacao"] and r["Área Segmentada (px²)"] is not None)
557
  else "No Segmentation"
558
  ),
 
561
  if (not r["SemSegmentacao"] and r["Área Segmentada (µm²)"] is not None)
562
  else ""
563
  ),
564
+ "Field (%)": (
565
+ f"{r['Área do campo (%)']:.2f}"
566
+ if (not r["SemSegmentacao"] and r["Área do campo (%)"] is not None)
567
+ else ""
568
+ ),
569
+ "Regions": r.get("Regiões", 0),
570
  }
571
  for r in results
572
  ]
 
590
  )
591
  with c2:
592
  st.download_button(
593
+ "Download segmented images and contours (ZIP)",
594
  data=zip_images_buffer,
595
  file_name="segmented_images.zip",
596
  mime="application/zip",
597
  use_container_width=True,
598
+ )
requirements.txt CHANGED
@@ -2,7 +2,6 @@ streamlit
2
  pandas
3
  matplotlib
4
  Pillow
5
- shapely
6
  openpyxl
7
  google-auth
8
  google-api-python-client
@@ -12,4 +11,4 @@ ultralytics
12
  torch
13
  torchvision
14
  opencv-python-headless
15
- numpy
 
2
  pandas
3
  matplotlib
4
  Pillow
 
5
  openpyxl
6
  google-auth
7
  google-api-python-client
 
11
  torch
12
  torchvision
13
  opencv-python-headless
14
+ numpy