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,94 @@
|
|
|
1
|
+
"""The Qt-free editing controller behind the napari curation tool.
|
|
2
|
+
|
|
3
|
+
It is the single seam the :class:`CurationWidget` talks to: it owns the curation
|
|
4
|
+
CSV path, loads it on construction (empty when absent), mutates it through the
|
|
5
|
+
pure authoring ops in :mod:`itasc.contact_analysis.curation`, and
|
|
6
|
+
**auto-saves** after every change so the table is always the source of truth. It
|
|
7
|
+
exposes per-position queries so the widget renders badges/lists without touching
|
|
8
|
+
pandas. Keeping it Qt-free keeps the table logic unit-testable headless.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from itasc.contact_analysis.curation import (
|
|
17
|
+
append_exclusion,
|
|
18
|
+
empty_curation,
|
|
19
|
+
read_curation,
|
|
20
|
+
remove_exclusion,
|
|
21
|
+
write_curation,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CurationController:
|
|
26
|
+
"""Load → mutate-and-autosave → query over one curation CSV."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: Path | str) -> None:
|
|
29
|
+
self.path = Path(path)
|
|
30
|
+
loaded = read_curation(self.path)
|
|
31
|
+
self.curation = empty_curation() if loaded is None else loaded
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------- mutations
|
|
34
|
+
def exclude_frame(
|
|
35
|
+
self, *, experiment_id: str, position_id: str, frame: int, reason: str
|
|
36
|
+
) -> None:
|
|
37
|
+
self.curation = append_exclusion(
|
|
38
|
+
self.curation,
|
|
39
|
+
experiment_id=experiment_id,
|
|
40
|
+
position_id=position_id,
|
|
41
|
+
frame=int(frame),
|
|
42
|
+
reason=reason,
|
|
43
|
+
)
|
|
44
|
+
self._save()
|
|
45
|
+
|
|
46
|
+
def exclude_position(
|
|
47
|
+
self, *, experiment_id: str, position_id: str, reason: str
|
|
48
|
+
) -> None:
|
|
49
|
+
self.curation = append_exclusion(
|
|
50
|
+
self.curation,
|
|
51
|
+
experiment_id=experiment_id,
|
|
52
|
+
position_id=position_id,
|
|
53
|
+
frame=None,
|
|
54
|
+
reason=reason,
|
|
55
|
+
)
|
|
56
|
+
self._save()
|
|
57
|
+
|
|
58
|
+
def remove(
|
|
59
|
+
self, *, experiment_id: str, position_id: str, frame: int | None
|
|
60
|
+
) -> None:
|
|
61
|
+
self.curation = remove_exclusion(
|
|
62
|
+
self.curation,
|
|
63
|
+
experiment_id=experiment_id,
|
|
64
|
+
position_id=position_id,
|
|
65
|
+
frame=None if frame is None else int(frame),
|
|
66
|
+
)
|
|
67
|
+
self._save()
|
|
68
|
+
|
|
69
|
+
# ----------------------------------------------------------------- queries
|
|
70
|
+
def exclusions_for(self, *, experiment_id: str, position_id: str) -> pd.DataFrame:
|
|
71
|
+
"""The exclusion rows for one position (reset index), for the widget list."""
|
|
72
|
+
cur = self.curation
|
|
73
|
+
key = (cur["experiment_id"].astype(str) == str(experiment_id)) & (
|
|
74
|
+
cur["position_id"].astype(str) == str(position_id)
|
|
75
|
+
)
|
|
76
|
+
return cur.loc[key].reset_index(drop=True)
|
|
77
|
+
|
|
78
|
+
def is_position_excluded(self, *, experiment_id: str, position_id: str) -> bool:
|
|
79
|
+
rows = self.exclusions_for(experiment_id=experiment_id, position_id=position_id)
|
|
80
|
+
return bool(rows["frame"].isna().any())
|
|
81
|
+
|
|
82
|
+
def is_frame_excluded(
|
|
83
|
+
self, *, experiment_id: str, position_id: str, frame: int
|
|
84
|
+
) -> bool:
|
|
85
|
+
rows = self.exclusions_for(experiment_id=experiment_id, position_id=position_id)
|
|
86
|
+
if rows.empty:
|
|
87
|
+
return False
|
|
88
|
+
if rows["frame"].isna().any():
|
|
89
|
+
return True
|
|
90
|
+
return bool((pd.to_numeric(rows["frame"], errors="coerce") == float(frame)).any())
|
|
91
|
+
|
|
92
|
+
# -------------------------------------------------------------------- save
|
|
93
|
+
def _save(self) -> None:
|
|
94
|
+
write_curation(self.path, self.curation)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Analysis-plugin contract and in-process registry.
|
|
2
|
+
|
|
3
|
+
The Contact Analysis studio is a thin base that manages a *catalog* of
|
|
4
|
+
positions and hosts pluggable analysis views on top. A plugin is a ``QWidget``
|
|
5
|
+
subclass of :class:`AnalysisPlugin` that declares a ``plugin_id`` +
|
|
6
|
+
``display_name`` and implements :meth:`~AnalysisPlugin.set_context`; the base
|
|
7
|
+
instantiates it and feeds it the currently-selected catalog records.
|
|
8
|
+
|
|
9
|
+
Plugins are **not** napari manifest contributions — they must not show up as
|
|
10
|
+
top-level dock widgets alongside the real tools. Instead, subclassing
|
|
11
|
+
:class:`AnalysisPlugin` auto-registers the plugin (via ``__init_subclass__``),
|
|
12
|
+
and :func:`available_analysis_plugins` imports every module in this package so
|
|
13
|
+
that dropping a new ``*.py`` file here is all it takes to add a plugin.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import pkgutil
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from qtpy.QtWidgets import QWidget
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AnalysisContext",
|
|
28
|
+
"AnalysisPlugin",
|
|
29
|
+
"available_analysis_plugins",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AnalysisContext:
|
|
35
|
+
"""What the base hands a plugin: the in-scope catalog rows + a cached loader.
|
|
36
|
+
|
|
37
|
+
``records`` are normalized catalog dicts (see
|
|
38
|
+
:mod:`itasc.contact_analysis.catalog`); each has at least
|
|
39
|
+
``contact_analysis_path``, ``condition``, ``date``, ``id`` and
|
|
40
|
+
``contact_analysis_status``. ``load`` reads (and caches) the
|
|
41
|
+
:class:`~itasc.contact_analysis.contacts.reader.PositionContactAnalysis` for a row,
|
|
42
|
+
so plugins do not each re-open the same HDF5.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
records: list[dict] = field(default_factory=list)
|
|
46
|
+
viewer: Any | None = None
|
|
47
|
+
#: Injected by the base; maps a contact-analysis path to its parsed object.
|
|
48
|
+
loader: Callable[[Path], Any] | None = None
|
|
49
|
+
|
|
50
|
+
def load(self, record: dict) -> Any:
|
|
51
|
+
if self.loader is None:
|
|
52
|
+
raise RuntimeError("AnalysisContext has no loader configured")
|
|
53
|
+
return self.loader(Path(record["contact_analysis_path"]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: plugin_id -> plugin class, populated by ``__init_subclass__``.
|
|
57
|
+
_REGISTRY: dict[str, type[AnalysisPlugin]] = {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AnalysisPlugin(QWidget):
|
|
61
|
+
"""Base class for Contact Analysis analysis plugins.
|
|
62
|
+
|
|
63
|
+
Subclasses set the ``plugin_id`` / ``display_name`` class attributes and
|
|
64
|
+
override :meth:`set_context`. Defining a subclass with a non-empty
|
|
65
|
+
``plugin_id`` registers it; the Contact Analysis studio discovers it
|
|
66
|
+
via :func:`available_analysis_plugins`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
#: Stable key; an empty value marks an intermediate (non-registered) base.
|
|
70
|
+
plugin_id: ClassVar[str] = ""
|
|
71
|
+
#: Human-readable label shown in the studio plugin list.
|
|
72
|
+
display_name: ClassVar[str] = ""
|
|
73
|
+
#: ``PositionInputs`` field names this plugin needs; the studio greys its
|
|
74
|
+
#: checkbox for a selection where no in-scope position supplies them. Empty
|
|
75
|
+
#: means "always available" (e.g. a cohort summary).
|
|
76
|
+
requires: ClassVar[tuple[str, ...]] = ()
|
|
77
|
+
|
|
78
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
79
|
+
super().__init_subclass__(**kwargs)
|
|
80
|
+
if cls.plugin_id:
|
|
81
|
+
_REGISTRY[cls.plugin_id] = cls
|
|
82
|
+
|
|
83
|
+
def __init__(self, viewer: object | None = None, parent: QWidget | None = None) -> None:
|
|
84
|
+
super().__init__(parent)
|
|
85
|
+
self.viewer = viewer
|
|
86
|
+
|
|
87
|
+
def set_context(self, ctx: AnalysisContext) -> None: # pragma: no cover - overridden
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _import_plugin_modules() -> None:
|
|
92
|
+
"""Import every (non-private) submodule so its plugins self-register."""
|
|
93
|
+
for info in pkgutil.iter_modules(__path__):
|
|
94
|
+
if info.name.startswith("_"):
|
|
95
|
+
continue
|
|
96
|
+
importlib.import_module(f"{__name__}.{info.name}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def available_analysis_plugins() -> list[type[AnalysisPlugin]]:
|
|
100
|
+
"""Return registered plugin classes, sorted by display name."""
|
|
101
|
+
_import_plugin_modules()
|
|
102
|
+
return sorted(_REGISTRY.values(), key=lambda cls: cls.display_name.lower())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Trivial analysis plugin: summarise the in-scope catalog.
|
|
2
|
+
|
|
3
|
+
This plugin does no per-position analysis; it exists to prove the base ↔ plugin
|
|
4
|
+
seam end-to-end. It reports how many positions are in scope, how they break down
|
|
5
|
+
by condition, and how many are contact-analysis ``ready`` vs ``incomplete``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
|
|
12
|
+
|
|
13
|
+
from itasc.contact_analysis.catalog import STATUS_READY
|
|
14
|
+
from itasc.napari.contact_analysis.plugins import AnalysisContext, AnalysisPlugin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CatalogSummaryPlugin(AnalysisPlugin):
|
|
18
|
+
"""Show counts for the currently-selected catalog records."""
|
|
19
|
+
|
|
20
|
+
plugin_id = "catalog_summary"
|
|
21
|
+
display_name = "Catalog Summary"
|
|
22
|
+
|
|
23
|
+
def __init__(self, viewer=None, parent: QWidget | None = None) -> None:
|
|
24
|
+
super().__init__(viewer=viewer, parent=parent)
|
|
25
|
+
layout = QVBoxLayout(self)
|
|
26
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
27
|
+
layout.setSpacing(4)
|
|
28
|
+
self._summary_lbl = QLabel("No positions in scope.")
|
|
29
|
+
self._summary_lbl.setWordWrap(True)
|
|
30
|
+
layout.addWidget(self._summary_lbl)
|
|
31
|
+
layout.addStretch()
|
|
32
|
+
|
|
33
|
+
def set_context(self, ctx: AnalysisContext) -> None:
|
|
34
|
+
records = list(ctx.records)
|
|
35
|
+
if not records:
|
|
36
|
+
self._summary_lbl.setText("No positions in scope.")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
total = len(records)
|
|
40
|
+
ready = sum(
|
|
41
|
+
1
|
|
42
|
+
for record in records
|
|
43
|
+
if record.get("contact_analysis_status") == STATUS_READY
|
|
44
|
+
)
|
|
45
|
+
by_condition = Counter(
|
|
46
|
+
str(record.get("condition", "unknown_condition")) for record in records
|
|
47
|
+
)
|
|
48
|
+
condition_lines = "\n".join(
|
|
49
|
+
f" • {condition}: {count}"
|
|
50
|
+
for condition, count in sorted(by_condition.items())
|
|
51
|
+
)
|
|
52
|
+
self._summary_lbl.setText(
|
|
53
|
+
f"{total} position(s) in scope\n"
|
|
54
|
+
f" ready: {ready} incomplete: {total - ready}\n"
|
|
55
|
+
f"by condition:\n{condition_lines}"
|
|
56
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Curation tool: author the exclusion table by eye, over the contact display.
|
|
2
|
+
|
|
3
|
+
The image-linked judgement ("scrub through a position, see a bad frame, exclude
|
|
4
|
+
it with a note") is what re-earns napari for Contact Analysis. This
|
|
5
|
+
plugin embeds the contact-visualization display for one selected position and
|
|
6
|
+
turns exclude actions into rows of the curation CSV — via the Qt-free
|
|
7
|
+
:class:`~itasc.napari.contact_analysis.curation_controller.CurationController`,
|
|
8
|
+
which auto-saves so the table is always the source of truth. No plots live here
|
|
9
|
+
(plotting is a downstream concern).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from qtpy.QtWidgets import (
|
|
16
|
+
QHBoxLayout,
|
|
17
|
+
QLabel,
|
|
18
|
+
QLineEdit,
|
|
19
|
+
QListWidget,
|
|
20
|
+
QListWidgetItem,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from itasc.contact_analysis.shape_tables import catalogue_root
|
|
27
|
+
from itasc.napari.contact_analysis.curation_controller import (
|
|
28
|
+
CurationController,
|
|
29
|
+
)
|
|
30
|
+
from itasc.napari.contact_analysis.plugins import (
|
|
31
|
+
AnalysisContext,
|
|
32
|
+
AnalysisPlugin,
|
|
33
|
+
)
|
|
34
|
+
from itasc.napari.ui_style import action_button, status_label
|
|
35
|
+
|
|
36
|
+
#: Qt.UserRole — stores the row's frame (int) or None (whole position) on a list item.
|
|
37
|
+
_FRAME_ROLE = 256
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CurationWidget(AnalysisPlugin):
|
|
41
|
+
"""Mark a frame / a whole position excluded with a reason, over the image."""
|
|
42
|
+
|
|
43
|
+
plugin_id = "curation"
|
|
44
|
+
display_name = "Curation"
|
|
45
|
+
|
|
46
|
+
def __init__(self, viewer=None, parent: QWidget | None = None) -> None:
|
|
47
|
+
super().__init__(viewer=viewer, parent=parent)
|
|
48
|
+
from itasc.napari.contact_analysis_widget import (
|
|
49
|
+
ContactAnalysisWidget,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self._experiment_id: str | None = None
|
|
53
|
+
self._position_id: str | None = None
|
|
54
|
+
self._controller: CurationController | None = None
|
|
55
|
+
|
|
56
|
+
layout = QVBoxLayout(self)
|
|
57
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
58
|
+
layout.setSpacing(6)
|
|
59
|
+
|
|
60
|
+
self._scope_lbl = QLabel("Select a single position to curate.")
|
|
61
|
+
self._scope_lbl.setWordWrap(True)
|
|
62
|
+
layout.addWidget(self._scope_lbl)
|
|
63
|
+
|
|
64
|
+
self._path_edit = QLineEdit()
|
|
65
|
+
self._path_edit.setPlaceholderText("curation.csv (defaults beside the tables)…")
|
|
66
|
+
self._path_edit.editingFinished.connect(self._on_path_edited)
|
|
67
|
+
layout.addLayout(self._labelled_row("Curation file:", self._path_edit))
|
|
68
|
+
|
|
69
|
+
self._view = ContactAnalysisWidget(viewer=viewer, standalone=False)
|
|
70
|
+
self._view.pipeline_files_header.setVisible(False)
|
|
71
|
+
self._view._pipeline_files_section.setVisible(False)
|
|
72
|
+
layout.addWidget(self._view, 1)
|
|
73
|
+
|
|
74
|
+
self._reason_edit = QLineEdit()
|
|
75
|
+
self._reason_edit.setPlaceholderText("Reason (required)…")
|
|
76
|
+
self._reason_edit.textChanged.connect(self._update_enabled)
|
|
77
|
+
layout.addLayout(self._labelled_row("Reason:", self._reason_edit))
|
|
78
|
+
|
|
79
|
+
actions = QHBoxLayout()
|
|
80
|
+
actions.setContentsMargins(0, 0, 0, 0)
|
|
81
|
+
actions.setSpacing(4)
|
|
82
|
+
self._exclude_frame_btn = QPushButton("Exclude this frame")
|
|
83
|
+
action_button(self._exclude_frame_btn, expand=True)
|
|
84
|
+
self._exclude_frame_btn.clicked.connect(self._on_exclude_frame)
|
|
85
|
+
self._exclude_position_btn = QPushButton("Exclude this position")
|
|
86
|
+
action_button(self._exclude_position_btn, expand=True)
|
|
87
|
+
self._exclude_position_btn.clicked.connect(self._on_exclude_position)
|
|
88
|
+
actions.addWidget(self._exclude_frame_btn)
|
|
89
|
+
actions.addWidget(self._exclude_position_btn)
|
|
90
|
+
layout.addLayout(actions)
|
|
91
|
+
|
|
92
|
+
layout.addWidget(QLabel("Exclusions for this position:"))
|
|
93
|
+
self._exclusions_list = QListWidget()
|
|
94
|
+
self._exclusions_list.setMaximumHeight(120)
|
|
95
|
+
layout.addWidget(self._exclusions_list)
|
|
96
|
+
self._remove_btn = QPushButton("Remove selected")
|
|
97
|
+
action_button(self._remove_btn)
|
|
98
|
+
self._remove_btn.clicked.connect(self._on_remove_selected)
|
|
99
|
+
layout.addWidget(self._remove_btn)
|
|
100
|
+
|
|
101
|
+
self._status_lbl = QLabel("")
|
|
102
|
+
self._status_lbl.setWordWrap(True)
|
|
103
|
+
status_label(self._status_lbl)
|
|
104
|
+
layout.addWidget(self._status_lbl)
|
|
105
|
+
|
|
106
|
+
self._update_enabled()
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _labelled_row(label: str, edit: QWidget) -> QHBoxLayout:
|
|
110
|
+
row = QHBoxLayout()
|
|
111
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
112
|
+
row.setSpacing(4)
|
|
113
|
+
lbl = QLabel(label)
|
|
114
|
+
lbl.setFixedWidth(90)
|
|
115
|
+
row.addWidget(lbl)
|
|
116
|
+
row.addWidget(edit, 1)
|
|
117
|
+
return row
|
|
118
|
+
|
|
119
|
+
def _current_frame(self) -> int:
|
|
120
|
+
dims = getattr(self.viewer, "dims", None)
|
|
121
|
+
step = getattr(dims, "current_step", (0,))
|
|
122
|
+
return int(step[0]) if step else 0
|
|
123
|
+
|
|
124
|
+
def _has_single_position(self) -> bool:
|
|
125
|
+
return self._position_id is not None and self._controller is not None
|
|
126
|
+
|
|
127
|
+
def _reason(self) -> str:
|
|
128
|
+
return self._reason_edit.text().strip()
|
|
129
|
+
|
|
130
|
+
def set_context(self, ctx: AnalysisContext) -> None:
|
|
131
|
+
if ctx.viewer is not None:
|
|
132
|
+
self.viewer = ctx.viewer
|
|
133
|
+
records = list(ctx.records)
|
|
134
|
+
if len(records) == 1:
|
|
135
|
+
record = records[0]
|
|
136
|
+
self._experiment_id = str(record.get("experiment_id", ""))
|
|
137
|
+
self._position_id = str(record.get("id", ""))
|
|
138
|
+
self._ensure_controller(records)
|
|
139
|
+
self._update_display(record)
|
|
140
|
+
self._scope_lbl.setText(f"Curating position: {self._position_id}")
|
|
141
|
+
else:
|
|
142
|
+
self._experiment_id = None
|
|
143
|
+
self._position_id = None
|
|
144
|
+
self._controller = None
|
|
145
|
+
self._clear_display()
|
|
146
|
+
self._scope_lbl.setText(
|
|
147
|
+
"Select a single position to curate."
|
|
148
|
+
if not records
|
|
149
|
+
else f"{len(records)} positions selected — pick exactly one to curate."
|
|
150
|
+
)
|
|
151
|
+
self._refresh_exclusions()
|
|
152
|
+
self._update_enabled()
|
|
153
|
+
|
|
154
|
+
def _ensure_controller(self, records: list[dict]) -> None:
|
|
155
|
+
text = self._path_edit.text().strip()
|
|
156
|
+
if text:
|
|
157
|
+
path = Path(text)
|
|
158
|
+
else:
|
|
159
|
+
path = catalogue_root(records) / "curation.csv"
|
|
160
|
+
self._path_edit.setText(str(path))
|
|
161
|
+
self._controller = CurationController(path)
|
|
162
|
+
|
|
163
|
+
def _on_path_edited(self) -> None:
|
|
164
|
+
text = self._path_edit.text().strip()
|
|
165
|
+
if text:
|
|
166
|
+
self._controller = CurationController(Path(text))
|
|
167
|
+
self._refresh_exclusions()
|
|
168
|
+
self._update_enabled()
|
|
169
|
+
|
|
170
|
+
def _update_display(self, record: dict) -> None:
|
|
171
|
+
self._view.set_context(
|
|
172
|
+
cell_labels=record.get("cell_tracked_labels_path"),
|
|
173
|
+
nucleus_labels=record.get("nucleus_tracked_labels_path"),
|
|
174
|
+
out_path=record.get("contact_analysis_path"),
|
|
175
|
+
status_root=record.get("position_path"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _clear_display(self) -> None:
|
|
179
|
+
self._view.set_context(
|
|
180
|
+
cell_labels=None, nucleus_labels=None, out_path=None, status_root=None
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _update_enabled(self) -> None:
|
|
184
|
+
can_act = self._has_single_position() and bool(self._reason())
|
|
185
|
+
self._exclude_frame_btn.setEnabled(can_act)
|
|
186
|
+
self._exclude_position_btn.setEnabled(can_act)
|
|
187
|
+
self._remove_btn.setEnabled(self._has_single_position())
|
|
188
|
+
|
|
189
|
+
def _on_exclude_frame(self) -> None:
|
|
190
|
+
if not self._has_single_position() or not self._reason():
|
|
191
|
+
return
|
|
192
|
+
frame = self._current_frame()
|
|
193
|
+
self._controller.exclude_frame(
|
|
194
|
+
experiment_id=self._experiment_id,
|
|
195
|
+
position_id=self._position_id,
|
|
196
|
+
frame=frame,
|
|
197
|
+
reason=self._reason(),
|
|
198
|
+
)
|
|
199
|
+
self._status_lbl.setText(f"Status: excluded frame {frame}.")
|
|
200
|
+
self._refresh_exclusions()
|
|
201
|
+
|
|
202
|
+
def _on_exclude_position(self) -> None:
|
|
203
|
+
if not self._has_single_position() or not self._reason():
|
|
204
|
+
return
|
|
205
|
+
self._controller.exclude_position(
|
|
206
|
+
experiment_id=self._experiment_id,
|
|
207
|
+
position_id=self._position_id,
|
|
208
|
+
reason=self._reason(),
|
|
209
|
+
)
|
|
210
|
+
self._status_lbl.setText("Status: excluded whole position.")
|
|
211
|
+
self._refresh_exclusions()
|
|
212
|
+
|
|
213
|
+
def _on_remove_selected(self) -> None:
|
|
214
|
+
if not self._has_single_position():
|
|
215
|
+
return
|
|
216
|
+
item = self._exclusions_list.currentItem()
|
|
217
|
+
if item is None:
|
|
218
|
+
return
|
|
219
|
+
frame = item.data(_FRAME_ROLE)
|
|
220
|
+
self._controller.remove(
|
|
221
|
+
experiment_id=self._experiment_id,
|
|
222
|
+
position_id=self._position_id,
|
|
223
|
+
frame=frame,
|
|
224
|
+
)
|
|
225
|
+
self._status_lbl.setText("Status: removed exclusion.")
|
|
226
|
+
self._refresh_exclusions()
|
|
227
|
+
|
|
228
|
+
def _refresh_exclusions(self) -> None:
|
|
229
|
+
self._exclusions_list.clear()
|
|
230
|
+
if not self._has_single_position():
|
|
231
|
+
return
|
|
232
|
+
rows = self._controller.exclusions_for(
|
|
233
|
+
experiment_id=self._experiment_id, position_id=self._position_id
|
|
234
|
+
)
|
|
235
|
+
import pandas as pd
|
|
236
|
+
|
|
237
|
+
for _, row in rows.iterrows():
|
|
238
|
+
frame = row["frame"]
|
|
239
|
+
if pd.isna(frame):
|
|
240
|
+
text = f"whole position — {row['exclusion_reason']}"
|
|
241
|
+
payload = None
|
|
242
|
+
else:
|
|
243
|
+
text = f"frame {int(frame)} — {row['exclusion_reason']}"
|
|
244
|
+
payload = int(frame)
|
|
245
|
+
item = QListWidgetItem(text)
|
|
246
|
+
item.setData(_FRAME_ROLE, payload)
|
|
247
|
+
self._exclusions_list.addItem(item)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Per-position contact visualizer, as an analysis plugin.
|
|
2
|
+
|
|
3
|
+
This was a hardwired "Visualize position" section inside the studio's Catalogue
|
|
4
|
+
region. It is now a regular :class:`AnalysisPlugin` so it lives in the same list
|
|
5
|
+
as every other analysis (one plugin = one collapsible), instead of being a
|
|
6
|
+
special case the studio assembles by hand.
|
|
7
|
+
|
|
8
|
+
It wraps the embedded :class:`ContactAnalysisWidget` and drives it from
|
|
9
|
+
the catalogue scope: a single in-scope position is visualized; zero or several
|
|
10
|
+
are ambiguous for one viewer, so the view is cleared.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
|
15
|
+
|
|
16
|
+
from itasc.napari.contact_analysis.plugins import AnalysisContext, AnalysisPlugin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VisualizeContactsPlugin(AnalysisPlugin):
|
|
20
|
+
"""Visualize (and compute-if-missing) one selected position's contacts."""
|
|
21
|
+
|
|
22
|
+
plugin_id = "visualize_contacts"
|
|
23
|
+
display_name = "Visualize Contacts"
|
|
24
|
+
# Deliberately no ``requires``: a row may carry only a loose ``.h5`` (show an
|
|
25
|
+
# existing result, no labels to gate on) or carry labels (compute on demand).
|
|
26
|
+
# Gating on ``cell_labels_path`` would wrongly hide it for visualize-only
|
|
27
|
+
# catalogues; the plugin self-handles missing inputs instead.
|
|
28
|
+
|
|
29
|
+
def __init__(self, viewer=None, parent: QWidget | None = None) -> None:
|
|
30
|
+
super().__init__(viewer=viewer, parent=parent)
|
|
31
|
+
# Imported lazily: keep the plugin module import cheap and avoid an
|
|
32
|
+
# import cycle with the widget/studio modules.
|
|
33
|
+
from itasc.napari.contact_analysis_widget import (
|
|
34
|
+
ContactAnalysisWidget,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
layout = QVBoxLayout(self)
|
|
38
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
39
|
+
self._view = ContactAnalysisWidget(viewer=viewer, standalone=False)
|
|
40
|
+
# The embedded widget's own "Pipeline Files" panel is an orchestrator
|
|
41
|
+
# concept; here the catalogue table is the position source instead.
|
|
42
|
+
self._view.pipeline_files_header.setVisible(False)
|
|
43
|
+
self._view._pipeline_files_section.setVisible(False)
|
|
44
|
+
layout.addWidget(self._view)
|
|
45
|
+
|
|
46
|
+
def set_context(self, ctx: AnalysisContext) -> None:
|
|
47
|
+
"""Point the embedded view at the single in-scope position, else clear.
|
|
48
|
+
|
|
49
|
+
Catalogue rows from a study scan carry the cell/nucleus label paths; rows
|
|
50
|
+
from a loose ``.h5`` add or a reloaded CSV carry only the ``.h5``, so
|
|
51
|
+
those support showing an existing result but not compute-on-demand.
|
|
52
|
+
"""
|
|
53
|
+
records = list(ctx.records)
|
|
54
|
+
if len(records) != 1:
|
|
55
|
+
# Zero or several positions is ambiguous for a single viewer.
|
|
56
|
+
self._view.set_context(cell_labels=None, nucleus_labels=None, out_path=None)
|
|
57
|
+
return
|
|
58
|
+
record = records[0]
|
|
59
|
+
self._view.set_context(
|
|
60
|
+
cell_labels=record.get("cell_tracked_labels_path"),
|
|
61
|
+
nucleus_labels=record.get("nucleus_tracked_labels_path"),
|
|
62
|
+
out_path=record.get("contact_analysis_path"),
|
|
63
|
+
status_root=record.get("position_path"),
|
|
64
|
+
)
|