开采之前:如何审计 Google 街景的覆盖范围与影像时效
元数据请求免费,且不消耗影像配额。先跑一遍,就能把「这里到底有没有覆盖」从一个假设变成一个测出来的数字。
- 作者
- HuiTu Technology
- 发布时间
- 更新时间
要发现一个研究区域的街景覆盖其实千疮百孔,最贵的方式是在影像采集跑到一半时才发现。第二贵的方式,是在分析阶段才发现——那时你才看到,三分之一的采样点用的是分属三个年份的影像。
其实有一条便宜得多的路。Street View Static API 提供了一个元数据端点,可以回答某个位置有没有影像、最近的全景实际落在哪里、大致什么时候拍的。Google 的文档明确说明这类请求免费,且不消耗影像配额——这意味着把一整座城市的覆盖度审计跑完,成本只有请求时间。把它放在最前面做,是街景项目中回报率最高的一小时。
元数据端点会返回什么
你发送的参数和请求影像时一样:位置、半径、来源类型。拿回来的不是一张 JPEG,而是一个很小的 JSON 对象。
| 字段 | 它告诉你什么 | 我们怎么用它 |
|---|---|---|
| status | 是否找到了全景;没找到的话,是什么原因 | 覆盖标记本身;其余字段都以它为前提 |
| location | 被匹配到的那个全景的经纬度 | 吸附距离:返回的全景离你查询的点有多远 |
| date | 拍摄月份,通常为 YYYY-MM 形式 | 影像年龄、季节控制,以及整批采样的年份一致性 |
| pano_id | 被匹配全景的标识符 | 单次运行内去重,以及两次运行之间的更替检测 |
| copyright | 该全景的署名信息 | 区分平台官方采集与用户贡献的全景 |
发第一个请求之前,先把采样设计定好
覆盖审计本质上是一次抽样,而设计糟糕的抽样只会给出一个自信的错误答案。三个决定几乎决定了全部结果。
- 沿路网采样,不要用方形格网。格网会把点撒到街区内部和公园中央——那里本来就不该有全景——然后把由此产生的空白算成覆盖缺失。
- 固定一个间距并记录下来。城市场景通常取 20 到 50 米。凡是你打算相互比较的行政区,间距必须完全一致,否则覆盖率之间没有可比性。
- 半径要有意识地设定。半径放宽会让覆盖率显得很漂亮,因为它会匹配到离查询点很远的全景;半径收紧更诚实,但会在宽阔道路上误拒合理匹配。我们通常取 30 米,并把吸附距离一并保留,以便日后重新审视这个选择。
来源参数同样重要。指定只要室外影像,可以排除室内全景——否则恰好落在采样点附近的商铺内部和车站大厅,会污染整个街道景观审计。
一段最小可用的审计脚本
不需要任何花活:读入点位、逐点探测、每点写一行,并且把响应给出的每个字段都原样留下,而不是在写入时就压缩成一个布尔值。
import csv
import os
import time
import requests
API = "https://maps.googleapis.com/maps/api/streetview/metadata"
KEY = os.environ["GOOGLE_MAPS_API_KEY"] # never hard-code a key into the script
session = requests.Session()
def probe(lat, lon, radius=30, source="outdoor"):
"""Metadata request: free of charge, and it consumes no image quota."""
params = {
"location": f"{lat},{lon}",
"radius": radius, # snap distance in metres; keep it tight
"source": source, # "outdoor" excludes indoor and interior panoramas
"key": KEY,
}
for attempt in range(4):
response = session.get(API, params=params, timeout=10)
if response.status_code == 200:
return response.json()
time.sleep(2 ** attempt) # back off, then retry
return {"status": "REQUEST_FAILED"}
with open("sample_points.csv") as source_file, \
open("coverage.csv", "w", newline="") as out_file:
writer = csv.writer(out_file)
writer.writerow(
["point_id", "status", "pano_id", "capture_month", "pano_lat", "pano_lon"]
)
for row in csv.DictReader(source_file):
meta = probe(row["lat"], row["lon"])
located = meta.get("location") or {}
writer.writerow([
row["point_id"],
meta.get("status"),
meta.get("pano_id", ""),
meta.get("date", ""), # YYYY-MM, when the platform returns it
located.get("lat", ""),
located.get("lng", ""),
])有两个习惯值得从任何采集流水线沿用过来:绝不把密钥硬编码进脚本,以及保留原始状态码而不是过早把它折叠掉。一次只记录了「有覆盖/无覆盖」的运行,事后无法区分真正的覆盖缺口和悄悄撞上配额上限的请求。
认真读 status 字段
| 状态 | 含义 | 正确处理方式 |
|---|---|---|
| OK | 在半径内找到了全景 | 记录下来,并记录吸附距离 |
| ZERO_RESULTS | 请求位置附近没有全景 | 真正的覆盖缺口,按缺口计入 |
| NOT_FOUND | 位置或全景 ID 无法解析 | 回头检查输入;不要和真实缺口混为一谈 |
| OVER_QUERY_LIMIT | 触发了频率或用量限制 | 退避后重试;绝不能计为覆盖缺口 |
| REQUEST_DENIED | 请求未获授权 | 这是配置问题而非数据结论;应中止本次运行 |
| INVALID_REQUEST | 必填参数缺失或格式错误 | 修调用方;这些行不构成关于覆盖的任何证据 |
一次审计应当产出哪些数字
把响应表变成一小组可以直接摆到出资方面前的指标。下面这些,是真正会改变决策的那几个。
- 覆盖率:返回 OK 的采样点占比,要按行政区和道路等级分别报告,而不是给一个总的头条数字。
- 吸附距离的中位数,以及第 90 百分位。尾部抬高意味着系统正在从相邻街道上给你匹配全景。
- 影像年龄分布:拍摄月份中位数,以及超出你时效阈值的比例。
- 每个比较单元内部的年份跨度。一个点位横跨 2019 到 2026 的行政区,无法与另一个整体拍摄于 2025 的行政区干净地比较。
- 拍摄月份构成,它是后续任何植被指标都会需要的季节控制变量。
- 唯一全景数与采样点数之比。这个比值远小于一,说明你的采样间距细于全景本身的间隔,你在为重复内容付费。

时效是项目约束,不是细节
拍摄月份是最常被一眼扫过、事后又最让人后悔的字段。临街商铺的更替速度足够快,四年前的影像会把「现在在营业的是谁」讲错。用一月落叶期影像算出来的植被指标,和七月的完全没有可比性,而这个差距通常比你所研究的各条街道之间的差异还要大。

全景 ID 会变,要为此做设计
全景 ID 标识的是「平台这次给你的那份影像」,而不是「这个地点」。平台会重拍街道、重新处理全景、下线旧数据,因此某次审计里记下的 ID,过一段时间可能就解析不出来了。请把它当作单次运行内的值:用于本轮去重,而你自己的数据库仍以采样点标识符为主键。两次审计之间 ID 发生变化,只能作为待复核标记;还要同时检查拍摄日期和返回位置,才能判断是不是发布了新影像,因为重新处理同样可能改变标识符。
报告要写到让决策一目了然
- 在最上面写明采样间距、半径与来源参数。没有它们,覆盖率就是一个没有定义的数字。
- 覆盖率按行政区和道路等级拆开,因为缺口几乎从不均匀分布。
- 影像年龄用拍摄月份的直方图呈现,而不是给一个平均值。
- 标出未通过时效或年份一致性阈值的比较单元,并为它们的替代方案单独报价。
- 直接用 OK 的数量估算影像请求量与费用——此时它已经是一个测量值,而不是一个猜测。
一次好的审计,其结论往往是把项目做小:与其五个行政区都覆盖得参差不齐,不如三个行政区覆盖扎实。这个结果,总好过等账单寄到之后才发现同一件事。