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.
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/PKG-INFO +1 -1
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/PKG-INFO +1 -1
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/_version.py +1 -1
- ign_pdal_tools-1.7.8/pdaltools/add_points_in_pointcloud.py +159 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_info.py +44 -1
- ign_pdal_tools-1.7.8/test/test_add_points_in_pointcloud.py +117 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_info.py +35 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_unlock.py +4 -0
- ign_pdal_tools-1.7.6/pdaltools/add_points_in_pointcloud.py +0 -102
- ign_pdal_tools-1.7.6/test/test_add_points_in_pointcloud.py +0 -82
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/LICENSE.md +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/README.md +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/SOURCES.txt +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/dependency_links.txt +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/ign_pdal_tools.egg-info/top_level.txt +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/add_points_in_las.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/color.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_add_buffer.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_clip.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_merge.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/las_remove_dimensions.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/pcd_info.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/replace_attribute_in_las.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/standardize_format.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pdaltools/unlock_file.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/pyproject.toml +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/setup.cfg +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_add_points_in_las.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_color.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_add_buffer.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_clip.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_merge.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_las_remove_dimensions.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_pcd_info.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_replace_attribute_in_las.py +0 -0
- {ign_pdal_tools-1.7.6 → ign_pdal_tools-1.7.8}/test/test_standardize_format.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|