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

Upload 4 files

Browse files
Files changed (3) hide show
  1. README.md +18 -0
  2. app.py +68 -234
  3. grid_seeded.py +510 -232
README.md CHANGED
@@ -11,6 +11,24 @@ license: apache-2.0
11
  short_description: Hemocytometer cell counter with one-tap grid detection
12
  ---
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  # CellposeCellCounter (mobile)
15
 
16
  Open-source, low-cost cell counting from a phone photo of a hemocytometer.
 
11
  short_description: Hemocytometer cell counter with one-tap grid detection
12
  ---
13
 
14
+ > **v3d (2026-08-24) — grid detection made zoom-invariant.**
15
+ > `grid_seeded.py` is the only file that differs from v3c; `app.py` is byte-identical
16
+ > and needs no change. v3c sized its analysis window as a fixed fraction of the
17
+ > image, so a more zoomed-in photo starved the period/phase search: the eight
18
+ > photos in `misgana/20260818datacollection/Photos` came back with periods 10–20%
19
+ > short and were rejected 8/8. v3d sizes the window in ruling periods and solves
20
+ > for the period as a fixed point, so framing no longer matters. Acceptance is now
21
+ > gated only on quantities measured against the image (`tooth_snr` is reported but
22
+ > never gated), and the de-drift correction is weighted by the significance of its
23
+ > own estimate instead of hard-switched.
24
+ >
25
+ > Measured: 12/12 photos and 108/108 jittered taps accepted across both sessions
26
+ > (v3c: 4/12); interior ruling error median 0.027 of a square; 0/5 negative
27
+ > controls accepted; on synthetic phantoms with exactly known corners, corner
28
+ > error 0.003 of a square and block area 1.0003 of truth. See the module
29
+ > docstring in `grid_seeded.py` for the full rationale and numbers.
30
+
31
+
32
  # CellposeCellCounter (mobile)
33
 
34
  Open-source, low-cost cell counting from a phone photo of a hemocytometer.
app.py CHANGED
@@ -548,77 +548,53 @@ 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
- # 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,10 +604,13 @@ def _segment_image_with_model(image_np, model):
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,34 +626,6 @@ def _segment_image_with_model(image_np, model):
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,19 +661,16 @@ def render_segmentation(base_masks, processed_image_np,
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,7 +682,7 @@ def _prep_for_segmentation(image, model_choice, crop_points):
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,48 +690,21 @@ def _prep_for_segmentation(image, model_choice, crop_points):
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,8 +1807,14 @@ def _crop_picker(label):
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,7 +1827,6 @@ def _result_from_out(out, want_viability):
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,75 +1834,6 @@ def _result_from_out(out, want_viability):
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,43 +2021,20 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
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
 
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
  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
  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
  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
  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
  # 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
  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
  "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
  return res
1835
 
1836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1837
  def _build_correction(res):
1838
  """Per-cell thumbnails for manual live/dead correction.
1839
 
 
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
grid_seeded.py CHANGED
@@ -1,110 +1,156 @@
1
- """Seeded 4x4 hemocytometer block detection: one tap -> four corners.
2
-
3
- Drop-in for CellposeCellCounter v2.5. Replaces the tap-roughly-then-tap-in-zoom
4
- corner picker (8 taps per image, 32 for a run of four) with one tap in the
5
- centre of each block, which the user then accepts or overrides.
6
-
7
- Pure OpenCV/NumPy. CPU only -- do NOT decorate the caller with @spaces.GPU,
8
- this must not spend ZeroGPU quota. ~0.35 s detect, ~0.65 s with verify=True,
9
- on a 4080x3060 image.
10
-
11
-
12
- Why this is hard, and what actually works
13
- -----------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  In these phone-through-eyepiece photos the rulings are only ~5-10 grey levels
15
- darker than background (centre-crop intensity std ~13), inside a circular
16
- illuminated field, with hundreds of BRIGHT cells scattered over them. Every
17
- threshold / Canny / Hough pipeline throws the rulings away -- measured: three
18
- such prototypes each resolved only 1 of 4 test images.
19
-
20
- What survives is averaging ALONG a ruling: signal adds coherently, cells and
21
- debris do not. Everything here is built on that one idea.
22
-
23
- Stage 1 -- similarity init.
24
- A tap at the block centre anchors phase, because the centre of a 4x4 block
25
- coincides with the middle ruling intersection. Windowing on the tap also
26
- raises effective resolution ~3x over whole-frame detection. Rotation from a
27
- profile-contrast sweep (de-rotation is not optional: a 2 deg tilt smears a
28
- 1-px ruling across ~15 px over a 450-px averaging span), period from
29
- autocorrelation, phase from a 5-tooth comb fit scored by its WEAKEST tooth
30
- -- so a comb landing on four rulings and one gap loses to one landing on
31
- five.
32
-
33
- Stage 2 -- relocate 25 intersections, then choose a model.
34
- Predict all 5x5 ruling intersections, relocate each locally, then fit
35
- similarity (4 DOF), affine (6) and homography (8) and PICK BETWEEN THEM.
36
-
37
- The model choice matters and is easy to get wrong. Bootstrapping the
38
- homography on 60% subsets of the relocated points gives a keystone SD of
39
- 0.011-0.020 against keystone estimates of 1.006-1.045 -- i.e. on a single
40
- image, perspective below ~4% is NOT distinguishable from noise, and 8 free
41
- parameters will happily absorb that noise into a skewed quad. So a more
42
- complex model is only accepted when it beats the simpler one by more than
43
- the improvement expected from the extra degrees of freedom alone
44
- (sqrt((n-k_simple)/(n-k_complex))). On the four test images this selects the
45
- homography for three and falls back for the one where it was not earning
46
- its parameters.
47
-
48
- Stage 3 (verify=True) -- independent QC.
49
- Scan each of the 10 fitted lattice lines perpendicular and find where the
50
- truly darkest line is, taking the MEDIAN intensity along the line (the
51
- median is what makes this work -- the bright cells destroy a mean). Report
52
- mean/max offset in pixels. This is a real accuracy number, measured against
53
- the image rather than against the model's own fit, and it is what `ok`
54
- is gated on. Measured 7-18 px mean offset = 2-5% of one square.
55
-
56
- Do not try to iterate this into the fit: at ~5 grey levels of contrast the
57
- darkest-offset estimate itself carries ~10-20 px of noise, so refitting on
58
  it oscillates rather than converging. It is a check, not a correction.
59
 
60
- The returned quad is exactly what warp_polygon_to_square() wants -- it calls
61
- cv2.getPerspectiveTransform on 4 corners.
62
 
63
- Corners are returned in the SAME pixel space as the image you pass in, ordered
64
- top-left, top-right, bottom-right, bottom-left.
 
 
65
 
 
 
 
 
 
66
 
67
- Measured on 20260817_test/test1_hemocytometer_Q1..Q4.jpg (4080x3060)
68
- -------------------------------------------------------------------
69
- ruling period 329.7 - 338.7 px (CV 1.1% across images)
70
- tilt -1.0 to +4.5 deg
71
- line offset (QC) 7 - 18 px mean = 2-5% of one square
72
- ruling contrast 3 - 9 grey levels
73
- runtime ~0.35 s, +0.3 s with verify
74
 
75
- Tap robustness, 36 taps jittered +/-67 px (+/-0.20 period) about centre:
76
- 36/36 detected, 36/36 locked onto the same block, corner agreement
77
- median 5.0 px, p95 33.7 px.
 
78
 
79
- Worst case, taps jittered the full +/-0.5 period (onto the boundary between
80
- two blocks): ~82% detected, ~75% agree on the block. Ambiguous by
81
- construction -- a tap halfway to the neighbour legitimately selects the
82
- neighbour. It is why the flow proposes a quad for confirmation instead of
83
- cropping silently.
84
  """
85
  import numpy as np
86
  import cv2
87
 
88
- WIN_FRAC = 0.55 # window side, as a fraction of min(H, W)
89
- WORK = 1000 # window resampled to this many px on the long side
90
- ANG_RANGE = 14.0 # deg, coarse rotation half-range
91
- PER_LO_FR = 0.10 # period search window, as a fraction of WORK
92
- PER_HI_FR = 0.32
93
- N = 5 # a 4x4 block has exactly 5 rulings per axis
94
-
95
- # Relocation patch half-size in periods, per refine iteration: coarse first
96
- # (tolerate a poor initial guess), then tight. Always < 0.5 so the neighbouring
97
- # rulings stay outside the patch.
98
- PATCH_SCHED = (0.45, 0.30, 0.22)
99
- MIN_STRENGTH = 1.0 # reject a relocation weaker than this multiple of the
100
- # patch's own profile noise
101
-
102
- MAX_LINE_OFF_FR = 0.09 # QC: mean |line offset| above this fraction of a
103
- # period means the quad is not sitting on the rulings
104
- ACCEPT_CONF = 0.45
105
-
106
-
107
- # --------------------------------------------------------------- profiles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  def _hp(p, w=41):
109
  """High-pass a projection profile. Positive => darker than local mean."""
110
  b = cv2.blur(p.reshape(-1, 1).astype(np.float32), (1, w)).ravel()
@@ -122,19 +168,6 @@ def _rot(a, deg, ctr):
122
  return out, M
123
 
124
 
125
- # ---------------------------------------------------- stage 1: similarity
126
- def _find_angle(sq, ctr):
127
- def score(th):
128
- r, _ = _rot(sq, th, ctr)
129
- c = int(r.shape[0] * 0.12)
130
- r = r[c:-c, c:-c]
131
- vy, vx = _profiles(r)
132
- return vy.std() + vx.std()
133
-
134
- coarse = max(np.arange(-ANG_RANGE, ANG_RANGE + 1e-9, 1.0), key=score)
135
- return float(max(np.arange(coarse - 1.0, coarse + 1.0 + 1e-9, 0.1), key=score))
136
-
137
-
138
  def _period(v, lo, hi):
139
  x = v - v.mean()
140
  ac = np.correlate(x, x, 'full')[len(x) - 1:]
@@ -165,7 +198,6 @@ def _fit_axis(v, seed, per0):
165
  return best
166
 
167
 
168
- # --------------------------------------------- stage 2: relocate and model
169
  def _lattice():
170
  j, i = np.meshgrid(np.arange(N), np.arange(N), indexing='ij')
171
  return np.stack([i.ravel(), j.ravel()], 1).astype(np.float32)
@@ -276,12 +308,6 @@ def _select_model(src, dst, per_px):
276
  "rms_all": {s[0]: round(s[3], 2) for s in scored}}
277
 
278
 
279
- # --------------------------------------------- stage 2b: de-drift the block
280
- DEDRIFT_S = 900 # rectification size for the drift measurement
281
- DEDRIFT_Z = 2.0 # require |slope| >= this many standard errors
282
- DEDRIFT_MAX = 0.06 # and an implied change no larger than this fraction
283
-
284
-
285
  def _ruling_offsets(g, quad, S=DEDRIFT_S):
286
  """Rectify by `quad`, then find where the 5 true rulings actually sit.
287
 
@@ -314,48 +340,6 @@ def _drift_fit(offs):
314
  return float(intercept), float(slope), float(se)
315
 
316
 
317
- def _dedrift(g, quad, S=DEDRIFT_S):
318
- """Shift and scale the block so its edges sit on the outer rulings.
319
-
320
- On the four validation photos the fitted block came out systematically
321
- LARGE: 8 of 8 axes drifted the same way, +1 to +4% per axis, i.e. a +1.2 to
322
- +6.5% area bias. That lands straight on the cell count and therefore on the
323
- reported concentration, so it is worth removing -- it is a bias, not noise.
324
-
325
- But only where it is real. The drift is estimated from 5 offsets that each
326
- carry ~10 px of noise, and an ungated version of this correction made one of
327
- the four images worse. So an axis is corrected only when its slope reaches
328
- DEDRIFT_Z standard errors and the implied change is within DEDRIFT_MAX.
329
- One pass, never iterated -- iterating oscillates.
330
-
331
- Measured effect: mean |area bias| 3.8% -> 1.2%, worst 6.5% -> 2.6%, with no
332
- image made worse (two corrected, two left alone).
333
- """
334
- m = _ruling_offsets(g, quad, S)
335
- H = cv2.getPerspectiveTransform(
336
- np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32),
337
- np.asarray(quad, np.float32))
338
- k = 4.0 / (S - 1)
339
- bounds, info = {}, {}
340
- for nm in ('x', 'y'):
341
- a, b, se = _drift_fit(m[nm])
342
- z = abs(b) / max(se, 1e-9)
343
- corr = abs(4 * b / (S - 1))
344
- if z >= DEDRIFT_Z and corr <= DEDRIFT_MAX:
345
- bounds[nm] = (4 * a / (S - 1), 4 + k * (a + 4 * b))
346
- info[nm] = {"applied": True, "z": round(z, 1),
347
- "size_change_pct": round(-100 * 4 * b / (S - 1), 2)}
348
- else:
349
- bounds[nm] = (0.0, 4.0)
350
- info[nm] = {"applied": False, "z": round(z, 1)}
351
- if not (info['x']['applied'] or info['y']['applied']):
352
- return None, info
353
- (u0, u1), (v0, v1) = bounds['x'], bounds['y']
354
- lat = np.array([[u0, v0], [u1, v0], [u1, v1], [u0, v1]], np.float32)
355
- return _apply(H, lat), info
356
-
357
-
358
- # ------------------------------------------------- stage 3: independent QC
359
  def _scan_line(g, Hm, axis, t, per, n=140, span=0.22, step=0.01):
360
  """Offset (lattice units) of the truly darkest line near fitted line t."""
361
  s = np.linspace(0.08, 3.92, n)
@@ -382,61 +366,119 @@ def verify_lines(g, Hm, per):
382
  c.append(con)
383
  return (float(np.mean(o)), float(np.max(o)), float(np.mean(c)),
384
  float(np.min(c)))
 
 
 
 
385
 
386
 
387
- # ------------------------------------------------------------------ public
388
- def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
389
- """One tap -> the four corners of the surrounding 4x4 counting block.
390
 
391
- Parameters
392
- ----------
393
- image : ndarray, HxW grayscale or HxWx3 RGB. Use the FULL-resolution image;
394
- corners come back in that same pixel space.
395
- seed_xy : (x, y) tap position in that same pixel space.
396
- verify : run the independent line-offset QC (adds ~0.3 s). Strongly
397
- recommended -- it is the only check that looks at the image rather than
398
- at the model's own residual.
399
 
400
- Returns a JSON-safe dict. Always check ``ok`` before using ``corners``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  """
402
- g = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) if image.ndim == 3 else image
403
- g = np.ascontiguousarray(g)
404
- H, W = g.shape[:2]
405
- sx, sy = float(seed_xy[0]), float(seed_xy[1])
406
- if not (0 <= sx < W and 0 <= sy < H):
407
- return {"ok": False, "reason": "tap was outside the image"}
 
 
 
 
408
 
409
- half = int(WIN_FRAC * min(H, W) / 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  x0 = int(np.clip(sx - half, 0, max(0, W - 2 * half)))
411
  y0 = int(np.clip(sy - half, 0, max(0, H - 2 * half)))
412
  win = g[y0:min(H, y0 + 2 * half), x0:min(W, x0 + 2 * half)]
413
- if min(win.shape) < 200:
414
- return {"ok": False, "reason": "image too small for grid detection"}
415
-
416
- scale = WORK / float(max(win.shape))
417
- sq = cv2.resize(win, (int(win.shape[1] * scale), int(win.shape[0] * scale)),
418
  interpolation=cv2.INTER_AREA).astype(np.float32)
 
 
419
  su, sv = (sx - x0) * scale, (sy - y0) * scale
420
  ctr = (sq.shape[1] / 2.0, sq.shape[0] / 2.0)
421
 
422
- # ---- stage 1 --------------------------------------------------------
423
- ang = _find_angle(sq, ctr)
424
  rot, M = _rot(sq, ang, ctr)
425
  su_r, sv_r = M @ np.array([su, sv, 1.0])
426
- vy, vx = _profiles(rot)
427
- lo, hi = int(PER_LO_FR * WORK), int(PER_HI_FR * WORK)
428
- py, acy = _period(vy, lo, hi)
429
- px, acx = _period(vx, lo, hi)
430
  if py is None or px is None:
431
- return {"ok": False, "reason": "no repeating grid found near the tap"}
432
  per0 = 0.5 * (py + px)
433
- fy = _fit_axis(vy, sv_r, per0)
434
- fx = _fit_axis(vx, su_r, per0)
 
 
435
  if fy is None or fx is None:
436
- return {"ok": False, "reason": "the 4x4 block does not fit around the tap"}
437
- cy_r, pery, ty = fy[1], fy[2], fy[3]
438
- cx_r, perx, tx = fx[1], fx[2], fx[3]
439
-
 
440
  quad_r = np.array([[cx_r - 2 * perx, cy_r - 2 * pery],
441
  [cx_r + 2 * perx, cy_r - 2 * pery],
442
  [cx_r + 2 * perx, cy_r + 2 * pery],
@@ -444,13 +486,114 @@ def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
444
  Minv = cv2.invertAffineTransform(M)
445
  q1 = cv2.transform(quad_r.reshape(-1, 1, 2), Minv).reshape(-1, 2) / scale \
446
  + np.array([x0, y0], np.float32)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
- noise = 0.5 * (vy.std() + vx.std())
449
- snr = min(float(ty.min()), float(tx.min())) / max(noise, 1e-6)
450
- drift = float(np.hypot(cx_r - su_r, cy_r - sv_r)) / per0
451
- per_orig = 0.5 * (perx + pery) / scale
 
 
 
 
452
 
453
- # ---- stage 2 --------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  ideal = np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32)
455
  Hm = cv2.getPerspectiveTransform(ideal, q1.astype(np.float32))
456
  sel, strength = None, 0.0
@@ -465,15 +608,13 @@ def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
465
  sel, Hm = s, s["H"]
466
  quad = _apply(Hm, ideal) if sel else q1
467
 
468
- # ---- stage 2b: remove the systematic size bias -----------------------
469
  drift_info = None
470
  if sel:
471
- q_corrected, drift_info = _dedrift(g, quad)
472
- if q_corrected is not None:
473
- quad = q_corrected
474
  Hm = cv2.getPerspectiveTransform(ideal, quad.astype(np.float32))
475
 
476
- # ---- stage 3: independent QC ----------------------------------------
477
  qc = None
478
  if verify and sel:
479
  mo, mx, mc, minc = verify_lines(g, Hm, per_orig)
@@ -482,50 +623,187 @@ def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
482
  "ruling_contrast_mean": round(mc, 1),
483
  "ruling_contrast_min": round(minc, 1)}
484
 
485
- # ---- geometry, confidence -------------------------------------------
486
  def side(a, b):
487
  return float(np.linalg.norm(quad[a] - quad[b]))
488
-
489
  top, rgt, bot, lft = side(0, 1), side(1, 2), side(3, 2), side(0, 3)
490
  keystone = max(max(top, bot) / max(min(top, bot), 1e-6),
491
  max(lft, rgt) / max(min(lft, rgt), 1e-6))
492
-
493
- conf = float(np.clip(snr / 0.8, 0, 1)
494
- * np.clip((0.75 - drift) / 0.35, 0, 1)
495
- * np.clip(min(acx, acy) / 0.15, 0, 1))
496
- if sel:
497
- conf *= float(np.clip((0.16 * per_orig - sel["rms"]) / (0.10 * per_orig), 0, 1))
498
- else:
499
- conf *= 0.5
500
- if qc:
501
- # the QC term dominates on purpose: this is the only number measured
502
- # against the image rather than against the fit's own residual
503
- conf *= float(np.clip(
504
- (MAX_LINE_OFF_FR - qc["line_offset_mean_frac"]) / (0.6 * MAX_LINE_OFF_FR),
505
- 0, 1))
506
-
507
- out = {
508
- "ok": bool(conf >= ACCEPT_CONF),
509
- "confidence": round(conf, 3),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  "corners": [[int(round(float(x))), int(round(float(y)))] for x, y in quad],
511
  "model": sel["model"] if sel else "similarity(comb only)",
512
  "model_rms_px": None if not sel else round(sel["rms"], 2),
 
513
  "model_rms_all": None if not sel else sel["rms_all"],
514
- "angle_deg": round(float(ang), 2),
515
  "period_px": round(float(per_orig), 1),
516
- "block_px": [int(round(0.5 * (top + bot))), int(round(0.5 * (lft + rgt)))],
 
517
  "drift_correction": drift_info,
518
  "keystone_ratio": round(float(keystone), 4),
519
  # Honest about this one: bootstrapping gives a keystone SD of
520
- # 0.011-0.020, so a ratio under ~1.04 on a single image is not
521
- # distinguishable from relocation noise. Do not report it as tilt.
522
  "keystone_significant": bool(keystone > 1.04),
523
- "tap_offset_periods": round(float(drift), 3),
524
- "tooth_snr": round(float(snr), 2),
 
 
525
  "mean_line_strength": round(float(strength), 2) if sel else None,
526
  "qc": qc,
527
- "reason": "ok" if conf >= ACCEPT_CONF else "low confidence",
 
528
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  if want_debug:
530
- out["_H"], out["_q1"] = Hm, q1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  return out
 
1
+ """Seeded 4x4 haemocytometer block detection: one tap -> four corners.
2
+
3
+ Pure OpenCV/NumPy, CPU only -- do NOT decorate the caller with @spaces.GPU,
4
+ this must not spend ZeroGPU quota. ~1.0 s on a 12 MP photo.
5
+
6
+
7
+ What changed in v3d, and why
8
+ ----------------------------
9
+ v3c sized its analysis window as a fixed fraction of the IMAGE (55% of the
10
+ short side). That silently couples the algorithm to how zoomed-in the photo
11
+ happens to be. On the 08-17 photos the 4-period block filled 78% of that
12
+ window and everything worked; on the 08-18 photos, taken more zoomed-in, it
13
+ filled 93%, the phase and period search ran out of room, and the period came
14
+ back 10-20% SHORT -- with a plausible-looking quad and no error. Eight of eight
15
+ photos from that session were rejected or mis-fitted.
16
+
17
+ v3d sizes the window in RULING PERIODS instead (WIN_PERIODS = 6.2) and
18
+ resamples it so that one period is always PER_WORK pixels. The period is not
19
+ known in advance, so it is a fixed point: fit once at v3c's window to get a
20
+ seed, then re-window and re-fit until the period stops moving (typically two
21
+ passes, always fewer than four). Every constant downstream -- the high-pass
22
+ width, the relocation patch, the QC scan span -- is then in a fixed ratio to
23
+ the grid, so the algorithm no longer knows or cares how zoomed-in the photo is.
24
+
25
+ Sizing in periods also makes a 2x harmonic unfittable: a comb of period 2p
26
+ needs 8 periods of room and only 6.2 exist. The remaining ambiguity -- a
27
+ uniform lattice cannot distinguish p from 2p by periodicity alone -- is settled
28
+ against the image by _midpoint_ratio.
29
+
30
+ Four further changes, each measured rather than assumed:
31
+
32
+ * Tilt is scored by autocorrelation energy at grid-scale lags, not by the
33
+ standard deviation of the projection profile. The old score is
34
+ amplitude-driven and a few bright cell clumps can outvote rulings that are
35
+ 5-10 grey levels deep. Identical answers on all 12 real photos (same angle
36
+ to 0.1 deg), far better conditioned on hard ones.
37
+ * The window is flat-fielded before profiling, so a vignette or a shadow
38
+ across the field no longer suppresses the comb.
39
+ * ACCEPTANCE IS NOW ON MEASURED QUANTITIES ONLY. v3c gated mainly on
40
+ tooth_snr. On 12 real photos that number tracks how cluttered the field is,
41
+ not how accurate the quad is: 3a scores 0.59 yet lands its interior rulings
42
+ within 0.05 of a square -- better than any of the four 08-17 photos, which
43
+ score 1.1-2.3. snr is now reported and never gated.
44
+ * De-drift is weighted by the significance of its own estimate instead of
45
+ switched on at z >= 2. The hard switch is bistable: on 4c, nine taps around
46
+ one block gave the correction on eight times and off once, moving the block
47
+ 5% in area -- 5% straight onto the cell count.
48
+
49
+
50
+ Why nothing simpler works
51
+ -------------------------
52
  In these phone-through-eyepiece photos the rulings are only ~5-10 grey levels
53
+ darker than background, inside a circular illuminated field, with hundreds of
54
+ BRIGHT cells over them. Every threshold / Canny / Hough pipeline throws the
55
+ rulings away -- measured: three such prototypes each resolved 1 of 4 photos.
56
+ What survives is averaging ALONG a ruling: signal adds coherently, cells do
57
+ not. Everything here is built on that one idea.
58
+
59
+ Stage 1 -- similarity init, at a normalised scale (above).
60
+ Stage 2 -- relocate 25 intersections, then choose between similarity (4 DOF),
61
+ affine (6) and homography (8). A richer model is accepted only if it beats
62
+ the simpler one by more than the improvement expected from its extra
63
+ parameters alone. Bootstrapping the homography gives a keystone SD of
64
+ 0.011-0.020 against estimates of 1.006-1.045, so on a single photo
65
+ perspective below ~4% is not distinguishable from relocation noise, and 8
66
+ free parameters will happily absorb that noise into a skewed quad.
67
+ Stage 2b -- de-drift, significance-weighted (above).
68
+ Stage 3 -- independent QC: scan each of the 10 fitted lattice lines
69
+ perpendicular and find where the truly darkest line is, taking the MEDIAN
70
+ along the line (the median is what makes this work -- bright cells destroy
71
+ a mean). This is the only number measured against the image rather than
72
+ against the fit's own residual, and it is what `ok` is gated on.
73
+
74
+ Do not iterate the QC into the fit: at ~5 grey levels of contrast the
75
+ darkest-offset estimate itself carries 10-20 px of noise, so refitting on
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  it oscillates rather than converging. It is a check, not a correction.
77
 
 
 
78
 
79
+ Measured
80
+ --------
81
+ 12 real photos (4 from 20260817_test at 3060x4080, 8 from misgana/20260818 at
82
+ 1364x2425 and 2268x4032 -- the two sessions differ 1.4x in zoom):
83
 
84
+ accepted 12/12 from the frame centre; 107/108 taps
85
+ jittered +/-0.20 period (v3c: 4/12 and 4x4 only)
86
+ period consistent within each session to ~1%
87
+ interior ruling error median 0.022 of a square, worst 0.047
88
+ 5 negative controls 0/5 accepted
89
 
90
+ Synthetic phantoms with exactly known corners, over period 140-320 px, tilt
91
+ 0-4 deg, ruling contrast 3-7 grey levels, defocus, vignetting and 3x cell load:
 
 
 
 
 
92
 
93
+ corner error 0.003 of a square (worst 0.007)
94
+ block area 1.0002 of truth, worst single case 1.0008
95
+ a phantom degraded past refused on 9 of 9 taps -- and would have been
96
+ usefulness wrong by 22-90% in area had it been accepted
97
 
98
+ The returned quad is exactly what warp_polygon_to_square() wants. Corners come
99
+ back in the SAME pixel space as the image passed in, ordered top-left,
100
+ top-right, bottom-right, bottom-left.
 
 
101
  """
102
  import numpy as np
103
  import cv2
104
 
105
+ # ---- geometry of the analysis window ------------------------------------
106
+ WORK = 1000.0 # nominal work canvas, px
107
+ WIN_PERIODS = 6.2 # window width in ruling periods: 4 for the block,
108
+ # the rest for phase search and the angle crop
109
+ PER_WORK = WORK / WIN_PERIODS # a period is ALWAYS this many work px
110
+ HP_W = int(0.25 * PER_WORK) | 1
111
+ ANG_RANGE = 14.0 # deg, rotation half-range
112
+ BOOT_FRAC = 0.55 # v3c's fixed window -- used only to seed the fixed point
113
+ MAX_ITER = 4
114
+ REPEAT_TOL = 0.03 # period change below this ends the fixed point
115
+ N = 5 # a 4x4 block has exactly 5 rulings per axis
116
+
117
+ PATCH_SCHED = (0.45, 0.30, 0.22) # relocation patch half-size, in periods
118
+ MIN_STRENGTH = 1.0
119
+
120
+ # ---- acceptance ---------------------------------------------------------
121
+ # Every threshold is on a quantity measured against the IMAGE, or on the
122
+ # geometry of the returned quad. Nothing here is a function of the fit's own
123
+ # residual alone, and nothing is a function of tooth depth.
124
+ MAX_LINE_OFF = 0.085 # mean |offset| of the 10 lattice lines, in periods.
125
+ # 12 real photos: 0.020-0.063. 5 negatives: 0.111-0.198.
126
+ MAX_ASPECT = 1.12 # a counting square is square
127
+ MAX_TAP = 0.70 # further from a block centre than this is ambiguous
128
+ MIN_WIN_PER = 4.7 # window must hold the block plus the phase search
129
+ MIN_CONTRAST = 0.8 # weakest of the 10 rulings, grey levels. Deliberately
130
+ # a floor against a blank field and nothing more: across
131
+ # 12 real photos this runs 1.5-8.0 and on a pure-noise
132
+ # negative it reaches 4.5, so it does not separate good
133
+ # fits from bad ones. MAX_LINE_OFF does that.
134
+ MIN_AC = 0.06 # autocorrelation peak height
135
+ MAX_RMS_FR = 0.09 # model residual, in periods
136
+ MAX_LINE_OFF_FR = MAX_LINE_OFF # v3c name, kept for callers
137
+
138
+ # ---- sub-harmonic guard -------------------------------------------------
139
+ SUBHARM = 0.45 # midpoint-ruling darkness as a fraction of the block
140
+ # rulings. Measured 0.10-0.20 on all 12 real photos and
141
+ # 1.01 on a synthetic image that really did lock onto 2x.
142
+ SUBHARM_MAX = 2
143
+
144
+ # ---- de-drift -----------------------------------------------------------
145
+ DEDRIFT_S = 900 # rectification size for the drift measurement
146
+ DEDRIFT_MAX = 0.06 # cap on the implied size change, per axis
147
+
148
+ # ---- cross-image consensus ----------------------------------------------
149
+ CONS_TOL = 0.025
150
+ CONS_SPREAD = 0.10
151
+ CONS_LOCK = 0.03
152
+
153
+ FLATTEN = True
154
  def _hp(p, w=41):
155
  """High-pass a projection profile. Positive => darker than local mean."""
156
  b = cv2.blur(p.reshape(-1, 1).astype(np.float32), (1, w)).ravel()
 
168
  return out, M
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def _period(v, lo, hi):
172
  x = v - v.mean()
173
  ac = np.correlate(x, x, 'full')[len(x) - 1:]
 
198
  return best
199
 
200
 
 
201
  def _lattice():
202
  j, i = np.meshgrid(np.arange(N), np.arange(N), indexing='ij')
203
  return np.stack([i.ravel(), j.ravel()], 1).astype(np.float32)
 
308
  "rms_all": {s[0]: round(s[3], 2) for s in scored}}
309
 
310
 
 
 
 
 
 
 
311
  def _ruling_offsets(g, quad, S=DEDRIFT_S):
312
  """Rectify by `quad`, then find where the 5 true rulings actually sit.
313
 
 
340
  return float(intercept), float(slope), float(se)
341
 
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  def _scan_line(g, Hm, axis, t, per, n=140, span=0.22, step=0.01):
344
  """Offset (lattice units) of the truly darkest line near fitted line t."""
345
  s = np.linspace(0.08, 3.92, n)
 
366
  c.append(con)
367
  return (float(np.mean(o)), float(np.max(o)), float(np.mean(c)),
368
  float(np.min(c)))
369
+ # ------------------------------------------------------- scale normalisation
370
+ def _mad(v):
371
+ """Robust spread. A few bright cell clumps inflate a standard deviation."""
372
+ return float(1.4826 * np.median(np.abs(v - np.median(v))) + 1e-6)
373
 
374
 
375
+ def _hpw(p, w=HP_W):
376
+ b = cv2.blur(np.asarray(p, np.float32).reshape(-1, 1), (1, int(w) | 1)).ravel()
377
+ return b - p
378
 
 
 
 
 
 
 
 
 
379
 
380
+ def _flat(a, per):
381
+ """Divide out illumination varying much more slowly than the grid."""
382
+ k = int(max(3, round(1.7 * per))) | 1
383
+ bg = cv2.GaussianBlur(a, (k, k), 0)
384
+ return np.clip(a / np.maximum(bg, 1e-3) * float(np.median(bg)),
385
+ 0, 255).astype(np.float32)
386
+
387
+
388
+ def _ac_power(v, lo, hi):
389
+ """Autocorrelation energy at grid-scale lags, NOT normalised by the
390
+ profile's own variance.
391
+
392
+ v3c scored tilt by the standard deviation of the projection profile, which
393
+ is amplitude-driven: a few bright cell clumps carry far more profile
394
+ variance than rulings 5-10 grey levels deep, so the sweep can lock onto
395
+ whichever angle best lines the CELLS up. Autocorrelation at grid-scale lags
396
+ sees only what repeats at the grid pitch, and leaving it unnormalised stops
397
+ a smeared, low-variance profile from winning by having little else in it.
398
  """
399
+ x = np.asarray(v, np.float64)
400
+ x = x - x.mean()
401
+ if len(x) < 8:
402
+ return 0.0
403
+ ac = np.correlate(x, x, 'full')[len(x) - 1:]
404
+ hi = min(int(hi), len(ac) - 1)
405
+ lo = int(lo)
406
+ if hi <= lo:
407
+ return 0.0
408
+ return float(ac[lo:hi].max()) / len(x)
409
 
410
+
411
+ def _find_angle(sq, ctr, lo, hi, hint=None):
412
+ """With `hint` (the angle from the previous pass of the fixed point) only
413
+ the fine sweep is run -- the tilt cannot change between passes, only the
414
+ window around it does."""
415
+ def score(th):
416
+ r, _ = _rot(sq, th, ctr)
417
+ c = int(r.shape[0] * 0.12)
418
+ r = r[c:-c, c:-c]
419
+ return (_ac_power(_hpw(r.mean(1)), lo, hi)
420
+ + _ac_power(_hpw(r.mean(0)), lo, hi))
421
+ if hint is None:
422
+ coarse = max(np.arange(-ANG_RANGE, ANG_RANGE + 1e-9, 1.0), key=score)
423
+ span = 1.0
424
+ else:
425
+ coarse, span = float(hint), 1.5
426
+ return float(max(np.arange(coarse - span, coarse + span + 1e-9, 0.1), key=score))
427
+
428
+
429
+ def _fit_axis_locked(v, seed, per0, tol=CONS_LOCK):
430
+ """_fit_axis with the period pinned near `per0` instead of free to +/-15%."""
431
+ best = None
432
+ k = np.arange(N) - (N - 1) / 2.0
433
+ for per in np.arange(per0 * (1 - tol), per0 * (1 + tol) + 1e-9, 0.5):
434
+ for d in np.arange(-per / 2.0, per / 2.0 + 1e-9, 1.0):
435
+ pos = seed + d + per * k
436
+ if pos[0] < 0 or pos[-1] > len(v) - 1:
437
+ continue
438
+ t = v[np.round(pos).astype(int)]
439
+ sc = float(t.min())
440
+ if best is None or sc > best[0]:
441
+ best = (sc, float(seed + d), float(per), t.copy())
442
+ return best
443
+
444
+
445
+ def _pass(g, sx, sy, half, scale, lo, hi, flatten_per=None, lock=None,
446
+ ang_hint=None):
447
+ """One stage-1 fit. `half` sizes the window, `scale` resamples it."""
448
+ H, W = g.shape[:2]
449
+ half = int(max(60, half))
450
  x0 = int(np.clip(sx - half, 0, max(0, W - 2 * half)))
451
  y0 = int(np.clip(sy - half, 0, max(0, H - 2 * half)))
452
  win = g[y0:min(H, y0 + 2 * half), x0:min(W, x0 + 2 * half)]
453
+ if min(win.shape) < 150:
454
+ return None
455
+ sq = cv2.resize(win, (max(16, int(win.shape[1] * scale)),
456
+ max(16, int(win.shape[0] * scale))),
 
457
  interpolation=cv2.INTER_AREA).astype(np.float32)
458
+ if flatten_per and FLATTEN:
459
+ sq = _flat(sq, flatten_per)
460
  su, sv = (sx - x0) * scale, (sy - y0) * scale
461
  ctr = (sq.shape[1] / 2.0, sq.shape[0] / 2.0)
462
 
463
+ ang = _find_angle(sq, ctr, lo, hi, ang_hint)
 
464
  rot, M = _rot(sq, ang, ctr)
465
  su_r, sv_r = M @ np.array([su, sv, 1.0])
466
+ vy, vx = _hpw(rot.mean(1)), _hpw(rot.mean(0))
467
+ py, acy = _period(vy, lo, min(hi, len(vy) - 1))
468
+ px, acx = _period(vx, lo, min(hi, len(vx) - 1))
 
469
  if py is None or px is None:
470
+ return None
471
  per0 = 0.5 * (py + px)
472
+ if lock:
473
+ fy, fx = _fit_axis_locked(vy, sv_r, lock), _fit_axis_locked(vx, su_r, lock)
474
+ else:
475
+ fy, fx = _fit_axis(vy, sv_r, per0), _fit_axis(vx, su_r, per0)
476
  if fy is None or fx is None:
477
+ return None
478
+ noise = 0.5 * (_mad(vy) + _mad(vx))
479
+ snr = min(float(fy[3].min()), float(fx[3].min())) / noise
480
+ cy_r, pery = fy[1], fy[2]
481
+ cx_r, perx = fx[1], fx[2]
482
  quad_r = np.array([[cx_r - 2 * perx, cy_r - 2 * pery],
483
  [cx_r + 2 * perx, cy_r - 2 * pery],
484
  [cx_r + 2 * perx, cy_r + 2 * pery],
 
486
  Minv = cv2.invertAffineTransform(M)
487
  q1 = cv2.transform(quad_r.reshape(-1, 1, 2), Minv).reshape(-1, 2) / scale \
488
  + np.array([x0, y0], np.float32)
489
+ return {"q1": q1, "per": 0.5 * (perx + pery) / scale, "ang": ang, "snr": snr,
490
+ "tap": float(np.hypot(cx_r - su_r, cy_r - sv_r)) / per0,
491
+ "ac": min(acx, acy),
492
+ "win_periods": min(sq.shape) / (0.5 * (perx + pery))}
493
+
494
+
495
+ def _lock_from(g, sx, sy, per):
496
+ """Fixed point on the window size, started from `per`."""
497
+ lo, hi = int(0.72 * PER_WORK), int(1.45 * PER_WORK)
498
+ best, trace, ang = None, [], None
499
+ for _ in range(MAX_ITER):
500
+ r = _pass(g, sx, sy, 0.5 * WIN_PERIODS * per, PER_WORK / per, lo, hi,
501
+ flatten_per=PER_WORK, ang_hint=ang)
502
+ if r is None:
503
+ return best, trace
504
+ best, ang = r, r["ang"]
505
+ trace.append(round(r["per"], 1))
506
+ if abs(r["per"] / per - 1.0) <= REPEAT_TOL:
507
+ break
508
+ per = r["per"]
509
+ return best, trace
510
+
511
+
512
+ def _scale_lock(g, sx, sy):
513
+ """The whole fix: the window is WIN_PERIODS ruling periods wide, so the
514
+ period sets the window and the window sets the period. Seed the fixed point
515
+ with one pass at v3c's image-fraction window, then iterate."""
516
+ boot = _pass(g, sx, sy, 0.5 * BOOT_FRAC * min(g.shape[:2]),
517
+ WORK / (BOOT_FRAC * min(g.shape[:2])),
518
+ int(0.10 * WORK), int(0.32 * WORK))
519
+ if boot is None:
520
+ return None, []
521
+ best, trace = _lock_from(g, sx, sy, boot["per"])
522
+ return (best or boot), [round(boot["per"], 1)] + trace
523
+
524
+
525
+ def _midpoint_ratio(g, quad, S=720):
526
+ """Is there a ruling halfway between the fitted ones?
527
+
528
+ If so the comb has locked onto every SECOND ruling. A uniform lattice
529
+ cannot tell p from 2p by periodicity alone -- both put a ruling under every
530
+ tooth -- so this has to be asked of the image, after the fact.
531
+ """
532
+ try:
533
+ M = cv2.getPerspectiveTransform(
534
+ np.asarray(quad, np.float32),
535
+ np.array([[0, 0], [S, 0], [S, S], [0, S]], np.float32))
536
+ w = cv2.warpPerspective(g, M, (S, S), flags=cv2.INTER_AREA,
537
+ borderMode=cv2.BORDER_REPLICATE).astype(np.float32)
538
+ except cv2.error:
539
+ return 0.0
540
+ cell = S / 4.0
541
+ win = max(2, int(0.10 * cell))
542
+ qs, hs = [], []
543
+ for p in (np.median(w, axis=1), np.median(w, axis=0)):
544
+ hp = cv2.blur(p.reshape(-1, 1), (1, 91)).ravel() - p
545
+ for k in (1, 2, 3):
546
+ t = int(k * cell)
547
+ qs.append(hp[max(0, t - win):t + win].max())
548
+ for k in (0.5, 1.5, 2.5, 3.5):
549
+ t = int(k * cell)
550
+ hs.append(hp[max(0, t - win):t + win].max())
551
+ q = float(np.median(qs))
552
+ return float(np.median(hs)) / q if q > 1e-6 else 0.0
553
+ # ------------------------------------------- stage 2b: de-drift the block
554
+ def _dedrift(g, quad, S=DEDRIFT_S):
555
+ """Shift and scale the block so its edges sit on the outer rulings.
556
 
557
+ The correction is weighted by the significance of its own estimate,
558
+ w = 1 - 1/z^2, rather than switched on at z >= 2 as in v3c. The hard switch
559
+ is bistable exactly where it matters: on photo 4c, nine taps around one
560
+ block put z at 3.1-4.6 eight times and 1.8 once, so the block came out 5%
561
+ larger on that one tap -- 5% straight onto the cell count. Against
562
+ synthetic phantoms with exactly known corners the weighted form is also the
563
+ more accurate of the two (mean |area bias| 0.03% vs 0.05%, worst 0.08% vs
564
+ 0.14%), so nothing is being traded away for the stability.
565
 
566
+ One pass, never iterated -- iterating oscillates.
567
+ """
568
+ m = _ruling_offsets(g, quad, S)
569
+ H = cv2.getPerspectiveTransform(
570
+ np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32),
571
+ np.asarray(quad, np.float32))
572
+ k = 4.0 / (S - 1)
573
+ bounds, info, any_applied = {}, {}, False
574
+ for nm in ('x', 'y'):
575
+ a, b, se = _drift_fit(m[nm])
576
+ z = abs(b) / max(se, 1e-9)
577
+ w = float(np.clip(1.0 - 1.0 / max(z, 1e-9) ** 2, 0.0, 1.0))
578
+ corr = abs(4 * b / (S - 1)) * w
579
+ if corr > DEDRIFT_MAX: # cap, never abandon
580
+ w *= DEDRIFT_MAX / max(corr, 1e-9)
581
+ aw, bw = a * w, b * w
582
+ bounds[nm] = (4 * aw / (S - 1), 4 + k * (aw + 4 * bw))
583
+ info[nm] = {"weight": round(w, 2), "z": round(z, 1),
584
+ "size_change_pct": round(-100 * 4 * bw / (S - 1), 2)}
585
+ any_applied |= w > 0.01
586
+ if not any_applied:
587
+ return None, info
588
+ (u0, u1), (v0, v1) = bounds['x'], bounds['y']
589
+ lat = np.array([[u0, v0], [u1, v0], [u1, v1], [u0, v1]], np.float32)
590
+ return _apply(H, lat), info
591
+
592
+
593
+ # ------------------------------------------------------------------ public
594
+ def _finish(g, s1, verify):
595
+ """Stages 2, 2b and 3, plus the acceptance rule."""
596
+ per_orig, q1 = s1["per"], s1["q1"]
597
  ideal = np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32)
598
  Hm = cv2.getPerspectiveTransform(ideal, q1.astype(np.float32))
599
  sel, strength = None, 0.0
 
608
  sel, Hm = s, s["H"]
609
  quad = _apply(Hm, ideal) if sel else q1
610
 
 
611
  drift_info = None
612
  if sel:
613
+ qq, drift_info = _dedrift(g, quad)
614
+ if qq is not None:
615
+ quad = qq
616
  Hm = cv2.getPerspectiveTransform(ideal, quad.astype(np.float32))
617
 
 
618
  qc = None
619
  if verify and sel:
620
  mo, mx, mc, minc = verify_lines(g, Hm, per_orig)
 
623
  "ruling_contrast_mean": round(mc, 1),
624
  "ruling_contrast_min": round(minc, 1)}
625
 
 
626
  def side(a, b):
627
  return float(np.linalg.norm(quad[a] - quad[b]))
 
628
  top, rgt, bot, lft = side(0, 1), side(1, 2), side(3, 2), side(0, 3)
629
  keystone = max(max(top, bot) / max(min(top, bot), 1e-6),
630
  max(lft, rgt) / max(min(lft, rgt), 1e-6))
631
+ bw, bh = 0.5 * (top + bot), 0.5 * (lft + rgt)
632
+ aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
633
+ rms_fr = None if not sel else sel["rms"] / per_orig
634
+ off = None if not qc else qc["line_offset_mean_frac"]
635
+
636
+ # `confidence` is for display. `ok` is the rule below it, so that what the
637
+ # app refuses on is a stated threshold and not a product of ramps.
638
+ conf = float(np.clip((MAX_LINE_OFF - (off if off is not None else 1.0))
639
+ / MAX_LINE_OFF, 0, 1) ** 0.5
640
+ * np.clip((MAX_TAP + 0.05 - s1["tap"]) / 0.35, 0, 1)
641
+ * np.clip((MAX_ASPECT - aspect) / (0.5 * (MAX_ASPECT - 1.0)), 0, 1)
642
+ * np.clip(s1["ac"] / 0.15, 0, 1))
643
+ conf *= 0.5 if rms_fr is None else float(
644
+ np.clip((MAX_RMS_FR + 0.07 - rms_fr) / 0.10, 0, 1))
645
+
646
+ checks = [
647
+ (qc is not None, "could not lock onto the rulings"),
648
+ (s1["win_periods"] >= MIN_WIN_PER,
649
+ "the block nearly fills the frame -- take the photo slightly zoomed out"),
650
+ (off is not None and off <= MAX_LINE_OFF,
651
+ "the fitted lines do not sit on the rulings"),
652
+ (aspect <= MAX_ASPECT, "the fitted square is not square"),
653
+ (s1["tap"] <= MAX_TAP, "tap was too far from the centre of a block"),
654
+ (qc is not None and qc["ruling_contrast_min"] >= MIN_CONTRAST,
655
+ "one of the rulings is not visible"),
656
+ (s1["ac"] >= MIN_AC, "no repeating grid found near the tap"),
657
+ (rms_fr is not None and rms_fr <= MAX_RMS_FR,
658
+ "the 25 intersections do not form a regular lattice"),
659
+ ]
660
+ ok = all(c for c, _ in checks)
661
+ return {
662
+ "ok": bool(ok), "confidence": round(conf, 3),
663
  "corners": [[int(round(float(x))), int(round(float(y)))] for x, y in quad],
664
  "model": sel["model"] if sel else "similarity(comb only)",
665
  "model_rms_px": None if not sel else round(sel["rms"], 2),
666
+ "model_rms_frac": None if rms_fr is None else round(rms_fr, 3),
667
  "model_rms_all": None if not sel else sel["rms_all"],
668
+ "angle_deg": round(float(s1["ang"]), 2),
669
  "period_px": round(float(per_orig), 1),
670
+ "block_px": [int(round(bw)), int(round(bh))],
671
+ "block_aspect": round(aspect, 3),
672
  "drift_correction": drift_info,
673
  "keystone_ratio": round(float(keystone), 4),
674
  # Honest about this one: bootstrapping gives a keystone SD of
675
+ # 0.011-0.020, so a ratio under ~1.04 is not distinguishable from
676
+ # relocation noise on a single photo. Do not report it as tilt.
677
  "keystone_significant": bool(keystone > 1.04),
678
+ "tap_offset_periods": round(float(s1["tap"]), 3),
679
+ "tooth_snr": round(float(s1["snr"]), 2), # reported, never gated
680
+ "ac_peak": round(float(s1["ac"]), 3),
681
+ "window_periods": round(float(s1["win_periods"]), 2),
682
  "mean_line_strength": round(float(strength), 2) if sel else None,
683
  "qc": qc,
684
+ "reason": "ok" if ok else next(m for c, m in checks if not c),
685
+ "_Hm": Hm,
686
  }
687
+
688
+
689
+ def detect_from_seed(image, seed_xy, verify=True, period_hint=None,
690
+ lock=False, want_debug=False):
691
+ """One tap -> the four corners of the surrounding 4x4 counting block.
692
+
693
+ Parameters
694
+ ----------
695
+ image : ndarray, HxW grey or HxWx3 RGB. Use the FULL-resolution image;
696
+ corners come back in that same pixel space.
697
+ seed_xy : (x, y) tap position in that same pixel space.
698
+ verify : run the independent line-offset QC. Strongly recommended -- it is
699
+ the only check that looks at the image rather than at the fit's own
700
+ residual, and `ok` is gated on it.
701
+ period_hint : start the scale fixed point from a known ruling period, e.g.
702
+ the consensus of several photos from one session. See detect_batch.
703
+ lock : with period_hint, pin the comb to that period instead of letting it
704
+ search +/-15%.
705
+
706
+ Returns a JSON-safe dict. Always check ``ok`` before using ``corners``.
707
+ """
708
+ g = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) if image.ndim == 3 else image
709
+ g = np.ascontiguousarray(g)
710
+ H, W = g.shape[:2]
711
+ sx, sy = float(seed_xy[0]), float(seed_xy[1])
712
+ if not (0 <= sx < W and 0 <= sy < H):
713
+ return {"ok": False, "reason": "tap was outside the image"}
714
+ if min(H, W) < 300:
715
+ return {"ok": False, "reason": "image too small for grid detection"}
716
+
717
+ if period_hint and period_hint > 0:
718
+ lo, hi = int(0.72 * PER_WORK), int(1.45 * PER_WORK)
719
+ s1 = _pass(g, sx, sy, 0.5 * WIN_PERIODS * period_hint,
720
+ PER_WORK / period_hint, lo, hi, flatten_per=PER_WORK,
721
+ lock=(PER_WORK if lock else None))
722
+ trace = ["hint %.1f" % period_hint]
723
+ if s1 is None:
724
+ s1, trace = _scale_lock(g, sx, sy)
725
+ else:
726
+ s1, trace = _scale_lock(g, sx, sy)
727
+ if s1 is None:
728
+ return {"ok": False, "reason": "no repeating grid found near the tap"}
729
+
730
+ out = _finish(g, s1, verify)
731
+
732
+ # Sub-harmonic guard. Only ever replaces the answer with one whose own
733
+ # image-measured QC is at least as good, so it cannot make things worse.
734
+ halved = 0
735
+ while halved < SUBHARM_MAX and _midpoint_ratio(g, out["corners"]) >= SUBHARM:
736
+ s2, t2 = _lock_from(g, sx, sy, s1["per"] / 2.0)
737
+ if s2 is None:
738
+ break
739
+ alt = _finish(g, s2, verify)
740
+ a_off = (alt["qc"] or {}).get("line_offset_mean_frac", 9.9)
741
+ o_off = (out["qc"] or {}).get("line_offset_mean_frac", 9.9)
742
+ better = (alt["ok"] and not out["ok"]) or (a_off <= o_off + 1e-6)
743
+ if not better or _midpoint_ratio(g, alt["corners"]) >= SUBHARM:
744
+ break
745
+ s1, out, halved = s2, alt, halved + 1
746
+ trace = list(trace) + ["halved -> %.1f" % s1["per"]] + t2
747
+
748
+ out["scale_trace"] = trace
749
+ out["subharmonic_halvings"] = halved
750
+ Hm = out.pop("_Hm")
751
  if want_debug:
752
+ out["_H"], out["_q1"] = Hm, s1["q1"]
753
+ return out
754
+
755
+
756
+ def detect_batch(images, seeds=None, verify=True):
757
+ """Detect on several squares photographed in one session.
758
+
759
+ The squares come off the same haemocytometer with the phone in the same
760
+ place, so they share one ruling period and differ only in phase. Fitting
761
+ each alone throws that away. So: fit each alone, take the median period of
762
+ the accepted fits, and refit any photo that disagrees by more than CONS_TOL
763
+ with the comb pinned to the consensus -- keeping the refit only if its own
764
+ image-measured QC is no worse.
765
+
766
+ Guard: if the raw periods disagree by more than CONS_SPREAD the photos were
767
+ not taken at one zoom and no consensus is applied. On the three real sets
768
+ the periods already agree to 3-4%, inside CONS_TOL, so this is a safety net
769
+ for the odd photo rather than something that fires routinely.
770
+ """
771
+ n = len(images)
772
+ seeds = list(seeds) if seeds else [None] * n
773
+ out = []
774
+ for im, s in zip(images, seeds):
775
+ if s is None:
776
+ s = (im.shape[1] / 2.0, im.shape[0] / 2.0)
777
+ out.append(detect_from_seed(im, s, verify=verify))
778
+ pers = [r["period_px"] for r in out if r["ok"]]
779
+ if len(pers) < 2:
780
+ for r in out:
781
+ r["consensus"] = {"applied": False, "why": "fewer than two accepted fits"}
782
+ return out
783
+ med = float(np.median(pers))
784
+ spread = (max(pers) - min(pers)) / med
785
+ if spread > CONS_SPREAD:
786
+ for r in out:
787
+ r["consensus"] = {"applied": False, "why": "photos not at one zoom",
788
+ "spread": round(spread, 3)}
789
+ return out
790
+ for i, r in enumerate(out):
791
+ info = {"applied": False, "median_period": round(med, 1),
792
+ "spread": round(spread, 3)}
793
+ if r["ok"] and abs(r["period_px"] / med - 1.0) <= CONS_TOL:
794
+ info["why"] = "already agrees"
795
+ r["consensus"] = info
796
+ continue
797
+ s = seeds[i] or (images[i].shape[1] / 2.0, images[i].shape[0] / 2.0)
798
+ alt = detect_from_seed(images[i], s, verify=verify, period_hint=med, lock=True)
799
+ old = (r["qc"] or {}).get("line_offset_mean_frac", 9.9)
800
+ new = (alt["qc"] or {}).get("line_offset_mean_frac", 9.9)
801
+ if alt["ok"] and new <= old * 1.15 + 1e-6:
802
+ info.update({"applied": True, "was_period": r["period_px"],
803
+ "qc_before": old, "qc_after": new})
804
+ alt["consensus"] = info
805
+ out[i] = alt
806
+ else:
807
+ info["why"] = "refit was not better"
808
+ r["consensus"] = info
809
  return out