Additional palette sorting algorithm (#514)
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 + #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 #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 #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
This commit is contained in:
parent
4ae64c568b
commit
b883c660fc
11 changed files with 227 additions and 10 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -69,24 +71,30 @@ 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
|
||||
|
|
@ -94,6 +102,7 @@ pub fn sorted_palette(png: &PngImage) -> Option<PngImage> {
|
|||
};
|
||||
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();
|
||||
|
|
@ -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 not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 63 KiB |
|
|
@ -11,6 +11,7 @@ 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;
|
||||
|
|
@ -18,6 +19,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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue