feat(report): add annotated wrinkle views and restore capture countdown
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# 皱纹三视图标注
|
||||||
|
|
||||||
|
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击侧脸查看完整标注图。
|
||||||
|
- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。
|
||||||
|
- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。
|
||||||
|
- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。
|
||||||
|
- 补取失败不影响主报告、评分或产品推荐。该侧展示原图并明确说明缺少标注;后台记录每侧获取状态和错误代码。
|
||||||
|
- 微信用户保存报告时同时保存侧脸标注,历史回看不重复调用检测接口。旧报告不自动补取、不产生额外费用。游客仍不保存报告或照片。
|
||||||
|
- 补取结果仅用于图片展示,不参与七个小维度、三个大维度、综合评分或产品推荐计算。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 单元测试:请求照片与标注一一对应、顺序调用、复用已有标注、缺图、接口失败、超时、预算不足、不污染评分结果。
|
||||||
|
- 浏览器回归:320/375/414/480 宽度,正面浮层保留、左右独立浮层与大图、真实坐标兜底、缺图/缺标注提示、清理旧照片。
|
||||||
|
- 报告持久化回归:三次调用、响应与缓存标注一致、用户报告隔离及后台查看。
|
||||||
|
- 已使用获授权的真实案例分别补取左右侧脸,确认均返回独立皱纹增强图和标注坐标。
|
||||||
+11
-4
@@ -2,6 +2,10 @@
|
|||||||
Native 248px WeChat header is excluded. Fixed text sizes/line breaks are
|
Native 248px WeChat header is excluded. Fixed text sizes/line breaks are
|
||||||
intentional; flexible photo backgrounds and safe areas accommodate phones. */
|
intentional; flexible photo backgrounds and safe areas accommodate phones. */
|
||||||
.account-logout { font-size: 11px; opacity: .8; white-space: nowrap; }
|
.account-logout { font-size: 11px; opacity: .8; white-space: nowrap; }
|
||||||
|
.cam-countdown { position:absolute; left:50%; top:45%; transform:translate(-50%,-50%); z-index:4; pointer-events:none; color:#fff; font:600 88px/1 Arial,sans-serif; text-shadow:0 2px 16px rgba(0,0,0,.5); }
|
||||||
|
.cam-countdown-tick { display:block; animation:camera-countdown-tick .6s ease-out both; }
|
||||||
|
@keyframes camera-countdown-tick { from { opacity:.3; transform:scale(1.25); } to { opacity:1; transform:scale(1); } }
|
||||||
|
@media (prefers-reduced-motion:reduce) { .cam-countdown-tick { animation:none; } }
|
||||||
.splash-slogan {
|
.splash-slogan {
|
||||||
left: calc(50% - 1.041667px); right: auto;
|
left: calc(50% - 1.041667px); right: auto;
|
||||||
width: 249.305556px; height: 75.347222px;
|
width: 249.305556px; height: 75.347222px;
|
||||||
@@ -85,7 +89,7 @@
|
|||||||
/* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */
|
/* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */
|
||||||
/* User-confirmed: both lines centered; use the same font size/line height
|
/* User-confirmed: both lines centered; use the same font size/line height
|
||||||
as the stability heading rather than scaling text on wide previews. */
|
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(13 * var(--u)) auto calc(8.583333 * 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(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; }
|
||||||
.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 { 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,
|
.report-care-products .card-heading,
|
||||||
.product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; }
|
.product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; }
|
||||||
@@ -102,6 +106,8 @@
|
|||||||
.report-care-products .prod-name { left:8.677249%; top:41.095890%; font-size:12.013166px; line-height:15.070408px; font-weight:700; color:#989898; white-space:nowrap; }
|
.report-care-products .prod-name { left:8.677249%; top:41.095890%; font-size:12.013166px; line-height:15.070408px; font-weight:700; color:#989898; white-space:nowrap; }
|
||||||
.report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; }
|
.report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; }
|
||||||
.report-care-products + .report-disclaimer { margin:calc(12.152778 * var(--u)) calc(5.208333 * var(--u)) calc(20 * var(--u)); font-size:7.233681px; line-height:10.127431px; }
|
.report-care-products + .report-disclaimer { margin:calc(12.152778 * var(--u)) calc(5.208333 * var(--u)) calc(20 * var(--u)); font-size:7.233681px; line-height:10.127431px; }
|
||||||
|
/* Equal whitespace above/below all three headings; keep the original total gap. */
|
||||||
|
.dimension-title { margin:calc(18 * var(--u)) 0; }
|
||||||
.dimension-stability-title { font-size:22.106597px; line-height:28.738542px; }
|
.dimension-stability-title { font-size:22.106597px; line-height:28.738542px; }
|
||||||
.stability-card .dtab-tx { font-size:11.055903px; }
|
.stability-card .dtab-tx { font-size:11.055903px; }
|
||||||
.stability-card .ditem-para { font-size:11.574074px; line-height:14.467593px; }
|
.stability-card .ditem-para { font-size:11.574074px; line-height:14.467593px; }
|
||||||
@@ -205,9 +211,10 @@
|
|||||||
.contour-sides[hidden], .contour-sides figure[hidden] { display: none; }
|
.contour-sides[hidden], .contour-sides figure[hidden] { display: none; }
|
||||||
.contour-sides figure { margin: 0; min-width: 0; }
|
.contour-sides figure { margin: 0; min-width: 0; }
|
||||||
.contour-sides figure[data-view="right"] { grid-column: 2; }
|
.contour-sides figure[data-view="right"] { grid-column: 2; }
|
||||||
.contour-side-photo { display: block; position: relative; width: 100%; height: calc(92.25 * var(--u)); padding: 0; border: 0; border-radius: calc(5 * var(--u)); overflow: hidden; background: #f6f6f6; cursor: zoom-in; }
|
.contour-side-photo, .wrinkle-side-photo { display: block; position: relative; width: 100%; height: calc(92.25 * var(--u)); padding: 0; border: 0; border-radius: calc(5 * var(--u)); overflow: hidden; background: #f6f6f6; cursor: zoom-in; }
|
||||||
.contour-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(15%) scale(1.3); transform-origin: center; -webkit-user-drag: none; }
|
.contour-side-photo img, .wrinkle-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(-25%) scale(2.8); transform-origin: center; -webkit-user-drag: none; }
|
||||||
.contour-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; }
|
.contour-side-photo:focus-visible, .wrinkle-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; }
|
||||||
|
.wrinkle-layer-note { width:calc(215 * var(--u)); margin:0 auto calc(12 * var(--u)); color:#888; font-size:10px; line-height:1.4; }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.detail-panels, .detail-viewport { transition-duration: .01ms; }
|
.detail-panels, .detail-viewport { transition-duration: .01ms; }
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -144,6 +144,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
|
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
|
||||||
<p class="cam-hint" id="cam-hint"></p>
|
<p class="cam-hint" id="cam-hint"></p>
|
||||||
|
<div class="cam-countdown" id="cam-countdown" aria-hidden="true" hidden></div>
|
||||||
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
|
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
|
||||||
<div class="cam-next" id="cam-next" aria-live="polite">
|
<div class="cam-next" id="cam-next" aria-live="polite">
|
||||||
<i class="cam-next-ok"></i>
|
<i class="cam-next-ok"></i>
|
||||||
@@ -218,7 +219,12 @@
|
|||||||
<div class="ditem-text"><p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p></div></div>
|
<div class="ditem-text"><p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dpanel" data-panel="1">
|
<div class="dpanel" data-panel="1">
|
||||||
<div class="ditem"><div class="viz-frame"><img id="ditem-img2" class="ditem-img" alt="皱纹检测图" hidden><span class="viz-missing">本次未获取皱纹检测图</span></div>
|
<div class="ditem"><div class="viz-frame contour-front-frame"><img id="ditem-img2" class="ditem-img" alt="皱纹检测图" hidden><span class="viz-missing">本次未获取皱纹检测图</span></div>
|
||||||
|
<div class="contour-sides wrinkle-sides" id="wrinkle-sides" hidden>
|
||||||
|
<figure data-view="left" hidden><button type="button" class="wrinkle-side-photo" aria-label="查看左脸皱纹检测大图"><img id="wrinkle-left" alt="左脸皱纹检测照片"></button></figure>
|
||||||
|
<figure data-view="right" hidden><button type="button" class="wrinkle-side-photo" aria-label="查看右脸皱纹检测大图"><img id="wrinkle-right" alt="右脸皱纹检测照片"></button></figure>
|
||||||
|
</div>
|
||||||
|
<p class="wrinkle-layer-note" id="wrinkle-layer-note" hidden>部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。</p>
|
||||||
<div class="ditem-text"><p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p></div></div>
|
<div class="ditem-text"><p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|||||||
+5
-1
@@ -419,7 +419,7 @@ function renderDetail(data) {
|
|||||||
if (r.wrinkle_score_rule === 'api-overall-v1') h += '<p class="sub">皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。</p>';
|
if (r.wrinkle_score_rule === 'api-overall-v1') h += '<p class="sub">皱纹评分直接使用接口整体皱纹分数,不做100减分,也不再按分区严重度限分。</p>';
|
||||||
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
|
if (r.tone_scoring && r.tone_scoring.version === 'color-dark-circle-20-v1') {
|
||||||
var tone = r.tone_scoring;
|
var tone = r.tone_scoring;
|
||||||
h += '<p class="sub">肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule ? '透亮感取独立肤色、水分、平滑度三项最低分,不再使用肤色水润平均分。' : '历史版本与水分项各占50%。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。</p>';
|
h += '<p class="sub">肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule === 'independent-color-water-smooth-min-v1' ? '该历史版本透亮感取独立肤色、水分、平滑度三项最低分。' : '肤色项与水分项各占50%合成肤色水润分,透亮感取肤色水润与平滑度的最低分。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。</p>';
|
||||||
}
|
}
|
||||||
h += '<p class="sub">前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。</p>';
|
h += '<p class="sub">前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。</p>';
|
||||||
|
|
||||||
@@ -449,6 +449,10 @@ function renderDetail(data) {
|
|||||||
h += dSec('痘痘三视图计分明细', '<p class="sub">每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。</p><table class="d-table"><thead><tr><th>类型</th><th>正脸</th><th>左脸</th><th>右脸</th><th>计分数量</th><th>单处扣分</th><th>该项扣分</th></tr></thead><tbody>' + acneRows + '</tbody></table>');
|
h += dSec('痘痘三视图计分明细', '<p class="sub">每种皮损分别取三视图最大数量,不相加,也不是全脸去重总数;“-”表示未获取。整体评分仍用正脸:' + esc(fmtScore(audit.front_score)) + ',映射分:' + esc(fmtScore(audit.mapped_score)) + '。计数扣分合计:' + esc(fmtScore(audit.raw_deduction)) + ',封顶50后:' + esc(fmtScore(audit.deduction)) + '。</p><table class="d-table"><thead><tr><th>类型</th><th>正脸</th><th>左脸</th><th>右脸</th><th>计分数量</th><th>单处扣分</th><th>该项扣分</th></tr></thead><tbody>' + acneRows + '</tbody></table>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (r.wrinkle_view_status) {
|
||||||
|
h += dSec('侧脸皱纹标注获取状态', '<p class="sub">左右侧脸各自独立调用,仅用于图层展示,不参与计分;查看报告不重新调用。</p>' +
|
||||||
|
['left','right'].map(function(view) { var s=r.wrinkle_view_status[view]||{}; return '<p>'+ (view==='left'?'左脸':'右脸') +':'+esc(s.status==='ready'?'已获取':'未获取')+(s.error_code?'('+esc(s.error_code)+')':'')+'</p>'; }).join(''));
|
||||||
|
}
|
||||||
/* 文案全文 */
|
/* 文案全文 */
|
||||||
var copyHtml = '';
|
var copyHtml = '';
|
||||||
var presentation = r.presentation;
|
var presentation = r.presentation;
|
||||||
|
|||||||
+103
-18
@@ -516,6 +516,7 @@
|
|||||||
startCamera();
|
startCamera();
|
||||||
}
|
}
|
||||||
function stopCamera(keepFrozen) {
|
function stopCamera(keepFrozen) {
|
||||||
|
clearCaptureCountdown();
|
||||||
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
camSeqCam++; // 使进行中的 getUserMedia 回调失效
|
||||||
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
|
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
|
||||||
camReadyTimer = camOpenTimer = null;
|
camReadyTimer = camOpenTimer = null;
|
||||||
@@ -701,14 +702,14 @@
|
|||||||
cur = null;
|
cur = null;
|
||||||
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
|
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
|
||||||
}
|
}
|
||||||
function start(id, kind) {
|
function start(id, kind, onStart) {
|
||||||
stopCur();
|
stopCur();
|
||||||
lastAt[id] = Date.now();
|
lastAt[id] = Date.now();
|
||||||
var token = {};
|
var token = {};
|
||||||
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
|
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
|
||||||
load(id).then(function (buf) {
|
load(id).then(function (buf) {
|
||||||
if (!cur || cur.token !== token) return; // 加载期间已被打断
|
if (!cur || cur.token !== token) return; // 加载期间已被打断
|
||||||
if (!buf || !ctx) { cur = null; drain(); return; }
|
if (!buf || !ctx) { if (onStart) onStart(3000); cur = null; drain(); return; }
|
||||||
var src = ctx.createBufferSource();
|
var src = ctx.createBufferSource();
|
||||||
src.buffer = buf;
|
src.buffer = buf;
|
||||||
src.connect(ctx.destination);
|
src.connect(ctx.destination);
|
||||||
@@ -716,11 +717,12 @@
|
|||||||
cur.end = Date.now() + buf.duration * 1000;
|
cur.end = Date.now() + buf.duration * 1000;
|
||||||
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
|
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
|
||||||
src.start(0);
|
src.start(0);
|
||||||
|
if (onStart) onStart(buf.duration * 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function drain() {
|
function drain() {
|
||||||
if (cur) return;
|
if (cur) return;
|
||||||
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
if (queue.length) { var next = queue.shift(); start(next.id, 'flow', next.onStart); return; }
|
||||||
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
||||||
var w = waiters;
|
var w = waiters;
|
||||||
waiters = [];
|
waiters = [];
|
||||||
@@ -731,16 +733,16 @@
|
|||||||
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
|
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
|
||||||
function play(id, opts) {
|
function play(id, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return;
|
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) { if (opts.onStart) opts.onStart(3000); return; }
|
||||||
if (opts.hint) {
|
if (opts.hint) {
|
||||||
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
|
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
|
||||||
if (busyWithFlow()) { pendingHint = id; return; }
|
if (busyWithFlow()) { pendingHint = id; return; }
|
||||||
start(id, 'hint');
|
start(id, 'hint');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; }
|
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow', opts.onStart); return; }
|
||||||
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
if (busyWithFlow()) { if (!queue.some(function(item) { return item.id === id; })) queue.push({ id:id, onStart:opts.onStart }); return; }
|
||||||
start(id, 'flow'); // 在播的姿态提示让位给流程音
|
start(id, 'flow', opts.onStart); // 在播的姿态提示让位给流程音
|
||||||
}
|
}
|
||||||
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
|
||||||
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
|
||||||
@@ -1082,7 +1084,29 @@
|
|||||||
}, hasNext ? NEXT_PAUSE_MS : 300);
|
}, hasNext ? NEXT_PAUSE_MS : 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
|
var captureCountdownTimers = [];
|
||||||
|
function clearCaptureCountdown() {
|
||||||
|
captureCountdownTimers.forEach(clearTimeout);
|
||||||
|
captureCountdownTimers = [];
|
||||||
|
var el = $('#cam-countdown');
|
||||||
|
el.hidden = true; el.textContent = '';
|
||||||
|
}
|
||||||
|
function startCaptureCountdown(seq, duration, done) {
|
||||||
|
clearCaptureCountdown();
|
||||||
|
function tick(number) {
|
||||||
|
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { clearCaptureCountdown(); return; }
|
||||||
|
var el = $('#cam-countdown'), digit = document.createElement('span');
|
||||||
|
digit.className = 'cam-countdown-tick'; digit.textContent = String(number);
|
||||||
|
el.replaceChildren(digit); el.hidden = false;
|
||||||
|
}
|
||||||
|
tick(3);
|
||||||
|
[2,1].forEach(function(number,i) { captureCountdownTimers.push(setTimeout(function() { tick(number); }, duration * (i+1) / 3)); });
|
||||||
|
captureCountdownTimers.push(setTimeout(function() {
|
||||||
|
clearCaptureCountdown();
|
||||||
|
if (seq === camSeq && camLive && currentScreen === 's-cam') done();
|
||||||
|
}, duration));
|
||||||
|
}
|
||||||
|
// 白屏补光抓拍:数字倒计时和语音都结束后才点亮补光、抓帧。
|
||||||
function doCapture() {
|
function doCapture() {
|
||||||
if (shutterLock) return;
|
if (shutterLock) return;
|
||||||
if (!camLive) return;
|
if (!camLive) return;
|
||||||
@@ -1093,8 +1117,19 @@
|
|||||||
hideNudge();
|
hideNudge();
|
||||||
var seq = camSeq;
|
var seq = camSeq;
|
||||||
voice.dropHint();
|
voice.dropHint();
|
||||||
voice.play(VOICE.countdown);
|
var audioDone = false, visualDone = false, started = false;
|
||||||
|
function finishCountdown() { if (audioDone && visualDone) capture(); }
|
||||||
|
voice.play(VOICE.countdown, { onStart:function(duration) {
|
||||||
|
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
|
||||||
|
started = true;
|
||||||
|
startCaptureCountdown(seq, duration, function() { visualDone = true; finishCountdown(); });
|
||||||
|
} });
|
||||||
voice.afterCurrent(function () {
|
voice.afterCurrent(function () {
|
||||||
|
audioDone = true;
|
||||||
|
if (!started) { started = true; startCaptureCountdown(seq, 3000, function() { visualDone = true; finishCountdown(); }); }
|
||||||
|
finishCountdown();
|
||||||
|
}, true);
|
||||||
|
function capture() {
|
||||||
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
|
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
|
||||||
var fill = $('#cam-fill');
|
var fill = $('#cam-fill');
|
||||||
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
fill.classList.add('on'); // .on 无过渡,瞬间点亮
|
||||||
@@ -1117,7 +1152,7 @@
|
|||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, true);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
|
||||||
@@ -1200,7 +1235,7 @@
|
|||||||
if (fx) {
|
if (fx) {
|
||||||
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
|
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
|
||||||
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
|
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
|
||||||
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
|
done(null, { result: fx.result, side: fx.side, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle });
|
||||||
} else {
|
} else {
|
||||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||||
}
|
}
|
||||||
@@ -1270,8 +1305,8 @@
|
|||||||
|
|
||||||
function setCat(key, cat) {
|
function setCat(key, cat) {
|
||||||
var dom = CAT_DOM[key];
|
var dom = CAT_DOM[key];
|
||||||
// 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文
|
// 分类卡只展示有效话术正文;缺数据不在报告中提示重拍。
|
||||||
$(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text;
|
$(dom.tx).textContent = cat.P == null ? '' : cat.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
|
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
|
||||||
@@ -1350,6 +1385,17 @@
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
function vizWrinkleMarks(result) {
|
||||||
|
var outlines = result && result.wrinkle_mark;
|
||||||
|
if (!outlines || typeof outlines !== 'object') return [];
|
||||||
|
var normalized = {}, defs = [];
|
||||||
|
Object.keys(outlines).forEach(function(key) {
|
||||||
|
if (!/_wrinkle_outline$/.test(key) || !Array.isArray(outlines[key])) return;
|
||||||
|
normalized[key] = { polygon: [outlines[key]] };
|
||||||
|
defs.push({ key: key, color: '#6E9900' });
|
||||||
|
});
|
||||||
|
return vizCollectMarks(defs, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
|
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
|
||||||
function vizScaleMarks(marks, scale) {
|
function vizScaleMarks(marks, scale) {
|
||||||
@@ -1395,6 +1441,7 @@
|
|||||||
if (!photo) return; // 照片不可用:保留占位
|
if (!photo) return; // 照片不可用:保留占位
|
||||||
var finish = function (overlays) {
|
var finish = function (overlays) {
|
||||||
if (epoch !== vizEpoch) return;
|
if (epoch !== vizEpoch) return;
|
||||||
|
if (!overlays.some(Boolean) && opts && opts.fallbackMarks) marks = opts.fallbackMarks;
|
||||||
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
|
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
|
||||||
var originalTop = crop.top;
|
var originalTop = crop.top;
|
||||||
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
|
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
|
||||||
@@ -1450,6 +1497,7 @@
|
|||||||
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
|
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
|
||||||
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
|
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
|
||||||
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
|
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
|
||||||
|
imgEl.dataset.hasAnnotation = String(overlays.some(Boolean) || marks.length > 0);
|
||||||
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
|
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
|
||||||
} catch (e) { /* 合成失败保留占位 */ }
|
} catch (e) { /* 合成失败保留占位 */ }
|
||||||
};
|
};
|
||||||
@@ -1503,9 +1551,10 @@
|
|||||||
}
|
}
|
||||||
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
||||||
var ov = vizMapURL(maps[cfg.overlay]);
|
var ov = vizMapURL(maps[cfg.overlay]);
|
||||||
if (!ov) continue;
|
var wrinkleMarks = key === 'wrinkles' && !ov ? vizWrinkleMarks(result) : [];
|
||||||
|
if (!ov && !frontPhoto) continue;
|
||||||
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
||||||
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle });
|
vizCompose(frontPhoto, ov, wrinkleMarks, imgEl, { dim: ov || wrinkleMarks.length ? cfg.dim : 0, faceRectangle: rectangle, contentDown: key === 'wrinkles' ? .09 : 0, fallbackMarks: key === 'wrinkles' ? vizWrinkleMarks(result) : null });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
||||||
@@ -1542,6 +1591,37 @@
|
|||||||
});
|
});
|
||||||
refreshHeight();
|
refreshHeight();
|
||||||
}
|
}
|
||||||
|
var wrinklePhotoEpoch = 0;
|
||||||
|
function renderWrinkleSides(reportPhotos, payload) {
|
||||||
|
var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note');
|
||||||
|
function refresh() {
|
||||||
|
var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])'));
|
||||||
|
wrap.hidden = !visible.length;
|
||||||
|
note.hidden = !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; });
|
||||||
|
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
|
||||||
|
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
|
||||||
|
}
|
||||||
|
wrap.hidden = note.hidden = true;
|
||||||
|
['left', 'right'].forEach(function(view, i) {
|
||||||
|
var img = $('#wrinkle-' + view), figure = img.closest('figure');
|
||||||
|
img.onload = img.onerror = null; img.removeAttribute('src');
|
||||||
|
img.dataset.hasAnnotation = 'false'; img.draggable = false; figure.hidden = true;
|
||||||
|
var src = reportPhotos && reportPhotos[i + 1];
|
||||||
|
if (!src) return;
|
||||||
|
// Only this photograph's own API data may be used. The front-face map
|
||||||
|
// is NEVER a fallback for a side photo.
|
||||||
|
var side = payload && payload.wrinkle_views && payload.wrinkle_views[view];
|
||||||
|
if (!side || !side.result) side = payload && payload.side && payload.side[view];
|
||||||
|
var result = side && (side.result || side);
|
||||||
|
var overlay = vizMapURL(result && result.face_maps && result.face_maps.texture_enhanced_lines);
|
||||||
|
var marks = overlay ? [] : vizWrinkleMarks(result);
|
||||||
|
img.onload = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = false; refresh(); } };
|
||||||
|
img.onerror = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = true; img.removeAttribute('src'); refresh(); } };
|
||||||
|
if (overlay || marks.length) vizCompose(src, overlay, marks, img, { dim: .55, fallbackMarks: vizWrinkleMarks(result) });
|
||||||
|
else img.src = src;
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
|
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
|
||||||
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
|
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
|
||||||
reportOwner = AccountClient.user() && AccountClient.user().id;
|
reportOwner = AccountClient.user() && AccountClient.user().id;
|
||||||
@@ -1578,10 +1658,15 @@
|
|||||||
focusEl.append(first, second);
|
focusEl.append(first, second);
|
||||||
}
|
}
|
||||||
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
|
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
|
||||||
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); });
|
presentation.views.forEach(function (v) {
|
||||||
|
var text = ReportUI.customerText(v), el = $(textIds[v.key]);
|
||||||
|
el.textContent = text;
|
||||||
|
el.hidden = !text;
|
||||||
|
});
|
||||||
|
|
||||||
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
||||||
renderViz(payload, frontPhoto);
|
renderViz(payload, frontPhoto);
|
||||||
|
renderWrinkleSides(reportPhotos || [frontPhoto, sidePhoto, null], payload);
|
||||||
|
|
||||||
// Fourth feedback: capture quality belongs in the protected admin record,
|
// Fourth feedback: capture quality belongs in the protected admin record,
|
||||||
// not in the customer report. Preserve vm.notes/snapshots unchanged.
|
// not in the customer report. Preserve vm.notes/snapshots unchanged.
|
||||||
@@ -1692,7 +1777,7 @@
|
|||||||
card.addEventListener('click', function (e) {
|
card.addEventListener('click', function (e) {
|
||||||
var btn = e.target.closest('.dtab');
|
var btn = e.target.closest('.dtab');
|
||||||
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
|
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
|
||||||
var sideButton = e.target.closest('.contour-side-photo');
|
var sideButton = e.target.closest('.contour-side-photo, .wrinkle-side-photo');
|
||||||
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
|
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
|
||||||
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
|
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
|
||||||
$('#zoom-img').src = img.src;
|
$('#zoom-img').src = img.src;
|
||||||
@@ -1944,7 +2029,7 @@
|
|||||||
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
|
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
|
||||||
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
||||||
skinType: SKINS[skinIndex].name
|
skinType: SKINS[skinIndex].name
|
||||||
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
|
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
|
||||||
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -23,9 +23,9 @@
|
|||||||
"优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。"
|
"优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。"
|
||||||
},
|
},
|
||||||
"water": {
|
"water": {
|
||||||
"改善": "水分分 {water_score};缺水面积 {water_area}%,{water_severity_note}肌肤水润度有待提升,建议加强补水与保湿。",
|
"改善": "角质层含水量较低,缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。",
|
||||||
"良好": "水分分 {water_score};缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。",
|
"良好": "角质层含水量适中,缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。",
|
||||||
"优秀": "水分分 {water_score};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。"
|
"优秀": "角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。"
|
||||||
},
|
},
|
||||||
"sens": {
|
"sens": {
|
||||||
"改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。",
|
"改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。",
|
||||||
|
|||||||
+5
-10
@@ -22,7 +22,7 @@
|
|||||||
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
|
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
|
||||||
}
|
}
|
||||||
var gradeRule = 'display-score-8-9-v1';
|
var gradeRule = 'display-score-8-9-v1';
|
||||||
var copyRule = 'wrinkles-excellent-acne-detected-v1';
|
var copyRule = 'wrinkles-excellent-acne-detected-water-v2';
|
||||||
function scoreOfP(P) {
|
function scoreOfP(P) {
|
||||||
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
|
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
|
||||||
}
|
}
|
||||||
@@ -45,11 +45,13 @@
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
function customerText(view) {
|
function customerText(view) {
|
||||||
|
// Keep missing-data diagnostics in the saved/admin record, not on the
|
||||||
|
// customer report. Also suppress equivalent text in older snapshots.
|
||||||
|
if (view.valid === false || /本次未检出有效数据|本次未获取轮廓有效数据/.test(view.text || '')) return '';
|
||||||
if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0];
|
if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0];
|
||||||
if (view.key !== 'contour') return view.text;
|
if (view.key !== 'contour') return view.text;
|
||||||
// Also cover immutable old presentations, without modifying stored copy.
|
// Also cover immutable old presentations, without modifying stored copy.
|
||||||
return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '')
|
return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '');
|
||||||
.replace('本次未检出有效数据,请重新拍摄后查看。', '本次未获取轮廓有效数据。');
|
|
||||||
}
|
}
|
||||||
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
|
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
|
||||||
var wrinkleDefs = [
|
var wrinkleDefs = [
|
||||||
@@ -115,13 +117,6 @@
|
|||||||
var viewS = valid ? scoreOfP(viewP) : null;
|
var viewS = valid ? scoreOfP(viewP) : null;
|
||||||
level = levelForScore(viewS);
|
level = levelForScore(viewS);
|
||||||
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
|
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
|
||||||
if (d[0] === 'water' && valid) {
|
|
||||||
vars.water_severity_note = numeric(vars.water_sev) && vars.water_sev >= 0 && vars.water_sev <= 100
|
|
||||||
? (vars.water_sev >= 60 ? '缺水程度较高(严重度 {water_sev}),' : '缺水严重度 {water_sev},') : '';
|
|
||||||
if (viewP !== vars.water_score) template = '水分分 {water_score};缺水面积 {water_area}%,缺水严重度 {water_sev}。水分分数与缺水指标不一致,本次按缺水指标评估,建议加强补水与保湿。';
|
|
||||||
// Resolve the optional phrase before normal scalar substitution.
|
|
||||||
template = template.replace(/\{water_severity_note\}/g, vars.water_severity_note);
|
|
||||||
}
|
|
||||||
if (d[0] === 'contour') {
|
if (d[0] === 'contour') {
|
||||||
var severity = vars.dark_circle_sev;
|
var severity = vars.dark_circle_sev;
|
||||||
var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null;
|
var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null;
|
||||||
|
|||||||
+3
-10
@@ -752,16 +752,9 @@
|
|||||||
var d = DIM_DEFS[i], ps = [], j;
|
var d = DIM_DEFS[i], ps = [], j;
|
||||||
for (j = 0; j < CAT_DEFS.length; j++) {
|
for (j = 0; j < CAT_DEFS.length; j++) {
|
||||||
if (CAT_DEFS[j].dim !== d.key) continue;
|
if (CAT_DEFS[j].dim !== d.key) continue;
|
||||||
if (d.key === 'vita' && CAT_DEFS[j].key === 'tone') continue; // retired combined score
|
|
||||||
var P = cats[CAT_DEFS[j].key].P;
|
var P = cats[CAT_DEFS[j].key].P;
|
||||||
if (P != null) ps.push(P);
|
if (P != null) ps.push(P);
|
||||||
}
|
}
|
||||||
if (d.key === 'vita') {
|
|
||||||
var toneVars = cats.tone.vars;
|
|
||||||
[toneVars.color_score, toneVars.tone_scoring && toneVars.tone_scoring.water_adjusted_score].forEach(function(score) {
|
|
||||||
if (isNum(score)) ps.push(score);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var dP = ps.length ? Math.min.apply(null, ps) : null;
|
var dP = ps.length ? Math.min.apply(null, ps) : null;
|
||||||
if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分');
|
if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分');
|
||||||
dims[d.key] = { name: d.name, P: dP, S: sShow(dP), level: levelOf(dP) };
|
dims[d.key] = { name: d.name, P: dP, S: sShow(dP), level: levelOf(dP) };
|
||||||
@@ -855,7 +848,7 @@
|
|||||||
return {
|
return {
|
||||||
total: weightedTotal(dims),
|
total: weightedTotal(dims),
|
||||||
totalRule: 'dimensions-433-v1',
|
totalRule: 'dimensions-433-v1',
|
||||||
vitaScoreRule: 'independent-color-water-smooth-min-v1',
|
vitaScoreRule: 'combined-tone-smooth-min-v2',
|
||||||
wrinkleScoreRule: 'api-overall-v1',
|
wrinkleScoreRule: 'api-overall-v1',
|
||||||
acneScoreRule: 'three-view-type-max-v1',
|
acneScoreRule: 'three-view-type-max-v1',
|
||||||
skinAgeRaw: skinAge,
|
skinAgeRaw: skinAge,
|
||||||
@@ -992,7 +985,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
|
|||||||
ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)');
|
ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)');
|
||||||
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
|
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
|
||||||
ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)');
|
ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)');
|
||||||
ok(vm.dims.firm.P === 23 && vm.dims.vita.P === 80 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 独立肤色短板80 / 稳定力85');
|
ok(vm.dims.firm.P === 23 && vm.dims.vita.P === Math.min(vm.cats.smooth.P,vm.cats.tone.P) && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 平滑度与肤色水润最低分 / 稳定力85');
|
||||||
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制');
|
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制');
|
||||||
ok(vm.group === '25+组', '原始肌龄20 → 25+组');
|
ok(vm.group === '25+组', '原始肌龄20 → 25+组');
|
||||||
ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1,
|
ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1,
|
||||||
@@ -1023,7 +1016,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
|
|||||||
console.log('== T2 木桶 min(显式构造) ==');
|
console.log('== T2 木桶 min(显式构造) ==');
|
||||||
var vm2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 40 });
|
var vm2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 40 });
|
||||||
ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P)
|
ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P)
|
||||||
&& vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.vars.color_score, vm2.cats.tone.vars.tone_scoring.water_adjusted_score)
|
&& vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P)
|
||||||
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
|
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
|
||||||
ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
|
ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
// Display-only side analyses. Never merge these scores into the primary
|
||||||
|
// three-view result or re-run detection when reading a historical report.
|
||||||
|
const VERSION = 'independent-side-wrinkles-v1';
|
||||||
|
function hasAnnotation(result) {
|
||||||
|
if (typeof result?.face_maps?.texture_enhanced_lines === 'string' && result.face_maps.texture_enhanced_lines.trim()) return true;
|
||||||
|
return Object.values(result?.wrinkle_mark || {}).some(points => Array.isArray(points) && points.filter(p => Number.isFinite(p?.x) && Number.isFinite(p?.y)).length >= 3);
|
||||||
|
}
|
||||||
|
async function collect(primary, photos, options) {
|
||||||
|
const {key, secret, url} = options;
|
||||||
|
const fetchImpl = options.fetch || fetch;
|
||||||
|
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
||||||
|
const now = options.now || Date.now;
|
||||||
|
const deadline = options.deadline || now() + 90000;
|
||||||
|
const gap = options.gapMs ?? 4000;
|
||||||
|
const views = {version: VERSION};
|
||||||
|
for (const view of ['left','right']) {
|
||||||
|
const existing = primary[view + '_side_result'] || primary.side_result?.[view + '_side_result'];
|
||||||
|
const existingResult = existing?.result || existing;
|
||||||
|
if (hasAnnotation(existingResult)) {
|
||||||
|
views[view] = {status:'ready',source:'primary-side-result',result:existingResult};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const photo = photos[view];
|
||||||
|
if (!photo) { views[view] = {status:'unavailable',error_code:'MISSING_SIDE_PHOTO'}; continue; }
|
||||||
|
if (deadline - now() <= gap + 1000) { views[view] = {status:'unavailable',error_code:'WRINKLE_TIME_BUDGET'}; continue; }
|
||||||
|
await sleep(gap);
|
||||||
|
const timeout = Math.min(options.timeoutMs || 20000, deadline - now());
|
||||||
|
if (timeout <= 0) { views[view] = {status:'unavailable',error_code:'WRINKLE_TIME_BUDGET'}; continue; }
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('api_key',key); form.append('api_secret',secret);
|
||||||
|
// A side photo must be the main image of ITS OWN request to obtain
|
||||||
|
// wrinkle maps in the corresponding coordinate space.
|
||||||
|
form.append('image_file',new Blob([Buffer.from(photo,'base64')]),view+'.jpg');
|
||||||
|
form.append('return_maps','texture_enhanced_lines');
|
||||||
|
form.append('return_marks','wrinkle_mark');
|
||||||
|
const response = await fetchImpl(url,{method:'POST',body:form,signal:AbortSignal.timeout(timeout)});
|
||||||
|
const raw = await response.json();
|
||||||
|
const code = raw.error_message ? String(raw.error_message).split(':')[0] : !response.ok ? 'WRINKLE_HTTP_'+response.status : !hasAnnotation(raw.result) ? 'WRINKLE_ANNOTATION_NOT_RETURNED' : null;
|
||||||
|
views[view] = Object.assign({},raw,{status:code?'unavailable':'ready',source:'independent-side-request',error_code:code});
|
||||||
|
} catch (e) {
|
||||||
|
views[view] = {status:'unavailable',source:'independent-side-request',error_code:e.name==='TimeoutError'||e.name==='AbortError'?'WRINKLE_TIMEOUT':'WRINKLE_REQUEST_FAILED'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
function forReport(views) {
|
||||||
|
if (!views) return null;
|
||||||
|
const out = {version:views.version};
|
||||||
|
for (const view of ['left','right']) {
|
||||||
|
const item=views[view];
|
||||||
|
if (!item) continue;
|
||||||
|
out[view]={status:item.status,error_code:item.error_code||null,face_rectangle:item.face_rectangle||null};
|
||||||
|
if (item.status==='ready'&&item.result) out[view].result={
|
||||||
|
face_maps:{texture_enhanced_lines:item.result.face_maps?.texture_enhanced_lines||null},
|
||||||
|
wrinkle_mark:item.result.wrinkle_mark||null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
module.exports={VERSION,hasAnnotation,collect,forReport};
|
||||||
@@ -28,6 +28,7 @@ const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一
|
|||||||
const ReportUI = require('./js/report-ui.js');
|
const ReportUI = require('./js/report-ui.js');
|
||||||
const BeijingTime = require('./js/beijing-time.js');
|
const BeijingTime = require('./js/beijing-time.js');
|
||||||
const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
|
const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
|
||||||
|
const WrinkleViews = require('./lib/wrinkle-views.js');
|
||||||
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
|
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
|
||||||
|
|
||||||
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
||||||
@@ -291,6 +292,9 @@ function fillSuccess(rec, fpJson, persist = true) {
|
|||||||
rec.wrinkle_score_rule = vm.wrinkleScoreRule;
|
rec.wrinkle_score_rule = vm.wrinkleScoreRule;
|
||||||
rec.acne_scoring = vm.cats.acne.vars.acne_scoring;
|
rec.acne_scoring = vm.cats.acne.vars.acne_scoring;
|
||||||
rec.tone_scoring = vm.cats.tone.vars.tone_scoring;
|
rec.tone_scoring = vm.cats.tone.vars.tone_scoring;
|
||||||
|
rec.wrinkle_view_status = fpJson.wrinkle_views ? Object.fromEntries(['left','right'].map(view => [view, {
|
||||||
|
status:fpJson.wrinkle_views[view]?.status||'unavailable', error_code:fpJson.wrinkle_views[view]?.error_code||null
|
||||||
|
}])) : null;
|
||||||
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
|
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
|
||||||
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
|
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
|
||||||
rec.cats = {}; rec.copy_6 = {};
|
rec.cats = {}; rec.copy_6 = {};
|
||||||
@@ -326,6 +330,15 @@ async function runDetect(rec, front, left, right, persist = true) {
|
|||||||
rec.updated_at = nowIso();
|
rec.updated_at = nowIso();
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const fpJson = await callFacepp(front, left, right);
|
const fpJson = await callFacepp(front, left, right);
|
||||||
|
if (fpJson.result && !fpJson.error_message) {
|
||||||
|
fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{
|
||||||
|
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,
|
||||||
|
gapMs:process.env.NODE_ENV==='test'?0:4000,
|
||||||
|
// Keep within the existing 150-second browser timeout, including
|
||||||
|
// upload/serialization headroom. Missing side maps never fail a report.
|
||||||
|
deadline:t0+120000
|
||||||
|
});
|
||||||
|
}
|
||||||
rec.cost_ms = Date.now() - t0;
|
rec.cost_ms = Date.now() - t0;
|
||||||
if (fpJson.error_message) {
|
if (fpJson.error_message) {
|
||||||
const code = fpJson.error_message.split(':')[0];
|
const code = fpJson.error_message.split(':')[0];
|
||||||
@@ -466,6 +479,7 @@ async function handleAnalyze(req, res) {
|
|||||||
const nested = fpJson.side_result || {};
|
const nested = fpJson.side_result || {};
|
||||||
const payload = {
|
const payload = {
|
||||||
result, face_rectangle: fpJson.face_rectangle || null,
|
result, face_rectangle: fpJson.face_rectangle || null,
|
||||||
|
wrinkle_views: WrinkleViews.forReport(fpJson.wrinkle_views),
|
||||||
side: { left: fpJson.left_side_result || nested.left_side_result || null,
|
side: { left: fpJson.left_side_result || nested.left_side_result || null,
|
||||||
right: fpJson.right_side_result || nested.right_side_result || null }
|
right: fpJson.right_side_result || nested.right_side_result || null }
|
||||||
};
|
};
|
||||||
@@ -485,6 +499,7 @@ async function handleAnalyze(req, res) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
record_id: recordId, report_saved: reportSaved, presentation, vm: snapshotVm,
|
record_id: recordId, report_saved: reportSaved, presentation, vm: snapshotVm,
|
||||||
result,
|
result,
|
||||||
|
wrinkle_views: payload.wrinkle_views,
|
||||||
face_rectangle: fpJson.face_rectangle || null,
|
face_rectangle: fpJson.face_rectangle || null,
|
||||||
side: {
|
side: {
|
||||||
left: fpJson.left_side_result || nested.left_side_result || null,
|
left: fpJson.left_side_result || nested.left_side_result || null,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ try {
|
|||||||
return{ratio:f.height/i.height,top:i.top-f.top,width:i.width-f.width,other:other.height/i.height,overflow:getComputedStyle(img.parentElement).overflow};
|
return{ratio:f.height/i.height,top:i.top-f.top,width:i.width-f.width,other:other.height/i.height,overflow:getComputedStyle(img.parentElement).overflow};
|
||||||
});
|
});
|
||||||
assert(Math.abs(crop.ratio-.9)<.002);assert(Math.abs(crop.top)<1);assert(Math.abs(crop.width)<1);
|
assert(Math.abs(crop.ratio-.9)<.002);assert(Math.abs(crop.top)<1);assert(Math.abs(crop.width)<1);
|
||||||
assert(Math.abs(crop.other-1)<.002);assert.equal(crop.overflow,'hidden');
|
assert(Math.abs(crop.other-.9)<.002,'wrinkle front frame matches contour crop');assert.equal(crop.overflow,'hidden');
|
||||||
const offset=await page.locator('#ditem-img1').getAttribute('data-content-offset');
|
const offset=await page.locator('#ditem-img1').getAttribute('data-content-offset');
|
||||||
assert(offset!=null&&Number(offset)>=0&&Number(offset)<=.090001);
|
assert(offset!=null&&Number(offset)>=0&&Number(offset)<=.090001);
|
||||||
const fullFront=await page.locator('#ditem-img1').getAttribute('src');
|
const fullFront=await page.locator('#ditem-img1').getAttribute('src');
|
||||||
@@ -49,8 +49,8 @@ try {
|
|||||||
const m=new DOMMatrix(getComputedStyle(e).transform),r=e.getBoundingClientRect(),b=e.parentElement.getBoundingClientRect();
|
const m=new DOMMatrix(getComputedStyle(e).transform),r=e.getBoundingClientRect(),b=e.parentElement.getBoundingClientRect();
|
||||||
return{sx:m.a,sy:m.d,shift:m.f/b.height,top:r.top-b.top,bottom:r.bottom-b.bottom};
|
return{sx:m.a,sy:m.d,shift:m.f/b.height,top:r.top-b.top,bottom:r.bottom-b.bottom};
|
||||||
});
|
});
|
||||||
assert(Math.abs(fill.sx-1.3)<.001);assert(Math.abs(fill.sy-1.3)<.001);assert(Math.abs(fill.shift-.15)<.001);
|
assert(Math.abs(fill.sx-2.8)<.001);assert(Math.abs(fill.sy-2.8)<.001);assert(Math.abs(fill.shift-(-.25))<.001);
|
||||||
assert(Math.abs(fill.top)<1&&fill.bottom>0,'zoomed fill covers frame without blank top edge');
|
assert(fill.top<=.01&&fill.bottom>0,'zoomed fill covers frame without blank top edge');
|
||||||
}
|
}
|
||||||
const spacing=await page.locator('#contour-sides').evaluate(e=>{
|
const spacing=await page.locator('#contour-sides').evaluate(e=>{
|
||||||
const frame=document.querySelector('.contour-front-frame'),f=frame.getBoundingClientRect(),s=e.getBoundingClientRect();
|
const frame=document.querySelector('.contour-front-frame'),f=frame.getBoundingClientRect(),s=e.getBoundingClientRect();
|
||||||
|
|||||||
@@ -17,15 +17,16 @@ try {
|
|||||||
captureFromVideo=function(){window.__shots.push(step);return 'fixture';};
|
captureFromVideo=function(){window.__shots.push(step);return 'fixture';};
|
||||||
checkStill=function(){return Promise.resolve(null);};
|
checkStill=function(){return Promise.resolve(null);};
|
||||||
onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); },
|
onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); },
|
||||||
again:doCapture, cancel:function(){camSeq++; voice.stop(true);},
|
again:doCapture, cancel:leaveCamera,
|
||||||
busy:function(){return shutterLock;}
|
busy:function(){return shutterLock;}
|
||||||
};
|
};
|
||||||
})();`)}));
|
})();`)}));
|
||||||
await page.addInitScript(()=>{
|
await page.addInitScript(()=>{
|
||||||
window.__audio=[];window.__shots=[];window.__completed=[];
|
window.__audio=[];window.__shots=[];window.__completed=[];
|
||||||
window.AudioContext=class {
|
window.AudioContext=class {
|
||||||
constructor(){this.state='running';this.destination={};}
|
constructor(){this.destination={};}
|
||||||
decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:3};resolve(b);return Promise.resolve(b);}
|
get state(){return window.__mute?'suspended':'running';}
|
||||||
|
decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:.9};resolve(b);return Promise.resolve(b);}
|
||||||
createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};}
|
createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -36,18 +37,30 @@ try {
|
|||||||
await page.evaluate(n=>window.__countdown.start(n),step);
|
await page.evaluate(n=>window.__countdown.start(n),step);
|
||||||
await page.waitForFunction(n=>window.__audio.length===n,step+1);
|
await page.waitForFunction(n=>window.__audio.length===n,step+1);
|
||||||
assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3');
|
assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3');
|
||||||
|
assert.equal(await page.locator('#cam-countdown').textContent(),'3');
|
||||||
|
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),false);
|
||||||
await page.evaluate(()=>window.__countdown.again());
|
await page.evaluate(()=>window.__countdown.again());
|
||||||
await page.waitForTimeout(350);
|
await page.waitForTimeout(350);
|
||||||
assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended');
|
assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended');
|
||||||
assert(await page.evaluate(()=>window.__countdown.busy()));
|
assert(await page.evaluate(()=>window.__countdown.busy()));
|
||||||
|
assert.equal(await page.locator('#cam-countdown').textContent(),'2');
|
||||||
|
await page.waitForFunction(()=>document.querySelector('#cam-countdown').textContent==='1');
|
||||||
await page.evaluate(()=>window.__audio.at(-1).onended());
|
await page.evaluate(()=>window.__audio.at(-1).onended());
|
||||||
await page.waitForFunction(n=>window.__completed.length===n,step+1);
|
await page.waitForFunction(n=>window.__completed.length===n,step+1);
|
||||||
|
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true);
|
||||||
}
|
}
|
||||||
await page.evaluate(()=>window.__countdown.start(0));
|
await page.evaluate(()=>window.__countdown.start(0));
|
||||||
await page.waitForFunction(()=>window.__audio.length===4);
|
await page.waitForFunction(()=>window.__audio.length===4);
|
||||||
await page.evaluate(()=>window.__countdown.cancel());
|
await page.evaluate(()=>window.__countdown.cancel());
|
||||||
|
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true,'cancel immediately clears countdown');
|
||||||
await page.waitForTimeout(700);
|
await page.waitForTimeout(700);
|
||||||
assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures');
|
assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures');
|
||||||
|
await page.evaluate(()=>{window.__mute=true;window.__countdown.start(0);});
|
||||||
|
assert.equal(await page.locator('#cam-countdown').textContent(),'3');
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
assert.equal(await page.evaluate(()=>window.__shots.length),3,'silent devices still wait for visual countdown');
|
||||||
|
await page.waitForFunction(()=>window.__completed.length===4);
|
||||||
|
assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true);
|
||||||
assert.deepEqual(errors,[]);
|
assert.deepEqual(errors,[]);
|
||||||
console.log('PASS: each of 3 shots waits for countdown ended; duplicate shutter ignored; cancellation safe');
|
console.log('PASS: all 3 shots show animated 3/2/1, wait for voice and visual completion; duplicate shutter ignored; cancellation clears digits; silent-device countdown');
|
||||||
}finally{await browser.close();}
|
}finally{await browser.close();}
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ const wrinkle = { key: 'wrinkles', text: '原始皱纹结论。\n分区明细(
|
|||||||
assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。');
|
assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。');
|
||||||
assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten');
|
assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten');
|
||||||
assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。');
|
assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。');
|
||||||
|
for (const key of ['contour','wrinkles','color','smooth','water','sens','acne']) {
|
||||||
|
const missing={key,valid:false,text:'本次未检出有效数据,请重新拍摄后查看。'};
|
||||||
|
const before=JSON.stringify(missing);
|
||||||
|
assert.equal(ReportUI.customerText(missing),'');
|
||||||
|
assert.equal(JSON.stringify(missing),before,'retain backend diagnostic');
|
||||||
|
assert.equal(ReportUI.customerText({key,text:'本次未检出有效数据'}),'','legacy missing-data text hidden');
|
||||||
|
assert.equal(ReportUI.customerText({key,text:'正常结论。',valid:true}),'正常结论。');
|
||||||
|
}
|
||||||
|
assert.equal(ReportUI.customerText({key:'contour',text:'本次未获取轮廓有效数据。'}),'');
|
||||||
const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } };
|
const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } };
|
||||||
assert.equal(Diagnostics.assess(raw).status, 'invalid');
|
assert.equal(Diagnostics.assess(raw).status, 'invalid');
|
||||||
assert.equal(Diagnostics.assess({}).status, 'unavailable');
|
assert.equal(Diagnostics.assess({}).status, 'unavailable');
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure m
|
|||||||
for (const manual of [true, false]) {
|
for (const manual of [true, false]) {
|
||||||
let manualBuzzes = 0, scheduled = [];
|
let manualBuzzes = 0, scheduled = [];
|
||||||
const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1,
|
const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1,
|
||||||
|
currentScreen:'s-cam', VOICE:{countdown:'countdown-321'},
|
||||||
|
voice:{dropHint:()=>{},play:(_,opts)=>opts.onStart(3000),afterCurrent:cb=>cb()},
|
||||||
|
startCaptureCountdown:(_,duration,done)=>done(),
|
||||||
isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } },
|
isManualMode: () => manual, navigator: { vibrate: () => { manualBuzzes++; throw Error('blocked'); } },
|
||||||
clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {},
|
clearTimeout: () => {}, nudgeTimer: null, hideNudge: () => {},
|
||||||
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
|
$: () => ({ classList: { add: () => {} } }), setTimeout: (_, ms) => scheduled.push(ms) };
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const child = spawn(process.execPath, ['-e', `
|
|||||||
const fs=require('fs'),vm=require('vm'),w={};
|
const fs=require('fs'),vm=require('vm'),w={};
|
||||||
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
|
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
|
||||||
const f=w.MOCK_FIXTURE; let calls=0;
|
const f=w.MOCK_FIXTURE; let calls=0;
|
||||||
global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
|
global.fetch=async(url,options)=>({ok:true,status:200,json:async()=>options.body.has('left_side_image_file')&&++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
|
||||||
{request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}});
|
{request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}});
|
||||||
require('./server.js');
|
require('./server.js');
|
||||||
`], { cwd: h5, env: { ...process.env, PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
`], { cwd: h5, env: { ...process.env, NODE_ENV: 'test', PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||||
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let browser;
|
let browser;
|
||||||
function diskSnapshot(dir = dataRoot, prefix = '') {
|
function diskSnapshot(dir = dataRoot, prefix = '') {
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ function main() {
|
|||||||
tr.cats.acne = { P: 99, S: 9.97, tier: '良好档', copy: '检出1处皮损(闭口粉刺1处)。建议维持清洁与控油节奏,注意作息与饮食。' };
|
tr.cats.acne = { P: 99, S: 9.97, tier: '良好档', copy: '检出1处皮损(闭口粉刺1处)。建议维持清洁与控油节奏,注意作息与饮食。' };
|
||||||
tr.avgS = 9.53;
|
tr.avgS = 9.53;
|
||||||
tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53');
|
tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53');
|
||||||
// Authorized independent minimum: water90 no longer averaged up to91.5.
|
// Restored original minimum: compare combined tone with smoothness.
|
||||||
tr.dims.vita.P = 90; tr.dims.vita.S = 9.7;
|
|
||||||
}
|
}
|
||||||
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
|
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ try{
|
|||||||
return{heading:rect('.prod-title'),care:rect('.card-care'),product:rect('.card-product'),info:rect('.prod-info'),title:rect('#product-title'),name:rect('#product-name'),image:rect('#product-image'),badge:rect('.prod-badge'),disclaimer:rect('.report-disclaimer'),align:getComputedStyle(document.querySelector('.prod-title')).textAlign,headingFont:parseFloat(getComputedStyle(document.querySelector('.prod-title')).fontSize)};
|
return{heading:rect('.prod-title'),care:rect('.card-care'),product:rect('.card-product'),info:rect('.prod-info'),title:rect('#product-title'),name:rect('#product-name'),image:rect('#product-image'),badge:rect('.prod-badge'),disclaimer:rect('.report-disclaimer'),align:getComputedStyle(document.querySelector('.prod-title')).textAlign,headingFont:parseFloat(getComputedStyle(document.querySelector('.prod-title')).fontSize)};
|
||||||
});
|
});
|
||||||
assert.equal(geo.align,'center');assert(geo.heading.bottom<=geo.care.y+1);
|
assert.equal(geo.align,'center');assert(geo.heading.bottom<=geo.care.y+1);
|
||||||
|
const spacing=await p.evaluate(()=>{
|
||||||
|
const u=document.querySelector('.viz-frame').getBoundingClientRect().width/215;
|
||||||
|
const title=getComputedStyle(document.querySelector('.prod-title'));
|
||||||
|
return{u,top:parseFloat(title.marginTop),bottom:parseFloat(title.marginBottom),dimensions:[...document.querySelectorAll('.dimension-title')].map(e=>{
|
||||||
|
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%');
|
||||||
|
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};});
|
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');
|
assert.equal(divider.width,'1px');assert.equal(divider.color,'rgb(221, 221, 221)');assert.equal(divider.left,'0px');assert.equal(divider.right,'0px');
|
||||||
const careGap=await p.locator('.product-section-heading').evaluate(e=>({gap:parseFloat(getComputedStyle(e).marginTop),u:document.querySelector('.viz-frame').getBoundingClientRect().width/215}));
|
const careGap=await p.locator('.product-section-heading').evaluate(e=>({gap:parseFloat(getComputedStyle(e).marginTop),u:document.querySelector('.viz-frame').getBoundingClientRect().width/215}));
|
||||||
|
|||||||
@@ -29,10 +29,17 @@ const child = spawn(process.execPath, ['-e', `
|
|||||||
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
|
f.side.left.left_acne_info={score:0};set(f.side.left.left_acne_info,[5,1,0,0,1]);
|
||||||
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
|
f.side.right.right_acne_info={score:0};set(f.side.right.right_acne_info,[3,1,0,0,4]);
|
||||||
}
|
}
|
||||||
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
|
let callCount=0;
|
||||||
|
global.fetch=async(url,options)=>{
|
||||||
|
callCount++;
|
||||||
|
const sideOnly=options.body.get('return_maps')==='texture_enhanced_lines';
|
||||||
|
if(sideOnly){if(options.body.has('left_side_image_file')||options.body.has('right_side_image_file'))throw Error('side wrinkle photo must be its own main image');}
|
||||||
|
if(callCount>3)throw Error('unexpected paid request count');
|
||||||
|
return{ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})};
|
||||||
|
};
|
||||||
require('./server.js');
|
require('./server.js');
|
||||||
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
`], { cwd: h5, env: { ...process.env, PORT: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||||
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', NODE_ENV:'test', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -53,6 +60,8 @@ try {
|
|||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.report_saved, true);
|
assert.equal(result.report_saved, true);
|
||||||
assert(result.vm && result.presentation);
|
assert(result.vm && result.presentation);
|
||||||
|
assert.equal(result.wrinkle_views.version,'independent-side-wrinkles-v1');
|
||||||
|
assert.equal(result.wrinkle_views.left.status,'ready');assert.equal(result.wrinkle_views.right.status,'ready');
|
||||||
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
|
const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3);
|
||||||
assert.equal(result.vm.total, expectedTotal);
|
assert.equal(result.vm.total, expectedTotal);
|
||||||
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
|
assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total');
|
||||||
@@ -73,9 +82,9 @@ try {
|
|||||||
for (const item of Object.values(adminRecord.sub_scores)) assert.equal(item.level,ReportUI.internalLevel(item.S));
|
for (const item of Object.values(adminRecord.sub_scores)) assert.equal(item.level,ReportUI.internalLevel(item.S));
|
||||||
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
|
assert.deepEqual(adminRecord.acne_scoring,result.vm.cats.acne.vars.acne_scoring);
|
||||||
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
|
assert.deepEqual(adminRecord.tone_scoring,result.vm.cats.tone.vars.tone_scoring);
|
||||||
assert.equal(adminRecord.vita_score_rule,'independent-color-water-smooth-min-v1');
|
assert.equal(adminRecord.vita_score_rule,'combined-tone-smooth-min-v2');
|
||||||
assert.equal(Object.keys(adminRecord.sub_scores).length,7);
|
assert.equal(Object.keys(adminRecord.sub_scores).length,7);
|
||||||
assert.equal(adminRecord.dims.vita.P,Math.min(...['color','water','smooth'].map(k=>adminRecord.sub_scores[k].P).filter(v=>v!=null)));
|
assert.equal(adminRecord.dims.vita.P,Math.min(...['tone','smooth'].map(k=>adminRecord.cats[k].P).filter(v=>v!=null)));
|
||||||
if(process.env.H5_ACNE_MAX_TEST==='1') {
|
if(process.env.H5_ACNE_MAX_TEST==='1') {
|
||||||
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
|
assert.deepEqual(result.vm.cats.acne.vars.acne_counts,{closed_comedones:5,acne:1,acne_pustule:0,acne_nodule:0,acne_mark:4});
|
||||||
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
|
assert.equal(adminRecord.acne_scoring.deduction,13);assert.equal(adminRecord.cats.acne.P,87);
|
||||||
@@ -95,6 +104,7 @@ try {
|
|||||||
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
|
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
|
||||||
assert.equal(snapshot.report.photos.length, 3);
|
assert.equal(snapshot.report.photos.length, 3);
|
||||||
assert.deepEqual(snapshot.report.presentation, result.presentation);
|
assert.deepEqual(snapshot.report.presentation, result.presentation);
|
||||||
|
assert.deepEqual(snapshot.report.payload.wrinkle_views,result.wrinkle_views);
|
||||||
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
|
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
|
||||||
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
|
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
|
||||||
assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views.
|
assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const assert=require('node:assert/strict'),S=require('../js/skin-score');
|
const assert=require('node:assert/strict'),S=require('../js/skin-score');
|
||||||
function run(melanin,water,dark){const r=structuredClone(S.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:melanin,water_score:water,dark_circle_score:dark});r.melasma={value:0};r.freckle={value:0};r.water={water_severity:3};return S.buildViewModel(r,S.MOCK_SIDE,{});}
|
function run(melanin,water,dark){const r=structuredClone(S.MOCK_RESULT);Object.assign(r.score_info,{melanin_score:melanin,water_score:water,dark_circle_score:dark});r.melasma={value:0};r.freckle={value:0};r.water={water_severity:3};return S.buildViewModel(r,S.MOCK_SIDE,{});}
|
||||||
for(const [m,w,d]of[[66,97,30],[99,20,99],[99,99,99],[null,97,null],[null,null,null]]){
|
for(const [m,w,d]of[[66,97,30],[99,20,99],[99,99,99],[null,97,null],[null,null,null]]){
|
||||||
const v=run(m,w,d),vars=v.cats.tone.vars,values=[v.cats.smooth.P,vars.color_score,vars.tone_scoring&&vars.tone_scoring.water_adjusted_score].filter(x=>typeof x==='number'&&Number.isFinite(x));
|
const v=run(m,w,d),values=[v.cats.smooth.P,v.cats.tone.P].filter(x=>typeof x==='number'&&Number.isFinite(x));
|
||||||
assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'independent-color-water-smooth-min-v1');assert.equal(v.total,S.weightedTotal(v.dims));
|
assert.equal(v.dims.vita.P,Math.min(...values));assert.equal(v.vitaScoreRule,'combined-tone-smooth-min-v2');assert.equal(v.total,S.weightedTotal(v.dims));
|
||||||
|
assert.equal(v.dims.firm.P,Math.min(v.cats.contour.P,v.cats.wrinkles.P));assert.equal(v.dims.stab.P,Math.min(v.cats.sens.P,v.cats.acne.P));
|
||||||
}
|
}
|
||||||
assert(Math.abs(run(66,97,30).dims.vita.P-58.8)<1e-9);
|
assert(Math.abs(run(66,97,30).cats.tone.P-77.9)<1e-9);
|
||||||
console.log('PASS independent vita min: color/water/smooth bottleneck, missing inputs, version and weighted total');
|
console.log('PASS restored dimension minima: combined tone/smooth, firm/stability unchanged, missing inputs, version and weighted total');
|
||||||
|
|||||||
+11
-5
@@ -8,15 +8,21 @@ function run(score,severity=3,pigment=66,dc=30){
|
|||||||
return{r,vm,presentation,water:presentation.views.find(v=>v.key==='water')};
|
return{r,vm,presentation,water:presentation.views.find(v=>v.key==='water')};
|
||||||
}
|
}
|
||||||
const example=run(97);assert.equal(example.vm.cats.tone.P,77.9);assert.equal(example.water.P,97);assert.equal(example.water.level,'优秀');
|
const example=run(97);assert.equal(example.vm.cats.tone.P,77.9);assert.equal(example.water.P,97);assert.equal(example.water.level,'优秀');
|
||||||
assert(!/含水量偏低|缺水比较严重|水润度不足/.test(example.water.text));assert(example.water.text.includes('97'));
|
assert.equal(example.water.text,'角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。');
|
||||||
assert.deepEqual(example.water.advice,Copy.care.water['优秀']);
|
assert.deepEqual(example.water.advice,Copy.care.water['优秀']);
|
||||||
assert(!example.presentation.care.some(v=>v.name==='水分'),'healthy water97 should not displace a weaker item in global bottom three');
|
assert(!example.presentation.care.some(v=>v.name==='水分'),'healthy water97 should not displace a weaker item in global bottom three');
|
||||||
for(const[p,level]of[[0,'改善'],[30,'改善'],[40,'良好'],[60,'良好'],[65,'优秀'],[79.99,'优秀'],[80,'优秀'],[90,'优秀'],[100,'优秀']]){
|
for(const[p,level]of[[0,'改善'],[30,'改善'],[40,'良好'],[60,'良好'],[65,'优秀'],[79.99,'优秀'],[80,'优秀'],[90,'优秀'],[100,'优秀']]){
|
||||||
for(const pigment of[0,50,100]){const x=run(p,3,pigment,0);assert.equal(x.water.level,level);assert.equal(x.water.P,p);assert.deepEqual(x.water.advice,Copy.care.water[level]);}
|
for(const pigment of[0,50,100]){const x=run(p,3,pigment,0);assert.equal(x.water.level,level);assert.equal(x.water.P,p);assert.deepEqual(x.water.advice,Copy.care.water[level]);}
|
||||||
}
|
}
|
||||||
const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert(crossed.water.text.includes('分数与缺水指标不一致'));assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30);
|
const crossed=run(97,70);assert.equal(crossed.water.P,30);assert.equal(crossed.water.level,'改善');assert.equal(crossed.vm.cats.tone.vars.tone_scoring.water_adjusted_score,30);
|
||||||
assert(run(20,70).water.text.includes('缺水程度较高'));
|
assert.equal(crossed.water.text,'角质层含水量较低,缺水面积 3%、肌肤缺水比较严重 70,肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。');
|
||||||
assert(!run(20,3).water.text.includes('程度较高'));
|
assert.equal(run(40).water.text,'角质层含水量适中,缺水面积 3%、肌肤含水量处于健康水平,整体水润度良好。');
|
||||||
assert(!run(20,null).water.text.includes('严重度'));
|
assert(run(20,3).water.text.includes('肌肤缺水比较严重 3'));
|
||||||
|
assert(!run(20,null).water.text.includes('肌肤缺水比较严重'));
|
||||||
|
const old=structuredClone(example.presentation);old.copyRule='wrinkles-excellent-acne-detected-v1';old.views.find(v=>v.key==='water').text='水分分 97;旧水分文案';
|
||||||
|
const savedBefore=JSON.stringify(old),updated=UI.forDisplay(example.vm,example.r,old);
|
||||||
|
assert.equal(updated.views.find(v=>v.key==='water').text,example.water.text);
|
||||||
|
assert.equal(JSON.stringify(old),savedBefore,'history snapshot not rewritten');assert.deepEqual(updated.product,old.product);
|
||||||
|
for(const v of updated.views.filter(v=>v.key!=='water'))assert.equal(v.text,old.views.find(x=>x.key===v.key).text,'other conclusions unchanged');
|
||||||
for(const score of[null,undefined,-1,101,NaN,Infinity]){const x=run(score);assert.equal(x.water.valid,false);assert.equal(x.water.advice,null);assert(!x.presentation.care.some(v=>v.name==='水分'));}
|
for(const score of[null,undefined,-1,101,NaN,Infinity]){const x=run(score);assert.equal(x.water.valid,false);assert.equal(x.water.advice,null);assert(!x.presentation.care.some(v=>v.name==='水分'));}
|
||||||
console.log('PASS water: independent copy/advice, displayed 8/9 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');
|
console.log('PASS water: independent copy/advice, displayed 8/9 boundaries, true severity, existing cross-check, missing/invalid, scoring immutable');
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Deterministic API payloads; no network writes or upstream detection.
|
||||||
|
import assert from 'node:assert/strict';import fs from 'node:fs';
|
||||||
|
import{execSync}from'node:child_process';import{pathToFileURL}from'node:url';
|
||||||
|
const{chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
|
||||||
|
const b=await chromium.launch();
|
||||||
|
try{
|
||||||
|
const p=await b.newPage({viewport:{width:390,height:1100},isMobile:true,hasTouch:true});const errors=[];p.on('pageerror',e=>errors.push(e.message));
|
||||||
|
await p.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});});
|
||||||
|
const source=fs.readFileSync(new URL('../js/app.js',import.meta.url),'utf8');
|
||||||
|
await p.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
|
||||||
|
window.__wrinkleTest=function(x){var fx=MOCK_FIXTURE,raw=JSON.parse(JSON.stringify(fx.result));
|
||||||
|
if(x.noFrontMap)delete raw.face_maps.texture_enhanced_lines;
|
||||||
|
var vm=SkinScore.buildViewModel(raw,fx.side,{});
|
||||||
|
renderReport(vm,fx.photos[0],null,{result:raw,side:fx.side,wrinkle_views:x.views,face_rectangle:fx.face_rectangle},[fx.photos[0],x.left,x.right]);
|
||||||
|
showScreen('s-report');setDetailTab(document.querySelector('.card-detail'),'1',true);
|
||||||
|
};})();`)}));
|
||||||
|
await p.goto((process.env.H5_BASE_URL||'http://127.0.0.1:8080/')+'?mock=1&screen=report');
|
||||||
|
const svg=body=>'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">'+body+'</svg>');
|
||||||
|
const left=svg('<rect width="320" height="240" fill="#e6bba2"/>'),right=svg('<rect width="320" height="240" fill="#dcb196"/>');
|
||||||
|
const map=color=>svg('<path d="M0 80 L320 80 M0 140 L320 140" stroke="'+color+'" stroke-width="8"/>');
|
||||||
|
const entry=color=>({status:'ready',result:{face_maps:{texture_enhanced_lines:map(color)}}});
|
||||||
|
for(const width of[320,375,414,480]){
|
||||||
|
await p.setViewportSize({width,height:1100});
|
||||||
|
await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{left:entry('red'),right:entry('blue')}});
|
||||||
|
await p.waitForFunction(()=>['wrinkle-left','wrinkle-right'].every(id=>{const i=document.getElementById(id);return i.naturalWidth>0&&!i.closest('figure').hidden&&i.dataset.hasAnnotation==='true';}));
|
||||||
|
assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden));
|
||||||
|
assert(await p.locator('#ditem-img2').evaluate(e=>e.dataset.hasAnnotation==='true'));
|
||||||
|
const layout=await p.evaluate(()=>{
|
||||||
|
const styles=e=>{const s=getComputedStyle(e);return{width:s.width,height:s.height,marginBottom:s.marginBottom,transform:s.transform,objectFit:s.objectFit,objectPosition:s.objectPosition};};
|
||||||
|
return{contour:styles(document.querySelector('#ditem-img1').parentElement),wrinkle:styles(document.querySelector('#ditem-img2').parentElement),frontA:styles(document.querySelector('#ditem-img1')),frontB:styles(document.querySelector('#ditem-img2')),sideA:styles(document.querySelector('#contour-left')),sideB:styles(document.querySelector('#wrinkle-left')),offsetA:document.querySelector('#ditem-img1').dataset.contentOffset,offsetB:document.querySelector('#ditem-img2').dataset.contentOffset};
|
||||||
|
});
|
||||||
|
assert.deepEqual(layout.contour,layout.wrinkle,'same front frame and spacing');
|
||||||
|
assert.deepEqual(layout.frontA,layout.frontB,'same front image size');
|
||||||
|
assert.deepEqual(layout.sideA,layout.sideB,'same side image scaling and positioning');
|
||||||
|
assert.equal(layout.offsetA,layout.offsetB,'same face crop offset; overlay moves with photo');
|
||||||
|
const leftSrc=await p.locator('#wrinkle-left').getAttribute('src'),rightSrc=await p.locator('#wrinkle-right').getAttribute('src');
|
||||||
|
assert.notEqual(leftSrc,left);assert.notEqual(rightSrc,right);assert.notEqual(leftSrc,rightSrc);
|
||||||
|
await p.getByRole('button',{name:'查看左脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),leftSrc);await p.locator('#p-zoom').click();
|
||||||
|
await p.getByRole('button',{name:'查看右脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),rightSrc);await p.locator('#p-zoom').click();
|
||||||
|
}
|
||||||
|
await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{}});
|
||||||
|
await p.waitForFunction(()=>!document.querySelector('#wrinkle-left').closest('figure').hidden&&!document.querySelector('#wrinkle-right').closest('figure').hidden);
|
||||||
|
assert.equal(await p.locator('#wrinkle-left').getAttribute('src'),left,'no front-face map projected onto unannotated side');
|
||||||
|
assert(await p.locator('#wrinkle-layer-note').isVisible());
|
||||||
|
const marks={left_cheek_wrinkle_outline:[{x:10,y:10},{x:200,y:10},{x:200,y:80}]};
|
||||||
|
await p.evaluate(x=>window.__wrinkleTest(x),{left,right:null,noFrontMap:true,views:{left:{status:'ready',result:{wrinkle_mark:marks}}}});
|
||||||
|
await p.waitForFunction(()=>document.querySelector('#wrinkle-left').dataset.hasAnnotation==='true'&&document.querySelector('#ditem-img2').dataset.hasAnnotation==='true');
|
||||||
|
assert(await p.locator('#wrinkle-right').evaluate(e=>e.closest('figure').hidden&&!e.hasAttribute('src')));
|
||||||
|
await p.evaluate(x=>window.__wrinkleTest(x),{left:null,right:null,views:{}});
|
||||||
|
assert(await p.locator('#wrinkle-sides').evaluate(e=>e.hidden));assert(await p.locator('#wrinkle-layer-note').evaluate(e=>e.hidden));
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
console.log('PASS wrinkle three views: 4 widths, own overlays, annotated zoom, front overlay preserved, missing layer/photo, real-coordinate fallback, no false projection and stale clearing');
|
||||||
|
}finally{await b.close();}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('node:assert/strict'), Views=require('../lib/wrinkle-views');
|
||||||
|
const side=label=>({request_id:label,result:{score_info:{wrinkle_score:1},face_maps:{texture_enhanced_lines:label+'-map',red_area:'do-not-forward'},wrinkle_mark:{left_cheek_wrinkle_outline:[{x:1,y:1},{x:2,y:1},{x:2,y:2}]}}});
|
||||||
|
const photos={left:Buffer.from('left photo').toString('base64'),right:Buffer.from('right photo').toString('base64')};
|
||||||
|
(async()=>{
|
||||||
|
const original={result:{score_info:{wrinkle_score:76}},left_side_result:{left_jawline_info:{left_jawline_angle:112}}};
|
||||||
|
const before=JSON.stringify(original),calls=[];
|
||||||
|
const options={key:'fixture',secret:'fixture',url:'https://fixture.invalid',sleep:async()=>{},now:()=>0,fetch:async(url,o)=>{
|
||||||
|
assert.equal(o.method,'POST');const form=o.body;
|
||||||
|
assert.equal(form.get('return_maps'),'texture_enhanced_lines');assert.equal(form.get('return_marks'),'wrinkle_mark');
|
||||||
|
assert(!form.has('left_side_image_file')&&!form.has('right_side_image_file'));
|
||||||
|
const file=form.get('image_file'),label=file.name.split('.')[0];calls.push(label);
|
||||||
|
assert.equal(await file.text(),label+' photo');
|
||||||
|
return{ok:true,status:200,json:async()=>side(label)};
|
||||||
|
}};
|
||||||
|
const views=await Views.collect(original,photos,options);
|
||||||
|
assert.deepEqual(calls,['left','right']);assert.equal(JSON.stringify(original),before);
|
||||||
|
assert.equal(views.left.status,'ready');assert.equal(views.right.status,'ready');
|
||||||
|
const report=Views.forReport(views);
|
||||||
|
assert.equal(report.left.result.face_maps.texture_enhanced_lines,'left-map');
|
||||||
|
assert.equal(report.right.result.face_maps.texture_enhanced_lines,'right-map');
|
||||||
|
assert(!report.left.result.score_info&&!report.left.result.face_maps.red_area);
|
||||||
|
const mixed=await Views.collect(original,photos,{...options,fetch:async(url,o)=>({ok:true,status:200,json:async()=>o.body.get('image_file').name==='left.jpg'?{error_message:'INVALID_IMAGE_FACE: test'}:side('right')})});
|
||||||
|
assert.equal(mixed.left.status,'unavailable');assert.equal(mixed.left.error_code,'INVALID_IMAGE_FACE');assert.equal(mixed.right.status,'ready');
|
||||||
|
assert(!Views.forReport(mixed).left.result);
|
||||||
|
const deadline=await Views.collect(original,photos,{...options,deadline:1,gapMs:4,fetch:()=>{throw Error('must not call');}});
|
||||||
|
assert.equal(deadline.left.error_code,'WRINKLE_TIME_BUDGET');assert.equal(deadline.right.error_code,'WRINKLE_TIME_BUDGET');
|
||||||
|
const missing=await Views.collect(original,{},options);assert.equal(missing.left.error_code,'MISSING_SIDE_PHOTO');
|
||||||
|
const reused=await Views.collect({left_side_result:side('cached').result},photos,{...options,fetch:async()=>({ok:true,json:async()=>side('right')})});
|
||||||
|
assert.equal(reused.left.source,'primary-side-result');assert.equal(reused.right.source,'independent-side-request');
|
||||||
|
const failed=await Views.collect(original,photos,{...options,fetch:async()=>{const e=new Error();e.name='TimeoutError';throw e;}});
|
||||||
|
assert.equal(failed.left.error_code,'WRINKLE_TIMEOUT');assert.equal(failed.right.error_code,'WRINKLE_TIMEOUT');
|
||||||
|
console.log('PASS wrinkle views: own photo requests, sequential order, separate maps, no score mutation/forwarding, native reuse, missing/API failure/timeout/budget and report-safe payload');
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
@@ -50,13 +50,13 @@
|
|||||||
|
|
||||||
#### 5\.5 水分(透亮感)
|
#### 5\.5 水分(透亮感)
|
||||||
|
|
||||||
**【改善】**水分分 \{water\_score\};缺水面积 \{water\_area\}%,\{water\_severity\_note\}肌肤水润度有待提升,建议加强补水与保湿。
|
**【改善】**角质层含水量较低,缺水面积 \{water\_area\}%、肌肤缺水比较严重 \{water\_sev\},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。
|
||||||
|
|
||||||
**【良好】**水分分 \{water\_score\};缺水面积 \{water\_area\}%、肌肤含水量处于健康水平,整体水润度良好。
|
**【良好】**角质层含水量适中,缺水面积 \{water\_area\}%、肌肤含水量处于健康水平,整体水润度良好。
|
||||||
|
|
||||||
**【优秀】** 水分分 \{water\_score\};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
|
**【优秀】** 角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
|
||||||
|
|
||||||
水分分档补充(2026-10-15):文案及水分护理建议独立使用水分项分档,低于80为改善、80至低于90为良好、90及以上为优秀,不再跟随肤色水润合成分。沿用现有水分交叉校验:水分分≥80且缺水严重度≥60时,水分项按100减严重度评估,并在文案中明确提示分数与缺水指标不一致。\{water\_severity\_note\}仅在严重度为0~100的有效数值时展示:≥60描述缺水程度较高,其他值仅报告严重度数值;缺失时省略。维度评分、综合评分、推荐及护理类别选取不变。
|
水分文案更新(2026-10-15):三个档位采用以上原文,不额外添加水分分数或分数与缺水指标不一致的提示。缺失的面积注明未获取,缺失的严重度省略对应短句,不填造数值。计分、交叉校验和分档规则保持现有代码不变;水分页卡及护理建议仍按独立水分项的展示分数分档(低于8为改善,8至低于9为良好,9及以上为优秀)。
|
||||||
|
|
||||||
#### 5\.6 泛红(稳韧力)
|
#### 5\.6 泛红(稳韧力)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user