diff options
Diffstat (limited to 'crates/asist-tools/src/camera.rs')
| -rw-r--r-- | crates/asist-tools/src/camera.rs | 313 |
1 files changed, 96 insertions, 217 deletions
diff --git a/crates/asist-tools/src/camera.rs b/crates/asist-tools/src/camera.rs index fe41319..bdc2aa0 100644 --- a/crates/asist-tools/src/camera.rs +++ b/crates/asist-tools/src/camera.rs @@ -1,56 +1,38 @@ -//! Mirar por la cámara. -//! -//! El servidor ya tiene cargado el proyector multimodal, así que el mismo -//! modelo que conversa puede describir una imagen. La herramienta hace tres -//! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la -//! respuesta como texto. -//! -//! Que la pregunta viaje hasta la cámara importa: «¿de qué color es mi -//! camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen pero -//! descripciones muy distintas, y pedir una descripción genérica para luego -//! interrogarla pierde justo el detalle que se buscaba. -//! -//! La captura no se guarda en disco salvo que se pida expresamente: un -//! asistente que deja fotogramas por ahí es un problema de privacidad, no una -//! comodidad de depuración. +//! La cámara como fuente de imágenes. use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use asist_core::error::{Error, Result}; -use asist_core::http::Cancel; -use asist_core::tools::Tool; -use asist_llm::LlmClient; -use serde_json::{json, Value}; -/// Cómo se captura el fotograma. +use crate::vision::{capture_jpeg, FrameSource}; + +const TOOL: &str = "mirar_por_la_camara"; + #[derive(Debug, Clone)] -pub struct CaptureConfig { +pub struct CameraConfig { /// Dispositivo V4L2. pub device: PathBuf, + /// Resolución de captura. Medido con este modelo: 1,3 s a 320x240, 2,9 s a + /// 640x480 y 7,8 s a 1280x720, con la misma descripción útil a partir de + /// 640. Para una escena basta; para leer texto no (eso es la pantalla). pub width: u32, pub height: u32, /// Fotogramas que se descartan antes de quedarse con uno. /// /// La cámara arranca con la exposición automática sin asentar y el primer /// fotograma suele salir quemado. Descartar unos pocos es prácticamente - /// gratis —medido, 0,45 s frente a 0,53 s— y en penumbra se nota. + /// gratis: medido, 0,45 s frente a 0,53 s. pub warmup_frames: u32, - /// Plazo máximo de la captura. pub timeout: Duration, - /// Carpeta donde dejar los fotogramas. Vacío = no se guarda ninguno. + /// Carpeta donde dejar los fotogramas. `None` = no se guarda ninguno. pub save_dir: Option<PathBuf>, } -impl Default for CaptureConfig { +impl Default for CameraConfig { fn default() -> Self { Self { device: PathBuf::from("/dev/video0"), - // 640x480 es el punto de equilibrio medido: el modelo tarda 2,9 s - // y sigue describiendo bien. A 1280x720 tarda 7,8 s, y a 320x240 - // baja a 1,3 s pero deja de distinguir detalles. width: 640, height: 480, warmup_frames: 5, @@ -60,190 +42,87 @@ impl Default for CaptureConfig { } } -/// Captura un fotograma en JPEG. -/// -/// Se apoya en ffmpeg en vez de hablar con V4L2 directamente: una cámara USB -/// entrega MJPEG, YUYV o lo que le parezca, y reimplementar esa negociación -/// para ahorrarse un proceso no sale a cuenta. -pub fn capture(config: &CaptureConfig) -> Result<Vec<u8>> { - let fail = |message: String| Error::Tool { - tool: "mirar_por_la_camara".into(), - message, - }; - - if !config.device.exists() { - return Err(fail(format!( - "no existe el dispositivo {}. Comprueba con «v4l2-ctl --list-devices»", - config.device.display() - ))); - } - - let started = Instant::now(); - let mut command = Command::new("ffmpeg"); - command - .args(["-hide_banner", "-loglevel", "error", "-nostdin"]) - .args(["-f", "v4l2"]) - .args([ - "-video_size", - &format!("{}x{}", config.width, config.height), - ]) - .arg("-i") - .arg(&config.device); - - if config.warmup_frames > 0 { - // Se leen N fotogramas y se conserva el último: es la forma de dejar - // que la exposición se asiente sin abrir el dispositivo dos veces. - command.args(["-vf", &format!("select=eq(n\\,{})", config.warmup_frames)]); - } - command - .args(["-frames:v", "1"]) - .args(["-f", "image2", "-c:v", "mjpeg"]) - .arg("-") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = command.spawn().map_err(|e| { - fail(format!( - "no se pudo ejecutar ffmpeg ({e}); hace falta para leer la cámara" - )) - })?; - - let deadline = Instant::now() + config.timeout; - loop { - match child.try_wait().map_err(|e| fail(e.to_string()))? { - Some(_) => break, - None if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(fail(format!( - "la cámara no respondió en {} s", - config.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } - - let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; - if !output.status.success() || output.stdout.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - let hint = if stderr.contains("Permission denied") { - ". Tu usuario necesita estar en el grupo «video»" - } else if stderr.contains("Device or resource busy") { - ". Otra aplicación está usando la cámara" - } else { - "" - }; - return Err(fail(format!( - "ffmpeg no capturó nada{hint}: {}", - stderr.trim().lines().next_back().unwrap_or("sin detalles") - ))); - } - - if let Some(dir) = &config.save_dir { - // Sólo si se ha pedido: por defecto la imagen no toca el disco. - if let Err(err) = std::fs::create_dir_all(dir).and_then(|()| { - let name = format!( - "frame-{}.jpg", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - ); - std::fs::write(dir.join(name), &output.stdout) - }) { - tracing::warn!(target: "camara", %err, "no se pudo guardar el fotograma"); - } - } - - tracing::info!( - target: "camara", - dispositivo = %config.device.display(), - resolucion = format!("{}x{}", config.width, config.height), - kb = output.stdout.len() / 1024, - ms = started.elapsed().as_millis(), - "fotograma capturado" - ); - Ok(output.stdout) -} - pub struct Camera { - config: CaptureConfig, - llm: Arc<LlmClient>, + config: CameraConfig, } impl Camera { - pub fn new(config: CaptureConfig, llm: Arc<LlmClient>) -> Self { - Self { config, llm } - } - - /// `true` si el dispositivo está presente. Sin esto no tiene sentido - /// declarar la herramienta: el modelo la llamaría y fallaría siempre. - pub fn available(config: &CaptureConfig) -> bool { - config.device.exists() + pub fn new(config: CameraConfig) -> Self { + Self { config } } } -impl Tool for Camera { - fn name(&self) -> &str { - "mirar_por_la_camara" +impl FrameSource for Camera { + fn label(&self) -> &str { + "cámara" } - fn description(&self) -> &str { - "Toma una foto con la cámara del equipo y responde a una pregunta sobre \ - lo que se ve. Úsala cuando te pregunten qué ves, qué hay delante, de qué \ - color es algo o cuántas cosas hay." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "pregunta": { - "type": "string", - "description": "La pregunta del usuario tal cual, sin concretarla más de lo que él dijo. Si sólo quiere saber qué hay delante, pon: ¿Qué se ve?" - } - }, - "required": ["pregunta"] - }) - } - - /// Enciende la cámara, así que se anuncia como tal. - fn is_side_effecting(&self) -> bool { - true - } + fn available(&self) -> Result<()> { + if !self.config.device.exists() { + return Err(Error::Tool { + tool: TOOL.into(), + message: format!( + "no existe {}; comprueba con «v4l2-ctl --list-devices»", + self.config.device.display() + ), + }); + } + Ok(()) + } + + fn capture(&self) -> Result<Vec<u8>> { + self.available()?; + + // ffmpeg y no V4L2 a pelo: una cámara USB entrega MJPEG, YUYV o lo que + // le parezca, y reimplementar esa negociación para ahorrarse un + // proceso no sale a cuenta. + let mut args = vec![ + "-hide_banner".into(), + "-loglevel".into(), + "error".into(), + "-nostdin".into(), + "-f".into(), + "v4l2".into(), + "-video_size".into(), + format!("{}x{}", self.config.width, self.config.height), + "-i".into(), + self.config.device.to_string_lossy().into_owned(), + ]; + if self.config.warmup_frames > 0 { + // Se leen N fotogramas y se conserva el último: así la exposición + // se asienta sin abrir el dispositivo dos veces. + args.push("-vf".into()); + args.push(format!("select=eq(n\\,{})", self.config.warmup_frames)); + } + args.extend( + ["-frames:v", "1", "-f", "image2", "-c:v", "mjpeg", "-"] + .iter() + .map(|s| s.to_string()), + ); - fn acknowledgement(&self) -> Option<&str> { - Some("Voy a mirar.") + let frame = capture_jpeg(TOOL, "ffmpeg".as_ref(), &args, self.config.timeout)?; + if let Some(dir) = &self.config.save_dir { + save(dir, &frame, "camara"); + } + Ok(frame) } +} - fn call(&self, args: &Value) -> Result<String> { - let question = args - .get("pregunta") - .and_then(Value::as_str) - .map(str::trim) - .filter(|q| !q.is_empty()) - .unwrap_or("¿Qué se ve en esta imagen?"); - - let frame = capture(&self.config)?; - - // Se le pide al modelo el estilo hablado aquí y no en la conversación: - // esta petición va fuera del historial, así que la instrucción de voz - // del asistente no le llega. - let prompt = format!( - "{question}\n\nResponde en una o dos frases cortas en español, en texto \ - plano, describiendo sólo lo que se ve de verdad en la imagen. Si no se \ - distingue, dilo." - ); - let started = Instant::now(); - let answer = self.llm.look(&frame, &prompt, &Cancel::new())?; - tracing::info!( - target: "camara", - ms = started.elapsed().as_millis(), - "el modelo describió el fotograma" - ); - Ok(answer) +/// Guarda una copia sólo si se ha pedido expresamente. Por defecto no se +/// escribe nada: un asistente que deja fotogramas por ahí es un problema de +/// privacidad, no una comodidad de depuración. +pub(crate) fn save(dir: &std::path::Path, bytes: &[u8], prefix: &str) { + let name = format!( + "{prefix}-{}.jpg", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + if let Err(err) = + std::fs::create_dir_all(dir).and_then(|()| std::fs::write(dir.join(name), bytes)) + { + tracing::warn!(target: "vision", %err, "no se pudo guardar la captura"); } } @@ -253,38 +132,38 @@ mod tests { #[test] fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() { - let config = CaptureConfig { + let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() - }; - assert!(!Camera::available(&config)); + }); + let err = camera.available().unwrap_err().to_string(); + assert!(err.contains("no existe"), "{err}"); + assert!( + err.contains("v4l2-ctl"), + "el error debe decir cómo comprobarlo: {err}" + ); } #[test] - fn capturar_de_un_dispositivo_inexistente_da_un_error_util() { - let config = CaptureConfig { + fn capturar_sin_dispositivo_falla_sin_llegar_a_ffmpeg() { + let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() - }; - let err = capture(&config).unwrap_err().to_string(); - assert!(err.contains("no existe el dispositivo"), "{err}"); - assert!( - err.contains("v4l2-ctl"), - "el error debe decir cómo comprobarlo: {err}" - ); + }); + assert!(camera.capture().is_err()); } #[test] fn por_defecto_no_se_guarda_ningun_fotograma() { assert!( - CaptureConfig::default().save_dir.is_none(), + CameraConfig::default().save_dir.is_none(), "guardar imágenes por defecto sería una fuga de privacidad" ); } #[test] fn la_resolucion_por_defecto_es_la_medida_como_equilibrada() { - let config = CaptureConfig::default(); + let config = CameraConfig::default(); assert_eq!((config.width, config.height), (640, 480)); } } |