// ==UserScript==
// @name SZ脚本
// @namespace http://118.196.97.105:8080
// @version 2.8
// @description 单面板合一:上=人脸+5关键点校验(镜像逻辑:人脸右眼在画面左侧、左眼在画面右侧;5点必须在人脸框(face)内;人脸框高度≥60像素),下=⚙批量改属性(可折叠、默认收起;帧范围双滑块,人脸框6属性 + 5个关键点可见性);✕ 关闭后面板不再被轮询重建,留有悬浮按钮可随时重开
// @author CC
// @match *:8080/*
// @grant none
// @run-at document-start
// @license CC
// ==/UserScript==
(function () {
'use strict';
const API_PATH = '/api/task/get-mark-data';
const KEYPOINT_ORDER = ['left_eye', 'right_eye', 'nose', 'left_mouth', 'right_mouth'];
const KEYPOINT_LABELS = {
left_eye: '左眼中心(人脸自身左眼,画面右侧)',
right_eye: '右眼中心(人脸自身右眼,画面左侧)',
nose: '鼻尖',
left_mouth: '左嘴角(人脸自身左嘴角,画面右侧)',
right_mouth: '右嘴角(人脸自身右嘴角,画面左侧)'
};
const PSELECT_MAP = {
'左眼中⼼': 'left_eye',
'左眼中心': 'left_eye',
'右眼中⼼': 'right_eye',
'右眼中心': 'right_eye',
'⿐尖': 'nose',
'鼻尖': 'nose',
'左嘴⻆': 'left_mouth',
'左嘴角': 'left_mouth',
'右嘴⻆': 'right_mouth',
'右嘴角': 'right_mouth'
};
function normalizePname(m) {
var pname = (m.class && m.class.pname) || '';
if (pname === 'righ_mouth') return 'right_mouth';
if (!pname || KEYPOINT_ORDER.indexOf(pname) === -1) {
var mapped = PSELECT_MAP[m.pselect];
if (mapped) return mapped;
}
return pname;
}
// ---- 关键点坐标几何校验配置 ----
const GEOM_CFG = {
QUAD_TOL: 0.10,
NOSE_Y_TOL: 0.15,
EPS_EYE_RATIO: 0.02,
FACEH_FALLBACK: 2.2,
MIN_EYE_DIST: 1
};
// 人脸框最小高度(像素):高度必须 ≥ 该值
const FACE_MIN_HEIGHT = 60;
function getPoint(m) {
if (!m || !m.point) return null;
var x = parseFloat(m.point.x);
var y = parseFloat(m.point.y);
if (isNaN(x) || isNaN(y)) 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 onSeg(p, a, b) {
return p.x >= Math.min(a.x, b.x) - 1e-9 && p.x <= Math.max(a.x, b.x) + 1e-9 &&
p.y >= Math.min(a.y, b.y) - 1e-9 && p.y <= Math.max(a.y, b.y) + 1e-9;
}
function segmentsIntersect(a, b, c, d) {
var d1 = cross(c, d, a);
var d2 = cross(c, d, b);
var d3 = cross(a, b, c);
var d4 = cross(a, b, d);
if (((d1 > 1e-9 && d2 < -1e-9) || (d1 < -1e-9 && d2 > 1e-9)) &&
((d3 > 1e-9 && d4 < -1e-9) || (d3 < -1e-9 && d4 > 1e-9))) return true;
if (Math.abs(d1) < 1e-9 && onSeg(a, c, d)) return true;
if (Math.abs(d2) < 1e-9 && onSeg(b, c, d)) return true;
if (Math.abs(d3) < 1e-9 && onSeg(c, a, b)) return true;
if (Math.abs(d4) < 1e-9 && onSeg(d, a, b)) return true;
return false;
}
function pointInQuad(p, quad, tol) {
var sign = cross(quad[0], quad[1], quad[2]) >= 0 ? 1 : -1;
for (var i = 0; i < quad.length; i++) {
var a = quad[i];
var b = quad[(i + 1) % quad.length];
var 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;
}
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) {
var missing = [];
if (!eL) missing.push('左眼');
if (!eR) missing.push('右眼');
if (!nose) missing.push('鼻尖');
if (!mL) missing.push('左嘴角');
if (!mR) missing.push('右嘴角');
errs.push('关键点坐标异常:关键点坐标缺失(' + missing.join('、') + ')');
return;
}
var 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;
}
var faceH = 0;
members.forEach(function (m) {
if (faceH === 0 && normalizePname(m) === 'face' && m.type === 'rect' && m.point) {
var bottom = parseFloat(m.point.bottom);
var top = parseFloat(m.point.top);
if (!isNaN(bottom) && !isNaN(top)) faceH = bottom - top;
}
});
if (!faceH || faceH <= 0) faceH = eyeDist * GEOM_CFG.FACEH_FALLBACK;
var eps = eyeDist * GEOM_CFG.EPS_EYE_RATIO;
var eyeDx = eL.x - eR.x;
var mouthDx = mL.x - mR.x;
// 【镜像修正】人脸自身右眼在画面左侧(X更小)、左眼在画面右侧(X更大)
if (eyeDx < eps) {
errs.push('关键点坐标异常:双眼颠倒;人脸右眼需要在画面左侧、左眼在画面右侧(右眼' + fmtP(eR) + '→左眼' + fmtP(eL) + ')');
}
if (mouthDx < eps) {
errs.push('关键点坐标异常:嘴角颠倒;人脸右嘴角需要在画面左侧、左嘴角在画面右侧(右嘴角' + fmtP(mR) + '→左嘴角' + fmtP(mL) + ')');
}
// 双眼、嘴角左右朝向需要保持一致
const eyeRight = eyeDx > eps;
const mouthRight = mouthDx > eps;
if (eyeRight !== mouthRight) {
errs.push('关键点坐标异常:双眼和嘴角左右朝向不一致(左眼' + fmtP(eL) + '→右眼' + fmtP(eR) + ',左嘴角' + fmtP(mL) + '→右嘴角' + fmtP(mR) + ')');
}
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;
const s1 = c12 >= 0 ? 1 : -1;
const s2 = c23 >= 0 ? 1 : -1;
const s3 = c30 >= 0 ? 1 : -1;
if (s0 !== s1 || s1 !== s2 || s2 !== s3) {
errs.push('关键点坐标异常:眼‑嘴四点形状错乱,五官左右颠倒(' + fmtP(eL) + ' ' + fmtP(eR) + ' ' + fmtP(mR) + ' ' + fmtP(mL) + ')');
}
var selfCross = segmentsIntersect(quad[0], quad[1], quad[2], quad[3]) ||
segmentsIntersect(quad[1], quad[2], quad[3], quad[0]);
if (selfCross) {
errs.push('关键点坐标异常:四关键点连线自交,坐标疑似错乱(' + fmtP(eL) + ' ' + fmtP(eR) + ' ' + fmtP(mR) + ' ' + fmtP(mL) + ')');
}
if (!pointInQuad(nose, quad, faceH * GEOM_CFG.QUAD_TOL)) {
errs.push('关键点坐标异常:鼻尖(' + fmtP(nose) + ')不在双眼‑嘴角四点框内部');
}
var yTol = faceH * GEOM_CFG.NOSE_Y_TOL;
var topY = Math.min(eL.y, eR.y) - yTol;
var 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) + ']');
}
const eyeMidY = (eL.y + eR.y) / 2;
if (nose.y < eyeMidY - yTol) {
errs.push('关键点坐标异常:鼻尖位置高于双眼,人脸上下坐标颠倒');
}
}
// ---- 悬浮窗 UI ----
let panelContainer = null;
let panelBody = null;
let panelContent = null;
let panelClosed = false; // 用户点了 ✕:阻断 150ms 轮询把面板重建出来
let reopenBtn = null; // 关闭后留下的悬浮重开按钮
let _lastResult = null; // 最近一次校验结果,供重开时立即回填
let isMinimized = false;
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
let panelX = 20;
let panelY = 60;
function createPanel() {
if (panelClosed) return;
if (panelContainer) return;
panelContainer = document.createElement('div');
panelContainer.id = '_gc_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 = '感知2D同源|无行人校验';
titleBar.addEventListener('mousedown', startDrag);
panelContainer.appendChild(titleBar);
const btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex;gap:6px;';
const minBtn = createBtn('—', '#e5e7eb', '#d1d5db', function (e) { e.stopPropagation(); toggleMinimize(); });
const closeBtn = createBtn('✕', '#fca5a5', '#f87171', function (e) { e.stopPropagation(); closePanel(); });
btnGroup.appendChild(minBtn);
btnGroup.appendChild(closeBtn);
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);
// 上:校验区;下:批量改属性区(可折叠)
panelContent.innerHTML = '
';
validEl = document.getElementById('_gc_valid');
bpHolderEl = document.getElementById('_gc_bpHolder');
// 事件委托:面板内容重建也不丢监听,只需绑定一次
panelContainer.addEventListener('click', bpOnEvent);
panelContainer.addEventListener('change', bpOnEvent);
panelContainer.addEventListener('input', bpOnEvent);
installBpStyle();
bpRestoreResult();
bpRender();
bpLoadFrames();
hideReopenBtn();
}
// ---- 关闭后残留的悬浮重开按钮 ----
function showReopenBtn() {
if (reopenBtn && document.body.contains(reopenBtn)) return;
reopenBtn = document.createElement('div');
reopenBtn.id = '_gc_reopen';
reopenBtn.textContent = '⚙ 校验面板';
reopenBtn.title = '重新打开校验 / 批量改属性面板';
reopenBtn.style.cssText = 'position:fixed;z-index:999998;left:' + panelX + 'px;top:' + panelY + 'px;'
+ 'padding:6px 11px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:16px;color:#3730a3;'
+ 'font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:12px;font-weight:bold;'
+ 'cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.12);user-select:none;';
reopenBtn.addEventListener('click', reopenPanel);
document.body.appendChild(reopenBtn);
}
function hideReopenBtn() {
if (reopenBtn) {
reopenBtn.remove();
reopenBtn = null;
}
}
function reopenPanel() {
panelClosed = false;
hideReopenBtn();
_lastValidHTML = null; // 复位指纹,强制重新渲染校验区
createPanel();
if (_lastResult) renderPanel(_lastResult);
}
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() {
panelClosed = true;
if (panelContainer) {
panelContainer.remove();
panelContainer = null;
}
panelBody = null;
panelContent = null;
validEl = null;
bpHolderEl = null;
showReopenBtn();
}
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';
panelContainer.style.transition = 'none';
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);
}
function renderPanel(result) {
_lastResult = result;
if (panelClosed) return; // 用户已关闭面板:不再重建、也不渲染到游离节点
createPanel();
if (!panelContent || !validEl) return;
const { groups, lonelyMarks, markStatus, marks } = result;
let html = '';
const totalGroups = Object.keys(groups).length;
const issueGroups = Object.values(groups).filter(g => !g.pass).length;
const lonelyCount = lonelyMarks.length;
const statusConflict = markStatus === 1 && marks && marks.length > 0;
const ok = issueGroups === 0 && lonelyCount === 0 && !statusConflict;
if (statusConflict) {
html += '';
html += '
🚫';
html += '
';
html += '
标注状态异常
';
html += '
mark_status=1(无需标注),但存在 ' + marks.length + ' 个标注数据';
html += '
';
}
html += '';
html += '
' + (ok ? '✅' : '⚠️') + '';
html += '
';
html += '
' + (ok ? '所有归组正确' : '存在异常') + '
';
html += '
' + totalGroups + ' 个组';
if (issueGroups > 0) html += ' · ' + issueGroups + ' 个组异常';
if (lonelyCount > 0) html += ' · ' + lonelyCount + ' 个孤立标注';
html += ' ';
if (lonelyCount > 0) {
html += '';
html += '
⚠ 孤立标注(未归组)
';
lonelyMarks.forEach(m => {
const label = m.class?.pname || m.pselect || '?';
html += '
';
html += '
';
html += '' + label + '';
html += 'markId: ' + m.markId + '';
html += '
';
if (m._boundErr) {
html += '
• ' + m._boundErr + '
';
}
html += '
';
});
html += '
';
}
const sortedGroupIds = Object.keys(groups).sort(function (a, b) { return Number(a) - Number(b); });
sortedGroupIds.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 label = m.class?.pname || m.pselect || '?';
const idx = m._kpIdx !== undefined ? (' [' + (m._kpIdx + 1) + ']') : '';
html += '' + label + idx + '';
});
html += '
';
html += '
';
});
// 内容未变则不重建 DOM:避免 150ms 轮询打断下拉选择/滑块拖动,并保留滚动位置
if (html === _lastValidHTML) return;
_lastValidHTML = html;
validEl.innerHTML = html;
}
let _lastMarkStatus = null;
let _lastMarks = null;
let lastRawData = null;
function analyzeMarks(marks, markStatus, imgWidth, imgHeight) {
_lastMarkStatus = markStatus;
_lastMarks = marks;
if (!marks || marks.length === 0) return;
const groupMap = {};
const lonelyMap = {};
marks.forEach(function (m) {
let gid = m.groupId;
if (gid === undefined || gid === null || gid === '' || gid === 0) {
gid = '__lonely_' + m.markId;
lonelyMap[gid] = true;
}
if (!groupMap[gid]) groupMap[gid] = [];
groupMap[gid].push(m);
});
const lonelyMarks = [];
Object.keys(groupMap).forEach(function (gid) {
if (lonelyMap[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, imgWidth, imgHeight);
});
lonelyMarks.forEach(function (m) {
var err = checkBounds(m, imgWidth, imgHeight);
if (err) m._boundErr = err;
});
const result = { groups: groups, lonelyMarks: lonelyMarks, markStatus: markStatus, marks: marks };
renderPanel(result);
}
function getPname(m) {
return normalizePname(m);
}
function checkBounds(m, imgWidth, imgHeight) {
var pname = getPname(m);
if (KEYPOINT_ORDER.indexOf(pname) !== -1) return null;
if (m.type !== 'rect') return null;
var pt = m.point;
if (!pt) return null;
var issues = [];
if (pt.left !== undefined) {
if (pt.left < 0) issues.push('left=' + pt.left.toFixed(1));
if (pt.top < 0) issues.push('top=' + pt.top.toFixed(1));
if (imgWidth && pt.right > imgWidth) issues.push('right=' + pt.right.toFixed(1) + '>' + imgWidth);
if (imgHeight && pt.bottom > imgHeight) issues.push('bottom=' + pt.bottom.toFixed(1) + '>' + imgHeight);
}
if (issues.length === 0) return null;
return '标注超出图像边界: ' + issues.join(', ');
}
function validateGroup(g, imgWidth, imgHeight) {
const members = g.members;
const errs = g.errors;
const counts = {};
const kpList = [];
members.forEach(function (m) {
const pname = getPname(m);
counts[pname] = (counts[pname] || 0) + 1;
if (KEYPOINT_ORDER.indexOf(pname) !== -1) {
kpList.push({ mark: m, pname: pname, idx: KEYPOINT_ORDER.indexOf(pname) });
}
});
//人脸框校验
const faceCount = counts['face'] || 0;
if (faceCount === 0) {
errs.push('缺少人脸框 (face)');
} else if (faceCount > 1) {
errs.push('人脸框超过 1 个 (' + faceCount + ')');
}
const fvCount = counts['face_visible'] || 0;
if (fvCount > 1) {
errs.push('face_visible 超过 1 个 (' + fvCount + ')');
}
// 【独立检测】人脸框高度必须 ≥ FACE_MIN_HEIGHT 像素(无前置条件)
members.forEach(function (m) {
if (getPname(m) !== 'face' || m.type !== 'rect' || !m.point) return;
var ft = parseFloat(m.point.top), fb = parseFloat(m.point.bottom);
if (isNaN(ft) || isNaN(fb)) return;
var fh = fb - ft;
if (fh < FACE_MIN_HEIGHT) {
errs.push('人脸框高度不足:' + fh.toFixed(1) + 'px < ' + FACE_MIN_HEIGHT + 'px');
}
});
if (kpList.length !== 5) {
errs.push('关键点数量错误:期望 5 个,实际 ' + kpList.length + ' 个');
} else {
var orderOk = true;
for (var i = 0; i < 5; i++) {
var expected = KEYPOINT_ORDER[i];
var actual = kpList[i].pname;
if (actual !== expected) {
orderOk = false;
break;
}
}
if (!orderOk) {
var actualStr = kpList.map(function (k) { return KEYPOINT_LABELS[k.pname] || k.pname; }).join(' → ');
var expectedStr = KEYPOINT_ORDER.map(function (k) { return KEYPOINT_LABELS[k]; }).join(' → ');
errs.push('关键点顺序错误:期望 ' + expectedStr + ',实际 ' + actualStr);
}
kpList.forEach(function (k, i) { k.mark._kpIdx = i + 1; });
const kpMap = {};
kpList.forEach(function (k) { kpMap[k.pname] = k.mark; });
validateKeypointGeometry(kpMap, members, errs);
// 【新增】5 点必须全部在人脸框(face)内
var faceMark = null;
members.forEach(function (m) {
if (!faceMark && getPname(m) === 'face' && m.type === 'rect' && m.point && m.point.left !== undefined) {
faceMark = m;
}
});
if (faceMark) {
var fLeft = parseFloat(faceMark.point.left), fTop = parseFloat(faceMark.point.top);
var fRight = parseFloat(faceMark.point.right), fBottom = parseFloat(faceMark.point.bottom);
if (!isNaN(fLeft) && !isNaN(fTop) && !isNaN(fRight) && !isNaN(fBottom)) {
kpList.forEach(function (k) {
var p = getPoint(k.mark);
if (p && (p.x < fLeft || p.x > fRight || p.y < fTop || p.y > fBottom)) {
errs.push('关键点出框:' + (KEYPOINT_LABELS[k.pname] || k.pname) + '(' + p.x.toFixed(1) + ',' + p.y.toFixed(1) + ') 不在人脸框 [' + fLeft.toFixed(1) + ',' + fTop.toFixed(1) + ',' + fRight.toFixed(1) + ',' + fBottom.toFixed(1) + '] 内');
}
});
}
}
}
members.forEach(function (m) {
var boundErr = checkBounds(m, imgWidth, imgHeight);
if (boundErr) errs.push(boundErr);
});
const standardClasses = ['face', 'face_visible'].concat(KEYPOINT_ORDER);
members.forEach(function (m) {
const pname = getPname(m);
if (standardClasses.indexOf(pname) === -1 && pname) {
errs.push('多余标注物: ' + pname);
}
});
if (errs.length > 0) g.pass = false;
return g;
}
//XHR拦截
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
this._url = url;
return origOpen.apply(this, arguments);
};
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
if (this._url && this._url.indexOf(API_PATH) !== -1) {
// 跳过切帧预取请求(SZ切帧秒开优化脚本标记),避免校验面板内容跳动
if (this._szPrefetch) return origSend.apply(this, arguments);
this.addEventListener('load', function () {
try {
var resp = JSON.parse(this.responseText);
if (resp.code === 0 && resp.data) {
lastRawData = resp.data;
var md = resp.data.markData;
analyzeMarks(md && md.marks, resp.data.mark_status, md && md.width, md && md.height);
}
} catch (e) {}
});
}
return origSend.apply(this, arguments);
};
//fetch拦截
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function (input, init) {
var url = typeof input === 'string' ? input : (input instanceof Request ? input.url : '');
// 内部批量请求跳过(带 X-GC-Internal 头),避免触发校验面板刷新
var hdrs = (init && init.headers) || {};
var isInternal = false;
if (hdrs instanceof Headers) { isInternal = hdrs.has('X-GC-Internal'); }
else if (hdrs && typeof hdrs === 'object') { isInternal = !!(hdrs['X-GC-Internal'] || hdrs['x-gc-internal']); }
if (url.indexOf(API_PATH) !== -1 && !isInternal) {
return origFetch.apply(this, arguments).then(function (response) {
var clone = response.clone();
clone.json().then(function (data) {
if (data.code === 0 && data.data) {
lastRawData = data.data;
var md = data.data.markData;
analyzeMarks(md && md.marks, data.data.mark_status, md && md.width, md.height);
}
}).catch(function () {});
return response;
});
}
return origFetch.apply(this, arguments);
};
}
//兜底定时器,切帧立刻刷新面板
setInterval(()=>{
if(lastRawData){
var md = lastRawData.markData;
analyzeMarks(md && md.marks, lastRawData.mark_status, md && md.width, md.height);
}
}, 150);
/* ==================== 批量改属性(集成自「SZ批量改属性」脚本) ==================== */
// 目标属性配置:对应平台 mark-conf 里的人脸框(face)与 5 个关键点类目
const BP_TARGETS = {
face: {
title: '人脸框(face)',
attrs: {
occlusion: { title: '遮挡程度', options: [['0', '无遮挡'], ['1', '轻微遮挡'], ['2', '严重遮挡']] },
truncation: { title: '截断程度', options: [['clear', '无截断'], ['light', '轻微截断'], ['medium', '中度截断'], ['heavy', '严重截断']] },
face_mask: { title: '佩戴口罩', options: [['0', '无口罩'], ['1', '有口罩']] },
glass: { title: '眼镜状态', options: [['0', '无眼镜'], ['1', '普通眼镜'], ['2', '墨镜']] },
face_illumination: { title: '光照条件', options: [['unknown', '未知'], ['dark', '过暗'], ['normal', '正常'], ['bright', '偏亮'], ['overexposed', '过曝']] },
ignore: { title: '是否忽略', options: [['0', '有效'], ['1', '忽略']] }
}
},
left_eye: { title: KEYPOINT_LABELS.left_eye, attrs: { vis: { title: '可见性', options: [['0', '可见'], ['1', '不可见']] } } },
right_eye: { title: KEYPOINT_LABELS.right_eye, attrs: { vis: { title: '可见性', options: [['0', '可见'], ['1', '不可见']] } } },
nose: { title: KEYPOINT_LABELS.nose, attrs: { vis: { title: '可见性', options: [['0', '可见'], ['1', '不可见']] } } },
left_mouth: { title: KEYPOINT_LABELS.left_mouth, attrs: { vis: { title: '可见性', options: [['0', '可见'], ['1', '不可见']] } } },
right_mouth: { title: KEYPOINT_LABELS.right_mouth, attrs: { vis: { title: '可见性', options: [['0', '可见'], ['1', '不可见']] } } }
};
const BP_RESULT_KEY = '_gc_bp_result_v1';
// ---- 批量改属性状态 ----
let bpOpen = false; // 折叠状态:默认收起
let bpTarget = 'face', bpAttr = 'occlusion', bpVal = '0';
let bpStart = 1, bpEnd = 1;
let bpFrames = [], bpTaskKey = '', bpFrameCount = 0;
let bpStatusHTML = '';
let bpRunning = false;
let validEl = null, bpHolderEl = null, _lastValidHTML = null;
// 纠正非法的目标/属性/取值组合(切换目标后属性会失效)
function bpEnsureSel() {
if (!BP_TARGETS[bpTarget]) bpTarget = Object.keys(BP_TARGETS)[0];
const T = BP_TARGETS[bpTarget];
if (!T.attrs[bpAttr]) bpAttr = Object.keys(T.attrs)[0];
const opts = T.attrs[bpAttr].options;
if (!opts.some(function (o) { return o[0] === bpVal; })) bpVal = opts[0][0];
}
// 双滑块样式只需注入一次(作用域限定在本面板内)
function installBpStyle() {
if (document.getElementById('_gc_bp_style')) return;
const st = document.createElement('style');
st.id = '_gc_bp_style';
st.textContent = '#_gc_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;line-height:0;font-size:0;vertical-align:top;}'
+ '#_gc_panel input[type=range]::-webkit-slider-runnable-track{height:14px;background:transparent;border:0;}'
+ '#_gc_panel input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:#3b82f6;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;pointer-events:auto;margin-top:0;}'
+ '#_gc_panel input[type=range]::-moz-range-track{height:14px;background:transparent;border:0;}'
+ '#_gc_panel input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#3b82f6;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.35);cursor:pointer;pointer-events:auto;}';
(document.head || document.documentElement).appendChild(st);
}
function bpSel(id, opts, cur) {
let h = '';
}
// 折叠区块 HTML:收起时只渲染标题条
function bpHTML() {
let h = '';
h += '
';
h += '⚙ 批量改属性';
h += '' + (bpOpen ? '▾ 收起' : '▸ 展开') + '';
h += '
';
if (!bpOpen) return h + '
';
bpEnsureSel();
const T = BP_TARGETS[bpTarget];
h += '';
h += '
目标
';
h += bpSel('_gc_bp_target', Object.keys(BP_TARGETS).map(function (k) { return [k, BP_TARGETS[k].title]; }), bpTarget);
h += '
属性
';
h += bpSel('_gc_bp_attr', Object.keys(T.attrs).map(function (k) { return [k, T.attrs[k].title]; }), bpAttr);
h += '
设为
';
h += bpSel('_gc_bp_val', T.attrs[bpAttr].options, bpVal);
const total = Math.max(bpFrameCount, 1);
const ps = total > 1 ? ((bpStart - 1) / (total - 1)) * 100 : 0;
const pe = total > 1 ? ((bpEnd - 1) / (total - 1)) * 100 : 100;
h += '
帧范围' + (bpFrameCount ? '(共 ' + bpFrameCount + ' 帧)' : '(读取中…)') + '
';
h += '
';
h += '
';
h += '
';
h += '
';
h += '
';
h += '
';
h += '
起始 ' + bpStart + ' - 结束 ' + bpEnd + ' 帧
';
h += '
';
h += '
' + (bpStatusHTML || '') + '
';
return h + '
';
}
// 只重绘批量区块,不动上方校验区
function bpRender() {
if (bpHolderEl) bpHolderEl.innerHTML = bpHTML();
}
function bpSetStatus(html) {
bpStatusHTML = html;
const el = document.getElementById('_gc_bp_status');
if (el) el.innerHTML = html;
}
// 直接同步滑块显示(不重建 DOM,避免打断拖动)
function bpSyncSlider() {
const total = Math.max(bpFrameCount, 1);
const sEl = document.getElementById('_gc_bp_srng');
const eEl = document.getElementById('_gc_bp_erng');
if (sEl) sEl.value = bpStart;
if (eEl) eEl.value = bpEnd;
const fill = document.getElementById('_gc_bp_fill');
if (fill && total > 1) {
const ps = ((bpStart - 1) / (total - 1)) * 100;
const pe = ((bpEnd - 1) / (total - 1)) * 100;
fill.style.left = ps + '%';
fill.style.width = Math.max(0, pe - ps) + '%';
}
const sl = document.getElementById('_gc_bp_slbl');
const el2 = document.getElementById('_gc_bp_elbl');
if (sl) sl.textContent = bpStart;
if (el2) el2.textContent = bpEnd;
}
// 切换目标/属性后只刷新下拉选项,不整块重建(否则刚点开的下拉会被销毁)
function bpRefreshSelects() {
bpEnsureSel();
const T = BP_TARGETS[bpTarget];
const tEl = document.getElementById('_gc_bp_target');
if (tEl) tEl.value = bpTarget;
const aEl = document.getElementById('_gc_bp_attr');
if (aEl) {
let h = '';
Object.keys(T.attrs).forEach(function (k) {
h += '';
});
aEl.innerHTML = h;
}
const vEl = document.getElementById('_gc_bp_val');
if (vEl) {
let h = '';
T.attrs[bpAttr].options.forEach(function (o) {
h += '';
});
vEl.innerHTML = h;
}
}
// 事件委托:面板重建也不会丢监听
function bpOnEvent(e) {
let t = e.target;
if (!t) return;
if (!t.id || t.id.indexOf('_gc_bp_') !== 0) {
t = (t.closest && t.closest('[id^="_gc_bp_"]')) || null;
if (!t) return;
}
const id = t.id;
if (id === '_gc_bp_toggle') {
if (e.type !== 'click') return;
bpOpen = !bpOpen;
bpRender();
// 校验结果可能很长,展开后滚到批量区,省得手动往下翻
if (bpOpen && bpHolderEl && typeof bpHolderEl.scrollIntoView === 'function') {
try { bpHolderEl.scrollIntoView({ block: 'nearest' }); } catch (e2) {}
}
} else if (id === '_gc_bp_run') {
if (e.type !== 'click') return;
bpRunBatch();
} else if (id === '_gc_bp_target') {
if (e.type !== 'change') return;
bpTarget = t.value; bpAttr = ''; bpVal = '';
bpRefreshSelects();
} else if (id === '_gc_bp_attr') {
if (e.type !== 'change') return;
bpAttr = t.value; bpVal = '';
bpRefreshSelects();
} else if (id === '_gc_bp_val') {
if (e.type !== 'change') return;
bpVal = t.value;
} else if (id === '_gc_bp_srng') {
if (e.type !== 'input') return;
bpStart = parseInt(t.value, 10);
if (bpStart > bpEnd) bpEnd = bpStart;
bpSyncSlider();
} else if (id === '_gc_bp_erng') {
if (e.type !== 'input') return;
bpEnd = parseInt(t.value, 10);
if (bpEnd < bpStart) bpStart = bpEnd;
bpSyncSlider();
}
}
/* ---- 数据请求(带 X-GC-Internal,避免触发本校验面板刷新) ---- */
function bpFetchInternal(url, opts) {
const tok = localStorage.getItem('token');
opts = opts || {};
opts.headers = Object.assign({ 'Access-Key': tok, 'X-GC-Internal': '1' }, opts.headers || {});
return fetch(url, opts).then(function (r) { return r.json(); });
}
function bpLoadFrames() {
const u = new URLSearchParams(location.search);
const taskId = u.get('id');
if (!taskId) return;
const status = u.get('status') || '0';
const access = u.get('access') || '';
bpFetchInternal('/v2/tasks/mark-conf?status=' + status + '&task_id=' + taskId + '&work_type=4&access=' + access)
.then(function (j) {
if (!j.data || !j.data.frame) return;
bpFrames = j.data.frame;
bpTaskKey = j.data.task_key || '';
const firstLoad = bpFrameCount === 0;
bpFrameCount = bpFrames.length;
// 首次载入取全范围;重开面板时保留用户已选范围(只做越界收敛)
if (firstLoad) {
bpStart = 1;
bpEnd = bpFrameCount;
} else {
if (bpStart < 1 || bpStart > bpFrameCount) bpStart = 1;
if (bpEnd < bpStart || bpEnd > bpFrameCount) bpEnd = bpFrameCount;
}
bpRender();
}).catch(function () {});
}
// 上轮批量结果(保存在 localStorage,reload 后回显并自动展开)
function bpRestoreResult() {
try {
const raw = localStorage.getItem(BP_RESULT_KEY);
if (!raw) return;
const o = JSON.parse(raw);
if (o && o.time && Date.now() - o.time < 120000 &&
String(o.package_id) === String(new URLSearchParams(location.search).get('package_id') || '')) {
let html = '📋 上轮结果:成功 ' + o.ok + ' 帧 / 失败 ' + o.fail + ' 帧(修改标注 ' + (o.changed || 0) + ' 个)';
if ((o.fails || []).length > 0) {
html += '
❌ 失败帧:' + o.fails.map(function (x) { return x.frame + '(' + x.err + ')'; }).join('、');
}
bpStatusHTML = html;
bpOpen = true;
}
localStorage.removeItem(BP_RESULT_KEY);
} catch (e) {}
}
/* ---- 执行批量修改 ---- */
function bpNormalizePname(m) {
const pname = (m.class && m.class.pname) || '';
if (pname === 'righ_mouth') return 'right_mouth';
return pname;
}
function bpSaveFrameData(d, taskId, workType, access) {
const md = d.markData;
const body = {
markData: {
imgUrl: md.imgUrl, width: md.width, height: md.height,
marks: md.marks, totalNums: md.totalNums,
rotateDeg: md.rotateDeg || 0, printscreen: md.printscreen || '',
is_minio: md.is_minio, imagePath: md.imagePath
},
task_id: Number(taskId), time: Date.now(),
mark_status: d.mark_status,
work_type: workType, access: access
};
return bpFetchInternal('/v2/tasks/mark-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (j) {
if (j.code && j.code !== '') throw new Error((j.msg || '') + ' [' + j.code + ']');
return j;
});
}
async function bpRunBatch() {
if (bpRunning) return;
const target = bpTarget, attr = bpAttr, value = bpVal;
const s = bpStart, e = bpEnd;
if (isNaN(s) || isNaN(e) || s < 1 || e < 1 || s > e) {
bpSetStatus('⚠ 请拖动滑块选择正确的帧范围(起始 ≤ 结束)');
return;
}
if (!bpFrames.length) { bpSetStatus('⚠ 未取到帧列表,请刷新页面后重试'); return; }
if (e > bpFrames.length) { bpSetStatus('⚠ 结束帧超出范围(共 ' + bpFrames.length + ' 帧)'); return; }
const u = new URLSearchParams(location.search);
const workType = parseInt(u.get('work_type') || '4', 10);
const access = parseInt(u.get('access') || '3', 10);
const attrTitle = BP_TARGETS[target].attrs[attr].title;
const valTitle = BP_TARGETS[target].attrs[attr].options.filter(function (o) { return o[0] === value; })[0][1];
if (!window.confirm('批量修改确认:\n目标:' + BP_TARGETS[target].title +
'\n属性:' + attrTitle + ' → ' + valTitle +
'\n帧范围:' + s + ' - ' + e + ' 帧(共 ' + (e - s + 1) + ' 帧,含首尾)' +
'\n\n将真实保存到平台,是否继续?')) {
return;
}
bpRunning = true;
bpRender();
const okList = [], failList = [];
const total = e - s + 1;
for (let i = s - 1; i < e; i++) {
const f = bpFrames[i];
bpSetStatus('⏳ 处理第 ' + (i - s + 2) + '/' + total + ' 帧(帧号 ' + (i + 1) + ')...');
let ok = false;
for (let retry = 0; retry < 2 && !ok; retry++) {
try {
const j = await bpFetchInternal('/api/task/get-mark-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time: Date.now(), task_id: Number(f.task_id), task_key: bpTaskKey, is_preview: 0 })
});
if (!j.data) throw new Error('get-mark-data 失败: ' + JSON.stringify(j).slice(0, 120));
const d = j.data;
const marks = d.markData.marks || [];
let changed = 0;
marks.forEach(function (m) {
const pname = bpNormalizePname(m);
if (target === 'face') {
if (m.type === 'rect' && pname === 'face') {
if (!m.attrs) m.attrs = {};
m.attrs[attr] = [value];
changed++;
}
} else {
if (m.type === 'point' && pname === target) {
if (!m.attrs) m.attrs = {};
m.attrs[attr] = [value];
changed++;
}
}
});
if (changed === 0) {
ok = true;
okList.push({ frame: i + 1, changed: 0 });
break;
}
await bpSaveFrameData(d, f.task_id, workType, access);
ok = true;
okList.push({ frame: i + 1, changed: changed });
} catch (err) {
if (retry === 1) {
failList.push({ frame: i + 1, err: err.message || String(err) });
} else {
bpSetStatus('⏳ 第 ' + (i + 1) + ' 帧失败,重试中...');
await new Promise(function (res) { setTimeout(res, 500); });
}
}
}
}
bpRunning = false;
const changedTotal = okList.reduce(function (a, x) { return a + (x.changed || 0); }, 0);
let html = '✅ 完成:成功 ' + okList.length + ' 帧,失败 ' + failList.length + ' 帧(修改标注 ' + changedTotal + ' 个)';
if (failList.length > 0) {
html += '
❌ 失败帧:' + failList.map(function (x) { return x.frame + '(' + x.err + ')'; }).join('、');
}
try {
localStorage.setItem(BP_RESULT_KEY, JSON.stringify({
package_id: String(u.get('package_id') || ''),
time: Date.now(),
ok: okList.length, fail: failList.length, total: total,
changed: changedTotal,
fails: failList
}));
} catch (e2) {}
bpRender();
bpSetStatus(html);
location.reload();
}
console.log('[SZ v2.8] 脚本加载成功|单面板:人脸校验 + 可折叠批量改属性(✕ 关闭后不再被重建)');
})();