// ==UserScript== // @name 感知2D同源 // @namespace http://118.196.97.105:8080 // @version 1.6 // @description 检测标注物归组规范:每组1人脸+5关键点(左眼/右眼/鼻尖/左嘴角/右嘴角),并校验关键点坐标几何合理性 // @author CC // @match http://*:8080/* // @grant none // @run-at document-end // @license CC // ==/UserScript== (function () { 'use strict'; const API_PATH = '/api/task/get-mark-data'; const KEYPOINT_ORDER = ['left_eye', 'right_eye', 'nose', 'left_mouth', 'right_mouth']; const KEYPOINT_LABELS = { left_eye: '左眼中心', right_eye: '右眼中心', nose: '鼻尖', left_mouth: '左嘴角', right_mouth: '右嘴角' }; const PSELECT_MAP = { '左眼中⼼': 'left_eye', '左眼中心': 'left_eye', '右眼中⼼': 'right_eye', '右眼中心': 'right_eye', '⿐尖': 'nose', '鼻尖': 'nose', '左嘴⻆': 'left_mouth', '左嘴角': 'left_mouth', '右嘴⻆': 'right_mouth', '右嘴角': 'right_mouth' }; function normalizePname(m) { var pname = (m.class && m.class.pname) || ''; if (pname === 'righ_mouth') return 'right_mouth'; if (!pname || KEYPOINT_ORDER.indexOf(pname) === -1) { var mapped = PSELECT_MAP[m.pselect]; if (mapped) return mapped; } return pname; } // ---- 关键点坐标几何校验配置 ---- const GEOM_CFG = { QUAD_TOL: 0.10, // 鼻尖相对四关键点四边形外扩容差(×人脸框高) NOSE_Y_TOL: 0.15, // 鼻尖y超出眼/嘴角区间的容差(×人脸框高),吸收抬头低头 EPS_EYE_RATIO: 0.02, // 左右/上下判定容差(×眼距),吸收标注微小误差 FACEH_FALLBACK: 2.2, // 无face框时 人脸高 ≈ 眼距 × 该系数 MIN_EYE_DIST: 1 // 双眼距离小于该值视为坐标重合 }; function getPoint(m) { if (!m || !m.point) return null; var x = parseFloat(m.point.x); var y = parseFloat(m.point.y); if (isNaN(x) || isNaN(y)) return null; return { x: x, y: y }; } function fmtP(p) { return p ? p.x.toFixed(1) + ',' + p.y.toFixed(1) : '缺失'; } // 向量 (o->a) × (o->b) function cross(o, a, b) { return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); } function onSeg(p, a, b) { return p.x >= Math.min(a.x, b.x) - 1e-9 && p.x <= Math.max(a.x, b.x) + 1e-9 && p.y >= Math.min(a.y, b.y) - 1e-9 && p.y <= Math.max(a.y, b.y) + 1e-9; } function segmentsIntersect(a, b, c, d) { var d1 = cross(c, d, a); var d2 = cross(c, d, b); var d3 = cross(a, b, c); var d4 = cross(a, b, d); if (((d1 > 1e-9 && d2 < -1e-9) || (d1 < -1e-9 && d2 > 1e-9)) && ((d3 > 1e-9 && d4 < -1e-9) || (d3 < -1e-9 && d4 > 1e-9))) return true; if (Math.abs(d1) < 1e-9 && onSeg(a, c, d)) return true; if (Math.abs(d2) < 1e-9 && onSeg(b, c, d)) return true; if (Math.abs(d3) < 1e-9 && onSeg(c, a, b)) return true; if (Math.abs(d4) < 1e-9 && onSeg(d, a, b)) return true; return false; } // 点是否在凸四边形内(含向外交扩容差 tol) function pointInQuad(p, quad, tol) { var sign = cross(quad[0], quad[1], quad[2]) >= 0 ? 1 : -1; for (var i = 0; i < quad.length; i++) { var a = quad[i]; var b = quad[(i + 1) % quad.length]; var d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); if (d * sign < -tol) return false; } return true; } // 关键点坐标几何校验:R1 左右成对顺序 / R2 右嘴角与右眼同侧 / R3 鼻尖四边形内 / R4 鼻尖y区间 function validateKeypointGeometry(kpMap, members, errs) { var eL = getPoint(kpMap['left_eye']); var eR = getPoint(kpMap['right_eye']); var nose = getPoint(kpMap['nose']); var mL = getPoint(kpMap['left_mouth']); var mR = getPoint(kpMap['right_mouth']); if (!eL || !eR || !nose || !mL || !mR) { var missing = []; if (!eL) missing.push('左眼'); if (!eR) missing.push('右眼'); if (!nose) missing.push('鼻尖'); if (!mL) missing.push('左嘴角'); if (!mR) missing.push('右嘴角'); errs.push('关键点坐标异常:关键点坐标缺失(' + missing.join('、') + ')'); return; } var eyeDist = Math.sqrt(Math.pow(eR.x - eL.x, 2) + Math.pow(eR.y - eL.y, 2)); if (eyeDist < GEOM_CFG.MIN_EYE_DIST) { errs.push('关键点坐标异常:左右眼坐标几乎重合(' + fmtP(eL) + ' / ' + fmtP(eR) + ')'); return; } // 人脸框高:优先取组内 face 框高度,缺省回退为眼距的 FACEH_FALLBACK 倍 var faceH = 0; members.forEach(function (m) { if (faceH === 0 && normalizePname(m) === 'face' && m.type === 'rect' && m.point) { var bottom = parseFloat(m.point.bottom); var top = parseFloat(m.point.top); if (!isNaN(bottom) && !isNaN(top)) faceH = bottom - top; } }); if (!faceH || faceH <= 0) faceH = eyeDist * GEOM_CFG.FACEH_FALLBACK; var eps = eyeDist * GEOM_CFG.EPS_EYE_RATIO; // 头部偏转(roll)校正:以左眼为原点旋转坐标系,使双眼连线水平后再做左右/上下判定。 // 否则头部倾斜较大时,图像坐标中右嘴角x可能小于左眼x(右偏),左嘴角x可能大于右眼x(左偏),导致误判。 var cosA = (eR.x - eL.x) / eyeDist; var sinA = -(eR.y - eL.y) / eyeDist; function toHeadFrame(p) { var rx = p.x - eL.x; var ry = p.y - eL.y; return { x: eL.x + rx * cosA - ry * sinA, y: eL.y + rx * sinA + ry * cosA }; } var mRr = toHeadFrame(mR); var mLr = toHeadFrame(mL); var noseR = toHeadFrame(nose); // R1 左右成对顺序(校正后双眼连线水平,嘴角连线方向应与之一致) if (mRr.x - mLr.x < -eps) { errs.push('关键点坐标异常:消除头部偏转后左右嘴角x方向与左右眼不一致(左眼' + fmtP(eL) + '→右眼' + fmtP(eR) + ',左嘴角' + fmtP(mL) + '→右嘴角' + fmtP(mR) + ')'); } // R2 右嘴角与右眼必须位于左眼的同一侧(校正后判定,头部倾斜不再误报) if (mRr.x - eL.x < -eps) { errs.push('关键点坐标异常:消除头部偏转后右嘴角(' + fmtP(mR) + ')与右眼(' + fmtP(eR) + ')不在左眼(' + fmtP(eL) + ')的同一侧'); } // R3 鼻尖应在四关键点连线框内(先检测连线自交) var quad = [eL, eR, mR, mL]; var selfCross = segmentsIntersect(quad[0], quad[1], quad[2], quad[3]) || segmentsIntersect(quad[1], quad[2], quad[3], quad[0]); if (selfCross) { errs.push('关键点坐标异常:四关键点连线自交,坐标疑似错乱(' + fmtP(eL) + ' ' + fmtP(eR) + ' ' + fmtP(mR) + ' ' + fmtP(mL) + ')'); } else if (!pointInQuad(nose, quad, faceH * GEOM_CFG.QUAD_TOL)) { errs.push('关键点坐标异常:鼻尖(' + fmtP(nose) + ')不在四关键点连线框内部'); } // R4 鼻尖y应介于眼睛与嘴角之间(校正后双眼等高,含抬头低头容差) var yTol = faceH * GEOM_CFG.NOSE_Y_TOL; var topY = eL.y - yTol; var botY = Math.max(mLr.y, mRr.y) + yTol; if (noseR.y < topY || noseR.y > botY) { errs.push('关键点坐标异常:消除头部偏转后鼻尖y=' + noseR.y.toFixed(1) + ' 超出眼嘴区间 [' + topY.toFixed(1) + ', ' + botY.toFixed(1) + ']'); } } // ---- 悬浮窗 UI ---- let panelContainer = null; let panelBody = null; let panelContent = null; let isMinimized = false; let isDragging = false; let dragOffsetX = 0; let dragOffsetY = 0; let panelX = 20; let panelY = 60; function createPanel() { if (panelContainer) return; panelContainer = document.createElement('div'); panelContainer.id = '_gc_panel'; panelContainer.style.cssText = 'position:fixed;z-index:999999;width:380px;background:#ffffff;border:1px solid #d1d5db;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,0.15);font-family:"Microsoft YaHei","PingFang SC",Arial,sans-serif;font-size:13px;color:#1f2937;overflow:hidden;left:' + panelX + 'px;top:' + panelY + 'px;user-select:none;'; // 标题栏 const titleBar = document.createElement('div'); titleBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#f3f4f6;cursor:move;border-bottom:1px solid #d1d5db;'; titleBar.innerHTML = '感知2D同源'; titleBar.addEventListener('mousedown', startDrag); panelContainer.appendChild(titleBar); // 按钮组 const btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex;gap:6px;'; const minBtn = createBtn('—', '#e5e7eb', '#d1d5db', function (e) { e.stopPropagation(); toggleMinimize(); }); const closeBtn = createBtn('✕', '#fca5a5', '#f87171', function (e) { e.stopPropagation(); closePanel(); }); btnGroup.appendChild(minBtn); btnGroup.appendChild(closeBtn); titleBar.appendChild(btnGroup); // 内容区域 panelBody = document.createElement('div'); panelBody.style.cssText = 'max-height:500px;overflow-y:auto;transition:max-height 0.25s ease;'; panelContainer.appendChild(panelBody); panelContent = document.createElement('div'); panelContent.style.cssText = 'padding:12px;background:#ffffff;'; panelBody.appendChild(panelContent); document.body.appendChild(panelContainer); } function createBtn(text, color, hoverColor, onClick) { const btn = document.createElement('span'); btn.textContent = text; btn.style.cssText = 'width:26px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:pointer;font-size:14px;font-weight:bold;color:#374151;background:' + color + ';'; btn.addEventListener('mouseenter', function () { btn.style.background = hoverColor; }); btn.addEventListener('mouseleave', function () { btn.style.background = color; }); btn.addEventListener('click', onClick); return btn; } function toggleMinimize() { isMinimized = !isMinimized; if (isMinimized) { panelBody.style.maxHeight = '0'; panelBody.style.padding = '0'; } else { panelBody.style.maxHeight = '500px'; panelBody.style.padding = ''; } } function closePanel() { if (panelContainer) { panelContainer.remove(); panelContainer = null; } } function startDrag(e) { if (e.button !== 0) return; isDragging = true; const rect = panelContainer.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; panelContainer.style.cursor = 'grabbing'; panelContainer.style.transition = 'none'; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); e.preventDefault(); } function onDrag(e) { if (!isDragging || !panelContainer) return; panelX = Math.max(0, e.clientX - dragOffsetX); panelY = Math.max(0, e.clientY - dragOffsetY); panelContainer.style.left = panelX + 'px'; panelContainer.style.top = panelY + 'px'; } function stopDrag() { isDragging = false; if (panelContainer) { panelContainer.style.cursor = ''; } document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); } function renderPanel(result) { createPanel(); if (!panelContent) return; const { groups, lonelyMarks, summary, markStatus, marks } = result; let html = ''; // ---- 概要栏 ---- const totalGroups = Object.keys(groups).length; const issueGroups = Object.values(groups).filter(g => !g.pass).length; const lonelyCount = lonelyMarks.length; const statusConflict = markStatus === 1 && marks && marks.length > 0; const ok = issueGroups === 0 && lonelyCount === 0 && !statusConflict; // mark_status 冲突提示 if (statusConflict) { html += '
'; html += '🚫'; html += '
'; html += '
标注状态异常
'; html += 'mark_status=1(无需标注),但存在 ' + marks.length + ' 个标注数据'; html += '
'; } html += '
'; html += '' + (ok ? '✅' : '⚠️') + ''; html += '
'; html += '
' + (ok ? '所有归组正确' : '存在异常') + '
'; html += '' + totalGroups + ' 个组'; if (issueGroups > 0) html += ' · ' + issueGroups + ' 个组异常'; if (lonelyCount > 0) html += ' · ' + lonelyCount + ' 个孤立标注'; html += '
'; // ---- 孤立标注 ---- if (lonelyCount > 0) { html += '
'; html += '
⚠ 孤立标注(未归组)
'; lonelyMarks.forEach(m => { const label = m.class?.pname || m.pselect || '?'; html += '
'; html += '
'; html += '' + label + ''; html += 'markId: ' + m.markId + ''; html += '
'; if (m._boundErr) { html += '
• ' + m._boundErr + '
'; } html += '
'; }); html += '
'; } // ---- 各组详情 ---- const sortedGroupIds = Object.keys(groups).sort(function (a, b) { return Number(a) - Number(b); }); sortedGroupIds.forEach(function (gid) { const g = groups[gid]; const pass = g.pass; const errs = g.errors || []; html += '
'; html += '
'; html += '组 ' + gid + ' (' + g.count + ' 个标注)'; html += '' + (pass ? '✓ 正确' : errs.length + ' 个问题') + ''; html += '
'; if (!pass) { html += '
'; errs.forEach(function (err) { html += '
• ' + err + '
'; }); html += '
'; } // 组内成员列表 html += '
'; g.members.forEach(function (m) { const label = m.class?.pname || m.pselect || '?'; const idx = m._kpIdx !== undefined ? (' [' + (m._kpIdx + 1) + ']') : ''; html += '' + label + idx + ''; }); html += '
'; html += '
'; }); panelContent.innerHTML = html; // 滚动到顶部 panelBody.scrollTop = 0; } // ---- 核心分析逻辑 ---- let _lastMarkStatus = null; let _lastMarks = null; function analyzeMarks(marks, markStatus, imgWidth, imgHeight) { _lastMarkStatus = markStatus; _lastMarks = marks; if (!marks || marks.length === 0) return; // 统计 groupId -> marks const groupMap = {}; const lonelyMap = {}; marks.forEach(function (m) { let gid = m.groupId; if (gid === undefined || gid === null || gid === '' || gid === 0) { gid = '__lonely_' + m.markId; lonelyMap[gid] = true; } if (!groupMap[gid]) groupMap[gid] = []; groupMap[gid].push(m); }); // 孤立标注:groupId 只出现一次 或 groupId=0/null/undefined const lonelyMarks = []; Object.keys(groupMap).forEach(function (gid) { if (lonelyMap[gid] || groupMap[gid].length === 1) { groupMap[gid].forEach(function (m) { lonelyMarks.push(m); }); delete groupMap[gid]; } }); // 分析每个 group const groups = {}; Object.keys(groupMap).forEach(function (gid) { const members = groupMap[gid]; const g = { groupId: gid, count: members.length, members: members, errors: [], pass: true }; groups[gid] = validateGroup(g, imgWidth, imgHeight); }); // 对孤立标注也做越界检查 lonelyMarks.forEach(function (m) { var err = checkBounds(m, imgWidth, imgHeight); if (err) m._boundErr = err; }); const result = { groups: groups, lonelyMarks: lonelyMarks, summary: {}, markStatus: markStatus, marks: marks, imgWidth: imgWidth, imgHeight: imgHeight }; renderPanel(result); } function getPname(m) { return normalizePname(m); } function checkBounds(m, imgWidth, imgHeight) { var pname = getPname(m); // 关键点允许在图像外 if (KEYPOINT_ORDER.indexOf(pname) !== -1) return null; // 只对 rect/box 类型做检查 if (m.type !== 'rect') return null; var pt = m.point; if (!pt) return null; var issues = []; if (pt.left !== undefined) { if (pt.left < 0) issues.push('left=' + pt.left.toFixed(1)); if (pt.top < 0) issues.push('top=' + pt.top.toFixed(1)); // 也可以检查 right/bottom 是否超出宽高 if (imgWidth && pt.right > imgWidth) issues.push('right=' + pt.right.toFixed(1) + '>' + imgWidth); if (imgHeight && pt.bottom > imgHeight) issues.push('bottom=' + pt.bottom.toFixed(1) + '>' + imgHeight); } if (issues.length === 0) return null; return '标注超出图像边界: ' + issues.join(', '); } function validateGroup(g, imgWidth, imgHeight) { const members = g.members; const errs = g.errors; // 按类别统计 const counts = {}; const kpList = []; members.forEach(function (m) { const pname = getPname(m); counts[pname] = (counts[pname] || 0) + 1; if (KEYPOINT_ORDER.indexOf(pname) !== -1) { kpList.push({ mark: m, pname: pname, idx: KEYPOINT_ORDER.indexOf(pname) }); } }); // 1) face 校验 const faceCount = counts['face'] || 0; if (faceCount === 0) { errs.push('缺少人脸框 (face)'); } else if (faceCount > 1) { errs.push('人脸框超过 1 个 (' + faceCount + ')'); } // 2) face_visible 校验(不应超过1个) const fvCount = counts['face_visible'] || 0; if (fvCount > 1) { errs.push('face_visible 超过 1 个 (' + fvCount + ')'); } // 3) 关键点校验 if (kpList.length !== 5) { errs.push('关键点数量错误:期望 5 个,实际 ' + kpList.length + ' 个'); } else { // 检查顺序(按在 marks 数组中的原始顺序,不排序) var orderOk = true; var orderErrIdx = -1; for (var i = 0; i < 5; i++) { var expected = KEYPOINT_ORDER[i]; var actual = kpList[i].pname; if (actual !== expected) { orderOk = false; orderErrIdx = i; break; } } if (!orderOk) { var actualStr = kpList.map(function (k) { return KEYPOINT_LABELS[k.pname] || k.pname; }).join(' → '); var expectedStr = KEYPOINT_ORDER.map(function (k) { return KEYPOINT_LABELS[k]; }).join(' → '); errs.push('关键点顺序错误:期望 ' + expectedStr + ',实际 ' + actualStr); } // 标记顺序索引用于展示 kpList.forEach(function (k, i) { k.mark._kpIdx = i + 1; }); // 关键点坐标几何校验(R1~R4) const kpMap = {}; kpList.forEach(function (k) { kpMap[k.pname] = k.mark; }); validateKeypointGeometry(kpMap, members, errs); } // 4) 检查边界越界(仅 rect 类型,关键点除外) members.forEach(function (m) { var boundErr = checkBounds(m, imgWidth, imgHeight); if (boundErr) errs.push(boundErr); }); // 5) 检查多余的非标准标注物 const standardClasses = ['face', 'face_visible'].concat(KEYPOINT_ORDER); members.forEach(function (m) { const pname = getPname(m); if (standardClasses.indexOf(pname) === -1 && pname) { errs.push('多余标注物: ' + pname); } }); if (errs.length > 0) g.pass = false; return g; } // ---- 拦截 XHR ---- var origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url) { this._url = url; return origOpen.apply(this, arguments); }; var origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { if (this._url && typeof this._url === 'string' && this._url.indexOf(API_PATH) !== -1) { this.addEventListener('load', function () { try { var resp = JSON.parse(this.responseText); if (resp.code === 0 && resp.data) { var md = resp.data.markData; analyzeMarks(md && md.marks, resp.data.mark_status, md && md.width, md && md.height); } } catch (e) {} }); } return origSend.apply(this, arguments); }; // ---- 拦截 fetch ---- var origFetch = window.fetch; if (origFetch) { window.fetch = function (input, init) { var url = typeof input === 'string' ? input : (input instanceof Request ? input.url : ''); if (url.indexOf(API_PATH) !== -1) { return origFetch.apply(this, arguments).then(function (response) { var clone = response.clone(); clone.json().then(function (data) { if (data.code === 0 && data.data) { var md = data.data.markData; analyzeMarks(md && md.marks, data.data.mark_status, md && md.width, md && md.height); } }).catch(function () {}); return response; }); } return origFetch.apply(this, arguments); }; } console.log('[归组检查] 脚本已加载 v1.6'); })();