Visualization of Cloud-Optimized Earth Observation Data using Lonboard¶
This tutorial illustrates processing and visualizing EO data - Cloud-Optimized GeoTIFFs (COGs) and Cloud-Optimized Point Clouds (COPCs) using Lonboard. Lonboard is capable of visualizing big raster and vector datasets including widely used image formats such as COGs and LiDAR data files such as COPCs, LAS file formats.
In this tutorial, Airborne LiDAR Scanning (ALS) based point cloud for Panama, BCI is used for visualization along with the Above-ground Biomass (AGB) Estimate and Meta's Canopy Height Model (CHM) products. We created COGs for these products and use TiTiler for dynamically serving tiles from the generated COGs for visualization. The point cloud represents normalized canopy cover heights generated from the raw point cloud data.
Additional Resources:
- Lonboard
- TiTiler
- Converting LiDAR LAS Files to Cloud-Optimized Point Clouds (COPCs)
- Introduction to Cloud-Optimized GeoTIFFs
- PDAL
Importing Packages¶
We import the required packages for this tutorial.
1 2 3 4 5 6 7 8 9 10 11 12 13 | import geopandas as gpd from lonboard import viz import pdal import pandas as pd import matplotlib.pyplot as plt import numpy as np from lonboard import Map, BitmapLayer, BitmapTileLayer, PointCloudLayer from lonboard.colormap import apply_continuous_cmap import httpx import json from palettable.colorbrewer.sequential import YlGnBu_7, Greens_3 from palettable.colorbrewer.diverging import Spectral_9 from matplotlib.colors import Normalize |
(PDAL Error) Can't load library /opt/conda/envs/pangeo/lib/libpdal_plugin_reader_draco.so: Failed to load "/opt/conda/envs/pangeo/lib/libpdal_plugin_reader_draco.so": libdraco.so.8: cannot open shared object file: No such file or directory(PDAL Error) Can't load library /opt/conda/envs/pangeo/lib/libpdal_plugin_writer_draco.so: Failed to load "/opt/conda/envs/pangeo/lib/libpdal_plugin_writer_draco.so": libdraco.so.8: cannot open shared object file: No such file or directory(PDAL Error) Can't load library /opt/conda/envs/pangeo/lib/libpdal_plugin_reader_draco.so: Failed to load "/opt/conda/envs/pangeo/lib/libpdal_plugin_reader_draco.so": libdraco.so.8: cannot open shared object file: No such file or directory(PDAL Error) Can't load library /opt/conda/envs/pangeo/lib/libpdal_plugin_writer_draco.so: Failed to load "/opt/conda/envs/pangeo/lib/libpdal_plugin_writer_draco.so": libdraco.so.8: cannot open shared object file: No such file or directory
Next, we define the path to the ALS LiDAR file in COPC format to be visualized.
1 | out_file = "data/ALS_ground_copc/outputs/norm_copc/norm_copc_chunk_624600_1013000.copc.laz" |
Reading the COPC file¶
We read the COPC file using the PDAL python package. Also, we sample the number of points by a factor of 10, thus reducing the number of points for visualization to save memory. PDAL processes point cloud data by executing pipelines comprising of various operations (separated by | below). For more details, the readers are recommended to follow Converting LiDAR LAS Files to Cloud-Optimized Point Clouds (COPCs).
1 2 | pipeline = (pdal.Reader.copc(filename=out_file) | pdal.Filter.decimation(step=10) | pdal.Filter.stats()) pipeline.execute() |
895968
Getting the Data Values¶
The data values from an executed pipeline are retrieved by calling the arrays method.
1 2 | # Getting array values arr_values = pipeline.arrays |
Creating Geo-dataframe¶
Now, we will create a data frame followed by a Geo-dataframe based on the data values. Lonboard requires the vector data as Geo-dataframe for visualization.
1 | df = pd.DataFrame(arr_values[0]) |
1 | gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.X, df.Y, z=df.Z), crs="32617") |
Also, Lonboard requires the Geo-dataframe to have its Spatial Reference System (SRS) as World Geodetic System 1984 (WGS 84). So, we re-project the geo-dataframe to EPSG 4326, which is the unique reference code for WGS 84.
1 2 | gdf_proj = gdf.to_crs(4326) gdf_proj.head() |
| X | Y | Z | Intensity | ReturnNumber | NumberOfReturns | ScanDirectionFlag | EdgeOfFlightLine | Classification | ScanAngleRank | UserData | PointSourceId | GpsTime | ScanChannel | ClassFlags | TargetThick | DevRatio | Zref | geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 624774.88 | 1013175.93 | 15.55 | 43 | 1 | 3 | 0 | 0 | 1 | -29.742001 | 1 | 13 | 3.691527e+08 | 0 | 0 | 0.184 | 0.9 | 43.19 | POINT Z (-79.86435 9.16396 15.55000) |
| 1 | 624773.58 | 1013178.64 | 14.74 | 22 | 3 | 5 | 0 | 0 | 1 | -29.658001 | 27 | 13 | 3.691527e+08 | 0 | 0 | 0.144 | 1.2 | 43.39 | POINT Z (-79.86436 9.16398 14.74000) |
| 2 | 624774.83 | 1013196.42 | 17.34 | 14 | 3 | 3 | 0 | 0 | 1 | -28.865999 | 1 | 13 | 3.691527e+08 | 0 | 0 | 0.216 | 1.1 | 50.77 | POINT Z (-79.86435 9.16414 17.34000) |
| 3 | 624765.98 | 1013175.28 | 16.76 | 59 | 1 | 1 | 0 | 0 | 1 | -30.167999 | 1 | 13 | 3.691527e+08 | 0 | 0 | 0.152 | 1.5 | 44.22 | POINT Z (-79.86443 9.16395 16.76000) |
| 4 | 624774.43 | 1013196.64 | -0.01 | 55 | 3 | 3 | 0 | 0 | 2 | -28.242001 | 1 | 13 | 3.691527e+08 | 0 | 0 | 0.088 | 0.9 | 33.52 | POINT Z (-79.86435 9.16415 -0.01000) |
In order to save memory, we delete the unrequired data frames. Also, we drop the columns which are not relevant for the visulization.
1 2 3 | # Deleting not required dataframes del(df) del(gdf) |
1 2 | #Dropping not required columns gdf_proj = gdf_proj.drop(['ReturnNumber', 'NumberOfReturns', 'ScanDirectionFlag', 'EdgeOfFlightLine', 'ScanAngleRank', 'UserData', 'PointSourceId', 'GpsTime', 'Intensity', 'Classification', 'ScanChannel', 'ClassFlags', 'TargetThick', 'DevRatio', 'Zref'], axis=1) |
Define a Point Cloud Layer¶
We define a Point Cloud Layer based on the geo-dataframe.
1 | point_layer = PointCloudLayer.from_geopandas(gdf_proj, point_size=2) |
Styling Point Cloud Layer¶
Here, we normalize the elevation values and create a color map for visualizing the point cloud layer.
1 2 | normalizer = Normalize(1, gdf_proj["Z"].max(), clip=True) normalized_heights = normalizer(gdf_proj["Z"]) |
1 | point_layer.get_color = apply_continuous_cmap(normalized_heights, Greens_3, alpha=0.6) |
Reading AGB Product Layer¶
For visualizing COGs, we use BitmapTileLayer which renders tiles dynamically generated by TiTiler.
1 | titiler_endpoint = "https://titiler.maap-project.org" |
1 2 | #AGB product stored locally in MAAP workspace agb_product_url = "s3://maap-ops-workspace/shared/omshinde23/agbd.tif" |
1 | agb_product_tile_url = "https://titiler.maap-project.org/cog/tiles/{z}/{x}/{y}?url=s3://maap-ops-workspace/shared/omshinde23/agbd.tif&bidx=5&rescale=0,600&colormap_name=viridis" |
1 2 3 4 5 6 7 8 9 | r_agb = httpx.get( f"{titiler_endpoint}/cog/info", params = { "url": agb_product_url, } ).json() bounds_agb = r_agb["bounds"] print(bounds_agb) |
[-79.87926821173158, 9.121956517182756, -79.81171917965493, 9.186829702218509]
1 2 3 4 5 6 7 8 | agb_product_layer = BitmapTileLayer( data=agb_product_tile_url, tile_size=148, max_requests=-1, min_zoom=3, max_zoom=18, extent=bounds_agb ) |
Creating Map Visualization for AGB Layer¶
Here, we visualize the AGB layer and point cloud layer in a single Map widget. The readers are recommended to use Ctrl+Click+Drag to change the viewing angle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #Function for generating colormap for AGB and CHM layer legend def plot_colormap(gradient_max, title, cmap): gradient = np.linspace(0, 10, gradient_max) gradient = np.vstack((gradient, gradient)) # Create figure and adjust figure height to number of colormaps nrows = 1 figh = 0.35 + 0.15 + (nrows + (nrows-1)*0.1)*0.22 fig, axs = plt.subplots(nrows=nrows, figsize=(6.4, figh)) fig.subplots_adjust(top=1-.35/figh, bottom=.15/figh, left=0.2, right=0.99) axs.get_yaxis().set_visible(False) axs.set_title(title, fontsize=14) axs.imshow(gradient, aspect='auto', cmap=cmap) |
1 2 | m = Map([agb_product_layer, point_layer]) m |
1 2 | # AGB values range 0-600, so gradient_max = 601 plot_colormap(601, "AGB Layer Legend",'viridis') |
Reading META CHM Layer¶
Now, we visualize the META CHM layer with the point cloud layer.
1 | chm_product_url = "s3://maap-ops-workspace/shared/omshinde23/meta_chm_new.tif" |
1 | chm_product_tile_url = "https://titiler.maap-project.org/cog/tiles/{z}/{x}/{y}?url=s3://maap-ops-workspace/shared/omshinde23/meta_chm_new.tif&rescale=0,60&colormap_name=viridis" |
1 2 3 4 5 6 7 8 9 | r_chm = httpx.get( f"{titiler_endpoint}/cog/info", params = { "url": chm_product_url, } ).json() bounds_chm = r_chm["bounds"] print(bounds_chm) |
[-79.87929430332252, 9.122007215999078, -79.81190140986412, 9.187029513446365]
1 2 3 4 5 6 7 8 | chm_product_layer = BitmapTileLayer( data=chm_product_tile_url, tile_size=148, max_requests=-1, min_zoom=3, max_zoom=18, extent=bounds_chm ) |
1 2 | m1 = Map([chm_product_layer, point_layer]) m1 |
1 | plot_colormap(61, "Meta CHM Layer Legend",'viridis') |