Skip to main content

apple_vision/face_landmarks/
mod.rs

1//! [`detect_face_landmarks_in_path`] — wraps `VNDetectFaceLandmarksRequest`.
2//!
3//! Returns each face's bounding box, optional roll/yaw/pitch, and the
4//! detected landmark regions (eyes, eyebrows, nose, lips, pupils, …).
5//! Each region is exposed as a list of normalised `(x, y)` points in
6//! Vision's bottom-left coordinate system.
7
8use core::ffi::c_char;
9use core::ptr;
10use std::ffi::CString;
11use std::path::Path;
12
13use crate::error::{from_swift, VisionError};
14use crate::ffi;
15use crate::recognize_text::BoundingBox;
16use crate::request_base::{ImageBasedRequest, RequestRevisionProviding};
17use crate::sdk::PointsClassification;
18
19/// A 2-D point in Vision's normalised image space (`0.0..=1.0`,
20/// bottom-left origin).
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct LandmarkPoint {
23    pub x: f64,
24    pub y: f64,
25}
26
27/// Mirrors `VNRequestFaceLandmarksConstellation`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29#[repr(usize)]
30pub enum RequestFaceLandmarksConstellation {
31    NotDefined = 0,
32    Points65 = 1,
33    Points76 = 2,
34}
35
36impl RequestFaceLandmarksConstellation {
37    pub const ALL: &'static [Self] = &[Self::NotDefined, Self::Points65, Self::Points76];
38
39    #[must_use]
40    pub const fn as_raw(self) -> usize {
41        self as usize
42    }
43}
44
45/// Base `VNFaceLandmarkRegion` wrapper.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct FaceLandmarkRegion {
48    pub point_count: usize,
49    pub request_revision: Option<usize>,
50}
51
52impl RequestRevisionProviding for FaceLandmarkRegion {
53    fn request_revision(&self) -> Option<usize> {
54        self.request_revision
55    }
56}
57
58/// Dedicated `VNFaceLandmarkRegion2D` wrapper.
59#[derive(Debug, Clone, PartialEq)]
60pub struct FaceLandmarkRegion2D {
61    pub region: FaceLandmarkRegion,
62    pub normalized_points: Vec<LandmarkPoint>,
63    pub precision_estimates_per_point: Vec<f32>,
64    pub points_classification: Option<PointsClassification>,
65}
66
67impl FaceLandmarkRegion2D {
68    #[must_use]
69    pub fn points_in_image_of_size(&self, image_size: (f64, f64)) -> Vec<LandmarkPoint> {
70        self.normalized_points
71            .iter()
72            .map(|point| LandmarkPoint {
73                x: point.x * image_size.0,
74                y: point.y * image_size.1,
75            })
76            .collect()
77    }
78}
79
80/// Base `VNFaceLandmarks` wrapper.
81#[derive(Debug, Clone, PartialEq)]
82pub struct FaceLandmarks {
83    pub confidence: f32,
84    pub request_revision: Option<usize>,
85}
86
87impl RequestRevisionProviding for FaceLandmarks {
88    fn request_revision(&self) -> Option<usize> {
89        self.request_revision
90    }
91}
92
93/// Dedicated `VNFaceLandmarks2D` wrapper.
94#[derive(Debug, Clone, PartialEq)]
95pub struct FaceLandmarks2D {
96    pub landmarks: FaceLandmarks,
97    pub all_points: Option<FaceLandmarkRegion2D>,
98    pub face_contour: Option<FaceLandmarkRegion2D>,
99    pub left_eye: Option<FaceLandmarkRegion2D>,
100    pub right_eye: Option<FaceLandmarkRegion2D>,
101    pub left_eyebrow: Option<FaceLandmarkRegion2D>,
102    pub right_eyebrow: Option<FaceLandmarkRegion2D>,
103    pub nose: Option<FaceLandmarkRegion2D>,
104    pub nose_crest: Option<FaceLandmarkRegion2D>,
105    pub median_line: Option<FaceLandmarkRegion2D>,
106    pub outer_lips: Option<FaceLandmarkRegion2D>,
107    pub inner_lips: Option<FaceLandmarkRegion2D>,
108    pub left_pupil: Option<FaceLandmarkRegion2D>,
109    pub right_pupil: Option<FaceLandmarkRegion2D>,
110}
111
112/// Rust mirror of `VNFaceObservationAccepting`.
113pub trait FaceObservationAccepting {
114    fn input_face_observations(&self) -> &[BoundingBox];
115}
116
117/// Builder mirroring `VNDetectFaceLandmarksRequest`'s extra public surface.
118#[derive(Debug, Clone, PartialEq, Default)]
119pub struct FaceLandmarksRequest {
120    image_based_request: ImageBasedRequest,
121    constellation: Option<RequestFaceLandmarksConstellation>,
122    input_face_observations: Vec<BoundingBox>,
123}
124
125impl FaceLandmarksRequest {
126    #[must_use]
127    pub const fn new() -> Self {
128        Self {
129            image_based_request: ImageBasedRequest::new(),
130            constellation: None,
131            input_face_observations: Vec::new(),
132        }
133    }
134
135    #[must_use]
136    pub const fn with_image_based_request(
137        mut self,
138        image_based_request: ImageBasedRequest,
139    ) -> Self {
140        self.image_based_request = image_based_request;
141        self
142    }
143
144    #[must_use]
145    pub const fn with_constellation(
146        mut self,
147        constellation: RequestFaceLandmarksConstellation,
148    ) -> Self {
149        self.constellation = Some(constellation);
150        self
151    }
152
153    #[must_use]
154    pub fn with_input_face_observations(
155        mut self,
156        input_face_observations: Vec<BoundingBox>,
157    ) -> Self {
158        self.input_face_observations = input_face_observations;
159        self
160    }
161
162    #[must_use]
163    pub const fn image_based_request(&self) -> &ImageBasedRequest {
164        &self.image_based_request
165    }
166
167    #[must_use]
168    pub const fn constellation(&self) -> Option<RequestFaceLandmarksConstellation> {
169        self.constellation
170    }
171
172    #[must_use]
173    pub const fn supports_constellation(
174        request_revision: usize,
175        constellation: RequestFaceLandmarksConstellation,
176    ) -> bool {
177        matches!(
178            (request_revision, constellation),
179            (_, RequestFaceLandmarksConstellation::NotDefined)
180                | (1 | 2, RequestFaceLandmarksConstellation::Points65)
181                | (
182                    3,
183                    RequestFaceLandmarksConstellation::Points65
184                        | RequestFaceLandmarksConstellation::Points76,
185                )
186        )
187    }
188}
189
190impl FaceObservationAccepting for FaceLandmarksRequest {
191    fn input_face_observations(&self) -> &[BoundingBox] {
192        &self.input_face_observations
193    }
194}
195
196impl RequestRevisionProviding for FaceLandmarksRequest {
197    fn request_revision(&self) -> Option<usize> {
198        self.image_based_request.revision()
199    }
200}
201
202/// One face plus its detected landmarks. Any region with no points
203/// detected is returned as an empty `Vec`.
204#[derive(Debug, Clone, PartialEq)]
205pub struct FaceWithLandmarks {
206    pub bounding_box: BoundingBox,
207    pub confidence: f32,
208    pub roll: Option<f32>,
209    pub yaw: Option<f32>,
210    pub pitch: Option<f32>,
211    pub face_contour: Vec<LandmarkPoint>,
212    pub left_eye: Vec<LandmarkPoint>,
213    pub right_eye: Vec<LandmarkPoint>,
214    pub left_eyebrow: Vec<LandmarkPoint>,
215    pub right_eyebrow: Vec<LandmarkPoint>,
216    pub nose: Vec<LandmarkPoint>,
217    pub nose_crest: Vec<LandmarkPoint>,
218    pub median_line: Vec<LandmarkPoint>,
219    pub outer_lips: Vec<LandmarkPoint>,
220    pub inner_lips: Vec<LandmarkPoint>,
221    pub left_pupil: Vec<LandmarkPoint>,
222    pub right_pupil: Vec<LandmarkPoint>,
223}
224
225impl FaceWithLandmarks {
226    #[must_use]
227    pub fn landmarks_2d(&self) -> FaceLandmarks2D {
228        let all_points = [
229            &self.face_contour,
230            &self.left_eye,
231            &self.right_eye,
232            &self.left_eyebrow,
233            &self.right_eyebrow,
234            &self.nose,
235            &self.nose_crest,
236            &self.median_line,
237            &self.outer_lips,
238            &self.inner_lips,
239            &self.left_pupil,
240            &self.right_pupil,
241        ]
242        .into_iter()
243        .flat_map(|region| region.iter().copied())
244        .collect::<Vec<_>>();
245
246        FaceLandmarks2D {
247            landmarks: FaceLandmarks {
248                confidence: self.confidence,
249                request_revision: None,
250            },
251            all_points: region_from_points(&all_points),
252            face_contour: region_from_points(&self.face_contour),
253            left_eye: region_from_points(&self.left_eye),
254            right_eye: region_from_points(&self.right_eye),
255            left_eyebrow: region_from_points(&self.left_eyebrow),
256            right_eyebrow: region_from_points(&self.right_eyebrow),
257            nose: region_from_points(&self.nose),
258            nose_crest: region_from_points(&self.nose_crest),
259            median_line: region_from_points(&self.median_line),
260            outer_lips: region_from_points(&self.outer_lips),
261            inner_lips: region_from_points(&self.inner_lips),
262            left_pupil: region_from_points(&self.left_pupil),
263            right_pupil: region_from_points(&self.right_pupil),
264        }
265    }
266}
267
268/// Copy a bridge-allocated landmark buffer into Rust-owned points.
269///
270/// # Safety
271///
272/// `ptr` must be either null or valid for `n * 2` consecutive `f64` values
273/// arranged as `(x, y)` pairs for the duration of this call.
274unsafe fn copy_region(ptr: *mut f64, n: usize) -> Vec<LandmarkPoint> {
275    if ptr.is_null() || n == 0 {
276        return Vec::new();
277    }
278    let mut v = Vec::with_capacity(n);
279    for i in 0..n {
280        // SAFETY: the point buffer is valid for the reported coordinates; this index is in bounds.
281        let x = unsafe { *ptr.add(i * 2) };
282        // SAFETY: the point buffer is valid for the reported coordinates; this index is in bounds.
283        let y = unsafe { *ptr.add(i * 2 + 1) };
284        v.push(LandmarkPoint { x, y });
285    }
286    v
287}
288
289fn region_from_points(points: &[LandmarkPoint]) -> Option<FaceLandmarkRegion2D> {
290    if points.is_empty() {
291        None
292    } else {
293        Some(FaceLandmarkRegion2D {
294            region: FaceLandmarkRegion {
295                point_count: points.len(),
296                request_revision: None,
297            },
298            normalized_points: points.to_vec(),
299            precision_estimates_per_point: Vec::new(),
300            points_classification: None,
301        })
302    }
303}
304
305/// Detect faces and their landmark regions in the image at `path`.
306///
307/// # Errors
308///
309/// Returns [`VisionError::ImageLoadFailed`] or
310/// [`VisionError::RequestFailed`] on Apple-side failures.
311pub fn detect_face_landmarks_in_path(
312    path: impl AsRef<Path>,
313) -> Result<Vec<FaceWithLandmarks>, VisionError> {
314    let path_str = path
315        .as_ref()
316        .to_str()
317        .ok_or_else(|| VisionError::InvalidArgument("non-UTF-8 path".into()))?;
318    let path_c = CString::new(path_str)
319        .map_err(|e| VisionError::InvalidArgument(format!("path NUL byte: {e}")))?;
320
321    let mut out_array: *mut core::ffi::c_void = ptr::null_mut();
322    let mut out_count: usize = 0;
323    let mut err_msg: *mut c_char = ptr::null_mut();
324
325    // SAFETY: all pointer arguments are valid stack locations or null-initialised out-params; strings are valid C strings for the duration of the call.
326    let status = unsafe {
327        ffi::vn_detect_face_landmarks_in_path(
328            path_c.as_ptr(),
329            &mut out_array,
330            &mut out_count,
331            &mut err_msg,
332        )
333    };
334    if status != ffi::status::OK {
335        // SAFETY: the error pointer is either null or a bridge-allocated C string; `from_swift` frees it.
336        return Err(unsafe { from_swift(status, err_msg) });
337    }
338    if out_array.is_null() || out_count == 0 {
339        return Ok(Vec::new());
340    }
341    let typed = out_array.cast::<ffi::FaceLandmarksRaw>();
342    let mut faces = Vec::with_capacity(out_count);
343    for i in 0..out_count {
344        // SAFETY: the pointer is valid for the reported element count; the index is in bounds.
345        let raw = unsafe { &*typed.add(i) };
346        faces.push(FaceWithLandmarks {
347            bounding_box: BoundingBox {
348                x: raw.bbox_x,
349                y: raw.bbox_y,
350                width: raw.bbox_w,
351                height: raw.bbox_h,
352            },
353            confidence: raw.confidence,
354            roll: if raw.roll.is_nan() {
355                None
356            } else {
357                Some(raw.roll)
358            },
359            yaw: if raw.yaw.is_nan() {
360                None
361            } else {
362                Some(raw.yaw)
363            },
364            pitch: if raw.pitch.is_nan() {
365                None
366            } else {
367                Some(raw.pitch)
368            },
369            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
370            face_contour: unsafe { copy_region(raw.face_contour, raw.face_contour_count) },
371            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
372            left_eye: unsafe { copy_region(raw.left_eye, raw.left_eye_count) },
373            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
374            right_eye: unsafe { copy_region(raw.right_eye, raw.right_eye_count) },
375            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
376            left_eyebrow: unsafe { copy_region(raw.left_eyebrow, raw.left_eyebrow_count) },
377            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
378            right_eyebrow: unsafe { copy_region(raw.right_eyebrow, raw.right_eyebrow_count) },
379            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
380            nose: unsafe { copy_region(raw.nose, raw.nose_count) },
381            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
382            nose_crest: unsafe { copy_region(raw.nose_crest, raw.nose_crest_count) },
383            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
384            median_line: unsafe { copy_region(raw.median_line, raw.median_line_count) },
385            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
386            outer_lips: unsafe { copy_region(raw.outer_lips, raw.outer_lips_count) },
387            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
388            inner_lips: unsafe { copy_region(raw.inner_lips, raw.inner_lips_count) },
389            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
390            left_pupil: unsafe { copy_region(raw.left_pupil, raw.left_pupil_count) },
391            // SAFETY: the region pointer/count pair comes from the current bridge row and is valid for the reported length.
392            right_pupil: unsafe { copy_region(raw.right_pupil, raw.right_pupil_count) },
393        });
394    }
395    // SAFETY: the pointer/count pair was allocated by the bridge and is freed exactly once here.
396    unsafe { ffi::vn_face_landmarks_free(out_array, out_count) };
397    Ok(faces)
398}