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,133 @@
1
+ """Export selected NodeDB nodes to a (T, Z, Y, X) tracked labelmap."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import tifffile
10
+
11
+ from itasc.core.tiff import imwrite_grayscale
12
+ from itasc.tracking_ultrack.config import TrackingConfig
13
+ from itasc.tracking_ultrack.corrections import (
14
+ Correction,
15
+ apply_post_solve_corrections,
16
+ corrections_from_validated_tracks,
17
+ )
18
+ from itasc.tracking_ultrack.solve import database_has_annotations
19
+
20
+
21
+ def _build_export_config(cfg: TrackingConfig, working_dir: Path):
22
+ from itasc.tracking_ultrack.ingest import _build_ultrack_config
23
+
24
+ return _build_ultrack_config(cfg, working_dir)
25
+
26
+
27
+ def _materialize_labels(labels) -> np.ndarray:
28
+ if hasattr(labels, "compute"):
29
+ labels = labels.compute()
30
+ labels = np.asarray(labels, dtype=np.uint32)
31
+ if labels.ndim == 4 and labels.shape[1] == 1:
32
+ labels = labels[:, 0]
33
+ return labels
34
+
35
+
36
+ def export_tracked_labels(
37
+ working_dir: str | Path,
38
+ cfg: TrackingConfig,
39
+ output_path: str | Path,
40
+ *,
41
+ corrections: list[Correction] | None = None,
42
+ validated_tracks: dict[int, set[int]] | None = None,
43
+ tracked_labels: np.ndarray | None = None,
44
+ preserve_validated_ids: bool | None = None,
45
+ ) -> np.ndarray:
46
+ """Write ``tracked_labels.tif`` and return the (T, [Z,] Y, X) array."""
47
+ wd = Path(working_dir)
48
+ output_path = Path(output_path)
49
+ annotated_db = database_has_annotations(wd)
50
+ if preserve_validated_ids is None:
51
+ preserve_validated_ids = annotated_db
52
+ if corrections is None and validated_tracks and tracked_labels is not None:
53
+ corrections = corrections_from_validated_tracks(
54
+ validated_tracks,
55
+ np.asarray(tracked_labels, dtype=np.uint32),
56
+ )
57
+ if preserve_validated_ids and not corrections and (
58
+ not validated_tracks or tracked_labels is None
59
+ ):
60
+ raise ValueError(
61
+ "Validated-aware export requires validated tracks and tracked labels."
62
+ )
63
+
64
+ labels = _export_tracked_labels_raw(wd, cfg, output_path)
65
+ if corrections:
66
+ if tracked_labels is None:
67
+ tracked_labels = np.zeros_like(labels, dtype=np.uint32)
68
+ labels, _report = apply_post_solve_corrections(
69
+ labels,
70
+ corrections,
71
+ np.asarray(tracked_labels, dtype=np.uint32),
72
+ cfg,
73
+ working_dir=wd,
74
+ )
75
+ imwrite_grayscale(output_path, labels, compression="zlib")
76
+ return labels
77
+
78
+
79
+ def _export_tracked_labels_raw(
80
+ working_dir: str | Path,
81
+ cfg: TrackingConfig,
82
+ output_path: str | Path,
83
+ ) -> np.ndarray:
84
+ wd = Path(working_dir)
85
+ output_path = Path(output_path)
86
+ ultrack_cfg = _build_export_config(cfg, wd)
87
+
88
+ # Prefer public track export: tracks_to_zarr rasterizes each segment with
89
+ # its track_id, while label-export helpers may expose per-frame segment IDs.
90
+ # The import is the capability probe — only a missing API (ImportError /
91
+ # AttributeError) should fall through to the next strategy. A runtime failure
92
+ # (corrupt DB, OOM) must propagate rather than silently degrade the output.
93
+ try:
94
+ from ultrack.core.export import to_tracks_layer, tracks_to_zarr # type: ignore[import]
95
+ except (ImportError, AttributeError):
96
+ pass
97
+ else:
98
+ tracks_df, _graph = to_tracks_layer(ultrack_cfg)
99
+ labels = _materialize_labels(
100
+ tracks_to_zarr(ultrack_cfg, tracks_df, overwrite=True)
101
+ )
102
+ imwrite_grayscale(output_path, labels, compression="zlib")
103
+ return labels
104
+
105
+ # Try the modern to_labels API next (returns dask or numpy)
106
+ try:
107
+ from ultrack.core.export.labels import to_labels # type: ignore[import]
108
+ except (ImportError, AttributeError):
109
+ pass
110
+ else:
111
+ labels = _materialize_labels(to_labels(ultrack_cfg))
112
+ imwrite_grayscale(output_path, labels, compression="zlib")
113
+ return labels
114
+
115
+ # Fallback: CTC export → stack TIFFs
116
+ tmpdir = Path(tempfile.mkdtemp(prefix="ultrack_ctc_"))
117
+ try:
118
+ from ultrack.core.export.ctc import to_ctc # type: ignore[import]
119
+
120
+ to_ctc(tmpdir, ultrack_cfg, overwrite=True)
121
+ mask_files = sorted(tmpdir.rglob("mask*.tif"))
122
+ if not mask_files:
123
+ mask_files = sorted(tmpdir.rglob("man_track*.tif"))
124
+ if not mask_files:
125
+ mask_files = sorted(tmpdir.rglob("*.tif"))
126
+ if not mask_files:
127
+ raise RuntimeError("CTC export produced no mask files.")
128
+ frames = [tifffile.imread(str(f)) for f in mask_files]
129
+ labels = _materialize_labels(np.stack(frames, axis=0))
130
+ imwrite_grayscale(output_path, labels, compression="zlib")
131
+ return labels
132
+ finally:
133
+ shutil.rmtree(tmpdir, ignore_errors=True)
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import numpy as np
8
+
9
+ from itasc.tracking_ultrack._node_geometry import (
10
+ match_mask_to_node,
11
+ node_bbox_and_mask,
12
+ )
13
+
14
+ # How many ranked candidates the gallery API surfaces per direction.
15
+ _EXTEND_CANDIDATE_LIMIT = 6
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ExtendAssignment:
20
+ cell_id: int
21
+ candidate_label: int # ultrack node id of the linked candidate
22
+ candidate_partition: int
23
+ mask_2d: np.ndarray # bool, full-frame (Y, X)
24
+ bbox: tuple[int, int, int, int]
25
+ weight: float # LinkDB.weight (association score); higher = better
26
+
27
+
28
+ @dataclass
29
+ class ExtendResult:
30
+ target_frame: int
31
+ candidate_label: int
32
+ candidate_partition: int
33
+ mask_2d: np.ndarray # bool, full-frame (Y, X)
34
+ bbox: tuple[int, int, int, int] # (y0, x0, y1, x1)
35
+ weight: float # LinkDB.weight of the chosen link
36
+ assignments: tuple[ExtendAssignment, ...] = ()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ExtendCandidates:
41
+ """Ranked extend shortlist for one direction, best-first, for a gallery.
42
+
43
+ ``target_frame`` is the adjacent frame the candidates live on (one step in
44
+ ``direction`` from the source); ``assignments`` carries each candidate's
45
+ full-frame mask, bbox and link weight so the UI can render clickable
46
+ thumbnails.
47
+ """
48
+
49
+ target_frame: int
50
+ assignments: tuple[ExtendAssignment, ...] = ()
51
+
52
+ def is_empty(self) -> bool:
53
+ return not self.assignments
54
+
55
+
56
+ def _ranked_link_targets(
57
+ session, node_id: int, direction: Literal["forward", "backward"]
58
+ ) -> list[tuple[int, float]]:
59
+ """Linked candidate node ids in the adjacent frame, best (highest weight) first.
60
+
61
+ ``LinkDB`` edges point forward in time (``source_id`` at ``t``, ``target_id``
62
+ at ``t + 1``). Forward extension reads rows where the matched node is the
63
+ source and collects the targets; backward extension reads rows where it is
64
+ the target and collects the sources. A NULL weight is treated as ``1.0``.
65
+ """
66
+ from ultrack.core.database import LinkDB
67
+
68
+ if direction == "forward":
69
+ rows = (
70
+ session.query(LinkDB.target_id, LinkDB.weight)
71
+ .filter(LinkDB.source_id == node_id)
72
+ .all()
73
+ )
74
+ else:
75
+ rows = (
76
+ session.query(LinkDB.source_id, LinkDB.weight)
77
+ .filter(LinkDB.target_id == node_id)
78
+ .all()
79
+ )
80
+ ranked = [
81
+ (int(nid), float(weight) if weight is not None else 1.0)
82
+ for nid, weight in rows
83
+ ]
84
+ ranked.sort(key=lambda item: item[1], reverse=True)
85
+ return ranked
86
+
87
+
88
+ def _extend_assignments(
89
+ *,
90
+ source_id: int,
91
+ source_frame: int,
92
+ direction: Literal["forward", "backward"],
93
+ tracked_labels: np.ndarray,
94
+ db_path: Path,
95
+ limit: int,
96
+ ) -> tuple[int, list[ExtendAssignment]]:
97
+ """Shared core: ``(target_frame, ranked assignments)`` for the adjacent frame.
98
+
99
+ Matches the source label's mask to an Ultrack node, then walks the
100
+ precomputed ``LinkDB`` edges from that node into the adjacent frame, ranked
101
+ by association weight. Only the top ``limit`` linked candidates' masks are
102
+ deserialized. Returns an empty assignment list (with the computed
103
+ ``target_frame``) when the DB is missing, the target frame is out of range,
104
+ the source cell is absent, the source mask matches no node, or the matched
105
+ node has no link in the requested direction.
106
+ """
107
+ target_frame = source_frame + (1 if direction == "forward" else -1)
108
+ if not Path(db_path).exists():
109
+ return target_frame, []
110
+ if target_frame < 0 or target_frame >= tracked_labels.shape[0]:
111
+ return target_frame, []
112
+
113
+ source_mask = tracked_labels[source_frame] == source_id
114
+ if not source_mask.any():
115
+ return target_frame, []
116
+
117
+ frame_shape = tracked_labels.shape[1:]
118
+
119
+ import sqlalchemy as sqla
120
+ from sqlalchemy.orm import Session
121
+ from ultrack.core.database import NodeDB
122
+
123
+ engine = sqla.create_engine(
124
+ f"sqlite:///{db_path}", connect_args={"check_same_thread": False}
125
+ )
126
+ assignments: list[ExtendAssignment] = []
127
+ try:
128
+ with Session(engine) as session:
129
+ matched = match_mask_to_node(session, source_frame, source_mask)
130
+ if matched is None:
131
+ return target_frame, []
132
+ matched_id, _bbox, _mask = matched
133
+
134
+ ranked = _ranked_link_targets(session, matched_id, direction)
135
+ if not ranked:
136
+ return target_frame, []
137
+
138
+ # Load only the linked candidates' masks (a handful), restricted to
139
+ # the adjacent frame as a safety net against malformed links.
140
+ node_rows = {
141
+ int(node.id): node
142
+ for node in session.query(NodeDB)
143
+ .filter(
144
+ NodeDB.t == target_frame,
145
+ NodeDB.id.in_([nid for nid, _ in ranked]),
146
+ )
147
+ .all()
148
+ }
149
+ for nid, weight in ranked:
150
+ node = node_rows.get(nid)
151
+ if node is None:
152
+ continue
153
+ try:
154
+ (y0, x0, y1, x1), mask_crop = node_bbox_and_mask(nid, node.pickle)
155
+ except Exception:
156
+ continue
157
+ if mask_crop.shape != (y1 - y0, x1 - x0):
158
+ continue
159
+ full_mask = np.zeros(frame_shape, dtype=bool)
160
+ full_mask[y0:y1, x0:x1] = mask_crop
161
+ if not full_mask.any():
162
+ continue
163
+ assignments.append(
164
+ ExtendAssignment(
165
+ cell_id=source_id,
166
+ candidate_label=nid,
167
+ candidate_partition=0,
168
+ mask_2d=full_mask,
169
+ bbox=(y0, x0, y1, x1),
170
+ weight=weight,
171
+ )
172
+ )
173
+ if limit and len(assignments) >= limit:
174
+ break
175
+ finally:
176
+ engine.dispose()
177
+ return target_frame, assignments
178
+
179
+
180
+ def extend_track_from_db(
181
+ *,
182
+ source_id: int,
183
+ source_frame: int,
184
+ direction: Literal["forward", "backward"],
185
+ tracked_labels: np.ndarray, # (T, Y, X) uint32
186
+ db_path: Path,
187
+ ) -> ExtendResult | None:
188
+ """Extend a track to the adjacent frame using ultrack ``LinkDB`` candidates.
189
+
190
+ The source label's mask is matched to an Ultrack node; the highest-weight
191
+ ``LinkDB`` edge into the adjacent frame picks the continuation. Returns
192
+ ``None`` when the DB is missing, the target frame is out of range, the
193
+ source cell is absent, or the matched node has no link in ``direction`` (the
194
+ caller should show a local status message and leave the frame untouched).
195
+ """
196
+ target_frame, assignments = _extend_assignments(
197
+ source_id=source_id,
198
+ source_frame=source_frame,
199
+ direction=direction,
200
+ tracked_labels=tracked_labels,
201
+ db_path=db_path,
202
+ limit=1,
203
+ )
204
+ if not assignments:
205
+ return None
206
+ best = assignments[0]
207
+ return ExtendResult(
208
+ target_frame=target_frame,
209
+ candidate_label=best.candidate_label,
210
+ candidate_partition=best.candidate_partition,
211
+ mask_2d=best.mask_2d,
212
+ bbox=best.bbox,
213
+ weight=best.weight,
214
+ assignments=(best,),
215
+ )
216
+
217
+
218
+ def list_extend_candidates(
219
+ *,
220
+ source_id: int,
221
+ source_frame: int,
222
+ direction: Literal["forward", "backward"],
223
+ tracked_labels: np.ndarray, # (T, Y, X) uint32
224
+ db_path: Path,
225
+ limit: int = _EXTEND_CANDIDATE_LIMIT,
226
+ ) -> ExtendCandidates:
227
+ """Ranked extend candidates at the adjacent frame, best-first, for a gallery.
228
+
229
+ Same matching as :func:`extend_track_from_db`, but returns the whole ranked
230
+ shortlist of ``LinkDB`` candidates (each with its full-frame mask) instead of
231
+ only the winner, so the correction UI can render clickable candidate
232
+ thumbnails. Returns an empty :class:`ExtendCandidates` (still carrying
233
+ ``target_frame``) when there is no DB, target frame, matched node or link.
234
+ """
235
+ target_frame, assignments = _extend_assignments(
236
+ source_id=source_id,
237
+ source_frame=source_frame,
238
+ direction=direction,
239
+ tracked_labels=tracked_labels,
240
+ db_path=db_path,
241
+ limit=limit,
242
+ )
243
+ return ExtendCandidates(target_frame=target_frame, assignments=tuple(assignments))
@@ -0,0 +1,83 @@
1
+ """Ultrack configuration helpers for ITASC tracking."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from itasc.tracking_ultrack.config import TrackingConfig
9
+
10
+
11
+ def _signed_power_transform(values, *, power: float, bias: float):
12
+ values = np.asarray(values)
13
+ return np.sign(values) * np.power(np.abs(values), power) + bias
14
+
15
+
16
+ def _install_signed_power_transform(ultrack_cfg) -> None:
17
+ tracking_cfg = getattr(ultrack_cfg, "tracking_config", None)
18
+ if tracking_cfg is None:
19
+ return
20
+ link_function = getattr(tracking_cfg, "link_function", None)
21
+ if getattr(link_function, "value", link_function) != "power":
22
+ return
23
+ if not hasattr(tracking_cfg, "dict"):
24
+ return
25
+
26
+ base_cls = tracking_cfg.__class__
27
+
28
+ class _ITASCSignedPowerTrackingConfig(base_cls):
29
+ @property
30
+ def apply_link_function(self):
31
+ return lambda values: _signed_power_transform(
32
+ values,
33
+ power=self.power,
34
+ bias=self.bias,
35
+ )
36
+
37
+ ultrack_cfg.tracking_config = _ITASCSignedPowerTrackingConfig.parse_obj(
38
+ tracking_cfg.dict()
39
+ )
40
+
41
+
42
+ def _build_ultrack_config(cfg: TrackingConfig, working_dir: Path):
43
+ from ultrack.config import MainConfig
44
+ from ultrack.config.segmentationconfig import NAME_TO_WS_HIER
45
+
46
+ ultrack_cfg = MainConfig(
47
+ data={"working_dir": str(working_dir)},
48
+ linking={
49
+ "max_distance": cfg.max_distance,
50
+ "max_neighbors": cfg.max_neighbors,
51
+ "distance_weight": cfg.distance_weight,
52
+ "n_workers": cfg.link_n_workers,
53
+ },
54
+ tracking={
55
+ "solver_name": _select_solver(),
56
+ "appear_weight": cfg.appear_weight,
57
+ "disappear_weight": cfg.disappear_weight,
58
+ "division_weight": cfg.division_weight,
59
+ "link_function": cfg.link_function,
60
+ "power": cfg.power,
61
+ "bias": cfg.bias,
62
+ "solution_gap": cfg.solution_gap,
63
+ "time_limit": cfg.time_limit,
64
+ "window_size": cfg.window_size if cfg.window_size > 0 else None,
65
+ },
66
+ )
67
+ _install_signed_power_transform(ultrack_cfg)
68
+ sc = ultrack_cfg.segmentation_config
69
+ sc.min_area = cfg.seg_min_area
70
+ sc.max_area = cfg.seg_max_area
71
+ sc.threshold = cfg.seg_foreground_threshold
72
+ sc.min_frontier = cfg.seg_min_frontier
73
+ sc.ws_hierarchy = NAME_TO_WS_HIER[cfg.seg_ws_hierarchy]
74
+ sc.n_workers = cfg.seg_n_workers
75
+ return ultrack_cfg
76
+
77
+
78
+ def _select_solver() -> str:
79
+ try:
80
+ import gurobipy # noqa: F401
81
+ return "GUROBI"
82
+ except ImportError:
83
+ return "CBC"