衛星データプラットフォーム Tellus と Elixir Livebook を使って、植物の分布から田畑の利用状況を推測する（NDVI算出編）

## はじめに

経済産業省の「衛星データ利用環境整備・ソリューション開発支援事業」において、弊社は衛星データ無料利用事業者として支援を受けています

https://sdu.go.jp/

本事業では日本発の衛星データプラットフォーム「Tellus」のデータの一部を無料利用できます

（一般利用者が無料利用できるデータもあります）

https://www.tellusxdp.com/

「Tellus」から取得できる様々なデータを使って何をどう実装できるのか、現在調査検証中です

連載で「Tellus」の公式メディア「宙畑-sorabatake-」の記事を参考にしながら、 Elixir で衛星データを扱っています

https://sorabatake.jp/

参考にする記事

https://sorabatake.jp/28126/

今回は植生（植物の集まり、植物がどれくらい生えているか）を見るため、 NDVI(Normalized Difference Vegetation Index) = 正規化植生指数を算出します

引き続き Elixir の Livebook を使っていきます

まだ読んでいない方は環境構築編、データ取得編から読んでください

連載記事

- [環境構築編](https://qiita.com/RyoWakabayashi/items/bf0590b5686c7462b4a0)
- [データ取得編](https://qiita.com/RyoWakabayashi/items/c87fe1256bcd105d370c)
- NDVI算出編 <- ここ
- [市区町村切り抜き編](https://qiita.com/RyoWakabayashi/items/d65dd3a508284e67c8cd)
- [休耕地探索編](https://qiita.com/RyoWakabayashi/items/4039649daa0d22a3bda0)

本記事で実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/tellus/ndvi.livemd

## 出典

提供：だいち(ALOS)　AVNIR-2　データ(JAXA)

## 前提条件

[データ取得編](https://qiita.com/RyoWakabayashi/items/c87fe1256bcd105d370c) で対象としたシーンのデータを Tellus からダウンロードし、 `/tmp/<シーンID>/` 配下に保存しているものとします

## セットアップ

Livebook を起動し、新しいノートブック（もしくは私の実装したノートブック）を開いてください

セットアップセルに以下の内容を入力して実行してください

```elixir
Mix.install(
  [
    {:nx, "~> 0.4"},
    {:evision, "~> 0.1"},
    {:exla, "~> 0.4"},
    {:kino, "~> 0.8"},
    {:kino_maplibre, "~> 0.1.3"},
    {:kino_vega_lite, "~> 0.1.7"}
  ],
  config: [
    nx: [
      default_backend: EXLA.Backend
    ]
  ]
)
```

ここでは以下の依存モジュールをインストールしています

- [Nx](https://github.com/elixir-nx/nx/tree/main/nx): 行列演算
- [Evision](https://github.com/cocoa-xu/evision): 画像処理
- [EXLA](https://github.com/elixir-nx/nx/tree/main/exla): 行列演算の高速化
- [Kino](https://github.com/livebook-dev/kino): Livebook での入出力
- [Kino MapLibre](https://github.com/livebook-dev/kino_maplibre): 地図出力
- [Kino VegaLite](https://github.com/livebook-dev/kino_vega_lite): グラフ出力

また、 Nx による行列演算を EXLA を使って高速化する設定をしています

## データの指定

[データ取得編](https://qiita.com/RyoWakabayashi/items/c87fe1256bcd105d370c) で選択したシーンIDリストを指定しておきます

```elixir
scene_id_list = [
  "202ce08d-ba4b-4ffe-8165-109fd3a8b917",
  "34d8dc6f-fdd1-4542-a038-c1235a5a97fa",
  "12ad308b-6ce1-40ec-9ebf-f0215c30500e",
  "e2e85b2e-a208-4a65-87fd-b92721b037a8",
  "208a3618-7700-421b-bf05-fd59551cc1aa",
  "d5ce7320-5b25-4ced-bda5-0e25a9d75940",
  "9d14706f-cee7-4eb4-9151-2558609c3de0",
  "3f4555ac-eaf3-4066-a1ba-20bb1ec1c0b3"
]
```

## 衛星データを画像として表示する

前回の最後にやりましたが、改めて衛星データを画像として表示してみます

まず先頭のシーンを表示対象にします

```elixir
scene_id = Enum.at(scene_id_list, 0)
```

対象データの保存先ディレクトリーからファイル一覧を取得します

```elixir
file_path_list =
  "/tmp/#{scene_id}"
  |> File.ls!()
  |> Enum.sort()
  |> Enum.map(fn filename ->
    ["/tmp", scene_id, filename]
    |> Path.join()
  end)
```

実行結果は以下のようになります

```elixir
["/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/ALAV2A040842780-OORIRFU-D069P3-20061031-001_thumb.png",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/ALAV2A040842780_webcog.tif",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/HDR-ALAV2A040842780-OORIRFU-D069P3-20061031-001.txt",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/IMG-01-ALAV2A040842780-OORIRFU-D069P3-20061031-001.tif",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/IMG-02-ALAV2A040842780-OORIRFU-D069P3-20061031-001.tif",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/IMG-03-ALAV2A040842780-OORIRFU-D069P3-20061031-001.tif",
 "/tmp/202ce08d-ba4b-4ffe-8165-109fd3a8b917/IMG-04-ALAV2A040842780-OORIRFU-D069P3-20061031-001.tif"]
```

ファイル名を見ただけでは何のデータか分からないので、説明書を確認してみましょう

[ALOS AVNIR-2 オルソ補正画像プロダクト プロダクトフォーマット説明書 Version 2.0](https://www.eorc.jaxa.jp/ALOS/alos-ori/doc/AVNIR-2_ORI_format_jp.pdf)

これを見ると、格納しているファイルは以下のものだと書かれています

> ヘッダファイル
> オルソデータファイル（バンド1）
> オルソデータファイル（バンド2）
> オルソデータファイル（バンド3）
> オルソデータファイル（バンド4）
> ブラウズ画像

ヘッダファイルはテキストで衛星データの座標情報などを持っています

ブラウズ画像は Tellus Traveler 上で表示する際のカラー画像のようです

ブラウズ画像は TIFF と PNG の2種類が入っています

ではオルソデータファイル（バンド1〜4）はどういう意味でしょうか

ALOS　AVNIR-2　の製品ページを見てみましょう

https://www.restec.or.jp/solution/product/product-alos/alos-avnir-2.html

すると、観測波長帯の項目に以下の記述があります

> Band1：0.42 ～ 0.50µm
> Band2：0.52 ～ 0.60µm
> Band3：0.61 ～ 0.69µm
> Band4：0.76 ～ 0.89µm

どうやらバンド1〜4はそれぞれの波長帯で観測したデータのようです

波長帯とは、、、？という方は以下の宙畑の記事を参照してください

https://sorabatake.jp/364/

要するに、以下のように対応しています

- バンド1: 青色光
- バンド2: 緑色光
- バンド3: 赤色光
- バンド4: 近赤外線

このうち、 NDVI の算出には赤色光と近赤外線のデータを使用します

では、これらのデータを画像としてみてみましょう

```elixir
file_path_list
# テキストデータであるヘッダファイルは除く
|> Enum.filter(fn file_path -> Path.extname(file_path) != ".txt" end)
|> Enum.map(fn file_path ->
  file_path
  |> Evision.imread()
  # 大きすぎるのでリサイズ
  |> Evision.resize({640, 640})
end)
|> Kino.Layout.grid(columns: 2)
```

![スクリーンショット 2023-01-05 20.19.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d19b87de-18e7-d11f-5cd3-2c9a06fffeb0.png)

各画像は以下のものを示します

- 左上: ブラウズ画像(PNG)
- 右上: ブラウズ画像(TIFF)
- 左中: 青色光データ
- 右中: 緑色光データ
- 左下: 赤色光データ
- 右下: 近赤外線データ

## ヘッダーファイルの読込

この画像だけだと、どこのいつのデータなのか分からないため（実はTIFF画像に情報が埋め込まれていますが）、ヘッダーファイルの情報を読み込みます

```elixir
header_path = Enum.find(file_path_list, & Path.extname(&1) == ".txt")
```

```elixir
File.read!(header_path)
```

![スクリーンショット 2023-01-06 17.21.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1c135f6a-3810-5f05-4e55-147c444372ce.png)

テキストファイルとして開いてみても、固定長のデータなので何が何だかわかりません

ヘッダーファイルの仕様を先ほどのプロダクトフォーマット説明書から読み解きましょう

https://www.eorc.jaxa.jp/ALOS/alos-ori/doc/AVNIR-2_ORI_format_jp.pdf

仕様を元にデータを読み込むモジュールを作ります

```elixir
defmodule Coordinate do
  defstruct latitude: 0.0, longitude: 0.0
end

defmodule BandInfo do
  defstruct gain: 0.0, offset: 0.0
end

defmodule Affine do
  defstruct a: 0.0, b: 0.0, c: 0.0, d: 0.0
end

defmodule Conversion do
  defstruct x: 0.0, y: 0.0
end
```

```elixir
defmodule HeaderInfo do
  defstruct blue_band: %BandInfo{},
            green_band: %BandInfo{},
            red_band: %BandInfo{},
            nir_band: %BandInfo{},
            center: %Coordinate{},
            left_top: %Coordinate{},
            right_top: %Coordinate{},
            left_bottom: %Coordinate{},
            right_bottom: %Coordinate{},
            affine: %Affine{},
            conversion: %Conversion{},
            degree: 0.0,
            map_degree: 0.0,
            datetime: nil,
            product_id: ""

  def get_string(info, start, value) do
    info
    |> String.slice(start, value)
    |> String.trim()
  end

  def get_value(info, start, value) do
    info
    |> get_string(start, value)
    |> String.to_float()
  end

  def read(hdr_file_path) do
    info = File.read!(hdr_file_path)

    %HeaderInfo{
      # 青色光バンド
      blue_band: %BandInfo{
        gain: get_value(info, 1721, 8),
        offset: get_value(info, 1729, 8)
      },
      # 緑色光バンド
      green_band: %BandInfo{
        gain: get_value(info, 1737, 8),
        offset: get_value(info, 1745, 8)
      },
      # 赤色光バンド
      red_band: %BandInfo{
        gain: get_value(info, 1752, 8),
        offset: get_value(info, 1760, 8)
      },
      # 近赤外線バンド
      nir_band: %BandInfo{
        gain: get_value(info, 1768, 8),
        offset: get_value(info, 1776, 8)
      },
      # 画像中央
      center: %Coordinate{
        latitude: get_value(info, 248, 16),
        longitude: get_value(info, 264, 16)
      },
      # 画像左上
      left_top: %Coordinate{
        latitude: get_value(info, 376, 16),
        longitude: get_value(info, 392, 16)
      },
      # 画像右上
      right_top: %Coordinate{
        latitude: get_value(info, 408, 16),
        longitude: get_value(info, 424, 16)
      },
      # 画像左下
      left_bottom: %Coordinate{
        latitude: get_value(info, 440, 16),
        longitude: get_value(info, 456, 16)
      },
      # 画像右下
      right_bottom: %Coordinate{
        latitude: get_value(info, 472, 16),
        longitude: get_value(info, 488, 16)
      },
      affine: %Affine{
        a: get_value(info, 1224, 16),
        b: get_value(info, 1240, 16),
        c: get_value(info, 1256, 16),
        d: get_value(info, 1272, 16)
      },
      conversion: %Conversion{
        x: get_value(info, 1208, 8),
        y: get_value(info, 1216, 8)
      },
      degree: get_value(info, 760, 16),
      map_degree: get_value(info, 921, 16),
      datetime:
        info
        |> get_string(192, 24)
        |> then(fn str ->
          String.slice(str, 0, 4) <>
            "-" <>
            String.slice(str, 4, 2) <>
            "-" <>
            String.slice(str, 6, 2) <>
            "T" <>
            String.slice(str, 8, 2) <>
            ":" <>
            String.slice(str, 10, 2) <>
            ":" <> String.slice(str, 12, 2)
        end)
        |> NaiveDateTime.from_iso8601!(),
      product_id: get_string(info, 128, 16)
    }
  end
end
```

作成したモジュールでヘッダーファイルを読み込みます

```elixir
header_info = HeaderInfo.read(header_path)
```

結果は以下のようなデータになります

```elixir
%HeaderInfo{
  blue_band: %BandInfo{gain: 0.588, offset: 0.0},
  green_band: %BandInfo{gain: 0.573, offset: 0.0},
  red_band: %BandInfo{gain: 0.502, offset: 0.0},
  nir_band: %BandInfo{gain: 0.557, offset: 0.0},
  center: %Coordinate{latitude: 40.5403552, longitude: 140.7569056},
  left_top: %Coordinate{latitude: 40.9672806, longitude: 140.4512007},
  right_top: %Coordinate{latitude: 40.81202, longitude: 141.2864133},
  left_bottom: %Coordinate{latitude: 40.2662829, longitude: 140.231684},
  right_bottom: %Coordinate{latitude: 40.1125883, longitude: 141.0587402},
  affine: %Affine{a: -23.9378926, b: 97.0926223, c: 64502.5142872, d: 451205.2954717},
  conversion: %Conversion{x: 10.0, y: 10.0},
  degree: 13.849887,
  map_degree: 0.0027576,
  datetime: ~N[2006-10-31 01:34:11],
  product_id: "OORIRFU"
}
```

データの座標や観測日時が分かりますね

## 地図へのプロット

では取得したヘッダー情報を元に衛星データを地図上にプロットしてみましょう

まず赤色光データを画像として読み込みます

```elixir
red_img_path =
  file_path_list
  |> Enum.find(fn file_path ->
    file_path
    |> Path.basename()
    # 赤色光バンドの画像
    |> String.starts_with?("IMG-03")
  end)
```

```elixir
red_img = Evision.imread(red_img_path)

# 大きすぎるのでリサイズして表示
Evision.resize(red_img, {640, 640})
```

![スクリーンショット 2023-01-06 17.28.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c9ee74aa-d78c-f9f6-710d-12eed2189575.png)

赤色光のデータですが、 BGR (Evision = OpenCV では RGB ではなく BGR です) の全てに同じ値が入っているため、灰色になって表示されます

これを地図に表示するために BASE64 エンコードします

```elixir
# 地図にプロットするために BASE64 エンコードする
get_data_url = fn mat ->
  Evision.imencode(".png", mat)
  |> Base.encode64()
  |> then(&"data:image/png;base64,#{&1}")
end
```

中心座標を指定します

```elixir
center = {header_info.center.longitude, header_info.center.latitude}
```

四隅の座標を指定します

```elixir
coordinates =
  [
    [header_info.left_top.longitude, header_info.left_top.latitude],
    [header_info.right_top.longitude, header_info.right_top.latitude],
    [header_info.right_bottom.longitude, header_info.right_bottom.latitude],
    [header_info.left_bottom.longitude, header_info.left_bottom.latitude]
  ]
```

MapLibre を使って、地図上に画像を重ねて表示します

```elixir
img_url = 
  red_img
  |> Evision.resize({640, 640})
  |> get_data_url.()

MapLibre.new(center: center, zoom: 8, style: :terrain)
|> MapLibre.add_source("image", type: :image, url: img_url, coordinates: coordinates)
|> MapLibre.add_layer(id: "overlay", source: "image", type: :raster)
```

![red.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e94fa4a5-464e-c3ec-31fe-8199ae9a4353.gif)

表示された地図を見ると、灰色の画像（衛星データ）が斜めになって青森県の航空写真に重なっています

地図をダブルクリックしてズームしていくと、衛星データと航空写真の海岸線がピッタリ重なっています

ヘッダー情報の座標を指定したことで、ピッタリ位置も縮尺も合いました

## カラー画像の生成

NDVI とは関係ありませんが、せっかくなので青と緑も合わせてカラー画像を生成してみましょう

まず赤、緑、青のデータを並べてみます

```elixir
blue_img =
  file_path_list
  |> Enum.find(& &1 |> Path.basename() |> String.starts_with?("IMG-01"))
  |> Evision.imread()

green_img =
  file_path_list
  |> Enum.find(& &1 |> Path.basename() |> String.starts_with?("IMG-02"))
  |> Evision.imread()

[
  Evision.resize(red_img, {640, 640}),
  Evision.resize(green_img, {640, 640}),
  Evision.resize(blue_img, {640, 640})
]
|> Kino.Layout.grid(columns: 3)
```

![スクリーンショット 2023-01-06 17.43.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/98798104-ec6b-8b60-b529-431b79d2e8d0.png)

これらの画像を BGR の順に重ねればカラー画像になります

```elixir
bgr_img =
  [blue_img, green_img, red_img]
  |> Enum.map(fn img ->
    img
    |> Evision.Mat.to_nx(EXLA.Backend)
    # ３チャネル（同一値）になっているので、１チャネルだけ取り出す
    |> Nx.slice_along_axis(0, 1, axis: 2)
  end)
  # チャネル方向に結合
  |> Nx.concatenate(axis: 2)
  |> Evision.Mat.from_nx_2d()

Evision.resize(bgr_img, {640, 640})
```

![スクリーンショット 2023-01-06 17.47.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/17346ed8-4e5c-d5ee-12bb-193ba5657503.png)

山や町、雲、湖などが分かりますね

これも地図にプロットしてみましょう

今度はもっと細かく見てみたいので、画像サイズを大きめにします

```elixir
img_url = 
  bgr_img
  |> Evision.resize({4000, 4000})
  |> get_data_url.()

MapLibre.new(center: center, zoom: 8, style: :terrain)
|> MapLibre.add_source("image", type: :image, url: img_url, coordinates: coordinates)
|> MapLibre.add_layer(id: "overlay", source: "image", type: :raster)
```

![farm.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c4f04a4d-2279-caf5-f3fc-72a09825b1ad.gif)

ズームしていくと、航空写真では緑色になっている田んぼが、衛星データでは茶色になっていることが分かります

そう、ヘッダー情報にある通り、このデータは 2006-10-31 に観測したものです

10月末では既にお米は収穫済で、田んぼは土が露出しているのです

## NDVIの算出

いよいよ NDVI を算出します

NDVI の計算式は以下のとおりです

$ NDVI = \frac{NIR - Red}{NIR + Red} $

NIR = 近赤外線、 Red = 赤色光です

この計算式を画像内の各画素について実行します

まず、参考元記事で行なっていたように、ヘッダー情報のゲインをかけてオフセットを足します

```elixir
# 赤色光バンド
red_tensor =
  red_img
  |> Evision.Mat.to_nx(EXLA.Backend)
  # ゲインをかけてオフセットを足す
  |> Nx.multiply(header_info.red_band.gain)
  |> Nx.add(header_info.red_band.offset)
  |> Nx.slice_along_axis(0, 1, axis: 2)
```

```elixir
# 近赤外線バンド
nir_tensor =
  file_path_list
  |> Enum.find(& &1 |> Path.basename() |> String.starts_with?("IMG-04"))
  |> Evision.imread()
  |> Evision.Mat.to_nx(EXLA.Backend)
  # ゲインをかけてオフセットを足す
  |> Nx.multiply(header_info.nir_band.gain)
  |> Nx.add(header_info.nir_band.offset)
  |> Nx.slice_along_axis(0, 1, axis: 2)
```

そして、計算式に従って NDVI を算出します

この計算は行列演算なので、各画素（ピクセル）に対して実行していますが、衛星データの左右両端には真っ黒な部分（値が 0 になっている部分）があります

値が 0 のピクセルについてこの計算をすると分母が 0 になってしまうため、その場合は計算結果を 0 とするようにします

`Nx.select` は第1引数の条件式に従って、真の場合は第2引数、偽の場合は第3引数の値を各要素に返してくれます

```elixir
ndvi_tensor =
  Nx.select(
    # 0 除算をしないため、 NIR と Red の両方が 0 でないところだけ演算する
    Nx.multiply(
      Nx.not_equal(red_tensor, 0),
      Nx.not_equal(nir_tensor, 0)
    ),
    # NDVI の演算
    Nx.divide(
      Nx.subtract(nir_tensor, red_tensor),
      Nx.add(nir_tensor, red_tensor)
    ),
    0
  )
```

この NDVI の値は NIR と Red の値によって以下のように変化します

- NIR=255(最大),Red=0(最小) の場合: 1(最大)
- NIR=0(最小),Red=255(最大) の場合: -1(最小)

従って、 NDVI の各画素は -1 〜 1 の範囲に分布しています

これを画像として表示する場合、 -1 〜 1 を 0 〜 255 に置き換えないといけません

また、灰色だと視覚的に分かりづらいため、カラーマップ（画素値と色の対応表）を 0 のとき青、 255 のとき緑となる COLORMAP_WINTER に指定します

```elixir
ndvi_map =
  ndvi_tensor
  |> Nx.multiply(128)
  |> Nx.add(128)
  |> Nx.as_type(:u8)
  |> Evision.Mat.from_nx_2d()
  |> Evision.applyColorMap(Evision.Constant.cv_COLORMAP_WINTER())

Evision.resize(ndvi_map, {640, 640})
```

:::note info
厳密にいうと　NDVI = 1 のときに値が 255 を超えてしまうのですが、ここでは無視します
:::

![スクリーンショット 2023-01-06 18.13.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9169f4c0-df1c-bbc1-f83e-7650b87e1513.png)

十和田湖と陸奥湾の水面は真っ青になっています

また、山は緑で町は青いです

ではこれを地図に重ねてみます

```elixir
img_url =
  ndvi_map
  |> Evision.resize({640, 640})
  |> get_data_url.()

MapLibre.new(center: center, zoom: 8, style: :terrain)
|> MapLibre.add_source("image", type: :image, url: img_url, coordinates: coordinates)
|> MapLibre.add_layer(id: "overlay", source: "image", type: :raster)
```

![スクリーンショット 2023-01-06 18.15.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7b466889-d2a0-8135-11d1-c0e6e5a4d72e.png)

青森県の11月末の植生の様子が可視化できました

## 時系列で確認する

11月末のデータを見ただけでは、本当にこれで植生が見えているのか判断できません

植物の多さを表すのであれば、春夏に高く（緑色）、秋冬に低く（青色）になるはずです

というわけで、他のシーンについても NDVI を計算し、時系列で確認してみましょう

今までやってきた NDVI の計算をモジュール化します

```elixir
defmodule NDVI do
  def read_header(file_path_list) do
    file_path_list
    |> Enum.find(fn file -> Path.extname(file) == ".txt" end)
    |> HeaderInfo.read()
  end

  def get_band_tensor(file_path_list, prefix) do
    file_path_list
    |> Enum.find(fn file ->
      file
      |> Path.basename()
      |> String.starts_with?(prefix)
    end)
    |> Evision.imread(flags: Evision.Constant.cv_IMREAD_GRAYSCALE())
    |> Evision.Mat.to_nx(EXLA.Backend)
  end

  def calc(file_path_list) do
    header_info = read_header(file_path_list)

    red_tensor =
      file_path_list
      |> get_band_tensor("IMG-03")
      |> Nx.multiply(header_info.red_band.gain)
      |> Nx.add(header_info.red_band.offset)

    nir_tensor =
      file_path_list
      |> get_band_tensor("IMG-04")
      |> Nx.multiply(header_info.nir_band.gain)
      |> Nx.add(header_info.nir_band.offset)

    ndvi_tensor =
      Nx.select(
        Nx.multiply(
          Nx.not_equal(red_tensor, 0),
          Nx.not_equal(nir_tensor, 0)
        ),
        Nx.divide(
          Nx.subtract(nir_tensor, red_tensor),
          Nx.add(nir_tensor, red_tensor)
        ),
        0
      )

    {header_info, ndvi_tensor}
  end
end
```

シーン毎に NDVI の計算を実行します

```elixir
ndvi_list =
  scene_id_list
  |> Enum.map(fn scene_id ->
    "/tmp/#{scene_id}"
    |> File.ls!()
    |> Enum.map(fn filename -> Path.join(["/tmp", scene_id, filename]) end)
    |> NDVI.calc()
  end)
```

計算結果を画像として表示してみましょう

```elixir
ndvi_list
|> Enum.map(fn {header_info, ndvi_tensor} ->
  center = {header_info.center.longitude, header_info.center.latitude}

  coordinates =
    [
      [header_info.left_top.longitude, header_info.left_top.latitude],
      [header_info.right_top.longitude, header_info.right_top.latitude],
      [header_info.right_bottom.longitude, header_info.right_bottom.latitude],
      [header_info.left_bottom.longitude, header_info.left_bottom.latitude]
    ]

  img_url = 
    ndvi_tensor
    |> Nx.multiply(128)
    |> Nx.add(128)
    |> Nx.as_type(:u8)
    |> Evision.resize({640, 640})
    |> Evision.applyColorMap(Evision.Constant.cv_COLORMAP_WINTER())
    |> get_data_url.()

  map =
    MapLibre.new(center: center, zoom: 8, style: :terrain)
    |> MapLibre.add_source("image", type: :image, url: img_url, coordinates: coordinates)
    |> MapLibre.add_layer(id: "overlay", source: "image", type: :raster)

  # 観測日をタブにする
  date =
    header_info.datetime
    |> NaiveDateTime.to_iso8601()
    |> String.slice(0..9)

  {date, map}
end)
|> Kino.Layout.tabs()
```

![ndvi.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/026fdbe9-b499-0df9-96bf-b90626b6efd4.gif)

タブ（観測日）を切り替えていくと、明らかに夏は緑で冬は青です

画像内の値全ての平均してグラフ化してみましょう

```elixir
plot_data =
  ndvi_list
  |> Enum.map(fn {header_info, ndvi_tensor} ->
    avg_ndvi =
      ndvi_tensor
      |> Nx.mean()
      |> Nx.to_number()

    %{
      datetime: header_info.datetime,
      ndvi: avg_ndvi
    }
  end)
```

```elixir
VegaLite.new(width: 700)
|> VegaLite.data_from_values(plot_data)
|> VegaLite.mark(:bar)
|> VegaLite.encode_field(:x, "datetime", type: :temporal)
|> VegaLite.encode_field(:y, "ndvi", type: :quantitative)
```

![graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/328fc767-e216-8a15-2738-23aa62317e3b.png)

こちらでも夏が高くて冬が低いことが分かりますね

## まとめ

衛星データから植生を可視化することができました

時系列の変化も非常に分かりやすいですね

次回は NDVI を町の形に切り取り、町内の植生を見てみます

https://qiita.com/RyoWakabayashi/items/d65dd3a508284e67c8cd