// ==UserScript== // @name ADO 3D框自动贴合(ALCHERA ADO Cuboid) // @namespace https://ado.treed.ai/ // @version 2.9.6 // @description 在 ALCHERA ADO 3D 标注平台上对立方体框做自动贴合,并支持摄像头属性快填、跨帧复制、整包质检、整包框数统计、清空全帧、自动勾选尺寸异常、粘贴自动补全属性。写入走平台原生通道(平台自动重算尺寸、位姿、朝向与 4 路摄像头 2D 投影)。v2.9.6 修帧号显示:「质检本帧」和「清空全帧确认」漏了 0-based→1-based 转换(明明在第 10 帧却显示帧 9)。v2.9.5 修同帧粘贴。v2.9.4 真选中 + 跨帧补全 + 最小化。v2.9.3 补全后刷新选中 + 跨帧三层兜底 + 面板可滚动。v2.9.2 粘贴后自动选中 + 面板可调大小。v2.9.1 粘贴开关 + 隐藏实验项。v2.9 粘贴自动补全。v2.8.1 尺寸异常批量勾选。v2.8 适配尺寸异常。v2.7.3 预填不重建弹窗。v2.7.2 点数字不重建 + 说明中文化。v2.7.1 纯复制上帧 + 面板固定尺寸 + 循环 0~4。v2.7 面板分组重排 + 隐藏全贴合·撤销·一键新增 + 修属性被清空 + 复制保留属性。v2.6.4 帧号 1-based。v2.6.3 纯复制下帧。v2.6.2 复制按钮并列。v2.6.1 面板最小化。v2.6 修近邻物体重叠。v2.5.1 ID 去重合并。v2.5 支持从下一帧复制。v2.4.1 属性快填对齐黄框。v2.4 属性快填。v2.3 朝向对比度阈值放宽。v2.2 判据包围盒改分位数。v2.1 位移上限 2m + 多起点细扫。v2.0 修误拒 + 配准加 yaw。v1.9.1 点云配准。v1.9 智能复制。v1.8 文件服务 API。v1.7 统计框数。v1.6 余量放远端。v1.5 排除自车 + 清空全帧。v1.4 质检。v1.3 质检 + 压线车。v1.2 一键新增 + 改属性。v1.1 壳体贴合朝向判据。 // @author Bro // @match https://ado.treed.ai/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== /* * ──────────────────────────────────────────────────────────────── * ADO 3D 框自动贴合 * ──────────────────────────────────────────────────────────────── * 两个按钮: * · 贴合选中 —— 只处理当前选中的 1 个对象 * · 全贴合 —— 处理本帧全部对象(先列出改动清单,确认后才写入) * · 撤销 —— 把本次操作前的几何原样还原 * * 算法(针对激光雷达"只扫到一面"的特性): * 1. 取框内点云 → BEV 聚类,剔除邻近物体的点 * 2. 朝向:把点云绕框心旋转扫描,取「点到紧包围矩形边界距离」最小的角度 * —— 距离最小=点云最贴着矩形四边=框最贴合物体外壳(v1.1 起) * 旧版用 PCA 直线拟合,L 形点云会被拐角拉偏:实测 #84 该转不转(歪 6.9° 报 0°)、 * #83 不该转乱转(转歪 9.4°)。换判据后 #84 给 6.4°(用户手动纠正 6.9°), * #83 落在死区内不动。另加闸门:外壳太窄(单面点云成一条线,朝向二义)不转、 * 谷底对比度不足不转、残差 < 2.5° 不转。判据对框初始角度 ±15° 不敏感, * 反复点不会累积偏移(实测第 2、3 次调整量恒为 0)。 * 3. 把可见面对齐到框的对应面(这是"贴合"的核心,单面点云下最可靠的几何线索) * 4. 沿车长方向重新居中到观测表面 * 5. 车长只增不减 —— 点云看到的是物体表面,测得的长度是真实长度的「下界」, * 远端常常根本没被扫到,因此不主动缩短 * 6. 车宽:仅单侧可见时不可测,沿用标注值并按项目标准范围夹紧 * 7. 车高:不碰 —— 雷达装在车顶,扫不到比自己高的物体顶部 * 8. 底面高度:默认不动,仅当明显离地/陷地(>0.35m)才纠正到地面 * * 安全: * · 只改 cuboid 几何,不动 category / sub_class / tracking_id / T·O 属性 * · 写入走平台自身通道(改场景框角点),平台自动重算所有派生字段, * 因此 4 路摄像头的 2D 投影不会出现"脏数据" * · 中心位移 > 1.5m 判定为异常,拒绝对该对象动手 * · 全程可撤销;不自动保存(你自己决定什么时候存) * ──────────────────────────────────────────────────────────────── */ (function () { "use strict"; /* ======================== 算法核心(纯函数) ======================== */ /* ============================================================================ * ADO 3D 框自动贴合 — 算法核心 v5(纯函数,无 DOM 依赖) * 坐标系约定(已实测验证,误差 0.000000): * · 朝向四元数在雷达系,q = [x,y,z,w] * · 局部 +x = 车长(length),+y = 车宽(width),+z = 车高(height) * · 位置 = 底面中心(不是几何中心!)角点 = 位置 + R·(±L/2, ±W/2, {0,H}) * · 角点顺序:0:(-,+,0) 1:(-,+,H) 2:(-,-,0) 3:(-,-,H) * 4:(+,+,0) 5:(+,+,H) 6:(+,-,0) 7:(+,-,H) * * ── 版本演进(记录踩过的坑,避免重蹈)──────────────────────────── * v1 取点=框内+0.5m;直方图峰值判可见面。框罩住物体时好用,但框偏在 * 边上时取到的只是切片,峰值落在切片边缘 → 把框继续往外推。 * v2 试图放大取点范围自动纠偏 → 把路面/邻近建筑吃进来,「必须包住全部点」 * 导致尺寸无限膨胀(83 宽 1.94→2.60)。已否决。 * v3 改用几何法判可见面 → 交叉校验误拒了正常对象。已否决。 * v4 回到 v1 机制(已验证可用),只叠加两道「纯保险」闸门,不引入新判据: * * 闸门1|覆盖度:所选点簇落在框内的比例 < 55% 就拒绝。 * 直接对应「框内有大部分点云才好用,在边边就翻车」这个现象。 * 闸门2|单调包含:贴合后框内的点数不得少于贴合前。 * 保证最坏情况是「框不动」,绝不可能「越贴越偏」。 * * v5 【换掉朝向判据】v4 的朝向用「迭代裁剪 PCA 直线拟合」,两个方向都会翻车: * · 该转的不转:车在 BEV 里是 L 形(可见侧面 + 车头/车尾两个面), * L 的拐角把主轴拉向对角线。实测 #84:PCA 报「残差 0.1°,无需调整」, * 而框实际歪了 6.9°(用户手动纠正到 -33.6°)。 * · 不该转的乱转:点云单薄时 PCA 依然「自信」。实测 #83:直线度 0.995、 * 拟合长 5.0m 看着很漂亮,却把框从 -37.25° 转到 -46.69°(转错 9.4°)。 * 改用「壳体贴合」判据(点到紧包围矩形边界的最短距离,全局扫描取最小): * · #84 给 -34.22°,与用户手动纠正的 -33.60° 只差 0.6°;PCA 报 0° 修正。 * · #83 给 -2.2°(落在死区内不动),不再被转歪 9.4°。 * · 合成台架(单面可见 / L 形 / 远距稀疏,已知真值)恢复误差 ≤0.25°, * 且对框初始角度 ±15° 的偏差完全不敏感 → 收敛到同一绝对角,不会越点越歪。 * 配套闸门:① 外壳必须「有宽度」(单面点云在 BEV 里是一条线,朝向有 0°/90° * 二义性 → 宁可不转);② 谷底要有对比度(证据不足不动);③ 死区 2.5°。 * * 物理事实(本帧实测): * · 观测宽度 ≈ 真实宽 − 0.08m(能看到车顶,车顶几乎铺满整个宽度) * · 长度只增不减:点云看到的是表面,测出的是真实长度的下界 * · 高度一律不动:雷达装在车顶,扫不到比自己高的物体顶部 * · 车宽单侧可见时不可测 → 沿用标注值,或用同类别典型值(可选) * ==========================================================================*/ var AdoFit = (function () { "use strict"; /* ---------------- 四元数 / 矩阵 ---------------- */ function quatToMat(q) { var x = q[0], y = q[1], z = q[2], w = q[3]; var xx = x * x, yy = y * y, zz = z * z; return [ 1 - 2 * (yy + zz), 2 * (x * y - z * w), 2 * (x * z + y * w), 2 * (x * y + z * w), 1 - 2 * (xx + zz), 2 * (y * z - x * w), 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (xx + yy) ]; } function toLocal(R, v) { return [ R[0] * v[0] + R[3] * v[1] + R[6] * v[2], R[1] * v[0] + R[4] * v[1] + R[7] * v[2], R[2] * v[0] + R[5] * v[1] + R[8] * v[2] ]; } function toWorld(R, v) { return [ R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2] ]; } /* 绕世界 z 轴旋转 ang 后的四元数:q' = q_z(ang) ⊗ q ⚠ 这里曾漏掉 z' 中的 bz·aw 项 → 「朝向调整」实际只转了 0.1°,极难察觉。 展开式(b = [0,0,sin(ang/2),cos(ang/2)],令 sz/ cz 为二者): x' = cz·x − sz·y y' = cz·y + sz·x z' = cz·z + sz·w w' = cz·w − sz·z */ function yawQuat(a, ang) { var h = ang / 2, sz = Math.sin(h), cz = Math.cos(h); return [ cz * a[0] - sz * a[1], cz * a[1] + sz * a[0], cz * a[2] + sz * a[3], cz * a[3] - sz * a[2] ]; } function yawOf(q) { var R = quatToMat(q); return Math.atan2(R[3], R[0]); } /* ---------------- PCD 解析 ---------------- */ function parsePCD(buf) { var u8 = new Uint8Array(buf), i, text = ""; var lim = Math.min(u8.length, 4096); for (i = 0; i < lim; i++) text += String.fromCharCode(u8[i]); var m = /DATA\s+binary\r?\n/i.exec(text); if (!m) return null; var headEnd = m.index + m[0].length, head = text.slice(0, m.index); function hdr(k) { var mm = new RegExp("^" + k + "\\s+(.+)$", "mi").exec(head); return mm ? mm[1].trim().split(/\s+/) : null; } var fields = hdr("FIELDS"), sizes = hdr("SIZE"); if (!fields) return null; var stride = 0, off = {}, j; for (j = 0; j < fields.length; j++) { off[fields[j]] = stride; stride += parseInt(sizes[j], 10); } var body = buf.slice(headEnd); if (body.byteLength < stride) return null; if (!(fields.indexOf("x") >= 0 && fields.indexOf("y") >= 0 && fields.indexOf("z") >= 0)) return null; return { n: Math.floor(body.byteLength / stride), stride: stride, off: off, view: new DataView(body) }; } function denseXYZ(pcd) { var n = pcd.n, out = new Float32Array(n * 3), v = pcd.view; var ox = pcd.off.x, oy = pcd.off.y, oz = pcd.off.z, st = pcd.stride; for (var i = 0, p = 0; i < n; i++, p += st) { out[i * 3] = v.getFloat32(p + ox, true); out[i * 3 + 1] = v.getFloat32(p + oy, true); out[i * 3 + 2] = v.getFloat32(p + oz, true); } return out; } function estimateGround(xyz) { var n = xyz.length / 3, hist = Object.create(null), i, best = 0, bestC = -1; for (i = 0; i < n; i++) { var z = xyz[i * 3 + 2]; if (z < -1 || z > 1) continue; var k = Math.round(z * 20) / 20, c = (hist[k] = (hist[k] || 0) + 1); if (c > bestC) { bestC = c; best = k; } } return best; } /* ---------------- 基础工具 ---------------- */ var CT = [ [-1, 1, 0], [-1, 1, 1], [-1, -1, 0], [-1, -1, 1], [1, 1, 0], [1, 1, 1], [1, -1, 0], [1, -1, 1] ]; function cornersOf(loc, quat, L, W, H) { var R = quatToMat(quat), out = [], i, t, w; for (i = 0; i < 8; i++) { t = CT[i]; w = toWorld(R, [t[0] * L / 2, t[1] * W / 2, t[2] * H]); out.push([loc[0] + w[0], loc[1] + w[1], loc[2] + w[2]]); } return out; } function pct(s, p) { if (!s.length) return NaN; var k = (s.length - 1) * p, f = Math.floor(k), c = Math.ceil(k); return f === c ? s[f] : s[f] * (c - k) + s[c] * (k - f); } function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } function allClusters(xyz, idx, cell) { if (!idx.length) return []; var map = Object.create(null), i, k, p; for (i = 0; i < idx.length; i++) { p = idx[i] * 3; k = Math.floor(xyz[p] / cell) + "," + Math.floor(xyz[p + 1] / cell); (map[k] || (map[k] = [])).push(idx[i]); } var dirs = [], dx, dy; for (dx = -1; dx <= 1; dx++) for (dy = -1; dy <= 1; dy++) dirs.push([dx, dy]); var seen = Object.create(null), out = [], stack, comp, cur, cx, cy, bucket, d, nk; for (var k0 in map) { if (seen[k0]) continue; stack = [k0]; comp = []; seen[k0] = 1; while (stack.length) { cur = stack.pop(); var parts = cur.split(","); cx = parseInt(parts[0], 10); cy = parseInt(parts[1], 10); bucket = map[cur]; for (i = 0; i < bucket.length; i++) comp.push(bucket[i]); for (d = 0; d < dirs.length; d++) { nk = (cx + dirs[d][0]) + "," + (cy + dirs[d][1]); if (map[nk] && !seen[nk]) { seen[nk] = 1; stack.push(nk); } } } out.push(comp); } return out; } /* 显著外缘锚点:找可见侧「成片存在」的最外一层表面。 用途:后视镜/天线这类零星外凸只占极少数点,会形成远低于峰值的桶而被丢弃, 于是锚点落在车体侧面上(贴车本身),而不是被镜子顶出去。 */ function significantOuter(lySorted, side, bin) { if (!lySorted.length) return NaN; var h = Object.create(null), i, k, mx = 0; for (i = 0; i < lySorted.length; i++) { k = Math.floor(lySorted[i] / bin); h[k] = (h[k] || 0) + 1; if (h[k] > mx) mx = h[k]; } var thr = Math.max(3, 0.30 * mx), bestKey = null; for (k in h) { if (h[k] < thr) continue; var kk = parseInt(k, 10); if (bestKey === null) bestKey = kk; else if (side > 0 ? kk > bestKey : kk < bestKey) bestKey = kk; } if (bestKey === null) { return side > 0 ? lySorted[lySorted.length - 1] : lySorted[0]; } return side > 0 ? (bestKey + 1) * bin : bestKey * bin; } /* ---------------- 朝向判据:壳体贴合(边界距离) ---------------- 原理:把点云绕框心转 δ,求紧包围矩形,再算每个点到「最近那条边」的距离; 距离越小说明点云越贴着矩形四边=越贴合物体外壳。对 δ 全局扫描取最小处, 即为正确朝向。判据对 90° 周期(矩形四边对称),因此天然化解「可见面是车长 方向还是车宽方向」的 ±90° 歧义 —— 取 ±cap 内的最近解即可。 实测(#84 轿车,L 形可见):本判据 -34.22°,用户手动纠正 -33.60°。 合成台架(真值已知):单面可见/L 形/远距稀疏三种取景,误差均 ≤0.25°。 */ var SHELL_CLAMP = 0.40; /* 单点距离上限(m):后视镜/背景这类远离外壳的零星点不该主导判据 */ var SHELL_MIN_DIM = 0.60; /* 包围矩形最小边长(m):防止退化成一条线(面积恒为 0,argmin 无意义) */ var SHELL_Q_LO = 0.02; /* 包围盒用 2%~98% 分位数,见下方说明 */ function shellScore(px, py, n, deg) { var a = deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a); var qxs = new Array(n), qys = new Array(n), i, qx, qy; for (i = 0; i < n; i++) { qx = px[i] * c + py[i] * s; qy = -px[i] * s + py[i] * c; qxs[i] = qx; qys[i] = qy; } /* ⚠ 包围盒必须用「分位数」而不是极值。 用极值时边界由最外层的几个离群点决定,真正的车体密集带被撑开、变得不贴边, 判据于是彻底失去分辨力 —— 实测 #122 场景(车体是一条密集横带 + 周围散点) 得分曲线完全单调,算法只能落到扫描边界,朝向就偏了 6°。 改成 2%~98% 分位数后,曲线在正确朝向处出现明确谷底(实测命中 0°)。 代价:每次评估要排序,67 次 × 3000 点约 200ms,可以接受。 */ var sx = qxs.slice().sort(function (x, y) { return x - y; }); var sy = qys.slice().sort(function (x, y) { return x - y; }); var iLo = Math.floor(SHELL_Q_LO * (n - 1)), iHi = Math.floor((1 - SHELL_Q_LO) * (n - 1)); var mnx = sx[iLo], mxx = sx[iHi], mny = sy[iLo], mxy = sy[iHi]; var bw = mxx - mnx, bh = mxy - mny; if (bw < SHELL_MIN_DIM || bh < SHELL_MIN_DIM) return { v: 9.9, bx: bw, by: bh }; var sum = 0, d, dx, dy; for (i = 0; i < n; i++) { dx = qxs[i] - mnx; d = mxx - qxs[i]; if (d < dx) dx = d; dy = qys[i] - mny; d = mxy - qys[i]; if (d < dy) dy = d; d = dx < dy ? dx : dy; sum += d < SHELL_CLAMP ? d : SHELL_CLAMP; } return { v: sum / n, bx: bw, by: bh }; } /* 两段扫描:2° 粗扫定谷底 → 0.1° 细扫收敛。共 67 次评估, 与 0.25° 全程扫描(201 次)结果差 <0.15°,耗时约为 1/3。 contrast =(粗扫中位数 − 谷底值)/ 中位数,衡量「谷够不够深」。 */ function scanShellYaw(px, py, capDeg) { var n = px.length, COARSE = 2.0, FINE = 0.1; var angs = [], a, i, r; for (a = -capDeg; a <= capDeg + 1e-9; a += COARSE) angs.push(a); var vals = []; for (i = 0; i < angs.length; i++) vals.push(shellScore(px, py, n, angs[i]).v); /* 收集所有「局部极小」(谷底)作为细扫起点。 ⚠ 只细扫全局最小那一个是不够的:粗扫步长 2°,正确谷底可能正好落在两个采样点 之间而没被采到,错误的谷底反而被采到 —— 细扫就在错的谷里收敛,表现就是「贴歪」。 取前 3 个谷底各自细扫再选最优,能把这种情况兜住(计算量约翻倍,绝对值仍很小)。 */ var cand = []; for (i = 0; i < angs.length; i++) { var pv = i > 0 ? vals[i - 1] : Infinity; var nx = i < angs.length - 1 ? vals[i + 1] : Infinity; if (vals[i] <= pv && vals[i] <= nx) cand.push({ a: angs[i], v: vals[i] }); } if (!cand.length) cand.push({ a: angs[0], v: vals[0] }); cand.sort(function (x, y) { return x.v - y.v; }); cand = cand.slice(0, 3); var bd = cand[0].a, bv = Infinity, bres = null, ci; for (ci = 0; ci < cand.length; ci++) { for (a = cand[ci].a - COARSE; a <= cand[ci].a + COARSE + 1e-9; a += FINE) { r = shellScore(px, py, n, a); if (r.v < bv) { bv = r.v; bd = a; bres = r; } } } vals.sort(function (x, y) { return x - y; }); var med = vals[Math.floor(vals.length / 2)]; return { delta: bd, score: bv, contrast: med > 1e-9 ? (med - bv) / med : 0, bx: bres ? bres.bx : 0, by: bres ? bres.by : 0, nPeaks: cand.length }; } /* ---------------- 一键新增:ROI 内车辆自动建框 ---------------- 输入:点云 xyz、地面高度 gz、ROI 范围 {xMin,xMax,yMin,yMax} 输出:候选框数组(只算,不写平台) 流程:ROI 裁剪 → 去地面 → 网格聚类 → 每簇壳体贴合求 OBB → 尺寸过滤 → 类型推断 */ var ADD_OPT = { cell: 0.40, /* 网格边长(m):相邻车通常间隔 ≥0.5m,0.40 能分开又不把单车切碎 */ minPts: 50, /* 簇最小点数:滤掉零星噪点与路沿碎点 */ zMin: 0.20, /* 高于地面(m):滤掉地面,但保留低矮车身下缘 */ zMax: 4.60, /* 最高(m):滤掉龙门架、高架桥面 */ minL: 2.20, maxL: 16.0, minW: 0.80, maxW: 3.60, minH: 0.70, maxH: 4.60, maxR: 9.0, /* 生长半径上限(m):防止连续墙面把整片区域连成巨簇 */ minPerCell: 8, /* 密度过滤:单格点数低于此值的格子视为墙面/稀疏面 */ roiMargin: 6.0, /* 裁剪外扩(m):压着 ROI 边线的车要拿到完整点云,不能硬裁 */ roiMinOverlap: 0.25, /* 候选框至少要有这么大比例落在 ROI 内,才算「范围内的车」 */ egoRadius: 3.5 /* 自车排除半径(m):雷达自己那辆车就在原点,不该被当成标注目标 */ }; /* 平台源码常量 C0 的复刻(各子类标准长宽高范围),阈值规则的依据。 格式 [宽min, 宽max, 高min, 高max, 长min, 长max] */ var SUB_RANGES = { compact: [1, 3, 1.5, 1.72, 2.6, 4.2], sedan: [1, 3, 1, 3, 2.5, 5.2], suv: [1, 3, 1, 3, 2.2, 6.2], van: [1, 3, 1, 3, 2.9, 7], truck: [1.6, 3, 1.9, 3.8, 2.5, 12], bus: [2, 3, 2.5, 4, 3.3, 14], special: [2, 3, 2.5, 5, 3.3, 20], unknown_vehicle: [1.4, 2.8, 1.2, 3, 3.3, 5] }; /* 按实测 L/W/H 推断子类:特征越独特越先判。 实测校验:#86 (L9.83/W2.70/H3.45) → bus ✓;#83 (L5.04/W1.94/H1.49) → sedan ✓ */ function inferSubClass(L, W, H) { if (H >= 2.5 && W >= 2.0) return "bus"; /* 又高又宽:大巴 */ if (H >= 1.9) return "truck"; /* 高车厢:卡车 */ if (L >= 5.2) return "van"; /* 长而不高:厢式货车 */ if (H <= 1.72 && L <= 4.2) return "compact"; /* 又矮又短:小型车 */ if (H >= 1.68) return "suv"; return "sedan"; } /* 单簇 → 有向包围盒 {L,W,H,loc,quat,yaw,contrast,pts,bx,by} loc 为「底面中心」,与平台 za()/Rr() 的约定一致(局部 z=0 在底面)。 */ function obbOfCluster(xyz, idx, gz) { var n = idx.length, i, p, sx = 0, sy = 0; for (i = 0; i < n; i++) { p = idx[i] * 3; sx += xyz[p]; sy += xyz[p + 1]; } var cx = sx / n, cy = sy / n; var px = new Array(n), py = new Array(n), zs = new Array(n); for (i = 0; i < n; i++) { p = idx[i] * 3; px[i] = xyz[p] - cx; py[i] = xyz[p + 1] - cy; zs[i] = xyz[p + 2]; } zs.sort(function (a, b) { return a - b; }); /* 分位数代替极值,避免噪点撑高/压低。底面若贴近地面就归到地面: 否则被 zMin 裁掉的 0~0.3m 会凭空吃掉车高(实测 #84 少算 0.32m)。 */ var z1 = pct(zs, 0.98), z0 = pct(zs, 0.02); if (z0 - gz < 0.5) z0 = Math.min(z0, gz); /* 矩形对 180° 周期对称,扫 ±90° 即覆盖全部姿态 */ var sh = scanShellYaw(px, py, 90); var a = sh.delta * Math.PI / 180, ca = Math.cos(a), sa = Math.sin(a); var mnx, mxx, mny, mxy, qx, qy, pass; /* ⚠ 紧包围矩形对 90° 也对称:转 90° 后仍是同一个矩形,只是长宽互换, shellScore 在 0° 与 90° 处取值完全相同,argmin 可能落到「长边沿 y」那支 (实测 #83 被算成 L1.91/W5.02,实际 L5.04/W1.94,正好颠倒)。 车永远长>宽 → 长边必须沿局部 x 轴,否则换到 90° 那一支重算。 */ for (pass = 0; pass < 2; pass++) { mnx = Infinity; mxx = -Infinity; mny = Infinity; mxy = -Infinity; for (i = 0; i < n; i++) { qx = px[i] * ca + py[i] * sa; qy = -px[i] * sa + py[i] * ca; if (qx < mnx) mnx = qx; if (qx > mxx) mxx = qx; if (qy < mny) mny = qy; if (qy > mxy) mxy = qy; } if (pass === 0 && (mxx - mnx) < (mxy - mny)) { a += Math.PI / 2; ca = Math.cos(a); sa = Math.sin(a); continue; } break; } var lc = (mnx + mxx) / 2, wc = (mny + mxy) / 2; return { L: mxx - mnx, W: mxy - mny, H: Math.max(0.1, z1 - z0), /* 局部中心逆旋转回世界,再加质心;z 取底面 */ loc: [cx + (lc * ca - wc * sa), cy + (lc * sa + wc * ca), z0], quat: [0, 0, Math.sin(a / 2), Math.cos(a / 2)], yaw: a * 180 / Math.PI, contrast: sh.contrast, pts: n, bx: sh.bx, by: sh.by, top: z1, bot: z0 }; } /* 带「生长半径上限 + 高密度优先」的网格聚类。 为什么不用朴素的 allClusters:墙面/护栏是连续表面,会把挨着的车一起吸进 一个几万点的巨簇(实测出现过 L9.9×W24.8 的簇),OBB 完全失真。 两个修正: ① 种子按格子点数降序 —— 车是立体多面、点密度远高于单面墙,车先成簇; ② 生长时限制「到种子格子的距离 ≤ maxR」—— 墙再长也只能被切成 maxR 段, 且切出来的段会因「顶到裁剪边界」被后续判据剔除。 */ function allClustersR(xyz, idx, cell, maxR) { if (!idx.length) return []; var map = Object.create(null), i, k, p; for (i = 0; i < idx.length; i++) { p = idx[i] * 3; k = Math.floor(xyz[p] / cell) + "," + Math.floor(xyz[p + 1] / cell); (map[k] || (map[k] = [])).push(idx[i]); } var dirs = [], dx, dy; for (dx = -1; dx <= 1; dx++) for (dy = -1; dy <= 1; dy++) dirs.push([dx, dy]); var keys = Object.keys(map).sort(function (a, b) { return map[b].length - map[a].length; }); var seen = Object.create(null), out = [], lim = Math.ceil(maxR / cell); var stack, comp, cur, bucket, d, nk, ki, k0, p0, pn, ox, oy, ccx, ccy, nx, ny; for (ki = 0; ki < keys.length; ki++) { k0 = keys[ki]; if (seen[k0]) continue; p0 = k0.split(","); ox = parseInt(p0[0], 10); oy = parseInt(p0[1], 10); stack = [k0]; comp = []; seen[k0] = 1; while (stack.length) { cur = stack.pop(); var pc = cur.split(","); ccx = parseInt(pc[0], 10); ccy = parseInt(pc[1], 10); bucket = map[cur]; for (i = 0; i < bucket.length; i++) comp.push(bucket[i]); for (d = 0; d < dirs.length; d++) { nk = (ccx + dirs[d][0]) + "," + (ccy + dirs[d][1]); if (!map[nk] || seen[nk]) continue; pn = nk.split(","); nx = parseInt(pn[0], 10); ny = parseInt(pn[1], 10); if (Math.abs(nx - ox) > lim || Math.abs(ny - oy) > lim) continue; seen[nk] = 1; stack.push(nk); } } out.push(comp); } return out; } /* 簇的 OBB 是否落在车辆尺寸范围内 */ function inVehicleRange(o, opt) { return o.L >= opt.minL && o.L <= opt.maxL && o.W >= opt.minW && o.W <= opt.maxW && o.H >= opt.minH && o.H <= opt.maxH; } /* 按「每格点数」卡密度:车是立体多面,单个网格里的点数远高于单面墙/护栏 */ function denseFilter(xyz, idx, cell, minPerCell) { var map = Object.create(null), i, p, k, out = []; for (i = 0; i < idx.length; i++) { p = idx[i] * 3; k = Math.floor(xyz[p] / cell) + "," + Math.floor(xyz[p + 1] / cell); (map[k] || (map[k] = [])).push(idx[i]); } for (k in map) if (map[k].length >= minPerCell) out = out.concat(map[k]); return out; } /* 顶部超界的簇:多半是「车 + 旁边的高柱子/树冠/天桥」粘在一起。 ⚠ 必须「迭代砍」而不是一刀切:实测某簇车体在 z ≤ 2.0m,而 z = 4.0~4.6m 另有 1232 点(中间 2.0~4.0m 是空的),单次砍 8% 只去掉 665 点,根本够不着 —— 得砍到约 15% 才能让最高点落回边界内。 */ function trimTop(xyz, idx, gz, opt) { var zs = [], i, p, cut, keep = idx, it, step = 0.08, maxCut = 0.40; for (i = 0; i < idx.length; i++) zs.push(xyz[idx[i] * 3 + 2]); zs.sort(function (a, b) { return a - b; }); var topLimit = gz + opt.zMax - 0.35; for (it = 1; it * step <= maxCut + 1e-9; it++) { cut = pct(zs, 1 - it * step); var kp = [], mx = -Infinity; for (i = 0; i < idx.length; i++) { p = idx[i] * 3; if (xyz[p + 2] <= cut) { kp.push(idx[i]); if (xyz[p + 2] > mx) mx = xyz[p + 2]; } } if (kp.length < opt.minPts) break; keep = kp; if (mx <= topLimit) return kp; /* 最高点已落回边界内 → 收手 */ } return keep; } /* 递归细分:网格聚类会把「挨得近的两辆车」或「车+墙」粘成一坨, 表现为 OBB 远超车辆尺寸、或顶面顶到裁剪边界。此时用更细的网格重聚该簇, 直到尺寸合理或到达深度上限;网格细分无效时退到密度过滤。 */ function splitCluster(xyz, idx, gz, opt, depth) { var o = obbOfCluster(xyz, idx, gz); var overTop = o.top - gz > opt.zMax - 0.35; var bad = !inVehicleRange(o, opt) || overTop; if (!bad) return [o]; if (overTop) { var kp = trimTop(xyz, idx, gz, opt); if (kp.length >= opt.minPts && kp.length < idx.length) { var o2 = obbOfCluster(xyz, kp, gz); if (inVehicleRange(o2, opt) && o2.top - gz <= opt.zMax - 0.35) return [o2]; } } if (depth <= 0) return [o]; var sub = allClustersR(xyz, idx, opt.cell * 0.55, opt.maxR * 0.55); if (sub.length <= 1) { var dens = denseFilter(xyz, idx, opt.cell, opt.minPerCell); if (dens.length >= opt.minPts && dens.length < idx.length * 0.92) { sub = allClustersR(xyz, dens, opt.cell * 0.55, opt.maxR * 0.55); } } if (sub.length <= 1) return [o]; var out = [], i; for (i = 0; i < sub.length; i++) { if (sub[i].length < opt.minPts) continue; out = out.concat(splitCluster(xyz, sub[i], gz, opt, depth - 1)); } return out.length ? out : [o]; } /* 世界 AABB(用于去重,不参与最终几何) */ function aabbOf(b) { var p = cornersOf(b.loc, b.quat, b.L, b.W, b.H); var x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, i; for (i = 0; i < 8; i++) { if (p[i][0] < x0) x0 = p[i][0]; if (p[i][0] > x1) x1 = p[i][0]; if (p[i][1] < y0) y0 = p[i][1]; if (p[i][1] > y1) y1 = p[i][1]; } return [x0, y0, x1, y1]; } function iou2d(a, b) { var A = aabbOf(a), B = aabbOf(b); var ix = Math.min(A[2], B[2]) - Math.max(A[0], B[0]); var iy = Math.min(A[3], B[3]) - Math.max(A[1], B[1]); if (ix <= 0 || iy <= 0) return 0; var inter = ix * iy; var uni = (A[2] - A[0]) * (A[3] - A[1]) + (B[2] - B[0]) * (B[3] - B[1]) - inter; return uni > 0 ? inter / uni : 0; } /* 去重:同一辆车被切成两半时会产出两个高度重叠的框,保留点数多的那个 */ function dedupe(boxes, thr) { boxes.sort(function (a, b) { return b.pts - a.pts; }); var keep = [], i, j, dup; for (i = 0; i < boxes.length; i++) { dup = false; for (j = 0; j < keep.length; j++) { if (iou2d(boxes[i], keep[j]) > (thr || 0.45)) { dup = true; break; } } if (!dup) keep.push(boxes[i]); } return keep; } /* 候选框有多大比例落在 ROI 矩形内(按世界 AABB 近似)。 用途:压着范围线的车要保留(框完整),完全在外的车要排除。 */ function roiOverlap(b, roi) { if (!roi) return 1; var A = aabbOf(b); var ix = Math.min(A[2], roi.xMax) - Math.max(A[0], roi.xMin); var iy = Math.min(A[3], roi.yMax) - Math.max(A[1], roi.yMin); if (ix <= 0 || iy <= 0) return 0; var area = (A[2] - A[0]) * (A[3] - A[1]); return area > 1e-9 ? (ix * iy) / area : 0; } /* 主入口:ROI 内聚类出候选车辆框。 ⚠ ROI 只用来「筛候选」,不用来「切点云」—— 压着范围线的车若被硬裁, 框会短一截(实测贴边的车长被砍掉 3m)。做法:裁剪范围向外扩 margin, 聚类出完整框后,再按「框有多大比例落在 ROI 内」决定去留。 */ function clusterVehicles(xyz, gz, roi, opt) { opt = opt || ADD_OPT; var margin = opt.roiMargin === undefined ? 6.0 : opt.roiMargin; var cut = roi ? { xMin: roi.xMin - margin, xMax: roi.xMax + margin, yMin: roi.yMin - margin, yMax: roi.yMax + margin } : null; var n = xyz.length / 3, idx = [], i, q, x, y, z, h; for (i = 0; i < n; i++) { x = xyz[i * 3]; y = xyz[i * 3 + 1]; z = xyz[i * 3 + 2]; if (cut && (x < cut.xMin || x > cut.xMax || y < cut.yMin || y > cut.yMax)) continue; h = z - gz; if (h < opt.zMin || h > opt.zMax) continue; idx.push(i); } var cl = allClustersR(xyz, idx, opt.cell, opt.maxR), cand = [], dropped = [], outRoi = 0, egoSkip = 0; for (i = 0; i < cl.length; i++) { if (cl[i].length < opt.minPts) { dropped.push({ n: cl[i].length, why: "点数不足" }); continue; } var parts = splitCluster(xyz, cl[i], gz, opt, 2); for (q = 0; q < parts.length; q++) { var o = parts[q]; var tag = { n: o.pts, loc: [+o.loc[0].toFixed(1), +o.loc[1].toFixed(1)], L: +o.L.toFixed(2), W: +o.W.toFixed(2), H: +o.H.toFixed(2) }; /* 顶到裁剪边界 → 没有顶面 → 是墙面/建筑立面,不是车 */ if (o.top - gz > opt.zMax - 0.35) { tag.why = "顶到裁剪边界(疑似墙)"; dropped.push(tag); continue; } if (!inVehicleRange(o, opt)) { tag.why = "尺寸不符"; dropped.push(tag); continue; } /* 自车:雷达自己那辆车就停在原点附近,点云极密,很容易被当成一个标注目标。 实测自车框中心距原点仅 1.4m,而最近的真实车辆在 6.4m 外,用 3.5m 区分很安全。 */ var dOrigin = Math.sqrt(o.loc[0] * o.loc[0] + o.loc[1] * o.loc[1]); if (dOrigin < (opt.egoRadius === undefined ? 3.5 : opt.egoRadius)) { tag.why = "自车(距原点 " + dOrigin.toFixed(1) + "m)"; dropped.push(tag); egoSkip++; continue; } var ov = roiOverlap(o, roi); if (ov < opt.roiMinOverlap) { tag.why = "在 ROI 外"; tag.overlap = +ov.toFixed(2); dropped.push(tag); outRoi++; continue; } o.subClass = inferSubClass(o.L, o.W, o.H); o.roiOverlap = ov; cand.push(o); } } var out = dedupe(cand); return { boxes: out, dropped: dropped, ptsIn: idx.length, clusters: cl.length, beforeDedupe: cand.length, outsideRoi: outRoi, egoSkipped: egoSkip }; } var PRESETS = { weak: { yawCap: 0.0, label: "弱(不动朝向)" }, mid: { yawCap: 25 * Math.PI / 180, label: "标准(朝向 ±25°)" }, strong: { yawCap: 45 * Math.PI / 180, label: "强(朝向 ±45°)" } }; var COVER_MIN = 0.45; /* 闸门1:覆盖度下限(容差判定后) */ var DILATE = 0.25; /* 容差:点云是物体外壳,框需要外扩这么多再判覆盖 */ /* 贴合位移上限(m):用户把框放在哪辆车上,就只该在那辆车附近收敛。 车辆密集时若不加限制,框可能被邻近车辆的强点云吸过去(实测反馈: 「我要的效果是我的框放的位置就是我想要贴合的车,而不是跑到别的车上去」)。 一辆车长 5m 左右,2m 的位移余量足够纠正「大致对上」的偏差。 */ var MAX_MOVE = 2.0; var YAW_DEAD = 2.5; /* 朝向死区(度):残差小于此值就不动,避免反复点击累积偏移 */ /* 容差:点云是物体外壳,框需要外扩这么多再判覆盖 */ var SLACK = 0.5; /* 取点余量 */ var MARGIN = 0.06; /* 尺寸余量 */ /* ========================================================================== * 核心:单个对象的自动贴合 * ========================================================================*/ function fit(xyz, gz, obj, range, opts) { opts = opts || {}; var pre = PRESETS[opts.strength] || PRESETS.mid; var R0 = quatToMat(obj.pose_in_lidar.orientation); var base = obj.pose_in_lidar.location; var L0 = obj.length, W0 = obj.width, H0 = obj.height; var notes = []; /* ---- 1. 取候选点(局部坐标) ---- */ var N = xyz.length / 3, i, p, dd, l; var halfL = L0 / 2, halfW = W0 / 2; var zTop = base[2] + H0 + 0.6; var candL = []; for (i = 0; i < N; i++) { p = i * 3; var pz = xyz[p + 2]; if (pz < gz + 0.12 || pz > zTop) continue; dd = [xyz[p] - base[0], xyz[p + 1] - base[1], pz - base[2]]; l = toLocal(R0, dd); if (l[0] < -halfL - SLACK || l[0] > halfL + SLACK) continue; if (l[1] < -halfW - SLACK || l[1] > halfW + SLACK) continue; if (l[2] < -0.3) continue; candL.push(l); } if (candL.length < 15) return { ok: false, reason: "框周围点太少(" + candL.length + " 个),无法贴合" }; /* ---- 2. BEV 聚类,取「与当前框重叠最多」的簇 ---- */ var tmp = new Float32Array(candL.length * 3); for (i = 0; i < candL.length; i++) { tmp[i * 3] = candL[i][0]; tmp[i * 3 + 1] = candL[i][1]; tmp[i * 3 + 2] = candL[i][2]; } var clusters = allClusters(tmp, candL.map(function (v, k) { return k; }), 0.4); var best = null, bestIn = -1, ci, c, inCnt, o; for (ci = 0; ci < clusters.length; ci++) { c = clusters[ci]; inCnt = 0; for (o = 0; o < c.length; o++) { var ll = candL[c[o]]; if (Math.abs(ll[0]) <= halfL && Math.abs(ll[1]) <= halfW) inCnt++; } if (inCnt > bestIn) { bestIn = inCnt; best = c; } } if (!best || bestIn < 12) return { ok: false, reason: "框内点太少(" + Math.max(0, bestIn) + " 个),请先把框大致对准物体" }; if (best.length < candL.length) notes.push("剔除邻近点云 " + (candL.length - best.length) + " 个"); /* 覆盖度闸门挪到「朝向确定之后」再算 —— 因为物体没转正时, 外壳点云会斜着跑出框外,此时算覆盖度必然偏低(实测 84 只有 33%)。 */ /* ---- 4. 朝向:壳体贴合扫描(v5 换判据,理由见文件头) ---- 取「车身中上部」点:去掉地面与车顶以上的背景。这两类点都在外壳内部/外部, 会把「贴着矩形四边」这个判据带偏(实测 #84 含背景点时给 +11.3°,去掉后 +6.3°)。 */ var q0 = obj.pose_in_lidar.orientation; var cap = pre.yawCap, bestAng = 0, yawNote = ""; var yawDbg = { 判据: "未执行", 过滤点: 0, 扫描角: null, 对比度: null, 外壳盒: null }; if (cap > 0 && best.length >= 60) { var ysx = [], ysy = [], k, cB; for (k = 0; k < best.length; k++) { cB = candL[best[k]]; if (cB[2] >= 0.35 && cB[2] <= H0 * 0.95 + 0.2) { ysx.push(cB[0]); ysy.push(cB[1]); } } if (ysx.length < 60) { ysx.length = 0; ysy.length = 0; for (k = 0; k < best.length; k++) { ysx.push(candL[best[k]][0]); ysy.push(candL[best[k]][1]); } } /* 抽稀:判据是统计量,3000 点足够;公交这种 3.7 万点的目标不抽稀会拖慢全帧贴合 */ var MAXN = 3000, stepN = ysx.length > MAXN ? Math.ceil(ysx.length / MAXN) : 1; var px = [], py = [], t; for (t = 0; t < ysx.length; t += stepN) { px.push(ysx[t]); py.push(ysy[t]); } yawDbg.过滤点 = px.length; /* ⚠ cap 存的是弧度,扫描用度 —— 曾漏乘 180/π 导致扫描范围只有 ±0.44°,判据失效 */ var sh = scanShellYaw(px, py, cap * 180 / Math.PI); yawDbg.判据 = "壳体贴合"; yawDbg.扫描角 = +sh.delta.toFixed(2); yawDbg.对比度 = +sh.contrast.toFixed(3); yawDbg.外壳盒 = [+sh.bx.toFixed(2), +sh.by.toFixed(2)]; /* 闸门A|外壳必须「有宽度」:单面点云在 BEV 里是一条线,朝向有 0°/90° 二义性, 此时宁可不转(框保持不动),也不能赌一个方向把正框转歪 */ if (Math.min(sh.bx, sh.by) < 0.5 * Math.min(L0, W0)) { yawNote = "可见面太窄(外壳仅 " + sh.bx.toFixed(1) + "×" + sh.by.toFixed(1) + "m,不足以判朝向),未调整"; } else if (sh.contrast < 0.08) { /* 阈值从 0.15 降到 0.08。 实测 #113:算法算出的扫描角 -20.8° 方向完全正确(正确朝向差 -22.9°), 却因为对比度 0.133 < 0.15 被拒,框停在错误朝向上。 而正确朝向处的对比度只有 0.08(说明这个判据在「两侧都可见」的车身上 对比度天然偏低),0.15 的门槛把大量正常情况都挡掉了。 降到 0.08 后 #113 能正常调整;#122 那种残差 1.8° 的情况本来就在 YAW_DEAD 死区内,不受影响。 */ yawNote = "朝向证据严重不足(对比度 " + sh.contrast.toFixed(2) + "),未调整"; } else if (Math.abs(sh.delta) < YAW_DEAD) { yawNote = "朝向已基本正确(残差 " + sh.delta.toFixed(1) + "°),未调整"; } else { bestAng = clamp(sh.delta * Math.PI / 180, -cap, cap); yawNote = "壳体贴合 " + sh.delta.toFixed(1) + "°(对比度 " + sh.contrast.toFixed(2) + ",外壳 " + sh.bx.toFixed(1) + "×" + sh.by.toFixed(1) + "m)"; /* 对比度偏低又大幅调整时提醒一句:这种组合下判据可能不够可靠 */ if (sh.contrast < 0.15 && Math.abs(sh.delta) > 8) { yawNote += " ⚠ 对比度偏低且调整幅度大,建议核对朝向"; } } } else if (cap === 0) { yawNote = "强度=弱,未调朝向"; } var quat = yawQuat(q0, bestAng); var R = quatToMat(quat); if (Math.abs(bestAng) > 1e-6) notes.push("朝向调整 " + (bestAng * 180 / Math.PI).toFixed(1) + "°"); if (yawNote) notes.push(yawNote); /* ---- 5. 在最终朝向下重建局部坐标 ---- */ var lx = [], ly = [], idx = []; for (i = 0; i < best.length; i++) { l = toLocal(R, toWorld(R0, candL[best[i]])); lx.push(l[0]); ly.push(l[1]); idx.push(i); } var lxs = lx.slice().sort(function (a, b) { return a - b; }); var lys = ly.slice().sort(function (a, b) { return a - b; }); /* ---- 5.5 【闸门1】覆盖度(容差判定)---- 为什么必须带容差:激光雷达打到的点云是物体的「外壳」,框内部本来就是空的。 框只要偏几厘米或差几度,整层壳就落到框外 —— 严格判定会让覆盖度从 99% 掉到 33%(实测 84)。所以用「外扩 DILATE 的框」来判:只要物体基本落在框附近就算通过。 */ var covIn = 0; for (i = 0; i < best.length; i++) { if (Math.abs(lx[i]) <= halfL + DILATE && Math.abs(ly[i]) <= halfW + DILATE) covIn++; } var cover = covIn / best.length; if (cover < COVER_MIN) { return { ok: false, reason: "框没罩住物体(点簇仅 " + (cover * 100).toFixed(0) + "% 在框附近),贴合会越贴越偏,已拒绝。" + "请先把框大致挪到物体上" }; } /* ---- 6. 找可见面(v1 机制:车身中上部点的 ly 直方图峰值) ---- */ var topIdx = []; for (i = 0; i < best.length; i++) { var lz = toWorld(R0, candL[best[i]])[2] - base[2]; if (lz >= 0.35 && lz <= H0 * 0.92 + 0.2) topIdx.push(i); } if (topIdx.length < 10) for (i = 0; i < best.length; i++) topIdx.push(i); var lyTop = []; for (i = 0; i < topIdx.length; i++) lyTop.push(ly[topIdx[i]]); lyTop.sort(function (a, b) { return a - b; }); var bw = 0.12, hist = Object.create(null), pk = -1, pkC = -1; for (i = 0; i < lyTop.length; i++) { var kk = Math.round(lyTop[i] / bw); hist[kk] = (hist[kk] || 0) + 1; if (hist[kk] > pkC) { pkC = hist[kk]; pk = kk; } } var faceY = pk * bw; var planeIdx = []; for (i = 0; i < topIdx.length; i++) if (Math.abs(ly[topIdx[i]] - faceY) <= 0.16) planeIdx.push(topIdx[i]); if (planeIdx.length < 8) planeIdx = topIdx; /* 两侧可见性 */ var nPos = 0, nNeg = 0; for (i = 0; i < topIdx.length; i++) { if (ly[topIdx[i]] > 0.18 * W0) nPos++; else if (ly[topIdx[i]] < -0.18 * W0) nNeg++; } var twoSided = (nPos > 0.18 * topIdx.length && nNeg > 0.18 * topIdx.length); /* ---- 7. 尺寸 ---- */ /* 长度用「完整点簇」测(车长方向对车来说观测完整,比只用可见面点集更稳、无偏) */ var xlo = pct(lxs, 0.01), xhi = pct(lxs, 0.99); var obsL = xhi - xlo; var xc = (xhi + xlo) / 2; var Wm, faceSide; if (twoSided) { Wm = (pct(lys, 0.995) - pct(lys, 0.005)) + 2 * MARGIN; faceSide = 0; notes.push("两侧可见,车宽按点云测量"); } else { faceSide = faceY >= 0 ? 1 : -1; Wm = opts.stdWidth ? opts.stdWidth : W0; notes.push(opts.stdWidth ? "车宽采用同类别典型值 " + opts.stdWidth.toFixed(2) + "m" : "车宽沿用标注值(对面被车身遮挡,点云测不到)"); } /* 长度只增不减(观测值是真实长度的下界) */ var Lf = Math.max(L0, obsL + 2 * MARGIN); var Wf = Wm, Hf = H0, clamped = []; if (range) { if (range.length) { var Lc = clamp(Lf, range.length.min, range.length.max); if (Lc !== Lf) { clamped.push("L"); Lf = Lc; } } if (range.width) { var Wc = clamp(Wf, range.width.min, range.width.max); if (Wc !== Wf) { clamped.push("W"); Wf = Wc; } } if (range.height) Hf = clamp(H0, range.height.min, range.height.max); } if (Lf > L0 + 1.0) { Lf = L0 + 1.0; notes.push("长度增幅限幅 +1.0m"); } if (obsL + 2 * MARGIN <= L0) notes.push("长度未增长(实测 " + (obsL).toFixed(2) + " ≤ 标注 " + L0.toFixed(2) + ")"); /* ---- 7.5 只贴位置模式:尺寸一律冻结在标注原值 ---- */ var noResize = !!opts.noResize; if (noResize) { Lf = L0; Wf = W0; Hf = H0; clamped.length = 0; notes.push("只贴位置模式:尺寸保持不变(L/W/H 冻结)"); } /* ---- 8. 位置 ---- 宽方向锚点=「点簇在可见侧的显著外缘」(significantOuter): · 只认成片存在的表面 → 后视镜/天线这类零星外凸被自动丢弃,贴的是车体本身 · 用外缘而非密度峰值 → 框永远朝点云方向移动,不会「越贴越偏」 */ var ylo = pct(lys, 0.005), yhi = pct(lys, 0.995); var lySortedTop = lyTop.slice().sort(function (a, b) { return a - b; }); var faceAnchor = significantOuter(lySortedTop, faceSide || 1, 0.05); var yc; if (twoSided) { yc = (significantOuter(lySortedTop, 1, 0.05) + significantOuter(lySortedTop, -1, 0.05)) / 2; } else if (faceSide > 0) { yc = (faceAnchor + 0.02) - Wf / 2; } else { yc = (faceAnchor - 0.02) + Wf / 2; } /* ---- 8.5 车长方向:框多出来的长度全部放到「远离雷达」的一侧 ---- 物理依据:雷达看远处物体总是「近侧完整、远侧缺失或稀疏」,远端的长度本来 就是外推出来的。若把这部分余量平均分到两端,靠雷达那一端就会留出空隙 (实测 #119:观测 4.08m / 车长 4.52m,居中后两端各空 0.22m, 用户看到的就是「屁股那侧有空隙、车头那侧像被切」)。 所以只要框比点云长,就让近端面贴住观测面,余量全部丢到远端。 反过来,点云比框长时(框盖不住)仍用居中,让两端缺失尽量平均。 */ var extra = Lf - obsL; if (extra > 0.10) { var orgLoc = toLocal(R0, [-base[0], -base[1], -base[2]]); var ns = orgLoc[0] > 0 ? 1 : -1; /* 靠雷达那一端的方向 */ xc = ns > 0 ? (xhi - Lf / 2) : (xlo + Lf / 2); notes.push("车长余量 " + extra.toFixed(2) + "m 全部放到远离雷达的一侧(观测 " + obsL.toFixed(2) + "m / 车长 " + Lf.toFixed(2) + "m),近端面贴住点云"); } /* ---- 8.6 位移上限:不许跑到别的车上 ---- */ var moveDist = Math.sqrt(xc * xc + yc * yc); if (moveDist > MAX_MOVE) { var mk = MAX_MOVE / moveDist; xc *= mk; yc *= mk; notes.push("位移已夹紧到 " + MAX_MOVE.toFixed(1) + "m(原拟移动 " + moveDist.toFixed(2) + "m,避免贴到邻近物体上)"); } /* ---- 9. 包含性微调(有界;只贴位置模式下完全跳过) ---- 注意:不做「必须包住全部点」的强制扩张 —— 那会把邻近物体和路面一起吃进来 导致尺寸膨胀。这里只处理「成片凸出」的情况,零星凸出(后视镜/天线)不管。 */ var outside = 0, ex = 0, ey = 0; for (i = 0; i < best.length; i++) { var dx = Math.abs(lx[i] - xc) - Lf / 2, dy = Math.abs(ly[i] - yc) - Wf / 2; if (dx > 0 || dy > 0) { outside++; if (dx > ex) ex = dx; if (dy > ey) ey = dy; } } var blobOutside = outside > 0.08 * best.length; /* 成片凸出而非零星 */ if (!noResize && blobOutside) { if (ex > 0.12) Lf += Math.min(2 * ex, 0.30); if (ey > 0.12) Wf += Math.min(2 * ey, 0.20); if (range && range.length) Lf = clamp(Lf, range.length.min, range.length.max); if (range && range.width) Wf = clamp(Wf, range.width.min, range.width.max); } /* ---- 10. 【闸门2】贴合后框必须仍然「罩住」点簇 ---- ⚠ 原判据是「贴合后框内点数不得少于贴合前」,但和第 8.5 节「车长余量放远端」 直接冲突:框往远端挪,近端密集的点就跑出去,于是明明贴合正确却被拒 (用户反馈「框里已经很多点云了还提示请先把框大致对上物体」)。 改成看「贴合后还有多少比例的点簇落在框附近」:贴合正确时这个比例很高, 只有框真跑到空地上才会掉下来。 */ var inNewDil = 0, inNewStrict = 0; for (i = 0; i < best.length; i++) { var ddx = Math.abs(lx[i] - xc), ddy = Math.abs(ly[i] - yc); if (ddx <= Lf / 2 && ddy <= Wf / 2) inNewStrict++; if (ddx <= Lf / 2 + DILATE && ddy <= Wf / 2 + DILATE) inNewDil++; } var inNew = inNewDil, inOld = covIn; var newRatio = best.length ? inNew / best.length : 0; var oldRatio = best.length ? inOld / best.length : 0; var tol = noResize ? Math.max(5, 0.08 * best.length) : Math.max(5, 0.06 * best.length); /* 两个条件同时成立才拒绝:① 点数确实减少 ② 而且覆盖度掉到下限以下 */ if (inNew < inOld - tol && newRatio < COVER_MIN) { return { ok: false, reason: "贴合会让框偏离点云(框附近点占比 " + (oldRatio * 100).toFixed(0) + "% → " + (newRatio * 100).toFixed(0) + "%),已拒绝。请先把框大致对上物体", dbg: { 点簇数: best.length, 覆盖度: +(cover * 100).toFixed(1), 原L0W0: [+L0.toFixed(3), +W0.toFixed(3)], 拟采用: [+Lf.toFixed(3), +Wf.toFixed(3)], 拟中心偏移: [+xc.toFixed(3), +yc.toFixed(3)], 可见面ly: +faceY.toFixed(3), 显著锚点: +faceAnchor.toFixed(3), 两侧可见: twoSided, 观测长: +obsL.toFixed(3), 簇lx范围: [+pct(lxs, 0.01).toFixed(3), +pct(lxs, 0.99).toFixed(3)], 簇ly范围: [+pct(lys, 0.01).toFixed(3), +pct(lys, 0.99).toFixed(3)] } }; } var inRatio = inNew / best.length; if (!noResize && inRatio < 0.90) { return { ok: false, reason: "贴合后仍有 " + ((1 - inRatio) * 100).toFixed(0) + "% 的簇点落在框外,已拒绝以免贴偏" }; } if (outside && blobOutside && !noResize) notes.push("已按成片凸出微调尺寸,簇内点包含率 " + (inRatio * 100).toFixed(1) + "%"); if (outside && !blobOutside) notes.push("框外零星点 " + outside + " 个(视为后视镜/天线等凸出,不纳入)"); /* ---- 11. 底面高度:默认不动;明显离地/陷地才纠正 ---- */ var zb = base[2]; if (Math.abs(base[2] - gz) > 0.35) { zb = gz + 0.02; notes.push("底面高度纠正到地面 " + zb.toFixed(2)); } /* ---- 12. 组装 + 位移保护 ---- */ var locNew = [ base[0] + toWorld(R, [xc, 0, 0])[0] + toWorld(R, [0, yc, 0])[0], base[1] + toWorld(R, [xc, 0, 0])[1] + toWorld(R, [0, yc, 0])[1], zb ]; var shift = Math.sqrt(Math.pow(locNew[0] - base[0], 2) + Math.pow(locNew[1] - base[1], 2)); if (shift > 1.2) return { ok: false, reason: "贴合后中心位移 " + shift.toFixed(2) + "m 过大,判定异常已放弃" }; /* 车长方向两端的「余量」诊断:正数 = 框超出点云(视觉上就是空隙), 负数 = 框没盖住点云(把车头/车尾切掉)。用来判断偏移方向是否合理。 */ var orgLx = toLocal(R0, [-base[0], -base[1], -base[2]])[0]; var nearSign = orgLx > 0 ? 1 : -1; /* 靠雷达那一端的方向 */ var boxLo = xc - Lf / 2, boxHi = xc + Lf / 2; var nearGap = nearSign > 0 ? (boxHi - xhi) : (xlo - boxLo); var farGap = nearSign > 0 ? (xlo - boxLo) : (boxHi - xhi); return { ok: true, corners: cornersOf(locNew, quat, Lf, Wf, Hf), L: Lf, W: Wf, H: Hf, loc: locNew, quat: quat, notes: notes, diag: { 点簇数: best.length, 框内点_前: inOld, 框内点_后: inNew, 严格框内_后: inNewStrict, 覆盖度: +(cover * 100).toFixed(1), 包含率: +(inRatio * 100).toFixed(1), 观测长: +obsL.toFixed(3), 可见面ly: +faceY.toFixed(3), 显著锚点: +faceAnchor.toFixed(3), 两侧可见: twoSided, 只贴位置: noResize, 朝向判据: yawDbg.判据, 朝向扫描角: yawDbg.扫描角, 朝向对比度: yawDbg.对比度, 外壳盒: yawDbg.外壳盒, 朝向点数: yawDbg.过滤点, 采用长: +Lf.toFixed(3), 采用宽: +Wf.toFixed(3), 采用高: +Hf.toFixed(3), 朝向调整度: +(bestAng * 180 / Math.PI).toFixed(2), 中心位移: +shift.toFixed(3), 底面z: +zb.toFixed(3), 夹紧轴: clamped.join("") || "无", 点云区间: [+xlo.toFixed(3), +xhi.toFixed(3)], 框中心x: +xc.toFixed(3), 靠雷达端: nearSign > 0 ? "+x" : "-x", 近端余量: +nearGap.toFixed(3), 远端余量: +farGap.toFixed(3) } }; } /* ================================================================ 质检规则(对齐客户工具 cuboid-valid-check_v3) 来源:config/dimension_ranges.yaml + cuboidcheck/checks/{ratio,validation}.py 只搬「单帧可判」的规则;整包前后帧对比那类(set_not_changed)不属于单帧工具。 ================================================================ */ /* 各 sub_class 的尺寸允许范围(米),抄自客户 dimension_ranges.yaml。 格式 [宽min,宽max, 高min,高max, 长min,长max];缺某轴则该轴不检查。 */ var DIM_RANGES = { /* 车辆 */ UNKNOWN_VEHICLE: [1.4, 2.8, 1.2, 3.0, 3.3, 5.0], SEDAN: [1.0, 3.0, 1.0, 3.0, 2.5, 5.2], SUV: [1.0, 3.0, 1.0, 3.0, 2.2, 6.2], VAN: [1.0, 3.0, 1.0, 3.0, 2.9, 7.0], COMPACT: [1.0, 3.0, 1.5, 1.72, 2.6, 4.2], TRUCK: [1.6, 3.0, 1.9, 3.8, 2.5, 12.0], BUS: [2.0, 3.0, 2.5, 4.0, 3.3, 14.0], SPECIAL: [2.0, 3.0, 2.5, 5.0, 3.3, 20.0], /* 人 */ UNKNOWN_PERSON: [0.2, 1.5, 0.7, 2.2, 0.2, 1.5], SITTING: [0.2, 1.5, 0.7, 2.2, 0.2, 1.5], ADULT_PEDESTRIAN: [0.3, 1.5, 1.5, 2.2, 0.3, 1.5], CHILD_PEDESTRIAN: [0.3, 1.5, 1.0, 1.6, 0.3, 1.5], MOTORCYCLE_RIDER: [0.3, 1.5, 0.6, 2.2, 0.3, 1.5], BICYCLE_RIDER: [0.3, 1.5, 0.6, 2.2, 0.3, 1.5], RIDER_CYCLE: [0.3, 1.5, 0.6, 2.2, 0.3, 1.5], MOBILITY_RIDER: [0.3, 1.5, 0.6, 2.2, 0.3, 1.5], /* 两轮车 */ UNKNOWN_CYCLE: [0.3, 1.3, 0.5, 2.0, 0.3, 3.0], MOTORCYCLE: [0.6, 1.3, 0.6, 1.3, 1.4, 2.8], BICYCLE: [0.3, 1.3, 0.6, 1.3, 1.2, 2.2], /* 个人移动装置 */ UNKNOWN_MOBILITY: [0.2, 1.1, 0.4, 1.5, 0.7, 1.5], KICK_SCOOTER: [0.2, 0.7, 0.4, 1.3, 0.7, 1.5], WHEELCHAIR: [0.5, 1.1, 0.6, 1.5, 0.7, 1.4], /* 购物车 */ UNKNOWN_CART: [0.3, 1.8, 0.3, 1.5, 0.3, 2.2], SHOPPING_SINGLE: [0.6, 1.0, 0.9, 1.4, 0.9, 1.5], SINGLE: [0.6, 1.0, 0.9, 1.4, 0.9, 1.5], SHOPPING_STACKED: [0.6, 1.0, 0.9, 1.4, null, null], STACKED: [0.6, 1.0, 0.9, 1.4, null, null], /* 停车障碍物 */ UNKNOWN_PARKING_OBSTACLE: [null, null, null, null, 0.1, 2.5], CONE: [0.25, 0.45, 0.2, 1.0, 0.25, 0.45], BOLLARD: [0.1, 0.29, 0.2, 1.2, 0.1, 0.29], BOLLARD_U: [0.5, 1.7, 0.3, 1.0, 0.1, 0.3], NO_PARKING_SIGN: [0.25, 0.6, 0.5, 1.4, 0.25, 0.6], PILLAR: [null, null, 2.0, 100.0, null, null], PILLAR_CIRCLE: [null, null, 2.0, 100.0, null, null], STOPPER: [0.6, 0.8, 0.1, 0.249, null, null], SPEED_BUMP: [1.0, 10.0, 0.1, 0.25, 0.9, 4.0], BARRIER_GATE: [0.3, 4.0, 1.0, 5.0, 0.6, 3.0], PARKING_LOCKER: [0.4, 0.6, 0.1, 0.3, 0.1, 0.4], PARKING_GATE_BODY: [0.2, 1.1, 0.8, 2.0, 0.2, 0.8], OPEN_BAR: [0.2, 2.0, 2.0, 4.5, 0.1, 0.6], CLOSE_BAR: [0.2, 2.0, 2.0, 4.5, 0.1, 0.6], MOVING_BAR: [0.2, 2.0, 2.0, 4.5, 0.1, 0.6], STOPPER_LONG: [0.6, 2.2, 0.1, 0.249, null, null] }; /* 同一类别被写成别的名字 → 归一到上表 */ var DIM_ALIASES = { NO_PARK_SIGN: "NO_PARKING_SIGN", SPEEDBUMP: "SPEED_BUMP" }; /* 可见性:至少一路相机有 bbox(客户工具 common.is_visible) */ function isVisible(obj) { var cams = obj.cameras; if (!cams || !cams.length) return false; for (var i = 0; i < cams.length; i++) { if (cams[i].bbox !== null && cams[i].bbox !== undefined) return true; } return false; } /* 超出范围百分之多少(负=偏小,正=偏大),对齐客户工具 calc_out_of_range_percent */ function outPct(v, mn, mx) { if (v < mn) { var b = mn !== 0 ? mn : 1; return -((mn - v) / b * 100); } if (v > mx) { var b2 = mx !== 0 ? mx : 1; return (v - mx) / b2 * 100; } return 0; } /* 尺寸超范围检查。只查「可见对象」,与客户工具一致(不可见的不判)。 */ function checkDimension(obj) { var sub = String(obj.sub_class || "").trim().toUpperCase(); sub = DIM_ALIASES[sub] || sub; var r = DIM_RANGES[sub]; if (!r) return null; /* 范围表里没有 → 交给类别有效性检查 */ if (!isVisible(obj)) return null; var axes = [["width", obj.width, r[0], r[1]], ["height", obj.height, r[2], r[3]], ["length", obj.length, r[4], r[5]]]; var bad = [], i, n, v, mn, mx; for (i = 0; i < axes.length; i++) { n = axes[i][0]; v = axes[i][1]; mn = axes[i][2]; mx = axes[i][3]; if (mn === null || mx === null || typeof v !== "number") continue; if (v < mn || v > mx) bad.push({ dim: n, value: v, min: mn, max: mx, pct: outPct(v, mn, mx) }); } return bad.length ? bad : null; } /* 欧拉角 → 旋转矩阵(行主序 9 元素)。 对齐 scipy Rotation.from_euler("XYZ", ...) 的 intrinsic 约定:R = Rx(a)·Ry(b)·Rz(c), 即客户工具 get_cuboid_points 里用的 pose_in_lidar_target.orientation 解析方式。 */ function eulerXYZToMat(o) { var a = o[0], b = o[1], c = o[2]; var ca = Math.cos(a), sa = Math.sin(a); var cb = Math.cos(b), sb = Math.sin(b); var cc = Math.cos(c), sc = Math.sin(c); return [ cb * cc, -cb * sc, sb, ca * sc + sa * sb * cc, ca * cc - sa * sb * sc, -sa * cb, sa * sc - ca * sb * cc, sa * cc + ca * sb * sc, ca * cb ]; } /* 由位姿+尺寸生成 cuboid 8 角点(对齐客户工具 get_cuboid_points: orientation 取 pose_in_lidar_target 的欧拉角,location 为底面中心,角点 z 从 0 到 height) */ function cuboidPointsFromTarget(obj) { var t = obj.pose_in_lidar_target; if (!t || !t.orientation || !t.location) return null; var R = eulerXYZToMat(t.orientation); var hl = obj.length / 2, hw = obj.width / 2, hh = obj.height; var local = [ [-hl, -hw, hh], [hl, -hw, hh], [hl, hw, hh], [-hl, hw, hh], [-hl, -hw, 0], [hl, -hw, 0], [hl, hw, 0], [-hl, hw, 0] ]; var loc = t.location, out = [], i, v; for (i = 0; i < 8; i++) { v = local[i]; out.push([ R[0] * v[0] + R[1] * v[1] + R[2] * v[2] + loc[0], R[3] * v[0] + R[4] * v[1] + R[5] * v[2] + loc[1], R[6] * v[0] + R[7] * v[1] + R[8] * v[2] + loc[2] ]); } return out; } /* 角点 → 相机坐标系(客户工具 get_img_cuboid_points / project_cuboid_uv 的前半段) */ function toCamera(pts, calib) { var rot = calib.lidar_to_camera.rotation, tr = calib.lidar_to_camera.translation; var out = [], i, p; for (i = 0; i < pts.length; i++) { p = pts[i]; out.push([ rot[0] * p[0] + rot[1] * p[1] + rot[2] * p[2] + tr[0], rot[3] * p[0] + rot[4] * p[1] + rot[5] * p[2] + tr[1], rot[6] * p[0] + rot[7] * p[1] + rot[8] * p[2] + tr[2] ]); } return out; } /* 等距鱼眼投影(对齐客户工具 project_cuboid_uv) r = hypot(xc,yc); theta = atan2(r,zc) r_d = K1·θ + K2·θ³ + K3·θ⁵ + K4·θ⁷ + K5·θ⁹ u = U0 + Mu·r_d·xc/r ; v = V0 + Mv·r_d·yc/r */ function projectFisheye(pts, calib) { var cam = toCamera(pts, calib); var intr = calib.intrinsic, d = intr.distortion; var u = [], v = [], zc = [], i, xc, yc, z, r, th, t2, rd, sr; for (i = 0; i < cam.length; i++) { xc = cam[i][0]; yc = cam[i][1]; z = cam[i][2]; r = Math.sqrt(xc * xc + yc * yc); th = Math.atan2(r, z); t2 = th * th; rd = th * (d.K1 + t2 * (d.K2 + t2 * (d.K3 + t2 * (d.K4 + t2 * d.K5)))); sr = r < 1e-9 ? 1e-9 : r; u.push(intr.U0 + intr.Mu * rd * (xc / sr)); v.push(intr.V0 + intr.Mv * rd * (yc / sr)); zc.push(z); } var W, H; if (calib.src_size) { W = calib.src_size[0]; H = calib.src_size[1]; } else { W = Math.round(intr.U0 * 2); H = Math.round(intr.V0 * 2); } return { u: u, v: v, zc: zc, W: W, H: H }; } /* 图像边缘内缩这么多像素才算「没贴边」(客户工具 TRUNC_EDGE_MARGIN) */ var TRUNC_EDGE_MARGIN = 200; /* truncation 一致性检查(对齐客户工具 truncation_error_check): ① 有 4 个以上角点落在相机后方(z<-2) 却标 truncation=0 → 漏标 ② 8 角点全在画面内(距四边 ≥200px) 却标 truncation≠0 → 错标 */ function checkTruncation(obj, calib) { if (!calib) return null; var pts = cuboidPointsFromTarget(obj); if (!pts) return null; var issues = [], cams = obj.cameras || [], i, k, cam, c, behind, pr, m, inside; for (i = 0; i < cams.length; i++) { cam = cams[i]; if (cam.bbox === null || cam.bbox === undefined) continue; c = calib[cam.name]; if (!c) continue; var camPts = toCamera(pts, c); behind = 0; for (k = 0; k < 8; k++) if (camPts[k][2] < -2) behind++; if (behind > 3 && cam.truncation === 0) { issues.push({ cam: cam.name, kind: "is_tc", detail: behind + " 个角点在相机后方,截断却填了 0 → 应改为非 0" }); continue; } if (behind < 3 && cam.truncation !== 0) { pr = projectFisheye(pts, c); m = TRUNC_EDGE_MARGIN; inside = true; for (k = 0; k < 8; k++) { if (!(pr.zc[k] > 0) || pr.u[k] < m || pr.u[k] > pr.W - m || pr.v[k] < m || pr.v[k] > pr.H - m) { inside = false; break; } } if (inside) { /* 客户工具是 tc != 0 判定,Python 里 None != 0 为真,所以「未填」也会命中。 保持这个行为(漏报比误报更麻烦),但把文案区分为「该填 0」的两种情形。 */ issues.push({ cam: cam.name, kind: "is_not_tc", detail: "8 角点全在画面内(距边 ≥200px),截断" + (cam.truncation === null || cam.truncation === undefined ? "未填 → 应填 0" : "填了 " + cam.truncation + " → 应改为 0") }); } } } return issues.length ? issues : null; } /* 有 bbox 但遮挡/截断没填(客户工具 check_tc_oc_null) */ function checkNulls(obj) { var out = [], cams = obj.cameras || [], i, c, nm; for (i = 0; i < cams.length; i++) { c = cams[i]; nm = c.name; if (nm !== "front" && nm !== "rear" && nm !== "left" && nm !== "right") continue; if (c.bbox === null || c.bbox === undefined) continue; if (c.occlusion === null || c.occlusion === undefined) out.push({ cam: nm, kind: "oc_null" }); if (c.truncation === null || c.truncation === undefined) out.push({ cam: nm, kind: "tc_null" }); } return out.length ? out : null; } /* 对一帧的对象列表跑全部单帧规则,返回 [{tid, sub, cat, issues:[...]}] */ function inspectFrame(objects, calib, categoryMap) { var out = [], i, a, b, o, item, cat, sub, nz, dim, tr; for (i = 0; i < objects.length; i++) { o = objects[i]; item = { tid: o.tracking_id, sub: o.sub_class, cat: o.category, issues: [] }; /* 类别有效性(客户工具 check_category_subclass) */ cat = String(o.category || "").toUpperCase(); sub = String(o.sub_class || "").toUpperCase(); if (o.category === null || o.category === undefined || o.sub_class === null || o.sub_class === undefined) { item.issues.push({ kind: "unspecified_class", cam: "", detail: "类别未指定(平台会阻止切帧/保存)" }); } else if (categoryMap && Object.keys(categoryMap).length) { if (!categoryMap[cat]) item.issues.push({ kind: "invalid_category", cam: "", detail: cat }); else if (categoryMap[cat].indexOf(sub) < 0) { item.issues.push({ kind: "invalid_subclass", cam: "", detail: cat + " / " + sub }); } } /* 遮挡/截断空值 */ nz = checkNulls(o); if (nz) for (a = 0; a < nz.length; a++) { item.issues.push({ kind: nz[a].kind, cam: nz[a].cam, detail: "有 bbox 但未填" }); } /* 尺寸超范围 */ dim = checkDimension(o); if (dim) for (b = 0; b < dim.length; b++) { item.issues.push({ kind: "dim_out_of_range", cam: "", detail: dim[b].dim + "=" + dim[b].value.toFixed(2) + " 超出 [" + dim[b].min + ", " + dim[b].max + "] " + dim[b].pct.toFixed(1) + "%" }); } /* truncation 一致性 */ tr = checkTruncation(o, calib); if (tr) for (a = 0; a < tr.length; a++) { item.issues.push({ kind: "Truncation_error_" + tr[a].kind, cam: tr[a].cam, detail: tr[a].detail }); } if (item.issues.length) out.push(item); } return out; } return { quatToMat: quatToMat, toLocal: toLocal, toWorld: toWorld, yawQuat: yawQuat, yawOf: yawOf, cornersOf: cornersOf, parsePCD: parsePCD, denseXYZ: denseXYZ, estimateGround: estimateGround, pct: pct, clamp: clamp, allClusters: allClusters, PRESETS: PRESETS, fit: fit, COVER_MIN: COVER_MIN, /* 一键新增(ROI 内批量建框) */ clusterVehicles: clusterVehicles, obbOfCluster: obbOfCluster, allClustersR: allClustersR, inferSubClass: inferSubClass, ADD_OPT: ADD_OPT, SUB_RANGES: SUB_RANGES, roiOverlap: roiOverlap, /* 质检规则(对齐客户工具 cuboid-valid-check_v3) */ inspectFrame: inspectFrame, checkDimension: checkDimension, checkTruncation: checkTruncation, checkNulls: checkNulls, isVisible: isVisible, projectFisheye: projectFisheye, cuboidPointsFromTarget: cuboidPointsFromTarget, eulerXYZToMat: eulerXYZToMat, DIM_RANGES: DIM_RANGES, DIM_ALIASES: DIM_ALIASES, TRUNC_EDGE_MARGIN: TRUNC_EDGE_MARGIN }; })(); /* ======================== 平台接入 ======================== */ var CACHE = { main: null, scene: null, ranges: null, t: 0 }; function findStores(force) { var now = Date.now(); if (!force && CACHE.main && CACHE.scene && now - CACHE.t < 400) return CACHE; var rootEl = document.getElementById("root") || document.body; var key = Object.keys(rootEl).filter(function (k) { return k.indexOf("__reactContainer$") === 0 || k.indexOf("__reactFiber$") === 0; })[0]; if (!key) return CACHE; var fiber = rootEl[key]; while (fiber && fiber.return) fiber = fiber.return; var main = null, scene = null, ranges = CACHE.ranges, n = 0; (function walk(f, d) { if (!f || n > 120000 || d > 260) return; n++; var h = f.memoizedState, g = 0; while (h && g < 90) { g++; var s = h.memoizedState; if (s && typeof s === "object" && !Array.isArray(s)) { if ("hasDataset" in s) main = s; if (s.annotationStore && s.selectionStore) scene = s; /* 类别标准尺寸范围表(形如 {sedan:{width:{min,max},...}}) */ if (!ranges && s.sedan && typeof s.sedan === "object" && s.sedan.length) ranges = s; } h = h.next; } walk(f.child, d + 1); walk(f.sibling, d); })(fiber, 0); if (main && scene) CACHE = { main: main, scene: scene, ranges: ranges, t: now }; return CACHE; } /* 场景框索引:tracking_id → shape */ function shapeMap(scene) { var all = scene.annotationStore.getAll(), m = Object.create(null); for (var i = 0; i < all.length; i++) { var t = all[i].meta ? all[i].meta.tracking_id : null; if (t !== null && t !== undefined) m[t] = all[i]; } return m; } function selectedTrackingId(scene) { var ids = scene.selectionStore.getSelectedIds(); for (var i = 0; i < ids.length; i++) { var sh = scene.annotationStore.getById(ids[i]); if (sh && sh.meta && sh.meta.tracking_id !== undefined) return sh.meta.tracking_id; } return null; } /* ======================== 计算(不写入) ======================== */ var STATE = { xyz: null, gz: 0, pcdKey: null }; function ensureCloud(main) { var tag = main.currentIndex + "|" + main.pcdFileName; if (STATE.xyz && STATE.pcdKey === tag) return STATE; if (!main.pcdBuffer) throw new Error("点云尚未加载(pcdBuffer 为空)"); var pcd = AdoFit.parsePCD(main.pcdBuffer); if (!pcd) throw new Error("PCD 解析失败(可能不是 binary 格式)"); var xyz = AdoFit.denseXYZ(pcd); STATE = { xyz: xyz, gz: AdoFit.estimateGround(xyz), pcdKey: tag }; return STATE; } function planFor(objs, strength) { var st = findStores(true), main = st.main, scene = st.scene; if (!main || !scene) throw new Error("读不到平台状态(页面可能还在加载)"); if (!main.hasDataset) throw new Error("尚未载入序列"); var cloud = ensureCloud(main); var smap = shapeMap(scene); var out = []; for (var i = 0; i < objs.length; i++) { var o = objs[i]; var sh = smap[o.tracking_id]; if (!sh) { out.push({ obj: o, ok: false, reason: "场景中找不到该对象" }); continue; } if (!sh.points || sh.points.length !== 8) { out.push({ obj: o, ok: false, reason: "不是立方体框" }); continue; } var rng = st.ranges ? (st.ranges[o.sub_class] || null) : null; var stdW = USE_STD_W ? (STD_W[o.sub_class] || null) : null; var r; try { r = AdoFit.fit(cloud.xyz, cloud.gz, o, rng, { strength: strength, stdWidth: stdW, noResize: NO_RESIZE }); } catch (e) { out.push({ obj: o, ok: false, reason: "计算异常:" + e.message }); continue; } if (!r.ok) { out.push({ obj: o, ok: false, reason: r.reason }); continue; } out.push({ obj: o, ok: true, shape: sh, plan: r, before: { points: sh.points.map(function (p) { return p.slice(); }) } }); } return out; } /* ======================== 写入 ======================== */ var HISTORY = []; function applyPlans(items) { var st = findStores(true); if (!st.scene) throw new Error("读不到场景"); var storeRef = st.scene.annotationStore; var done = []; for (var i = 0; i < items.length; i++) { var it = items[i]; if (!it.ok) continue; try { var sh = storeRef.getById(it.shape.id) || it.shape; storeRef.updatePoints(sh.id, it.plan.corners); /* 平台随即自动重算全部派生字段 */ done.push(it); } catch (e) { it.ok = false; it.reason = "写入失败:" + e.message; } } if (done.length) { HISTORY.push(done.map(function (it) { return { id: it.shape.id, points: it.before.points, tid: it.obj.tracking_id }; })); } return done.length; } function undo() { if (!HISTORY.length) { toast("没有可撤销的操作", "orange"); return; } var batch = HISTORY.pop(); var st = findStores(true), n = 0; for (var i = 0; i < batch.length; i++) { try { st.scene.annotationStore.updatePoints(batch[i].id, batch[i].points); n++; } catch (e) { } } toast("已撤销 " + n + " 个对象的贴合", "green"); } /* ======================== UI ======================== */ var COLORS = { green: "#16a34a", orange: "#ea580c", red: "#dc2626", gray: "#475569" }; function toast(msg, color) { tipline(msg, color); } function tipline(msg, color) { var el = document.getElementById("ado-fit-tip"); if (!el) return; el.textContent = msg; el.style.color = COLORS[color || "gray"]; el.style.opacity = "1"; clearTimeout(el.__t); el.__t = setTimeout(function () { el.style.opacity = "0"; }, 6000); } /* 确认弹窗:列出改动清单 */ function confirmDialog(items, onOk) { var old = document.getElementById("ado-fit-modal"); if (old) old.remove(); var box = document.createElement("div"); box.id = "ado-fit-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; var rows = items.map(function (it) { if (!it.ok) { return "#" + it.obj.tracking_id + "" + "跳过:" + it.reason + ""; } var d = it.plan.diag, o = it.obj; return "" + "#" + o.tracking_id + " " + (o.sub_class || "") + "" + "" + o.length.toFixed(2) + "/" + o.width.toFixed(2) + "/" + o.height.toFixed(2) + "" + "" + it.plan.L.toFixed(2) + "/" + it.plan.W.toFixed(2) + "/" + it.plan.H.toFixed(2) + "" + "位移 " + d.中心位移.toFixed(2) + "m / 含 " + d.包含率 + "%"; }).join(""); var okN = items.filter(function (i) { return i.ok; }).length; box.innerHTML = "
" + "
全帧自动贴合确认
" + "
共 " + items.length + " 个对象,将贴合 " + okN + " 个。" + "只改几何,不动类别与 T/O 属性;写入后可用「撤销」还原。
" + "" + "" + "" + "" + rows + "
对象原 L/W/H贴合后 L/W/H中心位移
" + "
" + "" + "" + "
"; document.body.appendChild(box); function close() { box.remove(); } box.querySelector("#ado-fit-cancel").onclick = close; box.querySelector("#ado-fit-ok").onclick = function () { close(); onOk(); }; box.addEventListener("click", function (e) { if (e.target === box) close(); }); } var STRENGTH = "mid"; /* 平台内置标准尺寸预设(源码常量,快捷键 1-9 用的就是这套) 用途:单侧可见时车宽在物理上不可测,可用同类别典型值补齐。默认关闭。 */ var STD_W = { sedan: 1.8, compact: 1.8, suv: 1.9, van: 2.0, truck: 2.2, bus: 2.2, special: 2.0, unknown_vehicle: 1.9 }; var USE_STD_W = false; var NO_RESIZE = true; /* 默认开:只贴位置,不改尺寸 */ function runSelected() { try { var st = findStores(true); var tid = selectedTrackingId(st.scene); if (tid === null) { toast("请先在 3D 视图或对象列表中选中一个框", "orange"); return; } var objs = st.main.annotation.object_list.filter(function (o) { return o.tracking_id === tid; }); if (!objs.length) { toast("选中的对象不在本帧列表中", "orange"); return; } var plan = planFor(objs, STRENGTH); var it = plan[0]; if (!it.ok) { toast("无法贴合 #" + tid + ":" + it.reason, "red"); return; } applyPlans([it]); var d = it.plan.diag; toast("#" + tid + " 已贴合(位移 " + d.中心位移.toFixed(2) + "m" + (Math.abs(d.朝向调整度) > 0.05 ? ",朝向 " + d.朝向调整度.toFixed(1) + "°" : "") + ")", "green"); } catch (e) { toast(e.message, "red"); } } function runAll() { try { var st = findStores(true); var objs = st.main.annotation.object_list.slice(); if (!objs.length) { toast("本帧没有对象", "orange"); return; } var plan = planFor(objs, STRENGTH); confirmDialog(plan, function () { var n = applyPlans(plan); toast("全帧贴合完成:处理 " + n + " 个对象", "green"); }); } catch (e) { toast(e.message, "red"); } } /* ======================== 一键新增(ROI 内批量建框) ======================== */ /* 读当前生效的 ROI。预设(如 PJT64_Cuboid)里 front/rear 是 x 轴、left/right 是 y 轴, 平台已换算成 boundary:{x:[min,max], y:[min,max]},直接取用。 */ function readRoi(scene) { try { var snap = scene.roiStore.getSnapshot(); var b = snap && snap.boundary; if (!b || !b.x || !b.y) return null; return { xMin: b.x[0], xMax: b.x[1], yMin: b.y[0], yMax: b.y[1] }; } catch (e) { return null; } } /* 下一个可用 tracking_id:复刻平台的 fx(fd(shapes) ∪ sequenceUsedTrackingIds), 即「现有最大值 + 1」,同时避开整个序列用过的号。 */ function nextTrackingId(main, scene) { var mx = 0, i, t, m, all = scene.annotationStore.getAll(), used = main.sequenceUsedTrackingIds; if (used && used.forEach) used.forEach(function (v) { if (v > mx) mx = v; }); for (i = 0; i < all.length; i++) { t = all[i].meta ? all[i].meta.tracking_id : null; if (t === null || t === undefined) { m = /^ado-(\d+)$/.exec(all[i].id); t = m ? +m[1] : null; } if (t !== null && t !== undefined && t > mx) mx = t; } return mx + 1; } function runAddAll() { try { var st = findStores(true), main = st.main, scene = st.scene; if (!main || !scene) throw new Error("读不到平台状态(页面可能还在加载)"); if (!main.hasDataset) throw new Error("尚未载入序列"); /* 前置硬闸门:一键新增是「从零建帧」,本帧必须没有任何框,否则会重复创建 */ var cur = main.annotation.object_list || []; if (cur.length) { var ids = cur.map(function (o) { return "#" + o.tracking_id; }).join(" "); toast("当前帧已有 " + cur.length + " 个框(" + ids + "),一键新增要求空帧,请先清空本帧", "red"); return; } var roi = readRoi(scene); if (!roi) { toast("读不到 ROI 范围(请先在左侧 ROI 下拉里选一个)", "red"); return; } var cloud = ensureCloud(main); var res = AdoFit.clusterVehicles(cloud.xyz, cloud.gz, roi, AdoFit.ADD_OPT); if (!res.boxes.length) { toast("ROI 内没找到符合条件的车辆(原始簇 " + res.clusters + ",过滤后 0 个)", "orange"); return; } confirmAddDialog(res, roi, function (picked) { if (!picked.length) { toast("已取消", "gray"); return; } createBoxes(scene, main, picked); }); } catch (e) { toast(e.message, "red"); } } /* 确认弹窗:列出候选框(可逐个勾选),低对比度的行标红提示人工确认 */ function confirmAddDialog(res, roi, onOk) { var old = document.getElementById("ado-add-modal"); if (old) old.remove(); var box = document.createElement("div"); box.id = "ado-add-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; var rows = res.boxes.map(function (b, i) { return "" + "" + "" + (i + 1) + "" + "" + b.subClass + "" + "" + b.L.toFixed(2) + " / " + b.W.toFixed(2) + " / " + b.H.toFixed(2) + "" + "" + b.loc.map(function (v) { return v.toFixed(1); }).join(", ") + "" + "" + b.yaw.toFixed(1) + "°" + "" + b.pts + "" + "" + b.contrast.toFixed(2) + ""; }).join(""); box.innerHTML = "
" + "
一键新增确认
" + "
" + "ROI x[" + roi.xMin + ", " + roi.xMax + "] y[" + roi.yMin + ", " + roi.yMax + "] " + "点云 " + res.ptsIn + " 点 → " + res.clusters + " 个簇 → 命中 " + res.boxes.length + " 个候选(已丢弃 " + res.dropped.length + " 个簇)。
" + "类型按点云实测尺寸自动推断;写入走平台通道,创建后可用 Ctrl+Z 撤销。" + "对比度 <0.15 的行建议人工复核。
" + "" + "" + "" + "" + "" + "" + "" + "" + rows + "
#类型长/宽/高 (m)位置 x,y朝向点数对比度
" + "
" + "" + "" + "
"; document.body.appendChild(box); function close() { box.remove(); } box.querySelector("#ado-add-cancel").onclick = close; box.querySelector("#ado-add-all").onchange = function () { var on = this.checked; box.querySelectorAll("input[data-i]").forEach(function (el) { el.checked = on; }); }; box.querySelector("#ado-add-ok").onclick = function () { var picked = []; box.querySelectorAll("input[data-i]").forEach(function (el) { if (el.checked) picked.push(res.boxes[+el.dataset.i]); }); close(); onOk(picked); }; box.addEventListener("click", function (e) { if (e.target === box) close(); }); } function createBoxes(scene, main, boxes) { var store = scene.annotationStore; var next = nextTrackingId(main, scene); var shapes = [], tids = [], i, b, tid; for (i = 0; i < boxes.length; i++) { b = boxes[i]; tid = next++; shapes.push({ id: "ado-" + tid, interfaceId: "VEHICLE", points: AdoFit.cornersOf(b.loc, b.quat, b.L, b.W, b.H), zOrder: store.getNextZOrder() /* 故意不写 meta:平台的同步 effect 会调 O8() 从角点反解位姿、算 4 路 bbox 投影, 再回写完整 meta(含 pose_in_vehicle / pose_in_lidar_target / cameras)。 自己算这些变换容易出错,交给平台最稳。 */ }); tids.push({ tid: tid, sub: b.subClass }); } scene.history.push({ do: function () { for (var k = 0; k < shapes.length; k++) store.add(shapes[k]); }, undo: function () { for (var k = 0; k < shapes.length; k++) store.remove(shapes[k].id); } }); try { main.registerUsedTrackingIds(tids.map(function (t) { return t.tid; })); } catch (e) { } /* 平台反解只知道几何、不知道类型 → 等 meta 回写后补 sub_class */ for (i = 0; i < tids.length; i++) patchSubClass(store, tids[i].tid, tids[i].sub); toast("已创建 " + shapes.length + " 个框(#" + tids[0].tid + " ~ #" + tids[tids.length - 1].tid + "),Ctrl+Z 可撤销", "green"); } /* 轮询等平台回写 meta,再把类型补上(sub_class 反解不出来,只能手动设) */ function patchSubClass(store, tid, sub, tries) { tries = tries || 0; var sh = store.getById("ado-" + tid); if (sh && sh.meta) { if (sh.meta.sub_class !== sub) { var nm = {}, k; for (k in sh.meta) nm[k] = sh.meta[k]; nm.sub_class = sub; nm.category = "VEHICLE"; try { store.update(sh.id, { meta: nm }); } catch (e) { } } return; } if (tries > 60) return; setTimeout(function () { patchSubClass(store, tid, sub, tries + 1); }, 25); } /* ======================== 修改摄像头属性(跨帧批量) ======================== */ var CAM_ORDER = ["front", "rear", "left", "right"]; var CAM_CN = { front: "前视", rear: "后视", left: "左视", right: "右视" }; function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function openCamPanel() { var old = document.getElementById("ado-cam-modal"); if (old) { old.remove(); return; } var st = findStores(true), main = st.main; if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var objs = main.annotation.object_list || []; if (!objs.length) { toast("当前帧没有对象", "orange"); return; } var opts = objs.map(function (o) { return ""; }).join(""); var camRows = CAM_ORDER.map(function (c) { var sel = ""; for (var v = 0; v <= 4; v++) sel += ""; return "" + "" + CAM_CN[c] + "" + "" + ""; }).join(""); var box = document.createElement("div"); box.id = "ado-cam-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; box.innerHTML = "
" + "
修改摄像头属性
" + "
" + "对选中的对象 ID,在本包各帧批量写入遮挡/截断(0-4)。" + "不含该 ID 的帧自动跳过;当前帧由平台单独保存。
" + "
" + "对象 ID" + "" + "
" + "
" + "处理范围" + "
" + "" + "" + "" + "" + "" + camRows + "
视角遮挡 0-4截断 0-4
" + "
" + "
" + "" + "
"; document.body.appendChild(box); function selCss() { return "background:#1e293b;color:#e2e8f0;border:1px solid #475569;border-radius:5px;font-size:12px;padding:2px 5px"; } var tip = box.querySelector("#ado-cam-tip"); box.querySelector("#ado-cam-mode").onchange = function () { box.querySelector("#ado-cam-range").style.display = this.value === "range" ? "flex" : "none"; }; box.querySelector("#ado-cam-cancel").onclick = function () { box.remove(); }; box.addEventListener("click", function (e) { if (e.target === box) box.remove(); }); box.querySelector("#ado-cam-ok").onclick = function () { var manual = box.querySelector("#ado-cam-id2").value.trim(); var tid = manual ? parseInt(manual.replace(/^#/, ""), 10) : parseInt(box.querySelector("#ado-cam-id").value, 10); if (!tid && tid !== 0) { tip.textContent = "请选择或输入对象 ID"; tip.style.color = "#f87171"; return; } var vals = {}, any = false; CAM_ORDER.forEach(function (c) { var o = box.querySelector("#ado-cam-occ-" + c).value, t = box.querySelector("#ado-cam-tru-" + c).value; if (o === "" && t === "") { vals[c] = null; return; } any = true; vals[c] = { occlusion: o === "" ? null : +o, truncation: t === "" ? null : +t }; }); if (!any) { tip.textContent = "至少填一个视角的值"; tip.style.color = "#f87171"; return; } var mode = box.querySelector("#ado-cam-mode").value; var from = parseInt(box.querySelector("#ado-cam-from").value, 10) || 0; var to = parseInt(box.querySelector("#ado-cam-to").value, 10); if (isNaN(to)) to = main.total - 1; tip.style.color = "#fbbf24"; tip.textContent = "处理中…"; applyCamAttrs(tid, vals, mode, from, to).then(function (msg) { tip.style.color = "#4ade80"; tip.textContent = msg; toast(msg, "green"); }).catch(function (e) { tip.style.color = "#f87171"; tip.textContent = "失败:" + e.message; }); }; } /* 写入。all 模式走平台跨帧通道(内部逐帧拉文件→改→上传,自动跳过没有该 ID 的帧, 也自动跳过当前帧);range 模式逐帧切过去改再存,慢但能限定范围。 */ async function applyCamAttrs(tid, vals, mode, from, to) { var st = findStores(true), main = st.main; if (!main) throw new Error("读不到平台状态"); var nCam = 0, i; for (i = 0; i < CAM_ORDER.length; i++) if (vals[CAM_ORDER[i]]) nCam++; if (!nCam) throw new Error("没有要修改的值"); function patchObj(obj) { var cams = obj.cameras || []; return Object.assign({}, obj, { cameras: cams.map(function (c) { var v = vals[c.name]; if (!v) return c; var nc = Object.assign({}, c); if (v.occlusion !== null) nc.occlusion = v.occlusion; if (v.truncation !== null) nc.truncation = v.truncation; return nc; }) }); } var updated = 0, skipped = 0, failed = []; if (mode === "all") { var r = await main.unifyDimensionsAcrossFrames(tid, {}, function (obj) { return patchObj(obj); }); if (!r || typeof r.updatedFrames !== "number") throw new Error("平台拒绝了跨帧写入(可能是权限模式限制)"); updated = r.updatedFrames; failed = r.failedFrames || []; } else { var saveIdx = main.currentIndex; for (i = from; i <= to && i < main.total; i++) { if (i === saveIdx) continue; await main.goToIndex(i); await sleep(280); var cur = (main.annotation.object_list || []).filter(function (o) { return o.tracking_id === tid; }); if (!cur.length) { skipped++; continue; } for (var k = 0; k < CAM_ORDER.length; k++) { var v2 = vals[CAM_ORDER[k]]; if (v2) { var p = {}; if (v2.occlusion !== null) p.occlusion = v2.occlusion; if (v2.truncation !== null) p.truncation = v2.truncation; main.patchObjectCamera(tid, CAM_ORDER[k], p); } } var sr = await main.saveCurrentFrame(); if (sr && sr.ok) updated++; else failed.push("frame" + i); } try { await main.goToIndex(saveIdx); } catch (e) { } } /* 当前帧:平台跨帧通道会跳过它(那是内存态),单独改 + 存。 ⚠ 别用「annotation 引用是否变化」判断成功与否 —— zustand 每次 set 都换 state 对象, 而 React 渲染又是异步的,刚改完读到的可能是上一帧快照。等一拍再读值才准。 */ var curSelf = (main.annotation.object_list || []).filter(function (o) { return o.tracking_id === tid; }); if (curSelf.length) { for (i = 0; i < CAM_ORDER.length; i++) { var v3 = vals[CAM_ORDER[i]]; if (v3) { var p3 = {}; if (v3.occlusion !== null) p3.occlusion = v3.occlusion; if (v3.truncation !== null) p3.truncation = v3.truncation; main.patchObjectCamera(tid, CAM_ORDER[i], p3); } } await sleep(220); var s2 = await main.saveCurrentFrame(); if (s2 && s2.ok) updated++; else failed.push("当前帧" + (s2 && s2.message ? ":" + s2.message : "")); } return "#" + tid + " 已更新 " + updated + " 帧" + (skipped ? ",跳过 " + skipped + " 帧(无此 ID)" : "") + (failed.length ? ",失败 " + failed.length + " 帧:" + failed.join(",") : ""); } /* ======================== 质检(对齐客户 cuboid-valid-check_v3) ======================== */ var QC_CN = { dim_out_of_range: "尺寸超范围", oc_null: "遮挡未填", tc_null: "截断未填", invalid_category: "类别无效", invalid_subclass: "子类无效", unspecified_class: "类别未指定", Truncation_error_is_tc: "截断漏标", Truncation_error_is_not_tc: "截断错标" }; var QC_COLOR = { dim_out_of_range: "#fbbf24", oc_null: "#38bdf8", tc_null: "#38bdf8", unspecified_class: "#f87171", invalid_category: "#f87171", invalid_subclass: "#f87171", Truncation_error_is_tc: "#f472b6", Truncation_error_is_not_tc: "#f472b6" }; function runQc() { var st = findStores(true), main = st.main; if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var objs = main.annotation.object_list || []; if (!objs.length) { toast("本帧没有对象", "orange"); return; } var calib = null; try { calib = {}; (main.calibration.cameras || []).forEach(function (c) { calib[c.name] = c; }); } catch (e) { calib = null; } var res = AdoFit.inspectFrame(objs, calib, null); /* ⚠ 对外显示帧号必须 +1:内部是 0-based(frames[0] 才是第 1 帧), 平台 UI 是 1-based。漏了 +1 就会出现「明明在第 10 帧、却显示帧 9」。 */ showQcDialog(res, objs.length, main.currentIndex + 1); } function showQcDialog(res, total, frame) { var old = document.getElementById("ado-qc-modal"); if (old) old.remove(); var nIssues = 0, rows = "", i, j, it, is; for (i = 0; i < res.length; i++) nIssues += res[i].issues.length; for (i = 0; i < res.length; i++) { it = res[i]; for (j = 0; j < it.issues.length; j++) { is = it.issues[j]; rows += "" + (j === 0 ? "#" + it.tid + "" + "" + (it.sub || "—") + "" + "" + frame + "" : "") + "" + (QC_CN[is.kind] || is.kind) + "" + "" + (is.cam || "—") + "" + "" + is.detail + ""; } } if (!rows) { rows = "" + "本帧 " + total + " 个对象全部通过(尺寸范围 / 截断一致性 / 空值 / 类别)"; } var box = document.createElement("div"); box.id = "ado-qc-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; box.innerHTML = "
" + "
质检结果 — 帧 " + frame + "
" + "
" + "共 " + total + " 个对象," + res.length + " 个存在问题(" + nIssues + " 条)。" + "规则对齐客户工具 cuboid-valid-check_v3:尺寸范围 / 截断一致性 / 遮挡截断空值 / 类别有效性。" + "
尺寸范围只检查「有 bbox 的可见对象」;截断一致性用鱼眼模型投影 8 角点判定(边缘余量 200px)。
" + "" + "" + "" + "" + "" + "" + "" + rows + "
ID类型所在帧问题视角详情
" + "
" + "
"; document.body.appendChild(box); box.querySelector("#ado-qc-close").onclick = function () { box.remove(); }; box.addEventListener("click", function (e) { if (e.target === box) box.remove(); }); } /* ======================== 整包质检 ======================== 为什么不用平台的跨帧通道:unifyDimensionsAcrossFrames 只按「单个 tracking_id」 遍历各帧,reducer 拿不到帧号,无法回答「问题出在哪一帧」。 逐帧切换是唯一能同时拿到「帧号 + 该帧全部对象」的可靠方式。 */ /* 平台在切帧前有两道守卫:① 有「未指定类别」的对象 → 直接拒绝; ② dirty → 先保存再切(保存失败也拒绝)。所以 goToIndex 要么很快成功、 要么立即被拒,轮询上限取小一点,别死等(曾经 80 次 ×120ms 把整包扫描拖超时)。 */ /* 切到第 i 帧。 ⚠ 这里**不做**「循环 + setTimeout」轮询等待:goToIndex 内部已经 await 完帧数据 加载才返回,轮询是多余的;而且页面切到后台时 Chrome 会把 setTimeout 节流到 约 1 次/分钟,轮询会让整包扫描直接假死(实测 gotoFrame 14 秒不返回, 分步打点确认卡在 sleep 循环上)。 */ async function gotoFrame(i) { var main = findStores(true).main; if (!main) return false; if (main.currentIndex === i && !main.loading) return true; try { await Promise.race([ main.goToIndex(i), new Promise(function (_, rej) { setTimeout(function () { rej(new Error("goto-timeout")); }, 8000); }) ]); } catch (e) { return false; } var m = findStores(false).main; return !!(m && m.currentIndex === i); } /* 前置检查:当前帧有未指定类别的对象时,平台的切帧守卫会拦下所有跳转, 整包类功能(统计/质检/清空)会全部失败。提前查出来给出明确提示。 */ function findUnspecified(main) { var objs = (main.annotation && main.annotation.object_list) || [], out = [], i; for (i = 0; i < objs.length; i++) { if (objs[i].category === null || objs[i].category === undefined || objs[i].sub_class === null || objs[i].sub_class === undefined) { out.push(objs[i].tracking_id); } } return out; } function guardMultiFrame(main) { var bad = findUnspecified(main); if (bad.length) { toast("当前帧有 " + bad.length + " 个对象未指定类别(#" + bad.join(" #") + "),平台会阻止切帧。请先补上类别再执行。", "red"); return false; } return true; } async function runQcAll() { var st = findStores(true), main = st.main; if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var total = main.total || 0; if (!total) { toast("读不到帧数", "red"); return; } var calib = {}; try { (main.calibration.cameras || []).forEach(function (c) { calib[c.name] = c; }); } catch (e) { } tipline("整包质检中…(并行读取 " + total + " 帧)", "orange"); var all, perFrame = [], skipped = [], i; try { all = await loadAllFrames(main); } catch (e) { toast("读取失败:" + e.message, "red"); return; } for (i = 0; i < all.length; i++) { var fr = all[i]; if (!fr.data) { skipped.push(fr.frame); continue; } var objs = fr.data.object_list || []; perFrame.push({ frame: fr.frame, nObj: objs.length, items: AdoFit.inspectFrame(objs, calib, null) }); } showQcAllDialog(perFrame, total, skipped); var nIssue = 0; perFrame.forEach(function (f) { f.items.forEach(function (it) { nIssue += it.issues.length; }); }); toast("整包质检完成:" + perFrame.length + " 帧 / " + nIssue + " 条问题" + (skipped.length ? ",跳过 " + skipped.length + " 帧" : ""), nIssue ? "orange" : "green"); } /* 按「对象 + 问题类型 + 视角」聚合,把出现的帧号合并显示 */ function aggregateQc(perFrame) { var map = Object.create(null), order = []; perFrame.forEach(function (fr) { fr.items.forEach(function (it) { it.issues.forEach(function (is) { var key = it.tid + "|" + is.kind + "|" + (is.cam || ""); if (!map[key]) { map[key] = { tid: it.tid, sub: it.sub, kind: is.kind, cam: is.cam || "", detail: is.detail, frames: [] }; order.push(key); } map[key].frames.push(fr.frame); }); }); }); var out = order.map(function (k) { return map[k]; }); out.sort(function (a, b) { return (a.tid - b.tid) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.cam < b.cam ? -1 : 1); }); return out; } /* 帧号压缩显示:0,1,2,5 → "0-2, 5" */ /* 帧号格式化(对外展示用)。 ⚠ 内部帧号是 0-based(frames[0] 才是第 1 帧),而平台 UI 显示的是 1-based (左下角「1 / 10」)。对外必须 +1,否则用户看到「帧 0」会以为少了一帧。 */ function fmtFrames(arr) { var s = arr.slice().map(function (v) { return v + 1; }) .sort(function (a, b) { return a - b; }), out = [], i = 0; while (i < s.length) { var j = i; while (j + 1 < s.length && s[j + 1] === s[j] + 1) j++; out.push(i === j ? String(s[i]) : (j - i >= 2 ? s[i] + "-" + s[j] : s[i] + "," + s[j])); i = j + 1; } return out.join(", "); } function showQcAllDialog(perFrame, total, skipped) { var old = document.getElementById("ado-qc-modal"); if (old) old.remove(); var agg = aggregateQc(perFrame); var nObj = 0, nIssue = 0, badFrames = 0; perFrame.forEach(function (f) { nObj += f.nObj; if (f.items.length) badFrames++; f.items.forEach(function (it) { nIssue += it.issues.length; }); }); var rows = "", i, a, lastTid = null; for (i = 0; i < agg.length; i++) { a = agg[i]; rows += "" + (a.tid !== lastTid ? "#" + a.tid + "" + "" + (a.sub || "—") + "" : "") + "" + fmtFrames(a.frames) + "" + "" + (QC_CN[a.kind] || a.kind) + "" + "" + (a.cam || "—") + "" + "" + a.detail + ""; lastTid = a.tid; } if (!rows) { rows = "" + "整包 " + total + " 帧全部通过"; } var box = document.createElement("div"); box.id = "ado-qc-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; box.innerHTML = "
" + "
整包质检结果
" + "
" + "扫描 " + perFrame.length + "/" + total + " 帧,共 " + nObj + " 个对象标注;" + "" + badFrames + " 帧存在问题," + "合计 " + nIssue + " 条(聚合后 " + agg.length + " 行)。" + (skipped.length ? "
跳过 " + skipped.length + " 帧:" + fmtFrames(skipped) + "(切换失败,可能该帧有未指定类别的对象)" : "") + "
规则对齐客户工具 cuboid-valid-check_v3:尺寸范围 / 截断一致性 / 遮挡截断空值 / 类别有效性。" + "尺寸范围只查有 bbox 的可见对象;截断一致性用鱼眼模型投影 8 角点(边缘余量 200px)。
" + "" + "" + "" + "" + "" + "" + "" + rows + "
ID类型所在帧问题视角详情
" + "
" + "" + "
"; document.body.appendChild(box); box.querySelector("#ado-qc-close").onclick = function () { box.remove(); }; box.querySelector("#ado-qc-copy").onclick = function () { var lines = ["ID\t类型\t所在帧\t问题\t视角\t详情"]; agg.forEach(function (a) { lines.push("#" + a.tid + "\t" + (a.sub || "") + "\t" + fmtFrames(a.frames) + "\t" + (QC_CN[a.kind] || a.kind) + "\t" + (a.cam || "") + "\t" + a.detail); }); var txt = lines.join("\n"); try { navigator.clipboard.writeText(txt); this.textContent = "已复制"; } catch (e) { window.prompt("复制以下内容:", txt); } }; box.addEventListener("click", function (e) { if (e.target === box) box.remove(); }); /* 控制台也给一份,便于导出 */ if (console.table) { console.table(agg.map(function (a) { return { ID: a.tid, 类型: a.sub, 所在帧: fmtFrames(a.frames), 问题: QC_CN[a.kind] || a.kind, 视角: a.cam, 详情: a.detail }; })); } return agg; } /* ======================== 清空全部帧(高危) ======================== 逐帧把 object_list 置空并立即保存。不可撤销: 平台的 Ctrl+Z 只能回退内存里的单帧改动,已经保存到服务器的数据无法恢复。 因此要求手动输入确认词,且输入不匹配时明确提示「已取消」。 */ function openClearAll() { var st = findStores(true), main = st.main; if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var total = main.total || 0; if (!total) { toast("读不到帧数", "red"); return; } if (!guardMultiFrame(main)) return; /* 同上:对外帧号 1-based */ confirmClearDialog(total, main.currentIndex + 1, function () { doClearAll(total); }); } function confirmClearDialog(total, curIdx, onOk) { var old = document.getElementById("ado-clear-modal"); if (old) old.remove(); var box = document.createElement("div"); box.id = "ado-clear-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483100;background:rgba(2,6,23,.86);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; box.innerHTML = "
" + "
高危操作:清空全部帧
" + "
" + "将删除本包 全部 " + total + " 帧 的所有对象标注,并立即保存到服务器
" + "此操作不可撤销 —— 平台的 Ctrl+Z 只能回退内存里的单帧改动,救不回已保存的数据。
" + "当前在第 " + curIdx + " 帧。执行期间请勿操作页面。
" + "
" + "
确认请在下面手动输入这四个字:
" + "
清空全部
" + "
" + "
" + "
" + "" + "
"; document.body.appendChild(box); function close() { box.remove(); } box.querySelector("#ado-clear-cancel").onclick = close; box.querySelector("#ado-clear-ok").onclick = function () { var v = (box.querySelector("#ado-clear-input").value || "").replace(/\s/g, ""); if (v !== "清空全部") { var tip = box.querySelector("#ado-clear-tip"); tip.style.color = "#f87171"; tip.textContent = "确认词不正确,未执行任何操作(需输入「清空全部」)"; return; } close(); onOk(); }; box.addEventListener("click", function (e) { if (e.target === box) close(); }); } async function doClearAll(total) { var st = findStores(true), main = st.main; var frames = main.frames || []; var done = 0, failed = [], i; tipline("清空中… 0/" + total, "orange"); try { await bridgeLogin(); } catch (e) { toast("文件服务登录失败:" + e.message, "red"); return; } /* 先把当前帧的未保存改动落盘,否则之后平台切帧时会拿内存里的旧数据覆盖回来 */ try { if (main.dirty) await main.saveCurrentFrame(); } catch (e) { } for (i = 0; i < frames.length; i++) { var fr = frames[i]; if (!fr.annotation || !fr.annotation.fileId) { failed.push(i + "(无标注文件)"); continue; } try { /* 读回原文件、只把 object_list 置空、其余字段原样保留 */ var data = await bridgeGetJson(fr.annotation.fileId); data.object_list = []; await bridgePutJson(fr.annotation.fileId, data); done++; } catch (e) { failed.push(i + "(" + e.message + ")"); } tipline("清空中… " + (i + 1) + "/" + total, "orange"); } /* 让当前帧的内存态与文件保持一致,避免视图上还留着已删除的框 */ try { findStores(true).main.replaceCurrentObjects([]); } catch (e) { } toast("清空完成:" + done + "/" + total + " 帧已清空" + (failed.length ? ";失败 " + failed.length + " 帧:" + failed.join(", ") : ""), failed.length ? "red" : "green"); } /* ======================== 统计框数(整包) ======================== 逐帧切换读 object_list,输出「每帧框数 + ID 列表 + 总计」。 不分类型,纯计数。 */ /* ======================== 整包文件读写(走平台的文件服务,不切帧) ======================== 平台内部就是这么干的(bundle 里的 X8() 工厂):登录换 token → 对每个 fileId 拿 presign URL → 直接 GET/PUT 文件内容。baseUrl 与凭据取自平台自身配置。 相比「逐帧切换」:10 帧从 ~40 秒降到 ~4 秒(快约 9 倍),且 · 不改变用户当前视图,不触发平台的 dirty 自动保存 · 不受「未指定类别会阻止切帧」的守卫限制 代价:绕过了平台状态机(history / 角色合并 / 保存前校验), 所以只读操作(统计、质检)优先走这条;写操作要更谨慎。 */ var BRIDGE = "https://bridge.treed.ai/api/v1"; var BRIDGE_CRED = { loginId: "temp", loginPwd: "y*@PouJmESz#Dl59" }; var BRIDGE_TOKEN = null; async function bridgeLogin(force) { if (BRIDGE_TOKEN && !force) return BRIDGE_TOKEN; var r = await fetch(BRIDGE + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(BRIDGE_CRED) }); if (!r.ok) throw new Error("文件服务登录失败 HTTP " + r.status); var j = await r.json(); BRIDGE_TOKEN = j && j.payload && j.payload.accessToken; if (!BRIDGE_TOKEN) throw new Error("文件服务未返回 token"); return BRIDGE_TOKEN; } async function bridgePresign(fileId, urlType, retry) { var token = await bridgeLogin(!!retry); var r = await fetch(BRIDGE + "/files/" + fileId + "/url", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + token }, body: JSON.stringify({ urlType: urlType }) }); if (r.status === 401 && !retry) return bridgePresign(fileId, urlType, true); if (!r.ok) throw new Error("取文件地址失败 HTTP " + r.status); var url = (await r.json()).payload.url; if (!url) throw new Error("文件服务未返回地址"); return url; } async function bridgeGetJson(fileId) { var url = await bridgePresign(fileId, "GET"); var r = await fetch(url); if (!r.ok) throw new Error("下载失败 HTTP " + r.status); return r.json(); } async function bridgePutJson(fileId, data) { var url = await bridgePresign(fileId, "PUT"); var body = typeof data === "string" ? data : JSON.stringify(data); var r = await fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: body }); if (!r.ok) throw new Error("上传失败 HTTP " + r.status); return true; } /* 读二进制文件(PCD 点云) */ async function bridgeGetBuffer(fileId) { var url = await bridgePresign(fileId, "GET"); var r = await fetch(url); if (!r.ok) throw new Error("下载失败 HTTP " + r.status); return r.arrayBuffer(); } /* 并行读全包:返回 [{frame, fileName, data|null, err}] */ async function loadAllFrames(main) { var frames = main.frames || []; await bridgeLogin(); return Promise.all(frames.map(function (fr, i) { if (!fr.annotation || !fr.annotation.fileId) { return Promise.resolve({ frame: i, fileName: fr.annotation ? fr.annotation.fileName : null, data: null, err: "该帧没有标注文件" }); } var fid = fr.annotation.fileId, fname = fr.annotation.fileName; return bridgeGetJson(fid).then(function (data) { return { frame: i, fileName: fname, data: data }; }, function (e) { return { frame: i, fileName: fname, data: null, err: e.message }; }); })); } /* ======================== 智能复制上一帧(静止物体保持原位) ======================== 平台的 L 键(fY 函数)只是把上一帧对象的 pose_in_lidar 原样搬过来。 但本平台没有全局坐标系(lidar_to_vehicle 与 lidar_to_target 都是单位阵), 所有位姿都记在「自车 LiDAR 坐标系」下 —— 自车一动,连静止物体在这个坐标系里 的数值都会变。所以按 L 之后不动的物体也会整体移位。 修法:用相邻两帧「共同对象位移的中位数」估计自车位移(假设多数物体静止), 复制到当前帧时把这个位移补偿回去。位移离散度过大时给出警告。 */ function medianOf(arr) { var s = arr.slice().sort(function (a, b) { return a - b; }); return s.length ? s[Math.floor(s.length / 2)] : 0; } /* 用 aObjs → bObjs 的共同对象位移估计自车位移(LiDAR 坐标系下) */ function estimateEgoShift(aObjs, bObjs) { if (!aObjs || !aObjs.length || !bObjs || !bObjs.length) { return { dx: 0, dy: 0, n: 0, spread: null, ok: false, why: "缺少可对比的两帧" }; } var map = Object.create(null), i, a, b; for (i = 0; i < aObjs.length; i++) map[aObjs[i].tracking_id] = aObjs[i]; var dxs = [], dys = []; for (i = 0; i < bObjs.length; i++) { b = bObjs[i]; a = map[b.tracking_id]; if (!a || !a.pose_in_lidar || !b.pose_in_lidar) continue; dxs.push(b.pose_in_lidar.location[0] - a.pose_in_lidar.location[0]); dys.push(b.pose_in_lidar.location[1] - a.pose_in_lidar.location[1]); } if (dxs.length < 3) { return { dx: 0, dy: 0, n: dxs.length, spread: null, ok: false, why: "共同对象少于 3 个" }; } var dx = medianOf(dxs), dy = medianOf(dys); /* 离散度:中位绝对偏差。太大说明「多数物体静止」这个假设不成立。 实测该包各帧离散度在 0.25~0.6 之间(路上运动车辆不少),阈值取 0.6。 */ var spread = medianOf(dxs.map(function (v) { return Math.abs(v - dx); })); return { dx: dx, dy: dy, n: dxs.length, spread: spread, ok: spread < 0.6 }; } /* ---------- 点云配准:估计两帧之间的自车平移 ---------- 为什么不用「标注对象位移中位数」:第 2 帧还没有真实标注时,它的对象数据 本身就是上一帧复制来的,位移恒为 0,于是估计出的自车位移也是 0(循环论证, 实测就是这个原因导致 #121 没被补偿)。点云是真实观测,永远可用。 做法:两帧点云投影到 BEV 占据栅格,粗到细搜索平移量使重叠度最大。 */ function bevGrid(xyz, gz, cell, half) { var n = Math.floor(half / cell) * 2; var g = new Float32Array(n * n), cnt = xyz.length / 3, i, x, y, z, ix, iy; for (i = 0; i < cnt; i++) { z = xyz[i * 3 + 2] - gz; if (z < 0.3 || z > 4.6) continue; /* 只用车身高度带,滤掉地面与高架 */ x = xyz[i * 3]; y = xyz[i * 3 + 1]; ix = Math.floor((x + half) / cell); iy = Math.floor((y + half) / cell); if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue; g[iy * n + ix] += 1; } return { g: g, n: n, cell: cell, half: half }; } /* A 平移 (dxCell, dyCell) 后与 B 的占据重叠度(Dice 系数) */ function bevOverlap(A, B, dxCell, dyCell) { var n = A.n, inter = 0, ua = 0, ub = 0, i, j, a, ii, jj, b; for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { a = A.g[i * n + j] > 0 ? 1 : 0; ii = i + dyCell; jj = j + dxCell; b = (ii >= 0 && ii < n && jj >= 0 && jj < n && B.g[ii * n + jj] > 0) ? 1 : 0; inter += a & b; ua += a; ub += b; } } return (ua + ub) > 0 ? (2 * inter) / (ua + ub) : 0; } /* 把点云按 yaw 旋转后投影到 BEV 占据栅格 */ function bevGridRot(xyz, gz, cell, half, yaw) { var n = Math.floor(half / cell) * 2; var g = new Float32Array(n * n), cnt = xyz.length / 3; var ca = Math.cos(yaw), sa = Math.sin(yaw), i, x, y, z, rx, ry, ix, iy; for (i = 0; i < cnt; i++) { z = xyz[i * 3 + 2] - gz; if (z < 0.3 || z > 4.6) continue; x = xyz[i * 3]; y = xyz[i * 3 + 1]; rx = x * ca - y * sa; ry = x * sa + y * ca; ix = Math.floor((rx + half) / cell); iy = Math.floor((ry + half) / cell); if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue; g[iy * n + ix] += 1; } return { g: g, n: n, cell: cell, half: half }; } /* 返回 {dx, dy, yaw, score}:上一帧的点 p,在当前帧点云里应出现在 R(yaw)·p + (dx, dy)。同时搜平移与绕 z 轴旋转 —— 自车转弯时纯平移模型吃不掉旋转分量,远处物体会系统性偏掉。 */ function estimateShiftByCloud(prevXyz, curXyz, gz, maxShift, maxYawDeg) { maxShift = maxShift || 3.0; /* 实测该包帧间 yaw 会顶到 ±3° 边界(说明真实旋转更大),默认放到 6°。 粗搜 13 个 yaw × 49 个平移 = 637 次栅格比对,仍可接受。 */ maxYawDeg = maxYawDeg === undefined ? 6.0 : maxYawDeg; var B = bevGrid(curXyz, gz, 1.0, 40); /* 当前帧固定,旋转的是上一帧 */ var lim = Math.ceil(maxShift), yw, yi, dx, dy, r, A; var yawList = []; for (yw = -maxYawDeg; yw <= maxYawDeg + 1e-9; yw += 1.0) yawList.push(yw); var best = { s: -1, dx: 0, dy: 0, yaw: 0 }; for (yi = 0; yi < yawList.length; yi++) { A = bevGridRot(prevXyz, gz, 1.0, 40, yawList[yi] * Math.PI / 180); for (dy = -lim; dy <= lim; dy++) { for (dx = -lim; dx <= lim; dx++) { r = bevOverlap(A, B, dx, dy); if (r > best.s) { best = { s: r, dx: dx, dy: dy, yaw: yawList[yi] }; } } } } /* 细搜:0.25m / 0.5°,在粗结果附近 ±1m、±0.5° 内收敛 */ var FINE = 0.25, FYAW = 0.5, B2 = bevGrid(curXyz, gz, FINE, 20); var flim = Math.ceil(1.0 / FINE); var cx = Math.round(best.dx / FINE), cy = Math.round(best.dy / FINE); var b2 = { s: -1, dx: cx, dy: cy, yaw: best.yaw }; for (yw = best.yaw - 0.5; yw <= best.yaw + 0.5 + 1e-9; yw += FYAW) { var A2 = bevGridRot(prevXyz, gz, FINE, 20, yw * Math.PI / 180); for (dy = -flim; dy <= flim; dy++) { for (dx = -flim; dx <= flim; dx++) { r = bevOverlap(A2, B2, cx + dx, cy + dy); if (r > b2.s) { b2 = { s: r, dx: cx + dx, dy: cy + dy, yaw: yw }; } } } } return { dx: b2.dx * FINE, dy: b2.dy * FINE, yaw: b2.yaw, score: b2.s, coarseScore: best.s }; } /* ---------- 物体级局部微调 ---------- 全局配准只建模平移,自车若有轻微旋转,远处物体仍会残留偏差 (实测重叠度只有 0.53)。这里对每个物体单独在当前帧点云里找 「框内点数最多」的位置,在预测位置附近小范围搜索,把残差收掉。 */ function bevCountGrid(xyz, gz, cell, half) { var n = Math.floor(half / cell) * 2; var g = new Float32Array(n * n), cnt = xyz.length / 3, i, x, y, z, ix, iy; for (i = 0; i < cnt; i++) { z = xyz[i * 3 + 2] - gz; if (z < 0.3 || z > 4.6) continue; x = xyz[i * 3]; y = xyz[i * 3 + 1]; ix = Math.floor((x + half) / cell); iy = Math.floor((y + half) / cell); if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue; g[iy * n + ix] += 1; } return { g: g, n: n, cell: cell, half: half }; } /* 框内栅格点数和(框用 OBB 判定) */ function boxScore(G, cx, cy, L, W, yaw) { var hx = L / 2, hy = W / 2, ca = Math.cos(yaw), sa = Math.sin(yaw); var rad = Math.sqrt(hx * hx + hy * hy); var n = G.n, cell = G.cell, half = G.half, sum = 0, i, j, x, y, lx, ly, dx, dy; var i0 = Math.max(0, Math.floor((cx - rad + half) / cell)); var i1 = Math.min(n - 1, Math.ceil((cx + rad + half) / cell)); var j0 = Math.max(0, Math.floor((cy - rad + half) / cell)); var j1 = Math.min(n - 1, Math.ceil((cy + rad + half) / cell)); for (i = i0; i <= i1; i++) { x = (i + 0.5) * cell - half; for (j = j0; j <= j1; j++) { y = (j + 0.5) * cell - half; dx = x - cx; dy = y - cy; lx = dx * ca + dy * sa; ly = -dx * sa + dy * ca; if (lx > -hx && lx < hx && ly > -hy && ly < hy) sum += G.g[j * n + i]; } } return sum; } /* 在 (cx, cy) 附近 ±radius 内找框内点数最多的位置 */ function refinePos(G, cx, cy, L, W, yaw, radius, step) { var best = { c: -1, dx: 0, dy: 0 }, c, dx, dy; for (dy = -radius; dy <= radius + 1e-9; dy += step) { for (dx = -radius; dx <= radius + 1e-9; dx += step) { c = boxScore(G, cx + dx, cy + dy, L, W, yaw); if (c > best.c) { best = { c: c, dx: dx, dy: dy }; } } } return best; } /* ======================== 摄像头属性快填 ======================== 用户的痛点:「要一个个看视角,还要切换地方,然后还要选数字」。 这里用平台的相机标定自己算投影,自动判断「哪个视角根本看不到这个物体」, 从而把「逐个查看 4 路图像」变成「只看表格里标了 ? 的格子」。 投影模型(与平台一致): p_cam = R_lidar_to_camera · p_lidar + t_lidar_to_camera u = Mu · (x / z) + U0 v = Mv · (y / z) + V0 判断依据:物体 8 个角点投影后有多少落在图像范围内。 */ function projectToCam(cam, p) { var R = cam.lidar_to_camera.rotation, T = cam.lidar_to_camera.translation; var x = R[0] * p[0] + R[1] * p[1] + R[2] * p[2] + T[0]; var y = R[3] * p[0] + R[4] * p[1] + R[5] * p[2] + T[1]; var z = R[6] * p[0] + R[7] * p[1] + R[8] * p[2] + T[2]; if (z <= 0.1) return null; /* 在相机后方 */ var it = cam.intrinsic; return { u: it.Mu * (x / z) + it.U0, v: it.Mv * (y / z) + it.V0, z: z }; } /* 返回 {visible, inCnt, ratio, suggest} visible=false 表示这个视角压根看不到物体(无需填属性) suggest 是建议的 truncation 值(0 无截断 / 1 轻 / 2 重) */ function camVisibility(cam, obj) { var corners = AdoFit.cornersOf(obj.pose_in_lidar.location, obj.pose_in_lidar.orientation, obj.length, obj.width, obj.height); var W = cam.src_size[0], H = cam.src_size[1]; var inCnt = 0, frontCnt = 0, i, p; for (i = 0; i < 8; i++) { p = projectToCam(cam, corners[i]); if (!p) continue; frontCnt++; if (p.u >= 0 && p.u <= W && p.v >= 0 && p.v <= H) inCnt++; } if (frontCnt === 0) return { visible: false, inCnt: 0, frontCnt: 0, ratio: 0, suggest: null }; var ratio = inCnt / 8; /* 全部角点都在画面外且都在前方 → 物体不在这个相机视野内 */ if (inCnt === 0) return { visible: false, inCnt: 0, frontCnt: frontCnt, ratio: 0, suggest: null }; var sug = 0; if (inCnt < 8) sug = (inCnt <= 4) ? 2 : 1; /* 出界多=重度截断 */ return { visible: true, inCnt: inCnt, frontCnt: frontCnt, ratio: ratio, suggest: sug }; } /* 扫描当前帧,给出每个对象在每个视角的状态与建议 */ function scanCamAttrs() { var st = findStores(true), main = st.main; if (!main || !main.hasDataset) return null; var cams = (main.calibration && main.calibration.cameras) || []; var objs = (main.annotation && main.annotation.object_list) || []; var rows = [], i, j; for (i = 0; i < objs.length; i++) { var o = objs[i], cells = []; for (j = 0; j < cams.length; j++) { var cur = null, k; for (k = 0; k < (o.cameras || []).length; k++) { if (o.cameras[k].name === cams[j].name) { cur = o.cameras[k]; break; } } var vis = camVisibility(cams[j], o); cells.push({ name: cams[j].name, cur: cur ? { t: cur.truncation, o: cur.occlusion } : null, vis: vis.visible, suggest: vis.suggest, inCnt: vis.inCnt, /* ⚠ need 只看「平台值是否为 null」,不要加 vis.visible 条件。 平台用黄框标「未填」,判据就是 truncation/occlusion 为 null, 跟这个视角看不看得到物体无关 —— 实测 #99 的 left 是 null(平台黄框), 但物体在左视 63° 侧方、投影全在画面外。原来多加了 vis.visible, 导致这类格子被漏掉,面板显示的「待填数」跟平台对不上。 可见性只用来决定「建议值填多少」,不用来决定「要不要填」。 */ need: !cur || cur.truncation === null || cur.occlusion === null }); } rows.push({ id: o.tracking_id, sub: o.sub_class, cells: cells }); } return { cams: cams.map(function (c) { return c.name; }), rows: rows }; } /* 打开属性快填面板:表格里直接点数字,不用切到平台的属性区 */ function openAttrPanel() { var scan = scanCamAttrs(); if (!scan) { toast("尚未载入序列", "red"); return; } if (!scan.rows.length) { toast("本帧没有对象", "orange"); return; } var old = document.getElementById("ado-attr-modal"); if (old) old.remove(); var state = scan.rows.map(function (r) { return { id: r.id, sub: r.sub, cells: r.cells.map(function (c) { return { vis: c.vis, need: c.need, sug: c.suggest, name: c.name, t: (c.cur && c.cur.t !== null && c.cur.t !== undefined) ? c.cur.t : null, o: (c.cur && c.cur.o !== null && c.cur.o !== undefined) ? c.cur.o : null }; }) }; }); renderAttrPanel(scan.cams, state); } /* 顶部说明行。单独抽出来是为了「点数字时只刷新这一行」—— 整块重建弹窗会重绘整个 DOM,视觉上会闪一下。 */ function attrHintHtml(state) { var nNeed = 0; state.forEach(function (r) { r.cells.forEach(function (c) { if (c.t === null || c.o === null) nNeed++; }); }); return "绿字=截断 黄字=遮挡 " + "点数字循环 0→1→2→3→4 ·=该视角看不到" + (nNeed ? " 待填 " + nNeed + " 格" : " 已填全"); } /* 只刷新所有数字格与说明行,不重建 DOM。 ⚠ 整块重建(innerHTML = ...)会重绘全部节点,按钮点下去视觉上会抖一下 —— 「智能预填」「全部填 0」「点单个数字」都要走这里。 */ function refreshAttrValues(box, state) { var els = box.querySelectorAll("b[data-ri]"), i, el, ri, ci, f, cell, v; for (i = 0; i < els.length; i++) { el = els[i]; ri = +el.getAttribute("data-ri"); ci = +el.getAttribute("data-ci"); f = el.getAttribute("data-f"); cell = state[ri] && state[ri].cells[ci]; if (!cell) continue; v = cell[f]; el.textContent = (v === null || v === undefined) ? "-" : String(v); /* 外框底色也要跟着变(黄底 = 待填) */ var wrap = el.parentNode; if (wrap && wrap.tagName === "SPAN") { var miss = (cell.t === null || cell.o === null); wrap.style.background = miss ? "#422006" : (cell.vis ? "#111c2e" : "#0b1220"); wrap.style.borderColor = miss ? "#a16207" : "#1e293b"; } } var hint = box.querySelector("#ado-attr-hint"); if (hint) hint.innerHTML = attrHintHtml(state); } function renderAttrPanel(camNames, state) { var box = document.getElementById("ado-attr-modal"); if (!box) { box = document.createElement("div"); box.id = "ado-attr-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:12px/1.5 system-ui,'Microsoft YaHei',sans-serif"; document.body.appendChild(box); } var nNeed = 0; state.forEach(function (r) { r.cells.forEach(function (c) { if (c.t === null || c.o === null) nNeed++; }); }); var head = "对象"; camNames.forEach(function (n) { head += "" + n + ""; }); head += ""; var body = ""; state.forEach(function (r, ri) { body += "" + "#" + r.id + " " + (r.sub || "") + ""; r.cells.forEach(function (c, ci) { /* 不可见的格子也要显示 —— 它可能是 null(平台黄框待填), 只是提示语不同:看不到的视角一律建议 0(无截断无遮挡)。 */ var miss = (c.t === null || c.o === null); var bg = miss ? "#422006" : (c.vis ? "#111c2e" : "#0b1220"); var bd = miss ? "#a16207" : "#1e293b"; var tcol = c.vis ? "#4ade80" : "#475569"; var ocol = c.vis ? "#fbbf24" : "#475569"; var tip = c.vis ? "该视角可见" : "该视角看不到此物体,建议填 0"; body += "" + "" + "" + (c.t === null ? "-" : c.t) + "" + "/" + "" + (c.o === null ? "-" : c.o) + "" + ""; }); body += ""; }); box.innerHTML = "
" + "
摄像头属性快填
" + "
" + attrHintHtml(state) + "
" + "" + head + body + "
" + "
" + "" + "" + "" + "
"; box.querySelector("#ado-attr-close").onclick = function () { box.remove(); }; box.addEventListener("click", function (ev) { if (ev.target === box) box.remove(); }); /* 点数字循环 */ box.querySelectorAll("b[data-ri]").forEach(function (el) { el.onclick = function () { var ri = +el.getAttribute("data-ri"), ci = +el.getAttribute("data-ci"); var f = el.getAttribute("data-f"), c = state[ri].cells[ci]; var v = c[f]; /* 循环 0→1→2→3→4→0(truncation/occlusion 取值范围是 0~4) */ c[f] = (v === null || v === undefined) ? 0 : (v >= 4 ? 0 : v + 1); /* ⚠ 只改这一个数字 + 刷新说明行,绝不重建整个弹窗 —— 整块重建会重绘所有 DOM,点击时视觉上会「闪一下」。 */ refreshAttrValues(box, state); }; }); box.querySelector("#ado-attr-sug").onclick = function () { state.forEach(function (r) { r.cells.forEach(function (c) { /* 看不到的视角:截断/遮挡必然是 0(物体不在画面里) */ if (!c.vis) { if (c.t === null) c.t = 0; if (c.o === null) c.o = 0; return; } if (c.t === null) c.t = (c.sug === null ? 0 : c.sug); if (c.o === null) c.o = 0; }); }); refreshAttrValues(box, state); }; box.querySelector("#ado-attr-zero").onclick = function () { state.forEach(function (r) { r.cells.forEach(function (c) { c.t = 0; c.o = 0; }); }); refreshAttrValues(box, state); }; box.querySelector("#ado-attr-apply").onclick = function () { applyAttrChanges(state); toast("属性已应用(" + state.length + " 个对象)", "green"); box.remove(); }; } /* 把表格里的值写回平台 */ function applyAttrChanges(state) { var st = findStores(true), main = st.main, scene = st.scene; if (!main) return; var objs = ((main.annotation && main.annotation.object_list) || []).slice(); var cams = (main.calibration && main.calibration.cameras) || []; var nSet = 0, i, j, k; for (i = 0; i < state.length; i++) { var row = state[i], o = objs[i]; if (!o) continue; for (j = 0; j < row.cells.length; j++) { var c = row.cells[j]; /* 不再跳过「看不到」的视角 —— 它们也可能是 null(平台黄框待填), 需要按用户/预填的值写回去。下面的「值未变则跳过」已保证幂等。 */ var name = cams[j] ? cams[j].name : c.name; for (k = 0; k < (o.cameras || []).length; k++) { if (o.cameras[k].name === name) { if (o.cameras[k].truncation !== c.t || o.cameras[k].occlusion !== c.o) { o.cameras[k].truncation = c.t; o.cameras[k].occlusion = c.o; nSet++; } break; } } } } main.replaceCurrentObjects(objs); /* ⚠ 必须同步重建场景里的 shape。 平台的数据流是双向的:object_list → shape(渲染 effect), shape.meta → object_list(反解)。只改 object_list 而不更新 shape.meta 时, 下一次渲染会用 shape 里的旧 meta 把改动覆盖回去 —— 表现就是「智能预填好的属性,改一个视角后其它视角莫名清空」(用户反馈)。 因为覆盖依赖渲染时机,所以现象时有时无、难以复现。 */ if (scene && scene.annotationStore) { try { var store = scene.annotationStore; store.clear(); for (i = 0; i < objs.length; i++) { var o2 = objs[i]; store.add({ id: "ado-" + o2.tracking_id, interfaceId: o2.category, points: AdoFit.cornersOf(o2.pose_in_lidar.location, o2.pose_in_lidar.orientation, o2.length, o2.width, o2.height), zOrder: i + 1, meta: o2 }); } } catch (e) { } } return nSet; } /* dir = -1 从上一帧复制;dir = +1 从下一帧复制。 两个方向共用同一套逻辑(读源帧 → 点云配准估自车位移 → 补偿 → 按 ID 去重合并), 只有源帧不同。 noComp = true 时跳过全部补偿,坐标原样搬 —— 即「照搬平台 L 键的复制逻辑」。 「纯复制下帧」按钮用的就是这个模式(源帧是下一帧)。 */ async function smartCopyPrev(dir, noComp) { dir = (dir === 1) ? 1 : -1; var lbl = dir < 0 ? "上一帧" : "下一帧"; var st = findStores(true), main = st.main, scene = st.scene; if (!main || !scene || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var idx = main.currentIndex, frames = main.frames || []; var srcIdx = idx + dir; if (srcIdx < 0 || srcIdx >= frames.length) { toast("没有" + lbl + "可复制", "orange"); return; } if (!frames[srcIdx] || !frames[srcIdx].annotation) { toast(lbl + "没有标注文件", "red"); return; } tipline("读取" + lbl + "…", "orange"); var prev; try { prev = await bridgeGetJson(frames[srcIdx].annotation.fileId); } catch (e) { toast("读取失败:" + e.message, "red"); return; } var prevObjs = prev.object_list || []; if (!prevObjs.length) { toast(lbl + "没有对象", "orange"); return; } /* 估计自车位移:优先点云配准。 对象法在「源帧本身就是复制来的」时会失效 —— 位移恒为 0(循环论证), 实测 #121 就是这么没被补偿的。点云是真实观测,永远可用。 配准参数顺序是 (源帧点云, 当前帧点云),返回的变换就是「源帧的点 在当前帧点云里应出现在 R(yaw)·p + (dx,dy)」,方向天然正确。 */ var ego = null, egoSrc = "", xyzCur = null, gzCur = 0; tipline("点云配准中…", "orange"); try { var prevPcd = noComp ? null : (frames[srcIdx].pcd && frames[srcIdx].pcd.fileId); if (prevPcd && main.pcdBuffer) { var bufA = await bridgeGetBuffer(prevPcd); var pcdA = AdoFit.parsePCD(bufA), pcdB = AdoFit.parsePCD(main.pcdBuffer); if (pcdA && pcdB) { var xyzA = AdoFit.denseXYZ(pcdA), xyzB = AdoFit.denseXYZ(pcdB); var gz = (AdoFit.estimateGround(xyzA) + AdoFit.estimateGround(xyzB)) / 2; xyzCur = xyzB; gzCur = gz; /* 留给后面的物体级微调复用 */ var sh = estimateShiftByCloud(xyzA, xyzB, gz, 3.0, 6.0); if (sh.score > 0.35) { ego = { dx: sh.dx, dy: sh.dy, yaw: sh.yaw, n: -1, spread: null, ok: true }; egoSrc = "点云配准(重叠度 " + sh.score.toFixed(2) + (Math.abs(sh.yaw) > 0.2 ? ",自车转了 " + sh.yaw.toFixed(1) + "°" : "") + ")"; } } } } catch (e) { /* 配准失败 → 退回对象法 */ } if (!ego && !noComp) { var prev2 = null; /* 对象法 fallback:取「源帧再往前/往后一帧」估计自车位移(方向要一致) */ var fbIdx = idx + 2 * dir; if (fbIdx >= 0 && fbIdx < frames.length && frames[fbIdx] && frames[fbIdx].annotation) { try { prev2 = await bridgeGetJson(frames[fbIdx].annotation.fileId); } catch (e) { } } ego = estimateEgoShift(prev2 ? (prev2.object_list || []) : null, prevObjs); egoSrc = "对象位移中位数" + (ego.n ? "(" + ego.n + " 个样本)" : ""); } if (noComp) { /* 纯复制模式:零位移、也不做点云微调,坐标原样搬过来(照搬平台 L 的逻辑) */ ego = { dx: 0, dy: 0, yaw: 0, n: 0, spread: null, ok: true }; egoSrc = "未补偿(照搬平台 L 的复制逻辑)"; } /* 按 ID 去重:当前帧已经有的 tracking_id 不重复粘贴,只补充缺的那些。 (用户要求:把源帧的框「合并」进来,而不是整帧替换 —— 这样当前帧手工调过的框不会被源帧的版本覆盖掉。) */ var curObjs = main.annotation.object_list || []; var haveIds = Object.create(null), hi; for (hi = 0; hi < curObjs.length; hi++) haveIds[curObjs[hi].tracking_id] = 1; var nSkipped = 0, todoObjs = []; for (hi = 0; hi < prevObjs.length; hi++) { if (haveIds[prevObjs[hi].tracking_id]) { nSkipped++; continue; } todoObjs.push(prevObjs[hi]); } if (!todoObjs.length) { toast("当前帧已包含" + lbl + "的全部 " + prevObjs.length + " 个 ID,无需粘贴", "orange"); return; } /* 物体级微调:在当前帧点云里给每个框找最贴合的位置(收掉全局平移模型吃不下的残差) */ var GRID = null; if (xyzCur) { try { GRID = bevCountGrid(xyzCur, gzCur, 0.25, 40); } catch (e) { GRID = null; } } /* 已占位表(当前帧原有对象的位置):防止微调把两个框吸到同一处。 近邻物体的搜索窗会重叠,实测出现过两个框完全叠在一起的情况。 */ var placed = [], pi; for (pi = 0; pi < curObjs.length; pi++) { var po = curObjs[pi]; if (po.pose_in_lidar && po.pose_in_lidar.location) { placed.push([po.pose_in_lidar.location[0], po.pose_in_lidar.location[1], po.tracking_id]); } } /* 构造待粘贴对象:位置补上自车位移,属性按平台 L 的规则重置 */ var nRefined = 0; var newObjs = todoObjs.map(function (o) { var c = JSON.parse(JSON.stringify(o)); var loc = o.pose_in_lidar.location; /* 配准模型是 R(yaw)·p + (dx, dy):先绕 z 轴转,再平移 */ var eyaw = (ego.yaw || 0) * Math.PI / 180; var eca = Math.cos(eyaw), esa = Math.sin(eyaw); var nx = loc[0] * eca - loc[1] * esa + ego.dx; var ny = loc[0] * esa + loc[1] * eca + ego.dy; if (GRID) { var yaw = AdoFit.yawOf(o.pose_in_lidar.orientation); /* 搜索半径 0.6m(原来 1.5m):只收全局配准的残差。 ⚠ 1.5m 时近邻物体的搜索窗会重叠、一起吸到同一处密集点云上, 实测出现过两个框完全叠在一起(用户反馈「两个框都重叠上去了」)。 */ var rf = refinePos(GRID, nx, ny, o.length, o.width, yaw, 0.6, 0.2); if (rf.c > 0 && (rf.dx !== 0 || rf.dy !== 0)) { var cx2 = nx + rf.dx, cy2 = ny + rf.dy; /* 重叠保护:微调后会贴到已有框上(中心距 < 1m)就放弃这次微调 */ var tooClose = false, pj; for (pj = 0; pj < placed.length; pj++) { var ddx2 = cx2 - placed[pj][0], ddy2 = cy2 - placed[pj][1]; if (ddx2 * ddx2 + ddy2 * ddy2 < 1.0) { tooClose = true; break; } } if (!tooClose) { nx = cx2; ny = cy2; nRefined++; } } } placed.push([nx, ny, o.tracking_id]); /* 登记位置,供后续对象避让 */ var nloc = [nx, ny, loc[2]]; c.pose_in_lidar = { location: nloc, orientation: AdoFit.yawQuat(o.pose_in_lidar.orientation, eyaw) }; if (c.pose_in_vehicle) c.pose_in_vehicle.location = nloc.slice(); if (c.pose_in_lidar_target) c.pose_in_lidar_target.location = nloc.slice(); /* 摄像头属性原样带过来(不重置为 null)。 平台 L 键会把有 bbox 的视角属性清空强制重填,但用户要求复制时保留 —— 相邻帧同一物体的遮挡/截断大多一致,保留能省掉大量重复填写。 */ c.cameras = (o.cameras || []).map(function (cam) { var m = {}, k; for (k in cam) m[k] = cam[k]; return m; }); return c; }); /* 走平台内存:替换对象列表 + 重建场景框(与平台 L 同路径) */ try { /* 合并写入:当前帧原有对象 + 新粘贴的(ID 已去重,不会冲突) */ var merged = curObjs.concat(newObjs); main.replaceCurrentObjects(merged); var store = scene.annotationStore; store.clear(); for (var i = 0; i < merged.length; i++) { var o2 = merged[i]; store.add({ id: "ado-" + o2.tracking_id, interfaceId: o2.category, points: AdoFit.cornersOf(o2.pose_in_lidar.location, o2.pose_in_lidar.orientation, o2.length, o2.width, o2.height), zOrder: i + 1, meta: o2 }); } try { main.registerUsedTrackingIds(merged.map(function (o) { return o.tracking_id; })); } catch (e) { } } catch (e) { toast("写入失败:" + e.message, "red"); return; } var msg = "已粘贴 " + newObjs.length + " 个对象" + (nSkipped ? "(跳过当前帧已有的 " + nSkipped + " 个 ID)" : "") + (noComp ? ",未做补偿(照搬 L)" : ",位移补偿 (" + ego.dx.toFixed(2) + ", " + ego.dy.toFixed(2) + ")m" + (nRefined ? ",其中 " + nRefined + " 个做了点云微调" : "")); if (ego.ok) { toast(msg + " 来源:" + egoSrc, "green"); } else { toast(msg + ";但自车位移估计不可靠(" + (ego.why || ("离散度 " + (ego.spread === null ? "?" : ego.spread.toFixed(2)) + "m")) + ",来源:" + egoSrc + "),静止物体可能仍有偏移,请检查", "orange"); } } async function runCountAll() { var st = findStores(true), main = st.main; if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; } var total = main.total || 0; if (!total) { toast("读不到帧数", "red"); return; } /* 走文件服务并行拉取,不再逐帧切换:不改变视图、不受切帧守卫限制、快约 9 倍 */ tipline("统计中…(并行读取 " + total + " 帧)", "orange"); var all, perFrame = [], skipped = [], i, k; try { all = await loadAllFrames(main); } catch (e) { toast("读取失败:" + e.message, "red"); return; } for (i = 0; i < all.length; i++) { var fr = all[i]; if (!fr.data) { skipped.push(fr.frame); continue; } var objs = fr.data.object_list || [], ids = []; for (k = 0; k < objs.length; k++) ids.push(objs[k].tracking_id); perFrame.push({ frame: fr.frame, n: objs.length, ids: ids }); } var res = showCountDialog(perFrame, total, skipped); toast("统计完成:共 " + res.nBox + " 个框(" + perFrame.length + " 帧," + res.uniq + " 个不同对象)", "green"); } function showCountDialog(perFrame, total, skipped) { var old = document.getElementById("ado-count-modal"); if (old) old.remove(); var nBox = 0, idMap = Object.create(null); perFrame.forEach(function (f) { nBox += f.n; f.ids.forEach(function (id) { idMap[id] = (idMap[id] || 0) + 1; }); }); var uniq = Object.keys(idMap).length; var avg = perFrame.length ? (nBox / perFrame.length) : 0; var rows = "", i; for (i = 0; i < perFrame.length; i++) { var f = perFrame[i]; rows += "" + "" + (f.frame + 1) + "" + "" + f.n + "" + "" + (f.ids.length ? f.ids.join(", ") : "—") + ""; } var box = document.createElement("div"); box.id = "ado-count-modal"; box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" + "display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif"; box.innerHTML = "
" + "
框数统计
" + "
" + "扫描帧数 " + perFrame.length + " / " + total + " · 总框数 " + nBox + "" + " · 平均 " + avg.toFixed(1) + " 个/帧" + " · 不同对象 " + uniq + " 个" + (skipped.length ? "
跳过 " + skipped.length + " 帧:" + fmtFrames(skipped) + "(切换失败)" : "") + "
" + "" + "" + "" + "" + rows + "
帧号框数对象 ID
" + "
" + "" + "
"; document.body.appendChild(box); box.querySelector("#ado-count-close").onclick = function () { box.remove(); }; box.querySelector("#ado-count-copy").onclick = function () { var lines = ["帧号\t框数\t对象ID"]; perFrame.forEach(function (f) { lines.push((f.frame + 1) + "\t" + f.n + "\t" + f.ids.join(",")); }); lines.push("合计\t" + nBox + "\t不同对象 " + uniq + " 个"); var txt = lines.join("\n"); try { navigator.clipboard.writeText(txt); this.textContent = "已复制"; } catch (e) { window.prompt("复制以下内容:", txt); } }; box.addEventListener("click", function (e) { if (e.target === box) box.remove(); }); if (console.table) { console.table(perFrame.map(function (f) { return { 帧号: f.frame + 1, 框数: f.n, 对象ID: f.ids.join(",") }; })); } return { nBox: nBox, uniq: uniq, avg: avg }; } /* ======================== 面板 ======================== */ /* ======================== 自动勾选「上下限例外」(尺寸异常) ======================== 平台更新后新增:对象尺寸超出该类别允许范围时,左侧尺寸区会多出一个 「上下限例外」勾选框,不勾选就没法保存/移动该框。逐个手动勾太费事,这里自动勾。 默认开启,面板里可手动关掉。 判据:勾选框「出现」本身就说明当前对象被判为尺寸异常 —— 不用自己算各类别的尺寸范围,直接跟着平台的判定走,平台改规则也不会失效。 */ var AUTO_EXEMPT = true; var AUTO_PASTE = true; /* 粘贴出来的对象是否自动补全类型/摄像头属性 */ function findExemptBox() { var boxes = document.querySelectorAll("input[type=checkbox]"); for (var i = 0; i < boxes.length; i++) { var el = boxes[i]; var p = el.closest("label") || el.parentNode; var tx = p ? (p.textContent || "") : ""; if (tx.indexOf("上下限例外") >= 0 || tx.indexOf("超出允许范围") >= 0) return el; } return null; } /* 判断某个列表项是否带「尺寸超范围」标记。 ⚠ 平台的标签文案变过:早期是「尺寸异常」,现在是「尺寸例外」, 所以不要匹配可见文字,直接看标签的 title —— 那里始终写着 「尺寸超出允许范围 — L 4.67(允许 3.937~4.663)」,最稳。 */ function liHasSizeFlag(li) { var spans = li.querySelectorAll("span"), i, t; for (i = 0; i < spans.length; i++) { t = spans[i].getAttribute("title") || ""; if (t.indexOf("尺寸超出允许范围") >= 0) return true; } /* 兜底:万一 title 也没了,退回匹配可见文字(两种历史文案都认) */ var tx = li.textContent || ""; return tx.indexOf("尺寸例外") >= 0 || tx.indexOf("尺寸异常") >= 0; } /* 从左侧对象列表读出所有被平台标记「尺寸超范围」的 ID。 列表项文本形如「133车辆尺寸例外乘用车」,取开头的数字即可。 */ function collectAnomalyIds() { var ids = [], seen = Object.create(null), i, tx, m, id; var items = document.querySelectorAll("li"); for (i = 0; i < items.length; i++) { if (!liHasSizeFlag(items[i])) continue; tx = items[i].textContent || ""; m = tx.match(/(\d{1,6})/); if (!m) continue; id = +m[1]; if (!seen[id]) { seen[id] = 1; ids.push(id); } } return ids; } /* 批量勾选「上下限例外」。 ⚠ 关键:不能只靠 DOM 上那个勾选框 —— 它只在对象被选中时才渲染, 所以之前必须逐个走帧才能勾完(用户反馈「很麻烦」)。 这里直接改数据层:给对象设 is_size_limit_exempt,一次性写回。 只在真的有变化时才写,避免每轮都触发平台重渲染。 */ function exemptAllAnomalies() { var st = findStores(true), main = st.main; if (!main || !main.replaceCurrentObjects) return 0; var objs = ((main.annotation && main.annotation.object_list) || []).slice(); var ids = collectAnomalyIds(); if (!ids.length) return 0; var want = Object.create(null), i; for (i = 0; i < ids.length; i++) want[ids[i]] = 1; var n = 0; for (i = 0; i < objs.length; i++) { if (want[objs[i].tracking_id] && objs[i].is_size_limit_exempt !== true) { objs[i] = JSON.parse(JSON.stringify(objs[i])); /* 换新引用,React 才认得出变化 */ objs[i].is_size_limit_exempt = true; n++; } } if (n) { try { main.replaceCurrentObjects(objs); } catch (e) { return 0; } } return n; } function tickExempt() { if (!AUTO_EXEMPT) return; try { /* ① 当前选中对象:走平台的勾选框(原生通道最保险) */ var el = findExemptBox(); if (el && !el.checked && !el.disabled) el.click(); /* ② 其余没被选中的异常对象:数据层批量勾上 */ exemptAllAnomalies(); } catch (e) { } } function startExemptWatcher() { tickExempt(); /* 用轮询而不是 MutationObserver。 ⚠ React 改的是 checkbox 的 checked「property」(el.checked = x), 而 MutationObserver 的 attributes 监听的是 HTML「attribute」, 两者不是一回事 —— 实测挂 childList + attributes 都收不到取消勾选的通知。 间隔 800ms:一次 querySelectorAll 开销可忽略,也避免写得太频繁。 */ setInterval(tickExempt, 800); } /* ======================== 粘贴补全属性 ======================== 平台的复制粘贴只带几何(尺寸 + 位姿):实测粘贴出来的对象 category / sub_class 为 null,cameras 的各个值也全是 null。 这里监听对象列表,发现「本轮新增且属性缺失」的对象时, 在同帧里找尺寸完全相同的完整对象当源,把属性补齐。 */ var _pasteIdx = -1; /* 上次看到的帧号 */ var _pasteIds = null; /* 上次看到的 ID 集合 */ var _seenObjs = Object.create(null); /* tracking_id → 完整对象(跨帧粘贴时当源用) */ /* 记住见过的完整对象。 平台的剪贴板只存 {id, interfaceId, points}(实测),不带 sub_class / cameras, 所以跨帧粘贴时源对象不在当前帧,只能从「之前见过的帧」里找。 tracking_id 跨帧唯一,用它当键最可靠。 */ function rememberObjs(objs) { for (var i = 0; i < objs.length; i++) { var o = objs[i]; if (!isAttrMissing(o)) _seenObjs[o.tracking_id] = JSON.parse(JSON.stringify(o)); } } /* 读平台剪贴板里的源对象 ID。 clipboardManager.items = [{ id:"ado-139", interfaceId, points }] —— id 里的数字 就是源对象的 tracking_id。跨帧粘贴时源对象不在当前帧,靠这个直接定位最可靠 (比按尺寸模糊匹配准得多,也不会撞到同尺寸的别的车)。 */ function readClipboardSrcIds() { try { var st = findStores(true); var cm = st.scene && st.scene.clipboardManager; if (!cm || !cm.items || !cm.items.length) return []; var out = []; for (var i = 0; i < cm.items.length; i++) { var m = String(cm.items[i].id || "").match(/(\d+)/); if (m) out.push(+m[1]); } return out; } catch (e) { return []; } } /* 读场景 shape store 里所有 entry 的 id(形如 "ado-197")。 ⚠ 同帧粘贴时平台只往场景里加 shape,**不写 annotation.object_list** —— 实测:shape 层 21 个(含 ado-197),数据层还是 20 个。 所以只比对 object_list 的 ID 是发现不了同帧粘贴的。 */ function getShapeEntries(st) { try { var s = st.scene && st.scene.annotationStore; if (!s || typeof s.getAll !== "function") return null; return s.getAll(); } catch (e) { return null; } } function shapeEntryId(ent) { var m = String(ent && ent.id || "").match(/(\d+)/); return m ? +m[1] : null; } /* 选中指定对象(平台粘贴后会取消选中,这里恢复)。 ⚠ 必须走 store 的方法,不能直接改 _selectedIds 这个 Set —— 直接改只是「伪选中」:3D 框不变绿、左侧属性面板也不刷新 (用户反馈「必须再点一下才正常」)。replaceWith 会走完整选中流程: 通知订阅者、更新高亮、刷新属性面板。 */ function selectIds(st, ids) { try { var ss = st.scene && st.scene.selectionStore; if (!ss) return false; var full = []; for (var i = 0; i < ids.length; i++) full.push("ado-" + ids[i]); if (typeof ss.replaceWith === "function") { ss.replaceWith(full); return true; } if (typeof ss.select === "function") { ss.select(full); return true; } /* 兜底:实在没有方法才退回直接改 Set */ if (ss._selectedIds && ss._selectedIds.add) { ss._selectedIds.clear(); for (var j = 0; j < full.length; j++) ss._selectedIds.add(full[j]); if (ss.onDirty) ss.onDirty(); } return true; } catch (e) { return false; } } /* 强制左侧属性面板重读数据:先清空选中、下一帧再选回来。 平台面板只在「选中变化」时才重读对象 —— 我们改的是数据层, 不切一下选中,面板上还是旧类型/旧属性(用户反馈「得再点两下才刷新」)。 */ function refreshSelectionUI(st, ids) { try { var ss = st.scene && st.scene.selectionStore; if (!ss) return; if (typeof ss.clearSelection === "function") ss.clearSelection(); else if (ss._selectedIds) { ss._selectedIds.clear(); if (ss.onDirty) ss.onDirty(); } /* 160ms:要等平台把新 object_list 渲染完、清空选中之后,我们再选回来。 40ms 太早,会被那次重渲染冲掉。 */ setTimeout(function () { selectIds(st, ids); }, 160); } catch (e) { } } function objSizeKey(o) { return o.length.toFixed(3) + "," + o.width.toFixed(3) + "," + o.height.toFixed(3); } /* 属性是否缺失。 ⚠ 不能只看类型为空:实测平台粘贴时会带上 category(interfaceId)甚至 sub_class, 但摄像头属性一律是 null。所以判据要两条都看: · 类型为空 → 缺 · 类型有、但任一视角的截断/遮挡是 null → 也算缺 */ function isAttrMissing(o) { if (!o.sub_class || !o.category) return true; var cs = o.cameras || [], i; if (!cs.length) return true; for (i = 0; i < cs.length; i++) { if (cs[i].truncation === null || cs[i].truncation === undefined) return true; if (cs[i].occlusion === null || cs[i].occlusion === undefined) return true; } return false; } function completePastedAttrs() { var st = findStores(true), main = st.main; if (!main || !main.replaceCurrentObjects) return 0; var objs = ((main.annotation && main.annotation.object_list) || []).slice(); var i, j, o, key, curIds = Object.create(null); for (i = 0; i < objs.length; i++) curIds[objs[i].tracking_id] = 1; /* 切帧 / 首次运行:只更新快照不处理 —— 否则整帧对象都会被当成「本轮新增」,白白跑一遍还容易补错。 */ if (main.currentIndex !== _pasteIdx || _pasteIds === null) { _pasteIdx = main.currentIndex; _pasteIds = curIds; rememberObjs(objs); /* 顺便记住这一帧的完整对象,供之后跨帧粘贴当源 */ return 0; } /* ① 同帧粘贴:平台只往场景加了 shape、没写数据层 → 先把这些「孤儿 shape」 补进 objs。否则它们既不在数据里、也没有属性, 用户看到的就是「框在那儿、但没选中、也没属性」。 */ var fresh = [], fi, si; var shapeAll = getShapeEntries(st); if (shapeAll) { var inData = Object.create(null); for (fi = 0; fi < objs.length; fi++) inData[objs[fi].tracking_id] = 1; for (si = 0; si < shapeAll.length; si++) { var sid = shapeEntryId(shapeAll[si]); if (sid === null || inData[sid]) continue; var mt = shapeAll[si].meta; if (!mt) continue; var nm = JSON.parse(JSON.stringify(mt)); nm.tracking_id = sid; objs.push(nm); fresh.push(sid); } } /* ② 数据层新增的(跨帧粘贴,或平台直接写数据的情况) */ for (fi = 0; fi < objs.length; fi++) { if (!_pasteIds[objs[fi].tracking_id] && fresh.indexOf(objs[fi].tracking_id) < 0) { fresh.push(objs[fi].tracking_id); } } if (fresh.length) selectIds(st, fresh); /* 开关关掉时只更新快照、不做补全 —— 否则重新打开会把这一整轮的对象都当成「新增」,误补一遍。 */ if (!AUTO_PASTE) { _pasteIds = curIds; rememberObjs(objs); return 0; } var byKey = Object.create(null), need = []; for (i = 0; i < objs.length; i++) { o = objs[i]; key = objSizeKey(o); if (!byKey[key]) byKey[key] = []; byKey[key].push(o); if (!_pasteIds[o.tracking_id] && isAttrMissing(o)) need.push(o); } _pasteIds = curIds; if (!need.length) return 0; var n = 0; var srcIds = readClipboardSrcIds(); /* 剪贴板里的源对象 ID,跨帧补全靠它 */ for (i = 0; i < need.length; i++) { var tgt = need[i], best = null, bd = 1e9; /* ① 剪贴板源 ID 直接命中 —— 跨帧时源对象不在当前帧,这是最可靠的路径 */ for (j = 0; j < srcIds.length; j++) { var sc = _seenObjs[srcIds[j]]; if (sc && !isAttrMissing(sc)) { best = sc; break; } } /* ② 同帧找尺寸相同的完整对象(多个时取位置最近的) */ if (!best) { var cands = byKey[objSizeKey(tgt)] || []; for (j = 0; j < cands.length; j++) { var c = cands[j]; if (c === tgt || isAttrMissing(c)) continue; var dx = c.pose_in_lidar.location[0] - tgt.pose_in_lidar.location[0]; var dy = c.pose_in_lidar.location[1] - tgt.pose_in_lidar.location[1]; var d = dx * dx + dy * dy; if (d < bd) { bd = d; best = c; } } } /* ③ 尺寸模糊匹配「见过的对象」。 容差 0.06m:剪贴板存的是 8 个角点,平台反算出的尺寸与源值 可能有浮点级差异,用精确匹配会漏。 */ if (!best) { var tL = tgt.length, tW = tgt.width, tH = tgt.height; for (var tid in _seenObjs) { var c2 = _seenObjs[tid]; if (c2.tracking_id === tgt.tracking_id) continue; if (Math.abs(c2.length - tL) < 0.06 && Math.abs(c2.width - tW) < 0.06 && Math.abs(c2.height - tH) < 0.06) { best = c2; break; } } } if (!best) continue; var idx = objs.indexOf(tgt); if (idx < 0) continue; var cp = JSON.parse(JSON.stringify(tgt)); cp.category = best.category; cp.sub_class = best.sub_class; cp.cameras = JSON.parse(JSON.stringify(best.cameras || [])); objs[idx] = cp; n++; } if (n) { try { main.replaceCurrentObjects(objs); /* ⚠ 必须同步重建场景 shape。 平台的数据流是双向的(object_list ↔ shape.meta), 粘贴出来的 shape.meta 是「缺属性」那一版,只改 object_list 的话 会被它反解覆盖回去 —— 和「属性快填被清空」是同一个坑。 */ var scene = st.scene; if (scene && scene.annotationStore) { var store = scene.annotationStore; store.clear(); for (i = 0; i < objs.length; i++) { var oo = objs[i]; store.add({ id: "ado-" + oo.tracking_id, interfaceId: oo.category, points: AdoFit.cornersOf(oo.pose_in_lidar.location, oo.pose_in_lidar.orientation, oo.length, oo.width, oo.height), zOrder: i + 1, meta: oo }); } } } catch (e) { return 0; } } /* ⚠ 恢复选中必须放在 replaceCurrentObjects 之后,而且要有延迟: 平台收到新的 object_list 会重建 shape 并清空选中, 紧接着调 selectIds 会被这次重渲染冲掉(实测 sel 变成空数组)。 */ if (fresh.length) refreshSelectionUI(st, fresh); rememberObjs(objs); /* 补全后的对象也记下来(含刚粘贴的那个) */ return n; } function startPasteWatcher() { /* 400ms:粘贴后要尽快补上,不然用户会看到类型空着 */ setInterval(function () { try { completePastedAttrs(); } catch (e) { } }, 400); } function buildUI() { if (document.getElementById("ado-fit-panel")) return; var p = document.createElement("div"); p.id = "ado-fit-panel"; /* ⚠ 固定宽度 + 可手动调整 + 内容可滚动: - width 固定是为了避免进度提示文字长短把面板撑得忽宽忽窄; - resize:both 让用户能拖右下角调大小(需要 overflow 非 visible); - overflow:auto 让缩小时内容能滚轮滚动(不然缩了看不到下面的按钮)。 */ p.style.cssText = "position:fixed;left:10px;bottom:10px;z-index:2147482000;" + "width:250px;box-sizing:border-box;resize:both;overflow:auto;" + "min-width:210px;min-height:44px;max-width:92vw;max-height:92vh;" + "background:rgba(15,23,42,.94);border:1px solid #334155;border-radius:10px;" + "padding:8px 10px 6px;font:12px/1.5 system-ui,'Microsoft YaHei',sans-serif;color:#e2e8f0;" + "box-shadow:0 6px 20px rgba(0,0,0,.45);user-select:none"; /* 分组行:左标签 + 若干按钮(btnCss 带 flex:1,按钮自动平分剩余宽度) */ function row(lbl, btns) { var s = "
" + "" + (lbl || "") + ""; for (var bi = 0; bi < btns.length; bi++) { s += ""; } return s + "
"; } p.innerHTML = "
" + "自动贴合" + "拖动可移" + "
" + "
" + row("贴合", [["ado-fit-sel", "贴合选中", "只处理当前选中的框(Ctrl+Alt+F)", "#2563eb"]]) + row("复制", [ ["ado-fit-smartcopy", "复制上帧", "从上一帧复制到当前帧:按 ID 去重合并 + 自车位移补偿 + 点云微调;摄像头属性原样带过来", "#059669"], ["ado-fit-smartnext", "复制下帧", "从下一帧复制到当前帧:逻辑与「复制上帧」完全一致,只换源帧", "#0d9488"] ]) + row("", [ ["ado-fit-pureprev", "纯复制上帧", "照搬平台 L 键的复制逻辑(不做补偿),源帧是上一帧;摄像头属性原样带过来", "#475569"], ["ado-fit-purecopy", "纯复制下帧", "照搬平台 L 键的复制逻辑(不做补偿),源帧是下一帧", "#475569"] ]) + row("属性", [ ["ado-fit-attr", "属性快填", "摄像头属性表格:自动判断哪些视角看不到物体,直接点数字填", "#7c3aed"], ["ado-fit-cam", "改属性", "对指定对象 ID 跨帧批量修改 4 路摄像头属性", "#0d9488"] ]) + row("检查", [ ["ado-fit-qc1", "质检本帧", "按客户质检规则检查当前帧", "#b45309"], ["ado-fit-qc", "质检整包", "整包逐帧质检并聚合问题", "#b45309"] ]) + row("", [["ado-fit-count", "统计框数", "逐帧统计整个包的框数(不分类型)", "#1d4ed8"]]) + row("", [["ado-fit-clear", "清空全帧", "删除本包全部帧的所有对象标注(不可撤销,需输入确认词)", "#991b1b"]]) + "
" + "强度" + "
" + "" + "" + "" + "
" + "
"; /* 闭合 ado-fit-body */ document.body.appendChild(p); /* 最小化 / 展开:折叠后只留标题栏一条,状态记在 localStorage */ (function () { var minBtn = p.querySelector("#ado-fit-min"); var body = p.querySelector("#ado-fit-body"); var hint = p.querySelector("#ado-fit-hint"); if (!minBtn || !body) return; function applyCollapsed(col) { body.style.display = col ? "none" : ""; minBtn.textContent = col ? "+" : "—"; minBtn.title = col ? "展开面板" : "最小化面板"; if (hint) hint.style.display = col ? "none" : ""; /* ⚠ 折叠时要把 min-height 也清掉,否则面板仍撑着 44px 一块空白, 看着就像「只隐藏了文字、框体还在」(用户反馈)。 */ p.style.minHeight = col ? "0" : "44px"; p.style.height = col ? "auto" : ""; } try { applyCollapsed(localStorage.getItem("adoFitCollapsed") === "1"); } catch (e) { } minBtn.onclick = function (ev) { if (ev) ev.stopPropagation(); var col = body.style.display !== "none"; applyCollapsed(col); try { localStorage.setItem("adoFitCollapsed", col ? "1" : "0"); } catch (e) { } }; })(); function btnCss(bg) { return "flex:1;padding:6px 8px;border-radius:6px;border:0;background:" + bg + ";color:#fff;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap"; } p.querySelector("#ado-fit-sel").onclick = runSelected; /* 以下三个按钮已从面板隐藏(功能代码保留,需要时把 row() 里的按钮加回来即可): 全贴合 runAll / 撤销 undo / 一键新增 runAddAll。 这里做空值保护,否则 querySelector 返回 null 会抛错、导致后面所有绑定失效。 */ (function () { var b1 = p.querySelector("#ado-fit-all"); if (b1) b1.onclick = runAll; var b2 = p.querySelector("#ado-fit-undo"); if (b2) b2.onclick = undo; var b3 = p.querySelector("#ado-fit-add"); if (b3) b3.onclick = runAddAll; })(); p.querySelector("#ado-fit-cam").onclick = openCamPanel; p.querySelector("#ado-fit-qc1").onclick = runQc; p.querySelector("#ado-fit-qc").onclick = runQcAll; p.querySelector("#ado-fit-clear").onclick = openClearAll; p.querySelector("#ado-fit-count").onclick = runCountAll; /* ⚠ 必须包一层:直接把 smartCopyPrev 赋给 onclick 会把 MouseEvent 当 dir 传进去 */ p.querySelector("#ado-fit-smartcopy").onclick = function () { smartCopyPrev(-1); }; p.querySelector("#ado-fit-smartnext").onclick = function () { smartCopyPrev(1); }; /* 纯复制:不补偿,照搬平台 L 的复制逻辑,只是源帧不同 */ p.querySelector("#ado-fit-pureprev").onclick = function () { smartCopyPrev(-1, true); }; p.querySelector("#ado-fit-purecopy").onclick = function () { smartCopyPrev(1, true); }; p.querySelector("#ado-fit-attr").onclick = openAttrPanel; p.querySelector("#ado-fit-strength").onchange = function () { STRENGTH = this.value; tipline("强度:" + AdoFit.PRESETS[STRENGTH].label, "gray"); }; p.querySelector("#ado-fit-autoexempt").onchange = function () { AUTO_EXEMPT = this.checked; if (AUTO_EXEMPT) tickExempt(); /* 重新打开时立刻补勾一次 */ }; p.querySelector("#ado-fit-autopaste").onchange = function () { AUTO_PASTE = this.checked; tipline(AUTO_PASTE ? "粘贴出来的对象会自动补齐类型与摄像头属性" : "粘贴补全已关闭", "gray"); }; /* 「用类别典型值补车宽(实验)」已从面板隐藏(代码保留)。 这里做空值保护,否则 querySelector 返回 null 会抛错、导致后面所有绑定失效。 */ (function () { var el = p.querySelector("#ado-fit-stdw"); if (el) el.onchange = function () { USE_STD_W = this.checked; tipline(USE_STD_W ? "车宽将按类别典型值补齐(轿车1.8/SUV1.9/厢货2.0/卡车·公交2.2)" : "车宽改用标注原值", "gray"); }; })(); p.querySelector("#ado-fit-noresize").onchange = function () { NO_RESIZE = this.checked; tipline(NO_RESIZE ? "只贴位置:尺寸保持原值,仅把框平移到贴合车体(不含后视镜等零星凸出)" : "允许调整尺寸:长/宽可按点云修正", "gray"); }; /* 拖动 */ var drag = p.querySelector("#ado-fit-drag"), dragging = false, ox = 0, oy = 0; drag.addEventListener("mousedown", function (e) { /* 点在按钮上(最小化)时不启动拖动,否则手一抖面板就跟着跑 */ if (e.target && e.target.tagName === "BUTTON") return; dragging = true; var r = p.getBoundingClientRect(); ox = e.clientX - r.left; oy = e.clientY - r.top; e.preventDefault(); }); window.addEventListener("mousemove", function (e) { if (!dragging) return; p.style.left = (e.clientX - ox) + "px"; p.style.top = (e.clientY - oy) + "px"; p.style.bottom = "auto"; }); window.addEventListener("mouseup", function () { dragging = false; }); /* 若已存在翻译脚本的按钮,向上错开,避免遮挡 */ setTimeout(function () { if (document.getElementById("ado-cn-toggle")) p.style.bottom = "74px"; }, 900); /* 面板加了「一键新增/改属性」两行后变高,若之前被拖到偏下位置, 底部会滑出视口(按钮点不到)→ 检测到越界就拉回左下角。 */ setTimeout(function () { var r = p.getBoundingClientRect(); if (r.bottom > window.innerHeight - 4 || r.top < 0) { p.style.top = "auto"; p.style.left = "10px"; p.style.bottom = document.getElementById("ado-cn-toggle") ? "74px" : "10px"; } }, 1100); document.addEventListener("keydown", function (e) { if (!e.ctrlKey || !e.altKey) return; if (e.key === "f" || e.key === "F") { e.preventDefault(); runSelected(); } if (e.key === "a" || e.key === "A") { e.preventDefault(); runAll(); } }, true); /* 对外接口,便于在控制台排查 */ window.adoFit = { runSelected: runSelected, runAll: runAll, undo: undo, /* v1.2 */ runAddAll: runAddAll, openCamPanel: openCamPanel, applyCamAttrs: applyCamAttrs, runQc: runQc, runQcAll: runQcAll, gotoFrame: gotoFrame, openClearAll: openClearAll, doClearAll: doClearAll, runCountAll: runCountAll, smartCopyPrev: smartCopyPrev, openAttrPanel: openAttrPanel, scanCamAttrs: scanCamAttrs, camVisibility: camVisibility, applyAttrChanges: applyAttrChanges, collectAnomalyIds: collectAnomalyIds, exemptAllAnomalies: exemptAllAnomalies, tickExempt: tickExempt, completePastedAttrs: completePastedAttrs, isAttrMissing: isAttrMissing, selectIds: selectIds, rememberObjs: rememberObjs, estimateEgoShift: estimateEgoShift, estimateShiftByCloud: estimateShiftByCloud, bevCountGrid: bevCountGrid, refinePos: refinePos, boxScore: boxScore, bridgeGetBuffer: bridgeGetBuffer, bridgeGetJson: bridgeGetJson, qcData: function () { var st = findStores(true); if (!st.main || !st.main.hasDataset) return null; var calib = {}; (st.main.calibration.cameras || []).forEach(function (c) { calib[c.name] = c; }); return AdoFit.inspectFrame(st.main.annotation.object_list || [], calib, null); }, previewAdd: function () { var st = findStores(true); if (!st.main || !st.scene) return null; var roi = readRoi(st.scene); if (!roi) return null; var cloud = ensureCloud(st.main); return AdoFit.clusterVehicles(cloud.xyz, cloud.gz, roi, AdoFit.ADD_OPT); }, setStrength: function (s) { STRENGTH = s; }, setNoResize: function (v) { NO_RESIZE = !!v; var el = document.getElementById("ado-fit-noresize"); if (el) el.checked = NO_RESIZE; }, setStdWidth: function (v) { USE_STD_W = !!v; var el = document.getElementById("ado-fit-stdw"); if (el) el.checked = USE_STD_W; }, report: function () { var st = findStores(true); if (!st.main) { console.log("[adoFit] 读不到平台状态"); return null; } var plan = planFor(st.main.annotation.object_list, STRENGTH); var rows = plan.map(function (it) { return { ID: it.obj.tracking_id, 子类: it.obj.sub_class, 状态: it.ok ? "可贴合" : it.reason, "原 L/W/H": it.ok ? [it.obj.length, it.obj.width, it.obj.height].map(function (v) { return +v.toFixed(2); }).join("/") : "-", "新 L/W/H": it.ok ? [it.plan.L, it.plan.W, it.plan.H].map(function (v) { return +v.toFixed(2); }).join("/") : "-", 位移: it.ok ? +it.plan.diag.中心位移.toFixed(2) : "-", 朝向调整: it.ok ? it.plan.diag.朝向调整度 + "°" : "-", 扫描角: it.ok ? it.plan.diag.朝向扫描角 : "-", 对比度: it.ok ? it.plan.diag.朝向对比度 : "-", 覆盖度: it.ok ? it.plan.diag.覆盖度 + "%" : "-", 包含率: it.ok ? it.plan.diag.包含率 + "%" : "-" }; }); if (console.table) console.table(rows); return rows; }, /* 仅供排查:返回完整诊断(不写入) */ diagnose: function () { var st = findStores(true); if (!st.main) return null; var plan = planFor(st.main.annotation.object_list, STRENGTH); return plan.map(function (it) { return { tid: it.obj.tracking_id, ok: it.ok, reason: it.reason || null, before: [it.obj.length, it.obj.width, it.obj.height], after: it.ok ? [it.plan.L, it.plan.W, it.plan.H] : null, diag: it.ok ? it.plan.diag : null, notes: it.ok ? it.plan.notes : null }; }); } }; console.log("[adoFit] 已加载。Ctrl+Alt+F 贴合选中 / Ctrl+Alt+A 全贴合 / adoFit.report() 查看预览。" + "v1.2 新增:一键新增(ROI 内自动建框)/ 改属性(跨帧批量改遮挡截断)。"); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildUI); } else { buildUI(); startExemptWatcher(); /* 自动勾选尺寸异常(「上下限例外」) */ startPasteWatcher(); /* 粘贴出来的对象自动补全类型与摄像头属性 */ } })();