// ==UserScript== // @name 手势归组校验工具 - 分组检查 // @namespace http://113.207.49.90/ // @version 2.3.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; // ============================================================ // 状态 // ============================================================ 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; // 捕获到的所有接口响应:{ 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'); 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; } 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) { 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 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' }; 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'); 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; for (let i = 0; i < total; i++) { 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'); ok++; } catch (e) { fail++; } updatePackageView('整个题包统计中 ' + (i + 1) + ' / ' + total + (fail ? '(失败 ' + fail + ')' : '')); if (i % 5 === 0) { saveStats(); await sleep(30); } } saveStats(); scanning = false; updatePackageView('整个题包统计完成:成功 ' + ok + ' 帧' + (fail ? ',失败 ' + fail + ' 帧' : '') + ',半身框共 ' + pkgHalfTotal() + ' 个'); return true; } 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 batchScanFrames(); }) .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 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 }); } 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(); } 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; } `; 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 = `
    全题包半身框总数 帧数未知
    0
    已统计 0 帧
    `; 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 += `
    ⚠ 发现问题(${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(); 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(); })();