diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 26eca62464..1ae19efa66 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -57,6 +57,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
+| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
diff --git a/NOTICE b/NOTICE
index 887010d4a6..a7302a940c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
+libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
diff --git a/crates/algo/data/LICENSE-libfacedetection.txt b/crates/algo/data/LICENSE-libfacedetection.txt
new file mode 100644
index 0000000000..33abd64f06
--- /dev/null
+++ b/crates/algo/data/LICENSE-libfacedetection.txt
@@ -0,0 +1,35 @@
+By downloading, copying, installing or using the software you agree to this license.
+If you do not agree to this license, do not download, install,
+copy or use the software.
+
+
+ License Agreement For libfacedetection
+ (3-clause BSD License)
+
+Copyright (c) 2015-2019, Shiqi Yu, all rights reserved.
+shiqi.yu@gmail.com
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ * Neither the names of the copyright holders nor the names of the contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+This software is provided by the copyright holders and contributors "as is" and
+any express or implied warranties, including, but not limited to, the implied
+warranties of merchantability and fitness for a particular purpose are disclaimed.
+In no event shall copyright holders or contributors be liable for any direct,
+indirect, incidental, special, exemplary, or consequential damages
+(including, but not limited to, procurement of substitute goods or services;
+loss of use, data, or profits; or business interruption) however caused
+and on any theory of liability, whether in contract, strict liability,
+or tort (including negligence or otherwise) arising in any way out of
+the use of this software, even if advised of the possibility of such damage.
\ No newline at end of file
diff --git a/crates/algo/data/libfacedetection.bin b/crates/algo/data/libfacedetection.bin
new file mode 100644
index 0000000000..0941c4f689
Binary files /dev/null and b/crates/algo/data/libfacedetection.bin differ
diff --git a/crates/algo/src/faces.rs b/crates/algo/src/faces.rs
new file mode 100644
index 0000000000..aa246fcaf8
--- /dev/null
+++ b/crates/algo/src/faces.rs
@@ -0,0 +1,90 @@
+//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`).
+//!
+//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with
+//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score,
+//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`,
+//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and
+//! inference is plain Rust: no runtime, so it works offline.
+//!
+//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image.
+//!
+//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with
+//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts
+//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing.
+
+#[cfg(not(target_arch = "wasm32"))]
+mod cnn;
+
+/// Whether this build can detect faces (not the browser build: see the module docs).
+pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32"));
+
+/// Longest side the network looks at; larger images are scaled down first (faces smaller than
+/// about 10 px at this scale are missed).
+pub const MAX_SIDE: usize = 1280;
+
+/// One detected face, in pixels of the image passed to [`detect`].
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Face {
+ /// Confidence `0..=1` (the library reports faces above 0.5 as reliable).
+ pub score: f32,
+ /// Left, top, width and height.
+ pub rect: [f32; 4],
+ /// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip,
+ /// right mouth corner, left mouth corner.
+ pub landmarks: [[f32; 2]; 5],
+}
+
+/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best
+/// first. Transparent pixels count as white. `None` when `px` doesn't match the size.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option> {
+ if w == 0 || h == 0 || px.len() != w.checked_mul(h)? {
+ return None;
+ }
+ // Scale down (box filter) so the longer side is at most MAX_SIDE.
+ let step = w.max(h).div_ceil(MAX_SIDE).max(1);
+ let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
+ let mut bgr = vec![[0.0f32; 3]; sw * sh];
+ for y in 0..sh {
+ for x in 0..sw {
+ let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
+ for yy in y * step..((y + 1) * step).min(h) {
+ for xx in x * step..((x + 1) * step).min(w) {
+ let [r, g, b, a] = px[yy * w + xx];
+ let a = a.clamp(0.0, 1.0);
+ for (k, v) in [b, g, r].into_iter().enumerate() {
+ acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a);
+ }
+ n += 1.0;
+ }
+ }
+ bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round());
+ }
+ }
+ let s = step as f32;
+ let faces = cnn::network(&bgr, sw, sh)?
+ .into_iter()
+ .filter(|f| f.score >= min_score)
+ .map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) })
+ .collect();
+ Some(faces)
+}
+
+/// The browser build has no detector (see the module docs): always `None`.
+#[cfg(target_arch = "wasm32")]
+pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option> {
+ None
+}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() {
+ assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![]));
+ assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![]));
+ assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None);
+ assert_eq!(detect(&[], 0, 0, 0.5), None);
+ }
+}
diff --git a/crates/algo/src/faces/cnn.rs b/crates/algo/src/faces/cnn.rs
new file mode 100644
index 0000000000..b86cac7eb2
--- /dev/null
+++ b/crates/algo/src/faces/cnn.rs
@@ -0,0 +1,323 @@
+//! The network itself (native builds only: see the parent module's docs).
+
+use super::Face;
+use crate::par;
+
+/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters
+/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16.
+static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin");
+
+const MAGIC: &[u8] = b"PCFACE1\0";
+const LAYERS: usize = 53;
+/// Faces whose boxes overlap more than this (intersection over union) are one face.
+const NMS_OVERLAP: f32 = 0.45;
+/// Candidates kept before non-maximum suppression, as the library does.
+const TOP_K: usize = 1000;
+
+struct Layer {
+ channels: usize,
+ filters: usize,
+ depthwise: bool,
+ weights: Vec,
+ biases: Vec,
+}
+
+/// A feature map: `rows × cols` cells of `channels` values each.
+#[derive(Clone)]
+struct Blob {
+ rows: usize,
+ cols: usize,
+ channels: usize,
+ data: Vec,
+}
+
+impl Blob {
+ fn new(rows: usize, cols: usize, channels: usize) -> Self {
+ Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] }
+ }
+}
+
+fn f16_to_f32(h: u16) -> f32 {
+ let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
+ let exp = i32::from((h >> 10) & 0x1f);
+ let frac = f32::from(h & 0x3ff);
+ match exp {
+ 0 => sign * frac * (2.0f32).powi(-24),
+ 31 => {
+ if frac == 0.0 {
+ sign * f32::INFINITY
+ } else {
+ f32::NAN
+ }
+ }
+ _ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15),
+ }
+}
+
+fn parse(bytes: &[u8]) -> Option> {
+ let rest = bytes.strip_prefix(MAGIC)?;
+ let mut at = 0usize;
+ let u16_at = |n: &mut usize| -> Option {
+ let b = rest.get(*n..*n + 2)?;
+ *n += 2;
+ Some(u16::from_le_bytes([b[0], b[1]]))
+ };
+ let count = usize::from(u16_at(&mut at)?);
+ if count != LAYERS {
+ return None;
+ }
+ let mut layers = Vec::with_capacity(count);
+ for _ in 0..count {
+ let channels = usize::from(u16_at(&mut at)?);
+ let filters = usize::from(u16_at(&mut at)?);
+ let depthwise = *rest.get(at)? == 1;
+ at += 1;
+ let nw = if depthwise { 9 * channels } else { filters * channels };
+ let mut read = |n: usize| -> Option> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() };
+ let weights = read(nw)?;
+ let biases = read(filters)?;
+ if depthwise && filters != channels {
+ return None;
+ }
+ layers.push(Layer { channels, filters, depthwise, weights, biases });
+ }
+ (at == rest.len()).then_some(layers)
+}
+
+/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`.
+fn conv(x: &Blob, l: &Layer, relu: bool) -> Option {
+ if x.channels != l.channels {
+ return None;
+ }
+ let mut out = Blob::new(x.rows, x.cols, l.filters);
+ let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters);
+ par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| {
+ for col in 0..cols {
+ let o = &mut line[col * cout..(col + 1) * cout];
+ if l.depthwise {
+ o.copy_from_slice(&l.biases);
+ for r in row.saturating_sub(1)..(row + 2).min(rows) {
+ for c in col.saturating_sub(1)..(col + 2).min(cols) {
+ let k = (r + 1 - row) * 3 + (c + 1 - col);
+ let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin];
+ let w = &l.weights[k * cin..(k + 1) * cin];
+ for ((o, i), w) in o.iter_mut().zip(input).zip(w) {
+ *o += i * w;
+ }
+ }
+ }
+ } else {
+ let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin];
+ for (f, o) in o.iter_mut().enumerate() {
+ let w = &l.weights[f * cin..(f + 1) * cin];
+ *o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::();
+ }
+ }
+ if relu {
+ for v in o.iter_mut() {
+ *v = v.max(0.0);
+ }
+ }
+ }
+ });
+ Some(out)
+}
+
+/// Pointwise then depthwise (ReLU after the depthwise one when `relu`).
+fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option {
+ conv(&conv(x, p, false)?, d, relu)
+}
+
+fn unit(x: &Blob, l: &[Layer]) -> Option {
+ let [p1, d1, p2, d2] = l else { return None };
+ conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true)
+}
+
+fn max_pool(x: &Blob) -> Blob {
+ let rows = x.rows.saturating_sub(3).div_ceil(2) + 1;
+ let cols = x.cols.saturating_sub(3).div_ceil(2) + 1;
+ let mut out = Blob::new(rows, cols, x.channels);
+ for r in 0..rows {
+ for c in 0..cols {
+ let o = (r * cols + c) * x.channels;
+ for ch in 0..x.channels {
+ let mut m = f32::NEG_INFINITY;
+ for rr in r * 2..(r * 2 + 2).min(x.rows) {
+ for cc in c * 2..(c * 2 + 2).min(x.cols) {
+ m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]);
+ }
+ }
+ out.data[o + ch] = m;
+ }
+ }
+ }
+ out
+}
+
+/// `small` upsampled ×2 (nearest) plus `big`.
+fn up_add(small: &Blob, big: &Blob) -> Option {
+ if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels {
+ return None;
+ }
+ let mut out = big.clone();
+ let ch = big.channels;
+ for r in 0..big.rows {
+ for c in 0..big.cols {
+ let s = ((r / 2) * small.cols + c / 2) * ch;
+ let o = (r * big.cols + c) * ch;
+ for k in 0..ch {
+ out.data[o + k] += small.data[s + k];
+ }
+ }
+ }
+ Some(out)
+}
+
+/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`,
+/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32.
+fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob {
+ let rows = h.div_ceil(32) * 16;
+ let cols = w.div_ceil(32) * 16;
+ let mut b = Blob::new(rows, cols, 32);
+ for r in 0..rows {
+ for c in 0..cols {
+ let cell = (r * cols + c) * 32;
+ for fy in 0..3 {
+ let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue };
+ for fx in 0..3 {
+ let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue };
+ let o = cell + (fy * 3 + fx) * 3;
+ b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]);
+ }
+ }
+ }
+ }
+ b
+}
+
+/// The heads of one pyramid level, decoded to candidate faces (in network pixels).
+fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec) -> Option<()> {
+ let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false);
+ let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?);
+ if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 {
+ return None;
+ }
+ let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp());
+ for r in 0..feat.rows {
+ for c in 0..feat.cols {
+ let i = r * feat.cols + c;
+ let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt();
+ if score < 0.2 {
+ continue;
+ }
+ let (px, py) = (c as f32 * stride, r as f32 * stride);
+ let b = ®.data[i * 4..i * 4 + 4];
+ let (cx, cy) = (b[0] * stride + px, b[1] * stride + py);
+ let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride);
+ let k = &kps.data[i * 10..i * 10 + 10];
+ let mut landmarks = [[0.0; 2]; 5];
+ for (n, lm) in landmarks.iter_mut().enumerate() {
+ *lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py];
+ }
+ out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks });
+ }
+ }
+ Some(())
+}
+
+fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
+ let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0);
+ let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0);
+ let i = w * h;
+ if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) }
+}
+
+/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels.
+const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]];
+
+/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read.
+fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> {
+ let x = conv(&input(bgr, w, h), l.first()?, true)?;
+ let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?);
+ let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?;
+ let f8 = unit(&max_pool(&x), l.get(11..15)?)?;
+ let f16 = unit(&max_pool(&f8), l.get(15..19)?)?;
+ let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?;
+ let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?;
+ let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?;
+ let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?;
+ Some([f8, f16, f32_])
+}
+
+pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option> {
+ let l = parse(WEIGHTS)?;
+ let levels = features(bgr, w, h, &l)?;
+ let mut faces = Vec::new();
+ // Heads (cls, reg, obj, kps) per level, as in the library's model.
+ for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) {
+ candidates(feat, &l, heads, stride, &mut faces)?;
+ }
+ faces.sort_by(|a, b| b.score.total_cmp(&a.score));
+ faces.truncate(TOP_K);
+ let mut kept: Vec = Vec::new();
+ for f in faces {
+ if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) {
+ kept.push(f);
+ }
+ }
+ Some(kept)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn weights_parse() {
+ let l = parse(WEIGHTS).expect("bundled weights");
+ assert_eq!(l.len(), LAYERS);
+ assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false));
+ assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true));
+ assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite())));
+ // Damaged weights are refused, not read past their end.
+ assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none());
+ assert!(parse(b"nope").is_none());
+ }
+
+ /// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++
+ /// (computed with its f32 weights; ours are f16, hence the tolerance).
+ #[test]
+ fn heads_match_the_reference_implementation() {
+ let (w, h) = (96usize, 72usize);
+ let bgr: Vec<[f32; 3]> = (0..w * h)
+ .map(|i| {
+ let (x, y) = (i % w, i / w);
+ let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256);
+ [b as f32, g as f32, r as f32]
+ })
+ .collect();
+ let l = parse(WEIGHTS).unwrap();
+ let levels = features(&bgr, w, h, &l).unwrap();
+ let reference =
+ [27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208];
+ let mut got = Vec::new();
+ for (feat, heads) in levels.iter().zip(HEADS) {
+ for first in heads {
+ let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap();
+ got.push(o.data.iter().map(|&v| f64::from(v)).sum::());
+ }
+ }
+ for (g, r) in got.iter().zip(reference) {
+ assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}");
+ }
+ }
+
+ #[test]
+ fn f16_decoding() {
+ assert_eq!(f16_to_f32(0x3c00), 1.0);
+ assert_eq!(f16_to_f32(0xc000), -2.0);
+ assert_eq!(f16_to_f32(0x0000), 0.0);
+ assert_eq!(f16_to_f32(0x3555), 0.333_251_95);
+ assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24));
+ }
+}
diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs
index 49ab15871c..c2ff6d2e6c 100644
--- a/crates/algo/src/lib.rs
+++ b/crates/algo/src/lib.rs
@@ -32,6 +32,7 @@ mod distort;
mod distort2;
pub mod erase;
pub mod exif;
+pub mod faces;
pub mod features;
mod fxutil;
mod gallery;
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 2f04187128..48b576dcb4 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -1422,6 +1422,7 @@ fn build() -> Vec {
v.extend(crate::edit_menu_cmds::specs());
v.extend(crate::fill_key_cmds::specs());
v.extend(crate::sample_cmds::specs());
+ v.extend(crate::face_cmds::specs());
v.extend(crate::sampler_cmds::specs());
v.extend(crate::brush_key_cmds::specs());
v.extend(crate::stamp_cmds::specs());
diff --git a/crates/engine/src/face_cmds.rs b/crates/engine/src/face_cmds.rs
new file mode 100644
index 0000000000..d48df95b84
--- /dev/null
+++ b/crates/engine/src/face_cmds.rs
@@ -0,0 +1,115 @@
+//! Face detection (#3603): the faces in the document, with their box and five landmarks, from
+//! [`photocraft_algo::faces`]. Select › Color Range's Detect Faces and Face-Aware Liquify read
+//! faces through [`faces_in`]; `image.detectFaces` reports them to scripts and agents.
+
+use photocraft_algo::faces::{self, Face};
+use photocraft_doc::{Document, LayerId};
+use serde_json::{Value, json};
+
+use crate::commands::CommandSpec;
+use crate::{EngineError, Result, Session};
+
+/// Faces scoring below this are left out unless asked for (libfacedetection's own threshold for a
+/// reliable face).
+pub const MIN_SCORE: f32 = 0.5;
+
+/// Why there are no faces in the browser build (see [`photocraft_algo::faces`]).
+const UNAVAILABLE: &str = "face detection isn't available in the browser version of PhotoCraft; use the desktop app";
+
+/// The faces in `px` (`w × h` straight RGBA), as [`faces::detect`] finds them.
+pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Result> {
+ if !faces::AVAILABLE {
+ return Err(EngineError::Other(UNAVAILABLE.into()));
+ }
+ faces::detect(px, w, h, min_score).ok_or_else(|| EngineError::Other("face detection failed".into()))
+}
+
+/// The faces in `doc`, best first: in the composite, or in the active layer's pixels when
+/// `all_layers` is off. Coordinates are document pixels.
+pub fn faces_in(doc: &Document, active: Option, all_layers: bool, min_score: f32) -> Result> {
+ let area = doc.bounds();
+ let (w, h) = (area.width() as usize, area.height() as usize);
+ let surface = active.and_then(|id| doc.layer(id)).and_then(|l| l.surface()).filter(|_| !all_layers);
+ let px = match surface {
+ Some(surf) => {
+ let mut px = vec![[0.0f32; 4]; w * h];
+ surf.read_rgba_into(area, &mut px);
+ px
+ }
+ None => photocraft_compose::render(doc, area).px,
+ };
+ let found = detect(&px, w, h, min_score)?;
+ let (x0, y0) = (area.x0 as f32, area.y0 as f32);
+ Ok(found
+ .into_iter()
+ .map(|f| Face { rect: [f.rect[0] + x0, f.rect[1] + y0, f.rect[2], f.rect[3]], landmarks: f.landmarks.map(|[x, y]| [x + x0, y + y0]), ..f })
+ .collect())
+}
+
+fn face_json(f: &Face) -> Value {
+ let pt = |i: usize| json!([f.landmarks[i][0], f.landmarks[i][1]]);
+ json!({
+ "score": f.score,
+ "rect": f.rect,
+ "landmarks": {"rightEye": pt(0), "leftEye": pt(1), "nose": pt(2), "mouthRight": pt(3), "mouthLeft": pt(4)},
+ })
+}
+
+fn detect_faces(s: &mut Session, p: &Value) -> Result {
+ const CMD: &str = "image.detectFaces";
+ let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() };
+ let min_score = match p.get("minScore") {
+ None => MIN_SCORE,
+ Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad("minScore must be a number in 0..1"))? as f32,
+ };
+ let all_layers = match p.get("sampleAllLayers") {
+ None => true,
+ Some(v) => v.as_bool().ok_or_else(|| bad("sampleAllLayers must be true or false"))?,
+ };
+ let d = s.active().ok_or(EngineError::NoDocument)?;
+ let found = faces_in(&d.doc, d.active_layer, all_layers, min_score)?;
+ Ok(json!({ "faces": found.iter().map(face_json).collect::>() }))
+}
+
+pub fn specs() -> Vec {
+ vec![CommandSpec {
+ id: "image.detectFaces",
+ label: "Detect Faces",
+ menu: &[],
+ shortcut: None,
+ params: r##"{"minScore":0..1=0.5,"sampleAllLayers":bool=true} → {"faces":[{"score","rect":[x,y,w,h],"landmarks":{"rightEye","leftEye","nose","mouthRight","mouthLeft"}: [x,y] each},…]} (best first; "rightEye" is the one on the image's left)"##,
+ enabled: |s| {
+ if !faces::AVAILABLE {
+ return Err(UNAVAILABLE.into());
+ }
+ s.active().map(|_| ()).ok_or_else(|| "no document open".into())
+ },
+ run: detect_faces,
+ journal: false,
+ }]
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use crate::Session;
+
+ #[test]
+ fn detect_faces_reports_no_faces_on_a_plain_image_and_validates_params() {
+ let mut s = Session::new();
+ assert!(s.execute("image.detectFaces", json!({})).is_err(), "no document");
+ assert!(!s.is_enabled("image.detectFaces"));
+ s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
+ assert!(s.is_enabled("image.detectFaces"));
+ s.execute("select.all", json!({})).unwrap();
+ assert_eq!(s.execute("image.detectFaces", json!({})).unwrap(), json!({"faces": []}));
+ assert_eq!(s.execute("image.detectFaces", json!({"minScore": 0, "sampleAllLayers": false})).unwrap(), json!({"faces": []}));
+ // No history step of its own: Undo undoes the Select All before it.
+ s.execute("edit.undo", json!({})).unwrap();
+ assert!(s.active().unwrap().doc.selection.is_none());
+ for bad in [json!({"minScore": 2}), json!({"minScore": "high"}), json!({"sampleAllLayers": 1})] {
+ assert!(s.execute("image.detectFaces", bad.clone()).is_err(), "{bad}");
+ }
+ }
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index b8d6167c3d..0bf4d1b40f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -41,6 +41,7 @@ pub mod eraser_cmds;
pub mod export_preset_cmds;
pub mod exr_cmds;
pub mod extra_cmds;
+pub mod face_cmds;
pub mod file_cmds;
pub mod fill_cmds;
pub mod fill_key_cmds;