LiangLabUMB commited on
Commit
55760c5
·
verified ·
1 Parent(s): 4422dd9

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +315 -747
  2. corner_canvas.js +328 -0
app.py CHANGED
@@ -9,7 +9,8 @@ from PIL import Image, ImageDraw, ImageOps
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
12
- from concurrent.futures import ThreadPoolExecutor, as_completed
 
13
  import csv
14
  import joblib
15
  import os
@@ -629,170 +630,35 @@ def toggle_stereological_mode(use_stereology):
629
 
630
 
631
  # ---------------------------------------------------------------------------
632
- # Patch segmentation
 
 
 
 
 
 
 
633
  # ---------------------------------------------------------------------------
634
- PATCH_SIZE = 512 # target patch side length
635
- PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
636
- MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
637
-
638
-
639
- def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
640
- """
641
- Split image into overlapping patches.
642
- Returns list of (patch_np, row_start, col_start) tuples.
643
- """
644
- h, w = image_np.shape[:2]
645
- patches = []
646
- row = 0
647
- while row < h:
648
- row_end = min(row + patch_size, h)
649
- col = 0
650
- while col < w:
651
- col_end = min(col + patch_size, w)
652
- patch = image_np[row:row_end, col:col_end]
653
- patches.append((patch, row, col))
654
- if col_end == w:
655
- break
656
- col += patch_size - overlap
657
- if row_end == h:
658
- break
659
- row += patch_size - overlap
660
- return patches
661
-
662
-
663
- def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
664
- """
665
- Stitch per-patch masks into a single full-image mask.
666
-
667
- Strategy:
668
- - Each patch gets a unique ID offset so cell IDs never collide.
669
- - Patches are pasted into the canvas using a priority canvas that
670
- gives interior pixels precedence over overlap-border pixels.
671
- - After pasting, cells whose centroids fall in the overlap zone
672
- of two adjacent patches are deduplicated: if two cells from
673
- different patches share >50% IoU they are the same cell — keep
674
- the one whose centroid is furthest from a patch edge.
675
- """
676
- full_mask = np.zeros((full_h, full_w), dtype=np.int32)
677
- # track which patch_idx owns each pixel (used for overlap resolution)
678
- owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
679
- # distance-to-nearest-edge for the owning patch (higher = more central)
680
- priority = np.zeros((full_h, full_w), dtype=np.float32)
681
-
682
- id_offset = 0
683
- patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
684
-
685
- for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
686
- ph, pw = mask_patch.shape
687
- # offset all non-zero IDs so they're globally unique.
688
- # Widen BEFORE adding: cellpose returns uint16 masks, and adding the
689
- # offset in that dtype wraps once ids pass 65535.
690
- mask_patch = mask_patch.astype(np.int32, copy=False)
691
- shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
692
-
693
- # compute per-pixel priority = min distance to any patch edge
694
- rows_idx = np.arange(ph)
695
- cols_idx = np.arange(pw)
696
- dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
697
- dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
698
- pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
699
-
700
- roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
701
- roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
702
- roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
703
-
704
- # where this patch has higher priority, overwrite
705
- better = pri_patch > roi_pri
706
- roi_full [better] = shifted [better]
707
- roi_owner[better] = patch_idx
708
- roi_pri [better] = pri_patch [better]
709
-
710
- max_id = int(mask_patch.max())
711
- patch_meta.append((id_offset, row_start, col_start, ph, pw))
712
- id_offset += max_id + 1
713
-
714
- # --- Renumber to compact sequential IDs ---
715
- unique_ids = np.unique(full_mask)
716
- unique_ids = unique_ids[unique_ids > 0]
717
- renumbered = np.zeros_like(full_mask)
718
- for new_id, old_id in enumerate(unique_ids, start=1):
719
- renumbered[full_mask == old_id] = new_id
720
-
721
- return renumbered
722
-
723
-
724
- def _segment_patch(args):
725
- """Worker: run cellpose on a single patch. Called from a thread pool."""
726
- patch_np, row_start, col_start, model_filename, hf_repo = args
727
- # Each thread uses the shared loaded_models cache (GIL-safe for reads;
728
- # model.eval() releases the GIL during GPU work so threads overlap.)
729
- model_path = resolve_model_path(hf_repo, model_filename)
730
- if model_filename in loaded_models:
731
- model = loaded_models[model_filename]
732
- else:
733
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
734
- loaded_models[model_filename] = model
735
-
736
- # No channels= argument: it is deprecated in cellpose 4 and ignored,
737
- # and its old meaning ([0,0] = convert to grayscale) is the OPPOSITE of
738
- # what now happens -- CP4 segments the RGB image. Passing it made the
739
- # code read as grayscale while running in colour, and emitted a
740
- # deprecation warning on every patch.
741
- mask, _, _ = model.eval(patch_np, diameter=None)
742
- return mask, row_start, col_start
743
-
744
-
745
  @spaces.GPU(duration=30)
746
- def run_segmentation_patched(image_np, model_filename):
747
- """
748
- Split image into overlapping patches, run Cellpose on each in parallel,
749
- then stitch back into a single full-resolution mask.
750
 
751
  The @spaces.GPU decorator sits HERE rather than on run_segmentation because
752
  ZeroGPU quota is charged for the whole time the GPU is attached, and the
753
  caller does a lot of CPU-only work -- decoding a 12 MP photo, the perspective
754
- warp, size filtering, overlay rendering. Holding an A10G through all of that
755
- burnt visitors' daily quota on NumPy.
756
- Falls back to whole-image segmentation if the image is small enough
757
- that patching adds overhead without benefit.
758
  """
759
  _t_gpu = time.perf_counter()
760
- h, w = image_np.shape[:2]
761
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
762
  if model_filename in loaded_models:
763
  model = loaded_models[model_filename]
764
  else:
765
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
 
766
  loaded_models[model_filename] = model
767
-
768
- # Small images: no benefit from patching
769
- if max(h, w) <= MIN_PATCH_DIM * 2:
770
- mask, _, _ = model.eval(image_np, diameter=None)
771
- return mask, 1, time.perf_counter() - _t_gpu
772
-
773
- patches = _split_patches(image_np)
774
- n_patches = len(patches)
775
-
776
- # Build argument list for the thread pool
777
- args_list = [
778
- (patch, r, c, model_filename, HF_REPO_ID)
779
- for patch, r, c in patches
780
- ]
781
-
782
- patch_results = [] # (mask, row_start, col_start) in submission order
783
-
784
- # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
785
- with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
786
- futures = {pool.submit(_segment_patch, a): a for a in args_list}
787
- for future in as_completed(futures):
788
- mask_patch, row_start, col_start = future.result()
789
- patch_results.append((mask_patch, row_start, col_start))
790
-
791
- # Re-sort by (row, col) so stitching is deterministic
792
- patch_results.sort(key=lambda x: (x[1], x[2]))
793
-
794
- full_mask = _merge_patch_masks(patch_results, h, w)
795
- return full_mask, n_patches, time.perf_counter() - _t_gpu
796
 
797
 
798
  def render_segmentation(base_masks, processed_image_np,
@@ -871,8 +737,7 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
871
  else:
872
  processed_image_np = image_np
873
 
874
- # Run patch-parallel Cellpose segmentation
875
- masks_raw, n_patches, gpu_seconds = run_segmentation_patched(
876
  processed_image_np, model_filename)
877
 
878
  # Same single pass feeds the log line and the recommendation below;
@@ -948,7 +813,6 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
948
  info_msg += filter_msg
949
  info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
950
  info_msg += f"Confluency: {confluency:.1f}%\n"
951
- info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
952
  if use_stereology:
953
  info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
954
  info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
@@ -1601,100 +1465,6 @@ def prepare_export(stored_masks, stored_image, threshold_bias):
1601
  return path, msg
1602
 
1603
 
1604
- # ---------------------------------------------------------------------------
1605
- # Tab builder
1606
- # ---------------------------------------------------------------------------
1607
-
1608
- def draw_polygon_overlay(image_pil, points):
1609
- """
1610
- Draw numbered vertex dots and polygon edges onto a copy of image_pil.
1611
- points: list of (x, y) tuples in preview pixel space.
1612
- Returns a new PIL image.
1613
- """
1614
- img = image_pil.copy().convert("RGBA")
1615
- overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
1616
- draw = ImageDraw.Draw(overlay)
1617
-
1618
- if len(points) >= 2:
1619
- # Draw edges
1620
- for i in range(len(points) - 1):
1621
- draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
1622
- if len(points) == 4:
1623
- draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
1624
- # Semi-transparent fill
1625
- draw.polygon(points, fill=(74, 170, 255, 50))
1626
-
1627
- # Draw vertex dots + numbers
1628
- r = max(8, min(img.width, img.height) // 60)
1629
- for i, (x, y) in enumerate(points):
1630
- draw.ellipse([x - r, y - r, x + r, y + r],
1631
- fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
1632
- draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
1633
-
1634
- combined = Image.alpha_composite(img, overlay)
1635
- return combined.convert("RGB")
1636
-
1637
-
1638
- PREVIEW_MAX = 1024 # crop preview is drawn at this size, not full resolution
1639
-
1640
-
1641
- def make_preview(image_pil):
1642
- """Downscaled copy for the crop picker, plus preview->original scale factor.
1643
-
1644
- Re-encoding a 12 MP photo on every tap costs ~3 s and ~12 MB of transfer,
1645
- which is what made corner selection feel broken on a phone. The picker only
1646
- ever renders a few hundred pixels tall, so nothing is lost by drawing on a
1647
- small copy and keeping the clicked coordinates in original-image space.
1648
- """
1649
- if image_pil is None:
1650
- return None, 1.0
1651
- preview = image_pil.copy()
1652
- preview.thumbnail((PREVIEW_MAX, PREVIEW_MAX), Image.LANCZOS)
1653
- return preview, (preview.width / float(image_pil.width)) if image_pil.width else 1.0
1654
-
1655
-
1656
- ZOOM_FACTOR = 6 # zoom window is 1/6 of the image width
1657
-
1658
-
1659
- def make_zoom_view(full_img, cx_full, cy_full):
1660
- """Zoomed window of the ORIGINAL pixels, centred on a rough tap.
1661
-
1662
- Corner placement fails on a phone because the fingertip covers the target.
1663
- Zooming makes the target ~6x larger than the finger, so the second tap
1664
- needs no precision at all -- and cropping from the original rather than the
1665
- preview means the second tap also gains real detail, not interpolation.
1666
- """
1667
- W, H = full_img.size
1668
- win_w = max(32, W // ZOOM_FACTOR)
1669
- win_h = max(24, int(win_w * 0.75))
1670
- x0 = int(min(max(0, cx_full - win_w // 2), max(0, W - win_w)))
1671
- y0 = int(min(max(0, cy_full - win_h // 2), max(0, H - win_h)))
1672
- win_w, win_h = min(win_w, W - x0), min(win_h, H - y0)
1673
-
1674
- view = full_img.crop((x0, y0, x0 + win_w, y0 + win_h))
1675
- out_w = PREVIEW_MAX
1676
- view = view.resize((out_w, max(1, int(out_w * win_h / win_w))), Image.LANCZOS)
1677
- zscale = view.width / float(win_w) # view px per original px
1678
-
1679
- d = ImageDraw.Draw(view)
1680
- cx_v = (cx_full - x0) * zscale
1681
- cy_v = (cy_full - y0) * zscale
1682
- arm = max(20, view.width // 25)
1683
- for dx, dy in ((1, 0), (0, 1)):
1684
- d.line([(cx_v - arm * dx, cy_v - arm * dy), (cx_v + arm * dx, cy_v + arm * dy)],
1685
- fill=(255, 90, 90, 255), width=2)
1686
- d.ellipse([cx_v - 4, cy_v - 4, cx_v + 4, cy_v + 4], outline=(255, 90, 90), width=2)
1687
- return view, {"x0": x0, "y0": y0, "zscale": zscale}
1688
-
1689
-
1690
- def clear_crop_points(image_pil):
1691
- """Reset polygon — return original image with no overlay and empty points."""
1692
- return image_pil, []
1693
-
1694
-
1695
-
1696
-
1697
-
1698
  # ---------------------------------------------------------------------------
1699
  # Label correction grid
1700
  # ---------------------------------------------------------------------------
@@ -1854,205 +1624,209 @@ GENERAL_MODEL = "General Model"
1854
  MAX_SQUARES = 4
1855
 
1856
 
1857
- def _load_uploads(files):
1858
- """Fan a multi-file upload out into the four per-square image slots.
1859
-
1860
- Returns FILE PATHS, not PIL images, and that is deliberate. gradio's
1861
- save_image() passes a str path straight through untouched, but re-encodes a
1862
- PIL image to lossy WebP (gr.Image.format defaults to "webp"). Measured on a
1863
- synthetic 6-grey-level ruling, that round trip costs ~9% of the contrast --
1864
- and contrast is the entire signal grid detection runs on. Returning the path
1865
- also leaves EXIF orientation to gradio's own preprocessing, exactly as for a
1866
- per-square upload, so the two upload routes cannot drift apart.
1867
-
1868
- Files are sorted by filename, so ..._Q1..Q4 land in Square 1..4. Anything
1869
- unreadable, or beyond the fourth image, is named in the status line rather
1870
- than dropped silently: alphabetical order is not always what the user
1871
- expected, so they need to see which file went where.
1872
-
1873
- Each slot assignment fires that square's own on_upload via gradio's
1874
- .change(), which is documented to trigger on a function update and not only
1875
- on user input. Nothing else in the picker needs to know about this.
1876
- """
1877
- slots = [gr.update() for _ in range(MAX_SQUARES)]
1878
- if not files:
1879
- return (*slots, "*Optional — or upload each square separately below*")
1880
-
1881
- paths = sorted((str(getattr(f, "name", f)) for f in files),
1882
- key=lambda p: os.path.basename(p).lower())
1883
- used, bad, leftover = [], [], []
1884
- for p in paths:
1885
- base = os.path.basename(p)
1886
- if len(used) >= MAX_SQUARES:
1887
- leftover.append(base)
1888
- continue
1889
- try:
1890
- with Image.open(p) as probe: # validate without re-encoding
1891
- probe.verify()
1892
- except Exception:
1893
- bad.append(base)
1894
- continue
1895
- slots[len(used)] = p
1896
- used.append(base)
1897
-
1898
- if not used:
1899
- return (*slots, "⚠️ *None of those files could be read as images.*")
1900
- msg = "*%s*" % " · ".join(
1901
- "Square %d ← %s" % (i + 1, n) for i, n in enumerate(used))
1902
- if bad:
1903
- msg += " \n⚠️ *Unreadable, skipped: %s*" % ", ".join(bad)
1904
- if leftover:
1905
- msg += (" \n⚠️ *Not used — there are only 4 squares: %s*"
1906
- % ", ".join(leftover))
1907
- msg += " \n*Now tap once in the centre of each block.*"
1908
- return (*slots, msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1909
 
1910
 
1911
- AUTO_LABEL = "Auto \u2014 one tap in the centre"
1912
- MANUAL_LABEL = "Manual \u2014 tap the 4 corners"
 
 
 
 
 
 
1913
 
1914
 
1915
- def _crop_picker(label):
1916
- """Upload + corner picker, with one-tap grid auto-detection.
 
 
 
 
 
 
 
 
 
 
 
1917
 
1918
- Auto mode: one tap near the centre of the 4x4 block proposes all four
1919
- corners via grid_seeded.detect_from_seed. The user accepts by moving on, or
1920
- presses "Tap corners myself" to fall back.
1921
 
1922
- Manual mode is the original tap-roughly-then-tap-in-the-zoom flow, byte for
1923
- byte unchanged, and is also where a failed detection drops the user.
1924
 
1925
- Detection is CPU-only and deliberately NOT behind @spaces.GPU.
1926
  """
1927
- img_input = gr.Image(type="pil", label=label, image_mode="RGB", height=220)
1928
- crop_display = gr.Image(
1929
- type="pil",
1930
- label="Auto: tap the centre of the block \u2014 Manual: tap roughly, then precisely",
1931
- interactive=True, height=340, format="jpeg",
1932
- show_download_button=False,
1933
- )
1934
- crop_status = gr.Markdown("*Upload an image to set the counting square*")
1935
- crop_mode = gr.Radio([AUTO_LABEL, MANUAL_LABEL], value=AUTO_LABEL,
1936
- label="How to set the counting square", interactive=True)
1937
- with gr.Row():
1938
- clear_btn = gr.Button("\u2715 Clear", size="sm")
1939
- manual_btn = gr.Button("\u270e Tap corners myself", size="sm")
1940
- cancel_btn = gr.Button("\u21a9 Back to full view", size="sm", visible=False)
1941
-
1942
- st = {
1943
- "img": img_input,
1944
- "display": crop_display,
1945
- "status": crop_status,
1946
- "mode": crop_mode,
1947
- "points": gr.State(value=[]),
1948
- "base": gr.State(value=None),
1949
- "scale": gr.State(value=1.0),
1950
- "zoom": gr.State(value=None),
1951
- }
1952
-
1953
- def on_upload(img):
1954
- if img is None:
1955
- return None, None, 1.0, "*Upload an image to set the counting square*"
1956
- preview, scale = make_preview(img)
1957
- return preview, preview, scale, "*Tap once in the centre of the 4\u00d74 block*"
1958
 
1959
- img_input.change(
1960
- fn=on_upload, inputs=[img_input],
1961
- outputs=[crop_display, st["base"], st["scale"], crop_status]
1962
- ).then(fn=lambda: ([], None), outputs=[st["points"], st["zoom"]])
1963
 
1964
- def _overview(preview, points, scale):
1965
- return draw_polygon_overlay(
1966
- preview, [(int(x * scale), int(y * scale)) for x, y in points])
1967
 
1968
- def _auto_status(res):
1969
- qc = res.get("qc") or {}
1970
- bits = ["**Square placed** \u2014 confidence {:.0%}".format(res["confidence"])]
1971
- if qc:
1972
- bits.append("edges sit {:.0f} px ({:.1f}% of a small square) from the "
1973
- "printed rulings".format(qc["line_offset_mean_px"],
1974
- 100 * qc["line_offset_mean_frac"]))
1975
- bits.append("tilt {:+.1f}\u00b0".format(res["angle_deg"]))
1976
- bits.append("fit: " + res["model"])
1977
- return (" \u2022 ".join(bits) + " \n*Correct? Move on to the next image. "
1978
- "If not, press **\u270e Tap corners myself**.*")
1979
-
1980
- def on_click(full_img, preview, points, scale, zoom, mode, evt: gr.SelectData):
1981
- points = list(points or [])
1982
- if preview is None or full_img is None:
1983
- return gr.update(), points, zoom, gr.update(), gr.update(), mode
1984
- if evt is None or evt.index is None or evt.index[0] is None:
1985
- return (gr.update(), points, zoom,
1986
- "*Tap not registered \u2014 try again inside the image*",
1987
- gr.update(), mode)
1988
- tx, ty = int(evt.index[0]), int(evt.index[1])
1989
-
1990
- # ---- auto: one tap in the centre proposes all four corners -------
1991
- if mode == AUTO_LABEL and zoom is None and not points:
1992
- res = detect_from_seed(np.array(full_img.convert("RGB")),
1993
- (tx / scale, ty / scale))
1994
- if res.get("ok"):
1995
- pts = [(int(x), int(y)) for x, y in res["corners"]]
1996
- return (_overview(preview, pts, scale), pts, None,
1997
- _auto_status(res), gr.update(visible=False), mode)
1998
- return (_overview(preview, [], scale), [], None,
1999
- "\u26a0\ufe0f *Auto-detect could not place the square reliably "
2000
- "({}). Falling back \u2014 tap roughly near corner 1.*".format(
2001
- res.get("reason", "no fit")),
2002
- gr.update(visible=False), MANUAL_LABEL)
2003
-
2004
- # ---- manual: original tap-roughly-then-tap-in-the-zoom flow ------
2005
- if zoom is None:
2006
- if len(points) >= 4:
2007
- return (_overview(preview, points, scale), points, None,
2008
- "*4 corners set \u2713 \u2014 \u2715 Clear to redo*",
2009
- gr.update(visible=False), mode)
2010
- view, z = make_zoom_view(full_img, tx / scale, ty / scale)
2011
- return (view, points, z,
2012
- "*Zoomed \u2014 tap corner {} precisely*".format(len(points) + 1),
2013
- gr.update(visible=True), mode)
2014
-
2015
- x = zoom["x0"] + tx / zoom["zscale"]
2016
- y = zoom["y0"] + ty / zoom["zscale"]
2017
- W, H = full_img.size
2018
- pts = points + [(int(min(max(0, x), W - 1)), int(min(max(0, y), H - 1)))]
2019
- n = len(pts)
2020
- msg = ("*{} / 4 corners \u2014 tap roughly near corner {}*".format(n, n + 1)
2021
- if n < 4 else "*4 corners set \u2713*")
2022
- return (_overview(preview, pts, scale), pts, None, msg,
2023
- gr.update(visible=False), mode)
2024
-
2025
- crop_display.select(
2026
- fn=on_click,
2027
- inputs=[img_input, st["base"], st["points"], st["scale"], st["zoom"], crop_mode],
2028
- outputs=[crop_display, st["points"], st["zoom"], crop_status, cancel_btn,
2029
- crop_mode])
2030
-
2031
- cancel_btn.click(
2032
- fn=lambda preview, pts, sc: (
2033
- _overview(preview, pts or [], sc), None,
2034
- "*Back to full view \u2014 {} / 4 corners*".format(len(pts or [])),
2035
- gr.update(visible=False)),
2036
- inputs=[st["base"], st["points"], st["scale"]],
2037
- outputs=[crop_display, st["zoom"], crop_status, cancel_btn])
2038
-
2039
- def on_clear(base, mode):
2040
- msg = ("*Cleared \u2014 tap once in the centre of the 4\u00d74 block*"
2041
- if mode == AUTO_LABEL else "*Cleared \u2014 tap roughly near corner 1*")
2042
- return base, [], None, msg, gr.update(visible=False)
2043
-
2044
- clear_btn.click(
2045
- fn=on_clear, inputs=[st["base"], crop_mode],
2046
- outputs=[crop_display, st["points"], st["zoom"], crop_status, cancel_btn])
2047
-
2048
- manual_btn.click(
2049
- fn=lambda base: (base, [], None, "*Tap roughly near corner 1*",
2050
- gr.update(visible=False), MANUAL_LABEL),
2051
- inputs=[st["base"]],
2052
- outputs=[crop_display, st["points"], st["zoom"], crop_status, cancel_btn,
2053
- crop_mode])
2054
-
2055
- return st
2056
 
2057
 
2058
  def _segment_one(image, points, model_name, use_min, min_px,
@@ -2111,71 +1885,24 @@ def _build_correction(res):
2111
  # Ids are never renumbered, which keeps the viability label map valid however
2112
  # the selection changes.
2113
  # ---------------------------------------------------------------------------
2114
- REGION_GRID = "Grid — tap block centre"
2115
- REGION_RECT = "Rectangle — drag to crop"
2116
- REGION_WHOLE = "Whole image"
2117
  F_STEREO_PCT = 1 # the same 1% / 1% exclusion comprehensive_counting ships with
2118
  F_PICK_RADIUS = 8 # px: a tap this close to an object still selects it
2119
 
2120
 
2121
- def f_region_info(region, is_block):
2122
  """(table label, may enter the concentration).
2123
 
2124
- Only a grid-detected block, or a rectangle the user has declared to be the
2125
- block, has a known area of 1 mm^2. Anything else still gets counts and
2126
- viability, but multiplying it by the chamber constant would be meaningless.
2127
  """
2128
- if region == REGION_GRID:
2129
- return "Grid", True
2130
- if region == REGION_RECT:
2131
- return ("Rectangle (block)", True) if is_block else ("Rectangle", False)
2132
  return "Whole image", False
2133
 
2134
 
2135
- def f_crop_from_editor(full, preview, scale, value):
2136
- """Full-resolution crop for the rectangle the user dragged in the editor.
2137
-
2138
- The editor is fed the <=1024 px preview, not the 12 MP original, so only a
2139
- small image goes to the browser and back. The crop it returns is then found
2140
- in the preview by template matching, which gives its box independently of
2141
- how the editor reports its own transforms, and the box is re-cut from the
2142
- ORIGINAL pixels so segmentation sees full detail.
2143
-
2144
- Returns (PIL crop or None, message).
2145
- """
2146
- if full is None or preview is None:
2147
- return None, "no image"
2148
- crop = None
2149
- if isinstance(value, dict):
2150
- crop = value.get("composite")
2151
- if crop is None:
2152
- crop = value.get("background")
2153
- elif isinstance(value, Image.Image):
2154
- crop = value
2155
- if crop is None:
2156
- return None, "no rectangle drawn"
2157
- crop = crop.convert("RGB")
2158
- pw, ph = preview.size
2159
- cw, ch = crop.size
2160
- if (cw, ch) == (pw, ph) or cw < 16 or ch < 16:
2161
- return None, "no rectangle drawn — use the crop tool to drag one"
2162
- if cw > pw or ch > ph:
2163
- return None, "crop is larger than the image"
2164
-
2165
- src = cv2.cvtColor(np.asarray(preview.convert("RGB")), cv2.COLOR_RGB2GRAY)
2166
- tpl = cv2.cvtColor(np.asarray(crop), cv2.COLOR_RGB2GRAY)
2167
- res = cv2.matchTemplate(src, tpl, cv2.TM_SQDIFF_NORMED)
2168
- min_val, _mx, (x0, y0), _ml = cv2.minMaxLoc(res)
2169
- if min_val > 0.02:
2170
- return None, "could not locate the crop in the image (%.3f)" % min_val
2171
- W, H = full.size
2172
- box = (int(round(x0 / scale)), int(round(y0 / scale)),
2173
- min(W, int(round((x0 + cw) / scale))),
2174
- min(H, int(round((y0 + ch) / scale))))
2175
- return full.crop(box), "rectangle %d×%d px" % (box[2] - box[0],
2176
- box[3] - box[1])
2177
-
2178
-
2179
  def f_segment_square(image, points):
2180
  """Segment one square and classify viability over every object found.
2181
 
@@ -2299,22 +2026,21 @@ def f_pick_object(masks, x, y, radius=F_PICK_RADIUS):
2299
  return int(win[ys[k], xs[k]]) if d2[k] <= radius * radius else 0
2300
 
2301
 
2302
- with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
 
2303
  gr.Markdown("# CellposeCellCounter")
2304
 
2305
  # =======================================================================
2306
  # Tab 1 — hemocytometer counting (4 squares, one run)
2307
  # =======================================================================
2308
  # ==================================================================
2309
- # Fast counting -- upload, choose a region per square, run, check.
2310
  #
2311
- # Each square chooses how its counting region is set:
2312
- # Grid one tap in the block centre -> grid_seeded detection
2313
- # Rectangle drag a crop box in an image editor
2314
- # Whole image no crop at all
2315
- # Only a grid block, or a rectangle the user declares to be the block, has
2316
- # a known 1 mm^2 area, so only those enter the concentration. The others
2317
- # still report counts and viability.
2318
  #
2319
  # After Run, the check section shows every square's segmentation. Tapping
2320
  # an object marks it as debris (grey) and tapping again restores it; the
@@ -2324,13 +2050,13 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2324
  with gr.Tab("fast_counting"):
2325
  gr.Markdown(
2326
  "### Fast counting\n"
2327
- "**1 ·** Upload the four square images. **2 ·** For each square "
2328
- "choose how to set the counting region: **Grid** (tap the centre "
2329
- "of the 4×4 block), **Rectangle** (drag a box with the crop tool), "
2330
- "or **Whole image**. **3 ·** Press **Run**, then check each "
2331
  "segmentation below and tap any debris to remove it.\n\n"
2332
- "Concentration uses only squares with a known 1 mm² area: grid "
2333
- "blocks, and rectangles you tick as the counting block."
2334
  )
2335
 
2336
  f_upload = gr.Files(label="1 · Upload the four square images",
@@ -2338,39 +2064,20 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2338
  height=95)
2339
  f_status = gr.Markdown("*Waiting for images.*")
2340
 
2341
- # Two squares per row: at four across the disc renders too small to
2342
- # see where the block centre is, which is the one thing the user has to
2343
- # judge in Grid mode.
2344
- f_mode, f_sel, f_edit, f_block = [], [], [], []
2345
  for _pair in range(2):
2346
  with gr.Row():
2347
  for _k in (2 * _pair, 2 * _pair + 1):
2348
  with gr.Column():
2349
- f_mode.append(gr.Radio(
2350
- [REGION_GRID, REGION_RECT, REGION_WHOLE],
2351
- value=REGION_GRID,
2352
- label="Square %d — counting region" % (_k + 1)))
2353
- f_sel.append(gr.Image(
2354
- type="pil",
2355
- label="Square %d — tap the block centre" % (_k + 1),
2356
- height=460, interactive=True, format="jpeg",
2357
- show_download_button=False))
2358
- f_edit.append(gr.ImageEditor(
2359
- type="pil",
2360
- label="Square %d — drag a crop rectangle" % (_k + 1),
2361
- height=460, visible=False, sources=(),
2362
- transforms=("crop",), brush=False, eraser=False,
2363
- layers=False, format="png",
2364
- show_download_button=False))
2365
- f_block.append(gr.Checkbox(
2366
- label="This rectangle is the 1 mm counting block "
2367
- "(include it in the concentration)",
2368
- value=False, visible=False))
2369
 
2370
  with gr.Row():
2371
- f_clear = gr.Button("✕ Clear selections", size="sm")
2372
- f_run = gr.Button("2 · Run segmentation", variant="primary",
2373
- size="lg")
2374
 
2375
  _F_HEADERS = ["Square", "Region", "Total", "Live", "Dead",
2376
  "Viability %", "Removed as debris"]
@@ -2424,39 +2131,17 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2424
  visible=False)
2425
  f_exp_msg = gr.Markdown()
2426
 
2427
- f_full = [gr.State(None) for _ in range(4)] # full-res PIL, per square
2428
- f_prev = [gr.State(None) for _ in range(4)] # preview PIL
2429
- f_scale = [gr.State(1.0) for _ in range(4)] # preview / full
2430
- f_pts = [gr.State(None) for _ in range(4)] # corners, full-res px
2431
- f_qc = [gr.State(None) for _ in range(4)] # f_segment_square result
2432
-
2433
- def _f_overlay(prev, pts, scale):
2434
- if prev is None:
2435
- return None
2436
- if not pts:
2437
- return prev
2438
- return draw_polygon_overlay(
2439
- prev, [(int(x * scale), int(y * scale)) for x, y in pts])
2440
-
2441
- def _f_detect(full, seed):
2442
- res = detect_from_seed(np.array(full.convert("RGB")), seed)
2443
- if not res.get("ok"):
2444
- return None, res.get("reason", "no fit")
2445
- return [(int(x), int(y)) for x, y in res["corners"]], res
2446
-
2447
- def _f_editor_value(prev):
2448
- if prev is None:
2449
- return None
2450
- return {"background": prev, "layers": [], "composite": None}
2451
 
2452
  def _f_blank_results():
2453
  rows = [["Square %d" % (k + 1)] + ["—"] * (len(_F_HEADERS) - 1)
2454
  for k in range(4)]
2455
  return (rows, None, None, None, None, "")
2456
 
2457
- def _f_summary(qcs, blocks, use_min, min_px, with_images):
2458
  """Table, means and concentration from the stored segmentations
2459
- under the current min size, removals and block ticks."""
2460
  rows, measured, imgs, qc_notes = [], [], [], []
2461
  for k, q in enumerate(qcs):
2462
  if q is None:
@@ -2465,7 +2150,7 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2465
  imgs.append(None)
2466
  continue
2467
  m = f_measure(q, use_min, min_px)
2468
- label, elig = f_region_info(q["region"], blocks[k])
2469
  measured.append((m, elig))
2470
  rows.append(["Square %d" % (k + 1),
2471
  label if elig else label + " · not in conc.",
@@ -2503,170 +2188,48 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2503
  conc += ("\n⚠ From %d of 4 squares — the convention uses "
2504
  "all 4." % n_elig)
2505
  else:
2506
- conc = ("Not computed — no square has a known 1 mm² area. Use "
2507
- "Grid, or tick “This rectangle is the 1 mm counting "
2508
- "block”.")
2509
  qc_msg = ("*" + " · ".join(qc_notes) + "*") if qc_notes else ""
2510
  return (imgs, rows, _mean("total"), _mean("live"), _mean("dead"),
2511
  _mean("viab"), conc, qc_msg)
2512
 
 
 
 
2513
  # ---- upload ------------------------------------------------------
2514
  def f_on_upload(files):
2515
- sels = [gr.update(value=None, visible=True) for _ in range(4)]
2516
- fulls, prevs = [None] * 4, [None] * 4
2517
- scs, ptss = [1.0] * 4, [None] * 4
2518
- modes = [REGION_GRID] * 4
2519
- edits = [gr.update(value=None, visible=False) for _ in range(4)]
2520
- blocks = [gr.update(value=False, visible=False) for _ in range(4)]
2521
- reset = ([None] * 4, [None] * 4, *_f_blank_results(), "")
2522
- if not files:
2523
- return (*sels, *fulls, *prevs, *scs, *ptss, *modes, *edits,
2524
- *blocks, *reset[0], *reset[1], *reset[2:],
2525
- "*Waiting for images.*")
2526
- paths = sorted((str(getattr(f, "name", f)) for f in files),
2527
- key=lambda p: os.path.basename(p).lower())
2528
- notes, found = [], 0
2529
- for i, p in enumerate(paths[:4]):
2530
- try:
2531
- im = Image.open(p)
2532
- im = ImageOps.exif_transpose(im) or im
2533
- im = im.convert("RGB")
2534
- except Exception:
2535
- notes.append("%s unreadable" % os.path.basename(p))
2536
- continue
2537
- fulls[i] = im
2538
- prevs[i], scs[i] = make_preview(im)
2539
- sels[i] = gr.update(value=prevs[i], visible=True)
2540
- found += 1
2541
- msg = ("**%d image%s loaded.** Tap the centre of the 4×4 block in "
2542
- "each image, or switch a square to Rectangle or Whole image."
2543
- % (found, "" if found == 1 else "s"))
2544
- if notes:
2545
- msg += " \n⚠️ " + "; ".join(notes)
2546
- if len(paths) > 4:
2547
- msg += " \n*Ignored %d extra file(s).*" % (len(paths) - 4)
2548
- return (*sels, *fulls, *prevs, *scs, *ptss, *modes, *edits,
2549
- *blocks, *reset[0], *reset[1], *reset[2:], msg)
2550
 
2551
- _f_result_outs = [f_table, f_m_total, f_m_live, f_m_dead, f_m_viab,
2552
- f_conc]
2553
  f_upload.change(
2554
  fn=f_on_upload, inputs=[f_upload],
2555
- outputs=[*f_sel, *f_full, *f_prev, *f_scale, *f_pts, *f_mode,
2556
- *f_edit, *f_block, *f_qc, *f_qc_img, *_f_result_outs,
2557
- f_qc_msg, f_status])
2558
-
2559
- # ---- region mode -------------------------------------------------
2560
- def _f_mk_mode(idx):
2561
- def h(mode, prev, pts, scale):
2562
- n = idx + 1
2563
- if mode == REGION_RECT:
2564
- return (gr.update(visible=False),
2565
- gr.update(value=_f_editor_value(prev), visible=True),
2566
- gr.update(visible=True),
2567
- "*Square %d: pick the crop tool and drag a rectangle. "
2568
- "Tick the box below if it is exactly the 1 mm "
2569
- "counting block.*" % n)
2570
- if mode == REGION_WHOLE:
2571
- return (gr.update(value=prev, visible=True,
2572
- label="Square %d — whole image will be "
2573
- "counted" % n),
2574
- gr.update(visible=False), gr.update(visible=False),
2575
- "*Square %d: the whole image will be counted. It is "
2576
- "not a 1 mm block, so it is left out of the "
2577
- "concentration.*" % n)
2578
- return (gr.update(value=_f_overlay(prev, pts, scale),
2579
- visible=True,
2580
- label="Square %d — tap the block centre" % n),
2581
- gr.update(visible=False), gr.update(visible=False),
2582
- "*Square %d: tap the centre of the 4×4 block.*" % n)
2583
- return h
2584
-
2585
- for _fi in range(4):
2586
- f_mode[_fi].change(
2587
- fn=_f_mk_mode(_fi),
2588
- inputs=[f_mode[_fi], f_prev[_fi], f_pts[_fi], f_scale[_fi]],
2589
- outputs=[f_sel[_fi], f_edit[_fi], f_block[_fi], f_status])
2590
-
2591
- # ---- grid tap ----------------------------------------------------
2592
- def _f_mk_reseed(idx):
2593
- def h(full, prev, scale, mode, evt: gr.SelectData):
2594
- if mode != REGION_GRID:
2595
- return gr.update(), gr.update(), gr.update()
2596
- if full is None or prev is None or evt is None or evt.index is None:
2597
- return gr.update(), gr.update(), gr.update()
2598
- seed = (evt.index[0] / scale, evt.index[1] / scale)
2599
- pts, info = _f_detect(full, seed)
2600
- if not pts:
2601
- return prev, None, (
2602
- "⚠️ *Square %d: %s. Tap again, or switch this square to "
2603
- "Rectangle.*" % (idx + 1, info))
2604
- return (_f_overlay(prev, pts, scale), pts,
2605
- "*Square %d placed — confidence %.0f%%, edges %.0f px "
2606
- "off the rulings. Check it looks like the block you meant.*" % (
2607
- idx + 1, info["confidence"] * 100,
2608
- (info.get("qc") or {}).get("line_offset_mean_px", 0)))
2609
- return h
2610
-
2611
- for _fi in range(4):
2612
- f_sel[_fi].select(fn=_f_mk_reseed(_fi),
2613
- inputs=[f_full[_fi], f_prev[_fi], f_scale[_fi],
2614
- f_mode[_fi]],
2615
- outputs=[f_sel[_fi], f_pts[_fi], f_status])
2616
-
2617
- # ---- clear -------------------------------------------------------
2618
- def f_on_clear(*a):
2619
- prevs, modes = a[:4], a[4:8]
2620
- sels = [gr.update(value=p) if m != REGION_RECT else gr.update()
2621
- for p, m in zip(prevs, modes)]
2622
- edits = [gr.update(value=_f_editor_value(p)) if m == REGION_RECT
2623
- else gr.update() for p, m in zip(prevs, modes)]
2624
- return (*sels, None, None, None, None, *edits,
2625
- "*Selections cleared — tap each block centre or drag each "
2626
- "rectangle again.*")
2627
-
2628
- f_clear.click(fn=f_on_clear, inputs=[*f_prev, *f_mode],
2629
- outputs=[*f_sel, *f_pts, *f_edit, f_status])
2630
 
2631
  # ---- run ---------------------------------------------------------
2632
  def f_on_run(*a):
2633
- fulls, prevs, scs, ptss, modes, edits, blocks = (
2634
- [a[4 * i + k] for k in range(4)] for i in range(7))
2635
- use_min, min_px = a[28], a[29]
2636
  t0 = time.time()
2637
  qcs, notes = [None] * 4, []
2638
  for k in range(4):
2639
- full, mode = fulls[k], modes[k] or REGION_GRID
2640
- if full is None:
2641
  continue
2642
- if mode == REGION_GRID:
2643
- if not (ptss[k] and len(ptss[k]) >= 3):
2644
- # Skipped, not segmented whole-frame: the whole frame is
2645
- # a much larger area than one block, so counting it as
2646
- # a block would silently corrupt the concentration.
2647
- notes.append("Square %d: no block tapped, skipped — tap "
2648
- "it, or choose Rectangle / Whole image"
2649
- % (k + 1))
2650
- continue
2651
- image, pts = full, ptss[k]
2652
- elif mode == REGION_RECT:
2653
- image, why = f_crop_from_editor(full, prevs[k], scs[k],
2654
- edits[k])
2655
- if image is None:
2656
- notes.append("Square %d: %s, skipped" % (k + 1, why))
2657
- continue
2658
- pts = None
2659
- else:
2660
- image, pts = full, None
2661
- r = f_segment_square(image, pts)
2662
  if "error" in r:
2663
  notes.append("Square %d failed: %s" % (k + 1, r["error"]))
2664
  continue
2665
- r["region"] = mode
2666
  qcs[k] = r
2667
  wall = time.time() - t0
2668
- imgs, *summary, qc_msg = _f_summary(qcs, blocks, use_min, min_px,
2669
- True)
2670
  n = sum(1 for q in qcs if q)
2671
  msg = "**Done — %d of 4 squares in %.1f s.** Check the " \
2672
  "segmentations below for debris." % (n, wall)
@@ -2676,34 +2239,28 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2676
 
2677
  f_run.click(
2678
  fn=f_on_run,
2679
- inputs=[*f_full, *f_prev, *f_scale, *f_pts, *f_mode, *f_edit,
2680
- *f_block, f_use_min, f_min_px],
2681
  outputs=[*f_qc, *f_qc_img, *_f_result_outs, f_qc_msg, f_secs,
2682
- f_status])
 
2683
 
2684
  # ---- debris check ------------------------------------------------
2685
  def f_on_refresh(*a):
2686
- qcs, blocks, use_min, min_px = a[:4], a[4:8], a[8], a[9]
2687
- imgs, *summary, qc_msg = _f_summary(qcs, blocks, use_min, min_px,
2688
- True)
2689
  return (*[gr.update() if q is None else im
2690
  for q, im in zip(qcs, imgs)], *summary, qc_msg)
2691
 
2692
  def f_on_numbers(*a):
2693
- qcs, blocks, use_min, min_px = a[:4], a[4:8], a[8], a[9]
2694
- _imgs, *summary, qc_msg = _f_summary(qcs, blocks, use_min, min_px,
2695
- False)
2696
- return (*summary, qc_msg)
2697
 
2698
- _f_refresh_ins = [*f_qc, *f_block, f_use_min, f_min_px]
2699
  for _ev in (f_use_min.change, f_min_px.change):
2700
  _ev(fn=f_on_refresh, inputs=_f_refresh_ins,
2701
  outputs=[*f_qc_img, *_f_result_outs, f_qc_msg],
2702
  trigger_mode="always_last", show_progress="hidden")
2703
- for _fi in range(4):
2704
- f_block[_fi].change(fn=f_on_numbers, inputs=_f_refresh_ins,
2705
- outputs=[*_f_result_outs, f_qc_msg],
2706
- show_progress="hidden")
2707
 
2708
  def _f_mk_debris(idx):
2709
  def h(q, use_min, min_px, evt: gr.SelectData):
@@ -2742,25 +2299,24 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2742
  inputs=[f_qc[_fi], f_use_min, f_min_px],
2743
  outputs=[f_qc[_fi], f_qc_img[_fi], f_qc_msg],
2744
  show_progress="hidden",
2745
- ).then(fn=lambda *a: f_on_numbers(*a)[:-1],
2746
- inputs=_f_refresh_ins, outputs=_f_result_outs,
2747
- show_progress="hidden")
2748
 
2749
  # ---- export ------------------------------------------------------
2750
  def f_on_export(*a):
2751
- qcs, blocks, use_min, min_px = a[:4], a[4:8], a[8], a[9]
2752
- want_csv, want_pdf = a[10], a[11]
2753
  if not any(qcs):
2754
  return gr.update(visible=False), "*Run segmentation first.*"
2755
  if not (want_csv or want_pdf):
2756
  return gr.update(visible=False), "*Tick CSV or PDF first.*"
2757
  results = []
2758
- for k, q in enumerate(qcs):
2759
  if q is None:
2760
  results.append(None)
2761
  continue
2762
  m = f_measure(q, use_min, min_px)
2763
- label, elig = f_region_info(q["region"], blocks[k])
2764
  viab_ov = Image.fromarray(draw_viability_overlay(
2765
  m["img"], m["counted"], q.get("label_map") or {}))
2766
  results.append(save_tab_result(
@@ -2790,9 +2346,12 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2790
  gr.Markdown(
2791
  "### Hemocytometer counting\n"
2792
  "Upload the **four corner squares** \u2014 all at once, or one at a "
2793
- "time. Tap once in the centre of each block to place the counting "
2794
- "square, then press **Run segmentation** once. Uses the "
2795
- "hemocytometer model and runs viability automatically."
 
 
 
2796
  )
2797
 
2798
  with gr.Accordion("Settings", open=False):
@@ -2830,7 +2389,9 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2830
  with gr.Accordion(f"Square {_i + 1}", open=(_i == 0)):
2831
  with gr.Row():
2832
  with gr.Column(scale=1):
2833
- pickers.append(_crop_picker(f"Square {_i + 1} image"))
 
 
2834
  with gr.Column(scale=1):
2835
  seg_imgs.append(gr.Image(type="pil", label="Segmentation",
2836
  height=260))
@@ -2889,9 +2450,10 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2889
 
2890
  result_states = [gr.State(value=None) for _ in range(4)]
2891
 
2892
- def run_all(i1, p1, i2, p2, i3, p3, i4, p4,
2893
  use_min, min_px, use_stereo, left_pct, top_pct, want_correct):
2894
- imgs = [(i1, p1), (i2, p2), (i3, p3), (i4, p4)]
 
2895
  outs, sts, results, done, notes = [], [], [], 0, []
2896
 
2897
  def _blank(msg):
@@ -2968,20 +2530,22 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2968
  inputs=[masks_sts[_i], img_sts[_i], lab_sts[_i], lmap_sts[_i]],
2969
  outputs=[corr_files[_i], corr_infos[_i]])
2970
 
 
2971
  multi_upload.change(
2972
- fn=_load_uploads, inputs=[multi_upload],
2973
- outputs=[pickers[0]["img"], pickers[1]["img"],
2974
- pickers[2]["img"], pickers[3]["img"], multi_status])
 
 
2975
 
2976
  run_all_btn.click(
2977
  fn=run_all,
2978
- inputs=[pickers[0]["img"], pickers[0]["points"],
2979
- pickers[1]["img"], pickers[1]["points"],
2980
- pickers[2]["img"], pickers[2]["points"],
2981
- pickers[3]["img"], pickers[3]["points"],
2982
  h_use_min, h_min_px, h_use_stereo, h_left, h_top,
2983
  h_correct],
2984
- outputs=run_all_outputs)
 
2985
 
2986
  def _csv(*rs):
2987
  path, msg = export_summary_csv(*rs)
@@ -3004,12 +2568,14 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
3004
  with gr.Tab("confluency"):
3005
  gr.Markdown(
3006
  "### Confluency estimation\n"
3007
- "Uses the **general** model. Cropping is optional — leave the corners "
3008
- "unset to measure the whole field. No viability classification."
 
3009
  )
3010
  with gr.Row():
3011
  with gr.Column(scale=1):
3012
- c_pick = _crop_picker("Image")
 
3013
  c_run = gr.Button("🔬 Run segmentation", variant="primary",
3014
  size="lg")
3015
  with gr.Column(scale=1):
@@ -3020,7 +2586,8 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
3020
  c_info = gr.Textbox(label="Processing info", lines=6,
3021
  interactive=False)
3022
 
3023
- def run_confluency(img, pts):
 
3024
  if img is None:
3025
  return None, 0, 0.0, "Upload an image first."
3026
  r = _segment_one(img, pts, GENERAL_MODEL, False, 0,
@@ -3032,8 +2599,9 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
3032
  r["info"] + note)
3033
 
3034
  c_run.click(fn=run_confluency,
3035
- inputs=[c_pick["img"], c_pick["points"]],
3036
- outputs=[c_overlay, c_count, c_conf, c_info])
 
3037
 
3038
 
3039
  if __name__ == "__main__":
 
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
12
+ import json
13
+ import uuid
14
  import csv
15
  import joblib
16
  import os
 
630
 
631
 
632
  # ---------------------------------------------------------------------------
633
+ # Segmentation -- one Cellpose call on the whole (cropped, <=1024 px) image.
634
+ #
635
+ # The previous version split the image into 512 px patches, segmented them in
636
+ # parallel and stitched the label maps back together. Stitching label maps
637
+ # cuts cells that straddle a seam into two objects or drops one copy, and the
638
+ # advertised IoU de-duplication was never implemented. Cellpose tiles
639
+ # internally and stitches its FLOWS before computing masks, so a single eval
640
+ # has no seams at all, and at <=1024 px it is one GPU call either way.
641
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  @spaces.GPU(duration=30)
643
+ def run_segmentation_whole(image_np, model_filename):
644
+ """Returns (masks, gpu_seconds).
 
 
645
 
646
  The @spaces.GPU decorator sits HERE rather than on run_segmentation because
647
  ZeroGPU quota is charged for the whole time the GPU is attached, and the
648
  caller does a lot of CPU-only work -- decoding a 12 MP photo, the perspective
649
+ warp, size filtering, overlay rendering.
 
 
 
650
  """
651
  _t_gpu = time.perf_counter()
 
 
652
  if model_filename in loaded_models:
653
  model = loaded_models[model_filename]
654
  else:
655
+ model = models.CellposeModel(
656
+ gpu=True, pretrained_model=resolve_model_path(HF_REPO_ID, model_filename))
657
  loaded_models[model_filename] = model
658
+ # No channels= argument: deprecated in cellpose 4 and ignored (CP4
659
+ # segments the RGB image).
660
+ masks, _, _ = model.eval(image_np, diameter=None)
661
+ return masks.astype(np.int32, copy=False), time.perf_counter() - _t_gpu
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
 
663
 
664
  def render_segmentation(base_masks, processed_image_np,
 
737
  else:
738
  processed_image_np = image_np
739
 
740
+ masks_raw, gpu_seconds = run_segmentation_whole(
 
741
  processed_image_np, model_filename)
742
 
743
  # Same single pass feeds the log line and the recommendation below;
 
813
  info_msg += filter_msg
814
  info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
815
  info_msg += f"Confluency: {confluency:.1f}%\n"
 
816
  if use_stereology:
817
  info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
818
  info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
 
1465
  return path, msg
1466
 
1467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1468
  # ---------------------------------------------------------------------------
1469
  # Label correction grid
1470
  # ---------------------------------------------------------------------------
 
1624
  MAX_SQUARES = 4
1625
 
1626
 
1627
+ # ---------------------------------------------------------------------------
1628
+ # 4-corner crop canvas (corner_canvas.js)
1629
+ #
1630
+ # Placing, dragging, zooming and panning corners all happen in the browser, so
1631
+ # no click round-trips an image and Gradio never covers the picture with a
1632
+ # progress bar. Python is called only to load an image into a canvas and, when
1633
+ # the user asks, to run grid auto-detection from a seed click; the detected
1634
+ # block comes back as 4 ordinary corners the user can drag.
1635
+ #
1636
+ # The canvas shows a <=2048 px preview; corners are converted back to the
1637
+ # full-resolution upload before they reach Python, so cropping (and the
1638
+ # perspective warp) always samples the original pixels.
1639
+ # ---------------------------------------------------------------------------
1640
+ CANVAS_PREVIEW_MAX = 2048
1641
+
1642
+ with open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
1643
+ "corner_canvas.js"), encoding="utf-8") as _f:
1644
+ CANVAS_HEAD = "<script>\n" + _f.read() + "\n</script>"
1645
+
1646
+ CANVAS_CSS = """
1647
+ .cp-hidden { display: none !important; }
1648
+ .cp-root { display: flex; flex-direction: column; gap: 4px; }
1649
+ .cp-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
1650
+ font-size: 13px; }
1651
+ .cp-title { font-weight: 600; }
1652
+ .cp-info { flex: 1 1 160px; opacity: .8; }
1653
+ .cp-info.cp-ok { color: #2e9e5b; opacity: 1; }
1654
+ .cp-bar button { padding: 2px 9px; border-radius: 6px; font-size: 12px;
1655
+ border: 1px solid #8884; background: transparent;
1656
+ color: inherit; cursor: pointer; }
1657
+ .cp-bar button:disabled { opacity: .4; cursor: default; }
1658
+ .cp-bar button.cp-on { background: #ffcc00; color: #000; border-color: #ffcc00; }
1659
+ .cp-stage { position: relative; border-radius: 8px; overflow: hidden;
1660
+ background: #1b1d22; }
1661
+ .cp-stage canvas { width: 100%; height: 100%; display: block;
1662
+ cursor: crosshair; touch-action: none; }
1663
+ .cp-empty { position: absolute; inset: 0; display: flex; align-items: center;
1664
+ justify-content: center; color: #aaa; pointer-events: none;
1665
+ font-size: 14px; }
1666
+ """
1667
+
1668
+ _CANVAS_TMPL = """
1669
+ <div class="cp-root" data-key="{key}">
1670
+ <div class="cp-bar">
1671
+ <span class="cp-title">{title}</span><span class="cp-info"></span>
1672
+ <button class="cp-auto" title="Auto-detect the 4x4 block: press, then click the block centre">&#8982; Auto</button>
1673
+ <button class="cp-reset">Reset</button>
1674
+ <button class="cp-fit">Fit</button>
1675
+ </div>
1676
+ <div class="cp-stage" style="height:{height}px">
1677
+ <canvas></canvas><div class="cp-empty">{empty}</div>
1678
+ </div>
1679
+ </div>
1680
+ """
1681
+
1682
+ CP_LOAD_JS = ("(...ps) => { for (const p of ps) if (window.CP) "
1683
+ "window.CP.load(p); }")
1684
+
1685
+
1686
+ def corners_js(keys):
1687
+ """js pre-step that swaps the first len(keys) inputs for canvas corners."""
1688
+ sets = "".join("a[%d] = window.CP ? window.CP.get('%s') : '';" % (i, k)
1689
+ for i, k in enumerate(keys))
1690
+ return "(...a) => { %s return a; }" % sets
1691
+
1692
+
1693
+ def parse_corners(value):
1694
+ """JSON corners from the canvas -> [(x, y)] x4 in full-res px, or None."""
1695
+ try:
1696
+ pts = json.loads(value) if value else None
1697
+ except ValueError:
1698
+ return None
1699
+ if not pts or len(pts) != 4:
1700
+ return None
1701
+ return [(float(x), float(y)) for x, y in pts]
1702
 
1703
 
1704
+ def open_upload(path):
1705
+ """Upload path -> RGB PIL with EXIF orientation applied, or None."""
1706
+ try:
1707
+ im = Image.open(path)
1708
+ im = ImageOps.exif_transpose(im) or im
1709
+ return im.convert("RGB")
1710
+ except Exception:
1711
+ return None
1712
 
1713
 
1714
+ def canvas_payload(key, full):
1715
+ """Image payload for CP.load. full=None clears the canvas."""
1716
+ p = {"seq": uuid.uuid4().hex, "key": key, "mode": "image", "img": None}
1717
+ if full is not None:
1718
+ prev = full.copy()
1719
+ prev.thumbnail((CANVAS_PREVIEW_MAX, CANVAS_PREVIEW_MAX), Image.LANCZOS)
1720
+ buf = io.BytesIO()
1721
+ prev.save(buf, "JPEG", quality=88)
1722
+ p.update(img="data:image/jpeg;base64,"
1723
+ + base64.b64encode(buf.getvalue()).decode(),
1724
+ w=prev.width, h=prev.height,
1725
+ scale=prev.width / float(full.width))
1726
+ return json.dumps(p)
1727
 
 
 
 
1728
 
1729
+ def canvas_detect(key, seed_json, full):
1730
+ """Grid auto-detection from a seed click; corners come back to the canvas.
1731
 
1732
+ CPU only, deliberately NOT behind @spaces.GPU (~1 s on a 12 MP photo).
1733
  """
1734
+ out = {"seq": uuid.uuid4().hex, "key": key, "mode": "corners",
1735
+ "corners": [], "msg": ""}
1736
+ try:
1737
+ seed = json.loads(seed_json) if seed_json else None
1738
+ except ValueError:
1739
+ seed = None
1740
+ if full is None or not seed:
1741
+ out["msg"] = "Upload an image first."
1742
+ return json.dumps(out)
1743
+ res = detect_from_seed(np.array(full), (float(seed[0]), float(seed[1])))
1744
+ if res.get("ok"):
1745
+ qc = res.get("qc") or {}
1746
+ out["corners"] = [[float(x), float(y)] for x, y in res["corners"]]
1747
+ out["msg"] = ("✓ Auto-detected — confidence %.0f%%, edges %.0f px "
1748
+ "off the rulings, tilt %+.1f°. Drag a handle to adjust."
1749
+ % (res["confidence"] * 100,
1750
+ qc.get("line_offset_mean_px", 0), res["angle_deg"]))
1751
+ else:
1752
+ out["msg"] = ("⚠ Auto-detect failed (%s) — click the 4 corners "
1753
+ "instead." % res.get("reason", "no fit"))
1754
+ return json.dumps(out)
 
 
 
 
 
 
 
 
 
 
1755
 
 
 
 
 
1756
 
1757
+ def corner_canvas(key, title, height=440, upload_label=None,
1758
+ empty="Upload an image"):
1759
+ """A canvas with its hidden plumbing. Returns a dict of components.
1760
 
1761
+ "corners" is a placeholder input: event handlers pass it first and use
1762
+ corners_js() so its value is read from the canvas at click time.
1763
+ """
1764
+ up = (gr.UploadButton(upload_label, file_types=["image"], size="sm")
1765
+ if upload_label else None)
1766
+ gr.HTML(_CANVAS_TMPL.format(key=key, title=title, height=int(height),
1767
+ empty=empty))
1768
+ full = gr.State(None)
1769
+ # Hidden with CSS rather than visible=False so the values always exist in
1770
+ # the page for the js steps to read.
1771
+ with gr.Column(elem_classes="cp-hidden"):
1772
+ payload = gr.Textbox(show_label=False, container=False, max_lines=1)
1773
+ corners = gr.Textbox(show_label=False, container=False, max_lines=1)
1774
+ seedbox = gr.Textbox(show_label=False, container=False, max_lines=1)
1775
+ det = gr.Button("detect", elem_id="cp-detect-%s" % key)
1776
+
1777
+ det.click(fn=lambda s, f: canvas_detect(key, s, f),
1778
+ inputs=[seedbox, full], outputs=[payload],
1779
+ js="(s, f) => [window.CP.seed('%s'), f]" % key,
1780
+ show_progress="hidden"
1781
+ ).then(fn=None, inputs=[payload], js=CP_LOAD_JS)
1782
+
1783
+ if up is not None:
1784
+ def _one(path):
1785
+ im = open_upload(path) if path else None
1786
+ return im, canvas_payload(key, im)
1787
+ up.upload(fn=_one, inputs=[up], outputs=[full, payload],
1788
+ show_progress="minimal"
1789
+ ).then(fn=None, inputs=[payload], js=CP_LOAD_JS)
1790
+
1791
+ return {"key": key, "full": full, "payload": payload, "corners": corners,
1792
+ "upload": up}
1793
+
1794
+
1795
+ def load_multi(keys, files):
1796
+ """Fan a multi-file upload out to len(keys) canvases, sorted by filename.
1797
+
1798
+ Returns (fulls..., payloads..., status). Anything unreadable, or beyond the
1799
+ last canvas, is named in the status rather than dropped silently:
1800
+ alphabetical order is not always what the user expected.
1801
+ """
1802
+ n = len(keys)
1803
+ fulls = [None] * n
1804
+ if not files:
1805
+ return (*fulls, *[canvas_payload(k, None) for k in keys],
1806
+ "*Waiting for images.*")
1807
+ paths = sorted((str(getattr(f, "name", f)) for f in files),
1808
+ key=lambda p: os.path.basename(p).lower())
1809
+ used, bad = [], []
1810
+ for p in paths:
1811
+ if len(used) >= n:
1812
+ break
1813
+ im = open_upload(p)
1814
+ if im is None:
1815
+ bad.append(os.path.basename(p))
1816
+ continue
1817
+ fulls[len(used)] = im
1818
+ used.append(os.path.basename(p))
1819
+ msg = " · ".join("Square %d ← %s" % (i + 1, b) for i, b in enumerate(used))
1820
+ msg = ("**%s**" % msg) if used else "⚠️ *None of those files could be read.*"
1821
+ if bad:
1822
+ msg += " \n⚠️ *Unreadable, skipped: %s*" % ", ".join(bad)
1823
+ extra = len(paths) - len(used) - len(bad)
1824
+ if extra > 0:
1825
+ msg += " \n⚠️ *Only %d squares — ignored %d extra file(s).*" % (n, extra)
1826
+ if used:
1827
+ msg += (" \n*Set each square: click its 4 corners, or press ⌖ Auto and "
1828
+ "click the block centre.*")
1829
+ return (*fulls, *[canvas_payload(k, im) for k, im in zip(keys, fulls)], msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1830
 
1831
 
1832
  def _segment_one(image, points, model_name, use_min, min_px,
 
1885
  # Ids are never renumbered, which keeps the viability label map valid however
1886
  # the selection changes.
1887
  # ---------------------------------------------------------------------------
1888
+ REGION_BLOCK = "Block" # 4 corners set, auto-detected or clicked
1889
+ REGION_WHOLE = "Whole image" # no corners: the whole frame is counted
 
1890
  F_STEREO_PCT = 1 # the same 1% / 1% exclusion comprehensive_counting ships with
1891
  F_PICK_RADIUS = 8 # px: a tap this close to an object still selects it
1892
 
1893
 
1894
+ def f_region_info(region):
1895
  """(table label, may enter the concentration).
1896
 
1897
+ Only a square cropped to the 4x4 block has a known area of 1 mm^2. A
1898
+ whole-frame count still gets counts and viability, but multiplying it by
1899
+ the chamber constant would be meaningless.
1900
  """
1901
+ if region == REGION_BLOCK:
1902
+ return "Block", True
 
 
1903
  return "Whole image", False
1904
 
1905
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1906
  def f_segment_square(image, points):
1907
  """Segment one square and classify viability over every object found.
1908
 
 
2026
  return int(win[ys[k], xs[k]]) if d2[k] <= radius * radius else 0
2027
 
2028
 
2029
+ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft(),
2030
+ head=CANVAS_HEAD, css=CANVAS_CSS) as demo:
2031
  gr.Markdown("# CellposeCellCounter")
2032
 
2033
  # =======================================================================
2034
  # Tab 1 — hemocytometer counting (4 squares, one run)
2035
  # =======================================================================
2036
  # ==================================================================
2037
+ # Fast counting -- upload, set corners per square, run, check.
2038
  #
2039
+ # Each square is set on a 4-corner canvas (corner_canvas.js): click the 4
2040
+ # corners of the block, or press Auto and click the block centre to run
2041
+ # grid detection, then drag any handle to fine-tune. A square with corners
2042
+ # is the 1 mm^2 block and enters the concentration; a square left without
2043
+ # corners is counted whole-frame and reported, but kept out of it.
 
 
2044
  #
2045
  # After Run, the check section shows every square's segmentation. Tapping
2046
  # an object marks it as debris (grey) and tapping again restores it; the
 
2050
  with gr.Tab("fast_counting"):
2051
  gr.Markdown(
2052
  "### Fast counting\n"
2053
+ "**1 ·** Upload the four square images. **2 ·** On each image, "
2054
+ "click the **4 corners** of the 4×4 block — or press **⌖ Auto** "
2055
+ "and click the block centre — and drag any handle to fine-tune. "
2056
+ "Wheel zooms, drag pans. **3 ·** Press **Run**, then check each "
2057
  "segmentation below and tap any debris to remove it.\n\n"
2058
+ "A square left without corners is counted whole-frame and kept out "
2059
+ "of the concentration, which needs the known 1 mm² block area."
2060
  )
2061
 
2062
  f_upload = gr.Files(label="1 · Upload the four square images",
 
2064
  height=95)
2065
  f_status = gr.Markdown("*Waiting for images.*")
2066
 
2067
+ # 2x2: four independent canvases.
2068
+ f_cv = []
 
 
2069
  for _pair in range(2):
2070
  with gr.Row():
2071
  for _k in (2 * _pair, 2 * _pair + 1):
2072
  with gr.Column():
2073
+ f_cv.append(corner_canvas(
2074
+ "f%d" % _k, "Square %d" % (_k + 1), height=440,
2075
+ empty="Square %d — upload above" % (_k + 1)))
2076
+ _F_KEYS = [c["key"] for c in f_cv]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2077
 
2078
  with gr.Row():
2079
+ f_run = gr.Button("2 · Run segmentation", variant="primary",
2080
+ size="lg")
 
2081
 
2082
  _F_HEADERS = ["Square", "Region", "Total", "Live", "Dead",
2083
  "Viability %", "Removed as debris"]
 
2131
  visible=False)
2132
  f_exp_msg = gr.Markdown()
2133
 
2134
+ f_full = [c["full"] for c in f_cv] # full-res PIL
2135
+ f_qc = [gr.State(None) for _ in range(4)] # f_segment_square result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2136
 
2137
  def _f_blank_results():
2138
  rows = [["Square %d" % (k + 1)] + ["—"] * (len(_F_HEADERS) - 1)
2139
  for k in range(4)]
2140
  return (rows, None, None, None, None, "")
2141
 
2142
+ def _f_summary(qcs, use_min, min_px, with_images):
2143
  """Table, means and concentration from the stored segmentations
2144
+ under the current min size and debris removals."""
2145
  rows, measured, imgs, qc_notes = [], [], [], []
2146
  for k, q in enumerate(qcs):
2147
  if q is None:
 
2150
  imgs.append(None)
2151
  continue
2152
  m = f_measure(q, use_min, min_px)
2153
+ label, elig = f_region_info(q["region"])
2154
  measured.append((m, elig))
2155
  rows.append(["Square %d" % (k + 1),
2156
  label if elig else label + " · not in conc.",
 
2188
  conc += ("\n⚠ From %d of 4 squares — the convention uses "
2189
  "all 4." % n_elig)
2190
  else:
2191
+ conc = ("Not computed — no square was cropped to the 1 mm² "
2192
+ "block. Set the 4 corners on each square.")
 
2193
  qc_msg = ("*" + " · ".join(qc_notes) + "*") if qc_notes else ""
2194
  return (imgs, rows, _mean("total"), _mean("live"), _mean("dead"),
2195
  _mean("viab"), conc, qc_msg)
2196
 
2197
+ _f_result_outs = [f_table, f_m_total, f_m_live, f_m_dead, f_m_viab,
2198
+ f_conc]
2199
+
2200
  # ---- upload ------------------------------------------------------
2201
  def f_on_upload(files):
2202
+ *fp, msg = load_multi(_F_KEYS, files)
2203
+ return (*fp, *[None] * 4, *[None] * 4, *_f_blank_results(), "", msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2204
 
 
 
2205
  f_upload.change(
2206
  fn=f_on_upload, inputs=[f_upload],
2207
+ outputs=[*f_full, *[c["payload"] for c in f_cv], *f_qc, *f_qc_img,
2208
+ *_f_result_outs, f_qc_msg, f_status],
2209
+ show_progress="minimal",
2210
+ ).then(fn=None, inputs=[c["payload"] for c in f_cv], js=CP_LOAD_JS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2211
 
2212
  # ---- run ---------------------------------------------------------
2213
  def f_on_run(*a):
2214
+ corners, fulls = a[0:4], a[4:8]
2215
+ use_min, min_px = a[8], a[9]
 
2216
  t0 = time.time()
2217
  qcs, notes = [None] * 4, []
2218
  for k in range(4):
2219
+ if fulls[k] is None:
 
2220
  continue
2221
+ pts = parse_corners(corners[k])
2222
+ if pts is None:
2223
+ notes.append("Square %d: no corners set — counted whole-frame, "
2224
+ "not in concentration" % (k + 1))
2225
+ r = f_segment_square(fulls[k], pts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2226
  if "error" in r:
2227
  notes.append("Square %d failed: %s" % (k + 1, r["error"]))
2228
  continue
2229
+ r["region"] = REGION_BLOCK if pts else REGION_WHOLE
2230
  qcs[k] = r
2231
  wall = time.time() - t0
2232
+ imgs, *summary, qc_msg = _f_summary(qcs, use_min, min_px, True)
 
2233
  n = sum(1 for q in qcs if q)
2234
  msg = "**Done — %d of 4 squares in %.1f s.** Check the " \
2235
  "segmentations below for debris." % (n, wall)
 
2239
 
2240
  f_run.click(
2241
  fn=f_on_run,
2242
+ inputs=[*[c["corners"] for c in f_cv], *f_full, f_use_min, f_min_px],
 
2243
  outputs=[*f_qc, *f_qc_img, *_f_result_outs, f_qc_msg, f_secs,
2244
+ f_status],
2245
+ js=corners_js(_F_KEYS))
2246
 
2247
  # ---- debris check ------------------------------------------------
2248
  def f_on_refresh(*a):
2249
+ qcs, use_min, min_px = a[:4], a[4], a[5]
2250
+ imgs, *summary, qc_msg = _f_summary(qcs, use_min, min_px, True)
 
2251
  return (*[gr.update() if q is None else im
2252
  for q, im in zip(qcs, imgs)], *summary, qc_msg)
2253
 
2254
  def f_on_numbers(*a):
2255
+ qcs, use_min, min_px = a[:4], a[4], a[5]
2256
+ _imgs, *summary, _qc_msg = _f_summary(qcs, use_min, min_px, False)
2257
+ return tuple(summary)
 
2258
 
2259
+ _f_refresh_ins = [*f_qc, f_use_min, f_min_px]
2260
  for _ev in (f_use_min.change, f_min_px.change):
2261
  _ev(fn=f_on_refresh, inputs=_f_refresh_ins,
2262
  outputs=[*f_qc_img, *_f_result_outs, f_qc_msg],
2263
  trigger_mode="always_last", show_progress="hidden")
 
 
 
 
2264
 
2265
  def _f_mk_debris(idx):
2266
  def h(q, use_min, min_px, evt: gr.SelectData):
 
2299
  inputs=[f_qc[_fi], f_use_min, f_min_px],
2300
  outputs=[f_qc[_fi], f_qc_img[_fi], f_qc_msg],
2301
  show_progress="hidden",
2302
+ ).then(fn=f_on_numbers, inputs=_f_refresh_ins,
2303
+ outputs=_f_result_outs, show_progress="hidden")
 
2304
 
2305
  # ---- export ------------------------------------------------------
2306
  def f_on_export(*a):
2307
+ qcs, use_min, min_px = a[:4], a[4], a[5]
2308
+ want_csv, want_pdf = a[6], a[7]
2309
  if not any(qcs):
2310
  return gr.update(visible=False), "*Run segmentation first.*"
2311
  if not (want_csv or want_pdf):
2312
  return gr.update(visible=False), "*Tick CSV or PDF first.*"
2313
  results = []
2314
+ for q in qcs:
2315
  if q is None:
2316
  results.append(None)
2317
  continue
2318
  m = f_measure(q, use_min, min_px)
2319
+ label, elig = f_region_info(q["region"])
2320
  viab_ov = Image.fromarray(draw_viability_overlay(
2321
  m["img"], m["counted"], q.get("label_map") or {}))
2322
  results.append(save_tab_result(
 
2346
  gr.Markdown(
2347
  "### Hemocytometer counting\n"
2348
  "Upload the **four corner squares** \u2014 all at once, or one at a "
2349
+ "time. On each image click the **4 corners** of the block, or press "
2350
+ "**⌖ Auto** and click the block centre, then drag any handle to "
2351
+ "fine-tune (wheel zooms, drag pans). Press **Run segmentation** "
2352
+ "once. Uses the hemocytometer model and runs viability "
2353
+ "automatically. A square left without corners is segmented "
2354
+ "whole-frame."
2355
  )
2356
 
2357
  with gr.Accordion("Settings", open=False):
 
2389
  with gr.Accordion(f"Square {_i + 1}", open=(_i == 0)):
2390
  with gr.Row():
2391
  with gr.Column(scale=1):
2392
+ pickers.append(corner_canvas(
2393
+ "h%d" % _i, f"Square {_i + 1}", height=420,
2394
+ upload_label=f"📂 Upload square {_i + 1} image"))
2395
  with gr.Column(scale=1):
2396
  seg_imgs.append(gr.Image(type="pil", label="Segmentation",
2397
  height=260))
 
2450
 
2451
  result_states = [gr.State(value=None) for _ in range(4)]
2452
 
2453
+ def run_all(c1, c2, c3, c4, i1, i2, i3, i4,
2454
  use_min, min_px, use_stereo, left_pct, top_pct, want_correct):
2455
+ imgs = [(i, parse_corners(c))
2456
+ for i, c in zip((i1, i2, i3, i4), (c1, c2, c3, c4))]
2457
  outs, sts, results, done, notes = [], [], [], 0, []
2458
 
2459
  def _blank(msg):
 
2530
  inputs=[masks_sts[_i], img_sts[_i], lab_sts[_i], lmap_sts[_i]],
2531
  outputs=[corr_files[_i], corr_infos[_i]])
2532
 
2533
+ _H_KEYS = [pk["key"] for pk in pickers]
2534
  multi_upload.change(
2535
+ fn=lambda files: load_multi(_H_KEYS, files), inputs=[multi_upload],
2536
+ outputs=[*[pk["full"] for pk in pickers],
2537
+ *[pk["payload"] for pk in pickers], multi_status],
2538
+ show_progress="minimal",
2539
+ ).then(fn=None, inputs=[pk["payload"] for pk in pickers], js=CP_LOAD_JS)
2540
 
2541
  run_all_btn.click(
2542
  fn=run_all,
2543
+ inputs=[*[pk["corners"] for pk in pickers],
2544
+ *[pk["full"] for pk in pickers],
 
 
2545
  h_use_min, h_min_px, h_use_stereo, h_left, h_top,
2546
  h_correct],
2547
+ outputs=run_all_outputs,
2548
+ js=corners_js(_H_KEYS))
2549
 
2550
  def _csv(*rs):
2551
  path, msg = export_summary_csv(*rs)
 
2568
  with gr.Tab("confluency"):
2569
  gr.Markdown(
2570
  "### Confluency estimation\n"
2571
+ "Uses the **general** model. Cropping is optional: click 4 corners "
2572
+ "to restrict the region, or leave them unset to measure the whole "
2573
+ "field. No viability classification."
2574
  )
2575
  with gr.Row():
2576
  with gr.Column(scale=1):
2577
+ c_pick = corner_canvas("c0", "Image", height=480,
2578
+ upload_label="📂 Upload image")
2579
  c_run = gr.Button("🔬 Run segmentation", variant="primary",
2580
  size="lg")
2581
  with gr.Column(scale=1):
 
2586
  c_info = gr.Textbox(label="Processing info", lines=6,
2587
  interactive=False)
2588
 
2589
+ def run_confluency(corners, img):
2590
+ pts = parse_corners(corners)
2591
  if img is None:
2592
  return None, 0, 0.0, "Upload an image first."
2593
  r = _segment_one(img, pts, GENERAL_MODEL, False, 0,
 
2599
  r["info"] + note)
2600
 
2601
  c_run.click(fn=run_confluency,
2602
+ inputs=[c_pick["corners"], c_pick["full"]],
2603
+ outputs=[c_overlay, c_count, c_conf, c_info],
2604
+ js=corners_js([c_pick["key"]]))
2605
 
2606
 
2607
  if __name__ == "__main__":
corner_canvas.js ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 4-corner crop canvas, one instance per <div class="cp-root" data-key="...">.
2
+ //
3
+ // All corner work happens in the browser: clicking, dragging, zoom and pan
4
+ // never call the server, so Gradio never swaps the image for a progress bar.
5
+ // The server is contacted only to load an image and, on request, to run grid
6
+ // auto-detection, whose proposal comes back as 4 ordinary draggable corners.
7
+ //
8
+ // Python <-> canvas:
9
+ // CP.load(payloadJSON) image or corner proposal for payload.key
10
+ // CP.get(key) JSON [[x,y] x4] in FULL-RES pixels, or "" if unset
11
+ // CP.seed(key) JSON [x,y] full-res auto-detect seed, or ""
12
+ (function () {
13
+ "use strict";
14
+
15
+ const CORNER_HIT_PX = 12; // screen px: grab radius for a handle
16
+ const DRAG_PX = 4; // movement beyond this turns a click into a pan
17
+ const MAX_ZOOM = 40;
18
+
19
+ const ST = {}; // key -> state; survives a re-mounted element
20
+ const PENDING = {}; // key -> payload that arrived before mounting
21
+
22
+ function state(key) {
23
+ if (!ST[key]) {
24
+ ST[key] = { key, root: null, cv: null, ctx: null, el: {},
25
+ img: null, w: 0, h: 0, scale: 1, corners: [],
26
+ view: { s: 1, tx: 0, ty: 0 }, fitted: false,
27
+ armed: false, seed: null, busy: false, auto: false,
28
+ down: null, drag: -1, panning: false, seq: null,
29
+ dirty: true };
30
+ }
31
+ return ST[key];
32
+ }
33
+
34
+ // ------------------------------------------------------------- mounting
35
+ function bind(root) {
36
+ const S = state(root.dataset.key);
37
+ if (S.root === root) return;
38
+ S.root = root;
39
+ S.cv = root.querySelector("canvas");
40
+ S.ctx = S.cv.getContext("2d");
41
+ for (const n of ["stage", "info", "auto", "reset", "fit", "empty"]) {
42
+ S.el[n] = root.querySelector(".cp-" + n);
43
+ }
44
+ const cv = S.cv;
45
+ cv.addEventListener("pointerdown", (e) => onDown(S, e));
46
+ cv.addEventListener("pointermove", (e) => onMove(S, e));
47
+ cv.addEventListener("pointerup", (e) => onUp(S, e));
48
+ cv.addEventListener("pointercancel", (e) => onUp(S, e));
49
+ cv.addEventListener("wheel", (e) => onWheel(S, e), { passive: false });
50
+ cv.addEventListener("contextmenu", (e) => e.preventDefault());
51
+ S.el.auto.addEventListener("click", () => arm(S));
52
+ S.el.reset.addEventListener("click", () => {
53
+ S.corners = []; S.auto = false; S.armed = false; ui(S); dirty(S);
54
+ });
55
+ S.el.fit.addEventListener("click", () => { fit(S); dirty(S); });
56
+ new ResizeObserver(() => { size(S); dirty(S); }).observe(S.el.stage);
57
+ size(S);
58
+ ui(S);
59
+ if (PENDING[S.key]) { const p = PENDING[S.key]; delete PENDING[S.key]; load(p); }
60
+ }
61
+
62
+ function scan() {
63
+ document.querySelectorAll(".cp-root").forEach(bind);
64
+ }
65
+
66
+ function size(S) {
67
+ const dpr = window.devicePixelRatio || 1;
68
+ const w = S.el.stage.clientWidth, h = S.el.stage.clientHeight;
69
+ if (!w || !h) return; // inside a closed accordion
70
+ S.cv.width = Math.round(w * dpr);
71
+ S.cv.height = Math.round(h * dpr);
72
+ if (!S.fitted && S.img) fit(S);
73
+ }
74
+
75
+ // ------------------------------------------------------------- loading
76
+ function loadImage(src) {
77
+ return new Promise((res, rej) => {
78
+ const im = new Image();
79
+ im.onload = () => res(im); im.onerror = rej; im.src = src;
80
+ });
81
+ }
82
+
83
+ async function load(payload) {
84
+ if (!payload) return;
85
+ let p;
86
+ try { p = typeof payload === "string" ? JSON.parse(payload) : payload; }
87
+ catch (e) { return; }
88
+ if (!p || !p.key) return;
89
+ const S = state(p.key);
90
+ if (p.seq && p.seq === S.seq) return; // same payload re-delivered
91
+ scan();
92
+ if (!S.root) { PENDING[p.key] = p; return; }
93
+ S.seq = p.seq;
94
+ if (p.mode === "image") {
95
+ S.img = p.img ? await loadImage(p.img) : null;
96
+ S.w = p.w || 0; S.h = p.h || 0; S.scale = p.scale || 1;
97
+ S.corners = []; S.auto = false; S.armed = false; S.busy = false;
98
+ S.fitted = false; fit(S);
99
+ } else if (p.mode === "corners") {
100
+ S.busy = false;
101
+ if (p.corners && p.corners.length === 4) {
102
+ S.corners = p.corners.map(([x, y]) => [x * S.scale, y * S.scale]);
103
+ S.auto = true;
104
+ }
105
+ S.msg = p.msg || "";
106
+ }
107
+ ui(S); dirty(S);
108
+ }
109
+
110
+ // ------------------------------------------------------------- view
111
+ function fit(S) {
112
+ const cw = S.el.stage.clientWidth, ch = S.el.stage.clientHeight;
113
+ if (!S.img || !cw || !ch) return;
114
+ const s = Math.min(cw / S.w, ch / S.h) * 0.97;
115
+ S.view = { s, tx: (cw - S.w * s) / 2, ty: (ch - S.h * s) / 2 };
116
+ S.fitted = true;
117
+ }
118
+
119
+ function pos(S, e) {
120
+ const r = S.cv.getBoundingClientRect();
121
+ return [e.clientX - r.left, e.clientY - r.top];
122
+ }
123
+ function toImg(S, sx, sy) {
124
+ return [(sx - S.view.tx) / S.view.s, (sy - S.view.ty) / S.view.s];
125
+ }
126
+ function toScr(S, ix, iy) {
127
+ return [ix * S.view.s + S.view.tx, iy * S.view.s + S.view.ty];
128
+ }
129
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
130
+
131
+ function onWheel(S, e) {
132
+ if (!S.img) return;
133
+ e.preventDefault();
134
+ const [sx, sy] = pos(S, e);
135
+ const fitS = Math.min(S.el.stage.clientWidth / S.w,
136
+ S.el.stage.clientHeight / S.h);
137
+ const s = clamp(S.view.s * Math.pow(1.0015, -e.deltaY), fitS * 0.5, MAX_ZOOM);
138
+ const [ix, iy] = toImg(S, sx, sy);
139
+ S.view = { s, tx: sx - ix * s, ty: sy - iy * s };
140
+ dirty(S);
141
+ }
142
+
143
+ // ------------------------------------------------------------- pointer
144
+ function cornerAt(S, sx, sy) {
145
+ let best = -1, bd = CORNER_HIT_PX;
146
+ S.corners.forEach(([x, y], k) => {
147
+ const [px, py] = toScr(S, x, y);
148
+ const d = Math.hypot(px - sx, py - sy);
149
+ if (d <= bd) { bd = d; best = k; }
150
+ });
151
+ return best;
152
+ }
153
+
154
+ function onDown(S, e) {
155
+ if (!S.img) return;
156
+ S.cv.setPointerCapture(e.pointerId);
157
+ const [sx, sy] = pos(S, e);
158
+ S.down = { sx, sy, tx: S.view.tx, ty: S.view.ty, b: e.button };
159
+ S.panning = e.button !== 0;
160
+ S.drag = e.button === 0 && !S.armed ? cornerAt(S, sx, sy) : -1;
161
+ }
162
+
163
+ function onMove(S, e) {
164
+ const [sx, sy] = pos(S, e);
165
+ if (!S.down) {
166
+ const over = S.img && !S.armed && cornerAt(S, sx, sy) >= 0;
167
+ S.cv.style.cursor = over ? "grab" : "crosshair";
168
+ return;
169
+ }
170
+ const dx = sx - S.down.sx, dy = sy - S.down.sy;
171
+ if (S.drag >= 0) {
172
+ const [ix, iy] = toImg(S, sx, sy);
173
+ S.corners[S.drag] = [clamp(ix, 0, S.w - 1), clamp(iy, 0, S.h - 1)];
174
+ S.auto = false; S.msg = "";
175
+ S.cv.style.cursor = "grabbing";
176
+ ui(S); dirty(S);
177
+ return;
178
+ }
179
+ if (!S.panning && Math.hypot(dx, dy) > DRAG_PX) S.panning = true;
180
+ if (S.panning) {
181
+ S.view.tx = S.down.tx + dx; S.view.ty = S.down.ty + dy; dirty(S);
182
+ }
183
+ }
184
+
185
+ function onUp(S, e) {
186
+ if (!S.down) return;
187
+ const [sx, sy] = pos(S, e);
188
+ const click = !S.panning && S.drag < 0 && S.down.b === 0 &&
189
+ e.type !== "pointercancel";
190
+ S.down = null; S.panning = false; S.drag = -1;
191
+ if (!click) return;
192
+ const [ix, iy] = toImg(S, sx, sy);
193
+ if (ix < 0 || iy < 0 || ix >= S.w || iy >= S.h) return;
194
+ if (S.armed) { detect(S, ix, iy); return; }
195
+ if (S.corners.length >= 4) {
196
+ S.msg = "All 4 corners set — drag a handle to adjust, or Reset.";
197
+ ui(S); return;
198
+ }
199
+ S.corners.push([ix, iy]); S.auto = false; S.msg = "";
200
+ ui(S); dirty(S);
201
+ }
202
+
203
+ // ------------------------------------------------------------- auto-detect
204
+ function arm(S) {
205
+ if (!S.img || S.busy) return;
206
+ S.armed = !S.armed;
207
+ ui(S); dirty(S);
208
+ }
209
+
210
+ function detect(S, ix, iy) {
211
+ S.armed = false; S.busy = true;
212
+ S.seed = [ix / S.scale, iy / S.scale];
213
+ S.msg = "Detecting the grid…";
214
+ ui(S); dirty(S);
215
+ // Python runs on a hidden Gradio button; its js pre-step reads CP.seed.
216
+ let b = document.getElementById("cp-detect-" + S.key);
217
+ if (b && b.tagName !== "BUTTON") b = b.querySelector("button");
218
+ if (b) b.click();
219
+ else { S.busy = false; S.msg = "Auto-detect unavailable."; ui(S); }
220
+ }
221
+
222
+ // ------------------------------------------------------------- text
223
+ function ui(S) {
224
+ if (!S.root) return;
225
+ S.el.empty.style.display = S.img ? "none" : "";
226
+ S.el.auto.classList.toggle("cp-on", S.armed);
227
+ S.el.auto.disabled = !S.img || S.busy;
228
+ S.el.reset.disabled = !S.corners.length;
229
+ let t;
230
+ if (!S.img) t = "No image";
231
+ else if (S.busy) t = S.msg || "Detecting…";
232
+ else if (S.armed) t = "Click the CENTRE of the 4×4 block to auto-detect";
233
+ else if (S.corners.length < 4) {
234
+ t = S.corners.length + " / 4 corners — click corner " +
235
+ (S.corners.length + 1) + " (any order), or ⌖ Auto";
236
+ if (S.msg) t = S.msg + " · " + t;
237
+ } else {
238
+ t = (S.auto ? "✓ Auto-detected" : "✓ 4 corners set") +
239
+ " — drag a handle to adjust";
240
+ if (S.msg) t = S.msg;
241
+ }
242
+ S.el.info.textContent = t;
243
+ S.el.info.classList.toggle("cp-ok", !!S.img && S.corners.length === 4 && !S.busy);
244
+ }
245
+
246
+ // ------------------------------------------------------------- draw
247
+ function dirty(S) { S.dirty = true; }
248
+
249
+ function frame() {
250
+ requestAnimationFrame(frame);
251
+ for (const k in ST) {
252
+ const S = ST[k];
253
+ if (S.dirty && S.ctx) { S.dirty = false; draw(S); }
254
+ }
255
+ }
256
+
257
+ function ordered(c) {
258
+ if (c.length !== 4) return c;
259
+ const mx = c.reduce((a, p) => a + p[0], 0) / 4;
260
+ const my = c.reduce((a, p) => a + p[1], 0) / 4;
261
+ return c.slice().sort((a, b) => Math.atan2(a[1] - my, a[0] - mx) -
262
+ Math.atan2(b[1] - my, b[0] - mx));
263
+ }
264
+
265
+ function draw(S) {
266
+ const ctx = S.ctx, dpr = window.devicePixelRatio || 1;
267
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
268
+ ctx.fillStyle = "#1b1d22";
269
+ ctx.fillRect(0, 0, S.cv.width, S.cv.height);
270
+ if (!S.img) return;
271
+ const { s, tx, ty } = S.view;
272
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * tx, dpr * ty);
273
+ ctx.imageSmoothingEnabled = s < 2;
274
+ ctx.drawImage(S.img, 0, 0);
275
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
276
+
277
+ const pts = ordered(S.corners).map(([x, y]) => toScr(S, x, y));
278
+ const col = S.auto ? "#3ddc84" : "#4aaaff";
279
+ ctx.lineWidth = 2; ctx.strokeStyle = col;
280
+ if (pts.length >= 2) {
281
+ ctx.beginPath();
282
+ ctx.moveTo(pts[0][0], pts[0][1]);
283
+ for (const p of pts.slice(1)) ctx.lineTo(p[0], p[1]);
284
+ if (pts.length === 4) {
285
+ ctx.closePath();
286
+ ctx.fillStyle = S.auto ? "rgba(61,220,132,0.12)" : "rgba(74,170,255,0.12)";
287
+ ctx.fill();
288
+ }
289
+ ctx.stroke();
290
+ }
291
+ for (const [x, y] of S.corners) {
292
+ const [px, py] = toScr(S, x, y);
293
+ ctx.beginPath(); ctx.arc(px, py, 9, 0, 2 * Math.PI);
294
+ ctx.fillStyle = col; ctx.globalAlpha = 0.85; ctx.fill(); ctx.globalAlpha = 1;
295
+ ctx.strokeStyle = "#fff"; ctx.stroke();
296
+ ctx.beginPath();
297
+ ctx.moveTo(px - 4, py); ctx.lineTo(px + 4, py);
298
+ ctx.moveTo(px, py - 4); ctx.lineTo(px, py + 4);
299
+ ctx.stroke();
300
+ }
301
+ if (S.seed && S.busy) {
302
+ const [px, py] = toScr(S, S.seed[0] * S.scale, S.seed[1] * S.scale);
303
+ ctx.strokeStyle = "#ffcc00"; ctx.lineWidth = 2;
304
+ ctx.beginPath(); ctx.arc(px, py, 12, 0, 2 * Math.PI); ctx.stroke();
305
+ }
306
+ }
307
+
308
+ // ------------------------------------------------------------- API
309
+ function get(key) {
310
+ const S = ST[key];
311
+ if (!S || !S.img || S.corners.length !== 4) return "";
312
+ return JSON.stringify(S.corners.map(([x, y]) => [x / S.scale, y / S.scale]));
313
+ }
314
+
315
+ function seed(key) {
316
+ const S = ST[key];
317
+ return S && S.seed ? JSON.stringify(S.seed) : "";
318
+ }
319
+
320
+ function fail(key, msg) { // detection finished without corners
321
+ const S = ST[key];
322
+ if (S) { S.busy = false; S.msg = msg || ""; ui(S); dirty(S); }
323
+ }
324
+
325
+ window.CP = { load, get, seed, fail, _st: ST };
326
+ setInterval(scan, 300);
327
+ requestAnimationFrame(frame);
328
+ })();