Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 55 additions & 187 deletions src/images.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::palettes::{Color, Palette};
use anyhow::{Context, Result, bail};
use image::{Pixel, Rgba, RgbaImage};
use image::{Pixel, Rgb, Rgba, RgbaImage};
use std::fs::File;
use std::io::Write;
use std::path::Path;
Expand All @@ -12,65 +12,30 @@ pub fn convert_image(in_path: &Path, out_path: &Path, sys_pal: &Palette) -> Resu
if img.width() % 8 != 0 {
bail!("image width must be divisible by 8");
}
let mut img_pal = make_palette(&img, sys_pal).context("detect colors used in the image")?;
let mut out = File::create(out_path).context("create output path")?;
// The magic number. "2"=image, "1"=v1.
write_u8(&mut out, 0x21)?;
let n_colors = img_pal.len();
if n_colors <= 2 {
if n_colors <= 1 {
println!("⚠️ the image has only one color.");
}
extend_palette(&mut img_pal, sys_pal, 2);
write_image::<1, 8>(out, &img, &img_pal, sys_pal).context("write 1BPP image")
} else if n_colors <= 4 {
extend_palette(&mut img_pal, sys_pal, 4);
write_image::<2, 4>(out, &img, &img_pal, sys_pal).context("write 1BPP image")
} else if n_colors <= 16 {
extend_palette(&mut img_pal, sys_pal, 16);
write_image::<4, 2>(out, &img, &img_pal, sys_pal).context("write 1BPP image")
} else {
let has_transparency = img_pal.iter().any(Option::is_none);
if has_transparency && n_colors == 17 {
bail!("cannot use all 16 colors with transparency, remove one color");
}
bail!("the image has too many colors");
}
let transp = find_unused_color(&img, sys_pal).context("detect colors used in the image")?;
let out = File::create(out_path).context("create output path")?;
write_image(out, &img, sys_pal, transp).context("write image")
}

fn write_image<const BPP: u8, const PPB: usize>(
mut out: File,
img: &RgbaImage,
img_pal: &[Color],
sys_pal: &Palette,
) -> Result<()> {
write_u8(&mut out, BPP)?; // BPP
fn write_image(mut out: File, img: &RgbaImage, sys_pal: &Palette, transp: u8) -> Result<()> {
const BPP: u8 = 4;
const PPB: usize = 2;

let Ok(width) = u16::try_from(img.width()) else {
bail!("the image is too big")
};
write_u8(&mut out, 0x22)?; // magic number
write_u16(&mut out, width)?; // image width
let transparent = pick_transparent(img_pal, sys_pal)?;
write_u8(&mut out, transparent)?; // transparent color
write_u8(&mut out, transp)?; // transparent color

// palette swaps
let mut byte = 0;
debug_assert!(img_pal.len() == 2 || img_pal.len() == 4 || img_pal.len() == 16);
for (i, color) in img_pal.iter().enumerate() {
let index = match color {
Some(color) => find_color(sys_pal, Some(*color)),
None => transparent,
};
byte = (byte << 4) | index;
if i % 2 == 1 {
write_u8(&mut out, byte)?;
}
}

// image raw packed bytes
// Pixel values.
let mut byte: u8 = 0;
for (i, pixel) in img.pixels().enumerate() {
let color = convert_color(*pixel);
let raw_color = find_color(img_pal, color);
let raw_color = match color {
Some(color) => find_color(sys_pal, color),
None => transp,
};
byte = (byte << BPP) | raw_color;
if (i + 1) % PPB == 0 {
write_u8(&mut out, byte)?;
Expand All @@ -79,77 +44,33 @@ fn write_image<const BPP: u8, const PPB: usize>(
Ok(())
}

/// Detect all colors used in the image.
fn make_palette(img: &RgbaImage, sys_pal: &Palette) -> Result<Vec<Color>> {
let mut palette = Vec::new();
/// Find color from the palette not used on the image.
///
/// Additionally ensures that the image uses the given color palette.
fn find_unused_color(img: &RgbaImage, sys_pal: &Palette) -> Result<u8> {
let mut used_colors: Vec<Color> = Vec::new();
let mut has_transp = false;
for (x, y, pixel) in img.enumerate_pixels() {
let color = convert_color(*pixel);
if !palette.contains(&color) {
if color.is_some() && !sys_pal.contains(&color) {
bail!(
"found a color not present in the color palette: {} (at x={x}, y={y})",
format_color(color),
);
}
palette.push(color);
let Some(color) = convert_color(*pixel) else {
has_transp = true;
continue;
};
if !sys_pal.contains(&color) {
bail!(
"found a color not present in the color palette: {} (at x={x}, y={y})",
format_color(color),
);
}
}
palette.sort_by_key(|c| match c {
Some(c) => find_color(sys_pal, Some(*c)),
None => 20,
});
Ok(palette)
}

/// Add empty colors at the end of the palette to match the BPP size.
///
/// If the given image palette is fully contained within the system palette
/// (after being cut to the expected swaps size), place the colors in the
/// image palette in the same positions as they are in the system palette.
/// This will make it possible to read such images without worrying about
/// applying color swaps.
fn extend_palette(img_pal: &mut Vec<Color>, sys_pal: &Palette, size: usize) {
if img_pal.len() > size {
return;
}

let sys_pal_prefix = &sys_pal[..size];
if !is_subpalette(img_pal, sys_pal_prefix) {
img_pal.extend_from_slice(&sys_pal[img_pal.len()..size]);
return;
}

// No transparency? Just use the system palette.
let has_transp = img_pal.iter().any(Option::is_none);
if !has_transp {
img_pal.clear();
img_pal.extend(sys_pal_prefix);
return;
}

// Has transparency? Then copy the system palette and poke one hole in it.
let mut new_pal: Vec<Color> = Vec::new();
let mut found_transp = false;
for c in sys_pal_prefix {
if found_transp || img_pal.contains(c) {
new_pal.push(*c);
} else {
new_pal.push(None);
found_transp = true;
if !used_colors.contains(&color) {
used_colors.push(color);
}
}
img_pal.clear();
img_pal.extend(new_pal);
}

/// Check if the image palette is fully contained within the given system palette.
fn is_subpalette(img_pal: &[Color], sys_pal: &[Color]) -> bool {
for c in img_pal {
if c.is_some() && !sys_pal.contains(c) {
return false;
}
if has_transp {
pick_transparent(&used_colors, sys_pal)
} else {
Ok(0xff)
}
true
}

fn write_u8(f: &mut File, v: u8) -> std::io::Result<()> {
Expand All @@ -161,7 +82,7 @@ fn write_u16(f: &mut File, v: u16) -> std::io::Result<()> {
}

/// Find the index of the given color in the given palette.
fn find_color(palette: &[Color], c: Color) -> u8 {
fn find_color(palette: &Palette, c: Rgb<u8>) -> u8 {
for (color, i) in palette.iter().zip(0u8..) {
if *color == c {
return i;
Expand All @@ -172,45 +93,35 @@ fn find_color(palette: &[Color], c: Color) -> u8 {

/// Make human-readable hex representation of the color code.
fn format_color(c: Color) -> String {
match c {
Some(c) => {
let c = c.0;
format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])
}
None => "ALPHA".to_string(),
}
let c = c.0;
format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])
}

fn convert_color(c: Rgba<u8>) -> Color {
if is_transparent(c) {
fn convert_color(c: Rgba<u8>) -> Option<Color> {
let alpha = c.0[3];
let is_transparent = alpha < 128;
if is_transparent {
return None;
}
Some(c.to_rgb())
}

const fn is_transparent(c: Rgba<u8>) -> bool {
let alpha = c.0[3];
alpha < 128
}

/// Pick the color to be used to represent transparency
fn pick_transparent(img_pal: &[Color], sys_pal: &Palette) -> Result<u8> {
if img_pal.iter().all(Option::is_some) {
// no transparency needed
return Ok(17);
}
assert!(img_pal.len() <= sys_pal.len());
assert!(sys_pal.len() <= 16);
for (color, i) in sys_pal.iter().zip(0u8..) {
if !img_pal.contains(color) {
return Ok(i);
}
}
if img_pal.len() > 16 {
bail!("the image cannot contain more than 16 colors")
if sys_pal.len() == 16 {
bail!("cannot use all 16 colors with transparency, remove one color");
}
if img_pal.len() == 16 {
bail!("an image cannot contain all 16 colors and transparency")
}
bail!("image contains colors not from the palette")
// If the system palette has less than 16 colors,
// any of the colors outside the palette
// can be used for transparency. We use 15.
Ok(0xf)
}

#[cfg(test)]
Expand All @@ -221,8 +132,7 @@ mod tests {

#[test]
fn test_format_color() {
assert_eq!(format_color(None), "ALPHA");
assert_eq!(format_color(Some(Rgb([0x89, 0xab, 0xcd]))), "#89ABCD");
assert_eq!(format_color(Rgb([0x89, 0xab, 0xcd])), "#89ABCD");
}

#[test]
Expand All @@ -232,50 +142,8 @@ mod tests {
let c1 = pal[1];
let c2 = pal[2];
let c3 = pal[3];
assert_eq!(pick_transparent(&[c0, c1], pal).unwrap(), 17);
assert_eq!(pick_transparent(&[c0, c1, None], pal).unwrap(), 2);
assert_eq!(pick_transparent(&[c0, None, c1], pal).unwrap(), 2);
assert_eq!(pick_transparent(&[c1, c0, None], pal).unwrap(), 2);
assert_eq!(pick_transparent(&[c0, c1, c2, c3, None], pal).unwrap(), 4);
}

#[test]
fn test_extend_palette() {
let pal = SWEETIE16;
let c0 = pal[0];
let c1 = pal[1];
let c2 = pal[2];
let c3 = pal[3];
let c4 = pal[4];

// Already the palette prefix, do nothing.
let mut img_pal = vec![c0, c1];
extend_palette(&mut img_pal, pal, 2);
assert_eq!(img_pal, vec![c0, c1]);

// A prefix but in a wrong order. Fix the order.
let mut img_pal = vec![c1, c0];
extend_palette(&mut img_pal, pal, 2);
assert_eq!(img_pal, vec![c0, c1]);

// Not a prefix and already full. Keep the given palette.
let mut img_pal = vec![c2, c1];
extend_palette(&mut img_pal, pal, 2);
assert_eq!(img_pal, vec![c2, c1]);

// A prefix but too short. Fill the rest.
let mut img_pal = vec![c0, c1];
extend_palette(&mut img_pal, pal, 4);
assert_eq!(img_pal, vec![c0, c1, c2, c3]);

// Within the palette prefix.
let mut img_pal = vec![c2, c1];
extend_palette(&mut img_pal, pal, 4);
assert_eq!(img_pal, vec![c0, c1, c2, c3]);

// Not a prefix but too short. Don't touch the given, fill the rest.
let mut img_pal = vec![c4, c2];
extend_palette(&mut img_pal, pal, 4);
assert_eq!(img_pal, vec![c4, c2, c2, c3]);
assert_eq!(pick_transparent(&[c0, c1], pal).unwrap(), 2);
assert_eq!(pick_transparent(&[c1, c0], pal).unwrap(), 2);
assert_eq!(pick_transparent(&[c0, c1, c2, c3], pal).unwrap(), 4);
}
}
Loading