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,114 @@
1
+ """Shared napari track-rendering helpers.
2
+
3
+ Pure numpy / napari colormap maths reused by both the contact-analysis
4
+ visualization (``itasc-aggregate``) and the nucleus-correction track overview
5
+ (``itasc-tracking``). It lives in ``itasc-core`` so neither piece has to
6
+ depend on the other.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ _UNLABELED_COLOR = np.array([0.7, 0.7, 0.7, 1.0], dtype=float)
15
+
16
+
17
+ def _track_label_color_styling(
18
+ track_ids: np.ndarray,
19
+ color_map: dict[int | None, tuple[float, float, float, float] | str],
20
+ ) -> tuple[Any, np.ndarray] | None:
21
+ """Colour each track exactly like its cell label.
22
+
23
+ A ``Tracks`` layer colours by mapping a property through a colormap. When a
24
+ custom colormap is supplied via ``colormaps_dict`` napari feeds it the *raw*
25
+ property values (no normalisation), so we build a per-vertex ``label_pos``
26
+ property in ``[0, 1]`` plus a step (``interpolation="zero"``) colormap whose
27
+ bins reproduce ``color_map`` for each track. Returns ``(colormap, label_pos)``
28
+ or ``None`` when there are no tracks / napari is unavailable.
29
+ """
30
+ ids = sorted({int(i) for i in np.asarray(track_ids).tolist()})
31
+ if not ids:
32
+ return None
33
+ try:
34
+ from napari.utils.colormaps import Colormap
35
+ except Exception: # pragma: no cover - napari compatibility
36
+ return None
37
+
38
+ def _color(cell_id: int) -> tuple[float, float, float, float]:
39
+ raw = color_map.get(int(cell_id))
40
+ # ``color_map`` may come straight from a caller (tuple/list values) or be
41
+ # read back off a napari ``DirectLabelColormap``, which normalises every
42
+ # entry to an ``np.ndarray``. Accept any length-4 numeric sequence so the
43
+ # latter doesn't silently fall through to the grey unlabeled colour.
44
+ if isinstance(raw, (tuple, list, np.ndarray)) and len(raw) == 4:
45
+ return tuple(float(c) for c in raw)
46
+ return tuple(float(c) for c in _UNLABELED_COLOR)
47
+
48
+ colors = [_color(i) for i in ids]
49
+ if len(ids) == 1:
50
+ cmap = Colormap([colors[0], colors[0]], controls=[0.0, 1.0])
51
+ pos_by_id = {ids[0]: 0.0}
52
+ else:
53
+ vals = [k / (len(ids) - 1) for k in range(len(ids))]
54
+ pos_by_id = {ids[k]: vals[k] for k in range(len(ids))}
55
+ edges = (
56
+ [0.0]
57
+ + [(vals[k - 1] + vals[k]) / 2 for k in range(1, len(ids))]
58
+ + [1.0]
59
+ )
60
+ cmap = Colormap(colors, controls=edges, interpolation="zero")
61
+ label_pos = np.array(
62
+ [pos_by_id[int(i)] for i in np.asarray(track_ids).tolist()], dtype=float
63
+ )
64
+ return cmap, label_pos
65
+
66
+
67
+ def _nucleus_centroids_by_track(
68
+ nucleus_labels: np.ndarray,
69
+ ) -> dict[int, list[tuple[int, float, float]]]:
70
+ labels = np.asarray(nucleus_labels)
71
+ if labels.ndim == 2:
72
+ labels = labels[np.newaxis, ...]
73
+ if labels.ndim > 3:
74
+ labels = np.squeeze(labels)
75
+ if labels.ndim != 3:
76
+ raise ValueError(
77
+ f"Expected time-first 2D/3D nucleus labels, got shape {nucleus_labels.shape}"
78
+ )
79
+
80
+ centroids: dict[int, list[tuple[int, float, float]]] = {}
81
+ for frame_idx in range(labels.shape[0]):
82
+ frame = labels[frame_idx]
83
+ flat = frame.ravel()
84
+ order = np.argsort(flat, kind="stable")
85
+ sorted_ids = flat[order]
86
+
87
+ change = np.empty(len(sorted_ids), dtype=bool)
88
+ change[0] = True
89
+ np.not_equal(sorted_ids[1:], sorted_ids[:-1], out=change[1:])
90
+ boundaries = np.flatnonzero(change)
91
+
92
+ rows_all, cols_all = np.divmod(order, frame.shape[1])
93
+
94
+ ends = np.empty_like(boundaries)
95
+ ends[:-1] = boundaries[1:]
96
+ ends[-1] = len(sorted_ids)
97
+
98
+ for bi in range(len(boundaries)):
99
+ cell_id = int(sorted_ids[boundaries[bi]])
100
+ if cell_id == 0:
101
+ continue
102
+ s, e = int(boundaries[bi]), int(ends[bi])
103
+ y = float(rows_all[s:e].mean())
104
+ x = float(cols_all[s:e].mean())
105
+ centroids.setdefault(cell_id, []).append((frame_idx, y, x))
106
+
107
+ return centroids
108
+
109
+
110
+ __all__ = [
111
+ "_UNLABELED_COLOR",
112
+ "_nucleus_centroids_by_track",
113
+ "_track_label_color_styling",
114
+ ]
@@ -0,0 +1,236 @@
1
+ """Small Qt widget factory helpers shared across napari workflow widgets."""
2
+ from __future__ import annotations
3
+
4
+ from qtpy.QtCore import QEvent, QObject, QSize, Qt
5
+ from qtpy.QtWidgets import (
6
+ QDoubleSpinBox,
7
+ QHBoxLayout,
8
+ QLabel,
9
+ QProgressBar,
10
+ QPushButton,
11
+ QSizePolicy,
12
+ QStyle,
13
+ QStyleOption,
14
+ QToolButton,
15
+ QVBoxLayout,
16
+ QWidget,
17
+ )
18
+ from superqt import (
19
+ QLabeledDoubleSlider,
20
+ QLabeledSlider,
21
+ )
22
+
23
+ from itasc.napari.ui_style import action_button, parameter_heading, status_label
24
+
25
+
26
+ def heading(text: str) -> QLabel:
27
+ lbl = QLabel(text)
28
+ return parameter_heading(lbl)
29
+
30
+
31
+ def make_status() -> QLabel:
32
+ lbl = QLabel("")
33
+ lbl.setWordWrap(True)
34
+ lbl.setVisible(False)
35
+ status_label(lbl)
36
+ return lbl
37
+
38
+
39
+ def make_progress() -> QProgressBar:
40
+ bar = QProgressBar()
41
+ bar.setRange(0, 100)
42
+ bar.setValue(0)
43
+ bar.setTextVisible(True)
44
+ bar.setVisible(False)
45
+ return bar
46
+
47
+
48
+ def dspin(lo, hi, val, step=0.1, decimals=2, tooltip=""):
49
+ s = QDoubleSpinBox()
50
+ s.setRange(lo, hi); s.setValue(val); s.setSingleStep(step)
51
+ s.setDecimals(decimals); s.setToolTip(tooltip)
52
+ return s
53
+
54
+
55
+ def btn(text, tooltip=""):
56
+ b = QPushButton(text)
57
+ b.setToolTip(tooltip)
58
+ action_button(b, expand=True)
59
+ return b
60
+
61
+
62
+ def _patch_label_autosize(label) -> None:
63
+ """Override the superqt SliderLabel's internal size calculation so it
64
+ fits the widest value it can display, formatted with the configured
65
+ decimals.
66
+
67
+ superqt's stock ``_get_size`` widths labels from ``str(minimum())`` /
68
+ ``str(maximum())`` — which drops trailing zeros (``str(1.0) == "1.0"``)
69
+ and ignores decimals, so e.g. a (-10, 10) range with 1 decimal sizes
70
+ the label for ``"-10.0"`` but then ``str(-10.0) == "-10.0"``... fine
71
+ in that case, while a (0, 1) range with 2 decimals sizes for ``"1.0"``
72
+ (3 chars) and clips the displayed ``"1.00"``. Style padding/font
73
+ tweaks compound this. We replace ``_get_size`` on the instance so
74
+ that every subsequent ``_update_size`` (rangeChanged, showEvent…)
75
+ re-derives the size from the actual format width plus headroom for
76
+ chrome and a possible minus sign.
77
+ """
78
+ def _get_size():
79
+ dec = label.decimals() if hasattr(label, "decimals") else 0
80
+
81
+ def _fmt(v):
82
+ return f"{v:.{dec}f}" if dec else f"{int(v)}"
83
+ lo, hi = label.minimum(), label.maximum()
84
+ sample = max((_fmt(lo), _fmt(hi)), key=len)
85
+ # ensure room for a minus sign even if both ends are non-negative
86
+ # (the user may type one into the editable label).
87
+ if not sample.startswith("-"):
88
+ sample = "-" + sample
89
+ fm = label.fontMetrics()
90
+ prefix = label.prefix() or ""
91
+ suffix = label.suffix() or ""
92
+ w = fm.horizontalAdvance(prefix + sample + suffix) + 18
93
+ h = label.sizeHint().height()
94
+ opt = QStyleOption()
95
+ return label.style().sizeFromContents(
96
+ QStyle.ContentsType.CT_LineEdit, opt, QSize(w, h), label
97
+ )
98
+
99
+ label._get_size = _get_size
100
+ label._update_size()
101
+
102
+
103
+ def _slider_step_button(text: str, object_name: str, tooltip: str) -> QToolButton:
104
+ button = QToolButton()
105
+ button.setText(text)
106
+ button.setObjectName(object_name)
107
+ button.setToolTip(tooltip)
108
+ button.setAutoRepeat(True)
109
+ button.setFixedSize(18, 18)
110
+ button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
111
+ return button
112
+
113
+
114
+ class _SliderStepButtonStateSyncer(QObject):
115
+ def __init__(self, sync_button_state) -> None:
116
+ super().__init__()
117
+ self._sync_button_state = sync_button_state
118
+
119
+ def eventFilter(self, watched, event) -> bool:
120
+ if event.type() == QEvent.Type.EnabledChange:
121
+ self._sync_button_state()
122
+ return False
123
+
124
+
125
+ def _connect_slider_step_buttons(slider) -> tuple[QToolButton, QToolButton]:
126
+ decrement = _slider_step_button(
127
+ "-", "slider_decrement_button", "Decrease by one step"
128
+ )
129
+ increment = _slider_step_button(
130
+ "+", "slider_increment_button", "Increase by one step"
131
+ )
132
+
133
+ def _set_stepped_value(direction: int) -> None:
134
+ if not slider.isEnabled():
135
+ return
136
+ slider.setValue(slider.value() + direction * slider.singleStep())
137
+
138
+ def _sync_button_state(*_args) -> None:
139
+ enabled = slider.isEnabled()
140
+ decrement.setEnabled(enabled and slider.value() > slider.minimum())
141
+ increment.setEnabled(enabled and slider.value() < slider.maximum())
142
+
143
+ decrement.clicked.connect(lambda: _set_stepped_value(-1))
144
+ increment.clicked.connect(lambda: _set_stepped_value(1))
145
+ slider.valueChanged.connect(_sync_button_state)
146
+ slider.rangeChanged.connect(_sync_button_state)
147
+ state_syncer = _SliderStepButtonStateSyncer(_sync_button_state)
148
+ state_syncer.setParent(slider)
149
+ slider.installEventFilter(state_syncer)
150
+ slider._itasc_slider_step_button_state_syncer = state_syncer
151
+ _sync_button_state()
152
+ return decrement, increment
153
+
154
+
155
+ def add_slider_step_buttons(
156
+ layout: QHBoxLayout, slider, track: QWidget | None = None
157
+ ) -> tuple[QToolButton, QToolButton]:
158
+ track = slider if track is None else track
159
+ decrement, increment = _connect_slider_step_buttons(slider)
160
+ layout.addWidget(decrement, alignment=Qt.AlignmentFlag.AlignVCenter)
161
+ layout.addWidget(track)
162
+ layout.addWidget(increment, alignment=Qt.AlignmentFlag.AlignVCenter)
163
+ return decrement, increment
164
+
165
+
166
+ def _stack_slider_label_above(slider, *, step_buttons: bool = False) -> None:
167
+ """Repack a QLabeledSlider / QLabeledDoubleSlider so the editable value
168
+ label sits centered above the slider track instead of beside it."""
169
+ label = slider._label
170
+ track = slider._slider
171
+ label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
172
+ label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
173
+ _patch_label_autosize(label)
174
+
175
+ old_layout = slider.layout()
176
+ if old_layout is not None:
177
+ old_layout.removeWidget(label)
178
+ old_layout.removeWidget(track)
179
+ QWidget().setLayout(old_layout)
180
+ label.setParent(slider)
181
+ track.setParent(slider)
182
+ vbox = QVBoxLayout()
183
+ vbox.setContentsMargins(0, 0, 0, 0)
184
+ vbox.setSpacing(0)
185
+ vbox.addWidget(label, alignment=Qt.AlignmentFlag.AlignHCenter)
186
+ if step_buttons:
187
+ row = QHBoxLayout()
188
+ row.setContentsMargins(0, 0, 0, 0)
189
+ row.setSpacing(2)
190
+ add_slider_step_buttons(row, slider, track)
191
+ vbox.addLayout(row)
192
+ else:
193
+ vbox.addWidget(track)
194
+ slider.setLayout(vbox)
195
+
196
+
197
+ def dslider(lo, hi, val, step=0.1, decimals=2, tooltip="", *, step_buttons=True):
198
+ """A horizontal QLabeledDoubleSlider — same call signature as `dspin`.
199
+
200
+ The editable value label sits above the slider track for a compact,
201
+ wide-track look."""
202
+ s = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
203
+ s.setRange(lo, hi)
204
+ s.setValue(val)
205
+ s.setSingleStep(step)
206
+ s.setDecimals(decimals)
207
+ s.setToolTip(tooltip)
208
+ s.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
209
+ s.setProperty("itasc_stack_section_label", True)
210
+ _stack_slider_label_above(s, step_buttons=step_buttons)
211
+ return s
212
+
213
+
214
+ def islider(lo, hi, val, step=1, tooltip="", *, step_buttons=True):
215
+ """A horizontal QLabeledSlider — same call signature as `ispin`.
216
+
217
+ The editable value label sits above the slider track."""
218
+ s = QLabeledSlider(Qt.Orientation.Horizontal)
219
+ s.setRange(lo, hi)
220
+ s.setValue(val)
221
+ s.setSingleStep(step)
222
+ s.setToolTip(tooltip)
223
+ s.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
224
+ s.setProperty("itasc_stack_section_label", True)
225
+ _stack_slider_label_above(s, step_buttons=step_buttons)
226
+ return s
227
+
228
+
229
+ def tool_btn(glyph: str, tooltip: str = "", *, checkable: bool = False) -> QToolButton:
230
+ """Compact icon-only QToolButton carrying a unicode glyph and a tooltip."""
231
+ b = QToolButton()
232
+ b.setText(glyph)
233
+ b.setToolTip(tooltip)
234
+ b.setCheckable(checkable)
235
+ b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
236
+ return b
@@ -0,0 +1,339 @@
1
+ """Aggregate capstone: pool every processed position into project-level tables.
2
+
3
+ The main app's project-level bookend to the per-position sections. Reads the same
4
+ catalog records the ``ExperimentsPanel`` builds, and drives the headless engine
5
+ (``author_config`` then ``pipeline.run``). Pool-only: it aggregates positions
6
+ whose ``contact_analysis.h5`` already exists and never builds missing ones, so ``run`` is
7
+ load-and-pool with no per-position recompute. Plotting is a downstream concern.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import tifffile
14
+ from napari.qt.threading import thread_worker
15
+ from napari.utils.notifications import show_error, show_info
16
+ from qtpy.QtWidgets import (
17
+ QCheckBox,
18
+ QDoubleSpinBox,
19
+ QHBoxLayout,
20
+ QLabel,
21
+ QListWidget,
22
+ QProgressBar,
23
+ QPushButton,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+
28
+ from itasc.contact_analysis import author_config, run
29
+ from itasc.contact_analysis.quantifier import available_quantifiers
30
+ from itasc.contact_analysis.records import supported_quantities
31
+ from itasc.contact_analysis.shape_tables import catalogue_root
32
+
33
+
34
+ def _lateral_pixels(path) -> int | None:
35
+ """The ``Y * X`` pixel count of a label image's lateral field, or ``None``.
36
+
37
+ Reads only the TIFF header (not the pixel data), so it is cheap to call on every
38
+ records refresh. Returns ``None`` when *path* is missing, unreadable, or not at
39
+ least 2-D. The field-of-view autofill multiplies this by ``pixel_size_um²``.
40
+ """
41
+ if not path:
42
+ return None
43
+ p = Path(path)
44
+ if not p.is_file():
45
+ return None
46
+ try:
47
+ with tifffile.TiffFile(p) as tif:
48
+ shape = tif.series[0].shape
49
+ except Exception:
50
+ return None
51
+ if len(shape) < 2:
52
+ return None
53
+ return int(shape[-2]) * int(shape[-1])
54
+
55
+
56
+ def pooled_quantifiers():
57
+ """The registered quantifiers that pool into a project table, in display order.
58
+
59
+ Only quantifiers declaring ``table_keys`` produce an aggregated CSV; producers
60
+ (e.g. ``contacts``) are excluded. :func:`available_quantifiers` already returns
61
+ them sorted by ``display_name``, which is the order the checkbox list shows.
62
+ """
63
+ return [cls for cls in available_quantifiers() if cls.table_keys]
64
+
65
+
66
+ def partition_ready(records):
67
+ """Split catalog *records* into ``(ready, not_ready)`` by ``contact_analysis.h5``.
68
+
69
+ A record is *ready* when its ``contact_analysis_path`` exists on disk.
70
+ """
71
+ ready, not_ready = [], []
72
+ for rec in records:
73
+ path = rec.get("contact_analysis_path")
74
+ if path is not None and Path(path).exists():
75
+ ready.append(rec)
76
+ else:
77
+ not_ready.append(rec)
78
+ return ready, not_ready
79
+
80
+
81
+ def _position_name(record) -> str:
82
+ path = record.get("position_path")
83
+ return Path(path).name if path else "(unknown)"
84
+
85
+
86
+ def pool_positions(ready_records, skipped_names, quantities=(), params=None):
87
+ """Author the project artifacts for *ready_records* and run the engine.
88
+
89
+ Writes ``catalog.csv`` + ``config.toml`` into the ready positions' common
90
+ ancestor (:func:`catalogue_root`), then ``run``s the pipeline over them.
91
+ *quantities* is the checked subset of pooled quantities to write (empty = every
92
+ available table); it is authored into the config and restricts which tables the
93
+ pool-only run emits. *params* are the shared build knobs (pixel size, frame
94
+ length, FOV area) authored into the config's ``[params]`` so the pooled cheap
95
+ quantities compute in physical units. Returns a result dict for the UI: the
96
+ ``name -> path`` table map, the ``skipped`` position names, and the
97
+ ``project_dir`` the tables landed under.
98
+ """
99
+ project_dir = catalogue_root(ready_records)
100
+ config_path = author_config(
101
+ project_dir, ready_records, quantities=tuple(quantities), params=params or None
102
+ )
103
+ # Pool-only: read each position's existing contact_analysis.h5 and pool it (plus the
104
+ # in-memory cheap quantities). build=False skips the producer's unconditional
105
+ # rebuild, so ready positions are loaded, never recomputed.
106
+ tables = run(config_path, build=False)
107
+ return {
108
+ "tables": tables,
109
+ "skipped": list(skipped_names),
110
+ "project_dir": project_dir,
111
+ }
112
+
113
+
114
+ class AggregateWidget(QWidget):
115
+ """Project-level capstone: pool every ready position into tidy tables.
116
+
117
+ Fed catalog records via :meth:`set_records` (the same records the app's
118
+ ``ExperimentsPanel`` builds). Pool-only: Run aggregates positions whose
119
+ ``contact_analysis.h5`` exists and reports the ones it skipped by name.
120
+ """
121
+
122
+ def __init__(self, parent: QWidget | None = None) -> None:
123
+ super().__init__(parent)
124
+ self._records: list[dict] = []
125
+ self._worker = None
126
+ self._has_run = False
127
+ self._fov_user_edited = False
128
+
129
+ layout = QVBoxLayout(self)
130
+ self.subtitle = QLabel(
131
+ "Pools every processed position into project-level tables."
132
+ )
133
+ self.subtitle.setWordWrap(True)
134
+ self.readout = QLabel("No data folders yet.")
135
+ self.readout.setWordWrap(True)
136
+ self.quantities_label = QLabel("Quantities to pool:")
137
+ # One checkbox per pooled quantity, all on by default. A box is greyed out
138
+ # (disabled + unchecked) when no ready position carries the inputs/params it
139
+ # needs, so the list also reads as "what this stage computes", and an
140
+ # enabled box never promises a table the run would skip. See
141
+ # :func:`~itasc.contact_analysis.records.supported_quantities`.
142
+ self._checks: dict[str, QCheckBox] = {}
143
+ for cls in pooled_quantifiers():
144
+ box = QCheckBox(cls.display_name)
145
+ box.setChecked(True)
146
+ box.setToolTip(cls.quantity_id)
147
+ self._checks[cls.quantity_id] = box
148
+ # Cell density's field-of-view area is a build param with no PositionInputs
149
+ # field, so it gets its own input beside the density checkbox. Autofilled
150
+ # from image size x pixel size (see :meth:`_maybe_autofill_fov`); the user
151
+ # can override, after which autofill leaves it alone.
152
+ self.fov_field = QDoubleSpinBox()
153
+ # Entered in µm² (a microscope field is a handful of ×10⁴ µm², which reads
154
+ # cleanly, where the mm² value would be a long decimal). Converted to the
155
+ # backend's mm² param in :meth:`_current_params`. 2 decimals is 0.01 µm²
156
+ # resolution — ample, and no risk of rounding a real area down to zero.
157
+ self.fov_field.setDecimals(2)
158
+ self.fov_field.setRange(0.0, 1e12)
159
+ self.fov_field.setSuffix(" µm²")
160
+ self.fov_field.setToolTip(
161
+ "Field-of-view area for Cell density. Autofilled from image size times "
162
+ "pixel size; edit to override."
163
+ )
164
+ self.fov_field.valueChanged.connect(self._on_fov_changed)
165
+ self.run_btn = QPushButton("Pool ready positions")
166
+ self.run_btn.clicked.connect(self._on_run)
167
+ self.progress = QProgressBar()
168
+ self.progress.setVisible(False)
169
+ self.results = QListWidget()
170
+ self.status = QLabel("")
171
+ self.status.setWordWrap(True)
172
+ for widget in (self.subtitle, self.readout, self.quantities_label):
173
+ layout.addWidget(widget)
174
+ for qid, box in self._checks.items():
175
+ if qid == "cell_density":
176
+ row = QHBoxLayout()
177
+ row.addWidget(box)
178
+ row.addWidget(QLabel("FOV area (µm²):"))
179
+ row.addWidget(self.fov_field)
180
+ row.addStretch()
181
+ layout.addLayout(row)
182
+ else:
183
+ layout.addWidget(box)
184
+ for widget in (self.run_btn, self.progress, self.results, self.status):
185
+ layout.addWidget(widget)
186
+ self._refresh_readout()
187
+
188
+ # ------------------------------------------------------------------ inputs
189
+ def set_records(self, records) -> None:
190
+ """Replace the catalog records the readiness readout reflects."""
191
+ self._records = list(records or [])
192
+ self._maybe_autofill_fov()
193
+ self._refresh_readout()
194
+
195
+ def _current_params(self) -> dict:
196
+ """The shared build knobs for greying + the run: calibration off the records
197
+ (pixel size, frame length are global, so the first record is representative)
198
+ plus the field-of-view area from :attr:`fov_field` (omitted when zero)."""
199
+ params: dict[str, float] = {}
200
+ if self._records:
201
+ first = self._records[0]
202
+ for key in ("pixel_size_um", "time_interval_s"):
203
+ value = first.get(key)
204
+ if value in (None, ""):
205
+ continue
206
+ try:
207
+ params[key] = float(value)
208
+ except (TypeError, ValueError):
209
+ pass
210
+ fov_um2 = self.fov_field.value()
211
+ if fov_um2 > 0:
212
+ # The field is µm²; the backend's Cell density param is mm² (density is
213
+ # reported in cells/mm²).
214
+ params["fov_area_mm2"] = fov_um2 / 1e6
215
+ return params
216
+
217
+ def _maybe_autofill_fov(self) -> None:
218
+ """Prefill the FOV field (µm²) from ``image lateral pixels x pixel_size_um²``,
219
+ reading a ready position's cell-label image. No-ops once the user has edited
220
+ the field, when no pixel size is set, or when no image is readable."""
221
+ if self._fov_user_edited:
222
+ return
223
+ pixel_size = self._current_params().get("pixel_size_um")
224
+ if not pixel_size:
225
+ return
226
+ ready, _ = partition_ready(self._records)
227
+ for record in ready:
228
+ n_pixels = _lateral_pixels(record.get("cell_tracked_labels_path"))
229
+ if n_pixels:
230
+ fov_um2 = n_pixels * pixel_size * pixel_size
231
+ self.fov_field.blockSignals(True)
232
+ self.fov_field.setValue(fov_um2)
233
+ self.fov_field.blockSignals(False)
234
+ return
235
+
236
+ def _on_fov_changed(self, _value) -> None:
237
+ """A user edit to the FOV field: remember it (so autofill backs off) and
238
+ re-grey, since a non-zero area lifts Cell density into support."""
239
+ self._fov_user_edited = True
240
+ self._refresh_quantities(partition_ready(self._records)[0])
241
+
242
+ def section_status(self) -> str:
243
+ """Status for the enclosing section dot: not_started / in_progress / done."""
244
+ ready, _ = partition_ready(self._records)
245
+ if not ready:
246
+ return "not_started"
247
+ return "done" if self._has_run else "in_progress"
248
+
249
+ # --------------------------------------------------------------- rendering
250
+ def _refresh_readout(self) -> None:
251
+ ready, not_ready = partition_ready(self._records)
252
+ total = len(self._records)
253
+ if total == 0:
254
+ self.readout.setText("No data folders yet.")
255
+ else:
256
+ message = f"{len(ready)} of {total} positions analyzed"
257
+ if not_ready:
258
+ names = ", ".join(_position_name(r) for r in not_ready)
259
+ message += f" — not yet ready: {names}"
260
+ self.readout.setText(message)
261
+ self._refresh_quantities(ready)
262
+ self.run_btn.setEnabled(bool(ready) and self._worker is None)
263
+
264
+ def _refresh_quantities(self, ready) -> None:
265
+ """Enable a quantity's checkbox iff a ready position can produce its table.
266
+
267
+ Greys out (disables + unchecks) any quantity whose required inputs/params
268
+ are absent from every ready position; re-enables and re-checks one that
269
+ becomes supported again. A deliberate uncheck of a *supported* quantity is
270
+ preserved: only the supported/unsupported transition touches the check.
271
+ """
272
+ supported = supported_quantities(ready, params=self._current_params() or None)
273
+ for qid, box in self._checks.items():
274
+ was_enabled = box.isEnabled()
275
+ now = qid in supported
276
+ box.setEnabled(now)
277
+ box.setToolTip(
278
+ qid
279
+ if now
280
+ else f"{qid}: required inputs or parameters not available for any ready position"
281
+ )
282
+ if not now:
283
+ box.setChecked(False)
284
+ elif not was_enabled:
285
+ box.setChecked(True)
286
+
287
+ def _selected_quantities(self) -> tuple[str, ...]:
288
+ """The checked quantities to author into the config.
289
+
290
+ Collapses to ``()`` (= write every available table) when the user has left
291
+ every *supported* quantity checked, so a default run writes a clean config;
292
+ any deliberate uncheck yields the explicit checked subset instead.
293
+ """
294
+ checked = {qid for qid, box in self._checks.items() if box.isChecked()}
295
+ supported = {qid for qid, box in self._checks.items() if box.isEnabled()}
296
+ return () if checked == supported else tuple(sorted(checked))
297
+
298
+ # --------------------------------------------------------------------- run
299
+ def _on_run(self) -> None:
300
+ ready, not_ready = partition_ready(self._records)
301
+ if not ready:
302
+ show_info("No analyzed positions to pool.")
303
+ return
304
+ skipped = [_position_name(r) for r in not_ready]
305
+ quantities = self._selected_quantities()
306
+ params = self._current_params()
307
+ self.results.clear()
308
+ self.status.setText("Pooling…")
309
+ self.progress.setVisible(True)
310
+ self.progress.setRange(0, 0)
311
+ self.run_btn.setEnabled(False)
312
+
313
+ @thread_worker(
314
+ connect={"returned": self._on_done, "errored": self._on_error}
315
+ )
316
+ def _work():
317
+ return pool_positions(ready, skipped, quantities, params)
318
+
319
+ self._worker = _work()
320
+
321
+ def _on_done(self, result: dict) -> None:
322
+ self._worker = None
323
+ self._has_run = True
324
+ self.progress.setVisible(False)
325
+ for name, path in sorted(result["tables"].items()):
326
+ self.results.addItem(f"{name}: {path}")
327
+ message = f"Pooled into {result['project_dir']}."
328
+ if result["skipped"]:
329
+ message += f" Skipped (not analyzed): {', '.join(result['skipped'])}."
330
+ self.status.setText(message)
331
+ show_info(message)
332
+ self._refresh_readout()
333
+
334
+ def _on_error(self, exc: Exception) -> None:
335
+ self._worker = None
336
+ self.progress.setVisible(False)
337
+ self.status.setText(f"Aggregate failed: {exc}")
338
+ show_error(f"Aggregate failed: {exc}")
339
+ self._refresh_readout()