// ==UserScript== // @name 手势归组校验工具 - 分组检查 // @namespace http://113.207.49.90/ // @version 2.6.0 // @description 全题包实时校验:归组规则=每组「1行人框+1半身框(+可选1手势框)」;框尺寸小于20×40px报错;列出全题包有问题的帧并可点击跳转;自动统计半身框总数并在超限时报错;题包仍有问题时阻止“整题通过”,且面板不可关闭 // @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, // 已废弃(越界校验已按需求移除),仅保留以兼容旧配置 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; // 题包存在问题时阻止整题通过 const BLOCK_ON_HALF_LIMIT = true; // 半身框总数超限也视为阻塞问题 const BLOCK_NET_SUBMIT = true; // 同时拦截“整题通过”的底层网络请求(防绕过) const LOCK_PANEL = true; // 题包有阻塞级问题时,面板不可关闭/隐藏 const AUTO_RESCAN_MS = 0; // >0 时定时重新扫描整个题包(毫秒),0=关闭 const REFRESH_CUR_MS = 2500; // 实时刷新当前帧校验的间隔(毫秒) const CHECK_PKG_ON_LOAD = true; // 打开页面即对整个题包做一次校验 // 框尺寸下限(像素):宽 < 20 或 高 < 40 即报错 const MIN_BOX_W = 20; const MIN_BOX_H = 40; // 每个对象/组的分框规则:1 个行人框 + 1 个半身框 + (可选的 1 个手势框) const GROUP_RULE_TEXT = '1 个行人框 + 1 个半身框(手势框可选,最多 1 个)'; // “整题通过”可能的接口地址特征(用于底层拦截,防止绕过点击拦截) const SUBMIT_URL_PATTERNS = [ /pass[_-]?all/i, /passall/i, /all[_-]?pass/i, /batch[_-]?(pass|accept|audit)/i, /task[_-]?pass/i, /mark[_-]?pass/i, /reject[_-]?sub[_-]?all/i ]; // ============================================================ // 状态 // ============================================================ 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; // 正在做提交前校验 let pendingBtn = null; // 被拦下的那个“整题通过”按钮 // 捕获到的所有接口响应:{ 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 (guardSubmitRequest(self._szUrl)) { try { self.abort(); } catch (e) {} return; } 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; const reqUrl = (typeof args[0] === 'string') ? args[0] : (args[0] && args[0].url); if (guardSubmitRequest(reqUrl)) { return Promise.reject(new Error('题包仍存在问题,已阻止整题通过')); } 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(); // 标注保存类请求成功后,延迟刷新当前帧,做到校验实时更新 if (url && isSaveUrl(url)) scheduleRefresh(700); 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; } // 归组标识:本平台用 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 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 img = { w: md.width || md.imgWidth || md.imageWidth || currentImage.w, h: md.height || md.imgHeight || md.imageHeight || currentImage.h }; const probs = frameProblems(md, data.mark_status, img); 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; const prev = pkgStats.frames[key]; 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, block: probs.some(function (p) { return p.block !== false; }) ? 1 : 0, probs: probs.slice(0, 6).map(function (p) { return String(p.msg).slice(0, 160); }), badMsg: probs.length ? probs.slice(0, 3).map(function (p) { return p.msg; }).join(';').slice(0, 300) : '', at: Date.now(), tid: (taskId !== undefined && taskId !== null) ? String(taskId) : (prev && prev.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; } // ============================================================ // 实时刷新当前帧:标注保存后 / 定时轮询,重新拉当前帧数据并校验 // ============================================================ let refreshTimer = null; function isSaveUrl(url) { if (!url) return false; return /(save|update|submit)[-_]?(mark|marks|bbox|box|label)/i.test(url) || /(mark|marks|bbox|box|label)[-_]?(save|update|submit)/i.test(url) || /mark[-_]?save/i.test(url); } // 保存类请求完成后,稍等片刻再刷新,避免拿到旧数据 function scheduleRefresh(delay) { if (refreshTimer) clearTimeout(refreshTimer); refreshTimer = setTimeout(function () { refreshTimer = null; refreshCurrentFrame(); }, delay || 700); } function frameTaskIdFor(no) { const domList = readFrameListFromDom(); if (domList && domList.list.length) { const hit = domList.list.filter(function (e) { return e.order === no - 1 || e.order === no; }); if (hit.length) return hit[0].id; if (domList.list[no - 1]) return domList.list[no - 1].id; } const rec = pkgStats.frames['n:' + no]; if (rec && rec.tid) return rec.tid; if (cachedFrameNo === no && cachedData && cachedData.markData) { const md = cachedData.markData; if (md.task_id || md.taskId) return md.task_id || md.taskId; } return null; } // 重新拉取当前帧的标注数据并刷新校验面板 async function refreshCurrentFrame(force) { if (scanning || autoBatchRunning || autoLoadBusy) return false; if (!lastRequest) return false; const ind = readFrameIndicator(); const no = ind ? ind.cur : cachedFrameNo; if (no === null || no === undefined) return false; if (!force && !cachedData) return false; const taskId = frameTaskIdFor(no); if (taskId === null || taskId === undefined) return false; autoLoadBusy = true; try { const data = await requestMarkData(taskId); setCachedData(data, no); recordFrame(data, no, 'refresh', taskId); if (panelReady) { updatePackageView(); autoRunCheck(); } return true; } catch (e) { return false; } finally { autoLoadBusy = false; } } // ============================================================ // 题包任务列表 → 逐帧请求 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 isSubmitTrigger(el) { if (!el || !el.closest) return false; if (el.closest('#admin_pass_all')) return true; if (el.closest('#reject-sub-all')) return true; if (el.closest('[id*="pass_all"]') || el.closest('[id*="pass-all"]')) return true; return false; } function isSubmitUrl(url) { const s = String(url || ''); for (let i = 0; i < SUBMIT_URL_PATTERNS.length; i++) { if (SUBMIT_URL_PATTERNS[i].test(s)) return true; } return false; } // 捕获阶段拦截点击。入口按钮和确认按钮都拦,避免从确认弹窗绕过。 function onSubmitClickCapture(e) { if (!SUBMIT_BLOCK) return; const t = e.target; if (!t || !t.closest) return; if (panel && panel.contains(t)) return; if (!isSubmitTrigger(t)) return; if (allowPassThrough) { allowPassThrough = false; pendingPass = false; return; } e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (blocking) return; pendingPass = true; blocking = true; // 记住被点的那个按钮:放行时重新点它,避免靠 id 再查一次查不到而吞掉点击 pendingBtn = t.closest('#admin_pass_all') || t.closest('#reject-sub-all') || t.closest('[id*="pass_all"]') || t.closest('[id*="pass-all"]') || t; updatePackageView('正在校验整个题包…'); Promise.resolve() .then(function () { return runScanOnce(); }) .then(function (scanOk) { blocking = false; // 只要还有任何问题证据就阻止提交(即使本次扫描不完整) const bad = pkgBlockingEntries(); const overLimit = halfLimitBlocking(); if (bad.length || overLimit) { if (scanOk === false) { renderSubmitBlock(bad, overLimit, '(注意:本次题包校验未完整完成,以下问题来自已获取的数据)'); } else { renderSubmitBlock(bad, overLimit, ''); } // 直接跳转到第一处有问题的帧,方便马上修正 try { awaitJumpToBadFrame(); } catch (e) {} return; } // 没有任何问题证据:扫描不完整时放行并提示,否则正常放行 if (scanOk === false) { updatePackageView('未能完整校验题包(帧列表或接口异常),未发现问题,已放行提交'); } pendingPass = false; passThrough(); }) .catch(function () { blocking = false; pendingPass = false; updatePackageView('提交前校验出错,已放行'); passThrough(); }); } // 底层拦截:即使绕过点击,也不让“整题通过”的请求发出去 function guardSubmitRequest(url) { if (!SUBMIT_BLOCK || !BLOCK_NET_SUBMIT) return false; if (!isSubmitUrl(url)) return false; if (!hasBlockingProblems()) return false; const bad = pkgBlockingEntries(); renderSubmitBlock(bad, halfLimitBlocking(), '(已从网络请求层拦截,请先修正问题帧)'); try { awaitJumpToBadFrame(); } catch (e) {} return true; } // 放行:重新触发一次“整题通过” function passThrough() { let btn = (pendingBtn && document.body && document.body.contains(pendingBtn)) ? pendingBtn : null; if (!btn) btn = document.querySelector('.toolBarEditorRight #admin_pass_all') || document.getElementById('admin_pass_all'); if (!btn) btn = pendingBtn; pendingBtn = null; if (!btn) return; allowPassThrough = true; try { btn.classList.remove('sz-blocked'); } catch (e) {} btn.click(); } // 触发跳转(不阻塞提交拦截流程,也不抛出) function awaitJumpToBadFrame() { Promise.resolve() .then(function () { return jumpToBadFrame(); }) .then(function (ok) { if (ok) setScanStatus('⛔ 提交被阻止:已跳转到第一处有问题的帧'); }) .catch(function () {}); } // 提交被拦时的提示 function renderSubmitBlock(bad, overLimit, 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 limitText = overLimit ? (',半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit)) : ''; // 面板内提示 const alerts = document.getElementById('sz-pkg-alerts'); if (alerts) { alerts.innerHTML = '
    ⛔ 题包仍存在问题,已阻止整题通过:' + (nums.length ? (nums.length + ' 帧(' + shown + more + ')') : '') + limitText + '
    ' + alerts.innerHTML; } setScanStatus('⛔ 提交被阻止:' + (blockingSummary() || '题包存在问题')); // 弹窗提示 showSubmitBlockDialog(bad, overLimit, 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; } function escHtml(s) { return String(s === null || s === undefined ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); } // 问题帧列表 HTML(面板与弹窗共用):每一项都可点击跳转 function probListHtml(entries, limit) { if (!entries || !entries.length) return '
    暂无问题帧
    '; const max = limit || 150; let html = ''; for (let i = 0; i < entries.length && i < max; i++) { const e = entries[i]; const no = (e.no === null || e.no === undefined) ? '?' : e.no; const msgs = (e.probs && e.probs.length) ? e.probs.slice() : (e.badMsg ? [e.badMsg] : ['存在问题']); const tip = msgs.join('\n'); const cls = e.block ? 'sz-prob-item sz-prob-block' : 'sz-prob-item'; html += '
    ' + '第 ' + escHtml(no) + ' 帧' + '' + (e.block ? '⛔ ' : '⚠ ') + escHtml(msgs[0]).slice(0, 70) + (msgs.length > 1 ? (' …共 ' + msgs.length + ' 条') : '') + '' + '
    '; } if (entries.length > max) html += '
    …还有 ' + (entries.length - max) + ' 帧
    '; return html; } // 提交被拦时的弹窗 function showSubmitBlockDialog(entries, overLimit, note) { if (!panel) return; let mask = document.getElementById('sz-block-mask'); if (!mask || !panel.contains(mask)) { if (mask && mask.parentNode) mask.parentNode.removeChild(mask); mask = document.createElement('div'); mask.id = 'sz-block-mask'; mask.innerHTML = `
    ⛔ 无法整题通过
    当前题包仍存在问题,请先修正后再提交:
    `; 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(); }); const dl = document.getElementById('sz-block-detail'); if (dl) dl.addEventListener('click', function (e) { const it = (e.target && e.target.closest) ? e.target.closest('[data-no]') : null; if (!it) return; mask.classList.remove('open'); jumpByAttrs(it); }); mask.addEventListener('click', function (e) { if (e.target === mask) mask.classList.remove('open'); }); } const sum = document.getElementById('sz-block-sum'); if (sum) { const parts = []; if (entries && entries.length) parts.push('问题帧共 ' + entries.length + ' 帧(点击可跳转)'); if (overLimit) parts.push('半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit) + ''); sum.innerHTML = parts.join(' | ') || '题包存在问题'; } const d = document.getElementById('sz-block-detail'); if (d) { d.innerHTML = probListHtml(entries || [], 40); d.style.maxHeight = '210px'; d.style.overflow = 'auto'; } 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; } // 跳到指定问题帧(优先用记录下来的 task_id,其次按帧号在帧列表里定位) async function jumpToEntry(entry) { if (!entry) return false; if (entry.tid && await jumpToTaskId(entry.tid)) return true; const no = entry.no; let list = null; const domList = readFrameListFromDom(); if (domList && domList.list.length) list = domList.list; else { const tl = await fetchFrameList(); if (tl && tl.list.length) list = tl.list; } if (!list) return false; let hit = null; for (let i = 0; i < list.length; i++) { if (list[i].order === no - 1 || list[i].order === no) { hit = list[i]; break; } } if (!hit && no >= 1 && no <= list.length) hit = list[no - 1]; if (!hit) return false; return await jumpToTaskId(hit.id); } // 面板/弹窗里点击问题帧条目时跳转 function jumpByAttrs(el) { if (!el) return; jumpToEntry({ tid: el.getAttribute('data-tid') || null, no: toNum(el.getAttribute('data-no')), key: el.getAttribute('data-key') || null }); } // 跳到第一处有问题的帧(优先跳不是当前帧的那个) async function jumpToBadFrame() { const blockBad = pkgBlockingEntries(); const list = blockBad.length ? blockBad : pkgProblemEntries(); if (!list.length) return false; const cur = cachedFrameNo; let target = list[0]; for (let i = 0; i < list.length; i++) { if (cur === null || cur === undefined || list[i].no !== cur) { target = list[i]; break; } } return await jumpToEntry(target); } 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, block: rec.block || 0, probs: Array.isArray(rec.probs) ? rec.probs : [], 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 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, imgOverride) { const results = []; const markStatus = respData.mark_status; const md = respData.markData || {}; const marks = (md.marks) || []; const hasMarks = marks.length > 0; const img = imgOverride || { w: md.width || currentImage.w, h: md.height || currentImage.h }; if (markStatus === 1 && !hasMarks) { results.push({ type: 'info', msg: '无需标注,跳过检查' }); return results; } if (markStatus === 1 && hasMarks) { results.push({ type: 'warning', block: true, msg: `[状态异常] 本帧点了"无需标注",但里面标注了 ${marks.length} 个框,可能误操作` }); } if (markStatus === 0 && !hasMarks) { results.push({ type: 'warning', block: true, msg: '[可能漏标] 标注状态为正常标注,但没有任何标注数据' }); return results; } if (!hasMarks) { results.push({ type: 'info', msg: '当前帧无标注数据' }); return results; } checkGroups(marks, results); checkBoxSizes(marks, img, results); return results; } const KIND_NAME = { full: '行人框', half: '半身框', hand: '手势框' }; const KIND_KEY = { full: 'fulls', half: 'halves', hand: 'hands' }; const KIND_ORDER = ['full', 'half', 'hand']; // 把一帧里的框按类别收集,并带上归组信息 function collectMarkItems(marks) { const out = { fulls: [], halves: [], hands: [] }; for (let i = 0; i < marks.length; i++) { const m = marks[i]; const kind = classifyMark(m); if (!kind) continue; out[KIND_KEY[kind]].push({ kind: kind, id: m.markId || m.boxId || ('index_' + i), label: (m.pselect || (m.class && m.class.pname) || ''), box: extractBox(m), group: getGroupId(m) }); } return out; } function markTag(it) { return KIND_NAME[it.kind] + '(' + it.id + ')'; } function markList(arr) { return arr.map(markTag).join('、'); } // 归组规则:每个组必须是「1 行人框 + 1 半身框(+ 可选 1 手势框)」。 // 同一对象的多余框、缺框、以及没有归组(groupId 为空/0)都算问题。 function checkGroups(marks, results) { const c = collectMarkItems(marks); // 1) 未归组的框 KIND_ORDER.forEach(function (k) { const arr = c[KIND_KEY[k]].filter(function (it) { return it.group === null; }); if (!arr.length) return; results.push({ type: 'warning', block: true, msg: '[未归组] ' + arr.length + ' 个' + KIND_NAME[k] + '没有归组:' + markList(arr) + ';同一对象应归为「' + GROUP_RULE_TEXT + '」' }); }); // 2) 按 groupId 分组校验组成 const groups = {}; const gids = []; KIND_ORDER.forEach(function (k) { c[KIND_KEY[k]].forEach(function (it) { if (it.group === null) return; if (!groups[it.group]) { groups[it.group] = { full: [], half: [], hand: [] }; gids.push(it.group); } groups[it.group][k].push(it); }); }); gids.forEach(function (gid) { const g = groups[gid]; if (g.full.length === 1 && g.half.length === 1 && g.hand.length <= 1) return; const detail = []; if (g.full.length) detail.push('行人框 ' + g.full.length + ' 个(' + markList(g.full) + ')'); if (g.half.length) detail.push('半身框 ' + g.half.length + ' 个(' + markList(g.half) + ')'); if (g.hand.length) detail.push('手势框 ' + g.hand.length + ' 个(' + markList(g.hand) + ')'); results.push({ type: 'warning', block: true, msg: '[归组错误] 组 ' + gid + ' 应为「' + GROUP_RULE_TEXT + '」,实际:' + (detail.join('、') || '无') }); }); } // 框尺寸:归一化坐标按原图尺寸换算成像素 function boxPixelSize(box, img) { if (!box) return null; const w = Math.abs(box.x2 - box.x1); const h = Math.abs(box.y2 - box.y1); if (!isNormalized(box)) return { w: w, h: h }; const iw = (img && img.w) || currentImage.w || 0; const ih = (img && img.h) || currentImage.h || 0; if (!iw || !ih) return null; return { w: w * iw, h: h * ih }; } // 框尺寸校验:小于 20×40px 报错 function checkBoxSizes(marks, img, results) { const c = collectMarkItems(marks); const all = c.fulls.concat(c.halves, c.hands); for (let i = 0; i < all.length; i++) { const it = all[i]; const tag = markTag(it); if (!it.box) { results.push({ type: 'warning', block: true, msg: '[无法校验] ' + tag + ' 没有取到坐标' }); continue; } const s = boxPixelSize(it.box, img); if (!s) { results.push({ type: 'warning', block: false, msg: '[无法校验尺寸] ' + tag + ' 坐标为归一化值且拿不到原图尺寸' }); continue; } if (s.w < MIN_BOX_W || s.h < MIN_BOX_H) { results.push({ type: 'warning', block: true, msg: '[框过小] ' + tag + ' 尺寸 ' + s.w.toFixed(1) + '×' + s.h.toFixed(1) + 'px,小于 ' + MIN_BOX_W + '×' + MIN_BOX_H + 'px' }); } } } function autoRunCheck() { if (!cachedData) return; const results = validateMarks(cachedData); renderFrameView(results, cachedData); updatePackageView(); } // ============================================================ // 全题包问题帧(面板展示 / 点击跳转 / 阻止整题提交) // ============================================================ // 一帧里的全部校验问题(warning 级),每项带 block 标记(是否阻塞提交) function frameProblems(markData, markStatus, img) { const results = validateMarks({ mark_status: markStatus, markData: markData || {} }, img); return results.filter(function (r) { return r.type === 'warning'; }); } // 整个题包里有问题的帧(bad>0 即至少有一条 warning) function pkgProblemEntries() { return pkgEntries().filter(function (e) { return (e.bad || 0) > 0; }); } // 阻塞提交的帧:存在 block 级问题 function pkgBlockingEntries() { return pkgEntries().filter(function (e) { return (e.block || 0) > 0; }); } // 半身框总数超限也算阻塞问题 function halfLimitBlocking() { return BLOCK_ON_HALF_LIMIT && isHalfOverLimit(pkgHalfTotal()); } // 当前题包是否还存在阻塞级问题(只要还有问题,就不允许提交) function hasBlockingProblems() { return pkgBlockingEntries().length > 0 || halfLimitBlocking(); } // 阻塞原因简述(用于弹窗/面板) function blockingSummary() { const frames = pkgBlockingEntries(); const parts = []; if (frames.length) parts.push(frames.length + ' 帧存在问题'); if (halfLimitBlocking()) parts.push('半身框总数 ' + pkgHalfTotal() + ' 超过上限 ' + toNum(settings.halfLimit)); return parts.join(','); } 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; } .sz-prob-block { margin-bottom: 10px; padding: 10px 12px; background: rgba(15,52,96,.4); border: 1px solid rgba(255,255,255,.08); border-radius: 8px; } .sz-prob-head { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; } .sz-prob-title { color: #e94560; font-weight: bold; font-size: 12px; letter-spacing: 1px; flex: 1; } .sz-btn-mini { background: rgba(255,255,255,.08); color: #9db8d6; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; font-family: inherit; } .sz-btn-mini:hover { background: rgba(0,210,255,.18); color: #fff; } .sz-prob-list { max-height: 190px; overflow-y: auto; } .sz-prob-list::-webkit-scrollbar { width: 6px; } .sz-prob-list::-webkit-scrollbar-thumb { background: rgba(233,69,96,.35); border-radius: 3px; } .sz-prob-item { position: relative; z-index: 2; pointer-events: auto; display: flex; gap: 6px; align-items: baseline; padding: 5px 7px; margin-bottom: 4px; border-radius: 5px; background: rgba(255,165,0,.08); border-left: 3px solid #ffa500; cursor: pointer; font-size: 11px; line-height: 1.45; } .sz-prob-item:hover { background: rgba(255,165,0,.2); } .sz-prob-item.sz-prob-block { background: rgba(233,69,96,.12); border-left-color: #e94560; } .sz-prob-item.sz-prob-block:hover { background: rgba(233,69,96,.26); } .sz-prob-no { color: #ffd0da; font-weight: bold; white-space: nowrap; } .sz-prob-msg { color: #b9c6da; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sz-prob-empty { color: #6b7899; font-size: 11px; padding: 4px 0; } .sz-prob-tip { color: #556080; font-size: 10px; margin-top: 5px; } .sz-btn-toggle.sz-locked { opacity: .6; cursor: not-allowed; color: #ff8fa3; border-color: rgba(233,69,96,.6); } #sz-check-panel.sz-panel-locked #sz-check-header { box-shadow: inset 0 0 0 1px rgba(233,69,96,.5); } `; 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; }); } let lockedRef = false; // 题包仍有问题时:锁住面板,禁止收起/关闭 function applyPanelLock() { if (!LOCK_PANEL || !panel || !panelReady) return; const locked = hasBlockingProblems(); lockedRef = locked; const btn = document.getElementById('sz-btn-toggle'); if (btn) { btn.disabled = false; if (locked) btn.classList.add('sz-locked'); else btn.classList.remove('sz-locked'); btn.title = locked ? '题包仍有问题,解决前无法关闭校验面板' : ''; if (locked) { const content = document.getElementById('sz-check-content'); if (content) content.style.display = ''; btn.innerHTML = '🔒'; } else if (btn.innerHTML === '🔒') { btn.innerHTML = '−'; } } if (locked) panel.classList.add('sz-panel-locked'); else panel.classList.remove('sz-panel-locked'); } function flashLockedTip() { setScanStatus('🔒 当前题包仍有问题(' + (blockingSummary() || '') + '),解决前无法关闭校验面板'); if (panel && panelReady) { panel.classList.remove('sz-shake'); void panel.offsetWidth; panel.classList.add('sz-shake'); } } function togglePanel() { if (LOCK_PANEL && hasBlockingProblems()) { flashLockedTip(); return; } 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 ensurePanelAlive() { if (!LOCK_PANEL) return; if (!document.body) return; if (!panel || !document.body.contains(panel)) { panel = null; panelReady = false; createPanel(); } if (panel) { if (panel.style.display === 'none') panel.style.display = ''; if (panel.style.visibility === 'hidden') panel.style.visibility = ''; if (panel.style.opacity === '0') panel.style.opacity = ''; if (hasBlockingProblems()) { const content = document.getElementById('sz-check-content'); if (content && content.style.display === 'none') content.style.display = ''; } } if (!document.getElementById('sz-check-styles')) injectStyles(); } function watchPanel() { if (!document.body || typeof MutationObserver === 'undefined') return; const mo = new MutationObserver(function () { if (!LOCK_PANEL) return; if (!document.getElementById('sz-check-styles')) { try { injectStyles(); } catch (e) {} } if (panel && !document.body.contains(panel)) ensurePanelAlive(); }); try { mo.observe(document.body, { childList: true, subtree: false, attributes: true, attributeFilter: ['style', 'class'] }); } catch (e) {} } function renderPackageBlock() { const slot = document.getElementById('sz-pkg-slot'); if (!slot) return; if (document.getElementById('sz-pkg-num')) { updatePackageView(); return; } slot.innerHTML = `
    全题包半身框总数 帧数未知
    0个
    已统计 0 帧
    全题包问题帧 0
    点击问题帧可跳转到该帧(实时更新)
    `; const listEl = document.getElementById('sz-prob-list'); if (listEl) { listEl.addEventListener('click', function (e) { const it = (e.target && e.target.closest) ? e.target.closest('[data-no]') : null; if (it) jumpByAttrs(it); }); } const rsBtn = document.getElementById('sz-btn-rescan'); if (rsBtn) rsBtn.addEventListener('click', function () { manualRescan(); }); updatePackageView(); } // 手动重新扫描整个题包 function manualRescan() { if (scanning) { setScanStatus('⏳ 正在扫描中,请稍候…'); return; } setScanStatus('⏳ 正在重新扫描全题包…'); Promise.resolve().then(function () { return runScanOnce(); }).catch(function () {}); } 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'); const probs = pkgProblemEntries(); const blockN = pkgBlockingEntries().length; if (alerts) { let html = ''; if (blockN || overLimit) { html += '
    ⛔ 当前题包仍有问题:' + (blockingSummary() || '') + ';已阻止整题通过,面板在问题解决前不可关闭
    '; } alerts.innerHTML = html; } // 全题包问题帧列表(实时更新,点击可跳转) const cntEl = document.getElementById('sz-prob-count'); if (cntEl) { cntEl.textContent = String(probs.length); cntEl.className = 'sz-badge ' + (blockN ? 'sz-badge-err' : (probs.length ? 'sz-badge-warn' : 'sz-badge-ok')); } const listEl = document.getElementById('sz-prob-list'); if (listEl) { const html2 = probListHtml(probs, 200); if (listEl._szHtml !== html2) { listEl.innerHTML = html2; listEl._szHtml = html2; } } // 还有阻塞问题时锁定面板 applyPanelLock(); // 超限时面板震动提醒 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 += `
    ⚠ 发现问题(${warnings.length}条)
    `; 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(); watchPanel(); setInterval(ensurePanelAlive, 1500); setInterval(function () { if (!scanning) refreshCurrentFrame(); }, REFRESH_CUR_MS > 0 ? REFRESH_CUR_MS : 2500); if (AUTO_RESCAN_MS > 0) setInterval(function () { if (!scanning) runScanOnce(); }, AUTO_RESCAN_MS); if (cachedData) { autoRunCheck(); maybeAutoBatch(); } else if (CHECK_PKG_ON_LOAD) maybeAutoBatch(); } else setTimeout(start, 200); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else setTimeout(start, 300); } init(); })();