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,8 @@
1
+ """Interactive label-correction subsystem for the ITASC napari plugin.
2
+
3
+ Groups the correction widgets (:mod:`correction_widget`,
4
+ :mod:`nucleus_correction_widget`, :mod:`cell_correction_widget`) and their
5
+ ``_correction_*`` helper modules. Importers use the fully-qualified
6
+ ``itasc.napari.correction.<module>`` path; this package intentionally
7
+ re-exports nothing so the module boundaries stay explicit.
8
+ """
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from itasc.tracking_ultrack.corrections import Correction
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AnchorRemovalResult:
11
+ remaining: list[Correction]
12
+ removed: bool
13
+
14
+
15
+ def without_anchor_correction(
16
+ corrections: Iterable[Correction],
17
+ *,
18
+ cell_id: int,
19
+ frame: int,
20
+ ) -> AnchorRemovalResult:
21
+ """Return corrections with the matching anchor removed, if present."""
22
+ selected_cell_id = int(cell_id)
23
+ selected_frame = int(frame)
24
+ original = list(corrections)
25
+ remaining = [
26
+ correction
27
+ for correction in original
28
+ if not (
29
+ int(correction.cell_id) == selected_cell_id
30
+ and int(correction.t) == selected_frame
31
+ and correction.kind == "anchor"
32
+ )
33
+ ]
34
+ return AnchorRemovalResult(
35
+ remaining=remaining,
36
+ removed=len(remaining) != len(original),
37
+ )
38
+
39
+
40
+ def anchor_correction(
41
+ *,
42
+ cell_id: int,
43
+ frame: int,
44
+ y: float,
45
+ x: float,
46
+ ) -> Correction:
47
+ """Build an anchor correction for the selected cell and frame."""
48
+ return Correction(
49
+ cell_id=int(cell_id),
50
+ t=int(frame),
51
+ kind="anchor",
52
+ y=float(y),
53
+ x=float(x),
54
+ )
@@ -0,0 +1,259 @@
1
+ """Qt view for the extend / swap candidate galleries.
2
+
3
+ Three :class:`CandidateColumn` blocks are stacked top-to-bottom — extend-backward
4
+ · swap · extend-forward — each a titled block whose clickable thumbnails (built
5
+ from a :class:`~itasc.napari.correction._correction_candidates.CandidateStrip`) flow
6
+ left→right and wrap onto new rows. The whole strip scrolls as one. Clicking a
7
+ thumbnail emits ``candidate_activated(which, key)`` so the controller can apply
8
+ that extend/swap; the pixels themselves come from the pure builder, so this stays
9
+ a thin view. ``rgb_to_qimage`` is the only import-safe symbol without a running
10
+ QApplication.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from qtpy.QtCore import Qt, Signal
15
+ from qtpy.QtGui import QPixmap
16
+ from qtpy.QtWidgets import (
17
+ QFrame,
18
+ QLabel,
19
+ QScrollArea,
20
+ QSplitter,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from itasc.napari.correction._correction_candidates import CandidateStrip, CandidateTile
26
+ from itasc.napari.correction._correction_track_accordion import rgb_to_qimage
27
+ from itasc.napari._flow_layout import FlowLayout
28
+
29
+ _TILE_PX = 64
30
+ _TILE_PX_MIN = 20
31
+ _TILE_PX_MAX = 256
32
+ _ZOOM_STEP = 8 # px added/removed from the tile size per Ctrl+wheel notch
33
+
34
+ # Draggable handle between the stacked regions. Mirrors the workspace splitter
35
+ # that sizes the strip widths (see nucleus_correction_widget), but oriented
36
+ # vertically: a mid-grey grip inset against the dark dock background. Qt adds
37
+ # the border *on top of* the handle width for vertical handles (it insets it for
38
+ # horizontal ones), so to land the same ~10 px total thickness as the workspace
39
+ # grabbers we pair a 3 px border with a thin handle width (see _HANDLE_WIDTH).
40
+ _HANDLE_STYLE = (
41
+ "QSplitter::handle:vertical {"
42
+ " background: #5a606b;"
43
+ " border-top: 3px solid #2e3440;"
44
+ " border-bottom: 3px solid #2e3440;"
45
+ " }"
46
+ "QSplitter::handle:vertical:hover { background: #7a828f; }"
47
+ )
48
+ _HANDLE_WIDTH = 2
49
+
50
+
51
+ class _ClickableTile(QFrame):
52
+ """One candidate thumbnail + caption that reports its key when clicked."""
53
+
54
+ clicked = Signal(int)
55
+
56
+ def __init__(
57
+ self, tile: CandidateTile, *, tile_px: int, parent: QWidget | None = None
58
+ ) -> None:
59
+ super().__init__(parent)
60
+ self._key = int(tile.key)
61
+ self.setObjectName("candidateTile")
62
+ self.setCursor(Qt.PointingHandCursor)
63
+ self.setToolTip(f"{tile.caption} — click to apply")
64
+ self.setStyleSheet(
65
+ "QFrame#candidateTile { border: 1px solid transparent; }"
66
+ "QFrame#candidateTile:hover { border: 1px solid #ffd246; }"
67
+ )
68
+
69
+ lay = QVBoxLayout(self)
70
+ lay.setContentsMargins(1, 1, 1, 1)
71
+ lay.setSpacing(0)
72
+
73
+ pixmap = QPixmap.fromImage(rgb_to_qimage(tile.rgb)).scaledToHeight(
74
+ tile_px, Qt.FastTransformation
75
+ )
76
+ image = QLabel()
77
+ image.setPixmap(pixmap)
78
+ image.setAlignment(Qt.AlignCenter)
79
+ lay.addWidget(image)
80
+
81
+ @property
82
+ def key(self) -> int:
83
+ return self._key
84
+
85
+ def mouseReleaseEvent(self, event) -> None:
86
+ super().mouseReleaseEvent(event)
87
+ if event.button() == Qt.LeftButton and self.rect().contains(event.pos()):
88
+ self.clicked.emit(self._key)
89
+
90
+
91
+ class CandidateColumn(QWidget):
92
+ """A titled block whose candidate thumbnails flow left→right and wrap."""
93
+
94
+ candidate_clicked = Signal(int) # key
95
+
96
+ def __init__(
97
+ self, title: str = "", parent: QWidget | None = None, *, tile_px: int = _TILE_PX
98
+ ) -> None:
99
+ super().__init__(parent)
100
+ self._tile_px = int(tile_px)
101
+ self._title_text = title
102
+ self._strip = CandidateStrip()
103
+
104
+ outer = QVBoxLayout(self)
105
+ outer.setContentsMargins(0, 0, 0, 0)
106
+ outer.setSpacing(2)
107
+
108
+ self._title = QLabel(title)
109
+ self._title.setContentsMargins(4, 2, 4, 2)
110
+ outer.addWidget(self._title)
111
+
112
+ self._body = QWidget()
113
+ self._body_lay = FlowLayout(self._body, margin=1, h_spacing=1, v_spacing=1)
114
+ outer.addWidget(self._body)
115
+ # Pin content to the top so a region dragged taller than its tiles
116
+ # leaves empty space at the bottom rather than floating them centred.
117
+ outer.addStretch(1)
118
+
119
+ def set_strip(self, strip: CandidateStrip, *, title: str | None = None) -> None:
120
+ """Replace the column's thumbnails (an empty strip shows a placeholder)."""
121
+ self._strip = strip
122
+ if title is not None:
123
+ self._title_text = title
124
+ self._rebuild()
125
+
126
+ def set_tile_size(self, tile_px: int) -> None:
127
+ """Change the thumbnail size and re-render the column's tiles."""
128
+ tile_px = max(_TILE_PX_MIN, min(int(tile_px), _TILE_PX_MAX))
129
+ if tile_px == self._tile_px:
130
+ return
131
+ self._tile_px = tile_px
132
+ self._rebuild()
133
+
134
+ def clear(self) -> None:
135
+ self.set_strip(CandidateStrip())
136
+
137
+ def tiles(self) -> list[_ClickableTile]:
138
+ """The live tile widgets, for tests and hit-testing."""
139
+ out: list[_ClickableTile] = []
140
+ for i in range(self._body_lay.count()):
141
+ widget = self._body_lay.itemAt(i).widget()
142
+ if isinstance(widget, _ClickableTile):
143
+ out.append(widget)
144
+ return out
145
+
146
+ def _clear_body(self) -> None:
147
+ for i in reversed(range(self._body_lay.count())):
148
+ item = self._body_lay.takeAt(i)
149
+ widget = item.widget() if item is not None else None
150
+ if widget is not None:
151
+ widget.deleteLater()
152
+
153
+ def _rebuild(self) -> None:
154
+ self._clear_body()
155
+ n = len(self._strip.tiles)
156
+ self._title.setText(
157
+ f"{self._title_text} ({n})" if self._title_text else str(n)
158
+ )
159
+ if n == 0:
160
+ placeholder = QLabel("—")
161
+ placeholder.setAlignment(Qt.AlignCenter)
162
+ placeholder.setStyleSheet("color: #707070;")
163
+ self._body_lay.addWidget(placeholder)
164
+ return
165
+ for tile in self._strip.tiles:
166
+ widget = _ClickableTile(tile, tile_px=self._tile_px, parent=self._body)
167
+ widget.clicked.connect(self.candidate_clicked)
168
+ self._body_lay.addWidget(widget)
169
+
170
+
171
+ class CandidateGalleryPanel(QWidget):
172
+ """Three stacked candidate blocks: extend-backward · swap · extend-forward.
173
+
174
+ The blocks stack top-to-bottom inside a vertical :class:`QSplitter`, so a
175
+ draggable handle between each pair resizes the regions (the same grip style
176
+ as the workspace strip-width splitter). Each block scrolls independently
177
+ when its thumbnails overflow the height it has been given, and wraps them
178
+ onto new rows (see :class:`CandidateColumn`).
179
+ """
180
+
181
+ EXTEND_BACKWARD = "extend_backward"
182
+ SWAP = "swap"
183
+ EXTEND_FORWARD = "extend_forward"
184
+
185
+ # (which, key) — which column, and the candidate's routing key.
186
+ candidate_activated = Signal(str, int)
187
+
188
+ def __init__(self, parent: QWidget | None = None, *, tile_px: int = _TILE_PX) -> None:
189
+ super().__init__(parent)
190
+ self._tile_px = int(tile_px)
191
+ outer = QVBoxLayout(self)
192
+ outer.setContentsMargins(0, 0, 0, 0)
193
+ outer.setSpacing(0)
194
+
195
+ splitter = QSplitter(Qt.Vertical, self)
196
+ splitter.setChildrenCollapsible(False)
197
+ splitter.setHandleWidth(_HANDLE_WIDTH)
198
+ splitter.setStyleSheet(_HANDLE_STYLE)
199
+
200
+ self._columns: dict[str, CandidateColumn] = {}
201
+ for which, title in (
202
+ (self.EXTEND_BACKWARD, "◀ Extend"),
203
+ (self.SWAP, "Swap"),
204
+ (self.EXTEND_FORWARD, "Extend ▶"),
205
+ ):
206
+ column = CandidateColumn(title, tile_px=tile_px)
207
+ column.candidate_clicked.connect(
208
+ lambda key, _which=which: self.candidate_activated.emit(_which, int(key))
209
+ )
210
+ self._columns[which] = column
211
+
212
+ pane = _GalleryScroll(self)
213
+ pane.setWidgetResizable(True)
214
+ pane.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
215
+ pane.setWidget(column)
216
+ splitter.addWidget(pane)
217
+
218
+ # Start the three regions at equal height; the user drags from there.
219
+ splitter.setSizes([1, 1, 1])
220
+ self._splitter = splitter
221
+ outer.addWidget(splitter)
222
+
223
+ def column(self, which: str) -> CandidateColumn:
224
+ return self._columns[which]
225
+
226
+ def set_column(self, which: str, strip: CandidateStrip) -> None:
227
+ self._columns[which].set_strip(strip)
228
+
229
+ def set_tile_size(self, tile_px: int) -> None:
230
+ """Resize every column's thumbnails (Ctrl+wheel zoom, like the film strip)."""
231
+ tile_px = max(_TILE_PX_MIN, min(int(tile_px), _TILE_PX_MAX))
232
+ if tile_px == self._tile_px:
233
+ return
234
+ self._tile_px = tile_px
235
+ for column in self._columns.values():
236
+ column.set_tile_size(tile_px)
237
+
238
+ def clear(self) -> None:
239
+ for column in self._columns.values():
240
+ column.clear()
241
+
242
+
243
+ class _GalleryScroll(QScrollArea):
244
+ """Per-region scroll area whose Ctrl+wheel zooms the gallery thumbnails."""
245
+
246
+ def __init__(self, panel: CandidateGalleryPanel) -> None:
247
+ super().__init__(panel)
248
+ self._panel = panel
249
+
250
+ def wheelEvent(self, event) -> None:
251
+ if event.modifiers() & Qt.ControlModifier:
252
+ step = _ZOOM_STEP if event.angleDelta().y() > 0 else -_ZOOM_STEP
253
+ self._panel.set_tile_size(self._panel._tile_px + step)
254
+ event.accept()
255
+ return
256
+ super().wheelEvent(event)
257
+
258
+
259
+ __all__ = ["CandidateColumn", "CandidateGalleryPanel"]
@@ -0,0 +1,169 @@
1
+ """Build thumbnail strips for extend / swap *candidates* at a single frame.
2
+
3
+ The correction workspace shows three side-by-side galleries — extend-backward,
4
+ swap, extend-forward — each a column of clickable thumbnails of the alternative
5
+ segmentations on offer. Where :func:`build_track_film_strip` crops one track
6
+ *across frames*, this crops a set of *candidate masks on one frame*: each
7
+ candidate is a full-frame boolean mask (from ``list_swap_candidates`` or
8
+ ``list_extend_candidates``), rendered through the shared
9
+ :func:`~itasc.napari.correction._correction_track_path.render_crop_tile` so a candidate
10
+ thumbnail reads identically to a film-strip tile.
11
+
12
+ Pure module: no Qt, no napari, so it is unit-testable on its own. The view half
13
+ (a clickable column) and the controller that maps a click back to an apply live
14
+ elsewhere.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+
21
+ import numpy as np
22
+
23
+ from itasc.napari.correction._correction_track_path import render_crop_tile
24
+
25
+ # Neutral outline when the caller does not pass the selected track's colour.
26
+ _DEFAULT_OUTLINE = (0.75, 0.75, 0.75)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CandidateSpec:
31
+ """One candidate to render: a routing ``key`` and its full-frame mask.
32
+
33
+ ``key`` is whatever the controller needs to apply the candidate later (the
34
+ node id for swap, the candidate label for extend); ``caption`` overrides the
35
+ default ``"<area> px"`` label when the caller has something better to show.
36
+ """
37
+
38
+ key: int
39
+ mask: np.ndarray # (H, W) bool, full frame
40
+ caption: str = ""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CandidateTile:
45
+ """One rendered candidate thumbnail plus the metadata the view shows."""
46
+
47
+ key: int
48
+ rgb: np.ndarray # (size, size, 3) uint8
49
+ area: int
50
+ caption: str
51
+
52
+ @property
53
+ def height(self) -> int:
54
+ return self.rgb.shape[0]
55
+
56
+ @property
57
+ def width(self) -> int:
58
+ return self.rgb.shape[1]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CandidateStrip:
63
+ """The rendered candidate tiles for one gallery column, in input order."""
64
+
65
+ tiles: tuple[CandidateTile, ...] = field(default_factory=tuple)
66
+
67
+ def is_empty(self) -> bool:
68
+ return len(self.tiles) == 0
69
+
70
+ @property
71
+ def keys(self) -> tuple[int, ...]:
72
+ return tuple(tile.key for tile in self.tiles)
73
+
74
+
75
+ def _mask_centroid_extent(mask: np.ndarray) -> tuple[int, int, int] | None:
76
+ """``(cy, cx, extent)`` of ``mask``; ``None`` for an empty mask."""
77
+ ys, xs = np.nonzero(mask)
78
+ if ys.size == 0:
79
+ return None
80
+ cy = int(round(float(ys.mean())))
81
+ cx = int(round(float(xs.mean())))
82
+ extent = max(int(ys.max() - ys.min()) + 1, int(xs.max() - xs.min()) + 1)
83
+ return cy, cx, extent
84
+
85
+
86
+ def build_candidate_strip(
87
+ intensity_2d: np.ndarray,
88
+ specs: Sequence[CandidateSpec],
89
+ *,
90
+ margin: int = 6,
91
+ colormap=None,
92
+ outline_color: tuple[float, float, float] | None = None,
93
+ outline_width: int = 2,
94
+ spotlight_dim: float = 0.35,
95
+ spotlight_dilation: int = 2,
96
+ ) -> CandidateStrip:
97
+ """Render ``specs`` as uniform, mask-centered thumbnails over ``intensity_2d``.
98
+
99
+ Every tile is a fixed square window (sized to the largest candidate plus
100
+ ``margin``) centered on that candidate's centroid, so the cell stays put in
101
+ the middle of each thumbnail. Contrast is stretched once against the union of
102
+ all candidate pixels (so the gallery is comparable tile-to-tile) and the mask
103
+ is spotlighted with an inner-edge ``outline_color`` border (a neutral grey if
104
+ ``None``). Empty masks are skipped; an empty ``specs`` yields an empty strip.
105
+ """
106
+ intensity = np.asarray(intensity_2d)
107
+ if intensity.ndim != 2:
108
+ raise ValueError(f"intensity_2d must be 2D, got {intensity.ndim}D")
109
+ shape = intensity.shape
110
+
111
+ kept: list[tuple[CandidateSpec, np.ndarray, int, int]] = []
112
+ extents: list[int] = []
113
+ for spec in specs:
114
+ mask = np.asarray(spec.mask, dtype=bool)
115
+ if mask.shape != shape:
116
+ raise ValueError(
117
+ f"candidate mask shape {mask.shape} != intensity shape {shape}"
118
+ )
119
+ ce = _mask_centroid_extent(mask)
120
+ if ce is None:
121
+ continue
122
+ cy, cx, extent = ce
123
+ kept.append((spec, mask, cy, cx))
124
+ extents.append(extent)
125
+ if not kept:
126
+ return CandidateStrip(tiles=())
127
+
128
+ size = int(max(extents)) + 2 * margin
129
+
130
+ # Shared contrast over every candidate's pixels, so brighter/dimmer fragments
131
+ # are judged on the same scale rather than each self-normalising.
132
+ pooled = np.concatenate([intensity[mask] for _, mask, _, _ in kept]).astype(float)
133
+ if pooled.size:
134
+ lo = float(np.percentile(pooled, 2.0))
135
+ hi = float(np.percentile(pooled, 98.0))
136
+ else: # pragma: no cover - kept implies non-empty masks
137
+ lo, hi = 0.0, 1.0
138
+
139
+ color = outline_color if outline_color is not None else _DEFAULT_OUTLINE
140
+
141
+ tiles: list[CandidateTile] = []
142
+ for spec, mask, cy, cx in kept:
143
+ rgb = render_crop_tile(
144
+ intensity,
145
+ mask,
146
+ cy,
147
+ cx,
148
+ size,
149
+ lo=lo,
150
+ hi=hi,
151
+ colormap=colormap,
152
+ outline_color=color,
153
+ outline_width=outline_width,
154
+ spotlight_dim=spotlight_dim,
155
+ spotlight_dilation=spotlight_dilation,
156
+ )
157
+ area = int(mask.sum())
158
+ tiles.append(
159
+ CandidateTile(
160
+ key=int(spec.key),
161
+ rgb=rgb,
162
+ area=area,
163
+ caption=spec.caption or f"{area} px",
164
+ )
165
+ )
166
+ return CandidateStrip(tiles=tuple(tiles))
167
+
168
+
169
+ __all__ = ["CandidateSpec", "CandidateTile", "CandidateStrip", "build_candidate_strip"]
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ # Uniform white used for the focused track's current-frame tip cross.
10
+ FOCUS_CROSS_COLOR = (1.0, 1.0, 1.0, 1.0)
11
+
12
+ # Single neutral colour shared by the labels outline and the all-tracks overview
13
+ # in the default ("outline") view. A saturated green reads clearly against both
14
+ # the white cell z-avg and the purple nucleus z-avg, where per-id colours were
15
+ # just noise on the thin outlines. The filled (by-id) view uses the per-id
16
+ # colormap instead — see ``correction_label_color_map`` / the tracks controller.
17
+ NEUTRAL_OVERLAY_COLOR = (0.18, 0.95, 0.32, 1.0)
18
+
19
+
20
+ def neutral_label_color_map() -> dict[int | None, tuple[float, float, float, float] | str]:
21
+ """Colour every non-zero label with the single :data:`NEUTRAL_OVERLAY_COLOR`.
22
+
23
+ The ``None`` key is napari's catch-all default colour, so every label maps to
24
+ the neutral colour without enumerating ids; ``0`` stays transparent.
25
+ """
26
+ return {None: NEUTRAL_OVERLAY_COLOR, 0: "transparent"}
27
+
28
+
29
+ def apply_neutral_label_colormap(
30
+ labels_layer: Any,
31
+ ) -> dict[int | None, tuple[float, float, float, float] | str]:
32
+ """Set a labels layer to the single-colour neutral colormap; return the dict."""
33
+ color_map = neutral_label_color_map()
34
+ try:
35
+ from napari.utils.colormaps import DirectLabelColormap
36
+
37
+ labels_layer.colormap = DirectLabelColormap(color_dict=dict(color_map))
38
+ except Exception:
39
+ pass
40
+ return color_map
41
+
42
+
43
+ def correction_label_color_map(
44
+ labels: np.ndarray,
45
+ *,
46
+ color_scale: float = 0.65,
47
+ ) -> dict[int | None, tuple[float, float, float, float] | str]:
48
+ """Return deterministic non-black colors for labels present in *labels*."""
49
+ labels_arr = np.asarray(labels)
50
+ label_ids = sorted(int(value) for value in np.unique(labels_arr) if int(value) != 0)
51
+ color_map: dict[int | None, tuple[float, float, float, float] | str] = {
52
+ None: "transparent",
53
+ 0: "transparent",
54
+ }
55
+ for label_id in label_ids:
56
+ rgba = _label_color(label_id)
57
+ rgba[:3] *= float(color_scale)
58
+ color_map[label_id] = tuple(float(channel) for channel in rgba)
59
+ return color_map
60
+
61
+
62
+ def refresh_label_colormap(
63
+ labels_layer: Any,
64
+ labels: np.ndarray,
65
+ *,
66
+ color_scale: float = 0.65,
67
+ ) -> dict[int | None, tuple[float, float, float, float] | str]:
68
+ """Refresh a labels layer colormap and return the color dictionary."""
69
+ color_map = correction_label_color_map(labels, color_scale=color_scale)
70
+ try:
71
+ from napari.utils.colormaps import DirectLabelColormap
72
+
73
+ labels_layer.colormap = DirectLabelColormap(color_dict=color_map)
74
+ except Exception:
75
+ pass
76
+ return color_map
77
+
78
+
79
+ def ensure_label_colormap_entries(
80
+ labels_layer: Any,
81
+ label_ids: Iterable[int],
82
+ *,
83
+ color_scale: float = 0.65,
84
+ ) -> dict[int | None, tuple[float, float, float, float] | str]:
85
+ """Ensure a labels layer has deterministic colors for the supplied IDs."""
86
+ color_map = _existing_color_dict(labels_layer)
87
+ for label_id in label_ids:
88
+ label_id = int(label_id)
89
+ if label_id == 0:
90
+ continue
91
+ rgba = _label_color(label_id)
92
+ rgba[:3] *= float(color_scale)
93
+ color_map[label_id] = tuple(float(channel) for channel in rgba)
94
+ try:
95
+ from napari.utils.colormaps import DirectLabelColormap
96
+
97
+ labels_layer.colormap = DirectLabelColormap(color_dict=color_map)
98
+ except Exception:
99
+ pass
100
+ return color_map
101
+
102
+
103
+ def _existing_color_dict(
104
+ labels_layer: Any,
105
+ ) -> dict[int | None, tuple[float, float, float, float] | str]:
106
+ color_map: dict[int | None, tuple[float, float, float, float] | str] = {
107
+ None: "transparent",
108
+ 0: "transparent",
109
+ }
110
+ raw = getattr(getattr(labels_layer, "colormap", None), "color_dict", None)
111
+ if isinstance(raw, Mapping):
112
+ color_map.update(raw)
113
+ return color_map
114
+
115
+
116
+ # Per-id label colours reuse napari's own default labels palette (the cyclic
117
+ # colormap a Labels layer is coloured with out of the box) so the nuclei read
118
+ # with the same well-spread, high-contrast hues napari uses everywhere else.
119
+ # The tracks layer mirrors these exact colours by reading the labels layer's
120
+ # colour dict back (see the tracks controller).
121
+ _LABEL_CMAP = None
122
+
123
+
124
+ def _label_color(label_id: int) -> np.ndarray:
125
+ """RGBA for *label_id* from napari's default cyclic labels colormap."""
126
+ global _LABEL_CMAP
127
+ if _LABEL_CMAP is None:
128
+ from napari.utils.colormaps import label_colormap
129
+
130
+ _LABEL_CMAP = label_colormap()
131
+ rgba = _LABEL_CMAP.map(np.asarray([int(label_id)]))[0]
132
+ return np.asarray(rgba, dtype=float)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from itasc.napari.correction._correction_utils import (
8
+ reassign_ids_stack,
9
+ remove_unvalidated_labels,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RemoveUnvalidatedResult:
15
+ changed_frames: int
16
+ changed_pixels: int
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CommitLabelsResult:
21
+ stack: np.ndarray
22
+ n_cells: int
23
+ old_to_new: dict[int, int]
24
+ validated_tracks: dict[int, set[int]]
25
+ changed_frames: int
26
+ changed_pixels: int
27
+
28
+
29
+ def remove_unvalidated_from_data(
30
+ data: np.ndarray,
31
+ validated_tracks: dict[int, set[int]],
32
+ ) -> RemoveUnvalidatedResult:
33
+ """Remove unvalidated labels from *data* in-place."""
34
+ changed_frames, changed_pixels = remove_unvalidated_labels(
35
+ data,
36
+ validated_tracks,
37
+ )
38
+ return RemoveUnvalidatedResult(
39
+ changed_frames=changed_frames,
40
+ changed_pixels=changed_pixels,
41
+ )
42
+
43
+
44
+ def prepare_committed_labels(
45
+ data: np.ndarray,
46
+ validated_tracks: dict[int, set[int]],
47
+ ) -> CommitLabelsResult:
48
+ """Return committed labels after ID compaction and validation filtering."""
49
+ remapped, n_cells, old_to_new = reassign_ids_stack(np.asarray(data))
50
+ remapped_validated = remap_validated_track_ids(validated_tracks, old_to_new)
51
+ removal = remove_unvalidated_from_data(remapped, remapped_validated)
52
+ return CommitLabelsResult(
53
+ stack=remapped,
54
+ n_cells=int(n_cells),
55
+ old_to_new=old_to_new,
56
+ validated_tracks=remapped_validated,
57
+ changed_frames=removal.changed_frames,
58
+ changed_pixels=removal.changed_pixels,
59
+ )
60
+
61
+
62
+ def remap_validated_track_ids(
63
+ validated_tracks: dict[int, set[int]],
64
+ old_to_new: dict[int, int],
65
+ ) -> dict[int, set[int]]:
66
+ """Remap validated-track IDs using the same drop-missing policy as the DB store."""
67
+ return {
68
+ int(old_to_new[int(cell_id)]): set(frames)
69
+ for cell_id, frames in validated_tracks.items()
70
+ if int(cell_id) in old_to_new
71
+ }