1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
|
//! 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<PathBuf>,
}
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<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()),
);
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));
}
}
|