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,114 @@
|
|
|
1
|
+
"""Shared napari track-rendering helpers.
|
|
2
|
+
|
|
3
|
+
Pure numpy / napari colormap maths reused by both the contact-analysis
|
|
4
|
+
visualization (``itasc-aggregate``) and the nucleus-correction track overview
|
|
5
|
+
(``itasc-tracking``). It lives in ``itasc-core`` so neither piece has to
|
|
6
|
+
depend on the other.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
_UNLABELED_COLOR = np.array([0.7, 0.7, 0.7, 1.0], dtype=float)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _track_label_color_styling(
|
|
18
|
+
track_ids: np.ndarray,
|
|
19
|
+
color_map: dict[int | None, tuple[float, float, float, float] | str],
|
|
20
|
+
) -> tuple[Any, np.ndarray] | None:
|
|
21
|
+
"""Colour each track exactly like its cell label.
|
|
22
|
+
|
|
23
|
+
A ``Tracks`` layer colours by mapping a property through a colormap. When a
|
|
24
|
+
custom colormap is supplied via ``colormaps_dict`` napari feeds it the *raw*
|
|
25
|
+
property values (no normalisation), so we build a per-vertex ``label_pos``
|
|
26
|
+
property in ``[0, 1]`` plus a step (``interpolation="zero"``) colormap whose
|
|
27
|
+
bins reproduce ``color_map`` for each track. Returns ``(colormap, label_pos)``
|
|
28
|
+
or ``None`` when there are no tracks / napari is unavailable.
|
|
29
|
+
"""
|
|
30
|
+
ids = sorted({int(i) for i in np.asarray(track_ids).tolist()})
|
|
31
|
+
if not ids:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
from napari.utils.colormaps import Colormap
|
|
35
|
+
except Exception: # pragma: no cover - napari compatibility
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def _color(cell_id: int) -> tuple[float, float, float, float]:
|
|
39
|
+
raw = color_map.get(int(cell_id))
|
|
40
|
+
# ``color_map`` may come straight from a caller (tuple/list values) or be
|
|
41
|
+
# read back off a napari ``DirectLabelColormap``, which normalises every
|
|
42
|
+
# entry to an ``np.ndarray``. Accept any length-4 numeric sequence so the
|
|
43
|
+
# latter doesn't silently fall through to the grey unlabeled colour.
|
|
44
|
+
if isinstance(raw, (tuple, list, np.ndarray)) and len(raw) == 4:
|
|
45
|
+
return tuple(float(c) for c in raw)
|
|
46
|
+
return tuple(float(c) for c in _UNLABELED_COLOR)
|
|
47
|
+
|
|
48
|
+
colors = [_color(i) for i in ids]
|
|
49
|
+
if len(ids) == 1:
|
|
50
|
+
cmap = Colormap([colors[0], colors[0]], controls=[0.0, 1.0])
|
|
51
|
+
pos_by_id = {ids[0]: 0.0}
|
|
52
|
+
else:
|
|
53
|
+
vals = [k / (len(ids) - 1) for k in range(len(ids))]
|
|
54
|
+
pos_by_id = {ids[k]: vals[k] for k in range(len(ids))}
|
|
55
|
+
edges = (
|
|
56
|
+
[0.0]
|
|
57
|
+
+ [(vals[k - 1] + vals[k]) / 2 for k in range(1, len(ids))]
|
|
58
|
+
+ [1.0]
|
|
59
|
+
)
|
|
60
|
+
cmap = Colormap(colors, controls=edges, interpolation="zero")
|
|
61
|
+
label_pos = np.array(
|
|
62
|
+
[pos_by_id[int(i)] for i in np.asarray(track_ids).tolist()], dtype=float
|
|
63
|
+
)
|
|
64
|
+
return cmap, label_pos
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _nucleus_centroids_by_track(
|
|
68
|
+
nucleus_labels: np.ndarray,
|
|
69
|
+
) -> dict[int, list[tuple[int, float, float]]]:
|
|
70
|
+
labels = np.asarray(nucleus_labels)
|
|
71
|
+
if labels.ndim == 2:
|
|
72
|
+
labels = labels[np.newaxis, ...]
|
|
73
|
+
if labels.ndim > 3:
|
|
74
|
+
labels = np.squeeze(labels)
|
|
75
|
+
if labels.ndim != 3:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Expected time-first 2D/3D nucleus labels, got shape {nucleus_labels.shape}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
centroids: dict[int, list[tuple[int, float, float]]] = {}
|
|
81
|
+
for frame_idx in range(labels.shape[0]):
|
|
82
|
+
frame = labels[frame_idx]
|
|
83
|
+
flat = frame.ravel()
|
|
84
|
+
order = np.argsort(flat, kind="stable")
|
|
85
|
+
sorted_ids = flat[order]
|
|
86
|
+
|
|
87
|
+
change = np.empty(len(sorted_ids), dtype=bool)
|
|
88
|
+
change[0] = True
|
|
89
|
+
np.not_equal(sorted_ids[1:], sorted_ids[:-1], out=change[1:])
|
|
90
|
+
boundaries = np.flatnonzero(change)
|
|
91
|
+
|
|
92
|
+
rows_all, cols_all = np.divmod(order, frame.shape[1])
|
|
93
|
+
|
|
94
|
+
ends = np.empty_like(boundaries)
|
|
95
|
+
ends[:-1] = boundaries[1:]
|
|
96
|
+
ends[-1] = len(sorted_ids)
|
|
97
|
+
|
|
98
|
+
for bi in range(len(boundaries)):
|
|
99
|
+
cell_id = int(sorted_ids[boundaries[bi]])
|
|
100
|
+
if cell_id == 0:
|
|
101
|
+
continue
|
|
102
|
+
s, e = int(boundaries[bi]), int(ends[bi])
|
|
103
|
+
y = float(rows_all[s:e].mean())
|
|
104
|
+
x = float(cols_all[s:e].mean())
|
|
105
|
+
centroids.setdefault(cell_id, []).append((frame_idx, y, x))
|
|
106
|
+
|
|
107
|
+
return centroids
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"_UNLABELED_COLOR",
|
|
112
|
+
"_nucleus_centroids_by_track",
|
|
113
|
+
"_track_label_color_styling",
|
|
114
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Small Qt widget factory helpers shared across napari workflow widgets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from qtpy.QtCore import QEvent, QObject, QSize, Qt
|
|
5
|
+
from qtpy.QtWidgets import (
|
|
6
|
+
QDoubleSpinBox,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QLabel,
|
|
9
|
+
QProgressBar,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QSizePolicy,
|
|
12
|
+
QStyle,
|
|
13
|
+
QStyleOption,
|
|
14
|
+
QToolButton,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
from superqt import (
|
|
19
|
+
QLabeledDoubleSlider,
|
|
20
|
+
QLabeledSlider,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from itasc.napari.ui_style import action_button, parameter_heading, status_label
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def heading(text: str) -> QLabel:
|
|
27
|
+
lbl = QLabel(text)
|
|
28
|
+
return parameter_heading(lbl)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_status() -> QLabel:
|
|
32
|
+
lbl = QLabel("")
|
|
33
|
+
lbl.setWordWrap(True)
|
|
34
|
+
lbl.setVisible(False)
|
|
35
|
+
status_label(lbl)
|
|
36
|
+
return lbl
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_progress() -> QProgressBar:
|
|
40
|
+
bar = QProgressBar()
|
|
41
|
+
bar.setRange(0, 100)
|
|
42
|
+
bar.setValue(0)
|
|
43
|
+
bar.setTextVisible(True)
|
|
44
|
+
bar.setVisible(False)
|
|
45
|
+
return bar
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dspin(lo, hi, val, step=0.1, decimals=2, tooltip=""):
|
|
49
|
+
s = QDoubleSpinBox()
|
|
50
|
+
s.setRange(lo, hi); s.setValue(val); s.setSingleStep(step)
|
|
51
|
+
s.setDecimals(decimals); s.setToolTip(tooltip)
|
|
52
|
+
return s
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def btn(text, tooltip=""):
|
|
56
|
+
b = QPushButton(text)
|
|
57
|
+
b.setToolTip(tooltip)
|
|
58
|
+
action_button(b, expand=True)
|
|
59
|
+
return b
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _patch_label_autosize(label) -> None:
|
|
63
|
+
"""Override the superqt SliderLabel's internal size calculation so it
|
|
64
|
+
fits the widest value it can display, formatted with the configured
|
|
65
|
+
decimals.
|
|
66
|
+
|
|
67
|
+
superqt's stock ``_get_size`` widths labels from ``str(minimum())`` /
|
|
68
|
+
``str(maximum())`` — which drops trailing zeros (``str(1.0) == "1.0"``)
|
|
69
|
+
and ignores decimals, so e.g. a (-10, 10) range with 1 decimal sizes
|
|
70
|
+
the label for ``"-10.0"`` but then ``str(-10.0) == "-10.0"``... fine
|
|
71
|
+
in that case, while a (0, 1) range with 2 decimals sizes for ``"1.0"``
|
|
72
|
+
(3 chars) and clips the displayed ``"1.00"``. Style padding/font
|
|
73
|
+
tweaks compound this. We replace ``_get_size`` on the instance so
|
|
74
|
+
that every subsequent ``_update_size`` (rangeChanged, showEvent…)
|
|
75
|
+
re-derives the size from the actual format width plus headroom for
|
|
76
|
+
chrome and a possible minus sign.
|
|
77
|
+
"""
|
|
78
|
+
def _get_size():
|
|
79
|
+
dec = label.decimals() if hasattr(label, "decimals") else 0
|
|
80
|
+
|
|
81
|
+
def _fmt(v):
|
|
82
|
+
return f"{v:.{dec}f}" if dec else f"{int(v)}"
|
|
83
|
+
lo, hi = label.minimum(), label.maximum()
|
|
84
|
+
sample = max((_fmt(lo), _fmt(hi)), key=len)
|
|
85
|
+
# ensure room for a minus sign even if both ends are non-negative
|
|
86
|
+
# (the user may type one into the editable label).
|
|
87
|
+
if not sample.startswith("-"):
|
|
88
|
+
sample = "-" + sample
|
|
89
|
+
fm = label.fontMetrics()
|
|
90
|
+
prefix = label.prefix() or ""
|
|
91
|
+
suffix = label.suffix() or ""
|
|
92
|
+
w = fm.horizontalAdvance(prefix + sample + suffix) + 18
|
|
93
|
+
h = label.sizeHint().height()
|
|
94
|
+
opt = QStyleOption()
|
|
95
|
+
return label.style().sizeFromContents(
|
|
96
|
+
QStyle.ContentsType.CT_LineEdit, opt, QSize(w, h), label
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
label._get_size = _get_size
|
|
100
|
+
label._update_size()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _slider_step_button(text: str, object_name: str, tooltip: str) -> QToolButton:
|
|
104
|
+
button = QToolButton()
|
|
105
|
+
button.setText(text)
|
|
106
|
+
button.setObjectName(object_name)
|
|
107
|
+
button.setToolTip(tooltip)
|
|
108
|
+
button.setAutoRepeat(True)
|
|
109
|
+
button.setFixedSize(18, 18)
|
|
110
|
+
button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
111
|
+
return button
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _SliderStepButtonStateSyncer(QObject):
|
|
115
|
+
def __init__(self, sync_button_state) -> None:
|
|
116
|
+
super().__init__()
|
|
117
|
+
self._sync_button_state = sync_button_state
|
|
118
|
+
|
|
119
|
+
def eventFilter(self, watched, event) -> bool:
|
|
120
|
+
if event.type() == QEvent.Type.EnabledChange:
|
|
121
|
+
self._sync_button_state()
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _connect_slider_step_buttons(slider) -> tuple[QToolButton, QToolButton]:
|
|
126
|
+
decrement = _slider_step_button(
|
|
127
|
+
"-", "slider_decrement_button", "Decrease by one step"
|
|
128
|
+
)
|
|
129
|
+
increment = _slider_step_button(
|
|
130
|
+
"+", "slider_increment_button", "Increase by one step"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _set_stepped_value(direction: int) -> None:
|
|
134
|
+
if not slider.isEnabled():
|
|
135
|
+
return
|
|
136
|
+
slider.setValue(slider.value() + direction * slider.singleStep())
|
|
137
|
+
|
|
138
|
+
def _sync_button_state(*_args) -> None:
|
|
139
|
+
enabled = slider.isEnabled()
|
|
140
|
+
decrement.setEnabled(enabled and slider.value() > slider.minimum())
|
|
141
|
+
increment.setEnabled(enabled and slider.value() < slider.maximum())
|
|
142
|
+
|
|
143
|
+
decrement.clicked.connect(lambda: _set_stepped_value(-1))
|
|
144
|
+
increment.clicked.connect(lambda: _set_stepped_value(1))
|
|
145
|
+
slider.valueChanged.connect(_sync_button_state)
|
|
146
|
+
slider.rangeChanged.connect(_sync_button_state)
|
|
147
|
+
state_syncer = _SliderStepButtonStateSyncer(_sync_button_state)
|
|
148
|
+
state_syncer.setParent(slider)
|
|
149
|
+
slider.installEventFilter(state_syncer)
|
|
150
|
+
slider._itasc_slider_step_button_state_syncer = state_syncer
|
|
151
|
+
_sync_button_state()
|
|
152
|
+
return decrement, increment
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_slider_step_buttons(
|
|
156
|
+
layout: QHBoxLayout, slider, track: QWidget | None = None
|
|
157
|
+
) -> tuple[QToolButton, QToolButton]:
|
|
158
|
+
track = slider if track is None else track
|
|
159
|
+
decrement, increment = _connect_slider_step_buttons(slider)
|
|
160
|
+
layout.addWidget(decrement, alignment=Qt.AlignmentFlag.AlignVCenter)
|
|
161
|
+
layout.addWidget(track)
|
|
162
|
+
layout.addWidget(increment, alignment=Qt.AlignmentFlag.AlignVCenter)
|
|
163
|
+
return decrement, increment
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _stack_slider_label_above(slider, *, step_buttons: bool = False) -> None:
|
|
167
|
+
"""Repack a QLabeledSlider / QLabeledDoubleSlider so the editable value
|
|
168
|
+
label sits centered above the slider track instead of beside it."""
|
|
169
|
+
label = slider._label
|
|
170
|
+
track = slider._slider
|
|
171
|
+
label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
172
|
+
label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
|
173
|
+
_patch_label_autosize(label)
|
|
174
|
+
|
|
175
|
+
old_layout = slider.layout()
|
|
176
|
+
if old_layout is not None:
|
|
177
|
+
old_layout.removeWidget(label)
|
|
178
|
+
old_layout.removeWidget(track)
|
|
179
|
+
QWidget().setLayout(old_layout)
|
|
180
|
+
label.setParent(slider)
|
|
181
|
+
track.setParent(slider)
|
|
182
|
+
vbox = QVBoxLayout()
|
|
183
|
+
vbox.setContentsMargins(0, 0, 0, 0)
|
|
184
|
+
vbox.setSpacing(0)
|
|
185
|
+
vbox.addWidget(label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
186
|
+
if step_buttons:
|
|
187
|
+
row = QHBoxLayout()
|
|
188
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
189
|
+
row.setSpacing(2)
|
|
190
|
+
add_slider_step_buttons(row, slider, track)
|
|
191
|
+
vbox.addLayout(row)
|
|
192
|
+
else:
|
|
193
|
+
vbox.addWidget(track)
|
|
194
|
+
slider.setLayout(vbox)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def dslider(lo, hi, val, step=0.1, decimals=2, tooltip="", *, step_buttons=True):
|
|
198
|
+
"""A horizontal QLabeledDoubleSlider — same call signature as `dspin`.
|
|
199
|
+
|
|
200
|
+
The editable value label sits above the slider track for a compact,
|
|
201
|
+
wide-track look."""
|
|
202
|
+
s = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
|
|
203
|
+
s.setRange(lo, hi)
|
|
204
|
+
s.setValue(val)
|
|
205
|
+
s.setSingleStep(step)
|
|
206
|
+
s.setDecimals(decimals)
|
|
207
|
+
s.setToolTip(tooltip)
|
|
208
|
+
s.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
209
|
+
s.setProperty("itasc_stack_section_label", True)
|
|
210
|
+
_stack_slider_label_above(s, step_buttons=step_buttons)
|
|
211
|
+
return s
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def islider(lo, hi, val, step=1, tooltip="", *, step_buttons=True):
|
|
215
|
+
"""A horizontal QLabeledSlider — same call signature as `ispin`.
|
|
216
|
+
|
|
217
|
+
The editable value label sits above the slider track."""
|
|
218
|
+
s = QLabeledSlider(Qt.Orientation.Horizontal)
|
|
219
|
+
s.setRange(lo, hi)
|
|
220
|
+
s.setValue(val)
|
|
221
|
+
s.setSingleStep(step)
|
|
222
|
+
s.setToolTip(tooltip)
|
|
223
|
+
s.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
224
|
+
s.setProperty("itasc_stack_section_label", True)
|
|
225
|
+
_stack_slider_label_above(s, step_buttons=step_buttons)
|
|
226
|
+
return s
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def tool_btn(glyph: str, tooltip: str = "", *, checkable: bool = False) -> QToolButton:
|
|
230
|
+
"""Compact icon-only QToolButton carrying a unicode glyph and a tooltip."""
|
|
231
|
+
b = QToolButton()
|
|
232
|
+
b.setText(glyph)
|
|
233
|
+
b.setToolTip(tooltip)
|
|
234
|
+
b.setCheckable(checkable)
|
|
235
|
+
b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
236
|
+
return b
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Aggregate capstone: pool every processed position into project-level tables.
|
|
2
|
+
|
|
3
|
+
The main app's project-level bookend to the per-position sections. Reads the same
|
|
4
|
+
catalog records the ``ExperimentsPanel`` builds, and drives the headless engine
|
|
5
|
+
(``author_config`` then ``pipeline.run``). Pool-only: it aggregates positions
|
|
6
|
+
whose ``contact_analysis.h5`` already exists and never builds missing ones, so ``run`` is
|
|
7
|
+
load-and-pool with no per-position recompute. Plotting is a downstream concern.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import tifffile
|
|
14
|
+
from napari.qt.threading import thread_worker
|
|
15
|
+
from napari.utils.notifications import show_error, show_info
|
|
16
|
+
from qtpy.QtWidgets import (
|
|
17
|
+
QCheckBox,
|
|
18
|
+
QDoubleSpinBox,
|
|
19
|
+
QHBoxLayout,
|
|
20
|
+
QLabel,
|
|
21
|
+
QListWidget,
|
|
22
|
+
QProgressBar,
|
|
23
|
+
QPushButton,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from itasc.contact_analysis import author_config, run
|
|
29
|
+
from itasc.contact_analysis.quantifier import available_quantifiers
|
|
30
|
+
from itasc.contact_analysis.records import supported_quantities
|
|
31
|
+
from itasc.contact_analysis.shape_tables import catalogue_root
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _lateral_pixels(path) -> int | None:
|
|
35
|
+
"""The ``Y * X`` pixel count of a label image's lateral field, or ``None``.
|
|
36
|
+
|
|
37
|
+
Reads only the TIFF header (not the pixel data), so it is cheap to call on every
|
|
38
|
+
records refresh. Returns ``None`` when *path* is missing, unreadable, or not at
|
|
39
|
+
least 2-D. The field-of-view autofill multiplies this by ``pixel_size_um²``.
|
|
40
|
+
"""
|
|
41
|
+
if not path:
|
|
42
|
+
return None
|
|
43
|
+
p = Path(path)
|
|
44
|
+
if not p.is_file():
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
with tifffile.TiffFile(p) as tif:
|
|
48
|
+
shape = tif.series[0].shape
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
if len(shape) < 2:
|
|
52
|
+
return None
|
|
53
|
+
return int(shape[-2]) * int(shape[-1])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pooled_quantifiers():
|
|
57
|
+
"""The registered quantifiers that pool into a project table, in display order.
|
|
58
|
+
|
|
59
|
+
Only quantifiers declaring ``table_keys`` produce an aggregated CSV; producers
|
|
60
|
+
(e.g. ``contacts``) are excluded. :func:`available_quantifiers` already returns
|
|
61
|
+
them sorted by ``display_name``, which is the order the checkbox list shows.
|
|
62
|
+
"""
|
|
63
|
+
return [cls for cls in available_quantifiers() if cls.table_keys]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def partition_ready(records):
|
|
67
|
+
"""Split catalog *records* into ``(ready, not_ready)`` by ``contact_analysis.h5``.
|
|
68
|
+
|
|
69
|
+
A record is *ready* when its ``contact_analysis_path`` exists on disk.
|
|
70
|
+
"""
|
|
71
|
+
ready, not_ready = [], []
|
|
72
|
+
for rec in records:
|
|
73
|
+
path = rec.get("contact_analysis_path")
|
|
74
|
+
if path is not None and Path(path).exists():
|
|
75
|
+
ready.append(rec)
|
|
76
|
+
else:
|
|
77
|
+
not_ready.append(rec)
|
|
78
|
+
return ready, not_ready
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _position_name(record) -> str:
|
|
82
|
+
path = record.get("position_path")
|
|
83
|
+
return Path(path).name if path else "(unknown)"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pool_positions(ready_records, skipped_names, quantities=(), params=None):
|
|
87
|
+
"""Author the project artifacts for *ready_records* and run the engine.
|
|
88
|
+
|
|
89
|
+
Writes ``catalog.csv`` + ``config.toml`` into the ready positions' common
|
|
90
|
+
ancestor (:func:`catalogue_root`), then ``run``s the pipeline over them.
|
|
91
|
+
*quantities* is the checked subset of pooled quantities to write (empty = every
|
|
92
|
+
available table); it is authored into the config and restricts which tables the
|
|
93
|
+
pool-only run emits. *params* are the shared build knobs (pixel size, frame
|
|
94
|
+
length, FOV area) authored into the config's ``[params]`` so the pooled cheap
|
|
95
|
+
quantities compute in physical units. Returns a result dict for the UI: the
|
|
96
|
+
``name -> path`` table map, the ``skipped`` position names, and the
|
|
97
|
+
``project_dir`` the tables landed under.
|
|
98
|
+
"""
|
|
99
|
+
project_dir = catalogue_root(ready_records)
|
|
100
|
+
config_path = author_config(
|
|
101
|
+
project_dir, ready_records, quantities=tuple(quantities), params=params or None
|
|
102
|
+
)
|
|
103
|
+
# Pool-only: read each position's existing contact_analysis.h5 and pool it (plus the
|
|
104
|
+
# in-memory cheap quantities). build=False skips the producer's unconditional
|
|
105
|
+
# rebuild, so ready positions are loaded, never recomputed.
|
|
106
|
+
tables = run(config_path, build=False)
|
|
107
|
+
return {
|
|
108
|
+
"tables": tables,
|
|
109
|
+
"skipped": list(skipped_names),
|
|
110
|
+
"project_dir": project_dir,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AggregateWidget(QWidget):
|
|
115
|
+
"""Project-level capstone: pool every ready position into tidy tables.
|
|
116
|
+
|
|
117
|
+
Fed catalog records via :meth:`set_records` (the same records the app's
|
|
118
|
+
``ExperimentsPanel`` builds). Pool-only: Run aggregates positions whose
|
|
119
|
+
``contact_analysis.h5`` exists and reports the ones it skipped by name.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
123
|
+
super().__init__(parent)
|
|
124
|
+
self._records: list[dict] = []
|
|
125
|
+
self._worker = None
|
|
126
|
+
self._has_run = False
|
|
127
|
+
self._fov_user_edited = False
|
|
128
|
+
|
|
129
|
+
layout = QVBoxLayout(self)
|
|
130
|
+
self.subtitle = QLabel(
|
|
131
|
+
"Pools every processed position into project-level tables."
|
|
132
|
+
)
|
|
133
|
+
self.subtitle.setWordWrap(True)
|
|
134
|
+
self.readout = QLabel("No data folders yet.")
|
|
135
|
+
self.readout.setWordWrap(True)
|
|
136
|
+
self.quantities_label = QLabel("Quantities to pool:")
|
|
137
|
+
# One checkbox per pooled quantity, all on by default. A box is greyed out
|
|
138
|
+
# (disabled + unchecked) when no ready position carries the inputs/params it
|
|
139
|
+
# needs, so the list also reads as "what this stage computes", and an
|
|
140
|
+
# enabled box never promises a table the run would skip. See
|
|
141
|
+
# :func:`~itasc.contact_analysis.records.supported_quantities`.
|
|
142
|
+
self._checks: dict[str, QCheckBox] = {}
|
|
143
|
+
for cls in pooled_quantifiers():
|
|
144
|
+
box = QCheckBox(cls.display_name)
|
|
145
|
+
box.setChecked(True)
|
|
146
|
+
box.setToolTip(cls.quantity_id)
|
|
147
|
+
self._checks[cls.quantity_id] = box
|
|
148
|
+
# Cell density's field-of-view area is a build param with no PositionInputs
|
|
149
|
+
# field, so it gets its own input beside the density checkbox. Autofilled
|
|
150
|
+
# from image size x pixel size (see :meth:`_maybe_autofill_fov`); the user
|
|
151
|
+
# can override, after which autofill leaves it alone.
|
|
152
|
+
self.fov_field = QDoubleSpinBox()
|
|
153
|
+
# Entered in µm² (a microscope field is a handful of ×10⁴ µm², which reads
|
|
154
|
+
# cleanly, where the mm² value would be a long decimal). Converted to the
|
|
155
|
+
# backend's mm² param in :meth:`_current_params`. 2 decimals is 0.01 µm²
|
|
156
|
+
# resolution — ample, and no risk of rounding a real area down to zero.
|
|
157
|
+
self.fov_field.setDecimals(2)
|
|
158
|
+
self.fov_field.setRange(0.0, 1e12)
|
|
159
|
+
self.fov_field.setSuffix(" µm²")
|
|
160
|
+
self.fov_field.setToolTip(
|
|
161
|
+
"Field-of-view area for Cell density. Autofilled from image size times "
|
|
162
|
+
"pixel size; edit to override."
|
|
163
|
+
)
|
|
164
|
+
self.fov_field.valueChanged.connect(self._on_fov_changed)
|
|
165
|
+
self.run_btn = QPushButton("Pool ready positions")
|
|
166
|
+
self.run_btn.clicked.connect(self._on_run)
|
|
167
|
+
self.progress = QProgressBar()
|
|
168
|
+
self.progress.setVisible(False)
|
|
169
|
+
self.results = QListWidget()
|
|
170
|
+
self.status = QLabel("")
|
|
171
|
+
self.status.setWordWrap(True)
|
|
172
|
+
for widget in (self.subtitle, self.readout, self.quantities_label):
|
|
173
|
+
layout.addWidget(widget)
|
|
174
|
+
for qid, box in self._checks.items():
|
|
175
|
+
if qid == "cell_density":
|
|
176
|
+
row = QHBoxLayout()
|
|
177
|
+
row.addWidget(box)
|
|
178
|
+
row.addWidget(QLabel("FOV area (µm²):"))
|
|
179
|
+
row.addWidget(self.fov_field)
|
|
180
|
+
row.addStretch()
|
|
181
|
+
layout.addLayout(row)
|
|
182
|
+
else:
|
|
183
|
+
layout.addWidget(box)
|
|
184
|
+
for widget in (self.run_btn, self.progress, self.results, self.status):
|
|
185
|
+
layout.addWidget(widget)
|
|
186
|
+
self._refresh_readout()
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------ inputs
|
|
189
|
+
def set_records(self, records) -> None:
|
|
190
|
+
"""Replace the catalog records the readiness readout reflects."""
|
|
191
|
+
self._records = list(records or [])
|
|
192
|
+
self._maybe_autofill_fov()
|
|
193
|
+
self._refresh_readout()
|
|
194
|
+
|
|
195
|
+
def _current_params(self) -> dict:
|
|
196
|
+
"""The shared build knobs for greying + the run: calibration off the records
|
|
197
|
+
(pixel size, frame length are global, so the first record is representative)
|
|
198
|
+
plus the field-of-view area from :attr:`fov_field` (omitted when zero)."""
|
|
199
|
+
params: dict[str, float] = {}
|
|
200
|
+
if self._records:
|
|
201
|
+
first = self._records[0]
|
|
202
|
+
for key in ("pixel_size_um", "time_interval_s"):
|
|
203
|
+
value = first.get(key)
|
|
204
|
+
if value in (None, ""):
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
params[key] = float(value)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
pass
|
|
210
|
+
fov_um2 = self.fov_field.value()
|
|
211
|
+
if fov_um2 > 0:
|
|
212
|
+
# The field is µm²; the backend's Cell density param is mm² (density is
|
|
213
|
+
# reported in cells/mm²).
|
|
214
|
+
params["fov_area_mm2"] = fov_um2 / 1e6
|
|
215
|
+
return params
|
|
216
|
+
|
|
217
|
+
def _maybe_autofill_fov(self) -> None:
|
|
218
|
+
"""Prefill the FOV field (µm²) from ``image lateral pixels x pixel_size_um²``,
|
|
219
|
+
reading a ready position's cell-label image. No-ops once the user has edited
|
|
220
|
+
the field, when no pixel size is set, or when no image is readable."""
|
|
221
|
+
if self._fov_user_edited:
|
|
222
|
+
return
|
|
223
|
+
pixel_size = self._current_params().get("pixel_size_um")
|
|
224
|
+
if not pixel_size:
|
|
225
|
+
return
|
|
226
|
+
ready, _ = partition_ready(self._records)
|
|
227
|
+
for record in ready:
|
|
228
|
+
n_pixels = _lateral_pixels(record.get("cell_tracked_labels_path"))
|
|
229
|
+
if n_pixels:
|
|
230
|
+
fov_um2 = n_pixels * pixel_size * pixel_size
|
|
231
|
+
self.fov_field.blockSignals(True)
|
|
232
|
+
self.fov_field.setValue(fov_um2)
|
|
233
|
+
self.fov_field.blockSignals(False)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
def _on_fov_changed(self, _value) -> None:
|
|
237
|
+
"""A user edit to the FOV field: remember it (so autofill backs off) and
|
|
238
|
+
re-grey, since a non-zero area lifts Cell density into support."""
|
|
239
|
+
self._fov_user_edited = True
|
|
240
|
+
self._refresh_quantities(partition_ready(self._records)[0])
|
|
241
|
+
|
|
242
|
+
def section_status(self) -> str:
|
|
243
|
+
"""Status for the enclosing section dot: not_started / in_progress / done."""
|
|
244
|
+
ready, _ = partition_ready(self._records)
|
|
245
|
+
if not ready:
|
|
246
|
+
return "not_started"
|
|
247
|
+
return "done" if self._has_run else "in_progress"
|
|
248
|
+
|
|
249
|
+
# --------------------------------------------------------------- rendering
|
|
250
|
+
def _refresh_readout(self) -> None:
|
|
251
|
+
ready, not_ready = partition_ready(self._records)
|
|
252
|
+
total = len(self._records)
|
|
253
|
+
if total == 0:
|
|
254
|
+
self.readout.setText("No data folders yet.")
|
|
255
|
+
else:
|
|
256
|
+
message = f"{len(ready)} of {total} positions analyzed"
|
|
257
|
+
if not_ready:
|
|
258
|
+
names = ", ".join(_position_name(r) for r in not_ready)
|
|
259
|
+
message += f" — not yet ready: {names}"
|
|
260
|
+
self.readout.setText(message)
|
|
261
|
+
self._refresh_quantities(ready)
|
|
262
|
+
self.run_btn.setEnabled(bool(ready) and self._worker is None)
|
|
263
|
+
|
|
264
|
+
def _refresh_quantities(self, ready) -> None:
|
|
265
|
+
"""Enable a quantity's checkbox iff a ready position can produce its table.
|
|
266
|
+
|
|
267
|
+
Greys out (disables + unchecks) any quantity whose required inputs/params
|
|
268
|
+
are absent from every ready position; re-enables and re-checks one that
|
|
269
|
+
becomes supported again. A deliberate uncheck of a *supported* quantity is
|
|
270
|
+
preserved: only the supported/unsupported transition touches the check.
|
|
271
|
+
"""
|
|
272
|
+
supported = supported_quantities(ready, params=self._current_params() or None)
|
|
273
|
+
for qid, box in self._checks.items():
|
|
274
|
+
was_enabled = box.isEnabled()
|
|
275
|
+
now = qid in supported
|
|
276
|
+
box.setEnabled(now)
|
|
277
|
+
box.setToolTip(
|
|
278
|
+
qid
|
|
279
|
+
if now
|
|
280
|
+
else f"{qid}: required inputs or parameters not available for any ready position"
|
|
281
|
+
)
|
|
282
|
+
if not now:
|
|
283
|
+
box.setChecked(False)
|
|
284
|
+
elif not was_enabled:
|
|
285
|
+
box.setChecked(True)
|
|
286
|
+
|
|
287
|
+
def _selected_quantities(self) -> tuple[str, ...]:
|
|
288
|
+
"""The checked quantities to author into the config.
|
|
289
|
+
|
|
290
|
+
Collapses to ``()`` (= write every available table) when the user has left
|
|
291
|
+
every *supported* quantity checked, so a default run writes a clean config;
|
|
292
|
+
any deliberate uncheck yields the explicit checked subset instead.
|
|
293
|
+
"""
|
|
294
|
+
checked = {qid for qid, box in self._checks.items() if box.isChecked()}
|
|
295
|
+
supported = {qid for qid, box in self._checks.items() if box.isEnabled()}
|
|
296
|
+
return () if checked == supported else tuple(sorted(checked))
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------- run
|
|
299
|
+
def _on_run(self) -> None:
|
|
300
|
+
ready, not_ready = partition_ready(self._records)
|
|
301
|
+
if not ready:
|
|
302
|
+
show_info("No analyzed positions to pool.")
|
|
303
|
+
return
|
|
304
|
+
skipped = [_position_name(r) for r in not_ready]
|
|
305
|
+
quantities = self._selected_quantities()
|
|
306
|
+
params = self._current_params()
|
|
307
|
+
self.results.clear()
|
|
308
|
+
self.status.setText("Pooling…")
|
|
309
|
+
self.progress.setVisible(True)
|
|
310
|
+
self.progress.setRange(0, 0)
|
|
311
|
+
self.run_btn.setEnabled(False)
|
|
312
|
+
|
|
313
|
+
@thread_worker(
|
|
314
|
+
connect={"returned": self._on_done, "errored": self._on_error}
|
|
315
|
+
)
|
|
316
|
+
def _work():
|
|
317
|
+
return pool_positions(ready, skipped, quantities, params)
|
|
318
|
+
|
|
319
|
+
self._worker = _work()
|
|
320
|
+
|
|
321
|
+
def _on_done(self, result: dict) -> None:
|
|
322
|
+
self._worker = None
|
|
323
|
+
self._has_run = True
|
|
324
|
+
self.progress.setVisible(False)
|
|
325
|
+
for name, path in sorted(result["tables"].items()):
|
|
326
|
+
self.results.addItem(f"{name}: {path}")
|
|
327
|
+
message = f"Pooled into {result['project_dir']}."
|
|
328
|
+
if result["skipped"]:
|
|
329
|
+
message += f" Skipped (not analyzed): {', '.join(result['skipped'])}."
|
|
330
|
+
self.status.setText(message)
|
|
331
|
+
show_info(message)
|
|
332
|
+
self._refresh_readout()
|
|
333
|
+
|
|
334
|
+
def _on_error(self, exc: Exception) -> None:
|
|
335
|
+
self._worker = None
|
|
336
|
+
self.progress.setVisible(False)
|
|
337
|
+
self.status.setText(f"Aggregate failed: {exc}")
|
|
338
|
+
show_error(f"Aggregate failed: {exc}")
|
|
339
|
+
self._refresh_readout()
|