OdmOrthoPhoto是一个正射影像生成器,用于从带纹理的3D网格模型(OBJ格式)生成正射校正的航拍图像。这是无人机摄影测量后端处理的关键组件,属于ODM项目的一部分。
1、正射影像生成器相关
1.1 关键技术栈
| 技术 | 用途 |
|---|---|
| Eigen | 3D 仿射变换、向量运算 |
| OpenCV | 纹理读取、Inpaint 修复、图像处理 |
| GDAL | GeoTIFF 输出、空间参考、地理编码 |
| OpenMP | Inpaint 并行加速 |
1.2 输入输出
输入:OBJ 网格文件 + 纹理图片 (PNG)
输出:GeoTIFF 正射影像 + Alpha 通道
1.3 工作流程
python
1. 参数解析 → 2. 网格加载 → 3. 边界计算 → 4. 纹理渲染 (光栅化)
→ 5. 边缘修复(Inpaint) → 6. GeoTIFF(输出 )
1.3.1 OBJ/MTL 文件解析 (loadObjFile)
支持解析的内容:
python
v - 顶点坐标 (x, y, z)
vt - 纹理坐标 (u, v)
f - 面(顶点/纹理索引)
usemtl - 材质引用
mtllib - MTL 材质库文件
数据结构 TextureMesh:
python
struct TextureMesh {
std::vector<PointXYZ> vertices; // 3D 顶点
std::vector<Tex2D> uvs; // UV 坐标
std::unordered_map<std::string, cv::Mat> materials; // 纹理图
std::unordered_map<std::string, std::vector<Face>> faces; // 面按材质分组
std::vector<std::string> materials_idx; // 材质名称列表
};
1.3.2 边界计算与坐标变换 (computeBoundsForModel, getROITransform)
边界计算: 遍历所有顶点,找出 X-Y 平面的最小/最大范围
pyhon
Point 1: (xMin, yMin)
Point 2: (xMin, yMax)
Point 3: (xMax, yMax)
Point 4: (xMax, yMin)
仿射变换矩阵:
T=[r00−xmin⋅r0−r0−ymin⋅r00100001],where r=resolution T = \begin{bmatrix} r & 0 & 0 & -x_{\min} \cdot r \\ 0 & -r & 0 & -y_{\min} \cdot r \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}, \quad \text{where } r = \text{resolution} T= r0000−r000010−xmin⋅r−ymin⋅r01 ,where r=resolution
其中,Y 轴镜像(便于从下到上渲染)
这个4×4 矩阵是3D 空间到 2D 图像空间的坐标变换,用于将世界坐标系中的网格顶点映射到正射影像的像素坐标。本质上就是对Y的值做了一个翻转,然后归一化坐标,并进行缩放,用这个仿射变换只是一种实现的形式。
1.3.3 纹理三角形光栅化 (drawTexturedTriangle)
核心问题
输入: 一个三角形,三个顶点各带一个纹理坐标 (UV)
python
v1 ──→ 贴图上的点 (0.2, 0.8)
v2 ──→ 贴图上的点 (0.5, 0.1)
v3 ──→ 贴图上的点 (0.9, 0.6)
任务: 把这个三角形"贴"到屏幕上,三角形内部每个像素应该显示贴图上的什么颜色?
屏幕上的三角形内部如果有几千个像素,每个像素该取贴图的哪个位置?
解决思路
通俗理解,想象三角形三个顶点是三个"灯",每个灯照亮三角形内部:
靠近 v1 的地方,v1 的"影响力"最大
靠近 v2 的地方,v2 的"影响力"最大
三个影响力的总和永远是 1
重心坐标 (l1, l2, l3) 就是这个"影响力权重",例如P 点的重心坐标 = (0.5, 0.3, 0.2),含义:v1 占 50%, v2 占 30%, v3 占 20%。
用重心坐标找纹理颜色
关键思路
如果 P 点在三角形中的权重是 (0.5, 0.3, 0.2),那么 P 点的纹理坐标也是三个顶点 UV 的同样比例混合。
python
P 的 U 坐标 = 0.5×u1 + 0.3×u2 + 0.2×u3
P 的 V 坐标 = 0.5×v1 + 0.3×v2 + 0.2×v3
然后拿着 (U, V) 去纹理图上采样颜色!
重心公式:
python
l1 = h1 / H1
l2 = h2 / H2
l3 = h3 / H3
其中, H1 是 v1 到对边的总高度, h1是P点到对边的高度。
注意在这个阶段的三角形是2D像素平面上的三角形,顶点是像素坐标(列,行),不同阶段的三角形是变化的:
| 阶段 | 三角形类型 | 坐标含义 | 示例 |
|---|---|---|---|
| OBJ 文件读取后 | 3D 空间三角形 | 顶点是世界坐标 (米) | v1: (17.05, -31.02, 340.06) , v2: (17.01, -30.09, 340.23),v3: (16.66, -30.87, 340.13) |
| 仿射变换后 | 2D 像素平面三角形 | 顶点是像素坐标 (列,行) | v1': (列=512, 行=1024, z=340.06),v2': (列=510, 行=998, z=340.23),v3': (列=498, 行=1020, z=340.13) |
| 光栅化时 | 2D 像素平面三角形 | 用像素坐标扫描,深度值 (z) 用于 Z-buffer | v1t: (0.123, 0.456),v2t: (0.234, 0.567),v3t: (0.345, 0.678) |
三角形的三个顶点有 UV 坐标,三角形覆盖区域内的所有像素,都要通过重心坐标插值,去纹理图上找对应的颜色。
如果纹理图不止一张怎么办
这正是 mtllib/usemtl 材质系统要解决的问题,当有多张纹理图时,数据结构如下:
python
mesh.faces = {
"material_0": [face1, face2, face3...], ← 用 texture0 的三角形
"material_1": [face4, face5, face6...], ← 用 texture1 的三角形
"material_2": [face7, face8, face9...], ← 用 texture2 的三角形
}
mesh.materials = {
"material_0": cv::Mat(texture0),
"material_1": cv::Mat(texture1),
"material_2": cv::Mat(texture2),
}
同一个三角型只能对应一个纹理图,当两个纹理图对应的三角形有重叠会根据视角远近覆盖,覆盖是像素级的,核心原则如下:
| 规则 | 说明 |
|---|---|
| 1. 一个三角形只能属于一个材质 | OBJ 文件中每个 f 面只能有一个 usemtl 引用 |
| 2. 重叠像素按深度决定谁显示 | Z-buffer 深度测试,近的覆盖远的 |
1.3.4 双线性纹理采样 (renderPixel)
这个算法发生在通过坐标去纹理图取颜色的阶段,出现的原因是计算出来坐标值不是整数,这个时候当然可以直接取整拿到坐标最接近的像素点颜色,但是好像这种做法有点糙。所以引入了双线性纹理采样。
python
计算出来的采样点坐标:(2.7, 3.4)
真实像素坐标:(2,3), (3,3)...
双线性纹理采样的核心思想就是让周围的4个像素投票,距离近的票权重越大。
例如:
python
最终颜色 = A 的颜色×0.42 + B 的颜色×0.28 + C 的颜色×0.18 + D 的颜色×0.12
距离越近权重越大,距离越远权重越小,有点像前面三角形求重心的方法。
1.3.5 多波段图像合成 (saveTIFF)
所谓的多波段实际就是把一幅图像分别差分成R、G、B、A4个单独的通道分开管理,每个通道可以单独分析处理。在渲染时当渲染了某个像素,填充了RGB后对应的R、G、B胶片的值就进行一次更新,同时A对应的位置加3,所有三角都渲染完成后,判断A中的值,数值<3设置成0(不透明),否则设置成255(透明)。最后把所有通道打包成一个 GeoTIFF 文件。
1.3.6 边缘修复 (inpaint)
边缘修复的场景是当有多个纹理需要拼接时,有时会在接缝出产生明显的边界,主要原因是不同纹理图的色调不一致。比如纹理1是在阳光充足时拍的,纹理2是在有云遮挡时拍的,这会导致最终的地图上有明显的分块。
出现接缝的主要原因如下:
python
不同纹理图色调不一致:光照变化
3D 模型投影变形:投影变形导致边缘像素不连续
深度突变:建筑物和地面这类深度突然发生变化的区域
核心思想:用周围的颜色"智能填充"有问题的区域
方法流程:
使用 OpenCV 的 Telea 算法修复接缝:
分块处理 - 将大图分为 1024x1024 的块(支持 OpenMP 并行)
计算深度梯度 - 检测深度突变区域
生成掩膜 - 梯度超过阈值的区域标记为需要修复
执行 Inpaint - 使用周围像素填充接缝
python
cv::inpaint(input, inpaintMask, output, 5, cv::INPAINT_TELEA);
注意这个修复方法也是有局限性的:
| Inpaint 能修复 | Inpaint 不能修复 |
|---|---|
| 有颜色但颜色不连续(接缝) | 根本没有颜色的区域(孔洞) |
| 纹理拼接处的色差 | 3D 重建缺失导致的空白 |
| 深度突变处的边缘 | Alpha 通道为 0 的区域 |
2、编译
python
WORKDIR /root/odm_orthophoto
COPY lib/odm_orthophoto /root/odm_orthophoto
RUN rm -rf build && mkdir -p build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release .. \
&& make -j$(nproc) \
&& make install
3、报段错误
3.1 错误原因
python
/usr/local/bin/odm_orthophoto -inputFiles ./odm_textured_model_geo.obj -resolution 25 -inpaintThreshold 0.05 2>&1
执行上面命令直接报了段错误,只能根据源码解决,经过调试发现/root/odm_orthophoto/src/OdmOrthoPhoto.cpp 文件中的 renderPixel 函数,缺少纹理边界检查,原代码在进行双线性插值时,需要访问 4 个像素:
- (top, left) - 左上
- (top, left + 1) - 右上
- (top + 1, left) - 左下
- (top + 1, left + 1) - 右下
但当 UV 坐标接近 1.0 时,top 可能等于 texture.rows - 1,此时 top + 1 就会越界访问,导致段错误。
3.2 修复方法
修复逻辑
将 left 和 top 钳制在有效范围内,确保 top + 1 和 left + 1 不会超出纹理尺寸:
如果 top = texture.rows - 2,则 top + 1 = texture.rows - 1(最后一个有效像素)
如果 top 原本超出范围,会被钳制到 texture.rows - 2
修改位置
OdmOrthoPhoto.cpp 第 961-965 行(在计算 left 和 top 之后)
修改内容
添加了 4 行边界钳制代码:
c++
// Boundary check: clamp to valid texture range to prevent segfault
// We need 2x2 pixels for bilinear interpolation, so check bounds accordingly
if (left < 0) left = 0;
if (top < 0) top = 0;
if (left + 1 >= texture.cols) left = texture.cols - 2;
if (top + 1 >= texture.rows) top = texture.rows - 2;
修改后已重新编译并测试通过,正射影像生成成功。