Introduce indexed color with the PICO-8 palette
This commit is contained in:
Generated
+10
@@ -242,6 +242,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"image",
|
"image",
|
||||||
|
"ordered-float",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -642,6 +643,15 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-float"
|
||||||
|
version = "4.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4a91171844676f8c7990ce64959210cd2eaef32c2612c50f9fae9f8aaa6065a6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2021"
|
|||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
clap = { version = "4.5.15", features = ["derive"] }
|
clap = { version = "4.5.15", features = ["derive"] }
|
||||||
image = "0.25.2"
|
image = "0.25.2"
|
||||||
|
ordered-float = "4.2.2"
|
||||||
|
|
||||||
[profile.dev.package.zune-jpeg]
|
[profile.dev.package.zune-jpeg]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
use image::{Rgb, RgbImage};
|
use image::{Rgb, RgbImage};
|
||||||
|
|
||||||
use crate::redmean_sq;
|
use crate::indexed::redmean_sq;
|
||||||
|
|
||||||
pub type Color = [u8; 3]; // TODO: Convert to indexed
|
pub type Color = [u8; 3]; // TODO: Convert to indexed
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use image::RgbImage;
|
||||||
|
use ordered_float::NotNan;
|
||||||
|
|
||||||
|
// pub fn euclid_sq(x: [u8; 3], y: [u8; 3]) -> f32 {
|
||||||
|
// let mut result = 0.0;
|
||||||
|
// for i in 0..3 {
|
||||||
|
// let delta = x[i] as f32 - y[i] as f32;
|
||||||
|
// result += delta * delta;
|
||||||
|
// }
|
||||||
|
// result
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn redmean_sq(x: [u8; 3], y: [u8; 3]) -> f32 {
|
||||||
|
let mut ds: [f32; 3] = [0.0; 3];
|
||||||
|
for (i, d) in ds.iter_mut().enumerate() {
|
||||||
|
let delta = x[i] as f32 - y[i] as f32;
|
||||||
|
*d = delta * delta;
|
||||||
|
}
|
||||||
|
let rm = (x[0] as f32 + y[0] as f32) / 2.0;
|
||||||
|
let result = (2.0 + rm / 256.0) * ds[0] + 4.0 * ds[1] + (2.0 + (255.0 - rm) / 256.0) * ds[2];
|
||||||
|
// normalize value to the range 0.0 - 1.0
|
||||||
|
result / 584971.0
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rgb = [u8; 3];
|
||||||
|
|
||||||
|
const PICO8_PALETTE: [Rgb; 32] = [
|
||||||
|
// 0-15: standard palette
|
||||||
|
[0x00, 0x00, 0x00],
|
||||||
|
[0x1d, 0x2b, 0x53],
|
||||||
|
[0x7e, 0x25, 0x53],
|
||||||
|
[0x00, 0x87, 0x51],
|
||||||
|
[0xab, 0x52, 0x36],
|
||||||
|
[0x5f, 0x57, 0x4f],
|
||||||
|
[0xc2, 0xc3, 0xc7],
|
||||||
|
[0xff, 0xf1, 0xe8],
|
||||||
|
[0xff, 0x00, 0x4d],
|
||||||
|
[0xff, 0xa3, 0x00],
|
||||||
|
[0xff, 0xec, 0x27],
|
||||||
|
[0x00, 0xe4, 0x36],
|
||||||
|
[0x29, 0xad, 0xff],
|
||||||
|
[0x83, 0x76, 0x9c],
|
||||||
|
[0xff, 0x77, 0xa8],
|
||||||
|
[0xff, 0xcc, 0xaa],
|
||||||
|
// 16-31: secret palette
|
||||||
|
// In PICO-8, these indexes start at 128
|
||||||
|
[0x29, 0x18, 0x14],
|
||||||
|
[0x11, 0x1d, 0x35],
|
||||||
|
[0x42, 0x21, 0x36],
|
||||||
|
[0x12, 0x53, 0x59],
|
||||||
|
[0x74, 0x2f, 0x29],
|
||||||
|
[0x49, 0x33, 0x3b],
|
||||||
|
[0xa2, 0x88, 0x79],
|
||||||
|
[0xf3, 0xef, 0x7d],
|
||||||
|
[0xbe, 0x12, 0x50],
|
||||||
|
[0xff, 0x6c, 0x24],
|
||||||
|
[0xa8, 0xe7, 0x2e],
|
||||||
|
[0x00, 0xb5, 0x43],
|
||||||
|
[0x06, 0x5a, 0xb5],
|
||||||
|
[0x75, 0x46, 0x65],
|
||||||
|
[0xff, 0x6e, 0x59],
|
||||||
|
[0xff, 0x9d, 0x81],
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PicoColor(u8);
|
||||||
|
|
||||||
|
impl PicoColor {
|
||||||
|
pub fn to_rgb(self) -> Rgb {
|
||||||
|
let idx = ((self.0 & 0x80) >> 3) | (self.0 & 0x0F);
|
||||||
|
PICO8_PALETTE[idx as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PicoImage {
|
||||||
|
pub palette: [PicoColor; 16],
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PicoImage {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
let mut palette = [PicoColor(0); 16];
|
||||||
|
for (i, color) in palette.iter_mut().enumerate() {
|
||||||
|
*color = PicoColor(i as u8);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
palette,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data: vec![0; (width * height) as usize],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_indexed(img: &RgbImage) -> Self {
|
||||||
|
// Brute-force nearest-color conversion
|
||||||
|
// TODO begin using this everywhere
|
||||||
|
let mut result = Self::new(img.width(), img.height());
|
||||||
|
for y in 0..img.height() {
|
||||||
|
let offset = (y * img.width()) as usize;
|
||||||
|
for x in 0..img.width() {
|
||||||
|
let original = img.get_pixel(x, y).0;
|
||||||
|
let best = (0..16)
|
||||||
|
.min_by_key(|&i| {
|
||||||
|
let dist_sq = redmean_sq(original, PicoColor(i).to_rgb());
|
||||||
|
NotNan::new(dist_sq).unwrap()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
result.data[offset + x as usize] = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, x: u32, y: u32) -> u8 {
|
||||||
|
self.data[(y * self.width + x) as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_rgb(&self) -> RgbImage {
|
||||||
|
let mut result = RgbImage::new(self.width, self.height);
|
||||||
|
for y in 0..self.height {
|
||||||
|
for x in 0..self.width {
|
||||||
|
let index = self.get(x, y);
|
||||||
|
let color = self.palette[index as usize];
|
||||||
|
result.put_pixel(x, y, image::Rgb(color.to_rgb()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_rgb() {
|
||||||
|
let to_rgb = |i| PicoColor(i).to_rgb();
|
||||||
|
|
||||||
|
// Regular palette
|
||||||
|
assert_eq!(to_rgb(0), [0, 0, 0]);
|
||||||
|
assert_eq!(to_rgb(1), PICO8_PALETTE[1]);
|
||||||
|
assert_eq!(to_rgb(2), to_rgb(0x12));
|
||||||
|
assert_eq!(to_rgb(3), to_rgb(0x73));
|
||||||
|
|
||||||
|
// Secret palette
|
||||||
|
assert_eq!(to_rgb(128), PICO8_PALETTE[16]);
|
||||||
|
assert_eq!(to_rgb(128 + 15), PICO8_PALETTE[31]);
|
||||||
|
assert_eq!(to_rgb(128 + 16), PICO8_PALETTE[16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quantization_round_trip() {
|
||||||
|
for i in 0..16 {
|
||||||
|
let mut img = RgbImage::new(1, 1);
|
||||||
|
let input_color = PICO8_PALETTE[i];
|
||||||
|
img.put_pixel(0, 0, image::Rgb(input_color));
|
||||||
|
let indexed = PicoImage::to_indexed(&img);
|
||||||
|
let img = indexed.to_rgb();
|
||||||
|
let output_color = img.get_pixel(0, 0).0;
|
||||||
|
assert_eq!(input_color, output_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-12
@@ -1,9 +1,11 @@
|
|||||||
mod encode;
|
mod encode;
|
||||||
|
mod indexed;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use encode::{choose_encoding, Region};
|
use encode::{choose_encoding, Region};
|
||||||
use image::{DynamicImage, RgbImage};
|
use image::{DynamicImage, RgbImage};
|
||||||
|
use indexed::PicoImage;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command()]
|
#[command()]
|
||||||
@@ -32,10 +34,17 @@ fn main() -> Result<()> {
|
|||||||
output.put_pixel(x, y, *pixel);
|
output.put_pixel(x, y, *pixel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lossy compression
|
||||||
let error_scale_factor = 0.2; // Adjusted by feel. Q=0 is unrecognizable but not blank.
|
let error_scale_factor = 0.2; // Adjusted by feel. Q=0 is unrecognizable but not blank.
|
||||||
let error_per_pixel = (1.0 - args.max_err.unwrap_or(80.0) / 100.0) * error_scale_factor;
|
let error_per_pixel = (1.0 - args.max_err.unwrap_or(80.0) / 100.0) * error_scale_factor;
|
||||||
let sq_error_per_pixel = error_per_pixel * error_per_pixel;
|
let sq_error_per_pixel = error_per_pixel * error_per_pixel;
|
||||||
quadtree_quant(&mut output, sq_error_per_pixel * 128.0 * 128.0);
|
quadtree_quant(&mut output, sq_error_per_pixel * 128.0 * 128.0);
|
||||||
|
|
||||||
|
// Convert to indexed
|
||||||
|
let indexed = PicoImage::to_indexed(&output);
|
||||||
|
output = indexed.to_rgb();
|
||||||
|
|
||||||
output.save(&args.output)?;
|
output.save(&args.output)?;
|
||||||
println!("Wrote {}", &args.output);
|
println!("Wrote {}", &args.output);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -55,15 +64,3 @@ fn quadtree_quant(img: &mut RgbImage, max_err: f32) {
|
|||||||
);
|
);
|
||||||
tree.apply(img, region);
|
tree.apply(img, region);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redmean_sq(x: [u8; 3], y: [u8; 3]) -> f32 {
|
|
||||||
let mut ds: [f32; 3] = [0.0, 0.0, 0.0];
|
|
||||||
for (i, d) in ds.iter_mut().enumerate() {
|
|
||||||
let delta = x[i] as f32 - y[i] as f32;
|
|
||||||
*d = delta * delta;
|
|
||||||
}
|
|
||||||
let rm = (x[0] as f32 + y[0] as f32) / 2.0;
|
|
||||||
let result = (2.0 + rm / 256.0) * ds[0] + 4.0 * ds[1] + (2.0 + (255.0 - rm) / 256.0) * ds[2];
|
|
||||||
// normalize value to the range 0.0 - 1.0
|
|
||||||
result / 584971.0
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user