Skip to content

Commit e3ff3b3

Browse files
dcherianclaude
andauthored
fix: do not transpose the lazy array for the rgb variant (#289)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0b9447d commit e3ff3b3

2 files changed

Lines changed: 62 additions & 5 deletions

File tree

src/xpublish_tiles/pipeline.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,7 +1168,9 @@ def apply_query(
11681168
if band_dim in array.coords
11691169
else None,
11701170
)
1171-
array = array.transpose(band_dim, ...)
1171+
# Do not transpose here: xarray turns a lazy transpose into a
1172+
# vectorized indexer over every element, and zarr then gathers
1173+
# pointwise. ``subset_to_bbox`` moves the band dim first after load.
11721174
datatype = RGBData(band_dim=band_dim, valid_range=_rgb_valid_range(array))
11731175
extra_dims.discard(band_dim)
11741176
else:
@@ -1201,6 +1203,7 @@ async def subset_to_bbox(
12011203
# returns. ``pending`` carries the per-var bookkeeping needed to build
12021204
# the ``PopulatedRenderContext`` once data has loaded.
12031205
plan_patches: list[Patch] = []
1206+
plan_datatypes: list[DataType] = []
12041207
pending: list[tuple[str, GridSystem, DataType, list[Patch]]] = []
12051208

12061209
for var_name, array in validated.items():
@@ -1325,11 +1328,15 @@ async def subset_to_bbox(
13251328
for patch in patches
13261329
)
13271330
plan_patches.extend(patches)
1331+
plan_datatypes.extend([array.datatype] * len(patches))
13281332
pending.append((var_name, grid, array.datatype, patches))
13291333

13301334
loaded = await load_plans(plans) if plans else []
13311335

1332-
async def _post_load(patch: Patch, ld: xr.DataArray) -> None:
1336+
async def _post_load(patch: Patch, datatype: DataType, ld: xr.DataArray) -> None:
1337+
if isinstance(datatype, RGBData):
1338+
# Downstream expects the band dim to lead; cheap on loaded data.
1339+
ld = ld.transpose(datatype.band_dim, ...)
13331340
if isinstance(patch.grid, Polar):
13341341
ld = patch.grid.assign_index(ld)
13351342
if patch.coarsen_factors:
@@ -1339,7 +1346,10 @@ async def _post_load(patch: Patch, ld: xr.DataArray) -> None:
13391346
patch.da = ld
13401347

13411348
await asyncio.gather(
1342-
*(_post_load(p, ld) for p, ld in zip(plan_patches, loaded, strict=True))
1349+
*(
1350+
_post_load(p, dt, ld)
1351+
for p, dt, ld in zip(plan_patches, plan_datatypes, loaded, strict=True)
1352+
)
13431353
)
13441354

13451355
for var_name, grid, datatype, patches in pending:

tests/test_pipeline.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import io
55
from dataclasses import replace
6+
from typing import Any
67

78
import cf_xarray # noqa: F401 - Enable cf accessor
89
import morecantile
@@ -28,11 +29,13 @@
2829
MissingParameterError,
2930
VariableNotFoundError,
3031
check_transparent_pixels,
32+
max_render_shape,
3133
)
3234
from xpublish_tiles.pipeline import (
3335
apply_query,
3436
bbox_overlap,
3537
pipeline,
38+
subset_to_bbox,
3639
)
3740
from xpublish_tiles.testing.datasets import (
3841
CUBED_SPHERE,
@@ -74,7 +77,14 @@
7477
WGS84_TMS,
7578
TileTestParam,
7679
)
77-
from xpublish_tiles.types import ImageFormat, OutputBBox, OutputCRS, QueryParams, RGBData
80+
from xpublish_tiles.types import (
81+
ImageFormat,
82+
OutputBBox,
83+
OutputCRS,
84+
PopulatedRenderContext,
85+
QueryParams,
86+
RGBData,
87+
)
7888

7989

8090
@st.composite
@@ -1250,13 +1260,50 @@ def test_apply_query_rgb_keeps_band_dim():
12501260
array = validated["foo"]
12511261
assert isinstance(array.datatype, RGBData)
12521262
assert array.datatype.band_dim == "rgb"
1253-
assert array.da.dims[0] == "rgb"
1263+
assert "rgb" in array.da.dims
12541264

12551265
# without the rgb variant the band dim is squeezed like any extra dim
12561266
validated = apply_query(ds, variables=["foo"], selectors={"rgb": "red"})
12571267
assert validated["foo"].da.dims == ("latitude", "longitude")
12581268

12591269

1270+
@pytest.mark.asyncio
1271+
async def test_rgb_lazy_array_stays_sliceable(tmp_path):
1272+
"""The rgb variant must not transpose the lazy array: xarray rewrites a
1273+
lazy transpose as a vectorized indexer over every element, and zarr then
1274+
gathers pointwise with full-size int64 index arrays. The band dim is moved
1275+
first only once the subset is in memory."""
1276+
from xarray.core.indexing import LazilyIndexedArray, LazilyVectorizedIndexedArray
1277+
1278+
RGB.create().to_zarr(
1279+
tmp_path / "rgb.zarr", mode="w", zarr_format=3, consolidated=False
1280+
)
1281+
ds = xr.open_zarr(
1282+
tmp_path / "rgb.zarr", chunks=None, consolidated=False, zarr_format=3
1283+
)
1284+
1285+
validated = apply_query(ds, variables=["foo"], selectors={}, rgb=True)
1286+
data: Any = validated["foo"].da.variable._data
1287+
chain: list[Any] = [data]
1288+
while hasattr(data, "array"):
1289+
data = data.array
1290+
chain.append(data)
1291+
assert any(isinstance(a, LazilyIndexedArray) for a in chain), chain
1292+
assert not any(isinstance(a, LazilyVectorizedIndexedArray) for a in chain), chain
1293+
1294+
query = create_query_params(Tile(x=0, y=0, z=0), WEBMERC_TMS, variant="rgb")
1295+
contexts = await subset_to_bbox(
1296+
validated,
1297+
bbox=query.bbox,
1298+
crs=query.crs,
1299+
max_shape=max_render_shape(style="raster", width=256, height=256),
1300+
)
1301+
context = contexts["foo"]
1302+
assert isinstance(context, PopulatedRenderContext)
1303+
(patch,) = context.patches
1304+
assert patch.da.dims[0] == "rgb"
1305+
1306+
12601307
@pytest.mark.asyncio
12611308
async def test_rgb_band_selector_renders_single_band(png_snapshot):
12621309
"""Colormap variants pick one band with a selector."""

0 commit comments

Comments
 (0)