From ce5b7d72e3d113de60b2b70cfaf053ac3219b9f5 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 28 Sep 2026 08:05:24 -0400 Subject: [PATCH] Thumbnail a composite during the merge, as develop first shows it The DNG a merge writes has no embedded preview, and an embedded preview is all the grid's thumbnail path reads, so a composite stood in the grid as a blank cell until it was opened. Reading an 800 MB file back to make one would cost what the merge already has in hand. The bands are box-reduced as they are written, after the border fill, into a copy 4096 pixels long. That copy is written as a linear DNG in memory with the composite's own profile, header and crop, and opened through open_session, the function develop opens every file with: the same decode, the default graph and view transform, the as-shot white balance and the conversion to the display's space. The grid and large thumbnails are rendered from that session, staged beside the payload before the rename releases it to a drain, and put in the store under the file id once the upload has learned it. Until then the grid draws them from memory, so the cell is not blank while the file is uploading. A test develops a synthetic composite both ways, the whole file as develop opens it and the merge's reduced copy, and holds the thumbnail's mean, 95th and 99.5th luma percentiles to within 3-4 levels of develop's render; the naive balanced-and-gamma picture the merge's preview draws misses by 13. --- ui/dr-ui/src/lib.rs | 1 + ui/dr-ui/src/library_ui/controller.rs | 11 + ui/dr-ui/src/library_ui/open.rs | 17 ++ ui/dr-ui/src/library_ui/window.rs | 39 ++- ui/dr-ui/src/merge.rs | 57 +++- ui/dr-ui/src/merge_thumbs.rs | 409 ++++++++++++++++++++++++++ 6 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 ui/dr-ui/src/merge_thumbs.rs diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index cab43d1..2c9cb51 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -59,6 +59,7 @@ pub mod manual; mod masks_ui; pub mod memory; pub mod merge; +mod merge_thumbs; mod merge_ui; mod net_runtime; mod peaking; diff --git a/ui/dr-ui/src/library_ui/controller.rs b/ui/dr-ui/src/library_ui/controller.rs index 0946e6b..d61b6b0 100644 --- a/ui/dr-ui/src/library_ui/controller.rs +++ b/ui/dr-ui/src/library_ui/controller.rs @@ -113,6 +113,16 @@ pub struct LibraryController { /// two states apart — without it a carried thumbnail either blocks the /// sharper fetch forever or is re-fetched on every scroll. pub(super) thumb_class: RefCell>>, + /// TRACES: FR-MRG-6 + /// Thumbnails a merge made for a composite it has just catalogued, by + /// image, decoded and at each class it made. + /// + /// The grid's own path keys the store on the server's file id, and a + /// composite has none until its upload lands — minutes, on Nextcloud. The + /// merge's pixels are drawn from here until then, so the cell is not + /// blank in the meantime; after, the store holds the same ones. + pub(super) merged_thumbs: + RefCell>>, /// Where in the catalog the current window starts. Scrubbing moves this. pub(super) offset: RefCell, /// The first visible ordinal, kept so returning from the develop view lands @@ -449,6 +459,7 @@ impl LibraryController { needs_metadata: RefCell::new(Vec::new()), captured_at: RefCell::new(Vec::new()), thumb_class: RefCell::new(Vec::new()), + merged_thumbs: RefCell::new(Default::default()), offset: RefCell::new(0), resume_at: std::cell::Cell::new(0), roll_open: std::cell::Cell::new(None), diff --git a/ui/dr-ui/src/library_ui/open.rs b/ui/dr-ui/src/library_ui/open.rs index fd5e8c0..3f8d898 100644 --- a/ui/dr-ui/src/library_ui/open.rs +++ b/ui/dr-ui/src/library_ui/open.rs @@ -81,6 +81,20 @@ pub fn catalogue_composite( match written { Ok(image) => { log::info!("merge: {} catalogued as image {image}", row.source_ref); + let decoded: Vec<(dr_thumbs::ThumbSize, slint::Image)> = c + .thumbnails + .iter() + .filter_map(|(class, t)| { + let (w, h, rgba) = dr_thumbs::decode_rgba(&t.bytes).ok()?; + Some((*class, super::window::to_slint_image(w, h, &rgba))) + }) + .collect(); + if !decoded.is_empty() { + ctl.merged_thumbs.borrow_mut().insert(image, decoded); + } + // Asked for afresh: a row under this name may have been drawn, + // and answered, before this merge wrote over it. + ctl.requested.borrow_mut().retain(|(id, _)| *id != image); reload(window, ctl); } Err(e) => log::warn!("merge: cataloguing {}: {e}", row.source_ref), @@ -114,6 +128,9 @@ pub fn open( let filter = account.format_filter(); *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); + // Image ids are one catalog's; another library's would name other + // photographs. + ctl.merged_thumbs.borrow_mut().clear(); // TRACES: FR-UI-8 // Where this library's position is kept, and what this device signs it diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs index 39dfa3e..efd331f 100644 --- a/ui/dr-ui/src/library_ui/window.rs +++ b/ui/dr-ui/src/library_ui/window.rs @@ -467,6 +467,43 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { hold_thumbnails(&previous, &ids, &classes) }; + // A composite a merge has just catalogued is drawn from the merge's own + // thumbnails until the store can serve it (FR-MRG-6), and counts as + // served at that class so no fetch is spent on a file still uploading. + let wanted = dr_thumbs::ThumbSize::for_cell( + window.global::().get_library_cell_size().max(1.0) as u32, + ); + let mut held = held; + { + let merged = ctl.merged_thumbs.borrow(); + for c in &cells { + let Some(made) = merged.get(&c.image_id) else { + continue; + }; + if held + .get(&c.image_id) + .is_some_and(|h| h.class == Some(wanted)) + { + continue; + } + let pick = made + .iter() + .find(|(class, _)| *class == wanted) + .or_else(|| made.iter().max_by_key(|(class, _)| class.edge())); + if let Some((class, image)) = pick { + held.insert( + c.image_id, + Held { + thumbnail: image.clone(), + has_thumb: true, + unavailable: false, + class: Some(*class), + }, + ); + } + } + } + let rows: Vec = cells .iter() .zip(headings) @@ -989,7 +1026,7 @@ fn drain_thumbnails( /// This is a CPU copy, which is acceptable here and not in the develop path: /// a 256px thumbnail is 256 KB and happens once per image, where the canvas /// would pay per frame (ARCH §6.1). -fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { +pub(super) fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { let mut buf = slint::SharedPixelBuffer::::new(width, height); let expected = (width as usize) * (height as usize) * 4; let src = &rgba[..expected.min(rgba.len())]; diff --git a/ui/dr-ui/src/merge.rs b/ui/dr-ui/src/merge.rs index 53433dc..da7952f 100644 --- a/ui/dr-ui/src/merge.rs +++ b/ui/dr-ui/src/merge.rs @@ -266,6 +266,10 @@ pub struct Composite { pub lens: Option, pub iso: Option, pub file_size: u64, + /// Made from the final image as develop would first show it + /// ([`crate::merge_thumbs`]); empty if that failed, and the grid then + /// thumbnails it the ordinary way. + pub thumbnails: Vec<(dr_thumbs::ThumbSize, dr_thumbs::Thumbnail)>, } impl MergeEvent { @@ -662,6 +666,7 @@ fn run_inner( let _ = std::fs::remove_file(part_name(path)); if staged { let _ = std::fs::remove_file(crate::export::destination_record(path)); + let _ = std::fs::remove_file(crate::export::thumbnails_record(path)); } }; let (out_w, out_h) = (report_size(&bounds).0, report_size(&bounds).1); @@ -671,6 +676,8 @@ fn run_inner( let profile = dng_profile(first, white_level); let balance = profile.as_shot_neutral.map(|n| 1.0 / n.max(1e-3)); let (_, mut carried) = crate::export::header_for_file(&first.meta); + // The thumbnails' copy of the profile; the writer takes the original. + let thumb_profile = profile.clone(); // The composite is dated at the middle of its sweep — the mean of the // frames' capture times — so it sorts among the frames it was made // from, not at the first of them and not at the moment of the merge. @@ -683,6 +690,7 @@ fn run_inner( // What the catalog is told about the file (FR-MRG-6), taken before the // header moves into the writer. + let thumb_header = carried.clone(); let described = ( carried.captured_at, carried.captured_offset, @@ -881,6 +889,9 @@ fn run_inner( let total_bands = out_h.div_ceil(rows_per_strip) as usize; let mut bands_done = 0usize; + // The final image, reduced as it goes by, for the thumbnails (FR-MRG-6). + let mut reduced = + crate::merge_thumbs::Reduced::new(out_w, out_h, crate::merge_thumbs::SOURCE_EDGE); progress("Merging", 0, total_bands); let merged = pass.merge( &mut adjust, @@ -935,6 +946,7 @@ fn run_inner( } } } + reduced.push(band.first_row, band.rows, &rgb); band_tx .send(rgb) .map_err(|_| dr_gpu::GpuError::Readback("the writer stopped".into())) @@ -961,10 +973,6 @@ fn run_inner( cleanup(&out_path); return Err(e); } - if let Err(e) = std::fs::rename(&part_path, &out_path) { - cleanup(&out_path); - return Err(format!("{}: {e}", out_path.display())); - } // The rectangle the file opens on, as the writer recorded it. let picture = if fill_cam.is_some() { None @@ -975,6 +983,42 @@ fn run_inner( .map(|i| clamp_crop(i.best(), out_w, out_h)) .filter(|r| r.width > 0 && r.height > 0) }; + + // TRACES: FR-MRG-6 + // The thumbnails, from the final image, developed as develop will first + // show the file. Before the rename, and staged beside the payload before + // it: the rename is what lets a drain take the file, and the drain is + // what puts these in the store once the server has named it. + // The merge's GPU memory back before develop asks for its own. + drop(resident); + drop(pass); + progress("Making thumbnails", 0, 1); + let (pw, ph) = picture.map_or((out_w, out_h), |r| (r.width, r.height)); + let thumbnails = match crate::merge_thumbs::render( + ctx, + reduced, + &thumb_profile, + &thumb_header, + picture, + request.decoder, + &crate::merge_thumbs::classes(pw, ph), + ) { + Ok(t) => t, + Err(e) => { + log::warn!("merge: the thumbnails could not be made: {e}"); + Vec::new() + } + }; + if staged && !thumbnails.is_empty() { + if let Err(e) = crate::export::write_thumbnails(&out_path, &thumbnails) { + log::warn!("merge: the thumbnails could not be staged: {e}"); + } + } + + if let Err(e) = std::fs::rename(&part_path, &out_path) { + cleanup(&out_path); + return Err(format!("{}: {e}", out_path.display())); + } log::info!( "merge: {}×{} written to {} in {:?}", out_w, @@ -1029,14 +1073,15 @@ fn run_inner( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), - width: picture.map_or(out_w, |r| r.width), - height: picture.map_or(out_h, |r| r.height), + width: pw, + height: ph, captured_at, captured_offset, camera, lens, iso, file_size: std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0), + thumbnails, }; Ok(Some(MergeEvent::Done { diff --git a/ui/dr-ui/src/merge_thumbs.rs b/ui/dr-ui/src/merge_thumbs.rs new file mode 100644 index 0000000..ceb6174 --- /dev/null +++ b/ui/dr-ui/src/merge_thumbs.rs @@ -0,0 +1,409 @@ +//! TRACES: FR-MRG-6 +//! The composite's thumbnails, made by the merge from its own final image. +//! +//! # Why the merge makes them +//! +//! A linear DNG as the merge writes it carries no embedded preview, and an +//! embedded preview is all the grid's ordinary thumbnail path reads — so a +//! composite sat in the grid as a blank cell until someone opened it. Making +//! the thumbnail afterwards means reading back a file of 800 MB; the merge +//! has every pixel of it in hand as the bands go by. +//! +//! # From the final image, through develop +//! +//! The bands are box-reduced as they are written — after the border fill, +//! so the thumbnail shows what the file holds — into a copy +//! [`SOURCE_EDGE`] pixels long. That copy is written as a linear DNG in +//! memory with the composite's own profile, header and crop, and opened +//! exactly as develop opens a file on first sight (`crate::open_session`): +//! the same decode, the same default graph with its default view transform +//! and tone mapping, the file's as-shot white balance and the conversion +//! from the working space to the display's. The thumbnail is therefore the +//! photograph the photographer will see on opening it, not a second, +//! plausible conversion — the merge's own quick look ([`crate::merge`]'s +//! `Look`) is exactly the naive linear-to-gamma picture this is not. +//! +//! The reduction is develop's too, in effect: a composite past one texture +//! is developed from a box-reduced copy (`DevelopSession::open_owned`), and +//! this is the same box at a coarser step. + +use std::io::Cursor; + +use dr_thumbs::{ThumbSize, Thumbnail}; + +/// The long edge of the reduced copy the thumbnails are developed from: +/// twice the largest thumbnail, so the develop render resamples down rather +/// than up, and small enough to cost the merge a few tens of megabytes. +pub const SOURCE_EDGE: u32 = 4096; + +/// The composite box-reduced as its bands go by. +pub struct Reduced { + width: u32, + height: u32, + factor: u32, + /// Per reduced pixel, per channel: the sum of the full-resolution + /// samples that fall in its box. + sums: Vec, +} + +impl Reduced { + /// A reduction of a `width × height` composite to at most `long_edge` + /// pixels on its longer side, by a whole factor. + pub fn new(width: u32, height: u32, long_edge: u32) -> Self { + let factor = width.max(height).div_ceil(long_edge.max(1)).max(1); + let (w, h) = (width.div_ceil(factor), height.div_ceil(factor)); + Reduced { + width, + height, + factor, + sums: vec![0; w as usize * h as usize * 3], + } + } + + /// The reduced size. + pub fn size(&self) -> (u32, u32) { + ( + self.width.div_ceil(self.factor), + self.height.div_ceil(self.factor), + ) + } + + /// The whole factor each side is divided by. + pub fn factor(&self) -> u32 { + self.factor + } + + /// Fold in `rows` rows of the composite starting at `first_row`, + /// interleaved RGB at the full width. + pub fn push(&mut self, first_row: u32, rows: u32, rgb: &[u16]) { + let (rw, _) = self.size(); + let (w, f) = (self.width as usize, self.factor as usize); + for r in 0..rows as usize { + let y = first_row as usize + r; + if y >= self.height as usize { + break; + } + let row = &rgb[r * w * 3..(r + 1) * w * 3]; + let out = (y / f) * rw as usize * 3; + for (x, px) in row.chunks_exact(3).enumerate() { + let o = out + (x / f) * 3; + self.sums[o] += u32::from(px[0]); + self.sums[o + 1] += u32::from(px[1]); + self.sums[o + 2] += u32::from(px[2]); + } + } + } + + /// The averages, as the DNG's `u16` samples. A box cut short by the + /// edge of the composite is the mean of what it holds. + pub fn finish(self) -> (u32, u32, Vec) { + let (rw, rh) = self.size(); + let f = self.factor; + let mut out = vec![0u16; self.sums.len()]; + for y in 0..rh { + let bh = f.min(self.height - y * f); + for x in 0..rw { + let bw = f.min(self.width - x * f); + let n = bh * bw; + let i = (y * rw + x) as usize * 3; + for c in 0..3 { + out[i + c] = ((self.sums[i + c] + n / 2) / n) as u16; + } + } + } + (rw, rh, out) + } +} + +/// Which size classes a composite of this shape is thumbnailed at. +pub fn classes(width: u32, height: u32) -> Vec { + let _ = (width, height); + vec![ThumbSize::Grid, ThumbSize::Large] +} + +/// Develop the reduced composite as develop would open the file, and render +/// it at each of `classes`. +/// +/// `crop` is the rectangle the file opens on, in the composite's pixels; +/// it is carried to the reduced copy so the thumbnail is of the picture +/// and not of the border around it. +pub fn render( + ctx: &dr_gpu::GpuContext, + reduced: Reduced, + profile: &dr_export::DngProfile, + header: &dr_export::SourceMetadata, + crop: Option, + decoder: &dyn dr_decode::Decoder, + classes: &[ThumbSize], +) -> Result, String> { + let f = reduced.factor(); + let (w, h, samples) = reduced.finish(); + let crop = crop.map(|r| { + let x = (r.x / f).min(w.saturating_sub(1)); + let y = (r.y / f).min(h.saturating_sub(1)); + dr_export::Rect { + x, + y, + width: (r.width / f).clamp(1, w - x), + height: (r.height / f).clamp(1, h - y), + } + }); + let mut file = Cursor::new(Vec::new()); + let mut once = Some(samples); + dr_export::write_linear_dng( + &mut file, + w, + h, + h, + profile, + Some(header), + |_, buf| { + buf.extend_from_slice(&once.take().unwrap_or_default()); + Ok(()) + }, + || crop, + ) + .map_err(|e| e.to_string())?; + let bytes = file.into_inner(); + + // The file as develop reads it on first open: its header, then the + // pixels, through the one function every develop session starts from. + let meta = decoder.metadata(&bytes).map_err(|e| e.to_string())?; + let mut session = crate::open_session(ctx, decoder, &bytes, &meta)?; + let mut out = Vec::with_capacity(classes.len()); + for &class in classes { + let (tw, th, rgba) = session.render_thumbnail(class.edge())?; + let jpeg = dr_thumbs::encode_rgba(tw, th, &rgba).map_err(|e| e.to_string())?; + out.push(( + class, + Thumbnail { + width: tw, + height: th, + bytes: jpeg, + }, + )); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reduction_averages_its_boxes_and_the_ragged_edge() { + // 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and + // 1 × 1 along the bottom. + let mut r = Reduced::new(5, 3, 3); + assert_eq!(r.factor(), 2); + assert_eq!(r.size(), (3, 2)); + let px = |x: u16, y: u16| [x * 10 + y, 0, 1000]; + let rows: Vec = (0..3) + .flat_map(|y| (0..5).flat_map(move |x| px(x, y))) + .collect(); + // In two bands, as the merge hands them over. + r.push(0, 2, &rows[..5 * 2 * 3]); + r.push(2, 1, &rows[5 * 2 * 3..]); + let (w, h, out) = r.finish(); + assert_eq!((w, h), (3, 2)); + let at = |x: usize, y: usize| out[(y * 3 + x) * 3]; + // (0,0),(1,0),(0,1),(1,1) → 0, 10, 1, 11: mean 5.5, rounded. + assert_eq!(at(0, 0), 6); + // The right-hand column is one pixel wide: (4,0) and (4,1). + assert_eq!(at(2, 0), (40 + 41 + 1) / 2); + // The bottom-right is a single pixel. + assert_eq!(at(2, 1), 42); + assert!(out.chunks_exact(3).all(|p| p[2] == 1000)); + } + + /// A synthetic composite in camera space: a sky that brightens to a + /// clipped band, green ground with texture, and a blown sun. + fn composite(width: u32, height: u32, white: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height { + for x in 0..width { + let fy = y as f32 / height as f32; + let fx = x as f32 / width as f32; + let tex = ((x / 7 + y / 5) % 2) as f32 * 0.03; + let cam = if fy < 0.45 { + // Sky: blue-ish in camera space, brighter toward the top + // and toward the right, clipping in the top corner. + let b = (0.25 + 0.9 * (1.0 - fy / 0.45) * fx).min(1.2); + [b * 0.45, b * 0.8, b] + } else { + let g = 0.08 + 0.25 * fx + tex; + [g * 0.6, g, g * 0.4] + }; + let sun = ((fx - 0.8).powi(2) + (fy - 0.2).powi(2)).sqrt() < 0.04; + let cam = if sun { [1.0, 1.0, 1.0] } else { cam }; + for c in cam { + out.push(((c * white as f32).round() as u32).min(white) as u16); + } + } + } + out + } + + fn profile(white: u32) -> dr_export::DngProfile { + // A Canon 6D's D65 matrix and as-shot neutral, as the fixture's + // composite carries them. + dr_export::DngProfile { + unique_model: "Canon EOS 6D".into(), + calibrations: vec![( + 21, + [ + [0.7034, -0.0804, -0.1014], + [-0.4420, 1.2564, 0.2058], + [-0.0851, 0.1994, 0.5758], + ], + )], + as_shot_neutral: [0.4985, 1.0, 0.6505], + white_level: white, + } + } + + /// Luma, in 0..255, for each pixel of an RGBA buffer. + fn luma(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) + .collect() + } + + fn stats(mut l: Vec) -> (f32, f32, f32) { + let mean = l.iter().sum::() / l.len() as f32; + l.sort_by(f32::total_cmp); + let at = |q: f32| l[((l.len() - 1) as f32 * q).round() as usize]; + (mean, at(0.95), at(0.995)) + } + + /// FR-MRG-6's acceptance: the thumbnail made during the merge is the + /// picture develop shows on first opening the composite — its overall + /// level and its highlights — and not a naive conversion of the linear + /// data, which comes out a stop and more away on both. + #[test] + fn the_thumbnail_is_what_develop_shows_on_first_open() { + let Some(ctx) = crate::develop::test_support::headless() else { + eprintln!("no GPU; skipping"); + return; + }; + let (width, height, white) = (3000u32, 760u32, 13_023u32); + let pixels = composite(width, height, white); + let profile = profile(white); + let header = dr_export::SourceMetadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + captured_at: Some(1_600_000_000), + ..Default::default() + }; + let crop = dr_export::Rect { + x: 40, + y: 30, + width: 2900, + height: 700, + }; + + // Develop's side: the whole composite written as the merge writes + // it, opened as develop opens it, rendered for the grid. + let rows_per_strip = 256u32; + let mut file = Cursor::new(Vec::new()); + dr_export::write_linear_dng( + &mut file, + width, + height, + rows_per_strip, + &profile, + Some(&header), + |k, buf| { + let from = k * (rows_per_strip * width * 3) as usize; + let to = (from + (rows_per_strip * width * 3) as usize).min(pixels.len()); + buf.extend_from_slice(&pixels[from..to]); + Ok(()) + }, + || Some(crop), + ) + .unwrap(); + let bytes = file.into_inner(); + let decoder = dr_decode::default(); + let meta = decoder.metadata(&bytes).unwrap(); + let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap(); + + // The merge's side: the same bands, reduced as they pass. + let mut reduced = Reduced::new(width, height, 1500); + for first in (0..height).step_by(rows_per_strip as usize) { + let rows = rows_per_strip.min(height - first); + let from = (first * width * 3) as usize; + reduced.push( + first, + rows, + &pixels[from..from + (rows * width * 3) as usize], + ); + } + let thumbs = render( + &ctx, + reduced, + &profile, + &header, + Some(crop), + decoder, + &[ThumbSize::Grid, ThumbSize::Large], + ) + .unwrap(); + + // The naive picture: the linear camera values balanced and gamma + // encoded, with no matrix, no curve and no highlight handling — what + // the merge's preview draws. + let naive = { + let mut l = Vec::new(); + for y in (crop.y..crop.y + crop.height).step_by(4) { + for x in (crop.x..crop.x + crop.width).step_by(4) { + let i = ((y * width + x) * 3) as usize; + let v = |c: usize| { + let lin = pixels[i + c] as f32 / white as f32 / profile.as_shot_neutral[c]; + lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0 + }; + l.push(0.2126 * v(0) + 0.7152 * v(1) + 0.0722 * v(2)); + } + } + stats(l) + }; + + for (class, thumb) in thumbs { + let (tw, th, ours) = dr_thumbs::decode_rgba(&thumb.bytes).unwrap(); + let (dw, dh, theirs) = developed.render_thumbnail(class.edge()).unwrap(); + // Through the store's encoding too: both are JPEGs in the store, + // and it is the encoding, not the development, that rounds a + // blown sun off to 247. + let theirs = { + let jpeg = dr_thumbs::encode_rgba(dw, dh, &theirs).unwrap(); + dr_thumbs::decode_rgba(&jpeg).unwrap().2 + }; + // The picture, not the border: the crop's shape at the class's + // size, give or take the reduction's rounding. + assert!( + (tw as i64 - dw as i64).abs() <= 2 && (th as i64 - dh as i64).abs() <= 2, + "{class:?}: {tw}×{th} against develop's {dw}×{dh}" + ); + let (m0, p95_0, p995_0) = stats(luma(&ours)); + let (m1, p95_1, p995_1) = stats(luma(&theirs)); + eprintln!( + "{class:?}: mean {m0:.1}/{m1:.1}, p95 {p95_0:.1}/{p95_1:.1}, \ + p99.5 {p995_0:.1}/{p995_1:.1}; naive {naive:?}" + ); + assert!((m0 - m1).abs() <= 3.0, "{class:?} mean {m0} vs {m1}"); + // And the tolerance means something: the conversion this + // replaces is nowhere near it. + assert!( + (naive.0 - m1).abs() > 12.0 || (naive.1 - p95_1).abs() > 12.0, + "a naive conversion ({naive:?}) would pass as well" + ); + assert!( + (p95_0 - p95_1).abs() <= 4.0, + "{class:?} p95 {p95_0} vs {p95_1}" + ); + assert!( + (p995_0 - p995_1).abs() <= 4.0, + "{class:?} p99.5 {p995_0} vs {p995_1}" + ); + } + } +}