Skip to content

Commit

Permalink
Add PointCloudLayer (#396)
Browse files Browse the repository at this point in the history
For #322
  • Loading branch information
kylebarron authored Mar 1, 2024
1 parent 27e3ef9 commit bdbc2a7
Show file tree
Hide file tree
Showing 10 changed files with 198 additions and 36 deletions.
8 changes: 1 addition & 7 deletions docs/api/colormap.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
# lonboard.colormap

::: lonboard.colormap.apply_continuous_cmap

::: lonboard.colormap.RGBColor

::: lonboard.colormap.DiscreteColormap

::: lonboard.colormap.apply_categorical_cmap
::: lonboard.colormap
5 changes: 5 additions & 0 deletions docs/api/layers/point-cloud-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# PointCloudLayer

::: lonboard.PointCloudLayer
options:
inherited_members: true
1 change: 1 addition & 0 deletions lonboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
BitmapTileLayer,
HeatmapLayer,
PathLayer,
PointCloudLayer,
ScatterplotLayer,
SolidPolygonLayer,
)
Expand Down
94 changes: 93 additions & 1 deletion lonboard/_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@
from lonboard._serialization import infer_rows_per_chunk
from lonboard._utils import auto_downcast as _auto_downcast
from lonboard._utils import get_geometry_column_index, remove_extension_kwargs
from lonboard.traits import ColorAccessor, FloatAccessor, PyarrowTableTrait
from lonboard.traits import (
ColorAccessor,
FloatAccessor,
NormalAccessor,
PyarrowTableTrait,
)

if TYPE_CHECKING:
if sys.version_info >= (3, 11):
Expand Down Expand Up @@ -927,6 +932,93 @@ class PathLayer(BaseArrowLayer):
"""


class PointCloudLayer(BaseArrowLayer):
"""
The `PointCloudLayer` renders a point cloud with 3D positions, normals and colors.
The `PointCloudLayer` can be more efficient at rendering large quantities of points
than the [`ScatterplotLayer`][lonboard.ScatterplotLayer], but has fewer rendering
options. In particular, you can have only one point size across all points in your
data.
**Example:**
From GeoPandas:
```py
import geopandas as gpd
from lonboard import Map, PointCloudLayer
# A GeoDataFrame with Point geometries
gdf = gpd.GeoDataFrame()
layer = PointCloudLayer.from_geopandas(
gdf,
get_color=[255, 0, 0],
point_size=2,
)
m = Map(layer)
```
"""

_layer_type = traitlets.Unicode("point-cloud").tag(sync=True)

table = PyarrowTableTrait(
allowed_geometry_types={EXTENSION_NAME.POINT}, allowed_dimensions={3}
)
"""A GeoArrow table with a Point column.
This is the fastest way to plot data from an existing GeoArrow source, such as
[geoarrow-rust](https://geoarrow.github.io/geoarrow-rs/python/latest) or
[geoarrow-pyarrow](https://geoarrow.github.io/geoarrow-python/main/index.html).
If you have a GeoPandas `GeoDataFrame`, use
[`from_geopandas`][lonboard.PointCloudLayer.from_geopandas] instead.
"""

size_units = traitlets.Unicode(None, allow_none=True).tag(sync=True)
"""
The units of the line width, one of `'meters'`, `'common'`, and `'pixels'`. See
[unit
system](https://deck.gl/docs/developer-guide/coordinate-systems#supported-units).
- Type: `str`, optional
- Default: `'pixels'`
"""

point_size = traitlets.Float(None, allow_none=True, min=0).tag(sync=True)
"""
Global radius of all points, in units specified by `size_units`.
- Type: `float`, optional
- Default: `10`
"""

get_color = ColorAccessor(None, allow_none=True)
"""
The color of each path in the format of `[r, g, b, [a]]`. Each channel is a number
between 0-255 and `a` is 255 if not supplied.
- Type: [ColorAccessor][lonboard.traits.ColorAccessor], optional
- If a single `list` or `tuple` is provided, it is used as the color for all
paths.
- If a numpy or pyarrow array is provided, each value in the array will be used
as the color for the path at the same row index.
- Default: `[0, 0, 0, 255]`.
"""

get_normal = NormalAccessor(None, allow_none=True)
"""
The normal of each object, in `[nx, ny, nz]`.
- Type: [NormalAccessor][lonboard.traits.NormalAccessor], optional
- If a single `list` or `tuple` is provided, it is used as the normal for all
points.
- If a numpy or pyarrow array is provided, each value in the array will be used
as the normal for the point at the same row index.
- Default: `1`.
"""


class SolidPolygonLayer(BaseArrowLayer):
"""
The `SolidPolygonLayer` renders filled and/or extruded polygons.
Expand Down
55 changes: 33 additions & 22 deletions lonboard/traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from __future__ import annotations

import warnings
from typing import Any, List, Set, Tuple, Union, cast
from typing import Any, List, Optional, Set, Tuple, Union, cast

import matplotlib as mpl
import numpy as np
Expand Down Expand Up @@ -131,12 +131,14 @@ def __init__(
self: TraitType,
*args,
allowed_geometry_types: Set[bytes] | None = None,
allowed_dimensions: Optional[Set[int]] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self.tag(
sync=True,
allowed_geometry_types=allowed_geometry_types,
allowed_dimensions=allowed_dimensions,
**TABLE_SERIALIZATION,
)

Expand All @@ -145,26 +147,39 @@ def validate(self, obj: Self, value: Any):
self.error(obj, value)

allowed_geometry_types = self.metadata.get("allowed_geometry_types")
# No restriction on the allowed geometry types in this table
if not allowed_geometry_types:
return value
allowed_geometry_types = cast(Optional[Set[bytes]], allowed_geometry_types)

allowed_dimensions = self.metadata.get("allowed_dimensions")
allowed_dimensions = cast(Optional[Set[int]], allowed_dimensions)

allowed_geometry_types = cast(Set[bytes], allowed_geometry_types)
geom_col_idx = get_geometry_column_index(value.schema)
geometry_extension_type = value.schema.field(geom_col_idx).metadata.get(
b"ARROW:extension:name"
)

if (
allowed_geometry_types
and geometry_extension_type not in allowed_geometry_types
):
allowed_types_str = ", ".join(map(str, allowed_geometry_types))
msg = (
f"Expected one of {allowed_types_str} geometry types, "
f"got {geometry_extension_type}."
# No restriction on the allowed geometry types in this table
if allowed_geometry_types:
geometry_extension_type = value.schema.field(geom_col_idx).metadata.get(
b"ARROW:extension:name"
)
self.error(obj, value, info=msg)

if (
allowed_geometry_types
and geometry_extension_type not in allowed_geometry_types
):
allowed_types_str = ", ".join(map(str, allowed_geometry_types))
msg = (
f"Expected one of {allowed_types_str} geometry types, "
f"got {geometry_extension_type}."
)
self.error(obj, value, info=msg)

if allowed_dimensions:
typ = value.column(geom_col_idx).type
while isinstance(typ, pa.ListType):
typ = typ.value_type

assert isinstance(typ, pa.FixedSizeListType)
if typ.list_size not in allowed_dimensions:
msg = " or ".join(map(str, list(allowed_dimensions)))
self.error(obj, value, info=f"{msg}-dimensional points")

return value

Expand All @@ -189,7 +204,7 @@ class ColorAccessor(FixedErrorTraitType):
PyCapsule
Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html).
You can use helpers in the `lonboard.colormap` module (i.e.
You can use helpers in the [`lonboard.colormap`][lonboard.colormap] module (i.e.
[`apply_continuous_cmap`][lonboard.colormap.apply_continuous_cmap]) to simplify
constructing numpy arrays for color values.
"""
Expand Down Expand Up @@ -745,10 +760,6 @@ def __init__(
def validate(
self, obj, value
) -> Union[Tuple[int, ...], List[int], pa.ChunkedArray, pa.FixedSizeListArray]:
"""
Values in acceptable types must be contiguous
(the same length for all values)
"""
if isinstance(value, (tuple, list)):
if len(value) != 3:
self.error(
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ nav:
- api/layers/bitmap-tile-layer.md
- api/layers/heatmap-layer.md
- api/layers/path-layer.md
- api/layers/point-cloud-layer.md
- api/layers/scatterplot-layer.md
- api/layers/solid-polygon-layer.md
- api/layers/base-layer.md
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"@deck.gl/extensions": "^8.9.34",
"@deck.gl/layers": "^8.9.34",
"@deck.gl/react": "^8.9.34",
"@geoarrow/deck.gl-layers": "^0.3.0-beta.14",
"@geoarrow/deck.gl-layers": "^0.3.0-beta.15",
"apache-arrow": "^15.0.0",
"maplibre-gl": "^3.6.2",
"parquet-wasm": "0.5.0",
Expand Down
45 changes: 45 additions & 0 deletions src/model/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
GeoArrowHeatmapLayerProps,
GeoArrowPathLayer,
GeoArrowPathLayerProps,
GeoArrowPointCloudLayer,
GeoArrowPointCloudLayerProps,
GeoArrowScatterplotLayer,
GeoArrowScatterplotLayerProps,
GeoArrowSolidPolygonLayer,
Expand Down Expand Up @@ -491,6 +493,45 @@ export class PathModel extends BaseArrowLayerModel {
});
}
}

export class PointCloudModel extends BaseArrowLayerModel {
static layerType = "point-cloud";

protected sizeUnits: GeoArrowPointCloudLayerProps["sizeUnits"] | null;
protected pointSize: GeoArrowPointCloudLayerProps["pointSize"] | null;
// protected material: GeoArrowPointCloudLayerProps["material"] | null;

protected getColor: GeoArrowPointCloudLayerProps["getColor"] | null;
protected getNormal: GeoArrowPointCloudLayerProps["getNormal"] | null;

constructor(model: WidgetModel, updateStateCallback: () => void) {
super(model, updateStateCallback);

this.initRegularAttribute("size_units", "sizeUnits");
this.initRegularAttribute("point_size", "pointSize");

this.initVectorizedAccessor("get_color", "getColor");
this.initVectorizedAccessor("get_normal", "getNormal");
}

layerProps(): Omit<GeoArrowPointCloudLayerProps, "id"> {
return {
data: this.table,
...(isDefined(this.sizeUnits) && { sizeUnits: this.sizeUnits }),
...(isDefined(this.pointSize) && { pointSize: this.pointSize }),
...(isDefined(this.getColor) && { getColor: this.getColor }),
...(isDefined(this.getNormal) && { getNormal: this.getNormal }),
};
}

render(): GeoArrowPointCloudLayer {
return new GeoArrowPointCloudLayer({
...this.baseLayerProps(),
...this.layerProps(),
});
}
}

export class ScatterplotModel extends BaseArrowLayerModel {
static layerType = "scatterplot";

Expand Down Expand Up @@ -801,6 +842,10 @@ export async function initializeLayer(
layerModel = new PathModel(model, updateStateCallback);
break;

case PointCloudModel.layerType:
layerModel = new PointCloudModel(model, updateStateCallback);
break;

case ScatterplotModel.layerType:
layerModel = new ScatterplotModel(model, updateStateCallback);
break;
Expand Down
15 changes: 14 additions & 1 deletion tests/test_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
from pyogrio.raw import read_arrow
from traitlets import TraitError

from lonboard import BitmapLayer, Map, ScatterplotLayer, SolidPolygonLayer, viz
from lonboard import (
BitmapLayer,
Map,
PointCloudLayer,
ScatterplotLayer,
SolidPolygonLayer,
viz,
)
from lonboard._geoarrow.geopandas_interop import geopandas_to_geoarrow
from lonboard.layer_extension import DataFilterExtension

Expand Down Expand Up @@ -122,3 +129,9 @@ def test_bitmap_layer():
bounds=[-122.5190, 37.7045, -122.355, 37.829],
)
_m = Map(layer)


def test_point_cloud_layer():
points = shapely.points([0, 1], [2, 3], [4, 5])
gdf = gpd.GeoDataFrame(geometry=points)
_layer = PointCloudLayer.from_geopandas(gdf)

0 comments on commit bdbc2a7

Please sign in to comment.