lightcraft/crates/engine/src/segment/worker.rs

477 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The SAM 3 worker thread: it owns the model and the last encoded photo, and runs every
//! request (load, render the model input, encode, clicks, descriptions, detail passes) in
//! turn. The session only sends jobs and receives outcomes over channels, so no thread ever
//! waits on a lock the model holds, and the UI thread never runs the model.
//!
//! Every job runs under `catch_unwind`: a panic inside candle becomes an error outcome, drops
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use lightcraft_develop::SegMask;
use lightcraft_segment::{
Encoded, Sam3,
remote::{self, Operation, Request, text_logits},
};
use super::{Input, Prompt, Tag};
use crate::media::RenderJob;
/// Unload the model after this long without a request.
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
/// What to compute.
pub enum Kind {
/// Load the model and encode the photo (so the first click is fast).
Prepare,
Clicks(Vec<lightcraft_segment::Click>),
Text(String),
/// A zoomed-in pass over `region` (normalized) of the photo, rendered by the job's `render`.
Detail {
prompt: Prompt,
region: [f64; 4],
},
/// Panics (tests of the worker's recovery).
#[cfg(test)]
Panic,
}
/// One request.
pub struct Job {
/// The model folder.
pub dir: PathBuf,
/// Which photo and look the request is about (the encode cache's key).
pub key: u64,
/// Identity of the full photo input, including for a detail job whose encode-cache key is zero.
pub source_key: u64,
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
pub render: Option<RenderJob>,
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
pub missing: Option<String>,
pub kind: Kind,
pub tag: Tag,
pub reply: mpsc::Sender<Outcome>,
}
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
pub struct Outcome {
pub tag: Tag,
pub source_key: u64,
pub result: Result<Option<SegMask>, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
}
/// Counters the session reads without locking.
#[derive(Default)]
pub struct Shared {
/// Requests other than detail passes, queued or running.
pub pending: AtomicUsize,
/// Detail passes queued or running.
pub detail: AtomicUsize,
/// The model is in memory.
pub loaded: AtomicBool,
/// Loading the model or encoding a photo right now (the slow parts).
pub encoding: AtomicBool,
}
/// Decrements a counter when dropped (also when unwinding).
struct Done<'a>(&'a AtomicUsize);
impl Drop for Done<'_> {
fn drop(&mut self) {
let _ = self.0.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
}
}
/// Sets a flag while alive.
struct Flag<'a>(&'a AtomicBool);
impl<'a> Flag<'a> {
fn raise(f: &'a AtomicBool) -> Self {
f.store(true, Ordering::SeqCst);
Flag(f)
}
}
impl Drop for Flag<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
/// The session's handle on the worker thread (started on the first request, restarted if it
/// ever ends).
#[derive(Default)]
pub struct Worker {
tx: Option<mpsc::Sender<Job>>,
thread: Option<std::thread::JoinHandle<()>>,
pub shared: Arc<Shared>,
}
impl Worker {
fn counter(&self, job: &Job) -> &AtomicUsize {
if matches!(job.kind, Kind::Detail { .. }) { &self.shared.detail } else { &self.shared.pending }
}
/// Whether the thread has ended (it never should); its counters are then meaningless.
fn dead(&self) -> bool {
self.thread.as_ref().is_some_and(|t| t.is_finished())
}
pub fn pending(&self) -> usize {
if self.dead() { 0 } else { self.shared.pending.load(Ordering::SeqCst) }
}
pub fn detail(&self) -> usize {
if self.dead() { 0 } else { self.shared.detail.load(Ordering::SeqCst) }
}
pub fn encoding(&self) -> bool {
!self.dead() && self.shared.encoding.load(Ordering::SeqCst)
}
pub fn loaded(&self) -> bool {
!self.dead() && self.shared.loaded.load(Ordering::SeqCst)
}
/// Queue `job`; returns at once.
pub fn submit(&mut self, job: Job) -> Result<(), String> {
if self.dead() {
// a thread that ended took its counters' meaning with it
self.tx = None;
self.thread = None;
self.shared = Arc::new(Shared::default());
}
self.counter(&job).fetch_add(1, Ordering::SeqCst);
let job = match &self.tx {
Some(tx) => match tx.send(job) {
Ok(()) => return Ok(()),
Err(mpsc::SendError(job)) => job,
},
None => job,
};
// (re)start the thread
let (tx, rx) = mpsc::channel();
let shared = self.shared.clone();
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
Ok(t) => {
self.thread = Some(t);
self.tx = Some(tx.clone());
tx.send(job).map_err(|_| "the AI mask worker stopped".to_string())
}
Err(e) => {
let _ = self.counter(&job).try_update(Ordering::SeqCst, Ordering::SeqCst, |n| Some(n.saturating_sub(1)));
Err(format!("could not start the AI mask worker: {e}"))
}
}
}
}
/// The model and the last encoded photo.
#[derive(Default)]
struct State {
model: Option<(PathBuf, Sam3)>,
cache: Option<(u64, Encoded)>,
remote: Option<remote::Client>,
}
/// Whether `later` makes `job` pointless (it asks for the same selection again).
fn supersedes(later: &Job, job: &Job) -> bool {
match (&later.tag, &job.tag) {
(Tag::Prepare, Tag::Prepare) => later.key == job.key,
(Tag::Clicks { photo: a, mask: am, comp: ac, .. }, Tag::Clicks { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
(Tag::Detail { photo: a, mask: am, comp: ac, .. }, Tag::Detail { photo: b, mask: bm, comp: bc, .. }) => (a, am, ac) == (b, bm, bc),
_ => false,
}
}
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
let mut state = State::default();
let mut queue: VecDeque<Job> = VecDeque::new();
loop {
if queue.is_empty() {
match rx.recv_timeout(idle) {
Ok(j) => queue.push_back(j),
Err(RecvTimeoutError::Timeout) => {
if state.model.is_some() || state.remote.is_some() {
state = State::default();
shared.loaded.store(false, Ordering::SeqCst);
log::info!("SAM 3 unloaded after {} minutes without use", idle.as_secs() / 60);
}
continue;
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
while let Ok(j) = rx.try_recv() {
queue.push_back(j);
}
let Some(mut job) = queue.pop_front() else { continue };
let is_detail = matches!(job.kind, Kind::Detail { .. });
// counts the job as pending until just before its reply goes out: a caller woken by the
// reply must not still see the worker busy with it
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result: Ok(None), superseded: true });
continue;
}
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
Ok(r) => r,
Err(p) => {
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::error!("SAM 3 panicked: {why}");
// don't trust what the model kept: load it again next time
state = State::default();
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
}
};
if result.is_err() && !is_detail {
state.cache = None;
}
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result, superseded: false });
}
}
/// The model for `dir`, loading it when needed.
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
state.model = None;
state.cache = None;
let _busy = Flag::raise(&shared.encoding);
let t = web_time::Instant::now();
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
log::info!("SAM 3 loaded in {:?}", t.elapsed());
state.model = Some((dir.to_path_buf(), m));
shared.loaded.store(true, Ordering::SeqCst);
}
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
}
/// Render the model input.
fn render(job: &mut Job) -> Result<Input, String> {
let r = job.render.take().ok_or("the photo is not available")?.run();
let img = match r.rendered {
Ok(r) => r.image,
Err(e) => {
return Err(match &job.missing {
Some(path) => format!(
"This photo's file is missing (moved or deleted), so it can't be analyzed for AI masks: {path}. Library ▸ Find Missing Photos can relink it."
),
None => format!("couldn't render the photo for AI masks: {e}"),
});
}
};
let rgb = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
Ok(Input { rgb, w: img.width, h: img.height })
}
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
model(state, shared, &job.dir)?;
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
state.cache = None;
let input = render(job)?;
let _busy = Flag::raise(&shared.encoding);
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
state.cache = Some((job.key, enc));
}
let State { model, cache, .. } = state;
match (model.as_mut(), cache.as_mut()) {
(Some((_, m)), Some((_, e))) => Ok((m, e)),
_ => Err("the model is not ready".into()),
}
}
fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) -> Result<Option<SegMask>, String> {
let _busy = Flag::raise(&shared.encoding);
if state.remote.is_none() {
state.remote = Some(remote::Client::connect(endpoint)?);
}
let client = state.remote.as_mut().ok_or("remote SAM unavailable")?;
let mut region = None;
let operation = match std::mem::replace(&mut job.kind, Kind::Prepare) {
Kind::Prepare => Operation::Prepare,
Kind::Text(text) => Operation::Text(text),
Kind::Clicks(clicks) => Operation::Clicks(clicks.iter().map(|c| [c.x, c.y, if c.positive { 1.0 } else { 0.0 }]).collect()),
Kind::Detail { prompt, region: r } => {
region = Some(r);
let (rw, rh) = (r[2] - r[0], r[3] - r[1]);
if !(rw > 0.0 && rh > 0.0) {
return Ok(None);
}
match prompt {
Prompt::Text(text) => Operation::Text(text),
Prompt::Clicks(clicks) => {
let inside: Vec<_> = clicks
.iter()
.map(|c| [((c.at.x - r[0]) / rw) as f32, ((c.at.y - r[1]) / rh) as f32, if c.include { 1.0 } else { 0.0 }])
.filter(|p| (0.0..=1.0).contains(&p[0]) && (0.0..=1.0).contains(&p[1]))
.collect();
if !inside.iter().any(|p| p[2] == 1.0) {
return Ok(None);
}
Operation::Clicks(inside)
}
}
}
#[cfg(test)]
Kind::Panic => return Err("test panic request".into()),
};
let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } };
let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation };
let logits = client.request(&request, &input.rgb)?;
if region.is_none() {
client.key = Some(job.key);
}
Ok(logits.map(|l| match region {
Some(r) => SegMask::from_logits_in(lightcraft_segment::MASK_SIDE, &l, r),
None => SegMask::from_logits(lightcraft_segment::MASK_SIDE, &l),
}))
}
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
if let Some(endpoint) = remote::configured() {
let result = remote_job(state, shared, job, &endpoint);
if result.is_err() {
state.remote = None;
}
return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."));
}
let side = lightcraft_segment::MASK_SIDE;
match std::mem::replace(&mut job.kind, Kind::Prepare) {
Kind::Prepare => {
encoded(state, shared, job)?;
Ok(None)
}
Kind::Clicks(clicks) => {
let (m, enc) = encoded(state, shared, job)?;
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
Ok(Some(SegMask::from_logits(side, &p.logits)))
}
Kind::Text(text) => {
let (m, enc) = encoded(state, shared, job)?;
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
}
#[cfg(test)]
#[allow(clippy::panic)]
Kind::Panic => panic!("a test panic"),
Kind::Detail { prompt, region } => {
let input = render(job)?;
let m = model(state, shared, &job.dir)?;
let t = web_time::Instant::now();
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
if !(rw > 0.0 && rh > 0.0) {
return Ok(None);
}
let logits = match prompt {
Prompt::Clicks(clicks) => {
let inside: Vec<lightcraft_segment::Click> = clicks
.iter()
.map(|c| ((c.at.x - region[0]) / rw, (c.at.y - region[1]) / rh, c.include))
.filter(|(x, y, _)| (0.0..=1.0).contains(x) && (0.0..=1.0).contains(y))
.map(|(x, y, positive)| lightcraft_segment::Click { x: x as f32, y: y as f32, positive })
.collect();
if !inside.iter().any(|c| c.positive) {
return Ok(None);
}
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
}
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
};
log::info!("SAM 3 detail pass ({}×{}) in {:?}", input.w, input.h, t.elapsed());
Ok(logits.map(|l| SegMask::from_logits_in(side, &l, region)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
// an empty folder: loading fails with an error (no model files)
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
Job { dir, key: 1, source_key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
}
#[test]
fn errors_come_back_and_the_counters_return_to_zero() {
let mut w = Worker::default();
let (tx, rx) = mpsc::channel();
for _ in 0..3 {
w.submit(job(Tag::Wait, &tx)).unwrap();
}
for _ in 0..3 {
let o = rx.recv_timeout(Duration::from_secs(30)).unwrap();
assert!(o.result.unwrap_err().contains("not found"));
}
let t = web_time::Instant::now();
while w.pending() > 0 && t.elapsed() < Duration::from_secs(5) {
std::thread::yield_now();
}
assert_eq!(w.pending(), 0);
assert!(!w.encoding() && !w.loaded());
}
#[test]
fn a_panic_in_a_job_is_an_error_and_the_worker_carries_on() {
let shared = Shared::default();
let (tx, rx) = mpsc::channel();
let (jtx, jrx) = mpsc::channel();
let mut j = job(Tag::Wait, &tx);
j.kind = Kind::Panic;
jtx.send(j).unwrap();
jtx.send(job(Tag::Wait, &tx)).unwrap();
shared.pending.store(2, Ordering::SeqCst);
drop(jtx);
run(&jrx, &shared, Duration::from_secs(60));
let first = rx.recv().unwrap();
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
let second = rx.recv().unwrap();
assert!(second.result.is_err(), "the next job still runs");
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
assert!(!shared.encoding.load(Ordering::SeqCst));
}
#[test]
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
let (tx, rx) = mpsc::channel();
let (jtx, jrx) = mpsc::channel();
let clicks = |n: usize| Tag::Clicks {
photo: lightcraft_catalog::PhotoId(1),
mask: 1,
comp: 0,
hint: vec![lightcraft_geom::Point::new(0.5, 0.5); n],
exclude: vec![],
};
for n in 1..=3 {
jtx.send(job(clicks(n), &tx)).unwrap();
}
drop(jtx);
let shared = Shared::default();
shared.pending.store(3, Ordering::SeqCst);
run(&jrx, &shared, Duration::from_secs(60));
let out: Vec<Outcome> = rx.try_iter().collect();
assert_eq!(out.len(), 3);
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
assert_eq!(shared.pending.load(Ordering::SeqCst), 0);
}
}