Elixir Image で図形・文字描画

## はじめに

Image モジュールを使って、 画像に図形や文字を描画します

https://github.com/elixir-image/image

今回も Livebook を使います

https://livebook.dev/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/vix/draw.livemd

## 実行環境

- Elixir: 1.14.2 OTP 24
- Livebook: 0.9.3

以下のリポジトリーの Docker コンテナ上で起動しています

https://github.com/RyoWakabayashi/elixir-learning

:::note info

Docker が使える環境であれば簡単に実行できます

https://docs.docker.com/engine/install/

Docker Desktop を無償利用できない場合は Rancher Desktop を使ってください

https://rancherdesktop.io/

:::

### 実行環境の注意点

Image (Vix) を使うためには libvpc が必要です

Ubuntu　の場合は `apt-get install libvpc-dev` を実行してください

## セットアップ

必要なモジュールをインストールします

```elixir
Mix.install([
  {:image, "~> 0.35"},
  {:req, "~> 0.3"},
  {:kino, "~> 0.9"}
])
```

Req は画像を Web からダウンロードするのに使っています

https://github.com/wojtekmach/req

## 画像の生成

画像サイズ（幅、高さ）を指定して画像を生成できます

```elixir
Image.new!(100, 200)
|> Image.Kino.show()
```

![black.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/099c6ba8-dc80-4ec7-9d9f-5106101c858d.png)

また、色を指定することもできます

CSS で使われる RGB の16進数表示、もしくは RGB　の各値を持つ List で色を指定できます

```elixir
Image.new!(200, 200, color: "#FF0000")
|> Image.Kino.show()
```

![red.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/677dae05-b7ea-f7ee-ce4a-86f74d3b014a.png)


```elixir
Image.new!(200, 200, color: [0, 255, 0])
|> Image.Kino.show()
```

![green.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b0f58d30-1424-01aa-4313-f203cfc80511.png)

また、CSS 用の名称で指定することも可能です

http://memopad.bitter.jp/w3c/css3/css3_ref_colornames.html

```elixir
Image.new!(200, 200, color: :blue)
|> Image.Kino.show()
```

![blue.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/82092db4-e7b6-c4f2-53a5-01f403ada5ea.png)

色の名称一覧は `Image.Color.color_map()` で取得できます

実行結果は以下のような 140 項目の Map になります

```elixir
%{
  "white" => [hex: "#FFFFFF", rgb: [255, 255, 255]],
  "navy" => [hex: "#000080", rgb: [0, 0, 128]],
  "hotpink" => [hex: "#FF69B4", rgb: [255, 105, 180]],
  ...
}
```

図形描画の背景にするため、 100 x 100 の真っ黒な画像を用意しておきます

```elixir
img = Image.new!(100, 100)

Image.Kino.show(img)
```

![bg_black.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5587619f-a2ae-d0c8-dc75-8bfa869975a2.png)

## 図形描画

### 点

`Image.Draw.point` に X 座標、 Y 座標、色を指定して点を描画します

1 ピクセルでは見にくいので 5 倍にして表示します

```elixir
img
|> Image.Draw.point!(80, 30, color: :white)
|> Image.resize!(5)
|> Image.Kino.show(max_height: 500)
```

![point.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7dd732ae-2b28-0d89-5eda-cc0b228fa88a.png)

### 直線

`Image.Draw.line` に始点と終点の XY 座標、色を指定して直線を描画します

現状、線の太さは指定できず、 1 ピクセルでしか描画できません

こちらも 1 ピクセルでは見にくいので 5 倍にして表示します

```elixir
img
|> Image.Draw.line!(10, 20, 30, 40, color: :white)
|> Image.resize!(5)
|> Image.Kino.show(max_height: 500)
```

![line.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/01b86d2c-7c2a-5f7d-f5e5-eec3c497266f.png)

### 四角形

`Image.Draw.rect` に左上の XY 座標、幅、高さ、色を指定することで四角形を描画します

```elixir
{left, top, width, height} = {10, 20, 30, 40}
```

```elixir
img
|> Image.Draw.rect!(left, top, width, height, color: :blue)
|> Image.Kino.show()
```

![rect_fill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c82ad73c-11f7-4f4f-b16c-238f8f0819d4.png)

`fill: false` を指定することで枠だけを描画できます

```elixir
img
|> Image.Draw.rect!(left, top, width, height, color: :green, fill: false)
|> Image.Kino.show()
```

![rect_not_fill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6a1b2fa8-4a45-9130-1349-81407cb28c13.png)

ただし、こちらも線の太さは 1 ピクセル固定です

### 塗りつぶし

`Image.Draw.flood` で指定した XY 座標を含み、指定した色に囲まれている範囲を塗りつぶします

枠の色と塗りつぶす色は同じなければなりません

```elixir
color = "#FF0000"

img
|> Image.Draw.rect!(left, top, width, height, color: color, fill: false)
|> Image.Draw.flood(left + 10, top + 10, color: color)
|> elem(1)
|> elem(0)
|> Image.Kino.show()
```

![flood.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/564cf286-e9a6-6d86-3e19-715e595ee4a5.png)

ちなみに flood 自体の戻り値は以下のようになっています

```elixir
{:ok,
 {%Vix.Vips.Image{ref: #Reference<0.243135144.3121479698.66185>},
  [left: 11, top: 21, width: 28, height: 38]}}
```

`{:ok, {<画像>, <塗りつぶした領域>}}` です

flood を応用することで太い線の四角形を描画することができます

```elixir
thickness = 8

img
# 外側の枠
|> Image.Draw.rect!(left, top, width, height, color: color, fill: false)
# 内側の枠
|> Image.Draw.rect!(
  left + thickness,
  top + thickness,
  width - thickness * 2,
  height - thickness * 2,
  color: color,
  fill: false
)
# 塗りつぶし
|> Image.Draw.flood(left + 1, top + 1, color: color)
|> elem(1)
|> elem(0)
|> Image.Kino.show()
```

![thick_rect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a114ed08-d1f7-8140-f335-2dbb06435f2b.png)

### 円

`Image.Draw.circle` に中心の XY 座標と半径、色を指定することで円を描画できます

```elixir
{center_x, center_y, radius} = {30, 40, 20}
```

```elixir
img
|> Image.Draw.circle!(center_x, center_y, radius, color: :blue)
|> Image.Kino.show()
```

![circle_fill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/586d0dc0-50c8-a077-98d7-12d0f8550abe.png)

`fill: false` で枠だけの円を描画できます

ただし、線の太さは 1 ピクセル固定です

```elixir
img
|> Image.Draw.circle!(center_x, center_y, radius, color: :green, fill: false)
|> Image.Kino.show()
```

![circle_not_fill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f84d9d91-3636-c0df-389f-fa788f6e44de.png)

円も塗りつぶしを使って太い枠にすることができます

```elixir
thickness = 4
color = :red

img
|> Image.Draw.circle!(center_x, center_y, radius, color: color, fill: false)
|> Image.Draw.circle!(
  center_x,
  center_y,
  radius - thickness,
  color: color,
  fill: false
)
|> Image.Draw.flood(center_x - radius + 1, center_y, color: color)
|> elem(1)
|> elem(0)
|> Image.Kino.show()
```

![thick_circle.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1747aec9-2f71-eae1-49ca-68a7192ff5ce.png)

### 多角形

`Image.Shape.polygon` で多角形を描画できます

第１引数に多角形の各点（XY座標）を List で指定します

`width` と `height` で指定したサイズに収まるよう、リサイズされます

指定しない場合は 500 x 500 になります

`opacity` 　は CSS と同じ不透明度で、 0 から 1 の範囲で指定します

デフォルトが 0.7 なので、指定しないとうっすら透けます

`fill_color` で内側の色、 `stroke_color` で枠線の色を指定します

`stroke_width` で枠線の太さが指定可能です

```elixir
Image.Shape.polygon!(
  [
    [100, 20],
    [20, 180],
    [180, 180],
  ],
  width: 200,
  height: 100,
  opacity: 1.0,
  fill_color: :red,
  stroke_color: :blue,
  stroke_width: 8
)
|> Image.Kino.show()
```

![polygon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/362f5164-7a0d-8635-ecb7-4e723abb6fc0.png)

また、第1引数に頂点の数、 `radius` に半径、 `rotation` に角度を指定することで正多角形を描画することもできます

```elixir
Image.Shape.polygon!(
  5,
  fill_color: :green,
  opacity: 0.5,
  radius: 100,
  rotation: 90
)
|> Image.Kino.show()
```

![pentagon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5a962682-abc7-2882-5939-84120dd664b0.png)

`Image.Shape.star` で星型も生成できます

第1引数は尖った点の数です

```elixir
star_img = Image.Shape.star!(5, width: 100, opacity: 1.0, fill_color: :yellow)

Image.Kino.show(star_img)
```

![star.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d34676b3-f00a-70a9-e602-2a2bc182505b.png)

内側の半径、外側の半径を指定して、もっとトゲトゲにすることも可能です

```elixir
Image.Shape.star!(10, inner_radius: 20, outer_radius: 180, opacity: 1.0, fill_color: :yellow)
|> Image.Kino.show()
```

![star_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ad8e8e98-af3b-5445-cc9c-9bbadf944532.png)

### 文字描画

`Image.Text.text` で文字を描画できます

デフォルトで文字色が白、背景色はなし（透明）のため、 Livebook だと何も見えなくなります

`background_fill_color` で背景色を指定すれば文字が見えます

```elixir
Image.Text.text!("Hello, Livebook!", background_fill_color: :black)
|> Image.Kino.show()
```

![string.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/96d41a09-9385-50d4-56e1-46e6d047793e.png)

`Image.Text.text` にはかなり多くのオプションがあります

[詳しくは公式ドキュメントを参照してください](https://hexdocs.pm/image/0.23.2/Image.Text.html#text/2)

```elixir
text_image =
  Image.Text.text!(
    "Hello, Livebook!",
    font_weight: 600,
    text_fill_color: :yellow,
    text_stroke_color: :black,
    text_stroke_width: 4,
    background_fill_color: :green,
    background_fill_opacity: 1.0,
    background_stroke_color: :blue,
    background_stroke_width: 4,
    background_stroke_opacity: 1.0,
    padding: 8,
    font_size: 54
  )

Image.Kino.show(text_image)
```

![text.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6d795435-88fd-ee8b-c18c-58072921c4ba.png)

## 画像との合成

多角形や文字の画像だけあっても仕方ないので、他の画像と合成します

まずいつものレナさんをダウンロードしてきます

```elixir
lenna_img =
  "https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png"
  |> Req.get!()
  |> then(& &1.body)
  |> Image.from_binary()
  |> elem(1)

Image.Kino.show(lenna_img, max_height: 400)
```

![lenna](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0b1aae1e-196f-a3db-bc57-6ebcc41ffc84.png)

ここで注意しないといけないのが、画像のチャネル数（バンド数）です

`Image.shape(lenna_img)` を実行すると、以下のようになります

```elixir
{512, 512, 3}
```

つまりレナさんは RGB で、 RGBA になっていません

アルファ（不透明度）がないため、このままだと他の RGBA の画像と合成できません（現状の Image の実装では）

そのため、アルファを追加します

`Image.new` に画像を与えると、同じサイズの画像が生成できます

また、 `bands` でチャネル数（バンド数） を指定することで 1　チャネルのグレースケール画像になります

`color` に `Image.Color.max_opacity` = 255 を指定して、全て不透明なアルファにします

これを Livebook 上で表示すると真っ白なので何も見えません

```elixir
alpha = Image.new!(lenna_img, bands: 1, color: Image.Color.max_opacity())

Image.Kino.show(alpha)
```

![alpha.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9c669985-ff4a-ba5d-3d34-de9bd62dad6c.png)

Image モジュールで内部的に使っている Vix の力を借りて RGB + A を実行します

https://github.com/akash-akya/vix

```elixir
rgba_lenna_img = Vix.Vips.Operation.bandjoin!([lenna_img, alpha])

Image.Kino.show(rgba_lenna_img, max_height: 400)
```

![lenna](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0b1aae1e-196f-a3db-bc57-6ebcc41ffc84.png)

不透明度 = 1 なので、見た目は何も変わりません

改めて `Image.shape(lenna_img)` を実行すると、以下のようになります

```elixir
{512, 512, 4}
```

単純にアルファチャネルだけ増やすことができました

`Image.compose` で画像を合成します `x` と `y` で左上座標を指定できます

また、 `Image.Draw.image` で画像を貼り付けることもできます

```elixir
rgba_lenna_img
|> Image.compose!(star_img, x: 30, y: 100)
|> Image.Draw.image(text_image, 10, 10)
|> elem(1)
|> Image.Kino.show(max_height: 400)
```

![hello_lenna.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/61ba2ade-0821-553a-c8b5-65319042fdd2.png)

おまけ

```elixir
star_img = Image.Shape.star!(10, inner_radius: 20, outer_radius: 180, width: 100, opacity: 1.0, fill_color: :yellow)
rect_img = Image.Shape.polygon!(4, width: 180, height: 200, opacity: 1.0, stroke_color: :red, stroke_width: 32, rotation: 45)
text_image = Image.Text.text!("Hello, Livebook!", text_fill_color: :blue, font_size: 54)

rgba_lenna_img
|> Image.compose!(star_img, x: 30, y: 100)
|> Image.compose!(rect_img, x: 190, y: 200)
|> Image.compose!(text_image, x: 10, y: 10)
|> Image.Kino.show(max_height: 400)
```

![lenna_star.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/aa943789-7223-d148-ff8a-3e7f089735fe.png)

## まとめ

Image はまだ機能追加中なので、これからもっと色々できるようになりそうです

楽しみですね