Skip to content

points2prints

Python package for the points_to_prints project.

Modules:

  • bd_topo –

    Specific to the BD TOPO dataset.

  • lidar_hd –

    Specific to the LiDAR HD dataset.

  • main –

    Main entry point for the CLI application.

  • outline –

    Process the initial outlines for the pipeline.

  • pipeline –

    Run the pipeline with the expected structure, using LiDAR HD and BD TOPO as the two inputs.

  • point_cloud –

    Process the point clouds.

  • polygon_deformation –

    Experiments with the polygon deformation algorithm and visualisation of its behaviour on toy datasets.

  • roof –

    Create the roofs.

  • utils –

    Generic utilities

  • validation –

    Preparation of the validation dataset and metrics utilities.

bd_topo

Specific to the BD TOPO dataset.

Modules:

Functions:

convert_bd_topo_implementation

convert_bd_topo_implementation(input_path: Path, output_path: Path, input_output: InputOutput)

Convert a BD TOPO file to Parquet format.

Parameters:

  • input_path

    (Path) –

    Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).

  • output_path

    (Path) –

    Path to the output parquet file.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/bd_topo/convert.py
def convert_bd_topo_implementation(
    input_path: Path,
    output_path: Path,
    input_output: InputOutput,
):
    """Convert a BD TOPO file to Parquet format.

    Parameters
    ----------
    input_path : Path
        Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).
    output_path : Path
        Path to the output parquet file.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Converting BD TOPO to Parquet"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[input_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create a temporary directory to store intermediate files
    logging.info(f"Creating temporary directory for processing {input_path.name}.")
    with TemporaryDirectory() as temp_dir:
        temp_dir_path = Path(temp_dir)

        # Extract the buildings from the BD TOPO file
        logging.info(f"Extracting buildings from {input_path.name}.")
        sql_query = f"SELECT ST_ForcePolygonCCW({INITIAL_GEOMETRY_COLUMN_NAME}) AS {FINAL_GEOMETRY_COLUMN_NAME}, * FROM batiment"
        extracted_file_path = temp_dir_path / "extracted.gpkg"
        extraction_command = [
            "gdal",
            "vector",
            "sql",
            "-i",
            str(input_path),
            "-o",
            str(extracted_file_path),
            "--output-layer",
            "buildings",
            "--sql",
            sql_query,
        ]

        return_code = run_command_with_tqdm_logging(extraction_command, display=True)
        if return_code != 0:
            logging.error(f"Failed to extract buildings from {input_path.name}.")
        else:
            logging.info(f"Successfully extracted buildings from {input_path.name}.")

        # Add a column with the integer part of the building ID
        logging.info(f"Processing extracted data from {input_path.name}.")
        db_path = temp_dir_path / "duckdb.duckdb"
        processed_file_path = temp_dir_path / "processed.parquet"
        with DuckDBConnectionManager(db_path) as con:
            con.create_schema(SCHEMA_NAME)

            read_query = f"""
                CREATE OR REPLACE TABLE {SCHEMA_NAME}.{TABLE_NAME} AS (
                    SELECT CAST(array_slice(cleabs, 9, -1) AS INT64) AS cleabs_int, *
                    FROM '{extracted_file_path}'
                );
            """

            con.execute(read_query)

            con.export_parquet(
                schema_name=SCHEMA_NAME,
                table_name=TABLE_NAME,
                geom_col_name=FINAL_GEOMETRY_COLUMN_NAME,
                output_file=processed_file_path,
            )

        # Format the file properly with gpio
        logging.info(
            f"Formatting the processed data from {input_path.name} to Parquet."
        )
        format_command = [
            "gpio",
            "convert",
            str(processed_file_path),
            str(output_path),
        ]

        return_code = run_command_with_tqdm_logging(format_command, display=True)
        if return_code != 0:
            logging.error(
                f"Failed to format the data from {input_path.name} to Parquet."
            )
        else:
            logging.info(
                f"Successfully formatted the data from {input_path.name} to Parquet."
            )

convert

Functions:

convert_bd_topo_call

convert_bd_topo_call(input_path: Path, output_path: Path, input_output: InputOutput, verbose: Verbose)

Convert a BD TOPO file to Parquet format.

Parameters:

  • input_path
    (Path) –

    Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).

  • output_path
    (Path) –

    Path to the output parquet file.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/bd_topo/convert.py
def convert_bd_topo_call(
    input_path: Path,
    output_path: Path,
    input_output: InputOutput,
    verbose: Verbose,
):
    """Convert a BD TOPO file to Parquet format.

    Parameters
    ----------
    input_path : Path
        Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).
    output_path : Path
        Path to the output parquet file.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    """
    with LoggingContext(verbose=verbose):
        convert_bd_topo_implementation(
            input_path=input_path,
            output_path=output_path,
            input_output=input_output,
        )

convert_bd_topo_implementation

convert_bd_topo_implementation(input_path: Path, output_path: Path, input_output: InputOutput)

Convert a BD TOPO file to Parquet format.

Parameters:

  • input_path
    (Path) –

    Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).

  • output_path
    (Path) –

    Path to the output parquet file.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/bd_topo/convert.py
def convert_bd_topo_implementation(
    input_path: Path,
    output_path: Path,
    input_output: InputOutput,
):
    """Convert a BD TOPO file to Parquet format.

    Parameters
    ----------
    input_path : Path
        Path to the input BD TOPO file containing all the layers including the buildings layer (e.g., a .gpkg file).
    output_path : Path
        Path to the output parquet file.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Converting BD TOPO to Parquet"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[input_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create a temporary directory to store intermediate files
    logging.info(f"Creating temporary directory for processing {input_path.name}.")
    with TemporaryDirectory() as temp_dir:
        temp_dir_path = Path(temp_dir)

        # Extract the buildings from the BD TOPO file
        logging.info(f"Extracting buildings from {input_path.name}.")
        sql_query = f"SELECT ST_ForcePolygonCCW({INITIAL_GEOMETRY_COLUMN_NAME}) AS {FINAL_GEOMETRY_COLUMN_NAME}, * FROM batiment"
        extracted_file_path = temp_dir_path / "extracted.gpkg"
        extraction_command = [
            "gdal",
            "vector",
            "sql",
            "-i",
            str(input_path),
            "-o",
            str(extracted_file_path),
            "--output-layer",
            "buildings",
            "--sql",
            sql_query,
        ]

        return_code = run_command_with_tqdm_logging(extraction_command, display=True)
        if return_code != 0:
            logging.error(f"Failed to extract buildings from {input_path.name}.")
        else:
            logging.info(f"Successfully extracted buildings from {input_path.name}.")

        # Add a column with the integer part of the building ID
        logging.info(f"Processing extracted data from {input_path.name}.")
        db_path = temp_dir_path / "duckdb.duckdb"
        processed_file_path = temp_dir_path / "processed.parquet"
        with DuckDBConnectionManager(db_path) as con:
            con.create_schema(SCHEMA_NAME)

            read_query = f"""
                CREATE OR REPLACE TABLE {SCHEMA_NAME}.{TABLE_NAME} AS (
                    SELECT CAST(array_slice(cleabs, 9, -1) AS INT64) AS cleabs_int, *
                    FROM '{extracted_file_path}'
                );
            """

            con.execute(read_query)

            con.export_parquet(
                schema_name=SCHEMA_NAME,
                table_name=TABLE_NAME,
                geom_col_name=FINAL_GEOMETRY_COLUMN_NAME,
                output_file=processed_file_path,
            )

        # Format the file properly with gpio
        logging.info(
            f"Formatting the processed data from {input_path.name} to Parquet."
        )
        format_command = [
            "gpio",
            "convert",
            str(processed_file_path),
            str(output_path),
        ]

        return_code = run_command_with_tqdm_logging(format_command, display=True)
        if return_code != 0:
            logging.error(
                f"Failed to format the data from {input_path.name} to Parquet."
            )
        else:
            logging.info(
                f"Successfully formatted the data from {input_path.name} to Parquet."
            )

lidar_hd

Specific to the LiDAR HD dataset.

Modules:

Functions:

download_lidar_hd_data_implementation

download_lidar_hd_data_implementation(xmin: int, xmax: int, ymin: int, ymax: int, output_path_template: Path, input_output: InputOutput, concurrency: Optional[int])

Download LiDAR HD data for a specified bounding box.

Parameters:

  • xmin

    (int) –

    Minimum X coordinate of the requested bounding box in EPSG:2154.

  • xmax

    (int) –

    Maximum X coordinate of the requested bounding box in EPSG:2154.

  • ymin

    (int) –

    Minimum Y coordinate of the requested bounding box in EPSG:2154.

  • ymax

    (int) –

    Maximum Y coordinate of the requested bounding box in EPSG:2154.

  • output_path_template

    (Path) –

    Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

  • concurrency

    (Optional[int]) –

    Maximum number of concurrent download workers.

Source code in points2prints/lidar_hd/download.py
def download_lidar_hd_data_implementation(
    xmin: int,
    xmax: int,
    ymin: int,
    ymax: int,
    output_path_template: Path,
    input_output: InputOutput,
    concurrency: Optional[int],
):
    """Download LiDAR HD data for a specified bounding box.

    Parameters
    ----------
    xmin : int
        Minimum X coordinate of the requested bounding box in EPSG:2154.
    xmax : int
        Maximum X coordinate of the requested bounding box in EPSG:2154.
    ymin : int
        Minimum Y coordinate of the requested bounding box in EPSG:2154.
    ymax : int
        Maximum Y coordinate of the requested bounding box in EPSG:2154.
    output_path_template : Path
        Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.
    input_output: InputOutput
        The handler for input and output file issues.
    concurrency: Optional[int]
        Maximum number of concurrent download workers.
    """
    asyncio.run(
        download_lidar_hd_data_async(
            xmin=xmin,
            xmax=xmax,
            ymin=ymin,
            ymax=ymax,
            output_path_template=output_path_template,
            input_output=input_output,
            concurrency=concurrency,
        )
    )

download

Functions:

collect_existing_tiles async

collect_existing_tiles(tiles: List[Box2154], concurrency: int = DEFAULT_CONCURRENCY) -> Dict[str, Optional[str]]

Collect the best matching WFS tile for each requested tile box.

Parameters:

  • tiles
    (List[Box2154]) –

    Requested tiles in EPSG:2154.

  • concurrency
    (int, default: DEFAULT_CONCURRENCY ) –

    Maximum HTTP concurrency for the client.

Returns:

  • dict[str, str | None] –

    Mapping from requested tile name to the matched download URL.

Source code in points2prints/lidar_hd/download.py
async def collect_existing_tiles(
    tiles: List[Box2154],
    concurrency: int = DEFAULT_CONCURRENCY,
) -> Dict[str, Optional[str]]:
    """Collect the best matching WFS tile for each requested tile box.

    Parameters
    ----------
    tiles
        Requested tiles in EPSG:2154.
    concurrency
        Maximum HTTP concurrency for the client.

    Returns
    -------
    dict[str, str | None]
        Mapping from requested tile name to the matched download URL.
    """
    # The WFS endpoint enforces a strict per-second rate limit; querying
    # sequentially avoids silent partial/empty responses.
    semaphore = asyncio.Semaphore(1)
    timeout = httpx.Timeout(timeout=60.0, connect=30.0)
    limits = httpx.Limits(
        max_connections=concurrency, max_keepalive_connections=concurrency
    )
    name_to_url: Dict[str, Optional[str]] = {
        _tile_box_to_name(tile_box): None for tile_box in tiles
    }

    async with httpx.AsyncClient(
        timeout=timeout, limits=limits, headers=STAC_HEADERS
    ) as client:
        with tqdm(total=len(tiles), desc="Checking tiles", unit="tile") as pbar:
            for i, tile_box in enumerate(tiles):
                logging.debug(f"Checking existence of tile: {tile_box}")
                tile_results = await _fetch_tiles_by_bbox(client, semaphore, tile_box)
                requested_tile_name = _tile_box_to_name(tile_box)

                matched_url: Optional[str] = None
                matched_tile_name: Optional[str] = None
                best_overlap = -1
                for tile_name, tile_url in tile_results:
                    candidate_box = _nw_coordinates_to_tile_box(tile_name)
                    if candidate_box is None:
                        continue

                    overlap = _intersection_area(tile_box, candidate_box) / 1000000
                    # We only accept a match if the overlap is greater than 90% of the requested tile area
                    if overlap > best_overlap and overlap > 0.9:
                        best_overlap = overlap
                        matched_url = tile_url
                        matched_tile_name = tile_name

                if matched_url is None and tile_results:
                    logging.debug(
                        f"No overlapping tile match for {requested_tile_name}; available tiles: {[tile_name for tile_name, _ in tile_results]}"
                    )
                elif (
                    matched_tile_name is not None
                    and matched_tile_name != requested_tile_name
                ):
                    logging.debug(
                        f"Requested {requested_tile_name} matched WFS tile {matched_tile_name} with overlap {best_overlap}"
                    )

                name_to_url[requested_tile_name] = matched_url

                pbar.update(1)
                if i < len(tiles) - 1:
                    await asyncio.sleep(WFS_MIN_QUERY_INTERVAL_SECONDS)

    return name_to_url

download_lidar_hd_call

Download LiDAR HD data for a specified bounding box.

Parameters:

  • xmin
    (int) –

    Minimum X coordinate of the requested bounding box in EPSG:2154.

  • xmax
    (int) –

    Maximum X coordinate of the requested bounding box in EPSG:2154.

  • ymin
    (int) –

    Minimum Y coordinate of the requested bounding box in EPSG:2154.

  • ymax
    (int) –

    Maximum Y coordinate of the requested bounding box in EPSG:2154.

  • output_path_template
    (Path) –

    Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

  • concurrency
    (Optional[int]) –

    Maximum number of concurrent download workers.

Source code in points2prints/lidar_hd/download.py
def download_lidar_hd_call(
    xmin: int,
    xmax: int,
    ymin: int,
    ymax: int,
    output_path_template: Path,
    input_output: InputOutput,
    verbose: Verbose,
    concurrency: Optional[int],
):
    """Download LiDAR HD data for a specified bounding box.

    Parameters
    ----------
    xmin : int
        Minimum X coordinate of the requested bounding box in EPSG:2154.
    xmax : int
        Maximum X coordinate of the requested bounding box in EPSG:2154.
    ymin : int
        Minimum Y coordinate of the requested bounding box in EPSG:2154.
    ymax : int
        Maximum Y coordinate of the requested bounding box in EPSG:2154.
    output_path_template : Path
        Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    concurrency: Optional[int]
        Maximum number of concurrent download workers.
    """
    with LoggingContext(verbose=verbose):
        download_lidar_hd_data_implementation(
            xmin=xmin,
            xmax=xmax,
            ymin=ymin,
            ymax=ymax,
            output_path_template=output_path_template,
            input_output=input_output,
            concurrency=concurrency,
        )

download_lidar_hd_data_async async

download_lidar_hd_data_async(xmin: int, xmax: int, ymin: int, ymax: int, output_path_template: Path, input_output: InputOutput, concurrency: Optional[int]) -> None

Download LIDAR HD tiles covering a requested EPSG:2154 bounding box.

Parameters:

  • xmin
    (int) –

    Minimum X coordinate of the requested bounding box in EPSG:2154.

  • xmax
    (int) –

    Maximum X coordinate of the requested bounding box in EPSG:2154.

  • ymin
    (int) –

    Minimum Y coordinate of the requested bounding box in EPSG:2154.

  • ymax
    (int) –

    Maximum Y coordinate of the requested bounding box in EPSG:2154.

  • output_path_template
    (Path) –

    Output path template supporting the tile placeholders.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • concurrency
    (Optional[int]) –

    Maximum HTTP concurrency for tile discovery and downloading.

Source code in points2prints/lidar_hd/download.py
async def download_lidar_hd_data_async(
    xmin: int,
    xmax: int,
    ymin: int,
    ymax: int,
    output_path_template: Path,
    input_output: InputOutput,
    concurrency: Optional[int],
) -> None:
    """Download LIDAR HD tiles covering a requested EPSG:2154 bounding box.

    Parameters
    ----------
    xmin : int
        Minimum X coordinate of the requested bounding box in EPSG:2154.
    xmax : int
        Maximum X coordinate of the requested bounding box in EPSG:2154.
    ymin : int
        Minimum Y coordinate of the requested bounding box in EPSG:2154.
    ymax : int
        Maximum Y coordinate of the requested bounding box in EPSG:2154.
    output_path_template : Path
        Output path template supporting the tile placeholders.
    input_output: InputOutput
        The handler for input and output file issues.
    concurrency : Optional[int]
        Maximum HTTP concurrency for tile discovery and downloading.
    """
    bbox = Box2154(Point2154(xmin, ymin), Point2154(xmax, ymax))
    tiles_boxes = bbox.get_tiles_boxes()

    logging.info(f"Generated {len(tiles_boxes)} tiles for the specified bounding box.")
    logging.debug("Tiles boxes:")
    for tile_box in tiles_boxes:
        logging.debug(
            f"  EPSG:2154({tile_box.p_min.x}, {tile_box.p_min.y}) -> EPSG:4326({tile_box.p_min.lon_4326}, {tile_box.p_min.lat_4326})"
        )

    # Discover WFS tiles intersecting the requested area.
    if concurrency is None:
        concurrency = DEFAULT_CONCURRENCY
    name_to_url_with_nones = await collect_existing_tiles(
        tiles=tiles_boxes, concurrency=concurrency
    )
    name_to_url = {
        name: url for name, url in name_to_url_with_nones.items() if url is not None
    }
    logging.info(
        f"Found {len(name_to_url)} existing tiles out of {len(name_to_url_with_nones)} candidates."
    )

    # Build the download path map, skipping files that already exist when requested.
    name_to_path: Dict[str, Path] = {}
    for tile_box in tiles_boxes:
        tile_name = _tile_box_to_name(tile_box)
        tile_url = name_to_url.get(tile_name)
        if tile_url is None:
            continue

        file_name = tile_url.split("/")[-1]

        output_path_template_str = str(output_path_template)
        output_path = output_path_template_str.format(
            xmin=tile_box.p_min.x,
            ymin=tile_box.p_min.y,
            xmax=tile_box.p_max.x,
            ymax=tile_box.p_max.y,
            xmin_km=tile_box.p_min.x // 1000,
            ymin_km=tile_box.p_min.y // 1000,
            xmax_km=tile_box.p_max.x // 1000,
            ymax_km=tile_box.p_max.y // 1000,
            file_name=file_name,
        )

        name_to_path[tile_name] = Path(output_path)

    # Handle input/output
    output_actions = input_output.get_output_actions(
        message_prefix="Downloaded LiDAR HD tiles",
        output_files=list(map(lambda path: [path], name_to_path.values())),
    )

    initial_keys = list(name_to_path.keys())
    for output_action, name in zip(output_actions, initial_keys):
        if output_action == OutputActionEnum.SKIP:
            name_to_url.pop(name)
            name_to_path.pop(name)
        elif output_action == OutputActionEnum.ERROR:
            raise Exception(f"Unexpected error when handling input/output for {name}")

    # Return if all tiles have already been downloaded
    if len(name_to_url) == 0:
        logging.info("No tiles to download after filtering. Exiting.")
        return

    # Download the tiles in parallel with retries and progress bars
    downloaded_count, failures, downloaded_files = await download_tiles(
        name_to_url,
        name_to_path,
        concurrency=concurrency,
    )
    logging.info(f"Downloaded {downloaded_count}/{len(name_to_url)} files.")

    # Log any download failures
    if failures:
        logging.error(f"{len(failures)} downloads failed:")
        for tile_name, error in failures:
            logging.error(f"  - {tile_name}: {error}")

    # Validate the downloaded files using PDAL and log results
    valid_count, invalid_files = validate_downloaded_files(downloaded_files)
    logging.info(f"Valid files: {valid_count}/{len(downloaded_files)}")
    if invalid_files:
        logging.error(f"{len(invalid_files)} invalid files:")
        for file_name, error in invalid_files:
            logging.error(f"  - {file_name}: {error}")

download_lidar_hd_data_implementation

download_lidar_hd_data_implementation(xmin: int, xmax: int, ymin: int, ymax: int, output_path_template: Path, input_output: InputOutput, concurrency: Optional[int])

Download LiDAR HD data for a specified bounding box.

Parameters:

  • xmin
    (int) –

    Minimum X coordinate of the requested bounding box in EPSG:2154.

  • xmax
    (int) –

    Maximum X coordinate of the requested bounding box in EPSG:2154.

  • ymin
    (int) –

    Minimum Y coordinate of the requested bounding box in EPSG:2154.

  • ymax
    (int) –

    Maximum Y coordinate of the requested bounding box in EPSG:2154.

  • output_path_template
    (Path) –

    Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • concurrency
    (Optional[int]) –

    Maximum number of concurrent download workers.

Source code in points2prints/lidar_hd/download.py
def download_lidar_hd_data_implementation(
    xmin: int,
    xmax: int,
    ymin: int,
    ymax: int,
    output_path_template: Path,
    input_output: InputOutput,
    concurrency: Optional[int],
):
    """Download LiDAR HD data for a specified bounding box.

    Parameters
    ----------
    xmin : int
        Minimum X coordinate of the requested bounding box in EPSG:2154.
    xmax : int
        Maximum X coordinate of the requested bounding box in EPSG:2154.
    ymin : int
        Minimum Y coordinate of the requested bounding box in EPSG:2154.
    ymax : int
        Maximum Y coordinate of the requested bounding box in EPSG:2154.
    output_path_template : Path
        Path to save the downloaded files. The path can contain the values {xmin}, {ymin}, {xmax}, {ymax}, {file_name} which will be replaced with the corresponding values. The values also have their kilometre equivalents {xmin_km}, {ymin_km}, {xmax_km}, {ymax_km}.
    input_output: InputOutput
        The handler for input and output file issues.
    concurrency: Optional[int]
        Maximum number of concurrent download workers.
    """
    asyncio.run(
        download_lidar_hd_data_async(
            xmin=xmin,
            xmax=xmax,
            ymin=ymin,
            ymax=ymax,
            output_path_template=output_path_template,
            input_output=input_output,
            concurrency=concurrency,
        )
    )

download_tiles async

download_tiles(name_to_url: Dict[str, str], name_to_path: Dict[str, Path], concurrency: int = DEFAULT_CONCURRENCY) -> Tuple[int, List[Tuple[str, str]], List[Path]]

Download the selected tiles in parallel.

Parameters:

  • name_to_url
    (Dict[str, str]) –

    Mapping from tile name to download URL.

  • name_to_path
    (Dict[str, Path]) –

    Mapping from tile name to output path.

  • concurrency
    (int, default: DEFAULT_CONCURRENCY ) –

    Maximum number of concurrent download workers.

Returns:

Source code in points2prints/lidar_hd/download.py
async def download_tiles(
    name_to_url: Dict[str, str],
    name_to_path: Dict[str, Path],
    concurrency: int = DEFAULT_CONCURRENCY,
) -> Tuple[int, List[Tuple[str, str]], List[Path]]:
    """Download the selected tiles in parallel.

    Parameters
    ----------
    name_to_url
        Mapping from tile name to download URL.
    name_to_path
        Mapping from tile name to output path.
    concurrency
        Maximum number of concurrent download workers.

    Returns
    -------
    tuple[int, list[tuple[str, str]], list[pathlib.Path]]
        Downloaded count, failures, and successfully written files.
    """
    timeout = httpx.Timeout(timeout=None, connect=30.0, write=60.0)
    limits = httpx.Limits(
        max_connections=concurrency, max_keepalive_connections=concurrency
    )
    failures: List[Tuple[str, str]] = []
    downloaded_count = 0
    downloaded_files: List[Path] = []
    progress_lock = asyncio.Lock()

    ordered_tiles = list(name_to_url.items())
    worker_count = min(concurrency, len(ordered_tiles)) if ordered_tiles else 0

    queue: asyncio.Queue[Optional[Tuple[str, str]]] = asyncio.Queue()
    for item in ordered_tiles:
        queue.put_nowait(item)
    for _ in range(worker_count):
        queue.put_nowait(None)

    async with httpx.AsyncClient(
        timeout=timeout, limits=limits, headers=STAC_HEADERS
    ) as client:
        results: List[Tuple[str, bool, Optional[str], Optional[Path]]] = []
        with tqdm(
            total=len(ordered_tiles),
            desc="Downloading tiles",
            unit="file",
            position=0,
            leave=True,
            dynamic_ncols=True,
        ) as overall_bar:
            workers = [
                asyncio.create_task(
                    _download_worker(
                        worker_id=worker_id,
                        queue=queue,
                        client=client,
                        name_to_path=name_to_path,
                        progress_lock=progress_lock,
                        overall_bar=overall_bar,
                        results=results,
                    )
                )
                for worker_id in range(worker_count)
            ]

            await queue.join()
            await asyncio.gather(*workers)

        for tile_name, success, error, file_path in results:
            if success:
                downloaded_count += 1
                if file_path is not None:
                    downloaded_files.append(file_path)
            else:
                failures.append((tile_name, error or "unknown error"))

    with suppress(Exception):
        tqdm.write("")

    return downloaded_count, failures, downloaded_files

validate_downloaded_files

validate_downloaded_files(downloaded_files: List[Path]) -> Tuple[int, List[Tuple[str, str]]]

Validate downloaded LAZ files using PDAL.

Parameters:

  • downloaded_files
    (List[Path]) –

    Paths to files that were downloaded successfully.

Returns:

Source code in points2prints/lidar_hd/download.py
def validate_downloaded_files(
    downloaded_files: List[Path],
) -> Tuple[int, List[Tuple[str, str]]]:
    """Validate downloaded LAZ files using PDAL.

    Parameters
    ----------
    downloaded_files
        Paths to files that were downloaded successfully.

    Returns
    -------
    tuple[int, list[tuple[str, str]]]
        Number of valid files and the invalid file/error pairs.
    """
    valid_count = 0
    invalid_files: List[Tuple[str, str]] = []

    if not downloaded_files:
        return valid_count, invalid_files

    with tqdm(
        total=len(downloaded_files), desc="Validating files", unit="file"
    ) as pbar:
        for file_path in downloaded_files:
            is_valid = False
            error_message = "unknown validation error"

            proc = subprocess.run(
                ["pdal", "info", "--summary", str(file_path)],
                capture_output=True,
                text=True,
            )
            if proc.returncode == 0:
                is_valid = True
            else:
                stderr_text = proc.stderr.strip()
                error_message = stderr_text if stderr_text else "pdal info failed"

            if is_valid:
                valid_count += 1
            else:
                invalid_files.append((file_path.name, error_message))

            pbar.update(1)

    return valid_count, invalid_files

main

Main entry point for the CLI application. This module defines the main Typer app and registers all sub-apps for different functionalities.

outline

Process the initial outlines for the pipeline.

Modules:

intersections

Functions:

intersections_call

summary

Parameters:

  • bd_topo_file
    (Path) –

    Input BD TOPO file (Parquet) containing the building geometries.

  • output_edges_file
    (Path) –

    Output Parquet file where the edges will be saved.

  • output_intersections_file
    (Path) –

    Output Parquet file where the intersections will be saved.

  • output_building_groups_file
    (Path) –

    Output Parquet file where the building groups will be saved.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/outline/intersections.py
def intersections_call(
    bd_topo_file: Path,
    output_edges_file: Path,
    output_intersections_file: Path,
    output_building_groups_file: Path,
    input_output: InputOutput,
    verbose: Verbose,
):
    """_summary_

    Parameters
    ----------
    bd_topo_file : Path
        Input BD TOPO file (Parquet) containing the building geometries.
    output_edges_file : Path
        Output Parquet file where the edges will be saved.
    output_intersections_file : Path
        Output Parquet file where the intersections will be saved.
    output_building_groups_file : Path
        Output Parquet file where the building groups will be saved.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    """
    with LoggingContext(verbose=verbose):
        intersections_implementation(
            bd_topo_file=bd_topo_file,
            output_edges_file=output_edges_file,
            output_intersections_file=output_intersections_file,
            output_building_groups_file=output_building_groups_file,
            input_output=input_output,
        )

pipeline

Run the pipeline with the expected structure, using LiDAR HD and BD TOPO as the two inputs.

Modules:

Functions:

compute_metrics_implementation

Compare the pipeline output to a validation dataset.

Parameters:

  • validation_dataset_indiv_file

    (Path) –

    Path to the individual building validation dataset (Parquet file).

  • validation_dataset_aggreg_file

    (Path) –

    Path to the aggregated building validation dataset (Parquet file).

  • bd_topo_file

    (Path) –

    Path to the BD TOPO polygon dataset to compare.

  • tiles_dirs

    (List[Path]) –

    List of tile directories containing the pipeline output to compare.

  • output_comparison_dir

    (Path) –

    Directory where the comparison results will be saved.

  • output_format

    (str) –

    Format to save the comparison results (e.g., 'parquet', 'csv', 'json').

  • id_column

    (str) –

    Name of the column containing the building IDs in the datasets.

  • spacing_m

    (float) –

    Spacing in meters to use for the comparison.

  • keep_columns

    (Optional[List[str]]) –

    List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

  • num_workers

    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def compute_metrics_implementation(
    validation_dataset_indiv_file: Path,
    validation_dataset_aggreg_file: Path,
    bd_topo_file: Path,
    tiles_dirs: List[Path],
    output_comparison_dir: Path,
    output_format: str,
    id_column: str,
    spacing_m: float,
    keep_columns: Optional[List[str]],
    input_output: InputOutput,
    num_workers: Optional[int],
):
    """Compare the pipeline output to a validation dataset.

    Parameters
    ----------
    validation_dataset_indiv_file: Path
        Path to the individual building validation dataset (Parquet file).
    validation_dataset_aggreg_file: Path
        Path to the aggregated building validation dataset (Parquet file).
    bd_topo_file: Path
        Path to the BD TOPO polygon dataset to compare.
    tiles_dirs: List[Path]
        List of tile directories containing the pipeline output to compare.
    output_comparison_dir: Path
        Directory where the comparison results will be saved.
    output_format: str
        Format to save the comparison results (e.g., 'parquet', 'csv', 'json').
    id_column: str
        Name of the column containing the building IDs in the datasets.
    spacing_m: float
        Spacing in meters to use for the comparison.
    keep_columns: Optional[List[str]]
         List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.
    input_output: InputOutput
        The handler for input and output file issues.
    num_workers: Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """

    message_prefix = "Computing metrics for pipeline output"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[
            validation_dataset_indiv_file,
            validation_dataset_aggreg_file,
            bd_topo_file,
        ],
    )

    # Check the output format
    valid_formats = ["parquet", "csv", "json"]
    if output_format not in valid_formats:
        logging.error(
            f"Invalid output format: {output_format}. Supported formats are: {', '.join(valid_formats)}."
        )
        raise ValueError(
            f"Invalid output format: {output_format}. Supported formats are: {', '.join(valid_formats)}."
        )

    comparison_jobs: List[
        Tuple[
            Path, Path, Path, Path, Path, str, float, Optional[List[str]], InputOutput
        ]
    ] = []

    scored_files: List[Path] = [bd_topo_file]
    output_indiv_files: List[Path] = [
        output_comparison_dir / f"bd_topo-indiv.{output_format}"
    ]
    output_aggreg_files: List[Path] = [
        output_comparison_dir / f"bd_topo-aggreg.{output_format}"
    ]

    for tile_dir in tiles_dirs:
        tile_name = tile_dir.name
        for roofprints_file in (tile_dir / "roofprints").glob("*.parquet"):
            roofprint_name = roofprints_file.stem
            scored_files.append(roofprints_file)
            output_indiv_files.append(
                output_comparison_dir
                / f"{tile_name}-{roofprint_name}-indiv.{output_format}"
            )
            output_aggreg_files.append(
                output_comparison_dir
                / f"{tile_name}-{roofprint_name}-aggreg.{output_format}"
            )

    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=scored_files,
    )
    output_actions = input_output.get_output_actions(
        message_prefix=message_prefix,
        output_files=list(zip(output_indiv_files, output_aggreg_files)),
    )
    if all(action.is_skip() for action in output_actions):
        logging.info("All comparison jobs are skipped.")
        return

    for output_action, scored_file, output_indiv_file, output_aggreg_file in zip(
        output_actions, scored_files, output_indiv_files, output_aggreg_files
    ):
        output_action.raise_if_error()
        if output_action.is_skip():
            logging.info("Skipping comparison for %s.", scored_file)
            continue
        comparison_jobs.append(
            (
                scored_file,
                output_indiv_file,
                output_aggreg_file,
                validation_dataset_indiv_file,
                validation_dataset_aggreg_file,
                id_column,
                spacing_m,
                keep_columns,
                input_output,
            )
        )

    if not comparison_jobs:
        logging.info("No comparison jobs to run.")
        return

    logging.info(f"Comparing {len(comparison_jobs)} polygon datasets in parallel...")

    failed_jobs: List[Tuple[Path, Exception]] = []
    with ProcessPoolExecutor(max_workers=num_workers) as executor:
        future_to_job = {
            executor.submit(_compare_polygon_datasets_single_job, job): job
            for job in comparison_jobs
        }
        for future in tqdm(
            as_completed(future_to_job),
            total=len(future_to_job),
            desc="Comparing polygon datasets",
        ):
            job = future_to_job[future]
            scored_file = job[0]
            try:
                future.result()
            except Exception as exc:
                failed_jobs.append((scored_file, exc))
                logging.exception("Comparison failed for %s.", scored_file)

    if failed_jobs:
        failed_names = ", ".join(scored_file.name for scored_file, _ in failed_jobs)
        raise RuntimeError(
            f"{len(failed_jobs)} comparison job(s) failed: {failed_names}"
        )

    logging.info("Comparison jobs completed successfully.")

prepare_bd_topo_implementation

Prepare the BD TOPO data for the pipeline.

Parameters:

  • bd_topo_source_file

    (Path) –

    Path to the source BD TOPO file (e.g., GeoPackage) to prepare.

  • bd_topo_output_dir

    (Path) –

    Directory where the prepared BD TOPO files will be saved.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/pipeline/bd_topo.py
def prepare_bd_topo_implementation(
    bd_topo_source_file: Path,
    bd_topo_output_dir: Path,
    input_output: InputOutput,
):
    """Prepare the BD TOPO data for the pipeline.

    Parameters
    ----------
    bd_topo_source_file : Path
        Path to the source BD TOPO file (e.g., GeoPackage) to prepare.
    bd_topo_output_dir : Path
        Directory where the prepared BD TOPO files will be saved.
    input_output: InputOutput
        The handler for input and output file issues.
    """

    from ..bd_topo import convert_bd_topo_implementation
    from ..outline import intersections_implementation

    full_output_path = bd_topo_output_dir / f"bd_topo.parquet"
    edges_path = bd_topo_output_dir / f"edges.parquet"
    intersections_path = bd_topo_output_dir / f"intersections.parquet"
    groups_path = bd_topo_output_dir / f"building_groups.parquet"

    convert_bd_topo_implementation(
        input_path=bd_topo_source_file,
        output_path=full_output_path,
        input_output=input_output,
    )

    intersections_implementation(
        bd_topo_file=full_output_path,
        output_edges_file=edges_path,
        output_intersections_file=intersections_path,
        output_building_groups_file=groups_path,
        input_output=input_output,
    )

run_pipeline_implementation

Execute the complete pipeline to compute roofprints from LiDAR HD data.

Parameters:

  • bd_topo_dir

    (Path) –

    Directory containing BD TOPO data

  • tile_dir

    (Path) –

    Working directory for intermediate and output files

  • stop_after_roofprints

    (bool) –

    Whether to stop after computing roofprints

  • stop_after_lod22

    (bool) –

    Whether to stop after computing LoD2.2 models

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

  • num_workers

    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def run_pipeline_implementation(
    bd_topo_dir: Path,
    tile_dir: Path,
    stop_after_roofprints: bool,
    stop_after_lod22: bool,
    input_output: InputOutput,
    num_workers: Optional[int],
):
    """Execute the complete pipeline to compute roofprints from LiDAR HD data.

    Parameters
    ----------
    bd_topo_dir : Path
        Directory containing BD TOPO data
    tile_dir : Path
        Working directory for intermediate and output files
    stop_after_roofprints : bool
        Whether to stop after computing roofprints
    stop_after_lod22 : bool
        Whether to stop after computing LoD2.2 models
    input_output: InputOutput
        The handler for input and output file issues.
    num_workers : Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """
    tile_bd_topo_dir = tile_dir / "bd_topo"
    tile_bd_topo_dir.mkdir(exist_ok=True)
    tile_lidar_hd_dir = tile_dir / "lidar_hd"
    tile_lidar_hd_dir.mkdir(exist_ok=True)
    tile_axes_dir = tile_lidar_hd_dir / "axes"
    tile_axes_dir.mkdir(exist_ok=True)
    tile_roofprints_dir = tile_dir / "roofprints"
    tile_roofprints_dir.mkdir(exist_ok=True)
    tile_roof_dir = tile_dir / "roof"
    tile_roof_dir.mkdir(exist_ok=True)
    tile_footprints_dir = tile_dir / "footprints"
    tile_footprints_dir.mkdir(exist_ok=True)

    initial_laz_file = tile_lidar_hd_dir / "lidar_hd.copc.laz"
    if not initial_laz_file.exists():
        initial_laz_file = tile_lidar_hd_dir / "lidar_hd.laz"
    input_output.handle_input(
        message_prefix="Initial LiDAR HD file",
        input_files=[initial_laz_file],
    )

    # Build the C++ tools
    _build_cpp_tool()

    # -------------------------------------------------------------------- #
    #                                BD TOPO                               #
    # -------------------------------------------------------------------- #

    edges_file = bd_topo_dir / "edges.parquet"
    intersections_file = bd_topo_dir / "intersections.parquet"
    groups_file = bd_topo_dir / "building_groups.parquet"
    cropped_edges_file = tile_bd_topo_dir / "edges.parquet"
    cropped_intersections_file = tile_bd_topo_dir / "intersections.parquet"
    cropped_groups_file = tile_bd_topo_dir / "building_groups.parquet"

    _process_bd_topo_data(
        initial_laz_file=initial_laz_file,
        input_edges_file=edges_file,
        input_intersections_file=intersections_file,
        input_building_groups_file=groups_file,
        output_edges_file=cropped_edges_file,
        output_intersections_file=cropped_intersections_file,
        output_building_groups_file=cropped_groups_file,
        input_output=input_output,
    )

    # -------------------------------------------------------------------- #
    #                               LiDAR HD                               #
    # -------------------------------------------------------------------- #

    # Process LiDAR HD: compute inward directions and split by flight strips
    las_with_inwards_roof_file = tile_lidar_hd_dir / "lidar_hd-with_inwards_roof.laz"
    template_flight_strip_file = tile_axes_dir / "axis_#.laz"

    all_flight_strip_files = _process_lidar_hd_data(
        initial_laz_file=initial_laz_file,
        laz_with_inwards_roof_file=las_with_inwards_roof_file,
        template_flight_strip_file=template_flight_strip_file,
        input_output=input_output,
    )

    # Compute trajectories in parallel
    trajectory_files = [
        tile_axes_dir / f"{laz_file.stem}-trajectory.txt"
        for laz_file in all_flight_strip_files
    ]
    successes = _compute_trajectories_parallel(
        flight_strip_files=all_flight_strip_files,
        trajectory_files=trajectory_files,
        input_output=input_output,
        num_workers=num_workers,
    )

    # Only keep files for which the trajectory computation was successful
    all_flight_strip_files = [
        laz for laz, success in zip(all_flight_strip_files, successes) if success
    ]
    trajectory_files = [
        traj for traj, success in zip(trajectory_files, successes) if success
    ]

    # Process flight strips with C++ pipeline in parallel
    distances_files = [
        tile_axes_dir / f"{laz_file.stem}-distances.laz"
        for laz_file in all_flight_strip_files
    ]
    edges_files = [
        tile_axes_dir / f"{laz_file.stem}-edges.laz"
        for laz_file in all_flight_strip_files
    ]
    _compute_distances_and_edges_parallel(
        flight_strip_files=all_flight_strip_files,
        trajectory_files=trajectory_files,
        distances_files=distances_files,
        edges_files=edges_files,
        input_output=input_output,
        num_workers=num_workers,
    )

    # Merge output files
    merged_distances_file = tile_lidar_hd_dir / "merged_distances.laz"
    merged_edges_file = tile_lidar_hd_dir / "merged_edges.laz"
    _merge_output_files(
        distances_files=distances_files,
        edges_files=edges_files,
        merged_distances_file=merged_distances_file,
        merged_edges_file=merged_edges_file,
        input_output=input_output,
    )

    # -------------------------------------------------------------------- #
    #                              Roofprints                              #
    # -------------------------------------------------------------------- #

    n_iterations_roofprints = 3
    roofprints_template_file = tile_roofprints_dir / f"roofprints-{{iteration}}.parquet"

    roofprints_files = _compute_roofprints(
        merged_edges_file=merged_edges_file,
        bd_topo_edges_file=cropped_edges_file,
        bd_topo_intersections_file=cropped_intersections_file,
        output_roofprints_template_file=roofprints_template_file,
        n_iterations=n_iterations_roofprints,
        input_output=input_output,
    )

    if stop_after_roofprints:
        logging.info("Stopping pipeline after roofprints as requested.")
        return

    # ------------------------------------------------------------------------ #
    #                                   Roof                                   #
    # ------------------------------------------------------------------------ #

    # Reclassify the input LiDAR HD file to classify as building all the points which are potentially building points, as roofer only uses those
    reclassified_lidar_hd_file = tile_lidar_hd_dir / "lidar_hd-reclassified.laz"
    classification_mapping_implementation(
        input_file=initial_laz_file,
        output_file=reclassified_lidar_hd_file,
        mapping={1: 6, 64: 6, 65: 6, 67: 6},
        input_output=input_output,
    )

    # Compute the LoD22 models
    lod22_files = []
    for roofprints_file in roofprints_files:
        lod22_file = tile_roof_dir / f"{roofprints_file.stem}-roof.city.json"
        _compute_lod22(
            lidar_hd_reclassified_file=reclassified_lidar_hd_file,
            roofprints_file=roofprints_file,
            output_lod22_cj_file=lod22_file,
            input_output=input_output,
        )
        lod22_files.append(lod22_file)

    if stop_after_lod22:
        logging.info("Stopping pipeline after LoD22 as requested.")
        return

    # ------------------------------------------------------------------------ #
    #                                Footprints                                #
    # ------------------------------------------------------------------------ #

    n_iterations_footprints = 3
    for roofprints_file, lod22_file in zip(roofprints_files, lod22_files):
        output_footprints_template_file = (
            tile_footprints_dir
            / f"{roofprints_file.stem.replace('roofprints', 'footprints')}-{{iteration}}.parquet"
        )
        _compute_footprints(
            lidar_hd_file=initial_laz_file,
            lod22_file=lod22_file,
            roofprints_file=roofprints_file,
            output_footprints_template_file=output_footprints_template_file,
            n_iterations=n_iterations_footprints,
            input_output=input_output,
        )

    logging.info("Pipeline completed successfully.")

bd_topo

Functions:

prepare_bd_topo_call

Prepare the BD TOPO data for the pipeline.

Parameters:

  • bd_topo_source_file
    (Path) –

    Path to the source BD TOPO file (e.g., GeoPackage) to prepare.

  • bd_topo_output_dir
    (Path) –

    Directory where the prepared BD TOPO files will be saved.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/pipeline/bd_topo.py
def prepare_bd_topo_call(
    bd_topo_source_file: Path,
    bd_topo_output_dir: Path,
    input_output: InputOutput,
    verbose: Verbose,
):
    """Prepare the BD TOPO data for the pipeline.

    Parameters
    ----------
    bd_topo_source_file : Path
        Path to the source BD TOPO file (e.g., GeoPackage) to prepare.
    bd_topo_output_dir : Path
        Directory where the prepared BD TOPO files will be saved.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    """

    with LoggingContext(verbose=verbose):
        prepare_bd_topo_implementation(
            bd_topo_source_file=bd_topo_source_file,
            bd_topo_output_dir=bd_topo_output_dir,
            input_output=input_output,
        )

prepare_bd_topo_implementation

Prepare the BD TOPO data for the pipeline.

Parameters:

  • bd_topo_source_file
    (Path) –

    Path to the source BD TOPO file (e.g., GeoPackage) to prepare.

  • bd_topo_output_dir
    (Path) –

    Directory where the prepared BD TOPO files will be saved.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/pipeline/bd_topo.py
def prepare_bd_topo_implementation(
    bd_topo_source_file: Path,
    bd_topo_output_dir: Path,
    input_output: InputOutput,
):
    """Prepare the BD TOPO data for the pipeline.

    Parameters
    ----------
    bd_topo_source_file : Path
        Path to the source BD TOPO file (e.g., GeoPackage) to prepare.
    bd_topo_output_dir : Path
        Directory where the prepared BD TOPO files will be saved.
    input_output: InputOutput
        The handler for input and output file issues.
    """

    from ..bd_topo import convert_bd_topo_implementation
    from ..outline import intersections_implementation

    full_output_path = bd_topo_output_dir / f"bd_topo.parquet"
    edges_path = bd_topo_output_dir / f"edges.parquet"
    intersections_path = bd_topo_output_dir / f"intersections.parquet"
    groups_path = bd_topo_output_dir / f"building_groups.parquet"

    convert_bd_topo_implementation(
        input_path=bd_topo_source_file,
        output_path=full_output_path,
        input_output=input_output,
    )

    intersections_implementation(
        bd_topo_file=full_output_path,
        output_edges_file=edges_path,
        output_intersections_file=intersections_path,
        output_building_groups_file=groups_path,
        input_output=input_output,
    )

pipeline

Functions:

compute_metrics_call

Compare the pipeline output to a validation dataset.

Parameters:

  • validation_dataset_indiv_file
    (Path) –

    Path to the individual building validation dataset (Parquet file).

  • validation_dataset_aggreg_file
    (Path) –

    Path to the aggregated building validation dataset (Parquet file).

  • bd_topo_file
    (Path) –

    Path to the BD TOPO polygon dataset to compare.

  • tiles_dirs
    (List[Path]) –

    List of tile directories containing the pipeline output to compare.

  • output_comparison_dir
    (Path) –

    Directory where the comparison results will be saved.

  • output_format
    (str) –

    Format to save the comparison results (e.g., 'parquet', 'csv', 'json').

  • id_column
    (str) –

    Name of the column containing the building IDs in the datasets.

  • spacing_m
    (float) –

    Spacing in meters to use for the comparison.

  • keep_columns
    (Optional[List[str]]) –

    List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

  • num_workers
    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def compute_metrics_call(
    validation_dataset_indiv_file: Path,
    validation_dataset_aggreg_file: Path,
    bd_topo_file: Path,
    tiles_dirs: List[Path],
    output_comparison_dir: Path,
    output_format: str,
    id_column: str,
    spacing_m: float,
    keep_columns: Optional[List[str]],
    input_output: InputOutput,
    verbose: Verbose,
    num_workers: Optional[int],
):
    """Compare the pipeline output to a validation dataset.

    Parameters
    ----------
    validation_dataset_indiv_file: Path
        Path to the individual building validation dataset (Parquet file).
    validation_dataset_aggreg_file: Path
        Path to the aggregated building validation dataset (Parquet file).
    bd_topo_file: Path
        Path to the BD TOPO polygon dataset to compare.
    tiles_dirs: List[Path]
        List of tile directories containing the pipeline output to compare.
    output_comparison_dir: Path
        Directory where the comparison results will be saved.
    output_format: str
        Format to save the comparison results (e.g., 'parquet', 'csv', 'json').
    id_column: str
        Name of the column containing the building IDs in the datasets.
    spacing_m: float
        Spacing in meters to use for the comparison.
    keep_columns: Optional[List[str]]
        List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    num_workers: Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """

    with LoggingContext(verbose=verbose):
        compute_metrics_implementation(
            validation_dataset_indiv_file=validation_dataset_indiv_file,
            validation_dataset_aggreg_file=validation_dataset_aggreg_file,
            bd_topo_file=bd_topo_file,
            tiles_dirs=tiles_dirs,
            output_comparison_dir=output_comparison_dir,
            output_format=output_format,
            id_column=id_column,
            spacing_m=spacing_m,
            keep_columns=keep_columns,
            input_output=input_output,
            num_workers=num_workers,
        )

compute_metrics_implementation

Compare the pipeline output to a validation dataset.

Parameters:

  • validation_dataset_indiv_file
    (Path) –

    Path to the individual building validation dataset (Parquet file).

  • validation_dataset_aggreg_file
    (Path) –

    Path to the aggregated building validation dataset (Parquet file).

  • bd_topo_file
    (Path) –

    Path to the BD TOPO polygon dataset to compare.

  • tiles_dirs
    (List[Path]) –

    List of tile directories containing the pipeline output to compare.

  • output_comparison_dir
    (Path) –

    Directory where the comparison results will be saved.

  • output_format
    (str) –

    Format to save the comparison results (e.g., 'parquet', 'csv', 'json').

  • id_column
    (str) –

    Name of the column containing the building IDs in the datasets.

  • spacing_m
    (float) –

    Spacing in meters to use for the comparison.

  • keep_columns
    (Optional[List[str]]) –

    List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • num_workers
    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def compute_metrics_implementation(
    validation_dataset_indiv_file: Path,
    validation_dataset_aggreg_file: Path,
    bd_topo_file: Path,
    tiles_dirs: List[Path],
    output_comparison_dir: Path,
    output_format: str,
    id_column: str,
    spacing_m: float,
    keep_columns: Optional[List[str]],
    input_output: InputOutput,
    num_workers: Optional[int],
):
    """Compare the pipeline output to a validation dataset.

    Parameters
    ----------
    validation_dataset_indiv_file: Path
        Path to the individual building validation dataset (Parquet file).
    validation_dataset_aggreg_file: Path
        Path to the aggregated building validation dataset (Parquet file).
    bd_topo_file: Path
        Path to the BD TOPO polygon dataset to compare.
    tiles_dirs: List[Path]
        List of tile directories containing the pipeline output to compare.
    output_comparison_dir: Path
        Directory where the comparison results will be saved.
    output_format: str
        Format to save the comparison results (e.g., 'parquet', 'csv', 'json').
    id_column: str
        Name of the column containing the building IDs in the datasets.
    spacing_m: float
        Spacing in meters to use for the comparison.
    keep_columns: Optional[List[str]]
         List of additional column names to keep in the output comparison results (in addition to the id_column). If None, only the id_column will be kept.
    input_output: InputOutput
        The handler for input and output file issues.
    num_workers: Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """

    message_prefix = "Computing metrics for pipeline output"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[
            validation_dataset_indiv_file,
            validation_dataset_aggreg_file,
            bd_topo_file,
        ],
    )

    # Check the output format
    valid_formats = ["parquet", "csv", "json"]
    if output_format not in valid_formats:
        logging.error(
            f"Invalid output format: {output_format}. Supported formats are: {', '.join(valid_formats)}."
        )
        raise ValueError(
            f"Invalid output format: {output_format}. Supported formats are: {', '.join(valid_formats)}."
        )

    comparison_jobs: List[
        Tuple[
            Path, Path, Path, Path, Path, str, float, Optional[List[str]], InputOutput
        ]
    ] = []

    scored_files: List[Path] = [bd_topo_file]
    output_indiv_files: List[Path] = [
        output_comparison_dir / f"bd_topo-indiv.{output_format}"
    ]
    output_aggreg_files: List[Path] = [
        output_comparison_dir / f"bd_topo-aggreg.{output_format}"
    ]

    for tile_dir in tiles_dirs:
        tile_name = tile_dir.name
        for roofprints_file in (tile_dir / "roofprints").glob("*.parquet"):
            roofprint_name = roofprints_file.stem
            scored_files.append(roofprints_file)
            output_indiv_files.append(
                output_comparison_dir
                / f"{tile_name}-{roofprint_name}-indiv.{output_format}"
            )
            output_aggreg_files.append(
                output_comparison_dir
                / f"{tile_name}-{roofprint_name}-aggreg.{output_format}"
            )

    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=scored_files,
    )
    output_actions = input_output.get_output_actions(
        message_prefix=message_prefix,
        output_files=list(zip(output_indiv_files, output_aggreg_files)),
    )
    if all(action.is_skip() for action in output_actions):
        logging.info("All comparison jobs are skipped.")
        return

    for output_action, scored_file, output_indiv_file, output_aggreg_file in zip(
        output_actions, scored_files, output_indiv_files, output_aggreg_files
    ):
        output_action.raise_if_error()
        if output_action.is_skip():
            logging.info("Skipping comparison for %s.", scored_file)
            continue
        comparison_jobs.append(
            (
                scored_file,
                output_indiv_file,
                output_aggreg_file,
                validation_dataset_indiv_file,
                validation_dataset_aggreg_file,
                id_column,
                spacing_m,
                keep_columns,
                input_output,
            )
        )

    if not comparison_jobs:
        logging.info("No comparison jobs to run.")
        return

    logging.info(f"Comparing {len(comparison_jobs)} polygon datasets in parallel...")

    failed_jobs: List[Tuple[Path, Exception]] = []
    with ProcessPoolExecutor(max_workers=num_workers) as executor:
        future_to_job = {
            executor.submit(_compare_polygon_datasets_single_job, job): job
            for job in comparison_jobs
        }
        for future in tqdm(
            as_completed(future_to_job),
            total=len(future_to_job),
            desc="Comparing polygon datasets",
        ):
            job = future_to_job[future]
            scored_file = job[0]
            try:
                future.result()
            except Exception as exc:
                failed_jobs.append((scored_file, exc))
                logging.exception("Comparison failed for %s.", scored_file)

    if failed_jobs:
        failed_names = ", ".join(scored_file.name for scored_file, _ in failed_jobs)
        raise RuntimeError(
            f"{len(failed_jobs)} comparison job(s) failed: {failed_names}"
        )

    logging.info("Comparison jobs completed successfully.")

run_pipeline_call

Execute the complete pipeline to compute roofprints from LiDAR HD data.

Parameters:

  • bd_topo_dir
    (Path) –

    Directory containing BD TOPO data

  • tile_dir
    (Path) –

    Working directory for intermediate and output files

  • stop_after_roofprints
    (bool) –

    Whether to stop after computing roofprints

  • stop_after_lod22
    (bool) –

    Whether to stop after computing LoD2.2 models

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

  • num_workers
    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def run_pipeline_call(
    bd_topo_dir: Path,
    tile_dir: Path,
    stop_after_roofprints: bool,
    stop_after_lod22: bool,
    input_output: InputOutput,
    verbose: Verbose,
    num_workers: Optional[int],
):
    """Execute the complete pipeline to compute roofprints from LiDAR HD data.

    Parameters
    ----------
    bd_topo_dir: Path
        Directory containing BD TOPO data
    tile_dir: Path
        Working directory for intermediate and output files
    stop_after_roofprints: bool
        Whether to stop after computing roofprints
    stop_after_lod22: bool
        Whether to stop after computing LoD2.2 models
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    num_workers: Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """
    with LoggingContext(verbose=verbose):
        run_pipeline_implementation(
            bd_topo_dir=bd_topo_dir,
            tile_dir=tile_dir,
            stop_after_roofprints=stop_after_roofprints,
            stop_after_lod22=stop_after_lod22,
            input_output=input_output,
            num_workers=num_workers,
        )

run_pipeline_implementation

Execute the complete pipeline to compute roofprints from LiDAR HD data.

Parameters:

  • bd_topo_dir
    (Path) –

    Directory containing BD TOPO data

  • tile_dir
    (Path) –

    Working directory for intermediate and output files

  • stop_after_roofprints
    (bool) –

    Whether to stop after computing roofprints

  • stop_after_lod22
    (bool) –

    Whether to stop after computing LoD2.2 models

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • num_workers
    (Optional[int]) –

    Number of worker processes for multiprocessing (None = CPU count)

Source code in points2prints/pipeline/pipeline.py
def run_pipeline_implementation(
    bd_topo_dir: Path,
    tile_dir: Path,
    stop_after_roofprints: bool,
    stop_after_lod22: bool,
    input_output: InputOutput,
    num_workers: Optional[int],
):
    """Execute the complete pipeline to compute roofprints from LiDAR HD data.

    Parameters
    ----------
    bd_topo_dir : Path
        Directory containing BD TOPO data
    tile_dir : Path
        Working directory for intermediate and output files
    stop_after_roofprints : bool
        Whether to stop after computing roofprints
    stop_after_lod22 : bool
        Whether to stop after computing LoD2.2 models
    input_output: InputOutput
        The handler for input and output file issues.
    num_workers : Optional[int]
        Number of worker processes for multiprocessing (None = CPU count)
    """
    tile_bd_topo_dir = tile_dir / "bd_topo"
    tile_bd_topo_dir.mkdir(exist_ok=True)
    tile_lidar_hd_dir = tile_dir / "lidar_hd"
    tile_lidar_hd_dir.mkdir(exist_ok=True)
    tile_axes_dir = tile_lidar_hd_dir / "axes"
    tile_axes_dir.mkdir(exist_ok=True)
    tile_roofprints_dir = tile_dir / "roofprints"
    tile_roofprints_dir.mkdir(exist_ok=True)
    tile_roof_dir = tile_dir / "roof"
    tile_roof_dir.mkdir(exist_ok=True)
    tile_footprints_dir = tile_dir / "footprints"
    tile_footprints_dir.mkdir(exist_ok=True)

    initial_laz_file = tile_lidar_hd_dir / "lidar_hd.copc.laz"
    if not initial_laz_file.exists():
        initial_laz_file = tile_lidar_hd_dir / "lidar_hd.laz"
    input_output.handle_input(
        message_prefix="Initial LiDAR HD file",
        input_files=[initial_laz_file],
    )

    # Build the C++ tools
    _build_cpp_tool()

    # -------------------------------------------------------------------- #
    #                                BD TOPO                               #
    # -------------------------------------------------------------------- #

    edges_file = bd_topo_dir / "edges.parquet"
    intersections_file = bd_topo_dir / "intersections.parquet"
    groups_file = bd_topo_dir / "building_groups.parquet"
    cropped_edges_file = tile_bd_topo_dir / "edges.parquet"
    cropped_intersections_file = tile_bd_topo_dir / "intersections.parquet"
    cropped_groups_file = tile_bd_topo_dir / "building_groups.parquet"

    _process_bd_topo_data(
        initial_laz_file=initial_laz_file,
        input_edges_file=edges_file,
        input_intersections_file=intersections_file,
        input_building_groups_file=groups_file,
        output_edges_file=cropped_edges_file,
        output_intersections_file=cropped_intersections_file,
        output_building_groups_file=cropped_groups_file,
        input_output=input_output,
    )

    # -------------------------------------------------------------------- #
    #                               LiDAR HD                               #
    # -------------------------------------------------------------------- #

    # Process LiDAR HD: compute inward directions and split by flight strips
    las_with_inwards_roof_file = tile_lidar_hd_dir / "lidar_hd-with_inwards_roof.laz"
    template_flight_strip_file = tile_axes_dir / "axis_#.laz"

    all_flight_strip_files = _process_lidar_hd_data(
        initial_laz_file=initial_laz_file,
        laz_with_inwards_roof_file=las_with_inwards_roof_file,
        template_flight_strip_file=template_flight_strip_file,
        input_output=input_output,
    )

    # Compute trajectories in parallel
    trajectory_files = [
        tile_axes_dir / f"{laz_file.stem}-trajectory.txt"
        for laz_file in all_flight_strip_files
    ]
    successes = _compute_trajectories_parallel(
        flight_strip_files=all_flight_strip_files,
        trajectory_files=trajectory_files,
        input_output=input_output,
        num_workers=num_workers,
    )

    # Only keep files for which the trajectory computation was successful
    all_flight_strip_files = [
        laz for laz, success in zip(all_flight_strip_files, successes) if success
    ]
    trajectory_files = [
        traj for traj, success in zip(trajectory_files, successes) if success
    ]

    # Process flight strips with C++ pipeline in parallel
    distances_files = [
        tile_axes_dir / f"{laz_file.stem}-distances.laz"
        for laz_file in all_flight_strip_files
    ]
    edges_files = [
        tile_axes_dir / f"{laz_file.stem}-edges.laz"
        for laz_file in all_flight_strip_files
    ]
    _compute_distances_and_edges_parallel(
        flight_strip_files=all_flight_strip_files,
        trajectory_files=trajectory_files,
        distances_files=distances_files,
        edges_files=edges_files,
        input_output=input_output,
        num_workers=num_workers,
    )

    # Merge output files
    merged_distances_file = tile_lidar_hd_dir / "merged_distances.laz"
    merged_edges_file = tile_lidar_hd_dir / "merged_edges.laz"
    _merge_output_files(
        distances_files=distances_files,
        edges_files=edges_files,
        merged_distances_file=merged_distances_file,
        merged_edges_file=merged_edges_file,
        input_output=input_output,
    )

    # -------------------------------------------------------------------- #
    #                              Roofprints                              #
    # -------------------------------------------------------------------- #

    n_iterations_roofprints = 3
    roofprints_template_file = tile_roofprints_dir / f"roofprints-{{iteration}}.parquet"

    roofprints_files = _compute_roofprints(
        merged_edges_file=merged_edges_file,
        bd_topo_edges_file=cropped_edges_file,
        bd_topo_intersections_file=cropped_intersections_file,
        output_roofprints_template_file=roofprints_template_file,
        n_iterations=n_iterations_roofprints,
        input_output=input_output,
    )

    if stop_after_roofprints:
        logging.info("Stopping pipeline after roofprints as requested.")
        return

    # ------------------------------------------------------------------------ #
    #                                   Roof                                   #
    # ------------------------------------------------------------------------ #

    # Reclassify the input LiDAR HD file to classify as building all the points which are potentially building points, as roofer only uses those
    reclassified_lidar_hd_file = tile_lidar_hd_dir / "lidar_hd-reclassified.laz"
    classification_mapping_implementation(
        input_file=initial_laz_file,
        output_file=reclassified_lidar_hd_file,
        mapping={1: 6, 64: 6, 65: 6, 67: 6},
        input_output=input_output,
    )

    # Compute the LoD22 models
    lod22_files = []
    for roofprints_file in roofprints_files:
        lod22_file = tile_roof_dir / f"{roofprints_file.stem}-roof.city.json"
        _compute_lod22(
            lidar_hd_reclassified_file=reclassified_lidar_hd_file,
            roofprints_file=roofprints_file,
            output_lod22_cj_file=lod22_file,
            input_output=input_output,
        )
        lod22_files.append(lod22_file)

    if stop_after_lod22:
        logging.info("Stopping pipeline after LoD22 as requested.")
        return

    # ------------------------------------------------------------------------ #
    #                                Footprints                                #
    # ------------------------------------------------------------------------ #

    n_iterations_footprints = 3
    for roofprints_file, lod22_file in zip(roofprints_files, lod22_files):
        output_footprints_template_file = (
            tile_footprints_dir
            / f"{roofprints_file.stem.replace('roofprints', 'footprints')}-{{iteration}}.parquet"
        )
        _compute_footprints(
            lidar_hd_file=initial_laz_file,
            lod22_file=lod22_file,
            roofprints_file=roofprints_file,
            output_footprints_template_file=output_footprints_template_file,
            n_iterations=n_iterations_footprints,
            input_output=input_output,
        )

    logging.info("Pipeline completed successfully.")

point_cloud

Process the point clouds.

Modules:

  • las_manipulations –

    Merge multiple point cloud files into a single output file while preserving all attributes.

Functions:

classification_mapping_implementation

classification_mapping_implementation(input_file: Path, output_file: Path, mapping: Dict[int, int], input_output: InputOutput)

Map the classification values in a LAS/LAZ file to a new set of values based on a provided mapping.

Parameters:

  • input_file

    (Path) –

    Input LAS/LAZ file.

  • output_file

    (Path) –

    Output LAS/LAZ file.

  • mapping

    (Dict[int, int]) –

    Mapping of old classification values to new classification values.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/point_cloud/las_manipulations.py
def classification_mapping_implementation(
    input_file: Path,
    output_file: Path,
    mapping: Dict[int, int],
    input_output: InputOutput,
):
    """
    Map the classification values in a LAS/LAZ file to a new set of values based on a provided mapping.

    Parameters
    ----------
    input_file : Path
        Input LAS/LAZ file.
    output_file : Path
        Output LAS/LAZ file.
    mapping : Dict[int, int]
        Mapping of old classification values to new classification values.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Reclassifying point cloud"
    input_output.handle_input(message_prefix=message_prefix, input_files=[input_file])
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_file]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create pipeline
    reader = Reader(str(input_file))
    filter_reclassify = Filter(
        "filters.assign",
        value=[
            f"Classification = {new} WHERE Classification == {old}"
            for old, new in mapping.items()
        ],
    )
    writer = Writer(str(output_file), extra_dims="all")

    pipeline = Pipeline([reader, filter_reclassify, writer])
    pipeline.execute()

merge_files

Merge multiple point cloud files into a single output file.

Parameters:

  • input_files

    (List[Path]) –

    List of input point cloud file paths.

  • output_file

    (Path) –

    Output point cloud file path.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/point_cloud/las_manipulations.py
def merge_files(
    input_files: List[Path], output_file: Path, input_output: InputOutput
) -> None:
    """
    Merge multiple point cloud files into a single output file.

    Parameters
    ----------
    input_files : List[Path]
        List of input point cloud file paths.
    output_file : Path
        Output point cloud file path.
    input_output : InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Merge point clouds"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=input_files,
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_file]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create pipeline
    pipeline = create_merge_pipeline(input_files, output_file)
    logging.debug(f"PDAL Pipeline: {json.dumps(pipeline, indent=2)}")

    # Execute pipeline
    pipeline_json = json.dumps(pipeline)
    pdal_pipeline = Pipeline(pipeline_json)
    pdal_pipeline.execute()

split_point_cloud_implementation

split_point_cloud_implementation(input_file: Path, output_file_template: Path, dimension: str, input_output: InputOutput) -> List[Path]

Split a point cloud file into multiple files based on a specified dimension.

Parameters:

  • input_file

    (Path) –

    Input LAS/LAZ file path.

  • output_file_template

    (Path) –

    Template for output file paths (should include # placeholder).

  • dimension

    (str) –

    Dimension to split on (e.g., "Classification").

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Returns:

  • List[Path] –

    List of paths to the created output files.

Raises:

  • ValueError –

    If the output file template doesn't contain a # placeholder.

Source code in points2prints/point_cloud/las_manipulations.py
def split_point_cloud_implementation(
    input_file: Path,
    output_file_template: Path,
    dimension: str,
    input_output: InputOutput,
) -> List[Path]:
    """
    Split a point cloud file into multiple files based on a specified dimension.

    Parameters
    ----------
    input_file : Path
        Input LAS/LAZ file path.
    output_file_template : Path
        Template for output file paths (should include # placeholder).
    dimension : str
        Dimension to split on (e.g., "Classification").
    input_output: InputOutput
        The handler for input and output file issues.

    Returns
    -------
    List[Path]
        List of paths to the created output files.

    Raises
    ------
    ValueError
        If the output file template doesn't contain a # placeholder.
    """
    message_prefix = "Splitting point cloud"
    input_output.handle_input(message_prefix=message_prefix, input_files=[input_file])

    # Check if the output file template contains the # placeholder
    if "#" not in output_file_template.name:
        raise ValueError(
            "Output file template must contain a # placeholder for the dimension value"
        )

    # Ensure output directory exists
    output_file_template.parent.mkdir(parents=True, exist_ok=True)

    # Create the pipeline
    reader = Reader(str(input_file))
    filter_groupby = Filter("filters.groupby", dimension=dimension)

    processing_pipeline = Pipeline([reader, filter_groupby])

    # Run the pipeline
    logging.info(
        f"Processing input file '{input_file}' and splitting by dimension '{dimension}'"
    )
    logging.debug(
        f"PDAL Pipeline: {json.dumps(processing_pipeline.pipeline, indent=2)}"
    )
    processing_pipeline.execute()
    logging.debug(
        f"Found {len(processing_pipeline.arrays)} different values for dimension '{dimension}'"
    )

    # Find the unique values for the dimension of interest
    dimension_unique_values = [arr[dimension][0] for arr in processing_pipeline.arrays]
    output_files = [
        Path(str(output_file_template).replace("#", f"{dimension_value}"))
        for dimension_value in dimension_unique_values
    ]

    # Check if the output files already exist
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[output_files],
    )
    if output_action == OutputActionEnum.SKIP:
        return output_files

    # Write every output to a separate file
    tqdm_iterable = tqdm(
        zip(processing_pipeline.arrays, dimension_unique_values, output_files),
        total=len(dimension_unique_values),
        desc="Writing output files",
    )
    for arr, dimension_value, output_file in tqdm_iterable:
        tqdm_iterable.set_postfix({dimension: dimension_value})
        tqdm_iterable.refresh()

        # Write the array to the output file
        writer = Writer(
            str(output_file),
            extra_dims="all",
        )
        pipeline_writer = Pipeline([writer], arrays=[arr])
        pipeline_writer.execute()

    return output_files

las_manipulations

Merge multiple point cloud files into a single output file while preserving all attributes. Uses PDAL to perform the merge operation.

Functions:

classification_mapping_implementation

classification_mapping_implementation(input_file: Path, output_file: Path, mapping: Dict[int, int], input_output: InputOutput)

Map the classification values in a LAS/LAZ file to a new set of values based on a provided mapping.

Parameters:

  • input_file
    (Path) –

    Input LAS/LAZ file.

  • output_file
    (Path) –

    Output LAS/LAZ file.

  • mapping
    (Dict[int, int]) –

    Mapping of old classification values to new classification values.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/point_cloud/las_manipulations.py
def classification_mapping_implementation(
    input_file: Path,
    output_file: Path,
    mapping: Dict[int, int],
    input_output: InputOutput,
):
    """
    Map the classification values in a LAS/LAZ file to a new set of values based on a provided mapping.

    Parameters
    ----------
    input_file : Path
        Input LAS/LAZ file.
    output_file : Path
        Output LAS/LAZ file.
    mapping : Dict[int, int]
        Mapping of old classification values to new classification values.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Reclassifying point cloud"
    input_output.handle_input(message_prefix=message_prefix, input_files=[input_file])
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_file]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create pipeline
    reader = Reader(str(input_file))
    filter_reclassify = Filter(
        "filters.assign",
        value=[
            f"Classification = {new} WHERE Classification == {old}"
            for old, new in mapping.items()
        ],
    )
    writer = Writer(str(output_file), extra_dims="all")

    pipeline = Pipeline([reader, filter_reclassify, writer])
    pipeline.execute()

create_merge_pipeline

create_merge_pipeline(input_files: List[Path], output_file: Path) -> Dict

Create a PDAL pipeline that merges multiple point cloud files.

Args: input_files: List of input point cloud file paths output_file: Output file path

Returns: Dictionary representing the PDAL pipeline

Source code in points2prints/point_cloud/las_manipulations.py
def create_merge_pipeline(input_files: List[Path], output_file: Path) -> Dict:
    """
    Create a PDAL pipeline that merges multiple point cloud files.

    Args:
        input_files: List of input point cloud file paths
        output_file: Output file path

    Returns:
        Dictionary representing the PDAL pipeline
    """
    input_files_str = [str(f) for f in input_files]
    output_file_str = str(output_file)

    # Create pipeline stages
    pipeline = []

    # Add additional files as merge stages (if there are multiple files)
    for input_file_str in input_files_str:
        pipeline.append({"type": "readers.las", "filename": input_file_str})

    pipeline.append(
        {
            "type": "filters.merge",
        }
    )

    # Add writer at the end
    pipeline.append(
        {"type": "writers.las", "filename": output_file_str, "extra_dims": "all"}
    )

    return {"pipeline": pipeline}

merge_files

Merge multiple point cloud files into a single output file.

Parameters:

  • input_files
    (List[Path]) –

    List of input point cloud file paths.

  • output_file
    (Path) –

    Output point cloud file path.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/point_cloud/las_manipulations.py
def merge_files(
    input_files: List[Path], output_file: Path, input_output: InputOutput
) -> None:
    """
    Merge multiple point cloud files into a single output file.

    Parameters
    ----------
    input_files : List[Path]
        List of input point cloud file paths.
    output_file : Path
        Output point cloud file path.
    input_output : InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Merge point clouds"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=input_files,
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_file]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    # Create pipeline
    pipeline = create_merge_pipeline(input_files, output_file)
    logging.debug(f"PDAL Pipeline: {json.dumps(pipeline, indent=2)}")

    # Execute pipeline
    pipeline_json = json.dumps(pipeline)
    pdal_pipeline = Pipeline(pipeline_json)
    pdal_pipeline.execute()

split_point_cloud_implementation

split_point_cloud_implementation(input_file: Path, output_file_template: Path, dimension: str, input_output: InputOutput) -> List[Path]

Split a point cloud file into multiple files based on a specified dimension.

Parameters:

  • input_file
    (Path) –

    Input LAS/LAZ file path.

  • output_file_template
    (Path) –

    Template for output file paths (should include # placeholder).

  • dimension
    (str) –

    Dimension to split on (e.g., "Classification").

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Returns:

  • List[Path] –

    List of paths to the created output files.

Raises:

  • ValueError –

    If the output file template doesn't contain a # placeholder.

Source code in points2prints/point_cloud/las_manipulations.py
def split_point_cloud_implementation(
    input_file: Path,
    output_file_template: Path,
    dimension: str,
    input_output: InputOutput,
) -> List[Path]:
    """
    Split a point cloud file into multiple files based on a specified dimension.

    Parameters
    ----------
    input_file : Path
        Input LAS/LAZ file path.
    output_file_template : Path
        Template for output file paths (should include # placeholder).
    dimension : str
        Dimension to split on (e.g., "Classification").
    input_output: InputOutput
        The handler for input and output file issues.

    Returns
    -------
    List[Path]
        List of paths to the created output files.

    Raises
    ------
    ValueError
        If the output file template doesn't contain a # placeholder.
    """
    message_prefix = "Splitting point cloud"
    input_output.handle_input(message_prefix=message_prefix, input_files=[input_file])

    # Check if the output file template contains the # placeholder
    if "#" not in output_file_template.name:
        raise ValueError(
            "Output file template must contain a # placeholder for the dimension value"
        )

    # Ensure output directory exists
    output_file_template.parent.mkdir(parents=True, exist_ok=True)

    # Create the pipeline
    reader = Reader(str(input_file))
    filter_groupby = Filter("filters.groupby", dimension=dimension)

    processing_pipeline = Pipeline([reader, filter_groupby])

    # Run the pipeline
    logging.info(
        f"Processing input file '{input_file}' and splitting by dimension '{dimension}'"
    )
    logging.debug(
        f"PDAL Pipeline: {json.dumps(processing_pipeline.pipeline, indent=2)}"
    )
    processing_pipeline.execute()
    logging.debug(
        f"Found {len(processing_pipeline.arrays)} different values for dimension '{dimension}'"
    )

    # Find the unique values for the dimension of interest
    dimension_unique_values = [arr[dimension][0] for arr in processing_pipeline.arrays]
    output_files = [
        Path(str(output_file_template).replace("#", f"{dimension_value}"))
        for dimension_value in dimension_unique_values
    ]

    # Check if the output files already exist
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[output_files],
    )
    if output_action == OutputActionEnum.SKIP:
        return output_files

    # Write every output to a separate file
    tqdm_iterable = tqdm(
        zip(processing_pipeline.arrays, dimension_unique_values, output_files),
        total=len(dimension_unique_values),
        desc="Writing output files",
    )
    for arr, dimension_value, output_file in tqdm_iterable:
        tqdm_iterable.set_postfix({dimension: dimension_value})
        tqdm_iterable.refresh()

        # Write the array to the output file
        writer = Writer(
            str(output_file),
            extra_dims="all",
        )
        pipeline_writer = Pipeline([writer], arrays=[arr])
        pipeline_writer.execute()

    return output_files

polygon_deformation

Experiments with the polygon deformation algorithm and visualisation of its behaviour on toy datasets.

Modules:

geometry

Classes:

Segment

Segment(start: Point, end: Point, name: Optional[str] = None)

Methods:

  • distance_to_point –

    Computes the distance from a point to the segment.

  • projection_on_line –

    Projects a point onto the line defined by the segment and checks if the projection lies on the segment.

Source code in points2prints/polygon_deformation/geometry.py
def __init__(self, start: Point, end: Point, name: Optional[str] = None) -> None:
    self.start = start
    self.end = end
    self.name = name
distance_to_point
distance_to_point(point: Point) -> Tuple[float, float]

Computes the distance from a point to the segment. The distance is divided between the distance in the direction of the normal vector and the distance in the direction of the segment. The distance in the direction of the segment is defined as the distance between the projection on the line and the projection on the segment.

Args: point (Point): The point from which the distance is calculated.

Returns: [float, float]: The distance in the direction of the normal vector and the distance in the direction of the segment.

Source code in points2prints/polygon_deformation/geometry.py
def distance_to_point(self, point: Point) -> Tuple[float, float]:
    """Computes the distance from a point to the segment.
    The distance is divided between the distance in the direction of the normal vector and the distance in the direction of the segment.
    The distance in the direction of the segment is defined as the distance between the projection on the line and the projection on the segment.

    Args:
        point (Point): The point from which the distance is calculated.

    Returns:
        [float, float]: The distance in the direction of the normal vector and the distance in the direction of the segment.
    """
    line = self.get_line()
    point_on_line = line.projection_on_line(point)
    normal_distance = point.distance_to(point_on_line)

    if self.min_x > point_on_line.x:
        segment_distance = point_on_line.distance_to(self.start)
    elif self.max_x < point_on_line.x:
        segment_distance = point_on_line.distance_to(self.end)
    elif self.min_y > point_on_line.y:
        segment_distance = point_on_line.distance_to(self.start)
    elif self.max_y < point_on_line.y:
        segment_distance = point_on_line.distance_to(self.end)
    else:
        segment_distance = 0.0

    return normal_distance, segment_distance
projection_on_line
projection_on_line(point: Point) -> tuple[Point, bool]

Projects a point onto the line defined by the segment and checks if the projection lies on the segment.

Args: point (Point): The point to be projected.

Returns: tuple[Point, bool]: The projected point and a boolean indicating if it lies on the segment.

Source code in points2prints/polygon_deformation/geometry.py
def projection_on_line(self, point: Point) -> tuple[Point, bool]:
    """Projects a point onto the line defined by the segment and checks if the projection lies on the segment.

    Args:
        point (Point): The point to be projected.

    Returns:
        tuple[Point, bool]: The projected point and a boolean indicating if it lies on the segment.
    """
    line = Line.from_points(self.start, self.end)
    point_on_line = line.projection_on_line(point)
    if self.min_x > point_on_line.x:
        return self.start, False
    elif self.max_x < point_on_line.x:
        return self.end, False
    elif self.min_y > point_on_line.y:
        return self.start, False
    elif self.max_y < point_on_line.y:
        return self.end, False
    else:
        return point_on_line, True

plot_recorder

Classes:

  • PlotRecorder –

    Stores named snapshots of geometry and can render/show/save them.

PlotRecorder

PlotRecorder(style: Optional[PlotStyle] = None)

Stores named snapshots of geometry and can render/show/save them.

Methods:

Source code in points2prints/polygon_deformation/plot_recorder.py
def __init__(self, style: Optional[PlotStyle] = None) -> None:
    self.style = style or PlotStyle()
    self._snapshots: dict[str, Snapshot] = {}
save_all_combined
save_all_combined(output_path: Path, dpi: int = 150) -> None

Save all snapshots as subplots in a single figure.

Source code in points2prints/polygon_deformation/plot_recorder.py
def save_all_combined(self, output_path: Path, dpi: int = 150) -> None:
    """Save all snapshots as subplots in a single figure."""
    names = self.snapshot_names()
    if not names:
        return

    # Calculate grid dimensions
    num_snapshots = len(names)
    num_cols = int(math.ceil(math.sqrt(num_snapshots)))
    num_rows = int(math.ceil(num_snapshots / num_cols))

    # Create figure with subplots
    fig, axes = plt.subplots(
        num_rows,
        num_cols,
        figsize=(5 * num_cols, 5 * num_rows),
        sharex=True,
        sharey=True,
    )

    # Flatten axes array if only one row or column
    if num_rows == 1 and num_cols == 1:
        axes = [axes]
    elif num_rows == 1 or num_cols == 1:
        axes = axes.flat
    else:
        axes = axes.flat

    # Plot each snapshot
    for idx, name in enumerate(names):
        ax: Axes = axes[idx]
        snap = self._snapshots[name]

        # Plot on this axis
        for p in snap.points:
            p.plot(ax=ax, color=self.style.point_color, size=self.style.point_size)

        for s in snap.segments:
            s.plot(
                ax=ax,
                color=self.style.segment_color,
                width=self.style.segment_width,
                point_color=self.style.segment_point_color,
                point_size=self.style.segment_point_size,
            )

        for poly in snap.polygons:
            poly.plot(
                ax=ax,
                color=self.style.polygon_color,
                width=self.style.polygon_width,
                point_color=self.style.polygon_point_color,
                point_size=self.style.polygon_point_size,
            )

        ax.set_title(snap.title or snap.name)
        ax.set_xlim(snap.bounds[0][0], snap.bounds[1][0]) if snap.bounds else None
        ax.set_ylim(snap.bounds[0][1], snap.bounds[1][1]) if snap.bounds else None
        ax.set_aspect("equal")
        ax.grid(True)

    # Hide unused subplots
    for idx in range(num_snapshots, len(axes)):
        axes[idx].set_visible(False)

    plt.tight_layout()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    plt.savefig(output_path, dpi=dpi, bbox_inches="tight")
    plt.close()
    plt.close()
    plt.close()
save_combined_as_video
save_combined_as_video(output_path: Path, fps: int = 10, dpi: int = 150, show_axes: bool = True) -> None

Save all snapshots as frames in a video.

Source code in points2prints/polygon_deformation/plot_recorder.py
def save_combined_as_video(
    self, output_path: Path, fps: int = 10, dpi: int = 150, show_axes: bool = True
) -> None:
    """Save all snapshots as frames in a video."""
    import matplotlib.animation as animation

    names = self.snapshot_names()
    if not names:
        return

    # Create figure
    fig, ax = plt.subplots(figsize=(4, 4))

    artists_points: Sequence[Artist] = []
    artists_segments: Sequence[Artist] = []
    artists_polygons: Sequence[Artist] = []

    x_min = float("inf")
    x_max = float("-inf")
    y_min = float("inf")
    y_max = float("-inf")
    for name in names:
        snap = self._snapshots[name]
        for p in snap.points:
            x_min = min(x_min, p.x)
            x_max = max(x_max, p.x)
            y_min = min(y_min, p.y)
            y_max = max(y_max, p.y)
        for s in snap.segments:
            bbox = s.bounding_box()
            x_min = min(x_min, bbox.min_x)
            x_max = max(x_max, bbox.max_x)
            y_min = min(y_min, bbox.min_y)
            y_max = max(y_max, bbox.max_y)
        for poly in snap.polygons:
            for segment in poly.get_segments():
                x_min = min(x_min, segment.start.x)
                x_max = max(x_max, segment.end.x)
                y_min = min(y_min, segment.start.y)
                y_max = max(y_max, segment.end.y)

    def update(frame_idx: int) -> Sequence[Artist]:
        ax.clear()
        ax.set_xlim(x_min - 1, x_max + 1)
        ax.set_ylim(y_min - 1, y_max + 1)
        ax.set_aspect("equal")

        if not show_axes:
            ax.axis("off")

        name = names[frame_idx]
        snap = self._snapshots[name]

        artists_points.clear()
        artists_segments.clear()
        artists_polygons.clear()

        for p in snap.points:
            artists_points.extend(
                p.plot(
                    ax=ax, color=self.style.point_color, size=self.style.point_size
                )
            )

        for s in snap.segments:
            artists_segments.extend(
                s.plot(
                    ax=ax,
                    color=self.style.segment_color,
                    width=self.style.segment_width,
                    point_color=self.style.segment_point_color,
                    point_size=self.style.segment_point_size,
                )
            )

        for poly in snap.polygons:
            artists_polygons.extend(
                poly.plot(
                    ax=ax,
                    color=self.style.polygon_color,
                    width=self.style.polygon_width,
                    point_color=self.style.polygon_point_color,
                    point_size=self.style.polygon_point_size,
                )
            )

        ax.set_title(snap.title or snap.name)

        fig.tight_layout()

        return artists_points + artists_segments + artists_polygons

    anim = animation.FuncAnimation(fig, update, frames=len(names), repeat=False)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    anim.save(output_path, fps=fps, dpi=dpi)
    plt.close()

roof

Create the roofs.

Modules:

Functions:

roofprints_to_lod22_implementation

roofprints_to_lod22_implementation(point_cloud_path: Path, roofprints_path: Path, roof_path: Path, input_output: InputOutput) -> None

Creates a 3D roof model from roofprints and a point cloud. This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

Parameters:

  • point_cloud_path

    (Path) –

    Path to the point cloud file (LAS/LAZ).

  • roofprints_path

    (Path) –

    Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).

  • roof_path

    (Path) –

    Path where the resulting 3D roof model will be saved (CityJSON).

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/roof/roof.py
def roofprints_to_lod22_implementation(
    point_cloud_path: Path,
    roofprints_path: Path,
    roof_path: Path,
    input_output: InputOutput,
) -> None:
    """
    Creates a 3D roof model from roofprints and a point cloud.
    This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

    Parameters
    ----------
    point_cloud_path : Path
        Path to the point cloud file (LAS/LAZ).
    roofprints_path : Path
        Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).
    roof_path : Path
        Path where the resulting 3D roof model will be saved (CityJSON).
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Roofprints to LoD2.2"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[point_cloud_path, roofprints_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[roof_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    with TemporaryDirectory() as temp_dir:
        # ----------------- Convert roofprints to GeoPackage ----------------- #
        if roofprints_path.suffix.lower() != ".gpkg":
            logging.info(
                f"Converting roofprints from {roofprints_path.suffix} to GeoPackage format..."
            )
            roofprints_gpkg_path = Path(temp_dir) / "roofprints.gpkg"
            command_convert = [
                "gdal",
                "convert",
                "-i",
                str(roofprints_path),
                "-o",
                str(roofprints_gpkg_path),
            ]
            return_code = run_command_with_tqdm_logging(command_convert, display=True)
            if return_code != 0:
                logging.error(f"Failed to convert roofprints to GeoPackage format.")
                return
            else:
                logging.info(f"Successfully converted roofprints to GeoPackage format.")
        else:
            roofprints_gpkg_path = roofprints_path

        # ------------------- Create the 3D building models ------------------ #

        roofer_output_path = Path(temp_dir) / "roofer_output"
        command_roofer = [
            "roofer",
            str(point_cloud_path),
            str(roofprints_gpkg_path),
            str(roofer_output_path),
            "--no-clip-terrain",
            "--id-attribute",
            "cleabs",
        ]

        return_code = run_command_with_tqdm_logging(command_roofer, display=True)
        if return_code != 0:
            logging.error(f"Failed to create 3D roof model.")
        else:
            logging.info(f"Successfully created 3D roof model.")

        # ------------ Convert the CityJSONSeq outputs to CityJSON ----------- #

        command_cat = ["cat", *glob(str(roofer_output_path / "*.city.jsonl"))]
        command_cjseq = ["cjseq", "collect"]

        logging.info(
            f"Running command: {' '.join(command_cat)} | {' '.join(command_cjseq)} > {roof_path}"
        )
        with roof_path.open("wb") as roof_file:
            ps = subprocess.Popen(command_cat, stdout=subprocess.PIPE)
            try:
                result = subprocess.run(
                    command_cjseq, stdin=ps.stdout, stdout=roof_file
                )
                return_code = result.returncode
            finally:
                if ps.stdout is not None:
                    ps.stdout.close()
                ps.wait()

        if return_code != 0:
            logging.error("Failed to convert CityJSONSeq to CityJSON.")
        else:
            logging.info("Successfully converted CityJSONSeq to CityJSON.")

roof

Functions:

roofprints_to_lod22_call

roofprints_to_lod22_call(point_cloud_path: Path, roofprints_path: Path, roof_path: Path, input_output: InputOutput, verbose: Verbose) -> None

Creates a 3D roof model from roofprints and a point cloud. This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

Parameters:

  • point_cloud_path
    (Path) –

    Path to the point cloud file (LAS/LAZ).

  • roofprints_path
    (Path) –

    Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).

  • roof_path
    (Path) –

    Path where the resulting 3D roof model will be saved (CityJSON).

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/roof/roof.py
def roofprints_to_lod22_call(
    point_cloud_path: Path,
    roofprints_path: Path,
    roof_path: Path,
    input_output: InputOutput,
    verbose: Verbose,
) -> None:
    """
    Creates a 3D roof model from roofprints and a point cloud.
    This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

    Parameters
    ----------
    point_cloud_path : Path
        Path to the point cloud file (LAS/LAZ).
    roofprints_path : Path
        Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).
    roof_path : Path
        Path where the resulting 3D roof model will be saved (CityJSON).
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    """
    with LoggingContext(verbose=verbose):
        roof_path.parent.mkdir(parents=True, exist_ok=True)

        roofprints_to_lod22_implementation(
            point_cloud_path=point_cloud_path,
            roofprints_path=roofprints_path,
            roof_path=roof_path,
            input_output=input_output,
        )

roofprints_to_lod22_implementation

roofprints_to_lod22_implementation(point_cloud_path: Path, roofprints_path: Path, roof_path: Path, input_output: InputOutput) -> None

Creates a 3D roof model from roofprints and a point cloud. This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

Parameters:

  • point_cloud_path
    (Path) –

    Path to the point cloud file (LAS/LAZ).

  • roofprints_path
    (Path) –

    Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).

  • roof_path
    (Path) –

    Path where the resulting 3D roof model will be saved (CityJSON).

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/roof/roof.py
def roofprints_to_lod22_implementation(
    point_cloud_path: Path,
    roofprints_path: Path,
    roof_path: Path,
    input_output: InputOutput,
) -> None:
    """
    Creates a 3D roof model from roofprints and a point cloud.
    This simply calls roofer with the appropriate arguments, and then converts the CityJSONSeq output to CityJSON.

    Parameters
    ----------
    point_cloud_path : Path
        Path to the point cloud file (LAS/LAZ).
    roofprints_path : Path
        Path to the roofprints file (Parquet, GeoPackage, Shapefile, ...).
    roof_path : Path
        Path where the resulting 3D roof model will be saved (CityJSON).
    input_output: InputOutput
        The handler for input and output file issues.
    """
    message_prefix = "Roofprints to LoD2.2"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[point_cloud_path, roofprints_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[roof_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    with TemporaryDirectory() as temp_dir:
        # ----------------- Convert roofprints to GeoPackage ----------------- #
        if roofprints_path.suffix.lower() != ".gpkg":
            logging.info(
                f"Converting roofprints from {roofprints_path.suffix} to GeoPackage format..."
            )
            roofprints_gpkg_path = Path(temp_dir) / "roofprints.gpkg"
            command_convert = [
                "gdal",
                "convert",
                "-i",
                str(roofprints_path),
                "-o",
                str(roofprints_gpkg_path),
            ]
            return_code = run_command_with_tqdm_logging(command_convert, display=True)
            if return_code != 0:
                logging.error(f"Failed to convert roofprints to GeoPackage format.")
                return
            else:
                logging.info(f"Successfully converted roofprints to GeoPackage format.")
        else:
            roofprints_gpkg_path = roofprints_path

        # ------------------- Create the 3D building models ------------------ #

        roofer_output_path = Path(temp_dir) / "roofer_output"
        command_roofer = [
            "roofer",
            str(point_cloud_path),
            str(roofprints_gpkg_path),
            str(roofer_output_path),
            "--no-clip-terrain",
            "--id-attribute",
            "cleabs",
        ]

        return_code = run_command_with_tqdm_logging(command_roofer, display=True)
        if return_code != 0:
            logging.error(f"Failed to create 3D roof model.")
        else:
            logging.info(f"Successfully created 3D roof model.")

        # ------------ Convert the CityJSONSeq outputs to CityJSON ----------- #

        command_cat = ["cat", *glob(str(roofer_output_path / "*.city.jsonl"))]
        command_cjseq = ["cjseq", "collect"]

        logging.info(
            f"Running command: {' '.join(command_cat)} | {' '.join(command_cjseq)} > {roof_path}"
        )
        with roof_path.open("wb") as roof_file:
            ps = subprocess.Popen(command_cat, stdout=subprocess.PIPE)
            try:
                result = subprocess.run(
                    command_cjseq, stdin=ps.stdout, stdout=roof_file
                )
                return_code = result.returncode
            finally:
                if ps.stdout is not None:
                    ps.stdout.close()
                ps.wait()

        if return_code != 0:
            logging.error("Failed to convert CityJSONSeq to CityJSON.")
        else:
            logging.info("Successfully converted CityJSONSeq to CityJSON.")

utils

Generic utilities

Modules:

Classes:

Functions:

InputOutput

Bases: Enum

Methods:

get_output_actions

get_output_actions(message_prefix: str, output_files: Sequence[Sequence[Path]]) -> List[OutputAction]

Handle input and output files based on the specified output action.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • output_files
    (Sequence[Sequence[Path]]) –

    A list of list of paths to output files. The first level of the handles all elements independently while the second level expects all or none to exist. For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

Returns:

  • List[OutputAction] –

    A list of output actions corresponding to the input and output files.

Raises:

Source code in points2prints/utils/input_output.py
def get_output_actions(
    self,
    message_prefix: str,
    output_files: Sequence[Sequence[Path]],
) -> List[OutputAction]:
    """Handle input and output files based on the specified output action.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    output_files : Sequence[Sequence[Path]]
        A list of list of paths to output files.
        The first level of the handles all elements independently while the second level expects all or none to exist.
        For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

    Returns
    -------
    List[OutputAction]
        A list of output actions corresponding to the input and output files.

    Raises
    ------
    NotImplementedError
        If the current InputOutput has an unexpected value.
    """
    # Check for existing output files
    input_outputs: List[OutputAction] = []
    for linked_output_files in output_files:
        existing_files: List[Path] = []
        for file_path in linked_output_files:
            if file_path.exists():
                existing_files.append(file_path)
            else:
                file_path.parent.mkdir(parents=True, exist_ok=True)

        existing_files_str = ", ".join(str(f) for f in existing_files)
        not_existing_files_str = ", ".join(
            str(f) for f in linked_output_files if f not in existing_files
        )

        match self:
            case InputOutput.SKIP_EXISTING:
                if len(existing_files) == len(linked_output_files):
                    message = f"{message_prefix}: These output files already exist and will be skipped: {existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.SKIP, message)
                elif len(existing_files) > 0:
                    error_message = f"{message_prefix}: Cannot skip existing files because these output files already exist: {existing_files_str} but these do not: {not_existing_files_str}."
                    input_output = OutputAction(
                        OutputActionEnum.ERROR, error_message
                    )
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.PROCEED, message)

            case InputOutput.OVERWRITE:
                if len(existing_files) > 0:
                    message = f"{message_prefix}: These output files already exist and will be overwritten: {existing_files_str}."
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                input_output = OutputAction(OutputActionEnum.PROCEED, message)
            case InputOutput.NONE:
                if len(existing_files) > 0:
                    error_message = f"{message_prefix}: Some output files already exist: {existing_files_str}. Use --overwrite to overwrite them or --skip-existing to skip processing if they already exist."
                    input_output = OutputAction(
                        OutputActionEnum.ERROR, error_message
                    )
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.PROCEED, message)
            case _:
                message = f"{message_prefix}: Invalid InputOutput value: {self}"
                raise NotImplementedError(message)

        input_outputs.append(input_output)

    return input_outputs

handle_input

Validate input files.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • input_files
    (List[Path]) –

    A list of paths to input files.

Raises:

  • InputFileNotFoundError –

    If any of the input files do not exist.

  • InputFileIsNotFileError –

    If any of the input files are not valid files.

  • InputFileIsEmptyError –

    If any of the input files are empty.

Source code in points2prints/utils/input_output.py
def handle_input(
    self,
    message_prefix: str,
    input_files: List[Path],
):
    """
    Validate input files.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    input_files : List[Path]
        A list of paths to input files.
    Raises
    ------
    InputFileNotFoundError
        If any of the input files do not exist.
    InputFileIsNotFileError
        If any of the input files are not valid files.
    InputFileIsEmptyError
        If any of the input files are empty.
    """
    # Validate input files
    for file in input_files:
        if not file.exists():
            raise InputFileNotFoundError(f"Input file {file} does not exist.")
        if not file.is_file():
            raise InputFileIsNotFileError(f"Input file {file} is not a file.")
        if not file.stat().st_size > 0:
            raise InputFileIsEmptyError(f"Input file {file} is empty.")

handle_output

handle_output(message_prefix: str, behaviour: OutputBehaviour, output_files: Sequence[Sequence[Path]]) -> OutputActionEnum

Handle output files based on the specified output action.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • behaviour
    (OutputBehaviour) –

    The handler for input and output file issues.

  • output_files
    (Sequence[Sequence[Path]]) –

    A list of list of paths to output files. The first level of the handles all elements independently while the second level expects all or none to exist. For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

Returns:

  • OutputActionEnum –

    The output action corresponding to the input and output files.

Source code in points2prints/utils/input_output.py
def handle_output(
    self,
    message_prefix: str,
    behaviour: OutputBehaviour,
    output_files: Sequence[Sequence[Path]],
) -> OutputActionEnum:
    """Handle output files based on the specified output action.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    behaviour: OutputBehaviour
        The handler for input and output file issues.
    output_files : Sequence[Sequence[Path]], optional
        A list of list of paths to output files.
        The first level of the handles all elements independently while the second level expects all or none to exist.
        For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

    Returns
    -------
    OutputActionEnum
        The output action corresponding to the input and output files.
    """
    output_actions = self.get_output_actions(
        message_prefix=message_prefix, output_files=output_files
    )

    match behaviour:
        case OutputBehaviour.ALL_OR_NOTHING:
            for action in output_actions:
                action.raise_if_error().log()

            all_proceed = all(action.is_proceed() for action in output_actions)
            all_skip = all(action.is_skip() for action in output_actions)
            if all_proceed:
                return OutputActionEnum.PROCEED
            elif all_skip:
                return OutputActionEnum.SKIP
            else:
                raise RuntimeError(
                    f"{message_prefix}: Inconsistent output actions: [\n{"\n".join("\t" + str(action) for action in output_actions)}\n]. All output files must either be created or skipped."
                )
        case _:
            raise NotImplementedError(f"Invalid OutputBehaviour value: {behaviour}")

Result

Result(inner: Union[Ok[T], Err[E]])

Bases: Generic[T, E]

Rust-like Result type: - Ok(value) - Err(error)

Use unwrap() / expect() to fail fast.

Methods:

  • expect –

    Return value or raise RuntimeError(message) chained from stored error.

  • unwrap –

    Return value or raise stored error.

  • unwrap_err –

    Return error or raise if this is Ok.

Source code in points2prints/utils/result.py
def __init__(self, inner: Union[Ok[T], Err[E]]):
    self._inner = inner

expect

expect(message: str) -> T

Return value or raise RuntimeError(message) chained from stored error.

Source code in points2prints/utils/result.py
def expect(self, message: str) -> T:
    """Return value or raise RuntimeError(message) chained from stored error."""
    if isinstance(self._inner, Ok):
        return self._inner.value
    raise RuntimeError(message) from self._inner.error

unwrap

unwrap() -> T

Return value or raise stored error.

Source code in points2prints/utils/result.py
def unwrap(self) -> T:
    """Return value or raise stored error."""
    if isinstance(self._inner, Ok):
        return self._inner.value
    raise self._inner.error

unwrap_err

unwrap_err() -> E

Return error or raise if this is Ok.

Source code in points2prints/utils/result.py
def unwrap_err(self) -> E:
    """Return error or raise if this is Ok."""
    if isinstance(self._inner, Err):
        return self._inner.error
    raise RuntimeError(f"Called unwrap_err on Ok({self._inner.value!r})")

run_command_with_tqdm_logging

run_command_with_tqdm_logging(command: list[str], display: bool = True) -> int

Run a command and handle logging its standard output and standard error properly.

Parameters:

  • command

    (list[str]) –

    The command to run.

  • display

    (bool, default: True ) –

    Whether to display the command output. By default False.

Returns:

  • int –

    description

Source code in points2prints/utils/custom_logging.py
def run_command_with_tqdm_logging(command: list[str], display: bool = True) -> int:
    """
    Run a command and handle logging its standard output and standard error properly.

    Parameters
    ----------
    command : list[str]
        The command to run.
    display : bool, optional
        Whether to display the command output.
        By default False.

    Returns
    -------
    int
        _description_
    """
    logging.debug(f"Running this command: {" ".join(command)}")
    env = os.environ.copy()
    env.setdefault("PY_COLORS", "1")
    env.setdefault("CLICOLOR_FORCE", "1")
    env.setdefault("FORCE_COLOR", "1")
    env.setdefault("TERM", "xterm-256color")

    if os.name == "posix":
        if display:
            parent_fd, child_fd = pty.openpty()
            try:
                process = subprocess.Popen(
                    command,
                    stdout=child_fd,
                    stderr=child_fd,
                    text=False,
                    env=env,
                )
            finally:
                os.close(child_fd)

            try:
                while True:
                    try:
                        chunk = os.read(parent_fd, 4096)
                    except OSError:
                        break

                    if not chunk:
                        break

                    sys.stdout.buffer.write(chunk)
                    sys.stdout.buffer.flush()
            finally:
                os.close(parent_fd)

        else:
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=False,
                env=env,
            )

        return_code = process.wait()
        logging.debug(f"Return code for {" ".join(command)}: {return_code}")
        return return_code

    else:
        process = subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=False,
            bufsize=0,
            env=env,
        )

        def _forward_stream(stream, target_buffer):
            if stream is None:
                return

            try:
                while True:
                    chunk = stream.read(4096)
                    if not chunk:
                        break
                    target_buffer.write(chunk)
                    target_buffer.flush()
            finally:
                stream.close()

        stdout_thread = threading.Thread(
            target=_forward_stream,
            args=(process.stdout, sys.stdout.buffer),
            daemon=True,
        )
        stderr_thread = threading.Thread(
            target=_forward_stream,
            args=(process.stderr, sys.stderr.buffer),
            daemon=True,
        )

        stdout_thread.start()
        stderr_thread.start()

        return_code = process.wait()
        stdout_thread.join()
        stderr_thread.join()
        return return_code

custom_logging

Functions:

run_command_with_tqdm_logging

run_command_with_tqdm_logging(command: list[str], display: bool = True) -> int

Run a command and handle logging its standard output and standard error properly.

Parameters:

  • command
    (list[str]) –

    The command to run.

  • display
    (bool, default: True ) –

    Whether to display the command output. By default False.

Returns:

  • int –

    description

Source code in points2prints/utils/custom_logging.py
def run_command_with_tqdm_logging(command: list[str], display: bool = True) -> int:
    """
    Run a command and handle logging its standard output and standard error properly.

    Parameters
    ----------
    command : list[str]
        The command to run.
    display : bool, optional
        Whether to display the command output.
        By default False.

    Returns
    -------
    int
        _description_
    """
    logging.debug(f"Running this command: {" ".join(command)}")
    env = os.environ.copy()
    env.setdefault("PY_COLORS", "1")
    env.setdefault("CLICOLOR_FORCE", "1")
    env.setdefault("FORCE_COLOR", "1")
    env.setdefault("TERM", "xterm-256color")

    if os.name == "posix":
        if display:
            parent_fd, child_fd = pty.openpty()
            try:
                process = subprocess.Popen(
                    command,
                    stdout=child_fd,
                    stderr=child_fd,
                    text=False,
                    env=env,
                )
            finally:
                os.close(child_fd)

            try:
                while True:
                    try:
                        chunk = os.read(parent_fd, 4096)
                    except OSError:
                        break

                    if not chunk:
                        break

                    sys.stdout.buffer.write(chunk)
                    sys.stdout.buffer.flush()
            finally:
                os.close(parent_fd)

        else:
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=False,
                env=env,
            )

        return_code = process.wait()
        logging.debug(f"Return code for {" ".join(command)}: {return_code}")
        return return_code

    else:
        process = subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=False,
            bufsize=0,
            env=env,
        )

        def _forward_stream(stream, target_buffer):
            if stream is None:
                return

            try:
                while True:
                    chunk = stream.read(4096)
                    if not chunk:
                        break
                    target_buffer.write(chunk)
                    target_buffer.flush()
            finally:
                stream.close()

        stdout_thread = threading.Thread(
            target=_forward_stream,
            args=(process.stdout, sys.stdout.buffer),
            daemon=True,
        )
        stderr_thread = threading.Thread(
            target=_forward_stream,
            args=(process.stderr, sys.stderr.buffer),
            daemon=True,
        )

        stdout_thread.start()
        stderr_thread.start()

        return_code = process.wait()
        stdout_thread.join()
        stderr_thread.join()
        return return_code

input_output

Classes:

InputOutput

Bases: Enum

Methods:

get_output_actions
get_output_actions(message_prefix: str, output_files: Sequence[Sequence[Path]]) -> List[OutputAction]

Handle input and output files based on the specified output action.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • output_files
    (Sequence[Sequence[Path]]) –

    A list of list of paths to output files. The first level of the handles all elements independently while the second level expects all or none to exist. For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

Returns:

  • List[OutputAction] –

    A list of output actions corresponding to the input and output files.

Raises:

Source code in points2prints/utils/input_output.py
def get_output_actions(
    self,
    message_prefix: str,
    output_files: Sequence[Sequence[Path]],
) -> List[OutputAction]:
    """Handle input and output files based on the specified output action.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    output_files : Sequence[Sequence[Path]]
        A list of list of paths to output files.
        The first level of the handles all elements independently while the second level expects all or none to exist.
        For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

    Returns
    -------
    List[OutputAction]
        A list of output actions corresponding to the input and output files.

    Raises
    ------
    NotImplementedError
        If the current InputOutput has an unexpected value.
    """
    # Check for existing output files
    input_outputs: List[OutputAction] = []
    for linked_output_files in output_files:
        existing_files: List[Path] = []
        for file_path in linked_output_files:
            if file_path.exists():
                existing_files.append(file_path)
            else:
                file_path.parent.mkdir(parents=True, exist_ok=True)

        existing_files_str = ", ".join(str(f) for f in existing_files)
        not_existing_files_str = ", ".join(
            str(f) for f in linked_output_files if f not in existing_files
        )

        match self:
            case InputOutput.SKIP_EXISTING:
                if len(existing_files) == len(linked_output_files):
                    message = f"{message_prefix}: These output files already exist and will be skipped: {existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.SKIP, message)
                elif len(existing_files) > 0:
                    error_message = f"{message_prefix}: Cannot skip existing files because these output files already exist: {existing_files_str} but these do not: {not_existing_files_str}."
                    input_output = OutputAction(
                        OutputActionEnum.ERROR, error_message
                    )
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.PROCEED, message)

            case InputOutput.OVERWRITE:
                if len(existing_files) > 0:
                    message = f"{message_prefix}: These output files already exist and will be overwritten: {existing_files_str}."
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                input_output = OutputAction(OutputActionEnum.PROCEED, message)
            case InputOutput.NONE:
                if len(existing_files) > 0:
                    error_message = f"{message_prefix}: Some output files already exist: {existing_files_str}. Use --overwrite to overwrite them or --skip-existing to skip processing if they already exist."
                    input_output = OutputAction(
                        OutputActionEnum.ERROR, error_message
                    )
                else:
                    message = f"{message_prefix}: These output files do not exist and will be created: {not_existing_files_str}."
                    input_output = OutputAction(OutputActionEnum.PROCEED, message)
            case _:
                message = f"{message_prefix}: Invalid InputOutput value: {self}"
                raise NotImplementedError(message)

        input_outputs.append(input_output)

    return input_outputs
handle_input

Validate input files.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • input_files
    (List[Path]) –

    A list of paths to input files.

Raises:

  • InputFileNotFoundError –

    If any of the input files do not exist.

  • InputFileIsNotFileError –

    If any of the input files are not valid files.

  • InputFileIsEmptyError –

    If any of the input files are empty.

Source code in points2prints/utils/input_output.py
def handle_input(
    self,
    message_prefix: str,
    input_files: List[Path],
):
    """
    Validate input files.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    input_files : List[Path]
        A list of paths to input files.
    Raises
    ------
    InputFileNotFoundError
        If any of the input files do not exist.
    InputFileIsNotFileError
        If any of the input files are not valid files.
    InputFileIsEmptyError
        If any of the input files are empty.
    """
    # Validate input files
    for file in input_files:
        if not file.exists():
            raise InputFileNotFoundError(f"Input file {file} does not exist.")
        if not file.is_file():
            raise InputFileIsNotFileError(f"Input file {file} is not a file.")
        if not file.stat().st_size > 0:
            raise InputFileIsEmptyError(f"Input file {file} is empty.")
handle_output
handle_output(message_prefix: str, behaviour: OutputBehaviour, output_files: Sequence[Sequence[Path]]) -> OutputActionEnum

Handle output files based on the specified output action.

Parameters:

  • message_prefix
    (str) –

    A prefix for log messages.

  • behaviour
    (OutputBehaviour) –

    The handler for input and output file issues.

  • output_files
    (Sequence[Sequence[Path]]) –

    A list of list of paths to output files. The first level of the handles all elements independently while the second level expects all or none to exist. For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

Returns:

  • OutputActionEnum –

    The output action corresponding to the input and output files.

Source code in points2prints/utils/input_output.py
def handle_output(
    self,
    message_prefix: str,
    behaviour: OutputBehaviour,
    output_files: Sequence[Sequence[Path]],
) -> OutputActionEnum:
    """Handle output files based on the specified output action.

    Parameters
    ----------
    message_prefix : str
        A prefix for log messages.
    behaviour: OutputBehaviour
        The handler for input and output file issues.
    output_files : Sequence[Sequence[Path]], optional
        A list of list of paths to output files.
        The first level of the handles all elements independently while the second level expects all or none to exist.
        For example, if output_files = [[path1, path2], [path3]], we need path1 and path2 to both exist or none to exist, and path3 can exist or not independently.

    Returns
    -------
    OutputActionEnum
        The output action corresponding to the input and output files.
    """
    output_actions = self.get_output_actions(
        message_prefix=message_prefix, output_files=output_files
    )

    match behaviour:
        case OutputBehaviour.ALL_OR_NOTHING:
            for action in output_actions:
                action.raise_if_error().log()

            all_proceed = all(action.is_proceed() for action in output_actions)
            all_skip = all(action.is_skip() for action in output_actions)
            if all_proceed:
                return OutputActionEnum.PROCEED
            elif all_skip:
                return OutputActionEnum.SKIP
            else:
                raise RuntimeError(
                    f"{message_prefix}: Inconsistent output actions: [\n{"\n".join("\t" + str(action) for action in output_actions)}\n]. All output files must either be created or skipped."
                )
        case _:
            raise NotImplementedError(f"Invalid OutputBehaviour value: {behaviour}")

result

Classes:

  • Result –

    Rust-like Result type:

Result

Result(inner: Union[Ok[T], Err[E]])

Bases: Generic[T, E]

Rust-like Result type: - Ok(value) - Err(error)

Use unwrap() / expect() to fail fast.

Methods:

  • expect –

    Return value or raise RuntimeError(message) chained from stored error.

  • unwrap –

    Return value or raise stored error.

  • unwrap_err –

    Return error or raise if this is Ok.

Source code in points2prints/utils/result.py
def __init__(self, inner: Union[Ok[T], Err[E]]):
    self._inner = inner
expect
expect(message: str) -> T

Return value or raise RuntimeError(message) chained from stored error.

Source code in points2prints/utils/result.py
def expect(self, message: str) -> T:
    """Return value or raise RuntimeError(message) chained from stored error."""
    if isinstance(self._inner, Ok):
        return self._inner.value
    raise RuntimeError(message) from self._inner.error
unwrap
unwrap() -> T

Return value or raise stored error.

Source code in points2prints/utils/result.py
def unwrap(self) -> T:
    """Return value or raise stored error."""
    if isinstance(self._inner, Ok):
        return self._inner.value
    raise self._inner.error
unwrap_err
unwrap_err() -> E

Return error or raise if this is Ok.

Source code in points2prints/utils/result.py
def unwrap_err(self) -> E:
    """Return error or raise if this is Ok."""
    if isinstance(self._inner, Err):
        return self._inner.error
    raise RuntimeError(f"Called unwrap_err on Ok({self._inner.value!r})")

validation

Preparation of the validation dataset and metrics utilities.

Modules:

Functions:

compare_polygon_datasets_implementation

compare_polygon_datasets_implementation(ground_truth_path: Path, scored_path: Path, output_path: Path, id_column: str, spacing_m: float, keep_columns: list[str] | None, input_output: InputOutput) -> None

Compute metrics against a prebuilt aggregated ground-truth dataset.

Parameters:

  • ground_truth_path

    (Path) –

    Path to the aggregated ground-truth polygon dataset.

  • scored_path

    (Path) –

    Path to the scored polygon dataset.

  • output_path

    (Path) –

    Path where the per-pair metrics table will be written (.csv, .parquet or .json).

  • id_column

    (str) –

    Column name of unique IDs in the scored dataset.

  • spacing_m

    (float) –

    Sampling spacing in metres used for boundary distances.

  • keep_columns

    (list[str] | None) –

    Additional columns from the ground-truth dataset to keep in the output.

  • input_output

    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/validation/metrics.py
def compare_polygon_datasets_implementation(
    ground_truth_path: Path,
    scored_path: Path,
    output_path: Path,
    id_column: str,
    spacing_m: float,
    keep_columns: list[str] | None,
    input_output: InputOutput,
) -> None:
    """Compute metrics against a prebuilt aggregated ground-truth dataset.

    Parameters
    ----------
    ground_truth_path : Path
        Path to the aggregated ground-truth polygon dataset.
    scored_path : Path
        Path to the scored polygon dataset.
    output_path : Path
        Path where the per-pair metrics table will be written (.csv, .parquet or .json).
    id_column : str
        Column name of unique IDs in the scored dataset.
    spacing_m : float
        Sampling spacing in metres used for boundary distances.
    keep_columns : list[str] | None
        Additional columns from the ground-truth dataset to keep in the output.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    input_output.handle_input(
        message_prefix="Comparing polygon datasets",
        input_files=[ground_truth_path, scored_path],
    )
    input_output.handle_output(
        message_prefix="Comparing polygon datasets",
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )

    ground_truth_dataset = _read_polygon_dataset(ground_truth_path)

    if _is_aggregated_ground_truth_dataset(ground_truth_dataset):
        _validate_aggregated_ground_truth_dataset(ground_truth_dataset)
    else:
        _validate_input_columns(ground_truth_dataset, id_column, "ground-truth")

    source_ids = _collect_ground_truth_source_ids(ground_truth_dataset, id_column)
    scored_dataset = _read_filtered_scored_dataset(scored_path, id_column, source_ids)

    normalized_keep_columns = _normalize_keep_columns(
        ground_truth_dataset, id_column, keep_columns or []
    )

    _validate_input_columns(scored_dataset, id_column, "scored")
    _validate_matching_crs(ground_truth_dataset, scored_dataset)

    rows: list[dict[str, object]] = []
    for _, ground_truth_row in ground_truth_dataset.iterrows():
        if _is_aggregated_ground_truth_dataset(ground_truth_dataset):
            source_ids = ground_truth_row["ground_truth_ids"]
            aggregate_id = ground_truth_row["ground_truth_aggregate_id"]
            ground_truth_area_m2 = float(ground_truth_row["ground_truth_area_m2"])
        else:
            aggregate_id = ground_truth_row[id_column]
            source_ids = [aggregate_id]
            ground_truth_area_m2 = float(ground_truth_row.geometry.area)

        scored_aggregate = _build_scored_aggregate(
            scored_dataset, source_ids, id_column
        )
        if scored_aggregate is None:
            continue

        scored_ids, scored_geometry = scored_aggregate
        metrics = _compare_polygon_pair(
            ground_truth_row.geometry,
            scored_geometry,
            spacing_m,
        )
        row: dict[str, object] = {
            "ground_truth_aggregate_id": aggregate_id,
            "ground_truth_ids": source_ids,
            "scored_ids": scored_ids,
        }
        for column_name in normalized_keep_columns:
            row[column_name] = _coerce_keep_column_values(ground_truth_row[column_name])
        row.update(
            {
                "geometry": scored_geometry,
                "ground_truth_area_m2": ground_truth_area_m2,
                **metrics,
            }
        )
        rows.append(row)

    ordered_columns = [
        "ground_truth_aggregate_id",
        "ground_truth_ids",
        *normalized_keep_columns,
        "scored_ids",
        "geometry",
        "ground_truth_area_m2",
        "iou",
        "ground_truth_to_scored_boundary_distance_m",
        "scored_to_ground_truth_boundary_distance_m",
        "symmetric_boundary_distance_m",
        "centroid_distance_m",
    ]
    paired_results = pd.DataFrame(rows)
    for column_name in ordered_columns:
        if column_name not in paired_results.columns:
            paired_results[column_name] = pd.Series(dtype="object")
    paired_results = paired_results.reindex(columns=ordered_columns)
    paired_results = gpd.GeoDataFrame(
        paired_results,
        geometry="geometry",
        crs=scored_dataset.crs,
    )

    matched_scored_ids = pd.Index(
        [
            scored_id
            for scored_ids in paired_results.get("scored_ids", [])
            for scored_id in (scored_ids if isinstance(scored_ids, list) else [])
        ]
    )

    summary: dict[str, float | int] = {
        "ground_truth_count": len(ground_truth_dataset),
        "scored_count": len(scored_dataset),
        "matched_count": len(paired_results),
        "ignored_ground_truth_count": len(ground_truth_dataset) - len(paired_results),
        "ignored_scored_count": len(scored_dataset)
        - len(pd.Index(scored_dataset[id_column]).intersection(matched_scored_ids)),
    }

    if not paired_results.empty:
        summary.update(
            {
                "mean_iou": float(paired_results["iou"].mean()),
                "mean_ground_truth_to_scored_boundary_distance_m": float(
                    paired_results["ground_truth_to_scored_boundary_distance_m"].mean()
                ),
                "mean_scored_to_ground_truth_boundary_distance_m": float(
                    paired_results["scored_to_ground_truth_boundary_distance_m"].mean()
                ),
                "mean_symmetric_boundary_distance_m": float(
                    paired_results["symmetric_boundary_distance_m"].mean()
                ),
                "mean_centroid_distance_m": float(
                    paired_results["centroid_distance_m"].mean()
                ),
            }
        )
    else:
        summary.update(
            {
                "mean_iou": 0.0,
                "mean_ground_truth_to_scored_boundary_distance_m": 0.0,
                "mean_scored_to_ground_truth_boundary_distance_m": 0.0,
                "mean_symmetric_boundary_distance_m": 0.0,
                "mean_centroid_distance_m": 0.0,
            }
        )

    metrics_result = PolygonMetricsResult(
        paired_results=paired_results, summary=summary
    )

    write_comparison_results(metrics_result.paired_results, output_path, input_output)
    logging.info(_format_summary(metrics_result.summary, output_path))

prepare_validation_dataset_implementation

prepare_validation_dataset_implementation(input_ground_truth_path: Path, id_column: str, individual_output_path: Path, aggregated_output_path: Path, keep_columns: list[str] | None, input_output: InputOutput) -> None

Persist both the raw validation dataset and the dissolved aggregate dataset.

Source code in points2prints/validation/processing.py
def prepare_validation_dataset_implementation(
    input_ground_truth_path: Path,
    id_column: str,
    individual_output_path: Path,
    aggregated_output_path: Path,
    keep_columns: list[str] | None,
    input_output: InputOutput,
) -> None:
    """Persist both the raw validation dataset and the dissolved aggregate dataset."""
    if individual_output_path == aggregated_output_path:
        raise ValueError("The individual and aggregated output paths must differ.")

    message_prefix = "Preparing validation datasets"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[input_ground_truth_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[individual_output_path, aggregated_output_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    ground_truth = _read_polygon_dataset(input_ground_truth_path)
    _validate_input_columns(ground_truth, id_column, "ground-truth")
    if ground_truth.crs is None:
        raise ValueError("The ground-truth dataset is missing CRS information.")
    normalized_keep_columns = _normalize_keep_columns(
        ground_truth, id_column, keep_columns or []
    )

    individual_dataset = ground_truth.copy()
    aggregated_dataset = _build_aggregated_ground_truth_dataset(
        ground_truth,
        id_column,
        normalized_keep_columns,
    )

    _write_polygon_dataset(individual_dataset, individual_output_path, input_output)
    _write_polygon_dataset(aggregated_dataset, aggregated_output_path, input_output)

    logging.info(
        "\n".join(
            [
                f"Validation dataset written to: {individual_output_path}",
                f"Aggregated validation dataset written to: {aggregated_output_path}",
            ]
        )
    )

metrics

Classes:

Functions:

PolygonMetricsResult dataclass

PolygonMetricsResult(paired_results: DataFrame, summary: dict[str, float | int])

Container for the per-pair metrics and global summary.

Parameters:

  • paired_results
    (DataFrame) –

    GeoDataFrame with per-pair (or per-aggregate) metrics and geometries.

  • summary
    (dict) –

    Dictionary with aggregated summary statistics (counts, means).

clean_polygon_topology_call

clean_polygon_topology_call(input_path: Path, output_path: Path, threshold_m: float, input_output: InputOutput, verbose: Verbose) -> None

Call wrapper for :func:clean_polygon_topology_implementation.

Parameters:

  • input_path
    (Path) –

    Path to the input polygon dataset.

  • output_path
    (Path) –

    Path where the cleaned polygon dataset will be written.

  • threshold_m
    (float) –

    Vertex merge threshold in metres.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/validation/metrics.py
def clean_polygon_topology_call(
    input_path: Path,
    output_path: Path,
    threshold_m: float,
    input_output: InputOutput,
    verbose: Verbose,
) -> None:
    """Call wrapper for :func:`clean_polygon_topology_implementation`.

    Parameters
    ----------
    input_path : Path
        Path to the input polygon dataset.
    output_path : Path
        Path where the cleaned polygon dataset will be written.
    threshold_m : float
        Vertex merge threshold in metres.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.

    """
    with LoggingContext(verbose=verbose):
        return clean_polygon_topology_implementation(
            input_path=input_path,
            output_path=output_path,
            threshold_m=threshold_m,
            input_output=input_output,
        )

clean_polygon_topology_implementation

clean_polygon_topology_implementation(input_path: Path, output_path: Path, threshold_m: float, input_output: InputOutput) -> None

Clean polygon topology by snapping nearby vertices and rebuilding rings.

Parameters:

  • input_path
    (Path) –

    Path to the input polygon dataset (.parquet or .gpkg).

  • output_path
    (Path) –

    Path where the cleaned polygon dataset will be written (.parquet or .gpkg).

  • threshold_m
    (float) –

    Distance threshold in metres used to merge nearby vertices.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/validation/metrics.py
def clean_polygon_topology_implementation(
    input_path: Path,
    output_path: Path,
    threshold_m: float,
    input_output: InputOutput,
) -> None:
    """Clean polygon topology by snapping nearby vertices and rebuilding rings.

    Parameters
    ----------
    input_path : Path
        Path to the input polygon dataset (.parquet or .gpkg).
    output_path : Path
        Path where the cleaned polygon dataset will be written (.parquet or .gpkg).
    threshold_m : float
        Distance threshold in metres used to merge nearby vertices.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    input_output.handle_input(
        message_prefix="Cleaning polygon topology",
        input_files=[input_path],
    )

    if threshold_m <= 0:
        raise ValueError("The merge threshold must be strictly positive.")

    dataset = _read_polygon_dataset(input_path)
    if dataset.crs is None:
        raise ValueError("The input dataset is missing CRS information.")

    polygons_by_feature: list[list[Polygon]] = []
    coordinate_values: list[tuple[float, float]] = []
    coordinate_slices: list[list[int]] = []

    for row_id, geometry in enumerate(dataset.geometry):
        if geometry is None or geometry.is_empty:
            raise ValueError(f"Polygon {row_id!r} in the input dataset is empty.")
        if geometry.geom_type not in {"Polygon", "MultiPolygon"}:
            raise ValueError(
                f"Polygon {row_id!r} in the input dataset must be a Polygon or MultiPolygon, got {geometry.geom_type}."
            )

        feature_polygons = _polygon_parts(geometry)
        if not feature_polygons:
            raise ValueError(
                f"Polygon {row_id!r} in the input dataset could not be read as a polygonal geometry."
            )

        feature_coordinate_indices: list[int] = []
        for polygon in feature_polygons:
            for coordinate in polygon.exterior.coords:
                coordinate_values.append((float(coordinate[0]), float(coordinate[1])))
                feature_coordinate_indices.append(len(coordinate_values) - 1)
            for interior in polygon.interiors:
                for coordinate in interior.coords:
                    coordinate_values.append(
                        (float(coordinate[0]), float(coordinate[1]))
                    )
                    feature_coordinate_indices.append(len(coordinate_values) - 1)

        polygons_by_feature.append(feature_polygons)
        coordinate_slices.append(feature_coordinate_indices)

    cleaned_dataset = dataset.copy()

    if coordinate_values:
        representatives = _build_vertex_representatives(coordinate_values, threshold_m)

        cleaned_geometries: list[Polygon | MultiPolygon] = []
        for row_id, feature_polygons in enumerate(polygons_by_feature):
            cleaned_polygons: list[Polygon] = []
            coordinate_cursor = 0
            feature_indices = coordinate_slices[row_id]

            for polygon in feature_polygons:
                ring_coordinate_count = len(polygon.exterior.coords)
                polygon_coordinate_indices = feature_indices[
                    coordinate_cursor : coordinate_cursor + ring_coordinate_count
                ]
                coordinate_cursor += ring_coordinate_count

                for interior in polygon.interiors:
                    polygon_coordinate_indices.extend(
                        feature_indices[
                            coordinate_cursor : coordinate_cursor + len(interior.coords)
                        ]
                    )
                    coordinate_cursor += len(interior.coords)

                cleaned_polygon = _rebuild_polygon_from_vertex_representatives(
                    polygon,
                    representatives,
                    polygon_coordinate_indices,
                    "input",
                    row_id,
                )
                if isinstance(cleaned_polygon, Polygon):
                    cleaned_polygons.append(cleaned_polygon)
                else:
                    cleaned_polygons.extend(list(cleaned_polygon.geoms))

            if len(cleaned_polygons) == 1:
                cleaned_geometries.append(cleaned_polygons[0])
            else:
                cleaned_geometries.append(MultiPolygon(cleaned_polygons))

        cleaned_dataset = dataset.copy()
        cleaned_dataset.geometry = cleaned_geometries

    write_polygon_topology_results(
        results=cleaned_dataset,
        output_path=output_path,
        input_output=input_output,
    )

    logging.info(
        "\n".join(
            [
                f"Cleaned polygon features: {len(cleaned_dataset)}",
                f"Results written to: {output_path}",
            ]
        )
    )

compare_polygon_datasets_call

compare_polygon_datasets_call(ground_truth_path: Path, scored_path: Path, output_path: Path, id_column: str, spacing_m: float, keep_columns: list[str] | None, input_output: InputOutput, verbose: Verbose) -> None

Entry-point wrapper for dataset comparison with logging context.

Parameters:

  • ground_truth_path
    (Path) –

    Path to the aggregated ground-truth dataset.

  • scored_path
    (Path) –

    Path to the scored dataset.

  • output_path
    (Path) –

    Path where the per-pair metrics table will be written (.csv, .parquet or .json).

  • id_column
    (str) –

    Column name used for matching IDs.

  • spacing_m
    (float) –

    Sampling spacing in metres.

  • keep_columns
    (list[str] | None) –

    Additional columns from the ground-truth dataset to keep in the output.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

  • verbose
    (Verbose) –

    The verbosity level for logging.

Source code in points2prints/validation/metrics.py
def compare_polygon_datasets_call(
    ground_truth_path: Path,
    scored_path: Path,
    output_path: Path,
    id_column: str,
    spacing_m: float,
    keep_columns: list[str] | None,
    input_output: InputOutput,
    verbose: Verbose,
) -> None:
    """Entry-point wrapper for dataset comparison with logging context.

    Parameters
    ----------
    ground_truth_path : Path
        Path to the aggregated ground-truth dataset.
    scored_path : Path
        Path to the scored dataset.
    output_path : Path
        Path where the per-pair metrics table will be written (.csv, .parquet or .json).
    id_column : str
        Column name used for matching IDs.
    spacing_m : float
        Sampling spacing in metres.
    keep_columns : list[str] | None
        Additional columns from the ground-truth dataset to keep in the output.
    input_output: InputOutput
        The handler for input and output file issues.
    verbose: Verbose
        The verbosity level for logging.
    """
    with LoggingContext(verbose=verbose):
        compare_polygon_datasets_implementation(
            ground_truth_path=ground_truth_path,
            scored_path=scored_path,
            output_path=output_path,
            id_column=id_column,
            spacing_m=spacing_m,
            keep_columns=keep_columns,
            input_output=input_output,
        )

compare_polygon_datasets_implementation

compare_polygon_datasets_implementation(ground_truth_path: Path, scored_path: Path, output_path: Path, id_column: str, spacing_m: float, keep_columns: list[str] | None, input_output: InputOutput) -> None

Compute metrics against a prebuilt aggregated ground-truth dataset.

Parameters:

  • ground_truth_path
    (Path) –

    Path to the aggregated ground-truth polygon dataset.

  • scored_path
    (Path) –

    Path to the scored polygon dataset.

  • output_path
    (Path) –

    Path where the per-pair metrics table will be written (.csv, .parquet or .json).

  • id_column
    (str) –

    Column name of unique IDs in the scored dataset.

  • spacing_m
    (float) –

    Sampling spacing in metres used for boundary distances.

  • keep_columns
    (list[str] | None) –

    Additional columns from the ground-truth dataset to keep in the output.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/validation/metrics.py
def compare_polygon_datasets_implementation(
    ground_truth_path: Path,
    scored_path: Path,
    output_path: Path,
    id_column: str,
    spacing_m: float,
    keep_columns: list[str] | None,
    input_output: InputOutput,
) -> None:
    """Compute metrics against a prebuilt aggregated ground-truth dataset.

    Parameters
    ----------
    ground_truth_path : Path
        Path to the aggregated ground-truth polygon dataset.
    scored_path : Path
        Path to the scored polygon dataset.
    output_path : Path
        Path where the per-pair metrics table will be written (.csv, .parquet or .json).
    id_column : str
        Column name of unique IDs in the scored dataset.
    spacing_m : float
        Sampling spacing in metres used for boundary distances.
    keep_columns : list[str] | None
        Additional columns from the ground-truth dataset to keep in the output.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    input_output.handle_input(
        message_prefix="Comparing polygon datasets",
        input_files=[ground_truth_path, scored_path],
    )
    input_output.handle_output(
        message_prefix="Comparing polygon datasets",
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )

    ground_truth_dataset = _read_polygon_dataset(ground_truth_path)

    if _is_aggregated_ground_truth_dataset(ground_truth_dataset):
        _validate_aggregated_ground_truth_dataset(ground_truth_dataset)
    else:
        _validate_input_columns(ground_truth_dataset, id_column, "ground-truth")

    source_ids = _collect_ground_truth_source_ids(ground_truth_dataset, id_column)
    scored_dataset = _read_filtered_scored_dataset(scored_path, id_column, source_ids)

    normalized_keep_columns = _normalize_keep_columns(
        ground_truth_dataset, id_column, keep_columns or []
    )

    _validate_input_columns(scored_dataset, id_column, "scored")
    _validate_matching_crs(ground_truth_dataset, scored_dataset)

    rows: list[dict[str, object]] = []
    for _, ground_truth_row in ground_truth_dataset.iterrows():
        if _is_aggregated_ground_truth_dataset(ground_truth_dataset):
            source_ids = ground_truth_row["ground_truth_ids"]
            aggregate_id = ground_truth_row["ground_truth_aggregate_id"]
            ground_truth_area_m2 = float(ground_truth_row["ground_truth_area_m2"])
        else:
            aggregate_id = ground_truth_row[id_column]
            source_ids = [aggregate_id]
            ground_truth_area_m2 = float(ground_truth_row.geometry.area)

        scored_aggregate = _build_scored_aggregate(
            scored_dataset, source_ids, id_column
        )
        if scored_aggregate is None:
            continue

        scored_ids, scored_geometry = scored_aggregate
        metrics = _compare_polygon_pair(
            ground_truth_row.geometry,
            scored_geometry,
            spacing_m,
        )
        row: dict[str, object] = {
            "ground_truth_aggregate_id": aggregate_id,
            "ground_truth_ids": source_ids,
            "scored_ids": scored_ids,
        }
        for column_name in normalized_keep_columns:
            row[column_name] = _coerce_keep_column_values(ground_truth_row[column_name])
        row.update(
            {
                "geometry": scored_geometry,
                "ground_truth_area_m2": ground_truth_area_m2,
                **metrics,
            }
        )
        rows.append(row)

    ordered_columns = [
        "ground_truth_aggregate_id",
        "ground_truth_ids",
        *normalized_keep_columns,
        "scored_ids",
        "geometry",
        "ground_truth_area_m2",
        "iou",
        "ground_truth_to_scored_boundary_distance_m",
        "scored_to_ground_truth_boundary_distance_m",
        "symmetric_boundary_distance_m",
        "centroid_distance_m",
    ]
    paired_results = pd.DataFrame(rows)
    for column_name in ordered_columns:
        if column_name not in paired_results.columns:
            paired_results[column_name] = pd.Series(dtype="object")
    paired_results = paired_results.reindex(columns=ordered_columns)
    paired_results = gpd.GeoDataFrame(
        paired_results,
        geometry="geometry",
        crs=scored_dataset.crs,
    )

    matched_scored_ids = pd.Index(
        [
            scored_id
            for scored_ids in paired_results.get("scored_ids", [])
            for scored_id in (scored_ids if isinstance(scored_ids, list) else [])
        ]
    )

    summary: dict[str, float | int] = {
        "ground_truth_count": len(ground_truth_dataset),
        "scored_count": len(scored_dataset),
        "matched_count": len(paired_results),
        "ignored_ground_truth_count": len(ground_truth_dataset) - len(paired_results),
        "ignored_scored_count": len(scored_dataset)
        - len(pd.Index(scored_dataset[id_column]).intersection(matched_scored_ids)),
    }

    if not paired_results.empty:
        summary.update(
            {
                "mean_iou": float(paired_results["iou"].mean()),
                "mean_ground_truth_to_scored_boundary_distance_m": float(
                    paired_results["ground_truth_to_scored_boundary_distance_m"].mean()
                ),
                "mean_scored_to_ground_truth_boundary_distance_m": float(
                    paired_results["scored_to_ground_truth_boundary_distance_m"].mean()
                ),
                "mean_symmetric_boundary_distance_m": float(
                    paired_results["symmetric_boundary_distance_m"].mean()
                ),
                "mean_centroid_distance_m": float(
                    paired_results["centroid_distance_m"].mean()
                ),
            }
        )
    else:
        summary.update(
            {
                "mean_iou": 0.0,
                "mean_ground_truth_to_scored_boundary_distance_m": 0.0,
                "mean_scored_to_ground_truth_boundary_distance_m": 0.0,
                "mean_symmetric_boundary_distance_m": 0.0,
                "mean_centroid_distance_m": 0.0,
            }
        )

    metrics_result = PolygonMetricsResult(
        paired_results=paired_results, summary=summary
    )

    write_comparison_results(metrics_result.paired_results, output_path, input_output)
    logging.info(_format_summary(metrics_result.summary, output_path))

write_comparison_results

write_comparison_results(results: DataFrame, output_path: Path, input_output: InputOutput) -> None

Write comparison results to CSV, GeoParquet or JSON.

Parameters:

  • results
    (DataFrame) –

    DataFrame or GeoDataFrame containing results. If a GeoDataFrame is supplied or a geometry column exists it will be written as GeoParquet when the output extension is .parquet.

  • output_path
    (Path) –

    Destination path. Supported extensions: .csv, .parquet and .json.

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/validation/metrics.py
def write_comparison_results(
    results: pd.DataFrame, output_path: Path, input_output: InputOutput
) -> None:
    """Write comparison results to CSV, GeoParquet or JSON.

    Parameters
    ----------
    results : pandas.DataFrame
        DataFrame or GeoDataFrame containing results. If a GeoDataFrame is
        supplied or a `geometry` column exists it will be written as GeoParquet
        when the output extension is ``.parquet``.
    output_path : Path
        Destination path. Supported extensions: ``.csv``, ``.parquet`` and ``.json``.
    input_output: InputOutput
        The handler for input and output file issues.
    """
    input_output.handle_output(
        message_prefix="Writing comparison results",
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )

    suffix = output_path.suffix.lower()
    if suffix == ".csv":
        csv_results = pd.DataFrame(results.copy())
        for column_name in csv_results.columns:
            csv_results[column_name] = csv_results[column_name].apply(
                lambda value: (
                    json.dumps(value)
                    if isinstance(value, (list, tuple, dict))
                    else (value.wkt if hasattr(value, "wkt") else value)
                )
            )
        csv_results.to_csv(output_path, index=False)
        return
    if suffix == ".json":
        json_results = pd.DataFrame(results.copy())

        def _serialize_value(v):
            try:
                if v is None or (isinstance(v, float) and pd.isna(v)):
                    return None
            except Exception:
                pass
            if hasattr(v, "wkt"):
                return getattr(v, "wkt")
            # numpy arrays and pandas arrays -> convert to list
            if hasattr(v, "tolist") and not isinstance(v, str):
                try:
                    lst = getattr(v, "tolist")()
                except Exception:
                    lst = None
                if lst is not None:
                    return [_serialize_value(x) for x in lst]
            if isinstance(v, (list, tuple)):
                return [_serialize_value(x) for x in v]
            if isinstance(v, dict):
                return {k: _serialize_value(val) for k, val in v.items()}
            # numpy scalar -> python native
            if hasattr(v, "item") and not isinstance(v, str):
                try:
                    return getattr(v, "item")()
                except Exception:
                    pass
            return v

        records: list[dict] = []
        for _, row in json_results.iterrows():
            record: dict = {}
            for column_name in json_results.columns:
                record[column_name] = _serialize_value(row[column_name])
            records.append(record)

        with output_path.open("w", encoding="utf-8") as fh:
            json.dump(records, fh, ensure_ascii=False, indent=2)
        return
    if suffix == ".parquet":
        if isinstance(results, gpd.GeoDataFrame):
            geo_results = results
        elif "geometry" in results.columns:
            geo_results = gpd.GeoDataFrame(results, geometry="geometry")
        else:
            results.to_parquet(output_path, index=False)
            return

        geo_results.to_parquet(
            output_path,
            index=False,
            write_covering_bbox=True,
            schema_version="1.1.0",
        )
        return
    raise ValueError("Unsupported output format. Use a .csv or .parquet file.")

write_polygon_topology_results

write_polygon_topology_results(results: GeoDataFrame, output_path: Path, input_output: InputOutput) -> None

Persist cleaned polygon topology results to disk.

Parameters:

  • results
    (GeoDataFrame) –

    Cleaned geometries to persist.

  • output_path
    (Path) –

    Destination path (.parquet or .gpkg supported).

  • input_output
    (InputOutput) –

    The handler for input and output file issues.

Source code in points2prints/validation/metrics.py
def write_polygon_topology_results(
    results: gpd.GeoDataFrame,
    output_path: Path,
    input_output: InputOutput,
) -> None:
    """Persist cleaned polygon topology results to disk.

    Parameters
    ----------
    results : geopandas.GeoDataFrame
        Cleaned geometries to persist.
    output_path : Path
        Destination path (.parquet or .gpkg supported).
    input_output: InputOutput
        The handler for input and output file issues.
    """
    input_output.handle_output(
        message_prefix="Writing cleaned polygon topology results",
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[output_path]],
    )

    suffix = output_path.suffix.lower()
    if suffix == ".parquet":
        results.to_parquet(
            output_path,
            index=False,
            write_covering_bbox=True,
            schema_version="1.1.0",
        )
        return
    if suffix == ".gpkg":
        results.to_file(output_path, driver="GPKG", index=False)
        return
    raise ValueError("Unsupported output format. Use a .parquet or .gpkg file.")

processing

Functions:

prepare_validation_dataset_implementation

prepare_validation_dataset_implementation(input_ground_truth_path: Path, id_column: str, individual_output_path: Path, aggregated_output_path: Path, keep_columns: list[str] | None, input_output: InputOutput) -> None

Persist both the raw validation dataset and the dissolved aggregate dataset.

Source code in points2prints/validation/processing.py
def prepare_validation_dataset_implementation(
    input_ground_truth_path: Path,
    id_column: str,
    individual_output_path: Path,
    aggregated_output_path: Path,
    keep_columns: list[str] | None,
    input_output: InputOutput,
) -> None:
    """Persist both the raw validation dataset and the dissolved aggregate dataset."""
    if individual_output_path == aggregated_output_path:
        raise ValueError("The individual and aggregated output paths must differ.")

    message_prefix = "Preparing validation datasets"
    input_output.handle_input(
        message_prefix=message_prefix,
        input_files=[input_ground_truth_path],
    )
    output_action = input_output.handle_output(
        message_prefix=message_prefix,
        behaviour=OutputBehaviour.ALL_OR_NOTHING,
        output_files=[[individual_output_path, aggregated_output_path]],
    )
    if output_action == OutputActionEnum.SKIP:
        return

    ground_truth = _read_polygon_dataset(input_ground_truth_path)
    _validate_input_columns(ground_truth, id_column, "ground-truth")
    if ground_truth.crs is None:
        raise ValueError("The ground-truth dataset is missing CRS information.")
    normalized_keep_columns = _normalize_keep_columns(
        ground_truth, id_column, keep_columns or []
    )

    individual_dataset = ground_truth.copy()
    aggregated_dataset = _build_aggregated_ground_truth_dataset(
        ground_truth,
        id_column,
        normalized_keep_columns,
    )

    _write_polygon_dataset(individual_dataset, individual_output_path, input_output)
    _write_polygon_dataset(aggregated_dataset, aggregated_output_path, input_output)

    logging.info(
        "\n".join(
            [
                f"Validation dataset written to: {individual_output_path}",
                f"Aggregated validation dataset written to: {aggregated_output_path}",
            ]
        )
    )