fix(h5): relax capture angles and annotate acne marks
This commit is contained in:
@@ -89,7 +89,7 @@
|
||||
/* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */
|
||||
/* User-confirmed: both lines centered; use the same font size/line height
|
||||
as the stability heading rather than scaling text on wide previews. */
|
||||
.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(16.9 * var(--u)) auto calc(11.158333 * var(--u)); padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; }
|
||||
.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(18 * var(--u)) auto; padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; }
|
||||
.report-care-products { width:calc(340.625 * var(--u)); margin-left:auto; margin-right:auto; padding:calc(21.180556 * var(--u)) calc(24.652778 * var(--u)) calc(7.986111 * var(--u)); }
|
||||
.report-care-products .card-heading,
|
||||
.product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; }
|
||||
|
||||
+2
-1
@@ -1326,7 +1326,8 @@
|
||||
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
|
||||
{ key: 'acne', color: '#D30C0C' }, // 痤疮
|
||||
{ key: 'acne_pustule', color: '#D30C0C' }, // 脓包
|
||||
{ key: 'acne_nodule', color: '#8E0000' } // 结节
|
||||
{ key: 'acne_nodule', color: '#8E0000' }, // 结节
|
||||
{ key: 'acne_mark', color: '#8750C8' } // 痘印:紫色,与红色炎性痘痘区分
|
||||
] }
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
// Real-phone feedback: ease alignment and normal hand/head movement while
|
||||
// retaining two checked frames, one face, framing and final JPEG validation.
|
||||
var config = Object.freeze({ version: '2026-10-12-fourth-v2', score: 0.4,
|
||||
var config = Object.freeze({ version: '2026-10-15-angle-relaxed-20-v3', score: 0.4,
|
||||
near: 0.30, far: 0.68, center: 0.16, edge: 0.01,
|
||||
dark: 65, bright: 235, holdMs: 450, minFrames: 2, maxGapMs: 2000,
|
||||
yawDrift: 8, pitchDrift: 8, centerDrift: 0.055, sizeDrift: 0.065 });
|
||||
|
||||
+18
-17
@@ -64,12 +64,13 @@
|
||||
// ---- check 阈值(角度制) ----
|
||||
// Real-phone feedback: relaxed alignment, without accepting frontal photos
|
||||
// as side views. These empirical angles are not upstream quality scores.
|
||||
var FRONT_YAW_MAX = 15;
|
||||
var FRONT_PITCH_MAX = 18;
|
||||
var SIDE_YAW_MIN = 30;
|
||||
var SIDE_YAW_MAX = 70;
|
||||
var SIDE_PITCH_MAX = 20;
|
||||
var ROLL_MAX = 15;
|
||||
// 2026-10-15: expand accepted angles by 20%; lower side minimum by 20%.
|
||||
var FRONT_YAW_MAX = 18;
|
||||
var FRONT_PITCH_MAX = 21.6;
|
||||
var SIDE_YAW_MIN = 24;
|
||||
var SIDE_YAW_MAX = 84;
|
||||
var SIDE_PITCH_MAX = 24;
|
||||
var ROLL_MAX = 18;
|
||||
|
||||
var HINT_KEEP = '保持不动';
|
||||
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
|
||||
@@ -158,10 +159,10 @@
|
||||
/**
|
||||
* check(points, target, mirrored) -> { ok, hint }
|
||||
* target: 'front' | 'left' | 'right'
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;角度不合格只提示回正,
|
||||
* 'front' : |yaw|<=18 且 |pitch|<=21.6、|roll|<=18;角度不合格只提示回正,
|
||||
* 不在正脸拍摄完成前播侧脸转头指令。
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-70°,-30°]),|pitch|<=20
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [30°,70°]),|pitch|<=20
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-84°,-24°]),|pitch|<=24
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [24°,84°]),|pitch|<=24
|
||||
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
|
||||
*/
|
||||
function check(points, target, mirrored) {
|
||||
@@ -295,13 +296,13 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(bc.ok === true, '反向侧脸 target=left ok');
|
||||
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
|
||||
|
||||
// 7) 侧脸做 front check → 拒绝并引导转回(被摄者左转 → 请向右转头;反向 → 请向左转头)
|
||||
// 7) 正面步骤只提示回正,不能提前引导拍下一张侧脸。
|
||||
var sc = HeadPose.check(makeSide(), 'front', false);
|
||||
ok(sc.ok === false && sc.hint === '请向右转头', '左转侧脸 front 拒绝,hint=请向右转头');
|
||||
ok(sc.ok === false && sc.hint === '请正对屏幕', '左转侧脸 front 拒绝,hint=请正对屏幕');
|
||||
var scb = makeSide();
|
||||
scb[30] = { x: 182.3, y: 140 };
|
||||
scb[33] = { x: 182.3, y: 161.5 };
|
||||
ok(HeadPose.check(scb, 'front', false).hint === '请向左转头', '右转侧脸 front 拒绝,hint=请向左转头');
|
||||
ok(HeadPose.check(scb, 'front', false).hint === '请正对屏幕', '右转侧脸 front 拒绝,hint=请正对屏幕');
|
||||
|
||||
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
|
||||
var tilted = makeFrontal();
|
||||
@@ -314,10 +315,10 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
var de = HeadPose.estimate(down, false);
|
||||
var dc = HeadPose.check(down, 'front', false);
|
||||
ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')');
|
||||
ok(dc.ok === false && dc.hint === '请略微抬头', '低头 front 拒绝,hint=请略微抬头');
|
||||
ok(dc.ok === false && dc.hint === '请正对屏幕', '低头 front 拒绝,hint=请正对屏幕');
|
||||
var up = makeFrontal();
|
||||
up[33] = { x: 200, y: 200 };
|
||||
ok(HeadPose.check(up, 'front', false).hint === '请略微低头', '抬头过多 front 拒绝,hint=请略微低头');
|
||||
ok(HeadPose.check(up, 'front', false).hint === '请正对屏幕', '抬头过多 front 拒绝,hint=请正对屏幕');
|
||||
|
||||
// 10) 非法输入:check 不抛异常,返回兜底引导
|
||||
var bad = HeadPose.check(null, 'front', false);
|
||||
@@ -336,8 +337,8 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
|
||||
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
|
||||
|
||||
// 13) Phone feedback: restore the 15° frontal margin, still reject 16°+.
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
|
||||
// 13) Phone feedback: expand the frontal margin to 18 degrees.
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 18, 'config.FRONT_YAW_MAX = 18');
|
||||
var slight = makeFrontal();
|
||||
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
|
||||
slight[33] = { x: 205.6, y: 161.5 };
|
||||
@@ -351,7 +352,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
var se2 = HeadPose.estimate(slight2, false);
|
||||
ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')');
|
||||
var sc3 = HeadPose.check(slight2, 'front', false);
|
||||
ok(sc3.ok === false && sc3.hint === '请向右转头', 'yaw≈16.2° front 拒绝,hint=请向右转头');
|
||||
ok(sc3.ok === true, 'yaw≈16.2° front 通过(放宽20%)');
|
||||
|
||||
if (failures > 0) {
|
||||
console.error('\n' + failures + ' 项自测失败');
|
||||
|
||||
@@ -147,11 +147,11 @@ try {
|
||||
const row = body.capture_diagnostics.views[key];
|
||||
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
|
||||
assert(row.score >= .4 && row.luma >= 65 && row.luma <= 235);
|
||||
assert(Math.abs(row.roll) <= 15);
|
||||
assert(Math.abs(row.roll) <= 18);
|
||||
}
|
||||
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 15);
|
||||
assert(body.capture_diagnostics.views.left.yaw <= -30);
|
||||
assert(body.capture_diagnostics.views.right.yaw >= 30);
|
||||
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 18);
|
||||
assert(body.capture_diagnostics.views.left.yaw <= -24 && body.capture_diagnostics.views.left.yaw >= -84);
|
||||
assert(body.capture_diagnostics.views.right.yaw >= 24 && body.capture_diagnostics.views.right.yaw <= 84);
|
||||
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; invalid front stays on step 1 with neutral audio, no early turn; actual decoded shot-done finishes before side guidance; wrong-direction frames blocked; approved samples/camera cleanup; no paid call/backend writes');
|
||||
|
||||
@@ -28,27 +28,33 @@ const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0
|
||||
const check = (d, changes) => Quality.evaluate(d, { ...options, ...changes });
|
||||
assert(check(face()).ok);
|
||||
assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
|
||||
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
|
||||
for (const yaw of [-16, 16, -45, 45]) {
|
||||
for (const yaw of [-17.99, 17.99]) assert(check(face(yaw)).ok, '20% relaxed front limit now passes');
|
||||
for (const yaw of [-18.01, 18.01, -45, 45]) {
|
||||
assert(!check(face(yaw)).ok);
|
||||
assert.equal(check(face(yaw)).reason, 'angle');
|
||||
assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture');
|
||||
assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕');
|
||||
}
|
||||
for (const pitch of [-17, 17]) assert(check(face(0, pitch)).ok);
|
||||
for (const pitch of [-20, 20]) assert.equal(check(face(0, pitch)).hint, '请正对屏幕');
|
||||
for (const pitch of [-21.59, 21.59]) assert(check(face(0, pitch)).ok);
|
||||
for (const pitch of [-21.61, 21.61]) assert.equal(check(face(0, pitch)).hint, '请正对屏幕');
|
||||
assert.equal(check(face(0), { target: 'right' }).hint, '请向左转头', 'side step retains its proper turn instruction');
|
||||
assert.equal(check(face(0), { target: 'left' }).hint, '请向右转头');
|
||||
assert(check(face(0, 0, 12)).ok, 'normal tilt now passes');
|
||||
assert.equal(check(face(0, 0, 16)).hint, '请勿歪头');
|
||||
for (const yaw of [25, 72]) assert(!check(face(yaw), { target: 'right' }).ok);
|
||||
for (const yaw of [32, 68]) assert(check(face(yaw), { target: 'right' }).ok);
|
||||
assert(check(face(0,0,17.99)).ok);
|
||||
assert.equal(check(face(0, 0, 18.01)).hint, '请勿歪头');
|
||||
for (const target of ['left','right']) {
|
||||
const sign=target==='left'?-1:1;
|
||||
for (const yaw of [23.99,84.01]) assert(!check(face(sign*yaw),{target}).ok);
|
||||
for (const yaw of [24.01,83.99]) assert(check(face(sign*yaw),{target}).ok);
|
||||
for (const pitch of [-23.99,23.99]) assert(check(face(sign*45,pitch),{target}).ok);
|
||||
for (const pitch of [-24.01,24.01]) assert(!check(face(sign*45,pitch),{target}).ok);
|
||||
}
|
||||
assert(check(face(45), { target: 'right' }).ok);
|
||||
assert(!check(face(45), { target: 'left' }).ok);
|
||||
assert(check(face(-45), { target: 'left' }).ok);
|
||||
assert(check(face(-45), { target: 'right', mirrored: true }).ok);
|
||||
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
|
||||
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
|
||||
assert(!check(face(23), { target: 'left', ignoreDirection: true }).ok);
|
||||
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
|
||||
assert.equal(check(face(), { luma: 50 }).reason, 'quality', 'lighting must not trigger angle vibration');
|
||||
assert.equal(check(null).reason, 'quality');
|
||||
@@ -108,7 +114,7 @@ assert.equal(Diagnostics.assess(raw).views.right.status, 'valid');
|
||||
const normalized = Diagnostics.normalize({ version: Quality.config.version, secret: 'discard', views: { front: { mode: 'live', yaw: 1, score: .9, luma: Infinity, secret: 'discard' }, left: { mode: 'unchecked', reason: 'model-unavailable' } } });
|
||||
assert.equal(normalized.policy.capture.holdMs, 450);
|
||||
assert.equal(normalized.policy.capture.minFrames, 2);
|
||||
assert.equal(normalized.version, '2026-10-12-fourth-v2');
|
||||
assert.equal(normalized.version, '2026-10-15-angle-relaxed-20-v3');
|
||||
assert.equal(normalized.views.front.luma, undefined);
|
||||
assert(!JSON.stringify(normalized).includes('discard'));
|
||||
assert.equal(Diagnostics.normalize({ version: '<script>' }).version, 'unknown');
|
||||
|
||||
@@ -68,7 +68,7 @@ try {
|
||||
assert.equal(admin.capture_quality.status, 'invalid');
|
||||
assert.equal(admin.capture_quality.views.right.quality, 3);
|
||||
assert.equal(admin.capture_diagnostics.version, CaptureQuality.config.version);
|
||||
assert.equal(admin.capture_diagnostics.policy.pose.SIDE_YAW_MIN, 30);
|
||||
assert.equal(admin.capture_diagnostics.policy.pose.SIDE_YAW_MIN, 24);
|
||||
assert.equal(admin.capture_diagnostics.policy.capture.minFrames, 2);
|
||||
assert.equal(admin.capture_diagnostics.ignore, undefined);
|
||||
assert(admin.notes.some(n => n.includes('quality=3')));
|
||||
|
||||
@@ -63,6 +63,15 @@ assert(smooth.text.includes('未完整获取'));
|
||||
const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
|
||||
const pure = source.match(/\/\* viz-pure-begin[\s\S]*?\*\/([\s\S]*?)\/\* viz-pure-end \*\//)[1];
|
||||
const sandbox = {}; vm.runInNewContext(pure, sandbox);
|
||||
vm.runInNewContext(source.slice(source.indexOf(' var VIZ_CAT ='),source.indexOf(' /* viz-pure-begin')),sandbox);
|
||||
const acneDefs=sandbox.VIZ_CAT.acne.marks;
|
||||
assert.equal(acneDefs.find(x=>x.key==='acne_mark').color,'#8750C8');
|
||||
const acneRaw={acne_mark:{count:1,rectangle:[{left:10,top:20,width:30,height:40}],polygon:[[{x:10,y:20},{x:40,y:20},{x:20,y:60}]]}};
|
||||
const acneBefore=JSON.stringify(acneRaw),acneMarks=sandbox.vizCollectMarks(acneDefs,acneRaw);
|
||||
assert.equal(acneMarks.length,1);assert.equal(acneMarks[0].rects.length,1);assert.equal(acneMarks[0].polygons.length,1);
|
||||
assert.equal(JSON.stringify(acneRaw),acneBefore,'overlay does not change detection or scoring data');
|
||||
assert.equal(sandbox.vizCollectMarks(acneDefs,{acne_mark:{count:3}}).length,0,'counts alone never generate fake positions');
|
||||
assert.equal(sandbox.vizCollectMarks(acneDefs,{acne_mark:{count:0,rectangle:[],polygon:[]}}).length,0);
|
||||
const crop = sandbox.vizFaceCrop(4000, 3000, { left: 1479, top: 1133, width: 1116, height: 1116 });
|
||||
assert(crop.focused);
|
||||
assert(crop.width < 1600 && crop.height < 1800);
|
||||
|
||||
@@ -48,8 +48,8 @@ try{
|
||||
const r=e.getBoundingClientRect();return{above:r.top-e.previousElementSibling.getBoundingClientRect().bottom,below:e.nextElementSibling.getBoundingClientRect().top-r.bottom};
|
||||
})};
|
||||
});
|
||||
assert(Math.abs(spacing.top-spacing.u*13*1.3)<.1,'bottom heading top whitespace increased 30%');
|
||||
assert(Math.abs(spacing.bottom-spacing.u*8.583333*1.3)<.1,'bottom heading bottom whitespace increased 30%');
|
||||
assert(Math.abs(spacing.top-spacing.u*18)<.1,'bottom heading top whitespace matches dimension headings');
|
||||
assert(Math.abs(spacing.bottom-spacing.u*18)<.1,'bottom heading bottom whitespace matches dimension headings');
|
||||
for(const d of spacing.dimensions){assert(Math.abs(d.above-d.below)<.1,'dimension heading whitespace symmetric');assert(Math.abs(d.above-spacing.u*18)<.1,'all dimension headings use same gap');}
|
||||
const divider=await p.locator('.product-section-heading').evaluate(e=>{const s=getComputedStyle(e,'::before');return{width:s.borderTopWidth,color:s.borderTopColor,left:s.left,right:s.right};});
|
||||
assert.equal(divider.width,'1px');assert.equal(divider.color,'rgb(221, 221, 221)');assert.equal(divider.left,'0px');assert.equal(divider.right,'0px');
|
||||
|
||||
Reference in New Issue
Block a user