Add support for an Oklab backend; Improve contrast

This commit is contained in:
Agatha Lovelace 2023-10-03 19:32:20 +02:00
parent 5689bff525
commit 93a44c16c1
Signed by: sorceress
GPG Key ID: 01D0B3AB10CED4F8
5 changed files with 547 additions and 478 deletions

788
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "colorpickle"
version = "0.1.1"
version = "0.2.0"
edition = "2021"
authors = ["Agatha V. Lovelace <agatha@technogothic.net>"]
description = "A colorscheme generator"
@ -9,8 +9,10 @@ license = "NVPLv7+"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
pastel = "0.9.0"
clap = { version = "4.4.6", features = ["derive"] }
color-thief = "0.2.2"
miette = { version = "5.7.0", features = ["fancy"] }
image = "0.24.6"
clap = { version = "4.2.1", features = ["derive"] }
image = "0.24.7"
miette = { version = "5.10.0", features = ["fancy"] }
nalgebra = "0.32.3"
okolors = "0.4.0"
pastel = "0.9.0"

View File

@ -21,11 +21,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1694343207,
"narHash": "sha256-jWi7OwFxU5Owi4k2JmiL1sa/OuBCQtpaAesuj5LXC8w=",
"lastModified": 1696234590,
"narHash": "sha256-mgOzQYTvaTT4bFopVOadlndy2RPwLy60rDjIWOGujwo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "78058d810644f5ed276804ce7ea9e82d92bee293",
"rev": "f902cb49892d300ff15cb237e48aa1cad79d68c3",
"type": "github"
},
"original": {
@ -35,11 +35,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1694343207,
"narHash": "sha256-jWi7OwFxU5Owi4k2JmiL1sa/OuBCQtpaAesuj5LXC8w=",
"lastModified": 1696234590,
"narHash": "sha256-mgOzQYTvaTT4bFopVOadlndy2RPwLy60rDjIWOGujwo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "78058d810644f5ed276804ce7ea9e82d92bee293",
"rev": "f902cb49892d300ff15cb237e48aa1cad79d68c3",
"type": "github"
},
"original": {
@ -76,11 +76,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {

View File

@ -1,18 +1,22 @@
use clap::Parser;
use color_thief::ColorFormat;
use image::io::Reader as ImageReader;
use miette::{IntoDiagnostic, Result};
use pastel::ansi::{self, Brush, ToAnsiStyle};
use std::io::{stdout, IsTerminal};
use utils::{generate_palette, validate_color_delta, Backend};
mod utils;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
pub struct Args {
/// Path to image to pick colors from
image: String,
/// Number of colors to generate (excluding bold colors)
#[arg(short, long, default_value_t = 8)]
colors: u8,
/// Path to image to pick colors from
image: String,
/// Generate a light colorscheme
#[arg(short, long)]
light: bool,
/// Skip generating bold color variants
#[arg(short = 'b', long)]
no_bold: bool,
@ -22,61 +26,31 @@ struct Args {
/// Rotate colors along the hue axis
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
rotate_hue: f64,
/// Lighten/darken colors
#[arg(long, default_value_t = 0.0, value_parser = validate_color_delta, allow_hyphen_values = true)]
lighten: f64,
/// Saturate/desaturate colors
#[arg(long, default_value_t = 0.0, value_parser = validate_color_delta, allow_hyphen_values = true)]
saturate: f64,
/// Generate a light colorscheme
#[arg(short, long)]
light: bool,
/// Lighten/darken colors
#[arg(long, default_value_t = 0.0, value_parser = validate_color_delta, allow_hyphen_values = true)]
lighten: f64,
/// Do not darken the background and lighten the foreground colors
#[arg(long)]
no_adjust: bool,
/// Which algorithm to use
#[arg(long, value_enum, rename_all = "PascalCase", default_value_t = Backend::Okolors)]
backend: Backend,
}
fn main() -> Result<()> {
let args = Args::parse();
// Parse image into a list of pixels
let pixels = ImageReader::open(args.image)
.into_diagnostic()?
.decode()
.into_diagnostic()?
.into_bytes();
// Open image file
let image = image::open(&args.image).into_diagnostic()?;
// Generate colorscheme
let mut colors: Vec<_> =
color_thief::get_palette(pixels.as_ref(), ColorFormat::Rgb, 1, args.colors + 1)
.into_diagnostic()?
.into_iter()
.map(|c| pastel::Color::from_rgb(c.r, c.g, c.b))
.collect();
// Sort colors by luminance
colors.sort_by_key(|c| (c.luminance() * 1000.0) as i32);
if !args.no_bold {
// Create second pairs of lighter colors
let bold_colors = colors.clone();
let bold_colors = bold_colors.iter().map(|c| c.lighten(args.bold_delta));
colors.extend(bold_colors);
}
// Apply color transformations, if any
let mut colors: Vec<_> = colors
.into_iter()
.map(|c| c.rotate_hue(args.rotate_hue))
.map(|c| c.lighten(args.lighten))
.map(|c| c.saturate(args.saturate))
.collect();
// Light theme transformations
if args.light {
colors.reverse();
}
let brush = Brush::from_mode(Some(ansi::Mode::TrueColor));
// Generate the colorscheme from image
let colors = generate_palette(&image, &args)?;
// Print colors with formatting turned off for pipes
let brush = Brush::from_mode(Some(ansi::Mode::TrueColor));
colors.iter().for_each(|c| {
println!(
"{}",
@ -90,15 +64,3 @@ fn main() -> Result<()> {
Ok(())
}
/// Make sure that input value is between -1.0 and 1.0
fn validate_color_delta(s: &str) -> Result<f64, String> {
let num = s
.parse()
.map_err(|_| format!("`{s}` is not a valid floating point number"))?;
if (-1.0..=1.0).contains(&num) {
Ok(num)
} else {
Err(format!("{num} is not in range [-1.0,1.0]"))
}
}

123
src/utils.rs Normal file
View File

@ -0,0 +1,123 @@
use clap::ValueEnum;
use color_thief::ColorFormat;
use image::DynamicImage;
use miette::{IntoDiagnostic, Result};
use nalgebra::{matrix, ArrayStorage, Const, Matrix};
use okolors::OklabCounts;
use pastel::{Color, Fraction};
use crate::Args;
#[derive(ValueEnum, Clone, Debug)]
pub enum Backend {
ColorThief,
Okolors,
}
/// Make sure that input value is between -1.0 and 1.0
pub fn validate_color_delta(s: &str) -> Result<f64, String> {
let num = s
.parse()
.map_err(|_| format!("`{s}` is not a valid floating point number"))?;
if (-1.0..=1.0).contains(&num) {
Ok(num)
} else {
Err(format!("{num} is not in range [-1.0,1.0]"))
}
}
pub fn generate_palette(image: &DynamicImage, args: &Args) -> Result<Vec<Color>> {
match args.backend {
Backend::ColorThief => generate_color_thief(image, args),
Backend::Okolors => generate_okolors(image, args),
}
.map(|mut c| transform_colors(&mut c, args))
}
/// Generate a palette using the color-thief algorithm
fn generate_color_thief(image: &DynamicImage, args: &Args) -> Result<Vec<Color>> {
let pixels = image.to_rgb8();
let colors: Vec<_> =
color_thief::get_palette(pixels.as_ref(), ColorFormat::Rgb, 5, args.colors + 1)
.into_diagnostic()?
.into_iter()
.map(|c| Color::from_rgb(c.r, c.g, c.b))
.collect();
Ok(colors)
}
/// Generate a palette using `okolors`
fn generate_okolors(image: &DynamicImage, args: &Args) -> Result<Vec<Color>> {
let oklab = OklabCounts::try_from_image(image, u8::MAX).into_diagnostic()?;
let colors: Vec<_> = okolors::run(&oklab, 5, args.colors, 0.05, 128, 0)
.centroids
.into_iter()
.map(|c| oklab_to_xyz(c.l, c.a, c.b))
.map(|c| Color::from_xyz(c.0 as f64, c.1 as f64, c.2 as f64, 1.0))
.collect();
Ok(colors)
}
fn transform_colors(colors: &mut Vec<Color>, args: &Args) -> Vec<Color> {
// Sort colors by luminance
colors.sort_by_key(|c| (c.luminance() * 1000.0) as i32);
if !args.no_adjust {
// Darken the darkest color
colors[0] = colors[0].mix::<pastel::Lab>(&Color::black(), Fraction::from(0.9));
// Lighten the lightest color
if let Some(c) = colors.last_mut() {
*c = c.mix::<pastel::Lab>(&Color::white(), Fraction::from(0.9))
}
}
if !args.no_bold {
// Create second pairs of lighter colors
let bold_colors = colors.clone();
let bold_colors = bold_colors.iter().map(|c| c.lighten(args.bold_delta));
colors.extend(bold_colors);
}
// Apply color transformations, if any
let mut colors: Vec<_> = colors
.iter_mut()
.map(|c| c.saturate(args.saturate))
.collect();
// Light theme transformations
if args.light {
colors.reverse();
}
colors
}
/// Convert Oklab coordinates to XYZ coordinates according to
/// https://bottosson.github.io/posts/oklab
fn oklab_to_xyz(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
// Pre-computed inversions of the M₁ and M₂ matrices
const M1_INV: Matrix<f32, Const<3>, Const<3>, ArrayStorage<f32, 3, 3>> = matrix![
1.227014, -0.5578, 0.28125617;
-0.04058018, 1.1122569, -0.07167668;
-0.07638129, -0.421482, 1.5861632;
];
const M2_INV: Matrix<f32, Const<3>, Const<3>, ArrayStorage<f32, 3, 3>> = matrix![
1.0000001, 0.3963378, 0.21580376;
1.0, -0.105561346, -0.06385418;
1.0000001, -0.08948418, -1.2914855;
];
let l_m_s_ = M2_INV * matrix![l; a; b];
let lms = l_m_s_.map(|v| v.powi(3));
let xyz = M1_INV * lms;
(xyz.x, xyz.y, xyz.z)
}