Merge pull request #1867 from storytold/claude/integrate-batch-40

Integration batch 40: 3 rebased community PRs (GPU face detection, soft-proof zoom windows, faster folder sync) and restored tracker rows
This commit is contained in:
Brandon Thomas
2026-10-15 04:00:13 -04:00
committed by GitHub
47 changed files with 3414 additions and 137 deletions
Generated
+1
View File
@@ -3304,6 +3304,7 @@ dependencies = [
"lightcraft-color",
"lightcraft-denoise",
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
+3 -2
View File
@@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
2. CR3 for every Canon body (25–45 h)
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
4. Full-resolution zoom (8–15 h)
4. ~~Full-resolution zoom~~ (done 2026-10-12)
5. AI masks: Subject / Sky / People (40–70 h + model decision)
6. Compressed ORF and HE NEF (28–55 h)
7. Lens profile database of our own (40–80 h)
@@ -192,6 +192,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
+47
View File
@@ -0,0 +1,47 @@
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
//! nothing there, and probes no file (the library is made from the folder's own listing).
//!
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
use std::time::Instant;
use lightcraft_catalog::{Op, Photo, Source};
use lightcraft_engine::Session;
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
fn main() {
let Some(folder) = std::env::args().nth(1) else {
eprintln!("usage: sync_scan_bench <folder>");
return;
};
let mut s = Session::new();
let t = Instant::now();
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
for f in &files {
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
eprintln!("could not add {name}");
}
}
for run in 0..2 {
let input = match SyncInput::new(&mut s, &folder, true) {
Ok(input) => input,
Err(e) => {
eprintln!("{e}");
return;
}
};
let t = Instant::now();
let changes = scan_with(input, &SyncProgress::default());
println!(
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
t.elapsed().as_secs_f64(),
changes.new.len(),
changes.missing.len(),
changes.metadata.len()
);
}
}
+147 -11
View File
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use super::{CommandSpec, always, bad, cmd, has_selection};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
@@ -54,10 +54,12 @@ impl NoDetector {
}
}
/// The YuNet loaded last, and from where.
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s
.face_models_dir
.as_deref()
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
#[cfg(not(target_arch = "wasm32"))]
{
let net = d.clone();
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
}
*loaded = Some((path, d.clone()));
Ok(d)
}
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
#[derive(Clone, Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GpuState {
/// The background check is still running.
pub checking: bool,
/// The GPU it runs on; `None` when it runs on the CPU.
pub device: Option<String>,
/// Why it runs on the CPU.
pub why: Option<String>,
}
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
}
fn set_gpu_state(what: &'static str, st: GpuState) {
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
match all.iter_mut().find(|(w, _)| *w == what) {
Some((_, old)) => *old = st,
None => all.push((what, st)),
}
}
/// The user chose the CPU for face models (Settings ▸ Faces).
pub(crate) fn cpu_only(s: &Session) -> bool {
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
}
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
/// The check shows in the activity stack while it runs.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn offer_gpu(
s: &Session,
what: &'static str,
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
) {
if cpu_only(s) {
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
return;
}
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
task.detail(what);
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
let _task = task;
let gpu = match lightcraft_gpu::faces::accelerator() {
Ok(gpu) => gpu,
Err(why) => {
log::info!("faces: {what} runs on the CPU ({why})");
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
};
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
match attach(gpu) {
Ok(()) => {
log::info!("faces: {what} runs on {device}");
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
}
Err(why) => {
log::info!("faces: {what} stays on the CPU: {why}");
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
}
});
if let Err(e) = spawned {
log::warn!("faces: could not check the GPU for {what}: {e}");
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
}
}
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.gpu";
if let Some(cpu) = p.get("cpuOnly") {
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
super::face_models::set_cpu_only(&dir, cpu)?;
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
#[cfg(not(target_arch = "wasm32"))]
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
#[cfg(target_arch = "wasm32")]
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
if cpu {
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
if let Some(net) = net {
net.detach();
}
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(d) = detector {
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
}
if let Some(e) = embedder {
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
}
}
}
}
Ok(gpu_json(s))
}
/// What `faces.gpu` and `faces.models.list` say about the GPU.
pub(crate) fn gpu_json(s: &Session) -> Value {
json!({
"cpuOnly": cpu_only(s),
"detection": gpu_state("detection"),
"recognition": gpu_state("recognition"),
})
}
fn face_json(f: &Face) -> Value {
json!({
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
)]
vec![
cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
),
cmd!(
query "faces.gpu",
"Face Models on the GPU",
[],
None,
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
always,
gpu
),
]
}
#[cfg(test)]
+12
View File
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
pub(super) enabled: bool,
/// The installed recogniser in use.
pub(super) embedder: Option<String>,
/// Keep every face model on the CPU, even when the GPU would be faster.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(super) cpu_only: bool,
}
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
})
}
/// Keep the face models on the CPU (or let them use the GPU again).
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
let mut st = read_settings(dir);
st.cpu_only = on;
write_settings(dir, &st)
}
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
"dir": dir.as_ref().map(|d| d.display().to_string()),
"enabled": settings.enabled,
"embedder": settings.embedder,
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
"gpu": super::face_detect::gpu_json(s),
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
"runtime": cfg!(not(target_arch = "wasm32")),
"models": models,
+5
View File
@@ -321,6 +321,8 @@ mod imp {
return Ok(e.clone());
}
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
let net = embedder.clone();
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
let dim = match installed.manifest.output {
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
@@ -672,6 +674,9 @@ mod imp {
if let Some(w) = &s.faces.worker {
w.allow(target, pace);
}
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
let (runs, share) = crate::faces_worker::gpu_pace(pace);
lightcraft_gpu::faces::set_pace(runs, share);
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
let Some(id) = s.faces.queue.pop() else { break };
let Some(job) = prepare(s, id, true) else { continue };
+21 -7
View File
@@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize {
cap.map_or(n, |c| n.min(c.max(1)))
}
/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be
/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders
/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes.
pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) {
match pace {
Pace::Pause | Pace::Light => (1, 0.25),
Pace::Normal => (2, 0.75),
Pace::Full => (4, 1.0),
}
}
/// One photo's faces to embed (and, if `detect`, to find first).
pub(crate) struct Prepared {
/// `<model id>@<sha256>` the embeddings are for.
@@ -201,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
let img = Rgb { data: &rgb, width: w, height: h };
let (iw, ih) = embedder.input_size();
let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32));
let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo
// every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo)
let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo
.into_iter()
.map(|rect| {
let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0));
@@ -218,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
ih,
),
};
let v = crop.and_then(|c| embedder.embed(&c).ok());
// shown by the detector's box where it found this face: every face then has the same tightness
let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect);
(rect, v, view)
(rect, crop, view)
})
.collect();
// a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass
let new_faces = if detect {
detected.map(|faces| {
detected.map(|found| {
let mut rects = Vec::new();
for f in faces.iter().filter(|f| f.score >= REGION_SCORE) {
for f in found.iter().filter(|f| f.score >= REGION_SCORE) {
let Some(rect) = face_rect(f) else { continue };
let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok());
results.push((rect, v, rect));
faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect));
rects.push(rect);
}
rects
@@ -239,6 +249,10 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done {
} else {
None
};
let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect();
let mut embedded = embedder.embed_batch(&crops).into_iter();
let results: Vec<(Rect, Option<Vec<f32>>, Rect)> =
faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect();
(results, new_faces)
}));
let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None));
+27 -3
View File
@@ -382,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
/// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound).
pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) {
expand_noting(paths, skip, limits, None)
}
/// [`expand_within`], handing the listing of every folder that is walked to `note`
/// ([`crate::walk::files_in_noting`]).
pub(crate) fn expand_noting(
paths: &[String],
skip: Option<&Path>,
limits: crate::walk::Limits,
mut note: Option<crate::walk::NoteFolder>,
) -> (Vec<String>, bool) {
let mut out = Vec::new();
let mut truncated = false;
for p in paths {
@@ -390,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk:
continue;
}
if p.is_dir() {
let w = crate::walk::files_in(p, skip, limits, is_supported);
let w = match note.as_mut() {
Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note),
None => crate::walk::files_in(p, skip, limits, is_supported),
};
if w.truncated {
log::warn!(
"import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)",
@@ -638,6 +652,11 @@ impl ScanInput {
(input, paths.to_vec())
}
/// The folder a scan never enters (the library's own).
pub(crate) fn skip(&self) -> Option<&Path> {
self.skip.as_deref()
}
/// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> {
std::sync::Arc::clone(&self.library)
@@ -656,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> {
/// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it
/// has) when `progress.cancel` is set.
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
let files = expand(paths, input.skip.as_deref());
scan_files(input, files, progress)
}
/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]).
pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let by_path = library_files(&input.library, &files);
let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
progress.total.store(todo.len(), Relaxed);
+34
View File
@@ -2189,6 +2189,40 @@ mod tests {
assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b));
}
// LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of
// the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and
// at a corner, with tone, colour and detail edits on a camera-sized photo
#[test]
fn one_to_one_window_pixels_are_export_pixels() {
use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow};
let mut s = crate::Session::with_demo();
let id = s.active().unwrap();
let p = s.catalog.photo(id).unwrap().clone();
let (w, h) = (p.width as usize, p.height as usize);
assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}");
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = 0.7;
d.light.contrast = 25.0;
d.color.saturation = 20.0;
s.set_develop(id, d, "Edits").unwrap();
let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image;
assert_eq!((export.width, export.height), (w, h), "a full-size export");
for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] {
let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image;
assert_eq!((tile.width, tile.height), (win.w, win.h));
let mut worst = 0u8;
for y in 0..win.h {
for x in 0..win.w {
let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]);
for k in 0..3 {
worst = worst.max(a[k].abs_diff(b[k]));
}
}
}
assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels");
}
}
/// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here
/// one nothing can decode) is not read again.
#[test]
+8 -2
View File
@@ -66,6 +66,13 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
sidecar_candidates(original, naming).find(|p| p.is_file())
}
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
/// then under the other naming (the full name also with the photo extension in the other case),
/// each as `.xmp`, `.XMP` and `.Xmp`.
pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> {
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let base = Path::new(original).with_extension("");
@@ -73,7 +80,7 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
[naming, other]
.into_iter()
.flat_map(|n| {
.flat_map(move |n| {
let mut v = vec![sidecar_path(original, n)];
if n == SidecarNaming::Full {
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
@@ -81,7 +88,6 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
v
})
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
.find(|p| p.is_file())
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
+167 -12
View File
@@ -38,7 +38,7 @@
//! not cancelled midway.
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
@@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let namings = crate::sidecar::namings_in(&labels, naming, &ids);
let photos: Vec<(Arc<Photo>, SidecarNaming)> =
ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
// The folder is walked once, and its listing answers for the library's photos in it: on a
// network share that is one request for a folder's names where it was several for each
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
// (how many photos there are to check is known before the walk: the count shows while it runs)
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
let mut listing = Listing::default();
let (files, _) = crate::import::expand_noting(
std::slice::from_ref(&folder),
scan.skip(),
crate::walk::Limits::default(),
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
);
let listing = &listing;
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, progress));
#[cfg(not(target_arch = "wasm32"))]
let (out, checks) = std::thread::scope(|sc| {
let photos = &photos;
let labels = &labels;
let checks = sc.spawn(move || check_photos(photos, labels, progress));
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
let checks = sc.spawn(move || check_photos(photos, labels, listing, progress));
let out = crate::import::scan_files(scan, files, &progress.files);
// (a check that panicked counts as nothing found; the scan still reports the rest)
(out, checks.join().unwrap_or_default())
});
log::debug!(
"synchronize: {} folders listed; the disk was asked {} times about the library's photos",
listing.folders.len(),
listing.asked.load(std::sync::atomic::Ordering::Relaxed)
);
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
for ((p, _), check) in photos.iter().zip(checks) {
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
@@ -335,11 +354,136 @@ enum Check {
News,
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file
/// there" for the library's photos and their sidecars without asking the disk, which on a
/// network share is a round trip for every name (and a photo without a sidecar took five).
///
/// It answers only what holds on any file system. A name is there when the folder lists exactly
/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end,
/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and
/// no name in the folder is that name in another case. Anything else is not answered, and the
/// disk is asked as before: a name that differs in case only, a link, a folder that was not
/// walked or not to its end, and every name that is not listed in a folder that holds a name
/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed
/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass
/// over marks altogether.)
///
/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it:
/// a file it lists is there though it could not be opened by its path (a folder that may be
/// listed but not searched, a path longer than the system takes), and a share that drops after
/// the walk does not turn every photo the listing answers for into a missing one.
#[derive(Default)]
pub(crate) struct Listing {
folders: HashMap<PathBuf, Listed>,
/// How often the disk was asked after all (for the tests, and the log).
pub(crate) asked: std::sync::atomic::AtomicUsize,
}
/// One folder's listing.
struct Listed {
/// name → what it is.
names: HashMap<String, crate::walk::EntryKind>,
/// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain
/// name asked about could be in another case.
lower: std::collections::HashSet<String>,
/// Every entry was read, and every name is ASCII: a plain name that is not listed, in any
/// case, is not there.
sure: bool,
}
/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for
/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed).
fn asks_the_disk(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase();
// (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them)
let numbered = ["com", "lpt"].iter().any(|port| {
let mut rest = stem.strip_prefix(port).unwrap_or_default().chars();
matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d))
});
numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str())
}
/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a
/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces
/// at its end, which Windows drops).
fn plain_name(name: &str) -> bool {
name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty()
}
impl Listing {
/// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]).
pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) {
let mut sure = complete;
let mut names = HashMap::with_capacity(entries.len());
let mut lower = std::collections::HashSet::new();
for (name, kind) in entries {
match name.into_string() {
Ok(name) => {
sure &= name.is_ascii();
lower.insert(name.to_ascii_lowercase());
names.insert(name, kind);
}
// (a name that is no text is not one a photo's path can be compared with)
Err(_) => sure = false,
}
}
if !sure {
lower.clear();
}
self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure });
}
/// What the listing of `path`'s folder says of it: its kind when it is listed under exactly
/// this name, `Some(None)` when it is surely not there, `None` when the listing can't say.
fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> {
let name = path.file_name()?.to_str()?;
// (the path ends in that name: no `/.` or separator after it, which the disk would resolve)
if !path.to_str()?.ends_with(name) {
return None;
}
if asks_the_disk(name) {
return None;
}
let folder = self.folders.get(path.parent()?)?;
if let Some(kind) = folder.names.get(name) {
return Some(Some(*kind));
}
(folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None)
}
/// `Path::exists`, where the listing can say.
pub(crate) fn exists(&self, path: &Path) -> Option<bool> {
match self.kind(path)? {
Some(crate::walk::EntryKind::Other) => None,
Some(_) => Some(true),
None => Some(false),
}
}
/// `Path::is_file`, where the listing can say.
pub(crate) fn is_file(&self, path: &Path) -> Option<bool> {
match self.kind(path)? {
Some(crate::walk::EntryKind::Other) => None,
Some(kind) => Some(kind == crate::walk::EntryKind::File),
None => Some(false),
}
}
/// The listing's answer, or the disk's where it has none.
fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool {
answer.unwrap_or_else(|| {
self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
ask()
})
}
}
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check {
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
if !Path::new(file).exists() {
let file = Path::new(file);
if !listing.or_ask(listing.exists(file), || file.exists()) {
Check::Missing
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) {
Check::News
} else {
Check::Nothing
@@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> {
use std::sync::atomic::Ordering::Relaxed;
progress.to_check.store(photos.len(), Relaxed);
let cancel = &progress.files.cancel;
let check = |p: &Photo, naming: SidecarNaming| {
let c = check_photo(p, naming, labels);
let c = check_photo(p, naming, labels, listing);
progress.checked.fetch_add(1, Relaxed);
c
};
@@ -421,10 +565,17 @@ impl Session {
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
/// (imported, or edited here) and says something the library doesn't.
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> bool {
let Source::File { path } = &p.source else { return false };
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
let Ok(meta) = std::fs::metadata(&file) else { return false };
// ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk)
// (a name the listing holds is read for its date next, and one that can't be read is passed
// over for the next name, as a name that is no file was)
let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| {
let there = listing.or_ask(listing.is_file(&c), || c.is_file());
let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?;
Some((c, meta))
});
let Some((file, meta)) = found else { return false };
if meta.len() > MAX_SIDECAR_BYTES {
return false;
}
@@ -708,3 +859,7 @@ impl SyncCommit {
self.report
}
}
#[cfg(test)]
#[path = "sync/tests_listing.rs"]
mod tests_listing;
+192
View File
@@ -0,0 +1,192 @@
//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the
//! listing of the folder its scan has just made, instead of asking the disk about every photo.
//!
//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB,
//! measured), and a photo without a sidecar took five of them; the listing has every name.
//! The listing answers only what it can be sure of on any file system: where a name could match
//! another way (case, letters outside ASCII, a link), the disk is asked as before.
use super::*;
use crate::walk::EntryKind::{File, Folder, Other};
fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing {
let mut l = Listing::default();
for (dir, names, complete) in folders {
l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete);
}
l
}
/// Given the listing of a folder, when it is asked about a name, then it says "there" for a
/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing
/// (ask the disk) wherever a file system could see it otherwise.
#[test]
fn the_listing_answers_only_what_it_is_sure_of() {
let l = listing(&[
(
"/pics/trip",
&[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)],
true,
),
("/pics/cut", &[("IMG_1.CR3", File)], false),
("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true),
// (the capital sharp s, which a file system that folds case takes for `SS`)
("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true),
("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true),
]);
let (there, gone, ask) = (Some(true), Some(false), None);
for (path, exists, is_file) in [
("/pics/trip/IMG_1.CR3", there, there),
("/pics/trip/IMG_1.xmp", there, there),
("/pics/trip/.hidden.xmp", there, there),
("/pics/trip/sub", there, gone),
("/pics/trip/IMG_9.CR3", gone, gone),
("/pics/trip/IMG_1.XMP", ask, ask),
("/pics/trip/IMG_2.JPG", ask, ask),
("/pics/trip/link.jpg", ask, ask),
("/pics/trip/SUB", ask, ask),
// a folder the walk did not list to its end, or not at all
("/pics/cut/IMG_1.CR3", there, there),
("/pics/cut/IMG_2.CR3", ask, ask),
("/pics/other/IMG_1.CR3", ask, ask),
// a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file
// systems hold such names equal in more ways than one)
("/pics/names/IMG_1.CR3", there, there),
("/pics/names/Größe.JPG", there, there),
("/pics/names/IMG_2.CR3", ask, ask),
("/pics/names/größe.JPG", ask, ask),
("/pics/trip/Größe.JPG", ask, ask),
("/pics/sharp/strasse.jpg", ask, ask),
("/pics/sharp/STRASSE.xmp", ask, ask),
("/pics/sharp/IMG_2.CR3", ask, ask),
// names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device
("/pics/trip/IMG_10~1.CR3", ask, ask),
("/pics/trip/IMG_1.CR3::$DATA", ask, ask),
("/pics/trip/IMG_1.CR3.", ask, ask),
("/pics/trip/IMG_1.CR3 ", ask, ask),
("/pics/devices/aux.jpg", ask, ask),
("/pics/devices/NUL", ask, ask),
("/pics/devices/nul.xmp", ask, ask),
("/pics/devices/COM1 .jpg", ask, ask),
("/pics/devices/COM\u{b9}.jpg", ask, ask),
("/pics/trip/con", ask, ask),
("/pics/trip/PRN.txt", ask, ask),
("/pics/trip/com2.jpg", ask, ask),
("/pics/trip/LPT9.xmp", ask, ask),
("/pics/trip/lpt0", ask, ask),
("/pics/trip/nul.tar.gz", ask, ask),
("/pics/trip/CONIN$", ask, ask),
("/pics/trip/com10.jpg", gone, gone),
("/pics/trip/computer.jpg", gone, gone),
("/pics/devices/IMG_1.CR3", there, there),
("/pics/devices/auxiliary.jpg", ask, ask),
("/pics/trip/auxiliary.jpg", gone, gone),
// a path that does not end in its file's name is the disk's to resolve
("/pics/trip/IMG_1.CR3/", ask, ask),
("/pics/trip/IMG_1.CR3/.", ask, ask),
("/pics/trip/", ask, ask),
("", ask, ask),
] {
assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}");
}
// a name that is no text: nothing in its folder is surely not there
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let mut l = Listing::default();
l.note(
Path::new("/pics/raw"),
vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)],
true,
);
assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask));
}
}
fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> {
let id = c.alloc_photo_id();
let name = path.file_name().unwrap().to_string_lossy().to_string();
c.apply(lightcraft_catalog::Op::AddPhoto {
photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")),
})
.unwrap();
Arc::clone(c.photo(id).unwrap())
}
/// Given a folder on disk whose photos are there or gone, with sidecars under every name a
/// sidecar can have, when each photo is checked with the folder's listing, then the answer is
/// the one the disk gives; and for photos with plain names the disk was asked nothing but the
/// sidecars' dates.
#[test]
fn a_check_from_the_listing_is_the_check_from_the_disk() {
let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#;
for name in [
"plain.jpg",
"stem.jpg",
"stem.xmp",
"full.jpg",
"full.jpg.xmp",
"upper.jpg",
"upper.XMP",
"sub/deep.jpg",
"sub/deep.xmp",
"Größe.jpg",
"Größe.xmp",
"dir.jpg.xmp",
] {
std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap();
}
let mut c = Catalog::new();
let photos: Vec<Arc<Photo>> =
["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"]
.iter()
.map(|n| photo(&mut c, &dir.join(n)))
.collect();
// (`dir.jpg` is gone, though a sidecar of its name is there)
let mut l = Listing::default();
let walked =
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
assert_eq!(walked.files.len(), 12);
let nothing = Listing::default();
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
for p in &photos {
let from_disk = check_photo(p, naming, &c, &nothing);
assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name);
}
}
let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing];
assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected);
// what the disk was asked about the same photos, with the listing and without: fewer questions with it
let asked = |l: &Listing| {
for p in &photos {
check_photo(p, SidecarNaming::Stem, &c, l);
}
l.asked.load(std::sync::atomic::Ordering::Relaxed)
};
let mut with = Listing::default();
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
let (with, without) = (asked(&with), asked(&Listing::default()));
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
// names are found in any case, 21 where they are not)
assert!(without >= 20, "{without}");
// (with: this folder holds `Größe.*`, so what it does not list is still asked about: the nine names of the
// sidecar `plain.jpg` has not (`.xmp`, `.XMP` and `.Xmp` of `plain`, `plain.jpg` and `plain.JPG`), the three
// `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What it lists is not asked about, nor
// anything in `sub`, whose names are all ASCII)
assert_eq!(with, 15, "asked {with} times with the listing, {without} without");
// a folder of plain names only: the disk is asked nothing at all
let sub = dir.join("sub");
let mut only_plain = Listing::default();
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
let mut c2 = Catalog::new();
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
let p = photo(&mut c2, &sub.join(name));
assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}");
}
assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing");
let _ = std::fs::remove_dir_all(&dir);
}
+14
View File
@@ -627,3 +627,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() {
assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() {
let d = temp("cpu-only");
let mut s = session(&d);
assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false);
assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true);
// kept with the models, so a new session starts on the CPU too
let mut again = session(&d);
assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true);
assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err());
assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false);
let _ = std::fs::remove_dir_all(d);
}
+93 -4
View File
@@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool {
p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
/// What a folder's listing says an entry is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntryKind {
File,
Folder,
/// A link, or an entry whose kind the listing did not say.
Other,
}
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
/// stopped inside the folder, or an entry could not be read).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
/// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped.
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked {
pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked {
walk(root, skip, limits, keep, None)
}
/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to
/// find its files has, with that, the answer to "is this name there" for everything in it, and
/// need not ask the disk again (a round trip for each name on a network share).
pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked {
walk(root, skip, limits, keep, Some(note))
}
fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked {
let mut out = Walked::default();
// canonical folders reached through a link, so a link loop is walked at most once
let mut linked: HashSet<PathBuf> = HashSet::new();
@@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
}
let Ok(rd) = std::fs::read_dir(&path) else { continue };
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
// (the folder's listing, for who asked for it; all of it unless something below says otherwise)
let mut listed = note.is_some().then(Vec::new);
let mut complete = true;
for e in rd {
let Ok(e) = e else {
complete = false;
continue;
};
if out.entries >= limits.max_entries {
out.truncated = true;
complete = false;
break;
}
out.entries = out.entries.saturating_add(1);
let p = e.path();
if hidden(&p) {
// (a hidden entry's kind is only asked for when the listing is wanted; every other
// entry's is asked once)
let is_hidden = hidden(&p);
if is_hidden && listed.is_none() {
continue;
}
let Ok(ft) = e.file_type() else { continue };
let ft = e.file_type();
if let Some(listed) = listed.as_mut() {
let kind = match &ft {
Ok(t) if t.is_symlink() => EntryKind::Other,
Ok(t) if t.is_dir() => EntryKind::Folder,
Ok(t) if t.is_file() => EntryKind::File,
_ => EntryKind::Other,
};
listed.push((e.file_name(), kind));
}
if is_hidden {
continue;
}
let Ok(ft) = ft else { continue };
let dir = if ft.is_symlink() {
// a link (or a Windows junction): a folder is entered once, a file is a file
match std::fs::metadata(&p) {
@@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
};
children.push((p, dir));
}
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
note(&path, listed, complete);
}
children.sort();
let below = depth.saturating_add(1);
stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d)));
@@ -123,6 +175,43 @@ mod tests {
d
}
/// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked
/// with its listings noted, then the walk finds what it finds without them, and each folder's
/// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor
/// folder) and says it is all of them; a walk that stops inside a folder says it is not.
#[test]
fn a_walk_notes_each_folders_listing() {
let root = tmp("noting");
std::fs::create_dir_all(root.join("sub")).unwrap();
for name in ["a.jpg", ".hidden", "sub/b.jpg"] {
std::fs::write(root.join(name), b"x").unwrap();
}
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap();
let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new();
let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| {
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
noted.push((dir.to_path_buf(), entries, complete));
});
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
let named = |n: &str, k: EntryKind| (n.to_string(), k);
let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)];
if cfg!(unix) {
top.insert(2, named("link.jpg", EntryKind::Other));
}
assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]);
// stopped inside the first folder: its listing is not all of it
let mut cut = Vec::new();
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
cut.push((dir.to_path_buf(), entries.len(), complete))
});
assert!(walked.truncated);
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() {
let d = tmp("order");
+2 -1
View File
@@ -5,7 +5,8 @@
//!
//! Everything here treats model files and manifests as hostile input: sizes are capped, numbers must be
//! finite and sane, and a malformed file is an error, never a panic. Detection and recognition run
//! on the same checked, single-thread pure-Rust CPU interpreter.
//! on the same checked, single-thread pure-Rust CPU interpreter; an [`net::Accelerator`] (the GPU, in `lightcraft-gpu`) can
//! take its convolutions once it agrees with it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+451 -3
View File
@@ -9,8 +9,10 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, PoisonError, RwLock};
mod kernels;
pub mod program;
mod shape;
use crate::graph::{Data, Graph, Node};
@@ -64,9 +66,64 @@ impl Tensor {
}
}
/// One convolution, as handed to an [`Accelerator`]: batch 1, planar (NCHW) float32, already checked.
///
/// `out[co][oy][ox] = bias[co] + Σ weights[co][ci][ky][kx] · input[g·cg + ci][oy·sh + ky − pt][ox·sw + kx − pl]`
/// with `g = co / (cout / groups)` and zero outside the input.
#[derive(Clone, Copy, Debug)]
pub struct ConvJob<'a> {
/// The node's index in the graph: the same for every run of a net, so its weights can stay on the device.
pub layer: usize,
pub cin: usize,
pub h: usize,
pub w: usize,
pub cout: usize,
pub groups: usize,
pub kh: usize,
pub kw: usize,
pub sh: usize,
pub sw: usize,
pub pt: usize,
pub pl: usize,
pub oh: usize,
pub ow: usize,
/// `[cout][cin / groups][kh][kw]`.
pub weights: &'a [f32],
/// `[cout]`, if the node has one.
pub bias: Option<&'a [f32]>,
/// `[cin][h][w]`.
pub input: &'a [f32],
}
impl ConvJob<'_> {
/// Multiply-adds the convolution takes.
pub fn macs(&self) -> usize {
let per_group = (self.cin / self.groups.max(1)).saturating_mul(self.kh).saturating_mul(self.kw);
self.cout.saturating_mul(per_group).saturating_mul(self.oh).saturating_mul(self.ow)
}
}
/// Something that can run a network's convolutions faster than the CPU kernels (the GPU, in `lightcraft-gpu`).
///
/// The CPU interpreter stays the reference: a net only uses an accelerator that agreed with it on a test input
/// ([`Net::attach_checked`]), and the first error makes the net drop it and run on the CPU again.
pub trait Accelerator: Send + Sync {
/// What it runs on, for the user ("AMD Radeon RX 7900 (Vulkan)").
fn device(&self) -> String;
/// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say).
fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>;
/// The whole network on the device, keeping every value there between steps, for each of `inputs` (all of
/// `program.lens[INPUT]` values) in one go: per input, each of `program.outputs` in order. `Ok(None)` leaves the
/// run to the CPU path (which may still use [`Accelerator::conv`]).
fn run_program(&self, _program: &program::Program, _inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
Ok(None)
}
}
/// A loaded network, ready to run.
pub struct Net {
graph: Graph,
accelerator: RwLock<Option<Arc<dyn Accelerator>>>,
}
/// Validate semantics rather than silently ignoring unknown or mistyped attributes.
@@ -80,7 +137,75 @@ impl Net {
for n in &graph.nodes {
check(n, &graph)?;
}
Ok(Net { graph })
Ok(Net { graph, accelerator: RwLock::new(None) })
}
/// The accelerator in use, if any.
pub fn accelerator(&self) -> Option<Arc<dyn Accelerator>> {
self.accelerator.read().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Use `accelerator` for this net's convolutions, but only after it gave the CPU's outputs on `probe` (every
/// output value within `tolerance` of the CPU's, relative to that output's largest magnitude) and, with
/// `require_faster`, ran it faster than the CPU did (a GPU's per-layer round trip can cost more than a small
/// model's layers). Returns why it was not used, so the caller can say so; the net then stays on the CPU.
pub fn attach_checked(
&self,
accelerator: Arc<dyn Accelerator>,
probe: &Tensor,
tolerance: f32,
require_faster: bool,
) -> std::result::Result<(), String> {
self.detach();
let started = web_time::Instant::now();
let cpu = self.run(probe.clone()).map_err(|e| e.to_string())?;
let cpu_time = started.elapsed();
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = Some(accelerator);
// the first run also uploads the weights and builds the kernels: time a second one
let fast = self.run(probe.clone()).map_err(|e| e.to_string());
let mut verdict = match fast {
Err(e) => Err(e),
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
Ok(fast) => agree(&cpu, &fast, tolerance),
};
if verdict.is_ok() && require_faster {
let started = web_time::Instant::now();
let again = self.run(probe.clone()).map_err(|e| e.to_string());
let fast_time = started.elapsed();
verdict = match again {
Err(e) => Err(e),
Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()),
Ok(_) if fast_time >= cpu_time => Err(format!(
"the device is not faster for this model ({:.0} ms against {:.0} ms on the CPU)",
fast_time.as_secs_f64() * 1000.0,
cpu_time.as_secs_f64() * 1000.0
)),
Ok(_) => Ok(()),
};
}
if verdict.is_err() {
self.detach();
}
verdict
}
/// Run on the CPU only from now on.
pub fn detach(&self) {
*self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = None;
}
/// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped.
fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> {
let Some(accelerator) = self.accelerator() else { return Ok(None) };
let job = kernels::conv_job(layer, node, &x.shape, &x.data, &self.graph)?;
match accelerator.conv(&job) {
Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)),
Ok(None) => Ok(None),
Ok(Some(_)) | Err(_) => {
self.detach();
Ok(None)
}
}
}
/// The shape the model declares for its input (0 for a dynamic size).
@@ -96,7 +221,67 @@ impl Net {
/// Run the network on `input` (its single input); returns each output by name.
pub fn run(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
shape::tensor(&input.shape, input.data.len(), &input.data)?;
self.run_batch(vec![input])?.pop().ok_or_else(|| NetError::Shape("the run gave nothing".into()))
}
/// Run the network on each of `inputs`; an accelerator that keeps the values on its device runs inputs of one shape
/// together (one upload and one read-back for all of them).
pub fn run_batch(&self, inputs: Vec<Tensor>) -> Result<Vec<Vec<(String, Tensor)>>> {
for input in &inputs {
shape::tensor(&input.shape, input.data.len(), &input.data)?;
}
if let Some(out) = self.run_on_device(&inputs)? {
return Ok(out);
}
inputs.into_iter().map(|input| self.run_one(input)).collect()
}
/// The whole batch on the attached accelerator, or `None` to run it on the CPU path. A failing or misbehaving
/// accelerator is dropped.
fn run_on_device(&self, inputs: &[Tensor]) -> Result<Option<Vec<Vec<(String, Tensor)>>>> {
let (Some(accelerator), Some(first)) = (self.accelerator(), inputs.first()) else { return Ok(None) };
if inputs.iter().any(|t| t.shape != first.shape) {
return Ok(None);
}
let Some(program) = self.program(&first.shape)? else { return Ok(None) };
let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect();
let started = web_time::Instant::now();
let outs = match accelerator.run_program(&program, &slices) {
Ok(Some(outs)) => outs,
Ok(None) => return Ok(None),
Err(_) => {
self.detach();
return Ok(None);
}
};
let gathered: Option<Vec<Vec<(String, Tensor)>>> = (outs.len() == inputs.len())
.then(|| {
outs.into_iter()
.map(|values| {
(values.len() == program.outputs.len())
.then(|| {
values
.into_iter()
.zip(&program.outputs)
.map(|(data, (name, _, shape))| Tensor::new(shape.clone(), data).ok().map(|t| (name.clone(), t)))
.collect::<Option<Vec<_>>>()
})
.flatten()
})
.collect()
})
.flatten();
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!("[faces-gpu] {} steps x {}: {:.3} ms", program.steps.len(), inputs.len(), started.elapsed().as_secs_f64() * 1000.0);
}
if gathered.is_none() {
// wrong sizes or values that are not finite: the device is not trusted any more
self.detach();
}
Ok(gathered)
}
fn run_one(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> {
self.validate_input(&input.shape)?;
let mut scratch = kernels::Scratch::default();
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
@@ -127,7 +312,13 @@ impl Net {
};
let started = web_time::Instant::now();
let out = match node.op.as_str() {
"Conv" => kernels::conv(node, &*get(0)?, g, &mut scratch)?,
"Conv" => {
let x = get(0)?;
match self.accelerated_conv(i, node, &x)? {
Some(out) => out,
None => kernels::conv(node, &x, g, &mut scratch)?,
}
}
"Relu" => map(&*get(0)?, |v| v.max(0.0)),
"Sigmoid" => map(&*get(0)?, |v| 1.0 / (1.0 + (-v).exp())),
"Add" | "Sub" | "Mul" => kernels::binary(node.op.as_str(), &*get(0)?, &*get(1)?)?,
@@ -168,6 +359,25 @@ impl Net {
}
}
/// Whether two runs' outputs agree: same names and shapes, values within `tolerance` of each other relative to the
/// output's largest magnitude.
fn agree(a: &[(String, Tensor)], b: &[(String, Tensor)], tolerance: f32) -> std::result::Result<(), String> {
if a.len() != b.len() {
return Err("the device gave a different number of outputs".into());
}
for ((name, x), (other, y)) in a.iter().zip(b) {
if name != other || x.shape != y.shape {
return Err(format!("the device gave `{other}` a different shape"));
}
let scale = x.data.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6);
let worst = x.data.iter().zip(&y.data).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
if worst.is_nan() || worst > tolerance * scale {
return Err(format!("the device's `{name}` differs from the CPU's by {:.2e} (of {scale:.2e})", worst));
}
}
Ok(())
}
fn map(x: &Tensor, f: impl Fn(f32) -> f32) -> Tensor {
Tensor { shape: x.shape.clone(), data: x.data.iter().map(|v| f(*v)).collect() }
}
@@ -534,6 +744,244 @@ mod tests {
assert_eq!(rs.shape, vec![1, 6, 2]);
}
/// An accelerator built on the slow reference convolution, optionally wrong or failing.
struct Fake {
skew: f32,
fail_after: std::sync::atomic::AtomicUsize,
}
impl Accelerator for Fake {
fn device(&self) -> String {
"test".into()
}
fn conv(&self, j: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 {
return Err("device lost".into());
}
assert!(j.pt == j.pl && j.sh == j.sw);
let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap();
let zero = vec![0.0; j.cout];
let dims = [j.cout, j.cin / j.groups, j.kh, j.kw];
let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups);
Ok(Some(out.data.iter().map(|v| v + self.skew).collect()))
}
}
fn two_convs() -> (Graph, Tensor) {
let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))];
let mut a = node("Conv", &["x", "w1", "b1"], &attrs);
a.outputs = vec!["h".into()];
let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]);
let mut g = graph(
vec![a, b],
vec![
("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())),
("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])),
("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())),
],
);
g.inputs = vec![("x".into(), vec![1, 3, 9, 8])];
(g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1))
}
#[test]
fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() {
let (g, x) = two_convs();
let net = Net::new(g).unwrap();
let cpu = net.run(x.clone()).unwrap();
let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() });
net.attach_checked(good, &x, 1e-4, false).unwrap();
assert!(net.accelerator().is_some());
assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape);
let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() });
assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err());
assert!(net.accelerator().is_none(), "a device that disagrees is not used");
assert_eq!(net.run(x).unwrap(), cpu);
}
#[test]
fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() {
let (g, x) = two_convs();
let net = Net::new(g).unwrap();
let cpu = net.run(x.clone()).unwrap();
// agrees on the probe (two convs), then fails on the second conv of the next run
net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap();
let out = net.run(x).unwrap();
assert!(net.accelerator().is_none());
for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) {
assert!((a - b).abs() < 1e-4);
}
}
/// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls;
/// `poison` makes it give a value that is not finite.
struct ProgramFake {
calls: std::sync::atomic::AtomicUsize,
poison: bool,
}
fn run_steps(p: &program::Program, input: &[f32]) -> Vec<Vec<f32>> {
use program::{Arg, Binary, Op, Unary};
let mut values: Vec<Vec<f32>> = vec![Vec::new(); p.lens.len()];
values[program::INPUT] = input.to_vec();
for step in &p.steps {
let arg = |i: usize| -> Vec<f32> {
match &step.args[i] {
Arg::Value(v) => values[*v].clone(),
Arg::Const { data, .. } => data.to_vec(),
}
};
let len = p.lens[step.out];
let x = arg(0);
let out: Vec<f32> = match &step.op {
Op::Conv(j) => {
let (m, cg) = (j.cout / j.groups, j.cin / j.groups);
let mut out = vec![0f32; len];
for co in 0..j.cout {
for oy in 0..j.oh {
for ox in 0..j.ow {
let mut v = j.bias.map_or(0.0, |b| b[co]);
for ci in 0..cg {
for ky in 0..j.kh {
for kx in 0..j.kw {
let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize);
if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w {
v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx]
* x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize];
}
}
}
}
out[(co * j.oh + oy) * j.ow + ox] = v;
}
}
}
out
}
Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(),
Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(),
Op::ChannelAffine { channels, plane } => {
let (s, o) = (arg(1), arg(2));
x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect()
}
Op::ChannelPrelu { channels, plane } => {
let a = arg(1);
x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect()
}
Op::Binary { op, dims, a, b } => {
let y = arg(1);
(0..len)
.map(|i| {
let (mut rest, mut ia, mut ib) = (i, 0, 0);
for d in (0..4).rev() {
let at = rest % dims[d];
rest /= dims[d];
ia += at * a[d];
ib += at * b[d];
}
match op {
Binary::Add => x[ia] + y[ib],
Binary::Sub => x[ia] - y[ib],
Binary::Mul => x[ia] * y[ib],
}
})
.collect()
}
Op::Transpose { dims, strides } => (0..len)
.map(|i| {
let (mut rest, mut from) = (i, 0);
for d in (0..4).rev() {
from += (rest % dims[d]) * strides[d];
rest /= dims[d];
}
x[from]
})
.collect(),
Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len)
.map(|i| {
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
let mut m = f32::NEG_INFINITY;
for ky in 0..*kh {
for kx in 0..*kw {
m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]);
}
}
m
})
.collect(),
Op::Resize { h, w, sh, sw, .. } => (0..len)
.map(|i| {
let (ow, oh) = (w * sw, h * sh);
let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh));
x[(c * h + oy / sh) * w + ox / sw]
})
.collect(),
Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::<f32>() / *plane as f32).collect(),
Op::Gemv { k, n, trans_b, alpha } => {
let (wt, bias) = (arg(1), arg(2));
(0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::<f32>() + bias[o]).collect()
}
};
assert_eq!(out.len(), len, "{:?}", step.op);
values[step.out] = out;
for f in &step.free {
values[*f] = Vec::new();
}
}
p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect()
}
impl Accelerator for ProgramFake {
fn device(&self) -> String {
"program test".into()
}
fn conv(&self, _: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> {
Ok(None)
}
fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut out: Vec<Vec<Vec<f32>>> = inputs.iter().map(|x| run_steps(p, x)).collect();
if self.poison {
out[0][0][0] = f32::NAN;
}
Ok(Some(out))
}
}
fn every_operator_input(seed: usize) -> Tensor {
input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05)
}
#[test]
fn a_program_of_every_operator_gives_what_the_interpreter_gives() {
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
let x = every_operator_input(0);
let cpu = net.run(x.clone()).unwrap();
let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false });
net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap();
let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed);
assert!(calls > 0, "the whole network ran as one program");
// several inputs go to the device together, and each gets its own outputs
let inputs: Vec<Tensor> = (0..3).map(every_operator_input).collect();
let together = net.run_batch(inputs.clone()).unwrap();
assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch");
net.detach();
for (x, got) in inputs.into_iter().zip(&together) {
let want = net.run(x).unwrap();
agree(&want, got, 1e-5).unwrap();
}
assert_eq!(cpu.len(), 2);
}
#[test]
fn a_device_that_gives_values_that_are_not_finite_is_dropped() {
let net = Net::new(crate::synthetic::every_operator_graph()).unwrap();
let x = every_operator_input(1);
let cpu = net.run(x.clone()).unwrap();
assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err());
assert!(net.accelerator().is_none());
assert_eq!(net.run(x).unwrap(), cpu);
}
#[test]
fn unsupported_and_inconsistent_models_are_errors() {
let x = input(1, 3, 3, |i| i as f32);
+28 -1
View File
@@ -59,6 +59,33 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p
}
Ok(())
}
/// The checked geometry of a convolution node, for an accelerator.
pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result<ConvJob<'a>> {
let (cin, h, w) = program::chw_of(shape)?;
let wt = shape::weight(n, g, 1)?;
let weights = shape::floats(n, g, 1)?;
let [cout, cg, kh, kw] = wt.dims.as_slice() else {
return shape_err("invalid convolution weights");
};
let (cout, cg, kh, kw) = (*cout, *cg, *kh, *kw);
let groups = usize::try_from(n.int("group").unwrap_or(1)).map_err(|_| NetError::Shape("invalid group".into()))?;
let (sh, sw) = pair(n, "strides", 1)?;
let (pt, pl, pb, pr) = pads(n)?;
let ph = h.checked_add(pt).and_then(|v| v.checked_add(pb)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
let pw = w.checked_add(pl).and_then(|v| v.checked_add(pr)).ok_or_else(|| NetError::Shape("padding overflow".into()))?;
if ph < kh || pw < kw || groups == 0 || cg == 0 || kh == 0 || kw == 0 || cin != groups * cg || cout % groups != 0 {
return shape_err("invalid convolution shape");
}
if cout.checked_mul(cg).and_then(|v| v.checked_mul(kh * kw)) != Some(weights.len()) {
return shape_err("short convolution weights");
}
let bias = if n.inputs.get(2).is_some_and(|s| !s.is_empty()) { Some(shape::floats(n, g, 2)?) } else { None };
if bias.is_some_and(|b| b.len() != cout) {
return shape_err("the convolution bias does not match");
}
let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1);
Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input })
}
pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> {
let (cin, h, w) = chw(x)?;
let wt = shape::weight(n, g, 1)?;
@@ -181,7 +208,7 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re
Ok(out)
}
/// Flat offset under ONNX's trailing-dimension broadcasting; shapes are checked before execution.
fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
pub(super) fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize {
let mut offset = 0;
let mut stride = 1;
for (axis, &size) in out.iter().enumerate().rev() {
+315
View File
@@ -0,0 +1,315 @@
//! A network lowered to a flat list of steps over numbered values, for an [`Accelerator`] that keeps every activation
//! on its device between layers (one upload, one read-back per run instead of one per convolution).
//!
//! Shapes are resolved for one input shape; every step names the values it reads and the one it writes, and which
//! values nothing reads after it. Shape-only operators (`Reshape`, `Flatten`, `Identity`, `Dropout`) make no step: their
//! output is the same value under another name. Anything this cannot express (a broadcast past four dimensions, a
//! transposed or batched `Gemm`, a slope that is not per channel) makes the whole lowering `None`, and the net runs
//! the CPU path instead.
use std::borrow::Cow;
use super::*;
/// Value 0 is the network's input.
pub const INPUT: usize = 0;
/// An operand: a value computed on the device, or a constant (uploaded once and kept, by `id`).
#[derive(Clone, Debug)]
pub enum Arg<'a> {
Value(usize),
/// `id` is the same for every run of the same net, so the device can keep the data.
Const {
id: usize,
data: Cow<'a, [f32]>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unary {
Relu,
Sigmoid,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Binary {
Add,
Sub,
Mul,
}
/// What a step computes. Element counts come from the program's `lens` of the step's output.
#[derive(Clone, Debug)]
pub enum Op<'a> {
/// A convolution; its `input` is empty (it reads the step's first argument).
Conv(ConvJob<'a>),
/// `out[i] = f(x[i])`. Args: `[x]`.
Unary(Unary),
/// `out[i] = x[i] * scale[c] + offset[c]`, `c = (i / plane) % channels`. Args: `[x, scale, offset]`.
ChannelAffine { channels: usize, plane: usize },
/// `out[i] = x[i] < 0 ? x[i] * slope[c] : x[i]`. Args: `[x, slope]` (`channels` values).
ChannelPrelu { channels: usize, plane: usize },
/// Broadcasting over four dimensions: `dims` of the output, element strides of each operand (0 where it repeats).
/// Args: `[a, b]`.
Binary { op: Binary, dims: [usize; 4], a: [usize; 4], b: [usize; 4] },
/// `out[n] = alpha · Σk x[k] · B(k, n) + bias[n]`, `B(k, n) = weights[n·k_len + k]` when `trans_b`, else
/// `weights[k·n_len + n]`. Args: `[x, weights, bias]` (bias already times beta; zeros when the node has none).
Gemv { k: usize, n: usize, trans_b: bool, alpha: f32 },
/// The mean of each `plane`-sized run. Args: `[x]`.
GlobalAverage { channels: usize, plane: usize },
/// Unpadded max pooling. Args: `[x]`.
MaxPool { c: usize, h: usize, w: usize, kh: usize, kw: usize, sh: usize, sw: usize, oh: usize, ow: usize },
/// Nearest-neighbour upscale by whole numbers. Args: `[x]`.
Resize { c: usize, h: usize, w: usize, sh: usize, sw: usize },
/// `out` in its own order (`dims`), read from the input at `strides` (the input's strides, permuted). Args: `[x]`.
Transpose { dims: [usize; 4], strides: [usize; 4] },
}
#[derive(Clone, Debug)]
pub struct Step<'a> {
pub op: Op<'a>,
pub args: Vec<Arg<'a>>,
pub out: usize,
/// Values nothing reads after this step (never an output): their memory can be reused.
pub free: Vec<usize>,
}
/// A whole network for one input shape.
#[derive(Clone, Debug)]
pub struct Program<'a> {
/// Elements of each value; `lens[INPUT]` is the input's.
pub lens: Vec<usize>,
pub steps: Vec<Step<'a>>,
/// Each graph output: its name, the value that holds it and its shape.
pub outputs: Vec<(String, usize, Vec<usize>)>,
}
/// Leading ones up to rank four, or `None` past four.
fn rank4(s: &[usize]) -> Option<[usize; 4]> {
let pad = 4usize.checked_sub(s.len())?;
let mut out = [1; 4];
for (i, d) in s.iter().enumerate() {
*out.get_mut(pad + i)? = *d;
}
Some(out)
}
/// Element strides of `input` read as `out` under trailing-dimension broadcasting (0 along repeated axes).
fn broadcast_strides(out: [usize; 4], input: &[usize]) -> Option<[usize; 4]> {
let dims = rank4(input)?;
let mut strides = [0; 4];
let mut step = 1;
for axis in (0..4).rev() {
let (d, o) = (*dims.get(axis)?, *out.get(axis)?);
if d == o && d != 1 {
*strides.get_mut(axis)? = step;
} else if d != 1 {
return None;
}
step *= d;
}
Some(strides)
}
impl Net {
/// This network lowered for `input` (its shape), or `None` when a step has no device form.
pub fn program(&self, input: &[usize]) -> Result<Option<Program<'_>>> {
let g = &self.graph;
let (shapes, _) = shape::infer_all(g, input)?;
let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") };
let mut lens = vec![shape::elements(input)?];
let mut names: HashMap<&str, (usize, Vec<usize>)> = HashMap::new();
names.insert(input_name.as_str(), (INPUT, input.to_vec()));
let mut steps: Vec<Step> = Vec::new();
for (i, node) in g.nodes.iter().enumerate() {
let Some(out_shape) = shapes.get(i) else { return shape_err("a node has no shape") };
let Some(out_name) = node.outputs.first() else { return shape_err("a node has no output") };
let value = |idx: usize| -> Option<&(usize, Vec<usize>)> { node.inputs.get(idx).and_then(|n| names.get(n.as_str())) };
let constant = |idx: usize| -> Option<(Cow<'_, [f32]>, &[usize])> {
let w = node.inputs.get(idx).and_then(|n| g.weights.get(n))?;
match &w.data {
Data::F32(v) => Some((Cow::Borrowed(v.as_slice()), w.dims.as_slice())),
_ => None,
}
};
let id = |k: usize| i * 4 + k;
let Some((x, x_shape)) = value(0).cloned() else { return Ok(None) };
let (op, args) = match node.op.as_str() {
"Reshape" | "Flatten" | "Identity" | "Dropout" => {
names.insert(out_name.as_str(), (x, out_shape.clone()));
continue;
}
"Conv" => (Op::Conv(kernels::conv_job(i, node, &x_shape, &[], g)?), vec![Arg::Value(x)]),
"Relu" => (Op::Unary(Unary::Relu), vec![Arg::Value(x)]),
"Sigmoid" => (Op::Unary(Unary::Sigmoid), vec![Arg::Value(x)]),
"BatchNormalization" => {
let channels = *x_shape.get(1).ok_or_else(|| NetError::Shape("BatchNormalization rank".into()))?;
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
let (s, b, m, v) =
(shape::floats(node, g, 1)?, shape::floats(node, g, 2)?, shape::floats(node, g, 3)?, shape::floats(node, g, 4)?);
let eps = shape::float(node, "epsilon", 1e-5);
let mut scale = Vec::with_capacity(channels);
let mut offset = Vec::with_capacity(channels);
for c in 0..channels {
let (Some(s), Some(b), Some(m), Some(v)) = (s.get(c), b.get(c), m.get(c), v.get(c)) else {
return shape_err("short BatchNormalization parameters");
};
let k = s / (v + eps).sqrt();
scale.push(k);
offset.push(b - m * k);
}
(
Op::ChannelAffine { channels, plane },
vec![Arg::Value(x), Arg::Const { id: id(1), data: scale.into() }, Arg::Const { id: id(2), data: offset.into() }],
)
}
"PRelu" => {
let Some((slope, dims)) = constant(1) else { return Ok(None) };
let channels = x_shape.get(1).copied().unwrap_or(1);
let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?;
let mut expanded = vec![1; x_shape.len().saturating_sub(dims.len())];
expanded.extend_from_slice(dims);
let per_channel = expanded.iter().enumerate().all(|(i, &d)| d == 1 || i == 1) && slope.len() == channels;
let slope: Cow<[f32]> = if per_channel {
slope
} else if let [one] = slope.as_ref() {
vec![*one; channels].into()
} else {
return Ok(None);
};
(Op::ChannelPrelu { channels, plane }, vec![Arg::Value(x), Arg::Const { id: id(1), data: slope }])
}
"Add" | "Sub" | "Mul" => {
let op = match node.op.as_str() {
"Add" => Binary::Add,
"Sub" => Binary::Sub,
_ => Binary::Mul,
};
let (b, b_shape) = match (value(1), constant(1)) {
(Some((v, s)), _) => (Arg::Value(*v), s.clone()),
(None, Some((data, dims))) => (Arg::Const { id: id(1), data }, dims.to_vec()),
_ => return Ok(None),
};
let Some(dims) = rank4(out_shape) else { return Ok(None) };
let (Some(sa), Some(sb)) = (broadcast_strides(dims, &x_shape), broadcast_strides(dims, &b_shape)) else { return Ok(None) };
(Op::Binary { op, dims, a: sa, b: sb }, vec![Arg::Value(x), b])
}
"Gemm" => {
let Some((weights, wdims)) = constant(1) else { return Ok(None) };
let ([1, k], [n_out]) = (x_shape.as_slice(), out_shape.get(1..).unwrap_or(&[])) else { return Ok(None) };
if node.int("transA").unwrap_or(0) != 0 || out_shape.first() != Some(&1) {
return Ok(None);
}
let trans_b = node.int("transB").unwrap_or(0) != 0;
if wdims.len() != 2 || weights.len() != k * n_out {
return shape_err("Gemm weights do not match");
}
let mut bias = vec![0.0f32; *n_out];
if node.inputs.get(2).is_some_and(|s| !s.is_empty()) {
let wt = shape::weight(node, g, 2)?;
let b = shape::floats(node, g, 2)?;
let beta = shape::float(node, "beta", 1.0);
for (j, to) in bias.iter_mut().enumerate() {
*to = beta
* b.get(kernels::broadcast_offset(j, out_shape, &wt.dims))
.copied()
.ok_or_else(|| NetError::Shape("short matrix bias".into()))?;
}
}
(
Op::Gemv { k: *k, n: *n_out, trans_b, alpha: shape::float(node, "alpha", 1.0) },
vec![Arg::Value(x), Arg::Const { id: id(1), data: weights }, Arg::Const { id: id(2), data: bias.into() }],
)
}
"GlobalAveragePool" => {
let (c, h, w) = chw_of(&x_shape)?;
(Op::GlobalAverage { channels: c, plane: h * w }, vec![Arg::Value(x)])
}
"MaxPool" => {
let (c, h, w) = chw_of(&x_shape)?;
let (kh, kw) = pair(node, "kernel_shape", 1)?;
let (sh, sw) = pair(node, "strides", 1)?;
if pads(node)? != (0, 0, 0, 0) {
return Ok(None);
}
let (_, oh, ow) = chw_of(out_shape)?;
(Op::MaxPool { c, h, w, kh, kw, sh, sw, oh, ow }, vec![Arg::Value(x)])
}
"Resize" => {
let (c, h, w) = chw_of(&x_shape)?;
let (_, oh, ow) = chw_of(out_shape)?;
if oh % h != 0 || ow % w != 0 {
return shape_err("a Resize that is not by whole numbers");
}
(Op::Resize { c, h, w, sh: oh / h, sw: ow / w }, vec![Arg::Value(x)])
}
"Transpose" => {
let rank = x_shape.len();
let default_perm: Vec<i64> = (0..rank as i64).rev().collect();
let perm = node.ints("perm").unwrap_or(&default_perm);
if rank > 4 || perm.len() != rank {
return Ok(None);
}
let mut in_strides = vec![1usize; rank];
for a in (0..rank.saturating_sub(1)).rev() {
let next = in_strides.get(a + 1).copied().unwrap_or(1) * x_shape.get(a + 1).copied().unwrap_or(1);
if let Some(s) = in_strides.get_mut(a) {
*s = next;
}
}
let Some(dims) = rank4(out_shape) else { return Ok(None) };
let mut strides = [0usize; 4];
for (j, p) in perm.iter().enumerate() {
let p = usize::try_from(*p).map_err(|_| NetError::Shape("a negative permutation".into()))?;
let s = in_strides.get(p).copied().ok_or_else(|| NetError::Shape("a bad permutation".into()))?;
*strides.get_mut(4 - rank + j).ok_or_else(|| NetError::Shape("a bad permutation".into()))? = s;
}
(Op::Transpose { dims, strides }, vec![Arg::Value(x)])
}
_ => return Ok(None),
};
let out = lens.len();
lens.push(shape::elements(out_shape)?);
names.insert(out_name.as_str(), (out, out_shape.clone()));
steps.push(Step { op, args, out, free: Vec::new() });
}
let outputs = g
.outputs
.iter()
.map(|name| {
let (v, s) = names.get(name.as_str()).ok_or_else(|| NetError::Shape(format!("output `{name}` was never computed")))?;
Ok((name.clone(), *v, s.clone()))
})
.collect::<Result<Vec<_>>>()?;
// the last step that reads each value; a value nothing reads is free right after it is made
let mut last: Vec<Option<usize>> = vec![None; lens.len()];
for (at, step) in steps.iter().enumerate() {
if let Some(l) = last.get_mut(step.out) {
*l = Some(at);
}
for a in &step.args {
if let Arg::Value(v) = a
&& let Some(l) = last.get_mut(*v)
{
*l = Some(at);
}
}
}
for (v, at) in last.iter().enumerate() {
if outputs.iter().any(|(_, o, _)| *o == v) {
continue;
}
if let Some(step) = at.and_then(|at| steps.get_mut(at)) {
step.free.push(v);
}
}
Ok(Some(Program { lens, steps, outputs }))
}
}
pub(super) fn chw_of(s: &[usize]) -> Result<(usize, usize, usize)> {
match s {
[1, c, h, w] => Ok((*c, *h, *w)),
s => unsupported(format!("a tensor of shape {s:?} (only [1, channels, height, width])")),
}
}
+9 -1
View File
@@ -257,7 +257,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> {
}
}
pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
Ok(infer_all(g, input)?.1)
}
/// Each node's output shape, and the graph's outputs' shapes.
pub(super) fn infer_all(g: &Graph, input: &[usize]) -> Result<(Vec<Vec<usize>>, Vec<Vec<usize>>)> {
elements(input)?;
let mut per_node = Vec::with_capacity(g.nodes.len());
let mut shapes: HashMap<&str, Vec<usize>> = g.weights.iter().map(|(n, w)| (n.as_str(), w.dims.clone())).collect();
shapes.insert(g.inputs.first().ok_or_else(|| NetError::Shape("no input".into()))?.0.as_str(), input.to_vec());
let mut live = elements(input)?;
@@ -480,6 +485,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
.checked_add(out_count)
.filter(|&n| n <= 128 * 1024 * 1024)
.ok_or_else(|| NetError::Shape("live tensors exceed the 512 MiB working limit".into()))?;
per_node.push(out.clone());
shapes.insert(n.outputs.first().ok_or_else(|| NetError::Shape("no output".into()))?, out);
let mut removed = std::collections::HashSet::new();
for name in &n.inputs {
@@ -488,5 +494,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> {
}
}
}
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect()
let outputs =
g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect::<Result<_>>()?;
Ok((per_node, outputs))
}
+93 -8
View File
@@ -40,6 +40,9 @@ pub struct SelfTest {
type Plan = Net;
/// Most faces [`Embedder::embed_batch`] sends to a GPU in one go.
pub const BATCH: usize = 16;
/// A loaded recognition model.
#[derive(Clone)]
pub struct Embedder {
@@ -99,6 +102,31 @@ impl Embedder {
})
}
/// The network, e.g. to see which device it runs on.
pub fn net(&self) -> &Net {
&self.plan
}
/// Run the model's convolutions on `accelerator` if it gives the CPU's embedding on a test face (and, with
/// `require_faster`, runs it faster than the CPU); otherwise (or on
/// its first error) recognition stays on the CPU. Returns why it was not used.
pub fn accelerate(&self, accelerator: Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), RuntimeError> {
let (mean, std) = (self.manifest.input.mean, self.manifest.input.std);
let plane = self.width * self.height;
let data: Vec<f32> = crate::yunet::probe_pixels(3 * plane)
.into_iter()
.enumerate()
.map(|(i, v)| {
let c = (i / plane.max(1)).min(2);
(v - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().filter(|s| *s != 0.0).unwrap_or(1.0)
})
.collect();
let probe = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
guarded("checking the device", || {
self.plan.attach_checked(accelerator, &probe, crate::yunet::ACCELERATOR_TOLERANCE, require_faster).map_err(RuntimeError::Run)
})
}
/// The size of the aligned face picture the model wants (width, height).
pub fn input_size(&self) -> (usize, usize) {
(self.width, self.height)
@@ -110,6 +138,66 @@ impl Embedder {
/// The face as a unit-length vector. `rgb` is an aligned face at exactly [`Self::input_size`], 8-bit RGB.
pub fn embed(&self, rgb: &[u8]) -> Result<Vec<f32>, RuntimeError> {
let input = self.input(rgb)?;
let out = guarded("running it", || {
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
if result.len() != 1 {
return Err(RuntimeError::Run("the model must give one embedding".into()));
}
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
})?;
self.unit(out)
}
/// Several faces at once, each as [`Self::embed`] gives it. On a GPU they run together, up to [`BATCH`] per
/// dispatch (one upload and one read-back for all of them); on the CPU one after another.
pub fn embed_batch(&self, faces: &[&[u8]]) -> Vec<Result<Vec<f32>, RuntimeError>> {
if self.plan.accelerator().is_none() || faces.len() < 2 {
return faces.iter().map(|f| self.embed(f)).collect();
}
let mut results: Vec<Option<Result<Vec<f32>, RuntimeError>>> = faces.iter().map(|_| None).collect();
let mut ready: Vec<(usize, Tensor)> = Vec::new();
for (i, face) in faces.iter().enumerate() {
match self.input(face) {
Ok(t) => ready.push((i, t)),
Err(e) => {
if let Some(r) = results.get_mut(i) {
*r = Some(Err(e));
}
}
}
}
while !ready.is_empty() {
let chunk: Vec<(usize, Tensor)> = ready.drain(..ready.len().min(BATCH)).collect();
let (at, inputs): (Vec<usize>, Vec<Tensor>) = chunk.into_iter().unzip();
let ran = guarded("running it", || self.plan.run_batch(inputs).map_err(|e| RuntimeError::Run(e.to_string())));
match ran {
Ok(outs) if outs.len() == at.len() => {
for (i, mut out) in at.into_iter().zip(outs) {
let r = match (out.len(), out.pop()) {
(1, Some((_, t))) => self.unit(t.data),
_ => Err(RuntimeError::Run("the model must give one embedding".into())),
};
if let Some(slot) = results.get_mut(i) {
*slot = Some(r);
}
}
}
// one bad face spoils a batch: run them one by one so the others still get theirs
_ => {
for i in at {
if let (Some(slot), Some(face)) = (results.get_mut(i), faces.get(i)) {
*slot = Some(self.embed(face));
}
}
}
}
}
results.into_iter().map(|r| r.unwrap_or_else(|| Err(RuntimeError::Run("the face was not embedded".into())))).collect()
}
/// The model's input for an aligned face.
fn input(&self, rgb: &[u8]) -> Result<Tensor, RuntimeError> {
if rgb.len() != self.width * self.height * 3 {
return Err(RuntimeError::Input("the face picture is not the size the model wants".into()));
}
@@ -124,14 +212,11 @@ impl Embedder {
}
}
}
let out = guarded("running it", || {
let input = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?;
let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?;
if result.len() != 1 {
return Err(RuntimeError::Run("the model must give one embedding".into()));
}
Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data)
})?;
Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))
}
/// The model's output, checked and scaled to unit length.
fn unit(&self, out: Vec<f32>) -> Result<Vec<f32>, RuntimeError> {
if out.len() != self.dim {
return Err(RuntimeError::Run(format!("the model gave {} numbers per face, not the {} its description says", out.len(), self.dim)));
}
+60
View File
@@ -248,3 +248,63 @@ pub fn tiny_detector_manifest() -> crate::manifest::ModelManifest {
m.size_bytes = Some(bytes.len() as u64);
m
}
/// A small network that uses every operator the interpreter runs (both kinds of convolution, batch norm, PRelu,
/// sigmoid, broadcasting add/sub/mul with values and constants, max pooling, nearest upscaling, transposes, reshape,
/// global pooling, flatten and a fully connected layer), with two outputs, for checking an accelerator's program
/// against the CPU. Its input is `[1, 4, 8, 8]`.
pub fn every_operator_graph() -> crate::graph::Graph {
use crate::graph::{Attr, Data, Graph, Node, Weight};
let node = |op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attr)]| Node {
op: op.into(),
inputs: inputs.iter().map(|s| s.to_string()).collect(),
outputs: vec![output.into()],
attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
};
let wave = |n: usize, k: usize| -> Vec<f32> { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() };
let w = |dims: &[usize], v: Vec<f32>| Weight { dims: dims.to_vec(), data: Data::F32(v) };
let weights = [
("w1", w(&[8, 4, 3, 3], wave(288, 7))),
("b1", w(&[8], wave(8, 5))),
("bn_s", w(&[8], (0..8).map(|i| 0.5 + i as f32 * 0.1).collect())),
("bn_b", w(&[8], wave(8, 3))),
("bn_m", w(&[8], wave(8, 11))),
("bn_v", w(&[8], (0..8).map(|i| 0.2 + i as f32 * 0.05).collect())),
("slope", w(&[8, 1, 1], (0..8).map(|i| 0.05 * i as f32).collect())),
("w2", w(&[8, 1, 3, 3], wave(72, 13))),
("scales", w(&[4], vec![1.0, 1.0, 2.0, 2.0])),
("half", w(&[1], vec![0.5])),
("per_channel", w(&[1, 8, 1, 1], wave(8, 17))),
("wg", w(&[5, 8], wave(40, 19))),
("bg", w(&[5], wave(5, 2))),
("flat", Weight { dims: vec![2], data: Data::I64(vec![1, -1]) }),
];
let pads = |p: i64| ("pads", Attr::Ints(vec![p; 4]));
let nodes = vec![
node("Conv", &["x", "w1", "b1"], "c1", &[pads(1)]),
node("BatchNormalization", &["c1", "bn_s", "bn_b", "bn_m", "bn_v"], "bn", &[]),
node("PRelu", &["bn", "slope"], "pr", &[]),
node("Conv", &["pr", "w2"], "c2", &[("group", Attr::Int(8)), ("strides", Attr::Ints(vec![2, 2])), pads(1)]),
node("Sigmoid", &["c2"], "sg", &[]),
node("Mul", &["c2", "sg"], "swish", &[]),
node("Resize", &["swish", "", "scales"], "up", &[]),
node("Add", &["up", "pr"], "sum", &[]),
node("Sub", &["sum", "half"], "less", &[]),
node("MaxPool", &["less"], "pool", &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))]),
node("Relu", &["pool"], "relu", &[]),
node("Transpose", &["relu"], "nhwc", &[("perm", Attr::Ints(vec![0, 2, 3, 1]))]),
node("Transpose", &["nhwc"], "nchw", &[("perm", Attr::Ints(vec![0, 3, 1, 2]))]),
node("Mul", &["nchw", "per_channel"], "scaled", &[]),
node("Identity", &["scaled"], "same", &[]),
node("GlobalAveragePool", &["same"], "avg", &[]),
node("Flatten", &["avg"], "row", &[]),
node("Reshape", &["row", "flat"], "row2", &[]),
node("Gemm", &["row2", "wg", "bg"], "y", &[("transB", Attr::Int(1)), ("alpha", Attr::Float(0.5))]),
];
Graph {
nodes,
weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
inputs: vec![("x".into(), vec![1, 4, 8, 8])],
outputs: vec!["y".into(), "nhwc".into()],
}
}
+22
View File
@@ -60,6 +60,14 @@ pub struct Face {
pub landmarks: [(f32, f32); 5],
}
/// How far an accelerator's outputs may stray from the CPU's on the test input, relative to each output's largest value.
pub const ACCELERATOR_TOLERANCE: f32 = 1e-3;
/// A deterministic, photo-like test input (smooth gradients plus texture, in 0..255).
pub fn probe_pixels(n: usize) -> Vec<f32> {
(0..n).map(|i| ((i % 640) as f32 * 0.3 + ((i * 7919) % 97) as f32 + (i / 640 % 480) as f32 * 0.2) % 255.0).collect()
}
pub struct Detector {
net: Net,
/// Side of the square network input.
@@ -90,6 +98,20 @@ impl Detector {
Ok(Detector { net, side })
}
/// The network, e.g. to see which device it runs on.
pub fn net(&self) -> &Net {
&self.net
}
/// Run the network's convolutions on `accelerator` if it gives the CPU's outputs on a test picture (and, with `require_faster`,
/// runs it faster than the CPU); otherwise
/// (or on its first error) detection stays on the CPU. Returns why it was not used.
pub fn accelerate(&self, accelerator: std::sync::Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), String> {
let side = self.side;
let probe = Tensor::new(vec![1, 3, side, side], probe_pixels(3 * side * side)).map_err(|e| e.to_string())?;
self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster)
}
/// The square input side, used to check an installed manifest.
pub fn input_side(&self) -> usize {
self.side
+1
View File
@@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-faces = { workspace = true }
lightcraft-pipeline = { workspace = true }
log = { workspace = true }
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -35,6 +35,8 @@ pub mod backend;
mod ctx;
#[cfg(not(target_arch = "wasm32"))]
mod exit;
#[cfg(not(target_arch = "wasm32"))]
pub mod faces;
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
pub mod nn;
#[cfg(not(target_arch = "wasm32"))]
@@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
}
}
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
/// Why GPU compute that has a device of its own (denoise, faces) is switched off by the user's settings, or `None`: the
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn switched_off() -> Option<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
+171
View File
@@ -0,0 +1,171 @@
// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping.
//
// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output
// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup
// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each.
// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a
// 64-row tile would be almost all waste.
//
// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl]
struct P {
m: u32, // output channels per group
n: u32, // output pixels (oh * ow)
k: u32, // cg * kh * kw
cg: u32, // input channels per group
h: u32,
w: u32,
ow: u32,
kh: u32,
kw: u32,
sh: u32,
sw: u32,
pt: u32,
pl: u32,
bias: u32, // 1 when there is a bias
total: u32, // cout * n
row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too)
splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce`
kchunk: u32, // K values per slice, a multiple of BK
_a: u32,
_b: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> src: array<f32>;
@group(0) @binding(2) var<storage, read> wgt: array<f32>;
@group(0) @binding(3) var<storage, read> bias: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
const BM: u32 = 64u;
const BN: u32 = 64u;
const BK: u32 = 16u;
var<workgroup> As: array<f32, 1024>; // [BK][BM]
var<workgroup> Bs: array<f32, 1024>; // [BK][BN]
fn gather(g: u32, k: u32, pix: u32) -> f32 {
let taps = p.kh * p.kw;
let ci = g * p.cg + k / taps;
let tap = k % taps;
let oy = pix / p.ow;
let ox = pix % p.ow;
let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt);
let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl);
if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) {
return 0.0;
}
return src[(ci * p.h + u32(iy)) * p.w + u32(ix)];
}
@compute @workgroup_size(256)
fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let n0 = wg.x * BN;
let m0 = wg.y * BM;
let g = wg.z / p.splits;
let slice = wg.z % p.splits;
let k_lo = slice * p.kchunk;
let k_hi = min(p.k, k_lo + p.kchunk);
let tx = tid % 16u;
let ty = tid / 16u;
var acc: array<array<f32, 4>, 4>;
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] = 0.0;
}
}
for (var k0 = k_lo; k0 < k_hi; k0 += BK) {
// 1024 values of each tile, 4 per thread; every slot has exactly one writer
for (var e = tid; e < 1024u; e += 256u) {
// weights: neighbouring threads read neighbouring k of one row
let mm = e / BK;
let ka = k0 + e % BK;
let m = m0 + mm;
var a = 0.0;
if (ka < k_hi && m < p.m) {
a = wgt[(g * p.m + m) * p.k + ka];
}
As[(e % BK) * BM + mm] = a;
// input: neighbouring threads read neighbouring pixels
let kk = e / BN;
let k = k0 + kk;
let pix = n0 + e % BN;
var b = 0.0;
if (k < k_hi && pix < p.n) {
b = gather(g, k, pix);
}
Bs[e] = b;
}
workgroupBarrier();
for (var kk = 0u; kk < BK; kk++) {
var a: array<f32, 4>;
var b: array<f32, 4>;
for (var i = 0u; i < 4u; i++) {
a[i] = As[kk * BM + ty * 4u + i];
b[i] = Bs[kk * BN + tx + 16u * i];
}
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] += a[i] * b[j];
}
}
}
workgroupBarrier();
}
for (var i = 0u; i < 4u; i++) {
let m = m0 + ty * 4u + i;
if (m >= p.m) {
continue;
}
let co = g * p.m + m;
var b0 = 0.0;
if (p.bias != 0u && p.splits == 1u) {
b0 = bias[co];
}
// with slices, each writes a partial sum of its own: [slice][cout][n]
let base = slice * p.total + co * p.n;
for (var j = 0u; j < 4u; j++) {
// columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels
let pix = n0 + tx + 16u * j;
if (pix < p.n) {
dst[base + pix] = acc[i][j] + b0;
}
}
}
}
@compute @workgroup_size(256)
fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
let co = at / p.n;
let pix = at % p.n;
let g = co / p.m;
var v = 0.0;
if (p.bias != 0u) {
v = bias[co];
}
for (var k = 0u; k < p.k; k++) {
v += wgt[co * p.k + k] * gather(g, k, pix);
}
dst[at] = v;
}
// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n].
@compute @workgroup_size(256)
fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
var v = 0.0;
if (p.bias != 0u) {
v = bias[at / p.n];
}
for (var s = 0u; s < p.splits; s++) {
v += src[s * p.total + at];
}
dst[at] = v;
}
+188
View File
@@ -0,0 +1,188 @@
// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers.
// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one
// thread per output value; `gavg` and `gemv` one workgroup per output value.
//
// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel.
struct P {
v: array<vec4<u32>, 6>,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> a: array<f32>;
@group(0) @binding(2) var<storage, read> b: array<f32>;
@group(0) @binding(3) var<storage, read> c: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
fn q(i: u32) -> u32 {
return p.v[i / 4u][i % 4u];
}
fn index(wid: vec3<u32>, lid: u32) -> u32 {
return (wid.y * q(1) + wid.x) * 256u + lid;
}
// q(2): 0 relu, 1 sigmoid
@compute @workgroup_size(256)
fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
if (q(2) == 0u) {
dst[i] = max(x, 0.0);
} else {
dst[i] = 1.0 / (1.0 + exp(-x));
}
}
// q(2) plane, q(3) channels: x * b[c] + c[c]
@compute @workgroup_size(256)
fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let ch = (i / q(2)) % q(3);
dst[i] = a[i] * b[ch] + c[ch];
}
// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x
@compute @workgroup_size(256)
fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
let ch = (i / q(2)) % q(3);
dst[i] = select(x, x * b[ch], x < 0.0);
}
// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides
@compute @workgroup_size(256)
fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var ia = 0u;
var ib = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
let at = rest % size;
rest = rest / size;
ia = ia + at * q(7u + du);
ib = ib + at * q(11u + du);
}
let x = a[ia];
let y = b[ib];
if (q(2) == 0u) {
dst[i] = x + y;
} else if (q(2) == 1u) {
dst[i] = x - y;
} else {
dst[i] = x * y;
}
}
// q(3..7) output dims; q(7..11) the input's strides for each output axis
@compute @workgroup_size(256)
fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var at_in = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
at_in = at_in + (rest % size) * q(7u + du);
rest = rest / size;
}
dst[i] = a[at_in];
}
// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow
@compute @workgroup_size(256)
fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let oh = q(8);
let ow = q(9);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
var m = -3.402823e38;
for (var ky = 0u; ky < q(4); ky = ky + 1u) {
for (var kx = 0u; kx < q(5); kx = kx + 1u) {
let y = oy * q(6) + ky;
let x = ox * q(7) + kx;
if (y < h && x < w) {
m = max(m, a[(ch * h + y) * w + x]);
}
}
}
dst[i] = m;
}
// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers
@compute @workgroup_size(256)
fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let ow = w * q(5);
let oh = h * q(4);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)];
}
var<workgroup> partial: array<f32, 256>;
fn reduce(lid: u32) {
for (var s = 128u; s > 0u; s = s >> 1u) {
workgroupBarrier();
if (lid < s) {
partial[lid] = partial[lid] + partial[lid + s];
}
}
workgroupBarrier();
}
// one workgroup per channel; q(2) plane
@compute @workgroup_size(256)
fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let ch = wid.y * q(1) + wid.x;
let plane = q(2);
var sum = 0.0;
if (ch < q(0)) {
for (var j = lid; j < plane; j = j + 256u) {
sum = sum + a[ch * plane + j];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && ch < q(0)) {
dst[ch] = partial[0] / f32(plane);
}
}
// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits
@compute @workgroup_size(256)
fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let o = wid.y * q(1) + wid.x;
let k = q(2);
let n = q(3);
var sum = 0.0;
if (o < q(0)) {
for (var j = lid; j < k; j = j + 256u) {
let wi = select(j * n + o, o * k + j, q(4) == 1u);
sum = sum + a[j] * b[wi];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && o < q(0)) {
dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o];
}
}
+10 -1
View File
@@ -2332,5 +2332,14 @@
"Animal Eye Tracking": "Tieraugen-Tracking",
"Human Eye Tracking": "Augen-Tracking (Mensch)",
"Read Focus Points": "Fokuspunkte lesen",
"Reading focus points": "Fokuspunkte werden gelesen"
"Reading focus points": "Fokuspunkte werden gelesen",
"Click to type a value": "Klicken, um einen Wert einzugeben",
"Run face models on the CPU only": "Gesichtsmodelle nur auf der CPU ausführen",
"Detection": "Erkennung",
"Recognition": "Wiedererkennung",
"Not loaded yet": "Noch nicht geladen",
"Checking the GPU…": "GPU wird geprüft …",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.",
"Checking the GPU for faces": "GPU für Gesichter wird geprüft"
}
+10 -1
View File
@@ -2331,5 +2331,14 @@
"Animal Eye Tracking": "Seguimiento de ojos de animales",
"Human Eye Tracking": "Seguimiento de ojos humanos",
"Read Focus Points": "Leer puntos de enfoque",
"Reading focus points": "Leyendo puntos de enfoque"
"Reading focus points": "Leyendo puntos de enfoque",
"Click to type a value": "Hacer clic para escribir un valor",
"Run face models on the CPU only": "Ejecutar los modelos de caras solo en la CPU",
"Detection": "Detección",
"Recognition": "Reconocimiento",
"Not loaded yet": "Aún no cargado",
"Checking the GPU…": "Comprobando la GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.",
"Checking the GPU for faces": "Comprobando la GPU para las caras"
}
+10 -1
View File
@@ -2329,5 +2329,14 @@
"Animal Eye Tracking": "Suivi des yeux d’animaux",
"Human Eye Tracking": "Suivi des yeux humains",
"Read Focus Points": "Lire les points de mise au point",
"Reading focus points": "Lecture des points de mise au point"
"Reading focus points": "Lecture des points de mise au point",
"Click to type a value": "Cliquez pour saisir une valeur",
"Run face models on the CPU only": "Exécuter les modèles de visages sur le processeur uniquement",
"Detection": "Détection",
"Recognition": "Reconnaissance",
"Not loaded yet": "Pas encore chargé",
"Checking the GPU…": "Vérification du GPU…",
"CPU": "Processeur",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.",
"Checking the GPU for faces": "Vérification du GPU pour les visages"
}
+10 -1
View File
@@ -2331,5 +2331,14 @@
"Animal Eye Tracking": "動物瞳トラッキング",
"Human Eye Tracking": "人物瞳トラッキング",
"Read Focus Points": "フォーカスポイントを読み込む",
"Reading focus points": "フォーカスポイントを読み込み中"
"Reading focus points": "フォーカスポイントを読み込み中",
"Click to type a value": "クリックして値を入力",
"Run face models on the CPU only": "顔モデルを CPU だけで実行",
"Detection": "検出",
"Recognition": "認識",
"Not loaded yet": "未読み込み",
"Checking the GPU…": "GPU を確認中…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。",
"Checking the GPU for faces": "顔用の GPU を確認中"
}
+10 -1
View File
@@ -2171,5 +2171,14 @@
"Animal Eye Tracking": "Rastreamento de olhos de animais",
"Human Eye Tracking": "Rastreamento de olhos humanos",
"Read Focus Points": "Ler pontos de foco",
"Reading focus points": "Lendo pontos de foco"
"Reading focus points": "Lendo pontos de foco",
"Click to type a value": "Clique para digitar um valor",
"Run face models on the CPU only": "Executar os modelos de rostos só na CPU",
"Detection": "Detecção",
"Recognition": "Reconhecimento",
"Not loaded yet": "Ainda não carregado",
"Checking the GPU…": "Verificando a GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.",
"Checking the GPU for faces": "Verificando a GPU para rostos"
}
+10 -1
View File
@@ -2324,5 +2324,14 @@
"Animal Eye Tracking": "Слежение за глазами животных",
"Human Eye Tracking": "Слежение за глазами людей",
"Read Focus Points": "Прочитать точки фокусировки",
"Reading focus points": "Чтение точек фокусировки"
"Reading focus points": "Чтение точек фокусировки",
"Click to type a value": "Щёлкните, чтобы ввести значение",
"Run face models on the CPU only": "Запускать модели лиц только на ЦП",
"Detection": "Обнаружение",
"Recognition": "Распознавание",
"Not loaded yet": "Ещё не загружена",
"Checking the GPU…": "Проверка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.",
"Checking the GPU for faces": "Проверка ГП для лиц"
}
+10 -1
View File
@@ -2329,5 +2329,14 @@
"Animal Eye Tracking": "Стеження за очима тварин",
"Human Eye Tracking": "Стеження за очима людей",
"Read Focus Points": "Прочитати точки фокусування",
"Reading focus points": "Читання точок фокусування"
"Reading focus points": "Читання точок фокусування",
"Click to type a value": "Клацніть, щоб ввести значення",
"Run face models on the CPU only": "Запускати моделі облич лише на ЦП",
"Detection": "Виявлення",
"Recognition": "Розпізнавання",
"Not loaded yet": "Ще не завантажено",
"Checking the GPU…": "Перевірка ГП…",
"CPU": "ЦП",
"A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.",
"Checking the GPU for faces": "Перевірка ГП для облич"
}
+10 -1
View File
@@ -2170,5 +2170,14 @@
"Animal Eye Tracking": "动物眼部追踪",
"Human Eye Tracking": "人眼追踪",
"Read Focus Points": "读取对焦点",
"Reading focus points": "正在读取对焦点"
"Reading focus points": "正在读取对焦点",
"Click to type a value": "单击以输入数值",
"Run face models on the CPU only": "仅在 CPU 上运行人脸模型",
"Detection": "检测",
"Recognition": "识别",
"Not loaded yet": "尚未加载",
"Checking the GPU…": "正在检查 GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。",
"Checking the GPU for faces": "正在检查用于人脸的 GPU"
}
+10 -1
View File
@@ -2132,5 +2132,14 @@
"Animal Eye Tracking": "動物眼部追蹤",
"Human Eye Tracking": "人眼追蹤",
"Read Focus Points": "讀取對焦點",
"Reading focus points": "正在讀取對焦點"
"Reading focus points": "正在讀取對焦點",
"Click to type a value": "點擊以輸入數值",
"Run face models on the CPU only": "僅在 CPU 上執行臉孔模型",
"Detection": "偵測",
"Recognition": "辨識",
"Not loaded yet": "尚未載入",
"Checking the GPU…": "正在檢查 GPU…",
"CPU": "CPU",
"A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。",
"Checking the GPU for faces": "正在檢查用於臉孔的 GPU"
}
+15 -11
View File
@@ -402,12 +402,16 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt
let job = if c.interacting { job.draft() } else { job };
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
let job = job.with_overlay(overlay);
// the After window is proofed like the whole-frame render under it; the Before side is
// never proofed (its whole-frame render isn't either)
let proof = app.ui.soft_proof.then_some(app.ui.proof);
let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof });
// keyed as the renderer keys the texture (a display profile changes the key)
let job = app.renderer.job_for(v.slot, job);
// The overlay is part of the tile's identity too, so changing mask view/colour never leaves a
// stale region tile on screen while its replacement renders.
let look = job.settings.hash64() ^ overlay.key().rotate_left(17);
// The overlay and the proof are part of the tile's identity too, so changing mask view/colour
// or the proof never leaves a stale region tile on screen while its replacement renders.
let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) };
let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41);
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
// windows of other photos, looks and zooms of this side can't be drawn any more
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
@@ -533,15 +537,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
let look = d.hash64();
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
// Diagnostic views that need whole-frame context, or reads too far for one window, still
// render the whole frame at the size it is drawn. Mask overlays and soft proofing (both act
// pixel by pixel) are window-safe.
let sizes = crate::region::ViewSizes {
drawn_long,
canvas_long: canvas.width().max(canvas.height()) * ppp,
native_long,
texture_side,
draft,
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
windows: overlay_supports_window(overlay),
};
let mut plan = crate::region::plan(&app.ui.settings, sizes);
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
@@ -623,10 +628,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.renderer.request(Slot::Before, job, 90);
}
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use
// the same window; diagnostic overlays that need whole-frame context do not.
let windowable_view =
hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and
// soft proofing use the same window; diagnostic overlays that need whole-frame context do not.
let windowable_view = hover_key.is_none() && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
let window_ctx = WindowCtx {
id,
overlay,
+37
View File
@@ -164,6 +164,42 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
}
/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that
/// keeps them on the CPU.
fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) {
heading(ui, t, "Processor");
let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false);
let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only"));
register(ui.ctx(), "check:settings.facesCpuOnly", r.rect);
if r.changed() {
match app.run("faces.gpu", json!({"cpuOnly": cpu})) {
Ok(_) => app.caches.faces_epoch += 1,
Err(e) => app.toast(ui.ctx(), e),
}
}
for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] {
let st = &gpu[what];
let (text, why) = if st.is_null() {
(crate::i18n::tr("Not loaded yet").to_string(), None)
} else if st["checking"] == true {
(crate::i18n::tr("Checking the GPU…").to_string(), None)
} else if let Some(device) = st["device"].as_str() {
(device.to_string(), None)
} else {
(crate::i18n::tr("CPU").to_string(), st["why"].as_str())
};
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim));
let r = ui.label(text);
register(ui.ctx(), format!("faces:device:{what}"), r.rect);
if let Some(why) = why {
r.on_hover_text(sentence(why));
}
});
}
hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster.");
}
/// A download-style size: decimal megabytes, as file managers and model pages show them.
fn mb(bytes: Option<u64>) -> String {
match bytes {
@@ -236,6 +272,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
let downloads =
app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default();
gpu_section(app, ui, t, &list["gpu"]);
heading(ui, t, "Face detector");
for m in all.iter().filter(|m| m["role"] == "detector") {
model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime);
+5 -5
View File
@@ -77,8 +77,8 @@ pub struct ViewSizes {
/// `None`: no drag.
pub draft: Option<f32>,
/// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay
/// that needs whole-frame context, soft proofing, or a read too far for one window). Mask
/// overlays are window-safe.
/// that needs whole-frame context, or a read too far for one window). Mask
/// overlays and soft proofing are window-safe.
pub windows: bool,
}
@@ -225,7 +225,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 });
if !v.windows {
// the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no
// the whole frame at the size it is drawn (a whole-frame diagnostic has no
// window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must
// not render the photo's every pixel each frame
let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long };
@@ -422,7 +422,7 @@ mod tests {
assert_eq!(drag.window_edge, still.window_edge);
}
// Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the
// Given no window (a whole-frame diagnostic), the view at rest renders the
// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the
// windowed path
#[test]
@@ -626,7 +626,7 @@ mod tests {
}
}
// Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a
// Given a mode whose picture can't be a window (a whole-frame diagnostic, or a
// spot read too far for one render), the whole-frame render is as big as the view
#[test]
fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() {
+34 -16
View File
@@ -261,6 +261,29 @@ mod in_the_loupe {
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
}
// Given soft proofing on at 1:1 (LR-VIEW-ZOOM), the view is a canvas-sized frame plus a
// native window carrying the proof, as without it, instead of a whole-frame render at the
// photo's full size; switching the proof off swaps the window back to the plain look
#[test]
fn soft_proofing_at_one_to_one_is_a_window() {
let (mut h, native) = detail();
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
let plain = h.app.region_view.expect("a window render without the proof");
let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": true, "destWarning": true}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.ui.soft_proof);
let main = rendered_long_edge(&h);
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
let proofed = h.app.region_view.expect("a window render with the proof");
assert_eq!(proofed.full.0.max(proofed.full.1), native, "cut from the photo's own pixels");
assert_ne!(proofed.settings, plain.settings, "the proof is part of the window's look");
h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": false}}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
}
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
#[test]
fn an_explicit_limit_still_caps_one_to_one() {
@@ -646,10 +669,16 @@ mod in_the_loupe {
#[test]
#[ignore = "a measurement, not a check"]
fn profile_slider_drag() {
for (size, percent) in
[([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)]
{
for (size, percent, proof) in [
([1400.0, 900.0], 0.0, false),
([1400.0, 900.0], 100.0, false),
([1400.0, 900.0], 400.0, false),
([2800.0, 1700.0], 100.0, false),
([2800.0, 1700.0], 400.0, false),
([1400.0, 900.0], 100.0, true),
] {
let (mut h, native) = detail_in(size);
h.app.ui.soft_proof = proof;
h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) };
h.settle(SETTLE);
h.app.session.begin_interaction("Exposure").unwrap();
@@ -673,9 +702,10 @@ mod in_the_loupe {
ms.sort_by(|a, b| a.total_cmp(b));
let (w, hh) = region_tile(&h).unwrap_or((0, 0));
eprintln!(
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
"PROFILE {}x{} canvas, {percent:>4}% (0 = fit){} of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}",
size[0],
size[1],
if proof { ", soft proof" } else { "" },
ms[ms.len() / 2],
ms[ms.len() * 9 / 10],
ms[ms.len() - 1],
@@ -819,18 +849,6 @@ mod in_the_loupe {
assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::<Vec<_>>());
}
// Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame
// render is as big as the view, not a canvas-sized upscale
#[test]
fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() {
let (mut h, native) = detail();
h.app.ui.soft_proof = true;
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.settle(SETTLE);
assert_eq!(h.app.region_view, None);
assert_eq!(rendered_long_edge(&h), native);
}
// Given a window the photo's spots make too big for one render (refused), the loupe falls back
// to the whole frame at the drawn size, and tries windows again when the look changes
#[test]
+37
View File
@@ -143,6 +143,43 @@ SFace's builtin speed metadata now uses the measured 6.9× ratio. User `catalog.
Older published tables for MobileFaceNet/R50/R100 came from a different runtime and are not measurements of this runner; architecture
names alone do not guarantee the same speed, and those additional model files were not verified in this task.
### On the GPU
On a machine with a usable GPU, the whole detector and recogniser run on it through wgpu (`crates/gpu/src/faces.rs`).
The interpreter lowers the graph to a flat program for the input's shape (`crates/faces/src/net/program.rs`): each
convolution (`wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input straight into workgroup memory,
cuts K into slices when a map is small, and a direct kernel for depthwise layers) and every other operator (activations,
batch norm, PRelu, broadcasting add/sub/mul, max pooling, nearest upscaling, transposes, global pooling and the fully
connected layer, `wgsl/faces_ops.wgsl`) is a kernel, every value stays on the device between layers in reused
buffers, and a run is one upload, one submission and one read-back. Shape-only operators make no step. A model that
needs something the program cannot express runs its convolutions one by one on the GPU instead. Several faces of one
photo go together (`Embedder::embed_batch`, up to 16 per submission).
The CPU interpreter remains the reference. After a model loads, a background check (a row in the activity stack, "Checking
the GPU for faces") runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when
every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then,
and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference, or **Settings ▸
Faces ▸ Run face models on the CPU only**), missing, wrong or slower, the model runs on the CPU. The first GPU error,
or a result of the wrong size or with values that are not finite, drops it for that model. Settings ▸ Faces shows
which device each model runs on (`faces.gpu`). The weights stay on a device of the scan's own (separate from the
editor's) while the model is loaded, at most 768 MB per model.
The background scan's pace also limits the GPU: paused or light, one model run at a time and the GPU busy at most a
quarter of the time (the editor renders on the same GPU); normal, two at once and three quarters; full, as fast as it
goes.
Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under very heavy load from
other jobs (load average 100–250, so the absolute numbers are noisy):
| Model | CPU | GPU, one layer at a time | GPU, whole network | Batch of 16, per face |
| --- | ---: | ---: | ---: | ---: |
| YuNet (640², one photo) | 58–60 ms | 85–89 ms | 21 ms | – |
| SFace 2021dec | 40–42 ms | 47–51 ms | 9.6 ms | 8.1 ms |
| AuraFace v1 | 295–308 ms | 165–172 ms | 70–73 ms | 71 ms |
`LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks
agreement on the real models and every operator, and prints these timings.
### Agreement and regression coverage
The public-crop embeddings match the reference runtime's outputs:
+7 -4
View File
@@ -16,9 +16,9 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| # | Gap | Evidence | User impact | Hours | Doc |
|---:|---|---|---|---:|---|
| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) |
| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) |
| 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) |
| 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) |
| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) |
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) |
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
@@ -36,14 +36,14 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them,
**~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)).
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
## Closed recently (kept for calibration)
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export.
- 2026-10-11: Sony ILCA-99M2 profile.
- 2026-10-11: Nikon edge bars on bodies without CropArea (#976, #914): 36 model / size / depth groups measured.
- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float
@@ -80,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) |
| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain |
| 2026-10-14 | minor | Gap 4 closed: soft proofing at 1:1 renders a window (mask overlays already did); 1:1 pixels tested equal to export; only diagnostic overlays (Visualize Spots, Point Color range, HDR range) stay whole-frame |
| 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice |
| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile |
| 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) |
File diff suppressed because one or more lines are too long
+22 -21
View File
@@ -78,45 +78,45 @@ committed table is stale.
| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported |
|---|---:|---:|---:|---:|---:|---|
| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 4 | 115 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) |
| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported |
| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) |
| Panasonic | 104 | 93 | 11 | 0 | 0 | — |
| Nikon | 99 | 73 | 23 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
| Nikon | 99 | 77 | 19 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only |
| Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) |
| Google | 88 | 1 | 87 | 0 | 0 | — |
| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** |
| Google | 88 | 5 | 83 | 0 | 0 | — |
| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** |
| Leica | 61 | 27 | 34 | 0 | 0 | — |
| Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) |
| Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — |
| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF |
| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF |
| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) |
| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) |
| DJI | 21 | 4 | 17 | 0 | 0 | DNG |
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview |
| **All** | **1,446** | **259 (18%)** | **919 (64%)** | **232 (16%)** | **36 (2%)** | |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) |
| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | |
## By use (estimated)
| Bucket | Weight | Camera fully works | On a verified body | Why |
|---|---:|---:|---:|---|
| Canon | 33% | 94% | 72% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
| Sony | 27% | 98% | 91% | bulk-checked |
| Nikon | 16% | 98% | 83% | decoders cover every NEF coding but HE; 73 bodies verified |
| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
| Sony | 27% | 98% | 92% | bulk-checked |
| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified |
| Fujifilm | 10% | 94% | 92% | |
| Panasonic | 4% | 100% | 98% | bulk-checked |
| OM / Olympus | 3% | 6% | 5% | compressed ORF |
| Phones | 3% | 93% | 1% | DNG |
| Pentax / Ricoh | 1.5% | 94% | 14% | |
| Phones | 3% | 93% | 4% | DNG |
| Pentax / Ricoh | 1.5% | 99% | 80% | |
| Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files |
| DJI | 1% | 95% | 0% | |
| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F |
| **Weighted** | 100% | **92%** | **59%** | |
| DJI | 1% | 96% | 19% | |
| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F |
| **Weighted** | 100% | **94%** | **85%** | |
About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually
checked, and Canon, the largest bucket, is the weakest of the big four.
About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually
checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left.
## Variant matrix (depth per format)
@@ -265,7 +265,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
|---:|---|---:|---:|---:|---|
| 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) |
| 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users |
| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples |
| 3 | ~~Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks)~~ done (#1045, #1081) | (+2.0, taken) | — | — | public samples |
| 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists |
| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us |
| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples |
@@ -310,3 +310,4 @@ Beyond decoding:
| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) |
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged |
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
+5 -4
View File
@@ -157,7 +157,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point).
| Discount | Mainstream | Essentials | Evidence |
|---|---:|---:|---|
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) |
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) |
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) |
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) |
| **Product** | **×0.804** | **×0.830** | |
@@ -235,13 +235,13 @@ open as previews, and colour on every non-DNG raw is estimated rather than measu
**Alpha gate:** all six core workflows (import, cull, develop, save and reopen, batch looks, export)
work end to end on macOS; see the table in [roadmap.md](roadmap.md#alpha-gate). The partial rows (CR3
variants, compressed ORF, HE NEF as previews; capped 100% zoom) degrade but don't block a workflow.
variants, compressed ORF, HE NEF as previews) degrade but don't block a workflow.
**To beta:** ~14 points of readiness, **~340–600 h**: the nine `B` gaps in [gaps.md](gaps.md)
**To beta:** ~14 points of readiness, **~340–600 h**: the `B` gaps in [gaps.md](gaps.md)
(~230–430 h), plus burning down the ~92 open core-path user issues (40–80 h) and stability work
around them. Beta needs: CR3, ORF and
HE NEF decoding from sensor data; measured colour for the popular models and a fidelity suite with
scores; full-resolution zoom; Subject / Sky / People masks; a lens profile database for the
scores; Subject / Sky / People masks; a lens profile database for the
popular lenses; tests in CI on macOS, Windows and Linux.
## How hours are calibrated
@@ -303,6 +303,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
| 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
| Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) |
| Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) |
| Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | **original-pixel detail at 100% with a mask overlay or soft proofing** (no zoom window there; Detail, Before/After, Compare and Reference have them; P0 gap LR-VIEW-ZOOM) |
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 |
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
| Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer |
| Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item |
@@ -136,6 +136,7 @@ a real screen reader on any platform yet.
| 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status |
| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section |
| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged |
| 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed |
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |
+17
View File
@@ -265,6 +265,23 @@
else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read
from or written through such a link.
### Synchronize Folder asks a network share less
- A Synchronize Folder scan no longer asks the disk about every photo of the library in the folder (is its file
there, has it a sidecar): it has just listed the folder, and reads the answers off the listing. On a network share
each of those questions is a round trip. Measured on a hard disk over SMB, for folders of 1,600 to 2,400 photos
that had not changed: 4.6 to 7.1 s of questions after the listing (about 3 ms a photo), now under 0.05 s. The
listing itself took 4 to 5 s and is as it was.
- The disk is still asked wherever a listing can't be sure what a file system would say: about a name that is
there in another case, a link, a name Windows reads another way (`aux.jpg`, a `~`, a `:`, a dot or a space at the
end), a folder the scan did not walk to its end, and, in a folder that holds any name with a character outside
ASCII (an umlaut, a curly apostrophe, a dash), about every name the folder does not list. Names it does list are
answered there too. In such a folder the scan saves less, and its checks now start after the listing where they
ran beside it.
- A photo the folder lists is there for the scan, also where its file could not be opened by its path (a folder
that may be listed but not searched, a path longer than the system takes): such photos used to be listed as
missing. And a share that drops after the scan has listed a folder no longer turns that folder's photos into
missing ones.
### Large libraries: Synchronize Folder, import and sidecars start sooner
- Opening Synchronize Folder, an import review or an import no longer makes a table of every photo in the library
before it looks at the first file. In a library of 3.5 million photos that held the window for about 6 s before