Rで画像をドット絵化する

はじめに

この記事はR言語 Advent Calendar 2024の19日目の記事です。

Rのパッケージimagerを用いて、アニメ絵をドット絵に変換してみました。ドット絵作りたいな~と思い立ちまして、せっかくなので好きなRで実装してみました。

ロジック

ドット絵化のロジックはこちらです。

  1. 画像を適当な幅でグリッドに切り、それぞれのグリッドについて、全ての画素のRGB値をグリッド内の画素のRGB値の平均値とする(平均プーリング)
  2. k-means法により、各グリッドのRGB値を、指定した色数でクラスタリングされた値に置き換える

1で色と色の境目をギザギザにして2で色数を減らすことでドット絵っぽさを出します。k-meansはこちらの記事(k-means法を用いて画像をドット絵風に変換する)にアイデアをもらいました。なお、2の前に1でも色数が減るため、2のk-meansの実行時間を抑えることができます。

グリッドの縦と横のピクセル数とk-meansの色数はハイパーパラメータとして与えます。これらの値次第でドット絵の味わいが変わってきます。

実装で使うimagerはC++のCImgをラップしたパッケージです。画像処理分野では他にもImageMagickのラッパーのmagickやOpenCVのラッパーのRvisionなどいくつかパッケージがあるようです。今回の内容は配列操作で完結するので何を使ってもいいと思います。

実装

アニメ「スローループ」の海凪小春ちゃんです1。この画像をドット絵にします。(©うちのまいこ・芳文社/スローループ製作委員会)

画像の読み込み

環境はR4.4.2, imager1.0.2です。

# install.packages(c("tidyverse", "imager"))
library(tidyverse)
library(imager)

まず、imager::load.image()で画像を読み込みます。

img <- imager::load.image("slowloop.jpg")
plot(img)

imager::load.image()で読み込まれた画像はcimgというS3クラスであり、その実体はwidth x height x depth(静止画なら1、動画ならフレーム数) x color channel(透過度(アルファチャンネル)がないカラー画像なら3)の4次元arrayです。

img
#> Image. Width: 1920 pix Height: 1080 pix Depth: 1 Colour channels: 3
str(img)
#>  'cimg' num [1:1920, 1:1080, 1, 1:3] 0.863 0.863 0.863 0.863 0.863 ...

1920px x 1080pxの画像であることが分かります。

実体はarrayなのでdimのような配列操作の関数が使えますし、as.array()するとarrayを得ることができます。

試しに左上の座標が(101, 101)、右下の座標が(105, 105)の長方形の領域を取り出してみます。R, G, Bの値が4次元で入っていることが分かります。(255で割って0-1にスケーリングされた値が入っています)

as.array(img)[101:105, 101:105, 1, , drop=FALSE]
#> , , 1, 1
#> 
#>           [,1]      [,2]      [,3]      [,4]      [,5]
#> [1,] 0.7333333 0.7215686 0.7137255 0.7058824 0.6980392
#> [2,] 0.7333333 0.7176471 0.7098039 0.7019608 0.6980392
#> [3,] 0.7333333 0.7176471 0.7098039 0.7019608 0.6941176
#> [4,] 0.7294118 0.7137255 0.7098039 0.7019608 0.6941176
#> [5,] 0.7254902 0.7137255 0.7098039 0.7058824 0.6941176
#> 
#> , , 1, 2
#> 
#>           [,1]      [,2]      [,3]      [,4]      [,5]
#> [1,] 0.8352941 0.8313725 0.8235294 0.8196078 0.8117647
#> [2,] 0.8352941 0.8274510 0.8196078 0.8156863 0.8117647
#> [3,] 0.8352941 0.8274510 0.8196078 0.8156863 0.8078431
#> [4,] 0.8313725 0.8235294 0.8196078 0.8156863 0.8078431
#> [5,] 0.8235294 0.8235294 0.8196078 0.8196078 0.8078431
#> 
#> , , 1, 3
#> 
#>           [,1]      [,2]      [,3]      [,4]      [,5]
#> [1,] 0.9803922 0.9843137 0.9764706 0.9764706 0.9764706
#> [2,] 0.9803922 0.9803922 0.9764706 0.9725490 0.9764706
#> [3,] 0.9803922 0.9803922 0.9764706 0.9803922 0.9725490
#> [4,] 0.9764706 0.9764706 0.9764706 0.9803922 0.9803922
#> [5,] 0.9803922 0.9764706 0.9764706 0.9843137 0.9803922

平均プーリングによる減色

1920px x 1080pxの元の画像を、縦横15pxずつのグリッドに切って、各グリッドのRGB値を平均値に置き換えます。

# グリッドの縦と横の幅
row_px <- 15
col_px <- 15

width <- dim(img)[1]
height <- dim(img)[2]

# 空のarrayを用意しておく
# 1は静止画なので、3はカラー画像なので
img_average <- array(NA_real_, dim=c(width, height, 1, 3))
for (x0 in seq(1, width, by=col_px)) {
  for (y0 in seq(1, height, by=row_px)) {
    # x1 <- x0-1+col_pxだと元の画像がグリッドで割り切れないときにエラーになる
    x1 <- min(x0-1+col_px, width)
    y1 <- min(y0-1+row_px, height)

    img_block <- img[x0:x1, y0:y1, 1, 1:3, drop=FALSE]
    img_average[x0:x1, y0:y1, 1, 1] <- mean(img_block[, , 1, 1])
    img_average[x0:x1, y0:y1, 1, 2] <- mean(img_block[, , 1, 2])
    img_average[x0:x1, y0:y1, 1, 3] <- mean(img_block[, , 1, 3])
  }
}
img_average <- imager::as.cimg(img_average)

img_average <- as.cimg(img_average)の部分ですが、width x height x depth x color channelの4次元arrayをimager::as.cimg()するとcimgオブジェクトに変換できます。

k-means

cimgオブジェクトをas.data.frame()すると、x, yにおけるカラーチャンネルの画素値がdata.frameで得られます。

as.data.frame(img_average) |>
  head(5)
#>   x y cc     value
#> 1 1 1  1 0.8627451
#> 2 2 1  1 0.8627451
#> 3 3 1  1 0.8627451
#> 4 4 1  1 0.8627451
#> 5 5 1  1 0.8627451

このdata.frameの状態でstats::kmeansを実行します。ただしさきほど平均プーリングをかけたので、高速化のために画素値が異なっている行のみを残してからk-meansを実行します。

使える色は16色にしてみます。

# k-meansのパラメータ
k <- 16
nstart <- 1234

# 扱いやすくするためにdata.frameに変換する
img_average_df <- as.data.frame(img_average) |>
  # pivot_widerで横持ちにすると`1`, `2`, `3`という列ができるので
  # 先頭にccを付けておくことでselectなどするときにバッククオートで囲わなくてすむ
  mutate(cc=str_c("cc", cc)) |>
  pivot_wider(names_from=cc, values_from=value)

# ユニークなピクセルだけでk-meansを実行することで高速化する
# ユニークなピクセルを識別するidを付けておく
img_unique_df <- img_average_df |>
  distinct(cc1, cc2, cc3, .keep_all=TRUE) |>
  select(cc1, cc2, cc3) |>
  mutate(id=row_number())
img_average_df <- img_average_df |>
  left_join(img_unique_df, by=c("cc1", "cc2", "cc3"))

# k-meansを実行する
res_kmeans <- kmeans(
  img_unique_df |>
    select("cc1", "cc2", "cc3"),
  centers=k, nstart=nstart, iter.max=1000, algorithm="MacQueen"
)
img_kmeans_df <- data.frame(
  res_kmeans$centers[res_kmeans$cluster, ]
) |>
  as_tibble() |>
  set_names(c("cc1_k", "cc2_k", "cc3_k")) |>
  mutate(id=row_number())

# 元の平均プーリングした画像のそれぞれのピクセルのRGB値にk-meansしたあとのRGB値をつける
res <- left_join(img_average_df, img_kmeans_df, by="id") |>
  select(x, y, cc1_k, cc2_k, cc3_k) |>
  pivot_longer(c(cc1_k, cc2_k, cc3_k), names_to="cc") |>
  mutate(cc=case_when(
    cc == "cc1_k" ~ 1L,
    cc == "cc2_k" ~ 2L,
    cc == "cc3_k" ~ 3L
  ))

# x, y, cc, valueを列に持つdata.frameを`imager::as.cimg()`すると`cimg`オブジェクトに変換できる
res <- imager::as.cimg(res, dim=c(width, height, 1, 3))

imager::save.image(res, "output.jpg")

結果

結果です。だいたい20秒くらいで作れました。

ドット絵感が出てますね!

今の画像は16色ですが、8色に絞ってみます。

ドット絵感はより強くなりましたが、使える色数が減ったのでほっぺたや手の指の境目など一部色が消えていますね。

もっとグリッドを小さくしたり色数を増やしたりすると絵はきれいになりますがドット絵感が薄れます。グリッドの幅と高さや色数の最適な値は元の画像によって異なります。書き込みが細かい画像であればグリッドを小さくしたり色数を増やしたりする方が上手に作れます。

おわりに

シンプルなアルゴリズムですが、いい感じのドット絵を作ることができました。Rで画像処理も楽しいですね。

関連リンク


  1. アニメの公式サイトを貼りたかったのですが、KADOKAWAが不正アクセスを受けた日以来サイトが閉まっていていまだにアクセスできなくて悲しい ↩︎