Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
### ⚠️ BREAKING CHANGES

* **sqzer:** `Output` has a new public field, `lossless`: the bytes hold exactly the samples the encoder was given. Code that builds an `Output` by hand sets it, and code that read `Output::target` to learn whether an encode was lossless reads the field instead (ADR-0012 D4)
* **metrics:** `Trial` has a new public field, `size`: the bytes the trial encoded to. `Search::run` takes a closure that returns `Scored`, the output with its score and its size, in place of the pair of output and score. Code that builds a `Trial` or calls `run` adds the size
* **sqzer:** `Progress::Trial` has a new field, `size`, the bytes the trial encoded to. A match that names every field adds it, or `..`
* **cli:** `--lossless` and `--preset lossless` refuse `-x png:optimize_alpha=true`, which changes the colour under fully transparent pixels. Drop `--lossless`: PNG comes from a lossless-only encoder either way, so the file is the same. `--json` now reports such a run as `"lossless": false`

### Features

* **codecs:** `-x png:colors=<2..=256>` reduces a PNG to a palette, what `rimage` had as `--quantization`, through `quantizr`. `-x png:dither=<0..=100>` sets how much of the error is diffused, 100 by default. An image that already has that few colours is written exactly, at its own bit depth. The output is lossy: `--json` and the npm package say `"lossless": false`, `{quality}` in a `--template` renders `lossy`, and `--lossless` refuses the option (ADR-0012)
* **core:** `Encoder::exact` says whether an encode at a lossless target keeps every sample under the given options. It defaults to `true`; a backend with an option that gives samples up overrides it, as `oxipng` does for `png:colors` and `png:optimize_alpha`
* **sqzer:** `Sqzer::lossy_options` names the options set on the builder under which an encoder gives samples up, for a caller that wants to refuse a lossless target before encoding
* **cli:** each entry of `trials` in a `--json` record carries `size`, the bytes that trial encoded to. `-v` prints it after the score, and the progress line shows it per trial
* **wasm:** each `Trial` in `trials`, and what `onTrial` receives, carries `size`, the bytes that trial encoded to, so a page can plot quality against score and size as the search runs ([#55](https://github.com/sqzer-dev/sqzer/issues/55))

## 0.3.0

Expand Down
15 changes: 13 additions & 2 deletions crates/sqzer-cli/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub struct Trial {
pub quality: f32,
/// SSIMULACRA2 score.
pub score: f32,
/// Size of the encoded trial in bytes.
pub size: u64,
}

/// One line of `--json`: one input, one output. Fields are omitted when
Expand Down Expand Up @@ -187,6 +189,7 @@ impl Record {
.map(|t| Trial {
quality: t.quality,
score: t.score,
size: t.size,
})
.collect(),
);
Expand Down Expand Up @@ -425,6 +428,8 @@ pub enum Stage {
quality: f32,
/// Score it reached.
score: f32,
/// Size of the encoded trial in bytes.
size: u64,
},
/// Writing the output.
Write,
Expand All @@ -438,11 +443,13 @@ impl From<sqzer::Progress> for Stage {
max,
quality,
score,
size,
} => Self::Trial {
n,
max,
quality,
score,
size,
},
// Stages the facade may add later show as plain encoding.
_ => Self::Encode,
Expand Down Expand Up @@ -493,9 +500,13 @@ impl Worker<'_> {
max,
quality,
score,
size,
} => (
"magenta",
format!("search {n}/{max} q{quality} -> {score:.1}"),
format!(
"search {n}/{max} q{quality} -> {score:.1} {}",
fmt_bytes(size)
),
),
Stage::Write => ("green", "write".to_string()),
};
Expand Down Expand Up @@ -638,7 +649,7 @@ impl Printer {
{
let list: Vec<String> = trials
.iter()
.map(|t| format!("q{} {:.2}", t.quality, t.score))
.map(|t| format!("q{} {:.2} {}", t.quality, t.score, fmt_bytes(t.size)))
.collect();
let _ = writeln!(
err,
Expand Down
14 changes: 14 additions & 0 deletions crates/sqzer-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,20 @@ fn shape_1_one_photo_becomes_a_sibling_avif() {
assert!(r["quality"].is_number(), "{r}");
assert!(r["iterations"].as_u64().unwrap() <= 6, "{r}");
assert!(r["trials"].is_array(), "{r}");
// Every trial carries the size of its encode, and the chosen one is
// the file written.
let trials = r["trials"].as_array().unwrap();
assert!(
trials
.iter()
.all(|t| t["size"].as_u64().is_some_and(|s| s > 0)),
"{r}"
);
let chosen = trials
.iter()
.find(|t| t["quality"] == r["quality"])
.unwrap();
assert_eq!(chosen["size"], r["output_bytes"], "{r}");
assert!(is_avif(&fs::read(sb.path("photo.avif")).unwrap()));
}

Expand Down
2 changes: 1 addition & 1 deletion crates/sqzer-metrics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ pub mod search;
pub mod seeds;
pub mod ssimulacra2;

pub use search::{Found, Search, SearchReport, Trial};
pub use search::{Found, Scored, Search, SearchReport, Trial};
pub use seeds::{Seed, SeedPoint, SeedTable};
pub use ssimulacra2::{Reference, Ssimulacra2};
69 changes: 58 additions & 11 deletions crates/sqzer-metrics/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub struct Trial {
pub quality: f32,
/// Metric score at that quality.
pub score: f32,
/// Size of the encoded output in bytes.
pub size: u64,
}

/// Result of one target search.
Expand Down Expand Up @@ -65,6 +67,17 @@ pub struct Found<T> {
pub report: SearchReport,
}

/// What one trial of [`Search::run`] produced.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scored<T> {
/// The encoded output.
pub output: T,
/// Its metric score.
pub score: f32,
/// Its size in bytes, carried into [`Trial::size`].
pub size: u64,
}

/// Search settings.
#[derive(Debug, Clone, PartialEq)]
pub struct Search {
Expand Down Expand Up @@ -159,12 +172,12 @@ impl Search {
}

/// Run the search over `trial`, which encodes at the given quality and
/// returns the encoded output together with its score.
/// returns the encoded output with its score and its size in bytes.
///
/// # Errors
/// [`Error::InvalidParams`] from [`Search::validate`], or the first
/// error `trial` returns.
pub fn run<T>(&self, mut trial: impl FnMut(f32) -> Result<(T, f32)>) -> Result<Found<T>> {
pub fn run<T>(&self, mut trial: impl FnMut(f32) -> Result<Scored<T>>) -> Result<Found<T>> {
self.validate()?;
let floor = self.floor.round();
let ceiling = self.ceiling.round();
Expand Down Expand Up @@ -202,10 +215,15 @@ impl Search {
midpoint(lo, hi)
};
}
let (output, score) = trial(next)?;
let Scored {
output,
score,
size,
} = trial(next)?;
trials.push(Trial {
quality: next,
score,
size,
});
let reaches = score >= self.target - self.tolerance;
let better = match &best {
Expand Down Expand Up @@ -325,8 +343,18 @@ impl Search {
Err(e) => return Err(e),
};
let s = score(&decoded)?;
observe(Trial { quality, score: s });
Ok((bytes, s))
// The output is a byte vector, so the size fits.
let size = bytes.len() as u64;
observe(Trial {
quality,
score: s,
size,
});
Ok(Scored {
output: bytes,
score: s,
size,
})
})
}
}
Expand All @@ -342,10 +370,29 @@ fn midpoint(lo: f32, hi: f32) -> f32 {
mod tests {
use super::*;

/// Run the search over a pure score curve, returning the quality as
/// the output so the two can be checked against each other.
/// A trial over a pure curve: the quality as the output, so the two
/// can be checked against each other, and a size that follows it.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn scored(q: f32, score: f32) -> Scored<f32> {
Scored {
output: q,
score,
size: (q * 100.0) as u64,
}
}

/// Run the search over a pure score curve.
fn over(search: &Search, curve: impl Fn(f32) -> f32) -> Found<f32> {
search.run(|q| Ok((q, curve(q)))).unwrap()
search.run(|q| Ok(scored(q, curve(q)))).unwrap()
}

#[test]
fn trials_carry_the_size_of_their_output() {
let found = over(&Search::new(70.0), |q| q * 0.9);
assert!(found.report.iterations > 1);
for t in &found.report.trials {
assert_eq!(t.size, scored(t.quality, 0.0).size, "{t:?}");
}
}

/// Lowest whole quality whose score reaches the target under the
Expand Down Expand Up @@ -393,7 +440,7 @@ mod tests {
let found = search
.run(|q| {
seen.push(q);
Ok((q, q * 0.9))
Ok(scored(q, q * 0.9))
})
.unwrap();
assert_eq!(
Expand Down Expand Up @@ -645,7 +692,7 @@ mod tests {
fn settings_are_validated() {
let bad = |s: Search| {
assert!(matches!(
s.run(|q| Ok((q, q))),
s.run(|q| Ok(scored(q, q))),
Err(Error::InvalidParams(_))
));
};
Expand Down Expand Up @@ -687,7 +734,7 @@ mod tests {
fn trial_errors_propagate() {
let mut calls = 0;
let err = Search::new(70.0)
.run(|_: f32| -> Result<((), f32)> {
.run(|_: f32| -> Result<Scored<()>> {
calls += 1;
Err(Error::Codec("boom".into()))
})
Expand Down
4 changes: 2 additions & 2 deletions crates/sqzer-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ enlarge allow scaling up. nothing scales up without it
filter "lanczos3" | "mitchell" | "catmull-rom" | "bilinear" | "box" | "nearest"
maxPixels refuse a larger image. default 24 million
codecOpts backend options, { "jpeg:progressive": "false" }. codecs() lists the keys
onTrial called per trial of the search with { n, max, quality, score }
onTrial called per trial of the search with { n, max, quality, score, size }
```

> **Note**: `maxPixels` defaults to 24 million, not the 268 million of the command line. The target search needs about 160 bytes per pixel, and a WebAssembly module addresses 4 GiB.
Expand All @@ -94,7 +94,7 @@ onTrial called per trial of the search with { n, max, quality, score }
content: "photo", outputWidth: 1600, outputHeight: 1200,
quality: 62.5, lossless: false,
target: 70, score: 70.3, reached: true, capped: false, iterations: 3,
trials: [{ quality: 62, score: 69.1 }, ...]
trials: [{ quality: 62, score: 69.1, size: 48213 }, ...]
}
```

Expand Down
2 changes: 2 additions & 0 deletions crates/sqzer-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ fn encode(
max,
quality,
score,
size,
},
) = (on_trial, progress)
else {
Expand All @@ -435,6 +436,7 @@ fn encode(
max,
quality: tidy(quality),
score: tidy(score),
size,
};
let called = serde_wasm_bindgen::to_value(&trial)
.map_err(JsValue::from)
Expand Down
5 changes: 5 additions & 0 deletions crates/sqzer-wasm/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ pub struct Trial {
pub quality: f64,
/// SSIMULACRA2 score.
pub score: f64,
/// Size of the encoded trial in bytes.
pub size: u64,
}

/// What `onTrial` receives: a trial as it is scored.
Expand All @@ -131,6 +133,8 @@ pub struct TrialProgress {
pub quality: f64,
/// SSIMULACRA2 score it reached.
pub score: f64,
/// Size of the encoded trial in bytes.
pub size: u64,
}

impl Output {
Expand Down Expand Up @@ -174,6 +178,7 @@ impl Output {
.map(|t| Trial {
quality: tidy(t.quality),
score: tidy(t.score),
size: t.size,
})
.collect()
}),
Expand Down
10 changes: 10 additions & 0 deletions crates/sqzer-wasm/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,17 @@ fn on_trial_is_called_once_per_trial() {
assert_eq!(number(called, "max"), 6.0);
assert_eq!(number(called, "quality"), number(&listed, "quality"));
assert_eq!(number(called, "score"), number(&listed, "score"));
assert_eq!(number(called, "size"), number(&listed, "size"));
assert!(number(called, "size") > 0.0);
}
// The chosen trial's size is the file's.
let quality = number(&out, "quality");
let chosen = trials
.iter()
.find(|t| number(t, "quality") == quality)
.unwrap();
let size = f64::from(u32::try_from(bytes(&out).len()).unwrap());
assert_eq!(number(&chosen, "size"), size);
// No search, no call.
let before = seen.borrow().len();
let png = JSON::parse(r#"{"format":"png"}"#).unwrap();
Expand Down
10 changes: 10 additions & 0 deletions crates/sqzer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ pub enum Progress {
quality: f32,
/// Score it reached.
score: f32,
/// Size of the encoded trial in bytes.
size: u64,
},
}

Expand Down Expand Up @@ -526,6 +528,7 @@ impl Sqzer {
max,
quality: trial.quality,
score: trial.score,
size: trial.size,
});
},
)?;
Expand Down Expand Up @@ -1285,6 +1288,13 @@ mod tests {
.map(|(i, t)| (u8::try_from(i + 1).unwrap(), t.quality))
.collect();
assert_eq!(seen, expected);
// The chosen trial's size is the output's.
let chosen = report
.trials
.iter()
.find(|t| t.quality.to_bits() == report.quality.to_bits())
.unwrap();
assert_eq!(chosen.size, u64::try_from(out.bytes.len()).unwrap());
// No search, no progress.
let mut count = 0;
Sqzer::new()
Expand Down
Loading