// ==UserScript== // @name 4D拉框方向同步 // @namespace swj.box-direction-sync // @version 1.6.0 // @description 按当前帧选中3D框的方向同步到前序、后续或指定帧;检查同一目标在连续帧中是否方向相反。 // @author swj // @match *://*/* // @run-at document-start // @grant none // @noframes // ==/UserScript== /* * 安全边界 * 1. “方向检查”只读取平台已经加载的帧和3D框,不修改、保存或提交任何标注。 * 2. 同步只修改同一题包、同一目标ID、同一类别在目标帧已存在的3D框方向。 * 3. 同步不会创建框、删除框,也不会修改位置、尺寸、类别、ID或属性。 * 4. 只有用户点击同步按钮后才调用平台自身的 marks/syncMarks 保存流程。 * 5. 页面判断依赖平台运行对象和帧数据,不绑定域名、IP或平台路径。 */ (function () { 'use strict'; const PAGE = (() => { try { if (typeof unsafeWindow !== 'undefined') return unsafeWindow; } catch (_) {} try { return window; } catch (_) { return {}; } })(); const VERSION = '1.6.0'; const HOST_ID = 'swj-box-direction-sync-host'; const BOOT_KEY = '__SWJ_BOX_DIRECTION_SYNC_BOOTED__'; const PANEL_POSITION_KEY = 'swj-box-direction-sync:panel-position:v1'; const DEFAULT_OPPOSITE_DEGREES = 150; const ANGLE_EPSILON = 1e-6; /* ------------------------------------------------------------------------ */ /* Pure helpers */ /* ------------------------------------------------------------------------ */ function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function numberOr(value, fallback = NaN) { const result = Number(value); return Number.isFinite(result) ? result : fallback; } function normalizeText(value) { return String(value == null ? '' : value).trim(); } function sameIdentity(first, second) { return normalizeText(first) === normalizeText(second); } function cloneJson(value) { if (value == null) return value; if (typeof structuredClone === 'function') { try { return structuredClone(value); } catch (_) {} } return JSON.parse(JSON.stringify(value)); } function normalizeDegrees(angle) { let value = numberOr(angle, 0) % 360; if (value > 180) value -= 360; if (value <= -180) value += 360; return value; } function angularDeltaDegrees(first, second) { return Math.abs(normalizeDegrees(first - second)); } function isOppositeDirections(first, second, thresholdDegrees = DEFAULT_OPPOSITE_DEGREES) { const threshold = clamp(numberOr(thresholdDegrees, DEFAULT_OPPOSITE_DEGREES), 1, 180); return angularDeltaDegrees(first, second) + ANGLE_EPSILON >= threshold; } function parseFrameExpression(text, frameCount) { const max = Math.max(0, Math.floor(numberOr(frameCount, 0))); const source = normalizeText(text) .replace(/[,、;;~~—–]/g, ',') .replace(/\s+/g, ','); if (!source || max === 0) return []; const result = new Set(); for (const rawPart of source.split(',')) { const part = rawPart.trim(); if (!part) continue; const range = part.match(/^(\d+)\s*-\s*(\d+)$/); if (range) { let start = Number(range[1]); let end = Number(range[2]); if (start > end) [start, end] = [end, start]; start = clamp(start, 1, max); end = clamp(end, 1, max); for (let value = start; value <= end; value += 1) result.add(value - 1); continue; } const single = Number(part); if (Number.isInteger(single) && single >= 1 && single <= max) { result.add(single - 1); } } return [...result].sort((a, b) => a - b); } function selectTargetIndices(frameCount, currentIndex, mode, expression = '') { const count = Math.max(0, Math.floor(numberOr(frameCount, 0))); const current = Math.floor(numberOr(currentIndex, -1)); if (count === 0 || current < 0 || current >= count) return []; if (mode === 'previous') { return Array.from({ length: current }, (_, index) => index); } if (mode === 'next') { return Array.from({ length: count - current - 1 }, (_, index) => current + 1 + index); } if (mode === 'specified') { return parseFrameExpression(expression, count).filter((index) => index !== current); } return []; } function normalizeRotation(rotation) { if (!Array.isArray(rotation) || rotation.length < 3) return null; const result = rotation.slice(0, 3).map((value) => numberOr(value)); return result.every(Number.isFinite) ? result : null; } function quaternionFromEulerXyz(rotation) { const [x, y, z] = normalizeRotation(rotation) || [0, 0, 0]; const c1 = Math.cos(x / 2); const c2 = Math.cos(y / 2); const c3 = Math.cos(z / 2); const s1 = Math.sin(x / 2); const s2 = Math.sin(y / 2); const s3 = Math.sin(z / 2); return { x: s1 * c2 * c3 + c1 * s2 * s3, y: c1 * s2 * c3 - s1 * c2 * s3, z: c1 * c2 * s3 + s1 * s2 * c3, w: c1 * c2 * c3 - s1 * s2 * s3, }; } function multiplyQuaternion(first, second) { return { x: first.w * second.x + first.x * second.w + first.y * second.z - first.z * second.y, y: first.w * second.y - first.x * second.z + first.y * second.w + first.z * second.x, z: first.w * second.z + first.x * second.y - first.y * second.x + first.z * second.w, w: first.w * second.w - first.x * second.x - first.y * second.y - first.z * second.z, }; } function eulerXyzFromQuaternion(quaternion) { const { x: qx, y: qy, z: qz, w: qw, } = quaternion; return [ Math.atan2(2 * (qw * qx - qy * qz), 1 - 2 * (qx * qx + qy * qy)), Math.asin(clamp(2 * (qw * qy + qz * qx), -1, 1)), Math.atan2(2 * (qw * qz - qx * qy), 1 - 2 * (qy * qy + qz * qz)), ]; } function rotateRotationAboutLocalZ(rotation, deltaDegrees) { const normalized = normalizeRotation(rotation); if (!normalized) return null; const half = deltaDegrees * Math.PI / 360; return eulerXyzFromQuaternion(multiplyQuaternion( quaternionFromEulerXyz(normalized), { x: 0, y: 0, z: Math.sin(half), w: Math.cos(half) }, )); } function rotateMarkDirection(mark, deltaDegrees) { if (!isVolumeBox(mark)) return null; const rotation = normalizeRotation(mark.rotation); const scale = mark.scale.map((value) => Math.abs(numberOr(value, 0))); if (!rotation || scale.length < 3) return null; const nextRotation = rotateRotationAboutLocalZ(rotation, deltaDegrees); if (!nextRotation) return null; // 与平台 P 键一致:转 90° 的奇数倍时交换长、宽,框的世界形状不变。 const swapLongWidth = Math.round(Math.abs(deltaDegrees) / 90) % 2 === 1; mark.rotation = nextRotation; mark.scale = swapLongWidth ? [scale[1], scale[0], scale[2]] : scale; return { rotation: mark.rotation, scale: mark.scale, swapLongWidth, }; } function sameRotation(first, second) { const a = normalizeRotation(first); const b = normalizeRotation(second); if (!a || !b) return false; return a.every((value, index) => Math.abs(value - b[index]) <= ANGLE_EPSILON); } function sameDirection(first, second) { const a = normalizeRotation(first); const b = normalizeRotation(second); if (!a || !b) return false; return Math.abs(a[2] - b[2]) <= ANGLE_EPSILON; } function yawDegrees(rotation) { const normalized = normalizeRotation(rotation); return normalized ? normalized[2] * 180 / Math.PI : NaN; } function isVolumeBox(box) { return !!( box && typeof box === 'object' && Array.isArray(box.position) && box.position.length >= 3 && Array.isArray(box.scale) && box.scale.length >= 3 && (!box.type || box.type === 'volume') ); } function boxesForTrack(marks, trackId) { const track = marks && marks[String(trackId)]; if (!track || !track['3d'] || typeof track['3d'] !== 'object') return null; return track['3d']; } function orderedBoxEntries(trackId, marks, frames) { const boxes = boxesForTrack(marks, trackId); if (!boxes) return []; const entries = []; (frames || []).forEach((frame, index) => { if (!frame || frame.task_id == null) return; const box = boxes[String(frame.task_id)]; const rotation = normalizeRotation(box && box.rotation); if (!isVolumeBox(box) || !rotation) return; entries.push({ frame, frameId: frame.task_id, frameIndex: index, box, rotation, yawDegrees: rotation[2] * 180 / Math.PI, }); }); return entries; } function buildSyncUpdates( marks, frames, trackId, classId, sourceRotation, targetIndices, ) { const normalizedSource = normalizeRotation(sourceRotation); const track = marks && marks[String(trackId)]; if (!normalizedSource || !track) { return { updates: [], inspected: 0, missing: 0, unchanged: 0 }; } if (classId != null && track.class != null && !sameIdentity(track.class, classId)) { throw new Error('当前目标的类别与标注数据不一致'); } const updates = []; let inspected = 0; let missing = 0; let unchanged = 0; for (const rawIndex of targetIndices || []) { const frameIndex = Math.floor(numberOr(rawIndex, -1)); const frame = frames && frames[frameIndex]; if (!frame || frame.task_id == null) continue; inspected += 1; const box = track['3d'] && track['3d'][String(frame.task_id)]; if (!isVolumeBox(box)) { missing += 1; continue; } const previousRotation = normalizeRotation(box.rotation); if (!previousRotation) { missing += 1; continue; } if (sameDirection(previousRotation, normalizedSource)) { unchanged += 1; continue; } updates.push({ frameIndex, frameId: frame.task_id, trackId, classId, previousRotation, // 只替换方向轴,保留目标框原有的横滚和俯仰值。 nextRotation: [ previousRotation[0], previousRotation[1], normalizedSource[2], ], box, }); } return { updates, inspected, missing, unchanged }; } function applySyncUpdates(marks, updates) { for (const update of updates || []) { const track = marks && marks[String(update.trackId)]; if (!track || !track['3d']) continue; const box = track['3d'][String(update.frameId)]; if (!isVolumeBox(box)) continue; box.rotation = update.nextRotation.slice(); } return marks; } function scanOppositeDirections(marks, frames) { const issues = []; let tracksChecked = 0; let boxesChecked = 0; for (const [trackId, track] of Object.entries(marks || {})) { if (!track || !track['3d']) continue; const entries = orderedBoxEntries(trackId, marks, frames); if (!entries.length) continue; tracksChecked += 1; boxesChecked += entries.length; for (let index = 1; index < entries.length; index += 1) { const previous = entries[index - 1]; const current = entries[index]; if (current.frameIndex !== previous.frameIndex + 1) continue; const deltaDegrees = angularDeltaDegrees( previous.yawDegrees, current.yawDegrees, ); // 只比较相邻帧,并把方向差归到 90° 整数倍。 const quarterSteps = Math.round(deltaDegrees / 90); if (quarterSteps === 0) continue; const quarterDegrees = quarterSteps * 90; issues.push({ trackId, classId: track.class, previousFrameId: previous.frameId, previousFrameIndex: previous.frameIndex, currentFrameId: current.frameId, currentFrameIndex: current.frameIndex, previousYawDegrees: previous.yawDegrees, currentYawDegrees: current.yawDegrees, deltaDegrees, quarterSteps, quarterDegrees, reason: `相邻帧方向相差约 ${quarterDegrees}°`, }); } } issues.sort((first, second) => ( first.currentFrameIndex - second.currentFrameIndex || numberOr(first.trackId, 0) - numberOr(second.trackId, 0) )); return { issues, tracksChecked, boxesChecked }; } const TEST_API = { normalizeDegrees, angularDeltaDegrees, isOppositeDirections, parseFrameExpression, selectTargetIndices, normalizeRotation, rotateRotationAboutLocalZ, rotateMarkDirection, sameRotation, sameDirection, scanOppositeDirections, buildSyncUpdates, applySyncUpdates, }; if (typeof module !== 'undefined' && module.exports) { module.exports = TEST_API; return; } if (!PAGE || PAGE[BOOT_KEY]) return; PAGE[BOOT_KEY] = true; /* ------------------------------------------------------------------------ */ /* Platform adapters */ /* ------------------------------------------------------------------------ */ function safeCall(fn, fallback = null) { try { return typeof fn === 'function' ? fn() : fallback; } catch (_) { return fallback; } } function getManager() { const instance = PAGE.instance || null; const manager = instance && instance.StoreManager; if (manager && typeof manager.configState === 'function' && typeof manager.marksState === 'function') { return manager; } const store = PAGE.__store || PAGE.store; if (store && store.state && store.state.config && store.state.marks) { const getter = (namespace, name) => { const fn = store.getters && store.getters[`${namespace}/${name}`]; return typeof fn === 'function' ? fn : () => null; }; return { configState: () => store.state.config, marksState: () => store.state.marks, statusState: () => store.state.status, getCurFrameId: () => store.state.status && store.state.status.curFrameId, getSelectMarkInfo: () => store.state.status && store.state.status.selectMarkInfo, marksGetter: (name) => getter('marks', name), configGetter: (name) => getter('config', name), commit: (name, payload) => store.commit(name, payload), dispatch: (name, payload) => store.dispatch(name, payload), markCommit: (name, payload) => store.commit(`marks/${name}`, payload), statusCommit: (name, payload) => store.commit(`status/${name}`, payload), selectMarkInfo: (trackId, classId, dimension) => store.commit( 'status/selectMarkInfo', [trackId, classId, dimension, null, false], ), }; } return null; } function getContext() { const manager = getManager(); if (!manager) return null; const config = safeCall(() => manager.configState(), null); const marksState = safeCall(() => manager.marksState(), null); const status = safeCall(() => manager.statusState(), {}); const frames = config && Array.isArray(config.frames) ? config.frames : []; if (!marksState || !frames.length) return null; return { manager, config, marksState, status: status || {}, frames, marks: marksState.marks || {}, currentFrameId: safeCall( () => manager.getCurFrameId(), status && status.curFrameId, ), selected: safeCall( () => manager.getSelectMarkInfo(), status && status.selectMarkInfo, ) || {}, }; } function frameIndexFor(context, frameId) { return context.frames.findIndex( (frame) => frame && sameIdentity(frame.task_id, frameId), ); } function getTrackBox(context, trackId, frameId) { const fromGetter = safeCall( () => context.manager.marksGetter('getMarks')(trackId, '3d', frameId), null, ); if (fromGetter) return fromGetter; return context.marks && context.marks[String(trackId)] && context.marks[String(trackId)]['3d'] && context.marks[String(trackId)]['3d'][String(frameId)] || null; } function classTitle(context, classId) { const managerValue = safeCall( () => context.manager.configGetter('className')(classId, 'volume'), '', ); if (managerValue && managerValue !== '未知') return managerValue; const cls = safeCall( () => context.manager.configGetter('getClass')(classId, 'volume'), null, ); return cls && (cls.ptitle || cls.name || cls.pname) || normalizeText(classId) || '未知类别'; } function selectedSource(context) { const selected = context && context.selected || {}; const trackId = selected.trackId; const frameId = context && context.currentFrameId; if (trackId == null || frameId == null) return null; const box = getTrackBox(context, trackId, frameId); const rotation = normalizeRotation(box && box.rotation); if (!isVolumeBox(box) || !rotation) return null; const track = context.marks && context.marks[String(trackId)]; return { trackId, classId: selected.classId != null ? selected.classId : track && track.class, frameId, frameIndex: frameIndexFor(context, frameId), box, rotation, yawDegrees: rotation[2] * 180 / Math.PI, }; } function statusMessage(message, tone = '') { if (!state.ui.host) return; const node = state.ui.host.shadowRoot.querySelector('#syncStatus'); if (!node) return; const text = node.querySelector('#syncStatusText'); const actions = node.querySelector('#syncStatusActions'); if (text) text.textContent = message; else node.textContent = message; if (actions) actions.innerHTML = ''; node.dataset.tone = tone; } function frameLabel(issue) { return `第 ${issue.previousFrameIndex + 1} → ${issue.currentFrameIndex + 1} 帧`; } function issueKey(issue) { return `${normalizeText(issue.trackId)}:${normalizeText(issue.previousFrameId)}:${normalizeText(issue.currentFrameId)}`; } function renderCheckStatus(report) { if (!state.ui.host) return; const root = state.ui.host.shadowRoot; const node = root.querySelector('#syncStatus'); const text = node.querySelector('#syncStatusText'); const actions = node.querySelector('#syncStatusActions'); if (!text || !actions) return; node.dataset.tone = report.issues.length ? 'warn' : 'ok'; text.textContent = report.issues.length ? `检查完成:发现 ${report.issues.length} 处相邻帧方向变化,点击帧号定位:` : '检查完成:未发现相邻帧方向出现 90° 整数倍变化。'; actions.innerHTML = report.issues.slice(0, 8).map((issue, index) => ` `).join('') + (report.issues.length > 8 ? `另有 ${report.issues.length - 8} 处,见下方列表` : ''); actions.querySelectorAll('[data-status-issue]').forEach((button) => { button.addEventListener('click', () => { const issue = report.issues[Number(button.dataset.statusIssue)]; if (issue) jumpToIssue(issue); }); }); } function setBusy(busy, text = '') { state.busy = busy; if (!state.ui.host) return; const root = state.ui.host.shadowRoot; root.querySelectorAll( '[data-sync-action], #applyTargetFrame, [data-direction-delta]', ) .forEach((button) => { button.disabled = busy || !state.source; }); const checkButton = root.querySelector('#checkDirections'); if (checkButton) checkButton.disabled = busy; if (text) statusMessage(text); } function formatAngle(angle) { return `${numberOr(angle, 0).toFixed(1)}°`; } function renderSelection() { if (!state.ui.host) return; const context = getContext(); const source = context ? selectedSource(context) : null; state.source = source; state.context = context; const root = state.ui.host.shadowRoot; const title = root.querySelector('#selectedTarget'); const meta = root.querySelector('#selectedMeta'); const dot = root.querySelector('#selectionDot'); if (!context) { title.textContent = '正在等待平台数据…'; meta.textContent = '请打开并加载点云任务'; dot.dataset.tone = 'waiting'; } else if (!source) { const currentIndex = frameIndexFor(context, context.currentFrameId); title.textContent = '请先点选一个3D框'; meta.textContent = `当前第 ${currentIndex >= 0 ? currentIndex + 1 : '?'} 帧 · 需要选中目标后才能同步`; dot.dataset.tone = 'idle'; } else { title.textContent = `#${source.trackId} · ${classTitle(context, source.classId)}`; meta.textContent = `当前第 ${source.frameIndex + 1} 帧 · 方向 ${formatAngle(source.yawDegrees)}`; dot.dataset.tone = 'ready'; } root.querySelectorAll( '[data-sync-action], #applyTargetFrame, [data-direction-delta]', ) .forEach((button) => { button.disabled = state.busy || !source; }); const checkButton = root.querySelector('#checkDirections'); if (checkButton) checkButton.disabled = state.busy; } function managerMarkCommit(manager, name, payload) { if (manager && typeof manager.markCommit === 'function') { return manager.markCommit(name, payload); } if (manager && typeof manager.commit === 'function') { return manager.commit(`marks/${name}`, payload); } throw new Error('平台没有提供标注提交接口'); } async function saveDirectionUpdate(context, trackId, classId, sourceRotation, targetIndices) { const beforeMarks = cloneJson(context.marksState.marks || {}); const plan = buildSyncUpdates( beforeMarks, context.frames, trackId, classId, sourceRotation, targetIndices, ); if (!plan.updates.length) { return { ...plan, saved: false }; } const beforeCurVer = numberOr(context.marksState.curVer, 0); const beforeUpdatedVer = numberOr(context.marksState.updatedVer, 0); let appliedUpdates = 0; try { if (!context.manager || typeof context.manager.dispatch !== 'function') { throw new Error('平台没有提供可用的标注保存流程'); } // 逐框替换同一个 3D 标注对象,只改变 rotation,保留其他全部字段。 for (const update of plan.updates) { const latestContext = getContext(); const currentBox = latestContext ? getTrackBox(latestContext, trackId, update.frameId) : null; if (!isVolumeBox(currentBox)) continue; const nextBox = cloneJson(currentBox); nextBox.rotation = update.nextRotation.slice(); managerMarkCommit( context.manager, 'addOrReplaceMark3D', [1, nextBox, trackId, classId, update.frameId, null, false], ); appliedUpdates += 1; } if (!appliedUpdates) { throw new Error('目标框在保存前发生变化,请重新进入任务后重试'); } managerMarkCommit(context.manager, 'changeMarkVersion'); await context.manager.dispatch('marks/syncMarks'); return { ...plan, appliedUpdates, saved: true }; } catch (error) { try { const latestMarks = safeCall(() => context.manager.marksState().marks, {}); const restored = safeCall(() => context.manager.marksState(), null); for (const update of plan.updates) { const track = latestMarks && latestMarks[String(trackId)]; const box = track && track['3d'] && track['3d'][String(update.frameId)]; if (!isVolumeBox(box) || !update.previousRotation) continue; const restoredBox = cloneJson(box); restoredBox.rotation = update.previousRotation.slice(); try { managerMarkCommit( context.manager, 'addOrReplaceMark3D', [1, restoredBox, trackId, classId, update.frameId, null, false], ); } catch (_) {} } if (restored) { restored.curVer = beforeCurVer; restored.updatedVer = beforeUpdatedVer; } } catch (_) {} throw error; } } async function rotateCurrentDirection(deltaDegrees) { if (state.busy) return; const context = getContext(); const source = context && selectedSource(context); if (!context || !source) { statusMessage('请先点选当前帧中的3D框。', 'error'); return; } const delta = numberOr(deltaDegrees, NaN); if (!Number.isFinite(delta) || delta === 0) { statusMessage('没有有效的方向调整值。', 'error'); return; } const nextBox = cloneJson(source.box); const change = rotateMarkDirection(nextBox, delta); if (!change) { statusMessage('当前框缺少有效的方向或尺寸字段。', 'error'); return; } const beforeCurVer = numberOr(context.marksState.curVer, 0); const beforeUpdatedVer = numberOr(context.marksState.updatedVer, 0); setBusy(true, `正在将箭头方向调整 ${delta > 0 ? '+' : ''}${delta}°…`); try { if (!context.manager || typeof context.manager.dispatch !== 'function') { throw new Error('平台没有提供可用的标注保存流程'); } managerMarkCommit( context.manager, 'addOrReplaceMark3D', [ 1, nextBox, source.trackId, source.classId, source.frameId, null, true, ], ); managerMarkCommit(context.manager, 'changeMarkVersion'); await context.manager.dispatch('marks/syncMarks'); const newDirection = change.rotation[2] * 180 / Math.PI; statusMessage( `箭头方向已调整 ${delta > 0 ? '+' : ''}${delta}°,当前方向 ${formatAngle(newDirection)}` + `${change.swapLongWidth ? ';长宽已对调,框的世界形状不变' : ''}。`, 'ok', ); } catch (error) { try { const latestContext = getContext(); const currentBox = latestContext ? getTrackBox(latestContext, source.trackId, source.frameId) : null; if (isVolumeBox(currentBox)) { const restoredBox = cloneJson(currentBox); restoredBox.rotation = source.rotation.slice(); restoredBox.scale = source.box.scale.slice(); managerMarkCommit( context.manager, 'addOrReplaceMark3D', [ 1, restoredBox, source.trackId, source.classId, source.frameId, null, false, ], ); } const restored = safeCall(() => context.manager.marksState(), null); if (restored) { restored.curVer = beforeCurVer; restored.updatedVer = beforeUpdatedVer; } } catch (_) {} const message = error && (error.msg || error.message) ? (error.msg || error.message) : String(error); statusMessage(`方向调整失败:${message}`, 'error'); } finally { setBusy(false); renderSelection(); } } async function runSync(mode) { if (state.busy) return; state.lastCheck = null; renderCheckReport(); const context = getContext(); const source = context && selectedSource(context); if (!context || !source) { statusMessage('请先点选当前帧中的3D框。', 'error'); return; } if (source.frameIndex < 0) { statusMessage('没有识别到当前帧号,请刷新任务后重试。', 'error'); return; } const input = state.ui.host.shadowRoot.querySelector('#targetFrames'); const targetIndices = selectTargetIndices( context.frames.length, source.frameIndex, mode, input.value, ); if (!targetIndices.length) { statusMessage( mode === 'specified' ? '指定帧为空或格式无效,请填写例如 8,10-15。' : '当前方向没有可同步的目标帧。', 'error', ); return; } const actionName = mode === 'next' ? '后续帧' : mode === 'previous' ? '前序帧' : '指定帧'; setBusy(true, `正在向${actionName}同步并保存…`); try { const result = await saveDirectionUpdate( context, source.trackId, source.classId, source.rotation, targetIndices, ); if (!result.saved) { statusMessage( `已检查 ${result.inspected} 帧,方向本来一致,无需保存。` + (result.missing ? ` 跳过 ${result.missing} 个没有该目标的帧。` : ''), 'ok', ); return; } statusMessage( `已保存:目标 #${source.trackId} 同步 ${result.appliedUpdates} 帧` + (result.missing ? `,跳过 ${result.missing} 个缺框帧` : '') + (result.unchanged ? `,${result.unchanged} 帧原本一致` : '') + '。', 'ok', ); runCheck(false); } catch (error) { const message = error && (error.msg || error.message) ? (error.msg || error.message) : String(error); statusMessage(`同步失败:${message}。如平台已有其他未保存修改,请刷新核对。`, 'error'); } finally { setBusy(false); renderSelection(); } } function renderCheckReport() { if (!state.ui.host) return; const root = state.ui.host.shadowRoot; const summary = root.querySelector('#checkSummary'); const list = root.querySelector('#checkList'); const report = state.lastCheck; if (!report) { summary.textContent = '检查只读运行,不会修改任何框。'; list.innerHTML = ''; return; } summary.textContent = `已检查 ${report.tracksChecked} 条轨迹、${report.boxesChecked} 个相邻帧框;发现 ${report.issues.length} 处方向变化。`; if (!report.issues.length) { list.innerHTML = '
未发现相邻帧方向相差 90° 整数倍的情况。
'; return; } const visible = report.issues.slice(0, 80); list.innerHTML = visible.map((issue, index) => ` `).join('') + (report.issues.length > visible.length ? `
仅显示前 ${visible.length} 条,共 ${report.issues.length} 条。
` : ''); list.querySelectorAll('[data-issue-index]').forEach((button) => { button.addEventListener('click', () => { const issue = visible[Number(button.dataset.issueIndex)]; if (issue) jumpToIssue(issue); }); }); } function runCheck(showStatus = true) { const context = getContext(); if (!context) { if (showStatus) statusMessage('平台数据尚未加载完成。', 'error'); return null; } state.selectedIssueKey = ''; state.lastClassNames = new Map(); Object.values(context.marks || {}).forEach((track) => { if (!track || track.class == null) return; state.lastClassNames.set( normalizeText(track.class), classTitle(context, track.class), ); }); const report = scanOppositeDirections(context.marks, context.frames); report.smart = true; state.lastCheck = report; renderCheckReport(); if (showStatus) { renderCheckStatus(report); } return report; } async function jumpToIssue(issue) { const context = getContext(); if (!context) return; let selected = false; state.selectedIssueKey = issueKey(issue); const root = state.ui.host && state.ui.host.shadowRoot; if (root) { root.querySelectorAll('.issue.active').forEach((node) => { node.classList.remove('active'); }); const row = root.querySelector(`[data-issue-key="${CSS.escape(issueKey(issue))}"]`); if (row) { row.classList.add('active'); row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } try { statusMessage( `正在定位${frameLabel(issue)},目标 #${issue.trackId}…`, 'warn', ); if (typeof context.manager.statusCommit === 'function') { context.manager.statusCommit('setCurFrameId', issue.currentFrameId); } else { context.manager.commit('status/setCurFrameId', issue.currentFrameId); } for (let attempt = 0; attempt < 20; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 80)); const fresh = getContext(); if (!fresh || !sameIdentity(fresh.currentFrameId, issue.currentFrameId)) { continue; } const box = getTrackBox(fresh, issue.trackId, issue.currentFrameId); if (!box) continue; try { if (typeof fresh.manager.selectMarkInfo === 'function') { fresh.manager.selectMarkInfo(issue.trackId, issue.classId, '3d'); } else { fresh.manager.commit( 'status/selectMarkInfo', [issue.trackId, issue.classId, '3d', null, false], ); } selected = true; } catch (_) {} if (selected && attempt >= 1) break; } if (!selected) { throw new Error('对应帧或目标尚未加载完成'); } statusMessage( `已定位${frameLabel(issue)},目标 #${issue.trackId} 已选中并高亮。`, 'ok', ); } catch (error) { statusMessage(`跳转失败:${error && error.message ? error.message : error}`, 'error'); } } function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function loadPanelPosition() { try { const value = JSON.parse(localStorage.getItem(PANEL_POSITION_KEY) || 'null'); if (value && Number.isFinite(Number(value.left)) && Number.isFinite(Number(value.top))) { return { left: Number(value.left), top: Number(value.top), }; } } catch (_) {} return null; } function savePanelPosition(position) { try { localStorage.setItem(PANEL_POSITION_KEY, JSON.stringify(position)); } catch (_) {} } function applyPanelPosition(panel, position) { if (!panel || !position) return; const rect = panel.getBoundingClientRect(); const width = rect.width || Math.min(390, window.innerWidth - 24); const maxLeft = Math.max(4, window.innerWidth - width - 4); const left = clamp(position.left, 4, maxLeft); const top = clamp(position.top, 4, Math.max(4, window.innerHeight - 80)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; state.panelPosition = { left, top }; } function enablePanelDrag(panel, handle) { let drag = null; handle.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target.closest('.close')) return; const rect = panel.getBoundingClientRect(); drag = { startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top, pointerId: event.pointerId, }; handle.classList.add('dragging'); handle.setPointerCapture(event.pointerId); event.preventDefault(); }); handle.addEventListener('pointermove', (event) => { if (!drag || drag.pointerId !== event.pointerId) return; const rect = panel.getBoundingClientRect(); const left = clamp( drag.startLeft + event.clientX - drag.startX, 4, Math.max(4, window.innerWidth - rect.width - 4), ); const top = clamp( drag.startTop + event.clientY - drag.startY, 4, Math.max(4, window.innerHeight - 80), ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); const finish = (event) => { if (!drag || (event && drag.pointerId !== event.pointerId)) return; const rect = panel.getBoundingClientRect(); state.panelPosition = { left: rect.left, top: rect.top }; savePanelPosition(state.panelPosition); handle.classList.remove('dragging'); drag = null; }; handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); } /* ------------------------------------------------------------------------ */ /* UI */ /* ------------------------------------------------------------------------ */ const state = { open: true, busy: false, context: null, source: null, lastCheck: null, lastClassNames: new Map(), selectedIssueKey: '', panelPosition: loadPanelPosition(), selectionTimer: 0, waitTimer: 0, ui: {}, }; function createUI() { if (state.ui.host || !document.documentElement) return; const host = document.createElement('div'); host.id = HOST_ID; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `
4D 拉框方向同步 只改方向 · 不建框 · 不删框
正在等待平台数据…
请打开并加载点云任务
调整箭头方向 点击一次立即生效
仅调整当前帧选中的框;±90° 自动对调长宽使框保持不动,同步范围由下方按钮决定。
同步已调整方向 仅同步已有同一目标
指定帧 可见帧序号,可填范围
同步范围仅限当前题包;缺框帧会自动跳过。
连续帧方向检查 相邻帧 · 90°基准
只比较相邻帧,并将方向差自动归到 90° 整数倍,无需手动设置阈值。
检查只读运行,不会修改任何框。
请先选中一个3D框。
`; document.documentElement.appendChild(host); state.ui.host = host; state.ui.panel = shadow.querySelector('#panel'); state.ui.launcher = shadow.querySelector('#launcher'); state.ui.panel.hidden = false; state.ui.launcher.hidden = true; state.selectionTimer = setInterval(renderSelection, 350); enablePanelDrag(state.ui.panel, shadow.querySelector('#panelHeader')); if (state.panelPosition) { requestAnimationFrame(() => applyPanelPosition(state.ui.panel, state.panelPosition)); } window.addEventListener('resize', () => { if (state.panelPosition) { applyPanelPosition(state.ui.panel, state.panelPosition); } }); const openPanel = () => { state.open = true; state.ui.panel.hidden = false; state.ui.launcher.hidden = true; if (state.panelPosition) applyPanelPosition(state.ui.panel, state.panelPosition); renderSelection(); }; const closePanel = () => { state.open = false; state.ui.panel.hidden = true; state.ui.launcher.hidden = false; }; state.ui.launcher.addEventListener('click', () => { if (state.open) closePanel(); else openPanel(); }); shadow.querySelector('#closePanel').addEventListener('click', closePanel); shadow.querySelectorAll('[data-sync-action]').forEach((button) => { button.addEventListener('click', () => runSync(button.dataset.syncAction)); }); shadow.querySelector('#applyTargetFrame').addEventListener('click', () => runSync('specified')); shadow.querySelector('#targetFrames').addEventListener('keydown', (event) => { if (event.key === 'Enter') runSync('specified'); }); shadow.querySelectorAll('[data-direction-delta]').forEach((button) => { button.addEventListener('click', () => { rotateCurrentDirection(Number(button.dataset.directionDelta)); }); }); shadow.querySelector('#checkDirections').addEventListener('click', () => runCheck(true)); renderCheckReport(); renderSelection(); } function looksLikeAnnotationPage(context) { return !!( context || /\/pointcloud(?:\/|$)/i.test(location.pathname) || ( new URLSearchParams(location.search).has('package_id') && new URLSearchParams(location.search).has('id') ) ); } function waitForPlatform() { let attempts = 0; const timer = setInterval(() => { attempts += 1; const context = getContext(); if (context || looksLikeAnnotationPage(null)) createUI(); if (createUI && state.ui.host && context) { clearInterval(timer); state.context = context; renderSelection(); try { console.log( `%c[4D方向同步]%c 已注入 v${VERSION}`, 'color:#f59e0b;font-weight:700', 'color:#94a3b8', ); } catch (_) {} } else if (attempts > 240) { clearInterval(timer); } }, 250); state.waitTimer = timer; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', waitForPlatform, { once: true }); } else { waitForPlatform(); } })();