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,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))
|