@jupitermetalabs/face-zk-sdk 0.3.4 → 0.3.7
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 +46 -1
- package/assets/face-guidance/pose-guidance.js.txt +4 -2
- package/assets/liveness/liveness.js.txt +3 -0
- package/dist/FaceZkSdk.d.ts +69 -0
- package/dist/FaceZkSdk.js +136 -0
- package/dist/assets/face-guidance/pose-guidance.js.txt +4 -2
- package/dist/assets/liveness/liveness.js.txt +3 -0
- package/dist/assets/onnx/ort-min.d.ts +1 -0
- package/dist/assets/onnx/ort-min.js +11 -0
- package/dist/config/defaults.d.ts +51 -0
- package/dist/config/defaults.js +61 -0
- package/dist/config/types.d.ts +169 -0
- package/dist/config/types.js +17 -0
- package/dist/core/enrollment-core.d.ts +70 -0
- package/dist/core/enrollment-core.js +206 -0
- package/dist/core/idGenerator.d.ts +11 -0
- package/dist/core/idGenerator.js +32 -0
- package/dist/core/matching.d.ts +69 -0
- package/dist/core/matching.js +101 -0
- package/dist/core/types.d.ts +379 -0
- package/dist/core/types.js +37 -0
- package/dist/core/verification-core.d.ts +120 -0
- package/dist/core/verification-core.js +442 -0
- package/dist/core/zk-core.d.ts +69 -0
- package/dist/core/zk-core.js +244 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +51 -0
- package/dist/react-native/adapters/faceEmbeddingProvider.d.ts +38 -0
- package/dist/react-native/adapters/faceEmbeddingProvider.js +45 -0
- package/dist/react-native/adapters/imageDataProvider.d.ts +53 -0
- package/dist/react-native/adapters/imageDataProvider.js +134 -0
- package/dist/react-native/adapters/livenessProvider.d.ts +133 -0
- package/dist/react-native/adapters/livenessProvider.js +150 -0
- package/dist/react-native/adapters/zkProofEngine-webview.d.ts +73 -0
- package/dist/react-native/adapters/zkProofEngine-webview.js +135 -0
- package/dist/react-native/bundledRuntimeAssets.d.ts +39 -0
- package/dist/react-native/bundledRuntimeAssets.js +44 -0
- package/dist/react-native/components/FacePoseGuidanceWebView.d.ts +30 -0
- package/dist/react-native/components/FacePoseGuidanceWebView.js +530 -0
- package/dist/react-native/components/LivenessWebView.d.ts +39 -0
- package/dist/react-native/components/LivenessWebView.js +386 -0
- package/dist/react-native/components/OnnxRuntimeWebView.d.ts +58 -0
- package/dist/react-native/components/OnnxRuntimeWebView.js +518 -0
- package/dist/react-native/components/ZkProofWebView.d.ts +59 -0
- package/dist/react-native/components/ZkProofWebView.js +297 -0
- package/dist/react-native/dependencies.d.ts +144 -0
- package/dist/react-native/dependencies.js +130 -0
- package/dist/react-native/hooks/useOnnxLoader.d.ts +37 -0
- package/dist/react-native/hooks/useOnnxLoader.js +74 -0
- package/dist/react-native/hooks/useWasmLoader.d.ts +30 -0
- package/dist/react-native/hooks/useWasmLoader.js +158 -0
- package/dist/react-native/index.d.ts +61 -0
- package/dist/react-native/index.js +144 -0
- package/dist/react-native/services/FaceRecognition.d.ts +92 -0
- package/dist/react-native/services/FaceRecognition.js +674 -0
- package/dist/react-native/ui/FaceZkVerificationFlow.d.ts +97 -0
- package/dist/react-native/ui/FaceZkVerificationFlow.js +477 -0
- package/dist/react-native/ui/ReferenceEnrollmentFlow.d.ts +72 -0
- package/dist/react-native/ui/ReferenceEnrollmentFlow.js +369 -0
- package/dist/react-native/utils/faceAlignment.d.ts +37 -0
- package/dist/react-native/utils/faceAlignment.js +186 -0
- package/dist/react-native/utils/modelInitialisationChecks.d.ts +36 -0
- package/dist/react-native/utils/modelInitialisationChecks.js +128 -0
- package/dist/react-native/utils/resolveModelUri.d.ts +55 -0
- package/dist/react-native/utils/resolveModelUri.js +211 -0
- package/dist/react-native/utils/resolveRuntimeAsset.d.ts +25 -0
- package/dist/react-native/utils/resolveRuntimeAsset.js +94 -0
- package/dist/react-native/utils/resolveUiConfig.d.ts +41 -0
- package/dist/react-native/utils/resolveUiConfig.js +81 -0
- package/dist/storage/defaultStorageAdapter.d.ts +44 -0
- package/dist/storage/defaultStorageAdapter.js +344 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/face-zk.config.example.js +10 -3
- package/package.json +2 -2
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright 2026 JupiterMeta Labs
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
20
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
21
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
22
|
+
}
|
|
23
|
+
Object.defineProperty(o, k2, desc);
|
|
24
|
+
}) : (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
o[k2] = m[k];
|
|
27
|
+
}));
|
|
28
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
29
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
30
|
+
}) : function(o, v) {
|
|
31
|
+
o["default"] = v;
|
|
32
|
+
});
|
|
33
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34
|
+
var ownKeys = function(o) {
|
|
35
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
36
|
+
var ar = [];
|
|
37
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
38
|
+
return ar;
|
|
39
|
+
};
|
|
40
|
+
return ownKeys(o);
|
|
41
|
+
};
|
|
42
|
+
return function (mod) {
|
|
43
|
+
if (mod && mod.__esModule) return mod;
|
|
44
|
+
var result = {};
|
|
45
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
46
|
+
__setModuleDefault(result, mod);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.faceRecognitionService = exports.FaceRecognitionService = void 0;
|
|
52
|
+
const FileSystem = __importStar(require("expo-file-system/legacy"));
|
|
53
|
+
const ImageManipulator = __importStar(require("expo-image-manipulator"));
|
|
54
|
+
const jpeg = __importStar(require("jpeg-js"));
|
|
55
|
+
const faceAlignment_1 = require("../utils/faceAlignment");
|
|
56
|
+
const FaceZkSdk_1 = require("../../FaceZkSdk");
|
|
57
|
+
const resolveModelUri_1 = require("../utils/resolveModelUri");
|
|
58
|
+
const resolveRuntimeAsset_1 = require("../utils/resolveRuntimeAsset");
|
|
59
|
+
/**
|
|
60
|
+
* Unified Face Recognition Service for iOS and Android
|
|
61
|
+
* Uses ONNX Runtime Web in WebView for cross-platform compatibility
|
|
62
|
+
* Models: buffalo_sc (SCRFD detection + MobileFaceNet recognition)
|
|
63
|
+
*/
|
|
64
|
+
class FaceRecognitionService {
|
|
65
|
+
bridge = null;
|
|
66
|
+
modelsLoaded = false;
|
|
67
|
+
setBridge(bridge) {
|
|
68
|
+
console.log("[FaceRecognition] setBridge called with:", bridge ? "valid bridge" : "null bridge");
|
|
69
|
+
this.bridge = bridge;
|
|
70
|
+
console.log("[FaceRecognition] Bridge set, this.bridge:", this.bridge ? "set" : "still null");
|
|
71
|
+
}
|
|
72
|
+
isBridgeSet() {
|
|
73
|
+
return !!this.bridge;
|
|
74
|
+
}
|
|
75
|
+
async loadModels() {
|
|
76
|
+
console.log("[FaceRecognition] loadModels called, this.bridge:", this.bridge ? "exists" : "NULL");
|
|
77
|
+
if (!this.bridge) {
|
|
78
|
+
throw new Error("WebView bridge not initialized. Call setBridge() first.");
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
let detUrl;
|
|
82
|
+
let recUrl;
|
|
83
|
+
let ageGenderUrl;
|
|
84
|
+
if (FaceZkSdk_1.FaceZkSdk.isInitialized()) {
|
|
85
|
+
// ── SDK-configured model sources ───────────────────────────────────
|
|
86
|
+
// Supports bundled modules, CDN URLs, or pre-downloaded local URIs.
|
|
87
|
+
const sdkConfig = FaceZkSdk_1.FaceZkSdk.getConfig();
|
|
88
|
+
console.log("[FaceRecognition] Step 1: Resolving detection model from SDK config");
|
|
89
|
+
detUrl = await (0, resolveModelUri_1.resolveModelUri)(sdkConfig.models.detection, undefined, sdkConfig.allowedDomains);
|
|
90
|
+
console.log("[FaceRecognition] Detection model URI:", detUrl);
|
|
91
|
+
console.log("[FaceRecognition] Step 2: Resolving recognition model from SDK config");
|
|
92
|
+
recUrl = await (0, resolveModelUri_1.resolveModelUri)(sdkConfig.models.recognition, undefined, sdkConfig.allowedDomains);
|
|
93
|
+
console.log("[FaceRecognition] Recognition model URI:", recUrl);
|
|
94
|
+
if (sdkConfig.models.ageGender) {
|
|
95
|
+
console.log("[FaceRecognition] Step 2b: Resolving age/gender model from SDK config");
|
|
96
|
+
ageGenderUrl = await (0, resolveModelUri_1.resolveModelUri)(sdkConfig.models.ageGender, undefined, sdkConfig.allowedDomains);
|
|
97
|
+
console.log("[FaceRecognition] Age/Gender model URI:", ageGenderUrl);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
throw new Error("[FaceZkSdk] SDK not initialized. Call initializeSdk() before loading face recognition models.\n" +
|
|
102
|
+
"Required setup:\n" +
|
|
103
|
+
" import { initializeSdk } from '@jupitermetalabs/face-zk-sdk/react-native';\n" +
|
|
104
|
+
" await initializeSdk({\n" +
|
|
105
|
+
" models: {\n" +
|
|
106
|
+
" detection: { url: 'https://your-cdn.com/det_500m.onnx' },\n" +
|
|
107
|
+
" recognition: { url: 'https://your-cdn.com/w600k_mbf.onnx' },\n" +
|
|
108
|
+
" antispoof: { url: 'https://your-cdn.com/antispoof.onnx' }, // required for liveness\n" +
|
|
109
|
+
" },\n" +
|
|
110
|
+
" });");
|
|
111
|
+
}
|
|
112
|
+
const allowedDomains = FaceZkSdk_1.FaceZkSdk.getConfig().allowedDomains;
|
|
113
|
+
console.log("[FaceRecognition] Step 2.5: Loading ONNX WASM asset");
|
|
114
|
+
// ORT WASM resolved from runtimeAssets config or bundled fallback
|
|
115
|
+
const wasmBase64Raw = await (0, resolveRuntimeAsset_1.resolveRuntimeAsset)('ortWasm', 'base64', allowedDomains);
|
|
116
|
+
console.log("[FaceRecognition] ONNX WASM loaded via resolveRuntimeAsset");
|
|
117
|
+
console.log("[FaceRecognition] Step 3: Reading model files as base64");
|
|
118
|
+
// Read models as base64 to send to WebView
|
|
119
|
+
const detBase64 = await FileSystem.readAsStringAsync(detUrl, {
|
|
120
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
121
|
+
});
|
|
122
|
+
console.log("[FaceRecognition] Detection model size:", Math.round(detBase64.length / 1024), "KB");
|
|
123
|
+
const recBase64 = await FileSystem.readAsStringAsync(recUrl, {
|
|
124
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
125
|
+
});
|
|
126
|
+
console.log("[FaceRecognition] Recognition model size:", Math.round(recBase64.length / 1024), "KB");
|
|
127
|
+
// Strip whitespace that can cause atob() failures in the WebView (same as useOnnxLoader)
|
|
128
|
+
const wasmBase64 = wasmBase64Raw.replace(/\s/g, '');
|
|
129
|
+
console.log("[FaceRecognition] ONNX WASM size:", Math.round(wasmBase64.length / 1024), "KB");
|
|
130
|
+
let ageGenderBase64;
|
|
131
|
+
if (ageGenderUrl) {
|
|
132
|
+
ageGenderBase64 = await FileSystem.readAsStringAsync(ageGenderUrl, {
|
|
133
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
134
|
+
});
|
|
135
|
+
console.log("[FaceRecognition] Age/Gender model size:", Math.round(ageGenderBase64.length / 1024), "KB");
|
|
136
|
+
}
|
|
137
|
+
console.log("[FaceRecognition] Step 4: Sending model data to WebView");
|
|
138
|
+
// Send base64 data to WebView - it will convert to Blob URLs
|
|
139
|
+
const loadPromise = this.bridge.loadModels(detBase64, recBase64, wasmBase64, ageGenderBase64);
|
|
140
|
+
console.log("[FaceRecognition] Step 5: Waiting for WebView to load models...");
|
|
141
|
+
await loadPromise;
|
|
142
|
+
console.log("[FaceRecognition] Step 6: WebView confirmed models loaded!");
|
|
143
|
+
this.modelsLoaded = true;
|
|
144
|
+
console.log("[FaceRecognition] ✅ Models loaded successfully via WebView");
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
console.error("[FaceRecognition] ❌ Error loading models:", e);
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async processImageForEmbedding(imageUri) {
|
|
152
|
+
if (!this.bridge || !this.modelsLoaded) {
|
|
153
|
+
throw new Error("Models not loaded");
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
console.log("[FaceRecognition] 📸 Processing image:", imageUri);
|
|
157
|
+
// 1. Resize and preprocess image for detection
|
|
158
|
+
console.log("[FaceRecognition] Step 1: Preprocessing image to 640x640...");
|
|
159
|
+
const { processedUri, data: processedData } = await this.preprocessImage(imageUri, 640, 640);
|
|
160
|
+
console.log("[FaceRecognition] Preprocessed image data size:", processedData.length);
|
|
161
|
+
// 2. Run face detection
|
|
162
|
+
console.log("[FaceRecognition] Step 2: Running face detection via WebView...");
|
|
163
|
+
const detectionResult = await this.bridge.runDetection(processedData, 640, 640);
|
|
164
|
+
console.log("[FaceRecognition] Detection result outputs:", Object.keys(detectionResult.outputs).length);
|
|
165
|
+
// 3. Parse detection results
|
|
166
|
+
console.log("[FaceRecognition] Step 3: Parsing detection output...");
|
|
167
|
+
const boxes = this.parseDetectionOutput(detectionResult.outputs);
|
|
168
|
+
console.log("[FaceRecognition] Detected boxes count:", boxes.length);
|
|
169
|
+
if (boxes.length > 0) {
|
|
170
|
+
console.log("[FaceRecognition] First box:", boxes[0]);
|
|
171
|
+
}
|
|
172
|
+
if (boxes.length === 0) {
|
|
173
|
+
console.warn("[FaceRecognition] ⚠️ No faces detected in image");
|
|
174
|
+
return {
|
|
175
|
+
status: "no_face",
|
|
176
|
+
message: "No face detected. Please ensure your face is clearly visible.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (boxes.length > 1) {
|
|
180
|
+
console.warn(`[FaceRecognition] ⚠️ ${boxes.length} faces detected — rejecting.`);
|
|
181
|
+
return {
|
|
182
|
+
status: "multiple_faces",
|
|
183
|
+
message: "Multiple faces detected. Please ensure only one face is visible.",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const box = boxes[0];
|
|
187
|
+
console.log("[FaceRecognition] ✅ Single face detected:", box);
|
|
188
|
+
// 4. Align face using 5-point landmarks (Umeyama + WarpAffine)
|
|
189
|
+
console.log("[FaceRecognition] Step 4: Aligning face using landmarks...");
|
|
190
|
+
// Calculate Similarity Transform Matrix
|
|
191
|
+
const matrix = (0, faceAlignment_1.estimateUmeyama)(box.landmarks);
|
|
192
|
+
console.log("[FaceRecognition] Affine Matrix estimated:", matrix);
|
|
193
|
+
// Warp affine to get 112x112 aligned face
|
|
194
|
+
const faceImage = (0, faceAlignment_1.warpAffine)(processedData, 640, 640, matrix, 112);
|
|
195
|
+
console.log("[FaceRecognition] Face aligned and warped. Data size:", faceImage.length);
|
|
196
|
+
// 5. Run recognition to get embedding
|
|
197
|
+
console.log("[FaceRecognition] Step 5: Running recognition to get embedding...");
|
|
198
|
+
const embeddingResult = await this.bridge.runRecognition(faceImage, 112, 112);
|
|
199
|
+
console.log("[FaceRecognition] Embedding dims:", embeddingResult.dims);
|
|
200
|
+
console.log("[FaceRecognition] Embedding size:", embeddingResult.data.length);
|
|
201
|
+
const embedding = this.normalizeEmbedding(Array.from(embeddingResult.data));
|
|
202
|
+
console.log("[FaceRecognition] ✅ Final normalized embedding sample (first 10):", embedding.slice(0, 10));
|
|
203
|
+
// 6. Estimate Pose
|
|
204
|
+
const pose = this.estimatePoseFromLandmarks(box.landmarks);
|
|
205
|
+
console.log("[FaceRecognition] Estimated Pose:", pose);
|
|
206
|
+
// 7. Age & Gender Prediction (if model loaded)
|
|
207
|
+
let detectedGender = "Unknown";
|
|
208
|
+
let estimatedAge = 0;
|
|
209
|
+
try {
|
|
210
|
+
console.log("[FaceRecognition] Step 7: Running Age/Gender inference (skip if not loaded)...");
|
|
211
|
+
// Python-equivalent center-crop from full 640×640 image (1.5× bbox padding, [0,255])
|
|
212
|
+
const ageGenderTensor = this.cropFaceForGenderAge(processedData, box);
|
|
213
|
+
const ageGenderRes = await this.bridge.runAgeGender(ageGenderTensor, 96, 96);
|
|
214
|
+
detectedGender = ageGenderRes.gender;
|
|
215
|
+
estimatedAge = ageGenderRes.age;
|
|
216
|
+
console.log("[FaceRecognition] Age/Gender Result:", { age: estimatedAge, gender: detectedGender });
|
|
217
|
+
}
|
|
218
|
+
catch (agError) {
|
|
219
|
+
if (!agError.message?.includes("model not loaded")) {
|
|
220
|
+
console.warn("[FaceRecognition] Age/Gender warning:", agError);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
status: "ok",
|
|
225
|
+
embedding,
|
|
226
|
+
box,
|
|
227
|
+
pose,
|
|
228
|
+
gender: detectedGender !== "Unknown" ? detectedGender : undefined,
|
|
229
|
+
age: estimatedAge > 0 ? estimatedAge : undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
console.error("[FaceRecognition] ❌ Error:", error);
|
|
234
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
235
|
+
if (message.startsWith("NO_FACE:")) {
|
|
236
|
+
return { status: "no_face", message: "No usable face detected in the image" };
|
|
237
|
+
}
|
|
238
|
+
return { status: "error", message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async getEmbeddings(imageUri) {
|
|
242
|
+
const result = await this.processImageForEmbedding(imageUri);
|
|
243
|
+
return result.status === "ok" ? result.embedding || null : null;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Process a pre-cropped reference image without face detection.
|
|
247
|
+
* This skips bounding box detection and directly extracts the embedding.
|
|
248
|
+
* Use this for small, already-cropped document photos to preserve quality.
|
|
249
|
+
*/
|
|
250
|
+
async processPreCroppedImage(imageUri) {
|
|
251
|
+
if (!this.bridge || !this.modelsLoaded) {
|
|
252
|
+
throw new Error("Models not loaded");
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
console.log("[FaceRecognition] 📸 Processing pre-cropped image (no detection):", imageUri);
|
|
256
|
+
// Get image dimensions first
|
|
257
|
+
const imageInfo = await ImageManipulator.manipulateAsync(imageUri, [], {
|
|
258
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
259
|
+
});
|
|
260
|
+
// Center crop to square to avoid aspect ratio distortion
|
|
261
|
+
console.log("[FaceRecognition] Step 1: Center-cropping to square...");
|
|
262
|
+
const size = Math.min(imageInfo.width, imageInfo.height);
|
|
263
|
+
const originX = (imageInfo.width - size) / 2;
|
|
264
|
+
const originY = (imageInfo.height - size) / 2;
|
|
265
|
+
const croppedResult = await ImageManipulator.manipulateAsync(imageUri, [
|
|
266
|
+
{
|
|
267
|
+
crop: {
|
|
268
|
+
originX,
|
|
269
|
+
originY,
|
|
270
|
+
width: size,
|
|
271
|
+
height: size,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{ resize: { width: 112, height: 112 } },
|
|
275
|
+
], { format: ImageManipulator.SaveFormat.JPEG, compress: 1 });
|
|
276
|
+
console.log("[FaceRecognition] Step 2: Converting to tensor format...");
|
|
277
|
+
const base64 = await FileSystem.readAsStringAsync(croppedResult.uri, {
|
|
278
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
279
|
+
});
|
|
280
|
+
const binaryString = atob(base64);
|
|
281
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
282
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
283
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
284
|
+
}
|
|
285
|
+
const rawImageData = jpeg.decode(bytes, { useTArray: true });
|
|
286
|
+
const data = new Float32Array(3 * 112 * 112);
|
|
287
|
+
const pixelData = rawImageData.data;
|
|
288
|
+
// Convert from HWC to CHW and normalize
|
|
289
|
+
for (let h = 0; h < 112; h++) {
|
|
290
|
+
for (let w = 0; w < 112; w++) {
|
|
291
|
+
const srcIdx = (h * 112 + w) * 4;
|
|
292
|
+
const dstIdxR = 0 * 112 * 112 + h * 112 + w;
|
|
293
|
+
const dstIdxG = 1 * 112 * 112 + h * 112 + w;
|
|
294
|
+
const dstIdxB = 2 * 112 * 112 + h * 112 + w;
|
|
295
|
+
data[dstIdxR] = (pixelData[srcIdx + 0] - 127.5) / 128.0;
|
|
296
|
+
data[dstIdxG] = (pixelData[srcIdx + 1] - 127.5) / 128.0;
|
|
297
|
+
data[dstIdxB] = (pixelData[srcIdx + 2] - 127.5) / 128.0;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
console.log("[FaceRecognition] Face image data size:", data.length);
|
|
301
|
+
// Run recognition to get embedding
|
|
302
|
+
console.log("[FaceRecognition] Step 3: Running recognition to get embedding...");
|
|
303
|
+
const embeddingResult = await this.bridge.runRecognition(data, 112, 112);
|
|
304
|
+
console.log("[FaceRecognition] Embedding dims:", embeddingResult.dims);
|
|
305
|
+
console.log("[FaceRecognition] Embedding size:", embeddingResult.data.length);
|
|
306
|
+
// Normalize embedding
|
|
307
|
+
console.log("[FaceRecognition] Step 4: Normalizing embedding...");
|
|
308
|
+
const embedding = this.normalizeEmbedding(Array.from(embeddingResult.data));
|
|
309
|
+
console.log("[FaceRecognition] ✅ Final normalized embedding sample (first 10):", embedding.slice(0, 10));
|
|
310
|
+
// Step 5: Run Detection to get Pose (New requirement)
|
|
311
|
+
console.log("[FaceRecognition] Step 5: Running detection on cropped image for POSE extraction...");
|
|
312
|
+
// We need to run detection on the *original* image (or a larger resized version),
|
|
313
|
+
// NOT the 112x112 blob, because 112 is too small for accurate landmarks if we already warped it?
|
|
314
|
+
// Wait, we warped it manually in processPreCropped?
|
|
315
|
+
// No, processPreCropped manipulates the URI then reads it.
|
|
316
|
+
// The `data` variable is the 112x112 CHW tensor.
|
|
317
|
+
// Detection needs 640x640 usually for best results with this model.
|
|
318
|
+
// Let's use the preprocessImage helper to get a 640x640 version of the URI
|
|
319
|
+
const detectionInput = await this.preprocessImage(imageUri, 640, 640);
|
|
320
|
+
const detResult = await this.bridge.runDetection(detectionInput.data, 640, 640);
|
|
321
|
+
const boxes = this.parseDetectionOutput(detResult.outputs);
|
|
322
|
+
let pose = { yaw: 0, pitch: 0, roll: 0 };
|
|
323
|
+
if (boxes.length > 0) {
|
|
324
|
+
pose = this.estimatePoseFromLandmarks(boxes[0].landmarks);
|
|
325
|
+
console.log("[FaceRecognition] ✅ Pose extracted from reference:", pose);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
console.warn("[FaceRecognition] ⚠️ No face detected for pose extraction, defaulting to 0");
|
|
329
|
+
}
|
|
330
|
+
// Step 6: Run Age/Gender (because data is identically prepared 112x112)
|
|
331
|
+
let detectedGender = "Unknown";
|
|
332
|
+
let estimatedAge = 0;
|
|
333
|
+
try {
|
|
334
|
+
console.log("[FaceRecognition] Step 6: Running Age/Gender inference on pre-cropped face...");
|
|
335
|
+
// Python-equivalent center-crop from full 640×640 image (1.5× bbox padding, [0,255])
|
|
336
|
+
const agBox = boxes.length > 0 ? boxes[0] : null;
|
|
337
|
+
const ageGenderTensor = agBox
|
|
338
|
+
? this.cropFaceForGenderAge(detectionInput.data, agBox)
|
|
339
|
+
: this.renormalizeForGenderAge(this.resizeTensorCHW(data, 112, 96));
|
|
340
|
+
const ageGenderRes = await this.bridge.runAgeGender(ageGenderTensor, 96, 96);
|
|
341
|
+
detectedGender = ageGenderRes.gender;
|
|
342
|
+
estimatedAge = ageGenderRes.age;
|
|
343
|
+
}
|
|
344
|
+
catch (agError) {
|
|
345
|
+
if (!agError.message?.includes("model not loaded")) {
|
|
346
|
+
console.warn("[FaceRecognition] Age/Gender warning:", agError);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
status: "ok",
|
|
351
|
+
embedding,
|
|
352
|
+
pose,
|
|
353
|
+
gender: detectedGender !== "Unknown" ? detectedGender : undefined,
|
|
354
|
+
age: estimatedAge > 0 ? estimatedAge : undefined,
|
|
355
|
+
// No bounding box to return as main result, as this was pre-cropped flow
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error("[FaceRecognition] ❌ Error:", error);
|
|
360
|
+
return {
|
|
361
|
+
status: "error",
|
|
362
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Helper methods (same as iOS version)
|
|
367
|
+
async preprocessImage(imageUri, targetWidth, targetHeight) {
|
|
368
|
+
// Implementation similar to iOS version
|
|
369
|
+
const manipResult = await ImageManipulator.manipulateAsync(imageUri, [{ resize: { width: targetWidth, height: targetHeight } }], { format: ImageManipulator.SaveFormat.JPEG, compress: 1 });
|
|
370
|
+
const base64 = await FileSystem.readAsStringAsync(manipResult.uri, {
|
|
371
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
372
|
+
});
|
|
373
|
+
// Convert base64 to Uint8Array (React Native compatible)
|
|
374
|
+
const binaryString = atob(base64);
|
|
375
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
376
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
377
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
378
|
+
}
|
|
379
|
+
const rawImageData = jpeg.decode(bytes, { useTArray: true });
|
|
380
|
+
// Convert to CHW format and normalize
|
|
381
|
+
// Input: HWC (height x width x channels) RGB
|
|
382
|
+
// Output: CHW (channels x height x width) normalized
|
|
383
|
+
const data = new Float32Array(3 * targetHeight * targetWidth);
|
|
384
|
+
const pixelData = rawImageData.data; // RGBA format
|
|
385
|
+
// Convert from HWC to CHW and normalize
|
|
386
|
+
// Mean: [127.5, 127.5, 127.5], Std: [128, 128, 128]
|
|
387
|
+
for (let h = 0; h < targetHeight; h++) {
|
|
388
|
+
for (let w = 0; w < targetWidth; w++) {
|
|
389
|
+
const srcIdx = (h * targetWidth + w) * 4; // RGBA, so *4
|
|
390
|
+
const dstIdxR = 0 * targetHeight * targetWidth + h * targetWidth + w;
|
|
391
|
+
const dstIdxG = 1 * targetHeight * targetWidth + h * targetWidth + w;
|
|
392
|
+
const dstIdxB = 2 * targetHeight * targetWidth + h * targetWidth + w;
|
|
393
|
+
// Normalize: (pixel - 127.5) / 128.0
|
|
394
|
+
data[dstIdxR] = (pixelData[srcIdx + 0] - 127.5) / 128.0;
|
|
395
|
+
data[dstIdxG] = (pixelData[srcIdx + 1] - 127.5) / 128.0;
|
|
396
|
+
data[dstIdxB] = (pixelData[srcIdx + 2] - 127.5) / 128.0;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
processedUri: manipResult.uri,
|
|
401
|
+
data,
|
|
402
|
+
width: targetWidth,
|
|
403
|
+
height: targetHeight,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
parseDetectionOutput(outputs) {
|
|
407
|
+
console.log("[FaceRecognition] Parsing SCRFD detection output...");
|
|
408
|
+
console.log("[FaceRecognition] Number of output tensors:", Object.keys(outputs).length);
|
|
409
|
+
const boxes = [];
|
|
410
|
+
const scoreThreshold = 0.5; // Raised from 0.25 — eliminates spurious detections that cause false "multiple faces"
|
|
411
|
+
// Group outputs by type (scores, bboxes, landmarks)
|
|
412
|
+
const scoreTensors = [];
|
|
413
|
+
const bboxTensors = [];
|
|
414
|
+
const landmarkTensors = [];
|
|
415
|
+
Object.keys(outputs).forEach((key) => {
|
|
416
|
+
const tensor = outputs[key];
|
|
417
|
+
const lastDim = tensor.dims[tensor.dims.length - 1];
|
|
418
|
+
if (lastDim === 1) {
|
|
419
|
+
scoreTensors.push(tensor);
|
|
420
|
+
}
|
|
421
|
+
else if (lastDim === 4) {
|
|
422
|
+
bboxTensors.push(tensor);
|
|
423
|
+
}
|
|
424
|
+
else if (lastDim === 10) {
|
|
425
|
+
landmarkTensors.push(tensor);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
console.log(`[FaceRecognition] Found ${scoreTensors.length} score tensors, ${bboxTensors.length} bbox tensors`);
|
|
429
|
+
// Process each scale
|
|
430
|
+
for (let scaleIdx = 0; scaleIdx < Math.min(scoreTensors.length, bboxTensors.length); scaleIdx++) {
|
|
431
|
+
const scores = scoreTensors[scaleIdx];
|
|
432
|
+
const bboxes = bboxTensors[scaleIdx];
|
|
433
|
+
if (!scores || !bboxes)
|
|
434
|
+
continue;
|
|
435
|
+
const numAnchors = scores.dims[0];
|
|
436
|
+
const stride = scaleIdx === 0 ? 8 : scaleIdx === 1 ? 16 : 32;
|
|
437
|
+
const height = Math.floor(640 / stride);
|
|
438
|
+
const width = Math.floor(640 / stride);
|
|
439
|
+
console.log(`[FaceRecognition] Scale ${scaleIdx}: stride=${stride}, grid=${height}x${width}, anchors=${numAnchors}`);
|
|
440
|
+
// Generate anchor centers (2 anchors per grid point)
|
|
441
|
+
const numAnchorsPerPoint = 2;
|
|
442
|
+
const anchorCenters = [];
|
|
443
|
+
for (let y = 0; y < height; y++) {
|
|
444
|
+
for (let x = 0; x < width; x++) {
|
|
445
|
+
for (let a = 0; a < numAnchorsPerPoint; a++) {
|
|
446
|
+
anchorCenters.push([x * stride, y * stride]);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Parse detections
|
|
451
|
+
for (let i = 0; i < numAnchors; i++) {
|
|
452
|
+
const score = scores.data[i];
|
|
453
|
+
if (score >= scoreThreshold) {
|
|
454
|
+
// Get bbox distance predictions and multiply by stride
|
|
455
|
+
const distLeft = bboxes.data[i * 4 + 0] * stride;
|
|
456
|
+
const distTop = bboxes.data[i * 4 + 1] * stride;
|
|
457
|
+
const distRight = bboxes.data[i * 4 + 2] * stride;
|
|
458
|
+
const distBottom = bboxes.data[i * 4 + 3] * stride;
|
|
459
|
+
// Get anchor center
|
|
460
|
+
const [anchorX, anchorY] = anchorCenters[i];
|
|
461
|
+
// distance2bbox decoding
|
|
462
|
+
const x1 = anchorX - distLeft;
|
|
463
|
+
const y1 = anchorY - distTop;
|
|
464
|
+
const x2 = anchorX + distRight;
|
|
465
|
+
const y2 = anchorY + distBottom;
|
|
466
|
+
// Clamp to image bounds
|
|
467
|
+
const clampedX1 = Math.max(0, Math.min(640, x1));
|
|
468
|
+
const clampedY1 = Math.max(0, Math.min(640, y1));
|
|
469
|
+
const clampedX2 = Math.max(0, Math.min(640, x2));
|
|
470
|
+
const clampedY2 = Math.max(0, Math.min(640, y2));
|
|
471
|
+
// Extract Landmarks
|
|
472
|
+
const landmarks = [];
|
|
473
|
+
// Check if we have landmark tensors for this scale
|
|
474
|
+
if (scaleIdx < landmarkTensors.length) {
|
|
475
|
+
const lmkTensor = landmarkTensors[scaleIdx];
|
|
476
|
+
// 10 values per anchor (5 points x 2 coords)
|
|
477
|
+
const lmkStart = i * 10;
|
|
478
|
+
for (let k = 0; k < 5; k++) {
|
|
479
|
+
const predX = lmkTensor.data[lmkStart + k * 2];
|
|
480
|
+
const predY = lmkTensor.data[lmkStart + k * 2 + 1];
|
|
481
|
+
const lmX = anchorX + predX * stride;
|
|
482
|
+
const lmY = anchorY + predY * stride;
|
|
483
|
+
landmarks.push([lmX, lmY]);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Sanity check
|
|
487
|
+
if (clampedX2 > clampedX1 && clampedY2 > clampedY1) {
|
|
488
|
+
const boxWidth = clampedX2 - clampedX1;
|
|
489
|
+
const boxHeight = clampedY2 - clampedY1;
|
|
490
|
+
if (boxWidth >= 20 && boxHeight >= 20) {
|
|
491
|
+
boxes.push({
|
|
492
|
+
x1: clampedX1,
|
|
493
|
+
y1: clampedY1,
|
|
494
|
+
x2: clampedX2,
|
|
495
|
+
y2: clampedY2,
|
|
496
|
+
score,
|
|
497
|
+
landmarks,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
console.log(`[FaceRecognition] Found ${boxes.length} boxes above threshold`);
|
|
505
|
+
// Apply NMS
|
|
506
|
+
const nmsBoxes = this.applyNMS(boxes, 0.4);
|
|
507
|
+
console.log(`[FaceRecognition] After NMS: ${nmsBoxes.length} boxes`);
|
|
508
|
+
// Return boxes sorted by score (no Y-axis adjustment to match InsightFace)
|
|
509
|
+
return nmsBoxes.sort((a, b) => b.score - a.score);
|
|
510
|
+
}
|
|
511
|
+
applyNMS(boxes, iouThreshold) {
|
|
512
|
+
if (boxes.length === 0)
|
|
513
|
+
return [];
|
|
514
|
+
// Sort by score
|
|
515
|
+
boxes.sort((a, b) => b.score - a.score);
|
|
516
|
+
const selected = [];
|
|
517
|
+
const suppressed = new Set();
|
|
518
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
519
|
+
if (suppressed.has(i))
|
|
520
|
+
continue;
|
|
521
|
+
selected.push(boxes[i]);
|
|
522
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
523
|
+
if (suppressed.has(j))
|
|
524
|
+
continue;
|
|
525
|
+
const iou = this.calculateIOU(boxes[i], boxes[j]);
|
|
526
|
+
if (iou > iouThreshold) {
|
|
527
|
+
suppressed.add(j);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return selected;
|
|
532
|
+
}
|
|
533
|
+
calculateIOU(box1, box2) {
|
|
534
|
+
const x1 = Math.max(box1.x1, box2.x1);
|
|
535
|
+
const y1 = Math.max(box1.y1, box2.y1);
|
|
536
|
+
const x2 = Math.min(box1.x2, box2.x2);
|
|
537
|
+
const y2 = Math.min(box1.y2, box2.y2);
|
|
538
|
+
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
539
|
+
const area1 = (box1.x2 - box1.x1) * (box1.y2 - box1.y1);
|
|
540
|
+
const area2 = (box2.x2 - box2.x1) * (box2.y2 - box2.y1);
|
|
541
|
+
const union = area1 + area2 - intersection;
|
|
542
|
+
return union === 0 ? 0 : intersection / union;
|
|
543
|
+
}
|
|
544
|
+
expandBox(box, margin = 0.2, imageWidth = 640, imageHeight = 640) {
|
|
545
|
+
// Expand bounding box by margin percentage to preserve more context
|
|
546
|
+
const width = box.x2 - box.x1;
|
|
547
|
+
const height = box.y2 - box.y1;
|
|
548
|
+
const expandX = width * margin;
|
|
549
|
+
const expandY = height * margin;
|
|
550
|
+
return {
|
|
551
|
+
x1: Math.max(0, box.x1 - expandX),
|
|
552
|
+
y1: Math.max(0, box.y1 - expandY),
|
|
553
|
+
x2: Math.min(imageWidth, box.x2 + expandX),
|
|
554
|
+
y2: Math.min(imageHeight, box.y2 + expandY),
|
|
555
|
+
score: box.score,
|
|
556
|
+
landmarks: box.landmarks,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
normalizeEmbedding(embedding) {
|
|
560
|
+
const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
|
|
561
|
+
if (norm === 0)
|
|
562
|
+
throw new Error("NO_FACE: model returned a zero-vector — face crop may be empty or invalid");
|
|
563
|
+
return embedding.map((val) => val / norm);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Converts a CHW tensor from ArcFace normalization [-1, 1] back to raw pixel
|
|
567
|
+
* values [0, 255] as expected by the genderage model (scalefactor=1, mean=0).
|
|
568
|
+
* ArcFace: v = (pixel - 127.5) / 128.0 → pixel = v * 128.0 + 127.5
|
|
569
|
+
*/
|
|
570
|
+
renormalizeForGenderAge(src) {
|
|
571
|
+
const dst = new Float32Array(src.length);
|
|
572
|
+
for (let i = 0; i < src.length; i++) {
|
|
573
|
+
dst[i] = src[i] * 128.0 + 127.5;
|
|
574
|
+
}
|
|
575
|
+
return dst;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Replicates the Python yakhyo/facial-analysis center-crop for genderage:
|
|
579
|
+
* scale = 96 / (max(w, h) * 1.5) ← 1.5× padding around the face
|
|
580
|
+
* SimilarityTransform(scale, translate_to_center)
|
|
581
|
+
* cv2.warpAffine → 96×96, borderValue=0
|
|
582
|
+
* blobFromImage scalefactor=1, mean=0, swapRB=True → [0, 255] RGB
|
|
583
|
+
*
|
|
584
|
+
* Crops directly from the normalized 640×640 tensor and denormalizes.
|
|
585
|
+
*/
|
|
586
|
+
cropFaceForGenderAge(processedData, box) {
|
|
587
|
+
const bboxW = box.x2 - box.x1;
|
|
588
|
+
const bboxH = box.y2 - box.y1;
|
|
589
|
+
const centerX = (box.x1 + box.x2) / 2;
|
|
590
|
+
const centerY = (box.y1 + box.y2) / 2;
|
|
591
|
+
const targetSize = 96;
|
|
592
|
+
const scale = targetSize / (Math.max(bboxW, bboxH) * 1.5);
|
|
593
|
+
const tx = targetSize / 2 - centerX * scale;
|
|
594
|
+
const ty = targetSize / 2 - centerY * scale;
|
|
595
|
+
// Simple scale + translate (no rotation) — matrix format [a, b, tx, c, d, ty]
|
|
596
|
+
const matrix = [scale, 0, tx, 0, scale, ty];
|
|
597
|
+
const crop = (0, faceAlignment_1.warpAffine)(processedData, 640, 640, matrix, targetSize);
|
|
598
|
+
return this.renormalizeForGenderAge(crop);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Resizes a CHW normalized float32 tensor using bilinear interpolation
|
|
602
|
+
*/
|
|
603
|
+
resizeTensorCHW(src, srcSize, dstSize) {
|
|
604
|
+
if (srcSize === dstSize)
|
|
605
|
+
return src;
|
|
606
|
+
const dst = new Float32Array(3 * dstSize * dstSize);
|
|
607
|
+
const scale = srcSize / dstSize;
|
|
608
|
+
const srcChSize = srcSize * srcSize;
|
|
609
|
+
const dstChSize = dstSize * dstSize;
|
|
610
|
+
for (let c = 0; c < 3; c++) {
|
|
611
|
+
for (let y = 0; y < dstSize; y++) {
|
|
612
|
+
for (let x = 0; x < dstSize; x++) {
|
|
613
|
+
const srcX = x * scale;
|
|
614
|
+
const srcY = y * scale;
|
|
615
|
+
const x0 = Math.floor(srcX);
|
|
616
|
+
const y0 = Math.floor(srcY);
|
|
617
|
+
const x1 = Math.min(x0 + 1, srcSize - 1);
|
|
618
|
+
const y1 = Math.min(y0 + 1, srcSize - 1);
|
|
619
|
+
const dx = srcX - x0;
|
|
620
|
+
const dy = srcY - y0;
|
|
621
|
+
const w00 = (1 - dx) * (1 - dy);
|
|
622
|
+
const w10 = dx * (1 - dy);
|
|
623
|
+
const w01 = (1 - dx) * dy;
|
|
624
|
+
const w11 = dx * dy;
|
|
625
|
+
const v00 = src[c * srcChSize + y0 * srcSize + x0];
|
|
626
|
+
const v10 = src[c * srcChSize + y0 * srcSize + x1];
|
|
627
|
+
const v01 = src[c * srcChSize + y1 * srcSize + x0];
|
|
628
|
+
const v11 = src[c * srcChSize + y1 * srcSize + x1];
|
|
629
|
+
dst[c * dstChSize + y * dstSize + x] = v00 * w00 + v10 * w10 + v01 * w01 + v11 * w11;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return dst;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Estimate Pose (Yaw, Pitch, Roll) from 5 landmarks (SCRFD)
|
|
637
|
+
* Landmarks: [LeftEye, RightEye, Nose, LeftMouth, RightMouth]
|
|
638
|
+
*/
|
|
639
|
+
estimatePoseFromLandmarks(landmarks) {
|
|
640
|
+
if (!landmarks || landmarks.length !== 5) {
|
|
641
|
+
return { yaw: 0, pitch: 0, roll: 0 };
|
|
642
|
+
}
|
|
643
|
+
const [leftEye, rightEye, nose, leftMouth, rightMouth] = landmarks;
|
|
644
|
+
// 1. Roll: Angle between eyes
|
|
645
|
+
const dy = rightEye[1] - leftEye[1];
|
|
646
|
+
const dx = rightEye[0] - leftEye[0];
|
|
647
|
+
const roll = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
648
|
+
// 2. Yaw: Ratio of nose to eyes
|
|
649
|
+
// Midpoint of eyes
|
|
650
|
+
const eyeMidX = (leftEye[0] + rightEye[0]) / 2;
|
|
651
|
+
// Distance from nose to eye midpoint
|
|
652
|
+
// If nose is to the left of midpoint -> Looking Left (Positive Yaw in some systems, let's normalize)
|
|
653
|
+
// In our guidance: (nose.x - midPointX) * 200
|
|
654
|
+
// Let's use the same logic as face-logic.js to match values
|
|
655
|
+
// face-logic: (nose.x - midPointX) * 200 (normalized coords)
|
|
656
|
+
// Here coords are absolute.
|
|
657
|
+
const eyeDist = Math.hypot(dx, dy);
|
|
658
|
+
if (eyeDist === 0)
|
|
659
|
+
return { yaw: 0, pitch: 0, roll: 0 };
|
|
660
|
+
// Normalize deviation by face scale (eye distance)
|
|
661
|
+
const yawRatio = (nose[0] - eyeMidX) / eyeDist;
|
|
662
|
+
const yaw = yawRatio * 90; // Approx degrees scaling
|
|
663
|
+
// 3. Pitch: Ratio of nose to eyes/mouth center
|
|
664
|
+
const mouthMidY = (leftMouth[1] + rightMouth[1]) / 2;
|
|
665
|
+
const eyeMidY = (leftEye[1] + rightEye[1]) / 2;
|
|
666
|
+
const midFaceY = (eyeMidY + mouthMidY) / 2;
|
|
667
|
+
const faceHeight = Math.hypot(mouthMidY - eyeMidY, (leftMouth[0] + rightMouth[0]) / 2 - eyeMidX);
|
|
668
|
+
const pitchRatio = faceHeight === 0 ? 0 : (nose[1] - midFaceY) / faceHeight;
|
|
669
|
+
const pitch = pitchRatio * 90;
|
|
670
|
+
return { yaw, pitch, roll };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
exports.FaceRecognitionService = FaceRecognitionService;
|
|
674
|
+
exports.faceRecognitionService = new FaceRecognitionService();
|