LiangLabUMB commited on
Commit
3a1f83c
·
verified ·
1 Parent(s): d9b06a0

Upload 4 files

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. app.py +234 -68
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: CellposeCellCounter_Mobile_v3c
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: pink
 
1
  ---
2
+ title: CellposeCellCounter_Mobile
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: pink
app.py CHANGED
@@ -548,53 +548,77 @@ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
548
  id_offset += max_id + 1
549
 
550
  # --- Renumber to compact sequential IDs ---
 
 
 
 
 
 
 
551
  unique_ids = np.unique(full_mask)
552
  unique_ids = unique_ids[unique_ids > 0]
553
- renumbered = np.zeros_like(full_mask)
554
- for new_id, old_id in enumerate(unique_ids, start=1):
555
- renumbered[full_mask == old_id] = new_id
 
 
556
 
557
  return renumbered
558
 
559
 
560
  def _segment_patch(args):
561
- """Worker: run cellpose on a single patch. Called from a thread pool."""
562
- patch_np, row_start, col_start, model_filename, hf_repo = args
563
- # Each thread uses the shared loaded_models cache (GIL-safe for reads;
564
- # model.eval() releases the GIL during GPU work so threads overlap.)
565
- model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
566
- if model_filename in loaded_models:
567
- model = loaded_models[model_filename]
568
- else:
569
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
570
- loaded_models[model_filename] = model
571
 
 
 
 
 
 
 
572
  mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
573
  return mask, row_start, col_start
574
 
575
 
576
- @spaces.GPU(duration=30)
577
- def run_segmentation_patched(image_np, model_filename):
578
- """
579
- Split image into overlapping patches, run Cellpose on each in parallel,
580
- then stitch back into a single full-resolution mask.
581
-
582
- The @spaces.GPU decorator sits HERE rather than on run_segmentation because
583
- ZeroGPU quota is charged for the whole time the GPU is attached, and the
584
- caller does a lot of CPU-only work -- decoding a 12 MP photo, the perspective
585
- warp, size filtering, overlay rendering. Holding an A10G through all of that
586
- burnt visitors' daily quota on NumPy.
587
- Falls back to whole-image segmentation if the image is small enough
588
- that patching adds overhead without benefit.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  """
590
  _t_gpu = time.perf_counter()
591
  h, w = image_np.shape[:2]
592
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
593
- if model_filename in loaded_models:
594
- model = loaded_models[model_filename]
595
- else:
596
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
597
- loaded_models[model_filename] = model
598
 
599
  # Small images: no benefit from patching
600
  if max(h, w) <= MIN_PATCH_DIM * 2:
@@ -604,13 +628,10 @@ def run_segmentation_patched(image_np, model_filename):
604
  patches = _split_patches(image_np)
605
  n_patches = len(patches)
606
 
607
- # Build argument list for the thread pool
608
- args_list = [
609
- (patch, r, c, model_filename, HF_REPO_ID)
610
- for patch, r, c in patches
611
- ]
612
 
613
- patch_results = [] # (mask, row_start, col_start) in submission order
614
 
615
  # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
616
  with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
@@ -626,6 +647,34 @@ def run_segmentation_patched(image_np, model_filename):
626
  return full_mask, n_patches, time.perf_counter() - _t_gpu
627
 
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  def render_segmentation(base_masks, processed_image_np,
630
  use_stereology, left_exclusion, top_exclusion):
631
  """
@@ -661,16 +710,19 @@ def render_segmentation(base_masks, processed_image_np,
661
  return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
662
 
663
 
664
- def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
665
- use_stereology, left_exclusion, top_exclusion,
666
- crop_points=None, use_min_filter=False, use_max_filter=False):
667
- _t_start = time.perf_counter()
 
 
 
668
  image_np = np.array(image)
669
 
670
  # Crop BEFORE downscaling, so the ROI is sampled from the original pixels
671
- # rather than upscaled out of a ≤MAX_SIDE working copy. The crop points were
672
  # clicked on the full-resolution upload, so they are already in this space.
673
- # (Need ≥3 points for a polygon.)
674
  if crop_points and len(crop_points) >= 3:
675
  import json
676
  if len(crop_points) == 4:
@@ -682,7 +734,7 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
682
  image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
683
  image_np = np.array(image_pil_masked)
684
 
685
- # Cap the working image only after cropping — a small ROI now keeps its
686
  # native detail, and a large one is still bounded for segmentation.
687
  image_np = safe_resize(image_np)
688
 
@@ -690,21 +742,48 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
690
  # Must be taken after the crop so it stays index-compatible with the masks.
691
  raw_image_np = image_np.copy()
692
 
 
693
 
694
- try:
695
- model_filename = MODEL_OPTIONS[model_choice]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
 
697
- # Process image format to RGB
698
- if len(image_np.shape) == 2:
699
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
700
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
701
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
702
  else:
703
- processed_image_np = image_np
 
 
 
 
704
 
705
  # Run patch-parallel Cellpose segmentation
706
- masks_raw, n_patches, gpu_seconds = run_segmentation_patched(
707
- processed_image_np, model_filename)
 
 
 
708
 
709
  # Same single pass feeds the log line and the recommendation below;
710
  # this used to be computed from scratch twice.
@@ -1807,14 +1886,8 @@ def _crop_picker(label):
1807
  return st
1808
 
1809
 
1810
- def _segment_one(image, points, model_name, use_min, min_px,
1811
- use_stereo, left_pct, top_pct, want_viability):
1812
- """Segment (and optionally classify) a single image. Returns a dict."""
1813
- if image is None:
1814
- return None
1815
- out = run_segmentation(image, model_name, min_px, 10000,
1816
- use_stereo, left_pct, top_pct, points,
1817
- use_min, False)
1818
  (cell_count, seg_overlay, info_msg, _vis, packed_masks, packed_img,
1819
  confluency, _rec, packed_raw, _base, seg_seconds, _ov) = out
1820
  if packed_masks is None:
@@ -1827,6 +1900,7 @@ def _segment_one(image, points, model_name, use_min, min_px,
1827
  "alive": None, "dead": None, "viab": None, "viab_overlay": None}
1828
 
1829
  if want_viability:
 
1830
  v_overlay, alive, dead, viab_pct, _vinfo, lm = run_viability(
1831
  packed_masks, packed_img)
1832
  res.update(alive=alive, dead=dead, viab=viab_pct,
@@ -1834,6 +1908,75 @@ def _segment_one(image, points, model_name, use_min, min_px,
1834
  return res
1835
 
1836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1837
  def _build_correction(res):
1838
  """Per-cell thumbnails for manual live/dead correction.
1839
 
@@ -2021,20 +2164,43 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2021
  imgs, ptss = [i1, i2, i3, i4], [p1, p2, p3, p4]
2022
  t0 = time.time()
2023
  rows, results, notes = [], [], []
 
 
 
 
 
 
2024
  for k, (im, pts) in enumerate(zip(imgs, ptss), start=1):
2025
- blank = ["Square %d" % k, "—", "—", "—", "—"]
2026
  if im is None:
2027
- rows.append(blank); results.append(None)
2028
  continue
2029
  if not (pts and len(pts) >= 3):
2030
  # Deliberately skipped, not segmented whole-frame: the whole
2031
  # frame is a much larger area than one block, so counting it
2032
  # would silently corrupt the concentration.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2033
  rows.append(blank); results.append(None)
2034
- notes.append("Square %d: no block tapped, skipped" % k)
2035
  continue
2036
- r = _segment_one(im, pts, HEMO_MODEL, True, 0, True, 1, 1, True)
2037
- if r is None or "error" in r:
 
 
 
2038
  rows.append(blank); results.append(None)
2039
  notes.append("Square %d failed" % k)
2040
  continue
 
548
  id_offset += max_id + 1
549
 
550
  # --- Renumber to compact sequential IDs ---
551
+ # Lookup table, not a loop over ids. The old form was
552
+ # for new_id, old_id in enumerate(unique_ids, 1):
553
+ # renumbered[full_mask == old_id] = new_id
554
+ # i.e. one full-array scan PER CELL -- O(cells x pixels). At ~450 cells on a
555
+ # 1024 px mask that is ~950 M element ops (measured 158 ms vs 12 ms here),
556
+ # and it ran inside the @spaces.GPU window, so it burnt ZeroGPU quota doing
557
+ # CPU integer relabelling. Output is bit-identical.
558
  unique_ids = np.unique(full_mask)
559
  unique_ids = unique_ids[unique_ids > 0]
560
+ if unique_ids.size == 0:
561
+ return np.zeros_like(full_mask)
562
+ lut = np.zeros(int(full_mask.max()) + 1, dtype=full_mask.dtype)
563
+ lut[unique_ids] = np.arange(1, unique_ids.size + 1, dtype=full_mask.dtype)
564
+ renumbered = lut[full_mask]
565
 
566
  return renumbered
567
 
568
 
569
  def _segment_patch(args):
570
+ """Worker: run cellpose on a single patch. Called from a thread pool.
 
 
 
 
 
 
 
 
 
571
 
572
+ The model is built ONCE by the caller and handed in. It used to be looked up
573
+ here, which meant an hf_hub_download cache round-trip per patch (nine of them
574
+ on a 1024 px image) plus a check-then-set race on loaded_models between the
575
+ pool threads. Both were pure overhead inside the GPU window.
576
+ """
577
+ patch_np, row_start, col_start, model = args
578
  mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
579
  return mask, row_start, col_start
580
 
581
 
582
+ # ---------------------------------------------------------------------------
583
+ # ZeroGPU duration budget
584
+ #
585
+ # Quota is charged on the duration you REQUEST, not the time you actually use,
586
+ # and remaining quota is what sets your place in the queue -- so over-requesting
587
+ # is paid for twice, once in quota and again in waiting. Tune these two numbers
588
+ # from the "GPU x.xx s" figure the app already prints in the processing info box.
589
+ # ---------------------------------------------------------------------------
590
+ GPU_SETUP_S = 20 # model construction + GPU attach: paid ONCE per call
591
+ GPU_PER_IMAGE_S = 18 # marginal cost of each image in the batch
592
+ GPU_MAX_S = 180 # ceiling: ZeroGPU rejects very long requests outright
593
+
594
+
595
+ def _gpu_duration(images, model_filename):
596
+ """Dynamic duration, so a 1-image call does not reserve a 4-image budget."""
597
+ try:
598
+ n = max(1, len(images))
599
+ except TypeError:
600
+ n = 1
601
+ return int(min(GPU_MAX_S, GPU_SETUP_S + GPU_PER_IMAGE_S * n))
602
+
603
+
604
+ def _get_model(model_filename):
605
+ """Build (or reuse) the Cellpose model. Called once per GPU acquisition."""
606
+ if model_filename in loaded_models:
607
+ return loaded_models[model_filename]
608
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
609
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
610
+ loaded_models[model_filename] = model
611
+ return model
612
+
613
+
614
+ def _segment_image_with_model(image_np, model):
615
+ """Patch, segment and stitch ONE image using an already-built model.
616
+
617
+ Falls back to whole-image segmentation if the image is small enough that
618
+ patching adds overhead without benefit.
619
  """
620
  _t_gpu = time.perf_counter()
621
  h, w = image_np.shape[:2]
 
 
 
 
 
 
622
 
623
  # Small images: no benefit from patching
624
  if max(h, w) <= MIN_PATCH_DIM * 2:
 
628
  patches = _split_patches(image_np)
629
  n_patches = len(patches)
630
 
631
+ # The model goes in the args now -- see _segment_patch.
632
+ args_list = [(patch, r, c, model) for patch, r, c in patches]
 
 
 
633
 
634
+ patch_results = [] # (mask, row_start, col_start) in completion order
635
 
636
  # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
637
  with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
 
647
  return full_mask, n_patches, time.perf_counter() - _t_gpu
648
 
649
 
650
+ @spaces.GPU(duration=_gpu_duration)
651
+ def run_segmentation_patched_many(images, model_filename):
652
+ """Segment a LIST of images inside ONE ZeroGPU acquisition.
653
+
654
+ This is the point of the batching change. The four hemocytometer squares
655
+ used to take four separate GPU allocations, which meant four waits in the
656
+ ZeroGPU queue and four constructions of the Cellpose model -- module state
657
+ does not survive from one allocation to the next, so `loaded_models` never
658
+ actually helped across squares.
659
+
660
+ The decorator sits HERE rather than on run_segmentation because ZeroGPU
661
+ quota is charged for the whole time the GPU is attached, and the caller does
662
+ a lot of CPU-only work -- decoding a 12 MP photo, the perspective warp, size
663
+ filtering, overlay rendering. Holding an A10G through all of that burnt
664
+ visitors' daily quota on NumPy.
665
+
666
+ Returns a list of (mask, n_patches, gpu_seconds), one per input image.
667
+ """
668
+ model = _get_model(model_filename)
669
+ return [_segment_image_with_model(im, model) for im in images]
670
+
671
+
672
+ def run_segmentation_patched(image_np, model_filename):
673
+ """Single-image wrapper. One image, one GPU acquisition -- unchanged for the
674
+ comprehensive and confluency tabs, which segment one image at a time."""
675
+ return run_segmentation_patched_many([image_np], model_filename)[0]
676
+
677
+
678
  def render_segmentation(base_masks, processed_image_np,
679
  use_stereology, left_exclusion, top_exclusion):
680
  """
 
710
  return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
711
 
712
 
713
+ def _prep_for_segmentation(image, model_choice, crop_points):
714
+ """CPU-only preparation: crop / perspective-warp, downscale, RGB-normalise.
715
+
716
+ Split out of run_segmentation so a whole batch of images can be prepared
717
+ BEFORE a single GPU acquisition, instead of one acquisition per image.
718
+ Returns (processed_image_np, raw_image_np, model_filename).
719
+ """
720
  image_np = np.array(image)
721
 
722
  # Crop BEFORE downscaling, so the ROI is sampled from the original pixels
723
+ # rather than upscaled out of a <=MAX_SIDE working copy. The crop points were
724
  # clicked on the full-resolution upload, so they are already in this space.
725
+ # (Need >=3 points for a polygon.)
726
  if crop_points and len(crop_points) >= 3:
727
  import json
728
  if len(crop_points) == 4:
 
734
  image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
735
  image_np = np.array(image_pil_masked)
736
 
737
+ # Cap the working image only after cropping -- a small ROI now keeps its
738
  # native detail, and a large one is still bounded for segmentation.
739
  image_np = safe_resize(image_np)
740
 
 
742
  # Must be taken after the crop so it stays index-compatible with the masks.
743
  raw_image_np = image_np.copy()
744
 
745
+ model_filename = MODEL_OPTIONS[model_choice]
746
 
747
+ # Process image format to RGB
748
+ if len(image_np.shape) == 2:
749
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
750
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
751
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
752
+ else:
753
+ processed_image_np = image_np
754
+
755
+ return processed_image_np, raw_image_np, model_filename
756
+
757
+
758
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
759
+ use_stereology, left_exclusion, top_exclusion,
760
+ crop_points=None, use_min_filter=False, use_max_filter=False,
761
+ _prepped=None, _gpu_result=None, _t_start_override=None):
762
+ """Segment one image and build the full result tuple.
763
+
764
+ _prepped / _gpu_result / _t_start_override are used by the batched path
765
+ (_segment_many): they let the same post-processing tail run against work
766
+ that has ALREADY been prepared and segmented, so the four squares can share
767
+ one GPU acquisition. Left unset, behaviour is exactly as before.
768
+ """
769
+ _t_start = time.perf_counter() if _t_start_override is None else _t_start_override
770
 
771
+ try:
772
+ if _prepped is not None:
773
+ processed_image_np, raw_image_np, model_filename = _prepped
 
 
774
  else:
775
+ # Now inside the try: a warp failure used to propagate out of
776
+ # run_segmentation and take the whole four-square run down with it.
777
+ # It now fails that one square and leaves the others countable.
778
+ processed_image_np, raw_image_np, model_filename = _prep_for_segmentation(
779
+ image, model_choice, crop_points)
780
 
781
  # Run patch-parallel Cellpose segmentation
782
+ if _gpu_result is not None:
783
+ masks_raw, n_patches, gpu_seconds = _gpu_result
784
+ else:
785
+ masks_raw, n_patches, gpu_seconds = run_segmentation_patched(
786
+ processed_image_np, model_filename)
787
 
788
  # Same single pass feeds the log line and the recommendation below;
789
  # this used to be computed from scratch twice.
 
1886
  return st
1887
 
1888
 
1889
+ def _result_from_out(out, want_viability):
1890
+ """Turn the run_segmentation tuple into the dict the tabs consume."""
 
 
 
 
 
 
1891
  (cell_count, seg_overlay, info_msg, _vis, packed_masks, packed_img,
1892
  confluency, _rec, packed_raw, _base, seg_seconds, _ov) = out
1893
  if packed_masks is None:
 
1900
  "alive": None, "dead": None, "viab": None, "viab_overlay": None}
1901
 
1902
  if want_viability:
1903
+ # CPU-only (sklearn), so it stays outside the GPU window on purpose.
1904
  v_overlay, alive, dead, viab_pct, _vinfo, lm = run_viability(
1905
  packed_masks, packed_img)
1906
  res.update(alive=alive, dead=dead, viab=viab_pct,
 
1908
  return res
1909
 
1910
 
1911
+ def _segment_one(image, points, model_name, use_min, min_px,
1912
+ use_stereo, left_pct, top_pct, want_viability):
1913
+ """Segment (and optionally classify) a single image. Returns a dict."""
1914
+ if image is None:
1915
+ return None
1916
+ out = run_segmentation(image, model_name, min_px, 10000,
1917
+ use_stereo, left_pct, top_pct, points,
1918
+ use_min, False)
1919
+ return _result_from_out(out, want_viability)
1920
+
1921
+
1922
+ def _segment_many(items, model_name, use_min, min_px,
1923
+ use_stereo, left_pct, top_pct, want_viability):
1924
+ """Segment several (image, points) pairs with ONE ZeroGPU acquisition.
1925
+
1926
+ Produces the same per-image dicts as calling _segment_one in a loop, but the
1927
+ squares share a single trip through the ZeroGPU queue and a single Cellpose
1928
+ model construction instead of one each. All the CPU work -- warping,
1929
+ filtering, overlays, viability -- stays outside the GPU window as before.
1930
+
1931
+ Returns a list the same length as `items`; an entry is None if that image
1932
+ was None, or {"error": ...} if that one square failed. One square failing
1933
+ never takes the others down.
1934
+ """
1935
+ t0 = time.perf_counter()
1936
+ n = len(items)
1937
+ results = [None] * n
1938
+ prepped = [None] * n
1939
+
1940
+ # --- CPU: prepare everything first, no GPU held ---
1941
+ for i, (image, points) in enumerate(items):
1942
+ if image is None:
1943
+ continue
1944
+ try:
1945
+ prepped[i] = _prep_for_segmentation(image, model_name, points)
1946
+ except Exception as e:
1947
+ import traceback; traceback.print_exc()
1948
+ results[i] = {"error": "Could not prepare image: %s" % e}
1949
+
1950
+ live = [i for i in range(n) if prepped[i] is not None]
1951
+ if not live:
1952
+ return results
1953
+
1954
+ # --- GPU: one acquisition for the whole batch ---
1955
+ model_filename = prepped[live[0]][2]
1956
+ try:
1957
+ gpu_out = run_segmentation_patched_many(
1958
+ [prepped[i][0] for i in live], model_filename)
1959
+ except Exception as e:
1960
+ import traceback; traceback.print_exc()
1961
+ for i in live:
1962
+ results[i] = {"error": "Segmentation failed: %s" % e}
1963
+ return results
1964
+
1965
+ # --- CPU: the unchanged post-processing tail, per image ---
1966
+ for k, i in enumerate(live):
1967
+ try:
1968
+ out = run_segmentation(
1969
+ None, model_name, min_px, 10000,
1970
+ use_stereo, left_pct, top_pct, items[i][1], use_min, False,
1971
+ _prepped=prepped[i], _gpu_result=gpu_out[k], _t_start_override=t0)
1972
+ results[i] = _result_from_out(out, want_viability)
1973
+ except Exception as e:
1974
+ import traceback; traceback.print_exc()
1975
+ results[i] = {"error": "Post-processing failed: %s" % e}
1976
+
1977
+ return results
1978
+
1979
+
1980
  def _build_correction(res):
1981
  """Per-cell thumbnails for manual live/dead correction.
1982
 
 
2164
  imgs, ptss = [i1, i2, i3, i4], [p1, p2, p3, p4]
2165
  t0 = time.time()
2166
  rows, results, notes = [], [], []
2167
+
2168
+ # Work out which squares are eligible BEFORE touching the GPU, then
2169
+ # segment all of them in ONE acquisition. Previously this loop
2170
+ # called _segment_one per square, so a four-square run queued for a
2171
+ # GPU four separate times and rebuilt the Cellpose model each time.
2172
+ skipped, eligible = {}, []
2173
  for k, (im, pts) in enumerate(zip(imgs, ptss), start=1):
 
2174
  if im is None:
 
2175
  continue
2176
  if not (pts and len(pts) >= 3):
2177
  # Deliberately skipped, not segmented whole-frame: the whole
2178
  # frame is a much larger area than one block, so counting it
2179
  # would silently corrupt the concentration.
2180
+ skipped[k - 1] = "Square %d: no block tapped, skipped" % k
2181
+ continue
2182
+ eligible.append(k - 1)
2183
+
2184
+ seg = [None, None, None, None]
2185
+ if eligible:
2186
+ batch = _segment_many([(imgs[i], ptss[i]) for i in eligible],
2187
+ HEMO_MODEL, True, 0, True, 1, 1, True)
2188
+ for slot, r in zip(eligible, batch):
2189
+ seg[slot] = r
2190
+
2191
+ # Rebuild rows in square order so the table and the notes read 1..4.
2192
+ for k in range(1, 5):
2193
+ slot = k - 1
2194
+ blank = ["Square %d" % k, "\u2014", "\u2014", "\u2014", "\u2014"]
2195
+ if slot in skipped:
2196
  rows.append(blank); results.append(None)
2197
+ notes.append(skipped[slot])
2198
  continue
2199
+ r = seg[slot]
2200
+ if r is None:
2201
+ rows.append(blank); results.append(None)
2202
+ continue
2203
+ if "error" in r:
2204
  rows.append(blank); results.append(None)
2205
  notes.append("Square %d failed" % k)
2206
  continue