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,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)