Merge branch 'worktree-agent-a69dcec0f0c61bc39'
# Conflicts: # crates/engine/src/media.rs # crates/ui-egui/src/render.rs
This commit is contained in:
commit
9886d49823
|
|
@ -2104,6 +2104,7 @@ dependencies = [
|
|||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ impl Bench {
|
|||
let idle = app.renderer.queued() == 0 && app.renderer.in_flight() == 0;
|
||||
match self.phase {
|
||||
Phase::Idle => {
|
||||
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count();
|
||||
if idle && thumbs > 0 && self.frames > 3 {
|
||||
self.thumbs_done_ms = Some(now - self.start_ms);
|
||||
let first = app.session.visible().first().copied();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@ impl Originals {
|
|||
let (l, p) = self.hooks();
|
||||
session.media.file_loader = Some(l);
|
||||
session.media.file_probe = Some(p);
|
||||
let s = self.clone();
|
||||
session.media.preview_loader =
|
||||
Some(Arc::new(move |path: &str, max_edge: usize| lightcraft_engine::files::embedded_preview_srgb(&s.get(path)?, max_edge)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ impl Workers {
|
|||
w.dead = true;
|
||||
w.worker.terminate();
|
||||
if let Some(b) = w.busy.take() {
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None };
|
||||
let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None };
|
||||
g.done.push((b.slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -308,7 +308,7 @@ impl Workers {
|
|||
}
|
||||
g.affinity.insert((b.photo, b.level), i);
|
||||
g.remote_done += 1;
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None }, ms));
|
||||
g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms));
|
||||
}
|
||||
|
||||
/// The thumbnail index as JSON if it changed since the last call (the host saves it).
|
||||
|
|
@ -340,7 +340,14 @@ impl RenderOffload for Workers {
|
|||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
let r = RenderResult { photo: job.photo, level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
|
||||
let r = RenderResult {
|
||||
photo: job.photo,
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
rendered: Ok(Rendered { image, histogram }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
g.done.push((slot, r, 0.0));
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -88,6 +88,43 @@ fn main() {
|
|||
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let only = std::env::var("ONLY").unwrap_or_default();
|
||||
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
|
||||
if only == "source" {
|
||||
// opening a photo: where the time goes before the first sharp loupe (per file)
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
let norm = raw.normalized().expect("norm");
|
||||
println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd))));
|
||||
if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
|
||||
println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99))));
|
||||
let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable");
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
"highlight reconstruct (binned):",
|
||||
best(n, || {
|
||||
let mut i = img.clone();
|
||||
lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99);
|
||||
})
|
||||
);
|
||||
println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone())));
|
||||
println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box))));
|
||||
}
|
||||
println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560))));
|
||||
for edge in [512usize, 2560, usize::MAX] {
|
||||
println!(
|
||||
" {:<36} {}",
|
||||
format!("load_bytes({edge}):"),
|
||||
best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load")))
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (full, info) = match args.first() {
|
||||
Some(path) => {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
|
|||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
|
||||
use crate::media::{FileLoader, FileProbe, ProbeInfo};
|
||||
use crate::media::{FileLoader, FileProbe, PreviewLoader, ProbeInfo};
|
||||
|
||||
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
|
||||
|
|
@ -167,8 +167,29 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||||
|
||||
/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels:
|
||||
/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the
|
||||
/// 2560 px preview). `None` = demosaic at full size.
|
||||
pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usize> {
|
||||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) };
|
||||
let need = max_edge.saturating_mul(9) / 10;
|
||||
[8usize, 6, 4, 3, 2].into_iter().find(|&k| long / k >= need.max(1) && raw.can_bin(k))
|
||||
}
|
||||
|
||||
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
|
||||
///
|
||||
/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue
|
||||
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
|
||||
/// loops themselves when the machine is busy).
|
||||
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
rayon::scope(|_| load_bytes_now(bytes, max_edge))
|
||||
}
|
||||
|
||||
fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(bytes) {
|
||||
Ok(r) => r,
|
||||
|
|
@ -181,11 +202,26 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
|
|||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw);
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
let mut img = raw.develop(method).map_err(|e| e.to_string())?;
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = std::time::Instant::now();
|
||||
let binned = match bin_factor(&raw, max_edge) {
|
||||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
let mut img = match binned {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
raw.develop(method).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, 0.99);
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
let m = t.matrix.to_f32();
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
let wb = t.wb;
|
||||
|
|
@ -197,7 +233,23 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
|
|||
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
|
||||
]
|
||||
});
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box).oriented(raw.orientation);
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
stages.push(("fit", t0.elapsed()));
|
||||
let img = img.oriented(raw.orientation);
|
||||
stages.push(("orient", t0.elapsed()));
|
||||
if lightcraft_pipeline::profiling() {
|
||||
let mut prev = std::time::Duration::ZERO;
|
||||
let parts: Vec<String> = stages
|
||||
.iter()
|
||||
.map(|(n, t)| {
|
||||
let d = *t - prev;
|
||||
prev = *t;
|
||||
format!("{n} {:.1}", d.as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||||
}
|
||||
|
|
@ -235,6 +287,24 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
|
|||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
}
|
||||
let o = preview_orientation(bytes, d.orientation);
|
||||
Some(d.to_srgb8().oriented(o))
|
||||
}
|
||||
|
||||
/// Filesystem-backed embedded-preview hook (native).
|
||||
pub fn fs_preview_loader() -> PreviewLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))
|
||||
}
|
||||
|
||||
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
|
||||
pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
|
||||
|
|
@ -254,6 +324,7 @@ impl crate::Session {
|
|||
let (l, p) = fs_hooks();
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -289,4 +360,40 @@ mod tests {
|
|||
// no preview at all: a clear error, not a panic
|
||||
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||||
use crate::media::QuickSource;
|
||||
let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.cr3");
|
||||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
// loupe: an unedited raw opens on its embedded preview…
|
||||
let q = s.quick_view_job(id, 1600, true).unwrap().run();
|
||||
assert_eq!(q.quick, Some(QuickSource::Embedded));
|
||||
let img = q.rendered.unwrap().image;
|
||||
assert_eq!((img.width, img.height), (96, 64));
|
||||
// …and once the loupe has rendered it, on that render (any size)
|
||||
let full = s.loupe_job(id, 48, 32, true).unwrap().run();
|
||||
assert!(full.rendered.is_ok() && full.quick.is_none());
|
||||
let q = s.quick_view_job(id, 1600, true).unwrap().run();
|
||||
assert_eq!(q.quick, Some(QuickSource::Cached));
|
||||
assert_eq!(q.rendered.unwrap().image.width, 48);
|
||||
// grid: embedded first, then the real thumbnail (final, with the thumbnail job's key)
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
let q = s.quick_thumb_job(&job).unwrap().run();
|
||||
assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key));
|
||||
drop(job.clone().run());
|
||||
assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached));
|
||||
// an edited raw: no embedded stand-in (it wouldn't show the edit)
|
||||
s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let job = s.thumb_job(id, 128).unwrap();
|
||||
assert!(s.quick_thumb_job(&job).is_none());
|
||||
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,25 @@ pub struct ImportReport {
|
|||
pub sidecars: usize,
|
||||
}
|
||||
|
||||
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
|
||||
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
|
||||
/// camera intended).
|
||||
pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings {
|
||||
let mut d = match p.as_shot_wb {
|
||||
Some((t, tint)) => lightcraft_develop::DevelopSettings::for_raw(t, tint),
|
||||
None => lightcraft_develop::DevelopSettings::default(),
|
||||
};
|
||||
if p.embedded_lens.is_some() {
|
||||
d.optics.lens_profile = true;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)?
|
||||
pub fn has_import_look(p: &Photo) -> bool {
|
||||
*p.develop == import_defaults(p)
|
||||
}
|
||||
|
||||
pub fn is_supported(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
|
||||
}
|
||||
|
|
@ -223,14 +242,8 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
|
|||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
if let Some((t, tint)) = info.as_shot_wb {
|
||||
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
|
||||
}
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
if p.embedded_lens.is_some() {
|
||||
// built-in (file-embedded) lens corrections are on by default, like the camera intended
|
||||
std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true;
|
||||
}
|
||||
p.develop = std::sync::Arc::new(import_defaults(&p));
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
//! disk cache in the library's `thumbs/` folder), keyed by the photo's content hash, the develop
|
||||
//! settings hash, the size and [`RENDER_CACHE_VERSION`] — so reopening a library shows its grid
|
||||
//! without decoding a single original, and an edit simply produces a new key.
|
||||
//!
|
||||
//! Opening a photo shows something at once ([`QuickJob`]): the loupe's last render for the current
|
||||
//! settings (kept in the same cache under a size-independent "view" key), else the camera's
|
||||
//! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is
|
||||
//! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -17,7 +22,7 @@ use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
|||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgb32f};
|
||||
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
|
|
@ -62,6 +67,10 @@ impl SourceLevel {
|
|||
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
|
||||
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
|
||||
|
||||
/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than
|
||||
/// `max_edge` (set by the app). `None`: no usable preview.
|
||||
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
|
|
@ -92,6 +101,7 @@ pub struct MediaCache {
|
|||
pub preview_capacity: usize,
|
||||
pub file_loader: Option<FileLoader>,
|
||||
pub file_probe: Option<FileProbe>,
|
||||
pub preview_loader: Option<PreviewLoader>,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
|
|
@ -106,6 +116,7 @@ impl Default for MediaCache {
|
|||
preview_capacity: 0,
|
||||
file_loader: None,
|
||||
file_probe: None,
|
||||
preview_loader: None,
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)),
|
||||
}
|
||||
|
|
@ -207,6 +218,8 @@ pub struct RenderJob {
|
|||
/// Intermediate results of this view's previous renders (set by the frontend for the loupe):
|
||||
/// slider drags then only redo the stages the changed setting feeds.
|
||||
pub stages: Option<Arc<StageCache>>,
|
||||
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
|
|
@ -216,6 +229,8 @@ pub struct RenderResult {
|
|||
pub rendered: Result<Rendered, String>,
|
||||
/// A source that was loaded by this job (to be inserted into the cache).
|
||||
pub loaded: Option<Arc<Rgb32f>>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
|
|
@ -240,7 +255,14 @@ impl RenderJob {
|
|||
{
|
||||
let image = Arc::unwrap_or_clone(img);
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
return RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
|
||||
return RenderResult {
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(Rendered { image, histogram }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load() {
|
||||
|
|
@ -252,9 +274,21 @@ impl RenderJob {
|
|||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put(*key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) }
|
||||
if let Some((cache, key)) = &self.view_cache
|
||||
&& self.request.quality == Quality::Full
|
||||
{
|
||||
cache.put_deferred(*key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
RenderResult {
|
||||
photo: self.photo,
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(src),
|
||||
quick: None,
|
||||
}
|
||||
}
|
||||
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None },
|
||||
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -272,6 +306,58 @@ pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a [`QuickJob`]'s stand-in image came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum QuickSource {
|
||||
/// The photo's own develop render for its current settings (cached view or thumbnail render).
|
||||
Cached,
|
||||
/// The camera's embedded JPEG (unedited raws).
|
||||
Embedded,
|
||||
/// A thumbnail-level develop render made for the occasion.
|
||||
Small,
|
||||
}
|
||||
|
||||
/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached
|
||||
/// renders, the embedded preview of an unedited raw, a thumbnail-level render.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickJob {
|
||||
pub photo: PhotoId,
|
||||
pub key: u64,
|
||||
/// Cached renders to try first, in order: (cache, key).
|
||||
pub cached: Vec<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Embedded preview of an unedited raw: (path, loader, max edge).
|
||||
pub embedded: Option<(String, PreviewLoader, usize)>,
|
||||
/// Last resort: run this thumbnail job.
|
||||
pub small: Option<Box<RenderJob>>,
|
||||
}
|
||||
|
||||
impl QuickJob {
|
||||
pub fn run(self) -> RenderResult {
|
||||
let (photo, key) = (self.photo, self.key);
|
||||
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
|
||||
let rendered = |image: Rgba8| {
|
||||
let histogram = Histogram::of_srgb8(&image);
|
||||
Ok(Rendered { image, histogram })
|
||||
};
|
||||
for (cache, k) in &self.cached {
|
||||
if let Some(img) = cache.get(*k) {
|
||||
return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached));
|
||||
}
|
||||
}
|
||||
if let Some((path, loader, edge)) = &self.embedded
|
||||
&& let Some(image) = loader(path, *edge)
|
||||
{
|
||||
return done(rendered(image), Some(QuickSource::Embedded));
|
||||
}
|
||||
if let Some(job) = self.small {
|
||||
let r = job.run();
|
||||
return RenderResult { key, quick: Some(QuickSource::Small), ..r };
|
||||
}
|
||||
done(Err("no quick preview".into()), None)
|
||||
}
|
||||
}
|
||||
|
||||
/// What identifies a photo's pixels for caching: its content hash, else its source.
|
||||
pub fn content_key(p: &Photo) -> String {
|
||||
match (&p.content_hash, &p.source) {
|
||||
|
|
@ -325,7 +411,71 @@ impl crate::Session {
|
|||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||||
(self.media.rendered.clone(), k)
|
||||
});
|
||||
Some(RenderJob { photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings, request, key, cache, stages: None })
|
||||
Some(RenderJob {
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
origin: p.source.clone(),
|
||||
info: source_info(&p),
|
||||
settings,
|
||||
request,
|
||||
key,
|
||||
cache,
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish()
|
||||
}
|
||||
|
||||
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
|
||||
/// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`].
|
||||
pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option<RenderJob> {
|
||||
let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?;
|
||||
let p = self.catalog.photo(id)?;
|
||||
job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop)));
|
||||
Some(job)
|
||||
}
|
||||
|
||||
/// The embedded preview of an unedited raw (path, loader), when the app installed a loader.
|
||||
fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> {
|
||||
match (&p.source, &self.media.preview_loader) {
|
||||
(Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view
|
||||
/// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail.
|
||||
pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option<QuickJob> {
|
||||
let p = self.catalog.photo(id)?.clone();
|
||||
let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?;
|
||||
// (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview)
|
||||
let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))];
|
||||
let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish();
|
||||
let key = h.0 as u64;
|
||||
let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge())));
|
||||
Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||||
}
|
||||
|
||||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||||
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
|
||||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
|
||||
/// other photos (render them directly).
|
||||
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
|
||||
let p = self.catalog.photo(job.photo)?.clone();
|
||||
let (path, l) = self.embedded_of(&p)?;
|
||||
let edge = job.request.max_w.max(job.request.max_h);
|
||||
Some(QuickJob {
|
||||
photo: job.photo,
|
||||
key: job.key,
|
||||
cached: job.cache.clone().into_iter().collect(),
|
||||
embedded: Some((path, l, edge)),
|
||||
small: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
|
|
|
|||
|
|
@ -55,15 +55,40 @@ fn env_disabled() -> bool {
|
|||
*OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn device() -> Option<&'static ctx::Gpu> {
|
||||
static GPU: std::sync::OnceLock<Option<ctx::Gpu>> = std::sync::OnceLock::new();
|
||||
if env_disabled() {
|
||||
return None;
|
||||
}
|
||||
GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref()
|
||||
}
|
||||
|
||||
/// Create the device and compile the kernels on a background thread now (app start), so the
|
||||
/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does.
|
||||
pub fn warm_up() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !env_disabled() && GPU.get().is_none() {
|
||||
let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| {
|
||||
let _ = device();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Has device creation finished (successfully or not)? Never blocks — for status displays.
|
||||
pub fn ready() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
env_disabled() || GPU.get().is_some()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a usable GPU adapter exists and GPU rendering is enabled (creates the device on first
|
||||
/// use).
|
||||
pub fn available() -> bool {
|
||||
|
|
|
|||
|
|
@ -351,9 +351,8 @@ pub(crate) fn is_bw(s: &DevelopSettings) -> bool {
|
|||
s.treatment == Treatment::Bw || s.profile.id == "lc.mono"
|
||||
}
|
||||
|
||||
/// Parallel map over output rows with index.
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr.
|
||||
pub(crate) fn profiling() -> bool {
|
||||
pub fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,28 @@ impl PreviewCache {
|
|||
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img, c);
|
||||
}
|
||||
|
||||
/// Like [`Self::put`], but the disk write (a JPEG encode) happens on a background thread, so
|
||||
/// the caller (a render about to hand its result to the screen) isn't held up.
|
||||
pub fn put_deferred(self: &Arc<Self>, key: Hash128, img: Arc<Rgba8>) {
|
||||
let c = cost(&img);
|
||||
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c);
|
||||
if self.disk.is_none() {
|
||||
return;
|
||||
}
|
||||
let me = self.clone();
|
||||
let write = move || {
|
||||
if let Some(d) = &me.disk {
|
||||
d.put(key, &img);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() {
|
||||
log::warn!("preview cache: could not start a writer thread");
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
write();
|
||||
}
|
||||
|
||||
/// Drop everything (memory and disk).
|
||||
pub fn clear(&self) {
|
||||
self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! Reduced-size development straight from the mosaic ("superpixel" binning).
|
||||
//!
|
||||
//! When the caller needs an image at most half (or a third, a quarter…) of the sensor size — a
|
||||
//! 2560 px loupe preview or a grid thumbnail of a 24 MP raw — demosaicing the full mosaic and then
|
||||
//! downscaling throws most of the work away. Instead every `k × k` block of CFA samples becomes one
|
||||
//! output pixel whose channels are the means of the block's samples of that colour. Every block must
|
||||
//! contain all three colours (Bayer: `k` even; X-Trans: `k` a multiple of 3). There is no
|
||||
//! interpolation, so there are no demosaicing artefacts; the colour planes are offset by less than
|
||||
//! one output pixel, which is invisible at these scales.
|
||||
//!
|
||||
//! Clipping is preserved for [`crate::highlight::reconstruct`]: a channel whose block contains a
|
||||
//! sample at or above `clip` takes that block's maximum sample of the colour (instead of a mean that
|
||||
//! would fall just below the clip level and escape reconstruction).
|
||||
|
||||
use crate::{Normalized, RawData, RawImage, Result, Rgb32f};
|
||||
use rayon::prelude::*;
|
||||
|
||||
impl RawImage {
|
||||
/// Can `k × k` blocks be binned (single-plane CFA data, every `k × k` window containing R, G
|
||||
/// and B; Bayer: `k` even, so that every block has the same layout)?
|
||||
pub fn can_bin(&self, k: usize) -> bool {
|
||||
let Some(cfa) = &self.cfa else { return false };
|
||||
if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) {
|
||||
return false;
|
||||
}
|
||||
// every window position relative to the pattern
|
||||
(0..cfa.height).all(|j| (0..cfa.width).all(|i| block_layout(cfa, i, j, k).1.iter().all(|&n| n > 0)))
|
||||
}
|
||||
|
||||
/// Like [`RawImage::develop`] at `1/k` of the size (camera RGB, white = 1.0, default crop applied,
|
||||
/// not oriented). `None` when the data cannot be binned ([`RawImage::can_bin`]) or carries an
|
||||
/// `OpcodeList3` (whose operations are defined at full resolution); callers then demosaic.
|
||||
pub fn develop_binned(&self, k: usize, clip: f32) -> Result<Option<Rgb32f>> {
|
||||
self.validate()?;
|
||||
if !self.can_bin(k) || !self.opcodes.list3.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(cfa) = &self.cfa else { return Ok(None) };
|
||||
let a = self.active_area;
|
||||
// bin the default crop (relative to the active area) only
|
||||
let c = self.crop.clipped(a.width, a.height);
|
||||
let c = if c.width < k || c.height < k { crate::Rect::new(0, 0, a.width, a.height) } else { c };
|
||||
let (bw, bh) = (c.width / k, c.height / k);
|
||||
if bw == 0 || bh == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Opcode lists 1/2 need the normalised copy; plain files are normalised row by row.
|
||||
let norm: Option<Normalized> = if self.opcodes.list1.is_empty() && self.opcodes.list2.is_empty() { None } else { Some(self.normalized()?) };
|
||||
let cfa = cfa.shifted(a.x, a.y);
|
||||
let range = self.white_at(0) - self.black.mean();
|
||||
let scale = if range > 0.0 { 1.0 / range } else { 1.0 };
|
||||
let uniform_black =
|
||||
self.black.delta_h.is_empty() && self.black.delta_v.is_empty() && self.black.values.iter().all(|&v| v == self.black.values[0]);
|
||||
let black0 = self.black.at(0, 0, 0, 1);
|
||||
// colour of every sample of a block, per pattern phase of the block's corner
|
||||
let (pw, ph) = (cfa.width, cfa.height);
|
||||
let layouts: Vec<(Vec<u8>, [f32; 3])> = (0..ph)
|
||||
.flat_map(|j| (0..pw).map(move |i| (i, j)))
|
||||
.map(|(i, j)| {
|
||||
let (l, n) = block_layout(&cfa, i, j, k);
|
||||
(l, n.map(|n| 1.0 / n.max(1) as f32))
|
||||
})
|
||||
.collect();
|
||||
let wlen = bw * k;
|
||||
let mut out = Rgb32f::new(bw, bh);
|
||||
out.data.par_chunks_mut(bw).enumerate().for_each(|(by, row)| {
|
||||
// the block row's k sample rows, normalised
|
||||
let mut rows = vec![0f32; k * wlen];
|
||||
for dy in 0..k {
|
||||
let y = c.y + by * k + dy;
|
||||
let dst = &mut rows[dy * wlen..(dy + 1) * wlen];
|
||||
match (&norm, &self.data) {
|
||||
(Some(nm), _) => dst.copy_from_slice(&nm.data[y * nm.width + c.x..][..wlen]),
|
||||
(None, RawData::U16(d)) if uniform_black => {
|
||||
let src = &d[(a.y + y) * self.width + a.x + c.x..][..wlen];
|
||||
for (o, &v) in dst.iter_mut().zip(src) {
|
||||
*o = (v as f32 - black0) * scale;
|
||||
}
|
||||
}
|
||||
(None, data) => {
|
||||
let base = (a.y + y) * self.width + a.x + c.x;
|
||||
for (x, o) in dst.iter_mut().enumerate() {
|
||||
*o = (data.get(base + x) - self.black.at(c.x + x, y, 0, 1)) * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let py = (c.y + by * k) % ph;
|
||||
for (bx, px) in row.iter_mut().enumerate() {
|
||||
let (layout, inv) = &layouts[py * pw + (c.x + bx * k) % pw];
|
||||
let (mut sum, mut max) = ([0f32; 3], [f32::MIN; 3]);
|
||||
for dy in 0..k {
|
||||
let src = &rows[dy * wlen + bx * k..][..k];
|
||||
for (&v, &col) in src.iter().zip(&layout[dy * k..(dy + 1) * k]) {
|
||||
let col = col as usize;
|
||||
sum[col] += v;
|
||||
max[col] = max[col].max(v);
|
||||
}
|
||||
}
|
||||
for ch in 0..3 {
|
||||
px[ch] = if max[ch] >= clip { max[ch] } else { sum[ch] * inv[ch] };
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// Colours of the `k × k` samples of a block whose corner sits at pattern position `(i, j)`
|
||||
/// (row-major) and the count of each colour.
|
||||
fn block_layout(cfa: &crate::Cfa, i: usize, j: usize, k: usize) -> (Vec<u8>, [u32; 3]) {
|
||||
let mut n = [0u32; 3];
|
||||
let l: Vec<u8> = (0..k * k)
|
||||
.map(|t| {
|
||||
let c = cfa.color_at(i + t % k, j + t / k).min(2);
|
||||
n[c as usize] += 1;
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
(l, n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::demosaic::mosaic_from_rgb;
|
||||
use crate::*;
|
||||
|
||||
fn raw_from(n: &Normalized, black: f32, white: f32) -> RawImage {
|
||||
let data = n.data.iter().map(|&v| (v * (white - black) + black).round() as u16).collect();
|
||||
RawImage {
|
||||
format: RawFormat::Dng,
|
||||
width: n.width,
|
||||
height: n.height,
|
||||
cpp: 1,
|
||||
data: RawData::U16(data),
|
||||
cfa: n.cfa.clone(),
|
||||
bits: 14,
|
||||
black: BlackLevel::uniform(black),
|
||||
white: vec![white],
|
||||
active_area: Rect::new(0, 0, n.width, n.height),
|
||||
crop: Rect::new(0, 0, n.width, n.height),
|
||||
orientation: Orientation::Normal,
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: None,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata: Metadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binning_matches_block_means_and_keeps_clipping() {
|
||||
let img = Rgb32f::from_fn(24, 18, |x, y| [0.1 + x as f32 * 0.01, 0.2 + y as f32 * 0.01, 0.3]);
|
||||
let mut raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("GRBG").unwrap()), 512.0, 16383.0);
|
||||
assert!(raw.can_bin(2) && raw.can_bin(4) && !raw.can_bin(3));
|
||||
let b = raw.develop_binned(2, 0.99).unwrap().unwrap();
|
||||
assert_eq!((b.width, b.height), (12, 9));
|
||||
let p = b.get(3, 2);
|
||||
// red sample of block (3,2) under GRBG is at (7,4); blue at (6,5); greens average (6,4)+(7,5)
|
||||
assert!((p[0] - img.get(7, 4)[0]).abs() < 1e-3, "{p:?}");
|
||||
assert!((p[2] - 0.3).abs() < 1e-3);
|
||||
assert!((p[1] - (img.get(6, 4)[1] + img.get(7, 5)[1]) / 2.0).abs() < 1e-3);
|
||||
// a single clipped green sample keeps the block's green clipped
|
||||
if let RawData::U16(d) = &mut raw.data {
|
||||
d[4 * 24 + 6] = 16383;
|
||||
}
|
||||
assert!(raw.develop_binned(2, 0.99).unwrap().unwrap().get(3, 2)[1] >= 0.99);
|
||||
// default crop is honoured at the binned scale
|
||||
raw.crop = Rect::new(2, 2, 20, 12);
|
||||
let c = raw.develop_binned(2, 0.99).unwrap().unwrap();
|
||||
assert_eq!((c.width, c.height), (10, 6));
|
||||
// X-Trans: 3×3 blocks hold all colours, 2×2 don't
|
||||
let x = raw_from(&mosaic_from_rgb(&img, &Cfa::xtrans()), 0.0, 1000.0);
|
||||
assert!(x.can_bin(3) && x.can_bin(6) && !x.can_bin(2));
|
||||
let xb = x.develop_binned(3, 0.99).unwrap().unwrap();
|
||||
assert_eq!((xb.width, xb.height), (8, 6));
|
||||
assert!((xb.get(4, 3)[2] - 0.3).abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binning_matches_demosaic_downscaled_on_smooth_images() {
|
||||
let img = Rgb32f::from_fn(64, 48, |x, y| [0.2 + 0.3 * (x as f32 / 64.0), 0.4 - 0.2 * (y as f32 / 48.0), 0.25]);
|
||||
let raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0);
|
||||
let b = raw.develop_binned(2, 0.99).unwrap().unwrap();
|
||||
let full = lightcraft_raster::resample::half(&raw.develop(Method::Ahd).unwrap());
|
||||
for y in 2..b.height - 2 {
|
||||
for x in 2..b.width - 2 {
|
||||
for c in 0..3 {
|
||||
assert!((b.get(x, y)[c] - full.get(x, y)[c]).abs() < 0.01, "({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,24 +13,115 @@ use lightcraft_raster::par_rows;
|
|||
|
||||
const EPS: f32 = 1e-3;
|
||||
|
||||
fn green(m: &Mosaic) -> Vec<f32> {
|
||||
/// Same-colour neighbour offsets for every pattern phase, in scan order (so sums run in the same
|
||||
/// order as a plain 2-D loop and interior pixels match border pixels bit for bit).
|
||||
struct Offsets {
|
||||
pw: usize,
|
||||
ph: usize,
|
||||
/// Per phase: green offsets at radius 1, then those at radius 2 (ring order as the loop below).
|
||||
green: Vec<(Vec<(isize, isize)>, Vec<(isize, isize)>)>,
|
||||
/// Per phase and colour (0 = red, 1 = blue): 5×5 offsets of that colour.
|
||||
rb: Vec<[Vec<(isize, isize)>; 2]>,
|
||||
}
|
||||
|
||||
impl Offsets {
|
||||
fn new(m: &Mosaic) -> Offsets {
|
||||
let (pw, ph) = (m.cfa.width.max(1), m.cfa.height.max(1));
|
||||
// phases are evaluated far from the borders (pattern coordinates wrap)
|
||||
let col = |x: isize, y: isize| m.cfa.color_at(x.rem_euclid(pw as isize) as usize, y.rem_euclid(ph as isize) as usize);
|
||||
let mut green = Vec::new();
|
||||
let mut rb = Vec::new();
|
||||
for py in 0..ph as isize {
|
||||
for px in 0..pw as isize {
|
||||
let ring = |r: isize| {
|
||||
let mut v = Vec::new();
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if (dx != 0 || dy != 0) && col(px + dx, py + dy) == 1 {
|
||||
v.push((dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
green.push((ring(1), ring(2)));
|
||||
let of = |c: u8| {
|
||||
let mut v = Vec::new();
|
||||
for dy in -2isize..=2 {
|
||||
for dx in -2isize..=2 {
|
||||
if col(px + dx, py + dy) == c {
|
||||
v.push((dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
rb.push([of(0), of(2)]);
|
||||
}
|
||||
}
|
||||
Offsets { pw, ph, green, rb }
|
||||
}
|
||||
#[inline]
|
||||
fn phase(&self, x: usize, y: usize) -> usize {
|
||||
(y % self.ph) * self.pw + x % self.pw
|
||||
}
|
||||
}
|
||||
|
||||
/// Robust weighted mean of `vals` around their median.
|
||||
#[inline]
|
||||
fn robust_mean(vals: &[f32]) -> f32 {
|
||||
let mut sorted = [0f32; 24];
|
||||
let sorted = &mut sorted[..vals.len()];
|
||||
sorted.copy_from_slice(vals);
|
||||
sorted.sort_unstable_by(|a, b| a.total_cmp(b));
|
||||
let med = sorted[sorted.len() / 2];
|
||||
let (mut s, mut ws) = (0.0f32, 0.0f32);
|
||||
for &v in vals {
|
||||
let wt = 1.0 / (EPS + (v - med).abs());
|
||||
s += wt * v;
|
||||
ws += wt;
|
||||
}
|
||||
s / ws
|
||||
}
|
||||
|
||||
fn green(m: &Mosaic, off: &Offsets) -> Vec<f32> {
|
||||
let (w, h) = (m.w, m.h);
|
||||
let mut g = vec![0f32; w * h];
|
||||
par_rows(&mut g, w, |y, row| {
|
||||
let y = y as isize;
|
||||
let interior_y = y >= 2 && y + 2 < h;
|
||||
let yi = y as isize;
|
||||
let mut vals: Vec<f32> = Vec::with_capacity(24);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
if interior_y && x >= 2 && x + 2 < w {
|
||||
// fast path: precomputed offsets, no reflection
|
||||
let ph = off.phase(x, y);
|
||||
let i = y * w + x;
|
||||
if m.cfa.color_at(x, y) == 1 {
|
||||
*o = m.data[i];
|
||||
continue;
|
||||
}
|
||||
let (r1, r2) = &off.green[ph];
|
||||
let ring = if r1.is_empty() { r2 } else { r1 };
|
||||
if ring.is_empty() {
|
||||
*o = m.data[i];
|
||||
continue;
|
||||
}
|
||||
vals.clear();
|
||||
vals.extend(ring.iter().map(|&(dx, dy)| m.data[(i as isize + dy * w as isize + dx) as usize]));
|
||||
*o = robust_mean(&vals);
|
||||
continue;
|
||||
}
|
||||
let x = x as isize;
|
||||
if m.color(x, y) == 1 {
|
||||
*o = m.get(x, y);
|
||||
if m.color(x, yi) == 1 {
|
||||
*o = m.get(x, yi);
|
||||
continue;
|
||||
}
|
||||
vals.clear();
|
||||
for r in [1isize, 2] {
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if (dx != 0 || dy != 0) && m.color(x + dx, y + dy) == 1 {
|
||||
vals.push(m.get(x + dx, y + dy));
|
||||
if (dx != 0 || dy != 0) && m.color(x + dx, yi + dy) == 1 {
|
||||
vals.push(m.get(x + dx, yi + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,19 +130,10 @@ fn green(m: &Mosaic) -> Vec<f32> {
|
|||
}
|
||||
}
|
||||
if vals.is_empty() {
|
||||
*o = m.get(x, y);
|
||||
*o = m.get(x, yi);
|
||||
continue;
|
||||
}
|
||||
let mut sorted = vals.clone();
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
let med = sorted[sorted.len() / 2];
|
||||
let (mut s, mut ws) = (0.0f32, 0.0f32);
|
||||
for &v in &vals {
|
||||
let wt = 1.0 / (EPS + (v - med).abs());
|
||||
s += wt * v;
|
||||
ws += wt;
|
||||
}
|
||||
*o = s / ws;
|
||||
*o = robust_mean(&vals);
|
||||
}
|
||||
});
|
||||
g
|
||||
|
|
@ -59,31 +141,57 @@ fn green(m: &Mosaic) -> Vec<f32> {
|
|||
|
||||
pub(crate) fn directional(m: &Mosaic) -> Rgb32f {
|
||||
let (w, h) = (m.w, m.h);
|
||||
let g = green(m);
|
||||
let off = Offsets::new(m);
|
||||
let g = green(m, &off);
|
||||
let gg = |x: isize, y: isize| g[super::reflect(y, h) * w + super::reflect(x, w)];
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let y = y as isize;
|
||||
let interior_y = y >= 2 && y + 2 < h;
|
||||
let yi = y as isize;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
if interior_y && x >= 2 && x + 2 < w {
|
||||
let i = y * w + x;
|
||||
let own = m.cfa.color_at(x, y) as usize;
|
||||
let gp = g[i];
|
||||
px[1] = gp;
|
||||
let lists = &off.rb[off.phase(x, y)];
|
||||
for (li, c) in [0usize, 2].into_iter().enumerate() {
|
||||
if c == own {
|
||||
px[c] = m.data[i];
|
||||
continue;
|
||||
}
|
||||
let (mut s, mut ws) = (0.0f32, 0.0f32);
|
||||
for &(dx, dy) in &lists[li] {
|
||||
let j = (i as isize + dy * w as isize + dx) as usize;
|
||||
let gq = g[j];
|
||||
let d2 = (dx * dx + dy * dy) as f32;
|
||||
let wt = 1.0 / (d2 * (EPS + (gq - gp).abs()));
|
||||
s += wt * (m.data[j] - gq);
|
||||
ws += wt;
|
||||
}
|
||||
px[c] = if ws > 0.0 { gp + s / ws } else { gp };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let x = x as isize;
|
||||
let own = m.color(x, y) as usize;
|
||||
let gp = gg(x, y);
|
||||
let own = m.color(x, yi) as usize;
|
||||
let gp = gg(x, yi);
|
||||
px[1] = gp;
|
||||
for c in [0usize, 2] {
|
||||
if c == own {
|
||||
px[c] = m.get(x, y);
|
||||
px[c] = m.get(x, yi);
|
||||
continue;
|
||||
}
|
||||
let (mut s, mut ws) = (0.0f32, 0.0f32);
|
||||
for dy in -2isize..=2 {
|
||||
for dx in -2isize..=2 {
|
||||
if m.color(x + dx, y + dy) as usize != c {
|
||||
if m.color(x + dx, yi + dy) as usize != c {
|
||||
continue;
|
||||
}
|
||||
let gq = gg(x + dx, y + dy);
|
||||
let gq = gg(x + dx, yi + dy);
|
||||
let d2 = (dx * dx + dy * dy) as f32;
|
||||
let wt = 1.0 / (d2 * (EPS + (gq - gp).abs()));
|
||||
s += wt * (m.get(x + dx, y + dy) - gq);
|
||||
s += wt * (m.get(x + dx, yi + dy) - gq);
|
||||
ws += wt;
|
||||
}
|
||||
}
|
||||
|
|
@ -93,3 +201,81 @@ pub(crate) fn directional(m: &Mosaic) -> Rgb32f {
|
|||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Cfa;
|
||||
|
||||
/// The straightforward per-pixel formulation (reflection at every access): the precomputed
|
||||
/// interior path must match it bit for bit.
|
||||
fn reference(m: &Mosaic) -> Rgb32f {
|
||||
let (w, h) = (m.w, m.h);
|
||||
let mut g = vec![0f32; w * h];
|
||||
for y in 0..h as isize {
|
||||
for x in 0..w as isize {
|
||||
let o = &mut g[y as usize * w + x as usize];
|
||||
if m.color(x, y) == 1 {
|
||||
*o = m.get(x, y);
|
||||
continue;
|
||||
}
|
||||
let mut vals = Vec::new();
|
||||
for r in [1isize, 2] {
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if (dx != 0 || dy != 0) && m.color(x + dx, y + dy) == 1 {
|
||||
vals.push(m.get(x + dx, y + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vals.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
*o = if vals.is_empty() { m.get(x, y) } else { robust_mean(&vals) };
|
||||
}
|
||||
}
|
||||
let gg = |x: isize, y: isize| g[super::super::reflect(y, h) * w + super::super::reflect(x, w)];
|
||||
Rgb32f::from_fn(w, h, |x, y| {
|
||||
let (x, y) = (x as isize, y as isize);
|
||||
let own = m.color(x, y) as usize;
|
||||
let gp = gg(x, y);
|
||||
let mut px = [0f32, gp, 0.0];
|
||||
for c in [0usize, 2] {
|
||||
if c == own {
|
||||
px[c] = m.get(x, y);
|
||||
continue;
|
||||
}
|
||||
let (mut s, mut ws) = (0.0f32, 0.0f32);
|
||||
for dy in -2isize..=2 {
|
||||
for dx in -2isize..=2 {
|
||||
if m.color(x + dx, y + dy) as usize == c {
|
||||
let gq = gg(x + dx, y + dy);
|
||||
let wt = 1.0 / ((dx * dx + dy * dy) as f32 * (EPS + (gq - gp).abs()));
|
||||
s += wt * (m.get(x + dx, y + dy) - gq);
|
||||
ws += wt;
|
||||
}
|
||||
}
|
||||
}
|
||||
px[c] = if ws > 0.0 { gp + s / ws } else { gp };
|
||||
}
|
||||
px
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_path_is_bit_exact() {
|
||||
let cfa = Cfa::xtrans().shifted(1, 2);
|
||||
let (w, h) = (37, 29);
|
||||
let mut seed = 12345u32;
|
||||
let data: Vec<f32> = (0..w * h)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32
|
||||
})
|
||||
.collect();
|
||||
let m = Mosaic { w, h, data: &data, cfa: &cfa };
|
||||
let (a, b) = (directional(&m), reference(&m));
|
||||
assert!(a.data.iter().zip(&b.data).all(|(p, q)| p.map(f32::to_bits) == q.map(f32::to_bits)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub(crate) fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
|||
.into_iter()
|
||||
.filter(|i| i.u32(t::NEW_SUBFILE_TYPE).unwrap_or(0) == 0)
|
||||
.filter(|i| matches!(i.u16(t::PHOTOMETRIC), Some(photometric::CFA) | Some(photometric::LINEAR_RAW)))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0)))
|
||||
}
|
||||
|
||||
fn mat3(v: Option<Vec<f64>>) -> Option<Mat3> {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
//! normalised-convolution fill). Fully clipped pixels become neutral at the brightest plausible level.
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Clip every channel of `wb ⊙ img` at `min_c(wb_c) · clip` — i.e. at the lowest channel's clip level after WB —
|
||||
/// then divide the multipliers back out. `clip` is the sensor clip in normalised units (≈ 1.0).
|
||||
|
|
@ -28,25 +29,28 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool])
|
|||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut cv = vec![[0f32; 3]; cw * ch];
|
||||
let mut cval = vec![false; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let (mut s, mut n) = ([0f32; 3], 0);
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let (sx, sy) = (2 * x + dx, 2 * y + dy);
|
||||
if sx < w && sy < h && valid[sy * w + sx] {
|
||||
for c in 0..3 {
|
||||
s[c] += values[sy * w + sx][c];
|
||||
{
|
||||
let (values, valid) = (&*values, &*valid);
|
||||
cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| {
|
||||
for x in 0..cw {
|
||||
let (mut s, mut n) = ([0f32; 3], 0);
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let (sx, sy) = (2 * x + dx, 2 * y + dy);
|
||||
if sx < w && sy < h && valid[sy * w + sx] {
|
||||
for c in 0..3 {
|
||||
s[c] += values[sy * w + sx][c];
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
vrow[x] = s.map(|v| v / n as f32);
|
||||
okrow[x] = true;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
cv[y * cw + x] = s.map(|v| v / n as f32);
|
||||
cval[y * cw + x] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if cw * ch < w * h {
|
||||
fill_invalid(cw, ch, &mut cv, &mut cval);
|
||||
|
|
@ -54,10 +58,9 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool])
|
|||
// cannot shrink further (1×1): nothing valid anywhere handled above
|
||||
return;
|
||||
}
|
||||
for y in 0..h {
|
||||
values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if !valid[i] {
|
||||
if !okrow[x] {
|
||||
// bilinear upsample of the coarse level
|
||||
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32);
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32);
|
||||
|
|
@ -71,11 +74,11 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool])
|
|||
let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx;
|
||||
v[c] = top + (bot - top) * ty;
|
||||
}
|
||||
values[i] = v;
|
||||
valid[i] = true;
|
||||
vrow[x] = v;
|
||||
okrow[x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly
|
||||
|
|
@ -84,52 +87,56 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool])
|
|||
pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let n = w * h;
|
||||
let clipped: Vec<[bool; 3]> = img.data.iter().map(|p| [p[0] >= clip, p[1] >= clip, p[2] >= clip]).collect();
|
||||
let count = clipped.iter().filter(|c| c.iter().any(|&b| b)).count();
|
||||
let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip;
|
||||
let count = img.data.par_iter().filter(|p| is_clipped(p)).count();
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
// chromaticity (white-balanced ratios to the channel mean) of unclipped pixels
|
||||
let mut chroma = vec![[1f32 / 3.0; 3]; n];
|
||||
let mut valid = vec![false; n];
|
||||
for i in 0..n {
|
||||
if clipped[i].iter().any(|&b| b) {
|
||||
continue;
|
||||
chroma.par_iter_mut().zip(valid.par_iter_mut()).zip(img.data.par_iter()).for_each(|((r, v), p)| {
|
||||
if is_clipped(p) {
|
||||
return;
|
||||
}
|
||||
let p = img.data[i];
|
||||
let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let s = q[0] + q[1] + q[2];
|
||||
if s > 1e-4 {
|
||||
chroma[i] = q.map(|v| v.max(0.0) / s);
|
||||
valid[i] = true;
|
||||
*r = q.map(|v| v.max(0.0) / s);
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
fill_invalid(w, h, &mut chroma, &mut valid);
|
||||
let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip;
|
||||
for i in 0..n {
|
||||
let cl = clipped[i];
|
||||
img.data.par_iter_mut().zip(chroma.par_iter()).for_each(|(px, r)| {
|
||||
let p = *px;
|
||||
let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip];
|
||||
if !cl.iter().any(|&b| b) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let p = img.data[i];
|
||||
let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
let r = chroma[i];
|
||||
// estimate the white-balanced channel sum from the unclipped channels
|
||||
let ests: Vec<f32> = (0..3).filter(|&c| !cl[c] && r[c] > 1e-3).map(|c| q[c] / r[c]).collect();
|
||||
let (mut sum, mut k) = (0f32, 0usize);
|
||||
for c in 0..3 {
|
||||
if !cl[c] && r[c] > 1e-3 {
|
||||
sum += q[c] / r[c];
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let mut out = q;
|
||||
if ests.is_empty() {
|
||||
if k == 0 {
|
||||
let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level);
|
||||
out = [v; 3];
|
||||
} else {
|
||||
let sum = ests.iter().sum::<f32>() / ests.len() as f32;
|
||||
let sum = sum / k as f32;
|
||||
for c in 0..3 {
|
||||
if cl[c] {
|
||||
out[c] = q[c].max(sum * r[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
img.data[i] = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]];
|
||||
}
|
||||
*px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]];
|
||||
});
|
||||
count
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG.
|
||||
//! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG
|
||||
//! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`];
|
||||
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop.
|
||||
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
|
||||
//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails).
|
||||
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
|
||||
//! and produces camera → linear Rec.2020 D65 matrices.
|
||||
//!
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod binned;
|
||||
pub mod color;
|
||||
pub mod demosaic;
|
||||
mod dng;
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
|||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
.filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::CFA) || i.contains(TONE_CURVE))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0)))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
|||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
.filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::CFA))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0))
|
||||
.max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Width without the optically masked columns some bodies append on the right: trailing columns (at most 64)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 96fbbf63f3e7e0d186dc8aee69f5452640ec49e763149965b40707abbe37065c # shrinks to flips = [(16380191992317618766, 16), (5757657205609716137, 1)], cut = 11088808457807325322, lj = true
|
||||
|
|
@ -77,7 +77,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
|||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"activeMask": app.session.active_mask,
|
||||
"widgetCount": app.widgets.len(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"status": app.ui.status,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,10 +80,15 @@ pub struct LightcraftApp {
|
|||
pub widgets: Vec<(String, egui::Rect)>,
|
||||
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
|
||||
pub gesture: Option<panels::detail::Gesture>,
|
||||
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
|
||||
/// "thumb", "none").
|
||||
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
// GPU device + kernels off the UI thread, before the first photo is opened
|
||||
lightcraft_engine::gpu::warm_up();
|
||||
Self {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
|
|
@ -105,6 +110,7 @@ impl LightcraftApp {
|
|||
image_rect: None,
|
||||
widgets: vec![],
|
||||
gesture: None,
|
||||
loupe_shown: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,10 +142,37 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
let scale = if interacting { 0.6 } else { 1.0 };
|
||||
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) {
|
||||
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
||||
let job = if interacting { job.draft() } else { job };
|
||||
app.renderer.request(Slot::Main, job, 100);
|
||||
}
|
||||
// once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and
|
||||
// kept, view render cached) so stepping to them is instant
|
||||
if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) {
|
||||
let ids = app.session.visible_cloned();
|
||||
if let Some(i) = ids.iter().position(|p| *p == id) {
|
||||
let next = ids.get(i + 1).copied();
|
||||
let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied();
|
||||
for (n, nid) in [next, prev].into_iter().enumerate() {
|
||||
let Some(nid) = nid else { continue };
|
||||
let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };
|
||||
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
|
||||
let na = nf.aspect() as f32;
|
||||
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
|
||||
let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize;
|
||||
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
|
||||
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
|
||||
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// until the loupe has this photo: show its cached render / embedded preview / a thumbnail
|
||||
if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id)
|
||||
&& let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool)
|
||||
{
|
||||
app.renderer.request_quick(Slot::Preview, q, 110);
|
||||
}
|
||||
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
|
||||
if (split || show_before || app.ui.before_after == BeforeAfter::Split)
|
||||
&& let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool)
|
||||
|
|
@ -153,29 +180,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
app.renderer.request(Slot::Before, job, 90);
|
||||
}
|
||||
let p = ui.painter_at(canvas);
|
||||
let draw = |slot: Slot, r: Rect| {
|
||||
if let Some(tex) = app.renderer.textures.get(&slot).filter(|t| t.photo == id) {
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
|
||||
// between photos, and the full render replaces them in place)
|
||||
let draw = |slot: Slot, r: Rect| -> &'static str {
|
||||
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
|
||||
let (tex, what) = if let Some(t) = mine(slot) {
|
||||
(t, "render")
|
||||
} else if let Some(t) = mine(Slot::Preview) {
|
||||
(t, t.quick.map(quick_name).unwrap_or("preview"))
|
||||
} else if let Some(t) = app.renderer.thumb(id) {
|
||||
(t, "thumb")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
return "none";
|
||||
};
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
what
|
||||
};
|
||||
let shown;
|
||||
if split {
|
||||
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
draw(Slot::Before, br);
|
||||
draw(Slot::Main, img_rect);
|
||||
shown = draw(Slot::Main, img_rect);
|
||||
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
|
||||
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
|
||||
} else if show_before {
|
||||
draw(Slot::Before, img_rect);
|
||||
shown = draw(Slot::Before, img_rect);
|
||||
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
|
||||
} else if !draw(Slot::Main, img_rect) {
|
||||
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
||||
} else {
|
||||
shown = draw(Slot::Main, img_rect);
|
||||
if shown == "none" {
|
||||
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
app.loupe_shown = Some((id, shown));
|
||||
if app.ui.before_after == BeforeAfter::Split {
|
||||
let mid = img_rect.center().x;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
|
|
@ -199,6 +236,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
|
||||
const PREFETCH_PRIORITY: u32 = 4;
|
||||
|
||||
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
|
||||
use lightcraft_engine::media::QuickSource;
|
||||
match q {
|
||||
QuickSource::Cached => "cached",
|
||||
QuickSource::Embedded => "embedded",
|
||||
QuickSource::Small => "small",
|
||||
}
|
||||
}
|
||||
|
||||
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
|
||||
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
|
||||
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
|
||||
|
|
@ -691,10 +740,8 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|||
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
if let Some(job) = app.session.thumb_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2) {
|
||||
app.renderer.request(Slot::Thumb(*id), job, 8);
|
||||
}
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {
|
||||
super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8);
|
||||
if let Some(tex) = app.renderer.thumb(*id) {
|
||||
let [tw, th] = tex.size;
|
||||
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
|
||||
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
|
||||
|
|
|
|||
|
|
@ -128,6 +128,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE);
|
||||
}
|
||||
|
||||
/// Request a grid/filmstrip thumbnail at `priority`. An unedited raw without a thumbnail texture
|
||||
/// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real
|
||||
/// render then follows in the background.
|
||||
pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) {
|
||||
let Some(job) = app.session.thumb_job(id, size) else { return };
|
||||
let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) };
|
||||
match quick {
|
||||
Some(q) => {
|
||||
app.renderer.request_quick(Slot::ThumbQuick(id), q, priority + 1);
|
||||
if !app.renderer.is_pending(Slot::ThumbQuick(id)) {
|
||||
app.renderer.request(Slot::Thumb(id), job, BACKGROUND_THUMB_PRIORITY);
|
||||
}
|
||||
}
|
||||
None => app.renderer.request(Slot::Thumb(id), job, priority),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendered thumbnails replacing embedded previews: after everything on screen.
|
||||
pub const BACKGROUND_THUMB_PRIORITY: u32 = 3;
|
||||
|
||||
fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
|
|
@ -144,10 +164,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
|||
};
|
||||
// thumbnail
|
||||
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
|
||||
if let Some(job) = app.session.thumb_job(id, size) {
|
||||
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
|
||||
}
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
request_thumb(app, id, size, if onscreen { 10 } else { 5 });
|
||||
if let Some(tex) = app.renderer.thumb(id) {
|
||||
let [tw, th] = tex.size;
|
||||
let fit = if square {
|
||||
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@
|
|||
//! disk) before rendering. On wasm the jobs run inline, one per frame, unless the host installs a
|
||||
//! [`RenderOffload`] (the browser build's Web Workers): then queued jobs are handed to it as it has
|
||||
//! room, and its results are collected every frame.
|
||||
//!
|
||||
//! Stand-ins ([`QuickJob`]s, once per photo and settings): [`Slot::Preview`] holds what the loupe
|
||||
//! shows until [`Slot::Main`] has the photo's render (cached view render, embedded camera JPEG or
|
||||
//! a thumbnail render); [`Slot::ThumbQuick`] holds a raw's embedded preview in the grid until its
|
||||
//! rendered thumbnail arrives (a cached thumbnail found by the quick job becomes the
|
||||
//! [`Slot::Thumb`] texture directly).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult};
|
||||
use lightcraft_engine::media::{QuickJob, QuickSource, RenderJob, RenderResult};
|
||||
use lightcraft_engine::pipeline::StageCache;
|
||||
use lightcraft_preview::JobPool;
|
||||
use lightcraft_raster::Histogram;
|
||||
|
|
@ -21,9 +27,16 @@ use std::sync::Arc;
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Slot {
|
||||
Thumb(PhotoId),
|
||||
/// A thumbnail's stand-in (embedded preview of an unedited raw).
|
||||
ThumbQuick(PhotoId),
|
||||
Main,
|
||||
/// The loupe's stand-in until `Main` has the photo.
|
||||
Preview,
|
||||
Before,
|
||||
Compare(u8),
|
||||
/// Background preparation of a neighbouring photo (no texture: its decoded source and view
|
||||
/// render are cached by the engine). 0 = next, 1 = previous.
|
||||
Prefetch(u8),
|
||||
}
|
||||
|
||||
pub struct Tex {
|
||||
|
|
@ -33,6 +46,8 @@ pub struct Tex {
|
|||
pub size: [usize; 2],
|
||||
pub histogram: Option<Histogram>,
|
||||
pub ms: f64,
|
||||
/// Set for stand-ins: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
/// CPU copy of the pixels (only with [`Renderer::keep_pixels`]; for headless screenshots).
|
||||
pub pixels: Option<std::sync::Arc<egui::ColorImage>>,
|
||||
}
|
||||
|
|
@ -71,6 +86,10 @@ pub struct Renderer {
|
|||
pub keep_pixels: bool,
|
||||
/// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed.
|
||||
stages: HashMap<Slot, Arc<StageCache>>,
|
||||
/// Slot → key of the last quick job requested for it (each is tried once).
|
||||
quick_tried: HashMap<Slot, u64>,
|
||||
/// Prefetch slot → key of the last job submitted (each runs once).
|
||||
prefetched: HashMap<Slot, u64>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
|
|
@ -87,6 +106,8 @@ impl Default for Renderer {
|
|||
completed: 0,
|
||||
keep_pixels: false,
|
||||
stages: HashMap::new(),
|
||||
quick_tried: HashMap::new(),
|
||||
prefetched: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -131,6 +152,39 @@ impl Renderer {
|
|||
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
|
||||
}
|
||||
|
||||
/// Request a stand-in for `slot` (once per job key).
|
||||
pub fn request_quick(&mut self, slot: Slot, job: QuickJob, priority: u32) {
|
||||
if self.quick_tried.get(&slot) == Some(&job.key) {
|
||||
return;
|
||||
}
|
||||
self.quick_tried.insert(slot, job.key);
|
||||
self.pending.insert(slot, (job.key, priority));
|
||||
let key = job.key;
|
||||
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
|
||||
}
|
||||
|
||||
/// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one
|
||||
/// replaces it while queued). Memory stays bounded by the engine's source caches.
|
||||
pub fn prefetch(&mut self, slot: Slot, job: RenderJob, priority: u32) {
|
||||
if self.prefetched.get(&slot) == Some(&job.key) {
|
||||
return;
|
||||
}
|
||||
self.prefetched.insert(slot, job.key);
|
||||
self.pending.insert(slot, (job.key, priority));
|
||||
let key = job.key;
|
||||
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
|
||||
}
|
||||
|
||||
/// Is a request for `slot` queued or running?
|
||||
pub fn is_pending(&self, slot: Slot) -> bool {
|
||||
self.pending.contains_key(&slot)
|
||||
}
|
||||
|
||||
/// The texture to show for a grid/filmstrip thumbnail: the rendered one, else its stand-in.
|
||||
pub fn thumb(&self, id: PhotoId) -> Option<&Tex> {
|
||||
self.textures.get(&Slot::Thumb(id)).or_else(|| self.textures.get(&Slot::ThumbQuick(id)))
|
||||
}
|
||||
|
||||
pub fn queued(&self) -> usize {
|
||||
self.pool.queued() + self.queue.len()
|
||||
}
|
||||
|
|
@ -154,7 +208,7 @@ impl Renderer {
|
|||
|
||||
/// Thumbnail textures currently loaded.
|
||||
pub fn thumb_textures(&self) -> usize {
|
||||
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count()
|
||||
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count()
|
||||
}
|
||||
|
||||
/// Collect finished jobs into textures. Returns true if anything changed.
|
||||
|
|
@ -177,7 +231,7 @@ impl Renderer {
|
|||
}
|
||||
self.dispatch();
|
||||
let mut changed = false;
|
||||
for (slot, r, ms) in finished {
|
||||
for (mut slot, r, ms) in finished {
|
||||
session.accept(&r);
|
||||
self.completed += 1;
|
||||
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
|
||||
|
|
@ -186,6 +240,21 @@ impl Renderer {
|
|||
let Ok(rendered) = r.rendered else {
|
||||
continue;
|
||||
};
|
||||
if matches!(slot, Slot::Prefetch(_)) {
|
||||
continue;
|
||||
}
|
||||
if let Slot::ThumbQuick(id) = slot {
|
||||
if self.textures.contains_key(&Slot::Thumb(id)) {
|
||||
continue; // the real thumbnail won the race
|
||||
}
|
||||
if r.quick == Some(QuickSource::Cached) {
|
||||
// the photo's own cached thumbnail: final
|
||||
slot = Slot::Thumb(id);
|
||||
}
|
||||
}
|
||||
if let Slot::Thumb(id) = slot {
|
||||
self.textures.remove(&Slot::ThumbQuick(id));
|
||||
}
|
||||
let img = &rendered.image;
|
||||
let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()));
|
||||
let pixels = self.keep_pixels.then(|| color.clone());
|
||||
|
|
@ -199,12 +268,22 @@ impl Renderer {
|
|||
t.size = [img.width, img.height];
|
||||
t.histogram = Some(rendered.histogram);
|
||||
t.ms = ms;
|
||||
t.quick = r.quick;
|
||||
}
|
||||
None => {
|
||||
let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR);
|
||||
self.textures.insert(
|
||||
slot,
|
||||
Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms, pixels },
|
||||
Tex {
|
||||
key: r.key,
|
||||
photo: r.photo,
|
||||
tex,
|
||||
size: [img.width, img.height],
|
||||
histogram: Some(rendered.histogram),
|
||||
ms,
|
||||
quick: r.quick,
|
||||
pixels,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -228,11 +307,12 @@ impl Renderer {
|
|||
/// thumbnail jobs for photos that scrolled out of `keep`.
|
||||
pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet<PhotoId>, max: usize) {
|
||||
let dropped = self.pool.reprioritize(|s, p| match s {
|
||||
Slot::Thumb(id) if !keep.contains(id) && p <= 10 => None,
|
||||
Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id) && p <= 11 => None,
|
||||
_ => Some(p),
|
||||
});
|
||||
for s in dropped {
|
||||
self.pending.remove(&s);
|
||||
self.quick_tried.remove(&s);
|
||||
}
|
||||
let pending = &mut self.pending;
|
||||
self.queue.retain(|q| match q.slot {
|
||||
|
|
@ -242,10 +322,11 @@ impl Renderer {
|
|||
}
|
||||
_ => true,
|
||||
});
|
||||
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
let thumbs = self.thumb_textures();
|
||||
if thumbs <= max {
|
||||
return;
|
||||
}
|
||||
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) if !keep.contains(id)));
|
||||
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id)));
|
||||
self.quick_tried.retain(|s, _| !matches!(s, Slot::ThumbQuick(id) if !keep.contains(id)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,19 @@ so CPU numbers are pessimistic; the GPU numbers are less affected):
|
|||
| export render 6000×4000 | 1037–1414 ms | 291–330 ms |
|
||||
|
||||
GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel
|
||||
compilation: ~0.4 s once per process (lazily, on the first GPU render).
|
||||
compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch
|
||||
(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create
|
||||
the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking.
|
||||
|
||||
## Opening a photo (M5.4)
|
||||
- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its
|
||||
current settings, else (raws with their import look) the embedded camera JPEG, else a
|
||||
thumbnail-level render; the full render replaces it in place.
|
||||
- Raw sources for previews (2560 px) and thumbnails are binned straight from the mosaic
|
||||
(`RawImage::develop_binned`, 2× for a 24 MP preview); only exports / 1:1 demosaic at full size.
|
||||
- The next and previous photos in filmstrip order are prepared in the background once the current
|
||||
one is rendered (decoded source kept, view render cached): stepping through photos shows the
|
||||
developed image in ~50 ms.
|
||||
|
||||
## Open items
|
||||
- Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of
|
||||
|
|
|
|||
Loading…
Reference in New Issue