Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice.
This commit is contained in:
@@ -269,11 +269,13 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't
|
||||
/// responding" over a window that has never painted, and offers to kill it.
|
||||
///
|
||||
/// This copies **41 MB** on the first launch after an install: 24.9 MB of scene
|
||||
/// This copied **41 MB** on the first launch after an install: 24.9 MB of scene
|
||||
/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the
|
||||
/// APK and written to `/data`. v0.10.0 added the scene model, which is 60% of
|
||||
/// APK and written to `/data`. v0.10.0 added the scene model, which was 60% of
|
||||
/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor
|
||||
/// faults in its report are what 41 MB of freshly touched pages looks like.
|
||||
/// The two further detectors the settings page offers since have made it
|
||||
/// 61 MB, which is the same argument with a larger number.
|
||||
///
|
||||
/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and
|
||||
/// this function returns as soon as the thread is running. Nothing on the
|
||||
@@ -317,8 +319,15 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// decodes to 150 anonymous channels — `library::scene_model` wants the
|
||||
// vocabulary and the category descriptor beside it, and requires all three
|
||||
// before it reports the tab available.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 5] = [
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 7] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
|
||||
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
|
||||
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
||||
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
|
||||
(
|
||||
@@ -334,8 +343,8 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
|
||||
for (asset_path, name) in BUNDLED {
|
||||
let dest = dir.join(name);
|
||||
// Already unpacked. Not re-read on every launch: this is 41 MB of
|
||||
// copying across the five entries, and the file does not change without
|
||||
// Already unpacked. Not re-read on every launch: this is 61 MB of
|
||||
// copying across the seven entries, and the file does not change without
|
||||
// the APK changing, at which point the install wiped it anyway. It
|
||||
// matters more now than it did — a launch that skips every entry here
|
||||
// costs nothing at all, which is what makes the second launch after an
|
||||
|
||||
@@ -19,8 +19,8 @@ pub use place::{Place, PlaceScope, Screen, StoredFilter};
|
||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||
pub use settings::{
|
||||
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||
ExportTarget, GroupNavigation, ImportSettings, LibrarySettings, OutputSharpening, ScreenSize,
|
||||
Settings, SizingMode,
|
||||
ExportTarget, FaceDetector, GroupNavigation, ImportSettings, LibrarySettings, OutputSharpening,
|
||||
ScreenSize, Settings, SizingMode,
|
||||
};
|
||||
pub use time::{
|
||||
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
|
||||
|
||||
@@ -157,6 +157,96 @@ pub struct FaceSettings {
|
||||
/// dropped by this, whatever their size: those are the user's judgements and
|
||||
/// a display preference does not overrule them (FR-CULL-12).
|
||||
pub min_group_size: u32,
|
||||
/// Which graph finds the faces. See [`FaceDetector`].
|
||||
pub detector: FaceDetector,
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
/// Which SCRFD graph the indexing pass detects with.
|
||||
///
|
||||
/// Three exports of one architecture, differing only in how much computation
|
||||
/// they spend, and docs/faces.md §12.3 is the measurement that made this a
|
||||
/// choice rather than a constant: over the same photographs the cheapest one
|
||||
/// misses the small faces in a group and reports a dog a dozen times, the
|
||||
/// middle one finds 14% more faces for 12% more time, and the largest a
|
||||
/// further 12% for three times the cost. Which of those is the right trade
|
||||
/// depends on the machine doing the sweep — a desktop left running overnight
|
||||
/// and a tablet on a battery want different answers — so it is a setting,
|
||||
/// per device, like the rest of this file.
|
||||
///
|
||||
/// # A detector is half of a model id
|
||||
///
|
||||
/// Every face row, run marker, sync shard and calibration is keyed by
|
||||
/// `faces.model_id` (catalog.md §10.1), and the schema's whole reason for
|
||||
/// carrying that column is that *a model change is a new id and a re-index*
|
||||
/// rather than a silent change under existing data. A detector change is a
|
||||
/// model change: it decides which faces exist and where the landmarks that
|
||||
/// align them land. So each variant names its own pipeline, and choosing
|
||||
/// another one puts every image back in the queue and shows the People screen
|
||||
/// for the new pipeline — empty until the sweep has run, with confirmed names
|
||||
/// carried across by `record_detections`' box overlap.
|
||||
///
|
||||
/// The first variant's id is the bare embedder name, because that is the id
|
||||
/// every library indexed before this setting existed was written under;
|
||||
/// making it `scrfd_500m+w600k_mbf` would have told those libraries they had
|
||||
/// never been indexed.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FaceDetector {
|
||||
/// 2.5 GFLOPs. The measured sweet spot: nearly the same speed, and a
|
||||
/// cleaner set of faces.
|
||||
#[serde(rename = "scrfd_2.5g")]
|
||||
Scrfd2_5g,
|
||||
/// 10 GFLOPs. The most faces, at three times the time per image.
|
||||
#[serde(rename = "scrfd_10g")]
|
||||
Scrfd10g,
|
||||
/// 500 MFLOPs. What every library was indexed with until now.
|
||||
///
|
||||
/// Last, because `other` has to be: a file written by a build that knows
|
||||
/// a fourth detector loads as this one rather than throwing every other
|
||||
/// setting away with it. [`FaceDetector::ALL`] is the display order.
|
||||
#[default]
|
||||
#[serde(rename = "scrfd_500m", other)]
|
||||
Scrfd500m,
|
||||
}
|
||||
|
||||
impl FaceDetector {
|
||||
/// In the order the settings page offers them: cheapest first.
|
||||
pub const ALL: [FaceDetector; 3] = [
|
||||
FaceDetector::Scrfd500m,
|
||||
FaceDetector::Scrfd2_5g,
|
||||
FaceDetector::Scrfd10g,
|
||||
];
|
||||
|
||||
/// The shape-fixed export's file name, as `tools/fix-face-model-shapes.sh`
|
||||
/// writes it and every packager installs it.
|
||||
pub fn file_name(self) -> &'static str {
|
||||
match self {
|
||||
FaceDetector::Scrfd500m => "scrfd_500m_640.onnx",
|
||||
FaceDetector::Scrfd2_5g => "scrfd_2.5g_640.onnx",
|
||||
FaceDetector::Scrfd10g => "scrfd_10g_640.onnx",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `faces.model_id` this detector's pipeline writes under.
|
||||
///
|
||||
/// The embedder is the same `w600k_mbf` in every case; see the type note
|
||||
/// for why the first is bare and the others are qualified.
|
||||
pub fn model_id(self) -> &'static str {
|
||||
match self {
|
||||
FaceDetector::Scrfd500m => "w600k_mbf",
|
||||
FaceDetector::Scrfd2_5g => "scrfd_2.5g+w600k_mbf",
|
||||
FaceDetector::Scrfd10g => "scrfd_10g+w600k_mbf",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the picker calls it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FaceDetector::Scrfd500m => "Fast",
|
||||
FaceDetector::Scrfd2_5g => "Balanced",
|
||||
FaceDetector::Scrfd10g => "Thorough",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceSettings {
|
||||
@@ -195,6 +285,7 @@ impl Default for FaceSettings {
|
||||
// a setting, so an existing library regroups identically until the
|
||||
// user moves it.
|
||||
min_group_size: 2,
|
||||
detector: FaceDetector::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1537,6 +1628,42 @@ mod tests {
|
||||
assert_eq!(s.faces, FaceSettings::default());
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn the_default_detector_keeps_the_legacy_model_id() {
|
||||
assert_eq!(FaceDetector::default(), FaceDetector::Scrfd500m);
|
||||
assert_eq!(FaceDetector::default().model_id(), "w600k_mbf");
|
||||
}
|
||||
|
||||
/// Two pipelines must never share an id: the whole point of the column is
|
||||
/// that their faces are not interchangeable.
|
||||
#[test]
|
||||
fn every_detector_has_its_own_model_id_and_file() {
|
||||
let ids: std::collections::HashSet<_> =
|
||||
FaceDetector::ALL.iter().map(|d| d.model_id()).collect();
|
||||
assert_eq!(ids.len(), FaceDetector::ALL.len());
|
||||
let files: std::collections::HashSet<_> =
|
||||
FaceDetector::ALL.iter().map(|d| d.file_name()).collect();
|
||||
assert_eq!(files.len(), FaceDetector::ALL.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_detector_round_trips_and_an_unknown_one_falls_back() {
|
||||
let mut s = Settings::default();
|
||||
s.faces.detector = FaceDetector::Scrfd10g;
|
||||
let text = serde_json::to_string(&s).unwrap();
|
||||
assert!(text.contains(r#""detector":"scrfd_10g""#));
|
||||
let back: Settings = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.faces.detector, FaceDetector::Scrfd10g);
|
||||
|
||||
let newer = r#"{"faces":{"detector":"scrfd_99g"}}"#;
|
||||
let s: Settings =
|
||||
serde_json::from_str(newer).expect("unknown detector should not refuse the file");
|
||||
assert_eq!(s.faces.detector, FaceDetector::Scrfd500m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitise_leaves_a_real_destination_alone() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
+9
-3
@@ -1099,9 +1099,15 @@ is not a desktop-only feature (NFR-RES-2). It loads in tract with the same fix a
|
||||
(`--input input.1=1,3,640,640`; its outputs are declared dynamic and tract infers them) and
|
||||
decodes through the same nine-output path unchanged. Same licence, same `buffalo_m` release page.
|
||||
|
||||
What a swap has to deal with: `face_index` is keyed on the *embedder's* `model_id`, so a new
|
||||
detector re-queues nothing. The images already indexed under `500M` keep their faces and their
|
||||
dogs until something re-detects them — that is a re-index policy question, not a detector one.
|
||||
**Which detector runs is a setting** — `FaceSettings::detector`, per device, on the settings page
|
||||
beside the indexing button as Fast / Balanced / Thorough. All three files ship. Each detector is
|
||||
its own `faces.model_id` (`w600k_mbf` for `500M`, unchanged, so nothing already indexed is
|
||||
disturbed; `scrfd_2.5g+w600k_mbf` and `scrfd_10g+w600k_mbf` for the others), which is the
|
||||
mechanism §2.1 always intended for a model change: the coverage figure restarts at zero under the
|
||||
new id, the sweep re-detects, `record_detections` carries confirmed names across by box overlap and
|
||||
drops the previous pipeline's marker for each image it revisits, and the sync shards are keyed by
|
||||
the same id so a peer on another setting neither adopts nor pollutes them. The default stays `500M`
|
||||
so that an upgrade changes nothing until the user chooses; the recommendation is `2.5G`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+56
-56
File diff suppressed because one or more lines are too long
+13
-6
@@ -1,7 +1,7 @@
|
||||
# Face models
|
||||
|
||||
The shape-fixed SCRFD detector and ArcFace/MobileFaceNet embedder, in LFS. One copy, packaged by
|
||||
every platform:
|
||||
The shape-fixed SCRFD detectors and the ArcFace/MobileFaceNet embedder, in LFS. One copy, packaged
|
||||
by every platform:
|
||||
|
||||
| Platform | How it ships | Where it lands |
|
||||
|---|---|---|
|
||||
@@ -11,18 +11,25 @@ every platform:
|
||||
`library::face_models` searches the account's own directory, then the shared user directory, then
|
||||
`$XDG_DATA_DIRS` — so a pair the user placed by hand always outranks the packaged one.
|
||||
|
||||
scrfd_500m_640.onnx 2.5 MB
|
||||
scrfd_500m_640.onnx 2.5 MB "Fast" in settings — what every library was indexed with until 0.12
|
||||
scrfd_2.5g_640.onnx 3.3 MB "Balanced" — 14% more faces for 12% more time (faces.md §12.3)
|
||||
scrfd_10g_640.onnx 17 MB "Thorough" — a further 12% for 3× the time
|
||||
arcface_mbf_b1.onnx 13 MB
|
||||
|
||||
Which detector runs is a per-device setting (`FaceSettings::detector`); all three are installed so
|
||||
the choice exists on every platform. Each is its own `faces.model_id`, so changing it re-indexes.
|
||||
|
||||
**A clone without git-lfs gets a ~130-byte pointer where each model should be.** Both packagers check
|
||||
for exactly that and refuse, rather than shipping the pointer and failing inside tract on the user's
|
||||
machine. Fix it with `git lfs pull`.
|
||||
|
||||
These are not what InsightFace ships. They came from `buffalo_sc.zip` and `buffalo_s.zip` on the
|
||||
InsightFace v0.7 release with their input dimensions pinned, because tract cannot parse either graph
|
||||
while they are dynamic:
|
||||
These are not what InsightFace ships. They came from `buffalo_sc.zip`, `buffalo_m.zip`, `buffalo_l.zip`
|
||||
and `buffalo_s.zip` on the InsightFace v0.7 release with their input dimensions pinned, because tract
|
||||
cannot parse any of the graphs while they are dynamic:
|
||||
|
||||
./tools/fix-face-model-shapes.sh det_500m.onnx models/face/scrfd_500m_640.onnx --input input.1=1,3,640,640
|
||||
./tools/fix-face-model-shapes.sh det_2.5g.onnx models/face/scrfd_2.5g_640.onnx --input input.1=1,3,640,640
|
||||
./tools/fix-face-model-shapes.sh det_10g.onnx models/face/scrfd_10g_640.onnx --input input.1=1,3,640,640
|
||||
./tools/fix-face-model-shapes.sh w600k_mbf.onnx models/face/arcface_mbf_b1.onnx --dim None=1
|
||||
|
||||
The weights carry a non-commercial research-only grant. They are here because this is a private
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -61,7 +61,7 @@ package() {
|
||||
# These live in LFS; a checkout without `git lfs pull` has ~130-byte
|
||||
# pointers here. Installing one produces a package whose face indexing
|
||||
# fails inside the graph loader on the user's machine, so refuse instead.
|
||||
for _m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do
|
||||
for _m in scrfd_500m_640.onnx scrfd_2.5g_640.onnx scrfd_10g_640.onnx arcface_mbf_b1.onnx; do
|
||||
_src="models/face/${_m}"
|
||||
if [[ "$(stat -c%s "${_src}")" -lt 100000 ]]; then
|
||||
echo "error: ${_m} is an LFS pointer, not a model — run: git lfs pull" >&2
|
||||
|
||||
@@ -157,7 +157,7 @@ modules:
|
||||
# inside the graph loader on the user's machine. Refuse instead, with the
|
||||
# command that fixes it.
|
||||
- |
|
||||
for m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do
|
||||
for m in scrfd_500m_640.onnx scrfd_2.5g_640.onnx scrfd_10g_640.onnx arcface_mbf_b1.onnx; do
|
||||
if [ "$(stat -c%s "models/face/$m")" -lt 100000 ]; then
|
||||
echo "error: $m is an LFS pointer, not a model — run: git lfs pull" >&2
|
||||
exit 1
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
# The two the face pipeline needs, verified 2026-08-26 (docs/faces.md §12 M1):
|
||||
#
|
||||
# ... det_500m.onnx scrfd_500m_640.onnx --input input.1=1,3,640,640
|
||||
# ... det_2.5g.onnx scrfd_2.5g_640.onnx --input input.1=1,3,640,640
|
||||
# ... det_10g.onnx scrfd_10g_640.onnx --input input.1=1,3,640,640
|
||||
# ... w600k_mbf.onnx arcface_mbf_b1.onnx --dim None=1
|
||||
#
|
||||
# ## Why this exists
|
||||
|
||||
@@ -113,6 +113,11 @@ pub fn spawn_sync(
|
||||
catalog_path: PathBuf,
|
||||
place_path: PathBuf,
|
||||
scratch: PathBuf,
|
||||
// TRACES: FR-CULL-8
|
||||
// Which face pipeline's shards to export and adopt. From the settings
|
||||
// page by way of the library controller, because a shard written under
|
||||
// one detector's faces must not be read back as another's.
|
||||
face_model_id: String,
|
||||
) -> std::sync::mpsc::Receiver<SyncMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -143,6 +148,7 @@ pub fn spawn_sync(
|
||||
&catalog_path,
|
||||
&place_path,
|
||||
&scratch,
|
||||
&face_model_id,
|
||||
&tx,
|
||||
)
|
||||
.await
|
||||
@@ -160,6 +166,10 @@ pub fn spawn_sync(
|
||||
rx
|
||||
}
|
||||
|
||||
// Eight, because a sync touches eight distinct things — the same reason
|
||||
// `spawn_face_sweep` carries the allow: bundling them into a struct would name
|
||||
// nothing that exists.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run(
|
||||
backend: &dyn RemoteBackend,
|
||||
root: &str,
|
||||
@@ -167,6 +177,7 @@ async fn run(
|
||||
catalog_path: &Path,
|
||||
place_path: &Path,
|
||||
scratch: &Path,
|
||||
face_model_id: &str,
|
||||
tx: &std::sync::mpsc::Sender<SyncMessage>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let mut report = SyncReport::default();
|
||||
@@ -180,7 +191,16 @@ async fn run(
|
||||
sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?;
|
||||
|
||||
let _ = tx.send(SyncMessage::Status("checking faces…".into()));
|
||||
sync_face_shards(backend, &base, catalog_path, scratch, &mut report, tx).await?;
|
||||
sync_face_shards(
|
||||
backend,
|
||||
&base,
|
||||
catalog_path,
|
||||
scratch,
|
||||
face_model_id,
|
||||
&mut report,
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = tx.send(SyncMessage::Status("checking collections…".into()));
|
||||
sync_catalog(backend, &base, catalog_path, scratch, &mut report).await?;
|
||||
@@ -361,6 +381,7 @@ async fn sync_face_shards(
|
||||
base: &RemotePath,
|
||||
catalog_path: &Path,
|
||||
scratch: &Path,
|
||||
face_model_id: &str,
|
||||
report: &mut SyncReport,
|
||||
tx: &std::sync::mpsc::Sender<SyncMessage>,
|
||||
) -> Result<(), String> {
|
||||
@@ -382,7 +403,7 @@ async fn sync_face_shards(
|
||||
};
|
||||
let client = store.client_id().to_string();
|
||||
|
||||
let model = crate::identity_ui::MODEL_ID;
|
||||
let model = face_model_id;
|
||||
|
||||
// ---- everything this device has detected, into the shards ------------
|
||||
if let Ok(catalog) = dr_catalog::Catalog::open(catalog_path) {
|
||||
|
||||
+61
-27
@@ -21,10 +21,14 @@ use crate::{AppWindow, IdentityFace, IdentityPerson};
|
||||
|
||||
/// Which model's faces the screen is looking at.
|
||||
///
|
||||
/// A constant for now because exactly one model is supported at a time; it is
|
||||
/// named rather than inlined because every query in this file is keyed on it,
|
||||
/// and a library indexed across a model change holds faces from both.
|
||||
pub const MODEL_ID: &str = "w600k_mbf";
|
||||
/// Every query in this file is keyed on it, and a library indexed across a
|
||||
/// detector change holds faces from both pipelines: the screen shows the one
|
||||
/// the settings page currently names (`FaceDetector::model_id`), read at each
|
||||
/// use rather than captured once, because the page can change it while the
|
||||
/// screen is open.
|
||||
pub fn model_id(settings: &crate::settings_ui::SettingsController) -> String {
|
||||
settings.snapshot().faces.detector.model_id().to_string()
|
||||
}
|
||||
|
||||
/// Screen state that outlives a single callback.
|
||||
#[derive(Default)]
|
||||
@@ -148,6 +152,7 @@ pub fn refresh(
|
||||
ctl: &Rc<IdentityController>,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
store: Option<Rc<ThumbStore>>,
|
||||
model_id: &str,
|
||||
) {
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else {
|
||||
@@ -156,7 +161,7 @@ pub fn refresh(
|
||||
return;
|
||||
};
|
||||
|
||||
let view = match identity::load_people(cat, MODEL_ID) {
|
||||
let view = match identity::load_people(cat, model_id) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("identity: reading people: {e}");
|
||||
@@ -268,7 +273,7 @@ pub fn refresh(
|
||||
|
||||
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||||
drop(borrow);
|
||||
refresh_coverage(window, catalog, store.as_deref());
|
||||
refresh_coverage(window, catalog, store.as_deref(), model_id);
|
||||
|
||||
// Last, so a portrait cannot delay anything above it.
|
||||
if let Some(store) = store {
|
||||
@@ -379,13 +384,14 @@ pub fn refresh_coverage(
|
||||
window: &AppWindow,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
store: Option<&ThumbStore>,
|
||||
model_id: &str,
|
||||
) {
|
||||
let borrow = catalog.borrow();
|
||||
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
|
||||
window.set_identity_coverage(Default::default());
|
||||
return;
|
||||
};
|
||||
match crate::faces::audit(cat, store, MODEL_ID) {
|
||||
match crate::faces::audit(cat, store, model_id) {
|
||||
Ok(a) => {
|
||||
window.set_identity_coverage(a.summary().into());
|
||||
// Complete means nothing left to index, not "every image has a
|
||||
@@ -591,8 +597,8 @@ pub fn wire<S, M, P>(
|
||||
// a rejection changes two counts — and a model patched by hand would
|
||||
// drift from the catalog in exactly the cases that matter.
|
||||
macro_rules! reload {
|
||||
($w:expr, $ctl:expr, $catalog:expr, $store:expr) => {
|
||||
refresh(&$w, &$ctl, &$catalog, $store())
|
||||
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr) => {
|
||||
refresh(&$w, &$ctl, &$catalog, $store(), &model_id(&$settings))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -601,6 +607,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
let models_present = models.clone();
|
||||
window.on_identity_open(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
@@ -615,7 +622,7 @@ pub fn wire<S, M, P>(
|
||||
// A fact about the filesystem, so it is re-checked on every open
|
||||
// rather than cached: the user may have just put the models there.
|
||||
w.set_identity_model_missing(models_present().is_none());
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -642,6 +649,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_person_picked(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Before the selection moves: a name typed into the field and never
|
||||
@@ -653,7 +661,7 @@ pub fn wire<S, M, P>(
|
||||
// The offer was about the person being navigated away from. Left
|
||||
// up, its "Merge" would fold whoever is selected *now*.
|
||||
clear_merge_offer(&w, &ctl);
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
reset_name_field(&w);
|
||||
});
|
||||
}
|
||||
@@ -663,6 +671,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_rename(move |name| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
@@ -697,7 +706,7 @@ pub fn wire<S, M, P>(
|
||||
Err(e) => log::warn!("identity: looking for a namesake: {e}"),
|
||||
}
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
// `rename` trims; the field should show what was actually stored
|
||||
// rather than the spacing the user happened to type.
|
||||
reset_name_field(&w);
|
||||
@@ -714,6 +723,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_merge_accept(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let (Some(target), Some(source)) = (ctl.merge_offer.get(), ctl.selected.get()) else {
|
||||
@@ -733,7 +743,7 @@ pub fn wire<S, M, P>(
|
||||
}
|
||||
}
|
||||
clear_merge_offer(&w, &ctl);
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
reset_name_field(&w);
|
||||
});
|
||||
}
|
||||
@@ -767,6 +777,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_confirm_face(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
@@ -777,7 +788,7 @@ pub fn wire<S, M, P>(
|
||||
log::warn!("identity: confirm: {e}");
|
||||
}
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -786,6 +797,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_reject_face(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
@@ -796,7 +808,7 @@ pub fn wire<S, M, P>(
|
||||
log::warn!("identity: reject: {e}");
|
||||
}
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -827,6 +839,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_confirm_all(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
@@ -838,7 +851,7 @@ pub fn wire<S, M, P>(
|
||||
Err(e) => log::warn!("identity: confirm all: {e}"),
|
||||
}
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -847,6 +860,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_split_picked(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(person) = ctl.selected.get() else {
|
||||
@@ -870,7 +884,7 @@ pub fn wire<S, M, P>(
|
||||
}
|
||||
}
|
||||
ctl.clear_picks();
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -893,7 +907,7 @@ pub fn wire<S, M, P>(
|
||||
w.set_identity_previewing(true);
|
||||
*ctl.preview.borrow_mut() = Some(crate::faces::spawn_grouping_preview(
|
||||
catalog_path,
|
||||
MODEL_ID.to_string(),
|
||||
model_id(&settings),
|
||||
settings.snapshot().faces,
|
||||
));
|
||||
|
||||
@@ -960,7 +974,7 @@ pub fn wire<S, M, P>(
|
||||
w.set_identity_regroup_status("regrouping…".into());
|
||||
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
|
||||
catalog_path,
|
||||
MODEL_ID.to_string(),
|
||||
model_id(&settings_for_regroup),
|
||||
settings_for_regroup.snapshot().faces,
|
||||
));
|
||||
|
||||
@@ -972,6 +986,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl_tick = ctl.clone();
|
||||
let catalog_tick = catalog.clone();
|
||||
let store_tick = store.clone();
|
||||
let settings_tick = settings_for_regroup.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
Duration::from_millis(100),
|
||||
@@ -1020,7 +1035,13 @@ pub fn wire<S, M, P>(
|
||||
// makes new people; a stale cache would draw the
|
||||
// previous pass's faces beside the new groups.
|
||||
ctl_tick.covers.borrow_mut().clear();
|
||||
refresh(&w, &ctl_tick, &catalog_tick, store_tick());
|
||||
refresh(
|
||||
&w,
|
||||
&ctl_tick,
|
||||
&catalog_tick,
|
||||
store_tick(),
|
||||
&model_id(&settings_tick),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1036,6 +1057,7 @@ pub fn wire<S, M, P>(
|
||||
let models = models.clone();
|
||||
let paths = paths.clone();
|
||||
let gpu = gpu.clone();
|
||||
let settings_for_sweep = settings.clone();
|
||||
window.on_identity_index(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if ctl.sweep.borrow().is_some() {
|
||||
@@ -1066,7 +1088,7 @@ pub fn wire<S, M, P>(
|
||||
store_dir,
|
||||
detector,
|
||||
embedder,
|
||||
MODEL_ID.to_string(),
|
||||
model_id(&settings_for_sweep),
|
||||
dr_face::DetectOptions::default(),
|
||||
gpu,
|
||||
));
|
||||
@@ -1083,6 +1105,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl_tick = ctl.clone();
|
||||
let catalog_tick = catalog.clone();
|
||||
let store_tick = store.clone();
|
||||
let settings_tick = settings_for_sweep.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
Duration::from_millis(250),
|
||||
@@ -1165,7 +1188,13 @@ pub fn wire<S, M, P>(
|
||||
// grouped, so a sweep ending with an unchanged people
|
||||
// rail is expected. The coverage line is what shows it
|
||||
// worked, and Regroup is the next step.
|
||||
refresh(&w, &ctl_tick, &catalog_tick, store_tick());
|
||||
refresh(
|
||||
&w,
|
||||
&ctl_tick,
|
||||
&catalog_tick,
|
||||
store_tick(),
|
||||
&model_id(&settings_tick),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1180,6 +1209,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_stop_indexing(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Dropping the receiver *is* the cancellation: the worker's next
|
||||
@@ -1190,7 +1220,7 @@ pub fn wire<S, M, P>(
|
||||
a.finish("stopped");
|
||||
}
|
||||
w.set_identity_indexing(false);
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1198,9 +1228,10 @@ pub fn wire<S, M, P>(
|
||||
let weak = window.as_weak();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_check_coverage(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
refresh_coverage(&w, &catalog, store().as_deref());
|
||||
refresh_coverage(&w, &catalog, store().as_deref(), &model_id(&settings));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1209,6 +1240,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_ignore_person(move |id, on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let person = PersonId(id.max(0) as u64);
|
||||
@@ -1225,7 +1257,7 @@ pub fn wire<S, M, P>(
|
||||
ctl.selected.set(None);
|
||||
ctl.clear_picks();
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1234,10 +1266,11 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_toggle_show_ignored(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl.show_ignored.set(!ctl.show_ignored.get());
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1246,6 +1279,7 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let settings = settings.clone();
|
||||
window.on_identity_delete_all(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(cat) = catalog.borrow().as_ref() {
|
||||
@@ -1257,7 +1291,7 @@ pub fn wire<S, M, P>(
|
||||
ctl.selected.set(None);
|
||||
ctl.clear_picks();
|
||||
ctl.covers.borrow_mut().clear();
|
||||
reload!(w, ctl, catalog, store);
|
||||
reload!(w, ctl, catalog, store, settings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+44
-11
@@ -1358,9 +1358,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// and their absence is the ordinary state of a fresh install.
|
||||
{
|
||||
let lib = library.clone();
|
||||
let settings = settings.clone();
|
||||
move || {
|
||||
let conn = lib.session()?;
|
||||
library::face_models(&conn.account)
|
||||
library::face_models(&conn.account, settings.snapshot().faces.detector)
|
||||
}
|
||||
},
|
||||
// The sweep opens its own connection on its own thread, so it
|
||||
@@ -1504,6 +1505,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
library.set_cache_budget(stored.cache.original_budget_bytes);
|
||||
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
|
||||
library.set_timeline_bars(stored.library.timeline_bars);
|
||||
library.set_face_model_id(stored.faces.detector.model_id());
|
||||
}
|
||||
|
||||
// --- the export folder picker ----------------------------------------
|
||||
@@ -1648,6 +1650,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// The pipeline the next sync and the next sweep run under, and
|
||||
// the two lines on the page that describe it: which detector
|
||||
// file is installed, and how much of the library that
|
||||
// pipeline has covered — which for a freshly chosen one is
|
||||
// nothing, and saying so is the point.
|
||||
lib.set_face_model_id(s.faces.detector.model_id());
|
||||
if let Some(w) = weak.upgrade() {
|
||||
refresh_face_status(&w, &lib, s.faces.detector);
|
||||
}
|
||||
|
||||
// Lowering the ceiling evicts, so the figure beside it has just
|
||||
// changed — leaving the old one would show the cache still over a
|
||||
// limit that was enforced a moment ago.
|
||||
@@ -1662,18 +1674,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// through here rather than being kept up to date continuously —
|
||||
// they are only ever looked at while this page is on screen.
|
||||
let lib = library.clone();
|
||||
let catalog = library.catalog();
|
||||
let ctl = settings.clone();
|
||||
move |w: &AppWindow| {
|
||||
let store = lib.session().and_then(|c| {
|
||||
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
|
||||
});
|
||||
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
|
||||
w.set_identity_model_missing(
|
||||
lib.session()
|
||||
.and_then(|c| library::face_models(&c.account))
|
||||
.is_none(),
|
||||
);
|
||||
refresh_face_status(w, &lib, ctl.snapshot().faces.detector);
|
||||
// Here rather than at startup, on exactly the reasoning
|
||||
// above: the catalog this reads is opened on a worker now,
|
||||
// so a launch has nothing to describe, and the figure is
|
||||
@@ -3588,6 +3591,36 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
/// the pinned figure is disk the user asked for and no ceiling will reclaim.
|
||||
/// One combined number would make the budget look wrong whenever a large
|
||||
/// collection was pinned.
|
||||
/// TRACES: FR-CULL-8
|
||||
/// The two face lines on the settings page: whether the chosen detector's file
|
||||
/// is installed, and how far the chosen pipeline has got through the library.
|
||||
///
|
||||
/// One function because they move together. A detector picked from the list
|
||||
/// changes which file has to exist *and* which `model_id` the coverage is
|
||||
/// counted under, and a page that updated one without the other would report
|
||||
/// "no model installed" over a coverage figure for a different model.
|
||||
fn refresh_face_status(
|
||||
window: &AppWindow,
|
||||
library: &Rc<library_ui::LibraryController>,
|
||||
detector: dr_types::FaceDetector,
|
||||
) {
|
||||
let store = library
|
||||
.session()
|
||||
.and_then(|c| dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok());
|
||||
identity_ui::refresh_coverage(
|
||||
window,
|
||||
&library.catalog(),
|
||||
store.as_ref(),
|
||||
detector.model_id(),
|
||||
);
|
||||
window.set_identity_model_missing(
|
||||
library
|
||||
.session()
|
||||
.and_then(|c| library::face_models(&c.account, detector))
|
||||
.is_none(),
|
||||
);
|
||||
}
|
||||
|
||||
fn describe_cache_usage(library: &Rc<library_ui::LibraryController>) -> String {
|
||||
let Some(cache) = library.cache() else {
|
||||
return String::new();
|
||||
|
||||
@@ -4419,12 +4419,15 @@ pub fn shared_face_models_dir() -> PathBuf {
|
||||
/// 3. **The system directories.** Where a package installs them — the Arch
|
||||
/// package puts the pair in `/usr/share/darkroom/models`. Last, so anything
|
||||
/// the user placed themselves outranks what the package shipped.
|
||||
pub fn face_models(account: &Account) -> Option<(PathBuf, PathBuf)> {
|
||||
fn pair(dir: PathBuf) -> Option<(PathBuf, PathBuf)> {
|
||||
let detector = dir.join("scrfd_500m_640.onnx");
|
||||
pub fn face_models(
|
||||
account: &Account,
|
||||
detector: dr_types::FaceDetector,
|
||||
) -> Option<(PathBuf, PathBuf)> {
|
||||
let pair = |dir: PathBuf| {
|
||||
let detector = dir.join(detector.file_name());
|
||||
let embedder = dir.join("arcface_mbf_b1.onnx");
|
||||
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
|
||||
}
|
||||
};
|
||||
pair(face_models_dir(account))
|
||||
.or_else(|| pair(shared_face_models_dir()))
|
||||
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
|
||||
|
||||
@@ -391,6 +391,15 @@ pub struct LibraryController {
|
||||
/// wrong shape of question. A `Cell` because the page can change it while
|
||||
/// a library is open.
|
||||
timeline_bars: std::cell::Cell<u32>,
|
||||
/// TRACES: FR-CULL-8
|
||||
/// Which face pipeline this device indexes with, from the settings page.
|
||||
///
|
||||
/// Held here for the same reason as the two above: the derived sync runs
|
||||
/// from a sweep's completion and from the Sync button, neither of which
|
||||
/// has the settings in reach, and the shards it exports and adopts are
|
||||
/// keyed on this id. A `RefCell` of the id rather than the enum so that
|
||||
/// nothing here has to know how a detector becomes a model id.
|
||||
face_model_id: RefCell<String>,
|
||||
/// Where every worker this module starts reports what it is doing.
|
||||
///
|
||||
/// Held on the controller rather than passed to each function because the
|
||||
@@ -458,9 +467,22 @@ impl LibraryController {
|
||||
dr_types::CacheSettings::default().keep_opened_originals,
|
||||
),
|
||||
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
|
||||
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
/// Which face pipeline the next sync exports and adopts under.
|
||||
///
|
||||
/// Set on every settings edit like the bars above. A sync already running
|
||||
/// keeps the id it started with, which is right: its shards are half
|
||||
/// written under that one.
|
||||
pub fn set_face_model_id(&self, id: &str) {
|
||||
if *self.face_model_id.borrow() != id {
|
||||
*self.face_model_id.borrow_mut() = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-6
|
||||
/// How many bars the capture-time axis is cut into.
|
||||
///
|
||||
@@ -4016,6 +4038,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
catalog_path,
|
||||
library::place_path(&conn.account),
|
||||
scratch,
|
||||
ctl.face_model_id.borrow().clone(),
|
||||
);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
|
||||
@@ -26,8 +26,8 @@ use std::rc::Rc;
|
||||
|
||||
use dr_types::settings::budget;
|
||||
use dr_types::{
|
||||
CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, GroupNavigation, LibrarySettings,
|
||||
OutputSharpening, ScreenSize, Settings, SizingMode,
|
||||
CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, FaceDetector, GroupNavigation,
|
||||
LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode,
|
||||
};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
@@ -186,6 +186,24 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
);
|
||||
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
|
||||
|
||||
// --- faces ---------------------------------------------------------
|
||||
//
|
||||
// TRACES: FR-CULL-8
|
||||
// Labelled from the enum's own order, so the chip index and
|
||||
// `FaceDetector::ALL` cannot disagree about which detector was picked.
|
||||
window.set_settings_face_detector_labels(slint::ModelRc::new(slint::VecModel::from(
|
||||
FaceDetector::ALL
|
||||
.iter()
|
||||
.map(|d| slint::SharedString::from(d.label()))
|
||||
.collect::<Vec<_>>(),
|
||||
)));
|
||||
window.set_settings_face_detector_selected(
|
||||
FaceDetector::ALL
|
||||
.iter()
|
||||
.position(|d| *d == s.faces.detector)
|
||||
.unwrap_or(0) as i32,
|
||||
);
|
||||
|
||||
// --- library -------------------------------------------------------
|
||||
//
|
||||
// Labelled here rather than in the page, so the chips cannot offer a count
|
||||
@@ -484,6 +502,28 @@ pub fn wire<F, G>(
|
||||
});
|
||||
}
|
||||
|
||||
// --- faces ---------------------------------------------------------
|
||||
//
|
||||
// TRACES: FR-CULL-8
|
||||
// Notified like a budget change, because the effect is outside this
|
||||
// page: the library controller carries the pipeline id into the next
|
||||
// sync, and the coverage line under the picker has to be re-counted
|
||||
// under the new id — which the caller's `on_budget_changed` does.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = controller.clone();
|
||||
let notify = on_budget_changed.clone();
|
||||
window.on_settings_face_detector_picked(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(choice) = FaceDetector::ALL.get(i as usize).copied() else {
|
||||
return;
|
||||
};
|
||||
ctl.edit(|s| s.faces.detector = choice);
|
||||
notify(&ctl.snapshot());
|
||||
render(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// --- export --------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
|
||||
@@ -879,6 +879,12 @@ export component AppWindow inherits Window {
|
||||
in property <int> settings-target-selected: 0;
|
||||
in property <string> settings-error: "";
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
/// Which face detector the indexing pass runs.
|
||||
in property <[string]> settings-face-detector-labels;
|
||||
in property <int> settings-face-detector-selected: 0;
|
||||
callback settings-face-detector-picked(int);
|
||||
|
||||
callback settings-group-nav-picked(int);
|
||||
callback settings-format-picked(int);
|
||||
callback settings-quality-changed(int);
|
||||
@@ -1350,6 +1356,9 @@ in property <bool> panel-visible: true;
|
||||
face-indexing: root.identity-indexing;
|
||||
face-coverage: root.identity-coverage;
|
||||
face-model-missing: root.identity-model-missing;
|
||||
face-detector-labels: root.settings-face-detector-labels;
|
||||
face-detector-selected: root.settings-face-detector-selected;
|
||||
face-detector-picked(i) => { root.settings-face-detector-picked(i); }
|
||||
library-open: root.library-open;
|
||||
thumbnail-library() => { root.library-thumbnail-all(); }
|
||||
index-faces() => { root.identity-index(); }
|
||||
|
||||
@@ -785,7 +785,7 @@ export component IdentityScreen inherits Rectangle {
|
||||
border-radius: Theme.radius;
|
||||
background: Theme.surface-raised;
|
||||
Text {
|
||||
text: "No face model installed — indexing is off.";
|
||||
text: "The chosen face detector is not installed — indexing is off.";
|
||||
color: Theme.warn-ink;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,13 @@ export component SettingsPage inherits Rectangle {
|
||||
/// No model on disk, so the pass cannot run at all.
|
||||
in property <bool> face-model-missing: false;
|
||||
callback index-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
|
||||
/// counted per pipeline, so picking another one starts from nothing.
|
||||
in property <[string]> face-detector-labels;
|
||||
in property <int> face-detector-selected: 0;
|
||||
callback face-detector-picked(int);
|
||||
|
||||
// --- cache ---------------------------------------------------------
|
||||
in-out property <string> original-budget;
|
||||
@@ -444,13 +451,32 @@ export component SettingsPage inherits Rectangle {
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.library-open: Segmented {
|
||||
label: "Face detector";
|
||||
options: root.face-detector-labels;
|
||||
selected: root.face-detector-selected;
|
||||
enabled: !root.face-indexing;
|
||||
picked(i) => { root.face-detector-picked(i); }
|
||||
}
|
||||
|
||||
if root.library-open: Caption {
|
||||
text: "Fast misses the small faces in a group and "
|
||||
+ "mistakes the odd dog for one. Balanced "
|
||||
+ "finds a seventh more for almost the same "
|
||||
+ "time. Thorough finds the most and takes "
|
||||
+ "three times as long. Changing it indexes "
|
||||
+ "the library again; names you have "
|
||||
+ "confirmed are kept.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.library-open && root.face-coverage != "": Caption {
|
||||
text: root.face-coverage;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.library-open && root.face-model-missing: Caption {
|
||||
text: "No face model is installed, so this cannot run.";
|
||||
text: "The chosen detector is not installed, so this cannot run.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user