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,128 @@
|
|
|
1
|
+
"""Filtering helpers for nucleus contour-map stacks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.ndimage import gaussian_filter, median_filter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ContourFilterParams:
|
|
12
|
+
"""Parameters for spatial and temporal contour-map filtering."""
|
|
13
|
+
|
|
14
|
+
median_kernel_time: int = 1
|
|
15
|
+
median_kernel_space: int = 1
|
|
16
|
+
gaussian_sigma_time: float = 0.0
|
|
17
|
+
gaussian_sigma_space: float = 0.0
|
|
18
|
+
memory_tau: float = 0.0
|
|
19
|
+
memory_floor: float = 0.01
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_contour_stack(contours: np.ndarray) -> tuple[np.ndarray, str]:
|
|
23
|
+
arr = np.asarray(contours, dtype=np.float32)
|
|
24
|
+
if arr.ndim == 2:
|
|
25
|
+
return arr[np.newaxis], "yx"
|
|
26
|
+
if arr.ndim == 3:
|
|
27
|
+
return arr, "tyx"
|
|
28
|
+
if arr.ndim == 4 and arr.shape[1] == 1:
|
|
29
|
+
return arr[:, 0], "tcyx"
|
|
30
|
+
raise ValueError(f"Unsupported contour maps shape {arr.shape}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _restore_contour_stack(contours_tyx: np.ndarray, layout: str) -> np.ndarray:
|
|
34
|
+
if layout == "yx":
|
|
35
|
+
return contours_tyx[0]
|
|
36
|
+
if layout == "tcyx":
|
|
37
|
+
return contours_tyx[:, np.newaxis]
|
|
38
|
+
return contours_tyx
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ema_pass(contours: np.ndarray, tau: float, floor: float) -> np.ndarray:
|
|
42
|
+
"""Single-direction EMA with signal-adaptive alpha.
|
|
43
|
+
|
|
44
|
+
contour_out[t] = α · contour[t] + (1 - α) · contour_out[t-1]
|
|
45
|
+
|
|
46
|
+
where α = clamp(contour[t] / (contour[t] + τ), floor, 1.0)
|
|
47
|
+
|
|
48
|
+
α ≈ 1 where contour is strong → trust the data
|
|
49
|
+
α ≈ 0 where contour is weak → trust the memory
|
|
50
|
+
|
|
51
|
+
``floor`` ensures slow decay even with zero signal, preventing
|
|
52
|
+
permanent ghosts from a single strong frame.
|
|
53
|
+
"""
|
|
54
|
+
T = contours.shape[0]
|
|
55
|
+
out = np.empty_like(contours)
|
|
56
|
+
out[0] = contours[0]
|
|
57
|
+
|
|
58
|
+
for t in range(1, T):
|
|
59
|
+
alpha = contours[t] / (contours[t] + tau)
|
|
60
|
+
alpha = np.clip(alpha, floor, 1.0)
|
|
61
|
+
out[t] = alpha * contours[t] + (1.0 - alpha) * out[t - 1]
|
|
62
|
+
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def contour_memory_filter(
|
|
67
|
+
contours: np.ndarray,
|
|
68
|
+
tau: float = 0.1,
|
|
69
|
+
floor: float = 0.01,
|
|
70
|
+
) -> np.ndarray:
|
|
71
|
+
"""Bidirectional contour memory filter.
|
|
72
|
+
|
|
73
|
+
Runs a signal-adaptive EMA forward (t=0→T) and backward (t=T→0),
|
|
74
|
+
then averages. Strong ridges reset the memory; weak/absent ridges
|
|
75
|
+
inherit from their temporal neighbours.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
contours : (T, H, W) float32
|
|
80
|
+
tau : Signal threshold controlling the crossover between "trust data"
|
|
81
|
+
and "trust memory". Set to roughly the contour value you consider
|
|
82
|
+
"weak". Lower = more aggressive persistence.
|
|
83
|
+
floor : Minimum alpha per frame. Prevents permanent ghosting — even
|
|
84
|
+
with zero signal the memory decays at rate ``(1 - floor)`` per
|
|
85
|
+
frame. At 0.01 a ghost halves in ~69 frames; at 0.05 ~14 frames.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
filtered : (T, H, W) float32
|
|
90
|
+
"""
|
|
91
|
+
contours = np.asarray(contours, dtype=np.float32)
|
|
92
|
+
if contours.shape[0] < 2:
|
|
93
|
+
return contours.copy()
|
|
94
|
+
forward = _ema_pass(contours, tau, floor)
|
|
95
|
+
backward = _ema_pass(contours[::-1], tau, floor)[::-1]
|
|
96
|
+
return (forward + backward) / 2.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_filtered_contour_maps(
|
|
100
|
+
contours: np.ndarray,
|
|
101
|
+
params: ContourFilterParams,
|
|
102
|
+
) -> np.ndarray:
|
|
103
|
+
"""Return contour maps after median, Gaussian, and memory filtering."""
|
|
104
|
+
filtered, layout = _normalize_contour_stack(contours)
|
|
105
|
+
if params.median_kernel_time > 1 or params.median_kernel_space > 1:
|
|
106
|
+
filtered = median_filter(
|
|
107
|
+
filtered,
|
|
108
|
+
size=(
|
|
109
|
+
int(params.median_kernel_time),
|
|
110
|
+
int(params.median_kernel_space),
|
|
111
|
+
int(params.median_kernel_space),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
if params.gaussian_sigma_time > 0.0 or params.gaussian_sigma_space > 0.0:
|
|
115
|
+
filtered = gaussian_filter(
|
|
116
|
+
filtered,
|
|
117
|
+
sigma=(
|
|
118
|
+
float(params.gaussian_sigma_time),
|
|
119
|
+
float(params.gaussian_sigma_space),
|
|
120
|
+
float(params.gaussian_sigma_space),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
filtered = np.asarray(filtered, dtype=np.float32)
|
|
124
|
+
if params.memory_tau > 0.0 and filtered.shape[0] > 1:
|
|
125
|
+
filtered = contour_memory_filter(
|
|
126
|
+
filtered, tau=params.memory_tau, floor=params.memory_floor,
|
|
127
|
+
)
|
|
128
|
+
return _restore_contour_stack(filtered, layout)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Backward-compatible shim.
|
|
2
|
+
|
|
3
|
+
The lineage swimlane model is shared substrate (used by the correction lineage
|
|
4
|
+
panel, which is part of the independently-installable tracking/correction piece),
|
|
5
|
+
so it now lives in :mod:`itasc.core.lineage`. This re-export keeps the historic
|
|
6
|
+
``itasc.segmentation.lineage`` import path working for the full orchestrator and
|
|
7
|
+
any out-of-repo consumers.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from itasc.core.lineage import (
|
|
12
|
+
LineageModel,
|
|
13
|
+
TrackLane,
|
|
14
|
+
TrackSegment,
|
|
15
|
+
build_lineage,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["LineageModel", "TrackLane", "TrackSegment", "build_lineage"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Nucleus segmentation helpers: cancellation and per-label hole filling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from itasc.core.cancellation import CancelledError
|
|
9
|
+
|
|
10
|
+
__all__ = ["CancelledError"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check_cancel(cancel: Callable[[], bool] | None) -> None:
|
|
14
|
+
if cancel is not None and cancel():
|
|
15
|
+
raise CancelledError("Operation cancelled.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fill_and_close_labels(labels: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Fill interior holes per label."""
|
|
20
|
+
from scipy.ndimage import binary_fill_holes
|
|
21
|
+
|
|
22
|
+
out = np.zeros_like(labels)
|
|
23
|
+
for label_id in np.unique(labels):
|
|
24
|
+
if label_id == 0:
|
|
25
|
+
continue
|
|
26
|
+
coords = np.nonzero(labels == label_id)
|
|
27
|
+
# np.nonzero always returns a tuple of ndim arrays (truthy), so the only
|
|
28
|
+
# real emptiness signal is a zero-length coordinate array.
|
|
29
|
+
if coords[0].size == 0:
|
|
30
|
+
continue
|
|
31
|
+
slices = tuple(slice(int(axis.min()), int(axis.max()) + 1) for axis in coords)
|
|
32
|
+
filled = binary_fill_holes(labels[slices] == label_id)
|
|
33
|
+
out_view = out[slices]
|
|
34
|
+
out_view[filled] = label_id
|
|
35
|
+
return out
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Ultrack-based ILP tracker for ITASC candidate databases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__all__ = ["TrackingConfig"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def __getattr__(name: str):
|
|
8
|
+
if name == "TrackingConfig":
|
|
9
|
+
from itasc.tracking_ultrack.config import TrackingConfig
|
|
10
|
+
|
|
11
|
+
return TrackingConfig
|
|
12
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Shared Ultrack node geometry helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pickle
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def node_bbox_and_mask(node_id: int, node) -> tuple[tuple[int, int, int, int], np.ndarray]:
|
|
10
|
+
if isinstance(node, (bytes, memoryview)):
|
|
11
|
+
node = pickle.loads(bytes(node))
|
|
12
|
+
|
|
13
|
+
bbox = np.asarray(node.bbox)
|
|
14
|
+
ndim = len(bbox) // 2
|
|
15
|
+
if ndim == 3:
|
|
16
|
+
y0, x0 = int(bbox[1]), int(bbox[2])
|
|
17
|
+
y1, x1 = int(bbox[4]), int(bbox[5])
|
|
18
|
+
elif ndim == 2:
|
|
19
|
+
y0, x0 = int(bbox[0]), int(bbox[1])
|
|
20
|
+
y1, x1 = int(bbox[2]), int(bbox[3])
|
|
21
|
+
else:
|
|
22
|
+
raise ValueError(f"Unexpected bbox for node {node_id}: {bbox}")
|
|
23
|
+
|
|
24
|
+
mask = np.asarray(node.mask, dtype=bool)
|
|
25
|
+
if mask.ndim == 3:
|
|
26
|
+
mask = mask[0] if mask.shape[0] == 1 else mask.any(axis=0)
|
|
27
|
+
elif mask.ndim != 2:
|
|
28
|
+
raise ValueError(f"Unexpected mask for node {node_id}: shape {mask.shape}")
|
|
29
|
+
|
|
30
|
+
return (y0, x0, y1, x1), np.ascontiguousarray(mask, dtype=bool)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def intersects(
|
|
34
|
+
lhs_bbox: tuple[int, int, int, int],
|
|
35
|
+
lhs_mask: np.ndarray,
|
|
36
|
+
rhs_bbox: tuple[int, int, int, int],
|
|
37
|
+
rhs_mask: np.ndarray,
|
|
38
|
+
) -> bool:
|
|
39
|
+
ly0, lx0, ly1, lx1 = lhs_bbox
|
|
40
|
+
ry0, rx0, ry1, rx1 = rhs_bbox
|
|
41
|
+
oy0, ox0 = max(ly0, ry0), max(lx0, rx0)
|
|
42
|
+
oy1, ox1 = min(ly1, ry1), min(lx1, rx1)
|
|
43
|
+
if oy0 >= oy1 or ox0 >= ox1:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
lhs_crop = lhs_mask[oy0 - ly0: oy1 - ly0, ox0 - lx0: ox1 - lx0]
|
|
47
|
+
rhs_crop = rhs_mask[oy0 - ry0: oy1 - ry0, ox0 - rx0: ox1 - rx0]
|
|
48
|
+
return bool(np.logical_and(lhs_crop, rhs_crop).any())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def intersection_area(
|
|
52
|
+
lhs_bbox: tuple[int, int, int, int],
|
|
53
|
+
lhs_mask: np.ndarray,
|
|
54
|
+
rhs_bbox: tuple[int, int, int, int],
|
|
55
|
+
rhs_mask: np.ndarray,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Number of pixels where the two cropped masks overlap."""
|
|
58
|
+
ly0, lx0, ly1, lx1 = lhs_bbox
|
|
59
|
+
ry0, rx0, ry1, rx1 = rhs_bbox
|
|
60
|
+
oy0, ox0 = max(ly0, ry0), max(lx0, rx0)
|
|
61
|
+
oy1, ox1 = min(ly1, ry1), min(lx1, rx1)
|
|
62
|
+
if oy0 >= oy1 or ox0 >= ox1:
|
|
63
|
+
return 0
|
|
64
|
+
lhs_crop = lhs_mask[oy0 - ly0: oy1 - ly0, ox0 - lx0: ox1 - lx0]
|
|
65
|
+
rhs_crop = rhs_mask[oy0 - ry0: oy1 - ry0, ox0 - rx0: ox1 - rx0]
|
|
66
|
+
return int(np.logical_and(lhs_crop, rhs_crop).sum())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def raw_iou(
|
|
70
|
+
lhs_bbox: tuple[int, int, int, int],
|
|
71
|
+
lhs_mask: np.ndarray,
|
|
72
|
+
rhs_bbox: tuple[int, int, int, int],
|
|
73
|
+
rhs_mask: np.ndarray,
|
|
74
|
+
) -> float:
|
|
75
|
+
intersection = intersection_area(lhs_bbox, lhs_mask, rhs_bbox, rhs_mask)
|
|
76
|
+
union = int(lhs_mask.sum()) + int(rhs_mask.sum()) - intersection
|
|
77
|
+
if union <= 0:
|
|
78
|
+
return 0.0
|
|
79
|
+
return float(intersection) / float(union)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mask_bbox(mask: np.ndarray) -> tuple[int, int, int, int] | None:
|
|
83
|
+
"""Tight ``(y0, x0, y1, x1)`` bounding box of a boolean mask, or ``None`` if empty."""
|
|
84
|
+
ys, xs = np.where(mask)
|
|
85
|
+
if ys.size == 0:
|
|
86
|
+
return None
|
|
87
|
+
return int(ys.min()), int(xs.min()), int(ys.max()) + 1, int(xs.max()) + 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def centroid_gate(mask: np.ndarray) -> tuple[float, float, float] | None:
|
|
91
|
+
"""Centroid-distance prefilter for nodes that may overlap ``mask``.
|
|
92
|
+
|
|
93
|
+
Returns ``(cy, cx, radius)``: a candidate node is plausible when its
|
|
94
|
+
centroid lies within ``radius`` of ``(cy, cx)``. This replaces requiring the
|
|
95
|
+
node centroid to fall *inside* the source bbox, which drops nodes that
|
|
96
|
+
overlap an elongated / crescent / merged source but whose centroid sits
|
|
97
|
+
outside that box. ``radius`` is the source bbox diagonal — generous enough
|
|
98
|
+
to reach a comparably-sized node touching the source anywhere along its
|
|
99
|
+
extent. Returns ``None`` for an empty mask.
|
|
100
|
+
"""
|
|
101
|
+
ys, xs = np.where(mask)
|
|
102
|
+
if ys.size == 0:
|
|
103
|
+
return None
|
|
104
|
+
cy = float(ys.mean())
|
|
105
|
+
cx = float(xs.mean())
|
|
106
|
+
radius = float(np.hypot(ys.max() - ys.min(), xs.max() - xs.min()))
|
|
107
|
+
return cy, cx, radius
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def match_mask_to_node(
|
|
111
|
+
session, frame: int, source_mask: np.ndarray
|
|
112
|
+
) -> tuple[int, tuple[int, int, int, int], np.ndarray] | None:
|
|
113
|
+
"""Best-IoU Ultrack node at ``frame`` overlapping ``source_mask``.
|
|
114
|
+
|
|
115
|
+
Returns ``(node_id, bbox, mask_crop)`` for the node whose mask has the
|
|
116
|
+
highest IoU with ``source_mask``, or ``None`` if the mask is empty or
|
|
117
|
+
nothing overlaps. Prefilters NodeDB by centroid distance (not source-bbox
|
|
118
|
+
containment) so a node overlapping an elongated/crescent/merged source —
|
|
119
|
+
whose centroid sits outside the source bbox — is still considered before the
|
|
120
|
+
exact-IoU stage prunes non-overlappers.
|
|
121
|
+
"""
|
|
122
|
+
from ultrack.core.database import NodeDB
|
|
123
|
+
|
|
124
|
+
src_bbox = mask_bbox(source_mask)
|
|
125
|
+
gate = centroid_gate(source_mask)
|
|
126
|
+
if src_bbox is None or gate is None:
|
|
127
|
+
return None
|
|
128
|
+
sy0, sx0, sy1, sx1 = src_bbox
|
|
129
|
+
src_crop = np.ascontiguousarray(source_mask[sy0:sy1, sx0:sx1], dtype=bool)
|
|
130
|
+
|
|
131
|
+
cy, cx, radius = gate
|
|
132
|
+
dist_sq = (NodeDB.y - cy) * (NodeDB.y - cy) + (NodeDB.x - cx) * (NodeDB.x - cx)
|
|
133
|
+
rows = (
|
|
134
|
+
session.query(NodeDB.id, NodeDB.pickle)
|
|
135
|
+
.filter(
|
|
136
|
+
NodeDB.t == frame,
|
|
137
|
+
dist_sq <= radius * radius,
|
|
138
|
+
)
|
|
139
|
+
.all()
|
|
140
|
+
)
|
|
141
|
+
matched_id: int | None = None
|
|
142
|
+
best_iou = 0.0
|
|
143
|
+
matched_geom: tuple[tuple[int, int, int, int], np.ndarray] | None = None
|
|
144
|
+
for nid, blob in rows:
|
|
145
|
+
try:
|
|
146
|
+
bbox, mask_crop = node_bbox_and_mask(int(nid), blob)
|
|
147
|
+
except Exception:
|
|
148
|
+
continue
|
|
149
|
+
iou = raw_iou(src_bbox, src_crop, bbox, mask_crop)
|
|
150
|
+
if iou > best_iou:
|
|
151
|
+
best_iou = iou
|
|
152
|
+
matched_id = int(nid)
|
|
153
|
+
matched_geom = (bbox, mask_crop)
|
|
154
|
+
if matched_id is None or matched_geom is None:
|
|
155
|
+
return None
|
|
156
|
+
bbox, mask_crop = matched_geom
|
|
157
|
+
return matched_id, bbox, mask_crop
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def node_pickle_ndim(node) -> int:
|
|
161
|
+
if isinstance(node, (bytes, memoryview)):
|
|
162
|
+
node = pickle.loads(bytes(node))
|
|
163
|
+
bbox = np.asarray(node.bbox)
|
|
164
|
+
return len(bbox) // 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def make_node_pickle(
|
|
168
|
+
t: int,
|
|
169
|
+
mask_2d: np.ndarray,
|
|
170
|
+
bbox: np.ndarray,
|
|
171
|
+
node_id: int,
|
|
172
|
+
*,
|
|
173
|
+
ndim: int = 2,
|
|
174
|
+
) -> bytes:
|
|
175
|
+
from ultrack.core.segmentation.node import Node
|
|
176
|
+
|
|
177
|
+
min_y, min_x, max_y, max_x = bbox
|
|
178
|
+
if ndim == 3:
|
|
179
|
+
bbox_arr = np.array(
|
|
180
|
+
[0, int(min_y), int(min_x), 1, int(max_y), int(max_x)],
|
|
181
|
+
dtype=np.int64,
|
|
182
|
+
)
|
|
183
|
+
mask = np.asarray(mask_2d, dtype=bool)[np.newaxis]
|
|
184
|
+
else:
|
|
185
|
+
bbox_arr = np.array(
|
|
186
|
+
[int(min_y), int(min_x), int(max_y), int(max_x)],
|
|
187
|
+
dtype=np.int64,
|
|
188
|
+
)
|
|
189
|
+
mask = np.asarray(mask_2d, dtype=bool)
|
|
190
|
+
node = Node.from_mask(time=t, mask=mask, bbox=bbox_arr, node_id=node_id)
|
|
191
|
+
return pickle.dumps(node)
|