ign-pdal-tools 1.12.2__tar.gz → 1.13.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.12.2 → ign_pdal_tools-1.13.0}/PKG-INFO +1 -1
  2. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/ign_pdal_tools.egg-info/PKG-INFO +1 -1
  3. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/_version.py +1 -1
  4. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/add_points_in_pointcloud.py +3 -3
  5. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/create_random_laz.py +36 -14
  6. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_rename_dimension.py +8 -0
  7. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pyproject.toml +5 -6
  8. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_add_points_in_pointcloud.py +11 -0
  9. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_create_random_laz.py +43 -2
  10. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_rename_dimension.py +34 -16
  11. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/LICENSE.md +0 -0
  12. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/README.md +0 -0
  13. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/ign_pdal_tools.egg-info/SOURCES.txt +0 -0
  14. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/ign_pdal_tools.egg-info/dependency_links.txt +0 -0
  15. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/ign_pdal_tools.egg-info/top_level.txt +0 -0
  16. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/color.py +0 -0
  17. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_add_buffer.py +0 -0
  18. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_clip.py +0 -0
  19. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_comparison.py +0 -0
  20. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_info.py +0 -0
  21. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_merge.py +0 -0
  22. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/las_remove_dimensions.py +0 -0
  23. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/pcd_info.py +0 -0
  24. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/replace_attribute_in_las.py +0 -0
  25. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/standardize_format.py +0 -0
  26. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/pdaltools/unlock_file.py +0 -0
  27. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/setup.cfg +0 -0
  28. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_color.py +0 -0
  29. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_add_buffer.py +0 -0
  30. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_clip.py +0 -0
  31. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_comparison.py +0 -0
  32. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_info.py +0 -0
  33. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_merge.py +0 -0
  34. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_las_remove_dimensions.py +0 -0
  35. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_pcd_info.py +0 -0
  36. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_pdal_custom.py +0 -0
  37. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_replace_attribute_in_las.py +0 -0
  38. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.0}/test/test_standardize_format.py +0 -0
  39. {ign_pdal_tools-1.12.2 → ign_pdal_tools-1.13.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.12.2
3
+ Version: 1.13.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.12.2
3
+ Version: 1.13.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,4 +1,4 @@
1
- __version__ = "1.12.2"
1
+ __version__ = "1.13.0"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -29,7 +29,7 @@ def parse_args(argv=None):
29
29
  "--spatial_ref",
30
30
  type=str,
31
31
  required=False,
32
- help="spatial reference for the writer",
32
+ help="spatial reference for the writer (eg: 'EPSG:2154')",
33
33
  )
34
34
  parser.add_argument(
35
35
  "--tile_width",
@@ -169,7 +169,7 @@ def add_points_to_las(
169
169
  pipeline |= pdal.Reader.las(filename=input_las)
170
170
  pipeline |= pdal.Reader.las(filename=tmp.name)
171
171
  pipeline |= pdal.Filter.merge()
172
- pipeline |= pdal.Writer.las(filename=output_las, forward="all", a_srs=a_srs)
172
+ pipeline |= pdal.Writer.las(filename=output_las, forward="all", extra_dims="all", a_srs=a_srs)
173
173
  pipeline.execute()
174
174
 
175
175
 
@@ -281,7 +281,7 @@ def add_points_from_geometry_to_las(
281
281
  gdf = gpd.read_file(input_geometry)
282
282
 
283
283
  if gdf.crs is None:
284
- gdf.set_crs(epsg=spatial_ref, inplace=True)
284
+ gdf.set_crs(crs=spatial_ref, inplace=True)
285
285
 
286
286
  # Check if both Z in geometries and altitude_column are provided
287
287
  if gdf.geometry.has_z.any() and altitude_column:
@@ -8,11 +8,12 @@ from typing import List, Tuple
8
8
 
9
9
  def create_random_laz(
10
10
  output_file: str,
11
- point_format: int = 3,
11
+ point_format: int = 6,
12
12
  num_points: int = 100,
13
13
  crs: int = 2154,
14
14
  center: Tuple[float, float] = (650000, 6810000),
15
15
  extra_dims: List[Tuple[str, str]] = [],
16
+ classifications: List[int] = None,
16
17
  ):
17
18
  """
18
19
  Create a test LAZ file with EPSG code and additional dimensions.
@@ -26,6 +27,7 @@ def create_random_laz(
26
27
  (default: (650000, 6810000) ; around Paris)
27
28
  extra_dims: List of tuples (dimension_name, dimension_type) where type can be:
28
29
  'float32', 'float64', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'
30
+ classifications: Optional list of classification values.
29
31
  """
30
32
 
31
33
  # Create a new point cloud
@@ -52,7 +54,7 @@ def create_random_laz(
52
54
  numpy_type = type_mapping[dim_type]
53
55
  header.add_extra_dim(laspy.ExtraBytesParams(name=dim_name, type=numpy_type))
54
56
 
55
- # Create point cloud
57
+ # Create point cloud
56
58
  las = laspy.LasData(header)
57
59
  las.header.add_crs(CRS.from_string(f"epsg:{crs}"))
58
60
 
@@ -64,14 +66,19 @@ def create_random_laz(
64
66
  # Generate random intensity values
65
67
  las.intensity = np.random.randint(0, 255, num_points)
66
68
 
67
- # Generate random classification values
68
- # 66 is the max value for classification of IGN LidarHD
69
- # cf. https://geoservices.ign.fr/sites/default/files/2022-05/DT_LiDAR_HD_1-0.pdf
70
- if point_format > 3:
71
- num_classifications = 66
69
+ # Set classification values
70
+ if classifications:
71
+ # Randomly select from the provided classification values
72
+ las.classification = np.random.choice(classifications, size=num_points, replace=True).astype(np.uint8)
72
73
  else:
73
- num_classifications = 10
74
- las.classification = np.random.randint(0, num_classifications, num_points)
74
+ # Generate random classification values if not provided
75
+ # 66 is the max value for classification of IGN LidarHD
76
+ # cf. https://geoservices.ign.fr/sites/default/files/2022-05/DT_LiDAR_HD_1-0.pdf
77
+ if point_format >= 6:
78
+ num_classifications = 66
79
+ else:
80
+ num_classifications = 10
81
+ las.classification = np.random.randint(0, num_classifications, num_points, dtype=np.uint8)
75
82
 
76
83
  # Generate random values for each extra dimension
77
84
  for dim_name, dim_type in extra_dims:
@@ -112,20 +119,24 @@ def parse_args():
112
119
  # Parse arguments (assuming argparse is used)
113
120
  parser = argparse.ArgumentParser(description="Create a random LAZ file.")
114
121
  parser.add_argument("--output_file", type=str, help="Path to save the LAZ file")
115
- parser.add_argument("--point_format", type=int, default=3, help="Point format of the LAZ file")
122
+ parser.add_argument("--point_format", type=int, default=6, help="Point format of the LAZ file")
116
123
  parser.add_argument("--num_points", type=int, default=100, help="Number of points to generate")
117
124
  parser.add_argument(
118
125
  "--extra_dims", type=str, nargs="*", default=[], help="Extra dimensions in the format name:type"
119
126
  )
120
127
  parser.add_argument("--crs", type=int, default=2154, help="Projection code")
121
128
  parser.add_argument(
122
- "--center", type=str, default="650000,6810000", help="Center of the area to generate points in"
129
+ "--center", type=float, nargs=2, default=[650000.0, 6810000.0],
130
+ help="Center coordinates (x y) of the area to generate points in (space-separated)"
131
+ )
132
+ parser.add_argument(
133
+ "--classifications", type=int, nargs='+',
134
+ help="List of classification values (space-separated)"
123
135
  )
124
136
  return parser.parse_args()
125
137
 
126
138
 
127
139
  def main():
128
-
129
140
  # Parse arguments
130
141
  args = parse_args()
131
142
 
@@ -133,10 +144,21 @@ def main():
133
144
  extra_dims = [tuple(dim.split(":")) for dim in args.extra_dims]
134
145
 
135
146
  # Parse center
136
- center = tuple(map(float, args.center.split(",")))
147
+ center = tuple(args.center[:2]) # Only take first 2 values if more are provided
148
+
149
+ # Parse classifications if provided
150
+ classifications = args.classifications
137
151
 
138
152
  # Call create_random_laz
139
- result = create_random_laz(args.output_file, args.point_format, args.num_points, args.crs, center, extra_dims)
153
+ result = create_random_laz(
154
+ args.output_file,
155
+ args.point_format,
156
+ args.num_points,
157
+ args.crs,
158
+ center,
159
+ extra_dims,
160
+ classifications
161
+ )
140
162
 
141
163
  # Test output file
142
164
  test_output_file(result, args.output_file)
@@ -5,10 +5,12 @@ This script allows renaming dimensions in a LAS file while preserving all other
5
5
  """
6
6
 
7
7
  import argparse
8
+ import logging
8
9
  import pdal
9
10
  import sys
10
11
  from pathlib import Path
11
12
  from pdaltools.las_remove_dimensions import remove_dimensions_from_points
13
+ from pdaltools.las_info import las_info_metadata
12
14
 
13
15
 
14
16
  def rename_dimension(input_file: str, output_file: str, old_dims: list[str], new_dims: list[str]):
@@ -31,8 +33,14 @@ def rename_dimension(input_file: str, output_file: str, old_dims: list[str], new
31
33
  if dim in mandatory_dimensions:
32
34
  raise ValueError(f"New dimension {dim} cannot be a mandatory dimension (X,Y,Z,x,y,z)")
33
35
 
36
+ metadata = las_info_metadata(input_file)
37
+ input_dimensions = metadata["dimensions"]
38
+
34
39
  pipeline = pdal.Pipeline() | pdal.Reader.las(input_file)
35
40
  for old, new in zip(old_dims, new_dims):
41
+ if old not in input_dimensions:
42
+ logging.warning(f"Dimension {old} not found in input file : we cannot rename it")
43
+ continue
36
44
  pipeline |= pdal.Filter.ferry(dimensions=f"{old} => {new}")
37
45
  pipeline |= pdal.Writer.las(output_file)
38
46
  pipeline.execute()
@@ -3,18 +3,17 @@ name = "ign-pdal-tools"
3
3
  dynamic = ["version"]
4
4
  description = "Library for common LAS files manipulation with PDAL"
5
5
  readme = "README.md"
6
- authors = [
7
- { name = "Guillaume Liegard", email = "guillaume.liegard@ign.fr" }
8
- ]
6
+ authors = [{ name = "Guillaume Liegard", email = "guillaume.liegard@ign.fr" }]
9
7
 
10
8
  [tool.setuptools.dynamic]
11
- version = {attr = "pdaltools._version.__version__"}
9
+ version = { attr = "pdaltools._version.__version__" }
12
10
  [tool.setuptools]
13
- packages = [ "pdaltools" ]
11
+ packages = ["pdaltools"]
14
12
 
15
13
  [tool.pytest.ini_options]
16
14
  markers = [
17
- "geopf: marks tests that request the (sometimes unreliable) data.geopf.fr",
15
+ "geopf: marks tests that request the (sometimes unreliable) data.geopf.fr",
16
+ "pdal_custom: marks tests that only work with PDAL compiled on a custom fork and branch",
18
17
  ]
19
18
 
20
19
  [tool.black]
@@ -12,6 +12,7 @@ from pdaltools import add_points_in_pointcloud
12
12
  from pdaltools.count_occurences.count_occurences_for_attribute import (
13
13
  compute_count_one_file,
14
14
  )
15
+ from pdaltools.las_info import las_info_metadata
15
16
 
16
17
  TEST_PATH = os.path.dirname(os.path.abspath(__file__))
17
18
  TMP_PATH = os.path.join(TEST_PATH, "tmp/add_points_in_pointcloud")
@@ -27,6 +28,7 @@ INPUT_LIGNES_3D_GEOJSON = os.path.join(DATA_LIGNES_PATH, "Lignes_3d_0292_6833.ge
27
28
  INPUT_LIGNES_SHAPE = os.path.join(DATA_LIGNES_PATH, "Lignes_3d_0292_6833.shp")
28
29
  OUTPUT_FILE = os.path.join(TMP_PATH, "test_semis_2023_0292_6833_LA93_IGN69.laz")
29
30
  INPUT_EMPTY_POINTS_2D = os.path.join(DATA_POINTS_3D_PATH, "Points_virtuels_2d_empty.geojson")
31
+ INPUT_PCD_WITH_EXTRA_DIMS = os.path.join(DATA_LIDAR_PATH, "test_semis_2023_0292_6833_LA93_IGN69_extra_dims.laz")
30
32
 
31
33
  # Cropped las tile used to test adding points that belong to the theorical tile but not to the
32
34
  # effective las file extent
@@ -89,6 +91,7 @@ def test_clip_3d_lines_to_tile(input_file, epsg):
89
91
  (INPUT_PCD_CROPPED, None, INPUT_POINTS_2D_FOR_CROPPED_PCD, 451),
90
92
  # Should also work if there is no points (direct copy of the input file)
91
93
  (INPUT_PCD_CROPPED, None, INPUT_EMPTY_POINTS_2D, 0),
94
+ (INPUT_PCD_WITH_EXTRA_DIMS, None, INPUT_POINTS_2D, 2423),
92
95
  ],
93
96
  )
94
97
  def test_add_points_to_las(input_file, epsg, input_points_2d, expected_nb_points):
@@ -96,10 +99,18 @@ def test_add_points_to_las(input_file, epsg, input_points_2d, expected_nb_points
96
99
  if Path(OUTPUT_FILE).exists():
97
100
  os.remove(OUTPUT_FILE)
98
101
 
102
+ metadata_in = las_info_metadata(input_file)
103
+ input_dimensions = metadata_in["dimensions"]
104
+
99
105
  points = gpd.read_file(input_points_2d)
100
106
  add_points_in_pointcloud.add_points_to_las(points, input_file, OUTPUT_FILE, epsg, 68)
101
107
  assert Path(OUTPUT_FILE).exists() # check output exists
102
108
 
109
+ metadata_out = las_info_metadata(OUTPUT_FILE)
110
+ output_dimensions = metadata_out["dimensions"]
111
+
112
+ assert input_dimensions == output_dimensions # All dimension should be preserve
113
+
103
114
  point_count = compute_count_one_file(OUTPUT_FILE)["68"]
104
115
  assert point_count == expected_nb_points # Add all points from geojson
105
116
 
@@ -138,6 +138,39 @@ def test_create_random_laz_data_ranges():
138
138
  assert np.all(las.uint_dim >= 0)
139
139
  assert np.all(las.uint_dim <= 100)
140
140
 
141
+ @pytest.mark.parametrize(
142
+ "classifications",
143
+ [
144
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
145
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
146
+ [1, 2, 3, 66, 68]
147
+ ]
148
+ )
149
+ def test_create_random_laz_classifications(classifications):
150
+ """Test that generated data is within expected ranges for different types"""
151
+ output_file = os.path.join(TMP_PATH, "test_data_ranges.laz")
152
+ extra_dims = [
153
+ ("float_dim", "float32"),
154
+ ("int_dim", "int32"),
155
+ ("uint_dim", "uint8"),
156
+ ]
157
+ create_random_laz(output_file, num_points=1000, extra_dims=extra_dims, classifications=classifications)
158
+
159
+ with laspy.open(output_file) as las_file:
160
+ las = las_file.read()
161
+
162
+ # Convert to set for faster lookups
163
+ valid_classifications = set(classifications)
164
+
165
+ # Check that all classification values are in the provided list
166
+ unique_classes = set(np.unique(las.classification))
167
+ assert unique_classes.issubset(valid_classifications), \
168
+ f"Found unexpected classification values: {unique_classes - valid_classifications}"
169
+
170
+ # Also check that we have the expected number of points
171
+ assert len(las.classification) == 1000, \
172
+ f"Expected 1000 points, got {len(las.classification)}"
173
+
141
174
 
142
175
  def test_main():
143
176
  """Test the main function"""
@@ -152,15 +185,17 @@ def test_main():
152
185
  "--output_file",
153
186
  output_file,
154
187
  "--point_format",
155
- "3",
188
+ "6",
156
189
  "--num_points",
157
190
  "50",
158
191
  "--crs",
159
192
  "2154",
160
193
  "--center",
161
- "650000,6810000",
194
+ "650000", "6810000",
162
195
  "--extra_dims",
163
196
  "height:float32",
197
+ "--classifications",
198
+ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
164
199
  ]
165
200
 
166
201
  # Run main function
@@ -174,6 +209,12 @@ def test_main():
174
209
  las = las_file.read()
175
210
  assert len(las.points) == 50
176
211
  assert "height" in las.point_format.dimension_names
212
+
213
+ # Verify classifications are within the provided range
214
+ unique_classes = set(np.unique(las.classification))
215
+ expected_classes = set(range(1, 11)) # 1-10
216
+ assert unique_classes.issubset(expected_classes), \
217
+ f"Found unexpected classification values: {unique_classes - expected_classes}"
177
218
 
178
219
  finally:
179
220
  # Restore original sys.argv
@@ -4,6 +4,7 @@ import tempfile
4
4
  import numpy as np
5
5
  import laspy
6
6
  import sys
7
+ import logging
7
8
  from pdaltools.las_rename_dimension import rename_dimension, main
8
9
  from pyproj import CRS
9
10
 
@@ -68,19 +69,27 @@ def test_rename_dimension():
68
69
  pass
69
70
 
70
71
 
71
- def test_rename_nonexistent_dimension():
72
+ def test_rename_nonexistent_dimension(caplog):
72
73
  """Test attempting to rename a dimension that doesn't exist."""
73
74
  input_file = create_test_las_file()
74
-
75
+
75
76
  with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as tmp_file:
76
77
  output_file = tmp_file.name
77
-
78
- try:
79
- with pytest.raises(RuntimeError):
80
- rename_dimension(input_file, output_file, ["nonexistent_dim"], ["new_dim"])
81
- finally:
82
- os.unlink(input_file)
83
- os.unlink(output_file)
78
+
79
+ try:
80
+ # Clear any existing log records
81
+ caplog.clear()
82
+
83
+ # Set the logging level to capture WARNING
84
+ with caplog.at_level(logging.WARNING):
85
+ rename_dimension(input_file, output_file, ["nonexistent_dim"], ["new_dim"])
86
+
87
+ # Check that the warning was logged
88
+ assert len(caplog.records) == 1
89
+ assert "Dimension nonexistent_dim not found in input file" in caplog.records[0].message
90
+ finally:
91
+ os.unlink(input_file)
92
+ os.unlink(output_file)
84
93
 
85
94
 
86
95
  def test_rename_to_existing_dimension():
@@ -98,19 +107,28 @@ def test_rename_to_existing_dimension():
98
107
  os.unlink(output_file)
99
108
 
100
109
 
101
- def test_rename_dimension_case_sensitive():
110
+ def test_rename_dimension_case_sensitive(caplog):
102
111
  """Test that dimension renaming is case-sensitive."""
103
112
  input_file = create_test_las_file()
104
113
 
105
114
  with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as tmp_file:
106
115
  output_file = tmp_file.name
116
+
117
+ try:
118
+ # Clear any existing log records
119
+ caplog.clear()
120
+
121
+ # Set the logging level to capture WARNING
122
+ with caplog.at_level(logging.WARNING):
123
+ rename_dimension(input_file, output_file, ["TEST_DIM"], ["new_dim"])
124
+
125
+ # Check that the warning was logged
126
+ assert len(caplog.records) == 1
127
+ assert "Dimension TEST_DIM not found in input file" in caplog.records[0].message
128
+ finally:
129
+ os.unlink(input_file)
130
+ os.unlink(output_file)
107
131
 
108
- try:
109
- with pytest.raises(RuntimeError):
110
- rename_dimension(input_file, output_file, ["TEST_DIM"], ["new_dim"])
111
- finally:
112
- os.unlink(input_file)
113
- os.unlink(output_file)
114
132
 
115
133
 
116
134
  def test_rename_dimension_main():