33__all__ = ["LasWriter" ]
44
55import pathlib
6- from typing import Optional , Union
6+ from typing import Optional , Tuple , Union
77
88import laspy
99from 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 ))
0 commit comments