diff --git a/config/derivations.json b/config/derivations.json index 4d670f267..4fa5eadbb 100644 --- a/config/derivations.json +++ b/config/derivations.json @@ -113,7 +113,7 @@ }, "regenerate": "bash tools/photos/extract-photo-metadata.sh \"$SOOC_DIR\" && bun tools/photos/build-histogram-index.ts", "recorded": { - "inputs": "4f8e62b9dd95c62238aea1ee278bd4b3e7e8b1a926c9bbc295585ccf4470bb28", + "inputs": "025a02d09757cb99791760ce11cddf1fc5d636942d254124409ad3f12e9973ba", "count": 261 } }, diff --git a/config/derivations.lock.json b/config/derivations.lock.json index e25f14c92..10680b585 100644 --- a/config/derivations.lock.json +++ b/config/derivations.lock.json @@ -1551,8 +1551,8 @@ "public/i/XT509996.47144221.avif": "471442213dffcaf2ad49d282c3a2c6058f555c6a57bdd1de0fa7fe3d2af84612", "public/i/XT509996.7c90b12d.jpg": "7c90b12dc70fc92e8f5e836e0b28248f7f07929b2df5607bf8a601b2a4b1b7c4", "tools/photos/shell-data.ts": "9f579625d015d884ac8cae51ee6075df9993b3b658f894cadbd168cde05b29e1", - "tools/photos/zenc/Cargo.lock": "f8461ff344258f61c9ca715e93c3f12a549be5cc0d7dd89364de27cb143e5897", + "tools/photos/zenc/Cargo.lock": "f7a8fc33f3c9da7a2f287dbf239758d567a9ab6dc87fd4a20870b77b7f01b207", "tools/photos/zenc/src/histogram.rs": "59e46d9f6e65905da2f6019714963cfc0d2d0cd40b518f5f57c5a14377b6612a", - "tools/photos/zenc/src/pixels.rs": "4ba5b98e36b3013260e2368f15a4a25adf7d3bce8dba4b326accab66a5dbd786" + "tools/photos/zenc/src/pixels.rs": "0185c8a11c7155d01993669a2e2dfb603e1ce72561d27f15ad8d32117b1ed2ab" } } diff --git a/tools/photos/zenc/Cargo.lock b/tools/photos/zenc/Cargo.lock index cd9e66d8a..ed39afaf8 100644 --- a/tools/photos/zenc/Cargo.lock +++ b/tools/photos/zenc/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "halflight" version = "0.1.0" -source = "git+https://github.com/oddharsh/halflight.git?rev=80e9f344f669f85cbf4e85d9337b11405691f7ba#80e9f344f669f85cbf4e85d9337b11405691f7ba" +source = "git+https://github.com/oddharsh/halflight.git?rev=871487e15be16dae0da39ddd882d94d188a5dc5f#871487e15be16dae0da39ddd882d94d188a5dc5f" [[package]] name = "hashbrown" diff --git a/tools/photos/zenc/Cargo.toml b/tools/photos/zenc/Cargo.toml index 2c73ac9f5..7bee1c07e 100644 --- a/tools/photos/zenc/Cargo.toml +++ b/tools/photos/zenc/Cargo.toml @@ -56,7 +56,14 @@ zenjpeg = { version = "0.8.4", features = ["trellis"] } # transfer curves) and the histogram bake over all 165 stems came back # identical from the binary built either way, and the same A/B is the gate for # moving this rev. -halflight = { git = "https://github.com/oddharsh/halflight.git", rev = "80e9f344f669f85cbf4e85d9337b11405691f7ba" } +# +# Moved 2026-09-22 to 871487e for resample_oriented, which square now calls so +# no upright copy of a full frame is built. `bun run zenc:bench` is that A/B +# now: all 24 square outputs and 258 histograms byte-identical, and the +# histograms regenerated from this build matched the committed file. Pin the +# rev to halflight's branch commit and merge that PR with a merge commit (not a +# squash), as #1 was, so this sha stays reachable from halflight's main. +halflight = { git = "https://github.com/oddharsh/halflight.git", rev = "871487e15be16dae0da39ddd882d94d188a5dc5f" } # Input decode only. `tiff` was added on 2026-08-25 for the Instagram export, # which needs a LOSSLESS decode of a HEIF source and cannot use JPEG for it. # Measured on a 7728x5152 HIF: `sips -s format png` takes 6222ms because it diff --git a/tools/photos/zenc/README.md b/tools/photos/zenc/README.md index 2fc89558e..77040abcf 100644 --- a/tools/photos/zenc/README.md +++ b/tools/photos/zenc/README.md @@ -95,9 +95,12 @@ whole rows for the flips and walks 64px tiles for the four transposes, which took the decode-to-resample floor from 731/774/859 ms to 572/577/601 ms at orientations 3/6/8. On the bench's corpus, rotated sources run 1.09-1.17x faster and use 5-9.5% less CPU, each at p = 0.0006, with every output -byte-identical. What orientation still costs over upright is the copy into a -fresh full-frame buffer. Removing it means reading through the orientation -inside halflight's resample, which is a halflight API change and its own A/B. +byte-identical. The second pass removed the copy itself: `square` now hands the +STORED frame to halflight's `resample_oriented`, which reads it upright inside +the resample and is bitwise equal to orienting first. On one 26 MP JPEG the +floor reads 491 ms upright and 491/445/461 ms at orientations 3/6/8, so a +rotated photo now costs nothing extra, and the axis-swapping ones are cheaper +than upright. `resize` and `frame` still orient with the tiled copy. ## Remaining work diff --git a/tools/photos/zenc/examples/heif-memory.rs b/tools/photos/zenc/examples/heif-memory.rs index b74626445..7cac3cad0 100644 --- a/tools/photos/zenc/examples/heif-memory.rs +++ b/tools/photos/zenc/examples/heif-memory.rs @@ -72,7 +72,7 @@ fn run(args: &[String]) -> Result<(), String> { let root = Path::new(&args[2]); std::fs::create_dir_all(root).map_err(|e| e.to_string())?; for size in [600, 400, 200] { - let tier = square::tier(&src, size, halflight::Filter::Box); + let tier = square::tier(&src, pixels::Orientation::Upright, size, halflight::Filter::Box); avif::write(&tier, &root.join(format!("{size}.avif")))?; if size == 600 { let jpg = jpeg::encode(&tier, 84, zenjpeg::encoder::ChromaSubsampling::Quarter)?; diff --git a/tools/photos/zenc/examples/memory-encode.rs b/tools/photos/zenc/examples/memory-encode.rs index 9c8dbe2c9..9d6d0c759 100644 --- a/tools/photos/zenc/examples/memory-encode.rs +++ b/tools/photos/zenc/examples/memory-encode.rs @@ -27,7 +27,7 @@ fn run(args: &[String]) -> Result<(), String> { let src = pixels::orient(pixels::load_linear(&args[1], pixels::TransferOption::Auto)?, orientation); std::fs::create_dir_all(root).map_err(|e| e.to_string())?; for size in [600, 400, 200] { - let tier = square::tier(&src, size, halflight::Filter::Box); + let tier = square::tier(&src, pixels::Orientation::Upright, size, halflight::Filter::Box); avif::write(&tier, &root.join(format!("{size}.avif")))?; if size == 600 { let encoded = jpeg::encode(&tier, 84, ChromaSubsampling::Quarter)?; diff --git a/tools/photos/zenc/src/pixels.rs b/tools/photos/zenc/src/pixels.rs index b2f629547..078547507 100644 --- a/tools/photos/zenc/src/pixels.rs +++ b/tools/photos/zenc/src/pixels.rs @@ -71,6 +71,20 @@ pub enum Orientation { Rotate270, } +impl Orientation { + /// Whether the upright frame's width is the stored frame's height. + pub fn swaps_axes(self) -> bool { + self as u8 >= 5 + } + + /// The same transform in halflight's terms. Both enums number the eight + /// cases as EXIF does and map a stored pixel identically, which zenc:bench + /// gates on real photos byte for byte. + fn halflight(self) -> halflight::Orientation { + halflight::Orientation::from_exif(self as u8).expect("Orientation admits exactly EXIF 1-8") + } +} + impl TryFrom for Orientation { type Error = &'static str; fn try_from(value: u8) -> Result { @@ -310,11 +324,11 @@ pub fn orient(f: Frame, orientation: Orientation) -> Frame { // orientation 6 774 -> 577 ms (tiled transpose) // orientation 8 859 -> 601 ms (tiled transpose) // - // What is left over upright is the copy into a fresh full-frame buffer. - // Removing that means reading through the orientation inside halflight's - // resample, which is a halflight API change. Output is byte-identical, - // since this moves the same values to the same places, and zenc:bench gates - // that on real photos. + // The ingest no longer calls this: `square` reads the stored frame upright + // inside the resample (scale_oriented, halflight::resample_oriented), which + // skips the copy this still makes. `resize` and `frame` use it. Output is + // byte-identical, since this moves the same values to the same places, and + // zenc:bench gates that on real photos. // // The channel count is a const generic so each pixel copy compiles to a // fixed-size move. @@ -389,6 +403,20 @@ pub fn scale(f: &Frame, dw: u32, dh: u32, filter: Filter) -> Frame { } } +/// Resample a STORED frame to an exact UPRIGHT size, reading it through the +/// orientation inside the resample rather than orienting a full-frame copy +/// first. halflight holds this bitwise equal to `scale(&orient(f, o), ..)`. +pub fn scale_oriented(f: &Frame, o: Orientation, dw: u32, dh: u32, filter: Filter) -> Frame { + let ch = if f.gray { 1 } else { 3 }; + Frame { + w: dw, + h: dh, + gray: f.gray, + transfer: f.transfer, + data: halflight::resample_oriented(&f.data, f.w as usize, f.h as usize, ch, o.halflight(), dw as usize, dh as usize, filter), + } +} + /// Quantize once after resampling; the returned pixels can feed several encoders. pub fn encoded(f: &Frame) -> DynamicImage { let t = f.transfer; diff --git a/tools/photos/zenc/src/square.rs b/tools/photos/zenc/src/square.rs index 72190a878..82fddafb2 100644 --- a/tools/photos/zenc/src/square.rs +++ b/tools/photos/zenc/src/square.rs @@ -17,20 +17,23 @@ // `--transfer` names the SOURCE's curve (srgb, or g22 for the Monochrom's // Gray Gamma 2.2) and is used for decode and encode both, so unaveraged values // pass through exactly. -use crate::pixels::{crop, load_linear, orient, parse_transfer, encoded, scale, Frame, Orientation, TransferOption}; +use crate::pixels::{crop, load_linear, parse_transfer, encoded, scale_oriented, Frame, Orientation, TransferOption}; use halflight::Filter; use std::path::Path; -/// The production geometry, shared with the benchmark front end. -pub fn tier(src: &Frame, size: u32, filter: Filter) -> image::DynamicImage { - let (w, h) = (src.w, src.h); +/// The production geometry, shared with the benchmark front end. `src` is the +/// STORED frame and `orientation` brings it upright inside the resample, so no +/// upright copy of the full frame is ever built. Geometry is computed on the +/// upright size, exactly as it was when this took an oriented frame. +pub fn tier(src: &Frame, orientation: Orientation, size: u32, filter: Filter) -> image::DynamicImage { + let (w, h) = if orientation.swaps_axes() { (src.h, src.w) } else { (src.w, src.h) }; let (nw, nh) = if w <= h { (size, (h as u64 * size as u64).div_ceil(w as u64) as u32) } else { ((w as u64 * size as u64).div_ceil(h as u64) as u32, size) }; let (cx, cy) = ((nw.saturating_sub(size)) / 2, (nh.saturating_sub(size)) / 2); - let scaled = scale(src, nw, nh, filter); + let scaled = scale_oriented(src, orientation, nw, nh, filter); encoded(&crop(&scaled, cx, cy, size.min(nw), size.min(nh))) } @@ -114,13 +117,14 @@ pub fn run(args: &[String]) -> i32 { Ok(f) => f, Err(e) => return err(&e), }; - // Orient BEFORE the resample, so the crop math sees the frame the viewer - // will. For orientation 1 the decoded buffer moves without copying. - let src = orient(src, exif); + // The orientation is applied INSIDE each tier's resample (tier() and + // halflight::resample_oriented), so the crop math still sees the frame the + // viewer will and no upright copy of the full frame is built. That copy was + // a 311 MB f32 buffer for a 26 MP photo, rebuilt once per ingest. for output in outputs { // Short edge lands on `size`. sips reaches the same crop from the long // edge, which is one more piece of arithmetic to get wrong. - let pixels = tier(&src, output.size, filter); + let pixels = tier(&src, exif, output.size, filter); if let Some(path) = output.png { if let Err(e) = pixels.save(path) { return err(&format!("cannot write {path}: {e}")); } }