mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
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:
@@ -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` (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/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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = ®.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));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod faces;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
|
||||
@@ -1422,6 +1422,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user