// ==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点校验] 脚本加载成功');
})();