Spotifyの歌詞カードっぽく画像に応じたいい感じの背景色と文字色を設定する（Vue.js）

## SpotifyのUIかっこいい
SpotifyのUIが好きです。特にアルバム画像に合わせて歌詞カードや背景の色が変わる機能は地味に感動します。

歌詞カードの色が変わるとは、例えば瑛人の『香水』ならこう。
![lyrics1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520951/00f382f2-2dcf-d56e-42f4-5f2493b3c3cc.png)


YOASOBI　『夜に駆ける』
![lyrics2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520951/79f5b519-d04b-e9af-7bd5-f2870dfbc77a.png)


ジェニーハイ　『ジェニーハイラプソディー』
![lyrics3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520951/52eafba0-25a3-6ef1-691c-c1679cecad7c.png)


直感で画像中に使われている色を上手いこと使って背景色と文字色に使ってるんだろうなーとは分かりますが、今回はこれをできる限りちゃんと理解して実装したいと思います。

## jsやPHPでの先行事例
この機能がはじめに実装されたのはiTunesみたいです。
jsやPHPで同じようなことをしている事例はこちら
[PHPで画像色抽出](https://qiita.com/mekkoo/items/ae9628e59914b09d268f)
[iTunesみたいに再生中の曲のアートワーク(ジャケット写真)に合わせて背景色と文字色を変える](https://qiita.com/katsuhisaishii/items/d5830bde33a21840879a)
[かっこよすぎる！JavaScriptで、画像に多く使われている色を背景色に設定しよう！サンプルコード付き](https://qiita.com/kenzooooo/items/6439ae650f31b6b9853f)

## ライブラリ
画像から代表色を抽出してくれるライブラリは色々ありますが、今回はVibrant.jsのnpmパッケージである[node-vibrant](https://www.npmjs.com/package/node-vibrant)を使います。

ライブラリ間の比較はこちらでやってくれてます。
[画像からメインカラーを取得するjavascriptライブラリの比較](https://wood-roots.com/web/javascript/2240)

## アルゴリズム
[iTunes 11の曲リストに色を付けるアルゴリズムはどのように機能しますか？](https://www.it-swarm.dev/ja/algorithm/itunes-11%E3%81%AE%E6%9B%B2%E3%83%AA%E3%82%B9%E3%83%88%E3%81%AB%E8%89%B2%E3%82%92%E4%BB%98%E3%81%91%E3%82%8B%E3%82%A2%E3%83%AB%E3%82%B4%E3%83%AA%E3%82%BA%E3%83%A0%E3%81%AF%E3%81%A9%E3%81%AE%E3%82%88%E3%81%86%E3%81%AB%E6%A9%9F%E8%83%BD%E3%81%97%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F/1070319912/)
が非常に参考になりました。

だいたいやることとしては
1. 画像の読み込み
2. 代表色の抽出
3. 代表色の中で鮮やかな色を背景色として決定
4. 決定した背景色と他の代表色とを比較して、コントラストが一定値以上ならその色を文字色として決定、コントラストが十分でなければ白や黒を文字色として選ぶ
です。

### 代表色の抽出
もっとも簡単で有名な手法は**Kmeansクラスタリング**を使った手法(ex. [画像のドミナントカラーをk-meansクラスタリングで抽出](https://qiita.com/gao_gao/items/26269cd4ae8fdf55e608))ですが、より高速な手法として**MMCQ (Modified median-cut color quantization)**というものが提案されています。
もともと画像の減色処理をするために使われるメディアン・カット法というものを修正したアルゴリズムらしいのですが、どこらへんが修正されているのかまでは理解してません。

メディアン・カット法についてはこちらの動画が非常に分かりやすいです。
[Color Quantization](https://www.youtube.com/watch?v=LQST9MITKrw)

node-vibrantもMMCQを使用して代表色抽出をしています。

###  鮮やかさの計算
node-vibrantでは**Vibrant=鮮やかな色**や**Muted=くすんだ色**をわざわざ自分で計算せずとも算出してくれるので今回は使いませんが、一応鮮やかさ *colorfulness* の定義を調べたので書いておきます。単純にHSVに変換してS（彩度）成分とればいいんじゃない？と思いますが、厳密には違うらしいです。[Hasler and Süsstrunk’s, 2003](https://infoscience.epfl.ch/record/33994/files/HaslerS03.pdf)で紹介されているように、反対色空間におけるRG成分とYB成分の平均と標準偏差を使った値を元に鮮やかさを定義します。

PythonとOpenCVによる実装と解説はこちらです。
[Computing image “colorfulness” with OpenCV and Python](https://www.pyimagesearch.com/2017/06/05/computing-image-colorfulness-with-opencv-and-python/)

これは人間が色を知覚するときに反対色空間で認識していることが理由なのではと思っています。
ちなみにnode-vibrantではHSVのV（輝度）とS（彩度）に閾値を設定してそれぞれの代表色をVibrantやMutedに割り振っているみたいです。（ここら辺あんまり自信ないです）

###  コントラストの計算
node-vibrantのgetTitleTextColorというメソッドを使えば白または黒のどちらか適した色を選んでくれるのですが、ただ白か黒を使うのも芸がないのでちゃんと自分で計算したいところです。[W3Cの定義](https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-procedure)を使います。

```math
contrast = (L_1 + 0.05) / (L_2 + 0.05), \\
```

ここで、Lは相対輝度です。詳しくはリンク先を見て下さい。

## 実装
Nuxt.js + Pug + Stylus で実装しています。

まずは公式の指示通りnpmでパッケージを入れます。

```
npm install node-vibrant
```

Pug(HTML)部分

```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部分

```vuejs
<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)部分

```sass

<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)*のような形に変換しています。

上のコードはこんな感じになります。まあまあうまく背景色と文字色を設定できているっぽいですね。
<img width="1343" alt="スクリーンショット 2020-07-04 22.19.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520951/973207dd-535d-fc18-185e-7804bc487f1a.png">

## デモ
せっかくなのでvue-awesome-swiperと組み合わせてスライドの変更に応じて背景色と文字色が変わるようにしてみました。
[DEMO](https://karinkiho.com/special/color)

## 結果
Spotify本家にどれくらい近づけたか見てみましょう。
Spotify APIを使って[『Tokyo Super Hits!』](https://open.spotify.com/playlist/37i9dQZF1DXafb0IuPwJyF)というプレイリストからアルバム画像の一覧を取得してきます。
APIの使い方に関しては割愛します。
アルバムに['primary_color']という要素があるのを見るとSpotifyはリアルタイムで背景色を算出しているわけではなさそうですね。

左列に今回の結果、中央列にSpotify上での表示、右列にAPIから取得した['primary_color']を載せています。
![グループ 142.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520951/ce3f2526-9c9d-012f-8d6c-ab1282ffcbec.jpeg)

概ね一致していそうなのは50%くらいでしょうか。
一致していない色を見てみると、黄色や明るい水色ということから、本家では歌詞部分に白文字を使っているため白とのコントラストも計算して背景色を決定していると考察できます。
よって、6色でなくもっと多い代表色を抽出してきて、白とのコントラストを考慮した鮮やかな色を抽出すれば一致するかもしれません。
また、文字に関しては本家は背景色の彩度と明度を下げたような色を使っています。

['primary_color']は背景色かなと思ってたのですが必ずしもそういうわけではないみたいですね。謎です。
Spotify API初めて使ったのですが色々遊べそうだったので挑戦してみたいです。