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,146 @@
1
+ """Load a pipeline stage's on-disk output(s) into a napari viewer.
2
+
3
+ The catalog status rail's dots are clickable: clicking a stage's dot loads that
4
+ stage's canonical output(s) for the position into the viewer. This is *per-stage*
5
+ loading (four stages), coarser than the Pipeline Files panel's per-file loading.
6
+ The image-stage targets prefer a committed ``*_labels.tif`` over its working file
7
+ so a finalized position loads its stable output.
8
+
9
+ Reads are *lazy*: each target is handed to napari as a dask array backed by
10
+ ``tifffile``'s zarr store, so clicking a dot builds a per-slice handle instead of
11
+ decoding the whole (often multi-frame) stack on the UI thread. Napari then reads
12
+ only the visible slice as you scrub. This is what keeps the click responsive; a
13
+ full ``imread`` of a long timelapse (× four maps for the cellpose dot) is exactly
14
+ the freeze it avoids. An eager read is the fallback if a file can't be opened as
15
+ a zarr store.
16
+
17
+ Qt-free: the viewer is used purely by its duck-typed ``layers`` / ``add_labels`` /
18
+ ``add_image`` surface, so this is testable with a stub viewer.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from itasc.napari._paths import NucleusArtifactPaths
26
+ from itasc.napari._stage_status import (
27
+ STAGE_CELL,
28
+ STAGE_CELL_LABELS,
29
+ STAGE_CELLPOSE,
30
+ STAGE_CONTACTS,
31
+ STAGE_NUCLEUS,
32
+ STAGE_NUCLEUS_LABELS,
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LoadTarget:
38
+ """One file to load, and how to present it."""
39
+
40
+ path: Path
41
+ as_labels: bool
42
+ colormap: str = "gray"
43
+
44
+
45
+ def stage_load_targets(pos_dir: Path | str, stage: str) -> list[LoadTarget]:
46
+ """Canonical file(s) a stage loads for *pos_dir* (existence not checked here).
47
+
48
+ Cellpose loads its divergence maps (foreground → grey, contours → magma) for
49
+ both channels. Nucleus / Cell load the committed ``*_labels.tif`` when present,
50
+ else the working stage file, as labels. Contacts has no raw-image output (the
51
+ ``.h5`` is opened by the Visualize Contacts tool), so it loads nothing.
52
+ """
53
+ paths = NucleusArtifactPaths(Path(pos_dir))
54
+ if stage == STAGE_CELLPOSE:
55
+ return [
56
+ LoadTarget(paths.nucleus_foreground, as_labels=False, colormap="gray"),
57
+ LoadTarget(paths.nucleus_contours, as_labels=False, colormap="magma"),
58
+ LoadTarget(paths.cell_foreground, as_labels=False, colormap="gray"),
59
+ LoadTarget(paths.cell_contours, as_labels=False, colormap="magma"),
60
+ ]
61
+ if stage == STAGE_NUCLEUS:
62
+ committed = paths.nucleus_labels
63
+ chosen = committed if committed.is_file() else paths.tracked
64
+ return [LoadTarget(chosen, as_labels=True)]
65
+ if stage == STAGE_CELL:
66
+ committed = paths.cell_labels
67
+ chosen = committed if committed.is_file() else paths.cell_tracked
68
+ return [LoadTarget(chosen, as_labels=True)]
69
+ # The contact-analysis-only app's label stages load the committed label
70
+ # images directly (they are its inputs, not a working/committed pair).
71
+ if stage == STAGE_CELL_LABELS:
72
+ return [LoadTarget(paths.cell_labels, as_labels=True)]
73
+ if stage == STAGE_NUCLEUS_LABELS:
74
+ return [LoadTarget(paths.nucleus_labels, as_labels=True)]
75
+ if stage == STAGE_CONTACTS:
76
+ return []
77
+ return []
78
+
79
+
80
+ def _layer_name(pos_dir: Path, path: Path) -> str:
81
+ """A per-position layer name so two positions' files don't collide."""
82
+ return f"{Path(pos_dir).name}:{path.stem}"
83
+
84
+
85
+ def _read_lazy(path: Path):
86
+ """A lazy, per-slice-backed array for *path*.
87
+
88
+ Uses ``tifffile``'s zarr store wrapped in a dask array so adding a layer
89
+ doesn't decode the whole stack up front — napari materializes only the
90
+ slices it displays. Falls back to an eager ``imread`` if the file can't be
91
+ opened as a zarr store (e.g. an exotic layout aszarr won't handle).
92
+ """
93
+ import tifffile
94
+
95
+ try:
96
+ import dask.array as da
97
+
98
+ return da.from_zarr(tifffile.imread(str(path), aszarr=True))
99
+ except Exception:
100
+ return tifffile.imread(str(path))
101
+
102
+
103
+ def _select(viewer, name: str) -> None:
104
+ """Make *name* the active/visible layer so a dot click has visible effect.
105
+
106
+ Guarded: a duck-typed stub viewer without a selection surface is a no-op.
107
+ """
108
+ try:
109
+ layer = viewer.layers[name]
110
+ layer.visible = True
111
+ viewer.layers.selection.active = layer
112
+ except Exception:
113
+ pass
114
+
115
+
116
+ def load_stage(viewer, pos_dir: Path | str | None, stage: str) -> list[str]:
117
+ """Load *stage*'s existing outputs for *pos_dir* into *viewer*.
118
+
119
+ Returns the layer names loaded (empty when the position has no canonical root,
120
+ no viewer, or nothing on disk yet). An already-present layer of the same name
121
+ has its data replaced rather than duplicated.
122
+ """
123
+ if viewer is None or pos_dir is None:
124
+ return []
125
+
126
+ loaded: list[str] = []
127
+ for target in stage_load_targets(pos_dir, stage):
128
+ if not target.path.is_file():
129
+ continue
130
+ name = _layer_name(Path(pos_dir), target.path)
131
+ if name in viewer.layers:
132
+ try:
133
+ viewer.layers[name].data = _read_lazy(target.path)
134
+ _select(viewer, name)
135
+ loaded.append(name)
136
+ continue
137
+ except Exception:
138
+ viewer.layers.remove(viewer.layers[name])
139
+ data = _read_lazy(target.path)
140
+ if target.as_labels:
141
+ viewer.add_labels(data, name=name)
142
+ else:
143
+ viewer.add_image(data, name=name, colormap=target.colormap)
144
+ _select(viewer, name)
145
+ loaded.append(name)
146
+ return loaded
@@ -0,0 +1,110 @@
1
+ """Qt-free per-position pipeline status — the source the catalog rail reads.
2
+
3
+ A position moves through four human-in-the-loop stages (Cellpose → Ultrack
4
+ nucleus tracking → cell workflow → contact analysis). :func:`position_stage_status`
5
+ reports how far one position has progressed by header-only file existence + mtime
6
+ checks (no pixel decode), mirroring the on-disk done-signals:
7
+
8
+ * **cellpose** (2-state) — the nucleus divergence maps
9
+ (``1_cellpose/nucleus_foreground.tif`` + ``_contours.tif``) exist.
10
+ * **nucleus** / **cell** (3-state) — the working-vs-committed split from the P2
11
+ commit contract (:func:`itasc.core.commit.commit_state`): a working file in
12
+ the numbered stage dir, optionally promoted to the base-folder ``*_labels.tif``.
13
+ * **contacts** (2-state) — the contact-analysis ``.h5`` exists.
14
+
15
+ The widget layer calls this per row on refresh and maps the returned states onto
16
+ dot rendering. A row with no canonical position root (a hand-authored catalog CSV
17
+ pointing at scattered paths) passes ``None`` and gets ``unknown`` for every stage.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from itasc.contact_analysis.catalog import CONTACT_ANALYSIS_RELPATH
24
+ from itasc.core.commit import commit_state
25
+ from itasc.napari._paths import NucleusArtifactPaths
26
+
27
+ # Stage keys, ordered left→right as the rail renders them.
28
+ STAGE_CELLPOSE = "cellpose"
29
+ STAGE_NUCLEUS = "nucleus"
30
+ STAGE_CELL = "cell"
31
+ STAGE_CONTACTS = "contacts"
32
+ STAGES: tuple[str, ...] = (STAGE_CELLPOSE, STAGE_NUCLEUS, STAGE_CELL, STAGE_CONTACTS)
33
+
34
+ # The contact-analysis-only vocabulary (the standalone ``itasc-aggregate`` app,
35
+ # which does not run segmentation/tracking). Here the committed label images are
36
+ # *inputs*, not stages this app produces, so the rail reports only their presence:
37
+ # cell labels → nucleus labels → contact analysis.
38
+ STAGE_CELL_LABELS = "cell_labels"
39
+ STAGE_NUCLEUS_LABELS = "nucleus_labels"
40
+ CONTACT_STAGES: tuple[str, ...] = (
41
+ STAGE_CELL_LABELS,
42
+ STAGE_NUCLEUS_LABELS,
43
+ STAGE_CONTACTS,
44
+ )
45
+
46
+ # State vocabulary. The widget maps each onto a dot glyph:
47
+ MISSING = "missing" # empty ring — nothing on disk yet
48
+ WORKING = "working" # hollow — working file present, not committed (3-state only)
49
+ DONE = "done" # filled — final artifact present
50
+ STALE = "stale" # committed, but the working file is newer (3-state only)
51
+ UNKNOWN = "unknown" # grey — no canonical position root to derive from
52
+
53
+ # commit_state's vocabulary → the rail's.
54
+ _COMMIT_TO_STATE: dict[str, str] = {
55
+ "missing": MISSING,
56
+ "uncommitted": WORKING,
57
+ "committed": DONE,
58
+ "stale": STALE,
59
+ }
60
+
61
+
62
+ def position_stage_status(pos_dir: Path | str | None) -> dict[str, str]:
63
+ """Return each pipeline stage's status for one canonical position directory.
64
+
65
+ ``pos_dir`` of ``None`` yields ``unknown`` for every stage (a hand-authored
66
+ catalog row with no canonical root). See the module docstring for the
67
+ per-stage done-signals and the state vocabulary.
68
+ """
69
+ if pos_dir is None:
70
+ return {stage: UNKNOWN for stage in STAGES}
71
+
72
+ paths = NucleusArtifactPaths(Path(pos_dir))
73
+ cellpose = (
74
+ DONE
75
+ if paths.nucleus_foreground.is_file() and paths.nucleus_contours.is_file()
76
+ else MISSING
77
+ )
78
+ nucleus = _COMMIT_TO_STATE[commit_state(paths.tracked, paths.nucleus_labels)]
79
+ cell = _COMMIT_TO_STATE[commit_state(paths.cell_tracked, paths.cell_labels)]
80
+ contacts = DONE if (paths.pos_dir / CONTACT_ANALYSIS_RELPATH).is_file() else MISSING
81
+
82
+ return {
83
+ STAGE_CELLPOSE: cellpose,
84
+ STAGE_NUCLEUS: nucleus,
85
+ STAGE_CELL: cell,
86
+ STAGE_CONTACTS: contacts,
87
+ }
88
+
89
+
90
+ def position_contact_status(pos_dir: Path | str | None) -> dict[str, str]:
91
+ """Return the contact-analysis-only stage status for one position directory.
92
+
93
+ The standalone aggregate app does not run segmentation/tracking: it consumes
94
+ the committed ``cell_labels.tif`` (required) and ``nucleus_labels.tif``
95
+ (optional) as *inputs* and produces the contact-analysis ``.h5``. Each of the
96
+ three :data:`CONTACT_STAGES` is therefore a plain present/missing check (no
97
+ working-vs-committed split). ``pos_dir`` of ``None`` yields ``unknown`` for
98
+ every stage (a hand-authored catalog row with no canonical root).
99
+ """
100
+ if pos_dir is None:
101
+ return {stage: UNKNOWN for stage in CONTACT_STAGES}
102
+
103
+ paths = NucleusArtifactPaths(Path(pos_dir))
104
+ return {
105
+ STAGE_CELL_LABELS: DONE if paths.cell_labels.is_file() else MISSING,
106
+ STAGE_NUCLEUS_LABELS: DONE if paths.nucleus_labels.is_file() else MISSING,
107
+ STAGE_CONTACTS: (
108
+ DONE if (paths.pos_dir / CONTACT_ANALYSIS_RELPATH).is_file() else MISSING
109
+ ),
110
+ }
@@ -0,0 +1,89 @@
1
+ """Shared input/output path-picker scaffolding for standalone napari pieces.
2
+
3
+ The independently-installable ITASC pieces (``itasc-tracking``,
4
+ ``itasc-segmentation``) each expose a standalone seam: when run without the
5
+ orchestrator they show their own labeled file/dir pickers instead of being
6
+ driven through ``refresh(pos_dir)``. This mixin holds the parts that are
7
+ identical across those seams — the labeled line-edit + *Browse* row builder, the
8
+ file/dir browse handlers, and QSettings persistence — leaving each piece to
9
+ declare its own fields and implement its piece-specific "apply these paths to my
10
+ workspace" step.
11
+
12
+ Ships in ``itasc-core`` (qtpy-only, no ITASC dependencies) so both the
13
+ tracking and segmentation distributions compose on it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from qtpy.QtCore import QSettings
18
+ from qtpy.QtWidgets import (
19
+ QFileDialog,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QLineEdit,
23
+ QPushButton,
24
+ QVBoxLayout,
25
+ )
26
+
27
+ #: Default file filter for the image inputs the pieces consume.
28
+ IMAGE_FILTER = "Images (*.tif *.tiff);;All files (*)"
29
+
30
+
31
+ class StandalonePathsMixin:
32
+ """Reusable path-picker helpers for a standalone workflow widget.
33
+
34
+ Mixed into a ``QWidget`` subclass: ``self`` is the dialog parent.
35
+ """
36
+
37
+ def _add_path_row(
38
+ self,
39
+ column: QVBoxLayout,
40
+ label: str,
41
+ placeholder: str,
42
+ on_browse,
43
+ on_edited,
44
+ ) -> QLineEdit:
45
+ """Build a ``[label] [line edit] [Browse…]`` row and return the edit."""
46
+ row = QHBoxLayout()
47
+ row.setContentsMargins(0, 0, 0, 0)
48
+ lbl = QLabel(label)
49
+ lbl.setMinimumWidth(80)
50
+ row.addWidget(lbl)
51
+ edit = QLineEdit()
52
+ edit.setPlaceholderText(placeholder)
53
+ edit.editingFinished.connect(on_edited)
54
+ row.addWidget(edit, 1)
55
+ browse_btn = QPushButton("Browse…")
56
+ browse_btn.clicked.connect(on_browse)
57
+ row.addWidget(browse_btn)
58
+ column.addLayout(row)
59
+ return edit
60
+
61
+ def _browse_file_into(
62
+ self, edit: QLineEdit, title: str, on_done, *, file_filter: str = IMAGE_FILTER
63
+ ) -> None:
64
+ """Pick a file into ``edit`` and run ``on_done`` if one was chosen."""
65
+ path, _ = QFileDialog.getOpenFileName(self, title, filter=file_filter)
66
+ if path:
67
+ edit.setText(path)
68
+ on_done()
69
+
70
+ def _browse_dir_into(self, edit: QLineEdit, title: str, on_done) -> None:
71
+ """Pick a directory into ``edit`` and run ``on_done`` if one was chosen."""
72
+ path = QFileDialog.getExistingDirectory(self, title)
73
+ if path:
74
+ edit.setText(path)
75
+ on_done()
76
+
77
+ def _load_path_settings(self, app: str, fields: dict[str, QLineEdit]) -> None:
78
+ """Populate ``fields`` (key → edit) from ``QSettings('itasc', app)``."""
79
+ s = QSettings("itasc", app)
80
+ for key, edit in fields.items():
81
+ value = s.value(key, "", type=str)
82
+ if value:
83
+ edit.setText(value)
84
+
85
+ def _save_path_settings(self, app: str, fields: dict[str, QLineEdit]) -> None:
86
+ """Persist ``fields`` (key → edit) into ``QSettings('itasc', app)``."""
87
+ s = QSettings("itasc", app)
88
+ for key, edit in fields.items():
89
+ s.setValue(key, edit.text().strip())
itasc/napari/_state.py ADDED
@@ -0,0 +1,146 @@
1
+ """Serialize/restore nucleus-workflow widget control values.
2
+
3
+ Pure helpers: ``dump_state`` reads spinbox/checkbox/combo values into a dict,
4
+ ``load_state`` writes them back. Each key is guarded so older snapshots that
5
+ predate newer controls still load cleanly.
6
+
7
+ The dict layout is part of the public state contract — main_widget persists
8
+ it across sessions and tests round-trip it. Keep keys stable; add, don't
9
+ rename.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from itasc.napari.nucleus_workflow_widget import NucleusWorkflowWidget
17
+
18
+
19
+ def dump_state(w: NucleusWorkflowWidget) -> dict:
20
+ return {
21
+ "db_generation": {
22
+ "min_area": w.db_gen_min_area_spin.value(),
23
+ "max_area": w.db_gen_max_area_spin.value(),
24
+ "min_frontier": w.db_gen_min_frontier_spin.value(),
25
+ "ws_hierarchy": w.db_gen_ws_hierarchy_combo.currentText(),
26
+ "n_workers": w.db_gen_n_workers_spin.value(),
27
+ "atom_union_max_area": w.atom_union_max_area_spin.value(),
28
+ "overlap_budget": w.atom_overlap_budget_spin.value(),
29
+ "max_distance": w.db_gen_max_dist_spin.value(),
30
+ "max_neighbors": w.db_gen_max_neighbors_spin.value(),
31
+ "linking_mode": w.db_gen_linking_mode_combo.currentText(),
32
+ "area_weight": w.db_gen_area_weight_spin.value(),
33
+ "iou_weight": w.db_gen_iou_weight_spin.value(),
34
+ "distance_weight": w.db_gen_distance_weight_spin.value(),
35
+ "quality_weight": w.db_gen_quality_weight_spin.value(),
36
+ "quality_exponent": w.db_gen_quality_exp_spin.value(),
37
+ "circularity_weight": w.db_gen_circularity_weight_spin.value(),
38
+ },
39
+ "extend": {
40
+ "area_weight": w.extend_area_weight_spin.value(),
41
+ "iou_weight": w.extend_iou_weight_spin.value(),
42
+ "distance_weight": w.extend_distance_weight_spin.value(),
43
+ "greedy_overwrite": w.extend_greedy_overwrite_check.isChecked(),
44
+ },
45
+ "ultrack": {
46
+ "max_partitions": w.ultrack_max_partitions_spin.value(),
47
+ "n_frames": w.ultrack_n_frames_spin.value(),
48
+ "appear_weight": w.ultrack_appear_spin.value(),
49
+ "disappear_weight": w.ultrack_disappear_spin.value(),
50
+ "division_weight": w.ultrack_division_spin.value(),
51
+ "power": w.ultrack_power_spin.value(),
52
+ "bias": w.ultrack_bias_spin.value(),
53
+ },
54
+ "atom_extraction": {
55
+ "fg_window": w.atom_extraction_widget.fg_window_spin.value(),
56
+ "fg_cutoff": w.atom_extraction_widget.fg_cutoff_spin.value(),
57
+ "fg_strength": w.atom_extraction_widget.fg_strength_spin.value(),
58
+ "contour_window": w.atom_extraction_widget.contour_window_spin.value(),
59
+ "contour_floor": w.atom_extraction_widget.contour_floor_spin.value(),
60
+ "contour_strength": w.atom_extraction_widget.contour_strength_spin.value(),
61
+ "atom_min_area": w.atom_extraction_widget.atom_min_area_spin.value(),
62
+ },
63
+ "correction": {
64
+ "retrack_max_distance": w.retrack_max_dist_spin.value(),
65
+ "show_track_path": w.nucleus_correction_widget.track_path_btn.isChecked(),
66
+ "show_spotlight": w.nucleus_correction_widget.spotlight_btn.isChecked(),
67
+ },
68
+ }
69
+
70
+
71
+ def _set_combo(combo, text: str) -> None:
72
+ idx = combo.findText(text)
73
+ if idx >= 0:
74
+ combo.setCurrentIndex(idx)
75
+
76
+
77
+ def load_state(w: NucleusWorkflowWidget, state: dict) -> None:
78
+ if not isinstance(state, dict):
79
+ return
80
+ if "db_generation" in state:
81
+ dbg = state["db_generation"]
82
+ if "min_area" in dbg: w.db_gen_min_area_spin.setValue(dbg["min_area"])
83
+ if "max_area" in dbg: w.db_gen_max_area_spin.setValue(dbg["max_area"])
84
+ if "min_frontier" in dbg: w.db_gen_min_frontier_spin.setValue(dbg["min_frontier"])
85
+ if "ws_hierarchy" in dbg: _set_combo(w.db_gen_ws_hierarchy_combo, dbg["ws_hierarchy"])
86
+ if "n_workers" in dbg: w.db_gen_n_workers_spin.setValue(dbg["n_workers"])
87
+ if "atom_union_max_area" in dbg: w.atom_union_max_area_spin.setValue(dbg["atom_union_max_area"])
88
+ if "overlap_budget" in dbg: w.atom_overlap_budget_spin.setValue(dbg["overlap_budget"])
89
+ # "max_atoms" (removed) in older snapshots is intentionally ignored.
90
+ if "max_distance" in dbg: w.db_gen_max_dist_spin.setValue(dbg["max_distance"])
91
+ if "max_neighbors" in dbg: w.db_gen_max_neighbors_spin.setValue(dbg["max_neighbors"])
92
+ if "linking_mode" in dbg: _set_combo(w.db_gen_linking_mode_combo, dbg["linking_mode"])
93
+ if "area_weight" in dbg: w.db_gen_area_weight_spin.setValue(dbg["area_weight"])
94
+ if "iou_weight" in dbg: w.db_gen_iou_weight_spin.setValue(dbg["iou_weight"])
95
+ if "distance_weight" in dbg: w.db_gen_distance_weight_spin.setValue(dbg["distance_weight"])
96
+ if "quality_weight" in dbg: w.db_gen_quality_weight_spin.setValue(dbg["quality_weight"])
97
+ if "quality_exponent" in dbg: w.db_gen_quality_exp_spin.setValue(dbg["quality_exponent"])
98
+ if "circularity_weight" in dbg: w.db_gen_circularity_weight_spin.setValue(dbg["circularity_weight"])
99
+ if "extend" in state:
100
+ ext = state["extend"]
101
+ if "area_weight" in ext: w.extend_area_weight_spin.setValue(ext["area_weight"])
102
+ if "iou_weight" in ext: w.extend_iou_weight_spin.setValue(ext["iou_weight"])
103
+ if "distance_weight" in ext: w.extend_distance_weight_spin.setValue(ext["distance_weight"])
104
+ if "greedy_overwrite" in ext: w.extend_greedy_overwrite_check.setChecked(ext["greedy_overwrite"])
105
+ if "ultrack" in state:
106
+ ul = state["ultrack"]
107
+ dbg_present = "db_generation" in state
108
+ if "min_area" in ul and not (dbg_present and "min_area" in state["db_generation"]):
109
+ w.db_gen_min_area_spin.setValue(ul["min_area"])
110
+ if "max_partitions" in ul: w.ultrack_max_partitions_spin.setValue(ul["max_partitions"])
111
+ if "n_frames" in ul: w.ultrack_n_frames_spin.setValue(ul["n_frames"])
112
+ if "max_distance" in ul and not (dbg_present and "max_distance" in state["db_generation"]):
113
+ w.db_gen_max_dist_spin.setValue(ul["max_distance"])
114
+ if "linking_mode" in ul and not (dbg_present and "linking_mode" in state["db_generation"]):
115
+ _set_combo(w.db_gen_linking_mode_combo, ul["linking_mode"])
116
+ if "iou_weight" in ul and not (dbg_present and "iou_weight" in state["db_generation"]):
117
+ w.db_gen_iou_weight_spin.setValue(ul["iou_weight"])
118
+ if "area_weight" in ul and not (dbg_present and "area_weight" in state["db_generation"]):
119
+ w.db_gen_area_weight_spin.setValue(ul["area_weight"])
120
+ if "distance_weight" in ul and not (dbg_present and "distance_weight" in state["db_generation"]):
121
+ w.db_gen_distance_weight_spin.setValue(ul["distance_weight"])
122
+ if "appear_weight" in ul: w.ultrack_appear_spin.setValue(ul["appear_weight"])
123
+ if "disappear_weight" in ul: w.ultrack_disappear_spin.setValue(ul["disappear_weight"])
124
+ if "division_weight" in ul: w.ultrack_division_spin.setValue(ul["division_weight"])
125
+ if "max_neighbors" in ul and not (dbg_present and "max_neighbors" in state["db_generation"]):
126
+ w.db_gen_max_neighbors_spin.setValue(ul["max_neighbors"])
127
+ if "power" in ul: w.ultrack_power_spin.setValue(ul["power"])
128
+ if "bias" in ul: w.ultrack_bias_spin.setValue(ul["bias"])
129
+ if "quality_exponent" in ul and not (dbg_present and "quality_exponent" in state["db_generation"]):
130
+ w.db_gen_quality_exp_spin.setValue(ul["quality_exponent"])
131
+ if "atom_extraction" in state:
132
+ ae = state["atom_extraction"]
133
+ aw = w.atom_extraction_widget
134
+ if "fg_window" in ae: aw.fg_window_spin.setValue(ae["fg_window"])
135
+ if "fg_cutoff" in ae: aw.fg_cutoff_spin.setValue(ae["fg_cutoff"])
136
+ if "fg_strength" in ae: aw.fg_strength_spin.setValue(ae["fg_strength"])
137
+ if "contour_window" in ae: aw.contour_window_spin.setValue(ae["contour_window"])
138
+ if "contour_floor" in ae: aw.contour_floor_spin.setValue(ae["contour_floor"])
139
+ if "contour_strength" in ae: aw.contour_strength_spin.setValue(ae["contour_strength"])
140
+ if "atom_min_area" in ae: aw.atom_min_area_spin.setValue(ae["atom_min_area"])
141
+ if "correction" in state:
142
+ cor = state["correction"]
143
+ cw = w.nucleus_correction_widget
144
+ if "retrack_max_distance" in cor: w.retrack_max_dist_spin.setValue(cor["retrack_max_distance"])
145
+ if "show_track_path" in cor: cw.track_path_btn.setChecked(cor["show_track_path"])
146
+ if "show_spotlight" in cor: cw.spotlight_btn.setChecked(cor["show_spotlight"])
@@ -0,0 +1,128 @@
1
+ """The per-position status rail: one dot per pipeline stage.
2
+
3
+ Each :class:`StatusDot` renders one stage's :mod:`itasc.napari._stage_status`
4
+ state as a small coloured circle with a plain-language tooltip; :class:`StatusRail`
5
+ lays them out left→right and repaints them from a status dict on refresh. The
6
+ stage set is configurable: the full app passes the four-stage default
7
+ (Cellpose → Nucleus → Cell → Contacts); the standalone aggregate app passes the
8
+ three-stage :data:`~itasc.napari._stage_status.CONTACT_STAGES`
9
+ (cell labels → nucleus labels → contact analysis). Rendering only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+
15
+ from qtpy.QtCore import Qt, Signal
16
+ from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
17
+
18
+ from itasc.napari._stage_status import (
19
+ DONE,
20
+ MISSING,
21
+ STAGE_CELL,
22
+ STAGE_CELL_LABELS,
23
+ STAGE_CELLPOSE,
24
+ STAGE_CONTACTS,
25
+ STAGE_NUCLEUS,
26
+ STAGE_NUCLEUS_LABELS,
27
+ STAGES,
28
+ STALE,
29
+ UNKNOWN,
30
+ WORKING,
31
+ )
32
+
33
+ #: Human-facing stage names for tooltips.
34
+ _STAGE_LABELS: dict[str, str] = {
35
+ STAGE_CELLPOSE: "Cellpose",
36
+ STAGE_NUCLEUS: "Nucleus tracking",
37
+ STAGE_CELL: "Cell workflow",
38
+ STAGE_CONTACTS: "Contact analysis",
39
+ STAGE_CELL_LABELS: "Cell labels",
40
+ STAGE_NUCLEUS_LABELS: "Nucleus labels",
41
+ }
42
+
43
+ #: Human-facing state names for tooltips.
44
+ _STATE_LABELS: dict[str, str] = {
45
+ MISSING: "not started",
46
+ WORKING: "working (uncommitted)",
47
+ DONE: "done",
48
+ STALE: "stale (re-run since commit)",
49
+ UNKNOWN: "unknown",
50
+ }
51
+
52
+ #: state → (fill, border) colours. ``MISSING`` is a hollow ring; the rest fill.
53
+ #: Chosen to read on napari's dark theme.
54
+ _STATE_COLOURS: dict[str, tuple[str, str]] = {
55
+ MISSING: ("transparent", "#7a7a7a"),
56
+ WORKING: ("#e0a020", "#a8791a"), # amber — working, not committed
57
+ DONE: ("#3aa84a", "#2c7d38"), # green — committed / present
58
+ STALE: ("#d9534f", "#a83836"), # red — committed but stale
59
+ UNKNOWN: ("#5a5a5a", "#4a4a4a"), # muted grey — no canonical root
60
+ }
61
+
62
+ _DOT_PX = 12
63
+
64
+
65
+ class StatusDot(QLabel):
66
+ """One stage's status, drawn as a small coloured circle.
67
+
68
+ Clicking the dot emits :attr:`clicked` with the stage name — the catalog wires
69
+ this to load that stage's output into the viewer.
70
+ """
71
+
72
+ clicked = Signal(str) # stage name
73
+
74
+ def __init__(self, stage: str, parent: QWidget | None = None) -> None:
75
+ super().__init__(parent)
76
+ self.stage = stage
77
+ self.state = UNKNOWN
78
+ self.setFixedSize(_DOT_PX, _DOT_PX)
79
+ self.setCursor(Qt.PointingHandCursor)
80
+ self.set_state(UNKNOWN)
81
+
82
+ def mousePressEvent(self, event) -> None:
83
+ # Consume the event (do not fall through to row selection) and load.
84
+ self.clicked.emit(self.stage)
85
+ event.accept()
86
+
87
+ def set_state(self, state: str) -> None:
88
+ self.state = state
89
+ fill, border = _STATE_COLOURS.get(state, _STATE_COLOURS[UNKNOWN])
90
+ radius = _DOT_PX // 2
91
+ self.setStyleSheet(
92
+ f"background-color: {fill}; border: 1px solid {border}; "
93
+ f"border-radius: {radius}px;"
94
+ )
95
+ stage_label = _STAGE_LABELS.get(self.stage, self.stage)
96
+ state_label = _STATE_LABELS.get(state, state)
97
+ self.setToolTip(f"{stage_label}: {state_label}")
98
+
99
+
100
+ class StatusRail(QWidget):
101
+ """One :class:`StatusDot` per stage in *stages*, in the given order.
102
+
103
+ Defaults to the four-stage pipeline :data:`STAGES`; pass an explicit stage
104
+ list (e.g. :data:`CONTACT_STAGES`) for a different app's rail.
105
+ :attr:`dotClicked` re-emits any dot's click with its stage name.
106
+ """
107
+
108
+ dotClicked = Signal(str) # stage name
109
+
110
+ def __init__(
111
+ self,
112
+ stages: Sequence[str] = STAGES,
113
+ parent: QWidget | None = None,
114
+ ) -> None:
115
+ super().__init__(parent)
116
+ layout = QHBoxLayout(self)
117
+ layout.setContentsMargins(0, 0, 0, 0)
118
+ layout.setSpacing(3)
119
+ self.dots: list[StatusDot] = [StatusDot(stage) for stage in stages]
120
+ for dot in self.dots:
121
+ dot.clicked.connect(self.dotClicked)
122
+ layout.addWidget(dot)
123
+ layout.addStretch(1)
124
+
125
+ def set_status(self, status: dict[str, str]) -> None:
126
+ """Repaint every dot; stages absent from *status* render as ``unknown``."""
127
+ for dot in self.dots:
128
+ dot.set_state(status.get(dot.stage, UNKNOWN))