# /// script
# requires-python = ">=3.12"
# dependencies = [
# "async-geotiff>=0.4",
# "lonboard>=0.15.0",
# "numpy>=2",
# "obstore>=0.9.2",
# "pillow>=12.1.1",
# "sidecar>=0.8.1",
# ]
# ///
Visual Cloud-Optimized GeoTIFFs in Lonboard¶
Lonboard now has the ability to render arbitrary Cloud-Optimized GeoTIFF images. This example notebook will go through the process of visualizing a Sentinel-2 True Color image in Lonboard.
Imports¶
First our imports. We'll use Obstore for accessing S3 and Async-GeoTIFF for efficient reading of GeoTIFF files. We use pillow (imported as PIL) for encoding image tiles to PNG.
import io
import numpy as np
from async_geotiff import GeoTIFF
from async_geotiff import Tile as GeoTIFFTile
from async_geotiff.utils import reshape_as_image
from obstore.store import S3Store
from PIL import Image
from sidecar import Sidecar
from lonboard import Map, RasterLayer
from lonboard.raster import EncodedImage
# Obstore store
store = S3Store("sentinel-cogs", region="us-west-2", skip_signature=True)
# Open our GeoTIFF instance
cog_path = "sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif"
geotiff = await GeoTIFF.open(cog_path, store=store)
Create Render Callback¶
Lonboard's COG support works by asynchronously fetching COG image tiles through Python and then transferring the tile to JavaScript for visualization.
In this initial version of our support, we require the user to create a "render callback" function that transforms the loaded COG tile to a PNG-formatted RGB image.
The benefit of this approach is that you can use any Python code to perform the rendering characteristics you desire.
def render_rgb_tile(tile: GeoTIFFTile) -> EncodedImage:
"""Convert the array data from the GeoTIFF to an RGB PNG."""
# Reshape from (bands, height, width) to (height, width, bands)
masked_array = reshape_as_image(tile.array.as_masked())
# Handle nodata regions, making those parts of the image transparent
rgb = masked_array.data
mask = masked_array.mask
if mask.ndim == 0:
# All pixels valid
alpha = np.full(rgb.shape[:2], 255, dtype=np.uint8)
elif mask.ndim == 2:
alpha = (~mask).astype(np.uint8) * 255
else:
alpha = (~mask.any(axis=-1)).astype(np.uint8) * 255
# Concatenate alpha axis to become (height, width, 4)
rgba = np.concatenate([rgb, alpha[..., None]], axis=-1)
# Encode as PNG
img = Image.fromarray(rgba)
buf = io.BytesIO()
img.save(buf, format="PNG")
return EncodedImage(data=buf.getvalue(), media_type="image/png")
Now, pass the geotiff instance and our "render callback" into the from_geotiff constructor:
layer = RasterLayer.from_geotiff(geotiff, render_tile=render_rgb_tile)
Now we can create a map and display it just like any other layer
# In JupyterLab, split the screen to render the map on the right
sidecar = Sidecar(anchor="split-right")
# Create the map
m = Map(layer, height=800)
# Render the map in the split screen
with sidecar:
display(m)
Debugging the render callback¶
We can debug our render_rgb_tile function by passing in a tile we fetch ourselves:
# Fetch a tile in the middle of the raster
num_tiles_x, num_tiles_y = geotiff.tile_count
midpoint_tile_x = num_tiles_x // 2
midpoint_tile_y = num_tiles_y // 2
tile = await geotiff.fetch_tile(midpoint_tile_x, midpoint_tile_y)
render_rgb_tile(tile)