// ==UserScript== // @name 标注校验插件 // @namespace http://tampermonkey.net/ // @version 7.2 // @description 2D框遮挡校验(Canvas文字) + 3D框可见性校验 + 切帧自动检测 + 点击错误定位标注实例 // @match http://121.37.95.217:8080/pointcloud/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; const VIEW_NAMES = { 'front_fisheye': '前鱼眼', 'front_wide': '前广角', 'back_fisheye': '后鱼眼', 'left_fisheye': '左鱼眼', 'right_fisheye': '右鱼眼' }; // 反向视角映射:中文视角名 → camId const VIEW_NAME_TO_CAM = Object.fromEntries(Object.entries(VIEW_NAMES).map(([k, v]) => [v, k])); let isAutoValidating = false; let lastFrameKey = ''; let autoEnabled = true; let panel = null; let panelReady = false; // 保存最新错误数组,用于点击索引取回原始错误 let latestErrors = []; // 当前高亮dom,切换时清除旧高亮 let activeHighlightDom = null; // ========== 1. Canvas文字拦截(保留hook但已知无效) ========== let allCanvasTexts = []; let hookInstalled = false; function installCanvasHook() { if (hookInstalled) return; hookInstalled = true; const proto = CanvasRenderingContext2D.prototype; const origFillText = proto.fillText; const origStrokeText = proto.strokeText; proto.fillText = function (text, x, y, maxWidth) { try { if (text && typeof text === 'string' && text.length > 0) { let canvas = this.canvas; let camId = ''; if (canvas) { camId = canvas.getAttribute('data-value') || canvas.dataset.value || ''; if (!camId) { let parent = canvas.parentElement; let depth = 0; while (parent && !camId && depth < 5) { camId = parent.getAttribute('data-value') || parent.dataset.value || ''; parent = parent.parentElement; depth++; } } } allCanvasTexts.push({ text: text, x: Math.round(x), y: Math.round(y), camId: camId || 'unknown' }); } } catch (e) { } return origFillText.apply(this, arguments); }; proto.strokeText = function (text, x, y, maxWidth) { try { if (text && typeof text === 'string' && text.length > 0) { let canvas = this.canvas; let camId = ''; if (canvas) { camId = canvas.getAttribute('data-value') || canvas.dataset.value || ''; if (!camId) { let parent = canvas.parentElement; let depth = 0; while (parent && !camId && depth < 5) { camId = parent.getAttribute('data-value') || parent.dataset.value || ''; parent = parent.parentElement; depth++; } } } allCanvasTexts.push({ text: text, x: Math.round(x), y: Math.round(y), camId: camId || 'unknown' }); } } catch (e) { } return origStrokeText.apply(this, arguments); }; } installCanvasHook(); function clearCanvasText() { allCanvasTexts = []; } // ========== 2. Vue Store获取 ========== function getVueStore() { let el = document.getElementById('app') || document.querySelector('#app'); if (!el) return null; if (el.__vue_app__) { try { let gp = el.__vue_app__.config.globalProperties; if (gp && gp.$store) return gp.$store; } catch (e) { } } if (el.__vue__) { try { if (el.__vue__.$store) return el.__vue__.$store; } catch (e) { } } return null; } // 尝试获取根Vue组件实例,用于调用内部选中方法 function getRootVueInstance() { const el = document.querySelector('#app'); if (!el) return null; if (el.__vue_app__) { const root = el.__vue_app__.instance; return root ?? null; } if (el.__vue__) { return el.__vue__; } return null; } const VALID_OCCLUSION_CODES = new Set(['10', '11', '12', '13']); // ========== 2b. 2D遮挡校验(通过平台getShowAttrs获取显示文字) ========== function check2DOcclusion() { let store = getVueStore(); if (!store || !store.state || !store.state.marks) { return { errors: [], views: ['前鱼眼', '前广角', '后鱼眼', '左鱼眼', '右鱼眼'] }; } let marks = store.state.marks; if (!marks || typeof marks === 'undefined' || marks === null) { return { errors: [], views: ['前鱼眼', '前广角', '后鱼眼', '左鱼眼', '右鱼眼'] }; } // 获取平台自己的getShowAttrs getter let getShowAttrs = null; try { getShowAttrs = store.getters['config/getShowAttrs']; } catch (e) { } // 获取className getter(获取中文类名) let getClassName = null; try { getClassName = store.getters['config/className']; } catch (e) { } // 获取classId getter let getClassId = null; try { getClassId = store.getters['marks/classId']; } catch (e) { } // 获取attrsDirection let attrsDirection = false; try { attrsDirection = store.state.status.attrsDirection; } catch (e) { } // 找当前帧ID let curFrameId = null; try { curFrameId = store.state.status.curFrameId; } catch (e) { } if (!curFrameId) { // fallback: 找出现次数最多的帧ID let frameIds = {}; for (let catKey in marks) { let category = marks[catKey]; if (!category || typeof category !== 'object') continue; for (let instKey in category) { let instance = category[instKey]; if (!instance || typeof instance !== 'object') continue; if (instance['3d'] && typeof instance['3d'] === 'object') { for (let fid in instance['3d']) { frameIds[fid] = (frameIds[fid] || 0) + 1; } } } } let maxCount = 0; for (let fid in frameIds) { if (frameIds[fid] > maxCount) { maxCount = frameIds[fid]; curFrameId = fid; } } } if (!curFrameId) { return { errors: [], views: ['前鱼眼', '前广角', '后鱼眼', '左鱼眼', '右鱼眼'] }; } let errors = []; let checkedViews = new Set(); for (let catKey in marks) { let category = marks[catKey]; if (!category || typeof category !== 'object') continue; for (let instKey in category) { let instance = category[instKey]; if (!instance || typeof instance !== 'object') continue; let d2 = instance['2d']; if (!d2 || typeof d2 !== 'object') continue; let frame2d = d2[curFrameId]; if (!frame2d) continue; let boxList = []; if (Array.isArray(frame2d)) { boxList = frame2d; } else if (typeof frame2d === 'object' && frame2d !== null) { for (let k in frame2d) { let v = frame2d[k]; if (Array.isArray(v)) boxList = boxList.concat(v); else if (typeof v === 'object' && v !== null) boxList.push(v); } } for (let i = 0; i < boxList.length; i++) { let box = boxList[i]; if (!box || typeof box !== 'object') continue; let camera = box.camera || ''; let viewName = VIEW_NAMES[camera] || camera || '未知视角'; checkedViews.add(viewName); let class_name = instance.class || instKey; // 尝试获取中文类名 let display_name = class_name + '-' + instKey; if (getClassName) { try { let classId = null; if (getClassId) classId = getClassId(instKey); if (!classId && instance.classId) classId = instance.classId; let toolType = box.type || instance.type || 'rect2d'; if (classId) { let cn = getClassName(classId, toolType); if (cn) display_name = cn + '-' + instKey; } } catch (e) { } } let attrs = box.attrs; if (!attrs) continue; if (typeof attrs === 'string') { try { attrs = JSON.parse(attrs); } catch (e) { } } // 用平台的getShowAttrs获取显示文字 let displayText = null; if (getShowAttrs) { try { let classId = null; if (getClassId) { classId = getClassId(instKey); } let boxType = box.originalType || box.type || instance.type || 'rect2d'; let result = getShowAttrs(boxType, classId, attrs, !attrsDirection); if (Array.isArray(result)) { displayText = result.join('\n'); } else if (typeof result === 'string') { displayText = result; } } catch (e) { console.log('[校验插件] getShowAttrs调用失败:', e.message, 'instKey:', instKey); } } if (displayText) { // 检查显示文字中"遮挡:"后面是数字还是汉字 let occMatch = displayText.match(/遮挡[::]\s*(.{1,30})/); if (occMatch) { let occValue = occMatch[1].trim(); occValue = occValue.split(/截断|运动|车门|可见性/)[0].trim(); // 如果遮挡值以数字开头且不含"可见" → 报错 if (!occValue.includes('可') && /^\d/.test(occValue)) { errors.push(`[2D] ${viewName} - ${display_name}:遮挡显示为"${occValue}"`); } } } else { // fallback: 直接检查attrs里的遮挡编码 let occlusion = null; if (attrs.occlusion) { occlusion = Array.isArray(attrs.occlusion) ? attrs.occlusion[0] : attrs.occlusion; } if (occlusion !== null && occlusion !== undefined && occlusion !== '') { let occlusionStr = String(occlusion); if (!VALID_OCCLUSION_CODES.has(occlusionStr)) { errors.push(`[2D] ${viewName} - ${display_name}:遮挡值"${occlusionStr}"`); } } } } } } return { errors, views: Array.from(checkedViews) }; } // ========== 3. 3D可见性校验 ========== function check3DVisibility() { let labels3D = document.querySelectorAll('.threejs-label'); let missing = []; let count = 0; labels3D.forEach(label => { let text = (label.textContent || '').trim(); if (text.length === 0) return; if (!text.includes(':') && !text.includes(':')) return; count++; if (!text.includes('可见性')) { missing.push(text.split('\n')[0].trim()); } }); return { count, missing }; } // ========== 解析错误文本,提取信息 ========== function parseErrorStr(errStr) { //样例 [2D] 前鱼眼 - 小汽车‑inst001:遮挡显示为"5" const reg = /^\[(2D|3D)\]\s*(.*?)(?:\s‑\s(.+?))[::]/; const match = errStr.match(reg); if (!match) return null; const type = match[1]; const viewName = match[2].trim(); const displayFull = match[3] ?? ''; // displayFull格式:名称‑instKey,取最后一段作为instKey const parts = displayFull.split('‑'); const instKey = parts.length >= 2 ? parts[parts.length -1].trim() : null; return { type, viewName, instKey, raw: errStr }; } // ========== 点击错误条目:定位实例逻辑 方案C ========== async function handleClickErrorItem(errStr) { const parsed = parseErrorStr(errStr); if (!parsed) { console.warn('[校验插件]解析错误文本失败', errStr); return; } const {type, viewName, instKey} = parsed; if (!instKey) { console.warn('[校验插件]无法提取instKey', errStr); return; } const store = getVueStore(); if (!store) { console.warn('[校验插件]拿不到Vue store'); return; } const marks = store.state.marks; let targetInstance = null; //遍历marks找到instKey对应的实例 outer: for(let cat in marks){ const catItem = marks[cat]; for(let k in catItem){ if(k === instKey){ targetInstance = catItem[k]; break outer; } } } if(!targetInstance){ console.warn('[校验插件]store找不到实例', instKey); return; } console.log('[校验插件定位] instKey:', instKey, 'type:', type, 'viewName:', viewName, '实例数据:', targetInstance); //1.尝试调用平台内部选中实例方法 const rootVm = getRootVueInstance(); let selectSuccess = false; if(rootVm){ // 遍历vm找可能的选中mark方法,常见名称 selectMark / selectInstance / setSelectedMark let vm = rootVm; const candidates = ['selectMark','selectInstance','setSelectedMark','onSelectMark']; for(const fnName of candidates){ if(vm[fnName] && typeof vm[fnName] === 'function'){ try{ vm[fnName](instKey); selectSuccess = true; break; }catch(e){ //该函数不匹配,继续尝试下一个 } } //如果有$children也往下尝试一层 if(vm.$children && vm.$children.length){ for(const child of vm.$children){ if(child[fnName] && typeof child[fnName] === 'function'){ try{ child[fnName](instKey); selectSuccess = true; break; }catch(e){} } } if(selectSuccess) break; } } } if(!selectSuccess){ console.log('[校验插件]未找到平台内部选中API,已打印实例,请手动查找;instKey=',instKey); } //2. 2D类型:尝试切换相机视角 if(type === '2D' && VIEW_NAME_TO_CAM[viewName]){ const camId = VIEW_NAME_TO_CAM[viewName]; //尝试找切换相机方法 const rootVm2 = getRootVueInstance(); if(rootVm2){ const camFuncs = ['switchCamera','changeCamera','setCurrentCamera']; for(const fn of camFuncs){ if(rootVm2[fn] && typeof rootVm2[fn] === 'function'){ try{ rootVm2[fn](camId); break; }catch(e){} } if(rootVm2.$children){ for(const c of rootVm2.$children){ if(c[fn] && typeof c[fn] === 'function'){ try{c[fn](camId);break;}catch(e){} } } } } } } //3. 对threejs‑label做闪烁高亮,先清除上一次 if(activeHighlightDom){ activeHighlightDom.classList.remove('chk-label-flash'); activeHighlightDom = null; } // 简单匹配包含instKey的label文本 const allLabels = document.querySelectorAll('.threejs-label'); for(const label of allLabels){ const txt = label.textContent; if(txt.includes(instKey)){ label.classList.add('chk-label-flash'); activeHighlightDom = label; break; } } } // ========== 4. 注入CSS ========== function injectStyles() { if (document.getElementById('chk-styles')) return; const style = document.createElement('style'); style.id = 'chk-styles'; style.textContent = ` #chk-panel { position: fixed; top: 80px; left: 320px; width: 340px; max-height: 70vh; background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%); color: #e0e0e0; border: 1px solid rgba(0,210,255,0.2); border-radius: 12px; z-index: 999999; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 13px; box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.05); overflow: hidden; transition: box-shadow 0.2s; } #chk-panel:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 20px rgba(0,210,255,0.15); } #chk-header { background: linear-gradient(135deg, #0f3460 0%, #1a1a40 100%); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; border-bottom: 1px solid rgba(0,210,255,0.15); } #chk-header:active { cursor: grabbing; } .chk-title { font-weight: bold; font-size: 14px; color: #00d2ff; text-shadow: 0 0 10px rgba(0,210,255,0.3); display: flex; align-items: center; gap: 6px; } .chk-title-dot { width: 8px; height: 8px; background: #00d2ff; border-radius: 50%; box-shadow: 0 0 8px #00d2ff; animation: chk-pulse 2s ease-in-out infinite; } @keyframes chk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } .chk-btn-check { background: linear-gradient(135deg, #00d2ff 0%, #0288d1 100%); color: #fff; border: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: bold; transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,210,255,0.3); } .chk-btn-check:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,210,255,0.5); } .chk-btn-check:active { transform: translateY(0); } .chk-btn-check:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .chk-btn-toggle { background: rgba(255,255,255,0.08); color: #aaa; border: 1px solid rgba(255,255,255,0.1); width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; margin-left: 6px; } .chk-btn-toggle:hover { background: rgba(255,255,255,0.15); color: #fff; } #chk-content { padding: 12px 14px; max-height: calc(70vh - 50px); overflow-y: auto; } #chk-content::-webkit-scrollbar { width: 6px; } #chk-content::-webkit-scrollbar-track { background: transparent; } #chk-content::-webkit-scrollbar-thumb { background: rgba(0,210,255,0.3); border-radius: 3px; } #chk-content::-webkit-scrollbar-thumb:hover { background: rgba(0,210,255,0.5); } .chk-stats { margin-bottom: 10px; padding: 10px 12px; background: rgba(15,52,96,0.4); border-radius: 8px; border: 1px solid rgba(255,255,255,0.05); } .chk-stats-title { color: #00d2ff; font-weight: bold; margin-bottom: 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } .chk-stats-body { color: #8892b0; font-size: 12px; line-height: 1.8; } .chk-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: bold; } .chk-badge-ok { background: rgba78,204,163,0.15); color: #4ecca3; border: 1px solid rgba(78,204,163,0.3); } .chk-badge-err { background: rgba(233,69,96,0.15); color: #e94560; border: 1px solid rgba(233,69,96,0.3); } .chk-pass { color: #4ecca3; text-align: center; padding: 24px 0; font-size: 15px; font-weight: bold; } .chk-warn-header { color: #e94560; font-weight: bold; margin: 10px 0 6px; font-size: 13px; display: flex; align-items: center; gap: 4px; } .chk-warn-item { background: rgba(233,69,96,0.08); border-left: 3px solid #e94560; padding: 8px 10px; margin-bottom: 5px; border-radius: 0 6px 6px 0; font-size: 13px; line-height: 1.6; color: #ccc; transition: background 0.2s; cursor:pointer; } .chk-warn-item:hover { background: rgba(233,69,96,0.15); } .chk-loading { color: #8892b0; text-align: center; padding: 24px 0; } @keyframes chk-shake { 0%, 100% { transform: translateX(0); } 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } 20%, 40%, 60%, 80% { transform: translateX(5px); } } .chk-shake { animation: chk-shake 0.5s ease-in-out; } /* label闪烁高亮动画 */ @keyframes chk-label-flash-anim { 0%,100% {outline:3px solid #ff4444; outline-offset:2px;} 50% {outline:3px solid transparent;} } .chk-label-flash { animation: chk-label-flash-anim 0.6s ease-in-out infinite; } `; document.head.appendChild(style); // 给content绑定事件委托:点击chk‑warn‑item setTimeout(()=>{ const contentDom = document.getElementById('chk-content'); if(contentDom && !contentDom.dataset.clickBind){ contentDom.dataset.clickBind = '1'; contentDom.addEventListener('click',(e)=>{ const targetItem = e.target.closest('.chk-warn-item'); if(!targetItem) return; const idx = Number(targetItem.dataset.errIdx); if(Number.isNaN(idx)) return; const errStr = latestErrors[idx]; if(!errStr) return; handleClickErrorItem(errStr); }) } },0); } // ========== 5. 创建面板 ========== function createPanel() { if (panel && document.body.contains(panel)) return panel; injectStyles(); panel = document.createElement('div'); panel.id = 'chk-panel'; panel.innerHTML = `