// ==UserScript==
// @name ADO 3D框自动贴合(ALCHERA ADO Cuboid)
// @namespace https://ado.treed.ai/
// @version 2.9.5
// @description 在 ALCHERA ADO 3D 标注平台上对立方体框做自动贴合,并支持摄像头属性快填、跨帧复制、整包质检、整包框数统计、清空全帧、自动勾选尺寸异常、粘贴自动补全属性。写入走平台原生通道(平台自动重算尺寸、位姿、朝向与 4 路摄像头 2D 投影)。v2.9.5 修同帧粘贴:平台只往场景 shape store 加、不写 object_list,改成从 shape 层检测「孤儿 shape」补进数据层(原来完全看不到同帧粘贴);恢复选中挪到写入之后并延迟 160ms(否则被平台重渲染冲掉)。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 属性;写入后可用「撤销」还原。
" +
"
" +
"| 对象 | " +
"原 L/W/H | 贴合后 L/W/H | " +
"中心位移 |
" + rows + "
" +
"
" +
"" +
"" +
"
";
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 的行建议人工复核。
" +
"
" +
"
" +
"" +
"" +
"
";
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" +
"" +
"
" +
"
" +
"处理范围" +
"
" +
"
" +
"从第" +
"" +
"帧 到第" +
"" +
"帧(共 " + main.total + " 帧)
" +
"
" +
"| 视角 | " +
"遮挡 0-4 | " +
"截断 0-4 |
" + camRows + "
" +
"
" +
"
" +
"" +
"
";
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);
showQcDialog(res, objs.length, main.currentIndex);
}
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)。
" +
"
" +
"| ID | " +
"类型 | " +
"所在帧 | " +
"问题 | " +
"视角 | " +
"详情 |
" + rows + "
" +
"
" +
"
";
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)。
" +
"
" +
"| ID | " +
"类型 | " +
"所在帧 | " +
"问题 | " +
"视角 | " +
"详情 |
" + rows + "
" +
"
" +
"" +
"
";
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;
confirmClearDialog(total, main.currentIndex, 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) + "
" +
"
" +
"
" +
"" +
"" +
"" +
"
";
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) + "(切换失败)" : "") +
"
" +
"
" +
"| 帧号 | " +
"框数 | " +
"对象 ID |
" + rows + "
" +
"
" +
"" +
"
";
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(); /* 粘贴出来的对象自动补全类型与摄像头属性 */
}
})();