ign-pdal-tools 1.7.9__tar.gz → 1.7.11__tar.gz

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.
Files changed (36) hide show
  1. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/PKG-INFO +3 -2
  2. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/ign_pdal_tools.egg-info/PKG-INFO +3 -2
  3. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/_version.py +1 -1
  4. ign_pdal_tools-1.7.11/pdaltools/add_points_in_pointcloud.py +329 -0
  5. ign_pdal_tools-1.7.11/test/test_add_points_in_pointcloud.py +332 -0
  6. ign_pdal_tools-1.7.9/pdaltools/add_points_in_pointcloud.py +0 -159
  7. ign_pdal_tools-1.7.9/test/test_add_points_in_pointcloud.py +0 -117
  8. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/LICENSE.md +0 -0
  9. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/README.md +0 -0
  10. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/ign_pdal_tools.egg-info/SOURCES.txt +0 -0
  11. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/ign_pdal_tools.egg-info/dependency_links.txt +0 -0
  12. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/ign_pdal_tools.egg-info/top_level.txt +0 -0
  13. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/add_points_in_las.py +0 -0
  14. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/color.py +0 -0
  15. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/las_add_buffer.py +0 -0
  16. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/las_clip.py +0 -0
  17. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/las_info.py +0 -0
  18. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/las_merge.py +0 -0
  19. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/las_remove_dimensions.py +0 -0
  20. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/pcd_info.py +0 -0
  21. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/replace_attribute_in_las.py +0 -0
  22. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/standardize_format.py +0 -0
  23. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pdaltools/unlock_file.py +0 -0
  24. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/pyproject.toml +0 -0
  25. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/setup.cfg +0 -0
  26. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_add_points_in_las.py +0 -0
  27. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_color.py +0 -0
  28. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_las_add_buffer.py +0 -0
  29. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_las_clip.py +0 -0
  30. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_las_info.py +0 -0
  31. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_las_merge.py +0 -0
  32. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_las_remove_dimensions.py +0 -0
  33. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_pcd_info.py +0 -0
  34. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_replace_attribute_in_las.py +0 -0
  35. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_standardize_format.py +0 -0
  36. {ign_pdal_tools-1.7.9 → ign_pdal_tools-1.7.11}/test/test_unlock.py +0 -0
@@ -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,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,4 +1,4 @@
1
- __version__ = "1.7.9"
1
+ __version__ = "1.7.11"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -0,0 +1,329 @@
1
+ import argparse
2
+ from shutil import copy2
3
+
4
+ import geopandas as gpd
5
+ import laspy
6
+ import numpy as np
7
+ from pyproj import CRS
8
+ from pyproj.exceptions import CRSError
9
+ from shapely.geometry import MultiPoint, Point, box
10
+
11
+ from pdaltools.las_info import get_epsg_from_las, get_tile_bbox
12
+
13
+
14
+ def parse_args(argv=None):
15
+ parser = argparse.ArgumentParser("Add points from GeoJSON in LIDAR tile")
16
+ parser.add_argument(
17
+ "--input_geometry", "-ig", type=str, required=True, help="Input Geometry file (GeoJSON or Shapefile)"
18
+ )
19
+ parser.add_argument("--input_las", "-i", type=str, required=True, help="Input las file")
20
+ parser.add_argument("--output_las", "-o", type=str, required=True, default="", help="Output las file")
21
+ parser.add_argument(
22
+ "--virtual_points_classes",
23
+ "-c",
24
+ type=int,
25
+ default=66,
26
+ help="classification value to assign to the added virtual points",
27
+ )
28
+ parser.add_argument(
29
+ "--spatial_ref",
30
+ type=str,
31
+ required=False,
32
+ help="spatial reference for the writer",
33
+ )
34
+ parser.add_argument(
35
+ "--tile_width",
36
+ type=int,
37
+ default=1000,
38
+ help="width of tiles in meters",
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
+ )
54
+
55
+ return parser.parse_args(argv)
56
+
57
+
58
+ def clip_3d_lines_to_tile(
59
+ input_lines: gpd.GeoDataFrame, input_las: str, crs: str, tile_width: int = 1000
60
+ ) -> gpd.GeoDataFrame:
61
+ """
62
+ Select lines from a GeoDataFrame that intersect the the LIDAR tile.
63
+
64
+ Args:
65
+ input_lines (gpd.GeoDataFrame): GeoDataFrame with lines.
66
+ input_las (str): Path to the LIDAR `.las/.laz` file.
67
+ crs (str): CRS of the data.
68
+ tile_width (int): Width of the tile in meters (default: 1000).
69
+
70
+ Returns:
71
+ gpd.GeoDataFrame: Lines that intersect with the tile.
72
+ """
73
+ # Compute the bounding box of the LIDAR tile
74
+ tile_bbox = get_tile_bbox(input_las, tile_width)
75
+
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)
105
+
106
+ if crs:
107
+ input_points = input_points.to_crs(crs)
108
+
109
+ # Create a polygon from the bounding box
110
+ bbox_polygon = box(*tile_bbox)
111
+
112
+ # Clip the points to the bounding box
113
+ clipped_points = input_points[input_points.intersects(bbox_polygon)]
114
+
115
+ return clipped_points
116
+
117
+
118
+ def add_points_to_las(
119
+ input_points_with_z: gpd.GeoDataFrame, input_las: str, output_las: str, crs: str, virtual_points_classes: int = 66
120
+ ):
121
+ """Add points (3D points in LAZ format) by LIDAR tiles (tiling file)
122
+
123
+ Args:
124
+ input_points_with_z (gpd.GeoDataFrame): geometry columns (3D points) as encoded to WKT.
125
+ input_las (str): Path to the LIDAR tiles (LAZ).
126
+ output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
127
+ crs (str): CRS of the data.
128
+ virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
129
+ """
130
+ if input_points_with_z.empty:
131
+ print(
132
+ "No points to add. All points of the geojson file are outside the tile. Copying the input file to output"
133
+ )
134
+ return
135
+
136
+ # Extract XYZ coordinates and additional attribute (classification)
137
+ nb_points = len(input_points_with_z.geometry.x)
138
+ x_coords = input_points_with_z.geometry.x
139
+ y_coords = input_points_with_z.geometry.y
140
+ z_coords = input_points_with_z.geometry.z
141
+ classes = virtual_points_classes * np.ones(nb_points)
142
+
143
+ # Open the input LAS file to check and possibly update the header of the output
144
+ with laspy.open(input_las) as las:
145
+ header = las.header
146
+ if not header:
147
+ header = laspy.LasHeader(point_format=8, version="1.4")
148
+ if crs:
149
+ try:
150
+ crs_obj = CRS.from_user_input(crs) # Convert to a pyproj.CRS object
151
+ except CRSError:
152
+ raise ValueError(f"Invalid CRS: {crs}")
153
+ header.add_crs(crs_obj)
154
+
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)
168
+
169
+ output_las.append_points(new_points)
170
+
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])
195
+
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,
250
+ ):
251
+ """Add points with Z value by LIDAR tiles (tiling file)
252
+
253
+ Args:
254
+ input_geometry (str): Path to the input geometry file (GeoJSON or Shapefile) with 3D points.
255
+ input_las (str): Path to the LIDAR `.las/.laz` file.
256
+ output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
257
+ virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
258
+ spatial_ref (str): CRS of the data.
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
262
+
263
+ Raises:
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.
267
+ """
268
+ if not spatial_ref:
269
+ spatial_ref = get_epsg_from_las(input_las)
270
+ if spatial_ref is None:
271
+ raise RuntimeError(f"LAS file {input_las} does not have a valid EPSG code.")
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
+
320
+ # Clip points from GeoJSON by LIDAR tile
321
+ points_clipped = clip_3d_points_to_tile(points_gdf, input_las, spatial_ref, tile_width)
322
+
323
+ # Add points by LIDAR tile and save the result
324
+ add_points_to_las(points_clipped, input_las, output_las, spatial_ref, virtual_points_classes)
325
+
326
+
327
+ if __name__ == "__main__":
328
+ args = parse_args()
329
+ add_points_from_geometry_to_las(**vars(args))
@@ -0,0 +1,332 @@
1
+ import inspect
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import geopandas as gpd
6
+ import laspy
7
+ import numpy as np
8
+ import pytest
9
+ from shapely.geometry import LineString, MultiPoint, Point
10
+
11
+ from pdaltools import add_points_in_pointcloud
12
+ from pdaltools.count_occurences.count_occurences_for_attribute import (
13
+ compute_count_one_file,
14
+ )
15
+
16
+ TEST_PATH = os.path.dirname(os.path.abspath(__file__))
17
+ TMP_PATH = os.path.join(TEST_PATH, "tmp/add_points_in_pointcloud")
18
+ DATA_LIDAR_PATH = os.path.join(TEST_PATH, "data/decimated_laz")
19
+ DATA_POINTS_3D_PATH = os.path.join(TEST_PATH, "data/points_3d")
20
+ DATA_LIGNES_PATH = os.path.join(TEST_PATH, "data/lignes_3d")
21
+
22
+ INPUT_PCD = os.path.join(DATA_LIDAR_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
23
+ INPUT_POINTS_2D = os.path.join(DATA_POINTS_3D_PATH, "Points_virtuels_2d_with_value_z_0292_6833.geojson")
24
+ INPUT_POINTS_3D = os.path.join(DATA_POINTS_3D_PATH, "Points_virtuels_0292_6833.geojson")
25
+ INPUT_LIGNES_2D_GEOJSON = os.path.join(DATA_LIGNES_PATH, "Lignes_2d_0292_6833.geojson")
26
+ INPUT_LIGNES_3D_GEOJSON = os.path.join(DATA_LIGNES_PATH, "Lignes_3d_0292_6833.geojson")
27
+ INPUT_LIGNES_SHAPE = os.path.join(DATA_LIGNES_PATH, "Lignes_3d_0292_6833.shp")
28
+ OUTPUT_FILE = os.path.join(TMP_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
29
+
30
+ # Cropped las tile used to test adding points that belong to the theorical tile but not to the
31
+ # effective las file extent
32
+ INPUT_PCD_CROPPED = os.path.join(DATA_LIDAR_PATH, "test_semis_2021_0382_6565_LA93_IGN69_cropped.laz")
33
+ INPUT_POINTS_2D_FOR_CROPPED_PCD = os.path.join(
34
+ DATA_POINTS_3D_PATH, "Points_virtuels_2d_with_value_z_0382_6565.geojson"
35
+ )
36
+ INPUT_POINTS_3D_FOR_CROPPED_PCD = os.path.join(DATA_POINTS_3D_PATH, "Points_virtuels_0382_6565.geojson")
37
+ OUTPUT_FILE_CROPPED_PCD = os.path.join(TMP_PATH, "test_semis_2021_0382_6565_LA93_IGN69.laz")
38
+
39
+
40
+ def setup_module(module):
41
+ os.makedirs(TMP_PATH, exist_ok=True)
42
+
43
+
44
+ @pytest.mark.parametrize(
45
+ "input_file, epsg",
46
+ [
47
+ (INPUT_POINTS_2D, "EPSG:2154"), # should work when providing an epsg value + GeoJSON POINTS 2D
48
+ (INPUT_POINTS_3D, "EPSG:2154"), # should work when providing an epsg value + GeoJSON POINTS 3D
49
+ (INPUT_POINTS_2D, None), # Should also work with no epsg value (get from las file) + GeoJSON POINTS 2D
50
+ (INPUT_POINTS_3D, None), # Should also work with no epsg value (get from las file) + GeoJSON POINTS 3D
51
+ ],
52
+ )
53
+ def test_clip_3d_points_to_tile(input_file, epsg):
54
+ points_input = gpd.read_file(input_file)
55
+ points_clipped = add_points_in_pointcloud.clip_3d_points_to_tile(points_input, INPUT_PCD, epsg, 1000)
56
+ assert len(points_clipped) == 678 # check the entity's number of points
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "input_file, epsg",
61
+ # Test on the same geomtries contained in various geometry formats
62
+ [
63
+ (INPUT_LIGNES_SHAPE, "EPSG:2154"), # should work when providing an epsg value + shapefile
64
+ (INPUT_LIGNES_2D_GEOJSON, "EPSG:2154"), # should work when providing an epsg value + GeoJSON 2D
65
+ (INPUT_LIGNES_3D_GEOJSON, "EPSG:2154"), # should work when providing an epsg value + GeoJSON 3D
66
+ (INPUT_LIGNES_SHAPE, None), # Should also work with no epsg value (get from las file) + shapefile
67
+ (INPUT_LIGNES_2D_GEOJSON, None), # Should also work with no epsg value (get from las file) + GeoJSON 2D
68
+ (INPUT_LIGNES_3D_GEOJSON, None), # Should also work with no epsg value (get from las file) + GeoJSON 3D
69
+ ],
70
+ )
71
+ def test_clip_3d_lines_to_tile(input_file, epsg):
72
+ # With lines contained in the LIDAR tile
73
+ lines_input = gpd.read_file(input_file)
74
+ lines_clipped = add_points_in_pointcloud.clip_3d_lines_to_tile(lines_input, INPUT_PCD, epsg, 1000)
75
+ assert len(lines_clipped) == 22 # check the entity's number of lines
76
+
77
+ # Without lines contained in the LIDAR tile
78
+ lines_input = gpd.read_file(input_file)
79
+ lines_clipped = add_points_in_pointcloud.clip_3d_lines_to_tile(lines_input, INPUT_PCD_CROPPED, epsg, 1000)
80
+ assert len(lines_clipped) == 0 # check the entity's number of lines
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "input_file, epsg, expected_nb_points",
85
+ [
86
+ (INPUT_PCD, "EPSG:2154", 2423), # should work when providing an epsg value
87
+ (INPUT_PCD, None, 2423), # Should also work with no epsg value (get from las file)
88
+ (INPUT_PCD_CROPPED, None, 2423),
89
+ ],
90
+ )
91
+ def test_add_points_to_las(input_file, epsg, expected_nb_points):
92
+ # Ensure the output file doesn't exist before the test
93
+ if Path(OUTPUT_FILE).exists():
94
+ os.remove(OUTPUT_FILE)
95
+
96
+ points = gpd.read_file(INPUT_POINTS_2D)
97
+ add_points_in_pointcloud.add_points_to_las(points, input_file, OUTPUT_FILE, epsg, 68)
98
+ assert Path(OUTPUT_FILE).exists() # check output exists
99
+
100
+ point_count = compute_count_one_file(OUTPUT_FILE)["68"]
101
+ assert point_count == expected_nb_points # Add all points from geojson
102
+
103
+ # Read original and updated point clouds
104
+ original_las = laspy.read(input_file)
105
+ updated_las = laspy.read(OUTPUT_FILE)
106
+
107
+ original_count = len(original_las.points)
108
+ added_count = len(points)
109
+
110
+ # Check total point count
111
+ assert len(updated_las.points) == original_count + added_count
112
+
113
+ default_zero_fields = [
114
+ "gps_time",
115
+ "intensity",
116
+ "return_number",
117
+ "number_of_returns",
118
+ "scan_direction_flag",
119
+ "edge_of_flight_line",
120
+ "R",
121
+ "G",
122
+ "B",
123
+ ]
124
+ # Ensure original points retain their values (gps_tme, intensity, etc)
125
+ for field in default_zero_fields:
126
+ if hasattr(updated_las, field):
127
+ values = getattr(updated_las, field)
128
+ original_values = getattr(original_las, field)
129
+ assert np.all(values[:original_count] == original_values[:original_count])
130
+
131
+ # Ensure added points have zero values for gps_time, intensity, etc
132
+ for field in default_zero_fields:
133
+ if hasattr(updated_las, field):
134
+ values = getattr(updated_las, field)
135
+ assert np.all(values[original_count:] == 0)
136
+
137
+
138
+ @pytest.mark.parametrize(
139
+ "line, spacing, z_value, expected_points",
140
+ [
141
+ # End point is a multiple of spacing, z_value is provided
142
+ (
143
+ LineString([(0, 0), (4, 0)]),
144
+ 2,
145
+ 0.5,
146
+ [
147
+ Point(0, 0, 0.5),
148
+ Point(2, 0, 0.5),
149
+ Point(4, 0, 0.5),
150
+ ],
151
+ ),
152
+ # End point is not a multiple of spacing, z_value is provided
153
+ (
154
+ LineString([(9, 0), (9, 9)]),
155
+ 5,
156
+ 0.5,
157
+ [Point(9, 0, 0.5), Point(9, 5, 0.5), Point(9, 9, 0.5)],
158
+ ),
159
+ # End point is not a multiple of spacing, z_value is provided in point.z instead of z_value
160
+ (
161
+ LineString([(0, 0, 1), (4, 0, 1)]),
162
+ 2,
163
+ 0.5,
164
+ [
165
+ Point(0, 0, 1),
166
+ Point(2, 0, 1),
167
+ Point(4, 0, 1),
168
+ ],
169
+ ),
170
+ ],
171
+ )
172
+ def test_line_to_multipoint(line, spacing, z_value, expected_points):
173
+ multipoint = add_points_in_pointcloud.line_to_multipoint(line, spacing, z_value)
174
+ assert multipoint.equals(MultiPoint(expected_points))
175
+
176
+
177
+ @pytest.mark.parametrize(
178
+ "lines_gdf, spacing, altitude_column, expected_points",
179
+ [
180
+ # Test case for 2D lines with Z values in "RecupZ"
181
+ (
182
+ gpd.GeoDataFrame(
183
+ {"geometry": [LineString([(0, 0), (10, 0)]), LineString([(10, 0), (10, 10)])], "RecupZ": [5.0, 10.0]},
184
+ crs="EPSG:2154",
185
+ ),
186
+ 2.5,
187
+ "RecupZ",
188
+ [
189
+ Point(0, 0, 5.0),
190
+ Point(2.5, 0, 5.0),
191
+ Point(5.0, 0, 5.0),
192
+ Point(7.5, 0, 5.0),
193
+ Point(10, 0, 5.0),
194
+ Point(10, 2.5, 10.0),
195
+ Point(10, 5.0, 10.0),
196
+ Point(10, 7.5, 10.0),
197
+ Point(10, 10, 10.0),
198
+ ],
199
+ ),
200
+ # Test case for 3D lines
201
+ (
202
+ gpd.GeoDataFrame(
203
+ {"geometry": [LineString([(0, 0, 3), (10, 0, 3)]), LineString([(10, 0, 6), (10, 10, 6)])]},
204
+ crs="EPSG:2154",
205
+ ),
206
+ 2.5,
207
+ None,
208
+ [
209
+ Point(0, 0, 3.0),
210
+ Point(2.5, 0, 3.0),
211
+ Point(5.0, 0, 3.0),
212
+ Point(7.5, 0, 3.0),
213
+ Point(10, 0, 3.0),
214
+ Point(10, 2.5, 6.0),
215
+ Point(10, 5.0, 6.0),
216
+ Point(10, 7.5, 6.0),
217
+ Point(10, 10, 6.0),
218
+ ],
219
+ ),
220
+ ],
221
+ )
222
+ def test_generate_3d_points_from_lines(lines_gdf, spacing, altitude_column, expected_points):
223
+ points_gdf = add_points_in_pointcloud.generate_3d_points_from_lines(lines_gdf, spacing, altitude_column)
224
+
225
+ # Check the result
226
+ assert points_gdf.geometry.tolist() == expected_points
227
+
228
+
229
+ @pytest.mark.parametrize(
230
+ "input_file, input_points, epsg, expected_nb_points, spacing, altitude_column",
231
+ [
232
+ (INPUT_PCD, INPUT_POINTS_2D, "EPSG:2154", 678, 0, "RecupZ"), # should add only points 2.5D within tile extent
233
+ (INPUT_PCD, INPUT_POINTS_3D, "EPSG:2154", 678, 0, None), # should add only points 3D within tile extent
234
+ (INPUT_PCD_CROPPED, INPUT_POINTS_3D_FOR_CROPPED_PCD, "EPSG:2154", 186, 0, None),
235
+ (INPUT_PCD_CROPPED, INPUT_POINTS_2D_FOR_CROPPED_PCD, "EPSG:2154", 186, 0, "RecupZ"),
236
+ (
237
+ INPUT_PCD,
238
+ INPUT_LIGNES_2D_GEOJSON,
239
+ "EPSG:2154",
240
+ 678,
241
+ 0.25,
242
+ "RecupZ",
243
+ ), # should add only lines (.GeoJSON) within tile extend
244
+ (
245
+ INPUT_PCD,
246
+ INPUT_LIGNES_SHAPE,
247
+ "EPSG:2154",
248
+ 678,
249
+ 0.25,
250
+ "RecupZ",
251
+ ), # should add only lignes (.shp) within tile extend
252
+ (INPUT_PCD, INPUT_LIGNES_SHAPE, None, 678, 0.25, "RecupZ"), # Should work with or with an input epsg
253
+ (
254
+ INPUT_PCD,
255
+ INPUT_LIGNES_3D_GEOJSON,
256
+ None,
257
+ 678,
258
+ 0.25,
259
+ None,
260
+ ), # Should work with or without an input epsg and without altitude_column
261
+ ],
262
+ )
263
+ def test_add_points_from_geometry_to_las(input_file, input_points, epsg, expected_nb_points, spacing, altitude_column):
264
+ # Ensure the output file doesn't exist before the test
265
+ if Path(OUTPUT_FILE).exists():
266
+ os.remove(OUTPUT_FILE)
267
+
268
+ add_points_in_pointcloud.add_points_from_geometry_to_las(
269
+ input_points, input_file, OUTPUT_FILE, 68, epsg, 1000, spacing, altitude_column
270
+ )
271
+ assert Path(OUTPUT_FILE).exists() # check output exists
272
+ point_count = compute_count_one_file(OUTPUT_FILE)["68"]
273
+ assert point_count == expected_nb_points # Add all points from geojson
274
+
275
+
276
+ @pytest.mark.parametrize(
277
+ "input_file, input_points, epsg, spacing, altitude_column",
278
+ [
279
+ (INPUT_PCD, INPUT_LIGNES_SHAPE, None, 0, "RecupZ"), # spacing <= 0
280
+ (INPUT_PCD, INPUT_LIGNES_SHAPE, None, -5, "RecupZ"), # spacing < 0
281
+ (
282
+ INPUT_PCD,
283
+ INPUT_LIGNES_3D_GEOJSON,
284
+ None,
285
+ 0,
286
+ None,
287
+ ), # spacing <= 0
288
+ ],
289
+ )
290
+ def test_add_points_from_geometry_to_las_nok(input_file, input_points, epsg, spacing, altitude_column):
291
+ # Ensure the output file doesn't exist before the test
292
+ if Path(OUTPUT_FILE).exists():
293
+ os.remove(OUTPUT_FILE)
294
+
295
+ with pytest.raises(NotImplementedError, match=".*LineString.*spacing.*"):
296
+ add_points_in_pointcloud.add_points_from_geometry_to_las(
297
+ input_points,
298
+ input_file,
299
+ OUTPUT_FILE,
300
+ 68,
301
+ epsg,
302
+ 1000,
303
+ spacing,
304
+ altitude_column,
305
+ )
306
+
307
+
308
+ def test_parse_args():
309
+ # sanity check for arguments parsing
310
+ args = add_points_in_pointcloud.parse_args(
311
+ [
312
+ "--input_geometry",
313
+ "data/points_3d/Points_virtuels_0292_6833.geojson",
314
+ "--input_las",
315
+ "data/decimated_laz/test_semis_2023_0292_6833_LA93_IGN69.laz",
316
+ "--output_las",
317
+ "data/output/test_semis_2023_0292_6833_LA93_IGN69.laz",
318
+ "--virtual_points_classes",
319
+ "68",
320
+ "--spatial_ref",
321
+ "EPSG:2154",
322
+ "--tile_width",
323
+ "1000",
324
+ "--spacing",
325
+ "0",
326
+ "--altitude_column",
327
+ "RecupZ",
328
+ ]
329
+ )
330
+ parsed_args_keys = args.__dict__.keys()
331
+ main_parameters = inspect.signature(add_points_in_pointcloud.add_points_from_geometry_to_las).parameters.keys()
332
+ assert set(parsed_args_keys) == set(main_parameters)
@@ -1,159 +0,0 @@
1
- import argparse
2
- import shutil
3
-
4
- import geopandas as gpd
5
- import laspy
6
- import numpy as np
7
- from pyproj import CRS
8
- from pyproj.exceptions import CRSError
9
- from shapely.geometry import box
10
-
11
- from pdaltools.las_info import get_epsg_from_las, get_tile_bbox
12
-
13
-
14
- def parse_args(argv=None):
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")
17
- parser.add_argument("--input_las", "-i", type=str, required=True, help="Input las file")
18
- parser.add_argument("--output_las", "-o", type=str, required=True, default="", help="Output las file")
19
- parser.add_argument(
20
- "--virtual_points_classes",
21
- "-c",
22
- type=int,
23
- default=66,
24
- help="classification value to assign to the added virtual points",
25
- )
26
- parser.add_argument(
27
- "--spatial_ref",
28
- type=str,
29
- required=False,
30
- help="spatial reference for the writer",
31
- )
32
- parser.add_argument(
33
- "--tile_width",
34
- type=int,
35
- default=1000,
36
- help="width of tiles in meters",
37
- )
38
-
39
- return parser.parse_args(argv)
40
-
41
-
42
- def clip_3d_points_to_tile(input_points: str, input_las: str, crs: str, tile_width: int) -> gpd.GeoDataFrame:
43
- """
44
- Add points from a GeoJSON file in the LIDAR's tile.
45
-
46
- Args:
47
- input_points (str): Path to the input GeoJSON file with 3D points.
48
- input_las (str): Path to the LIDAR `.las/.laz` file.
49
- crs (str): CRS of the data.
50
- tile_width (int): Width of the tile in meters (default: 1000).
51
-
52
- Return:
53
- gpd.GeoDataFrame: Points 2d with "Z" value
54
- """
55
- # Compute the bounding box of the LIDAR tile
56
- tile_bbox = get_tile_bbox(input_las, tile_width)
57
-
58
- # Read the input GeoJSON with 3D points
59
- points_gdf = gpd.read_file(input_points)
60
-
61
- if crs:
62
- points_gdf = points_gdf.to_crs(crs)
63
-
64
- # Create a polygon from the bounding box
65
- bbox_polygon = box(*tile_bbox)
66
-
67
- # Clip the points to the bounding box
68
- clipped_points = points_gdf[points_gdf.intersects(bbox_polygon)].copy()
69
-
70
- return clipped_points
71
-
72
-
73
- def add_points_to_las(
74
- input_points_with_z: gpd.GeoDataFrame, input_las: str, output_las: str, crs: str, virtual_points_classes=66
75
- ):
76
- """Add points (3D points in LAZ format) by LIDAR tiles (tiling file)
77
-
78
- Args:
79
- input_points_with_z(gpd.GeoDataFrame): geometry columns (2D points) as encoded to WKT.
80
- input_las (str): Path to the LIDAR tiles (LAZ).
81
- output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
82
- crs (str): CRS of the data.
83
- virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
84
- """
85
-
86
- if input_points_with_z.empty:
87
- print(
88
- "No points to add. All points of the geojson file are outside the tile. Copying the input file to output"
89
- )
90
- shutil.copy(input_las, output_las)
91
-
92
- return
93
-
94
- # Extract XYZ coordinates and additional attribute (classification)
95
- x_coords = input_points_with_z.geometry.x
96
- 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))
99
-
100
- with laspy.open(input_las, mode="r") as las:
101
- las_data = las.read()
102
- header = las.header
103
-
104
- if not header:
105
- header = laspy.LasHeader(point_format=8, version="1.4")
106
- if crs:
107
- try:
108
- crs_obj = CRS.from_user_input(crs) # Convert to a pyproj.CRS object
109
- except CRSError:
110
- raise ValueError(f"Invalid CRS: {crs}")
111
- header.add_crs(crs_obj)
112
-
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])
118
-
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
124
-
125
- with laspy.open(output_las, mode="w", header=header, do_compress=True) as writer:
126
- writer.write_points(updated_las.points)
127
-
128
-
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
131
- ):
132
- """Add points with Z value(GeoJSON format) by LIDAR tiles (tiling file)
133
-
134
- Args:
135
- input_geojson (str): Path to the input GeoJSON file with 3D points.
136
- input_las (str): Path to the LIDAR `.las/.laz` file.
137
- output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
138
- virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
139
- spatial_ref (str): CRS of the data.
140
- tile_width (int): Width of the tile in meters (default: 1000).
141
-
142
- Raises:
143
- RuntimeError: If the input LAS file has no valid EPSG code.
144
- """
145
- if not spatial_ref:
146
- spatial_ref = get_epsg_from_las(input_las)
147
- if spatial_ref is None:
148
- raise RuntimeError(f"LAS file {input_las} does not have a valid EPSG code.")
149
-
150
- # Clip points from GeoJSON by LIDAR tile
151
- points_clipped = clip_3d_points_to_tile(input_geojson, input_las, spatial_ref, tile_width)
152
-
153
- # Add points by LIDAR tile and save the result
154
- add_points_to_las(points_clipped, input_las, output_las, spatial_ref, virtual_points_classes)
155
-
156
-
157
- if __name__ == "__main__":
158
- args = parse_args()
159
- add_points_from_geojson_to_las(**vars(args))
@@ -1,117 +0,0 @@
1
- import inspect
2
- import os
3
- from pathlib import Path
4
-
5
- import geopandas as gpd
6
- import pytest
7
-
8
- from pdaltools import add_points_in_pointcloud
9
- from pdaltools.count_occurences.count_occurences_for_attribute import (
10
- compute_count_one_file,
11
- )
12
-
13
- TEST_PATH = os.path.dirname(os.path.abspath(__file__))
14
- TMP_PATH = os.path.join(TEST_PATH, "tmp/add_points_in_pointcloud")
15
- DATA_LIDAR_PATH = os.path.join(TEST_PATH, "data/decimated_laz")
16
- DATA_POINTS_PATH = os.path.join(TEST_PATH, "data/points_3d")
17
-
18
- INPUT_PCD = os.path.join(DATA_LIDAR_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
19
- INPUT_POINTS = os.path.join(DATA_POINTS_PATH, "Points_virtuels_0292_6833.geojson")
20
- OUTPUT_FILE = os.path.join(TMP_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
21
-
22
- # Cropped las tile used to test adding points that belong to the theorical tile but not to the
23
- # effective las file extent
24
- INPUT_PCD_CROPPED = os.path.join(DATA_LIDAR_PATH, "test_semis_2021_0382_6565_LA93_IGN69_cropped.laz")
25
- INPUT_POINTS_FOR_CROPPED_PCD = os.path.join(DATA_POINTS_PATH, "Points_virtuels_0382_6565.geojson")
26
- OUTPUT_FILE_CROPPED_PCD = os.path.join(TMP_PATH, "test_semis_2021_0382_6565_LA93_IGN69.laz")
27
-
28
-
29
- def setup_module(module):
30
- os.makedirs(TMP_PATH, exist_ok=True)
31
-
32
-
33
- @pytest.mark.parametrize(
34
- "epsg",
35
- [
36
- "EPSG:2154", # should work when providing an epsg value
37
- None, # Should also work with no epsg value (get from las file)
38
- ],
39
- )
40
- def test_clip_3d_points_to_tile(epsg):
41
- # With EPSG
42
- points_clipped = add_points_in_pointcloud.clip_3d_points_to_tile(INPUT_POINTS, INPUT_PCD, epsg, 1000)
43
- assert len(points_clipped) == 678 # check the entity's number of points
44
-
45
-
46
- @pytest.mark.parametrize(
47
- "input_file, epsg, expected_nb_points",
48
- [
49
- (INPUT_PCD, "EPSG:2154", 2423), # should work when providing an epsg value
50
- (INPUT_PCD, None, 2423), # Should also work with no epsg value (get from las file)
51
- (INPUT_PCD_CROPPED, None, 2423),
52
- ],
53
- )
54
- def test_add_points_to_las(input_file, epsg, expected_nb_points):
55
- # Ensure the output file doesn't exist before the test
56
- if Path(OUTPUT_FILE).exists():
57
- os.remove(OUTPUT_FILE)
58
-
59
- points = gpd.read_file(INPUT_POINTS)
60
- add_points_in_pointcloud.add_points_to_las(points, input_file, OUTPUT_FILE, epsg, 68)
61
- assert Path(OUTPUT_FILE).exists() # check output exists
62
-
63
- point_count = compute_count_one_file(OUTPUT_FILE)["68"]
64
- assert point_count == expected_nb_points # Add all points from geojson
65
-
66
-
67
- @pytest.mark.parametrize(
68
- "input_file, input_points, epsg, expected_nb_points",
69
- [
70
- (INPUT_PCD, INPUT_POINTS, None, 678), # should add only points within tile extent
71
- (INPUT_PCD_CROPPED, INPUT_POINTS_FOR_CROPPED_PCD, None, 186),
72
- (
73
- INPUT_PCD_CROPPED,
74
- INPUT_POINTS,
75
- None,
76
- 0,
77
- ), # Should add no points when there is only points outside the tile extent
78
- (
79
- INPUT_PCD_CROPPED,
80
- INPUT_POINTS_FOR_CROPPED_PCD,
81
- "EPSG:2154",
82
- 186,
83
- ), # Should work with or without an input epsg
84
- ],
85
- )
86
- def test_add_points_from_geojson_to_las(input_file, input_points, epsg, expected_nb_points):
87
- # Ensure the output file doesn't exist before the test
88
- if Path(OUTPUT_FILE).exists():
89
- os.remove(OUTPUT_FILE)
90
-
91
- add_points_in_pointcloud.add_points_from_geojson_to_las(input_points, input_file, OUTPUT_FILE, 68, epsg, 1000)
92
- assert Path(OUTPUT_FILE).exists() # check output exists
93
- point_count = compute_count_one_file(OUTPUT_FILE)["68"]
94
- assert point_count == expected_nb_points # Add all points from geojson
95
-
96
-
97
- def test_parse_args():
98
- # sanity check for arguments parsing
99
- args = add_points_in_pointcloud.parse_args(
100
- [
101
- "--input_geojson",
102
- "data/points_3d/Points_virtuels_0292_6833.geojson",
103
- "--input_las",
104
- "data/decimated_laz/test_semis_2023_0292_6833_LA93_IGN69.laz",
105
- "--output_las",
106
- "data/output/test_semis_2023_0292_6833_LA93_IGN69.laz",
107
- "--virtual_points_classes",
108
- "68",
109
- "--spatial_ref",
110
- "EPSG:2154",
111
- "--tile_width",
112
- "1000",
113
- ]
114
- )
115
- parsed_args_keys = args.__dict__.keys()
116
- main_parameters = inspect.signature(add_points_in_pointcloud.add_points_from_geojson_to_las).parameters.keys()
117
- assert set(parsed_args_keys) == set(main_parameters)