// ==UserScript== // @name 2D车位线标注验收 // @namespace http://tampermonkey.net/ // @version 1.4.1 // @description 拦截标注数据验收,根据业务规则精准校验,支持点击高亮定位、窗口拖动与参数自定义 // @author CC // @match http://113.207.49.80:8080/* // @grant none // ==/UserScript== (function() { 'use strict'; // 默认自定义配置参数 const CONFIG = { lineMinWidth: 8, // 车位线最小宽度(像素) lineMaxWidth: 13, // 车位线最大宽度(像素) pointRectMaxDist: 100, // 角点距离车位框中心最大允许距离(像素) matchThreshold: 20 // 角点与车位框顶点匹配阈值(像素) }; // 保存最后一次拦截到的数据,方便修改配置后重新校验 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. 数据预处理与分组 const groups = {}; const pointList = []; let mainIndex = 1; marks.forEach((mark) => { const gid = mark.groupId; if (gid !== undefined) { if (!groups[gid]) { groups[gid] = { mainIndex: mainIndex++, points: [], rect: null }; } if (mark.type === 'point') { mark._mainIndex = groups[gid].mainIndex; groups[gid].points.push(mark); pointList.push(mark); } else if (mark.type === 'rect' && mark.pselect === '车位') { mark._mainIndex = groups[gid].mainIndex; groups[gid].rect = mark; } } else { mark._displayLabel = `#${mainIndex}`; mark._mainIndex = mainIndex++; } }); // 为所有元素补充显示标签 for (const gid in groups) { const group = groups[gid]; let pointIdx = 1; group.points.forEach(p => { p._displayLabel = `#${group.mainIndex}-${pointIdx++}`; }); if (group.rect) { group.rect._displayLabel = `#${group.mainIndex}-${pointIdx}`; } } // 辅助函数:根据 points_label 查找角点 const findPoint = (points, label) => points.find(p => p.attrs && p.attrs.points_label && p.attrs.points_label[0] === label); // 2. 执行业务规则校验 // 规则1:完整库位判定——4个角点与车位框(category=lots)必须同时存在 for (const gid in groups) { const group = groups[gid]; const hasRect = !!group.rect; const pointCount = group.points.length; if (hasRect) { const category = group.rect.attrs && group.rect.attrs.category && group.rect.attrs.category[0]; if (category === 'lots' && pointCount !== 4) { errors.push({ type: '结构错误', label: `#${group.mainIndex}`, name: '车位组', markId: group.rect.markId, msg: `完整库位应含4个角点,实际包含 ${pointCount} 个` }); } } else { if (pointCount === 4) { errors.push({ type: '结构错误', label: `#${group.mainIndex}`, name: '车位组', markId: group.points[0].markId, msg: '已有4个角点,但缺少车位整体框' }); } } if (pointCount === 4 && hasRect) { const category = group.rect.attrs && group.rect.attrs.category && group.rect.attrs.category[0]; if (category !== 'lots') { errors.push({ type: '结构错误', label: `#${group.mainIndex}`, name: '车位组', markId: group.rect.markId, msg: `已有4个角点,车位框类别应为完整库位(lots),当前为 ${category || '空'}` }); } } } // 规则2:车位线宽度校验 marks.forEach(mark => { if (mark.type === 'rect' && mark.pselect === '车位线') { const p = mark.point || {}; const w = Math.abs((p.right || 0) - (p.left || 0)); const h = Math.abs((p.bottom || 0) - (p.top || 0)); const minSide = Math.min(w, h); if (minSide < CONFIG.lineMinWidth || minSide > CONFIG.lineMaxWidth) { errors.push({ type: '属性错误', label: mark._displayLabel, name: '车位线', markId: mark.markId, msg: `车位线宽度应为${CONFIG.lineMinWidth}-${CONFIG.lineMaxWidth}px,当前为 ${minSide.toFixed(1)}px` }); } } }); // 辅助函数:计算旋转矩形的四个顶点 const calcRotatedBoxCorners = (rectData) => { const p = rectData || {}; const x = parseFloat(p.x || 0); const y = parseFloat(p.y || 0); const w = parseFloat(p.width || 0); const h = parseFloat(p.height || 0); const rotate = parseFloat(p.rotate || 0); const cx = x + w / 2; const cy = y + h / 2; const rad = rotate * Math.PI / 180; const cosR = Math.cos(rad); const sinR = Math.sin(rad); const halfW = w / 2; const halfH = h / 2; const corners = [ [-halfW, -halfH], [halfW, -halfH], [halfW, halfH], [-halfW, halfH] ]; return corners.map(([dx, dy]) => [ Math.round((cx + dx * cosR + dy * sinR) * 100) / 100, Math.round((cy + dx * sinR - dy * cosR) * 100) / 100 ]); }; // 辅助函数:查找角点在框顶点列表中的匹配索引 const findMatchIndex = (targetPt, cornerList, threshold) => { for (let i = 0; i < cornerList.length; i++) { if (Math.abs(targetPt[0] - cornerList[i][0]) <= threshold && Math.abs(targetPt[1] - cornerList[i][1]) <= threshold) { return i; } } return -1; }; // 规则3:角点与车位框顶点匹配校验 for (const gid in groups) { const group = groups[gid]; if (group.rect) { const rect = group.rect; // 从 point(left/top/right/bottom) 和 rotate 计算旋转后顶点 const rectPt = rect.point || {}; const rectData = { x: parseFloat(rectPt.left || 0), y: parseFloat(rectPt.top || 0), width: parseFloat(rectPt.right || 0) - parseFloat(rectPt.left || 0), height: parseFloat(rectPt.bottom || 0) - parseFloat(rectPt.top || 0), rotate: parseFloat(rect.rotate || 0) }; const boxCorners = calcRotatedBoxCorners(rectData); const p1 = findPoint(group.points, 'p1'); const p2 = findPoint(group.points, 'p2'); [p1, p2].forEach(p => { if (!p) return; const pLabel = p.attrs && p.attrs.points_label && p.attrs.points_label[0]; const pt = p.point || {}; const px = parseFloat(pt.x || 0); const py = parseFloat(pt.y || 0); const matchIdx = findMatchIndex([px, py], boxCorners, CONFIG.matchThreshold); if (matchIdx === -1) { errors.push({ type: '逻辑错误', label: p._displayLabel, name: `入口角点${pLabel}`, markId: p.markId, msg: `入口角点(${px.toFixed(1)},${py.toFixed(1)})未匹配到车位框顶点,匹配阈值${CONFIG.matchThreshold}px` }); } }); } } // 规则4:入口点(p1或p2)不可见时,车位类别必须为不可辨别 for (const gid in groups) { const group = groups[gid]; if (group.rect) { const p1 = findPoint(group.points, 'p1'); const p2 = findPoint(group.points, 'p2'); const p1Vis = p1 && p1.attrs && p1.attrs.visibility && p1.attrs.visibility[0]; const p2Vis = p2 && p2.attrs && p2.attrs.visibility && p2.attrs.visibility[0]; if (p1Vis === 'invisible_p' || p2Vis === 'invisible_p') { const lotsType = group.rect.attrs && group.rect.attrs.lots_type && group.rect.attrs.lots_type[0]; const isUnknown = lotsType && (lotsType === 'vertical_unknown' || lotsType === 'oblique_unknown' || lotsType === 'horizontal_unknown'); if (!isUnknown) { errors.push({ type: '逻辑错误', label: `#${group.mainIndex}`, name: '车位框', markId: group.rect.markId, msg: `入口点(p1或p2)不可见时,车位类别必须为不可辨别,当前为 ${lotsType || '空'}` }); } } } } // 规则5:点的可见性与点类别对应关系 pointList.forEach(p => { const pLabel = p.attrs && p.attrs.points_label && p.attrs.points_label[0]; const vis = p.attrs && p.attrs.visibility && p.attrs.visibility[0]; const pType = p.attrs && p.attrs.points_type && p.attrs.points_type[0]; if (vis === 'invisible_p' && pType !== 'others_p') { errors.push({ type: '属性错误', label: p._displayLabel, name: `角点${pLabel}`, markId: p.markId, msg: `不可见时,点类别必须为其他(others_p),当前为 ${pType || '空'}` }); } if (vis === 'visible_p' && pType === 'others_p') { errors.push({ type: '属性错误', label: p._displayLabel, name: `角点${pLabel}`, markId: p.markId, msg: '可见时,点类别不能为其他(others_p)' }); } }); // 规则6:地砖线颜色校验 marks.forEach(mark => { if (mark.type === 'rect' && mark.pselect === '车位线') { const category = mark.attrs && mark.attrs.category && mark.attrs.category[0]; const color = mark.attrs && mark.attrs.color && mark.attrs.color[0]; if (category === 'floortileline' && color !== 'qita') { errors.push({ type: '属性错误', label: mark._displayLabel, name: '车位线', markId: mark.markId, msg: `地砖线颜色必须为其它(qita),当前为 ${color || '空'}` }); } } }); // 规则7:里侧点(p3或p4)可见提示 pointList.forEach(p => { const pLabel = p.attrs && p.attrs.points_label && p.attrs.points_label[0]; const vis = p.attrs && p.attrs.visibility && p.attrs.visibility[0]; if ((pLabel === 'p3' || pLabel === 'p4') && vis === 'visible_p') { errors.push({ type: '提示信息', label: p._displayLabel, name: `角点${pLabel}`, markId: p.markId, msg: `里侧点属性为可见,请确认是否正常` }); } }); // 规则8:局部库位中所有角点必须可见 for (const gid in groups) { const group = groups[gid]; if (group.rect) { const category = group.rect.attrs && group.rect.attrs.category && group.rect.attrs.category[0]; if (category === 'part_lots') { group.points.forEach(p => { const vis = p.attrs && p.attrs.visibility && p.attrs.visibility[0]; if (vis === 'invisible_p') { const pLabel = p.attrs && p.attrs.points_label && p.attrs.points_label[0]; errors.push({ type: '属性错误', label: p._displayLabel, name: `角点${pLabel || ''}`, markId: p.markId, msg: '局部库位中角点必须为可见,不可见的点无需标出' }); } }); } } } // 规则9:同一车位归组中p1/p2/p3/p4标签不能重复 for (const gid in groups) { const group = groups[gid]; const labelCount = {}; group.points.forEach(p => { const pLabel = p.attrs && p.attrs.points_label && p.attrs.points_label[0]; if (pLabel) { if (!labelCount[pLabel]) { labelCount[pLabel] = []; } labelCount[pLabel].push(p); } }); for (const label in labelCount) { if (labelCount[label].length > 1) { labelCount[label].forEach(p => { errors.push({ type: '结构错误', label: p._displayLabel, name: `角点${label}`, markId: p.markId, msg: `同一车位组中角点标签 ${label} 重复出现 ${labelCount[label].length} 次,每个标签只能出现一次` }); }); } } } // 规则10:p1点y坐标必须比p2点高,p4点y坐标必须比p3点高 for (const gid in groups) { const group = groups[gid]; const p1 = findPoint(group.points, 'p1'); const p2 = findPoint(group.points, 'p2'); const p3 = findPoint(group.points, 'p3'); const p4 = findPoint(group.points, 'p4'); if (p1 && p2) { const p1y = parseFloat((p1.point && p1.point.y) || 0); const p2y = parseFloat((p2.point && p2.point.y) || 0); if (p1y >= p2y) { errors.push({ type: '逻辑错误', label: p1._displayLabel, name: '角点p1', markId: p1.markId, msg: `p1点y坐标(${p1y.toFixed(1)})应比p2点y坐标(${p2y.toFixed(1)})高` }); } } if (p3 && p4) { const p3y = parseFloat((p3.point && p3.point.y) || 0); const p4y = parseFloat((p4.point && p4.point.y) || 0); if (p4y >= p3y) { errors.push({ type: '逻辑错误', label: p4._displayLabel, name: '角点p4', markId: p4.markId, msg: `p4点y坐标(${p4y.toFixed(1)})应比p3点y坐标(${p3y.toFixed(1)})高` }); } } } renderReport(errors); } function initPanel() { if (document.getElementById('val-panel')) return; const panel = document.createElement('div'); panel.id = 'val-panel'; panel.innerHTML = `

🛑 验收报告

线宽: -px
距离阈值: px
匹配阈值: px
`; 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: 10px; margin-bottom: 5px; } .config-item input { width: 40px; 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 minW = parseInt(document.getElementById('cfg-min').value); const maxW = parseInt(document.getElementById('cfg-max').value); const dist = parseInt(document.getElementById('cfg-dist').value); if (!isNaN(minW)) CONFIG.lineMinWidth = minW; if (!isNaN(maxW)) CONFIG.lineMaxWidth = maxW; if (!isNaN(dist)) CONFIG.pointRectMaxDist = dist; const match = parseInt(document.getElementById('cfg-match').value); if (!isNaN(match)) CONFIG.matchThreshold = match; 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(`无法定位到侧栏项,请检查页面是否已加载完全。`); } } })();