@facesmash/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -0
- package/dist/index.cjs +658 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +192 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +621 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +1068 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +292 -0
- package/dist/react.d.ts +292 -0
- package/dist/react.js +1024 -0
- package/dist/react.js.map +1 -0
- package/package.json +84 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var PocketBase = require('pocketbase');
|
|
4
|
+
var faceapi = require('@vladmandic/face-api');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
function _interopNamespace(e) {
|
|
9
|
+
if (e && e.__esModule) return e;
|
|
10
|
+
var n = Object.create(null);
|
|
11
|
+
if (e) {
|
|
12
|
+
Object.keys(e).forEach(function (k) {
|
|
13
|
+
if (k !== 'default') {
|
|
14
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
15
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
16
|
+
enumerable: true,
|
|
17
|
+
get: function () { return e[k]; }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
n.default = e;
|
|
23
|
+
return Object.freeze(n);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var PocketBase__default = /*#__PURE__*/_interopDefault(PocketBase);
|
|
27
|
+
var faceapi__namespace = /*#__PURE__*/_interopNamespace(faceapi);
|
|
28
|
+
|
|
29
|
+
// src/core/client.ts
|
|
30
|
+
var modelsLoaded = false;
|
|
31
|
+
async function loadModels(config, onProgress) {
|
|
32
|
+
if (modelsLoaded) {
|
|
33
|
+
onProgress?.(100);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
try {
|
|
38
|
+
const tf2 = faceapi__namespace.tf;
|
|
39
|
+
if (tf2) {
|
|
40
|
+
await tf2.setBackend("webgl");
|
|
41
|
+
await tf2.ready();
|
|
42
|
+
if (tf2.env().flagRegistry?.CANVAS2D_WILL_READ_FREQUENTLY) {
|
|
43
|
+
tf2.env().set("CANVAS2D_WILL_READ_FREQUENTLY", true);
|
|
44
|
+
}
|
|
45
|
+
if (tf2.env().flagRegistry?.WEBGL_EXP_CONV) {
|
|
46
|
+
tf2.env().set("WEBGL_EXP_CONV", true);
|
|
47
|
+
}
|
|
48
|
+
if (config.debug) {
|
|
49
|
+
console.log(`[FaceSmash] TF.js backend: ${tf2.getBackend()}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
onProgress?.(10);
|
|
55
|
+
await Promise.all([
|
|
56
|
+
faceapi__namespace.nets.ssdMobilenetv1.loadFromUri(config.modelUrl),
|
|
57
|
+
faceapi__namespace.nets.tinyFaceDetector.loadFromUri(config.modelUrl),
|
|
58
|
+
faceapi__namespace.nets.faceLandmark68Net.loadFromUri(config.modelUrl),
|
|
59
|
+
faceapi__namespace.nets.faceRecognitionNet.loadFromUri(config.modelUrl),
|
|
60
|
+
faceapi__namespace.nets.faceExpressionNet.loadFromUri(config.modelUrl)
|
|
61
|
+
]);
|
|
62
|
+
modelsLoaded = true;
|
|
63
|
+
onProgress?.(100);
|
|
64
|
+
if (config.debug) {
|
|
65
|
+
console.log("[FaceSmash] Models loaded successfully");
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (config.debug) {
|
|
70
|
+
console.error("[FaceSmash] Failed to load models:", error);
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function areModelsLoaded() {
|
|
76
|
+
return modelsLoaded;
|
|
77
|
+
}
|
|
78
|
+
function getSsdOptions(minConfidence) {
|
|
79
|
+
return new faceapi__namespace.SsdMobilenetv1Options({ minConfidence });
|
|
80
|
+
}
|
|
81
|
+
function getTinyOptions() {
|
|
82
|
+
return new faceapi__namespace.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.4 });
|
|
83
|
+
}
|
|
84
|
+
async function extractDescriptor(input, config) {
|
|
85
|
+
try {
|
|
86
|
+
const media = typeof input === "string" ? await faceapi__namespace.fetchImage(input) : input;
|
|
87
|
+
let detection = await faceapi__namespace.detectSingleFace(media, getSsdOptions(config.minDetectionConfidence)).withFaceLandmarks().withFaceDescriptor();
|
|
88
|
+
if (!detection) {
|
|
89
|
+
detection = await faceapi__namespace.detectSingleFace(media, getTinyOptions()).withFaceLandmarks().withFaceDescriptor();
|
|
90
|
+
}
|
|
91
|
+
return detection?.descriptor ?? null;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (config.debug) {
|
|
94
|
+
console.error("[FaceSmash] Descriptor extraction failed:", error);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function analyzeFace(imageData, config) {
|
|
100
|
+
try {
|
|
101
|
+
const img = await faceapi__namespace.fetchImage(imageData);
|
|
102
|
+
let detection = await faceapi__namespace.detectSingleFace(img, getSsdOptions(config.minDetectionConfidence)).withFaceLandmarks().withFaceDescriptor();
|
|
103
|
+
if (!detection) {
|
|
104
|
+
detection = await faceapi__namespace.detectSingleFace(img, getTinyOptions()).withFaceLandmarks().withFaceDescriptor();
|
|
105
|
+
}
|
|
106
|
+
if (!detection) return null;
|
|
107
|
+
const headPose = estimateHeadPose(detection.landmarks, detection.detection.box);
|
|
108
|
+
const imgWidth = img.width || 640;
|
|
109
|
+
const imgHeight = img.height || 480;
|
|
110
|
+
const faceSizeCheck = validateFaceSize(detection.detection.box, imgWidth, imgHeight);
|
|
111
|
+
if (!faceSizeCheck.isValid) {
|
|
112
|
+
return {
|
|
113
|
+
descriptor: detection.descriptor,
|
|
114
|
+
normalizedDescriptor: normalizeDescriptor(detection.descriptor),
|
|
115
|
+
confidence: detection.detection.score,
|
|
116
|
+
qualityScore: 0,
|
|
117
|
+
lightingScore: 0,
|
|
118
|
+
headPose,
|
|
119
|
+
faceSizeCheck,
|
|
120
|
+
eyeAspectRatio: 0,
|
|
121
|
+
rejectionReason: faceSizeCheck.reason
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const { avgEAR } = getEyeAspectRatios(detection.landmarks);
|
|
125
|
+
let lightingAnalysis;
|
|
126
|
+
try {
|
|
127
|
+
lightingAnalysis = analyzeLighting(detection, img);
|
|
128
|
+
} catch {
|
|
129
|
+
lightingAnalysis = {
|
|
130
|
+
score: 0.5,
|
|
131
|
+
brightness: 0.5,
|
|
132
|
+
contrast: 0.5,
|
|
133
|
+
evenness: 0.5,
|
|
134
|
+
conditions: { tooDark: false, tooBright: false, uneven: false, optimal: false }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let qualityScore = Math.min(detection.detection.score, 1);
|
|
138
|
+
qualityScore *= 0.7 + lightingAnalysis.score * 0.3;
|
|
139
|
+
const faceArea = detection.detection.box.width * detection.detection.box.height;
|
|
140
|
+
const imageArea = 640 * 640;
|
|
141
|
+
const sizeRatio = Math.min(faceArea / imageArea, 0.3) / 0.3;
|
|
142
|
+
qualityScore *= 0.8 + sizeRatio * 0.2;
|
|
143
|
+
if (!headPose.isFrontal) {
|
|
144
|
+
const anglePenalty = Math.max(0.5, 1 - (Math.abs(headPose.yaw) + Math.abs(headPose.pitch)) * 0.3);
|
|
145
|
+
qualityScore *= anglePenalty;
|
|
146
|
+
}
|
|
147
|
+
qualityScore = Math.max(0, Math.min(1, qualityScore));
|
|
148
|
+
return {
|
|
149
|
+
descriptor: detection.descriptor,
|
|
150
|
+
normalizedDescriptor: normalizeDescriptor(detection.descriptor),
|
|
151
|
+
confidence: detection.detection.score,
|
|
152
|
+
qualityScore,
|
|
153
|
+
lightingScore: lightingAnalysis.score,
|
|
154
|
+
headPose,
|
|
155
|
+
faceSizeCheck,
|
|
156
|
+
eyeAspectRatio: avgEAR
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (config.debug) {
|
|
160
|
+
console.error("[FaceSmash] Face analysis failed:", error);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function processImages(images, config) {
|
|
166
|
+
if (images.length === 1) {
|
|
167
|
+
return extractDescriptor(images[0], config);
|
|
168
|
+
}
|
|
169
|
+
const descriptors = [];
|
|
170
|
+
for (const image of images) {
|
|
171
|
+
const d = await extractDescriptor(image, config);
|
|
172
|
+
if (d) descriptors.push(d);
|
|
173
|
+
}
|
|
174
|
+
if (descriptors.length === 0) return null;
|
|
175
|
+
const avg = new Float32Array(descriptors[0].length);
|
|
176
|
+
for (let i = 0; i < avg.length; i++) {
|
|
177
|
+
let sum = 0;
|
|
178
|
+
for (const d of descriptors) sum += d[i];
|
|
179
|
+
avg[i] = sum / descriptors.length;
|
|
180
|
+
}
|
|
181
|
+
return avg;
|
|
182
|
+
}
|
|
183
|
+
function euclidean(a, b) {
|
|
184
|
+
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
|
|
185
|
+
}
|
|
186
|
+
function calculateEAR(eye) {
|
|
187
|
+
if (eye.length < 6) return 0.3;
|
|
188
|
+
const v1 = euclidean(eye[1], eye[5]);
|
|
189
|
+
const v2 = euclidean(eye[2], eye[4]);
|
|
190
|
+
const h = euclidean(eye[0], eye[3]);
|
|
191
|
+
return h === 0 ? 0 : (v1 + v2) / (2 * h);
|
|
192
|
+
}
|
|
193
|
+
function getEyeAspectRatios(landmarks) {
|
|
194
|
+
const leftEAR = calculateEAR(landmarks.getLeftEye());
|
|
195
|
+
const rightEAR = calculateEAR(landmarks.getRightEye());
|
|
196
|
+
return { leftEAR, rightEAR, avgEAR: (leftEAR + rightEAR) / 2 };
|
|
197
|
+
}
|
|
198
|
+
function estimateHeadPose(landmarks, box) {
|
|
199
|
+
const nose = landmarks.getNose();
|
|
200
|
+
const jaw = landmarks.getJawOutline();
|
|
201
|
+
const noseTip = nose[3];
|
|
202
|
+
const faceCenterX = box.x + box.width / 2;
|
|
203
|
+
const faceCenterY = box.y + box.height / 2;
|
|
204
|
+
const yaw = (noseTip.x - faceCenterX) / (box.width / 2);
|
|
205
|
+
const pitch = (noseTip.y - faceCenterY) / (box.height / 2);
|
|
206
|
+
const jawLeft = jaw[0];
|
|
207
|
+
const jawRight = jaw[jaw.length - 1];
|
|
208
|
+
const roll = Math.atan2(jawRight.y - jawLeft.y, jawRight.x - jawLeft.x);
|
|
209
|
+
const isFrontal = Math.abs(yaw) < 0.35 && Math.abs(pitch) < 0.4 && Math.abs(roll) < 0.25;
|
|
210
|
+
return { yaw, pitch, roll, isFrontal };
|
|
211
|
+
}
|
|
212
|
+
function validateFaceSize(box, frameWidth = 640, frameHeight = 480) {
|
|
213
|
+
const ratio = box.width * box.height / (frameWidth * frameHeight);
|
|
214
|
+
if (ratio < 0.02) return { isValid: false, ratio, reason: "Face too far from camera" };
|
|
215
|
+
if (ratio > 0.65) return { isValid: false, ratio, reason: "Face too close to camera" };
|
|
216
|
+
if (box.width < 80 || box.height < 80) return { isValid: false, ratio, reason: "Face too small for reliable recognition" };
|
|
217
|
+
return { isValid: true, ratio };
|
|
218
|
+
}
|
|
219
|
+
function normalizeDescriptor(descriptor) {
|
|
220
|
+
let norm = 0;
|
|
221
|
+
for (let i = 0; i < descriptor.length; i++) norm += descriptor[i] ** 2;
|
|
222
|
+
norm = Math.sqrt(norm);
|
|
223
|
+
if (norm === 0) return descriptor;
|
|
224
|
+
const normalized = new Float32Array(descriptor.length);
|
|
225
|
+
for (let i = 0; i < descriptor.length; i++) normalized[i] = descriptor[i] / norm;
|
|
226
|
+
return normalized;
|
|
227
|
+
}
|
|
228
|
+
function analyzeLighting(detection, imageElement) {
|
|
229
|
+
const canvas = document.createElement("canvas");
|
|
230
|
+
const ctx = canvas.getContext("2d");
|
|
231
|
+
if (!ctx) throw new Error("Cannot get canvas context");
|
|
232
|
+
canvas.width = imageElement.width || 640;
|
|
233
|
+
canvas.height = imageElement.height || 640;
|
|
234
|
+
if (imageElement instanceof HTMLImageElement) {
|
|
235
|
+
ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height);
|
|
236
|
+
} else {
|
|
237
|
+
ctx.drawImage(imageElement, 0, 0);
|
|
238
|
+
}
|
|
239
|
+
const faceBox = detection.detection.box;
|
|
240
|
+
const faceImageData = ctx.getImageData(
|
|
241
|
+
Math.max(0, faceBox.x - 20),
|
|
242
|
+
Math.max(0, faceBox.y - 20),
|
|
243
|
+
Math.min(canvas.width - faceBox.x, faceBox.width + 40),
|
|
244
|
+
Math.min(canvas.height - faceBox.y, faceBox.height + 40)
|
|
245
|
+
);
|
|
246
|
+
const pixels = faceImageData.data;
|
|
247
|
+
let totalBrightness = 0;
|
|
248
|
+
const brightnessValues = [];
|
|
249
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
250
|
+
const brightness = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
|
|
251
|
+
totalBrightness += brightness;
|
|
252
|
+
brightnessValues.push(brightness);
|
|
253
|
+
}
|
|
254
|
+
const avgBrightness = totalBrightness / (pixels.length / 4);
|
|
255
|
+
const variance = brightnessValues.reduce((acc, val) => acc + (val - avgBrightness) ** 2, 0) / brightnessValues.length;
|
|
256
|
+
const contrast = Math.sqrt(variance);
|
|
257
|
+
const evenness = Math.max(0, 1 - contrast / 128);
|
|
258
|
+
const tooDark = avgBrightness < 80;
|
|
259
|
+
const tooBright = avgBrightness > 200;
|
|
260
|
+
const uneven = evenness < 0.6;
|
|
261
|
+
const optimal = !tooDark && !tooBright && !uneven;
|
|
262
|
+
let score = 0.5;
|
|
263
|
+
if (optimal) score = 0.9;
|
|
264
|
+
else if (tooDark) score = Math.max(0.2, avgBrightness / 160);
|
|
265
|
+
else if (tooBright) score = Math.max(0.2, (255 - avgBrightness) / 110);
|
|
266
|
+
else if (uneven) score = Math.max(0.3, evenness);
|
|
267
|
+
return {
|
|
268
|
+
score,
|
|
269
|
+
brightness: avgBrightness / 255,
|
|
270
|
+
contrast: Math.min(contrast / 64, 1),
|
|
271
|
+
evenness,
|
|
272
|
+
conditions: { tooDark, tooBright, uneven, optimal }
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function calculateSimilarity(d1, d2) {
|
|
276
|
+
return 1 - faceapi__namespace.euclideanDistance(d1, d2);
|
|
277
|
+
}
|
|
278
|
+
function facesMatch(d1, d2, threshold = 0.45) {
|
|
279
|
+
return calculateSimilarity(d1, d2) >= threshold;
|
|
280
|
+
}
|
|
281
|
+
function enhancedMatch(descriptor1, descriptor2, baseThreshold = 0.45, confidenceBoost = 0, lightingScore = 0.5) {
|
|
282
|
+
if (descriptor1.length !== descriptor2.length) {
|
|
283
|
+
return { isMatch: false, similarity: 0, adaptedThreshold: baseThreshold };
|
|
284
|
+
}
|
|
285
|
+
const similarity = calculateSimilarity(descriptor1, descriptor2);
|
|
286
|
+
let adaptedThreshold = baseThreshold;
|
|
287
|
+
if (lightingScore < 0.4) {
|
|
288
|
+
adaptedThreshold = Math.max(0.35, adaptedThreshold - 0.05);
|
|
289
|
+
} else if (lightingScore > 0.8) {
|
|
290
|
+
adaptedThreshold = Math.min(0.6, adaptedThreshold + 0.02);
|
|
291
|
+
}
|
|
292
|
+
adaptedThreshold = Math.max(0.35, adaptedThreshold - confidenceBoost * 0.05);
|
|
293
|
+
return {
|
|
294
|
+
isMatch: similarity >= adaptedThreshold,
|
|
295
|
+
similarity,
|
|
296
|
+
adaptedThreshold
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function multiTemplateMatch(newDescriptor, templates, baseThreshold, lightingScore = 0.5) {
|
|
300
|
+
if (templates.length === 0) {
|
|
301
|
+
return { isMatch: false, bestSimilarity: 0, avgSimilarity: 0, matchCount: 0 };
|
|
302
|
+
}
|
|
303
|
+
let bestSimilarity = 0;
|
|
304
|
+
let weightedSum = 0;
|
|
305
|
+
let totalWeight = 0;
|
|
306
|
+
let matchCount = 0;
|
|
307
|
+
for (const template of templates) {
|
|
308
|
+
if (!template.descriptor || template.descriptor.length === 0) continue;
|
|
309
|
+
const result = enhancedMatch(
|
|
310
|
+
newDescriptor,
|
|
311
|
+
template.descriptor,
|
|
312
|
+
baseThreshold,
|
|
313
|
+
template.weight,
|
|
314
|
+
lightingScore
|
|
315
|
+
);
|
|
316
|
+
if (result.similarity > bestSimilarity) {
|
|
317
|
+
bestSimilarity = result.similarity;
|
|
318
|
+
}
|
|
319
|
+
const w = template.quality * template.weight;
|
|
320
|
+
weightedSum += result.similarity * w;
|
|
321
|
+
totalWeight += w;
|
|
322
|
+
if (result.isMatch) matchCount++;
|
|
323
|
+
}
|
|
324
|
+
const avgSimilarity = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
325
|
+
const isMatch = bestSimilarity >= baseThreshold || matchCount / templates.length >= 0.6;
|
|
326
|
+
return { isMatch, bestSimilarity, avgSimilarity, matchCount };
|
|
327
|
+
}
|
|
328
|
+
function calculateLearningWeight(qualityScore, lightingScore, confidence) {
|
|
329
|
+
let weight = 1;
|
|
330
|
+
if (qualityScore > 0.8) weight *= 1.5;
|
|
331
|
+
else if (qualityScore > 0.6) weight *= 1.2;
|
|
332
|
+
else if (qualityScore < 0.4) weight *= 0.5;
|
|
333
|
+
if (lightingScore > 0.7) weight *= 1.3;
|
|
334
|
+
else if (lightingScore < 0.4) weight *= 0.7;
|
|
335
|
+
if (confidence > 0.8) weight *= 1.2;
|
|
336
|
+
else if (confidence < 0.5) weight *= 0.8;
|
|
337
|
+
return Math.max(0.1, Math.min(weight, 3));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/core/types.ts
|
|
341
|
+
var DEFAULT_CONFIG = {
|
|
342
|
+
apiUrl: "https://api.facesmash.app",
|
|
343
|
+
modelUrl: "https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model",
|
|
344
|
+
minDetectionConfidence: 0.3,
|
|
345
|
+
matchThreshold: 0.45,
|
|
346
|
+
minQualityScore: 0.2,
|
|
347
|
+
maxTemplatesPerUser: 10,
|
|
348
|
+
debug: false
|
|
349
|
+
};
|
|
350
|
+
function resolveConfig(config) {
|
|
351
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/core/client.ts
|
|
355
|
+
var FaceSmashClient = class {
|
|
356
|
+
constructor(config) {
|
|
357
|
+
this.listeners = [];
|
|
358
|
+
this._modelsLoaded = false;
|
|
359
|
+
this.config = resolveConfig(config);
|
|
360
|
+
this.pb = new PocketBase__default.default(this.config.apiUrl);
|
|
361
|
+
this.pb.autoCancellation(false);
|
|
362
|
+
}
|
|
363
|
+
// ─── Event System ───────────────────────────────────────────
|
|
364
|
+
on(listener) {
|
|
365
|
+
this.listeners.push(listener);
|
|
366
|
+
return () => {
|
|
367
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
emit(event) {
|
|
371
|
+
for (const listener of this.listeners) {
|
|
372
|
+
try {
|
|
373
|
+
listener(event);
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ─── Model Loading ──────────────────────────────────────────
|
|
379
|
+
get isReady() {
|
|
380
|
+
return this._modelsLoaded;
|
|
381
|
+
}
|
|
382
|
+
async init(onProgress) {
|
|
383
|
+
if (this._modelsLoaded) return true;
|
|
384
|
+
this.emit({ type: "models-loading", progress: 0 });
|
|
385
|
+
const success = await loadModels(this.config, (progress) => {
|
|
386
|
+
onProgress?.(progress);
|
|
387
|
+
this.emit({ type: "models-loading", progress });
|
|
388
|
+
});
|
|
389
|
+
if (success) {
|
|
390
|
+
this._modelsLoaded = true;
|
|
391
|
+
this.emit({ type: "models-loaded" });
|
|
392
|
+
} else {
|
|
393
|
+
this.emit({ type: "models-error", error: "Failed to load face recognition models" });
|
|
394
|
+
}
|
|
395
|
+
return success;
|
|
396
|
+
}
|
|
397
|
+
// ─── Face Analysis ──────────────────────────────────────────
|
|
398
|
+
async analyzeFace(imageData) {
|
|
399
|
+
this.ensureReady();
|
|
400
|
+
const result = await analyzeFace(imageData, this.config);
|
|
401
|
+
if (result) {
|
|
402
|
+
this.emit({ type: "face-detected", analysis: result });
|
|
403
|
+
} else {
|
|
404
|
+
this.emit({ type: "face-lost" });
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
// ─── Login ──────────────────────────────────────────────────
|
|
409
|
+
async login(images) {
|
|
410
|
+
this.ensureReady();
|
|
411
|
+
this.emit({ type: "login-start" });
|
|
412
|
+
try {
|
|
413
|
+
let bestAnalysis = null;
|
|
414
|
+
for (const img of images) {
|
|
415
|
+
const analysis = await analyzeFace(img, this.config);
|
|
416
|
+
if (analysis && !analysis.rejectionReason) {
|
|
417
|
+
if (!bestAnalysis || analysis.qualityScore > bestAnalysis.qualityScore) {
|
|
418
|
+
bestAnalysis = analysis;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!bestAnalysis) {
|
|
423
|
+
const error2 = "No face detected in any image";
|
|
424
|
+
this.emit({ type: "login-failed", error: error2 });
|
|
425
|
+
return { success: false, error: error2 };
|
|
426
|
+
}
|
|
427
|
+
if (bestAnalysis.qualityScore < this.config.minQualityScore) {
|
|
428
|
+
const error2 = "Face quality too low. Improve lighting and face the camera directly.";
|
|
429
|
+
this.emit({ type: "login-failed", error: error2 });
|
|
430
|
+
return { success: false, error: error2 };
|
|
431
|
+
}
|
|
432
|
+
const profiles = await this.pb.collection("user_profiles").getFullList();
|
|
433
|
+
if (profiles.length === 0) {
|
|
434
|
+
const error2 = "No registered users found";
|
|
435
|
+
this.emit({ type: "login-failed", error: error2 });
|
|
436
|
+
return { success: false, error: error2 };
|
|
437
|
+
}
|
|
438
|
+
let bestMatch = { user: null, similarity: 0 };
|
|
439
|
+
for (const profile of profiles) {
|
|
440
|
+
if (!profile.face_embedding) continue;
|
|
441
|
+
const storedEmbedding = new Float32Array(profile.face_embedding);
|
|
442
|
+
let matchResult = enhancedMatch(
|
|
443
|
+
bestAnalysis.descriptor,
|
|
444
|
+
storedEmbedding,
|
|
445
|
+
this.config.matchThreshold,
|
|
446
|
+
0,
|
|
447
|
+
bestAnalysis.lightingScore
|
|
448
|
+
);
|
|
449
|
+
try {
|
|
450
|
+
const templates = await this.pb.collection("face_templates").getList(1, 50, {
|
|
451
|
+
filter: `user_email="${profile.email}"`,
|
|
452
|
+
sort: "-quality_score"
|
|
453
|
+
});
|
|
454
|
+
if (templates.items.length > 0) {
|
|
455
|
+
const templateData = templates.items.filter((t) => t.descriptor && t.descriptor.length > 0).map((t) => ({
|
|
456
|
+
descriptor: new Float32Array(t.descriptor),
|
|
457
|
+
quality: t.quality_score || 0.5,
|
|
458
|
+
weight: 1
|
|
459
|
+
}));
|
|
460
|
+
if (templateData.length > 0) {
|
|
461
|
+
const multiResult = multiTemplateMatch(
|
|
462
|
+
bestAnalysis.descriptor,
|
|
463
|
+
templateData,
|
|
464
|
+
this.config.matchThreshold,
|
|
465
|
+
bestAnalysis.lightingScore
|
|
466
|
+
);
|
|
467
|
+
if (multiResult.bestSimilarity > matchResult.similarity) {
|
|
468
|
+
matchResult = {
|
|
469
|
+
isMatch: multiResult.isMatch,
|
|
470
|
+
similarity: multiResult.bestSimilarity,
|
|
471
|
+
adaptedThreshold: this.config.matchThreshold
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
const userProfile = {
|
|
479
|
+
id: profile.id,
|
|
480
|
+
name: profile.name,
|
|
481
|
+
email: profile.email,
|
|
482
|
+
face_embedding: profile.face_embedding,
|
|
483
|
+
created: profile.created,
|
|
484
|
+
updated: profile.updated
|
|
485
|
+
};
|
|
486
|
+
if (matchResult.similarity > bestMatch.similarity) {
|
|
487
|
+
bestMatch = { user: userProfile, similarity: matchResult.similarity };
|
|
488
|
+
}
|
|
489
|
+
if (matchResult.isMatch) {
|
|
490
|
+
try {
|
|
491
|
+
await this.storeLoginScan(userProfile, bestAnalysis);
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
this.emit({
|
|
495
|
+
type: "login-success",
|
|
496
|
+
user: userProfile,
|
|
497
|
+
similarity: matchResult.similarity
|
|
498
|
+
});
|
|
499
|
+
return { success: true, user: userProfile, similarity: matchResult.similarity };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const error = bestMatch.similarity > 0.4 ? "Face partially matched but did not meet security threshold." : "Face not recognized.";
|
|
503
|
+
this.emit({ type: "login-failed", error, bestSimilarity: bestMatch.similarity });
|
|
504
|
+
return { success: false, error, similarity: bestMatch.similarity };
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const error = err instanceof Error ? err.message : "Unknown error during login";
|
|
507
|
+
this.emit({ type: "login-failed", error });
|
|
508
|
+
return { success: false, error };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// ─── Registration ───────────────────────────────────────────
|
|
512
|
+
async register(name, images, email) {
|
|
513
|
+
this.ensureReady();
|
|
514
|
+
this.emit({ type: "register-start" });
|
|
515
|
+
try {
|
|
516
|
+
let bestAnalysis = null;
|
|
517
|
+
let bestImageIdx = 0;
|
|
518
|
+
for (let i = 0; i < images.length; i++) {
|
|
519
|
+
const analysis = await analyzeFace(images[i], this.config);
|
|
520
|
+
if (analysis && !analysis.rejectionReason) {
|
|
521
|
+
if (!bestAnalysis || analysis.qualityScore > bestAnalysis.qualityScore) {
|
|
522
|
+
bestAnalysis = analysis;
|
|
523
|
+
bestImageIdx = i;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!bestAnalysis) {
|
|
528
|
+
const error = "No face detected in any image";
|
|
529
|
+
this.emit({ type: "register-failed", error });
|
|
530
|
+
return { success: false, error };
|
|
531
|
+
}
|
|
532
|
+
if (bestAnalysis.qualityScore < this.config.minQualityScore) {
|
|
533
|
+
const error = "Face quality too low for registration.";
|
|
534
|
+
this.emit({ type: "register-failed", error });
|
|
535
|
+
return { success: false, error };
|
|
536
|
+
}
|
|
537
|
+
const existingProfiles = await this.pb.collection("user_profiles").getFullList();
|
|
538
|
+
for (const profile of existingProfiles) {
|
|
539
|
+
if (!profile.face_embedding) continue;
|
|
540
|
+
const stored = new Float32Array(profile.face_embedding);
|
|
541
|
+
if (stored.length !== bestAnalysis.descriptor.length) continue;
|
|
542
|
+
const similarity = 1 - (await import('@vladmandic/face-api')).euclideanDistance(bestAnalysis.descriptor, stored);
|
|
543
|
+
if (similarity >= 0.75) {
|
|
544
|
+
const error = `This face is already registered to ${profile.name || profile.email}`;
|
|
545
|
+
this.emit({ type: "register-failed", error });
|
|
546
|
+
return { success: false, error };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const embeddingArray = Array.from(bestAnalysis.descriptor);
|
|
550
|
+
const record = await this.pb.collection("user_profiles").create({
|
|
551
|
+
name,
|
|
552
|
+
email: email || `${name.toLowerCase().replace(/\s+/g, ".")}@facesmash.app`,
|
|
553
|
+
face_embedding: embeddingArray
|
|
554
|
+
});
|
|
555
|
+
await this.pb.collection("face_templates").create({
|
|
556
|
+
user_email: record.email,
|
|
557
|
+
descriptor: embeddingArray,
|
|
558
|
+
quality_score: bestAnalysis.qualityScore,
|
|
559
|
+
label: "registration"
|
|
560
|
+
});
|
|
561
|
+
await this.pb.collection("face_scans").create({
|
|
562
|
+
user_email: record.email,
|
|
563
|
+
face_embedding: JSON.stringify(embeddingArray),
|
|
564
|
+
confidence: String(bestAnalysis.confidence),
|
|
565
|
+
scan_type: "registration",
|
|
566
|
+
quality_score: String(bestAnalysis.qualityScore)
|
|
567
|
+
});
|
|
568
|
+
const user = {
|
|
569
|
+
id: record.id,
|
|
570
|
+
name: record.name,
|
|
571
|
+
email: record.email,
|
|
572
|
+
face_embedding: embeddingArray,
|
|
573
|
+
created: record.created,
|
|
574
|
+
updated: record.updated
|
|
575
|
+
};
|
|
576
|
+
this.emit({ type: "register-success", user });
|
|
577
|
+
return { success: true, user };
|
|
578
|
+
} catch (err) {
|
|
579
|
+
const error = err instanceof Error ? err.message : "Unknown error during registration";
|
|
580
|
+
this.emit({ type: "register-failed", error });
|
|
581
|
+
return { success: false, error };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
585
|
+
ensureReady() {
|
|
586
|
+
if (!areModelsLoaded()) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
"FaceSmash models not loaded. Call client.init() first."
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async storeLoginScan(user, analysis) {
|
|
593
|
+
const embeddingArray = Array.from(analysis.descriptor);
|
|
594
|
+
await this.pb.collection("sign_in_logs").create({
|
|
595
|
+
user_email: user.email,
|
|
596
|
+
success: true
|
|
597
|
+
});
|
|
598
|
+
await this.pb.collection("face_scans").create({
|
|
599
|
+
user_email: user.email,
|
|
600
|
+
face_embedding: JSON.stringify(embeddingArray),
|
|
601
|
+
confidence: String(analysis.confidence),
|
|
602
|
+
scan_type: "login",
|
|
603
|
+
quality_score: String(analysis.qualityScore)
|
|
604
|
+
});
|
|
605
|
+
if (analysis.qualityScore > 0.5) {
|
|
606
|
+
const weight = calculateLearningWeight(
|
|
607
|
+
analysis.qualityScore,
|
|
608
|
+
analysis.lightingScore,
|
|
609
|
+
analysis.confidence
|
|
610
|
+
);
|
|
611
|
+
const learningRate = Math.min(weight * 0.1, 0.3);
|
|
612
|
+
const current = new Float32Array(user.face_embedding);
|
|
613
|
+
const updated = new Float32Array(current.length);
|
|
614
|
+
for (let i = 0; i < current.length; i++) {
|
|
615
|
+
updated[i] = current[i] * (1 - learningRate) + analysis.descriptor[i] * learningRate;
|
|
616
|
+
}
|
|
617
|
+
await this.pb.collection("user_profiles").update(user.id, {
|
|
618
|
+
face_embedding: Array.from(updated)
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (analysis.qualityScore > 0.6) {
|
|
622
|
+
const existing = await this.pb.collection("face_templates").getList(1, 50, {
|
|
623
|
+
filter: `user_email="${user.email}"`,
|
|
624
|
+
sort: "quality_score"
|
|
625
|
+
});
|
|
626
|
+
if (existing.items.length >= this.config.maxTemplatesPerUser) {
|
|
627
|
+
await this.pb.collection("face_templates").delete(existing.items[0].id);
|
|
628
|
+
}
|
|
629
|
+
await this.pb.collection("face_templates").create({
|
|
630
|
+
user_email: user.email,
|
|
631
|
+
descriptor: embeddingArray,
|
|
632
|
+
quality_score: analysis.qualityScore,
|
|
633
|
+
label: "auto"
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// src/index.ts
|
|
640
|
+
function createFaceSmash(config) {
|
|
641
|
+
return new FaceSmashClient(config);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
exports.FaceSmashClient = FaceSmashClient;
|
|
645
|
+
exports.analyzeFace = analyzeFace;
|
|
646
|
+
exports.areModelsLoaded = areModelsLoaded;
|
|
647
|
+
exports.calculateLearningWeight = calculateLearningWeight;
|
|
648
|
+
exports.calculateSimilarity = calculateSimilarity;
|
|
649
|
+
exports.createFaceSmash = createFaceSmash;
|
|
650
|
+
exports.enhancedMatch = enhancedMatch;
|
|
651
|
+
exports.extractDescriptor = extractDescriptor;
|
|
652
|
+
exports.facesMatch = facesMatch;
|
|
653
|
+
exports.loadModels = loadModels;
|
|
654
|
+
exports.multiTemplateMatch = multiTemplateMatch;
|
|
655
|
+
exports.normalizeDescriptor = normalizeDescriptor;
|
|
656
|
+
exports.processImages = processImages;
|
|
657
|
+
//# sourceMappingURL=index.cjs.map
|
|
658
|
+
//# sourceMappingURL=index.cjs.map
|