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,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
|
+
]
|