Skip to content

Commit 9f04366

Browse files
refactor: Las and laz I/O (#100)
1 parent c3d2ea4 commit 9f04366

4 files changed

Lines changed: 120 additions & 14 deletions

File tree

src/pointtorch/io/_las_reader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ def _read_points(
9595
for column_name in las_header.point_format.extra_dimension_names:
9696
if column_name.lower() in ["x", "y", "z"] or columns is not None and column_name not in columns:
9797
continue
98-
column_values = np.array(las_data[column_name])
99-
point_cloud_df[column_name] = column_values
98+
99+
point_cloud_df[column_name] = np.array(las_data[column_name])
100100

101101
return point_cloud_df
102102

src/pointtorch/io/_las_writer.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
__all__ = ["LasWriter"]
44

55
import pathlib
6-
from typing import Optional, Union
6+
from typing import Optional, Tuple, Union
77

88
import laspy
99
from laspy.compression import LazrsBackend
@@ -44,15 +44,15 @@ def supported_file_formats(self) -> list[str]:
4444

4545
return ["las", "laz"]
4646

47-
def _select_point_format(self, point_cloud: pd.DataFrame) -> int:
47+
def _select_point_format(self, point_cloud: pd.DataFrame) -> laspy.point.format.PointFormat:
4848
"""
4949
Determines the las file format that covers the most columns of the given point cloud.
5050
5151
Returns:
52-
ID of the chosen las file format.
52+
Chosen las file format.
5353
"""
5454
columns = set(point_cloud.columns).difference(["x", "y", "z"])
55-
best_format = 0
55+
best_format = laspy.point.format.PointFormat(0)
5656
covered_columns = 0
5757

5858
for f in LasWriter._supported_las_formats:
@@ -72,6 +72,46 @@ def _select_point_format(self, point_cloud: pd.DataFrame) -> int:
7272

7373
return best_format
7474

75+
@classmethod
76+
def _compute_scales_and_offsets(
77+
cls,
78+
xyz: np.ndarray,
79+
default_scales: np.ndarray,
80+
) -> Tuple[np.ndarray, np.ndarray]:
81+
"""
82+
Computes LAS scales and offsets for storing coordinates in LAS integer fields.
83+
84+
The returned offsets are centered on the coordinate range to maximize the usable signed 32-bit integer span.
85+
The requested scales are preserved unless they would cause the coordinate range to exceed that span, in which
86+
case the affected scales are increased to the minimum supported values.
87+
88+
Args:
89+
xyz: Point coordinates.
90+
default_scales: Preferred LAS scales for the x, y, and z coordinates.
91+
92+
Returns:
93+
Tuple containing the LAS scales and offsets for the x, y, and z coordinates.
94+
95+
Shape:
96+
- :code:`xyz`: :math:`(num_points, 3)`
97+
"""
98+
99+
if len(xyz) == 0:
100+
return default_scales, np.zeros(3, dtype=np.float64)
101+
102+
lower_bounds = xyz.min(axis=0)
103+
upper_bounds = xyz.max(axis=0)
104+
105+
offsets = (lower_bounds + upper_bounds) / 2
106+
107+
coordinate_spans = upper_bounds - lower_bounds
108+
minimum_supported_scales = coordinate_spans / (2 * np.iinfo(np.int32).max)
109+
scales = np.maximum(default_scales, minimum_supported_scales)
110+
111+
offsets = np.round(offsets / scales) * scales
112+
113+
return scales, offsets
114+
75115
def _write_data( # pylint: disable=too-many-locals
76116
self,
77117
point_cloud: pd.DataFrame,
@@ -101,17 +141,18 @@ def _write_data( # pylint: disable=too-many-locals
101141
):
102142
point_cloud = point_cloud.rename({"r": "red", "g": "green", "b": "blue"}, axis=1)
103143

104-
las_data = laspy.create(point_format=self._select_point_format(point_cloud))
144+
las_format = self._select_point_format(point_cloud)
145+
las_data = laspy.create(point_format=las_format)
105146
point_coords = point_cloud[["x", "y", "z"]].values
106-
offsets = point_coords.min(axis=0)
107-
scales = [self.maximum_resolution] * 3
147+
scales = np.array([self.maximum_resolution] * 3)
108148
if x_max_resolution is not None:
109149
scales[0] = x_max_resolution
110150
if y_max_resolution is not None:
111151
scales[1] = y_max_resolution
112152
if z_max_resolution is not None:
113153
scales[2] = z_max_resolution
114154

155+
scales, offsets = self._compute_scales_and_offsets(point_coords, scales)
115156
las_data.change_scaling(scales=scales, offsets=offsets)
116157
las_data.xyz = point_coords
117158

@@ -121,12 +162,16 @@ def _write_data( # pylint: disable=too-many-locals
121162
if column_name.lower() in ["x", "y", "z"]:
122163
continue
123164

165+
column_format = las_format[column_name]
166+
124167
if column_name.lower() in point_cloud.columns:
125-
las_data[column_name] = point_cloud[column_name.lower()]
168+
las_data[column_name] = point_cloud[column_name.lower()].to_numpy(dtype=column_format.dtype)
126169
extra_columns.remove(column_name)
127170
else:
128171
default_value = LasWriter._standard_field_defaults.get(column_name, 0)
129-
las_data[column_name] = np.full_like(las_data[column_name], fill_value=default_value)
172+
las_data[column_name] = np.full_like(
173+
las_data[column_name], fill_value=default_value, dtype=column_format.dtype
174+
)
130175

131176
extra_dims = []
132177
for column_name in extra_columns:
@@ -138,7 +183,7 @@ def _write_data( # pylint: disable=too-many-locals
138183
las_data.update_header()
139184

140185
for extra_dim in extra_dims:
141-
las_data[extra_dim.name] = point_cloud[extra_dim.name]
186+
las_data[extra_dim.name] = point_cloud[extra_dim.name].to_numpy(dtype=extra_dim.type)
142187

143188
if crs is not None:
144189
las_data.header.add_crs(CRS.from_string(crs))

test/io/test_las_writer.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import numpy as np
99
import pandas as pd
1010
import pytest
11+
import laspy
1112

1213
from pointtorch.io import LasWriter, LasReader, PointCloudIoData
1314

@@ -127,7 +128,7 @@ def test_write_max_resolutions(
127128

128129
read_point_cloud_data = las_reader.read(file_path)
129130

130-
assert (point_cloud_df.to_numpy() == read_point_cloud_data.data.to_numpy()).all()
131+
assert np.allclose(point_cloud_df.to_numpy(), read_point_cloud_data.data.to_numpy())
131132

132133
@pytest.mark.parametrize("file_format", ["las", "laz"])
133134
@pytest.mark.parametrize("use_pathlib", [True, False])
@@ -148,3 +149,63 @@ def test_write_crs(
148149
read_point_cloud_data = las_reader.read(file_path)
149150

150151
assert expected_crs == read_point_cloud_data.crs
152+
153+
@pytest.mark.parametrize("file_format", ["las", "laz"])
154+
def test_offsets_for_wide_coordinate_ranges(
155+
self, las_reader: LasReader, las_writer: LasWriter, cache_dir: str, file_format: str
156+
):
157+
point_cloud_df = pd.DataFrame([[0.0, 0.0, 0.0], [3000.0, 10.0, 5.0]], columns=["x", "y", "z"])
158+
point_cloud_data = PointCloudIoData(point_cloud_df)
159+
file_path = os.path.join(cache_dir, f"wide_range.{file_format}")
160+
161+
las_writer.write(point_cloud_data, file_path)
162+
163+
with laspy.open(file_path) as las_file:
164+
assert las_file.header.offsets[0] == pytest.approx(1500.0)
165+
assert las_file.header.offsets[1] == pytest.approx(5.0)
166+
assert las_file.header.offsets[2] == pytest.approx(2.5)
167+
168+
read_point_cloud_data = las_reader.read(file_path)
169+
assert np.allclose(point_cloud_df.to_numpy(), read_point_cloud_data.data.to_numpy())
170+
171+
@pytest.mark.parametrize("file_format", ["las", "laz"])
172+
def test_relaxes_resolution_to_prevent_overflow(
173+
self, las_reader: LasReader, las_writer: LasWriter, cache_dir: str, file_format: str
174+
):
175+
point_cloud_df = pd.DataFrame([[0.0, 0.0, 0.0], [50000.0, 0.0, 0.0]], columns=["x", "y", "z"])
176+
point_cloud_data = PointCloudIoData(point_cloud_df, x_max_resolution=1e-6)
177+
file_path = os.path.join(cache_dir, f"very_wide_range.{file_format}")
178+
179+
las_writer.write(point_cloud_data, file_path)
180+
181+
read_point_cloud_data = las_reader.read(file_path)
182+
183+
assert read_point_cloud_data.x_max_resolution is not None and read_point_cloud_data.x_max_resolution > 1e-6
184+
assert np.allclose(point_cloud_df.to_numpy(), read_point_cloud_data.data.to_numpy())
185+
186+
@pytest.mark.parametrize("file_format", ["las", "laz"])
187+
def test_write_empty_point_cloud(
188+
self, las_reader: LasReader, las_writer: LasWriter, cache_dir: str, file_format: str
189+
):
190+
x_max_resolution = 0.1
191+
y_max_resolution = 0.01
192+
z_max_resolution = 1.0
193+
194+
point_cloud_df = pd.DataFrame(columns=["x", "y", "z"], dtype=np.float64)
195+
point_cloud_data = PointCloudIoData(
196+
point_cloud_df,
197+
x_max_resolution=x_max_resolution,
198+
y_max_resolution=y_max_resolution,
199+
z_max_resolution=z_max_resolution,
200+
)
201+
file_path = os.path.join(cache_dir, f"empty_point_cloud.{file_format}")
202+
203+
las_writer.write(point_cloud_data, file_path)
204+
205+
read_point_cloud_data = las_reader.read(file_path)
206+
207+
assert len(read_point_cloud_data.data) == 0
208+
209+
assert read_point_cloud_data.x_max_resolution == x_max_resolution
210+
assert read_point_cloud_data.y_max_resolution == y_max_resolution
211+
assert read_point_cloud_data.z_max_resolution == z_max_resolution

test/io/test_point_cloud_writer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def test_write_max_resolutions(
9494

9595
read_point_cloud_data = point_cloud_reader.read(file_path)
9696

97-
assert (point_cloud_df.to_numpy() == read_point_cloud_data.data.to_numpy()).all()
97+
assert np.allclose(point_cloud_df.to_numpy(), read_point_cloud_data.data.to_numpy())
9898

9999
@pytest.mark.parametrize("file_format", ["h5", "hdf", "las", "laz"])
100100
@pytest.mark.parametrize("use_pathlib", [True, False])

0 commit comments

Comments
 (0)