// ==UserScript== // @name 4D拉框方向同步 // @namespace swj.box-direction-sync // @version 1.14.3 // @description 按平台P键规则,将当前框方向同步到前序、后续或指定帧;检查相邻帧方向变化。 // @author swj // @match *://*/* // @run-at document-start // @grant none // @noframes // ==/UserScript== /* * 安全边界 * 1. “方向检查”只读取平台已经加载的帧和3D框,不修改、保存或提交任何标注。 * 2. 同步只修改同一题包、同一目标ID、同一类别在目标帧已存在的3D框方向。 * 3. 同步不会创建框、删除框,也不会修改位置、尺寸、类别、ID或属性。 * 4. 当前帧方向由平台 P 键调整;同步时复刻 P 键的本地Z轴旋转与长宽交换。 * 5. 页面判断依赖平台运行对象和帧数据,不绑定域名、IP或平台路径。 */ (function () { 'use strict'; const PAGE = (() => { try { if (typeof unsafeWindow !== 'undefined') return unsafeWindow; } catch (_) {} try { return window; } catch (_) { return {}; } })(); const VERSION = '1.14.3'; 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 LAUNCHER_POSITION_KEY = 'swj-box-direction-sync:launcher-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 normalizeRadians(angle) { return normalizeDegrees(numberOr(angle, 0) * 180 / Math.PI) * Math.PI / 180; } function snapRadiansToQuarter(angle) { const degrees = normalizeDegrees(numberOr(angle, 0) * 180 / Math.PI); return normalizeDegrees(Math.round(degrees / 90) * 90) * Math.PI / 180; } 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 arrowYawRadians(box) { if (!box || typeof box !== 'object') return NaN; const rotation = normalizeRotation(box.rotation); return rotation ? rotation[2] : NaN; } 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 rotateMarkLikePlatformP(mark, quarterSteps) { if (!isVolumeBox(mark) || !Number.isInteger(quarterSteps)) 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, quarterSteps * 90); if (!nextRotation) return null; mark.rotation = nextRotation; mark.scale = Math.abs(quarterSteps) % 2 === 1 ? [scale[1], scale[0], scale[2]] : scale; return { rotation: mark.rotation, scale: mark.scale }; } 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); const arrowYaw = arrowYawRadians(box); if (!isVolumeBox(box) || !rotation || !Number.isFinite(arrowYaw)) return; entries.push({ frame, frameId: frame.task_id, frameIndex: index, box, rotation, arrowYaw, yawDegrees: snapRadiansToQuarter(arrowYaw) * 180 / Math.PI, }); }); return entries; } function buildSyncUpdates( marks, frames, trackId, classId, sourceArrowYaw, targetIndices, ) { const normalizedSourceYaw = snapRadiansToQuarter(sourceArrowYaw); const track = marks && marks[String(trackId)]; if (!Number.isFinite(normalizedSourceYaw) || !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 previousArrowYaw = arrowYawRadians(box); if (!Number.isFinite(previousArrowYaw)) { missing += 1; continue; } const targetQuarterYaw = snapRadiansToQuarter(previousArrowYaw); const sourceDegrees = normalizedSourceYaw * 180 / Math.PI; const targetDegrees = targetQuarterYaw * 180 / Math.PI; const quarterSteps = Math.round( normalizeDegrees(sourceDegrees - targetDegrees) / 90, ); if (quarterSteps === 0) { unchanged += 1; continue; } const nextBox = cloneJson(box); rotateMarkLikePlatformP(nextBox, quarterSteps); updates.push({ frameIndex, frameId: frame.task_id, trackId, classId, previousRotation: normalizeRotation(box.rotation), previousScale: box.scale.slice(), nextRotation: nextBox.rotation.slice(), nextScale: nextBox.scale.slice(), quarterSteps, 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(); box.scale = update.nextScale.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, snapRadiansToQuarter, arrowYawRadians, rotateRotationAboutLocalZ, rotateMarkLikePlatformP, 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; const context = { manager, config, marksState, status: status || {}, frames, marks: marksState.marks || {}, currentFrameId: safeCall( () => manager.getCurFrameId(), status && status.curFrameId, ), selected: safeCall( () => manager.getSelectMarkInfo(), status && status.selectMarkInfo, ) || {}, }; return context; } 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 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('平台没有提供标注提交接口'); } 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); const arrowYaw = arrowYawRadians(box); if (!isVolumeBox(box) || !rotation || !Number.isFinite(arrowYaw)) 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, arrowYaw, yawDegrees: snapRadiansToQuarter(arrowYaw) * 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', ) .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 rememberSelectedTarget(context = getContext()) { const selected = context && context.selected; if (selected && selected.trackId != null) { state.savedSelection = { trackId: selected.trackId, classId: selected.classId, }; } } function restoreSelectedTarget() { const selected = state.savedSelection; if (!selected) return; setTimeout(() => { const context = getContext(); if (!context || !context.manager) return; try { context.manager.selectMarkInfo(selected.trackId, selected.classId, '3d'); } catch (_) {} }, 60); } 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', ) .forEach((button) => { button.disabled = state.busy || !source; }); const checkButton = root.querySelector('#checkDirections'); if (checkButton) checkButton.disabled = state.busy; } async function saveDirectionUpdate(context, trackId, classId, sourceArrowYaw, targetIndices) { const plan = buildSyncUpdates( context.marks, context.frames, trackId, classId, sourceArrowYaw, targetIndices, ); if (!plan.updates.length) { return { ...plan, saved: false }; } let appliedUpdates = 0; const beforeCurVer = numberOr(context.marksState.curVer, 0); const beforeUpdatedVer = numberOr(context.marksState.updatedVer, 0); try { for (const update of plan.updates) { const currentBox = getTrackBox(context, trackId, update.frameId); if (!isVolumeBox(currentBox)) continue; const nextBox = cloneJson(currentBox); nextBox.rotation = update.nextRotation.slice(); nextBox.scale = update.nextScale.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) { for (const update of plan.updates) { try { const currentBox = getTrackBox(context, trackId, update.frameId); if (!isVolumeBox(currentBox)) continue; const restoredBox = cloneJson(currentBox); restoredBox.rotation = update.previousRotation.slice(); restoredBox.scale = update.previousScale.slice(); managerMarkCommit( context.manager, 'addOrReplaceMark3D', [1, restoredBox, trackId, classId, update.frameId, null, false], ); } catch (_) {} } context.marksState.curVer = beforeCurVer; context.marksState.updatedVer = beforeUpdatedVer; throw error; } } 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.arrowYaw, targetIndices, ); if (!result.saved) { statusMessage( `已检查 ${result.inspected} 帧,箭头方向本来一致,无需修改。` + (result.unchanged ? ` ${result.unchanged} 帧方向一致。` : '') + (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 = '