首页 / 资讯中心 / 文章详情

倾斜摄影OSGB转3D Tiles:坐标变换与LOD调优实战

倾斜摄影OSGB转3D Tiles:坐标变换与LOD调优实战 ★ FEATURED ARTICLE
简介这份工具包面向从事倾斜摄影三维建模、Web 三维发布与 GIS 可视化的技术人员用于把 OSGB 格式的倾斜摄影模型批量转换为可在 Cesium 等平台加载的 3dtile 瓦片解决 OSGB 数据难以直接在浏览器端高效渲染的问题。压缩包共 178 个文件、约 12.39MB主体为 1 个 exe 可执行程序并配套 89 个 dll 运行库与 35 个 gfs 配置模板另有 csv、wkt、xsd、xml 等坐标系与投影参数文件以及少量 dgn、dxf、svg、json、ini 等辅助资源覆盖转换过程中所需的投影定义与参数配置无需另行搭建编译环境。已有 580 人学习下载。使用者可借助目录内现成的配置与说明按目录结构准备 OSGB 数据后直接调用命令行完成转换并将生成的 3dtile 部署到 tomcat 中验证浏览效果适合需要快速打通倾斜摄影数据上桌与发布链路的中高级使用者参考。1. 拿到 38GB 倾斜摄影成果先想清楚 OSGB 转 3dtile 卡在哪空三跑完交付目录里躺着Data文件夹、一万七千多个.osgb分块外加一份metadata.xml——这是实景三维项目里很常见的现场。想在 Web 端把它立起来第一道坎就是OSGB 转 3dtileOSGB 是桌面渲染器认的场景图格式3D Tiles 才是浏览器和三维引擎认的瓦片规范。这个工具干的就是中间这段搬运活儿读目录层级、把每块 osgb 拆成 LOD 层级、转 glTF、包 b3dm 壳、拼出tileset.json。适合手里有倾斜摄影成果、要在 Web 端做展示和量测的从业者也适合想弄明白 LOD 与坐标变换究竟在哪一步翻车的人。2. OSGB 与 3D Tiles 之间差了什么目录、b3dm、ENU 变换2.1 目录名就是索引Tile_000_000 里藏着的三层信息ContextCapture 系其他建模软件输出结构大同小异导出的倾斜摄影成果通常长这样Data/ ├── metadata.xml ├── Tile_000_000/ │ ├── Tile_000_000.osgb │ └── ... ├── Tile_000_001/ └── Tile_001_000/目录名里的000_000是瓦片在平面网格里的行列号不是坐标值而是一个相对索引。真正的空间位置由metadata.xml里的SRS空间参考系和SRSOrigin局部坐标原点决定。SRSOrigin一般写成Easting,Northing,Height三个数是整份成果的局部原点在源投影坐标系下的坐标每个 osgb 内部的顶点坐标都是相对这个原点的偏移量。这一步决定了后面所有事情。转换器读目录不是随机遍历而是按行列号重建四叉树000_000是根000_001和001_000是它的邻居越往正方向走层级越细。有的产出用_L1、_L2后缀分文件有的把所有 LOD 塞进一个 osgb 的 paging 节点里——前者转换器按文件名切层后者得靠 OpenSceneGraph 的场景图遍历去抽。判断方法很简单看单个瓦片目录里是不是有多个同前缀文件有就是分文件型。还有一点容易忽略目录层级数和 LOD 层数不是一回事。一个Tile_003_005目录里可能只有 1 层几何也可能有 5 层。转换器要输出的是「每个 LOD 层一个 b3dm」而不是「每个目录一个 b3dm」。如果工具默认按目录出瓦片出来的tileset.json会缺失 LOD 梯度远处不会自动降级首屏直接拉满。2.2 b3dm 只是一层壳真正转的是里面的 glTF3D Tiles 的b3dmBatched 3D Model结构非常薄一个 28 字节的定长头加上 feature table、batch table后面直接拼接 glTF 二进制.glb的载荷。字段长度说明magic4 字节固定b3dmversion4 字节当前为 1byteLength4 字节整个 b3dm 文件的总字节数featureTableJSONByteLength4 字节要素表 JSON 长度featureTableBinaryByteLength4 字节要素表二进制长度batchTableJSONByteLength4 字节批处理表 JSON 长度batchTableBinaryByteLength4 字节批处理表二进制长度倾斜摄影模型没有单体化 ID所以BATCH_LENGTH通常写 0batch table 留空。真正吃工作量的是 glTF 转换OSGB 是 Z 轴向上Z-upglTF 2.0 规范要求 Y 轴向上Y-up转换器必须做一次(x, y, z) → (x, z, -y)的轴系翻转。这个翻转要么烘进顶点要么放在tileset.json根节点的transform矩阵里——两派做法都有人用但只能做一次做两次模型就躺平了这是后面避坑章要展开的第一个大坑。另外纹理也是个决策点。OSGB 里的贴图是外链 jpg转 glb 时要么内嵌到二进制里单文件体积涨 30% 左右但分发干净要么保持外链体积小但一个 b3dm 旁边得带一堆图路径一错就白模。做 Web 分发我一般选内嵌省掉路径穿越的麻烦。2.3 根节点 transform把局部 ENU 摆到 ECEF 上3D Tiles 的坐标系是地心地固系ECEF而 OSGB 的坐标是某个投影带下的平面坐标加高程。转换器要在tileset.json的root.transform里写一个 4×4 矩阵把「局部东-北-天ENU坐标」映射到 ECEF。这个矩阵的构造分两步先把原点经纬高转成 ECEF 的平移量再用原点的经度 λ、纬度 φ 构造三个轴向向量。东向单位向量 e (-sinλ, cosλ, 0)北向单位向量 n (-sinφ·cosλ, -sinφ·sinλ, cosφ)天向单位向量 u (cosφ·cosλ, cosφ·sinλ, sinφ)拼成矩阵后按列主序展开成 16 个浮点数——3D Tiles 规范要求transform数组是列主序的这一点写错的话模型要么转 90°要么飞到大西洋。import math A 6378137.0 # WGS84 长半轴 F 1 / 298.257223563 # WGS84 扁率 E2 2 * F - F * F # 第一偏心率平方 def lla_to_ecef(lon, lat, h): 经纬高度/米转 ECEF米 lon, lat math.radians(lon), math.radians(lat) n A / math.sqrt(1 - E2 * math.sin(lat) ** 2) # 卯酉圈曲率半径 x (n h) * math.cos(lat) * math.cos(lon) y (n h) * math.cos(lat) * math.sin(lon) z (n * (1 - E2) h) * math.sin(lat) return x, y, z def enu_transform(lon, lat, h): 生成列主序 4x4 矩阵局部 ENU - ECEF lon_r, lat_r math.radians(lon), math.radians(lat) sl, cl math.sin(lat_r), math.cos(lat_r) so, co math.sin(lon_r), math.cos(lon_r) east (-so, co, 0.0) north (-sl * co, -sl * so, cl) up ( cl * co, cl * so, sl) tx, ty, tz lla_to_ecef(lon, lat, h) # 列主序第 0~3 个元素是第一列依此类推 return [ east[0], east[1], east[2], 0.0, north[0], north[1], north[2], 0.0, up[0], up[1], up[2], 0.0, tx, ty, tz, 1.0, ] if __name__ __main__: m enu_transform(116.397, 39.909, 50.0) print([round(v, 6) for v in m])这段代码里三个向量的顺序不能换。改成east, up, north会得到另一个合法但语义不同的矩阵——如果你的 glTF 内容已经做过 Y-up 翻转那transform的第二列应该对应天向这样 Y-up 的「上」才落到真实的天顶方向。所以矩阵列序和内容轴系是一对耦合参数必须同时确认。lla_to_ecef里的h是椭球高不是正常高。有些成果的SRSOrigin写的是 85 高程直接当椭球高用在局部会有几十米的系统性偏差。这种偏差在单个瓦片上看不出来把模型和路网矢量叠一起时就非常刺眼。工程上要么拿到高程异常值做改正要么在引擎侧整体调一个偏移量别指望转换器自己猜。2.4 LOD 树怎么剪REPLACE 与 ADD 的取舍tileset.json里每个节点有refine属性两选一REPLACE子节点加载完成后替换父节点屏幕上的三角形总量保持稳定。倾斜摄影默认用这个。ADD子节点叠加在父节点之上总量累加。适合给粗模补细节的场景用在实景三维上会直接炸显存。还有geometricError它决定「什么时候切到下一层」。规范里的屏幕空间误差公式是SSE geometricError × 屏幕高度 / (距离 × 2 × tan(fov/2))引擎在SSE maximumScreenSpaceError时加载子节点。所以geometricError不是美术参数是几何尺寸量纲填错了要么远处一片糊要么近处疯狂请求。下一章会把倒推方法给全。3. 跑通一次转换从 Data 目录到能加载的 tileset.json3.1 开跑前的三件事运行环境、内存上限、路径规范转换过程本质是「解场景图 转几何 压纹理 打包二进制」是典型的 CPU 和内存双高任务。开工前先确认三样东西。第一是运行时版本。这类工具多数是 Node.js 写的底层调 OpenSceneGraph 的解析库或者纯 JS 解 osgbNode 16 以上基本都能跑但要注意 glb 打包用到的 Buffer API 在旧版本上行为不同能升就升到 LTS。第二是堆内存。默认 Node 堆上限只有 1.52GB一个 500MB 的 osgb 解出来的顶点数组加上纹理缓存很容易顶到天花板报JavaScript heap out of memory直接中断。启动时显式拉高# 把堆上限拉到 8GB适合 16GB 内存以上的工作站 node --max-old-space-size8192 ./bin/osgb2tiles.js --help--max-old-space-size的单位是 MB设成物理内存的 50%60% 比较稳留出页缓存给文件读写。第三是路径。源目录、输出目录、工具所在目录全部用纯英文加下划线不要有中文、空格、括号。这不是洁癖是血泪经验osgb 内部的纹理引用是相对路径某些解析库在拼路径时不做过 URI 编码一遇到中文字符就会拼出乱码路径表现为转换成功但模型全是白模。3.2 单块转换命令行参数逐个过一遍先拿一个瓦片目录做试跑别一上来就整包。参数名以你手上这包的--help为准下面这套是这类工具最常见的形态./bin/osgb2tiles \ --input /data/proj/Data \ --output /data/proj/tiles \ --srs EPSG:4547 \ --origin 500000,3300000,45.6 \ --max-lod 5 \ --texture-format jpg \ --texture-max-size 2048 \ --geometric-error 8 \ --threads 4 \ --log-level info逐个说清楚--input指向Data目录本身不是它的父目录。指向父目录时工具找不到metadata.xml会退回默认参数。--srs是源数据的投影坐标系。CGCS2000 三度带在 EPSG 里对应 44914512 区间六度带在 4491 之前的 4501 段附近具体带号对错了模型会整体平移几百公里。实在拿不准就从metadata.xml的SRS字段里抄。--origin覆盖metadata.xml里的SRSOrigin。什么时候用当元数据缺失、或者原点是 0,0,0 但顶点里已经烘了绝对坐标时需要手工指定。填的就是三个数东坐标、北坐标、椭球高。--max-lod限制抽几层。层级越深文件越多tileset.json的 JSON 体积会指数膨胀。5 层对城市规划级别的展示够用要看清建筑立面再往上加。--texture-format决定内嵌贴图格式。jpg最省体积有透明通道的树、栏杆会破图要保留透明就用png代价是包体翻倍。--texture-max-size是单张纹理的最大边长。原图常见 4096压到 2048 肉眼几乎看不出差别显存占用降到四分之一。--geometric-error是根节点的初始误差值子节点会按规则递减。下一章细说。--threads并发数。不是越大越好每个线程都有自己的解码缓冲48 是常见甜点。跑之前先加--dry-run如果有看一遍解析结果识别到多少瓦片、多少 LOD 层、原点坐标是多少。这一步的价值在于尽早发现原点解析错误而不是等到转换跑了四十分钟才发现模型在海上。3.3 产物冒烟校验先看头的 28 个字节转换完不要急着丢进引擎。用一段脚本把tileset.json和 b3dm 头扫一遍两分钟能省掉后面两小时的排查。import json, os, struct def check_b3dm(path): 校验 b3dm 头magic、版本、长度自洽、8 字节对齐 size os.path.getsize(path) with open(path, rb) as f: head f.read(28) if len(head) 28: return f{path}: 文件不足 28 字节 magic, ver, blen, ftj, ftb, btj, btb struct.unpack(4sIIIIII, head) if magic ! bb3dm: return f{path}: magic 错误 {magic!r} if ver ! 1: return f{path}: 版本号异常 {ver} if blen ! size: return f{path}: 头部声明 {blen} 与实际 {size} 不符 # 规范要求 feature table JSON 之后填充到 8 字节边界 if (28 ftj) % 8 ! 0: return f{path}: feature table 未按 8 字节对齐 return None def walk_tileset(root_dir): errors [] for dirpath, _, files in os.walk(root_dir): for fn in files: if fn.endswith(.b3dm): err check_b3dm(os.path.join(dirpath, fn)) if err: errors.append(err) return errors if __name__ __main__: ts tiles/tileset.json with open(ts, r, encodingutf-8) as f: data json.load(f) print(asset:, data.get(asset)) print(root geometricError:, data[root][geometricError]) print(root refine:, data[root].get(refine)) errs walk_tileset(tiles) print(f发现 {len(errs)} 个异常 b3dm) for e in errs[:10]: print( , e)blen ! size是最高频的失败信号。它意味着打包过程中文件被截断了——通常是磁盘写满或者转换进程被系统 OOM killer 干掉但没报错。(28 ftj) % 8不对则说明 feature table 的 padding 没做某些加载器会直接拒绝渲染表现为「模型加载 100% 但不显示」非常迷惑。3.4 挂到引擎里看一眼最小验证环境别等到集成到业务系统才验证。起个静态服务就能看# 在 tiles 目录的上一级启动注意不能直接 file:// 打开 python -m http.server 8080 --directory /data/proj然后在一个最小页面里加载tileset.json。三个必看的点模型是否落在正确的经纬度上。用引擎的相机飞到目标点看模型是不是在视野里。由远到近推镜头看有没有明显的「跳变」或者某一层突然消失。消失说明 LOD 链断了。打开浏览器的网络面板看 b3dm 请求数。如果首屏就拉了几百个请求说明geometricError给小了剪枝没起作用。4. 参数调优geometricError、纹理和并发到底怎么定4.1 geometricError 从屏幕误差倒推不要拍脑袋填很多人把根节点的geometricError填成场景半径结果远处不加载、近处过载。正确做法是从「希望在多远切换到下一层」倒推。上面那个 SSE 公式变形一下geometricError 目标切换距离 × maximumScreenSpaceError × 2 × tan(fov/2) / 屏幕高度假设屏幕高 1080px垂直视场角 60°tan30° ≈ 0.5774引擎的maximumScreenSpaceError设 16px希望 200 米处切到下一层geometricError 200 × 16 × 2 × 0.5774 / 1080 ≈ 3.42 米所以根节点的误差可以取约 3.4 米子节点按每层减半或按瓦片实际包围盒尺寸的 1/2递推。这个数如果直接填 100引擎会认为整块模型在 100 米外就是「足够精细」永远不请求子节点——表现就是无论怎么推镜头都是一坨粗模。反过来填 0.1 会让引擎在几公里外就开始请求最深层瓦片首屏几十秒白屏然后内存爆掉。geometricError 是唯一一个「填大了糊、填小了卡」的参数值得单独调一轮。调的时候有个技巧不要改tileset.json再重新转换直接编辑 JSON 里的数值刷新页面看效果定下来之后再回写到转换参数里。改 JSON 是秒级的重新转换是小时级的。4.2 纹理与网格压缩Draco 能省多少什么时候别用倾斜摄影的体积大头是纹理通常占 70%85%。所以第一刀砍纹理手段典型收益代价纹理边长 4096 → 2048体积 -60%70%近景文字模糊纹理转 jpg 质量 80体积 -20%30%无透明通道Draco 压缩几何几何部分 -50%80%解码耗时需加载器支持KTX2 Basis 压缩显存占用 -70%需 GPU 支持对应格式Draco 要谨慎。几何在倾斜摄影里本来占比就小大头是贴图压完收益有限但解码是同步的一次解码几百 KB 的几何在低端设备上会卡帧。真正需要压缩几何的场景是「城市级 移动端」普通 PC 端展示关掉它更省心。KTX2 是另一个思路它不减小文件体积太多但把纹理直接从压缩态送进 GPU显存占用直降。这个压缩对首屏帧率提升最明显代价是生成过程慢且部分老设备不支持。我的习惯是先不压跑通再看首屏内存超过 1.5GB 才上 KTX2。还有个容易踩的--texture-max-size压到 1024 以下时建筑檐口、广告牌这类细节会糊成一团验收时被挑刺。2048 是一个被反复验证过比较均衡的值。4.3 大场景拆块与并发把内存峰值压下来整包转换最常见的失败不是参数错是内存炸。几个降峰值的做法按优先级排先降并发。--threads从 8 降到 4内存峰值能降 40% 左右总耗时涨不到 30%。这笔买卖划算。再分批。按行列号把Data拆成几个子目录每个子目录单独转最后合并tileset.json。合并不是字符串拼接那么简单要把各子集的root.children挂到同一个根节点下并把boundingVolume重算成并集——包围盒算错的表现是模型在视野边缘被错误裁剪。最后才是加机器。32GB 内存的机器跑城市级2050GB 原始数据是底线16GB 会很痛苦。# 分批转换按行号切分每批独立进程串行执行避免内存叠加 for row in 000 001 002; do ./bin/osgb2tiles \ --input /data/proj/Data/Tile_${row}_* \ --output /data/proj/tiles_part${row} \ --srs EPSG:4547 \ --max-lod 5 \ --threads 4 \ --log-level info done注意这里是串行for循环不是后台并发。并发跑三个进程内存是叠加的等于把--threads设成 12反而更容易炸。要并行就得分机器一台机器上分进程并行在这个场景里没有收益。5. 常见问题排查五个真正会让人加班的坑5.1 模型整体偏移几百米或者埋在土里现象加载出来模型在正确位置附近但整体平移了一个固定距离或者高程差了几十米和底图矢量对不上。原因有三层按概率排一是SRSOrigin里的高程是正常高被当成椭球高用了差一个高程异常值二是--srs的带号选错平面坐标解算到了相邻投影带三是transform矩阵和 glTF 内容里的 Y-up 翻转重复应用了一次。解决先用 2.3 节的脚本把metadata.xml里的SRSOrigin转成 ECEF和引擎里模型的实际位置比对差值如果是常数就是原点问题如果是旋转关系就是矩阵问题。高程差在几十米量级的去查高程异常改正差在几米量级的多半是原点精度不够考虑用控制点重新解算。5.2 一片白模或者一片黑模现象模型轮廓正常但完全没有贴图或者贴图全黑。原因白模是纹理没找到。内嵌模式下这不该发生一旦发生说明转换时用了外链而 b3dm 里的相对路径和实际目录结构对不上——常见于源目录有中文或空格路径拼接时被截断。黑模则是法线方向反了光照算出来全是背光面或者用了不支持的光照模型。解决白模先看 b3dm 同目录下有没有textures/文件夹再看 glb 内部的images引用是 uri 还是 bufferView。黑模检查两点转换参数里有没有「翻转法线」选项试一下以及引擎里的光照方向实景三维一般用无光照的 unlit 材质误开了 PBR 光照就会发黑。5.3 首屏白屏十几秒然后内存爆掉现象页面打开后长时间空白网络面板里几百个 b3dm 请求排队最后浏览器标签页崩溃。原因geometricError太小剪枝失效加上maximumScreenSpaceError设得过小常见于抄来的配置里写 1 或 2。两者叠加引擎会尝试把整个场景的最高精度层全拉下来。解决先把maximumScreenSpaceError调到 16再按 4.1 节倒推geometricError。改完刷新请求数应该从几百降到十几个。如果还不行检查tileset.json的层级深度——超过 8 层的树在浏览器里本身就很难剪枝得考虑在转换阶段合并层级。5.4 中文路径、超长文件名、单文件超限现象转换中途报 ENOENT或者转换成功但部分瓦片缺失。原因Windows 下路径长度上限 260 字符Data/Tile_000_000/Tile_000_000_L3.osgb这种长名字叠加深层目录很容易超限。中文路径则是解码问题OSGB 内部字符串多为本地编码。解决转换前把整个工程挪到盘符根目录下的短英文路径比如D:\osgb\。这一步花五分钟能省掉后面所有的玄学问题。5.5 转换进程静默退出日志停在某个瓦片现象没有任何报错进程消失输出目录里最后一个 b3dm 只有几十字节。原因系统 OOM killer 把进程杀了Node 来不及打印错误。Linux 下dmesg | tail能看到Out of memory: Killed process。Windows 下则可能是杀毒软件扫描大文件时锁住了句柄。解决降--threads、分批、加--max-old-space-size。另外给转换脚本加一层「断点续跑」——转换前检查输出目录里已有的 b3dm跳过已完成的瓦片。这个后悔药值得提前准备。6. 批量自动化与验收把一次转换固化成流水线单次转换跑通只是开始真实项目里这套东西一周要跑七八次。我的做法是把「转换 校验 报告」写成一个脚本输入是Data目录输出是一份能贴进验收单的检查结果。import json, os, subprocess, sys from concurrent.futures import ThreadPoolExecutor def convert(part_dir, out_dir, srs): cmd [./bin/osgb2tiles, --input, part_dir, --output, out_dir, --srs, srs, --max-lod, 5, --texture-max-size, 2048, --threads, 4, --log-level, warn] r subprocess.run(cmd, capture_outputTrue, textTrue) return part_dir, r.returncode, r.stderr[-500:] def summarize(tileset_path): with open(tileset_path, encodingutf-8) as f: ts json.load(f) stats {nodes: 0, max_depth: 0, content: 0} def walk(node, depth): stats[nodes] 1 stats[max_depth] max(stats[max_depth], depth) if content in node: stats[content] 1 for c in node.get(children, []): walk(c, depth 1) walk(ts[root], 1) return stats if __name__ __main__: srs sys.argv[1] parts sys.argv[2:] with ThreadPoolExecutor(max_workers2) as pool: # 控制并发防内存叠加 for part, code, err in pool.map(lambda p: convert(p, p _tiles, srs), parts): print(f[{OK if code 0 else FAIL}] {part} {err if code else })这里的max_workers2是刻意的。并发数不是越高越好两个进程各自 4 线程内存峰值大约等于单进程的 1.8 倍比开四个进程安全得多。输出路径用part _tiles而不是统一目录是为了失败时能快速定位是哪个分片出的问题。跑完之后过一张验收表六项少一项都不签字检查项合格标准不通过的典型后果tileset.json 可解析JSON 结构完整asset.version 为 1.0引擎直接拒绝加载b3dm 头自洽byteLength 与文件实际大小一致部分瓦片静默丢失坐标落点与已知控制点偏差 1 米与业务数据套合错位LOD 层级数与源数据一致无断链近景糊或远景卡首屏请求数 20 个1080p默认视角白屏、崩溃纹理覆盖率抽查 10 个瓦片贴图无缺失白模观感直接崩最后一项我一般人工看。脚本能算贴图文件在不在但算不出「贴图贴歪了」——这种情况在转换参数里叫纹理坐标原点不一致只有肉眼能发现。这套流程跑了两年多最大的教训是别信「转换成功」这四个字。工具返回退出码 0 只代表进程没崩不代表产物能用。我现在每次转换完先跑 b3dm 头校验再开页面推一遍镜头两件事做完才敢往测试环境发。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站