feat: add minimum gain threshold for writes

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

View file

@ -181,6 +181,12 @@ Options:
--force --force
Write the output even if it is larger than the input 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 -z, --zopfli
Use the much slower but stronger Zopfli compressor for main compression trials. Use the much slower but stronger Zopfli compressor for main compression trials.
Recommended use is with '-o max' and '--fast'. 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. - 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 The default (`-o 2`) is quite fast and provides good compression. Higher levels can be notably
better* but generally have increasingly diminishing returns. 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]`. - 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 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 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") .long("force")
.action(ArgAction::SetTrue), .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(
Arg::new("zopfli") Arg::new("zopfli")
.help("Use the much slower but stronger Zopfli compressor") .help("Use the much slower but stronger Zopfli compressor")

View file

@ -30,7 +30,7 @@ pub use crate::{
error::PngError, error::PngError,
filters::{FilterStrategy, RowFilter}, filters::{FilterStrategy, RowFilter},
headers::StripChunks, headers::StripChunks,
options::{InFile, Options, OutFile}, options::{InFile, MinGain, Options, OutFile},
}; };
use crate::{ use crate::{
evaluate::{Candidate, Evaluator}, evaluate::{Candidate, Evaluator},
@ -167,6 +167,19 @@ impl RawImage {
/// ///
/// Returns the original and optimized file sizes /// Returns the original and optimized file sizes
pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult { 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. // Read in the file and try to decode as PNG.
info!("Processing: {input}"); info!("Processing: {input}");
@ -190,7 +203,7 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
let in_length = in_data.len(); 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) { match (output, input) {
// If output path is None, it also means same as the input path // If output path is None, it also means same as the input path
(OutFile::Path { path, .. }, InFile::Path(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 /// Perform optimization on the input file using the options provided, where the file is already
/// loaded in-memory /// loaded in-memory
pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult<Vec<u8>> { 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. // Read in the file and try to decode as PNG.
info!("Processing from memory"); 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. // Run the optimizer on the decoded PNG.
let optimized_output = optimize_png(&mut png, data, opts, deadline)?; 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"); info!("Image already optimized");
Ok(data.to_vec()) Ok(data.to_vec())
} else { } else {
@ -644,3 +666,13 @@ fn recompress_frames(
const fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool { const fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool {
original_size <= optimized_size && !opts.force 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")] #[cfg(feature = "zopfli")]
use oxipng::ZopfliOptions; use oxipng::ZopfliOptions;
use oxipng::{ 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 rayon::prelude::*;
use crate::cli::DISPLAY_CHUNKS; use crate::cli::DISPLAY_CHUNKS;
@ -38,7 +40,7 @@ fn main() -> ExitCode {
.after_long_help("") .after_long_help("")
.get_matches_from(std::env::args()); .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, Ok(x) => x,
Err(x) => { Err(x) => {
error!("{x}"); error!("{x}");
@ -89,7 +91,7 @@ fn main() -> ExitCode {
stdout().flush().ok(); stdout().flush().ok();
} }
let process = |(input, output): &(InFile, OutFile)| { 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(_)) { if print_progress && matches!(result, OptimizationResult::Ok(_)) {
let value = num_processed.fetch_add(1, AcqRel) + 1; let value = num_processed.fetch_add(1, AcqRel) + 1;
print!("\rFiles processed: {}/{}...", value, total_files); print!("\rFiles processed: {}/{}...", value, total_files);
@ -115,7 +117,7 @@ fn main() -> ExitCode {
num_succeeded += 1; num_succeeded += 1;
total_in += *insize as i64; total_in += *insize as i64;
total_out += *outsize 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; num_not_optimized += 1;
} }
} }
@ -292,7 +294,7 @@ fn apply_glob_pattern(path: PathBuf) -> Vec<PathBuf> {
fn parse_opts_into_struct( fn parse_opts_into_struct(
matches: &ArgMatches, matches: &ArgMatches,
) -> Result<(OutFile, Option<PathBuf>, Options), String> { ) -> Result<(OutFile, Option<PathBuf>, Options, Option<MinGain>), String> {
let log_level = match matches.get_count("verbose") { let log_level = match matches.get_count("verbose") {
_ if matches.get_flag("quiet") => LevelFilter::Off, _ if matches.get_flag("quiet") => LevelFilter::Off,
0 => LevelFilter::Warn, 0 => LevelFilter::Warn,
@ -399,6 +401,11 @@ fn parse_opts_into_struct(
opts.force = matches.get_flag("force"); 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.fix_errors = matches.get_flag("fix");
opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize); opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize);
@ -505,7 +512,7 @@ fn parse_opts_into_struct(
.map_err(|err| err.to_string())?; .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> { fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
@ -515,6 +522,33 @@ fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
.map_err(|_| format!("Invalid chunk name {name}")) .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( fn parse_numeric_range_opts(
input: &str, input: &str,
min_value: u8, min_value: u8,
@ -568,7 +602,12 @@ fn parse_numeric_range_opts(
Err(ERROR_MESSAGE.to_owned()) 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 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) { if path.metadata().is_ok_and(|m| m.len() > max_size as u64) {
warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)"); warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)");
@ -576,7 +615,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 { match &result {
Ok(_) => {} Ok(_) => {}
Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => { Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => {
@ -686,7 +725,8 @@ fn format_bytes(count: i64, include_raw: bool) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{escape_square_brackets_in_dir_prefix, format_bytes}; use super::{escape_square_brackets_in_dir_prefix, format_bytes, parse_min_gain};
use oxipng::MinGain;
#[test] #[test]
fn test_format_bytes() { fn test_format_bytes() {
@ -716,4 +756,23 @@ mod tests {
let pattern = r"F:\[a]bug test\image.png"; let pattern = r"F:\[a]bug test\image.png";
assert!(escape_square_brackets_in_dir_prefix(pattern).is_none()); assert!(escape_square_brackets_in_dir_prefix(pattern).is_none());
} }
#[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)] #[derive(Clone, Debug)]
/// Options controlling the output of the `optimize` function /// Options controlling the output of the `optimize` function
pub struct Options { 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::*; use oxipng::*;
@ -87,3 +93,91 @@ fn optimize_srgb_icc() {
let result = oxipng::optimize_from_memory(&file, &opts); let result = oxipng::optimize_from_memory(&file, &opts);
assert!(result.unwrap().len() < 1000); 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();
}