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.
Files changed (76) hide show
  1. aim/__init__.py +47 -0
  2. aim/__main__.py +6 -0
  3. aim/adata_schema.py +80 -0
  4. aim/aggregation.py +118 -0
  5. aim/aim_config.py +76 -0
  6. aim/analysis/__init__.py +13 -0
  7. aim/analysis/analysis.py +72 -0
  8. aim/analysis/biology.py +205 -0
  9. aim/analysis/confidence.py +49 -0
  10. aim/analysis/ksweep.py +184 -0
  11. aim/analysis/loading.py +70 -0
  12. aim/analysis/onehot.py +41 -0
  13. aim/analysis/reconstruction.py +131 -0
  14. aim/analysis/topology.py +142 -0
  15. aim/analysis/utils.py +16 -0
  16. aim/cli.py +371 -0
  17. aim/clustering.py +242 -0
  18. aim/data/__init__.py +0 -0
  19. aim/data/prepare/__init__.py +0 -0
  20. aim/data/prepare/build_imaging_pairs.py +397 -0
  21. aim/data/prepare/build_xenium_breast_pair.py +310 -0
  22. aim/data/prepare/convertTsvToCsv.py +39 -0
  23. aim/data/prepare/convert_csv_to_h5ad.py +217 -0
  24. aim/data/prepare/createOnlyHVGDataset.py +69 -0
  25. aim/data/prepare/createSmallerDataset.py +102 -0
  26. aim/data/prepare/normalize_gene_names.py +56 -0
  27. aim/data/prepare/perturb_sc_noise.py +107 -0
  28. aim/data/prepare/register_st_patches.py +180 -0
  29. aim/data/prepare/split_st_into_islands.py +551 -0
  30. aim/data/prepare/split_st_into_patches.py +240 -0
  31. aim/data/prepare/subset_st_hvg.py +43 -0
  32. aim/data/validate/__init__.py +35 -0
  33. aim/data/validate/common.py +141 -0
  34. aim/data/validate/runner.py +335 -0
  35. aim/data/validate/validate_pair.py +53 -0
  36. aim/data/validate/validate_sc.py +103 -0
  37. aim/data/validate/validate_st.py +74 -0
  38. aim/gui/__init__.py +8 -0
  39. aim/gui/__main__.py +77 -0
  40. aim/gui/app.py +1784 -0
  41. aim/gui/compute.py +106 -0
  42. aim/gui/data_access.py +292 -0
  43. aim/gui/render.py +1679 -0
  44. aim/gui/scaffold.py +129 -0
  45. aim/gui/widgets.py +675 -0
  46. aim/io.py +196 -0
  47. aim/mapping/__init__.py +14 -0
  48. aim/mapping/base.py +67 -0
  49. aim/mapping/confidence.py +51 -0
  50. aim/mapping/nearest_centroid.py +33 -0
  51. aim/mapping/reference.py +156 -0
  52. aim/mapping/wann.py +208 -0
  53. aim/metrics/__init__.py +26 -0
  54. aim/metrics/biology.py +69 -0
  55. aim/metrics/cossim.py +127 -0
  56. aim/metrics/kselection.py +272 -0
  57. aim/metrics/onehot.py +54 -0
  58. aim/metrics/reconstruction.py +73 -0
  59. aim/metrics/topology.py +62 -0
  60. aim/reference_aligners/__init__.py +0 -0
  61. aim/reference_aligners/environment_dot.yml +23 -0
  62. aim/reference_aligners/environment_tacco.yml +28 -0
  63. aim/reference_aligners/environment_tangram.yml +16 -0
  64. aim/reference_aligners/registry.py +350 -0
  65. aim/reference_aligners/run_dot.R +172 -0
  66. aim/reference_aligners/run_dot.py +116 -0
  67. aim/reference_aligners/run_tacco.py +143 -0
  68. aim/reference_aligners/run_tangram.py +210 -0
  69. aim/sweep.py +261 -0
  70. aim/tree.py +86 -0
  71. spatial_aim-1.0.0.dist-info/METADATA +1005 -0
  72. spatial_aim-1.0.0.dist-info/RECORD +76 -0
  73. spatial_aim-1.0.0.dist-info/WHEEL +5 -0
  74. spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
  75. spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
  76. 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()