Skip to content

Commit

Permalink
Additional palette sorting algorithm (shssoichiro#514)
Browse files Browse the repository at this point in the history
This adds a new palette sorting algorithm that attempts to minimise
entropy by an approximate solution to the Traveling Salesman Problem.
The algorithm comes from "An efficient Re-indexing algorithm for
color-mapped images" by Battiato et al
(https://ieeexplore.ieee.org/document/1344033).
It's fast and effective and works in addition to the luma sort (which
remains the single most effective sort). In order to keep lower presets
fast though, I've only enabled this for o3 and higher.

Results on a set of 190 indexed images at `-o5`:
18,932,727 bytes - master
18,578,306 bytes - PR
18,559,863 bytes - PR + shssoichiro#509
(These images may be particularly suited to alternative sorting methods
- the gains here are not necessarily what should be expected on average)

Note I looked into the 120 different palette sorting methods from
TruePNG, as mentioned in shssoichiro#74 (and seen in action in the Zopfli KrzYmod
fork). They're... largely ineffective. The combination of all 120
methods are outperformed by just the existing luma sort plus this new
one. That's not to say there's nothing further to be gained from them,
but trying to brute force all the combinations definitely seems like a
bad idea. There are other algorithms I hope to explore in future...

@ace-dent Thought this might interest you


UPDATE: I realised a quick tweak to alpha values in the luma sort can
provide a great improvement on images with transparency. The following
numbers were taken with PR shssoichiro#509 as base.
`-o2`:
19,065,549 bytes - base (luma sort)
18,949,747 bytes - modified luma sort

`-o5`:
18,922,165 bytes - base (luma sort)
18,559,863 bytes - new sorting algorithm + luma sort
18,544,813 bytes - new sorting algorithm + modified luma sort
  • Loading branch information
andrews05 authored and Pr0methean committed Dec 1, 2023
1 parent 003f700 commit d171d66
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 10 deletions.
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 @@ -12,13 +12,15 @@ use std::path::Path;
use std::path::PathBuf;
use oxipng::Deflaters::Zopfli;

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 @@ -598,7 +600,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

0 comments on commit d171d66

Please sign in to comment.