Code always tends to get messy over time. I've found the `optimize_raw`
function increasingly harder to read, particularly after the addition of
fast mode, so I've taken some time to refactor and simplify it.
One change of note here is the main compression trials now use the
Evaluator. This means verbose output is a little different which is
shown below.
There is no change to performance or output size.
`-vvo2`: master
```
Processing: tests/files/rgba_8_should_be_palette_4.png
500x400 pixels, PNG format
8-bit RGB + Alpha, non-interlaced
IDAT size = 2757 bytes
File size = 18109 bytes
Eval: 4-bit Indexed (5 colors) None 1837 bytes
Eval: 8-bit Indexed (5 colors) None 1988 bytes
Eval: 4-bit Indexed (5 colors) Bigrams >1837 bytes
Eval: 8-bit Indexed (5 colors) Bigrams >1837 bytes
Transformed image to 4-bit Indexed (5 colors), non-interlaced
Evaluating: 2 filters
Eval: 4-bit Indexed (5 colors) Sub >1810 bytes
Eval: 4-bit Indexed (5 colors) Entropy >1810 bytes
Trying: None
zc = 11 f = None 1583 bytes
Found better combination:
zc = 11 f = None 1583 bytes
IDAT size = 1583 bytes (1174 bytes decrease)
file size = 16962 bytes (1147 bytes = 6.33% decrease)
16962 bytes (6.33% smaller): Running in pretend mode, no output
```
`-vvo2`: PR
```
Processing: tests/files/rgba_8_should_be_palette_4.png
500x400 pixels, PNG format
8-bit RGB + Alpha, non-interlaced
IDAT size = 2757 bytes
File size = 18109 bytes
Eval: 4-bit Indexed (5 colors) None 1837 bytes
Eval: 8-bit Indexed (5 colors) None 1988 bytes
Eval: 4-bit Indexed (5 colors) Bigrams >1837 bytes
Eval: 8-bit Indexed (5 colors) Bigrams >1837 bytes
Transformed image to 4-bit Indexed (5 colors), non-interlaced
Evaluating 2 filters
Eval: 4-bit Indexed (5 colors) Sub >1810 bytes
Eval: 4-bit Indexed (5 colors) Entropy >1810 bytes
Trying filter None with zc = 11
1610 bytes
Found better result:
zc = 11, f = None
IDAT size = 1583 bytes (1174 bytes decrease)
file size = 16962 bytes (1147 bytes = 6.33% decrease)
16962 bytes (6.33% smaller): Running in pretend mode, no output
```
`-vvZo5`: master
```
Processing: tests/files/rgba_8_should_be_palette_4.png
500x400 pixels, PNG format
8-bit RGB + Alpha, non-interlaced
IDAT size = 2757 bytes
File size = 18109 bytes
Eval: 8-bit Indexed (battiato sort) None 1821 bytes
Eval: 4-bit Indexed (5 colors) None 1657 bytes
Eval: 8-bit Indexed (mzeng sort) None 1821 bytes
Eval: 8-bit Indexed (5 colors) None 1821 bytes
Eval: 8-bit Indexed (battiato sort) Bigrams >1821 bytes
Eval: 4-bit Indexed (5 colors) Bigrams >1657 bytes
Eval: 8-bit Indexed (mzeng sort) Bigrams >1657 bytes
Eval: 8-bit Indexed (5 colors) Bigrams >1657 bytes
Transformed image to 4-bit Indexed (5 colors), non-interlaced
Trying: 8 filters
zc = zopfli f = Brute 1562 bytes
zc = zopfli f = Sub >1562 bytes
zc = zopfli f = Bigrams >1562 bytes
zc = zopfli f = None 1407 bytes
zc = zopfli f = Up >1407 bytes
zc = zopfli f = MinSum >1407 bytes
zc = zopfli f = BigEnt >1407 bytes
zc = zopfli f = Entropy >1407 bytes
Found better combination:
zc = zopfli f = None 1407 bytes
IDAT size = 1407 bytes (1350 bytes decrease)
file size = 16786 bytes (1323 bytes = 7.31% decrease)
16786 bytes (7.31% smaller): Running in pretend mode, no output
```
`-vvZo5`: PR
```
Processing: tests/files/rgba_8_should_be_palette_4.png
500x400 pixels, PNG format
8-bit RGB + Alpha, non-interlaced
IDAT size = 2757 bytes
File size = 18109 bytes
Eval: 8-bit Indexed (battiato sort) None 1821 bytes
Eval: 4-bit Indexed (5 colors) None 1657 bytes
Eval: 8-bit Indexed (mzeng sort) None 1821 bytes
Eval: 8-bit Indexed (5 colors) None 1821 bytes
Eval: 8-bit Indexed (battiato sort) Bigrams >1657 bytes
Eval: 4-bit Indexed (5 colors) Bigrams >1657 bytes
Eval: 8-bit Indexed (mzeng sort) Bigrams >1657 bytes
Eval: 8-bit Indexed (5 colors) Bigrams >1657 bytes
Transformed image to 4-bit Indexed (5 colors), non-interlaced
Trying 8 filters with zopfli, zi = 15
Eval: 4-bit Indexed (5 colors) Brute 1589 bytes
Eval: 4-bit Indexed (5 colors) Bigrams 1641 bytes
Eval: 4-bit Indexed (5 colors) Sub 1711 bytes
Eval: 4-bit Indexed (5 colors) None 1434 bytes
Eval: 4-bit Indexed (5 colors) Up 1764 bytes
Eval: 4-bit Indexed (5 colors) MinSum 1760 bytes
Eval: 4-bit Indexed (5 colors) BigEnt 1742 bytes
Eval: 4-bit Indexed (5 colors) Entropy 1748 bytes
Found better result:
zopfli, zi = 15, f = None
IDAT size = 1407 bytes (1350 bytes decrease)
file size = 16786 bytes (1323 bytes = 7.31% decrease)
16786 bytes (7.31% smaller): Running in pretend mode, no output
```
193 lines
6.8 KiB
Rust
193 lines
6.8 KiB
Rust
//! Check if a reduction makes file smaller, and keep best reductions.
|
|
//! Works asynchronously when possible
|
|
|
|
#[cfg(not(feature = "parallel"))]
|
|
use std::cell::RefCell;
|
|
use std::sync::{
|
|
atomic::{AtomicUsize, Ordering::*},
|
|
Arc,
|
|
};
|
|
|
|
#[cfg(feature = "parallel")]
|
|
use crossbeam_channel::{unbounded, Receiver, Sender};
|
|
use deflate::Deflaters;
|
|
use indexmap::IndexSet;
|
|
use log::trace;
|
|
use rayon::prelude::*;
|
|
|
|
#[cfg(not(feature = "parallel"))]
|
|
use crate::rayon;
|
|
use crate::{atomicmin::AtomicMin, deflate, filters::RowFilter, png::PngImage, Deadline, PngError};
|
|
|
|
pub(crate) struct Candidate {
|
|
pub image: Arc<PngImage>,
|
|
pub idat_data: Vec<u8>,
|
|
pub filtered: Vec<u8>,
|
|
pub filter: RowFilter,
|
|
// For determining tie-breaker
|
|
nth: usize,
|
|
}
|
|
|
|
impl Candidate {
|
|
/// Return an estimate of the output size which can help with evaluation of very small data
|
|
#[must_use]
|
|
pub fn estimated_output_size(&self) -> usize {
|
|
self.idat_data.len() + self.image.key_chunks_size()
|
|
}
|
|
|
|
fn cmp_key(&self) -> impl Ord {
|
|
(
|
|
self.estimated_output_size(),
|
|
self.image.data.len(),
|
|
self.filter,
|
|
// Prefer the later image added (e.g. baseline, which is always added last)
|
|
usize::MAX - self.nth,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Collect image versions and pick one that compresses best
|
|
pub(crate) struct Evaluator {
|
|
deadline: Arc<Deadline>,
|
|
filters: IndexSet<RowFilter>,
|
|
deflater: Deflaters,
|
|
optimize_alpha: bool,
|
|
nth: AtomicUsize,
|
|
executed: Arc<AtomicUsize>,
|
|
best_candidate_size: Arc<AtomicMin>,
|
|
/// images are sent to the caller thread for evaluation
|
|
#[cfg(feature = "parallel")]
|
|
eval_channel: (Sender<Candidate>, Receiver<Candidate>),
|
|
// in non-parallel mode, images are evaluated synchronously
|
|
#[cfg(not(feature = "parallel"))]
|
|
eval_best_candidate: RefCell<Option<Candidate>>,
|
|
}
|
|
|
|
impl Evaluator {
|
|
pub fn new(
|
|
deadline: Arc<Deadline>,
|
|
filters: IndexSet<RowFilter>,
|
|
deflater: Deflaters,
|
|
optimize_alpha: bool,
|
|
) -> Self {
|
|
#[cfg(feature = "parallel")]
|
|
let eval_channel = unbounded();
|
|
Self {
|
|
deadline,
|
|
filters,
|
|
deflater,
|
|
optimize_alpha,
|
|
nth: AtomicUsize::new(0),
|
|
executed: Arc::new(AtomicUsize::new(0)),
|
|
best_candidate_size: Arc::new(AtomicMin::new(None)),
|
|
#[cfg(feature = "parallel")]
|
|
eval_channel,
|
|
#[cfg(not(feature = "parallel"))]
|
|
eval_best_candidate: RefCell::new(None),
|
|
}
|
|
}
|
|
|
|
/// Wait for all evaluations to finish and return smallest reduction
|
|
/// Or `None` if the queue is empty.
|
|
#[cfg(feature = "parallel")]
|
|
pub fn get_best_candidate(self) -> Option<Candidate> {
|
|
let (eval_send, eval_recv) = self.eval_channel;
|
|
// Disconnect the sender, breaking the loop in the thread
|
|
drop(eval_send);
|
|
let nth = self.nth.load(SeqCst);
|
|
// Yield to ensure all evaluations are executed
|
|
// This can prevent deadlocks when run within an existing rayon thread pool
|
|
while self.executed.load(Relaxed) < nth {
|
|
rayon::yield_local();
|
|
}
|
|
eval_recv.into_iter().min_by_key(Candidate::cmp_key)
|
|
}
|
|
|
|
#[cfg(not(feature = "parallel"))]
|
|
pub fn get_best_candidate(self) -> Option<Candidate> {
|
|
self.eval_best_candidate.into_inner()
|
|
}
|
|
|
|
/// Set best size, if known in advance
|
|
pub fn set_best_size(&self, size: usize) {
|
|
self.best_candidate_size.set_min(size);
|
|
}
|
|
|
|
/// Check if the image is smaller than others
|
|
pub fn try_image(&self, image: Arc<PngImage>) {
|
|
let description = format!("{}", image.ihdr.color_type);
|
|
self.try_image_with_description(image, &description);
|
|
}
|
|
|
|
/// Check if the image is smaller than others, with a description for verbose mode
|
|
pub fn try_image_with_description(&self, image: Arc<PngImage>, description: &str) {
|
|
let nth = self.nth.fetch_add(1, SeqCst);
|
|
// These clones are only cheap refcounts
|
|
let deadline = self.deadline.clone();
|
|
let filters = self.filters.clone();
|
|
let deflater = self.deflater;
|
|
let optimize_alpha = self.optimize_alpha;
|
|
let executed = self.executed.clone();
|
|
let best_candidate_size = self.best_candidate_size.clone();
|
|
let description = description.to_string();
|
|
// sends it off asynchronously for compression,
|
|
// but results will be collected via the message queue
|
|
#[cfg(feature = "parallel")]
|
|
let eval_send = self.eval_channel.0.clone();
|
|
rayon::spawn(move || {
|
|
executed.fetch_add(1, Relaxed);
|
|
let filters_iter = filters.par_iter().with_max_len(1);
|
|
|
|
// Updating of best result inside the parallel loop would require locks,
|
|
// which are dangerous to do in side Rayon's loop.
|
|
// Instead, only update (atomic) best size in real time,
|
|
// and the best result later without need for locks.
|
|
filters_iter.for_each(|&filter| {
|
|
if deadline.passed() {
|
|
return;
|
|
}
|
|
let filtered = image.filter_image(filter, optimize_alpha);
|
|
let idat_data = deflater.deflate(&filtered, best_candidate_size.get());
|
|
if let Ok(idat_data) = idat_data {
|
|
let new = Candidate {
|
|
image: image.clone(),
|
|
idat_data,
|
|
filtered,
|
|
filter,
|
|
nth,
|
|
};
|
|
let size = new.estimated_output_size();
|
|
best_candidate_size.set_min(size);
|
|
trace!(
|
|
"Eval: {}-bit {:23} {:8} {} bytes",
|
|
image.ihdr.bit_depth,
|
|
description,
|
|
filter,
|
|
size
|
|
);
|
|
|
|
#[cfg(feature = "parallel")]
|
|
{
|
|
eval_send.send(new).expect("send");
|
|
}
|
|
|
|
#[cfg(not(feature = "parallel"))]
|
|
{
|
|
match &mut *self.eval_best_candidate.borrow_mut() {
|
|
Some(prev) if prev.cmp_key() < new.cmp_key() => {}
|
|
best => *best = Some(new),
|
|
}
|
|
}
|
|
} else if let Err(PngError::DeflatedDataTooLong(size)) = idat_data {
|
|
trace!(
|
|
"Eval: {}-bit {:23} {:8} >{} bytes",
|
|
image.ihdr.bit_depth,
|
|
description,
|
|
filter,
|
|
size
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|