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