itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,6 @@
1
+ """napari layer for Contact Analysis.
2
+
3
+ Hosts the analysis-plugin contract and built-in plugins (see
4
+ :mod:`itasc.napari.contact_analysis.plugins`) used by the Aggregate
5
+ Quantification studio.
6
+ """
@@ -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
+ )