ign-pdal-tools 1.7.9__py3-none-any.whl → 1.7.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: ign-pdal-tools
3
- Version: 1.7.9
3
+ Version: 1.7.11
4
4
  Summary: Library for common LAS files manipulation with PDAL
5
5
  Author-email: Guillaume Liegard <guillaume.liegard@ign.fr>
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE.md
8
+ Dynamic: license-file
8
9
 
9
10
  # ign-pdal-tools
10
11
 
@@ -1,6 +1,7 @@
1
- pdaltools/_version.py,sha256=2YxDctk7vFYaW4gZ_YqrDboXUkA5pH4qHhNoYN-p2xc,74
1
+ ign_pdal_tools-1.7.11.dist-info/licenses/LICENSE.md,sha256=iVzCFZTUXeiqP8bP474iuWZiWO_kDCD4SPh1Wiw125Y,1120
2
+ pdaltools/_version.py,sha256=lXuB5yCoqB_XdA0YVaGjTGc2J_sFF0RE_IJePTMnyjI,75
2
3
  pdaltools/add_points_in_las.py,sha256=TGbt5JUkszjmbQiA2LCUntsjz6A8DHb7QPIXGDuEgWA,3643
3
- pdaltools/add_points_in_pointcloud.py,sha256=g9V_gUA-vOy3wuYdWXFSObDZtSKO2LhzXa5Vy-yypuA,5684
4
+ pdaltools/add_points_in_pointcloud.py,sha256=UHdC8tFagsehGggkLJCcmBCi8gknJOL4s99f_2TJjsA,12538
4
5
  pdaltools/color.py,sha256=-a0ramyLMkZX4-M3siPq1zIio5u-a0p8UzvgcfoU6zU,8313
5
6
  pdaltools/las_add_buffer.py,sha256=sBpTywlfsHHS8KuCUa-eydB2hylshEvjrMQt5TrqXb8,11275
6
7
  pdaltools/las_clip.py,sha256=GvEOYu8RXV68e35kU8i42GwSkbo4P9TvmS6rkrdPmFM,1034
@@ -11,8 +12,7 @@ pdaltools/pcd_info.py,sha256=NIAH5KGikVDQLlbCcw9FuaPqe20UZvRfkHsDZd5kmZA,3210
11
12
  pdaltools/replace_attribute_in_las.py,sha256=po1F-fi8s7iilqKWaryW4JRbsmdMOUe0yGvG3AEKxtk,4771
12
13
  pdaltools/standardize_format.py,sha256=gqm2GJbtDkT4k4oC_NX2LIPh9R2BLh4sMHLKYgfKrMc,3973
13
14
  pdaltools/unlock_file.py,sha256=pIThdWMNkTph0xgJVVRaM1o9aUMQhM6804PscScB3JI,1963
14
- ign_pdal_tools-1.7.9.dist-info/LICENSE.md,sha256=iVzCFZTUXeiqP8bP474iuWZiWO_kDCD4SPh1Wiw125Y,1120
15
- ign_pdal_tools-1.7.9.dist-info/METADATA,sha256=hVkIMUlnlbfnsMplmEmwC4sSKdWXS6X1K85XJUhqrkc,5722
16
- ign_pdal_tools-1.7.9.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
17
- ign_pdal_tools-1.7.9.dist-info/top_level.txt,sha256=KvGW0ZzqQbhCKzB5_Tp_buWMZyIgiO2M2krWF_ecOZc,10
18
- ign_pdal_tools-1.7.9.dist-info/RECORD,,
15
+ ign_pdal_tools-1.7.11.dist-info/METADATA,sha256=Bh4cFjnGLLt4DpiPw8GQuknhDTrLQqx3H5EjkFzV1n4,5745
16
+ ign_pdal_tools-1.7.11.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
17
+ ign_pdal_tools-1.7.11.dist-info/top_level.txt,sha256=KvGW0ZzqQbhCKzB5_Tp_buWMZyIgiO2M2krWF_ecOZc,10
18
+ ign_pdal_tools-1.7.11.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pdaltools/_version.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.7.9"
1
+ __version__ = "1.7.11"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -1,19 +1,21 @@
1
1
  import argparse
2
- import shutil
2
+ from shutil import copy2
3
3
 
4
4
  import geopandas as gpd
5
5
  import laspy
6
6
  import numpy as np
7
7
  from pyproj import CRS
8
8
  from pyproj.exceptions import CRSError
9
- from shapely.geometry import box
9
+ from shapely.geometry import MultiPoint, Point, box
10
10
 
11
11
  from pdaltools.las_info import get_epsg_from_las, get_tile_bbox
12
12
 
13
13
 
14
14
  def parse_args(argv=None):
15
15
  parser = argparse.ArgumentParser("Add points from GeoJSON in LIDAR tile")
16
- parser.add_argument("--input_geojson", "-ig", type=str, required=True, help="Input GeoJSON file")
16
+ parser.add_argument(
17
+ "--input_geometry", "-ig", type=str, required=True, help="Input Geometry file (GeoJSON or Shapefile)"
18
+ )
17
19
  parser.add_argument("--input_las", "-i", type=str, required=True, help="Input las file")
18
20
  parser.add_argument("--output_las", "-o", type=str, required=True, default="", help="Output las file")
19
21
  parser.add_argument(
@@ -35,72 +37,112 @@ def parse_args(argv=None):
35
37
  default=1000,
36
38
  help="width of tiles in meters",
37
39
  )
40
+ parser.add_argument(
41
+ "--spacing",
42
+ type=float,
43
+ default=0.25,
44
+ help="spacing between generated points in meters (default. 25 cm)",
45
+ )
46
+ parser.add_argument(
47
+ "--altitude_column",
48
+ "-z",
49
+ type=str,
50
+ required=False,
51
+ default=None,
52
+ help="altitude column name from input geometry (use point.z if altitude_column is not set)",
53
+ )
38
54
 
39
55
  return parser.parse_args(argv)
40
56
 
41
57
 
42
- def clip_3d_points_to_tile(input_points: str, input_las: str, crs: str, tile_width: int) -> gpd.GeoDataFrame:
58
+ def clip_3d_lines_to_tile(
59
+ input_lines: gpd.GeoDataFrame, input_las: str, crs: str, tile_width: int = 1000
60
+ ) -> gpd.GeoDataFrame:
43
61
  """
44
- Add points from a GeoJSON file in the LIDAR's tile.
62
+ Select lines from a GeoDataFrame that intersect the the LIDAR tile.
45
63
 
46
64
  Args:
47
- input_points (str): Path to the input GeoJSON file with 3D points.
65
+ input_lines (gpd.GeoDataFrame): GeoDataFrame with lines.
48
66
  input_las (str): Path to the LIDAR `.las/.laz` file.
49
67
  crs (str): CRS of the data.
50
68
  tile_width (int): Width of the tile in meters (default: 1000).
51
69
 
52
- Return:
53
- gpd.GeoDataFrame: Points 2d with "Z" value
70
+ Returns:
71
+ gpd.GeoDataFrame: Lines that intersect with the tile.
54
72
  """
55
73
  # Compute the bounding box of the LIDAR tile
56
74
  tile_bbox = get_tile_bbox(input_las, tile_width)
57
75
 
58
- # Read the input GeoJSON with 3D points
59
- points_gdf = gpd.read_file(input_points)
76
+ if crs:
77
+ input_lines = input_lines.to_crs(crs)
78
+
79
+ # Create a polygon from the bounding box
80
+ bbox_polygon = box(*tile_bbox)
81
+
82
+ # Clip the lines to the bounding box
83
+ clipped_lines = input_lines[input_lines.intersects(bbox_polygon)]
84
+
85
+ return clipped_lines
86
+
87
+
88
+ def clip_3d_points_to_tile(
89
+ input_points: gpd.GeoDataFrame, input_las: str, crs: str, tile_width: int = 1000
90
+ ) -> gpd.GeoDataFrame:
91
+ """
92
+ Select points from a GeoDataFrame that intersect the LIDAR tile
93
+
94
+ Args:
95
+ input_points (gpd.GeoDataFrame): GeoDataFrame with 3D points.
96
+ input_las (str): Path to the LIDAR `.las/.laz` file.
97
+ crs (str): CRS of the data.
98
+ tile_width (int): Width of the tile in meters (default: 1000).
99
+
100
+ Return:
101
+ gpd.GeoDataFrame: Points 2D with "Z" value
102
+ """
103
+ # Compute the bounding box of the LIDAR tile
104
+ tile_bbox = get_tile_bbox(input_las, tile_width)
60
105
 
61
106
  if crs:
62
- points_gdf = points_gdf.to_crs(crs)
107
+ input_points = input_points.to_crs(crs)
63
108
 
64
109
  # Create a polygon from the bounding box
65
110
  bbox_polygon = box(*tile_bbox)
66
111
 
67
112
  # Clip the points to the bounding box
68
- clipped_points = points_gdf[points_gdf.intersects(bbox_polygon)].copy()
113
+ clipped_points = input_points[input_points.intersects(bbox_polygon)]
69
114
 
70
115
  return clipped_points
71
116
 
72
117
 
73
118
  def add_points_to_las(
74
- input_points_with_z: gpd.GeoDataFrame, input_las: str, output_las: str, crs: str, virtual_points_classes=66
119
+ input_points_with_z: gpd.GeoDataFrame, input_las: str, output_las: str, crs: str, virtual_points_classes: int = 66
75
120
  ):
76
121
  """Add points (3D points in LAZ format) by LIDAR tiles (tiling file)
77
122
 
78
123
  Args:
79
- input_points_with_z(gpd.GeoDataFrame): geometry columns (2D points) as encoded to WKT.
124
+ input_points_with_z (gpd.GeoDataFrame): geometry columns (3D points) as encoded to WKT.
80
125
  input_las (str): Path to the LIDAR tiles (LAZ).
81
126
  output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
82
127
  crs (str): CRS of the data.
83
128
  virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
84
129
  """
85
-
86
130
  if input_points_with_z.empty:
87
131
  print(
88
132
  "No points to add. All points of the geojson file are outside the tile. Copying the input file to output"
89
133
  )
90
- shutil.copy(input_las, output_las)
91
-
92
134
  return
93
135
 
94
136
  # Extract XYZ coordinates and additional attribute (classification)
137
+ nb_points = len(input_points_with_z.geometry.x)
95
138
  x_coords = input_points_with_z.geometry.x
96
139
  y_coords = input_points_with_z.geometry.y
97
- z_coords = input_points_with_z.RecupZ
98
- classes = virtual_points_classes * np.ones(len(input_points_with_z.index))
140
+ z_coords = input_points_with_z.geometry.z
141
+ classes = virtual_points_classes * np.ones(nb_points)
99
142
 
100
- with laspy.open(input_las, mode="r") as las:
101
- las_data = las.read()
143
+ # Open the input LAS file to check and possibly update the header of the output
144
+ with laspy.open(input_las) as las:
102
145
  header = las.header
103
-
104
146
  if not header:
105
147
  header = laspy.LasHeader(point_format=8, version="1.4")
106
148
  if crs:
@@ -110,45 +152,173 @@ def add_points_to_las(
110
152
  raise ValueError(f"Invalid CRS: {crs}")
111
153
  header.add_crs(crs_obj)
112
154
 
113
- # Append new points
114
- new_x = np.concatenate([las_data.x, x_coords])
115
- new_y = np.concatenate([las_data.y, y_coords])
116
- new_z = np.concatenate([las_data.z, z_coords])
117
- new_classes = np.concatenate([las_data.classification, classes])
155
+ # Copy data pointcloud
156
+ copy2(input_las, output_las)
157
+
158
+ # Add the new points with 3D points
159
+ nb_points = len(x_coords)
160
+ with laspy.open(output_las, mode="a", header=header) as output_las: # mode `a` for adding points
161
+ # create nb_points points with "0" everywhere
162
+ new_points = laspy.ScaleAwarePointRecord.zeros(nb_points, header=header) # use header for input_las
163
+ # then fill in the gaps (X, Y, Z an classification)
164
+ new_points.x = x_coords.astype(new_points.x.dtype)
165
+ new_points.y = y_coords.astype(new_points.y.dtype)
166
+ new_points.z = z_coords.astype(new_points.z.dtype)
167
+ new_points.classification = classes.astype(new_points.classification.dtype)
118
168
 
119
- updated_las = laspy.LasData(header)
120
- updated_las.x = new_x
121
- updated_las.y = new_y
122
- updated_las.z = new_z
123
- updated_las.classification = new_classes
169
+ output_las.append_points(new_points)
124
170
 
125
- with laspy.open(output_las, mode="w", header=header, do_compress=True) as writer:
126
- writer.write_points(updated_las.points)
127
171
 
172
+ def line_to_multipoint(line, spacing: float, z_value: float = None):
173
+ """
174
+ Convert a LineString to a MultiPoint with equally spaced points and a given Z value.
175
+
176
+ Args:
177
+ line (shapely.geometry.LineString): The input LineString.
178
+ spacing (float): Spacing between generated points in meters.
179
+ z_value (float): The Z value to assign to each point.
180
+
181
+ Returns:
182
+ shapely.geometry.MultiPoint: A MultiPoint geometry with the generated points.
183
+ """
184
+ # Create points along the line with spacing
185
+ length = line.length
186
+ distances = np.arange(0, length + spacing, spacing)
187
+ points_nd = [line.interpolate(distance) for distance in distances]
188
+
189
+ if line.has_z:
190
+ multipoint = MultiPoint([Point(point.x, point.y, point.z) for point in points_nd])
191
+ else:
192
+ if z_value is None:
193
+ raise ValueError("z_value must be provided for 2D lines.")
194
+ multipoint = MultiPoint([Point(point.x, point.y, z_value) for point in points_nd])
128
195
 
129
- def add_points_from_geojson_to_las(
130
- input_geojson: str, input_las: str, output_las: str, virtual_points_classes: int, spatial_ref: str, tile_width: int
196
+ return multipoint
197
+
198
+
199
+ def generate_3d_points_from_lines(
200
+ lines_gdf: gpd.GeoDataFrame, spacing: float, altitude_column: str = None
201
+ ) -> gpd.GeoDataFrame:
202
+ """
203
+ Generate regularly spaced 3D points from 2.5D lines in a GeoJSON file.
204
+
205
+ Args:
206
+ lines_gdf (gpd.GeoDataFrame): GeoDataFrame with 2.5D lines.
207
+ spacing (float): Spacing between generated points in meters.
208
+ altitude_column (str, optional): Altitude column name from input geometry.
209
+ If not provided, use Z from geometry.
210
+
211
+ Returns:
212
+ gpd.GeoDataFrame: GeoDataFrame with generated 3D points.
213
+
214
+ Raises:
215
+ ValueError: If altitude_column is not provided or not found in the GeoDataFrame
216
+ and Z coordinates are not available in the geometry.
217
+ """
218
+ # Check if altitude_column is provided and exists in the GeoDataFrame
219
+ if altitude_column and (altitude_column not in lines_gdf.columns):
220
+ raise ValueError("altitude_column must exist in the GeoDataFrame if provided.")
221
+
222
+ if altitude_column:
223
+ lines_gdf["geometry"] = lines_gdf.apply(
224
+ lambda row: line_to_multipoint(row.geometry, spacing, row[altitude_column]), axis=1
225
+ )
226
+ else:
227
+ # Check if geometries have Z values
228
+ if lines_gdf.geometry.has_z.any():
229
+ lines_gdf["geometry"] = lines_gdf.apply(
230
+ lambda row: line_to_multipoint(row.geometry, spacing, None), axis=1
231
+ )
232
+ else:
233
+ raise ValueError("Geometries do not have Z values and altitude_column is not provided.")
234
+
235
+ # Explode the MultiPoint geometries into individual points
236
+ points_gdf = lines_gdf.dissolve().explode(index_parts=False).reset_index(drop=True)
237
+
238
+ return points_gdf
239
+
240
+
241
+ def add_points_from_geometry_to_las(
242
+ input_geometry: str,
243
+ input_las: str,
244
+ output_las: str,
245
+ virtual_points_classes: int,
246
+ spatial_ref: str,
247
+ tile_width: int,
248
+ spacing: float,
249
+ altitude_column: str,
131
250
  ):
132
- """Add points with Z value(GeoJSON format) by LIDAR tiles (tiling file)
251
+ """Add points with Z value by LIDAR tiles (tiling file)
133
252
 
134
253
  Args:
135
- input_geojson (str): Path to the input GeoJSON file with 3D points.
254
+ input_geometry (str): Path to the input geometry file (GeoJSON or Shapefile) with 3D points.
136
255
  input_las (str): Path to the LIDAR `.las/.laz` file.
137
256
  output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
138
257
  virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
139
258
  spatial_ref (str): CRS of the data.
140
259
  tile_width (int): Width of the tile in meters (default: 1000).
260
+ spacing (float): Spacing between generated points in meters.
261
+ altitude_column (str): Altitude column name from input geometry
141
262
 
142
263
  Raises:
143
264
  RuntimeError: If the input LAS file has no valid EPSG code.
265
+ ValueError: Unsupported geometry type in the input Geometry file OR
266
+ The parameters "spacing" <= 0.
144
267
  """
145
268
  if not spatial_ref:
146
269
  spatial_ref = get_epsg_from_las(input_las)
147
270
  if spatial_ref is None:
148
271
  raise RuntimeError(f"LAS file {input_las} does not have a valid EPSG code.")
149
272
 
273
+ # Read the input GeoJSON
274
+ gdf = gpd.read_file(input_geometry)
275
+
276
+ if gdf.crs is None:
277
+ gdf.set_crs(epsg=spatial_ref, inplace=True)
278
+
279
+ # Check if both Z in geometries and altitude_column are provided
280
+ if gdf.geometry.has_z.any() and altitude_column:
281
+ raise ValueError("Both Z in geometries and altitude_column are provided. Please provide only one.")
282
+
283
+ # Store the unique geometry type in a variable
284
+ unique_geom_type = gdf.geometry.geom_type.unique()
285
+
286
+ # Check the geometry type
287
+ if len(unique_geom_type) != 1:
288
+ raise ValueError("Several geometry types found in geometry file. This case is not handled.")
289
+
290
+ if unique_geom_type in ["Point", "MultiPoint"]:
291
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
292
+ if altitude_column:
293
+ # Add the Z dimension from the 'RecupZ' property
294
+ gdf["geometry"] = gdf.apply(
295
+ lambda row: Point(row["geometry"].x, row["geometry"].y, row[altitude_column]), axis=1
296
+ )
297
+ # If the geometry type is Point, use the points directly
298
+ points_gdf = gdf[["geometry"]]
299
+ else:
300
+ # Add the Z dimension from the Geometry Z
301
+ gdf["geometry"] = gdf.apply(
302
+ lambda row: Point(row["geometry"].x, row["geometry"].y, row["geometry"].z), axis=1
303
+ )
304
+ # If the geometry type is Point, use the points directly
305
+ points_gdf = gdf[["geometry"]]
306
+ elif unique_geom_type in ["LineString", "MultiLineString"]:
307
+ gdf = gdf.explode(index_parts=False).reset_index(drop=True)
308
+ gdf = clip_3d_lines_to_tile(gdf, input_las, spatial_ref, tile_width)
309
+ # If the geometry type is LineString, generate 3D points
310
+ if spacing <= 0:
311
+ raise NotImplementedError(
312
+ f"add_points_from_geometry_to_las requires spacing > 0 to run on (Multi)LineString geometries, \
313
+ but the values provided are geometry type: {unique_geom_type} and spacing = {spacing} "
314
+ )
315
+ else:
316
+ points_gdf = generate_3d_points_from_lines(gdf, spacing, altitude_column)
317
+ else:
318
+ raise ValueError("Unsupported geometry type in the input Geometry file.")
319
+
150
320
  # Clip points from GeoJSON by LIDAR tile
151
- points_clipped = clip_3d_points_to_tile(input_geojson, input_las, spatial_ref, tile_width)
321
+ points_clipped = clip_3d_points_to_tile(points_gdf, input_las, spatial_ref, tile_width)
152
322
 
153
323
  # Add points by LIDAR tile and save the result
154
324
  add_points_to_las(points_clipped, input_las, output_las, spatial_ref, virtual_points_classes)
@@ -156,4 +326,4 @@ def add_points_from_geojson_to_las(
156
326
 
157
327
  if __name__ == "__main__":
158
328
  args = parse_args()
159
- add_points_from_geojson_to_las(**vars(args))
329
+ add_points_from_geometry_to_las(**vars(args))