Skip to content

Commit a2924a2

Browse files
authored
feat: H3 layer (#917)
Edit 2: The below performance issues still stand, but I want to merge this, because it might be easier to implement the A5 layer on top of this. So I'll remove the H3 layer and trait from the public API and then merge this. ---- cc @felixpalmer <img width="427" height="343" alt="image" src="https://github.com/user-attachments/assets/fb7a9ca1-0fa3-47db-a5b7-38e8bcd64545" /> Works in principle with latest deck.gl-layers release. ### Change list - Adds `H3HexagonLayer` as a core layer type. - Implements h3 index validation in pure numpy, so that users can have data validated before it goes to JS (where it's hard to surface any data errors) - Implement `str_to_h3` vectorized function that converts str input into a uint64 h3 array. - Implement `H3Accessor` traitlet that takes in either an array of str or int, validates them, and then packs array as uint64 type to send to the frontend. ### todo - [ ] update layer docs - [ ] Update website docs for this layer - [ ] Implement layer bounds computation. For now, if the h3 binding exists in the environment, pick a random sample of ~10,000 input rows (with a stable seed) and compute viewport info based on that sample. ---- Edit: Sadly, this is extremely, unacceptably slow. Using as an example the [kontur population dataset, 22km resolution](https://data.humdata.org/dataset/kontur-population-dataset-22km), it takes _15 seconds_ to render on the JS side https://github.com/user-attachments/assets/eb6b5aeb-895e-4132-8353-7d4c59a46252 because you see the `readParquet` `console.log` statements immediately, I think _all_ of that is overhead in the [deck.gl](http://deck.gl/) code. <img width="3844" height="2358" alt="image" src="https://github.com/user-attachments/assets/d38d5f09-5da8-45fc-b618-b8d66b514f9e" /> the main task took 16.25 seconds and 85% of that (I think that's what the first three "self time" numbers mean?) was just allocations and GC...? the [implementation on the geoarrow/deck.gl-layers side](https://github.com/geoarrow/deck.gl-layers/blob/df9575179a583ac0aac568813f8a1f4e00a9092f/packages/deck.gl-layers/src/layers/h3-hexagon-layer.ts#L140-L148) seems pretty straightforward and simple So idk if I'm doing something wrong (very possible) or if that's just the performance of sending 70k h3 cells to the `H3HexagonLayer`? I don't think we can merge this as-is with this performance. Users expect to be able to render hundreds of thousands of polygons and 15s rendering with 70k doesn't hold to the standards of this library. I'd rather re-implement this to just convert H3 hexagons to GeoArrow polygons on the Python side. Code: ```ipynb { "cells": [ { "cell_type": "code", "execution_count": null, "id": "6039544a-9028-4b5f-915b-2b92b2e3df13", "metadata": {}, "outputs": [], "source": [ "import lonboard" ] }, { "cell_type": "code", "execution_count": null, "id": "b93514c8-773f-4e85-9587-8514f9f08283", "metadata": {}, "outputs": [], "source": [ "from lonboard import H3HexagonLayer, Map" ] }, { "cell_type": "code", "execution_count": null, "id": "26e6183a-2ec2-4288-9083-8a63906bad29", "metadata": {}, "outputs": [], "source": [ "from palettable.colorbrewer.diverging import BrBG_10" ] }, { "cell_type": "code", "execution_count": null, "id": "1f211696-0637-46ff-adec-22ffb7c389c8", "metadata": {}, "outputs": [], "source": [ "from lonboard.colormap import apply_continuous_cmap\n", "from matplotlib.colors import LogNorm" ] }, { "cell_type": "code", "execution_count": null, "id": "24b04f6f-f133-4107-8795-3eefe9186fae", "metadata": {}, "outputs": [], "source": [ "path = \"/Users/kyle/Downloads/kontur_population_20231101_r4.gpkg\"" ] }, { "cell_type": "code", "execution_count": null, "id": "3e1aa79a-3975-4059-8ee9-d18149074a73", "metadata": {}, "outputs": [], "source": [ "import geopandas as gpd" ] }, { "cell_type": "code", "execution_count": null, "id": "e7051bd8-3060-40eb-959a-554da532df69", "metadata": {}, "outputs": [], "source": [ "gdf = gpd.read_file(path)" ] }, { "cell_type": "code", "execution_count": null, "id": "5b08f0a6-c730-471f-87d9-75e5cca95e6c", "metadata": {}, "outputs": [], "source": [ "df = gdf[[\"h3\", \"population\"]]" ] }, { "cell_type": "code", "execution_count": null, "id": "8cc4cb73-02bc-4c95-8497-e563bc1e9a90", "metadata": {}, "outputs": [], "source": [ "layer = H3HexagonLayer.from_pandas(df, get_hexagon=df[\"h3\"])" ] }, { "cell_type": "code", "execution_count": null, "id": "de85090c-828f-4a80-8a65-fa2f45e9eeb1", "metadata": {}, "outputs": [], "source": [ "m = Map(layer)" ] }, { "cell_type": "code", "execution_count": null, "id": "bbb5f82b-0318-405a-86a5-216b6863ba82", "metadata": { "scrolled": true }, "outputs": [], "source": [ "m" ] }, { "cell_type": "code", "execution_count": null, "id": "0295d0fe-df22-4a8a-8a93-9e824630e7eb", "metadata": {}, "outputs": [], "source": [] }, { "cell_type": "code", "execution_count": null, "id": "de39f57a-c463-48e3-b505-058049de8588", "metadata": {}, "outputs": [], "source": [ "pop = df[\"population\"]\n", "min_bound = pop.min()\n", "max_bound = pop.max()\n", "normalizer = LogNorm(min_bound, max_bound, clip=True)\n", "normalized = normalizer(pop)" ] }, { "cell_type": "code", "execution_count": null, "id": "736a7c94-e8bc-48eb-a1d0-aca2af961a3a", "metadata": {}, "outputs": [], "source": [ "colors = apply_continuous_cmap(normalized, BrBG_10, alpha=0.7)" ] }, { "cell_type": "code", "execution_count": null, "id": "3eda2ed2-115d-4d8f-9dce-9fe062b3e520", "metadata": {}, "outputs": [], "source": [ "layer.get_fill_color = colors" ] }, { "cell_type": "code", "execution_count": null, "id": "d915d64b-5813-4a44-92d5-65b5ff00d06d", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "lonboard", "language": "python", "name": "lonboard" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.7" } }, "nbformat": 4, "nbformat_minor": 5 } ``` Closes #302, for #885
1 parent 0e254be commit a2924a2

File tree

21 files changed

+1349
-18
lines changed

21 files changed

+1349
-18
lines changed

lonboard/_geoarrow/ops/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Geometry operations on GeoArrow memory."""
22

3-
from .bbox import total_bounds
4-
from .centroid import weighted_centroid
3+
from .bbox import Bbox, total_bounds
4+
from .centroid import WeightedCentroid, weighted_centroid
55
from .reproject import reproject_column, reproject_table

lonboard/_h3/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from ._h3_to_str import h3_to_str
2+
from ._str_to_h3 import str_to_h3
3+
from ._validate_h3_cell import validate_h3_indices

lonboard/_h3/_h3_to_str.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import numpy as np
6+
7+
if TYPE_CHECKING:
8+
from numpy.typing import NDArray
9+
10+
11+
def h3_to_str(h3_indices: NDArray[np.uint64]) -> NDArray[np.str_]:
12+
"""Convert an array of H3 indices (uint64) to their hexadecimal string representations.
13+
14+
Returns a numpy array of type S15 (fixed-length ASCII strings of length 15).
15+
"""
16+
# Ensure input is a numpy array of uint64
17+
hex_chars = np.empty((h3_indices.size, 15), dtype="S1")
18+
19+
# Prepare hex digits lookup
20+
hex_digits = np.array(list("0123456789ABCDEF"), dtype="S1")
21+
22+
# Fill each digit
23+
for i in range(15):
24+
shift = (15 - 1 - i) * 4
25+
hex_chars[:, i] = hex_digits[(h3_indices >> shift) & 0xF]
26+
27+
return hex_chars.view("<S15")[:, 0]

lonboard/_h3/_str_to_h3.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import numpy as np
6+
7+
if TYPE_CHECKING:
8+
from numpy.typing import NDArray
9+
10+
11+
def str_to_h3(hex_arr: NDArray[np.str_]) -> NDArray[np.uint64]:
12+
"""Convert an array of hexadecimal strings to H3 indices (uint64).
13+
14+
This is a pure NumPy vectorized implementation that processes hex strings
15+
character by character without Python loops.
16+
17+
Args:
18+
hex_arr: Array of hexadecimal strings (15 characters each)
19+
20+
Returns:
21+
Array of H3 indices as uint64 integers
22+
23+
"""
24+
if len(hex_arr) == 0:
25+
return np.array([], dtype=np.uint64)
26+
27+
# Convert to S15 fixed-width byte strings if needed
28+
# View as 2D array of individual bytes (shape: n x 15)
29+
hex_bytes = np.asarray(hex_arr, dtype="S15").view("S1").reshape(len(hex_arr), -1)
30+
31+
# Convert ASCII bytes to numeric values
32+
# Get the ASCII code of each character
33+
ascii_vals = hex_bytes.view(np.uint8)
34+
35+
# Convert hex ASCII to numeric values (0-15)
36+
# '0'-'9' (48-57) -> 0-9
37+
# 'A'-'F' (65-70) -> 10-15
38+
# 'a'-'f' (97-102) -> 10-15
39+
vals = ascii_vals - ord("0") # Shift '0' to 0
40+
vals = np.where(vals > 9, vals - 7, vals) # 'A'=65-48=17 -> 17-7=10
41+
vals = np.where(vals > 15, vals - 32, vals) # 'a'=97-48=49 -> 49-7=42 -> 42-32=10
42+
43+
# Create powers of 16 for each position (most significant first)
44+
# For 15 hex digits: [16^14, 16^13, ..., 16^1, 16^0]
45+
n_digits = hex_bytes.shape[1]
46+
powers = 16 ** np.arange(n_digits - 1, -1, -1, dtype=np.uint64)
47+
48+
# Compute dot product to get final uint64 values
49+
# Each row: sum(digit_i * 16^(n-1-i))
50+
return np.dot(vals.astype(np.uint64), powers)

lonboard/_h3/_validate_h3_cell.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Implement h3 cell validation in pure numpy.
2+
3+
It's hard to surface errors from deck.gl back to Python, so it's a bad user experience
4+
if the JS console errors and silently nothing renders. But also I don't want to depend
5+
on the h3 library for this because the h3 library isn't vectorized (arghhhh!) and I
6+
don't want to require the dependency.
7+
8+
So instead, I spend my time porting code into Numpy 😄.
9+
10+
Ported from Rust code in h3o:
11+
12+
https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L1897-L1962
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import TYPE_CHECKING
18+
19+
import numpy as np
20+
21+
if TYPE_CHECKING:
22+
from numpy.typing import NDArray
23+
24+
__all__ = ["validate_h3_indices"]
25+
26+
MODE_OFFSET = 59
27+
"""Offset (in bits) of the mode in an H3 index."""
28+
29+
MODE_MASK = 0b1111 << MODE_OFFSET
30+
31+
EDGE_OFFSET = 56
32+
"""Offset (in bits) of the cell edge in an H3 index."""
33+
34+
EDGE_MASK = 0b111 << EDGE_OFFSET
35+
36+
VERTEX_OFFSET = 56
37+
"""Offset (in bits) of the cell vertex in an H3 index."""
38+
39+
VERTEX_MASK = 0b111 << VERTEX_OFFSET
40+
41+
DIRECTIONS_MASK = 0x0000_1FFF_FFFF_FFFF
42+
"""Bitmask to select the directions bits in an H3 index."""
43+
44+
INDEX_MODE_CELL = 1
45+
"""H3 index mode for cells."""
46+
47+
BASE_CELL_OFFSET = 45
48+
"""Offset (in bits) of the base cell in an H3 index."""
49+
50+
BASE_CELL_MASK = 0b111_1111 << BASE_CELL_OFFSET
51+
"""Bitmask to select the base cell bits in an H3 index."""
52+
53+
MAX_BASE_CELL = 121
54+
"""Maximum value for a base cell."""
55+
56+
RESOLUTION_OFFSET = 52
57+
"""The bit offset of the resolution in an H3 index."""
58+
59+
RESOLUTION_MASK = 0b1111 << RESOLUTION_OFFSET
60+
"""Bitmask to select the resolution bits in an H3 index."""
61+
62+
MAX_RESOLUTION = 15
63+
"""Maximum supported H3 resolution."""
64+
65+
DIRECTION_BITSIZE = 3
66+
"""Size, in bits, of a direction (range [0; 6])."""
67+
68+
BASE_PENTAGONS_HI = 0x0020_0802_0008_0100
69+
"""Bitmap where a bit's position represents a base cell value (high part).
70+
71+
Refactored from upstream 128 bit integer
72+
https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L12
73+
"""
74+
75+
BASE_PENTAGONS_LO = 0x8402_0040_0100_4010
76+
"""Bitmap where a bit's position represents a base cell value (low part).
77+
78+
Refactored from upstream 128 bit integer
79+
https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L12
80+
"""
81+
82+
PENTAGON_BASE_CELLS = np.array(
83+
[4, 14, 24, 33, 38, 49, 58, 63, 72, 83, 97, 107],
84+
dtype=np.uint8,
85+
)
86+
"""Set of pentagon base cells."""
87+
88+
89+
def validate_h3_indices(h3_indices: NDArray[np.uint64]) -> None:
90+
"""Validate an array of uint64 H3 indices.
91+
92+
Raises ValueError if any index is invalid.
93+
"""
94+
invalid_reserved_bits = h3_indices >> 56 & 0b1000_0111 != 0
95+
bad_indices = np.where(invalid_reserved_bits)[0]
96+
if len(bad_indices) > 0:
97+
raise ValueError(
98+
f"Tainted reserved bits in indices: {bad_indices.tolist()}\n"
99+
f"with values {h3_indices[bad_indices].tolist()}",
100+
)
101+
102+
invalid_mode = get_mode(h3_indices) != INDEX_MODE_CELL
103+
bad_indices = np.where(invalid_mode)[0]
104+
if len(bad_indices) > 0:
105+
raise ValueError(
106+
f"Invalid index mode in indices: {bad_indices.tolist()}",
107+
f"with values {h3_indices[bad_indices].tolist()}",
108+
)
109+
110+
base = get_base_cell(h3_indices)
111+
invalid_base_cell = base > MAX_BASE_CELL
112+
bad_indices = np.where(invalid_base_cell)[0]
113+
if len(bad_indices) > 0:
114+
raise ValueError(
115+
f"Invalid base cell in indices: {bad_indices.tolist()}",
116+
f"with values {h3_indices[bad_indices].tolist()}",
117+
)
118+
119+
# Resolution is always valid: coded on 4 bits, valid range is [0; 15].
120+
resolution = get_resolution(h3_indices)
121+
122+
# Check that we have a tail of unused cells after `resolution` cells.
123+
#
124+
# We expect every bit to be 1 in the tail (because unused cells are
125+
# represented by `0b111`), i.e. every bit set to 0 after a NOT.
126+
unused_count = MAX_RESOLUTION - resolution
127+
unused_bitsize = unused_count * DIRECTION_BITSIZE
128+
unused_mask = (1 << unused_bitsize.astype(np.uint64)) - 1
129+
invalid_unused_direction_pattern = (~h3_indices) & unused_mask != 0
130+
bad_indices = np.where(invalid_unused_direction_pattern)[0]
131+
if len(bad_indices) > 0:
132+
raise ValueError(
133+
f"Invalid unused direction pattern in indices: {bad_indices.tolist()}",
134+
f"with values {h3_indices[bad_indices].tolist()}",
135+
)
136+
137+
# Check that we have `resolution` valid cells (no unused ones).
138+
dirs_mask = (1 << (resolution * DIRECTION_BITSIZE).astype(np.uint64)) - 1
139+
dirs = (h3_indices >> unused_bitsize) & dirs_mask
140+
invalid_unused_direction = has_unused_direction(dirs)
141+
bad_indices = np.where(invalid_unused_direction)[0]
142+
if len(bad_indices) > 0:
143+
raise ValueError(
144+
f"Unexpected unused direction in indices: {bad_indices.tolist()}",
145+
f"with values {h3_indices[bad_indices].tolist()}",
146+
)
147+
148+
# Check for pentagons with deleted subsequence.
149+
has_pentagon_base = np.logical_and(is_pentagon(base), resolution != 0)
150+
pentagon_base_indices = np.where(has_pentagon_base)[0]
151+
if len(pentagon_base_indices) > 0:
152+
pentagons = h3_indices[pentagon_base_indices]
153+
pentagon_resolutions = resolution[pentagon_base_indices]
154+
pentagon_dirs = dirs[pentagon_base_indices]
155+
156+
# Move directions to the front, so that we can count leading zeroes.
157+
pentagon_offset = 64 - (pentagon_resolutions * DIRECTION_BITSIZE)
158+
159+
# NOTE: The following was ported via GPT from Rust `leading_zeros`
160+
# https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L1951
161+
162+
# Find the position of the first bit set, if it's a multiple of 3
163+
# that means we have a K axe as the first non-center direction,
164+
# which is forbidden.
165+
shifted = pentagon_dirs << pentagon_offset
166+
167+
# Compute leading zeros for each element (assuming 64-bit unsigned integers)
168+
# where `leading_zeros = 64 - shifted.bit_length()`
169+
# numpy doesn't have bit_length, so use log2 and handle zeros
170+
bitlen = np.where(shifted == 0, 0, np.floor(np.log2(shifted)).astype(int) + 1)
171+
leading_zeros = 64 - bitlen
172+
173+
# Add 1 and check if multiple of 3
174+
is_multiple_of_3 = ((leading_zeros + 1) % 3) == 0
175+
bad_indices = np.where(is_multiple_of_3)[0]
176+
if len(bad_indices) > 0:
177+
raise ValueError(
178+
f"Pentagonal cell index with a deleted subsequence: {bad_indices.tolist()}",
179+
f"with values {pentagons[bad_indices].tolist()}",
180+
)
181+
182+
183+
def get_mode(bits: NDArray[np.uint64]) -> NDArray[np.uint8]:
184+
"""Return the H3 index mode bits."""
185+
return ((bits & MODE_MASK) >> MODE_OFFSET).astype(np.uint8)
186+
187+
188+
def get_base_cell(bits: NDArray[np.uint64]) -> NDArray[np.uint8]:
189+
"""Return the H3 index base cell bits."""
190+
return ((bits & BASE_CELL_MASK) >> BASE_CELL_OFFSET).astype(np.uint8)
191+
192+
193+
def get_resolution(bits: NDArray[np.uint64]) -> NDArray[np.uint8]:
194+
"""Return the H3 index resolution."""
195+
return ((bits & RESOLUTION_MASK) >> RESOLUTION_OFFSET).astype(np.uint8)
196+
197+
198+
def has_unused_direction(dirs: NDArray) -> NDArray[np.bool_]:
199+
"""Check if there is at least one unused direction in the given directions.
200+
201+
Copied from upstream
202+
https://github.com/HydroniumLabs/h3o/blob/07dcb85d9cb539f685ec63050ef0954b1d9f3864/src/index/cell.rs#L2056-L2107
203+
"""
204+
LO_MAGIC = 0b001_001_001_001_001_001_001_001_001_001_001_001_001_001_001 # noqa: N806
205+
HI_MAGIC = 0b100_100_100_100_100_100_100_100_100_100_100_100_100_100_100 # noqa: N806
206+
207+
return ((~dirs - LO_MAGIC) & (dirs & HI_MAGIC)) != 0
208+
209+
210+
def is_pentagon(cell: NDArray[np.uint8]) -> NDArray[np.bool_]:
211+
"""Return true if the base cell is pentagonal.
212+
213+
Note that this is **not** copied from the upstream:
214+
https://github.com/HydroniumLabs/h3o/blob/3b40550291a57552117c48c19841557a3b0431e1/src/base_cell.rs#L33-L47
215+
216+
Because they use a 128 bit integer as a bitmap, which is not available in
217+
numpy. Instead we use a simple lookup in a static array.
218+
"""
219+
return np.isin(cell, PENTAGON_BASE_CELLS)

lonboard/_utils.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,19 @@ def auto_downcast(df: DF) -> DF:
5858

5959
check_pandas_version()
6060

61+
# This will fail if the df is pandas input:
62+
# TypeError: data type 'geometry' not understood
63+
try:
64+
df_attr = df.select_dtypes(exclude="geometry")
65+
except TypeError:
66+
df_attr = df
67+
6168
# Convert objects to numeric types where possible.
6269
# Note: we have to exclude geometry because
63-
# `convert_dtypes(dtype_backend="pyarrow")` fails on the geometory column, but we
70+
# `convert_dtypes(dtype_backend="pyarrow")` fails on the geometry column, but we
6471
# also have to manually cast to a non-geo data frame because it'll fail to convert
6572
# dtypes on a GeoDataFrame without a geom col
66-
casted_df = pd.DataFrame(df.select_dtypes(exclude="geometry")).convert_dtypes( # type: ignore
73+
casted_df = pd.DataFrame(df_attr).convert_dtypes( # type: ignore
6774
infer_objects=True,
6875
convert_string=True,
6976
convert_integer=True,

lonboard/layer/_base.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@
1414
from lonboard._geoarrow._duckdb import from_duckdb as _from_duckdb
1515
from lonboard._geoarrow.c_stream_import import import_arrow_c_stream
1616
from lonboard._geoarrow.geopandas_interop import geopandas_to_geoarrow
17-
from lonboard._geoarrow.ops import reproject_table
18-
from lonboard._geoarrow.ops.bbox import Bbox, total_bounds
19-
from lonboard._geoarrow.ops.centroid import WeightedCentroid, weighted_centroid
17+
from lonboard._geoarrow.ops import (
18+
Bbox,
19+
WeightedCentroid,
20+
reproject_table,
21+
total_bounds,
22+
weighted_centroid,
23+
)
2024
from lonboard._geoarrow.ops.coord_layout import make_geometry_interleaved
2125
from lonboard._geoarrow.parse_wkb import parse_serialized_table
2226
from lonboard._geoarrow.row_index import add_positional_row_index
2327
from lonboard._serialization import infer_rows_per_chunk
2428
from lonboard._utils import auto_downcast as _auto_downcast
2529
from lonboard._utils import get_geometry_column_index, remove_extension_kwargs
26-
from lonboard.traits import (
27-
ArrowTableTrait,
28-
VariableLengthTuple,
29-
)
30+
from lonboard.traits import ArrowTableTrait, VariableLengthTuple
3031

3132
if TYPE_CHECKING:
3233
import sys

lonboard/layer/_bitmap.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
import traitlets as t
88

9-
from lonboard._geoarrow.ops.bbox import Bbox
10-
from lonboard._geoarrow.ops.centroid import WeightedCentroid
9+
from lonboard._geoarrow.ops import Bbox, WeightedCentroid
1110
from lonboard.layer._base import BaseLayer
1211
from lonboard.traits import (
1312
VariableLengthTuple,

0 commit comments

Comments
 (0)