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