Canvasでフィルタを作る

フィルタを作ってみます

 画像加工と言えば「フィルタ」ですよね。全体の明るさやコントラストを変えたりなど、普段使わないかもしれませんがそういう機能は目にしたことがあると思います。 よく使われるフィルタであればCSS3のfilterで実現できてしまうのですが、ここはあえてcanvasでやってみようと思います。

 フィルタの実装には、ドット単位(ピクセル単位)にアクセスして加工する必要があります。 ドット単位での加工ができるようになるとできることの幅が広がるので、押さえておきたい機能かなと思います。

Canvas要素を載せたHTML5の書き方

htmlファイルを用意します。 文字エンコードは「UTF-8(BOM無し)」で保存してください。(サンプルページが下にあります。)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>canvas tutorial</title>
    <style>
      #canvas { background: #666; }
    </style>
  </head>
  <body>
    <h1>画像を選択してください</h1>
    <form id="my_form">
        <input id="ufile" name="ufile" type="file" accept="image/jpeg,image/png"><br>
    </form>
    <canvas id="canvas" width="640" height="480"></canvas>
    <div>
        <button id="btnReload">再読込</button>
        <button class="btnFilter" data-filter-type="grayscale">グレイスケール</button>
        <button class="btnFilter" data-filter-type="sepia">セピア</button>
        <button class="btnFilter" data-filter-type="negaposi">ネガポジ反転</button>
    <script src="sample03.js"></script>
    </div>
  </body>
</html>

 「再読込」ボタンと各種フィルタボタン(グレイスケール、セピア、ネガポジ反転)を設置しました。 「再読込」ボタンは普通の button 要素なので説明不要ですね。

 btnFilter クラスのボタン要素がフィルタボタンです。data-filter-typeという見たことがない属性が付いていますね。 この属性は今回のために、カスタムデータ属性(独自データ属性、data-* 属性)を使って作成した属性です。 カスタムデータ属性はその名の通りユーザーが自分で作れる属性です。名前の頭に「data-」を付けるなどのいくつかのルールが存在しています。 ルールを守って作るとアクセスが容易になる便利な属性です。 今回は、フィルタボタンにそれぞれ別の機能を振り分けるために使用してみました。

カスタムデータ属性についてはこちらが分かりやすいので御覧ください。あとの方でまた触れます。
いまさら聞けない、HTML5カスタムデータ属性の基本と使いどころ
https://www.webprofessional.jp/how-why-use-html5-custom-data-attributes/

そんなことより動くサンプル

次のスクリプトをファイル名「sample03.js」で保存してください。ファイルはさっきのhtmlファイルと同じフォルダに置きます。 もちろん文字エンコードは「UTF-8(BOM無し)」で。

// canvas要素
var cve = document.getElementById("canvas");

// --------------------
//  画像ファイル読み込み
// --------------------
function LoadPict(event) {
	// --------------------
	//	引数チェック
	// --------------------
	var tg = document.getElementById("ufile");

	if (!tg.files.length) {
		alert('ファイルが選択されていません');
		return;
	}

	// Formからファイルを取得
	var file = tg.files[0];
	
	// --------------------
	//	ファイル読み込み
	// --------------------
	if (cve.getContext) {
		var ctx = cve.getContext('2d');

		var img = new Image();
		var fr  = new FileReader();
		
		fr.onload = function(evt) {
			img.onload = function () {
				// canvasサイズを画像サイズに合わせて描画
				cve.setAttribute('width',  img.naturalWidth);
				cve.setAttribute('height', img.naturalHeight);
				// 描画
				ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
			}
			img.src = evt.target.result;
		}
		
		// fileを読み込む データはBase64エンコードされる
		fr.readAsDataURL(file);
	}
}

// --------------------
//	フィルタ
// --------------------
function Filter(event){
	//	フィルタタイプをボタンの属性値から取得
	var fType = event.target.dataset.filterType;

	if (cve.getContext) {
		// --------------------
		//	準備
		// --------------------
		var ctx = cve.getContext('2d');
		var befImgData = ctx.getImageData(0, 0, cve.width, cve.height);
		var buf = befImgData.data;

		// --------------------
		//	画像を加工
		// --------------------
		// カラーマトリックスを取得
		var m = colorMatrix[fType];
		// 1ドットずつ加工
		for (var i = 0, n = buf.length; i < n; i += 4){
			var R = buf[i];
			var G = buf[i+1];
			var B = buf[i+2];
			var A = buf[i+3];
			buf[i]   = m[0][0] * R + m[0][1] * G + m[0][2] * B + m[0][3] * A + m[0][4] * 255;
			buf[i+1] = m[1][0] * R + m[1][1] * G + m[1][2] * B + m[1][3] * A + m[1][4] * 255;
			buf[i+2] = m[2][0] * R + m[2][1] * G + m[2][2] * B + m[2][3] * A + m[2][4] * 255;
			buf[i+3] = m[3][0] * R + m[3][1] * G + m[3][2] * B + m[3][3] * A + m[3][4] * 255;
		}
		
		// 描画
		ctx.putImageData(befImgData, 0, 0);
	}
}


// --------------------
//	カラーマトリックス
// --------------------
var colorMatrix = {
// 変更なし
'identity' : [
	[ 1.0, 0.0, 0.0, 0.0, 0.0 ],
	[ 0.0, 1.0, 0.0, 0.0, 0.0 ],
	[ 0.0, 0.0, 1.0, 0.0, 0.0 ],
	[ 0.0, 0.0, 0.0, 1.0, 0.0 ]
	],
// グレイスケール
'grayscale' : [
	[ 0.299, 0.587, 0.114, 0.0, 0.0 ],
	[ 0.299, 0.587, 0.114, 0.0, 0.0 ],
	[ 0.299, 0.587, 0.114, 0.0, 0.0 ],
	[ 0.0  , 0.0  , 0.0  , 1.0, 0.0 ]
	],
// セピア
'sepia' : [
	[ 0.393, 0.769, 0.189, 0.0, 0.0 ],
	[ 0.349, 0.686, 0.168, 0.0, 0.0 ],
	[ 0.272, 0.534, 0.131, 0.0, 0.0 ],
	[ 0.0  , 0.0  , 0.0  , 1.0, 0.0 ]
	],
// ネガポジ反転
'negaposi' : [
	[ -1.0,  0.0,  0.0, 0.0, 1.0 ],
	[  0.0, -1.0,  0.0, 0.0, 1.0 ],
	[  0.0,  0.0, -1.0, 0.0, 1.0 ],
	[  0.0,  0.0,  0.0, 1.0, 1.0 ]
	]
};


// --------------------
//	イベントのリスナーを登録
// --------------------

//	ファイル名入力ボックスの変更
document.getElementById("ufile").addEventListener("change", LoadPict, false);
//	再読込 ボタン
document.getElementById("btnReload").addEventListener("click", LoadPict, false);

//	フィルタ ボタン
[].forEach.call(document.getElementsByClassName("btnFilter"), function(tg){
	tg.addEventListener("click", Filter, false);
});

 htmlファイルをブラウザで開いて動作確認してみてください。

 ファイルを選択して表示してください。画像が表示されたと思います。
次に各種フィルタボタン(グレイスケール、セピア、ネガポジ反転)を押してみて画像の変化を確認してください。
「再読込」ボタンを押すと画像を読み込み直します。
サンプルページ

解説:同じクラス全てに同じリスナーの設定

 リスナーの設定はこれまで何度かやったんですが、今回はクラス属性しか設定していない要素もあります。 同じクラス属性全部に同じリスナーを設定したいのでこのようにしました。

[].forEach.call(document.getElementsByClassName("btnFilter"), function(tg){
	tg.addEventListener("click", Filter, false);
});

 forEachで、btnFilter クラスすべての要素でclickイベントが起きたらFilter()を実行するようにしてあります。 for文を使って要素数分回しても同じことが出来ますが、こちらのほうがシンプルに記述できます。 [].forEach.call()Array.prototype.forEach.call()と書いてもいいようです。

解説:画像の読み込み・再読込

 リスナーの設定が終わったら画像ファイル読み込みです。 LoadPict()の部分なんですが、こちらは特に変わったことやっていません今までと同じです。 再読込でも使うので、ファイル情報を読み取る要素を固定しました。

解説:カスタムデータ属性から値の取得

 押された各フィルタボタンに応じて処理を分岐する必要があります。 しかしイベントリスナーだと関数は指定できますが引数が指定できません。指定しようとしたらgetElementsByClassName()の所で実行されてしまいました…。(´・ω・`)
そこで、クリックされた要素のカスタムデータ属性で処理を分岐することにしました。 カスタムデータ属性の値の取得には、datasetプロパティを使用します。 取得方法を大雑把に説明すると、要素.dataset.属性名で取得できます。例えば…

<button data-filter-type="grayscale">グレイスケール</button>
<button data-filter-type="sepia">セピア</button>
<button data-filter-type="negaposi">ネガポジ反転</button>

という要素から属性値を取り出す場合はこのようにします。

var fType = event.target.dataset.filterType;

 これで変数fTypeには'grayscale''sepia''negaposi'、のいずれかの文字列が入ります。 event.target.はクリックされたbutton要素です。

 filterTypeがdatasetプロパティを使う時の属性名です。data-filter-typeという属性名にすると、filterTypeと記述すると取り出せるのがポイントです。 このようにdatasetプロパティの使い方には、少しルールがあるので詳しくはこちらを参照ください。

いまさら聞けない、HTML5カスタムデータ属性の基本と使いどころ
https://www.webprofessional.jp/how-why-use-html5-custom-data-attributes/

解説:ピクセル操作

 canvasのピクセルデータにアクセスするためにImageData オブジェクトを取得します。

var befImgData = ctx.getImageData(0, 0, canvas.width, canvas.height);

 取得したImageData オブジェクトのdataプロパティから各座標の色情報にアクセスできます。 例えば…befImgData.data[0]とすると1ドット目のR(赤)の色が0~255の数値で取得できます。 色情報はRGBA(赤、緑、青、透明)の順で格納されていて、その次は2ドット目の色が格納されています。 つまり
befImgData.data[1]は1ドット目のG(緑)
befImgData.data[4]は2ドット目のR(赤)
となります。

 dataプロパティの中を書き換えて ImageData オブジェクトをputImageData()でcanvasに描画すると、内容を書き換えられた画像が表示されます。

ctx.putImageData(befImgData, 0, 0);

ピクセル操作に関してはこちらのサイトが詳しく載っていますので御覧ください。
Canvas とピクセル操作 - MDN web docs
https://developer.mozilla.org/ja/docs/Web/Guide/HTML/Canvas_tutorial/Pixel_manipulation_with_canvas

解説:フィルタ

 サンプルでは各ピクセルの色情報を元にフィルタ処理した結果を描画させています。 フィルタの手法としては、カラーマトリックス(カラー行列)を用いた方法を実装してみました。 カラーマトリックスだといろんなフィルタが同じ書き方で表現出来るので汎用性高くて便利ですね。 行列は高校数学で習うので敷居が低…え、最近は高校数学でやらないんですか。そうですか。色んな分野で使い倒すので知ってるといろいろ便利なんですけどね。(´・ω・`)

 カラーマトリックスは…探してみましたがあんまり資料ないんですね。まあ、私もHSP3のArtlet2D使うまで知りませんでしたけども…。 しかしやっていることは単純で、フィルタ用の数値の塊(カラーマトリックス)に今の色情報(RGBA)をかけ算してやると、フィルタかけた後の色が算出できる。というものです。行列的には1回のかけ算で答えが出てきます。 フィルタ用の数値の塊(カラーマトリックス)を変えるだけでいろんなフィルタが作れます。フィルタごとに関数を作る必要がないので楽ができる大変ありがたい仕組みです。

 今回はHSP3のalCopyModeColorMatrix 命令のマニュアルに載っていたフィルタから、グレイスケール、セピア、ネガポジ反転を使ってみました。

var colorMatrix = {
// 変更なし
'identity' : [
	[ 1.0, 0.0, 0.0, 0.0, 0.0 ],
	[ 0.0, 1.0, 0.0, 0.0, 0.0 ],
	[ 0.0, 0.0, 1.0, 0.0, 0.0 ],
	[ 0.0, 0.0, 0.0, 1.0, 0.0 ]
	],
…(省略)…
};

 カラーマトリックスとして、フィルタごとに連想配列を作成しました。各プロパティは4行5列の行列(2次元配列)になっています。 連想配列なので使うときは、このように必要なフィルタのみ取り出して使用します。

var m = colorMatrix[fType];

 変数 fType にはdata-filter-type属性から取り出したフィルタの種類を示す文字列が入っています。ここでフィルタの分岐を行うわけです。

次回予告

 canvasのドット単位での編集ができるようになりました。 ここまで出来たら、メディアンフィルタや平滑化フィルタなど画像を加工するフィルタも作れちゃいますね。

次は作成した画像をローカルに保存してみます。

関連記事

  1. Canvas再入門 Canvas再入門  以前、Canvasを使って一発ネタなツ...
  2. Canvasにローカル画像を表示 画像を読み込んでみようと思います  今回は画像を読み込んでみ...
  3. Canvasをファイルに保存 ファイルとして保存  canvas要素で作った画像、せっかく...
  4. カラーマップ カラーマップ  時間と距離、年齢と人口など、2つの要素からな...