// ==UserScript==
// @name 手势归组校验工具 - 分组检查
// @namespace http://113.207.49.90/
// @version 2.5.0
// @description 自动校验:切帧即校验(含缓存帧);自动统计整个题包半身框总数并在超过上限(默认33)时报错;检查归组、手势框<半身框<行人框包含关系、无需标注却有标注的帧;题包存在未归组的帧时阻止“整题通过”并自动跳到该帧
// @author 粟镇
// @match *://*/w/task.html*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// 配置区(可在面板“设置”里改,改完自动记住)
// ============================================================
const SETTINGS_KEY = 'sz-gesture-settings-v3';
const STORAGE_KEY = 'sz-gesture-package-stats-v3';
const DEFAULT_SETTINGS = {
clsFull: 'full_bbox',
clsHalf: 'half_bbox_xywh',
clsHand: 'handbboxxywh',
tolPx: 0, // 越界容差(像素);0 = 严格不能超出
totalOverride: '', // 读不到总帧数时手动填
halfLimit: 33 // 整个题包半身框总数上限,超过即报错;0 表示不限制
};
// 标签兜底:没有 class.pname 时按显示名判断
const LABEL_FULL = ['行人主框', '行人框', '全身框', '行人'];
const LABEL_HALF = ['半身框', '半身'];
const LABEL_HAND = ['手势框', '手势'];
const CAPTURE_MAX = 300; // 最多保留的接口响应数
const CAPTURE_MAX_BYTES = 8 * 1024 * 1024; // 单条超过 8MB 不解析
const SCAN_DEPTH_MAX = 16;
const SCAN_CONCURRENCY = 6; // 题包统计并发数
const SUBMIT_BLOCK = true; // 有分组问题时阻止整题通过
// ============================================================
// 状态
// ============================================================
let cachedData = null;
let panel = null;
let panelReady = false;
let lastRequest = null; // 最近一次 get-mark-data 请求
let scanning = false; // 是否正在接口批量统计
let currentImage = { w: null, h: null };
let settings = Object.assign({}, DEFAULT_SETTINGS);
let cachedFrameKey = null; // 当前 cachedData 对应的帧(图片路径)
let cachedFrameNo = null; // 当前 cachedData 对应的帧号
let lastWatchedFrame = null; // 帧号监听:上次看到的帧号
let autoBatchRanSig = null; // 已自动统计过的题包,避免重复
let autoBatchRunning = false;
// 提交拦截
let scanPromise = null; // 正在进行的题包扫描
let allowPassThrough = false; // 放行标志(校验通过后重新触发点击)
let pendingPass = false; // 点过“整题通过”,等待确认
let blocking = false; // 正在做提交前校验
// 捕获到的所有接口响应:{ url, method, json, frames:[...] }
let captured = [];
// 全题包统计:frames 以“帧唯一键”索引,避免翻页与接口统计重复计数
// { taskSig, totalFrames, frames: { key: {half, marks, status, no, src} } }
let pkgStats = { taskSig: '', totalFrames: null, frames: {} };
// ============================================================
// 拦截 XHR / fetch
// ============================================================
function hookXHR() {
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url) {
this._szUrl = url;
this._szMethod = method;
this._szHeaders = {};
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (this._szHeaders) this._szHeaders[name] = value;
return origSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
const self = this;
const isMarkData = self._szUrl && self._szUrl.indexOf('get-mark-data') !== -1;
if (isMarkData) {
lastRequest = {
url: self._szUrl,
method: self._szMethod || 'GET',
body: body,
headers: self._szHeaders || {},
frameNoAtSend: resolveFrameNo(self._szUrl, body)
};
}
let handled = false;
const onDone = function () {
if (handled || self.readyState !== 4) return;
handled = true;
if (self.status !== 200) return;
const resp = readJsonLoose(self.responseText, self.response);
if (!resp) return;
captureResponse(self._szUrl, self._szMethod, resp);
if (isMarkData && resp.code === 0 && resp.data) {
const md = resp.data.markData || {};
if (md.width) currentImage.w = md.width;
if (md.height) currentImage.h = md.height;
let frameNo = lastRequest.frameNoAtSend;
if (frameNo === null || frameNo === undefined) frameNo = extractFrameNo(resp.data);
if (frameNo === null || frameNo === undefined) {
const ind = readFrameIndicator();
if (ind) frameNo = ind.cur;
}
setCachedData(resp.data, frameNo);
recordFrame(resp.data.markData ? resp.data : { markData: md, mark_status: resp.data.mark_status }, frameNo, 'walk', extractTaskId(resp.data));
if (panelReady) autoRunCheck();
// 首次拿到数据后,自动统计整个题包(只跑一次)
maybeAutoBatch();
}
};
try { self.addEventListener('load', onDone); } catch (e) {}
try { self.addEventListener('readystatechange', onDone); } catch (e) {}
return origSend.apply(this, arguments);
};
}
function hookFetch() {
if (typeof window === 'undefined' || !window.fetch) return;
const origFetch = window.fetch;
window.fetch = function () {
const args = arguments;
return origFetch.apply(this, args).then(function (resp) {
try {
const url = (typeof args[0] === 'string') ? args[0] : (args[0] && args[0].url);
const method = (args[1] && args[1].method) || (args[0] && args[0].method) || 'GET';
resp.clone().text().then(function (txt) {
const json = readJsonLoose(txt, null);
if (json) captureResponse(url, method, json);
}).catch(function () {});
} catch (e) {}
return resp;
});
};
}
function readJsonLoose(text, fallbackObj) {
if (typeof text === 'string' && text) {
const t = text.trim();
if (t.length > CAPTURE_MAX_BYTES) return null;
if (t.charAt(0) === '{' || t.charAt(0) === '[') {
try { return JSON.parse(t); } catch (e) {}
}
}
if (fallbackObj && typeof fallbackObj === 'object') return fallbackObj;
return null;
}
// 捕获响应并就地解析出其中的“帧”数据
function captureResponse(url, method, json) {
let frames = [];
try { frames = collectFrames(json); } catch (e) { frames = []; }
captured.push({ url: url || '', method: method || '', json: json, frames: frames, at: Date.now() });
while (captured.length > CAPTURE_MAX) captured.shift();
return frames;
}
// 深度遍历任意 JSON,抓出所有“含 marks 的帧对象”
function collectFrames(root) {
const out = [];
const seen = new Set(); // 防止循环引用
const consumed = new Set(); // 已作为帧容器使用过的节点,不再向下挖,避免重复计数
const visited = [];
(function walk(node, depth) {
if (depth > SCAN_DEPTH_MAX || !node || typeof node !== 'object') return;
if (seen.has(node) || consumed.has(node)) return;
seen.add(node);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walk(node[i], depth + 1);
return;
}
let marks = null, status, imagePath = null, container = null;
if (Array.isArray(node.marks) && node.marks.length) {
marks = node.marks;
status = (node.mark_status !== undefined) ? node.mark_status : undefined;
container = node;
} else if (node.markData && Array.isArray(node.markData.marks) && node.markData.marks.length) {
marks = node.markData.marks;
status = (node.mark_status !== undefined) ? node.mark_status : node.markData.mark_status;
imagePath = node.markData.imagePath || node.markData.imgUrl || null;
container = node.markData;
} else if (Array.isArray(node.mark_data) && node.mark_data.length) {
marks = node.mark_data;
status = node.mark_status;
container = node;
}
if (marks && marks.some(isMarkLike)) {
if (!imagePath) imagePath = node.imagePath || node.imgUrl || node.img || node.url || null;
visited.push({ marks: marks, status: status, imagePath: imagePath });
consumed.add(container);
}
for (const k in node) {
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
if (k === 'marks' || k === 'mark_data') continue;
const v = node[k];
if (v && typeof v === 'object') walk(v, depth + 1);
}
})(root, 0);
// 同一响应内去重:先按图片路径,再用 markId 集合兜底
const bySig = {}, byMarks = {};
for (let i = 0; i < visited.length; i++) {
const f = visited[i];
const sig = frameSignature(f);
const mk = 'k:' + (f.marks || []).map(function (m) { return m.markId; }).filter(Boolean).join(',');
if (bySig[sig] || (mk !== 'k:' && byMarks[mk])) continue;
bySig[sig] = true;
if (mk !== 'k:') byMarks[mk] = true;
out.push(f);
}
return out;
}
function isMarkLike(o) {
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
const hasClass = (o.class && (o.class.pname || o.class.name)) || o.pselect;
if (!hasClass) return false;
return !!extractBox(o);
}
// 帧唯一键:优先图片路径(同一帧在任何接口里都一致),否则用 markId 组合
function frameSignature(f) {
if (f.imagePath) return 'p:' + f.imagePath;
const ids = (f.marks || []).map(function (m) { return m.markId; }).filter(Boolean).join(',');
if (ids) return 'm:' + ids;
return 'j:' + JSON.stringify((f.marks || []).slice(0, 6)).slice(0, 400);
}
// ============================================================
// 帧号 / 总帧数读取
// ============================================================
function readFrameIndicator() {
if (!document.body) return null;
const re = /^\s*(\d+)\s*[\/|·]\s*(\d+)\s*$/;
let best = null;
const consider = function (raw) {
if (raw === null || raw === undefined) return;
const t = String(raw).trim();
if (!t || t.length > 16) return;
const m = t.match(re);
if (!m) return;
const cur = parseInt(m[1], 10), tot = parseInt(m[2], 10);
if (cur >= 1 && tot >= cur && tot <= 200000) {
if (!best || tot > best.tot) best = { cur: cur, tot: tot };
}
};
const all = document.body.querySelectorAll('*');
for (let i = 0; i < all.length; i++) {
const el = all[i];
if (panel && panel.contains(el)) continue;
if (el.children && el.children.length > 2) continue;
consider(el.textContent);
}
const inputs = document.body.querySelectorAll('input, textarea');
for (let i = 0; i < inputs.length; i++) {
if (panel && panel.contains(inputs[i])) continue;
consider(inputs[i].value);
}
return best;
}
function getTotalFrames() {
const ind = readFrameIndicator();
if (ind) return ind.tot;
const n = toNum(settings.totalOverride);
return (n !== null && n > 0) ? n : null;
}
function resolveFrameNo(url, body) {
const fromReq = pickFrameFromRequest(url, body);
if (fromReq !== null) return fromReq;
const ind = readFrameIndicator();
return ind ? ind.cur : null;
}
function pickFrameFromRequest(url, body) {
const names = ['frame_id', 'frameId', 'frame_index', 'frameIndex', 'frame_no', 'frameNo',
'current_frame', 'currentFrame', 'frame', 'index', 'idx'];
for (let i = 0; i < names.length; i++) {
const n = toNum(pickParamValue(url, body, [names[i]]));
if (n !== null) return n;
}
return null;
}
function extractFrameNo(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['frame_id', 'frameId', 'frame_index', 'frameIndex', 'frame',
'frame_no', 'frameNo', 'current_frame', 'currentFrame', 'index', 'idx', 'seq'];
for (let i = 0; i < keys.length; i++) {
const n = toNum(data[keys[i]]);
if (n !== null) return n;
}
return null;
}
// 从接口返回里取 task_id(本平台 task_id 即帧标识)
function extractTaskId(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['task_id', 'taskId', 'taskID'];
for (let i = 0; i < keys.length; i++) {
const v = data[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return null;
}
function extractTotalFrames(data) {
if (!data || typeof data !== 'object') return null;
const keys = ['total_frame', 'totalFrame', 'total_frames', 'totalFrames', 'frame_count', 'frameCount', 'frames_total'];
for (let i = 0; i < keys.length; i++) {
const n = toNum(data[keys[i]]);
if (n !== null && n > 0 && n <= 200000) return n;
}
return null;
}
// ============================================================
// 参数工具
// ============================================================
function toNum(v) {
if (typeof v === 'number') return isNaN(v) ? null : v;
if (typeof v === 'string' && v.trim() !== '') {
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
return null;
}
function parseBody(body) {
if (body === null || body === undefined || body === '') return null;
if (typeof body === 'object') return body;
if (typeof body !== 'string') return null;
try { return JSON.parse(body); } catch (e) {}
try {
const sp = new URLSearchParams(body);
const o = {};
sp.forEach(function (v, k) { o[k] = v; });
return o;
} catch (e) {}
return null;
}
function pickParamValue(url, body, names) {
for (let i = 0; i < names.length; i++) {
const name = names[i];
try {
const u = new URL(url, location.href);
if (u.searchParams.has(name)) return u.searchParams.get(name);
} catch (e) {}
const obj = parseBody(body);
if (obj && obj[name] !== undefined && obj[name] !== null) return obj[name];
}
return null;
}
function getTaskSignature() {
let taskId = null;
if (lastRequest) taskId = pickParamValue(lastRequest.url, lastRequest.body, ['task_id', 'taskId']);
if (taskId === null) {
try {
const u = new URL(location.href);
taskId = u.searchParams.get('task_id') || u.searchParams.get('taskId');
} catch (e) {}
}
if (taskId !== null && taskId !== undefined && taskId !== '') return 'task:' + taskId;
if (cachedData && cachedData.markData && cachedData.markData.imagePath) {
return 'img:' + cachedData.markData.imagePath.replace(/\/[^/]*$/, '');
}
return 'path:' + location.pathname;
}
// ============================================================
// 标记分类 / 坐标提取
// ============================================================
function getClassNames(mark) {
const cls = mark.class || {};
const names = [];
if (cls.pname) names.push(String(cls.pname));
if (cls.name) names.push(String(cls.name));
if (mark.pselect) names.push(String(mark.pselect));
return names;
}
function classifyMark(mark) {
const names = getClassNames(mark);
const low = names.map(function (n) { return n.toLowerCase(); });
const eqAny = function (target) { return target && low.indexOf(String(target).toLowerCase()) !== -1; };
if (eqAny(settings.clsFull)) return 'full';
if (eqAny(settings.clsHalf)) return 'half';
if (eqAny(settings.clsHand)) return 'hand';
const hit = function (arr) { return names.some(function (n) { return arr.indexOf(n) !== -1; }); };
if (hit(LABEL_FULL)) return 'full';
if (hit(LABEL_HALF)) return 'half';
if (hit(LABEL_HAND)) return 'hand';
return null;
}
function extractBox(mark) {
if (!mark) return null;
const sources = [
mark.point, mark.points, mark,
mark.bbox, mark.box, mark.rect, mark.position, mark.pos,
mark.coords, mark.coord, mark.data
].filter(function (s) { return s && typeof s === 'object'; });
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
if (Array.isArray(s)) {
if (s.length >= 2 && s.every(function (p) { return p && typeof p === 'object' && toNum(p.x) !== null; })) {
const xs = [], ys = [];
s.forEach(function (p) { xs.push(toNum(p.x)); ys.push(toNum(p.y)); });
return { x1: Math.min.apply(null, xs), y1: Math.min.apply(null, ys), x2: Math.max.apply(null, xs), y2: Math.max.apply(null, ys) };
}
if (s.length >= 4) {
const a = s.map(toNum);
if (a[0] !== null && a[1] !== null && a[2] !== null && a[3] !== null) {
if (a[2] > a[0] && a[3] > a[1]) return { x1: a[0], y1: a[1], x2: a[2], y2: a[3] };
return { x1: a[0], y1: a[1], x2: a[0] + a[2], y2: a[1] + a[3] };
}
}
continue;
}
const l = toNum(s.left !== undefined ? s.left : s.x1);
const t = toNum(s.top !== undefined ? s.top : s.y1);
const r = toNum(s.right !== undefined ? s.right : s.x2);
const b = toNum(s.bottom !== undefined ? s.bottom : s.y2);
if (l !== null && t !== null && r !== null && b !== null) return { x1: l, y1: t, x2: r, y2: b };
const x = toNum(s.x !== undefined ? s.x : s.left);
const y = toNum(s.y !== undefined ? s.y : s.top);
const w = toNum(s.w !== undefined ? s.w : s.width);
const h = toNum(s.h !== undefined ? s.h : s.height);
if (x !== null && y !== null && w !== null && h !== null) return { x1: x, y1: y, x2: x + w, y2: y + h };
const x1 = toNum(s.xmin), y1 = toNum(s.ymin), x2 = toNum(s.xmax), y2 = toNum(s.ymax);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return { x1: x1, y1: y1, x2: x2, y2: y2 };
const pts = s.points || s.pts || s.xy || s.polygon;
if (Array.isArray(pts) && pts.length) {
const xs = [], ys = [];
pts.forEach(function (p) {
const px = Array.isArray(p) ? toNum(p[0]) : toNum(p.x);
const py = Array.isArray(p) ? toNum(p[1]) : toNum(p.y);
if (px !== null && py !== null) { xs.push(px); ys.push(py); }
});
if (xs.length) return { x1: Math.min.apply(null, xs), y1: Math.min.apply(null, ys), x2: Math.max.apply(null, xs), y2: Math.max.apply(null, ys) };
}
}
return null;
}
function isNormalized(box) {
const maxV = Math.max(Math.abs(box.x1), Math.abs(box.y1), Math.abs(box.x2), Math.abs(box.y2));
return maxV <= 1.5;
}
function getTolerance() {
return (typeof settings.tolPx === 'number' && !isNaN(settings.tolPx)) ? settings.tolPx : 0;
}
function intersectArea(a, b) {
const x1 = Math.max(a.x1, b.x1), y1 = Math.max(a.y1, b.y1);
const x2 = Math.min(a.x2, b.x2), y2 = Math.min(a.y2, b.y2);
if (x2 <= x1 || y2 <= y1) return 0;
return (x2 - x1) * (y2 - y1);
}
function isInside(outer, inner, tol) {
return inner.x1 >= outer.x1 - tol && inner.y1 >= outer.y1 - tol &&
inner.x2 <= outer.x2 + tol && inner.y2 <= outer.y2 + tol;
}
function getOverflow(outer, inner) {
let tol = getTolerance();
if (isNormalized(outer)) tol = tol / Math.max(currentImage.w || 1920, 1);
const eps = 1e-6;
const norm = isNormalized(outer);
const unit = norm ? '' : 'px';
const fmt = function (v) { return norm ? v.toFixed(4) : v.toFixed(1); };
const over = [];
if (inner.x1 < outer.x1 - tol - eps) over.push('左侧 ' + fmt(outer.x1 - inner.x1) + unit);
if (inner.y1 < outer.y1 - tol - eps) over.push('上侧 ' + fmt(outer.y1 - inner.y1) + unit);
if (inner.x2 > outer.x2 + tol + eps) over.push('右侧 ' + fmt(inner.x2 - outer.x2) + unit);
if (inner.y2 > outer.y2 + tol + eps) over.push('下侧 ' + fmt(inner.y2 - outer.y2) + unit);
return over;
}
// 归组标识:本平台用 groupId(配合 groupL),objectId 是“对象/轨迹”,不是归组键
function getGroupId(mark) {
const v = mark.groupId !== undefined ? mark.groupId
: (mark.group_id !== undefined ? mark.group_id : mark.group);
if (v === undefined || v === null || v === '') return null;
if (v === 0 || v === '0') return null; // 0 表示未归组
return String(v);
}
function pickParent(childBox, candidates) {
const withBox = candidates.filter(function (c) { return c.box; });
if (!withBox.length) return null;
let best = null, bestScore = -Infinity;
for (let i = 0; i < withBox.length; i++) {
const c = withBox[i];
const score = (isInside(c.box, childBox, getTolerance()) ? 1e12 : 0) + intersectArea(childBox, c.box);
if (score > bestScore) { bestScore = score; best = c; }
}
if (best && bestScore <= 0) return null;
return best;
}
// ============================================================
// 全题包统计
// ============================================================
function countClass(marks, kind) {
let n = 0;
for (let i = 0; i < (marks || []).length; i++) {
if (classifyMark(marks[i]) === kind) n++;
}
return n;
}
// 帧唯一键:优先图片路径,其次帧号
function getFrameKey(data, frameNo) {
const md = (data && data.markData) || {};
if (md.imagePath) return 'p:' + md.imagePath;
if (md.imgUrl) return 'u:' + String(md.imgUrl).split('?')[0];
if (frameNo !== null && frameNo !== undefined) return 'n:' + frameNo;
return null;
}
function recordFrame(data, frameNo, src, taskId) {
const md = (data && data.markData) || {};
const marks = md.marks || [];
if (!marks.length && data && data.mark_status === undefined) return;
const key = getFrameKey(data, frameNo);
if (!key) return;
const probs = frameGroupProblems(md, data.mark_status);
const sig = getTaskSignature();
if (pkgStats.taskSig !== sig) {
pkgStats = { taskSig: sig, totalFrames: pkgStats.totalFrames || null, frames: {} };
}
const tot = getTotalFrames() || extractTotalFrames(data);
if (tot) pkgStats.totalFrames = tot;
pkgStats.frames[key] = {
half: countClass(marks, 'half'),
marks: marks.length,
status: data.mark_status,
no: (frameNo !== null && frameNo !== undefined) ? frameNo : null,
src: src || 'walk',
bad: probs.length,
badMsg: probs.length ? probs.slice(0, 3).join(';').slice(0, 300) : '',
tid: (taskId !== undefined && taskId !== null) ? String(taskId)
: (pkgStats.frames[key] && pkgStats.frames[key].tid) || null
};
saveStats();
if (panelReady) updatePackageView();
}
// 记录当前帧数据对应的帧标识(图片路径 + 帧号)
function setCachedData(data, frameNo) {
cachedData = data;
const md = (data && data.markData) || {};
const path = md.imagePath || md.imgUrl || null;
cachedFrameKey = path ? String(path).split('?')[0] : null;
cachedFrameNo = (frameNo !== null && frameNo !== undefined) ? frameNo : extractFrameNo(data);
// 若帧号与页面指示器一致,同步监听基线,避免重复拉取
if (cachedFrameNo !== null && cachedFrameNo !== undefined) lastWatchedFrame = cachedFrameNo;
}
// 帧切换监听:靠帧号变化驱动自动校验。
// 平台对已看过的帧会走缓存、不发请求,只有轮询帧号才能保证切帧必校验。
function startFrameWatcher() {
setInterval(frameWatcherTick, 700);
}
// 一次帧切换检查:返回 'ok'(已校验当前缓存帧)/ 'load'(需拉取)/ 'idle'
function frameWatcherTick() {
if (scanning || autoBatchRunning) return 'idle';
const ind = readFrameIndicator();
if (!ind) return 'idle';
if (ind.cur === lastWatchedFrame) return 'idle';
lastWatchedFrame = ind.cur;
// 帧号变了:若当前缓存的数据就是这一帧,直接校验
if (cachedFrameNo === ind.cur) {
if (panelReady) autoRunCheck();
return 'ok';
}
autoLoadFrame(ind.cur);
return 'load';
}
let autoLoadBusy = false;
async function autoLoadFrame(frameNo) {
if (autoLoadBusy) return;
if (!lastRequest || !cachedData) return; // 等页面自身首次加载完成再自动拉取
autoLoadBusy = true;
try {
// 优先从 DOM 帧导航条拿这一帧的 task_id
const domList = readFrameListFromDom();
let taskId = null;
if (domList && domList.list.length) {
const hit = domList.list.filter(function (e) { return e.order === frameNo - 1 || e.order === frameNo; });
if (hit.length) taskId = hit[0].id;
else if (domList.list[frameNo - 1]) taskId = domList.list[frameNo - 1].id;
}
if (taskId === null) return; // 拿不到 task_id 就不乱请求
const data = await requestMarkData(taskId);
setCachedData(data, frameNo);
recordFrame(data, frameNo, 'walk', taskId);
if (panelReady) autoRunCheck();
} catch (e) {
// 自动拉取失败保持静默,不打扰标注
} finally {
autoLoadBusy = false;
}
}
function frameNoFromPath(p) {
if (!p) return null;
const m = String(p).match(/(\d+)(?=\.\w+$)/);
return m ? parseInt(m[1], 10) : null;
}
// ============================================================
// 题包任务列表 → 逐帧请求 get-mark-data
// ============================================================
function walkJson(node, visit, depth, key) {
depth = depth || 0;
if (depth > SCAN_DEPTH_MAX || !node || typeof node !== 'object') return;
visit(node, key);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walkJson(node[i], visit, depth + 1, null);
return;
}
for (const k in node) {
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
const v = node[k];
if (v && typeof v === 'object') walkJson(v, visit, depth + 1, k);
}
}
const TASK_ID_KEYS = ['task_id', 'taskId', 'taskID', 'tid'];
const TASK_UUID_KEYS = ['task_uuid', 'taskUuid', 'uuid'];
const TASK_ORDER_KEYS = ['order', 'sort', 'index', 'idx', 'frame', 'frame_no', 'frameNo', 'seq', 'no'];
function firstKey(obj, keys) {
for (let i = 0; i < keys.length; i++) {
if (obj[keys[i]] !== undefined && obj[keys[i]] !== null && obj[keys[i]] !== '') return obj[keys[i]];
}
return null;
}
// 判断一个对象是否像“帧/任务”条目(本平台:data.frame[] 只带 task_id 和状态位)
function taskEntryOf(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
const id = firstKey(obj, TASK_ID_KEYS);
if (id === null) return null;
if (Array.isArray(id) || typeof id === 'object') return null;
return {
id: id,
uuid: firstKey(obj, TASK_UUID_KEYS),
image: null, // 帧列表不含图片,图片从 get-mark-data 里取
order: toNum(firstKey(obj, TASK_ORDER_KEYS))
};
}
// 帧条目特征字段,用于给候选数组加分
const FRAME_FLAG_KEYS = ['done_status', 'mark_flag', 'is_save', 'is_acceptance', 'has_issues', 'mark_status'];
// 从所有已捕获响应里找出最像“题包帧列表”的数组
function findTaskList() {
const pageTotal = getTotalFrames();
let best = null;
for (let i = 0; i < captured.length; i++) {
const url = String(captured[i].url || '');
walkJson(captured[i].json, function (node, key) {
if (!Array.isArray(node) || node.length < 2 || node.length > 20000) return;
const entries = [];
let flagHits = 0;
for (let j = 0; j < node.length; j++) {
const e = taskEntryOf(node[j]);
if (e) entries.push(e);
if (node[j] && typeof node[j] === 'object') {
for (let f = 0; f < FRAME_FLAG_KEYS.length; f++) {
if (node[j][FRAME_FLAG_KEYS[f]] !== undefined) { flagHits++; break; }
}
}
}
if (entries.length < 2) return;
if (entries.length < node.length * 0.5) return;
let score = entries.length + flagHits * 10;
if (key === 'frame') score += 20000; // mark-conf 的 data.frame 最可信
if (pageTotal && entries.length === pageTotal) score += 100000; // 与页面总帧数一致,几乎可确定是全量
if (/packages?\/[^/]*\/tasks|mark-conf|package|batch|frame/i.test(url)) score += 5000;
else if (/task/i.test(url)) score += 1000;
if (!best || score > best.score) best = { score: score, entries: entries, url: url };
});
}
if (!best) return null;
const seen = {};
const list = [];
best.entries.forEach(function (e) {
const k = String(e.id);
if (seen[k]) return;
seen[k] = true;
list.push(e);
});
const hasOrder = list.some(function (e) { return e.order !== null; });
if (hasOrder) {
list.sort(function (a, b) {
const ao = a.order === null ? 1e9 : a.order, bo = b.order === null ? 1e9 : b.order;
return ao - bo;
});
}
return { list: list, url: best.url };
}
function buildMarkBody(taskId) {
let base = parseBody(lastRequest ? lastRequest.body : null) || {};
if (typeof base !== 'object' || Array.isArray(base)) base = {};
const body = {};
for (const k in base) { if (Object.prototype.hasOwnProperty.call(base, k)) body[k] = base[k]; }
body.task_id = taskId;
body.time = Date.now();
return body;
}
// 从页面 URL 读题包参数(id/task_id、package_id、status、work_type、access)
function readPageParams() {
const q = {};
try {
const u = new URL(location.href);
u.searchParams.forEach(function (v, k) { q[k] = v; });
} catch (e) {}
return q;
}
// 复用平台请求头(含 Access-Key 等鉴权头)
function authHeaders(extra) {
const h = Object.assign({}, (lastRequest && lastRequest.headers) || {});
// 采集不到时给常见默认值,保证基本可用
if (!h['X-Requested-With']) h['X-Requested-With'] = 'XMLHttpRequest';
h['Accept'] = h['Accept'] || 'application/json, text/javascript, */*; q=0.01';
return Object.assign(h, extra || {});
}
function getTaskKey() {
let tk = lastRequest ? pickParamValue(lastRequest.url, lastRequest.body, ['task_key', 'taskKey']) : null;
if (tk === null) tk = readPageParams().task_key || null;
return tk;
}
async function getJson(url, method) {
try {
const resp = await fetch(url, { method: method || 'GET', credentials: 'include', headers: authHeaders() });
if (!resp.ok) return { error: 'HTTP ' + resp.status };
const json = await resp.json();
captured.push({ url: url, method: method || 'GET', json: json, frames: [], at: Date.now() });
return { json: json };
} catch (e) {
return { error: String(e && e.message || e) };
}
}
// 从 DOM 的帧导航条读取帧列表:平台渲染
function readFrameListFromDom() {
if (!document.body) return null;
const nodes = document.body.querySelectorAll('[data-task]');
const list = [];
const seen = {};
for (let i = 0; i < nodes.length; i++) {
const el = nodes[i];
if (panel && panel.contains(el)) continue;
const id = el.getAttribute('data-task');
if (id === null || id === undefined || id === '') continue;
if (seen[id]) continue;
seen[id] = true;
const dl = el.getAttribute('data-list');
list.push({ id: id, uuid: null, image: null, order: toNum(dl) });
}
if (list.length < 2) return null;
list.sort(function (a, b) {
const ao = a.order === null ? 1e9 : a.order, bo = b.order === null ? 1e9 : b.order;
return ao - bo;
});
return { list: list, url: 'DOM: [data-task]' };
}
// 主动获取整个题包的帧列表(多路尝试)
async function fetchFrameList() {
// 1) 已捕获的数据里直接找
let tl = findTaskList();
if (tl && tl.list.length) return tl;
// 2) DOM 帧导航条
tl = readFrameListFromDom();
if (tl && tl.list.length) return tl;
const q = readPageParams();
const taskId = q.task_id || q.id || (lastRequest ? pickParamValue(lastRequest.url, lastRequest.body, ['task_id', 'taskId']) : null);
const taskKey = getTaskKey();
const packageId = q.package_id || q.packageId || null;
const status = q.status !== undefined ? q.status : 0;
const workType = q.work_type !== undefined ? q.work_type : 3;
const access = q.access !== undefined ? q.access : 3;
// 2) 题包全量帧列表:/v2/task-batches/{task_key}/packages/{package_id}/tasks
// 注意:平台源码里 URL 带一个多余的 } (packages/44841}/tasks),两种都试
if (taskKey && packageId) {
const urls = [
'/v2/task-batches/' + taskKey + '/packages/' + packageId + '}/tasks',
'/v2/task-batches/' + taskKey + '/packages/' + packageId + '/tasks'
];
for (let i = 0; i < urls.length; i++) {
const r = await getJson(urls[i]);
if (r.json) {
tl = findTaskList();
if (tl && tl.list.length) return tl;
}
}
}
// 3) mark-conf 的 data.frame
if (taskId !== null && taskId !== undefined) {
const url = '/v2/tasks/mark-conf?status=' + status + '&task_id=' + taskId + '&work_type=' + workType + '&access=' + access;
const r = await getJson(url);
if (r.json) {
tl = findTaskList();
if (tl && tl.list.length) return tl;
}
}
return tl;
}
async function requestMarkData(taskId) {
const url = lastRequest ? lastRequest.url : '/api/task/get-mark-data';
const headers = authHeaders({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' });
const resp = await fetch(url, {
method: (lastRequest && lastRequest.method) || 'POST',
credentials: 'include',
headers: headers,
body: JSON.stringify(buildMarkBody(taskId))
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const json = await resp.json();
if (!json || json.code !== 0 || !json.data) throw new Error('接口返回异常');
return json.data;
}
async function batchScanFrames() {
if (scanning) return false;
scanning = true;
if (!lastRequest) {
scanning = false;
updatePackageView('尚未捕获到 get-mark-data 请求,请先切换一帧');
return false;
}
const tl = await fetchFrameList();
if (!tl || !tl.list.length) {
scanning = false;
updatePackageView('自动统计:暂未拿到题包帧列表,稍后自动重试…');
return false;
}
const total = tl.list.length;
let ok = 0, fail = 0, done = 0;
// 并发请求,加快整个题包统计
let cursor = 0;
const worker = async function () {
while (true) {
const i = cursor++;
if (i >= total) return;
const t = tl.list[i];
try {
const data = await requestMarkData(t.id);
const md = data.markData || {};
recordFrame({ markData: md, mark_status: data.mark_status }, (t.order !== null ? t.order : (i + 1)), 'api', t.id);
ok++;
} catch (e) {
fail++;
}
done++;
updatePackageView('整个题包统计中 ' + done + ' / ' + total + (fail ? '(失败 ' + fail + ')' : ''));
if (done % 10 === 0) saveStats();
}
};
const ws = [];
for (let w = 0; w < Math.min(SCAN_CONCURRENCY, total); w++) ws.push(worker());
await Promise.all(ws);
saveStats();
scanning = false;
updatePackageView('整个题包统计完成:成功 ' + ok + ' 帧' + (fail ? ',失败 ' + fail + ' 帧' : '') + ',半身框共 ' + pkgHalfTotal() + ' 个');
return true;
}
// 共用一次扫描,避免重复请求
function runScanOnce() {
if (!scanPromise) {
scanPromise = Promise.resolve()
.then(function () { return batchScanFrames(); })
.finally(function () { scanPromise = null; });
}
return scanPromise;
}
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// 首次拿到标注数据后,自动统计整个题包(成功前会持续重试,最多若干次)
function maybeAutoBatch() {
if (autoBatchRunning || scanning) return;
const sig = getTaskSignature();
if (autoBatchRanSig === sig) return;
autoBatchRunning = true;
let attempts = 0;
const attempt = function () {
attempts++;
updatePackageView('正在自动统计整个题包…(第 ' + attempts + ' 次)');
Promise.resolve()
.then(function () { return runScanOnce(); })
.then(function (done) {
if (done) {
autoBatchRanSig = sig;
autoBatchRunning = false;
} else if (attempts < 5) {
setTimeout(attempt, 2500);
} else {
autoBatchRunning = false;
updatePackageView('自动统计暂未成功,可刷新题包页面后重试');
}
})
.catch(function () {
if (attempts < 5) setTimeout(attempt, 2500);
else autoBatchRunning = false;
});
};
setTimeout(attempt, 800);
}
// ============================================================
// 提交拦截:有分组/包含问题的帧存在时,不让整题通过
// ============================================================
function findSubmitButtons() {
const out = [];
const tryPush = function (el) {
if (el && (!panel || !panel.contains(el))) out.push(el);
};
tryPush(document.querySelector('.toolBarEditorRight #admin_pass_all'));
tryPush(document.getElementById('admin_pass_all'));
if (!out.length && document.body) {
const nodes = document.body.querySelectorAll('[id*="pass_all"], [id*="pass-all"]');
for (let i = 0; i < nodes.length; i++) if (panel && panel.contains(nodes[i])) continue; else out.push(nodes[i]);
}
// 确认弹窗里的最终按钮(点它才真正提交)
tryPush(document.getElementById('reject-sub-all'));
return out;
}
function clearPendingPass() {
pendingPass = false;
findSubmitButtons().forEach(function (b) { try { b.classList.remove('sz-blocked'); } catch (e) {} });
}
// 捕获阶段拦截点击;只拦最外层入口,确认弹窗里的最终按钮放行(避免重复提示)
function onSubmitClickCapture(e) {
if (!SUBMIT_BLOCK) return;
const t = e.target;
if (!t || !t.closest) return;
if (panel && panel.contains(t)) return;
if (!t.closest('#admin_pass_all')) return;
if (allowPassThrough) { allowPassThrough = false; pendingPass = false; return; }
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (blocking) return;
pendingPass = true;
blocking = true;
updatePackageView('正在校验整个题包的分组情况…');
Promise.resolve()
.then(function () { return runScanOnce(); })
.then(function (scanOk) {
blocking = false;
// 只要有任何问题证据就阻止提交(即使本次扫描不完整)
const bad = pkgGroupProblems();
if (bad.length) {
if (scanOk === false) {
renderSubmitBlock(bad, '(注意:本次题包校验未完整完成,以下问题来自已获取的数据)');
} else {
renderSubmitBlock(bad);
}
// 直接跳转到第一处有问题的帧,方便马上修正
try { awaitJumpToBadFrame(); } catch (e) {}
return;
}
// 没有任何问题证据:扫描不完整时放行并提示,否则正常放行
if (scanOk === false) {
updatePackageView('未能完整校验题包(帧列表或接口异常),未发现问题,已放行提交');
}
pendingPass = false;
passThrough();
})
.catch(function () {
blocking = false;
pendingPass = false;
updatePackageView('提交前校验出错,已放行');
passThrough();
});
}
// 放行:重新触发一次“整题通过”
function passThrough() {
const btn = document.querySelector('.toolBarEditorRight #admin_pass_all') || document.getElementById('admin_pass_all');
if (!btn) return;
allowPassThrough = true;
btn.classList.remove('sz-blocked');
btn.click();
}
// 触发跳转(不阻塞提交拦截流程,也不抛出)
function awaitJumpToBadFrame() {
Promise.resolve()
.then(function () { return jumpToBadFrame(); })
.then(function (ok) {
if (ok) setScanStatus('⛔ 提交被阻止:已跳转到第一处有问题的帧');
})
.catch(function () {});
}
// 提交被拦时的提示
function renderSubmitBlock(bad, note) {
const nums = bad.map(function (e) { return e.no === null || e.no === undefined ? '?' : e.no; });
const shown = nums.slice(0, 20).join('、');
const more = nums.length > 20 ? (' 等 ' + nums.length + ' 帧') : '';
const detail = bad.slice(0, 6).map(function (e) {
const n = (e.no === null || e.no === undefined) ? '?' : e.no;
return '第 ' + n + ' 帧:' + (e.badMsg || '');
}).join('\n');
// 面板内提示
const alerts = document.getElementById('sz-pkg-alerts');
if (alerts) {
alerts.innerHTML = '⛔ 存在分组问题的帧,已阻止整题通过:'
+ nums.length + ' 帧(' + shown + more + ')
' + alerts.innerHTML;
}
setScanStatus('⛔ 提交被阻止:' + nums.length + ' 帧存在分组问题');
// 弹窗提示
showSubmitBlockDialog(nums.length, shown + more, detail, note);
if (panel && panelReady) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
// 按钮标红
findSubmitButtons().forEach(function (b) { try { b.classList.add('sz-blocked'); } catch (e) {} });
}
function setScanStatus(t) {
const sEl = document.getElementById('sz-scan-status');
if (sEl) sEl.textContent = t;
}
// 提交被拦时的弹窗
let blockBadFrames = [];
function showSubmitBlockDialog(count, frameList, detail, note) {
if (!panel) return;
let mask = document.getElementById('sz-block-mask');
if (!mask) {
mask = document.createElement('div');
mask.id = 'sz-block-mask';
mask.innerHTML = `
⛔ 无法整题通过
以下帧存在分组或包含关系问题,请先修正后再提交:
共 ${count} 帧:${frameList}
`;
panel.appendChild(mask);
document.getElementById('sz-block-close').addEventListener('click', function () {
mask.classList.remove('open');
});
document.getElementById('sz-block-jump').addEventListener('click', function () {
mask.classList.remove('open');
jumpToBadFrame();
});
mask.addEventListener('click', function (e) {
if (e.target === mask) mask.classList.remove('open');
});
} else {
mask.querySelector('#sz-block-body').innerHTML =
'以下帧存在分组或包含关系问题,请先修正后再提交:
' +
'共 ' + count + ' 帧:' + frameList + '
' +
'' +
'';
}
const d = document.getElementById('sz-block-detail');
if (d) {
d.textContent = detail || '';
d.style.display = detail ? 'block' : 'none';
}
const nEl = document.getElementById('sz-block-note');
if (nEl) nEl.textContent = note || '';
mask.classList.add('open');
}
// 定位并跳转到指定 task_id 的帧
async function jumpToTaskId(taskId) {
if (taskId === null || taskId === undefined || taskId === '') return false;
// 只从帧导航条里找(排除面板),不要误匹配到自定义属性
let nodes = [];
if (document.body) {
nodes = Array.prototype.slice.call(document.body.querySelectorAll('li[data-task]'));
}
const node = nodes.filter(function (el) {
return (!panel || !panel.contains(el)) && String(el.getAttribute('data-task')) === String(taskId);
})[0];
if (node) {
node.click();
return true;
}
// 节点没渲染时,用平台自身的切帧方法(用完整帧列表,最可靠)
try {
const cc = window.InitCanvasInfo && (window.InitCanvasInfo.canvasCommon || window.InitCanvasInfo.canvasInstance);
if (cc && cc.Header && cc.Header.nextFrame && cc.params && cc.params.frame) {
cc.Header.nextFrame({ task_id: taskId }, { is_point: true, object: cc });
return true;
}
} catch (e) {}
return false;
}
// 跳到第一处有问题的帧
async function jumpToBadFrame() {
const bad = pkgGroupProblems();
if (!bad.length) return false;
const target = bad[0];
// 1) 优先用记录下来的 task_id
if (target.tid && await jumpToTaskId(target.tid)) return true;
// 2) 退化为按帧号取帧列表第 N 项
const no = target.no;
if (no === null || no === undefined || no < 1) return false;
const domList = readFrameListFromDom();
if (!domList || !domList.list.length) return false;
const entry = domList.list[no - 1];
if (!entry) return false;
return await jumpToTaskId(entry.id);
}
function pkgEntries() {
const out = [];
const f = pkgStats.frames;
for (const k in f) {
if (!Object.prototype.hasOwnProperty.call(f, k)) continue;
const v = f[k];
const rec = (typeof v === 'number') ? { half: v, marks: null, status: null, no: null } : v;
let no = (rec.no === undefined) ? null : rec.no;
if (no === null || no === undefined) no = frameNoFromKey(k);
out.push({ key: k, half: rec.half || 0, marks: rec.marks, status: rec.status, no: no, bad: rec.bad || 0, badMsg: rec.badMsg || '', tid: rec.tid || null });
}
out.sort(function (a, b) {
const na = a.no === null ? 1e9 : a.no, nb = b.no === null ? 1e9 : b.no;
return na - nb;
});
return out;
}
// 从帧唯一键里尽力还原帧号:'n:12' 或纯数字键,以及图片路径里的数字
function frameNoFromKey(k) {
if (k === null || k === undefined) return null;
const s = String(k);
let m = s.match(/^n:(\d+)$/);
if (m) return parseInt(m[1], 10);
m = s.match(/^(\d+)$/);
if (m) return parseInt(m[1], 10);
return frameNoFromPath(s);
}
function pkgHalfTotal() {
return pkgEntries().reduce(function (s, e) { return s + e.half; }, 0);
}
// 半身框总数是否超限(上限可配置,0 表示不限制)
function isHalfOverLimit(total) {
const limit = toNum(settings.halfLimit);
return (limit !== null && limit > 0 && total > limit);
}
function pkgFrameCount() { return Object.keys(pkgStats.frames).length; }
function frameNoLabel(e) { return (e.no === null || e.no === undefined) ? '?' : e.no; }
function pkgNoMarkViolations() {
return pkgEntries().filter(function (e) { return e.status === 1 && e.marks !== null && e.marks > 0; })
.map(frameNoLabel);
}
function pkgMissingFrames() {
return pkgEntries().filter(function (e) { return e.status === 0 && e.marks === 0; })
.map(frameNoLabel);
}
function saveStats() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(pkgStats)); } catch (e) {} }
function loadStats() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const obj = JSON.parse(raw);
if (obj && obj.frames && obj.taskSig === getTaskSignature()) pkgStats = obj;
} catch (e) {}
}
// ============================================================
// 校验逻辑
// ============================================================
function validateMarks(respData) {
const results = [];
const markStatus = respData.mark_status;
const marks = (respData.markData && respData.markData.marks) || [];
const hasMarks = marks.length > 0;
if (markStatus === 1 && !hasMarks) { results.push({ type: 'info', msg: '无需标注,跳过检查' }); return results; }
if (markStatus === 1 && hasMarks) {
results.push({ type: 'warning', msg: `[状态异常] 本帧点了"无需标注",但里面标注了 ${marks.length} 个框,可能误操作` });
}
if (markStatus === 0 && !hasMarks) {
results.push({ type: 'warning', msg: '[可能漏标] 标注状态为正常标注,但没有任何标注数据' });
return results;
}
if (!hasMarks) { results.push({ type: 'info', msg: '当前帧无标注数据' }); return results; }
runContainmentCheck(marks, results);
return results;
}
function runContainmentCheck(marks, results) {
const fulls = [], halves = [], hands = [];
for (let i = 0; i < marks.length; i++) {
const m = marks[i];
const kind = classifyMark(m);
if (!kind) continue;
const item = {
id: m.markId || m.boxId || ('index_' + i),
label: (m.pselect || (m.class && m.class.pname) || ''),
box: extractBox(m),
group: getGroupId(m)
};
if (kind === 'full') fulls.push(item);
else if (kind === 'half') halves.push(item);
else hands.push(item);
}
checkLayer(halves, fulls, '半身框', '行人框', results);
checkLayer(hands, halves, '手势框', '半身框', results);
}
function checkLayer(children, parents, childName, parentName, results) {
for (let i = 0; i < children.length; i++) {
const c = children[i];
const tag = childName + '(' + c.id + ')';
if (!c.box) { results.push({ type: 'warning', msg: `[无法校验] ${tag} 没有取到坐标` }); continue; }
// 分组校验:本平台靠 groupId 归组,没有 groupId 即未归组
if (c.group === null) {
results.push({ type: 'warning', msg: `[未归组] ${tag} 没有 groupId,应与对应${parentName}归组` });
}
if (!parents.length) {
results.push({ type: 'warning', msg: `[缺框] ${tag} 没有对应的${parentName},无法校验范围` });
continue;
}
// 已归组则只在同组父框里找;未归组则按几何位置兜底
let pool = parents;
if (c.group !== null) {
const same = parents.filter(function (p) { return p.group === c.group; });
if (same.length) pool = same;
else results.push({ type: 'warning', msg: `[未归组] ${tag} 的 groupId(${c.group}) 与任何${parentName}都不一致,未归到同一对象` });
}
const parent = pickParent(c.box, pool);
if (!parent) {
results.push({ type: 'warning', msg: `[越界] ${tag} 不在任何${parentName}范围内` });
continue;
}
const over = getOverflow(parent.box, c.box);
if (over.length) {
results.push({ type: 'warning', msg: `[越界] ${tag} 超出 ${parentName}(${parent.id}):${over.join('、')}` });
}
}
}
function autoRunCheck() {
if (!cachedData) return;
const results = validateMarks(cachedData);
renderFrameView(results, cachedData);
updatePackageView();
}
// ============================================================
// 分组问题(用于阻止整题提交)
// ============================================================
// 只有“分组不对”才阻止提交:未归组(没有 groupId,或 groupId 与父框不一致)。
// 越界 / 缺框 / 无法校验 属于范围与数据问题,不阻止提交。
function isGroupProblemMsg(msg) {
return msg.indexOf('未归组') !== -1;
}
// 一帧里的分组问题
function frameGroupProblems(markData, markStatus) {
const results = validateMarks({ mark_status: markStatus, markData: markData || {} });
return results
.filter(function (r) { return r.type === 'warning' && isGroupProblemMsg(r.msg); })
.map(function (r) { return r.msg; });
}
// 整个题包有分组问题的帧
function pkgGroupProblems() {
return pkgEntries().filter(function (e) { return (e.bad || 0) > 0; });
}
function manualCheck() {
if (!cachedData) {
const slot = document.getElementById('sz-frame-slot');
if (slot) slot.innerHTML = '暂无标注数据,请先切换到一帧
';
return;
}
const btn = document.getElementById('sz-btn-check');
if (btn) { btn.textContent = '校验中...'; btn.disabled = true; }
setTimeout(function () {
const results = validateMarks(cachedData);
renderFrameView(results, cachedData);
if (btn) { btn.textContent = '校验当前帧'; btn.disabled = false; }
}, 80);
}
// ============================================================
// 样式
// ============================================================
function injectStyles() {
if (document.getElementById('sz-check-styles')) return;
const style = document.createElement('style');
style.id = 'sz-check-styles';
style.textContent = `
#sz-check-panel { position: fixed; top: 80px; left: 320px; width: 372px; max-height: 78vh;
background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%); color: #e0e0e0;
border: 1px solid rgba(233,69,96,0.3); border-radius: 12px; z-index: 999999;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 13px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.05); overflow: hidden; }
#sz-check-header { background: linear-gradient(135deg, #0f3460 0%, #1a1a40 100%);
padding: 10px 14px; display: flex; justify-content: space-between; align-items: center;
cursor: move; user-select: none; border-bottom: 1px solid rgba(233,69,96,0.2); }
#sz-check-header:active { cursor: grabbing; }
.sz-title { font-weight: bold; font-size: 14px; color: #00d2ff; display: flex; align-items: center; gap: 6px; }
.sz-title-dot { width: 8px; height: 8px; background: #00d2ff; border-radius: 50%; box-shadow: 0 0 8px #00d2ff; animation: sz-pulse 2s ease-in-out infinite; }
@keyframes sz-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
.sz-btn-check { background: linear-gradient(135deg, #00d2ff 0%, #0288d1 100%); color: #fff; border: none;
padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: bold; }
.sz-btn-check:hover { transform: translateY(-1px); }
.sz-btn-check:disabled { opacity: .6; cursor: not-allowed; }
.sz-btn-toggle { background: rgba(255,255,255,0.08); color: #aaa; border: 1px solid rgba(255,255,255,0.1);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center; margin-left: 6px; }
.sz-btn-toggle:hover { background: rgba(255,255,255,0.15); color: #fff; }
#sz-check-content { padding: 12px 14px; max-height: calc(78vh - 50px); overflow-y: auto; }
#sz-check-content::-webkit-scrollbar { width: 6px; }
#sz-check-content::-webkit-scrollbar-thumb { background: rgba(233,69,96,0.3); border-radius: 3px; }
.sz-pkg { margin-bottom: 10px; padding: 10px 12px; background: linear-gradient(135deg, rgba(0,210,255,.10), rgba(15,52,96,.5)); border: 1px solid rgba(0,210,255,.35); border-radius: 8px; }
.sz-pkg-title { color: #00d2ff; font-weight: bold; font-size: 12px; letter-spacing: 1px; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; }
.sz-pkg-main { display: flex; align-items: baseline; gap: 8px; }
.sz-pkg-num { color: #00d2ff; font-size: 30px; font-weight: bold; line-height: 1.1; text-shadow: 0 0 12px rgba(0,210,255,.5); }
.sz-pkg-unit { color: #8892b0; font-size: 12px; }
.sz-pkg-sub { color: #8892b0; font-size: 11px; margin-top: 4px; line-height: 1.6; }
.sz-scan-status { color: #7fddc4; font-size: 11px; margin-top: 6px; word-break: break-all; line-height: 1.5; }
.sz-alert { margin-top: 7px; padding: 6px 8px; border-radius: 5px; font-size: 11px; line-height: 1.5; }
.sz-alert-err { background: rgba(233,69,96,.12); border: 1px solid rgba(233,69,96,.35); color: #ff9db0; }
.sz-alert-warn { background: rgba(255,165,0,.10); border: 1px solid rgba(255,165,0,.30); color: #ffc76b; }
.sz-alert-limit { background: rgba(233,69,96,.22); border: 1px solid rgba(233,69,96,.9); color: #ffd0da; font-weight: bold; font-size: 12px; }
.sz-stats { margin-bottom: 10px; padding: 10px 12px; background: rgba(15,52,96,.4); border-radius: 8px; border: 1px solid rgba(255,255,255,.05); }
.sz-stats-title { color: #e94560; font-weight: bold; margin-bottom: 6px; font-size: 12px; letter-spacing: 1px; display: flex; justify-content: space-between; align-items: center; }
.sz-stats-body { color: #8892b0; font-size: 12px; line-height: 1.9; }
.sz-halfline { margin-top: 8px; padding: 7px 10px; background: rgba(0,210,255,.08); border: 1px solid rgba(0,210,255,.25); border-radius: 6px; display: flex; align-items: baseline; gap: 8px; }
.sz-halfline .lbl { color: #8892b0; font-size: 12px; }
.sz-halfline .num { color: #00d2ff; font-size: 20px; font-weight: bold; line-height: 1; }
.sz-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: bold; }
.sz-badge-ok { background: rgba(78,204,163,.15); color: #4ecca3; border: 1px solid rgba(78,204,163,.3); }
.sz-badge-warn { background: rgba(255,165,0,.15); color: #ffa500; border: 1px solid rgba(255,165,0,.3); }
.sz-badge-err { background: rgba(233,69,96,.15); color: #e94560; border: 1px solid rgba(233,69,96,.3); }
.sz-pass { color: #4ecca3; text-align: center; padding: 20px 0; font-size: 15px; font-weight: bold; }
.sz-warn-header { color: #e94560; font-weight: bold; margin: 10px 0 6px; font-size: 13px; }
.sz-warn-item { background: rgba(233,69,96,.08); border-left: 3px solid #e94560; padding: 8px 10px; margin-bottom: 5px; border-radius: 0 6px 6px 0; font-size: 13px; line-height: 1.6; color: #ccc; }
.sz-warn-item:hover { background: rgba(233,69,96,.15); }
.sz-info { color: #8892b0; padding: 8px 0; text-align: center; }
.sz-loading { color: #8892b0; text-align: center; padding: 24px 0; }
@keyframes sz-shake { 0%,100%{transform:translateX(0)} 10%,30%,50%,70%,90%{transform:translateX(-5px)} 20%,40%,60%,80%{transform:translateX(5px)} }
.sz-shake { animation: sz-shake .5s ease-in-out; }
#sz-block-mask { position: fixed; inset: 0; background: rgba(8,12,24,.72); z-index: 1000000; display: none; align-items: center; justify-content: center; }
#sz-block-mask.open { display: flex; }
#sz-block-box { width: 460px; max-width: 92vw; max-height: 76vh; display: flex; flex-direction: column;
background: linear-gradient(145deg, #241a1e 0%, #16213e 100%); color: #e0e0e0;
border: 1px solid rgba(233,69,96,.75); border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,.7);
font-family: "Microsoft YaHei","PingFang SC",sans-serif; overflow: hidden; }
#sz-block-head { padding: 12px 16px; background: rgba(233,69,96,.16); border-bottom: 1px solid rgba(233,69,96,.4);
color: #ff8fa3; font-weight: bold; font-size: 15px; }
#sz-block-body { padding: 14px 16px; font-size: 13px; line-height: 1.7; color: #cfd8e8; overflow-y: auto; }
#sz-block-body b { color: #ff9db0; }
#sz-block-detail { margin-top: 10px; padding: 8px 10px; background: #0b1020; border: 1px solid rgba(255,255,255,.1);
border-radius: 5px; font-family: Consolas,monospace; font-size: 11px; color: #9db8d6; white-space: pre-wrap; max-height: 200px; overflow: auto; }
#sz-block-foot { padding: 10px 16px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid rgba(255,255,255,.08); }
#sz-block-foot button { border: none; border-radius: 6px; padding: 7px 16px; font-size: 13px; font-weight: bold; cursor: pointer; }
#sz-block-close { background: rgba(255,255,255,.1); color: #ccc; border: 1px solid rgba(255,255,255,.18); }
#sz-block-jump { background: linear-gradient(135deg,#00d2ff,#0288d1); color: #fff; }
.sz-blocked { outline: 2px solid #e94560 !important; outline-offset: 1px; box-shadow: 0 0 10px rgba(233,69,96,.8) !important; }
`;
document.head.appendChild(style);
}
// ============================================================
// 面板
// ============================================================
function createPanel() {
if (panel && document.body.contains(panel)) return panel;
injectStyles();
panel = document.createElement('div');
panel.id = 'sz-check-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
document.getElementById('sz-btn-check').addEventListener('click', manualCheck);
document.getElementById('sz-btn-toggle').addEventListener('click', togglePanel);
makeDraggable();
panelReady = true;
renderPackageBlock();
if (cachedData) autoRunCheck();
return panel;
}
function makeDraggable() {
const header = document.getElementById('sz-check-header');
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
header.addEventListener('mousedown', function (e) {
if (e.target.tagName === 'BUTTON') return;
dragging = true; sx = e.clientX; sy = e.clientY;
const r = panel.getBoundingClientRect(); sl = r.left; st = r.top;
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
let nl = sl + (e.clientX - sx), nt = st + (e.clientY - sy);
nl = Math.max(-260, Math.min(nl, window.innerWidth - 80));
nt = Math.max(0, Math.min(nt, window.innerHeight - 50));
panel.style.left = nl + 'px'; panel.style.top = nt + 'px'; panel.style.right = 'auto';
});
document.addEventListener('mouseup', function () { dragging = false; });
}
function togglePanel() {
const content = document.getElementById('sz-check-content');
const btn = document.getElementById('sz-btn-toggle');
if (content.style.display === 'none') { content.style.display = ''; btn.innerHTML = '−'; }
else { content.style.display = 'none'; btn.innerHTML = '+'; }
}
function renderPackageBlock() {
const slot = document.getElementById('sz-pkg-slot');
if (!slot) return;
if (document.getElementById('sz-pkg-num')) { updatePackageView(); return; }
slot.innerHTML = `
`;
updatePackageView();
}
function updatePackageView(statusText) {
const numEl = document.getElementById('sz-pkg-num');
if (!numEl) return;
const knownTotal = pkgStats.totalFrames || getTotalFrames();
const total = pkgHalfTotal();
const limit = toNum(settings.halfLimit);
const overLimit = isHalfOverLimit(total);
numEl.textContent = String(total);
numEl.style.color = overLimit ? '#ff5c7a' : '#00d2ff';
numEl.style.textShadow = overLimit ? '0 0 12px rgba(233,69,96,.7)' : '0 0 12px rgba(0,210,255,.5)';
const boxEl = document.getElementById('sz-pkg');
if (boxEl) boxEl.style.borderColor = overLimit ? 'rgba(233,69,96,.8)' : 'rgba(0,210,255,.35)';
const fEl = document.getElementById('sz-pkg-frames');
if (fEl) fEl.textContent = knownTotal ? (knownTotal + ' 帧') : '帧数未知';
const pEl = document.getElementById('sz-pkg-progress');
if (pEl) {
pEl.textContent = knownTotal
? ('已统计 ' + pkgFrameCount() + ' / ' + knownTotal + ' 帧')
: ('已统计 ' + pkgFrameCount() + ' 帧');
}
if (statusText !== undefined) {
const sEl = document.getElementById('sz-scan-status');
if (sEl) sEl.textContent = statusText;
}
const alerts = document.getElementById('sz-pkg-alerts');
if (alerts) {
let html = '';
if (overLimit) {
html += `⛔ 半身框总数超限:${total} 个 > 上限 ${limit} 个
`;
}
const bad = pkgNoMarkViolations();
if (bad.length) html += `⚠ 无需标注却有标注的帧(${bad.length}帧):${bad.join('、')}
`;
const miss = pkgMissingFrames();
if (miss.length) html += `⚠ 正常标注却无标注的帧(${miss.length}帧):${miss.join('、')}
`;
alerts.innerHTML = html;
}
// 超限时面板震动提醒
if (overLimit && panel && panelReady) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
}
function renderFrameView(results, respData) {
const slot = document.getElementById('sz-frame-slot');
if (!slot) return;
const markStatus = respData.mark_status;
const marks = (respData.markData && respData.markData.marks) || [];
const ind = readFrameIndicator();
const warnings = results.filter(function (r) { return r.type === 'warning'; });
const infos = results.filter(function (r) { return r.type === 'info'; });
const hasProblem = warnings.length > 0;
let statusText, statusClass, border, titleColor;
if (markStatus === 1) { statusText = '无需标注'; statusClass = 'sz-badge-warn'; border = 'rgba(255,165,0,.3)'; titleColor = '#ffa500'; }
else if (hasProblem) { statusText = '正常标注'; statusClass = 'sz-badge-err'; border = 'rgba(233,69,96,.3)'; titleColor = '#e94560'; }
else { statusText = '正常标注'; statusClass = 'sz-badge-ok'; border = 'rgba(78,204,163,.3)'; titleColor = '#4ecca3'; }
const fullCount = countClass(marks, 'full');
const halfCount = countClass(marks, 'half');
const handCount = countClass(marks, 'hand');
const unknown = marks.filter(function (m) { return !classifyMark(m); });
let html = `
当前帧统计
${ind ? '第 ' + ind.cur + ' / ' + ind.tot + ' 帧' : '帧号未知'}
状态: ${statusText}
标注总数: ${marks.length} | 行人框: ${fullCount} | 手势框: ${handCount}
本帧半身框${halfCount}个
`;
if (unknown.length) {
const names = unknown.map(function (m) { return (m.pselect || (m.class && m.class.pname) || '?'); });
html += `未识别类别 ${unknown.length} 个:${names.join('、')}(可在设置里加类名)
`;
}
for (let i = 0; i < infos.length; i++) html += `${infos[i].msg}
`;
if (warnings.length === 0 && infos.length === 0) {
html += `✓ 校验通过,无问题
`;
} else if (warnings.length > 0) {
html += ``;
for (let i = 0; i < warnings.length; i++) html += `${warnings[i].msg}
`;
}
slot.innerHTML = html;
if (warnings.length > 0) {
panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake');
}
}
function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) {} }
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) settings = Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw));
} catch (e) {}
}
// ============================================================
// 初始化
// ============================================================
function init() {
loadSettings();
hookXHR();
hookFetch();
document.addEventListener('click', onSubmitClickCapture, true);
const start = function () {
if (document.body) {
createPanel();
loadStats();
updatePackageView();
startFrameWatcher();
if (cachedData) { autoRunCheck(); maybeAutoBatch(); }
} else setTimeout(start, 200);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else setTimeout(start, 300);
}
init();
})();