この記事は 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
というファイル名で保存します。
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, 画像のパス, 色差, 色が画像に占める割合?
という形式で、色差の大きいものから順(逆じゃん)に表示しています。
実際にサムネを見にいくとこんな感じ...
まあ大体ピンクだと思う。 Spotify もピンク背景にしてるし。これを API で取得できたら気が楽なんですが、探した限りは提供されていなさそうだった。
実装諸々
実装諸々の話です。
記事書くネタがなくって慌てて実装したから、めちゃくちゃ間違い多いと思います。
もう間違い探しなので、ツッコんでください!!!!
Spotify のログイン
Spotify の API を利用するために、 rspotify を利用しています。
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_cached
を true
にしておくと、ファイルにアクセストークンだとかを残してくれます。
let url = spotify.get_authorize_url(None)?;
spotify.prompt_for_token(&url).await?;
ブラウザで承認ページが開かれて、リダイレクトURLを標準入力に貼り付けると認証完了というのは、 prompt_for_token
が行ってくれます。これは rspotify の cli
feature を有効にしておく必要があります。
サムネ画像のダウンロード
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
はライブラリと一緒に実行バイナリも同梱しているようです。
じゃあ、その実行バイナリがやっていることをとにかく真似するしかありません。
どうにか書いた。
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
}
流れとしては、
- image::DynamicImage から全てのピクセルの色(RGB)を取ってくる
- 色データを palette::Lab の配列に変換する
- K平均法の計算機(
get_kmeans()
) にLab
の配列を渡して計算させる - Lab::sort_indexed_colors を呼び出して代表色を輝度の低いものから順に並べてもらう
- 実装の都合上(呼び出し元の都合)で、これを image::Rgb へ変換していきます。
- 代表色を画像に占めた割合の高いものからソートし直す
それではディレクトリ内の各画像に対して、代表色を計算していきます。
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次元のユークリッド距離を計算すると良いらしい。
見よう見まねでやる。
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]
とかにするとちゃんと通らなかった。
それでは、色差を計算して行ってそれっぽいのを探します。
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);
}
なんか無茶苦茶読みにくい。マジックナンバーあるな〜
大体のやっていることは、
- それぞれの画像が持つ代表色の中で何かしら
-t
で制限した閾値より色差の少ないものがあれば、それを それっぽい画像 として扱う。 - それらのトラックIDを元にSpotifyから楽曲名とかを取り出す
- 色差順に並び替えたものを表示して終了
になります。
色差の小さいものから順に並べたいのにボケて大きいものから順に並べちゃってるぜ...
ほか
使っているクレートの一部で雑な紹介をします。
-
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平均法というのを使って、色のバッファから代表になりそうな色を計算してくれます
おわり