// ==UserScript== // @name 比邻云人脸5点校验 // @namespace https://label.bilinyun.net // @version 1.9 // @description 比邻云人脸质检助手:① 人脸框 + 5 关键点校验(分组/类型/镜像/凸性/鼻尖/5点在框内/越界/多余物/口罩联动/禁止忽略,连锁只报根因)② 批量改属性(折叠面板,双滑块选帧范围;批注模式点执行自动切标注模式)③ 切帧去黑屏 ④ 切帧自动聚焦人脸框 300% ⑤ 进题包预加载全部帧 ⑥ 一键复制「包号 + 有效帧」(包号与帧数之间空一列,可直接粘贴 Excel) // @author CC // @match https://label.bilinyun.net/* // @grant none // @run-at document-start // @license CC // ==/UserScript== (function () { 'use strict'; /* ==================== 常量与配置 ==================== */ const API_MARK = '/api/mark/image'; const API_STATIC = '/api/mark/contents/static'; const KP_ORDER = ['left_eye', 'right_eye', 'nose', 'left_mouth', 'right_mouth']; const KP_LABELS = { left_eye: '左眼中心', right_eye: '右眼中心', nose: '鼻尖', left_mouth: '左嘴角', right_mouth: '右嘴角' }; // ---- 关键点几何校验容差 ---- const GEOM_CFG = { QUAD_TOL: 0.10, // 鼻尖出四点框容差(相对脸高) NOSE_Y_TOL: 0.15, // 鼻尖 y 越界容差(相对脸高) EPS_EYE_RATIO: 0.02, // 镜像判断阈值(相对眼距) FACEH_FALLBACK: 2.2, // 人脸框缺失时用眼距×该系数估算脸高 MIN_EYE_DIST: 1 // 左右眼最小距离(像素) }; // 兜底类目映射(实测 ZJ-2DFace-001 项目值)。projectClass 捕获成功时会被覆盖 const FALLBACK_CMAP = { faceId: 'D9634D36D039', kpId: 'F0ADAF25B87D', kpAttrId: 'BA6D4C8C1A90', visAttrId: 'B915CC603B75', ignoreAttrId: 'E790388F0757', ignoreValueId: '70EB55F36921', ignoreValues: { '18645B23DB8B': '有效', '70EB55F36921': '忽略' }, maskAttrId: 'F8BCB6222B59', maskYesValueId: '527761D76A53', maskValues: { '4B5261FC83BC': '无口罩', '527761D76A53': '有口罩' }, occAttrId: 'B163D6012402', occHeavyValueId: 'FC102261CD06', occValues: { '8A6C3E778EB0': '无遮挡', '197224B8C2F4': '轻微遮挡', 'FC102261CD06': '严重遮挡' }, kpValues: { 'E5CEA82DC441': 'left_eye', 'DA980F85738B': 'right_eye', '40BD44492B30': 'nose', 'C0CC2DAA8739': 'left_mouth', '263506427E6D': 'right_mouth' }, allIds: ['D9634D36D039', 'F0ADAF25B87D'], idName: { 'D9634D36D039': '人脸', 'F0ADAF25B87D': '人脸关键点' } }; let CMAP = null; // 动态捕获的类目映射 let IMG_SIZE = null; // 图像宽高缓存 /* ==================== 类目映射构建 ==================== */ function num(v) { const n = parseFloat(v); return isNaN(n) ? null : n; } function buildClassMap(pcStr) { let pc; if (!pcStr) return null; try { pc = (typeof pcStr === 'string') ? JSON.parse(pcStr) : pcStr; } catch (e) { return null; } if (!pc || !pc.id) return null; const ids = pc.id || []; const exps = pc.exportName || []; const names = pc.name || []; const attrs = pc.attributes || []; const m = { faceId: null, kpId: null, kpAttrId: null, visAttrId: null, ignoreAttrId: null, ignoreValueId: null, ignoreValues: {}, maskAttrId: null, maskYesValueId: null, maskValues: {}, occAttrId: null, occHeavyValueId: null, occValues: {}, kpValues: {}, allIds: ids.slice(), idName: {} }; ids.forEach(function (id, i) { m.idName[id] = names[i] || id; if (exps[i] === 'Face') m.faceId = id; if (exps[i] === 'face5') m.kpId = id; }); // 关键点类目的属性组 ids.forEach(function (id, i) { if (id !== m.kpId) return; (attrs[i] || []).forEach(function (a) { if (a.exportName === 'keypoint') { m.kpAttrId = a.id; (a.attributes || []).forEach(function (v) { const hit = /^face([1-5])$/.exec(v.exportName || ''); if (hit) m.kpValues[v.id] = KP_ORDER[parseInt(hit[1], 10) - 1]; }); } if (a.exportName === 'vis') m.visAttrId = a.id; }); }); // 人脸框类目的属性组:是否忽略 / 是否佩戴口罩 / 遮挡程度 ids.forEach(function (id, i) { if (id !== m.faceId) return; (attrs[i] || []).forEach(function (a) { const vals = a.attributes || []; if (a.exportName === 'ignore') { m.ignoreAttrId = a.id; vals.forEach(function (v) { m.ignoreValues[v.id] = v.name; if (v.exportName === '1') m.ignoreValueId = v.id; }); } if (a.exportName === 'face_mask') { m.maskAttrId = a.id; vals.forEach(function (v) { m.maskValues[v.id] = v.name; if (v.exportName === '1') m.maskYesValueId = v.id; }); } if (a.exportName === 'occlusion') { m.occAttrId = a.id; vals.forEach(function (v) { m.occValues[v.id] = v.name; if (v.exportName === '2') m.occHeavyValueId = v.id; }); } }); }); return (m.faceId && m.kpId) ? m : null; } function getMap() { return CMAP || FALLBACK_CMAP; } /* ==================== 数据归一化 ==================== */ function parseJson(s) { try { return JSON.parse(s) || {}; } catch (e) { return {}; } } function normalizeShape(s, M) { if (!s || s.labelId === undefined) return null; const sh = parseJson(s.shapeJson); const at = parseJson(s.attrJson); const g = (sh && sh.shape) || {}; const typeId = at.type_id; const isFace = (typeId === M.faceId); const isKp = (typeId === M.kpId); let pname, type, point; if (isFace) { pname = 'face'; type = 'rect'; point = { left: num(g.lx), top: num(g.ly), right: num(g.rx), bottom: num(g.ry) }; } else if (isKp) { pname = M.kpValues[(at.attributes || {})[M.kpAttrId]] || '关键点(未标类型)'; type = 'point'; point = { x: num(g.x), y: num(g.y) }; } else { pname = M.idName[typeId] || typeId || '未知类目'; type = (String(sh.shapeType) === '0') ? 'rect' : 'point'; point = (type === 'rect') ? { left: num(g.lx), top: num(g.ly), right: num(g.rx), bottom: num(g.ry) } : { x: num(g.x), y: num(g.y) }; } return { markId: s.labelId, groupId: at.group_id, pname: pname, type: type, point: point, clsId: typeId, attrs: at.attributes || {} }; } function getPoint(m) { if (!m || !m.point) return null; const x = m.point.x, y = m.point.y; if (x === null || y === null) return null; return { x: x, y: y }; } function fmtP(p) { return p ? p.x.toFixed(1) + ',' + p.y.toFixed(1) : '缺失'; } /* ==================== 几何工具 ==================== */ function cross(o, a, b) { return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); } function pointInQuad(p, quad, tol) { const sign = cross(quad[0], quad[1], quad[2]) >= 0 ? 1 : -1; for (let i = 0; i < quad.length; i++) { const a = quad[i]; const b = quad[(i + 1) % quad.length]; const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); if (d * sign < -tol) return false; } return true; } /* ==================== 关键点几何校验 ==================== */ // 返回 true 表示几何结构正常(可继续做「5 点在人脸框内」检查) function validateKeypointGeometry(kpMap, members, errs) { const eL = getPoint(kpMap['left_eye']); const eR = getPoint(kpMap['right_eye']); const nose = getPoint(kpMap['nose']); const mL = getPoint(kpMap['left_mouth']); const mR = getPoint(kpMap['right_mouth']); if (!eL || !eR || !nose || !mL || !mR) { const miss = []; if (!eL) miss.push('左眼中心'); if (!eR) miss.push('右眼中心'); if (!nose) miss.push('鼻尖'); if (!mL) miss.push('左嘴角'); if (!mR) miss.push('右嘴角'); errs.push('关键点坐标缺失(' + miss.join('、') + ')'); return false; } const eyeDist = Math.sqrt(Math.pow(eR.x - eL.x, 2) + Math.pow(eR.y - eL.y, 2)); if (eyeDist < GEOM_CFG.MIN_EYE_DIST) { errs.push('左右眼坐标几乎重合(' + fmtP(eL) + ' / ' + fmtP(eR) + ')'); return false; } let faceH = 0; members.forEach(function (m) { if (faceH === 0 && m.pname === 'face' && m.type === 'rect' && m.point) { const b = m.point.bottom, t = m.point.top; if (b !== null && t !== null) faceH = b - t; } }); if (!faceH || faceH <= 0) faceH = eyeDist * GEOM_CFG.FACEH_FALLBACK; const eps = eyeDist * GEOM_CFG.EPS_EYE_RATIO; // ---- 根因 1:左右颠倒(双眼/嘴角合并成一条,同一根因不刷屏)---- // 镜像规则:人脸自身右眼、右嘴角在画面左侧(X 更小),左眼、左嘴角在画面右侧(X 更大) const eyeFlip = (eL.x - eR.x) < eps; const mouthFlip = (mL.x - mR.x) < eps; if (eyeFlip || mouthFlip) { const parts = []; if (eyeFlip) parts.push('双眼'); if (mouthFlip) parts.push('嘴角'); errs.push(parts.join('和') + '左右颠倒:人脸自身右眼/右嘴角须在画面左侧、左眼/左嘴角须在画面右侧(右眼' + fmtP(eR) + ' 左眼' + fmtP(eL) + ',右嘴角' + fmtP(mR) + ' 左嘴角' + fmtP(mL) + ')'); return false; } // ---- 根因 2:四点形状错乱(非凸,同时覆盖连线自交)---- const quad = [eR, eL, mL, mR]; const c01 = cross(quad[0], quad[1], quad[2]); const c12 = cross(quad[1], quad[2], quad[3]); const c23 = cross(quad[2], quad[3], quad[0]); const c30 = cross(quad[3], quad[0], quad[1]); const s0 = c01 >= 0 ? 1 : -1, s1 = c12 >= 0 ? 1 : -1; const s2 = c23 >= 0 ? 1 : -1, s3 = c30 >= 0 ? 1 : -1; if (s0 !== s1 || s1 !== s2 || s2 !== s3) { errs.push('眼‑嘴四点形状错乱、五官位置颠倒(' + fmtP(eL) + ' ' + fmtP(eR) + ' ' + fmtP(mR) + ' ' + fmtP(mL) + ')'); return false; } // ---- 根因 3:鼻尖不在四点框内 ---- if (!pointInQuad(nose, quad, faceH * GEOM_CFG.QUAD_TOL)) { errs.push('鼻尖(' + fmtP(nose) + ')不在双眼‑嘴角四点框内'); return false; } // ---- 根因 4:鼻尖 y 超出眼嘴区间 ---- const yTol = faceH * GEOM_CFG.NOSE_Y_TOL; const topY = Math.min(eL.y, eR.y) - yTol; const botY = Math.max(mL.y, mR.y) + yTol; if (nose.y < topY || nose.y > botY) { errs.push('鼻尖 y=' + nose.y.toFixed(1) + ' 超出眼嘴区间 [' + topY.toFixed(1) + ', ' + botY.toFixed(1) + ']'); return false; } return true; } /* ==================== 越界校验 ==================== */ function checkBounds(m, size) { if (m.type !== 'rect') return null; const pt = m.point; if (!pt || pt.left === null) return null; const issues = []; if (pt.left < 0) issues.push('left=' + pt.left.toFixed(1)); if (pt.top !== null && pt.top < 0) issues.push('top=' + pt.top.toFixed(1)); if (size && pt.right !== null && pt.right > size.w) issues.push('right=' + pt.right.toFixed(1) + ' > 图宽' + size.w); if (size && pt.bottom !== null && pt.bottom > size.h) issues.push('bottom=' + pt.bottom.toFixed(1) + ' > 图高' + size.h); if (!issues.length) return null; return '标注超出图像边界: ' + issues.join(', '); } /* ==================== 组校验 ==================== */ function validateGroup(g, size, M) { const members = g.members; const errs = g.errors; const counts = {}; const kpList = []; let geomOk = true; members.forEach(function (m) { counts[m.pname] = (counts[m.pname] || 0) + 1; if (KP_ORDER.indexOf(m.pname) !== -1) kpList.push({ mark: m, pname: m.pname }); }); // 人脸框 const faceCount = counts['face'] || 0; if (faceCount === 0) errs.push('缺少人脸框'); else if (faceCount > 1) errs.push('人脸框超过 1 个(' + faceCount + ')'); // 关键点数量与类型 if (kpList.length !== 5) { errs.push('关键点数量错误:期望 5 个,实际 ' + kpList.length + ' 个'); geomOk = false; } else { const missing = KP_ORDER.filter(function (p) { return !counts[p]; }); const dups = KP_ORDER.filter(function (p) { return counts[p] > 1; }); kpList.forEach(function (k, i) { k.mark._kpIdx = i + 1; }); if (missing.length || dups.length) { // 类型错乱时几何判定不可信,合并成一条根因,不再叠加坐标类报错 const parts = []; if (dups.length) parts.push('重复 ' + dups.map(function (p) { return KP_LABELS[p] + '×' + counts[p]; }).join('、')); if (missing.length) parts.push('缺少 ' + missing.map(function (p) { return KP_LABELS[p]; }).join('、')); errs.push('关键点类型错误:' + parts.join(',')); geomOk = false; } else { const kpMap = {}; kpList.forEach(function (k) { if (!kpMap[k.pname]) kpMap[k.pname] = k.mark; }); geomOk = validateKeypointGeometry(kpMap, members, errs); } } // 人脸框属性校验 + 5 点必须全部在人脸框内 let faceMark = null; members.forEach(function (m) { if (!faceMark && m.pname === 'face' && m.type === 'rect') faceMark = m; }); if (faceMark) { // 「是否忽略」只能是「有效」,不允许标「忽略」 if (M.ignoreAttrId && M.ignoreValueId && faceMark.attrs[M.ignoreAttrId] === M.ignoreValueId) { errs.push('人脸框「是否忽略」不允许标为「忽略」,只能标「有效」'); } // 有口罩 → 遮挡程度必须是「严重遮挡」 const maskV = M.maskAttrId ? faceMark.attrs[M.maskAttrId] : null; if (M.maskYesValueId && maskV === M.maskYesValueId) { const occV = M.occAttrId ? faceMark.attrs[M.occAttrId] : null; if (occV !== M.occHeavyValueId) { const occName = (M.occValues && M.occValues[occV]) || occV || '未设置'; errs.push('口罩联动错误:已标「有口罩」,遮挡程度必须为「严重遮挡」(当前:' + occName + ')'); } } // 5 点出框(几何结构已异常时不再叠加,避免同一根因刷屏) const fL = faceMark.point ? faceMark.point.left : null; const fT = faceMark.point ? faceMark.point.top : null; const fR = faceMark.point ? faceMark.point.right : null; const fB = faceMark.point ? faceMark.point.bottom : null; if (geomOk && fL !== null && fT !== null && fR !== null && fB !== null) { kpList.forEach(function (k) { const p = getPoint(k.mark); if (!p) return; if (p.x < fL || p.x > fR || p.y < fT || p.y > fB) { errs.push('关键点出框:' + KP_LABELS[k.pname] + '(' + p.x.toFixed(1) + ',' + p.y.toFixed(1) + ') 不在人脸框 [' + fL.toFixed(1) + ',' + fT.toFixed(1) + ',' + fR.toFixed(1) + ',' + fB.toFixed(1) + '] 内'); } }); } } // 越界 members.forEach(function (m) { const be = checkBounds(m, size); if (be) errs.push(be); }); // 多余标注物(不在项目类目白名单内) members.forEach(function (m) { if (m.clsId && M.allIds.indexOf(m.clsId) === -1) errs.push('多余标注物: ' + m.pname); }); if (errs.length > 0) g.pass = false; return g; } /* ==================== 悬浮窗 UI ==================== */ let panelContainer = null; let panelBody = null; let panelContent = null; let isMinimized = false; let isDragging = false; let dragOffsetX = 0; let dragOffsetY = 0; let panelX = 20; let panelY = 60; function createPanel() { if (panelContainer) return; panelContainer = document.createElement('div'); panelContainer.id = '_bly_panel'; panelContainer.style.cssText = 'position:fixed;z-index:999999;width:380px;background:#ffffff;border:1px solid #d1d5db;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,0.15);font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:13px;color:#1f2937;overflow:hidden;left:' + panelX + 'px;top:' + panelY + 'px;user-select:none;'; const titleBar = document.createElement('div'); titleBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#f3f4f6;cursor:move;border-bottom:1px solid #d1d5db;'; titleBar.innerHTML = '比邻云|人脸5点校验'; titleBar.addEventListener('mousedown', startDrag); panelContainer.appendChild(titleBar); const btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex;gap:6px;'; btnGroup.appendChild(createBtn('—', '#e5e7eb', '#d1d5db', function (e) { e.stopPropagation(); toggleMinimize(); })); btnGroup.appendChild(createBtn('✕', '#fca5a5', '#f87171', function (e) { e.stopPropagation(); closePanel(); })); titleBar.appendChild(btnGroup); panelBody = document.createElement('div'); panelBody.style.cssText = 'max-height:500px;overflow-y:auto;transition:max-height 0.25s ease;'; panelContainer.appendChild(panelBody); document.body.appendChild(panelContainer); panelContent = document.createElement('div'); panelContent.style.cssText = 'padding:12px;background:#ffffff;'; panelBody.appendChild(panelContent); // 批量改属性用的滑块样式(只注入一次) if (!document.getElementById('_bly_bp_style')) { const st = document.createElement('style'); st.id = '_bly_bp_style'; st.textContent = '#_bly_panel input[type=range]{position:absolute;top:0;left:0;right:0;width:100%;height:14px;margin:0;padding:0;border:0;outline:none;background:transparent;pointer-events:none;-webkit-appearance:none;appearance:none;box-sizing:border-box;}' + '#_bly_panel input[type=range]::-webkit-slider-runnable-track{height:14px;background:transparent;border:0;}' + '#_bly_panel input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:#4f46e5;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;pointer-events:auto;margin-top:0;}' + '#_bly_panel input[type=range]::-moz-range-track{height:14px;background:transparent;border:0;}' + '#_bly_panel input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#4f46e5;border:2px solid #fff;cursor:pointer;pointer-events:auto;}'; (document.head || document.documentElement).appendChild(st); } // 去黑屏样式(此处 DOM 已就绪) installMaskCss(); // 事件委托:面板 HTML 会随帧切换重建,绑在容器上可长期有效 panelContainer.addEventListener('input', bpOnEvent); panelContainer.addEventListener('change', bpOnEvent); panelContainer.addEventListener('click', bpOnEvent); } function createBtn(text, color, hoverColor, onClick) { const btn = document.createElement('span'); btn.textContent = text; btn.style.cssText = 'width:26px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:pointer;font-size:14px;font-weight:bold;color:#374151;background:' + color + ';'; btn.addEventListener('mouseenter', function () { btn.style.background = hoverColor; }); btn.addEventListener('mouseleave', function () { btn.style.background = color; }); btn.addEventListener('click', onClick); return btn; } function toggleMinimize() { isMinimized = !isMinimized; if (isMinimized) { panelBody.style.maxHeight = '0'; panelBody.style.padding = '0'; } else { panelBody.style.maxHeight = '500px'; panelBody.style.padding = ''; } } function closePanel() { if (panelContainer) { panelContainer.remove(); panelContainer = null; } } function startDrag(e) { if (e.button !== 0) return; isDragging = true; const rect = panelContainer.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; panelContainer.style.cursor = 'grabbing'; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); e.preventDefault(); } function onDrag(e) { if (!isDragging || !panelContainer) return; panelX = Math.max(0, e.clientX - dragOffsetX); panelY = Math.max(0, e.clientY - dragOffsetY); panelContainer.style.left = panelX + 'px'; panelContainer.style.top = panelY + 'px'; } function stopDrag() { isDragging = false; if (panelContainer) panelContainer.style.cursor = ''; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); } /* ==================== 批量改属性:面板 HTML ==================== */ function bpHTML() { const mode = currentMode(); const canUse = (mode === '标注模式'); let h = '
'; h += ''; h += ''; h += '
'; h += '📋'; h += '复制「包号 + 有效帧」'; h += '
'; h += '
'; h += '⚙ 批量改属性'; h += '' + (BP_OPEN ? '▾ 收起' : '▸ 展开') + ''; h += '
'; if (!BP_OPEN) return h + '
'; h += '
'; if (!TARGETS) { h += '
⚠ 未取到项目类目配置,无法批量改属性(请刷新页面后重试)
'; return h + '
'; } if (!canUse) { h += '
当前为「' + (mode || '未知模式') + '」,点执行会自动切换到「标注模式」
'; } bpEnsureSel(); const T = TARGETS[BP_TARGET]; const selCss = 'width:100%;font-size:12px;padding:3px 4px;border:1px solid #d1d5db;border-radius:4px;margin-bottom:6px;color:#374151;background:#fff;'; h += '
目标
'; h += '
属性
'; h += '
设为
'; const total = Math.max(FRAME_IDS.length, 1); const ps = total > 1 ? ((BP_S - 1) / (total - 1)) * 100 : 0; const pe = total > 1 ? ((BP_E - 1) / (total - 1)) * 100 : 100; h += '
帧范围(共 ' + FRAME_IDS.length + ' 帧)
'; h += '
'; h += '
'; h += '
'; h += ''; h += ''; h += '
'; h += '
起始 ' + BP_S + ' - 结束 ' + BP_E + '
'; h += ''; h += '
' + (BP_RESULT_HTML || '') + '
'; return h + ''; } function bpSyncSlider() { const total = Math.max(FRAME_IDS.length, 1); const sEl = document.getElementById('_bly_bp_srng'); const eEl = document.getElementById('_bly_bp_erng'); if (sEl) sEl.value = BP_S; if (eEl) eEl.value = BP_E; const fill = document.getElementById('_bly_bp_fill'); if (fill && total > 1) { const ps = ((BP_S - 1) / (total - 1)) * 100; const pe = ((BP_E - 1) / (total - 1)) * 100; fill.style.left = ps + '%'; fill.style.width = Math.max(0, pe - ps) + '%'; } const sl = document.getElementById('_bly_bp_slbl'); const el2 = document.getElementById('_bly_bp_elbl'); if (sl) sl.textContent = BP_S; if (el2) el2.textContent = BP_E; } function bpRedraw() { lastRenderKey = null; const no = currentFrameNo(); const fid = (no !== null) ? FRAME_IDS[no - 1] : null; const data = (fid && FRAME_CACHE[fid]) || lastShapeList; if (data) analyzeShapes(data, getImageSize()); else renderPanel({ groups: {}, lonelyMarks: [], total: 0 }); } function bpFillSelect(id, items, cur) { const el = document.getElementById(id); if (!el) return; let h = ''; items.forEach(function (o) { h += ''; }); el.innerHTML = h; } // 只刷新下拉内容,不重建整个面板(重建会导致下拉框刚点开就被销毁) function bpRefreshSelects() { if (!TARGETS || !TARGETS[BP_TARGET]) return; const T = TARGETS[BP_TARGET]; bpFillSelect('_bly_bp_target', Object.keys(TARGETS).map(function (k) { return [k, TARGETS[k].title]; }), BP_TARGET); bpFillSelect('_bly_bp_attr', Object.keys(T.attrs).map(function (k) { return [k, T.attrs[k].title]; }), BP_ATTR); bpFillSelect('_bly_bp_val', T.attrs[BP_ATTR].options, BP_VAL); } // 自动切换到标注模式(批注模式下不允许改属性) function bpEnsureAnnotateMode() { if (currentMode() === '标注模式') return true; const groups = document.querySelectorAll('.ant-radio-group'); for (let i = 0; i < groups.length; i++) { const opts = groups[i].querySelectorAll('.ant-radio-button-wrapper'); if (opts.length < 2) continue; const txts = [].map.call(opts, function (e) { return (e.innerText || '').trim(); }); if (txts.indexOf('标注模式') === -1 || txts.indexOf('批注模式') === -1) continue; for (let k = 0; k < opts.length; k++) { if (txts[k] !== '标注模式') continue; const input = opts[k].querySelector('input[type=radio]'); if (input) { try { input.click(); } catch (e) { } } opts[k].click(); return true; } } return false; } function bpOnEvent(e) { let t = e.target; if (!t) return; // 点击的可能是内层 span,向上找最近带 _bly_bp_ id 的元素 if (!t.id || t.id.indexOf('_bly_bp_') !== 0) { t = (t.closest && t.closest('[id^="_bly_bp_"]')) || null; if (!t) return; } const id = t.id; if (id === '_bly_bp_toggle') { if (e.type !== 'click') return; BP_OPEN = !BP_OPEN; bpRedraw(); } else if (id === '_bly_bp_run') { if (e.type !== 'click') return; runBatch(); } else if (id === '_bly_bp_target') { if (e.type !== 'change') return; BP_TARGET = t.value; BP_ATTR = ''; BP_VAL = ''; bpEnsureSel(); bpRefreshSelects(); } else if (id === '_bly_bp_attr') { if (e.type !== 'change') return; BP_ATTR = t.value; BP_VAL = ''; bpEnsureSel(); bpFillSelect('_bly_bp_val', TARGETS[BP_TARGET].attrs[BP_ATTR].options, BP_VAL); } else if (id === '_bly_bp_az') { if (e.type !== 'change') return; AUTO_ZOOM = !!t.checked; try { localStorage.setItem(AUTO_ZOOM_KEY, AUTO_ZOOM ? '1' : '0'); } catch (e2) { } if (AUTO_ZOOM) autoZoomFrame(); } else if (id === '_bly_bp_copy') { if (e.type !== 'click') return; copyPackInfo(); } else if (id === '_bly_bp_warm') { if (e.type !== 'change') return; WARMUP_ON = !!t.checked; try { localStorage.setItem(WARMUP_KEY, WARMUP_ON ? '1' : '0'); } catch (e2) { } if (WARMUP_ON) { warmupTried = true; WARMUP_DONE = false; warmupAllFrames(); } } else if (id === '_bly_bp_val') { if (e.type !== 'change') return; BP_VAL = t.value; } else if (id === '_bly_bp_srng') { BP_S = parseInt(t.value, 10); if (BP_S > BP_E) BP_E = BP_S; bpSyncSlider(); } else if (id === '_bly_bp_erng') { BP_E = parseInt(t.value, 10); if (BP_E < BP_S) BP_S = BP_E; bpSyncSlider(); } } function renderPanel(result) { createPanel(); if (!panelContent) return; const groups = result.groups || {}; const lonelyMarks = result.lonelyMarks || []; const total = result.total || 0; const totalGroups = Object.keys(groups).length; const issueGroups = Object.values(groups).filter(function (g) { return !g.pass; }).length; const lonelyCount = lonelyMarks.length; const ok = (issueGroups === 0 && lonelyCount === 0); let html = ''; // 空帧(无效帧) if (total === 0) { html += '
'; html += ''; html += '
'; html += '
本帧无任何标注
'; html += '平台不区分「规则允许的无效帧」与「漏标」。若该帧应为有效帧,请人工复核。'; html += '
'; html += bpHTML(); panelContent.innerHTML = html; panelBody.scrollTop = 0; return; } html += '
'; html += '' + (ok ? '✅' : '⚠️') + ''; html += '
'; html += '
' + (ok ? '所有归组正确' : '存在异常') + '
'; html += '' + total + ' 个标注 · ' + totalGroups + ' 个组'; if (issueGroups > 0) html += ' · ' + issueGroups + ' 个组异常'; if (lonelyCount > 0) html += ' · ' + lonelyCount + ' 个孤立标注'; html += '
'; if (lonelyCount > 0) { html += '
'; html += '
⚠ 孤立标注(未归组)
'; lonelyMarks.forEach(function (m) { html += '
'; html += '
'; html += '' + m.pname + ''; html += 'labelId: ' + m.markId + ''; html += '
'; if (m._boundErr) html += '
• ' + m._boundErr + '
'; html += '
'; }); html += '
'; } const sortedIds = Object.keys(groups).sort(function (a, b) { return Number(a) - Number(b); }); sortedIds.forEach(function (gid) { const g = groups[gid]; const pass = g.pass; const errs = g.errors || []; html += '
'; html += '
'; html += '组 ' + gid + ' (' + g.count + ' 个标注)'; html += '' + (pass ? '✓ 正确' : errs.length + ' 个问题') + ''; html += '
'; if (!pass) { html += '
'; errs.forEach(function (err) { html += '
• ' + err + '
'; }); html += '
'; } html += '
'; g.members.forEach(function (m) { const idx = (m._kpIdx !== undefined) ? (' [' + m._kpIdx + ']') : ''; html += '' + m.pname + idx + ''; }); html += '
'; }); html += bpHTML(); panelContent.innerHTML = html; panelBody.scrollTop = 0; } /* ==================== 主分析流程 ==================== */ let lastRenderKey = null; let lastShapeList = null; // 图像真实尺寸:优先从 Konva 舞台的 Image 节点取(页面 里有 logo,不可用) function getImageSize() { let w = 0, h = 0; try { if (typeof Konva !== 'undefined' && Konva.stages && Konva.stages.length) { const nodes = Konva.stages[0].find('Image'); for (let i = 0; i < nodes.length; i++) { const im = nodes[i].image && nodes[i].image(); if (im && im.naturalWidth > 200 && im.naturalHeight > 200) { w = im.naturalWidth; h = im.naturalHeight; break; } } } } catch (e) { } if (!w) { // 兜底:页面大图(排除 logo 等 UI 小图) const imgs = document.querySelectorAll('img'); for (let i = 0; i < imgs.length; i++) { const iw = imgs[i].naturalWidth, ih = imgs[i].naturalHeight; if (iw > 1000 && ih > 1000) { w = iw; h = ih; break; } } } if (!w) { IMG_SIZE = null; return null; } if (!IMG_SIZE || IMG_SIZE.w !== w || IMG_SIZE.h !== h) IMG_SIZE = { w: w, h: h }; return IMG_SIZE; } function analyzeShapes(shapeList, size) { // 数据一到就聚焦,比等固定延时快得多 if (autoZoomPending) { autoZoomPending = false; autoZoomFrame(); } const M = getMap(); lastShapeList = shapeList; const marks = []; (shapeList || []).forEach(function (s) { const n = normalizeShape(s, M); if (n) marks.push(n); }); // 渲染指纹去重:数据没变就不重渲染,避免面板闪烁与无谓开销(含当前帧号) const rk = currentFrameNo() + '#' + marks.map(function (m) { return m.markId + '|' + m.pname + '|' + JSON.stringify(m.point); }).join(';') + '|' + (size ? size.w + 'x' + size.h : '-'); if (rk === lastRenderKey) return; lastRenderKey = rk; if (marks.length === 0) { renderPanel({ groups: {}, lonelyMarks: [], total: 0 }); return; } const groupMap = {}; const lonelyIds = {}; marks.forEach(function (m) { let gid = m.groupId; if (gid === undefined || gid === null || gid === '' || gid === 0 || gid === '0') { gid = '__lonely_' + m.markId; lonelyIds[gid] = true; } if (!groupMap[gid]) groupMap[gid] = []; groupMap[gid].push(m); }); const lonelyMarks = []; Object.keys(groupMap).forEach(function (gid) { if (lonelyIds[gid] || groupMap[gid].length === 1) { groupMap[gid].forEach(function (m) { lonelyMarks.push(m); }); delete groupMap[gid]; } }); const groups = {}; Object.keys(groupMap).forEach(function (gid) { const members = groupMap[gid]; const g = { groupId: gid, count: members.length, members: members, errors: [], pass: true }; groups[gid] = validateGroup(g, size, M); }); lonelyMarks.forEach(function (m) { const e = checkBounds(m, size); if (e) m._boundErr = e; }); renderPanel({ groups: groups, lonelyMarks: lonelyMarks, total: marks.length }); } /* ==================== 体验优化:去黑屏 + 切帧自动聚焦 ==================== */ const AUTO_ZOOM_KEY = '_bly_auto_zoom'; let AUTO_ZOOM = true; const AUTO_ZOOM_SCALE = 3; // 用户实测:3 倍最合适 let autoZoomTimer = null; let autoZoomPending = false; try { AUTO_ZOOM = localStorage.getItem(AUTO_ZOOM_KEY) !== '0'; } catch (e) { } // 平台切帧时会弹全屏黑遮罩(.block-mask) + 转圈(.pc-loading),实测约 400ms。 // 遮罩底色调透明(保留其"加载中禁止点击"的作用),转圈图标直接隐藏 —— 切帧不再黑屏。 function installMaskCss() { if (document.getElementById('_bly_mask_style')) return; const st = document.createElement('style'); st.id = '_bly_mask_style'; st.textContent = '.block-mask{background-color:transparent !important;}' + '.pc-loading{display:none !important;}' + '#_bly_bp_copy:hover{background:#eef2ff;}' + '#_bly_bp_copy:active{background:#e0e7ff;}'; (document.head || document.documentElement).appendChild(st); } // 切帧后自动以人脸框为中心放大 function autoZoomFrame() { if (!AUTO_ZOOM || WARMUP_RUNNING) return; // 预热期间不缩放 try { if (typeof Konva === 'undefined' || !Konva.stages || !Konva.stages.length) return; const st = Konva.stages[0]; const no = currentFrameNo(); const fid = (no !== null) ? FRAME_IDS[no - 1] : null; const list = fid ? FRAME_CACHE[fid] : null; if (!list || !list.length) return; const M = getMap(); let box = null; for (let i = 0; i < list.length; i++) { let attr, geo; try { attr = JSON.parse(list[i].attrJson || '{}'); geo = JSON.parse(list[i].shapeJson || '{}'); } catch (e) { continue; } if (attr.type_id !== M.faceId) continue; const g = geo.shape || {}; const lx = parseFloat(g.lx), ly = parseFloat(g.ly); const rx = parseFloat(g.rx), ry = parseFloat(g.ry); if (isNaN(lx) || isNaN(ly) || isNaN(rx) || isNaN(ry)) continue; box = { lx: lx, ly: ly, rx: rx, ry: ry }; break; } if (!box) return; const s = AUTO_ZOOM_SCALE; const cx = (box.lx + box.rx) / 2; const cy = (box.ly + box.ry) / 2; st.scale({ x: s, y: s }); st.position({ x: st.width() / 2 - cx * s, y: st.height() / 2 - cy * s }); st.batchDraw(); } catch (e) { } } /* ==================== 进题包全量预热 ==================== */ // 平台切帧流程 loadAnnotatesDate() 第一步就 clearResource() 清空画布(灰屏来源), // 随后若 dataManager.getFrameObject(frameId) 命中则直接 return(秒开)。 // 所以这里在进题包后主动把每一帧都过一遍,把 dataManager 填满 —— 之后切帧就顺滑了。 const WARMUP_KEY = '_bly_warmup'; let WARMUP_ON = true; let WARMUP_RUNNING = false; let WARMUP_DONE = false; let warmupTried = false; try { WARMUP_ON = localStorage.getItem(WARMUP_KEY) !== '0'; } catch (e) { } function goToFrame(n) { const items = document.querySelectorAll('.frame-navigation .page-item'); for (let i = 0; i < items.length; i++) { if ((items[i].innerText || '').trim() === String(n)) { items[i].click(); return true; } } return false; } function frameDataReady(fid) { try { const dm = window.editor && window.editor.dataManager; if (!dm || typeof dm.getFrameObject !== 'function') return false; return !!dm.getFrameObject(fid); } catch (e) { return false; } } function waitFrameReady(fid, timeout) { return new Promise(function (resolve) { const t0 = Date.now(); const timer = setInterval(function () { if (frameDataReady(fid)) { clearInterval(timer); resolve(true); return; } if (Date.now() - t0 > (timeout || 4000)) { clearInterval(timer); resolve(false); } }, 80); }); } function warmupOverlay(show, cur, total) { let el = document.getElementById('_bly_warm'); if (!show) { if (el) el.remove(); return; } if (!el) { el = document.createElement('div'); el.id = '_bly_warm'; el.style.cssText = 'position:fixed;z-index:999997;left:50%;top:50%;transform:translate(-50%,-50%);' + 'background:rgba(17,24,39,0.92);color:#fff;padding:14px 22px;border-radius:9px;text-align:center;' + 'font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:13px;box-shadow:0 8px 28px rgba(0,0,0,.35);'; document.body.appendChild(el); } el.innerHTML = '
📦 正在预加载全部帧…
' + '
' + (cur || 0) + ' / ' + (total || 0) + ' 完成后切帧不再等待
'; } async function warmupAllFrames() { if (WARMUP_RUNNING || WARMUP_DONE) return; if (!WARMUP_ON || !FRAME_IDS.length || !AUTH_HDRS) return; WARMUP_RUNNING = true; const startNo = currentFrameNo() || 1; const total = FRAME_IDS.length; warmupOverlay(true, 0, total); let done = 0; for (let n = 1; n <= total; n++) { const fid = FRAME_IDS[n - 1]; if (!frameDataReady(fid)) { goToFrame(n); await waitFrameReady(fid, 4000); } done++; warmupOverlay(true, done, total); } goToFrame(startNo); await waitFrameReady(FRAME_IDS[startNo - 1], 3000); warmupOverlay(false); WARMUP_DONE = true; WARMUP_RUNNING = false; } /* ==================== 复制「包号 + 有效帧」 ==================== */ // 有效帧 = 该帧存在标注数据(shapeList 非空)。剪贴板格式:包号 有效帧数 async function countValidFrames() { let valid = 0; for (let i = 0; i < FRAME_IDS.length; i++) { const fid = FRAME_IDS[i]; let list = FRAME_CACHE[fid]; if (!list) { try { const r = await fetch(API_MARK + '?frame_id=' + fid, { headers: AUTH_HDRS }); const j = await r.json(); list = (j.data && j.data.shapeList) || []; FRAME_CACHE[fid] = list; } catch (e) { continue; } } if (list && list.length > 0) valid++; } return valid; } function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } return new Promise(function (resolve, reject) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); resolve(); } catch (e) { reject(e); } }); } async function copyPackInfo() { const txtOf = function () { const el = document.getElementById('_bly_bp_copy'); return el ? el.querySelector('span:last-child') : null; }; const setTxt = function (s) { const t = txtOf(); if (t) t.innerText = s; }; if (!FRAME_IDS.length) { setTxt('⚠ 帧列表未就绪,请刷新页面'); return; } if (!AUTH_HDRS) { setTxt('⚠ 请先在工作台切一次帧'); return; } setTxt('⏳ 统计中…'); try { const valid = await countValidFrames(); const no = TASK_NO || '未知包号'; // 两个制表符:粘贴后包号落在「包号」列,中间跳过一列(验收日期),有效帧落在「帧数」列 await copyText(no + '\t\t' + valid); setTxt('✓ 已复制:' + no + ' 与 ' + valid); } catch (e) { setTxt('⚠ 复制失败:' + (e && e.message ? e.message : e)); } setTimeout(function () { setTxt('复制「包号 + 有效帧」'); }, 2500); } /* ==================== 批量改属性:目标构建与模式判断 ==================== */ let PROJECT_CLASS = null; // projectClass 原始对象 let TARGETS = null; // 可批量修改的目标集合 let BP_OPEN = false; // 批量面板是否展开 let BP_S = 1, BP_E = 1; // 帧范围(1-based) let BP_TARGET = '', BP_ATTR = '', BP_VAL = ''; let BP_RUNNING = false; const BP_RESULT_KEY = '_bly_bp_result_v1'; let BP_RESULT_HTML = ''; // 平台前端对帧数据有缓存,改完必须刷新页面才能看到真实属性,故用 localStorage 接力显示结果 function bpLoadResult() { try { const raw = localStorage.getItem(BP_RESULT_KEY); if (!raw) return; localStorage.removeItem(BP_RESULT_KEY); const o = JSON.parse(raw); if (o && o.time && (Date.now() - o.time) < 120000) { BP_RESULT_HTML = '📋 上轮批量改属性:成功 ' + o.ok + ' 帧' + (o.fail ? ',失败 ' + o.fail + ' 帧' : '') + '(改动标注 ' + (o.changed || 0) + ' 个)'; BP_OPEN = true; } } catch (e) { } } // 模式:标注模式 / 批注模式(平台为 ant-radio-group,选中项 class 含 checked) function currentMode() { const groups = document.querySelectorAll('.ant-radio-group'); for (let i = 0; i < groups.length; i++) { const opts = groups[i].querySelectorAll('.ant-radio-button-wrapper'); if (opts.length < 2) continue; const txts = [].map.call(opts, function (e) { return (e.innerText || '').trim(); }); if (txts.indexOf('标注模式') === -1 || txts.indexOf('批注模式') === -1) continue; for (let k = 0; k < opts.length; k++) { if (opts[k].className.indexOf('checked') !== -1) return txts[k]; } return null; } return null; } // 从 projectClass 动态构建可改目标:人脸框各属性 + 5 关键点可见性 function buildTargets(pc) { if (!pc || !pc.id) return null; const ids = pc.id, names = pc.name || [], exps = pc.exportName || [], cfg = pc.attributes || []; const out = {}; ids.forEach(function (clsId, i) { const group = cfg[i] || []; const clsName = names[i] || clsId; if (exps[i] === 'Face') { const attrs = {}; group.forEach(function (a) { if (!a || a.attrType !== 0) return; const opts = (a.attributes || []).filter(function (v) { return v.leafType === 2; }) .map(function (v) { return [v.id, v.name]; }); if (opts.length) attrs[a.id] = { title: a.name || a.id, options: opts }; }); if (Object.keys(attrs).length) { out.face = { key: 'face', clsId: clsId, title: clsName, kpValueId: null, attrs: attrs }; } } else if (exps[i] === 'face5') { const vis = group.filter(function (a) { return a.exportName === 'vis'; })[0]; const kp = group.filter(function (a) { return a.exportName === 'keypoint'; })[0]; if (!vis || !kp) return; const visOpts = (vis.attributes || []).filter(function (v) { return v.leafType === 2; }) .map(function (v) { return [v.id, v.name]; }); if (!visOpts.length) return; (kp.attributes || []).forEach(function (v) { const hit = /^face([1-5])$/.exec(v.exportName || ''); if (!hit) return; const key = KP_ORDER[parseInt(hit[1], 10) - 1]; const attrs = {}; attrs[vis.id] = { title: vis.name || '可见性', options: visOpts }; out[key] = { key: key, clsId: clsId, title: KP_LABELS[key] || key, kpValueId: v.id, attrs: attrs }; }); } }); return Object.keys(out).length ? out : null; } function bpEnsureSel() { if (!TARGETS) return; if (!TARGETS[BP_TARGET]) BP_TARGET = Object.keys(TARGETS)[0]; const t = TARGETS[BP_TARGET]; const attrKeys = Object.keys(t.attrs); if (attrKeys.indexOf(BP_ATTR) === -1) BP_ATTR = attrKeys[0]; const opts = t.attrs[BP_ATTR].options; if (!opts.some(function (o) { return o[0] === BP_VAL; })) BP_VAL = opts[0][0]; } /* ==================== 帧缓存与帧号跟踪 ==================== */ let FRAME_IDS = []; // 帧序号(1-based) → frameId,来自 contents/static 的 image2dList const FRAME_CACHE = {}; // frameId → shapeList let AUTH_HDRS = null; // 从页面真实请求里捕获的鉴权头(用于兜底补请求) let TASK_NO = null; // 当前包号(任务编号),来自 projectInfo.taskNo function currentFrameNo() { const el = document.querySelector('.frame-counter-clickable'); if (!el) return null; const m = /(\d+)\s*\/\s*(\d+)/.exec(el.innerText || ''); return m ? parseInt(m[1], 10) : null; } function frameIdFromUrl(u) { const m = /frame_id=([^&]+)/.exec(u || ''); return m ? m[1] : null; } function taskIdFromUrl() { const m = /[?&]recordId=([^&]+)/.exec(location.search || ''); return m ? m[1] : null; } // 兜底:脚本注入晚于页面加载时(拦不到 contents/static),自己补拉一次 let staticTried = false; function bpFetchStatic() { const tid = taskIdFromUrl(); if (!tid || !AUTH_HDRS) return; fetch(API_STATIC + '?task_id=' + tid, { headers: AUTH_HDRS }) .then(function (r) { return r.json(); }) .then(function (j) { handleStaticResponse(j); if (FRAME_IDS.length === 0) staticTried = false; }) .catch(function () { staticTried = false; }); } function showWaiting(no) { if (WARMUP_RUNNING) return; // 预热期间不弹提示 lastShapeList = null; lastRenderKey = null; createPanel(); if (!panelContent) return; let html = '
' + '' + '
' + '
第 ' + no + ' 帧加载中…
' + '正在获取该帧标注数据' + '
'; html += bpHTML(); // 等待期间功能区照常可用 panelContent.innerHTML = html; if (panelBody) panelBody.scrollTop = 0; } // 平台对已看过的帧走本地缓存、不再发请求,这里主动补一次,避免显示上一帧的陈旧结果 function requestFrame(no) { const fid = FRAME_IDS[no - 1]; if (!fid || !AUTH_HDRS) { showWaiting(no); return; } fetch(API_MARK + '?frame_id=' + fid, { headers: AUTH_HDRS }) .then(function (r) { return r.json(); }) .then(function (j) { if (j && (j.code === 200 || j.code === 0) && j.data && j.data.shapeList) { FRAME_CACHE[fid] = j.data.shapeList; analyzeShapes(j.data.shapeList, getImageSize()); } else { showWaiting(no); } }) .catch(function () { showWaiting(no); }); } /* ==================== 批量改属性:执行 ==================== */ function bpSetStatus(html) { const el = document.getElementById('_bly_bp_status'); if (el) el.innerHTML = html; } function bpGetFrame(fid) { return fetch(API_MARK + '?frame_id=' + fid, { headers: AUTH_HDRS }) .then(function (r) { return r.json(); }) .then(function (j) { if (!j || (j.code !== 200 && j.code !== 0) || !j.data) throw new Error('读取帧数据失败'); return j.data.shapeList || []; }); } // 保存单个标注:PUT /api/mark/image { frameId, oldId, newShape } function bpPutShape(fid, sh) { return fetch(API_MARK, { method: 'PUT', headers: Object.assign({ 'Content-Type': 'application/json' }, AUTH_HDRS || {}), body: JSON.stringify({ frameId: fid, oldId: String(sh.labelId), newShape: { imageId: String(sh.imageId), labelId: sh.labelId, shapeJson: sh.shapeJson, attrJson: sh.attrJson } }) }).then(function (r) { return r.json(); }).then(function (j) { if (j && j.code !== undefined && j.code !== 200 && j.code !== 0) { throw new Error((j.msg || '保存失败') + ' [' + j.code + ']'); } return j; }); } function bpHit(attr, T) { if (!attr || attr.type_id !== T.clsId) return false; if (T.kpValueId) { return !!(attr.attributes && attr.attributes[getMap().kpAttrId] === T.kpValueId); } return true; } async function runBatch() { if (BP_RUNNING) return; // 批注模式下自动切到标注模式(平台切换是异步的,需轮询等待生效) if (currentMode() !== '标注模式') { if (!bpEnsureAnnotateMode()) { bpSetStatus('⚠ 未找到模式切换控件,请手动切到标注模式'); return; } bpSetStatus('⏳ 正在切换到「标注模式」...'); for (let w = 0; w < 20 && currentMode() !== '标注模式'; w++) { await new Promise(function (r) { setTimeout(r, 150); }); } if (currentMode() !== '标注模式') { bpSetStatus('⚠ 自动切换标注模式失败,请手动切换后重试'); return; } } if (!TARGETS || !TARGETS[BP_TARGET]) { bpSetStatus('⚠ 未取到项目类目配置'); return; } if (!FRAME_IDS.length) { bpSetStatus('⚠ 未取到帧列表,请刷新页面后重试'); return; } if (!AUTH_HDRS) { bpSetStatus('⚠ 未捕获到鉴权头,请先在工作台切一次帧再试'); return; } const T = TARGETS[BP_TARGET]; const attrCfg = T.attrs[BP_ATTR]; const valName = (attrCfg.options.filter(function (o) { return o[0] === BP_VAL; })[0] || [])[1] || BP_VAL; const s = BP_S, e = BP_E, count = e - s + 1; if (!(s >= 1 && e <= FRAME_IDS.length && s <= e)) { bpSetStatus('⚠ 帧范围不合法'); return; } if (!window.confirm('批量修改确认:\n目标:' + T.title + '\n属性:' + attrCfg.title + ' → ' + valName + '\n帧范围:第 ' + s + ' - ' + e + ' 帧(共 ' + count + ' 帧)' + '\n\n将真实保存到平台,是否继续?')) return; BP_RUNNING = true; const okList = [], failList = []; let changedTotal = 0; for (let i = s - 1; i < e; i++) { const fid = FRAME_IDS[i]; bpSetStatus('⏳ 处理第 ' + (i - s + 2) + '/' + count + ' 帧...'); let done = false; for (let retry = 0; retry < 2 && !done; retry++) { try { const list = await bpGetFrame(fid); let changed = 0; for (let k = 0; k < list.length; k++) { const sh = list[k]; let attr; try { attr = JSON.parse(sh.attrJson || '{}'); } catch (err) { continue; } if (!bpHit(attr, T)) continue; attr.attributes = attr.attributes || {}; if (attr.attributes[BP_ATTR] === BP_VAL) continue; // 已是目标值 attr.attributes[BP_ATTR] = BP_VAL; sh.attrJson = JSON.stringify(attr); await bpPutShape(fid, sh); changed++; } changedTotal += changed; delete FRAME_CACHE[fid]; // 缓存失效,强制重新拉取 okList.push({ frame: i + 1, changed: changed }); done = true; } catch (err) { if (retry === 1) { failList.push({ frame: i + 1, err: err.message || String(err) }); } else { bpSetStatus('⏳ 第 ' + (i + 1) + ' 帧失败,重试中...'); await new Promise(function (r) { setTimeout(r, 600); }); } } } } BP_RUNNING = false; let html = '✅ 完成:成功 ' + okList.length + ' 帧,失败 ' + failList.length + ' 帧(改动标注 ' + changedTotal + ' 个)'; if (failList.length) { html += '
❌ 失败帧:' + failList.map(function (x) { return x.frame + '(' + x.err + ')'; }).join('、'); } bpSetStatus(html); try { localStorage.setItem(BP_RESULT_KEY, JSON.stringify({ time: Date.now(), ok: okList.length, fail: failList.length, changed: changedTotal })); } catch (e) { } // 平台前端缓存不会自动失效,刷新才能看到真实属性 location.reload(); } /* ==================== 响应处理 ==================== */ function handleMarkResponse(url, json) { if (!json || (json.code !== 200 && json.code !== 0)) return; const d = json.data; if (!d || !d.shapeList) return; const fid = frameIdFromUrl(url); if (fid) FRAME_CACHE[fid] = d.shapeList; analyzeShapes(d.shapeList, getImageSize()); } function handleStaticResponse(json) { if (!json || (json.code !== 200 && json.code !== 0)) return; const d = json.data || {}; const list = d.image2dList || []; if (list.length) { FRAME_IDS = list.map(function (f) { return f.frameId; }); if (BP_E <= 1 || BP_E > FRAME_IDS.length) BP_E = FRAME_IDS.length; if (BP_S < 1 || BP_S > FRAME_IDS.length) BP_S = 1; } if (d.projectInfo && d.projectInfo.taskNo) TASK_NO = String(d.projectInfo.taskNo); const pcStr = d.projectInfo && d.projectInfo.projectClass; const m = buildClassMap(pcStr); if (m) { CMAP = m; let pc = null; try { pc = (typeof pcStr === 'string') ? JSON.parse(pcStr) : pcStr; } catch (e) { } if (pc) { PROJECT_CLASS = pc; const t = buildTargets(pc); if (t) TARGETS = t; bpEnsureSel(); } lastRenderKey = null; // 映射更新后强制重渲染 if (lastShapeList) analyzeShapes(lastShapeList, getImageSize()); // 进题包后延迟预热(等页面渲染稳定再开始) if (!warmupTried && WARMUP_ON) { warmupTried = true; setTimeout(function () { warmupAllFrames(); }, 2500); } } } function routeResponse(url, json) { if (!url) return; if (url.indexOf(API_MARK) !== -1) handleMarkResponse(url, json); else if (url.indexOf(API_STATIC) !== -1) handleStaticResponse(json); } /* ==================== 鉴权头捕获(供兜底补请求使用) ==================== */ const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (!AUTH_HDRS && /^blade-auth$/i.test(k)) { AUTH_HDRS = { 'Blade-Auth': v, 'Tenant-Id': localStorage.getItem('tenantId') || '' }; } return origSetHeader.apply(this, arguments); }; /* ==================== XHR 拦截 ==================== */ const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { this._blyUrl = url; return origOpen.apply(this, arguments); }; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { const u = this._blyUrl || ''; if (u.indexOf(API_MARK) !== -1 || u.indexOf(API_STATIC) !== -1) { this.addEventListener('load', function () { let j = null; try { j = JSON.parse(this.responseText); } catch (e) { return; } routeResponse(u, j); }); } return origSend.apply(this, arguments); }; /* ==================== fetch 拦截 ==================== */ const origFetch = window.fetch; if (origFetch) { window.fetch = function (input, init) { const url = (typeof input === 'string') ? input : ((input instanceof Request) ? input.url : ''); const hit = (url.indexOf(API_MARK) !== -1 || url.indexOf(API_STATIC) !== -1); if (!hit) return origFetch.apply(this, arguments); return origFetch.apply(this, arguments).then(function (response) { try { response.clone().json().then(function (data) { routeResponse(url, data); }).catch(function () { }); } catch (e) { } return response; }); }; } /* ==================== 兜底轮询:跟踪帧切换 + 指纹去重 ==================== */ let lastFrameNo = null; function handleFrameSwitch(no) { lastFrameNo = no; const fid = FRAME_IDS[no - 1]; const cached = (fid && FRAME_CACHE[fid]) ? FRAME_CACHE[fid] : null; if (cached) { analyzeShapes(cached, getImageSize()); } else { showWaiting(no); requestFrame(no); } // 数据已在缓存 → 立刻聚焦;否则等 analyzeShapes 拿到数据时触发 autoZoomPending = !cached; if (cached) autoZoomFrame(); if (autoZoomTimer) clearTimeout(autoZoomTimer); autoZoomTimer = setTimeout(function () { autoZoomPending = false; autoZoomFrame(); }, 300); } // 帧切换用 MutationObserver 即时感知(避免轮询带来的几百毫秒延迟),定时器只做兜底 function setupFrameWatcher() { const nav = document.querySelector('.frame-navigation'); if (!nav || nav.__blyMo) return; nav.__blyMo = new MutationObserver(function () { const n = currentFrameNo(); if (n !== null && n !== lastFrameNo) handleFrameSwitch(n); }); nav.__blyMo.observe(nav, { childList: true, subtree: true, attributes: true, characterData: true }); } setInterval(function () { // 未拦到项目配置时主动补拉(注入晚于页面加载的情况) if (!staticTried && AUTH_HDRS && FRAME_IDS.length === 0) { staticTried = true; bpFetchStatic(); } setupFrameWatcher(); const no = currentFrameNo(); if (no !== null && no !== lastFrameNo) { handleFrameSwitch(no); return; } if (lastShapeList) analyzeShapes(lastShapeList, getImageSize()); }, 800); /* ==================== 调试导出 ==================== */ window.__BLY_CHECK__ = { analyze: analyzeShapes, buildClassMap: buildClassMap, normalizeShape: normalizeShape, getCMAP: function () { return CMAP; }, setCMAP: function (m) { CMAP = m; lastRenderKey = null; }, setFrameIds: function (ids) { FRAME_IDS = ids || []; }, getState: function () { return { frameIds: FRAME_IDS.length, cached: Object.keys(FRAME_CACHE).length, auth: Boolean(AUTH_HDRS), curFrame: currentFrameNo(), imgSize: getImageSize() }; }, validateGroup: validateGroup, closePanel: closePanel, // ---- 批量改属性(调试/预演用)---- currentMode: currentMode, getTargets: function () { return TARGETS; }, getFrameIds: function () { return FRAME_IDS; }, buildPutBody: function (fid, sh) { return { frameId: fid, oldId: String(sh.labelId), newShape: { imageId: String(sh.imageId), labelId: sh.labelId, shapeJson: sh.shapeJson, attrJson: sh.attrJson } }; }, setBatchSel: function (t, a, v) { BP_TARGET = t; BP_ATTR = a; BP_VAL = v; bpEnsureSel(); }, setRange: function (s, e) { BP_S = s; BP_E = e; bpSyncSlider(); }, setOpen: function (o) { BP_OPEN = !!o; bpRedraw(); }, ensureAnnotateMode: bpEnsureAnnotateMode, batch: runBatch, // ---- 复制「包号 + 有效帧」 ---- getTaskNo: function () { return TASK_NO; }, countValidFrames: countValidFrames, copyPackInfo: copyPackInfo }; bpLoadResult(); installMaskCss(); console.log('[比邻云人脸5点校验] 脚本加载成功'); })();