NARITA YUSUKE

ウェブカメラの映像を Canvas でリアルタイム画像処理

ウェブカメラの画像データを受け取って、リアルタイムに Canvas に画像処理した結果を表示する処理を考えてみる。

ウェブカメラから受け取った映像をリアルタイムに画像処理して Canvas に描画してみよう。滑らかな映像にするために 30fps は確保したい。

30fps は1秒間に30フレームなので、1回の処理を1/30秒(= 33ミリ秒)以内に処理を終えなければならない。Canvas (CanvasRenderingContext2D) は Flash の ActionScript 3.0 ほど画像の扱いが得意じゃないから基本的にはすべてのピクセルを走査して処理する必要がある。だれがこんな設計にしたんだ。けっこう辛い。

たとえばウェブカメラから受け取る画像が 640 × 480 の場合は、1回のループ処理で 640 × 480 = 307,200ピクセル(!)を走査して処理することになる。 しかも、これを毎フレーム33ミリ秒以内に終える必要があるから、2回ループさせるのはとても無理だ。 1回のループでなんとかしたい。

WebGL でシェーダを使う方法もあるけどあれは難しいから今回は昔作った AS3 を移植してサクッと済まることにした。

今回作った処理

以下のいろんな処理を作ってみた。

デモ

さくっと試したい人はこちら (※ 記事にないものもある。そのうち説明を書くかも。)

共通の処理

まずは最初に1回だけ初期化的な処理を行う。 navigator.getUserMedia() でウェブカメラの映像を受け取るのだけど、いくらでも参考になる記事があるからここでは割愛。

※ コードは CoffeeScript です。適当に JavaScript に読み替えてください。

WIDTH  = 640
HEIGHT = 480

# ウェブカメラの映像を一旦受け取る Canvas を生成する
sourceCanvas = document.createElement 'canvas'
sourceCanvas.width = WIDTH
sourceCanvas.height = HEIGHT
sourceContext = sourceCanvas.getContext '2d'

# 描画する Canvas を取得する
canvas = document.getElementById 'result-canvas'
context = canvas.getContext '2d'

次の処理を requestAnimationFrame を使って毎フレーム行う。(video っていう変数がウェブカメラの映像を取得するための video 要素。これについては特に説明はしないけど適当に察して欲しい。)

今回はウェブカメラの映像を処理する前提だから video としてるけど Image でももちろん OK。

# 映像を CanvasRenderingContext2D に描画
sourceContext.drawImage video, 0, 0

# 画像データを取得する
sourceImageData = sourceContext.getImageData 0, 0, WIDTH, HEIGHT
sourceData      = sourceImageData.data

# ここでそれぞれの画像処理

2値化(threshold)

2値化とは画像を黒(0)と白(255)の2階調で表示する方法。

まずは一番単純な方法で、任意の閾値(threshold)より暗ければ黒、明るければ白に置き換えて表示する。

THRESHOLD = 0x88 # 2値化のための閾値。とりあえず 0x88。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    # RGB を取り出す
    r = sourcePixels[index + 0]
    g = sourcePixels[index + 1]
    b = sourcePixels[index + 2]

    # RGB 各色の明るさの平均を計算する
    v = r * 0.298912 + g * 0.586611 + b * 0.114478
    if v > THRESHOLD
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 255
    else
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 0

context.putImageData sourceImageData, 0, 0

明るさの平均を計算する処理は v = (r + g + b) / 3 と単純に RGB の各色の明るさの平均値を取ることもできるけど、人間の目は緑色の明るさには敏感で、青色の明るさには鈍感だからこの方法だと少し違和感を感じる(らしい)。それを解消するために各色のバランスを取るために、

v = r * 0.298912 + g * 0.586611 + b * 0.114478

というような計算をしている。これを「NTSC系加重平均法」って呼ぶらしいけど、この数値を覚えるくらいなら他のことを覚えよう。毎回コピペすればいい。

2値化(誤差拡散法)

threshold を使った2値化では単純に単純に白と黒に分けているので映像のディテールが失われてなんだかよく分からない画像になってしまう。これを解決するための方法がいくつかある。誤差拡散法はそのひとつ。どんなアルゴリズムかは難しいから このページ を見てもらうとして、コードにするとこんな感じ。

for y in [ 0 ... HEIGHT ]
  total = 0
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    v = r * 0.298912 + g * 0.586611 + b * 0.114478
    total += v
    if total > 255
      total = total - 255
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 255
    else
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 0

context.putImageData sourceImageData, 0, 0

この方法で2階調とは思えないディテールを表現することができる。

2値化(ランダムディザ)

ランダムディザは、明るさを確率として計算する方法。 RGB 各色の平均値を取るところは同じで、ここで得た 0 から 255 までの256階調を使って白にするか黒にするか確立計算を行う。

たとえば明るさが 128 の場合は 128 / 255 で 50% なので、50% の確立で白、50%の確立で白になるようにする。 明るさが 64 の場合は、64 / 255 で 約25% の確立で白になる。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    v = r * 0.298912 + g * 0.586611 + b * 0.114478
    if v < Math.random() * 256
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 0
    else
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 255

context.putImageData sourceImageData, 0, 0

2値化(ベイヤーディザ)

ベイヤーディザも白黒の2色で擬似的に階調を表現するアルゴリズム。RGB の256段階の明るさを16段階に置き換えて、ディザマトリックスっていう次のような表を入力画像に重ね合わせて2値化を行う。

0 8 2 10
12 4 14 6
3 11 1 9
15 7 13 5

コードにするとこんな感じ↓

matrix = [
   0,  8,  2, 10
  12,  4, 14,  6
   3, 11,  1,  9
  15,  7, 13,  5
]

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    threshold = matrix[(x % 4) + (y % 4) * 4]

    r = sourceData[index + 0] / 16
    g = sourceData[index + 1] / 16
    b = sourceData[index + 2] / 16

    v = r * 0.298912 + g * 0.586611 + b * 0.114478
    if v < threshold
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 0
    else
      sourceData[index + 0] = sourceData[index + 1] = sourceData[index + 2] = 255

context.putImageData sourceImageData, 0, 0

今回紹介している2値化のアルゴリズムのなかでは一番自然に階調が表現されている気がする。ぱっと見、白と黒の2色とは思えない。

こういう方法を組織的ディザ法と呼ぶ。ベイヤーディザ(ベイヤー型)の他に渦巻き型と網点型というディザマトリックスが知られている(らしい。知らなかった。)。

渦巻き型↓

matrix = [
   6,  7,  8,  9
   5,  0,  1, 10
   4,  3,  2, 11
  15, 14, 13, 12
]

網点型↓

matrix = [
  11,  4,  6,  9
  12,  0,  2, 14
   7,  8, 10,  5
   3, 15, 13,  1
]

拡散

色を拡散してすりガラス越しに見たような効果をつけてみる。これは単純に各ピクセルをランダムに任意の範囲に拡散するだけ。

radius = 5

imageData = context.getImageData 0, 0, WIDTH, HEIGHT
pixels    = imageData.data

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4
    break if index + 3 > sourceData.length

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]
    a = sourceData[index + 3]

    x2 = ~~(x + Math.random() * radius * 2 - radius)
    y2 = ~~(y + Math.random() * radius * 2 - radius)
    index2 = (x2 + y2 * WIDTH) * 4
    continue if index2 + 3 > sourceData.length
    pixels[index2 + 0] = r
    pixels[index2 + 1] = g
    pixels[index2 + 2] = b
    pixels[index2 + 3] = a

context.putImageData imageData, 0, 0

単純なことをしているのに他と比べるととっても重い。これだけはなぜか今回の 30fps という水準が満たせなかった。理由は、謎だ。

色を置き換え

R を B に、G を B に、B を R に置き換えてみる。各ピクセルの明るさの平均値は変わらないのでディテールを保ったまま色を変化させることができる。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    sourceData[index + 0] = b
    sourceData[index + 1] = r
    sourceData[index + 2] = g

context.putImageData sourceImageData, 0, 0

ネガ

写真のネガのように明暗を反転させるてみる。255 から明るさを引いて入れ直すだけ。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    sourceData[index + 0] = 255 - r
    sourceData[index + 1] = 255 - g
    sourceData[index + 2] = 255 - b

context.putImageData sourceImageData, 0, 0

白髪は黒髪に、黒髪は白髪になる。ただし肌の色も壊れるので白髪の人を若返らせることはできない。残念。

モザイク

モザイクの処理は、本来ならブロック内の色の平均を計算してモザイクを形成するんだけど、今回は全ピクセルを1回だけ走査するルールなので、単純に左上のピクセルの色を使って各ブロックを塗りつぶす。

コードにするとこうなる↓

SIZE = 10

for x in [ 0 ... WIDTH ] by SIZE
  for y in [ 0 ... HEIGHT ] by SIZE

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    for x2 in [ 0 ... SIZE ]
      for y2 in [ 0 ... SIZE ]
        i = (WIDTH * (y + y2) * 4) + ((x + x2) * 4)
        sourceData[i + 0] = r
        sourceData[i + 1] = g
        sourceData[i + 2] = b

context.putImageData sourceImageData, 0, 0

疑似カラー

明るさを色相に置き換えるとヒートマップっぽい効果が生まれる。

今回は色相の 0(赤) から 240(青) までを使う。360 まで使うと一周回って最暗部が赤になってしまう。240 までに制限することで「赤(明るい)→青(暗い)」となって、なんかヒートマップっぽく見えるのだ。

コードはこうだ。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    # 明るさを色相に変換する
    brightness = (r + g + b) / 3
    h = 240 - 240 / 255 * brightness

    s = 1
    v = 1

    # HSV を RGB に変換する
    u = v * 255
    i = Math.floor(h / 60) % 6
    f = (h / 60) - i
    p = u * (1 - s)
    q = u * (1 - f * s)
    t = u * (1 - (1 - f) * s)
    switch i
      when 0
        [ r, g, b ] = [ u, t, p ]
      when 1
        [ r, g, b ] = [ q, u, p ]
      when 2
        [ r, g, b ] = [ p, u, t ]
      when 3
        [ r, g, b ] = [ p, q, u ]
      when 4
        [ r, g, b ] = [ t, p, u ]
      when 5
        [ r, g, b ] = [ u, p, q ]

    sourceData[index + 0] = Math.round r
    sourceData[index + 1] = Math.round g
    sourceData[index + 2] = Math.round b

context.putImageData sourceImageData, 0, 0

なんだか少し長くなったけど、やっていることは単純で大切なのは、

h = 240 - 240 / 255 * brightness

ここだけ。そのあとの処理はあんまり気にしなくていい。(が、HSV → RGBの置き換えはいろいろ使えるので覚えておいて損はない。)

ポスタリゼーション

ポスタリゼーションは各色を任意の階調に落とす処理。

まず最初に1回だけ階調を落とした時の色の配列を作っておく。

step = 10
stepArray = (Math.round(255 / (step - 1) * i) for i in [ 0 ... step ])

step の値が減色後の階調なんだけど、RGB それぞれをこの階調に落とすから 10 を指定した場合は「10 × 3 = 最大30色」になる。

あとは各フレームで以下の処理を行えばポスタリゼーションの完成ー。

for y in [ 0 ... HEIGHT ]
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    sourceData[index + 0] = stepArray[Math.floor r / (256 / step)]
    sourceData[index + 1] = stepArray[Math.floor g / (256 / step)]
    sourceData[index + 2] = stepArray[Math.floor b / (256 / step)]

context.putImageData sourceImageData, 0, 0

雰囲気モーションブラー

毎フレーム、すべてのピクセルを走査しないでy軸をランダムに20ずつ処理すると面白い効果が得られる。

言葉で説明するのはちょっと難しいけど、「動きのない部分は普通に描画されて、動きのある部分だけx軸方向にモーションブラーがかかったような感じ」だ。ぜんぜんモーションブラーじゃないから雰囲気モーションブラー。

本気でブラーをやろうとすると周囲のピクセルを計算する必要があるのでループの数が飛躍的に増える。ブラーは重いのだ。
imageData = context.getImageData 0, 0, WIDTH, HEIGHT
pixels    = imageData.data

for i in [ 0 ... 20 ]
  y = Math.floor Math.random() * HEIGHT
  for x in [ 0 ... WIDTH ]

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    pixels[index + 0] = r
    pixels[index + 1] = g
    pixels[index + 2] = b
    pixels[index + 3] = 0xFF

context.putImageData imageData, 0, 0

アスキーアート化

画像をアスキーアートにしてみよう。アルゴリズムはとても単純で軽い。いままでは処理後の画像を canvas に描画していたけど、ここでは文字を直接 HTML に書き出す。

最初にHTML にアスキー文字列を表示するエリアを作っておく。

<div class="letter"></div>

ここのフォントは等幅(font-family: monospace)にしておく。margin, padding は 0。

次に表示される1文字の大きさを計算しよう。次のコードを使った。この処理は最初の1回だけ行えばいい。

$('.letters').text '.'
charWidth = Math.floor $('.letters').width()
charHeight = Math.floor $('.letters').height()

利用する ASCII 文字も用意しておく。このとき「密度の高い文字 → 密度の低い文字」になるように並べておくのが大切。

seeds = "@GC*o.."

あとは各フレームで RGB の明るさに応じて seeds から文字を取ってきて HTML にいれてやるだけ。

chars = []

for y in [ 0 ... HEIGHT ] by @charHeight
  for x in [ 0 ... WIDTH ] by @charWidth

    index = (x + y * WIDTH) * 4

    r = sourceData[index + 0]
    g = sourceData[index + 1]
    b = sourceData[index + 2]

    v = r * 0.298912 + g * 0.586611 + b * 0.114478

    chars.push seeds[Math.floor(v / 255 * seeds.length)]
  chars.push('
') $('.letters').html chars.join('')

これだけでアスキーアートっぽいものを作ることができた。

おしまい

今回は紹介してないけどほかにもセピア調にしたり、幕末写真っぽくしたり、マンガっぽくしたり、ループ1回でもこれらの応用でいろいろ考えられそう。

今回作ったデモ

謝辞

写真使わせてもらいました! → 【私たち、無料です。】フリー素材アイドル MIKA☆RIKA