In order to display zarr files you hvae to manually build the pyramids similar to a cog file. You also have to link all of the data together, so that I viauliser keeps track of the projections and crs metadata.
A common specification to work on this is GeoZarr - https://
Converert the dataset zarr into a tree, that represents the data at different scales
Specify how the groups link to each other
Specify the projection and transformation information for every grouop
We will follow the eopf-geozarr tree structure so that we can use the new open layers visualisations. This structure has a nested data tree structure where a dataset such as measurement/reflectance is a group in the tree has spatial, multiscale and projection infomation. Then the different subgroups are scaled versions of an original dataset, and each subgroup has only spatial information. The highest resolution subgroup is basically the same as teh orignal zarr file.
We will use the basal_melt zarr file as an example.
import xarray as xr
xr.open_datatree('https://s3.explorer.eopf.copernicus.eu/esa-zarr-sentinel-explorer-fra/tests-output/sentinel-2-l2a/S2B_MSIL2A_20260120T125339_N0511_R138_T27VWL_20260120T131151.zarr')This example uses the basal melt zarr created in the other tutorials, all source variables use the x/y spatial dimensions, EPSG:3031, metre coordinates, 512 pixel chunks, and pyramid levels [1, 2, 4, 8, 16].
The output is an EOPF-style GeoZarr hierarchy :
/basal_melt
/basal_melt/0
/basal_melt/1
/basal_melt/2
/basal_melt/3
/basal_melt/4from pathlib import Path
import shutil
from IPython.display import display
import numpy as np
import rioxarray # activates the .rio accessor used by xarray objects
import zarr
from geozarr_toolkit import (
MultiscalesConventionMetadata,
ProjConventionMetadata,
SpatialConventionMetadata,
create_proj_attrs,
create_spatial_attrs,
create_zarr_conventions,
)
ROOT = Path.cwd()
if not (ROOT / "downloaded_data").exists():
ROOT = ROOT.parent
SOURCE_ZARR = ROOT / "downloaded_data/basal_melt_map_racmo_firn_air_corrected_cog.zarr"
OUTPUT_ZARR = ROOT / "downloaded_data/basal_melt_geozarr.zarr"
NAME = "basal_melt"
CRS = "EPSG:3031"
UNITS = "m yr-1"
CHUNK = 512
PYRAMID_FACTORS = [1, 2, 4, 8, 16]
# overwrite np.nan default nodata value for openlayers visualisaiton
NODATA = -9999.0Open the source Zarr¶
The input store already has x, y, one or more data variables, and spatial_ref. We open it lazily with xarray, attach the known CRS at dataset level, find every variable that uses the spatial dimensions, and convert the encoded nodata value to NaN before downsampling. That way coarsen(...).mean(skipna=True) ignores nodata pixels for every spatial variable.
source = xr.open_zarr(SOURCE_ZARR, chunks={"y": CHUNK, "x": CHUNK})
source = source.rio.set_spatial_dims(x_dim="x", y_dim="y").rio.write_crs(CRS)
spatial_data_vars = [
name
for name, data_array in source.data_vars.items()
if any(dim in data_array.dims for dim in ("y", "x"))
]
non_numeric_spatial_vars = [
name
for name in spatial_data_vars
if not np.issubdtype(source[name].dtype, np.number)
]
for var_name in spatial_data_vars:
attrs = source[var_name].attrs.copy()
source[var_name] = source[var_name].where(source[var_name] != NODATA).astype("float32")
source[var_name].attrs.update(attrs)
source[var_name].attrs["grid_mapping"] = "spatial_ref"
if var_name == NAME:
source[var_name].attrs["units"] = UNITS
source
{
"source": str(SOURCE_ZARR),
"data_variables": list(source.data_vars),
"spatial_data_variables": spatial_data_vars,
"shape": [int(source.sizes["y"]), int(source.sizes["x"])],
"crs": CRS,
"bounds": [float(value) for value in source.rio.bounds()],
"resolution": [float(value) for value in source.rio.resolution()],
"nodata_written": float(NODATA),
}
{'source': '/home/krasen/tiling_test/downloaded_data/basal_melt_map_racmo_firn_air_corrected_cog.zarr',
'data_variables': ['basal_melt'],
'spatial_data_variables': ['basal_melt'],
'shape': [6435, 6120],
'crs': 'EPSG:3031',
'bounds': [-3060277.25, -3217740.25, 3059722.75, 3217259.75],
'resolution': [1000.0, -1000.0],
'nodata_written': -9999.0}Build the pyramid levels¶
The native dataset is level 0. Each following level halves the previous level in both spatial dimensions. Using Dataset.coarsen(...).mean(skipna=True) averages all spatial variables together and keeps their coordinate arrays aligned.
if PYRAMID_FACTORS[0] != 1:
raise ValueError("The first pyramid factor must be 1 for the native-resolution level.")
levels = []
level = source
previous_factor = PYRAMID_FACTORS[0]
for level_index, factor in enumerate(PYRAMID_FACTORS):
if level_index > 0:
step = factor / previous_factor
if not float(step).is_integer():
raise ValueError(f"Pyramid factor {factor} is not an integer step from {previous_factor}.")
level = level.coarsen(y=int(step), x=int(step), boundary="trim").mean(skipna=True)
for var_name in spatial_data_vars:
attrs = source[var_name].attrs.copy()
level[var_name] = level[var_name].astype("float32")
level[var_name].attrs.update(attrs)
level = level.rio.set_spatial_dims(x_dim="x", y_dim="y").rio.write_crs(CRS)
levels.append((factor, level))
previous_factor = factor
[
{
"level": level_index,
"factor": factor,
"variables": list(level_ds.data_vars),
"shape": [int(level_ds.sizes["y"]), int(level_ds.sizes["x"])],
"bounds": [float(value) for value in level_ds.rio.bounds()],
"resolution": [float(value) for value in level_ds.rio.resolution()],
}
for level_index, (factor, level_ds) in enumerate(levels)
]
[{'level': 0,
'factor': 1,
'variables': ['basal_melt'],
'shape': [6435, 6120],
'bounds': [-3060277.25, -3217740.25, 3059722.75, 3217259.75],
'resolution': [1000.0, -1000.0]},
{'level': 1,
'factor': 2,
'variables': ['basal_melt'],
'shape': [3217, 3060],
'bounds': [-3060277.25, -3216740.25, 3059722.75, 3217259.75],
'resolution': [2000.0, -2000.0]},
{'level': 2,
'factor': 4,
'variables': ['basal_melt'],
'shape': [1608, 1530],
'bounds': [-3060277.25, -3214740.25, 3059722.75, 3217259.75],
'resolution': [4000.0, -4000.0]},
{'level': 3,
'factor': 8,
'variables': ['basal_melt'],
'shape': [804, 765],
'bounds': [-3060277.25, -3214740.25, 3059722.75, 3217259.75],
'resolution': [8000.0, -8000.0]},
{'level': 4,
'factor': 16,
'variables': ['basal_melt'],
'shape': [402, 382],
'bounds': [-3060277.25, -3214740.25, 3051722.75, 3217259.75],
'resolution': [16000.0, -16000.0]}]Write the GeoZarr hierarchy¶
Each pyramid level is written as its own Zarr group under /basal_melt. The whole xarray Dataset is written at each level, so all source variables travel together with x, y, and spatial_ref metadata.
Most of the spatial metadata comes directly from rioxarray: rio.transform(), rio.bounds(), and the xarray dimension sizes.
if OUTPUT_ZARR.exists():
shutil.rmtree(OUTPUT_ZARR)
# Start with a clean output store and parent group.
root = zarr.group(store=OUTPUT_ZARR, zarr_format=3)
root.require_group(NAME)
# Reuse the same compression and CRS metadata at every pyramid level.
compressor = zarr.codecs.ZstdCodec(level=3)
proj_attrs = create_proj_attrs(code=CRS)
layout = []
written_levels = []
for level_index, (factor, level_ds) in enumerate(levels):
level_name = str(level_index)
group_path = f"{NAME}/{level_name}"
transform = level_ds.rio.transform()
spatial_transform = [
float(transform.a),
float(transform.b),
float(transform.c),
float(transform.d),
float(transform.e),
float(transform.f),
]
spatial_shape = [int(level_ds.sizes["y"]), int(level_ds.sizes["x"])]
spatial_bbox = [float(value) for value in level_ds.rio.bounds()]
# Attach GeoZarr spatial/proj attrs to this resolution group.
level_attrs = create_spatial_attrs(
["y", "x"],
transform=spatial_transform,
bbox=spatial_bbox,
shape=spatial_shape,
registration="pixel",
)
level_attrs.update(proj_attrs)
level_attrs["grid_mapping"] = "spatial_ref"
level_attrs["zarr_conventions"] = create_zarr_conventions(
SpatialConventionMetadata(),
ProjConventionMetadata(),
)
# Fill nodata and keep CF grid_mapping metadata on every spatial variable.
level_to_write = level_ds.copy()
for var_name in spatial_data_vars:
if var_name not in level_to_write:
continue
attrs = level_to_write[var_name].attrs.copy()
level_to_write[var_name] = level_to_write[var_name].fillna(NODATA).astype("float32")
level_to_write[var_name].attrs.update(attrs)
level_to_write[var_name].attrs.update(
{
"grid_mapping": "spatial_ref",
"_FillValue": float(NODATA),
"missing_value": float(NODATA),
}
)
if var_name == NAME:
level_to_write[var_name].attrs["units"] = UNITS
# Ensure spatial_ref is written as a scalar variable in each group.
level_to_write = level_to_write.rio.set_spatial_dims(x_dim="x", y_dim="y").rio.write_crs(
CRS,
grid_mapping_name="spatial_ref",
)
if "spatial_ref" in level_to_write.coords:
level_to_write = level_to_write.reset_coords("spatial_ref")
level_to_write.attrs.update(level_attrs)
# Chunk by variable shape and clear source-store encoding leftovers.
# In a larger dataset the chunnks should be different for each aggregate level to ensure optimal access
y_chunk = min(CHUNK, spatial_shape[0])
x_chunk = min(CHUNK, spatial_shape[1])
level_to_write = level_to_write.chunk({"y": y_chunk, "x": x_chunk})
for var_name in level_to_write.variables:
level_to_write[var_name].encoding.clear()
# Compress and chunk every data variable and coordinate that will be written.
encoding = {}
for var_name, variable in level_to_write.variables.items():
var_encoding = {"compressors": [compressor]}
if variable.dims:
var_encoding["chunks"] = tuple(
y_chunk
if dim == "y"
else x_chunk
if dim == "x"
else min(CHUNK, int(level_to_write.sizes[dim]))
for dim in variable.dims
)
encoding[var_name] = var_encoding
# Write the dataset for this pyramid level.
level_to_write.to_zarr(
OUTPUT_ZARR,
group=group_path,
mode="w",
zarr_format=3,
compute=True,
consolidated=False,
encoding=encoding,
)
# Re-apply group attrs after writing so GeoZarr metadata is explicit.
zarr.open_group(store=OUTPUT_ZARR, path=group_path, mode="a").attrs.update(level_attrs)
# Record this level for the parent multiscales layout.
layout_item = {
"asset": level_name,
"spatial:shape": spatial_shape,
"spatial:transform": spatial_transform,
}
if level_index > 0:
scale = float(factor / PYRAMID_FACTORS[level_index - 1])
layout_item["derived_from"] = str(level_index - 1)
layout_item["transform"] = {"scale": [scale, scale], "translation": [0.0, 0.0]}
layout.append(layout_item)
written_levels.append(
{
"path": group_path,
"factor": factor,
"variables": list(level_to_write.data_vars),
"shape": spatial_shape,
"transform": spatial_transform,
"bbox": spatial_bbox,
}
)
written_levels
[{'path': 'basal_melt/0',
'factor': 1,
'variables': ['basal_melt', 'spatial_ref'],
'shape': [6435, 6120],
'transform': [1000.0, 0.0, -3060277.25, 0.0, -1000.0, 3217259.75],
'bbox': [-3060277.25, -3217740.25, 3059722.75, 3217259.75]},
{'path': 'basal_melt/1',
'factor': 2,
'variables': ['basal_melt', 'spatial_ref'],
'shape': [3217, 3060],
'transform': [2000.0, 0.0, -3060277.25, 0.0, -2000.0, 3217259.75],
'bbox': [-3060277.25, -3216740.25, 3059722.75, 3217259.75]},
{'path': 'basal_melt/2',
'factor': 4,
'variables': ['basal_melt', 'spatial_ref'],
'shape': [1608, 1530],
'transform': [4000.0, 0.0, -3060277.25, 0.0, -4000.0, 3217259.75],
'bbox': [-3060277.25, -3214740.25, 3059722.75, 3217259.75]},
{'path': 'basal_melt/3',
'factor': 8,
'variables': ['basal_melt', 'spatial_ref'],
'shape': [804, 765],
'transform': [8000.0, 0.0, -3060277.25, 0.0, -8000.0, 3217259.75],
'bbox': [-3060277.25, -3214740.25, 3059722.75, 3217259.75]},
{'path': 'basal_melt/4',
'factor': 16,
'variables': ['basal_melt', 'spatial_ref'],
'shape': [402, 382],
'transform': [16000.0, 0.0, -3060277.25, 0.0, -16000.0, 3217259.75],
'bbox': [-3060277.25, -3214740.25, 3051722.75, 3217259.75]}]Add parent multiscale metadata¶
The parent /basal_melt group links the resolution groups together. It stores the GeoZarr conventions, the multiscale layout, the source file path, the source data variable names, and the CRS/spatial metadata shared by the pyramid.
parent_attrs = {
"zarr_conventions": create_zarr_conventions(
MultiscalesConventionMetadata(),
SpatialConventionMetadata(),
ProjConventionMetadata(),
),
"multiscales": {
"layout": layout,
"resampling_method": "average",
},
"title": f"{NAME} GeoZarr pyramid",
"source_file": str(SOURCE_ZARR),
"data_variables": spatial_data_vars,
}
parent_attrs.update(
create_spatial_attrs(
["y", "x"],
bbox=[float(value) for value in source.rio.bounds()],
registration="pixel",
)
)
parent_attrs.update(proj_attrs)
zarr.open_group(store=OUTPUT_ZARR, path=NAME, mode="a").attrs.update(parent_attrs)
parent_attrs
{'zarr_conventions': [{'uuid': 'd35379db-88df-4056-af3a-620245f8e347',
'schema_url': 'https://raw.githubusercontent.com/zarr-conventions/multiscales/refs/tags/v1/schema.json',
'spec_url': 'https://github.com/zarr-conventions/multiscales/blob/v1/README.md',
'name': 'multiscales',
'description': 'Multiscale layout of zarr datasets'},
{'uuid': '689b58e2-cf7b-45e0-9fff-9cfc0883d6b4',
'schema_url': 'https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json',
'spec_url': 'https://github.com/zarr-conventions/spatial/blob/v1/README.md',
'name': 'spatial:',
'description': 'Spatial coordinate information'},
{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f',
'schema_url': 'https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json',
'spec_url': 'https://github.com/zarr-experimental/geo-proj/blob/v1/README.md',
'name': 'proj:',
'description': 'Coordinate reference system information for geospatial data'}],
'multiscales': {'layout': [{'asset': '0',
'spatial:shape': [6435, 6120],
'spatial:transform': [1000.0, 0.0, -3060277.25, 0.0, -1000.0, 3217259.75]},
{'asset': '1',
'spatial:shape': [3217, 3060],
'spatial:transform': [2000.0, 0.0, -3060277.25, 0.0, -2000.0, 3217259.75],
'derived_from': '0',
'transform': {'scale': [2.0, 2.0], 'translation': [0.0, 0.0]}},
{'asset': '2',
'spatial:shape': [1608, 1530],
'spatial:transform': [4000.0, 0.0, -3060277.25, 0.0, -4000.0, 3217259.75],
'derived_from': '1',
'transform': {'scale': [2.0, 2.0], 'translation': [0.0, 0.0]}},
{'asset': '3',
'spatial:shape': [804, 765],
'spatial:transform': [8000.0, 0.0, -3060277.25, 0.0, -8000.0, 3217259.75],
'derived_from': '2',
'transform': {'scale': [2.0, 2.0], 'translation': [0.0, 0.0]}},
{'asset': '4',
'spatial:shape': [402, 382],
'spatial:transform': [16000.0,
0.0,
-3060277.25,
0.0,
-16000.0,
3217259.75],
'derived_from': '3',
'transform': {'scale': [2.0, 2.0], 'translation': [0.0, 0.0]}}],
'resampling_method': 'average'},
'title': 'basal_melt GeoZarr pyramid',
'source_file': '/home/krasen/tiling_test/downloaded_data/basal_melt_map_racmo_firn_air_corrected_cog.zarr',
'data_variables': ['basal_melt'],
'spatial:dimensions': ['y', 'x'],
'spatial:bbox': [-3060277.25, -3217740.25, 3059722.75, 3217259.75],
'spatial:transform_type': 'affine',
'spatial:registration': 'pixel',
'proj:code': 'EPSG:3031'}Validate using the geozarr_toolkit¶
from geozarr_toolkit import detect_conventions, validate_group
from zarr.storage import LocalStore
zarr_store = LocalStore(OUTPUT_ZARR)
root = zarr.open_group(zarr_store, mode="r")
pyramid = zarr.open_group(store=zarr_store, path=NAME, mode="r")
print(f"GeoZarr multiscale group: /{NAME}")
print(f"Detected conventions: {detect_conventions(dict(pyramid.attrs))}")
print("\nParent multiscale group validation:")
for convention, errors in validate_group(pyramid).items():
status = "PASS" if not errors else "FAIL"
print(f" [{status}] {convention}")
for error in errors:
print(f" {error}")
print("\nResolution group validation:")
for item in written_levels:
group = zarr.open_group(store=zarr_store, path=item["path"], mode="r")
print(f"\n/{item['path']}")
print(f" detected: {detect_conventions(dict(group.attrs))}")
for convention, errors in validate_group(group).items():
status = "PASS" if not errors else "FAIL"
print(f" [{status}] {convention}")
for error in errors:
print(f" {error}")GeoZarr multiscale group: /basal_melt
Detected conventions: ['spatial', 'proj', 'multiscales']
Parent multiscale group validation:
[PASS] spatial
[PASS] proj
[PASS] multiscales
[PASS] zarr_conventions
Resolution group validation:
/basal_melt/0
detected: ['spatial', 'proj']
[PASS] spatial
[PASS] proj
[PASS] zarr_conventions
/basal_melt/1
detected: ['spatial', 'proj']
[PASS] spatial
[PASS] proj
[PASS] zarr_conventions
/basal_melt/2
detected: ['spatial', 'proj']
[PASS] spatial
[PASS] proj
[PASS] zarr_conventions
/basal_melt/3
detected: ['spatial', 'proj']
[PASS] spatial
[PASS] proj
[PASS] zarr_conventions
/basal_melt/4
detected: ['spatial', 'proj']
[PASS] spatial
[PASS] proj
[PASS] zarr_conventions
display(xr.open_datatree(OUTPUT_ZARR))/tmp/ipykernel_77256/1472056715.py:1: RuntimeWarning: Failed to open Zarr store with consolidated metadata, but successfully read with non-consolidated metadata. This is typically much slower for opening a dataset. To silence this warning, consider:
1. Consolidating metadata in this existing store with zarr.consolidate_metadata().
2. Explicitly setting consolidated=False, to avoid trying to read consolidate metadata, or
3. Explicitly setting consolidated=True, to raise an error in this case instead of falling back to try reading non-consolidated metadata.
display(xr.open_datatree(OUTPUT_ZARR))
Visualising GeoZarr data¶
To actually visualise the data you need a viewer that reads geozarr datasets such as open payers 10.8 or above. And you need to serve the files through http or s3. So, youll need:
1. Upload the data to an s3 server, or simulate an http server locally using python. For example run a script like this in the directory with the geozarr file:¶
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
class CORSRequestHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Range, Content-Type")
self.send_header("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.end_headers()
if __name__ == "__main__":
server = ThreadingHTTPServer(("localhost", 8000), CORSRequestHandler)
print("Serving with CORS at http://localhost:8000")
server.serve_forever()2. Create an html file that creates the open layers Geozar layer viewer that points to the file. For example, something that contains:¶
const source = new GeoZarr({
url: GEOZARR_URL,
bands: [{name: BAND_NAME, group: DATA_GROUP}],
projection,
transition: 0,
});
const geozarrLayer = new WebGLTileLayer({
source,
opacity: OPACITY,
style: basalMeltStyle(),
});
const graticule = new Graticule({
strokeStyle: new Stroke({color: "rgba(31, 45, 56, 0.24)", width: 1}),
showLabels: true,
wrapX: false,
});
const map = new Map({
target: "map",
layers: [createAntarcticBasemap(), geozarrLayer, graticule],
view: getView(
source,
withLowerResolutions(1),
withHigherResolutions(2),
withExtentCenter(),
withZoom(1.71),
),
});