// ==UserScript== // @name 奇偶帧/跨帧复制/视角过滤 // @namespace https://github.com/local/od-annotation-helper // @version 1.2.0 // @description 1) 按奇偶隐藏底部帧数标签,支持单帧显隐;2) O复制跨过被隐藏帧(向后两帧,带位姿/运动补偿),A/D翻页自动跳帧;3) 仅显示1、2视角(相机)图片上的2D框与伪3D框。 // @author Codex // @match http://113.207.105.202:8080/pointcloud/* // @run-at document-start // @grant none // @noframes // ==/UserScript== (function () { 'use strict'; /* ================================================================== * * 配置 * ================================================================== */ const CONFIG = { // 允许显示标注框的相机 key(视角 1、2) allowedCameras: ['front_deep', 'front_mid'], copyOffset: 2, // O 复制向后跨过的帧数 frameListSelector: '#pages', frameItemSelector: '#pages > .page', }; const STATE = { parity: 'none', // none | odd(隐藏奇数) | even(隐藏偶数) copySkip: true, // O 复制跨过被隐藏帧 onlyViews12: true, // 仅显示 1、2 视角标注框 singleFrameMode: false, // 点击帧号=切换显隐 hideFrames: new Set(), // 强制隐藏的帧号 showFrames: new Set(), // 强制显示的帧号 panelBuilt: false, minimized: false, overridesInstalled: false, }; const allowedSet = new Set(CONFIG.allowedCameras); /* ================================================================== * * 工具 * ================================================================== */ function waitFor(condition, timeout, interval) { timeout = timeout == null ? 15000 : timeout; interval = interval == null ? 60 : interval; return new Promise(function (resolve) { if (condition()) return resolve(true); const start = Date.now(); const timer = setInterval(function () { if (condition()) { clearInterval(timer); resolve(true); } else if (Date.now() - start > timeout) { clearInterval(timer); resolve(false); } }, interval); }); } function showToast(text) { let host = document.getElementById('_od_helper_toast'); if (!host) { host = document.createElement('div'); host.id = '_od_helper_toast'; host.style.cssText = 'position:fixed;top:72px;left:50%;transform:translateX(-50%);' + 'background:rgba(28,30,34,.96);color:#e8eaed;padding:8px 16px;border-radius:4px;border:1px solid #33363b;' + 'font-size:13px;z-index:2147483600;pointer-events:none;opacity:0;' + 'transition:opacity .2s;font-family:system-ui,"Microsoft YaHei",sans-serif;' + 'box-shadow:0 2px 12px rgba(0,0,0,.18);'; document.body.appendChild(host); } host.textContent = text; host.style.opacity = '1'; clearTimeout(host._t); host._t = setTimeout(function () { host.style.opacity = '0'; }, 2200); } // 帧号 = 帧标签在 #pages 中的位置 + 1 function getFrameNumberFromItem(el) { const parent = el.parentNode; if (!parent) return 0; return Array.prototype.indexOf.call(parent.children, el) + 1; } function frameCount() { const sm = window.instance && window.instance.StoreManager; if (!sm) return 0; const frames = sm.configState && sm.configState().frames; return frames ? frames.length : 0; } function shouldHideFrame(num) { if (STATE.showFrames.has(num)) return false; if (STATE.hideFrames.has(num)) return true; if (STATE.parity === 'odd') return num % 2 === 1; // 隐藏 1,3,5... if (STATE.parity === 'even') return num % 2 === 0; // 隐藏 2,4,6... return false; } /* ================================================================== * * 功能 1:按奇偶 / 单帧隐藏底部帧数标签 * ================================================================== */ function paintFrames() { const items = document.querySelectorAll(CONFIG.frameItemSelector); if (!items.length) return; for (let i = 0; i < items.length; i++) { const el = items[i]; if (shouldHideFrame(i + 1)) el.style.setProperty('display', 'none', 'important'); else el.style.removeProperty('display'); } } let frameObserver = null; let docFrameObserver = null; let paintQueued = false; function queuePaint() { if (paintQueued) return; paintQueued = true; requestAnimationFrame(function () { paintQueued = false; paintFrames(); }); } function attachFrameObserver(root) { if (!root || frameObserver) return; frameObserver = new MutationObserver(queuePaint); frameObserver.observe(root, { childList: true }); queuePaint(); } function observeFrames() { const root = document.querySelector(CONFIG.frameListSelector); if (root) { attachFrameObserver(root); return; } if (!docFrameObserver) { docFrameObserver = new MutationObserver(function () { const r = document.querySelector(CONFIG.frameListSelector); if (r && !frameObserver) { attachFrameObserver(r); docFrameObserver.disconnect(); docFrameObserver = null; } }); docFrameObserver.observe(document, { childList: true, subtree: true }); } } if (window.MutationObserver) observeFrames(); else setInterval(paintFrames, 800); // 点击帧号:单帧显隐模式 / 平台默认跳帧 document.addEventListener('click', function (ev) { const el = ev.target.closest && ev.target.closest(CONFIG.frameItemSelector); if (!el) return; if (!STATE.singleFrameMode) return; const num = getFrameNumberFromItem(el); if (STATE.hideFrames.has(num)) { STATE.hideFrames.delete(num); STATE.showFrames.add(num); showToast('已显示第 ' + num + ' 帧'); } else { STATE.showFrames.delete(num); STATE.hideFrames.add(num); showToast('已隐藏第 ' + num + ' 帧'); } ev.preventDefault(); ev.stopPropagation(); queuePaint(); }, true); /* ================================================================== * * A/D 翻页:奇偶隐藏生效时自动跳到下一个可见帧 * ================================================================== */ function findNextVisibleIndex(startIdx, dir) { const total = frameCount(); if (!total) return -1; let i = startIdx; for (;;) { i += dir; if (i < 0 || i >= total) return -1; if (!shouldHideFrame(i + 1)) return i; } } document.addEventListener('keydown', function (ev) { if (STATE.parity === 'none' || STATE.singleFrameMode) return; const k = ev.key && ev.key.toLowerCase(); if (k !== 'a' && k !== 'd') return; if (ev.ctrlKey || ev.altKey || ev.metaKey) return; const t = ev.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; const sm = window.instance && window.instance.StoreManager; if (!sm) return; const cur = sm.getCurFrameId(); const idx = sm.configGetter('getFrameIndex')(cur); const nextIdx = findNextVisibleIndex(idx, k === 'd' ? 1 : -1); ev.preventDefault(); ev.stopPropagation(); if (nextIdx === -1 || nextIdx === idx) return; const tid = sm.configGetter('getFrameId')(nextIdx); if (tid !== -1) sm.statusCommit('setCurFrameId', tid); }, true); /* ================================================================== * * 功能 2:O 复制 → 跨过被隐藏帧(向后两帧,带位姿/运动补偿) * ================================================================== */ function installCopyOverride(sm) { if (sm.__odCopyPatched) return; sm.__odCopyPatched = true; sm.copyAllMarks = function () { if (this.statusState().copyStartFrameId || this.statusState().selectMarkInfo.trackId) { this.statusCommit('updateProperty', ['copyStartFrameId', null]); this.statusCommit('selectMarkInfo', [null, null, null, null, null]); } const offset = STATE.copySkip && STATE.parity !== 'none' ? CONFIG.copyOffset : 1; const e = this.getCurFrameId(); const n = this.configGetter('getFrameIndex')(e); const r = this.configGetter('getFrameId')(n + offset); if (r == -1) { showToast('已处于最后一帧,无法复制!'); return; } let hasMarks = false; const marks = this.marksState().marks; for (const trackId in marks) { const m3 = marks[trackId]['3d']; const m2 = marks[trackId]['2d']; if (!((!m3 || !m3[e]) && (!m2 || !m2[e]))) { hasMarks = true; break; } } if (!hasMarks) { showToast('当前帧无可复制标注物!'); return; } const content = offset === 1 ? '是否复制当前帧所有标注物到下一帧?' : '是否复制当前帧所有标注物到向后 ' + offset + ' 帧的帧(跳过被隐藏帧)?'; const dialog = window.instance && window.instance.showTipsDialog ? window.instance.showTipsDialog : null; if (dialog) { dialog({ content: content, btnTitles: ['取消', '确定'], callbacks: [null, function () { this.startCopyTask(e, r, true); }.bind(this)], }); } else { this.startCopyTask(e, r, true); } }; } /* ================================================================== * * 功能 3:仅显示 1、2 视角标注框(显示层过滤,不动数据) * ================================================================== */ function installViewFilter(sm, ft, store) { // 3.1 伪3D框投影:compute2DProjection 的结果仅保留白名单相机 if (ft && !ft.__odProjectPatched) { ft.__odProjectPatched = true; const orig = ft.compute2DProjection.bind(ft); ft.compute2DProjection = function (mark, trackId, classId, frameId, updateEvent) { const res = orig(mark, trackId, classId, frameId, updateEvent); if (!STATE.onlyViews12 || !res) return res; const out = {}; for (const key in res) if (allowedSet.has(key)) out[key] = res[key]; return out; }; } // 3.2 2D框:替换 curFrameMarks 为“每次访问都过滤”的 getter,避免 Vue computed 缓存旧值 // (消费方都只读 .value,不依赖响应式,安全) if (sm && !sm.__odCurMarksPatched) { sm.__odCurMarksPatched = true; const origFrameMarks = sm.frameMarks.bind(sm); Object.defineProperty(sm, 'curFrameMarks', { configurable: true, enumerable: true, get: function () { const self = this; return { get value() { const fm = origFrameMarks.call(self); if (!STATE.onlyViews12 || !fm || !fm['2d']) return fm; const filtered = {}; for (const trackId in fm['2d']) { const arr = (fm['2d'][trackId] || []).filter(function (m) { return m && allowedSet.has(m.camera); }); if (arr.length) filtered[trackId] = arr; } return { '3d': fm['3d'] || {}, '2d': filtered }; } }; } }); } // 3.3 编辑/新增 2D 标注时,BUS_ADD_MODIFY_2D 会绕过 curFrameMarks 直接上屏; // 通过拦截 commit,把非白名单相机的“上屏事件”抑制掉(数据仍写入 store,仅不上屏)。 // 注意:真正启动时 store 可能还未被 setStore,必须等真实 Vuex store 出现后再装, // 否则 getStore() 会返回 useStore 函数,覆盖它的 commit 会报错。 installCommitOverride(store); } function isRealStore(store) { return !!(store && typeof store.commit === 'function' && store._modules); } function installCommitOverride(store) { if (!isRealStore(store) || store.__odCommitPatched) return; store.__odCommitPatched = true; const origCommit = store.commit.bind(store); store.commit = function (type, payload) { if (STATE.onlyViews12 && type === 'marks/addOrReplaceMark2D' && Array.isArray(payload)) { const mark = payload[0]; if (mark && mark.camera && !allowedSet.has(mark.camera)) { payload = payload.slice(); payload[5] = false; // 关闭该 mutation 的事件上屏,数据照常写入 } } return origCommit(type, payload); }; } /* ================================================================== * * 面板 UI(匹配标注界面深色主题,可拖动、可最小化) * ================================================================== */ const PALETTE = { primary: '#409eff', primaryHover: '#66b1ff', primaryActive: '#337ecc', textMain: '#e8eaed', textRegular: '#c9cdd2', textSecondary: '#9aa0a6', border: '#3a3d42', borderLight: '#33363b', bg: '#1e2024', bgLight: '#282a2e', hover: '#2a2d31', success: '#67c23a', info: '#909399', }; function buildPanel() { if (STATE.panelBuilt || !document.body) return; STATE.panelBuilt = true; const panel = document.createElement('div'); panel.id = '_od_helper_panel'; panel.style.cssText = 'position:fixed;top:52px;right:12px;width:270px;z-index:2147483400;' + 'font-family:system-ui,"Microsoft YaHei",sans-serif;font-size:13px;color:' + PALETTE.textMain + ';' + 'user-select:none;border:1px solid ' + PALETTE.borderLight + ';border-radius:6px;' + 'box-shadow:0 12px 32px rgba(0,0,0,.55);background:' + PALETTE.bg + ';overflow:hidden;'; const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0 10px;height:34px;' + 'background:' + PALETTE.bgLight + ';border-bottom:1px solid ' + PALETTE.borderLight + ';' + 'cursor:move;font-weight:600;'; header.innerHTML = '' + '' + '标注平台辅助' + '' + '' + '' + ''; const body = document.createElement('div'); body.className = '_od_helper_body'; body.style.cssText = 'padding:0 12px 12px;'; body.innerHTML = '
' + '奇偶帧隐藏' + '仅隐藏底部帧号
' + '
' + '' + '' + '' + '
' + '' + '
' + '
复制 / 翻页
' + '' + '
' + '开启奇偶隐藏后,A/D 翻页自动跳到下一个可见帧。
' + '
' + '
视角标注
' + '' + '
' + '视角1=' + (CONFIG.allowedCameras[0] || '') + ',视角2=' + (CONFIG.allowedCameras[1] || '') + ';其余视角隐藏 2D 框与伪3D框。
'; panel.appendChild(header); panel.appendChild(body); document.body.appendChild(panel); const css = document.createElement('style'); css.id = '_od_helper_style'; css.textContent = '#_od_helper_panel .od-chip{border:1px solid ' + PALETTE.border + ';background:' + PALETTE.bg + ';' + 'border-radius:4px;padding:4px 0;cursor:pointer;font-size:12px;color:' + PALETTE.textRegular + ';transition:all .15s;}' + '#_od_helper_panel .od-chip:hover{border-color:' + PALETTE.primary + ';color:' + PALETTE.primary + ';background:' + PALETTE.hover + ';}' + '#_od_helper_panel .od-chip.active{background:' + PALETTE.primary + ';border-color:' + PALETTE.primary + ';color:#fff;}' + '#_od_helper_panel .od-hbtn:hover{background:#33363b !important;color:' + PALETTE.textMain + ' !important;}' + '#_od_helper_panel .od-chip:active{transform:scale(.97);}' + '#_od_helper_panel input[type=checkbox]{accent-color:' + PALETTE.primary + ';}' + '#_od_helper_panel label:hover{color:' + PALETTE.textMain + ';}'; document.head.appendChild(css); // 事件 header.querySelector('[data-od="min"]').addEventListener('click', function () { STATE.minimized = !STATE.minimized; body.style.display = STATE.minimized ? 'none' : 'block'; panel.style.width = STATE.minimized ? 'auto' : '270px'; }); header.querySelector('[data-od="close"]').addEventListener('click', function () { panel.style.display = 'none'; let btn = document.getElementById('_od_helper_open'); if (!btn) { btn = document.createElement('button'); btn.id = '_od_helper_open'; btn.textContent = '标注辅助'; btn.style.cssText = 'position:fixed;top:52px;right:12px;z-index:2147483400;padding:6px 12px;' + 'background:' + PALETTE.primary + ';border:none;border-radius:4px;color:#fff;cursor:pointer;' + 'font-size:12px;font-family:system-ui,"Microsoft YaHei",sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.15);'; btn.addEventListener('click', function () { btn.style.display = 'none'; panel.style.display = 'block'; }); document.body.appendChild(btn); } else btn.style.display = 'block'; }); body.querySelectorAll('[data-parity]').forEach(function (b) { b.addEventListener('click', function () { setParity(b.getAttribute('data-parity')); }); }); body.querySelector('[data-od="single"]').addEventListener('change', function (e) { STATE.singleFrameMode = e.target.checked; }); body.querySelector('[data-od="copy"]').addEventListener('change', function (e) { STATE.copySkip = e.target.checked; }); body.querySelector('[data-od="views"]').addEventListener('change', function (e) { STATE.onlyViews12 = e.target.checked; queuePaint(); refreshViewFilter(); showToast(STATE.onlyViews12 ? '已启用:仅显示 1、2 视角标注' : '已关闭视角过滤'); }); // 拖动 let drag = null; header.addEventListener('mousedown', function (e) { if (e.target.closest('button')) return; const rect = panel.getBoundingClientRect(); drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!drag) return; panel.style.left = (e.clientX - drag.dx) + 'px'; panel.style.top = (e.clientY - drag.dy) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', function () { drag = null; }); syncParityButtons(); } function setParity(mode) { STATE.parity = mode; STATE.hideFrames.clear(); STATE.showFrames.clear(); syncParityButtons(); queuePaint(); showToast(mode === 'none' ? '已恢复显示全部帧' : (mode === 'odd' ? '已隐藏奇数帧,A/D 自动跳帧' : '已隐藏偶数帧,A/D 自动跳帧')); } function syncParityButtons() { document.querySelectorAll('#_od_helper_panel [data-parity]').forEach(function (b) { b.classList.toggle('active', STATE.parity === b.getAttribute('data-parity')); }); } // 切换视角过滤后,触发一次相机重绘 function refreshViewFilter() { const viewer = window.instance && window.instance.viewer; if (viewer) { try { viewer.rendererMain && viewer.rendererMain(); } catch (e) {} } // 轻量触发相机视图重算:微调 #camera-div 高度(watcher 监听其尺寸变化) const camDiv = document.getElementById('camera-div'); if (camDiv) { const prev = camDiv.style.height; camDiv.style.height = (parseFloat(window.getComputedStyle(camDiv).height) + 1) + 'px'; setTimeout(function () { camDiv.style.height = prev || ''; }, 40); } } /* ================================================================== * * 启动 * ================================================================== */ function installOverrides() { if (STATE.overridesInstalled) return; const sm = window.instance && window.instance.StoreManager; const ft = window.instance && window.instance.fusionTools; if (!sm || !ft) return; installCopyOverride(sm); installViewFilter(sm, ft, null); STATE.overridesInstalled = true; console.info('[OD Helper] 标注平台辅助插件已启用。'); // store 可能尚未初始化,等真实 Vuex store 出现后再装 commit 拦截 waitFor(function () { try { return isRealStore(sm.getStore()); } catch (e) { return false; } }).then(function () { try { installCommitOverride(sm.getStore()); } catch (e) {} }); } function boot() { // 尽早安装覆写,确保在第一次相机渲染前生效(避免 Vue computed 缓存旧数据) waitFor(function () { return !!(window.instance && window.instance.StoreManager && window.instance.fusionTools); }).then(installOverrides); // 面板相关在 DOM 就绪后再构建 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', buildPanel); } else { buildPanel(); } } boot(); })();