
1. 右键另存为拿到的只是一张缩略图全景图的分发逻辑上个月帮一个项目组收拾尾巴甲方把展厅全景的拍摄和上线都验收完了最后提了一个很朴素的需求把平台上那套成品拷一份归档将来官网改版还要用。项目组的新同事打开页面右键、图片另存为得到一张 1024×512 的小图放大两倍糊得像隔着毛玻璃看世界。他以为是浏览器的问题换了三个浏览器又试了手机端结果一样。这就是全景图高清下载这件事最容易被误解的地方你在网页上看到的那张图从来就不是一张图。全景播放器在屏幕上铺开的画面是浏览器实时从服务器拉回来的一堆小方块用 WebGL 贴到球面上渲染出来的。你右键点中的那个img标签多半只是加载遮罩、logo 或者一张模糊的占位预览图真正的画面内容在 Canvas 里压根没有对应的图片文件。所以想把一张 8K 甚至 16K 的全景原图完整拿下来靠右键是没有出路的得换个思路——不去保存图片而是去复现服务器发图的方式。服务器怎么切你就怎么收服务器按什么规则命名你就按什么规则拼。这件事的技术门槛其实不高但它有一个很硬的前提你得先看懂全景图的投影方式和分片规则否则拿到几百个碎片也不知道该往哪放。下面要聊的内容适合三类人手里有自己拍摄、自己账号下的全景素材需要本地备份的做展厅、房产、文旅项目需要把成品交付给客户存档的以及对全景图渲染管线好奇、想搞明白为什么网页上的全景能转动的技术同学。完全零基础也能跟上我会把每个概念拆到能画在纸上的程度。文中涉及的具体平台一律用类型代称因为不同平台的规则会随版本变化讲清类型比记住某个名字有用得多。提示本文讨论的所有操作前提都是你对素材拥有处置权——自己拍摄的、自己账号上传的、或者已获得明确授权的。平台用户协议里关于是否允许导出原始资源的条款动手前请先读一遍这条线比任何技术细节都重要。2. 一张 2:1 的图被切成了几百块全景图的两种投影底子2.1 等距圆柱投影把地球摊平成一张 2:1 的纸要理解切片先得理解全景图的底片长什么样。绝大多数全景播放器吃的是一种叫等距圆柱投影Equirectangular Projection的图通俗说法就是展开的世界地图。它把水平方向 360 度的经度均匀映射到图像的宽度上把垂直方向 180 度的纬度均匀映射到图像的高度上。这个映射带来一个直接的数学结果宽高比必须是 2:1。因为水平 360 度对应宽垂直 180 度对应高360/180 2。所以你看到的所有标准全景底图尺寸都是 4096×2048、8192×4096、16384×8192 这样的比例。如果哪天你拼出来的图是 8000×4000那说明瓦片尺寸或者层级算错了一点点横向少了一列。等距圆柱投影有个明显的副作用越靠近两极图的上下边缘也就是全景里的天顶和地面正下方像素被拉伸得越厉害。同一块面积的画面在赤道附近占的像素少在两极占的像素多。这就是为什么很多全景图的天顶部分看起来糊、噪点多——不是渲染问题是投影本身把那一小块像素摊大了一圈。理解这一点很关键后面判断这张图是不是被插值放大了就要看极区的像素密度。2.2 立方体贴图六个正方形面的另一条路线还有一派用立方体贴图Cubemap把整个球面内容投影到一个立方体的六个面上每个面是 90 度 × 90 度的正方形视野。这种格式在 GPU 渲染时效率更高因为立方体面的采样是均匀的不需要在极点做特殊处理。立方体路线对下载拼接的影响很直接你拿到的不是一张 2:1 的长图而是六张正方形图通常命名成 front、back、left、right、up、down 或者 0 到 5 的编号。拼的时候不能简单横着接得按立方体的展开图常见是十字形或者 T 字形摆放再用工具转成等距圆柱或者干脆六面一起打包成播放器认的格式。判断你面对的是哪一种有个土办法看瓦片的宽高比。如果单个瓦片是正方形比如 512×512且整体拼出来是 2:1那大概率是等距圆柱切分如果发现瓦片集合天然分成六组、每组内部是个正方形网格那多半是立方体路线。这一步判断错了后面所有的坐标计算都会错位。2.3 瓦片金字塔为什么要分级而不是一刀切一张 16384×8192 的全景图原始文件轻松超过 20MB。如果用户打开页面就下载完整一张加载时间长、内存占用高手机端直接卡死。所以几乎所有平台都用瓦片金字塔Tile Pyramid的方案。金字塔的意思是同一张图服务端预先切出多个分辨率层级。层级 0 是最粗的往往只有一两张瓦片够你快速看到一张模糊的全局预览层级 1 是它的两倍宽、两倍高一直往上翻最高层级就是原始分辨率。播放器根据当前视口大小、缩放程度、以及用户的网络状况动态决定拉哪一层的哪些瓦片。具体切法通常是这样的以 512 像素瓦片、等距圆柱投影为例层级横向瓦片数纵向瓦片数拼出的分辨率瓦片总数0211024×51221422048×102482844096×20483231688192×40961284321616384×8192512看懂这张表你就掌握了整件事的核心下载高清原图翻译过来就是把最高层级的所有瓦片全部拉下来按行列顺序拼回去。128 张或者 512 张听起来多但对脚本来说就是两层循环的事。3. 三类平台的分片规则并不一样3.1 全景托管类最标准的 z/x/y 瓦片金字塔全景托管平台和作品展示站是规则最规整的一类。它们的核心业务就是把用户上传的全景图切成瓦片、分发给播放器所以路径结构往往是教科书式的/tiles/{场景ID}/{层级}/{行}/{列}.jpg或者干脆对齐地图行业的惯例用 z层级、x列、y行三个整数/tiles/{z}/{x}/{y}.jpg这类平台通常还会提供一个场景配置文件JSON 格式或者一个被压缩过的 JS 变量里面写着这个场景的总分辨率、瓦片尺寸、层级范围、初始视角。有些平台会把这个配置藏得比较深但只要有播放器在工作配置文件就一定会在网络请求里出现——没有它播放器不知道去哪儿取图。这类平台的另一个特征是最高层级往往是完整切好的。因为服务端切片是离线批量跑的任务不是实时生成所以理论上每一个 (层级, 行, 列) 组合都对应一个真实文件。这对我们是好消息意味着脚本不需要处理某些瓦片 404的复杂情况除了边界情况后面会讲。3.2 云设计类先渲染出图再打包分发在线云设计工具做户型、做样板间、做展厅效果的那一类走的是另一条路。全景图不是用户直接上传的而是软件根据三维场景实时渲染出来的。渲染完成后服务端会把结果存成一张大图然后再走切片流程。这类平台的特征是中间产物会以任务结果的形式暴露。你会看到类似/render/task/{任务ID}/result这样的请求返回一个 JSON里面有成品图的地址、分辨率、文件大小。如果这个地址直链可用那是最省事的直接下载一张完整的图就完事根本不用拼。如果没有直链、只有切片那规则往往和 3.1 类似但路径里会多带一层任务 ID 或者版本号。这类平台的坑在于同一个场景反复渲染会生成多个版本的产物你得确认自己拉的是最新那次、分辨率最高的那次别把上周的预览版当成最终稿。3.3 街景与地图类多分辨率层级叠加空间索引街景地图服务的复杂度最高。它不只是把一张图切开还要处理连续移动、多段拼接、深度信息、以及全球范围的空间索引。请求参数里通常同时带着经纬度、朝向、层级服务端根据这些参数返回当前视野最合适的那几张瓦片。这类服务的路径往往不遵循简单的 z/x/y而是带一堆 query 参数比如x、y、level、scene之类的整数对。技术上讲依然可以按层级 行列的模型去理解但你需要先确认三件事一是层级编号是递增还是递减有些服务 level 数字越大越模糊二是瓦片是固定像素尺寸还是按视口动态变化三是同一个位置是否有多套采集数据不同年份的街景。街景类还有一个特殊之处它的原图往往是多张鱼眼或者广角相机拼出来的边缘有重叠区平台做过融合。你想拿到原始的那几张相机图基本不可能能拿到的是平台融合后的成品瓦片。心理预期要放对。3.4 素材库类预览图和源文件是两套体系还有一类是设计素材库里面既有全景素材也有贴图、模型。这类站点的特点是预览区跑的是压缩过的低分辨率版本源文件需要单独的下载权限或者积分。你在页面上抓到的高清瓦片可能只是预览高清版真正标称的 8K 源文件在另一套存储里路径完全不同。判断方法很简单把最高层级的瓦片全拼起来看看总分辨率和页面上标注的规格对不对得上。对不上说明你抓的还不是最终稿。4. 动手前的判断先找到最高层级藏在哪4.1 从网络面板读出一张瓦片的身份打开目标页面按 F12 进开发者工具切到网络面板筛选图片类型或者直接筛 XHR有些平台用接口返回 base64 或者二进制。然后在页面上慢慢拖动全景画面转一圈再缩放几次。这时候你会看到一批请求刷出来。挑其中一张看它的完整 URL重点看三段信息路径里的数字、文件名里的数字、以及响应头里的内容类型。路径里的数字通常就是层级、行、列。文件名可能是0_3_5.jpg这种下划线分隔也可能是z3_x5_y2.webp这种带前缀的。一个很实用的技巧只转水平方向观察哪一个数字在变再只转垂直方向观察另一个数字怎么变。水平转动时变化的是列号垂直抬头低头时变化的是行号。这样你就能在没有文档的情况下反推出哪个位置是 x、哪个是 y。还有一个技巧是看请求的触发时机。层级 0 的那张瓦片通常在页面刚加载、还没开始渲染时就已经请求完了用于显示模糊预览。层级越高的瓦片越是在你放大或者盯着某个方向看的时候才懒加载。所以你只要疯狂放大如果播放器支持或者把窗口拉大就能逼出最高层级。4.2 用配置文件反推总分辨率找到瓦片还不够你得知道最高层级是多少否则循环写不出来。三个办法按可靠性排序第一个办法是找配置文件。在网络面板里搜json、config、scene、pano这些关键词找到那个描述场景的响应里面通常有width、height、levels、tileSize之类的字段。有些平台会把配置塞进页面的一个全局变量里在控制台输入window.然后按 Tab 看提示运气好能直接看到。第二个办法是试探。从当前看到的层级开始往上加一级随便请求一个瓦片看返回是 200 还是 404。200 就继续加404 就说明到顶了。这个方法很直接但要注意别请求太猛。第三个办法是看播放器的行为。有些播放器在左上角或者设置菜单里会显示当前画质或者分辨率信息点开看一眼能省不少事。拿到层级 L 和瓦片尺寸 S 之后总分辨率就能算出来宽 S × 2^(L1)高 S × 2^L针对等距圆柱切分。比如 S512、L4就是 512×3216384 宽512×168192 高。这个公式建议记牢它同时也是你拼接完成后校验尺寸的依据。4.3 先小批量验证再全量开跑不要一上来就写全量循环。先挑最高层级的四个角左上、右上、左下、右下各请求一张确认都能拿到并且尺寸一致。再挑中间一张用图片查看器打开看看画面内容对不对——有些平台的瓦片是带水印的、带 logo 的或者边缘做了羽化处理这些都会影响最终成品的观感。这一步花不了五分钟但能帮你避开后面最难受的一类问题跑了半小时、下载了几百张、拼出来发现有半边是黑的。先验证再开跑是所有批量操作的基本纪律。5. 批量抓取与拼接一条能跑通的脚本链路5.1 下载脚本限速、重试、断点续传三件事下面这段是通用骨架改改 URL 模板就能用。关键不在于代码多花哨而在于三个工程细节并发别太高、失败要重试、已下载的要跳过。import os import time import random import requests LEVEL 4 TILE 512 COLS 2 ** (LEVEL 1) # 32 ROWS 2 ** LEVEL # 16 OUT ftiles/L{LEVEL} # 模板里的 {z} {x} {y} 换成你实际观察到的参数位置 URL https://example.com/tiles/{z}/{x}/{y}.jpg HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://example.com/, # 很多平台校验来源 } os.makedirs(OUT, exist_okTrue) def fetch(z, x, y, tries3): path os.path.join(OUT, f{x}_{y}.jpg) if os.path.exists(path) and os.path.getsize(path) 0: return True # 断点续传 for i in range(tries): try: r requests.get(URL.format(zz, xx, yy), headersHEADERS, timeout15) if r.status_code 200 and len(r.content) 100: with open(path, wb) as f: f.write(r.content) return True if r.status_code 404: return False # 真没有别重试 except Exception: time.sleep(1 i * 2) # 退避 return False for y in range(ROWS): for x in range(COLS): ok fetch(LEVEL, x, y) if not ok: print(fmissing {x},{y}) time.sleep(0.05 random.random() * 0.05) # 礼貌间隔几个必须注意的点。Referer 头不少平台在服务端校验来源页头里没有对应域名会直接返回 403 或者一张错误占位图。这类失败往往返回 200内容是张灰图或者 logo光看状态码发现不了所以下载完最好抽查几张。并发别一上来开 32 线程大多数平台对单 IP 有频率限制开 4 到 8 个并发、加上几十毫秒的间隔是最稳妥的节奏。文件大小校验正常的 512 瓦片 JPEG 一般不会小于 3KB如果某张只有几百字节大概率是错误响应要当失败处理。5.2 坐标映射行号从上往下还是从下往上这是整条链路上最容易翻车的地方也是我踩过最多次的坑。同样叫y不同平台的含义可能完全相反从上往下也叫 XYZ 风格地图行业常见y0 是最上面一行也就是天顶。从下往上也叫 TMS 风格y0 是最下面一行也就是地面正下方。如果搞反了拼出来的图会是上下颠倒的——天在上变成了地在上的效果极其诡异抬头看见地板低头看见天空。判断方法我在第 7 节会详细讲这里先记住这个开关一定要做成参数别硬编码。from PIL import Image def build(level, tile512, flip_yFalse, outpano.jpg): cols 2 ** (level 1) rows 2 ** level canvas Image.new(RGB, (cols * tile, rows * tile), (0, 0, 0)) miss 0 for y in range(rows): for x in range(cols): p ftiles/L{level}/{x}_{y}.jpg if not os.path.exists(p): miss 1 continue # 留黑后面统一检查 img Image.open(p) if img.size ! (tile, tile): img img.resize((tile, tile), Image.LANCZOS) row rows - 1 - y if flip_y else y canvas.paste(img, (x * tile, row * tile)) print(missing:, miss, size:, canvas.size) canvas.save(out, quality95, subsampling0) return canvas注意subsampling0这个参数。JPEG 默认会对色度通道做下采样把 4:2:0 或者 4:2:2 的色度信息丢掉一半这本身是为了减体积。但如果你是分块拼接、每块独立编码色度下采样会让接缝处的颜色产生细微断差。存成 4:4:4 可以明显改善这个问题代价是文件变大。5.3 成品校验三个必看的指标拼完之后别急着交付先做三件事。第一看尺寸。必须是 2:1且等于你按公式算出来的值。差一点点比如 16384×8190那就是某些瓦片高度不标准被 resize 拉过。第二看空洞。如果脚本报告 missing 大于 0说明有位置没下到。这时候不要直接用黑块交付先确认那几块是不是真的不存在。最常见的情况是最后一列或者最后一行根本不存在。因为总像素数不一定能被瓦片尺寸整除比如宽度 8190 像素、瓦片 512最后一列只有 510 像素宽有些平台会选择把最后一列裁掉不生成播放器渲染时靠拉伸补齐。这种情况下的正确做法是把倒数第二列复制一点、或者做轻微放大而不是留黑条。第三看颜色。把整张图缩到屏幕大小通览一遍找有没有明显偏色的区块。如果有通常是某个瓦片请求返回了错误内容或者跨域请求被替换成了占位图。逐块对比 RGB 均值能快速定位。6. 从相机到浏览器画质到底在哪几层被削掉了6.1 上传时的二次压缩与分块编码很多人以为下载平台原图就等于拿回拍摄时的原始画质这个预期需要修正。一条典型的链路是这样的拍摄阶段用多机位环形拍摄或者鱼眼镜头拍三到六张然后用软件做特征匹配、融合、投影变换输出一张 8000×4000 左右的等距圆柱底图。这一步已经做过一次重采样了因为多张鱼眼投到同一个圆柱面上必须做插值。上传到平台后平台通常会再压一次。有些平台会限制上传文件大小超过就自动降质有些平台会统一转成特定质量参数的 JPEG还有些平台会限制最大边长比如强制压到 8192 以内。这一步的损失往往是最隐蔽的因为用户端看不出来。切片阶段再做一次独立编码。每一张 512×512 的瓦片单独走 JPEG 编码而 JPEG 是按 8×8 或者 16×16 的宏块做 DCT 变换的。瓦片边界上的宏块它的邻域信息被切断了压缩时只能靠自己那半边数据做预测误差就体现在接缝上。这就是为什么你拼出来的图在瓦片交界处偶尔能看出一条淡淡的竖线或者横线。6.2 高清两个字的水分怎么识别插值放大不是所有标着高清的全景图都真的高清。有些平台因为存储成本会把底图压到 4096×2048但为了让放大后不至于太糊在更上层的瓦片里做了插值放大——你拿到的 8192×4096 其实是 4096×2048 拉出来的细节并不会增加。识别方法有三个都很实用。看极区噪点。前面说过等距圆柱投影在两极会把像素拉伸。如果底图本来只有 4K拉到 8K 之后天顶区域会呈现出一种特征性的糊成一片的效果——不是锐利的噪点而是像被水彩晕开的团块。真正的 8K 底图即使在天顶也能看到相对清晰的纹理边界。看高频细节。挑一张图里有密集纹理的区域书脊、瓷砖缝、树叶、金属网格放大到 100% 看。真高清会有清晰的边缘伴随轻微但真实的锐度插值放大的会有明显的振铃边缘两侧出现亮暗环和块效应。比文件体积。同样 16384×8192 的图真高清的 JPEG 往往在 20MB 以上插值放大的可能只有 6 到 8MB。因为信息量少压缩效率高。这个判断不绝对但作为辅助很有效。6.3 接缝处理的两种手法如果你的成品要交付给客户、或者要印在展板上瓦片接缝是需要处理的。两种思路按需选。一种是以质换净拼接时不用平台给的 JPEG而是先把瓦片解码成位图做一次轻微的高斯模糊半径 0.3 到 0.5 像素让接缝两侧的量化误差被抹平然后再统一编码成 PNG 或者高质量 JPEG。副作用是整体锐度会掉一点点但接缝基本消失。适合对连续性要求高的场景比如长走廊、纯色墙面。另一种是局部修补只针对检测到的接缝做处理判断逻辑是——沿每一列或行的边界计算两侧像素的亮度差如果差值的绝对值连续超过阈值就把这一列往两侧各扩 1 像素做线性插值。这个方法保留了绝大部分区域的锐度只修有问题的地方实现起来稍复杂但效果好。注意不管用哪种方法都要在无损或高质量格式下做不要拿已经压过的 JPEG 反复解码编码。JPEG 是有损格式每存一次都会掉一层质量三五次之后画面会出现明显的色块和蚊状噪点。7. 一次拼接错位的完整排查记录说个真实遇到的情况我觉得比任何教程都更能说明问题。某次帮一个团队处理一套室内全景流程走得很顺瓦片全下齐了128 张一张不缺脚本跑完生成的图尺寸也对16384×8192看着挺完美。打开一看整张图上下颠倒了——天花板的灯槽在下面地砖在头顶。第一反应是 y 轴方向搞反了。改flip_yTrue重跑结果变成另一种错乱图不颠倒了但上下两半内容重复了。这就不是简单的翻转问题了。第二步排查怀疑是行数算错。重新数了一遍rows8、cols16跟公式对得上。又去页面里逐个观察请求发现一个细节当我把视角抬到最高看天顶时请求的行号是 0压到最低看地面时请求的行号是 7。也就是说这个平台的 y 确实是从上往下的不需要翻转。那为什么翻转和不翻转都出问题第三步把不翻转的版本重新检查了一遍。这次学乖了不是看整图而是把每一行单独切出来看内容。结果发现第 0 行和第 7 行的内容高度相似都是天花板的纹理第 3 行和第 4 行也都是地面附近的内容。这下清楚了——瓦片本身没问题是我下载的时候把行号理解成了纵向坐标而这个平台的 y 其实是第几圈一圈里包含多个方向的瓦片。第四步重新读网络请求。仔细看 URL 结构发现路径是/pano/{场景}/{层级}/{批次}/{序号}.jpg四个数字。我一开始把前两个当成行列实际是后两个才是。前面那个数字是面的编号立方体六面不是行号。第五步改成按面分组下载、每个面内部再按序号排布拼成六个正方形再用工具转成等距圆柱。这次出来的图完全正确接缝也对得上。这个坑给我的教训有三条都很实在。一是别急着相信自己对参数含义的第一判断路径里的数字顺序和实际含义没有必然关系必须用改变视角看哪个数字动的方法验证。二是拼接前先按行、按列抽样看内容不要整张拼完再看那样很难定位。三是当翻转和不翻转都不对时说明模型本身错了不是参数错了这时要退回去重新理解数据结构。还有一个衍生的坑值得提一句那套图的最后一列第 15 列请求全是 404。我一开始以为是权限或者限流加了重试、换了 UA、试了 Referer全没用。后来发现总宽度是 8160 而不是 8192最后一列只有 480 像素平台直接没生成。处理办法是把第 14 列的右边缘裁 32 像素宽的一条拉伸补到第 15 列的位置——因为那 32 像素在几何上覆盖的角度和缺失的 32 像素是一致的拉伸基本看不出来。8. 动手之前先把这几条边界想清楚技术上能做的事和该做的事中间隔着一条线。这条线在全景图这件事上特别需要说清楚因为全景作品绝大多数是有明确归属的——要么是摄影师的作品要么是甲方的商业空间要么是平台的付费素材。第一条先确认你手里的素材归谁。自己拍摄、自己账号上传的处置权在你备份、转存、二次加工都没问题。给别人做的项目交付条款里写没写提供原始文件、写没写允许本地存档这些要提前对齐。没有明确约定的先问一句比事后解释省事得多。第二条平台的用户协议值得花十分钟读一遍。有些平台明确写了允许作者导出自己上传的原始文件有些则规定所有上传内容的使用范围仅限于平台内展示。这两者的差别很大而协议文本通常就在页脚点两下就能看到。第三条别人的作品不要碰。这个不用多解释。全景图是有著作权的摄影作品抓取他人作品用于任何用途都不在技术学习的豁免范围内。第四条抓取行为本身要有节制。哪怕是自己账号下的内容也别开几十个并发去轰服务器。限速不仅是为了不被封也是基本的网络礼仪。几百张瓦片、每张间隔几十毫秒几分钟就跑完了没必要抢那几十秒。我个人的习惯是凡是这类批量操作先在笔记里写一行素材来源 授权情况 用途再动手。这行字花不了十秒钟但将来有人问起来或者在某个需要自证的场合它能省掉很多麻烦。技术上的坑可以靠调试解决归属上的坑一旦踩了是没有回滚按钮的。