Merge PR #1663 (rebased)

# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Brandon Thomas
2026-10-15 03:40:17 -04:00
30 changed files with 2726 additions and 43 deletions
+147 -11
View File
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use super::{CommandSpec, always, bad, cmd, has_selection};
use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
@@ -54,10 +54,12 @@ impl NoDetector {
}
}
/// The YuNet loaded last, and from where.
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
let home = s
.face_models_dir
.as_deref()
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
let d = Arc::new(
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
);
#[cfg(not(target_arch = "wasm32"))]
{
let net = d.clone();
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
}
*loaded = Some((path, d.clone()));
Ok(d)
}
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
#[derive(Clone, Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GpuState {
/// The background check is still running.
pub checking: bool,
/// The GPU it runs on; `None` when it runs on the CPU.
pub device: Option<String>,
/// Why it runs on the CPU.
pub why: Option<String>,
}
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
}
fn set_gpu_state(what: &'static str, st: GpuState) {
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
match all.iter_mut().find(|(w, _)| *w == what) {
Some((_, old)) => *old = st,
None => all.push((what, st)),
}
}
/// The user chose the CPU for face models (Settings ▸ Faces).
pub(crate) fn cpu_only(s: &Session) -> bool {
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
}
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
/// The check shows in the activity stack while it runs.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn offer_gpu(
s: &Session,
what: &'static str,
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
) {
if cpu_only(s) {
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
return;
}
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
task.detail(what);
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
let _task = task;
let gpu = match lightcraft_gpu::faces::accelerator() {
Ok(gpu) => gpu,
Err(why) => {
log::info!("faces: {what} runs on the CPU ({why})");
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
};
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
match attach(gpu) {
Ok(()) => {
log::info!("faces: {what} runs on {device}");
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
}
Err(why) => {
log::info!("faces: {what} stays on the CPU: {why}");
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
}
}
});
if let Err(e) = spawned {
log::warn!("faces: could not check the GPU for {what}: {e}");
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
}
}
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.gpu";
if let Some(cpu) = p.get("cpuOnly") {
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
super::face_models::set_cpu_only(&dir, cpu)?;
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
#[cfg(not(target_arch = "wasm32"))]
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
#[cfg(target_arch = "wasm32")]
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
if cpu {
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
if let Some(net) = net {
net.detach();
}
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(d) = detector {
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
}
if let Some(e) = embedder {
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
}
}
}
}
Ok(gpu_json(s))
}
/// What `faces.gpu` and `faces.models.list` say about the GPU.
pub(crate) fn gpu_json(s: &Session) -> Value {
json!({
"cpuOnly": cpu_only(s),
"detection": gpu_state("detection"),
"recognition": gpu_state("recognition"),
})
}
fn face_json(f: &Face) -> Value {
json!({
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
)]
vec![
cmd!(
"faces.detect",
"Detect Faces",
["Photo"],
None,
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
has_selection,
detect
),
cmd!(
query "faces.gpu",
"Face Models on the GPU",
[],
None,
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
always,
gpu
),
]
}
#[cfg(test)]
+12
View File
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
pub(super) enabled: bool,
/// The installed recogniser in use.
pub(super) embedder: Option<String>,
/// Keep every face model on the CPU, even when the GPU would be faster.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(super) cpu_only: bool,
}
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
})
}
/// Keep the face models on the CPU (or let them use the GPU again).
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
let mut st = read_settings(dir);
st.cpu_only = on;
write_settings(dir, &st)
}
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
"dir": dir.as_ref().map(|d| d.display().to_string()),
"enabled": settings.enabled,
"embedder": settings.embedder,
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
"gpu": super::face_detect::gpu_json(s),
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
"runtime": cfg!(not(target_arch = "wasm32")),
"models": models,
+5
View File
@@ -321,6 +321,8 @@ mod imp {
return Ok(e.clone());
}
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
let net = embedder.clone();
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
let dim = match installed.manifest.output {
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
@@ -672,6 +674,9 @@ mod imp {
if let Some(w) = &s.faces.worker {
w.allow(target, pace);
}
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
let (runs, share) = crate::faces_worker::gpu_pace(pace);
lightcraft_gpu::faces::set_pace(runs, share);
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
let Some(id) = s.faces.queue.pop() else { break };
let Some(job) = prepare(s, id, true) else { continue };