Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 78f75ef

Browse files
ome zarr chunks (#1092)
* ome zarr chunks * set scale factors to emtpy list + fix unit tests * mypy * lowercase to fix unit test linux * bump ome zarr in pyproject toml * dask accessor is now always loaded * deduplicate storage option util; use chunks from data when not specified in storage options * simplify, document and test the chunk helper functions * guard against storage_options["chunks"]="" + Change ValueError * remove data argument from _prepare_storage_options() * remove data argument from _prepare_storage_options() --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 6f65caf commit 78f75ef

5 files changed

Lines changed: 262 additions & 29 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"networkx",
3636
"numba>=0.55.0",
3737
"numpy",
38-
"ome_zarr>=0.12.2",
38+
"ome_zarr>=0.14.0",
3939
"pandas",
4040
"pooch",
4141
"pyarrow",

‎src/spatialdata/__init__.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
fromimportlib.metadataimportversion
55
fromtypingimportTYPE_CHECKING, Any
66

7+
importspatialdata.models._accessor# noqa: F401
8+
79
__version__=version("spatialdata")
810

911
_submodules= {
@@ -129,15 +131,8 @@
129131
"settings",
130132
]
131133

132-
_accessor_loaded=False
133-
134134

135135
def__getattr__(name: str) ->Any:
136-
global_accessor_loaded
137-
ifnot_accessor_loaded:
138-
_accessor_loaded=True
139-
importspatialdata.models._accessor# noqa: F401
140-
141136
ifnamein_submodules:
142137
returnimportlib.import_module(f"spatialdata.{name}")
143138
ifnamein_LAZY_IMPORTS:

‎src/spatialdata/_io/io_raster.py‎

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ importannotations
22

3+
fromcollections.abcimportSequence
34
frompathlibimportPath
4-
fromtypingimportAny, Literal
5+
fromtypingimportAny, Literal, TypeGuard
56

67
importdask.arrayasda
78
importnumpyasnp
@@ -38,6 +39,126 @@
3839
)
3940

4041

42+
def_is_flat_int_sequence(value: object) ->TypeGuard[Sequence[int]]:
43+
# e.g. "", "auto" or b"auto"
44+
ifisinstance(value, str|bytes):
45+
returnFalse
46+
ifnotisinstance(value, Sequence):
47+
returnFalse
48+
returnall(isinstance(v, int) forvinvalue)
49+
50+
51+
def_is_dask_chunk_grid(value: object) ->TypeGuard[Sequence[Sequence[int]]]:
52+
ifisinstance(value, str|bytes):
53+
returnFalse
54+
ifnotisinstance(value, Sequence):
55+
returnFalse
56+
returnlen(value) >0andall(_is_flat_int_sequence(axis_chunks) foraxis_chunksinvalue)
57+
58+
59+
def_is_regular_dask_chunk_grid(chunk_grid: Sequence[Sequence[int]]) ->bool:
60+
"""Check whether a Dask chunk grid is regular (zarr-compatible).
61+
62+
A grid is regular when every axis has at most one unique chunk size among all but the last
63+
chunk, and the last chunk is not larger than the first.
64+
65+
Parameters
66+
----------
67+
chunk_grid
68+
Per-axis tuple of chunk sizes, for instance as returned by ``dask_array.chunks``.
69+
70+
Examples
71+
--------
72+
Triggers ``continue`` on the first ``if`` (single or empty axis):
73+
74+
>>> _is_regular_dask_chunk_grid([(4,)]) # single chunk → True
75+
True
76+
>>> _is_regular_dask_chunk_grid([()]) # empty axis → True
77+
True
78+
79+
Triggers the first ``return False`` (non-uniform interior chunks):
80+
81+
>>> _is_regular_dask_chunk_grid([(4, 4, 3, 4)]) # interior sizes differ → False
82+
False
83+
84+
Triggers the second ``return False`` (last chunk larger than the first):
85+
86+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 5)]) # last > first → False
87+
False
88+
89+
Exits with ``return True``:
90+
91+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 4)]) # all equal → True
92+
True
93+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1)]) # last < first → True
94+
True
95+
96+
Empty grid (loop never executes) → True:
97+
98+
>>> _is_regular_dask_chunk_grid([])
99+
True
100+
101+
Multi-axis: all axes regular → True; one axis irregular → False:
102+
103+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (3, 3, 2)])
104+
True
105+
>>> _is_regular_dask_chunk_grid([(4, 4, 4, 1), (4, 4, 3, 4)])
106+
False
107+
"""
108+
# Match Dask's private _check_regular_chunks() logic without depending on its internal API.
109+
foraxis_chunksinchunk_grid:
110+
iflen(axis_chunks) <=1:
111+
continue
112+
iflen(set(axis_chunks[:-1])) >1:
113+
returnFalse
114+
ifaxis_chunks[-1] >axis_chunks[0]:
115+
returnFalse
116+
returnTrue
117+
118+
119+
def_chunks_to_zarr_chunks(chunks: object) ->tuple[int, ...] |int|None:
120+
ifisinstance(chunks, int):
121+
returnchunks
122+
if_is_flat_int_sequence(chunks):
123+
returntuple(chunks)
124+
if_is_dask_chunk_grid(chunks):
125+
chunk_grid=tuple(tuple(axis_chunks) foraxis_chunksinchunks)
126+
if_is_regular_dask_chunk_grid(chunk_grid):
127+
returntuple(axis_chunks[0] foraxis_chunksinchunk_grid)
128+
returnNone
129+
returnNone
130+
131+
132+
def_normalize_explicit_chunks(chunks: object) ->tuple[int, ...] |int:
133+
normalized=_chunks_to_zarr_chunks(chunks)
134+
ifnormalizedisNone:
135+
raiseValueError(
136+
'storage_options["chunks"] must resolve to a Zarr chunk shape or a regular Dask chunk grid. '
137+
"The current raster has irregular Dask chunks, which cannot be written to Zarr. "
138+
"To fix this, rechunk before writing, for example by passing regular chunks=... "
139+
"to Image2DModel.parse(...) / Labels2DModel.parse(...)."
140+
)
141+
returnnormalized
142+
143+
144+
def_prepare_storage_options(
145+
storage_options: JSONDict|list[JSONDict] |None,
146+
) ->JSONDict|list[JSONDict] |None:
147+
ifstorage_optionsisNone:
148+
returnNone
149+
ifisinstance(storage_options, dict):
150+
prepared=dict(storage_options)
151+
if"chunks"inprepared:
152+
prepared["chunks"] =_normalize_explicit_chunks(prepared["chunks"])
153+
returnprepared
154+
155+
prepared_options= [dict(options) foroptionsinstorage_options]
156+
foroptionsinprepared_options:
157+
if"chunks"inoptions:
158+
options["chunks"] =_normalize_explicit_chunks(options["chunks"])
159+
returnprepared_options
160+
161+
41162
def_read_multiscale(
42163
store: str|Path, raster_type: Literal["image", "labels"], reader_format: Format
43164
) ->DataArray|DataTree:
@@ -251,20 +372,18 @@ def _write_raster_dataarray(
251372
iftransformationsisNone:
252373
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
253374
input_axes: tuple[str, ...] =tuple(raster_data.dims)
254-
chunks=raster_data.chunks
255375
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
256-
ifstorage_optionsisnotNone:
257-
if"chunks"notinstorage_optionsandisinstance(storage_options, dict):
258-
storage_options["chunks"] =chunks
259-
else:
260-
storage_options= {"chunks": chunks}
261-
# Scaler needs to be None since we are passing the data already downscaled for the multiscale case.
262-
# We need this because the argument of write_image_ngff is called image while the argument of
376+
storage_options=_prepare_storage_options(storage_options)
377+
# Explicitly disable pyramid generation for single-scale rasters. Recent ome-zarr versions default
378+
# write_image()/write_labels() to scale_factors=(2, 4, 8, 16), which would otherwise write s0, s1, ...
379+
# even when the input is a plain DataArray.
380+
# We need this because the argument of write_image_ngff is called image while the argument of
263381
# write_labels_ngff is called label.
264382
metadata[raster_type] =data
265383
ome_zarr_format=get_ome_zarr_format(raster_format)
266384
write_single_scale_ngff(
267385
group=group,
386+
scale_factors=[],
268387
scaler=None,
269388
fmt=ome_zarr_format,
270389
axes=parsed_axes,
@@ -322,10 +441,9 @@ def _write_raster_datatree(
322441
transformations=_get_transformations_xarray(xdata)
323442
iftransformationsisNone:
324443
raiseValueError(f"{element_name} does not have any transformations and can therefore not be written.")
325-
chunks=get_pyramid_levels(raster_data, "chunks")
326444

327445
parsed_axes=_get_valid_axes(axes=list(input_axes), fmt=raster_format)
328-
storage_options=[{"chunks": chunk} forchunkinchunks]
446+
storage_options=_prepare_storage_options(storage_options)
329447
ome_zarr_format=get_ome_zarr_format(raster_format)
330448
dask_delayed=write_multi_scale_ngff(
331449
pyramid=data,

‎tests/io/test_partial_read.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def sdata_with_corrupted_image_chunks_zarrv3(session_tmp_path: Path) -> PartialR
184184
sdata.write(sdata_path)
185185

186186
corrupted="blobs_image"
187-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
189-
(sdata_path/"images"/corrupted/"0").touch()
187+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json") # it will hide the "0" array from the Zarr reader
188+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
189+
(sdata_path/"images"/corrupted/"s0").touch()
190190

191191
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
192192

@@ -206,9 +206,9 @@ def sdata_with_corrupted_image_chunks_zarrv2(session_tmp_path: Path) -> PartialR
206206
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
207207

208208
corrupted="blobs_image"
209-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray") # it will hide the "0" array from the Zarr reader
210-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
211-
(sdata_path/"images"/corrupted/"0").touch()
209+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray") # it will hide the "0" array from the Zarr reader
210+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
211+
(sdata_path/"images"/corrupted/"s0").touch()
212212
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
213213

214214
returnPartialReadTestCase(
@@ -315,8 +315,8 @@ def sdata_with_missing_image_chunks_zarrv3(
315315
sdata.write(sdata_path)
316316

317317
corrupted="blobs_image"
318-
os.unlink(sdata_path/"images"/corrupted/"0"/"zarr.json")
319-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
318+
os.unlink(sdata_path/"images"/corrupted/"s0"/"zarr.json")
319+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
320320

321321
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
322322

@@ -339,8 +339,8 @@ def sdata_with_missing_image_chunks_zarrv2(
339339
sdata.write(sdata_path, sdata_formats=SpatialDataContainerFormatV01())
340340

341341
corrupted="blobs_image"
342-
os.unlink(sdata_path/"images"/corrupted/"0"/".zarray")
343-
os.rename(sdata_path/"images"/corrupted/"0", sdata_path/"images"/corrupted/"0_corrupted")
342+
os.unlink(sdata_path/"images"/corrupted/"s0"/".zarray")
343+
os.rename(sdata_path/"images"/corrupted/"s0", sdata_path/"images"/corrupted/"s0_corrupted")
344344

345345
not_corrupted= [namefor_, name, _insdata.gen_elements() ifname!=corrupted]
346346

0 commit comments

Comments
 (0)