feat(diagnosis): 按新协议实现多光谱诊断算法+分区差异化下发+报告面罩图

- 新协议《协议简述-添加扫描时光色》落地: FFE4 Byte17 高3位=扫描光色(1红/2红外/3紫外/4暖黄),
  低5位保留贴合状态; Byte18=0xFF 故障; auto-scan 按光色分桶采集(旧固件无标识归 red 桶降级)
- diagnosis.js 重写: PD极性(值大=吸收多,固件已确认) NR=本区/本色5区平均-1;
  每区4色argmax>0.10判定; 3路PD区几何平均+MAD剔奇异(3σ对n=3失效,审计实证);
  严重度0.05/0.15/0.30仅展示; 全参数走 diagnosis_config 热调(极性/阈值/PD映射/亮度)
- buildVendorCommand 支持 plan 多区多色单命令下发(每IO独立 红/红外/紫外/暖黄);
  builder层安全硬上限 治疗亮度≤204(80%) 时长≤600s
- 区域映射修正: 掩码=IO纵列 左0x01/中上0x02/中0x04/中下0x08/右0x10,
  修复 treatment-setup/manual/treating/getRegionName/i18n 共7处标签错位, FULL_FACE 0x7F→0x1F
- 扫描报告页: 新增分区面罩示意图(5区按推荐光着色+模式名+严重度,点击跳光疗介绍),
  推荐护理改一条命令多色下发(亮度204); 扫描中设备故障即时中止
- schema.sql diagnosis_config 种子更新(新结构); i18n 清理死键,中英对齐25/24键
这个提交包含在:
Guoguo
2026-07-28 04:36:51 -07:00
父节点 b5416f56af
当前提交 6b91ebe35d
修改 19 个文件,包含 505 行新增234 行删除
+154 -119
查看文件
@@ -1,130 +1,165 @@
// Skin-tendency diagnosis — FRAMEWORK / placeholder implementation.
// Skin-tendency diagnosis — 按《扫描+护理完整流程.docx》7.1/7.2/7.3 +
// 《协议简述-添加扫描时光色.docx》+ 果果 2026-07 定案实现。
//
// STATUS: the diagnosis 依据 is NOT yet calibrated (客户也认为"还只是概念").
// This module defines the full data pipeline so the report UI + storage work end
// to end today, but the thresholds and the PD->region mapping are PLACEHOLDERS to
// be replaced after real-device calibration. It degrades gracefully when only
// partial scan data (currently single-wavelength red) is available.
// 核心链路:28 组 PD(4 波长×7 路) → 按 PD_REGION_MAP 聚合成 5 区 →
// 每波长算 5 区平均当基准 → 吸收度 NR = 本区/基准 − 1 →
// 每区取 4 个 NR 的最大者(argmax),超过阈值 TH 才判定问题 → 该波长即该区治疗光色。
//
// Two things here need hardware/calibration confirmation before this is "real":
// 1) PD_REGION_MAP — which of the 7 PDs feed which facial region (docs disagree).
// 2) levels thresholds — normalized-absorption cutoffs per severity level.
// Both can be overridden at runtime by the server-side diagnosis_config, so tuning
// after calibration does NOT require an app release.
// 极性(已由固件批注版文档坐实,新版协议简述再次确认"0x0FFF表示最低光照"):
// PD ADC 值越大 = 反射光越弱 = 皮肤吸收越多。故 NR = zone/avg − 1(吸收多→NR 高)。
// config.polarity = 'direct' 可切回"值大=光强"的旧假设(备用开关,标定时救急)。
//
// 仍未确认(走 config 热调,勿写死):
// 1) PD_REGION_MAP —— 7 路 PD 与 5 区的物理对应,所有文档从未给出,当前为推测值。
// 2) 3 路 PD 区的聚合 —— 文档 7.1 自相矛盾(几何平均 vs 取最小),按更细的几何平均+3σ剔异常实现。
// 所有阈值/亮度/映射均可被服务端 diagnosis_config 覆盖,标定后免发版调参。
// Problem type -> recommended care wavelength (IR=1, R=2, UV=3, Y=4).
// 问题类型 -> 治疗波长编码(下发命令用:IR=1, R=2, UV=3, Y=4)。
var PROBLEM_WAVELENGTH = { aging: 2, acne: 3, pigment: 4, deep: 1 }
// Scanned wavelength code -> the problem it reveals (inverse of the above).
var WAVELENGTH_PROBLEM = { 1: 'deep', 2: 'aging', 3: 'acne', 4: 'pigment' }
// Dictionary wave-key -> wavelength code.
// 波长 key -> 揭示的问题类型。
var WAVE_PROBLEM = { red: 'aging', uv: 'acne', yellow: 'pigment', ir: 'deep' }
// 波长 key -> 下发命令编码。
var WAVE_KEY_CODE = { red: 2, uv: 3, yellow: 4, ir: 1 }
// 5 regions in the app's neutral convention (right/left/top/middle/bottom),
// matching treatment-setup.js masks.
// 5 区 = IO 纵列布局(果果定案):左=IO1 中上=IO2 中=IO3 中下=IO4 右=IO5。
var REGION_DEFS = [
{ key: 'right', mask: 0x01 },
{ key: 'left', mask: 0x02 },
{ key: 'top', mask: 0x04 },
{ key: 'middle', mask: 0x08 },
{ key: 'bottom', mask: 0x10 }
{ key: 'left', mask: 0x01 },
{ key: 'top', mask: 0x02 },
{ key: 'middle', mask: 0x04 },
{ key: 'bottom', mask: 0x08 },
{ key: 'right', mask: 0x10 }
]
// PLACEHOLDER PD->region map. Flow doc: 7 PDs = 1 special region (3 PDs) + 4 regions
// (1 PD each). The exact assignment is UNCONFIRMED — replace after hardware confirms
// the PD physical layout. Each region lists the PD index(es) that feed its signal.
var PD_REGION_MAP = {
right: [0], left: [1], top: [2], middle: [3, 5, 6], bottom: [4]
}
var MAX_ADC = 4095
var DEFAULT_CONFIG = {
calibrated: false,
levels: { low: 0.15, mid: 0.35, high: 0.55 },
polarity: 'inverted', // inverted=值大吸收多(已确认) | direct=旧假设备用
th: 0.10, // 判定阈值(文档建议初始值,需临床标定)
severity: { low: 0.05, mid: 0.15, high: 0.30 }, // 轻/中/重分级,仅报告展示
brightness: 204, // 治疗强度 80%(果果定案),仅用于推荐方案下发
// 推测映射:中区含 3 路 PD(文档"1 特殊区 3 路+4 普通区各 1 路"),待固件确认后改 config。
pd_region_map: { left: [0], top: [1], middle: [2, 3, 4], bottom: [5], right: [6] },
problem_wavelength: PROBLEM_WAVELENGTH
}
function _avg(arr) {
if (!arr || !arr.length) return 0
var s = 0
for (var i = 0; i < arr.length; i++) s += arr[i]
return s / arr.length
function _median(arr) {
var s = arr.slice().sort(function (a, b) { return a - b })
var m = Math.floor(s.length / 2)
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
}
// Normalized absorption in [0,1]: the more the light is absorbed (lower reflected PD
// relative to the底噪 baseline), the stronger the tendency signal.
function _absorption(pdValue, baseline) {
if (!baseline || baseline <= 0) return 0
var a = (baseline - pdValue) / baseline
if (a < 0) a = 0
if (a > 1) a = 1
return a
// 区域有效值:1 路直接用;≥3 路按 7.1 节"几何平均 + 剔奇异值"(防黑痣/毛发单点失真)。
// 剔异常用 MAD(中位数绝对偏差)而非文档字面的 3σ:n=3 时单个离群点会把标准差同步撑大,
// 3σ 判据永不触发(审计实证 [900,9000,905] 不剔);MAD 阈值 4.45×MAD ≈ 3σ 等效(3×1.4826)。
function _aggregate(vals) {
if (!vals.length) return 0
if (vals.length === 1) return vals[0]
var kept = vals
if (vals.length >= 3) {
var med = _median(vals)
var devs = vals.map(function (v) { return Math.abs(v - med) })
var mad = _median(devs)
var thr = 4.45 * mad
var filtered = vals.filter(function (v) { return Math.abs(v - med) <= thr })
if (filtered.length >= 2) kept = filtered
}
var logSum = 0
for (var i = 0; i < kept.length; i++) {
if (kept[i] <= 0) return 0 // 有无效 0 值时该区数据不可信
logSum += Math.log(kept[i])
}
return Math.exp(logSum / kept.length)
}
function _levelOf(score, levels) {
if (score >= levels.high) return 3
if (score >= levels.mid) return 2
if (score >= levels.low) return 1
function _severityLevel(nr, severity) {
if (nr > severity.high) return 3
if (nr > severity.mid) return 2
if (nr > severity.low) return 1
return 0
}
// scanData: {
// device_id, scanned_at,
// region_mask, // which regions were scanned (default 0x1F)
// noise: [7], // per-PD 底噪 baseline (optional; defaults MAX_ADC)
// waves: { red:[7], uv:[7], yellow:[7], ir:[7] } // any subset; each = 7 PD values
// region_mask, // 本次扫描选中的区域(默认 0x1F)
// waves: { red:[7], ir:[7], uv:[7], yellow:[7] } // 任意子集;每项 = 7 路 PD ADC
// }
function analyze(scanData, config) {
config = config || DEFAULT_CONFIG
var levels = config.levels || DEFAULT_CONFIG.levels
var th = config.th !== undefined ? config.th : DEFAULT_CONFIG.th
var severity = config.severity || DEFAULT_CONFIG.severity
var brightness = config.brightness || DEFAULT_CONFIG.brightness
var pdMap = config.pd_region_map || DEFAULT_CONFIG.pd_region_map
var problemWave = config.problem_wavelength || PROBLEM_WAVELENGTH
var inverted = config.polarity !== 'direct'
scanData = scanData || {}
var waves = scanData.waves || {}
var noise = scanData.noise
var scannedMask = scanData.region_mask || 0x1F
// 1) 聚合:每波长 × 每选中区 → 区域有效值
var zone = {} // zone[waveKey][regionKey] = 有效值
var included = [] // 参与本次扫描的区域定义
var r, def
for (r = 0; r < REGION_DEFS.length; r++) {
if ((scannedMask & REGION_DEFS[r].mask) !== 0) included.push(REGION_DEFS[r])
}
for (var wk in waves) {
if (!waves.hasOwnProperty(wk) || !WAVE_PROBLEM[wk]) continue
var pdArr = waves[wk]
if (!pdArr || !pdArr.length) continue
zone[wk] = {}
for (r = 0; r < included.length; r++) {
def = included[r]
var idxs = pdMap[def.key] || []
var vals = []
for (var k = 0; k < idxs.length; k++) {
var v = pdArr[idxs[k]]
if (v !== undefined && v !== null && v > 0) vals.push(v)
}
if (vals.length) zone[wk][def.key] = _aggregate(vals)
}
}
// 2) 每波长的基准 = 该波长下各区有效值的平均(7.1Avg_PD_X_AllZones
var avg = {}
for (wk in zone) {
if (!zone.hasOwnProperty(wk)) continue
var sum = 0
var n = 0
for (r = 0; r < included.length; r++) {
var zv = zone[wk][included[r].key]
if (zv !== undefined) { sum += zv; n++ }
}
if (n > 0) avg[wk] = sum / n
}
// 3) 吸收度 NR + 每区 argmax 判定
var regions = []
var overallAcc = {}
for (var r = 0; r < REGION_DEFS.length; r++) {
var def = REGION_DEFS[r]
if ((scannedMask & def.mask) === 0) continue
var pdIdx = PD_REGION_MAP[def.key] || []
var problems = []
for (var wk in waves) {
if (!waves.hasOwnProperty(wk)) continue
var code = WAVE_KEY_CODE[wk]
var problemType = WAVELENGTH_PROBLEM[code]
if (!problemType) continue
var pdVals = []
var baseVals = []
for (var k = 0; k < pdIdx.length; k++) {
var idx = pdIdx[k]
if (waves[wk][idx] !== undefined) pdVals.push(waves[wk][idx])
baseVals.push(noise && noise[idx] !== undefined ? noise[idx] : MAX_ADC)
}
if (!pdVals.length) continue
var score = _absorption(_avg(pdVals), _avg(baseVals))
var level = _levelOf(score, levels)
if (level > 0) {
problems.push({ type: problemType, level: level, score: Math.round(score * 100) / 100 })
if (!overallAcc[problemType] || level > overallAcc[problemType]) {
overallAcc[problemType] = level
}
}
for (r = 0; r < included.length; r++) {
def = included[r]
var nrs = [] // 该区所有可算波长的 {wave, nr}
for (wk in zone) {
if (!zone.hasOwnProperty(wk)) continue
if (zone[wk][def.key] === undefined || !avg[wk]) continue
var ratio = zone[wk][def.key] / avg[wk]
var nr = inverted ? (ratio - 1) : (1 - ratio)
nrs.push({ wave: wk, nr: Math.round(nr * 1000) / 1000 })
}
nrs.sort(function (a, b) { return b.nr - a.nr })
var top = nrs.length ? nrs[0] : null
var hasProblem = !!(top && top.nr > th)
var problemType = hasProblem ? WAVE_PROBLEM[top.wave] : null
var level = hasProblem ? Math.max(1, _severityLevel(top.nr, severity)) : 0
if (problemType && (!overallAcc[problemType] || level > overallAcc[problemType])) {
overallAcc[problemType] = level
}
problems.sort(function (a, b) { return (b.level - a.level) || (b.score - a.score) })
var top = problems.length ? problems[0] : null
regions.push({
region: def.key,
mask: def.mask,
top_problem: top ? top.type : null,
level: top ? top.level : 0,
problems: problems
top_problem: problemType,
wave: hasProblem ? top.wave : null,
level: level,
score: top ? top.nr : 0,
nrs: nrs // 全量吸收度,供标定期回看
})
}
@@ -134,23 +169,22 @@ function analyze(scanData, config) {
}
overall.sort(function (a, b) { return b.level - a.level })
// Recommendation: care each region-with-a-top-problem using that problem's wavelength.
// One BLE command carries one wavelength, so plan entries are grouped by wavelength.
var planByWave = {}
var recommendMask = 0
for (var i = 0; i < regions.length; i++) {
var reg = regions[i]
if (!reg.top_problem) continue
var wl = problemWave[reg.top_problem]
if (!wl) continue
planByWave[wl] = (planByWave[wl] || 0) | reg.mask
recommendMask |= reg.mask
}
// 4) 推荐方案:每个问题区一条(区独立、可不同色),一条 BLE 命令即可全部下发。
var recommendPlan = []
for (var w in planByWave) {
if (!planByWave.hasOwnProperty(w)) continue
var code2 = parseInt(w, 10)
recommendPlan.push({ wavelength: code2, mask: planByWave[w], label_key: WAVELENGTH_PROBLEM[code2] })
var recommendMask = 0
for (r = 0; r < regions.length; r++) {
var reg = regions[r]
if (!reg.top_problem || !reg.wave) continue
recommendPlan.push({
region: reg.region,
mask: reg.mask,
wave: reg.wave,
wavelength: WAVE_KEY_CODE[reg.wave],
label_key: reg.top_problem,
level: reg.level,
brightness: brightness
})
recommendMask |= reg.mask
}
return {
@@ -165,30 +199,30 @@ function analyze(scanData, config) {
}
}
// Adapt the current (single-wavelength red) auto-scan result into scanData, so the
// pipeline works today. pdAvg = 7 averaged PD values under red light.
function fromRedScan(pdAvg, regionMask, deviceId, scannedAt, noise) {
// 把 auto-scan 按波长分桶后的平均值组装成 scanData
// buckets: { red:[7], ir:[7], uv:[7], yellow:[7] } 任意子集(旧固件无波长标识时只有 red)。
function buildScanData(buckets, regionMask, deviceId, scannedAt) {
return {
device_id: deviceId || null,
scanned_at: scannedAt || 0,
region_mask: regionMask || 0x1F,
noise: noise || null,
waves: { red: (pdAvg || []).slice(0, 7) }
waves: buckets || {}
}
}
// Deterministic mock report for UI development / demo when there is no device data.
// 无设备数据时的演示报告(开发者工具 mock)。
// 数值按已确认极性编造:值大=吸收多 → 左区红光偏高(光老化)、中区紫外偏高(痘痘油脂)。
function mockReport() {
return analyze({
device_id: 'MOCK',
scanned_at: 0,
region_mask: 0x1F,
noise: [4000, 4000, 4000, 4000, 4000, 4000, 4000],
waves: {
red: [3500, 3600, 1900, 3400, 3450, 3500, 3500],
uv: [3700, 3750, 3600, 1700, 3700, 1800, 1900],
yellow: [3500, 3550, 3400, 3450, 2300, 3500, 3520],
ir: [3600, 3600, 3550, 3580, 3560, 3600, 3600]
// 左 中上 中(3路) 中下 右
red: [1350, 920, 900, 910, 905, 890, 940],
uv: [2050, 2020, 2600, 2560, 2620, 1990, 2080],
yellow: [1500, 1520, 1490, 1505, 1495, 1530, 1510],
ir: [1800, 1790, 1805, 1795, 1800, 1815, 1790]
}
}, DEFAULT_CONFIG)
}
@@ -196,9 +230,10 @@ function mockReport() {
module.exports = {
DEFAULT_CONFIG: DEFAULT_CONFIG,
PROBLEM_WAVELENGTH: PROBLEM_WAVELENGTH,
WAVELENGTH_PROBLEM: WAVELENGTH_PROBLEM,
WAVE_PROBLEM: WAVE_PROBLEM,
WAVE_KEY_CODE: WAVE_KEY_CODE,
REGION_DEFS: REGION_DEFS,
analyze: analyze,
fromRedScan: fromRedScan,
buildScanData: buildScanData,
mockReport: mockReport
}