ign-pdal-tools 1.12.3__py3-none-any.whl → 1.14.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ign-pdal-tools
3
- Version: 1.12.3
3
+ Version: 1.14.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
@@ -68,6 +68,7 @@ pcd_infos.get_pointcloud_origin_from_tile_width(points, tile_width=1000)
68
68
  * [las_clip.py](pdaltools/las_clip.py): crop a LAS file using 2d bounding box
69
69
  * [las_merge.py](pdaltools/las_merge.py): merge a LAS file with its neighbors according to their filenames
70
70
  * [las_add_buffer.py](pdaltools/las_add_buffer.py): add points to a LAS file from a buffer (border) from its neighbors (using filenames to locate neighbors)
71
+ * [replace_area_in_pointcloud.py](pdaltools/replace_area_in_pointcloud.py): replace the points from a LAS file by the points of another LAS file inside areas defined in a vector file (geojson, shapefile)
71
72
 
72
73
  **WARNING**: In `las_merge.py` and `las_add_buffer.py`, filenames are used to get the LAS files extents
73
74
  and to find neighbors.
@@ -1,20 +1,22 @@
1
- ign_pdal_tools-1.12.3.dist-info/licenses/LICENSE.md,sha256=iVzCFZTUXeiqP8bP474iuWZiWO_kDCD4SPh1Wiw125Y,1120
2
- pdaltools/_version.py,sha256=imnrJGefuTNyDbmr09xOFtEVybYZhC7mSJkI2WzqG1Y,75
3
- pdaltools/add_points_in_pointcloud.py,sha256=Q8rCgLi81IsGRUf77tsVb1WJIF-IGLdFk-rC5NA0itE,12679
4
- pdaltools/color.py,sha256=vJgpb8dOvT5rnq5NdVOaMdGc_pKL3damLy4HwGvigJQ,14472
5
- pdaltools/create_random_laz.py,sha256=kFe5iHeHlkgKWRIKjK5l1AD65OG4qLYwAZdO1Wcvuos,5255
1
+ ign_pdal_tools-1.14.0.dist-info/licenses/LICENSE.md,sha256=iVzCFZTUXeiqP8bP474iuWZiWO_kDCD4SPh1Wiw125Y,1120
2
+ pdaltools/_version.py,sha256=eHT0NBFMCS431k6M3stEzwiZkw_EO8ROqR0MO0r5LM4,75
3
+ pdaltools/add_points_in_pointcloud.py,sha256=VM2HW2b1Ul_I8jtXaOpTsmyGjiEFgoSi8AmCLuj6gH8,12697
4
+ pdaltools/color.py,sha256=s-_rmLK6fIK3UwkUzHVZPEkm6r1LliG5ftGr-jkqyjM,9549
5
+ pdaltools/create_random_laz.py,sha256=XuHH4G8Nrs8DB-F8bkcIeto7JtmrlrNGF_R66oxGCbQ,6069
6
+ pdaltools/download_image.py,sha256=DG9PunQsjw7Uyyf4YMVp8LMH0G3Uo4cahx5EZbdi3so,7846
6
7
  pdaltools/las_add_buffer.py,sha256=rnFExAfi0KqlQpL4hDMh2aC08AcYdSHSB6WPG5RyFIc,11274
7
8
  pdaltools/las_clip.py,sha256=GvEOYu8RXV68e35kU8i42GwSkbo4P9TvmS6rkrdPmFM,1034
8
9
  pdaltools/las_comparison.py,sha256=B9hFGbmD0x4JEN4oHbiQFNbd0T-9P3mnAN67Czu0pZk,4505
9
10
  pdaltools/las_info.py,sha256=lMKxKzsViptDENI1wOlANG4qOvdc19ixyasYKD-N1ck,9512
10
11
  pdaltools/las_merge.py,sha256=tcFVueV9X9nNEaoAl5zCduY5DETlBg63MAgP2SuKiNo,4121
11
12
  pdaltools/las_remove_dimensions.py,sha256=f8imGhN6LNTuQ1GMJQRzIIV3Wab_oRPOyEnKi1CgfiM,2318
12
- pdaltools/las_rename_dimension.py,sha256=zXEKHyx1uQ3U0oZYo_BTnqbTHGSq5TIZHqZn_EPqNKQ,2576
13
+ pdaltools/las_rename_dimension.py,sha256=FEWIcq0ZZiv9xWbCLDRE9Hzb5K0YYfoi3Z8IZFEs-uU,2887
13
14
  pdaltools/pcd_info.py,sha256=NIAH5KGikVDQLlbCcw9FuaPqe20UZvRfkHsDZd5kmZA,3210
15
+ pdaltools/replace_area_in_pointcloud.py,sha256=SaF5NXjtMOVyU4XUfO0REZGBZoRp32zjmrb1ZodfEow,3319
14
16
  pdaltools/replace_attribute_in_las.py,sha256=MHpIizSupgWtbizteoRH8FKDE049hrAh4v_OhmRmSPU,4318
15
17
  pdaltools/standardize_format.py,sha256=I2oNiwhSMtr4e5ZK9qbB_yKmy3twOoO6QLiSFu4_AaI,3905
16
18
  pdaltools/unlock_file.py,sha256=G2odk0cpp_X9r49Y90oK88v3qlihaMfg6acwmWqblik,1958
17
- ign_pdal_tools-1.12.3.dist-info/METADATA,sha256=inI_jvR-SofWol9ZIbQS10hQtDPp173eXjOq8Rj1aLk,5778
18
- ign_pdal_tools-1.12.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- ign_pdal_tools-1.12.3.dist-info/top_level.txt,sha256=KvGW0ZzqQbhCKzB5_Tp_buWMZyIgiO2M2krWF_ecOZc,10
20
- ign_pdal_tools-1.12.3.dist-info/RECORD,,
19
+ ign_pdal_tools-1.14.0.dist-info/METADATA,sha256=QIzL7Suc-iA8MNeYrwzmJX6mT3pOXlfcLx07UhvmPVA,5982
20
+ ign_pdal_tools-1.14.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ ign_pdal_tools-1.14.0.dist-info/top_level.txt,sha256=KvGW0ZzqQbhCKzB5_Tp_buWMZyIgiO2M2krWF_ecOZc,10
22
+ ign_pdal_tools-1.14.0.dist-info/RECORD,,
pdaltools/_version.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.12.3"
1
+ __version__ = "1.14.0"
2
2
 
3
3
 
4
4
  if __name__ == "__main__":
@@ -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
 
pdaltools/color.py CHANGED
@@ -1,196 +1,12 @@
1
1
  import argparse
2
2
  import tempfile
3
- import time
4
3
  from math import ceil, floor
5
- from pathlib import Path
6
4
  from typing import Tuple
7
5
 
8
- import numpy as np
9
6
  import pdal
10
- import requests
11
- from osgeo import gdal, gdal_array
12
7
 
13
8
  import pdaltools.las_info as las_info
14
- from pdaltools.unlock_file import copy_and_hack_decorator
15
-
16
-
17
- def pretty_time_delta(seconds):
18
- sign_string = "-" if seconds < 0 else ""
19
- seconds = abs(int(seconds))
20
- days, seconds = divmod(seconds, 86400)
21
- hours, seconds = divmod(seconds, 3600)
22
- minutes, seconds = divmod(seconds, 60)
23
- if days > 0:
24
- return "%s%dd%dh%dm%ds" % (sign_string, days, hours, minutes, seconds)
25
- elif hours > 0:
26
- return "%s%dh%dm%ds" % (sign_string, hours, minutes, seconds)
27
- elif minutes > 0:
28
- return "%s%dm%ds" % (sign_string, minutes, seconds)
29
- else:
30
- return "%s%ds" % (sign_string, seconds)
31
-
32
-
33
- def retry(times, delay, factor, debug=False):
34
- def decorator(func):
35
- def newfn(*args, **kwargs):
36
- attempt = 1
37
- new_delay = delay
38
- while attempt <= times:
39
- need_retry = False
40
- try:
41
- return func(*args, **kwargs)
42
- except requests.exceptions.RequestException as err:
43
- print("Connection Error:", err)
44
- need_retry = True
45
- if need_retry:
46
- print(f"{attempt}/{times} Nouvel essai après une pause de {pretty_time_delta(new_delay)} .. ")
47
- if not debug:
48
- time.sleep(new_delay)
49
- new_delay = new_delay * factor
50
- attempt += 1
51
-
52
- return func(*args, **kwargs)
53
-
54
- return newfn
55
-
56
- return decorator
57
-
58
-
59
- def is_image_white(filename: str):
60
- raster_array = gdal_array.LoadFile(filename)
61
- band_is_white = [np.all(band == 255) for band in raster_array]
62
- return np.all(band_is_white)
63
-
64
-
65
- def download_image_from_geoplateforme(
66
- proj, layer, minx, miny, maxx, maxy, width_pixels, height_pixels, outfile, timeout, check_images
67
- ):
68
- """
69
- Download image using a wms request to geoplateforme.
70
-
71
- Args:
72
- proj (int): epsg code for the projection of the downloaded image.
73
- layer: which kind of image is downloaded (ORTHOIMAGERY.ORTHOPHOTOS, ORTHOIMAGERY.ORTHOPHOTOS.IRC, ...).
74
- minx, miny, maxx, maxy: box of the downloaded image.
75
- width_pixels: width in pixels of the downloaded image.
76
- height_pixels: height in pixels of the downloaded image.
77
- outfile: file name of the downloaded file
78
- timeout: delay after which the request is canceled (in seconds)
79
- check_images (bool): enable checking if the output image is not a white image
80
- """
81
-
82
- # for layer in layers:
83
- URL_GPP = "https://data.geopf.fr/wms-r/wms?"
84
- URL_FORMAT = "&EXCEPTIONS=text/xml&FORMAT=image/geotiff&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&STYLES="
85
- URL_EPSG = "&CRS=EPSG:" + str(proj)
86
- URL_BBOX = "&BBOX=" + str(minx) + "," + str(miny) + "," + str(maxx) + "," + str(maxy)
87
- URL_SIZE = "&WIDTH=" + str(width_pixels) + "&HEIGHT=" + str(height_pixels)
88
-
89
- URL = URL_GPP + "LAYERS=" + layer + URL_FORMAT + URL_EPSG + URL_BBOX + URL_SIZE
90
-
91
- print(URL)
92
- if timeout < 10:
93
- print(f"Mode debug avec un timeout à {timeout} secondes")
94
-
95
- req = requests.get(URL, allow_redirects=True, timeout=timeout)
96
- req.raise_for_status()
97
- print(f"Ecriture du fichier: {outfile}")
98
- open(outfile, "wb").write(req.content)
99
-
100
- if check_images and is_image_white(outfile):
101
- raise ValueError(f"Downloaded image is white, with stream: {layer}")
102
-
103
-
104
- def compute_cells_size(mind: float, maxd: float, pixel_per_meter: float, size_max_gpf: int) -> Tuple[int, int, int]:
105
- """Compute cell size to have cells of almost equal size, but phased the same way as
106
- if there had been no paving by forcing cell_size (in pixels) to be an integer
107
-
108
- Args:
109
- mind (float): minimum value along the dimension, in meters
110
- maxd (float): maximum value along the dimension, in meters
111
- pixel_per_meter (float): resolution (in number of pixels per meter)
112
- size_max_gpf (int): maximum image size in pixels
113
-
114
- Returns:
115
- Tuple[int, int, int]: number of pixels in total, number of cells along the dimension, cell size in pixels
116
- """
117
- nb_pixels = ceil((maxd - mind) * pixel_per_meter)
118
- nb_cells = ceil(nb_pixels / size_max_gpf)
119
- cell_size_pixels = ceil(nb_pixels / nb_cells) # Force cell size to be an integer
120
-
121
- return nb_pixels, nb_cells, cell_size_pixels
122
-
123
-
124
- @copy_and_hack_decorator
125
- def download_image(proj, layer, minx, miny, maxx, maxy, pixel_per_meter, outfile, timeout, check_images, size_max_gpf):
126
- """
127
- Download image using a wms request to geoplateforme with call of download_image_from_geoplateforme() :
128
- image are downloaded in blocks then merged, in order to limit the size of geoplateforme requests.
129
-
130
- Args:
131
- proj: projection of the downloaded image.
132
- layer: which kind of image is downloaed (ORTHOIMAGERY.ORTHOPHOTOS, ORTHOIMAGERY.ORTHOPHOTOS.IRC, ...).
133
- minx, miny, maxx, maxy: box of the downloaded image.
134
- pixel_per_meter: resolution of the downloaded image.
135
- outfile: file name of the downloaed file
136
- timeout: time after the request is canceled
137
- check_images: check if images is not a white image
138
- size_max_gpf: block size of downloaded images. (in pixels)
139
-
140
- return the number of effective requests
141
- """
142
-
143
- download_image_from_geoplateforme_retrying = retry(times=9, delay=5, factor=2)(download_image_from_geoplateforme)
144
-
145
- size_x_p, nb_cells_x, cell_size_x = compute_cells_size(minx, maxx, pixel_per_meter, size_max_gpf)
146
- size_y_p, nb_cells_y, cell_size_y = compute_cells_size(minx, maxx, pixel_per_meter, size_max_gpf)
147
-
148
- # the image size is under SIZE_MAX_IMAGE_GPF
149
- if (size_x_p <= size_max_gpf) and (size_y_p <= size_max_gpf):
150
- download_image_from_geoplateforme_retrying(
151
- proj, layer, minx, miny, maxx, maxy, cell_size_x, cell_size_y, outfile, timeout, check_images
152
- )
153
- return 1
154
-
155
- # the image is bigger than the SIZE_MAX_IMAGE_GPF
156
- # it's preferable to compute it by paving
157
- with tempfile.TemporaryDirectory() as tmp_dir:
158
- tmp_gpg_ortho = []
159
- for line in range(0, nb_cells_y):
160
- for col in range(0, nb_cells_x):
161
- # Cope for last line/col that can be slightly smaller than other cells
162
- remaining_pixels_x = size_x_p - col * cell_size_x
163
- remaining_pixels_y = size_y_p - line * cell_size_y
164
- cell_size_x_local = min(cell_size_x, remaining_pixels_x)
165
- cell_size_y_local = min(cell_size_y, remaining_pixels_y)
166
-
167
- minx_cell = minx + col * cell_size_x / pixel_per_meter
168
- maxx_cell = minx_cell + cell_size_x_local / pixel_per_meter
169
- miny_cell = miny + line * cell_size_y / pixel_per_meter
170
- maxy_cell = miny_cell + cell_size_y_local / pixel_per_meter
171
-
172
- cells_ortho_paths = str(Path(tmp_dir)) + f"cell_{col}_{line}.tif"
173
- download_image_from_geoplateforme_retrying(
174
- proj,
175
- layer,
176
- minx_cell,
177
- miny_cell,
178
- maxx_cell,
179
- maxy_cell,
180
- cell_size_x_local,
181
- cell_size_y_local,
182
- cells_ortho_paths,
183
- timeout,
184
- check_images,
185
- )
186
- tmp_gpg_ortho.append(cells_ortho_paths)
187
-
188
- # merge the cells
189
- with tempfile.NamedTemporaryFile(suffix="_gpf.vrt") as tmp_vrt:
190
- gdal.BuildVRT(tmp_vrt.name, tmp_gpg_ortho)
191
- gdal.Translate(outfile, tmp_vrt.name)
192
-
193
- return nb_cells_x * nb_cells_y
9
+ from pdaltools.download_image import download_image
194
10
 
195
11
 
196
12
  def match_min_max_with_pixel_size(min_d: float, max_d: float, pixel_per_meter: float) -> Tuple[float, float]:
@@ -224,11 +40,47 @@ def color(
224
40
  color_rvb_enabled=True,
225
41
  color_ir_enabled=True,
226
42
  veget_index_file="",
43
+ vegetation_dim="Deviation",
227
44
  check_images=False,
228
45
  stream_RGB="ORTHOIMAGERY.ORTHOPHOTOS",
229
46
  stream_IRC="ORTHOIMAGERY.ORTHOPHOTOS.IRC",
230
47
  size_max_gpf=5000,
231
48
  ):
49
+ """Colorize a las file with any of the following methods:
50
+ - R, G, B values from an image retrieved from ign geoplateform via the "stream_RGB" data feed name
51
+ - Infrared from another image retrieved from ign geoplateform via the "stream_IRC" data feed name
52
+ - any field "vegetation_dim" from another image stored locally at "veget_index_file"
53
+
54
+
55
+ Args:
56
+ input_file (str): Path to the las file to colorize
57
+ output_file (str): Path to the output colorized file
58
+ proj (str, optional): EPSG value of the SRS to apply to the output file (if not provided, use the one from
59
+ the input file). Eg. "2154" to use "EPSG:2154". Defaults to "".
60
+ pixel_per_meter (int, optional): Stream image resolution (for RGB and IRC) in pixels per meter. Defaults to 5.
61
+ timeout_second (int, optional): Timeout for the geoplateform request. Defaults to 300.
62
+ color_rvb_enabled (bool, optional): If true, apply R, G, B dimensions colorization. Defaults to True.
63
+ color_ir_enabled (bool, optional): If true, apply Infrared dimension colorization. Defaults to True.
64
+ veget_index_file (str, optional): Path to the tiff tile to use for "vegetation_dim" colorization.
65
+ Defaults to "".
66
+ vegetation_dim (str, optional): Name of the dimension to use to store the values of "veget_index_file".
67
+ Defaults to "Deviation".
68
+ check_images (bool, optional): If true, check if images from the geoplateform data feed are white
69
+ (and raise and error in this case). Defaults to False.
70
+ stream_RGB (str, optional): WMS raster stream for RGB colorization:
71
+ Default stream (ORTHOIMAGERY.ORTHOPHOTOS) let the server choose the resolution
72
+ for 20cm resolution rasters, use HR.ORTHOIMAGERY.ORTHOPHOTOS
73
+ for 50 cm resolution rasters, use ORTHOIMAGERY.ORTHOPHOTOS.BDORTHO
74
+ Defaults to ORTHOIMAGERY.ORTHOPHOTOS
75
+ stream_IRC (str, optional):WMS raster stream for IRC colorization.
76
+ Documentation about possible stream : https://geoservices.ign.fr/services-web-experts-ortho.
77
+ Defaults to "ORTHOIMAGERY.ORTHOPHOTOS.IRC".
78
+ size_max_gpf (int, optional): Maximum edge size (in pixels) of downloaded images. If input file needs more,
79
+ several images are downloaded and merged. Defaults to 5000.
80
+
81
+ Returns:
82
+ Paths to the temporary files that store the streamed images (tmp_ortho, tmp_ortho_irc)
83
+ """
232
84
  metadata = las_info.las_info_metadata(input_file)
233
85
  minx, maxx, miny, maxy = las_info.get_bounds_from_header_info(metadata)
234
86
 
@@ -243,9 +95,9 @@ def color(
243
95
  writer_extra_dims = "all"
244
96
 
245
97
  if veget_index_file and veget_index_file != "":
246
- print(f"Remplissage du champ Deviation à partir du fichier {veget_index_file}")
247
- pipeline |= pdal.Filter.colorization(raster=veget_index_file, dimensions="Deviation:1:256.0")
248
- writer_extra_dims = ["Deviation=ushort"]
98
+ print(f"Remplissage du champ {vegetation_dim} à partir du fichier {veget_index_file}")
99
+ pipeline |= pdal.Filter.colorization(raster=veget_index_file, dimensions=f"{vegetation_dim}:1:256.0")
100
+ writer_extra_dims = [f"{vegetation_dim}=ushort"]
249
101
 
250
102
  tmp_ortho = None
251
103
  if color_rvb_enabled:
@@ -316,7 +168,13 @@ def parse_args():
316
168
  parser.add_argument("--rvb", action="store_true", help="Colorize RVB")
317
169
  parser.add_argument("--ir", action="store_true", help="Colorize IR")
318
170
  parser.add_argument(
319
- "--vegetation", type=str, default="", help="Vegetation file, value will be stored in Deviation field"
171
+ "--vegetation",
172
+ type=str,
173
+ default="",
174
+ help="Vegetation file (raster), value will be stored in 'vegetation_dim' field",
175
+ )
176
+ parser.add_argument(
177
+ "--vegetation_dim", type=str, default="Deviation", help="name of the extra_dim uses for the vegetation value"
320
178
  )
321
179
  parser.add_argument("--check-images", "-c", action="store_true", help="Check that downloaded image is not white")
322
180
  parser.add_argument(
@@ -357,6 +215,7 @@ if __name__ == "__main__":
357
215
  color_rvb_enabled=args.rvb,
358
216
  color_ir_enabled=args.ir,
359
217
  veget_index_file=args.vegetation,
218
+ vegetation_dim=args.vegetation_dim,
360
219
  check_images=args.check_images,
361
220
  stream_RGB=args.stream_RGB,
362
221
  stream_IRC=args.stream_IRC,
@@ -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)
@@ -0,0 +1,192 @@
1
+ """Tool to download an image from IGN geoplateform: https://geoservices.ign.fr/"""
2
+
3
+ import tempfile
4
+ import time
5
+ from math import ceil
6
+ from pathlib import Path
7
+ from typing import Tuple
8
+
9
+ import numpy as np
10
+ import requests
11
+ from osgeo import gdal, gdal_array
12
+
13
+ from pdaltools.unlock_file import copy_and_hack_decorator
14
+
15
+
16
+ def compute_cells_size(mind: float, maxd: float, pixel_per_meter: float, size_max_gpf: int) -> Tuple[int, int, int]:
17
+ """Compute cell size to have cells of almost equal size, but phased the same way as
18
+ if there had been no paving by forcing cell_size (in pixels) to be an integer
19
+
20
+ Args:
21
+ mind (float): minimum value along the dimension, in meters
22
+ maxd (float): maximum value along the dimension, in meters
23
+ pixel_per_meter (float): resolution (in number of pixels per meter)
24
+ size_max_gpf (int): maximum image size in pixels
25
+
26
+ Returns:
27
+ Tuple[int, int, int]: number of pixels in total, number of cells along the dimension, cell size in pixels
28
+ """
29
+ nb_pixels = ceil((maxd - mind) * pixel_per_meter)
30
+ nb_cells = ceil(nb_pixels / size_max_gpf)
31
+ cell_size_pixels = ceil(nb_pixels / nb_cells) # Force cell size to be an integer
32
+
33
+ return nb_pixels, nb_cells, cell_size_pixels
34
+
35
+
36
+ def is_image_white(filename: str):
37
+ raster_array = gdal_array.LoadFile(filename)
38
+ band_is_white = [np.all(band == 255) for band in raster_array]
39
+ return np.all(band_is_white)
40
+
41
+
42
+ def download_image_from_geoplateforme(
43
+ proj, layer, minx, miny, maxx, maxy, width_pixels, height_pixels, outfile, timeout, check_images
44
+ ):
45
+ """
46
+ Download image using a wms request to geoplateforme.
47
+
48
+ Args:
49
+ proj (int): epsg code for the projection of the downloaded image.
50
+ layer: which kind of image is downloaded (ORTHOIMAGERY.ORTHOPHOTOS, ORTHOIMAGERY.ORTHOPHOTOS.IRC, ...).
51
+ minx, miny, maxx, maxy: box of the downloaded image.
52
+ width_pixels: width in pixels of the downloaded image.
53
+ height_pixels: height in pixels of the downloaded image.
54
+ outfile: file name of the downloaded file
55
+ timeout: delay after which the request is canceled (in seconds)
56
+ check_images (bool): enable checking if the output image is not a white image
57
+ """
58
+
59
+ # for layer in layers:
60
+ URL_GPP = "https://data.geopf.fr/wms-r/wms?"
61
+ URL_FORMAT = "&EXCEPTIONS=text/xml&FORMAT=image/geotiff&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&STYLES="
62
+ URL_EPSG = "&CRS=EPSG:" + str(proj)
63
+ URL_BBOX = "&BBOX=" + str(minx) + "," + str(miny) + "," + str(maxx) + "," + str(maxy)
64
+ URL_SIZE = "&WIDTH=" + str(width_pixels) + "&HEIGHT=" + str(height_pixels)
65
+
66
+ URL = URL_GPP + "LAYERS=" + layer + URL_FORMAT + URL_EPSG + URL_BBOX + URL_SIZE
67
+
68
+ print(URL)
69
+ if timeout < 10:
70
+ print(f"Mode debug avec un timeout à {timeout} secondes")
71
+
72
+ req = requests.get(URL, allow_redirects=True, timeout=timeout)
73
+ req.raise_for_status()
74
+ print(f"Ecriture du fichier: {outfile}")
75
+ open(outfile, "wb").write(req.content)
76
+
77
+ if check_images and is_image_white(outfile):
78
+ raise ValueError(f"Downloaded image is white, with stream: {layer}")
79
+
80
+
81
+ def pretty_time_delta(seconds):
82
+ sign_string = "-" if seconds < 0 else ""
83
+ seconds = abs(int(seconds))
84
+ days, seconds = divmod(seconds, 86400)
85
+ hours, seconds = divmod(seconds, 3600)
86
+ minutes, seconds = divmod(seconds, 60)
87
+ if days > 0:
88
+ return "%s%dd%dh%dm%ds" % (sign_string, days, hours, minutes, seconds)
89
+ elif hours > 0:
90
+ return "%s%dh%dm%ds" % (sign_string, hours, minutes, seconds)
91
+ elif minutes > 0:
92
+ return "%s%dm%ds" % (sign_string, minutes, seconds)
93
+ else:
94
+ return "%s%ds" % (sign_string, seconds)
95
+
96
+
97
+ def retry(times, delay, factor, debug=False):
98
+ def decorator(func):
99
+ def newfn(*args, **kwargs):
100
+ attempt = 1
101
+ new_delay = delay
102
+ while attempt <= times:
103
+ need_retry = False
104
+ try:
105
+ return func(*args, **kwargs)
106
+ except requests.exceptions.RequestException as err:
107
+ print("Connection Error:", err)
108
+ need_retry = True
109
+ if need_retry:
110
+ print(f"{attempt}/{times} Nouvel essai après une pause de {pretty_time_delta(new_delay)} .. ")
111
+ if not debug:
112
+ time.sleep(new_delay)
113
+ new_delay = new_delay * factor
114
+ attempt += 1
115
+
116
+ return func(*args, **kwargs)
117
+
118
+ return newfn
119
+
120
+ return decorator
121
+
122
+
123
+ @copy_and_hack_decorator
124
+ def download_image(proj, layer, minx, miny, maxx, maxy, pixel_per_meter, outfile, timeout, check_images, size_max_gpf):
125
+ """
126
+ Download image using a wms request to geoplateforme with call of download_image_from_geoplateforme() :
127
+ image are downloaded in blocks then merged, in order to limit the size of geoplateforme requests.
128
+
129
+ Args:
130
+ proj: projection of the downloaded image.
131
+ layer: which kind of image is downloaed (ORTHOIMAGERY.ORTHOPHOTOS, ORTHOIMAGERY.ORTHOPHOTOS.IRC, ...).
132
+ minx, miny, maxx, maxy: box of the downloaded image.
133
+ pixel_per_meter: resolution of the downloaded image.
134
+ outfile: file name of the downloaed file
135
+ timeout: time after the request is canceled
136
+ check_images: check if images is not a white image
137
+ size_max_gpf: block size of downloaded images. (in pixels)
138
+
139
+ return the number of effective requests
140
+ """
141
+
142
+ download_image_from_geoplateforme_retrying = retry(times=9, delay=5, factor=2)(download_image_from_geoplateforme)
143
+
144
+ size_x_p, nb_cells_x, cell_size_x = compute_cells_size(minx, maxx, pixel_per_meter, size_max_gpf)
145
+ size_y_p, nb_cells_y, cell_size_y = compute_cells_size(minx, maxx, pixel_per_meter, size_max_gpf)
146
+
147
+ # the image size is under SIZE_MAX_IMAGE_GPF
148
+ if (size_x_p <= size_max_gpf) and (size_y_p <= size_max_gpf):
149
+ download_image_from_geoplateforme_retrying(
150
+ proj, layer, minx, miny, maxx, maxy, cell_size_x, cell_size_y, outfile, timeout, check_images
151
+ )
152
+ return 1
153
+
154
+ # the image is bigger than the SIZE_MAX_IMAGE_GPF
155
+ # it's preferable to compute it by paving
156
+ with tempfile.TemporaryDirectory() as tmp_dir:
157
+ tmp_gpg_ortho = []
158
+ for line in range(0, nb_cells_y):
159
+ for col in range(0, nb_cells_x):
160
+ # Cope for last line/col that can be slightly smaller than other cells
161
+ remaining_pixels_x = size_x_p - col * cell_size_x
162
+ remaining_pixels_y = size_y_p - line * cell_size_y
163
+ cell_size_x_local = min(cell_size_x, remaining_pixels_x)
164
+ cell_size_y_local = min(cell_size_y, remaining_pixels_y)
165
+
166
+ minx_cell = minx + col * cell_size_x / pixel_per_meter
167
+ maxx_cell = minx_cell + cell_size_x_local / pixel_per_meter
168
+ miny_cell = miny + line * cell_size_y / pixel_per_meter
169
+ maxy_cell = miny_cell + cell_size_y_local / pixel_per_meter
170
+
171
+ cells_ortho_paths = str(Path(tmp_dir)) + f"cell_{col}_{line}.tif"
172
+ download_image_from_geoplateforme_retrying(
173
+ proj,
174
+ layer,
175
+ minx_cell,
176
+ miny_cell,
177
+ maxx_cell,
178
+ maxy_cell,
179
+ cell_size_x_local,
180
+ cell_size_y_local,
181
+ cells_ortho_paths,
182
+ timeout,
183
+ check_images,
184
+ )
185
+ tmp_gpg_ortho.append(cells_ortho_paths)
186
+
187
+ # merge the cells
188
+ with tempfile.NamedTemporaryFile(suffix="_gpf.vrt") as tmp_vrt:
189
+ gdal.BuildVRT(tmp_vrt.name, tmp_gpg_ortho)
190
+ gdal.Translate(outfile, tmp_vrt.name)
191
+
192
+ return nb_cells_x * nb_cells_y
@@ -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()
@@ -0,0 +1,79 @@
1
+ import argparse
2
+
3
+ import pdal
4
+
5
+ from pdaltools.las_info import get_writer_parameters_from_reader_metadata
6
+
7
+
8
+ def parse_args():
9
+ parser = argparse.ArgumentParser(
10
+ "Replace points in a pointcloud with points from another pointcloud based on a area"
11
+ )
12
+ parser.add_argument("--target_cloud", "-t", type=str, help="filepath of target cloud to be modified")
13
+ parser.add_argument("--source_cloud", "-s", type=str, help="filepath of source cloud to use for replacement")
14
+ parser.add_argument("--replacement_area_file", "-r", type=str, help="filepath of file containing areas to replace")
15
+ parser.add_argument("--filter", "-f", type=str, help="pdal filter expression to apply to target_cloud")
16
+ parser.add_argument("--outfile", "-o", type=str, help="output file")
17
+ return parser.parse_args()
18
+
19
+
20
+ def get_writer_params(input_file):
21
+ pipeline = pdal.Pipeline()
22
+ pipeline |= pdal.Reader.las(filename=input_file)
23
+ pipeline.execute()
24
+ params = get_writer_parameters_from_reader_metadata(pipeline.metadata)
25
+ return params
26
+
27
+
28
+ def replace_area(target_cloud, source_cloud, replacement_area_file, outfile, writer_params, filter=""):
29
+ crops = []
30
+ pipeline_target = pdal.Pipeline()
31
+ pipeline_target |= pdal.Reader.las(filename=target_cloud)
32
+ pipeline_target |= pdal.Filter.ferry(dimensions="=> geometryFid")
33
+ # Assign -1 to all points because overlay replaces values from 0 and more
34
+ pipeline_target |= pdal.Filter.assign(assignment="geometryFid[:]=-1")
35
+ if filter:
36
+ pipeline_target |= pdal.Filter.expression(expression=filter)
37
+ pipeline_target |= pdal.Filter.overlay(column="fid", dimension="geometryFid", datasource=replacement_area_file)
38
+ # Keep only points out of the area
39
+ pipeline_target |= pdal.Filter.expression(expression="geometryFid==-1", tag="A")
40
+ pipeline_target.execute()
41
+
42
+ input_dimensions = list(pipeline_target.arrays[0].dtype.fields.keys())
43
+ # do not keep geometryFid
44
+ output_dimensions = [dim for dim in input_dimensions if dim not in "geometryFid"]
45
+ target_cloud_pruned = pipeline_target.arrays[0][output_dimensions]
46
+ crops.append(target_cloud_pruned)
47
+
48
+ pipeline_source = pdal.Pipeline()
49
+ pipeline_source |= pdal.Reader.las(filename=source_cloud)
50
+ pipeline_source |= pdal.Filter.ferry(dimensions="=> geometryFid")
51
+ pipeline_source |= pdal.Filter.assign(assignment="geometryFid[:]=-1")
52
+ pipeline_source |= pdal.Filter.overlay(column="fid", dimension="geometryFid", datasource=replacement_area_file)
53
+ # Keep only points in the area
54
+ pipeline_source |= pdal.Filter.expression(expression="geometryFid>=0", tag="B")
55
+ pipeline_source.execute()
56
+
57
+ # delete geometryFid from source_cloud
58
+ source_cloud_pruned = pipeline_source.arrays[0][output_dimensions]
59
+ crops.append(source_cloud_pruned)
60
+
61
+ # Merge
62
+ pipeline = pdal.Filter.merge().pipeline(*crops)
63
+ pipeline |= pdal.Writer.las(filename=outfile, **writer_params)
64
+ pipeline.execute()
65
+
66
+
67
+ def main():
68
+ args = parse_args()
69
+
70
+ writer_parameters = get_writer_params(args.target_cloud)
71
+ # writer_parameters["extra_dims"] = "" # no extra-dim by default
72
+
73
+ replace_area(
74
+ args.target_cloud, args.source_cloud, args.replacement_area_file, args.outfile, writer_parameters, args.filter
75
+ )
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()