Skip to content

Commit 68dade6

Browse files
melonoraLucaMarconatoclaude
authored
restore option to compress raster (#944)
* add raster_compressor argument for writing raster data compressed to disk * add roundtrip tests for writing raster data in compressed manner --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 06e6981 commit 68dade6

6 files changed

Lines changed: 225 additions & 8 deletions

File tree

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
fail-fast: false
2020
matrix:
2121
include:
22-
- {os: windows-latest, python: "3.11", dask-version: "2025.12.0", name: "min dask"}
22+
- {os: windows-latest, python: "3.11", dask-version: "2026.3.0", name: "min dask"}
2323
- {os: windows-latest, python: "3.14", dask-version: "latest"}
2424
- {os: ubuntu-latest, python: "3.11", dask-version: "latest"}
2525
- {os: ubuntu-latest, python: "3.14", dask-version: "latest"}

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@ dependencies = [
2626
"annsel>=0.1.2",
2727
"click",
2828
"dask-image",
29-
"dask>=2025.12.0,<2026.1.2",
30-
"distributed<2026.1.2",
29+
"dask>=2026.3.0",
30+
"distributed>=2026.3.0",
3131
"datashader",
3232
"fsspec[s3,http]",
3333
"geopandas>=0.14",
3434
"multiscale_spatial_image==2.0.3",
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.14.0",
38+
"ome_zarr>=0.16.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

src/spatialdata/_core/spatialdata.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,7 @@ def write(
11131113
update_sdata_path: bool = True,
11141114
sdata_formats: SpatialDataFormatType | list[SpatialDataFormatType] | None = None,
11151115
shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None,
1116+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
11161117
) -> None:
11171118
"""
11181119
Write the `SpatialData` object to a Zarr store.
@@ -1160,11 +1161,17 @@ def write(
11601161
shapes_geometry_encoding
11611162
Whether to use the WKB or geoarrow encoding for GeoParquet. See :meth:`geopandas.GeoDataFrame.to_parquet`
11621163
for details. If None, uses the value from :attr:`spatialdata.settings.shapes_geometry_encoding`.
1164+
raster_compressor
1165+
A lenght-1 dictionary with as key the type of compression to use for images and labels and as value the
1166+
compression level which should be inclusive between 0 and 9. For compression, `lz4` and `zstd` are
1167+
supported. If not specified, the compression will be `lz4` with compression level 5. Bytes are automatically
1168+
ordered for more efficient compression.
11631169
"""
1164-
from spatialdata._io._utils import _resolve_zarr_store
1170+
from spatialdata._io._utils import _resolve_zarr_store, _validate_compressor_args
11651171
from spatialdata._io.format import _parse_formats
11661172

11671173
parsed = _parse_formats(sdata_formats)
1174+
_validate_compressor_args(raster_compressor)
11681175

11691176
if isinstance(file_path, str):
11701177
file_path = Path(file_path)
@@ -1186,6 +1193,7 @@ def write(
11861193
overwrite=False,
11871194
parsed_formats=parsed,
11881195
shapes_geometry_encoding=shapes_geometry_encoding,
1196+
raster_compressor=raster_compressor,
11891197
)
11901198

11911199
if self.path != file_path and update_sdata_path:
@@ -1203,6 +1211,7 @@ def _write_element(
12031211
overwrite: bool,
12041212
parsed_formats: dict[str, SpatialDataFormatType] | None = None,
12051213
shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None,
1214+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
12061215
) -> None:
12071216
from spatialdata._io.io_zarr import _get_groups_for_element
12081217

@@ -1241,13 +1250,15 @@ def _write_element(
12411250
group=element_group,
12421251
name=element_name,
12431252
element_format=parsed_formats["raster"],
1253+
raster_compressor=raster_compressor,
12441254
)
12451255
elif element_type == "labels":
12461256
write_labels(
12471257
labels=element,
12481258
group=root_group,
12491259
name=element_name,
12501260
element_format=parsed_formats["raster"],
1261+
raster_compressor=raster_compressor,
12511262
)
12521263
elif element_type == "points":
12531264
write_points(
@@ -1278,6 +1289,7 @@ def write_element(
12781289
overwrite: bool = False,
12791290
sdata_formats: SpatialDataFormatType | list[SpatialDataFormatType] | None = None,
12801291
shapes_geometry_encoding: Literal["WKB", "geoarrow"] | None = None,
1292+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
12811293
) -> None:
12821294
"""
12831295
Write a single element, or a list of elements, to the Zarr store used for backing.
@@ -1296,6 +1308,11 @@ def write_element(
12961308
shapes_geometry_encoding
12971309
Whether to use the WKB or geoarrow encoding for GeoParquet. See :meth:`geopandas.GeoDataFrame.to_parquet`
12981310
for details. If None, uses the value from :attr:`spatialdata.settings.shapes_geometry_encoding`.
1311+
raster_compressor
1312+
A lenght-1 dictionary with as key the type of compression to use for images and labels and as value the
1313+
compression level which should be inclusive between 0 and 9. For compression, `lz4` and `zstd` are
1314+
supported. If not specified, the compression will be `lz4` with compression level 5. Bytes are automatically
1315+
ordered for more efficient compression.
12991316
13001317
Notes
13011318
-----
@@ -1314,6 +1331,7 @@ def write_element(
13141331
overwrite=overwrite,
13151332
sdata_formats=sdata_formats,
13161333
shapes_geometry_encoding=shapes_geometry_encoding,
1334+
raster_compressor=raster_compressor,
13171335
)
13181336
return
13191337

@@ -1349,6 +1367,7 @@ def write_element(
13491367
overwrite=overwrite,
13501368
parsed_formats=parsed_formats,
13511369
shapes_geometry_encoding=shapes_geometry_encoding,
1370+
raster_compressor=raster_compressor,
13521371
)
13531372
# After every write, metadata should be consolidated, otherwise this can lead to IO problems like when deleting.
13541373
if self.has_consolidated_metadata():

src/spatialdata/_io/_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,26 @@ def handle_read_errors(
574574
else: # on_bad_files == BadFileHandleMethod.ERROR
575575
# Let it raise exceptions
576576
yield
577+
578+
579+
def _validate_compressor_args(compressor_dict: dict[Literal["lz4", "zstd"], int] | None) -> None:
580+
if compressor_dict:
581+
if not isinstance(compressor_dict, dict):
582+
raise TypeError(
583+
f"Expected a dictionary with as key the type of compression to use for images and labels and "
584+
f"as value the compression level which should be inclusive between 1 and 9. "
585+
f"Got type: {type(compressor_dict)}"
586+
)
587+
if len(compressor_dict) != 1:
588+
raise ValueError(
589+
"Expected a dictionary with a single key indicating the type of compression, either 'lz4' or "
590+
"'zstd' and an `int` inclusive between 1 and 9 as value representing the compression level."
591+
)
592+
if (compression := list(compressor_dict.keys())[0]) not in ["lz4", "zstd"]:
593+
raise ValueError(
594+
f"Compression must either be `lz4` or `zstd`, got: {compression}. If you would like "
595+
"another compression enabled, please open a Github issue at "
596+
"https://github.com/scverse/spatialdata/issues"
597+
)
598+
if not isinstance(value := list(compressor_dict.values())[0], int) or not (0 <= value <= 9):
599+
raise ValueError(f"The compression level must be an integer inclusive between 0 and 9. Got: {value}")

src/spatialdata/_io/io_raster.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Sequence
44
from pathlib import Path
5-
from typing import Any, Literal, TypeGuard
5+
from typing import Any, Literal, TypeGuard, cast
66

77
import dask.array as da
88
import numpy as np
@@ -265,6 +265,7 @@ def _write_raster(
265265
name: str,
266266
raster_format: RasterFormatType,
267267
storage_options: JSONDict | list[JSONDict] | None = None,
268+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
268269
label_metadata: JSONDict | None = None,
269270
**metadata: str | JSONDict | list[JSONDict],
270271
) -> None:
@@ -284,6 +285,8 @@ def _write_raster(
284285
The format used to write the raster data.
285286
storage_options
286287
Additional options for writing the raster data, like chunks and compression.
288+
raster_compressor
289+
Compression settings as a len-1 dictionary with a single key-value {compression: compression level} pair
287290
label_metadata
288291
Label metadata which can only be defined when writing 'labels'.
289292
metadata
@@ -313,6 +316,7 @@ def _write_raster(
313316
raster_data,
314317
raster_format,
315318
storage_options,
319+
raster_compressor=raster_compressor,
316320
**metadata,
317321
)
318322
elif isinstance(raster_data, DataTree):
@@ -323,6 +327,7 @@ def _write_raster(
323327
raster_data,
324328
raster_format,
325329
storage_options,
330+
raster_compressor=raster_compressor,
326331
**metadata,
327332
)
328333
else:
@@ -337,13 +342,93 @@ def _write_raster(
337342
group.attrs[ATTRS_KEY] = attrs
338343

339344

345+
def _build_v3_codec(
346+
compression: Literal["lz4", "zstd"],
347+
compression_level: int,
348+
) -> Any:
349+
"""Return the appropriate zarr v3 codec for the given compression type and level."""
350+
if compression == "zstd":
351+
from zarr.codecs import ZstdCodec
352+
353+
return ZstdCodec(level=compression_level)
354+
# lz4: use the native zarr v3 BloscCodec
355+
from zarr.codecs import BloscCodec
356+
357+
return BloscCodec(cname="lz4", clevel=compression_level)
358+
359+
360+
def _apply_compression(
361+
storage_options: JSONDict | list[JSONDict],
362+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None,
363+
zarr_format: Literal[2, 3] = 3,
364+
) -> JSONDict | list[JSONDict]:
365+
"""Apply compression settings to storage options.
366+
367+
Parameters
368+
----------
369+
storage_options
370+
Storage options for zarr arrays
371+
raster_compressor
372+
Compression settings as a dictionary with a single key-value pair
373+
zarr_format
374+
The zarr format version (2 or 3)
375+
376+
Returns
377+
-------
378+
Updated storage options with compression settings
379+
"""
380+
if not raster_compressor:
381+
return storage_options
382+
383+
((compression, compression_level),) = raster_compressor.items()
384+
385+
if zarr_format == 2:
386+
from numcodecs import Blosc as BloscV2
387+
388+
codec_v2 = BloscV2(cname=compression, clevel=compression_level, shuffle=1)
389+
390+
def _update_dict(d: dict[str, Any]) -> None:
391+
d["compressor"] = codec_v2
392+
393+
if isinstance(storage_options, dict):
394+
_update_dict(d=storage_options)
395+
elif isinstance(storage_options, list):
396+
for option in storage_options:
397+
_update_dict(d=option)
398+
elif storage_options is None:
399+
return {"compressor": codec_v2}
400+
else:
401+
raise ValueError(f"storage_options must be a dict or list, not {type(storage_options)}")
402+
else:
403+
# zarr v3: use native codec objects via the "compressors" (plural) key.
404+
# see https://github.com/ome/ome-zarr-py/blob/v0.16.0/ome_zarr/writer.py#L754
405+
# ome-zarr-py ≥ 0.16.0 with dask ≥ 2026.3.0 forwards this key to zarr_array_kwargs.
406+
codec_v3 = _build_v3_codec(compression, compression_level)
407+
408+
def _update_dict_v3(d: dict[str, Any]) -> None:
409+
d["compressors"] = [codec_v3]
410+
411+
if isinstance(storage_options, dict):
412+
_update_dict_v3(d=storage_options)
413+
elif isinstance(storage_options, list):
414+
for option in storage_options:
415+
_update_dict_v3(d=option)
416+
elif storage_options is None:
417+
return {"compressors": [codec_v3]}
418+
else:
419+
raise ValueError(f"storage_options must be a dict or list, not {type(storage_options)}")
420+
421+
return storage_options
422+
423+
340424
def _write_raster_dataarray(
341425
raster_type: Literal["image", "labels"],
342426
group: zarr.Group,
343427
element_name: str,
344428
raster_data: DataArray,
345429
raster_format: RasterFormatType,
346-
storage_options: JSONDict | list[JSONDict] | None = None,
430+
storage_options: JSONDict | list[JSONDict] | None,
431+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None,
347432
**metadata: str | JSONDict | list[JSONDict],
348433
) -> None:
349434
"""Write raster data of type DataArray to disk.
@@ -362,6 +447,8 @@ def _write_raster_dataarray(
362447
The format used to write the raster data.
363448
storage_options
364449
Additional options for writing the raster data, like chunks and compression.
450+
raster_compressor
451+
Compression settings as a len-1 dictionary with a single key-value {compression: compression level} pair
365452
metadata
366453
Additional metadata for the raster element
367454
"""
@@ -373,6 +460,11 @@ def _write_raster_dataarray(
373460
input_axes: tuple[str, ...] = tuple(raster_data.dims)
374461
parsed_axes = _get_valid_axes(axes=list(input_axes), fmt=raster_format)
375462
storage_options = _prepare_storage_options(storage_options)
463+
# Apply compression if specified
464+
storage_options = _apply_compression(
465+
storage_options, raster_compressor, zarr_format=cast(Literal[2, 3], raster_format.zarr_format)
466+
)
467+
376468
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
377469
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
378470
# even when the input is a plain DataArray.
@@ -406,7 +498,8 @@ def _write_raster_datatree(
406498
element_name: str,
407499
raster_data: DataTree,
408500
raster_format: RasterFormatType,
409-
storage_options: JSONDict | list[JSONDict] | None = None,
501+
storage_options: JSONDict | list[JSONDict] | None,
502+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None,
410503
**metadata: str | JSONDict | list[JSONDict],
411504
) -> zarr.Group:
412505
"""Write raster data of type DataTree to disk.
@@ -425,6 +518,8 @@ def _write_raster_datatree(
425518
The format used to write the raster data.
426519
storage_options
427520
Additional options for writing the raster data, like chunks and compression.
521+
raster_compressor
522+
Compression settings as a len-1 dictionary with a single key-value {compression: compression level} pair
428523
metadata
429524
Additional metadata for the raster element
430525
"""
@@ -442,6 +537,10 @@ def _write_raster_datatree(
442537

443538
parsed_axes = _get_valid_axes(axes=list(input_axes), fmt=raster_format)
444539
storage_options = _prepare_storage_options(storage_options)
540+
541+
# Apply compression if specified
542+
storage_options = _apply_compression(storage_options, raster_compressor, zarr_format=raster_format.zarr_format)
543+
445544
ome_zarr_format = get_ome_zarr_format(raster_format)
446545
dask_delayed = write_multi_scale_ngff(
447546
pyramid=data,
@@ -483,6 +582,7 @@ def write_image(
483582
name: str,
484583
element_format: RasterFormatType = CurrentRasterFormat(),
485584
storage_options: JSONDict | list[JSONDict] | None = None,
585+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
486586
**metadata: str | JSONDict | list[JSONDict],
487587
) -> None:
488588
_write_raster(
@@ -492,6 +592,7 @@ def write_image(
492592
name=name,
493593
raster_format=element_format,
494594
storage_options=storage_options,
595+
raster_compressor=raster_compressor,
495596
**metadata,
496597
)
497598

@@ -503,6 +604,7 @@ def write_labels(
503604
element_format: RasterFormatType = CurrentRasterFormat(),
504605
storage_options: JSONDict | list[JSONDict] | None = None,
505606
label_metadata: JSONDict | None = None,
607+
raster_compressor: dict[Literal["lz4", "zstd"], int] | None = None,
506608
**metadata: JSONDict,
507609
) -> None:
508610
_write_raster(
@@ -512,6 +614,7 @@ def write_labels(
512614
name=name,
513615
raster_format=element_format,
514616
storage_options=storage_options,
617+
raster_compressor=raster_compressor,
515618
label_metadata=label_metadata,
516619
**metadata,
517620
)

0 commit comments

Comments
 (0)