卫星影像 → 口袋城市 2 风格三维城市模型
这是什么
输入一个目标区域(中心经纬度 + 覆盖边长),产出单个 HTML 文件:
Three.js 真 3D 正交鸟瞰的城市模型,双击浏览器打开即可,无需服务器、无需联网。
- 建筑体块由卫星影像的纹理能量驱动 —— 位置、疏密、沿街排布反映真实城市形态
- 水系(江/河/湖/水库/渠道)走 OpenStreetMap 矢量,河宽按等级给
- 带 8 个视角按钮 / 4 个图层开关 / 地名自动避让 / 比例尺 / 指北针
实测样例:郑州全域 39.2 × 39.2 km(含黄河北岸)→ 1.62 MB 单文件、
18 461 栋建筑体块、278 个水体多边形、131 个湖心岛。
先读这三条(错了后面全白做)
-
立体感靠「逐面烘焙三档明暗」,不靠光照。
顶面×1.10、东面×0.80、南面×0.66,写进顶点色 +MeshBasicMaterial。
用MeshLambertMaterial+ 方向光会打爆顶面(蓝通道截断成白)、糊掉侧面。 -
「针林」的真凶是高度分布,不是抖动也不是网格。
不要给 20%+ 的建筑做大倍数放大。本项目真凶是best>0.85 时 h *= 2.2~4.8
命中 28% 的建筑。改成 3% × 1.62~2.67。 -
大湖在 OSM 是
multipolygon relation,只遍历way会把它们全丢掉。
郑州 755 个水系要素里有 37 个 relation,最大的三个水面恰好都是。
完整论证见 reference/style-card.md(色卡与硬参数)与
reference/pitfalls.md(24 条实测踩坑,按症状索引)。
快速开始
1. 建项目骨架
<项目根>/
config.json ← 唯一需要手写的文件(见下)
scripts/ ← 把本 skill 的 scripts/ 拷进来
vendor/three.min.js ← 把本 skill 的 vendor/ 拷进来(或指向已有的)
assets/template.html ← 把本 skill 的 assets/ 拷进来
config.json 最小示例(完整见 examples/config.zhengzhou.json):
{
"city": "郑州",
"city_en": "ZHENGZHOU",
"center_lat": 34.824,
"center_lon": 113.6575,
"half_m": 19000,
"out_name": "郑州城市模型图.html",
"labels": [["二七广场", "老城中心", "center", 34.7466, 113.6254]],
"views": {
"all": { "tx": 0, "tz": 0, "zoom": 1.0, "name": "全域总览" },
"plan": { "tx": 0, "tz": 0, "zoom": 1.0, "top": true, "az": 0, "name": "平面视图" }
}
}
> half_m 是图幅边长的一半。39.2 km 见方 → 19000。
> Web Mercator 会把实际边长略微撑大(郑州实测 39.2 km,比 38 km 多 3%),
> 这是正常的,因为图幅必须取整数瓦片。
>
> 本机运行前设一次环境变量指向项目根:
> export SCM_ROOT=<项目根>(否则脚本按自身所在目录猜)。
2. 一键跑链路
python scripts/scm_run_all.py
# 单步重跑(长链路被中断时用这个,不要从头再来)
python scripts/scm_run_all.py --only build_scene
七步流水线,每步产物存在即跳过:
| 步骤 | 脚本 | 产物 |
|---|---|---|
| tiles | scm_fetch_tiles.py |
data/sat/z15/*.jpg(39 km 需 1521 块,约 2 分钟) |
| mosaic | scm_mosaic.py |
data/sat/ortho_z15.png(9984²) |
| texture | scm_texture.py |
data/analysis/tex.npy(7.84 m/px 密度场) |
| water | scm_fetch_water.py + scm_water_vec.py |
data/vec/water2.json |
| build_scene | scm_build_scene.py |
data/vec/scene.json |
| emit | scm_emit_html.py |
web/<out_name> ← 成品 |
| shoot | scm_shoot.py |
web/_shot_all.png + 自检文本 |
3. 验收
python scripts/scm_shoot.py all 1500 950 all # 渲染 + 自检
python scripts/scm_style_check.py web/_shot_all.png
必须看到(对照 reference/style-card.md §3):
JS 报错:无 ✅- 自检里「实测建筑 … 高/宽 = 」落在 0.8 ~ 1.3(近景视角)
- 低饱和亮像素占比 < 0.30;p75 与 p95 不能都顶到 225
- 水系探针「应水 / 应陆」成对通过
> ⚠️ 图片不能随 skill 包分发(SkillHub 白名单不允许),所以本 skill 只带
> 脚本与文本日志证据(examples/log_*.txt)。渲染图请在本地自行生成查看。
各步骤详解
投影:scm_proj.py(所有脚本共用)
三套坐标必须严格自洽,否则 ll2m 与 px2m 会错位、水体整体偏移几百米:
像素 (col,row) ←→ 米 (x,y,原点=图幅几何中心,x 东 y 北) ←→ 经纬 (lat,lon)
米坐标原点必须取「图幅几何中心」(= 像素中心),不能直接用 config 里的经纬 ——
这一条踩过坑。另外图幅在模型空间里必须是正方形(等量像素 × MPP),
Web Mercator 在中纬度的南北/东西实际距离差 2~3%,但视觉上无影响,
保持正方形才能让等距投影正确。
卫星影像:Esri World Imagery
https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}
无需 key。URL 末尾是 y/x,不是 x/y,写反了会拿到另一颗星球的图。
两个主机名等价(server. / services.),互为备份。
z15 覆盖 39 km 需 39×39 = 1521 块 → 9984² px、3.92 m/px。
分析用 1/2 降采样(4992²,7.84 m/px)。
密度场:局部纹理能量
必须在原始分辨率算,再降采样。 7.84 m/px 下主城浅色屋顶的局部方差
(sd≈11)与农田(≈9)、水面(≈10)几乎重合,根本分不开;
3.92 m/px 下一栋 20 m 建筑 = 5 px,纹理信息才够。
hi = gray - cv2.GaussianBlur(gray, (0, 0), sigmaX=6.0)
k = min(max(3, int(round(50 / MPP0)) | 1), 21) # 窗口 ≈ 50 m
tex = cv2.blur(np.abs(hi), (k, k))
dens = np.clip((tex - 15.5) / (44.0 - 15.5), 0, 1)
dens = cv2.GaussianBlur(dens, (0, 0), sigmaX=16.0) # 消"空心"
实测参考值(郑州 z15):黄河 9.6~10.1 / 农田 17.8 / 山地 23.7 /
郑东 CBD 24.1 / 老城 36.6。换城市必须重新读这四个锚点定标,
不能照搬 15.5 / 44。
水系:OSM Overpass
镜像可用性:z.overpass-api.de > overpass.openstreetmap.fr > overpass-api.de。
osm.ch HTTP 200 但静默返回 0 字节(最危险,务必校验要素数)。
两个致命坑的修法写死在 scm_water_vec.py 里:
- 只处理
way→ 丢掉全部大湖(必须处理relation的 inner/outer 环,
inner 环导出为独立的「湖心岛」图层) - 把 100+ 个面烧进同一张栅格再取轮廓 → 连通即合并、形状被切歪、孔洞被填
(面状直接用 OSM 闭环矢量简化,完全不经过栅格;线状单条独立栅格化)
河宽按等级给,不能一刀切:
| 类型 | 半宽 |
|---|---|
| 名字含「黄」 | 900 m |
waterway=river 且点数 ≥ 150(主干常无名) |
700 m |
| 南水北调 / 总干渠 | 45 m |
waterway=river |
30~45 m |
waterway=canal |
30 m |
| stream / drain / ditch | 14 m |
必做:生成 water_overlay2.jpg(水体面 58% 不透明度叠到影像上),
肉眼确认河道吻合后再建模。
建筑:scm_build_scene.py
| 参数 | 值 | 为什么 |
|---|---|---|
| 网格 | 190 m | 80~150 m 会得到细针林;街区尺度才有城市感 |
| 基底 | 网格的 0.44~0.74 | 间隙 50~100 m,等距下才看得见街道 |
| 位置抖动 | ±0.07 格 | 抖 0.9 格会让相邻建筑重叠、街道消失 |
| 高度 | 中位 ~26 m、P90 ~44 m、上限 ~140 m | 保持一层低矮「地毯」 |
| 超高层 | 仅 3%,×1.62~2.67 | 概率给到 20%+ 就是针林 |
| 竖向夸张 HS | 近景 5.0 → 全域 ×1.85 | 屏幕高/宽 ≈ 0.8~1.3 才是扁方块 |
| 铺装阈值 | dens > 0.34 |
0.46 会让城市读作"绿地上的白点" |
CENTERS 是高度加成点(各 CBD 权重 0.55~1.15),
11000 m 是衰减尺度 —— 换城市要按实际组团间距调。
渲染:assets/template.html
墙面色池收敛到 5 桶,别做彩虹:
| 桶 | 墙色 | topMul | 占比 | 说明 |
|---|---|---|---|---|
| 0 | 0xa9b2dd |
1.10 | ~56% | 混凝土(主) |
| 1 | 0xcfdcf0 |
1.04 | ~31% | 亮混凝土 |
| 2 | 0x535f82 |
1.52 | ~13% | 玻璃塔:深墙 + 亮蓝顶,画面最暗 5% 的来源 |
| 3 | 0xc25f62 |
1.00 | ~0.2% | 砖红(只给旧城低层) |
| 4 | 0x5a68ae |
1.28 | ~0.2% | 地标深蓝 |
其它实现要点:
- InstancedMesh 分色桶:每桶一个
BoxGeometry(顶点色已烘焙)+ 一个
InstancedMesh,18k~60k 栋毫无压力。frustumCulled = false(实例包围盒不准)。 - 高层加裙房(
h > 62 m):底面积 ×1.20、高 7~14 m,观感立刻「真」起来。 - 地面外扩 3.2 倍 + 农田格纹每块 ~150 m,随地面尺寸联动。
up向量退化保护:el → π/2时camera.up要换成(-sin az, 0, -cos az)。- 标注避让:按优先级排序,冲突时上下交错试探 15 次。
cam必须声明在建筑生成之前 —— 自适应夸张要读cam.zoom,
声明晚了会refreshBld报错。
无头验证(Windows 本机唯一可行链路)
Edge headless 下 WebGL 无法合成进 --screenshot(截出来全黑,
但 gl.readPixels 证明渲染完全正常)。必须走:
msedge --headless=new --no-sandbox --enable-unsafe-swiftshader --use-angle=swiftshader \
--hide-scrollbars --force-device-scale-factor=1 --virtual-time-budget=20000 \
--user-data-dir=<临时profile> --window-size=1500,950 \
--dump-dom "file:///…/page.html?static=1&view=all"
页面里 ?static=1 时只渲一帧(rafOn = false)、preserveDrawingBuffer: true,
把 canvas toDataURL() 写进隐藏 <div id="dumpimg">,外部正则抠出 base64 解码。
DOM 标注不会进 canvas 像素 —— 所以要先把标注画到 2D canvas 上再导出。
调试查询参数(模板已内置):view / az / el / zoom / t=tx,tz / only=all|water|bld|pads|ground。
交付前检查清单
- [ ] 蓝紫像素亮度分位 vs 参考(
scm_style_check.py) - [ ] 屏幕高/宽比:近景 0.8~1.3;若 < 0.5 说明太扁或 HS 太小
- [ ] 低饱和亮像素占比 < 0.30
- [ ] 全景视角画布四周无空白天空
- [ ] 水系探针「应水 / 应陆」成对通过(例:黄河/龙湖/水库=是;老城/CBD=否)
- [ ] 全部视角渲染无 JS 报错、无黑屏
- [ ] 交付说明如实标注:建筑为程序化体块、高度为推断值、
OSM 建筑轮廓覆盖不足故未采用、河宽为示意值
文件清单
SKILL.md ← 本文件
assets/template.html ← 渲染模板(占位符:__THREE_JS__ / __SCENE_JSON__ /
__LABELS_JSON__ / __VIEWS_JSON__ / __CITY__ / __CITY_EN__)
vendor/three.min.js ← Three.js r128,内联进成品,保证离线可用
scripts/scm_proj.py ← 统一投影(米/像素/经纬 ↔)
scripts/scm_fetch_tiles.py ← Esri 瓦片下载(Session 连接池,比 urllib 快 90 倍)
scripts/scm_mosaic.py ← 拼瓦片 → 正射影像
scripts/scm_texture.py ← 原始分辨率算纹理能量场
scripts/scm_fetch_water.py ← Overpass 抓水系(755 要素)
scripts/scm_water_vec.py ← 水系矢量化(含 relation 内外环 + 影像验证图)
scripts/scm_build_scene.py ← 密度场 → 建筑 + 铺装 + 水体 → scene.json
scripts/scm_emit_html.py ← 合成单文件 HTML
scripts/scm_shoot.py ← 无头渲染验证(含自检数字)
scripts/scm_style_check.py ← 画风验收(色相/亮度分布对照)
scripts/scm_run_all.py ← 一键流水线(支持 --only 单步重跑)
examples/config.zhengzhou.json ← 完整配置样例(含 8 视角 + 18 地标)
examples/log_*.txt ← 实跑日志(过程凭证)
reference/style-card.md ← 《口袋城市 2》色卡与硬参数(画风宪法)
reference/pitfalls.md ← 24 条实测踩坑,按症状索引