ign-pdal-tools 1.7.6__tar.gz → 1.7.8__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.6 → ign_pdal_tools-1.7.8}/PKG-INFO +1 -1
  2. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/PKG-INFO +1 -1
  3. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/_version.py +1 -1
  4. ign_pdal_tools-1.7.8/pdaltools/add_points_in_pointcloud.py +159 -0
  5. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_info.py +44 -1
  6. ign_pdal_tools-1.7.8/test/test_add_points_in_pointcloud.py +117 -0
  7. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_info.py +35 -0
  8. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_unlock.py +4 -0
  9. ign_pdal_tools-1.7.6/pdaltools/add_points_in_pointcloud.py +0 -102
  10. ign_pdal_tools-1.7.6/test/test_add_points_in_pointcloud.py +0 -82
  11. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/LICENSE.md +0 -0
  12. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/README.md +0 -0
  13. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/SOURCES.txt +0 -0
  14. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/dependency_links.txt +0 -0
  15. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/top_level.txt +0 -0
  16. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/add_points_in_las.py +0 -0
  17. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/color.py +0 -0
  18. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_add_buffer.py +0 -0
  19. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_clip.py +0 -0
  20. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_merge.py +0 -0
  21. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_remove_dimensions.py +0 -0
  22. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/pcd_info.py +0 -0
  23. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/replace_attribute_in_las.py +0 -0
  24. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/standardize_format.py +0 -0
  25. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/unlock_file.py +0 -0
  26. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pyproject.toml +0 -0
  27. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/setup.cfg +0 -0
  28. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_add_points_in_las.py +0 -0
  29. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_color.py +0 -0
  30. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_add_buffer.py +0 -0
  31. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_clip.py +0 -0
  32. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_merge.py +0 -0
  33. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_remove_dimensions.py +0 -0
  34. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_pcd_info.py +0 -0
  35. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_replace_attribute_in_las.py +0 -0
  36. {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_standardize_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ign-pdal-tools
3
- Version: 1.7.6
3
+ Version: 1.7.8
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: ign-pdal-tools
3
- Version: 1.7.6
3
+ Version: 1.7.8
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
@@ -1,4 +1,4 @@
1
- __version__ = "1.7.6"
1
+ __version__ = "1.7.8"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -0,0 +1,159 @@
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,8 +1,10 @@
1
1
  import json
2
2
  import logging
3
3
  import os
4
+ from pathlib import Path
4
5
  from typing import Dict, Tuple
5
6
 
7
+ import laspy
6
8
  import osgeo.osr as osr
7
9
  import pdal
8
10
 
@@ -50,6 +52,23 @@ def get_tile_origin_using_header_info(filename: str, tile_width: int = 1000) ->
50
52
  return infer_tile_origin(minx, maxx, miny, maxy, tile_width)
51
53
 
52
54
 
55
+ def get_tile_bbox(input_las, tile_width=1000) -> tuple:
56
+ """
57
+ Get the theoretical bounding box (xmin, ymin, xmax, ymax) of a LIDAR tile
58
+ using its origin and the predefined tile width.
59
+
60
+ Args:
61
+ input_las (str): Path to the LIDAR `.las/.laz` file.
62
+ tile_width (int): Width of the tile in meters (default: 1000).
63
+
64
+ Returns:
65
+ tuple: Bounding box as (xmin, ymin, xmax, ymax).
66
+ """
67
+ origin_x, origin_y = get_tile_origin_using_header_info(input_las, tile_width)
68
+ bbox = (origin_x, origin_y - tile_width, origin_x + tile_width, origin_y)
69
+ return bbox
70
+
71
+
53
72
  def get_epsg_from_header_info(metadata):
54
73
  if "srs" not in metadata.keys():
55
74
  raise RuntimeError("EPSG could not be inferred from metadata: No 'srs' key in metadata.")
@@ -142,7 +161,14 @@ def parse_filename(file: str):
142
161
  For example Semis_2021_0000_1111_LA93_IGN69.las"""
143
162
  basename = os.path.basename(file) # Make sure that we work on the base name and not the full path
144
163
 
145
- prefix1, prefix2, coordx, coordy, suffix = basename.split("_", 4)
164
+ try:
165
+ prefix1, prefix2, coordx, coordy, suffix = basename.split("_", 4)
166
+ except ValueError:
167
+ raise ValueError(
168
+ f"Filename {Path(file).name} does not have the expected format. "
169
+ "Expected prefix1_prefix2_coordx_coordy_suffix"
170
+ )
171
+
146
172
  prefix = f"{prefix1}_{prefix2}"
147
173
 
148
174
  return prefix, int(coordx), int(coordy), suffix
@@ -211,3 +237,20 @@ def get_writer_parameters_from_reader_metadata(metadata: Dict, a_srs=None) -> Di
211
237
  "a_srs": a_srs if a_srs else reader_metadata["comp_spatialreference"],
212
238
  }
213
239
  return params
240
+
241
+
242
+ def get_epsg_from_las(filename: str) -> str:
243
+ """Extract EPSG code from LAS file metadata and return as 'EPSG:XXXX' format.
244
+
245
+ Args:
246
+ filename (str): full path of file for which to get the bounding box
247
+
248
+ Returns:
249
+ str : CRS's value of the data in 'EPSG:XXXX' format, or None if not found.
250
+ """
251
+ with laspy.open(filename) as las:
252
+ crs = las.header.parse_crs()
253
+ if crs is None:
254
+ return None # Return None if CRS is not defined
255
+ epsg_code = crs.to_epsg()
256
+ return f"EPSG:{epsg_code}" if epsg_code else None
@@ -0,0 +1,117 @@
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)
@@ -45,6 +45,27 @@ def test_get_tile_origin_using_header_info():
45
45
  assert (origin_x, origin_y) == (COORD_X * TILE_COORD_SCALE, COORD_Y * TILE_COORD_SCALE)
46
46
 
47
47
 
48
+ @pytest.mark.parametrize(
49
+ "tile_filename, tile_width, expected_bbox",
50
+ [
51
+ ( # Standard 1000m tile width
52
+ os.path.join(DATA_PATH, "decimated_laz", "test_semis_2023_0292_6833_LA93_IGN69.laz"),
53
+ 1000,
54
+ (292000.0, 6832000.0, 293000.0, 6833000.0),
55
+ ),
56
+ (
57
+ # Test 50m tile
58
+ INPUT_FILE,
59
+ 50,
60
+ (INPUT_MINS[0], INPUT_MINS[1], INPUT_MAXS[0], INPUT_MAXS[1]),
61
+ ),
62
+ ],
63
+ )
64
+ def test_get_tile_bbox(tile_filename, tile_width, expected_bbox):
65
+ bbox = las_info.get_tile_bbox(tile_filename, tile_width)
66
+ assert bbox == expected_bbox # check the bbox from LIDAR tile
67
+
68
+
48
69
  def test_get_epsg_from_quickinfo_metadata_ok():
49
70
  metadata = las_info.las_info_metadata(INPUT_FILE)
50
71
  assert las_info.get_epsg_from_header_info(metadata) == "2154"
@@ -136,3 +157,17 @@ def test_get_writer_parameters_from_reader_metadata():
136
157
  out_expected_metadata.pop(key)
137
158
 
138
159
  assert out_metadata == out_expected_metadata
160
+
161
+
162
+ def test_get_epsg_from_las_no_epsg():
163
+ input_file = os.path.join(DATA_PATH, "test_noepsg_043500_629205_IGN69.laz")
164
+
165
+ crs = las_info.get_epsg_from_las(input_file)
166
+ assert crs is None
167
+
168
+
169
+ def test_get_epsg_from_las_with_epsg():
170
+ input_file = os.path.join(DATA_PATH, "test_data_77050_627755_LA93_IGN69.laz")
171
+
172
+ crs = las_info.get_epsg_from_las(input_file)
173
+ assert crs == "EPSG:2154"
@@ -52,6 +52,10 @@ def test_copy_and_hack_decorator_color():
52
52
  assert os.path.isfile(LAS_FILE)
53
53
 
54
54
 
55
+ @pytest.mark.xfail(
56
+ reason="Unlock is deprecated as versions of pdal >= 2.7.2 don't raise an error anymore when GlobalEncoding "
57
+ "is not set correctly for WKT see https://github.com/PDAL/PDAL/releases/tag/2.7.2"
58
+ )
55
59
  def test_unlock_file():
56
60
  TMP_FILE = os.path.join(TMPDIR, "unlock_file.laz")
57
61
  shutil.copy(LAZ_FILE, TMP_FILE)
@@ -1,102 +0,0 @@
1
- import geopandas as gpd
2
- import laspy
3
- import numpy as np
4
- from shapely.geometry import box
5
-
6
- from pdaltools.las_info import get_tile_origin_using_header_info
7
-
8
-
9
- def get_tile_bbox(input_las, tile_width=1000) -> tuple:
10
- """
11
- Get the theoretical bounding box (xmin, ymin, xmax, ymax) of a LIDAR tile
12
- using its origin and the predefined tile width.
13
-
14
- Args:
15
- input_las (str): Path to the LIDAR `.las/.laz` file.
16
- tile_width (int): Width of the tile in meters (default: 1000).
17
-
18
- Returns:
19
- tuple: Bounding box as (xmin, ymin, xmax, ymax).
20
- """
21
- origin_x, origin_y = get_tile_origin_using_header_info(input_las)
22
- bbox = (origin_x, origin_y - tile_width, origin_x + tile_width, origin_y)
23
- return bbox
24
-
25
-
26
- def clip_3d_points_to_tile(input_points: str, input_las: str, crs: str) -> gpd.GeoDataFrame:
27
- """
28
- Add points from a GeoJSON file in the LIDAR's tile.
29
-
30
- Args:
31
- input_points (str): Path to the input GeoJSON file with 3D points.
32
- input_las (str): Path to the LIDAR `.las/.laz` file.
33
- crs (str): CRS of the data, e.g., 'EPSG:2154'.
34
-
35
- Return:
36
- gpd.GeoDataFrame: Points 2d with "Z" value
37
- """
38
- # Compute the bounding box of the LIDAR tile
39
- tile_bbox = get_tile_bbox(input_las)
40
-
41
- # Read the input GeoJSON with 3D points
42
- points_gdf = gpd.read_file(input_points)
43
-
44
- # Ensure the CRS matches
45
- if crs:
46
- points_gdf = points_gdf.to_crs(crs)
47
-
48
- # Create a polygon from the bounding box
49
- bbox_polygon = box(*tile_bbox)
50
-
51
- # Clip the points to the bounding box
52
- clipped_points = points_gdf[points_gdf.intersects(bbox_polygon)].copy()
53
-
54
- return clipped_points
55
-
56
-
57
- def add_points_to_las(
58
- input_points_with_z: gpd.GeoDataFrame, input_las: str, output_las: str, virtual_points_classes=66
59
- ):
60
- """Add points (3D points in LAZ format) by LIDAR tiles (tiling file)
61
-
62
- Args:
63
- input_points_with_z(gpd.GeoDataFrame): geometry columns (2D points) as encoded to WKT.
64
- input_las (str): Path to the LIDAR tiles (LAZ).
65
- output_las (str): Path to save the updated LIDAR file (LAS/LAZ format).
66
- virtual_points_classes (int): The classification value to assign to those virtual points (default: 66).
67
- """
68
- # Check if input points are empty
69
- if input_points_with_z.empty:
70
- raise ValueError("No points to add. The input GeoDataFrame is empty.")
71
-
72
- # Extract XYZ coordinates and additional attribute (classification)
73
- x_coords = input_points_with_z.geometry.x
74
- y_coords = input_points_with_z.geometry.y
75
- z_coords = input_points_with_z.RecupZ
76
- classes = virtual_points_classes * np.ones(len(input_points_with_z.index))
77
-
78
- # Read the existing LIDAR file
79
- with laspy.open(input_las, mode="r") as las:
80
- las_data = las.read()
81
- header = las.header
82
-
83
- # Create a new header if the original header is missing or invalid
84
- if header is None:
85
- header = laspy.LasHeader(point_format=6, version="1.4") # Example format and version
86
-
87
- # Append the clipped points to the existing LIDAR data
88
- new_x = np.concatenate([las_data.x, x_coords])
89
- new_y = np.concatenate([las_data.y, y_coords])
90
- new_z = np.concatenate([las_data.z, z_coords])
91
- new_classes = np.concatenate([las_data.classification, classes])
92
-
93
- # Create a new LAS file with updated data
94
- updated_las = laspy.LasData(header)
95
- updated_las.x = new_x
96
- updated_las.y = new_y
97
- updated_las.z = new_z
98
- updated_las.classification = new_classes
99
-
100
- # Write the updated LAS file
101
- with laspy.open(output_las, mode="w", header=header, do_compress=True) as writer:
102
- writer.write_points(updated_las.points)
@@ -1,82 +0,0 @@
1
- import os
2
- from pathlib import Path
3
-
4
- import pdal
5
-
6
- from pdaltools import add_points_in_pointcloud
7
-
8
- TEST_PATH = os.path.dirname(os.path.abspath(__file__))
9
- TMP_PATH = os.path.join(TEST_PATH, "data/output")
10
- DATA_LIDAR_PATH = os.path.join(TEST_PATH, "data/decimated_laz")
11
- DATA_POINTS_PATH = os.path.join(TEST_PATH, "data/points_3d")
12
-
13
- INPUT_FILE = os.path.join(DATA_LIDAR_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
14
- INPUT_POINTS = os.path.join(DATA_POINTS_PATH, "Points_virtuels_0292_6833.geojson")
15
- OUTPUT_FILE = os.path.join(TMP_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
16
-
17
- INPUT_FILE_SMALL = os.path.join(DATA_LIDAR_PATH, "test_semis_2021_0382_6565_LA93_IGN69.laz")
18
- INPUT_POINTS_SMALL = os.path.join(DATA_POINTS_PATH, "Points_virtuels_0382_6565.geojson")
19
- OUTPUT_FILE_SMALL = os.path.join(TMP_PATH, "test_semis_2021_0382_6565_LA93_IGN69.laz")
20
-
21
-
22
- def setup_module(module):
23
- os.makedirs("test/data/output", exist_ok=True)
24
-
25
-
26
- def test_get_tile_bbox():
27
- bbox = add_points_in_pointcloud.get_tile_bbox(INPUT_FILE, 1000)
28
- assert bbox == (292000.0, 6832000.0, 293000.0, 6833000.0) # check the bbox from LIDAR tile
29
-
30
-
31
- def test_clip_3d_points_to_tile():
32
- points_clipped = add_points_in_pointcloud.clip_3d_points_to_tile(INPUT_POINTS, INPUT_FILE, "EPSG:2154")
33
- assert len(points_clipped) == 678 # chech the entity's number of points
34
-
35
-
36
- def test_add_line_to_lidar():
37
- points_clipped = add_points_in_pointcloud.clip_3d_points_to_tile(INPUT_POINTS, INPUT_FILE, "EPSG:2154")
38
-
39
- add_points_in_pointcloud.add_points_to_las(points_clipped, INPUT_FILE, OUTPUT_FILE, 68)
40
- assert Path(OUTPUT_FILE).exists() # check output exists
41
-
42
- # Filter pointcloud by classes
43
- pipeline = (
44
- pdal.Reader.las(filename=OUTPUT_FILE, nosrs=True)
45
- | pdal.Filter.range(
46
- limits="Classification[68:68]",
47
- )
48
- | pdal.Filter.stats()
49
- )
50
- pipeline.execute()
51
- metadata = pipeline.metadata
52
- # Count the pointcloud's number from classe "68"
53
- point_count = metadata["metadata"]["filters.stats"]["statistic"][0]["count"]
54
- assert point_count == 678
55
-
56
-
57
- def test_get_tile_bbox_small():
58
- # Tile is not complete (NOT 1km * 1km)
59
- bbox = add_points_in_pointcloud.get_tile_bbox(INPUT_FILE_SMALL, 1000)
60
- assert bbox == (382000.0, 6564000.0, 383000.0, 6565000.0) # return BBOX 1km * 1km
61
-
62
-
63
- def test_add_line_to_lidar_small():
64
- # Tile is not complete (NOT 1km * 1km)
65
- points_clipped = add_points_in_pointcloud.clip_3d_points_to_tile(INPUT_POINTS_SMALL, INPUT_FILE_SMALL, "EPSG:2154")
66
-
67
- add_points_in_pointcloud.add_points_to_las(points_clipped, INPUT_FILE_SMALL, OUTPUT_FILE_SMALL, 68)
68
- assert Path(OUTPUT_FILE).exists() # check output exists
69
-
70
- # Filter pointcloud by classes
71
- pipeline = (
72
- pdal.Reader.las(filename=OUTPUT_FILE_SMALL, nosrs=True)
73
- | pdal.Filter.range(
74
- limits="Classification[68:68]",
75
- )
76
- | pdal.Filter.stats()
77
- )
78
- pipeline.execute()
79
- metadata = pipeline.metadata
80
- # Count the pointcloud's number from classe "68"
81
- point_count = metadata["metadata"]["filters.stats"]["statistic"][0]["count"]
82
- assert point_count == 186
File without changes
File without changes