// ==UserScript== // @name 标注助手 - 框数统计/自动保存 // @namespace https://dawnchan.online // @version 1.0.0 // @description 直接读取平台标注数据,按类别统计当前帧/全部帧框数;紧凑悬浮窗;支持定时保存。 // @author cc // @match *://*/*pointcloud* // @match *://*/w/pointcloud/* // @grant none // @noframes // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const LS_KEY = 'bch_widget_v2'; const MIN_SAVE_MS = 10000; const MAX_SAVE_MS = 24 * 60 * 60 * 1000; const DEFAULTS = { autoSave: true, interval: 5, unit: 'minutes', scope: 'all', collapsed: false, pos: null }; let settings = loadSettings(); // ========== 设置 ========== function loadSettings() { try { const raw = localStorage.getItem(LS_KEY); const parsed = raw ? JSON.parse(raw) : {}; const s = Object.assign({}, DEFAULTS, parsed || {}); s.interval = Math.round(Number(s.interval)); if (!Number.isFinite(s.interval) || s.interval < 1) s.interval = DEFAULTS.interval; s.interval = Math.min(999, Math.max(1, s.interval)); if (!['seconds', 'minutes', 'hours'].includes(s.unit)) s.unit = DEFAULTS.unit; if (!['frame', 'all'].includes(s.scope)) s.scope = DEFAULTS.scope; s.autoSave = !!s.autoSave; s.collapsed = !!s.collapsed; if (!s.pos || typeof s.pos.x !== 'number' || typeof s.pos.y !== 'number') s.pos = null; return s; } catch (e) { return Object.assign({}, DEFAULTS); } } function persist() { try { localStorage.setItem(LS_KEY, JSON.stringify(settings)); } catch (e) { // localStorage 写入失败不影响统计 } } function intervalMs() { const mult = settings.unit === 'seconds' ? 1000 : settings.unit === 'minutes' ? 60000 : 3600000; return Math.min(MAX_SAVE_MS, Math.max(MIN_SAVE_MS, Math.round(settings.interval * mult))); } // ========== 平台数据访问 ========== function getInstance() { try { return window.instance || null; } catch (e) { return null; } } function getSM() { const inst = getInstance(); try { return inst && inst.StoreManager ? inst.StoreManager : null; } catch (e) { return null; } } function canEdit() { const inst = getInstance(); try { return !!(inst && inst.canEdit); } catch (e) { return false; } } function getStatusState() { const sm = getSM(); if (!sm) return null; try { return sm.statusState(); } catch (e) { return null; } } function getSnapshot() { const sm = getSM(); if (!sm) return null; try { const cfgState = sm.configState(); const markState = sm.marksState(); const cfg = cfgState.config || {}; const frames = Array.isArray(cfgState.frames) ? cfgState.frames : []; const frameIds = cfgState.frameIds || {}; const curId = sm.getCurFrameId(); const idx = frameIds[curId]; return { sm, marks: markState.marks || {}, versions: `${markState.curVer}|${markState.curMetaVer}`, config: cfg, frames, frameIds, curId, curIndex: typeof idx === 'number' ? idx : -1, taskKey: cfg.task_key || cfg.id || '', frameSetKey: frames.length ? String(frames[0] && frames[0].task_id) + '|' + String(frames[frames.length - 1] && frames[frames.length - 1].task_id) : '' }; } catch (e) { return null; } } // ========== 统计 ========== const TOOL_2D_MAP = { rect: 'volume', cuboid_2d: 'volume', line: 'line_3d', polygon: 'polygon_3d' }; function frameHas2D(cams) { if (!cams) return false; if (Array.isArray(cams)) return cams.length > 0; if (typeof cams !== 'object') return false; for (const key in cams) { const arr = cams[key]; if (Array.isArray(arr) && arr.length) return true; } return false; } function trackHasMark(track, frameId) { if (!track || !frameId) return false; const d3 = track['3d']; if (d3 && d3[frameId]) return true; const d2 = track['2d']; return !!(d2 && frameHas2D(d2[frameId])); } function first2DType(arr) { if (!arr) return null; if (Array.isArray(arr)) { return arr.length && arr[0] && arr[0].type ? arr[0].type : null; } for (const key in arr) { const sub = arr[key]; if (Array.isArray(sub) && sub.length && sub[0] && sub[0].type) return sub[0].type; } return null; } function trackToolType(track, frameId) { if (!track) return null; const d3 = track['3d']; if (frameId && d3 && d3[frameId] && d3[frameId].type) return d3[frameId].type; if (d3) { for (const fid in d3) { const mark = d3[fid]; if (mark && mark.type) return mark.type; } } const d2 = track['2d']; if (d2) { if (frameId) { const type = first2DType(d2[frameId]); if (type) return TOOL_2D_MAP[type] || type; } for (const fid in d2) { const type = first2DType(d2[fid]); if (type) return TOOL_2D_MAP[type] || type; } } return null; } function countFrame(snapshot) { const map = new Map(); let total = 0; const frameId = snapshot.curId; if (!frameId) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!trackHasMark(track, frameId)) continue; const tool = trackToolType(track, frameId); if (!tool) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + 1); total++; } return { map, total }; } function countAll(snapshot) { const map = new Map(); let total = 0; const validFrames = new Set( snapshot.frames .map(function (f) { return f && f.task_id != null ? String(f.task_id) : null; }) .filter(function (id) { return id != null; }) ); if (!validFrames.size) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!track) continue; const tool = trackToolType(track, null); if (!tool) continue; const counted = new Set(); const d3 = track['3d']; if (d3) { for (const fid in d3) { if (validFrames.has(String(fid)) && d3[fid]) counted.add(fid); } } const d2 = track['2d']; if (d2) { for (const fid in d2) { if (!validFrames.has(String(fid)) || counted.has(fid)) continue; if (frameHas2D(d2[fid])) counted.add(fid); } } if (!counted.size) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + counted.size); total += counted.size; } return { map, total }; } function classInfo(snapshot, toolType, classId) { let cls = null; const classes = snapshot.config && snapshot.config.classes; const group = classes && classes[toolType]; if (group && group.listClass) cls = group.listClass[classId]; if (!cls) { const props = snapshot.config && snapshot.config.properties; if (Array.isArray(props)) { const g = props.find(function (p) { return p && p.toolType === toolType; }); if (g && g.listClass) cls = g.listClass[classId]; } } let name = ''; let color = ''; if (cls) { name = cls.ptitle || cls.pname || ''; color = cls.color || ''; } if (!name) name = '类别 ' + classId; if (!/^#[0-9a-f]{3,8}$/i.test(color) && !/^rgba?\(/i.test(color)) color = '#7a7a7a'; return { name: name, color: color, tool: toolType, id: classId }; } // ========== 格式化 ========== const NUM_FMT = (function () { try { return new Intl.NumberFormat('zh-CN'); } catch (e) { return null; } })(); function formatNum(n) { n = Number(n) || 0; return NUM_FMT ? NUM_FMT.format(n) : String(n); } function compactNum(n) { n = Number(n) || 0; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'm'; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; return String(n); } function formatDuration(sec) { sec = Math.max(0, Math.ceil(sec)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0'); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ========== 自动保存 ========== let autoTimer = null; let autoGeneration = 0; let nextSaveAt = 0; let savingUntil = 0; let pendingSave = null; let messageObserverStarted = false; let flash = { text: '', kind: '', until: 0 }; function setStatus(text, kind, ms) { flash = { text: text, kind: kind || '', until: Date.now() + (ms || 2500) }; updateFooter(); } function isDirty() { const sm = getSM(); if (!sm) return false; try { const marks = sm.marksState(); if (marks.curVer !== marks.updatedVer || marks.curMetaVer !== marks.updatedMetaVer) return true; const seg = sm.segState(); if (seg && (seg.curSegVer !== seg.updateSegVer || seg.segProjectionVer !== seg.updateSegProjectionVer)) return true; } catch (e) { return true; } return false; } function triggerSave() { // 优先点击平台真实的保存按钮,让平台自己的“保存成功/失败”提示出现 const button = findSaveButton(); if (button) { try { button.click(); return true; } catch (e) { console.error('[框数助手] 点击保存按钮失败', e); } } // 找不到按钮时退回平台保存快捷键 Ctrl+S const isMac = /Mac/.test(navigator.platform || ''); try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: !isMac, metaKey: isMac, bubbles: true, cancelable: true })); return true; } catch (e) { console.error('[框数助手] 保存快捷键派发失败', e); return false; } } function findSaveButton() { try { const top = document.getElementById('top'); if (!top) return null; const groups = []; for (const child of top.children) { if (child.classList && child.classList.contains('top-item')) groups.push(child); } const group = groups[groups.length - 1]; // 平台把保存按钮固定放在最后一个 top-item 组的最后一位 if (!group || top.lastElementChild !== group) return null; const buttons = group.querySelectorAll('button'); // 编辑权限下通常是 [通过, 保存];超过 4 个说明结构变了,退回快捷键更安全 if (buttons.length < 1 || buttons.length > 4) return null; const button = buttons[buttons.length - 1]; if (!button || button.disabled) return null; return button; } catch (e) { return null; } } function tryAutoSave(manual) { const inst = getInstance(); if (!inst) { if (manual) setStatus('平台未就绪', 'warn'); return; } if (!inst.canEdit) { setStatus('只读账号,无法保存', 'warn'); return; } const sm = getSM(); if (!sm) { if (manual) setStatus('平台未就绪', 'warn'); return; } let status = null; try { status = sm.statusState(); } catch (e) { status = null; } if (status && status.isMarksObtained === false) { if (manual) setStatus('标注数据加载中…', 'warn'); return; } if (status && status.multiFrame) { setStatus('多帧合并中,已跳过', 'warn'); return; } if (status && status.disabledShortKey) { setStatus('弹窗打开中,已跳过', 'warn'); return; } if (Date.now() < savingUntil) { if (manual) setStatus('正在保存…', 'warn'); return; } const dirty = isDirty(); // 自动保存只在有改动时触发,避免每隔几分钟弹一次“无需保存” // 手动保存即使没改动也触发,让平台给出“已同步至最新”的明确反馈 if (!manual && !dirty) return; if (!triggerSave()) { setStatus('保存请求失败', 'error'); return; } pendingSave = { at: Date.now(), baselineDirty: dirty }; savingUntil = Date.now() + 4000; setStatus('正在保存…', 'info', 5000); } function scheduleAutoSave() { autoGeneration++; if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; } if (!settings.autoSave) { nextSaveAt = 0; updateFooter(); return; } const ms = intervalMs(); nextSaveAt = Date.now() + ms; const generation = autoGeneration; const tick = function () { if (generation !== autoGeneration) return; try { if (!document.hidden) tryAutoSave(false); } catch (e) { console.error('[框数助手] 自动保存失败', e); } nextSaveAt = Date.now() + ms; autoTimer = setTimeout(tick, ms); updateFooter(); }; autoTimer = setTimeout(tick, ms); updateFooter(); } function checkPendingSave() { if (!pendingSave) return; const elapsed = Date.now() - pendingSave.at; if (!pendingSave.baselineDirty) { // 手动保存但本来就没改动:平台通常会提示“已同步至最新”,没捕获到就在这里兜底 if (elapsed > 1200) { setStatus('已同步至最新', 'success', 5000); pendingSave = null; savingUntil = 0; } return; } if (!isDirty()) { setStatus('保存成功', 'success', 6000); pendingSave = null; savingUntil = 0; return; } if (elapsed > 12000) { setStatus('保存状态未确认,请检查网络或平台提示', 'warn', 7000); pendingSave = null; savingUntil = 0; } } function setupMessageObserver() { if (messageObserverStarted || !document.body) return; messageObserverStarted = true; const observer = new MutationObserver(function (mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; const messages = []; if (node.matches && node.matches('.el-message')) messages.push(node); if (node.querySelectorAll) { const nested = node.querySelectorAll('.el-message'); for (const el of nested) messages.push(el); } for (const el of messages) handlePlatformMessage(el); } } }); observer.observe(document.body, { childList: true, subtree: true }); } function handlePlatformMessage(el) { const content = el.querySelector('.el-message__content') || el; const text = (content.textContent || '').trim(); if (!text) return; const isError = el.classList.contains('el-message--error'); const isSaveText = /保存成功|保存失败|无需保存|已同步至最新|保存出错/.test(text); const withinSave = pendingSave && Date.now() - pendingSave.at < 15000; if (!isSaveText && !(isError && withinSave)) return; pendingSave = null; savingUntil = 0; if (isError || /保存失败|保存出错/.test(text)) { setStatus(text, 'error', 8000); } else { setStatus(text, 'success', 6000); } } // ========== 界面 ========== let els = null; let refreshKey = ''; let booted = false; function buildUI() { if (document.getElementById('bch-panel')) return false; if (!document.getElementById('bch-style')) { const style = document.createElement('style'); style.id = 'bch-style'; style.textContent = ` #bch-panel, #bch-ball, #bch-panel * { box-sizing: border-box; } #bch-panel { position: fixed; top: 12px; right: 12px; width: 256px; display: flex; flex-direction: column; background: #232323; color: #e8e8e8; border: 1px solid #3a3a3a; border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.38); font: 12px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; z-index: 999999; overflow: hidden; user-select: none; max-height: calc(100vh - 8px); } #bch-panel .bch-head { display:flex; align-items:center; gap:4px; padding:7px 8px 7px 10px; border-bottom:1px solid #343434; cursor:move; } #bch-panel .bch-title { flex:1; font-size:12.5px; font-weight:600; color:#f2f2f2; white-space:nowrap; letter-spacing:.2px; } #bch-panel .bch-icon { width:24px; height:24px; padding:0; border:0; border-radius:5px; background:transparent; color:#a9a9a9; font-size:14px; line-height:24px; text-align:center; cursor:pointer; } #bch-panel .bch-icon:hover { background:#343434; color:#fff; } #bch-panel .bch-switch { position:relative; display:inline-block; width:34px; height:18px; flex:none; cursor:pointer; } #bch-panel .bch-switch input { position:absolute; opacity:0; width:0; height:0; } #bch-panel .bch-slider { position:absolute; inset:0; background:#5a5a5a; border-radius:10px; transition:background .2s; } #bch-panel .bch-slider::before { content:""; position:absolute; width:14px; height:14px; left:2px; top:2px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(0,0,0,.35); } #bch-panel .bch-switch input:checked + .bch-slider { background:#2ecc71; } #bch-panel .bch-switch input:checked + .bch-slider::before { transform:translateX(16px); } #bch-panel .bch-switch input:disabled + .bch-slider { opacity:.45; cursor:not-allowed; } #bch-panel .bch-settings { padding:8px 10px; background:#262626; border-bottom:1px solid #343434; } #bch-panel .bch-set-row { display:flex; align-items:center; gap:6px; color:#bdbdbd; font-size:11px; } #bch-panel .bch-set-row input, #bch-panel .bch-set-row select { background:#1a1a1a; color:#e8e8e8; border:1px solid #404040; border-radius:4px; padding:2px 4px; font-size:11px; outline:none; } #bch-panel .bch-set-row input { width:52px; } #bch-panel .bch-set-row select { flex:1; min-width:0; } #bch-panel .bch-hint { margin-top:6px; font-size:10px; color:#777; line-height:1.4; } #bch-panel .bch-tabs { display:flex; gap:2px; margin:8px 10px 0; padding:2px; background:#1b1b1b; border-radius:6px; } #bch-panel .bch-tab { flex:1; text-align:center; padding:3px 0; border-radius:4px; color:#9a9a9a; font-size:11px; cursor:pointer; } #bch-panel .bch-tab:hover { color:#ddd; } #bch-panel .bch-tab.active { background:#383838; color:#fff; box-shadow:0 1px 2px rgba(0,0,0,.3); } #bch-panel .bch-total { display:flex; align-items:flex-end; justify-content:space-between; gap:8px; padding:8px 12px 2px; } #bch-panel .bch-num { font-size:26px; font-weight:700; line-height:1; color:#fff; font-variant-numeric:tabular-nums; letter-spacing:-.5px; } #bch-panel .bch-unit { margin-left:3px; font-size:11px; color:#8d8d8d; font-weight:400; } #bch-panel .bch-sub { font-size:10px; color:#7d7d7d; white-space:nowrap; padding-bottom:2px; } #bch-panel .bch-list { margin:4px 10px 8px; max-height:188px; overflow-y:auto; } #bch-panel .bch-row { display:flex; align-items:center; gap:6px; padding:3px 2px; min-height:20px; } #bch-panel .bch-row:hover { background:#2b2b2b; border-radius:4px; } #bch-panel .bch-dot { width:7px; height:7px; border-radius:50%; flex:none; } #bch-panel .bch-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#cfcfcf; font-size:11.5px; } #bch-panel .bch-count { color:#fff; font-weight:600; font-size:11.5px; font-variant-numeric:tabular-nums; } #bch-panel .bch-empty { padding:12px 0; text-align:center; color:#777; font-size:11px; } #bch-panel .bch-foot { display:flex; align-items:center; gap:6px; padding:6px 10px; background:#202020; border-top:1px solid #343434; } #bch-panel .bch-status-dot { width:6px; height:6px; border-radius:50%; background:#2ecc71; flex:none; } #bch-panel .bch-foot-text { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#8f8f8f; font-size:10px; } #bch-panel .bch-save { border:1px solid #3c5a45; background:#28382d; color:#9fd6ae; border-radius:4px; padding:2px 7px; font-size:10px; cursor:pointer; } #bch-panel .bch-save:hover { background:#324737; } #bch-panel .bch-save:disabled { opacity:.4; cursor:not-allowed; } #bch-panel .bch-list::-webkit-scrollbar { width:4px; } #bch-panel .bch-list::-webkit-scrollbar-thumb { background:#4a4a4a; border-radius:2px; } #bch-ball { position: fixed; width:46px; height:46px; display:none; flex-direction:column; align-items:center; justify-content:center; background:rgba(35,35,35,.94); border:1px solid #444; border-radius:50%; box-shadow:0 4px 14px rgba(0,0,0,.35); color:#fff; cursor:grab; z-index:999998; user-select:none; backdrop-filter:blur(4px); } #bch-ball:active { cursor:grabbing; } #bch-ball .bch-ball-num { font-size:12px; font-weight:700; line-height:1.1; max-width:40px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #bch-ball .bch-ball-label { font-size:8px; color:#8f8f8f; line-height:1.1; } #bch-ball .bch-ball-dot { position:absolute; top:3px; right:4px; width:6px; height:6px; border-radius:50%; background:#2ecc71; } #bch-panel [hidden] { display:none !important; } `; document.head.appendChild(style); } const panel = document.createElement('div'); panel.id = 'bch-panel'; panel.innerHTML = `