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,313 @@
|
|
|
1
|
+
"""The napari-free orchestration surface for Contact Analysis.
|
|
2
|
+
|
|
3
|
+
Three composable functions thread the existing headless stages — discovery,
|
|
4
|
+
per-position build, aggregate — into one pipeline the CLI, notebooks, and (during
|
|
5
|
+
the napari parallel-run) the Qt studio all drive:
|
|
6
|
+
|
|
7
|
+
catalog = build_catalog(root, cell_name=..., nucleus_name=..., out_csv=...)
|
|
8
|
+
build_quantities(catalog) # one .build() per (quantifier, position)
|
|
9
|
+
tables = aggregate(catalog, out_dir) # pooled, index-keyed CSVs (flat)
|
|
10
|
+
|
|
11
|
+
Or, end to end from a TOML run-config: ``run("config.toml")``.
|
|
12
|
+
|
|
13
|
+
Everything produced is **label-agnostic** tidy CSVs: there is no classification
|
|
14
|
+
step and no plot/figure export — a subpopulation classification and any plots are a
|
|
15
|
+
downstream, dataset-specific concern, computed from these tables.
|
|
16
|
+
|
|
17
|
+
This module *composes* — it owns no compute. Discovery lives in :mod:`.catalog`,
|
|
18
|
+
the per-position units in :mod:`.quantifier`, the record→inputs bridge in
|
|
19
|
+
:mod:`.records`, and pooling in :mod:`.shape_tables`. The only orchestration that
|
|
20
|
+
previously lived nowhere but the napari studio — the per-position **build loop** —
|
|
21
|
+
is :func:`build_quantities`.
|
|
22
|
+
|
|
23
|
+
Backend-only (no Qt / napari): the standalone wheel and headless batch runs use
|
|
24
|
+
it unchanged.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from . import shape_tables
|
|
32
|
+
from .catalog import discover_catalog_entries, load_catalog, save_catalog
|
|
33
|
+
from .config import RunConfig, load_config, write_config
|
|
34
|
+
from .quantifier import Quantifier, available_quantifiers
|
|
35
|
+
from .records import (
|
|
36
|
+
available_fields,
|
|
37
|
+
output_for_record,
|
|
38
|
+
position_inputs_from_record,
|
|
39
|
+
record_build_params,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"author_config",
|
|
44
|
+
"build_catalog",
|
|
45
|
+
"build_quantities",
|
|
46
|
+
"select_quantifiers",
|
|
47
|
+
"aggregate",
|
|
48
|
+
"run",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_catalog(
|
|
53
|
+
root: Path | str,
|
|
54
|
+
*,
|
|
55
|
+
cell_name: str | None = None,
|
|
56
|
+
nucleus_name: str | None = None,
|
|
57
|
+
out_csv: Path | str | None = None,
|
|
58
|
+
) -> list[dict]:
|
|
59
|
+
"""Discover the positions under *root* and return them as catalogue records.
|
|
60
|
+
|
|
61
|
+
Wraps :func:`~itasc.contact_analysis.catalog.discover_catalog_entries`:
|
|
62
|
+
a *position* is any folder holding at least one named input (cell and/or
|
|
63
|
+
nucleus labels). When *out_csv* is given, the discovered skeleton is written
|
|
64
|
+
there (:func:`~itasc.contact_analysis.catalog.save_catalog`) for an
|
|
65
|
+
analyst to fill in ``condition`` / ``date`` / ``notes`` before building.
|
|
66
|
+
|
|
67
|
+
The records carry the discovered paths but no metadata; that is fine for an
|
|
68
|
+
immediate build (which keys only on the inputs) and is the editable skeleton
|
|
69
|
+
when persisted.
|
|
70
|
+
"""
|
|
71
|
+
entries = discover_catalog_entries(
|
|
72
|
+
root, cell_name=cell_name, nucleus_name=nucleus_name
|
|
73
|
+
)
|
|
74
|
+
if out_csv is not None:
|
|
75
|
+
save_catalog(out_csv, entries)
|
|
76
|
+
return entries
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_quantities(
|
|
80
|
+
catalog: Iterable[dict],
|
|
81
|
+
*,
|
|
82
|
+
quantifiers: Sequence[Quantifier] | None = None,
|
|
83
|
+
params: Mapping[str, object] | None = None,
|
|
84
|
+
progress_cb: Callable[[int, int, str], None] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Build and persist every **producer** quantity for every position in *catalog*.
|
|
87
|
+
|
|
88
|
+
THE extracted build loop (previously trapped in the napari studio): one job
|
|
89
|
+
per ``(quantifier, position)`` where the quantifier
|
|
90
|
+
:meth:`~itasc.contact_analysis.quantifier.Quantifier.can_build` the
|
|
91
|
+
position's inputs; each job calls ``.build(inputs, output_path, params=...)``,
|
|
92
|
+
overwriting an existing artifact. Positions lacking a quantifier's inputs are
|
|
93
|
+
skipped.
|
|
94
|
+
|
|
95
|
+
Only **producers** — quantifiers whose ``produces`` another quantifier's
|
|
96
|
+
``requires`` names (in practice just ``contacts``, writing
|
|
97
|
+
``contact_analysis.h5``) — persist a per-position artifact here. Every other
|
|
98
|
+
(cheap) quantity is never built to disk per-position: it is instead computed
|
|
99
|
+
in memory, straight from a position's raw inputs, inside
|
|
100
|
+
:func:`~itasc.contact_analysis.shape_tables.aggregate` /
|
|
101
|
+
:func:`~itasc.contact_analysis.shape_tables.build_table` via
|
|
102
|
+
:meth:`~itasc.contact_analysis.quantifier.Quantifier.compute_object_table`.
|
|
103
|
+
A normal run therefore writes only ``contact_analysis.h5`` per position; the
|
|
104
|
+
pooled tables are the only place the cheap quantities live.
|
|
105
|
+
|
|
106
|
+
*quantifiers* defaults to one instance of every registered quantifier
|
|
107
|
+
(:func:`~itasc.contact_analysis.quantifier.available_quantifiers`); it is
|
|
108
|
+
filtered down to producers before planning, so passing non-producers here is
|
|
109
|
+
harmless (they are simply skipped).
|
|
110
|
+
*params* (the shared build knobs — z-score shuffles, density FOV, pixel size,
|
|
111
|
+
frame interval) is threaded only into quantifiers that opt in via
|
|
112
|
+
``wants_build_params``; the rest keep their own ``params`` schema clean. A
|
|
113
|
+
quantifier whose ``required_build_params`` *params* does not satisfy is skipped
|
|
114
|
+
whole (mirrors the studio greying the metric out), so "build everything" stays
|
|
115
|
+
usable when an optional knob like the density FOV is unset.
|
|
116
|
+
*progress_cb* is called ``(done, total, position_name)`` before each build.
|
|
117
|
+
Exceptions propagate — a failed build aborts the run rather than being
|
|
118
|
+
silently swallowed.
|
|
119
|
+
"""
|
|
120
|
+
quants = (
|
|
121
|
+
list(quantifiers)
|
|
122
|
+
if quantifiers is not None
|
|
123
|
+
else [cls() for cls in available_quantifiers()]
|
|
124
|
+
)
|
|
125
|
+
quants = _dependency_order(quants)
|
|
126
|
+
quants = [q for q in quants if q.produces] # only producers persist (contacts)
|
|
127
|
+
records = list(catalog)
|
|
128
|
+
jobs: list[tuple[Quantifier, dict, dict | None]] = []
|
|
129
|
+
for quantifier in quants:
|
|
130
|
+
q_params = dict(params) if (params and quantifier.wants_build_params) else None
|
|
131
|
+
for record in records:
|
|
132
|
+
inputs = position_inputs_from_record(record)
|
|
133
|
+
if not set(quantifier.requires) <= available_fields(inputs):
|
|
134
|
+
continue
|
|
135
|
+
if quantifier.missing_build_params(record_build_params(quantifier, record, params)):
|
|
136
|
+
continue
|
|
137
|
+
jobs.append((quantifier, record, q_params))
|
|
138
|
+
|
|
139
|
+
total = len(jobs)
|
|
140
|
+
for index, (quantifier, record, q_params) in enumerate(jobs, start=1):
|
|
141
|
+
# Re-derive now: a producer built earlier this run has written its
|
|
142
|
+
# artifact, so the dependent's inputs resolve it (``position_inputs_from_record``
|
|
143
|
+
# gates a produced input on the file existing).
|
|
144
|
+
inputs = position_inputs_from_record(record)
|
|
145
|
+
if progress_cb is not None:
|
|
146
|
+
progress_cb(index, total, inputs.position_dir.name)
|
|
147
|
+
quantifier.build(inputs, output_for_record(quantifier, record), params=q_params)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _dependency_order(quants: Sequence[Quantifier]) -> list[Quantifier]:
|
|
151
|
+
"""*quants* sorted so each producer precedes the quantifiers whose ``requires``
|
|
152
|
+
names its ``produces``. Independent quantifiers keep their given order; a
|
|
153
|
+
dependency cycle raises ``ValueError``."""
|
|
154
|
+
produced_by = {q.produces: q for q in quants if q.produces}
|
|
155
|
+
order: list[Quantifier] = []
|
|
156
|
+
state: dict[int, str] = {} # id(q) -> "visiting" | "done"
|
|
157
|
+
|
|
158
|
+
def visit(q: Quantifier) -> None:
|
|
159
|
+
mark = state.get(id(q))
|
|
160
|
+
if mark == "done":
|
|
161
|
+
return
|
|
162
|
+
if mark == "visiting":
|
|
163
|
+
name = q.quantity_id or type(q).__name__
|
|
164
|
+
raise ValueError(f"Quantifier dependency cycle involving {name!r}")
|
|
165
|
+
state[id(q)] = "visiting"
|
|
166
|
+
for field in q.requires:
|
|
167
|
+
producer = produced_by.get(field)
|
|
168
|
+
if producer is not None and producer is not q:
|
|
169
|
+
visit(producer)
|
|
170
|
+
state[id(q)] = "done"
|
|
171
|
+
order.append(q)
|
|
172
|
+
|
|
173
|
+
for q in quants:
|
|
174
|
+
visit(q)
|
|
175
|
+
return order
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def select_quantifiers(quantities: Sequence[str]) -> list[Quantifier]:
|
|
179
|
+
"""Instantiate the quantifiers a run should build for the selected *quantities*.
|
|
180
|
+
|
|
181
|
+
Empty *quantities* selects **every** registered quantifier. A non-empty list
|
|
182
|
+
selects those ``quantity_id``\\ s plus, transitively, any **producer** whose
|
|
183
|
+
``produces`` field a selected (or pulled-in) quantifier ``requires`` — so asking
|
|
184
|
+
for a contacts-derived metric silently brings contacts along instead of leaving
|
|
185
|
+
it unbuildable. Order follows registration; the build loop re-sorts by
|
|
186
|
+
dependency.
|
|
187
|
+
"""
|
|
188
|
+
classes = list(available_quantifiers())
|
|
189
|
+
if not quantities:
|
|
190
|
+
return [cls() for cls in classes]
|
|
191
|
+
|
|
192
|
+
by_id = {cls.quantity_id: cls for cls in classes}
|
|
193
|
+
unknown = [q for q in quantities if q not in by_id]
|
|
194
|
+
if unknown:
|
|
195
|
+
raise ValueError(f"unknown quantit(y/ies) {unknown}; known: {sorted(by_id)}")
|
|
196
|
+
produced_by = {cls.produces: cls for cls in classes if cls.produces}
|
|
197
|
+
|
|
198
|
+
selected: dict[str, type[Quantifier]] = {}
|
|
199
|
+
|
|
200
|
+
def add(cls: type[Quantifier]) -> None:
|
|
201
|
+
if cls.quantity_id in selected:
|
|
202
|
+
return
|
|
203
|
+
selected[cls.quantity_id] = cls
|
|
204
|
+
for field in cls.requires:
|
|
205
|
+
producer = produced_by.get(field)
|
|
206
|
+
if producer is not None and producer is not cls:
|
|
207
|
+
add(producer)
|
|
208
|
+
|
|
209
|
+
for qid in quantities:
|
|
210
|
+
add(by_id[qid])
|
|
211
|
+
# Preserve registration order for a stable, readable build sequence.
|
|
212
|
+
return [cls() for cls in classes if cls.quantity_id in selected]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def aggregate(
|
|
216
|
+
catalog: Sequence[dict],
|
|
217
|
+
out_dir: Path | str | None = None,
|
|
218
|
+
*,
|
|
219
|
+
params: Mapping[str, object] | None = None,
|
|
220
|
+
quantities: Sequence[str] | None = None,
|
|
221
|
+
) -> dict[str, Path]:
|
|
222
|
+
"""Pool every built product across *catalog* into the index-keyed tables.
|
|
223
|
+
|
|
224
|
+
Thin pass-through to
|
|
225
|
+
:func:`itasc.contact_analysis.shape_tables.aggregate`: returns the
|
|
226
|
+
table name → written CSV path map. The tables are written **flat** under
|
|
227
|
+
*out_dir* (``<out_dir>/<name>.csv``); *out_dir* defaults to the catalogue root
|
|
228
|
+
(the common ancestor of the positions). *params* carries the shared build
|
|
229
|
+
knobs (pixel size, frame interval, FOV area, …) so a param-gated cheap
|
|
230
|
+
quantity (e.g. cell shape needing ``pixel_size_um``) pools with the same
|
|
231
|
+
knobs a normal build would have used. *quantities* restricts which tables are
|
|
232
|
+
written (``None`` = all); see :func:`shape_tables.aggregate`.
|
|
233
|
+
"""
|
|
234
|
+
return shape_tables.aggregate(catalog, out_dir, params=params, quantities=quantities)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def author_config(
|
|
238
|
+
out_dir: Path | str,
|
|
239
|
+
records: Sequence[dict],
|
|
240
|
+
*,
|
|
241
|
+
tables_dir: str | None = None,
|
|
242
|
+
quantities: Sequence[str] = (),
|
|
243
|
+
params: Mapping[str, object] | None = None,
|
|
244
|
+
catalog_name: str = "catalog.csv",
|
|
245
|
+
config_name: str = "config.toml",
|
|
246
|
+
) -> Path:
|
|
247
|
+
"""Write ``catalog.csv`` + ``config.toml`` into *out_dir*; return the config path.
|
|
248
|
+
|
|
249
|
+
The composition point behind the studio's "Save config…" / "Run": persist the
|
|
250
|
+
in-memory *records* to a catalog CSV, then author a run-config beside it that
|
|
251
|
+
points at that CSV (a relative ``catalog`` key, so the folder stays
|
|
252
|
+
relocatable). *tables_dir* is written as the config's ``out_dir`` (where the
|
|
253
|
+
flat pooled tables land); ``None`` leaves it unset (defaults to the catalogue
|
|
254
|
+
root at run time). ``run(author_config(...))`` reproduces the UI's run
|
|
255
|
+
headlessly. Creates *out_dir* if missing.
|
|
256
|
+
"""
|
|
257
|
+
out_dir = Path(out_dir)
|
|
258
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
save_catalog(out_dir / catalog_name, records)
|
|
260
|
+
return write_config(
|
|
261
|
+
out_dir / config_name,
|
|
262
|
+
catalog=catalog_name,
|
|
263
|
+
out_dir=tables_dir,
|
|
264
|
+
quantities=quantities,
|
|
265
|
+
params=params,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run(
|
|
270
|
+
config_path: Path | str, *, progress_cb=None, build: bool = True
|
|
271
|
+
) -> dict[str, Path]:
|
|
272
|
+
"""Run the whole pipeline from a TOML run-config: the "author once, then run".
|
|
273
|
+
|
|
274
|
+
Loads the :class:`~itasc.contact_analysis.config.RunConfig`, then
|
|
275
|
+
threads its choices through the stages: load the catalog CSV, build the
|
|
276
|
+
selected *quantities* — of which only producers (``contacts``) actually
|
|
277
|
+
persist a per-position artifact, see :func:`build_quantities` — and aggregate
|
|
278
|
+
into the flat measurement tables under the config's ``out_dir`` (default: the
|
|
279
|
+
catalogue root), where the cheap quantities are computed in memory. The same
|
|
280
|
+
``quantities`` selection restricts which tables ``aggregate`` writes (empty =
|
|
281
|
+
all), so a pool-only run honors the choice too. Returns
|
|
282
|
+
the table name → written CSV path map. The optional *progress_cb* is
|
|
283
|
+
forwarded to the build stage.
|
|
284
|
+
|
|
285
|
+
Set *build* to ``False`` for **pool-only**: skip the per-position build stage
|
|
286
|
+
entirely and go straight to aggregate. The producer's ``.build`` overwrites its
|
|
287
|
+
``.h5`` unconditionally (it is not guarded like ``ensure_contacts``), so a plain
|
|
288
|
+
``run`` recomputes every position's contacts even when the artifact already
|
|
289
|
+
exists. ``build=False`` reads the existing ``.h5`` via each quantifier's
|
|
290
|
+
``compute_object_table`` and computes the cheap quantities in memory — a
|
|
291
|
+
load-and-pool with no recompute. Positions missing their ``.h5`` simply
|
|
292
|
+
contribute nothing (scope them out before calling).
|
|
293
|
+
"""
|
|
294
|
+
cfg: RunConfig = load_config(config_path)
|
|
295
|
+
catalog = load_catalog(cfg.catalog)
|
|
296
|
+
# The global build knobs (``pixel_size_um`` / ``time_interval_s``) are read off
|
|
297
|
+
# each record by ``position_inputs_from_record``; in the studio they are stamped
|
|
298
|
+
# per-position, here they come from the config. Stamp config params onto every
|
|
299
|
+
# record that does not already carry them, then also pass them to the build loop
|
|
300
|
+
# (the required-param gate + opt-in ``wants_build_params`` quantifiers).
|
|
301
|
+
for record in catalog:
|
|
302
|
+
for key, value in cfg.params.items():
|
|
303
|
+
record.setdefault(key, value)
|
|
304
|
+
if build:
|
|
305
|
+
build_quantities(
|
|
306
|
+
catalog,
|
|
307
|
+
quantifiers=select_quantifiers(cfg.quantities),
|
|
308
|
+
params=cfg.params or None,
|
|
309
|
+
progress_cb=progress_cb,
|
|
310
|
+
)
|
|
311
|
+
return aggregate(
|
|
312
|
+
catalog, cfg.out_dir, params=cfg.params or None, quantities=cfg.quantities or None
|
|
313
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Resolve a position's physical pixel size (µm per pixel).
|
|
2
|
+
|
|
3
|
+
Backend-only (no Qt / napari) so the standalone wheel and headless batch runs can
|
|
4
|
+
use it. The cell-shape build needs µm/px to turn pixel-unit ``regionprops``
|
|
5
|
+
measurements into physical µm / µm². Two sources are consulted, in order:
|
|
6
|
+
|
|
7
|
+
1. The position's ``itasc_config.json`` — ``metadata.pixel_size_um`` — the key
|
|
8
|
+
the main pipeline widget already writes (``main_widget.py``).
|
|
9
|
+
2. The segmented label TIFF's resolution tags — ImageJ ``unit`` + ``resolution``
|
|
10
|
+
or the baseline ``XResolution`` / ``ResolutionUnit`` tags.
|
|
11
|
+
|
|
12
|
+
Returns ``None`` when neither yields a positive value; callers treat that as
|
|
13
|
+
"pixel size unknown" (the cell-shape build is then blocked until one is set).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import tifffile
|
|
21
|
+
|
|
22
|
+
from itasc.core.paths import position_config_path
|
|
23
|
+
|
|
24
|
+
__all__ = ["resolve_pixel_size_um", "pixel_size_from_config", "pixel_size_from_tiff"]
|
|
25
|
+
|
|
26
|
+
#: Multipliers turning a length in the tag's unit into µm.
|
|
27
|
+
_UNIT_TO_UM = {
|
|
28
|
+
"micron": 1.0,
|
|
29
|
+
"microns": 1.0,
|
|
30
|
+
"um": 1.0,
|
|
31
|
+
"µm": 1.0,
|
|
32
|
+
"micrometer": 1.0,
|
|
33
|
+
"micrometre": 1.0,
|
|
34
|
+
"mm": 1_000.0,
|
|
35
|
+
"millimeter": 1_000.0,
|
|
36
|
+
"cm": 10_000.0,
|
|
37
|
+
"centimeter": 10_000.0,
|
|
38
|
+
"inch": 25_400.0,
|
|
39
|
+
"in": 25_400.0,
|
|
40
|
+
}
|
|
41
|
+
#: Baseline-TIFF ``ResolutionUnit`` tag values (2 = inch, 3 = centimeter).
|
|
42
|
+
_RESOLUTION_UNIT_TO_UM = {2: 25_400.0, 3: 10_000.0}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_pixel_size_um(
|
|
46
|
+
position_dir: Path | str | None,
|
|
47
|
+
cell_labels_path: Path | str | None,
|
|
48
|
+
) -> float | None:
|
|
49
|
+
"""µm/px for a position, from its config first, then the label TIFF tags.
|
|
50
|
+
|
|
51
|
+
Returns ``None`` when no positive pixel size can be found.
|
|
52
|
+
"""
|
|
53
|
+
size = pixel_size_from_config(position_dir)
|
|
54
|
+
if size is not None:
|
|
55
|
+
return size
|
|
56
|
+
return pixel_size_from_tiff(cell_labels_path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pixel_size_from_config(position_dir: Path | str | None) -> float | None:
|
|
60
|
+
"""``metadata.pixel_size_um`` from ``itasc_config.json`` in *position_dir*."""
|
|
61
|
+
if position_dir is None:
|
|
62
|
+
return None
|
|
63
|
+
config_path = position_config_path(position_dir)
|
|
64
|
+
if not config_path.is_file():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
with open(config_path) as f:
|
|
68
|
+
config = json.load(f)
|
|
69
|
+
metadata = config.get("metadata", {})
|
|
70
|
+
return _positive_float(metadata.get("pixel_size_um"))
|
|
71
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pixel_size_from_tiff(cell_labels_path: Path | str | None) -> float | None:
|
|
76
|
+
"""µm/px from a TIFF's resolution tags (ImageJ unit, else ResolutionUnit)."""
|
|
77
|
+
if cell_labels_path is None:
|
|
78
|
+
return None
|
|
79
|
+
path = Path(cell_labels_path)
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
with tifffile.TiffFile(str(path)) as tf:
|
|
84
|
+
from_ome = _pixel_size_from_ome(tf)
|
|
85
|
+
if from_ome is not None:
|
|
86
|
+
return from_ome
|
|
87
|
+
from_imagej = _pixel_size_from_imagej(tf)
|
|
88
|
+
if from_imagej is not None:
|
|
89
|
+
return from_imagej
|
|
90
|
+
return _pixel_size_from_baseline(tf)
|
|
91
|
+
except (OSError, ValueError, KeyError, tifffile.TiffFileError):
|
|
92
|
+
# An unreadable/empty/corrupt TIFF means "pixel size unknown", not a
|
|
93
|
+
# crash. tifffile raises TiffFileError (not a KeyError/OSError subclass)
|
|
94
|
+
# for a malformed file, so it must be named explicitly.
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pixel_size_from_ome(tf: tifffile.TiffFile) -> float | None:
|
|
99
|
+
"""OME-TIFF stores µm/px directly as ``Pixels/@PhysicalSizeX`` (+ unit)."""
|
|
100
|
+
xml = tf.ome_metadata
|
|
101
|
+
if not xml:
|
|
102
|
+
return None
|
|
103
|
+
import xml.etree.ElementTree as ET
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
root = ET.fromstring(xml)
|
|
107
|
+
except ET.ParseError:
|
|
108
|
+
return None
|
|
109
|
+
for el in root.iter():
|
|
110
|
+
if el.tag.endswith("Pixels"):
|
|
111
|
+
value = _positive_float(el.get("PhysicalSizeX"))
|
|
112
|
+
if value is None:
|
|
113
|
+
return None
|
|
114
|
+
unit = str(el.get("PhysicalSizeXUnit", "µm")).strip().lower()
|
|
115
|
+
to_um = _UNIT_TO_UM.get(unit)
|
|
116
|
+
return value * to_um if to_um is not None else None
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _pixel_size_from_imagej(tf: tifffile.TiffFile) -> float | None:
|
|
121
|
+
"""ImageJ stores spacing as ``XResolution`` (pixels/unit) plus a ``unit``."""
|
|
122
|
+
metadata = tf.imagej_metadata or {}
|
|
123
|
+
unit = str(metadata.get("unit", "")).strip().lower()
|
|
124
|
+
to_um = _UNIT_TO_UM.get(unit)
|
|
125
|
+
if to_um is None:
|
|
126
|
+
return None
|
|
127
|
+
per_unit = _tag_ratio(tf, "XResolution")
|
|
128
|
+
if per_unit is None or per_unit <= 0:
|
|
129
|
+
return None
|
|
130
|
+
return to_um / per_unit
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pixel_size_from_baseline(tf: tifffile.TiffFile) -> float | None:
|
|
134
|
+
"""Baseline TIFF: ``XResolution`` pixels/unit with a ``ResolutionUnit``."""
|
|
135
|
+
page = tf.pages[0]
|
|
136
|
+
unit_tag = page.tags.get("ResolutionUnit")
|
|
137
|
+
to_um = _RESOLUTION_UNIT_TO_UM.get(int(unit_tag.value)) if unit_tag else None
|
|
138
|
+
if to_um is None:
|
|
139
|
+
return None
|
|
140
|
+
per_unit = _tag_ratio(tf, "XResolution")
|
|
141
|
+
if per_unit is None or per_unit <= 0:
|
|
142
|
+
return None
|
|
143
|
+
return to_um / per_unit
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _tag_ratio(tf: tifffile.TiffFile, name: str) -> float | None:
|
|
147
|
+
"""Read a rational TIFF tag (``(num, den)`` or scalar) as a float."""
|
|
148
|
+
tag = tf.pages[0].tags.get(name)
|
|
149
|
+
if tag is None:
|
|
150
|
+
return None
|
|
151
|
+
value = tag.value
|
|
152
|
+
if isinstance(value, (tuple, list)) and len(value) == 2:
|
|
153
|
+
num, den = value
|
|
154
|
+
return float(num) / float(den) if den else None
|
|
155
|
+
try:
|
|
156
|
+
return float(value)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _positive_float(value: object) -> float | None:
|
|
162
|
+
try:
|
|
163
|
+
result = float(value) # type: ignore[arg-type]
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
return None
|
|
166
|
+
return result if result > 0 else None
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The quantifier seam: per-position compute units, discovered by subclassing.
|
|
2
|
+
|
|
3
|
+
A :class:`Quantifier` turns a position's source files (:class:`PositionInputs`)
|
|
4
|
+
into a persisted, plottable quantity. Contacts is the first one; new quantities
|
|
5
|
+
(nucleus-track kinematics, nucleus-vs-cell offset, cell shape, …) drop in as
|
|
6
|
+
modules under :mod:`itasc.contact_analysis.quantifiers` without
|
|
7
|
+
touching the studio.
|
|
8
|
+
|
|
9
|
+
The mechanics mirror :mod:`itasc.napari.contact_analysis.plugins`: subclassing with a
|
|
10
|
+
non-empty ``quantity_id`` auto-registers the quantifier, and
|
|
11
|
+
:func:`available_quantifiers` imports every module in the ``quantifiers`` package
|
|
12
|
+
so its plugins self-register. This module stays backend-only (no Qt / napari) so
|
|
13
|
+
the standalone wheel and headless batch runs can use it.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import pkgutil
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
__all__ = ["OUTPUT_SUBDIR", "PositionInputs", "Quantifier", "available_quantifiers"]
|
|
25
|
+
|
|
26
|
+
#: Per-position subfolder for quantifier artifacts that persist to disk. Numbered
|
|
27
|
+
#: to mirror the staged layout (``0_input`` … ``3_cell``). The dynamics
|
|
28
|
+
#: quantifiers land here (``cell_dynamics.h5``, ``nucleus_dynamics.h5``); the
|
|
29
|
+
#: cheap quantities (shape, relational, density, neighbor count) are pooled in
|
|
30
|
+
#: memory during ``run()`` rather than written to disk. Note ``contact_analysis.h5``
|
|
31
|
+
#: is *not* here — :class:`~itasc.contact_analysis.quantifiers.contacts.ContactsQuantifier`
|
|
32
|
+
#: overrides :meth:`Quantifier.default_output` to write it to the position root,
|
|
33
|
+
#: beside the committed ``cell_labels.tif`` / ``nucleus_labels.tif``.
|
|
34
|
+
OUTPUT_SUBDIR = "4_contact_analysis"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PositionInputs:
|
|
39
|
+
"""Resolved source files for one position. Quantifiers read what they need.
|
|
40
|
+
|
|
41
|
+
Every field has a live consumer. A future track-based quantifier adds its own
|
|
42
|
+
field (e.g. ``tracks_db_path``) in the same commit that first reads it — no
|
|
43
|
+
speculative placeholders here.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
position_dir: Path
|
|
47
|
+
cell_labels_path: Path | None = None
|
|
48
|
+
nucleus_labels_path: Path | None = None
|
|
49
|
+
#: Physical pixel size (µm/px); ``None`` when unknown. Quantifiers that emit
|
|
50
|
+
#: values in physical units (cell shape) require it.
|
|
51
|
+
pixel_size_um: float | None = None
|
|
52
|
+
#: Frame interval (seconds/frame); ``None`` when unknown. Quantifiers that
|
|
53
|
+
#: emit time-derived values (track dynamics) require it.
|
|
54
|
+
time_interval_s: float | None = None
|
|
55
|
+
#: The position's built ``contact_analysis.h5``; ``None`` when contacts is not
|
|
56
|
+
#: in the catalogue. The contacts-derived quantifiers (neighbor count /
|
|
57
|
+
#: enrichment / z-score / density / signed contact length) read it as their input instead
|
|
58
|
+
#: of re-running contact extraction.
|
|
59
|
+
contact_analysis_path: Path | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: quantity_id -> quantifier class, populated by ``__init_subclass__``.
|
|
63
|
+
_REGISTRY: dict[str, type[Quantifier]] = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Quantifier:
|
|
67
|
+
"""Base class for per-position quantifiers.
|
|
68
|
+
|
|
69
|
+
Subclasses set ``quantity_id`` / ``display_name`` (and usually ``requires``)
|
|
70
|
+
and implement :meth:`build` and :meth:`read`. A quantifier **owns its own
|
|
71
|
+
persistence**: :meth:`build` writes whatever artifact format suits the
|
|
72
|
+
quantity and :meth:`read` parses it back — the framework imposes no schema.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
#: Stable key; an empty value marks an intermediate (non-registered) base.
|
|
76
|
+
quantity_id: ClassVar[str] = ""
|
|
77
|
+
#: Human-readable label shown wherever a quantity is selected.
|
|
78
|
+
display_name: ClassVar[str] = ""
|
|
79
|
+
#: ``PositionInputs`` field names this quantifier needs to build.
|
|
80
|
+
requires: ClassVar[tuple[str, ...]] = ()
|
|
81
|
+
#: The ``PositionInputs`` field this quantifier's artifact *populates*, if any
|
|
82
|
+
#: (e.g. contacts populates ``contact_analysis_path``). A quantifier whose
|
|
83
|
+
#: :attr:`requires` names another's :attr:`produces` is *derived from* it — the
|
|
84
|
+
#: studio uses this to draw the build-dependency graph. Empty for a leaf
|
|
85
|
+
#: quantity that only consumes raw source inputs.
|
|
86
|
+
produces: ClassVar[str] = ""
|
|
87
|
+
#: Default artifact file name (relative to a position); empty for an
|
|
88
|
+
#: intermediate base that does not persist.
|
|
89
|
+
default_output_name: ClassVar[str] = ""
|
|
90
|
+
#: The index columns of this quantifier's ``object_table`` — its natural grain
|
|
91
|
+
#: (e.g. ``("frame", "cell_id")``). A non-empty value marks the quantity as
|
|
92
|
+
#: **aggregated**: :mod:`itasc.contact_analysis.shape_tables` pools it
|
|
93
|
+
#: across positions into a table named by :attr:`quantity_id`, keyed on these
|
|
94
|
+
#: columns. Empty ⇒ not aggregated into an index-keyed table (e.g. contacts).
|
|
95
|
+
#: Value columns are namespaced by ``quantity_id`` so a later joined view across
|
|
96
|
+
#: tables never has colliding names.
|
|
97
|
+
table_keys: ClassVar[tuple[str, ...]] = ()
|
|
98
|
+
#: Whether the studio threads the shared plot/build params (z-score shuffle
|
|
99
|
+
#: count, density field-of-view) into :meth:`build` via ``params``. Off by
|
|
100
|
+
#: default so a quantifier with its own ``params`` schema (contacts edge
|
|
101
|
+
#: extraction, shape, dynamics) is never handed the shared bar's keys.
|
|
102
|
+
wants_build_params: ClassVar[bool] = False
|
|
103
|
+
#: Shared build-param keys that must be present and positive for this
|
|
104
|
+
#: quantifier to build, mapped to the human label shown in the UI (e.g. cell
|
|
105
|
+
#: density needs ``{"fov_area_mm2": "FOV area (mm²)"}``). The studio greys the
|
|
106
|
+
#: metric out — rather than letting the build fail — until they are supplied.
|
|
107
|
+
#: Empty for a quantifier that needs no shared param.
|
|
108
|
+
required_build_params: ClassVar[dict[str, str]] = {}
|
|
109
|
+
|
|
110
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
111
|
+
super().__init_subclass__(**kwargs)
|
|
112
|
+
if cls.quantity_id:
|
|
113
|
+
_REGISTRY[cls.quantity_id] = cls
|
|
114
|
+
|
|
115
|
+
def can_build(self, inputs: PositionInputs) -> bool:
|
|
116
|
+
"""True when *inputs* supplies every field named in :attr:`requires`."""
|
|
117
|
+
return all(getattr(inputs, name, None) is not None for name in self.requires)
|
|
118
|
+
|
|
119
|
+
def missing_build_params(self, params: Mapping[str, Any] | None) -> tuple[str, ...]:
|
|
120
|
+
"""Labels of :attr:`required_build_params` that *params* doesn't satisfy.
|
|
121
|
+
|
|
122
|
+
A required key is satisfied when present and a positive number; anything
|
|
123
|
+
else (absent, blank, ``None``, non-positive) counts as missing. The studio
|
|
124
|
+
uses the returned labels to grey the metric out and explain why. Empty
|
|
125
|
+
tuple ⇒ every required shared param is supplied."""
|
|
126
|
+
params = params or {}
|
|
127
|
+
missing: list[str] = []
|
|
128
|
+
for key, label in self.required_build_params.items():
|
|
129
|
+
value = params.get(key)
|
|
130
|
+
if not (isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0):
|
|
131
|
+
missing.append(label)
|
|
132
|
+
return tuple(missing)
|
|
133
|
+
|
|
134
|
+
def default_output(self, inputs: PositionInputs) -> Path:
|
|
135
|
+
"""Where this quantifier's artifact lives for *inputs*, by default.
|
|
136
|
+
|
|
137
|
+
``position_dir / OUTPUT_SUBDIR / default_output_name`` — every quantity
|
|
138
|
+
lands in the shared :data:`OUTPUT_SUBDIR` folder, so each subclass sets
|
|
139
|
+
just a bare file name. The studio uses this to decide a build's
|
|
140
|
+
destination, so a second quantifier no longer inherits the contacts
|
|
141
|
+
artifact path. Subclasses may override for richer layouts.
|
|
142
|
+
"""
|
|
143
|
+
if not self.default_output_name:
|
|
144
|
+
raise NotImplementedError(
|
|
145
|
+
f"{type(self).__name__} sets no default_output_name"
|
|
146
|
+
)
|
|
147
|
+
return inputs.position_dir / OUTPUT_SUBDIR / self.default_output_name
|
|
148
|
+
|
|
149
|
+
def is_built(self, output_path: Path) -> bool:
|
|
150
|
+
"""True when the artifact at *output_path* already exists."""
|
|
151
|
+
return Path(output_path).is_file()
|
|
152
|
+
|
|
153
|
+
def object_table(self, output_path: Path) -> Mapping[str, Any] | None:
|
|
154
|
+
"""A tidy, column-major per-object table for the plotting backend.
|
|
155
|
+
|
|
156
|
+
At least a ``frame`` key plus a per-object key (e.g. ``cell_id``).
|
|
157
|
+
Returns ``None`` when this quantifier produces no per-object table; the
|
|
158
|
+
plotting backend then skips it.
|
|
159
|
+
"""
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def compute_object_table(
|
|
163
|
+
self, inputs: PositionInputs, *, params: dict | None = None
|
|
164
|
+
) -> Mapping[str, Any] | None:
|
|
165
|
+
"""The pooled tidy table for one position, computed directly from *inputs*.
|
|
166
|
+
|
|
167
|
+
A **pooled** quantifier (one that declares ``table_keys``) implements this:
|
|
168
|
+
it returns the same column-major table that ``object_table`` used to return
|
|
169
|
+
after a disk round-trip, but never touches disk. ``None`` when this position
|
|
170
|
+
yields no rows. Producers (``contacts``) are not pooled and do not implement
|
|
171
|
+
it. *params* carries the shared build knobs (e.g. ``fov_area_mm2``) for the
|
|
172
|
+
quantifiers that opt in; per-position values (pixel size, frame interval)
|
|
173
|
+
arrive via *inputs*.
|
|
174
|
+
"""
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
|
|
177
|
+
def build(
|
|
178
|
+
self,
|
|
179
|
+
inputs: PositionInputs,
|
|
180
|
+
output_path: Path,
|
|
181
|
+
*,
|
|
182
|
+
params: dict | None = None,
|
|
183
|
+
progress_cb: Callable[[int, int, str], None] | None = None,
|
|
184
|
+
) -> Path: # pragma: no cover - overridden
|
|
185
|
+
raise NotImplementedError
|
|
186
|
+
|
|
187
|
+
def read(self, output_path: Path) -> Any: # pragma: no cover - overridden
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _import_quantifier_modules() -> None:
|
|
192
|
+
"""Import every (non-private) ``quantifiers`` submodule so it self-registers."""
|
|
193
|
+
from . import quantifiers
|
|
194
|
+
|
|
195
|
+
for info in pkgutil.iter_modules(quantifiers.__path__):
|
|
196
|
+
if info.name.startswith("_"):
|
|
197
|
+
continue
|
|
198
|
+
importlib.import_module(f"{quantifiers.__name__}.{info.name}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def available_quantifiers() -> list[type[Quantifier]]:
|
|
202
|
+
"""Return registered quantifier classes, sorted by display name."""
|
|
203
|
+
_import_quantifier_modules()
|
|
204
|
+
return sorted(_REGISTRY.values(), key=lambda cls: cls.display_name.lower())
|