aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools/src/camera.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools/src/camera.rs')
-rw-r--r--crates/asist-tools/src/camera.rs313
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));
}
}