feat: add minimum gain threshold for writes

This commit is contained in:
kyc001 2026-02-26 00:36:35 +08:00
parent ea9caac249
commit 5fba1a42ee
No known key found for this signature in database
7 changed files with 263 additions and 13 deletions

View file

@ -171,6 +171,12 @@ Options:
--force
Write the output even if it is larger than the input
--min-gain <value>
Only write optimized output when savings meet this threshold. Accepts percentages
(e.g. 1%, 0.5%) or byte sizes (e.g. 1024, 4KiB, 1MB).
If savings is below the threshold, it is treated as no-change.
-z, --zopfli
Use the much slower but stronger Zopfli compressor for main compression trials.
Recommended use is with '-o max' and '--fast'.

View file

@ -57,6 +57,8 @@ The most commonly used options are as follows:
- Optimization: `-o 0` through `-o 6` (or `-o max`), lower is faster, higher is better compression.
The default (`-o 2`) is quite fast and provides good compression. Higher levels can be notably
better* but generally have increasingly diminishing returns.
- Minimum gain: `--min-gain <value>` only writes optimized output when savings reach a threshold.
Use a percentage (`1%`, `0.5%`) or bytes (`1024`, `4KiB`, `1MB`).
- Strip: Used to remove metadata info from processed images. Used via `--strip [safe,all]`.
Can save a few kilobytes if you don't need the metadata. "Safe" removes only metadata that
will never affect rendering of the image. "All" removes all metadata that is not critical

View file

@ -340,6 +340,19 @@ be processed successfully. The output will always have correct checksums.")
.long("force")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("min-gain")
.help("Require savings of at least <value> before writing")
.long_help("\
Only write optimized output if savings meets this threshold. The value may be specified as \
a percentage (e.g. '1%' or '0.5%') or as a byte size (e.g. '1024', '4KiB', '1MB').
If savings is below the threshold, the result is treated as no-change:
- in-place output will not overwrite the input file
- explicit output path, stdout, and dry-run will use original data")
.long("min-gain")
.value_name("value"),
)
.arg(
Arg::new("zopfli")
.help("Use the much slower but stronger Zopfli compressor")

View file

@ -30,7 +30,7 @@ pub use crate::{
error::PngError,
filters::{FilterStrategy, RowFilter},
headers::StripChunks,
options::{InFile, Options, OutFile},
options::{InFile, MinGain, Options, OutFile},
};
use crate::{
evaluate::{Candidate, Evaluator},
@ -167,6 +167,19 @@ impl RawImage {
///
/// Returns the original and optimized file sizes
pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
optimize_with(input, output, opts, None)
}
/// Perform optimization on the input file using the options provided, with an optional minimum
/// savings threshold.
///
/// Returns the original and optimized file sizes
pub fn optimize_with(
input: &InFile,
output: &OutFile,
opts: &Options,
min_gain: Option<MinGain>,
) -> OptimizationResult {
// Read in the file and try to decode as PNG.
info!("Processing: {input}");
@ -190,7 +203,7 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
let in_length = in_data.len();
if is_fully_optimized(in_length, optimized_output.len(), opts) {
if should_keep_original(in_length, optimized_output.len(), opts, min_gain) {
match (output, input) {
// If output path is None, it also means same as the input path
(OutFile::Path { path, .. }, InFile::Path(input_path))
@ -285,6 +298,15 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
/// Perform optimization on the input file using the options provided, where the file is already
/// loaded in-memory
pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult<Vec<u8>> {
optimize_from_memory_with(data, opts, None)
}
/// Perform optimization on in-memory image data with an optional minimum savings threshold.
pub fn optimize_from_memory_with(
data: &[u8],
opts: &Options,
min_gain: Option<MinGain>,
) -> PngResult<Vec<u8>> {
// Read in the file and try to decode as PNG.
info!("Processing from memory");
@ -296,7 +318,7 @@ pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult<Vec<u8>> {
// Run the optimizer on the decoded PNG.
let optimized_output = optimize_png(&mut png, data, opts, deadline)?;
if is_fully_optimized(original_size, optimized_output.len(), opts) {
if should_keep_original(original_size, optimized_output.len(), opts, min_gain) {
info!("Image already optimized");
Ok(data.to_vec())
} else {
@ -644,3 +666,13 @@ fn recompress_frames(
const fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool {
original_size <= optimized_size && !opts.force
}
fn should_keep_original(
original_size: usize,
optimized_size: usize,
opts: &Options,
min_gain: Option<MinGain>,
) -> bool {
is_fully_optimized(original_size, optimized_size, opts)
|| min_gain.is_some_and(|threshold| !threshold.is_satisfied(original_size, optimized_size))
}

View file

@ -20,8 +20,10 @@ use log::{Level, LevelFilter, error, warn};
#[cfg(feature = "zopfli")]
use oxipng::ZopfliOptions;
use oxipng::{
Deflater, FilterStrategy, InFile, OptimizationResult, Options, OutFile, PngError, StripChunks,
Deflater, FilterStrategy, InFile, MinGain, OptimizationResult, Options, OutFile, PngError,
StripChunks,
};
use parse_size::parse_size;
use rayon::prelude::*;
use crate::cli::DISPLAY_CHUNKS;
@ -38,7 +40,7 @@ fn main() -> ExitCode {
.after_long_help("")
.get_matches_from(std::env::args());
let (mut out_file, out_dir, opts) = match parse_opts_into_struct(&matches) {
let (mut out_file, out_dir, opts, min_gain) = match parse_opts_into_struct(&matches) {
Ok(x) => x,
Err(x) => {
error!("{x}");
@ -89,7 +91,7 @@ fn main() -> ExitCode {
stdout().flush().ok();
}
let process = |(input, output): &(InFile, OutFile)| {
let result = process_file(input, output, &opts);
let result = process_file(input, output, &opts, min_gain);
if print_progress && matches!(result, OptimizationResult::Ok(_)) {
let value = num_processed.fetch_add(1, AcqRel) + 1;
print!("\rFiles processed: {}/{}...", value, total_files);
@ -115,7 +117,7 @@ fn main() -> ExitCode {
num_succeeded += 1;
total_in += *insize as i64;
total_out += *outsize as i64;
if !opts.force && insize == outsize {
if insize == outsize && (!opts.force || min_gain.is_some()) {
num_not_optimized += 1;
}
}
@ -237,7 +239,7 @@ fn apply_glob_pattern(path: PathBuf) -> Vec<PathBuf> {
fn parse_opts_into_struct(
matches: &ArgMatches,
) -> Result<(OutFile, Option<PathBuf>, Options), String> {
) -> Result<(OutFile, Option<PathBuf>, Options, Option<MinGain>), String> {
let log_level = match matches.get_count("verbose") {
_ if matches.get_flag("quiet") => LevelFilter::Off,
0 => LevelFilter::Warn,
@ -344,6 +346,11 @@ fn parse_opts_into_struct(
opts.force = matches.get_flag("force");
let min_gain = matches
.get_one::<String>("min-gain")
.map(|value| parse_min_gain(value))
.transpose()?;
opts.fix_errors = matches.get_flag("fix");
opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize);
@ -450,7 +457,7 @@ fn parse_opts_into_struct(
.map_err(|err| err.to_string())?;
}
Ok((out_file, out_dir, opts))
Ok((out_file, out_dir, opts, min_gain))
}
fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
@ -460,6 +467,33 @@ fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
.map_err(|_| format!("Invalid chunk name {name}"))
}
fn parse_min_gain(value: &str) -> Result<MinGain, String> {
let value = value.trim();
if value.is_empty() {
return Err("Minimum gain must not be empty".to_owned());
}
if let Some(percent) = value.strip_suffix('%') {
let parsed = percent
.trim()
.parse::<f64>()
.map_err(|_| format!("Invalid percentage for --min-gain: {value}"))?;
if !(0.0..=100.0).contains(&parsed) || !parsed.is_finite() {
return Err(format!(
"Percentage for --min-gain must be between 0% and 100%: {value}"
));
}
return MinGain::ratio(parsed / 100.0)
.ok_or_else(|| format!("Invalid percentage for --min-gain: {value}"));
}
let parsed =
parse_size(value).map_err(|_| format!("Invalid byte size for --min-gain: {value}"))?;
let bytes = usize::try_from(parsed)
.map_err(|_| format!("Byte size for --min-gain is too large: {value}"))?;
Ok(MinGain::bytes(bytes))
}
fn parse_numeric_range_opts(
input: &str,
min_value: u8,
@ -513,7 +547,12 @@ fn parse_numeric_range_opts(
Err(ERROR_MESSAGE.to_owned())
}
fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
fn process_file(
input: &InFile,
output: &OutFile,
opts: &Options,
min_gain: Option<MinGain>,
) -> OptimizationResult {
if let (Some(max_size), InFile::Path(path)) = (opts.max_decompressed_size, input) {
if path.metadata().is_ok_and(|m| m.len() > max_size as u64) {
warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)");
@ -521,7 +560,7 @@ fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
}
}
let result = oxipng::optimize(input, output, opts);
let result = oxipng::optimize_with(input, output, opts, min_gain);
match &result {
Ok(_) => {}
Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => {
@ -631,7 +670,8 @@ fn format_bytes(count: i64, include_raw: bool) -> String {
#[cfg(test)]
mod tests {
use super::format_bytes;
use super::{format_bytes, parse_min_gain};
use oxipng::MinGain;
#[test]
fn test_format_bytes() {
@ -641,4 +681,23 @@ mod tests {
assert_eq!(format_bytes(2_000_000_000, false), "1.86 GiB");
assert_eq!(format_bytes(-1024, false), "-1.00 KiB");
}
#[test]
fn test_parse_min_gain_valid_values() {
assert_eq!(parse_min_gain("1024"), Ok(MinGain::Bytes(1024)));
assert_eq!(parse_min_gain("4KiB"), Ok(MinGain::Bytes(4096)));
assert_eq!(parse_min_gain("1MB"), Ok(MinGain::Bytes(1_000_000)));
let parsed = parse_min_gain("0.5%").expect("valid percent should parse");
assert!(matches!(parsed, MinGain::Ratio(ratio) if (ratio - 0.005).abs() < f64::EPSILON));
}
#[test]
fn test_parse_min_gain_invalid_values() {
assert!(parse_min_gain("").is_err());
assert!(parse_min_gain("-1").is_err());
assert!(parse_min_gain("-0.5%").is_err());
assert!(parse_min_gain("101%").is_err());
assert!(parse_min_gain("abc").is_err());
}
}

View file

@ -83,6 +83,50 @@ impl<T: Into<PathBuf>> From<T> for InFile {
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
/// Minimum savings threshold required to accept optimized output.
pub enum MinGain {
/// Require at least this many bytes to be saved.
Bytes(usize),
/// Require at least this ratio of the original size to be saved.
///
/// The value is represented as a fraction in the range `0.0..=1.0`,
/// where `0.01` means 1%.
Ratio(f64),
}
impl MinGain {
#[must_use]
pub const fn bytes(bytes: usize) -> Self {
Self::Bytes(bytes)
}
#[must_use]
pub fn ratio(ratio: f64) -> Option<Self> {
if ratio.is_finite() && (0.0..=1.0).contains(&ratio) {
Some(Self::Ratio(ratio))
} else {
None
}
}
/// Check whether a given optimized size satisfies this threshold.
#[must_use]
pub fn is_satisfied(self, original_size: usize, optimized_size: usize) -> bool {
let Some(saved_bytes) = original_size.checked_sub(optimized_size) else {
return false;
};
saved_bytes >= self.required_savings(original_size)
}
fn required_savings(self, original_size: usize) -> usize {
match self {
Self::Bytes(bytes) => bytes,
Self::Ratio(ratio) => (original_size as f64 * ratio).ceil() as usize,
}
}
}
#[derive(Clone, Debug)]
/// Options controlling the output of the `optimize` function
pub struct Options {

View file

@ -1,4 +1,10 @@
use std::{fs, fs::File, io::prelude::*};
use std::{
fs,
fs::File,
io::prelude::*,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use oxipng::*;
@ -87,3 +93,91 @@ fn optimize_srgb_icc() {
let result = oxipng::optimize_from_memory(&file, &opts);
assert!(result.unwrap().len() < 1000);
}
fn temp_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("oxipng-{name}-{}-{nanos}.png", std::process::id()))
}
#[test]
fn min_gain_bytes_skips_in_place_write() {
let input_data = fs::read("tests/files/verbose_mode.png").unwrap();
let opts = Options::default();
let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap();
assert!(
optimized.len() < input_data.len(),
"fixture should produce measurable savings"
);
let savings = input_data.len() - optimized.len();
let input_path = temp_path("min-gain-bytes-input");
fs::write(&input_path, &input_data).unwrap();
let mut permissions = fs::metadata(&input_path).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&input_path, permissions).unwrap();
let result = oxipng::optimize_with(
&InFile::Path(input_path.clone()),
&OutFile::Path {
path: None,
preserve_attrs: false,
},
&opts,
Some(MinGain::Bytes(savings + 1)),
)
.unwrap();
assert_eq!(result, (input_data.len(), input_data.len()));
assert_eq!(fs::read(&input_path).unwrap(), input_data);
let mut permissions = fs::metadata(&input_path).unwrap().permissions();
permissions.set_readonly(false);
fs::set_permissions(&input_path, permissions).unwrap();
fs::remove_file(&input_path).ok();
}
#[test]
fn min_gain_percentage_threshold_behavior() {
let input_data = fs::read("tests/files/verbose_mode.png").unwrap();
let opts = Options::default();
let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap();
assert!(
optimized.len() < input_data.len(),
"fixture should produce measurable savings"
);
let savings_ratio = (input_data.len() - optimized.len()) as f64 / input_data.len() as f64;
let high_threshold = MinGain::ratio((savings_ratio + 0.001).min(1.0)).unwrap();
let low_threshold = MinGain::ratio(savings_ratio / 2.0).unwrap();
let input_path = temp_path("min-gain-percent-input");
let high_output = temp_path("min-gain-percent-high");
let low_output = temp_path("min-gain-percent-low");
fs::write(&input_path, &input_data).unwrap();
let high_result = oxipng::optimize_with(
&InFile::Path(input_path.clone()),
&OutFile::from_path(high_output.clone()),
&opts,
Some(high_threshold),
)
.unwrap();
assert_eq!(high_result, (input_data.len(), input_data.len()));
assert_eq!(fs::read(&high_output).unwrap(), input_data);
let low_result = oxipng::optimize_with(
&InFile::Path(input_path.clone()),
&OutFile::from_path(low_output.clone()),
&opts,
Some(low_threshold),
)
.unwrap();
assert_eq!(low_result.0, input_data.len());
assert!(low_result.1 < input_data.len());
assert_eq!(fs::read(&low_output).unwrap().len(), low_result.1);
fs::remove_file(&input_path).ok();
fs::remove_file(&high_output).ok();
fs::remove_file(&low_output).ok();
}