牛一越界,大屏 3 秒飞到它身边:我用真实卫星数据 + Three.js 做了个 3D 智慧牧场
一个「智慧牧场 · 草畜平衡」三维大屏的完整实现:从 Copernicus 地形和 Sentinel-2 卫星影像出发,沿河谷切出一块 12×8 km 的沙盘,用逐月 NDVI 表现草场长势,程序化生成牧场、5780 头牛羊和告警仿真,最后让页面和设计稿逐区比对。
在线体验:dani-bin.github.io/screen-demo...
一、真草原,假牧场
这是我大屏作品合集里的第十个 demo,题材是智慧农牧。动手之前,先用一组选择题把需求定下来:
| 问题 | 选择 |
|---|---|
| 牧场类型 | 草原放牧(牛 + 羊) |
| 规模 | 合作社 / 集团牧场 |
| 主线 | 草畜平衡与划区轮牧 + 畜群定位与安全 |
| 季节 | 夏秋放牧期(7--8 月) |
| 交互 | 告警联动 + 草场长势历史 |
| 场地 | 真实的中国草原 |
场地选在呼伦贝尔陈巴尔虎旗的莫日格勒河,号称「天下第一曲水」,河在草原上拐出几十道弯。选它有两个原因:这里是典型的夏营地,而且地形和卫星影像都有免费的公开数据。
但牧场本身是虚构的。所以整个项目有一条约定:真实的部分如实说,虚构的部分不说成真的。
| 内容 | 来源 | 真 / 假 |
|---|---|---|
| 地形 | Copernicus GLO-30 数字高程(30 m) | 真实 |
| 底图 | Sentinel-2 L2A,2026 年 7--8 月晴空像素中值合成(10 m) | 真实 |
| 草场长势 | Sentinel-2 逐月 NDVI(2025、2026 年 4--10 月) | 真实 |
| 河道、湿地 | 地形谷底线 + 影像水体 + ESA WorldCover | 真实 |
| 牧场边界、40 块轮牧分区、营地、饮水点 | 顺着真实河谷规划 | 虚构 |
| 5400 只羊、380 头牛、项圈、告警 | 仿真 | 虚构 |
技术栈
| 环节 | 工具 |
|---|---|
| 页面 | Vue 3 + Vite,px→rem 等比缩放(postcss-pxtorem,设计稿 1920 宽) |
| 三维 | three.js:自定义 ShaderMaterial 地形、Canvas 贴图叠加层、Points + InstancedMesh 牛羊、gsap 镜头 |
| 数据 | Python:rasterio 读云上的 COG,NumPy / SciPy / scikit-image 加工 |
| 仿真 | 纯 JS,按日期播种的伪随机数,时间跟北京时间走 |
| 验收 | 无头 Chrome 截图 + Python 逐区比对 |
这次没有用 Blender:地形本身就是数据,营地、饮水点和牛羊都是代码拼出来的低模。
二、整体流水线
和之前几个项目一样,坚持一条原则:页面用到的数据全部由脚本生成,不手改。
text
fetch_data.py(下载:DEM / Sentinel-2 / WorldCover)
└─→ models/smart-ranch/raw/ 16×14 km、正北朝上,不进仓库
│
prepare.py(唯一的数据加工入口)
├─→ terrain_h.png 1201×801 高程网格(16 位拆成 R/G 两个字节)
├─→ imagery.webp 2400×1600 夏季真彩色底图
├─→ ndvi_a/b.png 4--10 月逐月 NDVI(7 个月塞进 7 个通道)
├─→ layout.json 沙盘朝向、河道、保护带、40 块分区、设施、分区逐月 NDVI
└─→ plan-v1.png 规划平面图(给人看)
│
RanchScene.js(three.js,不依赖 Vue)+ simulator.js(仿真)+ Vue 面板
想换一块草原,只要改 fetch_data.py 里的中心经纬度,重跑两个脚本。
三、取数据:不注册账号,直接读云上的 COG
Copernicus DEM 和 Sentinel-2 在 AWS 上都有公开桶,文件是 COG(Cloud Optimized GeoTIFF)。COG 支持 HTTP Range 请求,只读需要的那一小块,不用把整景几百 MB 的文件下下来。
3.1 一个函数:远程读 + 重投影 + 拼接
rasterio 打开 /vsicurl/ 开头的地址就是远程读,再用 reproject 直接投到自己定义的 10 m 网格上。跨分幅时多次调用,已有的值保留,空洞由下一幅补上:
python
def warp_into(url, dst, resampling, band=1, src_nodata=None):
"""把远程 COG 的一个波段重投影进目标数组;目标里已有的有效值保留(多幅拼接用)"""
crs, transform, _, _ = grid()
tmp = np.full(dst.shape, np.nan, dtype=np.float32)
with rasterio.open("/vsicurl/" + url) as src:
reproject(
source=rasterio.band(src, band),
destination=tmp,
dst_transform=transform,
dst_crs=crs,
src_nodata=src_nodata if src_nodata is not None else src.nodata,
dst_nodata=np.nan,
resampling=resampling,
)
hole = np.isnan(dst)
dst[hole] = tmp[hole]
地形按 1° 分幅,文件名形如 Copernicus_DSM_COG_10_N49_00_E120_00_DEM,30 m 用三次卷积插到 10 m,缓丘过渡更顺。
3.2 Sentinel-2:先看云,再下载
影像用 Element84 的 Earth Search(STAC 接口)检索。三个小技巧让跨境网络下也能稳定跑完:
- 只要用得到的字段 :STAC 响应默认带全部元数据,约 1.6 MB,经常读到一半断掉;用
fields只取 id、时间、几个波段地址,响应降到几十 KB; - 先读场景分类图 SCL:SCL 是 20 m 分辨率、体积小,先用它算窗口内的晴空占比,不到 50% 的日期直接跳过,省掉大部分下载;
- GDAL 超时和重试 :
GDAL_HTTP_TIMEOUT、GDAL_HTTP_MAX_RETRY设上,按年月前缀开几个进程并行下载。
python
body = {
"collections": ["sentinel-2-l2a"],
"bbox": list(bbox_lonlat(pad=0)),
"datetime": f"{start}T00:00:00Z/{end}T23:59:59Z",
"query": {"eo:cloud_cover": {"lt": 80}},
# 只取用得到的字段,响应从约 1.6 MB 降到几十 KB
"fields": {"include": fields},
"limit": 100,
}
3.3 一个差点漏掉的坑:NDVI 算出了 1.27
NDVI =(近红外 − 红光)÷(近红外 + 红光),理论上在 −1 到 1 之间。可我算出来的 4 月份,出现了 1.27 这种不可能的值。
查下来是 L2A 的反射率偏移问题:2022 年起的处理基线 04.00 给反射率加了 +1000 的偏移,Earth Search 用 earthsearch:boa_offset_applied 标记是否已经扣除。2026-04-03 这一景标记成「未扣除」,数值却和同月已扣除的景一样。照标记再扣一次 1000,暗像元变成负数,NDVI 就爆了。
处理办法是三道保险:标记不一致的景直接跳过;有效像素要求红光、近红外反射率都为正;最后再把 NDVI 截到 −1, 1。
python
# 处理基线 04.00 起 L2A 数值带 +1000 偏移,Earth Search 绝大多数景已经扣除。
# 个别景标记为未扣除、数值却和已扣除的一致,照标记再扣会把暗像元扣成负数,
# 所以标记不一致的景直接跳过
if not all(meta.get("boaOffsetApplied") or [False]):
skipped_offset.append(day)
continue
def clear_mask(b):
"""晴空且反射率有效(红光、近红外都为正)的像素"""
return np.isin(b["scl"], SCL_CLEAR) & (b["red"] > 0.002) & (b["nir"] > 0.002)
教训:数据源的元数据也会错,算完一定要看值域。
四、把数据变成沙盘
4.1 河太细,用地形找河谷
莫日格勒河只有 10--20 m 宽,在 10 m 影像上断断续续,直接找水体会把旁边的牛轭湖当成河。换个思路:河一定在谷底。把高程当成代价图,求一条从西南走到东北、「尽量走低处」的最小代价路径,就是谷底线:
python
from skimage.graph import route_through_array
def thalweg_path(dem, start, end):
"""在 dem 上求一条"尽量走低处"的最小代价路径(行列号数组)"""
cost = (dem - dem.min() + 1.0) ** 3 # 高出谷底越多代价越大,路径贴着谷底走
path, _ = route_through_array(cost, start, end, fully_connected=True, geometric=True)
return np.array(path)
立方让高处的代价涨得很快,路径就会老老实实贴着谷底走。
4.2 沿河谷旋转切沙盘
河谷是斜的(西南---东北走向),正北朝上切一个矩形,河会从对角线穿过去,两边浪费一大片。所以对谷底线的点做一次主成分分析,用主方向当沙盘的 X 轴,算出来河谷方向是 39.6°,然后沿这个方向切一块 12×8 km 的沙盘:
python
class Sandbox:
"""沙盘坐标系:中心 (cx, cy)、河谷方向 theta(弧度,从正东逆时针)"""
def to_geo(self, X, Y):
return self.cx + X * self.c - Y * self.s, self.cy + X * self.s + Y * self.c
def resample(self, arr, order=1):
"""把下载网格上的栅格采样到沙盘网格"""
x, y = self.to_geo(self.X, self.Y)
col = (x - GX0) / PIXEL - 0.5
row = (GY0 - y) / PIXEL - 0.5
return ndi.map_coordinates(arr, [row, col], order=order, mode="nearest")
之后所有东西(高程、影像、NDVI、分区、设施)都用沙盘坐标,页面里不再关心经纬度。下图是采样出来的 7--8 月真彩色底图:

4.3 逐月 NDVI:7 个月塞进 7 个通道
每个月把所有晴空日期的 NDVI 逐像素取中值,整月都被云挡住的像素沿月份方向线性插值补上:
python
def monthly_ndvi(scenes, year):
"""某年 4--10 月逐月 NDVI:当月所有晴空日期的逐像素中值"""
by_month = {m: [] for m in MONTHS}
for d, b in scenes:
if d.year == year and d.month in by_month:
m = clear_mask(b)
nd = np.clip((b["nir"] - b["red"]) / np.maximum(b["nir"] + b["red"], 1e-4), -1, 1)
by_month[d.month].append(np.where(m, nd, np.nan))
...
4--10 月一共 7 个月,正好塞进两张 PNG:ndvi_a 的 RGBA 放 4--7 月,ndvi_b 的 RGB 放 8--10 月。每个像素值按 (NDVI + 0.2) ÷ 1.2 映射到 0--255。页面切月份时不换纹理,只改着色器里的权重(见 5.1)。
4.4 高程塞进 PNG
高程要 1 cm 精度,8 位不够。做法是减去 600 m 的基准、乘 100,变成 16 位整数,拆成高字节放 R、低字节放 G,存成无损 PNG。
页面里读的时候有个坑:浏览器解码图片默认会做颜色空间转换和 alpha 预乘,字节会被改掉,高程就全乱了。必须显式关掉:
js
const bmp = await createImageBitmap(blob, {
colorSpaceConversion: "none",
premultiplyAlpha: "none"
})
// ...画到 canvas 上取像素
for (let i = 0; i < data.length; i++)
data[i] = (px[i * 4] * 256 + px[i * 4 + 1]) / hScale + hOffset
4.5 在真河谷上排一个假牧场
牧场按 10 个轮牧单元、每个单元 4 块分区排成方格,顺着河谷两岸铺开。河道两侧留出禁牧保护带,营地、饮水点、场部和中继基站挑地势平坦的位置放。脚本按约 9 万亩的目标面积搜索单元尺寸,最后排出来暖季草场约 9.3 万亩,其中轮牧分区约 8.5 万亩,河岸保护带约 0.77 万亩:

每块分区还算好了 2025、2026 年逐月的 NDVI 均值,写进 layout.json,面板上的「长势矩阵」和「同比」直接用它。
五、three.js 场景
5.1 地形着色器:卫星图、NDVI 和牧场外降饱和
地表只有一个 ShaderMaterial,片元着色器里做三件事:
- 在牧场范围内,把卫星影像和当前月份的 NDVI 色带混合;
- 牧场外降饱和、压暗,把视线引到牧场里;
- 拉近时叠一层草地噪声(见 5.2)。
glsl
void main() {
vec3 col = texture2D(uImg, vUv).rgb;
float inside = texture2D(uMask, vUv).r;
// 当前月份的 NDVI(两个月之间按权重插值),像素值 0--1 → NDVI −0.2--1
float v = dot(texture2D(uNdviA, vUv), uWA) + dot(texture2D(uNdviB, vUv).rgb, uWB);
float ndvi = v * 1.2 - 0.2;
col = mix(col, ramp(ndvi), uNdviMix * inside);
// 牧场外:降饱和、压暗
float lum = dot(col, vec3(0.299, 0.587, 0.114));
col = mix(col, vec3(lum), uDesat * (1.0 - inside));
col *= 1.0 - uDim * (1.0 - inside);
// ......光照、雾
}
uWA、uWB 是 7 个月的权重,用点积一次取出当前月份的值。月份可以是小数,比如 6.5 就是 6 月、7 月各一半,月份播放时颜色是连续变化的:
js
export function monthWeights(month) {
const w = new Array(7).fill(0)
const f = THREE.MathUtils.clamp(month - 4, 0, 6)
const i = Math.floor(f)
const t = f - i
w[i] = 1 - t
if (i < 6) w[i + 1] = t
return {
a: new THREE.Vector4(w[0], w[1], w[2], w[3]),
b: new THREE.Vector3(w[4], w[5], w[6])
}
}

5.2 竖向放大 + 近景草地噪声
沙盘 12 km 宽,高差只有 270 m 左右,按真实比例看几乎是平的,所以高度放大了 2.4 倍,缓丘才看得出来。
另一个问题是 10 m 的影像拉近到几百米会糊成一片。解决办法是在着色器里叠几层不同尺度的值噪声,调明暗和黄绿,只在镜头靠近时淡入:
glsl
if (uDetail > 0.0) {
float near = uDetail * (1.0 - smoothstep(1200.0, 4500.0, distance(vWorld, cameraPosition)));
float n = vnoise(vWorld.xz / 3.0) * 0.5 + vnoise(vWorld.xz / 11.0) * 0.3 + vnoise(vWorld.xz / 37.0) * 0.2;
col *= 1.0 + (n - 0.5) * 0.28 * near;
col = mix(col, col * vec3(1.06, 1.03, 0.86), (vnoise(vWorld.xz / 90.0) - 0.4) * near * 0.8);
}
5.3 5780 头牛羊:远处是点,近处是模型
全景时镜头在 30 km 外,一只羊连一个像素都不到;拉近到几百米又要看得出是羊。所以牛羊有两套画法:
- 远景用
Points:一只一个点,点的大小随距离夹在 1--5 倍之间,羊群看起来是一团白; - 近景用
InstancedMesh:羊和牛各一个程序化低模,几千只一次绘制,模型放大 2.2 倍便于辨认; - 相机距离在 3600--6500 m 之间时两套交叉淡化,不会「啪」地一下切换。
js
const NEAR_IN = 3600
const NEAR_OUT = 6500
update(camDist) {
// 仿真每秒给一份状态,这里在前后两份之间按时间插值,画面不跳
const k = Math.min(1, (performance.now() - this.t0) / this.dt)
const near = THREE.MathUtils.smoothstep(camDist, NEAR_IN, NEAR_OUT)
this.pointMat.uniforms.uOpacity.value = near
this.points.visible = near > 0.01
this.sheepMesh.visible = this.cowMesh.visible = near < 0.99
// ......逐只计算位置,写进实例矩阵
}
位置怎么来?真实牧场里羊是按群管理的,只有头羊戴定位项圈;牛每头都有项圈。仿真也照这个来:羊群里每只羊的位置 = 群中心 + 一个固定的随机偏移 × 散开程度(采食时约 70 m、饮水时 40 m、夜宿时 22 m);牛直接用仿真里每头牛的坐标。


营地、饮水点、场部的低模是对着几张 AI 生成的近景概念图拼的(几个盒子、圆柱和斜屋顶),这几张图同时也是告警卡、视频墙里的「示意画面」,页面上标了「示意」:

5.4 告警效果按镜头距离缩放
告警有三样东西:竖直的光柱、地面的扩散波纹、发光的围栏段。全景时要足够醒目,拉近到几百米时又不能占满整个画面,所以每帧按镜头距离算一个缩放系数:
js
// 告警效果按镜头距离缩放:全景时醒目,拉近后不至于占满画面
const ks = THREE.MathUtils.clamp(this.camDist / 20000, 0.12, 1)
for (const o of this.fx.children) {
if (o.name === "wave") {
const k = (t % 2) / 2
o.scale.setScalar((0.6 + k * 1.4) * ks)
o.material.opacity = 0.6 * (1 - k)
} else if (o.name === "ring") o.scale.setScalar(ks)
else if (o.name === "beam") o.scale.set(ks, 1, ks)
}

5.5 地表叠加层:一张 Canvas 画所有线
围栏、分区编号、轮牧色块、放牧热力、测距线都画在一张 Canvas 上,当作地表的叠加纹理。同一张图远近共用会有问题:全景时合适的线宽,拉近后变得又粗又糊。所以镜头跨过 6 km 时重画一次,近景版线宽减半、不画大字。
5.6 性能
10 m 网格的地形有 1201×801 个顶点,约 192 万个三角形,M2 上只有 40 帧左右。地形改成隔一个顶点取一个(20 m 网格),约 51 万个三角形;10 m 的细节由影像和法线体现,肉眼看不出区别。现在 M2 上 1920×1080、2 倍像素密度稳定 60 帧。
六、仿真:让牧场按北京时间「活」着
大屏没有后端,所有数据来自一个纯 JS 的仿真器。它要满足三点:跟墙上的钟同步 、刷新页面不跳变 、数字讲得通。
6.1 时间:永远停在放牧季
大屏设定在 2026 年 7--8 月。把真实日期按「一年中的第几天」对 62 取余,映射到这 62 天里的某一天,时刻用真实的北京时间:
js
export function demoTime(ms = Date.now()) {
const p = Object.fromEntries(fmtParts.formatToParts(new Date(ms)).map((x) => [x.type, x.value]))
const realDay = Date.UTC(+p.year, +p.month - 1, +p.day)
const doy = Math.round((realDay - Date.UTC(+p.year, 0, 1)) / DAY_MS)
const dayStart = SEASON_START + (doy % SEASON_DAYS) * DAY_MS
const hours = +p.hour + +p.minute / 60 + +p.second / 3600
// ......
}
这样一年里任何一天打开,都是草最好的季节;而羊群早上出牧、中午饮水、晚上归牧的节奏,和观众手表上的时间一致。
6.2 草畜平衡:数字讲得通
面板上最重要的一个数是「草畜平衡指数」,背后的口径是:
- 羊单位:1 只成年绵羊 = 1,1 头牛 = 5;每羊单位每天吃 1.8 kg 干草;
- 产草量(干重,kg/亩)= K ×(NDVI − 0.15),NDVI 用的是这块分区真实的卫星值;
- 合理载畜量 = Σ 分区面积 × 产草量 × 利用率 50% ÷(1.8 kg × 暖季 183 天);
- 草畜平衡指数 =(实际载畜量 − 合理载畜量)÷ 合理载畜量,≤ 10% 算不过牧。
K 不是拍脑袋,而是反过来标定的:让全场指数落在 +5% 左右,再给每个单元一个压力系数,有的偏高、有的偏低,面板上才有红有绿。
轮牧也按规则走:每个单元 4 块分区依次放,一季轮 3 遍,每块放多少天按它的产草量分配。
6.3 告警:按日期播种,刷新不跳
告警要随机,但不能每次刷新都不一样。做法是每 10 分钟一个时段,用「日期 + 时段」当种子生成伪随机数,决定这个时段出不出事、出什么事:
js
function dayEvents(dayKey, plans) {
const list = []
for (let slot = 36; slot < 132; slot++) { // 6:00--22:00
const r = rng(`${dayKey}-ev-${slot}`)
if (r() > 0.3) continue // 约三成时段出一个事件
let pick = r() * total
const tpl = EVENT_KINDS.find((k) => (pick -= k.weight) < 0)
const start = (slot * SLOT_MIN + r() * 4) / 60
const dur = (8 + r() * 17) / 60
// ......越界选一头牛,离群选一个头羊项圈,低水位选一个饮水点
}
}
同一时刻算出来的数据永远一样,两块屏同时打开也一致。告警会经历「待处理 → 已派单 → 处理中 → 已解决」,越界、离群、饮水点水位低、项圈低电量、项圈离线五类。还有一条规则:畜群平时绝不进河岸保护带,只有「越界」事件才把那头牛放进去。
七、交互:巡游、告警联动、长势播放
7.1 自动巡游
大屏挂在墙上没人操作时,镜头自己巡游:全景停 14 秒,然后飞到一个畜群的近景停 9 秒,再回全景,依次轮换。有人拖动、点选就停下,空闲 45 秒后恢复。
7.2 告警联动
新告警出现时(发生在 3 分钟内,且不是页面刚打开时已有的),镜头自动飞到现场停 15 秒,选中出事的畜群,画出它近 6 小时的轨迹:
js
function linkEvent(e) {
const a = locateEvent(e, state.value, layout)
linked.value = e.id
stopTour()
sel.value = a.herd && e.kind !== "water"
? { type: "herd", id: a.herd }
: { type: "water", id: e.target }
scene.flyTo(close(a.pos, 1300, 32), 2.6)
linkTimer = setTimeout(() => {
linked.value = ""
sel.value = null
runTour()
}, LINK_HOLD * 1000)
}

右侧的告警卡上有「查看视频 / 轨迹回放 / 电话放牧员 / 关闭告警」四个处置按钮,派单状态会跟着仿真往前走。
7.3 长势播放
底部的月份条可以点任意月份,也可以从 4 月一路播到 10 月。因为月份只是着色器里的权重,播放就是用 gsap 补间一个数:
js
playMonths.tl = gsap.timeline({ onComplete: () => setMonth(state.value.t.month) })
playMonths.tl.to(proxy, {
m: 10,
duration: 8,
ease: "none",
onUpdate: () => ((month.value = proxy.m), scene.setMonth(proxy.m))
})

八、上线后被提的问题:普通浏览器窗口里,左栏底部被裁了
定稿要求「两侧栏铺满到底,不留空」,我在无头 Chrome 1920×1080 下验收全部通过。结果用户在自己的浏览器里一打开,左下角的「牧区气象」「称重日增重」两张卡被屏幕底边裁掉了:

量下来是三个原因叠加:
- 页面只按宽度缩放 。全站用
amfe-flexible设 rem = 视口宽 ÷ 10。浏览器窗口去掉标签栏、地址栏后几乎总比 16:9 扁,宽度决定的高度放不下; - 模板里写死的 px 不会被转换 。
postcss-pxtorem只处理 CSS,走势小图的<svg width="110">、环图的内联style都是绝对像素,屏幕不是 1920 宽时它们相对变大; - 侧栏本来就卡在极限。1920×1080 下左栏五张卡就比可用高度多 3 px,文字行高按整像素取整,不同缩放下还会多出几像素。
对应三处修改。第一处,这个页面挂载期间改成按宽、高取小:
js
// rem = min(宽, 高 × 16/9) / 10:窗口偏扁时界面整体按高度缩、两侧栏贴边
function fitRem() {
const el = document.documentElement
const w = Math.min(el.clientWidth, (el.clientHeight * 16) / 9)
el.style.fontSize = `${w / 10}px`
}
amfe-flexible 的 resize 监听先注册,本页的后注册、后执行,正好覆盖它的结果;离开页面时还原成按宽度。三维标注的锚点和镜头偏移也改用同一个比例换算,否则标签会和地图错开。
第二处,走势小图和环图的尺寸换算成 rem。第三处,给侧栏加一个兜底:内容超出可用高度时整栏等比缩小。
js
function fitColumn(col) {
const st = col.style
st.transform = st.height = st.bottom = ""
const avail = col.clientHeight
const last = col.lastElementChild
// 最后一张卡平时伸展填满剩余高度,量自然高度时先不让它伸展
last.style.flexGrow = "0"
const need = col.scrollHeight
last.style.flexGrow = ""
if (need <= avail + 1) return
st.bottom = "auto"
st.height = `${need}px`
st.transform = `scale(${avail / need})`
}
// 窗口缩放、卡片内容变化(告警出现 / 消失)都会改变高度,统一由 ResizeObserver 触发
修完在 6 种窗口尺寸下量了一遍,两栏最后一张卡都不超出底部卡片下沿:
| 窗口 | 左栏处理 |
|---|---|
| 1920×1080 | 不缩放,和定稿逐像素一致 |
| 2000×1117 | 缩 0.3% |
| 1920×960 | 缩 1.5% |
| 1512×845 | 缩 1.7% |
| 2560×1300 | 缩 0.3% |
| 1280×800 | 不缩放(窗口比 16:9 高) |
另外还测了浏览器最小字号设成 12px 的情况:小字被强制放大,左栏缩到约 78%,也能完整显示。
教训:侧栏排满到底的大屏,验收时一定要在几种「扁」的浏览器窗口里量一遍,不能只看 1920×1080。
九、和设计稿逐区比对
设计稿先出了 A、B、C 三个方向,选了「A 晴空牧场」;第一版被嫌「功能太少、数据单一」,加了模块;又被要求「两侧栏下方不要留空」,补满后定稿。
有一点做得比较省事:设计稿 HTML 直接调用页面同一份 RanchScene 渲染三维,面板是手写的 HTML。所以定稿里的地形、色彩、机位和页面天然一致,实现阶段主要是把面板搬进 Vue。
验收时用 ?at=2026-07-25T13:58 把仿真固定在定稿那一刻,无头 Chrome 截图,再用 Python 脚本逐区比对:


| 区域 | 指标 | 结果 |
|---|---|---|
| 天空 | 平均色差 ΔE | 0.01 |
| 沙盘中部 | 平均色差 ΔE | 1.0 |
| 沙盘前沿 | 平均色差 ΔE | 0.01 |
| 侧壁 | 平均色差 ΔE | 0.0 |
| 顶部指标卡 | 超差像素 | 2.09% |
| 左栏 | 超差像素 | 2.50% |
| 功能导航 / 工具条 / 底部卡片 / 右栏 | 超差像素 | 4.4%--11.4% |
地表四块取色区的色差都在 1 以内。面板里超出 3% 门槛的几块,主要是所有文字约 1 像素的基线偏移,加上秒数、告警闪烁这些实时变化;右栏是我主动改成和底部卡片下沿对齐(定稿里它多出 10 px),肉眼看不出区别。这也说明逐像素门槛对 1 px 的偏移太敏感,比对脚本还需要加一点位移容忍。
十、踩坑汇总
| 现象 | 原因 | 解决 |
|---|---|---|
| NDVI 出现 1.27 | 某一景的反射率偏移标记错了,被多扣了一次 | 跳过标记不一致的景,要求反射率为正,NDVI 截到 −1, 1 |
| 找河道找成了湖 | 河太细,水体指数抓到旁边的牛轭湖 | 用地形求谷底线定河道 |
| 牧场方格放不进沙盘 | 河谷是斜的,正北朝上的矩形浪费一半 | 主成分分析求河谷方向,沿河谷旋转切沙盘 |
| STAC 检索经常读断 | 默认响应约 1.6 MB,跨境网络不稳 | fields 只取需要的字段,加重试和本地缓存 |
| OSM Overpass 连不上 | 国内网络 | OSM 改成可选数据 |
| 高程全乱 | 浏览器解码图片时做了颜色转换和预乘 | createImageBitmap 关掉 colorSpaceConversion、premultiplyAlpha |
| 面板和设计稿差一截 | Tailwind 基础样式把行高设成 1.5 | 页面根节点 line-height: normal |
| 拉近后围栏又粗又糊 | 叠加层远近共用一套线宽 | 镜头跨过 6 km 换「近景细线版」 |
| 拉近后告警光柱占满画面 | 尺寸按全景定的 | 按镜头距离缩放 |
| 只有 40 帧 | 地形 192 万个三角形 | 隔点取 20 m 网格,约 51 万个,60 帧 |
| 浏览器窗口里左栏被裁 | rem 只按宽度 + 模板 px 不转换 | rem 按宽高取小、模板尺寸改 rem、侧栏兜底缩放 |
十一、总结
回头看,这个项目有几个做法值得记下来:
- 真实数据打底,虚构业务挂在上面:地形、影像、长势都是真的,牧场是假的,并且在页面底部、文章里如实标注;
- 脚本流水线 :页面用到的数据全部由
prepare.py生成,换一块草原只要改一个经纬度; - 大屏至少要有远、近两套画法:叠加层、牛羊、告警效果都要分远近,否则全景好看的东西拉近就露馅;
- 别只在 1920×1080 下验收:墙上的屏是 16:9,但第一个看到它的人,多半是在一个浏览器窗口里。
你们做大屏的时候,用过真实的遥感数据吗?欢迎在评论区聊聊。
说明:地形 Copernicus GLO-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018;影像与 NDVI:Contains modified Copernicus Sentinel data 2025--2026。牧场布局、牛羊和运行数据均为演示,近景概念图为 AI 生成的示意画面。