// ==UserScript== // @name 西井 3D 标注点数校验(窗口内提示 + 点击定位) // @namespace local.xijing.dimcount // @version 6.0.0 // @description 按平台内部数据校验 3D 标注点数(point_num),小于阈值判异常;虚拟/预测(visual_type=2)标注归入跳过。点击异常项在点云中高亮标注框与标签。切帧自动校验,窗口可拖动可最小化。 // @author you // @match *://121.37.95.217:8080/pointcloud/* // @grant none // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const CONFIG = { threshold: 5, // 3D 标注点数小于该值判为异常 checkInterval: 250, // 检测间隔(毫秒) sound: false, // 出现异常时是否提示音 }; const VISUAL_VIRTUAL = 2; // 虚拟/预测(斜纹)——不校验 /* ============================ 数据读取 ============================ */ function getStore() { const app = (document.querySelector('#app') || {}).__vue_app__; if (!app || !app.config || !app.config.globalProperties) return null; try { return app.config.globalProperties.$store || null; } catch (e) { return null; } } function guessFrameId(store) { try { const marks = store.state.marks; if (!marks || typeof marks !== 'object') return null; const counts = {}; for (const key in marks) { const v = marks[key]; const d3 = v && v['3d']; if (d3 && typeof d3 === 'object') { for (const fid in d3) counts[fid] = (counts[fid] || 0) + 1; } } let best = null, max = -1; for (const fid in counts) { if (counts[fid] > max) { max = counts[fid]; best = fid; } } return best; } catch (e) { return null; } } function currentFrameId(store) { let id = store.state.status && store.state.status.curFrameId; if (id == null || id === '') id = guessFrameId(store); return id; } function resolveClassName(store, classId, toolType, fallback) { try { const getClassName = store.getters['config/className']; if (getClassName) { const name = getClassName(classId, toolType) || getClassName(classId); if (name) return name; } } catch (e) {} return fallback || String(classId); } function toRow(store, trackId, mark, getters, forceVirtual, classIdHint) { const getClassName = getters.getClassName; const getClassId = getters.getClassId; const count = mark.point_num != null ? +mark.point_num : 0; const vt = mark.visual_type != null ? +mark.visual_type : 0; const virtual = !!forceVirtual || vt === VISUAL_VIRTUAL; let classId = mark.classId != null ? mark.classId : classIdHint; if (classId == null && getClassId) { try { classId = getClassId(trackId); } catch (e) {} } const className = resolveClassName(store, classId, mark.type || 'volume', mark.class || trackId); return { trackId, classId, count, virtual, label: className + '-' + trackId, mark }; } // 返回 { rows, frameId };合并当前帧真实标注 + 虚拟/预测标注 function detect(store) { const frameId = currentFrameId(store); const rows = []; if (!frameId || !store.getters) return { rows, frameId }; const getters = { getClassName: store.getters['config/className'], getClassId: store.getters['marks/classId'], }; // 真实标注:平台官方 getter 整理好的当前帧数据 const fmGetter = store.getters['marks/frameMarks']; const frameMarks = (typeof fmGetter === 'function' && fmGetter(frameId)) || {}; const real3d = (frameMarks['3d']) || {}; for (const trackId in real3d) { const mark = real3d[trackId]; if (mark && typeof mark === 'object') rows.push(toRow(store, trackId, mark, getters, false, null)); } // 虚拟/预测标注存在 curVMarks[trackId]['3d'] const curVMarks = (store.state.marks && store.state.marks.curVMarks) || {}; for (const trackId in curVMarks) { const v = curVMarks[trackId]; const mark = v && v['3d']; if (mark && typeof mark === 'object') { rows.push(toRow(store, trackId, mark, getters, true, v.class)); } } return { rows, frameId }; } /* ============================ 界面 ============================ */ const CSS = ` #xjd3d-root{position:fixed;left:340px;top:80px;z-index:999999;font:13px/1.6 -apple-system,"Microsoft YaHei","PingFang SC",sans-serif;color:#dbeafe;user-select:none} #xjd3d-root *{box-sizing:border-box} #xjd3d-panel{position:relative;width:352px;max-height:72vh;max-width:calc(100vw - 40px);background:linear-gradient(155deg,rgba(13,20,38,.97),rgba(8,12,24,.97));backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(56,189,248,.28);border-radius:14px;box-shadow:0 14px 46px rgba(0,0,0,.62),0 0 0 1px rgba(56,189,248,.08),0 0 26px rgba(56,189,248,.12);overflow:hidden} #xjd3d-accent{position:relative;height:4px;background:linear-gradient(90deg,#22d3ee,#3b82f6,#818cf8);overflow:hidden} #xjd3d-accent::after{content:'';position:absolute;top:0;left:-40%;width:40%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent);animation:xjd3d-shine 3.2s linear infinite} @keyframes xjd3d-shine{to{left:120%}} #xjd3d-head{position:relative;display:flex;align-items:center;justify-content:space-between;padding:11px 14px 9px;cursor:move;gap:10px;background:linear-gradient(180deg,rgba(56,189,248,.10),transparent)} #xjd3d-head:active{cursor:grabbing} #xjd3d-titlewrap{display:flex;align-items:center;gap:9px;min-width:0} #xjd3d-logo{width:27px;height:27px;border-radius:8px;background:linear-gradient(135deg,#22d3ee,#3b82f6);display:flex;align-items:center;justify-content:center;color:#04101f;font-size:13px;font-weight:800;flex:none;box-shadow:0 0 12px rgba(34,211,238,.55)} #xjd3d-led{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 9px #22c55e;flex:none;animation:xjd3d-led 1.6s ease-in-out infinite} #xjd3d-led.err{background:#ef4444;box-shadow:0 0 9px #ef4444} @keyframes xjd3d-led{0%,100%{opacity:1}50%{opacity:.35}} #xjd3d-title{font-size:14px;font-weight:700;color:#e6f7ff;letter-spacing:.5px;text-shadow:0 0 8px rgba(56,189,248,.35);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #xjd3d-sub{font-size:11px;color:#7aa0c0;font-weight:500;white-space:nowrap} #xjd3d-right{display:flex;align-items:center;gap:8px;cursor:default} #xjd3d-pill{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap;border:1px solid transparent} #xjd3d-pill.ok{color:#4ade80;background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.35);box-shadow:0 0 10px rgba(34,197,94,.15)} #xjd3d-pill.err{color:#f87171;background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);box-shadow:0 0 10px rgba(239,68,68,.2)} #xjd3d-check{height:24px;padding:0 11px;border:none;border-radius:7px;background:linear-gradient(135deg,#06b6d4,#3b82f6);color:#04101f;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:all .15s;box-shadow:0 0 12px rgba(56,189,248,.4)} #xjd3d-check:hover{transform:translateY(-1px);box-shadow:0 0 18px rgba(56,189,248,.6)} #xjd3d-check:active{transform:translateY(0)} #xjd3d-btn{width:24px;height:24px;border:1px solid rgba(56,189,248,.3);border-radius:7px;background:rgba(30,41,59,.6);color:#7aa0c0;cursor:pointer;font-size:15px;line-height:1;display:flex;align-items:center;justify-content:center;transition:all .15s} #xjd3d-btn:hover{background:rgba(56,189,248,.18);color:#e6f7ff;border-color:rgba(56,189,248,.6)} #xjd3d-body{position:relative;padding:4px 14px 14px;max-height:calc(72vh - 62px);overflow-y:auto} #xjd3d-body::-webkit-scrollbar{width:5px} #xjd3d-body::-webkit-scrollbar-track{background:transparent} #xjd3d-body::-webkit-scrollbar-thumb{background:rgba(56,189,248,.35);border-radius:4px} #xjd3d-body::-webkit-scrollbar-thumb:hover{background:rgba(56,189,248,.6)} #xjd3d-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px} .xjd3d-stat{background:rgba(30,41,59,.5);border:1px solid rgba(56,189,248,.16);border-radius:11px;padding:9px 6px;text-align:center} .xjd3d-stat b{display:block;font-family:'JetBrains Mono','Consolas',monospace;font-size:20px;font-weight:700;line-height:1.2;color:#38bdf8;font-variant-numeric:tabular-nums;text-shadow:0 0 10px rgba(56,189,248,.55)} .xjd3d-stat span{display:block;font-size:11px;color:#7aa0c0;margin-top:2px} .xjd3d-stat.t-err b{color:#f87171;text-shadow:0 0 10px rgba(239,68,68,.55)} .xjd3d-stat.t-ok b{color:#4ade80;text-shadow:0 0 10px rgba(34,197,94,.5)} .xjd3d-stat.t-skip b{color:#7c8aa0;text-shadow:none} .xjd3d-warnhead{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:#f87171;margin:6px 2px 8px;text-shadow:0 0 8px rgba(239,68,68,.3)} .xjd3d-item{display:flex;align-items:center;justify-content:space-between;gap:10px;background:rgba(30,15,24,.5);border:1px solid rgba(239,68,68,.25);border-left:3px solid #f87171;border-radius:9px;padding:8px 10px;margin-bottom:7px;font-size:13px;color:#e2e8f0;cursor:pointer;transition:all .15s} .xjd3d-item:hover{background:rgba(239,68,68,.14);transform:translateX(2px);box-shadow:0 3px 12px rgba(239,68,68,.25)} .xjd3d-item .xc{font-family:'JetBrains Mono','Consolas',monospace;font-weight:700;color:#f87171;font-variant-numeric:tabular-nums;white-space:nowrap} .xjd3d-item .lbl{flex:1;word-break:break-all;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} .xjd3d-item .loc{font-size:10px;color:#67e8f9;background:rgba(6,182,212,.12);border:1px solid rgba(34,211,238,.3);border-radius:999px;padding:1px 8px;opacity:0;transition:opacity .15s;white-space:nowrap} .xjd3d-item:hover .loc{opacity:1} .xjd3d-item.located{border-left-color:#fbbf24;background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.4)} .xjd3d-item.located .loc{opacity:1;color:#fbbf24;background:rgba(245,158,11,.14);border-color:rgba(245,158,11,.4)} .xjd3d-pass{display:flex;align-items:center;justify-content:center;gap:8px;color:#4ade80;font-weight:600;font-size:14px;padding:24px 0;text-shadow:0 0 8px rgba(34,197,94,.35)} .xjd3d-pass .ic{width:26px;height:26px;border-radius:50%;background:rgba(34,197,94,.16);display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 0 10px rgba(34,197,94,.3)} .xjd3d-empty{color:#7aa0c0;text-align:center;padding:20px 0} .xjd3d-note{font-size:11px;color:#64748b;margin-top:8px;text-align:center} @keyframes xjd3d-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} #xjd3d-panel{animation:xjd3d-in .25s ease} `; let rootEl = null, bodyEl = null, pillEl = null, ledEl = null; let minimized = false; let currentRows = []; let lastSig = null; let lastFrameId = null; function buildUI() { if (document.getElementById('xjd3d-root')) return; const style = document.createElement('style'); style.textContent = CSS; document.head.appendChild(style); rootEl = document.createElement('div'); rootEl.id = 'xjd3d-root'; rootEl.innerHTML = `
标注点数校验
点数少于 ${CONFIG.threshold} 判异常
· 正常
`; document.body.appendChild(rootEl); const headEl = rootEl.querySelector('#xjd3d-head'); bodyEl = rootEl.querySelector('#xjd3d-body'); pillEl = rootEl.querySelector('#xjd3d-pill'); ledEl = rootEl.querySelector('#xjd3d-led'); const checkBtn = rootEl.querySelector('#xjd3d-check'); const btn = rootEl.querySelector('#xjd3d-btn'); checkBtn.addEventListener('click', (e) => { e.stopPropagation(); checkBtn.textContent = '校验中'; checkBtn.disabled = true; setTimeout(() => { tick(true); checkBtn.textContent = '校验'; checkBtn.disabled = false; }, 30); }); btn.addEventListener('click', (e) => { e.stopPropagation(); minimized = !minimized; bodyEl.style.display = minimized ? 'none' : ''; btn.innerHTML = minimized ? '+' : '−'; btn.title = minimized ? '展开' : '最小化'; if (!minimized) tick(true); }); // 拖动(限制在视口内) let dragging = false, sx = 0, sy = 0, sl = 0, st = 0; headEl.addEventListener('mousedown', (e) => { if (e.target === btn || e.target === checkBtn) return; dragging = true; sx = e.clientX; sy = e.clientY; const r = rootEl.getBoundingClientRect(); sl = r.left; st = r.top; document.body.style.userSelect = 'none'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const nl = Math.max(-300, Math.min(sl + (e.clientX - sx), window.innerWidth - 80)); const nt = Math.max(0, Math.min(st + (e.clientY - sy), window.innerHeight - 50)); rootEl.style.left = nl + 'px'; rootEl.style.top = nt + 'px'; rootEl.style.right = 'auto'; }); window.addEventListener('mouseup', () => { dragging = false; document.body.style.userSelect = ''; }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } function render(rows) { buildUI(); currentRows = rows; const reals = rows.filter((r) => !r.virtual); const virtuals = rows.filter((r) => r.virtual); const danger = reals.filter((r) => r.count < CONFIG.threshold); const normal = reals.filter((r) => r.count >= CONFIG.threshold); pillEl.className = danger.length ? 'err' : 'ok'; pillEl.textContent = danger.length ? '⚠ ' + danger.length + ' 异常' : '✓ 正常'; if (ledEl) ledEl.className = danger.length ? 'err' : ''; let html = `
${rows.length}检测框
${danger.length}异常
${normal.length}正常
${virtuals.length}预测跳过
`; if (!rows.length) { html += '
未获取到平台标注数据,或页面未加载完成。
'; } else if (!danger.length) { html += '
校验通过,无异常
'; } else { html += `
⚠ ${danger.length} 项点数不足(点击定位)
`; danger.forEach((r, i) => { html += `
${escapeHtml(r.label)} 定位 ${r.count}
`; }); html += '
点击异常项,将在点云中选中并高亮对应标注框
'; } bodyEl.innerHTML = html; bodyEl.querySelectorAll('.xjd3d-item').forEach((item) => { item.addEventListener('click', () => { const idx = parseInt(item.getAttribute('data-idx'), 10); bodyEl.querySelectorAll('.xjd3d-item').forEach((e2) => e2.classList.remove('located')); item.classList.add('located'); locate(danger[idx]); }); }); } /* ============================ 定位 ============================ */ function highlight3DBox(row) { const store = getStore(); if (!store || !store.commit || row.trackId == null) return; try { store.commit('status/selectMarkInfo', [row.trackId, row.classId, '3d', null, true]); } catch (e) { console.log('[西井3D] 选中标注框失败:', e.message); } } function highlight3DLabel(row) { const trackId = String(row.trackId); const labels = document.querySelectorAll('.threejs-label') || []; for (const lab of labels) { const first = ((lab.textContent || '').trim().split('\n')[0] || ''); const hit = (row.label && first === row.label) || first.endsWith('-' + trackId) || first.indexOf('-' + trackId) !== -1; if (!hit) continue; try { lab.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (e) {} lab.style.transition = 'outline .2s, box-shadow .2s'; lab.style.outline = '2px solid #22c55e'; lab.style.outlineOffset = '2px'; lab.style.boxShadow = '0 0 0 4px rgba(34,197,94,.35), 0 0 20px 6px rgba(34,197,94,.5)'; lab.style.borderRadius = '6px'; setTimeout(() => { lab.style.outline = ''; lab.style.outlineOffset = ''; lab.style.boxShadow = ''; lab.style.borderRadius = ''; }, 3000); break; } } function locate(row) { highlight3DBox(row); // 点云里的标注框(平台选中高亮) setTimeout(() => highlight3DLabel(row), 250); // 3D 标签绿色描边 } /* ============================ 校验循环 ============================ */ function signature(rows) { return [CONFIG.threshold, rows.length, rows.map((r) => r.label + '#' + r.count + '#' + (r.virtual ? 1 : 0)).join(',')].join('|'); } function beep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(), g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; g.gain.setValueAtTime(0.15, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); o.start(); o.stop(ctx.currentTime + 0.3); } catch (e) {} } let checking = false; function tick(force) { if (checking) return; checking = true; try { const store = getStore(); const { rows, frameId } = store ? detect(store) : { rows: [], frameId: null }; const sig = signature(rows); const frameChanged = frameId !== lastFrameId; if (!force && !frameChanged && sig === lastSig) return; // 数据或帧未变则不动 DOM lastSig = sig; lastFrameId = frameId; if (rows.some((r) => !r.virtual && r.count < CONFIG.threshold) && CONFIG.sound) beep(); render(rows); } catch (e) { // 忽略平台内部异常 } finally { checking = false; } } setInterval(() => tick(false), CONFIG.checkInterval); // 自动校验(含切帧立即刷新) tick(); })();