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,141 @@
|
|
|
1
|
+
"""Compute a per-object, per-frame shape table — label-agnostic.
|
|
2
|
+
|
|
3
|
+
One row per ``(frame, object label)``: area, perimeter, equivalent diameter, the
|
|
4
|
+
fitted-ellipse axis lengths and their ratio, circularity, eccentricity,
|
|
5
|
+
solidity, extent, orientation, and centroid — all from
|
|
6
|
+
:func:`skimage.measure.regionprops`. Dimensional descriptors are converted to
|
|
7
|
+
physical units using the caller-supplied ``pixel_size_um`` (µm per pixel): areas
|
|
8
|
+
to µm² (``area_um2``), lengths and centroids to µm (the ``*_um`` columns). The
|
|
9
|
+
ratios (``aspect_ratio``, ``circularity``, ``eccentricity``, ``solidity``,
|
|
10
|
+
``extent``) are dimensionless and scale-invariant.
|
|
11
|
+
|
|
12
|
+
The core is **label-agnostic**: it runs over whatever label stack it is handed
|
|
13
|
+
(cell or nucleus), so the cell / nucleus quantifiers differ only by which input
|
|
14
|
+
field they read. The object-key column name is caller-supplied (always ``cell_id``
|
|
15
|
+
— really the shared track id).
|
|
16
|
+
|
|
17
|
+
The table is computed in memory (:func:`compute_object_shape`) and pooled by the
|
|
18
|
+
aggregate stage; nothing is persisted per position. The module is backend-only
|
|
19
|
+
(no Qt / napari import), so scripts and the standalone wheel can use it.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import tifffile
|
|
28
|
+
from skimage.measure import regionprops
|
|
29
|
+
|
|
30
|
+
#: The measured descriptor columns (everything but the tidy keys) — the value
|
|
31
|
+
#: axis a plot/export chooses from. Kept explicit so the on-disk order is stable.
|
|
32
|
+
DESCRIPTOR_COLUMNS = (
|
|
33
|
+
"area_um2",
|
|
34
|
+
"perimeter_um",
|
|
35
|
+
"equivalent_diameter_um",
|
|
36
|
+
"major_axis_length_um",
|
|
37
|
+
"minor_axis_length_um",
|
|
38
|
+
"aspect_ratio",
|
|
39
|
+
"circularity",
|
|
40
|
+
"eccentricity",
|
|
41
|
+
"solidity",
|
|
42
|
+
"extent",
|
|
43
|
+
"orientation",
|
|
44
|
+
"centroid_y_um",
|
|
45
|
+
"centroid_x_um",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_object_shape(
|
|
50
|
+
label_path: str | Path,
|
|
51
|
+
*,
|
|
52
|
+
pixel_size_um: float,
|
|
53
|
+
object_key: str = "cell_id",
|
|
54
|
+
) -> dict[str, np.ndarray]:
|
|
55
|
+
"""Per-object shape descriptors for every frame, as a column-major table.
|
|
56
|
+
|
|
57
|
+
Columns: ``frame``, *object_key*, then :data:`DESCRIPTOR_COLUMNS`."""
|
|
58
|
+
pixel_size_um = float(pixel_size_um)
|
|
59
|
+
if not pixel_size_um > 0:
|
|
60
|
+
raise ValueError(f"pixel_size_um must be positive, got {pixel_size_um!r}")
|
|
61
|
+
label_stack = read_label_stack(Path(label_path))
|
|
62
|
+
return _extract_shape_columns(label_stack, pixel_size_um, object_key)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_shape_columns(
|
|
66
|
+
label_stack: np.ndarray, pixel_size_um: float, object_key: str
|
|
67
|
+
) -> dict[str, np.ndarray]:
|
|
68
|
+
rows: list[dict[str, float | int]] = []
|
|
69
|
+
for frame_idx, frame in enumerate(label_stack):
|
|
70
|
+
for prop in sorted(regionprops(frame), key=lambda item: item.label):
|
|
71
|
+
rows.append(_shape_row(frame_idx, prop, pixel_size_um, object_key))
|
|
72
|
+
column_order = ("frame", object_key, *DESCRIPTOR_COLUMNS)
|
|
73
|
+
key_columns = ("frame", object_key)
|
|
74
|
+
return _columns_from_rows(rows, column_order, key_columns)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _shape_row(
|
|
78
|
+
frame_idx: int, prop, pixel_size_um: float, object_key: str
|
|
79
|
+
) -> dict[str, float | int]:
|
|
80
|
+
# Pixel-unit primitives: ratios are computed from these (scale-invariant),
|
|
81
|
+
# while dimensional outputs are scaled to µm / µm² below.
|
|
82
|
+
area_px = float(prop.area)
|
|
83
|
+
perimeter_px = float(prop.perimeter)
|
|
84
|
+
major_px = float(prop.axis_major_length)
|
|
85
|
+
minor_px = float(prop.axis_minor_length)
|
|
86
|
+
centroid_y, centroid_x = (float(c) for c in prop.centroid)
|
|
87
|
+
s = pixel_size_um
|
|
88
|
+
return {
|
|
89
|
+
"frame": int(frame_idx),
|
|
90
|
+
object_key: int(prop.label),
|
|
91
|
+
"area_um2": area_px * s * s,
|
|
92
|
+
"perimeter_um": perimeter_px * s,
|
|
93
|
+
"equivalent_diameter_um": float(prop.equivalent_diameter_area) * s,
|
|
94
|
+
"major_axis_length_um": major_px * s,
|
|
95
|
+
"minor_axis_length_um": minor_px * s,
|
|
96
|
+
# Degenerate (e.g. single-pixel) regions have a zero minor axis or
|
|
97
|
+
# perimeter; report NaN rather than dividing by zero.
|
|
98
|
+
"aspect_ratio": major_px / minor_px if minor_px > 0 else math.nan,
|
|
99
|
+
"circularity": circularity(area_px, perimeter_px),
|
|
100
|
+
"eccentricity": float(prop.eccentricity),
|
|
101
|
+
"solidity": float(prop.solidity),
|
|
102
|
+
"extent": float(prop.extent),
|
|
103
|
+
"orientation": float(prop.orientation),
|
|
104
|
+
"centroid_y_um": centroid_y * s,
|
|
105
|
+
"centroid_x_um": centroid_x * s,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def circularity(area: float, perimeter: float) -> float:
|
|
110
|
+
"""4π·area / perimeter², clamped to ≤ 1 (a perfect disk is 1.0)."""
|
|
111
|
+
if perimeter <= 0:
|
|
112
|
+
return math.nan
|
|
113
|
+
return min(4.0 * math.pi * area / (perimeter * perimeter), 1.0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ----------------------------------------------------------- shared CSV helpers
|
|
117
|
+
def _columns_from_rows(
|
|
118
|
+
rows: list[dict], column_order: tuple[str, ...], key_columns: tuple[str, ...]
|
|
119
|
+
) -> dict[str, np.ndarray]:
|
|
120
|
+
columns: dict[str, np.ndarray] = {}
|
|
121
|
+
for name in column_order:
|
|
122
|
+
values = [row[name] for row in rows]
|
|
123
|
+
dtype = np.int64 if name in key_columns else float
|
|
124
|
+
columns[name] = np.asarray(values, dtype=dtype)
|
|
125
|
+
return columns
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_label_stack(path: Path) -> np.ndarray:
|
|
129
|
+
path = Path(path)
|
|
130
|
+
if not path.exists():
|
|
131
|
+
raise FileNotFoundError(path)
|
|
132
|
+
arr = np.asarray(tifffile.imread(path))
|
|
133
|
+
if arr.ndim == 2:
|
|
134
|
+
arr = arr[np.newaxis, ...]
|
|
135
|
+
if arr.ndim != 3:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Expected a 2-D or 3-D tracked label TIFF at {path}, got shape {arr.shape}"
|
|
138
|
+
)
|
|
139
|
+
return arr.astype(np.int64, copy=False)
|
|
140
|
+
|
|
141
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Compute the relational nucleus-vs-cell shape table.
|
|
2
|
+
|
|
3
|
+
One row per ``(frame, cell_id)`` present in **both** the cell and nucleus label
|
|
4
|
+
stacks. Because cells are nucleus-seeded, a nucleus carries the same label id as
|
|
5
|
+
its cell, so pairing is a direct ``(frame, id)`` inner join — no geometry. Ids
|
|
6
|
+
present in only one source are dropped from the join.
|
|
7
|
+
|
|
8
|
+
The emitted columns are *relational* quantities — ratios and offsets between the
|
|
9
|
+
paired nucleus and cell — listed in :data:`RELATIONAL_COLUMNS`. The table is
|
|
10
|
+
computed in memory (:func:`compute_relational_table`) and pooled by the aggregate
|
|
11
|
+
stage; nothing is persisted per position. Backend-only (no Qt / napari).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from skimage.measure import regionprops
|
|
20
|
+
|
|
21
|
+
from .core import read_label_stack
|
|
22
|
+
|
|
23
|
+
#: The measured relational columns (everything but the tidy keys) — the value
|
|
24
|
+
#: axis a plot/export chooses from. Kept explicit so the on-disk order is stable.
|
|
25
|
+
RELATIONAL_COLUMNS = (
|
|
26
|
+
"nc_area_ratio",
|
|
27
|
+
"centroid_offset_um",
|
|
28
|
+
"centroid_offset_norm",
|
|
29
|
+
"orientation_delta",
|
|
30
|
+
"nc_perimeter_ratio",
|
|
31
|
+
"nc_major_axis_ratio",
|
|
32
|
+
"cell_area_um2",
|
|
33
|
+
"nucleus_area_um2",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_KEY_COLUMNS = ("frame", "cell_id")
|
|
37
|
+
_COLUMN_ORDER = (*_KEY_COLUMNS, *RELATIONAL_COLUMNS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compute_relational_table(
|
|
41
|
+
cell_labels_path: str | Path,
|
|
42
|
+
nucleus_labels_path: str | Path,
|
|
43
|
+
*,
|
|
44
|
+
pixel_size_um: float,
|
|
45
|
+
) -> dict[str, np.ndarray]:
|
|
46
|
+
"""The relational per-(frame, id) table, computed in memory (no file written)."""
|
|
47
|
+
pixel_size_um = float(pixel_size_um)
|
|
48
|
+
if not pixel_size_um > 0:
|
|
49
|
+
raise ValueError(f"pixel_size_um must be positive, got {pixel_size_um!r}")
|
|
50
|
+
cell_stack = read_label_stack(Path(cell_labels_path))
|
|
51
|
+
nucleus_stack = read_label_stack(Path(nucleus_labels_path))
|
|
52
|
+
cell_props = _object_props(cell_stack, pixel_size_um)
|
|
53
|
+
nucleus_props = _object_props(nucleus_stack, pixel_size_um)
|
|
54
|
+
rows, _dropped = _join_rows(cell_props, nucleus_props)
|
|
55
|
+
return _columns_from_rows(rows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _object_props(
|
|
59
|
+
label_stack: np.ndarray, pixel_size_um: float
|
|
60
|
+
) -> dict[tuple[int, int], dict[str, float]]:
|
|
61
|
+
"""Map ``(frame, label)`` -> the per-object primitives the join needs."""
|
|
62
|
+
s = pixel_size_um
|
|
63
|
+
out: dict[tuple[int, int], dict[str, float]] = {}
|
|
64
|
+
for frame_idx, frame in enumerate(label_stack):
|
|
65
|
+
for prop in regionprops(frame):
|
|
66
|
+
centroid_y, centroid_x = (float(c) for c in prop.centroid)
|
|
67
|
+
out[(int(frame_idx), int(prop.label))] = {
|
|
68
|
+
"area_um2": float(prop.area) * s * s,
|
|
69
|
+
"perimeter_um": float(prop.perimeter) * s,
|
|
70
|
+
"major_axis_length_um": float(prop.axis_major_length) * s,
|
|
71
|
+
"orientation": float(prop.orientation),
|
|
72
|
+
"centroid_y_um": centroid_y * s,
|
|
73
|
+
"centroid_x_um": centroid_x * s,
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _join_rows(
|
|
79
|
+
cell_props: dict[tuple[int, int], dict[str, float]],
|
|
80
|
+
nucleus_props: dict[tuple[int, int], dict[str, float]],
|
|
81
|
+
) -> tuple[list[dict[str, float | int]], int]:
|
|
82
|
+
"""Inner-join on ``(frame, id)``; return rows + the count of dropped ids."""
|
|
83
|
+
shared = sorted(set(cell_props) & set(nucleus_props))
|
|
84
|
+
dropped = len(set(cell_props) ^ set(nucleus_props))
|
|
85
|
+
rows = [
|
|
86
|
+
_relational_row(frame, cell_id, cell_props[(frame, cell_id)], nucleus_props[(frame, cell_id)])
|
|
87
|
+
for frame, cell_id in shared
|
|
88
|
+
]
|
|
89
|
+
return rows, dropped
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _relational_row(
|
|
93
|
+
frame: int, cell_id: int, cell: dict[str, float], nucleus: dict[str, float]
|
|
94
|
+
) -> dict[str, float | int]:
|
|
95
|
+
cell_area = cell["area_um2"]
|
|
96
|
+
offset = math.hypot(
|
|
97
|
+
nucleus["centroid_y_um"] - cell["centroid_y_um"],
|
|
98
|
+
nucleus["centroid_x_um"] - cell["centroid_x_um"],
|
|
99
|
+
)
|
|
100
|
+
equiv_radius = math.sqrt(cell_area / math.pi) if cell_area > 0 else math.nan
|
|
101
|
+
return {
|
|
102
|
+
"frame": int(frame),
|
|
103
|
+
"cell_id": int(cell_id),
|
|
104
|
+
"nc_area_ratio": _ratio(nucleus["area_um2"], cell_area),
|
|
105
|
+
"centroid_offset_um": offset,
|
|
106
|
+
"centroid_offset_norm": offset / equiv_radius if equiv_radius > 0 else math.nan,
|
|
107
|
+
"orientation_delta": _fold_orientation(nucleus["orientation"] - cell["orientation"]),
|
|
108
|
+
"nc_perimeter_ratio": _ratio(nucleus["perimeter_um"], cell["perimeter_um"]),
|
|
109
|
+
"nc_major_axis_ratio": _ratio(
|
|
110
|
+
nucleus["major_axis_length_um"], cell["major_axis_length_um"]
|
|
111
|
+
),
|
|
112
|
+
"cell_area_um2": cell_area,
|
|
113
|
+
"nucleus_area_um2": nucleus["area_um2"],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ratio(numerator: float, denominator: float) -> float:
|
|
118
|
+
return numerator / denominator if denominator > 0 else math.nan
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fold_orientation(delta: float) -> float:
|
|
122
|
+
"""Fold an ellipse-orientation difference into ``[0, π/2]`` (axes are
|
|
123
|
+
undirected, so a π flip and the sign of the gap are immaterial)."""
|
|
124
|
+
folded = abs(delta) % math.pi
|
|
125
|
+
return min(folded, math.pi - folded)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _columns_from_rows(rows: list[dict]) -> dict[str, np.ndarray]:
|
|
129
|
+
columns: dict[str, np.ndarray] = {}
|
|
130
|
+
for name in _COLUMN_ORDER:
|
|
131
|
+
values = [row[name] for row in rows]
|
|
132
|
+
dtype = np.int64 if name in _KEY_COLUMNS else float
|
|
133
|
+
columns[name] = np.asarray(values, dtype=dtype)
|
|
134
|
+
return columns
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Aggregated *shape tables* — index-keyed, materialized views of the products.
|
|
2
|
+
|
|
3
|
+
Contact Analysis builds one per-position artifact per quantity (a tidy
|
|
4
|
+
``object_table``). This module pools those, across the in-scope positions, into a
|
|
5
|
+
small set of **aggregated, index-keyed tables** — one file per distinct natural
|
|
6
|
+
index — written **flat** at ``<out_dir>/<name>.csv``. They are **materialized
|
|
7
|
+
views**: regenerate-whole, never upserted, so a re-aggregate rewrites the file from
|
|
8
|
+
scratch and CSV stays viable (no concurrent partial write into a shared file). The
|
|
9
|
+
per-position artifacts remain the normalized source of truth; these tables are a
|
|
10
|
+
reproducible projection of them.
|
|
11
|
+
|
|
12
|
+
The pooled tables are **label-agnostic**: a per-cell subpopulation classification
|
|
13
|
+
(NLS ``class_label`` etc.) is not joined here. A consumer that wants a class split
|
|
14
|
+
joins it downstream from the dataset that defines it (keyed on
|
|
15
|
+
``experiment_id, position_id, cell_id``).
|
|
16
|
+
|
|
17
|
+
Partitioning principle: **one table per quantifier.** Each
|
|
18
|
+
:class:`~itasc.contact_analysis.quantifier.Quantifier` that aggregates
|
|
19
|
+
declares its natural index (``table_keys``); its tidy ``object_table`` is pooled
|
|
20
|
+
across positions into a table named by the quantifier's ``quantity_id``. Value
|
|
21
|
+
columns stay namespaced by ``quantity_id`` (``cell_shape.area_um2``) so a later
|
|
22
|
+
joined *view* across tables never has colliding names. The keys and the catalogue
|
|
23
|
+
metadata (the classification columns — whatever the widget defined) stay
|
|
24
|
+
bare. (Previously several quantities sharing an index were outer-joined into one
|
|
25
|
+
wide table — that produced god tables and is gone.)
|
|
26
|
+
|
|
27
|
+
Generalizes the old one-quantity-at-a-time pooling
|
|
28
|
+
(``napari…plots._pooling.pool_quantity``) to "all quantities of an index,
|
|
29
|
+
persisted." Backend-only (no Qt) so headless / batch runs and the standalone
|
|
30
|
+
wheel use it unchanged.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
import pandas as pd
|
|
43
|
+
|
|
44
|
+
from ._provenance import itasc_version as _itasc_version
|
|
45
|
+
from .quantifier import Quantifier, available_quantifiers
|
|
46
|
+
from .records import available_fields, position_inputs_from_record, record_build_params
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ShapeTableSpec",
|
|
50
|
+
"shape_table_registry",
|
|
51
|
+
"table_for_quantity",
|
|
52
|
+
"build_table",
|
|
53
|
+
"read_table",
|
|
54
|
+
"aggregate",
|
|
55
|
+
"table_path",
|
|
56
|
+
"catalogue_root",
|
|
57
|
+
"PROVENANCE_NAME",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
#: The single run-level provenance sidecar written beside the pooled tables. The
|
|
61
|
+
#: cheap quantities are computed in memory and never persisted per-position, so
|
|
62
|
+
#: their old per-position ``.provenance.json`` sidecars are gone; this one file
|
|
63
|
+
#: records how a whole aggregate run was produced instead.
|
|
64
|
+
PROVENANCE_NAME = "provenance.json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ShapeTableSpec:
|
|
69
|
+
"""One aggregated table: a single quantifier's pooled ``object_table`` — the
|
|
70
|
+
table name (its ``quantity_id``), the index keys, and the contributing quantity
|
|
71
|
+
(``quantity_ids`` carries the one id, kept a tuple for the pooling machinery)."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
keys: tuple[str, ...]
|
|
75
|
+
quantity_ids: tuple[str, ...]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def shape_table_registry() -> dict[str, ShapeTableSpec]:
|
|
79
|
+
"""Map table name → :class:`ShapeTableSpec`. Each aggregating quantifier — one
|
|
80
|
+
that declares a ``table_keys`` index — is its **own** table, named by its
|
|
81
|
+
``quantity_id`` and keyed by its own grain. No quantifier shares a table with
|
|
82
|
+
another (no god tables)."""
|
|
83
|
+
return {
|
|
84
|
+
q_cls.quantity_id: ShapeTableSpec(
|
|
85
|
+
name=q_cls.quantity_id,
|
|
86
|
+
keys=tuple(q_cls.table_keys),
|
|
87
|
+
quantity_ids=(q_cls.quantity_id,),
|
|
88
|
+
)
|
|
89
|
+
for q_cls in available_quantifiers()
|
|
90
|
+
if q_cls.table_keys
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def table_for_quantity(quantity_id: str) -> str | None:
|
|
95
|
+
"""The aggregated table *quantity_id* lands in — its own ``quantity_id`` when it
|
|
96
|
+
aggregates (declares ``table_keys``), else ``None`` (contacts and other
|
|
97
|
+
non-aggregated quantities)."""
|
|
98
|
+
for q_cls in available_quantifiers():
|
|
99
|
+
if q_cls.quantity_id == quantity_id:
|
|
100
|
+
return q_cls.quantity_id if q_cls.table_keys else None
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _quantifiers_for(spec: ShapeTableSpec) -> list[Quantifier]:
|
|
105
|
+
by_id = {q_cls.quantity_id: q_cls for q_cls in available_quantifiers()}
|
|
106
|
+
return [by_id[qid]() for qid in spec.quantity_ids if qid in by_id]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_table(
|
|
110
|
+
name: str,
|
|
111
|
+
records: Iterable[dict],
|
|
112
|
+
*,
|
|
113
|
+
params: Mapping[str, object] | None = None,
|
|
114
|
+
) -> pd.DataFrame:
|
|
115
|
+
"""Pool the quantifier *name*'s per-position table across *records* into one frame.
|
|
116
|
+
|
|
117
|
+
For each in-scope record the quantifier's tidy table is computed in memory via
|
|
118
|
+
:meth:`~itasc.contact_analysis.quantifier.Quantifier.compute_object_table`
|
|
119
|
+
(value columns namespaced by ``quantity_id``) and the catalogue metadata is
|
|
120
|
+
stamped; the per-position frames are then concatenated. *params* carries the
|
|
121
|
+
shared build knobs (e.g. ``fov_area_mm2``, ``pixel_size_um``) — the same ones
|
|
122
|
+
:func:`~itasc.contact_analysis.pipeline.build_quantities` threads into
|
|
123
|
+
``build``, so a param-gated quantifier (cell shape needs ``pixel_size_um``)
|
|
124
|
+
pools consistently with what was actually built. The result is label-agnostic
|
|
125
|
+
(no ``class_label`` join). Empty when nothing computes. In-memory only — see
|
|
126
|
+
:func:`aggregate` to persist.
|
|
127
|
+
"""
|
|
128
|
+
spec = shape_table_registry().get(name)
|
|
129
|
+
if spec is None:
|
|
130
|
+
raise KeyError(f"No aggregated table named {name!r}")
|
|
131
|
+
quantifiers = _quantifiers_for(spec)
|
|
132
|
+
frames: list[pd.DataFrame] = []
|
|
133
|
+
identity_cols: list[str] = []
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for record in records:
|
|
136
|
+
merged = _position_frame(record, quantifiers, spec.keys, params)
|
|
137
|
+
if merged is None:
|
|
138
|
+
continue
|
|
139
|
+
meta = _position_metadata(record)
|
|
140
|
+
for key in meta:
|
|
141
|
+
if key not in seen:
|
|
142
|
+
seen.add(key)
|
|
143
|
+
identity_cols.append(key)
|
|
144
|
+
# Insert in reverse so the metadata columns lead the frame in bag order.
|
|
145
|
+
for key, value in reversed(list(meta.items())):
|
|
146
|
+
merged.insert(0, key, value)
|
|
147
|
+
frames.append(merged)
|
|
148
|
+
if not frames:
|
|
149
|
+
return pd.DataFrame()
|
|
150
|
+
pooled = pd.concat(frames, ignore_index=True)
|
|
151
|
+
_assign_row_id(pooled, identity_cols, spec.keys)
|
|
152
|
+
return pooled
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: Separator joining the id components; chosen not to occur in the identifier
|
|
156
|
+
#: values it concatenates.
|
|
157
|
+
_ROW_ID_SEP = "|"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _assign_row_id(
|
|
161
|
+
df: pd.DataFrame, identity_cols: Sequence[str], keys: tuple[str, ...]
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Insert a deterministic ``id`` first column: the row's identity (the
|
|
164
|
+
classification columns) plus the table's grain keys, joined as a string.
|
|
165
|
+
Stable across regeneration (a function of identity, not row order) so a row
|
|
166
|
+
keeps the same identity across rebuilds — what downstream consumers key on,
|
|
167
|
+
and what any upstream annotation joined by ``id`` relies on."""
|
|
168
|
+
components = [*identity_cols, *(k for k in keys if k not in identity_cols)]
|
|
169
|
+
present = [c for c in components if c in df.columns]
|
|
170
|
+
if not present:
|
|
171
|
+
return
|
|
172
|
+
# Vectorized string join (``str.cat``). The previous ``agg('|'.join, axis=1)``
|
|
173
|
+
# raised "Expected a one-dimensional object" whenever ``present`` named a
|
|
174
|
+
# duplicate column label; ``str.cat`` over explicit single columns is robust.
|
|
175
|
+
row_id = df[present[0]].astype(str)
|
|
176
|
+
for column in present[1:]:
|
|
177
|
+
row_id = row_id.str.cat(df[column].astype(str), sep=_ROW_ID_SEP)
|
|
178
|
+
df.insert(0, "id", row_id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _position_frame(
|
|
182
|
+
record: dict,
|
|
183
|
+
quantifiers: list[Quantifier],
|
|
184
|
+
keys: tuple[str, ...],
|
|
185
|
+
params: Mapping[str, object] | None,
|
|
186
|
+
) -> pd.DataFrame | None:
|
|
187
|
+
"""Outer-join (within one position) every co-targeting quantity on the table's
|
|
188
|
+
keys, namespacing each quantity's value columns. ``None`` when no targeting
|
|
189
|
+
quantity yields a table for this position."""
|
|
190
|
+
inputs = position_inputs_from_record(record)
|
|
191
|
+
merged: pd.DataFrame | None = None
|
|
192
|
+
for quantifier in quantifiers:
|
|
193
|
+
if not set(quantifier.requires) <= available_fields(inputs):
|
|
194
|
+
continue
|
|
195
|
+
if quantifier.missing_build_params(record_build_params(quantifier, record, params)):
|
|
196
|
+
continue
|
|
197
|
+
table = quantifier.compute_object_table(inputs, params=dict(params) if params else None)
|
|
198
|
+
if not table:
|
|
199
|
+
continue
|
|
200
|
+
df = pd.DataFrame({k: np.asarray(v) for k, v in dict(table).items()})
|
|
201
|
+
present_keys = [k for k in keys if k in df.columns]
|
|
202
|
+
value_cols = [c for c in df.columns if c not in present_keys]
|
|
203
|
+
df = df[[*present_keys, *value_cols]].rename(
|
|
204
|
+
columns={c: f"{quantifier.quantity_id}.{c}" for c in value_cols}
|
|
205
|
+
)
|
|
206
|
+
if merged is None:
|
|
207
|
+
merged = df
|
|
208
|
+
else:
|
|
209
|
+
on = [k for k in keys if k in merged.columns and k in df.columns]
|
|
210
|
+
merged = merged.merge(df, on=on, how="outer") if on else pd.concat(
|
|
211
|
+
[merged, df], ignore_index=True
|
|
212
|
+
)
|
|
213
|
+
return merged
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _position_metadata(record: dict) -> dict[str, str]:
|
|
217
|
+
"""The per-position descriptor columns stamped onto every pooled row — the
|
|
218
|
+
record's classification columns (``record["columns"]``, the widget columns),
|
|
219
|
+
verbatim. Their combination is the position's identity: it forms the row-id
|
|
220
|
+
prefix and is what the aggregator checks for uniqueness."""
|
|
221
|
+
return {key: str(value) for key, value in (record.get("columns") or {}).items()}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def catalogue_root(records: Sequence[dict]) -> Path:
|
|
225
|
+
"""A stable home for the aggregated tables: the common ancestor of the
|
|
226
|
+
in-scope positions' folders. Falls back to the first position's parent, then
|
|
227
|
+
to the current directory when no position folder is known."""
|
|
228
|
+
dirs = [Path(r["position_path"]) for r in records if r.get("position_path")]
|
|
229
|
+
if not dirs:
|
|
230
|
+
return Path.cwd()
|
|
231
|
+
if len(dirs) == 1:
|
|
232
|
+
return dirs[0].parent
|
|
233
|
+
try:
|
|
234
|
+
return Path(os.path.commonpath([str(d) for d in dirs]))
|
|
235
|
+
except ValueError: # paths on different drives (Windows) — no common root
|
|
236
|
+
return dirs[0].parent
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def table_path(out_dir: Path | str, name: str) -> Path:
|
|
240
|
+
"""Where table *name*'s CSV lives: **flat** under *out_dir*."""
|
|
241
|
+
return Path(out_dir) / f"{name}.csv"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def aggregate(
|
|
245
|
+
records: Sequence[dict],
|
|
246
|
+
out_dir: Path | str | None = None,
|
|
247
|
+
*,
|
|
248
|
+
params: Mapping[str, object] | None = None,
|
|
249
|
+
quantities: Sequence[str] | None = None,
|
|
250
|
+
) -> dict[str, Path]:
|
|
251
|
+
"""Regenerate every aggregated table for the in-scope *records*; return the
|
|
252
|
+
table name → written CSV path map.
|
|
253
|
+
|
|
254
|
+
Materialized-view semantics: each table is rebuilt whole and its CSV
|
|
255
|
+
overwritten (a previously-written table for a now-out-of-scope position is
|
|
256
|
+
simply not regenerated with that position). A table that pools to no rows is
|
|
257
|
+
skipped (no empty file). *out_dir* defaults to :func:`catalogue_root`. *params*
|
|
258
|
+
is forwarded to :func:`build_table` (the shared build knobs the param-gated
|
|
259
|
+
quantifiers need to compute at all).
|
|
260
|
+
|
|
261
|
+
*quantities* restricts which tables are written: ``None`` (the default) writes
|
|
262
|
+
every registered table; a sequence writes only those whose ``quantity_id`` it
|
|
263
|
+
names (unknown / non-tabular ids — e.g. the ``contacts`` producer — are simply
|
|
264
|
+
absent from the table registry and ignored). An empty sequence writes nothing.
|
|
265
|
+
"""
|
|
266
|
+
_require_unique_identity(records)
|
|
267
|
+
root = Path(out_dir) if out_dir is not None else catalogue_root(records)
|
|
268
|
+
selected = None if quantities is None else set(quantities)
|
|
269
|
+
written: dict[str, Path] = {}
|
|
270
|
+
quant_meta: dict[str, dict[str, object]] = {}
|
|
271
|
+
for name in shape_table_registry():
|
|
272
|
+
if selected is not None and name not in selected:
|
|
273
|
+
continue
|
|
274
|
+
df = build_table(name, records, params=params)
|
|
275
|
+
if df.empty:
|
|
276
|
+
# In scope for this run but pooled to no rows. Still recorded in
|
|
277
|
+
# provenance — uniformly with the tables that did materialize — so
|
|
278
|
+
# every quantifier the seam attempted is auditable, not just the
|
|
279
|
+
# ones that happened to write a file. No empty CSV is written.
|
|
280
|
+
quant_meta[name] = {"rows": 0, "columns": []}
|
|
281
|
+
continue
|
|
282
|
+
path = table_path(root, name)
|
|
283
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
df.to_csv(path, index=False)
|
|
285
|
+
written[name] = path
|
|
286
|
+
quant_meta[name] = {"rows": int(len(df)), "columns": list(df.columns)}
|
|
287
|
+
if written:
|
|
288
|
+
_write_run_provenance(root, records, quant_meta, params)
|
|
289
|
+
return written
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _identity(record: dict) -> tuple[tuple[str, str], ...]:
|
|
293
|
+
"""A position's identity: its classification columns as a name-sorted tuple of
|
|
294
|
+
``(column, value)`` pairs. Two positions with equal identities would pool their
|
|
295
|
+
cells under one row-id and silently merge — the collision the aggregator refuses."""
|
|
296
|
+
columns = record.get("columns") or {}
|
|
297
|
+
return tuple(sorted((str(k), str(v)) for k, v in columns.items()))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _require_unique_identity(records: Sequence[dict]) -> None:
|
|
301
|
+
"""Refuse to aggregate when the classification columns do not uniquely identify
|
|
302
|
+
the in-scope positions.
|
|
303
|
+
|
|
304
|
+
The pooled row-id is the combination of a position's columns plus the grain
|
|
305
|
+
keys; if two positions share every column value, their cells collapse onto the
|
|
306
|
+
same identities and merge silently. Rather than corrupt the pooled table, stop
|
|
307
|
+
and explain which positions collide and that a distinguishing column is needed.
|
|
308
|
+
"""
|
|
309
|
+
groups: dict[tuple[tuple[str, str], ...], list[dict]] = {}
|
|
310
|
+
for record in records:
|
|
311
|
+
groups.setdefault(_identity(record), []).append(record)
|
|
312
|
+
collisions = {ident: recs for ident, recs in groups.items() if len(recs) > 1}
|
|
313
|
+
if not collisions:
|
|
314
|
+
return
|
|
315
|
+
blocks = []
|
|
316
|
+
for ident, recs in collisions.items():
|
|
317
|
+
shown = ", ".join(f"{name}={value!r}" for name, value in ident) or "(no columns)"
|
|
318
|
+
paths = "\n".join(f" {r.get('position_path', '?')}" for r in recs)
|
|
319
|
+
blocks.append(f" {shown}\n{paths}")
|
|
320
|
+
detail = "\n".join(blocks)
|
|
321
|
+
raise ValueError(
|
|
322
|
+
"Cannot aggregate: these positions are not uniquely identified by their "
|
|
323
|
+
"columns. Positions sharing identical column values would pool their cells "
|
|
324
|
+
"under one identity and silently merge. Add or edit a column that "
|
|
325
|
+
f"distinguishes them:\n{detail}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _write_run_provenance(
|
|
330
|
+
root: Path,
|
|
331
|
+
records: Sequence[dict],
|
|
332
|
+
quant_meta: Mapping[str, dict[str, object]],
|
|
333
|
+
params: Mapping[str, object] | None,
|
|
334
|
+
) -> Path:
|
|
335
|
+
"""Write the run-level ``provenance.json`` beside the pooled tables.
|
|
336
|
+
|
|
337
|
+
One file per aggregate run (not per position — the cheap quantities are
|
|
338
|
+
computed in memory, so there are no per-position artifacts to sidecar). It
|
|
339
|
+
records the shared build params, the contributing positions (identity +
|
|
340
|
+
their source paths), and — under ``quantifiers`` — **every** in-scope pooled
|
|
341
|
+
quantifier the run attempted, each with its pooled row count and columns
|
|
342
|
+
(``rows: 0`` / empty ``columns`` for one that yielded nothing and so wrote no
|
|
343
|
+
CSV). Provenance is thus a property of the quantifier seam, uniform across
|
|
344
|
+
quantifiers, rather than a record of only the tables that materialized. Plus
|
|
345
|
+
the itasc version and a UTC timestamp — enough to reconstruct how the
|
|
346
|
+
tables were produced.
|
|
347
|
+
"""
|
|
348
|
+
record = {
|
|
349
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
350
|
+
"itasc_version": _itasc_version(),
|
|
351
|
+
"params": dict(params or {}),
|
|
352
|
+
"positions": [_provenance_position(r) for r in records],
|
|
353
|
+
"quantifiers": {name: dict(meta) for name, meta in quant_meta.items()},
|
|
354
|
+
}
|
|
355
|
+
path = Path(root) / PROVENANCE_NAME
|
|
356
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
path.write_text(json.dumps(record, indent=2, sort_keys=True))
|
|
358
|
+
return path
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _provenance_position(record: dict) -> dict[str, str]:
|
|
362
|
+
"""Identity + source paths for one contributing position, for provenance.
|
|
363
|
+
|
|
364
|
+
The identity is the position's classification columns (verbatim), alongside its
|
|
365
|
+
source paths — enough to trace any pooled row back to the folder it came from."""
|
|
366
|
+
prov = {
|
|
367
|
+
"position_path": str(record.get("position_path", "")),
|
|
368
|
+
"contact_analysis_path": str(record.get("contact_analysis_path", "")),
|
|
369
|
+
}
|
|
370
|
+
prov.update({key: str(value) for key, value in (record.get("columns") or {}).items()})
|
|
371
|
+
return prov
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def read_table(path: Path | str) -> pd.DataFrame:
|
|
375
|
+
"""Read an aggregated-table CSV, restoring integer dtype on the key columns
|
|
376
|
+
(``frame`` / ``*_id``) so downstream group-bys match the in-memory build."""
|
|
377
|
+
df = pd.read_csv(path)
|
|
378
|
+
for column in df.columns:
|
|
379
|
+
# The integer grain keys (``frame`` / per-object ``*_id``). The row-id
|
|
380
|
+
# column is named exactly ``id`` (never matches ``*_id``) and stays a
|
|
381
|
+
# string; classification columns are strings and only narrow when they
|
|
382
|
+
# happen to be fully numeric — harmless for a descriptor. Only a numeric,
|
|
383
|
+
# fully-populated column is narrowed (an outer-join NaN keeps it float).
|
|
384
|
+
if column == "frame" or column.endswith("_id"):
|
|
385
|
+
if pd.api.types.is_numeric_dtype(df[column]) and df[column].notna().all():
|
|
386
|
+
df[column] = df[column].astype(np.int64)
|
|
387
|
+
return df
|
itasc/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Cooperative-cancellation primitive shared across ITASC pieces.
|
|
2
|
+
|
|
3
|
+
Long-running workers (segmentation, tracking, correction) poll a ``cancel``
|
|
4
|
+
callback and raise :class:`CancelledError` when it returns ``True``. The
|
|
5
|
+
exception lives in ``itasc-core`` so the independently-installable pieces can
|
|
6
|
+
share one cancellation type without depending on the segmentation stage.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CancelledError(Exception):
|
|
12
|
+
"""Raised when a cooperative cancel signal is observed mid-computation."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["CancelledError"]
|