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,393 @@
1
+ """Catalog of contact-analysis positions for Contact Analysis.
2
+
3
+ Builds, loads, and saves a catalog of positions (each a contact-analysis ``.h5``
4
+ plus optional cell/nucleus label images) discovered by file name / relative path
5
+ via :func:`discover_catalog_entries`.
6
+
7
+ Each row anchors on a ``position_path`` — the **absolute** path to the position
8
+ folder — and stores every file (the contact-analysis ``.h5``, cell labels,
9
+ nucleus labels) as a path **relative to that folder** (e.g.
10
+ ``contact_analysis.h5``, beside the committed labels). Persisting the position folder lets
11
+ downstream tools (notably the NLS classifier) resolve their own per-position
12
+ relative paths against it. Older catalogs that lack ``position_path`` still load:
13
+ their file paths are resolved relative to the CSV file's directory.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ import os
20
+ from pathlib import Path
21
+ from collections.abc import Iterable
22
+
23
+ STATUS_READY = "ready"
24
+ STATUS_INCOMPLETE = "incomplete"
25
+
26
+ #: The contacts ``.h5`` is a *derived* Contact Analysis output, not a discovery
27
+ #: input. It lives in the position base folder, beside the committed
28
+ #: ``cell_labels.tif`` / ``nucleus_labels.tif`` — one homogeneous layout for every
29
+ #: downstream-stable per-position artifact.
30
+ CONTACT_ANALYSIS_RELPATH = "contact_analysis.h5"
31
+
32
+ # The only column validated on load: ``position_path`` is the anchor every
33
+ # canonical artifact path is resolved against, so it is required. A position's
34
+ # *identity* is the combination of its classification columns (whatever the
35
+ # widget defined); that is validated for uniqueness at aggregate time, not here.
36
+ REQUIRED_CSV_COLUMNS = ("position_path",)
37
+ # The fixed structural columns written first: the absolute position folder, the
38
+ # canonical contact-analysis h5 (relative to it), and the two committed label
39
+ # images (relative). The classification columns — the widget columns, whatever
40
+ # they are named — follow, verbatim (see :func:`save_catalog`). No privileged
41
+ # ``condition`` / ``experiment_id`` / ``id`` columns: a position is identified by
42
+ # the combination of its own columns, not a fixed triple.
43
+ CSV_COLUMNS = (
44
+ "position_path",
45
+ "contact_analysis",
46
+ "cell_labels",
47
+ "nucleus_labels",
48
+ )
49
+
50
+ #: Structural / internal record keys that are never classification columns.
51
+ #: Everything else on a record — a flat CSV field or an entry in its ``columns``
52
+ #: bag — is a free-form classification column and rides through verbatim.
53
+ _STRUCTURAL_KEYS = frozenset(
54
+ {
55
+ "path",
56
+ "position_path",
57
+ "contact_analysis",
58
+ "contact_analysis_path",
59
+ "cell_labels",
60
+ "nucleus_labels",
61
+ "cell_tracked_labels_path",
62
+ "nucleus_tracked_labels_path",
63
+ "contact_analysis_status",
64
+ "columns",
65
+ "pixel_size_um",
66
+ "time_interval_s",
67
+ }
68
+ )
69
+
70
+
71
+ def relative_levels(root: Path | str, position_path: Path | str) -> tuple[str, ...]:
72
+ """The folder-name segments from *root* (exclusive) to *position_path* (inclusive).
73
+
74
+ These are the nesting *levels* a discovered position sits under; the UI names
75
+ each level once and every position inherits its column values from its own
76
+ segments (see :func:`columns_from_levels`).
77
+ """
78
+ root = _resolve_path(Path(root))
79
+ pos = _resolve_path(Path(position_path))
80
+ return pos.relative_to(root).parts
81
+
82
+
83
+ def columns_from_levels(
84
+ level_names: Iterable[str | None], segments: Iterable[str]
85
+ ) -> dict[str, str]:
86
+ """Zip level *names* to folder-name *segments*; blank/``None`` names drop out.
87
+
88
+ Extra segments without a paired name (or extra names without a segment) are
89
+ ignored — only the overlap with a non-blank name becomes a column.
90
+ """
91
+ return {name: seg for name, seg in zip(level_names, segments) if name}
92
+
93
+
94
+ def discovered_level_depth(
95
+ root: Path | str, position_paths: Iterable[Path | str]
96
+ ) -> int | None:
97
+ """The shared nesting depth of discovered positions, or ``None`` if it varies.
98
+
99
+ Folder-derived columns anchor at the root (level 1 = first folder under root),
100
+ so the level names line up across positions only when every position sits at
101
+ the same depth. A ``None`` return tells the caller to warn rather than mislabel.
102
+ """
103
+ depths = {len(relative_levels(root, p)) for p in position_paths}
104
+ return depths.pop() if len(depths) == 1 else None
105
+
106
+
107
+ def load_catalog(csv_path: Path | str) -> list[dict]:
108
+ """Load CSV catalog records and expose normalized compatibility keys.
109
+
110
+ Only ``position_path`` is required. Uniqueness of the position *identity* (the
111
+ combination of classification columns) is **not** checked here — a catalog may
112
+ hold more positions than any one run aggregates; the aggregator validates
113
+ uniqueness over the in-scope subset and refuses with an explanation there (see
114
+ :func:`itasc.contact_analysis.shape_tables.aggregate`).
115
+ """
116
+ catalog_path = Path(csv_path)
117
+ with catalog_path.open(newline="") as handle:
118
+ reader = csv.DictReader(handle)
119
+ fieldnames = set(reader.fieldnames or [])
120
+ missing = [column for column in REQUIRED_CSV_COLUMNS if column not in fieldnames]
121
+ if missing:
122
+ missing_text = ", ".join(missing)
123
+ raise ValueError(f"Catalog is missing required column(s): {missing_text}")
124
+
125
+ records = [
126
+ _normalize_catalog_record(row, base_dir=catalog_path.parent)
127
+ for row in reader
128
+ ]
129
+
130
+ return sorted(records, key=_catalog_sort_key)
131
+
132
+
133
+ def save_catalog(csv_path: Path | str, records: Iterable[dict]) -> None:
134
+ """Write catalog records to CSV.
135
+
136
+ Each row stores the absolute ``position_path`` and the position's files as
137
+ paths relative to that folder. Records without a known position folder
138
+ (legacy / hand-made) fall back to paths relative to the CSV file.
139
+ """
140
+ catalog_path = Path(csv_path)
141
+ catalog_path.parent.mkdir(parents=True, exist_ok=True)
142
+
143
+ normalized_records = [
144
+ _normalize_catalog_record(record, base_dir=catalog_path.parent)
145
+ for record in records
146
+ ]
147
+ # The header is the fixed structural columns plus every classification column
148
+ # (the widget columns — folder levels + manual tags), in first-seen order.
149
+ # There are no privileged identity columns: whatever the widget shows is what
150
+ # the CSV holds, one column per column.
151
+ extras: list[str] = []
152
+ seen: set[str] = set()
153
+ for normalized in normalized_records:
154
+ for key in normalized.get("columns", {}):
155
+ if key in CSV_COLUMNS or key in seen:
156
+ continue
157
+ seen.add(key)
158
+ extras.append(key)
159
+ fieldnames = list(CSV_COLUMNS) + extras
160
+
161
+ with catalog_path.open("w", newline="") as handle:
162
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
163
+ writer.writeheader()
164
+ for normalized in normalized_records:
165
+ position_path = normalized.get("position_path")
166
+ # Files are written relative to the position folder when one is
167
+ # known; otherwise relative to the CSV file's directory.
168
+ file_base = position_path if position_path is not None else catalog_path.parent
169
+ columns = normalized.get("columns", {})
170
+ row = {
171
+ "position_path": str(position_path) if position_path is not None else "",
172
+ "contact_analysis": _path_for_csv(
173
+ normalized["contact_analysis_path"], file_base
174
+ ),
175
+ "cell_labels": _optional_path_for_csv(
176
+ normalized.get("cell_tracked_labels_path"), file_base
177
+ ),
178
+ "nucleus_labels": _optional_path_for_csv(
179
+ normalized.get("nucleus_tracked_labels_path"), file_base
180
+ ),
181
+ }
182
+ row.update({key: columns[key] for key in extras if key in columns})
183
+ writer.writerow(row)
184
+
185
+
186
+ def merge_catalog_records(existing: Iterable[dict], incoming: Iterable[dict]) -> list[dict]:
187
+ """Append incoming records while skipping duplicate resolved H5 paths."""
188
+ merged: list[dict] = []
189
+ seen: set[Path] = set()
190
+
191
+ for record in list(existing) + list(incoming):
192
+ normalized = _normalize_catalog_record(record)
193
+ contact_analysis_path = normalized["contact_analysis_path"]
194
+ resolved = _resolve_path(contact_analysis_path)
195
+ if resolved in seen:
196
+ continue
197
+ seen.add(resolved)
198
+ merged.append(normalized)
199
+
200
+ return merged
201
+
202
+
203
+ def _normalize_catalog_record(record: dict, base_dir: Path | None = None) -> dict:
204
+ """Return a record with resolved paths and a normalized ``columns`` bag.
205
+
206
+ The classification columns are carried verbatim in ``record["columns"]`` (the
207
+ widget columns). There are no privileged identity fields: a record's identity
208
+ is the combination of its columns, validated downstream at aggregate time.
209
+ """
210
+ normalized = dict(record)
211
+
212
+ # Absolute position folder; the anchor for this position's relative file
213
+ # paths. Resolved against the CSV directory when given as a relative path.
214
+ raw_position = normalized.get("position_path")
215
+ position_path = (
216
+ _resolve_with_base(raw_position, base_dir)
217
+ if raw_position not in (None, "")
218
+ else None
219
+ )
220
+ # File paths in a catalog row are relative to the position folder when one is
221
+ # known; older catalogs (no position_path) stored them relative to the CSV.
222
+ file_base = position_path if position_path is not None else base_dir
223
+
224
+ # Read the canonical ``contact_analysis`` header, falling back to the legacy
225
+ # ``contact_analysis_path`` / free-form ``path`` columns so older catalogs load.
226
+ contact_analysis_path = _resolve_with_base(
227
+ normalized.get(
228
+ "contact_analysis",
229
+ normalized.get("path", normalized.get("contact_analysis_path", "")),
230
+ ),
231
+ file_base,
232
+ )
233
+ cell_labels_path = _resolve_optional_with_base(
234
+ normalized.get("cell_tracked_labels_path", normalized.get("cell_labels")), file_base
235
+ )
236
+ nucleus_labels_path = _resolve_optional_with_base(
237
+ normalized.get("nucleus_tracked_labels_path", normalized.get("nucleus_labels")),
238
+ file_base,
239
+ )
240
+
241
+ columns = _columns_bag(normalized)
242
+
243
+ normalized.update({
244
+ "path": contact_analysis_path,
245
+ "columns": columns,
246
+ "position_path": position_path,
247
+ "contact_analysis_path": contact_analysis_path,
248
+ "cell_tracked_labels_path": cell_labels_path,
249
+ "nucleus_tracked_labels_path": nucleus_labels_path,
250
+ "contact_analysis_status": STATUS_READY if contact_analysis_path.is_file() else STATUS_INCOMPLETE,
251
+ })
252
+ return normalized
253
+
254
+
255
+ def _columns_bag(record: dict) -> dict[str, str]:
256
+ """The classification-column bag for a record — the widget columns, verbatim.
257
+
258
+ Every non-structural field rides along as a column: a flat CSV field surfaced
259
+ by ``csv.DictReader`` (legacy or hand-written catalogs) first, then an explicit
260
+ incoming ``columns`` dict (the folder-derived UI path) overriding. The
261
+ combination of these columns *is* the position's identity."""
262
+ bag: dict[str, str] = {}
263
+ # Flat columns first (a CSV load surfaces every column as a flat key)…
264
+ for key, value in record.items():
265
+ if key in _STRUCTURAL_KEYS or value is None:
266
+ continue
267
+ # ``id`` was the legacy privileged position column and is the pooled
268
+ # row-id column downstream — a classification column may not use that
269
+ # name, so carry its value under ``position_id`` (which is what it meant).
270
+ # A real ``position_id`` column, if also present, wins (it comes later).
271
+ bag["position_id" if key == "id" else key] = str(value)
272
+ # …then an explicit incoming bag overrides (the UI / round-trip path).
273
+ for key, value in (record.get("columns") or {}).items():
274
+ bag[key] = str(value)
275
+ return bag
276
+
277
+
278
+ def discover_catalog_entries(
279
+ root: Path | str,
280
+ *,
281
+ cell_name: str | None = None,
282
+ nucleus_name: str | None = None,
283
+ ) -> list[dict]:
284
+ """Find catalog entries under *root* by file name / relative path.
285
+
286
+ A *position* is any folder that contains at least one recognized **input** —
287
+ cell labels and/or nucleus labels, each optional (*cell_name* / *nucleus_name*
288
+ are a bare file name or a path relative to the position folder). A folder is
289
+ registered once and carries whichever inputs it has; the others are ``None``.
290
+ The contact-analysis ``.h5`` is a derived Contact Analysis **output**,
291
+ not a discovery input: every position's contact path is the fixed default
292
+ location (:data:`CONTACT_ANALYSIS_RELPATH`) and need not exist yet (it is built
293
+ later by the contacts quantifier). The returned dicts carry the discovered
294
+ paths but no metadata (date / condition / notes) — that is assigned before
295
+ adding to the catalog.
296
+ """
297
+ root = Path(root)
298
+ if not root.is_dir():
299
+ return []
300
+
301
+ contact_rel = Path(CONTACT_ANALYSIS_RELPATH)
302
+ # position_dir -> {input_key: resolved_path}, grouped so a folder with several
303
+ # inputs becomes one entry.
304
+ by_position: dict[Path, dict] = {}
305
+ for key, name in (
306
+ ("cell_tracked_labels_path", cell_name),
307
+ ("nucleus_tracked_labels_path", nucleus_name),
308
+ ):
309
+ for position_dir, path in _discover_input(root, name):
310
+ by_position.setdefault(position_dir, {})[key] = path
311
+
312
+ entries: list[dict] = []
313
+ for position_dir in sorted(by_position):
314
+ entry = {
315
+ "id": position_dir.name,
316
+ "position_path": position_dir,
317
+ "contact_analysis_path": _resolve_path(position_dir / contact_rel),
318
+ "cell_tracked_labels_path": None,
319
+ "nucleus_tracked_labels_path": None,
320
+ }
321
+ entry.update(by_position[position_dir])
322
+ entries.append(entry)
323
+ return entries
324
+
325
+
326
+ def _discover_input(root: Path, name: str | None):
327
+ """Yield ``(position_dir, resolved_path)`` for each file matching *name*.
328
+
329
+ *name* is a bare file name or a path relative to the position folder; the
330
+ position folder is the file's parent with the relative parts stripped.
331
+ """
332
+ if not name:
333
+ return
334
+ rel = Path(name)
335
+ for match in sorted(root.rglob(rel.name)):
336
+ if not match.is_file():
337
+ continue
338
+ if len(rel.parts) > 1 and not _path_ends_with(match, rel):
339
+ continue
340
+ position_dir = match
341
+ for _ in rel.parts:
342
+ position_dir = position_dir.parent
343
+ yield _resolve_path(position_dir), _resolve_path(match)
344
+
345
+
346
+ def _path_ends_with(path: Path, rel: Path) -> bool:
347
+ rel_parts = rel.parts
348
+ return len(path.parts) >= len(rel_parts) and path.parts[-len(rel_parts):] == rel_parts
349
+
350
+
351
+ def _resolve_path(path: Path) -> Path:
352
+ return path.expanduser().resolve(strict=False)
353
+
354
+
355
+ def _resolve_with_base(raw: Path | str, base_dir: Path | None) -> Path:
356
+ path = Path(raw)
357
+ if base_dir is not None and not path.is_absolute():
358
+ path = base_dir / path
359
+ return _resolve_path(path)
360
+
361
+
362
+ def _resolve_optional_with_base(
363
+ value: Path | str | None, base_dir: Path | None
364
+ ) -> Path | None:
365
+ if value in (None, ""):
366
+ return None
367
+ return _resolve_with_base(value, base_dir)
368
+
369
+
370
+ def _path_for_csv(path: Path, base_dir: Path) -> str:
371
+ # Normalise to forward slashes so the catalog stays a portable artifact:
372
+ # os.path.relpath emits the host separator (``\`` on Windows), and a
373
+ # backslash-separated relative path fails to load on POSIX (Path treats it
374
+ # as a literal filename, not a separator). Forward slashes load on both.
375
+ try:
376
+ return Path(os.path.relpath(path, start=base_dir)).as_posix()
377
+ except ValueError:
378
+ return Path(path).as_posix()
379
+
380
+
381
+ def _optional_path_for_csv(path: Path | str | None, base_dir: Path) -> str:
382
+ if not path:
383
+ return ""
384
+ return _path_for_csv(Path(path), base_dir)
385
+
386
+
387
+ def _catalog_sort_key(record: dict) -> tuple[tuple[str, ...], str]:
388
+ """Order by the classification columns (name-sorted values), then the position
389
+ folder — a stable order that groups like-classified positions together
390
+ regardless of what the columns are named."""
391
+ columns = record.get("columns") or {}
392
+ values = tuple(str(columns[key]) for key in sorted(columns))
393
+ return (values, str(record.get("position_path") or ""))
@@ -0,0 +1,155 @@
1
+ """The TOML *run-config* — Contact Analysis's hand-authored knob file.
2
+
3
+ One small file, authored once and git-versioned (it carries *code status*, per the
4
+ artifact-contract spec §1): it names the per-position **catalog** CSV, selects
5
+ which **quantities** to compute, and supplies the shared build **params**.
6
+ Everything in it is a run-level choice — the
7
+ per-position table itself stays a CSV (tabular, many-row, with its own
8
+ relative-path resolution); this file is the "author once, then run" surface a
9
+ single ``run(config)`` entry point consumes.
10
+
11
+ The run produces **label-agnostic** tidy tables only; no classification step and
12
+ no plot/figure export live here (a subpopulation classification and any plots are a
13
+ downstream, dataset-specific concern).
14
+
15
+ Paths resolve relative to the config file's own directory, so a project folder is
16
+ relocatable: keep ``config.toml`` next to ``catalog.csv`` and the whole thing moves
17
+ together.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping, Sequence
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ try: # Python 3.11+
26
+ import tomllib
27
+ except ModuleNotFoundError: # pragma: no cover - exercised on 3.10 only
28
+ import tomli as tomllib # type: ignore[no-redef]
29
+
30
+ from .quantifier import available_quantifiers
31
+
32
+ __all__ = ["RunConfig", "load_config", "write_config"]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RunConfig:
37
+ """A parsed run-config. Paths are absolute (resolved against the config dir).
38
+
39
+ *quantities* empty means "every available quantifier"; a non-empty tuple
40
+ selects a subset by ``quantity_id`` (dependency producers are pulled in at run
41
+ time even when omitted). *params* is the shared build-knob mapping threaded to
42
+ quantifiers that opt in. *out_dir* is where the pooled tidy tables land (flat);
43
+ ``None`` defaults to the catalogue root (the positions' common ancestor).
44
+ """
45
+
46
+ catalog: Path
47
+ out_dir: Path | None = None
48
+ quantities: tuple[str, ...] = ()
49
+ params: dict = field(default_factory=dict)
50
+
51
+
52
+ def load_config(config_path: Path | str) -> RunConfig:
53
+ """Parse the TOML run-config at *config_path* into a :class:`RunConfig`.
54
+
55
+ ``catalog`` is required. Relative paths resolve against the config file's
56
+ directory. Selected ``quantities`` are validated against the registered
57
+ quantifiers so a typo fails loudly rather than silently computing nothing.
58
+ """
59
+ path = Path(config_path)
60
+ base = path.parent
61
+ with path.open("rb") as handle:
62
+ data = tomllib.load(handle)
63
+
64
+ if "catalog" not in data:
65
+ raise ValueError(
66
+ f"Run-config {path.name!r} is missing the required 'catalog' key "
67
+ "(the path to the per-position catalog CSV)."
68
+ )
69
+
70
+ quantities = tuple(data.get("quantities", ()))
71
+ _check_known_quantities(quantities)
72
+
73
+ out_dir = data.get("out_dir")
74
+ return RunConfig(
75
+ catalog=_resolve(base, data["catalog"]),
76
+ out_dir=_resolve(base, out_dir) if out_dir else None,
77
+ quantities=quantities,
78
+ params=dict(data.get("params", {})),
79
+ )
80
+
81
+
82
+ def _resolve(base: Path, raw: str) -> Path:
83
+ candidate = Path(raw)
84
+ if not candidate.is_absolute():
85
+ candidate = base / candidate
86
+ return candidate.expanduser().resolve(strict=False)
87
+
88
+
89
+ def _check_known_quantities(quantities: tuple[str, ...]) -> None:
90
+ known = {cls.quantity_id for cls in available_quantifiers()}
91
+ unknown = [q for q in quantities if q not in known]
92
+ if unknown:
93
+ listed = ", ".join(repr(q) for q in unknown)
94
+ available = ", ".join(sorted(known))
95
+ raise ValueError(
96
+ f"Run-config selects unknown quantit(y/ies): {listed}. "
97
+ f"Available: {available}."
98
+ )
99
+
100
+
101
+ def write_config(
102
+ path: Path | str,
103
+ *,
104
+ catalog: str = "catalog.csv",
105
+ out_dir: str | None = None,
106
+ quantities: Sequence[str] = (),
107
+ params: Mapping[str, object] | None = None,
108
+ ) -> Path:
109
+ """Author a TOML run-config at *path* — the inverse of :func:`load_config`.
110
+
111
+ Paths are written **relative** (verbatim), so the project folder stays
112
+ relocatable. ``out_dir`` (where the flat tables land) is emitted only when
113
+ given. ``quantities`` is emitted only when non-empty (empty round-trips to
114
+ ``()`` = "all"). ``params`` keys that are ``None`` are dropped (an unset pixel
115
+ size etc.). ``load_config(write_config(path, ...))`` reproduces the inputs
116
+ (paths resolved against ``path.parent``). Returns *path*.
117
+ """
118
+ path = Path(path)
119
+ lines: list[str] = [
120
+ f"catalog = {_toml_str(catalog)}",
121
+ ]
122
+ if out_dir is not None:
123
+ lines.append(f"out_dir = {_toml_str(out_dir)}")
124
+ if quantities:
125
+ lines.append(f"quantities = {_toml_array(quantities)}")
126
+
127
+ if params:
128
+ kept = {k: v for k, v in params.items() if v is not None}
129
+ if kept:
130
+ lines.append("")
131
+ lines.append("[params]")
132
+ lines.extend(f"{k} = {_toml_value(v)}" for k, v in kept.items())
133
+
134
+ lines.append("")
135
+ path.write_text("\n".join(lines), encoding="utf-8")
136
+ return path
137
+
138
+
139
+ def _toml_str(value: object) -> str:
140
+ """A TOML basic string: backslash and double-quote escaped."""
141
+ text = str(value).replace("\\", "\\\\").replace('"', '\\"')
142
+ return f'"{text}"'
143
+
144
+
145
+ def _toml_array(values: Sequence[object]) -> str:
146
+ return "[" + ", ".join(_toml_str(v) for v in values) + "]"
147
+
148
+
149
+ def _toml_value(value: object) -> str:
150
+ """Serialize a scalar for our closed schema (bool / int / float / str)."""
151
+ if isinstance(value, bool):
152
+ return "true" if value else "false"
153
+ if isinstance(value, (int, float)):
154
+ return repr(value)
155
+ return _toml_str(value)
@@ -0,0 +1,24 @@
1
+ """Cell-cell contact quantifier: edges, T1 events, neighbourhood & density.
2
+
3
+ The contacts domain logic that used to live at the top-level
4
+ ``itasc.contact_analysis`` package. It is one quantifier within
5
+ :mod:`itasc.contact_analysis`; its public API is re-exported from the
6
+ package root for stability. All of it is **label-agnostic**.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from itasc.contact_analysis.contacts.contact_labels import label_contacts
11
+ from itasc.contact_analysis.contacts.signed_contact_length import (
12
+ signed_central_junction_lengths,
13
+ )
14
+ from itasc.contact_analysis.contacts.neighborhood import (
15
+ cell_density,
16
+ cell_neighbor_counts,
17
+ )
18
+
19
+ __all__ = [
20
+ "label_contacts",
21
+ "signed_central_junction_lengths",
22
+ "cell_neighbor_counts",
23
+ "cell_density",
24
+ ]