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,175 @@
1
+ """Resolve-time node probability scoring from image quality and validated seeds."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import math
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import tifffile
10
+
11
+ from itasc.tracking_ultrack._node_geometry import node_bbox_and_mask
12
+ from itasc.tracking_ultrack.config import TrackingConfig
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SeedPriorReport:
17
+ scored: int
18
+ seeds: int
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class _NodeScoreRecord:
23
+ node_id: int
24
+ t: int
25
+ bbox: tuple[int, int, int, int]
26
+ mask: np.ndarray
27
+
28
+
29
+ def _load_signal_stack(path: str | Path) -> np.ndarray:
30
+ path = Path(path)
31
+ if not path.exists():
32
+ raise FileNotFoundError(f"Quality signal image not found: {path}")
33
+
34
+ arr = np.asarray(tifffile.imread(path), dtype=np.float32)
35
+ if arr.ndim == 2:
36
+ return arr[np.newaxis]
37
+ if arr.ndim == 3:
38
+ return arr
39
+ if arr.ndim == 4 and arr.shape[1] == 1:
40
+ return arr[:, 0]
41
+ raise ValueError(
42
+ f"Expected quality signal image to be 2D, 3D, or singleton-Z 4D, got {arr.shape}"
43
+ )
44
+
45
+
46
+ def _binary_dilation_2d(mask: np.ndarray) -> np.ndarray:
47
+ padded = np.pad(np.asarray(mask, dtype=bool), 1, mode="constant")
48
+ h, w = mask.shape
49
+ dilated = np.zeros((h, w), dtype=bool)
50
+ for dy in range(3):
51
+ for dx in range(3):
52
+ dilated |= padded[dy:dy + h, dx:dx + w]
53
+ return dilated
54
+
55
+
56
+ def compute_drop_frac(frame: np.ndarray, bbox: tuple[int, int, int, int], mask: np.ndarray) -> float:
57
+ y0, x0, y1, x1 = bbox
58
+ frame = np.asarray(frame, dtype=np.float32)
59
+ mask = np.asarray(mask, dtype=bool)
60
+ inside = frame[y0:y1, x0:x1][mask]
61
+ if inside.size == 0:
62
+ return 0.0
63
+ inside_median = float(np.median(inside))
64
+
65
+ pad_y0 = max(0, y0 - 1)
66
+ pad_x0 = max(0, x0 - 1)
67
+ pad_y1 = min(frame.shape[0], y1 + 1)
68
+ pad_x1 = min(frame.shape[1], x1 + 1)
69
+
70
+ expanded = np.zeros((pad_y1 - pad_y0, pad_x1 - pad_x0), dtype=bool)
71
+ inner_y0 = y0 - pad_y0
72
+ inner_x0 = x0 - pad_x0
73
+ expanded[
74
+ inner_y0:inner_y0 + mask.shape[0],
75
+ inner_x0:inner_x0 + mask.shape[1],
76
+ ] = mask
77
+ ring = _binary_dilation_2d(expanded) & ~expanded
78
+ if not ring.any():
79
+ return 0.0
80
+
81
+ ring_values = frame[pad_y0:pad_y1, pad_x0:pad_x1][ring]
82
+ return float(np.mean(ring_values < inside_median))
83
+
84
+
85
+ def compute_mask_circularity(mask: np.ndarray) -> float:
86
+ from skimage.measure import perimeter
87
+
88
+ mask = np.asarray(mask, dtype=bool)
89
+ area = int(mask.sum())
90
+ if area == 0:
91
+ return 0.0
92
+
93
+ perimeter_px = float(perimeter(mask, neighborhood=4))
94
+ if perimeter_px <= 0.0:
95
+ return 0.0
96
+
97
+ circularity = 4.0 * math.pi * float(area) / (perimeter_px * perimeter_px)
98
+ return float(np.clip(circularity, 0.0, 1.0))
99
+
100
+
101
+ def _seed_node_prob(base: float, max_base: float, exponent: float) -> float:
102
+ """Normalise ``base`` to [0, 1] and raise it to ``exponent``.
103
+
104
+ Guards the zero-quality case: ``0 ** 0 == 1`` and ``0 ** negative == inf``
105
+ would both corrupt the seed prior, so a non-positive normalised base scores
106
+ a flat ``0.0`` regardless of exponent.
107
+ """
108
+ normalized = base / max_base if max_base > 0 else base
109
+ if normalized <= 0.0:
110
+ return 0.0
111
+ return float(normalized ** exponent)
112
+
113
+
114
+ def write_seed_prior_node_probs(
115
+ working_dir: str | Path,
116
+ intensity_image_path: str | Path,
117
+ cfg: TrackingConfig,
118
+ ) -> SeedPriorReport:
119
+ import sqlalchemy as sqla
120
+ from sqlalchemy.orm import Session
121
+ from ultrack.core.database import NodeDB, VarAnnotation
122
+
123
+ signal = _load_signal_stack(intensity_image_path)
124
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
125
+
126
+ with Session(engine) as session:
127
+ rows = session.query(
128
+ NodeDB.id,
129
+ NodeDB.t,
130
+ NodeDB.pickle,
131
+ NodeDB.node_annot,
132
+ ).all()
133
+
134
+ anchor_ids: list[int] = []
135
+ score_updates: list[dict[str, object]] = []
136
+ max_base = cfg.quality_weight + cfg.circularity_weight
137
+ for node_id, t, node, annot in rows:
138
+ if annot == VarAnnotation.REAL:
139
+ anchor_ids.append(int(node_id))
140
+ continue
141
+ bbox, mask = node_bbox_and_mask(int(node_id), node)
142
+ record = _NodeScoreRecord(
143
+ node_id=int(node_id),
144
+ t=int(t),
145
+ bbox=bbox,
146
+ mask=mask,
147
+ )
148
+ if record.t >= signal.shape[0]:
149
+ raise ValueError(
150
+ f"Quality signal image has {signal.shape[0]} frame(s), "
151
+ f"cannot score node at t={record.t}"
152
+ )
153
+ drop_frac = compute_drop_frac(signal[record.t], record.bbox, record.mask)
154
+ circularity = compute_mask_circularity(record.mask)
155
+ base = cfg.quality_weight * drop_frac + cfg.circularity_weight * circularity
156
+ node_prob = _seed_node_prob(base, max_base, cfg.quality_exponent)
157
+ # NodeDB has a composite primary key (id, t); bulk_update_mappings
158
+ # needs every PK column present in each row mapping.
159
+ score_updates.append(
160
+ {"id": record.node_id, "t": record.t, "node_prob": node_prob}
161
+ )
162
+
163
+ # One bulk UPDATE instead of a statement per node (was O(n) round-trips).
164
+ if score_updates:
165
+ session.bulk_update_mappings(NodeDB, score_updates)
166
+ if anchor_ids:
167
+ session.query(NodeDB).where(NodeDB.id.in_(anchor_ids)).update(
168
+ {NodeDB.node_prob: 1.0},
169
+ synchronize_session=False,
170
+ )
171
+ session.commit()
172
+ scored = len(score_updates)
173
+
174
+ engine.dispose()
175
+ return SeedPriorReport(scored=scored, seeds=len(anchor_ids))
@@ -0,0 +1,52 @@
1
+ """Thin wrapper around ultrack.core.solve.processing.solve."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from collections.abc import Generator
6
+
7
+ from itasc.tracking_ultrack.config import TrackingConfig
8
+ from itasc.tracking_ultrack.ingest import _build_ultrack_config
9
+
10
+
11
+ def database_has_annotations(working_dir: str | Path) -> bool:
12
+ """Return whether ``data.db`` contains REAL or FAKE node annotations."""
13
+ import sqlalchemy as sqla
14
+ from sqlalchemy.orm import Session
15
+ from ultrack.core.database import NodeDB, VarAnnotation
16
+
17
+ db_path = Path(working_dir) / "data.db"
18
+ if not db_path.exists():
19
+ return False
20
+
21
+ engine = sqla.create_engine(f"sqlite:///{db_path}")
22
+ try:
23
+ with Session(engine) as session:
24
+ return bool(
25
+ session.query(NodeDB.id)
26
+ .where(NodeDB.node_annot.in_([VarAnnotation.REAL, VarAnnotation.FAKE]))
27
+ .limit(1)
28
+ .first()
29
+ )
30
+ finally:
31
+ engine.dispose()
32
+
33
+
34
+ def run_solve(
35
+ working_dir: str | Path,
36
+ cfg: TrackingConfig,
37
+ *,
38
+ overwrite: bool = True,
39
+ use_annotations: bool | None = True,
40
+ ) -> Generator[tuple[int, int, str], None, None]:
41
+ """Run the ILP solver, yielding (step, total, label) progress tuples."""
42
+ from ultrack.core.solve.processing import solve
43
+
44
+ total = 2
45
+ wd = Path(working_dir)
46
+ ultrack_cfg = _build_ultrack_config(cfg, wd)
47
+ if use_annotations is None:
48
+ use_annotations = True
49
+
50
+ yield (0, total, "Running ILP solver…")
51
+ solve(ultrack_cfg, overwrite=overwrite, use_annotations=use_annotations)
52
+ yield (total, total, "Solve done.")
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from itasc.tracking_ultrack._node_geometry import (
9
+ intersection_area,
10
+ mask_bbox,
11
+ match_mask_to_node,
12
+ node_bbox_and_mask,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SwapCandidate:
18
+ node_id: int
19
+ mask_2d: np.ndarray
20
+ bbox: tuple[int, int, int, int]
21
+ centroid: tuple[float, float]
22
+ area: int
23
+
24
+
25
+ @dataclass
26
+ class _SwapCursor:
27
+ source_id: int
28
+ frame: int
29
+ candidates: tuple[SwapCandidate, ...]
30
+ index: int
31
+ baseline_frame: np.ndarray | None = None
32
+
33
+
34
+ def list_swap_candidates(
35
+ *,
36
+ db_path: Path,
37
+ frame: int,
38
+ source_mask: np.ndarray,
39
+ frame_shape: tuple[int, int],
40
+ protected_mask: np.ndarray | None = None,
41
+ ) -> list[SwapCandidate]:
42
+ """Return the matched node's nesting lineage around ``source_mask``.
43
+
44
+ Matches ``source_mask`` to the best-overlapping Ultrack node at ``frame``,
45
+ then walks the candidate **containment lattice** recorded in ``OverlapDB``:
46
+ every node that overlaps the matched node *and* is nested with it — a
47
+ superset (larger merged segment that contains it) or a subset (smaller
48
+ fragment it contains) — plus the matched node itself, returned sorted by
49
+ area. Partially-overlapping neighbours (siblings/cousins) are excluded: they
50
+ are different cells, so swapping onto them would relocate the cell rather
51
+ than resize it. ``Z``/``C`` cycle through this area-sorted lineage by index.
52
+
53
+ ``OverlapDB`` is populated by both database builders (atom-union enumeration
54
+ and canonical Ultrack segmentation), whereas ``hier_parent_id`` is only set
55
+ by the latter — so the overlap lattice is the portable source of structure.
56
+ """
57
+ if not Path(db_path).exists():
58
+ return []
59
+
60
+ if mask_bbox(source_mask) is None:
61
+ return []
62
+
63
+ import sqlalchemy as sqla
64
+ from sqlalchemy import or_
65
+ from sqlalchemy.orm import Session
66
+ from ultrack.core.database import NodeDB, OverlapDB
67
+
68
+ engine = sqla.create_engine(
69
+ f"sqlite:///{db_path}", connect_args={"check_same_thread": False}
70
+ )
71
+
72
+ try:
73
+ with Session(engine) as session:
74
+ # Match source mask to a node by max IoU (prefiltered by bbox so only
75
+ # plausibly-overlapping masks are deserialized).
76
+ matched = match_mask_to_node(session, frame, source_mask)
77
+ if matched is None:
78
+ return []
79
+ matched_id, matched_bbox, matched_mask = matched
80
+ matched_area = int(matched_mask.sum())
81
+
82
+ # Candidate set = the matched node plus everything that overlaps it
83
+ # (shares atoms) per OverlapDB. The pair direction is not meaningful
84
+ # here, so match the id on either column.
85
+ overlap_ids: set[int] = set()
86
+ for node_id, ancestor_id in session.query(
87
+ OverlapDB.node_id, OverlapDB.ancestor_id
88
+ ).filter(
89
+ or_(OverlapDB.node_id == matched_id, OverlapDB.ancestor_id == matched_id)
90
+ ):
91
+ overlap_ids.add(int(node_id))
92
+ overlap_ids.add(int(ancestor_id))
93
+ overlap_ids.add(matched_id)
94
+
95
+ node_rows = (
96
+ session.query(NodeDB)
97
+ .filter(NodeDB.t == frame, NodeDB.id.in_(overlap_ids))
98
+ .all()
99
+ )
100
+
101
+ results: list[SwapCandidate] = []
102
+ for node in node_rows:
103
+ try:
104
+ (y0, x0, y1, x1), mask_crop = node_bbox_and_mask(
105
+ int(node.id), node.pickle
106
+ )
107
+ except Exception:
108
+ continue
109
+ if mask_crop.shape != (y1 - y0, x1 - x0):
110
+ continue
111
+
112
+ # Keep only nodes nested with the matched node: one mask must be
113
+ # a subset of the other. Partially-overlapping neighbours (which
114
+ # share atoms but each hold unique ones) are different cells.
115
+ if int(node.id) != matched_id:
116
+ inter = intersection_area(
117
+ matched_bbox, matched_mask, (y0, x0, y1, x1), mask_crop
118
+ )
119
+ cand_area = int(mask_crop.sum())
120
+ nested = inter == matched_area or inter == cand_area
121
+ if not nested:
122
+ continue
123
+
124
+ full_mask = np.zeros(frame_shape, dtype=bool)
125
+ full_mask[y0:y1, x0:x1] = mask_crop
126
+ if not full_mask.any():
127
+ continue
128
+
129
+ if protected_mask is not None:
130
+ paintable = full_mask & ~protected_mask
131
+ if not paintable.any():
132
+ continue
133
+
134
+ area = int(full_mask.sum())
135
+ results.append(
136
+ SwapCandidate(
137
+ node_id=int(node.id),
138
+ mask_2d=full_mask,
139
+ bbox=(y0, x0, y1, x1),
140
+ centroid=(float(node.y), float(node.x)),
141
+ area=area,
142
+ )
143
+ )
144
+ finally:
145
+ engine.dispose()
146
+
147
+ results.sort(key=lambda c: c.area)
148
+ return results
149
+
150
+
151
+ def nearest_area_index(
152
+ candidates: tuple[SwapCandidate, ...] | list[SwapCandidate],
153
+ area: int,
154
+ ) -> int:
155
+ """Index of the candidate whose area is closest to ``area``.
156
+
157
+ Used to seed the cursor on the lineage member that best matches the
158
+ currently displayed (matched) cell, so the first ``Z``/``C`` step moves
159
+ relative to it.
160
+ """
161
+ return min(range(len(candidates)), key=lambda i: abs(candidates[i].area - area))
162
+
163
+
164
+ def cycle_index(count: int, current: int, *, larger: bool) -> int:
165
+ """Next index when cycling an area-sorted candidate list.
166
+
167
+ ``larger`` steps toward bigger areas (higher index), otherwise toward
168
+ smaller areas (lower index); both directions wrap around.
169
+ """
170
+ step = 1 if larger else -1
171
+ return (current + step) % count
@@ -0,0 +1,142 @@
1
+ """Per-track quality scoring derived from the solved Ultrack database.
2
+
3
+ Pure module (no Qt, no napari). The quality of an exported track is the sum of
4
+ its nodes' ``node_prob`` plus the sum of its selected edges' ``LinkDB.weight``;
5
+ higher is better. Ordering by this score lets the UI relabel so ID 1 is the
6
+ best track, 2 the next, and so on.
7
+
8
+ The score is keyed by the painted label value, which equals the exported
9
+ ``track_id`` from :func:`ultrack.core.export.to_tracks_layer` — the same
10
+ dataframe consumed in :mod:`itasc.tracking_ultrack.corrections`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ from itasc.tracking_ultrack.config import TrackingConfig
18
+ from itasc.tracking_ultrack.db_query import _engine, annotation_name
19
+
20
+ # NULL node_prob / link weight render as 1.0, matching db_query.py.
21
+ _NULL_VALUE = 1.0
22
+
23
+
24
+ def _resolve_prob(value: float | None) -> float:
25
+ if value is None:
26
+ return _NULL_VALUE
27
+ return float(value)
28
+
29
+
30
+ def _edge_weight(info: tuple[float | None, str] | None) -> float:
31
+ """Weight contributed by one selected edge.
32
+
33
+ ``None`` means no link row was found for the pair (contributes 0). A FAKE
34
+ link contributes 0. Otherwise a NULL weight renders as 1.0.
35
+ """
36
+ if info is None:
37
+ return 0.0
38
+ weight, annotation = info
39
+ if annotation == "FAKE":
40
+ return 0.0
41
+ return _NULL_VALUE if weight is None else float(weight)
42
+
43
+
44
+ def compute_track_scores(
45
+ tracks_df,
46
+ node_probs: Mapping[int, float | None],
47
+ links: Mapping[tuple[int, int], tuple[float | None, str]],
48
+ ) -> dict[int, float]:
49
+ """Pure scoring core: ``track_id -> Σ node_prob + Σ edge weight``.
50
+
51
+ ``tracks_df`` needs ``id``, ``t`` and ``track_id`` columns (one row per
52
+ node). ``node_probs`` maps node id -> raw ``node_prob`` (``None`` allowed).
53
+ ``links`` maps a (source_id, target_id) pair -> (raw weight, annotation
54
+ name); the pair is ordered earlier-frame -> later-frame, matching LinkDB.
55
+ """
56
+ scores: dict[int, float] = {}
57
+ for track_id, group in tracks_df.groupby("track_id"):
58
+ ordered = group.sort_values("t")
59
+ ids = [int(i) for i in ordered["id"].tolist()]
60
+ score = sum(_resolve_prob(node_probs.get(nid)) for nid in ids)
61
+ for src, tgt in zip(ids, ids[1:]):
62
+ score += _edge_weight(links.get((src, tgt)))
63
+ scores[int(track_id)] = score
64
+ return scores
65
+
66
+
67
+ def quality_order(scores: Mapping[int, float]) -> list[int]:
68
+ """track_ids sorted by score desc, then by id asc as a stable tiebreak."""
69
+ return [tid for tid, _ in sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))]
70
+
71
+
72
+ def _query_node_probs(db_path: Path, node_ids: list[int]) -> dict[int, float | None]:
73
+ from sqlalchemy.orm import Session
74
+ from ultrack.core.database import NodeDB
75
+
76
+ engine = _engine(db_path)
77
+ try:
78
+ with Session(engine) as session:
79
+ rows = (
80
+ session.query(NodeDB.id, NodeDB.node_prob)
81
+ .filter(NodeDB.id.in_(node_ids))
82
+ .all()
83
+ )
84
+ finally:
85
+ engine.dispose()
86
+ return {int(nid): (None if prob is None else float(prob)) for nid, prob in rows}
87
+
88
+
89
+ def _query_links(
90
+ db_path: Path, node_ids: list[int]
91
+ ) -> dict[tuple[int, int], tuple[float | None, str]]:
92
+ from sqlalchemy.orm import Session
93
+ from ultrack.core.database import LinkDB
94
+
95
+ wanted = set(node_ids)
96
+ engine = _engine(db_path)
97
+ try:
98
+ with Session(engine) as session:
99
+ rows = (
100
+ session.query(
101
+ LinkDB.source_id,
102
+ LinkDB.target_id,
103
+ LinkDB.weight,
104
+ LinkDB.annotation,
105
+ )
106
+ .filter(LinkDB.source_id.in_(node_ids))
107
+ .all()
108
+ )
109
+ finally:
110
+ engine.dispose()
111
+ links: dict[tuple[int, int], tuple[float | None, str]] = {}
112
+ for src, tgt, weight, annotation in rows:
113
+ if int(tgt) not in wanted:
114
+ continue
115
+ links[(int(src), int(tgt))] = (
116
+ None if weight is None else float(weight),
117
+ annotation_name(annotation),
118
+ )
119
+ return links
120
+
121
+
122
+ def track_quality_scores(db_path: str | Path, cfg: TrackingConfig) -> dict[int, float]:
123
+ """Map exported ``track_id`` -> quality score (Σ node_prob + Σ edge weight).
124
+
125
+ Read-only over the solved database. Returns ``{}`` when the export yields no
126
+ tracks (e.g. an unsolved or empty database).
127
+ """
128
+ from itasc.tracking_ultrack.ingest import _build_ultrack_config
129
+
130
+ db_path = Path(db_path)
131
+ ultrack_cfg = _build_ultrack_config(cfg, db_path.parent)
132
+
133
+ from ultrack.core.export import to_tracks_layer
134
+
135
+ tracks_df, _graph = to_tracks_layer(ultrack_cfg)
136
+ if tracks_df is None or tracks_df.empty or "id" not in tracks_df:
137
+ return {}
138
+
139
+ node_ids = [int(i) for i in tracks_df["id"].tolist()]
140
+ node_probs = _query_node_probs(db_path, node_ids)
141
+ links = _query_links(db_path, node_ids)
142
+ return compute_track_scores(tracks_df, node_probs, links)