// ==UserScript== // @name 标注校验插件 // @namespace http://tampermonkey.net/ // @version 9.5 // @description 2D遮挡+3D可见性+3D遮挡校验+2D/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': '右鱼眼' }; let isAutoValidating = false; let lastFrameKey = ''; let autoEnabled = true; let panel = null; let panelReady = false; // 2D/3D框属性扫描 / 删除相关状态 let scanItems = []; // 当前扫描出的“无属性/缺属性”2D/3D框 let scanFrameId = null; // 扫描时的帧 let scanHint = ''; // 提示信息 let deleting3D = false; // 删除进行中标记 let lastScanBadge = null; // 'none' | 'partial' | 'ok' // ========== 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; } const VALID_OCCLUSION_CODES = new Set(['0', '1', '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 !== 'object') { return { errors: [], views: ['前鱼眼', '前广角', '后鱼眼', '左鱼眼', '右鱼眼'] }; } let getShowAttrs = null; try { getShowAttrs = store.getters['config/getShowAttrs']; } catch (e) { } let getClassName = null; try { getClassName = store.getters['config/className']; } catch (e) { } let getClassId = null; try { getClassId = store.getters['marks/classId']; } catch (e) { } let attrsDirection = false; try { attrsDirection = store.state.status.attrsDirection; } catch (e) { } let curFrameId = null; try { curFrameId = store.state.status.curFrameId; } catch (e) { } if (!curFrameId) { 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 classId = null; if (getClassId) { try { classId = getClassId(instKey); } catch (e) { } } if (!classId && instance.classId) classId = instance.classId; let class_name = instance.class || instKey; let trackId = instKey.split('__')[0].split('-').pop(); let display_name = class_name + '-' + trackId; if (getClassName && classId) { try { let toolType = box.type || instance.type || 'rect2d'; let cn = getClassName(classId, toolType); if (cn) display_name = cn + '-' + trackId; } catch (e) { } } let attrs = box.attrs; if (!attrs) continue; if (typeof attrs === 'string') { try { attrs = JSON.parse(attrs); } catch (e) { } } 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) && !VALID_OCCLUSION_CODES.has(occValue)) { errors.push({ type: '2d', view: viewName, instKey: instKey, displayName: display_name, cameraKey: camera, classId: classId, message: `[2D] ${viewName} - ${display_name}:遮挡显示为"${occValue}"` }); } } } else { 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({ type: '2d', view: viewName, instKey: instKey, displayName: display_name, cameraKey: camera, classId: classId, message: `[2D] ${viewName} - ${display_name}:遮挡值"${occlusionStr}"` }); } } } } } } return { errors, views: Array.from(checkedViews) }; } // ========== 3. 3D可见性 + 遮挡校验 ========== function check3DVisibility() { let labels3D = document.querySelectorAll('.threejs-label'); let missing = []; let occlusionErrors = []; let count = 0; labels3D.forEach((label, idx) => { let text = (label.textContent || '').trim(); if (text.length === 0) return; if (!text.includes(':') && !text.includes(':')) return; count++; let firstLine = text.split('\n')[0].trim(); if (!text.includes('可见性')) { missing.push({ labelText: firstLine, labelIndex: idx, message: `${firstLine}:缺少"可见性"字段` }); } let occMatch = text.match(/遮挡[::]\s*(.{1,30})/); if (occMatch) { let occValue = occMatch[1].trim(); occValue = occValue.split(/[\n_]|截断|运动|车门|种类|可见性/)[0].trim(); if (!occValue.includes('可见')) { occlusionErrors.push({ labelText: firstLine, labelIndex: idx, message: `${firstLine}:遮挡显示为"${occValue}"` }); } } }); return { count, missing, occlusionErrors }; } // ========== 3b. 2D/3D框属性扫描(无属性 / 缺属性) ========== // 规则与平台内部一致: // attrs 为空 -> 属性未设置 // 缺少任一必填属性 -> 属性未设置完整 // 必填属性来自 store.getters['config/getAttrsByClassId'](3D工具类型, classId) // 平台 isPreMark 判定: id 存在且不以 "y4u-" 开头 // 3D 结构里实例节点(含 '3d')与帧标记对象都可能带 id,两处都看 function isPreMarkObj(mark, inst) { try { let ids = []; if (mark && mark.id) ids.push(String(mark.id)); if (inst && inst.id) ids.push(String(inst.id)); for (let i = 0; i < ids.length; i++) { if (ids[i].indexOf('y4u-') !== 0) return true; } return false; } catch (e) { return false; } } function getCurFrameIdSafe(store) { try { let fid = store.state.status.curFrameId; if (fid !== null && fid !== undefined && fid !== '') return fid; } catch (e) { } return null; } function parseAttrs(v) { if (!v) return {}; if (typeof v === 'string') { try { return JSON.parse(v) || {}; } catch (e) { return {}; } } if (typeof v === 'object') return v; return {}; } // 帧号在对象里是字符串键,但 store 里可能存成数字,必须宽松取值 function getFrameMark(bucket, frameId) { let m = null; try { m = bucket[frameId]; } catch (e) { m = null; } if (!m) { try { m = bucket[String(frameId)]; } catch (e) { m = null; } } if (!m) { try { m = bucket[Number(frameId)]; } catch (e) { m = null; } } return m || null; } // 仅使用平台自己写入的 class 字段派生候选值(保持原生类型,不产生错误的数字键) function classIdCandidates(instKey, inst, mark, classKey) { let cands = []; try { if (inst && inst.class !== undefined && inst.class !== null) cands.push(inst.class); } catch (e) { } try { if (mark && mark.class !== undefined && mark.class !== null) cands.push(mark.class); } catch (e) { } // 容器键(仅当它不像工具类型名时才用:避免把 "volume" 误当成 classId) if (classKey !== null && classKey !== undefined && classKey !== '' && String(classKey).indexOf('volume') !== 0 && String(classKey).indexOf('line') !== 0 && String(classKey).indexOf('polygon') !== 0 && String(classKey).indexOf('rect') !== 0) { cands.push(classKey); } cands.push(instKey); let tail = String(instKey).split('__').pop(); if (tail === String(instKey)) { if (tail.length > 6) tail = tail.substring(tail.length - 6); } cands.push(tail); let out = []; for (let i = 0; i < cands.length; i++) { if (cands[i] === '' || cands[i] === null || cands[i] === undefined) continue; if (out.indexOf(cands[i]) === -1) out.push(cands[i]); } return out; } // 比较两个键数组是否有交集(用于 toolType 提示,而不是拼进候选列表) function arraysShare(a, b) { for (let i = 0; i < a.length; i++) { if (b.indexOf(a[i]) !== -1) return true; } return false; } // 返回 { known, needed[], missing[], empty, unknown, cls, toolType } // dim: '3d' | '2d';toolTypeHint: 该框自身的工具类型(2D 是 box.originalType/box.type) function analyzeBoxAttrs(store, instKey, inst, mark, classKey, frameId, dim, toolTypeHint) { let getAttrsByClassId = null; try { getAttrsByClassId = store.getters['config/getAttrsByClassId']; } catch (e) { } if (!getAttrsByClassId) return { known: false, needed: [], missing: [], empty: false, unknown: true, cls: null, toolType: null }; // 先只按这个框自己声明的类型去查,避免“工具类型”候选互相借配置 let toolTypes = []; if (toolTypeHint) toolTypes.push(toolTypeHint); let extra = []; if (mark && mark.type) extra.push(mark.type); if (mark && mark.originalType) extra.push(mark.originalType); if (inst && inst.type) extra.push(inst.type); for (let i = 0; i < extra.length; i++) { if (toolTypes.indexOf(extra[i]) === -1) toolTypes.push(extra[i]); } // 2D 框常见 type 是 rect / cuboid_2d,属性配置挂在 rect 上 if (dim === '2d' && toolTypes.indexOf('rect') === -1) toolTypes.push('rect'); if (dim === '3d' && toolTypes.indexOf('volume') === -1) toolTypes.push('volume'); // 帧号可能是数字或字符串,属性配置有时按数字键索引,两种都试 let frameVars = []; if (frameId !== null && frameId !== undefined) { frameVars.push(frameId); if (frameVars.indexOf(String(frameId)) === -1) frameVars.push(String(frameId)); let num = Number(frameId); if (!isNaN(num) && frameVars.indexOf(num) === -1) frameVars.push(num); } else { frameVars.push(null); } let cands = classIdCandidates(instKey, inst, mark, classKey); // 按优先级锁定 classId:第一个能查到“有效工具类型”的候选就是真实类别 let lockedClassId = null; let lockedToolType = null; for (let c = 0; c < cands.length && lockedClassId === null; c++) { for (let t = 0; t < toolTypes.length; t++) { // 候选类别若只是工具类型名(rect/volume/…),肯定不是 classId,跳过 if (String(cands[c]) === toolTypes[t]) continue; let cfg = null; try { cfg = getAttrsByClassId(toolTypes[t], cands[c], frameVars[0]); } catch (e) { cfg = null; } if (cfg === null || cfg === undefined) continue; lockedClassId = cands[c]; lockedToolType = toolTypes[t]; break; } } // 声明类型查不到时,再放宽到其它可能的工具类型兜底 if (lockedClassId === null) { let fallback = dim === '2d' ? ['rect', 'cuboid_2d'] : ['volume', 'line_3d', 'polygon_3d']; for (let c = 0; c < cands.length && lockedClassId === null; c++) { for (let t = 0; t < fallback.length; t++) { if (String(cands[c]) === fallback[t]) continue; let cfg = null; try { cfg = getAttrsByClassId(fallback[t], cands[c], frameVars[0]); } catch (e) { cfg = null; } if (cfg === null || cfg === undefined) continue; lockedClassId = cands[c]; lockedToolType = fallback[t]; break; } } } // 一个候选都查不到 -> 无法判定,跳过(绝不乱报) if (lockedClassId === null) { return { known: false, needed: [], missing: [], empty: false, unknown: true, cls: (cands.length ? cands[0] : null), toolType: null }; } // 用锁定的 classId 读取该类别真正需要的属性配置 let cfgFinal = null; for (let f = 0; f < frameVars.length; f++) { let tryCfg = null; try { tryCfg = getAttrsByClassId(lockedToolType, lockedClassId, frameVars[f]); } catch (e) { tryCfg = null; } if (tryCfg && Object.keys(tryCfg).length > 0) { cfgFinal = tryCfg; break; } if (cfgFinal === null && tryCfg) cfgFinal = tryCfg; } let keys = []; for (let k in (cfgFinal || {})) keys.push(k); // 该类别本身没有属性配置 -> 不需要属性,属正常框 if (keys.length === 0) { return { known: true, needed: [], missing: [], empty: false, unknown: false, cls: lockedClassId, toolType: lockedToolType }; } let attrs = parseAttrs(mark.attrs); let attrsIsEmpty = (function (o) { for (let k in o) return false; return true; })(attrs); let missing = []; for (let i = 0; i < keys.length; i++) { let key = keys[i]; let val = attrs[key]; if (val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0)) { missing.push(key); } } return { known: true, needed: keys, missing: missing, empty: attrsIsEmpty || missing.length === keys.length, unknown: false, cls: lockedClassId, toolType: lockedToolType }; } // 该项目是否开启了“属性跨维度/跨帧同步”(平台: isSyncAttrs) // 判定: getters['config/isSyncAttrs'](工具类型) === true 表示同步开启, // 此时属性只需在最早那帧填一次,后面帧(以及2D投影)不会各自存 attrs。 function getSyncAttrsFlag(store, toolType) { // 优先直接读类别配置里的 is_attrs_sync: // 平台实现是 isSyncAttrs = !classes[tool].is_attrs_sync, // 也就是该字段没配(false/undefined)时等于“开启了同步”。 try { let classes = store.state.classes; if (classes) { let t = toolType || 'volume'; let cls = classes[t] || classes['volume'] || classes['rect']; if (cls && cls.is_attrs_sync !== undefined && cls.is_attrs_sync !== null) { return !cls.is_attrs_sync; } } } catch (e) { } // 其次问平台自己的 getter(传工具类型) try { let fn = store.getters['config/isSyncAttrs']; if (typeof fn === 'function') { let v = fn(toolType || 'volume'); if (v !== undefined && v !== null) return !!v; } } catch (e) { } return null; // 未知 } function scan3DAttrs() { let store = getVueStore(); if (!store || !store.state || !store.state.marks) { return { ok: false, msg: '未取到平台数据(store.state.marks)', frameId: null, boxes: [], unknown: [], total: 0 }; } let frameId = getCurFrameIdSafe(store); if (frameId === null || frameId === undefined) { return { ok: false, msg: '未取到当前帧号', frameId: null, boxes: [], unknown: [], total: 0 }; } let getClassName = null; try { getClassName = store.getters['config/className']; } catch (e) { } // 平台 store 是响应式代理,for...in 可能枚举不全,统一用 Object.keys 取键数组遍历。 // 目录结构在不同平台版本里可能是 {toolType:{classId:{trackId:mark}}} 或 {toolType:{trackId:mark}}, // 所以这里逐层下探,遇到“带 '3d' 的叶子”就当成一个实例,兼容两种结构。 function safeKeys(obj) { let out = []; try { out = Object.keys(obj || {}); } catch (e) { out = []; } return out; } function isMarkLeaf(node) { if (!node || typeof node !== 'object') return false; if (node['3d'] && typeof node['3d'] === 'object') return true; if (node['2d'] && typeof node['2d'] === 'object') return true; return false; } let leaves = []; let seen = []; // 每次扫描都新建,只在“本次遍历”内防环 function collect(node, path, depth) { if (!node || typeof node !== 'object' || depth > 4) return; if (seen.indexOf(node) !== -1) return; seen.push(node); if (isMarkLeaf(node)) { leaves.push({ inst: node, path: path }); return; } let keys = safeKeys(node); for (let i = 0; i < keys.length; i++) { let k = keys[i]; let v = null; try { v = node[k]; } catch (e) { v = null; } if (!v || typeof v !== 'object') continue; collect(v, path.concat([k]), depth + 1); } } collect(store.state.marks, [], 0); let boxes = []; // 2D + 3D 异常框 let unknown = []; let total3d = 0; let total2d = 0; let firstEntries = []; // 每个实例的公共信息(名字、classId、路径) function baseInfo(leaf) { let instKey = leaf.path.length ? leaf.path[leaf.path.length - 1] : ''; let inst = leaf.inst; let classKey = leaf.path.length >= 2 ? leaf.path[leaf.path.length - 2] : null; return { instKey: instKey, inst: inst, classKey: classKey, scanPath: leaf.path }; } function resolveNames(base, mark, typeHint, dim) { let info = analyzeBoxAttrs(store, base.instKey, base.inst, mark, base.classKey, frameId, dim, typeHint); let classId = (info.cls !== null && info.cls !== undefined) ? info.cls : (base.classKey || base.instKey); let className = base.inst.class || base.classKey || base.instKey; if (getClassName) { try { let cn = getClassName(classId, info.toolType || typeHint || (mark && mark.type)); if (cn) className = cn; } catch (e) { } } let trackId = String(base.instKey).split('__')[0].split('-').pop(); return { info: info, classId: classId, className: className, trackId: trackId, displayName: className + '-' + trackId }; } for (let n = 0; n < leaves.length; n++) { let base = baseInfo(leaves[n]); if (firstEntries.length < 3) firstEntries.push(base.instKey); // ---- 3D ---- let marks3d = base.inst['3d']; if (marks3d && typeof marks3d === 'object') { let mark3d = getFrameMark(marks3d, frameId); if (mark3d && typeof mark3d === 'object') { total3d++; let nm = resolveNames(base, mark3d, mark3d.originalType || mark3d.type, '3d'); if (nm.info.unknown) { unknown.push({ instKey: base.instKey, displayName: nm.displayName, dim: '3d' }); } else if (nm.info.missing.length > 0) { let attrKeys = []; for (let k in parseAttrs(mark3d.attrs)) attrKeys.push(k); boxes.push({ dim: '3d', viewName: '', cameraKey: '', instKey: base.instKey, scanPath: base.scanPath, trackId: nm.trackId, classId: nm.classId, className: nm.className, displayName: nm.displayName, frameId: frameId, toolType: nm.info.toolType || mark3d.type || 'volume', needed: nm.info.needed, missing: nm.info.missing, attrKeys: attrKeys, empty: nm.info.empty, preMark: isPreMarkObj(mark3d, base.inst), hasPos: Array.isArray(mark3d.position) }); } } } // ---- 2D(一帧多视角,按相机分开算) ---- let marks2d = base.inst['2d']; if (!marks2d || typeof marks2d !== 'object') continue; let frame2d = getFrameMark(marks2d, frameId); if (!frame2d) continue; let list2d = []; if (Array.isArray(frame2d)) { list2d = frame2d; } else if (typeof frame2d === 'object') { // 兼容 {相机: [box,...]} 与 {相机: box} 两种 let ck = safeKeys(frame2d); for (let i = 0; i < ck.length; i++) { let v = frame2d[ck[i]]; if (Array.isArray(v)) { for (let j = 0; j < v.length; j++) list2d.push(v[j]); } else if (v && typeof v === 'object') { list2d.push(v); } } } for (let b = 0; b < list2d.length; b++) { let box = list2d[b]; if (!box || typeof box !== 'object') continue; total2d++; let cameraKey = box.camera || ''; let viewName = VIEW_NAMES[cameraKey] || cameraKey || '未知视角'; let typeHint = box.originalType || box.type || (base.inst && base.inst.type) || 'rect'; let nm = resolveNames(base, box, typeHint, '2d'); if (nm.info.unknown) { unknown.push({ instKey: base.instKey, displayName: nm.displayName + ' [' + viewName + ']', dim: '2d' }); continue; } if (nm.info.missing.length === 0) continue; let attrKeys2 = []; for (let k in parseAttrs(box.attrs)) attrKeys2.push(k); boxes.push({ dim: '2d', viewName: viewName, cameraKey: cameraKey, instKey: base.instKey, scanPath: base.scanPath, trackId: nm.trackId, classId: nm.classId, className: nm.className, displayName: nm.displayName + ' [' + viewName + ']', frameId: frameId, toolType: nm.info.toolType || typeHint, needed: nm.info.needed, missing: nm.info.missing, attrKeys: attrKeys2, empty: nm.info.empty, preMark: isPreMarkObj(box, base.inst), hasPos: Array.isArray(box.points) }); } } return { ok: true, frameId: frameId, boxes: boxes, unknown: unknown, total: total3d + total2d, total2d: total2d, total3d: total3d, firstEntries: firstEntries }; } // ========== 3c. 删除2D/3D框(可绕过平台锁定) ========== function resolveMarksNamespace(store) { try { if (store && store._mutations && store._mutations['marks/deleteMarks']) return 'marks/deleteMarks'; } catch (e) { } try { if (store && store._mutations && store._mutations['deleteMarks']) return 'deleteMarks'; } catch (e) { } return 'marks/deleteMarks'; } // 按扫描时记录的路径找到“承载某个 trackId 的实例节点” function resolveContainerByPath(store, path) { let node = store.state.marks; for (let i = 0; i < path.length - 1; i++) { if (!node) return null; try { node = node[path[i]]; } catch (e) { return null; } } return node || null; } // 取“这个实例在该帧的标记”,同时兼容两种结构: // state.marks[toolType][classId][trackId] // state.marks[toolType][trackId] // state.marks[trackId] function find3DMarkAtFrame(store, instKey, frameId, scanPath) { let node = findAnyMarkAtFrame(store, instKey, frameId, scanPath); if (node && node['3d']) { let mk = getFrameMark(node['3d'], frameId); if (mk) return { mark: mk, container: node }; } return { mark: null, container: null }; } // 判断某个框现在是否还在(3D 看帧键;2D 看该相机下的数组项) function boxStillExists(store, item) { let mark = findAnyMarkAtFrame(store, item.instKey, item.frameId, item.scanPath); if (!mark) return false; if (item.dim === '3d') { return !!(mark['3d'] && getFrameMark(mark['3d'], item.frameId)); } if (!mark['2d']) return false; let frame2d = getFrameMark(mark['2d'], item.frameId); if (!frame2d) return false; let list = []; if (Array.isArray(frame2d)) list = frame2d; else if (typeof frame2d === 'object') { let ks = []; try { ks = Object.keys(frame2d); } catch (e) { ks = []; } for (let i = 0; i < ks.length; i++) { let v = frame2d[ks[i]]; if (Array.isArray(v)) list = list.concat(v); else if (v && typeof v === 'object') list.push(v); } } for (let i = 0; i < list.length; i++) { let b = list[i]; if (!b || typeof b !== 'object') continue; if (item.cameraKey) { if (b.camera === item.cameraKey) return true; } else { return true; } } return false; } // 找一个实例节点(同时带 3d/2d 的容器) function findAnyMarkAtFrame(store, instKey, frameId, scanPath) { let roots = []; try { roots.push(store.state.marks); } catch (e) { } try { if (store.state.marks) roots.push(store.state.marks.volume); } catch (e) { } try { let vol = store.state.marks && store.state.marks.volume; if (vol) { let classKeys = Object.keys(vol); for (let i = 0; i < classKeys.length; i++) { let c = vol[classKeys[i]]; if (c && typeof c === 'object') roots.push(c); } } } catch (e) { } for (let r = 0; r < roots.length; r++) { let node = null; try { node = roots[r] && roots[r][instKey]; } catch (e) { node = null; } if (node && (node['3d'] || node['2d'])) return node; } try { let container = resolveContainerByPath(store, scanPath || []); if (container) { let node = container[instKey]; if (node && (node['3d'] || node['2d'])) return node; } } catch (e) { } return null; } // 删除一个框:dim='3d' 删当前帧的3D框;dim='2d' 只删指定相机在当前帧的2D框 function doDeleteBox(item, bypassLock) { let store = getVueStore(); if (!store || !store.commit) return { ok: false, msg: '未取到Vuex Store' }; let frameId = item.frameId; if (frameId === null || frameId === undefined || frameId === '') return { ok: false, msg: '缺少帧号,无法删除' }; if (!boxStillExists(store, item)) { return { ok: false, msg: '该帧已找不到这个' + (item.dim === '2d' ? '2D' : '3D') + '框(可能已被删除)' }; } let type = resolveMarksNamespace(store); let dim = item.dim === '2d' ? '2d' : '3d'; let camera = dim === '2d' ? (item.cameraKey || '') : null; try { // 参数: [trackId, frameId, dimension, cameraKey, noMaps, delete2DToo, bypassLock] // - dim=3d: 删这一帧的3D框 // - dim=2d: cameraKey 非空 -> 只删这个相机/视角的2D框,其它视角不动 // 第7个参数 = true 时跳过平台的 isAllowOpera 锁定校验(预标注锁) store.commit(type, [item.instKey, frameId, dim, camera, false, false, !!bypassLock]); } catch (e) { return { ok: false, msg: '提交删除失败: ' + (e && e.message ? e.message : e) }; } // 立刻回读校验是否真的删掉了 if (boxStillExists(store, item)) { return { ok: false, msg: '平台未删除(可能被锁定/无编辑权限)', locked: true, bypassed: !!bypassLock }; } return { ok: true, bypassed: !!bypassLock }; } function deleteAllScanned() { if (deleting3D) return; let store = getVueStore(); if (!store || !store.state || !store.state.marks || !scanItems || scanItems.length === 0) return; let frameId = getCurFrameIdSafe(store); if (String(frameId) !== String(scanFrameId)) { window.alert('帧已切换,请先重新扫描后再删除。'); return; } let n2 = 0, n3 = 0; for (let i = 0; i < scanItems.length; i++) { if (scanItems[i].dim === '2d') n2++; else n3++; } let what = (n2 ? n2 + ' 个2D框' : '') + (n2 && n3 ? ' + ' : '') + (n3 ? n3 + ' 个3D框' : ''); if (!window.confirm('确定删除当前帧扫描出的 ' + what + '?\n(2D只删该框所在视角的框,其它视角不动;删除后如需还原请用撤销 Ctrl+Z)')) return; runDeleteAll(); } function runDeleteAll() { let store = getVueStore(); if (!store || !scanItems || scanItems.length === 0) return; deleting3D = true; let list = scanItems.slice(); let bypass = window.confirm('删除时是否绕过平台“锁定/预标注锁”?\n\n【确定】= 强制删除(含被锁定的框)\n【取消】= 只删平台允许删除的框'); let idx = 0; let okCount = 0; let failCount = 0; let bypassCount = 0; let stopReason = ''; function showProgress() { if (typeof window.__chkSetLastProgress === 'function') { window.__chkSetLastProgress('正在删除 ' + Math.min(idx + 1, list.length) + '/' + list.length + ' …(成功 ' + okCount + ',失败 ' + failCount + ')'); } } function step() { if (idx >= list.length) { deleting3D = false; if (typeof window.__chkSetLastProgress === 'function') { window.__chkSetLastProgress('删除完成:成功 ' + okCount + ',失败 ' + failCount + (bypassCount ? ',其中强制删除 ' + bypassCount : '') + (stopReason ? ';' + stopReason : '')); } setTimeout(function () { doScan3DAttrs(); }, 400); return; } // 帧被切走就立刻停,避免误删别的帧 let now = getCurFrameIdSafe(store); if (String(now) !== String(scanFrameId)) { stopReason = '已停止:第 ' + (idx + 1) + ' 个时检测到切帧'; idx = list.length; deleting3D = false; if (typeof window.__chkSetLastProgress === 'function') { window.__chkSetLastProgress('删除中断:成功 ' + okCount + ',失败 ' + failCount + ';' + stopReason); } setTimeout(function () { doScan3DAttrs(); }, 300); return; } let item = list[idx]; let res = doDeleteBox(item, bypass); if (res.ok) { okCount++; if (res.bypassed) bypassCount++; } else { failCount++; } idx++; showProgress(); setTimeout(step, 60); } showProgress(); step(); } function deleteOne3DBox(btn) { if (deleting3D) return; let idx = parseInt(btn.getAttribute('data-scan-idx')); let item = scanItems[idx]; if (!item) return; let store = getVueStore(); if (!store || !store.state || !store.state.marks) return; let frameId = getCurFrameIdSafe(store); if (String(frameId) !== String(item.frameId)) { window.alert('帧已切换,请先重新扫描后再删除。'); return; } let missText = item.missing.length === 0 ? '无' : (item.missing.join(', ') + (item.missing.length > 8 ? ' …' : '')); let dimText = item.dim === '2d' ? '2D框' : '3D框'; let extra = item.dim === '2d' ? '\n(只删该视角的2D框,其它视角不动)' : ''; if (!window.confirm('删除这个' + dimText + '?\n\n' + item.displayName + '(帧 ' + item.frameId + ')\n缺少属性: ' + missText + extra)) return; deleting3D = true; btn.textContent = '删除中'; btn.disabled = true; // 先按平台正常规则删;失败再询问是否强制绕过锁定 let res = doDeleteBox(item, false); if (!res.ok) { let force = window.confirm('正常删除失败:' + res.msg + '\n\n是否强制删除(绕过锁定)?'); if (force) res = doDeleteBox(item, true); } deleting3D = false; if (res.ok) { btn.textContent = res.bypassed ? '已强删' : '已删除'; setTimeout(function () { doScan3DAttrs(); }, 250); } else { btn.textContent = '删除'; btn.disabled = false; window.alert('删除失败:' + res.msg); } } // ========== 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-status-badge { display: none; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 8px; font-size: 11px; font-weight: bold; margin-left: 8px; white-space: nowrap; } .chk-status-badge.chk-status-validating { display: flex; background: rgba(255,193,7,0.15); color: #ffc107; border: 1px solid rgba(255,193,7,0.3); } .chk-status-badge.chk-status-done { display: flex; background: rgba(78,204,163,0.15); color: #4ecca3; border: 1px solid rgba(78,204,163,0.3); } .chk-status-badge.chk-status-error { display: flex; background: rgba(233,69,96,0.15); color: #e94560; border: 1px solid rgba(233,69,96,0.3); } #chk-scroll { max-height: calc(70vh - 50px); overflow-y: auto; } #chk-scroll::-webkit-scrollbar { width: 6px; } #chk-scroll::-webkit-scrollbar-track { background: transparent; } #chk-scroll::-webkit-scrollbar-thumb { background: rgba(0,210,255,0.3); border-radius: 3px; } #chk-scroll::-webkit-scrollbar-thumb:hover { background: rgba(0,210,255,0.5); } #chk-content { padding: 12px 14px 0; } .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: rgba(78,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, transform 0.1s; cursor: pointer; position: relative; } .chk-warn-item:hover { background: rgba(233,69,96,0.18); transform: translateX(2px); } .chk-warn-item:active { transform: translateX(0); } .chk-warn-item::after { content: '点击定位'; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; color: #8892b0; opacity: 0; transition: opacity 0.2s; } .chk-warn-item:hover::after { opacity: 1; } .chk-warn-item.chk-located { background: rgba(255,193,7,0.1); border-left-color: #ffc107; } .chk-warn-item.chk-located::before { content: '📍'; margin-right: 4px; font-size: 12px; } .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; } /* ===== 3D属性扫描 / 删除 ===== */ .chk-btn-del-scan { background: linear-gradient(135deg, #e94560 0%, #a3162f 100%); color: #fff; border: none; padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: bold; transition: all 0.2s; margin-left: 6px; box-shadow: 0 2px 8px rgba(233,69,96,0.3); } .chk-btn-del-scan:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(233,69,96,0.5); } .chk-btn-del-scan:active { transform: translateY(0); } .chk-btn-del-scan:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .chk-scan-block { margin-top: 12px; border: 1px solid rgba(255,193,7,0.25); border-radius: 8px; padding: 10px 10px 8px; background: rgba(255,193,7,0.05); } .chk-scan-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 8px; } .chk-scan-head-title { color: #ffc107; font-weight: bold; font-size: 13px; } .chk-scan-head-right { display: flex; align-items: center; gap: 6px; } .chk-scan-count { color: #8892b0; font-size: 11px; margin-right: 2px; } .chk-btn-del-all { background: linear-gradient(135deg, #e94560 0%, #8e0f24 100%); color: #fff; border: none; padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: bold; transition: all 0.2s; box-shadow: 0 2px 8px rgba(233,69,96,0.3); } .chk-btn-del-all:hover { transform: translateY(-1px); } .chk-btn-del-all:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .chk-scan-item { background: rgba(255,193,7,0.07); border-left: 3px solid #ffc107; border-radius: 0 6px 6px 0; margin-bottom: 6px; padding: 7px 8px; } .chk-scan-item.chk-scan-strong { background: rgba(233,69,96,0.10); border-left-color: #e94560; } .chk-scan-row1 { display: flex; align-items: center; justify-content: space-between; gap: 6px; } .chk-scan-name { color: #eee; font-size: 13px; font-weight: bold; cursor: pointer; flex: 1; word-break: break-all; } .chk-scan-name:hover { color: #00d2ff; text-decoration: underline; } .chk-scan-name::after { content: ' 定位'; font-size: 10px; color: #8892b0; } .chk-btn-del-one { background: rgba(233,69,96,0.18); color: #ff8fa3; border: 1px solid rgba(233,69,96,0.5); padding: 2px 9px; border-radius: 5px; cursor: pointer; font-size: 11px; white-space: nowrap; transition: all 0.2s; } .chk-btn-del-one:hover { background: rgba(233,69,96,0.45); color: #fff; } .chk-btn-del-one:disabled { opacity: 0.6; cursor: not-allowed; } .chk-scan-miss { color: #ffb3b3; font-size: 11px; line-height: 1.6; margin-top: 3px; word-break: break-all; } .chk-scan-tag { display: inline-block; padding: 0 5px; border-radius: 8px; font-size: 10px; background: rgba(255,193,7,0.18); color: #ffc107; margin-right: 4px; border: 1px solid rgba(255,193,7,0.35); } .chk-scan-tag.chk-tag-red { background: rgba(233,69,96,0.18); color: #ff8fa3; border-color: rgba(233,69,96,0.35); } .chk-scan-tag.chk-tag-dim2d { background: rgba(0,210,255,0.15); color: #00d2ff; border-color: rgba(0,210,255,0.4); font-weight: bold; } .chk-scan-tag.chk-tag-dim3d { background: rgba(78,204,163,0.15); color: #4ecca3; border-color: rgba(78,204,163,0.4); font-weight: bold; } .chk-scan-sub { font-size: 12px; font-weight: bold; margin: 8px 0 5px; padding-left: 2px; letter-spacing: 0.5px; } .chk-scan-foot { color: #8892b0; font-size: 11px; line-height: 1.6; margin-top: 4px; } .chk-progress { color: #ffc107; font-size: 12px; margin-top: 6px; line-height: 1.6; } .chk-ok-note { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(78,204,163,0.08); border: 1px solid rgba(78,204,163,0.25); color: #4ecca3; font-size: 12px; text-align: center; } /* ===== 面板分上下两段的分隔条 ===== */ .chk-sep { display: flex; align-items: center; justify-content: space-between; margin: 14px 12px 0; padding: 6px 2px 6px; border-top: 1px solid rgba(255,193,7,0.35); font-size: 12px; font-weight: bold; color: #ffc107; } .chk-sep-right { color: #8892b0; font-weight: normal; font-size: 11px; } `; document.head.appendChild(style); } // ========== 5. 创建面板 ========== // 一个面板,上下两段:上半部分是遮挡/可见性校验,下半部分是 2D/3D 缺属性校验 function createPanel() { if (panel && document.body.contains(panel)) return panel; injectStyles(); panel = document.createElement('div'); panel.id = 'chk-panel'; panel.innerHTML = `