MapLibre GL JSとCOGによるサーバーレスなラスタータイル配信・表示

## はじめに - Cloud-Optimizedという用語について

昨今のFOSS4G界隈のキーワードのひとつは`Cloud-Optimized`です。これはいわゆる「サーバーレス」を志向するファイルフォーマットを表す形容詞です。具体的には、`HTTP-Range-Request`を活用し、巨大なデータから必要最小限の部分のみを取り出せるようにした形式と言えます。言うなればリモートファイルへのランダムアクセスで、この仕組みを使えば、ウェブサーバーさえあればGISサーバーが必要なくなる、つまり「サーバーレス」が実現します。

Range-Requestでは、ブラウザからサーバーへ「AというファイルのBからCの範囲のバイト列を返してくれ」というリクエストを出す事になります。しかし、リモートサーバーにあるファイルのバイト列のうちブラウザで必要な範囲がどこなのかは、何もアテがなければ特定することが出来ません。この<b>アテ</b>を用意するのがCloud-Optimizedなファイル形式と言えます。

Cloud-Optimizedなファイル形式では、バイト列の先頭に、内部に保存されているデータの情報を格納するための一定のバイト長が確保されています。ブラウザではまず、その一定の長さのバイト列をRange-Requestで読み出し、データがどのように格納されているかを把握します。これを<b>アテ</b>にして、抽出したいデータのバイト列の範囲をブラウザ側で計算し改めてRange-Requestを送ることで、必要最小限のデータのみを取得することが出来ます。下記で紹介する各種フォーマットのExampleでブラウザの通信を確認すれば、どのようにしてRange-Requestが発出されているか確認することができるでしょう。

ベクター・ラスターでそれぞれで、Cloud-Optimizedな形式が実装されています。ベクターは[FlatGeobuf](https://github.com/flatgeobuf/flatgeobuf)が最も成熟しており、[GeoParquet](https://github.com/opengeospatial/geoparquet)が追いかける形です。ラスターでは[Cloud-Optimized GeoTIFF(COG)](https://www.cogeo.org/)がデファクトスタンダードです。変わり種では[PMTiles](https://github.com/protomaps/PMTiles)というタイル志向の規格も実装されています。

私の観測範囲では<b>COGはすでに普及期のようです</b>（従来のGeoTIFFの機能拡張なので普及しやすいのかも）。

本記事では、COGを用いて`MapLibre GL JS`のラスタータイルレイヤーをサーバーレスで実現出来るか実験した結果を紹介します。なお<b>実現はできて、条件付きで実用性もあるだろう</b>という結論に至りました。

### データについて

本記事で用いるデータは下記で取得出来るオルソ画像を加工したものとします。
[航空レーザー測量データ（平成28年　地方創生推進交付金）【当麻町】](https://www.harp.lg.jp/opendata/dataset/1623.html)

なお加工したデータのファイル名は`input.tif`とします。

![スクリーンショット 2022-05-23 21.55.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/c08f9700-d6b7-80a8-f164-dc9f2323877d.png)

### 先行事例

- https://qiita.com/yonda_gliese/items/19aae7024c4c670982b4
- https://zenn.dev/yonda/articles/f1ed8db1b2d6ae5a9155
- https://zenn.dev/yonda/articles/92e8df328c768e18a661

このあたりの記事を読めば、COGを用いたラスタータイルサーバーを構築することができるでしょう。そのうえで本記事は、<b>COGの仕組みがあるならば、そもそもサーバーを介さずにラスタータイル実装を実現出来るはず</b>という仮説を検証することを目的とします。

## Cloud-Optimized GeoTIFF(COG)を作成する

COGは`GDAL`で簡単に作る事ができます。ラスタータイルとして使いたいので、ここでは`TILING_SCHEME`オプションを設定し、ウェブメルカトルに適したCOGを作成します。

```sh
gdal_translate input.tif -of COG cog.tif -co TILING_SCHEME=GoogleMapsCompatible -co COMPRESS=DEFLATE
```

EPSG:3857に再投影されているのでデータ自体は変わっているものの、QGISなどで表示する限りは変換前とデータにほぼ差がない事がわかります。普通のGeoTIFFとしてそのまま使えるのもCOGの利点と言えるでしょう。

> ここで、投影法のほかブロックサイズも調整の余地があるようですが、実際にどのようにパフォーマンスに影響するかは深掘りしていません。

下記のようなGeoTIFFファイルとなりました。

```
gdalinfo input.tif 
Driver: GTiff/GeoTIFF
Files: input.tif
Size is 37632, 21248
Coordinate System is:
PROJCRS["WGS 84 / Pseudo-Mercator",
    BASEGEOGCRS["WGS 84",
        DATUM["World Geodetic System 1984",
            ELLIPSOID["WGS 84",6378137,298.257223563,
                LENGTHUNIT["metre",1]]],
        PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433]],
        ID["EPSG",4326]],
    CONVERSION["Popular Visualisation Pseudo-Mercator",
        METHOD["Popular Visualisation Pseudo Mercator",
            ID["EPSG",1024]],
        PARAMETER["Latitude of natural origin",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8801]],
        PARAMETER["Longitude of natural origin",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8802]],
        PARAMETER["False easting",0,
            LENGTHUNIT["metre",1],
            ID["EPSG",8806]],
        PARAMETER["False northing",0,
            LENGTHUNIT["metre",1],
            ID["EPSG",8807]]],
    CS[Cartesian,2],
        AXIS["easting (X)",east,
            ORDER[1],
            LENGTHUNIT["metre",1]],
        AXIS["northing (Y)",north,
            ORDER[2],
            LENGTHUNIT["metre",1]],
    USAGE[
        SCOPE["Web mapping and visualisation."],
        AREA["World between 85.06°S and 85.06°N."],
        BBOX[-85.06,-180,85.06,180]],
    ID["EPSG",3857]]
Data axis to CRS axis mapping: 1,2
Origin = (15862823.605966057628393,5450800.924044203013182)
Pixel Size = (0.298582141739031,-0.298582141738910)
Metadata:
  AREA_OR_POINT=Area
Image Structure Metadata:
  COMPRESSION=DEFLATE
  INTERLEAVE=PIXEL
  LAYOUT=COG
Tiling Scheme:
  NAME=GoogleMapsCompatible
  ZOOM_LEVEL=19
Corner Coordinates:
Upper Left  (15862823.606, 5450800.924) (142d29'53.41"E, 43d54'19.13"N)
Lower Left  (15862823.606, 5444456.651) (142d29'53.41"E, 43d51'51.26"N)
Upper Right (15874059.849, 5450800.924) (142d35'56.78"E, 43d54'19.13"N)
Lower Right (15874059.849, 5444456.651) (142d35'56.78"E, 43d51'51.26"N)
Center      (15868441.728, 5447628.787) (142d32'55.09"E, 43d53' 5.21"N)
Band 1 Block=256x256 Type=Byte, ColorInterp=Red
  Overviews: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
  Mask Flags: PER_DATASET ALPHA 
  Overviews of mask band: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
Band 2 Block=256x256 Type=Byte, ColorInterp=Green
  Overviews: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
  Mask Flags: PER_DATASET ALPHA 
  Overviews of mask band: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
Band 3 Block=256x256 Type=Byte, ColorInterp=Blue
  Overviews: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
  Mask Flags: PER_DATASET ALPHA 
  Overviews of mask band: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
Band 4 Block=256x256 Type=Byte, ColorInterp=Alpha
  Overviews: 18816x10624, 9408x5312, 4704x2656, 2352x1328, 1176x664, 588x332, 294x166, 147x83
```

一般的なウェブサーバーならどこでもいいのですが、今回は`AWS S3`にアップロードしておくことにします。
以降、URLは`https://<BUCKET_NAME>.s3.ap-northeast-1.amazonaws.com/cog.tif`とします。

## MapLibre GL JSとgeotiff.jsでCOGタイルを実装する

ブラウザでの地図ライブラリは`MapLibre GL JS`を選択しました。個人的に最もよく使うライブラリであることと、最近実装された仕組みを試したかったからです。ラスタータイルはふつうの地図ライブラリでは必ず実装があるものなので、ひとつのライブラリで検証が成功すれば横展開は難しくないはずです。

また、COGのJavaScript実装は[geotiff.js](https://geotiffjs.github.io/)一択となります。以下、全コードと実装のポイントを示します。

### 実装のポイント

- MapLibre GL JSの`addProtocol()`を用いて、COGへのタイルリクエストの振る舞いを定義する（ちなみにMapbox GL JSにはこの実装はありません）
- geotiff.jsの`GeoTIFFBase.readRasters()`を用いて、リモートサーバーのCOGに対してRange-Requestを送る
- （これは今回の本筋ではありませんが補足）`readRasters()`で取得した値は`Uint8Array`となります（0-255の範囲でのRGBA値）。一方でMapLibre GL JSでは画像ファイルとしてのバイナリデータを渡してやる必要があるので、[fast-png](https://github.com/image-js/fast-png)を用いてPNG形式へエンコードしています。canvasでやるより100倍程度高速でした。

### コード

検証なのであまり綺麗な実装ではありません。

#### HTML

```html
<div id="map" style="height:90vh;"></div>
```

#### TypeScript

```typescript
import maplibregl, { Map, RasterSourceSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Pool, fromUrl } from 'geotiff';
import { encode } from 'fast-png';

/**
 * transform x/y/z to webmercator-bbox
 * @param x
 * @param y
 * @param z
 * @returns {number[]} [minx, miny, maxx, maxy]
 */
const merc = (x: number, y: number, z: number): number[] => {
    // 参考: https://qiita.com/MALORGIS/items/1a9114dd090e5b891bf7
    const GEO_R = 6378137;
    const orgX = -1 * ((2 * GEO_R * Math.PI) / 2);
    const orgY = (2 * GEO_R * Math.PI) / 2;
    const unit = (2 * GEO_R * Math.PI) / Math.pow(2, z);
    const minx = orgX + x * unit;
    const maxx = orgX + (x + 1) * unit;
    const miny = orgY - (y + 1) * unit;
    const maxy = orgY - y * unit;
    return [minx, miny, maxx, maxy];
};

const generateCogSource = async (
    url: string,
): Promise<{ source: RasterSourceSpecification }> => {
    const tiff = await fromUrl(url);
    const pool = new Pool();
    maplibregl.addProtocol('cog', (params, callback) => {
        const segments = params.url.split('/');
        const [z, x, y] = segments.slice(segments.length - 3).map(Number);
        const bbox = merc(x, y, z);
        const size = 256;
        tiff.readRasters({
            bbox,
            samples: [0, 1, 2, 3], // 取得するバンドを指定
            width: size,
            height: size,
            interleave: true,
            pool,
        }).then((data) => {
            const img = new ImageData(
                //@ts-ignore
                new Uint8ClampedArray(data),
                size,
                size,
            );
            const png = encode(img);
            callback(null, png, null, null);
        });
        return { cancel: () => {} };
    });
    const source: RasterSourceSpecification = {
        type: 'raster',
        tiles: [`cog://${url.split('://')[1]}/{z}/{x}/{y}`],
        tileSize: 256,
        minzoom: 8,
        maxzoom: 16, // 今回用いたCOGは19までの解像度を持っているようだが制限しておく
    };
    return { source };
};

const map = new Map({
    container: 'map',
    style: {
        version: 8,
        sources: {
            osm: {
                type: 'raster',
                tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
                attribution:
                    '<a href="http://osm.org/copyright">OpenStreetMap contributors</a>',
            },
        },
        layers: [
            {
                id: 'osm',
                type: 'raster',
                source: 'osm',
            },
        ],
    },
});

map.on('load', async () => {
    const { source } = await generateCogSource(
        'https://<BUCKET_NAME>.s3.ap-northeast-1.amazonaws.com/cog.tif',
    );
    map.addSource('cogSample', source);
    map.addLayer({
        id: 'cogLayer',
        type: 'raster',
        source: 'cogSample',
    });
});

```

## 結果と結論

上記の実装で、<b>サーバーを用いずCOGをラスタータイルとして表示することができました。</b>

![スクリーンショット 2022-05-23 22.43.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/58a651a3-ee02-c5b1-278d-61e0e18f0b20.png)


![スクリーンショット 2022-05-23 22.25.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/752b7899-3db9-51a2-d333-816988b54d4f.png)

なお下記のような注意点があります。以下が許容・迂回できるなら実用できると思います。

### 注意点

#### ちょっと重たい

画像の読み込み時にややカクツキがあります。読み込み速度自体もタイルサーバーを用いるときよりは若干遅いです（これは当然か）。

#### 一度に大量のリクエストが発生すると、geotiff.jsがエラーを出すことがある

現状未解決の[Issue](https://github.com/geotiffjs/geotiff.js/issues/218)のようです。なるべくリクエストが少なくなるようにした方がよさそうです（MapLibre GL JSで言えば、tileSizeを大きくする・maxzoomをやや低くしておくなど）。

また、エラーが発生した場合はそのタイルの読み込みがストップしてしまいます。