//! The camera as an image source. 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, /// Capture resolution. Measured with this model: 1.3 s at 320x240, 2.9 s /// at 640x480 and 7.8 s at 1280x720, with the same useful description from /// 640 up. Enough for a scene; not for reading text (that is the screen). pub width: u32, pub height: u32, /// Frames discarded before keeping one. /// /// The camera starts with auto-exposure not settled yet and the first frame /// is usually blown out. Discarding a few is practically free: measured, /// 0.45 s versus 0.53 s. pub warmup_frames: u32, pub timeout: Duration, /// Directory to leave frames in. `None` = none is saved. 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!( "{} does not exist; check with «v4l2-ctl --list-devices»", self.config.device.display() ), }); } Ok(()) } fn capture(&self) -> Result> { self.available()?; // ffmpeg rather than raw V4L2: a USB camera delivers MJPEG, YUYV or // whatever it likes, and reimplementing that negotiation to save one // process is not worth it. 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 { // N frames are read and the last one is kept: that way exposure // settles without opening the device twice. 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) } } /// Saves a copy only if explicitly requested. By default nothing is /// written: an assistant that leaves frames lying around is a privacy /// problem, not a debugging convenience. 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, "could not save the capture"); } } #[cfg(test)] mod tests { use super::*; #[test] fn missing_device_is_detected_before_registering_the_tool() { let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-does-not-exist"), ..Default::default() }); let err = camera.available().unwrap_err().to_string(); assert!(err.contains("does not exist"), "{err}"); assert!( err.contains("v4l2-ctl"), "the error must say how to check it: {err}" ); } #[test] fn capturing_without_a_device_fails_before_ffmpeg() { let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-does-not-exist"), ..Default::default() }); assert!(camera.capture().is_err()); } #[test] fn no_frame_is_saved_by_default() { assert!( CameraConfig::default().save_dir.is_none(), "saving images by default would be a privacy leak" ); } #[test] fn default_resolution_is_the_measured_balance() { let config = CameraConfig::default(); assert_eq!((config.width, config.height), (640, 480)); } }