itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,983 @@
1
+ """Label correction operations on a single (H, W) segmentation frame.
2
+
3
+ All functions accept a 2-D ``seg`` array and modify it **in-place**.
4
+ They return ``True`` on success and ``False`` when the operation is
5
+ rejected (e.g. labels don't touch, result too small, background click).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+
12
+ import numpy as np
13
+ from scipy.ndimage import binary_dilation, binary_closing, binary_fill_holes, label as nd_label
14
+ from scipy.ndimage import distance_transform_edt
15
+ from skimage.draw import polygon as draw_polygon
16
+ from skimage.morphology import disk
17
+ from skimage.segmentation import watershed, expand_labels
18
+
19
+ log = logging.getLogger("itasc.correction")
20
+ if os.environ.get("ITASC_DEBUG"):
21
+ log.setLevel(logging.DEBUG)
22
+ if not log.handlers:
23
+ _h = logging.StreamHandler()
24
+ _h.setFormatter(logging.Formatter("[itasc.correction] %(levelname)s %(message)s"))
25
+ log.addHandler(_h)
26
+
27
+ MIN_CELL_SIZE: int = 4
28
+
29
+
30
+ # ── bounding-box helpers ──────────────────────────────────────────────────────
31
+
32
+ def _bbox_of_label(seg: np.ndarray, lab: int) -> tuple[int, int, int, int]:
33
+ rows, cols = np.where(seg == lab)
34
+ return int(rows.min()), int(cols.min()), int(rows.max()) + 1, int(cols.max()) + 1
35
+
36
+
37
+ def _bbox_of_two(seg: np.ndarray, la: int, lb: int) -> tuple[int, int, int, int]:
38
+ rows, cols = np.where(np.isin(seg, [la, lb]))
39
+ return int(rows.min()), int(cols.min()), int(rows.max()) + 1, int(cols.max()) + 1
40
+
41
+
42
+ def _extend_bbox(
43
+ bbox: tuple[int, int, int, int],
44
+ factor: float,
45
+ shape: tuple[int, int],
46
+ min_pad: int = 0,
47
+ ) -> tuple[int, int, int, int]:
48
+ r0, c0, r1, c1 = bbox
49
+ dr = max(int((r1 - r0) * (factor - 1) / 2), min_pad)
50
+ dc = max(int((c1 - c0) * (factor - 1) / 2), min_pad)
51
+ return (
52
+ max(0, r0 - dr), max(0, c0 - dc),
53
+ min(shape[0], r1 + dr), min(shape[1], c1 + dc),
54
+ )
55
+
56
+
57
+ def _crop(arr: np.ndarray, bbox: tuple) -> np.ndarray:
58
+ r0, c0, r1, c1 = bbox
59
+ return arr[r0:r1, c0:c1]
60
+
61
+
62
+ def _to_local(pts: list, bbox: tuple) -> list[tuple[float, float]]:
63
+ r0, c0 = bbox[0], bbox[1]
64
+ return [(float(p[-2]) - r0, float(p[-1]) - c0) for p in pts]
65
+
66
+
67
+ # ── line drawing ──────────────────────────────────────────────────────────────
68
+
69
+ def _interpolate(pts: list[tuple[float, float]]) -> list[tuple[int, int]]:
70
+ out: list[tuple[int, int]] = []
71
+ for i in range(len(pts) - 1):
72
+ r0, c0 = pts[i]
73
+ r1, c1 = pts[i + 1]
74
+ n = max(abs(int(r1) - int(r0)), abs(int(c1) - int(c0)), 1)
75
+ for t in np.linspace(0, 1, n + 1):
76
+ out.append((int(round(r0 + t * (r1 - r0))), int(round(c0 + t * (c1 - c0)))))
77
+ seen: set = set()
78
+ result = []
79
+ for p in out:
80
+ if p not in seen:
81
+ seen.add(p)
82
+ result.append(p)
83
+ return result
84
+
85
+
86
+ def _draw_line(shape: tuple[int, int], pts: list[tuple[int, int]]) -> np.ndarray:
87
+ line = np.zeros(shape, dtype=np.uint8)
88
+ for r, c in pts:
89
+ if 0 <= r < shape[0] and 0 <= c < shape[1]:
90
+ line[r, c] = 1
91
+ return line
92
+
93
+
94
+ # ── misc helpers ──────────────────────────────────────────────────────────────
95
+
96
+ def _free_label(seg: np.ndarray) -> int:
97
+ """Return an unused label id (``max + 1``).
98
+
99
+ The result is written back into ``seg``, so it must fit the array's
100
+ dtype; for an integer ``seg`` whose max equals the dtype ceiling the
101
+ increment would wrap to 0 (background) or collide with an existing id.
102
+ Raise instead of corrupting the segmentation.
103
+ """
104
+ new_label = int(seg.max()) + 1
105
+ if np.issubdtype(seg.dtype, np.integer) and new_label > np.iinfo(seg.dtype).max:
106
+ raise OverflowError(
107
+ f"No free label available: max id {new_label - 1} already at the "
108
+ f"{seg.dtype} ceiling. Re-save the stack with a wider integer dtype."
109
+ )
110
+ return new_label
111
+
112
+
113
+ def _touches(seg: np.ndarray, la: int, lb: int) -> bool:
114
+ dilated_a = binary_dilation(seg == la, disk(1))
115
+ dilated_b = binary_dilation(seg == lb, disk(1))
116
+ return bool(np.any(dilated_a & dilated_b))
117
+
118
+
119
+ def _label_at(seg: np.ndarray, pos: tuple) -> int:
120
+ r, c = int(round(float(pos[-2]))), int(round(float(pos[-1])))
121
+ r = max(0, min(r, seg.shape[0] - 1))
122
+ c = max(0, min(c, seg.shape[1] - 1))
123
+ return int(seg[r, c])
124
+
125
+
126
+ def frame_view_2d(arr: np.ndarray, t: int) -> np.ndarray | None:
127
+ """Return a 2D frame view from a time-indexed label stack."""
128
+ if arr.ndim < 3 or t < 0 or t >= arr.shape[0]:
129
+ return None
130
+ view = arr[t]
131
+ while view.ndim > 2:
132
+ if view.shape[0] != 1:
133
+ return None
134
+ view = view[0]
135
+ return view
136
+
137
+
138
+ def best_overlapping_label(
139
+ target_labels: np.ndarray,
140
+ source_labels: np.ndarray,
141
+ t: int,
142
+ source_label: int,
143
+ ) -> int:
144
+ """Return the non-zero target label with most overlap against source_label."""
145
+ if source_label == 0:
146
+ return 0
147
+ target_frame = frame_view_2d(target_labels, t)
148
+ source_frame = frame_view_2d(source_labels, t)
149
+ if target_frame is None or source_frame is None or target_frame.shape != source_frame.shape:
150
+ return 0
151
+ source_mask = source_frame == int(source_label)
152
+ if not np.any(source_mask):
153
+ return 0
154
+ overlap_values, counts = np.unique(target_frame[source_mask], return_counts=True)
155
+ best_label = 0
156
+ best_count = 0
157
+ for label, count in zip(overlap_values, counts, strict=True):
158
+ label = int(label)
159
+ if label != 0 and int(count) > best_count:
160
+ best_label = label
161
+ best_count = int(count)
162
+ return best_label
163
+
164
+
165
+ # ── public operations ─────────────────────────────────────────────────────────
166
+
167
+ def expand_label_to_foreground(
168
+ seg: np.ndarray,
169
+ foreground: np.ndarray,
170
+ label: int,
171
+ *,
172
+ max_distance: int,
173
+ ) -> int:
174
+ """Expand ``label`` into connected foreground background pixels in-place.
175
+
176
+ Returns the number of newly labelled pixels. A ``max_distance`` of 0 means
177
+ no distance cap.
178
+ """
179
+ if foreground.shape != seg.shape:
180
+ raise ValueError("foreground and seg must have the same shape")
181
+
182
+ label = int(label)
183
+ if label == 0:
184
+ return 0
185
+ seed = seg == label
186
+ if not np.any(seed):
187
+ return 0
188
+
189
+ allowed = (foreground > 0) & ((seg == 0) | seed)
190
+ component_labels, _num_components = nd_label(
191
+ allowed,
192
+ structure=np.ones((3, 3), dtype=np.uint8),
193
+ )
194
+ touching_ids = np.unique(component_labels[seed])
195
+ touching_ids = touching_ids[touching_ids != 0]
196
+ if touching_ids.size == 0:
197
+ return 0
198
+
199
+ touching_component = np.isin(component_labels, touching_ids)
200
+ if max_distance > 0:
201
+ dist = distance_transform_edt(~seed)
202
+ touching_component &= dist <= int(max_distance)
203
+
204
+ added = touching_component & (seg == 0)
205
+ n_added = int(np.count_nonzero(added))
206
+ if n_added:
207
+ seg[added] = label
208
+ return n_added
209
+
210
+ def erase_cell(seg: np.ndarray, pos: tuple | None = None, *, label: int | None = None) -> bool:
211
+ """Set all pixels of the label under *pos* (or *label*) to 0."""
212
+ if label is None:
213
+ if pos is None:
214
+ return False
215
+ label = _label_at(seg, pos)
216
+ log.debug("erase_cell: label=%s pos=%s", label, pos)
217
+ if label == 0:
218
+ return False
219
+ seg[seg == label] = 0
220
+ return True
221
+
222
+
223
+ def _snap_cell_mask(
224
+ image: np.ndarray,
225
+ r: int,
226
+ c: int,
227
+ radius: int,
228
+ ) -> np.ndarray | None:
229
+ """Try to carve a single nucleus out of *image* around (r, c).
230
+
231
+ Starts from a small window around the click and, whenever the blob spills
232
+ past it, grows the window and retries — so a nucleus much larger than
233
+ *radius* still snaps to its full contour instead of falling back to a tiny
234
+ disk. The same Otsu + connected-component logic runs at every size; only the
235
+ window grows. Returns a full-frame boolean mask, or ``None`` when the local
236
+ signal is too weak/ambiguous to trust (flat window, no bright blob under the
237
+ click, or a blob that never closes within the largest window — i.e.
238
+ background, not one nucleus). The caller falls back to a plain disk then.
239
+ """
240
+ try:
241
+ from skimage.filters import threshold_otsu
242
+
243
+ h, w = image.shape
244
+ win = max(int(radius * 2.5), 12)
245
+ # A handful of geometric growth steps. Capped so a bright *background*
246
+ # region — which never closes inside the window — can't snap to a giant
247
+ # blob; it spills at every size and we fall through to ``None``.
248
+ for _ in range(6):
249
+ r0, c0 = max(0, r - win), max(0, c - win)
250
+ r1, c1 = min(h, r + win + 1), min(w, c + win + 1)
251
+ full = r0 == 0 and c0 == 0 and r1 == h and c1 == w
252
+ crop = np.asarray(image[r0:r1, c0:c1], dtype=np.float64)
253
+ if crop.size == 0:
254
+ return None
255
+
256
+ lo, hi = float(crop.min()), float(crop.max())
257
+ # Flat / no contrast: the window may sit entirely inside the
258
+ # nucleus — grow to reach its edge. If it already spans the whole
259
+ # image there's nothing more to see, so give up.
260
+ if hi - lo < 1e-6:
261
+ if full:
262
+ return None
263
+ win = int(win * 1.6) + 1
264
+ continue
265
+
266
+ thr = float(threshold_otsu(crop))
267
+ fg = crop > thr
268
+ lr, lc = r - r0, c - c0
269
+ # The click itself must land on bright signal.
270
+ if not fg[lr, lc]:
271
+ return None
272
+
273
+ labelled, _ = nd_label(fg)
274
+ comp = labelled[lr, lc]
275
+ if comp == 0:
276
+ return None
277
+ blob = labelled == comp
278
+
279
+ # Does the blob run off an *interior* crop edge — i.e. spill into
280
+ # image we didn't search? Edges that coincide with the image border
281
+ # don't count; the nucleus genuinely ends there. While it spills,
282
+ # grow and retry; once the window covers the whole image it can't
283
+ # spill any further, so we accept what we have.
284
+ spills = (
285
+ (r0 > 0 and blob[0, :].any())
286
+ or (r1 < h and blob[-1, :].any())
287
+ or (c0 > 0 and blob[:, 0].any())
288
+ or (c1 < w and blob[:, -1].any())
289
+ )
290
+ if spills:
291
+ win = int(win * 1.6) + 1
292
+ continue
293
+
294
+ if blob.sum() < MIN_CELL_SIZE:
295
+ return None
296
+ # An enclosed blob filling almost the entire window is a bright
297
+ # sheet, not one nucleus (a real nucleus leaves background margin
298
+ # on the sides it doesn't touch).
299
+ if fg.mean() > 0.85:
300
+ return None
301
+
302
+ mask = np.zeros(image.shape, dtype=bool)
303
+ mask[r0:r1, c0:c1] = blob
304
+ return mask
305
+ return None
306
+ except Exception:
307
+ return None
308
+
309
+
310
+ def _keep_connected_region(
311
+ mask: np.ndarray, r: int, c: int
312
+ ) -> np.ndarray | None:
313
+ """Reduce *mask* to a single, hole-free connected region.
314
+
315
+ Picks the 8-connected component containing the click ``(r, c)`` — falling
316
+ back to the largest component when the click pixel itself was masked out
317
+ (e.g. it sits under a protected pixel) — then fills any interior holes.
318
+ Returns ``None`` when *mask* is empty.
319
+ """
320
+ if not mask.any():
321
+ return None
322
+ structure = np.ones((3, 3), dtype=bool) # 8-connectivity
323
+ labelled, n_comp = nd_label(mask, structure=structure)
324
+ if n_comp == 0:
325
+ return None
326
+ comp = int(labelled[r, c]) if mask[r, c] else 0
327
+ if comp == 0:
328
+ # Click pixel isn't part of the mask — keep the largest component.
329
+ counts = np.bincount(labelled.ravel())
330
+ counts[0] = 0
331
+ comp = int(counts.argmax())
332
+ region = labelled == comp
333
+ return binary_fill_holes(region)
334
+
335
+
336
+ def add_cell(
337
+ seg: np.ndarray,
338
+ pos: tuple,
339
+ *,
340
+ new_label: int,
341
+ radius: int = 6,
342
+ image: np.ndarray | None = None,
343
+ protected_mask: np.ndarray | None = None,
344
+ ) -> bool:
345
+ """Spawn a new cell at *pos* in empty space.
346
+
347
+ When *image* (a nucleus/intensity frame the same shape as *seg*) carries a
348
+ clear local signal the new cell snaps to it; otherwise a disk of *radius* is
349
+ stamped. Only background pixels are written — existing cells and any
350
+ *protected_mask* pixels are never overwritten — so the result can't clobber
351
+ neighbours. Returns ``False`` when *pos* is on an existing cell or nothing
352
+ paintable remains.
353
+ """
354
+ r, c = int(round(float(pos[-2]))), int(round(float(pos[-1])))
355
+ if not (0 <= r < seg.shape[0] and 0 <= c < seg.shape[1]):
356
+ return False
357
+ if seg[r, c] != 0:
358
+ return False
359
+ if new_label <= 0:
360
+ return False
361
+
362
+ mask = None
363
+ if image is not None and np.asarray(image).shape == seg.shape:
364
+ mask = _snap_cell_mask(np.asarray(image), r, c, max(1, radius))
365
+ if mask is None:
366
+ rad = max(1, radius)
367
+ d = disk(rad)
368
+ size = 2 * rad + 1
369
+ rr0, cc0 = r - rad, c - rad
370
+ sr0, sc0 = max(0, rr0), max(0, cc0)
371
+ sr1 = min(seg.shape[0], rr0 + size)
372
+ sc1 = min(seg.shape[1], cc0 + size)
373
+ mask = np.zeros(seg.shape, dtype=bool)
374
+ mask[sr0:sr1, sc0:sc1] = d[
375
+ sr0 - rr0 : sr0 - rr0 + (sr1 - sr0),
376
+ sc0 - cc0 : sc0 - cc0 + (sc1 - sc0),
377
+ ].astype(bool)
378
+
379
+ # Never overwrite existing cells or protected pixels.
380
+ allowed = seg == 0
381
+ if protected_mask is not None:
382
+ allowed &= ~protected_mask.astype(bool)
383
+ mask &= allowed
384
+ if mask.sum() < MIN_CELL_SIZE:
385
+ return False
386
+
387
+ # Intersecting with `allowed` can fracture the stamped blob into several
388
+ # disconnected pieces or shed stray pixels (e.g. an existing cell or a
389
+ # protected region cuts through it). Keep only the single connected
390
+ # component under the click so the spawned cell is always one contiguous
391
+ # region, never scattered fragments.
392
+ mask = _keep_connected_region(mask, r, c)
393
+ if mask is None:
394
+ return False
395
+ # Re-apply `allowed`: hole-filling in the helper may have spanned an
396
+ # enclosed existing/protected pixel, which must still not be overwritten.
397
+ # Removing those interior pixels leaves the surrounding region connected.
398
+ mask &= allowed
399
+ if mask.sum() < MIN_CELL_SIZE:
400
+ return False
401
+
402
+ log.debug("add_cell: label=%s pos=(%d,%d) px=%d", new_label, r, c, int(mask.sum()))
403
+ seg[mask] = new_label
404
+ return True
405
+
406
+
407
+ def merge_cells(
408
+ seg: np.ndarray,
409
+ pos_start: tuple,
410
+ pos_end: tuple,
411
+ *,
412
+ label_a: int | None = None,
413
+ label_b: int | None = None,
414
+ ) -> bool:
415
+ """Merge the cell at *pos_start* into the cell at *pos_end*."""
416
+ la = label_a if label_a is not None else _label_at(seg, pos_start)
417
+ lb = label_b if label_b is not None else _label_at(seg, pos_end)
418
+ log.debug("merge_cells: la=%s lb=%s", la, lb)
419
+ if la == 0 or lb == 0 or la == lb:
420
+ return False
421
+
422
+ if not _touches(seg, la, lb):
423
+ # Cells don't touch: just give them the same id without painting any
424
+ # new pixels. The shared label is left with disconnected components,
425
+ # which is intentional — merge joins ids, it does not tidy geometry.
426
+ seg[seg == la] = lb
427
+ return True
428
+
429
+ bbox = _bbox_of_two(seg, la, lb)
430
+ bbox = _extend_bbox(bbox, 1.25, seg.shape)
431
+ r0, c0, r1, c1 = bbox
432
+ crop = _crop(seg, bbox)
433
+
434
+ combined = np.isin(crop, [la, lb])
435
+ closed = binary_closing(combined, disk(2))
436
+ other_cells = (crop != 0) & ~combined
437
+ closed = closed & ~other_cells
438
+ seg[r0:r1, c0:c1][closed] = lb
439
+
440
+ remaining_la = seg == la
441
+ if remaining_la.any():
442
+ seg[remaining_la] = lb
443
+
444
+ return True
445
+
446
+
447
+ def split_across(
448
+ seg: np.ndarray,
449
+ img: np.ndarray | None,
450
+ pos_start: tuple,
451
+ pos_end: tuple,
452
+ *,
453
+ new_label: int | None = None,
454
+ ) -> bool:
455
+ """Watershed-split the cell under *pos_start* using two seeds."""
456
+ la = _label_at(seg, pos_start)
457
+ lb = _label_at(seg, pos_end)
458
+ log.debug("split_across: la=%s lb=%s", la, lb)
459
+ if la == 0 or la != lb:
460
+ return False
461
+
462
+ bbox = _bbox_of_label(seg, la)
463
+ bbox = _extend_bbox(bbox, 1.25, seg.shape)
464
+ r0, c0, r1, c1 = bbox
465
+ crop_seg = _crop(seg, bbox)
466
+ mask = (crop_seg == la).astype(np.uint8)
467
+ interior = mask.astype(bool)
468
+
469
+ rs = max(0, min(int(round(float(pos_start[-2]))) - r0, mask.shape[0] - 1))
470
+ cs = max(0, min(int(round(float(pos_start[-1]))) - c0, mask.shape[1] - 1))
471
+ re = max(0, min(int(round(float(pos_end[-2]))) - r0, mask.shape[0] - 1))
472
+ ce = max(0, min(int(round(float(pos_end[-1]))) - c0, mask.shape[1] - 1))
473
+
474
+ new_lab = int(new_label) if new_label is not None else _free_label(seg)
475
+
476
+ for radius in range(7):
477
+ markers = np.zeros(mask.shape, dtype=np.int32)
478
+ if radius == 0:
479
+ markers[rs, cs] = la
480
+ markers[re, ce] = new_lab
481
+ else:
482
+ d = disk(radius)
483
+ seed_a = np.zeros(mask.shape, dtype=bool)
484
+ seed_a[rs, cs] = True
485
+ seed_b = np.zeros(mask.shape, dtype=bool)
486
+ seed_b[re, ce] = True
487
+ markers[binary_dilation(seed_a, d) & interior] = la
488
+ markers[binary_dilation(seed_b, d) & interior] = new_lab
489
+
490
+ if img is not None:
491
+ crop_img = _crop(img, bbox)
492
+ ws = watershed(crop_img, markers=markers, mask=mask)
493
+ else:
494
+ dist = distance_transform_edt(mask)
495
+ ws = watershed(-dist, markers=markers, mask=mask)
496
+
497
+ size_a = int(np.sum(ws == la))
498
+ size_b = int(np.sum(ws == new_lab))
499
+ if size_a >= MIN_CELL_SIZE and size_b >= MIN_CELL_SIZE:
500
+ seg[r0:r1, c0:c1][ws == new_lab] = new_lab
501
+ return True
502
+
503
+ return False
504
+
505
+
506
+ def split_draw(
507
+ seg: np.ndarray,
508
+ positions: list,
509
+ *,
510
+ curlabel: int | None = None,
511
+ new_label: int | None = None,
512
+ ) -> bool:
513
+ """Split a cell along a manually drawn line."""
514
+ log.debug("split_draw: %d raw positions, curlabel=%s", len(positions), curlabel)
515
+ if curlabel is None or curlabel == 0 or not np.any(seg == curlabel):
516
+ return False
517
+
518
+ bbox = _bbox_of_label(seg, curlabel)
519
+ bbox = _extend_bbox(bbox, 1.25, seg.shape)
520
+ crop = _crop(seg, bbox).copy()
521
+ local_pts = _to_local(positions, bbox)
522
+
523
+ in_cell_indices = [
524
+ i for i, p in enumerate(local_pts)
525
+ if 0 <= int(round(p[0])) < crop.shape[0]
526
+ and 0 <= int(round(p[1])) < crop.shape[1]
527
+ and crop[int(round(p[0])), int(round(p[1]))] == curlabel
528
+ ]
529
+ if len(in_cell_indices) < 2:
530
+ return False
531
+
532
+ first_idx, last_idx = in_cell_indices[0], in_cell_indices[-1]
533
+ in_cell = [local_pts[i] for i in in_cell_indices]
534
+
535
+ ext_start = local_pts[first_idx - 1] if first_idx > 0 else in_cell[0]
536
+ ext_end = local_pts[last_idx + 1] if last_idx < len(local_pts) - 1 else in_cell[-1]
537
+
538
+ all_pts = [ext_start] + in_cell + [ext_end]
539
+ interp = _interpolate(all_pts)
540
+ line = _draw_line(crop.shape, interp)
541
+
542
+ if int(np.sum(line & (crop == curlabel))) == 0:
543
+ return False
544
+
545
+ return _split_in_crop(seg, crop, line, bbox, curlabel, new_label=new_label)
546
+
547
+
548
+ def _split_in_crop(
549
+ seg: np.ndarray,
550
+ crop: np.ndarray,
551
+ line: np.ndarray,
552
+ bbox: tuple,
553
+ curlabel: int,
554
+ retry: int = 0,
555
+ *,
556
+ new_label: int | None = None,
557
+ ) -> bool:
558
+ if retry > 6:
559
+ return False
560
+
561
+ dilated = binary_dilation(line, disk(retry)) if retry > 0 else line.astype(bool)
562
+ mask = np.zeros(crop.shape, dtype=np.uint8)
563
+ mask[crop == curlabel] = 1
564
+ mask[dilated] = 0
565
+
566
+ regions, n = nd_label(mask)
567
+ sizes = [int(np.sum(regions == i)) for i in range(1, n + 1)]
568
+ log.debug("_split_in_crop: retry=%d n_regions=%d sizes=%s", retry, n, sizes)
569
+
570
+ if n >= 2:
571
+ ids_by_size = sorted(range(1, n + 1), key=lambda i: sizes[i - 1], reverse=True)
572
+ id_a, id_b = ids_by_size[0], ids_by_size[1]
573
+ size_a, size_b = sizes[id_a - 1], sizes[id_b - 1]
574
+ if size_a >= MIN_CELL_SIZE and size_b >= MIN_CELL_SIZE:
575
+ regions_2 = np.zeros_like(regions)
576
+ regions_2[regions == id_a] = 1
577
+ regions_2[regions == id_b] = 2
578
+ expanded = expand_labels(regions_2, distance=max(retry + 2, 3))
579
+ r0, c0, r1, c1 = bbox
580
+ new_lab = int(new_label) if new_label is not None else _free_label(seg)
581
+ orig_cell = crop == curlabel
582
+ seg[r0:r1, c0:c1][(expanded == 2) & orig_cell] = new_lab
583
+ return True
584
+
585
+ return _split_in_crop(seg, crop, line, bbox, curlabel, retry + 1, new_label=new_label)
586
+
587
+
588
+ def _mask_touches(a: np.ndarray, b: np.ndarray) -> bool:
589
+ """True if boolean masks *a* and *b* are adjacent (8-connectivity)."""
590
+ return bool(np.any(binary_dilation(a, disk(1)) & b))
591
+
592
+
593
+ def _fragments_along_line(
594
+ seg: np.ndarray,
595
+ lab: int,
596
+ line: np.ndarray,
597
+ ) -> list[np.ndarray]:
598
+ """Split cell *lab* along *line* into its two largest fragments.
599
+
600
+ Returns two full-frame boolean masks that together tile the whole cell (the
601
+ drawn line pixels are handed to the nearest fragment, exactly like
602
+ ``_split_in_crop``). Returns ``[]`` when the line does not run *all the way*
603
+ through the cell — i.e. removing it leaves the cell in one piece, or one
604
+ side is below ``MIN_CELL_SIZE``. ``seg`` is never modified.
605
+ """
606
+ bbox = _extend_bbox(_bbox_of_label(seg, lab), 1.0, seg.shape, min_pad=2)
607
+ r0, c0, r1, c1 = bbox
608
+ cell = _crop(seg, bbox) == lab
609
+ crop_line = _crop(line, bbox).astype(bool)
610
+
611
+ # Only a hair of dilation, to bridge a 1-px diagonal gap left by the drawn
612
+ # line. Dilating further would force a split even where the line merely
613
+ # clips the cell, so a stroke that doesn't run clean through stays a no-op.
614
+ for retry in range(2):
615
+ cut = binary_dilation(crop_line, disk(retry)) if retry else crop_line
616
+ regions, n = nd_label(cell & ~cut)
617
+ if n < 2:
618
+ continue
619
+ sizes = [int(np.sum(regions == i)) for i in range(1, n + 1)]
620
+ id_a, id_b = sorted(range(1, n + 1), key=lambda i: sizes[i - 1], reverse=True)[:2]
621
+ if sizes[id_a - 1] < MIN_CELL_SIZE or sizes[id_b - 1] < MIN_CELL_SIZE:
622
+ continue
623
+ two = np.zeros_like(regions)
624
+ two[regions == id_a] = 1
625
+ two[regions == id_b] = 2
626
+ expanded = expand_labels(two, distance=max(retry + 2, 3))
627
+ frags = []
628
+ for v in (1, 2):
629
+ full = np.zeros(seg.shape, dtype=bool)
630
+ full[r0:r1, c0:c1] = (expanded == v) & cell
631
+ frags.append(full)
632
+ return frags
633
+ return []
634
+
635
+
636
+ def carve_into_selected(
637
+ seg: np.ndarray,
638
+ positions: list,
639
+ *,
640
+ selected_label: int,
641
+ ) -> bool:
642
+ """Cut neighbouring cells along the drawn line and annex the near pieces.
643
+
644
+ The user draws a line *through* one or more neighbouring cells, starting from
645
+ (or towards) the selected cell. Every *other* cell the line crosses end to
646
+ end is split along that line — exactly like the Shift+Right split gesture of
647
+ the nucleus widget — into two fragments. The fragment that touches the
648
+ selected cell is then merged into it; if both fragments touch, the smaller
649
+ one is taken so the neighbour keeps its bulk. A cell the line only clips
650
+ (without dividing it in two) is left alone.
651
+
652
+ Cells are merged iteratively: a fragment that is not adjacent to the
653
+ selection at first can become adjacent once a cell between them has been
654
+ annexed, so each pass re-checks adjacency against the grown selection until
655
+ no further fragment touches it. Only one fragment per neighbour is ever
656
+ annexed, so the cell label set stays in lock-step with the nuclei — no label
657
+ is created, deleted or renumbered.
658
+
659
+ Cutting clean through the *selected* cell takes priority and is exclusive: if
660
+ the line divides the selection in two, its smaller piece is removed entirely
661
+ (set to background), the larger piece stays as the selected cell, and no
662
+ neighbour is annexed on that stroke.
663
+
664
+ A line that divides nothing (e.g. a straight swipe over background, or a
665
+ stroke that never fully crosses a cell) is a no-op. Returns ``True`` if any
666
+ pixel changed.
667
+ """
668
+ if not selected_label or not np.any(seg == selected_label):
669
+ return False
670
+ if len(positions) < 2:
671
+ return False
672
+
673
+ pts = [(float(p[-2]), float(p[-1])) for p in positions]
674
+ line = _draw_line(seg.shape, _interpolate(pts))
675
+ line_mask = line.astype(bool)
676
+ if not line_mask.any():
677
+ return False
678
+
679
+ sel_mask = seg == selected_label
680
+
681
+ # A line cutting clean through the selection trims it and nothing else: drop
682
+ # the smaller piece to background, keep the larger one, and stop here.
683
+ if (line_mask & sel_mask).any():
684
+ sel_frags = _fragments_along_line(seg, int(selected_label), line)
685
+ if len(sel_frags) >= 2:
686
+ drop = min(sel_frags, key=lambda f: int(f.sum()))
687
+ seg[drop] = 0
688
+ return True
689
+
690
+ crossed = set(int(v) for v in np.unique(seg[line_mask]))
691
+ crossed.discard(0)
692
+ crossed.discard(int(selected_label))
693
+
694
+ # Pre-split every neighbour the line fully traverses. Each entry maps the
695
+ # cell label to the two fragments it was divided into.
696
+ pending: dict[int, list[np.ndarray]] = {}
697
+ for lab in crossed:
698
+ frags = _fragments_along_line(seg, lab, line)
699
+ if len(frags) >= 2:
700
+ pending[lab] = frags
701
+
702
+ changed = False
703
+ progress = True
704
+ while pending and progress:
705
+ progress = False
706
+ for lab in list(pending):
707
+ touching = [f for f in pending[lab] if _mask_touches(f, sel_mask)]
708
+ if not touching:
709
+ continue # may become reachable after a neighbour is annexed
710
+ annex = min(touching, key=lambda f: int(f.sum()))
711
+ seg[annex] = selected_label
712
+ sel_mask = sel_mask | annex
713
+ del pending[lab]
714
+ changed = True
715
+ progress = True
716
+
717
+ if not changed:
718
+ return False
719
+
720
+ # Annexing a slice can leave a thin background gap enclosed in the selection;
721
+ # close only those background holes so the boundary stays clean.
722
+ holes = binary_fill_holes(sel_mask) & ~sel_mask & (seg == 0)
723
+ if holes.any():
724
+ seg[holes] = selected_label
725
+ return True
726
+
727
+
728
+ def draw_cell_path(
729
+ seg: np.ndarray,
730
+ positions: list,
731
+ *,
732
+ curlabel: int | None = None,
733
+ new_label: int | None = None,
734
+ protected_mask: np.ndarray | None = None,
735
+ ) -> bool:
736
+ """Draw a closed region from the user's stroke and fill its interior."""
737
+ log.debug("draw_cell_path: %d raw positions, curlabel=%s", len(positions), curlabel)
738
+ if len(positions) < 2:
739
+ return False
740
+
741
+ local_pts = [(float(p[-2]), float(p[-1])) for p in positions]
742
+
743
+ rows = np.array([p[0] for p in local_pts])
744
+ cols = np.array([p[1] for p in local_pts])
745
+ rr, cc = draw_polygon(rows, cols, seg.shape)
746
+ log.debug("draw_cell_path: polygon fill pixels=%d", len(rr))
747
+
748
+ if len(rr) < MIN_CELL_SIZE:
749
+ return False
750
+
751
+ extending = bool(curlabel) and curlabel != 0 and np.any(seg == curlabel)
752
+ label = curlabel if extending else (
753
+ int(new_label) if new_label is not None else _free_label(seg)
754
+ )
755
+
756
+ fill_mask = np.zeros(seg.shape, dtype=bool)
757
+ fill_mask[rr, cc] = True
758
+ if protected_mask is not None:
759
+ protected_mask = np.asarray(protected_mask, dtype=bool)
760
+ if protected_mask.shape != seg.shape:
761
+ raise ValueError(
762
+ f"protected_mask shape {protected_mask.shape} does not match "
763
+ f"segmentation shape {seg.shape}"
764
+ )
765
+ fill_mask &= ~protected_mask
766
+ if extending:
767
+ existing_mask = seg == label
768
+ if protected_mask is not None:
769
+ existing_mask &= ~protected_mask
770
+ connected_regions, _ = nd_label(existing_mask | fill_mask)
771
+ connected_ids = np.unique(connected_regions[existing_mask])
772
+ fill_mask &= np.isin(connected_regions, connected_ids)
773
+ else:
774
+ fill_mask &= (seg == 0)
775
+
776
+ n_px = int(np.sum(fill_mask))
777
+ if n_px < MIN_CELL_SIZE:
778
+ return False
779
+
780
+ seg[fill_mask] = label
781
+ if extending:
782
+ cell_mask = seg == label
783
+ filled_mask = binary_fill_holes(cell_mask)
784
+ hole_fill = filled_mask & ~cell_mask
785
+ if protected_mask is not None:
786
+ hole_fill &= ~protected_mask
787
+ seg[hole_fill] = label
788
+ return True
789
+
790
+
791
+ def swap_labels(seg: np.ndarray, pos_a: tuple, pos_b: tuple) -> bool:
792
+ """Swap the label values at the two click positions across the whole frame."""
793
+ la = _label_at(seg, pos_a)
794
+ lb = _label_at(seg, pos_b)
795
+ log.debug("swap_labels: la=%s lb=%s", la, lb)
796
+ if la == 0 or lb == 0 or la == lb:
797
+ return False
798
+ mask_a = seg == la
799
+ mask_b = seg == lb
800
+ seg[mask_a] = lb
801
+ seg[mask_b] = la
802
+ return True
803
+
804
+
805
+ def relabel_cell(seg: np.ndarray, pos: tuple, new_label: int) -> bool:
806
+ """Assign *new_label* to the cell at *pos* in *seg* (in-place).
807
+
808
+ If *new_label* already exists in the frame, the two cells are swapped so
809
+ no label is lost. Returns ``False`` when *pos* hits background, already
810
+ has *new_label*, or *new_label* is 0.
811
+ """
812
+ old_label = _label_at(seg, pos)
813
+ if old_label == 0 or new_label == 0 or old_label == new_label:
814
+ return False
815
+ conflict = seg == new_label
816
+ seg[seg == old_label] = new_label
817
+ if np.any(conflict):
818
+ seg[conflict] = old_label
819
+ return True
820
+
821
+
822
+ def fill_label_holes(labels: np.ndarray, radius: int = 5) -> np.ndarray:
823
+ """Fill background holes enclosed within a single cell.
824
+
825
+ A "hole" is a background component that does not touch the image border and
826
+ is surrounded by exactly one label; it is filled with that label. Background
827
+ bordered by two or more cells is an inter-cellular gap, not a hole, and is
828
+ left untouched (so cells are never expanded into the space between them).
829
+ ``radius`` caps the hole depth: a hole is only filled when its deepest pixel
830
+ lies within ``radius`` of the surrounding cell. ``radius <= 0`` is a no-op.
831
+ """
832
+ if radius <= 0:
833
+ return labels
834
+
835
+ result = labels.copy()
836
+ background = labels == 0
837
+ bg_cc, n_cc = nd_label(background)
838
+ if n_cc == 0:
839
+ return result
840
+ depth = distance_transform_edt(background)
841
+
842
+ for comp_id in range(1, n_cc + 1):
843
+ comp = bg_cc == comp_id
844
+ if (
845
+ np.any(comp[0, :])
846
+ or np.any(comp[-1, :])
847
+ or np.any(comp[:, 0])
848
+ or np.any(comp[:, -1])
849
+ ):
850
+ continue
851
+ ring = binary_dilation(comp) & ~comp
852
+ neighbours = np.unique(labels[ring])
853
+ neighbours = neighbours[neighbours != 0]
854
+ if neighbours.size != 1:
855
+ continue # gap between cells, not a hole within one cell
856
+ if depth[comp].max() > radius:
857
+ continue
858
+ result[comp] = neighbours[0]
859
+
860
+ return result
861
+
862
+
863
+ def fix_label_semiholes(
864
+ labels: np.ndarray,
865
+ radius: int = 5,
866
+ max_opening: int = 3,
867
+ ) -> np.ndarray:
868
+ """Fill a hole enclosed by a single cell except for a narrow image-edge opening.
869
+
870
+ Like :func:`fill_label_holes`, but for background that escapes to the image
871
+ border through a thin gap (up to ``max_opening`` pixels of edge contact).
872
+ The opening's edge flanks are ignored; the hole is filled only when its
873
+ interior is enclosed by exactly one label, so cells are never expanded into
874
+ inter-cellular gaps. ``radius`` caps the hole depth as in
875
+ :func:`fill_label_holes`. ``radius <= 0`` or ``max_opening <= 0`` is a no-op.
876
+ """
877
+ if max_opening <= 0 or radius <= 0:
878
+ return labels
879
+
880
+ result = labels.copy()
881
+ background = labels == 0
882
+ bg_cc, n_cc = nd_label(background)
883
+ if n_cc == 0:
884
+ return result
885
+ depth = distance_transform_edt(background)
886
+ edge = np.zeros(labels.shape, dtype=bool)
887
+ edge[0, :] = edge[-1, :] = edge[:, 0] = edge[:, -1] = True
888
+
889
+ for comp_id in range(1, n_cc + 1):
890
+ comp = bg_cc == comp_id
891
+ border_contact = (
892
+ int(np.count_nonzero(comp[0, :]))
893
+ + int(np.count_nonzero(comp[-1, :]))
894
+ + int(np.count_nonzero(comp[1:-1, 0]))
895
+ + int(np.count_nonzero(comp[1:-1, -1]))
896
+ )
897
+ if border_contact == 0 or border_contact > max_opening:
898
+ continue
899
+ # Ignore ring pixels on the image edge: those flank the opening, not the
900
+ # interior wall, and may belong to a different (outside) label.
901
+ ring = binary_dilation(comp) & ~comp & ~edge
902
+ neighbours = np.unique(labels[ring])
903
+ neighbours = neighbours[neighbours != 0]
904
+ if neighbours.size != 1:
905
+ continue # gap between cells, not a hole within one cell
906
+ if depth[comp].max() > radius:
907
+ continue
908
+ result[comp] = neighbours[0]
909
+
910
+ return result
911
+
912
+
913
+ def clean_stranded_pixels(
914
+ seg: np.ndarray,
915
+ min_size: int = MIN_CELL_SIZE,
916
+ *,
917
+ exclude: set[int] | None = None,
918
+ ) -> int:
919
+ """Remove disconnected same-label fragments, keeping each label's largest component.
920
+
921
+ Fragments are cleared and then ``expand_labels`` is used to propose a new
922
+ label. The proposal is only accepted (written back) when the reassigned
923
+ fragment pixels are 8-connected to an existing component of that same
924
+ label — this prevents ``expand_labels`` from recreating a disconnected
925
+ fragment.
926
+
927
+ Labels in *exclude* are left untouched. This is used by ``merge_cells`` to
928
+ preserve a merge target whose components are intentionally disconnected
929
+ (e.g. after merging a fragmented nucleus).
930
+ """
931
+ from skimage.measure import label as _cc_label
932
+ cleared = 0
933
+
934
+ for cell_id in np.unique(seg):
935
+ if cell_id == 0:
936
+ continue
937
+ if exclude is not None and int(cell_id) in exclude:
938
+ continue
939
+ mask = seg == cell_id
940
+ labeled, n_comp = _cc_label(mask, return_num=True, connectivity=2)
941
+ if n_comp <= 1:
942
+ continue
943
+ comp_sizes = {cid: int(np.sum(labeled == cid)) for cid in range(1, n_comp + 1)}
944
+ largest = max(comp_sizes, key=comp_sizes.__getitem__)
945
+ for comp_id, n_px in comp_sizes.items():
946
+ if comp_id == largest:
947
+ continue
948
+ comp_mask = labeled == comp_id
949
+ seg[comp_mask] = 0
950
+
951
+ # expand_labels and the keep-check only read pixels within the
952
+ # expansion distance of the fragment, so crop to the fragment bbox
953
+ # padded by that distance instead of scanning the whole frame per
954
+ # fragment. Any label that can reach a fragment pixel within
955
+ # ``distance`` lies inside this padded box, so the result is identical.
956
+ distance = n_px + 2
957
+ ys, xs = np.nonzero(comp_mask)
958
+ y0 = max(int(ys.min()) - distance, 0)
959
+ y1 = min(int(ys.max()) + distance + 1, seg.shape[0])
960
+ x0 = max(int(xs.min()) - distance, 0)
961
+ x1 = min(int(xs.max()) + distance + 1, seg.shape[1])
962
+ sub = seg[y0:y1, x0:x1]
963
+ sub_comp = comp_mask[y0:y1, x0:x1]
964
+
965
+ filled = expand_labels(sub, distance=distance)
966
+ new_labels = filled[sub_comp]
967
+ keep = np.zeros(new_labels.shape, dtype=bool)
968
+
969
+ for lbl in np.unique(new_labels):
970
+ if lbl == 0:
971
+ continue
972
+ assigned_here = new_labels == lbl
973
+ assign_mask = np.zeros_like(sub, dtype=bool)
974
+ assign_mask[sub_comp] = assigned_here
975
+ dilated = binary_dilation(assign_mask, structure=np.ones((3, 3), dtype=bool))
976
+ if np.any((sub == lbl) & dilated):
977
+ keep[assigned_here] = True
978
+
979
+ sub[sub_comp] = np.where(keep, new_labels, 0).astype(seg.dtype)
980
+ cleared += n_px
981
+
982
+ return cleared
983
+