(Record) Making Voxel tiles from point cloud LAZ data using pdal and tippecanoe - trial 2　(thumbnail for OpenDroneMap data)

# Introduction
My colleague Steve gave me some sample date of the point cloud (about 5.5GB in LAZ), so I tried to make voxel tile to easily overview the data. As the source was large, I used **pdal tile** to reduce the burden during the voxel resampling. Here is the record of my work. The source data, the point cloud data, was generated by using OpenDroneMap.

# Result
Before introducing the procedure, here is the overview of my final result in GitHub page. They can be seen at https://ubukawa.github.io/voxel-2/index.html . It would be a good to change your browser scake to smaller such as 50% or 33%. 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/2b689d82-771c-2860-f062-5128076ee583.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/d40206c6-b0b2-39c4-a90e-c163122dcce0.png)

https://ubukawa.github.io/voxel-2/index.html#17.07/-4.894677/29.653191/-63.9
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/884806a5-f75b-a5f1-e1c1-f7c55639645c.png)



# My Environment
* Windows 10 Enterprise
* Docker version 20.10.8
* GitHub Repository for this work https://github.com/ubukawa/voxel-2


# Procedure

## Step 0. Starting a Docker container
In order to easily establish my working environment, I used Docker container. A docker image unvt/voxel has the pdal and other tools for this work.

```
docker run -it --rm -v ${PWD}:/data unvt/voxel
```

## Step 1. Check the point cloud data
We can check metadata of the targeted data by running the following command. The option "metadata" will save time if your data is too big. You can check the projection and datum of the target.

```
pdal info src/sample.laz --metadata
``` 
In this case, I confirmed that the datum is WGS84 and projection is UTM35S, therefore its EPSG code should be 32735.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/24170037-362c-be68-7c82-53d7267bfe1e.png)

Let's also check a specific point (#0) with the following command (dumping the point #0).

```
pdal info src/sample.laz -p 0
``` 

But, I found that it took long time wit the above command. Let's use this then.

```
pdal info src/sample.laz --summary
``` 
This one worked promptly, and I can see the properties at "dimensions." It seems that the RGB values are in #13rd, 14th, and 15th columns. 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/98885ba9-699b-a963-5ce6-a9c784d50351.png)




## Step 2. Reprojection
Using pdal with ruby, we can convert the projection from UTM to the web mercator which is for the web map tile. Prepare the following "1.rb" and "constants.rb", and run the command.


```ruby: 1.rb
# for reprojection. (actually it is not needed)
require './constants'
require 'yaml'
require 'json'

basename = ENV['BASENAME']
src_path = "#{TMP_DIR}/#{basename}.laz"
dst_path = "#{TMP_DIR}/#{basename}-3857.laz" # you can use las if you want

pipeline = <<-EOS
pipeline: 
  - 
    filename: #{src_path}
    type: readers.las
    spatialreference: "EPSG:32735"
  -
    type: filters.reprojection
    out_srs: "EPSG:3857" 
  -
    type: writers.las
    filename: #{dst_path}
EOS

print JSON.dump(YAML.load(pipeline))
```

1.rb defined the projection of the source and output. In addition, it defines the format of output as "LAS" or "LAZ". Although its export type is "writers.las", it covers LAZ format if you define the export file name as laz (see "dst_path" in the 1.rb).
After the conversion as the las format, I found that the exported file in LAS format is about 37GB while the source file in LAZ (compressed) is 5.6 GB. When I converted it into laz, the exported file was almost the same size with the source.


```ruby: constants.rb
SKIP = true

TMP_DIR = 'src'
Z_ONE_METER = 19
BASE = 2

MAXZOOM = 19
MINZOOM = 10
MINCOPYZOOM = 10

LAYER = 'voxe
```

You run the command with specifying the basename. Thus, "src/sample.laz" will be converted and exported as "src/sample-3857.las" (or "src/sample-3857.laz"). It will take several hours depend on your data size.  
```bash:
BASENAME=sample ruby 1.rb | pdal pipeline --stdin
```


 
## Step 3. Conversion (failed)
Now, we have "sample-3857.las" (or laz) in src directory. Let's go futher. Prepare 2.rb and 3.rb, then run the following command. Then, wait for a while. 

```bash:
Z=19 BASENAME=sample ruby 2.rb | pdal pipeline --stdin | Z=19 ruby 3.rb | tippecanoe --maximum-zoom=19 --minimum-zoom=19 --projection=EPSG:3857 --force --output out-19.mbtiles --no-tile-size-limit --no-feature-limit
```

2.rb is for resampleing using filters.voxelcenternearestneigbour of pdal.
```ruby.2.rb
require './constants'
require 'yaml'
require 'json'

z = ENV['Z'].to_i
spacing = (BASE ** (Z_ONE_METER - z)).to_f
basename = ENV['BASENAME']
src_path = "#{TMP_DIR}/#{basename}-3857.laz" #unless File.exist?(src_path)
dst_path = "#{TMP_DIR}/#{basename}-#{z}.laz"
$stderr.print "#{spacing}m for #{dst_path} from #{src_path}\n"

pipeline = <<-EOS
pipeline: 
  - 
    filename: #{src_path}
    type: readers.las
  -
    type: filters.voxelcenternearestneighbor
    cell: #{spacing}
  -
    type: writers.text
    format: csv
    filename: STDOUT
EOS

print JSON.dump(YAML.load(pipeline))
```

3.rb is for cleaning and adjusting the GeoJSON sequence for Tippecanoe, a vector tile conversion tool, processing. If you are an advanced user, you may find it interesting about the minzoom/maxzoom setting. This process will generate vector tiles at a single ZL. So, you have to repeat the process with the different z parameters. This is because we want to use different spacing for each zoom level (e.g. 1 meter at ZL19, 2 meter at ZL18, 4 meter at ZL17, etc..).

```ruby:3.rb
require './constants'
require 'yaml'
require 'json'

z = ENV['Z'].to_i
minzoom = (z == MINZOOM) ? MINCOPYZOOM : z
maxzoom = z
spacing = (BASE ** (Z_ONE_METER - z)).to_f
n = 0

first = true
start_time = Time.now
while gets
  if first
    first = false
    next
  else
    n += 1
  end
  r = $_.strip.split(',')
  x = r[0].to_f - r[0].to_f % spacing
  y = r[1].to_f - r[1].to_f % spacing
  h = r[2].to_f - r[2].to_f % spacing
  h = h.to_i
  color = '#' + r[13..15].map{|v| sprintf('%01x', v.to_i )}.join
  g = <<-EOS
type: Polygon
coordinates: 
  -
    -
      - #{x}
      - #{y}
    -
      - #{x + spacing}
      - #{y}
    -
      - #{x + spacing}
      - #{y + spacing}
    -
      - #{x}
      - #{y + spacing}
    -
      - #{x}
      - #{y}
  EOS
  g = YAML.load(g)
  f = <<-EOS
type: Feature
properties: 
  color: '#{color}'
  h: #{h}
  spacing: #{spacing}
tippecanoe:
  minzoom: #{minzoom}
  maxzoom: #{maxzoom}
  layer: #{LAYER}
  EOS
  f = YAML.load(f)
  f[:geometry] = g
  print JSON.dump(f), "\n"
end

def hms(s)
  min, sec = s.to_i.divmod(60)
  hour, min = min.divmod(60)
  "%02d:%02d:%02d" % [hour, min, sec]
end

$stderr.print "\n[#{z}] #{hms(Time.now - start_time)}s\n"
```

However, unfortunately, after a while, **I found that the process was killed, I meant the voxelcenternearestneighbour was killed**, dut to the large size of the source (about 5GB in LAZ format). If your data is not so big or if your PC is strong enough, you would succeed. **I need to try another way.**



## Step 4. Divide the data
Because the data is too big. Let's divide it into several pieces using **pdal tile**.

```
pdal tile --length 2000 src/sample-3857.laz "tile/output_#.laz"
```

Then, we have the tiled laz data. If the size of the files is still large, you can change the parametere of the previous tiling command. (I first used 3000, but it was not good, so I used 2000.)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/c7a3a54d-f850-62b8-7824-ca631a4506ef.png)

## Step 5. Resampling to Voxel, and other minor adjustments
### conversion of each file at each zoom level
For each laz file, we create the voxel vector tile with the following script. I ran the same command for zoom level 19 to 15. At ZL19, it has 1 meter voxel, while we have 16 meter voxel at ZL15.

```bash:all.sh
mkdir mbtiles/19
for f in tile/*.laz; do Z=19 BASENAME=`basename ${f} .laz` ruby 2.rb | pdal pipeline --stdin | Z=19 ruby 3.rb | tippecanoe --maximum-zoom=19 --minimum-zoom=19 --projection=EPSG:3857 --force --output mbtiles/19/`basename ${f} .laz`.mbtiles --no-tile-size-limit --no-feature-limit; done
mkdir mbtiles/18
for f in tile/*.laz; do Z=18 BASENAME=`basename ${f} .laz` ruby 2.rb | pdal pipeline --stdin | Z=18 ruby 3.rb | tippecanoe --maximum-zoom=18 --minimum-zoom=18 --projection=EPSG:3857 --force --output mbtiles/18/`basename ${f} .laz`.mbtiles --no-tile-size-limit --no-feature-limit; done
mkdir mbtiles/17
for f in tile/*.laz; do Z=17 BASENAME=`basename ${f} .laz` ruby 2.rb | pdal pipeline --stdin | Z=17 ruby 3.rb | tippecanoe --maximum-zoom=17 --minimum-zoom=17 --projection=EPSG:3857 --force --output mbtiles/17/`basename ${f} .laz`.mbtiles --no-tile-size-limit --no-feature-limit; done
mkdir mbtiles/16
for f in tile/*.laz; do Z=16 BASENAME=`basename ${f} .laz` ruby 2.rb | pdal pipeline --stdin | Z=16 ruby 3.rb | tippecanoe --maximum-zoom=16 --minimum-zoom=16 --projection=EPSG:3857 --force --output mbtiles/16/`basename ${f} .laz`.mbtiles --no-tile-size-limit --no-feature-limit; done
mkdir mbtiles/15
for f in tile/*.laz; do Z=15 BASENAME=`basename ${f} .laz` ruby 2.rb | pdal pipeline --stdin | Z=15 ruby 3.rb | tippecanoe --maximum-zoom=15 --minimum-zoom=15 --projection=EPSG:3857 --force --output mbtiles/15/`basename ${f} .laz`.mbtiles --no-tile-size-limit --no-feature-limit; done
```

## merge outputs into one at each zoom level
As we went through the resampling process, I think now we can marge output mbitles.
First, we will merge mbtiles at each zoom level.
```bash
tile-join -pk -o mbtiles/compile-19.mbtiles mbtiles/19/*.mbtiles
tile-join -pk -o mbtiles/compile-18.mbtiles mbtiles/18/*.mbtiles
tile-join -pk -o mbtiles/compile-17.mbtiles mbtiles/17/*.mbtiles
tile-join -pk -o mbtiles/compile-16.mbtiles mbtiles/16/*.mbtiles
tile-join -pk -o mbtiles/compile-15.mbtiles mbtiles/15/*.mbtiles
```

Ok, now, we can roughly see the size of the voxel tiles for each zoom level.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/166dae02-a222-9255-cd03-eb5411819382.png)
The source LAZ data was **5,574,559KB**. At zoom level 19, where the voxel spacing is 1 meter, it is about 1 GB, and I think this is still large. Although it is 2 meter voxel, at zoom level 18, I thik about 230MB is pretty good for overview the data. I can continue my work at 1 meter voxel, but in order to host them in GitHub page, I will continue with ZL 18 and smaller.


## Making pbf tiles from mbtiles
In order to host vector tile in static way, we will convert mbtiles into (zxy folder structured) pbf. I think we can do this with tile-join. By converting into pbf, although we can host them easily, the data size will be bigger.

```bash
tile-join -pk -pC -e  pbf/zxy18 mbtiles/compile-18.mbtiles
tile-join -pk -pC -e  pbf/zxy17 mbtiles/compile-17.mbtiles
tile-join -pk -pC -e  pbf/zxy16 mbtiles/compile-16.mbtiles
tile-join -pk -pC -e  pbf/zxy15 mbtiles/compile-15.mbtiles
```

We have pbf tiles like this.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/00205229-0733-b47d-c28d-0b1dffa50a24.png)

Oh, I found that the total size for ZL18 now exceeds 1GB while it was about 230MB in mbtiles format. It is well expected.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/0b167e8f-b4c0-0536-2496-72b115d55bb2.png)


So, I created voxel tile set from ZL 17 to 15 like this (at ZL17, voxel spacing is 4 meter). Its total size is 315MB.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/1cbbcf0c-4a83-16fd-2752-14097d22767a.png)

FYI, if I include all like below, its total size is **4.83GB**. Not good for easy hosting...
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/08260a8a-a05a-56e6-3e95-f374dbb85a34.png)


## Step 6. Hosting
I enabled GitHub page, and uploaded the files at docs folder. This time, we use MapLibre as the map library.
For detail, please check https://github.com/ubukawa/voxel-2/tree/main/docs
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/f7b92c8f-0440-633f-70f1-08338168182d.png)

# Result
Due to the file size constrain, I just uploaded the voxel tile of up to 4 meters at ZL 17. You can see it here https://ubukawa.github.io/voxel-2/index.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/b94a8ef7-e616-7727-e221-14d5b53c1126.png)

## Future issue
I want to know how we can remove the terrain offset for certain layers when I overlay over the 3D terrain model with MapLibre GL JS.
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/def41976-5e13-6491-cfe5-8e8a9f40774f.png)


# Acknowledgement
Thank you Steve V. M. for sharing your sample data. Thank you @hfu for developing this method.


# Reference
* PDAL https://pdal.io/en/stable/index.html
* MapLibre GL JS https://github.com/maplibre/maplibre-gl-js
* How to create voxel tiles (in Japanese) at YouTube (by Furuhashi Lab. and @hfu  ) https://www.youtube.com/watch?v=LrDk0VFodTE
* OpenDroneMap https://www.opendronemap.org/
* optgeo/kid-c https://github.com/optgeo/kid-c
* Making Voxel tiles from LAZ using pdal and Tippecanoe https://qiita.com/T-ubu/items/1a0cd7363b8f93de8821