From 8cc810bb69d2ccdc06407fc35be6dfaa3b20c1b6 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Thu, 11 Nov 2021 17:01:10 -0500 Subject: [PATCH] add similarity score range normalization --- CHANGELOG.md | 3 ++- demo/faceid/index.html | 2 +- demo/faceid/index.js | 24 +++++++++++++----------- demo/faceid/index.ts | 20 ++++++++++---------- demo/facematch/facematch.js | 7 ++++--- src/face/match.ts | 36 +++++++++++++++++++++++------------- 6 files changed, 53 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abe371d..fcc05e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ ## Changelog -### **HEAD -> main** 2021/11/10 mandic00@live.com +### **HEAD -> main** 2021/11/11 mandic00@live.com +- documentation overhaul - auto tensor shape and channels handling - disable use of path2d in node - add liveness module and facerecognition demo diff --git a/demo/faceid/index.html b/demo/faceid/index.html index 8409ab7c..0367f77e 100644 --- a/demo/faceid/index.html +++ b/demo/faceid/index.html @@ -18,7 +18,7 @@ html { font-family: 'Lato', 'Segoe UI'; font-size: 16px; font-variant: small-caps; } body { margin: 0; padding: 16px; background: black; color: white; overflow-x: hidden; width: 100vw; height: 100vh; } body::-webkit-scrollbar { display: none; } - .button { padding: 2px; cursor: pointer; box-shadow: 2px 2px black; width: 64px; text-align: center; margin-left: 16px; height: 16px } + .button { padding: 2px; cursor: pointer; box-shadow: 2px 2px black; width: 64px; text-align: center; margin-left: 16px; height: 16px; display: none } diff --git a/demo/faceid/index.js b/demo/faceid/index.js index 76b80bc4..d48ffc3f 100644 --- a/demo/faceid/index.js +++ b/demo/faceid/index.js @@ -225,19 +225,13 @@ async function deleteRecord() { } } async function detectFace() { - var _a; + var _a, _b; + (_a = dom.canvas.getContext("2d")) == null ? void 0 : _a.clearRect(0, 0, options.minSize, options.minSize); if (!face || !face.tensor || !face.embedding) return 0; - dom.canvas.width = face.tensor.shape[1] || 0; - dom.canvas.height = face.tensor.shape[0] || 0; - dom.source.width = dom.canvas.width; - dom.source.height = dom.canvas.height; - dom.canvas.style.width = ""; human.tf.browser.toPixels(face.tensor, dom.canvas); const descriptors = db2.map((rec) => rec.descriptor); const res = await human.match(face.embedding, descriptors); - dom.match.style.display = "flex"; - dom.retry.style.display = "block"; if (res.index === -1) { log2("no matches"); dom.delete.style.display = "none"; @@ -248,11 +242,12 @@ async function detectFace() { dom.delete.style.display = ""; dom.name.value = current.name; dom.source.style.display = ""; - (_a = dom.source.getContext("2d")) == null ? void 0 : _a.putImageData(current.image, 0, 0); + (_b = dom.source.getContext("2d")) == null ? void 0 : _b.putImageData(current.image, 0, 0); } return res.similarity > options.threshold; } async function main() { + var _a, _b; ok.faceCount = false; ok.faceConfidence = false; ok.facingCenter = false; @@ -269,9 +264,16 @@ async function main() { startTime = human.now(); face = await validationLoop(); dom.fps.style.display = "none"; + dom.canvas.width = ((_a = face == null ? void 0 : face.tensor) == null ? void 0 : _a.shape[1]) || options.minSize; + dom.canvas.height = ((_b = face == null ? void 0 : face.tensor) == null ? void 0 : _b.shape[0]) || options.minSize; + dom.source.width = dom.canvas.width; + dom.source.height = dom.canvas.height; + dom.canvas.style.width = ""; + dom.match.style.display = "flex"; + dom.retry.style.display = "block"; if (!allOk()) { - log2("did not find valid input", face); - return 0; + log2("did not find valid face"); + return false; } else { const res = await detectFace(); document.body.style.background = res ? "darkgreen" : "maroon"; diff --git a/demo/faceid/index.ts b/demo/faceid/index.ts index 5f706d64..c4311b0b 100644 --- a/demo/faceid/index.ts +++ b/demo/faceid/index.ts @@ -179,18 +179,11 @@ async function deleteRecord() { } async function detectFace() { - // draw face and dispose face tensor immediatey afterwards + dom.canvas.getContext('2d')?.clearRect(0, 0, options.minSize, options.minSize); if (!face || !face.tensor || !face.embedding) return 0; - dom.canvas.width = face.tensor.shape[1] || 0; - dom.canvas.height = face.tensor.shape[0] || 0; - dom.source.width = dom.canvas.width; - dom.source.height = dom.canvas.height; - dom.canvas.style.width = ''; human.tf.browser.toPixels(face.tensor as unknown as TensorLike, dom.canvas); const descriptors = db.map((rec) => rec.descriptor); const res = await human.match(face.embedding, descriptors); - dom.match.style.display = 'flex'; - dom.retry.style.display = 'block'; if (res.index === -1) { log('no matches'); dom.delete.style.display = 'none'; @@ -223,9 +216,16 @@ async function main() { // main entry point startTime = human.now(); face = await validationLoop(); // start validation loop dom.fps.style.display = 'none'; + dom.canvas.width = face?.tensor?.shape[1] || options.minSize; + dom.canvas.height = face?.tensor?.shape[0] || options.minSize; + dom.source.width = dom.canvas.width; + dom.source.height = dom.canvas.height; + dom.canvas.style.width = ''; + dom.match.style.display = 'flex'; + dom.retry.style.display = 'block'; if (!allOk()) { - log('did not find valid input', face); - return 0; + log('did not find valid face'); + return false; } else { // log('found valid face'); const res = await detectFace(); diff --git a/demo/facematch/facematch.js b/demo/facematch/facematch.js index 09cdc089..3338eca7 100644 --- a/demo/facematch/facematch.js +++ b/demo/facematch/facematch.js @@ -184,9 +184,8 @@ async function AddImageElement(index, image, length) { return new Promise((resolve) => { const img = new Image(128, 128); img.onload = () => { // must wait until image is loaded - human.detect(img, userConfig).then(async (res) => { + human.detect(img, userConfig).then((res) => { const ok = AddFaceCanvas(index, res, image); // then wait until image is analyzed - // log('Add image:', index + 1, image, 'faces:', res.face.length); if (ok) document.getElementById('images').appendChild(img); // and finally we can add it resolve(true); }); @@ -250,11 +249,13 @@ async function main() { // const promises = []; // for (let i = 0; i < images.length; i++) promises.push(AddImageElement(i, images[i], images.length)); // await Promise.all(promises); + const t0 = human.now(); for (let i = 0; i < images.length; i++) await AddImageElement(i, images[i], images.length); + const t1 = human.now(); // print stats const num = all.reduce((prev, cur) => prev += cur.length, 0); - log('Extracted faces:', num, 'from images:', all.length); + log('Extracted faces:', num, 'from images:', all.length, 'time:', Math.round(t1 - t0)); log(human.tf.engine().memory()); // if we didn't download db, generate it from current faces diff --git a/src/face/match.ts b/src/face/match.ts index 40d6d090..b96a67ec 100644 --- a/src/face/match.ts +++ b/src/face/match.ts @@ -1,6 +1,6 @@ /** Face descriptor type as number array */ export type Descriptor = Array -export type Options = { order?: number, threshold?: number, multiplier?: number } | undefined; +export type MatchOptions = { order?: number, threshold?: number, multiplier?: number, min?: number, max?: number } | undefined; /** Calculates distance between two descriptors * @param {object} options @@ -10,7 +10,7 @@ export type Options = { order?: number, threshold?: number, multiplier?: number * - default is 20 which normalizes results to similarity above 0.5 can be considered a match * @returns {number} */ -export function distance(descriptor1: Descriptor, descriptor2: Descriptor, options: Options = { order: 2, multiplier: 20 }) { +export function distance(descriptor1: Descriptor, descriptor2: Descriptor, options: MatchOptions = { order: 2, multiplier: 25 }) { // general minkowski distance, euclidean distance is limited case where order is 2 let sum = 0; for (let i = 0; i < descriptor1.length; i++) { @@ -20,19 +20,29 @@ export function distance(descriptor1: Descriptor, descriptor2: Descriptor, optio return (options.multiplier || 20) * sum; } +// invert distance to similarity, normalize to given range and clamp +const normalizeDistance = (dist, order, min, max) => { + if (dist === 0) return 1; // short circuit for identical inputs + const root = order === 2 ? Math.sqrt(dist) : dist ** (1 / order); // take root of distance + const norm = (1 - (root / 100) - min) / (max - min); // normalize to range + const clamp = Math.max(Math.min(norm, 1), 0); // clamp to 0..1 + return clamp; +}; + /** Calculates normalized similarity between two face descriptors based on their `distance` * @param {object} options * @param {number} options.order algorithm to use * - Euclidean distance if `order` is 2 (default), Minkowski distance algorithm of nth order if `order` is higher than 2 * @param {number} options.multiplier by how much to enhance difference analysis in range of 1..100 * - default is 20 which normalizes results to similarity above 0.5 can be considered a match + * @param {number} options.min normalize similarity result to a given range + * @param {number} options.max normalzie similarity resutl to a given range + * - default is 0.2...0.8 * @returns {number} similarity between two face descriptors normalized to 0..1 range where 0 is no similarity and 1 is perfect similarity */ -export function similarity(descriptor1: Descriptor, descriptor2: Descriptor, options: Options = { order: 2, multiplier: 20 }) { +export function similarity(descriptor1: Descriptor, descriptor2: Descriptor, options: MatchOptions = { order: 2, multiplier: 25, min: 0.2, max: 0.8 }) { const dist = distance(descriptor1, descriptor2, options); - const root = (!options.order || options.order === 2) ? Math.sqrt(dist) : dist ** (1 / options.order); - const invert = Math.max(0, 100 - root) / 100.0; - return invert; + return normalizeDistance(dist, options.order || 2, options.min || 0, options.max || 1); } /** Matches given descriptor to a closest entry in array of descriptors @@ -46,20 +56,20 @@ export function similarity(descriptor1: Descriptor, descriptor2: Descriptor, opt * - {@link distance} calculated `distance` of given descriptor to the best match * - {@link similarity} calculated normalized `similarity` of given descriptor to the best match */ -export function match(descriptor: Descriptor, descriptors: Array, options: Options = { order: 2, multiplier: 20, threshold: 0 }) { +export function match(descriptor: Descriptor, descriptors: Array, options: MatchOptions = { order: 2, multiplier: 25, threshold: 0, min: 0.2, max: 0.8 }) { if (!Array.isArray(descriptor) || !Array.isArray(descriptors) || descriptor.length < 64 || descriptors.length === 0 || descriptor.length !== descriptors[0].length) { // validate input return { index: -1, distance: Number.POSITIVE_INFINITY, similarity: 0 }; } - let best = Number.MAX_SAFE_INTEGER; + let lowestDistance = Number.MAX_SAFE_INTEGER; let index = -1; for (let i = 0; i < descriptors.length; i++) { const res = distance(descriptor, descriptors[i], options); - if (res < best) { - best = res; + if (res < lowestDistance) { + lowestDistance = res; index = i; } - if (best < (options.threshold || 0)) break; + if (lowestDistance < (options.threshold || 0)) break; } - best = (!options.order || options.order === 2) ? Math.sqrt(best) : best ** (1 / options.order); - return { index, distance: best, similarity: Math.max(0, 100 - best) / 100.0 }; + const normalizedSimilarity = normalizeDistance(lowestDistance, options.order || 2, options.min || 0, options.max || 1); + return { index, distance: lowestDistance, similarity: normalizedSimilarity }; }