feat(algo): face detection with libfacedetection's CNN in pure Rust, and image.detectFaces (part of #3603) (#4513)

photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference.

Part of #3603.
This commit is contained in:
Gert Pellin - GPE
2026-10-16 17:31:02 -07:00
committed by GitHub
parent 1b3bc48267
commit a6a1d2ed22
10 changed files with 568 additions and 0 deletions
+1
View File
@@ -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 | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | 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, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work | | `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, <https://invent.kde.org/plasma/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/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) ### Optional build input: craft-fonts (not files in this repo)
+1
View File
@@ -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. 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. 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. 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 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 https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
@@ -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.
Binary file not shown.
+90
View File
@@ -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<Vec<Face>> {
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<Vec<Face>> {
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);
}
}
+323
View File
@@ -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<f32>,
biases: Vec<f32>,
}
/// A feature map: `rows × cols` cells of `channels` values each.
#[derive(Clone)]
struct Blob {
rows: usize,
cols: usize,
channels: usize,
data: Vec<f32>,
}
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<Vec<Layer>> {
let rest = bytes.strip_prefix(MAGIC)?;
let mut at = 0usize;
let u16_at = |n: &mut usize| -> Option<u16> {
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<Vec<f32>> { (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<Blob> {
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::<f32>();
}
}
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<Blob> {
conv(&conv(x, p, false)?, d, relu)
}
fn unit(x: &Blob, l: &[Layer]) -> Option<Blob> {
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<Blob> {
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<Face>) -> 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 = &reg.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<Vec<Face>> {
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<Face> = 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::<f64>());
}
}
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));
}
}
+1
View File
@@ -32,6 +32,7 @@ mod distort;
mod distort2; mod distort2;
pub mod erase; pub mod erase;
pub mod exif; pub mod exif;
pub mod faces;
pub mod features; pub mod features;
mod fxutil; mod fxutil;
mod gallery; mod gallery;
+1
View File
@@ -1422,6 +1422,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::edit_menu_cmds::specs());
v.extend(crate::fill_key_cmds::specs()); v.extend(crate::fill_key_cmds::specs());
v.extend(crate::sample_cmds::specs()); v.extend(crate::sample_cmds::specs());
v.extend(crate::face_cmds::specs());
v.extend(crate::sampler_cmds::specs()); v.extend(crate::sampler_cmds::specs());
v.extend(crate::brush_key_cmds::specs()); v.extend(crate::brush_key_cmds::specs());
v.extend(crate::stamp_cmds::specs()); v.extend(crate::stamp_cmds::specs());
+115
View File
@@ -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<Vec<Face>> {
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<LayerId>, all_layers: bool, min_score: f32) -> Result<Vec<Face>> {
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<Value> {
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::<Vec<_>>() }))
}
pub fn specs() -> Vec<CommandSpec> {
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}");
}
}
}
+1
View File
@@ -41,6 +41,7 @@ pub mod eraser_cmds;
pub mod export_preset_cmds; pub mod export_preset_cmds;
pub mod exr_cmds; pub mod exr_cmds;
pub mod extra_cmds; pub mod extra_cmds;
pub mod face_cmds;
pub mod file_cmds; pub mod file_cmds;
pub mod fill_cmds; pub mod fill_cmds;
pub mod fill_key_cmds; pub mod fill_key_cmds;