// ==UserScript==
// @name SZ脚本
// @namespace http://118.196.97.105:8080
// @version 2.3
// @description 仅校验人脸+5个五官关键点;适配正对人脸镜像逻辑:人脸右眼位于画面左侧、左眼在画面右侧;5点必须全部在人脸框(face)内;批量修改框/点属性(自定义帧范围)
// @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
};
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 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 = '_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);
// 批量修改属性区块
batchSection = document.createElement('div');
batchSection.id = '_gc_batch_section';
batchSection.style.cssText = 'border-top:1px solid #d1d5db;padding:10px 12px;background:#fafafa;';
batchSection.innerHTML = buildBatchHTML();
panelContainer.appendChild(batchSection);
document.body.appendChild(panelContainer);
bindBatchEvents();
panelContent = document.createElement('div');
panelContent.style.cssText = 'padding:12px;background:#ffffff;';
panelBody.appendChild(panelContent);
}
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';
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) {
createPanel();
if (!panelContent) 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 += '
';
});
panelContent.innerHTML = html;
panelBody.scrollTop = 0;
}
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 + ')');
}
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);
};
}
// ==================== 批量修改属性模块 ====================
// 目标:人脸框 + 5 个关键点(独立选项),每个目标各自带属性
const BATCH_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', '不可见']] } } }
};
let batchSection = null;
let batchTargetSel = null;
let batchAttrSel = null;
let batchValueSel = null;
let batchStartRng = null;
let batchEndRng = null;
let batchStartLbl = null;
let batchEndLbl = null;
let batchFillEl = null;
let batchStatusDiv = null;
let batchRunBtn = null;
let batchRunning = false;
let batchFrameCount = 0;
const BATCH_RESULT_KEY = '_gc_sz_batch_result_v1';
let batchRestoreResult = null;
function batchSelHTML(id, opts) {
let h = '';
return h;
}
function buildBatchHTML() {
const targets = Object.keys(BATCH_TARGETS);
let h = '';
h += '⚙ 批量修改属性
';
h += '';
h += '';
h += '';
h += '';
h += '';
h += '
';
// 双滑块帧范围
h += '帧范围 (共 ? 帧)
';
h += '';
h += '
';
h += '
';
h += '
';
h += '
';
h += '
';
h += '起始 1 - 结束 1 帧
';
h += '';
h += '';
h += '
';
h += '';
return h;
}
// 双滑块区间同步(约束 start ≤ end + 填充条 + 数字显示)
function syncRangeUI() {
if (!batchStartRng || !batchEndRng || !batchFillEl) return;
let s = parseInt(batchStartRng.value, 10);
let e = parseInt(batchEndRng.value, 10);
if (s > e) { batchEndRng.value = s; e = s; }
if (e < s) { batchStartRng.value = e; s = e; }
const total = Math.max(batchFrameCount, 1);
const pctS = ((s - 1) / (total - 1)) * 100;
const pctE = ((e - 1) / (total - 1)) * 100;
batchFillEl.style.left = pctS + '%';
batchFillEl.style.width = Math.max(0, pctE - pctS) + '%';
batchStartLbl.textContent = s;
batchEndLbl.textContent = e;
}
function refreshBatchAttrSel() {
const t = batchTargetSel.value;
const attrs = BATCH_TARGETS[t].attrs;
const attrNames = Object.keys(attrs);
let cur = batchAttrSel ? batchAttrSel.value : null;
if (attrNames.indexOf(cur) === -1) cur = attrNames[0];
const wrap = document.getElementById('_gc_b_attr_wrap');
wrap.innerHTML = batchSelHTML('_gc_b_attr', attrNames.map(function (a) { return [a, attrs[a].title]; }));
batchAttrSel = document.getElementById('_gc_b_attr');
batchAttrSel.value = cur;
// 新 attr select 必须重新绑定 change 监听(旧 select 已被替换出 DOM)
batchAttrSel.addEventListener('change', refreshBatchValSel);
refreshBatchValSel();
}
function refreshBatchValSel() {
const t = batchTargetSel.value;
const a = batchAttrSel.value;
const opt = BATCH_TARGETS[t].attrs[a].options;
let cur = batchValueSel ? batchValueSel.value : null;
if (!opt.some(function (o) { return o[0] === cur; })) cur = opt[0][0];
const wrap = document.getElementById('_gc_b_val_wrap');
wrap.innerHTML = batchSelHTML('_gc_b_val', opt);
batchValueSel = document.getElementById('_gc_b_val');
batchValueSel.value = cur;
}
function bindBatchEvents() {
batchTargetSel = document.getElementById('_gc_b_target');
batchAttrSel = document.getElementById('_gc_b_attr');
batchValueSel = document.getElementById('_gc_b_val');
batchStartRng = document.getElementById('_gc_b_start_rng');
batchEndRng = document.getElementById('_gc_b_end_rng');
batchStartLbl = document.getElementById('_gc_b_start_lbl');
batchEndLbl = document.getElementById('_gc_b_end_lbl');
batchFillEl = document.getElementById('_gc_b_fill');
batchStatusDiv = document.getElementById('_gc_b_status');
batchRunBtn = document.getElementById('_gc_b_run');
if (!batchTargetSel || !batchRunBtn) return;
refreshBatchAttrSel();
// 双滑块:拖动实时约束 start ≤ end
batchStartRng.addEventListener('input', function () {
const s = parseInt(batchStartRng.value, 10);
const e = parseInt(batchEndRng.value, 10);
if (s > e) batchEndRng.value = s;
syncRangeUI();
});
batchEndRng.addEventListener('input', function () {
const s = parseInt(batchStartRng.value, 10);
const e = parseInt(batchEndRng.value, 10);
if (e < s) batchStartRng.value = e;
syncRangeUI();
});
// 帧总数提示 + 滑块范围
fetchFrameList().then(function (d) {
batchFrameCount = (d.frame || []).length;
const el = document.getElementById('_gc_b_total');
if (el) el.textContent = '(共 ' + batchFrameCount + ' 帧)';
batchStartRng.max = batchFrameCount;
batchEndRng.max = batchFrameCount;
batchEndRng.value = batchFrameCount;
syncRangeUI();
}).catch(function () {});
batchTargetSel.addEventListener('change', refreshBatchAttrSel);
// attr/val change 监听在 refreshBatchAttrSel/refreshBatchValSel 创建新 select 时绑定
batchRunBtn.addEventListener('click', runBatchModify);
// 恢复上轮批量修改结果(刷新后)
if (batchRestoreResult) {
const curPkg = String(new URLSearchParams(location.search).get('package_id') || '');
if (String(batchRestoreResult.package_id) === curPkg) {
let html = '📋 上轮结果:成功 ' + batchRestoreResult.ok + ' 帧 / 失败 ' + batchRestoreResult.fail + ' 帧(修改标注 ' + (batchRestoreResult.changed || 0) + ' 个)';
if ((batchRestoreResult.fails || []).length > 0) {
html += '
❌ 失败帧:' + batchRestoreResult.fails.map(function (x) { return x.frame + '(' + x.err + ')'; }).join('、');
}
batchStatusDiv.innerHTML = html;
try { localStorage.removeItem(BATCH_RESULT_KEY); } catch (e) {}
}
}
}
// 读取上轮批量修改结果(页面加载/刷新时)
function loadBatchRestore() {
try {
const raw = localStorage.getItem(BATCH_RESULT_KEY);
if (raw) {
const o = JSON.parse(raw);
if (o && o.time && Date.now() - o.time < 120000) batchRestoreResult = o;
else localStorage.removeItem(BATCH_RESULT_KEY);
}
} catch (e) {}
}
// 内部请求(带 token + 内部标记)
function fetchInternal(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 fetchFrameList() {
const u = new URLSearchParams(location.search);
const task_id = u.get('id');
const status = u.get('status') || '0';
const access = u.get('access') || '';
return fetchInternal('/v2/tasks/mark-conf?status=' + status + '&task_id=' + task_id + '&work_type=4&access=' + access).then(function (j) {
if (!j.data || !j.data.frame) throw new Error('mark-conf 获取失败');
return j.data;
});
}
function getFrameData(taskId, taskKey) {
return fetchInternal('/api/task/get-mark-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time: Date.now(), task_id: Number(taskId), task_key: taskKey, is_preview: 0 })
}).then(function (j) {
if (!j.data) throw new Error('get-mark-data 失败: ' + JSON.stringify(j).slice(0, 120));
return j.data;
});
}
function saveFrameData(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 fetchInternal('/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 runBatchModify() {
if (batchRunning) return;
const target = batchTargetSel.value;
const attr = batchAttrSel.value;
const value = batchValueSel.value;
const s = parseInt(batchStartRng.value, 10);
const e = parseInt(batchEndRng.value, 10);
if (isNaN(s) || isNaN(e) || s < 1 || e < 1 || s > e) {
batchStatusDiv.textContent = '⚠ 请拖动滑块选择正确的帧范围(起始 ≤ 结束)';
return;
}
const u = new URLSearchParams(location.search);
const workType = parseInt(u.get('work_type') || '4', 10);
const access = parseInt(u.get('access') || '3', 10);
let conf;
try {
conf = await fetchFrameList();
} catch (err) {
batchStatusDiv.textContent = '⚠ ' + err.message;
return;
}
const frames = conf.frame || [];
if (e > frames.length) {
batchStatusDiv.textContent = '⚠ 结束帧超出范围(共 ' + frames.length + ' 帧)';
return;
}
const attrTitle = BATCH_TARGETS[target].attrs[attr].title;
const valTitle = BATCH_TARGETS[target].attrs[attr].options.filter(function (o) { return o[0] === value; })[0][1];
if (!window.confirm('批量修改确认:\n目标:' + BATCH_TARGETS[target].title +
'\n属性:' + attrTitle + ' → ' + valTitle +
'\n帧范围:' + s + ' - ' + e + ' 帧(共 ' + (e - s + 1) + ' 帧,含首尾)' +
'\n\n将真实保存到平台,是否继续?')) {
return;
}
batchRunning = true;
batchRunBtn.disabled = true;
batchRunBtn.style.background = '#9ca3af';
const okList = [];
const failList = [];
const total = e - s + 1;
for (let i = s - 1; i < e; i++) {
const f = frames[i];
batchStatusDiv.textContent = '⏳ 处理第 ' + (i - s + 2) + '/' + total + ' 帧(帧号 ' + (i + 1) + ')...';
let ok = false;
for (let retry = 0; retry < 2 && !ok; retry++) {
try {
const d = await getFrameData(f.task_id, conf.task_key);
const marks = d.markData.marks || [];
let changed = 0;
marks.forEach(function (m) {
const pname = normalizePname(m);
if (target === 'face') {
if (m.type === 'rect' && pname === 'face') {
if (!m.attrs) m.attrs = {};
m.attrs[attr] = [value];
changed++;
}
} else {
// target 是某个关键点 pname
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 saveFrameData(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 {
batchStatusDiv.textContent = '⏳ 第 ' + (i + 1) + ' 帧失败,重试中...';
await new Promise(function (res) { setTimeout(res, 500); });
}
}
}
}
batchRunning = false;
batchRunBtn.disabled = false;
batchRunBtn.style.background = '#3b82f6';
let html = '✅ 完成:成功 ' + okList.length + ' 帧,失败 ' + failList.length + ' 帧';
const changedTotal = okList.reduce(function (a, x) { return a + (x.changed || 0); }, 0);
html += '(修改标注 ' + changedTotal + ' 个)';
if (failList.length > 0) {
html += '
❌ 失败帧:' + failList.map(function (x) { return x.frame + '(' + x.err + ')'; }).join('、');
}
batchStatusDiv.innerHTML = html;
// 保存结果供刷新后恢复显示,立即刷新
try {
localStorage.setItem(BATCH_RESULT_KEY, JSON.stringify({
package_id: String(new URLSearchParams(location.search).get('package_id') || ''),
time: Date.now(),
ok: okList.length, fail: failList.length, total: total,
changed: changedTotal,
fails: failList
}));
} catch (e) {}
location.reload();
}
//兜底定时器,切帧立刻刷新面板
setInterval(()=>{
if(lastRawData){
var md = lastRawData.markData;
analyzeMarks(md && md.marks, lastRawData.mark_status, md && md.width, md.height);
}
},150)
loadBatchRestore();
console.log('[归组检查] 脚本加载成功|已修复人脸镜像左右逻辑');
})();