// The local detector returns source-frame pixels in dlib/face-api 68-point // order. The caller supplies the lens-specific yaw mapping verified on phone. // Reuse H5 framing and pose geometry without AR's 106-point map. var CaptureQuality = require('../vendor/capture-quality'); var HeadPose = require('../vendor/head-pose'); // Tiny landmarks under-estimate some phone head turns. Relax only the native // side-pose gate; shared H5 rules and all preceding quality gates stay intact. var sidePoseConfig = Object.freeze({ yawMin: 12, yawMax: 100, pitchMax: 40, rollMax: 30 }); function finite(value) { return typeof value === 'number' && isFinite(value); } function relaxedSideVerdict(detection, target, options, width, height) { var pose; try { pose = HeadPose.estimate(detection.landmarks.positions, options.mirrored === true); } catch (_) { return null; } if (!pose) return null; var yaw = pose.yaw * (target === 'left' ? -1 : 1); if (yaw < sidePoseConfig.yawMin || yaw > sidePoseConfig.yawMax || Math.abs(pose.pitch) > sidePoseConfig.pitchMax || Math.abs(pose.roll) > sidePoseConfig.rollMax) return null; var box = detection.detection.box, luma = finite(options.luma) ? options.luma : null; return { ok: true, hint: '保持不动', warning: luma !== null && luma < CaptureQuality.config.dark ? '建议到光线明亮处拍摄' : luma !== null && luma > CaptureQuality.config.bright ? '光线过强,建议避开强光' : '', observation: { yaw: pose.yaw, pitch: pose.pitch, roll: pose.roll, score: detection.detection.score, luma: luma, ratio: box.height / height, center_x: (box.x + box.width / 2) / width, center_y: (box.y + box.height / 2) / height } }; } function evaluate(result, target, options) { options = options || {}; if (!result || !finite(result.width) || !finite(result.height) || result.width <= 0 || result.height <= 0 || !Array.isArray(result.detections)) { return { ok: false, reason: 'frame', hint: '等待相机真实画面…', frameReceived: false, faceCount: 0, width: 0, height: 0 }; } var width = result.width, height = result.height, first = result.detections[0], detection = null, offsetX = 0; if (first) { // Keep malformed detector responses fail-closed rather than throwing into // the camera loop. Never modify the detector's original points or box. detection = { detection: { box: first.detection && first.detection.box, score: first.detection && first.detection.score }, landmarks: { positions: first.landmarks && first.landmarks.positions } }; var box = detection.detection.box; if (box && finite(box.x) && finite(box.width)) { // H5 faceOffsetX: object-fit:cover, centered crop, preview scale 1. // Mirroring changes the sign, which does not change the centering gate. var scale = finite(options.viewWidth) && options.viewWidth > 0 && finite(options.viewHeight) && options.viewHeight > 0 ? Math.max(options.viewWidth / width, options.viewHeight / height) / options.viewWidth : 1 / width; offsetX = (box.x + box.width / 2 - width / 2) * scale; } } var luma = finite(options.luma) ? options.luma : finite(result.luma) ? result.luma : null; var verdict = CaptureQuality.evaluate(detection, { count: result.detections.length, target: target, width: width, height: height, mirrored: options.mirrored === true, offsetX: offsetX, luma: luma }); // An angle failure means confidence, framing, centering and single-face // checks already passed. Never relax failures from those earlier gates. if (!verdict.ok && verdict.reason === 'angle' && (target === 'left' || target === 'right')) { verdict = relaxedSideVerdict(detection, target, { mirrored: options.mirrored, luma: luma }, width, height) || verdict; } // Only quality metadata may cross setData. Do not expose native frames, // detections, boxes, landmarks, or private image fields in the verdict. verdict.frameReceived = true; verdict.faceCount = result.detections.length; verdict.width = width; verdict.height = height; return verdict; } module.exports = { evaluate: evaluate, hold: CaptureQuality.hold, config: CaptureQuality.config, poseConfig: HeadPose.config, sidePoseConfig: sidePoseConfig, scoreThreshold: CaptureQuality.scoreThreshold };