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,136 @@
1
+ """Contact cell-type labels — propagate a per-cell label onto contacts.
2
+
3
+ Each cell–cell contact (a row of the ``edges`` table) is labelled by the
4
+ unordered pair of its two cells' labels, supplied as a generic ``cell_id -> label``
5
+ map. A contact between cell types ``A`` and ``B`` is labelled ``"A-B"`` (sorted), so
6
+ a consumer can ask how subpopulations contact each other — homotypic vs
7
+ heterotypic — without that aggregation living here. The label map is the caller's
8
+ concern (e.g. a downstream, dataset-specific classification); this module is
9
+ label-agnostic.
10
+
11
+ Headless and Qt-free, like
12
+ :mod:`itasc.contact_analysis.contacts.signed_contact_length`: it operates on an
13
+ already-read
14
+ :class:`~itasc.contact_analysis.contacts.reader.PositionContactAnalysis`
15
+ plus a ``cell_id -> label`` map, so it never opens HDF5 itself and runs unchanged in
16
+ scripts, notebooks, and plugins.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+
22
+ import numpy as np
23
+
24
+ from itasc.contact_analysis.contacts.reader import PositionContactAnalysis
25
+
26
+ #: Columns the label table carries, in order. Kept as a constant so the empty
27
+ #: table and the populated one cannot drift apart.
28
+ _COLUMNS = (
29
+ "frame",
30
+ "edge_id",
31
+ "cell_a",
32
+ "cell_b",
33
+ "label_a",
34
+ "label_b",
35
+ "contact_label",
36
+ "homotypic",
37
+ "fully_classified",
38
+ "length",
39
+ )
40
+
41
+
42
+ def contact_label_for(
43
+ labels: Mapping[int, str],
44
+ cell_a: int,
45
+ cell_b: int,
46
+ *,
47
+ unclassified: str = "unclassified",
48
+ ) -> str:
49
+ """The sorted ``"label_a-label_b"`` contact label for one cell pair.
50
+
51
+ Each cell's label is looked up in *labels* (``cell_id -> label``); a cell
52
+ absent from the map takes *unclassified*. The pair is sorted so the contact
53
+ type is orientation-independent (``"A-B" == "B-A"``). This is the single
54
+ definition shared by :func:`label_contacts` (the per-edge table) and the
55
+ signed-contact-length reaction coordinate, so a contact types identically
56
+ wherever it is seen.
57
+ """
58
+ label_a = labels.get(int(cell_a), unclassified)
59
+ label_b = labels.get(int(cell_b), unclassified)
60
+ return "-".join(sorted((label_a, label_b)))
61
+
62
+
63
+ def label_contacts(
64
+ analysis: PositionContactAnalysis,
65
+ labels: Mapping[int, str],
66
+ *,
67
+ unclassified: str = "unclassified",
68
+ ) -> dict[str, np.ndarray]:
69
+ """Label every cell–cell contact by its two cells' NLS subpopulation labels.
70
+
71
+ For each ``kind == "cell_cell"`` edge, each endpoint's label is looked up in
72
+ *labels* (``cell_id -> label``; a cell absent from the map takes
73
+ *unclassified*) and the unordered-pair contact label
74
+ ``"-".join(sorted([label_a, label_b]))`` is formed. The vocabulary is whatever
75
+ *labels* holds — nothing here is hard-wired to positive/negative.
76
+
77
+ Border edges (``kind == "border"``, ``cell_b == 0``) are not contacts between
78
+ two cells and are excluded. Fragments are **not** joined: a boundary split
79
+ across several edge rows yields several labelled rows that share the same
80
+ ``(frame, cell_a, cell_b)`` and therefore the same label; ``edge_id`` and
81
+ ``length`` are carried through so a consumer can join or length-weight later.
82
+
83
+ Columns (column-major, all equal length, one row per cell–cell edge):
84
+
85
+ * ``frame`` — the edge's frame.
86
+ * ``edge_id`` — the edge's id within its frame.
87
+ * ``cell_a`` / ``cell_b`` — the contacting cell ids (as stored, ``a < b``).
88
+ * ``label_a`` / ``label_b`` — each cell's NLS label, or *unclassified*.
89
+ * ``contact_label`` — sorted ``"label_a-label_b"`` pair.
90
+ * ``homotypic`` — ``label_a == label_b`` (True for two unclassified;
91
+ gate on ``fully_classified`` if that matters).
92
+ * ``fully_classified`` — both cells had a label in *labels*.
93
+ * ``length`` — the edge length, carried through for weighting.
94
+
95
+ Returns empty (but typed) arrays when there are no cell–cell edges.
96
+ """
97
+ edges = analysis.edges
98
+ frame = np.asarray(edges.get("frame", ()), dtype=np.int64)
99
+ edge_id = np.asarray(edges.get("edge_id", ()), dtype=np.int64)
100
+ cell_a = np.asarray(edges.get("cell_a", ()), dtype=np.int64)
101
+ cell_b = np.asarray(edges.get("cell_b", ()), dtype=np.int64)
102
+ length = np.asarray(edges.get("length", ()), dtype=float)
103
+ kind = np.asarray(edges.get("kind", np.full(frame.shape, "cell_cell")), dtype=object)
104
+
105
+ out: dict[str, list] = {name: [] for name in _COLUMNS}
106
+
107
+ for i in range(frame.size):
108
+ if str(kind[i]) != "cell_cell":
109
+ continue
110
+ ca, cb = int(cell_a[i]), int(cell_b[i])
111
+ present_a, present_b = ca in labels, cb in labels
112
+ label_a = labels.get(ca, unclassified)
113
+ label_b = labels.get(cb, unclassified)
114
+ out["frame"].append(int(frame[i]))
115
+ out["edge_id"].append(int(edge_id[i]) if edge_id.size else 0)
116
+ out["cell_a"].append(ca)
117
+ out["cell_b"].append(cb)
118
+ out["label_a"].append(label_a)
119
+ out["label_b"].append(label_b)
120
+ out["contact_label"].append(contact_label_for(labels, ca, cb, unclassified=unclassified))
121
+ out["homotypic"].append(label_a == label_b)
122
+ out["fully_classified"].append(present_a and present_b)
123
+ out["length"].append(float(length[i]))
124
+
125
+ return {
126
+ "frame": np.asarray(out["frame"], dtype=np.int64),
127
+ "edge_id": np.asarray(out["edge_id"], dtype=np.int64),
128
+ "cell_a": np.asarray(out["cell_a"], dtype=np.int64),
129
+ "cell_b": np.asarray(out["cell_b"], dtype=np.int64),
130
+ "label_a": np.asarray(out["label_a"], dtype=object),
131
+ "label_b": np.asarray(out["label_b"], dtype=object),
132
+ "contact_label": np.asarray(out["contact_label"], dtype=object),
133
+ "homotypic": np.asarray(out["homotypic"], dtype=bool),
134
+ "fully_classified": np.asarray(out["fully_classified"], dtype=bool),
135
+ "length": np.asarray(out["length"], dtype=float),
136
+ }
@@ -0,0 +1,146 @@
1
+ """Neighborhood & density derivations over the cell–cell contact graph.
2
+
3
+ Two headless, Qt-free, **label-agnostic** derivations on an already-read
4
+ :class:`~itasc.contact_analysis.contacts.reader.PositionContactAnalysis`,
5
+ each returning a tidy column-major table (``dict[str, np.ndarray]``):
6
+
7
+ * :func:`cell_neighbor_counts` — adjacency degree per cell (how many neighbors).
8
+ * :func:`cell_density` — cells per unit field-of-view area. (Counts straight off
9
+ a ``frame -> [cell_id, …]`` map, so it needs only the cell labels, not the
10
+ contact graph.)
11
+
12
+ The ``cell_cell`` edges of the contacts ``edges`` table *are* the adjacency
13
+ graph; degree is the count of incident ``cell_cell`` edges, deduped per neighbor
14
+ (a boundary split across several edge rows still counts as one neighbor).
15
+
16
+ The classification-dependent derivations (neighbour enrichment; contact-type
17
+ z-score vs a label-shuffle null) are **not** here — they consume a per-cell
18
+ subpopulation ``class_label`` and so live with the dataset that defines it, not in
19
+ this label-agnostic library.
20
+
21
+ Like :mod:`...contacts.signed_contact_length` and :mod:`...contacts.contact_labels`, these
22
+ operate on the in-memory analysis object only: they never open HDF5, so they run
23
+ unchanged in scripts, notebooks, and the napari plugin.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from collections import defaultdict
28
+ from collections.abc import Mapping, Sequence
29
+
30
+ import numpy as np
31
+
32
+ from itasc.contact_analysis.contacts.reader import PositionContactAnalysis
33
+
34
+
35
+ def _frame_adjacency(analysis: PositionContactAnalysis) -> dict[int, dict[int, set[int]]]:
36
+ """``frame -> {cell_id -> set(neighbor cell_ids)}`` from ``cell_cell`` edges.
37
+
38
+ Only ``kind == "cell_cell"`` rows contribute; border edges (``kind ==
39
+ "border"``) are excluded. Each edge ``(cell_a, cell_b)`` adds each endpoint to
40
+ the other's neighbor *set*, so a boundary fragmented into several edge rows
41
+ still counts as a single neighbor relation (the set dedupes). Degree is
42
+ ``len(neighbors[cell_id])``.
43
+ """
44
+ edges = analysis.edges
45
+ frame = np.asarray(edges.get("frame", ()), dtype=np.int64)
46
+ cell_a = np.asarray(edges.get("cell_a", ()), dtype=np.int64)
47
+ cell_b = np.asarray(edges.get("cell_b", ()), dtype=np.int64)
48
+ kind = np.asarray(edges.get("kind", np.full(frame.shape, "cell_cell")), dtype=object)
49
+
50
+ adjacency: dict[int, dict[int, set[int]]] = defaultdict(lambda: defaultdict(set))
51
+ for i in range(frame.size):
52
+ if str(kind[i]) != "cell_cell":
53
+ continue
54
+ fr, ca, cb = int(frame[i]), int(cell_a[i]), int(cell_b[i])
55
+ adjacency[fr][ca].add(cb)
56
+ adjacency[fr][cb].add(ca)
57
+ return adjacency
58
+
59
+
60
+ def _frame_cells(analysis: PositionContactAnalysis) -> dict[int, list[int]]:
61
+ """``frame -> [cell_id, …]`` (ascending) from the ``cells`` table.
62
+
63
+ The cells table is the authority on which cells are present in a frame, so an
64
+ isolated cell (degree 0) still counts toward degree, abundance, and density.
65
+ """
66
+ cells = analysis.cells
67
+ frame = np.asarray(cells.get("frame", ()), dtype=np.int64)
68
+ cell_id = np.asarray(cells.get("cell_id", ()), dtype=np.int64)
69
+ out: dict[int, list[int]] = defaultdict(list)
70
+ for fr, cid in zip(frame, cell_id):
71
+ out[int(fr)].append(int(cid))
72
+ for ids in out.values():
73
+ ids.sort()
74
+ return out
75
+
76
+
77
+ def cell_neighbor_counts(analysis: PositionContactAnalysis) -> dict[str, np.ndarray]:
78
+ """Per ``(frame, cell_id)`` adjacency degree — the headline neighbor count.
79
+
80
+ One row per cell present in the ``cells`` table for that frame, with
81
+ ``n_neighbors`` its number of distinct ``cell_cell`` neighbors (0 for an
82
+ isolated cell). This count is also the numerator for density.
83
+
84
+ Columns (column-major, equal length): ``frame``, ``cell_id``,
85
+ ``n_neighbors`` (int).
86
+ """
87
+ adjacency = _frame_adjacency(analysis)
88
+ frame_cells = _frame_cells(analysis)
89
+
90
+ out_frame: list[int] = []
91
+ out_cell: list[int] = []
92
+ out_n: list[int] = []
93
+ for fr in sorted(frame_cells):
94
+ neighbors = adjacency.get(fr, {})
95
+ for cid in frame_cells[fr]:
96
+ out_frame.append(fr)
97
+ out_cell.append(cid)
98
+ out_n.append(len(neighbors.get(cid, ())))
99
+ return {
100
+ "frame": np.asarray(out_frame, dtype=np.int64),
101
+ "cell_id": np.asarray(out_cell, dtype=np.int64),
102
+ "n_neighbors": np.asarray(out_n, dtype=np.int64),
103
+ }
104
+
105
+
106
+ def cell_density(
107
+ frame_cells: Mapping[int, Sequence[int]],
108
+ *,
109
+ fov_area_mm2: float,
110
+ ) -> dict[str, np.ndarray]:
111
+ """Per ``frame`` cell count and ``density = n_cells / fov_area_mm2``.
112
+
113
+ Emits one ``label="all"`` row per frame that counts **every** cell in the
114
+ frame. ``density`` is in cells/mm²; *fov_area_mm2* is the user's field-of-view
115
+ area and is **required** (a positive number) — there is no silent image-area
116
+ fallback.
117
+
118
+ *frame_cells* maps ``frame -> [cell_id, …]`` (the cell labels present in that
119
+ frame), so this counts straight off the cell labels with no contacts
120
+ dependency.
121
+
122
+ Columns: ``frame``, ``label`` (str, always ``"all"``), ``n_cells`` (int),
123
+ ``density`` (float, cells/mm²).
124
+ """
125
+ area = float(fov_area_mm2)
126
+ if not area > 0:
127
+ raise ValueError("cell_density requires a positive fov_area_mm2")
128
+
129
+ out_frame: list[int] = []
130
+ out_label: list[str] = []
131
+ out_n: list[int] = []
132
+ out_density: list[float] = []
133
+
134
+ for fr in sorted(frame_cells):
135
+ n_cells = len(frame_cells[fr])
136
+ out_frame.append(fr)
137
+ out_label.append("all")
138
+ out_n.append(n_cells)
139
+ out_density.append(n_cells / area)
140
+
141
+ return {
142
+ "frame": np.asarray(out_frame, dtype=np.int64),
143
+ "label": np.asarray(out_label, dtype=object),
144
+ "n_cells": np.asarray(out_n, dtype=np.int64),
145
+ "density": np.asarray(out_density, dtype=float),
146
+ }
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import h5py
7
+ import numpy as np
8
+
9
+
10
+ def _read_dataset(dataset: h5py.Dataset) -> np.ndarray:
11
+ if h5py.check_string_dtype(dataset.dtype) is not None:
12
+ return dataset.asstr()[:]
13
+ return dataset[:]
14
+
15
+
16
+ def _read_table(group: h5py.Group) -> dict[str, np.ndarray]:
17
+ return {name: _read_dataset(dataset) for name, dataset in group.items()}
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PositionContactAnalysis:
22
+ cells: dict[str, np.ndarray]
23
+ edges: dict[str, np.ndarray]
24
+ t1_events: dict[str, np.ndarray]
25
+ cell_tracked_labels_path: str
26
+ nucleus_tracked_labels_path: str
27
+ _edge_coord_y: np.ndarray = field(repr=False)
28
+ _edge_coord_x: np.ndarray = field(repr=False)
29
+
30
+ @property
31
+ def coord_y(self) -> np.ndarray:
32
+ return self._edge_coord_y
33
+
34
+ @property
35
+ def coord_x(self) -> np.ndarray:
36
+ return self._edge_coord_x
37
+
38
+
39
+ def read_position_contacts(path: str | Path) -> PositionContactAnalysis:
40
+ path = Path(path)
41
+ with h5py.File(path, "r") as h5:
42
+ provenance = h5["provenance"].attrs
43
+ cell_tracked_labels_path = str(provenance["cell_tracked_labels_path"])
44
+ nucleus_tracked_labels_path = str(provenance["nucleus_tracked_labels_path"])
45
+ cells = _read_table(h5["cells/table"])
46
+ edges = _read_table(h5["edges/table"])
47
+ t1_events = _read_table(h5["t1_events/table"])
48
+ edge_coord_y = h5["edges/coordinates/y"][:]
49
+ edge_coord_x = h5["edges/coordinates/x"][:]
50
+ return PositionContactAnalysis(
51
+ cells=cells,
52
+ edges=edges,
53
+ t1_events=t1_events,
54
+ cell_tracked_labels_path=cell_tracked_labels_path,
55
+ nucleus_tracked_labels_path=nucleus_tracked_labels_path,
56
+ _edge_coord_y=edge_coord_y,
57
+ _edge_coord_x=edge_coord_x,
58
+ )
@@ -0,0 +1,143 @@
1
+ """Signed contact length — the signed central junction-length reaction coordinate.
2
+
3
+ Boltzmann-inversion of this coordinate yields the effective potential / barrier
4
+ ``ΔE_eff`` of T1 transitions: the energy a junction climbs to reach the
5
+ four-fold vertex. That inversion and the resulting potential-landscape plot are
6
+ downstream concerns owned by the data repo, not this package — this
7
+ module only computes the signed lengths themselves. It is the ITASC
8
+ analogue of the reference's ``extract_central_junction_lengths`` /
9
+ ``plot_signed_lengths_neg_log_p_histogram`` (``morphogenesis-on-chip_analysis``),
10
+ but the sign comes from the ``t1_events`` table (``losing`` ↔ ``gaining`` pairs)
11
+ rather than curated "quad" JSONs.
12
+
13
+ Headless and Qt-free: it operates on an already-read
14
+ :class:`~itasc.contact_analysis.contacts.reader.PositionContactAnalysis`,
15
+ so it never opens HDF5 itself and runs unchanged in scripts, notebooks, and the
16
+ napari plugin.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from collections import defaultdict
21
+ from collections.abc import Mapping
22
+
23
+ import numpy as np
24
+
25
+ from itasc.contact_analysis.contacts.contact_labels import contact_label_for
26
+ from itasc.contact_analysis.contacts.reader import PositionContactAnalysis
27
+
28
+
29
+ def signed_central_junction_lengths(
30
+ analysis: PositionContactAnalysis,
31
+ *,
32
+ pixel_size_um: float | None = None,
33
+ labels: Mapping[int, str] | None = None,
34
+ ) -> dict[str, np.ndarray]:
35
+ """Signed central junction length per T1 event, per frame.
36
+
37
+ For each T1 event the central junction is the edge that flips from the
38
+ *losing* cell pair (pre-transition) to the *gaining* pair (post-transition).
39
+ Every frame in which an event's losing edge exists contributes a **negative**
40
+ sample (``−length``); every frame its gaining edge exists contributes a
41
+ **positive** one (``+length``). The magnitude is the edge length; it crosses
42
+ zero at the four-fold vertex, so pooled and inverted these reproduce the
43
+ reference's double-well potential without curated quads.
44
+
45
+ Fragmented contacts are joined first: the build splits a single cell-cell
46
+ boundary into several edge rows (one per disconnected segment from
47
+ ``_coordinate_segments``), so the lengths of all rows sharing a
48
+ ``(frame, cell-pair)`` are **summed** into one total junction length before
49
+ signing. Otherwise each fragment would enter the landscape as its own
50
+ (short) sample. This is the headless analogue of the v1
51
+ ``find_shared_boundary`` / ``order_boundary_pixels`` join that produced one
52
+ length per junction.
53
+
54
+ All frames an edge exists are used (no ± window) — matching the reference,
55
+ which histograms the whole movie.
56
+
57
+ Columns (column-major, all equal length):
58
+
59
+ * ``t1_event_id`` — the event each sample belongs to.
60
+ * ``frame`` — the frame the sample is read from.
61
+ * ``signed_length`` — ``±length``, in µm when *pixel_size_um* is given else px.
62
+ * ``role`` — ``"losing"`` (negative) or ``"gaining"`` (positive).
63
+ * ``contact_type`` — the event's **transition pair** ``"<losing>→<gaining>"``
64
+ (e.g. ``"A-A→A-B"``), where each side is the contact label
65
+ (:func:`...contact_labels.contact_label_for`) of that junction's cell pair.
66
+ ``""`` when no *labels* map is given (the label-agnostic default). A single
67
+ per-event label is used (not per-side), so both the negative losing lobe and
68
+ the positive gaining lobe of an event share it and a grouped curve still
69
+ spans ``L = 0`` for the barrier.
70
+
71
+ *labels* maps ``cell_id -> label`` (an optional, caller-supplied per-cell
72
+ classification); a cell absent from it is ``"unclassified"``.
73
+
74
+ Returns empty (but typed) arrays when there are no events or no matching
75
+ edges; an event whose losing/gaining edges never appear in ``edges`` simply
76
+ contributes nothing.
77
+ """
78
+ edges = analysis.edges
79
+ events = analysis.t1_events
80
+ scale = float(pixel_size_um) if pixel_size_um else 1.0
81
+
82
+ e_frame = np.asarray(edges.get("frame", ()), dtype=np.int64)
83
+ e_a = np.asarray(edges.get("cell_a", ()), dtype=np.int64)
84
+ e_b = np.asarray(edges.get("cell_b", ()), dtype=np.int64)
85
+ e_len = np.asarray(edges.get("length", ()), dtype=float)
86
+
87
+ # Join fragments: sum every edge row sharing a (pair, frame) into one total
88
+ # junction length, so a boundary split across segments enters the landscape
89
+ # once at its real length rather than as several short samples.
90
+ total_length: dict[tuple[frozenset[int], int], float] = defaultdict(float)
91
+ for fr, ca, cb, ln in zip(e_frame, e_a, e_b, e_len):
92
+ total_length[(frozenset((int(ca), int(cb))), int(fr))] += float(ln)
93
+
94
+ # Unordered cell-pair -> [(frame, total_length), …], frame-sorted, so each
95
+ # event reads its losing and gaining edge in one lookup over all the frames
96
+ # they appear in.
97
+ pair_frames: dict[frozenset[int], list[tuple[int, float]]] = defaultdict(list)
98
+ for (pair, fr), total in total_length.items():
99
+ pair_frames[pair].append((fr, total))
100
+ for entries in pair_frames.values():
101
+ entries.sort()
102
+
103
+ ev_id = np.asarray(events.get("t1_event_id", ()), dtype=np.int64)
104
+ l_a = np.asarray(events.get("losing_cell_a", ()), dtype=np.int64)
105
+ l_b = np.asarray(events.get("losing_cell_b", ()), dtype=np.int64)
106
+ g_a = np.asarray(events.get("gaining_cell_a", ()), dtype=np.int64)
107
+ g_b = np.asarray(events.get("gaining_cell_b", ()), dtype=np.int64)
108
+
109
+ out_event: list[int] = []
110
+ out_frame: list[int] = []
111
+ out_signed: list[float] = []
112
+ out_role: list[str] = []
113
+ out_type: list[str] = []
114
+
115
+ for i in range(ev_id.size):
116
+ eid = int(ev_id[i])
117
+ # One transition label per event ("<losing>→<gaining>"), shared by both
118
+ # lobes so a grouped curve keeps its losing (−) and gaining (+) sides
119
+ # together and the barrier at L=0 stays defined.
120
+ if labels:
121
+ losing_type = contact_label_for(labels, int(l_a[i]), int(l_b[i]))
122
+ gaining_type = contact_label_for(labels, int(g_a[i]), int(g_b[i]))
123
+ contact_type = f"{losing_type}→{gaining_type}"
124
+ else:
125
+ contact_type = ""
126
+ for sign, role, pair in (
127
+ (-1.0, "losing", frozenset((int(l_a[i]), int(l_b[i])))),
128
+ (+1.0, "gaining", frozenset((int(g_a[i]), int(g_b[i])))),
129
+ ):
130
+ for frame, length in pair_frames.get(pair, ()):
131
+ out_event.append(eid)
132
+ out_frame.append(frame)
133
+ out_signed.append(sign * length * scale)
134
+ out_role.append(role)
135
+ out_type.append(contact_type)
136
+
137
+ return {
138
+ "t1_event_id": np.asarray(out_event, dtype=np.int64),
139
+ "frame": np.asarray(out_frame, dtype=np.int64),
140
+ "signed_length": np.asarray(out_signed, dtype=float),
141
+ "role": np.asarray(out_role, dtype=object),
142
+ "contact_type": np.asarray(out_type, dtype=object),
143
+ }
@@ -0,0 +1,185 @@
1
+ """The curation artifact — hand QC exclusions joined onto the measurement tables.
2
+
3
+ A separate, git-versioned tidy table kept apart from the disposable measurement
4
+ tables (the dividing line is who made the decisions in it). Schema, one row per
5
+ exclusion::
6
+
7
+ experiment_id, position_id, frame, excluded, exclusion_reason
8
+
9
+ ``frame`` empty/NA means *the whole position* (every frame). At export the table
10
+ is **left-joined** onto a measurement table by the natural keys the table already
11
+ carries — a frame-level exclusion matches ``(experiment_id, position_id, frame)``;
12
+ a position-level exclusion (``frame`` NA) matches ``(experiment_id, position_id)``
13
+ — marking matched rows ``excluded = True`` and copying the reason. Rows with no
14
+ entry default to kept; filter, don't delete. The measurement source is never
15
+ mutated.
16
+
17
+ This restores the curation that commit 95df159 removed, re-keyed on the natural
18
+ keys (the old version, at rev 39d0df2, joined on a deterministic row ``id``).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import pandas as pd
25
+
26
+ __all__ = [
27
+ "CURATION_COLUMNS",
28
+ "empty_curation",
29
+ "read_curation",
30
+ "write_curation",
31
+ "append_exclusion",
32
+ "remove_exclusion",
33
+ "apply_curation",
34
+ "filter_excluded",
35
+ ]
36
+
37
+ #: The columns a curation CSV carries.
38
+ CURATION_COLUMNS = (
39
+ "experiment_id",
40
+ "position_id",
41
+ "frame",
42
+ "excluded",
43
+ "exclusion_reason",
44
+ )
45
+
46
+
47
+ def read_curation(path: Path | str | None) -> pd.DataFrame | None:
48
+ """Read the curation CSV at *path*, or ``None`` when *path* is unset/absent.
49
+
50
+ A missing file is not an error: an uncurated series simply keeps every row.
51
+ """
52
+ if path is None:
53
+ return None
54
+ path = Path(path)
55
+ if not path.is_file():
56
+ return None
57
+ return pd.read_csv(path)
58
+
59
+
60
+ def empty_curation() -> pd.DataFrame:
61
+ """An empty curation table with the canonical columns (object dtype).
62
+
63
+ Object columns keep an empty ``frame`` as ``NA`` (whole-position) rather than
64
+ coercing the column to a float that would render NaN on write.
65
+ """
66
+ return pd.DataFrame({col: pd.Series(dtype="object") for col in CURATION_COLUMNS})
67
+
68
+
69
+ def write_curation(path: Path | str, curation: pd.DataFrame) -> None:
70
+ """Write *curation* to *path* as CSV (creating the parent dir), index-free."""
71
+ path = Path(path)
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ curation.to_csv(path, index=False)
74
+
75
+
76
+ def append_exclusion(
77
+ curation: pd.DataFrame | None,
78
+ *,
79
+ experiment_id: str,
80
+ position_id: str,
81
+ frame: int | None,
82
+ reason: str,
83
+ ) -> pd.DataFrame:
84
+ """Return *curation* with one exclusion row added.
85
+
86
+ ``frame=None`` records a **whole-position** exclusion (stored as NA); a numeric
87
+ *frame* records a single-frame exclusion. Idempotent on the
88
+ ``(experiment_id, position_id, frame)`` key: an existing row with the same key
89
+ is replaced (its reason updated) rather than duplicated. The input is not
90
+ mutated.
91
+ """
92
+ base = empty_curation() if curation is None else curation
93
+ out = remove_exclusion(
94
+ base, experiment_id=experiment_id, position_id=position_id, frame=frame
95
+ )
96
+ new_row = {
97
+ "experiment_id": str(experiment_id),
98
+ "position_id": str(position_id),
99
+ "frame": pd.NA if frame is None else int(frame),
100
+ "excluded": True,
101
+ "exclusion_reason": str(reason),
102
+ }
103
+ return pd.concat([out, pd.DataFrame([new_row])], ignore_index=True)
104
+
105
+
106
+ def remove_exclusion(
107
+ curation: pd.DataFrame | None,
108
+ *,
109
+ experiment_id: str,
110
+ position_id: str,
111
+ frame: int | None,
112
+ ) -> pd.DataFrame:
113
+ """Return *curation* without the row(s) matching the given key.
114
+
115
+ ``frame=None`` removes the **whole-position** row (``frame`` NA); a numeric
116
+ *frame* removes that single-frame row. A non-matching key is a no-op. The
117
+ input is not mutated.
118
+ """
119
+ if curation is None or len(curation) == 0:
120
+ return empty_curation()
121
+ out = curation.copy()
122
+ key = (out["experiment_id"].astype(str) == str(experiment_id)) & (
123
+ out["position_id"].astype(str) == str(position_id)
124
+ )
125
+ if frame is None:
126
+ key &= out["frame"].isna()
127
+ else:
128
+ frame_numeric = pd.to_numeric(out["frame"], errors="coerce")
129
+ key &= frame_numeric.notna() & (frame_numeric == float(frame))
130
+ return out.loc[~key].reset_index(drop=True)
131
+
132
+
133
+ def apply_curation(
134
+ table: pd.DataFrame, curation: pd.DataFrame | None
135
+ ) -> pd.DataFrame:
136
+ """Return *table* with ``excluded`` / ``exclusion_reason`` columns marked.
137
+
138
+ A measurement row is marked excluded iff a curation entry matches it by either
139
+ ``(experiment_id, position_id, frame)`` (frame-level) or
140
+ ``(experiment_id, position_id)`` with the curation ``frame`` NA
141
+ (position-level). Keys are compared as strings (CSV round-trips ids as
142
+ strings); ``frame`` as an integer. Unmatched rows default to kept with an empty
143
+ reason. The input frame is not mutated.
144
+ """
145
+ out = table.copy()
146
+ out["excluded"] = False
147
+ out["exclusion_reason"] = ""
148
+ if curation is None or len(curation) == 0:
149
+ return out
150
+
151
+ exp = out["experiment_id"].astype(str)
152
+ pos = out["position_id"].astype(str)
153
+
154
+ for _, entry in curation.iterrows():
155
+ if not bool(entry.get("excluded", True)):
156
+ continue # a future un-exclude override; ignored for now
157
+ key = (exp == str(entry["experiment_id"])) & (pos == str(entry["position_id"]))
158
+ frame = entry.get("frame")
159
+ if pd.notna(frame):
160
+ if "frame" not in out.columns:
161
+ continue # a frame-level entry cannot match a frameless table
162
+ # An outer-merged table can carry NaN frames (whole-position rows);
163
+ # coerce instead of astype("int64") so those rows don't crash the cast.
164
+ frame_numeric = pd.to_numeric(out["frame"], errors="coerce")
165
+ key &= frame_numeric.notna() & (frame_numeric == float(frame))
166
+ out.loc[key, "excluded"] = True
167
+ out.loc[key, "exclusion_reason"] = str(entry.get("exclusion_reason", ""))
168
+
169
+ return out
170
+
171
+
172
+ def filter_excluded(marked: pd.DataFrame) -> tuple[pd.DataFrame, int]:
173
+ """Drop the rows :func:`apply_curation` marked excluded.
174
+
175
+ Returns ``(kept, n_dropped)`` where *kept* has the two marker columns removed
176
+ (so it matches the source schema) and a reset index. A frame with no
177
+ ``excluded`` column is returned unchanged with ``n_dropped == 0``.
178
+ """
179
+ if "excluded" not in marked.columns:
180
+ return marked, 0
181
+ excluded = marked["excluded"].astype(bool)
182
+ n_dropped = int(excluded.sum())
183
+ drop_cols = [c for c in ("excluded", "exclusion_reason") if c in marked.columns]
184
+ kept = marked.loc[~excluded].drop(columns=drop_cols).reset_index(drop=True)
185
+ return kept, n_dropped