2017/10/02

Canvasを用いた9つの画像処理フィルターとそのアルゴリズムの解説

頭の体操のためにCanvasを用いたフィルタリングを学びはじめた。
当記事では、画像フィルターの中でも(独断と偏見で選んだ)代表的な9つの画像処理アルゴリズムを解説する。

  1. グレースケール
  2. 色調反転(ネガポジ変換)
  3. 二値化
  4. ガンマ補正
  5. ブラー(ぼかし)
  6. シャープ化(輪郭強調)
  7. メディアンフィルタ
  8. エンボス
  9. モザイク

たたみ込み演算を知らなくても、理解していただけると思う。

Canvasを用いた画像処理の基礎


当記事では、HTML5で導入されたCanvas要素を用いて画像処理(フィルタリング)を行う。

まずはCanvasに画像(フィルタをかける対象)を表示する。
// Canvas要素の作成
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

// 描画するための2Dコンテキスト
const ctx = canvas.getContext('2d');

// 画像ファイルの読み込み
const img = new Image();
img.src = '画像ファイル';
img.onload = () => {
  canvas.width = img.width;
  canvas.height = img.height;

  // Canvasに画像を描画する
  ctx.drawImage(img, 0, 0);
};


次に、フィルタをかけるためにコンテキストから画像情報を取得し、画像処理、書き戻しを行う。
// 略
img.onload = () => {
  canvas.width = img.width;
  canvas.height = img.height;
  ctx.drawImage(img, 0, 0);

  // 画像情報の取得(offsetX, offsetY, 幅、高さ)
  const imageData = ctx.getImageData(0, 0, canvas.clientWidth, canvas.clientHeight);
  
  // imageData.dataが1pxごとのRGBAが含まれる
  let data = imageData.data;

  // ここでimageData.dataに対して画像処理を行う

  // 画像情報からCanvasに書き戻す
  ctx.putImageData(imageData, 0, 0);
};

ctx.getImageDataで取得したimageData.dataには、1pxごとのRGBAが含まれている。
具体的には、[r, g, b, a, r, g, b, a, ..., r, g, b, a]のような1次元の配列になっている。たとえば[255, 0, 0, 1, 0, 255, 0, 1, 0, 0, 255, 1]という配列だった場合は、1px目が赤(255,0,0,1)、2px目が緑(0,255,0,1)、3px目が青(0,0,255,1)といった具合だ。

このRGBAの配列を書き換えてCanvasに書き戻すことで、画像にフィルターをかけることができる。


以降より具体的なフィルターのアルゴリズムについて解説する。


グレースケール

グレースケールは、色を白黒であらわすフィルターで、明るければ白、暗ければ黒に変換すること。
有色を白黒で表現するには、RGBを同一の値(例: RGB(123,123,123))にすればよい

さきほどの123という値を決めるのは、本来なら「ある比率」でかければ良いのだが、今回はシンプルにするためにRGBの平均値((R + G + B) / 3)として扱う。
for (let i = 0; i < data.length; i += 4) {
  // (r+g+b)/3
  const color = (data[i] + data[i+1] + data[i+2]) / 3;
  data[i] = data[i+1] = data[i+2] = color;
}
1つ目のアルゴリズムということで、ちょっとだけ詳しく解説する。
まずdatactx.getImageDataで取得したimageData.data

前述のとおり、このdataは[r,g,b,a,r,g,b,a,...r,g,b,a]という配列になっている。
rgbaの4つの要素で1pxを表現しているため、ループでは4ずつ(1px分)ずつインクリメントしている。

ループの中ではR: data[i], G: data[i+1], B: data[i+2]のように各要素にアクセスしている。A(alpha)は使わないので無視。

「1pxずつループしRGBの平均値を算出し書き戻す」という処理を全ピクセルに対して実施して、グレースケールのフィルターを実現している。



色調反転(ネガポジ変換)

色調反転は、ネガポジ変換とかネガティブフィルターと言われる、いわゆる写真のネガフィルムみたいに変換すること。(写真のネガは世代によってはわからないかもだけど…)

RGBの値を反転させればよいので、RGB(255-r, 255-g, 255-b)のような式を使う。
for (let i = 0; i < data.length; i += 4) {
  // 255-(r|g|b)
  data[i]   = 255 - data[i]  ;
  data[i+1] = 255 - data[i+1];
  data[i+2] = 255 - data[i+2];
}

最大値が255なので、ある値から-255すれば色が反転できる(例: 255を反転させると0、なので255-255、0を反転させると255なので255-0)



二値化

二値化とは、あらかじめ閾値(threshold)を決めておき、それを超えたら白、下回ったら黒と2色に変換すること。

本来なら閾値は変更できるのだが、今回は255/2(127.5)を閾値にする。
なのでRGBの平均値が127.5を超えたら255、下回ったら0に変換する。
const threshold = 255 / 2;

const getColor = (data, i) => {
  // threshold < rgbの平均
  const avg = (data[i] + data[i+1] + data[i+2]) / 3;
  if (threshold < avg) {
    // white
    return 255;
  } else {
    // black
    return 0;
  }
};

for (let i = 0; i < data.length; i += 4) {
  const color = getColor(data, i);
  data[i] = data[i+1] = data[i+2] = color;
}




ガンマ補正

ガンマ補正は、ガンマ値のカーブに従って色を補正すること。

ガンマ値のカーブは255 * (color / 255)^1/γで表される。
そのままの色で出力する場合は、直線の関係になる。よってガンマ値は1(γ=1)となる。

画像を明るくしたい場合は、ガンマ値を1より大きな値にする。
画像を暗くしたい場合は、ガンマ値を1より小さな値にする。
// ガンマ値=2.0
const gamma = 2.0;
// 補正式
const correctify = val => 255 * Math.pow(val / 255, 1 / gamma);

for (let i = 0; i < data.length; i += 4) {
  data[i]   = correctify(data[i]);
  data[i+1] = correctify(data[i+1]);
  data[i+2] = correctify(data[i+2]);
}



ブラー(ぼかし)

ブラーは、いわゆるボカシ。対象ピクセルの周辺の色の平均をとることでブラーフィルターがかけられる。

詳しく説明するには、画像処理でよくみる行列が登場する。

{
  1, 1, 1,
  1, 1, 1,
  1, 1, 1
}

//もしくは
{
  0, 1, 0,
  1, 1, 1,
  0, 1, 1,
}

3x3のブラーの場合、真ん中のピクセル(1,1)の値を基準にして、周辺のピクセルの色の平均をとる。
今回は前者の9つのピクセルにx1をしている。
後者のフィルターを使う場合は、基準から左上、右上、左下、右下のピクセルにx0して計算する。要は、四隅の色の値は無視するということ。
const _data = data.slice();
const avgColor = (color, i) => {
  const prevLine = i - (this.canvasWidth * 4);
  const nextLine = i + (this.canvasWidth * 4);

  const sumPrevLineColor = _data[prevLine-4+color] + _data[prevLine+color] + _data[prevLine+4+color];
  const sumCurrLineColor = _data[i       -4+color] + _data[i       +color] + _data[i       +4+color];
  const sumNextLineColor = _data[nextLine-4+color] + _data[nextLine+color] + _data[nextLine+4+color];

  return (sumPrevLineColor + sumCurrLineColor + sumNextLineColor) / 9
};

// 2行目〜n-1行目
for (let i = this.canvasWidth * 4; i < data.length - (this.canvasWidth * 4); i += 4) {
  // 2列目〜n-1列目
  if (i % (this.canvasWidth * 4) === 0 || i % ((this.canvasWidth * 4) + 300) === 0) {
    // nop
  } else {
    data[i]   = avgColor(0, i);
    data[i+1] = avgColor(1, i);
    data[i+2] = avgColor(2, i);
  }
}

計算用にdataを退避するために、data.slice()をしている。
ループは、2行目2列目〜最終行-1行目最終列-1列目までしている。理由は1行目1列目を基準にしたときに左上、上、右上、左、左下の色情報が取得できないからだ。


シャープ化(輪郭強調)

シャープ化(輪郭強調)は、画像の輪郭部分を検出して強調処理すること。
それにより、ボケやブレを解消できる。

シャープ化するには、以下のような係数をかけたあとにすべての合計を2で割った値を利用する。
{
  -1, -1, -1,
  -1, 10, -1,
  -1, -1, -1
}
const _data = data.slice();
const sharpedColor = (color, i) => {
  // 係数
  const sub = -1;
  const main = 10;

  const prevLine = i - (this.canvasWidth * 4);
  const nextLine = i + (this.canvasWidth * 4);

  const sumPrevLineColor = (_data[prevLine-4+color] * sub)  +  (_data[prevLine+color] * sub )  +  (_data[prevLine+4+color] * sub);
  const sumCurrLineColor = (_data[i       -4+color] * sub)  +  (_data[i       +color] * main)  +  (_data[i       +4+color] * sub);
  const sumNextLineColor = (_data[nextLine-4+color] * sub)  +  (_data[nextLine+color] * sub )  +  (_data[nextLine+4+color] * sub);

  return (sumPrevLineColor + sumCurrLineColor + sumNextLineColor) / 2
};

// 2行目〜n-1行目
for (let i = this.canvasWidth * 4; i < data.length - (this.canvasWidth * 4); i += 4) {
  // 2列目〜n-1列目
  if (i % (this.canvasWidth * 4) === 0 || i % ((this.canvasWidth * 4) + 300) === 0) {
    // nop
  } else {
    data[i]   = sharpedColor(0, i);
    data[i+1] = sharpedColor(1, i);
    data[i+2] = sharpedColor(2, i);
  }
}




メディアンフィルタ

メディアンフィルタは、ブラーフィルターに似ている。ブラーフィルターの場合は周辺の色の平均を使ったが、メディアンフィルタはその名の通り中央値を使う。

ブラーフィルタとメディアンフィルタの違いは、画像のエッジ部分をそのまま残してノイズが除去できるところだ。そのため、何らかの画像処理をするための前処理として使われることが多い。
const _data = data.slice();
const getMedian = (color, i) => {
  // 3x3の中央値を取得
  const prevLine = i - (this.canvasWidth * 4);
  const nextLine = i + (this.canvasWidth * 4);

  const colors = [
    _data[prevLine-4+color], _data[prevLine+color], _data[prevLine+4+color],
    _data[i       -4+color], _data[i       +color], _data[i       +4+color],
    _data[nextLine-4+color], _data[nextLine+color], _data[nextLine+4+color],
  ];

  colors.sort((a, b) => a - b);
  return colors[Math.floor(colors.length / 2)];
};

// 2行目〜n-1行目
for (let i = this.canvasWidth * 4; i < data.length - (this.canvasWidth * 4); i += 4) {
  // 2列目〜n-1列目
  if (i % (this.canvasWidth * 4) === 0 || i % ((this.canvasWidth * 4) + 300) === 0) {
    // nop
  } else {
    data[i]   = getMedian(0, i);
    data[i+1] = getMedian(1, i);
    data[i+2] = getMedian(2, i);
  }
}

return data;



エンボス

エンボスは、輪郭を強調し浮き上がったように変換すること。
エンボス加工するには、以下のような斜めに特徴のある係数をかけ、その値に255/2を足す必要がある。
{
   -1, 0, 0,
   0, 1, 0,
   0, 0, 0,
}
const _data = data.slice();
const embossColor = (color, i) => {
  const prevLine = i - (this.canvasWidth * 4);
  return ((_data[prevLine-4+color] * -1) + _data[i+color]) + (255 / 2);
};

// 2行目〜n-1行目
for (let i = this.canvasWidth * 4; i < data.length - (this.canvasWidth * 4); i += 4) {
  // 2列目〜n-1列目
  if (i % (this.canvasWidth * 4) === 0 || i % ((this.canvasWidth * 4) + 300) === 0) {
    // nop
  } else {
    data[i]   = embossColor(0, i);
    data[i+1] = embossColor(1, i);
    data[i+2] = embossColor(2, i);
  }
}




モザイク

モザイクは、いままでの8つのフィルターと異なり、ブロック単位で色を塗っていく。
3x3の9つの色の平均値を取得するのはブラーフィルターと同様だ。しかし、その平均値で3x3のブロックをまるごと塗りつぶす

今回は3x3のブロック単位だが、5x5や7x7のようにブロックを大きくしていくことで画像がより見づらくなる。
const _data = data.slice();
const avgColor = (i, j, color) => {
  // 3x3の平均値
  const prev = (((i - 1) * this.canvasWidth) + j) * 4;
  const curr = (( i      * this.canvasWidth) + j) * 4;
  const next = (((i + 1) * this.canvasWidth) + j) * 4;

  const sumPrevLineColor = _data[prev-4+color] + _data[prev+color] + _data[prev+4+color];
  const sumCurrLineColor = _data[curr-4+color] + _data[curr+color] + _data[curr+4+color];
  const sumNextLineColor = _data[next-4+color] + _data[next+color] + _data[next+4+color];

  return (sumPrevLineColor + sumCurrLineColor + sumNextLineColor) / 9;
};

// 3x3ブロックずつ色をぬる
for (let i = 1; i < this.canvasWidth; i += 3) {
  for (let j = 1; j < this.canvasHeight; j += 3) {

    const prev = (((i - 1) * this.canvasWidth) + j) * 4;
    const curr = (( i      * this.canvasWidth) + j) * 4;
    const next = (((i + 1) * this.canvasWidth) + j) * 4;

    ['r', 'g', 'b'].forEach((_, color) => {
      data[prev-4+color] = data[prev+color] = data[prev+4+color] = avgColor(i, j, color);
      data[curr-4+color] = data[curr+color] = data[curr+4+color] = avgColor(i, j, color);
      data[next-4+color] = data[next+color] = data[next+4+color] = avgColor(i, j, color);
    });
  }
}

ブロック単位で色をぬるため、今までは1つのループでやっていたところを、行と列の2つのループにわけて行っている。
ただし、参照するdataは1次元配列なのでちょっと面倒くさいけど…。



サンプルコード(全体図)



参考サイト




以上

written by @bc_rikko

0 件のコメント :

コメントを投稿