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,98 @@
1
+ """A wrapping flow layout — lays items left→right and wraps to new rows.
2
+
3
+ Qt ships no wrapping layout out of the box; this is the canonical
4
+ ``heightForWidth`` flow layout (per the Qt examples) trimmed to what the
5
+ correction workspace needs. Used to stack candidate thumbnails into rows that
6
+ reflow as the strip is resized.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from qtpy.QtCore import QMargins, QPoint, QRect, QSize, Qt
11
+ from qtpy.QtWidgets import QLayout
12
+
13
+
14
+ class FlowLayout(QLayout):
15
+ """Left-to-right layout that wraps its items onto new rows as needed."""
16
+
17
+ def __init__(
18
+ self,
19
+ parent=None,
20
+ *,
21
+ margin: int = 0,
22
+ h_spacing: int = 4,
23
+ v_spacing: int = 4,
24
+ ) -> None:
25
+ super().__init__(parent)
26
+ self._items: list = []
27
+ self._h_spacing = int(h_spacing)
28
+ self._v_spacing = int(v_spacing)
29
+ self.setContentsMargins(QMargins(margin, margin, margin, margin))
30
+
31
+ # -- QLayout plumbing ---------------------------------------------------
32
+ def addItem(self, item) -> None:
33
+ self._items.append(item)
34
+
35
+ def count(self) -> int:
36
+ return len(self._items)
37
+
38
+ def itemAt(self, index: int):
39
+ if 0 <= index < len(self._items):
40
+ return self._items[index]
41
+ return None
42
+
43
+ def takeAt(self, index: int):
44
+ if 0 <= index < len(self._items):
45
+ return self._items.pop(index)
46
+ return None
47
+
48
+ def expandingDirections(self):
49
+ return Qt.Orientation(0)
50
+
51
+ def hasHeightForWidth(self) -> bool:
52
+ return True
53
+
54
+ def heightForWidth(self, width: int) -> int:
55
+ return self._do_layout(QRect(0, 0, width, 0), test_only=True)
56
+
57
+ def setGeometry(self, rect: QRect) -> None:
58
+ super().setGeometry(rect)
59
+ self._do_layout(rect, test_only=False)
60
+
61
+ def sizeHint(self) -> QSize:
62
+ return self.minimumSize()
63
+
64
+ def minimumSize(self) -> QSize:
65
+ size = QSize()
66
+ for item in self._items:
67
+ size = size.expandedTo(item.minimumSize())
68
+ margins = self.contentsMargins()
69
+ size += QSize(
70
+ margins.left() + margins.right(), margins.top() + margins.bottom()
71
+ )
72
+ return size
73
+
74
+ # -- layout maths -------------------------------------------------------
75
+ def _do_layout(self, rect: QRect, *, test_only: bool) -> int:
76
+ margins = self.contentsMargins()
77
+ effective = rect.adjusted(
78
+ margins.left(), margins.top(), -margins.right(), -margins.bottom()
79
+ )
80
+ x = effective.x()
81
+ y = effective.y()
82
+ line_height = 0
83
+ for item in self._items:
84
+ hint = item.sizeHint()
85
+ next_x = x + hint.width() + self._h_spacing
86
+ if next_x - self._h_spacing > effective.right() and line_height > 0:
87
+ x = effective.x()
88
+ y = y + line_height + self._v_spacing
89
+ next_x = x + hint.width() + self._h_spacing
90
+ line_height = 0
91
+ if not test_only:
92
+ item.setGeometry(QRect(QPoint(x, y), hint))
93
+ x = next_x
94
+ line_height = max(line_height, hint.height())
95
+ return y + line_height - rect.y() + margins.bottom()
96
+
97
+
98
+ __all__ = ["FlowLayout"]
itasc/napari/_icons.py ADDED
@@ -0,0 +1,126 @@
1
+ """Crisp, accent-tinted vector icons for toolbar / stage-header action buttons.
2
+
3
+ Ported from napariTFM for visual parity across the two tools. Rendered from
4
+ inline SVG via QtSvg so they re-tint with the active theme and stay sharp at any
5
+ DPI — no bundled raster assets. Each body carries a ``{c}`` colour placeholder;
6
+ stroke icons inherit the wrapper stroke, filled icons override ``fill`` on their
7
+ own element.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from qtpy.QtCore import QByteArray, QRectF, Qt
12
+ from qtpy.QtGui import QIcon, QPainter, QPixmap
13
+ from qtpy.QtSvg import QSvgRenderer
14
+
15
+ _ICON_BODIES = {
16
+ # magnifier — "show this stage's data"
17
+ "files": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/>',
18
+ # sliders — "tune this stage's parameters"
19
+ "params": (
20
+ '<line x1="3" y1="8" x2="21" y2="8"/>'
21
+ '<circle cx="9" cy="8" r="2.4" fill="{c}" stroke="none"/>'
22
+ '<line x1="3" y1="16" x2="21" y2="16"/>'
23
+ '<circle cx="15" cy="16" r="2.4" fill="{c}" stroke="none"/>'
24
+ ),
25
+ # eye — "preview this stage"
26
+ "preview": (
27
+ '<path d="M2 12 C5 6 19 6 22 12 C19 18 5 18 2 12 Z"/>'
28
+ '<circle cx="12" cy="12" r="3"/>'
29
+ ),
30
+ # play triangle — "run this stage"
31
+ "run": '<path d="M8 5 L19 12 L8 19 Z" fill="{c}" stroke="none"/>',
32
+ # stop square — "cancel the running stage"
33
+ "cancel": '<rect x="6.5" y="6.5" width="11" height="11" rx="1.5" fill="{c}" stroke="none"/>',
34
+ # power — "enable / disable this stage"
35
+ "power": (
36
+ '<path d="M12 3 L12 11"/>'
37
+ '<path d="M6.4 6.8 A8 8 0 1 0 17.6 6.8"/>'
38
+ ),
39
+ # plus — "add folders"
40
+ "plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
41
+ # crosshair target — "auto-select the optimal regularization (GCV)"
42
+ "gcv": (
43
+ '<circle cx="12" cy="12" r="6.5"/>'
44
+ '<line x1="12" y1="1.5" x2="12" y2="5"/>'
45
+ '<line x1="12" y1="19" x2="12" y2="22.5"/>'
46
+ '<line x1="1.5" y1="12" x2="5" y2="12"/>'
47
+ '<line x1="19" y1="12" x2="22.5" y2="12"/>'
48
+ ),
49
+ # page with a folded corner + plus — "start a new project"
50
+ "new": (
51
+ '<path d="M6 3 H14 L18 7 V21 H6 Z"/>'
52
+ '<path d="M14 3 V7 H18"/>'
53
+ '<path d="M9 15 H15"/>'
54
+ '<path d="M12 12 V18"/>'
55
+ ),
56
+ # tabbed folder — "load / open a project or a preset"
57
+ "load": '<path d="M3 6 H9 L11 8 H21 V19 H3 Z"/>',
58
+ # floppy disk — "save a project or a preset"
59
+ "save": (
60
+ '<path d="M5 4 H15.5 L20 8.5 V20 H5 Z"/>'
61
+ '<path d="M8 20 V14 H16 V20"/>'
62
+ '<path d="M9 4 V8 H13.5 V4"/>'
63
+ ),
64
+ # counterclockwise rewind — "reset parameters / reload"
65
+ "reset": (
66
+ '<path d="M5.5 6.5 A8.5 8.5 0 1 0 18 6"/>'
67
+ '<path d="M6.06 8.89 L6.80 4.01 L2.94 7.08"/>'
68
+ ),
69
+ }
70
+
71
+ _SVG_TEMPLATE = (
72
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
73
+ 'fill="none" stroke="{c}" stroke-width="{w}" '
74
+ 'stroke-linecap="{cap}" stroke-linejoin="{join}">{body}</svg>'
75
+ )
76
+
77
+ ICON_NAMES = tuple(_ICON_BODIES)
78
+
79
+
80
+ def _render(svg: str, size: int) -> QPixmap:
81
+ pixmap = QPixmap(size, size)
82
+ pixmap.fill(Qt.transparent)
83
+ renderer = QSvgRenderer(QByteArray(svg.encode("utf-8")))
84
+ painter = QPainter(pixmap)
85
+ renderer.render(painter, QRectF(0, 0, size, size))
86
+ painter.end()
87
+ return pixmap
88
+
89
+
90
+ def stage_action_pixmap(
91
+ name: str,
92
+ color: str,
93
+ size: int = 18,
94
+ stroke_width: float = 2.0,
95
+ linecap: str = "round",
96
+ linejoin: str = "round",
97
+ ) -> QPixmap:
98
+ """Render a single tinted action icon to a transparent pixmap."""
99
+ body = _ICON_BODIES[name].format(c=color)
100
+ svg = _SVG_TEMPLATE.format(
101
+ c=color, w=stroke_width, cap=linecap, join=linejoin, body=body
102
+ )
103
+ return _render(svg, size)
104
+
105
+
106
+ def stage_action_icon(
107
+ name: str,
108
+ color: str,
109
+ disabled_color: str | None = None,
110
+ size: int = 18,
111
+ stroke_width: float = 2.0,
112
+ linecap: str = "round",
113
+ linejoin: str = "round",
114
+ ) -> QIcon:
115
+ """A QIcon for an action button, optionally carrying a dimmed disabled mode."""
116
+ icon = QIcon(
117
+ stage_action_pixmap(name, color, size, stroke_width, linecap, linejoin)
118
+ )
119
+ if disabled_color is not None:
120
+ icon.addPixmap(
121
+ stage_action_pixmap(
122
+ name, disabled_color, size, stroke_width, linecap, linejoin
123
+ ),
124
+ QIcon.Disabled,
125
+ )
126
+ return icon
@@ -0,0 +1,118 @@
1
+ """Compatibility patches for napari/Qt integration edge cases."""
2
+ from __future__ import annotations
3
+
4
+ from qtpy.QtCore import Qt
5
+ from qtpy.QtGui import QPixmap
6
+
7
+
8
+ def _index_size_hint(index):
9
+ if index is None or (hasattr(index, "isValid") and not index.isValid()):
10
+ return None
11
+ size_hint = index.data(Qt.ItemDataRole.SizeHintRole)
12
+ if size_hint is None or not hasattr(size_hint, "height"):
13
+ return None
14
+ return size_hint
15
+
16
+
17
+ def patch_napari_layer_delegate() -> None:
18
+ """Make napari's layer delegate tolerate transient indexes without size hints."""
19
+ try:
20
+ from napari._qt.containers._layer_delegate import LayerDelegate
21
+ from napari._qt.containers.qt_layer_model import LoadedRole, ThumbnailRole
22
+ except Exception:
23
+ return
24
+
25
+ if getattr(LayerDelegate, "_itasc_missing_size_hint_patch", False):
26
+ return
27
+
28
+ def _paint_loading(self, painter, option, index):
29
+ loaded = index.data(LoadedRole)
30
+ if loaded:
31
+ return
32
+
33
+ size_hint = _index_size_hint(index)
34
+ if size_hint is None:
35
+ return
36
+
37
+ self._load_movie.start()
38
+ load_rect = option.rect.translated(4, 8)
39
+ h = size_hint.height() - 16
40
+ load_rect.setWidth(h)
41
+ load_rect.setHeight(h)
42
+ painter.drawPixmap(load_rect, self._load_movie.currentPixmap())
43
+
44
+ def _paint_thumbnail(self, painter, option, index):
45
+ loaded = index.data(LoadedRole)
46
+ if not loaded:
47
+ return
48
+
49
+ size_hint = _index_size_hint(index)
50
+ if size_hint is None:
51
+ return
52
+
53
+ all_loaded = index.model().sourceModel().all_loaded()
54
+ if all_loaded:
55
+ self._load_movie.setPaused(True)
56
+
57
+ thumb_rect = option.rect.translated(-2, 2)
58
+ h = size_hint.height() - 4
59
+ thumb_rect.setWidth(h)
60
+ thumb_rect.setHeight(h)
61
+ image = index.data(ThumbnailRole)
62
+ painter.drawPixmap(thumb_rect, QPixmap.fromImage(image))
63
+
64
+ LayerDelegate._paint_loading = _paint_loading
65
+ LayerDelegate._paint_thumbnail = _paint_thumbnail
66
+ LayerDelegate._itasc_missing_size_hint_patch = True
67
+
68
+ patch_vispy_units_scale_guard()
69
+
70
+
71
+ def patch_vispy_units_scale_guard() -> None:
72
+ """Stop a canvas-draw ``IndexError`` from a layer whose per-axis units scale
73
+ is shorter than its displayed dims.
74
+
75
+ napari's ``VispyBaseLayer._on_matrix_change`` indexes
76
+ ``self._world_to_layer_units_scale`` by ``self.layer._slice_input.displayed``.
77
+ When physical units (e.g. an OME-TIFF's ``(pixel, pixel)``) reach a layer with
78
+ more dimensions than the units tuple — a calibrated 2D stack shown in a 3D
79
+ viewer — ``_recalculate_units_scale``'s ``strict=False`` zip yields a units
80
+ scale shorter than ``dims_displayed``, and indexing ``[2]`` overruns:
81
+
82
+ IndexError: tuple index out of range (canvas.py → _on_matrix_change)
83
+
84
+ The unresolved axes have no calibration, so their scale is just napari's
85
+ default of ``1.0``. Pad the units scale up to the displayed-dim extent with
86
+ ``1.0`` before delegating — semantically the identity, so the only behaviour
87
+ change is that the draw no longer crashes."""
88
+ try:
89
+ from napari._vispy.layers.base import VispyBaseLayer
90
+ except Exception: # pragma: no cover - napari layout changed / unavailable
91
+ return
92
+ if getattr(VispyBaseLayer, "_itasc_units_scale_guard", False):
93
+ return
94
+
95
+ original_on_matrix_change = VispyBaseLayer._on_matrix_change
96
+
97
+ def _guarded_on_matrix_change(self):
98
+ try:
99
+ self._world_to_layer_units_scale = _padded_units_scale(
100
+ self._world_to_layer_units_scale, self.layer._slice_input.displayed
101
+ )
102
+ except Exception: # pragma: no cover - never let the guard itself break draw
103
+ pass
104
+ return original_on_matrix_change(self)
105
+
106
+ VispyBaseLayer._on_matrix_change = _guarded_on_matrix_change
107
+ VispyBaseLayer._itasc_units_scale_guard = True
108
+
109
+
110
+ def _padded_units_scale(scale, displayed) -> tuple:
111
+ """*scale* extended with ``1.0`` (the no-calibration default) so every index
112
+ in *displayed* is in range; unchanged when it already covers them."""
113
+ scale = tuple(scale)
114
+ needed = (max(displayed) + 1) if len(displayed) else 0
115
+ if len(scale) >= needed:
116
+ return scale
117
+ return scale + (1.0,) * (needed - len(scale))
118
+
itasc/napari/_paths.py ADDED
@@ -0,0 +1,231 @@
1
+ """On-disk artifact paths for ITASC position directories.
2
+
3
+ Canonical file layout under ``<pos_dir>/``:
4
+
5
+ 0_input/ — cell_zavg.tif, nucleus_zavg.tif, NLS_zavg.tif
6
+ 1_cellpose/ — nucleus_prob.tif, nucleus_dp.tif,
7
+ cell_prob.tif, cell_dp.tif,
8
+ nucleus_contours.tif, nucleus_foreground.tif,
9
+ cell_contours.tif, cell_foreground.tif
10
+ 2_nucleus/ — contour_sources.tif, foreground_sources.tif,
11
+ tracked_labels.tif, ultrack_workdir/data.db
12
+ 3_cell/ — filtered_dp.tif, foreground_masks.tif,
13
+ contours.tif, foreground_scores.tif,
14
+ tracked_labels.tif
15
+ nucleus_labels.tif — FINAL output: committed nucleus tracked labels
16
+ cell_labels.tif — FINAL output: committed cell tracked labels
17
+ contact_analysis.h5 — FINAL output: contact graph built from the labels
18
+
19
+ The numbered stage dirs hold re-runnable *working* artifacts; the base-folder
20
+ ``nucleus_labels.tif`` / ``cell_labels.tif`` are the committed, downstream-stable
21
+ *final* outputs (see :func:`itasc.core.commit.promote_labels`), and
22
+ ``contact_analysis.h5`` — the graph derived from them — sits beside them. Contact
23
+ Analysis discovery defaults to these committed names.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class NucleusArtifactPaths:
33
+ """Resolve nucleus-workflow artifact locations under a position directory."""
34
+
35
+ pos_dir: Path
36
+
37
+ # 0_input
38
+ @property
39
+ def cell_zavg(self) -> Path:
40
+ return self.pos_dir / "0_input" / "cell_zavg.tif"
41
+
42
+ @property
43
+ def nucleus_zavg(self) -> Path:
44
+ return self.pos_dir / "0_input" / "nucleus_zavg.tif"
45
+
46
+ @property
47
+ def nls_zavg(self) -> Path:
48
+ return self.pos_dir / "0_input" / "NLS_zavg.tif"
49
+
50
+ # 1_cellpose — Cellpose outputs
51
+ @property
52
+ def prob(self) -> Path:
53
+ return self.pos_dir / "1_cellpose" / "nucleus_prob.tif"
54
+
55
+ @property
56
+ def cell_prob(self) -> Path:
57
+ return self.pos_dir / "1_cellpose" / "cell_prob.tif"
58
+
59
+ @property
60
+ def dp(self) -> Path:
61
+ return self.pos_dir / "1_cellpose" / "nucleus_dp.tif"
62
+
63
+ @property
64
+ def cell_dp(self) -> Path:
65
+ return self.pos_dir / "1_cellpose" / "cell_dp.tif"
66
+
67
+ # 1_cellpose — Divergence-map outputs (per channel)
68
+ @property
69
+ def nucleus_contours(self) -> Path:
70
+ return self.pos_dir / "1_cellpose" / "nucleus_contours.tif"
71
+
72
+ @property
73
+ def nucleus_foreground(self) -> Path:
74
+ return self.pos_dir / "1_cellpose" / "nucleus_foreground.tif"
75
+
76
+ @property
77
+ def cell_contours(self) -> Path:
78
+ return self.pos_dir / "1_cellpose" / "cell_contours.tif"
79
+
80
+ @property
81
+ def cell_foreground(self) -> Path:
82
+ return self.pos_dir / "1_cellpose" / "cell_foreground.tif"
83
+
84
+ # Aliases for the nucleus channel — historical names retained for callers
85
+ # that don't need a channel suffix (e.g. tracking-input writers).
86
+ @property
87
+ def contours(self) -> Path:
88
+ return self.nucleus_contours
89
+
90
+ @property
91
+ def foreground(self) -> Path:
92
+ return self.nucleus_foreground
93
+
94
+ # 2_nucleus
95
+ @property
96
+ def nucleus_dir(self) -> Path:
97
+ return self.pos_dir / "2_nucleus"
98
+
99
+ @property
100
+ def contour_sources(self) -> Path:
101
+ return self.nucleus_dir / "contour_sources.tif"
102
+
103
+ @property
104
+ def foreground_sources(self) -> Path:
105
+ return self.nucleus_dir / "foreground_sources.tif"
106
+
107
+ @property
108
+ def tracked(self) -> Path:
109
+ return self.nucleus_dir / "tracked_labels.tif"
110
+
111
+ @property
112
+ def ultrack_workdir(self) -> Path:
113
+ return self.nucleus_dir / "ultrack_workdir"
114
+
115
+ @property
116
+ def ultrack_db(self) -> Path:
117
+ return self.ultrack_workdir / "data.db"
118
+
119
+ @property
120
+ def nucleus_atoms(self) -> Path:
121
+ return self.nucleus_dir / "atoms.tif"
122
+
123
+ # 3_cell working output (the cell-workflow tracked labels, pre-commit)
124
+ @property
125
+ def cell_tracked(self) -> Path:
126
+ return self.pos_dir / "3_cell" / "tracked_labels.tif"
127
+
128
+ # Final outputs (committed) — base folder, downstream-stable
129
+ @property
130
+ def nucleus_labels(self) -> Path:
131
+ return self.pos_dir / "nucleus_labels.tif"
132
+
133
+ @property
134
+ def cell_labels(self) -> Path:
135
+ return self.pos_dir / "cell_labels.tif"
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class NucleusWorkspace:
140
+ """Single base directory for the nucleus tracking/correction piece.
141
+
142
+ All artifacts (database, tracked labels, atoms, source maps) and the
143
+ annotation store (validation/correction JSONs) live directly under
144
+ ``nucleus_dir``; the two foreground/contour *inputs* are explicit paths.
145
+
146
+ Three layouts are supported via the constructors:
147
+
148
+ * :meth:`staged` — the full orchestrator: ``nucleus_dir`` is
149
+ ``<pos>/2_nucleus`` and the inputs come from ``<pos>/1_cellpose``.
150
+ * :meth:`flat` — a standalone working directory that holds
151
+ ``foreground.tif`` + ``contours.tif`` and receives every output.
152
+ * :meth:`files` — the standalone tracking piece: explicit foreground and
153
+ contour file paths (any name, any location) plus an output directory that
154
+ receives every output.
155
+ """
156
+
157
+ nucleus_dir: Path
158
+ foreground: Path
159
+ contours: Path
160
+ # Optional cross-stage cell foreground, used purely as a correction-mode
161
+ # background overlay. Only the staged orchestrator supplies it.
162
+ cell_foreground: Path | None = None
163
+
164
+ @classmethod
165
+ def staged(cls, pos_dir: Path) -> NucleusWorkspace:
166
+ paths = NucleusArtifactPaths(Path(pos_dir))
167
+ return cls(
168
+ nucleus_dir=paths.nucleus_dir,
169
+ foreground=paths.nucleus_foreground,
170
+ contours=paths.nucleus_contours,
171
+ cell_foreground=paths.cell_foreground,
172
+ )
173
+
174
+ @classmethod
175
+ def flat(cls, work_dir: Path) -> NucleusWorkspace:
176
+ work_dir = Path(work_dir)
177
+ return cls(
178
+ nucleus_dir=work_dir,
179
+ foreground=work_dir / "foreground.tif",
180
+ contours=work_dir / "contours.tif",
181
+ )
182
+
183
+ @classmethod
184
+ def files(
185
+ cls,
186
+ *,
187
+ foreground: Path | str,
188
+ contours: Path | str,
189
+ output_dir: Path | str | None = None,
190
+ ) -> NucleusWorkspace:
191
+ """Explicit foreground/contour file paths + an output directory.
192
+
193
+ The two inputs may have any name and live anywhere; every output is
194
+ written under ``output_dir`` (defaulting to the foreground file's parent
195
+ when omitted).
196
+ """
197
+ foreground = Path(foreground)
198
+ contours = Path(contours)
199
+ nucleus_dir = Path(output_dir) if output_dir else foreground.parent
200
+ return cls(
201
+ nucleus_dir=nucleus_dir,
202
+ foreground=foreground,
203
+ contours=contours,
204
+ )
205
+
206
+ # Artifacts (outputs), all directly under nucleus_dir
207
+ @property
208
+ def contour_sources(self) -> Path:
209
+ return self.nucleus_dir / "contour_sources.tif"
210
+
211
+ @property
212
+ def foreground_sources(self) -> Path:
213
+ return self.nucleus_dir / "foreground_sources.tif"
214
+
215
+ @property
216
+ def tracked(self) -> Path:
217
+ return self.nucleus_dir / "tracked_labels.tif"
218
+
219
+ @property
220
+ def ultrack_workdir(self) -> Path:
221
+ return self.nucleus_dir / "ultrack_workdir"
222
+
223
+ @property
224
+ def ultrack_db(self) -> Path:
225
+ return self.ultrack_workdir / "data.db"
226
+
227
+ @property
228
+ def atoms(self) -> Path:
229
+ return self.nucleus_dir / "atoms.tif"
230
+
231
+
@@ -0,0 +1,60 @@
1
+ """A per-frame result cache for the live-preview widgets.
2
+
3
+ Every live preview recomputes the current frame off the GUI thread on each param
4
+ edit or time scrub. Without a cache, scrubbing back to a frame that was just
5
+ computed re-runs the (often heavy) pipeline for an identical result. This cache
6
+ holds whatever a compute produced for each visited frame, keyed on a *signature*
7
+ of the parameters that determine the result, so:
8
+
9
+ * scrubbing back to an already-computed frame repaints instantly, and
10
+ * any param edit (a changed signature) drops every cached frame at once.
11
+
12
+ It mirrors the per-frame cache the cell-segmentation preview
13
+ (:mod:`itasc.napari.cell_workflow_widget`) grew first; the other previews
14
+ share this implementation rather than re-deriving it.
15
+
16
+ Stale-write safety: a worker started under an old signature may finish *after* an
17
+ edit has moved the cache to a new one. The owner adopts the current signature up
18
+ front with :meth:`sync`, reads with :meth:`get`, and stores with :meth:`put`
19
+ passing the signature the result was computed under — :meth:`put` ignores the
20
+ write when that signature is no longer current, so a late worker can never poison
21
+ the fresh cache.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ _UNSET = object()
28
+
29
+
30
+ class FramePreviewCache:
31
+ """Frame ``t`` → result, valid only while the parameter signature holds."""
32
+
33
+ def __init__(self) -> None:
34
+ self._key: Any = _UNSET
35
+ self._frames: dict[int, Any] = {}
36
+
37
+ def sync(self, key: Any) -> None:
38
+ """Adopt *key* as the current signature, dropping all frames if it changed."""
39
+ if key != self._key:
40
+ self._key = key
41
+ self._frames = {}
42
+
43
+ def get(self, t: int) -> Any | None:
44
+ """The cached result for frame *t* under the current signature, or ``None``."""
45
+ return self._frames.get(t)
46
+
47
+ def put(self, key: Any, t: int, value: Any) -> None:
48
+ """Store *value* for frame *t* — ignored when *key* is no longer current.
49
+
50
+ Call :meth:`sync` with the live signature before reading; pass the
51
+ signature the *value* was computed under here so a worker that finishes
52
+ after an edit cannot overwrite the re-keyed cache.
53
+ """
54
+ if key == self._key:
55
+ self._frames[t] = value
56
+
57
+ def clear(self) -> None:
58
+ """Forget every frame and the signature (e.g. when the preview deactivates)."""
59
+ self._key = _UNSET
60
+ self._frames = {}
@@ -0,0 +1,46 @@
1
+ """Shared selection-highlight rendering: turn a boolean cell mask into the RGBA
2
+ overlay the correction studio paints over the labels.
3
+
4
+ Two visual cues, independently toggleable:
5
+
6
+ * ``dim`` — uniformly dim everything *outside* the mask (the "spotlight"), so the
7
+ selected cell stays at full brightness while its surroundings recede.
8
+ * ``border`` — draw an opaque yellow ring just outside the mask.
9
+
10
+ The correction widget uses one cue at a time (its ``spotlight`` / ``border``
11
+ styles); the aggregate-quantification click-to-load uses both at once so a picked
12
+ cell reads clearly against the full labels movie it sits in.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ from scipy.ndimage import binary_dilation
18
+
19
+ #: Alpha applied outside the mask in spotlight (``dim``) mode.
20
+ SPOTLIGHT_OPACITY = 0.7
21
+
22
+
23
+ def spotlight_rgba(
24
+ mask: np.ndarray,
25
+ *,
26
+ dim: bool = True,
27
+ border: bool = False,
28
+ opacity: float = SPOTLIGHT_OPACITY,
29
+ ring_iterations: int = 2,
30
+ ) -> np.ndarray:
31
+ """RGBA (``mask.shape + (4,)``, float32) overlay highlighting *mask*.
32
+
33
+ With *dim*, everything outside the mask is washed at *opacity* and the mask
34
+ itself left fully transparent. With *border*, an opaque yellow ring of
35
+ *ring_iterations* px is drawn just outside the mask. An all-False mask yields
36
+ an all-zero (fully transparent) overlay.
37
+ """
38
+ data = np.zeros(mask.shape + (4,), dtype=np.float32)
39
+ if dim:
40
+ alpha = np.full(mask.shape, opacity, dtype=np.float32)
41
+ alpha[mask] = 0.0
42
+ data[..., 3] = alpha
43
+ if border:
44
+ ring = binary_dilation(mask, iterations=ring_iterations) & ~mask
45
+ data[ring] = (1.0, 1.0, 0.0, 1.0) # opaque yellow outline
46
+ return data