// ==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 = `
标注校验
等待标注数据加载...
`; document.body.appendChild(panel); document.getElementById('chk-btn-check').addEventListener('click', function () { validate(false); }); document.getElementById('chk-btn-toggle').addEventListener('click', togglePanel); document.getElementById('chk-btn-del-scan').addEventListener('click', function () { doScan3DAttrs(); }); makeDraggable(); panelReady = true; return panel; } // ========== 6. 拖拽 ========== function makeDraggable() { makeDraggableFor(panel, 'chk-header'); } function makeDraggableFor(target, headerId) { const header = document.getElementById(headerId); if (!header || !target) return; let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; header.addEventListener('mousedown', function (e) { if (e.target.tagName === 'BUTTON') return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = target.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!isDragging) return; let newLeft = startLeft + (e.clientX - startX); let newTop = startTop + (e.clientY - startY); newLeft = Math.max(-260, Math.min(newLeft, window.innerWidth - 80)); newTop = Math.max(0, Math.min(newTop, window.innerHeight - 50)); target.style.left = newLeft + 'px'; target.style.top = newTop + 'px'; target.style.right = 'auto'; }); document.addEventListener('mouseup', function () { isDragging = false; }); } function togglePanel() { const content = document.getElementById('chk-scroll'); const btn = document.getElementById('chk-btn-toggle'); if (content.style.display === 'none') { content.style.display = ''; btn.innerHTML = '−'; } else { content.style.display = 'none'; btn.innerHTML = '+'; } } // ========== 7. 渲染结果 ========== let currentErrors = []; // 属性异常项在列表里的唯一键 & 归属的扫描项 function attrItemKey(box, seq) { return 'attr_' + (box.dim === '2d' ? '2d_' + (box.cameraKey || '') : '3d') + '_' + box.instKey + '_' + seq; } // 把扫描结果(2D/3D 无属性、缺属性)转成校验结果条目 function buildAttrErrors() { let out = []; if (!scanItems || scanItems.length === 0) return out; for (let i = 0; i < scanItems.length; i++) { let box = scanItems[i]; let miss = box.missing && box.missing.length ? box.missing.join('、') : '未知'; out.push({ type: 'attr', attrIndex: i, labelText: box.displayName, view: box.dim === '2d' ? box.viewName : '', instKey: box.instKey, cameraKey: box.dim === '2d' ? (box.cameraKey || '') : '', errKey: attrItemKey(box, i), message: '[' + (box.dim === '2d' ? '2D' : '3D') + '] ' + box.displayName + ':' + (box.empty ? '无属性' : '缺属性') + '(缺少 ' + miss + ')' }); } return out; } function renderResults(errors, info, isAuto) { if (!panelReady) createPanel(); const content = document.getElementById('chk-content'); const btn = document.getElementById('chk-btn-check'); btn.textContent = '校验当前帧'; btn.disabled = false; currentErrors = errors; // 本面板上半段只显示遮挡 / 可见性问题;2D/3D缺属性在下半段 let occErrors = errors.filter(function (e) { return e.type !== 'attr'; }); let hasErrors = occErrors.length > 0; let html = ''; let statsBorder = hasErrors ? 'rgba(233,69,96,0.3)' : 'rgba(78,204,163,0.3)'; let statsColor = hasErrors ? '#e94560' : '#4ecca3'; let statusBadge = hasErrors ? '有异常' : '通过'; if (isAuto) statusBadge += ' 自动'; html += `
① 遮挡 / 可见性校验
状态: ${statusBadge}
${info.join('
')}
`; if (hasErrors) { html += `
⚠ 遮挡/可见性问题(${occErrors.length}条,点击可定位)
`; for (let i = 0; i < errors.length; i++) { let err = errors[i]; if (err.type === 'attr') continue; let errKey = err.errKey ? err.errKey : (err.type === '3d' ? '3d_' + err.labelText : '2d_' + err.view + '_' + err.instKey); let locatedClass = errKey === lastLocatedKey ? ' chk-located' : ''; html += `
${err.message}
`; } } else { html += `
✓ 遮挡/可见性校验通过
`; } content.innerHTML = html; renderScanResults(); btn.textContent = '✓ 校验完成'; btn.disabled = false; let statusBadgeEl = document.getElementById('chk-status-badge'); if (statusBadgeEl) { if (hasErrors) { statusBadgeEl.className = 'chk-status-badge chk-status-error'; statusBadgeEl.textContent = '校验已完成'; } else { statusBadgeEl.className = 'chk-status-badge chk-status-done'; statusBadgeEl.textContent = '校验已完成'; } } if (hasErrors && !isAuto) { panel.classList.remove('chk-shake'); void panel.offsetWidth; panel.classList.add('chk-shake'); } content.querySelectorAll('.chk-warn-item').forEach(item => { item.addEventListener('click', function () { let idx = parseInt(this.getAttribute('data-err-idx')); let errKey = this.getAttribute('data-err-key'); lastLocatedKey = errKey; content.querySelectorAll('.chk-warn-item').forEach(el => el.classList.remove('chk-located')); this.classList.add('chk-located'); locateError(currentErrors[idx]); }); }); if (hasErrors && isAuto && autoEnabled) { setTimeout(function () { let firstItem = content.querySelector('.chk-warn-item'); if (firstItem) { firstItem.classList.add('chk-located'); lastLocatedKey = firstItem.getAttribute('data-err-key'); let idx = parseInt(firstItem.getAttribute('data-err-idx')); locateError(currentErrors[idx]); } }, 500); } } let lastLocatedKey = null; // ========== 7b. 2D/3D属性扫描结果渲染 / 删除 ========== function escapeHtml(s) { return String(s === null || s === undefined ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function scanCounts() { let n2 = 0, n3 = 0; for (let i = 0; i < scanItems.length; i++) { if (scanItems[i].dim === '2d') n2++; else n3++; } return { n2: n2, n3: n3 }; } function scanItemHtml(i) { let it = scanItems[i]; let strong = it.empty ? ' chk-scan-strong' : ''; let tag = it.empty ? '无属性' : '缺属性'; let dimTag = it.dim === '2d' ? '2D' : '3D'; let preTag = it.preMark ? '预标注' : ''; let missingText = it.missing.length ? '缺少: ' + it.missing.join(', ') : '缺少: (未知)'; let haveText = it.attrKeys.length ? '已有: ' + it.attrKeys.join(', ') : '已有: 无'; return `
${dimTag}${tag}${preTag}${escapeHtml(it.displayName)}
${escapeHtml(missingText)}
${escapeHtml(haveText)}
`; } function scanListHtml(indices, label, color) { if (indices.length === 0) return ''; let html = `
${label}(${indices.length})
`; for (let k = 0; k < indices.length; k++) { html += scanItemHtml(indices[k]); } return html; } // 下半段标题右侧的状态徽标 function setAttrBadge(mode) { let b = document.getElementById('chk-status-badge2'); if (!b) return; if (mode === 'err') { b.className = 'chk-status-badge chk-status-error'; b.textContent = '有缺属性'; } else if (mode === 'ok') { b.className = 'chk-status-badge chk-status-done'; b.textContent = '属性完整'; } else { b.className = 'chk-status-badge'; b.textContent = ''; } } function renderScanResults() { let wrap = document.getElementById('chk-scan3d'); if (!wrap) return; let html = ''; if (lastScanBadge === 'none') { setAttrBadge('none'); html += `
⚠ ② 2D/3D 缺属性校验未取到数据
`; html += `
未取到标注框数据${scanHint ? ':' + escapeHtml(scanHint) : ''}
`; wrap.innerHTML = html; return; } if (lastScanBadge === null) { setAttrBadge('none'); wrap.innerHTML = ''; return; } if (lastScanBadge === 'ok') { setAttrBadge('ok'); html += `
✓ ② 2D/3D 缺属性校验无缺属性
`; html += `
✓ 当前帧未发现无属性 / 缺属性的2D、3D框
`; } else { setAttrBadge('err'); let c = scanCounts(); let idx2 = [], idx3 = []; for (let i = 0; i < scanItems.length; i++) { if (scanItems[i].dim === '2d') idx2.push(i); else idx3.push(i); } html += `
⚠ ② 2D/3D 缺属性校验(${scanItems.length} 个:2D ${c.n2} / 3D ${c.n3}) 点击名称可定位
`; html += `
缺属性清单
`; html += scanListHtml(idx2, '2D框', '#00d2ff'); html += scanListHtml(idx3, '3D框', '#ffc107'); let it0 = scanItems[0]; html += `
帧 ${escapeHtml(it0.frameId)} / 本次扫描:3D框 ${it0.total3d || 0} 个、2D框 ${it0.total2d || 0} 个。
2D删除只删该框所在视角,其它视角不动。
删除后会立即重新扫描确认;删除失败会提示,可选择强制删除(绕过平台锁定)。
删除属破坏性操作,误删请用 Ctrl+Z 撤销。
`; } if (scanHint) { html += `
${escapeHtml(scanHint)}
`; } html += `
`; html += `
`; wrap.innerHTML = html; wrap.querySelectorAll('.chk-btn-del-one').forEach(function (btn) { btn.addEventListener('click', function (e) { e.stopPropagation(); deleteOne3DBox(this); }); }); wrap.querySelectorAll('.chk-scan-name').forEach(function (el) { el.addEventListener('click', function () { let i = parseInt(this.getAttribute('data-scan-name')); let it = scanItems[i]; if (it) locateScanItem(it); }); }); let allBtn = document.getElementById('chk-del-all'); if (allBtn) { allBtn.addEventListener('click', function (e) { e.stopPropagation(); deleteAllScanned(); }); } } function locateScanItem(it) { if (!it) return; let store = getVueStore(); if (store && store.commit) { try { let dim = it.dim === '2d' ? '2d' : '3d'; let cameraKey = dim === '2d' ? (it.cameraKey || '') : ''; store.commit('status/selectMarkInfo', [it.trackId, it.classId, dim, cameraKey, true]); } catch (e) { console.log('[校验插件] 属性异常定位失败:', e && e.message ? e.message : e); } } if (it.dim === '2d') return; // 2D 靠平台选中高亮即可 // 3D 标签高亮闪烁 setTimeout(function () { let labels = document.querySelectorAll('.threejs-label'); let want = String(it.className) + '-' + String(it.trackId); for (let i = 0; i < labels.length; i++) { let t = (labels[i].textContent || '').trim(); if (t.indexOf(want) === 0) { let label = labels[i]; let count = 0; let blink = setInterval(function () { count++; if (count % 2 === 0) { label.style.boxShadow = '0 0 0 4px #ffc107, 0 0 20px 8px rgba(255,193,7,0.6)'; } else { label.style.boxShadow = '0 0 0 2px #4ecca3, 0 0 10px 4px rgba(78,204,163,0.4)'; } if (count >= 6) { clearInterval(blink); setTimeout(function () { label.style.boxShadow = ''; }, 800); } }, 300); break; } } }, 200); } function doScan3DAttrs() { let btn = document.getElementById('chk-btn-del-scan'); let old = btn ? btn.textContent : ''; scanHint = ''; if (btn) { btn.textContent = '扫描中…'; btn.disabled = true; } setTimeout(function () { let res = null; try { res = scan3DAttrs(); } catch (e) { console.log('[校验插件] 属性扫描异常:', e && e.stack ? e.stack : e); res = { ok: false, msg: '扫描异常: ' + (e && e.message ? e.message : e), boxes: [], unknown: [], total: 0, total2d: 0, total3d: 0, frameId: null }; } if (!res || !res.ok) { scanItems = []; scanFrameId = null; lastScanBadge = 'none'; scanHint = (res && res.msg) ? res.msg : '未知原因'; } else { let t2 = res.total2d || 0, t3 = res.total3d || 0; scanItems = (res.boxes || []).map(function (b) { b.total = res.total; b.total2d = t2; b.total3d = t3; return b; }); scanFrameId = res.frameId; lastScanBadge = scanItems.length > 0 ? 'partial' : 'ok'; let parts = []; parts.push('本次扫描当前帧:3D框 ' + t3 + ' 个、2D框 ' + t2 + ' 个'); let syncFlag = getSyncAttrsFlag(getVueStore()); if (syncFlag === true) { parts.push('注意:本项目开启了属性同步(isSyncAttrs),属性只填在最早那帧,后续帧/2D投影的attrs常为空'); } else if (syncFlag === false) { parts.push('本项目属性不同步,每帧每个框都应各自带属性'); } if (res.unknown && res.unknown.length) { parts.push('另有 ' + res.unknown.length + ' 个框未找到属性配置(已跳过)'); } scanHint = parts.join(';'); console.log('[校验插件] 2D/3D属性扫描:', { frameId: res.frameId, total3D: t3, total2D: t2, badCount: scanItems.length, bad2D: scanItems.filter(function (b) { return b.dim === '2d'; }).length, bad3D: scanItems.filter(function (b) { return b.dim !== '2d'; }).length, unknownCount: (res.unknown || []).length, firstInstKeys: res.firstEntries || [], boxes: scanItems }); } if (btn) { btn.textContent = old || '扫2D/3D属性'; btn.disabled = false; } renderScanResults(); }, 30); } // 供批量删除过程更新进度文字 window.__chkSetLastProgress = function (text) { let el = document.getElementById('chk-progress'); if (el) el.textContent = text || ''; }; // ========== 7c. 诊断工具:为什么 2D/3D 没有属性 ========== // 控制台执行 window.__why2D() 查看当前帧每个2D框的属性来源与同步配置 // 控制台执行 window.__whyTrack(id) 查看某个 trackId 的 3D 属性与各视角 2D 属性 function diag2D() { let store = getVueStore(); if (!store || !store.state || !store.state.marks) { console.log('[诊断] 未取到平台数据'); return null; } let frameId = getCurFrameIdSafe(store); let marks = store.state.marks; let syncFlag = getSyncAttrsFlag(store); // 收集实例叶子 function safeKeys(o) { let r = []; try { r = Object.keys(o || {}); } catch (e) { } return r; } function isLeaf(n) { return !!(n && typeof n === 'object' && ((n['3d'] && typeof n['3d'] === 'object') || (n['2d'] && typeof n['2d'] === 'object'))); } let leaves = [], seen = []; (function col(node, path, depth) { if (!node || typeof node !== 'object' || depth > 4) return; if (seen.indexOf(node) !== -1) return; seen.push(node); if (isLeaf(node)) { leaves.push({ inst: node, path: path }); return; } let ks = safeKeys(node); for (let i = 0; i < ks.length; i++) { let v = null; try { v = node[ks[i]]; } catch (e) { } if (v && typeof v === 'object') col(v, path.concat([ks[i]]), depth + 1); } })(marks, [], 0); let rows = []; for (let i = 0; i < leaves.length; i++) { let leaf = leaves[i]; let inst = leaf.inst; let instKey = leaf.path.length ? leaf.path[leaf.path.length - 1] : ''; let classKey = leaf.path.length >= 2 ? leaf.path[leaf.path.length - 2] : null; let trackId = String(instKey).split('__')[0].split('-').pop(); let mark3d = (inst['3d'] && typeof inst['3d'] === 'object') ? getFrameMark(inst['3d'], frameId) : null; let attrs3d = mark3d ? parseAttrs(mark3d.attrs) : null; let n3d = attrs3d ? Object.keys(attrs3d).length : 0; let frame2d = (inst['2d'] && typeof inst['2d'] === 'object') ? getFrameMark(inst['2d'], frameId) : null; if (!frame2d) continue; let list = []; if (Array.isArray(frame2d)) list = frame2d; else { let ks = safeKeys(frame2d); for (let k = 0; k < ks.length; k++) { let v = frame2d[ks[k]]; if (Array.isArray(v)) list = list.concat(v); else if (v && typeof v === 'object') list.push(v); } } for (let b = 0; b < list.length; b++) { let box = list[b]; if (!box || typeof box !== 'object') continue; let attrs2d = parseAttrs(box.attrs); let keys2d = Object.keys(attrs2d); let cam = box.camera || ''; let info = analyzeBoxAttrs(store, instKey, inst, box, classKey, frameId, '2d', box.originalType || box.type || 'rect'); rows.push({ '实例': instKey, '类别': inst.class || classKey, '视角': VIEW_NAMES[cam] || cam, '2D框已有属性': keys2d.length ? keys2d.join(',') : '(空)', '需要属性': (info.needed || []).join(',') || '(该类别无需属性)', '缺少': (info.missing || []).join(','), '同帧3D框属性数': n3d, '同帧3D框属性': attrs3d ? (Object.keys(attrs3d).join(',') || '(空)') : '(该帧无3D框)', '判定': info.unknown ? '未知类别(已跳过)' : (info.missing.length ? (info.empty ? '无属性' : '缺属性') : '正常'), '工具类型': info.toolType || '' }); } } let res = { '当前帧': frameId, '属性同步_跨维度跨帧': syncFlag === null ? '未知(取不到isSyncAttrs)' : (syncFlag ? '开启 -> 属性只在最早帧填一次,后面帧的2D/3D框attrs通常为空,属正常!' : '关闭 -> 每个帧的每个2D/3D框都应各自带属性'), '2D框数': rows.length, '异常数': rows.filter(function (r) { return r['判定'] === '无属性' || r['判定'] === '缺属性'; }).length }; try { console.table(rows); } catch (e) { console.log(rows); } console.log('[诊断] 汇总', res); return { summary: res, rows: rows }; } // 直接对比某个 trackId 的 3D 与各视角 2D 属性 function diagTrack(trackId, frameId) { let store = getVueStore(); if (!store || !store.state || !store.state.marks) { console.log('[诊断] 未取到平台数据'); return null; } if (frameId === undefined || frameId === null) frameId = getCurFrameIdSafe(store); function safeKeys(o) { let r = []; try { r = Object.keys(o || {}); } catch (e) { } return r; } let found = null; let marks = store.state.marks; let cats = safeKeys(marks); for (let i = 0; i < cats.length && !found; i++) { let c = marks[cats[i]]; if (c && c[trackId]) { found = c[trackId]; break; } let ks = safeKeys(c); for (let j = 0; j < ks.length; j++) { let cc = c[ks[j]]; if (cc && cc[trackId]) { found = cc[trackId]; break; } } } if (!found) { console.log('[诊断] 找不到 trackId=' + trackId); return null; } let m3 = found['3d'] ? getFrameMark(found['3d'], frameId) : null; let out = { 'trackId': trackId, '帧': frameId, '类别': found.class, '3D框属性': m3 ? parseAttrs(m3.attrs) : '(该帧无3D框)', '属性同步': getSyncAttrsFlag(store) === null ? '未知' : (getSyncAttrsFlag(store) ? '开启' : '关闭'), '各视角2D属性': {} }; let f2 = found['2d'] ? getFrameMark(found['2d'], frameId) : null; if (f2) { let list = Array.isArray(f2) ? f2 : (function () { let acc = [], ks = safeKeys(f2); for (let k = 0; k < ks.length; k++) { if (Array.isArray(f2[ks[k]])) acc = acc.concat(f2[ks[k]]); else if (f2[ks[k]] && typeof f2[ks[k]] === 'object') acc.push(f2[ks[k]]); } return acc; })(); for (let i = 0; i < list.length; i++) { let cam = list[i].camera || ('视角' + i); out['各视角2D属性'][VIEW_NAMES[cam] || cam] = parseAttrs(list[i].attrs); } } console.log('[诊断] trackId=' + trackId, out); return out; } function locateError(err) { if (!err) return; let store = getVueStore(); // 属性异常(2D/3D无属性、缺属性):直接把扫描到的那一条交给定位函数 if (err.type === 'attr') { let it = scanItems[err.attrIndex]; if (it) { locateScanItem(it); lastLocatedKey = err.errKey || null; } else if (store && store.commit && err.instKey) { // 扫描结果已刷新时用兜底定位 try { store.commit('status/selectMarkInfo', [String(err.instKey).split('__')[0].split('-').pop(), null, err.cameraKey ? '2d' : '3d', err.cameraKey || null, true]); } catch (e) { } } return; } if (err.type === '3d') { let trackId = null; let classId = null; let match = err.labelText.match(/^(.+?)-(\d+)$/); if (match) { trackId = match[2]; let className = match[1]; if (store && store.getters) { try { for (let cat in store.state.marks) { let catData = store.state.marks[cat]; if (catData && catData[trackId]) { if (catData[trackId].class && catData[trackId].class === className) { classId = catData[trackId].classId || null; } break; } } } catch (e) { } } } if (trackId && store && store.commit) { try { store.commit('status/selectMarkInfo', [trackId, classId, '3d', null, true]); } catch (e) { console.log('[校验插件] 3D定位(Vuex)失败:', e.message); let labels = document.querySelectorAll('.threejs-label'); for (let i = 0; i < labels.length; i++) { let firstLine = (labels[i].textContent || '').split('\n')[0].trim(); if (firstLine === err.labelText) { labels[i].click(); break; } } } } lastLocatedKey = '3d_' + err.labelText; setTimeout(() => { let labels = document.querySelectorAll('.threejs-label'); for (let i = 0; i < labels.length; i++) { let firstLine = (labels[i].textContent || '').split('\n')[0].trim(); if (firstLine === err.labelText) { let label = labels[i]; label.style.transition = 'box-shadow 0.3s'; label.style.boxShadow = '0 0 0 4px #e94560, 0 0 20px 8px rgba(233,69,96,0.6)'; label.style.borderRadius = '6px'; let count = 0; let blink = setInterval(() => { count++; if (count % 2 === 0) { label.style.boxShadow = '0 0 0 4px #e94560, 0 0 20px 8px rgba(233,69,96,0.6)'; } else { label.style.boxShadow = '0 0 0 2px #4ecca3, 0 0 10px 4px rgba(78,204,163,0.4)'; } if (count >= 6) { clearInterval(blink); setTimeout(() => { label.style.boxShadow = ''; label.style.borderRadius = ''; }, 1000); } }, 300); break; } } }, 500); } else if (err.type === '2d') { let store = getVueStore(); if (store && store.commit) { try { let trackId = err.instKey.split('__')[0].split('-').pop(); let cameraKey = err.cameraKey || null; if (!cameraKey) { for (let k in VIEW_NAMES) { if (VIEW_NAMES[k] === err.view) { cameraKey = k; break; } } } let classId = err.classId || null; if (!classId && store.getters && store.getters['marks/classId']) { try { classId = store.getters['marks/classId'](err.instKey); } catch (e) { } } console.log('[校验插件] 2D定位: trackId=' + trackId + ', cameraKey=' + cameraKey + ', classId=' + classId); store.commit('status/selectMarkInfo', [trackId, classId, '2d', cameraKey, true]); lastLocatedKey = '2d_' + err.view + '_' + err.instKey; } catch (e) { console.log('[校验插件] 2D定位失败:', e.message); } } } } // ========== 8. 校验 ========== function validate(isAuto) { if (!panelReady) createPanel(); const content = document.getElementById('chk-content'); const btn = document.getElementById('chk-btn-check'); btn.textContent = '校验中...'; btn.disabled = true; let statusBadge = document.getElementById('chk-status-badge'); if (statusBadge) { statusBadge.className = 'chk-status-badge chk-status-validating'; statusBadge.textContent = '校验中'; } content.innerHTML = `
校验结果
状态: 校验中
🔍 正在校验...
2D遮挡 + 3D可见性 + 3D遮挡 + 2D/3D属性
`; setTimeout(function () { let errors = []; let info = []; let result2D = check2DOcclusion(); if (result2D.views.length > 0) { info.push(`2D遮挡校验(${result2D.views.join('/')})`); } errors = errors.concat(result2D.errors); let result3D = check3DVisibility(); info.push(`3D可见性+遮挡校验(共${result3D.count}个)`); for (let i = 0; i < result3D.missing.length; i++) { let m = result3D.missing[i]; errors.push({ type: '3d', labelIndex: m.labelIndex, labelText: m.labelText, message: `[3D] ${m.message}` }); } for (let i = 0; i < result3D.occlusionErrors.length; i++) { let m = result3D.occlusionErrors[i]; errors.push({ type: '3d', labelIndex: m.labelIndex, labelText: m.labelText, message: `[3D] ${m.message}` }); } // 2D/3D 属性校验(无属性 / 缺属性),与上面的遮挡校验一起出结果 let attrRes = null; try { attrRes = scan3DAttrs(); } catch (e) { console.log('[校验插件] 属性校验异常:', e && e.stack ? e.stack : e); attrRes = { ok: false, msg: '属性校验异常: ' + (e && e.message ? e.message : e) }; } if (attrRes && attrRes.ok) { let t2 = attrRes.total2d || 0, t3 = attrRes.total3d || 0; scanItems = (attrRes.boxes || []).map(function (b) { b.total = attrRes.total; b.total2d = t2; b.total3d = t3; return b; }); scanFrameId = attrRes.frameId; lastScanBadge = scanItems.length > 0 ? 'partial' : 'ok'; let parts = ['属性校验:3D框 ' + t3 + ' 个、2D框 ' + t2 + ' 个']; let n2 = 0, n3 = 0; for (let i = 0; i < scanItems.length; i++) { if (scanItems[i].dim === '2d') n2++; else n3++; } if (scanItems.length > 0) { parts.push('其中无属性/缺属性:3D ' + n3 + ' 个、2D ' + n2 + ' 个'); } let syncFlag = getSyncAttrsFlag(getVueStore()); if (syncFlag === true) { parts.push('本项目属性同步已开启(isSyncAttrs),属性只填在最早那帧,后续帧与2D投影attrs常为空'); } if (attrRes.unknown && attrRes.unknown.length) { parts.push('另有 ' + attrRes.unknown.length + ' 个框没找到属性配置(已跳过)'); } scanHint = parts.join(';'); errors = errors.concat(buildAttrErrors()); } else { scanItems = []; scanFrameId = null; lastScanBadge = 'none'; scanHint = (attrRes && attrRes.msg) ? attrRes.msg : '未知原因'; info.push('属性校验:未取到数据'); } renderResults(errors, info, isAuto); }, 50); } // ========== 9. 切帧自动检测 ========== let debounceTimer = null; let pollTimer = null; // 切帧识别:帧号 + 3D标签 + 标注框数量 // 只用 .threejs-label 是不够的(2D框没有标签),必须带上帧号, // 否则“只有2D框的帧”切过去也认不出变化、不会自动校验。 function getFrameKey() { let parts = []; let store = getVueStore(); let fid = getCurFrameIdSafe(store); if (fid !== null && fid !== undefined) parts.push('f' + fid); let labels = document.querySelectorAll('.threejs-label'); parts.push('L' + labels.length); for (let i = 0; i < Math.min(labels.length, 5); i++) { parts.push((labels[i].textContent || '').substring(0, 30)); } // 标注框数量变化(新增/删除框)也要能触发重新校验 try { if (store && store.state && store.state.marks) { let n = 0; let catKeys = Object.keys(store.state.marks); for (let i = 0; i < catKeys.length && i < 5; i++) { let cat = store.state.marks[catKeys[i]]; if (!cat || typeof cat !== 'object') continue; let ks = Object.keys(cat); n += ks.length; } parts.push('n' + n); } } catch (e) { } let key = parts.join('|'); // 一个信号都没有说明页面还没加载好 if (parts.length <= 1 && labels.length === 0) return ''; return key; } function doAutoValidate() { if (isAutoValidating || !autoEnabled) return; let frameKey = getFrameKey(); if (!frameKey || frameKey === lastFrameKey) return; lastFrameKey = frameKey; isAutoValidating = true; clearCanvasText(); setTimeout(function () { try { validate(true); } catch (e) { console.log('[校验插件] 自动校验出错:', e.message); } setTimeout(function () { isAutoValidating = false; doAutoValidate(); }, 50); }, 300); } let observer = new MutationObserver(function () { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(doAutoValidate, 300); }); function startObserver() { let target = document.getElementById('threejs-label') || document.body; observer.observe(target, { childList: true, subtree: true, characterData: true }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(doAutoValidate, 2000); } // ========== 10. 初始化 ========== function init() { let attempts = 0; let interval = setInterval(function () { attempts++; if (document.getElementById('app') || attempts > 60) { clearInterval(interval); setTimeout(function () { createPanel(); startObserver(); console.log('[校验插件V9.2 扩展版] 已加载'); setTimeout(doAutoValidate, 1500); }, 500); } }, 500); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } document.addEventListener('keydown', function (e) { if (e.ctrlKey && e.shiftKey && e.key === 'V') { e.preventDefault(); clearCanvasText(); validate(false); } if (e.ctrlKey && e.shiftKey && (e.key === 'D' || e.key === 'd')) { e.preventDefault(); doScan3DAttrs(); } }); window.__validate = validate; window.__scan3DAttrs = doScan3DAttrs; window.__scan3DAttrsRaw = scan3DAttrs; window.__getCurrentErrors = function () { return currentErrors; }; window.__getScanItems = function () { return scanItems; }; window.__getFrameKey = function () { return getFrameKey(); }; window.__getPanels = function () { return { main: panel, attr: null }; }; // 诊断:为什么2D没有属性 window.__why2D = diag2D; window.__whyTrack = diagTrack; window.__deleteScanItem = function (idx, btn) { if (!scanItems || !scanItems[idx]) { console.log('[校验插件] 没有第 ' + idx + ' 个可删除项,请先执行 window.__scan3DAttrs()'); return; } deleteOne3DBox(btn || { getAttribute: function () { return String(idx); }, textContent: '', disabled: false }); }; window.__clearCanvasText = clearCanvasText; window.__allCanvasTexts = allCanvasTexts; window.__dump2D = function () { let store = getVueStore(); if (!store || !store.state || !store.state.marks) { console.log('无Vue Store'); return; } let marks = store.state.marks; let frameIds = {}; for (let catKey in marks) { let category = marks[catKey]; for (let instKey in category) { let inst = category[instKey]; if (inst && inst['3d']) { for (let fid in inst['3d']) frameIds[fid] = (frameIds[fid] || 0) + 1; } } } let curFrame = Object.keys(frameIds).sort(function (a, b) { return frameIds[b] - frameIds[a]; })[0]; console.log('当前帧:', curFrame, '帧统计:', frameIds); let results = []; for (let catKey in marks) { let category = marks[catKey]; for (let instKey in category) { let inst = category[instKey]; if (!inst || !inst['2d']) continue; let d2 = inst['2d']; let frame2d = d2[curFrame]; if (!frame2d) continue; let boxList = []; if (Array.isArray(frame2d)) boxList = frame2d; else { for (let k in frame2d) { let v = frame2d[k]; if (Array.isArray(v)) boxList = boxList.concat(v); else if (typeof v === 'object' && v) boxList.push(v); } } for (let i = 0; i < boxList.length; i++) { let box = boxList[i]; if (!box) continue; let cam = box.camera || '?'; let viewName = VIEW_NAMES[cam] || cam; let attrs = box.attrs; let occ = '无attrs'; if (attrs) { if (typeof attrs === 'string') { try { attrs = JSON.parse(attrs); } catch (e) { } } if (attrs.occlusion) { occ = Array.isArray(attrs.occlusion) ? attrs.occlusion[0] : attrs.occlusion; } else { occ = '无occlusion字段'; } } results.push({ view: viewName, class: inst.class || instKey, inst: instKey, occlusion: occ, hasAttrs: !!box.attrs, allAttrKeys: box.attrs ? Object.keys(box.attrs) : [] }); } } } console.table(results); console.log('共' + results.length + '个2D框'); }; })();