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