Skip to main content

openentropy_core/sources/sensor/
camera.rs

1//! CameraNoiseSource — Camera sensor noise (read noise dominated).
2//!
3//! Captures frames from the camera via ffmpeg's avfoundation backend as raw
4//! grayscale video, then extracts the lower 4 bits of each pixel value.
5//! In dark frames at typical webcam exposures, these LSBs are dominated by
6//! read noise from the amplifier (~95%+), with small contributions from dark
7//! current and dark current shot noise.
8//!
9//! A persistent ffmpeg subprocess streams frames continuously so the camera
10//! LED stays solid instead of flickering on/off every collection cycle.
11
12use std::io::Read;
13use std::process::{Child, Command, Stdio};
14use std::sync::{Arc, Mutex};
15use std::thread::{self, JoinHandle};
16
17use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
18
19use crate::sources::helpers::{command_exists, pack_nibbles};
20
21const FRAME_WIDTH: usize = 320;
22const FRAME_HEIGHT: usize = 240;
23const FRAME_SIZE: usize = FRAME_WIDTH * FRAME_HEIGHT; // 76800 bytes per gray frame
24
25static CAMERA_NOISE_INFO: SourceInfo = SourceInfo {
26    name: "camera_noise",
27    description: "Camera sensor noise (read noise + dark current) via ffmpeg",
28    physics: "Captures frames from the camera sensor in darkness. Noise sources: (1) read \
29              noise from the amplifier \u{2014} classical analog noise, dominates at short \
30              exposures (~95%+ of variance); (2) dark current from thermal carrier generation \
31              in silicon \u{2014} classical at sensor operating temperatures; (3) dark current \
32              shot noise (Poisson counting) \u{2014} ~1-5% of variance in typical webcams. \
33              The LSBs of pixel values mix all three components.",
34    category: SourceCategory::Sensor,
35    platform: Platform::MacOS,
36    requirements: &[Requirement::Camera],
37    entropy_rate_estimate: 5.0,
38    composite: false,
39    is_fast: false,
40};
41
42/// Configuration for camera device selection.
43pub struct CameraNoiseConfig {
44    /// AVFoundation video device index (e.g., 0, 1, 2).
45    /// `None` means try common selectors in fallback order.
46    pub device_index: Option<u32>,
47}
48
49impl Default for CameraNoiseConfig {
50    fn default() -> Self {
51        let device_index = std::env::var("OPENENTROPY_CAMERA_DEVICE")
52            .ok()
53            .and_then(|s| s.parse().ok());
54        Self { device_index }
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Persistent ffmpeg subprocess
60// ---------------------------------------------------------------------------
61
62/// A long-lived ffmpeg process that continuously streams grayscale frames.
63///
64/// A reader thread reads exactly `FRAME_SIZE` bytes per frame from ffmpeg's
65/// stdout and stores the latest frame in shared memory. `collect()` just
66/// clones the most recent frame, avoiding the LED flicker caused by
67/// spawning a new process every second.
68struct PersistentCamera {
69    child: Child,
70    latest_frame: Arc<Mutex<Option<Vec<u8>>>>,
71    _reader: JoinHandle<()>,
72}
73
74impl PersistentCamera {
75    /// Try to spawn a persistent ffmpeg process for camera capture.
76    ///
77    /// Tries each avfoundation input selector in order. For the first one
78    /// that produces a frame within 2 seconds, returns a `PersistentCamera`.
79    /// Returns `None` if no device works.
80    fn spawn(device_index: Option<u32>) -> Option<Self> {
81        let inputs: Vec<String> = match device_index {
82            Some(n) => vec![format!("{n}:none")],
83            None => vec![
84                "default:none".into(),
85                "0:none".into(),
86                "1:none".into(),
87                "0:0".into(),
88            ],
89        };
90
91        for input in &inputs {
92            if let Some(cam) = Self::try_spawn(input) {
93                return Some(cam);
94            }
95        }
96        None
97    }
98
99    /// Attempt to spawn ffmpeg with a single avfoundation input selector.
100    ///
101    /// Waits up to 2 seconds for the first frame. If no frame arrives
102    /// (permission denied, device busy, wrong selector), kills the process
103    /// and returns `None`.
104    fn try_spawn(input: &str) -> Option<Self> {
105        let size = format!("{}x{}", FRAME_WIDTH, FRAME_HEIGHT);
106        let mut child = Command::new("ffmpeg")
107            .args([
108                "-hide_banner",
109                "-loglevel",
110                "error",
111                "-nostdin",
112                "-f",
113                "avfoundation",
114                "-framerate",
115                "30",
116                "-video_size",
117                &size,
118                "-i",
119                input,
120                "-f",
121                "rawvideo",
122                "-pix_fmt",
123                "gray",
124                "pipe:1",
125            ])
126            .stdin(Stdio::null())
127            .stdout(Stdio::piped())
128            .stderr(Stdio::null())
129            .spawn()
130            .ok()?;
131
132        let stdout = child.stdout.take()?;
133        let latest_frame: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
134        let frame_ref = Arc::clone(&latest_frame);
135
136        let reader = thread::spawn(move || {
137            Self::reader_loop(stdout, frame_ref);
138        });
139
140        // Wait up to 2 seconds for the first frame to confirm the device works.
141        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
142        loop {
143            if std::time::Instant::now() >= deadline {
144                // No frame arrived — kill and bail.
145                let _ = child.kill();
146                let _ = child.wait();
147                // Reader thread will exit when pipe closes.
148                return None;
149            }
150            {
151                let guard = latest_frame.lock().unwrap();
152                if guard.is_some() {
153                    break;
154                }
155            }
156            thread::sleep(std::time::Duration::from_millis(25));
157        }
158
159        Some(PersistentCamera {
160            child,
161            latest_frame,
162            _reader: reader,
163        })
164    }
165
166    /// Continuously read frames from ffmpeg stdout.
167    ///
168    /// Each frame is exactly `FRAME_SIZE` bytes of raw grayscale pixels.
169    /// On EOF or error (ffmpeg died), sets `latest_frame` to `None` so
170    /// `collect()` knows to respawn.
171    fn reader_loop(
172        mut stdout: std::process::ChildStdout,
173        latest_frame: Arc<Mutex<Option<Vec<u8>>>>,
174    ) {
175        let mut buf = vec![0u8; FRAME_SIZE];
176        loop {
177            match stdout.read_exact(&mut buf) {
178                Ok(()) => {
179                    let mut guard = latest_frame.lock().unwrap();
180                    *guard = Some(buf.clone());
181                }
182                Err(_) => {
183                    // EOF or broken pipe — ffmpeg exited.
184                    let mut guard = latest_frame.lock().unwrap();
185                    *guard = None;
186                    return;
187                }
188            }
189        }
190    }
191
192    /// Returns the most recent frame, or `None` if ffmpeg has died.
193    fn take_frame(&self) -> Option<Vec<u8>> {
194        let guard = self.latest_frame.lock().unwrap();
195        guard.clone()
196    }
197
198    /// Returns `true` if the reader thread has signaled that ffmpeg died.
199    fn is_dead(&self) -> bool {
200        let guard = self.latest_frame.lock().unwrap();
201        guard.is_none()
202    }
203}
204
205impl Drop for PersistentCamera {
206    fn drop(&mut self) {
207        let _ = self.child.kill();
208        let _ = self.child.wait();
209        // Reader thread will exit naturally when the pipe closes.
210    }
211}
212
213// ---------------------------------------------------------------------------
214// CameraNoiseSource
215// ---------------------------------------------------------------------------
216
217/// Entropy source that harvests sensor noise from camera dark frames.
218pub struct CameraNoiseSource {
219    pub config: CameraNoiseConfig,
220    camera: Mutex<Option<PersistentCamera>>,
221}
222
223impl Default for CameraNoiseSource {
224    fn default() -> Self {
225        Self {
226            config: CameraNoiseConfig::default(),
227            camera: Mutex::new(None),
228        }
229    }
230}
231
232impl EntropySource for CameraNoiseSource {
233    fn info(&self) -> &SourceInfo {
234        &CAMERA_NOISE_INFO
235    }
236
237    fn is_available(&self) -> bool {
238        cfg!(target_os = "macos") && command_exists("ffmpeg")
239    }
240
241    fn collect(&self, n_samples: usize) -> Vec<u8> {
242        let mut guard = self.camera.lock().unwrap();
243
244        // Lazy-init: spawn persistent camera on first call.
245        if guard.is_none() {
246            *guard = PersistentCamera::spawn(self.config.device_index);
247        }
248
249        // If spawn failed (no camera / no ffmpeg), nothing to do.
250        let cam = match guard.as_ref() {
251            Some(c) => c,
252            None => return Vec::new(),
253        };
254
255        // Grab the latest frame from the reader thread.
256        let raw_frame = match cam.take_frame() {
257            Some(frame) if !frame.is_empty() => frame,
258            _ => {
259                // ffmpeg died — drop so next call respawns.
260                if cam.is_dead() {
261                    *guard = None;
262                }
263                return Vec::new();
264            }
265        };
266
267        // Extract the lower 4 bits of each pixel value and pack nibbles.
268        let nibbles = raw_frame.iter().map(|pixel| pixel & 0x0F);
269        pack_nibbles(nibbles, n_samples)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn camera_noise_info() {
279        let src = CameraNoiseSource::default();
280        assert_eq!(src.name(), "camera_noise");
281        assert_eq!(src.info().category, SourceCategory::Sensor);
282        assert_eq!(src.info().entropy_rate_estimate, 5.0);
283        assert!(!src.info().composite);
284    }
285
286    #[test]
287    fn camera_config_default_is_none() {
288        // With no env var set, device_index should be None.
289        let config = CameraNoiseConfig { device_index: None };
290        assert!(config.device_index.is_none());
291    }
292
293    #[test]
294    fn camera_config_explicit_device() {
295        let src = CameraNoiseSource {
296            config: CameraNoiseConfig {
297                device_index: Some(1),
298            },
299            camera: Mutex::new(None),
300        };
301        assert_eq!(src.config.device_index, Some(1));
302    }
303
304    #[test]
305    #[cfg(target_os = "macos")]
306    #[ignore] // Requires camera and ffmpeg
307    fn camera_noise_collects_bytes() {
308        let src = CameraNoiseSource::default();
309        if src.is_available() {
310            let data = src.collect(64);
311            assert!(!data.is_empty());
312            assert!(data.len() <= 64);
313        }
314    }
315
316    #[test]
317    fn camera_source_is_send_sync() {
318        fn assert_send_sync<T: Send + Sync>() {}
319        assert_send_sync::<CameraNoiseSource>();
320    }
321
322    #[test]
323    fn persistent_camera_constants() {
324        assert_eq!(FRAME_WIDTH, 320);
325        assert_eq!(FRAME_HEIGHT, 240);
326        assert_eq!(FRAME_SIZE, 320 * 240);
327        assert_eq!(FRAME_SIZE, 76800);
328    }
329}