spatialdata-js-util 0.1.0__tar.gz → 0.2.0__tar.gz

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 (64) hide show
  1. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/PKG-INFO +47 -12
  2. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/README.md +45 -10
  3. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/pyproject.toml +2 -2
  4. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/__init__.py +6 -0
  5. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/cli.py +46 -11
  6. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/backends.py +16 -1
  7. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/images.py +7 -6
  8. spatialdata_js_util-0.2.0/src/spatialdata_js_util/pyramids.py +788 -0
  9. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/store.py +25 -0
  10. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tui/screens.py +4 -4
  11. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/PKG-INFO +47 -12
  12. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/SOURCES.txt +1 -0
  13. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/requires.txt +1 -1
  14. spatialdata_js_util-0.2.0/tests/test_htj2k_native_floor.py +51 -0
  15. spatialdata_js_util-0.2.0/tests/test_pyramids.py +799 -0
  16. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_tui.py +1 -1
  17. spatialdata_js_util-0.1.0/src/spatialdata_js_util/pyramids.py +0 -318
  18. spatialdata_js_util-0.1.0/tests/test_pyramids.py +0 -272
  19. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/setup.cfg +0 -0
  20. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/__init__.py +0 -0
  21. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/chunks.py +0 -0
  22. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/encoding.py +0 -0
  23. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/htj2k_wasm.py +0 -0
  24. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/names.py +0 -0
  25. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  26. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/probe/multicomponent.json +0 -0
  27. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  28. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/__init__.py +0 -0
  29. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/encode-plane.mjs +0 -0
  30. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/openjph/index.mjs +0 -0
  31. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +0 -0
  32. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  33. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +0 -0
  34. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  35. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/codecs/zarr_codec.py +0 -0
  36. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/errors.py +0 -0
  37. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/index_permutations.py +0 -0
  38. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/points.py +0 -0
  39. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/points_zarr.py +0 -0
  40. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/provenance.py +0 -0
  41. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/runners.py +0 -0
  42. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tables.py +0 -0
  43. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tui/__init__.py +0 -0
  44. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tui/app.py +0 -0
  45. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tui/models.py +0 -0
  46. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/tui/widgets.py +0 -0
  47. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util/verify.py +0 -0
  48. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/dependency_links.txt +0 -0
  49. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/entry_points.txt +0 -0
  50. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/src/spatialdata_js_util.egg-info/top_level.txt +0 -0
  51. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_htj2k_encode.py +0 -0
  52. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_htj2k_encode_demo.py +0 -0
  53. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_htj2k_quality.py +0 -0
  54. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_integration.py +0 -0
  55. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_points.py +0 -0
  56. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_points_zarr.py +0 -0
  57. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_recompress.py +0 -0
  58. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_synthetic_images.py +0 -0
  59. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_tables.py +0 -0
  60. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_verify.py +0 -0
  61. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_write_synthetic.py +0 -0
  62. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_writer.py +0 -0
  63. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_zarr.py +0 -0
  64. {spatialdata_js_util-0.1.0 → spatialdata_js_util-0.2.0}/tests/test_zarr_codec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdata-js-util
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Read/write utilities for browser-oriented SpatialData stores (image codecs, Morton-indexed points, CSC tables)
5
5
  Author: SpatialData.js contributors
6
6
  License: MIT
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
15
15
  Requires-Python: >=3.12
16
16
  Description-Content-Type: text/markdown
17
- Requires-Dist: imagecodecs>=2025.8.2
17
+ Requires-Dist: imagecodecs>=2026.6.26
18
18
  Requires-Dist: numcodecs>=0.16
19
19
  Requires-Dist: numpy>=2.0
20
20
  Requires-Dist: pandas>=2.2
@@ -111,7 +111,8 @@ The flow is the same for every command: **guided form → confirm → run → re
111
111
  - **Runs stream their JSON output** to a log pane, so a long recompression shows
112
112
  progress rather than appearing to hang.
113
113
  - The recompress form carries the [pyramid](#multiscale-pyramids) controls, so
114
- single-resolution input can be made browser-ready without leaving the UI.
114
+ single-resolution or shallow-pyramid input can be made browser-ready without
115
+ leaving the UI.
115
116
  - **Writes are verified afterwards** and the report screen shows a pass/fail
116
117
  table. For Morton writes that means:
117
118
 
@@ -238,18 +239,43 @@ spatial axis is `--pyramid-min-size` (1024) or smaller, so the coarsest level is
238
239
  roughly one chunk. Override with `--pyramid-levels N` (a *total* count including
239
240
  full resolution) and `--pyramid-downscale K`.
240
241
 
241
- Images that already have more than one level are **skipped**, and the manifest
242
- says so; `--pyramid-force` rebuilds them anyway. Pass `--labels` to
242
+ An image gets levels only when its coarsest existing level is still larger than
243
+ `--pyramid-min-size` (with `--pyramid-levels N`, when it has fewer than N
244
+ levels). Existing levels are kept and new ones are coarsened from the coarsest
245
+ level (full resolution, for a single-resolution image), so only that level is
246
+ read. Others are **skipped**, and the manifest says
247
+ why; `--pyramid-force` rebuilds every image from full resolution anyway. Pass `--labels` to
243
248
  `add-pyramid` to include label elements — they are downsampled by the label
244
249
  model, so ids are never averaged into values that identify no object.
245
250
 
246
- Full resolution is copied through unchanged; only the added levels are new.
247
- Levels are generated through SpatialData's own parsers, which get the per-level
248
- scale *and* half-pixel translation right — hand-written `multiscales` metadata
249
- tends to misalign every level against the full-resolution image.
251
+ Existing levels are copied through unchanged; only the added levels are new.
252
+ Levels are generated through SpatialData's own parsers and the per-level
253
+ transforms are derived from level shapes, which gets the scale *and* half-pixel
254
+ translation right — hand-written `multiscales` metadata tends to misalign every
255
+ level against the full-resolution image.
250
256
 
251
- Pyramids are always written to a **different** store than the source. SpatialData
252
- refuses to delete the files backing a live element, so there is no in-place mode.
257
+ `add-pyramid` and `recompress --pyramid` write to a **different** store than the
258
+ source, because SpatialData refuses to delete the files backing a live element.
259
+ Adding levels deletes nothing, though, so there are two ways to skip the copy:
260
+
261
+ ```bash
262
+ spatialdata-js-util images add-pyramid store.zarr --in-place
263
+ ```
264
+
265
+ `--in-place` appends the missing levels to the store itself. Existing arrays are
266
+ not read back, rewritten or recompressed; the new arrays are written first and
267
+ then declared in the element's `zarr.json` and the store's consolidated metadata,
268
+ so an interrupted run leaves a store that still reads as before and that a rerun
269
+ completes. New levels use the coarsest level's codecs when those are plain zarr
270
+ codecs, and zarr's defaults (zstd) when it uses an image codec, so they never add
271
+ JPEG 2000 or HTJ2K. It cannot replace levels, so `--pyramid-force` and
272
+ `--overwrite` do not apply, and it stops rather than append to levels whose
273
+ transforms do not follow their shapes. Do not run it while anything else writes
274
+ the store.
275
+
276
+ To avoid even that when you are about to write a `SpatialData` object anyway, call
277
+ `ensure_pyramids(sdata)`: it replaces the elements that need levels in memory and
278
+ writes nothing.
253
279
 
254
280
  ### Other image notes
255
281
 
@@ -364,9 +390,12 @@ csc = to_csc(adata.X) # or just a matrix
364
390
  ## Python API
365
391
 
366
392
  ```python
393
+ import spatialdata as sd
367
394
  from spatialdata_js_util import (
368
395
  recompress_spatialdata,
369
396
  add_pyramids,
397
+ add_pyramids_in_place,
398
+ ensure_pyramids,
370
399
  has_pyramid,
371
400
  write_morton_points_parquet,
372
401
  convert_store_tables_to_csc,
@@ -377,7 +406,7 @@ result = recompress_spatialdata(
377
406
  "input.zarr", "output-htj2k.zarr",
378
407
  codec="experimental.openjph_htj2k",
379
408
  preset="balanced",
380
- pyramid=True, # add levels to single-resolution images first
409
+ pyramid=True, # add levels to images whose coarsest level is too large
381
410
  overwrite=True,
382
411
  )
383
412
  print(result.manifest_path)
@@ -385,6 +414,12 @@ print(result.manifest_path)
385
414
  # Or pyramids on their own
386
415
  has_pyramid("input.zarr", "images", "morphology") # -> False
387
416
  add_pyramids("input.zarr", "output.zarr", levels=4)
417
+ add_pyramids_in_place("input.zarr") # append levels to the store itself
418
+
419
+ # Or in memory, before a write you were doing anyway
420
+ sdata = sd.read_zarr("input.zarr")
421
+ ensure_pyramids(sdata, include_labels=True)
422
+ sdata.write("output.zarr")
388
423
  ```
389
424
 
390
425
  ## Monorepo development
@@ -78,7 +78,8 @@ The flow is the same for every command: **guided form → confirm → run → re
78
78
  - **Runs stream their JSON output** to a log pane, so a long recompression shows
79
79
  progress rather than appearing to hang.
80
80
  - The recompress form carries the [pyramid](#multiscale-pyramids) controls, so
81
- single-resolution input can be made browser-ready without leaving the UI.
81
+ single-resolution or shallow-pyramid input can be made browser-ready without
82
+ leaving the UI.
82
83
  - **Writes are verified afterwards** and the report screen shows a pass/fail
83
84
  table. For Morton writes that means:
84
85
 
@@ -205,18 +206,43 @@ spatial axis is `--pyramid-min-size` (1024) or smaller, so the coarsest level is
205
206
  roughly one chunk. Override with `--pyramid-levels N` (a *total* count including
206
207
  full resolution) and `--pyramid-downscale K`.
207
208
 
208
- Images that already have more than one level are **skipped**, and the manifest
209
- says so; `--pyramid-force` rebuilds them anyway. Pass `--labels` to
209
+ An image gets levels only when its coarsest existing level is still larger than
210
+ `--pyramid-min-size` (with `--pyramid-levels N`, when it has fewer than N
211
+ levels). Existing levels are kept and new ones are coarsened from the coarsest
212
+ level (full resolution, for a single-resolution image), so only that level is
213
+ read. Others are **skipped**, and the manifest says
214
+ why; `--pyramid-force` rebuilds every image from full resolution anyway. Pass `--labels` to
210
215
  `add-pyramid` to include label elements — they are downsampled by the label
211
216
  model, so ids are never averaged into values that identify no object.
212
217
 
213
- Full resolution is copied through unchanged; only the added levels are new.
214
- Levels are generated through SpatialData's own parsers, which get the per-level
215
- scale *and* half-pixel translation right — hand-written `multiscales` metadata
216
- tends to misalign every level against the full-resolution image.
218
+ Existing levels are copied through unchanged; only the added levels are new.
219
+ Levels are generated through SpatialData's own parsers and the per-level
220
+ transforms are derived from level shapes, which gets the scale *and* half-pixel
221
+ translation right — hand-written `multiscales` metadata tends to misalign every
222
+ level against the full-resolution image.
217
223
 
218
- Pyramids are always written to a **different** store than the source. SpatialData
219
- refuses to delete the files backing a live element, so there is no in-place mode.
224
+ `add-pyramid` and `recompress --pyramid` write to a **different** store than the
225
+ source, because SpatialData refuses to delete the files backing a live element.
226
+ Adding levels deletes nothing, though, so there are two ways to skip the copy:
227
+
228
+ ```bash
229
+ spatialdata-js-util images add-pyramid store.zarr --in-place
230
+ ```
231
+
232
+ `--in-place` appends the missing levels to the store itself. Existing arrays are
233
+ not read back, rewritten or recompressed; the new arrays are written first and
234
+ then declared in the element's `zarr.json` and the store's consolidated metadata,
235
+ so an interrupted run leaves a store that still reads as before and that a rerun
236
+ completes. New levels use the coarsest level's codecs when those are plain zarr
237
+ codecs, and zarr's defaults (zstd) when it uses an image codec, so they never add
238
+ JPEG 2000 or HTJ2K. It cannot replace levels, so `--pyramid-force` and
239
+ `--overwrite` do not apply, and it stops rather than append to levels whose
240
+ transforms do not follow their shapes. Do not run it while anything else writes
241
+ the store.
242
+
243
+ To avoid even that when you are about to write a `SpatialData` object anyway, call
244
+ `ensure_pyramids(sdata)`: it replaces the elements that need levels in memory and
245
+ writes nothing.
220
246
 
221
247
  ### Other image notes
222
248
 
@@ -331,9 +357,12 @@ csc = to_csc(adata.X) # or just a matrix
331
357
  ## Python API
332
358
 
333
359
  ```python
360
+ import spatialdata as sd
334
361
  from spatialdata_js_util import (
335
362
  recompress_spatialdata,
336
363
  add_pyramids,
364
+ add_pyramids_in_place,
365
+ ensure_pyramids,
337
366
  has_pyramid,
338
367
  write_morton_points_parquet,
339
368
  convert_store_tables_to_csc,
@@ -344,7 +373,7 @@ result = recompress_spatialdata(
344
373
  "input.zarr", "output-htj2k.zarr",
345
374
  codec="experimental.openjph_htj2k",
346
375
  preset="balanced",
347
- pyramid=True, # add levels to single-resolution images first
376
+ pyramid=True, # add levels to images whose coarsest level is too large
348
377
  overwrite=True,
349
378
  )
350
379
  print(result.manifest_path)
@@ -352,6 +381,12 @@ print(result.manifest_path)
352
381
  # Or pyramids on their own
353
382
  has_pyramid("input.zarr", "images", "morphology") # -> False
354
383
  add_pyramids("input.zarr", "output.zarr", levels=4)
384
+ add_pyramids_in_place("input.zarr") # append levels to the store itself
385
+
386
+ # Or in memory, before a write you were doing anyway
387
+ sdata = sd.read_zarr("input.zarr")
388
+ ensure_pyramids(sdata, include_labels=True)
389
+ sdata.write("output.zarr")
355
390
  ```
356
391
 
357
392
  ## Monorepo development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spatialdata-js-util"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Read/write utilities for browser-oriented SpatialData stores (image codecs, Morton-indexed points, CSC tables)"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.md"
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Topic :: Scientific/Engineering :: Bio-Informatics",
16
16
  ]
17
17
  dependencies = [
18
- "imagecodecs>=2025.8.2",
18
+ "imagecodecs>=2026.6.26",
19
19
  "numcodecs>=0.16",
20
20
  "numpy>=2.0",
21
21
  "pandas>=2.2",
@@ -40,7 +40,10 @@ from .images import (
40
40
  from .pyramids import (
41
41
  PyramidResult,
42
42
  add_pyramids,
43
+ add_pyramids_in_place,
44
+ ensure_pyramids,
43
45
  has_pyramid,
46
+ needs_pyramid,
44
47
  resolve_scale_factors,
45
48
  )
46
49
  from .points import (
@@ -84,16 +87,19 @@ __all__ = [
84
87
  "RecompressedSpatialData",
85
88
  "add_points_zarr_to_store",
86
89
  "add_pyramids",
90
+ "add_pyramids_in_place",
87
91
  "anndata_to_csc",
88
92
  "backend_report",
89
93
  "build_spatialdata_multiscale_metadata",
90
94
  "convert_store_tables_to_csc",
91
95
  "dtype_quantum",
96
+ "ensure_pyramids",
92
97
  "has_pyramid",
93
98
  "htj2k_available",
94
99
  "htj2k_preset_quality",
95
100
  "is_htj2k_codec",
96
101
  "morton_sort_points",
102
+ "needs_pyramid",
97
103
  "plan_column_encodings",
98
104
  "recompress_spatialdata",
99
105
  "register_codecs",
@@ -128,8 +128,10 @@ def _add_pyramid_args(parser: argparse.ArgumentParser, *, with_flag: bool) -> No
128
128
  "--pyramid",
129
129
  action="store_true",
130
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."
131
+ "Before recompressing, add pyramid levels to any image whose coarsest "
132
+ "level is larger than --pyramid-min-size: a single-resolution image is "
133
+ "built from full resolution, a shallow pyramid is extended from its "
134
+ "coarsest level. Implied by the other --pyramid-* options."
133
135
  ),
134
136
  )
135
137
  parser.add_argument(
@@ -159,18 +161,36 @@ def _add_pyramid_args(parser: argparse.ArgumentParser, *, with_flag: bool) -> No
159
161
  parser.add_argument(
160
162
  "--pyramid-force",
161
163
  action="store_true",
162
- help="Rebuild pyramids for images that already have more than one level",
164
+ help="Rebuild every pyramid from full resolution, including ones already deep enough",
163
165
  )
164
166
 
165
167
 
166
168
  def _images_add_pyramid(args: argparse.Namespace) -> None:
167
- from .pyramids import add_pyramids
169
+ from .pyramids import add_pyramids, add_pyramids_in_place
168
170
 
169
171
  def command() -> dict[str, Any]:
172
+ image_keys = args.image_key.split(",") if args.image_key else None
173
+ if args.in_place:
174
+ if args.dest is not None:
175
+ raise WriterCommandError("--in-place takes no destination; omit DEST.")
176
+ if args.overwrite or args.pyramid_force:
177
+ raise WriterCommandError(
178
+ "--in-place only adds levels, so --overwrite and --pyramid-force do not apply."
179
+ )
180
+ return add_pyramids_in_place(
181
+ args.source,
182
+ image_keys=image_keys,
183
+ include_labels=args.labels,
184
+ levels=args.pyramid_levels,
185
+ downscale=args.pyramid_downscale,
186
+ min_size=args.pyramid_min_size,
187
+ ).manifest
188
+ if args.dest is None:
189
+ raise WriterCommandError("Give a destination store, or pass --in-place.")
170
190
  return add_pyramids(
171
191
  args.source,
172
192
  args.dest,
173
- image_keys=args.image_key.split(",") if args.image_key else None,
193
+ image_keys=image_keys,
174
194
  include_labels=args.labels,
175
195
  levels=args.pyramid_levels,
176
196
  downscale=args.pyramid_downscale,
@@ -265,16 +285,31 @@ def _add_images_commands(subparsers: argparse._SubParsersAction) -> None:
265
285
 
266
286
  add_pyramid = image_commands.add_parser(
267
287
  "add-pyramid",
268
- help="give single-resolution rasters a multiscale pyramid",
288
+ help="give rasters the overview levels they lack",
269
289
  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."
290
+ "Copy a store and add pyramid levels to rasters whose coarsest level is "
291
+ "larger than --pyramid-min-size (or that have fewer than --pyramid-levels "
292
+ "levels), keeping any levels they already have, so a zoomed-out browser "
293
+ "view does not have to read full-resolution chunks. Codecs are left "
294
+ "unchanged; use 'images recompress --pyramid' to do both in one pass. "
295
+ "With --in-place the levels are appended to SOURCE itself and nothing is "
296
+ "copied or rewritten."
274
297
  ),
275
298
  )
276
299
  add_pyramid.add_argument("source", help="Existing SpatialData Zarr store")
277
- add_pyramid.add_argument("dest", help="Output SpatialData Zarr store")
300
+ add_pyramid.add_argument(
301
+ "dest",
302
+ nargs="?",
303
+ help="Output SpatialData Zarr store (omit with --in-place)",
304
+ )
305
+ add_pyramid.add_argument(
306
+ "--in-place",
307
+ action="store_true",
308
+ help=(
309
+ "Append the missing levels to SOURCE instead of writing a copy. Existing "
310
+ "arrays are not touched; new levels use plain zarr codecs."
311
+ ),
312
+ )
278
313
  add_pyramid.add_argument(
279
314
  "--image-key",
280
315
  metavar="KEYS",
@@ -35,6 +35,7 @@ from __future__ import annotations
35
35
 
36
36
  import json
37
37
  import os
38
+ import warnings
38
39
  from functools import lru_cache
39
40
  from pathlib import Path
40
41
  from typing import Any, Protocol
@@ -205,6 +206,17 @@ def resolve_backend() -> Htj2kBackend | None:
205
206
  # so it is trusted on availability; every other backend must prove it.
206
207
  if name == BACKEND_OPENJPH_WASM:
207
208
  if _BACKENDS[name].available():
209
+ if not requested:
210
+ # Reaching here means `imagecodecs` failed, usually because the
211
+ # installed release has no HTJ2K for this platform. Encoding
212
+ # still works, but only while Node.js is on PATH.
213
+ warnings.warn(
214
+ "imagecodecs has no usable HTJ2K; falling back to the openjph-wasm "
215
+ "backend, which needs Node.js. Upgrade imagecodecs to the version "
216
+ "spatialdata-js-util requires to encode without Node.",
217
+ RuntimeWarning,
218
+ stacklevel=2,
219
+ )
208
220
  return _BACKENDS[name]
209
221
  elif backend_passes_probe(name):
210
222
  return _BACKENDS[name]
@@ -212,9 +224,12 @@ def resolve_backend() -> Htj2kBackend | None:
212
224
 
213
225
 
214
226
  def reset_backend_cache() -> None:
215
- """Clear memoized backend selection (used by tests that flip the env var)."""
227
+ """Clear memoized backend selection (used by tests that flip the env var or PATH)."""
228
+ from .htj2k_wasm import wasm_encode_available
229
+
216
230
  resolve_backend.cache_clear()
217
231
  backend_passes_probe.cache_clear()
232
+ wasm_encode_available.cache_clear()
218
233
 
219
234
 
220
235
  def require_backend() -> Htj2kBackend:
@@ -614,7 +614,7 @@ def _stage_pyramids(
614
614
  ) -> tuple[list[dict[str, Any]], dict[str, Path]]:
615
615
  """Build pyramids for *image_keys* into a staging store.
616
616
 
617
- Returns the per-raster reports and, for each key that was actually rebuilt,
617
+ Returns the per-raster reports and, for each key that was given levels,
618
618
  the root to read its levels from.
619
619
  """
620
620
  import spatialdata as sd
@@ -635,7 +635,7 @@ def _stage_pyramids(
635
635
  roots = {
636
636
  report["path"].split("/", 1)[1]: staging_path
637
637
  for report in reports
638
- if report.get("action") == "rebuilt"
638
+ if report.get("action") in ("rebuilt", "extended")
639
639
  }
640
640
  return reports, roots
641
641
 
@@ -675,10 +675,11 @@ def recompress_spatialdata(
675
675
  shapes, points, and unconfigured rasters intact without loading the whole
676
676
  object.
677
677
 
678
- With *pyramid*, any configured image that has only one resolution level is
679
- given a multiscale pyramid before it is recompressed, so single-resolution
680
- acquisition output becomes browser-ready in one pass. Images that already
681
- have a pyramid are left alone unless *pyramid_force* is set. See
678
+ With *pyramid*, any configured image whose coarsest level is larger than
679
+ *pyramid_min_size* is given the levels it lacks before it is recompressed,
680
+ so single-resolution or shallow-pyramid acquisition output becomes
681
+ browser-ready in one pass. Images whose pyramid is already deep enough are
682
+ left alone, and *pyramid_force* rebuilds them from full resolution. See
682
683
  `spatialdata_js_util.pyramids` for how levels are chosen.
683
684
  """
684
685