JavaScript

iTunesのアルバム情報のカラーリングをjsで実装する

More than 3 years have passed since last update.


iTunesのアルバム情報のカラーリングをjsで実装する

iTunesのアルバム情報を表示する時のカラーリング、オシャレだよね!

itunes.png

今回はこんな感じのカラーリングを画像から抽出する処理を、jsで実装します。

こういった面白そうな課題にはすでに先駆者様達がたくさんいるので、それを参考にしながら実装しようかと思います。

実装はいろいろありますが、今回参考にしたのはコレ!

user interface - How does the algorithm to color the song list in iTunes 11 work? - Stack Overflow

Mathematicaというやつで書かれてますが、手順をかなり詳細に記述してくれているので、js等でも問題なく実装できそうです。


ざっくり手順

ざっくりとした手順はこんな感じ。

ドミナントカラーの求め方とか、詳細は実装と一緒に記載します。


  1. 画像から色をRGBとして読み取る

  2. 読み取った色を、画像の周囲1px(ボーダー)とそれ以外に分離する

  3. RGBをYUVに変換する

  4. ボーダーのドミナントカラーを1色, ボーダー以外の部分のドミナントカラーを2色求める

  5. ボーダーから得たドミナントカラーを背景、ボーダー以外の部分の2色をそれぞれ文字色とする

あとはひたすら実装していくよ!


実装


1. 画像から色をRGBとして読み取る

やっぱりブラウザで動作させたいので、type属性がfileなinput要素を用意し、そこからFileオブジェクトを取得してImageオブジェクトを生成。canvasに描画してRGBを読み取る、という手順で行きます。

// FileオブジェクトからRGBを読み込む

function loadRgb( file, size, callback ) {

// 画像を読み込む
var img = new Image( );
img.src = window.URL.createObjectURL( file );
img.onload = function( ) {

// 読み込んだら指定サイズのcanvasに描画
var w = img.width, h = img.height;

var cw = w > h ? size : Math.ceil( size * ( w / h ) );
var ch = h > w ? size : Math.ceil( size * ( h / w ) );

var canvas = document.createElement( 'canvas' );
canvas.setAttribute( 'width', cw );
canvas.setAttribute( 'height', ch );

var ctx = canvas.getContext( '2d' );
ctx.drawImage(
img,
0, 0, w, h,
0, 0, cw, ch
);

// 縮小された画像データを読み込んでRGBの配列をコールバックに渡す
// 配列は1次元である点に注意
var data = ctx.getImageData( 0, 0, cw, ch ).data;
var len = data.length;

result = [ ];
for( var i = 0; i < len; i += 4 ) { // アルファは無視する
result.push( {
r: data[ i ],
g: data[ i + 1 ],
b: data[ i + 2 ]
} );
}

callback( result );
};
}


2. 読み取った色を、画像の周囲1px(ボーダー)とそれ以外に分離する

画像の周囲1px(ボーダー)とそれ以外とを分離する関数を用意。

// RGBの集合からボーダー部分を抜き出す

function cropBorder( width, rgbArray, callback ) {
// 行毎に切り出す
var row = [ ];
while( rgbArray.length > 0 ) {
row.push( rgbArray.splice( 0, width ) );
}

var border = row.pop( ).concat( row.shift( ) ); // 先頭末尾がボーダーなのは当たり前
var len = border.length;
border.concat( new Array( row.length * 2 ) ); // サイズは分かっているので予め確保

for( var i = 0; i < row.length; i++ ) {
// 各行の先頭末尾1pxがボーダー
border[ len++ ] = row[ i ].pop( );
border[ len++ ] = row[ i ].shift( );
}

// タプル欲しい
callback( border, Array.prototype.concat.apply( [ ], row ) ); // rowに残った部分はボーダー以外
}


3. RGBをYUVに変換する

RGBをYUVに変換する方法に関しては色々あってややこしいです。

今回はYUVフォーマット及び YUVとRGBの変換 - ITU-R BT.601 規定YCbCrと8bitフルスケールRGBの相互変換を参考に実装。

// RGBからYUVへ変換

function toYuv( rgb ) {
var r = rgb.r, g = rgb.g, b = rgb.b;
return {
y: r * 0.257 + g * 0.504 + b * 0.098 + 16,
u: r * -0.148 + g * -0.291 + b * 0.439 + 128,
v: r * 0.439 + g * -0.368 + b * -0.071 + 128
};
}

// YUVからRGBへ変換
function toRgb( yuv ) {
var y = yuv.y - 16, u = yuv.u - 128, v = yuv.v - 128;
return {
r: round( y * 1.164 + v * 1.596 ),
g: round( y * 1.164 + u * -0.391 + v * -0.813 ),
b: round( y * 1.164 + u * 2.018 )
};
}

// 丸め(不要?)
function round( rgbValue ) {
var value = Math.round( rgbValue );
return value < 255 ? value : 255;
};


4. ボーダーのドミナントカラーを1色, ボーダー以外の部分のドミナントカラーを2色求める

今回の実装のキモ!

ボーダーとそれ以外の部分からドミナントカラーを抽出するための関数, その他もろもろの関数を実装します。

ドミナントカラーについては以下の手順で求めます。


  1. 色(YUV)の配列を、2色間のユークリッド距離が指定の閾値以下の集合にまとめる

  2. 生成された集合の配列を、大きさで降順ソートする

  3. 各集合内の色の平均を計算する

  4. 必要なドミナントカラーの数だけ、集合の配列の先頭から取り出す

// YUV2色のユークリッド距離を求める

function calcYuvDistance( a, b ) {
return Math.sqrt(
Math.pow( a.y - b.y, 2 ) +
Math.pow( a.u - b.u, 2 ) +
Math.pow( a.v - b.v, 2 )
);
}

// 指定ユークリッド距離以内のYUVを配列にまとめる
function gatherYuv( yuvArray, threshold ) {
var sets = [ ];
var yuv = null;

while( yuv = yuvArray.shift( ) ) {
var set = [ yuv ];
for( var j = 0; j < yuvArray.length; j++ ) {
if( calcYuvDistance( yuv, yuvArray[ j ] ) < threshold ) {
set.push( yuvArray.splice( j--, 1 )[ 0 ] );
}
}
sets.push( set );
}

return sets;
}

// YUV配列の平均を計算する
function avgYuv( yuvArray ) {
var y = 0, u = 0, v = 0;
for( var i = 0; i < yuvArray.length; i++ ) {
y += yuvArray[ i ].y;
u += yuvArray[ i ].u;
v += yuvArray[ i ].v;
}

return {
y: y / yuvArray.length,
u: u / yuvArray.length,
v: v / yuvArray.length
};
}

// ドミナントカラーを求める
function calcDominantColor( rgbArray, n, threshold, numThreshold, filterColor, filterThreshold ) {

// YUV2色のユークリッド距離がthreshold以下の色を1つの配列にまとめていく
var sets = gatherYuv( rgbArray.map( toYuv ), threshold );
sets.sort( function( a, b ) {
return b.length - a.length; // 配列のサイズで降順ソート
} );

// 以降で配列のサイズを見ることはないので、この時点でまとめた各配列の色の平均を算出しておく
var yuvs = sets.map( avgYuv );

// filterColor(YUV)が指定されている場合、それよりfilterThresholdだけユークリッド距離が離れているYUVのみを抽出対象とする
// これはボーダーから算出されたドミナントカラー(背景)と同じドミナントカラーが算出されないようにするためで、ボーダー以外からのドミナントカラー算出時にボーダーから算出されたドミナントカラーをfilterColorとして渡す
if( filterColor ) {
yuvs = yuvs.filter( function( yuv ) {
return calcYuvDistance( yuv, this ) > filterThreshold;
}, toYuv( filterColor ) );
}

if( yuvs.length == 0 ) {
return [ ]
}

var results = [ ], prev = yuvs.shift( );

// ドミナントカラーをn個抽出する
// その際同じような色が連続して抽出されることを防ぐため、抽出される色は最低でもnumThresholdで指定されるユークリッド距離だけ離れるようにする
while( yuvs.length > 0 && results.length < n ) {
var next = yuvs.shift( );
if( calcYuvDistance( prev, next ) > numThreshold ) {
results.push( prev );
prev = next;
}
}

if( results.length < n ) {
results.push( prev );
}
return results.map( toRgb ); // 最後にRGBに戻して返す
}


組み合わせる

主要な実装はこれでOK

あとはこんな感じで使用して、色を抽出してみましょう。

var file = document.header.image.files[ 0 ] ? document.header.image.files[ 0 ] : null;

if (file === null || !file.type.match( 'image.*' ) ) {
alert( 'please select image file.' );
}

loadRgb( file, 128, function( rgb ) {
cropBorder( 128, rgb, function( border, body ) {

// 参考にしたページではRGBの値域が0~1だったが、こちらの実装では0~255なので、それに合わせて閾値も変更しておく
// 閾値や画像サイズは好みに応じて色々試すのがいいかも
border = calcDominantColor( border, 1, 20, 40 );
body = calcDominantColor( body, 2, 20, 40, border[ 0 ], 100 );

var bkg = border[ 0 ]; // 背景色
var text1 = body[ 0 ]; // アルバムタイトル, 曲名等
var test2 = body[ 1 ]; // 曲のナンバリング文字等
} );
} );


サンプル

html, cssまでここに載せると無駄が多くなるので、実際に1つのページとして動作するものはGithub Pagesに上げておきました。(一面同じ色とか、コーナーケースをつつかれると死ぬかも)コードを見たい方はGithubからどうぞ。

サンプルページ(Github Pages)

ss.png