feat: add plasma-face-unlock
This commit is contained in:
commit
f671acc93b
105 files changed
+13862
No files matched your search
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Runs a PAM stack from a directory of its own, so the module can be tried
|
||||
// against a development daemon without root and without touching /etc/pam.d:
|
||||
//
|
||||
// pam_harness CONFDIR SERVICE USER
|
||||
//
|
||||
// Prints every message the stack sends, answers nothing, and exits 0 when
|
||||
// the stack let the user in.
|
||||
|
||||
#include <security/pam_appl.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int conversation(int n, const struct pam_message **msg, struct pam_response **resp, void *data)
|
||||
{
|
||||
(void)data;
|
||||
*resp = calloc((size_t)n, sizeof(struct pam_response));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const char *kind = msg[i]->msg_style == PAM_TEXT_INFO ? "info"
|
||||
: msg[i]->msg_style == PAM_ERROR_MSG ? "error"
|
||||
: "prompt";
|
||||
printf("%s: %s\n", kind, msg[i]->msg);
|
||||
fflush(stdout);
|
||||
if (msg[i]->msg_style == PAM_PROMPT_ECHO_OFF || msg[i]->msg_style == PAM_PROMPT_ECHO_ON) {
|
||||
// Nobody is typing a password here.
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
}
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc != 4) {
|
||||
fprintf(stderr, "usage: %s CONFDIR SERVICE USER\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const struct pam_conv conv = {conversation, NULL};
|
||||
pam_handle_t *pamh = NULL;
|
||||
int rc = pam_start_confdir(argv[2], argv[3], &conv, argv[1], &pamh);
|
||||
if (rc != PAM_SUCCESS) {
|
||||
fprintf(stderr, "pam_start: %s\n", pam_strerror(pamh, rc));
|
||||
return 2;
|
||||
}
|
||||
rc = pam_authenticate(pamh, 0);
|
||||
printf("result: %s\n", pam_strerror(pamh, rc));
|
||||
pam_end(pamh, rc);
|
||||
return rc == PAM_SUCCESS ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Recognition on real pictures, for trying things out without a camera:
|
||||
//
|
||||
// test_images MODELDIR REFERENCE.jpg OTHER.jpg...
|
||||
//
|
||||
// Takes the face in REFERENCE as the enrolled one and prints how every other
|
||||
// picture compares, with the pose and quality the daemon would see.
|
||||
//
|
||||
// test_images --enroll STATEDIR UID NAME MODELDIR PICTURE...
|
||||
//
|
||||
// Writes the faces in the pictures straight into a face store, as if they had
|
||||
// been set up, so a development daemon has somebody to recognise.
|
||||
|
||||
#include "liveness.h"
|
||||
#include "store.h"
|
||||
#include "vision.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
#include <QFile>
|
||||
#include <QString>
|
||||
|
||||
#include <opencv2/imgcodecs.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace
|
||||
{
|
||||
cv::Mat load(const char *path)
|
||||
{
|
||||
cv::Mat img = cv::imread(path, cv::IMREAD_COLOR);
|
||||
if (!img.empty()) {
|
||||
const double s = 640.0 / std::max(img.cols, img.rows);
|
||||
if (s < 1) {
|
||||
cv::resize(img, img, {}, s, s, cv::INTER_AREA);
|
||||
}
|
||||
}
|
||||
return img;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int enroll(int argc, char **argv)
|
||||
{
|
||||
if (argc < 7) {
|
||||
std::fprintf(stderr, "usage: %s --enroll STATEDIR UID NAME MODELDIR PICTURE...\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
Vision vision;
|
||||
QString error;
|
||||
if (!vision.load(QString::fromLocal8Bit(argv[5]), &error)) {
|
||||
std::fprintf(stderr, "%s\n", qPrintable(error));
|
||||
return 1;
|
||||
}
|
||||
Identity id;
|
||||
id.id = FaceStore::newId();
|
||||
id.name = QString::fromLocal8Bit(argv[4]);
|
||||
id.created = QDateTime::currentSecsSinceEpoch();
|
||||
QList<float> noseT, eyes;
|
||||
for (int i = 6; i < argc; ++i) {
|
||||
const cv::Mat img = load(argv[i]);
|
||||
const std::vector<Face> faces = vision.detect(img);
|
||||
if (faces.empty()) {
|
||||
std::fprintf(stderr, "no face in %s\n", argv[i]);
|
||||
continue;
|
||||
}
|
||||
id.samples.append({vision.embed(img, faces.front()), QStringLiteral("center"), id.created});
|
||||
noseT.append(estimatePose(faces.front()).noseT);
|
||||
const EyeSample e = measureEyes(img, faces.front());
|
||||
if (e.valid) {
|
||||
eyes.append(e.openness);
|
||||
}
|
||||
}
|
||||
if (id.samples.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
std::sort(noseT.begin(), noseT.end());
|
||||
std::sort(eyes.begin(), eyes.end());
|
||||
id.noseT = noseT.at(noseT.size() / 2);
|
||||
id.eyes = eyes.isEmpty() ? 0.f : eyes.at(eyes.size() / 2);
|
||||
|
||||
const FaceStore store(QString::fromLocal8Bit(argv[2]));
|
||||
const uint uid = QString::fromLocal8Bit(argv[3]).toUInt();
|
||||
QList<Identity> all = store.load(uid);
|
||||
all.append(id);
|
||||
if (!store.save(uid, all, &error)) {
|
||||
std::fprintf(stderr, "%s\n", qPrintable(error));
|
||||
return 1;
|
||||
}
|
||||
std::printf("enrolled %s with %d samples\n", qPrintable(id.name), int(id.samples.size()));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc > 1 && QString::fromLocal8Bit(argv[1]) == u"--enroll") {
|
||||
return enroll(argc, argv);
|
||||
}
|
||||
if (argc < 4) {
|
||||
std::fprintf(stderr, "usage: %s MODELDIR REFERENCE OTHER...\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
Vision vision;
|
||||
QString error;
|
||||
if (!vision.load(QString::fromLocal8Bit(argv[1]), &error)) {
|
||||
std::fprintf(stderr, "%s\n", qPrintable(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const cv::Mat ref = load(argv[2]);
|
||||
const std::vector<Face> refFaces = vision.detect(ref);
|
||||
if (refFaces.empty()) {
|
||||
std::fprintf(stderr, "no face in %s\n", argv[2]);
|
||||
return 1;
|
||||
}
|
||||
const Embedding reference = vision.embed(ref, refFaces.front());
|
||||
|
||||
for (int i = 3; i < argc; ++i) {
|
||||
const cv::Mat img = load(argv[i]);
|
||||
const std::vector<Face> faces = vision.detect(img);
|
||||
if (faces.empty()) {
|
||||
std::printf("%-50s no face\n", argv[i]);
|
||||
continue;
|
||||
}
|
||||
const Face &f = faces.front();
|
||||
const HeadPose pose = estimatePose(f);
|
||||
const FaceQuality q = assessQuality(img, f);
|
||||
const EyeSample eyes = measureEyes(img, f);
|
||||
const GlareSample glare = measureGlare(img, f);
|
||||
std::printf("%-50s similarity %.3f yaw %5.1f noseT %.2f iod %3.0f light %3.0f sharp %5.0f eyes %.2f/%.2f glare %.3f/%.2f device %d\n",
|
||||
argv[i], Vision::similarity(reference, vision.embed(img, f)), yawDegrees(pose.yaw), pose.noseT, f.interocular(),
|
||||
q.brightness, q.sharpness, eyes.left, eyes.right, glare.fraction, glare.cluster, int(detectDevice(img, f)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The PAM file editing, against copies of what the distributions ship.
|
||||
#
|
||||
# Turning face unlock on for sudo edits /etc/pam.d/sudo, and a mistake there
|
||||
# is a machine nobody can sudo on any more. So every layout this has to cope
|
||||
# with is here: where the line lands, that it lands once, and that turning it
|
||||
# off gives back the file exactly as it was.
|
||||
#
|
||||
# When the pam_harness from the build is there, the files that come out are
|
||||
# also run through real PAM, with the module missing on purpose: the dash in
|
||||
# front of the line has to make PAM skip it without a word.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$tmp"' EXIT
|
||||
|
||||
export PFU_PAM_ETC_DIR="$tmp/etc"
|
||||
export PFU_PAM_VENDOR_DIRS="$tmp/vendor"
|
||||
PFU_LIBDIR="$here/src/lib"
|
||||
# shellcheck source=/dev/null
|
||||
source "$PFU_LIBDIR/common.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "$PFU_LIBDIR/config.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "$PFU_LIBDIR/pam.sh"
|
||||
|
||||
PFU_PAM_MODULE="$tmp/lib/pam_plasma_face_unlock.so"
|
||||
mkdir -p "$tmp/lib" "$tmp/etc" "$tmp/vendor"
|
||||
: > "$PFU_PAM_MODULE"
|
||||
|
||||
failures=0
|
||||
check() {
|
||||
if eval "$2"; then
|
||||
printf 'ok %s\n' "$1"
|
||||
else
|
||||
printf 'FAIL %s\n' "$1"
|
||||
failures=$(( failures + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
# first_auth <file>: the first line that has anything to do with auth.
|
||||
first_auth() {
|
||||
grep -m1 -E '^[[:space:]]*(-?auth[[:space:]]|@include[[:space:]]+common-auth)' "$1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
arch_sudo='#%PAM-1.0
|
||||
auth include system-auth
|
||||
account include system-auth
|
||||
session include system-auth
|
||||
session optional pam_systemd.so class=none'
|
||||
|
||||
debian_sudo='#%PAM-1.0
|
||||
|
||||
# Set up user limits from /etc/security/limits.conf.
|
||||
session required pam_limits.so
|
||||
|
||||
session required pam_env.so readenv=1 user_readenv=0
|
||||
session required pam_env.so readenv=1 envfile=/etc/default/locale user_readenv=0
|
||||
@include common-auth
|
||||
@include common-account
|
||||
@include common-session-noninteractive'
|
||||
|
||||
fedora_sudo='#%PAM-1.0
|
||||
auth include system-auth
|
||||
account include system-auth
|
||||
password include system-auth
|
||||
session optional pam_keyinit.so revoke
|
||||
session required pam_limits.so
|
||||
session include system-auth'
|
||||
|
||||
arch_polkit='#%PAM-1.0
|
||||
|
||||
auth include system-auth
|
||||
account include system-auth
|
||||
password include system-auth
|
||||
session include system-auth'
|
||||
|
||||
for layout in arch_sudo debian_sudo fedora_sudo; do
|
||||
printf '%s\n' "${!layout}" > "$tmp/etc/sudo"
|
||||
cp "$tmp/etc/sudo" "$tmp/original"
|
||||
|
||||
pfu_pam_enable sudo
|
||||
check "$layout: turned on" 'pfu_pam_enabled sudo'
|
||||
check "$layout: the face comes before the password" '[[ "$(first_auth "$tmp/etc/sudo")" == *pam_plasma_face_unlock.so* ]]'
|
||||
check "$layout: with the dash and as sufficient" 'grep -qE "^-auth[[:space:]]+sufficient[[:space:]]+$PFU_PAM_MODULE\$" "$tmp/etc/sudo"'
|
||||
check "$layout: the header stays first" '[[ "$(head -n1 "$tmp/etc/sudo")" == "#%PAM-1.0" ]]'
|
||||
|
||||
pfu_pam_enable sudo
|
||||
check "$layout: turning it on twice adds it once" '[[ $(grep -c pam_plasma_face_unlock "$tmp/etc/sudo") -eq 1 ]]'
|
||||
|
||||
pfu_pam_disable sudo
|
||||
check "$layout: turning it off gives back the same file" 'cmp -s "$tmp/etc/sudo" "$tmp/original"'
|
||||
check "$layout: turned off" '! pfu_pam_enabled sudo'
|
||||
rm -f "$tmp/etc/sudo"
|
||||
done
|
||||
|
||||
# Only the distribution's copy, in /usr/lib/pam.d.
|
||||
printf '%s\n' "$arch_polkit" > "$tmp/vendor/polkit-1"
|
||||
pfu_pam_enable polkit-1
|
||||
check "vendor polkit-1: a small file of our own in /etc" '[[ -f $tmp/etc/polkit-1 ]] && grep -qF "$PFU_PAM_WRAPPER_MARK" "$tmp/etc/polkit-1"'
|
||||
check "vendor polkit-1: it includes the distribution's file" 'grep -qE "^auth[[:space:]]+include[[:space:]]+$tmp/vendor/polkit-1\$" "$tmp/etc/polkit-1"'
|
||||
check "vendor polkit-1: face first" '[[ "$(first_auth "$tmp/etc/polkit-1")" == *pam_plasma_face_unlock.so* ]]'
|
||||
check "vendor polkit-1: the distribution's file is left alone" '[[ "$(cat "$tmp/vendor/polkit-1")" == "$arch_polkit" ]]'
|
||||
pfu_pam_disable polkit-1
|
||||
check "vendor polkit-1: turning it off removes our file" '[[ ! -e $tmp/etc/polkit-1 ]]'
|
||||
|
||||
# No configuration at all.
|
||||
check "an unknown service is refused" '! pfu_pam_enable nosuchservice 2>/dev/null'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Through real PAM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
harness="$here/build/pam_harness"
|
||||
if [[ -x $harness ]]; then
|
||||
mkdir -p "$tmp/real/vendor" "$tmp/real/etc"
|
||||
printf 'auth required pam_permit.so\naccount required pam_permit.so\n' > "$tmp/real/vendor/svc"
|
||||
PFU_PAM_ETC_DIR="$tmp/real/etc"
|
||||
PFU_PAM_VENDOR_DIRS=("$tmp/real/vendor")
|
||||
PFU_PAM_MODULE="$tmp/real/missing/pam_plasma_face_unlock.so"
|
||||
mkdir -p "$tmp/real/missing" && : > "$PFU_PAM_MODULE"
|
||||
pfu_pam_enable svc
|
||||
rm -f "$PFU_PAM_MODULE"
|
||||
check "a missing module is skipped, the rest of the stack still decides" '"$harness" "$tmp/real/etc" svc "$(id -un)" > /dev/null 2>&1'
|
||||
|
||||
printf 'auth required pam_deny.so\n' > "$tmp/real/vendor/svc"
|
||||
check "and a stack that says no still says no" '! "$harness" "$tmp/real/etc" svc "$(id -un)" > /dev/null 2>&1'
|
||||
else
|
||||
printf 'skip real PAM checks (build pam_harness first)\n'
|
||||
fi
|
||||
|
||||
printf '\n%s\n' "$( (( failures )) && echo "SOME CHECKS FAILED" || echo "all checks passed")"
|
||||
(( failures == 0 ))
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The face store: what goes in comes back out, matching picks the right
|
||||
// sample, and learning from unlocks stays within its bounds.
|
||||
|
||||
#include "store.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <random>
|
||||
|
||||
namespace
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
void check(bool ok, const char *what)
|
||||
{
|
||||
std::printf("%s %s\n", ok ? "ok " : "FAIL", what);
|
||||
failures += !ok;
|
||||
}
|
||||
|
||||
Embedding randomUnit(std::mt19937 &rng)
|
||||
{
|
||||
std::normal_distribution<float> n;
|
||||
Embedding e(Vision::EmbeddingSize);
|
||||
float norm = 0;
|
||||
for (float &v : e) {
|
||||
v = n(rng);
|
||||
norm += v * v;
|
||||
}
|
||||
for (float &v : e) {
|
||||
v /= std::sqrt(norm);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// A vector about `similarity` away from `base`.
|
||||
Embedding near(const Embedding &base, float similarity, std::mt19937 &rng)
|
||||
{
|
||||
Embedding other = randomUnit(rng);
|
||||
Embedding out(base.size());
|
||||
const float w = std::sqrt(1 - similarity * similarity);
|
||||
float norm = 0;
|
||||
for (size_t i = 0; i < base.size(); ++i) {
|
||||
out[i] = similarity * base[i] + w * other[i];
|
||||
norm += out[i] * out[i];
|
||||
}
|
||||
for (float &v : out) {
|
||||
v /= std::sqrt(norm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
std::mt19937 rng(7);
|
||||
QTemporaryDir dir;
|
||||
const FaceStore store(dir.path());
|
||||
|
||||
Identity a;
|
||||
a.id = FaceStore::newId();
|
||||
a.name = QStringLiteral("Felix mit Brille");
|
||||
a.created = 1758550000;
|
||||
a.noseT = 0.57f;
|
||||
a.eyes = 0.41f;
|
||||
const Embedding faceA = randomUnit(rng);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
a.samples.append({near(faceA, 0.8f, rng), QStringLiteral("center"), 1758550000 + i});
|
||||
}
|
||||
|
||||
Identity b;
|
||||
b.id = FaceStore::newId();
|
||||
b.name = QStringLiteral("Other");
|
||||
const Embedding faceB = randomUnit(rng);
|
||||
b.samples.append({faceB, QStringLiteral("center"), 1});
|
||||
|
||||
QString error;
|
||||
check(store.save(1000, {a, b}, &error), "save");
|
||||
const QString file = dir.filePath(QStringLiteral("users/1000.json"));
|
||||
check(QFileInfo(file).permissions() == (QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadUser | QFileDevice::WriteUser),
|
||||
"the file is readable by its owner only");
|
||||
|
||||
const QList<Identity> loaded = store.load(1000, &error);
|
||||
check(loaded.size() == 2, "both faces come back");
|
||||
check(loaded.value(0).name == a.name && loaded.value(0).samples.size() == 5, "names and samples survive");
|
||||
check(std::abs(loaded.value(0).noseT - 0.57f) < 1e-6f && std::abs(loaded.value(0).eyes - 0.41f) < 1e-6f, "attention baselines survive");
|
||||
check(Vision::similarity(loaded.value(0).samples.value(0).embedding, a.samples.value(0).embedding) > 0.9999f, "embeddings survive exactly");
|
||||
check(store.load(1001).isEmpty(), "another user has no faces");
|
||||
|
||||
const FaceMatch m = FaceStore::bestMatch(loaded, near(faceA, 0.9f, rng));
|
||||
check(m.identity == 0 && m.score > 0.6f, "the right face matches");
|
||||
const FaceMatch stranger = FaceStore::bestMatch(loaded, randomUnit(rng));
|
||||
check(stranger.score < 0.4f, "a stranger does not");
|
||||
|
||||
QList<Identity> disabled = loaded;
|
||||
disabled[0].enabled = false;
|
||||
check(FaceStore::bestMatch(disabled, near(faceA, 0.9f, rng)).identity != 0, "a face that is turned off never matches");
|
||||
|
||||
Identity learning = loaded.value(0);
|
||||
check(!FaceStore::adapt(learning, learning.samples.value(0).embedding, 5), "nothing new: nothing learned");
|
||||
int added = 0;
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
added += FaceStore::adapt(learning, near(faceA, 0.6f, rng), 10 + i);
|
||||
}
|
||||
check(added > FaceStore::MaxAdaptive, "new looks are learned");
|
||||
check(learning.adaptiveCount() == FaceStore::MaxAdaptive, "but only so many of them are kept");
|
||||
int setup = 0;
|
||||
for (const FaceSample &s : learning.samples) {
|
||||
setup += s.pose != u"adaptive";
|
||||
}
|
||||
check(setup == 5, "the samples from the setup are never replaced");
|
||||
|
||||
check(store.save(1000, {}, &error) && !QFileInfo::exists(file), "saving nothing removes the file");
|
||||
|
||||
std::printf("\n%s\n", failures ? "SOME CHECKS FAILED" : "all checks passed");
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Reference in new issue
Block a user