SpotifyのUIかっこいい
SpotifyのUIが好きです。特にアルバム画像に合わせて歌詞カードや背景の色が変わる機能は地味に感動します。
直感で画像中に使われている色を上手いこと使って背景色と文字色に使ってるんだろうなーとは分かりますが、今回はこれをできる限りちゃんと理解して実装したいと思います。
jsやPHPでの先行事例
この機能がはじめに実装されたのはiTunesみたいです。
jsやPHPで同じようなことをしている事例はこちら
PHPで画像色抽出
iTunesみたいに再生中の曲のアートワーク(ジャケット写真)に合わせて背景色と文字色を変える
かっこよすぎる!JavaScriptで、画像に多く使われている色を背景色に設定しよう!サンプルコード付き
ライブラリ
画像から代表色を抽出してくれるライブラリは色々ありますが、今回はVibrant.jsのnpmパッケージであるnode-vibrantを使います。
ライブラリ間の比較はこちらでやってくれてます。
画像からメインカラーを取得するjavascriptライブラリの比較
アルゴリズム
iTunes 11の曲リストに色を付けるアルゴリズムはどのように機能しますか?
が非常に参考になりました。
だいたいやることとしては
- 画像の読み込み
- 代表色の抽出
- 代表色の中で鮮やかな色を背景色として決定
- 決定した背景色と他の代表色とを比較して、コントラストが一定値以上ならその色を文字色として決定、コントラストが十分でなければ白や黒を文字色として選ぶ
です。
代表色の抽出
もっとも簡単で有名な手法はKmeansクラスタリングを使った手法(ex. 画像のドミナントカラーをk-meansクラスタリングで抽出)ですが、より高速な手法として**MMCQ (Modified median-cut color quantization)**というものが提案されています。
もともと画像の減色処理をするために使われるメディアン・カット法というものを修正したアルゴリズムらしいのですが、どこらへんが修正されているのかまでは理解してません。
メディアン・カット法についてはこちらの動画が非常に分かりやすいです。
Color Quantization
node-vibrantもMMCQを使用して代表色抽出をしています。
鮮やかさの計算
node-vibrantではVibrant=鮮やかな色やMuted=くすんだ色をわざわざ自分で計算せずとも算出してくれるので今回は使いませんが、一応鮮やかさ colorfulness の定義を調べたので書いておきます。単純にHSVに変換してS(彩度)成分とればいいんじゃない?と思いますが、厳密には違うらしいです。Hasler and Süsstrunk’s, 2003で紹介されているように、反対色空間におけるRG成分とYB成分の平均と標準偏差を使った値を元に鮮やかさを定義します。
PythonとOpenCVによる実装と解説はこちらです。
Computing image “colorfulness” with OpenCV and Python
これは人間が色を知覚するときに反対色空間で認識していることが理由なのではと思っています。
ちなみにnode-vibrantではHSVのV(輝度)とS(彩度)に閾値を設定してそれぞれの代表色をVibrantやMutedに割り振っているみたいです。(ここら辺あんまり自信ないです)
コントラストの計算
node-vibrantのgetTitleTextColorというメソッドを使えば白または黒のどちらか適した色を選んでくれるのですが、ただ白か黒を使うのも芸がないのでちゃんと自分で計算したいところです。W3Cの定義を使います。
contrast = (L_1 + 0.05) / (L_2 + 0.05), \\
ここで、Lは相対輝度です。詳しくはリンク先を見て下さい。
実装
Nuxt.js + Pug + Stylus で実装しています。
まずは公式の指示通りnpmでパッケージを入れます。
npm install node-vibrant
Pug(HTML)部分
<template lang="pug">
.container
.main(:style="{ background: background_color}")
img(:src="url" :alt="url")
ul.color
li.color__item(v-for="(rgb, id) in model_rgb_colors" :key="id" :style="{ background: rgb}")
.textbox(:style="{ color: txt_color}")
h3 いい感じの色になるように
p 抽出したカラーパレットを元にして
p 文章の色を変えてみてます
p 実装には
p node-vibrantというパッケージを使いました
</template>
JS部分
<script>
import Vibrant from 'node-vibrant';
export default {
layout: 'wide',
components: {
Vibrant
},
data(){
return{
model_colors: [],
model_rgb_colors: [],
background_color: "#fff",
txt_color: "#000",
url: "/test.jpg"
}
},
watch: {
model_colors: function(val){
let arr = []
for (let i=0; i < val.length; i++){
let rgb = 'rgb(' + val[i][0] + ',' + val[i][1] + ',' + val[i][2] + ')'
arr.push(rgb)
}
this.model_rgb_colors = arr
this.background_color = this.model_rgb_colors[0]
const limit_min = 2.0 // コントラストの閾値
let txt_color = null
for (let j=1; j < val.length; j++){
let cont = this.calcContrast(val[0], val[j])
if(cont >= limit_min){
// コントラストが閾値以上ならテキストの色を決定
txt_color = this.model_rgb_colors[j]
this.txt_color = txt_color
break
}
}
// 代表色を全て探索してコントラストが閾値以上になる色がなければ白か黒にする
if(txt_color == null){
if(this.calcContrast(val[0], [255, 255, 255]) >= limit_min){
this.txt_color = "#fff"
}else{
this.txt_color = "#000"
}
}
}
},
methods: {
getColor: function (img_path) {
let self = this
Vibrant.from(img_path).getPalette()
.then(function (palette) {
let arr = Object.keys(palette).map(function (key) {
return palette[key]._rgb
})
self.model_colors = arr
});
},
// 相対輝度
luminanace: function(r, g, b){
r /= 255
g /= 255
b /= 255
if(r <= 0.03928){
r /= 12.92
}else{
r = Math.pow((r + 0.055) / 1.055, 2.4)
}
return r * 0.2126 + g * 0.7152 + b * 0.0722
},
// コントラストの計算
calcContrast: function(rgb1, rgb2){
const lum1 = this.luminanace(rgb1[0], rgb1[1], rgb1[2])
const lum2 = this.luminanace(rgb2[0], rgb2[1], rgb2[2])
const brightest = Math.max(lum1, lum2)
const darkest = Math.min(lum1, lum2)
return (brightest + 0.05) / (darkest + 0.05)
}
},
created(){
this.getColor(this.url)
}
}
</script>
Stylus(CSS)部分
<style scoped lang="stylus">
.container
width 100%
display flex
justify-content center
align-items center
background #fff
.main
width 800px
display flex
flex-direction column
justify-content center
align-items center
padding 32px 0
border-radius 16px
img
width 400px
height auto
.color
list-style none
display flex
flex-direction row
justify-content center
align-items center
margin 8px 0
&__item
width 40px
height 40px
border-radius 50%
border 1px solid #fff
margin 16px
.textbox
width 400px
</style>
static直下においたtest.jpgという画像を参照しています。
ここで、コントラストの閾値(=2.0)は経験的に決定しました。
また、Vibrantから返ってくる値は*[255, 0, 0]のような形式なので、そのままスタイルバインディングしてもうまく背景色や文字色に適用されません。そのため、watchプロパティの中でrgb(255, 0, 0)*のような形に変換しています。
上のコードはこんな感じになります。まあまあうまく背景色と文字色を設定できているっぽいですね。
デモ
せっかくなのでvue-awesome-swiperと組み合わせてスライドの変更に応じて背景色と文字色が変わるようにしてみました。
DEMO
結果
Spotify本家にどれくらい近づけたか見てみましょう。
Spotify APIを使って『Tokyo Super Hits!』というプレイリストからアルバム画像の一覧を取得してきます。
APIの使い方に関しては割愛します。
アルバムに['primary_color']という要素があるのを見るとSpotifyはリアルタイムで背景色を算出しているわけではなさそうですね。
左列に今回の結果、中央列にSpotify上での表示、右列にAPIから取得した['primary_color']を載せています。
概ね一致していそうなのは50%くらいでしょうか。
一致していない色を見てみると、黄色や明るい水色ということから、本家では歌詞部分に白文字を使っているため白とのコントラストも計算して背景色を決定していると考察できます。
よって、6色でなくもっと多い代表色を抽出してきて、白とのコントラストを考慮した鮮やかな色を抽出すれば一致するかもしれません。
また、文字に関しては本家は背景色の彩度と明度を下げたような色を使っています。
['primary_color']は背景色かなと思ってたのですが必ずしもそういうわけではないみたいですね。謎です。
Spotify API初めて使ったのですが色々遊べそうだったので挑戦してみたいです。