// ==UserScript== // @name 点云3D标注增强 // @namespace cloud-annotate-plus // @version 3.4.1 // @description 点云标注平台增强:①索套切边——按 M 沿要保留的边界打点、Q 闭合,边界即被替换成这条线;②顶点吸附合并——拖动顶点靠近另一顶点自动吸附,松手合并成一个点;③吸附到地面——把折线/多边形(含闭合区域)顶点吸到正下方点云最低点;④RGB 着色(静默版 v2.2.0)——数据自带 rgba 的就地解出,没有颜色字段的用相机+标定投影着色。 // @author - // @match *://*/* // @grant none // @run-at document-idle // ==/UserScript== /* * 使用说明(三个独立功能,互不干扰) * ================================================================== * 一、索套切边(3D 主视图,2D 屏幕空间) * 按 M 键进入索套模式 -> 沿“想保留的那条边界”依次左键打点 * -> 线变绿表示可切(正好穿过多边形边界两次),画面上会用半透明红色标出“将被删除的区域” * 按住 Alt+Q 保留另一侧;模式内 Backspace 退点、Z 撤销上次切边、Esc/M 退出; * 只拦左键,右键拖动平移点云、中键/滚轮缩放照常可用。 * 打点时靠近多边形顶点(默认 12px 内)会自动吸附到该顶点(画成白圈):这样“从一个顶点切到另一个顶点” * 也算两次相交,可以直接沿已有顶点改边界。可调:lane_vertex_snap_px(吸附半径,默认 12)、 * lane_cut_vertex_px(判定“落在顶点上”的容差,默认 6)。 * * 二、标注范围限制(黄框内) * 平台会给一个标注范围(3D 视图里的黄色框)。脚本读取的是平台自己的那份数据: * config.mark_region 的 box/rect(x/y 各给 [min,max])、circle/arc(半径 + 起止角), * 以及按帧的 getFrameRange(帧号)。与平台画黄框用的是同一份配置,不需要另外填数值。 * 效果:拖动顶点时会自动吸附到范围边缘、**绝不超出**;画新点(含索套切出的新顶点)也夹在范围内。 * 只限制 X/Y,不动 Z(黄框在 z=0 平面上,只是范围示意)。 * 平台没配范围时不做任何限制;也可以在控制台手动指定兜底范围: * localStorage.setItem("lane_region_box", "xmin,xmax,ymin,ymax")。 * * 三、顶点吸附合并(编辑模式) * 拖动一个顶点靠近同一个多边形的另一个顶点(默认 12px、世界距离 3 米内)会吸附对齐, * 提示点琥珀色,松手合并成一个点(顶点数 -1,形状不变);顶点数到下限(多边形 3 点)时只吸附不合并。 * 只有“朝目标方向靠近”(当前距离小于抓取时距离的一半)才会吸附,所以正常拖动顶点不受影响。 * localStorage.setItem("lane_snap_other_px", 8) // 允许吸附别的多边形顶点(只吸附不删点,提示点青色) * localStorage.setItem("lane_snap_px", 16) // 吸附半径,默认 12 * localStorage.setItem("lane_snap_world", 5) // 世界距离上限,默认 3,0=不限 * * 四、RGB 点云着色(静默版 v2.2.0,原脚本逻辑原样并入) * 自动跟随帧变化着色并切到官方「rgb着色」;不再显示右上角胶囊,控制入口在控制台: * window.__lassoCut.rgb.setRgb() / diagnose() / restore() / stat()。 * 数据自带 rgba 的就地解出颜色,没有颜色字段的用相机+标定投影着色;不改数据、不重传。 * 统计信息在控制台 window.__xjRgbLastStat;诊断结果输出到 F12 Console。 * * 五、吸附到地面(贴地) * 按钮已植入平台顶部工具条(平台顶部工具条「显示距离」左边);万一平台改版找不到工具条, * 会自动退回“视图左下角浮动按钮”。 * 作用:把当前帧折线/多边形的顶点吸到正下方点云的最低点(先按 0.4 米找,找不到逐级扩大到 2 米, * 取半径内最低的 Z,所以不会吸到车顶)。支持闭合区域(polygon_3d 路面多边形)。 * 已选中某个标注物时只贴它,没选中则贴当前视图里全部;Alt+点击可撤销上一次贴地。 * **只改当前帧**:保存走平台的「修改3D点」(MARK_ACTION_MODIFY_POINTS)——平台对这种 action 不做多帧 * 同步,所以每帧的地面都要单独贴(这也是原「点云车道线工具」的做法;而拖动顶点走的是「矩阵修改」, * 多帧模式下仍会按平台行为同步到其它帧)。 * 约束与兜底(避免“附近没有点云被乱吸”):半径内至少要有 3 个点才认;找到的高度若偏离整帧参照 * 高度超过 1 米,判为乱吸改用兜底;兜底顺序 = 同一条线上已解决的前/后邻居高度 → 整帧参照高度 * (已吸附点的平均值,剔除离群;全无时用点云 5% 分位 Z)。 * 可调:lane_ground_r(初半径 0.4)、lane_ground_maxr(最大半径 2.0)、lane_ground_minpts(最少点数 3)、 * lane_ground_maxdev(偏离上限 1.0 米,0=不限)。 * * 版本记录: * v3.4.1 贴地改为**只影响当前帧**:保存从「矩阵修改」改成平台「修改3D点」(MARK_ACTION_MODIFY_POINTS), * 该 action 下平台不会把改动同步到其它帧,避免多帧模式下“点一次全包都贴了”。撤销同样只改当前帧。 * v3.4.0 新增「标注范围限制」:读取平台自己的标注范围配置(mark_region 的 box/rect/circle/arc 与按帧的 * getFrameRange),拖动顶点时自动吸附到范围边缘、绝不超出,画新点与索套切出的顶点同样受限。 * v3.3.1 「吸附到地面」按钮位置改为“显示距离”左边(之前插到了两个勾选项中间),并换成浅蓝底/蓝边的 * 清晰配色;索套浮动按钮与 RGB 胶囊保持移除状态。 * v3.3.0 贴地加了约束与兜底(避免“附近没点云被乱吸”):①半径内必须至少有 3 个点才认这个高度; * ②找到的高度若偏离整帧参照高度超过 1 米,视为乱吸、改走兜底;③兜底顺序 = 同一条线上已解决 * 的前/后邻居高度 → 整帧参照高度(已吸附点的平均值,剔离群;全无时用点云 5% 分位 Z)。 * 界面调整:「吸附到地面」按钮改为直接植入平台顶部工具条(“显示距离/无需标注”那一行); * 去掉索套浮动按钮(功能保留,按 M 进入)与 RGB 右上角胶囊(着色照常,控制入口改为 * window.__lassoCut.rgb.setRgb()/diagnose()/restore()/stat())。 * v3.2.0 新增「吸附到地面」:把当前帧折线/多边形的顶点吸到正下方点云的最低点(0.4 米起逐级扩大 * 到 2 米,取半径内最低 Z,避免吸到车顶);**支持闭合区域(polygon_3d 路面多边形)**, * 原「点云车道线工具」只处理 line_3d 折线。选中某个标注物时只贴它,未选中则贴当前视图全部; * 已提交保存,按钮上 Alt+点击可撤销上一次贴地。 * v3.1.0 索套可以选中多边形顶点了:打点时靠近顶点自动吸附(画成白圈),并且“从一个顶点切到另一个顶点” * 也算两个交点(交点可以是边上的穿越点,也可以直接是已有顶点)。仍要求正好两个交点。 * 顺带修掉弧线收集的顺序问题(跨过 0 号顶点时顶点顺序会乱、环自相交,导致选错侧)。 * v3.0.2 修复索套切边「删错一侧 / 顶点丢失」:索套从多边形的同一条边进出时,原来会把该侧的顶点全部丢掉, * 结果只剩“老边 + 新曲线”围成的小条,也就是该删的那部分反而被留下; * 同时把默认保留侧改成“多边形重心所在的一侧”(原来只按面积比大小,路面在屏幕上很扁时会选错), * 并新增“将被删除区域”的红色半透明预览(按住 Alt 会实时切到另一侧)。 * v3.0.2 修复索套切边「删错一侧 / 顶点丢失」:索套从多边形的同一条边进出时(沿着一条边切一刀,很常见), * 原来会把该侧的顶点全部丢掉,结果只剩“老边 + 新曲线”围成的小条,也就是该删的那部分反而被留下; * 同时把默认保留侧改成“多边形重心所在的一侧”(原来只按面积比大小,路面在屏幕上很扁时会选错), * 并新增“将被删除区域”的红色半透明预览(按住 Alt 会实时切到另一侧)。 * v3.0.1 修复「拖动顶点完全拖不动」:吸附合并的拖动包装函数没做 bind,而 three.js 派发事件时 * 是用球体作为 this 调用监听器的,于是每次拖动都抛异常并打断平台的鼠标事件链; * 同时修掉「顶点本来就贴近另一个顶点时一拖就被吸住」,改为必须朝目标靠近(距离减半)才吸附。 * v3.0.0 索套切边 + 顶点吸附合并 + RGB 着色三合一。 * * 注意事项: * - 本脚本已包含原「点云3D多边形-索套切边」与「平台端RGB着色(静默版)」两个脚本的全部功能, * 请在 ScriptCat 里停用/删除它们,避免 RGB 着色重复执行(会出现两个状态胶囊)。 * - @match 目前是全部站点,脚本会自行判断是否平台页面(依据 window.instance / #app / pointcloud 路径), * 非平台页面不做任何事。建议按需收窄成你们平台地址,例如 http://121.37.95.217:8080/ 加一个星号。 * - 索套切边与吸附合并只作用于点云 3D 主视图的标注物;四个 2D 投影视图、其它帧的预测标注物(虚拟)、 * 批注多边形都不参与。RGB 着色只影响显示,不改动任何标注与数据。 */ // ================================================================ // 一、索套切边 + 顶点吸附合并(3D 标注交互) // 依赖平台暴露的 window.instance / window.viewer;非平台页面自动退出 // ================================================================ (function () { "use strict"; var W = (typeof unsafeWindow !== "undefined" && unsafeWindow) || window; var LABEL_TYPE_MARK = "mark"; // Constants.LABEL_TYPE_MARK var VISUAL_TYPE_VIRTUAL = 2; // Constants.VISUAL_TYPE_VIRTUAL(其它帧的预测标注物) var MARK_ACTION_MODIFY_MATRIX = 1; // Constants.MARK_ACTION_MODIFY_MATRIX var MARK_ACTION_MODIFY_POINTS = 3; // Constants.MARK_ACTION_MODIFY_POINTS(平台对这种 action 不做多帧同步) var DEFAULTS = { SNAP_PX: 12, SNAP_OTHER_PX: 0, SNAP_WORLD: 3, SNAP_VERTEX_PX: 12, CUT_VERTEX_PX: 6, GROUND_R: 0.4, GROUND_MAXR: 2.0, GROUND_MINPTS: 3, GROUND_MAXDEV: 1.0 }; function readPx(key, def) { var v = parseFloat(localStorage.getItem(key)); return (isNaN(v) || v < 0) ? def : v; } // #region lane-methods // 索套切边依赖的基础工具(调用时 this 必须是 LineHandler 实例)。 // deps: { S: StoreManager, Vector3 } function makeLaneMethods(deps) { var S = deps.S, Vector3 = deps.Vector3, Color = deps.Color; return { // 只考虑:3D 主视图里的多边形/折线、非批注、有 mark、非虚拟(其它帧预测) lanePolylines: function () { var out = []; var scene = this.viewer.lineScene; if (!scene) return out; scene.traverseVisible(function (o) { if (o && typeof o.getSpheresAndLines === "function" && !o.isPostil && o.trackId && o.mark && o.markType !== VISUAL_TYPE_VIRTUAL) out.push(o); }); return out; }, // 边数组按索引与顶点配对:边 i = 顶点 i -> 顶点 i+1(闭合时最后一条回到 0) laneClosestOnSegment: function (ro, rd, a, b) { var ab = b.clone().sub(a), len2 = ab.lengthSq(); if (len2 < 1e-12) return a.clone(); var w = ro.clone().sub(a), wr = w.dot(rd), wa = w.dot(ab), bar = ab.dot(rd); var den = len2 - bar * bar; // 候选:两端点、射线原点在线段上的垂足、区域内部驻点、两区域交界处 var cands = [0, 1, wa / len2]; if (Math.abs(den) > 1e-12) cands.push((wa - bar * wr) / den); if (Math.abs(bar) > 1e-12) cands.push(wr / bar); var best = null, bestD = Infinity; for (var i = 0; i < cands.length; i++) { var s = cands[i]; s = s < 0 ? 0 : (s > 1 ? 1 : s); var ux = a.x + ab.x * s - ro.x, uy = a.y + ab.y * s - ro.y, uz = a.z + ab.z * s - ro.z; var t = ux * rd.x + uy * rd.y + uz * rd.z; if (t < 0) t = 0; var d2 = ux * ux + uy * uy + uz * uz - t * t; if (d2 < bestD - 1e-12) { bestD = d2; best = a.clone().add(ab.clone().multiplyScalar(s)); } } return best || a.clone(); }, // 世界坐标 -> 画布像素;相机后方返回 null laneProject: function (p, rect) { if (!p) return null; var cam = this.viewer.camera; if (cam.matrixWorldInverse) { var v = p.clone().applyMatrix4(cam.matrixWorldInverse); if (v.z >= -1e-6) return null; } var n = p.clone().project(cam); return { x: (n.x * .5 + .5) * rect.width, y: (-n.y * .5 + .5) * rect.height }; }, // ---------- 平台标注范围(黄框):读取平台自己的配置,拖动/绘制时把顶点夹在里面 ---------- // 来源:config.mark_region 的 box/rect(x/y/z 各给 [min,max])与 circle/arc(半径 + 可选起止角), // 以及按帧的 configGetter("getFrameRange")(frameId)。与平台画黄框用的是同一份数据,无需另外配置。 laneMarkRegions: function () { var frameId = null; try { frameId = S.getCurFrameId(); } catch (e) { } if (this._laneRegionsCache && this._laneRegionsFrame === frameId) return this._laneRegionsCache; var out = [], self = this; var num = function (v) { var n = parseFloat(v); return isNaN(n) ? null : n; }; var addBox = function (it) { if (!it) return; var x = it.x, y = it.y; if (!x || x.length < 2 || !y || y.length < 2) return; var x0 = num(x[0]), x1 = num(x[1]), y0 = num(y[0]), y1 = num(y[1]); if (x0 === null || x1 === null || y0 === null || y1 === null) return; out.push({ kind: "box", x: [Math.min(x0, x1), Math.max(x0, x1)], y: [Math.min(y0, y1), Math.max(y0, y1)] }); }; var addArc = function (it) { if (!it) return; var r = num(it.radius); if (!(r > 0)) return; var c = it.center || [0, 0, 0]; var a0 = num(it.start), a1 = num(it.end); if (a0 === null) a0 = 0; if (a1 === null) a1 = 360; out.push({ kind: "arc", cx: num(c[0]) || 0, cy: num(c[1]) || 0, r: r, a0: a0, a1: a1 }); }; var eat = function (src) { if (!src) return; var k; for (k = 0; k < 2; k++) if (src[k ? "rect" : "box"]) src[k ? "rect" : "box"].forEach(addBox); for (k = 0; k < 2; k++) if (src[k ? "arc" : "circle"]) src[k ? "arc" : "circle"].forEach(addArc); }; try { eat(S.configState().config.mark_region); } catch (e) { } try { var g = S.configGetter("getFrameRange"); if (g) eat(g(frameId)); } catch (e) { } // 手动覆盖(万一平台没配范围,可在控制台写 "xmin,xmax,ymin,ymax") try { var man = localStorage.getItem("lane_region_box"); if (man) { var p = man.split(",").map(num); if (p.length === 4 && p[0] !== null && p[1] !== null && p[2] !== null && p[3] !== null) { out.push({ kind: "box", x: [Math.min(p[0], p[1]), Math.max(p[0], p[1])], y: [Math.min(p[2], p[3]), Math.max(p[2], p[3])] }); } } } catch (e) { } this._laneRegionsCache = out; this._laneRegionsFrame = frameId; return out; }, // 把 (x,y) 夹进标注范围(多个范围取并集,落在最近的那个边缘上)。没有配置范围时返回 null 表示不限制。 laneClampXY: function (x, y) { var regions = this.laneMarkRegions(); if (!regions || !regions.length) return null; var norm = function (v) { v = v % 360; return v < 0 ? v + 360 : v; }; var best = null, bestD = Infinity; for (var i = 0; i < regions.length; i++) { var r = regions[i], cx = x, cy = y; if (r.kind === "box") { cx = x < r.x[0] ? r.x[0] : (x > r.x[1] ? r.x[1] : x); cy = y < r.y[0] ? r.y[0] : (y > r.y[1] ? r.y[1] : y); } else { var dx = x - r.cx, dy = y - r.cy, d = Math.sqrt(dx * dx + dy * dy); var span = norm(r.a1 - r.a0), full = span >= 359.999; var a = Math.atan2(dy, dx) * 180 / Math.PI, inSector = full; if (!full) { var rel = norm(a - r.a0); inSector = rel <= span + 1e-6; if (!inSector) { // 落在扇形角度之外 -> 投影到较近的那条边界射线 var useEnd = (span - rel) < rel; var ba = (useEnd ? r.a1 : r.a0) * Math.PI / 180; var proj = dx * Math.cos(ba) + dy * Math.sin(ba); proj = proj < 0 ? 0 : (proj > r.r ? r.r : proj); cx = r.cx + proj * Math.cos(ba); cy = r.cy + proj * Math.sin(ba); } } if (inSector) { if (d <= r.r || d < 1e-12) { cx = x; cy = y; } // 在扇形/圆内 else { cx = r.cx + dx / d * r.r; cy = r.cy + dy / d * r.r; } // 超出半径 -> 贴到弧上 } } var ex = x - cx, ey = y - cy, e = ex * ex + ey * ey; if (e < bestD) { bestD = e; best = { x: cx, y: cy, moved: e > 1e-12 }; } } return best; }, // 直接把一个顶点夹进标注范围(并同步显示/保存),返回是否发生了夹取 laneClampSphere: function (sphere) { if (!sphere || !sphere.position) return false; var c = this.laneClampXY(sphere.position.x, sphere.position.y); if (!c || !c.moved) return false; sphere.position.x = c.x; sphere.position.y = c.y; var poly = sphere.parent; if (poly && typeof poly.getSpheresAndLines === "function") { try { this.updateLinesPosition(poly); } catch (e) { } try { poly.initInner(); } catch (e) { } try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (e) { } } try { this.viewer.rendererMain(); } catch (e) { } return true; }, // 拖动顶点时的吸附目标:屏幕上足够近、且世界距离也在阈值内的“另一个顶点” // ndc 为拖拽事件里的鼠标位置(InputHandler.drag.end);不传则用被拖动顶点当前的投影位置 laneSnapTarget: function (sphere, ndc, grabPx) { var poly = sphere && sphere.parent; if (!poly || typeof poly.getSpheresAndLines !== "function") return null; var rect = this.domElement.getBoundingClientRect(); var from = null; if (ndc && typeof ndc.x === "number") { from = { x: (ndc.x * .5 + .5) * rect.width, y: (-ndc.y * .5 + .5) * rect.height }; } else { from = this.laneProject(sphere.position, rect); } if (!from) return null; var startPx = (grabPx && typeof grabPx.x === "number") ? grabPx : from; var pxSame = readPx("lane_snap_px", DEFAULTS.SNAP_PX); var pxOther = readPx("lane_snap_other_px", DEFAULTS.SNAP_OTHER_PX); // 默认 0:不吸附别的多边形 var worldMax = readPx("lane_snap_world", DEFAULTS.SNAP_WORLD); // 0 表示不限世界距离 var polys = this.lanePolylines(), best = null, bestD = Infinity; for (var i = 0; i < polys.length; i++) { var p = polys[i], same = (p === poly), limit = same ? pxSame : pxOther; if (!(limit > 0)) continue; var sph = p.getSpheresAndLines()[0]; for (var j = 0; j < sph.length; j++) { var sp = sph[j]; if (sp === sphere) continue; if (worldMax > 0 && sp.position.distanceTo(sphere.position) > worldMax) continue; var pt = this.laneProject(sp.position, rect); if (!pt) continue; var d = Math.hypot(pt.x - from.x, pt.y - from.y); var d0 = Math.hypot(pt.x - startPx.x, pt.y - startPx.y); // 必须“正在靠近”:当前距离要小于抓取时距离的一半,才算要吸附到这个顶点。 // 否则顶点本来就贴着另一个顶点(例如缩小后都挤在一起)时,一拖就会被吸住、拖不动。 if (!(d <= limit && d < d0 * .5)) continue; if (d < bestD) { bestD = d; best = { poly: p, sphere: sp, same: same, px: d }; } } } return best; }, // 松手后合并:删掉被拖动的那个顶点(同多边形才合并;顶点数已达下限时只保持吸附位置) laneMergeVertex: function (info) { if (!info || !info.poly || !info.sphere) return false; var r = info.poly.getSpheresAndLines(), spheres = r[0], lines = r[1]; var minNum = info.poly.isClosed ? 3 : (this.lineMinPointsNum || 2); if (spheres.length <= minNum) return false; var line = lines[0]; // 结构性改动:删一个顶点与一条边,随后边按索引重排(与平台删点逻辑一致) try { if (info.sphere.removeFromParent) info.sphere.removeFromParent(); if (line && line.removeFromParent) line.removeFromParent(); this.updateLinesPosition(info.poly); } catch (e) { console.warn("[snap-merge] merge", e); return false; } // 以下失败不影响数据 try { this.removePickEvent(info.sphere); } catch (e) { } try { if (line) this.removePickEvent(line); } catch (e) { } try { info.poly.initInner(); } catch (e) { } try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, info.poly); } catch (e) { console.warn("[snap-merge] 合并后保存失败,请拖动任一顶点触发保存:", e); } try { this.viewer.rendererMain(); } catch (e) { } return true; }, // 吸附提示小球(懒创建):琥珀色 = 会合并,青色 = 只吸附对齐 laneEnsureGhost: function () { if (this._laneGhost) return this._laneGhost; var ref = this.defColor || this.selectColor; var ColorCls = ref && ref.constructor; if (!ColorCls || typeof ColorCls !== "function") return null; if (!this._laneColor) this._laneColor = new ColorCls("#ffcc00"); if (!this._laneSnapColor) this._laneSnapColor = new ColorCls("#00e0ff"); var LH = this.constructor; if (!LH || !LH.createSphere) return null; var g = LH.createSphere(null, this._laneColor, this.radius); if (!g) return null; g.name = "lane_snap_point"; if (g.material) g.material.opacity = 1; g.visible = false; this.viewer.scene.add(g); this._laneGhost = g; return g; }, laneClearGhost: function () { if (this._laneGhost && this._laneGhost.visible) { this._laneGhost.visible = false; try { this.viewer.rendererMain(); } catch (e) { } } }, // 拖动结束(冒泡阶段 mouseup):把吸附到一起的顶点合并成一个 onLaneMergeMouseup: function (ev) { try { var snap = this.__laneSnap; this.__laneSnap = null; if (!snap || !ev || ev.button !== 0) return; if (this._laneLassoActive) return; if (snap.same) this.laneMergeVertex(snap); this.laneClearGhost(); } catch (e) { console.warn("[snap-merge] mouseup", e); } }, }; } // #endregion lane-methods // #region lane-lasso // 2D 索套切边:在屏幕上沿想保留的边界打点画一条开放折线,按 Q 闭合, // 多边形边界被这条线替换(画到哪、边界就走到哪),另一侧的顶点被裁掉。 function makeLassoMethods(deps) { var S = deps.S, Vector3 = deps.Vector3; var COL_OPEN = "#ff3b30"; // 还没形成有效切割 var COL_OK = "#22c55e"; // 可以切 var FONT = "12px/1.5 system-ui,-apple-system,'Microsoft YaHei',sans-serif"; return { // ---------- 屏幕 / 射线基础 ---------- laneRayFromNdc: function (ndc) { var cam = this.viewer.camera; var near = new Vector3(ndc.x, ndc.y, -1).unproject(cam); var far = new Vector3(ndc.x, ndc.y, 1).unproject(cam); return { origin: near, direction: far.sub(near).normalize() }; }, laneNdcOfEvent: function (ev) { var r = this.domElement.getBoundingClientRect(); return { x: (ev.clientX - r.left) / r.width * 2 - 1, y: -((ev.clientY - r.top) / r.height * 2 - 1) }; }, lanePxToNdc: function (px, rect) { return { x: px.x / rect.width * 2 - 1, y: -(px.y / rect.height * 2 - 1) }; }, laneToClient: function (px, rect) { return { x: rect.left + px.x, y: rect.top + px.y }; }, laneLerpPx: function (a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }, // 3 个不共线顶点确定的平面 lanePolyPlane: function (pts) { if (!pts || pts.length < 3) return null; var a = pts[0]; for (var i = 1; i < pts.length; i++) { var v1 = pts[i].clone().sub(a); if (v1.lengthSq() < 1e-12) continue; for (var j = i + 1; j < pts.length; j++) { var v2 = pts[j].clone().sub(a); if (v2.lengthSq() < 1e-12) continue; var n = v1.clone().cross(v2); if (n.length() > 1e-9) return { point: a.clone(), normal: n.normalize() }; } } return null; }, laneRayPlane: function (ray, plane) { var n = plane.normal, o = ray.origin, d = ray.direction; var den = d.x * n.x + d.y * n.y + d.z * n.z; if (Math.abs(den) < 1e-9) return null; var t = ((plane.point.x - o.x) * n.x + (plane.point.y - o.y) * n.y + (plane.point.z - o.z) * n.z) / den; if (t < 0) return null; return new Vector3(o.x + d.x * t, o.y + d.y * t, o.z + d.z * t); }, // 屏幕点 -> 世界点(优先落在该多边形所在平面,退化时落到地面) laneWorldOfNdc: function (ndc, poly) { var ray = this.laneRayFromNdc(ndc), plane = null; if (poly) { try { plane = this.lanePolyPlane(poly.getSpheresAndLines()[0].map(function (s) { return s.position; })); } catch (e) { } } if (!plane) { var gh = 0; try { gh = S.statusState().groundHeight || 0; } catch (e) { } plane = { point: new Vector3(0, 0, gh), normal: { x: 0, y: 0, z: 1 } }; } return this.laneRayPlane(ray, plane); }, // ---------- 2D 纯几何:索套切分 ---------- laneSegIntersect: function (a, b, c, d) { var rx = b.x - a.x, ry = b.y - a.y, sx = d.x - c.x, sy = d.y - c.y; var den = rx * sy - ry * sx; if (Math.abs(den) < 1e-12) return null; var qx = c.x - a.x, qy = c.y - a.y; var t = (qx * sy - qy * sx) / den; var u = (qx * ry - qy * rx) / den; if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9) return null; return { t: t < 0 ? 0 : (t > 1 ? 1 : t), u: u < 0 ? 0 : (u > 1 ? 1 : u) }; }, // 沿多边形边界:从 eFrom 边之后的顶点走到 eTo 边的起点(不含两端交点) // 沿多边形边界从参数 sFrom 正向走到参数 sTo,收集严格介于两者之间的顶点。 // 参数 = 边索引 + 该边上的比例:顶点 v 的参数就是 v,顶点 v→v+1 这条边上的参数在 (v, v+1)。 // 这样“边上的穿越点”和“正好落在顶点上”可以用同一套参数描述。 laneArcRefsBetween: function (sFrom, sTo, polyPx) { var out = [], n = polyPx.length, span = sTo - sFrom; while (span < 0) span += n; // 出口在入口“后方”时要绕整整一圈 if (span <= 1e-9) return out; // 必须按“沿边界行走”的顺序收集(不能按顶点序号遍历,否则跨过 0 号顶点时顺序会乱、环会自相交) var k = Math.floor(sFrom) + 1; for (var guard = 0; guard < n; guard++) { var d = k - sFrom; if (d >= span - 1e-9) break; // 已经到(或越过)出口 out.push({ k: "v", i: k % n, px: polyPx[k % n] }); k++; } return out; }, // 屏幕点是否落在某个多边形顶点附近(返回顶点序号,没有则 -1) laneNearVertex: function (x, y, polyPx, tol) { for (var i = 0; i < polyPx.length; i++) { var dx = x - polyPx[i].x, dy = y - polyPx[i].y; if (Math.sqrt(dx * dx + dy * dy) <= tol) return i; } return -1; }, // 点是否在(屏幕空间)多边形环内 lanePointInRing: function (pt, refs) { var inside = false; for (var i = 0, j = refs.length - 1; i < refs.length; j = i++) { var a = refs[i].px, b = refs[j].px; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) inside = !inside; } return inside; }, laneRingArea: function (refs) { var s = 0; for (var i = 0; i < refs.length; i++) { var a = refs[i].px, b = refs[(i + 1) % refs.length].px; s += a.x * b.y - b.x * a.y; } return s / 2; }, // 用开放折线 lasso 切闭合多边形 poly(屏幕坐标),返回 { refs, area, otherArea } // 用开放折线 lasso 切闭合多边形 poly(都是屏幕坐标)。 // 仍然要求正好两个“交点”,但交点既可以是边上的穿越点,也可以直接是多边形的已有顶点 // (画索套时靠近顶点会自动吸附,所以“从一个顶点切到另一个顶点”同样成立)。 laneCut2D: function (polyPx, lassoPx, flip, preferPoint, vertexTol) { var n = polyPx.length, m = lassoPx.length; if (n < 3 || m < 2) return null; var tol = (typeof vertexTol === "number" && vertexTol > 0) ? vertexTol : 6; var hits = []; // ① 索套的点正好落在多边形顶点上(吸附后就是这种情形):直接算一个交点,参数取该顶点 for (var i = 0; i < m; i++) { var vi = this.laneNearVertex(lassoPx[i].x, lassoPx[i].y, polyPx, tol); if (vi >= 0) hits.push({ order: i, v: vi, s: vi, px: polyPx[vi] }); } // ② 边上的穿越点:落在顶点附近的不再重复计入(交给 ①) for (var j = 0; j < m - 1; j++) { for (var k = 0; k < n; k++) { var h = this.laneSegIntersect(lassoPx[j], lassoPx[j + 1], polyPx[k], polyPx[(k + 1) % n]); if (!h) continue; var px = this.laneLerpPx(lassoPx[j], lassoPx[j + 1], h.t); if (this.laneNearVertex(px.x, px.y, polyPx, tol) >= 0) continue; hits.push({ order: j + h.t, e: k, u: h.u, s: k + h.u, px: px }); } } if (hits.length < 2) return null; hits.sort(function (x, y) { return x.order - y.order; }); // 合并几乎重合的命中,并要求恰好两个 var uniq = []; for (var q = 0; q < hits.length; q++) { var dup = false; for (var w = 0; w < uniq.length; w++) { var ddx = hits[q].px.x - uniq[w].px.x, ddy = hits[q].px.y - uniq[w].px.y; if (Math.sqrt(ddx * ddx + ddy * ddy) < 1.5) { dup = true; break; } } if (!dup) uniq.push(hits[q]); } if (uniq.length !== 2) return null; var entry = uniq[0], exit = uniq[1]; var refOf = function (hit) { return (hit.v === undefined || hit.v === null) ? { k: "c", e: hit.e, u: hit.u, px: hit.px } : { k: "v", i: hit.v, px: hit.px }; }; var entryRef = refOf(entry), exitRef = refOf(exit); var mid = []; for (var q2 = 0; q2 < m; q2++) { if (q2 > entry.order && q2 < exit.order) mid.push({ k: "l", i: q2, px: lassoPx[q2] }); } var arcA = this.laneArcRefsBetween(entry.s, exit.s, polyPx).concat([exitRef]); var arcB = this.laneArcRefsBetween(exit.s, entry.s, polyPx).concat([entryRef]); var ringA = [entryRef].concat(arcA, mid.slice().reverse()); var ringB = [exitRef].concat(arcB, mid); var aA = Math.abs(this.laneRingArea(ringA)), aB = Math.abs(this.laneRingArea(ringB)); // 两侧都要有实际面积:贴着自己边界画的索套只会在原地“折返”,这种退化的切分拒绝执行 if (!(Math.max(aA, aB) > 1) || Math.min(aA, aB) < 2) return null; // 默认保留“原多边形重心所在的那一侧”(也就是多边形主体)。 // 只按面积比大小在细长的路面上不可靠:屏幕上很扁时,要删掉的那一小片面积反而可能更大。 var takeA; if (preferPoint) { var inA = this.lanePointInRing(preferPoint, ringA); var inB = this.lanePointInRing(preferPoint, ringB); takeA = (inA !== inB) ? inA : (aA >= aB); } else { takeA = (aA >= aB); } if (flip) takeA = !takeA; // Alt:换成另一侧 return { refs: takeA ? ringA : ringB, removed: takeA ? ringB : ringA, // 将被删除的那一侧(用于预览) area: takeA ? aA : aB, otherArea: takeA ? aB : aA, crossings: [entryRef, exitRef] }; }, // ---------- 索套状态 / 绘制 ---------- laneLassoEnabled: function () { var t; try { t = S.getCurLabelType(); } catch (e) { return false; } if (t !== LABEL_TYPE_MARK) return false; if (this.isPainting || this.isDeleting) return false; return !!this.canEdit(); }, laneLassoCanvas: function () { if (this._laneCanvas && this._laneCanvas.isConnected) return this._laneCanvas; var cv = document.createElement("canvas"); cv.id = "lane-lasso-canvas"; cv.style.cssText = "position:fixed;left:0;top:0;pointer-events:none;z-index:4;display:none"; document.body.appendChild(cv); this._laneCanvas = cv; return cv; }, // 索套不再生成浮动按钮(功能保留:按 M 键进入/退出) laneLassoButton: function () { return null; }, laneLassoButtonUpdate: function () { }, laneLassoOwnUi: function (t) { return !!(t && ((this._laneBtn && this._laneBtn.contains(t)) || (this._laneToastEl && this._laneToastEl.contains(t)))); }, laneToast: function (text) { var el = this._laneToastEl; if (!el) { el = document.createElement("div"); el.id = "lane-toast"; el.style.cssText = "position:fixed;z-index:8;left:50%;transform:translateX(-50%);padding:6px 12px;border-radius:6px;font:" + FONT + ";color:#fff;background:rgba(0,0,0,.8);pointer-events:none;display:none;white-space:nowrap"; document.body.appendChild(el); this._laneToastEl = el; } var rect = this.domElement.getBoundingClientRect(); el.style.top = (rect.bottom - 64) + "px"; el.textContent = text; el.style.display = "block"; var self = this; if (this._laneToastTimer) clearTimeout(this._laneToastTimer); this._laneToastTimer = setTimeout(function () { el.style.display = "none"; }, 2600); }, laneLassoMode: function (on) { this._laneLassoActive = !!on; this._laneLassoPts = []; this._laneCursor = null; if (on) { this._laneUndo = null; this._laneSelTrack = null; this.laneClearGhost(); try { this._laneSelTrack = S.statusState().selectMarkInfo.trackId; } catch (e) { } } if (this.domElement) this.domElement.style.cursor = on ? "crosshair" : ""; this._laneLassoShow = on; this.laneLassoDraw(); }, // 当前索套点(世界点按当前视图重新投影)-> 画布相对像素 laneLassoScreen: function (rect) { var out = []; for (var i = 0; i < this._laneLassoPts.length; i++) { var p = this._laneLassoPts[i], px = null; if (p.world) px = this.laneProject(p.world, rect); if (!px) px = p.pxFallback; if (px) out.push(px); } return out; }, // 找到会被切分的多边形:优先已选中的那个 laneLassoPlan: function (flip) { var rect = this.domElement.getBoundingClientRect(); var lassoPx = this.laneLassoScreen(rect); if (lassoPx.length < 2) return null; var polys = this.lanePolylines(); var selected = null; try { selected = S.statusState().selectMarkInfo.trackId; } catch (e) { } if (!selected && this._laneSelTrack) selected = this._laneSelTrack; var best = null; for (var i = 0; i < polys.length; i++) { var p = polys[i], sph = null; try { sph = p.getSpheresAndLines()[0]; } catch (e) { continue; } if (!sph || sph.length < 3) continue; var polyPx = [], ok = true; for (var j = 0; j < sph.length; j++) { var px = this.laneProject(sph[j].position, rect); if (!px) { ok = false; break; } polyPx.push(px); } if (!ok) continue; // 以多边形重心为“要保留的那一侧”的参照点(重心落在原多边形外时不用它) var cx = 0, cy = 0, q2; for (q2 = 0; q2 < polyPx.length; q2++) { cx += polyPx[q2].x; cy += polyPx[q2].y; } cx /= polyPx.length; cy /= polyPx.length; var polyRefs = []; for (q2 = 0; q2 < polyPx.length; q2++) polyRefs.push({ px: polyPx[q2] }); var center = this.lanePointInRing({ x: cx, y: cy }, polyRefs) ? { x: cx, y: cy } : null; var cut = this.laneCut2D(polyPx, lassoPx, flip, center, readPx("lane_cut_vertex_px", DEFAULTS.CUT_VERTEX_PX)); if (!cut) continue; var isSel = p.trackId === selected; if (!best || (isSel && !best.isSel) || (isSel === best.isSel && cut.area > best.cut.area)) { best = { poly: p, cut: cut, isSel: isSel }; } } return best; }, laneLassoDraw: function () { var cv = this.laneLassoCanvas(); if (!cv) return; var dpr = window.devicePixelRatio || 1; var w = Math.max(1, window.innerWidth), h = Math.max(1, window.innerHeight); if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); cv.style.width = w + "px"; cv.style.height = h + "px"; } var ctx = cv.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); if (!this._laneLassoActive) { cv.style.display = "none"; this.laneLassoButtonUpdate(); return; } cv.style.display = "block"; var rect = this.domElement.getBoundingClientRect(); var pts = [], i; for (i = 0; i < this._laneLassoPts.length; i++) { var p = this._laneLassoPts[i], px = p.world ? this.laneProject(p.world, rect) : null; pts.push(px ? this.laneToClient(px, rect) : p.client); } var plan = this.laneLassoPlan(!!this._laneAlt); // 按住 Alt 时预览另一侧 var col = plan ? COL_OK : COL_OPEN; // 先把“将被删除的区域”铺出来(半透明红),这样按 Q 之前就能看清删哪一侧 if (plan && plan.cut.removed && plan.cut.removed.length > 2) { var rem = plan.cut.removed; var rp = this.laneToClient(rem[0].px, rect); ctx.beginPath(); ctx.moveTo(rp.x, rp.y); for (var ri = 1; ri < rem.length; ri++) { var rq = this.laneToClient(rem[ri].px, rect); ctx.lineTo(rq.x, rq.y); } ctx.closePath(); ctx.fillStyle = "rgba(255,72,60,.28)"; ctx.fill(); ctx.strokeStyle = "rgba(255,72,60,.85)"; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]); } ctx.lineJoin = "round"; ctx.lineCap = "round"; if (pts.length > 1) { ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.stroke(); } if (pts.length && this._laneCursor) { ctx.save(); ctx.setLineDash([6, 4]); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(pts[pts.length - 1].x, pts[pts.length - 1].y); ctx.lineTo(this._laneCursor.x, this._laneCursor.y); ctx.stroke(); ctx.restore(); } if (plan) { var cr = [plan.cut.crossings[0], plan.cut.crossings[1]]; for (i = 0; i < cr.length; i++) { var cp = this.laneToClient(cr[i].px, rect); ctx.beginPath(); ctx.arc(cp.x, cp.y, 6, 0, Math.PI * 2); ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2; ctx.stroke(); } } for (i = 0; i < pts.length; i++) { var isSnap = !!(this._laneLassoPts[i] && this._laneLassoPts[i].vertex); ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, isSnap ? 7 : 4, 0, Math.PI * 2); if (isSnap) { // 吸附到多边形顶点的点画成白圈,便于确认 ctx.fillStyle = "#ffffff"; ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, 4, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); } else { ctx.fillStyle = col; ctx.fill(); } } // 提示条 var snapN = 0; for (i = 0; i < this._laneLassoPts.length; i++) if (this._laneLassoPts[i].vertex) snapN++; var hint = plan ? "红色区域将被删除:" + (plan.poly.className || plan.poly.classId || plan.poly.trackId || "多边形") + " · Q 确认(Alt 换成另一侧)· Z 撤销 · Esc/M 退出" : (snapN ? "已吸附 " + snapN + " 个多边形顶点" : "索套切边:左键打点(靠近多边形顶点会自动吸附)") + " · Q 闭合切边 · Backspace 退点 · Esc/M 退出"; ctx.font = FONT.split("/")[0].replace("12px", "13px") + " system-ui,-apple-system,'Microsoft YaHei',sans-serif"; var tw = ctx.measureText(hint).width; var bx = Math.max(8, (w - tw) / 2 - 10), by = rect.top + 10; ctx.fillStyle = "rgba(0,0,0,.72)"; ctx.fillRect(bx, by, tw + 20, 26); ctx.fillStyle = "#fff"; ctx.fillText(hint, bx + 10, by + 18); this.laneLassoButtonUpdate(); }, // ---------- 交互 ---------- // 找光标附近的多边形顶点(用于吸附;优先当前会被切的那个多边形) laneLassoVertexAt: function (px, rect, preferPoly) { var limit = readPx("lane_vertex_snap_px", DEFAULTS.SNAP_VERTEX_PX); if (!(limit > 0)) return null; var polys = this.lanePolylines(), best = null, bestD = limit; for (var i = 0; i < polys.length; i++) { var p = polys[i], sph = p.getSpheresAndLines()[0]; for (var j = 0; j < sph.length; j++) { var sp = this.laneProject(sph[j].position, rect); if (!sp) continue; var d = Math.hypot(sp.x - px.x, sp.y - px.y) + ((preferPoly && p === preferPoly) ? 0 : 3); if (d < bestD) { bestD = d; best = { poly: p, index: j, px: sp, position: sph[j].position }; } } } return best; }, laneLassoAddPoint: function (ev) { var rect = this.domElement.getBoundingClientRect(); var ndc = this.laneNdcOfEvent(ev); var plan = this.laneLassoPlan(false); var world = this.laneWorldOfNdc(ndc, plan && plan.poly); var px = { x: ev.clientX - rect.left, y: ev.clientY - rect.top }; // 靠近多边形顶点就吸附到该顶点:可以精确地“从这个顶点切到那个顶点” var snapV = this.laneLassoVertexAt(px, rect, plan && plan.poly); if (snapV && snapV.position) { world = snapV.position.clone(); px = snapV.px; } this._laneLassoPts.push({ world: world, pxFallback: px, client: { x: rect.left + px.x, y: rect.top + px.y }, vertex: snapV ? { poly: snapV.poly, index: snapV.index } : null }); this._laneCursor = { x: ev.clientX, y: ev.clientY }; this.laneLassoDraw(); }, laneLassoHandleDom: function (ev) { if (!this._laneLassoActive) return; if (ev.type === "mousedown" || ev.type === "pointerdown") { this._laneAlt = !!ev.altKey; if (ev.button === 0) this.laneLassoAddPoint(ev); // 只有左键打点 return; } if (ev.type === "mousemove" || ev.type === "pointermove") { this._laneCursor = { x: ev.clientX, y: ev.clientY }; this._laneAlt = !!ev.altKey; this.laneLassoDraw(); } }, laneLassoApply: function (flip) { var plan = this.laneLassoPlan(flip); if (!plan) { this.laneToast("索套需要正好穿过多边形边界两次(线变绿才能切)"); return false; } var rect = this.domElement.getBoundingClientRect(); var poly = plan.poly; var sph = poly.getSpheresAndLines()[0]; var p3 = sph.map(function (s) { return s.position; }); var plane = this.lanePolyPlane(p3); var out = []; for (var i = 0; i < plan.cut.refs.length; i++) { var ref = plan.cut.refs[i], v = null; if (ref.k === "v") { v = p3[ref.i] ? p3[ref.i].clone() : null; } else { var ray = this.laneRayFromNdc(this.lanePxToNdc(ref.px, rect)); if (ref.k === "c") { var a = p3[ref.e], b = p3[(ref.e + 1) % p3.length]; v = (a && b) ? this.laneClosestOnSegment(ray.origin, ray.direction, a, b) : null; } else { v = plane ? this.laneRayPlane(ray, plane) : null; if (!v) { var gh = 0; try { gh = S.statusState().groundHeight || 0; } catch (e) { } v = this.laneRayPlane(ray, { point: new Vector3(0, 0, gh), normal: { x: 0, y: 0, z: 1 } }); } } } if (!v) { this.laneToast("切分失败:新顶点坐标无法求解,请调整索套"); return false; } var cc = this.laneClampXY ? this.laneClampXY(v.x, v.y) : null; // 新顶点也限制在标注范围内 if (cc && cc.moved) { v.x = cc.x; v.y = cc.y; } out.push([v.x, v.y, v.z]); } if (out.length < 3) { this.laneToast("切分后顶点不足 3 个,已取消"); return false; } var oldPts = (poly.mark && poly.mark.points) ? poly.mark.points.map(function (q) { return [q[0], q[1], q[2]]; }) : null; try { if (poly.mark) poly.mark.points = out; poly.initChildren(); } catch (e) { console.warn("[lasso-cut] cut", e); this.laneToast("切分失败"); return false; } try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (e) { console.warn("[lasso-cut] 保存失败", e); } this._laneUndo = oldPts ? { poly: poly, points: oldPts } : null; this._laneLassoPts = []; this.viewer.rendererMain(); this.laneLassoDraw(); this.laneToast("已切边:" + (poly.className || poly.classId || poly.trackId || "") + ",顶点 " + poly.getSpheresAndLines()[0].length + " 个(Z 可撤销)"); return true; }, laneLassoUndo: function () { var u = this._laneUndo; if (!u || !u.poly) { this.laneToast("没有可撤销的切边"); return false; } try { if (u.poly.mark) u.poly.mark.points = u.points.map(function (q) { return [q[0], q[1], q[2]]; }); u.poly.initChildren(); S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, u.poly); } catch (e) { console.warn("[lasso-cut] undo", e); this.laneToast("撤销失败"); return false; } this._laneUndo = null; this._laneLassoPts = []; this.viewer.rendererMain(); this.laneLassoDraw(); this.laneToast("已撤销上一次切边"); return true; }, laneLassoKey: function (ev) { var t = ev.target || {}, tag = (t.tagName || "").toUpperCase(); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || t.isContentEditable) return; var k = (ev.key || "").toLowerCase(); if (!this._laneLassoActive) { if (k === "m" && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey && this.laneLassoEnabled()) { var dis = false; try { dis = S.isDisabledShortKey ? S.isDisabledShortKey() : false; } catch (e) { } if (dis) return; this.laneLassoMode(true); this.laneToast("索套切边:沿要保留的边界打点,Q 闭合切边"); ev.preventDefault(); ev.stopPropagation(); } return; } var used = true; if (k === "q" || k === "enter") this.laneLassoApply(!!ev.altKey); else if (k === "escape") { this.laneLassoMode(false); this.laneToast("已退出索套切边"); } else if (k === "backspace") { this._laneLassoPts.pop(); this.laneLassoDraw(); } else if (k === "z" && !ev.ctrlKey && !ev.metaKey) this.laneLassoUndo(); else if (k === "m") { this.laneLassoMode(false); this.laneToast("已退出索套切边"); } else used = false; if (used) { ev.preventDefault(); ev.stopPropagation(); } } }; } // #endregion lane-lasso // #region lane-ground // 吸附到地面(贴地):把当前帧折线/多边形顶点吸到正下方点云的最低点,支持闭合区域(polygon_3d)。 // 约束与兜底(避免"附近没有点云被乱吸"): // 1) 半径内必须有足够多的点(默认 ≥3)才认这个高度,孤零零一两个点不算; // 2) 找到的高度若明显偏离整帧参照高度(默认 >1 米),视为乱吸,改用兜底; // 3) 兜底顺序:同一条线上已解决的前/后邻居高度 → 整帧参照高度(已吸附点的平均值, // 剔除离群后取均值;一个都没有时用点云整体的 5% 分位 Z)→ 都没有才保持原值。 function makeGroundMethods(deps) { var S = deps.S, Vector3 = deps.Vector3; return { // 把点云按 XY 网格分桶:避免每个顶点都扫一遍全量点云(百万点云会卡死) laneBuildGroundGrid: function (pos, cell) { var cells = new Map(); for (var i = 0; i + 2 < pos.length; i += 3) { var ix = Math.floor(pos[i] / cell), iy = Math.floor(pos[i + 1] / cell); var k = ix + "," + iy; var arr = cells.get(k); if (!arr) { arr = []; cells.set(k, arr); } arr.push(i); } return { cells: cells, cell: cell }; }, // 半径内最低的 Z;startR 起逐级扩大,直到 maxR;要求该半径内至少有 minPts 个点,否则继续扩大 laneGroundZBelow: function (grid, pos, x, y, startR, maxR, minPts) { var need = (minPts > 0) ? minPts : 1; var cell = grid.cell; var ix = Math.floor(x / cell), iy = Math.floor(y / cell); var rings = Math.ceil(maxR / cell) + 1, cand = [], dx, dy, t, arr; for (dx = -rings; dx <= rings; dx++) { for (dy = -rings; dy <= rings; dy++) { arr = grid.cells.get((ix + dx) + "," + (iy + dy)); if (arr) for (t = 0; t < arr.length; t++) cand.push(arr[t]); } } if (!cand.length) return null; for (var r = startR; r <= maxR + 1e-6; r += startR) { var r2 = r * r, minZ = Infinity, hit = 0; for (var q = 0; q < cand.length; q++) { var j = cand[q]; var ddx = pos[j] - x, ddy = pos[j + 1] - y; if (ddx * ddx + ddy * ddy <= r2) { var pz = pos[j + 2]; if (pz < minZ) minZ = pz; hit++; } } if (hit >= need) return minZ; } return null; }, laneGroundPosArray: function () { var pos = null; try { pos = this.viewer.cloudData && this.viewer.cloudData.positionArray; } catch (e) { } if (!pos) { try { pos = this.viewer.points.geometry.attributes.position.array; } catch (e) { } } return pos; }, // 最后的兜底:点云整体高度的 5% 分位(近似地面高度) laneGroundFallbackZ: function (pos) { var zs = []; for (var i = 2; i < pos.length; i += 3) zs.push(pos[i]); if (!zs.length) return null; zs.sort(function (a, b) { return a - b; }); return zs[Math.floor(zs.length * 0.05)]; }, // 吸附到地面。已选中某个标注物时只吸附它,否则吸附当前视图里所有折线/多边形。 laneSnapToGround: function () { if (!this.canEdit || !this.canEdit()) { this.laneToast("当前不是编辑模式,无法贴地"); return false; } var pos = this.laneGroundPosArray(); if (!pos) { this.laneToast("未取到点云数据,无法贴地"); return false; } var startR = readPx("lane_ground_r", DEFAULTS.GROUND_R); var maxR = readPx("lane_ground_maxr", DEFAULTS.GROUND_MAXR); var minPts = readPx("lane_ground_minpts", DEFAULTS.GROUND_MINPTS); var maxDev = readPx("lane_ground_maxdev", DEFAULTS.GROUND_MAXDEV); // 0 = 不做偏离限制 if (!(startR > 0)) startR = DEFAULTS.GROUND_R; if (!(maxR >= startR)) maxR = Math.max(startR, DEFAULTS.GROUND_MAXR); var polys = this.lanePolylines(); var sel = null; try { sel = S.statusState().selectMarkInfo.trackId; } catch (e) { } var targets = []; for (var i = 0; i < polys.length; i++) { if (!sel || polys[i].trackId === sel) targets.push(polys[i]); } if (!targets.length) { this.laneToast(sel ? "当前选中/视图里没有可贴地的折线或多边形" : "当前视图没有可贴地的折线或多边形"); return false; } var grid = this.laneBuildGroundGrid(pos, Math.max(maxR, 0.2)); // ---- 第一遍:用点云找高度,收集所有找到的值 ---- var plans = []; var zs = []; for (var a = 0; a < targets.length; a++) { var poly0 = targets[a], mk0 = poly0.mark; if (!mk0 || !mk0.points || !mk0.points.length) continue; var list = []; for (var b = 0; b < mk0.points.length; b++) { var p0 = mk0.points[b]; if (!p0 || p0.length < 3) { list.push(null); continue; } var gz = this.laneGroundZBelow(grid, pos, p0[0], p0[1], startR, maxR, minPts); list.push(gz); if (gz !== null) zs.push(gz); } plans.push({ poly: poly0, cloud: list }); } if (!plans.length) { this.laneToast("当前视图没有可贴地的折线或多边形"); return false; } // ---- 整帧参照高度:先取中位数,再对 ±maxDev 内的值取平均(比纯平均抗离群) ---- var ref = null; if (zs.length) { var sorted = zs.slice().sort(function (x, y) { return x - y; }); var med = sorted[Math.floor(sorted.length / 2)]; var sum = 0, n = 0; for (var s = 0; s < sorted.length; s++) { if (maxDev > 0 && Math.abs(sorted[s] - med) > maxDev) continue; sum += sorted[s]; n++; } ref = n ? (sum / n) : med; } else { ref = this.laneGroundFallbackZ(pos); // 点云一个都没找到(点云极稀疏)时的兜底 } // ---- 第二遍:决定每个顶点的最终高度 ---- var undo = [], byCloud = 0, byNeighbor = 0, byRef = 0, skipped = 0, rejected = 0, missed = []; for (var t = 0; t < plans.length; t++) { var poly = plans[t].poly, mk = poly.mark, cloud = plans[t].cloud; var before = mk.points.map(function (q) { return [q[0], q[1], q[2]]; }); var finalZ = [], changed = false; // 2.1 先用点云结果(过偏离限制) for (var j = 0; j < mk.points.length; j++) { var gz2 = cloud[j]; if (gz2 !== null && maxDev > 0 && ref !== null && Math.abs(gz2 - ref) > maxDev) { rejected++; gz2 = null; // 明显偏离整帧高度 -> 认为乱吸,走兜底 } finalZ.push(gz2); } // 2.2 没找到的:用同一条线上已解决的前/后邻居(各取最近 2 个)估算 for (var j2 = 0; j2 < finalZ.length; j2++) { if (finalZ[j2] !== null) continue; var nb = [], k2; for (k2 = j2 - 1; k2 >= 0 && nb.length < 2; k2--) if (finalZ[k2] !== null) nb.push(finalZ[k2]); for (k2 = j2 + 1; k2 < finalZ.length && nb.length < 4; k2++) if (finalZ[k2] !== null) nb.push(finalZ[k2]); if (nb.length) { var m = 0; for (k2 = 0; k2 < nb.length; k2++) m += nb[k2]; finalZ[j2] = m / nb.length; byNeighbor++; } else if (ref !== null) { finalZ[j2] = ref; // 整帧参照高度 byRef++; } else { missed.push({ trackId: poly.trackId, index: j2, x: mk.points[j2][0], y: mk.points[j2][1], z: mk.points[j2][2] }); continue; } } // 2.3 写回 for (var j3 = 0; j3 < mk.points.length; j3++) { var p3 = mk.points[j3], fz = finalZ[j3]; if (fz === null || fz === undefined || !p3 || p3.length < 3) continue; if (cloud[j3] !== null) byCloud++; if (Math.abs(fz - p3[2]) < 0.01) { skipped++; continue; } p3[2] = fz; changed = true; } if (!changed) continue; undo.push({ poly: poly, points: before }); try { poly.initChildren(); } catch (e) { } // 用「修改3D点」提交:平台对这种 action 不做多帧同步,所以贴地只改当前帧 try { S.modifyMark3D(MARK_ACTION_MODIFY_POINTS, poly); } catch (e) { } } if (undo.length) this._laneSnapUndo = undo; this._laneSnapMissed = missed; try { this.viewer.rendererMain(); } catch (e) { } var moved = byCloud + byNeighbor + byRef - skipped; if (!undo.length) { this.laneToast("无需贴地:" + skipped + " 个点已在地面" + (missed.length ? "," + missed.length + " 个点没有任何参照" : "")); return false; } this.laneToast("已贴地:" + undo.length + " 个标注物 / " + moved + " 个点(云点 " + byCloud + " · 邻近估算 " + byNeighbor + " · 参照高度 " + byRef + (rejected ? " · 偏离被拒 " + rejected : "") + (skipped ? " · 已在地面 " + skipped : "") + (missed.length ? " · 无参照 " + missed.length : "") + ") · Alt+点击可撤销"); return true; }, // 撤销上一次贴地(按钮上 Alt+点击) laneUndoSnap: function () { var u = this._laneSnapUndo; if (!u || !u.length) { this.laneToast("没有可撤销的贴地操作"); return false; } var n = 0; for (var i = 0; i < u.length; i++) { var it = u[i]; if (!it.poly || !it.poly.mark) continue; it.poly.mark.points = it.points.map(function (q) { return [q[0], q[1], q[2]]; }); try { it.poly.initChildren(); } catch (e) { } try { S.modifyMark3D(MARK_ACTION_MODIFY_POINTS, it.poly); } catch (e) { } // 撤销同样只改当前帧 n++; } this._laneSnapUndo = null; try { this.viewer.rendererMain(); } catch (e) { } this.laneToast("已撤销贴地(还原 " + n + " 个标注物)"); return true; }, /* ---------- 按钮:直接植入平台顶部工具条(“显示距离 / 无需标注”那一行) ---------- */ laneEnsureGroundStyle: function () { if (this._groundStyle || document.getElementById('lane-ground-style')) return; var st = document.createElement('style'); st.id = 'lane-ground-style'; st.textContent = '#lane-ground-btn{font:inherit;color:inherit;line-height:1.4;}' + '#lane-ground-btn.lane-gb-toolbar{position:static;display:inline-block;vertical-align:middle;' + 'margin:0 12px 0 0;padding:3px 12px;border:1px solid #2f6fb5;border-radius:3px;' + 'background:#e8f2fd;color:#1b5fa8;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none;' + 'box-shadow:0 1px 2px rgba(0,0,0,.12);}' + '#lane-ground-btn.lane-gb-toolbar:hover{background:#d3e6fb;border-color:#1b5fa8;}' + '#lane-ground-btn.lane-gb-float{position:fixed;z-index:6;padding:4px 9px;border-radius:5px;color:#fff;' + 'background:rgba(20,20,20,.72);border:1px solid rgba(255,255,255,.28);cursor:pointer;white-space:nowrap;user-select:none;}'; (document.head || document.body).appendChild(st); this._groundStyle = st; }, // 找平台工具条里“显示距离 / 无需标注”所在的那一行 laneToolbarHost: function () { var anchors = ['显示距离', '无需标注'], found = [], all, i, el, txt; try { all = document.querySelectorAll('label,span,em,dd,dt,div,p,li'); } catch (e) { all = []; } for (i = 0; i < all.length && found.length < 2; i++) { el = all[i]; if (!el || (el.children && el.children.length)) continue; // 只要最内层文本 txt = (el.textContent || '').replace(/\s+/g, '').trim(); if (anchors.indexOf(txt) >= 0) found.push(el); } if (!found.length) return null; // 插在“显示距离”这一项的**前面**:wrap 是这一项(可能就是 label 本身),row 是它所在的那一行 var anchor = found[0], wrap = anchor.parentElement || anchor, row = wrap.parentElement || wrap; return { row: row, before: wrap, anchor: anchor }; }, laneGroundButton: function () { if (this._groundBtn && this._groundBtn.isConnected) return this._groundBtn; var self = this; var b = document.createElement("div"); b.id = "lane-ground-btn"; b.textContent = "吸附到地面"; b.title = "把折线/多边形的顶点吸附到正下方点云的最低点(选中某个标注物则只贴它;Alt+点击=撤销上次贴地)"; b.addEventListener("click", function (ev) { ev.stopPropagation(); ev.preventDefault(); if (ev.altKey) self.laneUndoSnap(); else self.laneSnapToGround(); }, true); document.body.appendChild(b); this._groundBtn = b; return b; }, // 把按钮挂到平台工具条上;挂不上就退回“视图左下角浮动按钮” laneMountGroundButton: function () { var b = this.laneGroundButton(); if (!b) return false; this.laneEnsureGroundStyle(); var host = null; try { host = this.laneToolbarHost(); } catch (e) { } if (host && host.row && host.before && host.before.parentElement === host.row) { if (b.parentNode !== host.row || b.nextSibling !== host.before) { try { host.row.insertBefore(b, host.before); } catch (e) { } } if (b.className !== 'lane-gb-toolbar') { b.className = 'lane-gb-toolbar'; b.style.cssText = ''; } this._groundBtnFloating = false; return true; } // 兜底:浮动按钮 if (!this._groundBtnFloating) { b.className = 'lane-gb-float'; b.style.cssText = ''; this._groundBtnFloating = true; } if (this.domElement && this.domElement.getBoundingClientRect) { var rect = this.domElement.getBoundingClientRect(); b.style.left = (rect.left + 12) + "px"; b.style.top = (rect.bottom - 32) + "px"; b.style.display = this.laneLassoEnabled() ? "block" : "none"; } return false; } }; } // #endregion lane-ground // #endregion lane-ground // 注意:instance.viewer 是懒加载 getter,读取它会创建 Viewer; // 这里只读内部字段 _viewer,避免在平台还没准备好时提前实例化。 function findViewer() { var inst = W.instance; if (inst && inst._viewer) return inst._viewer; return W.viewer || null; // Viewer 构造时会把自己挂到 window.viewer } function findStore() { var inst = W.instance; try { return inst && inst.StoreManager; } catch (e) { return null; } } function install(viewer) { if (!viewer || !viewer.lineHandler || !viewer.render) return false; var lh = viewer.lineHandler; if (!lh.__laneInstalled) { var S = findStore(); if (!S) return false; if (!viewer.camera || !viewer.camera.position) return false; var Vector3 = viewer.camera.position.constructor; // 索套切边需要的基础工具(多边形枚举 / 投影 / 点到线段最近点) var helpers = makeLaneMethods({ S: S, Vector3: Vector3 }); for (var k in helpers) if (Object.prototype.hasOwnProperty.call(helpers, k)) lh[k] = helpers[k]; var lassoMethods = makeLassoMethods({ S: S, Vector3: Vector3 }); for (var k2 in lassoMethods) if (Object.prototype.hasOwnProperty.call(lassoMethods, k2)) lh[k2] = lassoMethods[k2]; var groundMethods = makeGroundMethods({ S: S, Vector3: Vector3 }); for (var k3 in groundMethods) if (Object.prototype.hasOwnProperty.call(groundMethods, k3)) lh[k3] = groundMethods[k3]; // 索套模式内于捕获阶段拦截鼠标/指针事件,避免平台同时处理或转动视角;未进入模式时直接放行 var lassoTarget = null; var bindLassoDom = function () { if (lassoTarget === lh.domElement || !lh.domElement || !lh.domElement.addEventListener) return; lassoTarget = lh.domElement; var swallow = function (ev) { if (!lh._laneLassoActive) return; if (lh.laneLassoOwnUi(ev.target)) return; // 右键/中键一律放行:平台的相机操作用的是 OrbitControls 默认映射 // (右键拖动平移点云、中键缩放、左键旋转),不能被索套抢走 var isMove = ev.type === "mousemove" || ev.type === "pointermove"; var nonLeft = (typeof ev.button === "number" && ev.button > 0) || (isMove && typeof ev.buttons === "number" && (ev.buttons & 6) !== 0); if (nonLeft) { lh.laneLassoDraw(); return; } ev.stopPropagation(); if (ev.cancelable) ev.preventDefault(); lh.laneLassoHandleDom(ev); }; lh.__laneSwallow = swallow; ["mousedown", "mouseup", "mousemove", "click", "dblclick", "contextmenu", "pointerdown", "pointerup", "pointermove"].forEach(function (t) { lassoTarget.addEventListener(t, swallow, true); }); }; lh.__laneBindLassoDom = bindLassoDom; bindLassoDom(); if (!window.__lassoCutKeyBound) { window.__lassoCutKeyBound = true; window.addEventListener("keydown", function (ev) { lh.laneLassoKey(ev); }, true); } // ---- 标注范围夹取:拖动、画点时取到的坐标统一夹进平台范围(只夹 X/Y,绝不超出黄框) ---- var prevGetMousePosition = (typeof lh.getMousePosition === "function") ? lh.getMousePosition : null; if (prevGetMousePosition) { lh.getMousePosition = function (ndc, forceGround) { var p = prevGetMousePosition.call(lh, ndc, forceGround); try { if (p && lh.laneClampXY) { var c = lh.laneClampXY(p.x, p.y); if (c && c.moved) { p.x = c.x; p.y = c.y; } } } catch (err) { } return p; }; } // ---- 顶点吸附合并:拖动顶点靠近另一个顶点时吸附对齐,松手后合并成一个点 ---- // 注意:three.js 派发事件用的是 listener.call(目标对象, event),监听器里的 this 会是那个球体 // 而不是 LineHandler(平台自己的实现就是因为这个才在构造函数里 bind 过)。 // 所以下面统一用 lh 取对象、并把包装函数 bind 到 lh,任何情况下都不会因为 this 不对而报错。 var prevOnLineDrag = (typeof lh.onLineDrag === "function") ? lh.onLineDrag // 平台构造时 bind 好的原实现 : Object.getPrototypeOf(lh).onLineDrag; // 兜底:原型方法 lh.onLineDrag = function (e) { try { if (!lh.canEdit() || lh.isDeleting) return; var sphere = e && e.drag && e.drag.object; if (sphere && sphere.parent && sphere.parent.markType !== VISUAL_TYPE_VIRTUAL && typeof sphere.parent.getSpheresAndLines === "function") { var rect = lh.domElement.getBoundingClientRect(); var st = e.drag && e.drag.start; var grabPx = (st && typeof st.x === "number") ? { x: (st.x * .5 + .5) * rect.width, y: (-st.y * .5 + .5) * rect.height } : null; var snap = lh.laneSnapTarget(sphere, e.drag && e.drag.end, grabPx); if (snap) { sphere.position.copy(snap.sphere.position); var poly = sphere.parent; lh.updateLinesPosition(poly); try { poly.initInner(); } catch (err) { } try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (err) { } lh.__laneSnap = { poly: poly, sphere: sphere, target: snap.sphere, same: snap.same }; var g = lh.laneEnsureGhost(); if (g) { if (g.material && g.material.color) { g.material.color = (snap.same ? lh._laneColor : lh._laneSnapColor).clone(); } g.position.copy(snap.sphere.position); g.visible = true; } lh.viewer.rendererMain(); return; } } } catch (err) { console.warn("[snap-merge] drag", err); } try { lh.__laneSnap = null; lh.laneClearGhost(); } catch (err) { } return prevOnLineDrag.call(lh, e); }.bind(lh); // 已有多边形重新注册拾取事件(平台注册时读取 this.onLineDrag,包装后才生效); // 先把旧的拖动监听摘掉,避免同一次拖动被处理两次 var reregister = function (scene) { if (!scene || !scene.children) return; scene.children.slice().forEach(function (o) { if (!o || typeof o.getSpheresAndLines !== "function") return; try { o.children.forEach(function (ch) { if (ch && ch.removeEventListener) { ch.removeEventListener("drag", prevOnLineDrag); ch.removeEventListener("drag", lh.onLineDrag); } }); } catch (e) { } try { lh.addPickEvents(o); } catch (e) { } }); }; reregister(viewer.lineScene); reregister(viewer.annotationGroup); lh.domElement.addEventListener("mouseup", function (e) { lh.onLaneMergeMouseup(e); }, false); lh.__laneInstalled = true; console.log("[lasso-cut] 已注入:按 M 进入索套切边(左键打点,Q 闭合切边)"); } // 画布容器被重建时重新挂监听(视图切换/重新挂载的情况) if (lh.__laneDom !== lh.domElement && lh.domElement && lh.domElement.addEventListener) { lh.__laneDom = lh.domElement; if (lh.__laneBindLassoDom) lh.__laneBindLassoDom(); } if (lh.laneLassoButtonUpdate) { try { lh.laneLassoButtonUpdate(); } catch (e) { } } if (lh.laneMountGroundButton) { try { lh.laneMountGroundButton(); } catch (e) { } } return true; } W.__lassoCut = { install: install, findViewer: findViewer, makeLaneMethods: makeLaneMethods, makeLassoMethods: makeLassoMethods, makeGroundMethods: makeGroundMethods, version: "2.0.0" }; // 点云页面是懒加载的:轮询等待 viewer / StoreManager 就绪;注入成功后转低频巡检, // 以便视图重新挂载、容器被替换时能自动补挂监听。 if (!document.querySelector("#app")) return; // 非平台页面,直接退出 var tries = 0, slow = false, timer = null; var tick = function () { tries++; var done = false; try { done = install(findViewer()); } catch (e) { console.warn("[lasso-cut]", e); } if (done && !slow) { // 注入成功:降频巡检 slow = true; clearInterval(timer); timer = setInterval(tick, 3000); } else if (!done && tries > 600) { // 5 分钟仍未就绪,放弃 clearInterval(timer); } }; timer = setInterval(tick, 500); W.addEventListener("load", function () { install(findViewer()); }); })(); // ================================================================ // 二、RGB 点云着色(静默版 v2.2.0,原样并入) // ================================================================ (function () { 'use strict'; /* ================================================================ * 0. 参数(无 UI 开关;颜色一律按原始值写入,不做任何增强) * ================================================================ */ const CFG = { autoFollow: true, // 自动跟随帧变化 keepRgbMode: true, // 自动切到官方「rgb着色」并保持(用户手动改后不再抢) prefetchNext: true, // 空闲时预热下一帧的相机图,切帧近乎瞬时(不用可设 false) pollMs: 80, imgCacheMax: 24, pcdCacheMax: 2, imgProxy: '', // 图片跨域读不了时可填代理前缀,例如 'http://127.0.0.1:8000/?url=' }; const LOG = '[平台RGB着色]'; const log = (...a) => console.log(LOG, ...a); /* ================================================================ * 1. 平台对象(均按平台自身实现核对过) * ================================================================ */ const getInstance = () => window.instance || null; const getSM = () => { const i = getInstance(); return (i && i.StoreManager) || null; }; const getViewer = () => { const i = getInstance(); return window.viewer || (i && i.viewer) || null; }; function sensorParams() { const SM = getSM(); if (!SM) return null; try { const sp = SM.configState().params.sensor_params; return sp && typeof sp === 'object' ? sp : null; } catch (e) { return null; } } function frameById(id) { const SM = getSM(); if (!SM) return null; try { const g = SM.configGetter('frame'); if (!g) return null; return (id !== null && id !== undefined ? g(id) : g()) || null; } catch (e) { return null; } } function poseById(id) { const SM = getSM(); if (!SM) return null; try { const g = SM.configGetter('getPose'); if (!g) return null; const p = g(id); return (p && p.length >= 16) ? p : null; } catch (e) { return null; } } function imageUrlOf(frame, key) { try { const arr = frame && frame.slave_info && frame.slave_info[key]; const first = Array.isArray(arr) ? arr[0] : arr; return (first && (first.url || first.src)) || null; } catch (e) { return null; } } /* ================================================================ * 2. 4x4 工具(行/列主序都支持,用于坐标系判定) * ================================================================ */ const colMajorTo4x4 = (el) => [ [el[0], el[4], el[8], el[12]], [el[1], el[5], el[9], el[13]], [el[2], el[6], el[10], el[14]], [el[3], el[7], el[11], el[15]], ]; const rowMajorTo4x4 = (el) => [ [el[0], el[1], el[2], el[3]], [el[4], el[5], el[6], el[7]], [el[8], el[9], el[10], el[11]], [el[12], el[13], el[14], el[15]], ]; function matMul(A, B) { const C = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]; for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { let s = 0; for (let k = 0; k < 4; k++) s += A[r][k] * B[k][c]; C[r][c] = s; } } return C; } function matInv(M) { const a = []; for (let r = 0; r < 4; r++) a.push(M[r].slice().concat([r === 0 ? 1 : 0, r === 1 ? 1 : 0, r === 2 ? 1 : 0, r === 3 ? 1 : 0])); for (let c = 0; c < 4; c++) { let piv = c; for (let r = c + 1; r < 4; r++) if (Math.abs(a[r][c]) > Math.abs(a[piv][c])) piv = r; if (Math.abs(a[piv][c]) < 1e-12) return null; const t = a[c]; a[c] = a[piv]; a[piv] = t; const d = a[c][c]; for (let k = 0; k < 8; k++) a[c][k] /= d; for (let r = 0; r < 4; r++) { if (r === c) continue; const f = a[r][c]; if (!f) continue; for (let k = 0; k < 8; k++) a[r][k] -= f * a[c][k]; } } return [ [a[0][4], a[0][5], a[0][6], a[0][7]], [a[1][4], a[1][5], a[1][6], a[1][7]], [a[2][4], a[2][5], a[2][6], a[2][7]], [a[3][4], a[3][5], a[3][6], a[3][7]], ]; } /* ================================================================ * 3. 相机图像(缓存 / 跨域降级 / 并发去重) * ================================================================ */ const imgCache = new Map(); const imgPending = new Map(); const imgFailed = new Map(); function decodeImage(url, useCors) { return new Promise((resolve, reject) => { const img = new Image(); if (useCors) img.crossOrigin = 'anonymous'; img.onload = () => { try { const w = img.naturalWidth || img.width; const h = img.naturalHeight || img.height; const cv = document.createElement('canvas'); cv.width = w; cv.height = h; const ctx = cv.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0); resolve({ data: ctx.getImageData(0, 0, w, h).data, width: w, height: h }); } catch (e) { reject(new Error('画布跨域污染,无法读取像素')); } }; img.onerror = () => reject(new Error('图片加载失败')); img.src = url; }); } async function fetchDecode(url) { const res = await fetch(url, { mode: 'cors', credentials: 'same-origin' }); if (!res.ok) throw new Error('HTTP ' + res.status); const bmp = await createImageBitmap(await res.blob()); const cv = document.createElement('canvas'); cv.width = bmp.width; cv.height = bmp.height; const ctx = cv.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bmp, 0, 0); const d = ctx.getImageData(0, 0, bmp.width, bmp.height).data; if (bmp.close) bmp.close(); return { data: d, width: bmp.width, height: bmp.height }; } /** * 取图策略按“来源”记忆:同源走 (能和平台共用同一份浏览器缓存与已解码位图); * 跨域必须先走 crossOrigin,否则会先下载一遍拿不到像素的图,再为 CORS 重下一遍。 * 第一次成功的方式会被记下来,后续同来源直接用,避免任何重复下载。 */ const originStrategy = new Map(); function originOf(url) { try { return new URL(url, location.href).origin; } catch (e) { return 'unknown'; } } function strategyOrder(url) { const known = originStrategy.get(originOf(url)); const base = originOf(url) === location.origin ? ['plain', 'fetch', 'cors'] : ['cors', 'fetch', 'plain']; if (!known) return base; return [known].concat(base.filter((x) => x !== known)); } const STRAT = { plain: (u) => decodeImage(u, false), cors: (u) => decodeImage(u, true), fetch: fetchDecode, }; async function loadImage(rawUrl) { if (imgCache.has(rawUrl)) return imgCache.get(rawUrl); if (imgPending.has(rawUrl)) return imgPending.get(rawUrl); if (imgFailed.has(rawUrl)) return null; const p = (async () => { const urls = CFG.imgProxy ? [CFG.imgProxy + encodeURIComponent(rawUrl), rawUrl] : [rawUrl]; const errs = []; for (const u of urls) { for (const name of strategyOrder(u)) { try { const r = await STRAT[name](u); originStrategy.set(originOf(u), name); // 记住这条来源可行的方式 return r; } catch (e) { errs.push(name + ': ' + e.message); } } } imgFailed.set(rawUrl, errs.join(' | ')); return null; })(); imgPending.set(rawUrl, p); const r = await p; imgPending.delete(rawUrl); if (r) { imgCache.set(rawUrl, r); if (imgCache.size > CFG.imgCacheMax) imgCache.delete(imgCache.keys().next().value); } return r; } /* ================================================================ * 4. PCD 直读 * 平台 loader 只认字段名 rgb;static_map.pcd 是 rgba,所以平台点不亮 rgb着色 * 这里把文件里的 rgb / rgba 直接解出来(支持 ascii / binary / binary_compressed) * ================================================================ */ const pcdCache = new Map(); function lzfDecompress(input, outLen) { const out = new Uint8Array(outLen); let i = 0, o = 0; while (i < input.length) { let ctrl = input[i++]; if (ctrl < 32) { ctrl++; if (o + ctrl > outLen) break; out.set(input.subarray(i, i + ctrl), o); i += ctrl; o += ctrl; } else { let len = ctrl >> 5; let ref = o - ((ctrl & 0x1f) << 8) - 1; if (len === 7) len += input[i++]; ref -= input[i++]; if (ref < 0) break; for (let k = 0; k <= len + 1; k++) { if (o >= outLen) break; out[o++] = out[ref++]; } } } return out; } function parsePcdHeader(bytes) { const headLen = Math.min(bytes.length, 8192); let text = ''; for (let i = 0; i < headLen; i++) text += String.fromCharCode(bytes[i]); // 逐行扫描:必须跳过 # 注释行,否则 // "# .PCD v0.7 - Point Cloud Data file format" 会被误当成 DATA 行 const map = {}; let dataType = null, dataOffset = -1, i = 0; while (i < text.length) { let end = text.indexOf('\n', i); if (end < 0) end = text.length; let line = text.slice(i, end); const next = end + 1; if (line.endsWith('\r')) line = line.slice(0, -1); const t = line.trim(); if (t && t.charAt(0) !== '#') { const parts = t.split(/\s+/); const key = parts[0].toUpperCase(); if (key === 'DATA') { dataType = (parts[1] || '').toLowerCase(); dataOffset = next; // 头部为纯 ASCII,字符偏移 == 字节偏移 break; } map[key] = parts.slice(1); } i = next; } if (!dataType || dataOffset < 0) return null; const fields = map.FIELDS || []; const sizes = (map.SIZE || []).map(Number); const types = map.TYPE || []; const counts = (map.COUNT || []).map(Number); const points = parseInt((map.POINTS && map.POINTS[0]) || '0', 10); if (!fields.length || !points) return null; const cnt = counts.length ? counts : fields.map(() => 1); let rowSize = 0; const offset = {}; for (let k = 0; k < fields.length; k++) { offset[fields[k]] = rowSize; rowSize += (sizes[k] || 1) * (cnt[k] || 1); } return { fields, sizes, types, counts: cnt, points, dataType, dataOffset, rowSize, offset }; } /** 取出数据段(自动处理 binary_compressed 的 LZF + SOA 布局) */ function dataOf(bytes, hdr) { if (hdr.dataType === 'binary_compressed') { const dv = new DataView(bytes.buffer, bytes.byteOffset + hdr.dataOffset); const compSize = dv.getUint32(0, true); const uncompSize = dv.getUint32(4, true); const comp = new Uint8Array(bytes.buffer, bytes.byteOffset + hdr.dataOffset + 8, compSize); return { data: lzfDecompress(comp, uncompSize), soa: true }; } if (hdr.dataType === 'binary') { return { data: new Uint8Array(bytes.buffer, bytes.byteOffset + hdr.dataOffset), soa: false }; } return null; // ascii 单独处理 } /** 解出 rgb / rgba 颜色(R 在 +2 字节、G +1、B +0,与平台 loader 的读法一致) */ function extractColors(bytes, hdr) { let ci = hdr.fields.indexOf('rgb'); if (ci < 0) ci = hdr.fields.indexOf('rgba'); if (ci < 0) return null; const fieldName = hdr.fields[ci]; if ((hdr.sizes[ci] || 0) !== 4) return null; const n = hdr.points; const out = new Float32Array(n * 3); const pack = dataOf(bytes, hdr); if (pack) { const { data, soa } = pack; const co = hdr.offset[fieldName]; const stride = hdr.sizes[ci] || 4; for (let i = 0; i < n; i++) { const base = soa ? (co * n + i * stride) : (i * hdr.rowSize + co); const j = i * 3; out[j] = data[base + 2] / 255; out[j + 1] = data[base + 1] / 255; out[j + 2] = data[base] / 255; } } else if (hdr.dataType === 'ascii') { const txt = new TextDecoder().decode(bytes.subarray(hdr.dataOffset)); const lines = txt.split(/\r?\n/); const isF = hdr.types[ci] === 'F'; for (let i = 0; i < n; i++) { const parts = (lines[i] || '').trim().split(/\s+/); if (parts.length <= ci) break; const raw = parseFloat(parts[ci]); let v; if (isF) { const f = new Float32Array(1); f[0] = raw; v = new Uint32Array(f.buffer)[0]; } else v = raw >>> 0; const j = i * 3; out[j] = ((v >> 16) & 255) / 255; out[j + 1] = ((v >> 8) & 255) / 255; out[j + 2] = (v & 255) / 255; } } else { return null; } return { colors: out, fieldName, points: n }; } /** 校验 pcd 与当前 geometry 是否同一份(点数 + 若干点 xyz 对齐) */ function verifySameCloud(bytes, hdr, posArr, count) { if (hdr.points !== count) return { same: false, why: '点数不一致 pcd=' + hdr.points + ' 几何=' + count }; const pack = dataOf(bytes, hdr); if (!pack) return { same: true, why: 'ascii 未校验' }; const { data, soa } = pack; const dv = new DataView(data.buffer, data.byteOffset, data.byteLength); const samples = [0, 1, 2, 100, 1000].filter((i) => i < count); for (const i of samples) { for (const pair of [['x', 0], ['y', 1], ['z', 2]]) { const f = pair[0], k = pair[1]; const off = hdr.offset[f]; if (off === undefined) continue; const at = soa ? (off * count + i * 4) : (i * hdr.rowSize + off); if (at + 4 > dv.byteLength) continue; const val = dv.getFloat32(at, true); const geo = posArr[i * 3 + k]; if (!isFinite(val) || !isFinite(geo)) continue; if (Math.abs(val - geo) > 1e-3) { return { same: false, why: '坐标不对齐(第' + i + '点 ' + f + ': pcd=' + val + ' 几何=' + geo + ')' }; } } } return { same: true, why: 'ok' }; } /** 只取 pcd 头部(8KB)判断有没有 rgb/rgba 字段 —— 避免为探明字段名而下整个文件 */ const probeCache = new Map(); const dirNoColor = new Set(); // 同一批数据的其他帧同样没有颜色字段,可直接跳过探测 const dirOf = (u) => { const i = u.lastIndexOf('/'); return i > 0 ? u.slice(0, i) : u; }; async function probePcdHeader(url) { if (probeCache.has(url)) return probeCache.get(url); let res = null; try { res = await fetch(url, { headers: { Range: 'bytes=0-8191' }, credentials: 'same-origin', }); if (!res.ok && res.status !== 206) throw new Error('HTTP ' + res.status); let bytes = null; if (res.status === 206) { bytes = new Uint8Array(await res.arrayBuffer()); } else if (res.body && typeof res.body.getReader === 'function') { // 服务器忽略了 Range:读第一块就中止,不把整个文件拖下来 const reader = res.body.getReader(); const chunks = []; let total = 0; while (total < 8192) { const r = await reader.read(); if (r.done) break; chunks.push(r.value); total += r.value.length; } try { await reader.cancel(); } catch (e) {} bytes = new Uint8Array(total); let o = 0; for (const c of chunks) { bytes.set(c, o); o += c.length; } } else { bytes = new Uint8Array(await res.arrayBuffer()); } const hdr = parsePcdHeader(bytes); const rec = hdr ? { hdr } : null; probeCache.set(url, rec); if (probeCache.size > 200) probeCache.delete(probeCache.keys().next().value); return rec; } catch (e) { probeCache.set(url, null); return null; } } async function readPcdFile(url) { if (!url) return null; if (pcdCache.has(url)) return pcdCache.get(url); const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) throw new Error('pcd HTTP ' + res.status); const bytes = new Uint8Array(await res.arrayBuffer()); const hdr = parsePcdHeader(bytes); if (!hdr) throw new Error('pcd 头解析失败(可能不是 PCD 或头部超长)'); const rec = { bytes, hdr }; pcdCache.set(url, rec); if (pcdCache.size > CFG.pcdCacheMax) pcdCache.delete(pcdCache.keys().next().value); return rec; } /* ================================================================ * 5. 投影(与平台 computePointWithoutBehindFilter 同一套公式) * ================================================================ */ function project(x, y, z, cam) { const e = cam.extrinsic; if (!e || e.length < 3) return null; const xc = e[0][0] * x + e[0][1] * y + e[0][2] * z + e[0][3]; const yc = e[1][0] * x + e[1][1] * y + e[1][2] * z + e[1][3]; const zc = e[2][0] * x + e[2][1] * y + e[2][2] * z + e[2][3]; if (!(zc > 0.01)) return null; let u = xc / zc, v = yc / zc; const k1 = cam.k1 || 0, k2 = cam.k2 || 0, k3 = cam.k3 || 0; const p1 = cam.p1 || 0, p2 = cam.p2 || 0; if (k1 || k2 || k3 || p1 || p2) { const r2 = u * u + v * v; const radial = 1 + k1 * r2 + k2 * r2 * r2 + k3 * r2 * r2 * r2; const tx = 2 * p1 * u * v + p2 * (r2 + 2 * u * u); const ty = p1 * (r2 + 2 * v * v) + 2 * p2 * u * v; u = u * radial + tx; v = v * radial + ty; } return [u * cam.fx + cam.cx, v * cam.fy + cam.cy, zc]; } /** 本帧可用的相机任务(只列参数与图 URL,不触发加载) */ function cameraJobs(sp, frame) { const jobs = []; for (const key of Object.keys(sp)) { const c = sp[key]; if (!c || c.fx === null || c.fx === undefined || !c.extrinsic) continue; const model = c.camera_model || 'pinhole'; if (model !== 'pinhole' && model !== 'pinhole1') continue; const url = imageUrlOf(frame, key); if (!url) continue; jobs.push({ key, cam: c, url }); } return jobs; } async function loadCameraImages(sp, frame) { const jobs = cameraJobs(sp, frame); const imgs = await Promise.all(jobs.map((jb) => loadImage(jb.url))); const out = []; for (let i = 0; i < jobs.length; i++) { if (!imgs[i]) continue; out.push({ key: jobs[i].key, cam: jobs[i].cam, data: imgs[i].data, width: imgs[i].width, height: imgs[i].height }); } return out; } /** 单台相机:只写"比已知更近"的点 —— 颜色随图像到达逐块出现,不必等 4 张全齐 */ function projectCamera(cam, img, ci, st) { const { rg, pos, bestZ, bestU, bestCi, buf } = st; const w = img.width, h = img.height, data = img.data; for (let i = rg.start; i < rg.end; i++) { const j = i * 3; const pr = project(pos[j], pos[j + 1], pos[j + 2], cam); if (!pr) continue; const px = pr[0], py = pr[1], zc = pr[2]; if (px < 0 || px >= w || py < 0 || py >= h) continue; if (zc >= bestZ[i]) continue; bestZ[i] = zc; bestU[i] = px; bestCi[i] = ci; const idx = ((py | 0) * w + (px | 0)) * 4; buf[j] = data[idx] / 255; buf[j + 1] = data[idx + 1] / 255; buf[j + 2] = data[idx + 2] / 255; } } /** 收尾统计:按"最终获胜相机"精确统计命中/亮度/像素离散 */ function finishStats(st) { const { rg, buf, bestZ, bestU, bestCi, camStat } = st; let hit = 0, lumSum = 0, darkN = 0, su = 0, su2 = 0, nPix = 0; for (let i = rg.start; i < rg.end; i++) { if (!(bestZ[i] < Infinity)) continue; const j = i * 3; const r = buf[j], g = buf[j + 1], b = buf[j + 2]; const lum = 0.299 * r + 0.587 * g + 0.114 * b; hit++; lumSum += lum; if (lum < 0.02) darkN++; su += bestU[i]; su2 += bestU[i] * bestU[i]; nPix++; const ci = bestCi[i]; if (ci >= 0 && camStat[ci]) { camStat[ci].hit++; camStat[ci].lum += lum; } } const meanLum = hit ? lumSum / hit : 0; const stdU = nPix ? Math.sqrt(Math.max(0, su2 / nPix - (su / nPix) ** 2)) : 0; return { hit, meanLum, darkN, spread: stdU / (st.imgW || 768) }; } const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()); /** 合并多次渲染请求到一帧动画里,避免重复 rendererMain */ let renderPending = false; function scheduleRender() { if (renderPending) return; renderPending = true; const doIt = () => { renderPending = false; const v = getViewer(); if (!v) return; try { v.rendererMain(); } catch (e) {} }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(doIt); else setTimeout(doIt, 16); } /** 采样一批点,统计"至少落在一台相机画面内"的比例 */ function sampleCoverage(pos, count, cams) { if (!cams.length) return 0; const step = Math.max(1, Math.floor(count / 4000)); let tried = 0, hit = 0; for (let i = 0; i < count; i += step) { const j = i * 3; const x = pos[j], y = pos[j + 1], z = pos[j + 2]; tried++; for (let ci = 0; ci < cams.length; ci++) { const c = cams[ci]; const pr = project(x, y, z, c.cam); if (!pr) continue; if (pr[0] >= 0 && pr[0] < c.width && pr[1] >= 0 && pr[1] < c.height) { hit++; break; } } } return tried ? hit / tried : 0; } /* ================================================================ * 6. 着色核心 * ================================================================ */ const ownColor = new WeakSet(); const busy = new WeakSet(); const sigOf = new WeakMap(); const _ids = new WeakMap(); let _idSeq = 0; function arrayId(arr) { if (!_ids.has(arr)) _ids.set(arr, ++_idSeq); return _ids.get(arr); } function signatureOf(p) { const g = p.geometry; if (!g || !g.attributes || !g.attributes.position) return null; const a = g.attributes.position; return (p.taskId === undefined ? '-' : String(p.taskId)) + '|' + a.count + '|' + (a.array ? arrayId(a.array) : ''); } function buildRanges(p, count) { const v = getViewer(), SM = getSM(); let cur = null; try { cur = SM.getCurFrameId(); } catch (e) {} const table = v && v.cloudData && v.cloudData.comparisonTable; const isMerged = (p.multiFrameIds && p.multiFrameIds.length) || (v && v.splicePCD && p === v.splicePCD) || p.name === 'split_points'; if (isMerged && Array.isArray(table) && table.length) { return table.map((t) => ({ start: t.Indexes.start, end: t.Indexes.end + 1, frameId: t.pcdInfo })); } return [{ start: 0, end: count, frameId: (p.taskId !== null && p.taskId !== undefined) ? p.taskId : cur }]; } /** * 坐标系统判定:同一批点分别按三种假设投影,谁落进画面的比例高谁对。 * H1 点云在本帧雷达系:p_cam = T_v2c * p * H2 点云在世界系,位姿按平台读法(列主序):p_cam = T_v2c * inv(pose) * p * H3 点云在世界系,位姿按 JSON 行主序: p_cam = T_v2c * inv(pose) * p * 返回按命中率降序的假设列表(带合成后的 extrinsic)。 */ function detectFrames(baseCam, pose, pos, count, cams) { const covOf = (ext) => { const cs = cams.map((c) => ({ cam: Object.assign({}, c.cam, { extrinsic: ext }), width: c.width, height: c.height, })); return sampleCoverage(pos, count, cs); }; const list = [{ name: '本帧雷达系', ext: baseCam.extrinsic, cov: covOf(baseCam.extrinsic) }]; if (pose) { const invA = matInv(colMajorTo4x4(pose)); if (invA) { const extA = matMul(baseCam.extrinsic, invA); list.push({ name: '世界系(位姿列主序)', ext: extA, cov: covOf(extA) }); } const invB = matInv(rowMajorTo4x4(pose)); if (invB) { const extB = matMul(baseCam.extrinsic, invB); list.push({ name: '世界系(位姿行主序)', ext: extB, cov: covOf(extB) }); } } list.sort((a, b) => b.cov - a.cov); return list; } async function colorizePoints(p) { const v = getViewer(), SM = getSM(); if (!v || !SM) return { ok: false, msg: '平台对象未就绪' }; const geom = p.geometry; if (!geom || !geom.attributes || !geom.attributes.position) return { ok: false, msg: '点云未加载' }; const count = geom.attributes.position.count; // 地图任务里除首帧外都是 0 点占位 pcd(上传脚本 create_empty_pcd 生成),不是错误 if (!count) return { ok: true, empty: true, msg: '本帧为空点云(占位帧),无需着色' }; // ① 平台原生已支持(文件字段名就是 rgb)→ 什么都不用做 if (geom.attributes.color && !ownColor.has(geom)) { v.supportRgbColor = true; return { ok: true, native: true, msg: '数据自带 rgb,平台原生支持' }; } const ranges = buildRanges(p, count); const buf = new Float32Array(count * 3); const pos = geom.attributes.position.array; const t0 = nowMs(); let filled = 0; let src = null, note = null; // 每个点的最近相机深度/像素/归属,供"逐相机到达即上色"用 const bestZ = new Float32Array(count).fill(Infinity); const bestU = new Float32Array(count); const bestCi = new Int8Array(count).fill(-1); // 颜色属性先挂上去,这样每台相机图像一到就能立刻看到效果 let attr = geom.attributes.color; if (!attr || attr.count !== count || !attr.array || attr.array.length !== count * 3) { const Ctor = geom.attributes.position.constructor; attr = new Ctor(buf, 3); geom.setAttribute('color', attr); } else { attr.array.set(buf); attr.needsUpdate = true; } const flush = () => { attr.needsUpdate = true; scheduleRender(); }; for (const rg of ranges) { const frame = frameById(rg.frameId); const sp = sensorParams(); const pcdUrl = p.url || (frame && frame.url) || null; // ②+③ 并行:一边探 pcd 头(8KB)判断有没有 rgb/rgba,一边同时开始下 4 张相机图 // 同一批数据的后续帧字段布局相同,已知没有颜色字段就直接跳过这次探测(省一个来回) // 但若几何里带 intensity,说明文件字段不止 xyz,必须真探测(地图就是 x y z intensity rgba label) const canSkipProbe = pcdUrl && !geom.attributes.intensity && dirNoColor.has(dirOf(pcdUrl)); const [probe] = await Promise.all([ pcdUrl ? (canSkipProbe ? Promise.resolve({ hdr: { fields: [] } }) : probePcdHeader(pcdUrl)) : Promise.resolve(null), sp ? loadCameraImages(sp, frame) : Promise.resolve([]), ]); // ② 文件自带 rgb/rgba → 就地解出(地图那种 rgba 就走这里) const hasColorField = !!(probe && probe.hdr && (probe.hdr.fields.indexOf('rgb') >= 0 || probe.hdr.fields.indexOf('rgba') >= 0)); if (probe && probe.hdr && !hasColorField && pcdUrl && probe.hdr.fields.length) { dirNoColor.add(dirOf(pcdUrl)); } if (hasColorField) { try { const rec = await readPcdFile(pcdUrl); // 确认有颜色字段才下整个文件 const chk = rec ? verifySameCloud(rec.bytes, rec.hdr, pos, count) : null; if (rec && chk && !chk.same) { note = 'pcd 与几何校验未通过:' + chk.why; } else if (rec) { const got = extractColors(rec.bytes, rec.hdr); if (got) { for (let i = rg.start; i < rg.end; i++) { const j = i * 3; buf[j] = got.colors[j]; buf[j + 1] = got.colors[j + 1]; buf[j + 2] = got.colors[j + 2]; } filled += (rg.end - rg.start); src = '文件字段 ' + got.fieldName + '(平台不认,已就地解出)'; attr.array.set(buf); flush(); continue; } } } catch (e) { note = 'pcd 读取失败:' + e.message; } } // ③ 相机投影着色:平台约定点云在本帧传感器系(poseMatrix 只用于显示/叠帧换算),直接用 T_v2c if (!sp) { note = note || '缺少标定参数 sensor_params'; continue; } const jobs = cameraJobs(sp, frame); if (!jobs.length) { note = note || ('本帧(' + rg.frameId + ')无可用相机图像(跨域或字段缺失)'); continue; } const camStat = jobs.map((c) => ({ key: c.key, size: '待加载', hit: 0, lum: 0 })); const st = { bestZ, bestU, bestCi, buf, rg, pos, camStat, imgW: 0, t0, firstMs: undefined }; // 每台相机图像一到就先给"比已知更近"的点上色,不再等 4 张全齐 let arrived = 0; await Promise.all(jobs.map(async (jb, ci) => { const img = await loadImage(jb.url); if (!img) return; camStat[ci].size = img.width + 'x' + img.height; if (img.width > st.imgW) st.imgW = img.width; projectCamera(jb.cam, img, ci, st); flush(); arrived++; if (st.firstMs === undefined) st.firstMs = Math.round(nowMs() - t0); if (arrived < jobs.length) setStatus('着色中… ' + arrived + '/' + jobs.length + ' 相机'); })); const fin = finishStats(st); const hit = fin.hit, meanLum = fin.meanLum, spread = fin.spread; window.__xjRgbLastStat = { 帧: rg.frameId, 点数: count, 命中: hit, 覆盖率: (hit / count * 100).toFixed(1) + '%', 平均亮度: meanLum.toFixed(3), 很暗点占比: hit ? (fin.darkN / hit * 100).toFixed(1) + '%' : '-', 像素横向离散: spread.toFixed(3), 首图上色耗时: (st.firstMs === undefined ? '-' : st.firstMs + 'ms'), 本帧总耗时: Math.round(nowMs() - t0) + 'ms', 各相机: camStat.map((c) => c.key + ':命中' + c.hit + ',亮度' + (c.hit ? (c.lum / c.hit).toFixed(3) : '-') + ',' + c.size), }; log('本帧统计', window.__xjRgbLastStat); // 退化/异常保护:像素挤在一条窄带里或采样明显发暗,说明投影关系不对, // 这时候切到 rgb 着色只会显示一片异常颜色,宁可不切 if (hit && spread < 0.05) { buf.fill(0); attr.array.set(buf); flush(); return { ok: false, msg: '投影退化(像素离散' + spread.toFixed(3) + '):点云坐标系可能与标定不一致,已放弃切到rgb着色' }; } if (hit > 100 && meanLum < 0.03) { buf.fill(0); attr.array.set(buf); flush(); return { ok: false, msg: '采样颜色异常发暗(平均亮度' + meanLum.toFixed(3) + '):相机图或标定可疑,已放弃切到rgb着色' }; } filled += hit; src = src || ('相机投影[命中 ' + (hit / count * 100).toFixed(0) + '% 亮度' + meanLum.toFixed(2) + ' 离散' + spread.toFixed(2) + ']'); if (!hit && !note) note = '投影未命中任何相机画面'; } if (!filled) return { ok: false, msg: note || '未能着色' }; // 颜色属性在前面已经建好并挂上去了,这里只做最终提交 ownColor.add(geom); attr.array.set(buf); attr.needsUpdate = true; // ★ 让平台自己认可"这份点云支持 rgb 着色",于是官方「rgb着色」就能勾选 v.supportRgbColor = true; if (CFG.keepRgbMode && !userOverride) setColorType(3); try { v.rendererMain(); } catch (e) {} const pct = ((filled / count) * 100).toFixed(1); return { ok: true, msg: filled + '/' + count + ' 点(' + pct + '%) · ' + src }; } /* ================================================================ * 7. 与平台着色模式联动(不抢用户手动选择) * ================================================================ */ let userOverride = false; let lastSeenColorType = null; let originalColorType = null; function setColorType(n) { const v = getViewer(), SM = getSM(); if (!v || !SM) return; try { SM.updateStatusProperty('colorType', n); v.setPCDColorModel(n, SM.statusState().colorValue); v.rendererMain(); } catch (e) {} } function watchColorType(sawNew) { const SM = getSM(); if (!SM) return; let cur = null; try { cur = SM.statusState().colorType; } catch (e) { return; } if (lastSeenColorType !== null && cur !== lastSeenColorType && cur !== 3 && !sawNew) userOverride = true; lastSeenColorType = cur; } /* ================================================================ * 8. 叠帧保险 + 主循环 * ================================================================ */ function patchSplice(v) { if (!v || v.__xjSplicePatched || typeof v.splicePointCloud !== 'function') return; const orig = v.splicePointCloud; v.splicePointCloud = function (points) { try { const g = points && points.geometry; if (g && g.attributes && g.attributes.position && !g.attributes.color) { const Ctor = g.attributes.position.constructor; g.setAttribute('color', new Ctor(new Float32Array(g.attributes.position.count * 3), 3)); ownColor.add(g); } } catch (e) {} return orig.apply(this, arguments); }; v.__xjSplicePatched = true; } function collectTargets() { const v = getViewer(); if (!v) return []; const out = [], seen = new Set(); const push = (p) => { if (!p || !p.geometry || !p.geometry.attributes || !p.geometry.attributes.position) return; if (seen.has(p)) return; seen.add(p); out.push(p); }; push(v.points); for (const g of [v.pointCloudGroup, v.compareGroup, v.sseGroup]) { if (g && Array.isArray(g.children)) g.children.forEach(push); } return out; } let ticking = false; async function tick() { if (ticking) return; ticking = true; try { // 帧号一变就立刻把"图和 pcd 头"先拉起来,与平台自己加载点云的过程重叠, // 等点云一出现基本只剩投影的十几毫秒 const SM0 = getSM(); if (SM0) { let fid = null; try { fid = SM0.getCurFrameId(); } catch (e) {} warmFrame(fid); } patchSplice(getViewer()); const targets = collectTargets(); let sawNew = false; for (const p of targets) { const sig = signatureOf(p); if (!sig || sigOf.get(p) === sig || busy.has(p)) continue; sawNew = true; busy.add(p); try { const r = await colorizePoints(p); if (r && r.ok) { sigOf.set(p, sig); setStatus(r.msg, false, r.native); schedulePrefetch(); } else if (r && r.msg) setStatus(r.msg, true); } catch (e) { setStatus('异常: ' + e.message, true); log('colorize 异常', e); } finally { busy.delete(p); } } watchColorType(sawNew); refreshModeText(); } finally { ticking = false; } } function restore() { const v = getViewer(); if (!v) return; let n = 0; for (const p of collectTargets()) { const g = p.geometry; if (!g || !ownColor.has(g)) continue; try { if (g.attributes.color) { g.deleteAttribute('color'); n++; } } catch (e) {} sigOf.delete(p); } v.supportRgbColor = false; setColorType(originalColorType !== null && originalColorType !== 3 ? originalColorType : 1); try { v.rendererMain(); } catch (e) {} setStatus('已还原(' + n + ' 个点云)', false); } async function diagnose() { const v = getViewer(), SM = getSM(); const out = {}; try { const g = v && v.points && v.points.geometry; out.点云已加载 = !!(g && g.attributes && g.attributes.position); if (out.点云已加载) { out.点数 = g.attributes.position.count; out.几何属性 = Object.keys(g.attributes); out.平台认为支持rgb = !!v.supportRgbColor; out.平台认为支持强度 = !!v.supportIntensityColor; } } catch (e) { out._err = String(e); } try { out.当前着色模式 = SM.statusState().colorType; } catch (e) {} try { const cfg = SM.configState(); out.data_type = cfg.params && cfg.params.data_type; out.相机 = Object.keys((cfg.params && cfg.params.sensor_params) || {}); out.有poses = !!(cfg.params && cfg.params.poses && Object.keys(cfg.params.poses).length); } catch (e) {} try { const p = v.points; const url = (p && p.url) || ((frameById(null) || {}).url); out.本帧pcd = url; if (url) { const rec = await readPcdFile(url); if (rec) { out.pcd字段 = rec.hdr.fields; out.pcd类型 = rec.hdr.types; out.pcd编码 = rec.hdr.dataType; out.pcd点数 = rec.hdr.points; out.有rgb字段 = rec.hdr.fields.indexOf('rgb') >= 0; out.有rgba字段 = rec.hdr.fields.indexOf('rgba') >= 0; if (p && p.geometry) { out.pcd与几何一致 = verifySameCloud(rec.bytes, rec.hdr, p.geometry.attributes.position.array, p.geometry.attributes.position.count); } } } } catch (e) { out._err_pcd = String(e); } try { out.本帧位姿 = poseById(SM.getCurFrameId()) ? '有' : '无'; out.本帧着色统计 = window.__xjRgbLastStat || null; } catch (e) {} const camArr = []; const cams = {}; const sp = sensorParams(), frame = frameById(null); if (sp) { for (const k of Object.keys(sp)) { const u = imageUrlOf(frame, k); const rec = { 标定: !!(sp[k] && sp[k].fx !== undefined), 图像: u || null }; if (u) { const im = await loadImage(u); rec.可读像素 = !!im; if (im) { rec.尺寸 = im.width + 'x' + im.height; camArr.push({ key: k, cam: sp[k], data: im.data, width: im.width, height: im.height }); } else rec.失败原因 = imgFailed.get(u) || '未知'; } cams[k] = rec; } } out.相机明细 = cams; // 参考:把点云按"本帧雷达系 / 世界系(位姿列主序) / 世界系(位姿行主序)"三种假设各投一遍 try { const p = v.points; if (p && p.geometry && p.geometry.attributes.position && camArr.length) { const pos = p.geometry.attributes.position.array; const cnt = p.geometry.attributes.position.count; const hyps = detectFrames(camArr[0].cam, poseById(SM.getCurFrameId()), pos, cnt, camArr); out.坐标系假设 = hyps.map((h) => ({ 假设: h.name, 命中率: (h.cov * 100).toFixed(1) + '%' })); } } catch (e) { out._err_hyp = String(e); } log('诊断', out); return out; } /* ================================================================ * 9. 极简 UI:一个状态胶囊(没有任何调色项) * ================================================================ */ const CSS = ` #xjrgb-root{position:fixed;right:20px;top:74px;z-index:2147483000;font:12px/1.5 -apple-system,"Microsoft YaHei","PingFang SC",sans-serif;user-select:none} #xjrgb-pill{display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:13px;cursor:pointer; background:rgba(9,14,26,.82);border:1px solid rgba(56,189,248,.32);color:#cfe6ff;font-size:11px} #xjrgb-dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 7px #22c55e} #xjrgb-dot.err{background:#f87171;box-shadow:0 0 7px #f87171} #xjrgb-card{display:none;margin-top:6px;width:256px;padding:9px 11px;border-radius:10px; background:rgba(9,14,26,.94);border:1px solid rgba(56,189,248,.28);box-shadow:0 10px 30px rgba(0,0,0,.5)} #xjrgb-card.open{display:block} #xjrgb-status{font-size:11px;color:#9fb6d0;line-height:1.5;word-break:break-all;margin-bottom:7px} .xjrgb-btns{display:flex;gap:6px} .xjrgb-btn{flex:1;height:26px;border:1px solid rgba(56,189,248,.3);border-radius:6px;background:rgba(30,41,59,.6); color:#9fb6d0;font-size:11px;cursor:pointer} .xjrgb-btn:hover{color:#e6f7ff} `; let ui = null; function setStatus(msg, isErr, native) { if (!ui) return; ui.status.textContent = msg || ''; ui.dot.className = isErr ? 'err' : ''; ui.pillTxt.textContent = native ? 'RGB 原生' : 'RGB'; } function buildPill() { if (document.getElementById('xjrgb-root')) return; const style = document.createElement('style'); style.textContent = CSS; document.head.appendChild(style); const root = document.createElement('div'); root.id = 'xjrgb-root'; root.innerHTML = `
RGB
启动中…
`; document.body.appendChild(root); ui = { status: root.querySelector('#xjrgb-status'), mode: root.querySelector('#xjrgb-mode'), dot: root.querySelector('#xjrgb-dot'), pillTxt: root.querySelector('#xjrgb-pill-txt'), card: root.querySelector('#xjrgb-card'), }; root.querySelector('#xjrgb-pill').addEventListener('click', () => ui.card.classList.toggle('open')); root.querySelector('#xjrgb-restore').addEventListener('click', () => restore()); root.querySelector('#xjrgb-force').addEventListener('click', () => { userOverride = false; setColorType(3); setStatus('已切到 rgb 着色'); }); root.querySelector('#xjrgb-diag').addEventListener('click', async () => { setStatus('诊断中…'); try { const d = await diagnose(); log(d); setStatus('诊断结果已输出到 F12 Console'); } catch (e) { setStatus('诊断失败: ' + e.message, true); } }); } const MODE_NAME = { 1: '高度着色', 2: '反射强度', 3: 'rgb着色', 4: '不着色' }; let lastModeText = ''; function refreshModeText() { if (!ui) return; try { const cur = getSM().statusState().colorType; const txt = '当前显示模式:' + (MODE_NAME[cur] || cur) + (cur === 3 ? ' ✓' : '(点上方按钮可切回 rgb)'); if (txt === lastModeText) return; // 避免每 120ms 写一次 DOM lastModeText = txt; ui.mode.textContent = txt; } catch (e) {} } /* ================================================================ * 10. 启动 / 预热 * ================================================================ */ function loop() { if (CFG.autoFollow) tick(); setTimeout(loop, CFG.pollMs); } /** 空闲时把下一帧的相机图预热进缓存,切帧时几乎瞬时 */ let prefetchTimer = null; function schedulePrefetch() { if (!CFG.prefetchNext || prefetchTimer) return; prefetchTimer = setTimeout(() => { prefetchTimer = null; prefetchNextFrame(); }, 1200); } /** * 帧切换时立刻预热:相机图 + pcd 头。loadImage/probePcdHeader 自带去重与缓存, * 所以这里只管发出去,不 await —— 目的是让下载和平台加载点云并行,而不是排在它后面。 */ let lastWarmedFrame = null; function warmFrame(fid) { if (fid === null || fid === undefined) return; if (fid === lastWarmedFrame) return; try { const frame = frameById(fid); if (!frame) return; // config 还没就绪,下一轮再试 lastWarmedFrame = fid; const sp = sensorParams(); if (sp) for (const jb of cameraJobs(sp, frame)) loadImage(jb.url); const u = frame.url; if (u && !dirNoColor.has(dirOf(u))) probePcdHeader(u); } catch (e) {} } function prefetchNextFrame() { try { const SM = getSM(); if (!SM) return; const cfg = SM.configState(); const ids = cfg.frameIds, frames = cfg.frames; if (!ids || !frames) return; const idx = ids[SM.getCurFrameId()]; if (idx === undefined || idx === null) return; const nf = frames[idx + 1]; if (!nf || !nf.slave_info) return; const sp = sensorParams(); if (!sp) return; for (const key of Object.keys(sp)) { const u = imageUrlOf(nf, key); if (u && !imgCache.has(u) && !imgFailed.has(u) && !imgPending.has(u)) loadImage(u); } } catch (e) {} } function init() { // 不再生成右上角状态胶囊;控制入口挂到 window.__lassoCut.rgb(F12 控制台可用) try { var host = window.__lassoCut || (window.__lassoCut = {}); host.rgb = { setRgb: function () { userOverride = false; setColorType(3); }, diagnose: diagnose, restore: restore, stat: function () { return window.__xjRgbLastStat || null; }, }; } catch (e) { } try { originalColorType = getSM() ? getSM().statusState().colorType : null; } catch (e) {} log('已启动(静默版)', 'instance=' + !!window.instance, 'viewer=' + !!getViewer()); try { warmFrame(getSM().getCurFrameId()); } catch (e) {} // 首帧立刻开始预热 tick(); // 立刻试一次,不再固定等待 loop(); } function waitReady(tries) { tries = tries || 0; if (window.instance && getViewer()) { setTimeout(init, 0); return; } if (tries > 400) { // 兜底初始化只在“确实像平台页面”时进行,避免在其他站点挂上 UI(合并脚本匹配所有站点) try { if (window.instance || document.querySelector("#app") || /pointcloud/i.test(location.pathname)) { init(); return; } } catch (e) {} return; } setTimeout(() => waitReady(tries + 1), 120); } waitReady(); })();