// ==UserScript==
// @name 感知2D同源
// @namespace http://118.196.97.105:8080
// @version 1.4
// @description 检测标注物归组规范:每组1行人+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;
}
// ---- 悬浮窗 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) Pedestrian 校验
const pedCount = counts['Pedestrian'] || 0;
if (pedCount === 0) {
errs.push('缺少行人框 (Pedestrian)');
} else if (pedCount > 1) {
errs.push('行人框超过 1 个 (' + pedCount + ')');
}
// 2) face 校验
const faceCount = counts['face'] || 0;
if (faceCount === 0) {
errs.push('缺少人脸框 (face)');
} else if (faceCount > 1) {
errs.push('人脸框超过 1 个 (' + faceCount + ')');
}
// 3) face_visible 校验(不应超过1个)
const fvCount = counts['face_visible'] || 0;
if (fvCount > 1) {
errs.push('face_visible 超过 1 个 (' + fvCount + ')');
}
// 4) 关键点校验
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; });
}
// 5) 检查边界越界(仅 rect 类型,关键点除外)
members.forEach(function (m) {
var boundErr = checkBounds(m, imgWidth, imgHeight);
if (boundErr) errs.push(boundErr);
});
// 6) 检查多余的非标准标注物
const standardClasses = ['Pedestrian', '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.4');
})();