Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a84ef68
init cog layer
kylebarron Jan 22, 2026
2521346
Merge branch 'main' into kyle/cog-layer-2026
kylebarron Jan 22, 2026
d18b9ba
define invoke
kylebarron Jan 22, 2026
613e00e
improve invoke typing
kylebarron Jan 22, 2026
8ec4c1e
define minimal cog layer
kylebarron Jan 22, 2026
6cb174f
avoid race condition in invoke
kylebarron Jan 22, 2026
4594683
init cog layer
kylebarron Jan 22, 2026
4737a52
consistent msg_kind
kylebarron Jan 22, 2026
87c59f9
proof of concept for async data fetching in callback
kylebarron Jan 22, 2026
67e2cce
scratch work
kylebarron Jan 23, 2026
bef901a
Move cog layer to experimental for now
kylebarron Jan 28, 2026
05b143e
Merge branch 'main' into kyle/cog-layer-2026
kylebarron Jan 28, 2026
066fa27
add async-geotiff dependency
kylebarron Jan 28, 2026
9e87122
COGLayer.from_async_geotiff
kylebarron Jan 28, 2026
3272bad
Merge branch 'main' into kyle/cog-layer-2026
kylebarron Feb 3, 2026
8bca5e2
cleanup
kylebarron Feb 3, 2026
53b9e59
Rename files to raster
kylebarron Feb 4, 2026
5de828b
Rename to RasterLayer
kylebarron Feb 4, 2026
8e5bf7a
cleanup
kylebarron Feb 4, 2026
4c371d9
remove coglayer import from experiemental
kylebarron Feb 4, 2026
bdb89e3
rename invoke to dispatch
kylebarron Feb 4, 2026
ed1a63a
Send model id in dispatch
kylebarron Feb 4, 2026
b26cdbf
Define initial raster models
kylebarron Feb 4, 2026
442c057
Working example with osm
kylebarron Feb 4, 2026
0411fb7
remove from base layer
kylebarron Feb 4, 2026
658a6a0
remove dispatch file
kylebarron Feb 4, 2026
7383411
diff
kylebarron Feb 4, 2026
c444b47
lint
kylebarron Feb 4, 2026
db175d7
move import
kylebarron Feb 4, 2026
f99cbd9
Merge branch 'main' into kyle/cog-layer-2026
kylebarron Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lonboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
PathLayer,
PointCloudLayer,
PolygonLayer,
RasterLayer,
S2Layer,
ScatterplotLayer,
SolidPolygonLayer,
Expand All @@ -39,6 +40,7 @@
"PathLayer",
"PointCloudLayer",
"PolygonLayer",
"RasterLayer",
"S2Layer",
"ScatterplotLayer",
"SolidPolygonLayer",
Expand Down
2 changes: 2 additions & 0 deletions lonboard/layer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ._path import PathLayer
from ._point_cloud import PointCloudLayer
from ._polygon import PolygonLayer, SolidPolygonLayer
from ._raster import RasterLayer
from ._s2 import S2Layer
from ._scatterplot import ScatterplotLayer
from ._trips import TripsLayer
Expand All @@ -36,6 +37,7 @@
"PathLayer",
"PointCloudLayer",
"PolygonLayer",
"RasterLayer",
"S2Layer",
"ScatterplotLayer",
"SolidPolygonLayer",
Expand Down
206 changes: 206 additions & 0 deletions lonboard/layer/_raster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# ruff: noqa: SLF001, ERA001

from __future__ import annotations

import asyncio
import traceback
from typing import TYPE_CHECKING, Any, Protocol

import numpy as np
import traitlets.traitlets as t

from lonboard.layer._base import BaseLayer

if TYPE_CHECKING:
from async_geotiff import GeoTIFF, Tile
from numpy.typing import NDArray


# This must be kept in sync with src/model/layer/raster.ts
MSG_KIND = "raster-get-tile-data"


class RenderTile(Protocol):
"""Protocol for user-defined render function."""

def __call__(self, tile: Tile) -> bytes: # EncodedImage:
"""Render a tile.

Args:
tile: A dictionary with tile information.

Returns:
An RGBA numpy array representing the rendered tile.

"""
...


# TODO: make this a generic Protocol that returns a Tile of a specific type
class FetchTile(Protocol):
"""Protocol for user-defined fetch_tile function."""

async def __call__(self, x: int, y: int, z: int) -> Tile:
"""Fetch a tile asynchronously.

Args:
x: The x coordinate of the tile.
y: The y coordinate of the tile.
z: The zoom level of the tile.

Returns:
A Tile object representing the fetched tile.

"""
...


def handle_anywidget_dispatch(
widget: RasterLayer,
msg: str | list | dict,
buffers: list[bytes],
) -> None:
if not isinstance(msg, dict) or msg.get("kind") != MSG_KIND:
return

# Schedule the async handler on the event loop
task = asyncio.create_task(_handle_tile_request(widget, msg, buffers))
widget._pending_tasks.add(task)
task.add_done_callback(widget._pending_tasks.discard)


# https://github.com/rasterio/rasterio/blob/2d79e5f3a00e919ecaa9573adba34a78274ce48c/rasterio/plot.py#L227-L241
def reshape_as_image(arr: NDArray) -> NDArray:
"""Return the source array reshaped image axis order.

This order is expected by image processing and visualization software (matplotlib,
scikit-image, etc) by swapping the axes order from
```
(bands, rows, columns)
```
to
```
(rows, columns, bands)
```
"""
# swap the axes order from (bands, rows, columns) to (rows, columns, bands)
return np.transpose(arr, [1, 2, 0])
# Or, if we use masked arrays in the future:
# np.ma.transpose(arr, [1, 2, 0])


async def _handle_tile_request(
widget: RasterLayer,
msg: dict,
buffers: list[bytes],
) -> None:
"""Async handler for tile data requests from the frontend."""
output = widget._error_output

try:
if widget.debug:
output.append_stdout(f"Received tile request: {msg}")

content = msg["msg"]
tile = content["tile"]
index = tile["index"]
x = index["x"]
y = index["y"]
z = index["z"]

if widget.debug:
output.append_stdout(f"Fetching tile at x={x}, y={y}, z={z}\n")

tile = await widget.fetch_tile(x=x, y=y, z=z)
# TODO: put rendering in thread pool?
rendered = widget.render(tile)
buffers = [rendered]

widget.send(
{
"id": msg["id"],
"kind": f"{MSG_KIND}-response",
"response": {}, # {"format": rendered.format},
},
buffers,
)
except Exception: # noqa: BLE001
error_msg = traceback.format_exc()
output.append_stderr(f"Error handling tile request: {error_msg}\n")

widget.send(
{
"id": msg["id"],
"kind": f"{MSG_KIND}-response",
"response": {"error": error_msg},
},
)


class RasterLayer(BaseLayer):
"""The RasterLayer renders raster imagery.

This layer expects input such as Cloud-Optimized GeoTIFFs (COGs) that can be
efficiently accessed by internal tiles.
"""

# Prevent garbage collection of async tasks before they complete.
# Tasks are removed automatically via add_done_callback when they finish.
#
# TODO: ensure JS AbortSignal propagates to cancel these tasks
_pending_tasks: set[asyncio.Task[None]]

fetch_tile: FetchTile
render: RenderTile

def __init__(
self,
# tms: TileMatrixSet,
*,
fetch_tile: FetchTile,
render: RenderTile,
debug: bool = True,
**kwargs: Any,
) -> None:
self._pending_tasks = set()
self.fetch_tile = fetch_tile
self.render = render
self.debug = debug
self.on_msg(handle_anywidget_dispatch)
super().__init__(**kwargs) # type: ignore

@classmethod
def from_async_geotiff(
cls,
geotiff: GeoTIFF,
/,
*,
render: RenderTile,
**kwargs: Any,
# TODO: can this return type specify RasterLayer[T] where T is the type of the
# GeoTIFF?
# Ideally, in a typed context, render should receive the correct tile type
) -> RasterLayer:
"""Create a RasterLayer from a GeoTIFF instance from async-geotiff."""
from async_geotiff.tms import generate_tms

tms = generate_tms(geotiff)

# This should create a closure for fetching tiles from the geotiff. So the user
# shouldn't have to manually provide a fetch_tile function.

async def geotiff_fetch_tile(
x: int,
y: int,
z: int, # noqa: ARG001
) -> Tile:
"""Fetch a specific tile from the GeoTIFF."""
# TODO: select correct IFD
return await geotiff.fetch_tile(x, y)

return cls(tms=tms, fetch_tile=geotiff_fetch_tile, render=render, **kwargs)

_layer_type = t.Unicode("raster").tag(sync=True)

# TODO: Restore TMS generic tile traversal. For now, for simplicity, we're only rendering standard web mercator tiles.
# _tms = Dict().tag(sync=True)
5 changes: 5 additions & 0 deletions lonboard/raster/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Raster support for Lonboard."""

from ._models import EncodedImage, ImageData

__all__ = ["EncodedImage", "ImageData"]
25 changes: 25 additions & 0 deletions lonboard/raster/_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
from numpy.typing import NDArray


@dataclass
class EncodedImage:
"""An encoded image in a specific format."""

data: bytes
format: Literal["PNG", "JPEG", "WebP"]


@dataclass
class ImageData:
"""Raw image data as a numpy array."""

array: NDArray
width: int
height: int
bands: int
Loading