// ==UserScript==
// @name 2D关键点标注验收
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description 拦截标注数据验收,针对抓手关键点标注做规则校验,支持点击高亮定位、窗口拖动与参数自定义
// @author CC
// @match http://121.37.95.217:8080/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 默认自定义配置参数
const CONFIG = {
maxKeypointIndex: 18, // 关键点最大编号 0~18
maxAllowGroupNum: 2 // 最大允许分组数量
};
// 保存最后一次拦截到的数据,方便修改配置后重新校验
let lastMarkData = null;
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._hookData = { method, url };
return originalOpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.send = function(...args) {
if (this._hookData && this._hookData.url.includes('/api/task/get-mark-data')) {
this.addEventListener('load', function() {
if (this.status === 200) {
try {
const responseData = JSON.parse(this.responseText);
if (responseData.code === 0 && responseData.data && responseData.data.markData) {
lastMarkData = responseData.data.markData;
validateAndReport(lastMarkData);
}
} catch (e) {
console.error('[验收助手] 解析JSON失败:', e);
}
}
});
}
return originalSend.apply(this, args);
}
function validateAndReport(markData) {
const marks = markData.marks || [];
const errors = [];
// 1. 为所有标注元素补充显示标签
let mainIndex = 1;
marks.forEach(mark => {
mark._displayLabel = `#${mainIndex}`;
mark._mainIndex = mainIndex++;
});
// 2. 筛选关键点类型标注
const gripPointMarks = marks.filter(m => m.type === 'point' && m.pselect === '关键点');
const allGripGid = new Set();
const groupIndexMap = {}; // 记录每个分组下已出现的关键点编号
// 3. 逐点执行校验
gripPointMarks.forEach(pMark => {
const gid = pMark.groupId;
// 规则1:groupId 合法性校验
if (gid === undefined || gid === null || isNaN(Number(gid))) {
errors.push({
type: '结构错误',
label: pMark._displayLabel,
name: '关键点',
markId: pMark.markId,
msg: '关键点缺少合法groupId,请手动归组'
});
} else {
const numGid = Number(gid);
allGripGid.add(numGid);
// 预构建分组编号映射,用于后续重复校验
if (!groupIndexMap[numGid]) {
groupIndexMap[numGid] = new Set();
}
}
// 规则2:关键点编号存在性与范围校验
const attrs = pMark.attrs || {};
const pointIndexStr = Array.isArray(attrs.point) ? attrs.point[0] : null;
if (pointIndexStr == null || pointIndexStr === '') {
errors.push({
type: '属性错误',
label: pMark._displayLabel,
name: '关键点',
markId: pMark.markId,
msg: '关键点缺失attrs.point编号(0-' + CONFIG.maxKeypointIndex + ')'
});
} else {
const idxNum = parseInt(pointIndexStr, 10);
if (isNaN(idxNum) || idxNum < 0 || idxNum > CONFIG.maxKeypointIndex) {
errors.push({
type: '属性错误',
label: pMark._displayLabel,
name: '关键点',
markId: pMark.markId,
msg: `关键点编号非法:${pointIndexStr},必须在 0~${CONFIG.maxKeypointIndex} 范围内`
});
} else if (!isNaN(Number(gid))) {
// 规则3:同一分组内编号不能重复
const numGid = Number(gid);
if (groupIndexMap[numGid].has(idxNum)) {
errors.push({
type: '结构错误',
label: pMark._displayLabel,
name: '关键点',
markId: pMark.markId,
msg: `同一分组groupId=${numGid}内,关键点编号 ${idxNum} 重复`
});
}
groupIndexMap[numGid].add(idxNum);
}
}
// 规则4:点坐标可解析性校验
const pt = pMark.point || {};
const px = parseFloat(pt.x);
const py = parseFloat(pt.y);
if (isNaN(px) || isNaN(py)) {
errors.push({
type: '属性错误',
label: pMark._displayLabel,
name: '关键点',
markId: pMark.markId,
msg: `点坐标解析失败 x=${pt.x}, y=${pt.y}`
});
}
});
// 规则5:分组总数超限提示
const gripGroupCount = allGripGid.size;
if (gripGroupCount > CONFIG.maxAllowGroupNum) {
errors.push({
type: '提示信息',
label: '全局',
name: '分组统计',
markId: null,
msg: `当前图片关键点总分组数 ${gripGroupCount},超过允许最大值 ${CONFIG.maxAllowGroupNum},请确认是否正常`
});
}
renderReport(errors);
}
function initPanel() {
if (document.getElementById('val-panel')) return;
const panel = document.createElement('div');
panel.id = 'val-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
if (!document.getElementById('validate-css')) {
const style = document.createElement('style');
style.id = 'validate-css';
style.textContent = `
#val-panel { position: fixed; top: 10px; right: 10px; width: 480px; background: rgba(17, 24, 39, 0.95); color: #fff; border-radius: 8px; z-index: 99999; font-family: sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,0.6); border: 1px solid #333; display: flex; flex-direction: column; max-height: 90vh; }
#val-header { padding: 12px 15px; background: #1f2937; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; }
#val-header h3 { margin: 0; font-size: 15px; color: #60a5fa; }
.val-btn { background: transparent; border: 1px solid #4b5563; color: #9ca3af; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px; }
.val-btn:hover { background: #374151; color: #fff; }
#val-config { padding: 10px 15px; background: #111827; border-bottom: 1px solid #333; font-size: 12px; }
.config-item { display: flex; align-items: center; color: #9ca3af; margin-right: 15px; margin-bottom: 5px; }
.config-item input { width: 50px; background: #374151; border: 1px solid #4b5563; color: #fff; text-align: center; border-radius: 3px; margin: 0 4px; padding: 2px 0; }
.config-apply { background: #2563eb; border: none; color: #fff; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.config-apply:hover { background: #1d4ed8; }
#val-stats { display: flex; padding: 10px 15px; background: #111827; border-bottom: 1px solid #333; font-size: 12px; }
.stat-item { margin-right: 15px; color: #9ca3af; }
.stat-num { font-weight: bold; margin-right: 4px; }
.stat-struct .stat-num { color: #f87171; }
.stat-attr .stat-num { color: #fbbf24; }
.stat-logic .stat-num { color: #a78bfa; }
.stat-tip .stat-num { color: #22d3ee; }
#val-body { padding: 10px 15px; overflow-y: auto; max-height: 60vh; }
.err-item { padding: 8px; margin-bottom: 6px; background: #1f2937; border-radius: 4px; font-size: 12px; border-left: 3px solid #ef4444; cursor: pointer; transition: 0.2s; display: flex; align-items: center; }
.err-item:hover { background: #374151; }
.err-item.attr-err { border-left-color: #f59e0b; }
.err-item.logic-err { border-left-color: #8b5cf6; }
.err-item.tip-err { border-left-color: #22d3ee; }
.err-item.tip-hidden { display: none; }
.err-type { font-weight: bold; margin-right: 6px; flex-shrink: 0; }
.struct-err .err-type { color: #f87171; }
.attr-err .err-type { color: #fbbf24; }
.logic-err .err-type { color: #a78bfa; }
.tip-err .err-type { color: #22d3ee; }
.err-name { color: #60a5fa; margin-right: 6px; flex-shrink: 0; }
.err-label { color: #34d399; font-weight: bold; margin-right: 8px; background: rgba(52,211,153,0.1); padding: 1px 4px; border-radius: 2px; flex-shrink: 0; }
.err-msg { color: #d1d5db; }
.val-success { text-align: center; color: #34d399; padding: 30px 0; font-size: 16px; font-weight: bold; }
`;
document.head.appendChild(style);
}
// 绑定拖拽
const header = panel.querySelector('#val-header');
let isDragging = false;
let startX, startY, initialLeft, initialTop;
header.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('val-btn')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
panel.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
panel.style.left = `${initialLeft + dx}px`;
panel.style.top = `${initialTop + dy}px`;
panel.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
isDragging = false;
panel.style.transition = '';
});
// 绑定按钮事件
panel.querySelector('#btn-fold').addEventListener('click', () => {
const body = document.getElementById('val-body');
body.style.display = body.style.display === 'none' ? 'block' : 'none';
});
panel.querySelector('#btn-close').addEventListener('click', () => {
panel.style.display = 'none';
});
panel.querySelector('#cfg-apply-btn').addEventListener('click', () => {
const maxIdx = parseInt(document.getElementById('cfg-max-index').value);
const maxGroup = parseInt(document.getElementById('cfg-max-group').value);
if (!isNaN(maxIdx)) CONFIG.maxKeypointIndex = maxIdx;
if (!isNaN(maxGroup)) CONFIG.maxAllowGroupNum = maxGroup;
if (lastMarkData) {
validateAndReport(lastMarkData);
} else {
alert('暂无标注数据,请先切换图片加载');
}
});
// 收起/展开提示信息
panel.querySelector('#btn-hide-tips').addEventListener('click', function() {
const tipItems = document.querySelectorAll('.tip-err');
const isHidden = this.classList.toggle('tips-hidden');
tipItems.forEach(item => {
item.classList.toggle('tip-hidden', isHidden);
});
this.textContent = isHidden ? '展开提示信息' : '收起提示信息';
});
}
function renderReport(errors) {
initPanel();
const statsDiv = document.getElementById('val-stats');
const bodyDiv = document.getElementById('val-body');
const structCount = errors.filter(e => e.type === '结构错误').length;
const attrCount = errors.filter(e => e.type === '属性错误').length;
const logicCount = errors.filter(e => e.type === '逻辑错误').length;
const tipCount = errors.filter(e => e.type === '提示信息').length;
statsDiv.innerHTML = `
结构错误: ${structCount}
属性错误: ${attrCount}
逻辑错误: ${logicCount}
提示信息: ${tipCount}
`;
let bodyHtml = '';
if (errors.length === 0) {
bodyHtml = `✅ 验收通过,未发现异常!
`;
} else {
errors.forEach(err => {
const cls = err.type === '属性错误' ? 'attr-err' : (err.type === '逻辑错误' ? 'logic-err' : (err.type === '提示信息' ? 'tip-err' : 'struct-err'));
bodyHtml += `
[${err.type}]
${err.label}
${err.name}
${err.msg}
`;
});
}
bodyDiv.innerHTML = bodyHtml;
// 恢复提示信息显示状态
const hideBtn = document.getElementById('btn-hide-tips');
if (hideBtn && hideBtn.classList.contains('tips-hidden')) {
document.querySelectorAll('.tip-err').forEach(item => item.classList.add('tip-hidden'));
}
bodyDiv.querySelectorAll('.err-item').forEach(item => {
item.addEventListener('click', () => {
const markId = item.getAttribute('data-markid');
locateInSidebar(markId);
});
});
document.getElementById('val-panel').style.display = 'flex';
}
function locateInSidebar(markId) {
if (!markId) return;
const targetItem = document.querySelector(`li[data-markid="${markId}"]`);
if (targetItem) {
targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetItem.style.transition = 'background 0.3s';
targetItem.style.background = 'rgba(239, 68, 68, 0.5)';
setTimeout(() => { targetItem.style.background = ''; }, 3000);
} else {
alert(`无法定位到侧栏项,请检查页面是否已加载完全。`);
}
}
})();