跟新需求文档

This commit is contained in:
xsl
2026-09-26 19:50:39 +08:00
parent ab9a39a8f3
commit 71a090fd75
26 changed files with 41746 additions and 712 deletions
+2
View File
@@ -3,6 +3,8 @@ apikey.bash
apikey.*
.env
.env.*
*.env
credentials.env
# 运行时产物
node_modules/
@@ -1,336 +0,0 @@
# 三维度评分与话术生成需求文档_V2.0
巨子测肤·三维度评分与话术生成需求文档
__版本 V2\.0 | 2026\-09\-26 | 数据来源:旷视FaceStyle智能测肤专业版 V1\.6\.3(skinanalyze\_pro,正式key)__
变更说明(V1\.2→V2\.0):①评分体系由"六维度"重构为"三维度×6分类"(紧塑力/活力感/稳定力),原衰老维度拆解归位,痘痘升级为正式评分分类;②产品分组改为按实际年龄分段(用户拍板,偏离V1\.2的skin\_age口径,已在第四章显著标注);③精华推荐由"均分单判据"升级为"三重判据取最强档",修复均分稀释短板缺陷;④新增三视图(正脸\+双侧脸)调用规范与下颌线评分规则;⑤全文自包含——6分类话术库、护理建议库、日常肤质映射库全部完整文本内嵌。
# 一、接口调用规范
每次调用必须带全可选参数(实测不带会导致 sensitivity 对象缺失、检测图不返回):
POST https://api\-cn\.faceplusplus\.com/facepp/v1/skinanalyze\_pro
\- image\_file:正脸图(必传,JPG/PNG,人脸框≥400px、yaw≤±30°、清晰度合格)
\- return\_maps=water\_area,red\_area,brown\_area,texture\_enhanced\_pores,texture\_enhanced\_blackheads,texture\_enhanced\_oily\_area,texture\_enhanced\_lines,rough\_area,roi\_outline\_map,texture\_enhanced\_bw
\- return\_marks=wrinkle\_mark,melanin\_mark,dark\_circle\_outline
三视图调用(本章新增):
\- left\_side\_image\_file / right\_side\_image\_file:左右侧脸图(可选,接口不校验左右是否传反,建议两侧都传)
\- return\_side\_results=jawline\_info:返回下颌线信息(在响应顶层 left\_side\_result / right\_side\_result,不在 result 内)
__返回块__
__字段__
__说明__
left\_side\_result\.left\_jawline\_info
left\_jawline\_angle
左下颌线角度,标准区间116–120°
同上
left\_jawline\_angle\_level
0=标准 / 1=不标准
同上
left\_jawline\_quality
侧脸质量:1=有效(20°~120°)/2=角度<20°/3=>120°/4=无人脸/5=无效人脸/6=其它;仅=1时角度才有校验意义
right\_side\_result\.right\_jawline\_info
right\_jawline\_angle 等
同上,右侧
实测注意事项:① roi\_outline\_color 参数实测报 BAD\_ARGUMENTS,弃用;② left/right\_roi\_outline\_map 请求后不返回;③ jawline 仅在有侧脸输入且 return\_side\_results=jawline\_info 时返回;④ 不带 return\_maps 时 sensitivity 对象随机缺失;⑤ 正式key也有并发窗口,串行调用\+间隔4秒,偶发 CONCURRENCY\_LIMIT\_EXCEEDED 自动重试1次;⑥ 返回图 face\_maps 为 base64(red\_area为JPEG头,其余PNG),需解码后嵌入报告。
计费:按次消耗次数包。调用前必须走图片预校验,不合规直接拦截重拍。正脸/侧脸判定依据 image\_quality\.face\_orientation\.yaw:|yaw|<10°为正脸,±40–50°为侧脸。
# 二、三维度×6分类 P 分计算规则(0–100)
__维度__
__分类__
__公式__
__修正规则(上限锁)__
__字段来源__
紧塑力 firming
轮廓 firmness\_contour
P = min\(眼袋severity映射分, 下颌线角度分\)。眼袋映射:0→100/1→90/2→79/3→74;下颌线角度分=100−|偏离标准区间116–120°|×2,下限60,左右取较差侧
无(映射自带)
eye\_pouch\_severity\.value;left/right\_side\_result\.\*\_jawline\_info\.\*\_jawline\_angle
紧塑力 firming
皱纹 wrinkles
P = 100 − max\(12分区 wrinkle\_score\)
分区 severity≥2→上限79;≥3→上限74
12个 \*\_wrinkle\_info\.wrinkle\_score / wrinkle\_severity\_level
活力感 vitality
光滑 smoothness
P = 0\.4×min\(4分区 pores\_type\_score\) \+ 0\.3×blackhead\_score \+ 0\.3×rough\_score\(纹理分\)
任一分区毛孔 severity≥2→上限79
score\_info\.pores\_type\_score\.\*、blackhead\_score、rough\_score
活力感 vitality
肤色水润 tone\_hydration
P = 0\.5×\(melanin\_score−melasma×10−freckle×5\) \+ 0\.5×water\_score
water\_severity≥60 且 water\_score≥80→水分项按 severity 换算
score\_info\.melanin\_score、melasma\.value、freckle\.value、water\_score、water\.water\_severity
稳定力 stability
敏感 sensitivity
P = min\(sensitivity\_score, 100−敏感面积%×1\.5\)
面积≥15%→上限79;≥30%→上限74(面积用百分比口径)
score\_info\.sensitivity\_score、red\_spot\.red\_spot\_area(小数×100=%)
稳定力 stability
痘痘 acne
P = min\(acne\_score分级映射, 100−计数扣分\)。分级映射:≥95→100/85–94→85/75–84→70/<75→55;扣分=闭口×1\+丘疹×2\+脓包×4\+结节×6\+痘印×1\.5,封顶50
无
score\_info\.acne\_score、closed\_comedones\.count、acne\.count\(丘疹\)、acne\_pustule\.count、acne\_nodule\.count、acne\_mark\.count
__维度级汇总(木桶口径):紧塑力P = min\(轮廓P, 皱纹P\);活力感P = min\(光滑P, 肤色水润P\);稳定力P = min\(敏感P, 痘痘P\)。__
待客户确认项:①痘痘P公式为本版自设计(接口无"痤疮分级severity"字段,以acne\_score分档替代),无文档背书;②下颌线角度映射系数(每偏离1°扣2分、下限60)为自设计;③光滑第三因子由V1\.2的skin\_type\_score改为rough\_score(接口表"光滑"清单无肤质分)。
兼容性提醒:不同批次返回的毛孔/皱纹分区字段命名存在变体(pores\_type\_score 嵌套在 score\_info 内、分区键 left\_nasolabial\_wrinkle\_info 与 left\_nasolabial\_fold\_info 并存),适配层必须做字段归一映射。
# 三、S 分与档位
S = 7 \+ 3×P/100(保留两位小数)。档位判定一律以 P 为准(含上限锁后):P<80 预警档(S 7\.0–7\.9);80≤P<90 改善档(S 8\.0–8\.9);P≥90 良好档(S 9\.0–10\.0)。判档必须使用未四舍五入的 P 原值(历史缺陷:先四舍五入到9\.0再判档,曾把8\.9967错挂25\+束紧)。
# 四、产品分组算法与精华推荐(重点)
__4\.1 分组口径(用户拍板,偏离V1\.2)__
按实际年龄 real\_age 分段:<32岁→25\+组;32–45岁→35\+组;>45岁→50\+组。
⚠️ 显著偏离说明:V1\.2 第四章规定"分组按 skin\_age 定且 skin\_age 不得改写,边界±3岁模糊带才允许R1–R4跨组修正"。V2\.0 改按实际年龄分段(业务方拍板)。skin\_age 数值本身仍不被改写,仅作为参考读数展示。上线前需与客户书面确认此口径变更。
__4\.2 肌肤年龄钳制__
为满足"肌肤年龄与真实年龄差不超过±3岁"的展示约束:肌肤年龄\(展示值\) = min\(max\(skin\_age, 实际年龄−3\), 实际年龄\+3\);超出即截断到边界,并输出钳制标记"触发\(原X→Y\)"。skin\_age 原始值在任何逻辑下不被改写。
__4\.3 精华推荐·三重判据取最强档(V2\.0修正,核心)__
__判据__
__规则__
__说明__
① 均分档
六分类S均分(未四舍五入原值)≥9→25\+束紧 / 8–9→35\+支撑 / <8→50\+重塑
V1\.2 原口径
② 最差项约束
最差分类 S<7→50\+重塑 / 7≤S<8→至少35\+支撑 / S≥8→不约束
防均分稀释短板:皱纹P=50的人均分仍可达9\.5,单看均分会错推最年轻产品线
③ 年龄组档
产品组别 25\+/35\+/50\+组 → 对应档
按实际年龄分段(4\.1)
__最终推荐 = max\(①②③\),推荐强度只升不降。报告页须展示命中的判据(如"触发:年龄组档")。__
历史缺陷记录:V1\.2 均分单判据下,实测 50 岁肌龄、皱纹P=50、最差S=8\.5 的案例因均分9\.54 被推"25\+束紧精华"——业务不可接受,此为本章修正的直接原因。
# 五、6分类话术库(全量文本,按档位 if\-else 拼接)
生成规则:按分类 P 档位选中话术主体,替换 \{\} 变量为当次实测值,输出100–150字。变量缺失时省略对应分句,禁止输出"None/—"占位(历史缺陷:enlarged\_pore\_count 为dict被当int取,输出"粗大毛孔None颗")。
变量表:\{skin\_age\}肤龄、\{la\}/\{ra\}左右下颌线角度、\{eye\_pouch\}眼袋级、\{dark\_circle\_sev\}黑眼圈级、\{wr\_cnt\}全脸皱纹条数、\{fl\_cnt\}细纹条数、\{worst\_wr\_region\}最差皱纹分区、\{wr\_max\}该区分区分、\{max\_sev\}最高严重度、\{worst\_pore\_region\}最差毛孔区、\{pore4\_min\}该区毛孔分、\{pore\_cnt\}粗大毛孔总数\(4分区求和\)、\{blackhead\_cnt\}黑头数、\{tex\}纹理分、\{ita\}ITA数值、\{st\}肤色分类、\{ha\}HA值、\{melanin\}色素分、\{spot\_flag\}色斑标记句、\{bs\_cnt\}褐色斑数、\{mole\_cnt\}痣数、\{water\_score\}水分分、\{water\_area\}缺水面积%、\{water\_sev\}缺水严重度、\{sens\_area\}泛红面积%、\{sens\_score\}红区分、\{acne\_cnt\}皮损合计、\{acne\_detail\}皮损明细句、\{skin\_type\_cn\}肤质类型、\{oil\_desc\}出油概览句。
## 5\.1 轮廓(紧塑力)
【预警】"面部轮廓出现可见松弛,下颌线角度左\{la\}°/右\{ra\}°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋\{eye\_pouch\}级、黑眼圈\{dark\_circle\_sev\}级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。"
【改善】"下颌线角度左\{la\}°/右\{ra\}°(标准区间116–120°),眼袋\{eye\_pouch\}级,眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。"
【良好】"面部轮廓紧致立体,下颌线角度左\{la\}°/右\{ra\}°(处于标准区间),眼袋\{eye\_pouch\}级、黑眼圈\{dark\_circle\_sev\}级,中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。"
## 5\.2 皱纹(紧塑力)
【预警】"全脸皱纹\{wr\_cnt\}条、细纹\{fl\_cnt\}条,最差区域为\{worst\_wr\_region\}(分区分\{wr\_max\},最高严重度\{max\_sev\}级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。"
【改善】"全脸皱纹\{wr\_cnt\}条、细纹\{fl\_cnt\}条,最差区域为\{worst\_wr\_region\}(分区分\{wr\_max\},严重度\{max\_sev\}级)。建议加强日常淡纹与弹润护理,保持肌肤滋润度。"
【良好】"全脸12分区纹路平整(最差区域\{worst\_wr\_region\},分区分\{wr\_max\}),细纹合计\{fl\_cnt\}条,未见明显静态纹。建议维持日常保湿与防晒,保持现有淡纹护理节奏。"
## 5\.3 光滑(活力感)
【预警】"皮肤表面平整度较低,最差毛孔区位于\{worst\_pore\_region\}(分区毛孔分\{pore4\_min\}),粗大毛孔\{pore\_cnt\}颗、黑头\{blackhead\_cnt\}颗,\{oil\_desc\}纹理分仅\{tex\},整体细腻度不足。建议避免过度清洁,重点关注\{worst\_pore\_region\},配合低浓度酸类周期护理与柔润精华。"
【改善】"皮肤表面整体较为平滑,毛孔最差区为\{worst\_pore\_region\}(分区毛孔分\{pore4\_min\}),粗大毛孔\{pore\_cnt\}颗、黑头\{blackhead\_cnt\}颗,\{oil\_desc\}纹理分\{tex\},局部存在轻微角质粗糙感。建议加强基础清洁与低浓度酸类周期护理。"
【良好】"皮肤表面细腻平滑,最差毛孔区\{worst\_pore\_region\}控制良好(分区毛孔分\{pore4\_min\},粗大毛孔\{pore\_cnt\}颗、黑头\{blackhead\_cnt\}颗),纹理分\{tex\},角质层排列整齐。建议维持温和清洁与保湿节奏。"
## 5\.4 肤色水润(活力感)
【预警】"肤色ITA \{ita\}(\{st\},HA \{ha\}),色素分\{melanin\},整体偏暗沉(\{spot\_flag\});水分分\{water\_score\},缺水面积\{water\_area\}%、严重度\{water\_sev\},水润度不足。建议加强透亮与补水护理,做好防晒,减少色素刺激。"
【改善】"肤色ITA \{ita\}(\{st\},HA \{ha\}),色素分\{melanin\},整体稳定但存在局部暗沉倾向(\{spot\_flag\});水分分\{water\_score\},缺水面积\{water\_area\}%、严重度\{water\_sev\}。建议加强抗氧化与光泽管理,做好基础防晒与补水。"
【良好】"肤色ITA \{ita\}(\{st\},HA \{ha\}),色素分\{melanin\},肤色均匀透亮(\{spot\_flag\});水分分\{water\_score\},缺水面积\{water\_area\}%、严重度\{water\_sev\}。色素与水润状态均衡,建议持续做好防晒与基础保湿。"
## 5\.5 敏感(稳定力)
【预警】"泛红面积达\{sens\_area\}%,红区分\{sens\_score\},屏障受损信号明显、敏感区域较大。建议立即转为舒缓修护路线,停用酸类与强清洁,必要时寻求专业建议。"
【改善】"泛红面积\{sens\_area\}%,红区分\{sens\_score\},存在局部轻微泛红与屏障波动。建议使用舒缓修护类产品,减少刺激源,注意温差与清洁力度。"
【良好】"泛红面积\{sens\_area\}%,红区分\{sens\_score\},屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。"
## 5\.6 痘痘(稳定力)
【预警】"检出\{acne\_cnt\}处皮损(\{acne\_detail\})\{heavy\_note\}。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。注:\{heavy\_note\}仅在真实检出脓包/结节时输出"含脓包、结节等中重度皮损,炎症反应明显",禁止模板硬套。"
【改善】"检出\{acne\_cnt\}处皮损(\{acne\_detail\}),以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。"
【良好】"未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。"
变量拼接规则:\{spot\_flag\}=检出黄褐斑和/或雀斑时输出"黄褐斑、雀斑"(只检出一种只写那一种),均未检出输出"未见明显色斑标记";计数为0的分句整体省略(如痣0处不出现);\{acne\_detail\}按实际计数列明细"闭口粉刺N处、丘疹N处、脓包N处、结节N处、痘印N处",为0的类型不列。
# 六、综合文案、生活方式与护理建议
__6\.1 综合文案模板__
【综合】实际年龄\{real\_age\}岁(接口测得肌肤年龄\{skin\_age\}岁,差\{age\_diff\}岁),产品组别\{group\};六分类S均分\{avg\}、最差分类\{worst\_name\} S=\{worst\_s\}。按三重判据(触发:\{triggers\})推荐\{essence\}:优先改善\{w1\}(P=\{w1\_p\})与\{w2\}(P=\{w2\_p\}),其余维度维持现有护理节奏。整体肤质\{skin\_type\_cn\},\{oil\_desc\}。
精华产品推荐文本(三档全文):
【25\+束紧精华】"建议搭配使用25\+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。"
【35\+支撑精华】"建议搭配使用35\+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。"
【50\+重塑精华】"建议搭配使用50\+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。"
__6\.2 生活方式建议(按最差分类取,并列时按 轮廓>皱纹>光滑>肤色水润>敏感>痘痘 取前者)__
__最差分类__
__生活方式全文__
轮廓
戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深
皱纹
保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳
光滑
规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食
肤色水润
每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏
敏感
避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤
痘痘
不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入
6\.3 护理建议结构与日常肤质映射:插入顺序【肤质定制】→【6分类按档位】→【综合】→【生活方式】。日常肤质 daily\_skin\_type 为用户自选项(混合偏干/干性/中性/油性/混合偏油五类),【肤质定制】段全文沿用 V1\.2 补充章节A映射库(五类全量文本与联动修正规则不变,此处不再重复);肤质不参与任何分数计算,仅影响措辞。
# 七、异常与边界
__场景__
__处理__
NO\_FACE\_FOUND/人脸过小
预校验拦截,引导正脸重拍,不消耗次数包
侧脸质量 jawline\_quality≠1
该侧下颌线角度不参与轮廓评分,报告标注"侧脸角度需20°–120°"引导重拍
仅正脸无侧脸
下颌线角度分不参与,轮廓P=眼袋映射分单独决定;报告标注"建议补拍侧脸视图"
图片质量borderline
正常返回但标记可信度降级,报告页提示重拍
sensitivity缺失
敏感维度文案输出数据缺失提示,禁止输出降级假分数
接口超时/5xx/CONCURRENCY
重试1次(间隔4秒)→失败展示"检测繁忙"
同图多次调用数值漂移
数值型字段应完全一致;不一致记录并反馈旷视
字段命名变体
毛孔/皱纹分区字段做归一映射后再计算
Excel存储
原始JSON禁止直接塞单元格(32767字符截断,历史AQ列事故);表格内用扁平化路径=值清单,完整JSON独立落盘文件
# 八、验收标准
1\) 三维度×6分类 P/S/档位与第二章公式逐项可复算,参数与JSON字段路径一一对应;2\) 维度P=旗下分类min\(\) 木桶口径可复算;3\) 分组按实际年龄分段、精华按三重判据取最强档,且报告页展示触发依据;4\) skin\_age 原值不被改写,钳制仅作用于展示值并留痕;5\) 6分类话术与第五章文本库逐字一致(变量除外),无None/占位残留;6\) 判档一律用未四舍五入原值;7\) 三视图调用下颌线数据完整落表(角度/质量/标准度),无侧脸时按第七章降级;8\) 全部可选参数固定入参;9\) 同一图片重复调用结果逐字一致。
<!-- conversion notes
- Message(type='warning', message='Unrecognised paragraph style: Title (Style ID: Title)')
-->
File diff suppressed because one or more lines are too long
Binary file not shown.
+59 -21
View File
@@ -1,18 +1,21 @@
# 1'me 在线肌肤测试 H5
拍摄正面与左右侧面照片,调用 **Face++ 皮肤分析(高阶版)** 真实检测,即时生成肌肤状态分析与护理建议的移动端 H5。
引导式自动三连拍(正脸/右脸/左脸),调用 **Face++ 皮肤分析(专业版 skinanalyze_pro)** 三视图真实检测,即时生成三维度×6分类肌肤状态分析与个性化护理建议的移动端 H5。
- 前端:原生 HTML/CSS/JS,零依赖、零构建
- 前端:原生 HTML/CSS/JS,零依赖、零构建(face-api.js 已本地化到 vendor/,运行时零外部请求)
- 后端:`server.js`(Node ≥ 18,零依赖)静态托管 + Face++ API 中转,**api_secret 只存在服务端**
## 目录结构
```
h5/
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ 中转)
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层 + 内联 SVG 取景蒙版)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)
├── css/style.css 全部样式(375px 基准)
├── js/app.js 全部交互(屏幕切换、肤质轮播、实时相机、结果映射)
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── vendor/faceapi/ face-api.js 0.22.2 + 模型(本地化,见该目录 README)
├── assets/
│ ├── favicon.svg 站点图标
│ ├── img/ 背景图、肤质插画、拍照底图、报告检测/产品图
@@ -39,20 +42,39 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
# 打开 http://127.0.0.1:8080
```
调试:`http://127.0.0.1:8080/?mock=1` 使用内置样本数据跳过真实检测(离线/自动化验证用)。
调试:
## 检测流程与数据映射
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1&screen=report` 直渲 mock 报告页(**仅调试用**,供无头浏览器截图验证布局;档案缺省按 28 岁/干性肌肤)。
1. 三连拍(正面/右脸/左脸):getUserMedia 实时取景,不支持/被拒时自动降级调起系统相机
2. **仅正面照**送检(Face++ 高阶版只接受正脸,yaw>45° 会报错)
3. 照片压缩到最长边 1280 JPEG 后 POST `/api/skin-analyze`,服务端转发 Face++ `skinanalyze_advanced`
4. 映射层(`mapSkinResult`):设计稿的 紧塑力/活力感/稳定力/综合评分 由真实维度合成——
- 紧塑力 ← 法令纹(+严重度)、眼袋(+严重度)
- 活力感 ← 黑眼圈、眼部细纹、鱼尾纹、抬头纹、眉间纹
- 稳定力 ← 敏感泛红(面积/强度)、痘痘、闭口、毛孔、黑头
- 肌肤年龄 = Face++ `skin_age` 真实值;综合评分 = 三维加权(0.35/0.33/0.32)×10
- 具体描述文案按检出项动态生成;报告第一/二张检测图使用用户实拍照片
5. 检测失败(无人脸/距离远/网络等)按错误码给出友好提示并退回档案页
## 拍照交互(引导式自动三连拍)
三步:1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向左转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向右转,target `left`,产物作 `left_image`)。
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸线框(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);框外 58% 黑色半透明压暗(SVG mask 镂空),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:320, scoreThreshold:0.4})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, /* mirrored= */true)`;人脸框高度占比 <35% 提示"请靠近一点"、>80% 提示"请远一点"。提示一次一条,优先级:无人脸 > 距离 > 角度。
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 进入 3s 倒计时(中央大数字,轮廓保持绿色);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(镜像 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
- **无进展提示**:单步 12s 未触发倒计时,底部浮出"识别不到?可点下方快门手动拍摄"(不阻塞流程)。
- **资源管理**:离开拍照页(cam-back/完成)停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次(缓存 promise,失败后下次进入重试)。
## 检测流程与评分渲染
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.0.md` 为最高契约:
- 6 分类:轮廓(眼袋 severity × 下颌线角度)、皱纹(12 分区)、光滑(毛孔/黑头/纹理)、肤色水润(色素×水润)、敏感(泛红面积/红区分)、痘痘(分级映射×计数扣分);
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 非空才展示,如下颌线未参与评分)、综合分析(`vm.summary`)、生活方式建议(`vm.lifestyle`)、精华推荐卡(`vm.essence` + 触发判据);
- 近期优先关注 = 最差维度(`vm.focus`);检测图 img1=正脸实拍、img2=右脸实拍。
5. 检测失败按服务端 message 提示并退回档案页。
## 部署
@@ -62,6 +84,21 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
```
### Windows 本机部署(提测用)
```bat
h5\deploy-local.bat :: 默认 PORT=8088,日志写 h5\server.log
```
登记开机自启(用户级计划任务,需系统允许;若提示"拒绝访问",直接双击 `deploy-local.bat` 或手动运行即可):
```bat
schtasks /create /tn "JuziSkinH5" /tr "\"C:\Windows\System32\cmd.exe\" /c \"D:\juzi\h5\deploy-local.bat\"" /sc onlogon /f
:: 移除: schtasks /delete /tn "JuziSkinH5" /f
```
局域网提测访问 `http://<本机局域网IP>:8088/`。注意:① 首次监听若弹 Windows 防火墙提示需允许 node.exe;② **手机走局域网 HTTP 时 getUserMedia 不可用**,会自动降级为系统相机手动三连拍(分析链路完整),要测引导式自动拍照需在本机浏览器访问 `http://127.0.0.1:8088`,或上 HTTPS。
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
```nginx
@@ -73,7 +110,7 @@ server {
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
client_max_body_size 12m; # 与 server.js 请求上限一致
client_max_body_size 20m; # 与 server.js 请求上限一致
}
}
```
@@ -82,14 +119,15 @@ server {
## 已知占位清单
- 报告页「活力感 / 稳定力」两个 Tab 为占位文案("以实际检测报告为准")。
- 「相关隐私协议」点击弹出的是占位说明层,协议全文待法务提供后接入。
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
- 相册、切换镜头、「了解更多」为开发中占位(Toast 提示)。
- 护理建议为设计稿静态文案,未按检测结果个性化。
- **侧脸角度符号与 yaw 系数需真机校准**:`head-pose.js` 的 `YAW_COEFF`/pitch 映射与 `mirrored` 符号在不同机型/镜像策略下可能相反,上线前用真机样本回归(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
- **face_maps 叠加图未回传前端**:服务端请求了 `return_maps` 但响应只保留键名列表(整幅 base64 体积过大),报告页暂不使用叠加图,需要时再加服务端裁剪/压缩通道。
## 技术说明
- 尺寸换算:设计稿 1080px 基准 ÷ 2.88 = 375px 落地基准。
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/流程令牌(camSeq)防竞态;拍照页 15% 压暗遮罩保证白字可读。
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页 15% 压暗遮罩 + 蒙版 58% 镂空压暗保证白字可读。
- face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。
+112 -9
View File
@@ -100,6 +100,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
display: flex; align-items: center;
padding: 0 6px;
background: rgba(255,255,255,.94);
-webkit-backdrop-filter: blur(8px); /* iOS<15.4 需要前缀 */
backdrop-filter: blur(8px);
}
.nav-dark { background: rgba(20,20,20,.55); }
@@ -239,12 +240,96 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.cam-side img { width: 31px; height: 31px; }
.cam-shutter img { width: 62px; height: 62px; }
.cam-shutter:active { transform: scale(.92); }
.cam-flash {
/* 白屏补光:瞬间点亮(.on 无过渡),移除后淡出(总时长 600–900ms) */
.cam-fill {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 30; /* 盖住导航(z 20) */
transition: opacity .18s ease;
transition: opacity .25s ease;
}
.cam-flash.on { opacity: .92; }
.cam-fill.on { opacity: 1; transition: none; }
/* 步骤圆点:当前步高亮、完成步绿色打勾 */
.cam-dots {
position: absolute; top: calc(var(--nav-h) + 10px); left: 0; right: 0;
display: flex; justify-content: center; gap: 12px;
z-index: 2;
}
.cam-dots i {
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,.35);
position: relative; transition: background .25s;
}
.cam-dots i.on { background: #fff; box-shadow: 0 0 0 3px rgba(255,255,255,.25); }
.cam-dots i.done { background: #34C759; }
.cam-dots i.done::after {
content: "✓"; position: absolute; left: 0; right: 0; top: -1px;
text-align: center; font-size: 8px; line-height: 10px; color: #fff;
}
/* 取景蒙版:SVG mask 镂空压暗(58%) + 轮廓线框,按 data-step 切换轮廓 */
.cam-mask {
position: absolute; inset: 0;
width: 100%; height: 100%;
z-index: 1; pointer-events: none;
}
.cam-mask [data-shape] { display: none; }
.cam-mask[data-step="0"] [data-shape="0"],
.cam-mask[data-step="1"] [data-shape="1"],
.cam-mask[data-step="2"] [data-shape="2"] { display: block; }
.cam-dim-rect { fill: rgba(0,0,0,.58); }
.cam-line path { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 2.5; }
/* 未检测到人脸:轮廓呼吸动画(~1.8s);姿态 ok:品牌绿定格 */
@keyframes maskBreath { 0%,100% { stroke-opacity: .3; } 50% { stroke-opacity: .95; } }
.cam-mask.no-face .cam-line path { animation: maskBreath 1.8s ease-in-out infinite; }
.cam-mask.ok .cam-line path { stroke: #34C759; stroke-opacity: 1; animation: none; }
/* 检测引擎加载状态(小字胶囊) */
.cam-status {
position: absolute; top: calc(var(--nav-h) + 32px); left: 50%;
transform: translateX(-50%);
font-size: 10px; color: rgba(255,255,255,.85);
background: rgba(0,0,0,.35);
padding: 4px 12px; border-radius: 11px;
z-index: 2; opacity: 0; transition: opacity .25s; white-space: nowrap;
}
.cam-status.show { opacity: 1; }
/* 引导提示:一次只显示一条,触发式出现/消失 */
.cam-hint {
position: absolute; left: 50%; bottom: 29%;
transform: translateX(-50%) translateY(6px);
font-size: 13px; color: #fff;
background: rgba(0,0,0,.45);
padding: 7px 16px; border-radius: 17px;
z-index: 2; opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
white-space: nowrap;
}
.cam-hint.show { opacity: 1; transform: translateX(-50%); }
.cam-hint.ok { background: rgba(52,199,89,.82); }
/* 倒计时大数字(取景中央 3→2→1) */
.cam-count {
position: absolute; left: 0; right: 0; top: 40%;
text-align: center;
font-size: 76px; font-weight: 600; color: #fff;
text-shadow: 0 2px 18px rgba(0,0,0,.55);
z-index: 3; opacity: 0; transition: opacity .15s; pointer-events: none;
}
.cam-count.show { opacity: 1; }
/* 长时间无进展轻提示(不阻塞流程) */
.cam-nudge {
position: absolute; left: 50%; bottom: 13%;
transform: translateX(-50%) translateY(6px);
font-size: 10px; color: #fff;
background: rgba(0,0,0,.55);
padding: 6px 14px; border-radius: 14px;
z-index: 2; opacity: 0; pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
white-space: nowrap;
}
.cam-nudge.show { opacity: 1; transform: translateX(-50%); }
/* ═══════════ 08 报告页 ═══════════ */
.report-body { padding: 12px 38px 34px; }
@@ -275,8 +360,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
.dim { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dim + .dim { border-left: 1px solid #EFEFEF; }
.dim-name { font-size: 9px; font-weight: 500; color: var(--grey); }
.dim-score { font-size: 29px; font-weight: 500; color: var(--ink); }
.dim-bad { color: var(--bad); }
.dim-score { font-size: 29px; font-weight: 500; color: var(--ink); line-height: 1.1; }
.dim-lv { font-size: 9px; font-weight: 500; min-height: 11px; }
.dims-note { font-size: 9px; font-weight: 500; color: var(--grey); line-height: 1.55; margin-top: 9px; text-align: center; }
.dims-disclaimer { font-size: 7px; font-weight: 400; color: var(--grey-3); margin: 0 2px 9px; line-height: 1.5; }
/* 具体描述 */
@@ -312,10 +397,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
margin-right: 12px; flex-shrink: 0;
}
.ditem-para { font-size: 10px; font-weight: 400; color: var(--para); line-height: 1.45; overflow-wrap: anywhere; }
.dempty {
padding: 26px 0; text-align: center;
font-size: 10px; color: var(--grey-2);
}
/* 护理建议 */
.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 8px; }
.care-item { font-size: 10px; color: var(--para); line-height: 1.6; margin-bottom: 8px; overflow-wrap: anywhere; }
@@ -408,6 +489,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
font-size: 13px; letter-spacing: 1px;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.analyzing-tip { font-size: 10px; color: rgba(255,255,255,.45); letter-spacing: 0; }
.spinner {
width: 34px; height: 34px;
border: 3px solid rgba(255,255,255,.25);
@@ -437,3 +519,24 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
/* 隐私协议占位弹层(轻量,复用 .overlay/.popup 骨架) */
#p-policy .popup { padding: 30px 26px 34px; }
.popup-title-policy { font-size: 18px; margin-bottom: 12px; }
/* ═══════════ 第4轮:引导式自动三连拍 + SkinScore 报告接入(新增,不动以上已验收样式) ═══════════ */
/* 档位配色:良好=绿 / 改善=琥珀 / 预警=红(文字场景直接上色) */
.lv-good { color: #2FA84F; }
.lv-warn { color: #E8A13D; }
.lv-bad { color: var(--bad); }
/* 肤龄钳制留痕(小字灰) */
.stat-sub { font-size: 8px; color: var(--grey-3); min-height: 10px; line-height: 10px; }
/* 分类条目:S 值 + 档位徽章 */
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.cat-s { font-size: 15px; font-weight: 600; color: var(--ink); }
.cat-s.cat-na { font-size: 9px; font-weight: 400; color: var(--grey-2); }
.cat-badge { font-style: normal; font-size: 8px; font-weight: 500; color: #fff; padding: 2px 7px; border-radius: 8px; }
.cat-badge.lv-good { background: #2FA84F; color: #fff; }
.cat-badge.lv-warn { background: #E8A13D; color: #fff; }
.cat-badge.lv-bad { background: var(--bad); color: #fff; }
/* 降级标注(下颌线未参与评分等,小字灰) */
.vm-notes { font-size: 7px; font-weight: 400; color: var(--grey-3); line-height: 1.7; margin: 0 2px 9px; }
/* 精华推荐卡 */
.essence-name { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; }
+5
View File
@@ -0,0 +1,5 @@
@echo off
rem 巨子测肤 H5 本机部署启动(提测用)。密钥由 server.js 自动加载。
set PORT=8088
cd /d %~dp0
node server.js >> "%~dp0server.log" 2>&1
+88 -17
View File
@@ -4,11 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<meta name="description" content="1'me 在线肌肤测试:拍摄正面与侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<meta name="description" content="1'me 在线肌肤测试:拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<meta name="theme-color" content="#101823">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta property="og:title" content="1'me 在线肌肤测试">
<meta property="og:description" content="拍摄正面与侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<meta property="og:description" content="拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
<title>1'me 在线肌肤测试</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="preload" as="image" href="assets/img/bg-splash.jpg">
@@ -86,13 +86,38 @@
</section>
<!-- ═══════════ 05-07 拍照页 ═══════════ -->
<section class="screen" id="s-cam" data-step="0">
<section class="screen" id="s-cam">
<div class="cam-bg" id="cam-bg"></div>
<header class="nav nav-dark">
<button class="nav-back" data-action="cam-back" aria-label="返回"><i class="chev left"></i></button>
<span class="nav-title">1'me在线肌肤测试</span>
</header>
<div class="cam-dots" id="cam-dots"><i></i><i></i><i></i></div>
<p class="cam-step" id="cam-step">1/3<br>请单人正脸出镜</p>
<!-- 取景蒙版:SVG mask 镂空压暗 + 轮廓线框;data-step 切换三种轮廓
0=正脸(含耳部) 1=右脸出镜(头向左转,镜像画面中鼻朝左) 2=左脸出镜(头向右转,鼻朝右) -->
<svg class="cam-mask" id="cam-mask" data-step="0" viewBox="0 0 375 812" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<defs>
<mask id="cam-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="375" height="812">
<rect x="0" y="0" width="375" height="812" fill="#fff"/>
<g fill="#000">
<path data-shape="0" d="M187.5 240 C252 240 300 285 302 365 C313 367 318 381 315 397 C312 412 305 418 299 420 C294 488 246 578 187.5 586 C129 578 81 488 76 420 C70 418 63 412 60 397 C57 381 62 367 73 365 C75 285 123 240 187.5 240 Z"/>
<path data-shape="1" transform="translate(375 0) scale(-1 1)" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<path data-shape="2" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
</g>
</mask>
</defs>
<rect class="cam-dim-rect" x="0" y="0" width="375" height="812" mask="url(#cam-cut)"/>
<g class="cam-line">
<path data-shape="0" d="M187.5 240 C252 240 300 285 302 365 C313 367 318 381 315 397 C312 412 305 418 299 420 C294 488 246 578 187.5 586 C129 578 81 488 76 420 C70 418 63 412 60 397 C57 381 62 367 73 365 C75 285 123 240 187.5 240 Z"/>
<path data-shape="1" transform="translate(375 0) scale(-1 1)" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
<path data-shape="2" d="M170 245 C230 240 258 270 262 320 C263 330 262 338 259 345 C270 360 282 380 284 398 C285 405 282 411 274 413 C267 415 262 417 261 421 C267 425 268 431 263 435 C267 439 267 445 262 448 C264 454 263 460 265 466 C266 476 260 483 248 486 C220 505 185 530 158 548 C140 560 122 558 112 538 C95 500 90 430 94 370 C98 305 120 250 170 245 Z"/>
</g>
</svg>
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
<p class="cam-hint" id="cam-hint"></p>
<div class="cam-count" id="cam-count"></div>
<p class="cam-nudge" id="cam-nudge">识别不到?可点下方快门手动拍摄</p>
<div class="cam-guide" id="cam-guide"></div>
<video id="cam-video" autoplay playsinline muted></video>
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
@@ -107,7 +132,7 @@
<img src="assets/svg/图标-重拍.svg" alt="切换">
</button>
</div>
<div class="cam-flash" id="cam-flash"></div>
<div class="cam-fill" id="cam-fill"></div>
</section>
<!-- ═══════════ 08 报告页 ═══════════ -->
@@ -127,6 +152,7 @@
<div class="stat">
<span class="stat-label">肌肤年龄</span>
<span class="stat-value" id="stat-age">--岁</span>
<span class="stat-sub" id="stat-age-note"></span>
</div>
</div>
@@ -135,9 +161,9 @@
<div class="card card-dims">
<div class="dims-row">
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm">-.-</span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span></div>
<div class="dim"><span class="dim-name">紧塑力</span><span class="dim-score" id="dim-firm">-.-</span><span class="dim-lv" id="dim-firm-lv"></span></div>
<div class="dim"><span class="dim-name">活力感</span><span class="dim-score" id="dim-vita">-.-</span><span class="dim-lv" id="dim-vita-lv"></span></div>
<div class="dim"><span class="dim-name">稳定力</span><span class="dim-score" id="dim-stab">-.-</span><span class="dim-lv" id="dim-stab-lv"></span></div>
</div>
<p class="dims-note" id="dims-note">近期优先关注:<br>---</p>
</div>
@@ -151,38 +177,79 @@
<div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem">
<h3 class="ditem-title">紧致</h3>
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="紧致检测图">
<h3 class="ditem-title">轮廓</h3>
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-contour">-.-</span><i class="cat-badge" id="lv-contour"></i></p>
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">纹路</h3>
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="纹路检测图">
<h3 class="ditem-title">皱纹</h3>
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-wrinkles">-.-</span><i class="cat-badge" id="lv-wrinkles"></i></p>
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="1">
<div class="dempty">活力感维度内容以实际检测报告为准</div>
<div class="ditem">
<h3 class="ditem-title">光滑</h3>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-smooth">-.-</span><i class="cat-badge" id="lv-smooth"></i></p>
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">肤色水润</h3>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-tone">-.-</span><i class="cat-badge" id="lv-tone"></i></p>
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="2">
<div class="dempty">稳定力维度内容以实际检测报告为准</div>
<div class="ditem">
<h3 class="ditem-title">敏感</h3>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-sens">-.-</span><i class="cat-badge" id="lv-sens"></i></p>
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">痘痘</h3>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-acne">-.-</span><i class="cat-badge" id="lv-acne"></i></p>
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
</div>
</div>
</div>
<p class="vm-notes" id="vm-notes" style="display:none"></p>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
<div class="card card-summary">
<h2 class="card-heading">综合分析</h2>
<p class="care-item" id="vm-summary">检测完成后显示分析结果。</p>
</div>
<div class="card card-care">
<h2 class="card-heading">护理建议</h2>
<p class="care-item">1、面部支撑力明显下降,软组织有下移趋势。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。</p>
<p class="care-item">2、肤色不均,局部有色素沉积。建议严格防晒,避免紫外线加重色沉;护肤上可尝试温和的美白精华,加速代谢。</p>
<h2 class="card-heading">生活方式建议</h2>
<p class="care-item" id="vm-lifestyle">--</p>
<p class="care-summary">好状态,是内外同调的长期节奏。</p>
</div>
<div class="card card-essence">
<h2 class="card-heading">精华推荐</h2>
<p class="essence-name" id="essence-name">--</p>
<p class="care-item" id="essence-text">--</p>
<p class="essence-triggers" id="essence-triggers"></p>
</div>
<div class="card card-product">
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="prod-card">
@@ -244,12 +311,16 @@
<div class="analyzing">
<div class="spinner"></div>
<p>正在生成您的肌肤报告…</p>
<p class="analyzing-tip">点击空白处取消</p>
</div>
</div>
<!-- ═══════════ Toast ═══════════ -->
<div class="toast" id="toast"></div>
<script src="js/app.js"></script>
<script src="vendor/faceapi/face-api.js" defer></script>
<script src="js/head-pose.js" defer></script>
<script src="js/skin-score.js" defer></script>
<script src="js/app.js" defer></script>
</body>
</html>
+456 -154
View File
@@ -60,6 +60,16 @@
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
});
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
camSeq++;
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程终止解锁
showScreen('s-profile');
});
/* ─── 肤质轮播 ─── */
var SKINS = [
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
@@ -142,32 +152,57 @@
});
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
var camVideo = $('#cam-video'), camFile = $('#cam-file');
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
function getCamStream() {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'user' }, width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).catch(function (err) {
if (err && err.name === 'OverconstrainedError') {
return navigator.mediaDevices.getUserMedia({ video: true, audio: false });
}
throw err;
});
}
function startCamera() {
photos = [];
if (camStream) { stopCamera(); }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
camSeqCam++;
var token = camSeqCam;
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).then(function (stream) {
getCamStream().then(function (stream) {
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
camStream = stream;
camVideo.srcObject = stream;
camLive = true;
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
stream.getTracks().forEach(function (t) {
t.onended = function () { onCamTrackEnded(token); };
});
$('#s-cam').classList.add('cam-live');
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
}).catch(function () {
if (token !== camSeqCam) return;
enableFallback();
});
}
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
function onCamTrackEnded(token) {
if (token !== camSeqCam || !camLive) return; // 旧流/已主动停止:忽略
if (document.hidden || currentScreen !== 's-cam') return; // 后台恢复由 visibilitychange 流程接管
camLive = false; // 多个 track 同时 ended 只触发一次重连
toast('摄像头连接中断,正在重连');
startCamera();
}
function stopCamera() {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
$('#cam-status').classList.remove('show');
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
camVideo.srcObject = null;
camLive = false;
@@ -175,6 +210,8 @@
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
toast('已切换为系统相机拍摄');
}
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
@@ -192,6 +229,7 @@
}
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
var f = camFile.files && camFile.files[0];
camFile.value = '';
if (!f) return;
@@ -210,130 +248,170 @@
};
reader.readAsDataURL(f);
});
// 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死
camFile.addEventListener('cancel', function () { shutterLock = false; });
/* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */
function num(node, key) {
if (node == null) return 0;
if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0;
return Number(node.value != null ? node.value : node) || 0;
}
function one(x) { return Math.round(x * 10) / 10; }
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
var COUNTDOWN_S = 3; // 倒计时秒数
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
var FACE_NEAR = 0.35, FACE_FAR = 0.8; // 人脸框高度占画面比:<35% 太远,>80% 太近
function mapSkinResult(r) {
var fw = num(r.forehead_wrinkle, 'value') === 1;
var cf = num(r.crows_feet, 'value') === 1;
var ef = num(r.eye_finelines, 'value') === 1;
var gw = num(r.glabella_wrinkle, 'value') === 1;
var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value');
var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value');
var dc = num(r.dark_circle, 'value');
var bh = num(r.blackhead, 'value');
var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw']
.filter(function (k) { return num(r[k], 'value') === 1; }).length;
var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0;
var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0;
var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0;
var sens = r.sensitivity || {};
var sensArea = Number(sens.sensitivity_area) || 0;
var sensInt = Number(sens.sensitivity_intensity) || 0;
var age = num(r.skin_age, 'value');
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var detRunning = false, detTimer = null; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 0; // 倒计时
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
// 紧塑力:法令纹 + 眼袋(松弛下垂类)
var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0);
// 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类)
var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0);
// 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类)
var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3;
firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8));
var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98));
var dims = [
{ name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' },
{ name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' },
{ name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' }
].sort(function (a, b) { return a.score - b.score; });
// 具体描述两行文案
var sevName = ['轻度', '中度', '重度'];
var para1;
if (!nf && !ep) {
para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。';
} else {
var parts = [];
if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势');
if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋');
para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。';
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
function loadModels() {
if (!modelsPromise) {
modelsPromise = Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
]).then(function () { modelsOk = true; },
function (err) { modelsPromise = null; throw err; });
}
var para2;
var wrinkles = [];
if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹');
if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹');
if (wrinkles.length === 0) {
para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。';
} else {
para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。';
}
var found = [];
if (spotN > 0) found.push('斑点' + spotN + '处');
if (acneN > 0) found.push('痘痘' + acneN + '处');
if (ccN > 0) found.push('闭口' + ccN + '处');
if (found.length) para2 += '(本次检测到' + found.join(',') + ')';
return {
total: total, age: age,
firm: firm, vita: vita, stab: stab,
focusName: dims[0].name, focusTip: dims[0].tip,
para1: para1, para2: para2,
lowest: dims[0].score
};
return modelsPromise;
}
function renderReport(m, frontPhoto, sidePhoto) {
$('#stat-score').textContent = m.total + '分';
$('#stat-age').textContent = m.age + '岁';
var setDim = function (id, score) {
var el = $(id);
el.textContent = score.toFixed(1);
el.classList.toggle('dim-bad', score < 6);
};
setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab);
$('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '<br>' + m.focusTip;
$('#ditem-para1').textContent = m.para1;
$('#ditem-para2').textContent = m.para2;
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
var m = $('#cam-mask');
m.classList.toggle('no-face', cls === 'no-face');
m.classList.toggle('ok', cls === 'ok');
}
function setHint(text, ok) {
var h = $('#cam-hint');
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; }
if (text !== lastHint) h.textContent = text; // 同文案不重复触发布局
h.classList.add('show');
h.classList.toggle('ok', !!ok);
lastHint = text;
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
lastHint = '';
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
setHint('');
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
armNudge();
}
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
}, NO_PROGRESS_MS);
}
/* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */
var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}};
function requestAnalyze(imageDataUrl, seq, done) {
if (/[?&]mock=1/.test(location.search)) {
setTimeout(function () { done(null, MOCK_RESULT); }, 900);
function startCountdown() {
counting = true;
clearTimeout(nudgeTimer);
hideNudge();
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true);
cdTimer = setTimeout(cdTick, 1000);
}
function cdTick() {
cdN--;
if (cdN <= 0) {
counting = false;
$('#cam-count').classList.remove('show');
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
return;
}
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: imageDataUrl })
}).then(function (res) { return res.json(); })
.then(function (data) {
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
if (!data.ok) done(data.message || '检测失败,请重试');
else done(null, data.result);
})
.catch(function () {
if (seq !== camSeq) return;
done('网络异常,请检查网络后重试');
});
$('#cam-count').textContent = cdN;
cdTimer = setTimeout(cdTick, 1000);
}
function cancelCountdown() {
counting = false;
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
/* ─── 拍照流程 ─── */
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
detTick();
}
function stopDetect() {
detRunning = false;
clearTimeout(detTimer);
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
}, DETECT_INTERVAL);
}
function detectFrame() {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
}
function onDetect(d) {
var hint = '', ok = false, hasFace = !!d;
if (!hasFace) {
hint = '未检测到人脸';
} else {
// 提示优先级:无人脸 > 距离 > 角度
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else {
// 前置预览为镜像画面 → mirrored=true;距离不合格时角度不判 ok
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, true);
hint = pose.hint;
ok = pose.ok;
}
}
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
setMask(counting || ok ? 'ok' : (hasFace ? '' : 'no-face'));
setHint(counting ? '保持不动' : hint, counting || ok);
if (counting) return;
if (ok) {
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
} else {
okSince = 0;
}
}
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] },
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'] },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'] }
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' }
];
var camStep = 0;
@@ -344,61 +422,106 @@
$('#cam-guide').innerHTML = cfg.guides
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
// 步骤圆点:当前步高亮、完成步绿色打勾
var dots = $('#cam-dots').children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle('done', i < camStep);
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
resetDetectState();
}
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时截帧与降级选图共用)
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
function onPhotoCaptured(dataUrl) {
if (shutterLock && !camFallback) return;
shutterLock = true;
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
photos[camStep] = dataUrl;
var flash = $('#cam-flash');
flash.classList.add('on');
setTimeout(function () { flash.classList.remove('on'); }, 220);
$('#cam-dots').children[camStep].classList.add('done');
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep();
shutterLock = false; // 进入下一步即解锁
renderCamStep(); // 内含检测状态机重置
shutterLock = false; // 进入下一步即解锁
} else {
// 三张拍完:送检正面照
renderCamStep();
camStep = 0;
openOverlay('p-analyzing');
var t0 = Date.now();
requestAnalyze(photos[0], seq, function (errMsg, result) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
if (errMsg) {
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
shutterLock = false;
return;
}
renderReport(mapSkinResult(result), photos[0], photos[1]);
showScreen('s-report');
shutterLock = false; // 流程结束进入报告后解锁
}, wait);
});
submitPhotos(seq); // 三张拍完:送检
}
}, 260);
}, 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
shutterLock = true;
capturing = true;
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
if (navigator.vibrate) navigator.vibrate(30);
setTimeout(function () {
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
onPhotoCaptured(shot);
}, 300);
}, 300);
}
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
function submitPhotos(seq) {
camStep = 0;
openOverlay('p-analyzing');
var t0 = Date.now();
requestAnalyze(photos, seq, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程结束解锁
if (errMsg) {
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
return;
}
var vm = null;
try {
vm = SkinScore.buildViewModel(payload.result, payload.side, {
realAge: +($('#f-age').value || 0) || null,
skinType: SKINS[skinIndex].name
});
} catch (e) { vm = null; }
if (!vm) {
toast('报告生成失败,请重新拍摄');
showScreen('s-profile');
return;
}
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
}, wait);
});
}
function shutter() {
if (shutterLock) return;
if (camLive) {
var shot = captureFromVideo();
if (!shot) { toast('相机尚未就绪,请稍候'); return; }
onPhotoCaptured(shot);
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
} else if (camFallback) {
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
camFile.click(); // 降级:调起系统相机,回调里推进
} else {
// 相机还在启动中
@@ -406,6 +529,150 @@
}
}
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
function requestAnalyze(ph, seq, done) {
if (IS_MOCK && typeof SkinScore !== 'undefined') {
setTimeout(function () {
if (seq !== camSeq) return; // mock 同样尊重取消/重置
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
}, 900);
return;
}
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS);
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ front_image: ph[0], left_image: ph[2], right_image: ph[1] }),
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
}).then(function (res) { return res.json(); })
.then(function (data) {
clearTimeout(timer);
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
if (!data.ok) done(data.message || '检测失败,请重试');
else done(null, { result: data.result, side: data.side });
})
.catch(function (err) {
clearTimeout(timer);
if (seq !== camSeq) return;
if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); // 45s 超时主动中断
else done('网络异常,请检查网络后重试');
});
}
/* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */
var LV_CLASS = { '良好': 'lv-good', '改善': 'lv-warn', '预警': 'lv-bad' };
var CAT_DOM = {
contour: { s: '#s-contour', lv: '#lv-contour', tx: '#ditem-para1' },
wrinkles: { s: '#s-wrinkles', lv: '#lv-wrinkles', tx: '#ditem-para2' },
smooth: { s: '#s-smooth', lv: '#lv-smooth', tx: '#tx-smooth' },
tone: { s: '#s-tone', lv: '#lv-tone', tx: '#tx-tone' },
sens: { s: '#s-sens', lv: '#lv-sens', tx: '#tx-sens' },
acne: { s: '#s-acne', lv: '#lv-acne', tx: '#tx-acne' }
};
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setDim(scoreSel, lvSel, dim) {
var el = $(scoreSel), lv = $(lvSel);
if (dim.S == null) {
el.textContent = '-.-';
lv.textContent = '未检出';
lv.className = 'dim-lv';
return;
}
el.textContent = dim.S.toFixed(1);
lv.textContent = dim.level;
lv.className = 'dim-lv ' + (LV_CLASS[dim.level] || '');
}
function setCat(key, cat) {
var dom = CAT_DOM[key];
var s = $(dom.s), lv = $(dom.lv);
if (cat.P == null) { // 缺数据分类:显示说明,不显示分数
s.textContent = '本次未检出有效数据';
s.className = 'cat-s cat-na';
lv.style.display = 'none';
} else {
s.textContent = cat.S.toFixed(1);
s.className = 'cat-s';
lv.style.display = '';
lv.textContent = cat.level;
lv.className = 'cat-badge ' + (LV_CLASS[cat.level] || '');
}
$(dom.tx).textContent = cat.text;
}
function renderReport(vm, frontPhoto, sidePhoto) {
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
var total = vm.total;
if (total == null) {
var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i;
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
if (n) total = Math.round(sum / n * 10);
}
$('#stat-score').textContent = total != null ? total + '分' : '--分';
$('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁';
$('#stat-age-note').textContent = vm.ageClampNote || ''; // 肤龄钳制留痕(小字灰)
// 三维度:S 值(1 位小数) + 档位(良好绿/改善琥珀/预警红)
setDim('#dim-firm', '#dim-firm-lv', vm.dims.firm);
setDim('#dim-vita', '#dim-vita-lv', vm.dims.vita);
setDim('#dim-stab', '#dim-stab-lv', vm.dims.stab);
$('#dims-note').innerHTML = vm.focus.name
? '近期优先关注:' + esc(vm.focus.name) + '<br>' + esc(vm.focus.tip)
: '近期优先关注:<br>---';
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
// 降级标注(下颌线未参与评分等,非空才展示)
var notesEl = $('#vm-notes');
if (vm.notes && vm.notes.length) {
notesEl.innerHTML = vm.notes.map(function (t) { return '*' + esc(t); }).join('<br>');
notesEl.style.display = '';
} else {
notesEl.style.display = 'none';
}
// 综合文案 / 生活方式 / 精华推荐
$('#vm-summary').textContent = vm.summary;
$('#vm-lifestyle').textContent = vm.lifestyle;
$('#essence-name').textContent = vm.essence.name;
$('#essence-text').textContent = vm.essence.text;
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
function beginDetect() {
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
loadModels().then(function () {
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
});
}
/* ─── 隐私勾选 ─── */
var agreed = false;
$('#privacy-row').addEventListener('click', function (e) {
@@ -456,15 +723,16 @@
closeOverlay('p-photoguide');
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
startCamera();
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); }
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
else { stopCamera(); showScreen('s-profile'); }
},
'shutter': shutter,
@@ -481,6 +749,40 @@
if (fn) fn();
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
function handleCamHidden() {
if (currentScreen !== 's-cam') return;
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
shutterLock = false;
capturing = false;
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
closeOverlay('p-analyzing'); // 分析中被打断:回调已作废,弹层不能残留
stopCamera(); // 停检测循环/倒计时/nudge 并释放摄像头(camSeqCam++ 令迟到授权回调失效)
}
function handleCamVisible() {
if (currentScreen !== 's-cam') return;
camSeq++; // 双保险:隐藏期间残留的迟到回调一并作废
renderCamStep(); // 重置当前步检测状态机,从当前步继续
if (!camFallback) startCamera(); // 降级模式无流可起,仅重置状态机;重走 camSeqCam 令牌防复活
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) handleCamHidden(); else handleCamVisible();
});
window.addEventListener('pagehide', handleCamHidden);
window.addEventListener('pageshow', function (e) {
if (e.persisted) handleCamVisible(); // bfcache 恢复(不一定伴随 visibilitychange)
});
/* ─── 初始化 ─── */
renderCamStep();
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
showScreen('s-report');
try {
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), null, null);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();
+349
View File
@@ -0,0 +1,349 @@
/**
* head-pose.js —— 纯姿态估算模块(yaw / pitch / roll)
*
* 输入为 face-api.js 的 68 特征点([{x,y}, ...],图片坐标系、未镜像),
* 输出角度制姿态角。纯数据进、纯数据出:不碰 DOM、不 require face-api,
* 浏览器挂 window.HeadPose,Node 下 module.exports,可直接单测。
*
* ------------------------- 坐标与符号约定 -------------------------
* 68 点索引(dlib/face-api 约定,以「看图视角」描述):
* 36 = 画面左侧眼外角(被摄者右眼外角)
* 45 = 画面右侧眼外角(被摄者左眼外角)
* 30 = 鼻尖,33 = 鼻底,19/24 = 左右眉峰,8 = 下巴尖
* 图片坐标系:x 向右,y 向下。
*
* yaw(左右转头):
* yaw > 0 表示鼻尖偏向画面右侧(45 点一侧),即被摄者向【自己的左侧】转头
* (此时画面中看到被摄者的右脸 → check 的 target 'right');
* yaw < 0 表示被摄者向【自己的右侧】转头(画面中看到左脸 → target 'left')。
* mirrored=true(前置摄像头预览做了水平镜像)时内部翻转 yaw 符号,
* 使输出相对用户所看到的画面方向一致;check() 内部会还原语义方向,
* 因此 check 结果与 mirrored 取值无关。
* ⚠ 方向符号真机需校准:不同机型/镜像策略下若左右相反,
* 只需调整调用方传入的 mirrored 标志或 YAW_COEFF 符号。
*
* pitch(俯仰):
* pitch > 0 表示抬头(鼻底相对眉眼连线中点的纵向下移增大),
* pitch < 0 表示低头。⚠ 经验映射,真机需校准 PITCH_BASELINE / PITCH_COEFF。
*
* roll(平面内歪头):
* 两眼外角连线倾角,roll > 0 表示画面右侧(45 点一侧)偏低。
*
* 所有映射均为基于 68 点几何的轻量经验估算(非 solvePnP 精确解),
* 系数仅保证量级与单调性合理,上线前需用真机样本回归校准。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.HeadPose = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- 68 点索引 ----
var IDX = {
EYE_LEFT_OUTER: 36, // 画面左侧眼外角
EYE_RIGHT_OUTER: 45, // 画面右侧眼外角
NOSE_TIP: 30,
NOSE_BASE: 33,
BROW_LEFT: 19,
BROW_RIGHT: 24,
CHIN: 8
};
// ---- 经验系数(⚠ 真机需校准) ----
// yaw = atan((dLeft-dRight)/(dLeft+dRight)) × YAW_COEFF。
// atan 输出 ∈ (-45°,45°),取 2.2 使真实 ~45° 侧脸读数落在 40° 上下。
var YAW_COEFF = 2.2;
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
// 减去中性正脸基线后线性映射为角度。
var PITCH_BASELINE = 0.32; // 正脸时 offset/faceHeight 的典型值
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// 正脸口径:Face++ 正脸判定 |yaw|<10°,前端留 2° 余量取 ≤12°
var FRONT_YAW_MAX = 12;
var FRONT_PITCH_MAX = 15;
var SIDE_YAW_MIN = 28;
var SIDE_YAW_MAX = 75;
var SIDE_PITCH_MAX = 20;
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
var HINT_TURN_RIGHT = '请向右转头'; // 用户向自己的右侧转
var HINT_RAISE = '请抬头';
var HINT_LOWER = '请低头';
var HINT_FRONT = '请正对屏幕';
var RAD2DEG = 180 / Math.PI;
function dist(a, b) {
var dx = a.x - b.x;
var dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function midpoint(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function assertPoints(points) {
if (!points || typeof points.length !== 'number' || points.length < 68) {
throw new TypeError('HeadPose.estimate: 需要 68 个特征点数组');
}
var names = Object.keys(IDX);
for (var i = 0; i < names.length; i++) {
var p = points[IDX[names[i]]];
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' ||
!isFinite(p.x) || !isFinite(p.y)) {
throw new TypeError('HeadPose.estimate: 特征点 ' + IDX[names[i]] + ' 非法');
}
}
}
/**
* estimate(points, mirrored) -> { yaw, pitch, roll }(角度制),非法/退化输入返回 null
* points : face-api 68 特征点 [{x,y}, ...](图片坐标系,未镜像)
* mirrored : 预览画面是否水平镜像(true 时翻转 yaw 符号)
* 返回 null 的情形:特征点重合(dLeft+dRight=0,yaw 退化为 NaN)、
* 脸高过小(pitch 不可信,不再静默置 0)、结果出现 NaN/Infinity。
*/
function estimate(points, mirrored) {
assertPoints(points);
var eyeL = points[IDX.EYE_LEFT_OUTER];
var eyeR = points[IDX.EYE_RIGHT_OUTER];
var noseTip = points[IDX.NOSE_TIP];
var noseBase = points[IDX.NOSE_BASE];
var browMid = midpoint(points[IDX.BROW_LEFT], points[IDX.BROW_RIGHT]);
var chin = points[IDX.CHIN];
// roll:两眼外角连线倾角(y 向下,45 点偏低为正)
var roll = Math.atan2(eyeR.y - eyeL.y, eyeR.x - eyeL.x) * RAD2DEG;
// yaw:鼻尖到左右眼外角距离的不对称度;特征点重合(dLeft+dRight=0)时为 NaN,走非法分支
var dLeft = dist(noseTip, eyeL);
var dRight = dist(noseTip, eyeR);
var dSum = dLeft + dRight;
var yaw = NaN;
if (dSum > 0) yaw = Math.atan((dLeft - dRight) / dSum) * RAD2DEG * YAW_COEFF;
// pitch:鼻底相对眉眼连线中点的纵向偏移,以脸高归一后线性映射;
// 脸高过小时 pitch 不可信,不再静默置 0,走非法分支
var eyeMid = midpoint(eyeL, eyeR);
var browEyeMid = midpoint(browMid, eyeMid);
var faceHeight = chin.y - browMid.y;
var pitch = NaN;
if (faceHeight > 1) {
var ratio = (noseBase.y - browEyeMid.y) / faceHeight;
pitch = (ratio - PITCH_BASELINE) * PITCH_COEFF;
}
if (!isFinite(yaw) || !isFinite(pitch) || !isFinite(roll)) return null; // 退化输入 → 非法三角
yaw = clamp(yaw, -90, 90);
pitch = clamp(pitch, -90, 90);
if (mirrored) { yaw = -yaw; }
return { yaw: yaw, pitch: pitch, roll: roll };
}
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=12 且 |pitch|<=15
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
*/
function check(points, target, mirrored) {
var e;
try {
e = estimate(points, mirrored);
} catch (err) {
return { ok: false, hint: HINT_FRONT };
}
if (!e) return { ok: false, hint: HINT_FRONT }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
// 语义 yaw:>0 = 被摄者向自己的左侧转头,与预览是否镜像无关
var sYaw = mirrored ? -e.yaw : e.yaw;
var pitch = e.pitch;
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
}
if (Math.abs(sYaw) > FRONT_YAW_MAX) {
// 已向左转过头 → 提示向右回正,反之亦然
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE };
}
if (target === 'left' || target === 'right') {
// 'left' 需要语义 yaw 为负(头向右转),'right' 需要为正
var want = target === 'left' ? -1 : 1;
var sy = sYaw * want; // 统一折算到正区间判断
if (sy >= SIDE_YAW_MIN && sy <= SIDE_YAW_MAX && Math.abs(pitch) <= SIDE_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
}
if (sy < SIDE_YAW_MIN) {
// 转得不够(含转错方向):提示朝目标方向转
return { ok: false, hint: target === 'left' ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
}
if (sy > SIDE_YAW_MAX) {
// 转过了:提示往回
return { ok: false, hint: target === 'left' ? HINT_TURN_LEFT : HINT_TURN_RIGHT };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE };
}
return { ok: false, hint: HINT_FRONT };
}
return {
estimate: estimate,
check: check,
// 导出阈值与系数便于调用方/真机校准阶段调整
config: {
YAW_COEFF: YAW_COEFF,
PITCH_BASELINE: PITCH_BASELINE,
PITCH_COEFF: PITCH_COEFF,
FRONT_YAW_MAX: FRONT_YAW_MAX,
FRONT_PITCH_MAX: FRONT_PITCH_MAX,
SIDE_YAW_MIN: SIDE_YAW_MIN,
SIDE_YAW_MAX: SIDE_YAW_MAX,
SIDE_PITCH_MAX: SIDE_PITCH_MAX
}
};
}));
// -------------------------- Node 自测 --------------------------
if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.main === module) {
var HeadPose = module.exports;
var failures = 0;
function ok(cond, msg) {
if (cond) {
console.log('PASS ' + msg);
} else {
failures++;
console.error('FAIL ' + msg);
}
}
// 合成对称正脸:眼距 100,脸高 170,pitch 偏移恰好落在基线上
function makeFrontal() {
var pts = [];
for (var i = 0; i < 68; i++) { pts.push({ x: 200, y: 150 }); }
pts[36] = { x: 150, y: 100 }; // 左眼外角(画面左)
pts[45] = { x: 250, y: 100 }; // 右眼外角(画面右)
pts[19] = { x: 170, y: 70 };
pts[24] = { x: 230, y: 70 };
pts[8] = { x: 200, y: 240 }; // 下巴 → 脸高 240-70=170
pts[30] = { x: 200, y: 140 }; // 鼻尖居中
pts[33] = { x: 200, y: 139.4 }; // (139.4-85)/170 = 0.32 = PITCH_BASELINE
return pts;
}
// 把鼻点横向偏移,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转)
function makeSide() {
var pts = makeFrontal();
pts[30] = { x: 235, y: 140 };
pts[33] = { x: 235, y: 139.4 };
return pts;
}
// 1) 对称正脸 → yaw/pitch/roll ≈ 0
var front = HeadPose.estimate(makeFrontal(), false);
ok(Math.abs(front.yaw) < 0.5, '正脸 yaw≈0 (got ' + front.yaw.toFixed(2) + ')');
ok(Math.abs(front.pitch) < 1, '正脸 pitch≈0 (got ' + front.pitch.toFixed(2) + ')');
ok(Math.abs(front.roll) < 0.5, '正脸 roll≈0 (got ' + front.roll.toFixed(2) + ')');
// 2) 正脸 front check 通过
var fc = HeadPose.check(makeFrontal(), 'front', false);
ok(fc.ok === true && fc.hint === '保持不动', '正脸 front ok,hint=保持不动');
// 3) 45° 侧脸:读数落在合理区间
var side = HeadPose.estimate(makeSide(), false);
ok(side.yaw > 40 && side.yaw < 50, '侧脸 yaw≈45° (got ' + side.yaw.toFixed(2) + ')');
// 4) 方向语义:鼻尖偏画面右 = 被摄者向左转 = target 'right' 通过、'left' 拒绝
var rc = HeadPose.check(makeSide(), 'right', false);
var lc = HeadPose.check(makeSide(), 'left', false);
ok(rc.ok === true, '侧脸(被摄者左转)target=right ok');
ok(lc.ok === false && lc.hint === '请向右转头', '同侧脸 target=left 拒绝并引导向右');
// 5) mirrored=true 翻转 yaw 符号,但 check 语义不变
var sideM = HeadPose.estimate(makeSide(), true);
ok(Math.abs(sideM.yaw + side.yaw) < 0.001, 'mirrored=true 时 yaw 符号翻转');
ok(HeadPose.check(makeSide(), 'right', true).ok === true, 'mirrored 下 check(right) 仍 ok');
ok(HeadPose.check(makeSide(), 'left', true).ok === false, 'mirrored 下 check(left) 仍拒绝');
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
var back = makeSide();
back[30] = { x: 165, y: 140 };
back[33] = { x: 165, y: 139.4 };
var bc = HeadPose.check(back, 'left', false);
ok(bc.ok === true, '反向侧脸 target=left ok');
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
// 7) 侧脸做 front check → 拒绝并引导回正
var sc = HeadPose.check(makeSide(), 'front', false);
ok(sc.ok === false && sc.hint === '请向右转头', '侧脸 front 拒绝,hint=请向右转头');
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
var tilted = makeFrontal();
tilted[45] = { x: 250, y: 110 };
ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°');
// 9) 低头:鼻底上移 → pitch<0,front 提示抬头
var down = makeFrontal();
down[33] = { x: 200, y: 100 };
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=请抬头');
// 10) 非法输入:check 不抛异常,返回兜底引导
var bad = HeadPose.check(null, 'front', false);
ok(bad.ok === false && bad.hint === '请正对屏幕', '非法输入兜底 hint=请正对屏幕');
// 11) 退化特征点:全部重合 → dLeft+dRight=0 → yaw=NaN → estimate 返回 null,check 兜底「请正对屏幕」(不再误导「请向左转头」)
var flat = [];
for (var fi = 0; fi < 68; fi++) { flat.push({ x: 100, y: 100 }); }
ok(HeadPose.estimate(flat, false) === null, '特征点重合 → estimate 返回 null');
var fc2 = HeadPose.check(flat, 'front', false);
ok(fc2.ok === false && fc2.hint === '请正对屏幕', '退化输入 check 兜底 hint=请正对屏幕');
// 12) 脸高过小 → pitch 不可信,不再静默置 0,走非法分支
var tiny = makeFrontal();
tiny[8] = { x: 200, y: 70.5 }; // faceHeight = 0.5 ≤ 1
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
// 13) 正脸阈值收紧到 |yaw|≤12°(Face++ 口径 <10° + 2° 余量):yaw≈13.7°(旧阈值 15 内)应拒绝
ok(HeadPose.config.FRONT_YAW_MAX === 12, 'config.FRONT_YAW_MAX = 12');
var slight = makeFrontal();
slight[30] = { x: 209, y: 140 };
slight[33] = { x: 209, y: 139.4 };
var se = HeadPose.estimate(slight, false);
ok(se.yaw > 12 && se.yaw < 15, '轻微偏头 yaw∈(12,15) (got ' + se.yaw.toFixed(2) + ')');
var sc2 = HeadPose.check(slight, 'front', false);
ok(sc2.ok === false, 'yaw≈13.7° front 拒绝(旧阈值 15 会放行)');
var slight2 = makeFrontal();
slight2[30] = { x: 207, y: 140 };
slight2[33] = { x: 207, y: 139.4 };
ok(HeadPose.check(slight2, 'front', false).ok === true, 'yaw≈10.7°(≤12°)front 通过');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
process.exit(1);
}
console.log('\n全部自测通过');
}
+977
View File
@@ -0,0 +1,977 @@
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 最高契约:docs/三维度评分与话术生成需求文档_V2.0.md(公式/锁值/话术/产品推荐以其为准)
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
*
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.0 §137 归一)
* - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键
* - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n}
* - 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源),缺失回退 sensitivity.sensitivity_area
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
* - sensitivity_area / red_spot_area / water_area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(待客户确认)
* - oily_intensity_score → 出油概览句为自设计措辞(文档未给全文)
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SkinScore = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
/* ─── 基础工具 ─── */
function isNum(x) { return typeof x === 'number' && isFinite(x); }
function val(x) { // 解包 {value:n} / 数字 / 数字字符串
if (x == null) return null;
if (isNum(x)) return x;
if (typeof x === 'object') { return isNum(x.value) ? x.value : null; }
var n = Number(x);
return isNaN(n) ? null : n;
}
function pick(r, key) { // score_info 优先,再顶层(§137 字段归一)
if (r == null) return null;
var si = r.score_info;
if (si && si[key] != null) { var v = val(si[key]); if (v != null) return v; }
return val(r[key]);
}
function cnt(node) { // 计数:.count 缺失退化 rectangle 数组长度
if (node == null) return null;
var c = val(node.count);
if (c != null) return c;
if (node.rectangle && typeof node.rectangle.length === 'number') return node.rectangle.length;
return null;
}
function sumLeaves(node) { // 分区计数对象(真实 wrinkle_count/fine_line 为 {forehead_count:22,...} 形状):对数值叶子求和
if (node == null || typeof node !== 'object') return val(node);
var sum = 0, any = false, k;
for (k in node) {
if (node[k] != null && typeof node[k] === 'object') continue; // 只看数值叶子,不递归嵌套对象
var v = val(node[k]);
if (v != null) { sum += v; any = true; }
}
return any ? sum : null;
}
function cntField(r, key) { // 计数字段归一:标量/{value} 直读(score_info 优先),分区对象对数值叶子求和
var v = pick(r, key);
if (v != null) return v;
var si = r.score_info;
var node = (si && si[key] != null) ? si[key] : r[key];
return sumLeaves(node);
}
function round2(x) { return Math.round((x + 1e-9) * 100) / 100; } // 1e-9 修正 9.445 类浮点
function sRaw(P) { return 7 + 3 * P / 100; }
function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾;
if (P == null) return null; // 仅钳 viewModel 展示值,判档/最差项/精华三重判据仍用未舍入原值
var s = sRaw(P);
return round2(s < 7 ? 7 : (s > 10 ? 10 : s));
}
function levelOf(P) { // 判档一律用未四舍五入 P 原值
if (P == null) return null;
return P < 80 ? '预警' : (P < 90 ? '改善' : '良好');
}
function fmt(x) { // 展示用:两位内,去掉多余的 .0
if (x == null) return '';
var v = round2(x);
return Math.round(v) === v ? String(Math.round(v)) : String(v);
}
/* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界),
* 严禁输出 None/undefined/— 占位(V2.0 第五章生成规则)。 */
function fill(tpl, vars) {
var parts = tpl.split(/([,;。])/);
var out = '';
for (var i = 0; i < parts.length; i += 2) {
var clause = parts[i], delim = parts[i + 1] || '';
var miss = false;
var c = clause.replace(/\{(\w+)\}/g, function (m, k) {
var v = vars[k];
if (v === null || v === undefined || v === '') { miss = true; return m; }
return v;
});
if (!miss) out += c + delim; // 缺失:整分句连同分隔符省略
}
return out;
}
function joinParts(parts, sep) {
var out = [];
for (var i = 0; i < parts.length; i++) if (parts[i]) out.push(parts[i]);
return out.join(sep);
}
/* ─── 字段归一:分区配置 ─── */
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(§137)
{ keys: ['forehead_wrinkle_info'], cn: '抬头纹' },
{ keys: ['glabella_wrinkle_info'], cn: '眉间纹' },
{ keys: ['left_eye_wrinkle_info'], cn: '左眼周' },
{ keys: ['right_eye_wrinkle_info'], cn: '右眼周' },
{ keys: ['left_crowsfeet_wrinkle_info'], cn: '左鱼尾纹' },
{ keys: ['right_crowsfeet_wrinkle_info'], cn: '右鱼尾纹' },
{ keys: ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], cn: '左法令纹' },
{ keys: ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], cn: '右法令纹' },
{ keys: ['left_mouth_wrinkle_info'], cn: '左嘴角纹' },
{ keys: ['right_mouth_wrinkle_info'], cn: '右嘴角纹' },
{ keys: ['left_cheek_wrinkle_info'], cn: '左脸颊' },
{ keys: ['right_cheek_wrinkle_info'], cn: '右脸颊' }
];
var PORE_REGION_CN = { // 真实返回同时存在下划线/无下划线变体(如 pores_left_cheek_score 与 pores_leftcheek_score),均需中文名兜底
forehead: '额头', left_cheek: '左脸颊', right_cheek: '右脸颊', jaw: '下巴', nose: '鼻部',
leftcheek: '左侧面颊', rightcheek: '右侧面颊', chin: '下巴'
};
/* ─── 分类计算(每项返回 { P, vars },P=null 表示缺项) ─── */
// 轮廓:P = min(眼袋severity映射, 下颌线角度分)
var EYE_POUCH_MAP = [100, 90, 79, 74]; // 0→100/1→90/2→79/3→74
function jawScore(angle) { // 100−|偏离116–120°|×2,下限60
var dev = angle < 116 ? 116 - angle : (angle > 120 ? angle - 120 : 0);
return Math.max(60, 100 - dev * 2);
}
function jawlineOf(side) { // 键名归一:*_jawline_info / *_jawline_angle / *_jawline_quality
if (!side) return null;
var info = null, k;
for (k in side) if (/_jawline_info$/.test(k)) { info = side[k]; break; }
if (!info) return null;
var angle = null, quality = null;
for (k in info) {
if (/_jawline_angle$/.test(k)) angle = val(info[k]);
else if (/_jawline_quality$/.test(k)) quality = val(info[k]);
}
return { angle: angle, quality: quality };
}
function calcContour(r, sides, notes) {
var pouchSev = pick(r, 'eye_pouch_severity');
if (pouchSev == null) pouchSev = pick(r, 'eye_pouch'); // 退化
var pouchP = null, pouchLvl = null;
if (pouchSev != null) {
var idx = Math.round(pouchSev); // severity 可能返回非整数:取整后再做映射数组索引
if (idx < 0 || idx > 3) {
notes.push('眼袋 severity 越界(' + pouchSev + '),按缺失处理,轮廓评分缺眼袋项');
} else {
pouchLvl = idx;
pouchP = EYE_POUCH_MAP[idx];
}
} else {
notes.push('眼袋 severity 字段缺失,轮廓评分缺眼袋项');
}
var la = null, ra = null, jawParts = [], jawScores = [];
var anySide = sides && (sides.left || sides.right);
var sidesMeta = [['left', '左', sides && sides.left], ['right', '右', sides && sides.right]];
for (var i = 0; i < 2; i++) {
var tag = sidesMeta[i][1], side = sidesMeta[i][2];
if (!side) continue;
var j = jawlineOf(side);
if (!j) { notes.push(tag + '侧脸未返回下颌线数据,下颌线未参与轮廓评分'); continue; }
if (j.quality !== 1) { // 仅 quality=1 角度有效
notes.push(tag + '侧脸角度无效(quality=' + (j.quality == null ? '缺失' : j.quality) + '),下颌线未参与轮廓评分,侧脸角度需20°–120°,建议重拍');
continue;
}
if (j.angle == null) { notes.push(tag + '侧脸下颌线角度缺失,未参与轮廓评分'); continue; }
if (tag === '左') la = j.angle; else ra = j.angle;
jawScores.push(jawScore(j.angle));
}
var jawP = jawScores.length ? Math.min.apply(null, jawScores) : null; // 左右取较差侧
if (!anySide) notes.push('未拍摄侧脸视图,轮廓评分仅由眼袋决定,建议补拍侧脸视图');
else if (jawP == null) notes.push('双侧下颌线均不可用,轮廓评分仅由眼袋决定');
var cand = [];
if (pouchP != null) cand.push(pouchP);
if (jawP != null) cand.push(jawP);
if (!cand.length) { notes.push('轮廓:眼袋与下颌线数据均缺失,轮廓分类不参与评分'); return { P: null, vars: {} }; }
return {
P: Math.min.apply(null, cand),
vars: {
la: la, ra: ra,
eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句)
dark_circle_sev: pick(r, 'dark_circle')
}
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
function calcWrinkles(r, notes) {
var regions = [], used = {}, i, k, node;
for (i = 0; i < WRINKLE_REGIONS.length; i++) {
var rg = WRINKLE_REGIONS[i]; node = null;
for (var j = 0; j < rg.keys.length; j++) {
if (r[rg.keys[j]] != null) { node = r[rg.keys[j]]; used[rg.keys[j]] = 1; break; }
}
if (!node) continue;
var sc = val(node.wrinkle_score);
if (sc == null) continue;
regions.push({ cn: rg.cn, score: sc, sev: val(node.wrinkle_severity_level) });
}
for (k in r) { // 兜底:未配置的 *_wrinkle_info / *_fold_info 变体也参与
if (used[k] || !/(_wrinkle_info|_fold_info)$/.test(k)) continue;
node = r[k];
if (!node || typeof node !== 'object') continue;
var s2 = val(node.wrinkle_score);
if (s2 == null) continue;
regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) });
}
if (!regions.length) { notes.push('皱纹:12 分区 wrinkle_info 全部缺失,皱纹分类不参与评分'); return { P: null, vars: {} }; }
var worst = regions[0], maxSev = null;
for (i = 0; i < regions.length; i++) {
if (regions[i].score > worst.score) worst = regions[i];
if (regions[i].sev != null && (maxSev == null || regions[i].sev > maxSev)) maxSev = regions[i].sev;
}
var P = 100 - worst.score;
if (maxSev != null) {
if (maxSev >= 3) P = Math.min(P, 74);
else if (maxSev >= 2) P = Math.min(P, 79);
} else {
notes.push('皱纹:分区 severity 不可得,上限锁未执行');
}
var wrCnt = cntField(r, 'wrinkle_count');
var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count
if (flCnt == null) flCnt = cntField(r, 'fine_line_count');
return {
P: P,
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
max_sev: maxSev
}
};
}
// 光滑:P = 0.4×min(4分区 pores_type_score) + 0.3×blackhead_score + 0.3×rough_score;分区 severity≥2 锁79
function calcSmooth(r, notes) {
var pts = (r.score_info && r.score_info.pores_type_score) || r.pores_type_score || null;
var poreMin = null, poreRegion = null, poreSevMax = null, k;
if (pts && typeof pts === 'object') {
for (k in pts) {
if (/_score$/.test(k)) {
var s = val(pts[k]);
if (s == null) continue;
var region = k.replace(/^pores_/, '').replace(/_score$/, '');
if (poreMin == null || s < poreMin) { poreMin = s; poreRegion = PORE_REGION_CN[region] || region; }
} else if (/_severity$/.test(k)) {
var sv = val(pts[k]);
if (sv != null && (poreSevMax == null || sv > poreSevMax)) poreSevMax = sv;
}
}
} else {
notes.push('pores_type_score 缺失(score_info 内亦无),光滑评分缺毛孔项');
}
// 真实 pores_type_score 内无 severity 键:锁所需数据缺失时留痕,避免锁静默失效
if (pts && typeof pts === 'object' && poreSevMax == null) {
notes.push('毛孔分区严重度未返回,光滑上限锁未执行');
}
var bh = pick(r, 'blackhead_score');
var rough = pick(r, 'rough_score');
if (bh == null) notes.push('blackhead_score 缺失,光滑评分缺黑头项');
if (rough == null) notes.push('rough_score 缺失,光滑评分缺纹理项');
var factors = [[0.4, poreMin], [0.3, bh], [0.3, rough]], wSum = 0, acc = 0, n = 0, i;
for (i = 0; i < factors.length; i++) {
if (factors[i][1] == null) continue; // 单项缺失:剔除并按剩余权重归一
wSum += factors[i][0]; acc += factors[i][0] * factors[i][1]; n++;
}
if (!n) { notes.push('光滑:毛孔/黑头/纹理三项全部缺失,光滑分类不参与评分'); return { P: null, vars: {} }; }
var P = acc / wSum;
if (poreSevMax != null && poreSevMax >= 2) P = Math.min(P, 79);
var poreCnt = null;
if (r.enlarged_pore_count != null) {
if (isNum(val(r.enlarged_pore_count))) poreCnt = val(r.enlarged_pore_count);
else if (typeof r.enlarged_pore_count === 'object') { // 4 分区求和
poreCnt = 0;
for (k in r.enlarged_pore_count) { var c = val(r.enlarged_pore_count[k]); if (c != null) poreCnt += c; }
}
}
var bhCnt = cnt(r.blackhead);
if (bhCnt == null) bhCnt = pick(r, 'blackhead_count');
return {
P: P,
vars: {
worst_pore_region: poreRegion,
pore4_min: poreMin,
pore_cnt: poreCnt,
blackhead_cnt: bhCnt,
tex: rough,
oily: pick(r, 'oily_intensity_score')
}
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分)
var freckle = pick(r, 'freckle') || 0;
var waterScore = pick(r, 'water_score');
var waterObj = r.water || {};
var waterSev = val(waterObj.water_severity);
if (waterSev == null) waterSev = pick(r, 'water_severity');
var waterArea = val(waterObj.water_area);
if (waterArea == null) waterArea = pick(r, 'water_area');
if (waterArea != null && waterArea <= 1) waterArea = waterArea * 100; // 小数口径×100 → 百分比
var melaninItem = null, waterItem = null;
if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5;
else notes.push('melanin_score 缺失,肤色水润评分缺肤色项');
if (waterScore != null) {
waterItem = waterScore;
if (waterSev != null && waterSev >= 60 && waterScore >= 80) {
waterItem = 100 - waterSev;
notes.push('水分交叉校验触发(water_severity=' + waterSev + ' 且 water_score=' + waterScore + '),水润项按 severity 换算为 ' + waterItem);
}
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, waterItem], acc = 0, n = 0, i;
for (i = 0; i < 2; i++) if (items[i] != null) { acc += 0.5 * items[i]; n++; }
if (!n) { notes.push('肤色水润:肤色项与水润项均缺失,肤色水润分类不参与评分'); return { P: null, vars: {} }; }
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
var itaObj = r.skintone_ita || {};
var haObj = r.skin_hue_ha || {};
var st = itaObj.skin_tone != null ? itaObj.skin_tone
: (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone
: (itaObj.tone != null ? itaObj.tone : null));
return {
P: P,
vars: {
ita: val(itaObj.ITA != null ? itaObj.ITA : itaObj.value),
st: st,
ha: val(haObj.HA != null ? haObj.HA : haObj.value),
melanin: melanin,
spot_flag: (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记',
water_score: waterScore,
water_area: waterArea,
water_sev: waterSev
}
};
}
// 敏感:P = min(sensitivity_score, 100−面积%×1.5);面积≥15% 锁79、≥30% 锁74
function calcSens(r, notes) {
var sensObj = r.sensitivity || null;
var score = pick(r, 'sensitivity_score');
if (score == null && sensObj) score = val(sensObj.sensitivity_score);
// 面积口径来源(V2.0 第二章字段来源):优先 red_spot.red_spot_area(小数×100=%);
// 缺失再回退 sensitivity.sensitivity_area;两者均保留 ≤1→×100 启发式
var areaRaw = r.red_spot && typeof r.red_spot === 'object' ? val(r.red_spot.red_spot_area) : null;
if (areaRaw == null) {
areaRaw = sensObj ? val(sensObj.sensitivity_area) : null;
if (areaRaw == null) areaRaw = pick(r, 'sensitivity_area');
}
if (score == null && areaRaw == null) {
notes.push('sensitivity 对象缺失,敏感分类不参与评分与均分(禁止编造降级假分数)');
return { P: null, vars: { missing: true } };
}
// 面积口径:≤1 视为小数(如 0.001)×100 换算成百分比;否则按百分比直读(如 2.0 → 2%)
var areaPct = areaRaw == null ? null : (areaRaw <= 1 ? areaRaw * 100 : areaRaw);
var cand = [];
if (score != null) cand.push(score);
if (areaPct != null) cand.push(100 - areaPct * 1.5);
var P = Math.min.apply(null, cand);
if (areaPct != null) {
if (areaPct >= 30) P = Math.min(P, 74);
else if (areaPct >= 15) P = Math.min(P, 79);
}
return { P: P, vars: { sens_area: areaPct, sens_score: score } };
}
// 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50
function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); }
function calcAcne(r, notes) {
var score = pick(r, 'acne_score');
var cc = cnt(r.closed_comedones); // 闭口 ×1
var pa = cnt(r.acne); // 丘疹 ×2
var pu = cnt(r.acne_pustule); // 脓包 ×4
var no = cnt(r.acne_nodule); // 结节 ×6
var mk = cnt(r.acne_mark); // 痘印 ×1.5
var counts = [cc, pa, pu, no, mk], allNull = true, i;
for (i = 0; i < 5; i++) if (counts[i] != null) { allNull = false; break; }
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, vars: {} };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var deduct = Math.min(50, (cc || 0) * 1 + (pa || 0) * 2 + (pu || 0) * 4 + (no || 0) * 6 + (mk || 0) * 1.5);
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
var detail = joinParts([
cc ? '闭口粉刺' + cc + '处' : '',
pa ? '丘疹' + pa + '处' : '',
pu ? '脓包' + pu + '处' : '',
no ? '结节' + no + '处' : '',
mk ? '痘印' + mk + '处' : ''
], '、');
var total = 0;
for (i = 0; i < 5; i++) total += counts[i] || 0;
return {
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail,
heavy: (pu || 0) > 0 || (no || 0) > 0 // 仅在真实检出脓包/结节时输出 heavy_note
}
};
}
/* ─── 话术库(V2.0 第五章逐字;变量缺失省略对应分句) ─── */
function oilDesc(oily) { // {oil_desc} 出油概览句(自设计措辞,文档未给全文)
if (oily == null) return '';
if (oily < 40) return '肌肤出油较少,整体清爽';
if (oily < 60) return 'T区存在轻度出油';
return '全脸出油偏多,需加强控油';
}
function jawSeg(v, goodTpl) { // 组合下颌线分句;返回 '' 表示省略
if (v.la == null && v.ra == null) return '';
var ang = v.la != null && v.ra != null ? '下颌线角度左' + fmt(v.la) + '°/右' + fmt(v.ra) + '°'
: (v.la != null ? '下颌线角度左' + fmt(v.la) + '°' : '下颌线角度右' + fmt(v.ra) + '°');
var outL = v.la != null && (v.la < 116 || v.la > 120);
var outR = v.ra != null && (v.ra < 116 || v.ra > 120);
var paren;
if (goodTpl && !outL && !outR) paren = '(处于标准区间)';
else if (outL && outR) paren = '(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降)';
else if (outL || outR) paren = '(标准区间116–120°,单侧偏离标准,提示下颌缘支撑力下降)';
else paren = '(标准区间116–120°)';
return ang + paren;
}
function eyeSeg(v) {
return joinParts([
v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '',
v.dark_circle_sev != null ? '黑眼圈' + fmt(v.dark_circle_sev) + '级' : ''
], '、');
}
function textContour(P, v) {
var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v);
if (lv === '预警') return fill('面部轮廓出现可见松弛{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
// V2.0 §5.1 改善档原文只含眼袋,不含黑眼圈
if (lv === '改善') return fill('{jaw},{eye},眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。',
{ jaw: jaw, eye: v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '' });
return fill('面部轮廓紧致立体{jaw},{eye},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
}
function wrSeg(v) {
return joinParts([
v.wr_cnt != null ? '全脸皱纹' + fmt(v.wr_cnt) + '条' : '',
v.fl_cnt != null ? '细纹' + fmt(v.fl_cnt) + '条' : ''
], '、');
}
function textWrinkles(P, v) {
var lv = levelOf(P), seg = wrSeg(v);
var paren = v.wr_max != null
? (lv === '预警'
? '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',最高严重度' + fmt(v.max_sev) + '级' : '') + ')'
: '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',严重度' + fmt(v.max_sev) + '级' : '') + ')')
: '';
if (lv === '预警') return fill('{seg},最差区域为{r}{p},部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。',
{ seg: seg, r: v.worst_wr_region, p: paren });
if (lv === '改善') return fill('{seg},最差区域为{r}{p}。建议加强日常淡纹与弹润护理,保持肌肤滋润度。',
{ seg: seg, r: v.worst_wr_region, p: paren });
return fill('全脸12分区纹路平整(最差区域{r}{p2}){fl},未见明显静态纹。建议维持日常保湿与防晒,保持现有淡纹护理节奏。',
{ r: v.worst_wr_region, p2: v.wr_max != null ? ',分区分' + fmt(v.wr_max) : '', fl: v.fl_cnt != null ? ',细纹合计' + fmt(v.fl_cnt) + '条' : '' });
}
function cntSeg(v) {
return joinParts([
v.pore_cnt != null ? '粗大毛孔' + fmt(v.pore_cnt) + '颗' : '',
v.blackhead_cnt != null ? '黑头' + fmt(v.blackhead_cnt) + '颗' : ''
], '、');
}
function textSmooth(P, v) {
var lv = levelOf(P), seg = cntSeg(v), oil = oilDesc(v.oily);
if (lv === '预警') return fill('皮肤表面平整度较低,最差毛孔区位于{r}{p},{c}{o}纹理分仅{t},整体细腻度不足。建议避免过度清洁,重点关注{r},配合低浓度酸类周期护理与柔润精华。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
if (lv === '改善') return fill('皮肤表面整体较为平滑,毛孔最差区为{r}{p},{c}{o}纹理分{t},局部存在轻微角质粗糙感。建议加强基础清洁与低浓度酸类周期护理。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
return fill('皮肤表面细腻平滑,最差毛孔区{r}控制良好(分区毛孔分{p}{c}),纹理分{t},角质层排列整齐。建议维持温和清洁与保湿节奏。',
{ r: v.worst_pore_region, p: fmt(v.pore4_min), c: seg ? ',' + seg : '', t: fmt(v.tex) });
}
function itaHdr(v) { // 「肤色ITA {ita}({st},HA {ha})」组合,缺项省略
var stStr = (v.st === null || v.st === undefined || v.st === '') ? '' : String(v.st); // st=0 是合法肤色分类,不能被 falsy 吞掉
var inner = joinParts([stStr, v.ha != null ? 'HA ' + fmt(v.ha) : ''], ',');
if (v.ita == null && !inner) return '';
if (v.ita == null) return '肤色' + (inner ? '(' + inner + ')' : '');
return '肤色ITA ' + fmt(v.ita) + (inner ? '(' + inner + ')' : '');
}
function textTone(P, v) {
var lv = levelOf(P), hdr = itaHdr(v);
var wseg = joinParts([
v.water_area != null ? '缺水面积' + fmt(v.water_area) + '%' : '',
v.water_sev != null ? '严重度' + fmt(v.water_sev) : ''
], '、');
if (wseg) wseg = ',' + wseg;
var vars = { h: hdr, m: fmt(v.melanin), f: v.spot_flag, w: fmt(v.water_score), ws: wseg };
if (lv === '预警') return fill('{h},色素分{m},整体偏暗沉({f});水分分{w}{ws},水润度不足。建议加强透亮与补水护理,做好防晒,减少色素刺激。', vars);
if (lv === '改善') return fill('{h},色素分{m},整体稳定但存在局部暗沉倾向({f});水分分{w}{ws}。建议加强抗氧化与光泽管理,做好基础防晒与补水。', vars);
return fill('{h},色素分{m},肤色均匀透亮({f});水分分{w}{ws}。色素与水润状态均衡,建议持续做好防晒与基础保湿。', vars);
}
function textSens(P, v) {
if (v.missing) return '本次检测未获取到敏感度数据(sensitivity 字段缺失),敏感分类暂不参与评分,建议重新拍摄检测以获取完整结果。';
var lv = levelOf(P), vars = { a: fmt(v.sens_area), s: fmt(v.sens_score) };
if (lv === '预警') return fill('泛红面积达{a}%,红区分{s},屏障受损信号明显、敏感区域较大。建议立即转为舒缓修护路线,停用酸类与强清洁,必要时寻求专业建议。', vars);
if (lv === '改善') return fill('泛红面积{a}%,红区分{s},存在局部轻微泛红与屏障波动。建议使用舒缓修护类产品,减少刺激源,注意温差与清洁力度。', vars);
return fill('泛红面积{a}%,红区分{s},屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', vars);
}
function textAcne(P, v) {
var lv = levelOf(P);
var heavy = v.heavy ? ',含脓包、结节等中重度皮损,炎症反应明显' : '';
// 「检出N处皮损(明细)」与 heavy 拼接成句:heavy 为空时不得拖垮整分句(fill 的缺失省略以分句为单位)
var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : '';
if (lv === '预警') return (found ? found + heavy + '。' : '')
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
if (lv === '改善') return (found ? found + ',' : '')
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
/* ─── 常量表 ─── */
var CAT_DEFS = [ // 顺序即最差分类并列取舍优先级:轮廓>皱纹>光滑>肤色水润>敏感>痘痘
{ key: 'contour', name: '轮廓', dim: 'firm', calc: calcContour, text: textContour },
{ key: 'wrinkles', name: '皱纹', dim: 'firm', calc: calcWrinkles, text: textWrinkles },
{ key: 'smooth', name: '光滑', dim: 'vita', calc: calcSmooth, text: textSmooth },
{ key: 'tone', name: '肤色水润', dim: 'vita', calc: calcTone, text: textTone },
{ key: 'sens', name: '敏感', dim: 'stab', calc: calcSens, text: textSens },
{ key: 'acne', name: '痘痘', dim: 'stab', calc: calcAcne, text: textAcne }
];
var DIM_DEFS = [
{ key: 'firm', name: '紧塑力', tip: '轮廓支撑与紧致度:重点针对下颌线与眼周使用紧致类精华,配合由下向上的提拉按摩手法' },
{ key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' },
{ key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' }
];
var LIFESTYLE = { // V2.0 §6.2 全文
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏',
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
};
var ESSENCE = { // V2.0 §6.1 三档全文
1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。' }
};
/* ─── 主入口 ─── */
function buildViewModel(apiResult, sideResults, profile) {
var r = apiResult || {};
profile = profile || {};
var notes = [];
var realAge = val(profile.realAge);
// 肌肤年龄:原值不改写,展示值钳制到 realAge±3 并留痕
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
if (realAge == null) {
notes.push('未提供实际年龄:产品组别、肌肤年龄钳制与精华「年龄组档」判据均不生效');
} else if (skinAge != null) {
skinAgeDisplay = Math.min(Math.max(skinAge, realAge - 3), realAge + 3);
if (skinAgeDisplay !== skinAge) ageClampNote = '触发(原' + skinAge + '→' + skinAgeDisplay + ')';
}
var group = null;
if (realAge != null) group = realAge < 32 ? '25+组' : (realAge <= 45 ? '35+组' : '50+组');
// 6 分类
var cats = {}, sList = [], i, def, res;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
res = def.key === 'contour' ? def.calc(r, sideResults, notes) : def.calc(r, notes);
cats[def.key] = {
name: def.name, dim: def.dim,
P: res.P, S: sShow(res.P), level: levelOf(res.P),
text: res.P == null
? (res.vars.missing ? def.text(null, res.vars) : '本次检测' + def.name + '分类数据缺失,暂不参与评分。')
: def.text(res.P, res.vars)
};
if (res.P != null) sList.push(sRaw(res.P));
}
// 三维度(木桶 min,缺项剔除)
var dims = {};
for (i = 0; i < DIM_DEFS.length; i++) {
var d = DIM_DEFS[i], ps = [], j;
for (j = 0; j < CAT_DEFS.length; j++) {
if (CAT_DEFS[j].dim !== d.key) continue;
var P = cats[CAT_DEFS[j].key].P;
if (P != null) ps.push(P);
}
var dP = ps.length ? Math.min.apply(null, ps) : null;
if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分');
dims[d.key] = { name: d.name, P: dP, S: sShow(dP), level: levelOf(dP) };
}
// 均分(判档用未四舍五入原值)与最差分类(并列按 CAT_DEFS 顺序取前者)
var avgRaw = null, avgS = null;
if (sList.length) {
var sum = 0;
for (i = 0; i < sList.length; i++) sum += sList[i];
avgRaw = sum / sList.length;
avgS = round2(avgRaw);
}
var worst = null;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
var cP = cats[def.key].P;
if (cP == null) continue;
if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) };
}
// 精华推荐:三重判据取最强档,triggers 记录命中(档位=最终档)的判据
var crits = []; // {name, tier}
if (avgRaw != null) crits.push({ name: '均分档', tier: avgRaw >= 9 ? 1 : (avgRaw >= 8 ? 2 : 3) });
if (worst) {
var wS = sRaw(worst.P);
if (wS < 7) crits.push({ name: '最差项约束', tier: 3 });
else if (wS < 8) crits.push({ name: '最差项约束', tier: 2 });
}
if (group) crits.push({ name: '年龄组档', tier: group === '25+组' ? 1 : (group === '35+组' ? 2 : 3) });
var tier = 1;
if (crits.length) {
tier = crits[0].tier;
for (i = 0; i < crits.length; i++) tier = Math.max(tier, crits[i].tier); // 推荐强度只升不降
} else {
notes.push('三重判据均不可用,精华推荐缺省为 25+束紧精华');
}
var triggers = [];
for (i = 0; i < crits.length; i++) if (crits[i].tier === tier) triggers.push(crits[i].name);
var essence = { name: ESSENCE[tier].name, text: ESSENCE[tier].text, triggers: triggers };
// 近期优先关注:最差维度
var focus = { name: '', tip: '' }, fP = null;
for (i = 0; i < DIM_DEFS.length; i++) {
var dd = DIM_DEFS[i], dP2 = dims[dd.key].P;
if (dP2 == null) continue;
if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; }
}
// 综合文案(§6.1)
var summary;
if (avgRaw == null) {
summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。';
} else {
var ageSeg = '';
if (realAge != null) {
ageSeg = '实际年龄' + realAge + '岁';
if (skinAge != null) ageSeg += '(接口测得肌肤年龄' + skinAge + '岁,差' + Math.abs(skinAge - realAge) + '岁)';
} else if (skinAge != null) {
ageSeg = '接口测得肌肤年龄' + skinAge + '岁';
}
var head = joinParts([ageSeg, group ? '产品组别' + group : ''], ',');
// 优先改善:P 最低两项
var sorted = [];
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
if (cats[def.key].P != null) sorted.push({ name: def.name, P: cats[def.key].P });
}
sorted.sort(function (a, b) { return a.P - b.P; });
var improve = '';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmt(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + '),其余维度维持现有护理节奏。';
var tail = joinParts([
profile.skinType ? '整体肤质' + profile.skinType : '',
oilDesc(pick(r, 'oily_intensity_score'))
], ',');
summary = '【综合】' + (head ? head + ';' : '')
+ '六分类S均分' + fmt(avgS) + (worst ? '、最差分类' + worst.name + ' S=' + fmt(worst.S) : '') + '。'
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
}
return {
total: pick(r, 'total_score'),
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
dims: dims,
cats: cats,
group: group,
essence: essence,
avgS: avgS,
worst: worst,
focus: focus,
summary: summary,
lifestyle: worst ? LIFESTYLE[worst.catKey] : '',
notes: notes
};
}
/* ─── Mock 数据(pro 形状;数值对齐测试文档「图片 13」案例) ─── */
var MOCK_RESULT = {
score_info: {
total_score: 79,
water_score: 83,
rough_score: 87,
sensitivity_score: 98,
melanin_score: 80,
blackhead_score: 88,
acne_score: 86,
oily_intensity_score: 42,
pores_type_score: { // §137:嵌套在 score_info 内
pores_forehead_score: 73, pores_left_cheek_score: 86,
pores_right_cheek_score: 85, pores_jaw_score: 90,
pores_forehead_severity: 1, pores_left_cheek_severity: 1,
pores_right_cheek_severity: 1, pores_jaw_severity: 1
}
},
skin_age: { value: 20 },
skin_type: { skin_type: 3 },
eye_pouch: { value: 0, confidence: 0.75 },
eye_pouch_severity: { value: 0 },
dark_circle: { value: 1, confidence: 0.63 },
wrinkle_count: { value: 11 },
forehead_wrinkle_info: { wrinkle_score: 20, wrinkle_severity_level: 1 },
glabella_wrinkle_info: { wrinkle_score: 15, wrinkle_severity_level: 1 },
left_eye_wrinkle_info: { wrinkle_score: 30, wrinkle_severity_level: 1 },
right_eye_wrinkle_info: { wrinkle_score: 25, wrinkle_severity_level: 1 },
left_crowsfeet_wrinkle_info: { wrinkle_score: 40, wrinkle_severity_level: 2 },
right_crowsfeet_wrinkle_info: { wrinkle_score: 35, wrinkle_severity_level: 1 },
left_nasolabial_wrinkle_info: { wrinkle_score: 60, wrinkle_severity_level: 2 },
right_nasolabial_fold_info: { wrinkle_score: 77, wrinkle_severity_level: 3 }, // _fold_info 变体
left_mouth_wrinkle_info: { wrinkle_score: 45, wrinkle_severity_level: 2 },
right_mouth_wrinkle_info: { wrinkle_score: 50, wrinkle_severity_level: 2 },
left_cheek_wrinkle_info: { wrinkle_score: 10, wrinkle_severity_level: 0 },
right_cheek_wrinkle_info: { wrinkle_score: 12, wrinkle_severity_level: 0 },
water: { water_score: 83, water_area: 15.0, water_severity: 12 },
sensitivity: { sensitivity_score: 98, sensitivity_area: 0.001, sensitivity_intensity: 30 }, // 0.001=小数口径→0.1%
melasma: { value: 0 },
freckle: { value: 0 },
skintone_ita: { ITA: 71.8 },
skin_hue_ha: { HA: 2 },
enlarged_pore_count: { forehead: 150, left_cheek: 66, right_cheek: 50, jaw: 30 }, // 合计 296
blackhead: { count: 39, rectangle: [] },
closed_comedones: { count: 3, rectangle: [{}, {}, {}] },
acne: { count: 3, rectangle: [{}, {}, {}] }, // 丘疹
acne_pustule: { count: 1, rectangle: [{}] },
acne_nodule: { count: 0, rectangle: [] },
acne_mark: { count: 1, rectangle: [{}] },
mole: { count: 2, rectangle: [{}, {}] }
};
var MOCK_SIDE = {
left: { left_jawline_info: { left_jawline_angle: 117.2, left_jawline_angle_level: 0, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
return {
buildViewModel: buildViewModel,
MOCK_RESULT: MOCK_RESULT,
MOCK_SIDE: MOCK_SIDE
};
}));
/* ─── Node 自测:node h5/js/skin-score.js ─── */
if (typeof require === 'function' && typeof module !== 'undefined' && require.main === module) {
(function () {
var SS = module.exports;
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
function approx(a, b) { return a != null && Math.abs(a - b) < 1e-9; }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function collectTexts(vm) { // 收集 viewModel 全部面向用户的字符串
var out = [], k;
out.push(vm.summary, vm.lifestyle, vm.ageClampNote, vm.focus.name, vm.focus.tip, vm.essence.name, vm.essence.text);
for (k in vm.cats) out.push(vm.cats[k].text);
for (k = 0; k < vm.notes.length; k++) out.push(vm.notes[k]);
return out;
}
var BAD = /None|undefined|NaN|null|—|\{[a-z_0-9]+\}/i;
function checkTexts(vm, tag) {
var ts = collectTexts(vm), bad = [];
for (var i = 0; i < ts.length; i++) if (BAD.test(ts[i])) bad.push(ts[i]);
ok(bad.length === 0, tag + ':话术无 None/undefined/NaN/null/—/未替换占位' + (bad.length ? ' → ' + bad[0] : ''));
}
console.log('== T0 MOCK 全量(数值对拍测试文档「图片 13」) ==');
var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' });
ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)');
ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P = 100−77 = 23(severity=3 锁74 不再抬升)');
ok(approx(vm.cats.contour.P, 94.8), '轮廓P = min(眼袋100, 下颌线较差侧94.8) = 94.8');
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.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 === 81.5 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 活力感81.5 / 稳定力85');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 22 && vm.ageClampNote === '触发(原20→22)', '肌肤年龄钳制 20→22 留痕,原值不改写');
ok(vm.group === '25+组', 'realAge=25 → 25+组');
ok(vm.essence.name === '35+支撑精华' && vm.essence.triggers.length === 1 && vm.essence.triggers[0] === '最差项约束',
'精华三重判据:均分9.32→25+、最差S7.69→至少35+、年龄→25+,取最强档35+,triggers=["最差项约束"]');
ok(vm.avgS === 9.32, '六分类S均分 9.32');
ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹');
ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力');
ok(vm.total === 79, '综合评分 = score_info.total_score');
ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('35+支撑精华') > 0, '综合文案生成');
ok(vm.cats.smooth.text.indexOf('粗大毛孔296颗、黑头39颗') >= 0 && vm.cats.smooth.text.indexOf('纹理分87') >= 0,
'光滑话术含粗大毛孔/黑头/纹理分变量(分区计数 4 区求和 296)');
checkTexts(vm, 'T0');
console.log('== T1 上限锁 ==');
var r1 = clone(SS.MOCK_RESULT);
var rk;
for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95
r1.right_nasolabial_fold_info.wrinkle_severity_level = 3;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 74, '皱纹分区 severity=3 → 锁 74');
r1.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 79, '皱纹分区 severity=2 → 锁 79');
var r1b = clone(SS.MOCK_RESULT);
r1b.score_info.pores_type_score.pores_forehead_severity = 2; // 自然 81.7
ok(SS.buildViewModel(r1b, SS.MOCK_SIDE, { realAge: 25 }).cats.smooth.P === 79, '毛孔分区 severity=2 → 光滑锁 79');
checkTexts(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }), 'T1');
console.log('== T2 木桶 min(显式构造) ==');
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)
&& 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(旗下两分类)');
ok(vm2.group === '35+组', 'realAge=40 → 35+组');
console.log('== T3 钳制边界 ==');
var r3 = clone(SS.MOCK_RESULT);
r3.skin_age = { value: 24 };
var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
r3.skin_age = { value: 40 };
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3b.skinAgeDisplay === 28 && vm3b.ageClampNote === '触发(原40→28)' && vm3b.skinAgeRaw === 40, '上边界钳制 40→28');
console.log('== T4 精华三重判据:最差项 S<7 → 50+重塑 ==');
var r4 = clone(SS.MOCK_RESULT);
r4.score_info.melanin_score = 0;
r4.score_info.water_score = 0;
r4.melasma = { value: 1 };
r4.freckle = { value: 1 };
r4.water = { water_score: 0, water_area: 40, water_severity: 70 }; // sev≥60 但 score<80,不触发换算
var vm4 = SS.buildViewModel(r4, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm4.cats.tone.P, -7.5), '肤色水润P = 0.5×(0−10−5)+0.5×0 = −7.5(P 原值不动,不夹逼)');
ok(vm4.cats.tone.S === 7, '展示层 S 钳到 7(未钳原值 6.78 仍参与判档)');
ok(vm4.essence.name === '50+重塑精华' && vm4.essence.triggers.indexOf('最差项约束') >= 0, '最差项 S 原值<7 → 50+重塑精华,triggers 含最差项约束(三重判据用未舍入原值)');
checkTexts(vm4, 'T4');
console.log('== T5 缺数据降级(V2.0 第七章) ==');
var r5 = clone(SS.MOCK_RESULT);
delete r5.sensitivity;
delete r5.score_info.sensitivity_score;
var vm5 = SS.buildViewModel(r5, SS.MOCK_SIDE, { realAge: 25 });
ok(vm5.cats.sens.P === null && vm5.cats.sens.text.indexOf('缺失') >= 0, 'sensitivity 缺失 → 敏感 P=null + 数据缺失提示(不编假分数)');
ok(vm5.dims.stab.P === 85, '稳定力剔除敏感项 = 痘痘P 85');
var hasSensNote = false, k;
for (k = 0; k < vm5.notes.length; k++) if (vm5.notes[k].indexOf('sensitivity') >= 0) hasSensNote = true;
ok(hasSensNote, 'notes 记录 sensitivity 缺失降级');
ok(vm5.avgS === 9.2, '均分剔除敏感项按 5 分类计算(未舍入 9.196 → 展示 9.2)');
var vm5b = SS.buildViewModel(clone(SS.MOCK_RESULT), null, { realAge: 25 });
ok(vm5b.cats.contour.P === 100 && vm5b.notes.join('|').indexOf('侧脸') >= 0, '无侧脸 → 轮廓 P 由眼袋单独决定并记 note');
var vm5c = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, {});
ok(vm5c.group === null && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === ''
&& vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0,
'realAge 缺失 → group=null、不钳制、判据③不生效并记 note');
checkTexts(vm5, 'T5');
console.log('== T6 下颌线 quality 降级 ==');
var vm6 = SS.buildViewModel(clone(SS.MOCK_RESULT), {
left: { left_jawline_info: { left_jawline_angle: 100, left_jawline_angle_level: 1, left_jawline_quality: 3 } },
right: { right_jawline_info: { right_jawline_angle: 118, right_jawline_angle_level: 0, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm6.cats.contour.P === 100, '左 quality=3 不参与,右 118° 标准 → 轮廓 P=min(100,100)=100');
ok(vm6.notes.join('|').indexOf('左侧脸角度无效(quality=3)') >= 0, 'notes 含「左侧脸角度无效(quality=3)」');
console.log('== T7 AR列数值对拍(第二条) ==');
var vm7 = SS.buildViewModel({
score_info: { sensitivity_score: 30 },
sensitivity: { sensitivity_score: 30, sensitivity_area: 0.103 } // 图片37:10.3%
}, null, { realAge: 29 });
ok(vm7.cats.sens.P === 30, '敏感P = min(30, 100−10.3×1.5=84.55) = 30(AR列对拍)');
console.log('== T8 count 缺失退化 rectangle 长度 ==');
var r8 = clone(SS.MOCK_RESULT);
r8.acne_pustule = { rectangle: [{}, {}] }; // 无 count → 2 个脓包
var vm8 = SS.buildViewModel(r8, SS.MOCK_SIDE, { realAge: 25 });
// 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85
ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)');
ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处
ok(vm8b.cats.acne.text.indexOf('含脓包、结节等中重度皮损') < 0, '改善档无 heavy_note(heavy_note 仅预警档且真实检出时输出)');
var r8c = clone(SS.MOCK_RESULT);
r8c.score_info.acne_score = 70; // 分级映射 → 55,预警档
ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),含脓包、结节等中重度皮损,炎症反应明显。') >= 0,
'预警档真实检出脓包 → heavy_note 逐字输出');
console.log('== T9 真实接口字段形状(对齐 reference/facepp-skinanalyze/examples 样本) ==');
// (5)(9) 毛孔分区无下划线变体键 + 无 severity 键
var r9 = clone(SS.MOCK_RESULT);
r9.score_info.pores_type_score = { pores_forehead_score: 60, pores_leftcheek_score: 50, pores_rightcheek_score: 72, pores_jaw_score: 84 };
var vm9 = SS.buildViewModel(r9, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9.cats.smooth.text.indexOf('左侧面颊') >= 0 && vm9.cats.smooth.text.indexOf('leftcheek') < 0,
'毛孔分区无下划线变体 pores_leftcheek_score → 中文「左侧面颊」,话术无英文键名残留');
ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」');
checkTexts(vm9, 'T9-毛孔');
// (6) 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源)
var r9b = clone(SS.MOCK_RESULT);
r9b.red_spot = { red_spot_area: 0.006, red_spot_intensity: 3 }; // 小数口径 → 0.6%
r9b.sensitivity.sensitivity_area = 0.5; // 若干扰字段被误读会变 50% → 锁 74
var vm9b = SS.buildViewModel(r9b, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9b.cats.sens.P === 98 && vm9b.cats.sens.text.indexOf('泛红面积0.6%') >= 0,
'敏感面积优先 red_spot.red_spot_area:0.006×100=0.6%,P=min(98, 100−0.9)=98(不回退 sensitivity_area=0.5)');
ok(SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }).cats.sens.P === 98,
'red_spot 缺失 → 回退 sensitivity.sensitivity_area(0.001 小数口径→0.1%,P=98)');
// (7) wrinkle_count/fine_line 为分区计数对象(真实样本形状,无 .value)
var r9d = clone(SS.MOCK_RESULT);
r9d.wrinkle_count = { forehead_count: 22, left_undereye_count: 58, right_undereye_count: 44, glabella_count: 14, left_crowsfeet_count: 10, right_crowsfeet_count: 7, left_mouth_count: 1, right_mouth_count: 1, left_nasolabial_count: 1, right_nasolabial_count: 1, left_cheek_count: 0, right_cheek_count: 0 };
r9d.fine_line = { forehead_count: 18, left_undereye_count: 57, right_undereye_count: 46, glabella_count: 9, left_crowsfeet_count: 11, right_crowsfeet_count: 9, left_cheek_count: 0, right_cheek_count: 0 };
var vm9d = SS.buildViewModel(r9d, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9d.cats.wrinkles.text.indexOf('全脸皱纹159条') >= 0 && vm9d.cats.wrinkles.text.indexOf('细纹150条') >= 0,
'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条');
checkTexts(vm9d, 'T9-皱纹计数');
// (8) 肤色分类真实键名 skintone_ita.skintone(值为 0 时不得被 falsy 吞掉)
var r9e = clone(SS.MOCK_RESULT);
r9e.skintone_ita = { ITA: 58.64615, skintone: 0 };
r9e.skin_hue_ha = { HA: 56.246513, skin_hue: 0 };
var vm9e = SS.buildViewModel(r9e, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.65(0,HA 56.25)') >= 0, 'skintone_ita.skintone=0 → 肤色分类 0 正常输出');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.0 §5.1 逐字)
var r9f = clone(SS.MOCK_RESULT);
r9f.eye_pouch_severity = { value: 1.6 };
var vm9f = SS.buildViewModel(r9f, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9f.cats.contour.P === 79 && vm9f.cats.contour.text.indexOf('眼袋2级') >= 0,
'眼袋 severity 非整数 1.6 → 取整 2 → 映射 79(min(79, 下颌线94.8),预警档)');
var vm9f2 = SS.buildViewModel(clone(SS.MOCK_RESULT), { // 下颌线 117°/110° → jawP=88 → P=88 改善档
left: { left_jawline_info: { left_jawline_angle: 117, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0,
'轮廓改善档含「眼袋0级」且无「黑眼圈」(§5.1 改善档原文无黑眼圈)');
var r9g = clone(SS.MOCK_RESULT);
r9g.eye_pouch_severity = { value: 9 };
var vm9g = SS.buildViewModel(r9g, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm9g.cats.contour.P, 94.8) && vm9g.notes.join('|').indexOf('眼袋 severity 越界(9)') >= 0,
'眼袋 severity 越界 9 → 按缺失处理记 note,轮廓 P 仅由下颌线 94.8 决定');
checkTexts(vm9f, 'T9-眼袋');
// (13) 痘痘良好档计数全缺失 → 缺数据提示句,不声称「未检出」
var r9h = clone(SS.MOCK_RESULT);
delete r9h.closed_comedones; delete r9h.acne; delete r9h.acne_pustule; delete r9h.acne_nodule; delete r9h.acne_mark;
r9h.score_info.acne_score = 96; // 分级映射 100 → 良好档
var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0,
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
// 预警档无脓包/结节(heavy 为空)时「检出N处皮损(明细)」不得被 fill 分句省略拖垮(真实样本形状:丘疹3+闭口6)
var r9i = clone(SS.MOCK_RESULT);
r9i.acne_pustule = { count: 0, rectangle: [] };
r9i.acne_mark = { count: 0, rectangle: [] };
r9i.score_info.acne_score = 70; // 分级映射 55 → 预警档
var vm9i = SS.buildViewModel(r9i, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9i.cats.acne.text.indexOf('检出6处皮损(闭口粉刺3处、丘疹3处)。建议尽快') >= 0,
'痘痘预警档无中重度皮损 → 计数明细正常输出、无 heavy_note');
console.log('----------------------------------------');
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
if (fail > 0) process.exit(1);
}());
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+112 -26
View File
@@ -1,7 +1,7 @@
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 1'me — 后端服务(零依赖,Node ≥ 18)
1) 静态托管 h5/ 目录
2) POST /api/skin-analyze:图片中转 Face++ 高阶版测肤
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
前端永远接触不到 api_secret。
启动: node server.js (默认 8080,可用 PORT 环境变量改)
@@ -14,9 +14,9 @@ const path = require('path');
const PORT = Number(process.env.PORT) || 8080;
const ROOT = __dirname;
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_advanced';
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
/* ─── 密钥加载:环境变量优先,其次解析项目根的 apikey.bash ─── */
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
function loadCredentials() {
if (process.env.SKIN_API_KEY && process.env.SKIN_API_SECRET) {
return { key: process.env.SKIN_API_KEY, secret: process.env.SKIN_API_SECRET };
@@ -26,9 +26,16 @@ function loadCredentials() {
const key = (txt.match(/SKIN_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则继续下一级兜底 */ }
try {
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', 'credentials.env'), 'utf8');
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
if (key && secret) return { key, secret };
} catch (e) { /* 文件不存在则走下面的报错 */ }
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等)');
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等),');
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等');
process.exit(1);
}
const CRED = loadCredentials();
@@ -41,7 +48,13 @@ const MIME = {
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2'
};
function serveStatic(req, res, pathname) {
let p = decodeURIComponent(pathname);
let p;
try {
p = decodeURIComponent(pathname);
} catch (e) { // 畸形编码(如 /%、/%zz)会抛 URIError,返回 400 而不是打挂进程
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('Bad Request');
}
if (p === '/') p = '/index.html';
const file = path.normalize(path.join(ROOT, p));
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
@@ -63,38 +76,93 @@ const ERR_MSG = {
'AUTHENTICATION_ERROR': '检测服务配置有误,请联系管理员',
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试'
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试',
'NETWORK_ERROR': '网络异常,请检查网络后重试'
};
function readBody(req, limitBytes) {
return new Promise((resolve, reject) => {
let size = 0; const chunks = [];
let size = 0, tooLarge = false; const chunks = [];
req.on('data', c => {
size += c.length;
if (size > limitBytes) { reject(new Error('TOO_LARGE')); req.destroy(); return; }
if (size > limitBytes) {
// 不 destroy:否则 catch 里的 JSON 响应永远发不出去;只 reject 并丢弃后续 chunk(排空)
if (!tooLarge) { tooLarge = true; reject(new Error('TOO_LARGE')); }
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('end', () => { if (!tooLarge) resolve(Buffer.concat(chunks)); });
req.on('error', reject);
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* 剥离 data:image/xxx;base64, 前缀,空值返回 '' */
function stripDataPrefix(s) {
return (s || '').replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, '');
}
/* base64 可用性校验:字符集合法且解码后 >500B(过小人脸框必然不合格,提前拦截避免白烧次数包) */
function isUsableB64(b64) {
if (!b64 || !/^[A-Za-z0-9+/=\s]+$/.test(b64)) return false;
return Buffer.from(b64, 'base64').length > 500;
}
/* 调用 Face++ 专业版三视图测肤;
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
async function callFacepp(front, left, right) {
const form = new FormData();
form.append('api_key', CRED.key);
form.append('api_secret', CRED.secret);
// 专业版三视图必须走文件形式(image_file),不支持 image_base64
form.append('image_file', new Blob([Buffer.from(front, 'base64')]), 'front.jpg');
if (left) form.append('left_side_image_file', new Blob([Buffer.from(left, 'base64')]), 'left.jpg');
if (right) form.append('right_side_image_file', new Blob([Buffer.from(right, 'base64')]), 'right.jpg');
form.append('return_side_results', 'jawline_info');
form.append('return_maps', 'water_area,red_area,brown_area,texture_enhanced_pores,texture_enhanced_blackheads,texture_enhanced_oily_area,texture_enhanced_lines,rough_area,roi_outline_map,texture_enhanced_bw');
form.append('return_marks', 'wrinkle_mark,melanin_mark,dark_circle_outline');
let fetchErr = null, fpRes = null, fpJson = null;
for (let attempt = 0; attempt < 2; attempt++) {
fetchErr = null; fpRes = null; fpJson = null;
try {
fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
fpJson = await fpRes.json().catch(() => ({}));
} catch (e) { // 60s 超时(AbortError)/网络异常:无 fpJson,与 5xx/并发超限统一走「等 4s 重试 1 次」
fetchErr = e;
}
const isConcurrency = !fetchErr && typeof fpJson.error_message === 'string' && fpJson.error_message.startsWith('CONCURRENCY_LIMIT_EXCEEDED');
const is5xx = !fetchErr && fpRes && !fpRes.ok && fpRes.status >= 500;
if (!fetchErr && !is5xx && !isConcurrency) return fpJson; // 正常响应(含 4xx 业务错误)直接返回
if (attempt === 0) {
const why = fetchErr ? '请求超时/网络异常' : (is5xx ? `HTTP ${fpRes.status}` : '并发超限');
console.log(`[analyze] Face++ ${why},4s 后重试 1 次`);
await sleep(4000);
}
}
// 重试后仍失败:fetch 抛错无 fpJson,合成 NETWORK_ERROR 走现有错误响应;5xx 则返回最后一次响应
return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson;
}
async function handleAnalyze(req, res) {
try {
const body = JSON.parse((await readBody(req, 12 * 1024 * 1024)).toString('utf8'));
const b64 = (body.image_base64 || '').replace(/^data:image\/\w+;base64,/, '');
if (!b64) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code: 'NO_IMAGE', message: '缺少图片' })); }
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
const rightB64 = stripDataPrefix(typeof body.right_image === 'string' ? body.right_image : '');
// 左/右图校验失败按未传处理(降级单视图),不报错
const left = isUsableB64(leftB64) ? leftB64 : '';
const right = isUsableB64(rightB64) ? rightB64 : '';
if (!front) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code: 'NO_IMAGE', message: '缺少图片' })); }
if (!isUsableB64(front)) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code: 'INVALID_IMAGE_SIZE', message: '照片数据异常,请重试' }));
}
const form = new FormData();
form.append('api_key', CRED.key);
form.append('api_secret', CRED.secret);
form.append('image_base64', b64);
form.append('face_quality_control', '0');
form.append('return_rect_confidence', '1');
form.append('return_maps', 'red_area');
const fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(30000) });
const fpJson = await fpRes.json().catch(() => ({}));
const fpJson = await callFacepp(front, left, right);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
@@ -106,13 +174,31 @@ async function handleAnalyze(req, res) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
}
// 只回传前端需要的字段,不透传密钥相关内容
const result = fpJson.result;
// face_maps 是整幅 base64 叠加图,体积过大且报告页暂不使用;
// 这里只保留其键名列表,便于前端/调用方知晓可用图层
if (result.face_maps && typeof result.face_maps === 'object') {
result.face_maps = Object.keys(result.face_maps);
}
// 下颌线结果在响应顶层 left_side_result/right_side_result(不在 result 内),
// 个别批次可能嵌套在 side_result 下,两种层级都兼容
const nested = fpJson.side_result || {};
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, result: fpJson.result, face_rectangle: fpJson.face_rectangle || null }));
res.end(JSON.stringify({
ok: true,
result,
face_rectangle: fpJson.face_rectangle || null,
side: {
left: fpJson.left_side_result || nested.left_side_result || null,
right: fpJson.right_side_result || nested.right_side_result || null
}
}));
} catch (e) {
const code = e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
const tooLarge = e.message === 'TOO_LARGE';
const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, code, message: code === 'NETWORK_ERROR' ? '网络异常,请检查网络后重试' : ERR_MSG[code] }));
// 响应发出后再销毁底层 socket:超大请求体客户端可能仍在推送,直接断开避免连接悬挂
res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] }), () => { if (tooLarge) req.destroy(); });
}
}
+60
View File
@@ -0,0 +1,60 @@
# face-api.js 本地化依赖(h5/vendor/faceapi/)
测肤 H5 的人脸角度检测(yaw / pitch / roll)依赖 face-api.js 及其模型。
所有文件均已本地化,**运行时零外部请求**(不依赖任何 CDN),保证微信 webview /
iOS Safari 在弱网、内网部署或 CDN 不可达场景下仍可稳定加载。
## 来源与版本
| 文件 | 来源 | 版本 / 分支 |
| --- | --- | --- |
| `face-api.js` | https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.js | 0.22.2(UMD 未压缩构建,0.22.2 未提供 min 版) |
| `models/tiny_face_detector_model-*` | https://github.com/justadudewhohacks/face-api.js-models (`tiny_face_detector/` 目录,raw master) | master |
| `models/face_landmark_68_model-*` | 同上(`face_landmark_68/` 目录,raw master) | master |
下载时间:2026-09-26。
## 许可证
- **face-api.js**(库与模型均出自该项目)以 **MIT License** 发布,
版权归 Vincent Mühler (justadudewhohacks) 所有。
许可证全文见 https://github.com/justadudewhohacks/face-api.js/blob/master/LICENSE 。
- `face-api.js` UMD 构建内联打包了 TensorFlow.js(tfjs-core / tfjs-converter 等),
该部分为 Apache License 2.0,Copyright 2020 Google LLC(见 `face-api.js` 文件头注释)。
MIT 与 Apache-2.0 均允许商用及再分发,保留版权声明即可。
## 文件清单与体积
| 路径 | 体积 | 说明 |
| --- | --- | --- |
| `face-api.js` | 859,250 B(≈839 KB) | UMD 构建,浏览器全局变量 `faceapi` |
| `models/tiny_face_detector_model-weights_manifest.json` | 2,953 B | 轻量人脸检测器权重清单(19 个张量) |
| `models/tiny_face_detector_model-shard1` | 193,321 B(≈189 KB) | 检测器权重分片 |
| `models/face_landmark_68_model-weights_manifest.json` | 7,889 B | 68 点特征模型权重清单(49 个张量) |
| `models/face_landmark_68_model-shard1` | 356,840 B(≈349 KB) | 68 点特征模型权重分片 |
合计约 1.4 MB。已用 Node 校验:manifest JSON 可解析、`paths` 引用的 shard 均存在且非空。
## 用法
```html
<script src="vendor/faceapi/face-api.js"></script>
<script>
const MODEL_URL = 'vendor/faceapi/models';
await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL);
await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL);
// 检测 + 68 特征点:
// const det = await faceapi
// .detectSingleFace(video, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 }))
// .withFaceLandmarks();
</script>
```
姿态估算(yaw/pitch/roll)由 `js/head-pose.js` 基于 68 特征点做纯几何计算,
不依赖 face-api 的内部 API,可独立单测。
## 为什么本地化
1. 主战场是 iOS Safari + 微信 webview,弱网/CDN 抖动会直接导致检测功能不可用;
2. 内网 / 私有化部署时外网 CDN 可能完全不可达;
3. 锁定 0.22.2 版本,避免上游变更引发回归。
+6568
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long