spatialdata-js-util 0.1.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 (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,803 @@
1
+ """Unified `spatialdata-js-util` command line.
2
+
3
+ Subcommands are grouped by what they act on:
4
+
5
+ images recompress rasters with browser-readable codecs
6
+ points Morton-index Points elements
7
+ tables convert table matrices to CSC
8
+ codecs inspect codec backend availability
9
+ tui interactive workflow UI
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from .errors import WriterCommandError
22
+
23
+ _EPILOG = """\
24
+ examples:
25
+ # Recompress every image in a store as lossy HTJ2K siblings
26
+ spatialdata-js-util images recompress in.zarr out.zarr \\
27
+ --codec experimental.openjph_htj2k --quality 0.0005 --sibling --overwrite
28
+
29
+ # Morton-sort transcripts in place
30
+ spatialdata-js-util points morton-from-zarr ~/data/xenium.zarr --points-key transcripts
31
+
32
+ # Convert table matrices to CSC for fast per-gene reads in the browser
33
+ spatialdata-js-util tables to-csc ~/data/xenium.zarr
34
+
35
+ # Show which HTJ2K backend is in use
36
+ spatialdata-js-util codecs info
37
+ """
38
+
39
+
40
+ def _positive_int(value: str) -> int:
41
+ parsed = int(value)
42
+ if parsed <= 0:
43
+ raise argparse.ArgumentTypeError("value must be positive")
44
+ return parsed
45
+
46
+
47
+ def _print_json(payload: dict[str, Any]) -> None:
48
+ print(json.dumps(payload, indent=2, sort_keys=True))
49
+
50
+
51
+ def _run_command(command: Callable[[], dict[str, Any]]) -> None:
52
+ try:
53
+ _print_json(command())
54
+ except WriterCommandError as exc:
55
+ raise SystemExit(str(exc)) from exc
56
+
57
+
58
+ # --------------------------------------------------------------------------- images
59
+
60
+
61
+ def _recompress_chunks(value: list[str] | None):
62
+ if value is None:
63
+ return None
64
+ if len(value) == 1 and value[0] == "auto":
65
+ return "auto"
66
+ try:
67
+ return tuple(int(part) for part in value)
68
+ except ValueError as exc:
69
+ # Called from the command body rather than as an argparse `type=`, so an
70
+ # ArgumentTypeError here would surface as a traceback rather than a usage
71
+ # error. Match how the rest of the command reports bad arguments.
72
+ raise SystemExit("error: chunks must be 'auto' or integer axis sizes") from exc
73
+
74
+
75
+ def _images_recompress(args: argparse.Namespace) -> None:
76
+ from .images import recompress_spatialdata
77
+
78
+ if args.quality is not None and args.reversible:
79
+ raise SystemExit("error: --quality cannot be used with --reversible")
80
+ if args.quality is not None and args.codec != "experimental.openjph_htj2k":
81
+ raise SystemExit(
82
+ "error: --quality is for HTJ2K only; use --codec experimental.openjph_htj2k"
83
+ )
84
+
85
+ result = recompress_spatialdata(
86
+ args.source,
87
+ args.dest,
88
+ config=args.config,
89
+ overwrite=args.overwrite,
90
+ image_key=args.image_key,
91
+ codec=args.codec,
92
+ preset=args.preset,
93
+ chunks=_recompress_chunks(args.chunks),
94
+ quality=args.quality,
95
+ reversible=True if args.reversible else None,
96
+ sibling=args.sibling,
97
+ workers=args.workers,
98
+ # Any explicit --pyramid-* option means the user wants pyramids, so the
99
+ # bare --pyramid flag is a convenience rather than a requirement.
100
+ pyramid=args.pyramid
101
+ or args.pyramid_levels != "auto"
102
+ or args.pyramid_downscale != 2
103
+ or args.pyramid_min_size != 1024
104
+ or args.pyramid_force,
105
+ pyramid_levels=args.pyramid_levels,
106
+ pyramid_downscale=args.pyramid_downscale,
107
+ pyramid_min_size=args.pyramid_min_size,
108
+ pyramid_force=args.pyramid_force,
109
+ )
110
+ _print_json(result.manifest)
111
+
112
+
113
+ def _pyramid_levels(value: str) -> int | str:
114
+ if value == "auto":
115
+ return "auto"
116
+ try:
117
+ parsed = int(value)
118
+ except ValueError:
119
+ raise argparse.ArgumentTypeError("--pyramid-levels must be 'auto' or an integer") from None
120
+ if parsed < 1:
121
+ raise argparse.ArgumentTypeError("--pyramid-levels must be at least 1")
122
+ return parsed
123
+
124
+
125
+ def _add_pyramid_args(parser: argparse.ArgumentParser, *, with_flag: bool) -> None:
126
+ if with_flag:
127
+ parser.add_argument(
128
+ "--pyramid",
129
+ action="store_true",
130
+ help=(
131
+ "Build a multiscale pyramid for any image that has only one resolution "
132
+ "level, before recompressing. Implied by the other --pyramid-* options."
133
+ ),
134
+ )
135
+ parser.add_argument(
136
+ "--pyramid-levels",
137
+ type=_pyramid_levels,
138
+ default="auto",
139
+ metavar="N",
140
+ help=(
141
+ "Total resolution levels including full res, or 'auto' to halve until the "
142
+ "largest spatial axis fits --pyramid-min-size (default: auto)"
143
+ ),
144
+ )
145
+ parser.add_argument(
146
+ "--pyramid-downscale",
147
+ type=_positive_int,
148
+ default=2,
149
+ metavar="K",
150
+ help="Downscale factor between levels (default: 2)",
151
+ )
152
+ parser.add_argument(
153
+ "--pyramid-min-size",
154
+ type=_positive_int,
155
+ default=1024,
156
+ metavar="PX",
157
+ help="Stop 'auto' once the largest spatial axis is this size or smaller (default: 1024)",
158
+ )
159
+ parser.add_argument(
160
+ "--pyramid-force",
161
+ action="store_true",
162
+ help="Rebuild pyramids for images that already have more than one level",
163
+ )
164
+
165
+
166
+ def _images_add_pyramid(args: argparse.Namespace) -> None:
167
+ from .pyramids import add_pyramids
168
+
169
+ def command() -> dict[str, Any]:
170
+ return add_pyramids(
171
+ args.source,
172
+ args.dest,
173
+ image_keys=args.image_key.split(",") if args.image_key else None,
174
+ include_labels=args.labels,
175
+ levels=args.pyramid_levels,
176
+ downscale=args.pyramid_downscale,
177
+ min_size=args.pyramid_min_size,
178
+ force=args.pyramid_force,
179
+ overwrite=args.overwrite,
180
+ ).manifest
181
+
182
+ _run_command(command)
183
+
184
+
185
+ def _images_inspect(args: argparse.Namespace) -> None:
186
+ manifest_path = Path(args.path)
187
+ if manifest_path.is_dir():
188
+ manifest_path = manifest_path.with_suffix(".manifest.json")
189
+ print(manifest_path.read_text())
190
+
191
+
192
+ def _add_images_commands(subparsers: argparse._SubParsersAction) -> None:
193
+ images = subparsers.add_parser(
194
+ "images",
195
+ help="recompress rasters with browser-readable codecs",
196
+ description="Recompress SpatialData/OME-Zarr image stores.",
197
+ )
198
+ image_commands = images.add_subparsers(dest="images_command", required=True, metavar="command")
199
+
200
+ recompress = image_commands.add_parser(
201
+ "recompress",
202
+ help="rewrite image rasters with JPEG 2000 or HTJ2K",
203
+ description=(
204
+ "Copy a SpatialData store and recompress configured image rasters. "
205
+ "Tables, shapes, points, and unconfigured rasters are preserved."
206
+ ),
207
+ )
208
+ recompress.add_argument("source", help="Existing SpatialData Zarr store")
209
+ recompress.add_argument("dest", help="Output SpatialData Zarr store")
210
+ recompress.add_argument("--config", help="JSON recompression config")
211
+ recompress.add_argument(
212
+ "--image-key",
213
+ help="Apply convenience flags to one image only (default: all images)",
214
+ )
215
+ recompress.add_argument(
216
+ "--codec",
217
+ choices=["imagecodecs_jpeg2k", "experimental.openjph_htj2k"],
218
+ help="Image codec (all images unless --image-key is set)",
219
+ )
220
+ recompress.add_argument(
221
+ "--preset",
222
+ choices=["lossless", "balanced", "small"],
223
+ help="Named image preset (ignored when --quality is set)",
224
+ )
225
+ recompress.add_argument(
226
+ "--quality",
227
+ type=float,
228
+ metavar="Q",
229
+ help=(
230
+ "HTJ2K quantization step, relative to the dtype's full range "
231
+ "(lower = better fidelity, larger output). Implies lossy encoding; use "
232
+ "with --codec experimental.openjph_htj2k. Overrides preset quality. "
233
+ "Below one input LSB (1/256 for 8-bit, 1/65536 for 16-bit) the output "
234
+ "is bit-identical but LARGER than --preset lossless; that warns."
235
+ ),
236
+ )
237
+ recompress.add_argument(
238
+ "--reversible",
239
+ action="store_true",
240
+ help="Force lossless HTJ2K (cannot be combined with --quality)",
241
+ )
242
+ recompress.add_argument(
243
+ "--chunks",
244
+ nargs="+",
245
+ metavar="CHUNK",
246
+ help="Use 'auto' or pass one integer per raster axis",
247
+ )
248
+ recompress.add_argument("--overwrite", action="store_true")
249
+ recompress.add_argument(
250
+ "--sibling",
251
+ action="store_true",
252
+ help=(
253
+ "Write compressed images as new sibling groups (e.g. morphology_focus:jp2k_lossless) "
254
+ "instead of replacing the originals in-place"
255
+ ),
256
+ )
257
+ recompress.add_argument(
258
+ "--workers",
259
+ type=_positive_int,
260
+ default=os.cpu_count() or 1,
261
+ help="Parallel encoder workers (default: CPU count)",
262
+ )
263
+ _add_pyramid_args(recompress, with_flag=True)
264
+ recompress.set_defaults(func=_images_recompress)
265
+
266
+ add_pyramid = image_commands.add_parser(
267
+ "add-pyramid",
268
+ help="give single-resolution rasters a multiscale pyramid",
269
+ description=(
270
+ "Copy a store and add multiscale pyramids to rasters that have only one "
271
+ "resolution level, so a zoomed-out browser view does not have to read "
272
+ "full-resolution chunks. Codecs are left unchanged; use "
273
+ "'images recompress --pyramid' to do both in one pass."
274
+ ),
275
+ )
276
+ add_pyramid.add_argument("source", help="Existing SpatialData Zarr store")
277
+ add_pyramid.add_argument("dest", help="Output SpatialData Zarr store")
278
+ add_pyramid.add_argument(
279
+ "--image-key",
280
+ metavar="KEYS",
281
+ help="Comma-separated image element names (default: all images)",
282
+ )
283
+ add_pyramid.add_argument(
284
+ "--labels",
285
+ action="store_true",
286
+ help="Also build pyramids for labels (downsampled without inventing label ids)",
287
+ )
288
+ add_pyramid.add_argument("--overwrite", action="store_true")
289
+ _add_pyramid_args(add_pyramid, with_flag=False)
290
+ add_pyramid.set_defaults(func=_images_add_pyramid)
291
+
292
+ inspect = image_commands.add_parser(
293
+ "inspect",
294
+ help="print a recompression manifest",
295
+ )
296
+ inspect.add_argument("path")
297
+ inspect.set_defaults(func=_images_inspect)
298
+
299
+
300
+ # --------------------------------------------------------------------------- points
301
+
302
+
303
+ def _points_list(args: argparse.Namespace) -> None:
304
+ from .runners import run_list_points
305
+
306
+ _run_command(lambda: run_list_points(args.zarr))
307
+
308
+
309
+ def _points_morton(args: argparse.Namespace) -> None:
310
+ from .runners import run_morton_points
311
+
312
+ _run_command(
313
+ lambda: run_morton_points(
314
+ args.input,
315
+ args.output,
316
+ feature_key=args.feature_key,
317
+ row_group_size=args.row_group_size,
318
+ compression=args.compression,
319
+ )
320
+ )
321
+
322
+
323
+ def _points_multiscale(args: argparse.Namespace) -> None:
324
+ from .runners import run_multiscale_points
325
+
326
+ _run_command(
327
+ lambda: run_multiscale_points(
328
+ args.input,
329
+ args.output,
330
+ metadata_json=args.metadata_json,
331
+ row_group_size=args.row_group_size,
332
+ compression=args.compression,
333
+ )
334
+ )
335
+
336
+
337
+ def _points_morton_from_zarr(args: argparse.Namespace) -> None:
338
+ from .runners import run_morton_points_from_zarr
339
+
340
+ _run_command(
341
+ lambda: run_morton_points_from_zarr(
342
+ args.zarr,
343
+ points_key=args.points_key,
344
+ experimental=args.experimental,
345
+ output=args.output,
346
+ output_points_key=args.output_points_key,
347
+ feature_key=args.feature_key,
348
+ overwrite=args.overwrite,
349
+ row_group_size=args.row_group_size,
350
+ compression=args.compression,
351
+ )
352
+ )
353
+
354
+
355
+ def _points_index_permutations(args: argparse.Namespace) -> None:
356
+ from .runners import run_write_index_permutations
357
+
358
+ condition_ids = args.conditions.split(",") if args.conditions else None
359
+ _run_command(
360
+ lambda: run_write_index_permutations(
361
+ args.source_zarr,
362
+ args.dest_zarr,
363
+ points_key=args.points_key,
364
+ max_rows=args.max_rows,
365
+ condition_ids=condition_ids,
366
+ overwrite=args.overwrite,
367
+ row_group_size=args.row_group_size,
368
+ compression=args.compression,
369
+ )
370
+ )
371
+
372
+
373
+ def _points_zarr(args: argparse.Namespace) -> None:
374
+ from .runners import run_points_zarr
375
+
376
+ _run_command(
377
+ lambda: run_points_zarr(
378
+ args.input,
379
+ args.output,
380
+ feature_key=args.feature_key,
381
+ coordinates=args.coordinates.split(","),
382
+ points_key=args.points_key,
383
+ tile_size=args.tile_size,
384
+ n_levels=args.levels,
385
+ seed=args.seed,
386
+ chunk_rows=args.chunk_rows,
387
+ shard_chunks=args.shard_chunks,
388
+ overwrite=args.overwrite,
389
+ )
390
+ )
391
+
392
+
393
+ def _points_add_zarr(args: argparse.Namespace) -> None:
394
+ from .runners import run_add_points_zarr
395
+
396
+ _run_command(
397
+ lambda: run_add_points_zarr(
398
+ args.zarr,
399
+ points_key=args.points_key,
400
+ tile_size=args.tile_size,
401
+ n_levels=args.levels,
402
+ seed=args.seed,
403
+ chunk_rows=args.chunk_rows,
404
+ shard_chunks=args.shard_chunks,
405
+ overwrite=args.overwrite,
406
+ )
407
+ )
408
+
409
+
410
+ def _add_points_zarr_args(parser: argparse.ArgumentParser) -> None:
411
+ parser.add_argument(
412
+ "--tile-size",
413
+ type=_positive_float,
414
+ default=100.0,
415
+ metavar="UNITS",
416
+ help="Level-0 tile size, doubled per level (default: 100)",
417
+ )
418
+ parser.add_argument(
419
+ "--levels",
420
+ type=_positive_int,
421
+ metavar="N",
422
+ help="Number of levels (default: until a level holds about a million points)",
423
+ )
424
+ parser.add_argument("--seed", type=int, default=0, help="Seed for the level sample (default: 0)")
425
+ parser.add_argument(
426
+ "--chunk-rows",
427
+ type=_positive_int,
428
+ default=65_536,
429
+ metavar="N",
430
+ help="Rows per chunk of a point column (default: 65536)",
431
+ )
432
+ parser.add_argument(
433
+ "--shard-chunks", type=_positive_int, default=64, metavar="N", help="Chunks per shard (default: 64)"
434
+ )
435
+ parser.add_argument("--overwrite", action="store_true")
436
+
437
+
438
+ def _positive_float(value: str) -> float:
439
+ parsed = float(value)
440
+ if parsed <= 0:
441
+ raise argparse.ArgumentTypeError("value must be positive")
442
+ return parsed
443
+
444
+
445
+ def _add_row_group_args(parser: argparse.ArgumentParser) -> None:
446
+ parser.add_argument(
447
+ "--row-group-size",
448
+ type=_positive_int,
449
+ default=50_000,
450
+ metavar="N",
451
+ help="Target row-group size after sentinel rows (default: 50000)",
452
+ )
453
+ parser.add_argument(
454
+ "--compression",
455
+ default="zstd",
456
+ help="Parquet compression codec (default: zstd)",
457
+ )
458
+
459
+
460
+ def _add_points_commands(subparsers: argparse._SubParsersAction) -> None:
461
+ points = subparsers.add_parser(
462
+ "points",
463
+ help="Morton-index Points elements, or write multiscale Zarr points",
464
+ description=(
465
+ "Write browser-oriented Points artifacts: Morton-sorted Parquet with "
466
+ "sentinel bounding-box rows and controlled row groups, or the experimental "
467
+ "multiscale Zarr layout (points zarr)."
468
+ ),
469
+ )
470
+ point_commands = points.add_subparsers(dest="points_command", required=True, metavar="command")
471
+
472
+ list_points = point_commands.add_parser(
473
+ "list",
474
+ help="list Points element keys in a store",
475
+ description="List Points element keys under <zarr>/points/.",
476
+ )
477
+ list_points.add_argument("zarr", metavar="ZARR", help="Path to a SpatialData Zarr store")
478
+ list_points.set_defaults(func=_points_list)
479
+
480
+ morton_from_zarr = point_commands.add_parser(
481
+ "morton-from-zarr",
482
+ help="Morton-sort a Points element from a store",
483
+ description=(
484
+ "Read points/<key>/points.parquet from a SpatialData Zarr store, "
485
+ "add morton_code_2d sentinel rows, and write Vitessce-compatible Parquet. "
486
+ "Defaults to in-place replacement of points/<key>/points.parquet."
487
+ ),
488
+ )
489
+ morton_from_zarr.add_argument("zarr", metavar="ZARR", help="Path to a SpatialData Zarr store")
490
+ morton_from_zarr.add_argument(
491
+ "--experimental",
492
+ action="store_true",
493
+ help="Write to points.experimental/<key>/points.parquet instead of the canonical path",
494
+ )
495
+ morton_from_zarr.add_argument(
496
+ "--points-key",
497
+ metavar="KEY",
498
+ help=(
499
+ "Points element name under points/ (for example transcripts). "
500
+ "Required when the store has more than one Points element."
501
+ ),
502
+ )
503
+ morton_from_zarr.add_argument(
504
+ "--output",
505
+ metavar="PATH",
506
+ help=(
507
+ "Output Parquet path (default: in-place on points/<key>/points.parquet, "
508
+ "or points.experimental/<key>/points.parquet with --experimental)"
509
+ ),
510
+ )
511
+ morton_from_zarr.add_argument(
512
+ "--output-points-key",
513
+ metavar="KEY",
514
+ help=(
515
+ "Output Points element name under points/ (for example transcripts_morton). "
516
+ "Cannot be combined with --output."
517
+ ),
518
+ )
519
+ morton_from_zarr.add_argument(
520
+ "--overwrite",
521
+ action="store_true",
522
+ help="Allow overwriting an existing explicit output path or output Points element.",
523
+ )
524
+ morton_from_zarr.add_argument(
525
+ "--feature-key",
526
+ metavar="COLUMN",
527
+ help=(
528
+ "Column used to derive <feature_key>_codes (default: spatialdata_attrs.feature_key "
529
+ "from the element zarr.json)"
530
+ ),
531
+ )
532
+ _add_row_group_args(morton_from_zarr)
533
+ morton_from_zarr.set_defaults(func=_points_morton_from_zarr)
534
+
535
+ morton = point_commands.add_parser(
536
+ "morton",
537
+ help="Morton-sort points from CSV or Parquet",
538
+ description=(
539
+ "Sort x/y points by 2D Morton order, prepend sentinel bbox rows, "
540
+ "and write Vitessce-compatible Parquet."
541
+ ),
542
+ )
543
+ morton.add_argument(
544
+ "input", metavar="INPUT", help="Input .csv, .parquet file, or directory of Parquet parts"
545
+ )
546
+ morton.add_argument("output", metavar="OUTPUT", help="Output .parquet file")
547
+ morton.add_argument(
548
+ "--feature-key",
549
+ metavar="COLUMN",
550
+ help="Column used to derive <feature_key>_codes for categorical features",
551
+ )
552
+ _add_row_group_args(morton)
553
+ morton.set_defaults(func=_points_morton)
554
+
555
+ multiscale = point_commands.add_parser(
556
+ "multiscale",
557
+ help="write multiscale Points Parquet with spatialdata_multiscale metadata",
558
+ description=(
559
+ "Write Points Parquet with Padua-style spatialdata_multiscale JSON "
560
+ "stored in the file schema metadata."
561
+ ),
562
+ )
563
+ multiscale.add_argument(
564
+ "input", metavar="INPUT", help="Input .csv, .parquet file, or directory of Parquet parts"
565
+ )
566
+ multiscale.add_argument("output", metavar="OUTPUT", help="Output .parquet file")
567
+ multiscale.add_argument(
568
+ "--metadata-json",
569
+ metavar="PATH",
570
+ help="Optional spatialdata_multiscale metadata JSON (default: inferred from input)",
571
+ )
572
+ _add_row_group_args(multiscale)
573
+ multiscale.set_defaults(func=_points_multiscale)
574
+
575
+ points_zarr = point_commands.add_parser(
576
+ "zarr",
577
+ help="write a points store with an experimental points-zarr copy",
578
+ description=(
579
+ "Write a new SpatialData store with one standard points element and, beside "
580
+ "it, a copy in merfisheyes' draft points-zarr layout (levels of uniform random "
581
+ "samples, each in tile and feature order, with a row index). Standard readers "
582
+ "read the standard element and ignore the copy. Parquet input is streamed and "
583
+ "sorted out of core."
584
+ ),
585
+ )
586
+ points_zarr.add_argument(
587
+ "input", metavar="INPUT", help="Input .csv, .parquet file, or directory of Parquet parts"
588
+ )
589
+ points_zarr.add_argument("output", metavar="OUTPUT", help="Output store directory")
590
+ points_zarr.add_argument(
591
+ "--feature-key", required=True, metavar="COLUMN", help="Categorical feature column"
592
+ )
593
+ points_zarr.add_argument(
594
+ "--coordinates",
595
+ default="x,y",
596
+ metavar="COLUMNS",
597
+ help="Comma-separated coordinate columns, two or three, written as x,y(,z) (default: x,y)",
598
+ )
599
+ points_zarr.add_argument(
600
+ "--points-key", default="transcripts", metavar="KEY", help="Element name under points/"
601
+ )
602
+ _add_points_zarr_args(points_zarr)
603
+ points_zarr.set_defaults(func=_points_zarr)
604
+
605
+ add_zarr = point_commands.add_parser(
606
+ "add-zarr",
607
+ help="add an experimental points-zarr copy beside an existing points element",
608
+ description=(
609
+ "Read points/<key>/points.parquet from a store and write its points-zarr copy "
610
+ "as the element's experimental.points-zarr subgroup, merging the copy into the "
611
+ "store's consolidated metadata. The standard element is left as it is."
612
+ ),
613
+ )
614
+ add_zarr.add_argument("zarr", metavar="ZARR", help="Path to a SpatialData Zarr store")
615
+ add_zarr.add_argument("--points-key", required=True, metavar="KEY", help="Element name under points/")
616
+ _add_points_zarr_args(add_zarr)
617
+ add_zarr.set_defaults(func=_points_add_zarr)
618
+
619
+ index_permutations = point_commands.add_parser(
620
+ "index-permutations",
621
+ help="write a derivative store with transcript index sort permutations",
622
+ description=(
623
+ "Copy a SpatialData Zarr store and add sibling points elements with "
624
+ "different transcript sort/index layouts plus index-manifest.json."
625
+ ),
626
+ )
627
+ index_permutations.add_argument("source_zarr", metavar="SOURCE_ZARR")
628
+ index_permutations.add_argument("dest_zarr", metavar="DEST_ZARR")
629
+ index_permutations.add_argument("--points-key", metavar="KEY")
630
+ index_permutations.add_argument("--max-rows", type=_positive_int, metavar="N")
631
+ index_permutations.add_argument(
632
+ "--conditions", metavar="IDS", help="Comma-separated condition ids (default: all)"
633
+ )
634
+ index_permutations.add_argument("--overwrite", action="store_true")
635
+ _add_row_group_args(index_permutations)
636
+ index_permutations.set_defaults(func=_points_index_permutations)
637
+
638
+
639
+ # --------------------------------------------------------------------------- tables
640
+
641
+
642
+ def _tables_list(args: argparse.Namespace) -> None:
643
+ from .runners import run_list_tables
644
+
645
+ _run_command(lambda: run_list_tables(args.zarr))
646
+
647
+
648
+ def _tables_to_csc(args: argparse.Namespace) -> None:
649
+ from .runners import run_tables_to_csc
650
+
651
+ _run_command(
652
+ lambda: run_tables_to_csc(
653
+ args.source,
654
+ args.dest,
655
+ tables=args.table.split(",") if args.table else None,
656
+ layers=not args.no_layers,
657
+ densify=args.densify,
658
+ overwrite=args.overwrite,
659
+ )
660
+ )
661
+
662
+
663
+ def _add_tables_commands(subparsers: argparse._SubParsersAction) -> None:
664
+ tables = subparsers.add_parser(
665
+ "tables",
666
+ help="convert table matrices to CSC",
667
+ description="Rewrite AnnData table matrices for fast per-variable browser reads.",
668
+ )
669
+ table_commands = tables.add_subparsers(dest="tables_command", required=True, metavar="command")
670
+
671
+ list_tables = table_commands.add_parser(
672
+ "list", help="list table element keys in a store"
673
+ )
674
+ list_tables.add_argument("zarr", metavar="ZARR", help="Path to a SpatialData Zarr store")
675
+ list_tables.set_defaults(func=_tables_list)
676
+
677
+ to_csc = table_commands.add_parser(
678
+ "to-csc",
679
+ help="convert X (and layers) to CSC",
680
+ description=(
681
+ "Convert table matrices from CSR to CSC so that reading one variable "
682
+ "(gene) is a single contiguous range read instead of a scan of every row. "
683
+ "Values are unchanged and the result stays readable by any AnnData client."
684
+ ),
685
+ )
686
+ to_csc.add_argument("source", metavar="SOURCE", help="SpatialData Zarr store")
687
+ to_csc.add_argument(
688
+ "dest",
689
+ metavar="DEST",
690
+ nargs="?",
691
+ help="Output store (default: rewrite SOURCE in place)",
692
+ )
693
+ to_csc.add_argument(
694
+ "--table",
695
+ metavar="KEYS",
696
+ help="Comma-separated table element names (default: all tables)",
697
+ )
698
+ to_csc.add_argument(
699
+ "--no-layers",
700
+ action="store_true",
701
+ help="Convert X only, leaving adata.layers untouched",
702
+ )
703
+ to_csc.add_argument(
704
+ "--densify",
705
+ action="store_true",
706
+ help=(
707
+ "Also convert dense matrices to CSC. Off by default because sparsifying "
708
+ "a dense matrix can make it larger."
709
+ ),
710
+ )
711
+ to_csc.add_argument("--overwrite", action="store_true", help="Replace an existing DEST")
712
+ to_csc.set_defaults(func=_tables_to_csc)
713
+
714
+
715
+ # --------------------------------------------------------------------------- codecs
716
+
717
+
718
+ def _codecs_info(_args: argparse.Namespace) -> None:
719
+ from .codecs.backends import backend_report
720
+
721
+ _print_json(backend_report())
722
+
723
+
724
+ def _add_codecs_commands(subparsers: argparse._SubParsersAction) -> None:
725
+ codecs = subparsers.add_parser(
726
+ "codecs",
727
+ help="inspect image codec backends",
728
+ description="Report which HTJ2K backends are installed and which one is selected.",
729
+ )
730
+ codec_commands = codecs.add_subparsers(dest="codecs_command", required=True, metavar="command")
731
+
732
+ info = codec_commands.add_parser(
733
+ "info",
734
+ help="show HTJ2K backend availability and probe results",
735
+ description=(
736
+ "Show each HTJ2K backend's availability and whether it decodes the bundled "
737
+ "multi-component probe codestream correctly, plus which one is selected."
738
+ ),
739
+ )
740
+ info.set_defaults(func=_codecs_info)
741
+
742
+
743
+ # --------------------------------------------------------------------------- tui
744
+
745
+
746
+ def _tui(args: argparse.Namespace) -> None:
747
+ try:
748
+ from .tui.app import run_tui
749
+ except ImportError as exc:
750
+ raise SystemExit(
751
+ "TUI dependencies are not installed. Install with: pip install 'spatialdata-js-util[tui]'"
752
+ ) from exc
753
+ run_tui(initial_zarr=args.zarr)
754
+
755
+
756
+ def _add_tui_command(subparsers: argparse._SubParsersAction) -> None:
757
+ tui = subparsers.add_parser(
758
+ "tui",
759
+ help="interactive terminal UI for every command",
760
+ description=(
761
+ "Launch the interactive workflow UI. It covers image recompression, "
762
+ "Points indexing, table CSC conversion, and codec backend info, with "
763
+ "guided forms, confirmation before any in-place write, and post-write "
764
+ "verification. Requires the 'tui' extra: "
765
+ "pip install 'spatialdata-js-util[tui]'"
766
+ ),
767
+ )
768
+ tui.add_argument(
769
+ "zarr",
770
+ nargs="?",
771
+ metavar="ZARR",
772
+ help="Optional SpatialData Zarr store path (skips initial store picker)",
773
+ )
774
+ tui.set_defaults(func=_tui)
775
+
776
+
777
+ def build_parser() -> argparse.ArgumentParser:
778
+ parser = argparse.ArgumentParser(
779
+ prog="spatialdata-js-util",
780
+ description=(
781
+ "Read/write utilities for browser-oriented SpatialData stores: image "
782
+ "codecs, Morton-indexed points, and CSC table matrices."
783
+ ),
784
+ formatter_class=argparse.RawDescriptionHelpFormatter,
785
+ epilog=_EPILOG,
786
+ )
787
+ subparsers = parser.add_subparsers(dest="group", required=True, metavar="group")
788
+ _add_images_commands(subparsers)
789
+ _add_points_commands(subparsers)
790
+ _add_tables_commands(subparsers)
791
+ _add_codecs_commands(subparsers)
792
+ _add_tui_command(subparsers)
793
+ return parser
794
+
795
+
796
+ def main(argv: list[str] | None = None) -> None:
797
+ parser = build_parser()
798
+ args = parser.parse_args(argv)
799
+ args.func(args)
800
+
801
+
802
+ if __name__ == "__main__":
803
+ main()