//! 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), /// SQL over `faces f`, true where the face still owes it; the image owes /// the repair if any of its faces does. /// /// Kept as the per-face fragment rather than folded into an image /// predicate, because the two questions asked of it want opposite /// shapes. The list and the per-image check want `EXISTS (... WHERE /// f.image_id = i.id AND fragment)`, one probe per image. The count /// wants to start from the faces, where the partial indexes V19 keeps /// for exactly these fragments make it a walk over the few thousand /// still owing rather than a probe into eight-kilobyte rows for every /// image in the library -- and the planner will not use those indexes /// from inside the EXISTS. Face(String), /// Evaluated once, at the start of the job. Set(SetFn), } /// [`Needs::Face`] as an image predicate: the image holds a face owing it. fn any_face(fragment: &str) -> String { format!("EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {fragment})") } /// 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}))") }; // The per-face fragment `Needs::Face` carries: this embedder's face, // still owing the pass. Spelled as the partial indexes' WHERE clauses // are (V19), which is what lets the count be served from them. let face_needing = |pred: &str| format!("{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::Face(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::Face(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::Face(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"), ))?; // The index once, not a probe per image with faces — see // `ThumbStore::held`. An unreadable index reads as empty, as `contains` // would have reported it. let held = store.held(dr_thumbs::ThumbSize::Large).unwrap_or_else(|e| { log::warn!("repairs: reading the thumbnail index: {e}"); Default::default() }); let rows = stmt .query_map([embedder], |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) })? .filter_map(Result::ok) .filter(|(_, file_id)| !held.contains(&(*file_id as u64))) .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::Face(fragment) => (any_face(fragment), 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 { let sql = match (&repair.needs, set) { (Needs::Set(_), Some(s)) => return s.contains(&(image.0 as i64)), (Needs::Set(_), None) => return false, (Needs::Sql(sql), _) => sql.clone(), (Needs::Face(fragment), _) => any_face(fragment), }; 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 and the /// coverage line. /// /// Counted, not listed. [`listed`] builds a `Target` per image — its path, /// its size — and sorts the faces-first order the job visits them in, none of /// which a count reads; asked for six repairs on a 24,000-image library that /// was 350 ms of `source_ref` strings built to be dropped. A `COUNT(*)` over /// the same predicate is the same number in a tenth of the time. pub fn counts( catalog: &Catalog, store: &ThumbStore, repairs: &[Repair], ) -> Result, dr_catalog::CatalogError> { repairs .iter() .map(|r| Ok((r.label, count(catalog, store, r)?))) .collect() } /// How many images one repair lists — the size of [`listed`]'s answer, /// without building it. fn count( catalog: &Catalog, store: &ThumbStore, repair: &Repair, ) -> Result { match &repair.needs { Needs::Sql(sql) => { let n: i64 = catalog.connection().query_row( &format!( "SELECT COUNT(*) FROM images i JOIN remote r ON r.image_id = i.id WHERE r.file_id IS NOT NULL AND {VISIBLE} AND ({sql})" ), [], |r| r.get(0), )?; Ok(n as u64) } // From the faces, not the images: see `Needs::Face`. Needs::Face(fragment) => { let n: i64 = catalog.connection().query_row( &format!( "SELECT COUNT(DISTINCT f.image_id) FROM faces f JOIN images i ON i.id = f.image_id JOIN remote r ON r.image_id = i.id WHERE {fragment} AND r.file_id IS NOT NULL AND {VISIBLE}" ), [], |r| r.get(0), )?; Ok(n as u64) } // The set is built from its own query and may name images `listed` // would not visit, so it is intersected with the same base rather // than trusted for its size. Needs::Set(f) => { let set = f(catalog, store)?; let mut stmt = catalog.connection().prepare(&format!( "SELECT i.id FROM images i JOIN remote r ON r.image_id = i.id WHERE r.file_id IS NOT NULL AND {VISIBLE}" ))?; let n = stmt .query_map([], |r| r.get::<_, i64>(0))? .filter_map(Result::ok) .filter(|id| set.contains(id)) .count(); Ok(n as u64) } } } /// 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(_) | Needs::Face(_) => 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); } /// `counts` answers from the faces, `listed` from the images (see /// `Needs::Face`), and the two spellings of each predicate have to /// agree -- for every repair, on a library where each has something to /// do and something already done. #[test] fn counts_are_the_sizes_of_the_lists() { let catalog = with_images(5); let ids = image_ids(&catalog); let (store, dir) = store(); let conn = catalog.connection(); // 0: two faces, one measured, neither read for eyes, one without a // crop -- the per-face repairs disagree about it face by face. faces::record_detections( conn, ids[0], "w600k_mbf", 4000, &[ face("w600k_mbf", None, vec![1]), face("w600k_mbf", Some(18.0), Vec::new()), ], ) .unwrap(); // 1: done, under the chosen detector. faces::record_detections( conn, ids[1], "scrfd_10g+w600k_mbf", 4000, &[complete("scrfd_10g+w600k_mbf")], ) .unwrap(); // 2: examined by a weaker detector, nothing found. faces::record_detections(conn, ids[2], "w600k_mbf", 4000, &[]).unwrap(); // 3, 4: never examined. let repairs = registry( Scope::Outstanding, "scrfd_10g+w600k_mbf", FaceDetector::Scrfd10g, ALL, ); let counted = counts(&catalog, &store, &repairs).unwrap(); for (repair, (label, n)) in repairs.iter().zip(counted) { assert_eq!(label, repair.label); let list = listed(&catalog, &store, repair).unwrap(); assert_eq!(n as usize, list.len(), "{}", repair.name); } // And the fixture exercised what it claims to. let names: Vec<&str> = repairs.iter().map(|r| r.name).collect(); for name in [ "face-quality", "face-eyes", "face-crop", "face-detection", "face-upgrade", ] { assert!(names.contains(&name), "{name} missing from the registry"); assert!( !listed(&catalog, &store, by_name(&repairs, name)) .unwrap() .is_empty(), "{name} has nothing to do" ); } 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); } }