Skip to content

Commit 27e3ef9

Browse files
NormalAccessor (#376)
Adds NormalAccessor and draft tests Respond to comments on #328 and move recent work here. Branch #328 will be deleted --------- Co-authored-by: Kyle Barron <kyle@developmentseed.org>
1 parent 209045c commit 27e3ef9

File tree

4 files changed

+189
-8
lines changed

4 files changed

+189
-8
lines changed

docs/api/traits.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
# lonboard.traits
22

3-
::: lonboard.traits.PyarrowTableTrait
4-
53
::: lonboard.traits.ColorAccessor
6-
74
::: lonboard.traits.FloatAccessor
8-
9-
::: lonboard.traits.PointAccessor
10-
115
::: lonboard.traits.GetFilterValueAccessor
6+
::: lonboard.traits.NormalAccessor
7+
::: lonboard.traits.PointAccessor
8+
::: lonboard.traits.PyarrowTableTrait

docs/ecosystem/panel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[Panel](https://panel.holoviz.org/) is a tool to build interactive web applications and dashboards using Python code.
44

5-
Panel [has been reported to work](https://github.com/developmentseed/lonboard/issues/262) with Lonboard. However, it appears that Panel [does not support reactive updates](https://github.com/holoviz/panel/issues/5921) in the same way that [Shiny](./shiny) does, so the map will necessarily be recreated from scratch on every update.
5+
Panel [has been reported to work](https://github.com/developmentseed/lonboard/issues/262) with Lonboard. However, it appears that Panel [does not support reactive updates](https://github.com/holoviz/panel/issues/5921) in the same way that [Shiny](./shiny.md) does, so the map will necessarily be recreated from scratch on every update.
66

77
## Example
88

lonboard/traits.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import warnings
910
from typing import Any, List, Set, Tuple, Union, cast
1011

1112
import matplotlib as mpl
@@ -706,3 +707,107 @@ def validate(self, obj, value) -> Union[float, pa.ChunkedArray, pa.DoubleArray]:
706707

707708
self.error(obj, value)
708709
assert False
710+
711+
712+
class NormalAccessor(FixedErrorTraitType):
713+
"""
714+
A representation of a deck.gl "normal" accessor
715+
716+
This is primarily used in the [lonboard.PointCloudLayer].
717+
718+
Acceptable inputs:
719+
- A `list` or `tuple` with three `int` or `float` values. This will be used as the
720+
normal for all objects.
721+
- A numpy ndarray with two dimensions and floating point type. The size of the
722+
second dimension must be 3, i.e. its shape must be `(N, 3)`.
723+
- a pyarrow `FixedSizeListArray` or `ChunkedArray` containing `FixedSizeListArray`s
724+
where the size of the inner fixed size list 3. The child array must have type
725+
float32.
726+
- Any Arrow array that matches the above restrictions from a library that implements
727+
the [Arrow PyCapsule
728+
Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html).
729+
"""
730+
731+
default_value = (0, 0, 1)
732+
info_text = (
733+
"List representing normal of all objects in [nx, ny, nz] or numpy ndarray or "
734+
"pyarrow FixedSizeList representing the normal of each object, in [nx, ny, nz]"
735+
)
736+
737+
def __init__(
738+
self: TraitType,
739+
*args,
740+
**kwargs: Any,
741+
) -> None:
742+
super().__init__(*args, **kwargs)
743+
self.tag(sync=True, **ACCESSOR_SERIALIZATION)
744+
745+
def validate(
746+
self, obj, value
747+
) -> Union[Tuple[int, ...], List[int], pa.ChunkedArray, pa.FixedSizeListArray]:
748+
"""
749+
Values in acceptable types must be contiguous
750+
(the same length for all values)
751+
"""
752+
if isinstance(value, (tuple, list)):
753+
if len(value) != 3:
754+
self.error(
755+
obj, value, info="normal scalar to have length 3, (nx, ny, nz)"
756+
)
757+
758+
if not all(isinstance(item, (int, float)) for item in value):
759+
self.error(
760+
obj,
761+
value,
762+
info="all elements of normal scalar to be int or float type",
763+
)
764+
765+
return value
766+
767+
if isinstance(value, np.ndarray):
768+
if not np.issubdtype(value.dtype, np.number):
769+
self.error(obj, value, info="normal array to have numeric type")
770+
771+
if value.ndim != 2 or value.shape[1] != 3:
772+
self.error(obj, value, info="normal array to be 2D with shape (N, 3)")
773+
774+
if not np.issubdtype(value.dtype, np.float32):
775+
warnings.warn(
776+
"""Warning: Numpy array should be float32 type.
777+
Converting to float32 point pyarrow array"""
778+
)
779+
value = value.astype(np.float32)
780+
781+
return pa.FixedSizeListArray.from_arrays(value.flatten("C"), 3)
782+
783+
# Check for Arrow PyCapsule Interface
784+
# https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html
785+
# TODO: with pyarrow v16 also import chunked array from stream
786+
if not isinstance(value, (pa.ChunkedArray, pa.Array)):
787+
if hasattr(value, "__arrow_c_array__"):
788+
value = pa.array(value)
789+
790+
if isinstance(value, (pa.ChunkedArray, pa.Array)):
791+
if not pa.types.is_fixed_size_list(value.type):
792+
self.error(
793+
obj, value, info="normal pyarrow array to be a FixedSizeList."
794+
)
795+
796+
if value.type.list_size != 3:
797+
self.error(
798+
obj,
799+
value,
800+
info=("normal pyarrow array to have an inner size of 3."),
801+
)
802+
803+
if not pa.types.is_floating(value.type.value_type):
804+
self.error(
805+
obj,
806+
value,
807+
info="pyarrow array to be floating point type",
808+
)
809+
810+
return value.cast(pa.list_(pa.float32(), 3))
811+
812+
self.error(obj, value)
813+
assert False

tests/test_traits.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from lonboard._base import BaseExtension
1010
from lonboard._layer import BaseArrowLayer, BaseLayer
1111
from lonboard.layer_extension import DataFilterExtension
12-
from lonboard.traits import ColorAccessor, FloatAccessor, PyarrowTableTrait
12+
from lonboard.traits import (
13+
ColorAccessor,
14+
FloatAccessor,
15+
NormalAccessor,
16+
PyarrowTableTrait,
17+
)
1318

1419

1520
class ColorAccessorWidget(BaseLayer):
@@ -343,3 +348,77 @@ def test_filter_value_validation_filter_size_3():
343348
np.array([1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=np.float64), 3
344349
),
345350
)
351+
352+
353+
class NormalAccessorWidget(BaseLayer):
354+
_rows_per_chunk = 2
355+
356+
table = pa.table({"data": [1, 2, 3]})
357+
358+
value = NormalAccessor()
359+
360+
361+
def test_normal_accessor_validation_list_length():
362+
with pytest.raises(TraitError, match="normal scalar to have length 3"):
363+
NormalAccessorWidget(value=(1, 2))
364+
365+
with pytest.raises(TraitError, match="normal scalar to have length 3"):
366+
NormalAccessorWidget(value=(1, 2, 3, 4))
367+
368+
NormalAccessorWidget(value=(1, 2, 3))
369+
370+
371+
def test_normal_accessor_validation_list_type():
372+
# tuple or list must be of scalar type
373+
with pytest.raises(
374+
TraitError, match="all elements of normal scalar to be int or float type"
375+
):
376+
NormalAccessorWidget(value=["1.1", 2.2, 3.3])
377+
378+
379+
def test_normal_accessor_validation_dim_shape_np_arr():
380+
arr_size3 = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]).reshape(-1, 3)
381+
arr_size2 = np.array([1, 2, 3, 4, 5, 6]).reshape(-1, 2)
382+
383+
NormalAccessorWidget(value=arr_size3)
384+
385+
with pytest.raises(TraitError, match="normal array to be 2D with shape"):
386+
NormalAccessorWidget(value=arr_size2)
387+
388+
389+
def test_normal_accessor_validation_np_dtype():
390+
arr_size3_int = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]).reshape(-1, 3)
391+
arr_size3_float = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=np.float64).reshape(
392+
-1, 3
393+
)
394+
395+
NormalAccessorWidget(value=arr_size3_int)
396+
NormalAccessorWidget(value=arr_size3_float)
397+
398+
arr_size3_str = np.array(["1", "2", "3", "4", "5", "6", "7", "8", "9"]).reshape(
399+
-1, 3
400+
)
401+
402+
# acceptable data types within a numpy array are float32
403+
with pytest.raises(TraitError, match="expected normal array to have numeric type"):
404+
NormalAccessorWidget(value=arr_size3_str)
405+
406+
407+
def test_normal_accessor_validation_pyarrow_array_type():
408+
# array type must be FixedSizeList, of length 3, of float32 type
409+
with pytest.raises(
410+
TraitError, match="expected normal pyarrow array to be a FixedSizeList"
411+
):
412+
NormalAccessorWidget(value=pa.array(np.array([1, 2, 3], dtype=np.float64)))
413+
414+
np_arr = np.array([1, 2, 3], dtype=np.float32).repeat(3, axis=0)
415+
NormalAccessorWidget(value=pa.FixedSizeListArray.from_arrays(np_arr, 3))
416+
417+
np_arr = np.array([1, 2, 3], dtype=np.float64).repeat(3, axis=0)
418+
NormalAccessorWidget(value=pa.FixedSizeListArray.from_arrays(np_arr, 3))
419+
420+
np_arr = np.array([1, 2, 3], dtype=np.uint8).repeat(3, axis=0)
421+
with pytest.raises(
422+
TraitError, match="expected pyarrow array to be floating point type"
423+
):
424+
NormalAccessorWidget(value=pa.FixedSizeListArray.from_arrays(np_arr, 3))

0 commit comments

Comments
 (0)