Forestry - TLS

Automate TLS Forestry Analysis - Data Processing Enhanced

< Previous section

Recap and Introduction

In the previous tutorial section, we completed our Python-based TLS forestry analysis workflow, automating the entire processing pipeline from data preparation and ground classification to stem detection, individual tree segmentation, and 3D tree reconstruction.

We also introduced tile-by-tile processing, parallelization, and automated logging, allowing us to analyze larger forest datasets efficiently and reproducibly. The resulting datasets provide detailed information about individual trees, including their positions, dimensions, stem and branch characteristics, wood volume, and biomass estimates.

With the main processing workflow complete, we will now explore what we can do with these results beyond the standard LIS Pro 3D tools.

In this final tutorial section, we will take advantage of the broader Python ecosystem to create additional visualizations and reports. Using libraries such as Matplotlib and Folium, we will turn our forestry analysis results into informative graphs and interactive maps that help us explore, interpret, and communicate the data.

Rather than introducing another major processing step, this tutorial section is intended as a practical extension of the workflow. We will experiment with a few examples to demonstrate how easily LIS Pro 3D results can be combined with other Python tools.

This final step highlights one of the key advantages of a Python-based workflow: once the data have been processed, we can build on the results using a wide range of libraries for visualization, analysis, and reporting.

Important

This tutorial section assumes that you have completed the previous section, Automate TLS Forestry Analysis – Data Processing, and have the resulting tree-level datasets ready for visualization and further analysis.

Install Additional Python Modules

To create graphs and interactive maps from our forestry analysis results, we will use a few additional Python libraries.

Open the Miniforge Prompt

First, open the Miniforge Prompt on your computer.

Activate Your Conda Environment

Activate the Conda environment containing your LIS Pro 3D installation. Replace myenv with the name of your environment.

(base) C:\Users\LiDAR>conda activate myenv

(myenv) C:\Users\LiDAR>

Install the Required Python Libraries

With your Conda environment activated, we will install three additional Python libraries:

  1. numpy: Provides tools for numerical calculations and working with arrays.
  2. matplotlib: Allows us to create graphs, charts, and other data visualizations.
  3. folium: Allows us to create interactive web maps that can be viewed in a browser.

Install all three libraries using the following command:

(myenv) C:\Users\LiDAR>conda install numpy matplotlib folium

Once the installation is complete, we are ready to use these libraries alongside LIS Pro 3D to visualize and explore our forestry analysis results.

Install Quarto

Quarto is an open-source scientific and technical publishing system for creating reproducible documents, reports, presentations, websites, and books.

It supports Markdown and integrates with programming languages such as Python, R, and Julia. This allows us to combine code, data analysis, visualizations, and explanatory text in a single document. Quarto can generate output in various formats, including HTML, PDF, and Word, making it a flexible tool for research, data analysis, and scientific communication.

In fact, this entire tutorial series was created using Quarto.

We will now use Quarto to automatically generate an informative report summarizing the results of our TLS-based forest plot analysis.

Visit the Quarto Website

Open the official Quarto website in your browser.

Download and Install Quarto

Navigate to the Get Started section, select Download Quarto CLI, and follow the installation instructions for your operating system.

Install the Quarto VS Code Extension

To work with Quarto directly in Visual Studio Code, we will also install the Quarto VS Code Extension.

Visit the Quarto documentation for VS Code and click Quarto VS Code Extension.

On the extension page, click Install.

You will be redirected to Visual Studio Code.

Click Install again to add the extension to VS Code.

Once the installation is complete, you can create and edit Quarto documents directly in VS Code.

We now have everything we need to combine our forestry analysis results, Python-generated visualizations, and explanatory text into an automatically generated forest plot report.

Create Additional Processing Scripts

Before creating the scripts for visualization and reporting, let’s briefly revisit the project directory structure established in the previous tutorials:

project # top-level folder
├── data
├── scripts
|   ├── project_config.py
|   ├── 01_data_preparation.py
|   ├── 02_ground_classification.py
|   ├── 03_stem_detection.py
|   ├── 04_tree_segmentation.py
│   └── 05_tree_models.py
└── processing

In the scripts folder, create the following four Python scripts:

  • 06_export_sideviews.py: Export side views of the reconstructed trees.
  • 07_create_plots.py: Generate graphs and visualizations of the forestry analysis results.
  • 08_export_report.py: Create a report summarizing the forest plot analysis.
  • 09_create_leaflet_map.py: Generate an interactive web map using Folium and Leaflet.

In VS Code, right-click the scripts folder in the Explorer panel and select New File... to create each script.

These scripts will extend our existing forestry analysis workflow with additional visualizations, reports, and interactive maps.

project # top-level folder
├── data
├── scripts
|   ├── project_config.py
|   ├── 01_data_preparation.py
|   ├── 02_ground_classification.py
|   ├── 03_stem_detection.py
|   ├── 04_tree_segmentation.py
│   ├── 05_tree_models.py
|   ├── 06_export_sideviews.py
|   ├── 07_create_plots.py
|   ├── 08_export_report.py
│   └── 09_create_leaflet_map.py
└── processing

Fill the Export Side Views Script

Open the empty 06_export_sideviews.py file in VS Code and copy the following code into it.

Note

This script goes through all the individual trees of the dataset and creates sideviews of each tree. It creates sideviews for both point cloud and extracted pipe model.

import math, os
import project_config as cfg
from PySAGA import saga_api, lis_cmd

from PySAGA.tools import *


@lis_cmd.proc_function_wrapper
def process_unit(unit_index=0):

    # -----------------------------------------------------------------------------
    # Create a list of all metrics tiles.
    input_filelist = lis_cmd.Get_File_List_As_List(
        os.path.join(cfg.TREE_METRICS_DIR, "shp_input_file_list.txt")
    )

    # -----------------------------------------------------------------------------
    # get current tile by unit_index.
    file = input_filelist[unit_index]

    filename_metrics = os.path.basename(file)

    unit_name, unitext = os.path.splitext(filename_metrics)
    unit_name = unit_name.replace("_metrics", "")

    filename_pc = f"{unit_name}_segments.sg-pts-z"
    filename_pipes = f"{unit_name}_pipes.shp"

    # -----------------------------------------------------------------------------
    # load info table for analysed dataset
    info_table = saga_api.SG_Get_Data_Manager().Add_Table(
        os.path.join(os.path.join(cfg.TABLES_DIR, "tree_info.csv"))
    )

    if not info_table or info_table.Get_Count() < 1:
        return lis_cmd.Error_Return(f"Loading info table failed")

    # -----------------------------------------------------------------------------
    # get overview statistics
    maxCrownRadius = 5.0
    maxTreeHeight = 10.0
    for i in range(info_table.Get_Field_Count()):

        if info_table.Get_Field_Name(i) == "CrownMaxRad":
            maxCrownRadius = math.ceil(info_table.Get_Maximum(i))
        if info_table.Get_Field_Name(i) == "Height":
            maxTreeHeight = math.ceil(info_table.Get_Maximum(i))

    # -----------------------------------------------------------------------------
    # delete info table again
    saga_api.SG_Get_Data_Manager().Delete()

    # -----------------------------------------------------------------------------
    # load metrics of processing unit
    metrics = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.TREE_METRICS_DIR, filename_metrics)
    )

    if not metrics or metrics.Get_Count() < 1:
        return lis_cmd.Error_Return(
            f"Loading processing_units {filename_metrics} failed"
        )

    # -----------------------------------------------------------------------------
    # load point cloud of processing unit
    if not os.path.exists(os.path.join(cfg.SEGMENTS_DIR, filename_pc)):
        return lis_cmd.Error_Return(
            "tree segments file is not existing for tile, skipping... "
        )

    pc = saga_api.SG_Get_Data_Manager().Add_PointCloud(
        os.path.join(cfg.SEGMENTS_DIR, filename_pc)
    )

    if not pc or pc.Get_Count() < 1000:
        return lis_cmd.Error_Return(f"Loading processing_units {filename_pc} failed")

    # -----------------------------------------------------------------------------
    # load pipe model of processing unit
    if not os.path.exists(os.path.join(cfg.PIPES_DIR, filename_pipes)):
        return lis_cmd.Error_Return(
            "pipe model file is not existing for tile, skipping... "
        )

    pipes = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.PIPES_DIR, filename_pipes)
    )

    if not pipes or pipes.Get_Count() < 1:
        return lis_cmd.Error_Return(f"Loading processing_units {filename_pipes} failed")

    # -----------------------------------------------------------------------------
    # extract low deviation points
    pc_filtered_deviation = (
        saga_api.SG_Get_Data_Manager().Add_PointCloud()
    )  # output data object

    if not (
        lis_tools.Extract_Subset_from_Point_Cloud(
            PC_IN=pc,
            PC_OUT=pc_filtered_deviation,
            FIELD_ATTRIB="Deviation",
            METHOD="single value",
            VALUE=20,
            SINGLE_OP="< single value",
        )
    ):
        return lis_cmd.Error_Return("Failed to execute tool")

    # -----------------------------------------------------------------------------
    # extract high intensity/reflectance points
    pc_filtered = saga_api.SG_Get_Data_Manager().Add_PointCloud()  # output data object

    if not (
        lis_tools.Extract_Subset_from_Point_Cloud(
            PC_IN=pc_filtered_deviation,
            PC_OUT=pc_filtered,
            FIELD_ATTRIB="Reflectance",
            METHOD="single value",
            VALUE=-20,
            SINGLE_OP="> single value",
        )
    ):
        return lis_cmd.Error_Return("Failed to execute tool")

    lis_cmd.Message_Print(
        f"maxTreeHeight {maxTreeHeight}, maxCrownRadius {maxCrownRadius}"
    )

    # -----------------------------------------------------------------------------
    # go through single trees of current tile (tree_by_tree)
    for i in range(metrics.Get_Count()):

        shape = metrics.Get_Shape(i)

        # read attributes of tree
        x = shape.Get_Point(0, 0).x
        y = shape.Get_Point(0, 0).y
        z = shape.Get_Z(0, 0)

        treeid = shape.asLong("treeid")
        sliceheight = shape.asFloat("slice_height")
        treeheight = shape.asFloat("tree_height")

        treename = f"{cfg.PLOT_NAME}_{treeid}"

        lis_cmd.Message_Print(f"{treeid},{treeheight},{x},{y},{z}")

        if treeheight < cfg.MIN_TREE_HEIGHT_REPORT:
            continue

        # -----------------------------------------------------------------------------
        # extract point cloud of current single tree
        pc_tree = saga_api.SG_Get_Data_Manager().Add_PointCloud()

        if not (
            lis_tools.Extract_Subset_from_Point_Cloud(
                PC_IN=pc_filtered,
                PC_OUT=pc_tree,
                FIELD_ATTRIB="treeid",
                METHOD="single value",
                VALUE=treeid,
                SINGLE_OP="= single value",
            )
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        if pc_tree.Get_Count() < 1000:
            return lis_cmd.Error_Return(
                f"single tree point cloud has not enough points"
            )

        # -----------------------------------------------------------------------------
        # translate the point cloud so the tree location becomes the local origin.
        pc_tree_centred = (
            saga_api.SG_Get_Data_Manager().Add_PointCloud()
        )  # output data object
        if not (
            lis_orientation.Transform_Point_Cloud(
                PC_IN=pc_tree,
                PC_OUT=pc_tree_centred,
                DX=-x,
                DY=-y,
                DZ=-(z - sliceheight),
                WRITE_MATRIX=False,
            )
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        # -----------------------------------------------------------------------------
        # export single tree to LAZ file
        lazname = f"{treename}.laz"
        if not (
            lis_io.Export_LASLAZ_File(
                POINTS=pc_tree_centred,
                T="gps-time",
                r="number of the return",
                n="number of returns of given pulse",
                i="intensity",
                FILE=os.path.join(cfg.SINGLETREES_LAZ_DIR, lazname),
                FILE_FORMAT="LAS 1.4",
                FORMAT="3",
            )
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        # -----------------------------------------------------------------------------
        # extract pipe model of single tree
        query = f"and([TreeID]={treeid},[Hierarchy] < {cfg.MAX_HIERARCHY_REPORT+1})"
        pipes_tree = saga_api.SG_Get_Data_Manager().Add_Shapes()  # output data object

        if not shapes_tools.Select_by_Attributes_Numerical_Expression(
            SHAPES=pipes,
            COPY=pipes_tree,
            FIELD="<not set>",
            EXPRESSION=query,
            INVERSE=False,
            USE_NODATA=False,
            METHOD="new selection",
            POSTJOB="copy",
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        if pipes_tree.Get_Count() < 1:
            return lis_cmd.Error_Return(f"single tree pipemodel has no entries")

        # -----------------------------------------------------------------------------
        # translate the pipe model so the tree location becomes the local origin.
        if not shapes_tools.Transform_Shapes(
            SHAPES=pipes_tree,
            MOVEX=-x,
            MOVEY=-y,
            MOVEZ=-(z - sliceheight),
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        # -----------------------------------------------------------------------------
        # create samples on pipemodel for visualization
        pipes_samples = saga_api.SG_Get_Data_Manager().Add_PointCloud()

        if not (
            lis_conversion.Create_Points_on_Polygons(
                SHP_IN=pipes_tree,
                PC_OUT=pipes_samples,
                FIELD_Z="<not set>",
                COPY_ATTR=True,
                SAMPLING="3-dimensional",
                SPACING=0.005,
                FIT="cells",
            )
        ):
            return lis_cmd.Error_Return("Failed to execute tool")

        if pipes_samples.Get_Count() < 1000:
            return lis_cmd.Error_Return(f"not enough samples for pipemodel")

        # -----------------------------------------------------------------------------
        # generate sideviews from two perspectives
        for iProfile in range(2):
            filename_pointcloud = f"{treename}_pointcloud{iProfile}.png"
            filename_pipemodel = f"{treename}_pipemodel{iProfile}.png"

            if iProfile == 0:
                xmin, xmax = -maxCrownRadius, maxCrownRadius
                ymin, ymax = -maxCrownRadius, -maxCrownRadius
            else:
                xmin, xmax = maxCrownRadius, maxCrownRadius
                ymin, ymax = -maxCrownRadius, maxCrownRadius

            x_range = f"{int(xmin)};{int(xmax)}"
            y_range = f"{int(ymin)};{int(ymax)}"
            z_range = f"{0};{int(maxTreeHeight)}"

            # -----------------------------------------------------------------------------
            # create sideview image of point cloud
            tree_image = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object
            tree_range = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object

            if not lis_visualization.Point_Cloud_Orthoimager(
                PC_IN=pc_tree_centred,
                GRID_IMAGE=tree_image,
                GRID_RANGE=tree_range,
                FIELD_IMAGE="Reflectance",
                IS_RGB=False,
                COPY_ATTR=True,
                AGGR_METHOD="min Range",
                AOI_METHOD="left to profile line",
                RANGE=200,
                CELLSIZE=cfg.IMAGE_RESOLUTION,
                CUSTOM_ELEV_RANGE=True,
                ELEV_RANGE=z_range,
                ADD_GRID_LINES=True,
                GRID_LINE_SPACING=cfg.LINE_SPACING,
                PRO_XRANGE=x_range,
                PRO_YRANGE=y_range,
                GRIDIT=True,
            ):
                return lis_cmd.Error_Return("Failed to execute ortho imager")

            # -----------------------------------------------------------------------------
            # export sideview image of point cloud
            if not io_grid_image.Export_to_Image_File(
                GRID=tree_image,
                FILE=os.path.join(cfg.SINGLETREES_PNG_DIR, filename_pointcloud),
                FILE_WORLD=False,
                NO_DATA=False,
                COLOURING="histogram stretch to standard deviation",
                GRADUATED=True,
                STDDEV=2.000000,
                SCALE_MODE="linear intervals",
            ):
                return lis_cmd.Error_Return("Failed to execute export image")

            saga_api.SG_Get_Data_Manager().Delete(tree_image)
            saga_api.SG_Get_Data_Manager().Delete(tree_range)

            # -----------------------------------------------------------------------------
            # create sideview image of pipe model
            tree_image = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object
            tree_range = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object

            if not lis_visualization.Point_Cloud_Orthoimager(
                PC_IN=pipes_samples,
                GRID_IMAGE=tree_image,
                GRID_RANGE=tree_range,
                FIELD_IMAGE="RadiusModel",
                IS_RGB=False,
                COPY_ATTR=True,
                AGGR_METHOD="min Range",
                AOI_METHOD="left to profile line",
                RANGE=200,
                CELLSIZE=cfg.IMAGE_RESOLUTION,
                CUSTOM_ELEV_RANGE=True,
                ELEV_RANGE=z_range,
                ADD_GRID_LINES=True,
                GRID_LINE_SPACING=cfg.LINE_SPACING,
                PRO_XRANGE=x_range,
                PRO_YRANGE=y_range,
                GRIDIT=True,
            ):
                return lis_cmd.Error_Return("Failed to execute ortho imager")

            # -----------------------------------------------------------------------------
            # export sideview image of pipe model
            if not io_grid_image.Export_to_Image_File(
                GRID=tree_image,
                FILE=os.path.join(cfg.SINGLEPIPES_PNG_DIR, filename_pipemodel),
                FILE_WORLD=False,
                NO_DATA=False,
                COLOURING="histogram stretch to standard deviation",
                GRADUATED=True,
                STDDEV=2.000000,
                SCALE_MODE="linear intervals",
            ):
                return lis_cmd.Error_Return("Failed to execute export image")

            saga_api.SG_Get_Data_Manager().Delete(tree_image)
            saga_api.SG_Get_Data_Manager().Delete(tree_range)
            # -------------------------------------------------------------------------
            # end of viewing direction loop.

        saga_api.SG_Get_Data_Manager().Delete(pc_tree)
        saga_api.SG_Get_Data_Manager().Delete(pc_tree_centred)
        saga_api.SG_Get_Data_Manager().Delete(pipes_tree)
        saga_api.SG_Get_Data_Manager().Delete(pipes_samples)
        # -------------------------------------------------------------------------
        # end of single tree loop.

    # -----------------------------------------------------------------------------
    # free memory resources
    saga_api.SG_Get_Data_Manager().Delete()
    return True


# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
if __name__ == "__main__":

    # -----------------------------------------------------------------------------
    # start the logging
    starttime = lis_cmd.Start_Logging(False)

    # -----------------------------------------------------------------------------
    # create a list of all segmented tiles
    file_list = lis_cmd.Get_File_List_As_List(
        os.path.join(cfg.TREE_METRICS_DIR, "shp_input_file_list.txt")
    )

    # -----------------------------------------------------------------------------
    # run the process_unit function in parallel (each tile in a separate thread)
    # here we will use 4 threads, please adjust to your CPU and available memory
    #
    # the maximum number of threads available with your CPU can be queried with:
    # num_threads = saga_api.SG_OMP_Get_Max_Num_Threads()

    num_threads = 4
    num_processing_units = len(file_list)

    lis_cmd.Run_Parallel_Tasks(process_unit, num_processing_units, num_threads)
    # -----------------------------------------------------------------------------

    # -----------------------------------------------------------------------------
    # free memory resources and stop logging
    saga_api.SG_Get_Data_Manager().Delete()
    lis_cmd.Stop_Logging(starttime)

Fill the Plots Script

Open the empty 07_create_plots.py file in VS Code and copy the following code into it.

Note

This script iterates over all individual trees in the dataset and uses their reconstructed tree skeletons, together with the corresponding diameter information stored for each graph edge, to visualize stem taper.

The resulting diagrams illustrate how stem diameter changes with height, providing a useful overview of the stem shape of each tree.

import math, os
import project_config as cfg
from PySAGA import saga_api, lis_cmd

import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict


def generate_sideview_plot(
    diameter,
    height,
    circleCompleteness,
    goodnessOfFit,
    maxTreeHeight,
    maxCrownRadius,
    img_pointcloud,
    img_pipemodel,
    diagram_file,
):
    fig, (ax, ax_img, ax_img2) = plt.subplots(
        ncols=3,
        figsize=(20, 8),
        gridspec_kw={"width_ratios": [0.5, 1.0, 1.0], "wspace": 0.1},
    )
    constrained_layout = True

    # ----------------------------------------------------------------------
    # main axis: diameter over height
    ax.set_ylabel("height [m]", fontsize=14)
    ax.set_ylim(0.0, maxTreeHeight)  # fixed tree height
    ax.set_yticks(np.arange(0, maxTreeHeight + 1, 2))

    ax.set_xlabel("diameter [m]", fontsize=14)
    ax.set_xlim(0.0, 0.6)

    ax.grid(axis="y", color="0.9")

    ax.plot(diameter, height, color="black", linewidth=2, marker="o", label="diameter")

    # ----------------------------------------------------------------------
    # completeness axis
    ax2 = ax.twiny()

    ax2.set_xlim(0.0, 1.1)
    ax2.set_xlabel("completeness [0-1]", color="blue", fontsize=14)

    ax2.tick_params(axis="x", colors="blue")

    ax2.plot(circleCompleteness, height, color="blue", linestyle="--")

    ax2.fill_betweenx(height, 0, circleCompleteness, color="blue", alpha=0.25)

    # ----------------------------------------------------------------------
    # correctness axis
    ax3 = ax.twiny()

    ax3.spines["top"].set_position(("axes", 1.12))

    ax3.set_xlim(0.0, 1.1)
    ax3.set_xlabel("correctness [0-1]", color="red", fontsize=14)

    ax3.tick_params(axis="x", colors="red")

    ax3.plot(goodnessOfFit, height, color="red", linestyle="--")

    ax3.fill_betweenx(height, 0, goodnessOfFit, color="red", alpha=0.25)

    # ----------------------------------------------------------------------
    # sideview image, aligned to same height axis
    ax_img.imshow(
        img_pipemodel,
        extent=[0, maxCrownRadius * 2, 0, maxTreeHeight],  # 100 px = 1 m
        aspect="equal",
    )

    ax_img.set_ylim(0.0, maxTreeHeight)
    # ax_img.set_ylabel("height [m]", fontsize=14)
    ax_img.set_yticks(np.arange(0, maxTreeHeight + 1, 2))
    ax_img.set_xticks([])
    ax_img.set_title("pipe model", fontsize=14)

    # ----------------------------------------------------------------------
    # sideview image, aligned to same height axis
    ax_img2.imshow(
        img_pointcloud,
        extent=[0, maxCrownRadius * 2, 0, maxTreeHeight],  # 100 px = 1 m
        aspect="equal",
    )

    ax_img2.set_ylim(0.0, maxTreeHeight)
    # ax_img2.set_ylabel("height [m]", fontsize=14)
    ax_img2.set_yticks(np.arange(0, maxTreeHeight + 1, 2))
    ax_img2.set_xticks([])
    ax_img2.set_title("point cloud", fontsize=14)

    # ----------------------------------------------------------------------
    fig.tight_layout()

    # ----------------------------------------------------------------------
    fig.savefig(
        os.path.join(cfg.DIAGRAMS_DIR, diagram_file),
        format="png",
        dpi=300,
        bbox_inches="tight",
    )

    plt.close(fig)
    return


@lis_cmd.proc_function_wrapper
def process_unit(unit_index=0):

    # -----------------------------------------------------------------------------
    # create a list of all skeleton files.
    input_filelist = lis_cmd.Get_File_List_As_List(
        os.path.join(cfg.SKELETONS_DIR, "shp_input_file_list.txt")
    )

    # -----------------------------------------------------------------------------
    # get current unit by unit_index.
    file = input_filelist[unit_index]

    filename_skeletons = os.path.basename(file)

    unit_name, unitext = os.path.splitext(filename_skeletons)
    unit_name = unit_name.replace("_skeletons", "")

    # -----------------------------------------------------------------------------
    # load info table for analysed dataset
    info_table = saga_api.SG_Get_Data_Manager().Add_Table(
        os.path.join(os.path.join(cfg.TABLES_DIR, "tree_info.csv"))
    )

    if not info_table.is_Valid() or info_table.Get_Count() < 1:
        return lis_cmd.Error_Return(f"Loading info table failed")

    # -----------------------------------------------------------------------------
    # get overview statistics
    maxCrownRadius = 5.0
    maxTreeHeight = 10.0
    for i in range(info_table.Get_Field_Count()):

        if info_table.Get_Field_Name(i) == "CrownMaxRad":
            maxCrownRadius = math.ceil(info_table.Get_Maximum(i))
        if info_table.Get_Field_Name(i) == "Height":
            maxTreeHeight = math.ceil(info_table.Get_Maximum(i))

    # -----------------------------------------------------------------------------
    # load skeletons of processing unit
    skeletons = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.SKELETONS_DIR, filename_skeletons)
    )

    if not skeletons.is_Valid() or skeletons.Get_Count() < 1:
        return lis_cmd.Error_Return(
            f"Loading processing_units {filename_skeletons} failed"
        )

    # -----------------------------------------------------------------------------
    # initialize the nested dictionary for storing skeleton topology data.
    stem_database = defaultdict(
        lambda: defaultdict(
            lambda: {
                "branchCode": None,
                "height": [],
                "diameter": [],
                "goodnessOfFit": [],
                "circleCompleteness": [],
            }
        )
    )

    # -----------------------------------------------------------------------------
    # go through individual tree-skeleton graph-edges and fill nested dictionary (topology structure)
    for i in range(skeletons.Get_Count()):
        shape = skeletons.Get_Shape(i)
        treeid = shape.asLong("TreeID")
        branchid = shape.asInt("BranchID")

        if (
            shape.asInt("BranchCode_Leader") != 1
        ):  # only the main axis, no forks considered
            continue

        stem_database[treeid][branchid]["branchCode"] = shape.asInt("BranchCode_Leader")
        stem_database[treeid][branchid]["height"].append(shape.asFloat("DZ"))
        stem_database[treeid][branchid]["diameter"].append(
            shape.asFloat("RadiusModel") * 2
        )
        stem_database[treeid][branchid]["goodnessOfFit"].append(
            shape.asFloat("GoodnessOfFit")
        )
        stem_database[treeid][branchid]["circleCompleteness"].append(
            shape.asFloat("CircleCompleteness")
        )

    # -----------------------------------------------------------------------------
    # go through tree ids in nested dictionary and generate diameter-over-height diagrams
    for tree_id in stem_database:

        imagefile_point_cloud0 = f"{cfg.PLOT_NAME}_{tree_id}_pointcloud0.png"
        img_pointcloud0 = plt.imread(
            os.path.join(cfg.SINGLETREES_PNG_DIR, imagefile_point_cloud0)
        )
        imagefile_point_cloud1 = f"{cfg.PLOT_NAME}_{tree_id}_pointcloud1.png"
        img_pointcloud1 = plt.imread(
            os.path.join(cfg.SINGLETREES_PNG_DIR, imagefile_point_cloud1)
        )

        imagefile_pipemodel0 = f"{cfg.PLOT_NAME}_{tree_id}_pipemodel0.png"
        img_pipemodel0 = plt.imread(
            os.path.join(cfg.SINGLEPIPES_PNG_DIR, imagefile_pipemodel0)
        )
        imagefile_pipemodel1 = f"{cfg.PLOT_NAME}_{tree_id}_pipemodel1.png"
        img_pipemodel1 = plt.imread(
            os.path.join(cfg.SINGLEPIPES_PNG_DIR, imagefile_pipemodel1)
        )

        # -----------------------------------------------------------------------------
        # go through branch ids of current tree
        for branch_id in stem_database[tree_id]:
            branch_code = stem_database[tree_id][branch_id]["branchCode"]

            height = stem_database[tree_id][branch_id]["height"]
            diameter = stem_database[tree_id][branch_id]["diameter"]
            goodnessOfFit = stem_database[tree_id][branch_id]["goodnessOfFit"]
            circleCompleteness = stem_database[tree_id][branch_id]["circleCompleteness"]

            # -----------------------------------------------------------------------------
            # generate diameter-over-height diagram with view direction north
            plotname = f"{cfg.PLOT_NAME}_{tree_id}_0"
            diagram_file = f"{plotname}.png"

            generate_sideview_plot(
                diameter,
                height,
                circleCompleteness,
                goodnessOfFit,
                maxTreeHeight,
                maxCrownRadius,
                img_pointcloud0,
                img_pipemodel0,
                diagram_file,
            )

            # -----------------------------------------------------------------------------
            # generate diameter-over-height diagram with view direction west
            plotname = f"{cfg.PLOT_NAME}_{tree_id}_1"
            diagram_file = f"{plotname}.png"

            generate_sideview_plot(
                diameter,
                height,
                circleCompleteness,
                goodnessOfFit,
                maxTreeHeight,
                maxCrownRadius,
                img_pointcloud1,
                img_pipemodel1,
                diagram_file,
            )

    # -----------------------------------------------------------------------------
    # free memory resources
    saga_api.SG_Get_Data_Manager().Delete()
    return True


# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
if __name__ == "__main__":

    # -----------------------------------------------------------------------------
    # start the logging
    starttime = lis_cmd.Start_Logging(False)

    # -----------------------------------------------------------------------------
    # create a list of all skeleton tiles.
    pathlist = lis_cmd.Create_File_List(
        cfg.SKELETONS_DIR,
        ".shp;.SHP",
        os.path.join(cfg.SKELETONS_DIR, "shp_input_file_list.txt"),
        False,
    )

    # -----------------------------------------------------------------------------
    # get a Python list object of all skeleton tiles.
    file_list = lis_cmd.Get_File_List_As_List(
        os.path.join(cfg.SKELETONS_DIR, "shp_input_file_list.txt")
    )

    # -----------------------------------------------------------------------------
    # run the process_unit function in parallel (each tile in a separate thread)
    # here we will use 4 threads, please adjust to your CPU and available memory
    #
    # the maximum number of threads available with your CPU can be queried with:
    # num_threads = saga_api.SG_OMP_Get_Max_Num_Threads()

    num_threads = 4
    num_processing_units = len(file_list)

    lis_cmd.Run_Parallel_Tasks(process_unit, num_processing_units, num_threads)
    # -----------------------------------------------------------------------------

    # -----------------------------------------------------------------------------
    # free memory resources and stop logging
    saga_api.SG_Get_Data_Manager().Delete()
    lis_cmd.Stop_Logging(starttime)

Fill the Report Script

Open the empty 08_export_report.py file in VS Code and copy the following code into it.

Note

This script reads basic tree information, such as diameter at breast height (DBH), tree height, and other characteristics, from the tree metrics files.

It then combines these measurements with the previously generated tree side views and stem taper diagrams to create an informative report for each individual tree.

The reports provide a convenient way to explore and communicate the results of our TLS-based forestry analysis.

import os
import subprocess
import project_config as cfg
from PySAGA import saga_api, lis_cmd
from PySAGA.tools import *

from collections import defaultdict


def AddTitle(ExportText, Title):
    ExportText += (
        f"---\n"
        f'title: "{Title}"\n'
        'title-block-banner: "#88151b"\n'
        "format:\n"
        "  html:\n"
        "    code-fold: false\n"
        "    self-contained: true\n"
        "---\n\n"
    )
    return ExportText


def AddHeading(ExportText, Heading):
    ExportText += f"\n## {Heading}\n\n"
    return ExportText


def AddLink(ExportText, Link):
    ExportText += f"\n[Back to Map]({Link})\n\n"
    return ExportText


def AddFigure(ExportText, Figure, Caption):
    ExportText += f"\n![**{Caption}**]({Figure})\n\n"
    return ExportText


def Add2Figures(ExportText, Figure1, Caption1, Figure2, Caption2):
    ExportText += (
        "\n:::: {.columns}\n\n"
        '::: {.column width="49%"}\n\n'
        f"![**{Caption1}**]({Figure1})\n\n"
        ":::\n\n"
        '::: {.column width="2%"}\n\n'
        ":::\n\n"
        '::: {.column width="49%"}\n\n'
        f"![**{Caption2}**]({Figure2})\n\n"
        ":::\n\n"
        "::::\n\n"
    )
    return ExportText


def AddText(ExportText, Text):
    ExportText += f"\n{Text}\n"
    return ExportText


def GenerateQuartoTables(
    info_table, caption, treeid_field_name="treeid", stem_field_name=None
):

    treeid_field = info_table.Get_Field(treeid_field_name)
    if treeid_field < 0:
        raise ValueError(f"Field '{treeid_field_name}' not found.")

    stem_field = None
    if stem_field_name is not None:
        stem_field = info_table.Get_Field(stem_field_name)
        if stem_field < 0:
            raise ValueError(f"Field '{stem_field_name}' not found.")

    headers = [
        info_table.Get_Field_Name(field)
        for field in range(info_table.Get_Field_Count())
    ]

    # group rows
    if stem_field_name is None:
        grouped_rows = defaultdict(list)
    else:
        grouped_rows = defaultdict(lambda: defaultdict(list))

    for i in range(info_table.Get_Count()):

        record = info_table.Get_Record(i)

        treeid = record.asString(treeid_field).strip()

        row = []
        for field in range(info_table.Get_Field_Count()):
            sValue = record.asString(field).strip()
            if (
                info_table.Get_Field_Type(field) == saga_api.SG_DATATYPE_Float
                or info_table.Get_Field_Type(field) == saga_api.SG_DATATYPE_Double
            ):
                value = record.asFloat(field)
                sValue = f"{value:.2f}"

                if value.is_integer():
                    value = int(value)
                    sValue = str(value)

            row.append("" if sValue == "-1" else sValue)

        if stem_field_name is None:
            grouped_rows[treeid].append(row)
        else:
            stemno = record.asString(stem_field).strip()
            grouped_rows[treeid][stemno].append(row)

    # build Quarto tables
    tables = {}

    if stem_field_name is None:

        for treeid, rows in grouped_rows.items():

            text = '\n::: {style="text-align: center;"}\n\n'
            text += f"**{caption} {treeid}**\n\n"
            text += "| " + " | ".join(headers) + " |\n"
            text += "| " + " | ".join(["---"] * len(headers)) + " |\n"

            for row in rows:
                text += "| " + " | ".join(row) + " |\n"

            text += "\n:::\n\n"

            tables[treeid] = text

    else:

        for treeid, stems in grouped_rows.items():

            tables[treeid] = {}

            for stemno, rows in stems.items():

                text = '\n::: {style="text-align: center;"}\n\n'
                text += f"**{caption} {treeid} - Stem {stemno}**\n\n"
                text += "| " + " | ".join(headers) + " |\n"
                text += "| " + " | ".join(["---"] * len(headers)) + " |\n"

                for row in rows:
                    text += "| " + " | ".join(row) + " |\n"

                text += "\n:::\n\n"

                tables[treeid][stemno] = text

    return tables


def process_unit():

    # --------------------------------------------------------------
    # start writing quarto document for current plot
    FileName = os.path.join(cfg.REPORT_DIR, f"{cfg.PLOT_NAME}_Report.qmd")

    with open(FileName, "w") as f:
        TitleText = ""
        Title = f"{cfg.PLOT_NAME}_Report"
        TitleText = AddTitle(TitleText, Title)

        EntryList = []
        EntryList.append(TitleText)

        # --------------------------------------------------------------
        # prepare dictionary with basic tree information
        tree_database = defaultdict(
            lambda: {
                "position": None,
                "DBH": None,
                "Height": None,
                "CrownArea": None,
                "CrownDiameter": None,
            }
        )

        # --------------------------------------------------------------
        # load tree metrics
        metrics = saga_api.SG_Get_Data_Manager().Add_Shapes(
            os.path.join(cfg.METRICS_DIR, "tree_metrics.shp")
        )

        if not metrics.is_Valid() or metrics.Get_Count() < 1:
            return lis_cmd.Error_Return(f"Loading metrics failed")

        # --------------------------------------------------------------
        # go through tree metrics (tree-by-tree) and fill tree dictionary
        for i in range(metrics.Get_Count()):
            shape = metrics.Get_Shape(i)

            treeid = shape.asInt("treeid")
            if shape.asFloat("tree_height") < cfg.MIN_TREE_HEIGHT_REPORT:
                continue

            tree_database[treeid]["position"] = (
                shape.Get_Point(0, 0).x,
                shape.Get_Point(0, 0).y,
                shape.Get_Z(0, 0),
            )
            tree_database[treeid]["DBH"] = shape.asFloat("DBH")
            tree_database[treeid]["Height"] = shape.asFloat("tree_height")
            tree_database[treeid]["CrownArea"] = shape.asFloat("crown_area")
            tree_database[treeid]["CrownDiameter"] = shape.asFloat("crown_diameter")

        # --------------------------------------------------------------
        # load tree info table and generate quarto table from it
        tree_info = saga_api.SG_Get_Data_Manager().Add_Table(
            os.path.join(os.path.join(cfg.TABLES_DIR, "tree_info.csv"))
        )

        if not tree_info or tree_info.Get_Count() < 1:
            return lis_cmd.Error_Return(f"Loading info table failed")
        tree_tables = GenerateQuartoTables(tree_info, "tree_information", "TreeNumber")

        # --------------------------------------------------------------
        # load stem description table and generate quarto table from it
        stem_description = saga_api.SG_Get_Data_Manager().Add_Table(
            os.path.join(os.path.join(cfg.TABLES_DIR, "stem_description.csv"))
        )

        if not stem_description or stem_description.Get_Count() < 1:
            return lis_cmd.Error_Return(f"Loading info table failed")
        stem_tables = GenerateQuartoTables(
            stem_description, "stem_information", "TreeNumber", "StemNo"
        )

        # --------------------------------------------------------------
        # free memory resources
        saga_api.SG_Get_Data_Manager().Delete()

        # --------------------------------------------------------------
        # go through tree dictionary (tree-by-tree), sorted by tree height
        for treeid, data in sorted(
            tree_database.items(), key=lambda item: item[1]["Height"], reverse=True
        ):
            x, y, z = data["position"]

            # --------------------------------------------------------------
            # generate metrics textblock
            NewTextBlock = ""
            NewTextBlock = AddHeading(NewTextBlock, f"Tree {treeid}")
            NewTextBlock = AddLink(NewTextBlock, f"{cfg.PLOT_NAME}_Map.html")
            NewTextBlock = AddText(NewTextBlock, f"- Position: {x:.2f} | {y:.2f}")
            NewTextBlock = AddText(
                NewTextBlock, f"- Tree height: ${data['Height']:.2f}\\ \\mathrm{{m}}$"
            )
            NewTextBlock = AddText(
                NewTextBlock, f"- DBH: ${data['DBH'] * 1000:.0f}\\ \\mathrm{{mm}}$"
            )
            NewTextBlock = AddText(
                NewTextBlock,
                f"- Crown diameter: ${data['CrownDiameter']:.2f}\\ \\mathrm{{m}}$",
            )
            NewTextBlock = AddText(
                NewTextBlock,
                f"- Crown area: ${data['CrownArea']:.2f}\\ \\mathrm{{m}}^2$",
            )

            # --------------------------------------------------------------
            # organize the two sideviews, generated from pipes and pointcloud and add figure to textblock
            for iProfile in range(2):

                relative_path_sideviews = os.path.relpath(
                    cfg.DIAGRAMS_DIR, cfg.REPORT_DIR
                )

                diagramName = f"{cfg.PLOT_NAME}_{treeid}_{iProfile}.png"
                filepathImage = os.path.join(cfg.DIAGRAMS_DIR, diagramName)
                filepathImageRel = os.path.join(relative_path_sideviews, diagramName)

                if os.path.exists(filepathImage):
                    heading = "view direction north"
                    if iProfile == 1:
                        heading = "view direction west"

                    lis_cmd.Message_Print(filepathImageRel)

                    NewTextBlock = AddFigure(NewTextBlock, filepathImageRel, heading)

            # --------------------------------------------------------------
            # add textblock with figure
            EntryList.append(NewTextBlock)

            # --------------------------------------------------------------
            # add tree info table
            if str(treeid) in tree_tables:
                print("\n", treeid)
                EntryList.append(tree_tables[str(treeid)])

            # --------------------------------------------------------------
            # add stem info table
            if str(treeid) in stem_tables:
                if "1" in stem_tables[str(treeid)]:
                    EntryList.append(stem_tables[str(treeid)]["1"])
                if "101" in stem_tables[str(treeid)]:
                    EntryList.append(stem_tables[str(treeid)]["101"])
                if "201" in stem_tables[str(treeid)]:
                    EntryList.append(stem_tables[str(treeid)]["201"])
                if "10101" in stem_tables[str(treeid)]:
                    EntryList.append(stem_tables[str(treeid)]["10101"])

            # --------------------------------------------------------------
            # add Link to leaflet map
            NewTextBlock = ""
            NewTextBlock = AddLink(NewTextBlock, f"{cfg.PLOT_NAME}_Map.html")
            EntryList.append(NewTextBlock)

        # --------------------------------------------------------------
        # write current tree infos into quarto document.
        with open(FileName, "w", encoding="utf-8") as f:
            f.writelines(EntryList)

    # --------------------------------------------------------------
    # render the final document with quarto.
    subprocess.run(
        ["quarto", "render", FileName],
        check=True,
    )

    # -----------------------------------------------------------------------------
    # free memory resources
    saga_api.SG_Get_Data_Manager().Delete()
    return True


# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
if __name__ == "__main__":

    # -----------------------------------------------------------------------------
    # start the logging
    starttime = lis_cmd.Start_Logging(False)

    # -----------------------------------------------------------------------------
    # call the processing function
    process_unit()

    # -----------------------------------------------------------------------------
    # free memory resources and stop logging
    saga_api.SG_Get_Data_Manager().Delete()
    lis_cmd.Stop_Logging(starttime)

Fill the Leaflet Map Script

Open the empty 09_create_leaflet_map.py file in VS Code and copy the following code into it.

Note

This script uses the output layers from our forestry analysis to create an interactive web map using Folium and Leaflet.

The map displays the spatial distribution of the analyzed trees and provides links to their individual reports. This allows us to navigate between the map and detailed information about each tree, combining spatial visualization with the results of our TLS-based forestry analysis.

import folium
from folium.raster_layers import ImageOverlay

import json
import os
import project_config as cfg
from PySAGA import saga_api, lis_cmd
from PySAGA.tools import *


# -----------------------------------------------------------------------------
# function generating popup content with all attributes
def get_popup_content(properties, page):
    popup_content = ""
    tree_id_entry = ""
    for key, value in properties.items():

        if key not in ["treeid", "tree_height", "DBH", "crown_diameter", "crown_area"]:
            continue

        if value == None:
            continue

        if key == "treeid":
            treeid = int(value)
            tree_id_entry = f"<b>{key}</b>: {value}<br>"
            continue

        popup_content += f"<b>{key}</b>: {value}<br>"

    popup_content = tree_id_entry + popup_content
    popup_content += (
        f"<a href='{page}#tree-{treeid}'>Click to see the tree in detail</a>"
    )
    return popup_content


# -----------------------------------------------------------------------------
# function deriving the centroid of your plot dataset in order to centre the view in the leaflet map
def compute_centroid(stem_pos):
    x = 0.0
    y = 0.0
    for i in range(stem_pos.Get_Count()):
        shape = stem_pos.Get_Shape(i)
        x += shape.Get_Point(0, 0).x
        y += shape.Get_Point(0, 0).y
    x /= stem_pos.Get_Count()
    y /= stem_pos.Get_Count()
    return (x, y)


# ----------------------------------------------------------------------------


def process_unit():

    # --------------------------------------------------------------
    # define leaflet map filepath
    map_page = os.path.join(cfg.REPORT_DIR, f"{cfg.PLOT_NAME}_Map.html")

    # --------------------------------------------------------------
    # load tree metrics
    lis_cmd.Message_Print("start")
    metrics = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.METRICS_DIR, "tree_metrics.shp")
    )

    if not metrics or metrics.Get_Count() < 1:
        return lis_cmd.Error_Return(f"Loading metrics failed")

    metrics.Get_Projection().Create(cfg.TARGET_COORD)

    # --------------------------------------------------------------
    # translate into latlon reference system for leaflet map
    metrics_latlon = saga_api.SG_Get_Data_Manager().Add_Shapes()
    if not pj_proj4.Coordinate_Transformation_Shapes(
        SOURCE=metrics,
        TARGET=metrics_latlon,
        CRS_STRING="+proj=longlat +datum=WGS84 +no_defs",
        TRANSFORM_Z=True,
        PARALLEL=False,
        COPY=True,
    ):
        return lis_cmd.Error_Return("Failed to execute tool")

    # --------------------------------------------------------------
    # save latlon metrics as geojson
    metrics_latlon.Save(os.path.join(cfg.LAYERS_DIR, "treepositions.geojson"))

    # --------------------------------------------------------------
    # compute centroid of stems
    x, y = compute_centroid(metrics_latlon)

    # --------------------------------------------------------------
    # generate stem cross-sections from tree metrics
    stem_crosssections = saga_api.SG_Get_Data_Manager().Add_Shapes()

    if not shapes_tools.Shapes_Buffer(
        SHAPES=metrics,
        BUFFER=stem_crosssections,
        DIST_FIELD="DBH",
        DIST_SCALE=0.500000,
        DISSOLVE=False,
        NZONES=1,
        DARC=5.000000,
    ):
        return lis_cmd.Error_Return("Failed to execute tool")

    # --------------------------------------------------------------
    # translate into latlon reference system for leaflet map
    stem_crosssections_latlon = saga_api.SG_Get_Data_Manager().Add_Shapes()

    if not pj_proj4.Coordinate_Transformation_Shapes(
        SOURCE=stem_crosssections,
        TARGET=stem_crosssections_latlon,
        CRS_STRING="+proj=longlat +datum=WGS84 +no_defs",
        TRANSFORM_Z=True,
        PARALLEL=False,
        COPY=True,
    ):
        return lis_cmd.Error_Return("Failed to project shapefile")

    # --------------------------------------------------------------
    # save latlon stem cross-sections as geojson
    stem_crosssections_latlon.Save(
        os.path.join(cfg.LAYERS_DIR, "stem_crosssections.geojson")
    )

    # --------------------------------------------------------------
    # load boundaries
    outlines = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.SEGMENTATION_DIR, "tree_outlines.shp")
    )

    if not outlines or outlines.Get_Count() < 1:
        return lis_cmd.Error_Return(f"Loading outlines failed")

    outlines.Get_Projection().Create(cfg.TARGET_COORD)

    # --------------------------------------------------------------
    # translate into latlon reference system for leaflet map
    outlines_latlon = saga_api.SG_Get_Data_Manager().Add_Shapes()  # output data object
    if not pj_proj4.Coordinate_Transformation_Shapes(
        SOURCE=outlines,
        TARGET=outlines_latlon,
        CRS_STRING="+proj=longlat +datum=WGS84 +no_defs",
        TRANSFORM_Z=True,
        PARALLEL=False,
        COPY=True,
    ):
        return lis_cmd.Error_Return("Failed to project shapefile")

    # --------------------------------------------------------------
    # save latlon outlines as geojson
    outlines_latlon.Save(os.path.join(cfg.LAYERS_DIR, "tree_outlines.geojson"))

    # --------------------------------------------------------------
    # load chm
    chm = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.GRIDS_DIR, f"{cfg.PLOT_NAME}_chm.tif")
    )

    if not chm:
        return lis_cmd.Error_Return(f"Loading chm failed")

    chm.Get_Projection().Create(cfg.TARGET_COORD)

    # --------------------------------------------------------------
    # translate into latlon reference system for leaflet map
    chm_latlon = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object
    if not (
        pj_proj4.Coordinate_Transformation_Grid(
            SOURCE=chm,
            GRID=chm_latlon,
            CRS_STRING="+proj=longlat +datum=WGS84 +no_defs ",
            RESAMPLING="B-Spline Interpolation",
            BYTEWISE=False,
            DATA_TYPE="Preserve",
            TARGET_AREA=False,
            TARGET_DEFINITION="user defined",
            TARGET_USER_FITS="nodes",
        )
    ):
        return lis_cmd.Error_Return("Failed to projection to latlon")

    chm_latlon.Save(os.path.join(cfg.LAYERS_DIR, "chm_latlon.tif"))

    # --------------------------------------------------------------
    # export into png image
    if not io_grid_image.Export_to_Image_File(
        GRID=chm_latlon,
        FILE=os.path.join(cfg.LAYERS_DIR, "chm.png"),
        FILE_WORLD=True,
        NO_DATA=True,
        COLOURING="histogram stretch to standard deviation",
        GRADUATED=True,
        STDDEV=2.000000,
        SCALE_MODE="linear intervals",
    ):
        return lis_cmd.Error_Return("Failed to execute export image")

    xmin = chm_latlon.Get_Extent().Get_XMin()
    ymin = chm_latlon.Get_Extent().Get_YMin()
    xmax = chm_latlon.Get_Extent().Get_XMax()
    ymax = chm_latlon.Get_Extent().Get_YMax()

    bounds_chm = [(ymin, xmin), (ymax, xmax)]

    # --------------------------------------------------------------
    # loading diameter slices
    slice = saga_api.SG_Get_Data_Manager().Add_Shapes(
        os.path.join(cfg.GRIDS_DIR, f"{cfg.PLOT_NAME}_slices_grid.tif")
    )

    if not slice:
        return lis_cmd.Error_Return(f"Loading slice failed")

    slice.Get_Projection().Create(cfg.TARGET_COORD)

    # --------------------------------------------------------------
    # translate into latlon reference system for leaflet map
    slice_latlon = saga_api.SG_Get_Data_Manager().Add_Grid()  # output data object
    if not (
        pj_proj4.Coordinate_Transformation_Grid(
            SOURCE=slice,
            GRID=slice_latlon,
            CRS_STRING="+proj=longlat +datum=WGS84 +no_defs ",
            RESAMPLING="B-Spline Interpolation",
            BYTEWISE=False,
            DATA_TYPE="Preserve",
            TARGET_AREA=False,
            TARGET_DEFINITION="user defined",
            TARGET_USER_FITS="nodes",
        )
    ):
        return lis_cmd.Error_Return("Failed to projection to latlon")

    slice_latlon.Save(os.path.join(cfg.LAYERS_DIR, "slice_latlon.tif"))

    # --------------------------------------------------------------
    # export into png image
    if not io_grid_image.Export_to_Image_File(
        GRID=slice_latlon,
        FILE=os.path.join(cfg.LAYERS_DIR, "slice.png"),
        FILE_WORLD=True,
        NO_DATA=True,
        COLOURING="histogram stretch to standard deviation",
        GRADUATED=True,
        STDDEV=2.000000,
        SCALE_MODE="linear intervals",
    ):
        return lis_cmd.Error_Return("Failed to execute export image")

    xmin = slice_latlon.Get_Extent().Get_XMin()
    ymin = slice_latlon.Get_Extent().Get_YMin()
    xmax = slice_latlon.Get_Extent().Get_XMax()
    ymax = slice_latlon.Get_Extent().Get_YMax()

    bounds_slice = [(ymin, xmin), (ymax, xmax)]

    # -----------------------------------------------------------------------------
    # free memory resources
    saga_api.SG_Get_Data_Manager().Delete()

    # -----------------------------------------------------------------------------
    # -----------------------------------------------------------------------------
    # Create a map centered at a specific location (x,y)
    m = folium.Map(
        location=[y, x], max_zoom=100, zoom_start=19, control_scale=True, tiles=None
    )

    # -----------------------------------------------------------------------------
    # Add background layer (google maps)
    folium.TileLayer(
        tiles="https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
        attr="Google Maps",
        name="Google Maps Satellite",
        detect_retina=False,
        max_native_zoom=200,
        max_zoom=200,
        min_zoom=0,
        no_wrap=False,
        opacity=1,
        subdomains="abc",
        tms=False,
    ).add_to(m)

    # -----------------------------------------------------------------------------
    # add dbh slice to the map
    ImageOverlay(
        os.path.join(cfg.LAYERS_DIR, "slice.png"),
        bounds=bounds_slice,
        opacity=1,  # set the opacity of the image (0 = transparent, 1 = opaque)
        name="Trunk Crosssection",
        show=True,
    ).add_to(m)
    lis_cmd.Message_Print("entropy added")

    # -----------------------------------------------------------------------------
    # add chm file to the map
    ImageOverlay(
        os.path.join(cfg.LAYERS_DIR, "chm.png"),
        bounds=bounds_chm,
        opacity=1,  # set the opacity of the image (0 = transparent, 1 = opaque)
        name="Canopy Height Model",
        show=True,
    ).add_to(m)
    lis_cmd.Message_Print("chm added")

    # -----------------------------------------------------------------------------
    # add fitted dbh crossections to the map
    geojson_layer = folium.GeoJson(
        os.path.join(cfg.LAYERS_DIR, "stem_crosssections.geojson"),
        name="Trunk Diameters",
        show=True,
    )
    geojson_layer.add_to(m)
    m.fit_bounds(geojson_layer.get_bounds())
    lis_cmd.Message_Print("stem diameters added")

    # -----------------------------------------------------------------------------
    # add tree crown outlines to the map
    geojson_layer = folium.GeoJson(
        os.path.join(cfg.LAYERS_DIR, "tree_outlines.geojson"),
        name="Tree Outlines",
        show=True,
    )
    geojson_layer.add_to(m)
    m.fit_bounds(geojson_layer.get_bounds())
    lis_cmd.Message_Print("crown shapes added")

    # -----------------------------------------------------------------------------
    # add tree positions to the map
    with open(os.path.join(cfg.LAYERS_DIR, "treepositions.geojson"), "r") as file:
        data = json.load(file)

    # go through features of tree positions
    for feature in data["features"]:
        attributes = feature["properties"]

        treeid = feature["properties"]["treeid"]

        # define the marker label to be used
        label = f"Tree {treeid}"

        # define the position of the marker
        coordinates = feature["geometry"]["coordinates"][
            ::-1
        ]  # reverse the coordinates due to Leaflet's [lat, lon] order
        coordinates = coordinates[1:]

        # generate pop up content for the marker
        popup_content = get_popup_content(attributes, f"{cfg.PLOT_NAME}_Report.html")

        # define icon for marker
        icon = folium.Icon(icon="leaf")

        # create marker
        marker = folium.Marker(
            location=coordinates, icon=icon, label=label, popup=popup_content
        )
        marker.add_to(m)
    lis_cmd.Message_Print("Icons added")

    # -----------------------------------------------------------------------------
    # add layer control
    folium.LayerControl().add_to(m)

    # -----------------------------------------------------------------------------
    # save map
    m.save(map_page)

    # -----------------------------------------------------------------------------
    # free memory resources
    saga_api.SG_Get_Data_Manager().Delete()
    return True


# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
if __name__ == "__main__":

    # -----------------------------------------------------------------------------
    # start the logging
    starttime = lis_cmd.Start_Logging(False)

    # -----------------------------------------------------------------------------
    # call the processing function
    process_unit()

    # -----------------------------------------------------------------------------
    # free memory resources and stop logging
    saga_api.SG_Get_Data_Manager().Delete()
    lis_cmd.Stop_Logging(starttime)

Execute the Processing Pipeline

Now that we have created all four additional scripts, we are ready to generate the tree visualizations, individual reports, and interactive web map.

Open the Miniforge Prompt

First, open the Miniforge Prompt on your computer.

Activate Your Conda Environment

Activate the Conda environment containing your LIS Pro 3D installation and the additional Python libraries. Replace myenv with the name of your environment.

(base) C:\Users\LiDAR>conda activate myenv

(myenv) C:\Users\LiDAR>

Run the Visualization and Reporting Pipeline

Execute the following command to run all four scripts sequentially:

python run_script.py 06_export_sideviews.py 07_create_plots.py 08_export_report.py 09_create_leaflet_map.py

The runner script will execute the individual steps in the specified order:

  1. Export tree side views: Generate visual representations of the individual trees.
  2. Create plots: Produce stem taper diagrams using the reconstructed tree skeletons and diameter information.
  3. Generate tree reports: Combine tree metrics, side views, and diagrams into individual tree reports.
  4. Create an interactive web map: Visualize the spatial distribution of the trees and link them to their corresponding reports.

Once the scripts have finished, you can explore the generated reports and interactive web map to inspect and communicate the results of your TLS-based forestry analysis.

Tip

Try running the entire workflow in one go!

You can combine all scripts from the previous tutorial sections into a single command, starting with data preparation and ending with the interactive web map and individual tree reports.

python run_script.py 01_data_preparation.py 02_ground_classification.py 03_stem_detection.py 04_tree_segmentation.py 05_tree_models.py 06_export_sideviews.py 07_create_plots.py 08_export_report.py 09_create_leaflet_map.py

This demonstrates how the complete workflow can be automated, from the original TLS point clouds to the final visualizations and reports.

Explore the Results

Once the processing pipeline has finished, open the generated interactive web map in your browser.

The map displays the analyzed forest plot, including the individual tree positions and crown boundaries. Clicking on a tree marker opens a popup showing basic tree information, such as tree height, diameter at breast height (DBH), crown diameter, and crown area.

The popup also provides a link to the corresponding individual tree report, allowing you to move directly from the spatial overview to a more detailed analysis of each tree.

Each tree report combines the extracted tree metrics with additional visualizations. Alongside basic measurements, the reports include stem taper diagrams, reconstructed 3D pipe models, and point cloud side views from different viewing directions.

These visualizations help us explore individual tree characteristics, inspect the reconstructed tree geometry, and better understand the results of our TLS-based forestry analysis.

The reports also include a Back to Map link, making it easy to navigate between individual trees and the overall forest plot.

Together, the interactive map and individual tree reports demonstrate how we can extend the standard LIS Pro 3D processing workflow using additional Python libraries to create informative, accessible, and visually appealing outputs.

Summary and Outlook

With this final tutorial section, we have reached the end of our TLS-based forestry analysis tutorial series.

We started by exploring the basic functionality of the LIS Pro 3D forestry tools through the graphical user interface. Using individual trees and small point cloud subsets, we learned how to detect tree stems, segment individual trees, reconstruct their 3D structure, and extract forestry-related information such as tree height, diameter, wood volume, and biomass.

Along the way, we also discussed the underlying principles of the individual tools and how their parameters influence the processing results. Understanding these principles is essential when adapting the workflow to different forest structures, tree species, and TLS datasets.

Next, we moved from individual trees to larger forest areas. We introduced tile-by-tile processing, processing units, and buffered areas to handle datasets that are too large to process in memory all at once. Using the LIS Pro 3D graphical user interface, we learned how to organize intermediate results and combine the outputs of individual processing tiles into consistent datasets.

Building on this experience, we then translated the complete workflow into Python scripts. By separating the individual processing steps, managing parameters through a central configuration file, and introducing parallel processing, we created a more efficient and reproducible approach to TLS-based forestry analysis.

Finally, in this last tutorial section, we explored how to extend the standard LIS Pro 3D workflow using additional Python libraries. With Matplotlib, Folium, and Quarto, we generated informative diagrams, individual tree reports, and an interactive web map that connects the spatial distribution of trees with their detailed measurements and reconstructed structures.

And that brings us to the main advantage of automation: Once everything is configured, we can launch the entire workflow with a single command, from the original TLS point clouds to the final tree reports and interactive web map.

Depending on the size of your dataset, you might have time to grab a coffee, catch up on other office work, or even call it a day while your computer does the processing!

Of course, automation does not eliminate the need to inspect the results, evaluate their quality, and adjust processing parameters where necessary. However, it frees us from repeatedly executing the same processing steps manually and allows us to focus more on interpreting and using the resulting information.

We hope this tutorial series has provided you with a solid foundation for applying LIS Pro 3D to your own TLS forestry datasets, whether you are interested in exploring individual trees, analyzing entire forest plots, or developing more advanced automated workflows.

Happy processing!

< Previous section