あの曲なんだっけ？をサムネの色から調べる

https://qiita.com/advent-calendar/2022/rust

この記事は Rust Advent Calendar 2022 7日目の記事です。

---

みなさんは、「ある曲が聴きたいけど、曲名もアーティスト名も思い出せない！」みたいなことありませんか？
というか、あるとします。 ~~曲名もアーティスト名もそもそも覚えられないよ！！！！~~
こういうときに私が普段これをどうしているかというと、

- 曲の雰囲気はわかるので、そういう曲を作っていそうなアーティストの曲を適当に漁る
- サムネの見た目を何となく覚えているので、自分のライブラリを目視でどうにか探す
- 諦める

などがあります。

今回は、この中の *「サムネ見た目を何となく覚えているので、自分のライブラリを目視でどうにか探す」* をもうちょい楽にしてみようと思います。

ということで、 **find-track-by-color** というツールを Rust で実装しました。

https://github.com/ekuinox/find-track-by-color

前提として

- rustc が 1.65.0 以降であること
- Spotify にのみ対応している

があります。

人によっては、 `rustup update` するのと Spotify 契約してもらうことが必要です。

## 導入と使い方

とりあえず、インストールします。

```console
$ cargo install --git https://github.com/ekuinox/find-track-by-color
```

[Spotify の開発者ダッシュボード](https://developer.spotify.com/dashboard/applications)から新しくアプリを発行します。コールバックURLの設定もします。
発行・設定したそれぞれの値を作業ディレクトリに `.env` というファイル名で保存します。

```.env
RSPOTIFY_CLIENT_ID=
RSPOTIFY_CLIENT_SECRET=
RSPOTIFY_REDIRECT_URI=
```

続いて、自身のライブラリにある楽曲のサムネ（アルバム）画像を一括でダウンロードします。
`prepare` サブコマンドを実行すると、 Spotify の承認ページがブラウザで開かれます。
承認後、リダイレクトされたURLをブラウザからコピーして標準入力に貼り付けるとログイン完了です。
ログインが済むと、そのまま画像のダウンロードが実行されます。（楽曲の数の分だけ重い）

```console
$ find-track-by-color prepare [-d <画像ダウンロード先>]
Opened https://accounts.spotify.com/authorize...
Please enter the URL you were redirected to: 
```

では、探したい色を `find` サブコマンドに与えて探してみます。
色の指定には CSS の色指定と同じものが使えます（はず） `#ff0011` とか `aqua` とか。

```console
$ find-track-by-color find <色> [-d <画像ダウンロード先>]
```

とは言っても、急ごしらえで作った（言い訳）ので、色々オプションを与えてやった方が試し易いです。
何となくピンク色かつ、色差が0.1までのものをディレクトリにある画像100件で探してみます。

```console
$ find-track-by-color find pink -l 100 -t 0.1
█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ 100/100
恋?で愛?で暴君です! ... spotify:track:0kFmdj4wjarVHE8dZUizxz, "./images/spotify:track:0kFmdj4wjarVHE8dZUizxz.jpg", 0.09661713721376407, 0.38915282
Love [in Every Single Way] ... spotify:track:4Pq95yEczlyMWnoZ7glHOd, "./images/spotify:track:4Pq95yEczlyMWnoZ7glHOd.jpg", 0.09246922449060081, 0.28844237
I Feel The Love ... spotify:track:6UiDiFJUGEDzkGpZBL8IYq, "./images/spotify:track:6UiDiFJUGEDzkGpZBL8IYq.jpg", 0.07444097267923581, 0.3167456
```

`タイトル ... SpotifyのURI, 画像のパス, 色差, 色が画像に占める割合?` という形式で、色差の大きいものから順（逆じゃん）に表示しています。

実際にサムネを見にいくとこんな感じ...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55719/113d3cd8-ba20-237d-0957-fc03c657215e.png)

まあ大体ピンクだと思う。 Spotify もピンク背景にしてるし。これを API で取得できたら気が楽なんですが、探した限りは提供されていなさそうだった。

## 実装諸々

実装諸々の話です。

:::note warn
記事書くネタがなくって慌てて実装したから、めちゃくちゃ間違い多いと思います。
もう間違い探しなので、ツッコんでください！！！！
:::

### Spotify のログイン

Spotify の API を利用するために、 [rspotify](https://docs.rs/rspotify/latest/rspotify/) を利用しています。

[client::get_client](https://github.com/ekuinox/find-track-by-color/blob/master/src/client.rs#L7L25)より

```rust:client.rs
pub async fn get_client() -> Result<impl BaseClient + OAuthClient> {
    let Some(creds) = Credentials::from_env() else { bail!("Credentials::from_env failed.") };

    let scopes = scopes!("user-library-read");
    let Some(oauth) = OAuth::from_env(scopes) else { bail!("OAuth::from_env failed.") };
    let config = Config {
        token_refreshing: true,
        token_cached: true,
        ..Default::default()
    };

    let mut spotify = AuthCodePkceSpotify::with_config(creds, oauth, config);

    let url = spotify.get_authorize_url(None)?;
    spotify.prompt_for_token(&url).await?;

    spotify.write_token_cache().await?;
    Ok(spotify)
}
```

クレデンシャル類は、 `Credentials::from_env()` で読み込まれます。
冒頭の使い方で、 `.env` を作成していましたが、環境変数にセットしていても機能します。
`.env` からの読み込みは、 rspotify の `env-file` feature を有効にしておくと使用できます。

今回は、ログインしているユーザのお気に入り一覧を取得したいので、スコープには `"user-library-read"` を指定します。
スコープ一覧は、 [Authorization Scopes](https://developer.spotify.com/documentation/general/guides/authorization/scopes/) より確認できます。

`AuthCodePkceSpotify` を使って、 OAuth をやります。
Config は全て `Default::default` を取っても良いですが、 `token_cached` を `true` にしておくと、ファイルにアクセストークンだとかを残してくれます。

```rust
let url = spotify.get_authorize_url(None)?;
spotify.prompt_for_token(&url).await?;
```

ブラウザで承認ページが開かれて、リダイレクトURLを標準入力に貼り付けると認証完了というのは、 `prompt_for_token` が行ってくれます。これは rspotify の `cli` feature を有効にしておく必要があります。

### サムネ画像のダウンロード

[prepare::prepare#L20L33](https://github.com/ekuinox/find-track-by-color/blob/master/src/prepare.rs#L20L33)

```rust:prepare.rs
let items = stream
    .collect::<Vec<_>>()
    .await
    .into_iter()
    .flatten()
    .collect::<Vec<_>>();
let pb = Arc::new(ProgressBar::new(items.len() as u64));

let _ = futures::future::join_all(
    items
        .into_iter()
        .map(|item| save_track_image_with_pb(&directory, item.track, pb.clone())),
    )
    .await;
```

ぶっっちゃけ futures よくわかりません。
`stream` からは楽曲1つ分のデータが順に取れます。
理想（私の勝手な考え）だと、楽曲のデータが取得されるたびににサムネ画像のダウンロードをしてしまえば並列で一気に終わらせられると思うのですが、どうも上手くやれません。
諦めて、一旦全て `collect::<Vec<_>>()` しちゃって全ての楽曲データが集まってから並列でダウンロードすることにしました。

ダウンロードの進捗が全くわからないのも困り物なので、 [indicatif::ProgressBar](https://docs.rs/indicatif/latest/indicatif/struct.ProgressBar.html) を使って進捗をプログレスバーに表示しています。

`Arc` で包んでいるけど、 `ProgressBar` 自体が中で `Arc` 使っていて多分無意味ですね...

https://docs.rs/indicatif/latest/src/indicatif/progress_bar.rs.html#20

### 代表色を探す

サムネの画像から代表になる色を取り出すにはどうしたら良いんだろうと思って調べていると、以下の記事に辿り着きました。

https://artteknika.hatenablog.com/entry/2019/09/17/151412

斜め読みします。多分 **k平均法** というのを使うのが良い。
それっぽいクレートを探したところ、 [kmeans_colors](https://docs.rs/kmeans_colors/latest/kmeans_colors/) というのがあったので使います。

理屈がわかっていないもののライブラリを使うのでチンプンカンプンです。
`kmeans_colors` はライブラリと一緒に実行バイナリも同梱しているようです。
じゃあ、その実行バイナリがやっていることをとにかく真似するしかありません。

https://github.com/okaneco/kmeans-colors/tree/master/src/bin/kmeans_colors


[find::FindColors::get_colors](https://github.com/ekuinox/find-track-by-color/blob/master/src/find.rs#L146L181)

どうにか書いた。

```rust:find.rs
fn get_colors(&self, img: DynamicImage) -> Vec<(Rgb<u8>, f32)> {
    let bytes = img
        .pixels()
        .flat_map(|(_, _, Rgba([r, g, b, _]))| [r, g, b])
        .collect::<Vec<u8>>();
    let lab: Vec<Lab> = Srgb::from_raw_slice(&bytes)
        .iter()
        .map(|x| x.into_format::<f32>().into_color())
        .collect();

    let mut result = Kmeans::new();
    for i in 0..self.runs {
        let run_result = get_kmeans(
            self.k,
            self.max_iter,
            self.coverage,
            self.verbose,
            &lab,
            (self.seed + i) as u64,
        );
        if run_result.score < result.score {
            result = run_result;
        }
    }
    let mut colors = Lab::sort_indexed_colors(&result.centroids, &result.indices)
        .into_iter()
        .map(|color| {
            let per = color.percentage;
            let color: Srgb = color.centroid.into_color();
            let color = color.into_format::<u8>();
            (Rgb([color.red, color.green, color.blue]), per)
        })
        .collect::<Vec<_>>();
    colors.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(Ordering::Equal));
    colors
}
```

流れとしては、

1. [image::DynamicImage](https://docs.rs/image/latest/image/enum.DynamicImage.html) から全てのピクセルの色(RGB)を取ってくる
1. 色データを [palette::Lab](https://docs.rs/palette/latest/palette/struct.Lab.html) の配列に変換する
1. K平均法の計算機(`get_kmeans()`) に `Lab` の配列を渡して計算させる
1. [Lab::sort_indexed_colors](https://docs.rs/kmeans_colors/latest/kmeans_colors/trait.Sort.html#tymethod.sort_indexed_colors) を呼び出して代表色を輝度の低いものから順に並べてもらう
1. 実装の都合上（呼び出し元の都合）で、これを [image::Rgb](https://docs.rs/image/latest/image/struct.Rgb.html) へ変換していきます。
1. 代表色を画像に占めた割合の高いものからソートし直す

:::note
Lab が何を指すのかわからなくてドキュメントから [Lab色空間](https://ja.wikipedia.org/wiki/Lab%E8%89%B2%E7%A9%BA%E9%96%93) に行き着いたけど、RGB とかとは別の色空間ということらしい。
> Lab色空間は人間の視覚を近似するよう設計されている。

そっか〜
:::

それではディレクトリ内の各画像に対して、代表色を計算していきます。

[find.rs#L36L52](https://github.com/ekuinox/find-track-by-color/blob/master/src/find.rs#L36L52)

```rust:find.rs
let tasks = std::fs::read_dir(&self.directory)?
    .into_iter()
    .flatten()
    .map(|entry| {
        let pb = pb.clone();
        let finder = finder.clone();
        tokio::spawn(async move {
            {
                let r =
                    get_color_by_entry(&finder, &entry).map(|color| (entry.path(), color));
                pb.inc(1);
                r
            }
        })
    })
    .take(self.limit)
    .collect::<Vec<_>>();
```

ディレクトリの各エントリを `get_color_by_entry` へ投げ込んでいきます。
`get_color_by_entry` が内部で先ほどの `FindColors::get_colors` を呼び出して計算しています。
重たいので、 `tokio::spawn` で並列にやってもらいます。

### 色差を求める

前述した代表色との距離とコマンドラインから指定した色の距離を求めたいです。

https://qiita.com/shinido/items/2904fa1e9a6c78650b93

RGBで 3次元のユークリッド距離を計算すると良いらしい。
見よう見まねでやる。

[find.rs#L115L129](https://github.com/ekuinox/find-track-by-color/blob/master/src/find.rs#L115L129)

```rust:find.rs
fn diff(a: u8, b: u8) -> f64 {
    let a = a as f64;
    let b = b as f64;
    let a = a / (u8::MAX as f64);
    let b = b / (u8::MAX as f64);
    a - b
}

fn color_diff(Rgb([a_r, a_g, a_b]): &Rgb<u8>, Rgb([b_r, b_g, b_b]): &Rgb<u8>) -> f64 {
    let d_r = diff(*a_r, *b_r);
    let d_g = diff(*a_g, *b_g);
    let d_b = diff(*a_b, *b_b);
    let x = (d_r.powf(2.0) + d_g.powf(2.0) + d_b.powf(2.0)).sqrt() / 3.0f64.sqrt();
    x.abs()
}
```

これでとりあえず距離が出ます。

:::note
タプル構造体のフィールドが使えるのは知っていたけど、それが持つ配列もパターンで取り出せるんだな〜って今回初めて気付いた。
```rust
Rgb([a_r, a_g, a_b]): &Rgb<u8>
```
`if let Ok(Some(v)) = result { }` とかと同じことか

スライスに対しても、絞り込めば使えるっぽい。

```rust
let x = [1, 2, 3];
if let [a, b, c] = x.as_slice() {
  dbg!(a, b, c);
}
```

パターンを `[a, b, c, d]` とかにするとちゃんと通らなかった。
:::

それでは、色差を計算して行ってそれっぽいのを探します。

[find.rs#L58](https://github.com/ekuinox/find-track-by-color/blob/2f837b9247fb1f939f53362c6b7a79b27464524e/src/find.rs#L58)

```rust:find.rs
let tasks = results
    .into_iter()
    .flat_map(|(path, colors)| {
        colors
            .into_iter()
            .filter(|(_, per)| *per >= 0.1)
            .map(|(color, per)| (color_diff(&target_color, &color), per))
            .into_iter()
            .find(|(diff, _)| *diff < self.threshold)
            .map(|(diff, per)| (path, diff, per))
    })
    .flat_map(|(path, diff, per)| {
        track_id_by_image_path(&path).map(|id| (id, path, diff, per))
    })
    .map(|(track_id, path, diff, per)| {
        get_track_with_scores(&self.spotify, track_id.clone(), (track_id, path, diff, per))
    });
let results = futures::future::join_all(tasks).await;
let mut tracks = results.into_iter().flatten().collect::<Vec<_>>();
tracks.sort_by(|(_, (_, _, a, _)), (_, (_, _, b, _))| {
    b.partial_cmp(a).unwrap_or(Ordering::Equal)
});
for (track, (id, path, diff, per)) in tracks {
    println!("{} ... {id}, {path:?}, {diff}, {per}", track.name);
}
```

なんか無茶苦茶読みにくい。~~マジックナンバーあるな〜~~

大体のやっていることは、
1. それぞれの画像が持つ代表色の中で何かしら `-t` で制限した閾値より色差の少ないものがあれば、それを **それっぽい画像** として扱う。
1. それらのトラックIDを元にSpotifyから楽曲名とかを取り出す
1. 色差順に並び替えたものを表示して終了
になります。

:::note alert
色差の小さいものから順に並べたいのにボケて大きいものから順に並べちゃってるぜ...
:::

### ほか

使っているクレートの一部で雑な紹介をします。

- [anyhow](https://crates.io/crates/anyhow) ... エラーを `anyhow::Result` に包んで扱うのを簡単にしてくれるやつです
- [clap](https://crates.io/crates/clap) ... コマンドラインパーサー。 `derive` feature が出てからメチャクチャ便利
- [futures](https://crates.io/crates/futures) ... JavaScript でいう `Promise.all` 的な `join_all` を提供してくれます
- [css-colors](https://crates.io/crates/css-colors) ... CSSの文字列から sRGB へ変換してくれます
- [indicatif](https://crates.io/crates/indicatif) ... CLI で使えるプログレスバーなど、進捗を表示するサムシングを提供してくれます
- [derive-new](https://crates.io/crates/derive-new) ... `#[derive(new)]` した 構造体の `new` を自動で実装してくれます
- [derive_builder](https://crates.io/crates/derive_builder) ... `#[derive(Builder)]` した構造体の `Builder` を実装してくれます
- [kmeans_colors](https://crates.io/crates/kmeans_colors) ... k平均法というのを使って、色のバッファから代表になりそうな色を計算してくれます

おわり