spatial-aim 1.0.0__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.
- aim/__init__.py +47 -0
- aim/__main__.py +6 -0
- aim/adata_schema.py +80 -0
- aim/aggregation.py +118 -0
- aim/aim_config.py +76 -0
- aim/analysis/__init__.py +13 -0
- aim/analysis/analysis.py +72 -0
- aim/analysis/biology.py +205 -0
- aim/analysis/confidence.py +49 -0
- aim/analysis/ksweep.py +184 -0
- aim/analysis/loading.py +70 -0
- aim/analysis/onehot.py +41 -0
- aim/analysis/reconstruction.py +131 -0
- aim/analysis/topology.py +142 -0
- aim/analysis/utils.py +16 -0
- aim/cli.py +371 -0
- aim/clustering.py +242 -0
- aim/data/__init__.py +0 -0
- aim/data/prepare/__init__.py +0 -0
- aim/data/prepare/build_imaging_pairs.py +397 -0
- aim/data/prepare/build_xenium_breast_pair.py +310 -0
- aim/data/prepare/convertTsvToCsv.py +39 -0
- aim/data/prepare/convert_csv_to_h5ad.py +217 -0
- aim/data/prepare/createOnlyHVGDataset.py +69 -0
- aim/data/prepare/createSmallerDataset.py +102 -0
- aim/data/prepare/normalize_gene_names.py +56 -0
- aim/data/prepare/perturb_sc_noise.py +107 -0
- aim/data/prepare/register_st_patches.py +180 -0
- aim/data/prepare/split_st_into_islands.py +551 -0
- aim/data/prepare/split_st_into_patches.py +240 -0
- aim/data/prepare/subset_st_hvg.py +43 -0
- aim/data/validate/__init__.py +35 -0
- aim/data/validate/common.py +141 -0
- aim/data/validate/runner.py +335 -0
- aim/data/validate/validate_pair.py +53 -0
- aim/data/validate/validate_sc.py +103 -0
- aim/data/validate/validate_st.py +74 -0
- aim/gui/__init__.py +8 -0
- aim/gui/__main__.py +77 -0
- aim/gui/app.py +1784 -0
- aim/gui/compute.py +106 -0
- aim/gui/data_access.py +292 -0
- aim/gui/render.py +1679 -0
- aim/gui/scaffold.py +129 -0
- aim/gui/widgets.py +675 -0
- aim/io.py +196 -0
- aim/mapping/__init__.py +14 -0
- aim/mapping/base.py +67 -0
- aim/mapping/confidence.py +51 -0
- aim/mapping/nearest_centroid.py +33 -0
- aim/mapping/reference.py +156 -0
- aim/mapping/wann.py +208 -0
- aim/metrics/__init__.py +26 -0
- aim/metrics/biology.py +69 -0
- aim/metrics/cossim.py +127 -0
- aim/metrics/kselection.py +272 -0
- aim/metrics/onehot.py +54 -0
- aim/metrics/reconstruction.py +73 -0
- aim/metrics/topology.py +62 -0
- aim/reference_aligners/__init__.py +0 -0
- aim/reference_aligners/environment_dot.yml +23 -0
- aim/reference_aligners/environment_tacco.yml +28 -0
- aim/reference_aligners/environment_tangram.yml +16 -0
- aim/reference_aligners/registry.py +350 -0
- aim/reference_aligners/run_dot.R +172 -0
- aim/reference_aligners/run_dot.py +116 -0
- aim/reference_aligners/run_tacco.py +143 -0
- aim/reference_aligners/run_tangram.py +210 -0
- aim/sweep.py +261 -0
- aim/tree.py +86 -0
- spatial_aim-1.0.0.dist-info/METADATA +1005 -0
- spatial_aim-1.0.0.dist-info/RECORD +76 -0
- spatial_aim-1.0.0.dist-info/WHEEL +5 -0
- spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
- spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
- spatial_aim-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactively split ST h5ad files into per-island files using drawn bounding boxes.
|
|
3
|
+
|
|
4
|
+
For each target file an interactive plot opens. Draw rectangles to define islands,
|
|
5
|
+
then press Enter. If any spots fall outside all boxes the plot reopens with those
|
|
6
|
+
spots highlighted in red so you can extend/add boxes. Once every spot is covered
|
|
7
|
+
the file is split and the database updated.
|
|
8
|
+
|
|
9
|
+
Controls:
|
|
10
|
+
Click + drag draw a rectangle
|
|
11
|
+
a add the current selection as an island box
|
|
12
|
+
u undo the last added box
|
|
13
|
+
s skip this file (no split)
|
|
14
|
+
Enter confirm all boxes and proceed
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python -m src.data_preparation.split_st_into_islands
|
|
18
|
+
[--datasets 06 07 08]
|
|
19
|
+
[--data-root <path>]
|
|
20
|
+
[--dry-run]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import csv
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import anndata as ad
|
|
32
|
+
import matplotlib.patches as mpatches
|
|
33
|
+
import matplotlib.pyplot as plt
|
|
34
|
+
import numpy as np
|
|
35
|
+
from matplotlib.widgets import RectangleSelector
|
|
36
|
+
from sklearn.neighbors import NearestNeighbors
|
|
37
|
+
|
|
38
|
+
DEFAULT_DATA_ROOT = Path(r"/01_Datasets")
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_NAME_RE = re.compile(r"^(\d+)_(\d+)_(.+)$")
|
|
42
|
+
|
|
43
|
+
# Standard tab10 palette — avoids colourmap lookup across matplotlib versions
|
|
44
|
+
_TAB10 = [
|
|
45
|
+
"#1f77b4",
|
|
46
|
+
"#ff7f0e",
|
|
47
|
+
"#2ca02c",
|
|
48
|
+
"#d62728",
|
|
49
|
+
"#9467bd",
|
|
50
|
+
"#8c564b",
|
|
51
|
+
"#e377c2",
|
|
52
|
+
"#7f7f7f",
|
|
53
|
+
"#bcbd22",
|
|
54
|
+
"#17becf",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Interactive bounding-box UI
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _BoxSelector:
|
|
64
|
+
"""One interactive session for a single ST file."""
|
|
65
|
+
|
|
66
|
+
_COLORS = _TAB10
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
coords: np.ndarray,
|
|
72
|
+
file_num: int,
|
|
73
|
+
total_files: int,
|
|
74
|
+
existing_boxes: list | None = None,
|
|
75
|
+
uncovered_mask: np.ndarray | None = None,
|
|
76
|
+
):
|
|
77
|
+
self.name = name
|
|
78
|
+
self.coords = coords
|
|
79
|
+
self.boxes: list[tuple] = list(existing_boxes) if existing_boxes else []
|
|
80
|
+
self._pending: tuple | None = None
|
|
81
|
+
self._skip = False
|
|
82
|
+
|
|
83
|
+
fig, ax = plt.subplots(figsize=(11, 9))
|
|
84
|
+
self.fig = fig
|
|
85
|
+
self.ax = ax
|
|
86
|
+
plt.subplots_adjust(top=0.87)
|
|
87
|
+
|
|
88
|
+
# Spots
|
|
89
|
+
if uncovered_mask is not None and uncovered_mask.any():
|
|
90
|
+
covered = ~uncovered_mask
|
|
91
|
+
ax.scatter(
|
|
92
|
+
coords[covered, 0],
|
|
93
|
+
coords[covered, 1],
|
|
94
|
+
s=1,
|
|
95
|
+
c="#aaaaaa",
|
|
96
|
+
linewidths=0,
|
|
97
|
+
zorder=1,
|
|
98
|
+
)
|
|
99
|
+
ax.scatter(
|
|
100
|
+
coords[uncovered_mask, 0],
|
|
101
|
+
coords[uncovered_mask, 1],
|
|
102
|
+
s=3,
|
|
103
|
+
c="red",
|
|
104
|
+
linewidths=0,
|
|
105
|
+
zorder=2,
|
|
106
|
+
label=f"{int(uncovered_mask.sum()):,} uncovered (red)",
|
|
107
|
+
)
|
|
108
|
+
ax.legend(fontsize=9, loc="upper right")
|
|
109
|
+
else:
|
|
110
|
+
ax.scatter(
|
|
111
|
+
coords[:, 0], coords[:, 1], s=1, c="#aaaaaa", linewidths=0, zorder=1
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Pre-draw existing boxes
|
|
115
|
+
self._patches: list[mpatches.Rectangle] = []
|
|
116
|
+
for i, box in enumerate(self.boxes):
|
|
117
|
+
self._patches.append(self._make_patch(box, i))
|
|
118
|
+
ax.add_patch(self._patches[-1])
|
|
119
|
+
|
|
120
|
+
suffix = (
|
|
121
|
+
" ← red = uncovered"
|
|
122
|
+
if (uncovered_mask is not None and uncovered_mask.any())
|
|
123
|
+
else ""
|
|
124
|
+
)
|
|
125
|
+
ax.set_title(
|
|
126
|
+
f"[{file_num}/{total_files}] {name} ({len(coords):,} spots){suffix}\n"
|
|
127
|
+
"drag → rectangle | a = add box | u = undo | s = skip file | Enter = done",
|
|
128
|
+
fontsize=10,
|
|
129
|
+
)
|
|
130
|
+
ax.set_aspect("equal")
|
|
131
|
+
|
|
132
|
+
self._label = ax.text(
|
|
133
|
+
0.02,
|
|
134
|
+
0.02,
|
|
135
|
+
self._label_text(),
|
|
136
|
+
transform=ax.transAxes,
|
|
137
|
+
fontsize=10,
|
|
138
|
+
va="bottom",
|
|
139
|
+
bbox=dict(boxstyle="round", facecolor="white", alpha=0.85),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self._selector = RectangleSelector(
|
|
143
|
+
ax,
|
|
144
|
+
self._on_rect, # type: ignore[arg-type]
|
|
145
|
+
useblit=True,
|
|
146
|
+
button=[1], # type: ignore[arg-type, list-item]
|
|
147
|
+
minspanx=1,
|
|
148
|
+
minspany=1,
|
|
149
|
+
spancoords="data",
|
|
150
|
+
)
|
|
151
|
+
fig.canvas.mpl_connect("key_press_event", self._on_key) # type: ignore[arg-type]
|
|
152
|
+
|
|
153
|
+
# --- helpers ---
|
|
154
|
+
|
|
155
|
+
def _make_patch(self, box: tuple, index: int) -> mpatches.Rectangle:
|
|
156
|
+
color = self._COLORS[index % len(self._COLORS)]
|
|
157
|
+
x0, y0, x1, y1 = box
|
|
158
|
+
return mpatches.Rectangle(
|
|
159
|
+
(x0, y0),
|
|
160
|
+
x1 - x0,
|
|
161
|
+
y1 - y0,
|
|
162
|
+
linewidth=2,
|
|
163
|
+
edgecolor=color,
|
|
164
|
+
facecolor=color,
|
|
165
|
+
alpha=0.12,
|
|
166
|
+
label=f"Island {index + 1}",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _label_text(self) -> str:
|
|
170
|
+
return f"{len(self.boxes)} box(es) defined"
|
|
171
|
+
|
|
172
|
+
def _refresh(self) -> None:
|
|
173
|
+
self._label.set_text(self._label_text())
|
|
174
|
+
handles = [self._make_patch(b, i) for i, b in enumerate(self.boxes)]
|
|
175
|
+
if handles:
|
|
176
|
+
self.ax.legend(handles=handles, fontsize=8, loc="upper right")
|
|
177
|
+
else:
|
|
178
|
+
legend = self.ax.get_legend()
|
|
179
|
+
if legend:
|
|
180
|
+
legend.remove()
|
|
181
|
+
self.fig.canvas.draw_idle()
|
|
182
|
+
|
|
183
|
+
# --- event handlers ---
|
|
184
|
+
|
|
185
|
+
def _on_rect(self, eclick, erelease) -> None:
|
|
186
|
+
if eclick.xdata is None or erelease.xdata is None:
|
|
187
|
+
return
|
|
188
|
+
self._pending = (
|
|
189
|
+
min(eclick.xdata, erelease.xdata),
|
|
190
|
+
min(eclick.ydata, erelease.ydata),
|
|
191
|
+
max(eclick.xdata, erelease.xdata),
|
|
192
|
+
max(eclick.ydata, erelease.ydata),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _on_key(self, event) -> None:
|
|
196
|
+
if event.key == "a" and self._pending is not None:
|
|
197
|
+
patch = self._make_patch(self._pending, len(self.boxes))
|
|
198
|
+
self.ax.add_patch(patch)
|
|
199
|
+
self._patches.append(patch)
|
|
200
|
+
self.boxes.append(self._pending)
|
|
201
|
+
self._pending = None
|
|
202
|
+
self._refresh()
|
|
203
|
+
|
|
204
|
+
elif event.key == "u" and self.boxes:
|
|
205
|
+
self.boxes.pop()
|
|
206
|
+
self._patches[-1].remove()
|
|
207
|
+
self._patches.pop()
|
|
208
|
+
self._refresh()
|
|
209
|
+
|
|
210
|
+
elif event.key == "s":
|
|
211
|
+
self._skip = True
|
|
212
|
+
plt.close(self.fig)
|
|
213
|
+
|
|
214
|
+
elif event.key == "enter":
|
|
215
|
+
plt.close(self.fig)
|
|
216
|
+
|
|
217
|
+
# --- public ---
|
|
218
|
+
|
|
219
|
+
def run(self) -> list | None:
|
|
220
|
+
"""Block until user finishes. Returns box list, or None if skipped."""
|
|
221
|
+
plt.show(block=True)
|
|
222
|
+
return None if self._skip else list(self.boxes)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Coverage logic
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _assign(coords: np.ndarray, boxes: list) -> np.ndarray:
|
|
231
|
+
"""First-box-wins assignment. Unassigned spots get label -1."""
|
|
232
|
+
labels = np.full(len(coords), -1, dtype=int)
|
|
233
|
+
for i, (x0, y0, x1, y1) in enumerate(boxes):
|
|
234
|
+
free = labels == -1
|
|
235
|
+
in_box = (
|
|
236
|
+
free
|
|
237
|
+
& (coords[:, 0] >= x0)
|
|
238
|
+
& (coords[:, 0] <= x1)
|
|
239
|
+
& (coords[:, 1] >= y0)
|
|
240
|
+
& (coords[:, 1] <= y1)
|
|
241
|
+
)
|
|
242
|
+
labels[in_box] = i
|
|
243
|
+
return labels
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _assign_remaining_to_nearest(coords: np.ndarray, labels: np.ndarray) -> np.ndarray:
|
|
247
|
+
"""Fallback: assign still-uncovered spots to the nearest covered spot's label."""
|
|
248
|
+
noise = labels == -1
|
|
249
|
+
if not noise.any():
|
|
250
|
+
return labels
|
|
251
|
+
nn = NearestNeighbors(n_neighbors=1)
|
|
252
|
+
nn.fit(coords[~noise])
|
|
253
|
+
_, idx = nn.kneighbors(coords[noise])
|
|
254
|
+
out = labels.copy()
|
|
255
|
+
out[noise] = labels[~noise][idx[:, 0]]
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_boxes_for_file(
|
|
260
|
+
name: str,
|
|
261
|
+
coords: np.ndarray,
|
|
262
|
+
file_num: int,
|
|
263
|
+
total_files: int,
|
|
264
|
+
) -> tuple[list, np.ndarray] | tuple[None, None]:
|
|
265
|
+
"""
|
|
266
|
+
Run the interactive UI in a loop until every spot is covered.
|
|
267
|
+
Returns (boxes, labels) or (None, None) if the user skipped the file.
|
|
268
|
+
"""
|
|
269
|
+
existing_boxes: list | None = None
|
|
270
|
+
uncovered_mask: np.ndarray | None = None
|
|
271
|
+
|
|
272
|
+
while True:
|
|
273
|
+
sel = _BoxSelector(
|
|
274
|
+
name,
|
|
275
|
+
coords,
|
|
276
|
+
file_num,
|
|
277
|
+
total_files,
|
|
278
|
+
existing_boxes=existing_boxes,
|
|
279
|
+
uncovered_mask=uncovered_mask,
|
|
280
|
+
)
|
|
281
|
+
boxes = sel.run()
|
|
282
|
+
|
|
283
|
+
if boxes is None:
|
|
284
|
+
return None, None # type: ignore[return-value] # user pressed 's'
|
|
285
|
+
|
|
286
|
+
if not boxes:
|
|
287
|
+
print(
|
|
288
|
+
f"\n No boxes defined — showing plot again. "
|
|
289
|
+
"Press 's' to skip this file."
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
labels = _assign(coords, boxes)
|
|
294
|
+
n_uncovered = int((labels == -1).sum())
|
|
295
|
+
|
|
296
|
+
if n_uncovered == 0:
|
|
297
|
+
return boxes, labels
|
|
298
|
+
|
|
299
|
+
pct = 100 * n_uncovered / len(coords)
|
|
300
|
+
print(
|
|
301
|
+
f"\n {n_uncovered:,} spots ({pct:.1f}%) are outside all boxes. "
|
|
302
|
+
"Reopening — uncovered spots shown in red."
|
|
303
|
+
)
|
|
304
|
+
existing_boxes = boxes
|
|
305
|
+
uncovered_mask = labels == -1
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Naming / sorting helpers
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_name(name: str) -> tuple[str, str, str]:
|
|
314
|
+
m = _NAME_RE.match(name)
|
|
315
|
+
if not m:
|
|
316
|
+
raise ValueError(f"ST name {name!r} does not match pattern nn_i_tissue")
|
|
317
|
+
return m.group(1), m.group(2), m.group(3)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _st_sort_key(row: dict) -> tuple[int, int, int]:
|
|
321
|
+
name = row["Name"]
|
|
322
|
+
m = re.match(r"^(\d+)_(\d+)_(\d+)_", name)
|
|
323
|
+
if m:
|
|
324
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
325
|
+
m = re.match(r"^(\d+)_(\d+)_", name)
|
|
326
|
+
if m:
|
|
327
|
+
return (int(m.group(1)), int(m.group(2)), 0)
|
|
328
|
+
m = re.match(r"^(\d+)_", name)
|
|
329
|
+
if m:
|
|
330
|
+
return (int(m.group(1)), 0, 0)
|
|
331
|
+
return (999, 999, 999)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Result plot
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _save_result_plot(
|
|
340
|
+
coords: np.ndarray,
|
|
341
|
+
labels: np.ndarray,
|
|
342
|
+
boxes: list,
|
|
343
|
+
name: str,
|
|
344
|
+
plot_dir: Path,
|
|
345
|
+
) -> None:
|
|
346
|
+
n = len(boxes)
|
|
347
|
+
colors = [_TAB10[i % len(_TAB10)] for i in range(n)]
|
|
348
|
+
fig, ax = plt.subplots(figsize=(10, 8))
|
|
349
|
+
for i in range(n):
|
|
350
|
+
mask = labels == i
|
|
351
|
+
ax.scatter(
|
|
352
|
+
coords[mask, 0],
|
|
353
|
+
coords[mask, 1],
|
|
354
|
+
s=1,
|
|
355
|
+
color=colors[i],
|
|
356
|
+
linewidths=0,
|
|
357
|
+
label=f"Island {i + 1} ({int(mask.sum()):,} spots)",
|
|
358
|
+
)
|
|
359
|
+
ax.set_title(f"{name} — {n} islands ({len(coords):,} spots)")
|
|
360
|
+
ax.set_aspect("equal")
|
|
361
|
+
ax.legend(markerscale=6, fontsize=8, bbox_to_anchor=(1.02, 1), loc="upper left")
|
|
362
|
+
out = plot_dir / f"{name}_islands.png"
|
|
363
|
+
fig.savefig(out, dpi=150, bbox_inches="tight")
|
|
364
|
+
plt.close(fig)
|
|
365
|
+
logger.info(" Plot saved: %s", out)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Per-file split
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def split_file(
|
|
374
|
+
h5ad_path: Path,
|
|
375
|
+
st_dir: Path,
|
|
376
|
+
st_index: list,
|
|
377
|
+
pairs: list,
|
|
378
|
+
dry_run: bool,
|
|
379
|
+
plot_dir: Path,
|
|
380
|
+
file_num: int,
|
|
381
|
+
total_files: int,
|
|
382
|
+
) -> bool:
|
|
383
|
+
name = h5ad_path.stem
|
|
384
|
+
logger.info("Loading %s (%d / %d)", name, file_num, total_files)
|
|
385
|
+
adata = ad.read_h5ad(h5ad_path)
|
|
386
|
+
coords = adata.obsm["spatial"].astype(float)
|
|
387
|
+
|
|
388
|
+
boxes, labels = get_boxes_for_file(name, coords, file_num, total_files)
|
|
389
|
+
|
|
390
|
+
if boxes is None or labels is None:
|
|
391
|
+
logger.info(" Skipped by user.")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
n_islands = len(boxes)
|
|
395
|
+
island_counts = [int((labels == i).sum()) for i in range(n_islands)]
|
|
396
|
+
logger.info(" %d islands: %s", n_islands, island_counts)
|
|
397
|
+
|
|
398
|
+
if n_islands < 2:
|
|
399
|
+
logger.info(" Only 1 box — no split needed for %s.", name)
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
dataset_id, slice_idx, tissue = _parse_name(name)
|
|
403
|
+
|
|
404
|
+
orig_idx_row = next((r for r in st_index if r["Name"] == name), None)
|
|
405
|
+
if orig_idx_row is None:
|
|
406
|
+
raise RuntimeError(f"{name} not found in ST/index.csv")
|
|
407
|
+
orig_pairs = [p for p in pairs if p["stName"] == name]
|
|
408
|
+
|
|
409
|
+
if dry_run:
|
|
410
|
+
print(f"\n[DRY RUN] {name}: {n_islands} islands")
|
|
411
|
+
for i in range(n_islands):
|
|
412
|
+
new_name = f"{dataset_id}_{slice_idx}_{i + 1}_{tissue}"
|
|
413
|
+
print(f" island_{i + 1}: {island_counts[i]:,} spots → {new_name}")
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
# Persist box definitions so the split is reproducible
|
|
417
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
boxes_path = plot_dir / f"{name}_boxes.json"
|
|
419
|
+
with open(boxes_path, "w") as f:
|
|
420
|
+
json.dump({"name": name, "boxes": [list(b) for b in boxes]}, f, indent=2)
|
|
421
|
+
|
|
422
|
+
_save_result_plot(coords, labels, boxes, name, plot_dir)
|
|
423
|
+
|
|
424
|
+
# Update in-memory database
|
|
425
|
+
st_index[:] = [r for r in st_index if r["Name"] != name]
|
|
426
|
+
pairs[:] = [p for p in pairs if p["stName"] != name]
|
|
427
|
+
|
|
428
|
+
total_spots = adata.n_obs
|
|
429
|
+
for i in range(n_islands):
|
|
430
|
+
island_num = i + 1
|
|
431
|
+
new_name = f"{dataset_id}_{slice_idx}_{island_num}_{tissue}"
|
|
432
|
+
mask = labels == i
|
|
433
|
+
n_spots = island_counts[i]
|
|
434
|
+
|
|
435
|
+
(adata[mask].copy()).write_h5ad(st_dir / f"{new_name}.h5ad")
|
|
436
|
+
logger.info(" Wrote %s (%d spots)", new_name, n_spots)
|
|
437
|
+
|
|
438
|
+
new_idx = {**orig_idx_row, "Name": new_name, "SpotCount": str(n_spots)}
|
|
439
|
+
st_index.append(new_idx)
|
|
440
|
+
|
|
441
|
+
frac = n_spots / total_spots
|
|
442
|
+
for p in orig_pairs:
|
|
443
|
+
pairs.append(
|
|
444
|
+
{
|
|
445
|
+
**p,
|
|
446
|
+
"stName": new_name,
|
|
447
|
+
"estimated_gpu_gb": f"{float(p['estimated_gpu_gb']) * frac:.2f}",
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
h5ad_path.unlink()
|
|
452
|
+
logger.info(" Deleted original %s", h5ad_path.name)
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Entry point
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def main() -> None:
|
|
462
|
+
logging.basicConfig(
|
|
463
|
+
stream=sys.stdout,
|
|
464
|
+
level=logging.INFO,
|
|
465
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
parser = argparse.ArgumentParser(
|
|
469
|
+
description="Interactively split ST files into islands using drawn bounding boxes."
|
|
470
|
+
)
|
|
471
|
+
parser.add_argument(
|
|
472
|
+
"--datasets",
|
|
473
|
+
nargs="+",
|
|
474
|
+
default=["06", "07", "08"],
|
|
475
|
+
help="Dataset prefixes to process (default: 06 07 08)",
|
|
476
|
+
)
|
|
477
|
+
parser.add_argument(
|
|
478
|
+
"--data-root",
|
|
479
|
+
type=Path,
|
|
480
|
+
default=DEFAULT_DATA_ROOT,
|
|
481
|
+
)
|
|
482
|
+
parser.add_argument(
|
|
483
|
+
"--dry-run",
|
|
484
|
+
action="store_true",
|
|
485
|
+
help="Show UI and print planned splits without writing files",
|
|
486
|
+
)
|
|
487
|
+
args = parser.parse_args()
|
|
488
|
+
|
|
489
|
+
st_dir = args.data_root / "ST"
|
|
490
|
+
st_index_path = st_dir / "index.csv"
|
|
491
|
+
pairs_path = args.data_root / "pairs.csv"
|
|
492
|
+
plot_dir = st_dir / "island_split_plots"
|
|
493
|
+
|
|
494
|
+
with open(st_index_path, newline="") as f:
|
|
495
|
+
reader = csv.DictReader(f)
|
|
496
|
+
st_fields: list[str] = list(reader.fieldnames or [])
|
|
497
|
+
st_index = list(reader)
|
|
498
|
+
|
|
499
|
+
with open(pairs_path, newline="") as f:
|
|
500
|
+
reader = csv.DictReader(f)
|
|
501
|
+
pairs_fields: list[str] = list(reader.fieldnames or [])
|
|
502
|
+
pairs = list(reader)
|
|
503
|
+
|
|
504
|
+
target_files = sorted(
|
|
505
|
+
p for prefix in args.datasets for p in st_dir.glob(f"{prefix}_*.h5ad")
|
|
506
|
+
)
|
|
507
|
+
if not target_files:
|
|
508
|
+
logger.error("No matching h5ad files found for prefixes: %s", args.datasets)
|
|
509
|
+
sys.exit(1)
|
|
510
|
+
|
|
511
|
+
logger.info("%d files to process.", len(target_files))
|
|
512
|
+
|
|
513
|
+
n_split = 0
|
|
514
|
+
for file_num, h5ad_path in enumerate(target_files, start=1):
|
|
515
|
+
try:
|
|
516
|
+
if split_file(
|
|
517
|
+
h5ad_path,
|
|
518
|
+
st_dir,
|
|
519
|
+
st_index,
|
|
520
|
+
pairs,
|
|
521
|
+
args.dry_run,
|
|
522
|
+
plot_dir,
|
|
523
|
+
file_num,
|
|
524
|
+
len(target_files),
|
|
525
|
+
):
|
|
526
|
+
n_split += 1
|
|
527
|
+
except Exception:
|
|
528
|
+
logger.exception("Failed on %s", h5ad_path.name)
|
|
529
|
+
sys.exit(1)
|
|
530
|
+
|
|
531
|
+
if not args.dry_run and n_split > 0:
|
|
532
|
+
for i, pair in enumerate(pairs):
|
|
533
|
+
pair["PairID"] = str(i)
|
|
534
|
+
|
|
535
|
+
with open(st_index_path, "w", newline="") as f:
|
|
536
|
+
writer = csv.DictWriter(f, fieldnames=st_fields)
|
|
537
|
+
writer.writeheader()
|
|
538
|
+
writer.writerows(sorted(st_index, key=_st_sort_key)) # type: ignore[arg-type]
|
|
539
|
+
logger.info("Updated %s", st_index_path)
|
|
540
|
+
|
|
541
|
+
with open(pairs_path, "w", newline="") as f:
|
|
542
|
+
writer = csv.DictWriter(f, fieldnames=pairs_fields)
|
|
543
|
+
writer.writeheader()
|
|
544
|
+
writer.writerows(pairs)
|
|
545
|
+
logger.info("Updated %s", pairs_path)
|
|
546
|
+
|
|
547
|
+
logger.info("Done. %d / %d files split.", n_split, len(target_files))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
if __name__ == "__main__":
|
|
551
|
+
main()
|