// ==UserScript== // @name 标注平台辅助插件(奇偶帧/跨帧复制/视角过滤) // @namespace https://github.com/local/od-annotation-helper // @version 1.6.0 // @description 1) 按奇偶隐藏底部帧数标签,支持单帧显隐;2) O复制跨过被隐藏帧(向后两帧,带位姿/运动补偿),A/D翻页自动跳帧;3) 仅显示1、2视角(相机)图片上的2D框与伪3D框(默认不勾选,切换即时生效,无需刷新);4) 一键删除 3~7 视角的 2D 标注框数据(可当前帧或全部帧,删除后不重新生成,1、2视角与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: false, // 仅显示 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.2b 可见性级过滤:拦截 marksGetter('isVisible'),让非白名单相机的 2D 框/投影 // 在上屏或重算可见性时直接判为不可见(仅显示层隐藏,底层数据不动)。 // 这样切换开关后,只要触发一次平台原生重绘(updateMarksVisible + erase/paint), // 即可即时隐藏/恢复,无需刷新页面。 if (sm && !sm.__odIsVisiblePatched) { sm.__odIsVisiblePatched = true; const origMarksGetter = sm.marksGetter.bind(sm); let wrappedIsVisible = null; let wrappedGetMarks = null; const wrapperFn = function (name) { const fn = origMarksGetter(name); if (name === 'isVisible') { if (!wrappedIsVisible) { wrappedIsVisible = function (isPostil, mark, trackId, classId) { if (STATE.onlyViews12 && !isPostil && mark && mark.camera && !allowedSet.has(mark.camera)) { return false; } return fn(isPostil, mark, trackId, classId); }; } return wrappedIsVisible; } if (name === 'getMarks') { if (!wrappedGetMarks) { wrappedGetMarks = function (trackId, dimension, frameId, camera) { const res = fn(trackId, dimension, frameId, camera); // 仅当读取“某一帧的全部 2D 框”(未指定 camera)且开启视角过滤时, // 才只保留白名单相机的框;带 camera 的调用(如投影重算)保持原样,避免误删。 if (!STATE.onlyViews12 || dimension !== '2d' || camera) return res; if (Array.isArray(res)) { return res.filter(function (m) { return m && allowedSet.has(m.camera); }); } return res; }; } return wrappedGetMarks; } return fn; }; try { sm.marksGetter = wrapperFn; } catch (e) { try { Object.defineProperty(sm, 'marksGetter', { configurable: true, get: function () { return wrapperFn; }, }); } catch (e2) {} } } // 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: '#7c5cff', primaryHover: '#9a7fff', primaryActive: '#6a4bd8', textMain: '#e8eaed', textRegular: '#c9cdd2', textSecondary: '#9aa0a6', border: '#2a3145', borderLight: '#252c3d', bg: '#141826', bgLight: '#1a2030', 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.className = 'od-panel'; panel.style.cssText = 'position:fixed;top:52px;right:12px;width:320px;z-index:2147483400;' + 'font-family:system-ui,"Microsoft YaHei",sans-serif;font-size:13px;color:#e8eaed;' + 'user-select:none;overflow:hidden;'; const header = document.createElement('div'); header.className = 'od-head'; header.innerHTML = '标注平台辅助' + '' + '' + '' + ''; const body = document.createElement('div'); body.className = '_od_helper_body od-body'; body.innerHTML = '
' + '
帧数显示
' + '
' + '' + '' + '' + '
' + '' + '
' + '
' + '
复制 / 翻页
' + '' + '
' + '
' + '
视角标注
' + '' + '
' + '
' + '
数据清理
' + '' + '' + '
' + '
标注平台辅助 · v1.6.0
'; 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{background:linear-gradient(180deg,#171c28,#10141d);border:1px solid #232a38;' + 'border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.5),0 0 0 1px rgba(124,92,255,.05);}' + '#_od_helper_panel .od-head{display:flex;align-items:center;justify-content:space-between;height:40px;padding:0 12px;' + 'background:linear-gradient(180deg,#1b2130,#141826);border-bottom:1px solid #232a38;cursor:move;position:relative;}' + '#_od_helper_panel .od-head::after{content:\'\';position:absolute;left:0;right:0;bottom:-1px;height:1px;' + 'background:linear-gradient(90deg,transparent,rgba(124,92,255,.30),transparent);}' + '#_od_helper_panel .od-head-t{display:flex;align-items:center;gap:8px;min-width:0;}' + '#_od_helper_panel .od-title{font-weight:600;font-size:13px;color:#e3e6ee;letter-spacing:.4px;white-space:nowrap;}' + '#_od_helper_panel .od-dot{width:9px;height:9px;flex:0 0 auto;border-radius:50%;background:#7f6fe0;' + 'animation:odBreath 2.6s ease-in-out infinite;}' + '@keyframes odBreath{0%,100%{box-shadow:0 0 3px rgba(127,111,224,.35),0 0 8px rgba(127,111,224,.2);opacity:.75;}' + '50%{box-shadow:0 0 6px rgba(127,111,224,.7),0 0 14px rgba(127,111,224,.4);opacity:1;}}' + '#_od_helper_panel .od-head-btns{display:flex;gap:2px;flex:0 0 auto;}' + '#_od_helper_panel .od-hbtn{width:24px;height:24px;line-height:1;padding:0;border:none;background:transparent;' + 'color:#6b7280;font-size:15px;cursor:pointer;border-radius:5px;transition:all .15s;}' + '#_od_helper_panel .od-hbtn:hover{background:#242b3a;color:#e3e6ee;}' + '#_od_helper_panel .od-body{padding:2px 14px 14px;}' + '#_od_helper_panel .od-sec{padding-top:14px;}' + '#_od_helper_panel .od-sec+.od-sec{margin-top:9px;padding-top:14px;border-top:1px solid #232a38;}' + '#_od_helper_panel .od-sec-title{display:flex;align-items:center;gap:7px;margin-bottom:10px;font-weight:600;' + 'font-size:12.5px;color:#e3e6ee;letter-spacing:.4px;white-space:nowrap;}' + '#_od_helper_panel .od-bar{width:3px;height:13px;flex:0 0 auto;border-radius:2px;background:#8a7cf0;}' + '#_od_helper_panel .od-bar-danger{background:#f06473;}' + '#_od_helper_panel .od-seg{display:flex;gap:7px;margin-bottom:10px;}' + '#_od_helper_panel .od-seg-btn{flex:1;border:1px solid #262d3c;background:#161a24;' + 'border-radius:6px;padding:6px 0;cursor:pointer;font-size:12px;color:#a8aebd;transition:all .15s;text-align:center;letter-spacing:.3px;white-space:nowrap;}' + '#_od_helper_panel .od-seg-btn:hover{border-color:#8a7cf0;color:#bcb0f2;background:#1b2130;}' + '#_od_helper_panel .od-seg-btn.active{background:#6b5bd0;border-color:#7c6ce0;color:#fff;font-weight:600;' + 'box-shadow:0 1px 3px rgba(0,0,0,.35);}' + '#_od_helper_panel .od-seg-btn:active{transform:scale(.97);}' + '#_od_helper_panel .od-check{display:flex;align-items:center;gap:8px;cursor:pointer;color:#b6bcd0;font-size:12.5px;line-height:1.4;}' + '#_od_helper_panel .od-check:hover{color:#e3e6ee;}' + '#_od_helper_panel .od-check input[type=checkbox]{width:14px;height:14px;margin:0;flex:0 0 auto;accent-color:#7c5cff;cursor:pointer;}' + '#_od_helper_panel .od-del-btn{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;margin-top:10px;' + 'border:1px solid #3f2a31;background:#1d181c;border-radius:6px;padding:8px 0;cursor:pointer;' + 'font-size:12.5px;color:#ff8593;font-weight:600;transition:all .18s;letter-spacing:.3px;}' + '#_od_helper_panel .od-del-btn:hover{border-color:#e85a6b;color:#ff9aa6;background:#241c1f;}' + '#_od_helper_panel .od-del-btn:active{transform:scale(.98);}' + '#_od_helper_panel .od-del-ico{font-size:14px;}' + '#_od_helper_panel .od-foot{margin-top:14px;padding-top:10px;border-top:1px solid #232a38;text-align:center;' + 'font-size:10.5px;color:#5c6470;letter-spacing:1px;}' + '#_od_helper_panel .od-foot-dot{margin-left:6px;color:#7f6fe0;animation:odBreath 3s ease-in-out infinite;}'; 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 视角标注' : '已关闭视角过滤'); }); body.querySelector('[data-od="delviews"]').addEventListener('click', confirmDeleteViewsBeyond12); // 拖动 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')); }); } // —— 相机视图即时显隐(无需刷新)—— // 关键:相机容器组件把 cameraKey→视图 的映射存在 setup() 的闭包变量里(不在 setupState 上), // 所以无法从外部直接拿到相机视图。但每个 CameraView 实例都在构造时 watch 了 // statusState().isHidePostil / markVisible,一旦这些状态变化,所有相机视图都会 // 执行 updateMarksVisible() + erasePaint()(即平台原生"过滤变化"的重绘路径)。 // 因此这里用 statusCommit 短暂触发 isHidePostil 来强制所有相机视图重算可见性并重绘, // 配合 marksGetter('isVisible') 覆写,即可让"仅显示1、2视角"开关即时生效。 function triggerCameraRepaint() { const sm = window.instance && window.instance.StoreManager; if (!sm) return; try { const cur = sm.statusState().isHidePostil; // 切到相反值:每个 CameraView 的 watch 触发 → updateMarksVisible + erasePaint sm.statusCommit('updateProperty', ['isHidePostil', cur ? 0 : 1]); // 待 watcher flush 后再恢复原值,保持用户设置不变(约一个渲染帧) setTimeout(function () { sm.statusCommit('updateProperty', ['isHidePostil', cur]); }, 80); } catch (e) {} } // 切换视角过滤后,立即让所有相机视图重算可见性并重绘 function refreshViewFilter() { try { triggerCameraRepaint(); } catch (e) {} // 兜底:顺便刷新 3D 视图,确保点云侧同步 const viewer = window.instance && window.instance.viewer; if (viewer) { try { viewer.rendererMain && viewer.rendererMain(); } catch (e) {} } } /* ================================================================== * * 功能 4:一键删除 3~7 视角标注框(真删数据) * ================================================================== */ // 平台把 3D 框投影到各相机并持久化为 2D 框 marks[trackId]['2d'][frameId](每条带 .camera)。 // 这里用 markCommit('deleteMarks', [trackId, frameId, '2d', camera, true]) 删除非白名单相机的 2D 框; // 第 5 参 true 会把该相机写入 no_maps,避免下次重绘被重新生成(与平台 Delete 键行为一致)。 function collectDeleteTargets(allFrames) { const sm = window.instance && window.instance.StoreManager; const targets = []; if (!sm) return targets; const marks = sm.marksState && sm.marksState().marks; if (!marks) return targets; const mk = function (t, f, c) { return { trackId: t, frameId: f, camera: c }; }; const seen = new Set(); // 帧号键可能是 number 或 string,做一次容错匹配 const pick = function (m2, frameId) { if (!m2) return null; if (m2[frameId]) return m2[frameId]; if (m2[String(frameId)]) return m2[String(frameId)]; return m2[Number(frameId)] || null; }; if (allFrames) { for (const trackId in marks) { const m2 = marks[trackId] && marks[trackId]['2d']; if (!m2) continue; for (const frameId in m2) { const arr = pick(m2, frameId); if (!arr) continue; for (const m of arr) { if (m && m.camera && !allowedSet.has(m.camera)) { const key = trackId + '|' + frameId + '|' + m.camera; if (!seen.has(key)) { seen.add(key); targets.push(mk(trackId, frameId, m.camera)); } } } } } } else { const frameId = sm.getCurFrameId(); for (const trackId in marks) { const m2 = marks[trackId] && marks[trackId]['2d']; if (!m2) continue; const arr = pick(m2, frameId); if (!arr) continue; for (const m of arr) { if (m && m.camera && !allowedSet.has(m.camera)) { const key = trackId + '|' + m.camera; if (!seen.has(key)) { seen.add(key); targets.push(mk(trackId, frameId, m.camera)); } } } } } return targets; } function runDeleteViewsBeyond12(targets, allFrames) { const sm = window.instance && window.instance.StoreManager; if (!sm || !targets.length) return; let deleted = 0; for (const t of targets) { try { sm.markCommit('deleteMarks', [t.trackId, t.frameId, '2d', t.camera, true]); deleted++; } catch (e) {} } console.info('[OD Helper] 删除 3~7 视角 2D 框:共 ' + targets.length + ' 个,成功 ' + deleted + ' 个。', targets); refreshViewFilter(); showToast(allFrames ? '已删除 ' + deleted + '/' + targets.length + ' 个 3~7 视角标注框(全部帧)' : '当前帧已删除 ' + deleted + '/' + targets.length + ' 个 3~7 视角标注框'); } function confirmDeleteViewsBeyond12() { const sm = window.instance && window.instance.StoreManager; if (!sm) { showToast('插件尚未就绪'); return; } if (window.instance && window.instance.canEdit === false) { showToast('当前为只读任务,无法删除标注数据'); return; } const allBox = document.getElementById('_od_helper_panel') && document.querySelector('#_od_helper_panel [data-od="delall"]'); const allFrames = !!(allBox && allBox.checked); const targets = collectDeleteTargets(allFrames); if (!targets.length) { showToast(allFrames ? '没有 3~7 视角的标注框可删除' : '当前帧没有 3~7 视角的标注框'); return; } const frameId = sm.getCurFrameId(); const content = '将删除 ' + (allFrames ? '全部帧' : '当前帧 ' + frameId) + ' 共 ' + targets.length + ' 个 3~7 视角的 2D 标注框。\n仅影响当前任务的数据,不影响其它项目;1、2 视角与 3D 框数据保留。\n该操作不可恢复,是否继续?'; const dialog = window.instance && window.instance.showTipsDialog ? window.instance.showTipsDialog : null; if (dialog) { dialog({ content: content, btnTitles: ['取消', '确定'], callbacks: [null, function () { runDeleteViewsBeyond12(targets, allFrames); }], }); } else if (window.confirm(content)) { runDeleteViewsBeyond12(targets, allFrames); } } /* ================================================================== * * 启动 * ================================================================== */ 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(); })();