ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MATLAB球面投影实现与坐标约定详解:从基础公式到全景图生成

MATLAB球面投影实现与坐标约定详解:从基础公式到全景图生成 1. 球面投影坐标体系MATLAB约定与数学约定之间的换算球面投影最容易翻车的地方从来不是投影公式本身而是MATLAB自带的坐标函数和你从教科书上抄来的公式默认使用的角度基准根本不是一个东西。你花半小时写完投影调用发现图像是倒的、位置差了半个球十有八九是这里出了问题。1.1 cart2sph/sph2cart的方位角与余纬陷阱MATLAB里有两个最基础的球坐标转换函数cart2sph和sph2cart。官方文档写得直白cart2sph(x,y,z)返回(az, el, r)其中az是方位角el是相对于x-y平面的仰角r是到原点的距离。注意el是仰角不是数学教材里那个从z轴正方向开始往下量的余纬。教科书里更常见的是这种写法x r * sin(theta) * cos(phi) y r * sin(theta) * sin(phi) z r * cos(theta)这里的theta是余纬从z轴算起phi是方位角。问题就来了MATLAB的el与教科书里的theta之间相差一个pi/2。如果你直接从cart2sph拿结果套进球面投影公式图像大概率是斜着转了一个直角。我习惯在代码入口统一做一次换算[az, el, r] cart2sph(x, y, z); theta pi/2 - el; % 数学约定中的余纬 phi az; % 方位角反推的时候用对称方式[x, y, z] sph2cart(phi, pi/2 - theta, r);这个习惯帮我避免了很多次坐标错乱。尤其当你从别人写的脚本里复制公式时先问一句这个theta到底是从北极往下量的余纬还是相对于赤道面的纬度不确认清楚后面全是白做。1.2 图像坐标与球面坐标的方向约定第二个容易踩的坑是图像坐标系。一张M x N的灰度图行方向通常对应图像从上到下列方向对应从左到右。但在数学计算里我们习惯把y轴朝上把x轴朝右。于是当你把球面坐标映射到图像像素坐标时如果不做一个y方向翻转输出的图会上下颠倒。很多全景图OpenCV代码里都有一句flipud或者cv2.flip来干这件事。MATLAB里同样需要但经常被忽略img flipud(imgTmp); % 因为图像v轴向下画图时v0在顶部另一个隐藏方向问题是角度零点的位置。球面上方位角phi 0通常对着x轴正方向但球面全景图的像素横坐标u 0可能对应经度-180度也可能对应0度。不同数据集的约定不一样读取数据之前必须看清楚头文件或标注否则拼出来的全景图左右镜像。1.3 用一段代码统一坐标约定为了不让这些零碎的约定散落在各处我在项目里会先写一个公共脚本把球面坐标的方向、范围、像素映射规则都规定清楚% 统一约定 % 球坐标系theta为余纬[0,pi]phi为方位角[-pi,pi] % 像素坐标系u为列坐标[0,W-1]v为行坐标[0,H-1] % 投影关系u对应经度v对应纬度零度经线指向图像中心列 w2lon (u, W) (u / (W-1)) * 2 * pi - pi; h2lat (v, H) pi/2 - (v / (H-1)) * pi; lon2w (lon, W) (lon pi) / (2 * pi) * (W-1); lat2h (lat, H) (pi/2 - lat) / pi * (H-1);把这类约定集中到一处比在每个函数里分散处理要稳妥得多。改起来也方便换数据集时只需调整这一个文件。2. 四类常用球面投影公式的MATLAB实现与选型投影不是一个万能公式走天下的操作。实际项目中不同数据格式、不同应用场景对投影方式的要求完全不同。这里我把最常用的四类投影逐一实现一遍并给出选型建议。2.1 等距圆柱投影Equirectangular全景图默认方案等距圆柱投影可能是球面全景领域出现频率最高的投影方式。它的核心思想是让经度线性对应图像的横坐标让纬度线性对应图像的纵坐标。公式非常简单function [u, v] equirect_proj(lon, lat, W, H) u (lon pi) / (2 * pi) * (W - 1); v (pi/2 - lat) / pi * (H - 1); end反投影同样简单function [lon, lat] equirect_inv(u, v, W, H) lon u / (W - 1) * 2 * pi - pi; lat pi/2 - v / (H - 1) * pi; end这个投影的好处是像素与角度均匀对应不引入额外的非线性拉伸所以很多全景相机直接输出等距圆柱投影格式比如常见的2:1全景图宽度是高度的两倍正好对应经度360度、纬度180度。坏处也很明显高纬度区域被横向拉伸。如果你拿一个满分辨率全景图去切北极附近的局部视角会发现地面细节被拉得很宽像被橡皮筋扯过。所以它适合展示和存储不适合做精确测量。2.2 透视投影相机成像模型与球面方向的互转如果球面投影指的是把球面点投到某个视平面那透视投影才是真正贴近相机成像的模型。针孔相机模型是最常见的透视投影形式function [u, v] perspective_proj(x, y, z, fx, fy, cx, cy) u fx .* x ./ z cx; v fy .* y ./ z cy; end这里的(x,y,z)是相机坐标系下的三维点。反过来如果知道图像坐标想求这个像素对应的球面方向向量做反投影function dir perspective_inv(u, v, fx, fy, cx, cy) x (u - cx) / fx; y (v - cy) / fy; z 1; dir [x, y, z]; dir dir / norm(dir); % 归一化到单位球面 end这一步在视觉定位、全景拼接里特别常用。相机像素坐标先反投影成相机坐标系下的方向再通过外参矩阵转到世界坐标系最后落到球面上就能和全景图对上了。需要注意z1的假设只对非零深度的方向有效。如果你处理的是深度图要拿真实深度值去替换z。另外fx、fy的单位是像素标定内参时如果焦距单位为毫米要先做像素尺度换算。2.3 墨卡托投影等角保形但纬度拉伸明显墨卡托投影是航海图里的经典方案核心特点是等角保形即地球表面任意两点的夹角投影到平面上后保持不变。公式为function [x, y] mercator_proj(lon, lat, R) x R * lon; y R * log(tan(pi/4 lat/2)); end用MATLAB写起来也不难但我实际用的时候被两个问题缠住过。第一纬度接近90度时tan(pi/4lat/2)会迅速趋向无穷大导致y坐标爆炸。所以墨卡托投影通常有纬度截断比如只做到正负85度。处理前要主动裁剪lat(lat 85 * pi/180) 85 * pi/180; lat(lat -85 * pi/180) -85 * pi/180;第二墨卡托的y坐标非线性但x坐标线性。如果拿它做距离测量除赤道附近外都会有较大误差。所以选型时先问自己想保留的是角度关系还是面积关系两者不能兼得。2.4 极射赤面投影适合单极点区域极射赤面投影也叫球极平面投影是一种把球面投影到与球在某一点相切的平面上的方式。通常选择北极点作为投影原点把整个南半球以外的区域映射到平面上。它的极坐标形式写起来很干净function [u, v] stereographic_proj(theta, phi, R) rho R * tan(theta / 2); u rho .* cos(phi); v rho .* sin(phi); end反投影function [theta, phi] stereographic_inv(u, v, R) rho sqrt(u.^2 v.^2); theta 2 * atan(rho / R); phi atan2(v, u); end这种投影在北半球高纬度地区的变形很小适合极区海冰、极光等局部场景的分析。但它有个明显毛病赤道附近的形变非常大南半球直接投到无穷远。所以如果你做的是全球数据它并不是一个理想的全域方案。2.5 几类投影的适用场景对比投影类型保角保面积高纬变典型场景等距圆柱否否横向拉伸全景图存储、全景视频透视投影近似局部近似边缘拉伸相机标定、视觉定位墨卡托是否纵向拉伸航海图、导航切片极射赤面是否低纬严重极区测绘、天文观测选型没有绝对正确核心是搞清楚你的下游任务关心什么。全景拼接更看重均匀采样选等距圆柱测角度关系选墨卡托或极射赤面要还原相机成像过程就直接用透视投影。3. 球面全景图生成反向投影与重采样是核心难点很多人以为把球面展开成平面图就是把每个球面点按公式算到像素坐标再填进去。这个想法逻辑上没错但实际写代码时你会遇到一个严重问题结果图里会留下大量空洞。3.1 正向映射为什么不如反向映射好用正向映射的思路是遍历球面上的网格点把每个点投到目标像素位置然后赋值给该像素。问题在于球面上均匀分布的网格点投到平面上往往不均匀有的像素被多次赋值有的像素一次都没被赋到形成黑洞。尤其在经纬网格靠近极点的地方密集度差异巨大。反向映射的思路完全反过来遍历目标图像上的每个像素反算出它在球面上对应的经纬度再从原数据中取颜色值。这个过程保证了每个目标像素都能被访问到不会出现空洞。3.2 反向映射的标准计算流程以生成一个等距圆柱投影的全景图为例完整流程可以拆成四步。第一步定义输出图像大小和视角范围H 1080; W 2 * H; lonRange [-pi, pi]; latRange [-pi/2, pi/2];第二步构建输出像素网格[uGrid, vGrid] meshgrid(0:W-1, 0:H-1); lonGrid (uGrid / (W-1)) * diff(lonRange) lonRange(1); latGrid (pi/2 - vGrid / (H-1)) * diff(latRange) latRange(1);第三步把网格经纬度转换为3D方向向量x cos(latGrid) .* cos(lonGrid); y cos(latGrid) .* sin(lonGrid); z sin(latGrid);第四步根据方向向量从原始全景图或3D场景中采样。如果原始数据是另一张等距圆柱全景图直接用lonGrid、latGrid去插值result interp2(single(srcImg(:,:,1)), ... (lonGrid pi) / (2*pi) * (size(srcImg,2)-1) 1, ... (pi/2 - latGrid) / pi * (size(srcImg,1)-1) 1, linear, 0);三步转四步之间看起来多做了不少矩阵运算但这些操作在MATLAB里都是向量化执行的比逐像素循环快出好几个量级。3.3 插值与边界处理重采样的时候插值方法的选择会直接影响图像质量。linear双线性插值效果适中速度快适合大多数情况。如果做精细画面可以用spline但边缘可能出现振铃效应。nearest虽然最省资源但锯齿明显不适合实际出图。边界处理同样要提前想清楚。等距圆柱投影图像左侧和右侧在球面上是相邻的即经度-180度和180度实际上是同一个位置。用interp2做插值时左右边界会被当成图像边界没法自动循环。需要先把源图左右各扩展一圈imgExt [img, img, img];然后用偏移后的坐标去采样最后裁回原尺寸。这个方法简单粗暴但能有效避免全景图接缝处出现一条突兀的竖线。极点区域的边界也很敏感。纬度90度时所有经度归为同一个点插值算法会遇到多对一的情况。处理方式是把极点附近的像素坐标做一次球面邻域平均或者干脆把超出纬度范围的值置为黑色或背景色看你的业务需求。3.4 一个全景局部视角生成示例我做过一个功能从一张全景图里切出任意俯仰角、任意水平角、任意视场角的局部透视图。核心代码可以缩成下面这样% 输入panoImg 等距圆柱全景图 % fovDeg 视场角centerAzDeg 中心方位角centerElDeg 中心俯仰角 % outSize 输出尺寸 function viewImg extractPerspective(panoImg, fovDeg, centerAzDeg, centerElDeg, outSize) H outSize(1); W outSize(2); f (W/2) / tan(deg2rad(fovDeg)/2); cx W/2; cy H/2; [uGrid, vGrid] meshgrid(0:W-1, 0:H-1); xv (uGrid - cx) / f; yv (vGrid - cy) / f; zv ones(H, W); % 归一化方向向量 normv sqrt(xv.^2 yv.^2 zv.^2); xv xv ./ normv; yv yv ./ normv; zv zv ./ normv; % 绕相机姿态旋转 az0 deg2rad(centerAzDeg); el0 deg2rad(centerElDeg); Rx [1 0 0; 0 cos(el0) -sin(el0); 0 sin(el0) cos(el0)]; Rz [cos(az0) -sin(az0) 0; sin(az0) cos(az0) 0; 0 0 1]; R Rx * Rz; pts R * [xv(:); yv(:); zv(:)]; xr reshape(pts(1,:), H, W); yr reshape(pts(2,:), H, W); zr reshape(pts(3,:), H, W); lon atan2(yr, xr); lat asin(zr); % 重采样 ui (lon pi) / (2*pi) * (size(panoImg,2)-1) 1; vi (pi/2 - lat) / pi * (size(panoImg,1)-1) 1; viewImg zeros(H, W, 3); for ch 1:3 viewImg(:,:,ch) interp2(double(panoImg(:,:,ch)), ui, vi, linear, 0); end end这套代码我在多个全景预览项目里直接复用过输出稳定速度也够快。核心思路就是从目标像素反算方向向量再做旋转最后回到全景图重采样逻辑清晰排查问题也方便。4. 制图应用中的投影参数与边界精度处理如果你做的不是图像而是地理坐标数据那球面投影就变成了正经的地图投影。MATLAB有Mapping Toolbox里面封装了一批现成的投影函数但真实业务中依然有不少坑。4.1 Mapping Toolbox中的projfwd与projinvprojfwd和projinv是一对非常方便的正反算接口。用axesm创建投影坐标系然后调用projfwd把经纬度转成平面x、y坐标figure; axesm(eqdcylin, MapLatLimit, [-85 85], MapLonLimit, [-180 180]); [x, y] projfwd(geoid, lat, lon);projinv则是反算[lat, lon] projinv(geoid, x, y);这套接口的好处是投影库很全支持上百种投影基本覆盖主流制图需求。但问题在于投影参数调起来有些微妙比如eqdcylin默认的参考椭球体是美国海军的flattening1/298....你直接拿它处理局部城市数据时如果不指定自己的geoid参数会出现几百米的偏差。我的做法是每次都显式指定地理参考对象geoid wgs84Ellipsoid(); [x, y] projfwd(geoid, lat, lon);别偷懒省掉这一步。4.2 经度跳变、极点与日期变更线附近的处理经度跳变是制图里最经典的边界问题。比如一个物体的经度在东经179度运动到西经-179度中间只差2度但直接做差分会得到358度导致轨迹图出现一条横穿整张图的斜线。MATLAB里处理这类问题可以在unwrap之前先把经度转换到连续范围lonCont unwrap(lon * pi/180) * 180/pi;unwrap会自动判断跳变超过pi的值并调整加或减2*pi。用完之后再转回标准经度范围即可。极点附近的处理要分投影类型讨论。在墨卡托投影下极点根本显示不出来因为y坐标无穷大。在等距圆柱投影下极点被拉成一条横线。在极射赤面投影下极点反而是图中心。处理办法取决于你要表达什么但我给的建议是先把数据裁剪到投影的有效范围内避免生成离谱的坐标值。4.3 变形控制与比例参数选择投影变形是制图绕不开的话题。等角投影保角度但不保面积等积投影保面积但不保角度没有两全其美。实际工作中要看你输出的图是给谁看、用来量什么。如果是船舶航线图角度比面积重要选墨卡托合理。如果是人口密度分布图面积比角度重要选等积投影更科学。如果是城市级GIS数据UTM投影这种按6度带划分的局部投影效果最好因为带内变形小。比例参数的选择也会影响结果。UTM投影里有个Scale factor默认为0.9996是为了减小中央经线附近的变形。MATLAB中如果你手动设置投影时没配这一项数据在远离中央经线的区域会偏得厉害。建议在做区域制图前先查一下你所在区域的UTM带号并设置好对应的中央经线。5. 高性能与调试MATLAB球面投影的实战经验写球面投影的MATLAB代码用来教学和做演示是一回事真正跑在大量数据上又是另一回事。这一节我总结几段踩坑后的心得。5.1 用向量化替代逐像元循环MATLAB慢大多不是语言问题而是循环太多。一张1920x1080的全景图如果逐像素调用投影函数即使函数本身很快循环的损耗也会拖垮时间。正确做法是把网格一次性生成然后用矩阵运算整体处理。我见过不少人把球面投影写成这样for i 1:rows for j 1:cols % 逐个像素计算 end end数据量小还好一到4K分辨率基本卡死。改成meshgrid之后代码量更少、可读性更高、速度提升几百倍。5.2 矩阵维度与方向混乱的排查方法球面投影代码里最常见的bug就是矩阵维度对不上。interp2要求Xq、Yq与输出矩阵的尺寸一致而很多人用meshgrid生成网格后又对某个坐标做了转置结果出来全是花屏或报错。排查这类问题的办法很简单在关键步骤后面打点检查尺寸和数值范围。assert(isequal(size(lonGrid), size(latGrid)), 投影网格尺寸不一致); assert(min(lonGrid(:)) -pi max(lonGrid(:)) pi, 经度越界);用assert把前置条件卡死只要数据不满足要求程序直接崩给你看。这样出错时能第一时间定位到是哪一步出了问题而不是看着花屏图像猜。方向混乱的问题我通常会拿几个已知点位做验证。比如经度0度、纬度0度的点在等距圆柱投影下应该在图像正中心经度90度、纬度0度的点应该在图像水平1/4位置。先跑简单点位再跑真实数据能避免把方向问题混进其他bug里。5.3 数值精度问题与极坐标退化球面投影中涉及大量三角函数和除法运算。当方向向量接近z轴时atan2(y,x)的数值是稳定的但当x和y同时接近0时sin(theta)会非常接近0phi的精度也会掉下来。此时最好是直接判断tol阈值给phi赋0或保留上一步的值。另外在墨卡托投影里纬度过大时数值溢出。在等距圆柱投影里当lat恰好等于pi/2时cos(lat)等0会得到NaN。这些边界值在做插值前必须用isfinite筛一遍lonGrid(~isfinite(lonGrid)) 0; latGrid(~isfinite(latGrid)) 0;5.4 一个最小化测试脚本的建议最后分享一个实用性极高的习惯不管写什么投影先写一个最小测试脚本验证一下正反变换是否闭环。lat 30 * pi/180; lon 120 * pi/180; % 正算 [u, v] equirect_proj(lon, lat, 4096, 2048); % 反算 [lon2, lat2] equirect_inv(u, v, 4096, 2048); assert(abs(angdiff(lon, lon2)) 1e-10, 经度闭环失败); assert(abs(lat - lat2) 1e-12, 纬度闭环失败);angdiff是MATLAB里专门比较两个角度差值的函数能自动处理取值范围跳到[-pi, pi]的问题。如果你用普通减法做验证经度179度和-179度虽然实际只差2度但相减结果却是358度会误判成失败。这个最小化测试脚本虽然只占十几行但我每次换投影类型、改数据结构都会先跑一遍它。确认闭环没问题再去做全景拼接、地图制图节省的时间远大于写脚本的那几分钟。我做球面投影相关项目也有几年了回头看看真正决定成败的其实不是复杂的数学推导而是坐标约定、边界处理、向量化这几件看似基础的小事。每换一个数据集、每换一次投影方式多数时间都花在了为什么结果偏了半个球的排查上。如果你正在写自己的投影代码我建议先把测试脚本写出来再动手写实现。这样一路下来会轻松很多。
返回列表