Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional palette sorting algorithm #514

Merged
merged 7 commits into from
Jul 11, 2023
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
10 changes: 10 additions & 0 deletions benches/reductions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,16 @@ fn reductions_palette_sort(b: &mut Bencher) {
b.iter(|| palette::sorted_palette(&png.raw));
}

#[bench]
fn reductions_palette_sort_battiato(b: &mut Bencher) {
let input = test::black_box(PathBuf::from(
"tests/files/palette_8_should_be_palette_8.png",
));
let png = PngData::new(&input, &Options::default()).unwrap();

b.iter(|| palette::sorted_palette_battiato(&png.raw));
}

#[bench]
fn reductions_alpha(b: &mut Bencher) {
let input = test::black_box(PathBuf::from("tests/files/rgba_8_reduce_alpha.png"));
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ pub struct Options {
///
/// `Some(x)` will change the file to interlacing mode `x`.
///
/// Default: `Some(None)`
/// Default: `Some(Interlacing::None)`
pub interlace: Option<Interlacing>,
/// Whether to allow transparent pixels to be altered to improve compression.
pub optimize_alpha: bool,
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ fn parse_opts_into_struct(

opts.scale_16 = matches.get_flag("scale16");

opts.fast_evaluation = matches.get_flag("fast");
// The default value for fast depends on the preset - make sure we don't change when not provided
if matches.get_flag("fast") {
opts.fast_evaluation = matches.get_flag("fast");
}

opts.backup = matches.get_flag("backup");

Expand Down
5 changes: 2 additions & 3 deletions src/png/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,8 @@ impl PngImage {
match &self.ihdr.color_type {
ColorType::Indexed { palette } => {
let plte = 12 + palette.len() * 3;
let trns = palette.iter().filter(|p| p.a != 255).count();
if trns != 0 {
plte + 12 + trns
if let Some(trns) = palette.iter().rposition(|p| p.a != 255) {
plte + 12 + trns + 1
} else {
plte
}
Expand Down
18 changes: 17 additions & 1 deletion src/reduction/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::evaluate::Evaluator;
use crate::png::PngImage;
use crate::Deadline;
use crate::Deflaters;
use crate::Options;
use std::sync::Arc;

Expand All @@ -21,6 +22,13 @@ pub(crate) fn perform_reductions(
) -> Arc<PngImage> {
let mut evaluation_added = false;

// At low compression levels, skip some transformations which are less likely to be effective
// This currently affects optimization presets 0-2
let cheap = match opts.deflate {
Deflaters::Libdeflater { compression } => compression < 12 && opts.fast_evaluation,
_ => false,
};

// Interlacing must be processed first in order to evaluate the rest correctly
if let Some(interlacing) = opts.interlace {
if let Some(reduced) = png.change_interlacing(interlacing) {
Expand Down Expand Up @@ -98,7 +106,7 @@ pub(crate) fn perform_reductions(

// Attempt to convert from indexed to channels
// This may give a better result due to dropping the PLTE chunk
if opts.color_type_reduction && !deadline.passed() {
if !cheap && opts.color_type_reduction && !deadline.passed() {
if let Some(reduced) = indexed_to_channels(&png, opts.grayscale_reduction) {
// This result should not be passed on to subsequent reductions
eval.try_image(Arc::new(reduced));
Expand All @@ -124,6 +132,14 @@ pub(crate) fn perform_reductions(
}
}

// Attempt to sort the palette using an alternative method
if !cheap && opts.palette_reduction && !deadline.passed() {
if let Some(reduced) = sorted_palette_battiato(&png) {
eval.try_image(Arc::new(reduced));
evaluation_added = true;
}
}

// Attempt to reduce to a lower bit depth
if opts.bit_depth_reduction && !deadline.passed() {
// Try reducing the previous png, falling back to the indexed one if it exists
Expand Down
190 changes: 187 additions & 3 deletions src/reduction/palette.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::colors::{BitDepth, ColorType};
use crate::headers::IhdrData;
use crate::png::scan_lines::ScanLine;
use crate::png::PngImage;
use crate::Interlacing;
use indexmap::IndexSet;
use rgb::RGBA8;

Expand Down Expand Up @@ -69,31 +71,38 @@ fn add_color_to_set(mut color: RGBA8, set: &mut IndexSet<RGBA8>, optimize_alpha:
idx as u8
}

/// Attempt to sort the colors in the palette, returning the sorted image if successful
/// Attempt to sort the colors in the palette by luma, returning the sorted image if successful
#[must_use]
pub fn sorted_palette(png: &PngImage) -> Option<PngImage> {
if png.ihdr.bit_depth != BitDepth::Eight {
return None;
}
let palette = match &png.ihdr.color_type {
ColorType::Indexed { palette } => palette,
ColorType::Indexed { palette } if palette.len() > 1 => palette,
_ => return None,
};

let mut enumerated: Vec<_> = palette.iter().enumerate().collect();
// Put the most popular edge color first, which can help slightly if the filter bytes are 0
let keep_first = most_popular_edge_color(palette.len(), png);
let first = enumerated.remove(keep_first);

// Sort the palette
enumerated.sort_by(|a, b| {
// Sort by ascending alpha and descending luma
let color_val = |color: &RGBA8| {
((color.a as i32) << 18)
let a = i32::from(color.a);
// Put 7 high bits of alpha first, then luma, then low bit of alpha
// This provides notable improvement in images with a lot of alpha
((a & 0xFE) << 18) + (a & 0x01)
// These are coefficients for standard sRGB to luma conversion
- i32::from(color.r) * 299
- i32::from(color.g) * 587
- i32::from(color.b) * 114
};
color_val(a.1).cmp(&color_val(b.1))
});
enumerated.insert(0, first);

// Extract the new palette and determine if anything changed
let (old_map, palette): (Vec<_>, Vec<RGBA8>) = enumerated.into_iter().unzip();
Expand All @@ -116,3 +125,178 @@ pub fn sorted_palette(png: &PngImage) -> Option<PngImage> {
data,
})
}

/// Sort the colors in the palette by minimizing entropy, returning the sorted image if successful
#[must_use]
pub fn sorted_palette_battiato(png: &PngImage) -> Option<PngImage> {
// Interlacing not currently supported
if png.ihdr.bit_depth != BitDepth::Eight || png.ihdr.interlaced != Interlacing::None {
return None;
}
let palette = match &png.ihdr.color_type {
// Images with only two colors will remain unchanged from previous luma sort
ColorType::Indexed { palette } if palette.len() > 2 => palette,
_ => return None,
};

let matrix = co_occurrence_matrix(palette.len(), png);
let edges = weighted_edges(&matrix);
let mut old_map = battiato_tsp(palette.len(), edges);

// Put the most popular edge color first, which can help slightly if the filter bytes are 0
let keep_first = most_popular_edge_color(palette.len(), png);
let first_idx = old_map.iter().position(|&i| i == keep_first).unwrap();
// If the index is past halfway, reverse the order so as to minimize the change
if first_idx >= old_map.len() / 2 {
old_map.reverse();
old_map.rotate_right(first_idx + 1);
} else {
old_map.rotate_left(first_idx);
}

// Check if anything changed
if old_map.iter().enumerate().all(|(a, b)| a == *b) {
return None;
}

// Construct the palette and byte maps and convert the data
let mut new_palette = Vec::new();
let mut byte_map = [0; 256];
for (i, &v) in old_map.iter().enumerate() {
new_palette.push(palette[v]);
byte_map[v] = i as u8;
}
let data = png.data.iter().map(|&b| byte_map[b as usize]).collect();

Some(PngImage {
ihdr: IhdrData {
color_type: ColorType::Indexed {
palette: new_palette,
},
..png.ihdr
},
data,
})
}

// Find the most popular color on the image edges (the pixels neighboring the filter bytes)
fn most_popular_edge_color(num_colors: usize, png: &PngImage) -> usize {
let mut counts = vec![0; num_colors];
for line in png.scan_lines(false) {
counts[line.data[0] as usize] += 1;
counts[line.data[line.data.len() - 1] as usize] += 1;
}
counts.iter().enumerate().max_by_key(|(_, &v)| v).unwrap().0
}

// Calculate co-occurences matrix
fn co_occurrence_matrix(num_colors: usize, png: &PngImage) -> Vec<Vec<usize>> {
let mut matrix = vec![vec![0; num_colors]; num_colors];
let mut prev: Option<ScanLine> = None;
for line in png.scan_lines(false) {
for i in 0..line.data.len() {
let val = line.data[i] as usize;
if i > 0 {
matrix[line.data[i - 1] as usize][val] += 1;
}
if let Some(prev) = &prev {
matrix[prev.data[i] as usize][val] += 1;
}
}
prev = Some(line)
}
matrix
}

// Calculate edge list sorted by weight
fn weighted_edges(matrix: &[Vec<usize>]) -> Vec<(usize, usize)> {
let mut edges = Vec::new();
for i in 0..matrix.len() {
for j in 0..i {
edges.push(((j, i), matrix[i][j] + matrix[j][i]));
}
}
edges.sort_by(|(_, w1), (_, w2)| w2.cmp(w1));
edges.into_iter().map(|(e, _)| e).collect()
}

// Calculate an approximate solution of the Traveling Salesman Problem using the algorithm
// from "An efficient Re-indexing algorithm for color-mapped images" by Battiato et al
// https://ieeexplore.ieee.org/document/1344033
fn battiato_tsp(num_colors: usize, edges: Vec<(usize, usize)>) -> Vec<usize> {
let mut chains = Vec::new();
// Keep track of the state of each vertex (.0) and it's chain number (.1)
// 0 = an unvisited vertex (White)
// 1 = an endpoint of a chain (Red)
// 2 = part of the middle of a chain (Black)
let mut vx = vec![(0, 0); num_colors];

// Iterate the edges and assemble them into a chain
for (i, j) in edges {
let vi = vx[i];
let vj = vx[j];
if vi.0 == 0 && vj.0 == 0 {
// Two unvisited vertices - create a new chain
vx[i].0 = 1;
vx[i].1 = chains.len();
vx[j].0 = 1;
vx[j].1 = chains.len();
chains.push(vec![i, j]);
} else if vi.0 == 0 && vj.0 == 1 {
// An unvisited vertex connects with an endpoint of an existing chain
vx[i].0 = 1;
vx[i].1 = vj.1;
vx[j].0 = 2;
let chain = &mut chains[vj.1];
if chain[0] == j {
chain.insert(0, i);
} else {
chain.push(i);
}
} else if vi.0 == 1 && vj.0 == 0 {
// An unvisited vertex connects with an endpoint of an existing chain
vx[j].0 = 1;
vx[j].1 = vi.1;
vx[i].0 = 2;
let chain = &mut chains[vi.1];
if chain[0] == i {
chain.insert(0, j);
} else {
chain.push(j);
}
} else if vi.0 == 1 && vj.0 == 1 && vi.1 != vj.1 {
// Two endpoints of different chains are connected together
vx[i].0 = 2;
vx[j].0 = 2;
let (a, b) = if vi.1 < vj.1 { (i, j) } else { (j, i) };
let ca = vx[a].1;
let cb = vx[b].1;
let chainb = std::mem::take(&mut chains[cb]);
for &v in &chainb {
vx[v].1 = ca;
}
let chaina = &mut chains[ca];
if chaina[0] == a && chainb[0] == b {
for v in chainb {
chaina.insert(0, v);
}
} else if chaina[0] == a {
chaina.splice(0..0, chainb);
} else if chainb[0] == b {
chaina.extend(chainb);
} else {
let pos = chaina.len();
for v in chainb {
chaina.insert(pos, v);
}
}
}

if chains[0].len() == num_colors {
break;
}
}

// Return the completed chain
chains.swap_remove(0)
}
Binary file modified tests/files/interlaced_palette_8_should_be_grayscale_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/files/palette_8_should_be_grayscale_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion tests/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ use std::ops::Deref;
use std::path::Path;
use std::path::PathBuf;

const GRAYSCALE: u8 = 0;
const RGB: u8 = 2;
const INDEXED: u8 = 3;
const RGBA: u8 = 6;

fn get_opts(input: &Path) -> (OutFile, oxipng::Options) {
let mut options = oxipng::Options {
force: true,
fast_evaluation: false,
..Default::default()
};
let mut filter = IndexSet::new();
Expand Down Expand Up @@ -597,7 +599,7 @@ fn fix_errors() {
}
};

assert_eq!(png.raw.ihdr.color_type.png_header_code(), INDEXED);
assert_eq!(png.raw.ihdr.color_type.png_header_code(), GRAYSCALE);
assert_eq!(png.raw.ihdr.bit_depth, BitDepth::Eight);

// Cannot check if pixels are equal because image crate cannot read corrupt (input) PNGs
Expand Down
2 changes: 2 additions & 0 deletions tests/interlaced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const RGBA: u8 = 6;
fn get_opts(input: &Path) -> (OutFile, oxipng::Options) {
let mut options = oxipng::Options {
force: true,
fast_evaluation: false,
interlace: None,
..Default::default()
};
let mut filter = IndexSet::new();
Expand Down
1 change: 1 addition & 0 deletions tests/reduction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const RGBA: u8 = 6;
fn get_opts(input: &Path) -> (OutFile, oxipng::Options) {
let mut options = oxipng::Options {
force: true,
fast_evaluation: false,
..Default::default()
};
let mut filter = IndexSet::new();
Expand Down
Loading