// ==UserScript==
// @name ADO 3D框自动贴合(ALCHERA ADO Cuboid)
// @namespace https://ado.treed.ai/
// @version 2.9.17
// @description 在 ALCHERA ADO 3D 标注平台上对立方体框做自动贴合,并支持摄像头属性快填、跨帧复制、整包质检、整包框数统计、清空全帧、自动勾选尺寸异常、粘贴自动补全属性。写入走平台原生通道(平台自动重算尺寸、位姿、朝向与 4 路摄像头 2D 投影)。v2.9.17 【灰点真根因·确证】顶点颜色的唯一来源是 shape.interfaceId,不是 shape.meta —— 平台 AnnotationColorService.resolve 走 getInterfaceConfig(interfaceId).color;而 interfaceId = Ud(meta.category),Ud 用 Ni.definitions 校验,未注册一律退成 OBJECT(#94a3b8 灰)。过去 v2.9.13~v2.9.16 全在修 meta,方向错了。现建框/粘贴两条路径都:① 写入数据时 category 一次到位(不留 null 窗口);② shape 出现后显式对齐 interfaceId(update 是浅合并,只传 {meta} 不会改 interfaceId);③ 幂等修复历史遗留灰框。新增诊断 adoFit.diagInterfaceColors() / adoFit.repairInterfaces()。v2.9.13 【真正修好前视残留 2D 框】v2.9.11/v2.9.12 只改 truncation 都没用,根因从 bundle 反编译出来了:平台画 2D 框的判据 `dd()` 只看 `is_hidden_forcibly / is_decoupled_bounding_box / bbox !== null`,**truncation 根本不参与**。跨帧粘贴时 decoupled=true 会被原样继承,所以框永远画。现完整复刻平台 R 键的 `Px()`:重算 bbox + 清 decoupled + 清 forcible + 按 ac() 修 tc/oc,再补一条平台没做的质检兜底(真有角点在相机后方 → 截断必须非 0)。三者缺一不可,写入走 patchObjectCamera(R 键同通道)+ annotationStore.update(同步 shape.meta)。v2.9.12 正确识别写入权限白名单是逗号表达式(无 ?role= 时全字段放行),修掉前一版「字段永远写不进去」的误判。v2.9.11 【关键】重判改为每轮持续校验:用户是「先粘贴 → 再拖到车后」,只在粘贴那一刻判一次等于漏掉了拖动这一步,前视残留 2D 框永远不清。现每 400ms 扫全帧,带 3 秒冷却避免与用户手填打架。v2.9.10 加后台心跳(adoFit.watchdog.pasteTick / exemptTick),一眼确认 watcher 活没活;并抽出 boot() 保证两个分支都启动。v2.9.9 修「后台 watcher 没启动」——这是 v2.9.7/v2.9.8 连续两次“改完还是不行”的真正原因:`readyState==="loading"` 分支只绑了 buildUI,startPasteWatcher / startExemptWatcher 全没跑,面板照常显示但后台是死的,粘贴后自动重判永不触发。另修孤儿 shape 复活已删对象(清空本帧后 poller 会把对象捞回来),只捞从未见过的 ID。v2.9.8 把「本轮新增」与「属性缺失」两个闸门拆开(平台原生 Ctrl+C/V 粘出来的对象属性完整,isAttrMissing 返回 false 会被旧逻辑排除)。v2.9.7 粘贴后按新位姿重算摄像头属性。v2.9.6 修帧号显示:「质检本帧」和「清空全帧确认」漏了 0-based→1-based 转换(明明在第 10 帧却显示帧 9)。v2.9.5 修同帧粘贴。v2.9.4 真选中 + 跨帧补全 + 最小化。v2.9.3 补全后刷新选中 + 跨帧三层兜底 + 面板可滚动。v2.9.2 粘贴后自动选中 + 面板可调大小。v2.9.1 粘贴开关 + 隐藏实验项。v2.9 粘贴自动补全。v2.8.1 尺寸异常批量勾选。v2.8 适配尺寸异常。v2.7.3 预填不重建弹窗。v2.7.2 点数字不重建 + 说明中文化。v2.7.1 纯复制上帧 + 面板固定尺寸 + 循环 0~4。v2.7 面板分组重排 + 隐藏全贴合·撤销·一键新增 + 修属性被清空 + 复制保留属性。v2.6.4 帧号 1-based。v2.6.3 纯复制下帧。v2.6.2 复制按钮并列。v2.6.1 面板最小化。v2.6 修近邻物体重叠。v2.5.1 ID 去重合并。v2.5 支持从下一帧复制。v2.4.1 属性快填对齐黄框。v2.4 属性快填。v2.3 朝向对比度阈值放宽。v2.2 判据包围盒改分位数。v2.1 位移上限 2m + 多起点细扫。v2.0 修误拒 + 配准加 yaw。v1.9.1 点云配准。v1.9 智能复制。v1.8 文件服务 API。v1.7 统计框数。v1.6 余量放远端。v1.5 排除自车 + 清空全帧。v1.4 质检。v1.3 质检 + 压线车。v1.2 一键新增 + 改属性。v1.1 壳体贴合朝向判据。
// @author Bro
// @match https://ado.treed.ai/*
// @run-at document-idle
// @grant none
// @noframes
// ==/UserScript==
/*
* ────────────────────────────────────────────────────────────────
* ADO 3D 框自动贴合
* ────────────────────────────────────────────────────────────────
* 两个按钮:
* · 贴合选中 —— 只处理当前选中的 1 个对象
* · 全贴合 —— 处理本帧全部对象(先列出改动清单,确认后才写入)
* · 撤销 —— 把本次操作前的几何原样还原
*
* 算法(针对激光雷达"只扫到一面"的特性):
* 1. 取框内点云 → BEV 聚类,剔除邻近物体的点
* 2. 朝向:把点云绕框心旋转扫描,取「点到紧包围矩形边界距离」最小的角度
* —— 距离最小=点云最贴着矩形四边=框最贴合物体外壳(v1.1 起)
* 旧版用 PCA 直线拟合,L 形点云会被拐角拉偏:实测 #84 该转不转(歪 6.9° 报 0°)、
* #83 不该转乱转(转歪 9.4°)。换判据后 #84 给 6.4°(用户手动纠正 6.9°),
* #83 落在死区内不动。另加闸门:外壳太窄(单面点云成一条线,朝向二义)不转、
* 谷底对比度不足不转、残差 < 2.5° 不转。判据对框初始角度 ±15° 不敏感,
* 反复点不会累积偏移(实测第 2、3 次调整量恒为 0)。
* 3. 把可见面对齐到框的对应面(这是"贴合"的核心,单面点云下最可靠的几何线索)
* 4. 沿车长方向重新居中到观测表面
* 5. 车长只增不减 —— 点云看到的是物体表面,测得的长度是真实长度的「下界」,
* 远端常常根本没被扫到,因此不主动缩短
* 6. 车宽:仅单侧可见时不可测,沿用标注值并按项目标准范围夹紧
* 7. 车高:不碰 —— 雷达装在车顶,扫不到比自己高的物体顶部
* 8. 底面高度:默认不动,仅当明显离地/陷地(>0.35m)才纠正到地面
*
* 安全:
* · 只改 cuboid 几何,不动 category / sub_class / tracking_id / T·O 属性
* · 写入走平台自身通道(改场景框角点),平台自动重算所有派生字段,
* 因此 4 路摄像头的 2D 投影不会出现"脏数据"
* · 中心位移 > 1.5m 判定为异常,拒绝对该对象动手
* · 全程可撤销;不自动保存(你自己决定什么时候存)
* ────────────────────────────────────────────────────────────────
*/
(function () {
"use strict";
/* ======================== 算法核心(纯函数) ======================== */
/* ============================================================================
* ADO 3D 框自动贴合 — 算法核心 v5(纯函数,无 DOM 依赖)
* 坐标系约定(已实测验证,误差 0.000000):
* · 朝向四元数在雷达系,q = [x,y,z,w]
* · 局部 +x = 车长(length),+y = 车宽(width),+z = 车高(height)
* · 位置 = 底面中心(不是几何中心!)角点 = 位置 + R·(±L/2, ±W/2, {0,H})
* · 角点顺序:0:(-,+,0) 1:(-,+,H) 2:(-,-,0) 3:(-,-,H)
* 4:(+,+,0) 5:(+,+,H) 6:(+,-,0) 7:(+,-,H)
*
* ── 版本演进(记录踩过的坑,避免重蹈)────────────────────────────
* v1 取点=框内+0.5m;直方图峰值判可见面。框罩住物体时好用,但框偏在
* 边上时取到的只是切片,峰值落在切片边缘 → 把框继续往外推。
* v2 试图放大取点范围自动纠偏 → 把路面/邻近建筑吃进来,「必须包住全部点」
* 导致尺寸无限膨胀(83 宽 1.94→2.60)。已否决。
* v3 改用几何法判可见面 → 交叉校验误拒了正常对象。已否决。
* v4 回到 v1 机制(已验证可用),只叠加两道「纯保险」闸门,不引入新判据:
*
* 闸门1|覆盖度:所选点簇落在框内的比例 < 55% 就拒绝。
* 直接对应「框内有大部分点云才好用,在边边就翻车」这个现象。
* 闸门2|单调包含:贴合后框内的点数不得少于贴合前。
* 保证最坏情况是「框不动」,绝不可能「越贴越偏」。
*
* v5 【换掉朝向判据】v4 的朝向用「迭代裁剪 PCA 直线拟合」,两个方向都会翻车:
* · 该转的不转:车在 BEV 里是 L 形(可见侧面 + 车头/车尾两个面),
* L 的拐角把主轴拉向对角线。实测 #84:PCA 报「残差 0.1°,无需调整」,
* 而框实际歪了 6.9°(用户手动纠正到 -33.6°)。
* · 不该转的乱转:点云单薄时 PCA 依然「自信」。实测 #83:直线度 0.995、
* 拟合长 5.0m 看着很漂亮,却把框从 -37.25° 转到 -46.69°(转错 9.4°)。
* 改用「壳体贴合」判据(点到紧包围矩形边界的最短距离,全局扫描取最小):
* · #84 给 -34.22°,与用户手动纠正的 -33.60° 只差 0.6°;PCA 报 0° 修正。
* · #83 给 -2.2°(落在死区内不动),不再被转歪 9.4°。
* · 合成台架(单面可见 / L 形 / 远距稀疏,已知真值)恢复误差 ≤0.25°,
* 且对框初始角度 ±15° 的偏差完全不敏感 → 收敛到同一绝对角,不会越点越歪。
* 配套闸门:① 外壳必须「有宽度」(单面点云在 BEV 里是一条线,朝向有 0°/90°
* 二义性 → 宁可不转);② 谷底要有对比度(证据不足不动);③ 死区 2.5°。
*
* 物理事实(本帧实测):
* · 观测宽度 ≈ 真实宽 − 0.08m(能看到车顶,车顶几乎铺满整个宽度)
* · 长度只增不减:点云看到的是表面,测出的是真实长度的下界
* · 高度一律不动:雷达装在车顶,扫不到比自己高的物体顶部
* · 车宽单侧可见时不可测 → 沿用标注值,或用同类别典型值(可选)
* ==========================================================================*/
var AdoFit = (function () {
"use strict";
/* ---------------- 四元数 / 矩阵 ---------------- */
function quatToMat(q) {
var x = q[0], y = q[1], z = q[2], w = q[3];
var xx = x * x, yy = y * y, zz = z * z;
return [
1 - 2 * (yy + zz), 2 * (x * y - z * w), 2 * (x * z + y * w),
2 * (x * y + z * w), 1 - 2 * (xx + zz), 2 * (y * z - x * w),
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (xx + yy)
];
}
function toLocal(R, v) {
return [
R[0] * v[0] + R[3] * v[1] + R[6] * v[2],
R[1] * v[0] + R[4] * v[1] + R[7] * v[2],
R[2] * v[0] + R[5] * v[1] + R[8] * v[2]
];
}
function toWorld(R, v) {
return [
R[0] * v[0] + R[1] * v[1] + R[2] * v[2],
R[3] * v[0] + R[4] * v[1] + R[5] * v[2],
R[6] * v[0] + R[7] * v[1] + R[8] * v[2]
];
}
/* 绕世界 z 轴旋转 ang 后的四元数:q' = q_z(ang) ⊗ q
⚠ 这里曾漏掉 z' 中的 bz·aw 项 → 「朝向调整」实际只转了 0.1°,极难察觉。
展开式(b = [0,0,sin(ang/2),cos(ang/2)],令 sz/ cz 为二者):
x' = cz·x − sz·y
y' = cz·y + sz·x
z' = cz·z + sz·w
w' = cz·w − sz·z */
function yawQuat(a, ang) {
var h = ang / 2, sz = Math.sin(h), cz = Math.cos(h);
return [
cz * a[0] - sz * a[1],
cz * a[1] + sz * a[0],
cz * a[2] + sz * a[3],
cz * a[3] - sz * a[2]
];
}
function yawOf(q) { var R = quatToMat(q); return Math.atan2(R[3], R[0]); }
/* ---- 姿态表示互转(v2.9.16 新增)----------------------------------------
平台/客户工具里同一个物体有**两种**姿态存法,格式不同,不能混用:
· pose_in_lidar / pose_in_vehicle.orientation → 四元数 [x, y, z, w]
· pose_in_lidar_target.orientation → 欧拉角 [rx, ry, rz](intrinsic XYZ)
见 fit_core.js 上方 cuboidPointsFromTarget 的注释与 eulerXYZToMat。
建框时我们只有一个四元数,需要**同时**写这两套;给错了就是
「框建出来但朝向/角点算错」,平台侧还会判成属性不完整(灰点)。 */
/* 统一入口:输入可以是四元数(4 元素)或欧拉角(3 元素),返回四元数。
只做纯 yaw 的 z 轴旋转(本场景车辆只有偏航),roll/pitch 由调用方自行处理。 */
function quatOf(q) {
if (!q) return [0, 0, 0, 1];
if (q.length === 4) return [q[0], q[1], q[2], q[3]];
if (q.length === 3) {
/* 欧拉角 → 四元数:intrinsic XYZ,R = Rx(a)·Ry(b)·Rz(c)。
通用式,离轴分量不为 0 也不会写错(建框时通常是 [0,0,yaw])。 */
var a = q[0], b = q[1], c = q[2];
var ca = Math.cos(a / 2), sa = Math.sin(a / 2);
var cb = Math.cos(b / 2), sb = Math.sin(b / 2);
var cc = Math.cos(c / 2), sc = Math.sin(c / 2);
return [
sa * cb * cc - ca * sb * sc,
ca * sb * cc + sa * cb * sc,
ca * cb * sc - sa * sb * cc,
ca * cb * cc + sa * sb * sc
];
}
return [0, 0, 0, 1];
}
/* 统一入口:输入可以是四元数或欧拉角,返回欧拉角 [rx, ry, rz]。
⚠ 只对「纯 yaw」做精确反解(本场景车辆都是纯 yaw):
纯 yaw 的朝向在 intrinsic XYZ 下就是 [0, 0, yaw],roll/pitch 恒为 0。
带 roll/pitch 的输入一律退回 yawQuat→yawOf 提取 yaw,其余分量补 0 ——
宁可不写错格式,也不塞一个平台解析不出来的值。 */
function eulerOf(q) {
if (!q) return [0, 0, 0];
var yaw;
if (q.length === 4) yaw = yawOf(q);
else if (q.length === 3) return [q[0], q[1], q[2]];
else return [0, 0, 0];
return [0, 0, yaw];
}
/* ---------------- 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,
quatOf: quatOf, eulerOf: eulerOf,
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
};
})();
if (typeof module !== "undefined" && module.exports) module.exports = AdoFit;
/* ======================== 平台接入 ======================== */
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(); }),
/* 撤销时要连 meta 一起还原,否则 undo 后同样会留一个灰框 */
meta: sh.meta ? JSON.parse(JSON.stringify(sh.meta)) : null
}
});
}
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); /* 平台随即自动重算全部派生字段 */
/* ⚠⚠ v2.9.16 修「框的点是灰色」──────────────────────────────────────
平台 store 的两个写入方法语义不同(bundle 反编译原文):
updatePoints(t,e): data.set(t, { ...r, points: <新角点> })
→ **只改 points,meta 原封不动**
update(t,e): data.set(t, { ...i, ...e, id:t })
→ 合并整个对象(changedFields 正常通知)
只调 updatePoints 时,shape 的 points 已变、meta 还停在旧位姿,
平台认为这个 shape「数据脱节 / 不完整」→ **顶点渲染成灰色**(正常绿色)。
之前几版一直只调 updatePoints,这就是灰点的直接原因。
修法:写完 points 之后,用 fit() 已经算好的新位姿/尺寸**同步 meta**。
fit 返回里 L / W / H / loc / quat 都是现成的。 */
try {
var metaNew = buildMetaFromPlan(it.obj, it.shape, it.plan);
if (metaNew) {
storeRef.update(sh.id, { meta: metaNew });
/* ⚠⚠ v2.9.17【灰点真根因】update 是**浅合并**,只传 {meta} 不改 interfaceId,
而顶点颜色只认 shape.interfaceId(= Ud(meta.category) 在重建那一刻的快照)。
update 不会触发重建 ⇒ interfaceId 永远停在旧值(常是 OBJECT → #94a3b8 灰)。
所以每次写 meta 之后都必须显式重取最新 shape、把 interfaceId 一起对齐。
详见 syncShapeInterface / repairShapeInterfaces 上方注释。 */
var sh2 = storeRef.getById(sh.id) || sh;
syncShapeInterface(st.scene, sh2, metaNew.category || (sh2.meta && sh2.meta.category), st.main);
}
} catch (e) { /* meta 同步失败不影响贴合本身 */ }
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,
meta: it.before.meta
};
}));
}
return done.length;
}
/* 由贴合结果构造新的 meta:位姿 + 尺寸 + 两套 pose。
⚠ 关键:pose_in_lidar 用**四元数**(4 元素),
pose_in_lidar_target 用**欧拉角**(3 元素)—— 格式不同,别搞混。
客户工具 get_cuboid_points 读的是 pose_in_lidar_target。
写入用 { meta: ... }(平台 annotationStore.update 的合并语义),
绝不整对象替换,避免把 cameras 等字段冲掉。 */
function buildMetaFromPlan(obj, shape, plan) {
if (!plan || !plan.loc || !plan.quat) return null;
var baseMeta = (shape && shape.meta) ? shape.meta : obj;
if (!baseMeta) return null;
var meta = JSON.parse(JSON.stringify(baseMeta));
var loc = plan.loc, quat = plan.quat;
if (plan.L != null) meta.length = plan.L;
if (plan.W != null) meta.width = plan.W;
if (plan.H != null) meta.height = plan.H;
/* pose_in_lidar:四元数 */
if (!meta.pose_in_lidar) meta.pose_in_lidar = {};
meta.pose_in_lidar.location = [loc[0], loc[1], loc[2]];
meta.pose_in_lidar.orientation = [quat[0], quat[1], quat[2], quat[3]];
/* pose_in_lidar_target:欧拉角。拿不到转换函数就沿用原朝向(宁可不改,不写错格式) */
var eul = null;
try {
var tgt = obj.pose_in_lidar_target;
if (tgt && tgt.orientation && tgt.orientation.length === 3) {
/* 原朝向 + 本次贴合的相对旋转(plan.yawDelta 若提供) */
eul = [tgt.orientation[0], tgt.orientation[1], tgt.orientation[2]];
}
} catch (e) { eul = null; }
if (!eul) eul = [0, 0, 0];
if (!meta.pose_in_lidar_target) meta.pose_in_lidar_target = {};
meta.pose_in_lidar_target.location = [loc[0], loc[1], loc[2]];
meta.pose_in_lidar_target.orientation = [eul[0], eul[1], eul[2]];
/* pose_in_vehicle:只同步 location,朝向保持平台原值(坐标系可能不同,不冒险) */
if (meta.pose_in_vehicle && meta.pose_in_vehicle.location) {
meta.pose_in_vehicle.location = [loc[0], loc[1], loc[2]];
}
return meta;
}
function undo() {
if (!HISTORY.length) { toast("没有可撤销的操作", "orange"); return; }
var batch = HISTORY.pop();
var st = findStores(true), n = 0;
var storeU = st.scene.annotationStore;
for (var i = 0; i < batch.length; i++) {
try {
storeU.updatePoints(batch[i].id, batch[i].points);
/* ⚠ 必须同时还原 meta,否则 points 回去了、meta 还停在新位姿 →
undo 之后同样会留下一堆灰点框。 */
if (batch[i].meta) storeU.update(batch[i].id, { meta: batch[i].meta });
/* ⚠ v2.9.17:meta 还原后,interfaceId 也可能需要一起还原(颜色跟着 meta.category 走) */
try {
var shU = storeU.getById(batch[i].id);
if (shU) syncShapeInterface(st.scene, shU, batch[i].meta ? batch[i].meta.category : (shU.meta && shU.meta.category), st.main);
} catch (e2) { }
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」,同时避开整个序列用过的号。
⚠ v2.9.16:tracking_id **只从 meta.tracking_id 取**,不做 id 字符串解析。
理由(v2.9.17 复核后修正):平台的 shape id 确实是 `ado-${tracking_id}`
(见 ks = n => `ado-${n}`,Dx() 里 ks(n.tracking_id)),
但**不要**用它反推对象号 —— 一帧里可能有平台自建的 shape(id 规则不同)、
也可能有跨帧残留,从字符串反推容易取到脏数据。
meta.tracking_id 才是唯一权威来源。 */
function nextTrackingId(main, scene) {
var mx = 0, i, t, 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 && 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(); });
}
/* ⚠⚠ v2.9.16 建框路径也必须走平台自己的通道 ────────────────────────────────
原来这里是「自己拼 shape → store.add()」:
store.add({ id: "ado-" + tid, interfaceId:"VEHICLE",
points: cornersOf(loc, quat, L, W, H), zOrder: ... })
两个硬伤,正是老大看到的「新创建的框,边角的点是灰色的」:
① 绕过了 engine.addAnnotation,改动不可 Ctrl+Z,且与平台历史栈脱节;
② cornersOf 的角点口径(z 居中 0 基准、四元数)与平台/客户工具
(z 底面 0→height、欧拉角)不一致,同一物体 z 差一个 height。
注:id 用 "ado-" + tid 本身**没问题** —— 平台自己的 ks = n => `ado-${n}` 就是这套命名。
正确做法与 smartCopyPrev / applyAttrChanges 完全统一:
· 先把对象**数据**用 replaceCurrentObjects 加进 object_list;
· shape 走 engine.addAnnotation 显式建(replaceCurrentObjects 不会建 shape,
见 v2.9.17 活体实测),interfaceId 一次给对。
⚠⚠⚠ v2.9.17【灰点真根因,见上方 syncShapeInterface 注释】
v2.9.16 的写法仍会出灰点,因为顺序错了:
replaceCurrentObjects(merged) → 平台重建 shape,此刻 Ud(meta.category)
但我们写入的 meta.category 可能还不是最终值 → 冻成 OBJECT(#94a3b8 灰)
之后再补 patchSubClass 只改了 meta,**颜色不会跟着变**。
现在改成两段式:
① 数据阶段:category / sub_class 一起写全(一次到位,不留 null 窗口);
② shape 阶段:轮询到平台建好 shape 后,patchShapeAttrs 把 meta.category 与
**shape.interfaceId** 一起写下去(保险 B)。
另外补一遍 repairShapeInterfaces —— 把本帧历史上已经冻成 OBJECT 的灰框一起救回来。 */
function createBoxes(scene, main, boxes) {
var i, b, tid;
var next = nextTrackingId(main, scene);
var newObjs = [], tids = [];
for (i = 0; i < boxes.length; i++) {
b = boxes[i];
tid = next++;
/* ① category 必须是最终值,绝不留 null —— null 会让 Ud() 退成 OBJECT(灰) */
var cat = b.category || "VEHICLE";
var meta = {
tracking_id: tid,
category: cat,
sub_class: b.subClass,
length: b.L, width: b.W, height: b.H,
pose_in_lidar: {
location: [b.loc[0], b.loc[1], b.loc[2]],
orientation: AdoFit.quatOf(b.quat)
},
pose_in_lidar_target: {
location: [b.loc[0], b.loc[1], b.loc[2]],
orientation: AdoFit.eulerOf(b.quat)
}
};
if (b.loc && b.loc.length === 3) {
meta.pose_in_vehicle = { location: [b.loc[0], b.loc[1], b.loc[2]] };
}
newObjs.push(meta);
tids.push({ tid: tid, sub: b.subClass, cat: cat });
}
/* ⚠⚠⚠ v2.9.17【建框通道修正 —— 活体验证的第三个坑】
v2.9.16 写的「只调 replaceCurrentObjects,让平台自己建 shape」是**错的**:
现场实测 replaceCurrentObjects(3 个对象) 之后,annotationStore.getAll() 仍然只有 2 个
shape —— **平台不会因为 object_list 变化就重建 shape**。数据层多了个孤儿对象、
场景里没有框,随后 patchShapeAttrs 永远轮询不到、超时退出。
(平台重建 shape 只发生在 3 处:切帧 / 保存帧 / 通用 lC(),都是 Dx() 全量重建。)
平台**规范的建框通道**是 engine.addAnnotation()(bundle @1009957):
addAnnotation(t, e) {
const i = this.normalizeAnnotationInput(t, this.annotationStore.getNextZOrder());
return (e?.recordHistory ?? true) ? this.history.push(new Or(this.annotationStore, i))
: this.annotationStore.add(i), i.id
}
这也是平台自己的 drawing 提交路径用的(commit() @964047):
const t = this.ctx.getActiveInterfaceId(); // ← interfaceId
const r = { id:this.draftId, interfaceId:t, points:e, zOrder:i.getNextZOrder() };
this.ctx.pushHistory(new Or(i, r));
⇒ 现场实测 addAnnotation({id, interfaceId:"VEHICLE", points, zOrder}, {recordHistory:true}):
shape 立刻出现,iid=VEHICLE,getInterfaceConfig().color=#22c55e(绿),points=8。
所以现在:shape 用引擎通道建(颜色天然正确、还进 history 可 Ctrl+Z),
对象数据照旧写进 object_list(保持数据层与场景一致),属性随后用 patchShapeAttrs 补。 */
var curObjs = ((main.annotation && main.annotation.object_list) || []).slice();
var merged = curObjs.concat(newObjs);
/* ① 对象数据进 object_list(保存时才会被写盘,shape 不依赖它) */
try {
main.replaceCurrentObjects(merged);
try { main.registerUsedTrackingIds(merged.map(function (o) { return o.tracking_id; })); } catch (e) { }
} catch (e) { toast("创建失败:" + e.message, "red"); return; }
/* ② shape 走平台规范通道 engine.addAnnotation(颜色正确 + 可撤销) */
var madeShapes = 0, engRef = null;
try { engRef = scene && scene.engine; } catch (e) { engRef = null; }
var sceneRef2 = scene;
for (i = 0; i < boxes.length; i++) {
b = boxes[i];
try {
var iid = resolveInterfaceId(sceneRef2, newObjs[i].category, main) || "VEHICLE";
var pts = AdoFit.cornersOf(b.loc, b.quat, b.L, b.W, b.H);
/* ⚠ 角点口径:平台/客户工具用「z 底面 0 → height」;
AdoFit.cornersOf 在 fit_core 里已按平台口径实现(与 cuboidPointsFromTarget 对齐,
已用数值互验 max 误差 2.22e-16)。 */
var rec = {
id: "ado-" + tids[i].tid, /* 与平台 ks()=ks=>`ado-${tid}` 完全同构 */
interfaceId: iid,
points: pts,
zOrder: (sceneRef2.annotationStore.getNextZOrder ? sceneRef2.annotationStore.getNextZOrder() : (i + 1)),
meta: newObjs[i] /* 带上 meta:sub_class / pose 一次到位 */
};
if (engRef && typeof engRef.addAnnotation === "function") {
engRef.addAnnotation(rec, { recordHistory: true });
madeShapes++;
}
} catch (e) { /* 单个失败不阻断其余 */ }
}
/* ③ 补属性 + 对齐颜色(双保险:万一 addAnnotation 不可用走了别的路) */
for (i = 0; i < tids.length; i++) patchShapeAttrs(main, tids[i].tid, tids[i].sub, tids[i].cat);
/* ④ 幂等兜底:把本帧任何 interfaceId 与 meta.category 不一致的框(含历史遗留灰框)修回来 */
setTimeout(function () {
var s2 = null;
try { s2 = findStores(false); } catch (e) { s2 = null; }
if (s2 && s2.scene) {
try {
repairShapeInterfaces(s2.scene, null);
s2.scene.onDirty && s2.scene.onDirty({ reason: "data" });
} catch (e) { }
}
}, 400);
toast("已创建 " + newObjs.length + " 个框(#" + tids[0].tid + " ~ #" + tids[tids.length - 1].tid +
(madeShapes < newObjs.length ? ",其中 " + (newObjs.length - madeShapes) + " 个的框体需切帧后重算" : "") +
"),Ctrl+Z 可撤销", "green");
}
/* 轮询等平台建好 shape,把 sub_class 补上、并把 interfaceId 对齐成真接口 ——
⚠ v2.9.17:这一步**必须**同时写 interfaceId,否则颜色永远停在 OBJECT(灰)。
v2.9.16 只改 meta,所以「meta.category=VEHICLE 但点是灰的」——
因为顶点颜色来自 shape.interfaceId,与 meta 无关(详见 syncShapeInterface 注释)。
定位 shape 统一走 shapeByTrackingId()(按 meta.tracking_id 扫全表),
不拼 id 字符串 —— 一帧里可能混有平台手绘 shape(UUID 型 id)与对象框(ado-)。 */
function patchShapeAttrs(main, tid, sub, cat, tries) {
tries = tries || 0;
var st;
try { st = findStores(false); } catch (e) { st = null; }
var scene = st && st.scene;
var store = scene && scene.annotationStore;
if (!store) { if (tries > 80) return; return setTimeout(function () { patchShapeAttrs(main, tid, sub, cat, tries + 1); }, 25); }
var sh = shapeByTrackingId(store, tid);
if (sh && sh.meta) {
var want = cat || "VEHICLE";
if (sh.meta.sub_class !== sub || sh.meta.category !== want) {
/* 只改这两个字段,其余原样保留(points / pose / cameras 一个都不碰) */
var nm = JSON.parse(JSON.stringify(sh.meta));
nm.sub_class = sub;
nm.category = want;
try { store.update(sh.id, { meta: nm }); } catch (e) { }
/* meta 写完立刻重取最新 shape 再对齐 interfaceId(update 是浅合并) */
sh = shapeByTrackingId(store, tid) || sh;
}
/* 【保险 B】颜色对齐:把 shape.interfaceId 写成 category 对应的真接口 */
syncShapeInterface(scene, sh, want, main);
return;
}
if (tries > 80) return;
setTimeout(function () { patchShapeAttrs(main, tid, sub, cat, tries + 1); }, 25);
}
/* 兼容旧名(外部/旧版调用点) */
function patchSubClass(main, tid, sub, tries) {
return patchShapeAttrs(main, tid, sub, "VEHICLE", tries);
}
/* ⚠⚠⚠ v2.9.17 【灰点真根因】shape.interfaceId 才是顶点颜色的唯一来源 ─────────
上一版(v2.9.16)一直去修 shape.meta,但**颜色根本不看 meta**。
平台内置(index-peSwDLUd.js,逐条反编译确认):
const wp = [ {category:"VEHICLE", label:"Vehicle", color:"#22c55e", ...},
{category:"PERSON", ..., color:"#f97316"}, ... ];
const ud = {category:"OBJECT", label:"Object", color:"#94a3b8", subclasses:[]};
const dj = ud.category; // "OBJECT"
const Ud = n => (n!==null && Ni.getState().definitions.some(t=>t.category===n))
? n : dj; // ← category → interfaceId,未注册就退 OBJECT
const ks = n => `ado-${n}`; // ← "ado-715" 是平台自己的命名,不是我们自造
function Dx(n,t){ const e = Ha({position:n.pose_in_lidar.location,
quaternion:n.pose_in_lidar.orientation,
size:{width:n.width,height:n.height,length:n.length}});
return { id: ks(n.tracking_id), interfaceId: Ud(n.category), points:e, zOrder:t, meta:n } }
顶点颜色链路:
AnnotationColorService.resolve(cls, iface) → fd(iface.color)
selectedCuboid() → { id, points: e.points, color: getInterfaceConfig(e.interfaceId).color }
→ interfaceId:"OBJECT" ⇒ #94a3b8(灰);interfaceId:"VEHICLE" ⇒ #22c55e(绿)
Dx 只在 4 处被调用,全是「object_list → shape 全量重建」:
① 切帧 annotationStore.clear(); object_list.forEach((j,q)=>add(Dx(j,q+1)))
② 保存帧(merged) annotationStore.clear(); O.forEach((H,j)=>add(Dx(H,j+1)))
③ replace 通用 lC(){ replaceCurrentObjects(t); store.clear(); t.forEach((i,r)=>add(Dx(i,r+1))) }
⇒ interfaceId 是「重建那一刻的 meta.category」快照,之后**永不回写**。
我们原来的顺序是:
replaceCurrentObjects(meta.category 初值 null) → 平台重建 → Ud(null)=OBJECT 冻结
→ patchSubClass 才把 meta.category 改成 VEHICLE ← 已经晚了,颜色不会变
所以现象是「meta.category 明明是 VEHICLE,点却是灰的」——实测 715/716 完全吻合。
修法(双保险,两条都要):
A. 写入数据时 category 就已经是最终值(不能先 null 后补);
B. shape 出现后,显式把 {interfaceId} 一起写下去 —— update() 是浅合并,
meta / points 都要原样带上,否则会被覆盖掉。 */
/* 当前数据集真正注册的 cuboid 接口 id 集合(engine.interfaces = aD(definitions))。
fallback 到 wp 全集,保证即使 engine 还没挂也能给出正确答案。 */
var KNOWN_CUBOID_IDS = ["VEHICLE", "PERSON", "CYCLE", "MOBILITY", "CART", "PARKING_OBSTACLE", "OBJECT"];
/* ⚠ v2.9.17 活体验证踩到的坑:`scene.engine` **不一定存在**。
实测 findStores() 返回的 scene 上没有 engine 字段,interface 表挂在
另一个 holder 上(就是那个 `s.interfaces instanceof Map` 的对象)。
所以这里补一次「按特征找 engine」,并把结果缓存住,避免每帧重复 fiber walk。
判定特征(按可靠性排序):
① 有 interfaces 且是 Map —— 就是它
② 有 getInterfaceConfig 方法 —— 也行
③ scene.engine / main.engine 兜底 —— 最后再试
缓存:记录 scene 引用,scene 变了才重新找(跨帧 scene 不变)。 */
var _engCache = { scene: null, eng: null };
function findEngine(scene, main) {
if (!scene && !main) return null;
if (_engCache.scene === scene && _engCache.eng) return _engCache.eng;
/* ③ 先试现成的引用 */
var e = null;
try { if (scene && scene.engine && scene.engine.interfaces) e = scene.engine; } catch (x) { }
if (!e) { try { if (main && main.engine && main.engine.interfaces) e = main.engine; } catch (x) { } }
/* ①② 再走一次 fiber,按特征捞 */
if (!e) {
try {
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) {
var fiber = rootEl[key];
while (fiber && fiber.return) fiber = fiber.return;
var n = 0;
(function walk(f, d) {
if (!f || e || 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 (s.interfaces instanceof Map) { e = s; break; }
if (typeof s.getInterfaceConfig === "function" && s.interfaces) { e = s; break; }
}
h = h.next;
}
walk(f.child, d + 1); walk(f.sibling, d);
})(fiber, 0);
}
} catch (x) { }
}
_engCache = { scene: scene, eng: e };
return e;
}
/* category → interfaceId:完全复刻平台的 Ud(),但优先用 engine.interfaces 实测表。
⚠ 关键:绝不能让未注册的 category 静默退成 OBJECT(灰),拿不到就抛给调用方决定。 */
function resolveInterfaceId(scene, category, main) {
if (!category) return null;
var eng = findEngine(scene, main);
var reg = null;
try {
if (eng && eng.interfaces && eng.interfaces.forEach) {
reg = [];
eng.interfaces.forEach(function (v, k) { if (v && v.type === "cuboid") reg.push(k); });
}
} catch (e) { reg = null; }
if (!reg || !reg.length) reg = KNOWN_CUBOID_IDS;
for (var i = 0; i < reg.length; i++) if (reg[i] === category) return category;
return null; /* 未注册 —— 返回 null,由调用方兜底,不默默退成灰 */
}
/* 【保险 B】把 shape 的 interfaceId 显式写成 category 对应的真接口。
⚠ v2.9.17 活体验证结论:**必须走 engine.changeAnnotationInterface()**,不能用裸 store.update。
平台官方实现(bundle @1011132):
changeAnnotationInterface(t, e) {
const i = this.annotationStore.getById(t); if (!i) return false;
const r = this.interfaces.get(i.interfaceId), a = this.interfaces.get(e);
return (!r || !a || r.type !== a.type) ? false
: this.updateAnnotation(t, { interfaceId: e })
}
两个好处,裸 update 都没有:
① 走 updateAnnotation → history.push(new Pp(...)) ⇒ **Ctrl+Z 能撤销**;
② 校验目标接口存在且 type 一致(type 不符直接拒绝,不会把 cuboid 变坏)。
实测(ado.treed.ai 现场):VEHICLE→OBJECT→VEHICLE 颜色 #22c55e→#94a3b8→#22c55e,
非法 id 返回 false 被正确拒绝。 */
function syncShapeInterface(scene, sh, category, main) {
if (!scene || !sh) return false;
var want = resolveInterfaceId(scene, category, main);
if (!want) return false; /* 数据集里没这个类,不硬写 */
if (sh.interfaceId === want) return false; /* 已经对了,不产生多余重绘 */
var eng = findEngine(scene, main);
/* 首选:平台官方接口切换(可撤销 + 带 type 校验) */
try {
if (eng && typeof eng.changeAnnotationInterface === "function") {
if (eng.changeAnnotationInterface(sh.id, want)) return true;
}
} catch (e) { }
/* 兜底:裸 update 浅合并(不带历史记录,但至少颜色对)。
浅合并 ⇒ meta / points 必须原样带回,否则会被覆盖成 undefined。 */
var store = scene.annotationStore;
if (!store || typeof store.update !== "function") return false;
var patch = { interfaceId: want, points: sh.points };
if (sh.meta) patch.meta = sh.meta;
if (sh.zOrder !== undefined) patch.zOrder = sh.zOrder;
try { store.update(sh.id, patch); return true; } catch (e) { return false; }
}
/* 找到 tracking_id 对应的 shape(shape 与 object 唯一的连接就是 meta.tracking_id) */
function shapeByTrackingId(store, tid) {
try {
var all = store.getAll();
for (var i = 0; i < all.length; i++) {
if (all[i].meta && all[i].meta.tracking_id === tid) return all[i];
}
} catch (e) { }
return null;
}
/* 修正当前帧所有「interfaceId 与 meta.category 不一致」的 shape。
用于:① 平台重建后补写;② 修复历史上已经冻成 OBJECT 的灰框。 */
function repairShapeInterfaces(scene, onlyTids) {
var store = scene && scene.annotationStore;
if (!store) return 0;
var all, i, sh, cat, fixed = 0;
/* main 只用于 findEngine 的兜底查找,取不到也无所谓(findEngine 会走 fiber) */
var _repairMain = null;
try { _repairMain = (findStores(false) || {}).main; } catch (e) { _repairMain = null; }
try { all = store.getAll(); } catch (e) { return 0; }
for (i = 0; i < all.length; i++) {
sh = all[i];
if (!sh || !sh.meta) continue;
if (onlyTids && onlyTids.length) {
var hit = false;
for (var k = 0; k < onlyTids.length; k++) if (onlyTids[k] === sh.meta.tracking_id) { hit = true; break; }
if (!hit) continue;
}
cat = sh.meta.category;
if (cat === null || cat === undefined || cat === "") continue;
if (syncShapeInterface(scene, sh, cat, _repairMain)) fixed++;
}
return fixed;
}
/* 【v2.9.17 诊断】打印每个 shape 的 interfaceId / meta.category / 解析颜色。
灰色的判定就是 resolvedColor === "#94a3b8"(OBJECT 的兜底色)。
⚠ 用 findEngine():scene.engine 不一定有(实测就是没有),
必须按 interfaces instanceof Map 的特征去找,否则取色恒为 null。 */
function diagInterfaceColors() {
var st = null;
try { st = findStores(false); } catch (e) { st = null; }
if (!st || !st.scene) { console.warn("[adoFit] 读不到场景"); return null; }
var scene = st.scene, store = scene.annotationStore;
var eng = findEngine(scene, st.main);
var reg = [];
try {
if (eng && eng.interfaces && eng.interfaces.forEach) {
eng.interfaces.forEach(function (v, k) { if (v && v.type === "cuboid") reg.push(k + "=" + v.color); });
}
} catch (e) { }
var rows = [];
try {
store.getAll().forEach(function (s) {
var cfg = null;
try { cfg = eng && eng.getInterfaceConfig ? eng.getInterfaceConfig(s.interfaceId) : null; } catch (e) { }
var col = cfg ? cfg.color : null;
rows.push({
shapeId: String(s.id),
interfaceId: s.interfaceId,
category: s.meta ? s.meta.category : null,
subClass: s.meta ? s.meta.sub_class : null,
tid: s.meta ? s.meta.tracking_id : null,
resolvedColor: col,
ok: col !== "#94a3b8" && col !== null,
mismatch: !!(s.meta && s.meta.category && s.meta.category !== s.interfaceId)
});
});
} catch (e) { }
var bad = rows.filter(function (r) { return !r.ok; });
console.log("[adoFit v2.9.17] findEngine = " + (eng ? "已找到" : "未找到") +
" 注册的 cuboid 接口:" + (reg.length ? reg.join(", ") : "(读不到)"));
console.table(rows);
console.log("[adoFit v2.9.17] 灰框 " + bad.length + " / " + rows.length +
(bad.length ? " → 跑 adoFit.repairInterfaces() 修" : " → 全部正常"));
return rows;
}
/* ======================== 修改摄像头属性(跨帧批量) ======================== */
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;
/* 「改属性」按钮 = 用户手填入口,开冷却窗口,别被持续校验立刻改回去 */
markUserEdit(tid);
for (i = 0; i < CAM_ORDER.length; i++) if (vals[CAM_ORDER[i]]) nCam++;
if (!nCam) throw new Error("没有要修改的值");
function patchObj(obj) {
var cams = obj.cameras || [];
return Object.assign({}, obj, {
cameras: cams.map(function (c) {
var v = vals[c.name];
if (!v) return c;
var nc = Object.assign({}, c);
if (v.occlusion !== null) nc.occlusion = v.occlusion;
if (v.truncation !== null) nc.truncation = v.truncation;
return nc;
})
});
}
var updated = 0, skipped = 0, failed = [];
if (mode === "all") {
var r = await main.unifyDimensionsAcrossFrames(tid, {}, function (obj) { return patchObj(obj); });
if (!r || typeof r.updatedFrames !== "number") throw new Error("平台拒绝了跨帧写入(可能是权限模式限制)");
updated = r.updatedFrames;
failed = r.failedFrames || [];
} else {
var saveIdx = main.currentIndex;
for (i = from; i <= to && i < main.total; i++) {
if (i === saveIdx) continue;
await main.goToIndex(i);
await sleep(280);
var cur = (main.annotation.object_list || []).filter(function (o) { return o.tracking_id === tid; });
if (!cur.length) { skipped++; continue; }
for (var k = 0; k < CAM_ORDER.length; k++) {
var v2 = vals[CAM_ORDER[k]];
if (v2) {
var p = {};
if (v2.occlusion !== null) p.occlusion = v2.occlusion;
if (v2.truncation !== null) p.truncation = v2.truncation;
main.patchObjectCamera(tid, CAM_ORDER[k], p);
}
}
var sr = await main.saveCurrentFrame();
if (sr && sr.ok) updated++; else failed.push("frame" + i);
}
try { await main.goToIndex(saveIdx); } catch (e) { }
}
/* 当前帧:平台跨帧通道会跳过它(那是内存态),单独改 + 存。
⚠ 别用「annotation 引用是否变化」判断成功与否 —— zustand 每次 set 都换 state 对象,
而 React 渲染又是异步的,刚改完读到的可能是上一帧快照。等一拍再读值才准。 */
var curSelf = (main.annotation.object_list || []).filter(function (o) { return o.tracking_id === tid; });
if (curSelf.length) {
for (i = 0; i < CAM_ORDER.length; i++) {
var v3 = vals[CAM_ORDER[i]];
if (v3) {
var p3 = {};
if (v3.occlusion !== null) p3.occlusion = v3.occlusion;
if (v3.truncation !== null) p3.truncation = v3.truncation;
main.patchObjectCamera(tid, CAM_ORDER[i], p3);
}
}
await sleep(220);
var s2 = await main.saveCurrentFrame();
if (s2 && s2.ok) updated++;
else failed.push("当前帧" + (s2 && s2.message ? ":" + s2.message : ""));
}
return "#" + tid + " 已更新 " + updated + " 帧" +
(skipped ? ",跳过 " + skipped + " 帧(无此 ID)" : "") +
(failed.length ? ",失败 " + failed.length + " 帧:" + failed.join(",") : "");
}
/* ======================== 质检(对齐客户 cuboid-valid-check_v3) ======================== */
var QC_CN = {
dim_out_of_range: "尺寸超范围",
oc_null: "遮挡未填",
tc_null: "截断未填",
invalid_category: "类别无效",
invalid_subclass: "子类无效",
unspecified_class: "类别未指定",
Truncation_error_is_tc: "截断漏标",
Truncation_error_is_not_tc: "截断错标"
};
var QC_COLOR = {
dim_out_of_range: "#fbbf24",
oc_null: "#38bdf8", tc_null: "#38bdf8",
unspecified_class: "#f87171", invalid_category: "#f87171", invalid_subclass: "#f87171",
Truncation_error_is_tc: "#f472b6", Truncation_error_is_not_tc: "#f472b6"
};
function runQc() {
var st = findStores(true), main = st.main;
if (!main || !main.hasDataset) { toast("尚未载入序列", "red"); return; }
var objs = main.annotation.object_list || [];
if (!objs.length) { toast("本帧没有对象", "orange"); return; }
var calib = null;
try {
calib = {};
(main.calibration.cameras || []).forEach(function (c) { calib[c.name] = c; });
} catch (e) { calib = null; }
var res = AdoFit.inspectFrame(objs, calib, null);
/* ⚠ 对外显示帧号必须 +1:内部是 0-based(frames[0] 才是第 1 帧),
平台 UI 是 1-based。漏了 +1 就会出现「明明在第 10 帧、却显示帧 9」。 */
showQcDialog(res, objs.length, main.currentIndex + 1);
}
function showQcDialog(res, total, frame) {
var old = document.getElementById("ado-qc-modal");
if (old) old.remove();
var nIssues = 0, rows = "", i, j, it, is;
for (i = 0; i < res.length; i++) nIssues += res[i].issues.length;
for (i = 0; i < res.length; i++) {
it = res[i];
for (j = 0; j < it.issues.length; j++) {
is = it.issues[j];
rows += "" +
(j === 0 ? "| #" + it.tid + " | " +
"" + (it.sub || "—") + " | " +
"" + frame + " | " : "") +
"" + (QC_CN[is.kind] || is.kind) + " | " +
"" + (is.cam || "—") + " | " +
"" + is.detail + " |
";
}
}
if (!rows) {
rows = "| " +
"本帧 " + total + " 个对象全部通过(尺寸范围 / 截断一致性 / 空值 / 类别) |
";
}
var box = document.createElement("div");
box.id = "ado-qc-modal";
box.style.cssText = "position:fixed;inset:0;z-index:2147483000;background:rgba(2,6,23,.72);" +
"display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif";
box.innerHTML =
"" +
"
质检结果 — 帧 " + frame + "
" +
"
" +
"共 " + total + " 个对象," +
res.length + " 个存在问题(" + nIssues + " 条)。" +
"规则对齐客户工具 cuboid-valid-check_v3:尺寸范围 / 截断一致性 / 遮挡截断空值 / 类别有效性。" +
"
尺寸范围只检查「有 bbox 的可见对象」;截断一致性用鱼眼模型投影 8 角点判定(边缘余量 200px)。
" +
"
" +
"| 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;
/* 同上:对外帧号 1-based */
confirmClearDialog(total, main.currentIndex + 1, function () { doClearAll(total); });
}
function confirmClearDialog(total, curIdx, onOk) {
var old = document.getElementById("ado-clear-modal");
if (old) old.remove();
var box = document.createElement("div");
box.id = "ado-clear-modal";
box.style.cssText = "position:fixed;inset:0;z-index:2147483100;background:rgba(2,6,23,.86);" +
"display:flex;align-items:center;justify-content:center;font:13px/1.6 system-ui,'Microsoft YaHei',sans-serif";
box.innerHTML =
"" +
"
高危操作:清空全部帧
" +
"
" +
"将删除本包 全部 " + total + " 帧 的所有对象标注,并立即保存到服务器。
" +
"此操作不可撤销 —— 平台的 Ctrl+Z 只能回退内存里的单帧改动,救不回已保存的数据。
" +
"当前在第 " + curIdx + " 帧。执行期间请勿操作页面。
" +
"
" +
"
确认请在下面手动输入这四个字:
" +
"
清空全部
" +
"
" +
"
" +
"
" +
"" +
"
";
document.body.appendChild(box);
function close() { box.remove(); }
box.querySelector("#ado-clear-cancel").onclick = close;
box.querySelector("#ado-clear-ok").onclick = function () {
var v = (box.querySelector("#ado-clear-input").value || "").replace(/\s/g, "");
if (v !== "清空全部") {
var tip = box.querySelector("#ado-clear-tip");
tip.style.color = "#f87171";
tip.textContent = "确认词不正确,未执行任何操作(需输入「清空全部」)";
return;
}
close();
onOk();
};
box.addEventListener("click", function (e) { if (e.target === box) close(); });
}
async function doClearAll(total) {
var st = findStores(true), main = st.main;
var frames = main.frames || [];
var done = 0, failed = [], i;
tipline("清空中… 0/" + total, "orange");
try { await bridgeLogin(); } catch (e) { toast("文件服务登录失败:" + e.message, "red"); return; }
/* 先把当前帧的未保存改动落盘,否则之后平台切帧时会拿内存里的旧数据覆盖回来 */
try { if (main.dirty) await main.saveCurrentFrame(); } catch (e) { }
for (i = 0; i < frames.length; i++) {
var fr = frames[i];
if (!fr.annotation || !fr.annotation.fileId) { failed.push(i + "(无标注文件)"); continue; }
try {
/* 读回原文件、只把 object_list 置空、其余字段原样保留 */
var data = await bridgeGetJson(fr.annotation.fileId);
data.object_list = [];
await bridgePutJson(fr.annotation.fileId, data);
done++;
} catch (e) { failed.push(i + "(" + e.message + ")"); }
tipline("清空中… " + (i + 1) + "/" + total, "orange");
}
/* 让当前帧的内存态与文件保持一致,避免视图上还留着已删除的框 */
try { findStores(true).main.replaceCurrentObjects([]); } catch (e) { }
toast("清空完成:" + done + "/" + total + " 帧已清空" +
(failed.length ? ";失败 " + failed.length + " 帧:" + failed.join(", ") : ""),
failed.length ? "red" : "green");
}
/* ======================== 统计框数(整包) ========================
逐帧切换读 object_list,输出「每帧框数 + ID 列表 + 总计」。
不分类型,纯计数。 */
/* ======================== 整包文件读写(走平台的文件服务,不切帧) ========================
平台内部就是这么干的(bundle 里的 X8() 工厂):登录换 token → 对每个 fileId
拿 presign URL → 直接 GET/PUT 文件内容。baseUrl 与凭据取自平台自身配置。
相比「逐帧切换」:10 帧从 ~40 秒降到 ~4 秒(快约 9 倍),且
· 不改变用户当前视图,不触发平台的 dirty 自动保存
· 不受「未指定类别会阻止切帧」的守卫限制
代价:绕过了平台状态机(history / 角色合并 / 保存前校验),
所以只读操作(统计、质检)优先走这条;写操作要更谨慎。 */
var BRIDGE = "https://bridge.treed.ai/api/v1";
var BRIDGE_CRED = { loginId: "temp", loginPwd: "y*@PouJmESz#Dl59" };
var BRIDGE_TOKEN = null;
async function bridgeLogin(force) {
if (BRIDGE_TOKEN && !force) return BRIDGE_TOKEN;
var r = await fetch(BRIDGE + "/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(BRIDGE_CRED)
});
if (!r.ok) throw new Error("文件服务登录失败 HTTP " + r.status);
var j = await r.json();
BRIDGE_TOKEN = j && j.payload && j.payload.accessToken;
if (!BRIDGE_TOKEN) throw new Error("文件服务未返回 token");
return BRIDGE_TOKEN;
}
async function bridgePresign(fileId, urlType, retry) {
var token = await bridgeLogin(!!retry);
var r = await fetch(BRIDGE + "/files/" + fileId + "/url", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
body: JSON.stringify({ urlType: urlType })
});
if (r.status === 401 && !retry) return bridgePresign(fileId, urlType, true);
if (!r.ok) throw new Error("取文件地址失败 HTTP " + r.status);
var url = (await r.json()).payload.url;
if (!url) throw new Error("文件服务未返回地址");
return url;
}
async function bridgeGetJson(fileId) {
var url = await bridgePresign(fileId, "GET");
var r = await fetch(url);
if (!r.ok) throw new Error("下载失败 HTTP " + r.status);
return r.json();
}
async function bridgePutJson(fileId, data) {
var url = await bridgePresign(fileId, "PUT");
var body = typeof data === "string" ? data : JSON.stringify(data);
var r = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: body
});
if (!r.ok) throw new Error("上传失败 HTTP " + r.status);
return true;
}
/* 读二进制文件(PCD 点云) */
async function bridgeGetBuffer(fileId) {
var url = await bridgePresign(fileId, "GET");
var r = await fetch(url);
if (!r.ok) throw new Error("下载失败 HTTP " + r.status);
return r.arrayBuffer();
}
/* 并行读全包:返回 [{frame, fileName, data|null, err}] */
async function loadAllFrames(main) {
var frames = main.frames || [];
await bridgeLogin();
return Promise.all(frames.map(function (fr, i) {
if (!fr.annotation || !fr.annotation.fileId) {
return Promise.resolve({ frame: i, fileName: fr.annotation ? fr.annotation.fileName : null,
data: null, err: "该帧没有标注文件" });
}
var fid = fr.annotation.fileId, fname = fr.annotation.fileName;
return bridgeGetJson(fid).then(function (data) {
return { frame: i, fileName: fname, data: data };
}, function (e) {
return { frame: i, fileName: fname, data: null, err: e.message };
});
}));
}
/* ======================== 智能复制上一帧(静止物体保持原位) ========================
平台的 L 键(fY 函数)只是把上一帧对象的 pose_in_lidar 原样搬过来。
但本平台没有全局坐标系(lidar_to_vehicle 与 lidar_to_target 都是单位阵),
所有位姿都记在「自车 LiDAR 坐标系」下 —— 自车一动,连静止物体在这个坐标系里
的数值都会变。所以按 L 之后不动的物体也会整体移位。
修法:用相邻两帧「共同对象位移的中位数」估计自车位移(假设多数物体静止),
复制到当前帧时把这个位移补偿回去。位移离散度过大时给出警告。 */
function medianOf(arr) {
var s = arr.slice().sort(function (a, b) { return a - b; });
return s.length ? s[Math.floor(s.length / 2)] : 0;
}
/* 用 aObjs → bObjs 的共同对象位移估计自车位移(LiDAR 坐标系下) */
function estimateEgoShift(aObjs, bObjs) {
if (!aObjs || !aObjs.length || !bObjs || !bObjs.length) {
return { dx: 0, dy: 0, n: 0, spread: null, ok: false, why: "缺少可对比的两帧" };
}
var map = Object.create(null), i, a, b;
for (i = 0; i < aObjs.length; i++) map[aObjs[i].tracking_id] = aObjs[i];
var dxs = [], dys = [];
for (i = 0; i < bObjs.length; i++) {
b = bObjs[i];
a = map[b.tracking_id];
if (!a || !a.pose_in_lidar || !b.pose_in_lidar) continue;
dxs.push(b.pose_in_lidar.location[0] - a.pose_in_lidar.location[0]);
dys.push(b.pose_in_lidar.location[1] - a.pose_in_lidar.location[1]);
}
if (dxs.length < 3) {
return { dx: 0, dy: 0, n: dxs.length, spread: null, ok: false, why: "共同对象少于 3 个" };
}
var dx = medianOf(dxs), dy = medianOf(dys);
/* 离散度:中位绝对偏差。太大说明「多数物体静止」这个假设不成立。
实测该包各帧离散度在 0.25~0.6 之间(路上运动车辆不少),阈值取 0.6。 */
var spread = medianOf(dxs.map(function (v) { return Math.abs(v - dx); }));
return { dx: dx, dy: dy, n: dxs.length, spread: spread, ok: spread < 0.6 };
}
/* ---------- 点云配准:估计两帧之间的自车平移 ----------
为什么不用「标注对象位移中位数」:第 2 帧还没有真实标注时,它的对象数据
本身就是上一帧复制来的,位移恒为 0,于是估计出的自车位移也是 0(循环论证,
实测就是这个原因导致 #121 没被补偿)。点云是真实观测,永远可用。
做法:两帧点云投影到 BEV 占据栅格,粗到细搜索平移量使重叠度最大。 */
function bevGrid(xyz, gz, cell, half) {
var n = Math.floor(half / cell) * 2;
var g = new Float32Array(n * n), cnt = xyz.length / 3, i, x, y, z, ix, iy;
for (i = 0; i < cnt; i++) {
z = xyz[i * 3 + 2] - gz;
if (z < 0.3 || z > 4.6) continue; /* 只用车身高度带,滤掉地面与高架 */
x = xyz[i * 3]; y = xyz[i * 3 + 1];
ix = Math.floor((x + half) / cell);
iy = Math.floor((y + half) / cell);
if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue;
g[iy * n + ix] += 1;
}
return { g: g, n: n, cell: cell, half: half };
}
/* A 平移 (dxCell, dyCell) 后与 B 的占据重叠度(Dice 系数) */
function bevOverlap(A, B, dxCell, dyCell) {
var n = A.n, inter = 0, ua = 0, ub = 0, i, j, a, ii, jj, b;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
a = A.g[i * n + j] > 0 ? 1 : 0;
ii = i + dyCell; jj = j + dxCell;
b = (ii >= 0 && ii < n && jj >= 0 && jj < n && B.g[ii * n + jj] > 0) ? 1 : 0;
inter += a & b; ua += a; ub += b;
}
}
return (ua + ub) > 0 ? (2 * inter) / (ua + ub) : 0;
}
/* 把点云按 yaw 旋转后投影到 BEV 占据栅格 */
function bevGridRot(xyz, gz, cell, half, yaw) {
var n = Math.floor(half / cell) * 2;
var g = new Float32Array(n * n), cnt = xyz.length / 3;
var ca = Math.cos(yaw), sa = Math.sin(yaw), i, x, y, z, rx, ry, ix, iy;
for (i = 0; i < cnt; i++) {
z = xyz[i * 3 + 2] - gz;
if (z < 0.3 || z > 4.6) continue;
x = xyz[i * 3]; y = xyz[i * 3 + 1];
rx = x * ca - y * sa; ry = x * sa + y * ca;
ix = Math.floor((rx + half) / cell);
iy = Math.floor((ry + half) / cell);
if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue;
g[iy * n + ix] += 1;
}
return { g: g, n: n, cell: cell, half: half };
}
/* 返回 {dx, dy, yaw, score}:上一帧的点 p,在当前帧点云里应出现在
R(yaw)·p + (dx, dy)。同时搜平移与绕 z 轴旋转 ——
自车转弯时纯平移模型吃不掉旋转分量,远处物体会系统性偏掉。 */
function estimateShiftByCloud(prevXyz, curXyz, gz, maxShift, maxYawDeg) {
maxShift = maxShift || 3.0;
/* 实测该包帧间 yaw 会顶到 ±3° 边界(说明真实旋转更大),默认放到 6°。
粗搜 13 个 yaw × 49 个平移 = 637 次栅格比对,仍可接受。 */
maxYawDeg = maxYawDeg === undefined ? 6.0 : maxYawDeg;
var B = bevGrid(curXyz, gz, 1.0, 40); /* 当前帧固定,旋转的是上一帧 */
var lim = Math.ceil(maxShift), yw, yi, dx, dy, r, A;
var yawList = [];
for (yw = -maxYawDeg; yw <= maxYawDeg + 1e-9; yw += 1.0) yawList.push(yw);
var best = { s: -1, dx: 0, dy: 0, yaw: 0 };
for (yi = 0; yi < yawList.length; yi++) {
A = bevGridRot(prevXyz, gz, 1.0, 40, yawList[yi] * Math.PI / 180);
for (dy = -lim; dy <= lim; dy++) {
for (dx = -lim; dx <= lim; dx++) {
r = bevOverlap(A, B, dx, dy);
if (r > best.s) { best = { s: r, dx: dx, dy: dy, yaw: yawList[yi] }; }
}
}
}
/* 细搜:0.25m / 0.5°,在粗结果附近 ±1m、±0.5° 内收敛 */
var FINE = 0.25, FYAW = 0.5, B2 = bevGrid(curXyz, gz, FINE, 20);
var flim = Math.ceil(1.0 / FINE);
var cx = Math.round(best.dx / FINE), cy = Math.round(best.dy / FINE);
var b2 = { s: -1, dx: cx, dy: cy, yaw: best.yaw };
for (yw = best.yaw - 0.5; yw <= best.yaw + 0.5 + 1e-9; yw += FYAW) {
var A2 = bevGridRot(prevXyz, gz, FINE, 20, yw * Math.PI / 180);
for (dy = -flim; dy <= flim; dy++) {
for (dx = -flim; dx <= flim; dx++) {
r = bevOverlap(A2, B2, cx + dx, cy + dy);
if (r > b2.s) { b2 = { s: r, dx: cx + dx, dy: cy + dy, yaw: yw }; }
}
}
}
return { dx: b2.dx * FINE, dy: b2.dy * FINE, yaw: b2.yaw,
score: b2.s, coarseScore: best.s };
}
/* ---------- 物体级局部微调 ----------
全局配准只建模平移,自车若有轻微旋转,远处物体仍会残留偏差
(实测重叠度只有 0.53)。这里对每个物体单独在当前帧点云里找
「框内点数最多」的位置,在预测位置附近小范围搜索,把残差收掉。 */
function bevCountGrid(xyz, gz, cell, half) {
var n = Math.floor(half / cell) * 2;
var g = new Float32Array(n * n), cnt = xyz.length / 3, i, x, y, z, ix, iy;
for (i = 0; i < cnt; i++) {
z = xyz[i * 3 + 2] - gz;
if (z < 0.3 || z > 4.6) continue;
x = xyz[i * 3]; y = xyz[i * 3 + 1];
ix = Math.floor((x + half) / cell);
iy = Math.floor((y + half) / cell);
if (ix < 0 || ix >= n || iy < 0 || iy >= n) continue;
g[iy * n + ix] += 1;
}
return { g: g, n: n, cell: cell, half: half };
}
/* 框内栅格点数和(框用 OBB 判定) */
function boxScore(G, cx, cy, L, W, yaw) {
var hx = L / 2, hy = W / 2, ca = Math.cos(yaw), sa = Math.sin(yaw);
var rad = Math.sqrt(hx * hx + hy * hy);
var n = G.n, cell = G.cell, half = G.half, sum = 0, i, j, x, y, lx, ly, dx, dy;
var i0 = Math.max(0, Math.floor((cx - rad + half) / cell));
var i1 = Math.min(n - 1, Math.ceil((cx + rad + half) / cell));
var j0 = Math.max(0, Math.floor((cy - rad + half) / cell));
var j1 = Math.min(n - 1, Math.ceil((cy + rad + half) / cell));
for (i = i0; i <= i1; i++) {
x = (i + 0.5) * cell - half;
for (j = j0; j <= j1; j++) {
y = (j + 0.5) * cell - half;
dx = x - cx; dy = y - cy;
lx = dx * ca + dy * sa;
ly = -dx * sa + dy * ca;
if (lx > -hx && lx < hx && ly > -hy && ly < hy) sum += G.g[j * n + i];
}
}
return sum;
}
/* 在 (cx, cy) 附近 ±radius 内找框内点数最多的位置 */
function refinePos(G, cx, cy, L, W, yaw, radius, step) {
var best = { c: -1, dx: 0, dy: 0 }, c, dx, dy;
for (dy = -radius; dy <= radius + 1e-9; dy += step) {
for (dx = -radius; dx <= radius + 1e-9; dx += step) {
c = boxScore(G, cx + dx, cy + dy, L, W, yaw);
if (c > best.c) { best = { c: c, dx: dx, dy: dy }; }
}
}
return best;
}
/* ======================== 摄像头属性快填 ========================
用户的痛点:「要一个个看视角,还要切换地方,然后还要选数字」。
这里用平台的相机标定自己算投影,自动判断「哪个视角根本看不到这个物体」,
从而把「逐个查看 4 路图像」变成「只看表格里标了 ? 的格子」。
投影模型(与平台一致):
p_cam = R_lidar_to_camera · p_lidar + t_lidar_to_camera
u = Mu · (x / z) + U0
v = Mv · (y / z) + V0
判断依据:物体 8 个角点投影后有多少落在图像范围内。 */
function projectToCam(cam, p) {
var R = cam.lidar_to_camera.rotation, T = cam.lidar_to_camera.translation;
var x = R[0] * p[0] + R[1] * p[1] + R[2] * p[2] + T[0];
var y = R[3] * p[0] + R[4] * p[1] + R[5] * p[2] + T[1];
var z = R[6] * p[0] + R[7] * p[1] + R[8] * p[2] + T[2];
if (z <= 0.1) return null; /* 在相机后方 */
var it = cam.intrinsic;
return { u: it.Mu * (x / z) + it.U0, v: it.Mv * (y / z) + it.V0, z: z };
}
/* ======================== 粘贴后摄像头属性重算 ========================
背景(用户反馈的质检 bug):
一个静止物体(如柱子)在第 1 帧位于自车前方、正常标注截断=0;
用 Ctrl+C/V 粘到最后一帧时,雷达车已经开过去,物体相对落到了车后方。
插件做自车位移补偿把框放对了位置,但 cameras 属性是「原样带过来」的 ——
front 视角 truncation 仍是源帧的 0。质检因此报:
「8 个角点在相机后方,截断却填了 0 → 应改为非 0」
⚠ 关键约束:平台的白名单是 uz = ["truncation","occlusion"],
patchObjectCamera 只认这两个字段,**bbox 写不进去** ——
平台会在几何变更后走 Px() 从角点重算 bbox 并覆盖。
所以「把 bbox 清成 null 让质检跳过」这条路走不通(实测会被重算回来)。
因此修法只能落在 truncation 上:物体主体跑到相机后方时,
该视角的截断应改成非 0(质检要求「后方>3 个角点 → truncation ≠ 0」)。
取 2(重度截断):8 角点基本全在后方,物体实际已几乎离开该视野。 */
/* ======================== 复刻平台 R 键(重新贴合自动投影) ========================
用户第四次反馈「挪动框以后切帧或保存再打开都没用」,并指出
「粘贴后我手动选中视角然后按 R 就不会有这种问题了」——
这句话直接指向了平台内部真正的修复通道。
从 bundle 反编译出平台的三段源码(v2.9.12 的新认知):
① 渲染是否画 2D 框,判据是 dd():
function dd(cam) {
return cam.is_hidden_forcibly ? false
: cam.is_decoupled_bounding_box ? true
: cam.bbox !== null;
}
⚠ 注意:truncation 根本不参与这个判定!
只要 is_decoupled_bounding_box 为 true,2D 框就**永远**画出来,
哪怕 8 个角点全在相机后方。
—— 这就是「质检报错消失了、2D 框却还在」的真正原因:
我们只把 truncation 改成了 2,dd() 依然返回 true。
② R 键的 handler(2D 框模式下 KeyR):
if (i.code === "KeyR" && !i.ctrlKey) {
const C = n?.cameras.find(R => R.name === r); // r = 当前 2D 通道
if (!C) return;
Px(l, f, r, C), i.preventDefault();
}
③ Px() —— 一键把该视角「重新绑回自动投影」:
function Px(engine, tid, camName, cam) {
const o = ...; // 该对象
const l = sD(o, cam); // 用 3D 位姿重算投影 bbox
const cur = o.cameras.find(c => c.name === camName);
const visible = l !== null;
return gc(engine, tid, camName, {
bbox: l, // ← 重算后的框(不可见 → null)
is_decoupled_bounding_box: false, // ← 解绑,交回自动投影
is_hidden_forcibly: false,
truncation: ac(cur?.truncation ?? null, visible),
occlusion: ac(cur?.occlusion ?? null, visible)
});
}
function ac(v, visible) { return visible ? v : (v ?? 0); }
⚠ 之前我以为 is_decoupled_bounding_box / bbox 写不进去(被权限白名单拦)。
重读白名单源码后发现自己读错了:
const uz = ["truncation", "occlusion"];
function OR(patch) {
const t = Object.keys(patch);
return (t.some(r => !uz.includes(r)) ? mr() : sc())
? { allowed: patch, blocked: false } // ← allowed 是完整入参
: { allowed: {}, blocked: true };
}
function mr() { return Oo() !== "attribute"; } // Oo() 读 URL 的 ?role=
function sc() { const n = Oo(); return n !== "object" && n !== "reviewer"; }
关键在「逗号表达式」这一句:
if (l && Yi(), Object.keys(a).length === 0) return;
含白名单外字段时走 mr();我们的页面 URL 没有 ?role= → Oo() = null
→ mr() 返回 true → { allowed: 完整入参, blocked: false } → 五个字段全部写入。
(只有 ?role=attribute 才会被拦。)
所以 R 键在我们这个页面是完全生效的,插件也能做同样的事。
结论:正确修法不是「把 truncation 改成 2」,而是**完整复刻 Px()** ——
重算 bbox + 清掉两个标记。三者缺一不可:
· 只改 truncation → dd() 仍 true,2D 框照旧(前三次失败的根源)
· 只清 decoupled → bbox 还在,dd() 仍 true(离线验证过)
· 三者一起 → 物体在后方时 bbox 算成 null,dd() = false,2D 框才真正消失
写入通道与平台一致:patchObjectCamera(走 store action = R 键通道)+
annotationStore.update(同步 shape.meta,平台是双向数据流,缺一会被反解覆盖)。 */
/* 复刻平台的 ac():不可见时把 null 补成 0,可见时保留原值 */
function acVal(v, visible) {
if (visible) return v;
return (v === null || v === undefined) ? 0 : v;
}
/* 复刻平台的 sD():用 3D 位姿重算该视角的投影 bbox。
返回 null = 该视角看不到物体(所有角点都不在相机前方)。
⚠ 与 camVisibility 的区别:这里只要求「有角点在相机前方」就给框,
完全对齐平台 ij() 的语义(c===0 → null,否则取并集)。 */
function projectBboxOf(obj, calib) {
var pts = AdoFit.cuboidPointsFromTarget(obj);
if (!pts) return null;
var pr = null;
try { pr = AdoFit.projectFisheye(pts, calib); } catch (e) { return null; }
if (!pr) return null;
var u0 = Infinity, v0 = Infinity, u1 = -Infinity, v1 = -Infinity, cnt = 0, k;
for (k = 0; k < 8; k++) {
/* 平台 ij() 里 Tx(...) 只对 zClamp 内的角点返回像素,等价于「在相机前方」 */
if (!(pr.zc[k] > 0)) continue;
if (!isFinite(pr.u[k]) || !isFinite(pr.v[k])) continue;
if (pr.u[k] < u0) u0 = pr.u[k];
if (pr.u[k] > u1) u1 = pr.u[k];
if (pr.v[k] < v0) v0 = pr.v[k];
if (pr.v[k] > v1) v1 = pr.v[k];
cnt++;
}
return cnt === 0 ? null : [u0, v0, u1, v1];
}
/* 完整复刻平台的 Px() + 补一条平台不做的质检兜底。
返回 {changed, why};changed=true 表示该视角真的有字段被改动。
⚠ 为什么要在 Px() 之上再补一条:
Px() 里的 ac(cam.truncation, visible) 只在「不可见时把 null 补成 0」,
如果源帧继承下来的 truncation 本来就是 0(而实际上有角点跑到相机后方),
那么 ac(0, false) 依然返回 0 —— 2D 框是消失了(dd() 只看 decoupled),
但质检仍会报「8 个角点在相机后方,截断却填了 0 → 应改为非 0」。
所以这里额外套一层纯几何判据:真有角点在后方且截断为 0 → 改成 2。 */
function platformReproject(obj, camName, calib) {
var cam = null, i;
for (i = 0; i < (obj.cameras || []).length; i++) {
if (obj.cameras[i].name === camName) { cam = obj.cameras[i]; break; }
}
if (!cam) return { changed: false, why: "" };
var newBbox = projectBboxOf(obj, calib);
var visible = (newBbox !== null);
var newTc = acVal(cam.truncation, visible);
var newOc = acVal(cam.occlusion, visible);
/* ===== 质检兜底:有角点在相机后方,截断就不能是 0 =====
平台自己不做这件事(Px 只做 ac()),但质检会报错,所以必须补上。
behind 用 < -2 的阈值(与 v2.9.11 判据一致),> 3 才算「大半跑到后方」,
避免物体压在画面边缘、个别角点轻微越界时误判成截断。 */
var behind = 0, newTcReason = "";
if (calib) {
var pr = null;
try { pr = AdoFit.projectFisheye(AdoFit.cuboidPointsFromTarget(obj), calib); } catch (e) { pr = null; }
if (pr) for (var k = 0; k < 8; k++) if (pr.zc[k] < -2) behind++;
}
if (behind > 3 && (newTc === 0 || newTc === null || newTc === undefined)) {
newTc = 2;
/* 2D 框都算出来了却把截断填 0,质检必报;这里记一笔原因方便排查 */
newTcReason = "后方" + behind + "/8角点";
}
/* 有没有真的需要改?逐字段比对,避免每轮空写触发平台重渲染 */
var needBbox = !sameBbox(cam.bbox, newBbox);
var needDec = (cam.is_decoupled_bounding_box === true);
var needHid = (cam.is_hidden_forcibly === true);
var needTc = (cam.truncation !== newTc);
var needOc = (cam.occlusion !== newOc);
if (!needBbox && !needDec && !needHid && !needTc && !needOc) {
return { changed: false, why: "" };
}
var why = [];
if (needDec) why.push("解绑手动投影");
if (needHid) why.push("取消强制隐藏");
if (needBbox) why.push("重算2D框(" + (visible ? "有" : "无") + ")");
if (needTc) why.push("截断 " + cam.truncation + "→" + newTc + (newTcReason ? "(" + newTcReason + ")" : ""));
if (needOc) why.push("遮挡 " + cam.occlusion + "→" + newOc);
/* 直接改对象(object_list 就地改,调用方负责 replaceCurrentObjects 落盘) */
cam.bbox = newBbox;
cam.is_decoupled_bounding_box = false;
cam.is_hidden_forcibly = false;
cam.truncation = newTc;
cam.occlusion = newOc;
return { changed: true, why: why.join("+"), visible: visible, behind: behind };
}
/* bbox 比对:都是 null / 都是数组且四项近似相等 → 视为相同 */
function sameBbox(a, b) {
var an = (a === null || a === undefined), bn = (b === null || b === undefined);
if (an && bn) return true;
if (an !== bn) return false;
if (!(a.length === 4 && b.length === 4)) return false;
for (var i = 0; i < 4; i++) if (Math.abs(a[i] - b[i]) > 0.5) return false;
return true;
}
function revalidatePastedCameras(obj, calibMap) {
var cams = obj.cameras || [];
if (!cams.length || !calibMap) return { n: 0, detail: [] };
var pts = AdoFit.cuboidPointsFromTarget(obj);
if (!pts) return { n: 0, detail: [] };
var changed = 0, detail = [], i, k;
/* ⚠ v2.9.12 起:主路径改为「完整复刻平台 R 键(Px)」——
因为只改 truncation 是治不好的(dd() 不看 truncation,见上方长注释)。
每个视角都跑一遍 platformReproject:重算 bbox + 清两个标记 + 按可见性修 truncation/occlusion。 */
for (i = 0; i < cams.length; i++) {
var camName = cams[i].name;
if (!calibMap[camName]) continue;
var rp = platformReproject(obj, camName, calibMap[camName]);
if (rp.changed) {
changed++;
detail.push(camName + " " + rp.why);
}
}
return { n: changed, detail: detail };
/* ↓↓↓ 以下是 v2.9.11 的旧逻辑,保留作对照(已不再执行)↓↓↓ */
/* eslint-disable no-unreachable */
for (i = 0; i < cams.length; i++) {
var cam = cams[i];
var c = calibMap[cam.name];
if (!c) continue;
/* projectFisheye 一次给出 u/v 与相机系 z(zc),两个判据都用它 */
var pr = null;
try { pr = AdoFit.projectFisheye(pts, c); } catch (e) { continue; }
if (!pr) continue;
/* ⚠ 不能只看「当前 bbox 是否为 null」来决定跳过 ——
实测:粘贴时继承的 bbox 可能还是 null(源帧那会儿物体在该视角不可见),
但对象写进场景后,平台会按新位姿重算 bbox,可能重新给出一块框。
那时遗留的 truncation(源帧的 0)就变成了假数据,质检照样报错。
实测 #668 front 修好了、left 又冒出来,就是这个时序造成的。
所以判据改成纯几何:谁在相机后方,谁的截断就不能是 0。 */
var behind = 0;
for (k = 0; k < 8; k++) if (pr.zc[k] < -2) behind++;
/* ① 框大半跑到相机后方 —— 源帧的 truncation=0 必然失效。
按质检判据改成非 0;已经非 0 的保留原值(别覆盖用户填的程度)。 */
if (behind > 3) {
var isZero = (cam.truncation === 0 || cam.truncation === null || cam.truncation === undefined);
var ocBad = (cam.occlusion === null || cam.occlusion === undefined);
if (!isZero && !ocBad) continue;
if (isZero) cam.truncation = 2;
if (ocBad) cam.occlusion = 0;
changed++;
detail.push(cam.name + " 后方(" + behind + "/8)→截断改 " + cam.truncation);
continue;
}
/* ② 全部角点稳稳在画面内 → 源帧的非 0 截断是错的,归 0。
⚠ 只在「该视角本来就被认为可见(有 bbox)」时才归 0:
不可见视角的 truncation 本来就该是 0,非 0 也无意义,
强行改写反而可能破坏用户意图。 */
if (behind === 0 && cam.truncation !== 0 && cam.truncation !== null && cam.truncation !== undefined &&
cam.bbox !== null && cam.bbox !== undefined) {
var m = AdoFit.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) {
cam.truncation = 0;
changed++;
detail.push(cam.name + " 全可见→截断归 0");
}
}
}
return { n: changed, detail: detail };
}
/* 返回 {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 {
/* ⚠⚠ v2.9.16 修「数字乱跳 / 改完又跳回去」:
state 必须显式带上 tracking_id。applyAttrChanges 里原来用
「state[i] ↔ objs[i]」按下标配对 —— 而 scanCamAttrs 遍历 objs 时
用的是 `main.annotation.object_list || []` 的**当轮快照**,
applyAttrChanges 里又重新读了一遍。两次读取之间只要平台插入了
任何一个对象(跨帧切换、刚粘贴的对象被平台补进数据层、
自动贴合重建 …),下标就会整体错位:
A 面板里的值 → 写到 B 的 cameras 上。
表现就是「摄像头属性数字会乱跳」「前视的截断一直是 2,
我改完自动又跳回去」—— 不是冷却问题,是**张冠李戴**。
修法:配对一律走 tracking_id,绝不用下标。 */
tid: r.id, 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;
/* ⚠⚠ v2.9.16 关键修复:配对一律走 tracking_id,**绝不按下标**。
原来写的是 var o = objs[i] / objs.indexOf 之类 —— 但
· scanCamAttrs 读的是「打开面板那一刻」的 object_list;
· applyAttrChanges 读的是「点应用这一刻」的 object_list。
两次读取之间平台可能已经插入/删除过对象(跨帧、平台补 shape、
我们自己刚粘贴补全…),下标一错位就会把 A 的截断值写到 B 身上。
这就是「摄像头属性数字会乱跳」「前视的截断改完自动又跳回去」的根因
—— 不是冷却窗口的问题,是张冠李戴。
这里先建 tracking_id → 对象 的索引,后面全部按 tid 取。 */
var byTid = Object.create(null);
for (i = 0; i < objs.length; i++) {
if (objs[i] && objs[i].tracking_id !== null && objs[i].tracking_id !== undefined) {
byTid[objs[i].tracking_id] = objs[i];
}
}
function objOfRow(row) {
if (!row) return null;
var t = (row.tid !== undefined && row.tid !== null) ? row.tid : row.id;
if (t === undefined || t === null) return null;
return byTid[t] || null;
}
/* 这是「用户在属性快填里手填」的入口 —— 给自己人开冷却窗口,
否则 400ms 后的持续校验会立刻把手填的值按几何判据改掉,跟用户打架。 */
for (i = 0; i < state.length; i++) {
var oM = objOfRow(state[i]);
if (oM) markUserEdit(oM.tracking_id);
}
for (i = 0; i < state.length; i++) {
var row = state[i], o = objOfRow(row);
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;
}
}
}
}
/* ── 写入:v2.9.15 起统一走 patchObjectCamera,禁止整表覆盖 + shape 重建 ──────
⚠⚠ 这里原本是 replaceCurrentObjects(objs) + store.clear() + store.add(...),
它同时造成两类症状(老大实测反馈):
【症状一 · 边角点变灰色】—— 平台认不出 shape 了
store.add({ id: "ado-" + tracking_id, ... })
这个 id 是我们**自己编的**,而平台的 shape 有自己的 id。
清空后塞进去一堆平台不认识的 shape → 平台无法把它们关联回已有对象,
属性丢失、渲染退化成「灰点」。正常应是绿点。
【症状二 · 位置/形状错位】
points: AdoFit.cornersOf(pose_in_lidar.location, 四元数, L, W, H)
口径与平台不一致(cornersOf 的 z 居中 0 基准;平台 get_cuboid_points
是底面 0→height,同一物体实测 z 差一个 height)。
正确做法:**只改属性字段(truncation / occlusion),位置交给平台**。
对象 ID 用平台上真实存在的 tracking_id,走官方 action。 */
var wrote = 0;
for (i = 0; i < state.length; i++) {
var rowW = state[i], oW = objOfRow(rowW); /* ⚠ 按 tid 取,不按下标 */
if (!oW) continue;
for (j = 0; j < rowW.cells.length; j++) {
var cW = rowW.cells[j];
var nameW = cams[j] ? cams[j].name : cW.name;
if (oW.cameras) {
for (k = 0; k < oW.cameras.length; k++) {
if (oW.cameras[k].name !== nameW) continue;
/* 只在真的不同时才写,避免空写触发平台重渲染 */
if (oW.cameras[k].truncation === cW.t && oW.cameras[k].occlusion === cW.o) break;
try {
main.patchObjectCamera(oW.tracking_id, nameW, {
truncation: cW.t,
occlusion: cW.o
});
/* ⭐ 用户手填的视角 → 永久定型,后续 sweepCameras 永不再改它。
这是「改完自动又跳回去」的根治点。 */
markSettled(oW.tracking_id, nameW);
markUserEdit(oW.tracking_id);
wrote++;
} catch (e) { }
break;
}
}
}
}
/* 同步 shape.meta —— 只 update 已存在的 shape,绝不 clear / add 重建。
(平台数据流双向,不改这边会被反解覆盖;但重建 shape 会破坏位置与属性。) */
if (scene && scene.annotationStore && wrote) {
try {
var store = scene.annotationStore;
var allW = store.getAll();
for (i = 0; i < state.length; i++) {
var oF = objOfRow(state[i]); /* ⚠ 同样按 tid 取 */
if (!oF) continue;
var shF = null;
for (var sw = 0; sw < allW.length; sw++) {
if (allW[sw].meta && allW[sw].meta.tracking_id === oF.tracking_id) { shF = allW[sw]; break; }
}
if (!shF) continue;
/* 保留原有 points / interfaceId / id,只替换 cameras */
var metaF = JSON.parse(JSON.stringify(shF.meta || {}));
metaF.cameras = JSON.parse(JSON.stringify(oF.cameras || []));
store.update(shF.id, { meta: metaF });
}
} catch (e) { }
}
return wrote;
}
/* 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]];
var nquat = AdoFit.yawQuat(o.pose_in_lidar.orientation, eyaw);
c.pose_in_lidar = { location: nloc, orientation: nquat };
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();
/* ⚠ pose_in_lidar_target.orientation 也要跟着转:
cuboidPointsFromTarget(以及质检的角点计算)走的是 target 的欧拉角,
只挪 location 不改朝向 → 角点算出来是错的,粘贴后属性重算会误判。
实测两种存法不同:
· pose_in_lidar / pose_in_vehicle.orientation → 四元数 [x,y,z,w]
· pose_in_lidar_target.orientation → 欧拉角 [rx,ry,rz](下标 2 是 yaw) */
if (c.pose_in_lidar_target && c.pose_in_lidar_target.orientation) {
var tq = c.pose_in_lidar_target.orientation;
if (tq.length === 3) {
c.pose_in_lidar_target.orientation = [tq[0], tq[1], tq[2] + eyaw];
} else if (tq.length === 4) {
c.pose_in_lidar_target.orientation = AdoFit.yawQuat(tq, eyaw);
}
}
/* 摄像头属性原样带过来(不重置为 null)。
平台 L 键会把有 bbox 的视角属性清空强制重填,但用户要求复制时保留 ——
相邻帧同一物体的遮挡/截断大多一致,保留能省掉大量重复填写。
⚠ 但「原样带过来」有个致命前提:物体的相对视角没变。
自车运动后物体可能整个跑到相机后方,源帧的 truncation=0 就变成假数据,
质检必报「角点在相机后方,截断却填了 0」。见下方 revalidatePastedCameras。 */
c.cameras = (o.cameras || []).map(function (cam) {
var m = {}, k;
for (k in cam) m[k] = cam[k];
return m;
});
return c;
});
/* ⚠ 关键一步:粘贴后的位姿变了,源帧的摄像头属性可能已失效。
典型场景(用户实测 #668):静止物体在第 1 帧位于自车前方、截断=0;
粘到最后一帧时自车已开过去,物体落到相机后方 →
前视角 bbox 还在、截断仍是 0,质检直接报错。
这里按「粘贴后的新位姿」逐个视角重判,只改真正失效的项。 */
var nCamFixed = 0, camFixDetail = [];
try {
var calibMap = Object.create(null);
((main.calibration && main.calibration.cameras) || []).forEach(function (cm) {
calibMap[cm.name] = cm;
});
for (var ci = 0; ci < newObjs.length; ci++) {
var rv = revalidatePastedCameras(newObjs[ci], calibMap);
if (rv.n) {
nCamFixed++;
if (rv.detail.length) {
camFixDetail.push("#" + newObjs[ci].tracking_id + " " + rv.detail.join("/"));
}
}
}
} catch (e) { /* 属性重算是增强项,失败不阻断粘贴 */ }
/* 走平台内存:替换对象列表 + 重建场景框(与平台 L 同路径) */
/* ── 写入:v2.9.15 起禁止「整表覆盖 + 清空重建 shape」────────────────────
⚠⚠ 这里原本是 replaceCurrentObjects(merged) + store.clear() + store.add(...),
会造成两类症状(老大实测):
① 位置/形状错位:cornersOf 的角点口径与平台不一致(已修正,见 fit_core.js);
② 更致命的是「整表 clear + 重建」会把 shape 的 interfaceId 重算一遍 ——
而 interfaceId 才是顶点颜色的唯一来源(见 v2.9.17 根因说明)。
⚠ 这里是「新增对象」场景,与改属性不同 —— 新对象必须真的被**加进** object_list。
正确分工:
· 数据层:用平台自己的写入路径 replaceCurrentObjects 把对象加进去
(它只负责对象数据,**不会**建 shape —— 活体实测见下方 v2.9.17 说明);
· 场景层:显式走 engine.addAnnotation() 建框,interfaceId 一次给对。
两件事分开做,绝不用「整表重建」把已有 shape 一起冲掉。 */
try {
/* 合并写入:当前帧原有对象 + 新粘贴的(ID 已去重,不会冲突) */
var merged = curObjs.concat(newObjs);
main.replaceCurrentObjects(merged);
/* ⛔ 不调用 store.clear() —— 整表重建会顺带重算 interfaceId 并冲掉 cameras */
try { main.registerUsedTrackingIds(merged.map(function (o) { return o.tracking_id; })); } catch (e) { }
} catch (e) { toast("写入失败:" + e.message, "red"); return; }
/* ⚠⚠ v2.9.17【建框通道修正】replaceCurrentObjects **不会**建 shape(活体实测:
传 3 个对象进去,annotationStore.getAll() 还是只有 2 个)。
新粘贴的对象必须显式走平台规范通道 engine.addAnnotation(),否则
「数据层有对象、场景里没框」,属性面板/选中都会跟着不对。
注:id 用 "ado-" + tracking_id 与平台 ks() 完全同构(ks = n => `ado-${n}`),
不是我们自造的约定 —— 之前误判成自造 id 反而绕远路了。
顺便:interfaceId 直接用对象自己的 category 解析 → 颜色一次就对,
不再依赖事后回写(事后那次留着做兜底,见下方 repairShapeInterfaces)。 */
var madePasteShapes = 0, engP = null;
try { engP = st.scene && st.scene.engine; } catch (e) { engP = null; }
if (!engP) { try { engP = (findStores(false) || {}).scene; engP = engP && engP.engine; } catch (e) { } }
for (var mk = 0; mk < newObjs.length; mk++) {
try {
var oM = newObjs[mk];
if (shapeByTrackingId(st.scene.annotationStore, oM.tracking_id)) continue; /* 已有就不重复建 */
var iidM = resolveInterfaceId(st.scene, oM.category, main) || "VEHICLE";
var locM = oM.pose_in_lidar.location;
var quatM = oM.pose_in_lidar.orientation;
var ptsM = AdoFit.cornersOf(locM, quatM, oM.length, oM.width, oM.height);
if (engP && typeof engP.addAnnotation === "function") {
engP.addAnnotation({
id: "ado-" + oM.tracking_id, interfaceId: iidM, points: ptsM,
zOrder: st.scene.annotationStore.getNextZOrder(), meta: oM
}, { recordHistory: true });
madePasteShapes++;
}
} catch (e) { }
}
/* ⚠⚠ v2.9.17【灰点真根因】粘贴路径的 interfaceId 修复 ─────────────────────
平台重建 shape 时 interfaceId = Ud(meta.category):
· category 在 definitions 里 → 用它(VEHICLE → #22c55e 绿)
· 否则一律退成 OBJECT → #94a3b8 灰
粘贴对象虽然是从源帧整条 JSON 带过来的(category 应该是全的),但有两条会翻车:
① 源帧对象的 category 是 null(历史遗留 / 非本插件创建)→ Ud(null) = OBJECT;
② object_list 传入后平台不一定立即重建,重建时机的 category 快照可能被别的
写入路径覆盖。
⇒ 拿到新粘贴的 tracking_id 后,轮询等 shape 出现,把 meta.category 与
**shape.interfaceId** 一起对齐(patchShapeAttrs 内部两件都做)。 */
var pasteTids = newObjs.map(function (o) { return o.tracking_id; });
var catOf = Object.create(null);
for (var ci2 = 0; ci2 < newObjs.length; ci2++) {
catOf[newObjs[ci2].tracking_id] = newObjs[ci2].category || "VEHICLE";
}
for (var ti2 = 0; ti2 < pasteTids.length; ti2++) {
(function (tidX) {
var srcSub = null, k2;
for (k2 = 0; k2 < newObjs.length; k2++) {
if (newObjs[k2].tracking_id === tidX) { srcSub = newObjs[k2].sub_class; break; }
}
patchShapeAttrs(main, tidX, srcSub, catOf[tidX]);
})(pasteTids[ti2]);
}
/* 幂等兜底:400ms 后全帧扫一遍,把任何 interfaceId 与 meta.category 不一致的修回来 */
setTimeout(function () {
var s3 = null;
try { s3 = findStores(false); } catch (e) { s3 = null; }
if (s3 && s3.scene) {
try {
repairShapeInterfaces(s3.scene, null);
s3.scene.onDirty && s3.scene.onDirty({ reason: "data" });
} catch (e) { }
}
}, 400);
var msg = "已粘贴 " + newObjs.length + " 个对象" +
(nSkipped ? "(跳过当前帧已有的 " + nSkipped + " 个 ID)" : "") +
(noComp ? ",未做补偿(照搬 L)" :
",位移补偿 (" + ego.dx.toFixed(2) + ", " + ego.dy.toFixed(2) + ")m" +
(nRefined ? ",其中 " + nRefined + " 个做了点云微调" : ""));
if (nCamFixed) {
msg += ";已按新位姿修正 " + nCamFixed + " 个对象的摄像头属性(" +
camFixDetail.slice(0, 3).join(";") +
(camFixDetail.length > 3 ? " 等" : "") + ")";
}
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) { }
}
/* 后台 watcher 心跳:在 window 上暴露两个计数器,
排查「粘贴了但没反应」时可以直接看这两个数会不会涨 ——
.pasteTick 涨 = 粘贴补全轮询在跑;.exemptTick 涨 = 尺寸异常勾选在跑。
这两个数是 v2.9.7/v2.9.8 两次「改完还是不行」的教训:
面板显示正常 ≠ 后台活着,必须有个可观察的活着证明。 */
var WATCHDOG = { pasteTick: 0, exemptTick: 0, startedAt: Date.now() };
try { window.adoFitWatchdog = WATCHDOG; } catch (e) { }
function startExemptWatcher() {
tickExempt();
/* 用轮询而不是 MutationObserver。
⚠ React 改的是 checkbox 的 checked「property」(el.checked = x),
而 MutationObserver 的 attributes 监听的是 HTML「attribute」,
两者不是一回事 —— 实测挂 childList + attributes 都收不到取消勾选的通知。
间隔 800ms:一次 querySelectorAll 开销可忽略,也避免写得太频繁。 */
setInterval(function () {
WATCHDOG.exemptTick++;
try { tickExempt(); } catch (e) { }
}, 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; }
}
/* 由 tracking_id 反查平台真实的 shape id。
⚠⚠ v2.9.17(复核修正,v2.9.16 的说法有误):
对象的 shape id 实际**就是** `ado-${tracking_id}`
(bundle 原文:ks = n => `ado-${n}`,Dx() 内部 ks(n.tracking_id))。
但要区分两类 shape 混在同一帧里:
· 平台从 object_list 重建出来的对象框 → id = "ado-"
· 平台**手绘**新建的框 → id 来自 generateAnnotationId() → hd("obj")
→ crypto.randomUUID() 去横线(32 位十六进制),不带对象号
所以「拼 id」在任何路径上都不可靠(v2.9.16 曾据此断言 id 里没有对象号,
导致 selectIds 全查空)。
⇒ 反查一律走 meta.tracking_id 全表扫描(下面的实现),不拼字符串。 */
function shapeIdByTrackingId(st, tid) {
try {
var s = st.scene && st.scene.annotationStore;
if (!s || typeof s.getAll !== "function") return null;
var all = s.getAll();
for (var i = 0; i < all.length; i++) {
if (all[i].meta && all[i].meta.tracking_id === tid) return all[i].id;
}
} catch (e) { }
return null;
}
/* shape id → tracking_id(也走 meta,不再对 id 做正则取数字) */
function trackingIdOfShape(ent) {
if (!ent) return null;
if (ent.meta && ent.meta.tracking_id !== undefined && ent.meta.tracking_id !== null) {
return ent.meta.tracking_id;
}
return null;
}
/* 兼容旧名:以前是靠正则从 shape id 里抠数字,现在统一走 meta.tracking_id */
function shapeEntryId(ent) { return trackingIdOfShape(ent); }
/* 选中指定对象(平台粘贴后会取消选中,这里恢复)。
⚠ 必须走 store 的方法,不能直接改 _selectedIds 这个 Set ——
直接改只是「伪选中」:3D 框不变绿、左侧属性面板也不刷新
(用户反馈「必须再点一下才正常」)。replaceWith 会走完整选中流程:
通知订阅者、更新高亮、刷新属性面板。
⚠⚠ v2.9.16:传入的 ids 是 **tracking_id**,必须先经 shapeIdByTrackingId
换算成平台真实的 shape id —— 不能拼 "ado-"+tid(见该函数上方说明)。 */
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++) {
var sid = shapeIdByTrackingId(st, ids[i]);
if (sid !== null && sid !== undefined) full.push(sid);
}
/* 一个 shape 都没找到就**别写空选择** —— 传空数组给 replaceWith
会把用户现有的选中也一起清掉。宁可什么都不做。 */
if (!full.length) return false;
if (typeof ss.replaceWith === "function") { ss.replaceWith(full); return true; }
if (typeof ss.select === "function") {
if (full.length === 1) { ss.select(full[0]); return true; }
if (typeof ss.addToSelection === "function") { ss.replaceWith([]); ss.addToSelection(full); return true; }
ss.select(full[0]); 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({ reason: "data", dirtyIds: full });
}
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。否则它们既不在数据里、也没有属性,
用户看到的就是「框在那儿、但没选中、也没属性」。 */
/* ⚠ 只捞「从来没见过的」孤儿 shape。
曾经见过、但这一轮已经从数据层消失的 ID,说明用户是**主动删掉了它**
—— 此时场景 shape 可能还没跟着销毁(平台是异步清理),
我们要是照着 shape.meta 把它补回 object_list,就成了「删了又自己长回来」。
实测踩到过:调 replaceCurrentObjects([]) 清空本帧,下一轮 poller
又把 16 个对象全捞回来了。 */
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;
if (_pasteIds && _pasteIds[sid]) continue; /* 见过 → 是删除残留,不捞 */
if (_seenObjs[sid]) continue; /* 同上:认识的 ID 一律不捞 */
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);
/* ⚠⚠ v2.9.17【灰点真根因】本轮新增的框(新建/粘贴)→ 立刻对齐 interfaceId。
平台重建 shape 用 interfaceId = Ud(meta.category):category 不在
Ni.definitions 里就退成 OBJECT(#94a3b8 灰)。而 update() 只改 meta 不改
interfaceId ⇒ 光靠前面几版补 meta.category 是永远不会变绿的。
这里在「确实出现了新对象」的时机做一次,覆盖平台原生粘贴(不走 smartCopyPrev)。 */
try {
var repairCat = Object.create(null);
for (fi = 0; fi < objs.length; fi++) {
repairCat[objs[fi].tracking_id] = objs[fi].category || "VEHICLE";
}
for (fi = 0; fi < fresh.length; fi++) {
patchShapeAttrs(main, fresh[fi], repairCat[fresh[fi]] ? (function () {
for (var q = 0; q < objs.length; q++) if (objs[q].tracking_id === fresh[fi]) return objs[q].sub_class;
return null;
})() : null, repairCat[fresh[fi]]);
}
repairShapeInterfaces(st.scene, fresh);
} catch (e) { }
}
/* 开关关掉时只更新快照、不做补全 ——
否则重新打开会把这一整轮的对象都当成「新增」,误补一遍。 */
if (!AUTO_PASTE) { _pasteIds = curIds; rememberObjs(objs); return 0; }
/* ⚠ 两条需求必须分开,不能共用 isAttrMissing 这一个闸门(用户第二次反馈的 bug):
· need —— 「属性缺失」的对象,去别的帧/别的框找源把类型+cameras 补全;
· needReval —— 「本轮新增」的对象,**无论属性是否完整**都要按当前帧位姿重判摄像头。
平台的 Ctrl+C/V 是「属性一并带过来」的:粘出来的 668 有 sub_class=suv、
category=VEHICLE、truncation=0、occlusion=0 —— 一条 null 都没有,
isAttrMissing(#668) 返回 false → 旧逻辑直接把它排除在 need 之外,
结果补全分支不跑、重判也就跟着不跑,前视那个残留的 2D 框照样留着、质检照样报错。
(只有插件自己的「复制上帧」按钮走 smartCopyPrev 里那次调用,
用户用平台原生粘贴时完全不走那条路。) */
var byKey = Object.create(null), need = [], needReval = [];
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]) continue; /* 老对象不动 */
needReval.push(o); /* 新增 → 一律重判摄像头 */
if (isAttrMissing(o)) need.push(o); /* 属性缺 → 额外去补类型 */
}
_pasteIds = curIds;
/* ── 新增对象:先按当前帧位姿重判 cameras(几何判据,不看属性完整性) ──
⚠ 必须在补全分支之前跑:补全分支若把源帧 cameras 原样贴过来,
又会带进一个陈旧的 truncation=0。
⚠⚠ v2.9.14 修回归:这里**绝对不能**再用
replaceCurrentObjects(objs) + store.clear() + store.add(...)
那是「整表覆盖 + 清空重建 shape」,会导致
「粘贴后闪一下 → 3D 框回到被复制框的位置完全重合」:
· objs 是本轮开头读到的快照,而平台粘贴时会自己把新对象摆到**鼠标投射位置**。
我们用「粘贴前/摆放前」的快照覆盖回去 = 位置回退。
· store.clear() + add() 会用我们拼的 points 重建 shape,而
`cornersOf(loc, 四元数, L, W, H)` 与平台的角点口径不一致
(实测:cornersOf 的 z 是「居中」0 基准,平台/客户工具是「底面」0→height,
同一物体两者 z 相差一个 height,本场景 1.6m)→ 重建出来必然错位。
正确做法:**只改 cameras 字段,一根毫毛都不碰 location / points / shape**。
走 patchObjectCamera —— 和 sweepCameras 完全一致(那里已经验证过是好的)。
shape.meta 的同步也改成「只 update 已有 shape」,不再 clear 重建。 */
var nCamFixed = 0, camFixDetail = [];
if (needReval.length) {
try {
var cmA = Object.create(null);
((main.calibration && main.calibration.cameras) || []).forEach(function (X) { cmA[X.name] = X; });
var patches = []; /* [{tid, camName, patch}] 精确到「对象+视角」 */
for (i = 0; i < needReval.length; i++) {
var tgtR = needReval[i];
var rvA = revalidatePastedCameras(tgtR, cmA);
if (!rvA.n) continue;
nCamFixed++;
if (rvA.detail.length) camFixDetail.push("#" + tgtR.tracking_id + " " + rvA.detail.join("/"));
/* 逐视角生成 patch:值已在 revalidatePastedCameras 里就地改进 cameras 了 */
for (var rci = 0; rci < (tgtR.cameras || []).length; rci++) {
var rCam = tgtR.cameras[rci];
patches.push({
tid: tgtR.tracking_id,
camName: rCam.name,
patch: {
bbox: rCam.bbox,
is_decoupled_bounding_box: false,
is_hidden_forcibly: false,
truncation: rCam.truncation,
occlusion: rCam.occlusion
}
});
}
}
if (patches.length) {
/* ① 改 object_list:走平台官方 action(= R 键同通道),精确到单对象单视角 */
for (i = 0; i < patches.length; i++) {
try {
main.patchObjectCamera(patches[i].tid, patches[i].camName, patches[i].patch);
/* ⭐ 粘贴时的这次重判是「一次性纠错」——写完即定型,
之后 sweepCameras 不会再碰这个视角,更不会跟用户手改打架。 */
markSettled(patches[i].tid, patches[i].camName);
} catch (e) { }
}
/* ② 同步 shape.meta —— 只 update 已存在的 shape,不 clear、不重建、不碰 points。
平台数据流双向,不改这边会被反解覆盖;但重建 shape 会破坏位置。 */
try {
var scA = st.scene;
if (scA && scA.annotationStore) {
var stA = scA.annotationStore;
var allA = stA.getAll();
for (i = 0; i < needReval.length; i++) {
var oA = needReval[i], shA = null;
for (var sj = 0; sj < allA.length; sj++) {
if (allA[sj].meta && allA[sj].meta.tracking_id === oA.tracking_id) { shA = allA[sj]; break; }
}
if (!shA) continue;
/* 只替换 meta 里的 cameras,其余字段(尤其 points / location)原样保留 */
var newMeta = JSON.parse(JSON.stringify(shA.meta || oA));
newMeta.cameras = JSON.parse(JSON.stringify(oA.cameras || []));
stA.update(shA.id, { meta: newMeta });
}
}
} catch (e) { }
}
} catch (e) { }
if (camFixDetail.length) tipline("粘贴重判摄像头:" + camFixDetail.join(";"), "gray");
}
if (!need.length) {
if (fresh.length) refreshSelectionUI(st, fresh);
rememberObjs(objs);
return nCamFixed;
}
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;
/* ⚠ v2.9.14:**就地在 tgt 上补属性**,不再 JSON 克隆成 cp 再整表覆盖。
克隆 + objs[idx] = cp 会连带把位置字段一起写回(而且 objs 是旧快照),
这正是「粘贴后回到源位置重合」的元凶之一。
现在只动属性字段,位置交给平台自己管。 */
tgt.category = best.category;
tgt.sub_class = best.sub_class;
tgt.cameras = JSON.parse(JSON.stringify(best.cameras || []));
/* ⚠ 补全来的 cameras 取自「别的帧的同一个对象」,位姿已经不同 ——
必须按当前帧 tgt 的位姿重判一次,否则会把源帧的 truncation=0
带到物体已跑到相机后方的帧上(质检报「角点在后方,截断却填 0」)。
上面的 needReval 分支已按当前帧 objs 里的 tgt 判过一轮,但那里判的是
tgt 的旧 cameras;这里 cameras 换成新的一份,必须再判一次。 */
try {
var cm2 = Object.create(null);
((st.main.calibration && st.main.calibration.cameras) || []).forEach(function (X) {
cm2[X.name] = X;
});
revalidatePastedCameras(tgt, cm2);
} catch (e) { }
tgt.__done = true; /* 标记:这个对象本轮真的被补过属性 */
n++;
}
if (n) {
/* ⚠⚠ v2.9.14 修回归:这里原本是 replaceCurrentObjects(objs) +
store.clear() + store.add(...),即「整表覆盖 + 清空重建 shape」。
它会:
① 用 400ms 前的快照覆盖平台刚摆好的位置(平台粘贴会把对象落到鼠标投射点);
② 用 cornersOf(四元数, z居中) 拼的 points 重建 shape,
与平台/客户工具的口径(欧拉角, z 从 0 到 height)不一致 → 错位。
表现就是「粘贴后闪一下 → 3D 框回到被复制框的位置完全重合」。
现在只改「补全出来的属性字段」(category / sub_class / cameras),
位置与 points 一律不碰。走 patchObjectCamera(对象+视角级精确改写),
shape.meta 也只 update 对应字段,绝不 clear 重建。 */
for (i = 0; i < need.length; i++) {
var tgtC = need[i], idxC = objs.indexOf(tgtC);
if (idxC < 0 || !tgtC.__done) continue;
/* 逐视角写 cameras 的五个字段(= R 键同通道) */
for (var cci = 0; cci < (tgtC.cameras || []).length; cci++) {
var cc = tgtC.cameras[cci];
try {
main.patchObjectCamera(tgtC.tracking_id, cc.name, {
bbox: cc.bbox,
is_decoupled_bounding_box: !!cc.is_decoupled_bounding_box,
is_hidden_forcibly: !!cc.is_hidden_forcibly,
truncation: cc.truncation,
occlusion: cc.occlusion
});
} catch (e) { }
}
}
/* 同步 shape.meta 的属性部分(category / sub_class),只 update 不重建 */
try {
var scene = st.scene;
if (scene && scene.annotationStore) {
var store = scene.annotationStore;
var allC = store.getAll();
for (i = 0; i < need.length; i++) {
var oC = need[i];
if (!oC.__done) continue;
var shC = null;
for (var sk = 0; sk < allC.length; sk++) {
if (allC[sk].meta && allC[sk].meta.tracking_id === oC.tracking_id) { shC = allC[sk]; break; }
}
if (!shC) continue;
var metaC = JSON.parse(JSON.stringify(shC.meta || oC));
metaC.category = oC.category;
metaC.sub_class = oC.sub_class;
metaC.cameras = JSON.parse(JSON.stringify(oC.cameras || []));
store.update(shC.id, { meta: metaC });
}
}
} catch (e) { }
}
/* ⚠ 恢复选中放在写入之后,并带延迟:平台收到新数据会重建 shape 并清空选中,
紧接着调 selectIds 会被这次重渲染冲掉(实测 sel 变成空数组)。 */
if (fresh.length) refreshSelectionUI(st, fresh);
rememberObjs(objs); /* 补全后的对象也记下来(含刚粘贴的那个) */
if (n) tipline("粘贴补全 " + n + " 个对象的类型/属性" + (nCamFixed ? ",重判摄像头 " + nCamFixed + " 个" : ""), "gray");
return n + nCamFixed;
}
/* ======================== 持续校验:截断与实际投影不符就纠正 ========================
为什么不能只在「本轮新增」那一刻判一次(用户第三次反馈的真正原因):
用户的实际操作顺序是「① 粘贴 → ② 把框拖到雷达车后面 → ③ 质检」。
第 ① 步粘贴时,框还落在源帧那个位置(车前方),这时重判会得出
「一切正常、不用改」;第 ② 步拖到车后是**之后**才发生的,
而那时已经没有任何东西会再触发一次重判 —— 前视残留的 2D 框就留下来了。
我们只盯着「新增」这个瞬间,等于把「拖动」这一步完全漏掉了。
做法:每轮轮询扫一遍当前帧所有对象,用同一套纯几何判据
(revalidatePastedCameras)检查每个视角的 truncation 是否与当前位姿相符,
不符就改。判据是幂等的 —— 已经正确的不会被反复写。
防打架:v2.9.15 起改成**「定型即止」**机制,不再靠时间冷却硬扛。
⚠⚠ 为什么必须改(老大实测反馈):
原来的 CLASH_COOL = 3 秒冷却只在「属性快填弹窗点确定」时触发。
但用户是**直接在平台界面上改**前视截断的 —— 这条路我压根没监听。
于是:用户改成 2 → 3 秒后 sweep 按几何算出来该是 0 → 改回去 →
用户再改 → 又被改回去。表现就是「前视截断一直是 2,我改完自动又跳回去」
以及「摄像头属性数字乱跳」。
根因不是冷却时间长短,而是**设计上就跟用户抢控制权**:
一个字段只要被人为设定过(用户手改,或我们粘贴时按几何修过一次),
它的「意图」就已经明确了,任何自动逻辑都不该再动它。
做法(两道闸):
① 永久锁 `_settled`:对象+视角 → true。一旦某个视角的 tc/oc 被人为设定过,
从此 sweepCameras 永不改它(粘贴那一刻的重判只算一次,锁上之后就不再重复纠正)。
② 平台原生输入监听:监听属性面板的 input/change 事件,
用户在平台界面上直接改数值也会被识别 → 记锁 + 开冷却。
效果:插件只在「刚粘贴、还没被人碰过」的窗口内自动纠错一次;
只要你碰过那个字段,它就归你,插件再也不会跳回去。 */
var CLASH_COOL = 1500; /* 只用于「刚锁上后短暂让一让」,不再是主要防线 */
var _userEditedAt = Object.create(null); /* tracking_id → 时间戳 */
/* ⭐ 永久锁:tracking_id + "|" + camName → true
表示「这个对象的这个视角,属性已被人为设定,禁止自动修改」。 */
var _settled = Object.create(null);
function settleKey(tid, camName) { return tid + "|" + camName; }
function markSettled(tid, camName) {
if (tid === null || tid === undefined || !camName) return;
_settled[settleKey(tid, camName)] = true;
}
function isSettled(tid, camName) { return _settled[settleKey(tid, camName)] === true; }
/* 在用户主动改属性 / 拖动之后调用,给该对象开一个冷却窗口,
避免我们下一轮轮询立刻把手填的值改掉。 */
function markUserEdit(id) {
if (id !== null && id !== undefined) _userEditedAt[id] = Date.now();
}
var _lastSweepSig = "";
/* 签名:位姿 + 各视角截断 + 各视角的投影绑定标记。
⚠ v2.9.12 起必须把 is_decoupled_bounding_box / is_hidden_forcibly 也纳入:
平台的 dd() 判定 2D 框是否绘制,看的是这两个标记而不是 truncation。
只签 truncation 的话,「dec 被平台重新设成 true、但 truncation 没变」
这种状态变化不会让签名变化 → 我们这轮直接跳过 → 残留框永远修不掉。
位姿变了或上述任一标记变了才需要真扫,否则每 400ms 白跑一遍投影。 */
function frameSweepSig(objs) {
var parts = [];
for (var i = 0; i < objs.length; i++) {
var o = objs[i], lo = (o.pose_in_lidar && o.pose_in_lidar.location) || [0, 0, 0];
var s = o.tracking_id + ":" + (+lo[0]).toFixed(2) + "," + (+lo[1]).toFixed(2) +
"," + (+(o.pose_in_lidar && o.pose_in_lidar.orientation ? o.pose_in_lidar.orientation[3] : 1)).toFixed(3) +
"," + o.length + "," + o.width + "," + o.height;
var cs = o.cameras || [];
for (var j = 0; j < cs.length; j++) {
s += "," + cs[j].name + cs[j].truncation + cs[j].occlusion +
(cs[j].is_decoupled_bounding_box ? "D" : "") +
(cs[j].is_hidden_forcibly ? "H" : "") +
(cs[j].bbox === null || cs[j].bbox === undefined ? "0" : "1");
}
parts.push(s);
}
return parts.join("|");
}
function sweepCameras(st) {
var main = st.main;
if (!main || !main.replaceCurrentObjects) return 0;
var objs = ((main.annotation && main.annotation.object_list) || []).slice();
if (!objs.length) return 0;
var sig = frameSweepSig(objs);
if (sig === _lastSweepSig) return 0; /* 没变化,不重复算 */
_lastSweepSig = sig;
var calibMap = Object.create(null);
var cams = (main.calibration && main.calibration.cameras) || [];
if (!cams.length) return 0;
for (var i = 0; i < cams.length; i++) calibMap[cams[i].name] = cams[i];
var now = Date.now(), fixed = 0, detail = [], changed = false;
/* 待写回清单:tid → { camName: patch },最后统一走平台通道写入 */
var pending = [];
for (i = 0; i < objs.length; i++) {
var o = objs[i];
/* ① 用户刚手填过 → 这一轮别动它(短冷却,只是让一让) */
if (_userEditedAt[o.tracking_id] && now - _userEditedAt[o.tracking_id] < CLASH_COOL) continue;
/* 逐视角跑「重新贴合自动投影」,先在内存里算出要改什么。
⚠⭐ 已被人为设定过的视角**一律跳过** —— 这是 v2.9.15 的核心改动。
没有这一条,用户手改的截断会在 400ms 后被按几何算出来的值覆盖掉,
表现就是「改完自动又跳回去」+「数字乱跳」。 */
var any = false, whys = [];
for (var ci = 0; ci < (o.cameras || []).length; ci++) {
var cn = o.cameras[ci].name;
if (!calibMap[cn]) continue;
if (isSettled(o.tracking_id, cn)) continue; /* ⭐ 定型了,归用户 */
var rp = platformReproject(o, cn, calibMap[cn]);
if (rp.changed) { any = true; whys.push(cn + " " + rp.why); }
}
if (!any) continue;
fixed++; changed = true;
detail.push("#" + o.tracking_id + " " + whys.join(";"));
}
if (!changed) return 0;
/* ── 写入:走平台的 patchObjectCamera(= 用户按 R 键走的同一条通道)────────
⚠ 为什么不能再用 replaceCurrentObjects 整体覆盖:
前面几轮实测发现「写进去又被反解回来」,原因是平台的数据流是双向的
(object_list → shape.meta 渲染 effect、shape.meta → object_list 反解)。
而 patchObjectCamera 是平台的官方 action,内部同时做了:
· patchObjectCamera(改 object_list)
· history.push(可撤销)
之后我们再同步 shape.meta,两边就一致了。
另外 bbox / is_decoupled_bounding_box / is_hidden_forcibly 这些字段
在 URL 无 ?role= 时是放行的(见 platformReproject 上方对 OR() 的分析),
走 patchObjectCamera 才能一次性写全 —— 这正是 R 键能干成的原因。 */
var wrote = 0, failed = [];
for (i = 0; i < objs.length; i++) {
var ob = objs[i];
if (_userEditedAt[ob.tracking_id] && now - _userEditedAt[ob.tracking_id] < CLASH_COOL) continue;
for (var cj = 0; cj < (ob.cameras || []).length; cj++) {
var camObj = ob.cameras[cj];
if (!calibMap[camObj.name]) continue;
if (isSettled(ob.tracking_id, camObj.name)) continue; /* ⭐ 定型了,不写 */
/* 重新算一次(上面的 platformReproject 已经把值改进 camObj 了,
所以这里直接用 camObj 的当前值当 patch —— 幂等) */
var patch = {
bbox: camObj.bbox,
is_decoupled_bounding_box: false,
is_hidden_forcibly: false,
truncation: camObj.truncation,
occlusion: camObj.occlusion
};
try {
main.patchObjectCamera(ob.tracking_id, camObj.name, patch);
/* ⭐ 写成功即「定型」:这个视角的属性已经符合几何,之后不再重复纠正。
这样插件对同一个视角最多只干预一次,绝不会跟用户反复拉扯。
(用户之后手改也不会被覆盖,因为手改走 markSettled 再次上锁。) */
markSettled(ob.tracking_id, camObj.name);
wrote++;
} catch (e) { failed.push("#" + ob.tracking_id + "/" + camObj.name); }
}
}
/* 同步 shape.meta(平台双向数据流,不同步会被反解覆盖回旧值) */
try {
var scene2 = st.scene;
if (scene2 && scene2.annotationStore) {
var store2 = scene2.annotationStore;
var all2 = store2.getAll();
for (i = 0; i < objs.length; i++) {
var ob2 = objs[i];
var sh2 = null;
for (var si = 0; si < all2.length; si++) {
if (all2[si].meta && all2[si].meta.tracking_id === ob2.tracking_id) { sh2 = all2[si]; break; }
}
if (!sh2) continue;
store2.update(sh2.id, { meta: ob2 });
}
}
} catch (e) { }
/* 写入后签名会变,更新一下避免下一轮空跑 */
_lastSweepSig = frameSweepSig(objs);
if (detail.length) tipline("自动投影重绑:" + detail.join(";"), "gray");
return fixed;
}
/* 后台 watcher 心跳(WATCHDOG 定义见 startExemptWatcher 上方) */
function startPasteWatcher() {
/* 400ms:粘贴后要尽快补上,不然用户会看到类型空着 */
setInterval(function () {
WATCHDOG.pasteTick++;
try {
completePastedAttrs();
/* 补完之后再扫一遍「拖动导致截断过期」的情况。
注意:sweepCameras 内部会跳过所有「已定型」的视角,
所以它只会纠正从没被人碰过的字段,不会跟用户手改打架。 */
sweepCameras(findStores(true));
} catch (e) { }
}, 400);
}
/* ==================== 平台原生属性输入的监听(v2.9.15 新增)====================
⚠ 为什么需要它:
原来的冷却只挂在「属性快填弹窗点确定」上,用户**直接在平台界面上改**
截断/遮挡时完全不被识别 → 400ms 后被 sweepCameras 按几何改回去 →
表现就是「数字乱跳」「改完自动又跳回去」。
做法:在 document 上捕获 input/change 事件(capture 阶段,穿透 shadow DOM 之外
的普通输入框),辨认它是不是平台属性面板里的数值输入,
是的话就:① 该对象进入短冷却;② 尝试定位到具体视角并**永久定型**。
定位视角的方法:从输入框向上找最近的容器,在容器文本里找视角名关键词
(front / rear / left / right 及其中文名)。找不到就只开冷却、不定型 ——
保守处理,宁可少锁不可错锁。 */
function startPlatformInputWatcher() {
if (window.__adoFitInputHooked) return;
window.__adoFitInputHooked = true;
var CAM_WORDS = [
{ re: /front|前视|前方|前摄/i, name: "front" },
{ re: /rear|后视|后方|后摄/i, name: "rear" },
{ re: /left|左视|左侧|左摄/i, name: "left" },
{ re: /right|右视|右侧|右摄/i, name: "right" }
];
function onEdit(ev) {
var el = ev.target;
if (!el || !el.tagName) return;
if (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA" && el.tagName !== "SELECT") return;
var st, main, objs;
try {
st = findStores(true);
main = st.main;
if (!main) return;
objs = (main.annotation && main.annotation.object_list) || [];
} catch (e) { return; }
/* 先给「当前选中的所有对象」开冷却 —— 平台属性面板改的就是选中对象 */
var selIds = [];
try {
var ss = st.scene && st.scene.selectionStore;
if (ss) {
var sel = ss.getSelectedIds ? ss.getSelectedIds() : (ss.selectedIds || ss._selectedIds);
if (sel) { if (sel.forEach) sel.forEach(function (x) { selIds.push(x); }); else selIds = [].concat(sel); }
}
} catch (e) { }
/* 平台选中可能记的是 shape id,转成 tracking_id */
var tids = [];
try {
var allSh = st.scene.annotationStore.getAll();
for (var a = 0; a < allSh.length; a++) {
for (var b = 0; b < selIds.length; b++) {
if (allSh[a].id === selIds[b] && allSh[a].meta) tids.push(allSh[a].meta.tracking_id);
}
}
} catch (e) { }
/* 找视角名:从输入框往上找容器文字 */
var camName = null;
try {
var node = el, hops = 0;
while (node && hops < 6 && !camName) {
var txt = (node.textContent || "").slice(0, 400);
for (var w = 0; w < CAM_WORDS.length; w++) {
if (CAM_WORDS[w].re.test(txt)) { camName = CAM_WORDS[w].name; break; }
}
node = node.parentElement; hops++;
}
} catch (e) { }
for (var t = 0; t < tids.length; t++) {
markUserEdit(tids[t]);
if (camName) markSettled(tids[t], camName);
}
/* 万一没解析出选中对象,就"广谱"处理:把当前帧所有对象的该视角全部定型,
保证用户的手改一定不被覆盖(宁可多锁,不可让用户白改)。 */
if (!tids.length && camName) {
for (var q = 0; q < objs.length; q++) {
markUserEdit(objs[q].tracking_id);
markSettled(objs[q].tracking_id, camName);
}
}
}
document.addEventListener("input", onEdit, true);
document.addEventListener("change", onEdit, true);
}
/* 清空定型锁(用户在面板上点「重新自动校验」时用)*/
function clearSettled() {
_settled = Object.create(null);
_lastSweepSig = "";
return true;
}
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"],
["ado-fit-unlock", "恢复自动校验", "清空「定型锁」:让插件重新对全部视角做自动校验。\n平时不需要点 —— 你手改过的属性默认永久归你,插件不会覆盖;\n只有想让它重新按几何纠错时才用。", "#0f766e"]
]) +
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;
/* v2.9.15:清空定型锁 → 恢复自动校验。顺便清签名缓存,强制下一轮真扫。 */
p.querySelector("#ado-fit-unlock").onclick = function () {
clearSettled();
tipline("已恢复自动校验(定型锁已清空)", "gray");
};
/* ⚠ 必须包一层:直接把 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 = {
/* 运行时版本号:在控制台敲 adoFit.version 就能确认当前生效的是哪一版,
排查「我明明更新了脚本怎么还这样」时最省事。 */
version: "2.9.17",
/* ⚠⚠ v2.9.17【灰点真根因】顶点颜色 = shape.interfaceId → engine.getInterfaceConfig(iid).color
排查「框的点是灰的」时,先跑这个:
adoFit.diagInterfaceColors()
→ 打印每个 shape 的 {sid, interfaceId, meta.category, 解析出的颜色}
正常:(VEHICLE, VEHICLE, #22c55e);异常:(OBJECT, VEHICLE, #94a3b8 灰)
adoFit.repairInterfaces()
→ 幂等修复:把 interfaceId 与 meta.category 不一致的全对齐,返回修了几个 */
diagInterfaceColors: diagInterfaceColors,
repairInterfaces: function () {
var st = null;
try { st = findStores(false); } catch (e) { st = null; }
if (!st || !st.scene) return -1;
var n = repairShapeInterfaces(st.scene, null);
try { st.scene.onDirty && st.scene.onDirty({ reason: "data" }); } catch (e) { }
return n;
},
/* 解析 category → interfaceId(复刻平台 Ud(),用 engine.interfaces 实测表) */
resolveInterfaceId: function (cat) {
var st = null;
try { st = findStores(false); } catch (e) { st = null; }
return resolveInterfaceId(st && st.scene, cat, st && st.main);
},
/* 后台 watcher 心跳,排查「粘贴没反应」时看这两个数涨不涨 */
watchdog: WATCHDOG,
/* 手动触发一次全帧截断校验(等价于 watcher 每轮做的事) */
sweepCameras: sweepCameras,
markUserEdit: markUserEdit,
/* v2.9.15:定型锁 —— 某对象的某视角被人为设定过就永不再自动修改。
排查「截断改完又跳回去」时可用:
adoFit.isSettled(717,'front') → true 表示该视角已归用户,插件不再动
adoFit.clearSettled() → 清空所有锁,恢复自动校验 */
markSettled: markSettled,
isSettled: isSettled,
clearSettled: clearSettled,
settledCount: function () { return Object.keys(_settled).length; },
/* v2.9.14:复刻平台 R 键(重新贴合自动投影)+ 质检兜底。
单对象单视角调用;排查「残留 2D 框」时可手工跑:
var s = adoFast.state(); adoFit.platformReproject(s.annotation.object_list[0], 'front', s.calibration.cameras[0]).why */
platformReproject: platformReproject,
projectBboxOf: projectBboxOf,
revalidatePastedCameras: revalidatePastedCameras,
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,
revalidatePastedCameras: revalidatePastedCameras,
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 内自动建框)/ 改属性(跨帧批量改遮挡截断)。");
}
/* ⚠ 两个后台 watcher 必须**无条件**启动,不能只挂在 else 分支里。
踩过的坑(用户第三次反馈的直接原因):
readyState === "loading" 时只绑了 buildUI,startPasteWatcher /
startExemptWatcher 全都没跑 —— 面板照常显示(buildUI 跑了),
但粘贴后「自动重判摄像头」「自动勾选尺寸异常」永远不触发。
面板看着一切正常,实际后台是死的,极难排查。
ScriptCat 的 @run-at document-idle 在页面快照恢复 / 脚本热重载时
仍可能赶上 loading,所以这条分支是真实可达的。 */
function boot() {
buildUI();
startExemptWatcher(); /* 自动勾选尺寸异常(「上下限例外」) */
startPlatformInputWatcher(); /* v2.9.15:监听平台原生属性输入 → 定型锁(防跳回去) */
startPasteWatcher(); /* 粘贴出来的对象自动补全类型与摄像头属性 + 按新位姿重判 */
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();