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