267 lines
20 KiB
JavaScript
267 lines
20 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test'), assert = require('node:assert/strict');
|
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), zlib = require('node:zlib');
|
|
const root = path.resolve(__dirname, '..');
|
|
function runtime() {
|
|
// No document, Image, canvas, fetch, process, Buffer, global, self, window or
|
|
// Node module resolution. Only explicitly allowlisted packaged modules are permitted.
|
|
const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout });
|
|
const cache = {};
|
|
function load(file) {
|
|
file = path.resolve(file);
|
|
if (cache[file]) return cache[file].exports;
|
|
const module = { exports: {} }; cache[file] = module;
|
|
const invoke = vm.runInContext('(function(exports, require, module) {' + fs.readFileSync(file, 'utf8') + '\n})', context, { filename: file });
|
|
invoke(module.exports, name => {
|
|
assert(name.startsWith('.'), 'no runtime Node module: ' + name);
|
|
const target = path.resolve(path.dirname(file), name + '.js');
|
|
assert(target.startsWith(path.join(root, 'vendor/native-face') + path.sep) || ['native-face-cpu.js', 'native-face-webgl.js', 'native-face-webgl-probe.js', 'face-luma.js'].some(name => target === path.join(root, 'utils', name)));
|
|
return load(target);
|
|
}, module);
|
|
return module.exports;
|
|
}
|
|
return { api: load(path.join(root, 'utils/native-face.js')), load, context };
|
|
}
|
|
function blank(format = 'RGBA') { return { width: 128, height: 128, pixelFormat: format, data: new Uint8Array(128 * 128 * 4) }; }
|
|
function fixture() {
|
|
const value = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures/native-face-c3.json'), 'utf8'));
|
|
return { width: value.width, height: value.height, pixelFormat: value.pixelFormat, data: new Uint8Array(zlib.gunzipSync(Buffer.from(value.gzipBase64, 'base64'))) };
|
|
}
|
|
|
|
test('real quantized models load and detect a local reference face with 68 landmarks, without DOM or Node runtime globals', async t => {
|
|
const r = runtime(), engine = r.api.create({ inputSize: 256, maxSide: 320 });
|
|
const started = performance.now(); await engine.init(); const initMs = performance.now() - started;
|
|
const baseline = engine.stats();
|
|
const result = await engine.detect(fixture());
|
|
assert.equal(result.backend, 'cpu'); assert(result.detections.length > 0, 'reference photo must exercise actual detection and landmark networks');
|
|
assert.equal(result.width, 320); assert.equal(result.height, 240);
|
|
assert(Number.isFinite(result.luma) && result.luma >= 0 && result.luma <= 255, 'real inference measures its detected face brightness');
|
|
const detection = result.detections[0];
|
|
assert(detection.detection.score > 0.35); assert(detection.detection.box.width > 20);
|
|
assert.equal(detection.landmarks.positions.length, 68);
|
|
assert(detection.landmarks.positions.every(p => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x <= 320 && p.y <= 240));
|
|
assert.equal(engine.stats().numTensors, baseline.numTensors);
|
|
t.diagnostic(JSON.stringify({ initMs: Math.round(initMs), inferenceMs: result.elapsedMs, modelsBytes: baseline.numBytes, modelsTensors: baseline.numTensors }));
|
|
engine.dispose(); assert.equal(engine.stats().numTensors, 0); assert.equal(engine.stats().numBytes, 0);
|
|
});
|
|
|
|
test('downsampled boxes and all 68 landmarks return to original raw frame pixels without mirroring', async () => {
|
|
const r = runtime(), engine = r.api.create(), small = fixture();
|
|
const large = { width: 640, height: 480, pixelFormat: 'RGBA', data: new Uint8Array(640 * 480 * 4) };
|
|
for (let y = 0; y < 480; y++) for (let x = 0; x < 640; x++) {
|
|
const source = (Math.floor(y / 2) * 320 + Math.floor(x / 2)) * 4;
|
|
large.data.set(small.data.subarray(source, source + 4), (y * 640 + x) * 4);
|
|
}
|
|
const a = await engine.detect(small), b = await engine.detect(large);
|
|
assert.equal(b.width, 640); assert.equal(b.height, 480); assert.equal(a.detections.length, b.detections.length);
|
|
for (let index = 0; index < a.detections.length; index++) {
|
|
for (const key of ['x', 'y', 'width', 'height']) assert(Math.abs(a.detections[index].detection.box[key] * 2 - b.detections[index].detection.box[key]) < 0.001);
|
|
for (let point = 0; point < 68; point++) for (const key of ['x', 'y']) assert(Math.abs(a.detections[index].landmarks.positions[point][key] * 2 - b.detections[index].landmarks.positions[point][key]) < 0.001);
|
|
}
|
|
engine.dispose(); assert.equal(engine.stats().numTensors, 0);
|
|
});
|
|
|
|
test('supported byte layouts produce the same tensor results, and explicit unsupported formats never use a fallback', async () => {
|
|
const r = runtime(), engine = r.api.create({ inputSize: 128 }); await engine.init();
|
|
const original = fixture(), reduced = { width: 128, height: 96, pixelFormat: 'RGBA', data: new Uint8Array(128 * 96 * 4) };
|
|
for (let y = 0; y < 96; y++) for (let x = 0; x < 128; x++) {
|
|
const at = (Math.floor(y * 240 / 96) * 320 + Math.floor(x * 320 / 128)) * 4;
|
|
reduced.data.set(original.data.subarray(at, at + 4), (y * 128 + x) * 4);
|
|
}
|
|
const expected = await engine.detect(reduced), baseline = engine.stats().numTensors;
|
|
for (const [layout, order] of [['BGRA', [2, 1, 0, 3]], ['ARGB', [3, 0, 1, 2]], ['ABGR', [3, 2, 1, 0]]]) {
|
|
const data = new Uint8Array(reduced.data.length);
|
|
for (let offset = 0; offset < data.length; offset += 4) for (let channel = 0; channel < 4; channel++) data[offset + channel] = reduced.data[offset + order[channel]];
|
|
const actual = await engine.detect({ ...reduced, pixelFormat: layout, data });
|
|
assert.equal(JSON.stringify(actual.detections) === JSON.stringify(expected.detections), true, 'channel layouts must yield identical normalized results');
|
|
assert.equal(actual.luma, expected.luma, 'pixel-layout conversion also preserves measured face brightness');
|
|
}
|
|
await assert.rejects(engine.detect(blank('NV21')), error => error.code === 'NATIVE_FACE_FORMAT');
|
|
await assert.rejects(engine.detect({ ...blank(), stride: 516 }), error => error.code === 'NATIVE_FACE_STRIDE');
|
|
await assert.rejects(engine.detect({ ...blank(), rotation: 90 }), error => error.code === 'NATIVE_FACE_ROTATION');
|
|
const missing = blank(); delete missing.pixelFormat;
|
|
const noFace = await engine.detect(missing);
|
|
assert.equal(noFace.faceCount, 0); assert.equal(noFace.luma, null);
|
|
assert.equal(engine.stats().numTensors, baseline); engine.dispose(); assert.equal(engine.stats().numTensors, 0);
|
|
});
|
|
|
|
test('inference is serialized and disposal during init or detection safely rejects without releasing live tensors early', async () => {
|
|
const r = runtime(), first = r.api.create(), second = r.api.create();
|
|
const a = first.init(), b = first.init(); first.dispose();
|
|
await Promise.all([assert.rejects(a, error => error.code === 'NATIVE_FACE_DISPOSED'), assert.rejects(b, error => error.code === 'NATIVE_FACE_DISPOSED')]);
|
|
await second.init(); assert(second.stats().numTensors > 0);
|
|
const work = second.detect(blank());
|
|
await assert.rejects(second.detect(blank()), error => error.code === 'NATIVE_FACE_BUSY');
|
|
second.dispose(); await assert.rejects(work, error => error.code === 'NATIVE_FACE_DISPOSED');
|
|
assert.equal(second.stats().numTensors, 0);
|
|
await assert.rejects(second.init(), error => error.code === 'NATIVE_FACE_DISPOSED');
|
|
const next = r.api.create({ inputSize: 128 }); await next.init();
|
|
for (let index = 0; index < 3; index++) assert.equal((await next.detect(blank())).faceCount, 0);
|
|
next.dispose(); assert.equal(next.stats().numTensors, 0);
|
|
});
|
|
|
|
test('detect snapshots downsampled RGB before yielding, so a recycled native byte buffer cannot change inference', async () => {
|
|
const r = runtime(), engine = r.api.create({ inputSize: 128 }); await engine.init();
|
|
const expected = await engine.detect(fixture()), frame = fixture();
|
|
const work = engine.detect(frame);
|
|
frame.data.fill(0);
|
|
const actual = await work;
|
|
assert(actual.detections.length > 0);
|
|
assert.equal(JSON.stringify(actual.detections) === JSON.stringify(expected.detections), true, 'a recycled source buffer must not change the original inference');
|
|
engine.dispose(); assert.equal(engine.stats().numTensors, 0);
|
|
});
|
|
|
|
test('generated vendor has no static Node imports and retains source provenance and upstream notices', () => {
|
|
const bundle = fs.readFileSync(path.join(root, 'vendor/native-face/face-api.js'), 'utf8');
|
|
assert.doesNotMatch(bundle, /\brequire\s*\(/);
|
|
assert.match(bundle, /Copyright 2020 Google LLC/);
|
|
const provenance = JSON.parse(fs.readFileSync(path.join(root, 'vendor/native-face/provenance.json'), 'utf8'));
|
|
assert.equal(provenance.quantizedBytes, 627385);
|
|
for (const name of ['crypto', 'node-fetch', 'util', 'fs', '@tensorflow/tfjs-node']) assert.equal(provenance.nodeOnlyStubs[name], 1);
|
|
assert.equal(provenance.sourceFiles.length, 7);
|
|
assert.match(fs.readFileSync(path.join(root, 'vendor/native-face/LICENSE.face-api.txt'), 'utf8'), /Copyright \(c\) 2018 Vincent Mühler/);
|
|
assert.match(fs.readFileSync(path.join(root, 'vendor/native-face/LICENSE.tensorflow.txt'), 'utf8'), /Apache License/);
|
|
});
|
|
|
|
test('observed 540x960 portrait RGBA shape without format survives buffer reuse and returns real landmarks in original dimensions', async t => {
|
|
const r = runtime(), engine = r.api.create({ defaultPixelFormat: 'RGBA', maxSide: 320, inputSize: 256 });
|
|
await engine.init();
|
|
const baseline = engine.stats().numTensors, reference = fixture();
|
|
// Match the observed iPhone frame shape and byte length. Fit the 4:3 local
|
|
// reference at 540x405, centered on an opaque 540x960 background: no face
|
|
// aspect-ratio distortion and no network/device/image decoder dependency.
|
|
const frame = { width: 540, height: 960, data: new Uint8Array(540 * 960 * 4) };
|
|
for (let offset = 3; offset < frame.data.length; offset += 4) frame.data[offset] = 255;
|
|
const fittedWidth = 540, fittedHeight = 405;
|
|
const left = Math.floor((frame.width - fittedWidth) / 2), top = Math.floor((frame.height - fittedHeight) / 2);
|
|
assert.equal(fittedWidth / fittedHeight, reference.width / reference.height);
|
|
for (let y = 0; y < fittedHeight; y++) for (let x = 0; x < fittedWidth; x++) {
|
|
const source = (Math.floor(y * reference.height / fittedHeight) * reference.width + Math.floor(x * reference.width / fittedWidth)) * 4;
|
|
frame.data.set(reference.data.subarray(source, source + 4), ((y + top) * frame.width + x + left) * 4);
|
|
}
|
|
assert.equal(frame.data.byteLength, 2073600); assert.equal(frame.pixelFormat, undefined); assert.equal(frame.format, undefined);
|
|
const pending = engine.detect(frame);
|
|
frame.data.fill(0); // Reproduce a native camera recycling its storage immediately.
|
|
const result = await pending;
|
|
assert.equal(result.width, 540); assert.equal(result.height, 960);
|
|
assert(result.faceCount > 0, 'real model must detect the centered reference after the source buffer was recycled');
|
|
assert(result.detections.length > 0);
|
|
for (const detected of result.detections) {
|
|
const box = detected.detection.box;
|
|
assert(detected.detection.score > 0.35);
|
|
assert(Number.isFinite(box.x) && Number.isFinite(box.y) && box.x >= 0 && box.y >= 0 && box.width > 0 && box.height > 0);
|
|
assert(box.x + box.width <= 540 && box.y + box.height <= 960);
|
|
assert.equal(detected.landmarks.positions.length, 68);
|
|
assert(detected.landmarks.positions.every(point => Number.isFinite(point.x) && Number.isFinite(point.y) && point.x >= 0 && point.y >= 0 && point.x <= 540 && point.y <= 960));
|
|
}
|
|
assert.equal(engine.stats().numTensors, baseline);
|
|
t.diagnostic(JSON.stringify({ portraitFrameBytes: 2073600, inferenceMs: result.elapsedMs, faceCount: result.faceCount, landmarkCount: result.detections[0].landmarks.positions.length }));
|
|
engine.dispose(); assert.equal(engine.stats().numTensors, 0);
|
|
});
|
|
|
|
test('pad-copy specialization is bit-exact to the original CPU pad across ranks, dtypes, offsets and constants', async () => {
|
|
const r = runtime(), face = r.load(path.join(root, 'vendor/native-face/face-api.js'));
|
|
await face.tf.setBackend('cpu');
|
|
const controller = r.load(path.join(root, 'utils/native-face-cpu.js'))(face);
|
|
const cases = [
|
|
{ shape: [3], pads: [[2, 1]] }, { shape: [2, 3], pads: [[1, 2], [2, 1]] },
|
|
{ shape: [2, 3, 4], pads: [[1, 0], [2, 3], [0, 0]] },
|
|
{ shape: [2, 3, 4, 3], pads: [[0, 0], [1, 2], [2, 1], [0, 0]] },
|
|
{ shape: [2, 3, 4, 3], pads: [[1, 0], [0, 0], [0, 0], [1, 2]] },
|
|
{ shape: [2, 3, 4, 3], pads: [[0, 0], [0, 0], [0, 0], [0, 0]] }
|
|
];
|
|
for (const dtype of ['float32', 'int32', 'bool']) for (const constant of [0, 3]) for (const item of cases) {
|
|
const size = item.shape.reduce((a, b) => a * b, 1);
|
|
const input = face.tf.tensor(Array.from({ length: size }, (_, i) => dtype === 'float32' ? (i % 11 - 5) / 7 : i % 2), item.shape, dtype);
|
|
controller.setEnabled(false); const reference = face.tf.pad(input, item.pads, constant);
|
|
controller.setEnabled(true); const optimized = face.tf.pad(input, item.pads, constant);
|
|
const a = reference.dataSync(), b = optimized.dataSync();
|
|
assert.equal(a.length, b.length); assert(Array.from(a).every((value, index) => Object.is(value, b[index])), 'pad values must be bit-exact');
|
|
input.dispose(); reference.dispose(); optimized.dispose();
|
|
}
|
|
assert.equal(face.tf.memory().numTensors, 0);
|
|
});
|
|
|
|
test('tiny retains 68-point geometry, exact pad optimization preserves results, and real multi-face frames stay rejected', async () => {
|
|
const r = runtime(), full = r.api.create({ inputSize: 160, landmarkModel: 'full', cpuOptimization: false });
|
|
const baseline = await full.detect(fixture()); full.dispose();
|
|
const fastFull = r.api.create({ inputSize: 160, landmarkModel: 'full' });
|
|
const fullResult = await fastFull.detect(fixture()); fastFull.dispose();
|
|
assert.equal(JSON.stringify(baseline.detections) === JSON.stringify(fullResult.detections), true, 'pad optimization must preserve complete real-network results exactly');
|
|
const tiny = r.api.create({ inputSize: 160, landmarkModel: 'tiny' }), result = await tiny.detect(fixture());
|
|
assert.equal(result.cpuOptimization, 'pad-copy'); assert.equal(result.landmarkModel, 'tiny');
|
|
assert.equal(result.detections[0].landmarks.positions.length, 68);
|
|
const old = baseline.detections[0], current = result.detections[0];
|
|
const distances = current.landmarks.positions.map((point, index) => Math.hypot(point.x - old.landmarks.positions[index].x, point.y - old.landmarks.positions[index].y));
|
|
assert(distances.reduce((sum, value) => sum + value, 0) / 68 / old.detection.box.width < 0.025, 'tiny model must preserve landmark index geometry on the reference face');
|
|
const source = fixture(), duo = { width: 320, height: 240, data: new Uint8Array(320 * 240 * 4) };
|
|
// Crop a reference face with margin, then fit two copies with the same scale
|
|
// on both axes. No stretched faces or mocked detector results.
|
|
const box = baseline.detections[0].detection.box;
|
|
const roiX = Math.max(0, Math.floor(box.x - box.width * 0.1)), roiY = Math.max(0, Math.floor(box.y - box.height * 0.1));
|
|
const roiW = Math.min(320 - roiX, Math.ceil(box.width * 1.2)), roiH = Math.min(240 - roiY, Math.ceil(box.height * 1.2));
|
|
const scale = Math.min(140 / roiW, 220 / roiH), fittedWidth = Math.round(roiW * scale), fittedHeight = Math.round(roiH * scale);
|
|
for (let side = 0; side < 2; side++) for (let y = 0; y < fittedHeight; y++) for (let x = 0; x < fittedWidth; x++) {
|
|
const at = ((roiY + Math.floor(y / scale)) * 320 + roiX + Math.floor(x / scale)) * 4;
|
|
const to = ((y + Math.floor((240 - fittedHeight) / 2)) * 320 + x + side * 160 + Math.floor((160 - fittedWidth) / 2)) * 4;
|
|
duo.data.set(source.data.subarray(at, at + 4), to);
|
|
}
|
|
const multiple = await tiny.detect(duo);
|
|
assert(multiple.faceCount >= 2); assert.equal(multiple.detections.length, 2);
|
|
assert(multiple.detections.every(item => item.landmarks.positions.length === 68));
|
|
const verdict = require('../utils/native-face-quality').evaluate(multiple, 'front', { viewWidth: 320, viewHeight: 240 });
|
|
assert.equal(verdict.ok, false); assert.match(verdict.hint, /单人/);
|
|
tiny.dispose(); assert.equal(tiny.stats().numTensors, 0);
|
|
});
|
|
|
|
test('a failed optional full-model load does not poison the ready tiny engine or retain decoded tensors', async () => {
|
|
const r = runtime(), tiny = r.api.create({ inputSize: 160, landmarkModel: 'tiny' });
|
|
await tiny.init(); const baseline = tiny.stats().numTensors;
|
|
const face = r.load(path.join(root, 'vendor/native-face/face-api.js'));
|
|
const original = face.nets.faceLandmark68Net.loadFromWeightMap;
|
|
face.nets.faceLandmark68Net.loadFromWeightMap = function () { throw new Error('synthetic model load failure'); };
|
|
const failing = r.api.create({ landmarkModel: 'full' });
|
|
await assert.rejects(failing.init(), /synthetic model load failure/); failing.dispose();
|
|
assert.equal(tiny.stats().numTensors, baseline);
|
|
const result = await tiny.detect(fixture());
|
|
assert(result.faceCount > 0); assert.equal(result.detections[0].landmarks.positions.length, 68);
|
|
assert.equal(tiny.stats().numTensors, baseline);
|
|
face.nets.faceLandmark68Net.loadFromWeightMap = original;
|
|
const retry = r.api.create({ landmarkModel: 'full' }); await retry.init();
|
|
assert(retry.stats().numTensors > baseline);
|
|
tiny.dispose(); assert(retry.stats().numTensors > 0);
|
|
retry.dispose(); assert.equal(retry.stats().numTensors, 0);
|
|
});
|
|
|
|
test('WebGL admission is exclusive and a missing native Canvas API fails explicitly without a CPU fallback', async () => {
|
|
const r = runtime(), cpu = r.api.create(); await cpu.init();
|
|
const conflict = r.api.create({ backend: 'webgl', platform: {} });
|
|
await assert.rejects(conflict.init(), error => error.code === 'NATIVE_FACE_BACKEND_CONFLICT');
|
|
await assert.rejects(conflict.detect(blank()), error => error.code === 'NATIVE_FACE_BACKEND_CONFLICT');
|
|
conflict.dispose(); assert.equal((await cpu.detect(blank())).backend, 'cpu'); cpu.dispose();
|
|
const missing = r.api.create({ backend: 'webgl', platform: {} });
|
|
await assert.rejects(missing.init(), error => error.code === 'NATIVE_FACE_WEBGL_API_MISSING' && error.gpu.selftest === 'failed');
|
|
assert.equal(missing.stats().backend, ''); assert.equal(missing.stats().gpu.requested, true);
|
|
assert.equal(missing.stats().gpu.code, 'API_MISSING'); assert.equal(missing.stats().numTensors, 0);
|
|
missing.dispose(); const next = r.api.create(); await next.init(); next.dispose(); assert.equal(next.stats().numTensors, 0);
|
|
});
|
|
|
|
test('native cleanup exceptions cannot preserve a busy lock, prevent backend retirement, or replace the original failure', async () => {
|
|
const r = runtime(), engine = r.api.create(); await engine.init();
|
|
const face = r.load(path.join(root, 'vendor/native-face/face-api.js')), tensorDispose = face.tf.Tensor.prototype.dispose;
|
|
const netDispose = face.nets.faceLandmark68TinyNet.dispose;
|
|
face.tf.Tensor.prototype.dispose = function () { tensorDispose.call(this); throw new Error('synthetic native cleanup error'); };
|
|
const rejected = engine.detect(blank()); engine.dispose();
|
|
await assert.rejects(rejected, error => error.code === 'NATIVE_FACE_DISPOSED');
|
|
assert.equal(engine.stats().busy, false); assert.equal(engine.stats().numTensors, 0);
|
|
face.tf.Tensor.prototype.dispose = tensorDispose;
|
|
// Each model's native cleanup must be independent of the other two models.
|
|
const next = r.api.create(); await next.init();
|
|
face.nets.faceLandmark68TinyNet.dispose = function (value) { netDispose.call(this, value); throw new Error('synthetic model dispose error'); };
|
|
next.dispose(); assert.equal(next.stats().numTensors, 0);
|
|
face.nets.faceLandmark68TinyNet.dispose = netDispose;
|
|
const recovered = r.api.create({ inputSize: 128 }); await recovered.init();
|
|
assert.equal((await recovered.detect(blank())).faceCount, 0);
|
|
recovered.dispose(); assert.equal(recovered.stats().numTensors, 0);
|
|
});
|