地理院標高タイルを Cloudflare Workers でTerrain RGBに変換して利用する

国土地理院は「[標高タイル](https://maps.gsi.go.jp/development/ichiran.html#dem)」という標高データ (DEMデータ) を表現した地図タイルを無償で配信してくれています。しかしこのタイルは独自のエンコード形式になっているため、[QGIS](https://www.qgis.org/)などの一般的なアプリケーションや地図系ライブラリで簡単に読み込んで利用することができません。今回は、[Cloudflare Workers](https://workers.cloudflare.com/) というエッジコンピューティングサービスを使って「Terrain-RGB」という、より一般的なエンコーディング形式に「オンデマンド」で変換することでQGISで扱えるようにしてみます。

![Screenshot 2023-08-12 at 6.54.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/1891db10-4248-dace-663c-e7094071e9a4.png)
（↑今回、国土地理院[標高タイル](https://maps.gsi.go.jp/development/ichiran.html#dem)をQGISで表示してみた例）

Cloudflare Workers は、いわゆる「CDN」でのエッジコンピューティングを行えるサービスです。今回はこれを使って、ユーザからタイルが要求されるたびに、地理院の標高タイル (PNG画像) を取得しつつ、オンザフライで Terrain RGB 形式のPNG画像に変換して配信するようにします。CDNエッジコンピューティングならではの利点として、変換したファイルをCDNにキャッシュさせることも容易です。


## 地理院標高タイル vs. Terrain RGB

両者はいずれも24bitカラー画像のRGB値をうまく使って標高 (DEM) データを表現する手法ですが、RGB値の表現の仕方が異なります。

地理院標高タイルの標高 h は以下のような式で表現されます：
> $x = 2^{16} R + 2^{8} G + B$
> $x < 2^{23}$ の場合　$h = xu$
> $x = 2^{23}$ の場合　$h = NA$ (無効値)
> $x > 2^{23}$ の場合　$h = (x-2^{24})u$
> uは標高分解能（0.01m）を表します。

一方で、世界でより広く使われている Terrain RGB という形式では以下のスタイルで標高を表現します：

> elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)

分解能は標高タイルの形式のほうが優れているのですが、そのほかは各ピクセルの値の表現が違うだけです。したがってCloudflare Workers上で実行するJavaScriptでPNG画像を読み書きして、全ピクセルについてRGB値の変換を行えば今回の目的が実現できそうです。


## Cloudflare Workers のプロジェクトをセットアップする

Cloudflare Workers についての説明は省略して、Cloudflareのアカウントも登録済みだとして、その後の手順のみを示します。

1. Wrangler (Cloudflare WorkersのCLI開発ツール) をインストールします

    ```console
    $ npm install -g wrangler
    ```

2. Cloudflare Workers にログインします

    ```console
    $ wrangler login
    ```

3. Wrangler にプロジェクトのひな形を出力させましょう

    ```console
    $ wrangler init gsi-terrain-rgb
    ```

## 画像を変換するコードを実装する

wranglerによって生成されたひな形の `src/workers.ts` を編集して、地理院タイルのPNG画像を Terrain RGB 形式のPNG画像に変換するコードを書きます。

PNG画像のデコードと再エンコードには [fast-png](https://www.npmjs.com/package/fast-png) というモジュールを使ってみました。

```src/workers.ts
import { decode, encode } from 'fast-png';

export interface Env {
  API_KEY: string;
}

function gsiToTerrainRGB(r: number, g: number, b: number): [number, number, number] {
  // Terrain RGB 形式のRGB値に変換
  if (r == 128 && g == 0 && b == 0) return [1, 134, 160]; // NA値は0にマップ
  let x = 65536 * r + 256 * g + b;
  const elev = x < 8388608 ? x * 0.01 : (x - 16777216) * 0.01;
  let value = (elev + 10000) / 0.1;
  r = Math.floor(value / (256 * 256));
  value %= 256 * 256;
  g = Math.floor(value / 256);
  b = Math.floor(value % 256);
  return [r, g, b];
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // AUTH
    if (url.searchParams.get('key') != env.API_KEY) {
      return new Response('authentication failed', { status: 401 });
    }

    const cache = caches.default;
    const cacheKey = new Request(url.toString(), request);
    let response = await cache.match(cacheKey);
    if (response) return response;

    const m = url.pathname.match(/dem(.+)\/\d+\/\d+\/\d+.png/);
    if (m == null) {
      return new Response('not found', { status: 404 });
    }

    const res = await fetch('https://cyberjapandata.gsi.go.jp/xyz/' + m[0], {
      cf: { cacheEverything: true, cacheTtlByStatus: { '200-299': 14400, 404: 7200, '500-599': 0 } },
    });
    if (!res.ok) {
      return res;
    }

    // 画像の各ピクセルの値を変換
    const img = decode(await (await res.blob()).arrayBuffer());
    for (let i = 0; i < img.height * img.width; i++) {
      const p = i * 3;
      [img.data[p], img.data[p + 1], img.data[p + 2]] = gsiToTerrainRGB(img.data[p], img.data[p + 1], img.data[p + 2]);
    }

    response = new Response(encode(img), { status: 200, headers: { 'Content-Type': 'image/png' } });
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

```

リクエストの処理と画像の変換を行うworkerのコードは以上だけで、短いです。（キャッシュのための処理を省けばさらにシンプルにもなります）。リクエストの処理の仕方などはCloudflare公式のサンプルコードを参考にしました。

それでは、さっそくCloudflare Workersにデプロイしてみましょう。以下を実行するだけでOKです：

```console
$ wrangler deploy
```

デプロイ結果として `https://gsi-terrain-rgb.xxxxxxxx.workers.dev` のような worker のURLが表示されると思います。

今回は不特定多数からのアクセスを防ぐために、`?key=` というリクエストパラメータを要求するコードにしてあります。そこで使っている環境変数 `API_KEY` の値は今回はCloudflareのコンソールからセットしました。

:::note info
なお、Cloudflare のキャッシュを利用する場合は、Cloudflare のDNSで管理している**独自ドメイン名をworkerに割り当てる必要**があります。しかし以下の説明では Cloudflare によって付与された workers.dev ドメインのまま利用しています（つまりキャッシュは機能しません）。
:::

## QGISで読み込んでみる

QGISのXYZタイルソースとして読み込んでみましょう。デプロイされた worker のURLを使って `https://gsi-terrain-rgb.xxxxxxxx.workers.dev/dem_png/{z}/{x}/{y}.png` をタイルソースとして参照します。また、今回は不特定多数から閲覧されないようにするため `?key={秘密のキー}` というキーを要求するようなコードにしているので、それもURLに付与します。

標高タイルとして認識させるため、「データの解釈」で「**MapTiler Terrain RGB**」を選択するのを忘れずに！（今回は、地理院の標高タイルを、Cloudflare Workers で Terrain RGB の形式に変換しているのでした）。

![Screenshot 2023-08-12 at 6.42.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/066e6d38-1cd1-d38d-627f-82aecbf316dd.png)

このソースを読み込むと、無事に標高データが表示されました！

![Screenshot 2023-08-12 at 6.55.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/92404510-da8e-5744-9811-37a2c042cbb4.png)

レンダリングタイプを「陰影図 (hillshade)」にすると次のような表示になります。

![Screenshot 2023-08-12 at 7.06.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/981585a1-fd1e-d656-cd51-ecf93ac89de4.png)

Cloudflare Workers のダッシュボードでメトリクスを見ると、おおむね各タイルを1ミリ秒以下のCPU時間で処理できているようです。

### 追記: 3D表示もできる

地理院の写真タイルと一緒に読み込んでQGISの3Dビューを開けば、簡単にそれらしい3D表示ができます（この例は黒部ダムのあたり）。

![Screenshot 2023-08-20 at 12.10.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/a9f43792-d123-3289-4f34-90425b9e02a7.png)

地形のタイル解像度は 256px に設定しましょう（デフォルトの16pxだとかなり荒い地形になってしまいます）。

![Screenshot 2023-08-20 at 12.11.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95370/93614da6-d4e4-fe49-3645-7f46be5c9116.png)


## おわりに

Cloudflare Workersなどのエッジコンピューティングには様々な活用方法がありそうですし、もっともっと高度なことができるでしょうが、今回は、ちょっとしたJavaScriptを書くだけで、ピクセル操作を伴う画像のオンデマンド加工がCDNエッジ上で手軽にできることを示しました。