LiangLabUMB commited on
Commit
eb58d0f
Β·
verified Β·
1 Parent(s): 7590343

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +474 -254
app.py CHANGED
@@ -9,42 +9,143 @@ from PIL import Image, ImageDraw
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
12
- from concurrent.futures import ThreadPoolExecutor, as_completed
13
  import csv
14
  import joblib
15
  import os
16
-
17
- HF_REPO_ID = "myang4218/cellposemodel"
18
- HF_REPO_ID2 = "LiangLabUMB/viability_model"
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  MODEL_OPTIONS = {
20
  "Hemocytometer Model": "hemocytometermodel.npy",
21
- "General Model": "generalmodel.npy"
 
 
 
 
 
 
22
  }
23
 
24
- loaded_models = {}
25
-
26
- VIABILITY_CLF = None
27
- VIABILITY_SCALER = None
28
-
29
- try:
30
- _clf_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_clf.pkl")
31
- _scaler_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_scaler.pkl")
32
- VIABILITY_CLF = joblib.load(_clf_path)
33
- VIABILITY_SCALER = joblib.load(_scaler_path)
34
- print("βœ“ Viability classifier loaded.")
35
- except Exception as e:
36
- print(f"Viability classifier not found or failed to load: {e}")
37
-
38
- # ---- mobile-safe size limits (aggressive for Safari) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  MAX_SIDE = 1024
40
  MAX_PIXELS = 1024 * 1024
41
 
42
 
43
  def safe_resize(image_np):
44
- """
45
- Downscale image to fit within MAX_SIDE and MAX_PIXELS while
46
- preserving aspect ratio. Works for RGB / RGBA / grayscale.
47
- """
48
  h, w = image_np.shape[:2]
49
  total = h * w
50
 
@@ -152,11 +253,7 @@ FEATURE_COLS_INFERENCE = [
152
 
153
 
154
  def classify_cells_by_model(image_np, masks):
155
- """
156
- Run the trained LogisticRegression classifier to predict live/dead per cell.
157
- Returns (dead_count, alive_count, overlay_np, {cell_id: label}).
158
- Requires VIABILITY_CLF and VIABILITY_SCALER to be loaded.
159
- """
160
  import numpy as np
161
  cell_ids = np.unique(masks)
162
  cell_ids = cell_ids[cell_ids > 0]
@@ -187,12 +284,23 @@ def classify_cells_by_model(image_np, masks):
187
  return dead, alive, overlay, label_map
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def draw_viability_overlay(image_np, masks, label_map):
191
- """
192
- Draw coloured contours + cell-number labels onto image_np.
193
- label_map: {cell_id: 0=live, 1=dead}
194
- Returns a uint8 numpy array.
195
- """
196
  overlay = image_np.copy()
197
  cell_ids = np.unique(masks)
198
  cell_ids = cell_ids[cell_ids > 0]
@@ -220,114 +328,10 @@ def draw_viability_overlay(image_np, masks, label_map):
220
  (0, 0, 0), -1)
221
  cv2.putText(overlay, label_str,
222
  (cx - tw//2, cy + th//2),
223
- font, font_scale, color, thickness, cv2.LINE_AA)
224
  return overlay
225
 
226
 
227
- def classify_cells_by_blueness(image_np, masks, threshold_bias):
228
- """
229
- Classify cells as dead (blue) or alive using an adaptive Otsu threshold
230
- on per-cell blueness scores, with a user bias to fine-tune.
231
-
232
- Args:
233
- image_np: RGB image array
234
- masks: Cellpose segmentation masks
235
- threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
236
- Negative = more cells classified dead (looser).
237
- Positive = fewer cells classified dead (stricter).
238
- 0 = pure Otsu (fully automatic).
239
-
240
- Returns:
241
- dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
242
- """
243
-
244
- if len(image_np.shape) == 2:
245
- image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
246
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
247
- image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
248
-
249
- hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
250
-
251
- hue = hsv[:, :, 0].astype(np.float32)
252
- saturation = hsv[:, :, 1].astype(np.float32)
253
-
254
- # Raw blueness: hue proximity to 115Β° Γ— saturation
255
- hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
256
- hue_score = np.maximum(0, 1 - hue_distance / 65)
257
- blueness = hue_score * (saturation / 255.0)
258
-
259
- # --- Compute per-cell mean blueness scores ---
260
- cell_ids = np.unique(masks)
261
- cell_ids = cell_ids[cell_ids > 0]
262
-
263
- if len(cell_ids) == 0:
264
- blank = image_np.copy()
265
- return 0, 0, blank, 0.0, 0.0
266
-
267
- cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
268
-
269
- # --- Otsu on the distribution of per-cell scores ---
270
- # cv2.threshold expects uint8; scale 0-1 β†’ 0-255
271
- scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
272
-
273
- if scores_u8.max() == scores_u8.min():
274
- # All cells identical β†’ Otsu is undefined; use midpoint
275
- otsu_threshold = float(scores_u8[0]) / 255.0
276
- else:
277
- # Reshape to a single-column image so cv2.threshold works
278
- thresh_val, _ = cv2.threshold(
279
- scores_u8.reshape(-1, 1), 0, 255,
280
- cv2.THRESH_BINARY + cv2.THRESH_OTSU
281
- )
282
- otsu_threshold = thresh_val / 255.0
283
-
284
- # --- Apply user bias: slider -50..+50 maps to Β±0.20 shift ---
285
- bias = (threshold_bias / 50.0) * 0.20
286
- final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
287
-
288
- # --- Classify ---
289
- dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
290
- alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
291
-
292
- # --- Outline-only overlay on raw image with enumerated labels ---
293
- final_overlay = image_np.copy()
294
-
295
- # Compute a consistent enumeration order (cell_ids is already sorted ascending)
296
- cell_enum = {cid: idx + 1 for idx, cid in enumerate(cell_ids)}
297
-
298
- dead_set = set(dead_cells)
299
- alive_set = set(alive_cells)
300
-
301
- for cid in cell_ids:
302
- cell_mask = (masks == cid).astype(np.uint8)
303
- contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
304
- color = (220, 50, 50) if cid in dead_set else (50, 220, 80)
305
- cv2.drawContours(final_overlay, contours, -1, color, thickness=2)
306
-
307
- # Draw enumeration label at centroid
308
- ys, xs = np.where(cell_mask)
309
- if len(ys) > 0:
310
- cx, cy = int(xs.mean()), int(ys.mean())
311
- label_str = str(cell_enum[cid])
312
- font = cv2.FONT_HERSHEY_SIMPLEX
313
- font_scale = 0.35
314
- thickness = 1
315
- (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
316
- # Dark background rectangle for readability
317
- cv2.rectangle(
318
- final_overlay,
319
- (cx - tw // 2 - 1, cy - th // 2 - 1),
320
- (cx + tw // 2 + 1, cy + th // 2 + 1),
321
- (0, 0, 0),
322
- -1
323
- )
324
- cv2.putText(
325
- final_overlay, label_str,
326
- (cx - tw // 2, cy + th // 2),
327
- font, font_scale, color, thickness, cv2.LINE_AA
328
- )
329
-
330
- return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
331
 
332
 
333
  def measure_confluency(masks, image_np):
@@ -451,12 +455,10 @@ def warp_polygon_to_square(image_np, points):
451
 
452
 
453
  def toggle_stereological_mode(use_stereology):
454
- """Show/hide stereological controls based on checkbox"""
455
  return gr.update(visible=use_stereology)
456
 
457
 
458
  def update_exclusion_preview(image, left_width, top_width):
459
- """Update the preview image with exclusion zone overlay"""
460
  if image is None:
461
  return None
462
 
@@ -465,9 +467,8 @@ def update_exclusion_preview(image, left_width, top_width):
465
  return Image.fromarray(overlay)
466
 
467
 
468
- # ---------------------------------------------------------------------------
469
  # Patch segmentation
470
- # ---------------------------------------------------------------------------
471
  PATCH_SIZE = 512 # target patch side length
472
  PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
473
  MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
@@ -555,62 +556,42 @@ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
555
  return renumbered
556
 
557
 
558
- def _segment_patch(args):
559
- """Worker: run cellpose on a single patch. Called from a thread pool."""
560
- patch_np, row_start, col_start, model_filename, hf_repo = args
561
- # Each thread uses the shared loaded_models cache (GIL-safe for reads;
562
- # model.eval() releases the GIL during GPU work so threads overlap.)
563
- model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
564
- if model_filename in loaded_models:
565
- model = loaded_models[model_filename]
566
- else:
567
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
568
- loaded_models[model_filename] = model
569
-
570
- mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
571
- return mask, row_start, col_start
572
-
573
-
574
  def run_segmentation_patched(image_np, model_filename):
575
  """
576
- Split image into overlapping patches, run Cellpose on each in parallel,
577
  then stitch back into a single full-resolution mask.
578
- Falls back to whole-image segmentation if the image is small enough
579
- that patching adds overhead without benefit.
 
 
 
 
580
  """
581
  h, w = image_np.shape[:2]
582
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
583
- if model_filename in loaded_models:
584
- model = loaded_models[model_filename]
585
- else:
586
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
587
- loaded_models[model_filename] = model
 
 
 
 
 
588
 
589
  # Small images: no benefit from patching
590
  if max(h, w) <= MIN_PATCH_DIM * 2:
591
- mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
592
- return mask, 1 # 1 patch
593
 
594
  patches = _split_patches(image_np)
595
  n_patches = len(patches)
596
 
597
- # Build argument list for the thread pool
598
- args_list = [
599
- (patch, r, c, model_filename, HF_REPO_ID)
600
- for patch, r, c in patches
601
- ]
602
-
603
- patch_results = [] # (mask, row_start, col_start) in submission order
604
-
605
- # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
606
- with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
607
- futures = {pool.submit(_segment_patch, a): a for a in args_list}
608
- for future in as_completed(futures):
609
- mask_patch, row_start, col_start = future.result()
610
- patch_results.append((mask_patch, row_start, col_start))
611
-
612
- # Re-sort by (row, col) so stitching is deterministic
613
- patch_results.sort(key=lambda x: (x[1], x[2]))
614
 
615
  full_mask = _merge_patch_masks(patch_results, h, w)
616
  return full_mask, n_patches
@@ -618,6 +599,7 @@ def run_segmentation_patched(image_np, model_filename):
618
 
619
  @spaces.GPU
620
  def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
 
621
  use_stereology, left_exclusion, top_exclusion,
622
  crop_points=None):
623
  image_np = np.array(image)
@@ -661,21 +643,18 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
661
  print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
662
  print("max:", sizes.max() if len(sizes) > 0 else 0)
663
 
664
- # Compute recommendation from RAW masks
665
  recommend_min = rec_min_size(masks_raw)
666
 
667
- # If user sets slider to 0, use the recommendation
668
- min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
669
-
670
- # Apply filters
671
  masks = masks_raw.copy()
672
  removed_small = 0
673
  removed_large = 0
674
 
675
- if min_used > 0:
676
- masks, removed_small = filter_mask_by_size(masks, min_used)
677
 
678
- if max_cell_size > 0:
679
  masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
680
 
681
  # Apply stereological exclusion if enabled
@@ -687,7 +666,7 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
687
 
688
  filter_msg = ""
689
  if removed_small:
690
- filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
691
  if removed_large:
692
  filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
693
  if use_stereology and excluded_count > 0:
@@ -697,16 +676,8 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
697
  confluency = measure_confluency(masks, processed_image_np)
698
 
699
  # Create a basic segmentation overlay (without viability)
700
- segmentation_overlay = processed_image_np.copy().astype(np.float32)
701
- if masks.max() > 0:
702
- np.random.seed(42) # For consistent random colors
703
- colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
704
- colors[0] = [0, 0, 0]
705
- colored_mask = colors[masks]
706
- alpha = 0.4
707
- segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
708
- segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
709
-
710
  # Add exclusion zone overlay if stereology is enabled
711
  if use_stereology:
712
  segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
@@ -729,7 +700,7 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
729
  pack_array(masks),
730
  pack_array(processed_image_np),
731
  confluency,
732
- gr.update(value=recommend_min),
733
  pack_array(raw_image_np),
734
  )
735
 
@@ -744,17 +715,20 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
744
  None,
745
  None,
746
  0.0,
747
- gr.update(),
748
  None,
749
  )
750
 
751
 
752
  def run_viability(stored_masks, stored_image_np):
753
- """Run model-based viability classification. Returns overlay + counts + label_map."""
754
  if stored_masks is None or stored_image_np is None:
755
  return None, 0, 0, 0.0, "Please run segmentation first.", {}
 
 
 
756
  if VIABILITY_CLF is None:
757
- return None, 0, 0, 0.0, "No viability model found. Add viability_clf.pkl and viability_scaler.pkl to the app directory.", {}
 
758
 
759
  masks = unpack_array(stored_masks)
760
  image_np = unpack_array(stored_image_np)
@@ -773,14 +747,20 @@ def run_viability(stored_masks, stored_image_np):
773
 
774
 
775
  def pack_array(arr):
776
- pil = Image.fromarray(arr.astype(np.uint8))
 
 
 
 
 
777
  buf = io.BytesIO()
778
- pil.save(buf, format="PNG")
779
- return buf.getvalue()
780
 
781
 
782
  def unpack_array(data):
783
- return np.array(Image.open(io.BytesIO(data)))
 
784
 
785
 
786
  def save_tab_result(cell_count, confluency, viab_percent):
@@ -820,29 +800,12 @@ def compute_summary(r1, r2, r3, r4):
820
  return avg_count, avg_conf, avg_viab, "\n".join(lines)
821
 
822
 
823
- # ---------------------------------------------------------------------------
824
  # Training data export β€” feature extraction per cell
825
- # ---------------------------------------------------------------------------
826
 
827
  def extract_cell_features(image_np, masks):
828
- """
829
- For every segmented cell, extract a fixed feature vector from the pixels
830
- inside its mask. Returns a list of dicts, one per cell.
831
-
832
- Features:
833
- RGB channels β€” mean_r, mean_g, mean_b, std_r, std_g, std_b
834
- HSV channels β€” mean_h, mean_s, mean_v, std_s, std_v
835
- Ratios β€” blue_red_ratio, blue_green_ratio, rg_ratio
836
- Morphology β€” area_px, circularity
837
- Centre/edge profile β€” inner_brightness, peak_brightness,
838
- bright_spot_fraction, ring_darkness,
839
- centre_periphery_ratio, brightness_std_normalised
840
-
841
- Profile zones are tuned to hemocytometer live-cell morphology:
842
- a small intense specular highlight at the centre surrounded by a dark
843
- navy membrane ring. Dead cells are pale blue-grey blobs with no ring
844
- and no bright spot.
845
- """
846
  if len(image_np.shape) == 2:
847
  image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
848
  elif image_np.shape[2] == 4:
@@ -1003,9 +966,8 @@ def prepare_export(stored_masks, stored_image, threshold_bias):
1003
  return path, msg
1004
 
1005
 
1006
- # ---------------------------------------------------------------------------
1007
  # Tab builder
1008
- # ---------------------------------------------------------------------------
1009
 
1010
  def draw_polygon_overlay(image_pil, points):
1011
  """
@@ -1063,14 +1025,12 @@ def clear_crop_points(image_pil):
1063
 
1064
 
1065
 
1066
- # ---------------------------------------------------------------------------
1067
  # Label correction grid
1068
- # ---------------------------------------------------------------------------
1069
 
1070
- THUMB_SIZE = 80 # each cell thumbnail is THUMB_SIZE Γ— THUMB_SIZE px
1071
- GRID_COLS = 8 # thumbnails per row
1072
- BORDER = 4 # coloured border thickness in px
1073
- LABEL_H = 16 # height of the text label strip at the bottom of each thumb
1074
 
1075
  def _crop_cell_thumb(image_np, masks, cid):
1076
  """
@@ -1102,14 +1062,7 @@ def _crop_cell_thumb(image_np, masks, cid):
1102
 
1103
 
1104
  def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
1105
- """
1106
- Render all cell thumbnails into a single PIL image grid.
1107
- Each thumbnail has a coloured border: green=live(0), red=dead(1).
1108
- A small number in the corner identifies the cell_id.
1109
-
1110
- Returns the PIL grid image.
1111
- Cell order in the grid matches the order of labelled_features.
1112
- """
1113
  if not labelled_features:
1114
  placeholder = Image.fromarray(
1115
  np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
@@ -1192,6 +1145,197 @@ def toggle_cell_label(labelled_features, image_np, masks, raw_image_np, evt: gr.
1192
  return grid, updated, f"Tapped cell #{cell['cell_id']} β†’ {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1193
 
1194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1195
  def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1196
  """Export CSV using labelled_features with any manual corrections applied."""
1197
  if stored_masks is None or stored_image is None:
@@ -1256,14 +1400,29 @@ def build_tab(tab_index, masks_state, image_state, result_state):
1256
  value="Hemocytometer Model"
1257
  )
1258
 
 
 
 
 
 
 
 
1259
  min_size_slider = gr.Slider(
1260
  minimum=0,
1261
  maximum=500,
1262
  value=0,
1263
  step=10,
1264
- label="Minimum Cell Size (pixels). Leave at zero for automated recommendation",
 
 
 
1265
  )
1266
 
 
 
 
 
 
1267
  max_size_slider = gr.Slider(
1268
  minimum=0,
1269
  maximum=10000,
@@ -1311,6 +1470,20 @@ def build_tab(tab_index, masks_state, image_state, result_state):
1311
  info="Width of top exclusion zone"
1312
  )
1313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1314
  segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
1315
 
1316
  with gr.Column():
@@ -1319,6 +1492,30 @@ def build_tab(tab_index, masks_state, image_state, result_state):
1319
  overlay_out = gr.Image(type="pil", label="Segmentation Result")
1320
  info_out = gr.Textbox(label="Processing Info", lines=4)
1321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1322
  with gr.Group(visible=False) as viability_section:
1323
  gr.Markdown("### Viability Assessment (Trypan Blue)")
1324
 
@@ -1405,9 +1602,27 @@ def build_tab(tab_index, masks_state, image_state, result_state):
1405
  segment_btn.click(
1406
  fn=run_segmentation,
1407
  inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
 
1408
  use_stereo, left_excl, top_excl, crop_points_state],
1409
  outputs=[cell_count_out, overlay_out, info_out, viability_section,
1410
- masks_state, image_state, confluency_out, min_size_slider, raw_image_state]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1411
  )
1412
 
1413
  # ---- Run Viability button -------------------------------------------
@@ -1496,9 +1711,8 @@ def build_tab(tab_index, masks_state, image_state, result_state):
1496
 
1497
 
1498
 
1499
- # ---------------------------------------------------------------------------
1500
  # Gradio interface
1501
- # ---------------------------------------------------------------------------
1502
  with gr.Blocks(
1503
  title="CellposeCellCounter",
1504
  theme=gr.themes.Soft(),
@@ -1540,6 +1754,12 @@ with gr.Blocks(
1540
  outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1541
  )
1542
 
 
 
 
 
 
 
1543
 
1544
 
1545
  if __name__ == "__main__":
 
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
 
12
  import csv
13
  import joblib
14
  import os
15
+ import sys
16
+ import json
17
+
18
+ from error_labeling_tab import build_error_labeling_tab
19
+
20
+ # error_detection/'s modules (correct.py, inference.py, model.py, ...) use bare
21
+ # intra-package imports (e.g. "from dataset import ...") and assume their own
22
+ # directory is on sys.path, per how their test suite is run
23
+ # (track_a/TrackA_SelfCorrection_ImplementationGuide.md, Step 7-9).
24
+ _ERROR_DETECTION_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "track_a", "error_detection")
25
+ if _ERROR_DETECTION_DIR not in sys.path:
26
+ sys.path.insert(0, _ERROR_DETECTION_DIR)
27
+
28
+ HF_REPO_ID = "myang4218/cellposemodel"
29
+ HF_REPO_ID2 = "LiangLabUMB/viability_model"
30
+ HF_REPO_CPSAM = "mouseland/cellpose-sam"
31
  MODEL_OPTIONS = {
32
  "Hemocytometer Model": "hemocytometermodel.npy",
33
+ "General Model": "generalmodel.npy",
34
+ "Cellpose SAMv2": "cpsam_v2",
35
+ }
36
+ MODEL_REPOS = {
37
+ "hemocytometermodel.npy": HF_REPO_ID,
38
+ "generalmodel.npy": HF_REPO_ID,
39
+ "cpsam_v2": HF_REPO_CPSAM,
40
  }
41
 
42
+
43
+ # Viability classifier is loaded LAZILY (only when viability is first requested).
44
+ # Loading XGBoost / the pickled model at module level initialises a CUDA context
45
+ # in the main process, which conflicts with ZeroGPU's per-call GPU allocation and
46
+ # leaves segmentation requests stuck in the GPU queue. Keeping the main process
47
+ # CUDA-clean until viability is explicitly called avoids this.
48
+ VIABILITY_CLF = None
49
+ VIABILITY_SCALER = None
50
+ _VIABILITY_LOADED = False
51
+ _VIABILITY_ERROR = None
52
+
53
+
54
+ def ensure_viability_loaded():
55
+ """
56
+ Load the XGBoost classifier + scaler on first use, pinned to CPU.
57
+
58
+ XGBoost initialises a CUDA context if it thinks a GPU is available. Once
59
+ that context exists in the main process, every subsequent ZeroGPU fork for
60
+ run_segmentation inherits a dirty CUDA state and hangs in the GPU queue.
61
+ We force the loaded booster onto CPU so it never touches CUDA β€” viability
62
+ inference on ~20 features per cell is trivially fast on CPU anyway.
63
+ """
64
+ global VIABILITY_CLF, VIABILITY_SCALER, _VIABILITY_LOADED, _VIABILITY_ERROR
65
+ if _VIABILITY_LOADED:
66
+ return
67
+ try:
68
+ import xgboost
69
+ _clf_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_clf.pkl")
70
+ _scaler_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_scaler.pkl")
71
+ VIABILITY_CLF = joblib.load(_clf_path)
72
+ VIABILITY_SCALER = joblib.load(_scaler_path)
73
+
74
+ # Force CPU inference so XGBoost never initialises a CUDA context.
75
+ try:
76
+ VIABILITY_CLF.set_params(device="cpu", predictor="cpu_predictor", tree_method="hist")
77
+ except Exception:
78
+ pass
79
+ try:
80
+ booster = VIABILITY_CLF.get_booster()
81
+ booster.set_param({"device": "cpu", "predictor": "cpu_predictor"})
82
+ except Exception:
83
+ pass
84
+
85
+ _VIABILITY_LOADED = True
86
+ print("βœ“ Viability classifier loaded (lazy, CPU-pinned).")
87
+ except Exception as e:
88
+ _VIABILITY_ERROR = str(e)
89
+ print(f"Viability classifier failed to load: {e}")
90
+
91
+
92
+ # Self-correction (Track A4, TrackA_SelfCorrection_ImplementationGuide.md Step
93
+ # 7-9) model is loaded LAZILY, same rationale as the viability classifier:
94
+ # keep the main process CUDA-clean for ZeroGPU. HybridErrorNet is a small
95
+ # 5x96x96 CNN + MLP β€” batched CPU inference over every instance in an image is
96
+ # fast, so this never needs @spaces.GPU.
97
+ SELF_CORRECTION_MODEL = None
98
+ SELF_CORRECTION_TEMP = 1.0
99
+ SELF_CORRECTION_RUN_FN = None
100
+ _SELF_CORRECTION_LOADED = False
101
+ _SELF_CORRECTION_ERROR = None
102
+
103
+
104
+ def ensure_self_correction_loaded():
105
+ global SELF_CORRECTION_MODEL, SELF_CORRECTION_TEMP, SELF_CORRECTION_RUN_FN
106
+ global _SELF_CORRECTION_LOADED, _SELF_CORRECTION_ERROR
107
+ if _SELF_CORRECTION_LOADED:
108
+ return
109
+ try:
110
+ import torch
111
+ from model import HybridErrorNet
112
+ from inference import run_self_correction
113
+
114
+ ckpt_path = os.path.join(_ERROR_DETECTION_DIR, "checkpoints", "best.pt")
115
+ ckpt = torch.load(ckpt_path, map_location="cpu")
116
+ model = HybridErrorNet()
117
+ model.load_state_dict(ckpt["model_state"])
118
+ model.eval()
119
+ SELF_CORRECTION_MODEL = model
120
+ SELF_CORRECTION_RUN_FN = run_self_correction
121
+
122
+ # Temperature from calibrate.py's --val-dir run against real_val/ (see
123
+ # calibration.json's n_val_examples_by_class for how much real data it
124
+ # was fit on β€” merge is the scarcest class, hence the extra-cautious
125
+ # default merge threshold in inference.DEFAULT_THRESHOLDS).
126
+ calib_path = os.path.join(_ERROR_DETECTION_DIR, "checkpoints", "calibration.json")
127
+ if os.path.exists(calib_path):
128
+ with open(calib_path) as f:
129
+ SELF_CORRECTION_TEMP = json.load(f)["temperature"]
130
+
131
+ _SELF_CORRECTION_LOADED = True
132
+ # Plain ASCII: a checkmark here previously raised UnicodeEncodeError on
133
+ # a cp1252 (default Windows console) stdout, which β€” since it ran
134
+ # inside this try block β€” got mis-caught below as a load failure even
135
+ # though the model had already loaded successfully.
136
+ print(f"Self-correction model loaded (lazy, CPU-pinned, T={SELF_CORRECTION_TEMP:.2f}).")
137
+ except Exception as e:
138
+ _SELF_CORRECTION_ERROR = str(e)
139
+ print(f"Self-correction model failed to load: {e}")
140
+
141
+
142
+ # mobile safe resize limits
143
  MAX_SIDE = 1024
144
  MAX_PIXELS = 1024 * 1024
145
 
146
 
147
  def safe_resize(image_np):
148
+
 
 
 
149
  h, w = image_np.shape[:2]
150
  total = h * w
151
 
 
253
 
254
 
255
  def classify_cells_by_model(image_np, masks):
256
+
 
 
 
 
257
  import numpy as np
258
  cell_ids = np.unique(masks)
259
  cell_ids = cell_ids[cell_ids > 0]
 
284
  return dead, alive, overlay, label_map
285
 
286
 
287
+ def build_colored_mask_overlay(image_np, masks, alpha=0.4):
288
+ """Deterministic per-instance-ID random color blended over the image β€”
289
+ the exact rendering run_segmentation uses for its 'Segmentation Result'
290
+ view. Factored out here so the self-correction 'before'/'after' panels
291
+ can produce a visually consistent overlay from any given masks array."""
292
+ overlay = image_np.copy().astype(np.float32)
293
+ if masks.max() > 0:
294
+ np.random.seed(42) # same seed as run_segmentation -> stable colors per ID
295
+ colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
296
+ colors[0] = [0, 0, 0]
297
+ colored_mask = colors[masks]
298
+ overlay = (1 - alpha) * overlay + alpha * colored_mask
299
+ return np.clip(overlay, 0, 255).astype(np.uint8)
300
+
301
+
302
  def draw_viability_overlay(image_np, masks, label_map):
303
+
 
 
 
 
304
  overlay = image_np.copy()
305
  cell_ids = np.unique(masks)
306
  cell_ids = cell_ids[cell_ids > 0]
 
328
  (0, 0, 0), -1)
329
  cv2.putText(overlay, label_str,
330
  (cx - tw//2, cy + th//2),
331
+ font, font_scale, color, thickness, cv2.LINE_AA)
332
  return overlay
333
 
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
 
337
  def measure_confluency(masks, image_np):
 
455
 
456
 
457
  def toggle_stereological_mode(use_stereology):
 
458
  return gr.update(visible=use_stereology)
459
 
460
 
461
  def update_exclusion_preview(image, left_width, top_width):
 
462
  if image is None:
463
  return None
464
 
 
467
  return Image.fromarray(overlay)
468
 
469
 
 
470
  # Patch segmentation
471
+
472
  PATCH_SIZE = 512 # target patch side length
473
  PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
474
  MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
 
556
  return renumbered
557
 
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  def run_segmentation_patched(image_np, model_filename):
560
  """
561
+ Split image into overlapping patches, run Cellpose sequentially on each,
562
  then stitch back into a single full-resolution mask.
563
+
564
+ NOTE: ThreadPoolExecutor was removed because ZeroGPU allocates the GPU
565
+ only to the main thread. Worker threads spawned by ThreadPoolExecutor run
566
+ outside the GPU context, causing a 3-minute timeout waiting for a GPU
567
+ grant that never arrives for the worker threads.
568
+ Sequential patching within the @spaces.GPU context is correct and fast.
569
  """
570
  h, w = image_np.shape[:2]
571
+ repo = MODEL_REPOS.get(model_filename, HF_REPO_ID)
572
+ # hf_hub_download caches the weights file on disk, so this only hits the
573
+ # network once β€” subsequent calls read from the local cache.
574
+ model_path = hf_hub_download(repo_id=repo, filename=model_filename)
575
+
576
+ # IMPORTANT: build the CellposeModel fresh on every call. ZeroGPU allocates
577
+ # a DIFFERENT physical GPU for each @spaces.GPU invocation, so a model cached
578
+ # from a previous call holds a CUDA context bound to a now-deallocated GPU.
579
+ # Reusing it makes the next request hang in the GPU queue. Constructing the
580
+ # model is cheap because the weights are already on local disk.
581
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
582
 
583
  # Small images: no benefit from patching
584
  if max(h, w) <= MIN_PATCH_DIM * 2:
585
+ mask, _, _ = model.eval(image_np, diameter=None)
586
+ return mask, 1
587
 
588
  patches = _split_patches(image_np)
589
  n_patches = len(patches)
590
 
591
+ patch_results = []
592
+ for patch, row_start, col_start in patches:
593
+ mask_patch, _, _ = model.eval(patch, diameter=None)
594
+ patch_results.append((mask_patch, row_start, col_start))
 
 
 
 
 
 
 
 
 
 
 
 
 
595
 
596
  full_mask = _merge_patch_masks(patch_results, h, w)
597
  return full_mask, n_patches
 
599
 
600
  @spaces.GPU
601
  def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
602
+ use_min_filter, use_max_filter,
603
  use_stereology, left_exclusion, top_exclusion,
604
  crop_points=None):
605
  image_np = np.array(image)
 
643
  print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
644
  print("max:", sizes.max() if len(sizes) > 0 else 0)
645
 
646
+ # Compute recommendation from RAW masks (always shown, never auto-applied)
647
  recommend_min = rec_min_size(masks_raw)
648
 
649
+ # Apply filters only if their checkboxes are enabled
 
 
 
650
  masks = masks_raw.copy()
651
  removed_small = 0
652
  removed_large = 0
653
 
654
+ if use_min_filter and int(min_cell_size) > 0:
655
+ masks, removed_small = filter_mask_by_size(masks, int(min_cell_size))
656
 
657
+ if use_max_filter and max_cell_size > 0:
658
  masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
659
 
660
  # Apply stereological exclusion if enabled
 
666
 
667
  filter_msg = ""
668
  if removed_small:
669
+ filter_msg += f"Removed {removed_small} small objects (< {int(min_cell_size)} pixels).\n"
670
  if removed_large:
671
  filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
672
  if use_stereology and excluded_count > 0:
 
676
  confluency = measure_confluency(masks, processed_image_np)
677
 
678
  # Create a basic segmentation overlay (without viability)
679
+ segmentation_overlay = build_colored_mask_overlay(processed_image_np, masks)
680
+
 
 
 
 
 
 
 
 
681
  # Add exclusion zone overlay if stereology is enabled
682
  if use_stereology:
683
  segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
 
700
  pack_array(masks),
701
  pack_array(processed_image_np),
702
  confluency,
703
+ f"Recommended minimum: **{recommend_min} px** (25th percentile of detected cell sizes)",
704
  pack_array(raw_image_np),
705
  )
706
 
 
715
  None,
716
  None,
717
  0.0,
718
+ "",
719
  None,
720
  )
721
 
722
 
723
  def run_viability(stored_masks, stored_image_np):
 
724
  if stored_masks is None or stored_image_np is None:
725
  return None, 0, 0, 0.0, "Please run segmentation first.", {}
726
+
727
+ # Lazy-load the classifier the first time viability is requested
728
+ ensure_viability_loaded()
729
  if VIABILITY_CLF is None:
730
+ err = _VIABILITY_ERROR or "unknown error"
731
+ return None, 0, 0, 0.0, f"Viability model failed to load: {err}", {}
732
 
733
  masks = unpack_array(stored_masks)
734
  image_np = unpack_array(stored_image_np)
 
747
 
748
 
749
  def pack_array(arr):
750
+ """
751
+ Serialise a numpy array to a base64 string for gr.State storage.
752
+ Uses numpy .npy format (preserves int32 exactly, no 255 truncation)
753
+ encoded as base64 so it is a plain Python string β€” safe for ZeroGPU
754
+ state serialisation which cannot handle raw bytes objects.
755
+ """
756
  buf = io.BytesIO()
757
+ np.save(buf, arr)
758
+ return base64.b64encode(buf.getvalue()).decode("ascii")
759
 
760
 
761
  def unpack_array(data):
762
+ buf = io.BytesIO(base64.b64decode(data))
763
+ return np.load(buf, allow_pickle=False)
764
 
765
 
766
  def save_tab_result(cell_count, confluency, viab_percent):
 
800
  return avg_count, avg_conf, avg_viab, "\n".join(lines)
801
 
802
 
803
+
804
  # Training data export β€” feature extraction per cell
805
+
806
 
807
  def extract_cell_features(image_np, masks):
808
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
809
  if len(image_np.shape) == 2:
810
  image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
811
  elif image_np.shape[2] == 4:
 
966
  return path, msg
967
 
968
 
969
+
970
  # Tab builder
 
971
 
972
  def draw_polygon_overlay(image_pil, points):
973
  """
 
1025
 
1026
 
1027
 
 
1028
  # Label correction grid
 
1029
 
1030
+ THUMB_SIZE = 80
1031
+ GRID_COLS = 10
1032
+ BORDER = 4
1033
+ LABEL_H = 16
1034
 
1035
  def _crop_cell_thumb(image_np, masks, cid):
1036
  """
 
1062
 
1063
 
1064
  def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
1065
+
 
 
 
 
 
 
 
1066
  if not labelled_features:
1067
  placeholder = Image.fromarray(
1068
  np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
 
1145
  return grid, updated, f"Tapped cell #{cell['cell_id']} β†’ {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1146
 
1147
 
1148
+ # ---------------------------------------------------------------------------
1149
+ # Track A4 β€” self-correction review UI
1150
+ # ---------------------------------------------------------------------------
1151
+
1152
+ SELF_CORRECTION_CLASS_COLORS = {
1153
+ "split": (255, 165, 0), # orange
1154
+ "merge": (220, 50, 50), # red
1155
+ "debris": (150, 50, 220), # purple
1156
+ }
1157
+ SC_LABEL_H = 30 # taller than the other grids' LABEL_H β€” two lines of text
1158
+ SELF_CORRECTION_REPAIR_TEXT = {
1159
+ "removed": "removed",
1160
+ "watershed_split": "split apart",
1161
+ "merged_with_neighbor": "merged",
1162
+ "already_resolved": "resolved w/ sibling",
1163
+ "flagged_for_review": "needs review",
1164
+ }
1165
+
1166
+
1167
+ def draw_self_correction_overlay(base_image_np, masks_before, logs):
1168
+ """Enumerates only the flagged (non-'correct') instances, colored by
1169
+ predicted class and labeled with the model's calibrated confidence β€”
1170
+ mirrors draw_viability_overlay's contour+label convention
1171
+ (app_final.py:226) so a flagged cell can be matched back to its native
1172
+ location. `base_image_np` is typically build_colored_mask_overlay's
1173
+ output (the same "Segmentation Result" rendering), so the outlines draw
1174
+ on top of the existing segmentation view rather than the raw photo.
1175
+ `masks_before` is the pre-correction mask array (the crop location a
1176
+ repaired/removed instance had before this run)."""
1177
+ overlay = base_image_np.copy()
1178
+ for log in logs:
1179
+ color = SELF_CORRECTION_CLASS_COLORS.get(log.predicted_class, (200, 200, 200))
1180
+ cell_mask = (masks_before == log.instance_id).astype(np.uint8)
1181
+ contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
1182
+ cv2.drawContours(overlay, contours, -1, color, thickness=2)
1183
+
1184
+ ys, xs = np.where(cell_mask)
1185
+ if len(ys) == 0:
1186
+ continue
1187
+ cx, cy = int(xs.mean()), int(ys.mean())
1188
+ label_str = f"{log.predicted_class[0].upper()} {log.confidence:.0%}"
1189
+ font, font_scale, thickness = cv2.FONT_HERSHEY_SIMPLEX, 0.35, 1
1190
+ (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
1191
+ cv2.rectangle(overlay, (cx - tw // 2 - 1, cy - th // 2 - 1),
1192
+ (cx + tw // 2 + 1, cy + th // 2 + 1), (0, 0, 0), -1)
1193
+ cv2.putText(overlay, label_str, (cx - tw // 2, cy + th // 2),
1194
+ font, font_scale, color, thickness, cv2.LINE_AA)
1195
+ return overlay
1196
+
1197
+
1198
+ def build_self_correction_grid(image_np, masks_before, logs):
1199
+ """Thumbnail grid of every flagged instance β€” mirrors build_correction_grid
1200
+ (app_final.py:996) but reports the model's predicted class + calibrated
1201
+ confidence + repair outcome (read-only review, not tap-to-toggle, since
1202
+ the repair itself is auto-applied or explicitly left for review)."""
1203
+ if not logs:
1204
+ return Image.fromarray(np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8))
1205
+
1206
+ n_cols = GRID_COLS
1207
+ n_rows = (len(logs) + n_cols - 1) // n_cols
1208
+ cell_w = THUMB_SIZE + 2 * BORDER
1209
+ cell_h = THUMB_SIZE + 2 * BORDER + SC_LABEL_H
1210
+
1211
+ grid = Image.new("RGB", (n_cols * cell_w, n_rows * cell_h), (30, 30, 30))
1212
+ draw = ImageDraw.Draw(grid)
1213
+
1214
+ for idx, log in enumerate(logs):
1215
+ color = SELF_CORRECTION_CLASS_COLORS.get(log.predicted_class, (200, 200, 200))
1216
+ thumb = _crop_cell_thumb(image_np, masks_before, log.instance_id)
1217
+
1218
+ col, row = idx % n_cols, idx // n_cols
1219
+ x0, y0 = col * cell_w, row * cell_h
1220
+
1221
+ draw.rectangle([x0, y0, x0 + cell_w - 1, y0 + cell_h - 1], outline=color, width=BORDER)
1222
+ grid.paste(thumb, (x0 + BORDER, y0 + BORDER))
1223
+
1224
+ strip_y = y0 + BORDER + THUMB_SIZE
1225
+ draw.rectangle([x0, strip_y, x0 + cell_w - 1, y0 + cell_h - 1], fill=(20, 20, 20))
1226
+ repaired = log.repair_applied not in ("flagged_for_review", "already_resolved")
1227
+ status_mark = "βœ“" if repaired else "βš‘" # checkmark / flag
1228
+ status_text = SELF_CORRECTION_REPAIR_TEXT.get(log.repair_applied, log.repair_applied)
1229
+ draw.text((x0 + BORDER + 2, strip_y + 1),
1230
+ f"#{log.instance_id} {log.predicted_class[:1].upper()} {log.confidence:.0%}",
1231
+ fill=color)
1232
+ draw.text((x0 + BORDER + 2, strip_y + 12),
1233
+ f"{status_mark} {status_text}",
1234
+ fill=color)
1235
+
1236
+ return grid
1237
+
1238
+
1239
+ def build_self_correction_details(logs):
1240
+ """Full-text per-instance breakdown (class, confidence, outcome, and β€”
1241
+ critically β€” *why* an instance was left for review) as a markdown table.
1242
+ The thumbnail grid's text is too small to fit a real explanation; this is
1243
+ the readable counterpart, directly answering 'why did/didn't this cell
1244
+ get auto-fixed'."""
1245
+ if not logs:
1246
+ return ""
1247
+ rows = ["| # | Class | Confidence | Outcome | Reason |", "|---|---|---|---|---|"]
1248
+ for log in logs:
1249
+ outcome = SELF_CORRECTION_REPAIR_TEXT.get(log.repair_applied, log.repair_applied)
1250
+ reason = log.reason or "β€”"
1251
+ rows.append(f"| {log.instance_id} | {log.predicted_class} | {log.confidence:.0%} | {outcome} | {reason} |")
1252
+ return "\n".join(rows)
1253
+
1254
+
1255
+ def run_self_correction_step(use_correction, stored_masks, stored_image):
1256
+ """Called after run_segmentation (opt-in, off by default per the guide's
1257
+ Step 9). Classifies every instance and auto-applies the matching CPU
1258
+ repair above its confidence threshold (error_detection/inference.py's
1259
+ DEFAULT_THRESHOLDS: split/debris ~75%, merge kept elevated at 90% since
1260
+ real merge examples are still scarce). Split repair additionally passes
1261
+ through a geometric gate (flat-edge-fraction + combined-area check) that
1262
+ tells a real over-segmentation split apart from two genuinely separate,
1263
+ correctly-segmented cells that merely touch. Anything left unresolved is
1264
+ reported for manual review rather than guessed at.
1265
+
1266
+ Updates the main 'Segmentation Result' image in place with colored
1267
+ outlines on every flagged cell, and produces a second 'Corrected
1268
+ Segmentation' panel rendering the masks after repair (splits merged back
1269
+ into one color, debris removed, merges split into distinct colors) β€”
1270
+ the before/after pair needed to actually judge repair quality."""
1271
+ hidden_after = gr.update(value=None, visible=False)
1272
+ if not use_correction or stored_masks is None or stored_image is None:
1273
+ return (stored_masks, gr.update(), hidden_after, gr.update(value=None, visible=False),
1274
+ gr.update(value="", visible=False), "", [], None,
1275
+ gr.update(), gr.update(visible=False))
1276
+
1277
+ masks_before = unpack_array(stored_masks)
1278
+ image_np = unpack_array(stored_image)
1279
+
1280
+ ensure_self_correction_loaded()
1281
+ if SELF_CORRECTION_MODEL is None:
1282
+ msg = f"Self-correction model unavailable ({_SELF_CORRECTION_ERROR}) β€” skipped."
1283
+ return (stored_masks, gr.update(), hidden_after, gr.update(value=None, visible=False),
1284
+ gr.update(value=msg, visible=True), "", [], None,
1285
+ gr.update(), gr.update(visible=True))
1286
+
1287
+ corrected_masks, logs = SELF_CORRECTION_RUN_FN(
1288
+ image_np, masks_before, SELF_CORRECTION_MODEL, temperature=SELF_CORRECTION_TEMP
1289
+ )
1290
+
1291
+ n_repaired = sum(1 for log in logs if log.repair_applied not in ("flagged_for_review", "already_resolved"))
1292
+ n_flagged = sum(1 for log in logs if log.repair_applied == "flagged_for_review")
1293
+ if logs:
1294
+ msg = (f"Self-correction: {len(logs)} instance(s) flagged β€” "
1295
+ f"{n_repaired} auto-repaired, {n_flagged} left for manual review "
1296
+ f"(split repairs are additionally checked against cell shape β€” "
1297
+ f"see the table below for why any cell wasn't auto-fixed). "
1298
+ f"'βš‘ review' cells in the grid were not modified.")
1299
+ else:
1300
+ msg = "Self-correction: no split/merge/debris errors flagged above threshold."
1301
+
1302
+ before_overlay = build_colored_mask_overlay(image_np, masks_before)
1303
+ annotated_before = draw_self_correction_overlay(before_overlay, masks_before, logs)
1304
+ after_overlay = build_colored_mask_overlay(image_np, corrected_masks)
1305
+ grid = build_self_correction_grid(image_np, masks_before, logs)
1306
+ details_md = build_self_correction_details(logs)
1307
+ new_cell_count = int(len(np.unique(corrected_masks)) - 1)
1308
+
1309
+ return (pack_array(corrected_masks),
1310
+ gr.update(value=Image.fromarray(annotated_before)),
1311
+ gr.update(value=Image.fromarray(after_overlay), visible=True),
1312
+ gr.update(value=grid, visible=True),
1313
+ gr.update(value=msg, visible=True),
1314
+ details_md, logs, stored_masks,
1315
+ new_cell_count, gr.update(visible=True))
1316
+
1317
+
1318
+ def revert_self_correction_step(pre_correction_masks, stored_image):
1319
+ """Restores the pre-correction masks saved by run_self_correction_step,
1320
+ and resets the Segmentation Result view + hides the correction panels."""
1321
+ if pre_correction_masks is None:
1322
+ return (pre_correction_masks, gr.update(), gr.update(value=None, visible=False),
1323
+ gr.update(), gr.update(value=None, visible=False), gr.update(value="", visible=False),
1324
+ gr.update(value="Nothing to revert.", visible=True))
1325
+ masks = unpack_array(pre_correction_masks)
1326
+ new_cell_count = int(len(np.unique(masks)) - 1)
1327
+ plain_overlay = None
1328
+ if stored_image is not None:
1329
+ image_np = unpack_array(stored_image)
1330
+ plain_overlay = Image.fromarray(build_colored_mask_overlay(image_np, masks))
1331
+ return (pre_correction_masks, new_cell_count,
1332
+ gr.update(value=None, visible=False),
1333
+ gr.update(value=plain_overlay) if plain_overlay is not None else gr.update(),
1334
+ gr.update(value=None, visible=False),
1335
+ gr.update(value="", visible=False),
1336
+ gr.update(value="Self-correction reverted β€” original segmentation restored.", visible=True))
1337
+
1338
+
1339
  def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1340
  """Export CSV using labelled_features with any manual corrections applied."""
1341
  if stored_masks is None or stored_image is None:
 
1400
  value="Hemocytometer Model"
1401
  )
1402
 
1403
+ gr.Markdown("### Size Filters")
1404
+
1405
+ use_min_filter = gr.Checkbox(
1406
+ label="Enable minimum size filter",
1407
+ value=False,
1408
+ info="Remove objects smaller than the threshold below"
1409
+ )
1410
  min_size_slider = gr.Slider(
1411
  minimum=0,
1412
  maximum=500,
1413
  value=0,
1414
  step=10,
1415
+ label="Minimum Cell Size (pixels)",
1416
+ )
1417
+ min_size_recommendation = gr.Markdown(
1418
+ value="*Run segmentation to see recommended minimum*",
1419
  )
1420
 
1421
+ use_max_filter = gr.Checkbox(
1422
+ label="Enable maximum size filter",
1423
+ value=False,
1424
+ info="Remove objects larger than the threshold below"
1425
+ )
1426
  max_size_slider = gr.Slider(
1427
  minimum=0,
1428
  maximum=10000,
 
1470
  info="Width of top exclusion zone"
1471
  )
1472
 
1473
+ gr.Markdown("### Automated Error Correction (Track A4 β€” experimental)")
1474
+ use_self_correction = gr.Checkbox(
1475
+ label="Run self-correction after segmentation",
1476
+ value=False,
1477
+ info=(
1478
+ "Auto-repairs high-confidence split/merge/debris errors "
1479
+ "(CPU only, no extra GPU cost β€” see the review grid below "
1480
+ "for what changed). Merge repair uses a conservative "
1481
+ "confidence threshold since real merge examples are still "
1482
+ "scarce; anything below threshold is left flagged for you "
1483
+ "to review instead of being auto-applied."
1484
+ ),
1485
+ )
1486
+
1487
  segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
1488
 
1489
  with gr.Column():
 
1492
  overlay_out = gr.Image(type="pil", label="Segmentation Result")
1493
  info_out = gr.Textbox(label="Processing Info", lines=4)
1494
 
1495
+ with gr.Group(visible=False) as self_correction_section:
1496
+ gr.Markdown(
1497
+ "### Self-Correction Review\n"
1498
+ "The **Segmentation Result** image above is outlined per flagged cell "
1499
+ "(orange=split Β· red=merge Β· purple=debris) once self-correction runs. "
1500
+ "Compare it against **Corrected Segmentation** below to judge repair quality β€” "
1501
+ "splits should merge back into one color, merges should split into two, "
1502
+ "debris should disappear."
1503
+ )
1504
+ self_correction_status = gr.Markdown(visible=False)
1505
+ corrected_overlay_out = gr.Image(
1506
+ type="pil", label="Corrected Segmentation (after auto-repair)", visible=False
1507
+ )
1508
+ self_correction_grid = gr.Image(
1509
+ type="pil",
1510
+ label="Flagged cells (orange=split Β· red=merge Β· purple=debris β€” βœ“ fixed / βš‘ needs review)",
1511
+ visible=False,
1512
+ )
1513
+ self_correction_details = gr.Markdown(visible=True)
1514
+ revert_self_correction_btn = gr.Button("↩ Revert self-correction", size="sm")
1515
+
1516
+ correction_log_state = gr.State(value=[])
1517
+ pre_correction_masks_state = gr.State(value=None)
1518
+
1519
  with gr.Group(visible=False) as viability_section:
1520
  gr.Markdown("### Viability Assessment (Trypan Blue)")
1521
 
 
1602
  segment_btn.click(
1603
  fn=run_segmentation,
1604
  inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1605
+ use_min_filter, use_max_filter,
1606
  use_stereo, left_excl, top_excl, crop_points_state],
1607
  outputs=[cell_count_out, overlay_out, info_out, viability_section,
1608
+ masks_state, image_state, confluency_out, min_size_recommendation, raw_image_state]
1609
+ ).then(
1610
+ # Opt-in step between segmentation and viability, per the guide's
1611
+ # Step 9 β€” off unless use_self_correction is checked.
1612
+ fn=run_self_correction_step,
1613
+ inputs=[use_self_correction, masks_state, image_state],
1614
+ outputs=[masks_state, overlay_out, corrected_overlay_out, self_correction_grid,
1615
+ self_correction_status, self_correction_details,
1616
+ correction_log_state, pre_correction_masks_state,
1617
+ cell_count_out, self_correction_section]
1618
+ )
1619
+
1620
+ revert_self_correction_btn.click(
1621
+ fn=revert_self_correction_step,
1622
+ inputs=[pre_correction_masks_state, image_state],
1623
+ outputs=[masks_state, cell_count_out, corrected_overlay_out,
1624
+ overlay_out, self_correction_grid, self_correction_details,
1625
+ self_correction_status]
1626
  )
1627
 
1628
  # ---- Run Viability button -------------------------------------------
 
1711
 
1712
 
1713
 
 
1714
  # Gradio interface
1715
+
1716
  with gr.Blocks(
1717
  title="CellposeCellCounter",
1718
  theme=gr.themes.Soft(),
 
1754
  outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1755
  )
1756
 
1757
+ # -------------------------------------------------------------------------
1758
+ # Tab 6 β€” Error Labeling (QA) β€” builds a real hand-labeled validation set
1759
+ # for error_detection/train.py --real-val-dir and calibrate.py --val-dir
1760
+ # -------------------------------------------------------------------------
1761
+ build_error_labeling_tab(MODEL_OPTIONS, run_segmentation, pack_array, unpack_array)
1762
+
1763
 
1764
 
1765
  if __name__ == "__main__":