ign-pdal-tools 1.11.1__tar.gz → 1.12.0__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 (39) hide show
  1. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/PKG-INFO +1 -1
  2. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/ign_pdal_tools.egg-info/PKG-INFO +1 -1
  3. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/ign_pdal_tools.egg-info/SOURCES.txt +4 -0
  4. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/_version.py +1 -1
  5. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/add_points_in_pointcloud.py +5 -5
  6. ign_pdal_tools-1.12.0/pdaltools/create_random_laz.py +146 -0
  7. ign_pdal_tools-1.12.0/pdaltools/las_comparison.py +105 -0
  8. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_add_points_in_pointcloud.py +22 -4
  9. ign_pdal_tools-1.12.0/test/test_create_random_laz.py +174 -0
  10. ign_pdal_tools-1.12.0/test/test_las_comparison.py +275 -0
  11. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/LICENSE.md +0 -0
  12. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/README.md +0 -0
  13. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/ign_pdal_tools.egg-info/dependency_links.txt +0 -0
  14. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/ign_pdal_tools.egg-info/top_level.txt +0 -0
  15. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/color.py +0 -0
  16. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_add_buffer.py +0 -0
  17. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_clip.py +0 -0
  18. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_info.py +0 -0
  19. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_merge.py +0 -0
  20. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_remove_dimensions.py +0 -0
  21. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/las_rename_dimension.py +0 -0
  22. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/pcd_info.py +0 -0
  23. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/replace_attribute_in_las.py +0 -0
  24. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/standardize_format.py +0 -0
  25. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pdaltools/unlock_file.py +0 -0
  26. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/pyproject.toml +0 -0
  27. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/setup.cfg +0 -0
  28. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_color.py +0 -0
  29. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_add_buffer.py +0 -0
  30. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_clip.py +0 -0
  31. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_info.py +0 -0
  32. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_merge.py +0 -0
  33. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_remove_dimensions.py +0 -0
  34. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_las_rename_dimension.py +0 -0
  35. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_pcd_info.py +0 -0
  36. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_pdal_custom.py +0 -0
  37. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_replace_attribute_in_las.py +0 -0
  38. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_standardize_format.py +0 -0
  39. {ign_pdal_tools-1.11.1 → ign_pdal_tools-1.12.0}/test/test_unlock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ign-pdal-tools
3
- Version: 1.11.1
3
+ Version: 1.12.0
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.4
2
2
  Name: ign-pdal-tools
3
- Version: 1.11.1
3
+ Version: 1.12.0
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
@@ -8,8 +8,10 @@ ign_pdal_tools.egg-info/top_level.txt
8
8
  pdaltools/_version.py
9
9
  pdaltools/add_points_in_pointcloud.py
10
10
  pdaltools/color.py
11
+ pdaltools/create_random_laz.py
11
12
  pdaltools/las_add_buffer.py
12
13
  pdaltools/las_clip.py
14
+ pdaltools/las_comparison.py
13
15
  pdaltools/las_info.py
14
16
  pdaltools/las_merge.py
15
17
  pdaltools/las_remove_dimensions.py
@@ -20,8 +22,10 @@ pdaltools/standardize_format.py
20
22
  pdaltools/unlock_file.py
21
23
  test/test_add_points_in_pointcloud.py
22
24
  test/test_color.py
25
+ test/test_create_random_laz.py
23
26
  test/test_las_add_buffer.py
24
27
  test/test_las_clip.py
28
+ test/test_las_comparison.py
25
29
  test/test_las_info.py
26
30
  test/test_las_merge.py
27
31
  test/test_las_remove_dimensions.py
@@ -1,4 +1,4 @@
1
- __version__ = "1.11.1"
1
+ __version__ = "1.12.0"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -1,18 +1,15 @@
1
1
  import argparse
2
- from shutil import copy2
3
2
  import tempfile
3
+ from shutil import copy2
4
4
 
5
5
  import geopandas as gpd
6
6
  import laspy
7
7
  import numpy as np
8
- from pyproj import CRS
9
- from pyproj.exceptions import CRSError
8
+ import pdal
10
9
  from shapely.geometry import MultiPoint, Point, box
11
10
 
12
11
  from pdaltools.las_info import get_epsg_from_las, get_tile_bbox
13
12
 
14
- import pdal
15
-
16
13
 
17
14
  def parse_args(argv=None):
18
15
  parser = argparse.ArgumentParser("Add points from GeoJSON in LIDAR tile")
@@ -223,6 +220,9 @@ def generate_3d_points_from_lines(
223
220
  and Z coordinates are not available in the geometry.
224
221
  """
225
222
  # Check if altitude_column is provided and exists in the GeoDataFrame
223
+ if lines_gdf.empty:
224
+ return lines_gdf
225
+
226
226
  if altitude_column and (altitude_column not in lines_gdf.columns):
227
227
  raise ValueError("altitude_column must exist in the GeoDataFrame if provided.")
228
228
 
@@ -0,0 +1,146 @@
1
+ import numpy as np
2
+ import laspy
3
+ from pathlib import Path
4
+ import sys
5
+ import argparse
6
+ import pdal
7
+ from pyproj import CRS
8
+ from typing import List, Tuple, Union
9
+
10
+ def create_random_laz(output_file: str, point_format: int = 3, num_points: int = 100, crs: int = 2154,
11
+ center: Tuple[float, float] = (650000, 6810000),
12
+ extra_dims: List[Tuple[str, str]] = [],
13
+ ):
14
+ """
15
+ Create a test LAZ file with EPSG code and additional dimensions.
16
+
17
+ Args:
18
+ output_file: Path to save the LAZ file
19
+ point_format: Point format of the LAZ file (default: 3)
20
+ num_points: Number of points to generate
21
+ crs: EPSG code of the CRS (default: 2154)
22
+ center: Tuple of floats (x, y) of the center of the area to generate points in (default: (650000, 6810000) ; around Paris)
23
+ extra_dims: List of tuples (dimension_name, dimension_type) where type can be:
24
+ 'float32', 'float64', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'
25
+ """
26
+
27
+ # Create a new point cloud
28
+ header = laspy.LasHeader(point_format=point_format, version="1.4")
29
+
30
+ # Map string types to numpy types
31
+ type_mapping = {
32
+ 'float32': np.float32,
33
+ 'float64': np.float64,
34
+ 'int8': np.int8,
35
+ 'int16': np.int16,
36
+ 'int32': np.int32,
37
+ 'int64': np.int64,
38
+ 'uint8': np.uint8,
39
+ 'uint16': np.uint16,
40
+ 'uint32': np.uint32,
41
+ 'uint64': np.uint64,
42
+ }
43
+
44
+ for dim_name, dim_type in extra_dims:
45
+ if dim_type not in type_mapping:
46
+ raise ValueError(f"Unsupported dimension type: {dim_type}. Supported types: {list(type_mapping.keys())}")
47
+
48
+ numpy_type = type_mapping[dim_type]
49
+ header.add_extra_dim(laspy.ExtraBytesParams(name=dim_name, type=numpy_type))
50
+
51
+ # Create point cloud
52
+ las = laspy.LasData(header)
53
+ las.header.add_crs(CRS.from_string(f"epsg:{crs}"))
54
+
55
+ # Generate random points in a small area
56
+ las.x = np.random.uniform(center[0] - 1000, center[0] + 1000, num_points)
57
+ las.y = np.random.uniform(center[1] - 1000, center[1] + 1000, num_points)
58
+ las.z = np.random.uniform(0, 200, num_points)
59
+
60
+ # Generate random intensity values
61
+ las.intensity = np.random.randint(0, 255, num_points)
62
+
63
+ # Generate random classification values
64
+ # 66 is the max value for classification of IGN LidarHD
65
+ # cf. https://geoservices.ign.fr/sites/default/files/2022-05/DT_LiDAR_HD_1-0.pdf
66
+ if point_format > 3:
67
+ num_classifications = 66
68
+ else:
69
+ num_classifications = 10
70
+ las.classification = np.random.randint(0, num_classifications, num_points)
71
+
72
+ # Generate random values for each extra dimension
73
+ for dim_name, dim_type in extra_dims:
74
+ numpy_type = type_mapping[dim_type]
75
+
76
+ # Generate appropriate random values based on the type
77
+ if numpy_type in [np.float32, np.float64]:
78
+ las[dim_name] = np.random.uniform(0, 10, num_points).astype(numpy_type)
79
+ elif numpy_type in [np.int8, np.int16, np.int32, np.int64]:
80
+ las[dim_name] = np.random.randint(-100, 100, num_points).astype(numpy_type)
81
+ elif numpy_type in [np.uint8, np.uint16, np.uint32, np.uint64]:
82
+ las[dim_name] = np.random.randint(0, 100, num_points).astype(numpy_type)
83
+
84
+ # Write to file
85
+ las.write(output_file)
86
+ dimensions = list(las.point_format.dimension_names)
87
+ return {
88
+ "output_file": output_file,
89
+ "num_points": num_points,
90
+ "dimensions": dimensions,
91
+ }
92
+
93
+
94
+ def test_output_file(result : dict, output_file: str):
95
+
96
+ # Validate output file path
97
+ output_path = Path(output_file)
98
+ if not output_path.exists():
99
+ raise ValueError(f"Error: Output file {output_file} does not exist")
100
+
101
+ # Print results
102
+ print(f"Successfully created test LAZ file at {result['output_file']}")
103
+ print(f"Number of points: {result['num_points']}")
104
+ print(f"Dimensions available: {result['dimensions']}")
105
+
106
+ # Print available dimensions using PDAL
107
+ pipeline = pdal.Pipeline() | pdal.Reader.las(result['output_file'])
108
+ pipeline.execute()
109
+ points = pipeline.arrays[0]
110
+ dimensions = list(points.dtype.fields.keys())
111
+ print("\nAvailable dimensions in input file:")
112
+ for dim in dimensions:
113
+ print(f"- {dim}")
114
+
115
+
116
+ def parse_args():
117
+ # Parse arguments (assuming argparse is used)
118
+ parser = argparse.ArgumentParser(description="Create a random LAZ file.")
119
+ parser.add_argument("--output_file", type=str, help="Path to save the LAZ file")
120
+ parser.add_argument("--point_format", type=int, default=3, help="Point format of the LAZ file")
121
+ parser.add_argument("--num_points", type=int, default=100, help="Number of points to generate")
122
+ parser.add_argument("--extra_dims", type=str, nargs="*", default=[], help="Extra dimensions in the format name:type")
123
+ parser.add_argument("--crs", type=int, default=2154, help="Projection code")
124
+ parser.add_argument("--center", type=str, default="650000,6810000", help="Center of the area to generate points in")
125
+ return parser.parse_args()
126
+
127
+
128
+ def main():
129
+
130
+ # Parse arguments
131
+ args = parse_args()
132
+
133
+ # Parse extra dimensions
134
+ extra_dims = [tuple(dim.split(":")) for dim in args.extra_dims]
135
+
136
+ # Parse center
137
+ center = tuple(map(float, args.center.split(",")))
138
+
139
+ # Call create_random_laz
140
+ result = create_random_laz(args.output_file, args.point_format, args.num_points, args.crs, center, extra_dims)
141
+
142
+ # Test output file
143
+ test_output_file(result, args.output_file)
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,105 @@
1
+ import laspy
2
+ from pathlib import Path
3
+ import numpy as np
4
+ import argparse
5
+
6
+ def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None) -> bool:
7
+ """
8
+ Compare specified dimensions between two LAS files.
9
+ If no dimensions are specified, compares all available dimensions.
10
+ Sorts points by x,y,z,gps_time coordinates before comparison to ensure point order consistency.
11
+
12
+ Args:
13
+ file1: Path to the first LAS file
14
+ file2: Path to the second LAS file
15
+ dimensions: List of dimension names to compare (optional)
16
+
17
+ Returns:
18
+ bool: True if all specified dimensions are identical, False otherwise
19
+ """
20
+ try:
21
+ # Read both LAS files
22
+ las1 = laspy.read(file1)
23
+ las2 = laspy.read(file2)
24
+
25
+ # Check if files have the same number of points
26
+ if len(las1) != len(las2):
27
+ print(f"Files have different number of points: {len(las1)} vs {len(las2)}")
28
+ return False
29
+
30
+ # Sort points by x,y,z,gps_time coordinates
31
+ # Create sorting indices
32
+ sort_idx1 = np.lexsort((las1.z, las1.y, las1.x, las1.gps_time))
33
+ sort_idx2 = np.lexsort((las2.z, las2.y, las2.x, las2.gps_time))
34
+
35
+ # If no dimensions specified, compare all dimensions
36
+ dimensions_las1 = sorted(las1.point_format.dimension_names)
37
+ dimensions_las2 = sorted(las2.point_format.dimension_names)
38
+
39
+ if dimensions is None:
40
+ if dimensions_las1 != dimensions_las2:
41
+ print("Files have different dimensions")
42
+ return False
43
+ dimensions = dimensions_las1
44
+ else:
45
+ for dim in dimensions:
46
+ if dim not in dimensions_las1 or dim not in dimensions_las2:
47
+ print(f"Dimension '{dim}' is not found in one or both files. Available dimensions: {las1.point_format.dimension_names}")
48
+ return False
49
+
50
+ # Compare each dimension
51
+ for dim in dimensions:
52
+ try:
53
+ # Get sorted dimension arrays
54
+ dim1 = np.array(las1[dim])[sort_idx1]
55
+ dim2 = np.array(las2[dim])[sort_idx2]
56
+
57
+ # Compare dimensions
58
+ if not np.array_equal(dim1, dim2):
59
+ # Find differences
60
+ diff_indices = np.where(dim1 != dim2)[0]
61
+ print(f"Found {len(diff_indices)} points with different {dim}:")
62
+ for idx in diff_indices[:10]: # Show first 10 differences
63
+ print(f"Point {idx}: file1={dim1[idx]}, file2={dim2[idx]}")
64
+ if len(diff_indices) > 10:
65
+ print(f"... and {len(diff_indices) - 10} more differences")
66
+ return False
67
+
68
+ except KeyError:
69
+ print(f"Dimension '{dim}' not found in one or both files")
70
+ return False
71
+
72
+ return True
73
+
74
+ except laspy.errors.LaspyException as e:
75
+ print(f"LAS file error: {str(e)}")
76
+ return False
77
+ except FileNotFoundError as e:
78
+ print(f"File not found: {str(e)}")
79
+ return False
80
+ except ValueError as e:
81
+ print(f"Value error: {str(e)}")
82
+ return False
83
+
84
+ # Update main function to use the new compare function
85
+ def main():
86
+ parser = argparse.ArgumentParser(description='Compare dimensions between two LAS files')
87
+ parser.add_argument('file1', type=str, help='Path to first LAS file')
88
+ parser.add_argument('file2', type=str, help='Path to second LAS file')
89
+ parser.add_argument('--dimensions', nargs='*', help='List of dimensions to compare. If not specified, compares all dimensions.')
90
+
91
+ args = parser.parse_args()
92
+
93
+ file1 = Path(args.file1)
94
+ file2 = Path(args.file2)
95
+
96
+ if not file1.exists() or not file2.exists():
97
+ print("Error: One or both files do not exist")
98
+ exit(1)
99
+
100
+ result = compare_las_dimensions(file1, file2, args.dimensions)
101
+ print(f"Dimensions comparison result: {'identical' if result else 'different'}")
102
+ return result
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -220,6 +220,16 @@ def test_line_to_multipoint(line, spacing, z_value, expected_points):
220
220
  Point(10, 10, 6.0),
221
221
  ],
222
222
  ),
223
+ # Test case for empty lines
224
+ (
225
+ gpd.GeoDataFrame(
226
+ {"geometry": [], "RecupZ": []},
227
+ crs="EPSG:2154",
228
+ ),
229
+ 2.5,
230
+ "RecupZ",
231
+ [],
232
+ ),
223
233
  ],
224
234
  )
225
235
  def test_generate_3d_points_from_lines(lines_gdf, spacing, altitude_column, expected_points):
@@ -251,7 +261,7 @@ def test_generate_3d_points_from_lines(lines_gdf, spacing, altitude_column, expe
251
261
  678,
252
262
  0.25,
253
263
  "RecupZ",
254
- ), # should add only lignes (.shp) within tile extend
264
+ ), # should add only lines (.shp) within tile extend
255
265
  (INPUT_PCD, INPUT_LIGNES_SHAPE, None, 678, 0.25, "RecupZ"), # Should work with or with an input epsg
256
266
  (
257
267
  INPUT_PCD,
@@ -261,6 +271,14 @@ def test_generate_3d_points_from_lines(lines_gdf, spacing, altitude_column, expe
261
271
  0.25,
262
272
  None,
263
273
  ), # Should work with or without an input epsg and without altitude_column
274
+ (
275
+ INPUT_PCD_CROPPED,
276
+ INPUT_LIGNES_3D_GEOJSON,
277
+ None,
278
+ 0,
279
+ 0.25,
280
+ None,
281
+ ), # Should work with lines and add no points if there is no geometry in the tile extent
264
282
  ],
265
283
  )
266
284
  def test_add_points_from_geometry_to_las(input_file, input_points, epsg, expected_nb_points, spacing, altitude_column):
@@ -272,11 +290,11 @@ def test_add_points_from_geometry_to_las(input_file, input_points, epsg, expecte
272
290
  input_points, input_file, OUTPUT_FILE, 68, epsg, 1000, spacing, altitude_column
273
291
  )
274
292
  assert Path(OUTPUT_FILE).exists() # check output exists
275
-
293
+
276
294
  # Read input and output files to compare headers
277
295
  input_las = laspy.read(input_file)
278
296
  output_las = laspy.read(OUTPUT_FILE)
279
-
297
+
280
298
  # Compare headers
281
299
  assert input_las.header.version == output_las.header.version
282
300
  assert input_las.header.system_identifier == output_las.header.system_identifier
@@ -287,7 +305,7 @@ def test_add_points_from_geometry_to_las(input_file, input_points, epsg, expecte
287
305
  assert np.array_equal(input_las.header.scales, output_las.header.scales)
288
306
  assert np.array_equal(input_las.header.offsets, output_las.header.offsets)
289
307
  assert input_las.header.vlrs[0].string == output_las.header.vlrs[0].string
290
-
308
+
291
309
  point_count = compute_count_one_file(OUTPUT_FILE)["68"]
292
310
  assert point_count == expected_nb_points # Add all points from geojson
293
311
 
@@ -0,0 +1,174 @@
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+ import numpy as np
5
+ import laspy
6
+ import sys
7
+
8
+ from pdaltools.create_random_laz import create_random_laz, main
9
+
10
+ TEST_PATH = os.path.dirname(os.path.abspath(__file__))
11
+ TMP_PATH = os.path.join(TEST_PATH, "tmp")
12
+
13
+
14
+ def setup_module(module):
15
+ try:
16
+ os.makedirs(TMP_PATH, exist_ok=True)
17
+ except FileNotFoundError:
18
+ pass
19
+
20
+
21
+ def test_create_random_laz_basic():
22
+ """Test basic functionality without extra dimensions"""
23
+ output_file = os.path.join(TMP_PATH, "test_basic.laz")
24
+ create_random_laz(output_file=output_file, num_points=50)
25
+
26
+ # Check file exists
27
+ assert os.path.isfile(output_file)
28
+
29
+ # Check file can be read
30
+ with laspy.open(output_file) as las_file:
31
+ las = las_file.read()
32
+ assert len(las.points) == 50
33
+ assert "X" in las.point_format.dimension_names
34
+ assert "Y" in las.point_format.dimension_names
35
+ assert "Z" in las.point_format.dimension_names
36
+ assert "intensity" in las.point_format.dimension_names
37
+ assert "classification" in las.point_format.dimension_names
38
+
39
+
40
+ def test_create_random_laz_invalid_type():
41
+ """Test error handling for invalid dimension type"""
42
+ output_file = os.path.join(TMP_PATH, "test_invalid_type.laz")
43
+ extra_dims = [("height", "invalid_type")]
44
+
45
+ with pytest.raises(ValueError):
46
+ create_random_laz(output_file=output_file, num_points=50, point_format=3, extra_dims=extra_dims)
47
+
48
+
49
+ def test_create_random_point_format():
50
+ """Test that the point format is set correctly"""
51
+ output_file = os.path.join(TMP_PATH, "test_point_format.laz")
52
+ create_random_laz(output_file=output_file, point_format=6, num_points=50)
53
+
54
+ with laspy.open(output_file) as las_file:
55
+ las = las_file.read()
56
+ assert las.header.point_format.id == 6
57
+
58
+
59
+ def test_create_random_laz_no_extra_dims():
60
+ """Test that the output file is created correctly when no extra dimensions are provided"""
61
+ output_file = os.path.join(TMP_PATH, "test_no_extra_dims.laz")
62
+ create_random_laz(output_file, num_points=50)
63
+
64
+ with laspy.open(output_file) as las_file:
65
+ las = las_file.read()
66
+ assert len(las.points) == 50
67
+ assert "X" in las.point_format.dimension_names
68
+ assert "Y" in las.point_format.dimension_names
69
+ assert "Z" in las.point_format.dimension_names
70
+ assert "intensity" in las.point_format.dimension_names
71
+ assert "classification" in las.point_format.dimension_names
72
+
73
+
74
+ def test_create_random_laz_crs_and_center():
75
+ """Test that the CRS is set correctly"""
76
+ output_file = os.path.join(TMP_PATH, "test_crs.laz")
77
+ create_random_laz(output_file, num_points=50, crs=2153, center=(650000, 6810000))
78
+
79
+ with laspy.open(output_file) as las_file:
80
+ las = las_file.read()
81
+ epsg = las.header.parse_crs().to_epsg()
82
+ assert epsg == 2153
83
+
84
+
85
+ def test_create_random_laz_all_types():
86
+ """Test all supported dimension types"""
87
+ output_file = os.path.join(TMP_PATH, "test_all_types.laz")
88
+ extra_dims = [
89
+ ("float32_dim", "float32"),
90
+ ("float64_dim", "float64"),
91
+ ("int8_dim", "int8"),
92
+ ("int16_dim", "int16"),
93
+ ("int32_dim", "int32"),
94
+ ("int64_dim", "int64"),
95
+ ("uint8_dim", "uint8"),
96
+ ("uint16_dim", "uint16"),
97
+ ("uint32_dim", "uint32"),
98
+ ("uint64_dim", "uint64"),
99
+ ]
100
+
101
+ create_random_laz(output_file, num_points=25, extra_dims=extra_dims)
102
+
103
+ # Check file exists
104
+ assert os.path.isfile(output_file)
105
+
106
+ # Check file can be read and has correct dimensions
107
+ with laspy.open(output_file) as las_file:
108
+ las = las_file.read()
109
+ assert len(las.points) == 25
110
+
111
+ # Check that all extra dimensions exist
112
+ for dim_name, _ in extra_dims:
113
+ assert dim_name in las.point_format.dimension_names
114
+
115
+
116
+ def test_create_random_laz_data_ranges():
117
+ """Test that generated data is within expected ranges for different types"""
118
+ output_file = os.path.join(TMP_PATH, "test_data_ranges.laz")
119
+ extra_dims = [
120
+ ("float_dim", "float32"),
121
+ ("int_dim", "int32"),
122
+ ("uint_dim", "uint8"),
123
+ ]
124
+
125
+ create_random_laz(output_file, num_points=1000, extra_dims=extra_dims)
126
+
127
+ with laspy.open(output_file) as las_file:
128
+ las = las_file.read()
129
+
130
+ # Check float data is in expected range (0-10)
131
+ assert np.all(las.float_dim >= 0)
132
+ assert np.all(las.float_dim <= 10)
133
+
134
+ # Check int data is in expected range (-100 to 100)
135
+ assert np.all(las.int_dim >= -100)
136
+ assert np.all(las.int_dim <= 100)
137
+
138
+ # Check uint data is in expected range (0 to 100)
139
+ assert np.all(las.uint_dim >= 0)
140
+ assert np.all(las.uint_dim <= 100)
141
+
142
+ def test_main():
143
+ """Test the main function"""
144
+ output_file = os.path.join(TMP_PATH, "test_main.laz")
145
+
146
+ original_argv = sys.argv
147
+
148
+ try:
149
+ # Set up mock command-line arguments
150
+ sys.argv = [
151
+ "create_random_laz",
152
+ "--output_file", output_file,
153
+ "--point_format", "3",
154
+ "--num_points", "50",
155
+ "--crs", "2154",
156
+ "--center", "650000,6810000",
157
+ "--extra_dims", "height:float32",
158
+ ]
159
+
160
+ # Run main function
161
+ main()
162
+
163
+ # Verify output file exists
164
+ assert os.path.isfile(output_file)
165
+
166
+ # Verify points count is reduced
167
+ with laspy.open(output_file) as las_file:
168
+ las = las_file.read()
169
+ assert len(las.points) == 50
170
+ assert "height" in las.point_format.dimension_names
171
+
172
+ finally:
173
+ # Restore original sys.argv
174
+ sys.argv = original_argv
@@ -0,0 +1,275 @@
1
+ import pytest
2
+ import tempfile
3
+ import numpy as np
4
+ from pathlib import Path
5
+ import laspy
6
+ from pdaltools.las_comparison import compare_las_dimensions, main
7
+ from typing import Tuple
8
+
9
+ def create_test_las_file(x: np.ndarray, y: np.ndarray, z: np.ndarray, dimensions: dict = None) -> Path:
10
+ """Helper function to create a test LAS file with specified dimensions"""
11
+ with tempfile.NamedTemporaryFile(suffix='.las', delete=False) as temp:
12
+ las = laspy.create(point_format=3, file_version="1.4")
13
+
14
+ # Use provided dimensions or create default ones
15
+ if dimensions is None:
16
+ dimensions = {
17
+ 'classification': np.random.randint(0, 10, len(x)),
18
+ 'intensity': np.random.randint(0, 255, len(x)),
19
+ 'return_number': np.random.randint(1, 5, len(x))
20
+ }
21
+
22
+ las.x = x
23
+ las.y = y
24
+ las.z = z
25
+
26
+ # Set all specified dimensions
27
+ for dim_name, dim_data in dimensions.items():
28
+ setattr(las, dim_name, dim_data)
29
+
30
+ las.write(temp.name)
31
+ return Path(temp.name)
32
+
33
+ def get_random_points(points: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
34
+ x = np.random.rand(points) * 1000
35
+ y = np.random.rand(points) * 1000
36
+ z = np.random.rand(points) * 100
37
+ return x, y, z
38
+
39
+ def test_identical_dimensions():
40
+ """Test that identical dimensions return True"""
41
+ points = 10
42
+ x, y, z = get_random_points(points)
43
+ dimensions = {
44
+ 'classification': np.random.randint(0, 10, points),
45
+ 'intensity': np.random.randint(0, 255, points),
46
+ 'return_number': np.random.randint(1, 5, points)
47
+ }
48
+
49
+ file1 = create_test_las_file(x, y, z, dimensions)
50
+ file2 = create_test_las_file(x, y, z, dimensions)
51
+
52
+ try:
53
+ # Test with specific dimensions
54
+ result = compare_las_dimensions(file1, file2, ['classification', 'intensity'])
55
+ assert result is True, "Files with identical dimensions should return True"
56
+
57
+ # Test with identical files
58
+ result = compare_las_dimensions(file1, file2)
59
+ assert result is True, "Files with identical dimensions should return True"
60
+ finally:
61
+ # Clean up
62
+ file1.unlink()
63
+ file2.unlink()
64
+
65
+ def test_identical_dimensions_not_sorted():
66
+ """Test that identical dimensions return True"""
67
+ points = 10
68
+ x, y, z = get_random_points(points)
69
+ dimensions = {
70
+ 'classification': np.random.randint(0, 10, points),
71
+ 'intensity': np.random.randint(0, 255, points),
72
+ 'return_number': np.random.randint(1, 5, points)
73
+ }
74
+ dimensions2 = {
75
+ 'classification': dimensions['classification'][::-1],
76
+ 'intensity': dimensions['intensity'][::-1],
77
+ 'return_number': dimensions['return_number'][::-1]
78
+ }
79
+
80
+ file1 = create_test_las_file(x, y, z, dimensions)
81
+ file2 = create_test_las_file(x[::-1], y[::-1], z[::-1], dimensions2)
82
+
83
+ try:
84
+ # Test with specific dimensions
85
+ result = compare_las_dimensions(file1, file2, ['classification', 'intensity'])
86
+ assert result is True, "Files with identical dimensions should return True"
87
+
88
+ # Test with identical files
89
+ result = compare_las_dimensions(file1, file2)
90
+ assert result is True, "Files with identical dimensions should return True"
91
+ finally:
92
+ # Clean up
93
+ file1.unlink()
94
+ file2.unlink()
95
+
96
+
97
+ def test_different_dimensions_random():
98
+ """Test that files with random dimensions return False"""
99
+ points = 100
100
+ x, y, z = get_random_points(points)
101
+
102
+ # Create file1 with random dimensions
103
+ dimensions1 = {
104
+ 'classification': np.random.randint(0, 10, points),
105
+ 'intensity': np.random.randint(0, 255, points),
106
+ 'return_number': np.random.randint(1, 5, points)
107
+ }
108
+ file1 = create_test_las_file(x, y, z, dimensions1)
109
+
110
+ # Create file2 with different dimensions
111
+ dimensions2 = {
112
+ 'classification': np.random.randint(0, 10, points), # Different classification
113
+ 'intensity': dimensions1['intensity'], # Same intensity
114
+ 'return_number': dimensions1['return_number'] # Same return_number
115
+ }
116
+ file2 = create_test_las_file(x, y, z, dimensions2)
117
+
118
+ try:
119
+ # Test full comparison (should be different)
120
+ result = compare_las_dimensions(file1, file2)
121
+ assert result is False, "Files with different classification should return False"
122
+
123
+ # Test specific dimensions (should be identical)
124
+ result = compare_las_dimensions(file1, file2, ['intensity', 'return_number'])
125
+ assert result is True, "Files with identical intensity and return_number should return True"
126
+ finally:
127
+ # Clean up
128
+ file1.unlink()
129
+ file2.unlink()
130
+
131
+ def test_different_number_of_points():
132
+ """Test that files with different number of points return False"""
133
+ points = 100
134
+ x1, y1, z1 = get_random_points(points)
135
+ x2, y2, z2 = get_random_points(points + 1)
136
+ file1 = create_test_las_file(x1, y1, z1)
137
+ file2 = create_test_las_file(x2, y2, z2)
138
+
139
+ try:
140
+ result = compare_las_dimensions(file1, file2)
141
+ assert result is False, "Files with different number of points should return False"
142
+ finally:
143
+ # Clean up
144
+ file1.unlink()
145
+ file2.unlink()
146
+
147
+ def test_different_dimensions_number():
148
+ """Test that files with different number of dimensions return False"""
149
+ points = 100
150
+ x, y, z = get_random_points(points)
151
+
152
+ # Create file1 with random dimensions
153
+ dimensions1 = {
154
+ 'classification': np.random.randint(0, 10, points),
155
+ 'intensity': np.random.randint(0, 255, points),
156
+ 'return_number': np.random.randint(1, 5, points)
157
+ }
158
+ file1 = create_test_las_file(x, y, z, dimensions1)
159
+
160
+ # Create file2 only 2 dimensions
161
+ dimensions2 = {
162
+ 'classification': np.random.randint(0, 10, points), # Different classification
163
+ 'intensity': dimensions1['intensity'], # Same intensity
164
+ }
165
+ file2 = create_test_las_file(x, y, z, dimensions2)
166
+
167
+ try:
168
+ # Test full comparison (should be different)
169
+ result = compare_las_dimensions(file1, file2)
170
+ assert result is False, "Files with different dimensions should return False"
171
+ finally:
172
+ # Clean up
173
+ file1.unlink()
174
+ file2.unlink()
175
+
176
+ def test_one_empty_file():
177
+ """Test that one empty file returns False"""
178
+ points = 100
179
+ x = np.random.rand(points) * 1000
180
+ y = np.random.rand(points) * 1000
181
+ z = np.random.rand(points) * 100
182
+ file1 = create_test_las_file(x, y, z)
183
+ file2 = create_test_las_file(np.array([]), np.array([]), np.array([]))
184
+
185
+ try:
186
+ result = compare_las_dimensions(file1, file2)
187
+ assert result is False, "One empty file should return False"
188
+ finally:
189
+ # Clean up
190
+ file1.unlink()
191
+ file2.unlink()
192
+
193
+ def test_both_empty_files():
194
+ """Test that two empty files return True"""
195
+ file1 = create_test_las_file(np.array([]), np.array([]), np.array([]))
196
+ file2 = create_test_las_file(np.array([]), np.array([]), np.array([]))
197
+
198
+ try:
199
+ result = compare_las_dimensions(file1, file2)
200
+ assert result is True, "Two empty files should return True"
201
+ finally:
202
+ # Clean up
203
+ file1.unlink()
204
+ file2.unlink()
205
+
206
+ def test_single_point():
207
+ """Test with single point files"""
208
+ points = 1
209
+ x = np.random.rand(points) * 1000
210
+ y = np.random.rand(points) * 1000
211
+ z = np.random.rand(points) * 100
212
+ dimensions = {
213
+ 'classification': np.array([1]),
214
+ 'intensity': np.array([100]),
215
+ 'return_number': np.array([1])
216
+ }
217
+ file1 = create_test_las_file(x, y, z, dimensions)
218
+ file2 = create_test_las_file(x, y, z, dimensions)
219
+
220
+ try:
221
+ result = compare_las_dimensions(file1, file2)
222
+ assert result is True, "Single point files with same dimensions should return True"
223
+ finally:
224
+ # Clean up
225
+ file1.unlink()
226
+ file2.unlink()
227
+
228
+ def test_main_function():
229
+ """Test the main function with direct sys.argv"""
230
+ import sys
231
+ from io import StringIO
232
+ from contextlib import redirect_stdout
233
+
234
+ # Test with identical files
235
+ points = 100
236
+ x = np.random.rand(points) * 1000
237
+ y = np.random.rand(points) * 1000
238
+ z = np.random.rand(points) * 100
239
+ classification = np.random.randint(0, 10, points)
240
+
241
+ file1 = create_test_las_file(x, y, z, {'classification': classification})
242
+ file2 = create_test_las_file(x, y, z, {'classification': classification})
243
+ file3 = create_test_las_file(x, y, z, {'classification': classification + 1}) # Different classification
244
+
245
+ try:
246
+ # Test with identical files
247
+ sys.argv = ['script_name', str(file1), str(file2)]
248
+ with redirect_stdout(StringIO()) as f:
249
+ result = main()
250
+ assert result is True
251
+
252
+ sys.argv = ['script_name', str(file1), str(file2), '--dimensions', 'classification']
253
+ with redirect_stdout(StringIO()) as f:
254
+ result = main()
255
+ assert result is True
256
+
257
+ sys.argv = ['script_name', str(file1), str(file2), '--dimensions', 'classification', 'intensity']
258
+ with redirect_stdout(StringIO()) as f:
259
+ result = main()
260
+ assert result is True
261
+
262
+ # Test with different files
263
+ sys.argv = ['script_name', str(file1), str(file3)]
264
+ with redirect_stdout(StringIO()) as f:
265
+ result = main()
266
+ assert result is False
267
+
268
+ finally:
269
+ # Clean up
270
+ for f in [file1, file2, file3]:
271
+ if f.exists():
272
+ f.unlink()
273
+
274
+ if __name__ == "__main__":
275
+ pytest.main()