diff --git a/core/dr-catalog/src/faces.rs b/core/dr-catalog/src/faces.rs index 695ba4c..40d156d 100644 --- a/core/dr-catalog/src/faces.rs +++ b/core/dr-catalog/src/faces.rs @@ -502,58 +502,77 @@ fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec> { carried } -/// A face embedded again from its stored landmarks: the new vector and its -/// length. What the measuring pass hands back per face. +/// What a per-face pass wants written over one stored face. +/// +/// Each field is `Some` where the pass produced it and `None` where it is +/// to be left exactly as it was: a device without the eye models writing a +/// fresh vector must not blank a reading a peer had already made. One +/// struct for every pass rather than one writer per column, so a new +/// per-face field is a field here and a handler in `dr_ui::faces::repairs`, +/// and nothing else. #[derive(Debug, Clone, PartialEq)] -pub struct Measurement { +pub struct FaceUpdate { pub face: FaceId, - /// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`. - pub embedding: Vec, - pub quality: f32, - /// See [`DetectedFace::eyes`]. `None` where the measuring device has no - /// eye models, in which case the stored reading is left as it was. - pub eyes: Option, - /// See [`DetectedFace::landmarks_dense`]; written with `eyes`. - pub landmarks_dense: Vec, + /// The raw vector (`dr_face::Embedded::to_f16_bytes`) and its length. + pub embedding: Option<(Vec, f32)>, + /// See [`DetectedFace::eyes`], with the dense landmarks it was read from + /// (see [`DetectedFace::landmarks_dense`]; empty stores NULL). + pub eyes: Option<(EyeReading, Vec)>, + /// See [`DetectedFace::crop`]. An empty crop is not written. + pub crop: Option>, } -/// Write fresh embeddings over faces that were found before their quality was -/// kept, and re-mark the image as indexed. +impl FaceUpdate { + /// An update that changes nothing yet, for a handler to fill one field of. + pub fn for_face(face: FaceId) -> Self { + Self { + face, + embedding: None, + eyes: None, + crop: None, + } + } +} + +/// Write what a per-face pass produced over the faces it read, and re-mark +/// the image as indexed. /// -/// The cheaper half of what `record_detections` does, for the case schema V14 -/// created: the boxes and landmarks are right, the identities are the user's -/// work, and only the vector needs doing again. Updating in place is what -/// keeps `face_person` and the face ids exactly as they were -- a -/// re-detection carries identities across by matching old faces to new, and -/// a match is a judgement where an update in place is a fact. +/// The cheaper half of what `record_detections` does, for a face whose box +/// and landmarks are right and whose identity is the user's work, and which +/// lacks something a later pass can fill from the native render: its +/// quality (schema V14), its eye reading and dense landmarks (V16, V18), its +/// crop. Updating in place is what keeps `face_person` and the face ids +/// exactly as they were -- a re-detection carries identities across by +/// matching old faces to new, and a match is a judgement where an update +/// in place is a fact. /// -/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp -/// could not be built from them. Deleted here, as detection would have refused -/// to store them (`dr_ui::faces::index_proxy`), and because a face left with -/// no reading would put its image back on the measuring pass's list on every -/// sweep, at the cost of an original each time. +/// `dropped` are faces whose landmarks turned out to be degenerate -- the +/// warp could not be built from them. Deleted here, as detection would have +/// refused to store them (`dr_ui::faces::index_native`), and because a face +/// that can never be filled would put its image back on the pass's list on +/// every sweep, at the cost of an original each time. /// /// The run marker is re-written with a fresh time, and that is not /// bookkeeping: `face_shard::export_to_shards` re-exports an image whose -/// marker is newer than the store's copy, which is how the measured vectors -/// reach the other devices. -pub fn record_measurements( +/// marker is newer than the store's copy, which is how what was written +/// here reaches the other devices. +pub fn record_updates( conn: &Connection, image_id: ImageId, model_id: &str, source_edge: u32, - measured: &[Measurement], + updates: &[FaceUpdate], dropped: &[FaceId], ) -> Result<(), CatalogError> { let tx = conn.unchecked_transaction()?; - for m in measured { - tx.execute( - "UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1", - rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)], - )?; - // Written only when read: a device without the eye models measuring - // a face a peer had already read must not blank the reading. - if let Some(e) = m.eyes { + for u in updates { + if let Some((embedding, quality)) = &u.embedding { + tx.execute( + "UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1", + rusqlite::params![u.face.0 as i64, embedding, f64::from(*quality)], + )?; + } + if let Some((e, dense)) = &u.eyes { tx.execute( "UPDATE faces SET eye_right = ?2, eye_right_px = ?3, eye_right_sharp = ?4, @@ -561,7 +580,7 @@ pub fn record_measurements( sunglasses = ?8, landmarks_dense = ?9 WHERE id = ?1", rusqlite::params![ - m.face.0 as i64, + u.face.0 as i64, f64::from(e.right.open), f64::from(e.right.px), f64::from(e.right.sharpness), @@ -569,10 +588,16 @@ pub fn record_measurements( f64::from(e.left.px), f64::from(e.left.sharpness), f64::from(e.sunglasses), - (!m.landmarks_dense.is_empty()).then_some(m.landmarks_dense.as_slice()), + (!dense.is_empty()).then_some(dense.as_slice()), ], )?; } + if let Some(crop) = u.crop.as_ref().filter(|c| !c.is_empty()) { + tx.execute( + "UPDATE faces SET crop = ?2 WHERE id = ?1", + rusqlite::params![u.face.0 as i64, crop], + )?; + } } for f in dropped { tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?; @@ -604,45 +629,48 @@ pub fn record_measurements( Ok(()) } -/// The faces on one image that have no quality reading yet — or, when the -/// device can read eyes, no eye reading either. +/// The faces on one image that a per-face pass still owes something to. /// -/// The measuring pass's per-image work: every face this model found whose -/// vector was stored as a unit one (schema V14), with the landmarks the -/// warp is rebuilt from; and, with `eyes`, every face never shown to the eye -/// models (schema V16). `eyes` is whether this device *has* those models — -/// a device without them must not list faces it cannot measure, or the pass -/// would fetch every original in the library to do nothing to it. -pub fn unmeasured_on_image( +/// `needs` is SQL over `faces` aliased as `f` -- `f.quality IS NULL`, say -- +/// and it comes from the pass, not from here: which columns a face can lack +/// is the business of the handlers that fill them (`dr_ui::faces::repairs`), +/// and the catalog's part is to answer the question exactly as asked, so +/// that the count a screen shows, the list a sweep fetches and the faces a +/// handler is given are one predicate and the pass converges. +/// +/// Keyed on the embedder half of `model_id`, like every other reader: a +/// face found by another detector in front of the same embedder is one of +/// this pipeline's faces. +pub fn faces_needing( conn: &Connection, image_id: ImageId, model_id: &str, - eyes: bool, + needs: &str, ) -> Result, CatalogError> { + let mut q = conn.prepare(&format!( + "SELECT f.id FROM faces f + WHERE f.image_id = ?1 AND {} = ?2 AND ({needs})", + embedder_sql("f.model_id") + ))?; + let owed: std::collections::HashSet = q + .query_map( + rusqlite::params![image_id.0 as i64, embedder_of(model_id)], + |r| r.get::<_, i64>(0), + )? + .collect::>()?; Ok(for_image(conn, image_id)? .into_iter() - .filter(|f| { - embedder_of(&f.model_id) == embedder_of(model_id) - && (f.quality.is_none() || (eyes && f.eyes.is_none())) - }) + .filter(|f| owed.contains(&(f.id.0 as i64))) .collect()) } -/// How many of a model's faces have no quality reading, or — with `eyes` — -/// no eye reading. -/// -/// What the measuring pass has left to do, for a screen that wants to say so. -/// `eyes` means what it means in [`unmeasured_on_image`]. -pub fn faces_unmeasured( - conn: &Connection, - model_id: &str, - eyes: bool, -) -> Result { +/// How many of a model's faces a per-face pass still owes something to. +/// `needs` is what it is in [`faces_needing`]. +pub fn count_needing(conn: &Connection, model_id: &str, needs: &str) -> Result { conn.query_row( &format!( - "SELECT COUNT(*) FROM faces WHERE {} = ?1 AND {}", - embedder_sql("model_id"), - unmeasured_sql("", eyes) + "SELECT COUNT(*) FROM faces f WHERE {} = ?1 AND ({needs})", + embedder_sql("f.model_id") ), [embedder_of(model_id)], |r| r.get::<_, i64>(0), @@ -1381,22 +1409,6 @@ pub(crate) fn read_eyes( })) } -/// The predicate "this face still needs measuring", over `faces` aliased as -/// `prefix` (`"f."` or `""`). -/// -/// One place for it because two queries ask — the count above and the -/// sweep's work list in `dr_ui::library` — and the two agreeing is what -/// makes the pass converge: a face the count reports is a face the list -/// fetches, and a face the list fetches is one whose reading the write -/// fills, so it leaves both. -pub fn unmeasured_sql(prefix: &str, eyes: bool) -> String { - if eyes { - format!("({prefix}quality IS NULL OR {prefix}eye_right IS NULL)") - } else { - format!("{prefix}quality IS NULL") - } -} - fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec { let mut out = Vec::with_capacity(40); for &(x, y) in lm { @@ -1575,10 +1587,13 @@ mod tests { ); } + const NEEDS_QUALITY: &str = "f.quality IS NULL"; + const NEEDS_EYES: &str = "f.eye_right IS NULL"; + /// The seven eye columns are one fact: a face with none of them reads as - /// unread, and the measuring pass is what fills them. + /// unread, and a per-face pass asking by predicate is what fills them. #[test] - fn eyes_are_measured_only_where_the_device_can_read_them() { + fn eyes_are_filled_only_where_a_pass_produced_a_reading() { let c = db(); let img = image(&c, 1); let unread = DetectedFace { @@ -1588,67 +1603,64 @@ mod tests { let ids = record_detections(&c, img, "w600k_mbf", 1024, &[unread, face(2)]).unwrap(); assert_eq!(for_image(&c, img).unwrap().len(), 2); - // Quality is present on both, so a device without the eye models has - // nothing to do here; one with them has one face to read. - assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0); - assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1); + // Quality is present on both, so a pass that only fills quality has + // nothing to do here; one that reads eyes has one face. + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0); + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1); assert_eq!( - unmeasured_on_image(&c, img, "w600k_mbf", false) + faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY) .unwrap() .len(), 0 ); - let todo = unmeasured_on_image(&c, img, "w600k_mbf", true).unwrap(); + let todo = faces_needing(&c, img, "w600k_mbf", NEEDS_EYES).unwrap(); assert_eq!(todo.len(), 1); assert_eq!(todo[0].id, ids[0]); - // A measurement with no reading leaves the columns alone … - record_measurements( + // An update with no reading leaves the columns alone … + record_updates( &c, img, "w600k_mbf", 6000, - &[Measurement { - face: ids[0], - embedding: vec![9; 1024], - quality: 21.5, - eyes: None, - landmarks_dense: Vec::new(), + &[FaceUpdate { + embedding: Some((vec![9; 1024], 21.5)), + ..FaceUpdate::for_face(ids[0]) }], &[], ) .unwrap(); - assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1); + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1); // … and one with a reading fills them. - record_measurements( + record_updates( &c, img, "w600k_mbf", 6000, - &[Measurement { - face: ids[0], - embedding: vec![9; 1024], - quality: 21.5, - eyes: Some(EyeReading { - right: Eye { - open: 0.2, - px: 40.0, - sharpness: 0.2, + &[FaceUpdate { + eyes: Some(( + EyeReading { + right: Eye { + open: 0.2, + px: 40.0, + sharpness: 0.2, + }, + left: Eye { + open: 0.9, + px: 40.0, + sharpness: 0.2, + }, + sunglasses: 0.0, }, - left: Eye { - open: 0.9, - px: 40.0, - sharpness: 0.2, - }, - sunglasses: 0.0, - }), - landmarks_dense: vec![9; 424], + vec![9; 424], + )), + ..FaceUpdate::for_face(ids[0]) }], &[], ) .unwrap(); - assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 0); + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 0); assert_eq!( for_image(&c, img).unwrap()[0].landmarks_dense.len(), 424, @@ -1662,10 +1674,46 @@ mod tests { ); } - /// The measuring pass writes over the vector and nothing else: the face - /// keeps its id, its box and whoever the user said it was. + /// A crop is filled the same way, and an empty one is not written. #[test] - fn measuring_replaces_the_vector_and_keeps_the_identity() { + fn a_crop_is_filled_in_place() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap(); + assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1); + record_updates( + &c, + img, + "w600k_mbf", + 6000, + &[FaceUpdate { + crop: Some(Vec::new()), + ..FaceUpdate::for_face(ids[0]) + }], + &[], + ) + .unwrap(); + assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1); + record_updates( + &c, + img, + "w600k_mbf", + 6000, + &[FaceUpdate { + crop: Some(vec![1, 2, 3]), + ..FaceUpdate::for_face(ids[0]) + }], + &[], + ) + .unwrap(); + assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 0); + assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![1, 2, 3])); + } + + /// An update writes over the vector and nothing else: the face keeps its + /// id, its box and whoever the user said it was. + #[test] + fn an_update_replaces_the_vector_and_keeps_the_identity() { let c = db(); let img = image(&c, 1); let unmeasured = DetectedFace { @@ -1682,9 +1730,9 @@ mod tests { .unwrap(); let person = create_person(&c, "Anna").unwrap(); confirm(&c, ids[0], person).unwrap(); - assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 2); + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 2); assert_eq!( - unmeasured_on_image(&c, img, "w600k_mbf", false) + faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY) .unwrap() .len(), 2 @@ -1695,17 +1743,14 @@ mod tests { c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", []) .unwrap(); - record_measurements( + record_updates( &c, img, "w600k_mbf", 6000, - &[Measurement { - face: ids[0], - embedding: vec![9; 1024], - quality: 21.5, - eyes: None, - landmarks_dense: Vec::new(), + &[FaceUpdate { + embedding: Some((vec![9; 1024], 21.5)), + ..FaceUpdate::for_face(ids[0]) }], &[ids[1]], ) @@ -1719,7 +1764,7 @@ mod tests { assert!(got[0].confirmed); let e = embeddings(&c, "w600k_mbf").unwrap(); assert_eq!(e[0].embedding[0], 9); - assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0); + assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0); // The marker says one face at the native edge, and is fresh — which // is what makes the sync export it again. diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index f4654eb..fd6f93a 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -61,7 +61,7 @@ pub use collections::{Collection, CollectionKind, TreeRow}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; pub use error::CatalogError; pub use face_shard::{FaceShardStore, SharedFace}; -pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId}; +pub use faces::{Calibration, DetectedFace, Face, FaceId, FaceUpdate, Person, PersonId}; pub use jobs::{Job, JobKind, Priority}; pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword}; pub use merge::MergeReport; diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index 121d1a0..83feaba 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -701,20 +701,20 @@ const V14: &str = r#" -- face this rule is not yet protecting anyone from, and the only way to -- measure it is to embed it again. -- --- The sweep's measuring pass is what does that: `dr_ui::library:: --- faces_unmeasured` lists every image holding a face with no reading, and --- each face is embedded again from the native render with the landmarks it --- already has, the raw vector written over the old one (`record_measurements`) --- and nothing else touched -- not the id, not the box, not who the user said --- it was. The faces keep drawing the People screen throughout. +-- The `face-quality` repair is what does that (`dr_ui::repairs`, once the +-- sweep's measuring pass): it lists every face with no reading, and each is +-- embedded again from the native render with the landmarks it already has, +-- the raw vector written over the old one (`record_updates`) and nothing +-- else touched -- not the id, not the box, not who the user said it was. +-- The faces keep drawing the People screen throughout. -- -- The run markers of those images are forgotten too, exactly as V12 forgot -- the runs made against too small a proxy. The build this shipped in had no -- measuring pass yet, and a marker is the one thing that stops a face ever --- being looked at again; with the pass in place `faces_unindexed` leaves --- these images to it rather than detecting them from scratch, so the --- deletion costs nothing -- and an image that was examined and found empty --- keeps its marker, since there is nothing on it to measure. +-- being looked at again; with the repair in place, detection leaves an +-- image holding this embedder's faces to it rather than detecting from +-- scratch, so the deletion costs nothing -- and an image that was examined +-- and found empty keeps its marker, since there is nothing on it to measure. -- -- The cost is a re-fetch of every image with a face on it, on the next pass -- the user starts. That is a whole-library transfer (FR-NC-6), and it starts diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index e99537f..0bd42a6 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -275,15 +275,24 @@ impl FaceDetector { } } + /// Both ids this detector writes under — the f32 form and the int8 one — + /// for a question that is about the detector and not about which form + /// of it a device happened to run: "has the chosen detector been over + /// this image", asked by a re-index that must not ping-pong between a + /// desktop that runs it in f32 and a tablet that runs it on the Hexagon. + pub fn model_ids(self) -> [&'static str; 2] { + [self.model_id(), self.model_id_int8()] + } + /// The detector that writes under a pipeline id, if it is one of these. /// - /// The inverse of [`Self::model_id`]. `None` for an id from another - /// embedder or a build this one does not know, which a caller treats as - /// "cannot rank" rather than as weaker than anything. + /// The inverse of [`Self::model_ids`] -- either spelling. `None` for an + /// id from another embedder or a build this one does not know, which a + /// caller treats as "cannot rank" rather than as weaker than anything. pub fn for_model_id(model_id: &str) -> Option { FaceDetector::ALL .into_iter() - .find(|d| d.model_id() == model_id) + .find(|d| d.model_ids().contains(&model_id)) } /// Whether this detector finds more than `other` does — the measured @@ -1738,6 +1747,17 @@ mod tests { assert_eq!(s.faces, FaceSettings::default()); } + #[test] + fn a_detectors_two_spellings_share_its_embedder_and_nothing_else() { + for d in FaceDetector::ALL { + let [f32_id, int8_id] = d.model_ids(); + assert_eq!(f32_id, d.model_id()); + assert_eq!(int8_id, d.model_id_int8()); + assert_ne!(f32_id, int8_id); + assert_eq!(f32_id.rsplit('+').next(), int8_id.rsplit('+').next()); + } + } + /// The detector every existing library was indexed with must keep the id /// those libraries were written under, or an upgrade would report every /// one of them un-indexed. @@ -1757,6 +1777,7 @@ mod tests { ); } assert_eq!(FaceDetector::for_model_id(d.model_id()), Some(d)); + assert_eq!(FaceDetector::for_model_id(d.model_id_int8()), Some(d)); } assert_eq!(FaceDetector::for_model_id("scrfd_10g+other"), None); } diff --git a/docs/faces.md b/docs/faces.md index be3a1fe..1f46286 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -816,10 +816,10 @@ here, and it is the phone and tablet story that should decide whether it gets bu anything downstream sees them. A probe's confidence (§9.1) is computed from the references it matched; a reference's confidence hears nothing from a probe. A face whose quality was never recorded is admitted to the gallery — a rule that cannot be checked admits rather than excludes — - and the next indexing pass **measures** it: `faces_unmeasured` lists every image holding one, and - each such face is embedded again from the native render with the landmarks it already has, the raw - vector written over the old one and its id, box and identity untouched - (`faces::record_measurements`). No detector runs and no suggestion is lost — the cost is the + and the next indexing pass **measures** it: the `face-quality` repair (§18.1) lists every image + holding one, and each such face is embedded again from the native render with the landmarks it + already has, the raw vector written over the old one and its id, box and identity untouched + (`faces::record_updates`). No detector runs and no suggestion is lost — the cost is the original fetched once more, since the length exists only at the moment of embedding. **The algorithm.** Constrained average-link agglomeration over the probability graph, merging while @@ -1119,8 +1119,8 @@ variant; its vectors are one space, and the detector only decides where the boxe So every reader now keys on the embedder half of the id (`faces::embedder_of`, and `embedder_sql` for the queries): all three detectors are one population, and changing between them empties nothing. `record_detections` is unchanged — an image holds one pipeline's faces at a -time, and a re-detection carries confirmations across by box overlap — and it is where the -generations meet. The merge's `match_faces` matches within an embedder for the same reason. The +time, and a re-detection carries identities across by box overlap and embedding (§18) — and it is +where the generations meet. The merge's `match_faces` matches within an embedder for the same reason. The shards travel every generation, each under its own id, and a peer adopts whichever it is sent. What a stronger choice still does is queue the images a weaker detector indexed for re-detection (`FaceDetector::supersedes`), after the ones nothing has indexed and never downwards, so a tablet @@ -1257,9 +1257,9 @@ frames to family snapshots as the real unknowns. | ID | How this document addresses it | |---|---| -| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job | +| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job, §18 the re-index | | FR-CULL-9 | §8 — pairs, fit, validity, and the reliability-diagram acceptance test | -| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration | +| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration; §18 what a re-index carries across | | FR-CULL-11 | §10 the `Person` selector term, confirmed-only by default | | FR-CULL-12 | §10 schema unchanged from catalog.md §10.1: embeddings derived, names to the sidecar | | NFR-SEC-5 | §11 — the obligations restated as structural properties, one of them CI-checkable | @@ -1447,12 +1447,12 @@ been the same size and worse: three figures near 1.0 is six pixels at that scale ### 17.5 The measuring pass, and shards -A face indexed before the models existed, or on a device without them, has no reading. The sweep's -measuring pass — the one V14 built to re-embed faces stored as unit vectors — lists those faces -too, on a device that has the models, and reads their eyes from the same native render with the -box and landmarks already stored (`dr_ui::faces::measure_native`). No detector runs and no identity -moves. A device *without* the models does not list them, or it would fetch every original in the -library to do nothing to it; `dr_catalog::faces::unmeasured_sql` is the one predicate both the +A face indexed before the models existed, or on a device without them, has no reading. The +`face-eyes` repair (§18.1; on the day this was written, the sweep's measuring pass — the one V14 +built to re-embed faces stored as unit vectors) lists those faces, on a device that has the models, +and reads their eyes from the same native render with the box and landmarks already stored. No +detector runs and no identity moves. A device *without* the models has no such repair, or it would +fetch every original in the library to do nothing to it; the repair's predicate is the one both the count and the work list use, so the pass converges. The People screen's coverage line counts these faces as work to read and keeps the button while any remain — reading **Read eye state** once detection is complete and only readings are left, which is the state an already-indexed @@ -1470,3 +1470,100 @@ the only device that has done the detection at all. | **M11** | Open-eye recall and blink precision from the **native** pass with the shipped configuration, on a labelled sample that contains real blinks — a burst with one in it is enough | §17.3's figures are proxy figures from 25 faces, with no precision beside them; this is the number FR-CULL-13's acceptance clause asks for, and where the two readability floors get set on more than 25 faces | | **M12** | Whether the remaining open-eye failure — a lens reflection over an open eye — moves with the sharpness floor, or needs the eye classifier told about spectacles | If the latter, the fix is a classifier trained closer to this domain, and that is a different decision | | **M13** | Sunglasses recall on more than twelve faces, and the false-positive rate on caps and clear glasses | The two sunglasses the head classifier missed on the sample became false blinks; a library of skiers would say whether that is two faces or a class | + +--- + +## 18. The completeness job, and what a re-index carries across · 2026-09-19 + +The reference library on the day this was written: 17,762 faces under the bare `w600k_mbf` id — +found by the fast detector on 1024 px proxies, stored as unit vectors, no quality, no crop on 4,144 +of them, no eye reading, no dense landmarks — beside 1,177 under `scrfd_10g+w600k_mbf` from the +native pass, and 12,217 images the fast detector examined and found nothing in. Over those faces: +3,778 confirmations, 13,011 suggestions, 77 rejections, and 17,276 people rows. Every one of those +gaps was, until now, its own pass: V14's measuring pass for the quality, §17.5's for the eyes, the +sweep's proxy repair, the sweep's detector upgrade, and a re-index that did not exist. Adding a +per-face field meant adding a pass, with its own work list, its own count and its own idea of done. + +### 18.1 One job over a registry + +`dr_ui::repairs` replaces them with one job over a **registry**. A `Repair` names one thing a catalog +record can lack — the predicate that says which images still owe it, the input its handler needs +(the file's header, the whole original, or a native render), the handler that fills it, and, where +there is one, what to record for an image that can never be done. The job unions the predicates +into one work list, fetches each image once at the most any claimant asks for, renders it at most +once, and runs every handler whose predicate that image still matches — checked again before each, +because one handler's write satisfies the next's (a detection writes every field a per-face handler +would fill). The registry today: + +| Repair | Owed by | Input | Handler | +|---|---|---|---| +| `face-proxy` (sweep only) | images holding faces whose 1024 px proxy is not in the store | native render | detect again, write the proxy | +| `face-quality` | faces with `quality IS NULL` | native render | warp from the stored landmarks, embed, write the raw vector and its length (reads eyes on the same warp where it can) | +| `face-eyes` | faces with no eye reading or no dense landmarks, on a device with the eye models | native render | read the eyes and dense landmarks from the stored box and landmarks | +| `face-crop` | faces with `crop IS NULL` | native render | cut the crop from the frame | +| `face-detection` | sweep: images with no marker under the embedder and no faces; re-index: images with no marker under the **chosen detector**, either spelling | native render | detect, embed, replace the faces, carry identities across (§18.2) | +| `face-upgrade` (sweep only) | images whose marker is a weaker detector's | native render | as `face-detection` | +| `metadata` | `images.metadata_state < 2` | header | EXIF to the catalog, the dateless marked examined | + +A repair's predicate is the *only* definition of its work. The count the settings page shows +(`faces::audit`, per repair), the list the job fetches and the check before each handler are one +predicate, so a record the count reports is one the job fetches and one the handler fills, and the +job ends. This is why an eye reading that cannot be cut is not a criterion — such a face stays +unread however often it is detected, and listing it would fetch its original on every press — and +why a degenerate face is dropped rather than left. It is also why the registry is cut to what the +device can do (`Capabilities`): a device without the eye models has no `face-eyes` entry, rather +than an entry it skips, because an entry is a count and a set of originals to fetch. + +The registry is ordered, and the order is the work list's: an image only the last repair claims +comes after one the first does, which is what puts a few hundred proxy repairs ahead of twenty +thousand un-indexed images. Within one image the same order runs the handlers, detection before the +per-face repairs, since detection fills what they would. + +Adding a field is one entry: a predicate over `faces f` or `images i`, and a handler that fills it +from `Fetched`. `metadata` is in the table to say that this is not a face job — the same machinery +carries a capture date, and could carry a thumbnail, a perceptual hash or a head pose. + +### 18.1a The two scopes + +Both buttons on the settings page run the job; they differ in one predicate. **Index faces** +(`Scope::Outstanding`) converges on *coverage* — has anything examined this image — and treats a +face a weaker detector found on a proxy as found, which is the right question for a pass that must +not fetch the library twice. **Re-index every face** (`Scope::Reindex`) converges on *provenance*: +`face-detection` claims every image with no marker under the chosen detector, in either of its +forms (`FaceDetector::model_ids`, so a desktop running it in f32 and a tablet on the Hexagon in int8 +do not re-index each other's work), and a marker saying a weaker one looked is not that. This is +the one place in the subsystem keyed on the exact detector rather than the embedder. Convergent all +the same: an image the job has been through leaves the list, a kill costs the images in flight, and +a second press resumes. + +An original over the fetch budget is skipped without being fetched. Under the sweep, detection +records an examination that found nothing — the honest record for an image that cannot be +examined, and what stops the half gigabyte being spent once per sweep. Under the re-index, and +under every repair over records that already exist, it is left exactly as it was: a re-detection +with nothing found would delete the faces, and "cannot fetch" is not "no faces". + +### 18.2 What is carried across + +`dr_catalog::faces::record_detections` replaces an image's faces and carries identities onto the +new ones. Before this section it carried confirmations only, by box overlap above 0.5 IoU, and a +re-detection of the library above would have left 13,011 suggestions and 77 rejections on the +floor — correct by FR-CULL-12's letter, since suggestions are derived data, and a People screen +emptied to strangers by the user's own button. + +Now every old face is read before the delete — box, vector, assignment, rejections — and matched to +the new faces one-to-one, best pair first. A pair qualifies when the boxes **overlap at all** and +either the overlap alone says so (IoU above 0.5, the old rule) or the embeddings do (cosine above +`SAME_FACE_COSINE` = 0.45, the reference library's P≈0.95 line from §9's table). The embedding route +is for the box a low-resolution pass drew badly enough that overlap alone would not claim it; the +vector is also what breaks the tie in a group photograph, where two neighbouring faces overlap both +new boxes. Overlap is required on both routes because the same vector elsewhere in the frame — a +mirror, a print on the wall — is not the same face and must not take its name. Onto the matched +face go the assignment as it was, confirmed or suggested with its probability, and every +rejection. + +It is a match, not an update in place, and that is why the per-face repairs exist beside +detection: where nothing about a face but one field needs doing, `record_updates` keeps the id and +there is nothing to judge. + +The merge's `match_faces` still matches by overlap alone across devices. It is the same question, +and the same answer would serve it; it is not changed here. diff --git a/docs/traceability.md b/docs/traceability.md index 0cce3cd..10f3956 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -35,14 +35,14 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | ID | Tagged in | |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:129`](../core/dr-sync/src/scan.rs#L129), [`core/dr-types/src/lib.rs:203`](../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:368`](../core/dr-types/src/settings.rs#L368), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1033`](../ui/dr-ui/ui/library.slint#L1033), [`ui/dr-ui/ui/library.slint:1254`](../ui/dr-ui/ui/library.slint#L1254), [`ui/dr-ui/ui/library.slint:979`](../ui/dr-ui/ui/library.slint#L979) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:377`](../core/dr-types/src/settings.rs#L377), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1033`](../ui/dr-ui/ui/library.slint#L1033), [`ui/dr-ui/ui/library.slint:1254`](../ui/dr-ui/ui/library.slint#L1254), [`ui/dr-ui/ui/library.slint:979`](../ui/dr-ui/ui/library.slint#L979) | | FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:3367`](../ui/dr-ui/src/library.rs#L3367), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137) | | FR-CAT-13 | [`core/dr-catalog/src/rating.rs:278`](../core/dr-catalog/src/rating.rs#L278), [`core/dr-catalog/src/schema.rs:740`](../core/dr-catalog/src/schema.rs#L740), [`core/dr-sync/src/scan.rs:40`](../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library.rs:1564`](../ui/dr-ui/src/library.rs#L1564), [`ui/dr-ui/src/library.rs:1627`](../ui/dr-ui/src/library.rs#L1627), [`ui/dr-ui/src/library.rs:1643`](../ui/dr-ui/src/library.rs#L1643), [`ui/dr-ui/src/library.rs:1750`](../ui/dr-ui/src/library.rs#L1750), [`ui/dr-ui/src/library_ui.rs:1329`](../ui/dr-ui/src/library_ui.rs#L1329), [`ui/dr-ui/src/library_ui.rs:301`](../ui/dr-ui/src/library_ui.rs#L301), [`ui/dr-ui/src/library_ui.rs:3455`](../ui/dr-ui/src/library_ui.rs#L3455), [`ui/dr-ui/src/library_ui.rs:3511`](../ui/dr-ui/src/library_ui.rs#L3511), [`ui/dr-ui/src/library_ui.rs:3609`](../ui/dr-ui/src/library_ui.rs#L3609), [`ui/dr-ui/src/library_ui.rs:392`](../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/library_ui.rs:5794`](../ui/dr-ui/src/library_ui.rs#L5794), [`ui/dr-ui/src/settings_ui.rs:540`](../ui/dr-ui/src/settings_ui.rs#L540), [`ui/dr-ui/src/xmp_sync.rs:1`](../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:885`](../ui/dr-ui/ui/app.slint#L885), [`ui/dr-ui/ui/settings.slint:1093`](../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../ui/dr-ui/ui/settings.slint#L199) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1076`](../core/dr-catalog/src/schema.rs#L1076), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:507`](../core/dr-sync-nextcloud/src/lib.rs#L507), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:618`](../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`core/dr-types/src/place.rs:91`](../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1435`](../ui/dr-ui/src/collections_ui.rs#L1435), [`ui/dr-ui/src/collections_ui.rs:2341`](../ui/dr-ui/src/collections_ui.rs#L2341), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:200`](../ui/dr-ui/src/library.rs#L200), [`ui/dr-ui/src/library.rs:249`](../ui/dr-ui/src/library.rs#L249), [`ui/dr-ui/src/library.rs:4547`](../ui/dr-ui/src/library.rs#L4547), [`ui/dr-ui/src/library.rs:4581`](../ui/dr-ui/src/library.rs#L4581), [`ui/dr-ui/src/library_ui.rs:238`](../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:914`](../ui/dr-ui/src/library_ui.rs#L914), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../ui/dr-ui/ui/collections.slint#L745) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:56`](../core/dr-types/src/lib.rs#L56), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../core/dr-sync/src/scan.rs#L105), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:3911`](../ui/dr-ui/src/library.rs#L3911), [`ui/dr-ui/src/library_ui.rs:177`](../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4543`](../ui/dr-ui/src/library_ui.rs#L4543), [`ui/dr-ui/src/library_ui.rs:6055`](../ui/dr-ui/src/library_ui.rs#L6055), [`ui/dr-ui/src/repairs.rs:1`](../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:669`](../ui/dr-ui/src/repairs.rs#L669), [`ui/dr-ui/ui/app.slint:386`](../ui/dr-ui/ui/app.slint#L386), [`ui/dr-ui/ui/settings.slint:435`](../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../core/dr-sync/src/scan.rs#L105), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:3911`](../ui/dr-ui/src/library.rs#L3911), [`ui/dr-ui/src/library_ui.rs:177`](../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4543`](../ui/dr-ui/src/library_ui.rs#L4543), [`ui/dr-ui/src/library_ui.rs:6055`](../ui/dr-ui/src/library_ui.rs#L6055), [`ui/dr-ui/src/repairs.rs:1`](../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:670`](../ui/dr-ui/src/repairs.rs#L670), [`ui/dr-ui/ui/app.slint:386`](../ui/dr-ui/ui/app.slint#L386), [`ui/dr-ui/ui/settings.slint:435`](../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:468`](../core/dr-catalog/src/schema.rs#L468), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:230`](../ui/dr-ui/src/library.rs#L230), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1548`](../core/dr-catalog/src/schema.rs#L1548), [`core/dr-catalog/src/schema.rs:991`](../core/dr-catalog/src/schema.rs#L991), [`core/dr-decode/src/lib.rs:301`](../core/dr-decode/src/lib.rs#L301), [`core/dr-decode/src/lib.rs:424`](../core/dr-decode/src/lib.rs#L424), [`core/dr-pipeline/src/sidecar.rs:171`](../core/dr-pipeline/src/sidecar.rs#L171), [`ui/dr-ui/src/collections_ui.rs:1731`](../ui/dr-ui/src/collections_ui.rs#L1731), [`ui/dr-ui/src/collections_ui.rs:1837`](../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1886`](../ui/dr-ui/src/collections_ui.rs#L1886), [`ui/dr-ui/src/collections_ui.rs:275`](../ui/dr-ui/src/collections_ui.rs#L275), [`ui/dr-ui/src/collections_ui.rs:484`](../ui/dr-ui/src/collections_ui.rs#L484), [`ui/dr-ui/src/collections_ui.rs:87`](../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library.rs:4595`](../ui/dr-ui/src/library.rs#L4595), [`ui/dr-ui/src/library_ui.rs:789`](../ui/dr-ui/src/library_ui.rs#L789), [`ui/dr-ui/src/library_ui.rs:8077`](../ui/dr-ui/src/library_ui.rs#L8077), [`ui/dr-ui/src/library_ui.rs:8088`](../ui/dr-ui/src/library_ui.rs#L8088), [`ui/dr-ui/src/library_ui.rs:8101`](../ui/dr-ui/src/library_ui.rs#L8101), [`ui/dr-ui/src/library_ui.rs:8116`](../ui/dr-ui/src/library_ui.rs#L8116), [`ui/dr-ui/src/library_ui.rs:8125`](../ui/dr-ui/src/library_ui.rs#L8125), [`ui/dr-ui/ui/app.slint:307`](../ui/dr-ui/ui/app.slint#L307), [`ui/dr-ui/ui/app.slint:574`](../ui/dr-ui/ui/app.slint#L574), [`ui/dr-ui/ui/library.slint:1360`](../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4235`](../ui/dr-ui/ui/library.slint#L4235) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:991`](../core/dr-catalog/src/schema.rs#L991), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:5071`](../ui/dr-ui/src/library.rs#L5071), [`ui/dr-ui/src/library_ui.rs:396`](../ui/dr-ui/src/library_ui.rs#L396), [`ui/dr-ui/src/library_ui.rs:505`](../ui/dr-ui/src/library_ui.rs#L505), [`ui/dr-ui/src/library_ui.rs:6794`](../ui/dr-ui/src/library_ui.rs#L6794), [`ui/dr-ui/src/library_ui.rs:6847`](../ui/dr-ui/src/library_ui.rs#L6847), [`ui/dr-ui/src/library_ui.rs:8217`](../ui/dr-ui/src/library_ui.rs#L8217), [`ui/dr-ui/ui/app.slint:319`](../ui/dr-ui/ui/app.slint#L319), [`ui/dr-ui/ui/app.slint:574`](../ui/dr-ui/ui/app.slint#L574), [`ui/dr-ui/ui/app.slint:881`](../ui/dr-ui/ui/app.slint#L881), [`ui/dr-ui/ui/library.slint:111`](../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1552`](../ui/dr-ui/ui/library.slint#L1552), [`ui/dr-ui/ui/library.slint:4235`](../ui/dr-ui/ui/library.slint#L4235), [`ui/dr-ui/ui/settings.slint:191`](../ui/dr-ui/ui/settings.slint#L191) | @@ -59,8 +59,8 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:171`](../core/dr-pipeline/src/sidecar.rs#L171), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:564`](../ui/dr-ui/src/library.rs#L564) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:550`](../core/dr-catalog/src/schema.rs#L550), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:210`](../ui/dr-ui/src/library.rs#L210), [`ui/dr-ui/src/library.rs:5994`](../ui/dr-ui/src/library.rs#L5994) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:618`](../core/dr-catalog/src/schema.rs#L618), [`core/dr-catalog/src/schema.rs:836`](../core/dr-catalog/src/schema.rs#L836), [`core/dr-catalog/src/schema.rs:877`](../core/dr-catalog/src/schema.rs#L877), [`core/dr-face/src/align.rs:296`](../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../core/dr-types/src/settings.rs#L176), [`ui/dr-ui/examples/face_detectors.rs:1`](../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/derived_sync.rs:124`](../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:431`](../ui/dr-ui/src/faces.rs#L431), [`ui/dr-ui/src/faces.rs:447`](../ui/dr-ui/src/faces.rs#L447), [`ui/dr-ui/src/faces.rs:569`](../ui/dr-ui/src/faces.rs#L569), [`ui/dr-ui/src/identity_ui.rs:1287`](../ui/dr-ui/src/identity_ui.rs#L1287), [`ui/dr-ui/src/identity_ui.rs:570`](../ui/dr-ui/src/identity_ui.rs#L570), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:3941`](../ui/dr-ui/src/lib.rs#L3941), [`ui/dr-ui/src/library.rs:3595`](../ui/dr-ui/src/library.rs#L3595), [`ui/dr-ui/src/library.rs:3860`](../ui/dr-ui/src/library.rs#L3860), [`ui/dr-ui/src/library_ui.rs:406`](../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:493`](../ui/dr-ui/src/library_ui.rs#L493), [`ui/dr-ui/src/repairs.rs:1`](../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/settings_ui.rs:210`](../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:552`](../ui/dr-ui/src/settings_ui.rs#L552), [`ui/dr-ui/ui/app.slint:931`](../ui/dr-ui/ui/app.slint#L931), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100), [`ui/dr-ui/ui/settings.slint:467`](../ui/dr-ui/ui/settings.slint#L467), [`ui/dr-ui/ui/settings.slint:529`](../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../ui/dr-ui/ui/settings.slint#L94) | -| FR-CULL-8a | [`core/dr-catalog/src/faces.rs:124`](../core/dr-catalog/src/faces.rs#L124), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-face/src/classify.rs:1`](../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../core/dr-face/src/landmarks.rs#L1), [`core/dr-types/src/place.rs:129`](../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/faces.rs:160`](../ui/dr-ui/src/faces.rs#L160), [`ui/dr-ui/src/faces.rs:1733`](../ui/dr-ui/src/faces.rs#L1733), [`ui/dr-ui/src/faces.rs:293`](../ui/dr-ui/src/faces.rs#L293), [`ui/dr-ui/src/identity.rs:104`](../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library.rs:321`](../ui/dr-ui/src/library.rs#L321), [`ui/dr-ui/src/library.rs:7174`](../ui/dr-ui/src/library.rs#L7174), [`ui/dr-ui/src/library.rs:7217`](../ui/dr-ui/src/library.rs#L7217), [`ui/dr-ui/src/library.rs:7255`](../ui/dr-ui/src/library.rs#L7255), [`ui/dr-ui/src/repairs.rs:553`](../ui/dr-ui/src/repairs.rs#L553), [`ui/dr-ui/ui/app.slint:706`](../ui/dr-ui/ui/app.slint#L706), [`ui/dr-ui/ui/identity.slint:273`](../ui/dr-ui/ui/identity.slint#L273), [`ui/dr-ui/ui/identity.slint:63`](../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../ui/dr-ui/ui/library.slint#L1512) | -| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:685`](../core/dr-catalog/src/schema.rs#L685), [`core/dr-catalog/src/schema.rs:877`](../core/dr-catalog/src/schema.rs#L877), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:129`](../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:569`](../ui/dr-ui/src/faces.rs#L569), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/repairs.rs:499`](../ui/dr-ui/src/repairs.rs#L499), [`ui/dr-ui/ui/identity.slint:347`](../ui/dr-ui/ui/identity.slint#L347) | +| FR-CULL-8a | [`core/dr-catalog/src/faces.rs:124`](../core/dr-catalog/src/faces.rs#L124), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-face/src/classify.rs:1`](../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../core/dr-face/src/landmarks.rs#L1), [`core/dr-types/src/place.rs:129`](../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/faces.rs:160`](../ui/dr-ui/src/faces.rs#L160), [`ui/dr-ui/src/faces.rs:1733`](../ui/dr-ui/src/faces.rs#L1733), [`ui/dr-ui/src/faces.rs:293`](../ui/dr-ui/src/faces.rs#L293), [`ui/dr-ui/src/identity.rs:104`](../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library.rs:321`](../ui/dr-ui/src/library.rs#L321), [`ui/dr-ui/src/library.rs:7174`](../ui/dr-ui/src/library.rs#L7174), [`ui/dr-ui/src/library.rs:7217`](../ui/dr-ui/src/library.rs#L7217), [`ui/dr-ui/src/library.rs:7255`](../ui/dr-ui/src/library.rs#L7255), [`ui/dr-ui/src/repairs.rs:554`](../ui/dr-ui/src/repairs.rs#L554), [`ui/dr-ui/ui/app.slint:706`](../ui/dr-ui/ui/app.slint#L706), [`ui/dr-ui/ui/identity.slint:273`](../ui/dr-ui/ui/identity.slint#L273), [`ui/dr-ui/ui/identity.slint:63`](../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../ui/dr-ui/ui/library.slint#L1512) | +| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:685`](../core/dr-catalog/src/schema.rs#L685), [`core/dr-catalog/src/schema.rs:877`](../core/dr-catalog/src/schema.rs#L877), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:129`](../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:569`](../ui/dr-ui/src/faces.rs#L569), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/repairs.rs:500`](../ui/dr-ui/src/repairs.rs#L500), [`ui/dr-ui/ui/identity.slint:347`](../ui/dr-ui/ui/identity.slint#L347) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1027`](../core/dr-gpu/src/mask.rs#L1027), [`core/dr-gpu/src/mask.rs:1251`](../core/dr-gpu/src/mask.rs#L1251), [`core/dr-gpu/src/mask.rs:1509`](../core/dr-gpu/src/mask.rs#L1509), [`core/dr-gpu/src/mask.rs:421`](../core/dr-gpu/src/mask.rs#L421), [`core/dr-gpu/src/mask.rs:463`](../core/dr-gpu/src/mask.rs#L463), [`core/dr-gpu/src/mask.rs:58`](../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:834`](../core/dr-gpu/src/mask.rs#L834), [`core/dr-gpu/src/mask.rs:85`](../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:913`](../core/dr-gpu/src/mask.rs#L913), [`core/dr-gpu/src/shaders/mask.wgsl:117`](../core/dr-gpu/src/shaders/mask.wgsl#L117), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2845`](../core/dr-pipeline/src/mask.rs#L2845), [`core/dr-pipeline/src/mask.rs:2858`](../core/dr-pipeline/src/mask.rs#L2858), [`core/dr-pipeline/src/mask.rs:2877`](../core/dr-pipeline/src/mask.rs#L2877), [`core/dr-pipeline/src/mask.rs:2891`](../core/dr-pipeline/src/mask.rs#L2891), [`core/dr-pipeline/src/mask.rs:2901`](../core/dr-pipeline/src/mask.rs#L2901), [`core/dr-pipeline/src/mask.rs:710`](../core/dr-pipeline/src/mask.rs#L710), [`core/dr-pipeline/src/mask.rs:749`](../core/dr-pipeline/src/mask.rs#L749), [`core/dr-pipeline/src/mask.rs:809`](../core/dr-pipeline/src/mask.rs#L809), [`core/dr-pipeline/src/mask.rs:828`](../core/dr-pipeline/src/mask.rs#L828), [`core/dr-pipeline/src/mask.rs:852`](../core/dr-pipeline/src/mask.rs#L852), [`core/dr-pipeline/src/mask.rs:864`](../core/dr-pipeline/src/mask.rs#L864), [`core/dr-pipeline/src/mask.rs:876`](../core/dr-pipeline/src/mask.rs#L876), [`core/dr-pipeline/src/sidecar.rs:1336`](../core/dr-pipeline/src/sidecar.rs#L1336), [`core/dr-pipeline/src/sidecar.rs:1515`](../core/dr-pipeline/src/sidecar.rs#L1515), [`core/dr-pipeline/src/sidecar.rs:1604`](../core/dr-pipeline/src/sidecar.rs#L1604), [`core/dr-pipeline/src/sidecar.rs:1708`](../core/dr-pipeline/src/sidecar.rs#L1708), [`core/dr-pipeline/src/sidecar.rs:1916`](../core/dr-pipeline/src/sidecar.rs#L1916), [`core/dr-pipeline/tests/mask_sidecar.rs:1157`](../core/dr-pipeline/tests/mask_sidecar.rs#L1157), [`core/dr-pipeline/tests/mask_sidecar.rs:1186`](../core/dr-pipeline/tests/mask_sidecar.rs#L1186), [`core/dr-pipeline/tests/mask_sidecar.rs:1204`](../core/dr-pipeline/tests/mask_sidecar.rs#L1204), [`core/dr-pipeline/tests/mask_sidecar.rs:1234`](../core/dr-pipeline/tests/mask_sidecar.rs#L1234), [`ui/dr-ui/src/develop.rs:2318`](../ui/dr-ui/src/develop.rs#L2318), [`ui/dr-ui/src/develop.rs:3657`](../ui/dr-ui/src/develop.rs#L3657), [`ui/dr-ui/src/develop.rs:3684`](../ui/dr-ui/src/develop.rs#L3684), [`ui/dr-ui/src/develop.rs:3716`](../ui/dr-ui/src/develop.rs#L3716), [`ui/dr-ui/src/develop.rs:3740`](../ui/dr-ui/src/develop.rs#L3740), [`ui/dr-ui/src/develop.rs:3746`](../ui/dr-ui/src/develop.rs#L3746), [`ui/dr-ui/src/develop.rs:3753`](../ui/dr-ui/src/develop.rs#L3753), [`ui/dr-ui/src/develop.rs:3773`](../ui/dr-ui/src/develop.rs#L3773), [`ui/dr-ui/src/labels.rs:63`](../ui/dr-ui/src/labels.rs#L63), [`ui/dr-ui/src/masks_ui.rs:1150`](../ui/dr-ui/src/masks_ui.rs#L1150), [`ui/dr-ui/src/masks_ui.rs:748`](../ui/dr-ui/src/masks_ui.rs#L748) | | FR-DEV-12 | [`core/dr-pipeline/ops/colour_grading.yaml:1`](../core/dr-pipeline/ops/colour_grading.yaml#L1) | @@ -82,7 +82,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-DEV-3i | [`core/dr-pipeline/src/mask.rs:558`](../core/dr-pipeline/src/mask.rs#L558), [`core/dr-segment/src/lib.rs:1`](../core/dr-segment/src/lib.rs#L1), [`ui/dr-ui/src/segmentation.rs:1`](../ui/dr-ui/src/segmentation.rs#L1) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:798`](../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:368`](../core/dr-gpu/src/lib.rs#L368), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | | FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:1407`](../core/dr-pipeline/src/graph.rs#L1407), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/sidecar.rs:1045`](../core/dr-pipeline/src/sidecar.rs#L1045), [`core/dr-pipeline/src/sidecar.rs:144`](../core/dr-pipeline/src/sidecar.rs#L144), [`core/dr-pipeline/src/sidecar.rs:2472`](../core/dr-pipeline/src/sidecar.rs#L2472), [`core/dr-pipeline/src/sidecar.rs:2508`](../core/dr-pipeline/src/sidecar.rs#L2508), [`core/dr-pipeline/src/sidecar.rs:2520`](../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`core/dr-pipeline/src/sidecar.rs:694`](../core/dr-pipeline/src/sidecar.rs#L694), [`core/dr-pipeline/src/sidecar.rs:710`](../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:857`](../core/dr-pipeline/src/sidecar.rs#L857), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:4400`](../ui/dr-ui/src/develop.rs#L4400), [`ui/dr-ui/src/develop.rs:4797`](../ui/dr-ui/src/develop.rs#L4797), [`ui/dr-ui/src/develop.rs:5475`](../ui/dr-ui/src/develop.rs#L5475), [`ui/dr-ui/src/develop.rs:5493`](../ui/dr-ui/src/develop.rs#L5493), [`ui/dr-ui/src/develop.rs:5532`](../ui/dr-ui/src/develop.rs#L5532), [`ui/dr-ui/src/develop.rs:5546`](../ui/dr-ui/src/develop.rs#L5546), [`ui/dr-ui/src/develop.rs:5557`](../ui/dr-ui/src/develop.rs#L5557), [`ui/dr-ui/src/develop.rs:5616`](../ui/dr-ui/src/develop.rs#L5616), [`ui/dr-ui/src/develop.rs:5629`](../ui/dr-ui/src/develop.rs#L5629), [`ui/dr-ui/src/develop.rs:5659`](../ui/dr-ui/src/develop.rs#L5659), [`ui/dr-ui/src/develop.rs:5672`](../ui/dr-ui/src/develop.rs#L5672), [`ui/dr-ui/src/develop.rs:5684`](../ui/dr-ui/src/develop.rs#L5684), [`ui/dr-ui/src/develop.rs:5700`](../ui/dr-ui/src/develop.rs#L5700), [`ui/dr-ui/src/develop.rs:5732`](../ui/dr-ui/src/develop.rs#L5732), [`ui/dr-ui/src/develop.rs:5736`](../ui/dr-ui/src/develop.rs#L5736), [`ui/dr-ui/src/develop.rs:5755`](../ui/dr-ui/src/develop.rs#L5755), [`ui/dr-ui/src/develop.rs:5771`](../ui/dr-ui/src/develop.rs#L5771), [`ui/dr-ui/src/develop.rs:6613`](../ui/dr-ui/src/develop.rs#L6613), [`ui/dr-ui/src/develop.rs:6633`](../ui/dr-ui/src/develop.rs#L6633), [`ui/dr-ui/src/develop.rs:6664`](../ui/dr-ui/src/develop.rs#L6664), [`ui/dr-ui/src/develop.rs:6721`](../ui/dr-ui/src/develop.rs#L6721), [`ui/dr-ui/src/develop.rs:6781`](../ui/dr-ui/src/develop.rs#L6781), [`ui/dr-ui/src/develop.rs:772`](../ui/dr-ui/src/develop.rs#L772), [`ui/dr-ui/src/develop.rs:781`](../ui/dr-ui/src/develop.rs#L781), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:278`](../ui/dr-ui/src/labels.rs#L278), [`ui/dr-ui/src/labels.rs:31`](../ui/dr-ui/src/labels.rs#L31), [`ui/dr-ui/src/lib.rs:1920`](../ui/dr-ui/src/lib.rs#L1920), [`ui/dr-ui/src/lib.rs:1925`](../ui/dr-ui/src/lib.rs#L1925), [`ui/dr-ui/src/lib.rs:1988`](../ui/dr-ui/src/lib.rs#L1988), [`ui/dr-ui/src/lib.rs:1997`](../ui/dr-ui/src/lib.rs#L1997), [`ui/dr-ui/src/lib.rs:2009`](../ui/dr-ui/src/lib.rs#L2009), [`ui/dr-ui/src/lib.rs:3140`](../ui/dr-ui/src/lib.rs#L3140), [`ui/dr-ui/src/lib.rs:3160`](../ui/dr-ui/src/lib.rs#L3160), [`ui/dr-ui/src/library.rs:611`](../ui/dr-ui/src/library.rs#L611), [`ui/dr-ui/src/library.rs:896`](../ui/dr-ui/src/library.rs#L896), [`ui/dr-ui/src/presets.rs:1678`](../ui/dr-ui/src/presets.rs#L1678), [`ui/dr-ui/src/presets.rs:373`](../ui/dr-ui/src/presets.rs#L373), [`ui/dr-ui/src/presets.rs:437`](../ui/dr-ui/src/presets.rs#L437), [`ui/dr-ui/src/presets.rs:557`](../ui/dr-ui/src/presets.rs#L557), [`ui/dr-ui/ui/history.slint:191`](../ui/dr-ui/ui/history.slint#L191), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:218`](../ui/dr-ui/ui/history.slint#L218), [`ui/dr-ui/ui/history.slint:303`](../ui/dr-ui/ui/history.slint#L303), [`ui/dr-ui/ui/history.slint:43`](../ui/dr-ui/ui/history.slint#L43), [`ui/dr-ui/ui/history.slint:53`](../ui/dr-ui/ui/history.slint#L53) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:434`](../core/dr-types/src/settings.rs#L434), [`ui/dr-ui/src/develop.rs:5415`](../ui/dr-ui/src/develop.rs#L5415), [`ui/dr-ui/src/develop.rs:5436`](../ui/dr-ui/src/develop.rs#L5436), [`ui/dr-ui/src/lib.rs:1887`](../ui/dr-ui/src/lib.rs#L1887), [`ui/dr-ui/src/lib.rs:2673`](../ui/dr-ui/src/lib.rs#L2673), [`ui/dr-ui/src/lib.rs:2702`](../ui/dr-ui/src/lib.rs#L2702), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/src/library.rs:564`](../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:592`](../ui/dr-ui/src/library.rs#L592), [`ui/dr-ui/src/library_ui.rs:3283`](../ui/dr-ui/src/library_ui.rs#L3283), [`ui/dr-ui/src/library_ui.rs:3866`](../ui/dr-ui/src/library_ui.rs#L3866), [`ui/dr-ui/src/library_ui.rs:630`](../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1678`](../ui/dr-ui/ui/app.slint#L1678), [`ui/dr-ui/ui/app.slint:2510`](../ui/dr-ui/ui/app.slint#L2510), [`ui/dr-ui/ui/app.slint:3145`](../ui/dr-ui/ui/app.slint#L3145), [`ui/dr-ui/ui/app.slint:764`](../ui/dr-ui/ui/app.slint#L764), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775), [`ui/dr-ui/ui/library.slint:1579`](../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4251`](../ui/dr-ui/ui/library.slint#L4251), [`ui/dr-ui/ui/library.slint:4275`](../ui/dr-ui/ui/library.slint#L4275), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../ui/dr-ui/ui/settings.slint#L711) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:443`](../core/dr-types/src/settings.rs#L443), [`ui/dr-ui/src/develop.rs:5415`](../ui/dr-ui/src/develop.rs#L5415), [`ui/dr-ui/src/develop.rs:5436`](../ui/dr-ui/src/develop.rs#L5436), [`ui/dr-ui/src/lib.rs:1887`](../ui/dr-ui/src/lib.rs#L1887), [`ui/dr-ui/src/lib.rs:2673`](../ui/dr-ui/src/lib.rs#L2673), [`ui/dr-ui/src/lib.rs:2702`](../ui/dr-ui/src/lib.rs#L2702), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/src/library.rs:564`](../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:592`](../ui/dr-ui/src/library.rs#L592), [`ui/dr-ui/src/library_ui.rs:3283`](../ui/dr-ui/src/library_ui.rs#L3283), [`ui/dr-ui/src/library_ui.rs:3866`](../ui/dr-ui/src/library_ui.rs#L3866), [`ui/dr-ui/src/library_ui.rs:630`](../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1678`](../ui/dr-ui/ui/app.slint#L1678), [`ui/dr-ui/ui/app.slint:2510`](../ui/dr-ui/ui/app.slint#L2510), [`ui/dr-ui/ui/app.slint:3145`](../ui/dr-ui/ui/app.slint#L3145), [`ui/dr-ui/ui/app.slint:764`](../ui/dr-ui/ui/app.slint#L764), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775), [`ui/dr-ui/ui/library.slint:1579`](../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4251`](../ui/dr-ui/ui/library.slint#L4251), [`ui/dr-ui/ui/library.slint:4275`](../ui/dr-ui/ui/library.slint#L4275), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../ui/dr-ui/ui/settings.slint#L711) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:4334`](../ui/dr-ui/src/develop.rs#L4334), [`ui/dr-ui/src/develop.rs:4375`](../ui/dr-ui/src/develop.rs#L4375), [`ui/dr-ui/src/develop.rs:5571`](../ui/dr-ui/src/develop.rs#L5571), [`ui/dr-ui/src/develop.rs:5588`](../ui/dr-ui/src/develop.rs#L5588), [`ui/dr-ui/src/develop.rs:5700`](../ui/dr-ui/src/develop.rs#L5700), [`ui/dr-ui/src/develop.rs:5732`](../ui/dr-ui/src/develop.rs#L5732), [`ui/dr-ui/src/develop.rs:6664`](../ui/dr-ui/src/develop.rs#L6664), [`ui/dr-ui/src/develop.rs:6781`](../ui/dr-ui/src/develop.rs#L6781), [`ui/dr-ui/src/develop.rs:797`](../ui/dr-ui/src/develop.rs#L797), [`ui/dr-ui/src/lib.rs:1997`](../ui/dr-ui/src/lib.rs#L1997), [`ui/dr-ui/src/lib.rs:2069`](../ui/dr-ui/src/lib.rs#L2069), [`ui/dr-ui/src/lib.rs:2083`](../ui/dr-ui/src/lib.rs#L2083), [`ui/dr-ui/src/lib.rs:3079`](../ui/dr-ui/src/lib.rs#L3079), [`ui/dr-ui/src/lib.rs:3140`](../ui/dr-ui/src/lib.rs#L3140), [`ui/dr-ui/src/lib.rs:3212`](../ui/dr-ui/src/lib.rs#L3212), [`ui/dr-ui/src/lib.rs:410`](../ui/dr-ui/src/lib.rs#L410), [`ui/dr-ui/ui/app.slint:131`](../ui/dr-ui/ui/app.slint#L131), [`ui/dr-ui/ui/app.slint:2417`](../ui/dr-ui/ui/app.slint#L2417), [`ui/dr-ui/ui/app.slint:2432`](../ui/dr-ui/ui/app.slint#L2432), [`ui/dr-ui/ui/app.slint:2752`](../ui/dr-ui/ui/app.slint#L2752), [`ui/dr-ui/ui/history.slint:102`](../ui/dr-ui/ui/history.slint#L102), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:226`](../ui/dr-ui/ui/history.slint#L226), [`ui/dr-ui/ui/history.slint:53`](../ui/dr-ui/ui/history.slint#L53), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | | FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:1059`](../core/dr-pipeline/src/graph.rs#L1059), [`core/dr-pipeline/src/graph.rs:115`](../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:247`](../core/dr-pipeline/src/graph.rs#L247), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:664`](../core/dr-pipeline/src/operation.rs#L664), [`core/dr-pipeline/src/sidecar.rs:1071`](../core/dr-pipeline/src/sidecar.rs#L1071), [`core/dr-pipeline/src/sidecar.rs:1125`](../core/dr-pipeline/src/sidecar.rs#L1125), [`core/dr-pipeline/src/sidecar.rs:1155`](../core/dr-pipeline/src/sidecar.rs#L1155), [`core/dr-pipeline/src/sidecar.rs:219`](../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:395`](../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:888`](../core/dr-pipeline/src/sidecar.rs#L888), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:3200`](../ui/dr-ui/src/develop.rs#L3200), [`ui/dr-ui/src/develop.rs:3238`](../ui/dr-ui/src/develop.rs#L3238), [`ui/dr-ui/src/develop.rs:3303`](../ui/dr-ui/src/develop.rs#L3303), [`ui/dr-ui/src/develop.rs:3386`](../ui/dr-ui/src/develop.rs#L3386), [`ui/dr-ui/src/develop.rs:3400`](../ui/dr-ui/src/develop.rs#L3400), [`ui/dr-ui/src/develop.rs:904`](../ui/dr-ui/src/develop.rs#L904), [`ui/dr-ui/src/labels.rs:80`](../ui/dr-ui/src/labels.rs#L80), [`ui/dr-ui/src/lib.rs:2027`](../ui/dr-ui/src/lib.rs#L2027), [`ui/dr-ui/src/lib.rs:3365`](../ui/dr-ui/src/lib.rs#L3365), [`ui/dr-ui/src/lib.rs:452`](../ui/dr-ui/src/lib.rs#L452), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/app.slint:2273`](../ui/dr-ui/ui/app.slint#L2273), [`ui/dr-ui/ui/app.slint:2475`](../ui/dr-ui/ui/app.slint#L2475), [`ui/dr-ui/ui/app.slint:3051`](../ui/dr-ui/ui/app.slint#L3051), [`ui/dr-ui/ui/icons.slint:366`](../ui/dr-ui/ui/icons.slint#L366), [`ui/dr-ui/ui/session.slint:102`](../ui/dr-ui/ui/session.slint#L102), [`ui/dr-ui/ui/session.slint:52`](../ui/dr-ui/ui/session.slint#L52), [`ui/dr-ui/ui/spots.slint:191`](../ui/dr-ui/ui/spots.slint#L191), [`ui/dr-ui/ui/spots.slint:49`](../ui/dr-ui/ui/spots.slint#L49), [`ui/dr-ui/ui/spots.slint:6`](../ui/dr-ui/ui/spots.slint#L6), [`ui/dr-ui/ui/spots.slint:91`](../ui/dr-ui/ui/spots.slint#L91) | | FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2366`](../core/dr-gpu/src/adjust.rs#L2366), [`core/dr-gpu/src/adjust.rs:2443`](../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:2528`](../core/dr-gpu/src/adjust.rs#L2528), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:798`](../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:174`](../core/dr-gpu/src/lib.rs#L174), [`core/dr-gpu/src/lib.rs:194`](../core/dr-gpu/src/lib.rs#L194), [`core/dr-gpu/src/lib.rs:72`](../core/dr-gpu/src/lib.rs#L72), [`core/dr-gpu/src/lib.rs:95`](../core/dr-gpu/src/lib.rs#L95), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:889`](../core/dr-pipeline/src/graph.rs#L889), [`core/dr-pipeline/src/graph.rs:919`](../core/dr-pipeline/src/graph.rs#L919), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:4021`](../ui/dr-ui/src/develop.rs#L4021), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:6026`](../ui/dr-ui/src/develop.rs#L6026), [`ui/dr-ui/src/develop.rs:7354`](../ui/dr-ui/src/develop.rs#L7354), [`ui/dr-ui/src/develop.rs:7388`](../ui/dr-ui/src/develop.rs#L7388), [`ui/dr-ui/src/lib.rs:1025`](../ui/dr-ui/src/lib.rs#L1025), [`ui/dr-ui/src/lib.rs:1084`](../ui/dr-ui/src/lib.rs#L1084), [`ui/dr-ui/src/lib.rs:99`](../ui/dr-ui/src/lib.rs#L99) | @@ -92,13 +92,13 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:4105`](../ui/dr-ui/src/develop.rs#L4105), [`ui/dr-ui/src/develop.rs:804`](../ui/dr-ui/src/develop.rs#L804), [`ui/dr-ui/src/develop.rs:8641`](../ui/dr-ui/src/develop.rs#L8641), [`ui/dr-ui/src/develop.rs:8673`](../ui/dr-ui/src/develop.rs#L8673), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:2107`](../ui/dr-ui/src/lib.rs#L2107), [`ui/dr-ui/src/lib.rs:429`](../ui/dr-ui/src/lib.rs#L429), [`ui/dr-ui/ui/histogram.slint:184`](../ui/dr-ui/ui/histogram.slint#L184), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:3997`](../ui/dr-ui/src/develop.rs#L3997), [`ui/dr-ui/src/develop.rs:4051`](../ui/dr-ui/src/develop.rs#L4051), [`ui/dr-ui/src/develop.rs:8971`](../ui/dr-ui/src/develop.rs#L8971), [`ui/dr-ui/src/develop.rs:9017`](../ui/dr-ui/src/develop.rs#L9017), [`ui/dr-ui/src/develop.rs:9036`](../ui/dr-ui/src/develop.rs#L9036), [`ui/dr-ui/src/develop.rs:957`](../ui/dr-ui/src/develop.rs#L957), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1909`](../ui/dr-ui/src/lib.rs#L1909), [`ui/dr-ui/src/lib.rs:1973`](../ui/dr-ui/src/lib.rs#L1973), [`ui/dr-ui/src/lib.rs:3663`](../ui/dr-ui/src/lib.rs#L3663), [`ui/dr-ui/src/lib.rs:3756`](../ui/dr-ui/src/lib.rs#L3756), [`ui/dr-ui/ui/app.slint:1953`](../ui/dr-ui/ui/app.slint#L1953), [`ui/dr-ui/ui/app.slint:64`](../ui/dr-ui/ui/app.slint#L64), [`ui/dr-ui/ui/settings.slint:165`](../ui/dr-ui/ui/settings.slint#L165), [`ui/dr-ui/ui/settings.slint:968`](../ui/dr-ui/ui/settings.slint#L968) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:157`](../core/dr-export/src/lib.rs#L157), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:57`](../core/dr-export/src/lib.rs#L57), [`core/dr-gpu/src/adjust.rs:2676`](../core/dr-gpu/src/adjust.rs#L2676), [`core/dr-pipeline/src/graph.rs:835`](../core/dr-pipeline/src/graph.rs#L835), [`core/dr-pipeline/src/graph.rs:933`](../core/dr-pipeline/src/graph.rs#L933), [`core/dr-pipeline/src/operation.rs:512`](../core/dr-pipeline/src/operation.rs#L512), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:949`](../core/dr-types/src/settings.rs#L949), [`ui/dr-ui/src/develop.rs:9036`](../ui/dr-ui/src/develop.rs#L9036), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-3 | [`core/dr-export/src/lib.rs:186`](../core/dr-export/src/lib.rs#L186), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1002`](../core/dr-types/src/settings.rs#L1002), [`core/dr-types/src/settings.rs:1018`](../core/dr-types/src/settings.rs#L1018), [`core/dr-types/src/settings.rs:1087`](../core/dr-types/src/settings.rs#L1087), [`core/dr-types/src/settings.rs:1115`](../core/dr-types/src/settings.rs#L1115), [`core/dr-types/src/settings.rs:1126`](../core/dr-types/src/settings.rs#L1126), [`core/dr-types/src/settings.rs:1132`](../core/dr-types/src/settings.rs#L1132), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:994`](../core/dr-types/src/settings.rs#L994), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:274`](../ui/dr-ui/src/settings_ui.rs#L274), [`ui/dr-ui/src/settings_ui.rs:287`](../ui/dr-ui/src/settings_ui.rs#L287), [`ui/dr-ui/src/settings_ui.rs:302`](../ui/dr-ui/src/settings_ui.rs#L302), [`ui/dr-ui/src/settings_ui.rs:701`](../ui/dr-ui/src/settings_ui.rs#L701), [`ui/dr-ui/ui/settings.slint:851`](../ui/dr-ui/ui/settings.slint#L851), [`ui/dr-ui/ui/settings.slint:861`](../ui/dr-ui/ui/settings.slint#L861) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:157`](../core/dr-export/src/lib.rs#L157), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:57`](../core/dr-export/src/lib.rs#L57), [`core/dr-gpu/src/adjust.rs:2676`](../core/dr-gpu/src/adjust.rs#L2676), [`core/dr-pipeline/src/graph.rs:835`](../core/dr-pipeline/src/graph.rs#L835), [`core/dr-pipeline/src/graph.rs:933`](../core/dr-pipeline/src/graph.rs#L933), [`core/dr-pipeline/src/operation.rs:512`](../core/dr-pipeline/src/operation.rs#L512), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:958`](../core/dr-types/src/settings.rs#L958), [`ui/dr-ui/src/develop.rs:9036`](../ui/dr-ui/src/develop.rs#L9036), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-3 | [`core/dr-export/src/lib.rs:186`](../core/dr-export/src/lib.rs#L186), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1003`](../core/dr-types/src/settings.rs#L1003), [`core/dr-types/src/settings.rs:1011`](../core/dr-types/src/settings.rs#L1011), [`core/dr-types/src/settings.rs:1027`](../core/dr-types/src/settings.rs#L1027), [`core/dr-types/src/settings.rs:1096`](../core/dr-types/src/settings.rs#L1096), [`core/dr-types/src/settings.rs:1124`](../core/dr-types/src/settings.rs#L1124), [`core/dr-types/src/settings.rs:1135`](../core/dr-types/src/settings.rs#L1135), [`core/dr-types/src/settings.rs:1141`](../core/dr-types/src/settings.rs#L1141), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:274`](../ui/dr-ui/src/settings_ui.rs#L274), [`ui/dr-ui/src/settings_ui.rs:287`](../ui/dr-ui/src/settings_ui.rs#L287), [`ui/dr-ui/src/settings_ui.rs:302`](../ui/dr-ui/src/settings_ui.rs#L302), [`ui/dr-ui/src/settings_ui.rs:701`](../ui/dr-ui/src/settings_ui.rs#L701), [`ui/dr-ui/ui/settings.slint:851`](../ui/dr-ui/ui/settings.slint#L851), [`ui/dr-ui/ui/settings.slint:861`](../ui/dr-ui/ui/settings.slint#L861) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:606`](../ui/dr-ui/src/lib.rs#L606), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:815`](../ui/dr-ui/src/settings_ui.rs#L815) | | FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1005`](../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:267`](../ui/dr-ui/src/lib.rs#L267), [`ui/dr-ui/src/lib.rs:2812`](../ui/dr-ui/src/lib.rs#L2812), [`ui/dr-ui/src/lib.rs:606`](../ui/dr-ui/src/lib.rs#L606), [`ui/dr-ui/src/lib.rs:652`](../ui/dr-ui/src/lib.rs#L652), [`ui/dr-ui/src/lib.rs:679`](../ui/dr-ui/src/lib.rs#L679), [`ui/dr-ui/src/library_ui.rs:4277`](../ui/dr-ui/src/library_ui.rs#L4277), [`ui/dr-ui/src/library_ui.rs:721`](../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/library_ui.rs:8059`](../ui/dr-ui/src/library_ui.rs#L8059), [`ui/dr-ui/src/library_ui.rs:8136`](../ui/dr-ui/src/library_ui.rs#L8136), [`ui/dr-ui/src/library_ui.rs:8148`](../ui/dr-ui/src/library_ui.rs#L8148), [`ui/dr-ui/src/library_ui.rs:821`](../ui/dr-ui/src/library_ui.rs#L821), [`ui/dr-ui/src/library_ui.rs:878`](../ui/dr-ui/src/library_ui.rs#L878), [`ui/dr-ui/ui/app.slint:1124`](../ui/dr-ui/ui/app.slint#L1124), [`ui/dr-ui/ui/app.slint:1688`](../ui/dr-ui/ui/app.slint#L1688), [`ui/dr-ui/ui/library.slint:1588`](../ui/dr-ui/ui/library.slint#L1588), [`ui/dr-ui/ui/library.slint:4321`](../ui/dr-ui/ui/library.slint#L4321) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:346`](../core/dr-decode/src/lib.rs#L346), [`core/dr-decode/src/lib.rs:370`](../core/dr-decode/src/lib.rs#L370), [`core/dr-decode/src/lib.rs:384`](../core/dr-decode/src/lib.rs#L384), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:140`](../core/dr-export/src/lib.rs#L140), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:662`](../core/dr-types/src/settings.rs#L662), [`ui/dr-ui/src/develop.rs:4539`](../ui/dr-ui/src/develop.rs#L4539), [`ui/dr-ui/src/develop.rs:4651`](../ui/dr-ui/src/develop.rs#L4651), [`ui/dr-ui/src/develop.rs:729`](../ui/dr-ui/src/develop.rs#L729), [`ui/dr-ui/src/export.rs:474`](../ui/dr-ui/src/export.rs#L474), [`ui/dr-ui/src/export.rs:641`](../ui/dr-ui/src/export.rs#L641), [`ui/dr-ui/src/export.rs:687`](../ui/dr-ui/src/export.rs#L687), [`ui/dr-ui/src/export.rs:715`](../ui/dr-ui/src/export.rs#L715), [`ui/dr-ui/src/export.rs:839`](../ui/dr-ui/src/export.rs#L839), [`ui/dr-ui/src/export.rs:857`](../ui/dr-ui/src/export.rs#L857), [`ui/dr-ui/src/lib.rs:267`](../ui/dr-ui/src/lib.rs#L267), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/lib.rs:638`](../ui/dr-ui/src/lib.rs#L638), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:346`](../core/dr-decode/src/lib.rs#L346), [`core/dr-decode/src/lib.rs:370`](../core/dr-decode/src/lib.rs#L370), [`core/dr-decode/src/lib.rs:384`](../core/dr-decode/src/lib.rs#L384), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:140`](../core/dr-export/src/lib.rs#L140), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:671`](../core/dr-types/src/settings.rs#L671), [`ui/dr-ui/src/develop.rs:4539`](../ui/dr-ui/src/develop.rs#L4539), [`ui/dr-ui/src/develop.rs:4651`](../ui/dr-ui/src/develop.rs#L4651), [`ui/dr-ui/src/develop.rs:729`](../ui/dr-ui/src/develop.rs#L729), [`ui/dr-ui/src/export.rs:474`](../ui/dr-ui/src/export.rs#L474), [`ui/dr-ui/src/export.rs:641`](../ui/dr-ui/src/export.rs#L641), [`ui/dr-ui/src/export.rs:687`](../ui/dr-ui/src/export.rs#L687), [`ui/dr-ui/src/export.rs:715`](../ui/dr-ui/src/export.rs#L715), [`ui/dr-ui/src/export.rs:839`](../ui/dr-ui/src/export.rs#L839), [`ui/dr-ui/src/export.rs:857`](../ui/dr-ui/src/export.rs#L857), [`ui/dr-ui/src/lib.rs:267`](../ui/dr-ui/src/lib.rs#L267), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/lib.rs:638`](../ui/dr-ui/src/lib.rs#L638), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-9 | [`core/dr-decode/src/lib.rs:526`](../core/dr-decode/src/lib.rs#L526), [`core/dr-export/src/lib.rs:132`](../core/dr-export/src/lib.rs#L132), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1134`](../core/dr-gpu/src/adjust.rs#L1134), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop.rs:4672`](../ui/dr-ui/src/develop.rs#L4672), [`ui/dr-ui/src/develop.rs:4710`](../ui/dr-ui/src/develop.rs#L4710), [`ui/dr-ui/src/develop.rs:6878`](../ui/dr-ui/src/develop.rs#L6878), [`ui/dr-ui/src/lib.rs:606`](../ui/dr-ui/src/lib.rs#L606), [`ui/dr-ui/src/library.rs:3860`](../ui/dr-ui/src/library.rs#L3860), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-MRG-1 | [`core/dr-pano/src/align.rs:1`](../core/dr-pano/src/align.rs#L1), [`core/dr-pano/src/lib.rs:1`](../core/dr-pano/src/lib.rs#L1), [`ui/dr-ui/src/lib.rs:1575`](../ui/dr-ui/src/lib.rs#L1575), [`ui/dr-ui/src/merge.rs:1`](../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1693`](../ui/dr-ui/ui/app.slint#L1693), [`ui/dr-ui/ui/app.slint:828`](../ui/dr-ui/ui/app.slint#L828), [`ui/dr-ui/ui/library.slint:1596`](../ui/dr-ui/ui/library.slint#L1596), [`ui/dr-ui/ui/library.slint:4266`](../ui/dr-ui/ui/library.slint#L4266), [`ui/dr-ui/ui/merge.slint:1`](../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-10 | [`core/dr-gpu/src/merge.rs:1`](../core/dr-gpu/src/merge.rs#L1), [`core/dr-gpu/src/shaders/merge.wgsl:1`](../core/dr-gpu/src/shaders/merge.wgsl#L1), [`core/dr-pano/src/lib.rs:1`](../core/dr-pano/src/lib.rs#L1) | @@ -121,10 +121,10 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | | FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1048`](../core/dr-catalog/src/schema.rs#L1048), [`core/dr-catalog/src/schema.rs:1503`](../core/dr-catalog/src/schema.rs#L1503), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2940`](../ui/dr-ui/src/collections_ui.rs#L2940), [`ui/dr-ui/src/collections_ui.rs:3182`](../ui/dr-ui/src/collections_ui.rs#L3182), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:1349`](../ui/dr-ui/src/lib.rs#L1349), [`ui/dr-ui/src/lib.rs:2486`](../ui/dr-ui/src/lib.rs#L2486), [`ui/dr-ui/src/lib.rs:2557`](../ui/dr-ui/src/lib.rs#L2557), [`ui/dr-ui/src/lib.rs:3853`](../ui/dr-ui/src/lib.rs#L3853), [`ui/dr-ui/src/lib.rs:3854`](../ui/dr-ui/src/lib.rs#L3854), [`ui/dr-ui/src/lib.rs:3893`](../ui/dr-ui/src/lib.rs#L3893), [`ui/dr-ui/src/library.rs:2173`](../ui/dr-ui/src/library.rs#L2173), [`ui/dr-ui/src/library.rs:2196`](../ui/dr-ui/src/library.rs#L2196), [`ui/dr-ui/src/library.rs:2471`](../ui/dr-ui/src/library.rs#L2471), [`ui/dr-ui/src/library.rs:2652`](../ui/dr-ui/src/library.rs#L2652), [`ui/dr-ui/src/library.rs:2789`](../ui/dr-ui/src/library.rs#L2789), [`ui/dr-ui/src/library.rs:2804`](../ui/dr-ui/src/library.rs#L2804), [`ui/dr-ui/src/library_ui.rs:1489`](../ui/dr-ui/src/library_ui.rs#L1489), [`ui/dr-ui/src/library_ui.rs:1562`](../ui/dr-ui/src/library_ui.rs#L1562), [`ui/dr-ui/src/library_ui.rs:1663`](../ui/dr-ui/src/library_ui.rs#L1663), [`ui/dr-ui/src/library_ui.rs:1718`](../ui/dr-ui/src/library_ui.rs#L1718), [`ui/dr-ui/src/library_ui.rs:1853`](../ui/dr-ui/src/library_ui.rs#L1853), [`ui/dr-ui/src/library_ui.rs:1971`](../ui/dr-ui/src/library_ui.rs#L1971), [`ui/dr-ui/src/library_ui.rs:2738`](../ui/dr-ui/src/library_ui.rs#L2738), [`ui/dr-ui/src/library_ui.rs:344`](../ui/dr-ui/src/library_ui.rs#L344), [`ui/dr-ui/src/library_ui.rs:355`](../ui/dr-ui/src/library_ui.rs#L355), [`ui/dr-ui/src/library_ui.rs:370`](../ui/dr-ui/src/library_ui.rs#L370), [`ui/dr-ui/src/library_ui.rs:379`](../ui/dr-ui/src/library_ui.rs#L379), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:517`](../ui/dr-ui/src/library_ui.rs#L517), [`ui/dr-ui/src/library_ui.rs:527`](../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:589`](../ui/dr-ui/src/library_ui.rs#L589), [`ui/dr-ui/src/library_ui.rs:604`](../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:678`](../ui/dr-ui/src/library_ui.rs#L678), [`ui/dr-ui/src/library_ui.rs:6909`](../ui/dr-ui/src/library_ui.rs#L6909), [`ui/dr-ui/src/library_ui.rs:6927`](../ui/dr-ui/src/library_ui.rs#L6927), [`ui/dr-ui/src/library_ui.rs:709`](../ui/dr-ui/src/library_ui.rs#L709), [`ui/dr-ui/src/library_ui.rs:721`](../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:157`](../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:524`](../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:1582`](../ui/dr-ui/ui/app.slint#L1582), [`ui/dr-ui/ui/app.slint:3135`](../ui/dr-ui/ui/app.slint#L3135), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/app.slint:548`](../ui/dr-ui/ui/app.slint#L548), [`ui/dr-ui/ui/app.slint:554`](../ui/dr-ui/ui/app.slint#L554), [`ui/dr-ui/ui/collections.slint:1037`](../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262), [`ui/dr-ui/ui/settings.slint:114`](../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../ui/dr-ui/ui/settings.slint#L656) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1718`](../ui/dr-ui/src/library_ui.rs#L1718), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:1031`](../ui/dr-ui/src/derived_sync.rs#L1031), [`ui/dr-ui/src/derived_sync.rs:631`](../ui/dr-ui/src/derived_sync.rs#L631), [`ui/dr-ui/src/library.rs:1084`](../ui/dr-ui/src/library.rs#L1084), [`ui/dr-ui/src/library.rs:2293`](../ui/dr-ui/src/library.rs#L2293), [`ui/dr-ui/src/library.rs:2383`](../ui/dr-ui/src/library.rs#L2383), [`ui/dr-ui/src/library.rs:4031`](../ui/dr-ui/src/library.rs#L4031), [`ui/dr-ui/src/library_ui.rs:1489`](../ui/dr-ui/src/library_ui.rs#L1489), [`ui/dr-ui/src/library_ui.rs:1562`](../ui/dr-ui/src/library_ui.rs#L1562), [`ui/dr-ui/src/library_ui.rs:1761`](../ui/dr-ui/src/library_ui.rs#L1761), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1048`](../ui/dr-ui/src/repairs.rs#L1048), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:1031`](../ui/dr-ui/src/derived_sync.rs#L1031), [`ui/dr-ui/src/derived_sync.rs:631`](../ui/dr-ui/src/derived_sync.rs#L631), [`ui/dr-ui/src/library.rs:1084`](../ui/dr-ui/src/library.rs#L1084), [`ui/dr-ui/src/library.rs:2293`](../ui/dr-ui/src/library.rs#L2293), [`ui/dr-ui/src/library.rs:2383`](../ui/dr-ui/src/library.rs#L2383), [`ui/dr-ui/src/library.rs:4031`](../ui/dr-ui/src/library.rs#L4031), [`ui/dr-ui/src/library_ui.rs:1489`](../ui/dr-ui/src/library_ui.rs#L1489), [`ui/dr-ui/src/library_ui.rs:1562`](../ui/dr-ui/src/library_ui.rs#L1562), [`ui/dr-ui/src/library_ui.rs:1761`](../ui/dr-ui/src/library_ui.rs#L1761), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1049`](../ui/dr-ui/src/repairs.rs#L1049), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262) | | FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | | FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3911`](../ui/dr-ui/src/library.rs#L3911), [`ui/dr-ui/src/library_ui.rs:4543`](../ui/dr-ui/src/library_ui.rs#L4543), [`ui/dr-ui/ui/settings.slint:435`](../ui/dr-ui/ui/settings.slint#L435) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:368`](../core/dr-types/src/settings.rs#L368), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:377`](../core/dr-types/src/settings.rs#L377), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1558`](../ui/dr-ui/src/lib.rs#L1558) | | FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:231`](../core/dr-catalog/src/schema.rs#L231), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-pipeline/src/sidecar.rs:2991`](../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:735`](../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/sidecar.rs:96`](../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2456`](../ui/dr-ui/src/lib.rs#L2456), [`ui/dr-ui/src/library.rs:1126`](../ui/dr-ui/src/library.rs#L1126), [`ui/dr-ui/src/library.rs:2582`](../ui/dr-ui/src/library.rs#L2582), [`ui/dr-ui/src/library.rs:564`](../ui/dr-ui/src/library.rs#L564), [`ui/dr-ui/src/library.rs:7509`](../ui/dr-ui/src/library.rs#L7509), [`ui/dr-ui/src/library.rs:824`](../ui/dr-ui/src/library.rs#L824), [`ui/dr-ui/src/library_ui.rs:630`](../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../ui/dr-ui/src/presets.rs#L336) | | FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:231`](../core/dr-catalog/src/schema.rs#L231), [`core/dr-catalog/src/schema.rs:644`](../core/dr-catalog/src/schema.rs#L644), [`core/dr-catalog/src/schema.rs:991`](../core/dr-catalog/src/schema.rs#L991), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:192`](../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:219`](../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:2520`](../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:2757`](../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:2991`](../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:395`](../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:493`](../core/dr-pipeline/src/sidecar.rs#L493), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:710`](../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:735`](../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:124`](../core/dr-sync-nextcloud/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1059`](../ui/dr-ui/src/derived_sync.rs#L1059), [`ui/dr-ui/src/derived_sync.rs:602`](../ui/dr-ui/src/derived_sync.rs#L602), [`ui/dr-ui/src/derived_sync.rs:631`](../ui/dr-ui/src/derived_sync.rs#L631), [`ui/dr-ui/src/derived_sync.rs:664`](../ui/dr-ui/src/derived_sync.rs#L664), [`ui/dr-ui/src/derived_sync.rs:810`](../ui/dr-ui/src/derived_sync.rs#L810), [`ui/dr-ui/src/library.rs:1126`](../ui/dr-ui/src/library.rs#L1126), [`ui/dr-ui/src/library.rs:1146`](../ui/dr-ui/src/library.rs#L1146), [`ui/dr-ui/src/library.rs:1455`](../ui/dr-ui/src/library.rs#L1455), [`ui/dr-ui/src/library.rs:1490`](../ui/dr-ui/src/library.rs#L1490), [`ui/dr-ui/src/library.rs:2582`](../ui/dr-ui/src/library.rs#L2582), [`ui/dr-ui/src/library.rs:7341`](../ui/dr-ui/src/library.rs#L7341), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:7509`](../ui/dr-ui/src/library.rs#L7509), [`ui/dr-ui/src/library.rs:824`](../ui/dr-ui/src/library.rs#L824), [`ui/dr-ui/src/library.rs:896`](../ui/dr-ui/src/library.rs#L896), [`ui/dr-ui/src/library.rs:980`](../ui/dr-ui/src/library.rs#L980), [`ui/dr-ui/src/library_ui.rs:1346`](../ui/dr-ui/src/library_ui.rs#L1346), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../ui/dr-ui/src/xmp_sync.rs#L287) | @@ -142,13 +142,13 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-RAW-3 | [`core/dr-decode/src/lib.rs:155`](../core/dr-decode/src/lib.rs#L155), [`core/dr-decode/src/lib.rs:526`](../core/dr-decode/src/lib.rs#L526), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../core/dr-decode/src/error.rs#L30), [`ui/dr-ui/src/lib.rs:267`](../ui/dr-ui/src/lib.rs#L267) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:183`](../core/dr-decode/src/lib.rs#L183), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:734`](../core/dr-gpu/src/demosaic.rs#L734), [`core/dr-gpu/src/demosaic.rs:813`](../core/dr-gpu/src/demosaic.rs#L813), [`core/dr-gpu/src/demosaic.rs:937`](../core/dr-gpu/src/demosaic.rs#L937) | -| FR-UI-1 | [`core/dr-types/src/settings.rs:1363`](../core/dr-types/src/settings.rs#L1363), [`core/dr-types/src/settings.rs:483`](../core/dr-types/src/settings.rs#L483), [`core/dr-types/src/settings.rs:503`](../core/dr-types/src/settings.rs#L503), [`ui/dr-ui/src/lib.rs:107`](../ui/dr-ui/src/lib.rs#L107), [`ui/dr-ui/src/lib.rs:122`](../ui/dr-ui/src/lib.rs#L122), [`ui/dr-ui/src/lib.rs:144`](../ui/dr-ui/src/lib.rs#L144), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/lib.rs:4044`](../ui/dr-ui/src/lib.rs#L4044), [`ui/dr-ui/src/lib.rs:4063`](../ui/dr-ui/src/lib.rs#L4063), [`ui/dr-ui/src/lib.rs:4090`](../ui/dr-ui/src/lib.rs#L4090), [`ui/dr-ui/src/lib.rs:4141`](../ui/dr-ui/src/lib.rs#L4141), [`ui/dr-ui/src/lib.rs:4174`](../ui/dr-ui/src/lib.rs#L4174), [`ui/dr-ui/src/masks_ui.rs:1471`](../ui/dr-ui/src/masks_ui.rs#L1471), [`ui/dr-ui/src/settings_ui.rs:179`](../ui/dr-ui/src/settings_ui.rs#L179), [`ui/dr-ui/ui/adjust.slint:974`](../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2814`](../ui/dr-ui/ui/app.slint#L2814), [`ui/dr-ui/ui/app.slint:891`](../ui/dr-ui/ui/app.slint#L891), [`ui/dr-ui/ui/app.slint:995`](../ui/dr-ui/ui/app.slint#L995), [`ui/dr-ui/ui/identity.slint:246`](../ui/dr-ui/ui/identity.slint#L246), [`ui/dr-ui/ui/library.slint:1076`](../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:122`](../ui/dr-ui/ui/settings.slint#L122), [`ui/dr-ui/ui/settings.slint:681`](../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:156`](../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | +| FR-UI-1 | [`core/dr-types/src/settings.rs:1372`](../core/dr-types/src/settings.rs#L1372), [`core/dr-types/src/settings.rs:492`](../core/dr-types/src/settings.rs#L492), [`core/dr-types/src/settings.rs:512`](../core/dr-types/src/settings.rs#L512), [`ui/dr-ui/src/lib.rs:107`](../ui/dr-ui/src/lib.rs#L107), [`ui/dr-ui/src/lib.rs:122`](../ui/dr-ui/src/lib.rs#L122), [`ui/dr-ui/src/lib.rs:144`](../ui/dr-ui/src/lib.rs#L144), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/lib.rs:4044`](../ui/dr-ui/src/lib.rs#L4044), [`ui/dr-ui/src/lib.rs:4063`](../ui/dr-ui/src/lib.rs#L4063), [`ui/dr-ui/src/lib.rs:4090`](../ui/dr-ui/src/lib.rs#L4090), [`ui/dr-ui/src/lib.rs:4141`](../ui/dr-ui/src/lib.rs#L4141), [`ui/dr-ui/src/lib.rs:4174`](../ui/dr-ui/src/lib.rs#L4174), [`ui/dr-ui/src/masks_ui.rs:1471`](../ui/dr-ui/src/masks_ui.rs#L1471), [`ui/dr-ui/src/settings_ui.rs:179`](../ui/dr-ui/src/settings_ui.rs#L179), [`ui/dr-ui/ui/adjust.slint:974`](../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2814`](../ui/dr-ui/ui/app.slint#L2814), [`ui/dr-ui/ui/app.slint:891`](../ui/dr-ui/ui/app.slint#L891), [`ui/dr-ui/ui/app.slint:995`](../ui/dr-ui/ui/app.slint#L995), [`ui/dr-ui/ui/identity.slint:246`](../ui/dr-ui/ui/identity.slint#L246), [`ui/dr-ui/ui/library.slint:1076`](../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:122`](../ui/dr-ui/ui/settings.slint#L122), [`ui/dr-ui/ui/settings.slint:681`](../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:156`](../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | | FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:1197`](../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:3596`](../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:107`](../ui/dr-ui/src/lib.rs#L107), [`ui/dr-ui/src/library_ui.rs:6794`](../ui/dr-ui/src/library_ui.rs#L6794), [`ui/dr-ui/src/library_ui.rs:8167`](../ui/dr-ui/src/library_ui.rs#L8167), [`ui/dr-ui/ui/app.slint:593`](../ui/dr-ui/ui/app.slint#L593), [`ui/dr-ui/ui/app.slint:607`](../ui/dr-ui/ui/app.slint#L607), [`ui/dr-ui/ui/app.slint:71`](../ui/dr-ui/ui/app.slint#L71), [`ui/dr-ui/ui/app.slint:979`](../ui/dr-ui/ui/app.slint#L979), [`ui/dr-ui/ui/controls.slint:626`](../ui/dr-ui/ui/controls.slint#L626), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2615`](../ui/dr-ui/ui/library.slint#L2615), [`ui/dr-ui/ui/library.slint:4359`](../ui/dr-ui/ui/library.slint#L4359), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:136`](../ui/dr-ui/ui/settings.slint#L136) | | FR-UI-3 | [`ui/dr-ui/src/collections_ui.rs:209`](../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:414`](../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/develop.rs:2850`](../ui/dr-ui/src/develop.rs#L2850), [`ui/dr-ui/src/develop.rs:3238`](../ui/dr-ui/src/develop.rs#L3238), [`ui/dr-ui/src/library_ui.rs:5806`](../ui/dr-ui/src/library_ui.rs#L5806), [`ui/dr-ui/src/masks_ui.rs:1563`](../ui/dr-ui/src/masks_ui.rs#L1563), [`ui/dr-ui/src/masks_ui.rs:1585`](../ui/dr-ui/src/masks_ui.rs#L1585), [`ui/dr-ui/src/masks_ui.rs:339`](../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2357`](../ui/dr-ui/ui/app.slint#L2357), [`ui/dr-ui/ui/collections.slint:1127`](../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:1208`](../ui/dr-ui/ui/masks.slint#L1208), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | | FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1197`](../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:126`](../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1837`](../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2000`](../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:209`](../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2116`](../ui/dr-ui/src/collections_ui.rs#L2116), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:3208`](../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:3596`](../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/collections_ui.rs:3633`](../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:414`](../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/collections_ui.rs:738`](../ui/dr-ui/src/collections_ui.rs#L738), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5308`](../ui/dr-ui/src/develop.rs#L5308), [`ui/dr-ui/src/develop.rs:5319`](../ui/dr-ui/src/develop.rs#L5319), [`ui/dr-ui/src/develop.rs:5333`](../ui/dr-ui/src/develop.rs#L5333), [`ui/dr-ui/src/develop.rs:6561`](../ui/dr-ui/src/develop.rs#L6561), [`ui/dr-ui/src/develop.rs:6592`](../ui/dr-ui/src/develop.rs#L6592), [`ui/dr-ui/src/develop.rs:6613`](../ui/dr-ui/src/develop.rs#L6613), [`ui/dr-ui/src/gesture_book.rs:3`](../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1349`](../ui/dr-ui/src/lib.rs#L1349), [`ui/dr-ui/src/lib.rs:1943`](../ui/dr-ui/src/lib.rs#L1943), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2557`](../ui/dr-ui/src/lib.rs#L2557), [`ui/dr-ui/src/lib.rs:2617`](../ui/dr-ui/src/lib.rs#L2617), [`ui/dr-ui/src/lib.rs:3285`](../ui/dr-ui/src/lib.rs#L3285), [`ui/dr-ui/src/lib.rs:3854`](../ui/dr-ui/src/lib.rs#L3854), [`ui/dr-ui/src/lib.rs:4278`](../ui/dr-ui/src/lib.rs#L4278), [`ui/dr-ui/src/lib.rs:459`](../ui/dr-ui/src/lib.rs#L459), [`ui/dr-ui/src/lib.rs:488`](../ui/dr-ui/src/lib.rs#L488), [`ui/dr-ui/src/library.rs:2789`](../ui/dr-ui/src/library.rs#L2789), [`ui/dr-ui/src/library.rs:2804`](../ui/dr-ui/src/library.rs#L2804), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:527`](../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:5806`](../ui/dr-ui/src/library_ui.rs#L5806), [`ui/dr-ui/src/library_ui.rs:5818`](../ui/dr-ui/src/library_ui.rs#L5818), [`ui/dr-ui/src/library_ui.rs:5888`](../ui/dr-ui/src/library_ui.rs#L5888), [`ui/dr-ui/src/library_ui.rs:5998`](../ui/dr-ui/src/library_ui.rs#L5998), [`ui/dr-ui/src/library_ui.rs:6026`](../ui/dr-ui/src/library_ui.rs#L6026), [`ui/dr-ui/src/library_ui.rs:604`](../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:6927`](../ui/dr-ui/src/library_ui.rs#L6927), [`ui/dr-ui/src/settings_ui.rs:157`](../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:524`](../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/ui/app.slint:171`](../ui/dr-ui/ui/app.slint#L171), [`ui/dr-ui/ui/app.slint:2052`](../ui/dr-ui/ui/app.slint#L2052), [`ui/dr-ui/ui/app.slint:2174`](../ui/dr-ui/ui/app.slint#L2174), [`ui/dr-ui/ui/app.slint:2581`](../ui/dr-ui/ui/app.slint#L2581), [`ui/dr-ui/ui/app.slint:2714`](../ui/dr-ui/ui/app.slint#L2714), [`ui/dr-ui/ui/app.slint:3185`](../ui/dr-ui/ui/app.slint#L3185), [`ui/dr-ui/ui/app.slint:569`](../ui/dr-ui/ui/app.slint#L569), [`ui/dr-ui/ui/app.slint:598`](../ui/dr-ui/ui/app.slint#L598), [`ui/dr-ui/ui/app.slint:607`](../ui/dr-ui/ui/app.slint#L607), [`ui/dr-ui/ui/app.slint:711`](../ui/dr-ui/ui/app.slint#L711), [`ui/dr-ui/ui/collections.slint:75`](../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2615`](../ui/dr-ui/ui/library.slint#L2615), [`ui/dr-ui/ui/library.slint:4213`](../ui/dr-ui/ui/library.slint#L4213), [`ui/dr-ui/ui/library.slint:4300`](../ui/dr-ui/ui/library.slint#L4300), [`ui/dr-ui/ui/library.slint:4359`](../ui/dr-ui/ui/library.slint#L4359), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/settings.slint:114`](../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../ui/dr-ui/ui/settings.slint#L656) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:3330`](../ui/dr-ui/src/lib.rs#L3330), [`ui/dr-ui/src/lib.rs:4238`](../ui/dr-ui/src/lib.rs#L4238), [`ui/dr-ui/src/lib.rs:4429`](../ui/dr-ui/src/lib.rs#L4429), [`ui/dr-ui/src/masks_ui.rs:1518`](../ui/dr-ui/src/masks_ui.rs#L1518), [`ui/dr-ui/ui/app.slint:2541`](../ui/dr-ui/ui/app.slint#L2541), [`ui/dr-ui/ui/app.slint:2581`](../ui/dr-ui/ui/app.slint#L2581), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/session.slint:75`](../ui/dr-ui/ui/session.slint#L75) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:483`](../core/dr-types/src/settings.rs#L483), [`ui/dr-ui/src/develop.rs:1330`](../ui/dr-ui/src/develop.rs#L1330), [`ui/dr-ui/src/lib.rs:4063`](../ui/dr-ui/src/lib.rs#L4063), [`ui/dr-ui/ui/adjust.slint:974`](../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2297`](../ui/dr-ui/ui/app.slint#L2297), [`ui/dr-ui/ui/settings.slint:681`](../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:492`](../core/dr-types/src/settings.rs#L492), [`ui/dr-ui/src/develop.rs:1330`](../ui/dr-ui/src/develop.rs#L1330), [`ui/dr-ui/src/lib.rs:4063`](../ui/dr-ui/src/lib.rs#L4063), [`ui/dr-ui/ui/adjust.slint:974`](../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2297`](../ui/dr-ui/ui/app.slint#L2297), [`ui/dr-ui/ui/settings.slint:681`](../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | | FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:883`](../ui/dr-ui/src/derived_sync.rs#L883), [`ui/dr-ui/src/derived_sync.rs:893`](../ui/dr-ui/src/derived_sync.rs#L893), [`ui/dr-ui/src/derived_sync.rs:975`](../ui/dr-ui/src/derived_sync.rs#L975), [`ui/dr-ui/src/library.rs:1187`](../ui/dr-ui/src/library.rs#L1187), [`ui/dr-ui/src/library.rs:4663`](../ui/dr-ui/src/library.rs#L4663), [`ui/dr-ui/src/library.rs:5431`](../ui/dr-ui/src/library.rs#L5431), [`ui/dr-ui/src/library.rs:5497`](../ui/dr-ui/src/library.rs#L5497), [`ui/dr-ui/src/library_ui.rs:1014`](../ui/dr-ui/src/library_ui.rs#L1014), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2199`](../ui/dr-ui/src/library_ui.rs#L2199), [`ui/dr-ui/src/library_ui.rs:2388`](../ui/dr-ui/src/library_ui.rs#L2388), [`ui/dr-ui/src/library_ui.rs:2405`](../ui/dr-ui/src/library_ui.rs#L2405), [`ui/dr-ui/src/library_ui.rs:2503`](../ui/dr-ui/src/library_ui.rs#L2503), [`ui/dr-ui/src/library_ui.rs:5839`](../ui/dr-ui/src/library_ui.rs#L5839), [`ui/dr-ui/src/library_ui.rs:5879`](../ui/dr-ui/src/library_ui.rs#L5879), [`ui/dr-ui/src/library_ui.rs:5908`](../ui/dr-ui/src/library_ui.rs#L5908), [`ui/dr-ui/src/library_ui.rs:6171`](../ui/dr-ui/src/library_ui.rs#L6171), [`ui/dr-ui/src/library_ui.rs:6394`](../ui/dr-ui/src/library_ui.rs#L6394), [`ui/dr-ui/src/library_ui.rs:7148`](../ui/dr-ui/src/library_ui.rs#L7148), [`ui/dr-ui/src/library_ui.rs:935`](../ui/dr-ui/src/library_ui.rs#L935), [`ui/dr-ui/src/library_ui.rs:968`](../ui/dr-ui/src/library_ui.rs#L968), [`ui/dr-ui/src/place.rs:1`](../ui/dr-ui/src/place.rs#L1) | | NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:171`](../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/app.slint:2714`](../ui/dr-ui/ui/app.slint#L2714), [`ui/dr-ui/ui/app.slint:2752`](../ui/dr-ui/ui/app.slint#L2752) | | NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:761`](../ui/dr-ui/ui/library.slint#L761), [`ui/dr-ui/ui/library.slint:909`](../ui/dr-ui/ui/library.slint#L909), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1) | @@ -177,7 +177,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1057`](../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:4512`](../ui/dr-ui/src/develop.rs#L4512), [`ui/dr-ui/src/identity_ui.rs:125`](../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:99`](../ui/dr-ui/src/lib.rs#L99), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| NFR-RES-2 | [`ui/dr-ui/src/faces.rs:447`](../ui/dr-ui/src/faces.rs#L447), [`ui/dr-ui/src/library.rs:3595`](../ui/dr-ui/src/library.rs#L3595), [`ui/dr-ui/src/repairs.rs:1048`](../ui/dr-ui/src/repairs.rs#L1048), [`ui/dr-ui/src/repairs.rs:1`](../ui/dr-ui/src/repairs.rs#L1) | +| NFR-RES-2 | [`ui/dr-ui/src/faces.rs:447`](../ui/dr-ui/src/faces.rs#L447), [`ui/dr-ui/src/library.rs:3595`](../ui/dr-ui/src/library.rs#L3595), [`ui/dr-ui/src/repairs.rs:1049`](../ui/dr-ui/src/repairs.rs#L1049), [`ui/dr-ui/src/repairs.rs:1`](../ui/dr-ui/src/repairs.rs#L1) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:1048`](../core/dr-catalog/src/schema.rs#L1048), [`core/dr-gpu/src/lib.rs:95`](../core/dr-gpu/src/lib.rs#L95), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../core/dr-decode/src/error.rs#L30) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:304`](../core/dr-sync/src/account.rs#L304), [`platform/dr-plat/src/crash.rs:1`](../platform/dr-plat/src/crash.rs#L1), [`platform/dr-plat/src/diagnostics.rs:1`](../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/diagnostics/bundle.rs:1`](../platform/dr-plat/src/diagnostics/bundle.rs#L1), [`platform/dr-plat/src/diagnostics/redact.rs:1`](../platform/dr-plat/src/diagnostics/redact.rs#L1), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | diff --git a/ui/dr-ui/examples/face_index.rs b/ui/dr-ui/examples/face_index.rs index 747ba20..82535b5 100644 --- a/ui/dr-ui/examples/face_index.rs +++ b/ui/dr-ui/examples/face_index.rs @@ -60,7 +60,19 @@ fn main() { } }; - let audit = match faces::audit(&catalog, &store, MODEL_ID, false) { + // The registry a device without the eye models would run: what this + // example counts as work is what the app would. + let repairs = dr_ui::repairs::registry( + dr_ui::repairs::Scope::Outstanding, + MODEL_ID, + dr_types::FaceDetector::Scrfd500m, + dr_ui::repairs::Capabilities { + gpu: true, + face_models: true, + eye_models: false, + }, + ); + let audit = match faces::audit(&catalog, &store, MODEL_ID, &repairs) { Ok(a) => a, Err(e) => { eprintln!("coverage check failed: {e}"); @@ -227,7 +239,7 @@ fn main() { } } - if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID, false) { + if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID, &repairs) { println!("{}", a.summary()); } println!("\nrun again with --cluster to group these faces into people."); diff --git a/ui/dr-ui/src/derived_sync.rs b/ui/dr-ui/src/derived_sync.rs index 9bcd91c..04032b8 100644 --- a/ui/dr-ui/src/derived_sync.rs +++ b/ui/dr-ui/src/derived_sync.rs @@ -175,7 +175,7 @@ pub fn spawn_sync( } // Eight, because a sync touches eight distinct things — the same reason -// `spawn_face_sweep` carries the allow: bundling them into a struct would name +// `repairs::spawn` carries the allow: bundling them into a struct would name // nothing that exists. #[allow(clippy::too_many_arguments)] async fn run( diff --git a/ui/dr-ui/src/faces.rs b/ui/dr-ui/src/faces.rs index 5294b5b..ed37df6 100644 --- a/ui/dr-ui/src/faces.rs +++ b/ui/dr-ui/src/faces.rs @@ -132,7 +132,7 @@ pub fn faces_outstanding( // proxy exists, the job requests one at background priority". An // earlier comment here read it the other way round, and the result was // a whole-library button that could only reach photographs the user had - // personally zoomed into. `library::spawn_face_sweep` is that + // personally zoomed into. `repairs::spawn` is that // requirement implemented; this one is the local-only variant. .filter(|req| store.contains(req.file_id, FACE_TIER)) .collect(); @@ -144,7 +144,7 @@ pub fn faces_outstanding( /// The catalog can say how many images have been through the model; only this /// layer can say *why* the rest have not, because the reason usually lives in /// the thumbnail store rather than the catalog. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct IndexAudit { pub coverage: faces::Coverage, /// Outstanding, with a proxy already on disk. @@ -158,13 +158,17 @@ pub struct IndexAudit { /// ones above now do. pub awaiting_proxy: u64, /// TRACES: FR-CULL-8a - /// Faces this model found that the measuring pass still has to read: - /// stored without their quality, or — on a device with the eye models — - /// without an eye reading. Work that is not visible in the coverage - /// figure, since every one of these images carries its run marker, and - /// that has to be counted here or the screen calls the library finished - /// and takes the button away that would finish it. - pub unmeasured: u64, + /// What each repair in the registry still lists, by its label + /// (`crate::repairs::counts`) — the faces stored without their quality, + /// without an eye reading on a device with the eye models, without a + /// crop; the images a weaker detector indexed. Work that is not visible + /// in the coverage figure, since every one of these images carries its + /// run marker, and that has to be counted here or the screen calls the + /// library finished and takes the button away that would finish it. + /// + /// Detection's own entry is left out: it is the outstanding figure + /// above, split by proxy. + pub owed: Vec<(&'static str, u64)>, } impl IndexAudit { @@ -199,21 +203,27 @@ impl IndexAudit { if outstanding > 0 { s.push_str(&format!("; {outstanding} to index")); } - if self.unmeasured > 0 { - s.push_str(&format!( - "; {} face(s) to read for quality or eye state", - self.unmeasured - )); + for (label, n) in &self.owed { + if *n > 0 { + s.push_str(&format!("; {n} {label}")); + } } s } + /// Whether anything but detection is left: the state an already-indexed + /// library is in the day the eye models arrive, where the button reads + /// "Read eye state" rather than promising to index. + pub fn has_repairs(&self) -> bool { + self.owed.iter().any(|(_, n)| *n > 0) + } + /// Whether the sweep has nothing left to do — nothing to index *and* /// nothing to measure. The screen hides the button on this, so it has to /// be false while the measuring pass has work, or the eye readings of an /// already-indexed library could never be filled in. pub fn is_complete(&self) -> bool { - self.coverage.is_complete() && self.unmeasured == 0 + self.coverage.is_complete() && !self.has_repairs() } } @@ -223,18 +233,23 @@ impl IndexAudit { /// and the one to run before deciding whether to start a sweep. Cheap: two /// counts and one indexed scan, no decoding and no inference. /// -/// `eyes` is whether this device has the eye models, and it decides whether -/// a face without an eye reading counts as work — see -/// `dr_catalog::faces::faces_unmeasured`. +/// `repairs` is the registry this device would run (`crate::repairs:: +/// registry`), which decides what counts as work: a device without the eye +/// models has no eye repair and so lists no faces to read. pub fn audit( catalog: &Catalog, store: &ThumbStore, model_id: &str, - eyes: bool, + repairs: &[crate::repairs::Repair], ) -> Result { let conn = catalog.connection(); let coverage = faces::coverage(conn, model_id)?; - let unmeasured = faces::faces_unmeasured(conn, model_id, eyes)?; + let owed = crate::repairs::counts(catalog, store, repairs)? + .into_iter() + .zip(repairs.iter()) + .filter(|(_, r)| r.name != "face-detection") + .map(|(c, _)| c) + .collect(); // Split the outstanding set by whether a proxy exists. This is the query // `faces_outstanding` runs without the store filter, so the two cannot @@ -271,7 +286,7 @@ pub fn audit( coverage, ready, awaiting_proxy: awaiting, - unmeasured, + owed, }) } @@ -545,17 +560,17 @@ pub fn index_native( /// What re-embedding the faces already on one image produced. #[derive(Debug, Default, PartialEq)] pub struct Measured { - pub measured: Vec, + pub measured: Vec, /// Faces whose stored landmarks no longer make a warp. See - /// `dr_catalog::faces::record_measurements` for what becomes of them. + /// `dr_catalog::faces::record_updates` for what becomes of them. pub dropped: Vec, } /// TRACES: FR-CULL-8 | FR-CULL-9 /// Embed the faces already found on one image again, from its native render. /// -/// The measuring half of the sweep, for faces stored before their quality was -/// kept (schema V14). No detector: the boxes and landmarks in the catalog are +/// The `face-quality` repair (`crate::repairs`), for faces stored before their +/// quality was kept (schema V14). No detector: the boxes and landmarks in the catalog are /// taken as read, scaled back from the long edge they were normalised to, and /// each face is warped out of the native frame and embedded exactly as /// [`index_native`] would have done on the day. What comes back is the raw @@ -616,12 +631,10 @@ pub fn measure_native( long_edge, ) }; - out.measured.push(faces::Measurement { - face: f.id, - embedding: embedded.to_f16_bytes(), - quality: embedded.quality, - eyes, - landmarks_dense, + out.measured.push(faces::FaceUpdate { + embedding: Some((embedded.to_f16_bytes(), embedded.quality)), + eyes: eyes.map(|e| (e, landmarks_dense)), + ..faces::FaceUpdate::for_face(f.id) }); } Ok(out) @@ -714,7 +727,7 @@ fn reduce_to(rgba: &[u8], width: usize, height: usize, target: usize) -> (usize, /// than a generalisation of it: this one takes a box already in native /// coordinates, and blurring that distinction is how a crop ends up sampled /// from the wrong scale. -fn cut_crop_native( +pub(crate) fn cut_crop_native( px: dr_face::Pixels<'_>, width: usize, height: usize, @@ -766,7 +779,7 @@ fn normalise_landmarks(lm: &[(f32, f32); 5], long_edge: f32) -> [(f32, f32); 5] /// Index every image whose proxy is **already on this disk**, in the background. /// -/// Not the whole-library pass — that is `library::spawn_face_sweep`, which +/// Not the whole-library pass — that is `repairs::spawn`, which /// fetches what it has not got. This one never touches the network, which makes /// it the right shape for a tool run against a local store (see /// `examples/face_index.rs`) and the wrong shape for a user pressing "index my @@ -846,7 +859,7 @@ pub fn spawn_store_face_sweep( // single image as failed: twenty thousand refusals that all say the // same thing. The pass is not repairable here either, because there is // no larger tier for it to read; the pixels it needs have to come off - // the server, which is `library::spawn_face_sweep`'s job. + // the server, which is `repairs::spawn`'s job. if FACE_TIER.edge() < dr_face::MIN_CROP_EDGE { log::warn!( "face sweep: the local store's largest tier is {}px, below the {}px \ @@ -1640,12 +1653,12 @@ mod tests { assert_eq!(out.measured.len(), 1); let m = &out.measured[0]; assert_eq!(m.face, faces::FaceId(7)); - assert!(m.quality > 0.0); - let (_, length) = dr_face::read_f16_bytes(model, &m.embedding).expect("decode"); + let (embedding, quality) = m.embedding.as_ref().expect("a vector"); + assert!(*quality > 0.0); + let (_, length) = dr_face::read_f16_bytes(model, embedding).expect("decode"); assert!( - (length - m.quality).abs() < 0.05 * m.quality, - "stored length {length} against reported quality {}", - m.quality + (length - quality).abs() < 0.05 * quality, + "stored length {length} against reported quality {quality}" ); // Degenerate landmarks -- five points on one spot, which no @@ -1681,7 +1694,7 @@ mod tests { }, ready: 30, awaiting_proxy: 10, - unmeasured: 0, + owed: Vec::new(), }; let s = a.summary(); assert!(s.contains("60/100"), "{s}"); @@ -1707,7 +1720,7 @@ mod tests { }, ready: 0, awaiting_proxy: 0, - unmeasured: 0, + owed: Vec::new(), }; let s = a.summary(); assert!(!s.contains("ready"), "{s}"); @@ -1732,11 +1745,19 @@ mod tests { }, ready: 0, awaiting_proxy: 0, - unmeasured: 4, + owed: vec![ + ("images with faces to read for quality", 4), + ("images with faces to read for eye state", 0), + ], }; assert!(a.coverage.is_complete()); assert!(!a.is_complete()); - assert!(a.summary().contains("4 face(s) to read"), "{}", a.summary()); + assert!( + a.summary() + .ends_with("; 4 images with faces to read for quality"), + "{}", + a.summary() + ); } /// The figure the real library actually produced: 110 of 23,528 rounds to @@ -1752,7 +1773,7 @@ mod tests { }, ready: 69, awaiting_proxy: 23_349, - unmeasured: 0, + owed: Vec::new(), }; let s = a.summary(); assert!(s.contains("0.5%"), "{s}"); @@ -1770,7 +1791,7 @@ mod tests { }, ready: 99_999, awaiting_proxy: 0, - unmeasured: 0, + owed: Vec::new(), }; assert!(a.summary().contains("<0.1%"), "{}", a.summary()); } diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 73ef482..0ec61ec 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -382,7 +382,8 @@ fn fill_covers( /// Cheap enough to call on every open and after every sweep: two counts and one /// indexed scan, no decoding and no inference. /// `eyes` is whether this device has the eye models: with them, faces with -/// no eye reading are work the sweep has left (`crate::faces::audit`). +/// no eye reading are work the job has left (`crate::faces::audit`), and +/// the registry the count is taken from is the one the job would run. pub fn refresh_coverage( window: &AppWindow, catalog: &Rc>>, @@ -395,7 +396,18 @@ pub fn refresh_coverage( window.set_identity_coverage(Default::default()); return; }; - match crate::faces::audit(cat, store, model_id, eyes) { + let detector = dr_types::FaceDetector::for_model_id(model_id).unwrap_or_default(); + let repairs = crate::repairs::registry( + crate::repairs::Scope::Outstanding, + model_id, + detector, + crate::repairs::Capabilities { + gpu: true, + face_models: true, + eye_models: eyes, + }, + ); + match crate::faces::audit(cat, store, model_id, &repairs) { Ok(a) => { window.set_identity_coverage(a.summary().into()); // Complete means nothing left to index or measure, not "every @@ -404,7 +416,7 @@ pub fn refresh_coverage( window.set_identity_coverage_complete(a.is_complete()); // Detection done, readings outstanding: the button names the // pass it will run rather than promising to index. - window.set_identity_coverage_read_only(a.coverage.is_complete() && a.unmeasured > 0); + window.set_identity_coverage_read_only(a.coverage.is_complete() && a.has_repairs()); } Err(e) => { log::warn!("identity: coverage check: {e}"); @@ -518,7 +530,7 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { /// /// A connection and not just paths, because the pass fetches its own pixels: an /// image with no proxy is the ordinary case, not one to skip (see -/// `library::spawn_face_sweep`). +/// `repairs::spawn`). pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf); /// The detector and embedder files, when both are present — and the eye @@ -1087,7 +1099,12 @@ pub fn wire( }); } - { + // One launcher behind two buttons. "Index faces" and "Re-index every + // face" differ only in which images the pass visits (`FaceSweepScope`); + // the models, the progress, the activity row and the Stop button are the + // same, and a second copy of this closure would be a second place for + // them to disagree. + let launch: Rc = { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); @@ -1097,7 +1114,8 @@ pub fn wire( let gpu = gpu.clone(); let settings_for_sweep = settings.clone(); let eyes_available = eyes_available.clone(); - window.on_identity_index(move || { + let activity = activity.clone(); + Rc::new(move |scope: crate::repairs::Scope| { let Some(w) = weak.upgrade() else { return }; if ctl.sweep.borrow().is_some() { return; @@ -1116,30 +1134,38 @@ pub fn wire( return; }; + let reindex = scope == crate::repairs::Scope::Reindex; ctl.progress.set((0, 0)); ctl.faces_found.set(0); ctl.sweep_failed.set(0); - *ctl.activity.borrow_mut() = - Some(activity.begin(crate::activity::Kind::Index, "Indexing faces")); - *ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep( + *ctl.activity.borrow_mut() = Some(activity.begin( + crate::activity::Kind::Index, + if reindex { + "Re-indexing faces" + } else { + "Indexing faces" + }, + )); + *ctl.sweep.borrow_mut() = Some(crate::repairs::spawn( conn, catalog_path, store_dir, - models, + Some(models), model_id(&settings_for_sweep), - settings_for_sweep - .snapshot() - .faces - .detector - .supersedes() - .iter() - .map(|m| m.to_string()) - .collect(), + settings_for_sweep.snapshot().faces.detector, + scope, dr_face::DetectOptions::default(), - gpu, + Some(gpu), )); w.set_identity_indexing(true); - w.set_identity_indexing_status("looking for images to index…".into()); + w.set_identity_indexing_status( + if reindex { + "looking for images to detect again…" + } else { + "looking for images to index…" + } + .into(), + ); // Polled rather than pushed: the worker is a plain thread with an // mpsc channel, and a timer on the UI thread keeps every Slint @@ -1249,7 +1275,18 @@ pub fn wire( // A Slint timer stops when it drops, so it has to outlive this // callback. park_timer(timer); - }); + }) + }; + + { + let launch = launch.clone(); + window.on_identity_index(move || launch(crate::repairs::Scope::Outstanding)); + } + + { + // TRACES: FR-CULL-8 | FR-CULL-10 + let launch = launch.clone(); + window.on_identity_reindex(move || launch(crate::repairs::Scope::Reindex)); } { diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 5d9ae7a..7681a86 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -56,6 +56,7 @@ mod preset_store; mod presets; mod recovery_ui; mod remote; +pub mod repairs; mod segmentation; mod settings_store; mod settings_ui; diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index b232847..40b42ff 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -3331,7 +3331,7 @@ fn encode_preview(file_id: u64, preview: &dr_decode::Preview) -> Option(futures: impl IntoIterator) -> Vec +pub(crate) async fn futures_join_all(futures: impl IntoIterator) -> Vec where F: std::future::Future, { @@ -3590,7 +3590,7 @@ const SWEEP_CHUNK: usize = 96; /// real server — Nextcloud's file locking answering a plain read under /// contention we were creating ourselves. Six keeps most of the speedup /// without provoking it; the retry above covers what still slips through. -const SWEEP_LANES: usize = 6; +pub(crate) const SWEEP_LANES: usize = 6; /// TRACES: FR-CULL-8 | NFR-RES-2 /// The largest original the face sweep will fetch, in bytes. @@ -3610,7 +3610,7 @@ const SWEEP_LANES: usize = 6; /// That later pass is the real answer for a panorama — read it in tiles, /// detect in each, and stitch the boxes back — and this constant is the /// placeholder for it, not a decision that panoramas hold no faces. -const SWEEP_MAX_ORIGINAL_BYTES: u64 = 256 * 1024 * 1024; +pub(crate) const SWEEP_MAX_ORIGINAL_BYTES: u64 = 256 * 1024 * 1024; /// Date **every** image in the library, not just the ones on screen. /// @@ -3857,753 +3857,10 @@ fn flush_sweep(catalog: &Catalog, found: &mut Vec) { found.clear(); } -/// TRACES: FR-CAT-3 | FR-NC-3 | NFR-RES-4 -/// TRACES: FR-CULL-8 -/// Every visible image this model has not been run over. -/// -/// **No thumbnail-store filter.** The pass this feeds fetches its own pixels, -/// so an image with no proxy is work to be done rather than work to be skipped -/// — which is the whole difference between indexing a library and indexing the -/// fraction of it that has been browsed. -/// -/// **An image whose faces are merely unmeasured is not here.** Schema V14 -/// forgot the run marker of every such image so that *something* would look -/// at it again; [`faces_unmeasured`] is that something, and it does the cheap -/// thing — embed the faces already found — where this list would have the -/// whole detection run again. Both lists are drawn from the same catalog in -/// the same sweep, so the exclusion is in the query rather than left to the -/// caller to remember. -fn faces_unindexed( - catalog: &Catalog, - model_id: &str, -) -> Result, dr_catalog::CatalogError> { - let mut stmt = catalog.connection().prepare(&format!( - "SELECT i.id, i.source_ref, r.file_id, i.file_size - FROM images i - JOIN remote r ON r.image_id = i.id - WHERE r.file_id IS NOT NULL AND {VISIBLE} - AND NOT EXISTS ( - SELECT 1 FROM face_index fi - WHERE fi.image_id = i.id AND {fi_embedder} = ?1 - ) - AND NOT EXISTS ( - SELECT 1 FROM faces f - WHERE f.image_id = i.id AND {f_embedder} = ?1 AND f.quality IS NULL - ) - ORDER BY i.id", - fi_embedder = dr_catalog::faces::embedder_sql("fi.model_id"), - f_embedder = dr_catalog::faces::embedder_sql("f.model_id"), - ))?; - let rows = stmt - .query_map([dr_catalog::faces::embedder_of(model_id)], |r| { - Ok(ThumbnailRequest { - // Face indexing wants the detail a thumbnail discards. - full_resolution: true, - // Named because the field must say something; ignored, because - // `full_resolution` overrides it. - thumb_size: dr_thumbs::ThumbSize::Large, - // No grid cell waits on this. - row: 0, - image_id: r.get(0)?, - path: r.get(1)?, - file_id: r.get::<_, Option>(2)?.map(|v| v as u64), - size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, - // The thumbnail sweep owns dating. Reading EXIF here would - // write the same rows from a second pass for no gain. - needs_metadata: false, - }) - })? - .collect::, _>>()?; - Ok(rows) -} - -/// Images whose faces have nothing left to be cut out of. -/// -/// A face is stored normalised and drawn by cropping the proxy it was found on -/// (`identity::decode_proxy`). Where that proxy is gone the People screen draws -/// "no preview" for every cell and cannot repair itself, because the image -/// already has its `face_index` row and so is not outstanding work. -/// -/// Two ways in: an indexing pass that fetched a preview and did not keep it, -/// and an ordinary cache eviction. Both are the same state, and re-running -/// detection over the image fixes it — `record_detections` replaces rather than -/// appends, and carries the user's confirmations across the replacement, so -/// this costs a fetch and loses nothing. -fn faces_without_proxy( - catalog: &Catalog, - store: &ThumbStore, - model_id: &str, -) -> Result, dr_catalog::CatalogError> { - let mut stmt = catalog.connection().prepare(&format!( - "SELECT DISTINCT i.id, i.source_ref, r.file_id, i.file_size - FROM images i - JOIN remote r ON r.image_id = i.id - JOIN faces f ON f.image_id = i.id - WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {embedder} = ?1 - ORDER BY i.id", - embedder = dr_catalog::faces::embedder_sql("f.model_id"), - ))?; - let rows = stmt - .query_map([dr_catalog::faces::embedder_of(model_id)], |r| { - Ok(ThumbnailRequest { - full_resolution: true, - thumb_size: dr_thumbs::ThumbSize::Large, - row: 0, - image_id: r.get(0)?, - path: r.get(1)?, - file_id: r.get::<_, Option>(2)?.map(|v| v as u64), - size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, - needs_metadata: false, - }) - })? - .filter_map(Result::ok) - .filter(|req| { - req.file_id - .is_some_and(|id| !store.contains(id, dr_thumbs::ThumbSize::Large)) - }) - .collect(); - Ok(rows) -} - -/// TRACES: FR-CULL-9 -/// Images holding a face this model found before its quality was kept. -/// -/// The work list of the sweep's measuring pass: every stored face whose vector -/// is a unit one (schema V14) is embedded again from the native render, with -/// the landmarks it already has, and the raw vector and its length written -/// over it. Nothing is re-detected and no face changes identity — see -/// `dr_catalog::faces::record_measurements`. -/// -/// Costs what the indexing pass costs per image, an original fetched and -/// rendered, because the length exists only at the moment of embedding and -/// there is no embedding without the pixels. What it saves is the detector, -/// and — the part that matters — every suggestion and confirmation on those -/// faces, which a re-detection would rebuild from box overlap. -/// -/// With `eyes` — this device has the eye models — the list also names every -/// image holding a face with no eye reading (schema V16), which the same -/// pass reads from the same render. Without them it does not, or a device -/// that cannot read eyes would fetch every original to do nothing to it. -fn faces_unmeasured( - catalog: &Catalog, - model_id: &str, - eyes: bool, -) -> Result, dr_catalog::CatalogError> { - let mut stmt = catalog.connection().prepare(&format!( - "SELECT DISTINCT i.id, i.source_ref, r.file_id, i.file_size - FROM images i - JOIN remote r ON r.image_id = i.id - JOIN faces f ON f.image_id = i.id - WHERE r.file_id IS NOT NULL AND {VISIBLE} - AND {embedder} = ?1 AND {unmeasured} - ORDER BY i.id", - embedder = dr_catalog::faces::embedder_sql("f.model_id"), - unmeasured = dr_catalog::faces::unmeasured_sql("f.", eyes), - ))?; - let rows = stmt - .query_map([dr_catalog::faces::embedder_of(model_id)], |r| { - Ok(ThumbnailRequest { - full_resolution: true, - thumb_size: dr_thumbs::ThumbSize::Large, - row: 0, - image_id: r.get(0)?, - path: r.get(1)?, - file_id: r.get::<_, Option>(2)?.map(|v| v as u64), - size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, - needs_metadata: false, - }) - })? - .collect::, _>>()?; - Ok(rows) -} - -/// Images indexed by a detector the chosen one outranks. -/// -/// The tail of the sweep's work list: behind everything nothing has looked -/// at, because a photograph with no faces recorded is worth more than one -/// whose faces a weaker detector may have under-counted. `weaker` is -/// `FaceDetector::supersedes` for the current choice, and it is a list of -/// exact ids rather than "anything else sharing the embedder" so the -/// re-detection only ever runs upwards — a device set to the fast detector -/// leaves a peer's thorough pass alone. -/// -/// The user's confirmations survive the re-detection by box overlap -/// (`faces::record_detections`), which is what makes this safe to run over a -/// library that is already named. -fn faces_superseded( - catalog: &Catalog, - weaker: &[&str], -) -> Result, dr_catalog::CatalogError> { - if weaker.is_empty() { - return Ok(Vec::new()); - } - let placeholders = weaker - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", i + 1)) - .collect::>() - .join(", "); - let mut stmt = catalog.connection().prepare(&format!( - "SELECT i.id, i.source_ref, r.file_id, i.file_size - FROM images i - JOIN remote r ON r.image_id = i.id - WHERE r.file_id IS NOT NULL AND {VISIBLE} - AND EXISTS ( - SELECT 1 FROM face_index fi - WHERE fi.image_id = i.id AND fi.model_id IN ({placeholders}) - ) - ORDER BY i.id" - ))?; - let rows = stmt - .query_map(rusqlite::params_from_iter(weaker.iter()), |r| { - Ok(ThumbnailRequest { - full_resolution: true, - thumb_size: dr_thumbs::ThumbSize::Large, - row: 0, - image_id: r.get(0)?, - path: r.get(1)?, - file_id: r.get::<_, Option>(2)?.map(|v| v as u64), - size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, - needs_metadata: false, - }) - })? - .collect::, _>>()?; - Ok(rows) -} - -/// Take the originals over [`SWEEP_MAX_ORIGINAL_BYTES`] out of the work list, -/// marking each as examined so no later pass fetches it either. -/// -/// Before the fetch loop and on the byte count the catalog already holds, -/// which is the whole point: the decision costs nothing, where fetching the -/// file to decide would cost the file. Returns how many were set aside, which -/// the sweep reports as failed — not indexed, and said so. -fn set_aside_oversized( - catalog: &Catalog, - model_id: &str, - wanted: &mut Vec<(ThumbnailRequest, SweepWork)>, -) -> usize { - let mut skipped = 0usize; - wanted.retain(|(req, _)| { - if req.size <= SWEEP_MAX_ORIGINAL_BYTES { - return true; - } - log::warn!( - "face sweep: {} is {} MB, over the {} MB budget for a background fetch; \ - marked as examined and skipped", - req.path, - req.size >> 20, - SWEEP_MAX_ORIGINAL_BYTES >> 20 - ); - if let Err(e) = dr_catalog::faces::record_detections( - catalog.connection(), - dr_types::ImageId(req.image_id as u64), - model_id, - 0, - &[], - ) { - log::warn!("face sweep: marking {} as skipped: {e}", req.image_id); - } - skipped += 1; - false - }); - skipped -} - -/// What the sweep does with one fetched original. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SweepWork { - /// Detect and embed from scratch: an image never indexed, or one whose - /// faces have nothing left to be cut from. - Detect, - /// Embed the faces already found, again — see [`faces_unmeasured`]. - Measure, -} - -/// TRACES: FR-CULL-8 | FR-EXP-9 | NFR-ARCH-2 | NFR-RES-2 -/// Index faces across the **whole** library, at native resolution. -/// -/// # The two resolutions, and why they are not one -/// -/// FR-CULL-8 asks for a native render, a *reduction* for the detector, and the -/// crop taken back out of the native buffer. That is not three sizes for the -/// sake of it. The detector letterboxes whatever it is handed into a fixed -/// 640×640, so above that its input resolution decides nothing and paying for -/// it is waste; `align::warp` produces the fixed 112×112 ArcFace sees, so -/// *its* input resolution decides everything and economising there is a -/// silent loss. The two stages want opposite things, and a single buffer -/// serving both is how this pass previously came to store 47% of the -/// reference library's faces upsampled (faces.md §7b). -/// -/// # Why this replaced a pass that read an embedded preview -/// -/// The previous version range-fetched the JPEG preview embedded in each RAW -/// (FR-NC-3) and used it for both stages. It was cheap and it was the tier -/// FR-CULL-8 named at the time. On the reference library that preview tops out -/// at 3072 px against a ~6000 px sensor, which is what put those 8,505 faces -/// below the embedder's 112 px with no way to tell from the catalog that -/// anything was wrong. -/// -/// Before that it filtered its work list to images with a `ThumbSize::Large` -/// proxy already on disk, which nothing filled for a whole library, so it -/// reached 220 images out of 23,529. -/// -/// # Cost, stated plainly -/// -/// **One whole original per un-indexed image, and one full render.** On the -/// reference library that is 412 GB and roughly a hundred minutes of decode — -/// a different order of thing from the byte ranges this used to pay, which is -/// why FR-CULL-8 makes a whole-library pass a transfer under FR-NC-6 rather -/// than something that may start on its own. -/// -/// Nothing is kept that was not already wanted: the original is borrowed and -/// given back (ARCH §9.0a), and the only thing written per image is the -/// 1024 px proxy the People screen crops from, and only where a face was -/// found. Resumable by construction — the work list is what the catalog has no -/// `face_index` row for, so a kill costs the images in flight and nothing else. -#[allow(clippy::too_many_arguments)] -pub fn spawn_face_sweep( - conn: Connection, - catalog_path: PathBuf, - store_dir: PathBuf, - models: FaceModelPaths, - model_id: String, - supersedes: Vec, - options: dr_face::DetectOptions, - gpu: dr_gpu::GpuContext, -) -> Receiver { - use crate::faces::FaceSweepMessage; - let (tx, rx) = std::sync::mpsc::channel(); - - std::thread::spawn(move || { - let finish_empty = |tx: &Sender| { - let _ = tx.send(FaceSweepMessage::Finished { - images: 0, - faces: 0, - failed: 0, - }); - }; - - let catalog = match Catalog::open(&catalog_path) { - Ok(c) => c, - Err(e) => { - log::warn!("face sweep: cannot open catalog: {e}"); - finish_empty(&tx); - return; - } - }; - - // The proxy an indexed face is later *cut from*. - // - // `identity::decode_proxy` reads `FACE_TIER` out of this store to draw - // the People screen, so a face found on a preview that was fetched and - // dropped has nowhere to come from and the grid shows "no preview" for - // every cell. Fetching the pixels and not keeping them was the whole - // bug: they cost a round trip and a decode, and the crop needs them - // again the moment the user looks at the person. - let mut store = match ThumbStore::open(&store_dir) { - Ok(s) => s, - Err(e) => { - log::warn!("face sweep: cannot open the thumbnail store: {e}"); - finish_empty(&tx); - return; - } - }; - - // Models before the work list: they are the expensive failure, and - // listing twenty thousand images before discovering the weights are - // missing helps nobody. A library with no model installed takes this - // path, so it is a quiet return rather than an error. - let mut detector = match dr_face::Detector::from_path(&models.detector) { - Ok(d) => d, - Err(e) => { - log::warn!("face sweep: cannot load the detector: {e}"); - finish_empty(&tx); - return; - } - }; - let mut embedder = match dr_face::Embedder::from_path( - &models.embedder, - dr_face::ModelId::new(model_id.clone()), - ) { - Ok(e) => e, - Err(e) => { - log::warn!("face sweep: cannot load the embedder: {e}"); - finish_empty(&tx); - return; - } - }; - // Optional, and loaded after the two that are not: a library - // without them indexes faces and simply has no eye readings. - let mut eye_models = models.load_eyes(); - - // **Repairs first, and the order is the whole point.** These are the - // images the People screen is drawing *right now* and failing to, and - // there are a few hundred of them against tens of thousands of - // un-indexed ones. Appended instead, they sit two hours of fetching - // down the queue and the screen stays empty for the whole session — - // which is indistinguishable from the repair not existing. - let mut wanted: Vec<(ThumbnailRequest, SweepWork)> = - match faces_without_proxy(&catalog, &store, &model_id) { - Ok(repair) => { - if !repair.is_empty() { - log::info!( - "face sweep: repairing {} image(s) whose faces have no proxy to crop from", - repair.len() - ); - } - repair.into_iter().map(|r| (r, SweepWork::Detect)).collect() - } - Err(e) => { - log::warn!("face sweep: looking for orphaned faces: {e}"); - Vec::new() - } - }; - - // Then the faces to measure again. Behind the proxy repair and ahead - // of the rest for the same reason that one leads: these are faces the - // People screen is showing and grouping *now*, on vectors the gallery - // rule cannot act on. An image already queued for a full re-detection - // gets its quality from that, so it is not queued twice. - let mut queued: std::collections::HashSet = - wanted.iter().map(|(r, _)| r.image_id).collect(); - match faces_unmeasured(&catalog, &model_id, eye_models.is_some()) { - Ok(measure) => { - let fresh: Vec<_> = measure - .into_iter() - .filter(|r| queued.insert(r.image_id)) - .collect(); - if !fresh.is_empty() { - log::info!( - "face sweep: measuring the faces on {} image(s) stored before their quality was kept", - fresh.len() - ); - } - wanted.extend(fresh.into_iter().map(|r| (r, SweepWork::Measure))); - } - Err(e) => log::warn!("face sweep: looking for unmeasured faces: {e}"), - } - - // Disjoint from both of the above by construction: an image with faces - // recorded is not an image with no `face_index` row, and one whose - // faces are unmeasured is excluded by the query. - match faces_unindexed(&catalog, &model_id) { - Ok(fresh) => wanted.extend(fresh.into_iter().map(|r| (r, SweepWork::Detect))), - Err(e) => { - log::warn!("face sweep: {e}"); - if wanted.is_empty() { - finish_empty(&tx); - return; - } - } - } - - // Last: what a weaker detector already indexed. Everything above is - // a photograph the People screen cannot show at all; these it shows - // already, and the re-detection only adds the faces that detector - // missed. See `faces_superseded`. - let weaker: Vec<&str> = supersedes.iter().map(String::as_str).collect(); - let mut upgrades = 0usize; - match faces_superseded(&catalog, &weaker) { - Ok(older) => { - let fresh: Vec<_> = older - .into_iter() - .filter(|r| queued.insert(r.image_id)) - .collect(); - upgrades = fresh.len(); - wanted.extend(fresh.into_iter().map(|r| (r, SweepWork::Detect))); - } - Err(e) => log::warn!("face sweep: looking for images under a weaker detector: {e}"), - } - - let skipped = set_aside_oversized(&catalog, &model_id, &mut wanted); - - let total = wanted.len(); - if total == 0 { - log::info!("face sweep: every image has been through this model"); - let _ = tx.send(FaceSweepMessage::Finished { - images: 0, - faces: 0, - failed: skipped, - }); - return; - } - if upgrades > 0 { - log::info!( - "face sweep: {total} image(s) to index, {upgrades} of them indexed by a weaker detector" - ); - } else { - log::info!("face sweep: {total} image(s) to index"); - } - if tx.send(FaceSweepMessage::Total(total)).is_err() { - return; - } - - let rt = match crate::net_runtime::build() { - Ok(rt) => rt, - Err(e) => { - log::warn!("face sweep: no runtime: {e}"); - finish_empty(&tx); - return; - } - }; - - rt.block_on(async { - // Through `remote::connect`, which is the only place in the - // interface that knows whose backend this is. - let backend = match crate::remote::connect(&conn) { - Ok(b) => b, - Err(e) => { - log::warn!("face sweep: {e}"); - finish_empty(&tx); - return; - } - }; - - // `failed` starts at what the size budget already set aside, so - // the report counts them the way it counts a file that would not - // decode: not indexed, and said so. - let (mut images, mut found, mut failed) = (0usize, 0usize, skipped); - let mut offline = false; - - // TRACES: FR-NC-6c - // The same borrow the thumbnail sweep makes, and deliberately its - // own pool: the two passes run at different times, so sharing one - // would keep every file the earlier pass touched hydrated until - // the later one finished. Each gives its own back (ARCH §9.0a). - let pool = dr_sync_folder::BorrowPool::new(); - - // TRACES: NFR-RES-2 - // **Fetch wide, render narrow.** The chunk is one original per - // lane and not the 96 the preview sweep used, because the two - // passes hold different things: that one kept an 8 MB preview per - // image, this one keeps whole RAWs. Six at ~23 MB is a working set - // a phone can carry; ninety-six is not. - // - // The render is then sequential, and that is not a limitation to - // be optimised away later. There is one GPU, so concurrent renders - // would queue on it anyway, and each one materialises a native - // frame -- 96 MB for a 24 MP photograph. Overlapping them would - // multiply the one allocation that actually threatens the budget - // while buying no parallelism that exists. - for chunk in wanted.chunks(SWEEP_LANES) { - let fetched = futures_join_all(chunk.iter().map(|(req, work)| { - let backend = &*backend; - let pool = &pool; - async move { - let held = match pool.borrow(backend, &RemotePath::new(&req.path)).await { - Ok(h) => h, - Err(e) if e.indicates_offline() => { - log::info!("face sweep: {e}"); - return (req, *work, Err(FetchOutcome::Offline)); - } - Err(e) => { - log::debug!("face sweep: {}: {e}", req.path); - return (req, *work, Err(FetchOutcome::Failed)); - } - }; - - // TRACES: FR-CULL-8 - // The whole file, not FR-NC-3's byte range. The - // requirement now asks for a native render and there - // is no native render without the original -- which - // is why the pass is a transfer under FR-NC-6 and says - // so before it starts. - let id = RemoteId::Path(RemotePath::new(&req.path)); - let got = match backend.get(&id, None).await { - Ok(b) => Ok(b), - Err(e) if e.indicates_offline() => { - log::info!("face sweep: server unreachable: {e}"); - Err(FetchOutcome::Offline) - } - Err(e) => { - log::debug!("face sweep: {}: {e}", req.path); - Err(FetchOutcome::Failed) - } - }; - drop(held); - (req, *work, got) - } - })) - .await; - - let mut lane_failed = 0usize; - for (req, work, got) in fetched { - let bytes = match got { - Ok(b) => b, - Err(FetchOutcome::Offline) => { - offline = true; - continue; - } - Err(FetchOutcome::Failed) => { - lane_failed += 1; - continue; - } - }; - - if work == SweepWork::Measure { - let image = dr_types::ImageId(req.image_id as u64); - match measure_one_native( - &gpu, - &mut embedder, - eye_models.as_mut(), - &catalog, - image, - &model_id, - &bytes, - ) { - Ok(n) => { - images += 1; - found += n; - if tx - .send(FaceSweepMessage::Indexed { image, faces: n }) - .is_err() - { - log::info!("face sweep: cancelled after {images} image(s)"); - pool.release_all(&*backend).await; - return; - } - } - Err(e) => { - log::debug!("face sweep: measuring {}: {e}", req.path); - lane_failed += 1; - } - } - continue; - } - - match index_one_native( - &gpu, - &mut detector, - &mut embedder, - eye_models.as_mut(), - &bytes, - &options, - ) { - Ok((faces, edge, proxy)) => { - // Before the detections, so a kill between the two - // leaves a proxy with no faces recorded -- which - // the next pass simply re-indexes -- rather than - // faces with no proxy, which is the state that - // draws an empty grid and cannot repair itself. - if let (Some(file_id), Some(thumb)) = (req.file_id, proxy) { - store_thumbnail( - &mut store, - file_id, - dr_thumbs::ThumbSize::Large, - &thumb, - ); - } - match dr_catalog::faces::record_detections( - catalog.connection(), - dr_types::ImageId(req.image_id as u64), - &model_id, - edge, - &faces, - ) { - Ok(_) => { - images += 1; - found += faces.len(); - if tx - .send(FaceSweepMessage::Indexed { - image: dr_types::ImageId(req.image_id as u64), - faces: faces.len(), - }) - .is_err() - { - // Receiver dropped: the screen closed, - // or the user pressed Stop. Everything - // written so far stays written -- and - // everything borrowed is given back. - log::info!("face sweep: cancelled after {images} image(s)"); - pool.release_all(&*backend).await; - return; - } - } - Err(e) => { - log::warn!( - "face sweep: storing faces for {}: {e}", - req.image_id - ); - lane_failed += 1; - } - } - } - Err(e) => { - // A file the decoder cannot open fails the same - // way on every pass, and every pass fetched it - // first: a 521 MB panorama the decoder refuses - // was downloaded once per sweep, on a tablet, - // and reported at debug level where nobody saw - // it. It is marked examined with nothing found, - // so the next pass does not fetch it again; the - // zero edge is what says why, and is what a - // later "try again with a better decoder" pass - // would select on. The count still says it - // failed, because it did. - log::warn!("face sweep: {}: {e}", req.path); - if let Err(e) = dr_catalog::faces::record_detections( - catalog.connection(), - dr_types::ImageId(req.image_id as u64), - &model_id, - 0, - &[], - ) { - log::warn!( - "face sweep: marking {} as unreadable: {e}", - req.image_id - ); - } - lane_failed += 1; - } - } - } - - failed += lane_failed; - if lane_failed > 0 - && tx - .send(FaceSweepMessage::Failed { - images: lane_failed, - }) - .is_err() - { - log::info!("face sweep: cancelled after {images} image(s)"); - pool.release_all(&*backend).await; - return; - } - - if offline { - break; - } - } - - let returned = pool.release_all(&*backend).await; - if returned.released > 0 { - log::info!( - "face sweep: released {} borrowed file(s)", - returned.released - ); - } - - log::info!( - "face sweep: {found} face(s) across {images} image(s), {failed} failed{}", - if offline { ", server went away" } else { "" } - ); - let _ = tx.send(FaceSweepMessage::Finished { - images, - faces: found, - failed, - }); - }); - }); - - rx -} - /// TRACES: FR-CULL-8 | FR-EXP-9 /// Open one original for a native render, orientation applied and nothing else. /// -/// The half of [`index_one_native`] that has nothing to do with faces, exposed +/// The half of `repairs::detect` that has nothing to do with faces, exposed /// because measuring what this pass is worth means rendering the same file two /// ways and comparing the crops — see `examples/face_native.rs`. A tool that /// had to reimplement the render would be measuring its own reimplementation. @@ -4611,11 +3868,11 @@ pub fn render_native(gpu: &dr_gpu::GpuContext, bytes: &[u8]) -> Result Result { @@ -4626,150 +3883,6 @@ fn open_native( crate::open_session(gpu, bytes, &meta) } -/// Why one original did not arrive. -/// -/// Named rather than a bool because the two mean opposite things to the loop: -/// one image failing is one image, and the server going away means nothing -/// after it would have worked either. -enum FetchOutcome { - Failed, - Offline, -} - -/// TRACES: FR-CULL-8 | FR-EXP-9 -/// Render one original at native resolution and index the faces in it. -/// -/// The FR-CULL-8 pipeline end to end, for one photograph: decode, render -/// through the same path export uses, detect on a reduction, crop from the -/// native frame. Returns the faces, the native long edge that went into the -/// run marker, and the 1024px proxy the People screen later cuts thumbnails -/// from. -/// -/// # Why the stored edit is not applied -/// -/// `export::render_from_library` fetches the sidecar and applies it, because -/// an export is of the photograph the user has made. This is not: the face -/// geometry stored in the catalog is normalised to the frame, so applying a -/// crop would record faces against a frame that changes whenever the user -/// changes their mind, and every stored box would silently become wrong. What -/// is applied is the orientation, which is a fact about the file rather than -/// an edit. -fn index_one_native( - gpu: &dr_gpu::GpuContext, - detector: &mut dr_face::Detector, - embedder: &mut dr_face::Embedder, - eye_models: Option<&mut dr_face::EyeModels>, - bytes: &[u8], - options: &dr_face::DetectOptions, -) -> Result< - ( - Vec, - u32, - Option, - ), - String, -> { - let mut session = open_native(gpu, bytes)?; - let frame = session.render_for_export(dr_types::ColourSpace::Srgb)?; - - let edge = frame.width.max(frame.height); - let faces = crate::faces::index_native( - detector, - embedder, - eye_models, - &frame.rgba, - frame.width as usize, - frame.height as usize, - options, - ) - .map_err(|e| e.to_string())?; - - // Only where there is a face to cut out of it. Two thirds of a personal - // library is landscapes and documents, and those never need a crop -- so - // this fills the large class for the images the People screen will - // actually ask about and leaves the rest alone. - // - // Rendered rather than downscaled from the frame in hand: the session is - // still open and `render_thumbnail` is the path the grid's own thumbnails - // take, so the proxy this writes is the one the store would have had - // anyway. - let proxy = if faces.is_empty() { - None - } else { - match session.render_thumbnail(dr_thumbs::ThumbSize::Large.edge()) { - Ok((w, h, rgba)) => match dr_thumbs::encode_rgba(w, h, &rgba) { - Ok(bytes) => Some(dr_thumbs::Thumbnail { - width: w, - height: h, - bytes, - }), - Err(e) => { - log::debug!("encoding a face proxy: {e}"); - None - } - }, - Err(e) => { - log::debug!("rendering a face proxy: {e}"); - None - } - } - }; - - Ok((faces, edge, proxy)) -} - -/// TRACES: FR-CULL-8 | FR-CULL-9 -/// Render one original at native resolution and embed its stored faces again. -/// -/// [`index_one_native`]'s sibling for the measuring pass: the same render, no -/// detection, and the result written *over* the faces rather than in place of -/// them. Returns how many faces were measured. -/// -/// The proxy is left alone. The faces here were found on a pass that stored -/// one, or [`faces_without_proxy`] would have claimed the image first. -fn measure_one_native( - gpu: &dr_gpu::GpuContext, - embedder: &mut dr_face::Embedder, - eye_models: Option<&mut dr_face::EyeModels>, - catalog: &Catalog, - image: dr_types::ImageId, - model_id: &str, - bytes: &[u8], -) -> Result { - let faces = dr_catalog::faces::unmeasured_on_image( - catalog.connection(), - image, - model_id, - eye_models.is_some(), - ) - .map_err(|e| e.to_string())?; - if faces.is_empty() { - return Ok(0); - } - let frame = render_native(gpu, bytes)?; - let edge = frame.width.max(frame.height); - let measured = crate::faces::measure_native( - embedder, - eye_models, - &frame.rgba, - frame.width as usize, - frame.height as usize, - &faces, - ) - .map_err(|e| e.to_string())?; - let n = measured.measured.len(); - dr_catalog::faces::record_measurements( - catalog.connection(), - image, - model_id, - edge, - &measured.measured, - &measured.dropped, - ) - .map_err(|e| e.to_string())?; - Ok(n) -} - /// The class the whole-library pass fills. /// /// Grid only, deliberately. The large class is four times the transfer for a @@ -6104,8 +5217,58 @@ pub fn now_secs() -> i64 { .unwrap_or(0) } +#[cfg(test)] +pub(crate) mod test_support { + //! Fixtures shared with the modules that build on this one's catalog + //! layout -- `repairs`, whose work lists are read over the same `images` + //! and `remote` rows a scan writes. + use super::*; + use dr_sync::RemoteEntry; + + pub fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry { + RemoteEntry { + id: RemoteId::Stable(file_id), + path: RemotePath::new(path), + kind: dr_sync::EntryKind::File, + validator: dr_sync::Validator::new("v"), + size, + modified: None, + has_preview: false, + materialised: true, + } + } + + /// A catalog with `n` images, ready to file into collections. + pub fn with_images(n: usize) -> Catalog { + let catalog = Catalog::in_memory().unwrap(); + let images: Vec = (0..n) + .map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000)) + .collect(); + let result = dr_sync::ScanResult { + images, + directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], + progress: Default::default(), + sidecars: Vec::new(), + }; + persist(&catalog, "PhotosRaw", &result).unwrap(); + catalog + } + + pub fn image_ids(catalog: &Catalog) -> Vec { + let mut stmt = catalog + .connection() + .prepare("SELECT id FROM images ORDER BY source_ref") + .unwrap(); + stmt.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))) + .unwrap() + .map(Result::unwrap) + .collect() + } +} + #[cfg(test)] mod tests { + use super::test_support::*; use super::*; use dr_sync::RemoteEntry; @@ -6828,22 +5991,6 @@ mod tests { ); } - /// A catalog with `n` images, ready to file into collections. - fn with_images(n: usize) -> Catalog { - let catalog = Catalog::in_memory().unwrap(); - let images: Vec = (0..n) - .map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000)) - .collect(); - let result = dr_sync::ScanResult { - images, - directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], - progress: Default::default(), - sidecars: Vec::new(), - }; - persist(&catalog, "PhotosRaw", &result).unwrap(); - catalog - } - /// TRACES: FR-CULL-5 /// A folded burst takes rows out of the cells, the count and the range a /// shift-click resolves — all three, together. @@ -6896,290 +6043,6 @@ mod tests { ); } - fn image_ids(catalog: &Catalog) -> Vec { - let mut stmt = catalog - .connection() - .prepare("SELECT id FROM images ORDER BY source_ref") - .unwrap(); - stmt.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))) - .unwrap() - .map(Result::unwrap) - .collect() - } - - /// A face with no proxy left draws "no preview" and cannot repair itself: - /// the image has its `face_index` row, so it is not outstanding work. The - /// sweep has to pick it up by a second route. - #[test] - fn a_face_whose_proxy_is_gone_is_work_again() { - let catalog = with_images(3); - let ids = image_ids(&catalog); - // An empty store, which is the state the bug lives in: the face is - // recorded and there is nothing on disk to cut it out of. - let store_dir = - std::env::temp_dir().join(format!("dr-face-proxy-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&store_dir); - std::fs::create_dir_all(&store_dir).unwrap(); - let store = ThumbStore::open(&store_dir).unwrap(); - - let face = dr_catalog::faces::DetectedFace { - x: 0.1, - y: 0.1, - w: 0.2, - h: 0.2, - landmarks: [(0.0, 0.0); 5], - confidence: 0.9, - embedding: vec![0u8; 1024], - crop_px: 120.0, - quality: None, - eyes: None, - landmarks_dense: Vec::new(), - crop: Vec::new(), - model_id: "w600k_mbf".into(), - }; - dr_catalog::faces::record_detections( - catalog.connection(), - ids[0], - "w600k_mbf", - 1024, - std::slice::from_ref(&face), - ) - .unwrap(); - - // Indexed, so not outstanding — but with nothing to crop from. - assert!(!faces_unindexed(&catalog, "w600k_mbf") - .unwrap() - .iter() - .any(|r| r.image_id == ids[0].0 as i64)); - let repair = faces_without_proxy(&catalog, &store, "w600k_mbf").unwrap(); - assert_eq!(repair.len(), 1, "the orphaned face was not picked up"); - assert_eq!(repair[0].image_id, ids[0].0 as i64); - let _ = std::fs::remove_dir_all(&store_dir); - } - - /// Ordering is load-bearing. A repair queued behind every un-indexed image - /// in the library is a repair that does not happen inside a session, and - /// the screen it was meant to fix stays empty. - #[test] - fn repairs_are_reached_before_the_rest_of_the_library() { - let catalog = with_images(50); - let ids = image_ids(&catalog); - let store_dir = std::env::temp_dir().join(format!( - "dr-face-order-test-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&store_dir); - std::fs::create_dir_all(&store_dir).unwrap(); - let store = ThumbStore::open(&store_dir).unwrap(); - - // One image late in the library has a face and no proxy. - let face = dr_catalog::faces::DetectedFace { - x: 0.1, - y: 0.1, - w: 0.2, - h: 0.2, - landmarks: [(0.0, 0.0); 5], - confidence: 0.9, - embedding: vec![0u8; 1024], - crop_px: 120.0, - quality: None, - eyes: None, - landmarks_dense: Vec::new(), - crop: Vec::new(), - model_id: "w600k_mbf".into(), - }; - let orphan = ids[40]; - dr_catalog::faces::record_detections( - catalog.connection(), - orphan, - "w600k_mbf", - 1024, - std::slice::from_ref(&face), - ) - .unwrap(); - - let mut wanted = faces_without_proxy(&catalog, &store, "w600k_mbf").unwrap(); - wanted.extend(faces_unindexed(&catalog, "w600k_mbf").unwrap()); - - assert_eq!( - wanted.first().map(|r| r.image_id), - Some(orphan.0 as i64), - "the image the screen cannot draw must be fetched first" - ); - assert_eq!( - wanted.len(), - 50, - "49 un-indexed plus the one being repaired" - ); - let _ = std::fs::remove_dir_all(&store_dir); - } - - /// TRACES: FR-CULL-8 | NFR-RES-2 - /// A panorama over the fetch budget leaves the work list before anything - /// is fetched, and does not come back on the next pass. - #[test] - fn an_original_over_the_size_budget_is_set_aside_once() { - let catalog = Catalog::in_memory().unwrap(); - let images = vec![ - entry("PhotosRaw/IMG_4181.dng", 1, 23_230_634), - entry("PhotosRaw/IMG_4181-Pano.dng", 2, 521_218_956), - entry("PhotosRaw/IMG_4182.dng", 3, 22_841_218), - ]; - let result = dr_sync::ScanResult { - images, - directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))], - progress: Default::default(), - sidecars: Vec::new(), - }; - persist(&catalog, "PhotosRaw", &result).unwrap(); - - let mut wanted: Vec<_> = faces_unindexed(&catalog, "w600k_mbf") - .unwrap() - .into_iter() - .map(|r| (r, SweepWork::Detect)) - .collect(); - assert_eq!(wanted.len(), 3); - - let skipped = set_aside_oversized(&catalog, "w600k_mbf", &mut wanted); - assert_eq!(skipped, 1); - assert_eq!(wanted.len(), 2, "the two camera files are still work"); - assert!( - wanted.iter().all(|(r, _)| !r.path.contains("Pano")), - "the panorama must not be fetched" - ); - - // Marked, so the next pass does not offer it again: the point is to - // spend the half gigabyte never, not once per sweep. - let again = faces_unindexed(&catalog, "w600k_mbf").unwrap(); - assert_eq!(again.len(), 2); - assert!(again.iter().all(|r| !r.path.contains("Pano"))); - } - - /// The regression this whole pass exists for. - /// - /// The previous work list intersected with the thumbnail store, so a - /// library nobody had zoomed into produced an empty one and "index the - /// whole library" indexed nothing. Nothing here puts a proxy on disk. - #[test] - fn every_unindexed_image_is_work_even_with_no_proxy_anywhere() { - let catalog = with_images(10); - let wanted = faces_unindexed(&catalog, "w600k_mbf").unwrap(); - assert_eq!( - wanted.len(), - 10, - "a library with no thumbnails is still work" - ); - assert!( - wanted.iter().all(|r| r.full_resolution), - "the pass must keep the detail a thumbnail would throw away" - ); - } - - /// `face_index` records that detection *ran*, so an image with no face in - /// it must not come back on the next pass — otherwise a personal library, - /// which is mostly landscapes and documents, never finishes. - #[test] - fn an_image_already_run_over_is_not_work_again() { - let catalog = with_images(3); - let ids = image_ids(&catalog); - dr_catalog::faces::record_detections(catalog.connection(), ids[0], "w600k_mbf", 1024, &[]) - .unwrap(); - - let wanted = faces_unindexed(&catalog, "w600k_mbf").unwrap(); - assert_eq!(wanted.len(), 2); - assert!(!wanted.iter().any(|r| r.image_id == ids[0].0 as i64)); - - // A different model has seen none of them. - assert_eq!(faces_unindexed(&catalog, "other").unwrap().len(), 3); - } - - /// A detector change keeps the embedder, so it is not a new library: the - /// images the old detector ran over are not "unindexed" for the new one. - /// They are an *upgrade*, listed separately and only when the chosen - /// detector outranks the one that indexed them — never the other way, - /// or a tablet on the fast detector would undo the desktop's thorough - /// pass. - #[test] - fn a_stronger_detector_upgrades_rather_than_re_indexes() { - let catalog = with_images(3); - let ids = image_ids(&catalog); - let conn = catalog.connection(); - dr_catalog::faces::record_detections(conn, ids[0], "w600k_mbf", 1024, &[]).unwrap(); - dr_catalog::faces::record_detections(conn, ids[1], "scrfd_10g+w600k_mbf", 1024, &[]) - .unwrap(); - - let fresh = faces_unindexed(&catalog, "scrfd_10g+w600k_mbf").unwrap(); - assert_eq!( - fresh.len(), - 1, - "the earlier detector's images were re-queued" - ); - assert_eq!(fresh[0].image_id, ids[2].0 as i64); - - // Thorough re-runs what Fast did; Fast leaves what Thorough did alone. - let up = faces_superseded(&catalog, &["w600k_mbf", "scrfd_2.5g+w600k_mbf"]).unwrap(); - assert_eq!(up.len(), 1); - assert_eq!(up[0].image_id, ids[0].0 as i64); - assert!(faces_superseded(&catalog, &[]).unwrap().is_empty()); - } - - /// The state schema V14 leaves: a face with no quality and an image with - /// no marker. It is the measuring pass's work, and *only* that pass's — a - /// full re-detection of the same image would throw away every suggestion - /// on it for nothing. - #[test] - fn an_unmeasured_face_is_measured_rather_than_re_detected() { - let catalog = with_images(3); - let ids = image_ids(&catalog); - let stored = |quality: Option| dr_catalog::faces::DetectedFace { - x: 0.1, - y: 0.1, - w: 0.2, - h: 0.2, - landmarks: [(0.0, 0.0); 5], - confidence: 0.9, - embedding: vec![0u8; 1024], - crop_px: 120.0, - quality, - eyes: None, - landmarks_dense: Vec::new(), - crop: Vec::new(), - model_id: "w600k_mbf".into(), - }; - let conn = catalog.connection(); - dr_catalog::faces::record_detections(conn, ids[0], "w600k_mbf", 4000, &[stored(None)]) - .unwrap(); - dr_catalog::faces::record_detections( - conn, - ids[1], - "w600k_mbf", - 4000, - &[stored(Some(18.0))], - ) - .unwrap(); - // What V14 does to the first: the marker goes, the face stays. - dr_catalog::faces::clear_index_marker(conn, ids[0], "w600k_mbf").unwrap(); - - let measure = faces_unmeasured(&catalog, "w600k_mbf", false).unwrap(); - assert_eq!(measure.len(), 1); - assert_eq!(measure[0].image_id, ids[0].0 as i64); - assert!(measure[0].full_resolution); - - // Not re-detected, marker or no marker; the third image, never seen, - // still is. - let detect = faces_unindexed(&catalog, "w600k_mbf").unwrap(); - assert_eq!(detect.len(), 1); - assert_eq!(detect[0].image_id, ids[2].0 as i64); - - // Neither face has an eye reading. A device that can read eyes has - // both images to measure; one that cannot still has only the first - // — it must not fetch an original to do nothing to it. - let with_eyes = faces_unmeasured(&catalog, "w600k_mbf", true).unwrap(); - assert_eq!(with_eyes.len(), 2); - assert_eq!(faces_unindexed(&catalog, "w600k_mbf").unwrap().len(), 1); - } - #[test] fn a_scoped_grid_shows_only_that_collections_images() { use dr_catalog::collections::{self as coll, CollectionKind}; @@ -8004,19 +6867,6 @@ mod tests { ); } - fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry { - RemoteEntry { - id: RemoteId::Stable(file_id), - path: RemotePath::new(path), - kind: dr_sync::EntryKind::File, - validator: dr_sync::Validator::new("v"), - size, - modified: None, - has_preview: false, - materialised: true, - } - } - // --- the local-only filter (FR-CAT-9) --------------------------------- /// Record that an image's original is held locally at `tier`. diff --git a/ui/dr-ui/src/repairs.rs b/ui/dr-ui/src/repairs.rs new file mode 100644 index 0000000..1c88138 --- /dev/null +++ b/ui/dr-ui/src/repairs.rs @@ -0,0 +1,1616 @@ +//! TRACES: FR-CULL-8 | FR-CULL-10 | FR-CAT-3 | NFR-ARCH-2 | NFR-RES-2 +//! The completeness job: what a catalog record can lack, and how to fill it. +//! +//! A library's records are never all complete at once. A face found before +//! its quality was kept has no quality; one found before the eye models +//! existed has no reading; one adopted from a peer's shard has no crop; an +//! image the fast detector examined on a 1024 px proxy has boxes the current +//! detector would not have drawn; an image the scan stat'ed has no capture +//! date. Every one of those used to be its own pass with its own work list, +//! its own button and its own idea of "done", and adding a field to a record +//! meant adding a pass. +//! +//! This module is one job over a **registry**. A [`Repair`] names one thing +//! a record can lack: the predicate that says which images still owe it, +//! the input its handler needs (a header, the original, or a native +//! render), and the handler that fills it. The job unions the predicates +//! into one work list, fetches each image once and renders it once, and +//! runs every handler whose predicate that image still matches — checked +//! again before each handler, because one handler's write satisfies the +//! next's predicate (a re-detection writes every field a per-face handler +//! would have filled). Adding a per-face field, or a per-image one, is one +//! entry in [`registry`]. +//! +//! # Convergence is the property, and the predicate is what carries it +//! +//! A repair's `needs` is the *only* definition of its work: the count the +//! settings page shows, the list the job fetches and the check before its +//! handler runs are one predicate, so a record the count reports is one the +//! job fetches, and a record the job fetches is one the handler fills — and +//! the job ends. A handler that cannot fill what its predicate lists is a +//! job that fetches the same originals on every press, which is why an eye +//! reading that cannot be cut is *not* a criterion (a face stays unread +//! however often it is detected) and why a degenerate face is dropped +//! rather than left. +//! +//! # Two scopes +//! +//! [`Scope::Outstanding`] is the converging pass behind "Index faces": +//! detection runs over what nothing has examined, and every other repair +//! over what it lists. [`Scope::Reindex`] is "Re-index every face": detection +//! runs over everything the *chosen detector* has not been over, whatever a +//! weaker one found there. Same job, one predicate differs. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; + +use dr_catalog::faces::{self, FaceUpdate}; +use dr_catalog::Catalog; +use dr_sync::{Connection, RemoteId, RemotePath}; +use dr_thumbs::ThumbStore; +use dr_types::{FaceDetector, ImageId}; + +use crate::faces::FaceSweepMessage; +use crate::library::{FaceModelPaths, MetadataFound}; + +/// What a handler needs in hand for one image, cheapest first. +/// +/// Ordered, because an image several repairs claim is fetched once at the +/// most a handler asks for: a header serves the metadata handler, and a +/// native render serves every face handler and the metadata one besides. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Input { + /// The first `dr_decode::HEADER_BYTES` of the file: EXIF, and the + /// embedded preview's offset. + Header, + /// The whole original, undecoded. + Original, + /// The whole original, rendered at native resolution through the export + /// path (FR-CULL-8's first resolution). + NativeRender, +} + +/// A set of image ids the catalog cannot compute alone -- one that asks the +/// thumbnail store. +pub type SetFn = + Box Result, dr_catalog::CatalogError>>; + +/// Which images still owe a repair. +pub enum Needs { + /// SQL over `images i JOIN remote r ON r.image_id = i.id`, true where the + /// image still owes it. Evaluated for the list, for the count, and again + /// per image before the handler runs. + Sql(String), + /// Evaluated once, at the start of the job. + Set(SetFn), +} + +/// A handler: fill one image, given what was fetched for it. +pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result; + +/// What to record for an image that can never be done. +pub type GiveUpFn = fn(&Toolkit, &Catalog, &Target) -> Result<(), String>; + +/// Why a handler could not do its work on one image. +#[derive(Debug)] +pub enum Failure { + /// The decoder refused the file. It will refuse it on every pass, so + /// the repair's `give_up` is called and the image is not fetched again. + Unreadable(String), + /// Anything else -- a catalog error, a model error. Left for the next + /// pass. + Other(String), +} + +impl From for Failure { + fn from(s: String) -> Self { + Failure::Other(s) + } +} + +/// One thing the catalog can be missing for an image, and how to fill it. +pub struct Repair { + /// For the log. + pub name: &'static str, + /// For the settings line, after a count of images: "images with faces to + /// read for quality". + pub label: &'static str, + pub needs: Needs, + pub input: Input, + /// Fill it for one image. Returns how many records it wrote -- faces, + /// for a face handler; one, for a per-image one. + pub apply: ApplyFn, + /// What to record for an image that can never be done -- over the + /// fetch budget, or refused by the decoder -- so the job does not offer + /// it again. `None` leaves the image exactly as it was, which is right + /// for a repair over records that already exist. + pub give_up: Option, +} + +/// One image on the work list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub image: ImageId, + pub path: String, + /// The thumbnail store's key (`oc:fileid`). + pub file_id: Option, + pub size: u64, +} + +/// What was fetched for one image, and what has been made of it so far. +/// +/// The render is lazy and cached: the first handler that asks pays for it, +/// the rest read it. That is the whole reason the job runs handlers per +/// image rather than per repair -- a native render is the expensive step, +/// and the reference library holds three per-face fields to fill from one. +pub struct Fetched { + bytes: Vec, + input: Input, + session: Option, + frame: Option, +} + +impl Fetched { + fn new(bytes: Vec, input: Input) -> Self { + Self { + bytes, + input, + session: None, + frame: None, + } + } + + /// The bytes fetched -- the header, or the whole file, per [`Input`]. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// The open session, for a handler that wants a second render out of + /// it -- the proxy the People screen crops from. + pub fn session( + &mut self, + gpu: &dr_gpu::GpuContext, + ) -> Result<&mut crate::develop::DevelopSession, Failure> { + if self.input < Input::Original { + return Err(Failure::Other("only the header was fetched".into())); + } + if self.session.is_none() { + self.session = + Some(crate::library::open_native(gpu, &self.bytes).map_err(Failure::Unreadable)?); + } + Ok(self.session.as_mut().expect("just opened")) + } + + /// The native render, made once. + pub fn frame(&mut self, gpu: &dr_gpu::GpuContext) -> Result<&dr_export::Frame, Failure> { + if self.frame.is_none() { + let frame = self + .session(gpu)? + .render_for_export(dr_types::ColourSpace::Srgb) + .map_err(Failure::Unreadable)?; + self.frame = Some(frame); + } + Ok(self.frame.as_ref().expect("just rendered")) + } +} + +/// The models this device has loaded, handed to every face handler. +pub struct FaceModels { + pub detector: dr_face::Detector, + pub embedder: dr_face::Embedder, + /// `None` on a device without them: it detects and embeds, and its + /// faces have no eye reading until a device that has them measures. + pub eyes: Option, +} + +/// Everything a handler may need besides the catalog and the image. +pub struct Toolkit { + /// `None` on a build with no adapter. Every handler that renders needs + /// it, and says so by failing rather than by being left out of the + /// registry: the registry is built from [`Capabilities`], which is + /// where a missing GPU takes those repairs out. + pub gpu: Option, + pub models: Option, + /// The pipeline id this device indexes under. + pub model_id: String, + pub store: ThumbStore, + pub options: dr_face::DetectOptions, +} + +impl Toolkit { + fn gpu(&self) -> Result<&dr_gpu::GpuContext, Failure> { + self.gpu + .as_ref() + .ok_or_else(|| Failure::Other("no GPU to render with".into())) + } + + fn models(&mut self) -> Result<&mut FaceModels, Failure> { + self.models + .as_mut() + .ok_or_else(|| Failure::Other("face models not loaded".into())) + } +} + +/// What this device can do, which decides which repairs are registered. +/// +/// A repair a device cannot perform is left out rather than listed and +/// skipped, and that is not tidiness: a repair in the registry is a count +/// on the settings page and a set of originals the job will fetch, and a +/// device without the eye models must not fetch every original in the +/// library to do nothing to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Capabilities { + pub gpu: bool, + pub face_models: bool, + pub eye_models: bool, +} + +/// Which images the job visits. See the module note. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Outstanding, + Reindex, +} + +const VISIBLE: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NULL"; + +/// The registry: every repair this device can do, in the order the job +/// runs them over one image -- and the order the work list is built in, +/// so an image only the last repair lists comes after one the first does. +/// +/// The order is deliberate. Under [`Scope::Outstanding`]: the faces the +/// People screen is drawing now and cannot (no proxy) first, then the ones +/// it is grouping on vectors the gallery rule cannot act on, then what +/// nothing has examined, then what a weaker detector examined; metadata +/// last, since the timeline is the grid's own concern and the grid fills +/// it as it browses. Under [`Scope::Reindex`] detection leads, because +/// after it the per-face repairs have nothing left to do on that image. +/// +/// Within the list, detection runs before the per-face repairs on any one +/// image, for the same reason: a detection writes every field they fill. +pub fn registry( + scope: Scope, + model_id: &str, + detector: FaceDetector, + caps: Capabilities, +) -> Vec { + let mut out = Vec::new(); + let embedder = faces::embedder_of(model_id).to_string(); + let fi_embedder = faces::embedder_sql("fi.model_id"); + let f_embedder = faces::embedder_sql("f.model_id"); + let faces_here = format!( + "EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {f_embedder} = '{embedder}')" + ); + let marker_under = |ids: &[&str]| { + let list = ids + .iter() + .map(|id| format!("'{id}'")) + .collect::>() + .join(", "); + format!("EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND fi.model_id IN ({list}))") + }; + let face_needing = |pred: &str| { + format!( + "EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {f_embedder} = '{embedder}' AND ({pred}))" + ) + }; + + let can_detect = caps.gpu && caps.face_models; + + if can_detect && scope == Scope::Reindex { + // Keyed on the chosen detector, in both its forms, where everything + // else in this subsystem keys on the embedder. The sweep converges + // on coverage -- has anything looked -- and treats a face a weaker + // detector found on a proxy as found. The re-index converges on + // provenance: every box, landmark, crop and vector from the current + // detector over the native render, because those are what every + // later per-face pass reads (docs/faces.md §17.4a). + out.push(Repair { + name: "face-detection", + label: "images to detect faces in with the chosen detector", + needs: Needs::Sql(format!("NOT {}", marker_under(&detector.model_ids()))), + input: Input::NativeRender, + apply: detect, + // Faces already there stay: a re-detection with nothing found + // would delete them, and "cannot fetch" is not "no faces". + give_up: None, + }); + } + + if can_detect && scope == Scope::Outstanding { + // Faces with nothing left to be cut out of: the proxy an indexing + // pass fetched and did not keep, or an ordinary eviction. The + // People screen draws "no preview" for every cell and cannot repair + // itself, because the image has its marker. + let embedder_for_set = embedder.clone(); + out.push(Repair { + name: "face-proxy", + label: "images whose faces have no proxy to draw", + needs: Needs::Set(Box::new(move |catalog, store| { + faces_without_proxy(catalog, store, &embedder_for_set) + })), + input: Input::NativeRender, + apply: detect, + give_up: None, + }); + } + + if can_detect { + out.push(Repair { + name: "face-quality", + label: "images with faces to read for quality", + needs: Needs::Sql(face_needing(NEEDS_QUALITY)), + input: Input::NativeRender, + apply: quality, + give_up: None, + }); + } + if can_detect && caps.eye_models { + out.push(Repair { + name: "face-eyes", + label: "images with faces to read for eye state", + needs: Needs::Sql(face_needing(NEEDS_EYES)), + input: Input::NativeRender, + apply: eyes, + give_up: None, + }); + } + if caps.gpu { + out.push(Repair { + name: "face-crop", + label: "images with faces without a crop", + needs: Needs::Sql(face_needing(NEEDS_CROP)), + input: Input::NativeRender, + apply: crop, + give_up: None, + }); + } + + if can_detect && scope == Scope::Outstanding { + // What nothing has examined. `face_index` records that detection + // *ran*, and an image with no face in it must not come back on the + // next pass -- otherwise a personal library, which is mostly + // landscapes and documents, never finishes. An image holding this + // embedder's faces but no marker is the state V14 left, and it is + // `face-quality`'s work, not this one's: a re-detection would carry + // the identities across by matching, where the update keeps them. + out.push(Repair { + name: "face-detection", + label: "images to index", + needs: Needs::Sql(format!( + "NOT EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND {fi_embedder} = '{embedder}') + AND NOT {faces_here}" + )), + input: Input::NativeRender, + apply: detect, + // An examination that found nothing is the honest record for an + // image that cannot be examined, and it is what stops the half + // gigabyte being spent once per sweep. + give_up: Some(mark_examined_empty), + }); + let weaker = detector.supersedes(); + if !weaker.is_empty() { + // Images a weaker detector indexed. Only ever upwards: a device + // set to the fast detector leaves a peer's thorough pass alone. + out.push(Repair { + name: "face-upgrade", + label: "images indexed by a weaker detector", + needs: Needs::Sql(marker_under(weaker)), + input: Input::NativeRender, + apply: detect, + give_up: None, + }); + } + } + + // Not a face at all, and here to say that this is not a face job. The + // grid dates images as it browses and the thumbnail sweep dates the + // rest; an image neither has reached is one this pass has the header + // of anyway, or can fetch for 256 KB. + out.push(Repair { + name: "metadata", + label: "images without capture metadata", + needs: Needs::Sql("i.metadata_state < 2".into()), + input: Input::Header, + apply: metadata, + give_up: None, + }); + + out +} + +/// The per-face predicates, over `faces f`. Named once each because the +/// registry lists by them and the handler selects by them, and the two +/// agreeing is what makes a repair converge. +const NEEDS_QUALITY: &str = "f.quality IS NULL"; +const NEEDS_EYES: &str = "(f.eye_right IS NULL OR f.landmarks_dense IS NULL)"; +const NEEDS_CROP: &str = "f.crop IS NULL"; + +// ── the handlers ────────────────────────────────────────────────────────── + +/// Detect and embed from scratch, replacing the image's faces and carrying +/// their identities across (`faces::record_detections`). +fn detect( + tk: &mut Toolkit, + catalog: &Catalog, + target: &Target, + fetched: &mut Fetched, +) -> Result { + let gpu = tk.gpu()?.clone(); + let options = tk.options; + let model_id = tk.model_id.clone(); + let frame = fetched.frame(&gpu)?; + let edge = frame.width.max(frame.height); + let models = tk.models()?; + let found = crate::faces::index_native( + &mut models.detector, + &mut models.embedder, + models.eyes.as_mut(), + &frame.rgba, + frame.width as usize, + frame.height as usize, + &options, + ) + .map_err(|e| Failure::Other(e.to_string()))?; + + // The proxy the People screen crops from, only where there is a face to + // cut out of it, and before the detections: a kill between the two + // leaves a proxy with no faces recorded -- which the next pass simply + // re-indexes -- rather than faces with no proxy, which is the state + // that draws an empty grid and cannot repair itself. + if !found.is_empty() { + if let Some(file_id) = target.file_id { + match fetched + .session(&gpu)? + .render_thumbnail(dr_thumbs::ThumbSize::Large.edge()) + { + Ok((w, h, rgba)) => match dr_thumbs::encode_rgba(w, h, &rgba) { + Ok(bytes) => { + crate::library::store_thumbnail( + &mut tk.store, + file_id, + dr_thumbs::ThumbSize::Large, + &dr_thumbs::Thumbnail { + width: w, + height: h, + bytes, + }, + ); + } + Err(e) => log::debug!("encoding a face proxy: {e}"), + }, + Err(e) => log::debug!("rendering a face proxy: {e}"), + } + } + } + + faces::record_detections(catalog.connection(), target.image, &model_id, edge, &found) + .map_err(|e| Failure::Other(e.to_string()))?; + Ok(found.len()) +} + +/// Mark an image examined with nothing found, at a zero edge that says why. +fn mark_examined_empty(tk: &Toolkit, catalog: &Catalog, target: &Target) -> Result<(), String> { + faces::record_detections(catalog.connection(), target.image, &tk.model_id, 0, &[]) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// TRACES: FR-CULL-9 +/// Embed a face again from the native render, for the raw vector and its +/// length (schema V14). +/// +/// The size and sharpness gates are deliberately not re-applied. They +/// decide whether a face is worth *storing*, and these are stored; what is +/// being established now is how much the model can make of each, which is +/// the quality itself, and a face that would have failed a gate is precisely +/// one that should come out short and stop vouching for anyone. +fn quality( + tk: &mut Toolkit, + catalog: &Catalog, + target: &Target, + fetched: &mut Fetched, +) -> Result { + let owed = faces::faces_needing( + catalog.connection(), + target.image, + &tk.model_id, + NEEDS_QUALITY, + ) + .map_err(|e| Failure::Other(e.to_string()))?; + if owed.is_empty() { + return Ok(0); + } + let gpu = tk.gpu()?.clone(); + let frame = fetched.frame(&gpu)?; + let edge = frame.width.max(frame.height); + let models = tk.models()?; + // The eyes are read on the same warp where the device can, for the + // faces that have none: the pixels are in hand, and it spares the eye + // repair a second claim on this image. + let measured = crate::faces::measure_native( + &mut models.embedder, + models.eyes.as_mut(), + &frame.rgba, + frame.width as usize, + frame.height as usize, + &owed, + ) + .map_err(|e| Failure::Other(e.to_string()))?; + let n = measured.measured.len(); + faces::record_updates( + catalog.connection(), + target.image, + &tk.model_id, + edge, + &measured.measured, + &measured.dropped, + ) + .map_err(|e| Failure::Other(e.to_string()))?; + Ok(n) +} + +/// TRACES: FR-CULL-8a +/// Read a face's eyes and dense landmarks from the native render, with the +/// box and five landmarks it already has (schema V16, V18). +fn eyes( + tk: &mut Toolkit, + catalog: &Catalog, + target: &Target, + fetched: &mut Fetched, +) -> Result { + let owed = faces::faces_needing(catalog.connection(), target.image, &tk.model_id, NEEDS_EYES) + .map_err(|e| Failure::Other(e.to_string()))?; + if owed.is_empty() { + return Ok(0); + } + let gpu = tk.gpu()?.clone(); + let frame = fetched.frame(&gpu)?; + let edge = frame.width.max(frame.height); + let long_edge = edge as f32; + let Some(models) = tk.models()?.eyes.as_mut() else { + return Err(Failure::Other("eye models not loaded".into())); + }; + let mut updates = Vec::new(); + for f in &owed { + let mut landmarks = [(0.0_f32, 0.0_f32); 5]; + for (o, &(x, y)) in landmarks.iter_mut().zip(f.landmarks.iter()) { + *o = (x * long_edge, y * long_edge); + } + let bbox = ( + f.x * long_edge, + f.y * long_edge, + (f.x + f.w) * long_edge, + (f.y + f.h) * long_edge, + ); + match models.read( + dr_face::Pixels::Rgba8(&frame.rgba), + frame.width as usize, + frame.height as usize, + bbox, + &landmarks, + ) { + Ok(Some((reading, dense))) => updates.push(FaceUpdate { + eyes: Some((reading, dense.to_packed_bytes(long_edge))), + ..FaceUpdate::for_face(f.id) + }), + // Nothing could be cut: the face stays unread, and is not + // listed again by anything -- see the module note. + Ok(None) => {} + Err(e) => log::debug!("eye reading failed: {e}"), + } + } + let n = updates.len(); + if n > 0 { + faces::record_updates( + catalog.connection(), + target.image, + &tk.model_id, + edge, + &updates, + &[], + ) + .map_err(|e| Failure::Other(e.to_string()))?; + } + Ok(n) +} + +/// Cut a face's crop out of the native render, for the People screen. +fn crop( + tk: &mut Toolkit, + catalog: &Catalog, + target: &Target, + fetched: &mut Fetched, +) -> Result { + let owed = faces::faces_needing(catalog.connection(), target.image, &tk.model_id, NEEDS_CROP) + .map_err(|e| Failure::Other(e.to_string()))?; + if owed.is_empty() { + return Ok(0); + } + let gpu = tk.gpu()?.clone(); + let frame = fetched.frame(&gpu)?; + let edge = frame.width.max(frame.height); + let long_edge = edge as f32; + let updates: Vec = owed + .iter() + .filter_map(|f| { + let cut = crate::faces::cut_crop_native( + dr_face::Pixels::Rgba8(&frame.rgba), + frame.width as usize, + frame.height as usize, + ( + f.x * long_edge, + f.y * long_edge, + f.w * long_edge, + f.h * long_edge, + ), + )?; + Some(FaceUpdate { + crop: Some(cut), + ..FaceUpdate::for_face(f.id) + }) + }) + .collect(); + let n = updates.len(); + if n > 0 { + faces::record_updates( + catalog.connection(), + target.image, + &tk.model_id, + edge, + &updates, + &[], + ) + .map_err(|e| Failure::Other(e.to_string()))?; + } + Ok(n) +} + +/// TRACES: FR-CAT-3 +/// Read the capture metadata out of the header and promote the image. +/// +/// What the thumbnail sweep does per image, as a repair: a real date takes +/// the image to `metadata_state = 2`, and a file that genuinely has none is +/// marked examined too, so it is not fetched again -- the state the sweep +/// records for the dateless, for the same reason. +fn metadata( + _tk: &mut Toolkit, + catalog: &Catalog, + target: &Target, + fetched: &mut Fetched, +) -> Result { + let found = match dr_decode::metadata(fetched.bytes()) { + Ok(md) => MetadataFound { + image_id: target.image.0 as i64, + captured_at: md.captured_at, + captured_offset: md.captured_offset, + camera: crate::library::camera_label(md.make.as_deref(), md.model.as_deref()), + lens: md.lens.map(|l| l.trim().to_string()), + iso: md.iso, + }, + Err(e) => { + log::debug!("metadata for {}: {e}", target.path); + MetadataFound { + image_id: target.image.0 as i64, + captured_at: None, + captured_offset: None, + camera: None, + lens: None, + iso: None, + } + } + }; + let dated = found.captured_at.is_some(); + crate::library::write_metadata(catalog, std::slice::from_ref(&found)) + .map_err(|e| Failure::Other(e.to_string()))?; + if !dated { + catalog + .connection() + .execute( + "UPDATE images SET metadata_state = 2 WHERE id = ?1", + [target.image.0 as i64], + ) + .map_err(|e| Failure::Other(e.to_string()))?; + } + Ok(1) +} + +// ── the scan ────────────────────────────────────────────────────────────── + +/// Images holding this embedder's faces whose proxy is not in the store. +fn faces_without_proxy( + catalog: &Catalog, + store: &ThumbStore, + embedder: &str, +) -> Result, dr_catalog::CatalogError> { + let mut stmt = catalog.connection().prepare(&format!( + "SELECT DISTINCT i.id, r.file_id + FROM images i + JOIN remote r ON r.image_id = i.id + JOIN faces f ON f.image_id = i.id + WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1", + faces::embedder_sql("f.model_id"), + ))?; + let rows = stmt + .query_map([embedder], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) + })? + .filter_map(Result::ok) + .filter(|(_, file_id)| !store.contains(*file_id as u64, dr_thumbs::ThumbSize::Large)) + .map(|(id, _)| id) + .collect(); + Ok(rows) +} + +/// The images one repair lists, in the order the job visits them: the ones +/// holding faces first -- they are what the People screen is drawing and +/// what a re-detection carries names across -- then the rest, by id. +fn listed( + catalog: &Catalog, + store: &ThumbStore, + repair: &Repair, +) -> Result, dr_catalog::CatalogError> { + let (predicate, set) = match &repair.needs { + Needs::Sql(sql) => (sql.clone(), None), + Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)), + }; + let mut stmt = catalog.connection().prepare(&format!( + "SELECT i.id, i.source_ref, r.file_id, i.file_size + FROM images i + JOIN remote r ON r.image_id = i.id + WHERE r.file_id IS NOT NULL AND {VISIBLE} AND ({predicate}) + ORDER BY EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id) DESC, i.id" + ))?; + let rows = stmt + .query_map([], |r| { + Ok(Target { + image: ImageId(r.get::<_, i64>(0)? as u64), + path: r.get(1)?, + file_id: r.get::<_, Option>(2)?.map(|v| v as u64), + size: r.get::<_, Option>(3)?.unwrap_or(0) as u64, + }) + })? + .filter_map(Result::ok) + .filter(|t| set.as_ref().is_none_or(|s| s.contains(&(t.image.0 as i64)))) + .collect(); + Ok(rows) +} + +/// Whether one image still owes a repair -- asked again before each +/// handler runs, because an earlier handler's write may have answered it. +fn still_owed( + catalog: &Catalog, + repair: &Repair, + set: Option<&HashSet>, + image: ImageId, +) -> bool { + match (&repair.needs, set) { + (Needs::Set(_), Some(s)) => s.contains(&(image.0 as i64)), + (Needs::Set(_), None) => false, + (Needs::Sql(sql), _) => catalog + .connection() + .query_row( + &format!( + "SELECT EXISTS (SELECT 1 FROM images i JOIN remote r ON r.image_id = i.id + WHERE i.id = ?1 AND ({sql}))" + ), + [image.0 as i64], + |r| r.get::<_, bool>(0), + ) + .unwrap_or(false), + } +} + +/// How many images each repair still lists, for the settings line. +pub fn counts( + catalog: &Catalog, + store: &ThumbStore, + repairs: &[Repair], +) -> Result, dr_catalog::CatalogError> { + repairs + .iter() + .map(|r| Ok((r.label, listed(catalog, store, r)?.len() as u64))) + .collect() +} + +/// One image on the work list, with the most any repair claiming it asks +/// for. +struct Planned { + target: Target, + input: Input, +} + +/// The work list, and each [`Needs::Set`] repair's set (`None` for a SQL +/// one), indexed like the registry. +type Plan = (Vec, Vec>>); + +/// The union of every repair's list, first claim first. +/// +/// The order across repairs is the registry's, so the images the first +/// repair lists come first however many the last lists -- which is what +/// puts a few hundred proxy repairs ahead of twenty thousand un-indexed +/// images, where appended they would sit two hours down the queue. +fn plan( + catalog: &Catalog, + store: &ThumbStore, + repairs: &[Repair], +) -> Result { + let mut out: Vec = Vec::new(); + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + let mut sets = Vec::with_capacity(repairs.len()); + for repair in repairs { + let set = match &repair.needs { + Needs::Set(f) => Some(f(catalog, store)?), + Needs::Sql(_) => None, + }; + let listed = listed(catalog, store, repair)?; + if !listed.is_empty() { + log::info!("repairs: {}: {} image(s)", repair.name, listed.len()); + } + for target in listed { + match at.get(&target.image.0) { + Some(&i) => out[i].input = out[i].input.max(repair.input), + None => { + at.insert(target.image.0, out.len()); + out.push(Planned { + target, + input: repair.input, + }); + } + } + } + sets.push(set); + } + Ok((out, sets)) +} + +/// Originals over this are not fetched for a repair that wants the whole +/// file. See `library::SWEEP_MAX_ORIGINAL_BYTES`. +const MAX_ORIGINAL_BYTES: u64 = crate::library::SWEEP_MAX_ORIGINAL_BYTES; + +/// How many originals are in flight at once. See `library::SWEEP_LANES`. +const LANES: usize = crate::library::SWEEP_LANES; + +// ── the job ─────────────────────────────────────────────────────────────── + +/// Run the registry over the library, at native resolution, in the +/// background. +/// +/// Every image on the union work list is fetched once -- the header, or +/// the whole original, per the most any repair claiming it asks for -- +/// rendered at most once, and handed to each repair whose predicate it +/// still matches. The receiver is the cancellation handle: dropping it +/// stops the job at the next image, and everything written stays written. +/// +/// Resumable by construction, because the work list is what the catalog +/// says is incomplete: a kill costs the images in flight and nothing else. +/// +/// # Cost, stated plainly +/// +/// One whole original per image any face repair claims, and one full +/// render. On the reference library that is 412 GB and roughly a hundred +/// minutes of decode for a whole-library pass -- which is why FR-CULL-8 +/// makes it a transfer under FR-NC-6 that starts when the user says so. +/// Nothing is kept that was not already wanted: the original is borrowed +/// and given back (ARCH §9.0a), and the only thing written per image +/// besides the catalog is the proxy the People screen crops from. +#[allow(clippy::too_many_arguments)] +pub fn spawn( + conn: Connection, + catalog_path: PathBuf, + store_dir: PathBuf, + models: Option, + model_id: String, + detector: FaceDetector, + scope: Scope, + options: dr_face::DetectOptions, + gpu: Option, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let finish_empty = |tx: &Sender| { + let _ = tx.send(FaceSweepMessage::Finished { + images: 0, + faces: 0, + failed: 0, + }); + }; + + let catalog = match Catalog::open(&catalog_path) { + Ok(c) => c, + Err(e) => { + log::warn!("repairs: cannot open catalog: {e}"); + finish_empty(&tx); + return; + } + }; + let store = match ThumbStore::open(&store_dir) { + Ok(s) => s, + Err(e) => { + log::warn!("repairs: cannot open the thumbnail store: {e}"); + finish_empty(&tx); + return; + } + }; + + // Models before the work list: they are the expensive failure, and + // listing twenty thousand images before discovering the weights are + // missing helps nobody. A library with no model installed takes + // this path, and the registry is simply shorter. + let loaded = models.as_ref().and_then(|paths| { + let detector = match dr_face::Detector::from_path(&paths.detector) { + Ok(d) => d, + Err(e) => { + log::warn!("repairs: cannot load the detector: {e}"); + return None; + } + }; + let embedder = match dr_face::Embedder::from_path( + &paths.embedder, + dr_face::ModelId::new(model_id.clone()), + ) { + Ok(e) => e, + Err(e) => { + log::warn!("repairs: cannot load the embedder: {e}"); + return None; + } + }; + Some(FaceModels { + detector, + embedder, + eyes: paths.load_eyes(), + }) + }); + let caps = Capabilities { + gpu: gpu.is_some(), + face_models: loaded.is_some(), + eye_models: loaded.as_ref().is_some_and(|m| m.eyes.is_some()), + }; + let mut tk = Toolkit { + gpu, + models: loaded, + model_id: model_id.clone(), + store, + options, + }; + let repairs = registry(scope, &model_id, detector, caps); + + let (planned, sets) = match plan(&catalog, &tk.store, &repairs) { + Ok(p) => p, + Err(e) => { + log::warn!("repairs: listing the work: {e}"); + finish_empty(&tx); + return; + } + }; + + // Over budget, decided on the byte count the catalog holds rather + // than by fetching the file to find out. Given up on where a + // repair says how, left alone otherwise, and counted as failed + // either way: not done, and said so. + let mut skipped = 0usize; + let planned: Vec = planned + .into_iter() + .filter(|p| { + if p.input == Input::Header || p.target.size <= MAX_ORIGINAL_BYTES { + return true; + } + log::warn!( + "repairs: {} is {} MB, over the {} MB budget for a background fetch; skipped", + p.target.path, + p.target.size >> 20, + MAX_ORIGINAL_BYTES >> 20 + ); + give_up(&tk, &catalog, &repairs, &sets, &p.target); + skipped += 1; + false + }) + .collect(); + + let total = planned.len(); + if total == 0 { + log::info!("repairs: nothing is incomplete"); + let _ = tx.send(FaceSweepMessage::Finished { + images: 0, + faces: 0, + failed: skipped, + }); + return; + } + log::info!("repairs: {total} image(s) to visit"); + if tx.send(FaceSweepMessage::Total(total)).is_err() { + return; + } + + let rt = match crate::net_runtime::build() { + Ok(rt) => rt, + Err(e) => { + log::warn!("repairs: no runtime: {e}"); + finish_empty(&tx); + return; + } + }; + + rt.block_on(async { + let backend = match crate::remote::connect(&conn) { + Ok(b) => b, + Err(e) => { + log::warn!("repairs: {e}"); + finish_empty(&tx); + return; + } + }; + + let (mut images, mut found, mut failed) = (0usize, 0usize, skipped); + let mut offline = false; + + // TRACES: FR-NC-6c | NFR-RES-2 + // Fetch wide, render narrow: one original per lane in flight, + // and the render sequential, because there is one GPU and each + // render materialises a native frame -- 96 MB for a 24 MP + // photograph. Every file borrowed is given back (ARCH §9.0a). + let pool = dr_sync_folder::BorrowPool::new(); + + for chunk in planned.chunks(LANES) { + let fetched = crate::library::futures_join_all(chunk.iter().map(|p| { + let backend = &*backend; + let pool = &pool; + async move { + let id = RemoteId::Path(RemotePath::new(&p.target.path)); + let got = if p.input == Input::Header { + backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await + } else { + let held = match pool + .borrow(backend, &RemotePath::new(&p.target.path)) + .await + { + Ok(h) => h, + Err(e) => return (p, Err(e)), + }; + let got = backend.get(&id, None).await; + drop(held); + got + }; + (p, got) + } + })) + .await; + + let mut lane_failed = 0usize; + for (p, got) in fetched { + let bytes = match got { + Ok(b) => b, + Err(e) if e.indicates_offline() => { + log::info!("repairs: server unreachable: {e}"); + offline = true; + continue; + } + Err(e) => { + log::debug!("repairs: {}: {e}", p.target.path); + lane_failed += 1; + continue; + } + }; + + let mut fetched = Fetched::new(bytes, p.input); + let mut wrote = 0usize; + let mut broke = false; + for (repair, set) in repairs.iter().zip(sets.iter()) { + if !still_owed(&catalog, repair, set.as_ref(), p.target.image) { + continue; + } + match (repair.apply)(&mut tk, &catalog, &p.target, &mut fetched) { + Ok(n) => wrote += n, + Err(Failure::Unreadable(e)) => { + // Fails the same way on every pass, and every + // pass fetched it first: a 521 MB panorama the + // decoder refuses was downloaded once per + // sweep, on a tablet. + log::warn!("repairs: {}: {e}", p.target.path); + give_up(&tk, &catalog, &repairs, &sets, &p.target); + broke = true; + break; + } + Err(Failure::Other(e)) => { + log::warn!("repairs: {} on {}: {e}", repair.name, p.target.path); + broke = true; + } + } + } + if broke { + lane_failed += 1; + continue; + } + images += 1; + found += wrote; + if tx + .send(FaceSweepMessage::Indexed { + image: p.target.image, + faces: wrote, + }) + .is_err() + { + // Receiver dropped: the screen closed, or Stop. + log::info!("repairs: cancelled after {images} image(s)"); + pool.release_all(&*backend).await; + return; + } + } + + failed += lane_failed; + if lane_failed > 0 + && tx + .send(FaceSweepMessage::Failed { + images: lane_failed, + }) + .is_err() + { + pool.release_all(&*backend).await; + return; + } + if offline { + break; + } + } + + let returned = pool.release_all(&*backend).await; + if returned.released > 0 { + log::info!("repairs: released {} borrowed file(s)", returned.released); + } + log::info!( + "repairs: {found} record(s) written across {images} image(s), {failed} failed{}", + if offline { ", server went away" } else { "" } + ); + let _ = tx.send(FaceSweepMessage::Finished { + images, + faces: found, + failed, + }); + }); + }); + + rx +} + +/// Record, for every repair that still claims an image and knows how, that +/// it can never be done. +fn give_up( + tk: &Toolkit, + catalog: &Catalog, + repairs: &[Repair], + sets: &[Option>], + target: &Target, +) { + for (repair, set) in repairs.iter().zip(sets.iter()) { + let Some(f) = repair.give_up else { continue }; + if !still_owed(catalog, repair, set.as_ref(), target.image) { + continue; + } + if let Err(e) = f(tk, catalog, target) { + log::warn!("repairs: giving up {} on {}: {e}", repair.name, target.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_catalog::faces::DetectedFace; + + fn with_images(n: usize) -> Catalog { + crate::library::test_support::with_images(n) + } + + fn image_ids(catalog: &Catalog) -> Vec { + crate::library::test_support::image_ids(catalog) + } + + fn store() -> (ThumbStore, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "dr-repairs-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + (ThumbStore::open(&dir).unwrap(), dir) + } + + fn face(model: &str, quality: Option, crop: Vec) -> DetectedFace { + DetectedFace { + x: 0.1, + y: 0.1, + w: 0.2, + h: 0.2, + landmarks: [(0.0, 0.0); 5], + confidence: 0.9, + embedding: vec![0u8; 1024], + crop_px: 120.0, + quality, + eyes: None, + landmarks_dense: Vec::new(), + crop, + model_id: model.into(), + } + } + + /// A face with every field a native detection writes. + fn complete(model: &str) -> DetectedFace { + DetectedFace { + eyes: Some(dr_face::EyeReading { + right: dr_face::Eye { + open: 0.9, + px: 40.0, + sharpness: 0.2, + }, + left: dr_face::Eye { + open: 0.9, + px: 40.0, + sharpness: 0.2, + }, + sunglasses: 0.1, + }), + landmarks_dense: vec![7; 424], + ..face(model, Some(20.0), vec![1]) + } + } + + const ALL: Capabilities = Capabilities { + gpu: true, + face_models: true, + eye_models: true, + }; + + fn by_name<'a>(repairs: &'a [Repair], name: &str) -> &'a Repair { + repairs.iter().find(|r| r.name == name).expect(name) + } + + fn ids_of(targets: &[Target]) -> Vec { + targets.iter().map(|t| t.image.0).collect() + } + + /// The registry is what the device can do, and no more: a device that + /// cannot read eyes lists no faces to read, or it would fetch every + /// original in the library to do nothing to it. + #[test] + fn the_registry_is_cut_to_what_the_device_can_do() { + let names = |caps: Capabilities| -> Vec<&'static str> { + registry( + Scope::Outstanding, + "scrfd_10g+w600k_mbf", + FaceDetector::Scrfd10g, + caps, + ) + .iter() + .map(|r| r.name) + .collect() + }; + assert_eq!( + names(ALL), + vec![ + "face-proxy", + "face-quality", + "face-eyes", + "face-crop", + "face-detection", + "face-upgrade", + "metadata" + ] + ); + assert!(!names(Capabilities { + eye_models: false, + ..ALL + }) + .contains(&"face-eyes")); + assert_eq!(names(Capabilities::default()), vec!["metadata"]); + // The fast detector supersedes nothing, so there is nothing to + // upgrade. + let fast = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + assert!(!fast.iter().any(|r| r.name == "face-upgrade")); + // Under a re-index, detection leads and the proxy repair is + // subsumed by it. + let re: Vec<_> = registry( + Scope::Reindex, + "scrfd_10g+w600k_mbf", + FaceDetector::Scrfd10g, + ALL, + ) + .iter() + .map(|r| r.name) + .collect(); + assert_eq!(re[0], "face-detection"); + assert!(!re.contains(&"face-proxy")); + assert!(!re.contains(&"face-upgrade")); + } + + /// The regression the whole-library pass exists for: nothing here puts + /// a proxy on disk, and every image is still work. + #[test] + fn every_unindexed_image_is_work_even_with_no_proxy_anywhere() { + let catalog = with_images(10); + let (store, dir) = store(); + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let wanted = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + assert_eq!(wanted.len(), 10); + let _ = std::fs::remove_dir_all(dir); + } + + /// `face_index` records that detection *ran*, so an image with no face + /// in it must not come back -- otherwise a personal library, which is + /// mostly landscapes and documents, never finishes. + #[test] + fn an_image_already_run_over_is_not_work_again() { + let catalog = with_images(3); + let ids = image_ids(&catalog); + let (store, dir) = store(); + faces::record_detections(catalog.connection(), ids[0], "w600k_mbf", 1024, &[]).unwrap(); + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let wanted = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + assert_eq!(ids_of(&wanted), vec![ids[1].0, ids[2].0]); + let _ = std::fs::remove_dir_all(dir); + } + + /// A detector change keeps the embedder, so it is not a new library: + /// the images the old detector ran over are an *upgrade*, listed after + /// what nothing has examined and only when the chosen detector outranks + /// the one that indexed them. + #[test] + fn a_stronger_detector_upgrades_rather_than_re_indexes() { + let catalog = with_images(3); + let ids = image_ids(&catalog); + let (store, dir) = store(); + let conn = catalog.connection(); + faces::record_detections(conn, ids[0], "w600k_mbf", 1024, &[]).unwrap(); + faces::record_detections(conn, ids[1], "scrfd_10g+w600k_mbf", 1024, &[]).unwrap(); + + let repairs = registry( + Scope::Outstanding, + "scrfd_10g+w600k_mbf", + FaceDetector::Scrfd10g, + ALL, + ); + let fresh = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + assert_eq!(ids_of(&fresh), vec![ids[2].0]); + let up = listed(&catalog, &store, by_name(&repairs, "face-upgrade")).unwrap(); + assert_eq!(ids_of(&up), vec![ids[0].0]); + + // And the plan puts the never-examined image first. + let (planned, _) = plan(&catalog, &store, &repairs).unwrap(); + let order: Vec = planned.iter().map(|p| p.target.image.0).collect(); + assert_eq!(order[..2], [ids[2].0, ids[0].0]); + // The third is the metadata repair's, and last. + assert_eq!(order[2], ids[1].0); + let _ = std::fs::remove_dir_all(dir); + } + + /// The state schema V14 leaves: a face with no quality and an image + /// with no marker. It is the quality repair's work, and *only* that + /// repair's -- a full re-detection of the same image would throw away + /// every suggestion on it for nothing. + #[test] + fn an_unmeasured_face_is_measured_rather_than_re_detected() { + let catalog = with_images(3); + let ids = image_ids(&catalog); + let (store, dir) = store(); + let conn = catalog.connection(); + faces::record_detections( + conn, + ids[0], + "w600k_mbf", + 4000, + &[face("w600k_mbf", None, vec![1])], + ) + .unwrap(); + faces::record_detections( + conn, + ids[1], + "w600k_mbf", + 4000, + &[face("w600k_mbf", Some(18.0), vec![1])], + ) + .unwrap(); + faces::clear_index_marker(conn, ids[0], "w600k_mbf").unwrap(); + + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let quality = listed(&catalog, &store, by_name(&repairs, "face-quality")).unwrap(); + assert_eq!(ids_of(&quality), vec![ids[0].0]); + let detect = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + assert_eq!( + ids_of(&detect), + vec![ids[2].0], + "the unmeasured image is not re-detected" + ); + // Neither face has an eye reading: both are the eye repair's. + let eyes = listed(&catalog, &store, by_name(&repairs, "face-eyes")).unwrap(); + assert_eq!(ids_of(&eyes), vec![ids[0].0, ids[1].0]); + + // One image, one fetch, three claims: the plan asks for the render + // once. + let (planned, _) = plan(&catalog, &store, &repairs).unwrap(); + assert_eq!( + planned.iter().filter(|p| p.target.image == ids[0]).count(), + 1 + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// The re-index is keyed on the chosen detector, not the embedder: an + /// image the fast detector examined is work, one the chosen detector + /// examined in either of its forms is not -- and it converges, because + /// a re-detection under the chosen detector takes the image off. + #[test] + fn the_re_index_lists_what_the_chosen_detector_has_not_been_over() { + let catalog = with_images(6); + let ids = image_ids(&catalog); + let (store, dir) = store(); + let conn = catalog.connection(); + let current = FaceDetector::Scrfd10g.model_ids(); + // 0: the fast detector found a face on a proxy -- work. + faces::record_detections( + conn, + ids[0], + "w600k_mbf", + 1024, + &[face("w600k_mbf", None, vec![])], + ) + .unwrap(); + // 1: the fast detector found nothing -- still work. + faces::record_detections(conn, ids[1], "w600k_mbf", 1024, &[]).unwrap(); + // 2, 3: the chosen detector, native, in each of its forms -- done. + faces::record_detections(conn, ids[2], current[0], 4000, &[complete(current[0])]).unwrap(); + faces::record_detections(conn, ids[3], current[1], 4000, &[complete(current[1])]).unwrap(); + // 4: the chosen detector's marker, but a face a peer's shard brought + // without its crop -- the crop repair's, not detection's. + faces::record_detections( + conn, + ids[4], + current[0], + 4000, + &[face(current[0], Some(20.0), vec![])], + ) + .unwrap(); + // 5: never examined -- work. + + let repairs = registry(Scope::Reindex, current[0], FaceDetector::Scrfd10g, ALL); + let detect = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + // Images holding faces first, then the rest, each in id order. + assert_eq!(ids_of(&detect), vec![ids[0].0, ids[1].0, ids[5].0]); + let crop = listed(&catalog, &store, by_name(&repairs, "face-crop")).unwrap(); + assert_eq!(ids_of(&crop), vec![ids[0].0, ids[4].0]); + + // Re-detecting the first under the chosen detector takes it off + // every list, since a detection writes every field. + faces::record_detections(conn, ids[0], current[0], 4000, &[complete(current[0])]).unwrap(); + for r in &repairs { + if r.name.starts_with("face-") { + assert!( + !still_owed(&catalog, r, None, ids[0]), + "{} still claims a re-detected image", + r.name + ); + } + } + let _ = std::fs::remove_dir_all(dir); + } + + /// The proxy repair is the one list the catalog cannot compute alone, + /// and it leads the plan: the image the screen cannot draw is fetched + /// before the rest of the library. + #[test] + fn repairs_are_reached_before_the_rest_of_the_library() { + let catalog = with_images(50); + let ids = image_ids(&catalog); + let (store, dir) = store(); + let orphan = ids[40]; + faces::record_detections( + catalog.connection(), + orphan, + "w600k_mbf", + 1024, + &[face("w600k_mbf", Some(20.0), vec![1])], + ) + .unwrap(); + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let (planned, sets) = plan(&catalog, &store, &repairs).unwrap(); + assert_eq!(planned[0].target.image, orphan); + assert_eq!( + planned.len(), + 50, + "49 un-indexed plus the one being repaired" + ); + assert!(still_owed( + &catalog, + by_name(&repairs, "face-proxy"), + sets[0].as_ref(), + orphan + )); + let _ = std::fs::remove_dir_all(dir); + } + + /// A face with no proxy left draws "no preview" and cannot repair + /// itself: the image has its `face_index` row, so it is not outstanding + /// work. The job has to pick it up by a second route. + #[test] + fn a_face_whose_proxy_is_gone_is_work_again() { + let catalog = with_images(3); + let ids = image_ids(&catalog); + // An empty store, which is the state the bug lives in: the face is + // recorded and there is nothing on disk to cut it out of. + let (store, dir) = store(); + faces::record_detections( + catalog.connection(), + ids[0], + "w600k_mbf", + 1024, + &[face("w600k_mbf", Some(20.0), vec![1])], + ) + .unwrap(); + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let detect = listed(&catalog, &store, by_name(&repairs, "face-detection")).unwrap(); + assert!( + !detect.iter().any(|t| t.image == ids[0]), + "indexed, so not outstanding" + ); + let proxy = listed(&catalog, &store, by_name(&repairs, "face-proxy")).unwrap(); + assert_eq!( + ids_of(&proxy), + vec![ids[0].0], + "the orphaned face was not picked up" + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// Metadata is a repair like any other, and a header is all it asks + /// for -- so an image both claim is fetched whole, and one only it + /// claims is not. + #[test] + fn an_image_needs_the_most_any_repair_asks_of_it() { + let catalog = with_images(2); + let ids = image_ids(&catalog); + let (store, dir) = store(); + faces::record_detections(catalog.connection(), ids[1], "w600k_mbf", 1024, &[]).unwrap(); + let repairs = registry( + Scope::Outstanding, + "w600k_mbf", + FaceDetector::Scrfd500m, + ALL, + ); + let (planned, _) = plan(&catalog, &store, &repairs).unwrap(); + let input_of = |id: ImageId| planned.iter().find(|p| p.target.image == id).unwrap().input; + assert_eq!(input_of(ids[0]), Input::NativeRender); + assert_eq!(input_of(ids[1]), Input::Header); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index cb0805c..8267239 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -448,6 +448,7 @@ export component AppWindow inherits Window { callback identity-ignore-person(int, bool); callback identity-show-photos(int, bool); callback identity-index(); + callback identity-reindex(); callback identity-stop-indexing(); callback identity-check-coverage(); callback identity-delete-all(); @@ -1440,6 +1441,7 @@ in property panel-visible: true; library-open: root.library-open; thumbnail-library() => { root.library-thumbnail-all(); } index-faces() => { root.identity-index(); } + reindex-faces() => { root.identity-reindex(); } // TRACES: NFR-OPS-1 diagnostics-preview: root.diagnostics-preview; diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 110f348..b9efb33 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -91,6 +91,12 @@ export component SettingsPage inherits Rectangle { /// No model on disk, so the pass cannot run at all. in property face-model-missing: false; callback index-faces(); + /// TRACES: FR-CULL-8 | FR-CULL-10 + /// The re-index: every image the chosen detector has not been over at + /// native resolution, detected again with names carried across. The + /// same running state as the pass above — one job, two ways to ask for + /// it — so both buttons go quiet together. + callback reindex-faces(); /// TRACES: FR-CULL-8 /// Which detector the pass finds faces with — docs/faces.md §12.3 for /// what each costs and finds. The choice is a model change: coverage is @@ -520,6 +526,49 @@ export component SettingsPage inherits Rectangle { } } + // TRACES: FR-CULL-8 | FR-CULL-10 + // The re-index, under the pass it is the heavier + // form of. Both run the completeness job + // (dr_ui::repairs) and differ in one predicate: + // indexing converges on coverage and leaves a face a + // weaker detector found on a small proxy as found; + // this one detects every such image again so every + // box, landmark, crop and vector is the current + // detector's from the native render, with names, + // suggestions and rejections carried onto the new + // faces. It fetches every original it visits, which + // is why it says so and never starts on its own. + if root.library-open: Rectangle { + height: Theme.gap-sm; + } + + if root.library-open: Caption { + text: "Re-indexing detects every face again with " + + "the chosen detector at full resolution, " + + "on every photograph it has not yet been " + + "over — including those an earlier, " + + "faster pass looked at — and fills in " + + "whatever else a record is missing on the " + + "way. Names, suggestions and rejections " + + "are carried onto the new faces. It " + + "fetches every original it visits, and " + + "it can be stopped and resumed."; + wrap: word-wrap; + } + + if root.library-open: Rectangle { + height: Theme.control-height; + + Button { + x: 0; + text: root.face-indexing + ? "Indexing faces…" + : "Re-index every face"; + enabled: !root.face-indexing && !root.face-model-missing; + clicked => { root.reindex-faces(); } + } + } + if root.activity-kept > 0: Rectangle { height: Theme.control-height;