//! La cámara como fuente de imágenes. use std::path::PathBuf; use std::time::Duration; use asist_core::error::{Error, Result}; use crate::vision::{capture_jpeg, FrameSource}; const TOOL: &str = "mirar_por_la_camara"; #[derive(Debug, Clone)] 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. pub warmup_frames: u32, pub timeout: Duration, /// Carpeta donde dejar los fotogramas. `None` = no se guarda ninguno. pub save_dir: Option, } impl Default for CameraConfig { fn default() -> Self { Self { device: PathBuf::from("/dev/video0"), width: 640, height: 480, warmup_frames: 5, timeout: Duration::from_secs(15), save_dir: None, } } } pub struct Camera { config: CameraConfig, } impl Camera { pub fn new(config: CameraConfig) -> Self { Self { config } } } impl FrameSource for Camera { fn label(&self) -> &str { "cámara" } 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> { 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()), ); 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) } } /// 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"); } } #[cfg(test)] mod tests { use super::*; #[test] fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() { let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() }); 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_sin_dispositivo_falla_sin_llegar_a_ffmpeg() { let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() }); assert!(camera.capture().is_err()); } #[test] fn por_defecto_no_se_guarda_ningun_fotograma() { assert!( 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 = CameraConfig::default(); assert_eq!((config.width, config.height), (640, 480)); } }