カラーマップ

 時間と距離、年齢と人口など、2つの要素からなる数値データをグラフに表現する場合、棒グラフや折れ線グラフを使うのが一般的です。 では3つの要素からなる数値データを表現する場合どうするかというと、バブルチャートや等高線グラフ(3Dモデル)、ウォーターフォール(折れ線グラフを奥行き方向に並べて斜め上から見たもの)などいろいろあります。 今回は、入力された音声のFFT結果の時間変化を描画したいなーと思ったのでカラーマップ(ヒートマップ、コンター)を作ってみました。

 カラーマップとは、2次元配列(複数の1次元配列の集まり)のデータを色の分布で描画したものです。 出力結果は、縦横軸に配列の各次元、値の大きさを色で表します。MATLABで言うとcolormapというかcontourfとかspectrogramみたいな感じの出力結果になります。 一般には赤外線カメラで撮ったサーモグラフィー(温度分布画像)とかをイメージしてもらえると馴染み深いと思います。 詳しくはWikipediaを御覧ください。
ヒートマップ - Wikipedia
https://ja.wikipedia.org/wiki/%E3%83%92%E3%83%BC%E3%83%88%E3%83%9E%E3%83%83%E3%83%97

Canvasの準備

 まずは表示領域として、canvas要素を用意しておきましょう。


<canvas id="canvas_id" width="200" height="500"></canvas>
JavaScriptの方も描画する準備をします。

<script>
//	canvas要素
let cve = document.getElementById("canvas_id");

if (cve.getContext) {	// Canvasを利用できない環境では動作させない
	//	図形を描画するためのコンテキストを取得
	let context = cve.getContext('2d');

	// ここに描画処理を書く
}
</script>

表示する数値データを準備する

 カラーマップは、2次元の配列のデータを表示するグラフです。 したがって描画するには、2次元の配列データが必要になります。 今回は適当な1次元の配列のデータを沢山作って2次元の配列データとすることにします。

 サンプルではコサインを使って0~255までの値を持つ1次元配列を作成しています。 カラーマップはRBGAの色情報なので、値をそのまま使えるようにするため数値データも0~255としています。


let cve = document.getElementById("canvas_id");
let h = cve.height;
let w = cve.width;
let data = new Uint8Array(h);
for( let n = 0; n < data_num; n = n + 1) {
	// 1ライン分のグラデーションデータを作成
	for( var l = 0 ; l < h ; l = l + 1 ){
		data[l] = Math.round(255 * (Math.cos((l+n) * Math.PI / 180) / 2 + 0.5));
	}
}

 とりあえずと思い正弦波を使用しましたが、用意するデータは何でも良いです。

描き方 すべてのデータが揃っている場合

 最初から2次元配列のデータが全て準備できている場合。まずは、イメージデータを取得。

let src = context.getImageData(0, 0, context.canvas.width, context.canvas.height);

 src.dataに色の情報が入っているのでこれをデータに合わせて書き換えてやればOKです。 色情報は、RGBAの順で、値は0~255のUint8ClampedArrayです。1次元の配列になっています。
src.data[i] : R 赤 0で黒、255で赤
src.data[i+1] : G 緑 0で黒、255で緑
src.data[i+2] : B 青 0で黒、255で青
src.data[i+3] : A 不透明度、 0で透明、255で不透明

 src.dataに対して、forforEachなどで適当に値を入れてやってください。
白黒表示ならこんな感じです。代入する値は0~255にしておく必要があります。

src.data[i] = src.data[i+1] = src.data[i+2] = value[i/4][j];
src.data[i+3] = 255;

 src.dataは1次元の配列で、画像の縦サイズ×横サイズ×4の長さを持ちます。 画像の左上が0で、画像の右下が最後。左から右へ、それが下に続きます。BMPより直感的でわかりやすいですね。 データを書き換えたらcanvasに出力。

context.putImageData(src, 0, 0);

 これで出力できたと思います。 分からかなかったら、こちらのサイトを読んでみることをおすすめします。
Canvas とピクセル操作 - 開発者ガイド | MDN
https://developer.mozilla.org/ja/docs/Web/Guide/HTML/Canvas_tutorial/Pixel_manipulation_with_canvas

描き方 データを作りながら描画する場合

 今回の最終目標はこんな感じです。

  • 入力された音声をFFTして結果をすぐに見たい。
  • 過去の結果は消さずに残しておきたい。

 したがって仕様はこうなります。

  • 入ってきた音声をFFTしてすぐに描画
  • 次に入ってきた音声をFFTして描画。だたし上書きしない。

 前述した方法で描画しようとすると、1回 FFT が終わるたびに画像サイズの配列データをすべてfor文で書き換える必要があります。すごく時間かかりそうですね。 別の方法として、未描画の空いている領域に書き足していく方法があります。この方法の場合、画像幅分の回数が終わったら、一番古いデータから上書きしていかないといけなくなります。そうなるとデータの最新位置が画像の端から端にジャンプする瞬間ができてしまい、とても見辛いグラフになってしまいます。 できればループ回数は減らして、データの最新位置は常に同じ位置に見えるようにしたいですね。ということで考えてみました。

  • データ出力前に古いデータ全てを1ラインずらす。
  • ずらして空いた1ラインに、最新の分析結果を出力する。(決まった位置)

 この方法なら最新結果の描画位置は固定できるし、ループ処理も書く必要がありません。 古いデータをずらすのは簡単です。getImageDataでデータ領域をコピーし、1ピクセル移動した位置にputImageDataで貼り付けます。処理負荷は Canvas API が上手に割り振ってくれるので楽ちんです。


let src_old  = context.getImageData(1, 0, width-1, height);
let src_line = context.getImageData(width - 1, 0, 1, height);

//	データ -> RGBA
convRGBAfromValue( src_line.data, lineValue, alp );

//	ImageDataに書き込み
// 元々あった画像データ部分は1px左にシフト
context.putImageData(src_old, 0, 0);
// 新しいデータ
context.putImageData(src_line, width-1, 0);

// 数値を色(白黒)に置き換え
convRGBAfromValue( data, value, alp = 255 ) {
	let e    = value.length;
	for( let i = 0 ; i < e*4 ; i = i + 4 ){
		data[i]   = data[i+1] = data[i+2] = value[e - i/4 - 1];
		data[i+3] = alp;	// 不透明
	}
	return data;
}

サンプル

白黒な画像に色をつける

 ここまで、カラーマップを白黒で描画してきました。白黒だと印刷するときは良いんですが、データとしては見にくいですね。 Photoshopのグラデーションマップみたいな機能を作ってカラー化してみようと思います。 グラデーションマップについては、下記サイトの説明がわかりやすいと思います。

Photoshopのグラデーションマップの使い方
http://designers-tips.com/archives/19735

白黒画像はRGBが 0~255 なので、これに対応する色のマップ配列(全256色)を事前に用意しておき、1ドットずつ置き換えてやれば出来上がりです。 まずは色のマップ配列を作ってみます。


function colorMapArray(cname=''){
	var colors = {
		r: new Uint8Array(256),
		g: new Uint8Array(256),
		b: new Uint8Array(256)
	}
	var r, g, b;
	switch(cname) {
		case 'cool':
			for (var i=0; i<256; i++) {
				colors.r[i] = i;
				colors.g[i] = 255-i;
				colors.b[i] = 255;
			}
			break;

		case 'spring':
			for (var i=0; i<256; i++) {
				colors.r[i] = 255;
				colors.g[i] = i;
				colors.b[i] = 255-i;
			}
			break;
		default:
			for (var i=0; i<256; i++) {
				colors.r[i] = i;
				colors.g[i] = i;
				colors.b[i] = i;
			}
			break;
	}
	return colors;
}

colors = colorMapArray('cool'); というようにすると取得できます。

ここでは一部のみを掲載しましたが、実装した配色パターンは次の通り。
カラーマップの色見本
https://mclab.uunyan.com/lab/html/canvas_sample/sample05.html

参考にしたサイト
jet-color
https://github.com/timmysiauw/jet-color


HSVからRGBへの変換プログラム
https://qiita.com/hachisukansw/items/633d1bf6baf008e82847

 次はこれを使ってcanvasの白黒画像を用意したカラーマップに置き換える関数を作ってみます。


//  imgData       : 変換元 ImageData オブジェクト
//  convImgData   : 変換後 ImageData オブジェクト
//  colorMapArray : カラーマップ配列
//  alp           : 透過度(省略=255=不透明)
function GradationMap(imgData, convImgData, colorMapArray, alp=255){
	//  パレットに置き換え
	let idx = 0;
	for (let i = 0; i < imgData.data.length; i = i + 4) {
		idx = imgData.data[i];
		convImgData.data[i]     = colorMapArray.r[idx]  // R
		convImgData.data[i + 1] = colorMapArray.g[idx]  // G
		convImgData.data[i + 2] = colorMapArray.b[idx]  // B
		convImgData.data[i + 3] = alp  // A
	}
	return convImgData;
}

 素直にループで置き換えをやっています。白黒の色が 0~255 なのでそのまま配列の位置として使えるので簡単です。 作った関数を使ってみます。


let cve     = document.getElementById("canvas_id");
let context = cve.getContext('2d');
let src = context.getImageData(0, 0, cve.width, cve.height); // 元の画像
let dst = context.createImageData(src.width, src.height);    // 作業領域
…
let clrPlt  = colorMapArray('cool');
context.putImageData( GradationMap(src, dst, clrPlt), 0, 0);

なかなかシンプルに描けました。

サンプル
サンプルでは1ラインずつ描画していますが、瞬時に計算結果が出てしまうため、描画経過が目視できませんね。(´・ω・`)

サンプル
こちらはデータを全部準備してから出力するサンプル。

こっちのFFT機能をつけたサンプルを見たほうが、実行時の感覚はつかみやすいと思います。

サンプル

まとめ

 あまりちゃんと作り込んでいませんが、1個のjsファイルにまとめてみました。 一部はclassにしたのでそれなりに使いやすくなっているのではないかなと思います。

colormap2.js