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