Skip to content
Snippets Groups Projects
palette.rs 5.49 KiB
use clap::Parser;
use std::cmp::{min, Ordering};
use std::collections::hash_map::RandomState;
use std::collections::{HashMap, HashSet};

use anyhow::Error;
use deltae::{Delta, LabValue, DE2000};
use image::{GenericImage, Pixel, Rgba};
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
use std::io::Write;

use crate::format::PaletteFormat;
use crate::utils::{new_image, BasicRgba};

pub type PixelPalette = Vec<BasicRgba>;
pub type ColourMapping = HashMap<BasicRgba, BasicRgba>;

pub fn sort_by_hue(palette: &mut PixelPalette) {
	palette.sort_by(|pa, pb| {
		let hue_a = pa.hue();
		let hue_b = pb.hue();

		if hue_a.is_nan() && hue_b.is_nan() {
			Ordering::Equal
		} else if hue_a.is_nan() {
			Ordering::Less
		} else if hue_b.is_nan() || hue_a > hue_b {
			Ordering::Greater
		} else if hue_b > hue_a {
			Ordering::Less
		} else {
			Ordering::Equal
		}
	});
}

/// Create a palette file containing every distinct colour from the input image
#[derive(Parser, Clone, Serialize, Deserialize, Debug)]
#[clap(author, version = "0.9.0")]
pub struct Palette {
	/// The path to the image file
	#[serde(default)]
	pub input: String,
	/// The path to write the palette data
	#[serde(default)]
	pub output: String,

	/// The format for the palette file. PNG will output an image with colours, while TXT will
	/// output a text file with one hex value per line
	#[clap(short, long, value_enum, default_value_t)]
	#[serde(default)]
	format: PaletteFormat,
}

impl Palette {
	pub fn run(&self, image: &impl GenericImage) -> anyhow::Result<()> {
		let palette = Palette::extract_from(image)?;
		self.write_palette(palette)
	}

	pub fn extract_from(image: &impl GenericImage) -> anyhow::Result<PixelPalette> {
		let mut colours = HashSet::new();
		for (_, _, pixel) in image.pixels() {
			let pixel = pixel.to_rgba();
			colours.insert(Rgba::from([
				pixel.0[0].to_u8().unwrap(),
				pixel.0[1].to_u8().unwrap(),
				pixel.0[2].to_u8().unwrap(),
				pixel.0[3].to_u8().unwrap(),
			]));
		}

		Ok(colours.iter().map(BasicRgba::from).collect())
	}

	pub fn write_palette(&self, mut colours: PixelPalette) -> anyhow::Result<()> {
		sort_by_hue(&mut colours);

		match self.format {
			PaletteFormat::Png => {
				let num_colours = colours.len();
				let image_width = min(16, num_colours);
				let image_height = if num_colours % 16 > 0 {
					num_colours / image_width + 1
				} else {
					num_colours / image_width
				};

				let mut out_image = new_image(image_width as u32, image_height as u32);
				for (idx, colour) in colours.iter().enumerate() {
					out_image.put_pixel(
						(idx % image_width) as u32,
						(idx / image_width) as u32,
						Rgba::from(colour),
					);
				}

				out_image.save(self.output.as_str())?;
			}
			PaletteFormat::Columns => {
				let image_width = 2;
				let image_height = colours.len();
				let mut out_image = new_image(image_width as u32, image_height as u32);

				for (idx, colour) in colours.iter().enumerate() {
					out_image.put_pixel(0, idx as u32, Rgba::from(colour));
					out_image.put_pixel(1, idx as u32, Rgba::from(colour));
				}

				out_image.save(self.output.as_str())?;
			}
			PaletteFormat::Txt => {
				let mut file = std::fs::File::create(self.output.as_str())?;
				for colour in colours.iter() {
					let line = format!("#{:X}\n", colour);
					file.write_all(line.as_bytes())?;
				}
			}
		}

		Ok(())
	}

	pub fn from_columns(image: &impl GenericImage) -> anyhow::Result<ColourMapping> {
		if image.width() != 2 {
			return Err(Error::msg("Image must have a pixel width of 2"))?;
		}

		let mut mapping = ColourMapping::with_capacity(image.height() as usize);
		for y in 0..image.height() {
			let left = image.get_pixel(0, y);
			let right = image.get_pixel(1, y);
			let left = left.to_rgba();
			let right = right.to_rgba();
			let data = Rgba::from([
				left.0[0].to_u8().unwrap(),
				left.0[1].to_u8().unwrap(),
				left.0[2].to_u8().unwrap(),
				left.0[3].to_u8().unwrap(),
			]);
			let left_pixel = BasicRgba::from(data);
			let data = Rgba::from([
				right.0[0].to_u8().unwrap(),
				right.0[1].to_u8().unwrap(),
				right.0[2].to_u8().unwrap(),
				right.0[3].to_u8().unwrap(),
			]);
			let right_pixel = BasicRgba::from(data);
			mapping.insert(left_pixel, right_pixel);
		}

		Ok(mapping)
	}

	pub fn calculate_mapping(from: &PixelPalette, to: &PixelPalette) -> ColourMapping {
		let colour_labs = Vec::from_iter(to.iter().map(LabValue::from));

		let to_palette_vectors: HashMap<usize, &BasicRgba, RandomState> =
			HashMap::from_iter(to.iter().enumerate());
		let mut out_map: ColourMapping = HashMap::with_capacity(from.len());

		for colour in from {
			let closest = to_palette_vectors
				.keys()
				.fold(None, |lowest, idx| match lowest {
					Some(num) => {
						let current = colour_labs[*idx];
						let previous: LabValue = colour_labs[num];

						if colour.delta(current, DE2000) < colour.delta(previous, DE2000) {
							Some(*idx)
						} else {
							Some(num)
						}
					}
					None => Some(*idx),
				});

			match closest {
				Some(idx) => match to_palette_vectors.get(&idx) {
					Some(col) => {
						out_map.insert(*colour, **col);
					}
					None => {
						log::warn!("No matching set for {} with col {:?}", idx, &colour);

						out_map.insert(
							*colour,
							BasicRgba {
								r: 0,
								g: 0,
								b: 0,
								a: 0,
							},
						);
					}
				},
				None => {
					log::warn!("No closest for {:?}", &colour);
					out_map.insert(
						*colour,
						BasicRgba {
							r: 0,
							g: 0,
							b: 0,
							a: 0,
						},
					);
				}
			}
		}

		out_map
	}
}