diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2fe0fe5..9aa3596 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: 23.12.0 hooks: - id: black - language_version: python3.11 + language_version: python3.12 - repo: https://github.com/pycqa/flake8 rev: 6.1.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 771da14..9eeea4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.15.7 +- [update] Update las_comparison with tolerance +- [update] Colorisation of Las with stream or files + # 1.15.6 - [fix] fix use of tempory file in windows diff --git a/pdaltools/_version.py b/pdaltools/_version.py index 4863c70..4551ee2 100644 --- a/pdaltools/_version.py +++ b/pdaltools/_version.py @@ -1,4 +1,4 @@ -__version__ = "1.15.6" +__version__ = "1.15.7" if __name__ == "__main__": diff --git a/pdaltools/color.py b/pdaltools/color.py index 7f1750e..4a771f3 100644 --- a/pdaltools/color.py +++ b/pdaltools/color.py @@ -31,7 +31,7 @@ def match_min_max_with_pixel_size(min_d: float, max_d: float, pixel_per_meter: f return min_d, max_d -def color( +def color_from_stream( input_file: str, output_file: str, proj="", @@ -156,28 +156,58 @@ def color( return tmp_ortho, tmp_ortho_irc -def parse_args(): - parser = argparse.ArgumentParser("Colorize tool", formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("--input", "-i", type=str, required=True, help="Input file") - parser.add_argument("--output", "-o", type=str, default="", help="Output file") - parser.add_argument( - "--proj", "-p", type=str, default="", help="Projection, default will use projection from metadata input" +def color_from_files( + input_file: str, + output_file: str, + rgb_image: str, + irc_image: str, + color_rvb_enabled=True, + color_ir_enabled=True, + veget_index_file="", + vegetation_dim="Deviation", +): + pipeline = pdal.Reader.las(filename=input_file) + + writer_extra_dims = "all" + + if veget_index_file and veget_index_file != "": + print(f"Remplissage du champ {vegetation_dim} à partir du fichier {veget_index_file}") + pipeline |= pdal.Filter.colorization(raster=veget_index_file, dimensions=f"{vegetation_dim}:1:256.0") + writer_extra_dims = [f"{vegetation_dim}=ushort"] + + # Warning: the initial color is multiplied by 256 despite its initial 8-bits encoding + # which turns it to a 0 to 255*256 range. + # It is kept this way because of other dependencies that have been tuned to fit this range + if color_rvb_enabled: + pipeline |= pdal.Filter.colorization(raster=rgb_image, dimensions="Red:1:256.0, Green:2:256.0, Blue:3:256.0") + if color_ir_enabled: + pipeline |= pdal.Filter.colorization(raster=irc_image, dimensions="Infrared:1:256.0") + + pipeline |= pdal.Writer.las( + filename=output_file, extra_dims=writer_extra_dims, minor_version="4", dataformat_id="8", forward="all" ) - parser.add_argument("--resolution", "-r", type=float, default=5, help="Resolution, in pixel per meter") - parser.add_argument("--timeout", "-t", type=int, default=300, help="Timeout, in seconds") - parser.add_argument("--rvb", action="store_true", help="Colorize RVB") - parser.add_argument("--ir", action="store_true", help="Colorize IR") - parser.add_argument( - "--vegetation", - type=str, - default="", - help="Vegetation file (raster), value will be stored in 'vegetation_dim' field", + + print("Traitement du nuage de point") + pipeline.execute() + + +def argument_parser(): + parser = argparse.ArgumentParser("Colorize tool") + subparsers = parser.add_subparsers(required=True) + + # first command is 'from_stream' + from_stream = subparsers.add_parser("from_stream", help="Images are downloaded from streams") + from_stream.add_argument( + "--proj", "-p", type=str, default="", help="Projection, default will use projection from metadata input" ) - parser.add_argument( - "--vegetation_dim", type=str, default="Deviation", help="name of the extra_dim uses for the vegetation value" + from_stream.add_argument("--timeout", "-t", type=int, default=300, help="Timeout, in seconds") + from_stream.add_argument("--rvb", action="store_true", help="Colorize RVB") + from_stream.add_argument("--ir", action="store_true", help="Colorize IR") + from_stream.add_argument("--resolution", "-r", type=float, default=5, help="Resolution, in pixel per meter") + from_stream.add_argument( + "--check-images", "-c", action="store_true", help="Check that downloaded image is not white" ) - parser.add_argument("--check-images", "-c", action="store_true", help="Check that downloaded image is not white") - parser.add_argument( + from_stream.add_argument( "--stream-RGB", type=str, default="ORTHOIMAGERY.ORTHOPHOTOS", @@ -186,27 +216,49 @@ def parse_args(): for 20cm resolution rasters, use HR.ORTHOIMAGERY.ORTHOPHOTOS for 50 cm resolution rasters, use ORTHOIMAGERY.ORTHOPHOTOS.BDORTHO""", ) - parser.add_argument( + from_stream.add_argument( "--stream-IRC", type=str, default="ORTHOIMAGERY.ORTHOPHOTOS.IRC", help="""WMS raster stream for IRC colorization. Default to ORTHOIMAGERY.ORTHOPHOTOS.IRC Documentation about possible stream : https://geoservices.ign.fr/services-web-experts-ortho""", ) - parser.add_argument( + from_stream.add_argument( "--size-max-GPF", type=int, default=5000, help="Maximum edge size (in pixels) of downloaded images." " If input file needs more, several images are downloaded and merged.", ) + add_common_options(from_stream) + from_stream.set_defaults(func=from_stream_func) - return parser.parse_args() + # second command is 'from_files' + from_files = subparsers.add_parser("from_files", help="Images are in directories from RGB/IRC") + from_files.add_argument("--image_RGB", type=str, required=True, help="RGB image filepath") + from_files.add_argument("--image_IRC", type=str, required=True, help="IRC image filepath") + add_common_options(from_files) + from_files.set_defaults(func=from_files_func) + return parser -if __name__ == "__main__": - args = parse_args() - color( + +def add_common_options(parser): + parser.add_argument("--input", "-i", type=str, required=True, help="Input file") + parser.add_argument("--output", "-o", type=str, default="", help="Output file") + parser.add_argument( + "--vegetation", + type=str, + default="", + help="Vegetation file (raster), value will be stored in 'vegetation_dim' field", + ) + parser.add_argument( + "--vegetation_dim", type=str, default="Deviation", help="name of the extra_dim uses for the vegetation value" + ) + + +def from_stream_func(args): + color_from_stream( input_file=args.input, output_file=args.output, proj=args.proj, @@ -221,3 +273,33 @@ def parse_args(): stream_IRC=args.stream_IRC, size_max_gpf=args.size_max_GPF, ) + + +def from_files_func(args): + if args.image_RGB and args.image_RGB != "": + color_rvb_enabled = True + else: + color_rvb_enabled = False + if args.image_IRC and args.image_IRC != "": + color_irc_enabled = True + else: + color_irc_enabled = False + + if not color_rvb_enabled and not color_irc_enabled: + raise ValueError("At least one of --rvb or --ir must be provided") + + color_from_files( + input_file=args.input, + output_file=args.output, + rgb_image=args.image_RGB, + irc_image=args.image_IRC, + color_rvb_enabled=color_rvb_enabled, + color_ir_enabled=color_irc_enabled, + veget_index_file=args.vegetation, + vegetation_dim=args.vegetation_dim, + ) + + +if __name__ == "__main__": + args = argument_parser.parse_args() + args.func(args) diff --git a/pdaltools/las_comparison.py b/pdaltools/las_comparison.py index 4393f9e..46f639a 100644 --- a/pdaltools/las_comparison.py +++ b/pdaltools/las_comparison.py @@ -1,12 +1,13 @@ import argparse -from pathlib import Path -from typing import Tuple +from typing import Tuple, Dict, Optional import laspy import numpy as np +from pathlib import Path + -def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None) -> Tuple[bool, int, float]: +def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None, precision: Optional[Dict[str, float]] = None) -> Tuple[bool, int, float]: """ Compare specified dimensions between two LAS files. If no dimensions are specified, compares all available dimensions. @@ -16,6 +17,8 @@ def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None) -> file1: Path to the first LAS file file2: Path to the second LAS file dimensions: List of dimension names to compare (optional) + precision: Dictionary mapping dimension names to tolerance values for float comparison. + If None or dimension not in dict, uses exact comparison (default: None) Returns: bool: True if all specified dimensions are identical, False otherwise @@ -59,20 +62,42 @@ def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None) -> # Compare each dimension for dim in dimensions: try: + # Get sorted dimension arrays dim1 = np.array(las1[dim])[sort_idx1] dim2 = np.array(las2[dim])[sort_idx2] + # Get precision for this dimension (if specified) + dim_precision = None + if precision is not None and dim in precision: + dim_precision = precision[dim] + # Compare dimensions - if not np.array_equal(dim1, dim2): - # Find differences - diff_indices = np.where(dim1 != dim2)[0] - print(f"Found {len(diff_indices)} points with different {dim}:") - for idx in diff_indices[:10]: # Show first 10 differences - print(f"Point {idx}: file1={dim1[idx]}, file2={dim2[idx]}") - if len(diff_indices) > 10: - print(f"... and {len(diff_indices) - 10} more differences") - return False, len(diff_indices), 100 * len(diff_indices) / len(las1) + if dim_precision is not None: + # Use tolerance-based comparison for floats + are_equal = np.allclose(dim1, dim2, rtol=0, atol=dim_precision) + if not are_equal: + # Find differences + diff_mask = ~np.isclose(dim1, dim2, rtol=0, atol=dim_precision) + diff_indices = np.where(diff_mask)[0] + print(f"Found {len(diff_indices)} points with different {dim} (tolerance={dim_precision}):") + for idx in diff_indices[:10]: # Show first 10 differences + diff_value = abs(dim1[idx] - dim2[idx]) + print(f"Point {idx}: file1={dim1[idx]}, file2={dim2[idx]}, diff={diff_value}") + if len(diff_indices) > 10: + print(f"... and {len(diff_indices) - 10} more differences") + return False, len(diff_indices), 100 * len(diff_indices) / len(las1) + else: + # Exact comparison + if not np.array_equal(dim1, dim2): + # Find differences + diff_indices = np.where(dim1 != dim2)[0] + print(f"Found {len(diff_indices)} points with different {dim}:") + for idx in diff_indices[:10]: # Show first 10 differences + print(f"Point {idx}: file1={dim1[idx]}, file2={dim2[idx]}") + if len(diff_indices) > 10: + print(f"... and {len(diff_indices) - 10} more differences") + return False, len(diff_indices), 100 * len(diff_indices) / len(las1) except KeyError: print(f"Dimension '{dim}' not found in one or both files") @@ -93,12 +118,32 @@ def compare_las_dimensions(file1: Path, file2: Path, dimensions: list = None) -> # Update main function to use the new compare function def main(): - parser = argparse.ArgumentParser(description="Compare dimensions between two LAS files") + parser = argparse.ArgumentParser( + description="Compare dimensions between two LAS files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Compare all dimensions with exact match + python las_comparison.py file1.las file2.las + + # Compare specific dimensions with precision per dimension + python las_comparison.py file1.las file2.las --dimensions X Y Z --precision X=0.001 Y=0.001 Z=0.0001 + + # Compare all dimensions with precision for specific ones + python las_comparison.py file1.las file2.las --precision X=0.001 Y=0.001 + """ + ) parser.add_argument("file1", type=str, help="Path to first LAS file") parser.add_argument("file2", type=str, help="Path to second LAS file") parser.add_argument( "--dimensions", nargs="*", help="List of dimensions to compare. If not specified, compares all dimensions." ) + parser.add_argument( + "--precision", nargs="*", metavar="DIM=VAL", + help="Tolerance for float comparison per dimension (format: DIMENSION=PRECISION). " + "Example: --precision X=0.001 Y=0.001 Z=0.0001. " + "Dimensions not specified will use exact comparison." + ) args = parser.parse_args() @@ -109,7 +154,18 @@ def main(): print("Error: One or both files do not exist") exit(1) - result = compare_las_dimensions(file1, file2, args.dimensions) + # Parse precision dictionary from command line arguments + precision_dict = None + if args.precision: + precision_dict = {} + for prec_spec in args.precision: + try: + dim_name, prec_value = prec_spec.split('=', 1) + precision_dict[dim_name] = float(prec_value) + except ValueError: + parser.error(f"Invalid precision format: '{prec_spec}'. Expected format: DIMENSION=PRECISION (e.g., X=0.001)") + + result = compare_las_dimensions(file1, file2, args.dimensions, precision_dict) print(f"Dimensions comparison result: {'identical' if result[0] else 'different'}") return result diff --git a/test/data/color/test_data_irc.tif b/test/data/color/test_data_irc.tif new file mode 100644 index 0000000..20883e7 Binary files /dev/null and b/test/data/color/test_data_irc.tif differ diff --git a/test/data/color/test_data_rgb.tif b/test/data/color/test_data_rgb.tif new file mode 100644 index 0000000..2c43232 Binary files /dev/null and b/test/data/color/test_data_rgb.tif differ diff --git a/test/test_color.py b/test/test_color.py index 6c3b610..67d2161 100644 --- a/test/test_color.py +++ b/test/test_color.py @@ -7,6 +7,7 @@ import pytest from pdaltools import color +from pdaltools.color import argument_parser cwd = os.getcwd() @@ -14,6 +15,11 @@ TMPDIR = os.path.join(TEST_PATH, "tmp", "color") INPUT_PATH = os.path.join(TEST_PATH, "data/test_noepsg_043500_629205_IGN69.laz") +INPUT_PATH_TILE = os.path.join(TEST_PATH, "data/test_data_77055_627760_LA93_IGN69.laz") + +RGB_IMAGE = os.path.join(TEST_PATH, "data/color/test_data_rgb.tif") +IRC_IMAGE = os.path.join(TEST_PATH, "data/color/test_data_irc.tif") + OUTPUT_FILE = os.path.join(TMPDIR, "Semis_2021_0435_6292_LA93_IGN69.colorized.las") EPSG = "2154" @@ -33,12 +39,12 @@ def test_epsg_fail(): RuntimeError, match="EPSG could not be inferred from metadata: No 'srs' key in metadata.", ): - color.color(INPUT_PATH, OUTPUT_FILE, "", 0.1, 15) + color.color_from_stream(INPUT_PATH, OUTPUT_FILE, "", 0.1, 15) @pytest.mark.geopf def test_color_and_keeping_orthoimages(): - tmp_ortho, tmp_ortho_irc = color.color(INPUT_PATH, OUTPUT_FILE, EPSG, check_images=True) + tmp_ortho, tmp_ortho_irc = color.color_from_stream(INPUT_PATH, OUTPUT_FILE, EPSG, check_images=True) assert Path(tmp_ortho.name).exists() assert Path(tmp_ortho_irc.name).exists() @@ -63,7 +69,7 @@ def test_color_narrow_cloud(): input_path = os.path.join(TEST_PATH, "data/test_data_0436_6384_LA93_IGN69_single_point.laz") output_path = os.path.join(TMPDIR, "color_narrow_cloud_test_data_0436_6384_LA93_IGN69_single_point.colorized.laz") # Test that clouds that are smaller in width or height to 20cm are still colorized without an error. - color.color(input_path, output_path, EPSG) + color.color_from_stream(input_path, output_path, EPSG) with laspy.open(output_path, "r") as las: las_data = las.read() # Check all points are colored @@ -75,10 +81,9 @@ def test_color_narrow_cloud(): @pytest.mark.geopf def test_color_standard_cloud(): - input_path = os.path.join(TEST_PATH, "data/test_data_77055_627760_LA93_IGN69.laz") output_path = os.path.join(TMPDIR, "color_standard_cloud_test_data_77055_627760_LA93_IGN69.colorized.laz") # Test that clouds that are smaller in width or height to 20cm are still colorized without an error. - color.color(input_path, output_path, EPSG) + color.color_from_stream(INPUT_PATH_TILE, output_path, EPSG) with laspy.open(output_path, "r") as las: las_data = las.read() # Check all points are colored @@ -92,7 +97,7 @@ def test_color_epsg_2975_forced(): input_path = os.path.join(TEST_PATH, "data/sample_lareunion_epsg2975.laz") output_path = os.path.join(TMPDIR, "color_epsg_2975_forced_sample_lareunion_epsg2975.colorized.laz") - color.color(input_path, output_path, 2975) + color.color_from_stream(input_path, output_path, 2975) # the test is not working, the image is not detected as white @@ -104,7 +109,7 @@ def test_color_epsg_2975_forced(): # output_path = os.path.join(TMPDIR, "sample_lareunion_epsg2975.colorized.white.laz")# # with pytest.raises(ValueError) as excinfo: -# color.color(input_path, output_path, check_images=True) +# color.color_from_stream(input_path, output_path, check_images=True) # assert "Downloaded image is white" in str(excinfo.value) @@ -114,18 +119,17 @@ def test_color_epsg_2975_detected(): input_path = os.path.join(TEST_PATH, "data/sample_lareunion_epsg2975.laz") output_path = os.path.join(TMPDIR, "color_epsg_2975_detected_sample_lareunion_epsg2975.colorized.laz") # Test that clouds that are smaller in width or height to 20cm are still clorized without an error. - color.color(input_path, output_path) + color.color_from_stream(input_path, output_path) def test_color_vegetation_only(): - """Test the color() function with only vegetation""" - input_path = os.path.join(TEST_PATH, "data/test_data_77055_627760_LA93_IGN69.laz") + """Test the color_from_stream() function with only vegetation""" output_path = os.path.join(TMPDIR, "test_color_vegetation.colorized.las") vegetation_path = os.path.join(TEST_PATH, "data/mock_vegetation.tif") # Test with all parameters explicitly defined - color.color( - input_file=input_path, + color.color_from_stream( + input_file=INPUT_PATH_TILE, output_file=output_path, proj="2154", # EPSG:2154 (Lambert 93) color_rvb_enabled=False, # RGB enabled @@ -153,14 +157,13 @@ def test_color_vegetation_only(): @pytest.mark.geopf def test_color_with_all_parameters(): - """Test the color() function with all parameters specified""" - input_path = os.path.join(TEST_PATH, "data/test_data_77055_627760_LA93_IGN69.laz") + """Test the color_from_stream() function with all parameters specified""" output_path = os.path.join(TMPDIR, "test_color_all_params.colorized.las") vegetation_path = os.path.join(TEST_PATH, "data/mock_vegetation.tif") # Test with all parameters explicitly defined - tmp_ortho, tmp_ortho_irc = color.color( - input_file=input_path, + tmp_ortho, tmp_ortho_irc = color.color_from_stream( + input_file=INPUT_PATH_TILE, output_file=output_path, proj="2154", # EPSG:2154 (Lambert 93) pixel_per_meter=2.0, # custom resolution @@ -196,3 +199,56 @@ def test_color_with_all_parameters(): # Verify that the vegetation dimension is present assert "vegetation_dim" in las_data.point_format.dimension_names, "Vegetation dimension should be present" assert not np.all(las_data.vegetation_dim == 0), "Vegetation dimension should not be empty" + + +def test_color_from_files(): + output_path = os.path.join(TMPDIR, "color_standard_cloud_files_test_data_77055_627760_LA93_IGN69.colorized.laz") + + color.color_from_files(INPUT_PATH_TILE, output_path, RGB_IMAGE, IRC_IMAGE) + + assert os.path.exists(output_path) + + with laspy.open(output_path, "r") as las: + las_data = las.read() + + # Verify that all points have been colorized (no 0 values) + las_rgb_missing = (las_data.red == 0) & (las_data.green == 0) & (las_data.blue == 0) + assert not np.any(las_rgb_missing), f"No point should have missing RGB, found {np.count_nonzero(las_rgb_missing)}" + assert not np.any(las_data.nir == 0), "No point should have missing NIR" + + +@pytest.mark.geopf +def test_main_from_stream(): + output_file = os.path.join(TMPDIR, "main_from_stream", "output_main_from_stream.laz") + os.makedirs(os.path.dirname(output_file)) + cmd = f"from_stream -i {INPUT_PATH_TILE} -o {output_file} -p {EPSG} --rvb --ir".split() + args = argument_parser().parse_args(cmd) + args.func(args) + + assert os.path.exists(output_file) + + with laspy.open(output_file, "r") as las: + las_data = las.read() + + # Verify that all points have been colorized (no 0 values) + las_rgb_missing = (las_data.red == 0) & (las_data.green == 0) & (las_data.blue == 0) + assert not np.any(las_rgb_missing), f"No point should have missing RGB, found {np.count_nonzero(las_rgb_missing)}" + assert not np.any(las_data.nir == 0), "No point should have missing NIR" + + +def test_main_from_files(): + output_file = os.path.join(TMPDIR, "main_from_files", "output_main_from_files.laz") + os.makedirs(os.path.dirname(output_file)) + cmd = f"from_files -i {INPUT_PATH_TILE} -o {output_file} --image_RGB {RGB_IMAGE} --image_IRC {IRC_IMAGE}".split() + args = argument_parser().parse_args(cmd) + args.func(args) + + assert os.path.exists(output_file) + + with laspy.open(output_file, "r") as las: + las_data = las.read() + + # Verify that all points have been colorized (no 0 values) + las_rgb_missing = (las_data.red == 0) & (las_data.green == 0) & (las_data.blue == 0) + assert not np.any(las_rgb_missing), f"No point should have missing RGB, found {np.count_nonzero(las_rgb_missing)}" + assert not np.any(las_data.nir == 0), "No point should have missing NIR" diff --git a/test/test_las_comparison.py b/test/test_las_comparison.py index f8441f7..28f65a7 100644 --- a/test/test_las_comparison.py +++ b/test/test_las_comparison.py @@ -235,6 +235,211 @@ def test_single_point(): file2.unlink() +def test_precision_within_tolerance(): + """Test that precision argument works when values are within tolerance""" + points = 10 + x, y, z = get_random_points(points) + + # Create custom float dimension with small differences (within tolerance) + custom_dim1 = np.random.rand(points) * 100.0 + custom_dim2 = custom_dim1 + 0.05 # Small difference, within 0.1 tolerance + + # Create files with custom dimension + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp1: + las1 = laspy.create(point_format=3, file_version="1.4") + las1.x = x + las1.y = y + las1.z = z + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las1.custom_float = custom_dim1 + las1.write(temp1.name) + file1 = Path(temp1.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp2: + las2 = laspy.create(point_format=3, file_version="1.4") + las2.x = x + las2.y = y + las2.z = z + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las2.custom_float = custom_dim2 + las2.write(temp2.name) + file2 = Path(temp2.name) + + try: + # Test without precision (should fail - exact comparison) + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float"]) + assert result is False, "Without precision, small differences should be detected" + + # Test with precision (should pass - within tolerance) + precision = {"custom_float": 0.1} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float"], precision) + assert result is True, "With precision, values within tolerance should be considered equal" + finally: + # Clean up + file1.unlink() + file2.unlink() + + +def test_precision_outside_tolerance(): + """Test that precision argument works when values are outside tolerance""" + points = 10 + x, y, z = get_random_points(points) + + # Create custom float dimension with differences outside tolerance + custom_dim1 = np.random.rand(points) * 100.0 + custom_dim2 = custom_dim1 + 0.2 # Difference larger than 0.1 tolerance + + # Create files with custom dimension + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp1: + las1 = laspy.create(point_format=3, file_version="1.4") + las1.x = x + las1.y = y + las1.z = z + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las1.custom_float = custom_dim1 + las1.write(temp1.name) + file1 = Path(temp1.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp2: + las2 = laspy.create(point_format=3, file_version="1.4") + las2.x = x + las2.y = y + las2.z = z + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las2.custom_float = custom_dim2 + las2.write(temp2.name) + file2 = Path(temp2.name) + + try: + # Test with precision (should fail - outside tolerance) + precision = {"custom_float": 0.1} + result, nb_diff, percentage = compare_las_dimensions(file1, file2, ["custom_float"], precision) + assert result is False, "With precision, values outside tolerance should be detected" + assert nb_diff == points, "All points should be different" + assert percentage == 100.0, "100% of points should be different" + finally: + # Clean up + file1.unlink() + file2.unlink() + + +def test_precision_multiple_dimensions(): + """Test precision argument with multiple dimensions""" + points = 10 + x, y, z = get_random_points(points) + + # Create custom float dimensions with small differences + custom_dim1_a = np.random.rand(points) * 100.0 + custom_dim1_b = np.random.rand(points) * 100.0 + custom_dim1_c = np.random.rand(points) * 100.0 + + custom_dim2_a = custom_dim1_a + 0.05 # Within 0.1 tolerance + custom_dim2_b = custom_dim1_b + 0.03 # Within 0.1 tolerance + custom_dim2_c = custom_dim1_c + 0.2 # Outside 0.1 tolerance + + # Create files with custom dimensions + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp1: + las1 = laspy.create(point_format=3, file_version="1.4") + las1.x = x + las1.y = y + las1.z = z + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_c", type=np.float64)) + las1.custom_float_a = custom_dim1_a + las1.custom_float_b = custom_dim1_b + las1.custom_float_c = custom_dim1_c + las1.write(temp1.name) + file1 = Path(temp1.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp2: + las2 = laspy.create(point_format=3, file_version="1.4") + las2.x = x + las2.y = y + las2.z = z + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_c", type=np.float64)) + las2.custom_float_a = custom_dim2_a + las2.custom_float_b = custom_dim2_b + las2.custom_float_c = custom_dim2_c + las2.write(temp2.name) + file2 = Path(temp2.name) + + try: + # Test with precision for custom_float_a and custom_float_b (should pass) + precision = {"custom_float_a": 0.1, "custom_float_b": 0.1} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float_a", "custom_float_b"], precision) + assert result is True, "custom_float_a and custom_float_b within tolerance should pass" + + # Test with precision for all three (should fail because custom_float_c is outside tolerance) + precision = {"custom_float_a": 0.1, "custom_float_b": 0.1, "custom_float_c": 0.1} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float_a", "custom_float_b", "custom_float_c"], precision) + assert result is False, "custom_float_c outside tolerance should cause failure" + + # Test with larger precision for custom_float_c (should pass) + precision = {"custom_float_a": 0.1, "custom_float_b": 0.1, "custom_float_c": 0.3} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float_a", "custom_float_b", "custom_float_c"], precision) + assert result is True, "All dimensions within their respective tolerances should pass" + finally: + # Clean up + file1.unlink() + file2.unlink() + + +def test_precision_partial_dimensions(): + """Test that precision only applies to specified dimensions""" + points = 10 + x, y, z = get_random_points(points) + + # Create custom float dimensions with small differences + custom_dim1_a = np.random.rand(points) * 100.0 + custom_dim1_b = np.random.rand(points) * 100.0 + + custom_dim2_a = custom_dim1_a + 0.05 # Within 0.1 tolerance + custom_dim2_b = custom_dim1_b + 0.05 # Within 0.1 tolerance but no precision specified + + # Create files with custom dimensions + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp1: + las1 = laspy.create(point_format=3, file_version="1.4") + las1.x = x + las1.y = y + las1.z = z + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las1.custom_float_a = custom_dim1_a + las1.custom_float_b = custom_dim1_b + las1.write(temp1.name) + file1 = Path(temp1.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp2: + las2 = laspy.create(point_format=3, file_version="1.4") + las2.x = x + las2.y = y + las2.z = z + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las2.custom_float_a = custom_dim2_a + las2.custom_float_b = custom_dim2_b + las2.write(temp2.name) + file2 = Path(temp2.name) + + try: + # Test with precision only for custom_float_a (custom_float_b should use exact comparison) + precision = {"custom_float_a": 0.1} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float_a", "custom_float_b"], precision) + assert result is False, "custom_float_b without precision should use exact comparison and fail" + + # Test with precision for both + precision = {"custom_float_a": 0.1, "custom_float_b": 0.1} + result, _, _ = compare_las_dimensions(file1, file2, ["custom_float_a", "custom_float_b"], precision) + assert result is True, "Both dimensions with precision should pass" + finally: + # Clean up + file1.unlink() + file2.unlink() + + def test_main_function(): """Test the main function with direct sys.argv""" import sys @@ -282,5 +487,99 @@ def test_main_function(): f.unlink() +def test_main_function_with_precision(): + """Test the main function with precision argument""" + import sys + from io import StringIO + from contextlib import redirect_stdout + + # Test with files having small differences in custom float dimension + points = 10 + x, y, z = get_random_points(points) + custom_dim1 = np.random.rand(points) * 100.0 + custom_dim2 = custom_dim1 + 0.05 # Small difference within 0.1 tolerance + + # Create files with custom dimension + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp1: + las1 = laspy.create(point_format=3, file_version="1.4") + las1.x = x + las1.y = y + las1.z = z + las1.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las1.custom_float = custom_dim1 + las1.write(temp1.name) + file1 = Path(temp1.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp2: + las2 = laspy.create(point_format=3, file_version="1.4") + las2.x = x + las2.y = y + las2.z = z + las2.add_extra_dim(laspy.ExtraBytesParams(name="custom_float", type=np.float64)) + las2.custom_float = custom_dim2 + las2.write(temp2.name) + file2 = Path(temp2.name) + + try: + # Test without precision (should fail) + sys.argv = ["script_name", str(file1), str(file2), "--dimensions", "custom_float"] + with redirect_stdout(StringIO()) as f: + result, _, _ = main() + assert result is False, "Without precision, differences should be detected" + + # Test with precision (should pass) + sys.argv = ["script_name", str(file1), str(file2), "--dimensions", "custom_float", "--precision", "custom_float=0.1"] + with redirect_stdout(StringIO()) as f: + result, _, _ = main() + assert result is True, "With precision, values within tolerance should pass" + + # Test with multiple precision values + custom_dim1_a = np.random.rand(points) * 100.0 + custom_dim1_b = np.random.rand(points) * 100.0 + custom_dim2_a = custom_dim1_a + 0.05 + custom_dim2_b = custom_dim1_b + 0.03 + + # Create new files with multiple custom dimensions + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp3: + las3 = laspy.create(point_format=3, file_version="1.4") + las3.x = x + las3.y = y + las3.z = z + las3.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las3.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las3.custom_float_a = custom_dim1_a + las3.custom_float_b = custom_dim1_b + las3.write(temp3.name) + file3 = Path(temp3.name) + + with tempfile.NamedTemporaryFile(suffix=".las", delete=False) as temp4: + las4 = laspy.create(point_format=3, file_version="1.4") + las4.x = x + las4.y = y + las4.z = z + las4.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_a", type=np.float64)) + las4.add_extra_dim(laspy.ExtraBytesParams(name="custom_float_b", type=np.float64)) + las4.custom_float_a = custom_dim2_a + las4.custom_float_b = custom_dim2_b + las4.write(temp4.name) + file4 = Path(temp4.name) + + sys.argv = ["script_name", str(file3), str(file4), "--dimensions", "custom_float_a", "custom_float_b", + "--precision", "custom_float_a=0.1", "custom_float_b=0.1"] + with redirect_stdout(StringIO()) as f: + result, _, _ = main() + assert result is True, "Multiple precision values should work" + + # Clean up additional files + file3.unlink() + file4.unlink() + + finally: + # Clean up + for f in [file1, file2]: + if f.exists(): + f.unlink() + + if __name__ == "__main__": pytest.main() diff --git a/test/test_unlock.py b/test/test_unlock.py index ecae35d..c41d4f8 100644 --- a/test/test_unlock.py +++ b/test/test_unlock.py @@ -4,7 +4,7 @@ import laspy import pytest -from pdaltools.color import color +from pdaltools.color import color_from_stream from pdaltools.las_info import las_info_metadata from pdaltools.unlock_file import copy_and_hack_decorator, unlock_file @@ -39,7 +39,7 @@ def test_copy_and_hack_decorator_color(): LAS_FILE = os.path.join(TMPDIR, "test_pdalfail_0643_6319_LA93_IGN69.las") # Color works only when an epsg is present in the header or as a parameter - color(LAZ_FILE, LAS_FILE, "2154", 1) + color_from_stream(LAZ_FILE, LAS_FILE, "2154", 1) las = laspy.read(LAS_FILE) print(las.header)