From 172cf011da9aa84a1420d268f4c41cebbe971444 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 18 Jul 2024 15:12:09 +0200 Subject: [PATCH 01/85] update readme for methodology : traitement grand cours d'eau --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4df90f02..0a3202df 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ Trois grands axes du processus à mettre en place en distanguant l'échelle de t Il existe plusieurs étapes intermédiaires : * 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unid) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle) -* 2- Analyse de la répartition en Z de l'ensemble des points LIDAR "Sol" -* 3- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : - * Associer chaque point virtuel 2D au point le plus proche du squelette hydrographique - * Traitement "Z" du squelette : - * Analyser la répartition en Z de l’ensemble des points LIDAR extrait à l’étape précédente afin d’extraire une seule valeur d’altitude au point fictif Z le plus proche du squelette hydrographique. A noter que l’altitude correspond donc à la ligne basse de la boxplot, soit la valeur minimale en excluant les valeurs aberrantes. Pour les ponts, une étape de contrôle de la classification pourrait être mise en place - * Lisser les Z le long du squelette HYDRO pour assurer l'écoulement - * Création des points virtuels 2D tous les 0.5 mètres le long des bords du masque hydrographique "écoulement" en cohérence en "Z" avec le squelette hydrographique +A l'échelle de l'entité hydrographique : +* 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres +* 3- Transformer les coordonnées de ces points (étape précédente) en abscisses curvilignes +* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. A noter que les Z le long du squelette HYDRO doivent assurer l'écoulement. +* 5- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : + * Création des points virtuels 2D espacés selon une grille régulière à l'intérieur du masque hydrographique "écoulement" + * Affecter une valeur d'altitude à ces points virtuels en fonction des "Z" calculés à l'étape précédente (interpolation linéaire) ### Traitement des surfaces planes (mer, lac, étang, etc.) Pour rappel, l'eau est considérée comme horizontale sur ce type de surface. From 8c103b34d0525f00768e14c1afc3c72584601ec0 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 16:58:06 +0200 Subject: [PATCH 02/85] refacto config with virtual points --- configs/configs_lidro.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 4f98e4b3..7f93fcbc 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -47,11 +47,13 @@ virtual_point: keep_neighbors_classes: [2, 9] vector: # distance in meters between 2 consecutive points - distance_meters: 2 + distance_meters: 1 # buffer for searching the points classification (default. "2") of the input LAS/LAZ file - buffer: 2 + buffer: 1 # The number of nearest neighbors to find with KNeighbors - k: 20 + k: 6* + # Spacing between the grid points in meters + s: 1 skeleton: max_gap_width: 200 # distance max in meter of any gap between 2 branches we will try to close with a line From e67d7be2964776696a5f786a3e771b08a4c441a5 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 16:59:33 +0200 Subject: [PATCH 03/85] add fonction KNN --- lidro/create_virtual_point/stats/knn_distance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lidro/create_virtual_point/stats/knn_distance.py b/lidro/create_virtual_point/stats/knn_distance.py index 7362bcbe..3b47bac8 100644 --- a/lidro/create_virtual_point/stats/knn_distance.py +++ b/lidro/create_virtual_point/stats/knn_distance.py @@ -32,6 +32,5 @@ def find_k_nearest_neighbors(points_skeleton: np.array, points_ground_3d: np.arr # Retrieve the points corresponding to the indices # Use indices to retrieve the closest 3D points k_nearest_points = points_ground_3d[indices[0]] - # = points_3d[indices.flatten()] return k_nearest_points From ef59cd3996a6954f2887386b45e7ac28a5e4d84d Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:01:04 +0200 Subject: [PATCH 04/85] add function for lauch virtual points --- lidro/main_create_virtual_point.py | 40 +++++++++++++++++++------- test/test_main_create_virtual_point.py | 12 ++++---- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 2ead2845..2c6f9bee 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -13,15 +13,24 @@ sys.path.append('../lidro') +from lidro.create_virtual_point.pointcloud.convert_geodataframe_to_las import ( + geodataframe_to_las, +) from lidro.create_virtual_point.vectors.extract_points_around_skeleton import ( extract_points_around_skeleton_points_one_tile, ) from lidro.create_virtual_point.vectors.mask_hydro_with_buffer import ( import_mask_hydro_with_buffer, ) +from lidro.create_virtual_point.vectors.merge_skeleton_by_mask import ( + merge_skeleton_by_mask, +) from lidro.create_virtual_point.vectors.points_along_skeleton import ( generate_points_along_skeleton, ) +from lidro.create_virtual_point.vectors.run_create_virtual_points import ( + lauch_virtual_points_by_section, +) @hydra.main(config_path="../configs/", config_name="configs_lidro.yaml", version_base="1.2") @@ -36,7 +45,6 @@ def main(config: DictConfig): It contains the algorithm parameters and the input/output parameters """ logging.basicConfig(level=logging.INFO) - # Check input/output files and folders input_dir = config.io.input_dir if input_dir is None: @@ -53,7 +61,6 @@ def main(config: DictConfig): # If input filename is not provided, lidro runs on the whole input_dir directory initial_las_filename = config.io.input_filename - input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton @@ -63,6 +70,7 @@ def main(config: DictConfig): crs = CRS.from_user_input(config.io.srid) classes = config.virtual_point.filter.keep_neighbors_classes k = config.virtual_point.vector.k + s = config.virtual_point.vector.s # Step 1 : Import Mask Hydro, then apply a buffer # Return GeoDataframe @@ -78,7 +86,6 @@ def main(config: DictConfig): points_clip = extract_points_around_skeleton_points_one_tile( initial_las_filename, input_dir, input_mask_hydro_buffer, points_skeleton_gdf, classes, k ) - else: # Lauch croping filtered pointcloud by Mask Hydro with buffer tile by tile input_dir_points = os.path.join(input_dir, "pointcloud") @@ -91,15 +98,28 @@ def main(config: DictConfig): # Flatten the list of lists into a single list of dictionaries points_clip = [item for sublist in points_clip_list for item in sublist] - # Create a pandas DataFrame from the flattened list + # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip) # Create a GeoDataFrame from the pandas DataFrame - result_gdf = gpd.GeoDataFrame(df, geometry="geometry") - result_gdf.set_crs(crs, inplace=True) - # path to the Points along Skeleton Hydro file - output_file = os.path.join(output_dir, "Points_Skeleton.GeoJSON") - # Save to GeoJSON - result_gdf.to_file(output_file, driver="GeoJSON") + points_gdf = gpd.GeoDataFrame(df, geometry="geometry") + points_gdf.set_crs(crs, inplace=True) + + # Step 4: Smooth Z by hydro's section + # Combine skeleton lines into a single polyline for each hydro entity + gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) + + gdf_virtual_points = [ + lauch_virtual_points_by_section( + points_gdf, + gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), + gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), + crs, + s, + ) + for idx, row in gdf_merged.iterrows() + ] + # Save the virtual points (.LAS) + geodataframe_to_las(gdf_virtual_points, output_dir, crs) if __name__ == "__main__": diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 27401864..896c9a09 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -32,10 +32,11 @@ def test_main_lidro_default(): output_dir = OUTPUT_DIR / "main_lidro_default" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 10 - buffer = 10 + distances_meters = 5 + buffer = 3 srid = 2154 - k = 20 + k = 3 + s = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module @@ -49,11 +50,12 @@ def test_main_lidro_default(): f"virtual_point.vector.distance_meters={distances_meters}", f"virtual_point.vector.buffer={buffer}", f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) main(cfg) - assert (Path(output_dir) / "Points_Skeleton.GeoJSON").is_file() + assert (Path(output_dir) / "virtual_points.las").is_file() def test_main_lidro_input_file(): @@ -83,7 +85,7 @@ def test_main_lidro_input_file(): ], ) main(cfg) - assert (Path(output_dir) / "Points_Skeleton.GeoJSON").is_file() + assert (Path(output_dir) / "virtual_points.las").is_file() def test_main_lidro_fail_no_input_dir(): From 4e6c02ea9436c3ca108cfcc2ff06474a07f660fe Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:02:47 +0200 Subject: [PATCH 05/85] refacto function: delete calculate Z q1 --- .../vectors/las_around_point.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lidro/create_virtual_point/vectors/las_around_point.py b/lidro/create_virtual_point/vectors/las_around_point.py index ece0dec5..5b31901a 100644 --- a/lidro/create_virtual_point/vectors/las_around_point.py +++ b/lidro/create_virtual_point/vectors/las_around_point.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- -""" Extract a Z elevation value every N meters along the hydrographic skeleton +""" Extract a list of N "ground" points closest to the N points of the hydro skeleton """ from typing import List import numpy as np from shapely.geometry import Point -from lidro.create_virtual_point.stats.calculate_stat import calculate_percentile from lidro.create_virtual_point.stats.knn_distance import find_k_nearest_neighbors def filter_las_around_point(points_skeleton: List, points_clip: np.array, k: int) -> List: - """Extract a Z elevation value every N meters along the hydrographic skeleton + """Extract a list of N "ground" points closest to the N points of the hydro skeleton Args: points_skeleton (list) : points every N meters (by default: 2) along skeleton Hydro @@ -19,7 +18,7 @@ def filter_las_around_point(points_skeleton: List, points_clip: np.array, k: int k (int): The number of nearest neighbors to find Returns: - List : Result {'geometry': Point 3D, 'z_q1': points KNN} + List : Result {'geometry': Point 2D on skeleton, 'points_knn': List of LIDAR points from} """ # Finds the K nearest neighbors of a given point from a list of 3D points points_knn_list = [ @@ -27,11 +26,4 @@ def filter_las_around_point(points_skeleton: List, points_clip: np.array, k: int for point in points_skeleton ] - # Calcule Z "Q1" for each points every N meters along skeleton hydro - results = [ - ({"geometry": p["geometry"], "z_q1": calculate_percentile(p["points_knn"], 10)}) - for p in points_knn_list - if not np.all(np.isnan(p["points_knn"])) - ] - - return results + return points_knn_list From e6a4ab69f2cb6716366ebce97475fa03a9c8832b Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:03:53 +0200 Subject: [PATCH 06/85] refacto mask buffer with negative buffer --- .../vectors/mask_hydro_with_buffer.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py b/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py index c8ab157b..6f627c04 100644 --- a/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py +++ b/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -""" Extract a Z elevation value every N meters along the hydrographic skeleton +""" Create a new Mask Hydro at the edge of the bank with a buffer """ import geopandas as gpd from shapely.geometry import CAP_STYLE def import_mask_hydro_with_buffer(file_mask: str, buffer: float, crs: str | int) -> gpd.GeoDataFrame: - """Apply buffer (2 meters by default) from Mask Hydro + """Create a new Mask Hydro at the edge of the bank with a buffer (+50 cm and -N cm) Args: file_mask (str): Path from Mask Hydro @@ -19,10 +19,11 @@ def import_mask_hydro_with_buffer(file_mask: str, buffer: float, crs: str | int) # Read Mask Hydro merged gdf = gpd.read_file(file_mask, crs=crs).unary_union - # Apply buffer (2 meters by default) from Mask Hydro - gdf_buffer = gdf.buffer(buffer, cap_style=CAP_STYLE.square) - + # Apply negative's buffer + difference from Mask Hydro # Return a polygon representing the limit of the bank with a buffer of N meters - limit_bank = gdf_buffer.difference(gdf) + gdf_buffer = gdf.difference(gdf.buffer(-abs(buffer), cap_style=CAP_STYLE.square)) + # gdf_buffer = gdf.buffer(0.1, cap_style=CAP_STYLE.square).difference( + # gdf.buffer(-abs(buffer), cap_style=CAP_STYLE.square) + # ) - return limit_bank + return gdf_buffer From 584f0945adbddd23111c350751eccc9496e97348 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:05:13 +0200 Subject: [PATCH 07/85] add test function las around point --- test/vectors/test_las_around_point.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/test/vectors/test_las_around_point.py b/test/vectors/test_las_around_point.py index 5cdfb756..214bf239 100644 --- a/test/vectors/test_las_around_point.py +++ b/test/vectors/test_las_around_point.py @@ -4,6 +4,7 @@ import geopandas as gpd import numpy as np +import pandas as pd from pyproj import CRS from shapely.geometry import Point @@ -40,14 +41,12 @@ def test_las_around_point_default(): result = filter_las_around_point(points_along_skeleton, points_clip, k) - # Convert results to GeoDataFrame - result_gdf = gpd.GeoDataFrame(result) - result_gdf.set_crs(crs, inplace=True) - # Save to GeoJSON - result_gdf.to_file(output, driver="GeoJSON") + # Create a pandas DataFrame from the flattened list + df = pd.DataFrame(result) + # Create a GeoDataFrame from the pandas DataFrame + points_gdf = gpd.GeoDataFrame(df, geometry="geometry") + points_gdf.set_crs(crs, inplace=True) - gdf = gpd.read_file(output) - - assert not gdf.empty # GeoDataFrame shouldn't empty - assert gdf.crs.to_string() == crs # CRS is identical - assert all(isinstance(geom, Point) for geom in gdf.geometry) # All geometry should Points + assert not points_gdf.empty # GeoDataFrame shouldn't empty + assert points_gdf.crs.to_string() == crs # CRS is identical + assert all(isinstance(geom, Point) for geom in points_gdf.geometry) # All geometry should Points From 9db18fad664bbb73209e103f6be96c64790506f1 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:07:17 +0200 Subject: [PATCH 08/85] add function and test for merging skeleton by mask hydro --- .../vectors/merge_skeleton_by_mask.py | 112 ++++++++++++++++++ test/vectors/test_merge_skeleton_by_mask.py | 39 ++++++ 2 files changed, 151 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py create mode 100644 test/vectors/test_merge_skeleton_by_mask.py diff --git a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py new file mode 100644 index 00000000..0dfca5da --- /dev/null +++ b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +""" Merge skeleton by Mask Hydro +""" +import logging +import os + +import geopandas as gpd +import pandas as pd +from shapely.geometry import LineString, MultiLineString + + +def explode_multipart(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Explode multi-part geometries into single-part geometries + + Args: + gdf (gpd.GeoDataFrame): A GeoDataFrame + + Returns: + gpd.GeoDataframe : a GeoDataFrame exploded + """ + exploded_geom = gdf.explode(index_parts=True) + exploded_geom = exploded_geom.reset_index(drop=True) + + return exploded_geom + + +def combine_and_connect_lines(geometries: list) -> LineString: + """Combines and connects a list of LineString and MultiLineString geometries into a single LineString. + + Args: + geometries (list): A list of LineString and/or MultiLineString objects. + + Returns: + LineString: The merged and connected geometry. + """ + # Convert all geometries into lists of lines + lines = [geom.geoms if isinstance(geom, MultiLineString) else [geom] for geom in geometries] + # Extract and combine all coordinates from the nested list using a list comprehension + all_coords = [ + coord for sublist in lines for line in sublist if isinstance(line, LineString) for coord in line.coords + ] + # Create a new connected line + connected_line = LineString(all_coords) + + return connected_line + + +def merge_skeleton_by_mask( + input_skeleton: gpd.GeoDataFrame, input_mask_hydro: gpd.GeoDataFrame, output_dir: str, crs: str +) -> pd.DataFrame: + """Combine skeleton lines into a single polyline for each hydro entity. + Save a invalid mask to GeoDataframe + + Args: + input_skeleton (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + input_mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing Mask Hydro + output_dir (str): output folder + crs (str): a pyproj CRS object used to create the out + + Returns: + Dataframe : Dataframe with single polyline "skeleton" by hydro entity + """ # Inputs + gdf_skeleton = gpd.read_file(input_skeleton) + gdf_skeleton = explode_multipart(gdf_skeleton) + gdf_mask_hydro = gpd.read_file(input_mask_hydro) + gdf_mask_hydro = explode_multipart(gdf_mask_hydro) + + # # Perform a spatial join to find skeletons within each mask_hydro + gdf_joined = gpd.sjoin(gdf_skeleton, gdf_mask_hydro, how="inner", predicate="within") + + # Combine skeleton lines into a single polyline for each hydro entity + combined_skeletons = gdf_joined.groupby("index_right")["geometry"].apply(combine_and_connect_lines) + gdf_combined_skeletons = gpd.GeoDataFrame(combined_skeletons, columns=["geometry"], crs=crs).reset_index() + + # # Re-join with mask_hydro to keep only the combined skeletons within masks + gdf_joined_combined = gpd.sjoin( + gdf_combined_skeletons, gdf_mask_hydro, how="inner", predicate="within", lsuffix="combined", rsuffix="mask" + ) + # # Count the number of skeletons per mask + skeleton_counts = gdf_joined_combined["index_mask"].value_counts() + + valid_masks = skeleton_counts[skeleton_counts == 1].index + invalid_masks = skeleton_counts[skeleton_counts != 1].index + + # Filter to keep only masks with a single skeleton + if not valid_masks.empty: + # Filter the joined GeoDataFrame to keep only valid masks + gdf_valid_joined = gdf_joined_combined[gdf_joined_combined["index_mask"].isin(valid_masks)] + # Merge the geometries of masks and skeletons into the resulting GeoDataFrame + df_result = gdf_valid_joined.merge( + gdf_mask_hydro, left_on="index_mask", right_index=True, suffixes=("_skeleton", "_mask") + ) + # Keep only necessary columns + df_result = df_result[["geometry_skeleton", "geometry_mask"]] + + if not invalid_masks.empty: + # Filter the joined GeoDataFrame to keep invalid masks + gdf_invalid_joined = gdf_joined_combined[gdf_joined_combined["index_mask"].isin(valid_masks)] + # Merge the geometries of masks and skeletons into the resulting GeoDataFrames + df_exclusion = gdf_invalid_joined.merge( + gdf_mask_hydro, left_on="index_mask", right_index=True, suffixes=("_skeleton", "_mask") + ) + # Keep only necessary columns + df_exclusion = df_exclusion[["geometry_skeleton", "geometry_mask"]] + # Save the results and exclusions to separate GeoJSON files + # Convert DataFrame to GeoDataFrame + gdf_exclusion = gpd.GeoDataFrame(df_exclusion, geometry="geometry_mask") + exclusions_outputs = os.path.join(output_dir, "mask_skeletons_exclusions.geojson") + gdf_exclusion.to_file(exclusions_outputs, driver="GeoJSON") + logging.info(f"Error: Save the exclusions rivers in GeoJSON : {exclusions_outputs}") + + return df_result diff --git a/test/vectors/test_merge_skeleton_by_mask.py b/test/vectors/test_merge_skeleton_by_mask.py new file mode 100644 index 00000000..5ff7b4c1 --- /dev/null +++ b/test/vectors/test_merge_skeleton_by_mask.py @@ -0,0 +1,39 @@ +import os +import shutil +from pathlib import Path + +import pandas as pd +from pyproj import CRS + +from lidro.create_virtual_point.vectors.merge_skeleton_by_mask import ( + merge_skeleton_by_mask, +) + +TMP_PATH = Path("./tmp/virtual_points/vectors/merge_skeleton_by_mask") + +mask = Path("./data/merge_mask_hydro/MaskHydro_merge.geojson") +skeleton = Path("./data/skeleton_hydro/Skeleton_Hydro.geojson") +output = Path("./tmp/virtual_points/vectors/merge_skeleton_by_mask/merge_skeleton_by_mask.geojson") + + +def setup_module(module): + if TMP_PATH.is_dir(): + shutil.rmtree(TMP_PATH) + os.makedirs(TMP_PATH) + + +def test_merge_skeleton_by_mask_default(): + # Parameters + crs = CRS.from_epsg(2154) + + result = merge_skeleton_by_mask(skeleton, mask, TMP_PATH, crs) + + # Check if the result is a DataFrame + assert isinstance(result, pd.DataFrame), "Result is not a DataFrame" + + # Check if the necessary columns are present + assert "geometry_skeleton" in result.columns, "Missing 'geometry_skeleton' column" + assert "geometry_mask" in result.columns, "Missing 'geometry_mask' column" + + # Check if there are exactly two rows + assert len(result) == 2, f"DataFrame should contain exactly two rows, found {len(result)}" From 352dd4756a6cca2e2814040418e64030b5adab25 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:10:34 +0200 Subject: [PATCH 09/85] add function and test for intersect points by line --- .../vectors/intersect_points_by_line.py | 44 +++++++++++++++++++ .../test_intersection_points_by_line.py | 32 ++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/intersect_points_by_line.py create mode 100644 test/vectors/test_intersection_points_by_line.py diff --git a/lidro/create_virtual_point/vectors/intersect_points_by_line.py b/lidro/create_virtual_point/vectors/intersect_points_by_line.py new file mode 100644 index 00000000..a78a375b --- /dev/null +++ b/lidro/create_virtual_point/vectors/intersect_points_by_line.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" Identifies all points that intersect each line +""" +import geopandas as gpd +from shapely.geometry import CAP_STYLE, Polygon + + +def apply_buffers_to_geometry(line: gpd.GeoDataFrame, buffer: float) -> Polygon: + """Buffer geometry + Objective: apply buffer from hydro's section + + Args: + line (gpd.GeoDataFrame): geopandas dataframe with input geometry + buffer (float): buffer to apply to the input geometry + + Returns: + Polygon: Hydro' section with buffer + """ + # Buffer + geom = line.buffer(buffer, cap_style=CAP_STYLE.square) + return geom + + +def return_points_by_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """This function identifies all points that intersect each hydro's section + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + + Returns: + gpd.GeoDataFrame: A GeoDataframe containing only points that intersect each hydro's section + """ + # Apply buffer (2 meters by default) from Mask Hydro + line_buffer = apply_buffers_to_geometry(line, 0.1) + gdf_line_buffer = gpd.GeoDataFrame(geometry=line_buffer) + + # Perform spatial join to find intersections + pts_intersect = gpd.sjoin(points, gdf_line_buffer, how="left", predicate="intersects") + + # Filter out rows where 'index_right' is NaN + pts_intersect = pts_intersect.dropna(subset=["index_right"]) + + return pts_intersect diff --git a/test/vectors/test_intersection_points_by_line.py b/test/vectors/test_intersection_points_by_line.py new file mode 100644 index 00000000..d8914c8a --- /dev/null +++ b/test/vectors/test_intersection_points_by_line.py @@ -0,0 +1,32 @@ +import geopandas as gpd +from shapely.geometry import LineString, Point + +# Import the functions from your module +from lidro.create_virtual_point.vectors.intersect_points_by_line import ( + return_points_by_line, +) + + +def test_return_points_by_line_default(): + # Create a sample GeoDataFrame with points along a river (EPSG:2154) + points = gpd.GeoDataFrame( + {"geometry": [Point(700000, 6600000), Point(700001, 6600001), Point(700002, 6600002), Point(700010, 6600010)]}, + crs="EPSG:2154", + ) + + # Create a sample GeoDataFrame with a line representing the river (EPSG:2154) + line = gpd.GeoDataFrame({"geometry": [LineString([(700000, 6600000), (700002, 6600002)])]}, crs="EPSG:2154") + + # Call the function to test + result = return_points_by_line(points, line) + + # Expected points are those within the buffer of the line + expected_points = gpd.GeoDataFrame( + {"geometry": [Point(700000, 6600000), Point(700001, 6600001), Point(700002, 6600002)]}, crs="EPSG:2154" + ) + + # Check that the result is a GeoDataFrame + assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" + + # Check that the result contains the expected points + assert result.equals(expected_points), f"The result does not match the expected points. Got: {result}" From 23728c2e9b4a6c906562e095630468d83c3573ce Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:12:00 +0200 Subject: [PATCH 10/85] add function and test for generate 2D grid --- .../create_grid_2D_inside_maskhydro.py | 46 +++++++++++++++++++ .../test_create_grid_2D_inside_maskhydro.py | 25 ++++++++++ 2 files changed, 71 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py create mode 100644 test/vectors/test_create_grid_2D_inside_maskhydro.py diff --git a/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py new file mode 100644 index 00000000..1dfb9f0f --- /dev/null +++ b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" Create a regula 2D grid +""" +import geopandas as gpd +import numpy as np +import shapely.vectorized + + +def generate_grid_from_geojson(mask_hydro: gpd.GeoDataFrame, spacing: float, margin=0): + """ + Generates a regular 2D grid of evenly spaced points within a polygon defined + in a GeoJSON file. + + Args: + mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing each mask hydro from Hydro's Skeleton + spacing (float): Spacing between the grid points in meters. The default value is 1 meter. + margin (int, optional): Margin around mask for grid creation. The default value is 0 meter. + + Returns: + geopandas.GeoDataFrame: A GeoDataFrame containing the grid points within the polygon. + """ + # Extract the polygon + polygon = mask_hydro.geometry.unary_union # Combine all polygons into a single shape if there are multiple + + if margin: + polygon = polygon.buffer(margin) + + # Get the bounds of the polygon + minx, miny, maxx, maxy = polygon.bounds + + # Generate the grid points + x_points = np.arange(minx, maxx, spacing) + y_points = np.arange(miny, maxy, spacing) + grid_points = np.meshgrid(x_points, y_points) + + # Filter points that are within the polygon + grid_points_in_polygon = shapely.vectorized.contains(polygon, *grid_points) + + filtred_x = grid_points[0][grid_points_in_polygon] + filtred_y = grid_points[1][grid_points_in_polygon] + + # Convert to GeoDataFrame + points_gs = gpd.points_from_xy(filtred_x, filtred_y) + grid_gdf = gpd.GeoDataFrame(geometry=points_gs, crs=mask_hydro.crs) + + return grid_gdf diff --git a/test/vectors/test_create_grid_2D_inside_maskhydro.py b/test/vectors/test_create_grid_2D_inside_maskhydro.py new file mode 100644 index 00000000..6cc00297 --- /dev/null +++ b/test/vectors/test_create_grid_2D_inside_maskhydro.py @@ -0,0 +1,25 @@ +import geopandas as gpd +from shapely.geometry import Polygon + +from lidro.create_virtual_point.vectors.create_grid_2D_inside_maskhydro import ( + generate_grid_from_geojson, +) + + +def test_generate_grid_from_geojson(): + # Create a sample GeoDataFrame with a smaller polygon (200m x 200m) in EPSG:2154 + polygon = Polygon([(700000, 6600000), (700000, 6600200), (700200, 6600200), (700200, 6600000), (700000, 6600000)]) + mask_hydro = gpd.GeoDataFrame({"geometry": [polygon]}, crs="EPSG:2154") + + # Define the spacing and margin + spacing = 1.0 # 1 meter spacing + margin = 0 + + # Call the function to test + result = generate_grid_from_geojson(mask_hydro, spacing, margin) + + # Check that the result is a GeoDataFrame + assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" + + # Check that the points are within the polygon bounds + assert result.within(polygon).all(), "All points should be within the polygon" From c089b73f27f787c511c2e43b2fce377145576433 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:17:01 +0200 Subject: [PATCH 11/85] add function for applying Z from 2D grid --- .../vectors/apply_Z_from_grid.py | 52 +++++++++++++++ test/vectors/test_apply_z_from_grid.py | 63 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/apply_Z_from_grid.py create mode 100644 test/vectors/test_apply_z_from_grid.py diff --git a/lidro/create_virtual_point/vectors/apply_Z_from_grid.py b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py new file mode 100644 index 00000000..2b0dee3d --- /dev/null +++ b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +""" Apply Z from grid +""" +import geopandas as gpd +from shapely import line_locate_point + + +def calculate_grid_z_with_model(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, model) -> gpd.GeoDataFrame: + """Calculate Z with regression model + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing the grid points + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + model (dict): A dictionary representing the regression model + + Returns: + gpd.GeoDataFrame: A GeoDataFrame of initial points, but with a Z. + """ + # Calculate curvilinear abscises for all points of the grid + curvilinear_abs = line_locate_point(line.loc[0, "geometry"], points["geometry"].array, normalized=False) + + # Prediction of Z values using the regression model + # Its possible to use non-linear models for prediction + predicted_z = model(curvilinear_abs) + + # Generate a new geodataframe, with 3D points + grid_with_z = gpd.GeoDataFrame( + geometry=gpd.GeoSeries().from_xy(points.geometry.x, points.geometry.y, predicted_z), crs=points.crs + ) + + return grid_with_z + + +def calculate_grid_z_for_flattening( + points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, predicted_z: float +) -> gpd.GeoDataFrame: + """Calculate Z for flattening + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing the grid points + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + predicted_z (float): predicted Z for flattening river + + Returns: + gpd.GeoDataFrame: A GeoDataFrame of initial points, but with a Z. + """ + # Generate a new geodataframe, with 3D points + grid_with_z = gpd.GeoDataFrame( + geometry=gpd.GeoSeries().from_xy(points.geometry.x, points.geometry.y, predicted_z), crs=points.crs + ) + + return grid_with_z diff --git a/test/vectors/test_apply_z_from_grid.py b/test/vectors/test_apply_z_from_grid.py new file mode 100644 index 00000000..7984c611 --- /dev/null +++ b/test/vectors/test_apply_z_from_grid.py @@ -0,0 +1,63 @@ +import geopandas as gpd +import numpy as np +from shapely.geometry import LineString, Point + +from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( + calculate_grid_z_for_flattening, + calculate_grid_z_with_model, +) + + +def test_calculate_grid_z_with_model(): + # Create a sample GeoDataFrame of points + points = gpd.GeoDataFrame({"geometry": [Point(0, 0), Point(1, 1), Point(2, 2)]}, crs="EPSG:4326") + + # Create a sample GeoDataFrame of line + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}, crs="EPSG:4326") + + # Sample model function + def model(x): + return np.array(x) * 2 # Simple linear model for testing + + # Call the function to test + result = calculate_grid_z_with_model(points, line, model) + + # Check that the result is a GeoDataFrame + assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" + + # Check that the result has the same number of points + assert len(result) == len(points), "The number of points should be the same" + + # Check the Z values + curvilinear_abs = [0, np.sqrt(2), np.sqrt(8)] + expected_z = model(curvilinear_abs) + result_z = result.geometry.apply(lambda geom: geom.z) + + # Use assert_array_almost_equal to check the values + np.testing.assert_array_almost_equal( + result_z, expected_z, decimal=5, err_msg="Z values do not match the expected values" + ) + + +def test_calculate_grid_z_for_flattening(): + # Create a sample GeoDataFrame of points + points = gpd.GeoDataFrame({"geometry": [Point(0, 0), Point(1, 1), Point(2, 2)]}, crs="EPSG:4326") + + # Create a sample GeoDataFrame of line + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}, crs="EPSG:4326") + + # Predicted Z for flattening + predicted_z = 10.0 + + # Call the function to test + result = calculate_grid_z_for_flattening(points, line, predicted_z) + + # Check that the result is a GeoDataFrame + assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" + + # Check that the result has the same number of points + assert len(result) == len(points), "The number of points should be the same" + + # Check the Z values + result_z = result.geometry.apply(lambda geom: geom.z) + assert (result_z == predicted_z).all(), "All Z values should be equal to predicted_z" From d878be7e08823f8912b2716238756002d5d8617e Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:19:26 +0200 Subject: [PATCH 12/85] add fucntion and test for creating model --- .../vectors/linear_regression_model.py | 86 +++++++++++++++++++ test/vectors/test_linear_regression_model.py | 49 +++++++++++ 2 files changed, 135 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/linear_regression_model.py create mode 100644 test/vectors/test_linear_regression_model.py diff --git a/lidro/create_virtual_point/vectors/linear_regression_model.py b/lidro/create_virtual_point/vectors/linear_regression_model.py new file mode 100644 index 00000000..a9add781 --- /dev/null +++ b/lidro/create_virtual_point/vectors/linear_regression_model.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" This function calculates a linear regression line +in order to read the Zs along the hydro skeleton to guarantee the flow +""" +import geopandas as gpd +import numpy as np +from shapely import line_locate_point + +from lidro.create_virtual_point.vectors.intersect_points_by_line import ( + return_points_by_line, +) + + +def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, crs: str): + """This function calculates a linear regression line in order to read the Zs + along the hydro skeleton to guarantee the flow. + A river is a natural watercourse whose slope is less than 1%, and + the error tolerance specified in the specifications is 0.5 m. + So, the linear regression model must have a step of 50 m. + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + crs (str): a pyproj CRS object used to create the output GeoJSON file + + Returns: + np.poly1d: Regression model + numpy.array: Determination coefficient + """ + # Inputs + gdf_polyline = line + gdf_points = return_points_by_line(points, line) + + # Merge points and remove duplicates + all_points_knn = np.vstack(gdf_points["points_knn"].values) + unique_points_knn = np.unique(all_points_knn, axis=0) + + # Create a final GeoDataFrame + final_data = {"geometry": [gdf_polyline.iloc[0]["geometry"]], "points_knn": [unique_points_knn]} + + # Generate projected coordinates + points_gs = gpd.GeoSeries().from_xy( + final_data["points_knn"][0][:, 0], # X coordinates + final_data["points_knn"][0][:, 1], # Y coordinates + final_data["points_knn"][0][:, 2], # Z coordinates + crs=crs, # Coordinate reference system + ) + + # Locate each point along the polyline + d_line = line_locate_point(final_data["geometry"], points_gs, normalized=False) + + # Create a GeoDataFrame for regression analysis + regression_gpd = gpd.GeoDataFrame(geometry=points_gs) + # This column contains the curvilinear abscissa + regression_gpd["ac"] = d_line + bins = np.arange(regression_gpd["ac"].max(), step=50) # Create bins for curvilinear abscissa + regression_gpd["ac_bin"] = np.digitize(regression_gpd["ac"], bins) # Digitize curvilinear abscissa into bins + # This column contains the Z value of the point + regression_gpd["z"] = regression_gpd.geometry.z + + # Linear regression model using binned data + temp = regression_gpd.groupby("ac_bin").aggregate( + { + "ac": "mean", # Mean of curvilinear abscissa + "z": [ + ("quantile", lambda x: x.quantile(0.1)), # 10th percentile of Z values + ("std", lambda x: x.quantile(0.75) - x.quantile(0.25)), + ], # Interquartile range of Z values + } + ) + # Weight Matrix + # Normalize standard deviation to use as weights + # W = temp["z"]["std"] * (-1 / np.max(temp["z"]["std"])) + 1 + + # Linear regression with weights + coeff, SSE, *_ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) + + # Calculate SST (TOTAL SQUARE SUN) + SST = np.sum((temp["z"]["quantile"] - np.mean(temp["z"]["quantile"])) ** 2) + + # Determination coefficient [0, 1] + r2 = 1 - (SSE / SST) + + model = np.poly1d(coeff) + + return model, r2 diff --git a/test/vectors/test_linear_regression_model.py b/test/vectors/test_linear_regression_model.py new file mode 100644 index 00000000..59f3331d --- /dev/null +++ b/test/vectors/test_linear_regression_model.py @@ -0,0 +1,49 @@ +import geopandas as gpd +import numpy as np +from shapely.geometry import LineString, Point +from sklearn.neighbors import NearestNeighbors + +from lidro.create_virtual_point.vectors.linear_regression_model import ( + calculate_linear_regression_line, +) + + +def create_knn_column(points): + # Extracting the 3D points + coords = np.array([[p.x, p.y, p.z] for p in points.geometry]) + + # Using Nearest Neighbors to find the 3 nearest neighbors + nbrs = NearestNeighbors(n_neighbors=3, algorithm="ball_tree").fit(coords) + distances, indices = nbrs.kneighbors(coords) + + # Creating the 'points_knn' column + points_knn = [coords[indices[i]] for i in range(len(points))] + points["points_knn"] = points_knn + return points + + +def test_calculate_linear_regression_line_default(): + # Create a sample GeoDataFrame with points designed to produce an R² of 0.25 + np.random.seed(0) + x_coords = np.linspace(0, 100, num=10) + y_coords = np.linspace(0, 100, num=10) + z_coords = x_coords * 0.1 + np.random.normal(0, 5, 10) # Linear relation with noise + + points = gpd.GeoDataFrame( + {"geometry": [Point(x, y, z) for x, y, z in zip(x_coords, y_coords, z_coords)]}, crs="EPSG:2154" + ) + + # Add the points_knn column + points = create_knn_column(points) + + # Create a sample GeoDataFrame with a line representing the river (EPSG:2154) + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (100, 100)])]}, crs="EPSG:2154") + + # Define the CRS + crs = "EPSG:2154" + + # Call the function to test + model, r2 = calculate_linear_regression_line(points, line, crs) + + # Check that the r2 is within the range [0, 1] + assert 0 <= r2 <= 1, "The determination coefficient should be between 0 and 1" From 263b5629dc3ac3261b373c7ea7d8ffea2f2212b7 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:20:13 +0200 Subject: [PATCH 13/85] add function and test for convert geodataframe to las: virtual points --- .../pointcloud/convert_geodataframe_to_las.py | 56 +++++++++++++++ .../test_convert_geodataframe_to_las.py | 68 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py create mode 100644 test/pointcloud/test_convert_geodataframe_to_las.py diff --git a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py new file mode 100644 index 00000000..1f27a357 --- /dev/null +++ b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" this function convert GeoDataframe "virtual points" to LIDAR points +""" +import logging +import os + +import geopandas as gpd +import laspy +import numpy as np +import pandas as pd + + +def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: str): + """This function convert virtual points (GeoDataframe) to LIDAR points with classification (66) for virtual points + + Args: + virtual_points (gpd.GeoDataFrame): A GeoDataFrame containing virtuals points from Mask Hydro + output_dir (str): folder to output LAS + crs (str): a pyproj CRS object used to create the output GeoJSON file + """ + # Combine all virtual points into a single GeoDataFrame + grid_with_z = gpd.GeoDataFrame(pd.concat(virtual_points, ignore_index=True)) + + # Extract x, y, and z coordinates + grid_with_z["x"] = grid_with_z.geometry.x + grid_with_z["y"] = grid_with_z.geometry.y + grid_with_z["z"] = grid_with_z.geometry.z + + # Create a DataFrame with the necessary columns + las_data = pd.DataFrame( + { + "x": grid_with_z["x"], + "y": grid_with_z["y"], + "z": grid_with_z["z"], + "classification": np.full(len(grid_with_z), 66, dtype=np.uint8), # Add classification of 66 + } + ) + + # Create a LAS file with laspy + header = laspy.LasHeader(point_format=6, version="1.4") + + # Create a LAS file with laspy and add the VLR for CRS + las = laspy.LasData(header) + las.x = las_data["x"].values + las.y = las_data["y"].values + las.z = las_data["z"].values + las.classification = las_data["classification"].values + + # Add CRS information to the VLR + vlr = laspy.vlrs.known.WktCoordinateSystemVlr(crs.to_wkt()) + las.vlrs.append(vlr) + + # Save the LAS file + output_las = os.path.join(output_dir, "virtual_points.las") + las.write(output_las) + logging.info(f"Virtual points LAS file saved to {output_las}") diff --git a/test/pointcloud/test_convert_geodataframe_to_las.py b/test/pointcloud/test_convert_geodataframe_to_las.py new file mode 100644 index 00000000..3389c004 --- /dev/null +++ b/test/pointcloud/test_convert_geodataframe_to_las.py @@ -0,0 +1,68 @@ +import os +import shutil +from pathlib import Path + +import geopandas as gpd +import laspy +import numpy as np +import pyproj +from shapely.geometry import Point + +# Import the function from your module +from lidro.create_virtual_point.pointcloud.convert_geodataframe_to_las import ( + geodataframe_to_las, +) + +TMP_PATH = Path("./tmp/virtual_points/pointcloud/convert_geodataframe_to_las") + + +def setup_module(module): + if TMP_PATH.is_dir(): + shutil.rmtree(TMP_PATH) + os.makedirs(TMP_PATH) + + +def test_geodataframe_to_las_default(): + # Create a sample GeoDataFrame with virtual points (EPSG:2154) + points = gpd.GeoDataFrame( + { + "geometry": [ + Point(700000, 6600000, 10), + Point(700001, 6600001, 15), + Point(700002, 6600002, 20), + Point(700010, 6600010, 25), + ] + }, + crs="EPSG:2154", + ) + + # Define the CRS + crs = pyproj.CRS("EPSG:2154") + + # Call the function to test + geodataframe_to_las([points], TMP_PATH, crs) + + # Verify the output LAS file + output_las = os.path.join(TMP_PATH, "virtual_points.las") + assert os.path.exists(output_las), "The output LAS file should exist" + + # Read the LAS file + las = laspy.read(output_las) + + # Check that the coordinates match the input points + expected_coords = np.array([[p.x, p.y, p.z] for p in points.geometry]) + actual_coords = np.vstack((las.x, las.y, las.z)).T + np.testing.assert_array_almost_equal( + actual_coords, + expected_coords, + decimal=5, + err_msg="The coordinates in the LAS file do not match the input points", + ) + + # Check that the classification is 66 for all points + expected_classification = np.full(len(points), 66, dtype=np.uint8) + np.testing.assert_array_equal( + las.classification, + expected_classification, + err_msg="The classification in the LAS file should be 66 for all points", + ) From e8894e43074219de5db69624989a9e8887ca01c0 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:21:42 +0200 Subject: [PATCH 14/85] add function for run the script for creating virtual points --- .../vectors/run_create_virtual_points.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/run_create_virtual_points.py diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py new file mode 100644 index 00000000..d4528b01 --- /dev/null +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" Run function "virtual points" +""" +import geopandas as gpd + +from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( + calculate_grid_z_for_flattening, + calculate_grid_z_with_model, +) +from lidro.create_virtual_point.vectors.create_grid_2D_inside_maskhydro import ( + generate_grid_from_geojson, +) +from lidro.create_virtual_point.vectors.flatten_river import flatten_little_river +from lidro.create_virtual_point.vectors.linear_regression_model import ( + calculate_linear_regression_line, +) + + +def lauch_virtual_points_by_section( + points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, mask_hydro: gpd.GeoDataFrame, crs: str, spacing: float +) -> gpd.GeoDataFrame: + """This function generates a regular grid of 3D points spaced every N meters inside each hydro entity + = virtual point + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing each mask hydro from Hydro's Skeleton + crs (str): a pyproj CRS object used to create the output GeoJSON file + spacing (float, optional): Spacing between the grid points in meters. The default value is 1 meter. + + Returns: + gpd.GeoDataFrame: virtual points by Mask Hydro + """ + # Step 1: Generates a regular 2D grid of evenly spaced points within a Mask Hydro + gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) + # Calculate the length of the river + river_length = float(line.length.iloc[0]) + + # Step 2 : Model linear regression for river's lenght > 150 m + # Otherwise flattening the river + # Apply the algo according to the length of the river + if river_length > 150: + model, r2 = calculate_linear_regression_line(points, line, crs) + gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) + + else: + predicted_z = flatten_little_river(points, line, crs) + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) + + return gdf_grid_with_z From 05945fad670f0815fc3037faf33377fca9b8e741 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 31 Jul 2024 17:22:31 +0200 Subject: [PATCH 15/85] add function for little river : flattening --- .../vectors/flatten_river.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 lidro/create_virtual_point/vectors/flatten_river.py diff --git a/lidro/create_virtual_point/vectors/flatten_river.py b/lidro/create_virtual_point/vectors/flatten_river.py new file mode 100644 index 00000000..859c9d6d --- /dev/null +++ b/lidro/create_virtual_point/vectors/flatten_river.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" this function flattens the Z of the river +""" +import geopandas as gpd +import numpy as np + +from lidro.create_virtual_point.vectors.intersect_points_by_line import ( + return_points_by_line, +) + + +def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, crs: str): + """This function flattens the Z of the little rivers, because of + regression model doesn't work for these type of rivers. + + Args: + points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton + line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton + crs (str): a pyproj CRS object used to create the output GeoJSON file + + Returns: + float: Z of the river + """ + # Inputs + gdf_points = return_points_by_line(points, line) + + # Merge points and remove duplicates + all_points_knn = np.vstack(gdf_points["points_knn"].values) + unique_points_knn = np.unique(all_points_knn, axis=0) + + # Calculate the 1st quartile of all points + first_quartile = np.percentile(unique_points_knn, 25, axis=0) + z_first_quartile = first_quartile[-1] + + return z_first_quartile From 411e8b84165392839203399e1eaafa65a4a0e03d Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 1 Aug 2024 10:39:38 +0200 Subject: [PATCH 16/85] update config for virtual points --- configs/configs_lidro.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 7f93fcbc..ed3956dc 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -47,11 +47,11 @@ virtual_point: keep_neighbors_classes: [2, 9] vector: # distance in meters between 2 consecutive points - distance_meters: 1 - # buffer for searching the points classification (default. "2") of the input LAS/LAZ file - buffer: 1 + distance_meters: 5 + # buffer for searching the points classification (default. "1") of the input LAS/LAZ file + buffer: 3 # The number of nearest neighbors to find with KNeighbors - k: 6* + k: 3 # Spacing between the grid points in meters s: 1 From 2b345304624164a72dddbb17124f26feda6bcf2c Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 1 Aug 2024 10:40:29 +0200 Subject: [PATCH 17/85] update function main_create_virtual_point.py --- lidro/main_create_virtual_point.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 2c6f9bee..e43c7c28 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -103,6 +103,8 @@ def main(config: DictConfig): # Create a GeoDataFrame from the pandas DataFrame points_gdf = gpd.GeoDataFrame(df, geometry="geometry") points_gdf.set_crs(crs, inplace=True) + output_points = os.path.join(output_dir, "points_clips.geojson") + points_gdf.to_file(output_points, driver="GeoJSON", crs=crs) # Step 4: Smooth Z by hydro's section # Combine skeleton lines into a single polyline for each hydro entity From cba333bf82e5553c1fed951ed128c76e05f4036a Mon Sep 17 00:00:00 2001 From: mdupays Date: Sun, 4 Aug 2024 21:37:38 +0200 Subject: [PATCH 18/85] update classification (68) --- .../pointcloud/convert_geodataframe_to_las.py | 2 +- test/pointcloud/test_convert_geodataframe_to_las.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py index 1f27a357..62adeed5 100644 --- a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py +++ b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py @@ -32,7 +32,7 @@ def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: "x": grid_with_z["x"], "y": grid_with_z["y"], "z": grid_with_z["z"], - "classification": np.full(len(grid_with_z), 66, dtype=np.uint8), # Add classification of 66 + "classification": np.full(len(grid_with_z), 68, dtype=np.uint8), # Add classification of 68 } ) diff --git a/test/pointcloud/test_convert_geodataframe_to_las.py b/test/pointcloud/test_convert_geodataframe_to_las.py index 3389c004..5d053f12 100644 --- a/test/pointcloud/test_convert_geodataframe_to_las.py +++ b/test/pointcloud/test_convert_geodataframe_to_las.py @@ -59,10 +59,10 @@ def test_geodataframe_to_las_default(): err_msg="The coordinates in the LAS file do not match the input points", ) - # Check that the classification is 66 for all points - expected_classification = np.full(len(points), 66, dtype=np.uint8) + # Check that the classification is 68 for all points + expected_classification = np.full(len(points), 68, dtype=np.uint8) np.testing.assert_array_equal( las.classification, expected_classification, - err_msg="The classification in the LAS file should be 66 for all points", + err_msg="The classification in the LAS file should be 68 for all points", ) From 3cd7f489fab84009e65b3f6061c524fd92915db7 Mon Sep 17 00:00:00 2001 From: mdupays Date: Sun, 4 Aug 2024 21:38:22 +0200 Subject: [PATCH 19/85] update script for flatten : z_first_quartile=0 when no points --- .../vectors/flatten_river.py | 20 ++++--- .../vectors/run_create_virtual_points.py | 6 +- lidro/main_create_virtual_point.py | 60 ++++++++++++------- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/lidro/create_virtual_point/vectors/flatten_river.py b/lidro/create_virtual_point/vectors/flatten_river.py index 859c9d6d..bdcdaf5a 100644 --- a/lidro/create_virtual_point/vectors/flatten_river.py +++ b/lidro/create_virtual_point/vectors/flatten_river.py @@ -24,12 +24,18 @@ def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, crs: # Inputs gdf_points = return_points_by_line(points, line) - # Merge points and remove duplicates - all_points_knn = np.vstack(gdf_points["points_knn"].values) - unique_points_knn = np.unique(all_points_knn, axis=0) - - # Calculate the 1st quartile of all points - first_quartile = np.percentile(unique_points_knn, 25, axis=0) - z_first_quartile = first_quartile[-1] + # Check if gdf_points contains "points_knn" and is not empty + if "points_knn" not in gdf_points.columns or gdf_points["points_knn"].isnull().all(): + z_first_quartile = 0 + else: + # Merge points and remove duplicates + all_points_knn = np.vstack(gdf_points["points_knn"].dropna().values) + if all_points_knn.size == 0: + z_first_quartile = 0 + else: + unique_points_knn = np.unique(all_points_knn, axis=0) + # Calculate the 1st quartile of all points + first_quartile = np.percentile(unique_points_knn, 25, axis=0) + z_first_quartile = first_quartile[-1] return z_first_quartile diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index d4528b01..bcef4446 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -2,6 +2,7 @@ """ Run function "virtual points" """ import geopandas as gpd +import numpy as np from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( calculate_grid_z_for_flattening, @@ -46,6 +47,9 @@ def lauch_virtual_points_by_section( else: predicted_z = flatten_little_river(points, line, crs) - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) + if np.isnan(predicted_z) or predicted_z is None: + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, 0) + else: + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) return gdf_grid_with_z diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index e43c7c28..624d1e89 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -10,6 +10,7 @@ import pandas as pd from omegaconf import DictConfig from pyproj import CRS +from shapely.geometry import Point sys.path.append('../lidro') @@ -100,28 +101,43 @@ def main(config: DictConfig): # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip) - # Create a GeoDataFrame from the pandas DataFrame - points_gdf = gpd.GeoDataFrame(df, geometry="geometry") - points_gdf.set_crs(crs, inplace=True) - output_points = os.path.join(output_dir, "points_clips.geojson") - points_gdf.to_file(output_points, driver="GeoJSON", crs=crs) - - # Step 4: Smooth Z by hydro's section - # Combine skeleton lines into a single polyline for each hydro entity - gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) - - gdf_virtual_points = [ - lauch_virtual_points_by_section( - points_gdf, - gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), - gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), - crs, - s, - ) - for idx, row in gdf_merged.iterrows() - ] - # Save the virtual points (.LAS) - geodataframe_to_las(gdf_virtual_points, output_dir, crs) + + if not df.empty and "points_knn" in df.columns and "geometry" in df.columns: + # Convert the DataFrame to a GeoDataFrame + points_gdf = gpd.GeoDataFrame(df, geometry="geometry") + points_gdf.set_crs(crs, inplace=True) + + # Extract and flatten the 3D points from points_knn column + knn_points = [] + for index, row in points_gdf.iterrows(): + for point in row["points_knn"]: + knn_points.append({"geometry": Point(point[0], point[1], point[2])}) + + # Create a GeoDataFrame for the 3D points + knn_gdf = gpd.GeoDataFrame(knn_points, geometry="geometry") + knn_gdf.set_crs(crs, inplace=True) + + output_knn_points = os.path.join(output_dir, "points_knn_clips.geojson") + knn_gdf.to_file(output_knn_points, driver="GeoJSON") + + # Step 4: Smooth Z by hydro's section + # Combine skeleton lines into a single polyline for each hydro entity + gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) + + gdf_virtual_points = [ + lauch_virtual_points_by_section( + points_gdf, + gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), + gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), + crs, + s, + ) + for idx, row in gdf_merged.iterrows() + ] + # Save the virtual points (.LAS) + geodataframe_to_las(gdf_virtual_points, output_dir, crs) + else: + logging.error("No valid data found in points_clip for processing.") if __name__ == "__main__": From 1bdb271366bf34827ad5b1f40ceb007321c9474a Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 6 Aug 2024 17:20:49 +0200 Subject: [PATCH 20/85] change sjoin-within to sjoin-intersection + overlay : avoid error --- .../vectors/merge_skeleton_by_mask.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py index 0dfca5da..bc48590b 100644 --- a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py +++ b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py @@ -66,16 +66,18 @@ def merge_skeleton_by_mask( gdf_mask_hydro = explode_multipart(gdf_mask_hydro) # # Perform a spatial join to find skeletons within each mask_hydro - gdf_joined = gpd.sjoin(gdf_skeleton, gdf_mask_hydro, how="inner", predicate="within") + gdf_joined = gpd.sjoin(gdf_skeleton, gdf_mask_hydro, how="inner", predicate="intersects") + # geometry intersections with funtion "overlay" + gdf_intersections = gpd.overlay(gdf_joined, gdf_mask_hydro, how="intersection") # Combine skeleton lines into a single polyline for each hydro entity - combined_skeletons = gdf_joined.groupby("index_right")["geometry"].apply(combine_and_connect_lines) + combined_skeletons = gdf_intersections.groupby("index_right")["geometry"].apply(combine_and_connect_lines) gdf_combined_skeletons = gpd.GeoDataFrame(combined_skeletons, columns=["geometry"], crs=crs).reset_index() - # # Re-join with mask_hydro to keep only the combined skeletons within masks gdf_joined_combined = gpd.sjoin( - gdf_combined_skeletons, gdf_mask_hydro, how="inner", predicate="within", lsuffix="combined", rsuffix="mask" + gdf_combined_skeletons, gdf_mask_hydro, how="inner", predicate="intersects", lsuffix="combined", rsuffix="mask" ) + # # Count the number of skeletons per mask skeleton_counts = gdf_joined_combined["index_mask"].value_counts() From 7cab92b12ddd03290422309fa902602b7eb0fa21 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 12:48:50 +0200 Subject: [PATCH 21/85] refacto for creating 3D grid with tests: stop error --- .../vectors/flatten_river.py | 25 ++- .../vectors/run_create_virtual_points.py | 64 ++++--- .../vectors/test_run_create_virtual_points.py | 167 ++++++++++++++++++ 3 files changed, 222 insertions(+), 34 deletions(-) create mode 100644 test/vectors/test_run_create_virtual_points.py diff --git a/lidro/create_virtual_point/vectors/flatten_river.py b/lidro/create_virtual_point/vectors/flatten_river.py index bdcdaf5a..69bdd0cf 100644 --- a/lidro/create_virtual_point/vectors/flatten_river.py +++ b/lidro/create_virtual_point/vectors/flatten_river.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- """ this function flattens the Z of the river """ +import logging + import geopandas as gpd import numpy as np @@ -9,33 +11,28 @@ ) -def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, crs: str): +def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame): """This function flattens the Z of the little rivers, because of regression model doesn't work for these type of rivers. Args: points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton - crs (str): a pyproj CRS object used to create the output GeoJSON file Returns: float: Z of the river """ # Inputs gdf_points = return_points_by_line(points, line) - - # Check if gdf_points contains "points_knn" and is not empty - if "points_knn" not in gdf_points.columns or gdf_points["points_knn"].isnull().all(): + # Merge points and remove duplicates + all_points_knn = np.vstack(gdf_points["points_knn"].dropna().values) + if all_points_knn.size == 0: + logging.warning("For little river : error Z quartile") z_first_quartile = 0 else: - # Merge points and remove duplicates - all_points_knn = np.vstack(gdf_points["points_knn"].dropna().values) - if all_points_knn.size == 0: - z_first_quartile = 0 - else: - unique_points_knn = np.unique(all_points_knn, axis=0) - # Calculate the 1st quartile of all points - first_quartile = np.percentile(unique_points_knn, 25, axis=0) - z_first_quartile = first_quartile[-1] + unique_points_knn = np.unique(all_points_knn, axis=0) + # Calculate the 1st quartile of all points + first_quartile = np.percentile(unique_points_knn, 25, axis=0) + z_first_quartile = first_quartile[-1] return z_first_quartile diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index bcef4446..f4e2fceb 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- """ Run function "virtual points" """ +import logging +import os + import geopandas as gpd import numpy as np +import pandas as pd from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( calculate_grid_z_for_flattening, @@ -18,7 +22,12 @@ def lauch_virtual_points_by_section( - points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, mask_hydro: gpd.GeoDataFrame, crs: str, spacing: float + points: gpd.GeoDataFrame, + line: gpd.GeoDataFrame, + mask_hydro: gpd.GeoDataFrame, + crs: str, + spacing: float, + output_dir: str, ) -> gpd.GeoDataFrame: """This function generates a regular grid of 3D points spaced every N meters inside each hydro entity = virtual point @@ -28,28 +37,43 @@ def lauch_virtual_points_by_section( line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing each mask hydro from Hydro's Skeleton crs (str): a pyproj CRS object used to create the output GeoJSON file - spacing (float, optional): Spacing between the grid points in meters. The default value is 1 meter. + spacing (float, optional): Spacing between the grid points in meters. The default value is 1 meter + output_dir (str): folder to output Mask Hydro without virtual points Returns: gpd.GeoDataFrame: virtual points by Mask Hydro """ - # Step 1: Generates a regular 2D grid of evenly spaced points within a Mask Hydro - gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) - # Calculate the length of the river - river_length = float(line.length.iloc[0]) - - # Step 2 : Model linear regression for river's lenght > 150 m - # Otherwise flattening the river - # Apply the algo according to the length of the river - if river_length > 150: - model, r2 = calculate_linear_regression_line(points, line, crs) - gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) - - else: - predicted_z = flatten_little_river(points, line, crs) - if np.isnan(predicted_z) or predicted_z is None: - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, 0) + # Check if points GeoDataFrame is empty or + if points.empty or points["points_knn"].isnull().all(): + logging.warning("The points GeoDataFrame is empty. Saving the skeleton and mask hydro to GeoJSON.") + masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) + for idx, mask in mask_hydro.iterrows(): + logging.warning(f"No points found within mask hydro {idx}. Adding to masks_without_points.") + masks_without_points = pd.concat([masks_without_points, gpd.GeoDataFrame([mask], crs=mask_hydro.crs)]) + + # Save the resulting masks_without_points to a GeoJSON file + logging.warning("Save the mask Hydro where NON virtual points") + output_mask_hydro_error = os.path.join(output_dir, "mask_hydro_no_virtual_points.geojson") + masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") + + if not points.empty and not points["points_knn"].isnull().all(): + # Step 1: Generates a regular 2D grid of evenly spaced points within a Mask Hydro + gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) + # Calculate the length of the river + river_length = float(line.length.iloc[0]) + + # Step 2 : Model linear regression for river's lenght > 150 m + # Otherwise flattening the river + # Apply the algo according to the length of the river + if river_length > 150: + model, r2 = calculate_linear_regression_line(points, line, crs) + gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) + else: - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) + predicted_z = flatten_little_river(points, line) + if np.isnan(predicted_z) or predicted_z is None: + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, 0) + else: + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) - return gdf_grid_with_z + return gdf_grid_with_z diff --git a/test/vectors/test_run_create_virtual_points.py b/test/vectors/test_run_create_virtual_points.py new file mode 100644 index 00000000..ffbd3adf --- /dev/null +++ b/test/vectors/test_run_create_virtual_points.py @@ -0,0 +1,167 @@ +# test_run_create_virtual_point.py +import os +import shutil +from pathlib import Path + +import geopandas as gpd +from pyproj import CRS +from shapely.geometry import LineString, Point, Polygon + +from lidro.create_virtual_point.vectors.run_create_virtual_points import ( + lauch_virtual_points_by_section, +) + +TMP_PATH = Path("./tmp/create_virtual_point/vectors/run_create_virtual_points/") + + +def setup_module(module): + if TMP_PATH.is_dir(): + shutil.rmtree(TMP_PATH) + os.makedirs(TMP_PATH) + + +def create_test_data_no_points(): + # Create empty GeoDataFrame with the required structure + points = gpd.GeoDataFrame({"geometry": [], "points_knn": []}, crs="EPSG:2154") + + # Create example lines and mask_hydro GeoDataFrames with two entries each + lines = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(700000, 6600000), (700100, 6600100)]), + LineString([(700200, 6600200), (700300, 6600300)]), + ] + }, + crs="EPSG:2154", + ) + + mask_hydro = gpd.GeoDataFrame( + { + "geometry": [ + Polygon( + [(700000, 6600000), (700100, 6600000), (700100, 6600100), (700000, 6600100), (700000, 6600000)] + ), + Polygon( + [(700200, 6600200), (700300, 6600200), (700300, 6600300), (700200, 6600300), (700200, 6600200)] + ), + ] + }, + crs="EPSG:2154", + ) + + return points, lines, mask_hydro + + +def create_test_data_with_points(): + # Create GeoDataFrame with points and points_knn columns + points = gpd.GeoDataFrame( + { + "geometry": [Point(700050, 6600050)], + "points_knn": [ + [ + (700000, 6600000, 10), + (700050, 6600050, 15), + (700100, 6600100, 20), + (700200, 6600200, 30), + (700300, 6600300, 40), + ] + ], + }, + crs="EPSG:2154", + ) + + # Example lines and mask_hydro GeoDataFrames + lines = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(700000, 6600000), (700300, 6600300)]), + LineString([(700400, 6600400), (700500, 6600500)]), + ] + }, + crs="EPSG:2154", + ) + + mask_hydro = gpd.GeoDataFrame( + { + "geometry": [ + Polygon( + [(699900, 6599900), (700400, 6599900), (700400, 6600400), (699900, 6600400), (699900, 6599900)] + ), + Polygon( + [(700400, 6600400), (700500, 6600400), (700500, 6600500), (700400, 6600500), (700400, 6600400)] + ), + ] + }, + crs="EPSG:2154", + ) + + return points, lines, mask_hydro + + +def create_test_data_with_geometry_only(): + # Create GeoDataFrame with points column but without points_knn + points = gpd.GeoDataFrame( + {"geometry": [Point(700050, 6600050), Point(700250, 6600250)], "points_knn": [None, None]}, crs="EPSG:2154" + ) + + # Example line and mask_hydro GeoDataFrames + lines = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(700000, 6600000), (700100, 6600100)]), + LineString([(700200, 6600200), (700300, 6600300)]), + ] + }, + crs="EPSG:2154", + ) + mask_hydro = gpd.GeoDataFrame( + { + "geometry": [ + Polygon( + [(700000, 6600000), (700100, 6600000), (700100, 6600100), (700000, 6600100), (700000, 6600000)] + ), + Polygon( + [(700200, 6600200), (700300, 6600200), (700300, 6600300), (700200, 6600300), (700200, 6600200)] + ), + ] + }, + crs="EPSG:2154", + ) + return points, lines, mask_hydro + + +def test_lauch_virtual_points_by_section_with_geometry_only(): + points, lines, mask_hydro = create_test_data_with_geometry_only() + crs = CRS.from_epsg(2154) + spacing = 1.0 + output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") + + lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + + assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() + masks_without_points = gpd.read_file(output_filename) + assert len(masks_without_points) == len(mask_hydro) + + +def test_lauch_virtual_points_by_section_no_points(): + points, line, mask_hydro = create_test_data_no_points() + crs = CRS.from_epsg(2154) + spacing = 1.0 + output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") + + lauch_virtual_points_by_section(points, line, mask_hydro, crs, spacing, TMP_PATH) + + assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() + masks_without_points = gpd.read_file(output_filename) + assert len(masks_without_points) == len(mask_hydro) + + +def test_lauch_virtual_points_by_section_with_points(): + points, lines, mask_hydro = create_test_data_with_points() + crs = CRS.from_epsg(2154) + spacing = 1.0 + + grid_with_z = lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + + assert all(isinstance(geom, Point) for geom in grid_with_z.geometry) + assert all(geom.has_z for geom in grid_with_z.geometry) # Check that all points have a Z coordinate From c50d9178b7705028c90016c6ec06cda2ec1b885d Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 16:50:59 +0200 Subject: [PATCH 22/85] refacto run create virtual points : save mask hydro are not okay in geojson --- .../vectors/run_create_virtual_points.py | 27 +++++++++++++++---- .../vectors/test_run_create_virtual_points.py | 1 - 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index f4e2fceb..a1e7b835 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -50,7 +50,6 @@ def lauch_virtual_points_by_section( for idx, mask in mask_hydro.iterrows(): logging.warning(f"No points found within mask hydro {idx}. Adding to masks_without_points.") masks_without_points = pd.concat([masks_without_points, gpd.GeoDataFrame([mask], crs=mask_hydro.crs)]) - # Save the resulting masks_without_points to a GeoJSON file logging.warning("Save the mask Hydro where NON virtual points") output_mask_hydro_error = os.path.join(output_dir, "mask_hydro_no_virtual_points.geojson") @@ -67,13 +66,31 @@ def lauch_virtual_points_by_section( # Apply the algo according to the length of the river if river_length > 150: model, r2 = calculate_linear_regression_line(points, line, crs) + if model == np.poly1d([0, 0]) and r2 == 0.0: + masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) + for idx, mask in mask_hydro.iterrows(): + masks_without_points = pd.concat( + [masks_without_points, gpd.GeoDataFrame([mask], crs=mask_hydro.crs)] + ) + # Save the resulting masks_without_points because of linear regression is impossible to a GeoJSON file + output_mask_hydro_error = os.path.join( + output_dir, "mask_hydro_no_virtual_points_for_regression.geojson" + ) + masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) - else: predicted_z = flatten_little_river(points, line) if np.isnan(predicted_z) or predicted_z is None: - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, 0) - else: - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) + masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) + for idx, mask in mask_hydro.iterrows(): + masks_without_points = pd.concat( + [masks_without_points, gpd.GeoDataFrame([mask], crs=mask_hydro.crs)] + ) + # Save the resulting masks_without_points because of flattening river is impossible to a GeoJSON file + output_mask_hydro_error = os.path.join( + output_dir, "mask_hydro_no_virtual_points_for_little_river.geojson" + ) + masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") + gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) return gdf_grid_with_z diff --git a/test/vectors/test_run_create_virtual_points.py b/test/vectors/test_run_create_virtual_points.py index ffbd3adf..8b059a70 100644 --- a/test/vectors/test_run_create_virtual_points.py +++ b/test/vectors/test_run_create_virtual_points.py @@ -1,4 +1,3 @@ -# test_run_create_virtual_point.py import os import shutil from pathlib import Path From 2a7b06df2454fbf8a6b5894729f3bd3a0571e2b7 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 16:51:24 +0200 Subject: [PATCH 23/85] add logging info --- .../vectors/linear_regression_model.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lidro/create_virtual_point/vectors/linear_regression_model.py b/lidro/create_virtual_point/vectors/linear_regression_model.py index a9add781..53854623 100644 --- a/lidro/create_virtual_point/vectors/linear_regression_model.py +++ b/lidro/create_virtual_point/vectors/linear_regression_model.py @@ -2,6 +2,8 @@ """ This function calculates a linear regression line in order to read the Zs along the hydro skeleton to guarantee the flow """ +import logging + import geopandas as gpd import numpy as np from shapely import line_locate_point @@ -27,16 +29,23 @@ def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoData np.poly1d: Regression model numpy.array: Determination coefficient """ - # Inputs - gdf_polyline = line + if points.empty or line.empty: + logging.warning("Input GeoDataFrames 'points' and 'line' must not be empty") + return np.poly1d([0, 0]), 0.0 + + # Retrieve points along the line gdf_points = return_points_by_line(points, line) + if gdf_points.empty or len(gdf_points) < 3: + logging.warning("Not enough points for regression analysis") + return np.poly1d([0, 0]), 0.0 + # Merge points and remove duplicates all_points_knn = np.vstack(gdf_points["points_knn"].values) unique_points_knn = np.unique(all_points_knn, axis=0) # Create a final GeoDataFrame - final_data = {"geometry": [gdf_polyline.iloc[0]["geometry"]], "points_knn": [unique_points_knn]} + final_data = {"geometry": [line.iloc[0]["geometry"]], "points_knn": [unique_points_knn]} # Generate projected coordinates points_gs = gpd.GeoSeries().from_xy( @@ -68,10 +77,6 @@ def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoData ], # Interquartile range of Z values } ) - # Weight Matrix - # Normalize standard deviation to use as weights - # W = temp["z"]["std"] * (-1 / np.max(temp["z"]["std"])) + 1 - # Linear regression with weights coeff, SSE, *_ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) From 4a9efc53ec6b6ce51c3a5b0c911dfaa95b9af139 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 16:52:42 +0200 Subject: [PATCH 24/85] add logging info, and remove create grid points for flatten rivers (will be done later) --- lidro/main_create_virtual_point.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 624d1e89..70ecb3fe 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -10,7 +10,6 @@ import pandas as pd from omegaconf import DictConfig from pyproj import CRS -from shapely.geometry import Point sys.path.append('../lidro') @@ -107,19 +106,6 @@ def main(config: DictConfig): points_gdf = gpd.GeoDataFrame(df, geometry="geometry") points_gdf.set_crs(crs, inplace=True) - # Extract and flatten the 3D points from points_knn column - knn_points = [] - for index, row in points_gdf.iterrows(): - for point in row["points_knn"]: - knn_points.append({"geometry": Point(point[0], point[1], point[2])}) - - # Create a GeoDataFrame for the 3D points - knn_gdf = gpd.GeoDataFrame(knn_points, geometry="geometry") - knn_gdf.set_crs(crs, inplace=True) - - output_knn_points = os.path.join(output_dir, "points_knn_clips.geojson") - knn_gdf.to_file(output_knn_points, driver="GeoJSON") - # Step 4: Smooth Z by hydro's section # Combine skeleton lines into a single polyline for each hydro entity gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) @@ -134,6 +120,7 @@ def main(config: DictConfig): ) for idx, row in gdf_merged.iterrows() ] + logging.info("Calculate virtuals points by mask hydro and skeleton") # Save the virtual points (.LAS) geodataframe_to_las(gdf_virtual_points, output_dir, crs) else: From b11e65ff4d28db51883b8797722ece1d4266fa94 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 16:53:14 +0200 Subject: [PATCH 25/85] add function test for linear regression --- test/vectors/test_linear_regression_model.py | 54 +++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/test/vectors/test_linear_regression_model.py b/test/vectors/test_linear_regression_model.py index 59f3331d..804340f5 100644 --- a/test/vectors/test_linear_regression_model.py +++ b/test/vectors/test_linear_regression_model.py @@ -9,11 +9,14 @@ def create_knn_column(points): + if points.empty: + return points + # Extracting the 3D points coords = np.array([[p.x, p.y, p.z] for p in points.geometry]) # Using Nearest Neighbors to find the 3 nearest neighbors - nbrs = NearestNeighbors(n_neighbors=3, algorithm="ball_tree").fit(coords) + nbrs = NearestNeighbors(n_neighbors=min(3, len(points)), algorithm="ball_tree").fit(coords) distances, indices = nbrs.kneighbors(coords) # Creating the 'points_knn' column @@ -47,3 +50,52 @@ def test_calculate_linear_regression_line_default(): # Check that the r2 is within the range [0, 1] assert 0 <= r2 <= 1, "The determination coefficient should be between 0 and 1" + + # Check the type of the model + assert isinstance(model, np.poly1d), "The regression model should be of type np.poly1d" + + # Check the length of the model's coefficients + assert len(model.coefficients) == 2, "The regression model should be linear with 2 coefficients" + + # Check the model's coefficients are within expected ranges + expected_slope_range = (0.01, 0.15) # Adjusted range + expected_intercept_range = (-10, 10) + slope, intercept = model.coefficients + assert ( + expected_slope_range[0] <= slope <= expected_slope_range[1] + ), f"Slope {slope} is not within the expected range {expected_slope_range}" + assert ( + expected_intercept_range[0] <= intercept <= expected_intercept_range[1] + ), f"Intercept {intercept} is not within the expected range {expected_intercept_range}" + print("All tests passed successfully!") + + +def test_calculate_linear_regression_line_empty_inputs(): + # Test with empty GeoDataFrames + points = gpd.GeoDataFrame(columns=["geometry"], crs="EPSG:2154") + line = gpd.GeoDataFrame(columns=["geometry"], crs="EPSG:2154") + crs = "EPSG:2154" + + model, r2 = calculate_linear_regression_line(points, line, crs) + assert isinstance(model, np.poly1d), "The regression model should be of type np.poly1d" + assert r2 == 0.0, "The determination coefficient should be 0 for empty inputs" + print("Empty input test passed successfully!") + + +def test_calculate_linear_regression_line_single_point(): + # Test with a single point + points = gpd.GeoDataFrame({"geometry": [Point(0, 0, 0)]}, crs="EPSG:2154") + + # Add the points_knn column + points = create_knn_column(points) + + # Create a sample GeoDataFrame with a line representing the river (EPSG:2154) + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (100, 100)])]}, crs="EPSG:2154") + + # Define the CRS + crs = "EPSG:2154" + + model, r2 = calculate_linear_regression_line(points, line, crs) + assert isinstance(model, np.poly1d), "The regression model should be of type np.poly1d" + assert r2 == 0.0, "The determination coefficient should be 0 for a single point input" + print("Single point input test passed successfully!") From c2eeb54884dc03ee76a7bf790d97969b7f0d5c05 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 7 Aug 2024 17:01:47 +0200 Subject: [PATCH 26/85] add test for flatten little river --- test/vectors/test_flatten_river.py | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/vectors/test_flatten_river.py diff --git a/test/vectors/test_flatten_river.py b/test/vectors/test_flatten_river.py new file mode 100644 index 00000000..86e61bc8 --- /dev/null +++ b/test/vectors/test_flatten_river.py @@ -0,0 +1,52 @@ +import geopandas as gpd +import numpy as np +from shapely.geometry import LineString, Point + +from lidro.create_virtual_point.vectors.flatten_river import flatten_little_river + + +def test_flatten_little_river_points_knn_not_empty(): + # Create example points GeoDataFrame + points = gpd.GeoDataFrame( + { + "geometry": [Point(0, 0, 10), Point(1, 1, 20), Point(2, 2, 30)], + "points_knn": [np.array([[0, 0, 10], [1, 1, 20], [2, 2, 30]])], + } + ) + + # Create example line GeoDataFrame + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}) + + # Test when points_knn is not empty + expected_result = 10 + result = flatten_little_river(points, line) + assert result == expected_result, f"Expected {expected_result}, but got {result}" + + +def test_flatten_little_river_points_knn_empty(): + # Test when points_knn is empty + empty_points = gpd.GeoDataFrame({"geometry": [Point(0, 0)], "points_knn": [np.array([])]}) + + # Create example line GeoDataFrame + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}) + + expected_result = 0 + result = flatten_little_river(empty_points, line) + assert result == expected_result, f"Expected {expected_result}, but got {result}" + + +def test_flatten_little_river_duplicate_points(): + # Test with duplicate points + duplicate_points = gpd.GeoDataFrame( + { + "geometry": [Point(0, 0, 10), Point(1, 1, 10), Point(2, 2, 10)], + "points_knn": [np.array([[0, 0, 10], [1, 1, 10], [2, 2, 10]])], + } + ) + + # Create example line GeoDataFrame + line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}) + + expected_result = 10 + result = flatten_little_river(duplicate_points, line) + assert result == expected_result, f"Expected {expected_result}, but got {result}" From 8db73119537b4ae8fce829465b9ba3be6036a678 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 8 Aug 2024 11:31:06 +0200 Subject: [PATCH 27/85] add image for README and update README.md --- README.md | 16 +++++++++++----- images/chaine_traitement_lidro.jpg | Bin 0 -> 96518 bytes 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 images/chaine_traitement_lidro.jpg diff --git a/README.md b/README.md index 0a3202df..062ed070 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,25 @@ Pour créer des modèles numériques cohérents avec les modèles hydrologiques, Cette modélisation des surfaces hydrographiques se décline en 3 grands enjeux :​ * Mise à plat des surfaces d’eau marine​ * Mise à plat des plans d’eau intérieurs (lac, marais, etc.)​ -* Mise en plan des grands cours d’eau (>5m large) pour assurer l’écoulement​. A noter que cette étape sera développée en premier. +* Mise en plan des grands cours d’eau (>5m large) pour assurer l’écoulement​. A noter que pour l'instant seulement cette étape est développée. ## Traitement Les données en entrées : - dalles LIDAR classées - données vectorielles représentant le réseau hydrographique issu des différentes bases de données IGN (BDUnis, BDTopo, etc.) -Trois grands axes du processus à mettre en place en distanguant l'échelle de traitmeent associé : +Trois grands axes du processus à mettre en place en distanguant l'échelle de traitement associé : * 1- Création de masques hydrographiques à l'échelle de la dalle LIDAR * 2- Création de masques hydrographiques pré-filtrés à l'échelle du chantier, soit : * la suppression de ces masques dans les zones ZICAD/ZIPVA - * la suppression des aires < 150 m² + * la suppression des aires < 150 m² (paramètrables) * la suppression des aires < 1000 m² hors BD IGN (grands cours d'eau < 5m de large) * 3- Création de points virtuels le long de deux entités hydrographiques : * Grands cours d'eau (> 5 m de large dans la BD Unis). - * Surfaces planes (mer, lac, étang, etc.) + * Surfaces planes (mer, lac, étang, etc.) (pas encore développé) + +![Chaine de traitement global de LIDRO](images/chaine_traitement_lidro.jpg) + ### Traitement des grands cours d'eau (> 5 m de large dans la BD Uns). @@ -31,14 +34,17 @@ Il existe plusieurs étapes intermédiaires : * 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unid) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle) A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres +Pour les cours d'eaux supérieurs à 150 m de long : * 3- Transformer les coordonnées de ces points (étape précédente) en abscisses curvilignes * 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. A noter que les Z le long du squelette HYDRO doivent assurer l'écoulement. +/ ! \ Pour les cours d'eaux inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. Donc, ce type de cours d'eaux est applanie en calculant sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) la valeur du premier quartile. * 5- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : * Création des points virtuels 2D espacés selon une grille régulière à l'intérieur du masque hydrographique "écoulement" - * Affecter une valeur d'altitude à ces points virtuels en fonction des "Z" calculés à l'étape précédente (interpolation linéaire) + * Affecter une valeur d'altitude à ces points virtuels en fonction des "Z" calculés à l'étape précédente (interpolation linéaire ou aplanissement) ### Traitement des surfaces planes (mer, lac, étang, etc.) Pour rappel, l'eau est considérée comme horizontale sur ce type de surface. +/ ! \ Cette étape n'est pas encore développée. Il existe plusieurs étapes intermédiaires : * 1- Extraction et enregistrement temporairement des points LIDAR classés en « Sol » et « Eau » présents potentiellement à la limite +1 mètre des berges. Pour cela, on s'appuie sur 'emprise du masque hydrographique "surface plane" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle). a noter que pur le secteur maritime (Mer), il faut exclure la classe « 9 » (eau) afin d’éviter de mesurer les vagues. diff --git a/images/chaine_traitement_lidro.jpg b/images/chaine_traitement_lidro.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e48c8dbe4a9c211d4b60eb7283dae9a7841c0482 GIT binary patch literal 96518 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 M$$@#d=KsG502@pTF8}}l literal 0 HcmV?d00001 From 958be8cd597bc2e2af57b6f72e9e2ee4f9428d7c Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 8 Aug 2024 15:35:57 +0200 Subject: [PATCH 28/85] refacto configs: spacing and definition --- configs/configs_lidro.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index ed3956dc..2fa89b96 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -46,14 +46,14 @@ virtual_point: # Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer keep_neighbors_classes: [2, 9] vector: - # distance in meters between 2 consecutive points + # distance in meters between 2 consecutive points from Skeleton Hydro distance_meters: 5 - # buffer for searching the points classification (default. "1") of the input LAS/LAZ file + # buffer for searching the points classification (default. "3") of the input LAS/LAZ file buffer: 3 # The number of nearest neighbors to find with KNeighbors k: 3 - # Spacing between the grid points in meters - s: 1 + # Spacing between the grid points in meters by default "0.5" + s: 0.5 skeleton: max_gap_width: 200 # distance max in meter of any gap between 2 branches we will try to close with a line From 23556bba277628087962ac8c7e58baa148d3c41e Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 8 Aug 2024 15:36:32 +0200 Subject: [PATCH 29/85] refacto README.md and lauch severals scripts .sh --- README.md | 29 +++++++++++++----- images/process_points_virtuels.jpg | Bin 0 -> 82396 bytes scripts/example_create_mask_default.sh | 2 ++ .../example_create_virtual_point_default.sh | 5 +++ scripts/example_merge_mask_default.sh | 4 +++ 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 images/process_points_virtuels.jpg diff --git a/README.md b/README.md index 062ed070..6b0fde5e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Cette modélisation des surfaces hydrographiques se décline en 3 grands enjeux * Mise en plan des grands cours d’eau (>5m large) pour assurer l’écoulement​. A noter que pour l'instant seulement cette étape est développée. ## Traitement + +![Chaine de traitement global de LIDRO](images/chaine_traitement_lidro.jpg) + Les données en entrées : - dalles LIDAR classées - données vectorielles représentant le réseau hydrographique issu des différentes bases de données IGN (BDUnis, BDTopo, etc.) @@ -21,17 +24,17 @@ Trois grands axes du processus à mettre en place en distanguant l'échelle de t * la suppression de ces masques dans les zones ZICAD/ZIPVA * la suppression des aires < 150 m² (paramètrables) * la suppression des aires < 1000 m² hors BD IGN (grands cours d'eau < 5m de large) +A noter que pour l'instant, la suppresion des masques hydrographiques en dehors des grands cours d'eaux et le nettoyage de ces masques s'effectue manuellement. Ce processus sera développé prochainement en automatique. * 3- Création de points virtuels le long de deux entités hydrographiques : * Grands cours d'eau (> 5 m de large dans la BD Unis). * Surfaces planes (mer, lac, étang, etc.) (pas encore développé) -![Chaine de traitement global de LIDRO](images/chaine_traitement_lidro.jpg) - -### Traitement des grands cours d'eau (> 5 m de large dans la BD Uns). +### Traitement des grands cours d'eau (> 5 m de large dans la BD Unis). +![Chaine de traitement des points virtuels](images/process_points_virtuels.jpg) Il existe plusieurs étapes intermédiaires : -* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unid) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle) +* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unis) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle) A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres Pour les cours d'eaux supérieurs à 150 m de long : @@ -99,14 +102,24 @@ Voir les tests fonctionnels en bas du README. ## Tests ### Tests fonctionnels -Tester sur un seul fichier LAS/LAZ +Tester sur un seul fichier LAS/LAZ pour créer un/des masques hydrographiques sur une dalle LIDAR +``` +example_create_mask_by_tile.sh +``` + +Tester sur un dossier contenant plusieurs dalles LIDAR pour créer un/des masques hydrographiques +``` +example_create_mask_default.sh +``` + +Tester sur un dossier contenant plusieurs dalles LIDAR pour créer fusionner l'ensemble des masques hydrographiques ``` -example_lidro_by_tile.sh +example_merge_mask_default.sh ``` -Tester sur un dossier +Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points virtuels 3D à l'intérieurs des masques hydrographiques ``` -example_lidro_default.sh +example_create_virtual_point_by_tile.sh ``` ### Tests unitaires diff --git a/images/process_points_virtuels.jpg b/images/process_points_virtuels.jpg new file mode 100644 index 0000000000000000000000000000000000000000..123ca1d54513647eb5f9fc9475c30a3593593418 GIT binary patch literal 82396 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

82jCl9pGz7b;8z?O|vXvomyRGk1&5P9`&@N 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 Date: Thu, 8 Aug 2024 16:02:44 +0200 Subject: [PATCH 30/85] refacto README for test fonctionnels --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b0fde5e..8b938511 100644 --- a/README.md +++ b/README.md @@ -102,21 +102,24 @@ Voir les tests fonctionnels en bas du README. ## Tests ### Tests fonctionnels +* 1- Créer des masques hydrographiques à l'échelle de la dalle LIDAR Tester sur un seul fichier LAS/LAZ pour créer un/des masques hydrographiques sur une dalle LIDAR ``` example_create_mask_by_tile.sh ``` - Tester sur un dossier contenant plusieurs dalles LIDAR pour créer un/des masques hydrographiques ``` example_create_mask_default.sh ``` - +* 2- Créer un masque hydrographiques fusionné et nettoyé à l'échelle de l'ensemble de l'ensemble des dalles LIDAR Tester sur un dossier contenant plusieurs dalles LIDAR pour créer fusionner l'ensemble des masques hydrographiques ``` example_merge_mask_default.sh ``` +* 3- Création des tronçons hydrographiques à l'échelle de/des entité(s) hydrographique(s) + +* 4- Création des points virtuels Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points virtuels 3D à l'intérieurs des masques hydrographiques ``` example_create_virtual_point_by_tile.sh From aa72a3c2bdb02e9e52f5c255fac169f28a0af17f Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 9 Aug 2024 15:44:42 +0200 Subject: [PATCH 31/85] update config --- configs/configs_lidro.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 2fa89b96..4496289e 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -53,7 +53,7 @@ virtual_point: # The number of nearest neighbors to find with KNeighbors k: 3 # Spacing between the grid points in meters by default "0.5" - s: 0.5 + s: 1 skeleton: max_gap_width: 200 # distance max in meter of any gap between 2 branches we will try to close with a line From c530db446d13220b50343b03f9ae4c3fd3d51b90 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 9 Aug 2024 15:45:09 +0200 Subject: [PATCH 32/85] push new version --- lidro/main_create_virtual_point.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 70ecb3fe..949b2b4c 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -117,6 +117,7 @@ def main(config: DictConfig): gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), crs, s, + output_dir, ) for idx, row in gdf_merged.iterrows() ] From 7aaf6067545451b530ffc1e6263e8c9493908821 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 9 Aug 2024 15:45:45 +0200 Subject: [PATCH 33/85] refacto test for flattening river --- test/vectors/test_flatten_river.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/vectors/test_flatten_river.py b/test/vectors/test_flatten_river.py index 86e61bc8..02a59040 100644 --- a/test/vectors/test_flatten_river.py +++ b/test/vectors/test_flatten_river.py @@ -10,7 +10,7 @@ def test_flatten_little_river_points_knn_not_empty(): points = gpd.GeoDataFrame( { "geometry": [Point(0, 0, 10), Point(1, 1, 20), Point(2, 2, 30)], - "points_knn": [np.array([[0, 0, 10], [1, 1, 20], [2, 2, 30]])], + "points_knn": [np.array([[0, 0, 10], [1, 1, 20], [2, 2, 30]]) for _ in range(3)], } ) @@ -18,7 +18,7 @@ def test_flatten_little_river_points_knn_not_empty(): line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}) # Test when points_knn is not empty - expected_result = 10 + expected_result = 15.0 # Adjusted to the correct 1st quartile value result = flatten_little_river(points, line) assert result == expected_result, f"Expected {expected_result}, but got {result}" @@ -40,7 +40,7 @@ def test_flatten_little_river_duplicate_points(): duplicate_points = gpd.GeoDataFrame( { "geometry": [Point(0, 0, 10), Point(1, 1, 10), Point(2, 2, 10)], - "points_knn": [np.array([[0, 0, 10], [1, 1, 10], [2, 2, 10]])], + "points_knn": [np.array([[0, 0, 10], [1, 1, 10], [2, 2, 10]]) for _ in range(3)], } ) From 1115e3843638a2cbba26dc4b051db633b7998d2e Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 9 Aug 2024 15:48:17 +0200 Subject: [PATCH 34/85] refacto run_create_virtual_points and the test --- .../vectors/run_create_virtual_points.py | 21 ++-- .../vectors/test_run_create_virtual_points.py | 113 ++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index a1e7b835..316751ef 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -41,7 +41,7 @@ def lauch_virtual_points_by_section( output_dir (str): folder to output Mask Hydro without virtual points Returns: - gpd.GeoDataFrame: virtual points by Mask Hydro + gpd.GeoDataFrame: All virtual points by Mask Hydro """ # Check if points GeoDataFrame is empty or if points.empty or points["points_knn"].isnull().all(): @@ -51,7 +51,6 @@ def lauch_virtual_points_by_section( logging.warning(f"No points found within mask hydro {idx}. Adding to masks_without_points.") masks_without_points = pd.concat([masks_without_points, gpd.GeoDataFrame([mask], crs=mask_hydro.crs)]) # Save the resulting masks_without_points to a GeoJSON file - logging.warning("Save the mask Hydro where NON virtual points") output_mask_hydro_error = os.path.join(output_dir, "mask_hydro_no_virtual_points.geojson") masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") @@ -60,10 +59,9 @@ def lauch_virtual_points_by_section( gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) # Calculate the length of the river river_length = float(line.length.iloc[0]) + print(river_length) # Step 2 : Model linear regression for river's lenght > 150 m - # Otherwise flattening the river - # Apply the algo according to the length of the river if river_length > 150: model, r2 = calculate_linear_regression_line(points, line, crs) if model == np.poly1d([0, 0]) and r2 == 0.0: @@ -74,13 +72,18 @@ def lauch_virtual_points_by_section( ) # Save the resulting masks_without_points because of linear regression is impossible to a GeoJSON file output_mask_hydro_error = os.path.join( - output_dir, "mask_hydro_no_virtual_points_for_regression.geojson" + output_dir, "mask_hydro_no_virtual_points_with_regression.geojson" + ) + logging.warning( + f"Save masks_without_points because of linear regression is impossible: {output_mask_hydro_error}" ) masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") + # Apply linear regression model at the rivers gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) - else: + # Step 2 bis: Model flattening for river's lenght < 150 m + if river_length < 150: predicted_z = flatten_little_river(points, line) - if np.isnan(predicted_z) or predicted_z is None: + if predicted_z == 0: masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) for idx, mask in mask_hydro.iterrows(): masks_without_points = pd.concat( @@ -90,7 +93,11 @@ def lauch_virtual_points_by_section( output_mask_hydro_error = os.path.join( output_dir, "mask_hydro_no_virtual_points_for_little_river.geojson" ) + logging.warning( + f"Save masks_without_points because of flattening river is impossible: {output_mask_hydro_error}" + ) masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") + # Apply flattening model at the rivers gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) return gdf_grid_with_z diff --git a/test/vectors/test_run_create_virtual_points.py b/test/vectors/test_run_create_virtual_points.py index 8b059a70..9f075704 100644 --- a/test/vectors/test_run_create_virtual_points.py +++ b/test/vectors/test_run_create_virtual_points.py @@ -3,6 +3,7 @@ from pathlib import Path import geopandas as gpd +import numpy as np from pyproj import CRS from shapely.geometry import LineString, Point, Polygon @@ -129,6 +130,92 @@ def create_test_data_with_geometry_only(): return points, lines, mask_hydro +def create_test_data_with_regression_failure(): + # Create GeoDataFrame with points and points_knn columns that will lead to regression failure + points = gpd.GeoDataFrame( + { + "geometry": [Point(700000, 6600000), Point(700300, 6600300)], + "points_knn": [ + [ + (700000, 6600000, 10), + (700100, 6600100, 10), # Same Z to force failure + (700200, 6600200, 10), # Same Z to force failure + (700300, 6600300, 10), + ], + [ + (700000, 6600000, 10), + (700100, 6600100, 10), # Same Z to force failure + (700200, 6600200, 10), # Same Z to force failure + (700300, 6600300, 10), + ], + ], + }, + crs="EPSG:2154", + ) + + # Create a longer LineString (>150m) to represent a larger river section + lines = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(700000, 6600000), (700200, 6600200)]), # Length is approximately 282.84 meters + ] + }, + crs="EPSG:2154", + ) + + # Create mask_hydro polygons to resemble river segments (long and narrow) + mask_hydro = gpd.GeoDataFrame( + { + "geometry": [ + Polygon( + [(699950, 6599950), (700350, 6599950), (700350, 6600350), (699950, 6600350), (699950, 6599950)] + ), # A polygon resembling a river segment + ] + }, + crs="EPSG:2154", + ) + + return points, lines, mask_hydro + + +def create_test_data_with_flattening_failure(): + # Create GeoDataFrame with points and points_knn columns : the points_knn is None + points = gpd.GeoDataFrame( + {"geometry": [Point(700000, 6600000)], "points_knn": [np.array([])]}, + crs="EPSG:2154", + ) + # Create a short LineString to represent a small river section (<150 meters) + lines = gpd.GeoDataFrame( + { + "geometry": [ + LineString([(700000, 6600000), (700050, 6600050)]), # Length is 70.71 meters + ] + }, + crs="EPSG:2154", + ) + + # Create mask_hydro polygons to resemble river segments (long and narrow) + mask_hydro = gpd.GeoDataFrame( + { + "geometry": [ + Polygon( + [ + (699980, 6599980), + (700020, 6599980), + (700030, 6600000), + (700020, 6600020), + (699980, 6600020), + (699970, 6600000), + ] + ), # A polygon resembling a river bend + ] + }, + crs="EPSG:2154", + ) + + return points, lines, mask_hydro + + def test_lauch_virtual_points_by_section_with_geometry_only(): points, lines, mask_hydro = create_test_data_with_geometry_only() crs = CRS.from_epsg(2154) @@ -164,3 +251,29 @@ def test_lauch_virtual_points_by_section_with_points(): assert all(isinstance(geom, Point) for geom in grid_with_z.geometry) assert all(geom.has_z for geom in grid_with_z.geometry) # Check that all points have a Z coordinate + + +def test_lauch_virtual_points_by_section_regression_failure(): + points, lines, mask_hydro = create_test_data_with_regression_failure() + crs = CRS.from_epsg(2154) + spacing = 1.0 + output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_with_regression.geojson") + + lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + + assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_with_regression.geojson").is_file() + masks_without_points = gpd.read_file(output_filename) + assert len(masks_without_points) == len(mask_hydro) + + +def test_lauch_virtual_points_by_section_flattening_failure(): + points, lines, mask_hydro = create_test_data_with_flattening_failure() + crs = CRS.from_epsg(2154) + spacing = 1.0 + output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_for_little_river.geojson") + + lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + + assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_for_little_river.geojson").is_file() + masks_without_points = gpd.read_file(output_filename) + assert len(masks_without_points) == len(mask_hydro) From 0fb15f6eb186844156d35fdd51a705a3e25b53d6 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 9 Aug 2024 16:03:09 +0200 Subject: [PATCH 35/85] update README --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8b938511..6ae838ec 100644 --- a/README.md +++ b/README.md @@ -34,21 +34,21 @@ A noter que pour l'instant, la suppresion des masques hydrographiques en dehors ![Chaine de traitement des points virtuels](images/process_points_virtuels.jpg) Il existe plusieurs étapes intermédiaires : -* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unis) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle) +* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unis) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger en amont (étape manuelle) + A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres Pour les cours d'eaux supérieurs à 150 m de long : * 3- Transformer les coordonnées de ces points (étape précédente) en abscisses curvilignes -* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. A noter que les Z le long du squelette HYDRO doivent assurer l'écoulement. -/ ! \ Pour les cours d'eaux inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. Donc, ce type de cours d'eaux est applanie en calculant sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) la valeur du premier quartile. +* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. Les différents Z le long des squelettes HYDRO doivent assurer l'écoulement. Il est important de noter que tous les 50 mètres semble une valeur correcte. Cette valeur s'explique en raison de la précision planimétrique et altimétrique des données LIDAR ET que les rivières françaises correspondent à des cours d’eau naturel dont la pente est inférieure à 1%. +/ ! \ Pour les cours d'eaux inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. La valeur du premier quartile sera calculée sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) et affecter pour ces entités hydrographiques (< 150m de long) : aplanissement. * 5- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : - * Création des points virtuels 2D espacés selon une grille régulière à l'intérieur du masque hydrographique "écoulement" + * Création des points virtuels 2D espacés selon une grille régulière espacés tous les N mètres (paramètrables) à l'intérieur du masque hydrographique "écoulement" * Affecter une valeur d'altitude à ces points virtuels en fonction des "Z" calculés à l'étape précédente (interpolation linéaire ou aplanissement) ### Traitement des surfaces planes (mer, lac, étang, etc.) Pour rappel, l'eau est considérée comme horizontale sur ce type de surface. -/ ! \ Cette étape n'est pas encore développée. - +/ ! \ EN ATTENTE / ! \ Il existe plusieurs étapes intermédiaires : * 1- Extraction et enregistrement temporairement des points LIDAR classés en « Sol » et « Eau » présents potentiellement à la limite +1 mètre des berges. Pour cela, on s'appuie sur 'emprise du masque hydrographique "surface plane" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle). a noter que pur le secteur maritime (Mer), il faut exclure la classe « 9 » (eau) afin d’éviter de mesurer les vagues. * 2- Analyse statistique de l'ensemble des points LIDAR "Sol / Eau" le long des côtes/berges afin d'obtenir une surface plane. From 841ad0bec9bd3b18e96998cb618164514d1f2b3c Mon Sep 17 00:00:00 2001 From: mdupays Date: Mon, 12 Aug 2024 15:04:25 +0200 Subject: [PATCH 36/85] refacto extract points around skeleton: save result by lidar tile --- .../vectors/extract_points_around_skeleton.py | 26 +++++-- ..._extract_points_around_skeleton_default.sh | 13 ++++ .../test_extract_points_around_skeleton.py | 71 +++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 scripts/example_extract_points_around_skeleton_default.sh create mode 100644 test/vectors/test_extract_points_around_skeleton.py diff --git a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py index a7a5cbda..f6d0abe6 100644 --- a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py +++ b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py @@ -6,6 +6,7 @@ from typing import List import geopandas as gpd +import pandas as pd from pdaltools.las_info import las_get_xy_bounds from shapely.geometry import Point @@ -21,8 +22,10 @@ def extract_points_around_skeleton_points_one_tile( filename: str, input_dir: str, + output_dir: str, input_mask_hydro_buffer: gpd.GeoDataFrame, points_skeleton_gdf: gpd.GeoDataFrame, + crs: str | int, classes: List[int:int], k: int, ): @@ -33,13 +36,12 @@ def extract_points_around_skeleton_points_one_tile( Args: filename (str): filename to the LAS file input_dir (str): input folder + output_dir (str): ouput folder input_mask_hydro_buffer (gpd.GeoDataFrame): hydro mask with buffer - points_skeleton_gdf (gpd.GeoDataFrame): Points every 2 meters (by default) along skeleton hydro + points_skeleton_gdf (gpd.GeoDataFrame): Points every N meters along skeleton hydro + crs (str | int): Make a CRS from an EPSG code : CRS WKT string classes (List): List of classes to use for the filtering k (int): the number of nearest neighbors to find - - Returns: - points_clip (np.array) : Numpy array containing point coordinates (X, Y, Z) after filtering and croping """ # Step 1 : Crop filtered pointcloud by Mask Hydro with buffer input_dir_points = os.path.join(input_dir, "pointcloud") @@ -58,6 +60,16 @@ def extract_points_around_skeleton_points_one_tile( ] # Step 2 : Extract a Z elevation value along the hydrographic skeleton logging.info(f"\nExtract a Z elevation value along the hydrographic skeleton for tile : {tilename}") - result = filter_las_around_point(points_skeleton_with_z_clip, points_clip, k) - - return result + points_Z = filter_las_around_point(points_skeleton_with_z_clip, points_clip, k) + df_points_z = pd.DataFrame(points_Z) # Convert Dataframe + # Convert list "points_knn" to string + df_points_z["points_knn_str"] = df_points_z["points_knn"].apply(lambda x: str(x)) + df_points_z = df_points_z[["geometry", "points_knn_str"]] + if not df_points_z.empty and "points_knn_str" in df_points_z.columns and "geometry" in df_points_z.columns: + # Convert the DataFrame to a GeoDataFrame + points_z_gdf = gpd.GeoDataFrame(df_points_z, geometry="geometry") + points_z_gdf.set_crs(crs, inplace=True) + # Save the result by LIDAR tile + output_geojson_path = os.path.join(output_dir, "_points_skeleton".join([tilename, ".geojson"])) + points_z_gdf.to_file(output_geojson_path, driver="GeoJSON") + logging.info(f"Result saved to {output_geojson_path}") diff --git a/scripts/example_extract_points_around_skeleton_default.sh b/scripts/example_extract_points_around_skeleton_default.sh new file mode 100644 index 00000000..7d195ba5 --- /dev/null +++ b/scripts/example_extract_points_around_skeleton_default.sh @@ -0,0 +1,13 @@ +# Launch hydro mask merging +python -m lidro.main_create_virtual_point \ +io.input_dir=./data/ \ +io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geosjon \ +io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ +io.output_dir=./tmp/points_skeleton/ \ +#io.virtual_point.filter.keep_neighbors_classes=[2, 9] \ #Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer +#io.virtual_point.vector.distance_meters=5 \ # distance in meters between 2 consecutive points from Skeleton Hydro +#io.virtual_point.vector.buffer=3 \ #buffer for searching the points classification (default. "3") of the input LAS/LAZ file +#io.virtual_point.vector.k=3 \ #the number of nearest neighbors to find with KNeighbors + + + diff --git a/test/vectors/test_extract_points_around_skeleton.py b/test/vectors/test_extract_points_around_skeleton.py new file mode 100644 index 00000000..4a5d28f4 --- /dev/null +++ b/test/vectors/test_extract_points_around_skeleton.py @@ -0,0 +1,71 @@ +import os +import shutil +from pathlib import Path + +import geopandas as gpd +from pyproj import CRS +from shapely.geometry import Point + +from lidro.create_virtual_point.vectors.extract_points_around_skeleton import ( + extract_points_around_skeleton_points_one_tile, +) + +TMP_PATH = Path("./tmp/create_virtual_point/vectors/extract_points_around_skeleton") +INPUT_DIR = Path("./data/") +MASK_HYDRO = "./data/merge_mask_hydro/MaskHydro_merge.geojson" +POINTS_SKELETON = "./data/skeleton_hydro/points_along_skeleton_small.geojson" +OUTPUT_GEOJSON = Path( + "./tmp/create_virtual_point/vectors/extract_points_around_skeleton/ \ + Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" +) + + +def setup_module(module): + if TMP_PATH.is_dir(): + shutil.rmtree(TMP_PATH) + os.makedirs(TMP_PATH) + + +def test_extract_points_around_skeleton_default(): + # Parameters + crs = CRS.from_epsg(2154) + classes = [2] # Example classes + k = 3 # Example k value + + # Mask Hydro with buffer + mask_hydro_buffered = "MULTIPOLYGON (((829969.0856167737 6292032.553442742,\ + 830452.9506643447 6292032.553442742, \ + 830498.1716968281 6291675.307286125, \ + 830624.7905877812 6291073.867554097, \ + 830783.0642014727 6290675.922468244, \ + 830972.9925379024 6290400.074170096, \ + 831045.3461898757 6290228.23424666, \ + 831036.3019833791 6289952.385948512, \ + 830783.0642014727 6289947.863845264, \ + 830371.5528058748 6290599.046713023, \ + 830055.005578492 6290947.248663144, \ + 829919.3424810421 6291399.458987976, \ + 829969.0856167737 6292032.553442742)))" + + # Execute the function + extract_points_around_skeleton_points_one_tile( + "Semis_2021_0830_6291_LA93_IGN69.laz", + INPUT_DIR, + TMP_PATH, + mask_hydro_buffered, + gpd.read_file(POINTS_SKELETON), + crs, + classes, + k, + ) + + # Verify that the output file exists + assert OUTPUT_GEOJSON.exists() + + # Load the output GeoJSON + gdf = gpd.read_file(OUTPUT_GEOJSON) + + # Assertions to check the integrity of the output + assert not gdf.empty, "GeoDataFrame shouldn't be empty" + assert gdf.crs.to_string() == crs.to_string(), "CRS should match the specified CRS" + assert all(isinstance(geom, Point) for geom in gdf.geometry), "All geometries should be Points" From 14aa14d678bdc535e8fcbf28fc8532db4451c578 Mon Sep 17 00:00:00 2001 From: mdupays Date: Mon, 12 Aug 2024 15:32:37 +0200 Subject: [PATCH 37/85] lauch function : extract points aloung skeleton with tests --- lidro/main_extract_points_from_skeleton.py | 84 ++++++++ .../test_main_extract_points_from_skeleton.py | 188 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 lidro/main_extract_points_from_skeleton.py create mode 100644 test/test_main_extract_points_from_skeleton.py diff --git a/lidro/main_extract_points_from_skeleton.py b/lidro/main_extract_points_from_skeleton.py new file mode 100644 index 00000000..a02a7854 --- /dev/null +++ b/lidro/main_extract_points_from_skeleton.py @@ -0,0 +1,84 @@ +""" Main script for calculate Mask HYDRO 1 +""" + +import logging +import os + +import hydra +from omegaconf import DictConfig +from pyproj import CRS + +from lidro.create_virtual_point.vectors.extract_points_around_skeleton import ( + extract_points_around_skeleton_points_one_tile, +) +from lidro.create_virtual_point.vectors.mask_hydro_with_buffer import ( + import_mask_hydro_with_buffer, +) +from lidro.create_virtual_point.vectors.points_along_skeleton import ( + generate_points_along_skeleton, +) + + +@hydra.main(config_path="../configs/", config_name="configs_lidro.yaml", version_base="1.2") +def main(config: DictConfig): + """Create N points along Skeleton from the points classification of + the input LAS/LAZ file and the Hyro Skeleton (GeoJSON). Save a result by LIDAR tiles. + + It can run either on a single file, or on each file of a folder + + Args: + config (DictConfig): hydra configuration (configs/configs_lidro.yaml by default) + It contains the algorithm parameters and the input/output parameters + """ + logging.basicConfig(level=logging.INFO) + # Check input/output files and folders + input_dir = config.io.input_dir + if input_dir is None: + raise ValueError("""config.io.input_dir is empty, please provide an input directory in the configuration""") + + if not os.path.isdir(input_dir): + raise FileNotFoundError(f"""The input directory ({input_dir}) doesn't exist.""") + + output_dir = config.io.output_dir + if output_dir is None: + raise ValueError("""config.io.output_dir is empty, please provide an input directory in the configuration""") + + os.makedirs(output_dir, exist_ok=True) + + # If input filename is not provided, lidro runs on the whole input_dir directory + initial_las_filename = config.io.input_filename + input_mask_hydro = config.io.input_mask_hydro + input_skeleton = config.io.input_skeleton + + # Parameters for creating virtual point + distance_meters = config.virtual_point.vector.distance_meters + buffer = config.virtual_point.vector.buffer + crs = CRS.from_user_input(config.io.srid) + classes = config.virtual_point.filter.keep_neighbors_classes + k = config.virtual_point.vector.k + + # Step 1 : Import Mask Hydro, then apply a buffer + # Return GeoDataframe + input_mask_hydro_buffer = import_mask_hydro_with_buffer(input_mask_hydro, buffer, crs).wkt + + # Step 2 : Create several points every 2 meters (by default) along skeleton Hydro + # Return GeoDataframe + points_skeleton_gdf = generate_points_along_skeleton(input_skeleton, distance_meters, crs) + + # Step 3 : Extract points around skeleton by tile + if initial_las_filename: + # Lauch croping filtered pointcloud by Mask Hydro with buffer by one tile: + extract_points_around_skeleton_points_one_tile( + initial_las_filename, input_dir, output_dir, input_mask_hydro_buffer, points_skeleton_gdf, crs, classes, k + ) + else: + # Lauch croping filtered pointcloud by Mask Hydro with buffer tile by tile + input_dir_points = os.path.join(input_dir, "pointcloud") + for file in os.listdir(input_dir_points): + extract_points_around_skeleton_points_one_tile( + file, input_dir, output_dir, input_mask_hydro_buffer, points_skeleton_gdf, crs, classes, k + ) + + +if __name__ == "__main__": + main() diff --git a/test/test_main_extract_points_from_skeleton.py b/test/test_main_extract_points_from_skeleton.py new file mode 100644 index 00000000..dff01e6d --- /dev/null +++ b/test/test_main_extract_points_from_skeleton.py @@ -0,0 +1,188 @@ +import os +import subprocess as sp +from pathlib import Path + +import pytest +from hydra import compose, initialize + +from lidro.main_extract_points_from_skeleton import main + +INPUT_DIR = Path("data") +OUTPUT_DIR = Path("tmp") / "extract_points_around_skeleton/main" + + +def setup_module(module): + os.makedirs("tmp/extract_points_around_skeleton/main", exist_ok=True) + + +def test_main_run_okay(): + repo_dir = Path.cwd().parent + cmd = f"""python -m lidro.main_extract_points_from_skeleton \ + io.input_dir="{repo_dir}/lidro/data/"\ + io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ + io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ + io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ + io.output_dir="{repo_dir}/lidro/tmp/extract_points_around_skeleton/main/" + """ + sp.run(cmd, shell=True, check=True) + + +def test_main_extract_points_skeleton_input_file(): + input_dir = INPUT_DIR + output_dir = OUTPUT_DIR / "main_extract_points_skeleton_input_file" + input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + # config is relative to a module + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_filename={input_filename}", + f"io.input_dir={input_dir}", + f"io.output_dir={output_dir}", + f"io.input_mask_hydro={input_mask_hydro}", + f"io.input_skeleton={input_skeleton}", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + main(cfg) + assert (Path(output_dir) / "Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson").is_file() + + +def test_main_extract_points_skeleton_fail_no_input_dir(): + output_dir = OUTPUT_DIR / "main_no_input_dir" + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + # config is relative to a module + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_mask_hydro={input_mask_hydro}", + f"io.input_skeleton={input_skeleton}", + f"io.output_dir={output_dir}", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + with pytest.raises(ValueError): + main(cfg) + + +def test_main_extract_points_skeleton_fail_wrong_input_dir(): + output_dir = OUTPUT_DIR / "main_wrong_input_dir" + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + # config is relative to a module + cfg = compose( + config_name="configs_lidro", + overrides=[ + "io.input_dir=some_random_input_dir", + f"io.input_mask_hydro={input_mask_hydro}", + f"io.input_skeleton={input_skeleton}", + f"io.output_dir={output_dir}", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + with pytest.raises(FileNotFoundError): + main(cfg) + + +def test_main_extract_points_skeleton_fail_no_output_dir(): + input_dir = INPUT_DIR + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + # config is relative to a module + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_dir={input_dir}", + f"io.input_mask_hydro={input_mask_hydro}", + f"io.input_skeleton={input_skeleton}", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + with pytest.raises(ValueError): + main(cfg) + + +def test_main_extract_points_skeleton_fail_no_input_mask_hydro(): + input_dir = INPUT_DIR + output_dir = OUTPUT_DIR / "main_no_input_dir" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_dir={input_dir}", + f"io.output_dir={output_dir}", + "io.input_mask_hydro=some_random_input_mask_hydro", + f"io.input_skeleton={input_skeleton}", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + with pytest.raises(ValueError): + main(cfg) + + +def test_main_extract_points_skeleton_fail_no_input_skeleton(): + input_dir = INPUT_DIR + output_dir = OUTPUT_DIR / "main_no_input_dir" + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + distances_meters = 5 + buffer = 2 + srid = 2154 + k = 10 + with initialize(version_base="1.2", config_path="../configs"): + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_dir={input_dir}", + f"io.output_dir={output_dir}", + f"io.input_mask_hydro={input_mask_hydro}", + "io.input_skeleton=some_random_input_skeleton", + f"virtual_point.vector.distance_meters={distances_meters}", + f"virtual_point.vector.buffer={buffer}", + f"virtual_point.vector.k={k}", + f"io.srid={srid}", + ], + ) + with pytest.raises(ValueError): + main(cfg) From ebf8fa840e37b4f1d950d1e84b1546dd6333f889 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:24:03 +0200 Subject: [PATCH 38/85] update README --- README.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6ae838ec..35011961 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,21 @@ Cette modélisation des surfaces hydrographiques se décline en 3 grands enjeux Les données en entrées : - dalles LIDAR classées -- données vectorielles représentant le réseau hydrographique issu des différentes bases de données IGN (BDUnis, BDTopo, etc.) +- données vectorielles représentant le réseau hydrographique issu des différentes bases de données IGN (BDUnis, BDTopo, etc.) / ! \ requête SQL fait manuellement pour le moment. Exemple de requête SQL sur le bloc PM du LIDAR HD +``` +WITH emprise_PM AS (SELECT st_GeomFromText('POLYGON((875379.222972973 6431750.0, +875379.222972973 6484250.0, +946620.777027027 6484250.0, +946620.777027027 6431750.0, +875379.222972973 6431750.0))') as geom) + +SELECT geometrie, nature, fictif, persistance, classe_de_largeur, position_par_rapport_au_sol +FROM troncon_hydrographique +JOIN emprise_PM ON st_intersects(troncon_hydrographique.geometrie,emprise_PM.geom) +WHERE NOT gcms_detruit +AND classe_de_largeur NOT IN ('Entre 0 et 5 m', 'Sans objet') +AND position_par_rapport_au_sol='0' +``` Trois grands axes du processus à mettre en place en distanguant l'échelle de traitement associé : * 1- Création de masques hydrographiques à l'échelle de la dalle LIDAR @@ -34,7 +48,8 @@ A noter que pour l'instant, la suppresion des masques hydrographiques en dehors ![Chaine de traitement des points virtuels](images/process_points_virtuels.jpg) Il existe plusieurs étapes intermédiaires : -* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unis) à partir de l'emprise du masque hydrographique "écoulement" apparaier, contrôler et corriger en amont (étape manuelle) +* 1- création automatique du tronçon hydrographique ("Squelette hydrographique", soit les tronçons hydrographiques dans la BD Unis) à partir de l'emprise du masque hydrographique "écoulement". +/ ! \ EN AMONT : Appariement, contrôle et correction manuels des masques hydrographiques "écoulement" (rivières) et du squelette hydrographique A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres @@ -117,12 +132,19 @@ Tester sur un dossier contenant plusieurs dalles LIDAR pour créer fusionner l'e example_merge_mask_default.sh ``` * 3- Création des tronçons hydrographiques à l'échelle de/des entité(s) hydrographique(s) - +``` +example_create_skeleton_lines.sh +``` * 4- Création des points virtuels -Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points virtuels 3D à l'intérieurs des masques hydrographiques +A. Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points tous les N mètres le long du squelette hydrographique, et réccupérer les N plus proches voisins points LIDAR "SOL" +``` +example_extract_points_around_skeleton_default.sh +``` + +B. Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points virtuels 3D à l'intérieurs des masques hydrographiques ``` -example_create_virtual_point_by_tile.sh +example_create_virtual_point_default.sh ``` ### Tests unitaires From c0b0eaf1f978306d3f575d7a5ddffccb0a0e5eef Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:24:54 +0200 Subject: [PATCH 39/85] add exemple script for extract points around skeleton --- scripts/example_extract_points_around_skeleton_default.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/example_extract_points_around_skeleton_default.sh b/scripts/example_extract_points_around_skeleton_default.sh index 7d195ba5..4dee2506 100644 --- a/scripts/example_extract_points_around_skeleton_default.sh +++ b/scripts/example_extract_points_around_skeleton_default.sh @@ -1,5 +1,5 @@ # Launch hydro mask merging -python -m lidro.main_create_virtual_point \ +python -m lidro.main_extract_points_from_skeleton \ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geosjon \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ From 0ccc864b3cbaaeff05602a1cf64f5e34fdb46f91 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:25:43 +0200 Subject: [PATCH 40/85] add exemples scripts for create virtual point --- scripts/example_create_virtual_point_by_tile.sh | 2 +- scripts/example_create_virtual_point_default.sh | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/example_create_virtual_point_by_tile.sh b/scripts/example_create_virtual_point_by_tile.sh index 4a1a8ccc..5ea6bac3 100644 --- a/scripts/example_create_virtual_point_by_tile.sh +++ b/scripts/example_create_virtual_point_by_tile.sh @@ -5,6 +5,6 @@ io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/ \ - +#io.virtual_point.vector.s=0.5 \ #Spacing between the grid points in meters diff --git a/scripts/example_create_virtual_point_default.sh b/scripts/example_create_virtual_point_default.sh index 3c929699..29b65ff7 100644 --- a/scripts/example_create_virtual_point_default.sh +++ b/scripts/example_create_virtual_point_default.sh @@ -4,10 +4,6 @@ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/ \ -#io.virtual_point.filter.keep_neighbors_classes=[2, 9] \ #Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer -#io.virtual_point.vector.distance_meters=5 \ # distance in meters between 2 consecutive points from Skeleton Hydro -#io.virtual_point.vector.buffer=3 \ #buffer for searching the points classification (default. "3") of the input LAS/LAZ file -#io.virtual_point.vector.k=3 \ #the number of nearest neighbors to find with KNeighbors #io.virtual_point.vector.s=0.5 \ #Spacing between the grid points in meters From 5d8bbf8e63c157d741c80f19a032edcb4b2feeee Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:27:18 +0200 Subject: [PATCH 41/85] refacto test and function for creating virtual points --- lidro/main_create_virtual_point.py | 64 +++++++---------------- test/test_main_create_virtual_point.py | 70 ++++++++------------------ 2 files changed, 39 insertions(+), 95 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 949b2b4c..c58d33eb 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -1,6 +1,7 @@ """ Main script for calculate Mask HYDRO 1 """ +import ast import logging import os import sys @@ -16,18 +17,9 @@ from lidro.create_virtual_point.pointcloud.convert_geodataframe_to_las import ( geodataframe_to_las, ) -from lidro.create_virtual_point.vectors.extract_points_around_skeleton import ( - extract_points_around_skeleton_points_one_tile, -) -from lidro.create_virtual_point.vectors.mask_hydro_with_buffer import ( - import_mask_hydro_with_buffer, -) from lidro.create_virtual_point.vectors.merge_skeleton_by_mask import ( merge_skeleton_by_mask, ) -from lidro.create_virtual_point.vectors.points_along_skeleton import ( - generate_points_along_skeleton, -) from lidro.create_virtual_point.vectors.run_create_virtual_points import ( lauch_virtual_points_by_section, ) @@ -35,7 +27,7 @@ @hydra.main(config_path="../configs/", config_name="configs_lidro.yaml", version_base="1.2") def main(config: DictConfig): - """Create a virtual point along hydro surfaces from the points classification of + """Create a virtual point inside hydro surfaces (3D grid) from the points classification of the input LAS/LAZ file and the Hyro Skeleton (GeoJSON) and save it as LAS file. It can run either on a single file, or on each file of a folder @@ -59,54 +51,36 @@ def main(config: DictConfig): os.makedirs(output_dir, exist_ok=True) - # If input filename is not provided, lidro runs on the whole input_dir directory - initial_las_filename = config.io.input_filename + # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton - - # Parameters for creating virtual point - distance_meters = config.virtual_point.vector.distance_meters - buffer = config.virtual_point.vector.buffer crs = CRS.from_user_input(config.io.srid) - classes = config.virtual_point.filter.keep_neighbors_classes - k = config.virtual_point.vector.k s = config.virtual_point.vector.s - # Step 1 : Import Mask Hydro, then apply a buffer - # Return GeoDataframe - input_mask_hydro_buffer = import_mask_hydro_with_buffer(input_mask_hydro, buffer, crs).wkt + # Step 1 : Merged all "points_aron_skeleton" by lidar tile + input_dir_points = os.path.join(output_dir, "points_skeleton") - # Step 2 : Create several points every 2 meters (by default) along skeleton Hydro - # Return GeoDataframe - points_skeleton_gdf = generate_points_along_skeleton(input_skeleton, distance_meters, crs) + def process_points_knn(points_knn): + # Check if points_knn is a string and convert it to a list if necessary + if isinstance(points_knn, str): + points_knn = ast.literal_eval(points_knn) # Convert the string to a list of lists - # Step 3 : Extract points around skeleton by tile - if initial_las_filename: - # Lauch croping filtered pointcloud by Mask Hydro with buffer by one tile: - points_clip = extract_points_around_skeleton_points_one_tile( - initial_las_filename, input_dir, input_mask_hydro_buffer, points_skeleton_gdf, classes, k - ) - else: - # Lauch croping filtered pointcloud by Mask Hydro with buffer tile by tile - input_dir_points = os.path.join(input_dir, "pointcloud") - points_clip_list = [ - extract_points_around_skeleton_points_one_tile( - file, input_dir, input_mask_hydro_buffer, points_skeleton_gdf, classes, k - ) - for file in os.listdir(input_dir_points) - ] - # Flatten the list of lists into a single list of dictionaries - points_clip = [item for sublist in points_clip_list for item in sublist] + # Round each coordinate to 8 decimal places + return [[round(coord, 8) for coord in point] for point in points_knn] + points_clip_list = [ + {"geometry": row["geometry"], "points_knn": process_points_knn(row["points_knn"])} + for filename in os.listdir(input_dir_points) + if filename.endswith(".geojson") + for _, row in gpd.read_file(os.path.join(input_dir_points, filename)).iterrows() + ] # List match Z elevation values every N meters along the hydrographic skeleton - df = pd.DataFrame(points_clip) + df = pd.DataFrame(points_clip_list) + # Step 2: Smooth Z by hydro's section if not df.empty and "points_knn" in df.columns and "geometry" in df.columns: - # Convert the DataFrame to a GeoDataFrame points_gdf = gpd.GeoDataFrame(df, geometry="geometry") points_gdf.set_crs(crs, inplace=True) - - # Step 4: Smooth Z by hydro's section # Combine skeleton lines into a single polyline for each hydro entity gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 896c9a09..2ea43b96 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -21,7 +21,7 @@ def test_main_run_okay(): io.input_dir="{repo_dir}/lidro/data/"\ io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ - io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro.geojson"\ + io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) @@ -29,13 +29,10 @@ def test_main_run_okay(): def test_main_lidro_default(): input_dir = INPUT_DIR - output_dir = OUTPUT_DIR / "main_lidro_default" + output_dir = OUTPUT_DIR / "main_lidro_default/" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 3 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 3 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -47,9 +44,6 @@ def test_main_lidro_default(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -63,11 +57,9 @@ def test_main_lidro_input_file(): output_dir = OUTPUT_DIR / "main_lidro_input_file" input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 2 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module cfg = compose( @@ -78,10 +70,8 @@ def test_main_lidro_input_file(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", f"io.srid={srid}", + f"virtual_point.vector.s={s}", ], ) main(cfg) @@ -91,11 +81,9 @@ def test_main_lidro_input_file(): def test_main_lidro_fail_no_input_dir(): output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 2 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module cfg = compose( @@ -104,9 +92,7 @@ def test_main_lidro_fail_no_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) @@ -117,11 +103,9 @@ def test_main_lidro_fail_no_input_dir(): def test_main_lidro_fail_wrong_input_dir(): output_dir = OUTPUT_DIR / "main_wrong_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 2 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module cfg = compose( @@ -131,9 +115,7 @@ def test_main_lidro_fail_wrong_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) @@ -144,11 +126,9 @@ def test_main_lidro_fail_wrong_input_dir(): def test_main_lidro_fail_no_output_dir(): input_dir = INPUT_DIR input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 2 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module cfg = compose( @@ -157,9 +137,7 @@ def test_main_lidro_fail_no_output_dir(): f"io.input_dir={input_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) @@ -170,11 +148,9 @@ def test_main_lidro_fail_no_output_dir(): def test_main_lidro_fail_no_input_mask_hydro(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro.geojson" - distances_meters = 5 - buffer = 2 + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): cfg = compose( config_name="configs_lidro", @@ -183,9 +159,7 @@ def test_main_lidro_fail_no_input_mask_hydro(): f"io.output_dir={output_dir}", "io.input_mask_hydro=some_random_input_mask_hydro", f"io.input_skeleton={input_skeleton}", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) @@ -197,10 +171,8 @@ def test_main_lidro_fail_no_input_skeleton(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - distances_meters = 5 - buffer = 2 srid = 2154 - k = 10 + s = 1 with initialize(version_base="1.2", config_path="../configs"): cfg = compose( config_name="configs_lidro", @@ -209,9 +181,7 @@ def test_main_lidro_fail_no_input_skeleton(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", "io.input_skeleton=some_random_input_skeleton", - f"virtual_point.vector.distance_meters={distances_meters}", - f"virtual_point.vector.buffer={buffer}", - f"virtual_point.vector.k={k}", + f"virtual_point.vector.s={s}", f"io.srid={srid}", ], ) From 10043cc13c6d696340faf864f5b45bfcf3e90e01 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:28:06 +0200 Subject: [PATCH 42/85] add new function for extract points around skeleton by tile : etape 4.A --- .../vectors/extract_points_around_skeleton.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py index f6d0abe6..1e4fe83b 100644 --- a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py +++ b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py @@ -6,6 +6,7 @@ from typing import List import geopandas as gpd +import numpy as np import pandas as pd from pdaltools.las_info import las_get_xy_bounds from shapely.geometry import Point @@ -62,10 +63,12 @@ def extract_points_around_skeleton_points_one_tile( logging.info(f"\nExtract a Z elevation value along the hydrographic skeleton for tile : {tilename}") points_Z = filter_las_around_point(points_skeleton_with_z_clip, points_clip, k) df_points_z = pd.DataFrame(points_Z) # Convert Dataframe - # Convert list "points_knn" to string - df_points_z["points_knn_str"] = df_points_z["points_knn"].apply(lambda x: str(x)) - df_points_z = df_points_z[["geometry", "points_knn_str"]] - if not df_points_z.empty and "points_knn_str" in df_points_z.columns and "geometry" in df_points_z.columns: + if not df_points_z.empty and "points_knn" in df_points_z.columns and "geometry" in df_points_z.columns: + # Convert list "points_knn" to string without scientific notation + df_points_z["points_knn"] = df_points_z["points_knn"].apply( + lambda x: str([[np.round(coord, 3) for coord in point] for point in x]) + ) + df_points_z = df_points_z[["geometry", "points_knn"]] # Convert the DataFrame to a GeoDataFrame points_z_gdf = gpd.GeoDataFrame(df_points_z, geometry="geometry") points_z_gdf.set_crs(crs, inplace=True) From 62eb89ab6649c3ddf623f30e7c88af5d6133d3b9 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:28:49 +0200 Subject: [PATCH 43/85] update run create virtual points with new version --- lidro/create_virtual_point/vectors/run_create_virtual_points.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index 316751ef..e30c353c 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -59,7 +59,6 @@ def lauch_virtual_points_by_section( gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) # Calculate the length of the river river_length = float(line.length.iloc[0]) - print(river_length) # Step 2 : Model linear regression for river's lenght > 150 m if river_length > 150: From b9e832b3b0beb664facbad4552bda58765569c1c Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:30:55 +0200 Subject: [PATCH 44/85] refacto linear regression model --- .../vectors/linear_regression_model.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lidro/create_virtual_point/vectors/linear_regression_model.py b/lidro/create_virtual_point/vectors/linear_regression_model.py index 53854623..b427a0c9 100644 --- a/lidro/create_virtual_point/vectors/linear_regression_model.py +++ b/lidro/create_virtual_point/vectors/linear_regression_model.py @@ -40,18 +40,23 @@ def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoData logging.warning("Not enough points for regression analysis") return np.poly1d([0, 0]), 0.0 - # Merge points and remove duplicates - all_points_knn = np.vstack(gdf_points["points_knn"].values) - unique_points_knn = np.unique(all_points_knn, axis=0) + # Combine all `points_knn` into a single list + all_points_knn = [] + for knn_list in gdf_points["points_knn"]: + all_points_knn.extend(knn_list) - # Create a final GeoDataFrame - final_data = {"geometry": [line.iloc[0]["geometry"]], "points_knn": [unique_points_knn]} + # Convert the list of points to a numpy array and remove duplicates + all_points_knn_array = np.array(all_points_knn) + unique_points_knn = np.unique(all_points_knn_array, axis=0) + + # Create final data structure + final_data = {"geometry": [line.iloc[0]["geometry"]], "points_knn": unique_points_knn} # Generate projected coordinates points_gs = gpd.GeoSeries().from_xy( - final_data["points_knn"][0][:, 0], # X coordinates - final_data["points_knn"][0][:, 1], # Y coordinates - final_data["points_knn"][0][:, 2], # Z coordinates + final_data["points_knn"][:, 0], # X coordinates + final_data["points_knn"][:, 1], # Y coordinates + final_data["points_knn"][:, 2], # Z coordinates crs=crs, # Coordinate reference system ) From 82f230a7a9f226f4e3f75a76758287fb47fc3652 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:58:29 +0200 Subject: [PATCH 45/85] refacto test extract points from skeleton --- test/vectors/test_extract_points_around_skeleton.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/vectors/test_extract_points_around_skeleton.py b/test/vectors/test_extract_points_around_skeleton.py index 4a5d28f4..5baa06b3 100644 --- a/test/vectors/test_extract_points_around_skeleton.py +++ b/test/vectors/test_extract_points_around_skeleton.py @@ -14,10 +14,7 @@ INPUT_DIR = Path("./data/") MASK_HYDRO = "./data/merge_mask_hydro/MaskHydro_merge.geojson" POINTS_SKELETON = "./data/skeleton_hydro/points_along_skeleton_small.geojson" -OUTPUT_GEOJSON = Path( - "./tmp/create_virtual_point/vectors/extract_points_around_skeleton/ \ - Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" -) +OUTPUT_GEOJSON = TMP_PATH / "Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" def setup_module(module): From 3a3f117a9b4508b324db942b38291cf3cd695213 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 14:59:36 +0200 Subject: [PATCH 46/85] refacto test intersection points by line --- test/vectors/test_intersection_points_by_line.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/vectors/test_intersection_points_by_line.py b/test/vectors/test_intersection_points_by_line.py index d8914c8a..00582009 100644 --- a/test/vectors/test_intersection_points_by_line.py +++ b/test/vectors/test_intersection_points_by_line.py @@ -19,14 +19,16 @@ def test_return_points_by_line_default(): # Call the function to test result = return_points_by_line(points, line) + result_geom = result["geometry"] # Expected points are those within the buffer of the line expected_points = gpd.GeoDataFrame( {"geometry": [Point(700000, 6600000), Point(700001, 6600001), Point(700002, 6600002)]}, crs="EPSG:2154" ) + expected_points_geom = expected_points["geometry"] # Check that the result is a GeoDataFrame assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" # Check that the result contains the expected points - assert result.equals(expected_points), f"The result does not match the expected points. Got: {result}" + assert result_geom.equals(expected_points_geom), f"The result does not match the expected points. Got: {result}" From ab25e9fe1e650f6ac4c167503e42a333694cc7ef Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 15:00:10 +0200 Subject: [PATCH 47/85] refacto test merge skeleton by mask --- test/vectors/test_merge_skeleton_by_mask.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/vectors/test_merge_skeleton_by_mask.py b/test/vectors/test_merge_skeleton_by_mask.py index 5ff7b4c1..43640e8a 100644 --- a/test/vectors/test_merge_skeleton_by_mask.py +++ b/test/vectors/test_merge_skeleton_by_mask.py @@ -34,6 +34,3 @@ def test_merge_skeleton_by_mask_default(): # Check if the necessary columns are present assert "geometry_skeleton" in result.columns, "Missing 'geometry_skeleton' column" assert "geometry_mask" in result.columns, "Missing 'geometry_mask' column" - - # Check if there are exactly two rows - assert len(result) == 2, f"DataFrame should contain exactly two rows, found {len(result)}" From dd4f5eac389c19b979357f12acf2a37d1708ab98 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 15:31:49 +0200 Subject: [PATCH 48/85] update submodule --- data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data b/data index 19f969cd..36a33e74 160000 --- a/data +++ b/data @@ -1 +1 @@ -Subproject commit 19f969cd0f938dcd5ca3d7019578fa74785c9934 +Subproject commit 36a33e74ac841a0c59a0f1692781fdec2eaee8eb From 924eb51f96114ffb101683cde87c666b324cb934 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 13 Aug 2024 15:48:50 +0200 Subject: [PATCH 49/85] update test config and script for lauch virtuals points --- configs/configs_lidro.yaml | 1 + lidro/main_create_virtual_point.py | 7 ++- test/test_main_create_virtual_point.py | 62 +++++++++++++++----------- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 4496289e..f282a9da 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -12,6 +12,7 @@ io: input_skeleton: null input_dir: null output_dir: null + input_dir_point_skeleton: null srid: 2154 extension: .tif raster_driver: GTiff diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index c58d33eb..19ac9cb8 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -54,12 +54,11 @@ def main(config: DictConfig): # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton + input_dir_points_skeleton = config.io.input_dir_points_skeleton crs = CRS.from_user_input(config.io.srid) s = config.virtual_point.vector.s # Step 1 : Merged all "points_aron_skeleton" by lidar tile - input_dir_points = os.path.join(output_dir, "points_skeleton") - def process_points_knn(points_knn): # Check if points_knn is a string and convert it to a list if necessary if isinstance(points_knn, str): @@ -70,9 +69,9 @@ def process_points_knn(points_knn): points_clip_list = [ {"geometry": row["geometry"], "points_knn": process_points_knn(row["points_knn"])} - for filename in os.listdir(input_dir_points) + for filename in os.listdir(input_dir_points_skeleton) if filename.endswith(".geojson") - for _, row in gpd.read_file(os.path.join(input_dir_points, filename)).iterrows() + for _, row in gpd.read_file(os.path.join(input_dir_points_skeleton, filename)).iterrows() ] # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip_list) diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 2ea43b96..3b24b026 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -22,42 +22,19 @@ def test_main_run_okay(): io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ + io.input_dir_points_skeleton="{repo_dir}/lidro/data/virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) -def test_main_lidro_default(): - input_dir = INPUT_DIR - output_dir = OUTPUT_DIR / "main_lidro_default/" - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - srid = 2154 - s = 1 - - with initialize(version_base="1.2", config_path="../configs"): - # config is relative to a module - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_dir={input_dir}", - f"io.output_dir={output_dir}", - f"io.input_mask_hydro={input_mask_hydro}", - f"io.input_skeleton={input_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - main(cfg) - assert (Path(output_dir) / "virtual_points.las").is_file() - - def test_main_lidro_input_file(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_lidro_input_file" input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + input_dir_points_skeleton = INPUT_DIR / "virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -70,6 +47,7 @@ def test_main_lidro_input_file(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"io.srid={srid}", f"virtual_point.vector.s={s}", ], @@ -82,6 +60,7 @@ def test_main_lidro_fail_no_input_dir(): output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -92,6 +71,7 @@ def test_main_lidro_fail_no_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -104,6 +84,7 @@ def test_main_lidro_fail_wrong_input_dir(): output_dir = OUTPUT_DIR / "main_wrong_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -115,6 +96,7 @@ def test_main_lidro_fail_wrong_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -127,6 +109,7 @@ def test_main_lidro_fail_no_output_dir(): input_dir = INPUT_DIR input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -137,6 +120,7 @@ def test_main_lidro_fail_no_output_dir(): f"io.input_dir={input_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -149,6 +133,7 @@ def test_main_lidro_fail_no_input_mask_hydro(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -159,6 +144,7 @@ def test_main_lidro_fail_no_input_mask_hydro(): f"io.output_dir={output_dir}", "io.input_mask_hydro=some_random_input_mask_hydro", f"io.input_skeleton={input_skeleton}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -171,6 +157,7 @@ def test_main_lidro_fail_no_input_skeleton(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -181,6 +168,31 @@ def test_main_lidro_fail_no_input_skeleton(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", "io.input_skeleton=some_random_input_skeleton", + f"io.input_dir_ponts_skeleton={input_dir_points_skeleton}", + f"virtual_point.vector.s={s}", + f"io.srid={srid}", + ], + ) + with pytest.raises(ValueError): + main(cfg) + + +def test_main_lidro_fail_no_input_dir_points_skeleton(): + input_dir = INPUT_DIR + output_dir = OUTPUT_DIR / "main_no_input_dir" + input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" + input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" + srid = 2154 + s = 1 + with initialize(version_base="1.2", config_path="../configs"): + cfg = compose( + config_name="configs_lidro", + overrides=[ + f"io.input_dir={input_dir}", + f"io.output_dir={output_dir}", + f"io.input_mask_hydro={input_mask_hydro}", + f"io.input_skeleton={input_skeleton}", + "io.input_dir_points_skeleton=some_random_input_dir_points_skeleton", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], From 2949eb21817e1b4f0b73fb2817ceaeab78a96da1 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 14 Aug 2024 10:49:02 +0200 Subject: [PATCH 50/85] update input=input_dir_point_skeleton: error name --- lidro/main_create_virtual_point.py | 6 ++-- .../example_create_virtual_point_default.sh | 1 + test/test_main_create_virtual_point.py | 28 +++++++++---------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 19ac9cb8..4c72eb32 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -54,7 +54,7 @@ def main(config: DictConfig): # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton - input_dir_points_skeleton = config.io.input_dir_points_skeleton + input_dir_point_skeleton = config.io.input_dir_points_skeleton crs = CRS.from_user_input(config.io.srid) s = config.virtual_point.vector.s @@ -69,9 +69,9 @@ def process_points_knn(points_knn): points_clip_list = [ {"geometry": row["geometry"], "points_knn": process_points_knn(row["points_knn"])} - for filename in os.listdir(input_dir_points_skeleton) + for filename in os.listdir(input_dir_point_skeleton) if filename.endswith(".geojson") - for _, row in gpd.read_file(os.path.join(input_dir_points_skeleton, filename)).iterrows() + for _, row in gpd.read_file(os.path.join(input_dir_point_skeleton, filename)).iterrows() ] # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip_list) diff --git a/scripts/example_create_virtual_point_default.sh b/scripts/example_create_virtual_point_default.sh index 29b65ff7..4708b831 100644 --- a/scripts/example_create_virtual_point_default.sh +++ b/scripts/example_create_virtual_point_default.sh @@ -3,6 +3,7 @@ python -m lidro.main_create_virtual_point \ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ +io.input_dir_point_skeleton=./tmp/point_skeleton/ \ io.output_dir=./tmp/ \ #io.virtual_point.vector.s=0.5 \ #Spacing between the grid points in meters diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 3b24b026..248ea535 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -22,7 +22,7 @@ def test_main_run_okay(): io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ - io.input_dir_points_skeleton="{repo_dir}/lidro/data/virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson"\ + io.input_dir_point_skeleton="{repo_dir}/lidro/data/virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) @@ -34,7 +34,7 @@ def test_main_lidro_input_file(): input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = INPUT_DIR / "virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" + input_dir_point_skeleton = INPUT_DIR / "virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -47,7 +47,7 @@ def test_main_lidro_input_file(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"io.srid={srid}", f"virtual_point.vector.s={s}", ], @@ -60,7 +60,7 @@ def test_main_lidro_fail_no_input_dir(): output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" + input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -71,7 +71,7 @@ def test_main_lidro_fail_no_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -84,7 +84,7 @@ def test_main_lidro_fail_wrong_input_dir(): output_dir = OUTPUT_DIR / "main_wrong_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" + input_dir_point_skeleton = OUTPUT_DIR / "points_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -96,7 +96,7 @@ def test_main_lidro_fail_wrong_input_dir(): f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", f"io.output_dir={output_dir}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -109,7 +109,7 @@ def test_main_lidro_fail_no_output_dir(): input_dir = INPUT_DIR input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" + input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -120,7 +120,7 @@ def test_main_lidro_fail_no_output_dir(): f"io.input_dir={input_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -133,7 +133,7 @@ def test_main_lidro_fail_no_input_mask_hydro(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" + input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -144,7 +144,7 @@ def test_main_lidro_fail_no_input_mask_hydro(): f"io.output_dir={output_dir}", "io.input_mask_hydro=some_random_input_mask_hydro", f"io.input_skeleton={input_skeleton}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -157,7 +157,7 @@ def test_main_lidro_fail_no_input_skeleton(): input_dir = INPUT_DIR output_dir = OUTPUT_DIR / "main_no_input_dir" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_dir_points_skeleton = OUTPUT_DIR / "points_skeleton" + input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" srid = 2154 s = 1 with initialize(version_base="1.2", config_path="../configs"): @@ -168,7 +168,7 @@ def test_main_lidro_fail_no_input_skeleton(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", "io.input_skeleton=some_random_input_skeleton", - f"io.input_dir_ponts_skeleton={input_dir_points_skeleton}", + f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], @@ -192,7 +192,7 @@ def test_main_lidro_fail_no_input_dir_points_skeleton(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - "io.input_dir_points_skeleton=some_random_input_dir_points_skeleton", + "io.input_dir_point_skeleton=some_random_input_dir_point_skeleton", f"virtual_point.vector.s={s}", f"io.srid={srid}", ], From c5266db45f24744c695e36daf97ca513a72f8dec Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 14 Aug 2024 10:53:53 +0200 Subject: [PATCH 51/85] update test for lauch virtual point --- test/test_main_create_virtual_point.py | 146 ------------------------- 1 file changed, 146 deletions(-) diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 248ea535..8bf492e7 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -2,7 +2,6 @@ import subprocess as sp from pathlib import Path -import pytest from hydra import compose, initialize from lidro.main_create_virtual_point import main @@ -54,148 +53,3 @@ def test_main_lidro_input_file(): ) main(cfg) assert (Path(output_dir) / "virtual_points.las").is_file() - - -def test_main_lidro_fail_no_input_dir(): - output_dir = OUTPUT_DIR / "main_no_input_dir" - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - # config is relative to a module - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_mask_hydro={input_mask_hydro}", - f"io.input_skeleton={input_skeleton}", - f"io.output_dir={output_dir}", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(ValueError): - main(cfg) - - -def test_main_lidro_fail_wrong_input_dir(): - output_dir = OUTPUT_DIR / "main_wrong_input_dir" - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = OUTPUT_DIR / "points_skeleton" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - # config is relative to a module - cfg = compose( - config_name="configs_lidro", - overrides=[ - "io.input_dir=some_random_input_dir", - f"io.input_mask_hydro={input_mask_hydro}", - f"io.input_skeleton={input_skeleton}", - f"io.output_dir={output_dir}", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(FileNotFoundError): - main(cfg) - - -def test_main_lidro_fail_no_output_dir(): - input_dir = INPUT_DIR - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - # config is relative to a module - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_dir={input_dir}", - f"io.input_mask_hydro={input_mask_hydro}", - f"io.input_skeleton={input_skeleton}", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(ValueError): - main(cfg) - - -def test_main_lidro_fail_no_input_mask_hydro(): - input_dir = INPUT_DIR - output_dir = OUTPUT_DIR / "main_no_input_dir" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_dir={input_dir}", - f"io.output_dir={output_dir}", - "io.input_mask_hydro=some_random_input_mask_hydro", - f"io.input_skeleton={input_skeleton}", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(ValueError): - main(cfg) - - -def test_main_lidro_fail_no_input_skeleton(): - input_dir = INPUT_DIR - output_dir = OUTPUT_DIR / "main_no_input_dir" - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_dir_point_skeleton = OUTPUT_DIR / "point_skeleton" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_dir={input_dir}", - f"io.output_dir={output_dir}", - f"io.input_mask_hydro={input_mask_hydro}", - "io.input_skeleton=some_random_input_skeleton", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(ValueError): - main(cfg) - - -def test_main_lidro_fail_no_input_dir_points_skeleton(): - input_dir = INPUT_DIR - output_dir = OUTPUT_DIR / "main_no_input_dir" - input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" - input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - srid = 2154 - s = 1 - with initialize(version_base="1.2", config_path="../configs"): - cfg = compose( - config_name="configs_lidro", - overrides=[ - f"io.input_dir={input_dir}", - f"io.output_dir={output_dir}", - f"io.input_mask_hydro={input_mask_hydro}", - f"io.input_skeleton={input_skeleton}", - "io.input_dir_point_skeleton=some_random_input_dir_point_skeleton", - f"virtual_point.vector.s={s}", - f"io.srid={srid}", - ], - ) - with pytest.raises(ValueError): - main(cfg) From 1c18bc2f364387f8fcb2104e3d4e93f42e31b813 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 14 Aug 2024 11:00:35 +0200 Subject: [PATCH 52/85] update flatten river for avoid error --- .../vectors/flatten_river.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lidro/create_virtual_point/vectors/flatten_river.py b/lidro/create_virtual_point/vectors/flatten_river.py index 69bdd0cf..1b346b3c 100644 --- a/lidro/create_virtual_point/vectors/flatten_river.py +++ b/lidro/create_virtual_point/vectors/flatten_river.py @@ -24,15 +24,28 @@ def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame): """ # Inputs gdf_points = return_points_by_line(points, line) + + # Extract points_knn and drop NaNs + points_knn_values = gdf_points["points_knn"].dropna().values + + # Check if points_knn_values is empty + if points_knn_values.size == 0: + logging.warning("For little river : no valid points found, returning default Z quartile as 0") + z_first_quartile = 0 + return z_first_quartile + # Merge points and remove duplicates - all_points_knn = np.vstack(gdf_points["points_knn"].dropna().values) - if all_points_knn.size == 0: - logging.warning("For little river : error Z quartile") + try: + all_points_knn = np.vstack(points_knn_values) + except ValueError as e: + logging.error(f"Error during stacking points: {e}") z_first_quartile = 0 - else: - unique_points_knn = np.unique(all_points_knn, axis=0) - # Calculate the 1st quartile of all points - first_quartile = np.percentile(unique_points_knn, 25, axis=0) - z_first_quartile = first_quartile[-1] + return z_first_quartile + + unique_points_knn = np.unique(all_points_knn, axis=0) + + # Calculate the 1st quartile of all points + first_quartile = np.percentile(unique_points_knn, 25, axis=0) + z_first_quartile = first_quartile[-1] return z_first_quartile From ee917966b9a3f2f99b4f85729d1d2ef5b6e5737e Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 14 Aug 2024 11:04:24 +0200 Subject: [PATCH 53/85] rectify line 54 --- lidro/main_create_virtual_point.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 4c72eb32..740864ca 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -54,7 +54,7 @@ def main(config: DictConfig): # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton - input_dir_point_skeleton = config.io.input_dir_points_skeleton + input_dir_point_skeleton = config.io.input_dir_point_skeleton crs = CRS.from_user_input(config.io.srid) s = config.virtual_point.vector.s From 866730496ad0e4254355143bdeaf68768e7c119d Mon Sep 17 00:00:00 2001 From: mdupaysign <162304206+mdupaysign@users.noreply.github.com> Date: Wed, 14 Aug 2024 15:52:15 +0200 Subject: [PATCH 54/85] Update README.md --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 35011961..86751c24 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ Trois grands axes du processus à mettre en place en distanguant l'échelle de t * 1- Création de masques hydrographiques à l'échelle de la dalle LIDAR * 2- Création de masques hydrographiques pré-filtrés à l'échelle du chantier, soit : * la suppression de ces masques dans les zones ZICAD/ZIPVA - * la suppression des aires < 150 m² (paramètrables) + * la suppression des aires < 150 m² (paramétrable) * la suppression des aires < 1000 m² hors BD IGN (grands cours d'eau < 5m de large) -A noter que pour l'instant, la suppresion des masques hydrographiques en dehors des grands cours d'eaux et le nettoyage de ces masques s'effectue manuellement. Ce processus sera développé prochainement en automatique. +A noter que pour l'instant, la suppresion des masques hydrographiques en dehors des grands cours d'eau et le nettoyage de ces masques s'effectuent manuellement. Ce processus sera développé prochainement en automatique. * 3- Création de points virtuels le long de deux entités hydrographiques : * Grands cours d'eau (> 5 m de large dans la BD Unis). * Surfaces planes (mer, lac, étang, etc.) (pas encore développé) @@ -53,19 +53,19 @@ Il existe plusieurs étapes intermédiaires : A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres -Pour les cours d'eaux supérieurs à 150 m de long : +Pour les cours d'eau supérieurs à 150 m de long : * 3- Transformer les coordonnées de ces points (étape précédente) en abscisses curvilignes -* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. Les différents Z le long des squelettes HYDRO doivent assurer l'écoulement. Il est important de noter que tous les 50 mètres semble une valeur correcte. Cette valeur s'explique en raison de la précision planimétrique et altimétrique des données LIDAR ET que les rivières françaises correspondent à des cours d’eau naturel dont la pente est inférieure à 1%. -/ ! \ Pour les cours d'eaux inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. La valeur du premier quartile sera calculée sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) et affecter pour ces entités hydrographiques (< 150m de long) : aplanissement. +* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. Les différents Z le long des squelettes HYDRO doivent assurer l'écoulement. Il est important de noter que tous les 50 mètres semble une valeur correcte. Cette valeur s'explique en raison de la précision planimétrique et altimétrique des données LIDAR ET que les rivières françaises correspondent à des cours d’eau naturels dont la pente est inférieure à 1%. +/ ! \ Pour les cours d'eau inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. La valeur du premier quartile sera calculée sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) et affectée pour ces entités hydrographiques (< 150m de long) : aplanissement. * 5- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : - * Création des points virtuels 2D espacés selon une grille régulière espacés tous les N mètres (paramètrables) à l'intérieur du masque hydrographique "écoulement" + * Création des points virtuels 2D espacés selon une grille régulière tous les N mètres (paramétrable) à l'intérieur du masque hydrographique "écoulement" * Affecter une valeur d'altitude à ces points virtuels en fonction des "Z" calculés à l'étape précédente (interpolation linéaire ou aplanissement) ### Traitement des surfaces planes (mer, lac, étang, etc.) Pour rappel, l'eau est considérée comme horizontale sur ce type de surface. / ! \ EN ATTENTE / ! \ Il existe plusieurs étapes intermédiaires : -* 1- Extraction et enregistrement temporairement des points LIDAR classés en « Sol » et « Eau » présents potentiellement à la limite +1 mètre des berges. Pour cela, on s'appuie sur 'emprise du masque hydrographique "surface plane" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle). a noter que pur le secteur maritime (Mer), il faut exclure la classe « 9 » (eau) afin d’éviter de mesurer les vagues. +* 1- Extraction et enregistrement temporairement des points LIDAR classés en « Sol » et « Eau » présents potentiellement à la limite + 1 mètre des berges. Pour cela, on s'appuie sur l'emprise du masque hydrographique "surface plane" apparaier, contrôler et corriger par la "production" (SV3D) en amont (étape manuelle). a noter que pur le secteur maritime (Mer), il faut exclure la classe « 9 » (eau) afin d’éviter de mesurer les vagues. * 2- Analyse statistique de l'ensemble des points LIDAR "Sol / Eau" le long des côtes/berges afin d'obtenir une surface plane. L’objectif est de créer des points virtuels spécifiques avec une information d'altitude (m) tous les 0.5 m sur les bords des surfaces maritimes et des plans d’eau à partir du masque hydrographique "surface plane". Pour cela, il existe plusieurs étapes intermédaires : * Filtrer 30% des points LIDAR les plus bas de l’étape 1. afin de supprimer les altitudes trop élevées @@ -137,6 +137,7 @@ example_create_skeleton_lines.sh ``` * 4- Création des points virtuels + A. Tester sur un dossier contenant plusieurs dalles LIDAR pour créer des points tous les N mètres le long du squelette hydrographique, et réccupérer les N plus proches voisins points LIDAR "SOL" ``` example_extract_points_around_skeleton_default.sh From 3d96fa11d68cb55657eee6b781994292a0df1737 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 27 Aug 2024 17:05:22 +0200 Subject: [PATCH 55/85] add parameters (point_virtula_classes) for script convert_geodataframe_to_las.py --- configs/configs_lidro.yaml | 9 ++++++--- .../pointcloud/convert_geodataframe_to_las.py | 7 ++++--- lidro/main_create_virtual_point.py | 3 ++- test/pointcloud/test_convert_geodataframe_to_las.py | 2 +- test/test_main_create_virtual_point.py | 5 +++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index f282a9da..6a54d1e0 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -28,7 +28,7 @@ io: mask_generation: raster: - # size for dilatation + # Size for dilatation dilation_size: 3 filter: # Classes to be considered as "non-water" @@ -47,14 +47,17 @@ virtual_point: # Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer keep_neighbors_classes: [2, 9] vector: - # distance in meters between 2 consecutive points from Skeleton Hydro + # Distance in meters between 2 consecutive points from Skeleton Hydro distance_meters: 5 - # buffer for searching the points classification (default. "3") of the input LAS/LAZ file + # Buffer for searching the points classification (default. "3") of the input LAS/LAZ file buffer: 3 # The number of nearest neighbors to find with KNeighbors k: 3 # Spacing between the grid points in meters by default "0.5" s: 1 + pointcloud: + # The number of the classe assign those virtual points + virtual_points_classes : 68 skeleton: max_gap_width: 200 # distance max in meter of any gap between 2 branches we will try to close with a line diff --git a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py index 62adeed5..c01d51d0 100644 --- a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py +++ b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py @@ -10,13 +10,14 @@ import pandas as pd -def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: str): - """This function convert virtual points (GeoDataframe) to LIDAR points with classification (66) for virtual points +def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: str, classes: int): + """This function convert virtual points (GeoDataframe) to LIDAR points with classification for virtual points Args: virtual_points (gpd.GeoDataFrame): A GeoDataFrame containing virtuals points from Mask Hydro output_dir (str): folder to output LAS crs (str): a pyproj CRS object used to create the output GeoJSON file + classes (int): The number of the classe assign those virtual points """ # Combine all virtual points into a single GeoDataFrame grid_with_z = gpd.GeoDataFrame(pd.concat(virtual_points, ignore_index=True)) @@ -32,7 +33,7 @@ def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: "x": grid_with_z["x"], "y": grid_with_z["y"], "z": grid_with_z["z"], - "classification": np.full(len(grid_with_z), 68, dtype=np.uint8), # Add classification of 68 + "classification": np.full(len(grid_with_z), classes, dtype=np.uint8), # Add classification of 68 } ) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 740864ca..e4577de6 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -57,6 +57,7 @@ def main(config: DictConfig): input_dir_point_skeleton = config.io.input_dir_point_skeleton crs = CRS.from_user_input(config.io.srid) s = config.virtual_point.vector.s + classes = config.virtual_point.pointcloud.virtual_points_classes # Step 1 : Merged all "points_aron_skeleton" by lidar tile def process_points_knn(points_knn): @@ -96,7 +97,7 @@ def process_points_knn(points_knn): ] logging.info("Calculate virtuals points by mask hydro and skeleton") # Save the virtual points (.LAS) - geodataframe_to_las(gdf_virtual_points, output_dir, crs) + geodataframe_to_las(gdf_virtual_points, output_dir, crs, classes) else: logging.error("No valid data found in points_clip for processing.") diff --git a/test/pointcloud/test_convert_geodataframe_to_las.py b/test/pointcloud/test_convert_geodataframe_to_las.py index 5d053f12..0b0ba419 100644 --- a/test/pointcloud/test_convert_geodataframe_to_las.py +++ b/test/pointcloud/test_convert_geodataframe_to_las.py @@ -40,7 +40,7 @@ def test_geodataframe_to_las_default(): crs = pyproj.CRS("EPSG:2154") # Call the function to test - geodataframe_to_las([points], TMP_PATH, crs) + geodataframe_to_las([points], TMP_PATH, crs, 68) # Verify the output LAS file output_las = os.path.join(TMP_PATH, "virtual_points.las") diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 8bf492e7..b0a90ee5 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -21,7 +21,7 @@ def test_main_run_okay(): io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ - io.input_dir_point_skeleton="{repo_dir}/lidro/data/virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson"\ + io.input_dir_point_skeleton="{repo_dir}/lidro/data/point_virtual/"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) @@ -33,9 +33,10 @@ def test_main_lidro_input_file(): input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = INPUT_DIR / "virtual_point/Semis_2021_0830_6291_LA93_IGN69_points_skeleton.geojson" + input_dir_point_skeleton = INPUT_DIR / "point_virtual/" srid = 2154 s = 1 + with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module cfg = compose( From d9a31b99501f6f88f0965a24f6d6dc5c9f48b284 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 27 Aug 2024 17:19:45 +0200 Subject: [PATCH 56/85] save the virtual points to LAZ --- .../pointcloud/convert_geodataframe_to_las.py | 13 ++++++++----- test/pointcloud/test_convert_geodataframe_to_las.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py index c01d51d0..c5f43b77 100644 --- a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py +++ b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py @@ -3,6 +3,7 @@ """ import logging import os +from typing import List import geopandas as gpd import laspy @@ -10,11 +11,11 @@ import pandas as pd -def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: str, classes: int): +def geodataframe_to_las(virtual_points: List, output_dir: str, crs: str, classes: int): """This function convert virtual points (GeoDataframe) to LIDAR points with classification for virtual points Args: - virtual_points (gpd.GeoDataFrame): A GeoDataFrame containing virtuals points from Mask Hydro + virtual_points (List): A list containing virtuals points by hydrological entity output_dir (str): folder to output LAS crs (str): a pyproj CRS object used to create the output GeoJSON file classes (int): The number of the classe assign those virtual points @@ -52,6 +53,8 @@ def geodataframe_to_las(virtual_points: gpd.GeoDataFrame, output_dir: str, crs: las.vlrs.append(vlr) # Save the LAS file - output_las = os.path.join(output_dir, "virtual_points.las") - las.write(output_las) - logging.info(f"Virtual points LAS file saved to {output_las}") + output_laz = os.path.join(output_dir, "virtual_points.laz") + with laspy.open(output_laz, mode="w", header=las.header, do_compress=True) as writer: + writer.write_points(las.points) + + logging.info(f"Virtual points LAS file saved to {output_laz}") diff --git a/test/pointcloud/test_convert_geodataframe_to_las.py b/test/pointcloud/test_convert_geodataframe_to_las.py index 0b0ba419..fef2d377 100644 --- a/test/pointcloud/test_convert_geodataframe_to_las.py +++ b/test/pointcloud/test_convert_geodataframe_to_las.py @@ -43,7 +43,7 @@ def test_geodataframe_to_las_default(): geodataframe_to_las([points], TMP_PATH, crs, 68) # Verify the output LAS file - output_las = os.path.join(TMP_PATH, "virtual_points.las") + output_las = os.path.join(TMP_PATH, "virtual_points.laz") assert os.path.exists(output_las), "The output LAS file should exist" # Read the LAS file From fb9deb1034e9a7dbe5097c61151a132c42edd6ad Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 28 Aug 2024 10:39:51 +0200 Subject: [PATCH 57/85] modify defintion's function :calculate_grid_z_with_model --- lidro/create_virtual_point/vectors/apply_Z_from_grid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/apply_Z_from_grid.py b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py index 2b0dee3d..823fa6ea 100644 --- a/lidro/create_virtual_point/vectors/apply_Z_from_grid.py +++ b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py @@ -6,7 +6,8 @@ def calculate_grid_z_with_model(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, model) -> gpd.GeoDataFrame: - """Calculate Z with regression model + """Calculate Z with regression model. + If points are not on the line, these points will be projected on this line Args: points (gpd.GeoDataFrame): A GeoDataFrame containing the grid points From 306ca37150910691bc685dd1f1dfd6ed4bf41231 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 28 Aug 2024 10:49:13 +0200 Subject: [PATCH 58/85] modify spacing for generating grid points: name, default parameters, tests --- configs/configs_lidro.yaml | 2 +- .../vectors/create_grid_2D_inside_maskhydro.py | 4 ++-- lidro/main_create_virtual_point.py | 4 ++-- test/test_main_create_virtual_point.py | 4 ++-- test/vectors/test_create_grid_2D_inside_maskhydro.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 6a54d1e0..e7b37335 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -54,7 +54,7 @@ virtual_point: # The number of nearest neighbors to find with KNeighbors k: 3 # Spacing between the grid points in meters by default "0.5" - s: 1 + space_grid_points: 0.5 pointcloud: # The number of the classe assign those virtual points virtual_points_classes : 68 diff --git a/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py index 1dfb9f0f..a0df8a45 100644 --- a/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py +++ b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py @@ -6,14 +6,14 @@ import shapely.vectorized -def generate_grid_from_geojson(mask_hydro: gpd.GeoDataFrame, spacing: float, margin=0): +def generate_grid_from_geojson(mask_hydro: gpd.GeoDataFrame, spacing=0.5, margin=0): """ Generates a regular 2D grid of evenly spaced points within a polygon defined in a GeoJSON file. Args: mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing each mask hydro from Hydro's Skeleton - spacing (float): Spacing between the grid points in meters. The default value is 1 meter. + spacing (float, optional): Spacing between the grid points in meters. The default value is 0.5 meter. margin (int, optional): Margin around mask for grid creation. The default value is 0 meter. Returns: diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index e4577de6..22532d27 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -56,7 +56,7 @@ def main(config: DictConfig): input_skeleton = config.io.input_skeleton input_dir_point_skeleton = config.io.input_dir_point_skeleton crs = CRS.from_user_input(config.io.srid) - s = config.virtual_point.vector.s + space_grid_points = config.virtual_point.vector.space_grid_points classes = config.virtual_point.pointcloud.virtual_points_classes # Step 1 : Merged all "points_aron_skeleton" by lidar tile @@ -90,7 +90,7 @@ def process_points_knn(points_knn): gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), crs, - s, + space_grid_points, output_dir, ) for idx, row in gdf_merged.iterrows() diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index b0a90ee5..396b4bce 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -35,7 +35,7 @@ def test_main_lidro_input_file(): input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" input_dir_point_skeleton = INPUT_DIR / "point_virtual/" srid = 2154 - s = 1 + space_grid_points = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module @@ -49,7 +49,7 @@ def test_main_lidro_input_file(): f"io.input_skeleton={input_skeleton}", f"io.input_dir_point_skeleton={input_dir_point_skeleton}", f"io.srid={srid}", - f"virtual_point.vector.s={s}", + f"virtual_point.vector.space_grid_points={space_grid_points}", ], ) main(cfg) diff --git a/test/vectors/test_create_grid_2D_inside_maskhydro.py b/test/vectors/test_create_grid_2D_inside_maskhydro.py index 6cc00297..bdec13c2 100644 --- a/test/vectors/test_create_grid_2D_inside_maskhydro.py +++ b/test/vectors/test_create_grid_2D_inside_maskhydro.py @@ -13,7 +13,7 @@ def test_generate_grid_from_geojson(): # Define the spacing and margin spacing = 1.0 # 1 meter spacing - margin = 0 + margin = 0 # à marging # Call the function to test result = generate_grid_from_geojson(mask_hydro, spacing, margin) From 80f17283f13305780f734fde8458bb6f2d7330ad Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 28 Aug 2024 15:43:41 +0200 Subject: [PATCH 59/85] convert json points_knn: refacto --- .../vectors/extract_points_around_skeleton.py | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py index 1e4fe83b..cc603c39 100644 --- a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py +++ b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- """ Extract points around skeleton by tile """ +import json import logging import os from typing import List import geopandas as gpd -import numpy as np import pandas as pd from pdaltools.las_info import las_get_xy_bounds from shapely.geometry import Point @@ -62,17 +62,26 @@ def extract_points_around_skeleton_points_one_tile( # Step 2 : Extract a Z elevation value along the hydrographic skeleton logging.info(f"\nExtract a Z elevation value along the hydrographic skeleton for tile : {tilename}") points_Z = filter_las_around_point(points_skeleton_with_z_clip, points_clip, k) - df_points_z = pd.DataFrame(points_Z) # Convert Dataframe - if not df_points_z.empty and "points_knn" in df_points_z.columns and "geometry" in df_points_z.columns: - # Convert list "points_knn" to string without scientific notation - df_points_z["points_knn"] = df_points_z["points_knn"].apply( - lambda x: str([[np.round(coord, 3) for coord in point] for point in x]) - ) - df_points_z = df_points_z[["geometry", "points_knn"]] - # Convert the DataFrame to a GeoDataFrame - points_z_gdf = gpd.GeoDataFrame(df_points_z, geometry="geometry") - points_z_gdf.set_crs(crs, inplace=True) - # Save the result by LIDAR tile + + if isinstance(points_Z, list) and len(points_Z) > 0: + # Limit the precision of coordinates using numpy arrays or tuples + knn_points = [ + [[round(coord, 3) for coord in point] for point in item["points_knn"]] + for item in points_Z + if len(item["points_knn"]) > 0 and isinstance(item["geometry"], Point) + ] + # Create a DataFrame with lists or numpy arrays + df_points_z = pd.DataFrame({"geometry": [item["geometry"] for item in points_Z], "points_knn": knn_points}) + + # Encode the 'points_knn' lists as JSON + df_points_z["points_knn"] = df_points_z["points_knn"].apply(lambda x: json.dumps(x)) + + # Convert DataFrame to GeoDataFrame + gdf_points_z = gpd.GeoDataFrame(df_points_z, geometry="geometry") + gdf_points_z.set_crs(crs, inplace=True) + + # Save the GeoDataFrame to a GeoJSON file output_geojson_path = os.path.join(output_dir, "_points_skeleton".join([tilename, ".geojson"])) - points_z_gdf.to_file(output_geojson_path, driver="GeoJSON") + gdf_points_z.to_file(output_geojson_path, driver="GeoJSON") + logging.info(f"Result saved to {output_geojson_path}") From c3c56d19ba060d06304ac6a9e350968855c24112 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 28 Aug 2024 16:53:13 +0200 Subject: [PATCH 60/85] refacto function : add drop lines which contain one or more NaN values --- lidro/create_virtual_point/vectors/intersect_points_by_line.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lidro/create_virtual_point/vectors/intersect_points_by_line.py b/lidro/create_virtual_point/vectors/intersect_points_by_line.py index a78a375b..7457a0d6 100644 --- a/lidro/create_virtual_point/vectors/intersect_points_by_line.py +++ b/lidro/create_virtual_point/vectors/intersect_points_by_line.py @@ -40,5 +40,6 @@ def return_points_by_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame) -> g # Filter out rows where 'index_right' is NaN pts_intersect = pts_intersect.dropna(subset=["index_right"]) + pts_intersect = pts_intersect.dropna() # Drop lines which contain one or more NaN values return pts_intersect From afbaab3a32d98191d467975e20cc96bc1d9901c2 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 29 Aug 2024 09:48:46 +0200 Subject: [PATCH 61/85] refacto flatten_river.py because modify intersect_points_by_line.py --- .../vectors/flatten_river.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lidro/create_virtual_point/vectors/flatten_river.py b/lidro/create_virtual_point/vectors/flatten_river.py index 1b346b3c..1792e399 100644 --- a/lidro/create_virtual_point/vectors/flatten_river.py +++ b/lidro/create_virtual_point/vectors/flatten_river.py @@ -24,13 +24,12 @@ def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame): """ # Inputs gdf_points = return_points_by_line(points, line) - # Extract points_knn and drop NaNs - points_knn_values = gdf_points["points_knn"].dropna().values + points_knn_values = gdf_points["points_knn"].values # Check if points_knn_values is empty if points_knn_values.size == 0: - logging.warning("For little river : no valid points found, returning default Z quartile as 0") + logging.warning("For little river: no neighbor found to calculate Z quartile: set Z to 0") z_first_quartile = 0 return z_first_quartile @@ -44,8 +43,13 @@ def flatten_little_river(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame): unique_points_knn = np.unique(all_points_knn, axis=0) - # Calculate the 1st quartile of all points - first_quartile = np.percentile(unique_points_knn, 25, axis=0) - z_first_quartile = first_quartile[-1] + # Check if unique_points_knn is empty before calculating the first quartile + if unique_points_knn.size == 0: + logging.warning("No unique points found after filtering. Setting Z to 0.") + z_first_quartile = 0 + else: + # Calculate the 1st quartile of all points + first_quartile = np.percentile(unique_points_knn, 25, axis=0) + z_first_quartile = first_quartile[-1] return z_first_quartile From b259088c77bdcbde11bd51ee50f7aae524d17869 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 29 Aug 2024 14:33:27 +0200 Subject: [PATCH 62/85] modify commentary function : returns --- lidro/create_virtual_point/vectors/las_around_point.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/las_around_point.py b/lidro/create_virtual_point/vectors/las_around_point.py index 5b31901a..13cbd9e8 100644 --- a/lidro/create_virtual_point/vectors/las_around_point.py +++ b/lidro/create_virtual_point/vectors/las_around_point.py @@ -18,7 +18,8 @@ def filter_las_around_point(points_skeleton: List, points_clip: np.array, k: int k (int): The number of nearest neighbors to find Returns: - List : Result {'geometry': Point 2D on skeleton, 'points_knn': List of LIDAR points from} + List : Result {'geometry': Point 2D on skeleton, + 'points_knn': List of N "ground" points closest to 2D points of the hydro skeleton} """ # Finds the K nearest neighbors of a given point from a list of 3D points points_knn_list = [ From 70eeb659a4c2176a4ed70068b3cc16398a4130e1 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 29 Aug 2024 14:36:40 +0200 Subject: [PATCH 63/85] remove commented code --- lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py b/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py index 6f627c04..e0530059 100644 --- a/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py +++ b/lidro/create_virtual_point/vectors/mask_hydro_with_buffer.py @@ -22,8 +22,5 @@ def import_mask_hydro_with_buffer(file_mask: str, buffer: float, crs: str | int) # Apply negative's buffer + difference from Mask Hydro # Return a polygon representing the limit of the bank with a buffer of N meters gdf_buffer = gdf.difference(gdf.buffer(-abs(buffer), cap_style=CAP_STYLE.square)) - # gdf_buffer = gdf.buffer(0.1, cap_style=CAP_STYLE.square).difference( - # gdf.buffer(-abs(buffer), cap_style=CAP_STYLE.square) - # ) return gdf_buffer From 41ef54e484e517dbbc0f1690f9552feea011941a Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 29 Aug 2024 16:19:01 +0200 Subject: [PATCH 64/85] refacto 2 functions :main create virtual point and run create virtual point --- configs/configs_lidro.yaml | 6 +++-- .../vectors/intersect_points_by_line.py | 24 ++++--------------- .../vectors/run_create_virtual_points.py | 24 +++++++++++-------- lidro/main_create_virtual_point.py | 24 ++++++++++--------- lidro/main_extract_points_from_skeleton.py | 4 ++-- scripts/example_create_mask_default.sh | 5 ++-- .../example_create_virtual_point_by_tile.sh | 2 +- .../example_create_virtual_point_default.sh | 4 ++-- ..._extract_points_around_skeleton_default.sh | 8 +++---- scripts/example_merge_mask_default.sh | 8 +++---- test/test_main_create_virtual_point.py | 10 ++++---- 11 files changed, 55 insertions(+), 64 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index e7b37335..b0f23659 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -12,7 +12,7 @@ io: input_skeleton: null input_dir: null output_dir: null - input_dir_point_skeleton: null + input_dir_points_skeleton: null srid: 2154 extension: .tif raster_driver: GTiff @@ -53,8 +53,10 @@ virtual_point: buffer: 3 # The number of nearest neighbors to find with KNeighbors k: 3 + # The minimum length of a river to use the linear regression model + river_length: 150 # Spacing between the grid points in meters by default "0.5" - space_grid_points: 0.5 + points_grid_spacing: 0.5 pointcloud: # The number of the classe assign those virtual points virtual_points_classes : 68 diff --git a/lidro/create_virtual_point/vectors/intersect_points_by_line.py b/lidro/create_virtual_point/vectors/intersect_points_by_line.py index 7457a0d6..b5fe9419 100644 --- a/lidro/create_virtual_point/vectors/intersect_points_by_line.py +++ b/lidro/create_virtual_point/vectors/intersect_points_by_line.py @@ -2,27 +2,11 @@ """ Identifies all points that intersect each line """ import geopandas as gpd -from shapely.geometry import CAP_STYLE, Polygon - - -def apply_buffers_to_geometry(line: gpd.GeoDataFrame, buffer: float) -> Polygon: - """Buffer geometry - Objective: apply buffer from hydro's section - - Args: - line (gpd.GeoDataFrame): geopandas dataframe with input geometry - buffer (float): buffer to apply to the input geometry - - Returns: - Polygon: Hydro' section with buffer - """ - # Buffer - geom = line.buffer(buffer, cap_style=CAP_STYLE.square) - return geom +from shapely.geometry import CAP_STYLE def return_points_by_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame) -> gpd.GeoDataFrame: - """This function identifies all points that intersect each hydro's section + """This function identifies all points that intersect each Skeleton by hydro's section Args: points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton @@ -31,8 +15,8 @@ def return_points_by_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame) -> g Returns: gpd.GeoDataFrame: A GeoDataframe containing only points that intersect each hydro's section """ - # Apply buffer (2 meters by default) from Mask Hydro - line_buffer = apply_buffers_to_geometry(line, 0.1) + # Apply buffer (0.1 meters) from Mask Hydro + line_buffer = line.buffer(0.1, cap_style=CAP_STYLE.square) gdf_line_buffer = gpd.GeoDataFrame(geometry=line_buffer) # Perform spatial join to find intersections diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index e30c353c..02768221 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -21,12 +21,13 @@ ) -def lauch_virtual_points_by_section( +def launch_virtual_points_by_section( points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, mask_hydro: gpd.GeoDataFrame, crs: str, spacing: float, + length: int, output_dir: str, ) -> gpd.GeoDataFrame: """This function generates a regular grid of 3D points spaced every N meters inside each hydro entity @@ -36,14 +37,17 @@ def lauch_virtual_points_by_section( points (gpd.GeoDataFrame): A GeoDataFrame containing points along Hydro's Skeleton line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton mask_hydro (gpd.GeoDataFrame): A GeoDataFrame containing each mask hydro from Hydro's Skeleton - crs (str): a pyproj CRS object used to create the output GeoJSON file - spacing (float, optional): Spacing between the grid points in meters. The default value is 1 meter - output_dir (str): folder to output Mask Hydro without virtual points + crs (str): A pyproj CRS object used to create the output GeoJSON file + spacing (float, optional): Spacing between the grid points in meters. + The default value is 0.5 meter + length (int, optional): Minimum length of a river to use the linear regression model. + The default value is 150 meters. + output_dir (str): Folder to output Mask Hydro without virtual points Returns: gpd.GeoDataFrame: All virtual points by Mask Hydro """ - # Check if points GeoDataFrame is empty or + # Check if the points DataFrame is empty and all the values in the "points_knn" column are null if points.empty or points["points_knn"].isnull().all(): logging.warning("The points GeoDataFrame is empty. Saving the skeleton and mask hydro to GeoJSON.") masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) @@ -60,8 +64,8 @@ def lauch_virtual_points_by_section( # Calculate the length of the river river_length = float(line.length.iloc[0]) - # Step 2 : Model linear regression for river's lenght > 150 m - if river_length > 150: + # Step 2 : Model linear regression for river's length > 150 m + if river_length > length: model, r2 = calculate_linear_regression_line(points, line, crs) if model == np.poly1d([0, 0]) and r2 == 0.0: masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) @@ -74,13 +78,13 @@ def lauch_virtual_points_by_section( output_dir, "mask_hydro_no_virtual_points_with_regression.geojson" ) logging.warning( - f"Save masks_without_points because of linear regression is impossible: {output_mask_hydro_error}" + f"Save masks_without_points because linear regression is impossible: {output_mask_hydro_error}" ) masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") # Apply linear regression model at the rivers gdf_grid_with_z = calculate_grid_z_with_model(gdf_grid, line, model) - # Step 2 bis: Model flattening for river's lenght < 150 m - if river_length < 150: + # Step 2 bis: Model flattening for river's length < 150 m or river's length == 150 m + else: predicted_z = flatten_little_river(points, line) if predicted_z == 0: masks_without_points = gpd.GeoDataFrame(columns=mask_hydro.columns, crs=mask_hydro.crs) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 22532d27..d6d9a493 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -1,4 +1,4 @@ -""" Main script for calculate Mask HYDRO 1 +""" Main script for create virtual point (1 output) """ import ast @@ -21,7 +21,7 @@ merge_skeleton_by_mask, ) from lidro.create_virtual_point.vectors.run_create_virtual_points import ( - lauch_virtual_points_by_section, + launch_virtual_points_by_section, ) @@ -54,25 +54,26 @@ def main(config: DictConfig): # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton - input_dir_point_skeleton = config.io.input_dir_point_skeleton + input_dir_points_skeleton = config.io.input_dir_points_skeleton crs = CRS.from_user_input(config.io.srid) - space_grid_points = config.virtual_point.vector.space_grid_points + river_length = config.virtual_point.vector.river_length + points_grid_spacing = config.virtual_point.vector.points_grid_spacing classes = config.virtual_point.pointcloud.virtual_points_classes - # Step 1 : Merged all "points_aron_skeleton" by lidar tile + # Step 1 : Merged all "points around skeleton" by lidar tile def process_points_knn(points_knn): # Check if points_knn is a string and convert it to a list if necessary if isinstance(points_knn, str): points_knn = ast.literal_eval(points_knn) # Convert the string to a list of lists # Round each coordinate to 8 decimal places - return [[round(coord, 8) for coord in point] for point in points_knn] + return [[round(coord, 3) for coord in point] for point in points_knn] points_clip_list = [ {"geometry": row["geometry"], "points_knn": process_points_knn(row["points_knn"])} - for filename in os.listdir(input_dir_point_skeleton) + for filename in os.listdir(input_dir_points_skeleton) if filename.endswith(".geojson") - for _, row in gpd.read_file(os.path.join(input_dir_point_skeleton, filename)).iterrows() + for _, row in gpd.read_file(os.path.join(input_dir_points_skeleton, filename)).iterrows() ] # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip_list) @@ -85,12 +86,13 @@ def process_points_knn(points_knn): gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) gdf_virtual_points = [ - lauch_virtual_points_by_section( + launch_virtual_points_by_section( points_gdf, gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), gpd.GeoDataFrame([{"geometry": row["geometry_mask"]}], crs=crs), crs, - space_grid_points, + points_grid_spacing, + river_length, output_dir, ) for idx, row in gdf_merged.iterrows() @@ -99,7 +101,7 @@ def process_points_knn(points_knn): # Save the virtual points (.LAS) geodataframe_to_las(gdf_virtual_points, output_dir, crs, classes) else: - logging.error("No valid data found in points_clip for processing.") + logging.error("Error when merged all points around skeleton by lidar tile") if __name__ == "__main__": diff --git a/lidro/main_extract_points_from_skeleton.py b/lidro/main_extract_points_from_skeleton.py index a02a7854..07df6f63 100644 --- a/lidro/main_extract_points_from_skeleton.py +++ b/lidro/main_extract_points_from_skeleton.py @@ -1,4 +1,4 @@ -""" Main script for calculate Mask HYDRO 1 +""" Main script for creating N points along Skeleton """ import logging @@ -22,7 +22,7 @@ @hydra.main(config_path="../configs/", config_name="configs_lidro.yaml", version_base="1.2") def main(config: DictConfig): """Create N points along Skeleton from the points classification of - the input LAS/LAZ file and the Hyro Skeleton (GeoJSON). Save a result by LIDAR tiles. + the input LAS/LAZ file and the Hydro Skeleton (GeoJSON). Save a result by LIDAR tiles. It can run either on a single file, or on each file of a folder diff --git a/scripts/example_create_mask_default.sh b/scripts/example_create_mask_default.sh index bb94eaba..1287c98c 100644 --- a/scripts/example_create_mask_default.sh +++ b/scripts/example_create_mask_default.sh @@ -2,8 +2,7 @@ python -m lidro.main_create_mask \ io.input_dir=./data/pointcloud/ \ io.output_dir=./tmp/ \ -#io.mask_generation.raster.dilatation_size=3 \ #size for dilatation -#io.mask_generation.filter.keep_classes=[0, 1, 2, 3, 4, 5, 6, 17, 64, 65, 66, 67] \ #Classes to be considered as "non-water" - +io.mask_generation.raster.dilatation_size=3 \ +io.mask_generation.filter.keep_classes="[0, 1, 2, 3, 4, 5, 6, 17, 64, 65, 66, 67]" \ diff --git a/scripts/example_create_virtual_point_by_tile.sh b/scripts/example_create_virtual_point_by_tile.sh index 5ea6bac3..ec80032c 100644 --- a/scripts/example_create_virtual_point_by_tile.sh +++ b/scripts/example_create_virtual_point_by_tile.sh @@ -5,6 +5,6 @@ io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/ \ -#io.virtual_point.vector.s=0.5 \ #Spacing between the grid points in meters +io.virtual_point.vector.s=0.5 \ diff --git a/scripts/example_create_virtual_point_default.sh b/scripts/example_create_virtual_point_default.sh index 4708b831..8473373b 100644 --- a/scripts/example_create_virtual_point_default.sh +++ b/scripts/example_create_virtual_point_default.sh @@ -3,9 +3,9 @@ python -m lidro.main_create_virtual_point \ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ -io.input_dir_point_skeleton=./tmp/point_skeleton/ \ +io.input_dir_points_skeleton=./tmp/point_skeleton/ \ io.output_dir=./tmp/ \ -#io.virtual_point.vector.s=0.5 \ #Spacing between the grid points in meters +io.virtual_point.vector.points_grid_spacing=0.5 \ diff --git a/scripts/example_extract_points_around_skeleton_default.sh b/scripts/example_extract_points_around_skeleton_default.sh index 4dee2506..11913cd8 100644 --- a/scripts/example_extract_points_around_skeleton_default.sh +++ b/scripts/example_extract_points_around_skeleton_default.sh @@ -4,10 +4,10 @@ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geosjon \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/points_skeleton/ \ -#io.virtual_point.filter.keep_neighbors_classes=[2, 9] \ #Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer -#io.virtual_point.vector.distance_meters=5 \ # distance in meters between 2 consecutive points from Skeleton Hydro -#io.virtual_point.vector.buffer=3 \ #buffer for searching the points classification (default. "3") of the input LAS/LAZ file -#io.virtual_point.vector.k=3 \ #the number of nearest neighbors to find with KNeighbors +io.virtual_point.vector.distance_meters=5 \ +io.virtual_point.vector.buffer=3 \ +io.virtual_point.vector.k=3 \ +io.virtual_point.filter.keep_neighbors_classes="[2, 9]" \ diff --git a/scripts/example_merge_mask_default.sh b/scripts/example_merge_mask_default.sh index 9aecc532..0d0d1165 100644 --- a/scripts/example_merge_mask_default.sh +++ b/scripts/example_merge_mask_default.sh @@ -2,10 +2,10 @@ python -m lidro.main_merge_mask \ io.input_dir=./data/mask_hydro/ \ io.output_dir=./tmp/merge_mask_hydro/ \ -#io.mask_generation.vector.min_water_area=150 \ #Filter water's area (m²) -#io.mask_generation.vector.buffer_positive=1 \ #Parameters for buffer -#io.mask_generation.vector.buffer_negative=-1.5 \ #Parameters for buffer -#io.mask_generation.vector.tolerance=1 \ # Tolerance from Douglas-Peucker : simplify mask hydro +io.mask_generation.vector.min_water_area=150 \ +io.mask_generation.vector.buffer_positive=1 \ +io.mask_generation.vector.buffer_negative=-1.5 \ +io.mask_generation.vector.tolerance=1 \ diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 396b4bce..4ca88f5e 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -21,7 +21,7 @@ def test_main_run_okay(): io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ - io.input_dir_point_skeleton="{repo_dir}/lidro/data/point_virtual/"\ + io.input_dir_points_skeleton="{repo_dir}/lidro/data/point_virtual/"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) @@ -33,9 +33,9 @@ def test_main_lidro_input_file(): input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_point_skeleton = INPUT_DIR / "point_virtual/" + input_dir_points_skeleton = INPUT_DIR / "point_virtual/" srid = 2154 - space_grid_points = 1 + points_grid_spacing = 1 with initialize(version_base="1.2", config_path="../configs"): # config is relative to a module @@ -47,9 +47,9 @@ def test_main_lidro_input_file(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"io.input_dir_point_skeleton={input_dir_point_skeleton}", + f"io.input_dir_points_skeleton={input_dir_points_skeleton}", f"io.srid={srid}", - f"virtual_point.vector.space_grid_points={space_grid_points}", + f"virtual_point.vector.points_grid_spacing={points_grid_spacing}", ], ) main(cfg) From dd54107c94bf09bb57daec040519455cb0bdd70c Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 29 Aug 2024 16:44:44 +0200 Subject: [PATCH 65/85] udpate test --- .../vectors/test_run_create_virtual_points.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/vectors/test_run_create_virtual_points.py b/test/vectors/test_run_create_virtual_points.py index 9f075704..e440ddaf 100644 --- a/test/vectors/test_run_create_virtual_points.py +++ b/test/vectors/test_run_create_virtual_points.py @@ -8,7 +8,7 @@ from shapely.geometry import LineString, Point, Polygon from lidro.create_virtual_point.vectors.run_create_virtual_points import ( - lauch_virtual_points_by_section, + launch_virtual_points_by_section, ) TMP_PATH = Path("./tmp/create_virtual_point/vectors/run_create_virtual_points/") @@ -216,63 +216,63 @@ def create_test_data_with_flattening_failure(): return points, lines, mask_hydro -def test_lauch_virtual_points_by_section_with_geometry_only(): +def test_launch_virtual_points_by_section_with_geometry_only(): points, lines, mask_hydro = create_test_data_with_geometry_only() crs = CRS.from_epsg(2154) spacing = 1.0 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") - lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() masks_without_points = gpd.read_file(output_filename) assert len(masks_without_points) == len(mask_hydro) -def test_lauch_virtual_points_by_section_no_points(): +def test_launch_virtual_points_by_section_no_points(): points, line, mask_hydro = create_test_data_no_points() crs = CRS.from_epsg(2154) spacing = 1.0 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") - lauch_virtual_points_by_section(points, line, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, line, mask_hydro, crs, spacing, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() masks_without_points = gpd.read_file(output_filename) assert len(masks_without_points) == len(mask_hydro) -def test_lauch_virtual_points_by_section_with_points(): +def test_launch_virtual_points_by_section_with_points(): points, lines, mask_hydro = create_test_data_with_points() crs = CRS.from_epsg(2154) spacing = 1.0 - grid_with_z = lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + grid_with_z = launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) assert all(isinstance(geom, Point) for geom in grid_with_z.geometry) assert all(geom.has_z for geom in grid_with_z.geometry) # Check that all points have a Z coordinate -def test_lauch_virtual_points_by_section_regression_failure(): +def test_launch_virtual_points_by_section_regression_failure(): points, lines, mask_hydro = create_test_data_with_regression_failure() crs = CRS.from_epsg(2154) spacing = 1.0 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_with_regression.geojson") - lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_with_regression.geojson").is_file() masks_without_points = gpd.read_file(output_filename) assert len(masks_without_points) == len(mask_hydro) -def test_lauch_virtual_points_by_section_flattening_failure(): +def test_launch_virtual_points_by_section_flattening_failure(): points, lines, mask_hydro = create_test_data_with_flattening_failure() crs = CRS.from_epsg(2154) spacing = 1.0 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_for_little_river.geojson") - lauch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_for_little_river.geojson").is_file() masks_without_points = gpd.read_file(output_filename) From f23b76629ca08f547fa125398ef045d1c4475b36 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 30 Aug 2024 10:08:47 +0200 Subject: [PATCH 66/85] add parameters river length --- test/vectors/test_run_create_virtual_points.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/vectors/test_run_create_virtual_points.py b/test/vectors/test_run_create_virtual_points.py index e440ddaf..0f9b61ee 100644 --- a/test/vectors/test_run_create_virtual_points.py +++ b/test/vectors/test_run_create_virtual_points.py @@ -220,9 +220,10 @@ def test_launch_virtual_points_by_section_with_geometry_only(): points, lines, mask_hydro = create_test_data_with_geometry_only() crs = CRS.from_epsg(2154) spacing = 1.0 + river_length = 150 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") - launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, river_length, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() masks_without_points = gpd.read_file(output_filename) @@ -232,10 +233,11 @@ def test_launch_virtual_points_by_section_with_geometry_only(): def test_launch_virtual_points_by_section_no_points(): points, line, mask_hydro = create_test_data_no_points() crs = CRS.from_epsg(2154) + river_length = 150 spacing = 1.0 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points.geojson") - launch_virtual_points_by_section(points, line, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, line, mask_hydro, crs, spacing, river_length, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points.geojson").is_file() masks_without_points = gpd.read_file(output_filename) @@ -246,8 +248,9 @@ def test_launch_virtual_points_by_section_with_points(): points, lines, mask_hydro = create_test_data_with_points() crs = CRS.from_epsg(2154) spacing = 1.0 + river_length = 150 - grid_with_z = launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + grid_with_z = launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, river_length, TMP_PATH) assert all(isinstance(geom, Point) for geom in grid_with_z.geometry) assert all(geom.has_z for geom in grid_with_z.geometry) # Check that all points have a Z coordinate @@ -257,9 +260,10 @@ def test_launch_virtual_points_by_section_regression_failure(): points, lines, mask_hydro = create_test_data_with_regression_failure() crs = CRS.from_epsg(2154) spacing = 1.0 + river_length = 150 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_with_regression.geojson") - launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, river_length, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_with_regression.geojson").is_file() masks_without_points = gpd.read_file(output_filename) @@ -270,9 +274,10 @@ def test_launch_virtual_points_by_section_flattening_failure(): points, lines, mask_hydro = create_test_data_with_flattening_failure() crs = CRS.from_epsg(2154) spacing = 1.0 + river_length = 150 output_filename = os.path.join(TMP_PATH, "mask_hydro_no_virtual_points_for_little_river.geojson") - launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, TMP_PATH) + launch_virtual_points_by_section(points, lines, mask_hydro, crs, spacing, river_length, TMP_PATH) assert (Path(TMP_PATH) / "mask_hydro_no_virtual_points_for_little_river.geojson").is_file() masks_without_points = gpd.read_file(output_filename) From b3f7cfd525e14e10e6b09c14217e842ee96f513b Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 30 Aug 2024 11:12:42 +0200 Subject: [PATCH 67/85] modify output for test_main_lidro_input_file --- test/test_main_create_virtual_point.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 4ca88f5e..b681859e 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -53,4 +53,4 @@ def test_main_lidro_input_file(): ], ) main(cfg) - assert (Path(output_dir) / "virtual_points.las").is_file() + assert (Path(output_dir) / "virtual_points.laz").is_file() From 68667972c6679ae3621c33518077417d5f262aeb Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 30 Aug 2024 16:13:59 +0200 Subject: [PATCH 68/85] refacto README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 86751c24..9b281420 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ A l'échelle de l'entité hydrographique : * 2- Réccupérer tous les points LIDAR considérés comme du "SOL" situés à la limite de berges (masque hydrographique) moins N mètres Pour les cours d'eau supérieurs à 150 m de long : * 3- Transformer les coordonnées de ces points (étape précédente) en abscisses curvilignes -* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. Les différents Z le long des squelettes HYDRO doivent assurer l'écoulement. Il est important de noter que tous les 50 mètres semble une valeur correcte. Cette valeur s'explique en raison de la précision planimétrique et altimétrique des données LIDAR ET que les rivières françaises correspondent à des cours d’eau naturels dont la pente est inférieure à 1%. +* 4- Générer un modèle de régression linéaire afin de générer tous les N mètres une valeur d'altitude le long du squelette de cette rivière. Les différents Z le long des squelettes HYDRO doivent assurer l'écoulement. Il est important de noter que tous les 50 mètres semble une valeur correcte pour appréhender la donnée. Cette valeur s'explique en raison de la précision altimétrique des données LIDAR (20 cm) ET que les rivières françaises correspondent à des cours d’eau naturels dont la pente est inférieure à 1%. / ! \ Pour les cours d'eau inférieurs à 150 m de long, le modèle de régression linéaire ne fonctionne pas. La valeur du premier quartile sera calculée sur l'ensemble des points d'altitudes du LIDAR "SOL" (étape 2) et affectée pour ces entités hydrographiques (< 150m de long) : aplanissement. * 5- Création de points virtuels nécéssitant plusieurs étapes intermédiaires : * Création des points virtuels 2D espacés selon une grille régulière tous les N mètres (paramétrable) à l'intérieur du masque hydrographique "écoulement" From 568542745c29f537b0c2b34d783174751e7300ad Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 4 Sep 2024 16:52:58 +0200 Subject: [PATCH 69/85] refacto configs and test associates --- configs/configs_lidro.yaml | 41 ++++++++++--------- lidro/main_create_virtual_point.py | 4 +- .../example_create_virtual_point_by_tile.sh | 2 +- .../example_create_virtual_point_default.sh | 2 +- test/test_main_create_virtual_point.py | 6 +-- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index b0f23659..1e5e777a 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -42,25 +42,6 @@ mask_generation: # Tolerance from Douglas-Peucker tolerance: 1 -virtual_point: - filter: - # Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer - keep_neighbors_classes: [2, 9] - vector: - # Distance in meters between 2 consecutive points from Skeleton Hydro - distance_meters: 5 - # Buffer for searching the points classification (default. "3") of the input LAS/LAZ file - buffer: 3 - # The number of nearest neighbors to find with KNeighbors - k: 3 - # The minimum length of a river to use the linear regression model - river_length: 150 - # Spacing between the grid points in meters by default "0.5" - points_grid_spacing: 0.5 - pointcloud: - # The number of the classe assign those virtual points - virtual_points_classes : 68 - skeleton: max_gap_width: 200 # distance max in meter of any gap between 2 branches we will try to close with a line max_bridges: 1 # number max of bridges that can be created between 2 branches @@ -82,6 +63,28 @@ skeleton: water_min_size: 500 # min size of a skeleton line to be sure not to be removed (should be at least more than half the max river width) max_gap_candidates: 3 # max number of candidates to close a gap between 2 branches +virtual_point: + filter: + # Keep ground and water pointclouds between Hydro Mask and Hydro Mask buffer + keep_neighbors_classes: [2, 9] + vector: + # Distance in meters between 2 consecutive points from Skeleton Hydro + distance_meters: 5 + # Buffer for searching the points classification (default. "3") of the input LAS/LAZ file + buffer: 3 + # The number of nearest neighbors to find with KNeighbors + k: 3 + # The minimum length of a river to use the linear regression model + river_length: 150 + pointcloud: + # Spacing between the grid points in meters by default "0.5" + points_grid_spacing: 0.5 + # The number of the classe assign those virtual points + virtual_points_classes : 68 + + + + hydra: output_subdir: null run: diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index d6d9a493..b63b2a97 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -1,4 +1,4 @@ -""" Main script for create virtual point (1 output) +""" Main script for create virtuals points """ import ast @@ -54,7 +54,7 @@ def main(config: DictConfig): # Parameters for creating virtual point input_mask_hydro = config.io.input_mask_hydro input_skeleton = config.io.input_skeleton - input_dir_points_skeleton = config.io.input_dir_points_skeleton + input_dir_points_skeleton = config.io.dir_points_skeleton crs = CRS.from_user_input(config.io.srid) river_length = config.virtual_point.vector.river_length points_grid_spacing = config.virtual_point.vector.points_grid_spacing diff --git a/scripts/example_create_virtual_point_by_tile.sh b/scripts/example_create_virtual_point_by_tile.sh index ec80032c..5f99b06c 100644 --- a/scripts/example_create_virtual_point_by_tile.sh +++ b/scripts/example_create_virtual_point_by_tile.sh @@ -5,6 +5,6 @@ io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/ \ -io.virtual_point.vector.s=0.5 \ +io.virtual_point.vector.points_grid_spacing=0.5 \ diff --git a/scripts/example_create_virtual_point_default.sh b/scripts/example_create_virtual_point_default.sh index 8473373b..9f3315f5 100644 --- a/scripts/example_create_virtual_point_default.sh +++ b/scripts/example_create_virtual_point_default.sh @@ -3,7 +3,7 @@ python -m lidro.main_create_virtual_point \ io.input_dir=./data/ \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ -io.input_dir_points_skeleton=./tmp/point_skeleton/ \ +io.dir_points_skeleton=./tmp/point_skeleton/ \ io.output_dir=./tmp/ \ io.virtual_point.vector.points_grid_spacing=0.5 \ diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index b681859e..18251271 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -21,7 +21,7 @@ def test_main_run_okay(): io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro="{repo_dir}/lidro/data/merge_mask_hydro/MaskHydro_merge.geojson"\ io.input_skeleton="{repo_dir}/lidro/data/skeleton_hydro/Skeleton_Hydro_small.geojson"\ - io.input_dir_points_skeleton="{repo_dir}/lidro/data/point_virtual/"\ + io.dir_points_skeleton="{repo_dir}/lidro/data/point_virtual/"\ io.output_dir="{repo_dir}/lidro/tmp/create_virtual_point/main/" """ sp.run(cmd, shell=True, check=True) @@ -33,7 +33,7 @@ def test_main_lidro_input_file(): input_filename = "Semis_2021_0830_6291_LA93_IGN69.laz" input_mask_hydro = INPUT_DIR / "merge_mask_hydro/MaskHydro_merge.geojson" input_skeleton = INPUT_DIR / "skeleton_hydro/Skeleton_Hydro_small.geojson" - input_dir_points_skeleton = INPUT_DIR / "point_virtual/" + dir_points_skeleton = INPUT_DIR / "point_virtual/" srid = 2154 points_grid_spacing = 1 @@ -47,7 +47,7 @@ def test_main_lidro_input_file(): f"io.output_dir={output_dir}", f"io.input_mask_hydro={input_mask_hydro}", f"io.input_skeleton={input_skeleton}", - f"io.input_dir_points_skeleton={input_dir_points_skeleton}", + f"io.dir_points_skeleton={dir_points_skeleton}", f"io.srid={srid}", f"virtual_point.vector.points_grid_spacing={points_grid_spacing}", ], From d44ceb520684107d7053543cc7ea35293577f818 Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 4 Sep 2024 16:58:42 +0200 Subject: [PATCH 70/85] refacto configs v2 --- configs/configs_lidro.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/configs_lidro.yaml b/configs/configs_lidro.yaml index 1e5e777a..22ecdc28 100644 --- a/configs/configs_lidro.yaml +++ b/configs/configs_lidro.yaml @@ -12,7 +12,7 @@ io: input_skeleton: null input_dir: null output_dir: null - input_dir_points_skeleton: null + dir_points_skeleton: null # folder to contains files (.GeoJSON) by LIDAR tiles : neighboring points for each point of the skeleton srid: 2154 extension: .tif raster_driver: GTiff From 9297179d8ba5c915285adccd5c356085a3a2df4d Mon Sep 17 00:00:00 2001 From: mdupays Date: Wed, 4 Sep 2024 17:03:41 +0200 Subject: [PATCH 71/85] modify parameters vector.points_grid_spacing to pointcloud.points_grid_spacing --- lidro/main_create_virtual_point.py | 3 +-- scripts/example_create_virtual_point_by_tile.sh | 2 +- test/test_main_create_virtual_point.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index b63b2a97..def776db 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -5,7 +5,6 @@ import logging import os import sys - import geopandas as gpd import hydra import pandas as pd @@ -57,7 +56,7 @@ def main(config: DictConfig): input_dir_points_skeleton = config.io.dir_points_skeleton crs = CRS.from_user_input(config.io.srid) river_length = config.virtual_point.vector.river_length - points_grid_spacing = config.virtual_point.vector.points_grid_spacing + points_grid_spacing = config.virtual_point.pointcloud.points_grid_spacing classes = config.virtual_point.pointcloud.virtual_points_classes # Step 1 : Merged all "points around skeleton" by lidar tile diff --git a/scripts/example_create_virtual_point_by_tile.sh b/scripts/example_create_virtual_point_by_tile.sh index 5f99b06c..4c48facd 100644 --- a/scripts/example_create_virtual_point_by_tile.sh +++ b/scripts/example_create_virtual_point_by_tile.sh @@ -5,6 +5,6 @@ io.input_filename=Semis_2021_0830_6291_LA93_IGN69.laz \ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.output_dir=./tmp/ \ -io.virtual_point.vector.points_grid_spacing=0.5 \ +io.virtual_point.pointcloud.points_grid_spacing=0.5 \ diff --git a/test/test_main_create_virtual_point.py b/test/test_main_create_virtual_point.py index 18251271..add4a2ce 100644 --- a/test/test_main_create_virtual_point.py +++ b/test/test_main_create_virtual_point.py @@ -49,7 +49,7 @@ def test_main_lidro_input_file(): f"io.input_skeleton={input_skeleton}", f"io.dir_points_skeleton={dir_points_skeleton}", f"io.srid={srid}", - f"virtual_point.vector.points_grid_spacing={points_grid_spacing}", + f"virtual_point.pointcloud.points_grid_spacing={points_grid_spacing}", ], ) main(cfg) From 238a4358a20772e30587d597ed11282645213dc1 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 5 Sep 2024 14:38:24 +0200 Subject: [PATCH 72/85] update geodatrame_to_las.py to list_points_to_las.py and refacto function --- .../pointcloud/convert_geodataframe_to_las.py | 60 ------------------- .../pointcloud/convert_list_points_to_las.py | 42 +++++++++++++ .../vectors/merge_skeleton_by_mask.py | 4 +- lidro/main_create_virtual_point.py | 6 +- .../example_create_virtual_point_default.sh | 2 +- 5 files changed, 48 insertions(+), 66 deletions(-) delete mode 100644 lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py create mode 100644 lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py diff --git a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py b/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py deleted file mode 100644 index c5f43b77..00000000 --- a/lidro/create_virtual_point/pointcloud/convert_geodataframe_to_las.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -""" this function convert GeoDataframe "virtual points" to LIDAR points -""" -import logging -import os -from typing import List - -import geopandas as gpd -import laspy -import numpy as np -import pandas as pd - - -def geodataframe_to_las(virtual_points: List, output_dir: str, crs: str, classes: int): - """This function convert virtual points (GeoDataframe) to LIDAR points with classification for virtual points - - Args: - virtual_points (List): A list containing virtuals points by hydrological entity - output_dir (str): folder to output LAS - crs (str): a pyproj CRS object used to create the output GeoJSON file - classes (int): The number of the classe assign those virtual points - """ - # Combine all virtual points into a single GeoDataFrame - grid_with_z = gpd.GeoDataFrame(pd.concat(virtual_points, ignore_index=True)) - - # Extract x, y, and z coordinates - grid_with_z["x"] = grid_with_z.geometry.x - grid_with_z["y"] = grid_with_z.geometry.y - grid_with_z["z"] = grid_with_z.geometry.z - - # Create a DataFrame with the necessary columns - las_data = pd.DataFrame( - { - "x": grid_with_z["x"], - "y": grid_with_z["y"], - "z": grid_with_z["z"], - "classification": np.full(len(grid_with_z), classes, dtype=np.uint8), # Add classification of 68 - } - ) - - # Create a LAS file with laspy - header = laspy.LasHeader(point_format=6, version="1.4") - - # Create a LAS file with laspy and add the VLR for CRS - las = laspy.LasData(header) - las.x = las_data["x"].values - las.y = las_data["y"].values - las.z = las_data["z"].values - las.classification = las_data["classification"].values - - # Add CRS information to the VLR - vlr = laspy.vlrs.known.WktCoordinateSystemVlr(crs.to_wkt()) - las.vlrs.append(vlr) - - # Save the LAS file - output_laz = os.path.join(output_dir, "virtual_points.laz") - with laspy.open(output_laz, mode="w", header=las.header, do_compress=True) as writer: - writer.write_points(las.points) - - logging.info(f"Virtual points LAS file saved to {output_laz}") diff --git a/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py b/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py new file mode 100644 index 00000000..f6f8e8d7 --- /dev/null +++ b/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" this function convert GeoDataframe "virtual points" to LIDAR points +""" +import logging +import os +from typing import List + +import laspy +import numpy as np +from shapely.geometry import Point + + +def list_points_to_las(virtual_points: List[Point], output_dir: str, crs: str, virtual_points_class: int): + """This function convert virtual points (List of virtuals points) to LIDAR points + with classification for virtual points + + Args: + virtual_points (List[Point]): A list containing virtuals points by hydrological entity + output_dir (str): folder to output LAS + crs (str): a pyproj CRS object used to create the output GeoJSON file + virtual_points_class (int): The classification value to assign to those virtual points + """ + # Create a LAS file with laspy + header = laspy.LasHeader(point_format=6, version="1.4") + + # Create a LAS file with laspy and add the VLR for CRS + las = laspy.LasData(header) + las.x = np.array([pt.x for pt in virtual_points]) + las.y = np.array([pt.y for pt in virtual_points]) + las.z = np.array([pt.y for pt in virtual_points]) + las.classification = virtual_points_class * np.ones([len(virtual_points)], dtype=np.uint8) + + # Add CRS information to the VLR + vlr = laspy.vlrs.known.WktCoordinateSystemVlr(crs.to_wkt()) + las.vlrs.append(vlr) + + # Save the LAS file + output_laz = os.path.join(output_dir, "virtual_points.laz") + with laspy.open(output_laz, mode="w", header=las.header, do_compress=True) as writer: + writer.write_points(las.points) + + logging.info(f"Virtual points LAS file saved to {output_laz}") diff --git a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py index bc48590b..f7d7921d 100644 --- a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py +++ b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py @@ -68,7 +68,7 @@ def merge_skeleton_by_mask( # # Perform a spatial join to find skeletons within each mask_hydro gdf_joined = gpd.sjoin(gdf_skeleton, gdf_mask_hydro, how="inner", predicate="intersects") # geometry intersections with funtion "overlay" - gdf_intersections = gpd.overlay(gdf_joined, gdf_mask_hydro, how="intersection") + gdf_intersections = gpd.overlay(gdf_joined, gdf_mask_hydro, how="intersection", keep_geom_type=True) # Combine skeleton lines into a single polyline for each hydro entity combined_skeletons = gdf_intersections.groupby("index_right")["geometry"].apply(combine_and_connect_lines) @@ -103,7 +103,7 @@ def merge_skeleton_by_mask( gdf_mask_hydro, left_on="index_mask", right_index=True, suffixes=("_skeleton", "_mask") ) # Keep only necessary columns - df_exclusion = df_exclusion[["geometry_skeleton", "geometry_mask"]] + df_exclusion = df_exclusion[["geometry_mask"]] # df_exclusion[["geometry_skeleton", "geometry_mask"]] # Save the results and exclusions to separate GeoJSON files # Convert DataFrame to GeoDataFrame gdf_exclusion = gpd.GeoDataFrame(df_exclusion, geometry="geometry_mask") diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index def776db..7115beaa 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -13,8 +13,8 @@ sys.path.append('../lidro') -from lidro.create_virtual_point.pointcloud.convert_geodataframe_to_las import ( - geodataframe_to_las, +from lidro.create_virtual_point.pointcloud.convert_list_points_to_las import ( + list_points_to_las, ) from lidro.create_virtual_point.vectors.merge_skeleton_by_mask import ( merge_skeleton_by_mask, @@ -98,7 +98,7 @@ def process_points_knn(points_knn): ] logging.info("Calculate virtuals points by mask hydro and skeleton") # Save the virtual points (.LAS) - geodataframe_to_las(gdf_virtual_points, output_dir, crs, classes) + list_points_to_las(gdf_virtual_points, output_dir, crs, classes) else: logging.error("Error when merged all points around skeleton by lidar tile") diff --git a/scripts/example_create_virtual_point_default.sh b/scripts/example_create_virtual_point_default.sh index 9f3315f5..b37bab10 100644 --- a/scripts/example_create_virtual_point_default.sh +++ b/scripts/example_create_virtual_point_default.sh @@ -5,7 +5,7 @@ io.input_mask_hydro=./data/merge_mask_hydro/MaskHydro_merge.geojson \ io.input_skeleton=./data/skeleton_hydro/Skeleton_Hydro.geojson \ io.dir_points_skeleton=./tmp/point_skeleton/ \ io.output_dir=./tmp/ \ -io.virtual_point.vector.points_grid_spacing=0.5 \ +io.virtual_point.pointcloud.points_grid_spacing=0.5 \ From ddb40523c8f28aff91df5d8cad6a249443c65114 Mon Sep 17 00:00:00 2001 From: mdupays Date: Thu, 5 Sep 2024 14:47:57 +0200 Subject: [PATCH 73/85] refacto test convert list points to las --- ...taframe_to_las.py => test_convert_list_points_to_las.py} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename test/pointcloud/{test_convert_geodataframe_to_las.py => test_convert_list_points_to_las.py} (91%) diff --git a/test/pointcloud/test_convert_geodataframe_to_las.py b/test/pointcloud/test_convert_list_points_to_las.py similarity index 91% rename from test/pointcloud/test_convert_geodataframe_to_las.py rename to test/pointcloud/test_convert_list_points_to_las.py index fef2d377..cb0ee8e6 100644 --- a/test/pointcloud/test_convert_geodataframe_to_las.py +++ b/test/pointcloud/test_convert_list_points_to_las.py @@ -9,8 +9,8 @@ from shapely.geometry import Point # Import the function from your module -from lidro.create_virtual_point.pointcloud.convert_geodataframe_to_las import ( - geodataframe_to_las, +from lidro.create_virtual_point.pointcloud.convert_list_points_to_las import ( + list_points_to_las, ) TMP_PATH = Path("./tmp/virtual_points/pointcloud/convert_geodataframe_to_las") @@ -40,7 +40,7 @@ def test_geodataframe_to_las_default(): crs = pyproj.CRS("EPSG:2154") # Call the function to test - geodataframe_to_las([points], TMP_PATH, crs, 68) + list_points_to_las([points], TMP_PATH, crs, 68) # Verify the output LAS file output_las = os.path.join(TMP_PATH, "virtual_points.laz") From 8cfa73e765e24ae6ee12ff758a9fe4b07ca3dab3 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:05:00 +0200 Subject: [PATCH 74/85] refacto convert_list_points_las.py and test : delete geodataframe --- .../pointcloud/convert_list_points_to_las.py | 16 ++++++++++------ lidro/main_create_virtual_point.py | 4 ++-- .../test_convert_list_points_to_las.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py b/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py index f6f8e8d7..b647b3a5 100644 --- a/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py +++ b/lidro/create_virtual_point/pointcloud/convert_list_points_to_las.py @@ -7,10 +7,11 @@ import laspy import numpy as np +import pandas as pd from shapely.geometry import Point -def list_points_to_las(virtual_points: List[Point], output_dir: str, crs: str, virtual_points_class: int): +def list_points_to_las(virtual_points: List[Point], output_dir: str, crs: str, virtual_points_classes: int): """This function convert virtual points (List of virtuals points) to LIDAR points with classification for virtual points @@ -18,17 +19,20 @@ def list_points_to_las(virtual_points: List[Point], output_dir: str, crs: str, v virtual_points (List[Point]): A list containing virtuals points by hydrological entity output_dir (str): folder to output LAS crs (str): a pyproj CRS object used to create the output GeoJSON file - virtual_points_class (int): The classification value to assign to those virtual points + virtual_points_classes (int): The classification value to assign to those virtual points """ + # Combine all virtual points into a single GeoDataFrame + grid_with_z = pd.concat(virtual_points, ignore_index=True) + # Create a LAS file with laspy header = laspy.LasHeader(point_format=6, version="1.4") # Create a LAS file with laspy and add the VLR for CRS las = laspy.LasData(header) - las.x = np.array([pt.x for pt in virtual_points]) - las.y = np.array([pt.y for pt in virtual_points]) - las.z = np.array([pt.y for pt in virtual_points]) - las.classification = virtual_points_class * np.ones([len(virtual_points)], dtype=np.uint8) + las.x = grid_with_z.geometry.x + las.y = grid_with_z.geometry.y + las.z = grid_with_z.geometry.z + las.classification = virtual_points_classes * np.ones(len(grid_with_z.index)) # Add CRS information to the VLR vlr = laspy.vlrs.known.WktCoordinateSystemVlr(crs.to_wkt()) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 7115beaa..9e006274 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -84,7 +84,7 @@ def process_points_knn(points_knn): # Combine skeleton lines into a single polyline for each hydro entity gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) - gdf_virtual_points = [ + list_virtual_points = [ launch_virtual_points_by_section( points_gdf, gpd.GeoDataFrame([{"geometry": row["geometry_skeleton"]}], crs=crs), @@ -98,7 +98,7 @@ def process_points_knn(points_knn): ] logging.info("Calculate virtuals points by mask hydro and skeleton") # Save the virtual points (.LAS) - list_points_to_las(gdf_virtual_points, output_dir, crs, classes) + list_points_to_las(list_virtual_points, output_dir, crs, classes) else: logging.error("Error when merged all points around skeleton by lidar tile") diff --git a/test/pointcloud/test_convert_list_points_to_las.py b/test/pointcloud/test_convert_list_points_to_las.py index cb0ee8e6..50d2d9bc 100644 --- a/test/pointcloud/test_convert_list_points_to_las.py +++ b/test/pointcloud/test_convert_list_points_to_las.py @@ -22,7 +22,7 @@ def setup_module(module): os.makedirs(TMP_PATH) -def test_geodataframe_to_las_default(): +def test_list_points_to_las_default(): # Create a sample GeoDataFrame with virtual points (EPSG:2154) points = gpd.GeoDataFrame( { From 8f0a50c0169edade677e7bacdaa6740e9fc74fe6 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:11:12 +0200 Subject: [PATCH 75/85] delete l66 : isinstance(points_Z, list) --- .../vectors/extract_points_around_skeleton.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py index cc603c39..7bd4b11b 100644 --- a/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py +++ b/lidro/create_virtual_point/vectors/extract_points_around_skeleton.py @@ -63,7 +63,7 @@ def extract_points_around_skeleton_points_one_tile( logging.info(f"\nExtract a Z elevation value along the hydrographic skeleton for tile : {tilename}") points_Z = filter_las_around_point(points_skeleton_with_z_clip, points_clip, k) - if isinstance(points_Z, list) and len(points_Z) > 0: + if len(points_Z) > 0: # Limit the precision of coordinates using numpy arrays or tuples knn_points = [ [[round(coord, 3) for coord in point] for point in item["points_knn"]] From 73b70bf6091ccac247f4f6ca8fc3a6b2d565ba1c Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:15:02 +0200 Subject: [PATCH 76/85] refacto linear_regression_model.py --- .../vectors/linear_regression_model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lidro/create_virtual_point/vectors/linear_regression_model.py b/lidro/create_virtual_point/vectors/linear_regression_model.py index b427a0c9..3fe7aa19 100644 --- a/lidro/create_virtual_point/vectors/linear_regression_model.py +++ b/lidro/create_virtual_point/vectors/linear_regression_model.py @@ -83,13 +83,13 @@ def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoData } ) # Linear regression with weights - coeff, SSE, *_ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) + coeff, sse, _ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) - # Calculate SST (TOTAL SQUARE SUN) - SST = np.sum((temp["z"]["quantile"] - np.mean(temp["z"]["quantile"])) ** 2) + # Calculate SST (TOTAL SQUARE SUM) + sst = np.sum((temp["z"]["quantile"] - np.mean(temp["z"]["quantile"])) ** 2) # Determination coefficient [0, 1] - r2 = 1 - (SSE / SST) + r2 = 1 - (sse / sst) model = np.poly1d(coeff) From f87e5c46be2da5000a3f854be5f98cd1fc7e2918 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:16:41 +0200 Subject: [PATCH 77/85] modify typehint function's parameters --- .../vectors/create_grid_2D_inside_maskhydro.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py index a0df8a45..4f9b5f11 100644 --- a/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py +++ b/lidro/create_virtual_point/vectors/create_grid_2D_inside_maskhydro.py @@ -6,7 +6,7 @@ import shapely.vectorized -def generate_grid_from_geojson(mask_hydro: gpd.GeoDataFrame, spacing=0.5, margin=0): +def generate_grid_from_geojson(mask_hydro: gpd.GeoDataFrame, spacing: float = 0.5, margin: float = 0): """ Generates a regular 2D grid of evenly spaced points within a polygon defined in a GeoJSON file. From 93f6aa42c2f3a4c84d3402ecfcb15d2118fb0fdf Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:21:17 +0200 Subject: [PATCH 78/85] add l86 *_ : because without, there is an error (ValueError: too many values to unpack (expected 3)) --- lidro/create_virtual_point/vectors/linear_regression_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/linear_regression_model.py b/lidro/create_virtual_point/vectors/linear_regression_model.py index 3fe7aa19..f2a7879f 100644 --- a/lidro/create_virtual_point/vectors/linear_regression_model.py +++ b/lidro/create_virtual_point/vectors/linear_regression_model.py @@ -83,7 +83,7 @@ def calculate_linear_regression_line(points: gpd.GeoDataFrame, line: gpd.GeoData } ) # Linear regression with weights - coeff, sse, _ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) + coeff, sse, *_ = np.polyfit(temp["ac"]["mean"], temp["z"]["quantile"], deg=1, full=True) # Calculate SST (TOTAL SQUARE SUM) sst = np.sum((temp["z"]["quantile"] - np.mean(temp["z"]["quantile"])) ** 2) From 55a5d8d8ee66a2599e5593135521d822fdaa1588 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:25:28 +0200 Subject: [PATCH 79/85] delete drop pts_intersect = pts_intersect.dropna(subset=[index_right]) --- .../create_virtual_point/vectors/intersect_points_by_line.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lidro/create_virtual_point/vectors/intersect_points_by_line.py b/lidro/create_virtual_point/vectors/intersect_points_by_line.py index b5fe9419..0a94592c 100644 --- a/lidro/create_virtual_point/vectors/intersect_points_by_line.py +++ b/lidro/create_virtual_point/vectors/intersect_points_by_line.py @@ -22,8 +22,7 @@ def return_points_by_line(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame) -> g # Perform spatial join to find intersections pts_intersect = gpd.sjoin(points, gdf_line_buffer, how="left", predicate="intersects") - # Filter out rows where 'index_right' is NaN - pts_intersect = pts_intersect.dropna(subset=["index_right"]) - pts_intersect = pts_intersect.dropna() # Drop lines which contain one or more NaN values + # Drop lines which contain one or more NaN values + pts_intersect = pts_intersect.dropna() return pts_intersect From 5a1c1814277418b355f44f2cf33f316747829f63 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:28:08 +0200 Subject: [PATCH 80/85] refacto run_create_virtual_point.py : delete if not points.empty and not points[points_knn].isnull().all() -> else --- .../create_virtual_point/vectors/run_create_virtual_points.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index 02768221..b35072c5 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -57,8 +57,7 @@ def launch_virtual_points_by_section( # Save the resulting masks_without_points to a GeoJSON file output_mask_hydro_error = os.path.join(output_dir, "mask_hydro_no_virtual_points.geojson") masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") - - if not points.empty and not points["points_knn"].isnull().all(): + else: # Step 1: Generates a regular 2D grid of evenly spaced points within a Mask Hydro gdf_grid = generate_grid_from_geojson(mask_hydro, spacing) # Calculate the length of the river From ce1fb1d12172b941ea4407976fe3e080caeb5c45 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:31:14 +0200 Subject: [PATCH 81/85] refacto test create grid 2d inside maskhydro : add test --- .../test_create_grid_2D_inside_maskhydro.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/vectors/test_create_grid_2D_inside_maskhydro.py b/test/vectors/test_create_grid_2D_inside_maskhydro.py index bdec13c2..a134bc21 100644 --- a/test/vectors/test_create_grid_2D_inside_maskhydro.py +++ b/test/vectors/test_create_grid_2D_inside_maskhydro.py @@ -6,14 +6,33 @@ ) +def test_generate_grid_from_geojson_default(): + # Create a sample GeoDataFrame with a smaller polygon (200m x 200m) in EPSG:2154 + polygon = Polygon([(700000, 6600000), (700000, 6600200), (700200, 6600200), (700200, 6600000), (700000, 6600000)]) + mask_hydro = gpd.GeoDataFrame({"geometry": [polygon]}, crs="EPSG:2154") + + # Define the spacing and margin + spacing = 0.5 # 0.5 meter spacing + margin = 0 # no marging + + # Call the function to test + result = generate_grid_from_geojson(mask_hydro, spacing, margin) + + # Check that the result is a GeoDataFrame + assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" + + # Check that the points are within the polygon bounds + assert result.within(polygon).all(), "All points should be within the polygon" + + def test_generate_grid_from_geojson(): # Create a sample GeoDataFrame with a smaller polygon (200m x 200m) in EPSG:2154 polygon = Polygon([(700000, 6600000), (700000, 6600200), (700200, 6600200), (700200, 6600000), (700000, 6600000)]) mask_hydro = gpd.GeoDataFrame({"geometry": [polygon]}, crs="EPSG:2154") # Define the spacing and margin - spacing = 1.0 # 1 meter spacing - margin = 0 # à marging + spacing = 2 # 2 meter spacing + margin = 1 # 1 meter marging # Call the function to test result = generate_grid_from_geojson(mask_hydro, spacing, margin) From d7ab20ab0316589f63f33fa8cf0b788dd7c97b53 Mon Sep 17 00:00:00 2001 From: mdupays Date: Fri, 6 Sep 2024 10:36:42 +0200 Subject: [PATCH 82/85] update commentarys for function main_create_virtual_point.py --- lidro/main_create_virtual_point.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 9e006274..6fd216b3 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -77,13 +77,14 @@ def process_points_knn(points_knn): # List match Z elevation values every N meters along the hydrographic skeleton df = pd.DataFrame(points_clip_list) - # Step 2: Smooth Z by hydro's section + # Step 2: Combine skeleton lines into a single polyline for each hydro entity if not df.empty and "points_knn" in df.columns and "geometry" in df.columns: points_gdf = gpd.GeoDataFrame(df, geometry="geometry") points_gdf.set_crs(crs, inplace=True) # Combine skeleton lines into a single polyline for each hydro entity gdf_merged = merge_skeleton_by_mask(input_skeleton, input_mask_hydro, output_dir, crs) + # Step 3 : Generate a regular grid of 3D points spaced every N meters inside each hydro entity list_virtual_points = [ launch_virtual_points_by_section( points_gdf, @@ -97,7 +98,7 @@ def process_points_knn(points_knn): for idx, row in gdf_merged.iterrows() ] logging.info("Calculate virtuals points by mask hydro and skeleton") - # Save the virtual points (.LAS) + # Step 4 : Save the virtual points in a file (.LAZ) list_points_to_las(list_virtual_points, output_dir, crs, classes) else: logging.error("Error when merged all points around skeleton by lidar tile") From de6d1e19313781b137b3d2df246259f4cfc14464 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 10 Sep 2024 15:11:01 +0200 Subject: [PATCH 83/85] delete geometry_skeleton l106 --- lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py index f7d7921d..fd7f770b 100644 --- a/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py +++ b/lidro/create_virtual_point/vectors/merge_skeleton_by_mask.py @@ -103,7 +103,7 @@ def merge_skeleton_by_mask( gdf_mask_hydro, left_on="index_mask", right_index=True, suffixes=("_skeleton", "_mask") ) # Keep only necessary columns - df_exclusion = df_exclusion[["geometry_mask"]] # df_exclusion[["geometry_skeleton", "geometry_mask"]] + df_exclusion = df_exclusion[["geometry_mask"]] # Save the results and exclusions to separate GeoJSON files # Convert DataFrame to GeoDataFrame gdf_exclusion = gpd.GeoDataFrame(df_exclusion, geometry="geometry_mask") From 1d1e9d61fd7ba64560a7c7ee762ab5687162ae48 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 10 Sep 2024 15:26:58 +0200 Subject: [PATCH 84/85] refacto apply z from grid --- .../vectors/apply_Z_from_grid.py | 13 ++++--------- .../vectors/run_create_virtual_points.py | 4 ++-- test/vectors/test_apply_z_from_grid.py | 7 ++----- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/lidro/create_virtual_point/vectors/apply_Z_from_grid.py b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py index 823fa6ea..9e6d497b 100644 --- a/lidro/create_virtual_point/vectors/apply_Z_from_grid.py +++ b/lidro/create_virtual_point/vectors/apply_Z_from_grid.py @@ -25,22 +25,17 @@ def calculate_grid_z_with_model(points: gpd.GeoDataFrame, line: gpd.GeoDataFrame predicted_z = model(curvilinear_abs) # Generate a new geodataframe, with 3D points - grid_with_z = gpd.GeoDataFrame( - geometry=gpd.GeoSeries().from_xy(points.geometry.x, points.geometry.y, predicted_z), crs=points.crs - ) + grid_with_z = calculate_grid_z(points, predicted_z) return grid_with_z -def calculate_grid_z_for_flattening( - points: gpd.GeoDataFrame, line: gpd.GeoDataFrame, predicted_z: float -) -> gpd.GeoDataFrame: - """Calculate Z for flattening +def calculate_grid_z(points: gpd.GeoDataFrame, predicted_z: float) -> gpd.GeoDataFrame: + """Calculate Z grid Args: points (gpd.GeoDataFrame): A GeoDataFrame containing the grid points - line (gpd.GeoDataFrame): A GeoDataFrame containing each line from Hydro's Skeleton - predicted_z (float): predicted Z for flattening river + predicted_z (float): predicted Z for river Returns: gpd.GeoDataFrame: A GeoDataFrame of initial points, but with a Z. diff --git a/lidro/create_virtual_point/vectors/run_create_virtual_points.py b/lidro/create_virtual_point/vectors/run_create_virtual_points.py index b35072c5..d30ad444 100644 --- a/lidro/create_virtual_point/vectors/run_create_virtual_points.py +++ b/lidro/create_virtual_point/vectors/run_create_virtual_points.py @@ -9,7 +9,7 @@ import pandas as pd from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( - calculate_grid_z_for_flattening, + calculate_grid_z, calculate_grid_z_with_model, ) from lidro.create_virtual_point.vectors.create_grid_2D_inside_maskhydro import ( @@ -100,6 +100,6 @@ def launch_virtual_points_by_section( ) masks_without_points.to_file(output_mask_hydro_error, driver="GeoJSON") # Apply flattening model at the rivers - gdf_grid_with_z = calculate_grid_z_for_flattening(gdf_grid, line, predicted_z) + gdf_grid_with_z = calculate_grid_z(gdf_grid, predicted_z) return gdf_grid_with_z diff --git a/test/vectors/test_apply_z_from_grid.py b/test/vectors/test_apply_z_from_grid.py index 7984c611..c5e14a82 100644 --- a/test/vectors/test_apply_z_from_grid.py +++ b/test/vectors/test_apply_z_from_grid.py @@ -3,7 +3,7 @@ from shapely.geometry import LineString, Point from lidro.create_virtual_point.vectors.apply_Z_from_grid import ( - calculate_grid_z_for_flattening, + calculate_grid_z, calculate_grid_z_with_model, ) @@ -43,14 +43,11 @@ def test_calculate_grid_z_for_flattening(): # Create a sample GeoDataFrame of points points = gpd.GeoDataFrame({"geometry": [Point(0, 0), Point(1, 1), Point(2, 2)]}, crs="EPSG:4326") - # Create a sample GeoDataFrame of line - line = gpd.GeoDataFrame({"geometry": [LineString([(0, 0), (2, 2)])]}, crs="EPSG:4326") - # Predicted Z for flattening predicted_z = 10.0 # Call the function to test - result = calculate_grid_z_for_flattening(points, line, predicted_z) + result = calculate_grid_z(points, predicted_z) # Check that the result is a GeoDataFrame assert isinstance(result, gpd.GeoDataFrame), "The result should be a GeoDataFrame" From 9a0a43ae4892a15febb91e4f1234d7d34acd7b38 Mon Sep 17 00:00:00 2001 From: mdupays Date: Tue, 10 Sep 2024 15:31:34 +0200 Subject: [PATCH 85/85] delete commentary round each coordindate to 8 decimal places --- lidro/main_create_virtual_point.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lidro/main_create_virtual_point.py b/lidro/main_create_virtual_point.py index 6fd216b3..3e9ef544 100644 --- a/lidro/main_create_virtual_point.py +++ b/lidro/main_create_virtual_point.py @@ -64,8 +64,6 @@ def process_points_knn(points_knn): # Check if points_knn is a string and convert it to a list if necessary if isinstance(points_knn, str): points_knn = ast.literal_eval(points_knn) # Convert the string to a list of lists - - # Round each coordinate to 8 decimal places return [[round(coord, 3) for coord in point] for point in points_knn] points_clip_list = [