// ==UserScript== // @name 车道线拉直与2D整线/多边形平移(swj) // @namespace https://github.com/swj/lane-straighten // @version 1.2.0 // @description 3D车道线首尾锁定拉直、自定义点数、保持原Z曲线;2D车道线和多边形人行横道选中后按住快捷键整体拖动并实时跟手。 // @author swj // @match *://*/pointcloud/* // @run-at document-start // @grant unsafeWindow // @noframes // ==/UserScript== /* * 车道线拉直与 2D 整线 / 多边形平移 * 作者:swj * * 安全约定: * 1. 页面加载、扫描、预览都不会调用平台的 commit / dispatch,不会改标注数据; * 2. 只有点击「应用到选中」「平移」或完成一次 2D 线/多边形拖动后,才提交一次平台 mutation; * 3. 插件不会调用平台保存按钮,保存由使用者自行确认; * 4. 3D 只替换当前帧选中车道线的 points,其他字段原样保留; * 5. 3D 首尾点 XYZ 原样保留;拉直只改 XY,Z 默认按原线在水平投影上的里程插值。 */ (function () { 'use strict'; const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const VERSION = '1.2.0'; const PANEL_ID = 'swj-lane-straighten-panel'; const OVERLAY_ID = 'swj-lane-2d-overlay'; const TOAST_ID = 'swj-lane-toast'; const MAX_POINTS = 1000; const DEFAULT_TARGET_POINTS = 20; const platformTransformCache = new WeakMap(); const platformViewCache = new WeakMap(); if (PAGE_WINDOW.__SWJ_LANE_TOOL__ && PAGE_WINDOW.__SWJ_LANE_TOOL__.version === VERSION) return; const state = { mounted: false, selectedTrackId: '', followPlatformSelection: true, targetPoints: DEFAULT_TARGET_POINTS, mode: 'straighten-resample', preview: null, undoStack: [], dragShortcut: 'Control', momentaryDragKeyDown: false, hasSelected3D: false, activeDrag: null, overlayTimer: null, laneListSignature: '', lastPlatformSelect: '', lastStatus: '', lastStatusType: 'info', }; /** * 平台把 CameraView 的鼠标处理函数以 `handler.bind(cameraView)` 绑定到画布上。 * 我们在 document-start 只做一层透明的 bind/addEventListener 记录, * 由此拿到平台当前真实的 CameraView(含缩放后的图片和线框屏幕坐标)。 * 不改变事件冒泡、不替换处理函数,也不修改平台数据。 */ function installCameraViewCaptureHook() { const Win = PAGE_WINDOW; const FunctionProto = Win.Function && Win.Function.prototype; const EventProto = Win.EventTarget && Win.EventTarget.prototype; if (!FunctionProto || !EventProto) return; let hook = Win.__SWJ_LANE_CAMERA_HOOK__; if (!hook || typeof hook !== 'object' || !hook.boundTargets || !hook.cameraViews) { hook = Win.__SWJ_LANE_CAMERA_HOOK__ = { version: VERSION, boundTargets: new Win.WeakMap(), cameraViews: new Win.WeakMap(), bindInstalled: false, addInstalled: false, }; } else { hook.version = VERSION; } if (hook.bindInstalled && hook.addInstalled) return; if (!hook.bindInstalled) { const originalBind = FunctionProto.bind; FunctionProto.bind = function (thisArg) { const args = Array.prototype.slice.call(arguments, 1); const bound = Reflect.apply(originalBind, this, [thisArg].concat(args)); try { if (thisArg && (typeof thisArg === 'object' || typeof thisArg === 'function')) { hook.boundTargets.set(bound, thisArg); } } catch (e) { /* ignore */ } return bound; }; hook.bindInstalled = true; } if (!hook.addInstalled) { const originalAdd = EventProto.addEventListener; EventProto.addEventListener = function (type, listener, options) { try { const owner = hook.boundTargets.get(listener); const CanvasCtor = Win.HTMLCanvasElement; if ( owner && CanvasCtor && this instanceof CanvasCtor && owner.canvas === this && owner.cameraKey && typeof owner.erasePaint === 'function' ) { hook.cameraViews.set(this, owner); } } catch (e) { /* ignore */ } return Reflect.apply(originalAdd, this, [type, listener, options]); }; hook.addInstalled = true; } } installCameraViewCaptureHook(); /* ============================== 通用工具 ============================== */ function clonePlain(value) { if (value === null || value === undefined) return value; if (Array.isArray(value)) return value.map(clonePlain); if (typeof value === 'object') { const out = {}; for (const key of Object.keys(value)) out[key] = clonePlain(value[key]); return out; } return value; } function finiteNumber(value) { if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'string' && value.trim() !== '') { const n = Number(value); return Number.isFinite(n) ? n : null; } return null; } function pointX(point) { if (Array.isArray(point)) return finiteNumber(point[0]); return point && typeof point === 'object' ? finiteNumber(point.x) : null; } function pointY(point) { if (Array.isArray(point)) return finiteNumber(point[1]); return point && typeof point === 'object' ? finiteNumber(point.y) : null; } function pointZ(point) { if (Array.isArray(point)) return finiteNumber(point[2]); return point && typeof point === 'object' ? finiteNumber(point.z) : null; } function setPointXY(point, x, y) { if (Array.isArray(point)) { point[0] = x; point[1] = y; } else if (point && typeof point === 'object') { point.x = x; point.y = y; } return point; } function setPointZ(point, z) { if (Array.isArray(point)) { if (point.length >= 3) point[2] = z; else point.push(z); } else if (point && typeof point === 'object') { point.z = z; } return point; } function round(value, digits) { const p = Math.pow(10, digits == null ? 6 : digits); return Math.round(value * p) / p; } function dist2D(ax, ay, bx, by) { return Math.hypot(ax - bx, ay - by); } function dist3D(ax, ay, az, bx, by, bz) { return Math.hypot(ax - bx, ay - by, az - bz); } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function shortcutLabel(value) { switch (value || state.dragShortcut) { case 'Alt': return 'Alt'; case 'Shift': return 'Shift'; case 'KeyM': return 'M'; case 'Control': default: return 'Ctrl'; } } function shortcutPressed(event) { switch (state.dragShortcut) { case 'Alt': return !!event.altKey; case 'Shift': return !!event.shiftKey; case 'KeyM': return !!state.momentaryDragKeyDown; case 'Control': default: return !!event.ctrlKey; } } function isTypingTarget(target) { const el = target && target.nodeType === 1 ? target : null; if (!el) return false; const tag = String(el.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable; } function interpolate(a, b, t) { return a + (b - a) * t; } function pointsFingerprint(points) { return JSON.stringify( points.map((point) => { const x = pointX(point); const y = pointY(point); const z = pointZ(point); return [x, y, z]; }) ); } function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /* ============================== 核心几何 ============================== */ /** * 3D 车道线拉直 / 重采样。 * * modes: * straighten-resample: XY 拉直,点数改为 targetCount,Z 按原线里程插值 * straighten-only: XY 拉直,点数不变,Z 按原线里程插值 * resample-only: 保持原曲线形状,只把点数改为 targetCount */ function buildStraightPoints(sourcePoints, targetCount, mode) { if (!Array.isArray(sourcePoints) || sourcePoints.length < 2) { throw new Error('车道线至少需要 2 个点'); } const source = []; for (let i = 0; i < sourcePoints.length; i++) { const p = sourcePoints[i]; const x = pointX(p); const y = pointY(p); const z = pointZ(p); if (x === null || y === null) continue; source.push({ originalIndex: i, point: p, x, y, z: z === null ? 0 : z, hasZ: z !== null, }); } if (source.length < 2) throw new Error('有效点不足 2 个'); if (mode !== 'resample-only' && mode !== 'straighten-only' && mode !== 'straighten-resample') { throw new Error('未知处理模式'); } let count = mode === 'straighten-only' ? source.length : Math.floor(Number(targetCount)); if (!Number.isFinite(count)) count = source.length; count = clamp(count, 2, MAX_POINTS); const first = source[0]; const last = source[source.length - 1]; const endpointDistanceXY = dist2D(first.x, first.y, last.x, last.y); if (endpointDistanceXY < 1e-9) throw new Error('首尾点在 XY 平面重合,无法定义直线方向'); const xyCumulative = [0]; const xyzCumulative = [0]; for (let i = 1; i < source.length; i++) { const prev = source[i - 1]; const cur = source[i]; xyCumulative.push(xyCumulative[i - 1] + dist2D(prev.x, prev.y, cur.x, cur.y)); xyzCumulative.push( xyzCumulative[i - 1] + dist3D(prev.x, prev.y, prev.z, cur.x, cur.y, cur.z) ); } const xyLength = xyCumulative[xyCumulative.length - 1]; const xyzLength = xyzCumulative[xyzCumulative.length - 1]; function interpolateAlong(cumulative, distance, pickXYZ) { if (distance <= 0) return pickXYZ(source[0]); const total = cumulative[cumulative.length - 1]; if (distance >= total) return pickXYZ(source[source.length - 1]); let lo = 0; let hi = cumulative.length - 1; while (lo + 1 < hi) { const mid = (lo + hi) >> 1; if (cumulative[mid] <= distance) lo = mid; else hi = mid; } const span = cumulative[hi] - cumulative[lo]; const t = span > 1e-12 ? (distance - cumulative[lo]) / span : 0; return pickXYZ(source[lo], source[hi], t); } function zAtXYDistance(distance) { return interpolateAlong( xyCumulative, distance, (a, b, t) => { if (!b) return a.z; return interpolate(a.z, b.z, t); } ); } function nearestSourceDistance(distance, cumulative) { let best = 0; let bestDiff = Infinity; for (let i = 0; i < cumulative.length; i++) { const diff = Math.abs(cumulative[i] - distance); if (diff < bestDiff) { bestDiff = diff; best = i; } } return best; } const output = []; if (mode === 'resample-only') { for (let k = 0; k < count; k++) { const distance = xyzLength * (count === 1 ? 0 : k / (count - 1)); const sampled = interpolateAlong( xyzCumulative, distance, (a, b, t) => { if (!b) return [a.x, a.y, a.z]; return [ interpolate(a.x, b.x, t), interpolate(a.y, b.y, t), interpolate(a.z, b.z, t), ]; } ); const nearest = nearestSourceDistance(distance, xyzCumulative); const p = clonePlain(source[nearest].point); setPointXY(p, round(sampled[0], 6), round(sampled[1], 6)); if (source[nearest].hasZ) setPointZ(p, round(sampled[2], 6)); output.push(p); } } else { for (let k = 0; k < count; k++) { const t = count === 1 ? 0 : k / (count - 1); const x = first.x + (last.x - first.x) * t; const y = first.y + (last.y - first.y) * t; const z = zAtXYDistance(xyLength * t); const nearest = nearestSourceDistance(xyLength * t, xyCumulative); const p = clonePlain(source[nearest].point); setPointXY(p, round(x, 6), round(y, 6)); if (source[nearest].hasZ) setPointZ(p, round(z, 6)); output.push(p); } } // 首尾必须原样保留,包括点的全部原始字段。 output[0] = clonePlain(first.point); output[output.length - 1] = clonePlain(last.point); let maxDeviationXY = 0; const vx = last.x - first.x; const vy = last.y - first.y; const endpointLenSq = vx * vx + vy * vy; for (const item of source) { const wx = item.x - first.x; const wy = item.y - first.y; const t = endpointLenSq > 0 ? clamp((wx * vx + wy * vy) / endpointLenSq, 0, 1) : 0; const px = first.x + vx * t; const py = first.y + vy * t; maxDeviationXY = Math.max(maxDeviationXY, dist2D(item.x, item.y, px, py)); } let zMinOriginal = Infinity; let zMaxOriginal = -Infinity; for (const item of source) { zMinOriginal = Math.min(zMinOriginal, item.z); zMaxOriginal = Math.max(zMaxOriginal, item.z); } let zMinOutput = Infinity; let zMaxOutput = -Infinity; for (const p of output) { const z = pointZ(p); if (z === null) continue; zMinOutput = Math.min(zMinOutput, z); zMaxOutput = Math.max(zMaxOutput, z); } const newFirst = output[0]; const newLast = output[output.length - 1]; const endpointError = Math.max( dist3D(first.x, first.y, first.z, pointX(newFirst), pointY(newFirst), pointZ(newFirst) || 0), dist3D(last.x, last.y, last.z, pointX(newLast), pointY(newLast), pointZ(newLast) || 0) ); const newLength3D = (() => { let total = 0; for (let i = 1; i < output.length; i++) { const a = output[i - 1]; const b = output[i]; total += dist3D( pointX(a), pointY(a), pointZ(a) === null ? 0 : pointZ(a), pointX(b), pointY(b), pointZ(b) === null ? 0 : pointZ(b) ); } return total; })(); return { points: output, meta: { mode, originalCount: source.length, outputCount: output.length, maxDeviationXY, xyLength, originalLength3D: xyzLength, newLength3D, endpointError, zMinOriginal: zMinOriginal === Infinity ? null : zMinOriginal, zMaxOriginal: zMaxOriginal === -Infinity ? null : zMaxOriginal, zMinOutput: zMinOutput === Infinity ? null : zMinOutput, zMaxOutput: zMaxOutput === -Infinity ? null : zMaxOutput, endpointDistanceXY, }, }; } /** 2D 整线平移:不改原点、不改点数、不改点顺序。 */ function translate2DPoints(sourcePoints, dx, dy) { if (!Array.isArray(sourcePoints)) throw new Error('2D 车道线 points 无效'); const xShift = finiteNumber(dx); const yShift = finiteNumber(dy); if (xShift === null || yShift === null) throw new Error('平移量必须是数字'); return sourcePoints.map((point) => { const p = clonePlain(point); const x = pointX(p); const y = pointY(p); if (x === null || y === null) return p; return setPointXY(p, round(x + xShift, 4), round(y + yShift, 4)); }); } /* ============================== 平台适配 ============================== */ function getStoreManager() { try { if (PAGE_WINDOW.instance && PAGE_WINDOW.instance.StoreManager) { return PAGE_WINDOW.instance.StoreManager; } } catch (e) { /* ignore */ } try { const app = document.querySelector('#app')?.__vue_app__; const store = app?.config?.globalProperties?.$store; if (!store) return null; return { marksState: () => store.state.marks, statusState: () => store.state.status, configState: () => store.state.config, marksGetter: (name) => store.getters['marks/' + name], configGetter: (name) => store.getters['config/' + name], getCurFrameId: () => store.state.status.curFrameId, markCommit: (name, payload) => store.commit('marks/' + name, payload), }; } catch (e) { return null; } } function getRawStore() { try { return document.querySelector('#app')?.__vue_app__?.config?.globalProperties?.$store || null; } catch (e) { return null; } } function getFrameId(sm) { try { if (sm && typeof sm.getCurFrameId === 'function') { const id = sm.getCurFrameId(); if (id !== undefined && id !== null) return String(id); } const id = sm?.statusState?.().curFrameId; return id === undefined || id === null ? null : String(id); } catch (e) { return null; } } function getMarks(sm) { try { return sm?.marksState?.().marks || null; } catch (e) { return null; } } function getSelectInfo(sm) { try { return sm?.statusState?.().selectMarkInfo || {}; } catch (e) { return {}; } } function getFrameMark(mark, frameId, dimension) { if (!mark || !mark[dimension]) return null; return mark[dimension][frameId] || mark[dimension][String(frameId)] || null; } function getLane3D(sm, trackId, frameId) { const marks = getMarks(sm); const mark = marks && marks[trackId]; if (!mark) return null; const frameMark = getFrameMark(mark, frameId, '3d'); if (!frameMark || frameMark.type !== 'line_3d') return null; if (!Array.isArray(frameMark.points) || frameMark.points.length < 2) return null; return { trackId: String(trackId), classId: mark.class, mark, frameMark }; } function collectLanes(sm, frameId) { const marks = getMarks(sm); const out = []; if (!marks) return out; for (const trackId of Object.keys(marks)) { const lane = getLane3D(sm, trackId, frameId); if (!lane) continue; out.push({ trackId: String(trackId), classId: lane.classId, count: lane.frameMark.points.length, }); } out.sort((a, b) => { const ai = Number(a.trackId); const bi = Number(b.trackId); if (Number.isFinite(ai) && Number.isFinite(bi)) return ai - bi; return a.trackId.localeCompare(b.trackId, 'zh-CN', { numeric: true }); }); return out; } function collectIndependent2DMarks(sm, frameId, lanes) { const marks = getMarks(sm); const laneIds = new Set((lanes || []).map((lane) => lane.trackId)); const out = []; if (!marks) return out; for (const trackId of Object.keys(marks)) { if (laneIds.has(String(trackId))) continue; const marks2D = get2DMarksForLane(sm, trackId, frameId); if (marks2D.length === 0) continue; const mark = marks[trackId]; const polygon = marks2D.some( (item) => item.type === 'polygon' || item.type === 'polygon_2d' ); out.push({ trackId: String(trackId), classId: mark && mark.class, count: marks2D[0].points.length, kindLabel: polygon ? '2D多边形' : '2D线', }); } out.sort((a, b) => { const ai = Number(a.trackId); const bi = Number(b.trackId); if (Number.isFinite(ai) && Number.isFinite(bi)) return ai - bi; return a.trackId.localeCompare(b.trackId, 'zh-CN', { numeric: true }); }); return out; } function get2DMarksForLane(sm, trackId, frameId) { const marks = getMarks(sm); const mark = marks && marks[trackId]; if (!mark) return []; const frameMarks = getFrameMark(mark, frameId, '2d'); if (!Array.isArray(frameMarks)) return []; return frameMarks.filter( (item) => item && (item.type === 'line' || item.type === 'polygon' || item.type === 'line_2d' || item.type === 'polygon_2d') && Array.isArray(item.points) && item.points.length > 0 ); } function getConfigFrame(sm) { try { const config = sm.configState(); if (config && typeof config.frame === 'function') { const frame = config.frame(); if (frame) return frame; } if (config && config.curFrame) return config.curFrame; } catch (e) { /* ignore */ } try { if (sm && typeof sm.frame === 'function') return sm.frame(); } catch (e) { /* ignore */ } return null; } function getCameraResolution(sm, cameraKey, frameId) { let frame = getConfigFrame(sm); if (!frame) { try { const config = sm.configState(); const index = config?.frameIds?.[frameId]; const frames = config?.frames; if (Array.isArray(frames) && Number.isFinite(Number(index))) { frame = frames[Number(index)] || null; } } catch (e) { /* ignore */ } } const cameras = frame?.slave_info || frame?.slaveInfo || null; const item = cameras && cameras[cameraKey]; const raw = Array.isArray(item) ? item[0]?.resolution : item?.resolution; if (typeof raw === 'string') { const parts = raw.split(/[*xX]/).map((v) => Number(v)); if (parts.length >= 2 && parts[0] > 0 && parts[1] > 0) { return { width: parts[0], height: parts[1], source: 'platform' }; } } return null; } function isVisibleCanvas(canvas) { if (!canvas) return false; const rect = canvas.getBoundingClientRect(); if (rect.width < 20 || rect.height < 20) return false; const style = getComputedStyle(canvas); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; return true; } function getVisibleCameras() { const list = []; const canvases = document.querySelectorAll('canvas.camera-canvas[data-value]'); for (const canvas of canvases) { if (!isVisibleCanvas(canvas)) continue; const rect = canvas.getBoundingClientRect(); list.push({ canvas, cameraKey: canvas.getAttribute('data-value'), rect, area: rect.width * rect.height, }); } list.sort((a, b) => b.area - a.area); return list; } /** * 平台相机图允许缩放/平移,DragImg 内部保存了当前图片显示位置。 * 这不是公开 API,因此只在 Vue 运行时对象里做有界只读探测; * 找不到时自动退回“图片自适应铺满画布”的换算。 */ function findPlatformImageTransform(canvas) { if (!canvas) return null; try { const capturedView = PAGE_WINDOW.__SWJ_LANE_CAMERA_HOOK__?.cameraViews?.get(canvas); const capturedBg = capturedView && capturedView.bgImg; if ( capturedBg && capturedBg.canvas === canvas && capturedBg.imageWidth > 0 && capturedBg.imageHeight > 0 && Number.isFinite(capturedBg.x) && Number.isFinite(capturedBg.y) && capturedBg.width > 0 && capturedBg.height > 0 ) { platformTransformCache.set(canvas, capturedBg); return capturedBg; } } catch (e) { /* ignore */ } const cached = platformTransformCache.get(canvas); if ( cached && cached.canvas === canvas && cached.imageWidth > 0 && cached.imageHeight > 0 && Number.isFinite(cached.x) && Number.isFinite(cached.y) ) { return cached; } const seen = new WeakSet(); const queue = []; for (const key of ['__vueParentComponent', '__vueComponent', '__vue_app__']) { try { if (canvas[key]) queue.push({ value: canvas[key], depth: 0 }); } catch (e) { /* ignore */ } } let visited = 0; while (queue.length && visited < 1200) { const item = queue.shift(); const value = item.value; if (!value || (typeof value !== 'object' && typeof value !== 'function')) continue; if (seen.has(value)) continue; seen.add(value); visited++; try { if ( value.canvas === canvas && value.imageWidth > 0 && value.imageHeight > 0 && Number.isFinite(value.x) && Number.isFinite(value.y) && value.width > 0 && value.height > 0 ) { platformTransformCache.set(canvas, value); return value; } const bg = value.bgImg; if ( bg && bg.canvas === canvas && bg.imageWidth > 0 && bg.imageHeight > 0 && Number.isFinite(bg.x) && Number.isFinite(bg.y) && bg.width > 0 && bg.height > 0 ) { platformTransformCache.set(canvas, bg); return bg; } } catch (e) { /* ignore getters that throw */ } if (item.depth >= 6) continue; let keys = []; try { keys = Object.keys(value); } catch (e) { continue; } for (const key of keys) { if (/^(ownerDocument|defaultView|document|window|parentElement|firstChild|nextSibling)$/.test(key)) { continue; } let child; try { child = value[key]; } catch (e) { continue; } if (!child || (typeof child !== 'object' && typeof child !== 'function')) continue; const NodeCtor = PAGE_WINDOW.Node || (typeof Node !== 'undefined' ? Node : null); if ((NodeCtor && child instanceof NodeCtor) || child === PAGE_WINDOW || child === document) continue; queue.push({ value: child, depth: item.depth + 1 }); } } return null; } /** * 找到平台的 CameraView(含 bgImg.marks 与 erasePaint)。 * 优先级高于覆盖层:拖动时可以暂时移动平台自己绘制的整条线, * 松开后只提交一次,视觉上就是“线跟着鼠标走”。 */ function findPlatformCameraView(canvas) { if (!canvas) return null; try { const captured = PAGE_WINDOW.__SWJ_LANE_CAMERA_HOOK__?.cameraViews?.get(canvas); if ( captured && captured.bgImg && captured.bgImg.canvas === canvas && Array.isArray(captured.bgImg.marks) && typeof captured.erasePaint === 'function' ) { platformViewCache.set(canvas, captured); return captured; } } catch (e) { /* ignore */ } const cached = platformViewCache.get(canvas); if (cached && cached.bgImg && cached.bgImg.canvas === canvas) return cached; const targetBg = findPlatformImageTransform(canvas); if (!targetBg) return null; const seen = new WeakSet(); const queue = []; for (const key of ['__vueParentComponent', '__vueComponent', '__vue_app__']) { try { if (canvas[key]) queue.push({ value: canvas[key], depth: 0 }); } catch (e) { /* ignore */ } } let visited = 0; while (queue.length && visited < 1500) { const item = queue.shift(); const value = item.value; if (!value || (typeof value !== 'object' && typeof value !== 'function')) continue; if (seen.has(value)) continue; seen.add(value); visited++; try { if ( value.bgImg === targetBg && typeof value.erasePaint === 'function' && Array.isArray(value.bgImg.marks) ) { platformViewCache.set(canvas, value); return value; } if ( value.bgImg && value.bgImg.canvas === canvas && typeof value.erasePaint === 'function' && Array.isArray(value.bgImg.marks) ) { platformViewCache.set(canvas, value); return value; } } catch (e) { /* ignore getters that throw */ } if (item.depth >= 7) continue; let keys = []; try { keys = Object.keys(value); } catch (e) { continue; } for (const key of keys) { if (/^(ownerDocument|defaultView|document|window|parentElement|firstChild|nextSibling)$/.test(key)) { continue; } let child; try { child = value[key]; } catch (e) { continue; } if (!child || (typeof child !== 'object' && typeof child !== 'function')) continue; const NodeCtor = PAGE_WINDOW.Node || (typeof Node !== 'undefined' ? Node : null); if ((NodeCtor && child instanceof NodeCtor) || child === PAGE_WINDOW || child === document) continue; queue.push({ value: child, depth: item.depth + 1 }); } } return null; } function get2DContext(sm, trackId, frameId) { if (!trackId) return null; const marks = get2DMarksForLane(sm, trackId, frameId); if (marks.length === 0) return null; const selectInfo = getSelectInfo(sm); const visible = getVisibleCameras(); let cameraKey = selectInfo.cameraKey || null; let mark = cameraKey ? marks.find((m) => m.camera === cameraKey) : null; if (!mark) { for (const item of visible) { const found = marks.find((m) => m.camera === item.cameraKey); if (found) { mark = found; cameraKey = item.cameraKey; break; } } } if (!mark) { mark = marks[0]; cameraKey = mark.camera; } let canvasInfo = visible.find((item) => item.cameraKey === cameraKey) || null; if (!canvasInfo) { const canvas = document.querySelector( 'canvas.camera-canvas[data-value="' + String(cameraKey).replace(/"/g, '\\"') + '"]' ); if (canvas) { canvasInfo = { canvas, cameraKey, rect: canvas.getBoundingClientRect(), area: 0, }; } } if (!canvasInfo) { return { mark, marks, cameraKey, canvas: null, rect: null, resolution: null, scale: 1, offsetX: 0, offsetY: 0 }; } const rect = canvasInfo.rect; const resolution = getCameraResolution(sm, cameraKey, frameId) || { width: canvasInfo.canvas.width || rect.width, height: canvasInfo.canvas.height || rect.height, source: 'canvas', }; const platformTransform = findPlatformImageTransform(canvasInfo.canvas); let scaleX; let scaleY; let offsetX; let offsetY; let mappingSource = 'fit'; let imageWidth = resolution.width; let imageHeight = resolution.height; if (platformTransform) { imageWidth = Number(platformTransform.imageWidth); imageHeight = Number(platformTransform.imageHeight); scaleX = Number(platformTransform.width) / imageWidth; scaleY = Number(platformTransform.height) / imageHeight; offsetX = Number(platformTransform.x); offsetY = Number(platformTransform.y); mappingSource = 'platform'; } else { const scale = Math.min(rect.width / resolution.width, rect.height / resolution.height); scaleX = scale; scaleY = scale; offsetX = (rect.width - resolution.width * scale) / 2; offsetY = (rect.height - resolution.height * scale) / 2; } const view = findPlatformCameraView(canvasInfo.canvas); let shape = null; if (view && view.bgImg && Array.isArray(view.bgImg.marks)) { shape = view.bgImg.marks.find( (item) => item && String(item.trackId) === String(trackId) && (item.type === 'line' || item.type === 'polygon' || item.type === 'line_2d' || item.type === 'polygon_2d' || item.shapeType === '线') && Array.isArray(item.points) ) || null; } return { mark, marks, view, shape, cameraKey, canvas: canvasInfo.canvas, rect, resolution: { width: imageWidth, height: imageHeight, source: mappingSource === 'platform' ? 'platform-transform' : resolution.source, }, scale: (scaleX + scaleY) / 2, scaleX, scaleY, offsetX, offsetY, mappingSource, }; } function imageToLocalScreen(ctx, x, y) { return { x: ctx.offsetX + x * ctx.scaleX, y: ctx.offsetY + y * ctx.scaleY, }; } function distanceToSegment(px, py, ax, ay, bx, by) { const vx = bx - ax; const vy = by - ay; const lenSq = vx * vx + vy * vy; if (lenSq < 1e-12) return Math.hypot(px - ax, py - ay); const t = clamp(((px - ax) * vx + (py - ay) * vy) / lenSq, 0, 1); return Math.hypot(px - (ax + vx * t), py - (ay + vy * t)); } function isNear2DLine(ctx, clientX, clientY, tolerance) { if (!ctx || !ctx.rect || !ctx.mark || !Array.isArray(ctx.mark.points)) return false; const x = clientX - ctx.rect.left; const y = clientY - ctx.rect.top; const points = ctx.mark.points .map((point) => { const px = pointX(point); const py = pointY(point); if (px === null || py === null) return null; return imageToLocalScreen(ctx, px, py); }) .filter(Boolean); if (points.length === 0) return false; if (points.length === 1) return Math.hypot(x - points[0].x, y - points[0].y) <= tolerance; for (let i = 1; i < points.length; i++) { if (distanceToSegment(x, y, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y) <= tolerance) { return true; } } return false; } function commitMutation(name, payload) { const sm = getStoreManager(); if (!sm) throw new Error('无法访问平台 StoreManager'); if (typeof sm.markCommit === 'function') { sm.markCommit(name, payload); return; } const store = getRawStore(); if (store && typeof store.commit === 'function') { store.commit('marks/' + name, payload); return; } throw new Error('平台 markCommit 不可用'); } function pushUndo(entry) { state.undoStack.push(entry); if (state.undoStack.length > 10) state.undoStack.shift(); updateUndoButton(); } function undoLast() { const entry = state.undoStack[state.undoStack.length - 1]; if (!entry) { setStatus('没有可撤销的插件操作', 'warning'); return; } const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || frameId !== entry.frameId) { setStatus('请先回到操作时的帧(' + entry.frameId + ')再撤销', 'warning'); return; } try { if (entry.kind === '3d') { commitMutation('addOrReplaceMark3D', [ 3, clonePlain(entry.mark), entry.trackId, entry.classId, entry.frameId, null, ]); } else if (entry.kind === '2d') { commitMutation('addOrReplaceMark2D', [ clonePlain(entry.mark), entry.trackId, entry.classId, entry.frameId, null, true, ]); } state.undoStack.pop(); updateUndoButton(); state.preview = null; setStatus('已撤销:' + entry.label, 'success'); refreshUi(true); } catch (e) { setStatus('撤销失败:' + e.message, 'error'); } } /* ============================== 2D 覆盖层 / 拖动 ============================== */ function hide2DOverlay() { const el = document.getElementById(OVERLAY_ID); if (el) el.style.display = 'none'; } function ensure2DOverlay(rect) { let overlay = document.getElementById(OVERLAY_ID); if (!overlay) { overlay = document.createElement('canvas'); overlay.id = OVERLAY_ID; overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:2147482900;display:none;' + 'background:transparent;image-rendering:auto;'; document.body.appendChild(overlay); } const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); const width = Math.max(1, Math.round(rect.width)); const height = Math.max(1, Math.round(rect.height)); overlay.style.left = rect.left + 'px'; overlay.style.top = rect.top + 'px'; overlay.style.width = width + 'px'; overlay.style.height = height + 'px'; if (overlay.width !== Math.round(width * dpr) || overlay.height !== Math.round(height * dpr)) { overlay.width = Math.round(width * dpr); overlay.height = Math.round(height * dpr); } const ctx = overlay.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); overlay.style.display = 'block'; return { overlay, ctx, width, height }; } function draw2DOverlay(ctx, offsetX, offsetY, modeLabel) { if (!ctx || !ctx.rect || !ctx.mark || !isVisibleCanvas(ctx.canvas)) { hide2DOverlay(); return; } const { overlay, ctx: canvasCtx, width, height } = ensure2DOverlay(ctx.rect); const points = ctx.mark.points .map((point) => { const px = pointX(point); const py = pointY(point); if (px === null || py === null) return null; return imageToLocalScreen(ctx, px, py); }) .filter(Boolean); if (points.length === 0) { overlay.style.display = 'none'; return; } canvasCtx.save(); canvasCtx.lineJoin = 'round'; canvasCtx.lineCap = 'round'; canvasCtx.strokeStyle = offsetX || offsetY ? '#00e5ff' : 'rgba(0,229,255,.62)'; canvasCtx.lineWidth = offsetX || offsetY ? 2.5 : 1.5; canvasCtx.setLineDash(offsetX || offsetY ? [] : [7, 5]); canvasCtx.shadowColor = 'rgba(0,229,255,.9)'; canvasCtx.shadowBlur = offsetX || offsetY ? 7 : 3; canvasCtx.beginPath(); points.forEach((p, index) => { const x = p.x + offsetX; const y = p.y + offsetY; if (index === 0) canvasCtx.moveTo(x, y); else canvasCtx.lineTo(x, y); }); canvasCtx.stroke(); canvasCtx.shadowBlur = 0; canvasCtx.setLineDash([]); for (const index of [0, points.length - 1]) { const p = points[index]; canvasCtx.beginPath(); canvasCtx.arc(p.x + offsetX, p.y + offsetY, 4.5, 0, Math.PI * 2); canvasCtx.fillStyle = '#00151b'; canvasCtx.fill(); canvasCtx.lineWidth = 2; canvasCtx.strokeStyle = '#00e5ff'; canvasCtx.stroke(); } if (modeLabel) { const text = modeLabel; canvasCtx.font = '12px "Microsoft YaHei", sans-serif'; const metrics = canvasCtx.measureText(text); const tx = clamp(points[0].x + offsetX + 8, 6, Math.max(6, width - metrics.width - 14)); const ty = clamp(points[0].y + offsetY - 28, 6, Math.max(6, height - 10)); canvasCtx.fillStyle = 'rgba(0,15,20,.86)'; canvasCtx.fillRect(tx - 6, ty - 4, metrics.width + 12, 20); canvasCtx.fillStyle = '#9ff5ff'; canvasCtx.fillText(text, tx, ty + 11); } canvasCtx.restore(); } function refresh2DOverlay() { const drag = state.activeDrag; if (!drag || drag.mode !== 'overlay') { hide2DOverlay(); return; } draw2DOverlay( drag.ctx, drag.dxScreen, drag.dyScreen, '拖到目标位置:' + (drag.dxScreen >= 0 ? '+' : '') + drag.dxScreen.toFixed(1) + ', ' + (drag.dyScreen >= 0 ? '+' : '') + drag.dyScreen.toFixed(1) + ' px' ); } function applyNativeShapeOffset(drag) { if (!drag || drag.mode !== 'native' || !drag.shape || !drag.screenPoints) return; for (let i = 0; i < drag.shape.points.length && i < drag.screenPoints.length; i++) { drag.shape.points[i].x = drag.screenPoints[i].x + drag.dxScreen; drag.shape.points[i].y = drag.screenPoints[i].y + drag.dyScreen; } try { drag.view.erasePaint(); } catch (e) { restoreNativeShape(drag); drag.mode = 'overlay'; refresh2DOverlay(); } } function restoreNativeShape(drag) { if (!drag || drag.mode !== 'native') return; if (drag.shape && drag.screenPoints) { for (let i = 0; i < drag.shape.points.length && i < drag.screenPoints.length; i++) { drag.shape.points[i].x = drag.screenPoints[i].x; drag.shape.points[i].y = drag.screenPoints[i].y; } try { drag.view.erasePaint(); } catch (e) { /* ignore */ } } if (drag.ctx && drag.ctx.canvas) drag.ctx.canvas.style.cursor = drag.previousCursor || ''; } function nativePointsToReal(drag, currentPoints) { if (!drag || !drag.shape || !drag.ctx) return null; if (!Array.isArray(currentPoints) || drag.shape.points.length !== currentPoints.length) return null; return currentPoints.map((point, index) => { const screenPoint = drag.shape.points[index]; const p = clonePlain(point); const x = (Number(screenPoint.x) - drag.ctx.offsetX) / drag.ctx.scaleX; const y = (Number(screenPoint.y) - drag.ctx.offsetY) / drag.ctx.scaleY; if (!Number.isFinite(x) || !Number.isFinite(y)) return p; return setPointXY(p, round(x, 4), round(y, 4)); }); } function onDocumentMouseDown(event) { if (state.activeDrag || event.button !== 0 || !shortcutPressed(event)) return; const panel = document.getElementById(PANEL_ID); if (panel && panel.contains(event.target)) return; const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || !frameId || !state.selectedTrackId) return; const ctx = get2DContext(sm, state.selectedTrackId, frameId); if (!ctx || !ctx.canvas || !isVisibleCanvas(ctx.canvas)) return; const rect = ctx.canvas.getBoundingClientRect(); if ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom ) { return; } event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); const nativeMode = !!(ctx.view && ctx.shape && ctx.shape.points && ctx.shape.points.length); state.activeDrag = { mode: nativeMode ? 'native' : 'overlay', ctx, view: ctx.view || null, shape: ctx.shape || null, startClientX: event.clientX, startClientY: event.clientY, dxScreen: 0, dyScreen: 0, cameraKey: ctx.cameraKey, previousCursor: ctx.canvas.style.cursor || '', screenPoints: nativeMode ? ctx.shape.points.map((point) => ({ x: Number(point.x), y: Number(point.y) })) : null, }; ctx.canvas.style.cursor = 'grabbing'; if (!nativeMode) draw2DOverlay(ctx, 0, 0, '拖动中:松开后提交'); setStatus( '整线拖动中:移动到目标位置后松开鼠标;Esc 取消', 'info' ); } function onDocumentMouseMove(event) { const drag = state.activeDrag; if (!drag) return; event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); let dx = event.clientX - drag.startClientX; let dy = event.clientY - drag.startClientY; if (event.shiftKey && state.dragShortcut !== 'Shift') { if (Math.abs(dx) >= Math.abs(dy)) dy = 0; else dx = 0; } drag.dxScreen = dx; drag.dyScreen = dy; const dxImage = dx / drag.ctx.scaleX; const dyImage = dy / drag.ctx.scaleY; if (drag.mode === 'native') { applyNativeShapeOffset(drag); } else { refresh2DOverlay(); } setStatus( '2D 平移中:' + dxImage.toFixed(1) + ', ' + dyImage.toFixed(1) + ' 像素(松开后提交)', 'info' ); } function onDocumentMouseUp(event) { const drag = state.activeDrag; if (!drag) return; event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); state.activeDrag = null; const dxImage = drag.dxScreen / drag.ctx.scaleX; const dyImage = drag.dyScreen / drag.ctx.scaleY; if (Math.abs(drag.dxScreen) < 0.01 && Math.abs(drag.dyScreen) < 0.01) { if (drag.mode === 'native') restoreNativeShape(drag); hide2DOverlay(); if (drag.ctx && drag.ctx.canvas) drag.ctx.canvas.style.cursor = drag.previousCursor || ''; setStatus('移动量为 0,未写入', 'info'); return; } if (drag.mode === 'native') { applyNativeShapeOffset(drag); const sm = getStoreManager(); const frameId = getFrameId(sm); const current = sm && frameId ? get2DMarksForLane(sm, state.selectedTrackId, frameId).find( (item) => item.camera === drag.cameraKey ) : null; const nextPoints = current ? nativePointsToReal(drag, current.points) : null; if (!nextPoints) { restoreNativeShape(drag); setStatus('2D 整线拖动失败:无法读取当前线条,未写入', 'warning'); return; } if (!commit2DPoints(drag.ctx, nextPoints, '快捷键整线拖动', dxImage, dyImage)) { restoreNativeShape(drag); } if (drag.ctx && drag.ctx.canvas) drag.ctx.canvas.style.cursor = drag.previousCursor || ''; } else { apply2DTranslation(drag.ctx, dxImage, dyImage, '快捷键整线拖动'); } hide2DOverlay(); } function cancelActiveDrag() { const drag = state.activeDrag; if (!drag) return false; state.activeDrag = null; if (drag.mode === 'native') restoreNativeShape(drag); else if (drag.ctx && drag.ctx.canvas) drag.ctx.canvas.style.cursor = drag.previousCursor || ''; hide2DOverlay(); setStatus('已取消本次 2D 拖动,未写入', 'info'); return true; } function apply2DTranslation(ctx, dx, dy, label) { if (!ctx || !ctx.mark) { setStatus('没有找到可移动的 2D 车道线', 'warning'); return false; } const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || !frameId) { setStatus('平台尚未就绪', 'warning'); return false; } const current = get2DMarksForLane(sm, state.selectedTrackId, frameId).find( (item) => item.camera === ctx.cameraKey ); if (!current) { setStatus('所选 2D 车道线已不存在,请重新选择', 'warning'); return false; } let points; try { points = translate2DPoints(current.points, dx, dy); } catch (e) { setStatus('2D 平移失败:' + e.message, 'error'); return false; } return commit2DPoints(ctx, points, label || '2D 整线平移', dx, dy); } function commit2DPoints(ctx, nextPoints, label, dx, dy) { if (!ctx || !ctx.mark || !Array.isArray(nextPoints)) { setStatus('没有找到可移动的 2D 车道线', 'warning'); return false; } const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || !frameId) { setStatus('平台尚未就绪', 'warning'); return false; } const marks = getMarks(sm); const trackMark = marks && marks[state.selectedTrackId]; if (!trackMark) { setStatus('所选车道线已不存在', 'warning'); return false; } const current = get2DMarksForLane(sm, state.selectedTrackId, frameId).find( (item) => item.camera === ctx.cameraKey ); if (!current) { setStatus('所选 2D 车道线已不存在,请重新选择', 'warning'); return false; } const before = clonePlain(current); const after = clonePlain(current); try { after.points = clonePlain(nextPoints); after.is_add = 0; after.is_edit = 1; commitMutation('addOrReplaceMark2D', [ after, state.selectedTrackId, trackMark.class, frameId, null, true, ]); pushUndo({ kind: '2d', trackId: state.selectedTrackId, classId: trackMark.class, frameId, camera: ctx.cameraKey, mark: before, label: (label || '2D 整线平移') + ' ' + (dx >= 0 ? '+' : '') + Number(dx).toFixed(1) + ', ' + (dy >= 0 ? '+' : '') + Number(dy).toFixed(1) + ' px', }); setStatus( '已提交 2D 整线平移:' + Number(dx).toFixed(1) + ', ' + Number(dy).toFixed(1) + ' 像素(尚未保存,请用平台保存按钮)', 'success' ); refreshUi(true); return true; } catch (e) { setStatus('2D 平移失败:' + e.message, 'error'); return false; } } /* ============================== 3D 预览 / 应用 ============================== */ function readControls() { const countInput = document.getElementById('swj-lane-count'); const modeSelect = document.getElementById('swj-lane-mode'); const count = clamp( Math.floor(Number(countInput && countInput.value) || DEFAULT_TARGET_POINTS), 2, MAX_POINTS ); state.targetPoints = count; state.mode = (modeSelect && modeSelect.value) || 'straighten-resample'; return { count, mode: state.mode }; } function buildPreview() { const sm = getStoreManager(); const frameId = getFrameId(sm); const trackId = state.selectedTrackId; if (!sm || !frameId) { setStatus('平台尚未就绪', 'warning'); return null; } if (!trackId) { setStatus('请先选择一条 3D 车道线', 'warning'); return null; } const lane = getLane3D(sm, trackId, frameId); if (!lane) { setStatus('当前帧找不到所选 line_3d 车道线', 'warning'); return null; } const controls = readControls(); try { const result = buildStraightPoints(lane.frameMark.points, controls.count, controls.mode); state.preview = { trackId, classId: lane.classId, frameId, mode: controls.mode, targetCount: controls.count, fingerprint: pointsFingerprint(lane.frameMark.points), sourcePoints: clonePlain(lane.frameMark.points), outputPoints: clonePlain(result.points), meta: result.meta, }; renderPreviewStats(state.preview); setStatus('预览已生成,尚未修改平台数据', 'success'); updateActionButtons(); return state.preview; } catch (e) { state.preview = null; renderPreviewStats(null); setStatus('预览失败:' + e.message, 'error'); updateActionButtons(); return null; } } function applyPreview() { const preview = state.preview; if (!preview) { setStatus('请先生成预览', 'warning'); return false; } const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || !frameId) { setStatus('平台尚未就绪', 'warning'); return false; } if (frameId !== preview.frameId) { setStatus('帧已切换,请重新生成预览', 'warning'); return false; } const lane = getLane3D(sm, preview.trackId, frameId); if (!lane) { setStatus('所选车道线已不存在,请重新选择', 'warning'); return false; } if (pointsFingerprint(lane.frameMark.points) !== preview.fingerprint) { setStatus('车道线数据在预览后发生了变化,请重新生成预览', 'warning'); return false; } try { const before = clonePlain(lane.frameMark); const after = clonePlain(lane.frameMark); after.points = clonePlain(preview.outputPoints); commitMutation('addOrReplaceMark3D', [ 3, after, preview.trackId, lane.classId, frameId, null, ]); pushUndo({ kind: '3d', trackId: preview.trackId, classId: lane.classId, frameId, mark: before, label: '3D 拉直/重采样 ' + before.points.length + ' → ' + after.points.length + ' 点', }); setStatus( '已提交 3D 车道线:' + before.points.length + ' → ' + after.points.length + ' 点(尚未保存,请用平台保存按钮)', 'success' ); state.preview = null; renderPreviewStats(null); refreshUi(true); return true; } catch (e) { setStatus('应用失败:' + e.message, 'error'); return false; } } /* ============================== 面板 UI ============================== */ function injectStyle() { if (document.getElementById('swj-lane-style')) return; const style = document.createElement('style'); style.id = 'swj-lane-style'; style.textContent = ` #${PANEL_ID} { position: fixed; right: 18px; top: 82px; width: 374px; max-height: calc(100vh - 108px); overflow: auto; box-sizing: border-box; z-index: 2147483000; color: #dce8f2; background: rgba(18, 24, 31, .97); border: 1px solid rgba(98, 165, 205, .45); border-radius: 11px; box-shadow: 0 12px 36px rgba(0,0,0,.48); backdrop-filter: blur(10px); font: 12px/1.55 "Microsoft YaHei", "PingFang SC", sans-serif; user-select: none; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} .swj-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; cursor: move; background: linear-gradient(90deg, rgba(21,91,124,.92), rgba(23,43,57,.94)); border-bottom: 1px solid rgba(98,165,205,.3); border-radius: 10px 10px 0 0; } #${PANEL_ID} .swj-title { font-size: 13px; font-weight: 700; letter-spacing: .2px; } #${PANEL_ID} .swj-author { color: #8fb6ca; font-size: 10px; font-weight: 400; margin-left: 5px; } #${PANEL_ID} .swj-min { border: 0; background: rgba(255,255,255,.09); color: #dce8f2; width: 24px; height: 22px; border-radius: 5px; cursor: pointer; font-size: 15px; line-height: 18px; } #${PANEL_ID} .swj-body { padding: 10px 12px 12px; } #${PANEL_ID}.swj-minimized .swj-body { display: none; } #${PANEL_ID} .swj-section { border: 1px solid rgba(115,153,176,.23); border-radius: 8px; padding: 9px; margin-top: 9px; background: rgba(255,255,255,.025); } #${PANEL_ID} .swj-section:first-child { margin-top: 0; } #${PANEL_ID} .swj-section-title { color: #9cdcf1; font-weight: 700; margin-bottom: 7px; display: flex; align-items: center; justify-content: space-between; } #${PANEL_ID} .swj-badge { color: #a9e9c3; border: 1px solid rgba(85,196,138,.35); background: rgba(85,196,138,.08); border-radius: 10px; padding: 0 6px; font-size: 10px; font-weight: 400; } #${PANEL_ID} .swj-row { display: flex; gap: 6px; align-items: center; margin-bottom: 7px; } #${PANEL_ID} .swj-row:last-child { margin-bottom: 0; } #${PANEL_ID} .swj-col { flex: 1; min-width: 0; } #${PANEL_ID} label.swj-label { display: block; color: #8fa7b9; font-size: 10px; margin-bottom: 2px; } #${PANEL_ID} select, #${PANEL_ID} input[type=number] { width: 100%; height: 27px; border: 1px solid rgba(115,153,176,.42); border-radius: 5px; background: #111a22; color: #dce8f2; padding: 0 6px; outline: none; font: inherit; } #${PANEL_ID} select:focus, #${PANEL_ID} input[type=number]:focus { border-color: #4eb8dd; } #${PANEL_ID} button.swj-btn { border: 1px solid rgba(77,170,210,.5); background: rgba(30,96,124,.58); color: #dff7ff; border-radius: 5px; min-height: 27px; padding: 3px 9px; cursor: pointer; font: inherit; white-space: nowrap; } #${PANEL_ID} button.swj-btn:hover { background: rgba(41,126,161,.75); } #${PANEL_ID} button.swj-btn:disabled { opacity: .38; cursor: not-allowed; } #${PANEL_ID} button.swj-primary { background: #146d8c; border-color: #32a6cb; font-weight: 700; } #${PANEL_ID} button.swj-primary:hover { background: #1983a7; } #${PANEL_ID} button.swj-danger { background: rgba(126,65,35,.55); border-color: rgba(213,127,81,.55); } #${PANEL_ID} button.swj-toggle[aria-pressed=true] { background: rgba(21,126,107,.72); border-color: #42d6b5; box-shadow: 0 0 0 1px rgba(66,214,181,.28) inset; } #${PANEL_ID} .swj-meta { color: #89a3b5; font-size: 10px; } #${PANEL_ID} .swj-warn { color: #ffbf7a; } #${PANEL_ID} .swj-ok { color: #8fe5b1; } #${PANEL_ID} .swj-preview-box { margin-top: 7px; padding: 6px; border-radius: 6px; background: #0d151b; border: 1px solid rgba(115,153,176,.22); } #${PANEL_ID} #swj-lane-mini { display: block; width: 100%; height: 104px; border-radius: 4px; } #${PANEL_ID} .swj-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px; margin-top: 5px; color: #93adbe; font-size: 10px; } #${PANEL_ID} .swj-stats b { color: #dce8f2; font-weight: 500; } #${PANEL_ID} .swj-status { margin-top: 9px; min-height: 30px; padding: 6px 8px; border-radius: 6px; background: rgba(5, 13, 19, .72); border: 1px solid rgba(115,153,176,.25); color: #9fb7c7; font-size: 11px; word-break: break-all; } #${PANEL_ID} .swj-status.error { color: #ff9bac; border-color: rgba(255,99,132,.45); } #${PANEL_ID} .swj-status.success { color: #95e6b4; border-color: rgba(89,203,138,.38); } #${PANEL_ID} .swj-status.warning { color: #ffc47d; border-color: rgba(255,176,85,.38); } #${PANEL_ID} .swj-foot { margin-top: 7px; color: #708796; font-size: 10px; text-align: center; } #${TOAST_ID} { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); z-index: 2147483500; max-width: min(760px, 82vw); padding: 9px 15px; border-radius: 7px; color: #102019; background: #a8e9c3; box-shadow: 0 8px 24px rgba(0,0,0,.34); font: 12px/1.5 "Microsoft YaHei", sans-serif; pointer-events: none; opacity: 0; transition: opacity .18s ease; white-space: pre-wrap; text-align: center; } #${TOAST_ID}.show { opacity: 1; } `; document.head.appendChild(style); } function createPanel() { if (document.getElementById(PANEL_ID)) return; injectStyle(); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.innerHTML = `
车道线拉直 作者:swj · v${VERSION}
3D 拉直 / 点数 首尾 XYZ 锁定
尚未读取到车道线
预览:空首尾:已锁定 XY 最大摆动:—Z:原线里程插值
2D 整线 / 多边形平移 整体跟随鼠标
先在平台选中车道线或多边形,再按住 Ctrl 拖动
选中后按住 Ctrl 在相机图内整体拖动;松开提交,Esc 取消。
等待平台加载…
预览不写数据 · 保存请使用平台原按钮
`; document.body.appendChild(panel); document.getElementById('swj-lane-min').addEventListener('click', (event) => { event.stopPropagation(); panel.classList.toggle('swj-minimized'); }); document.getElementById('swj-lane-rescan').addEventListener('click', () => { refreshUi(true); setStatus('已刷新当前帧车道线列表', 'info'); }); document.getElementById('swj-lane-preview').addEventListener('click', buildPreview); document.getElementById('swj-lane-apply').addEventListener('click', applyPreview); document.getElementById('swj-2d-undo').addEventListener('click', undoLast); document.getElementById('swj-2d-shortcut').addEventListener('change', (event) => { cancelActiveDrag(); state.dragShortcut = event.target.value || 'Control'; event.target.blur(); update2DShortcutHint(); setStatus('2D 拖动快捷键已改为:' + shortcutLabel(), 'info'); }); document.getElementById('swj-2d-apply').addEventListener('click', () => { const dx = Number(document.getElementById('swj-2d-dx').value) || 0; const dy = Number(document.getElementById('swj-2d-dy').value) || 0; if (dx === 0 && dy === 0) { setStatus('请输入非 0 的 X/Y 平移量', 'warning'); return; } const sm = getStoreManager(); const frameId = getFrameId(sm); const ctx = sm && frameId ? get2DContext(sm, state.selectedTrackId, frameId) : null; if (!ctx) { setStatus('当前选中的标注没有可移动的 2D 线或多边形', 'warning'); return; } apply2DTranslation(ctx, dx, dy, '2D 精确平移'); }); document.getElementById('swj-lane-select').addEventListener('change', (event) => { state.followPlatformSelection = false; state.selectedTrackId = event.target.value || ''; state.preview = null; renderPreviewStats(null); refreshUi(false); setStatus('已切换选择;尚未修改数据', 'info'); }); document.getElementById('swj-lane-count').addEventListener('input', () => { state.preview = null; renderPreviewStats(null); updateActionButtons(); }); document.getElementById('swj-lane-mode').addEventListener('change', () => { state.preview = null; renderPreviewStats(null); updateActionButtons(); }); makePanelDraggable(panel); } function makePanelDraggable(panel) { const head = panel.querySelector('.swj-head'); let dragging = false; let dx = 0; let dy = 0; head.addEventListener('mousedown', (event) => { if (event.target.closest('button')) return; const rect = panel.getBoundingClientRect(); dragging = true; dx = event.clientX - rect.left; dy = event.clientY - rect.top; panel.style.right = 'auto'; event.preventDefault(); }); window.addEventListener('mousemove', (event) => { if (!dragging) return; const maxLeft = Math.max(0, window.innerWidth - panel.offsetWidth); const maxTop = Math.max(0, window.innerHeight - 40); panel.style.left = clamp(event.clientX - dx, 0, maxLeft) + 'px'; panel.style.top = clamp(event.clientY - dy, 0, maxTop) + 'px'; }); window.addEventListener('mouseup', () => { dragging = false; }); } function setStatus(message, type) { const nextType = type || 'info'; if (message === state.lastStatus && nextType === state.lastStatusType) return; state.lastStatus = message; state.lastStatusType = nextType; const el = document.getElementById('swj-lane-status'); if (el) { el.textContent = message; el.className = 'swj-status' + (nextType !== 'info' ? ' ' + nextType : ''); } if (nextType === 'error' || nextType === 'warning') showToast(message, nextType); } function showToast(message, type) { let toast = document.getElementById(TOAST_ID); if (!toast) { toast = document.createElement('div'); toast.id = TOAST_ID; document.body.appendChild(toast); } toast.textContent = message; toast.style.background = type === 'error' ? '#ff9fb0' : type === 'warning' ? '#ffc47d' : '#a8e9c3'; toast.classList.add('show'); clearTimeout(toast.__swjTimer); toast.__swjTimer = setTimeout(() => toast.classList.remove('show'), 2600); } function updateUndoButton() { const button = document.getElementById('swj-2d-undo'); if (!button) return; button.disabled = state.undoStack.length === 0; button.textContent = state.undoStack.length ? '撤销上一步 (' + state.undoStack.length + ')' : '撤销上一步'; } function updateActionButtons() { const previewButton = document.getElementById('swj-lane-preview'); const applyButton = document.getElementById('swj-lane-apply'); const countInput = document.getElementById('swj-lane-count'); const modeSelect = document.getElementById('swj-lane-mode'); if (!previewButton || !applyButton) return; previewButton.disabled = !state.hasSelected3D; applyButton.disabled = !state.preview; if (countInput) countInput.disabled = state.mode === 'straighten-only' || !modeSelect || modeSelect.value === 'straighten-only'; updateUndoButton(); } function updateSelectionInfo(sm, frameId, lanes) { const info = document.getElementById('swj-lane-selection-info'); if (!info) return; const lane = lanes.find((item) => item.trackId === state.selectedTrackId); if (!lane) { const has2D = !!state.selectedTrackId && get2DMarksForLane(sm, state.selectedTrackId, frameId).length > 0; info.textContent = has2D ? '当前选中是 2D 线/多边形,3D 拉直不适用;可直接在下方整体拖动' : '当前帧没有可处理的 line_3d 车道线'; info.className = 'swj-meta swj-warn'; return; } info.textContent = '选中 ID ' + lane.trackId + ' · 类别 ' + (lane.classId == null ? '—' : lane.classId) + ' · 当前 ' + lane.count + ' 点'; info.className = 'swj-meta swj-ok'; } function update2DInfo(sm, frameId) { const info = document.getElementById('swj-2d-info'); const applyButton = document.getElementById('swj-2d-apply'); if (!info || !applyButton) return; update2DShortcutHint(); if (!state.selectedTrackId) { info.textContent = '先在平台选中车道线或多边形,再按住 ' + shortcutLabel() + ' 拖动'; info.className = 'swj-meta swj-warn'; applyButton.disabled = true; if (state.activeDrag) cancelActiveDrag(); return; } const marks2D = get2DMarksForLane(sm, state.selectedTrackId, frameId); if (marks2D.length === 0) { info.textContent = '所选标注在当前帧没有可移动的 2D 线或多边形'; info.className = 'swj-meta swj-warn'; applyButton.disabled = true; if (state.activeDrag) cancelActiveDrag(); return; } const ctx = get2DContext(sm, state.selectedTrackId, frameId); applyButton.disabled = false; const cameras = marks2D.map((item) => item.camera).join(', '); if (!ctx || !ctx.canvas) { info.textContent = '2D 标注存在(' + cameras + '),但当前没有可见相机画布'; info.className = 'swj-meta swj-warn'; } else { const resolutionNote = ctx.mappingSource === 'platform' ? ctx.resolution.width + '×' + ctx.resolution.height + ' · 跟随平台缩放' : ctx.resolution && ctx.resolution.source === 'platform' ? ctx.resolution.width + '×' + ctx.resolution.height : '分辨率未知,按画布尺寸换算'; info.textContent = '2D 标注:ID ' + state.selectedTrackId + ' · 相机 ' + ctx.cameraKey + ' · ' + ctx.mark.points.length + ' 点 · ' + resolutionNote + (ctx.view && ctx.shape ? ' · 实时拖动' : ' · 兼容拖动'); info.className = 'swj-meta' + (ctx.mappingSource === 'platform' || ctx.resolution?.source === 'platform' ? ' swj-ok' : ' swj-warn'); } } function update2DShortcutHint() { const hint = document.getElementById('swj-2d-hint'); if (!hint) return; const label = shortcutLabel(); const lock = state.dragShortcut === 'Shift' ? '' : ';再按 Shift 可锁定水平/垂直'; hint.textContent = '选中后按住 ' + label + ' 在相机图内整体拖动线或多边形;松开提交,Esc 取消' + lock + '。'; } function syncLaneList(sm, frameId, lanes, force) { const select = document.getElementById('swj-lane-select'); if (!select) return; const independent2D = collectIndependent2DMarks(sm, frameId, lanes); const signature = frameId + '|' + lanes.map((lane) => lane.trackId + ':' + lane.count + ':' + lane.classId).join(',') + '|' + independent2D .map((item) => item.trackId + ':' + item.count + ':' + item.classId + ':' + item.kindLabel) .join(',') + '|' + state.selectedTrackId; if (!force && signature === state.laneListSignature) return; state.laneListSignature = signature; select.innerHTML = ''; for (const lane of lanes) { const option = document.createElement('option'); option.value = lane.trackId; option.textContent = lane.trackId + ' · ' + (lane.classId || '—') + ' · ' + lane.count + ' 点'; select.appendChild(option); } for (const item of independent2D) { const option = document.createElement('option'); option.value = item.trackId; option.textContent = item.trackId + ' · ' + (item.classId || '—') + ' · ' + item.kindLabel + ' · ' + item.count + ' 点'; select.appendChild(option); } const availableIds = new Set( lanes.map((lane) => lane.trackId).concat(independent2D.map((item) => item.trackId)) ); if (availableIds.size === 0) { state.selectedTrackId = ''; const option = document.createElement('option'); option.value = ''; option.textContent = '当前帧没有可处理的标注'; select.appendChild(option); select.value = ''; return; } const selectedExists = availableIds.has(state.selectedTrackId); if (!selectedExists) { const selectInfo = getSelectInfo(sm); const preferredId = String(selectInfo.trackId || ''); state.selectedTrackId = availableIds.has(preferredId) ? preferredId : lanes[0]?.trackId || independent2D[0].trackId; } select.value = state.selectedTrackId; } function refreshUi(force) { const sm = getStoreManager(); const frameId = getFrameId(sm); if (!sm || !frameId) { state.hasSelected3D = false; const select = document.getElementById('swj-lane-select'); if (select) { select.innerHTML = ''; } updateActionButtons(); return; } const lanes = collectLanes(sm, frameId); const selectInfo = getSelectInfo(sm); const platformSelectKey = String(selectInfo.trackId || '') + '|' + String(selectInfo.dimension || '') + '|' + String(selectInfo.cameraKey || ''); const platformSelectionChanged = platformSelectKey !== state.lastPlatformSelect; state.lastPlatformSelect = platformSelectKey; const selected = String(selectInfo.trackId || ''); const selectedHas2D = !!selected && get2DMarksForLane(sm, selected, frameId).length > 0; if ( (state.followPlatformSelection || (platformSelectionChanged && selectedHas2D)) && selected ) { if ( (lanes.some((lane) => lane.trackId === selected) || selectedHas2D) ) { state.selectedTrackId = selected; } } syncLaneList(sm, frameId, lanes, force); state.hasSelected3D = lanes.some((lane) => lane.trackId === state.selectedTrackId); updateSelectionInfo(sm, frameId, lanes); update2DInfo(sm, frameId); updateActionButtons(); if ( state.preview && (state.preview.frameId !== frameId || state.preview.trackId !== state.selectedTrackId) ) { state.preview = null; renderPreviewStats(null); setStatus('帧或选中车道线已变化,旧预览已丢弃', 'warning'); } if ( !state.lastStatus || /^等待平台/.test(state.lastStatus) || /^当前帧读取到/.test(state.lastStatus) || /^当前帧没有/.test(state.lastStatus) ) { setStatus( lanes.length ? '当前帧读取到 ' + lanes.length + ' 条 3D 车道线;所有操作均需点击后生效' : '当前帧没有 line_3d 车道线', lanes.length ? 'info' : 'warning' ); } } function renderPreviewStats(preview) { const stats = document.getElementById('swj-lane-stats'); if (!stats) return; if (!preview) { stats.innerHTML = '预览:空首尾:已锁定' + 'XY 最大摆动:—Z:原线里程插值'; drawMiniPreview(null); return; } const m = preview.meta; const endpointErrorText = m.endpointError < 1e-9 ? '0' : m.endpointError.toExponential(1); const zOk = m.zMinOriginal !== null && m.zMaxOriginal !== null && m.zMinOutput !== null && m.zMaxOutput !== null && m.zMinOutput >= m.zMinOriginal - 1e-6 && m.zMaxOutput <= m.zMaxOriginal + 1e-6; stats.innerHTML = '点数:' + m.originalCount + ' → ' + m.outputCount + '' + '首尾误差:' + endpointErrorText + '' + 'XY 最大摆动:' + m.maxDeviationXY.toFixed(3) + ' m' + 'Z 范围:' + (zOk ? '保持原区间' : '需核对') + ''; drawMiniPreview(preview); } function drawMiniPreview(preview) { const canvas = document.getElementById('swj-lane-mini'); if (!canvas) return; const rect = canvas.getBoundingClientRect(); const width = Math.max(240, Math.round(rect.width || 330)); const height = 104; const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = '#0b1217'; ctx.fillRect(0, 0, width, height); if (!preview) { ctx.fillStyle = '#647b89'; ctx.font = '11px "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center'; ctx.fillText('生成预览后显示俯视对比', width / 2, height / 2 + 4); return; } const source = preview.sourcePoints; const output = preview.outputPoints; const all = source.concat(output); let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const p of all) { const x = pointX(p); const y = pointY(p); if (x === null || y === null) continue; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); } if (!Number.isFinite(minX)) return; const pad = 14; const plotW = width - pad * 2; const plotH = height - pad * 2; const dx = Math.max(1e-6, maxX - minX); const dy = Math.max(1e-6, maxY - minY); const scale = Math.min(plotW / dx, plotH / dy); const offsetX = pad + (plotW - dx * scale) / 2; const offsetY = pad + (plotH - dy * scale) / 2; function project(p) { return { x: offsetX + (pointX(p) - minX) * scale, y: height - (offsetY + (pointY(p) - minY) * scale), }; } ctx.strokeStyle = 'rgba(255,255,255,.07)'; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { ctx.beginPath(); ctx.moveTo(0, (height * i) / 4); ctx.lineTo(width, (height * i) / 4); ctx.stroke(); } ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = '#ff9f43'; ctx.lineWidth = 1.5; ctx.beginPath(); source.forEach((p, index) => { const q = project(p); if (index === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); }); ctx.stroke(); ctx.strokeStyle = '#21d6ff'; ctx.lineWidth = 2; ctx.beginPath(); output.forEach((p, index) => { const q = project(p); if (index === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); }); ctx.stroke(); for (const p of [source[0], source[source.length - 1]]) { const q = project(p); ctx.beginPath(); ctx.arc(q.x, q.y, 3.2, 0, Math.PI * 2); ctx.fillStyle = '#aff6ff'; ctx.fill(); ctx.strokeStyle = '#063b49'; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.font = '10px "Microsoft YaHei", sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = '#ff9f43'; ctx.fillText('原线', 8, 13); ctx.fillStyle = '#21d6ff'; ctx.fillText('预览', 44, 13); } /* ============================== 启动 ============================== */ function bindGlobalEvents() { document.addEventListener('mousedown', onDocumentMouseDown, true); document.addEventListener('mousemove', onDocumentMouseMove, true); document.addEventListener('mouseup', onDocumentMouseUp, true); document.addEventListener( 'keydown', (event) => { if (event.key === 'Escape' && cancelActiveDrag()) { event.preventDefault(); event.stopPropagation(); return; } if (event.code === 'KeyM' && !isTypingTarget(event.target)) { state.momentaryDragKeyDown = true; } if ( !state.activeDrag && shortcutPressed(event) && !isTypingTarget(event.target) ) { const sm = getStoreManager(); const frameId = getFrameId(sm); const ctx = sm && frameId ? get2DContext(sm, state.selectedTrackId, frameId) : null; if (ctx && ctx.canvas && isVisibleCanvas(ctx.canvas)) { setStatus( '2D 整线拖动就绪:按住 ' + shortcutLabel() + ' 在相机图中拖动,松开后提交', 'info' ); } } }, true ); document.addEventListener( 'keyup', (event) => { if (event.code === 'KeyM') { state.momentaryDragKeyDown = false; } }, true ); window.addEventListener('blur', () => { state.momentaryDragKeyDown = false; cancelActiveDrag(); }); } function tick() { if (!state.mounted) return; refreshUi(false); if (state.preview) updateActionButtons(); } function start() { if (state.mounted) return; if (!document.body) { setTimeout(start, 50); return; } state.mounted = true; createPanel(); bindGlobalEvents(); setInterval(tick, 900); refreshUi(true); } PAGE_WINDOW.__SWJ_LANE_TOOL__ = { version: VERSION, author: 'swj', buildStraightPoints, translate2DPoints, getState: () => ({ selectedTrackId: state.selectedTrackId, preview: state.preview ? clonePlain(state.preview) : null, undoCount: state.undoStack.length, dragShortcut: state.dragShortcut, activeDrag: state.activeDrag ? state.activeDrag.mode : null, mounted: state.mounted, lastStatus: state.lastStatus, }), refresh: () => refreshUi(true), buildPreview, applyPreview, diagnose2D: () => { const sm = getStoreManager(); const frameId = getFrameId(sm); const ctx = sm && frameId ? get2DContext(sm, state.selectedTrackId, frameId) : null; return { selectedTrackId: state.selectedTrackId, frameId, shortcut: state.dragShortcut, activeDrag: state.activeDrag ? state.activeDrag.mode : null, found: !!ctx, cameraKey: ctx ? ctx.cameraKey : null, hasCanvas: !!(ctx && ctx.canvas), mappingSource: ctx ? ctx.mappingSource : null, hasView: !!(ctx && ctx.view), hasShape: !!(ctx && ctx.shape), shapePoints: ctx && ctx.shape ? ctx.shape.points.map((p) => ({ x: p.x, y: p.y })) : null, }; }, }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } })();