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.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- 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
|
+
|