feat: add plasma-face-unlock

This commit is contained in:
Felitendo committed 2026-09-22 19:39:04 +02:00
commit f671acc93b
105 files changed
+13862

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The liveness cues against synthetic faces.
//
// There is no camera in a test, so the five landmarks are made the way a
// camera would make them: points of a head in millimetres, turned, projected
// through a pinhole the size of a laptop webcam, and jittered by the noise
// YuNet adds. A real head keeps its nose in front of its face. A photograph
// is the same five points printed flat on a card, and the card is turned and
// tilted instead of the head.
//
// What has to hold, over many random runs:
// - a head that turns about 15 degrees each way confirms,
// - a card turned twice as far never confirms, at any noise level a real
// camera produces,
// - a still head abstains (it is not a fake, there is just no evidence),
// - a blink confirms, and the things that look like one (the whole picture
// blurring as it moves, the light changing, one eye) do not.
#include "liveness.h"
#include <cmath>
#include <cstdio>
#include <functional>
#include <random>
namespace
{
constexpr double Focal = 600; // pixels, a 640x480 webcam with ~56 degree view
constexpr double Cx = 320, Cy = 240;
constexpr double Distance = 550; // millimetres from the camera
constexpr double Fps = 20;
struct P3 {
double x, y, z;
};
// Person-right eye first, the way YuNet reports it. x to the image's right,
// y down, z towards the camera. Proportions of an average adult face: 63 mm
// between the eyes, the nose tip 28 mm in front of the eyes, the mouth corners
// a little in front of them too.
const std::array<P3, 5> Head = {{
{-31.5, 0, 0},
{31.5, 0, 0},
{0, 42, 28},
{-25, 72, 8},
{25, 72, 8},
}};
P3 rotate(P3 p, double yaw, double pitch, double roll)
{
// yaw about y (positive turns the nose to the image's right, which is the
// person's left), pitch about x, roll about z.
P3 a{p.x * std::cos(yaw) + p.z * std::sin(yaw), p.y, -p.x * std::sin(yaw) + p.z * std::cos(yaw)};
P3 b{a.x, a.y * std::cos(pitch) - a.z * std::sin(pitch), a.y * std::sin(pitch) + a.z * std::cos(pitch)};
return {b.x * std::cos(roll) - b.y * std::sin(roll), b.x * std::sin(roll) + b.y * std::cos(roll), b.z};
}
cv::Point2f project(P3 p, double offsetX, double offsetY, double distance)
{
const double depth = distance - p.z;
return {float(Cx + Focal * (p.x + offsetX) / depth), float(Cy + Focal * (p.y + offsetY) / depth)};
}
LivenessFrame frameFrom(const std::array<cv::Point2f, 5> &pts, double t)
{
Face face;
face.points = pts;
LivenessFrame f;
f.t = t;
f.points = pts;
f.interocular = face.interocular();
f.pose = estimatePose(face);
return f;
}
std::array<cv::Point2f, 5> noisy(std::array<cv::Point2f, 5> pts, double sigma, std::mt19937 &rng)
{
std::normal_distribution<float> n(0.f, float(sigma));
for (auto &p : pts) {
p.x += n(rng);
p.y += n(rng);
}
return pts;
}
// A real head over one scan: it turns from side to side by up to `amplitude`
// degrees, nods a little, drifts a little.
LivenessReading realHead(double amplitudeDeg, double sigma, std::mt19937 &rng, double seconds = 3.0)
{
std::uniform_real_distribution<double> phase(0, 2 * M_PI);
const double ph = phase(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(seconds * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 3 * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
const double roll = 2 * M_PI / 180 * std::sin(2 * M_PI * 0.2 * t + 1);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(Head[k], yaw, pitch, roll), 8 * std::sin(t), 4 * std::cos(t), Distance);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// Heads are not all the same shape. This one draws a new face for every run:
// flatter and deeper noses, fuller lips, wider or narrower eyes, and turns it
// by a random amount between 12 and 22 degrees at a random noise level.
LivenessReading variedHead(std::mt19937 &rng)
{
std::uniform_real_distribution<double> u(0, 1);
const double eye = 29 + 5 * u(rng), noseZ = 18 + 17 * u(rng), noseY = 38 + 10 * u(rng);
const double mouthZ = 15 * u(rng), mouthY = 65 + 15 * u(rng), mouthX = 22 + 6 * u(rng);
const std::array<P3, 5> head = {{{-eye, 0, 0}, {eye, 0, 0}, {0, noseY, noseZ}, {-mouthX, mouthY, mouthZ}, {mouthX, mouthY, mouthZ}}};
const double amplitude = 12 + 10 * u(rng), ph = 2 * M_PI * u(rng), sigma = 0.5 + 2.0 * u(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(3.0 * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitude * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 4 * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(head[k], yaw, pitch, 0), 0, 0, Distance);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// A photograph of the same head, taken from straight ahead (or turned by
// `bakedYawDeg`), printed flat and then held up and turned by up to
// `amplitudeDeg`. `bend` curls the card around a vertical axis, in
// millimetres of sag at the edges, which puts some depth back into it.
LivenessReading photo(double amplitudeDeg, double sigma, std::mt19937 &rng, double bakedYawDeg = 0, double bend = 0,
double seconds = 3.0)
{
std::array<P3, 5> card;
for (int k = 0; k < 5; ++k) {
const P3 r = rotate(Head[k], bakedYawDeg * M_PI / 180, 0, 0);
// What the photographer's camera saw, scaled back to life size.
const double depth = Distance - r.z;
const double s = Distance / depth;
card[k] = {r.x * s, r.y * s, 0};
card[k].z = -bend * (card[k].x / 45.0) * (card[k].x / 45.0);
}
std::uniform_real_distribution<double> phase(0, 2 * M_PI);
const double ph = phase(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(seconds * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 0.4 * amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
const double roll = 4 * M_PI / 180 * std::sin(2 * M_PI * 0.2 * t);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(card[k], yaw, pitch, roll), 10 * std::sin(t), 5 * std::cos(t), Distance - 100);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// ---------------------------------------------------------------------------
// Blinks, as a series of eye measurements
// ---------------------------------------------------------------------------
struct EyeScript {
std::function<float(int)> left;
std::function<float(int)> right;
std::function<float(int)> skin = [](int) { return 150.f; };
std::function<cv::Point2f(int)> shift = [](int) { return cv::Point2f(0, 0); };
};
bool blinkRun(const EyeScript &script, std::mt19937 &rng, int frames = 60)
{
std::normal_distribution<float> n(0.f, 0.03f);
LivenessAnalyzer an;
for (int i = 0; i < frames; ++i) {
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(Head[k], 0, 0, Distance) + script.shift(i);
}
LivenessFrame f = frameFrom(pts, i * 1000.0 / 30.0);
f.eyes.valid = true;
f.eyes.left = std::max(0.f, script.left(i) + n(rng));
f.eyes.right = std::max(0.f, script.right(i) + n(rng));
f.eyes.openness = (f.eyes.left + f.eyes.right) / 2;
f.eyes.skin = script.skin(i);
an.add(f);
}
return an.reading().confirmedBy == u"blink";
}
int failures = 0;
// Printed, not judged: what a known limit looks like, so a change that moves
// it shows up in the output.
void report(const char *what, int hits, int runs)
{
std::printf("info %-58s %5.1f%%\n", what, 100.0 * hits / runs);
}
void expectRate(const char *what, int hits, int runs, double min, double max)
{
const double rate = double(hits) / runs;
const bool ok = rate >= min && rate <= max;
std::printf("%s %-58s %5.1f%% (want %.0f%% to %.0f%%)\n", ok ? "ok " : "FAIL", what, rate * 100, min * 100, max * 100);
if (!ok) {
++failures;
}
}
} // namespace
int main()
{
std::mt19937 rng(20260922);
constexpr int Runs = 300;
auto depthRate = [&](auto &&make) {
int hits = 0;
for (int i = 0; i < Runs; ++i) {
hits += make().confirmedBy == u"depth";
}
return hits;
};
std::printf("depth cue\n");
expectRate("head turning 15 degrees, 1 px noise", depthRate([&] { return realHead(15, 1.0, rng); }), Runs, 0.9, 1.0);
expectRate("head turning 15 degrees, 2 px noise", depthRate([&] { return realHead(15, 2.0, rng); }), Runs, 0.6, 1.0);
expectRate("head turning 25 degrees, 2 px noise", depthRate([&] { return realHead(25, 2.0, rng); }), Runs, 0.9, 1.0);
expectRate("heads of all shapes turning 12 to 22 degrees", depthRate([&] { return variedHead(rng); }), Runs, 0.9, 1.0);
expectRate("head held still (2 degrees), 1 px noise", depthRate([&] { return realHead(2, 1.0, rng); }), Runs, 0.0, 0.05);
expectRate("photo turned 30 degrees, 0.5 px noise", depthRate([&] { return photo(30, 0.5, rng); }), Runs, 0.0, 0.0);
expectRate("photo turned 30 degrees, 1 px noise", depthRate([&] { return photo(30, 1.0, rng); }), Runs, 0.0, 0.01);
expectRate("photo turned 30 degrees, 2 px noise", depthRate([&] { return photo(30, 2.0, rng); }), Runs, 0.0, 0.03);
expectRate("photo turned 45 degrees, 1 px noise", depthRate([&] { return photo(45, 1.0, rng); }), Runs, 0.0, 0.01);
expectRate("photo of a turned head, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 20); }), Runs, 0.0, 0.01);
expectRate("photo curled by 5 mm, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 0, 5); }), Runs, 0.0, 0.05);
// Curled this hard, a card has about a quarter of a real nose's depth, and
// turned twice as far as a head would be it moves its "nose" as far. Five
// points cannot tell that from a very flat face turned a little. Blink,
// glare and the device edge are what is left against it.
report("known limit: photo curled by 15 mm, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 0, 15); }), Runs);
expectRate("photo turned 60 degrees, 3 px noise", depthRate([&] { return photo(60, 3.0, rng); }), Runs, 0.0, 0.05);
expectRate("photo shaken for 6 seconds, 3 px noise", depthRate([&] { return photo(20, 3.0, rng, 0, 0, 6.0); }), Runs, 0.0, 0.05);
expectRate("head turning 20 degrees, 3 px noise", depthRate([&] { return realHead(20, 3.0, rng); }), Runs, 0.6, 1.0);
std::printf("\nblink cue\n");
auto blinkRate = [&](const EyeScript &s) {
int hits = 0;
for (int i = 0; i < Runs; ++i) {
hits += blinkRun(s, rng);
}
return hits;
};
const auto open = [](int) { return 0.45f; };
const auto blink = [](int i) { return (i >= 30 && i < 34) ? 0.08f : 0.45f; };
const auto slowClose = [](int i) { return (i >= 20 && i < 45) ? 0.08f : 0.45f; };
expectRate("eyes open the whole time", blinkRate({open, open}), Runs, 0.0, 0.01);
expectRate("a normal blink (130 ms)", blinkRate({blink, blink}), Runs, 0.95, 1.0);
expectRate("eyes closed for most of a second", blinkRate({slowClose, slowClose}), Runs, 0.0, 0.01);
expectRate("one eye only", blinkRate({blink, open}), Runs, 0.0, 0.01);
expectRate("light dims at the same moment",
blinkRate({blink, blink, [](int i) { return (i >= 30 && i < 34) ? 110.f : 150.f; }}), Runs, 0.0, 0.01);
expectRate("picture jerked sideways at the same moment",
blinkRate({blink, blink, [](int) { return 150.f; },
[](int i) { return i >= 32 ? cv::Point2f(25, 0) : cv::Point2f(0, 0); }}),
Runs, 0.0, 0.01);
std::printf("\n%s\n", failures ? "SOME CHECKS FAILED" : "all checks passed");
return failures ? 1 : 0;
}