// ==UserScript== // @name 感知2D同源 // @namespace http://118.196.97.105:8080 // @version 1.9.1 // @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 = { 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 }; } // 读取标注物属性值(兼容多种存储形式: // m.attrs 为对象(值可能是数组,如 {truncation:["clear"]})/ m.attrs 为 [{pname,value}] 数组 / // m.class.attrs 同上 / m.class[key]) function getMarkAttr(m, key) { if (!m) return undefined; function unwrap(v) { if (Array.isArray(v)) return v.length ? v[0] : undefined; return v; } var candidates = []; if (m.attrs != null) candidates.push(m.attrs); if (m.class && m.class.attrs != null) candidates.push(m.class.attrs); if (m.class && m.class[key] != null && typeof m.class[key] !== 'object') return m.class[key]; for (var ci = 0; ci < candidates.length; ci++) { var a = candidates[ci]; if (Array.isArray(a)) { for (var i = 0; i < a.length; i++) { var it = a[i]; if (it && (it.pname === key || it.name === key)) { if (it.value !== undefined) return it.value; if (it.pname !== undefined) return it.pname; } } } else if (a && typeof a === 'object') { if (key in a) return unwrap(a[key]); } } return undefined; } function fmtP(p) { return p ? p.x.toFixed(1) + ',' + p.y.toFixed(1) : '缺失'; } // 口罩与关键点可见性一致性校验: // 人脸框 face_mask=1(有口罩)时,鼻尖及左右嘴角必须设为不可见(vis=1)。 function validateMaskMouthConsistency(faceMark, kpMap, errs) { if (!faceMark) return; var mask = getMarkAttr(faceMark, 'face_mask'); if (mask !== '1') return; // 仅“有口罩”时要求鼻尖/嘴角不可见 ['nose', 'left_mouth', 'right_mouth'].forEach(function (k) { var mk = kpMap[k]; if (!mk) return; var vis = getMarkAttr(mk, 'vis'); if (vis !== '1') { errs.push('属性不一致:人脸框为“有口罩”,但' + (KEYPOINT_LABELS[k] || k) + '未标记为不可见(应设为不可见)'); } }); } // 关键点坐标几何校验(极宽松,不依赖头部姿态): // 保留 坐标缺失 / 左右眼几乎重合 两项原有判定, // 新增:左眼x 必须大于 右眼x;左嘴角x 必须大于 右嘴角x; // 当人脸框截断程度为“无”(truncation=clear)时,5 个关键点坐标必须落在人脸框内。 // 已去除方向/姿态类判定(R1 嘴角x方向 / R2 嘴角与眼同侧 / R3 四关键点自交 / // 鼻尖四边形 / R4 鼻尖y区间):头部偏转、侧脸、抬头低头等姿态下不可靠,易误报。 function validateKeypointGeometry(kpMap, members, errs, faceMark) { 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; } // 左眼 x 必须大于 右眼 x if (!(eL.x > eR.x)) { errs.push('关键点坐标异常:左眼应在右眼右侧(左眼x=' + eL.x.toFixed(1) + ',右眼x=' + eR.x.toFixed(1) + ')'); } // 左嘴角 x 必须大于 右嘴角 x if (!(mL.x > mR.x)) { errs.push('关键点坐标异常:左嘴角应在右嘴角右侧(左嘴角x=' + mL.x.toFixed(1) + ',右嘴角x=' + mR.x.toFixed(1) + ')'); } // 人脸框截断程度为“无”时,关键点坐标不允许在框外 if (faceMark) { var trunc = getMarkAttr(faceMark, 'truncation'); if (trunc === 'clear') { var fb = faceMark.point; if (fb && fb.left !== undefined) { var L = parseFloat(fb.left), T = parseFloat(fb.top), Rt = parseFloat(fb.right), B = parseFloat(fb.bottom); KEYPOINT_ORDER.forEach(function (k) { var p = getPoint(kpMap[k]); if (!p) return; if (p.x < L || p.x > Rt || p.y < T || p.y > B) { errs.push('关键点坐标异常:' + (KEYPOINT_LABELS[k] || k) + ' 坐标(' + fmtP(p) + ')在人脸框外(截断程度为无,关键点必须在框内)'); } }); } } } } // ---- 悬浮窗 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 += '