use clap::{Args, FromArgMatches};
use color_quant::NeuQuant;
use image::{DynamicImage, Rgb, RgbImage, imageops::ColorMap};
use crate::crapifier::{Crapifier, CrapifierEntry};
use crate::error::CrapifyError;
struct PaletteSpec {
name: &'static str,
slug: &'static str,
description: &'static str,
colors: &'static [[u8; 3]],
}
#[rustfmt::skip]
const WEB_SAFE_16_COLORS: &[[u8; 3]] = &[
[0x00, 0xFF, 0xFF], [0x00, 0x00, 0x00], [0x00, 0x00, 0xFF], [0xFF, 0x00, 0xFF], [0x80, 0x80, 0x80], [0x00, 0x80, 0x00], [0x00, 0xFF, 0x00], [0x80, 0x00, 0x00], [0x00, 0x00, 0x80], [0x80, 0x80, 0x00], [0x80, 0x00, 0x80], [0xFF, 0x00, 0x00], [0xC0, 0xC0, 0xC0], [0x00, 0x80, 0x80], [0xFF, 0xFF, 0xFF], [0xFF, 0xFF, 0x00], ];
const fn build_web_safe_216() -> [[u8; 3]; 216] {
let mut out = [[0u8; 3]; 216];
let levels = [0u8, 51, 102, 153, 204, 255];
let mut i = 0;
let mut r = 0;
while r < 6 {
let mut g = 0;
while g < 6 {
let mut b = 0;
while b < 6 {
out[i] = [levels[r], levels[g], levels[b]];
i += 1;
b += 1;
}
g += 1;
}
r += 1;
}
out
}
const WEB_SAFE_216_ARR: [[u8; 3]; 216] = build_web_safe_216();
const WEB_SAFE_216_COLORS: &[[u8; 3]] = &WEB_SAFE_216_ARR;
#[rustfmt::skip]
const VGA_16_COLORS: &[[u8; 3]] = &[
[0x00, 0x00, 0x00], [0x00, 0x00, 0xAA], [0x00, 0xAA, 0x00], [0x00, 0xAA, 0xAA], [0xAA, 0x00, 0x00], [0xAA, 0x00, 0xAA], [0xAA, 0x55, 0x00], [0xAA, 0xAA, 0xAA], [0x55, 0x55, 0x55], [0x55, 0x55, 0xFF], [0x55, 0xFF, 0x55], [0x55, 0xFF, 0xFF], [0xFF, 0x55, 0x55], [0xFF, 0x55, 0xFF], [0xFF, 0xFF, 0x55], [0xFF, 0xFF, 0xFF], ];
const EGA_16_COLORS: &[[u8; 3]] = VGA_16_COLORS;
#[rustfmt::skip]
const CGA0_LO_COLORS: &[[u8; 3]] = &[
[0x00, 0x00, 0x00], [0x00, 0xAA, 0x00], [0xAA, 0x00, 0x00], [0xAA, 0x55, 0x00], ];
#[rustfmt::skip]
const CGA1_LO_COLORS: &[[u8; 3]] = &[
[0x00, 0x00, 0x00], [0x00, 0xAA, 0xAA], [0xAA, 0x00, 0xAA], [0xAA, 0xAA, 0xAA], ];
#[rustfmt::skip]
const CGA1_HI_COLORS: &[[u8; 3]] = &[
[0x00, 0x00, 0x00], [0x55, 0xFF, 0xFF], [0xFF, 0x55, 0xFF], [0xFF, 0xFF, 0xFF], ];
#[rustfmt::skip]
const GAMEBOY_POCKET_COLORS: &[[u8; 3]] = &[
[0x0F, 0x38, 0x0F],
[0x30, 0x62, 0x30],
[0x8B, 0xAC, 0x0F],
[0x9B, 0xBC, 0x0F],
];
#[rustfmt::skip]
const MAC_CLASSIC_16_COLORS: &[[u8; 3]] = &[
[0xFF, 0xFF, 0xFF], [0xFC, 0xF3, 0x05], [0xFF, 0x64, 0x02], [0xDD, 0x08, 0x06], [0xF2, 0x08, 0x84], [0x46, 0x00, 0xA5], [0x00, 0x00, 0xD4], [0x02, 0xAB, 0xEA], [0x1F, 0xB7, 0x14], [0x00, 0x64, 0x11], [0x56, 0x2C, 0x05], [0x90, 0x71, 0x3A], [0xC0, 0xC0, 0xC0], [0x80, 0x80, 0x80], [0x40, 0x40, 0x40], [0x00, 0x00, 0x00], ];
#[rustfmt::skip]
const C64_16_COLORS: &[[u8; 3]] = &[
[0x00, 0x00, 0x00], [0xFF, 0xFF, 0xFF], [0x96, 0x28, 0x2E], [0x5B, 0xD6, 0xCE], [0x9F, 0x2D, 0xAD], [0x41, 0xB9, 0x36], [0x27, 0x24, 0xC4], [0xEF, 0xF3, 0x47], [0x9F, 0x48, 0x15], [0x5E, 0x35, 0x00], [0xDA, 0x5F, 0x66], [0x47, 0x47, 0x47], [0x78, 0x78, 0x78], [0x91, 0xFF, 0x84], [0x68, 0x64, 0xFF], [0xAE, 0xAE, 0xAE], ];
#[rustfmt::skip]
const NES_54_COLORS: &[[u8; 3]] = &[
[0x62, 0x62, 0x62], [0x00, 0x1C, 0x95], [0x19, 0x04, 0xAC], [0x42, 0x00, 0x9D],
[0x61, 0x00, 0x6B], [0x6E, 0x00, 0x25], [0x65, 0x05, 0x00], [0x49, 0x1E, 0x00],
[0x22, 0x37, 0x00], [0x00, 0x49, 0x00], [0x00, 0x4F, 0x00], [0x00, 0x48, 0x16],
[0x00, 0x35, 0x5E], [0x00, 0x00, 0x00],
[0xAB, 0xAB, 0xAB], [0x0C, 0x4E, 0xDB], [0x3D, 0x2E, 0xFF], [0x71, 0x15, 0xF3],
[0x9B, 0x0B, 0xB9], [0xB0, 0x12, 0x62], [0xA9, 0x27, 0x04], [0x89, 0x46, 0x00],
[0x57, 0x66, 0x00], [0x23, 0x7F, 0x00], [0x00, 0x89, 0x00], [0x00, 0x83, 0x32],
[0x00, 0x6D, 0x90],
[0xFF, 0xFF, 0xFF], [0x57, 0xA5, 0xFF], [0x82, 0x87, 0xFF], [0xB4, 0x6D, 0xFF],
[0xDF, 0x60, 0xFF], [0xF8, 0x63, 0xC6], [0xF8, 0x74, 0x6D], [0xDE, 0x90, 0x20],
[0xB3, 0xAE, 0x00], [0x81, 0xC8, 0x00], [0x56, 0xD5, 0x22], [0x3D, 0xD3, 0x6F],
[0x3E, 0xC1, 0xC8], [0x4E, 0x4E, 0x4E],
[0xBE, 0xE0, 0xFF], [0xCD, 0xD4, 0xFF], [0xE0, 0xCA, 0xFF], [0xF1, 0xC4, 0xFF],
[0xFC, 0xC4, 0xEF], [0xFD, 0xCA, 0xCE], [0xF5, 0xD4, 0xAF], [0xE6, 0xDF, 0x9C],
[0xD3, 0xE9, 0x9A], [0xC2, 0xEF, 0xA8], [0xB7, 0xEF, 0xC4], [0xB6, 0xEA, 0xE5],
[0xB8, 0xB8, 0xB8],
];
#[rustfmt::skip]
const ATARI_2600_NTSC_COLORS: &[[u8; 3]] = &[
[0x00,0x00,0x00],[0x4A,0x4A,0x4A],[0x6F,0x6F,0x6F],[0x8E,0x8E,0x8E],
[0xAA,0xAA,0xAA],[0xC0,0xC0,0xC0],[0xD6,0xD6,0xD6],[0xEC,0xEC,0xEC],
[0x48,0x48,0x00],[0x69,0x69,0x0F],[0x86,0x86,0x1D],[0xA2,0xA2,0x2A],
[0xBB,0xBB,0x35],[0xD2,0xD2,0x40],[0xE8,0xE8,0x4A],[0xFC,0xFC,0x54],
[0x7C,0x2C,0x00],[0x90,0x48,0x11],[0xA2,0x62,0x21],[0xB4,0x7A,0x30],
[0xC3,0x90,0x3D],[0xD2,0xA4,0x4A],[0xDF,0xB7,0x55],[0xEC,0xC8,0x60],
[0x90,0x1C,0x00],[0xA3,0x39,0x15],[0xB5,0x53,0x28],[0xC6,0x6C,0x3A],
[0xD5,0x82,0x4A],[0xE3,0x97,0x59],[0xF0,0xAA,0x67],[0xFC,0xBC,0x74],
[0x94,0x00,0x00],[0xA7,0x1A,0x1A],[0xB8,0x32,0x32],[0xC8,0x48,0x48],
[0xD6,0x5C,0x5C],[0xE4,0x6F,0x6F],[0xF0,0x80,0x80],[0xFC,0x90,0x90],
[0x84,0x00,0x64],[0x97,0x19,0x7A],[0xA8,0x30,0x8F],[0xB8,0x46,0xA2],
[0xC6,0x59,0xB3],[0xD4,0x6C,0xC3],[0xE0,0x7C,0xD2],[0xEC,0x8C,0xE0],
[0x50,0x00,0x84],[0x68,0x19,0x9A],[0x7D,0x30,0xAD],[0x92,0x46,0xC0],
[0xA4,0x59,0xD0],[0xB5,0x6C,0xE0],[0xC5,0x7C,0xEE],[0xD4,0x8C,0xFC],
[0x14,0x00,0x90],[0x33,0x1A,0xA3],[0x4E,0x32,0xB5],[0x68,0x48,0xC6],
[0x7F,0x5C,0xD5],[0x95,0x6F,0xE3],[0xA9,0x80,0xF0],[0xBC,0x90,0xFC],
[0x00,0x00,0x94],[0x18,0x1A,0xA7],[0x2D,0x32,0xB8],[0x42,0x48,0xC8],
[0x54,0x5C,0xD6],[0x65,0x6F,0xE4],[0x75,0x80,0xF0],[0x84,0x90,0xFC],
[0x00,0x1C,0x88],[0x18,0x3B,0x9D],[0x2D,0x57,0xB0],[0x42,0x72,0xC2],
[0x54,0x8A,0xD2],[0x65,0xA0,0xE1],[0x75,0xB5,0xEF],[0x84,0xC8,0xFC],
[0x00,0x30,0x64],[0x18,0x50,0x80],[0x2D,0x6D,0x98],[0x42,0x88,0xB0],
[0x54,0xA0,0xC5],[0x65,0xB7,0xD9],[0x75,0xCC,0xEB],[0x84,0xE0,0xFC],
[0x00,0x40,0x30],[0x18,0x62,0x4E],[0x2D,0x81,0x69],[0x42,0x9E,0x82],
[0x54,0xB8,0x99],[0x65,0xD1,0xAE],[0x75,0xE7,0xC2],[0x84,0xFC,0xD4],
[0x00,0x44,0x00],[0x1A,0x66,0x1A],[0x32,0x84,0x32],[0x48,0xA0,0x48],
[0x5C,0xBA,0x5C],[0x6F,0xD2,0x6F],[0x80,0xE8,0x80],[0x90,0xFC,0x90],
[0x14,0x3C,0x00],[0x35,0x5F,0x18],[0x52,0x7E,0x2D],[0x6E,0x9C,0x42],
[0x87,0xB7,0x54],[0x9E,0xD0,0x65],[0xB4,0xE7,0x75],[0xC8,0xFC,0x84],
[0x30,0x38,0x00],[0x50,0x59,0x16],[0x6D,0x76,0x2B],[0x88,0x92,0x3E],
[0xA0,0xAB,0x4F],[0xB7,0xC2,0x5F],[0xCC,0xD8,0x6E],[0xE0,0xEC,0x7C],
[0x48,0x2C,0x00],[0x69,0x4D,0x14],[0x86,0x6A,0x26],[0xA2,0x86,0x38],
[0xBB,0x9F,0x47],[0xD2,0xB6,0x56],[0xE8,0xCC,0x63],[0xFC,0xE0,0x70],
];
const PALETTES: &[&PaletteSpec] = &[
&PaletteSpec {
name: "Web-safe 16",
slug: "web-safe-16",
description: "CSS-1 / HTML 3.2 named colors (aqua, black, blue, ..., yellow).",
colors: WEB_SAFE_16_COLORS,
},
&PaletteSpec {
name: "Web-safe 216",
slug: "web-safe-216",
description: "Netscape's 6×6×6 color cube.",
colors: WEB_SAFE_216_COLORS,
},
&PaletteSpec {
name: "VGA 16",
slug: "vga-16",
description: "IBM VGA / EGA IRGB 16-color palette. Includes a true brown that web-safe-16 lacks.",
colors: VGA_16_COLORS,
},
&PaletteSpec {
name: "EGA 16",
slug: "ega-16",
description: "Same colors as vga-16; provided under both slugs.",
colors: EGA_16_COLORS,
},
&PaletteSpec {
name: "CGA 0 (low)",
slug: "cga0-lo",
description: "CGA palette 0, low intensity: black, dark green, dark red, brown.",
colors: CGA0_LO_COLORS,
},
&PaletteSpec {
name: "CGA 1 (low)",
slug: "cga1-lo",
description: "CGA palette 1, low intensity: black, dark cyan, dark magenta, light gray.",
colors: CGA1_LO_COLORS,
},
&PaletteSpec {
name: "CGA 1 (high)",
slug: "cga1-hi",
description: "CGA palette 1, high intensity: black, cyan, magenta, white.",
colors: CGA1_HI_COLORS,
},
&PaletteSpec {
name: "GameBoy Pocket",
slug: "gameboy-pocket",
description: "Four shades of swamp green from the Game Boy LCD.",
colors: GAMEBOY_POCKET_COLORS,
},
&PaletteSpec {
name: "Mac Classic 16",
slug: "mac-classic-16",
description: "Macintosh System 7 default 16-color desktop palette.",
colors: MAC_CLASSIC_16_COLORS,
},
&PaletteSpec {
name: "NES",
slug: "nes-54",
description: "NES master palette, 54 unique colors.",
colors: NES_54_COLORS,
},
&PaletteSpec {
name: "Commodore 64",
slug: "c64-16",
description: "Commodore 64's 16 colors.",
colors: C64_16_COLORS,
},
&PaletteSpec {
name: "Atari 2600 (NTSC)",
slug: "atari2600",
description: "Atari 2600 NTSC palette, 16 hues × 8 luma.",
colors: ATARI_2600_NTSC_COLORS,
},
];
fn palette_by_slug(slug: &str) -> Option<&'static PaletteSpec> {
PALETTES.iter().copied().find(|p| p.slug == slug)
}
const LUT_BITS: u32 = 5;
const LUT_LEVELS: usize = 1 << LUT_BITS;
const LUT_SIZE: usize = LUT_LEVELS * LUT_LEVELS * LUT_LEVELS;
const LUT_MIN_PALETTE: usize = 8;
#[inline(always)]
fn lut_key(r: u8, g: u8, b: u8) -> usize {
let r = (r as usize) >> (8 - LUT_BITS);
let g = (g as usize) >> (8 - LUT_BITS);
let b = (b as usize) >> (8 - LUT_BITS);
(r << (2 * LUT_BITS)) | (g << LUT_BITS) | b
}
#[derive(Clone)]
struct Palette {
colors: Vec<[u8; 3]>,
lut: Option<Box<[u8; LUT_SIZE]>>,
}
impl Palette {
fn new(colors: Vec<[u8; 3]>) -> Self {
let mut p = Palette { colors, lut: None };
if p.colors.len() >= LUT_MIN_PALETTE && p.colors.len() <= 256 {
p.build_lut();
}
p
}
fn from_spec(spec: &PaletteSpec) -> Self {
Self::new(spec.colors.to_vec())
}
fn from_neuquant(rgb: &RgbImage, n: usize) -> Self {
let src = rgb.as_raw();
let mut rgba = Vec::with_capacity(src.len() / 3 * 4);
for px in src.chunks_exact(3) {
rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
let nq = NeuQuant::new(10, n, &rgba);
let colors: Vec<[u8; 3]> = nq
.color_map_rgb()
.chunks_exact(3)
.map(|c| [c[0], c[1], c[2]])
.collect();
Self::new(colors)
}
fn build_lut(&mut self) {
let center_bias: u8 = 1 << (7 - LUT_BITS);
let mut lut = Box::new([0u8; LUT_SIZE]);
for r_bucket in 0..LUT_LEVELS {
for g_bucket in 0..LUT_LEVELS {
for b_bucket in 0..LUT_LEVELS {
let r = ((r_bucket as u8) << (8 - LUT_BITS)) | center_bias;
let g = ((g_bucket as u8) << (8 - LUT_BITS)) | center_bias;
let b = ((b_bucket as u8) << (8 - LUT_BITS)) | center_bias;
let key = (r_bucket << (2 * LUT_BITS)) | (g_bucket << LUT_BITS) | b_bucket;
lut[key] = self.index_of_linear(&Rgb([r, g, b])) as u8;
}
}
}
self.lut = Some(lut);
}
fn index_of_linear(&self, color: &Rgb<u8>) -> usize {
let [r, g, b] = color.0;
let r = r as i32;
let g = g as i32;
let b = b as i32;
let mut best_idx = 0usize;
let mut best_dist = i32::MAX;
for (idx, p) in self.colors.iter().enumerate() {
let dr = r - p[0] as i32;
let dg = g - p[1] as i32;
let db = b - p[2] as i32;
let d = dr * dr + dg * dg + db * db;
if d < best_dist {
best_dist = d;
best_idx = idx;
}
}
best_idx
}
}
impl ColorMap for Palette {
type Color = Rgb<u8>;
#[inline]
fn index_of(&self, color: &Rgb<u8>) -> usize {
if let Some(lut) = &self.lut {
let [r, g, b] = color.0;
lut[lut_key(r, g, b)] as usize
} else {
self.index_of_linear(color)
}
}
fn lookup(&self, idx: usize) -> Option<Rgb<u8>> {
self.colors.get(idx).copied().map(Rgb)
}
fn has_lookup(&self) -> bool {
true
}
fn map_color(&self, color: &mut Rgb<u8>) {
let idx = self.index_of(color);
color.0 = self.colors[idx];
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum DitherKind {
None,
Bayer2,
Bayer4,
Bayer8,
FloydSteinberg,
}
struct DitherSpec {
kind: DitherKind,
slug: &'static str,
description: &'static str,
}
#[rustfmt::skip]
const DITHERS: &[DitherSpec] = &[
DitherSpec { kind: DitherKind::None, slug: "none", description: "Pure nearest-color, hard banding." },
DitherSpec { kind: DitherKind::Bayer2, slug: "bayer-2", description: "2×2 ordered, coarsest tile." },
DitherSpec { kind: DitherKind::Bayer4, slug: "bayer-4", description: "4×4 ordered, middle ground." },
DitherSpec { kind: DitherKind::Bayer8, slug: "bayer-8", description: "8×8 ordered, the classic banner-ad crosshatch." },
DitherSpec { kind: DitherKind::FloydSteinberg, slug: "floyd-steinberg", description: "Error diffusion, smooth and grainy." },
];
impl DitherKind {
fn from_slug(s: &str) -> Option<Self> {
DITHERS.iter().find(|d| d.slug == s).map(|d| d.kind)
}
fn slug(self) -> &'static str {
DITHERS
.iter()
.find(|d| d.kind == self)
.map(|d| d.slug)
.unwrap_or("none")
}
}
#[rustfmt::skip]
const BAYER_2: [[u8; 2]; 2] = [
[0, 2],
[3, 1],
];
#[rustfmt::skip]
const BAYER_4: [[u8; 4]; 4] = [
[ 0, 8, 2, 10],
[12, 4, 14, 6],
[ 3, 11, 1, 9],
[15, 7, 13, 5],
];
#[rustfmt::skip]
const BAYER_8: [[u8; 8]; 8] = [
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
];
const BAYER_SPREAD: f32 = 64.0;
fn bayer_threshold(cell: u8, n: usize) -> f32 {
(cell as f32 / (n * n) as f32 - 0.5) * BAYER_SPREAD
}
fn apply_bayer<const N: usize>(rgb: &mut RgbImage, palette: &Palette, matrix: &[[u8; N]; N]) {
let mut threshold = [[0.0f32; N]; N];
for y in 0..N {
for x in 0..N {
threshold[y][x] = bayer_threshold(matrix[y][x], N);
}
}
for (x, y, pixel) in rgb.enumerate_pixels_mut() {
let t = threshold[(y as usize) % N][(x as usize) % N];
let p = pixel.0;
let shifted = Rgb([
(p[0] as f32 + t).clamp(0.0, 255.0) as u8,
(p[1] as f32 + t).clamp(0.0, 255.0) as u8,
(p[2] as f32 + t).clamp(0.0, 255.0) as u8,
]);
let idx = palette.index_of(&shifted);
*pixel = Rgb(palette.colors[idx]);
}
}
fn apply_none(rgb: &mut RgbImage, palette: &Palette) {
for pixel in rgb.pixels_mut() {
palette.map_color(pixel);
}
}
fn apply_palette(rgb: &mut RgbImage, palette: &Palette, dither: DitherKind) {
match dither {
DitherKind::None => apply_none(rgb, palette),
DitherKind::Bayer2 => apply_bayer(rgb, palette, &BAYER_2),
DitherKind::Bayer4 => apply_bayer(rgb, palette, &BAYER_4),
DitherKind::Bayer8 => apply_bayer(rgb, palette, &BAYER_8),
DitherKind::FloydSteinberg => image::imageops::dither(rgb, palette),
}
}
struct CrushLevel {
name: &'static str,
slug: &'static str,
description: &'static str,
palette_slug: &'static str,
dither: DitherKind,
}
#[rustfmt::skip]
const CRUSH_LEVELS: &[CrushLevel] = &[
CrushLevel { name: "GeoCities", slug: "geocities", description: "Tiled banner-ad crosshatch.", palette_slug: "web-safe-16", dither: DitherKind::Bayer8 },
CrushLevel { name: "GameBoy Pocket", slug: "gameboy-pocket", description: "Four shades of swamp green.", palette_slug: "gameboy-pocket", dither: DitherKind::None },
CrushLevel { name: "CGA eyestrain", slug: "cga-eyestrain", description: "Cyan and magenta in your face.", palette_slug: "cga1-hi", dither: DitherKind::None },
];
const DEFAULT_PRESET_SLUG: &str = "geocities";
fn default_preset() -> &'static CrushLevel {
CRUSH_LEVELS
.iter()
.find(|l| l.slug == DEFAULT_PRESET_SLUG)
.expect("DEFAULT_PRESET_SLUG must reference a real CRUSH_LEVELS row")
}
#[derive(Args, Debug)]
pub struct PaletteCrushArgs {
#[arg(
long,
value_parser = clap::builder::PossibleValuesParser::new(
CRUSH_LEVELS.iter().map(|l| l.slug)
),
help = "Named recipe preset (overridable by --palette/--colors/--dither)",
long_help = "Named recipe preset — picks a palette and dither pairing in one shot.\n\n\
The default 'geocities' pairs the 16-color CSS palette with 8×8 Bayer for that \
visible crosshatch you remember from 1997 banner GIFs. 'gameboy-pocket' and \
'cga-eyestrain' both use raw nearest-color (no dither) against tiny fixed \
palettes — the banding is the look. See the PRESETS table at the bottom of \
`--help` for what each preset actually binds to."
)]
preset: Option<String>,
#[arg(
long,
conflicts_with = "colors",
value_parser = clap::builder::PossibleValuesParser::new(
PALETTES.iter().map(|p| p.slug)
),
help = "Named palette from the built-in catalog",
long_help = "Named palette to quantize against. Overrides the preset's palette but leaves \
the preset's dither choice alone. Mutually exclusive with --colors. See the \
PALETTES table at the bottom of `--help` for the full list."
)]
palette: Option<String>,
#[arg(
long,
conflicts_with = "palette",
value_parser = clap::value_parser!(u32).range(2..=256),
help = "Adaptive palette size (2..=256), via NeuQuant",
long_help = "Skip the named-palette catalog and let an adaptive algorithm pick N \
representative colors from the input image. Mutually exclusive with \
--palette.\n\n\
Quality drops sharply below ~8 colors — for very small N, a named palette \
(gameboy-pocket, cga1-hi) usually looks better than asking the algorithm \
to learn 4 representative colors from scratch."
)]
colors: Option<u32>,
#[arg(
long,
value_parser = clap::builder::PossibleValuesParser::new(
DITHERS.iter().map(|d| d.slug)
),
help = "Dither algorithm (overrides preset)",
long_help = "Which dither to apply when quantizing.\n\n\
'none' is pure nearest-color — every pixel snaps to the closest palette entry, \
and you get hard banding on gradients (the honest 'this image really only has \
4 colors' look).\n\n\
'bayer-2', 'bayer-4', 'bayer-8' add a fixed threshold from an N×N Bayer matrix \
before snapping; the matrix tiles across the image, giving the visible \
crosshatch/dot pattern you see in 1997-era banner GIFs. Larger N = finer \
pattern.\n\n\
'floyd-steinberg' diffuses quantization error to neighboring pixels (right + \
below). Looks smooth and grainy rather than tiled."
)]
dither: Option<String>,
}
pub struct PaletteCrush;
impl Crapifier for PaletteCrush {
type Args = PaletteCrushArgs;
fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
let preset = args
.preset
.as_deref()
.and_then(|slug| CRUSH_LEVELS.iter().find(|l| l.slug == slug))
.unwrap_or_else(default_preset);
let mut rgb = img.to_rgb8();
let palette = match (args.palette.as_deref(), args.colors) {
(Some(slug), None) => Palette::from_spec(
palette_by_slug(slug).expect("clap restricts --palette to known slugs"),
),
(None, Some(n)) => Palette::from_neuquant(&rgb, n as usize),
(None, None) => Palette::from_spec(
palette_by_slug(preset.palette_slug)
.expect("preset palette_slug must reference a real catalog entry"),
),
(Some(_), Some(_)) => unreachable!("clap conflicts_with rejects --palette + --colors"),
};
let dither = args
.dither
.as_deref()
.and_then(DitherKind::from_slug)
.unwrap_or(preset.dither);
apply_palette(&mut rgb, &palette, dither);
Ok(DynamicImage::ImageRgb8(rgb))
}
}
const STAGE_ABOUT: &str =
"Crush an image's colors to a tiny palette: web-safe 16, gameboy green, CGA, NES, and friends.";
fn build_help_footer() -> String {
let mut s = String::from(
"Snaps every pixel to the nearest color in a palette (optionally with dithering to break \
the banding). Default preset is 'geocities' — 16-color CSS palette with 8×8 Bayer dither \
for that visible 1997-banner-GIF crosshatch.\n\n",
);
s.push_str("PRESETS (via --preset <slug>):\n");
let slug_col = CRUSH_LEVELS.iter().map(|l| l.slug.len()).max().unwrap_or(0);
let name_col = CRUSH_LEVELS
.iter()
.map(|l| l.name.len() + 2)
.max()
.unwrap_or(0);
for lvl in CRUSH_LEVELS {
let quoted = format!("\"{}\"", lvl.name);
s.push_str(&format!(
" {slug:<slug_w$} {quoted:<name_w$} palette={pal:<14} dither={dith:<16} {desc}\n",
slug = lvl.slug,
quoted = quoted,
pal = lvl.palette_slug,
dith = lvl.dither.slug(),
desc = lvl.description,
slug_w = slug_col,
name_w = name_col,
));
}
s.push_str("\nPALETTES (via --palette <slug>):\n");
let count_labels: Vec<String> = PALETTES
.iter()
.map(|p| format!("{} colors", p.colors.len()))
.collect();
let pal_slug_col = PALETTES.iter().map(|p| p.slug.len()).max().unwrap_or(0);
let pal_name_col = PALETTES.iter().map(|p| p.name.len() + 2).max().unwrap_or(0);
let pal_count_col = count_labels.iter().map(|s| s.len()).max().unwrap_or(0);
for (spec, count_label) in PALETTES.iter().zip(count_labels.iter()) {
let quoted = format!("\"{}\"", spec.name);
s.push_str(&format!(
" {slug:<slug_w$} {quoted:<name_w$} {count:<count_w$} {desc}\n",
slug = spec.slug,
quoted = quoted,
count = count_label,
desc = spec.description,
slug_w = pal_slug_col,
name_w = pal_name_col,
count_w = pal_count_col,
));
}
s.push_str("\nDITHERS (via --dither <slug>):\n");
let dith_slug_col = DITHERS.iter().map(|d| d.slug.len()).max().unwrap_or(0);
for d in DITHERS {
s.push_str(&format!(
" {slug:<slug_w$} {desc}\n",
slug = d.slug,
desc = d.description,
slug_w = dith_slug_col,
));
}
s.push_str(
"\nKnobs override preset values when both are supplied:\n \
crapify palettecrush --preset geocities --dither floyd-steinberg in.png out.png\n\n\
With no flags, defaults resolve to the 'geocities' preset.",
);
s
}
inventory::submit! {
CrapifierEntry {
name: "palettecrush",
augment_command: |cmd| {
PaletteCrushArgs::augment_args(
cmd.about(STAGE_ABOUT).after_long_help(build_help_footer()),
)
},
run: |img, matches| {
let args = PaletteCrushArgs::from_arg_matches(matches).map_err(CrapifyError::Clap)?;
PaletteCrush.run(img, &args)
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_preset_slug_points_to_real_row() {
let _ = default_preset();
}
#[test]
fn preset_slugs_are_unique_and_dash_lowercase() {
let mut seen = std::collections::HashSet::new();
for lvl in CRUSH_LEVELS {
assert!(
lvl.slug.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"slug {:?} has chars outside [a-z-]",
lvl.slug
);
assert!(
seen.insert(lvl.slug),
"duplicate preset slug: {:?}",
lvl.slug
);
}
}
#[test]
fn palette_slugs_are_unique_and_dash_lowercase() {
let mut seen = std::collections::HashSet::new();
for spec in PALETTES {
assert!(
spec.slug
.chars()
.all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()),
"palette slug {:?} has chars outside [a-z0-9-]",
spec.slug
);
assert!(
seen.insert(spec.slug),
"duplicate palette slug: {:?}",
spec.slug
);
}
}
#[test]
fn palette_colors_match_declared_count() {
let expected: &[(&str, usize)] = &[
("web-safe-16", 16),
("web-safe-216", 216),
("vga-16", 16),
("ega-16", 16),
("cga0-lo", 4),
("cga1-lo", 4),
("cga1-hi", 4),
("gameboy-pocket", 4),
("mac-classic-16", 16),
("nes-54", 54),
("c64-16", 16),
("atari2600", 128),
];
for (slug, expected_count) in expected {
let spec = palette_by_slug(slug).expect("palette must exist");
assert_eq!(
spec.colors.len(),
*expected_count,
"palette {slug:?} has {} colors but its slug implies {}",
spec.colors.len(),
expected_count
);
}
}
#[test]
fn every_preset_palette_slug_resolves() {
for lvl in CRUSH_LEVELS {
assert!(
palette_by_slug(lvl.palette_slug).is_some(),
"preset {:?} references unknown palette {:?}",
lvl.slug,
lvl.palette_slug
);
}
}
#[test]
fn help_footer_includes_every_preset_palette_and_dither() {
let s = build_help_footer();
for lvl in CRUSH_LEVELS {
assert!(s.contains(lvl.slug), "missing preset slug: {:?}", lvl.slug);
assert!(s.contains(lvl.name), "missing preset name: {:?}", lvl.name);
}
for spec in PALETTES {
assert!(
s.contains(spec.slug),
"missing palette slug: {:?}",
spec.slug
);
}
for d in DITHERS {
assert!(s.contains(d.slug), "missing dither slug: {:?}", d.slug);
}
}
#[test]
fn palette_color_round_trips_within_bucket_tolerance() {
let max_drift = (1u32 << (8 - LUT_BITS)) as i32;
for spec in PALETTES {
let palette = Palette::from_spec(spec);
for &color in &palette.colors {
let pixel = Rgb(color);
let idx = palette.index_of(&pixel);
let got = palette.colors[idx];
for c in 0..3 {
let drift = (got[c] as i32 - color[c] as i32).abs();
assert!(
drift <= max_drift,
"{}: palette color {:?} -> index_of returned {:?}, channel {} drift {} > {}",
spec.slug,
color,
got,
c,
drift,
max_drift,
);
}
}
}
}
#[test]
fn lut_built_when_palette_large_enough() {
assert!(
Palette::from_spec(palette_by_slug("cga1-hi").unwrap())
.lut
.is_none()
);
assert!(
Palette::from_spec(palette_by_slug("nes-54").unwrap())
.lut
.is_some()
);
assert!(
Palette::from_spec(palette_by_slug("web-safe-216").unwrap())
.lut
.is_some()
);
}
#[test]
fn quantize_none_produces_only_palette_colors() {
let spec = palette_by_slug("gameboy-pocket").unwrap();
let palette = Palette::from_spec(spec);
let mut img = RgbImage::from_fn(4, 4, |x, y| {
Rgb([(x * 64) as u8, (y * 64) as u8, ((x + y) * 32) as u8])
});
apply_palette(&mut img, &palette, DitherKind::None);
let palette_set: std::collections::HashSet<[u8; 3]> =
palette.colors.iter().copied().collect();
for pixel in img.pixels() {
assert!(
palette_set.contains(&pixel.0),
"pixel {:?} is not in the gameboy-pocket palette",
pixel.0
);
}
}
#[test]
fn quantize_bayer_produces_only_palette_colors() {
let spec = palette_by_slug("cga1-hi").unwrap();
let palette = Palette::from_spec(spec);
let mut img = RgbImage::from_fn(16, 16, |x, y| Rgb([(x * 16) as u8, (y * 16) as u8, 128]));
apply_palette(&mut img, &palette, DitherKind::Bayer8);
let palette_set: std::collections::HashSet<[u8; 3]> =
palette.colors.iter().copied().collect();
for pixel in img.pixels() {
assert!(
palette_set.contains(&pixel.0),
"pixel {:?} fell outside the palette after bayer-8",
pixel.0
);
}
}
#[test]
fn quantize_floyd_steinberg_produces_only_palette_colors() {
let spec = palette_by_slug("cga1-hi").unwrap();
let palette = Palette::from_spec(spec);
let mut img = RgbImage::from_fn(16, 16, |x, y| Rgb([(x * 16) as u8, (y * 16) as u8, 128]));
apply_palette(&mut img, &palette, DitherKind::FloydSteinberg);
let palette_set: std::collections::HashSet<[u8; 3]> =
palette.colors.iter().copied().collect();
for pixel in img.pixels() {
assert!(
palette_set.contains(&pixel.0),
"pixel {:?} fell outside the palette after floyd-steinberg",
pixel.0
);
}
}
#[test]
fn bayer8_thresholds_average_near_zero() {
let mut sum = 0.0f32;
for row in &BAYER_8 {
for &cell in row {
sum += bayer_threshold(cell, 8);
}
}
let mean = sum / 64.0;
assert!(
mean.abs() < BAYER_SPREAD * 0.5,
"bayer-8 mean drifted: {mean}"
);
}
#[test]
fn dither_kind_from_slug_roundtrips() {
for d in DITHERS {
assert_eq!(DitherKind::from_slug(d.slug), Some(d.kind));
assert_eq!(d.kind.slug(), d.slug);
}
assert_eq!(DitherKind::from_slug("nope"), None);
}
#[test]
fn adaptive_palette_returns_requested_size() {
let img = RgbImage::from_fn(32, 32, |x, y| {
Rgb([
((x * 7) % 256) as u8,
((y * 11) % 256) as u8,
(((x + y) * 13) % 256) as u8,
])
});
for n in [8usize, 16, 32, 64] {
let palette = Palette::from_neuquant(&img, n);
assert_eq!(
palette.colors.len(),
n,
"adaptive palette size {n} but got {}",
palette.colors.len()
);
}
}
#[test]
fn vga16_and_ega16_share_colors() {
let vga = palette_by_slug("vga-16").unwrap();
let ega = palette_by_slug("ega-16").unwrap();
assert_eq!(vga.colors, ega.colors);
}
#[test]
fn web_safe_216_uses_canonical_levels() {
assert_eq!(WEB_SAFE_216_ARR[0], [0, 0, 0]);
assert_eq!(WEB_SAFE_216_ARR[215], [255, 255, 255]);
let r2_g1_b4 = WEB_SAFE_216_ARR[2 * 36 + 6 + 4];
assert_eq!(r2_g1_b4, [102, 51, 204]);
}
}