add vibrant color detection

This commit is contained in:
Daniel Bulant 2024-10-02 22:41:34 +02:00
parent 721576aa50
commit b45739b2e0
No known key found for this signature in database
9 changed files with 445 additions and 7 deletions

21
Cargo.lock generated
View file

@ -276,9 +276,13 @@ dependencies = [
name = "barrs"
version = "0.1.0"
dependencies = [
"color_quant",
"cushy",
"hsl",
"image",
"itertools 0.10.5",
"mpris",
"palette",
"plotters",
"reqwest",
"tokio",
@ -1330,6 +1334,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hsl"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575fb7f1167f3b88ed825e90eb14918ac460461fdeaa3965c6a50951dee1c970"
[[package]]
name = "http"
version = "1.1.0"
@ -1542,6 +1552,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.12.1"
@ -2760,7 +2779,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
"itertools",
"itertools 0.12.1",
"libc",
"libfuzzer-sys",
"log",

View file

@ -9,4 +9,8 @@ tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread"] }
plotters = { version = "0.3.7", default-features = false }
image = { version = "0.25.0", features = ["png"] }
mpris = "2.0.1"
reqwest = "0.12.8"
reqwest = "0.12.8"
color_quant = "1.0"
hsl = "0.1.1"
itertools = "0.10.0"
palette = "0.7.3"

View file

@ -1,6 +1,7 @@
use cushy::{Open, PendingApp, Run, TokioRuntime};
mod spotify;
mod vibrancy;
fn main() -> cushy::Result {
let mut app = PendingApp::new(TokioRuntime::default());

View file

@ -1,10 +1,12 @@
use std::{sync::{Arc, Mutex}, thread, time::Duration};
use mpris::{LoopStatus, PlaybackStatus, PlayerFinder};
use cushy::{kludgine::{AnyTexture, LazyTexture}, styles::components::WidgetBackground, value::{Destination, Dynamic, IntoReader, Source}, widget::MakeWidget, widgets::Image, Open, PendingApp};
use cushy::{kludgine::{AnyTexture, LazyTexture}, styles::{components::WidgetBackground, Color}, value::{Destination, Dynamic, IntoReader, Source}, widget::MakeWidget, widgets::Image, Open, PendingApp};
use palette::Srgb;
use reqwest::Client;
use image;
use image::{self, Rgb};
use tokio::{runtime, task::JoinHandle};
use crate::vibrancy::Vibrancy;
#[derive(PartialEq)]
struct PlayingTrack {
@ -22,7 +24,7 @@ struct PlayingTrack {
pub fn spotify_controls() -> impl MakeWidget {
let (progress, track) = get_track_dynamics();
let texture = get_texture_dynamic(track.clone());
let (texture, vibrancy) = get_texture_dynamic(track.clone());
track.map_each(|track| {
if let Some(track) = track {
@ -36,10 +38,14 @@ pub fn spotify_controls() -> impl MakeWidget {
}
})
.to_label()
.centered()
.pad()
.and(
Image::new(texture)
)
.into_rows()
.with(&WidgetBackground, vibrancy.map_each(|vib| vib.dark.unwrap_or(Color::BLACK).into()))
.pad()
}
fn get_empty_texture() -> AnyTexture {
@ -83,15 +89,27 @@ fn tokio_runtime() -> &'static runtime::Handle {
})
}
fn get_texture_dynamic(track: Dynamic<Option<PlayingTrack>>) -> Dynamic<AnyTexture> {
#[derive(Debug, PartialEq, Eq, Default)]
pub struct ImageVibrancy {
primary: Option<Color>,
dark: Option<Color>,
light: Option<Color>,
muted: Option<Color>,
dark_muted: Option<Color>,
light_muted: Option<Color>,
}
fn get_texture_dynamic(track: Dynamic<Option<PlayingTrack>>) -> (Dynamic<AnyTexture>, Dynamic<ImageVibrancy>) {
let client = Client::new();
let texture = Dynamic::new(get_empty_texture());
let vibrancy = Dynamic::new(ImageVibrancy::default());
let prev_request_join = Arc::new(Mutex::new(None::<JoinHandle<()>>));
track.for_each({
let texture = texture.clone();
let vibrancy = vibrancy.clone();
move |track| {
if let Some(track) = track {
let mut prev_request_join = prev_request_join.lock().unwrap();
@ -99,12 +117,22 @@ fn get_texture_dynamic(track: Dynamic<Option<PlayingTrack>>) -> Dynamic<AnyTextu
prev_request_join.abort();
}
let texture = texture.clone();
let vibrancy = vibrancy.clone();
let client = client.clone();
let track_url = track.album_art.clone().unwrap();
*prev_request_join = Some(tokio_runtime().spawn(async move {
let response = client.get(track_url).send().await.unwrap();
let bytes = response.bytes().await.unwrap();
let image = image::load_from_memory(&bytes).unwrap();
let image_vibrancy = Vibrancy::new(&image);
vibrancy.set(ImageVibrancy {
primary: image_vibrancy.primary.map(|c| rgb_to_color(c)),
dark: image_vibrancy.dark.map(|c| rgb_to_color(c)),
light: image_vibrancy.light.map(|c| rgb_to_color(c)),
muted: image_vibrancy.muted.map(|c| rgb_to_color(c)),
dark_muted: image_vibrancy.dark_muted.map(|c| rgb_to_color(c)),
light_muted: image_vibrancy.light_muted.map(|c| rgb_to_color(c)),
});
let image_texture = LazyTexture::from_image(image, cushy::kludgine::wgpu::FilterMode::Linear);
let image_texture = AnyTexture::Lazy(image_texture);
texture.map_mut(move |mut t| *t = image_texture);
@ -112,12 +140,17 @@ fn get_texture_dynamic(track: Dynamic<Option<PlayingTrack>>) -> Dynamic<AnyTextu
}));
} else {
texture.map_mut(move |mut t| *t = get_empty_texture());
vibrancy.set(ImageVibrancy::default());
// texture.set(empty_texture);
}
}
}).persist();
texture
(texture, vibrancy)
}
fn rgb_to_color(rgb: Rgb<u8>) -> Color {
Color::new(rgb[0], rgb[1], rgb[2], 255)
}
/// This spawns a new thread to track the current playing track and its progress.

1
src/vibrancy/README.md Normal file
View file

@ -0,0 +1 @@
Adapted from https://github.com/killercup/vibrant-rs. MIT License

7
src/vibrancy/mod.rs Normal file
View file

@ -0,0 +1,7 @@
mod settings;
mod palette;
mod vibrant;
pub use vibrant::Vibrancy;
pub use palette::Palette;

114
src/vibrancy/palette.rs Normal file
View file

@ -0,0 +1,114 @@
use std::fmt;
use std::collections::BTreeMap;
use itertools::Itertools;
use image::{GenericImage, Pixel, Rgb, Rgba};
use color_quant::NeuQuant;
/// Palette of colors.
#[derive(Debug, Hash, PartialEq, Eq, Default)]
pub struct Palette {
/// Palette of Colors represented in RGB
pub palette: Vec<Rgb<u8>>,
/// A map of indices in the palette to a count of pixels in approximately that color in the
/// original image.
pub pixel_counts: BTreeMap<usize, usize>,
}
impl Palette {
/// Create a new palett from an image
///
/// Color count and quality are given straight to [color_quant], values should be between
/// 8...512 and 1...30 respectively. (By the way: 10 is a good default quality.)
///
/// [color_quant]: https://github.com/PistonDevelopers/color_quant
pub fn new<P, G>(image: &G, color_count: usize, quality: i32) -> Palette
where P: Sized + Pixel<Subpixel = u8>,
G: Sized + GenericImage<Pixel = P>
{
let pixels: Vec<Rgba<u8>> = image.pixels()
.map(|(_, _, pixel)| pixel.to_rgba())
.collect();
let mut flat_pixels: Vec<u8> = Vec::with_capacity(pixels.len());
for rgba in &pixels {
if is_boring_pixel(&rgba) {
continue;
}
for subpixel in rgba.channels() {
flat_pixels.push(*subpixel);
}
}
let quant = NeuQuant::new(quality, color_count, &flat_pixels);
let pixel_counts = pixels.iter()
.map(|rgba| quant.index_of(&rgba.channels()))
.fold(BTreeMap::new(),
|mut acc, pixel| {
*acc.entry(pixel).or_insert(0) += 1;
acc
});
let palette: Vec<Rgb<u8>> = quant.color_map_rgba()
.iter()
.chunks(4)
.into_iter()
.map(|rgba_iter| {
let rgba_slice: Vec<u8> = rgba_iter.cloned().collect();
Rgba::from_slice(&rgba_slice).clone().to_rgb()
})
.unique()
.collect();
Palette {
palette: palette,
pixel_counts: pixel_counts,
}
}
fn frequency_of(&self, color: &Rgb<u8>) -> usize {
let index = self.palette.iter().position(|x| x.channels() == color.channels());
if let Some(index) = index {
*self.pixel_counts.get(&index).unwrap_or(&0)
} else {
0
}
}
/// Change ordering of colors in palette to be of frequency using the pixel count.
pub fn sort_by_frequency(&self) -> Self {
let mut colors = self.palette.clone();
colors.sort_by(|a, b| self.frequency_of(&a).cmp(&self.frequency_of(&b)));
Palette {
palette: colors,
pixel_counts: self.pixel_counts.clone(),
}
}
}
fn is_boring_pixel(pixel: &Rgba<u8>) -> bool {
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
// If pixel is mostly opaque and not white
const MIN_ALPHA: u8 = 125;
const MAX_COLOR: u8 = 250;
let interesting = (a >= MIN_ALPHA) && !(r > MAX_COLOR && g > MAX_COLOR && b > MAX_COLOR);
!interesting
}
impl fmt::Display for Palette {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use itertools::Itertools;
let color_list = self.palette
.iter()
.map(|rgb| format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]))
.join(", ");
write!(f, "Color Palette {{ {} }}", color_list)
}
}

21
src/vibrancy/settings.rs Normal file
View file

@ -0,0 +1,21 @@
#![allow(dead_code)]
pub const TARGET_DARK_LUMA: f64 = 0.26;
pub const MAX_DARK_LUMA: f64 = 0.45;
pub const MIN_LIGHT_LUMA: f64 = 0.55;
pub const TARGET_LIGHT_LUMA: f64 = 0.74;
pub const MIN_NORMAL_LUMA: f64 = 0.3;
pub const TARGET_NORMAL_LUMA: f64 = 0.5;
pub const MAX_NORMAL_LUMA: f64 = 0.7;
pub const TARGET_MUTED_SATURATION: f64 = 0.3;
pub const MAX_MUTED_SATURATION: f64 = 0.4;
pub const TARGET_VIBRANT_SATURATION: f64 = 1.0;
pub const MIN_VIBRANT_SATURATION: f64 = 0.35;
pub const WEIGHT_SATURATION: f64 = 3.0;
pub const WEIGHT_LUMA: f64 = 6.0;
pub const WEIGHT_POPULATION: f64 = 1.0;

238
src/vibrancy/vibrant.rs Normal file
View file

@ -0,0 +1,238 @@
use std::fmt;
use std::collections::BTreeMap;
use image::{GenericImage, Pixel, Rgb};
use hsl::HSL;
// tf is rust on?????
use crate::vibrancy::settings;
// I have no idea why it's imported like this
use super::palette::Palette;
/// Vibrancy
///
/// 6 vibrant colors: primary, dark, light, dark muted and light muted.
#[derive(Debug, Hash, PartialEq, Eq, Default)]
pub struct Vibrancy {
pub primary: Option<Rgb<u8>>,
pub dark: Option<Rgb<u8>>,
pub light: Option<Rgb<u8>>,
pub muted: Option<Rgb<u8>>,
pub dark_muted: Option<Rgb<u8>>,
pub light_muted: Option<Rgb<u8>>,
}
impl Vibrancy {
/// Create new vibrancy map from an image
pub fn new<P, G>(image: &G) -> Vibrancy
where P: Sized + Pixel<Subpixel = u8>,
G: Sized + GenericImage<Pixel = P>
{
generate_varation_colors(&Palette::new(image, 256, 10))
}
fn color_already_set(&self, color: &Rgb<u8>) -> bool {
let color = Some(*color);
self.primary == color || self.dark == color || self.light == color ||
self.muted == color || self.dark_muted == color || self.light_muted == color
}
fn find_color_variation(&self,
palette: &[Rgb<u8>],
pixel_counts: &BTreeMap<usize, usize>,
luma: &MTM<f64>,
saturation: &MTM<f64>)
-> Option<Rgb<u8>> {
let mut max = None;
let mut max_value = 0_f64;
let complete_population = pixel_counts.values().fold(0, |acc, c| acc + c);
for (index, swatch) in palette.iter().enumerate() {
let HSL {h: _, s, l} = HSL::from_rgb(swatch.channels());
if s >= saturation.min && s <= saturation.max && l >= luma.min && l <= luma.max &&
!self.color_already_set(swatch) {
let population = *pixel_counts.get(&index).unwrap_or(&0) as f64;
if population == 0_f64 {
continue;
}
let value = create_comparison_value(s,
saturation.target,
l,
luma.target,
population,
complete_population as f64);
if max.is_none() || value > max_value {
max = Some(swatch.clone());
max_value = value;
}
}
}
max
}
// fn fill_empty_swatches(self) {
// if self.primary.is_none() {
// // If we do not have a vibrant color...
// if let Some(dark) = self.dark {
// // ...but we do have a dark vibrant, generate the value by modifying the luma
// let hsl = HSL::from_pixel(&dark).clone()
// hsl.l = settings::TARGET_NORMAL_LUMA;
// }
// }
// }
}
impl fmt::Display for Vibrancy {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
(write!(f, "Vibrant Colors {{\n"))?;
macro_rules! display_color {
($formatter:expr, $name:expr, $color:expr) => {
{
(write!($formatter, "\t"))?;
(write!($formatter, $name))?;
if let Some(c) = $color {
let rgb = c.channels();
(write!($formatter,
" Color: #{:02X}{:02X}{:02X}\n",
rgb[0], rgb[1], rgb[2]
))?;
} else {
(write!($formatter, " Color: None\n"))?;
}
}
};
}
display_color!(f, "Primary Vibrant", self.primary);
display_color!(f, "Dark Vibrant", self.dark);
display_color!(f, "Light Vibrant", self.light);
display_color!(f, "Muted", self.muted);
display_color!(f, "Dark Muted", self.dark_muted);
display_color!(f, "Light Muted", self.light_muted);
write!(f, "}}")
}
}
fn generate_varation_colors(p: &Palette) -> Vibrancy {
let mut vibrancy = Vibrancy::default();
vibrancy.primary =
vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: settings::MIN_NORMAL_LUMA,
target: settings::TARGET_NORMAL_LUMA,
max: settings::MAX_NORMAL_LUMA,
},
&MTM {
min: settings::MIN_VIBRANT_SATURATION,
target: settings::TARGET_VIBRANT_SATURATION,
max: 1_f64,
});
vibrancy.light = vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: settings::MIN_LIGHT_LUMA,
target: settings::TARGET_LIGHT_LUMA,
max: 1_f64,
},
&MTM {
min: settings::MIN_VIBRANT_SATURATION,
target: settings::TARGET_VIBRANT_SATURATION,
max: 1_f64,
});
vibrancy.dark = vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: 0_f64,
target: settings::TARGET_DARK_LUMA,
max: settings::MAX_DARK_LUMA,
},
&MTM {
min: settings::MIN_VIBRANT_SATURATION,
target: settings::TARGET_VIBRANT_SATURATION,
max: 1_f64,
});
vibrancy.muted = vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: settings::MIN_NORMAL_LUMA,
target: settings::TARGET_NORMAL_LUMA,
max: settings::MAX_NORMAL_LUMA,
},
&MTM {
min: 0_f64,
target: settings::TARGET_MUTED_SATURATION,
max: settings::MAX_MUTED_SATURATION,
});
vibrancy.light_muted = vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: settings::MIN_LIGHT_LUMA,
target: settings::TARGET_LIGHT_LUMA,
max: 1_f64,
},
&MTM {
min: 0_f64,
target: settings::TARGET_MUTED_SATURATION,
max: settings::MAX_MUTED_SATURATION,
});
vibrancy.dark_muted = vibrancy.find_color_variation(&p.palette,
&p.pixel_counts,
&MTM {
min: 0_f64,
target: settings::TARGET_DARK_LUMA,
max: settings::MAX_DARK_LUMA,
},
&MTM {
min: 0_f64,
target: settings::TARGET_MUTED_SATURATION,
max: settings::MAX_MUTED_SATURATION,
});
vibrancy
}
fn invert_diff(val: f64, target_val: f64) -> f64 {
1_f64 - (val - target_val).abs()
}
fn weighted_mean(vals: &[(f64, f64)]) -> f64 {
let (sum, sum_weight) = vals.iter().fold((0_f64, 0_f64),
|(sum, sum_weight), &(val, weight)| {
(sum + val * weight, sum_weight + weight)
});
sum / sum_weight
}
fn create_comparison_value(sat: f64,
target_sat: f64,
luma: f64,
target_uma: f64,
population: f64,
max_population: f64)
-> f64 {
weighted_mean(&[(invert_diff(sat, target_sat),
settings::WEIGHT_SATURATION),
(invert_diff(luma, target_uma), settings::WEIGHT_LUMA),
(population / max_population,
settings::WEIGHT_POPULATION)])
}
/// Minimum, Maximum, Target
#[derive(Debug, Hash)]
struct MTM<T> {
min: T,
target: T,
max: T,
}