itasc 0.2.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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"]
|