Skip to main content

ci2_webcam/
lib.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A [ci2] camera backend for consumer webcams (UVC and similar).
5//!
6//! This backend is intended as a development convenience so that Strand Camera
7//! can be exercised without high-end machine-vision hardware. It is *not* the
8//! default backend (Pylon is) and deliberately implements only the subset of
9//! the [ci2::Camera] trait that maps cleanly onto a webcam.
10//!
11//! Frame capture is provided by the [`nokhwa`] crate. The backend is named
12//! `webcam` rather than `nokhwa` so the underlying capture library can be
13//! swapped later without changing the user-facing backend name.
14//!
15//! # Supported vs. unsupported features
16//!
17//! Webcams expose almost none of the controls that machine-vision cameras do.
18//! Operations that have no webcam analogue (hardware triggering, exposure time
19//! in microseconds, gain in dB, node-map save/load, frame-rate limiting, the
20//! generic GenICam feature accessors) return [`ci2::Error::FeatureNotPresent`].
21//! Strand Camera's startup path tolerates this error for the values it reads.
22//!
23//! # Pixel formats
24//!
25//! Each frame from the webcam (commonly YUYV or MJPEG) is decoded on the host.
26//! Both [`PixFmt::RGB8`] and [`PixFmt::Mono8`] are offered; RGB8 is the default.
27
28extern crate machine_vision_formats as formats;
29
30use std::sync::OnceLock;
31
32use nokhwa::{
33    Camera,
34    pixel_format::{LumaFormat, RgbFormat},
35    utils::{CameraIndex, RequestedFormat, RequestedFormatType},
36};
37
38use ci2::{
39    AcquisitionMode, AutoMode, DynamicFrameWithInfo, HostTimingInfo, TriggerMode, TriggerSelector,
40};
41use formats::PixFmt;
42use strand_dynamic_frame::DynamicFrameOwned;
43use tracing::info;
44
45/// Map a [`nokhwa::NokhwaError`] into a [`ci2::Error`].
46fn nokhwa_err(e: nokhwa::NokhwaError) -> ci2::Error {
47    ci2::Error::BackendError(anyhow::Error::new(e))
48}
49
50/// `nokhwa` requires a one-time initialization. On most platforms the callback
51/// fires immediately; on macOS it gates on the camera permission prompt. We
52/// block until it completes the first time and skip it on subsequent calls.
53fn ensure_nokhwa_initialized() -> ci2::Result<()> {
54    static GRANTED: OnceLock<bool> = OnceLock::new();
55
56    let granted = *GRANTED.get_or_init(|| {
57        let (tx, rx) = std::sync::mpsc::channel();
58        nokhwa::nokhwa_initialize(move |granted| {
59            let _ = tx.send(granted);
60        });
61        rx.recv().unwrap_or(false)
62    });
63
64    if granted {
65        Ok(())
66    } else {
67        Err(ci2::Error::from(
68            "nokhwa initialization failed or camera access was not granted",
69        ))
70    }
71}
72
73/// Enumerate the webcams visible to the native backend.
74fn enumerate() -> ci2::Result<Vec<nokhwa::utils::CameraInfo>> {
75    ensure_nokhwa_initialized()?;
76    let backend = nokhwa::native_api_backend()
77        .ok_or_else(|| ci2::Error::from("no native nokhwa backend available"))?;
78    nokhwa::query(backend).map_err(nokhwa_err)
79}
80
81/// A name we can round-trip through [`ci2::CameraModule::camera`].
82///
83/// Webcams have no reliable serial number, so the human-readable device name is
84/// used as the primary identifier, matching how [`nokhwa`] presents devices.
85fn device_name(info: &nokhwa::utils::CameraInfo) -> String {
86    info.human_name()
87}
88
89pub struct WrappedModule {}
90
91pub fn new_module() -> ci2::Result<WrappedModule> {
92    Ok(WrappedModule {})
93}
94
95/// The webcam backend keeps no global SDK state that needs tearing down, so the
96/// guard is a no-op. It exists to match the shape of the other ci2 backends.
97pub struct WebcamTerminateGuard {}
98
99pub fn make_singleton_guard(
100    _module: &dyn ci2::CameraModule<CameraType = WrappedCamera, Guard = WebcamTerminateGuard>,
101) -> ci2::Result<WebcamTerminateGuard> {
102    Ok(WebcamTerminateGuard {})
103}
104
105impl<'a> ci2::CameraModule for &'a WrappedModule {
106    type CameraType = WrappedCamera;
107    type Guard = WebcamTerminateGuard;
108
109    fn name(self: &&'a WrappedModule) -> &'static str {
110        "webcam"
111    }
112
113    fn camera_infos(self: &&'a WrappedModule) -> ci2::Result<Vec<Box<dyn ci2::CameraInfo>>> {
114        let infos = enumerate()?
115            .iter()
116            .map(|info| {
117                let ci: Box<dyn ci2::CameraInfo> = Box::new(WebcamCameraInfo::from_nokhwa(info));
118                ci
119            })
120            .collect();
121        Ok(infos)
122    }
123
124    fn camera(self: &mut &'a WrappedModule, name: &str) -> ci2::Result<Self::CameraType> {
125        WrappedCamera::new(name)
126    }
127
128    fn settings_file_extension(&self) -> &str {
129        // Webcams have no node map, but a value is required by the trait.
130        "txt"
131    }
132}
133
134#[derive(Debug, Clone)]
135struct WebcamCameraInfo {
136    name: String,
137    serial: String,
138    model: String,
139    vendor: String,
140}
141
142impl WebcamCameraInfo {
143    fn from_nokhwa(info: &nokhwa::utils::CameraInfo) -> Self {
144        // Webcams do not expose vendor/serial the way GenICam cameras do, so we
145        // populate these from the information nokhwa provides.
146        Self {
147            name: device_name(info),
148            serial: index_to_string(info.index()),
149            model: info.human_name(),
150            vendor: info.description().to_string(),
151        }
152    }
153}
154
155impl ci2::CameraInfo for WebcamCameraInfo {
156    fn name(&self) -> &str {
157        &self.name
158    }
159    fn serial(&self) -> &str {
160        &self.serial
161    }
162    fn model(&self) -> &str {
163        &self.model
164    }
165    fn vendor(&self) -> &str {
166        &self.vendor
167    }
168}
169
170fn index_to_string(index: &CameraIndex) -> String {
171    match index {
172        CameraIndex::Index(i) => i.to_string(),
173        CameraIndex::String(s) => s.clone(),
174    }
175}
176
177pub struct WrappedCamera {
178    cam: Camera,
179    info: WebcamCameraInfo,
180    store_fno: usize,
181    /// The pixel format presented to the caller. Frames are decoded to this
182    /// format on the host. Only [`PixFmt::RGB8`] and [`PixFmt::Mono8`] are
183    /// supported; RGB8 is the default.
184    pixel_format: PixFmt,
185}
186
187fn _test_camera_is_send() {
188    // Compile-time test to ensure WrappedCamera implements Send trait.
189    fn implements<T: Send>() {}
190    implements::<WrappedCamera>();
191}
192
193impl WrappedCamera {
194    fn new(name: &str) -> ci2::Result<Self> {
195        let devices = enumerate()?;
196        if devices.is_empty() {
197            return Err(ci2::Error::from("no webcams found"));
198        }
199
200        for (i, device) in devices.iter().enumerate() {
201            info!("webcam #{i}: {}", device.human_name());
202        }
203
204        // Match on the human-readable name first, then fall back to the index
205        // string. An empty name selects the first available device.
206        let device = if name.is_empty() {
207            &devices[0]
208        } else {
209            devices
210                .iter()
211                .find(|d| device_name(d) == name || index_to_string(d.index()) == name)
212                .ok_or_else(|| ci2::Error::from(format!("could not find webcam \"{name}\"")))?
213        };
214
215        let info = WebcamCameraInfo::from_nokhwa(device);
216
217        // Request the highest available frame rate, decoding to RGB on the
218        // host. Decoding to mono is also possible from the same stream.
219        let requested =
220            RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);
221        let cam = Camera::new(device.index().clone(), requested).map_err(nokhwa_err)?;
222
223        info!(
224            "opened webcam \"{}\" with format {}",
225            info.name,
226            cam.camera_format()
227        );
228
229        Ok(Self {
230            cam,
231            info,
232            store_fno: 0,
233            pixel_format: PixFmt::RGB8,
234        })
235    }
236}
237
238impl ci2::CameraInfo for WrappedCamera {
239    fn name(&self) -> &str {
240        &self.info.name
241    }
242    fn serial(&self) -> &str {
243        &self.info.serial
244    }
245    fn model(&self) -> &str {
246        &self.info.model
247    }
248    fn vendor(&self) -> &str {
249        &self.info.vendor
250    }
251}
252
253impl ci2::Camera for WrappedCamera {
254    // ----- start: weakly typed but easier to implement API -----
255    //
256    // Webcams have no GenICam feature tree, so all of these are unsupported.
257
258    fn command_execute(&self, _name: &str, _verify: bool) -> ci2::Result<()> {
259        Err(ci2::Error::FeatureNotPresent())
260    }
261    fn feature_bool(&self, _name: &str) -> ci2::Result<bool> {
262        Err(ci2::Error::FeatureNotPresent())
263    }
264    fn feature_bool_set(&self, _name: &str, _value: bool) -> ci2::Result<()> {
265        Err(ci2::Error::FeatureNotPresent())
266    }
267    fn feature_enum(&self, _name: &str) -> ci2::Result<String> {
268        Err(ci2::Error::FeatureNotPresent())
269    }
270    fn feature_enum_set(&self, _name: &str, _value: &str) -> ci2::Result<()> {
271        Err(ci2::Error::FeatureNotPresent())
272    }
273    fn feature_float(&self, _name: &str) -> ci2::Result<f64> {
274        Err(ci2::Error::FeatureNotPresent())
275    }
276    fn feature_float_set(&self, _name: &str, _value: f64) -> ci2::Result<()> {
277        Err(ci2::Error::FeatureNotPresent())
278    }
279    fn feature_int(&self, _name: &str) -> ci2::Result<i64> {
280        Err(ci2::Error::FeatureNotPresent())
281    }
282    fn feature_int_set(&self, _name: &str, _value: i64) -> ci2::Result<()> {
283        Err(ci2::Error::FeatureNotPresent())
284    }
285
286    // ----- end: weakly typed but easier to implement API -----
287
288    fn node_map_load(&self, _settings: &str) -> ci2::Result<()> {
289        Err(ci2::Error::FeatureNotPresent())
290    }
291    fn node_map_save(&self) -> ci2::Result<String> {
292        Err(ci2::Error::FeatureNotPresent())
293    }
294
295    fn width(&self) -> ci2::Result<u32> {
296        Ok(self.cam.resolution().width())
297    }
298    fn height(&self) -> ci2::Result<u32> {
299        Ok(self.cam.resolution().height())
300    }
301
302    fn pixel_format(&self) -> ci2::Result<PixFmt> {
303        Ok(self.pixel_format)
304    }
305    fn possible_pixel_formats(&self) -> ci2::Result<Vec<PixFmt>> {
306        Ok(vec![PixFmt::RGB8, PixFmt::Mono8])
307    }
308    fn set_pixel_format(&mut self, pixel_format: PixFmt) -> ci2::Result<()> {
309        match pixel_format {
310            PixFmt::RGB8 | PixFmt::Mono8 => {
311                self.pixel_format = pixel_format;
312                Ok(())
313            }
314            other => Err(ci2::Error::from(format!(
315                "webcam backend does not support pixel format {other}"
316            ))),
317        }
318    }
319
320    fn exposure_time(&self) -> ci2::Result<f64> {
321        Err(ci2::Error::FeatureNotPresent())
322    }
323    fn exposure_time_range(&self) -> ci2::Result<(f64, f64)> {
324        Err(ci2::Error::FeatureNotPresent())
325    }
326    fn set_exposure_time(&mut self, _: f64) -> ci2::Result<()> {
327        Err(ci2::Error::FeatureNotPresent())
328    }
329
330    fn exposure_auto(&self) -> ci2::Result<AutoMode> {
331        Err(ci2::Error::FeatureNotPresent())
332    }
333    fn set_exposure_auto(&mut self, _: AutoMode) -> ci2::Result<()> {
334        Err(ci2::Error::FeatureNotPresent())
335    }
336
337    fn gain(&self) -> ci2::Result<f64> {
338        Err(ci2::Error::FeatureNotPresent())
339    }
340    fn gain_range(&self) -> ci2::Result<(f64, f64)> {
341        Err(ci2::Error::FeatureNotPresent())
342    }
343    fn set_gain(&mut self, _: f64) -> ci2::Result<()> {
344        Err(ci2::Error::FeatureNotPresent())
345    }
346
347    fn gain_auto(&self) -> ci2::Result<AutoMode> {
348        Err(ci2::Error::FeatureNotPresent())
349    }
350    fn set_gain_auto(&mut self, _: AutoMode) -> ci2::Result<()> {
351        Err(ci2::Error::FeatureNotPresent())
352    }
353
354    fn trigger_mode(&self) -> ci2::Result<TriggerMode> {
355        Err(ci2::Error::FeatureNotPresent())
356    }
357    fn set_trigger_mode(&mut self, _: TriggerMode) -> ci2::Result<()> {
358        Err(ci2::Error::FeatureNotPresent())
359    }
360
361    fn acquisition_frame_rate_enable(&self) -> ci2::Result<bool> {
362        Err(ci2::Error::FeatureNotPresent())
363    }
364    fn set_acquisition_frame_rate_enable(&mut self, _value: bool) -> ci2::Result<()> {
365        Err(ci2::Error::FeatureNotPresent())
366    }
367
368    fn acquisition_frame_rate(&self) -> ci2::Result<f64> {
369        Err(ci2::Error::FeatureNotPresent())
370    }
371    fn acquisition_frame_rate_range(&self) -> ci2::Result<(f64, f64)> {
372        Err(ci2::Error::FeatureNotPresent())
373    }
374    fn set_acquisition_frame_rate(&mut self, _value: f64) -> ci2::Result<()> {
375        Err(ci2::Error::FeatureNotPresent())
376    }
377
378    fn trigger_selector(&self) -> ci2::Result<TriggerSelector> {
379        Err(ci2::Error::FeatureNotPresent())
380    }
381    fn set_trigger_selector(&mut self, _: TriggerSelector) -> ci2::Result<()> {
382        Err(ci2::Error::FeatureNotPresent())
383    }
384
385    fn acquisition_mode(&self) -> ci2::Result<AcquisitionMode> {
386        Err(ci2::Error::FeatureNotPresent())
387    }
388    fn set_acquisition_mode(&mut self, _: AcquisitionMode) -> ci2::Result<()> {
389        Err(ci2::Error::FeatureNotPresent())
390    }
391
392    fn acquisition_start(&mut self) -> ci2::Result<()> {
393        self.store_fno = 0;
394        self.cam.open_stream().map_err(nokhwa_err)
395    }
396    fn acquisition_stop(&mut self) -> ci2::Result<()> {
397        self.cam.stop_stream().map_err(nokhwa_err)
398    }
399
400    fn next_frame(&mut self) -> ci2::Result<DynamicFrameWithInfo> {
401        // `nokhwa`'s `frame` call blocks until the next frame is available.
402        let buffer = self.cam.frame().map_err(nokhwa_err)?;
403        let datetime = chrono::Utc::now();
404
405        let image = match self.pixel_format {
406            PixFmt::Mono8 => {
407                let decoded = buffer.decode_image::<LumaFormat>().map_err(nokhwa_err)?;
408                let (width, height) = (decoded.width(), decoded.height());
409                let stride = width as usize;
410                DynamicFrameOwned::from_buf(
411                    width,
412                    height,
413                    stride,
414                    decoded.into_raw(),
415                    PixFmt::Mono8,
416                )
417            }
418            // RGB8 is the default; any unexpected value would have been
419            // rejected by `set_pixel_format`.
420            _ => {
421                let decoded = buffer.decode_image::<RgbFormat>().map_err(nokhwa_err)?;
422                let (width, height) = (decoded.width(), decoded.height());
423                let stride = width as usize * 3;
424                DynamicFrameOwned::from_buf(width, height, stride, decoded.into_raw(), PixFmt::RGB8)
425            }
426        }
427        .ok_or_else(|| ci2::Error::SingleFrameError("decoded frame had invalid layout".into()))?;
428
429        let fno = self.store_fno;
430        self.store_fno += 1;
431
432        Ok(DynamicFrameWithInfo {
433            image: std::sync::Arc::new(image),
434            host_timing: HostTimingInfo { fno, datetime },
435            backend_data: None,
436        })
437    }
438}