5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RustAdvent Calendar 2022

Day 7

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

Last updated at Posted at 2022-12-07

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


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

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

などがあります。

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

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

前提として

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

があります。

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

導入と使い方

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

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

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

.env
RSPOTIFY_CLIENT_ID=
RSPOTIFY_CLIENT_SECRET=
RSPOTIFY_REDIRECT_URI=

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

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

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

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

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

$ 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

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

実装諸々

実装諸々の話です。

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

Spotify のログイン

Spotify の API を利用するために、 rspotify を利用しています。

client::get_clientより

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 より確認できます。

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

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

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

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

prepare::prepare#L20L33

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 を使って進捗をプログレスバーに表示しています。

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

代表色を探す

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

斜め読みします。多分 k平均法 というのを使うのが良い。
それっぽいクレートを探したところ、 kmeans_colors というのがあったので使います。

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

find::FindColors::get_colors

どうにか書いた。

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 から全てのピクセルの色(RGB)を取ってくる
  2. 色データを palette::Lab の配列に変換する
  3. K平均法の計算機(get_kmeans()) に Lab の配列を渡して計算させる
  4. Lab::sort_indexed_colors を呼び出して代表色を輝度の低いものから順に並べてもらう
  5. 実装の都合上(呼び出し元の都合)で、これを image::Rgb へ変換していきます。
  6. 代表色を画像に占めた割合の高いものからソートし直す

Lab が何を指すのかわからなくてドキュメントから Lab色空間 に行き着いたけど、RGB とかとは別の色空間ということらしい。

Lab色空間は人間の視覚を近似するよう設計されている。

そっか〜

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

find.rs#L36L52

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 で並列にやってもらいます。

色差を求める

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

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

find.rs#L115L129

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()
}

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

タプル構造体のフィールドが使えるのは知っていたけど、それが持つ配列もパターンで取り出せるんだな〜って今回初めて気付いた。

Rgb([a_r, a_g, a_b]): &Rgb<u8>

if let Ok(Some(v)) = result { } とかと同じことか

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

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

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

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

find.rs#L58

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 で制限した閾値より色差の少ないものがあれば、それを それっぽい画像 として扱う。
  2. それらのトラックIDを元にSpotifyから楽曲名とかを取り出す
  3. 色差順に並び替えたものを表示して終了
    になります。

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

ほか

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

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

おわり

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?