Upload 4 files
Browse files- README.md +18 -0
- app.py +68 -234
- 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 |
-
|
| 561 |
-
|
| 562 |
-
|
| 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 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 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 |
-
#
|
| 632 |
-
args_list = [
|
|
|
|
|
|
|
|
|
|
| 633 |
|
| 634 |
-
patch_results = [] # (mask, row_start, col_start) in
|
| 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
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 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
|
| 724 |
# clicked on the full-resolution upload, so they are already in this space.
|
| 725 |
-
# (Need
|
| 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
|
| 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 |
-
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
else:
|
| 775 |
-
|
| 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 |
-
|
| 783 |
-
|
| 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
|
| 1890 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2198 |
continue
|
| 2199 |
-
r =
|
| 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
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
In these phone-through-eyepiece photos the rulings are only ~5-10 grey levels
|
| 15 |
-
darker than background
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 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 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
-
|
| 89 |
-
WORK
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
#
|
| 96 |
-
|
| 97 |
-
#
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 388 |
-
|
| 389 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
"""
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) <
|
| 414 |
-
return
|
| 415 |
-
|
| 416 |
-
|
| 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 |
-
|
| 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 =
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
px, acx = _period(vx, lo, hi)
|
| 430 |
if py is None or px is None:
|
| 431 |
-
return
|
| 432 |
per0 = 0.5 * (py + px)
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
| 435 |
if fy is None or fx is None:
|
| 436 |
-
return
|
| 437 |
-
|
| 438 |
-
|
| 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 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 472 |
-
if
|
| 473 |
-
quad =
|
| 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 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
"
|
| 509 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
| 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
|
| 521 |
-
#
|
| 522 |
"keystone_significant": bool(keystone > 1.04),
|
| 523 |
-
"tap_offset_periods": round(float(
|
| 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
|
|
|
|
| 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
|