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,39 @@
1
+ """Domain events for the nucleus correction widget.
2
+
3
+ The widget emits one of these when something *happens* — a hand mask edit, a
4
+ track rebuild, a selection change — and the display collaborators subscribe to
5
+ repaint themselves. They already pull the current layer / selection / frame /
6
+ enabled-state through their provider callbacks (see how the controllers are
7
+ constructed), so an event only has to say *when* to refresh, not *what* changed.
8
+
9
+ This inverts the older shape, where every operation hand-called each controller's
10
+ refresh, scattering display coupling across the edit logic. With events, an
11
+ operation announces what it did and stays ignorant of who listens; the
12
+ subscriber map lives in one place (``NucleusCorrectionWidget._wire_events``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from qtpy.QtCore import QObject, Signal
18
+
19
+
20
+ class CorrectionEvents(QObject):
21
+ """Signals announcing correction-domain changes (see module docstring)."""
22
+
23
+ # A hand mask edit landed at one frame: ``(t, changed_ids)``.
24
+ labels_edited = Signal(object, object)
25
+ # The selected track's geometry changed across frames (extend / retrack):
26
+ # repaint the live track views (comet, lineage canvas, candidate gallery).
27
+ tracks_rebuilt = Signal()
28
+ # Per-frame validation / anchor *flags* changed, but not the labels
29
+ # themselves (validate / invalidate). Flag-only, so the lineage canvas
30
+ # recolours (status refresh) rather than rebuilding the whole stack.
31
+ validation_changed = Signal()
32
+ # One swap candidate was stepped to (Z / C, or a gallery pick). A cheap
33
+ # refresh — comet + the selected track's detail strip + gallery — without
34
+ # the whole-stack lineage rebuild, so keystroke stepping stays responsive.
35
+ swap_stepped = Signal()
36
+ # The whole label stack was rewritten (reassign IDs / remove-unvalidated /
37
+ # commit / anchor): full refresh — recolour, validated overlay + counter,
38
+ # and a whole-stack lineage rebuild.
39
+ stack_relabeled = Signal()
@@ -0,0 +1,80 @@
1
+ """Auto-repeat for held navigation keys in correction focus mode.
2
+
3
+ napari only auto-repeats *bare* arrows (not Shift+arrow), so the correction
4
+ widget drives a steady repeat itself: a press fires the action once and arms a
5
+ one-shot for an initial hold delay; if the key is still held when that elapses,
6
+ the timer settles into a steady repeat paced to the viewer's playback fps. The
7
+ matching release disarms it.
8
+
9
+ The state machine and its timer are isolated here so the widget only wires the
10
+ key bindings. The QTimer is injected (the widget passes ``QTimer(self)``) so the
11
+ logic can be unit-tested with a mock timer and no event loop.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+
18
+ KEY_REPEAT_DELAY_MS = 300 # hold threshold before repeating kicks in
19
+
20
+
21
+ class HeldKeyRepeater:
22
+ """Drive a steady repeat for whichever navigation key is currently held.
23
+
24
+ ``interval_provider`` returns the steady repeat interval (ms) at the moment
25
+ repeating begins, so it can track the viewer's live playback fps.
26
+ """
27
+
28
+ def __init__(self, timer, interval_provider: Callable[[], int]) -> None:
29
+ self._timer = timer
30
+ self._timer.timeout.connect(self._on_tick)
31
+ self._interval_provider = interval_provider
32
+ self._key: str | None = None
33
+ self._action: Callable[[], None] | None = None
34
+
35
+ def begin(self, key: str, action: Callable[[], None]) -> None:
36
+ # An OS auto-repeat re-press of the same held key would re-enter here;
37
+ # ignore it so the steady timer stays the single source of repeats.
38
+ if self._key == key:
39
+ return
40
+ self._key = key
41
+ self._action = action
42
+ action()
43
+ # Arm a one-shot for the initial hold delay; a tap released before it
44
+ # elapses never repeats. The first tick then settles into the steady
45
+ # fps-paced repeat (see _on_tick).
46
+ self._timer.setSingleShot(True)
47
+ self._timer.start(KEY_REPEAT_DELAY_MS)
48
+
49
+ def end(self, key: str) -> None:
50
+ if self._key == key:
51
+ self.stop()
52
+
53
+ def stop(self) -> None:
54
+ self._timer.stop()
55
+ self._key = None
56
+ self._action = None
57
+
58
+ def _on_tick(self) -> None:
59
+ if self._action is None:
60
+ return
61
+ self._action()
62
+ if self._timer.isSingleShot():
63
+ # The initial delay just elapsed; from here repeat steadily at the
64
+ # viewer's playback fps until the key is released.
65
+ self._timer.setSingleShot(False)
66
+ self._timer.start(self._interval_provider())
67
+
68
+ def key_handler(self, key: str, slot: Callable[[], None]):
69
+ """A napari generator keybinding: press starts repeat, release stops it.
70
+
71
+ napari runs the pre-``yield`` half on key press and the post-``yield``
72
+ half on release (it stashes the paused generator per key). Between them
73
+ the repeat timer drives ``slot`` at a steady rate.
74
+ """
75
+ def handler(_layer, _key=key, _slot=slot):
76
+ self.begin(_key, _slot)
77
+ yield
78
+ self.end(_key)
79
+
80
+ return handler
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LayerViewState:
12
+ visibility: dict[str, bool]
13
+ active: str | None
14
+ selected: tuple[str, ...]
15
+
16
+
17
+ def capture_layer_view_state(layers: Any) -> LayerViewState:
18
+ """Capture layer visibility and selection before entering correction mode."""
19
+ active = layers.selection.active
20
+ return LayerViewState(
21
+ visibility={layer.name: bool(layer.visible) for layer in layers},
22
+ active=active.name if active is not None else None,
23
+ selected=tuple(layer.name for layer in layers.selection),
24
+ )
25
+
26
+
27
+ def hide_all_layers(layers: Any) -> None:
28
+ """Hide all current layers while correction-mode layers are loaded."""
29
+ for layer in list(layers):
30
+ layer.visible = False
31
+
32
+
33
+ def restore_layer_view_state(layers: Any, state: LayerViewState | None) -> None:
34
+ """Restore captured visibility and selection for layers that still exist."""
35
+ if state is None:
36
+ return
37
+ for name, visible in state.visibility.items():
38
+ if name in layers:
39
+ layers[name].visible = bool(visible)
40
+ layers.selection.clear()
41
+ for name in state.selected:
42
+ if name in layers:
43
+ layers.selection.add(layers[name])
44
+ if state.active is not None and state.active in layers:
45
+ layers.selection.active = layers[state.active]
46
+
47
+
48
+ def remove_owned_layers(layers: Any, owned_layer_names: set[str]) -> None:
49
+ """Remove registered correction-owned layers and clear the ownership set."""
50
+ for name in list(owned_layer_names):
51
+ if name in layers:
52
+ layers.remove(layers[name])
53
+ owned_layer_names.clear()
54
+
55
+
56
+ def detach_higher_dim_stacks(
57
+ layers: Any, *, max_ndim: int, keep_names: Any = ()
58
+ ) -> list:
59
+ """Remove and return layers with more dimensions than the correction stack.
60
+
61
+ Correction shows a ``(T, Y, X)`` stack — a single slider whose axis 0 is the
62
+ frame. A pre-loaded 3D z-stack carried as ``(T, Z, Y, X)`` data has a higher
63
+ rank, so napari (which aligns layer axes to the right) treats its trailing
64
+ ``(T, Y, X)`` as the global ``(Z, Y, X)`` and the frame slider no longer
65
+ scrubs the correction frames. Such higher-rank layers are *removed* — not
66
+ merely hidden, because napari derives the dims sliders from every layer
67
+ regardless of visibility.
68
+
69
+ Only layers whose rank *exceeds* ``max_ndim`` (the correction stack's own
70
+ rank) are detached, so same-rank intensity/label layers — even ones with a
71
+ different frame count — are left alone. The detached ``Layer`` objects are
72
+ returned verbatim so they can be re-appended on restore with their data,
73
+ contrast limits and colormap intact (nothing is reloaded from disk).
74
+ ``keep_names`` (e.g. the correction-owned layers) are never detached.
75
+ """
76
+ keep = set(keep_names)
77
+ detached: list = []
78
+ for layer in list(layers):
79
+ if getattr(layer, "name", None) in keep:
80
+ continue
81
+ ndim = getattr(getattr(layer, "data", None), "ndim", None)
82
+ if ndim is not None and ndim > int(max_ndim):
83
+ detached.append(layer)
84
+ layers.remove(layer)
85
+ return detached
86
+
87
+
88
+ def reattach_layers(layers: Any, detached: list) -> None:
89
+ """Re-add layers removed by :func:`detach_higher_dim_stacks`, verbatim.
90
+
91
+ Skips any whose name is already back in the list (e.g. re-loaded meanwhile),
92
+ so restore is idempotent.
93
+ """
94
+ for layer in detached:
95
+ if getattr(layer, "name", None) not in layers:
96
+ layers.append(layer)
97
+
98
+
99
+ class CorrectionViewStateMixin:
100
+ """Shared correction-mode view-state + owned-layer handling.
101
+
102
+ Mixed into both the cell and nucleus correction widgets, which were carrying
103
+ byte-identical copies of these methods. The host must expose ``self.viewer``
104
+ and ``self.correction_status_lbl``, and track ``self._correction_view_state``
105
+ (captured on activate, restored + cleared on deactivate),
106
+ ``self._correction_owned_layers`` (the layer names the widget added and must
107
+ tear down) and the ``self._correction_dirty`` flag.
108
+ """
109
+
110
+ _correction_view_state: LayerViewState | None
111
+ _correction_owned_layers: set[str]
112
+ _correction_dirty: bool
113
+
114
+ def _capture_correction_view_state(self) -> None:
115
+ self._correction_view_state = capture_layer_view_state(self.viewer.layers)
116
+
117
+ def _restore_correction_view_state(self) -> None:
118
+ restore_layer_view_state(self.viewer.layers, self._correction_view_state)
119
+ self._correction_view_state = None
120
+
121
+ def _remove_correction_owned_layers(self) -> None:
122
+ remove_owned_layers(self.viewer.layers, self._correction_owned_layers)
123
+
124
+ def _current_t(self) -> int:
125
+ step = getattr(getattr(self.viewer, "dims", None), "current_step", (0,))
126
+ return int(step[0]) if len(step) >= 1 else 0
127
+
128
+ def _correction_status(self, msg: str) -> None:
129
+ self.correction_status_lbl.setText(msg)
130
+ self.correction_status_lbl.setVisible(bool(msg))
131
+ lowered = msg.lower()
132
+ if "unsaved" in lowered:
133
+ self._correction_dirty = True
134
+ elif lowered.startswith("saved") or lowered.startswith("loaded"):
135
+ self._correction_dirty = False
136
+ if msg:
137
+ logger.info(msg)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ from napari.utils.colormaps import Colormap
8
+
9
+ from itasc.napari.correction._correction_centroids import (
10
+ refresh_label_colormap,
11
+ )
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TrackedLayerLoadResult:
16
+ labels_layer: Any
17
+ color_map: dict[int | None, tuple[float, float, float, float] | str]
18
+
19
+
20
+ def add_correction_image_layer(
21
+ viewer: Any,
22
+ data: np.ndarray,
23
+ *,
24
+ name: str,
25
+ colormap: str,
26
+ owned_layer_names: set[str],
27
+ ) -> Any:
28
+ """Add a correction-owned reference image layer."""
29
+ arr = np.asarray(data, dtype=np.float32)
30
+ cmap: str | Colormap = colormap
31
+ if cmap == "bop_blue":
32
+ cmap = Colormap(
33
+ [[0.0, 0.0, 0.0, 1.0], [0.0, 0.25, 1.0, 1.0]],
34
+ name="bop_blue",
35
+ )
36
+ layer = viewer.add_image(arr, name=name, colormap=cmap, blending="minimum")
37
+ owned_layer_names.add(name)
38
+ return layer
39
+
40
+
41
+ def add_tracked_labels_and_track_layer(
42
+ viewer: Any,
43
+ labels: np.ndarray,
44
+ *,
45
+ labels_layer_name: str,
46
+ owned_layer_names: set[str],
47
+ color_scale: float = 0.65,
48
+ ) -> TrackedLayerLoadResult:
49
+ """Add the correction labels layer with its deterministic colormap.
50
+
51
+ The trajectory overlay is no longer rasterised here — it's a live napari
52
+ ``Tracks`` layer owned by ``AllTracksController`` (built on demand from this
53
+ same label data), so this just lays down the labels and returns the colour
54
+ map both share.
55
+ """
56
+ labels_arr = np.asarray(labels)
57
+ labels_layer = viewer.add_labels(
58
+ labels_arr,
59
+ name=labels_layer_name,
60
+ blending="additive",
61
+ )
62
+ labels_layer.blending = "additive"
63
+ owned_layer_names.add(labels_layer_name)
64
+ color_map = refresh_label_colormap(
65
+ labels_layer,
66
+ labels_arr,
67
+ color_scale=color_scale,
68
+ )
69
+ return TrackedLayerLoadResult(labels_layer=labels_layer, color_map=color_map)
70
+
71
+
72
+ def remove_other_correction_label_layers(
73
+ viewer: Any,
74
+ *,
75
+ owned_layer_names: set[str],
76
+ label_layer_type: type,
77
+ prefix: str = "[Correction]",
78
+ ) -> None:
79
+ """Remove stale correction-prefixed labels not owned by the active session."""
80
+ for layer in list(viewer.layers):
81
+ if (
82
+ layer.name.startswith(prefix)
83
+ and layer.name not in owned_layer_names
84
+ and isinstance(layer, label_layer_type)
85
+ ):
86
+ viewer.layers.remove(layer)
@@ -0,0 +1,97 @@
1
+ """Frame the selected track in the napari image viewer.
2
+
3
+ Camera math for the focus-mode navigation: given the active tracked Labels layer
4
+ and a selected cell id, pan the viewer onto the track's whole-stack bounding box
5
+ and zoom so it fills a fixed fraction of the canvas. These are pure functions of
6
+ ``(viewer, layer)`` with no widget state, so they live here rather than on the
7
+ correction widget; the widget's navigation handlers (``_navigate_to_cell``,
8
+ ``_step_track``) call :func:`center_viewer_on_cell` after selecting a cell.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # The selected track's bbox is zoomed to span ~this fraction of the canvas in
20
+ # its limiting dimension (≈25% of the canvas area), leaving margin around it.
21
+ TRACK_VIEWPORT_FRACTION = 0.5
22
+
23
+
24
+ def canvas_size_px(viewer):
25
+ """``(height, width)`` of the viewer canvas in pixels, or ``None``.
26
+
27
+ napari's ``_qt_viewer.canvas`` is private and its shape varies across
28
+ versions, so a missing/odd attribute degrades to "leave the zoom alone".
29
+ """
30
+ try:
31
+ h, w = viewer.window._qt_viewer.canvas.size
32
+ h, w = int(h), int(w)
33
+ if h > 0 and w > 0:
34
+ return h, w
35
+ except Exception:
36
+ logger.debug("track framing: canvas size unavailable", exc_info=True)
37
+ return None
38
+
39
+
40
+ def center_viewer_on_cell(viewer, layer, t: int, cell_id: int) -> None:
41
+ """Frame the whole selected track in the image viewer.
42
+
43
+ Centers the napari camera on the track's full spatial bounding box — its
44
+ union across *every* frame it appears in, not just frame ``t`` — and zooms so
45
+ that box spans about :data:`TRACK_VIEWPORT_FRACTION` of the canvas in both
46
+ directions (≈25% of the canvas area), leaving margin around the track. ``t``
47
+ only fixes the camera's non-spatial axes (the current frame); the y/x framing
48
+ comes from the whole track.
49
+ """
50
+ if layer is None or not cell_id:
51
+ return
52
+ try:
53
+ data = np.asarray(layer.data)
54
+ coords = np.nonzero(data == int(cell_id))
55
+ if coords[-1].size == 0:
56
+ return
57
+ ys, xs = coords[-2], coords[-1]
58
+ ymin, ymax = float(ys.min()), float(ys.max())
59
+ xmin, xmax = float(xs.min()), float(xs.max())
60
+
61
+ def to_world(y: float, x: float):
62
+ coord = (int(t), y, x) if data.ndim >= 3 else (y, x)
63
+ return layer.data_to_world(coord)
64
+
65
+ world_c = to_world((ymin + ymax) / 2.0, (xmin + xmax) / 2.0)
66
+ center = list(viewer.camera.center)
67
+ center[-2:] = [float(world_c[-2]), float(world_c[-1])]
68
+ viewer.camera.center = tuple(center)
69
+ zoom_to_track_bbox(viewer, ymin, ymax, xmin, xmax, to_world)
70
+ except Exception:
71
+ logger.exception("lineage navigation: camera framing failed")
72
+
73
+
74
+ def zoom_to_track_bbox(viewer, ymin, ymax, xmin, xmax, to_world) -> None:
75
+ """Zoom so the track's world bbox fills ~:data:`TRACK_VIEWPORT_FRACTION`.
76
+
77
+ napari's ``camera.zoom`` is canvas pixels per world unit, so the world span
78
+ visible along an axis is ``canvas_px / zoom``. Picking the smaller of the
79
+ per-axis zooms keeps the larger bbox side at exactly the target fraction and
80
+ the other side within it (the camera zoom is uniform). Degenerate
81
+ (zero-extent) sides are skipped; if no canvas size is available the zoom is
82
+ left untouched.
83
+ """
84
+ canvas = canvas_size_px(viewer)
85
+ if canvas is None:
86
+ return
87
+ canvas_h, canvas_w = canvas
88
+ w0, w1 = to_world(ymin, xmin), to_world(ymax, xmax)
89
+ bbox_h = abs(float(w1[-2]) - float(w0[-2]))
90
+ bbox_w = abs(float(w1[-1]) - float(w0[-1]))
91
+ candidates = []
92
+ if bbox_h > 0:
93
+ candidates.append(TRACK_VIEWPORT_FRACTION * canvas_h / bbox_h)
94
+ if bbox_w > 0:
95
+ candidates.append(TRACK_VIEWPORT_FRACTION * canvas_w / bbox_w)
96
+ if candidates:
97
+ viewer.camera.zoom = min(candidates)
@@ -0,0 +1,46 @@
1
+ """Paint label assignments into a single tracked-labels frame.
2
+
3
+ Shared by the extend paths (keyboard A/D and the candidate-gallery pick): each
4
+ assignment carries a ``cell_id`` and a boolean ``mask_2d``. The frame is mutated
5
+ in place — every assigned id is first cleared, then repainted either greedily
6
+ (overwriting anything not protected) or only into background — and the set of
7
+ ids whose pixels actually changed is returned so the caller can recolour just
8
+ those. Kept pure (no widget / napari state) so it is unit-tested directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+
15
+ import numpy as np
16
+
17
+
18
+ def paint_assignments(
19
+ frame: np.ndarray,
20
+ assignments: Iterable,
21
+ protected_mask: np.ndarray,
22
+ *,
23
+ greedy: bool,
24
+ ) -> set[int]:
25
+ """Paint ``assignments`` into ``frame``; return the changed cell ids.
26
+
27
+ ``greedy`` overwrites everything outside ``protected_mask``; otherwise an
28
+ assignment only fills background (``frame == 0``) pixels.
29
+ """
30
+ assignments = tuple(assignments)
31
+ before = np.asarray(frame).copy()
32
+ for a in assignments:
33
+ frame[frame == int(a.cell_id)] = 0
34
+ if greedy:
35
+ for a in assignments:
36
+ frame[a.mask_2d & ~protected_mask] = int(a.cell_id)
37
+ else:
38
+ for a in assignments:
39
+ frame[a.mask_2d & (frame == 0)] = int(a.cell_id)
40
+ changed = before != frame
41
+ changed_ids = (
42
+ set(int(v) for v in np.unique(before[changed]))
43
+ | set(int(v) for v in np.unique(np.asarray(frame)[changed]))
44
+ )
45
+ changed_ids.discard(0)
46
+ return changed_ids
@@ -0,0 +1,68 @@
1
+ """Read the napari viewer's frame-playback settings (fps + loop mode).
2
+
3
+ These are pure queries over the viewer's frame (axis-0) slider — the widget that
4
+ the right-click play-button popup writes to — falling back to napari's global
5
+ preferences when the slider is absent. They carry no widget state, so they live
6
+ here as free functions rather than on the correction widget; the held-key
7
+ auto-repeat (:mod:`itasc.napari.correction._correction_keymap`) paces itself from
8
+ :func:`nav_repeat_interval_ms`, and the film-strip stepping reads
9
+ :func:`playback_loops` to decide whether to wrap at the ends.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ DEFAULT_NAV_FPS = 10.0 # napari's default when no slider/preference exists
15
+
16
+
17
+ def frame_slider_widget(viewer):
18
+ """The frame (axis-0) playback slider widget, or ``None`` if absent.
19
+
20
+ It carries the viewer's live fps / loop mode (the right-click play-button
21
+ popup writes here, defaulting from the global preference).
22
+ """
23
+ try:
24
+ sliders = viewer.window._qt_viewer.dims.slider_widgets
25
+ return sliders[0] if sliders else None
26
+ except Exception:
27
+ return None
28
+
29
+
30
+ def playback_fps(viewer) -> float:
31
+ """The viewer's playback fps — the slider's, else the global preference."""
32
+ slider = frame_slider_widget(viewer)
33
+ if slider is not None:
34
+ try:
35
+ fps = abs(float(slider.fps))
36
+ if fps > 0:
37
+ return fps
38
+ except Exception:
39
+ pass
40
+ try:
41
+ from napari.settings import get_settings
42
+
43
+ return float(get_settings().application.playback_fps)
44
+ except Exception:
45
+ return DEFAULT_NAV_FPS
46
+
47
+
48
+ def playback_loops(viewer) -> bool:
49
+ """True when the viewer's playback loop mode wraps at the ends.
50
+
51
+ ``once`` stops at an end; ``loop`` / ``back_and_forth`` both wrap around.
52
+ """
53
+ slider = frame_slider_widget(viewer)
54
+ mode = getattr(slider, "loop_mode", None) if slider is not None else None
55
+ if mode is None:
56
+ try:
57
+ from napari.settings import get_settings
58
+
59
+ mode = get_settings().application.playback_mode
60
+ except Exception:
61
+ return True
62
+ return str(getattr(mode, "value", mode)).lower() != "once"
63
+
64
+
65
+ def nav_repeat_interval_ms(viewer) -> int:
66
+ """Held-key repeat interval (ms) matching the viewer's playback fps."""
67
+ fps = max(playback_fps(viewer), 1.0)
68
+ return max(int(round(1000.0 / fps)), 1)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping
4
+
5
+ import numpy as np
6
+
7
+ from itasc.tracking_ultrack.corrections import Correction
8
+
9
+
10
+ def protected_cell_ids_at_frame(
11
+ validated_tracks: Mapping[int, Iterable[int]],
12
+ corrections: Iterable[Correction],
13
+ *,
14
+ frame: int,
15
+ exclude_cell_id: int | None = None,
16
+ ) -> set[int]:
17
+ """Return cell IDs that should not be overwritten at *frame*."""
18
+ target_frame = int(frame)
19
+ excluded = int(exclude_cell_id) if exclude_cell_id is not None else None
20
+ protected: set[int] = set()
21
+
22
+ for raw_cell_id, frames in validated_tracks.items():
23
+ cell_id = int(raw_cell_id)
24
+ if excluded is not None and cell_id == excluded:
25
+ continue
26
+ if target_frame in {int(raw_frame) for raw_frame in frames}:
27
+ protected.add(cell_id)
28
+
29
+ for correction in corrections:
30
+ cell_id = int(correction.cell_id)
31
+ if excluded is not None and cell_id == excluded:
32
+ continue
33
+ if correction.kind == "anchor" and int(correction.t) == target_frame:
34
+ protected.add(cell_id)
35
+
36
+ return protected
37
+
38
+
39
+ def protected_cell_mask(frame: np.ndarray, protected_ids: Iterable[int]) -> np.ndarray:
40
+ """Return a boolean mask of protected labels in *frame*."""
41
+ ids = [int(cell_id) for cell_id in protected_ids]
42
+ if not ids:
43
+ return np.zeros_like(frame, dtype=bool)
44
+ return np.isin(frame, ids)
@@ -0,0 +1,64 @@
1
+ """Hide and restore napari's native dock chrome for correction *focus mode*.
2
+
3
+ The correction widget is effectively a standalone app: when it activates we hand
4
+ its panels the whole right column by hiding napari's built-in layer-list and
5
+ layer-controls docks, and restore them (to exactly their prior visibility) when
6
+ it deactivates. Everything is defensive — napari's private ``_qt_viewer`` dock
7
+ attributes differ across versions, so a missing attribute degrades to "leave it
8
+ alone" rather than raising into the correction lifecycle.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # napari ``QtViewer`` dock attributes for the chrome we reclaim. ``dockConsole``
17
+ # is intentionally left alone (it starts hidden and users toggle it themselves).
18
+ _NATIVE_DOCKS = ("dockLayerList", "dockLayerControls")
19
+
20
+
21
+ def _qt_viewer(viewer):
22
+ return getattr(getattr(viewer, "window", None), "_qt_viewer", None)
23
+
24
+
25
+ def hide_native_docks(viewer) -> dict[str, bool]:
26
+ """Hide napari's native docks, returning their prior visibility for restore.
27
+
28
+ The returned mapping is opaque — pass it back to :func:`restore_native_docks`.
29
+ An empty mapping means nothing was hidden (and restore is a no-op).
30
+ """
31
+ qt = _qt_viewer(viewer)
32
+ if qt is None:
33
+ return {}
34
+ prior: dict[str, bool] = {}
35
+ for name in _NATIVE_DOCKS:
36
+ dock = getattr(qt, name, None)
37
+ if dock is None:
38
+ continue
39
+ try:
40
+ prior[name] = bool(dock.isVisible())
41
+ dock.setVisible(False)
42
+ except Exception:
43
+ logger.exception("could not hide native dock %s", name)
44
+ return prior
45
+
46
+
47
+ def restore_native_docks(viewer, prior: dict[str, bool] | None) -> None:
48
+ """Restore docks hidden by :func:`hide_native_docks` to their prior state."""
49
+ if not prior:
50
+ return
51
+ qt = _qt_viewer(viewer)
52
+ if qt is None:
53
+ return
54
+ for name, was_visible in prior.items():
55
+ dock = getattr(qt, name, None)
56
+ if dock is None:
57
+ continue
58
+ try:
59
+ dock.setVisible(bool(was_visible))
60
+ except Exception:
61
+ logger.exception("could not restore native dock %s", name)
62
+
63
+
64
+ __all__ = ["hide_native_docks", "restore_native_docks"]