// ==UserScript== // @name SZ脚本 // @namespace http://118.196.97.105:8080 // @version 1.9 // @description 仅校验人脸+5个五官关键点;适配正对人脸镜像逻辑:人脸右眼位于画面左侧、左眼在画面右侧 // @author CC // @match *:8080/* // @grant none // @run-at document-start // @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, EPS_EYE_RATIO: 0.02, FACEH_FALLBACK: 2.2, 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) : '缺失'; } 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; } 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; } function validateKeypointGeometry(kpMap, members, errs) { const eL = getPoint(kpMap['left_eye']); const eR = getPoint(kpMap['right_eye']); const nose = getPoint(kpMap['nose']); const mL = getPoint(kpMap['left_mouth']); const 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; } 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; var eyeDx = eL.x - eR.x; var mouthDx = mL.x - mR.x; // 【镜像修正】人脸自身右眼在画面左侧(X更小)、左眼在画面右侧(X更大) if (eyeDx < eps) { errs.push('关键点坐标异常:双眼颠倒;人脸右眼需要在画面左侧、左眼在画面右侧(右眼' + fmtP(eR) + '→左眼' + fmtP(eL) + ')'); } if (mouthDx < eps) { errs.push('关键点坐标异常:嘴角颠倒;人脸右嘴角需要在画面左侧、左嘴角在画面右侧(右嘴角' + fmtP(mR) + '→左嘴角' + fmtP(mL) + ')'); } // 双眼、嘴角左右朝向需要保持一致 const eyeRight = eyeDx > eps; const mouthRight = mouthDx > eps; if (eyeRight !== mouthRight) { errs.push('关键点坐标异常:双眼和嘴角左右朝向不一致(左眼' + fmtP(eL) + '→右眼' + fmtP(eR) + ',左嘴角' + fmtP(mL) + '→右嘴角' + fmtP(mR) + ')'); } const quad = [eR, eL, mL, mR]; const c01 = cross(quad[0], quad[1], quad[2]); const c12 = cross(quad[1], quad[2], quad[3]); const c23 = cross(quad[2], quad[3], quad[0]); const c30 = cross(quad[3], quad[0], quad[1]); const s0 = c01 >= 0 ? 1 : -1; const s1 = c12 >= 0 ? 1 : -1; const s2 = c23 >= 0 ? 1 : -1; const s3 = c30 >= 0 ? 1 : -1; if (s0 !== s1 || s1 !== s2 || s2 !== s3) { errs.push('关键点坐标异常:眼‑嘴四点形状错乱,五官左右颠倒(' + fmtP(eL) + ' ' + fmtP(eR) + ' ' + fmtP(mR) + ' ' + fmtP(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) + ')'); } if (!pointInQuad(nose, quad, faceH * GEOM_CFG.QUAD_TOL)) { errs.push('关键点坐标异常:鼻尖(' + fmtP(nose) + ')不在双眼‑嘴角四点框内部'); } var yTol = faceH * GEOM_CFG.NOSE_Y_TOL; var topY = Math.min(eL.y, eR.y) - yTol; var botY = Math.max(mL.y, mR.y) + yTol; if (nose.y < topY || nose.y > botY) { errs.push('关键点坐标异常:鼻尖y=' + nose.y.toFixed(1) + ' 超出眼嘴区间 [' + topY.toFixed(1) + ', ' + botY.toFixed(1) + ']'); } const eyeMidY = (eL.y + eR.y) / 2; if (nose.y < eyeMidY - yTol) { errs.push('关键点坐标异常:鼻尖位置高于双眼,人脸上下坐标颠倒'); } } // ---- 悬浮窗 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, 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; if (statusConflict) { html += '