GEE中利用ee.Reducer.sum().group()高效统计土地分类面积

发布时间:2026/10/14 6:52:36
GEE中利用ee.Reducer.sum().group()高效统计土地分类面积 做土地分类项目时最常被问的一句话就是每一类占了多少平方公里在 GEE 里统计分类面积的方式不少但如果你的分类结果是整幅整数值影像又不想为每个类别各写一遍 filter 和 reduceRegion用ee.Reducer.sum().group()一步算完是目前我最推荐的方案。这篇就以北方某地级市下称目标城市的行政区边界为例把 API 原理、完整代码、面积换算和常见坑一次性讲清楚。想直接抄作业的跳到第 3 节想搞清楚为什么这么写、避免面积算错的建议从头看。我默认你已经知道什么是土地分类结果也大概知道 GEE 的基本操作。如果你连ee.Image和ee.FeatureCollection都还没概念建议先把 asset 上传、Map 可视化、影像波段这几个基础过一遍再回来看不然后面的代码会有点吃不消。1. 为什么统计分类面积会这么麻烦1.1 面积统计的本质不是“数格子”土地分类结果在 GEE 里并不是一张直接给你“XX类多少平方公里”的表格而是一张栅格图层。每个像元只有一个整数值比如 1 代表耕地、2 代表林地、3 代表建设用地。你要做的是把这张栅格和行政区边界叠在一起再把每个类别对应的面积累加起来。听起来很简单但这里有一个非常隐蔽的坑像素的几何尺寸在不同投影、不同纬度下是不一样的。很多人刚学遥感时习惯“数像元个数乘分辨率”例如 30 米分辨率1000 个像元就是 30 × 30 × 1000 900000 平方米。这个算法在地面开阔、范围不大、又恰好用等积投影时勉强能用但放到 GEE 里面对全球影像、Web 墨卡托投影、高纬度区域时误差会大到让你怀疑人生。正确的做法是使用ee.Image.pixelArea()。它返回一张影像每个像元的值就是这个像元实际对应的地球表面面积单位是平方米并且已经考虑了投影和椭球因素。你需要做的是让这个面积影像参与 reducer 的计算而不是自己去数格子。1.2 逐类统计的痛点刚开始用 GEE 统计面积时我写过很长一段“老实人代码”先把类别列表写死然后对每个类别分别updateMask(classImage.eq(i))再逐个reduceRegion求和。代码大概是这种画风var areaList []; for (var i 0; i classValues.length; i) { var oneClass classImage.updateMask(classImage.eq(classValues[i])) .multiply(ee.Image.pixelArea()); var area oneClass.reduceRegion({ reducer: ee.Reducer.sum(), geometry: boundary, scale: 30, maxPixels: 1e13 }); areaList.push(area); }这段代码最大的问题不是逻辑错而是太啰嗦、太脆弱。类别一多请求次数直线上升如果有一天你调整了分类方案类别从 8 类变成 12 类你还得回去改循环里的列表。更麻烦的是这种写法很容易在并发请求多的时候触发 GEE 的限流或超时。ee.Reducer.sum().group()解决的就是这个痛点它允许你在一次 reduce 操作里把“求和”和“按类别分组”两件事同时完成最后返回一个“类别 → 面积”的列表。代码更短请求更少逻辑也更统一。2. 核心 API 原理解析2.1 sum() 和 group() 是怎么配合的ee.Reducer是 GEE 里所有降维操作的核心。sum()很好理解就是把一堆像元的值加起来。group()则是一个“包装器”它会让包裹在里面的 reducer 先按某个输入波段的值分组然后在每一组里分别执行聚合。我用一个生活化的类比来解释假设你手里有一张全班成绩单里面有“班级”“分数”两列。普通的sum()是把所有分数加在一起得到全年级总分sum().group(1)是先把成绩单按班级分类再分别计算每个班级的总分。最后你得到的不是单个数字而是一张“班级 → 总分”的对照表。在 GEE 里group()必须指定三样东西groupField用哪个输入波段来分组keyName分组结果里代表组别名称的字段叫什么keyValue分组结果里代表聚合数值的字段叫什么2.2 波段顺序和 groupField 是最容易写错的地方看下面这行代码var areaClassImage ee.Image.pixelArea().addBands(classImage.select(lc));这个影像有两个波段第一个波段是“每个像元的面积”第二个波段是“土地类别”。groupField的意思就是让 reducer 把输入波段按索引理解。我们习惯性的把面积波段放在第一位、类别波段放在第二位所以groupField写1表示“按第二个波段来分组对第一个波段求和”。很多人在这一步容易反向有人把areaClassImage写成了classImage.addBands(ee.Image.pixelArea())然后groupField还写成1结果变成按面积分组面积字段被切成了无数个组类别反而不见了。这不是 GEE 的 bug是你优先进来的波段是类别面积跑到第二位去了。我给你的可复用套路是先ee.Image.pixelArea()再.addBands(classImage)然后groupField: 1这个组合在我实际项目里验证过很多次出错概率最低。如果你非要把类别放第一个那groupField要改成0但我不建议这么干因为团队里其他人接手时很容易看错。2.3 用 pixelArea() 算面积不要用格子数乘分辨率我在第 1 节已经提过“数格子乘分辨率”的局限性这里再往深说一点。GEE 里默认的显示投影通常不是等积投影尤其当你在浏览器里看全球影像时高纬度地区的像元会被拉得非常宽。这时如果还用“像元宽度 × 像元高度”来算面积每个像元都会被当成一个各边长度相等的方块实际面积其实远远小于你的估算值。ee.Image.pixelArea()返回的是每个像元的真实地面面积GEE 在生成时会按影像所在投影和参考椭球计算。因此用面积波段去参与sum()比用像元个数乘以分辨率的做法稳健得多。另外要注意scale参数。scale表示这次统计使用的空间分辨率单位是米。分类影像如果是 30 米分辨率你就应该写scale: 30。如果你漏写了GEE 可能使用影像的默认影像投影有时候会是一个很离谱的尺度比如 1 度左右算出来的面积会完全不准。3. 完整实操以北方某地级市为例3.1 先准备边界和分类影像我这次没有把边界写死在代码里因为你的数据和我的数据大概率不一样。你只需要准备两个东西目标城市的面状边界一个FeatureCollection一张土地分类影像波段名建议叫lc类别是整数值边界资产我建议用你自己的 asset。在 GEE 里点Assets→New→Shape file上传即可也可以把 GeoJSON 转成表格后导入。分类影像可以是你用随机森林、CART、面向对象分类等算法得到的输出也可以是某个公开土地覆盖产品里的主导类别波段。有两个细节值得注意如果你的分类影像类别是浮点型建议先toInt()或toDouble()避免分组时因为小数精度问题出现一堆“看起来相同但值不一样”的奇怪类别。如果你的分类影像有很多波段记得select(lc)只保留你要用的类别波段。否则addBands会把所有波段都叠进来groupField的索引就乱了。3.2 单个行政区最小可用代码先看一个目标城市整体统计的版本这是最简核心代码// 1. 目标城市边界来自个人上传的 asset替换成你自己的 var targetCity ee.FeatureCollection(users/yourname/target_city_boundary); // 2. 分类结果影像波段名 lc类别为整数值 var landcover ee.Image(projects/yourproject/landcover_demo); // 3. 面积波段 类别波段 var areaClassImage ee.Image.pixelArea().addBands(landcover.select(lc)); // 4. 按类别分组对面积求和 var stats areaClassImage.reduceRegion({ reducer: ee.Reducer.sum().group({ groupField: 1, keyName: class, keyValue: area }), geometry: targetCity.geometry(), scale: 30, maxPixels: 1e13, bestEffort: true, tileScale: 4 }); print(原始统计结果, stats); // 5. 转成平方公里输出 stats.get(groups).evaluate(function(groups) { groups.forEach(function(g) { print(类别, g[class], 面积(m²), g[area], ≈, (g[area] / 1e6).toFixed(2), km²); }); });这里每一步都有讲究。stats是一个Dictionary里面只有一个键groups对应的值是一个列表。列表里每一项是一个字典例如[ {class: 1, area: 12345678.9}, {class: 2, area: 5432109.8} ]evaluate是客户端异步回调它会在服务器算完后把结果返回到浏览器里。对于小面积、低分辨率的数据你也可以直接print(stats.get(groups))然后手动打开控制台看但我觉得用evaluate转成平方公里更直观。3.3 按区县或乡镇批量统计实际业务里领导往往不只是要整个城市的总面积而是要每个区县分别的面积。这时候reduceRegion就不够了得用reduceRegions配合FeatureCollection。核心代码如下var resultFC areaClassImage.reduceRegions({ collection: targetCity, // 每个区县一个面务必保留一个名称字段 reducer: ee.Reducer.sum().group({ groupField: 1, keyName: class, keyValue: area }), scale: 30, tileScale: 4, bestEffort: true }); Map.addLayer(resultFC, {}, 分区县统计结果); // 查看第一个区县的分类面积明细 resultFC.first().get(groups).evaluate(function(groups) { groups.forEach(function(g) { print(第一个区县 - 类别, g[class], g[area]); }); });reduceRegions会把每个面要素单独做一次 reduce然后把结果写回到对应要素的属性里。注意它生成的groups不是一个普通数值属性而是一个列表。如果你只想看一眼print(resultFC)就够了如果你想导出成表格直接导出的 CSV 里groups列会是一长串类似 JSON 的字符串虽然能看但不适合直接做透视统计。3.4 结果导出与列转置如果你想得到“一个区县一行每个类别一列”的宽表可以在reduceRegions之后做一个map把groups列表展开成多个属性。这里有一段我常用的转换代码var wideFC resultFC.map(function(f) { var groups ee.List(f.get(groups)); var kv groups.map(function(g) { var d ee.Dictionary(g); return ee.List([ ee.String(class_).cat(ee.String(d.get(class))), d.get(area) ]); }).flatten(); return f.select([]).set(ee.Dictionary(kv)); }); print(wideFC); Export.table.toDrive({ collection: wideFC, description: area_by_class_per_district, fileFormat: CSV });这段代码本质上做的事情是把[{class: 1, area: 123}, {class: 2, area: 456}]变成{class_1: 123, class_2: 456}然后合并到原来的要素属性里。这样导出的 CSV 就是一行一个区县列名是class_1、class_2这样的宽表结构。如果你不追求宽表只想导出最原始的groups那直接Export.table.toDrive(resultFC, ...)也完全可以。只是你要接受后续自己写脚本解析字符串。3.5 Python 版核心代码也差不多很多人现在用 Python 版的 GEE也就是在 Colab 或本地环境里通过geemap操作。逻辑几乎一模一样区别主要是客户端打印用getInfo()更常见import ee ee.Initialize() target_city ee.FeatureCollection(users/yourname/target_city_boundary) landcover ee.Image(projects/yourproject/landcover_demo) area_class ee.Image.pixelArea().addBands(landcover.select(lc)) stats area_class.reduceRegion( reduceree.Reducer.sum().group(groupField1, keyNameclass, keyValuearea), geometrytarget_city.geometry(), scale30, maxPixels1e13, bestEffortTrue, tileScale4 ) # getInfo() 在 Python 里更直白 result stats.get(groups).getInfo() for g in result: print(g[class], g[area] / 1e6, km²)如果你用geemap还能直接把resultFC加载到底图上查看交互体验会比 Console 好不少。但要注意Python 环境下如果数据量特别大尽量用Export.table.toDrive(...)异步导出不要卡在getInfo()上等半天。4. 高频问题与排查技巧4.1 统计结果为空或者只有一条数据遇到空结果先不要怀疑 GEE 坏了大概率是你的波段顺序和groupField没对上。前面说过ee.Image.pixelArea()是第一个波段landcover.select(lc)是第二个波段groupField: 1是按第二个波段分组。如果你把groupField写成了0那就会按面积波段去分“无数个组”出来一个几乎不可读的结果。还有一种情况是分类影像里所有像元都被掩膜了。比如你只统计某个小县但分类影像在边界外全是 NoData边界内又恰好没有有效像元。这时候建议先Map.addLayer(landcover, {}, landcover)可视化看一眼确认目标区域内有值。4.2 面积数值偏大或偏小面积偏大最典型的原因是scale没写对。我见过有人直接把scale: 1000写上去这等于把 30 米分辨率的分类影像重采样到 1000 米去统计结果当然会非常离谱。scale应当跟你的分类影像原始分辨率尽量一致差的太远时GEE 会用重采样后的像元来参与统计误差很难估计。面积偏小则要检查你是不是用了别的投影。比如你设置了一个自定义坐标系而分类影像本身是地理坐标系那么在边界附近可能出现像素被截断的情况。常规做法是不要把投影玩得太花统计面积时老老实实用默认的pixelArea()和合理scale结果最稳定。4.3 各类别面积加起来比城市总面积还大这是一个很经典的问题为什么所有类别面积加总之后会超过行政区总面积最常见的原因是分类影像里有大量无效值被当成了一个类别。很多土地覆盖产品的类别编码是从 0 开始的0 在数据里代表“无数据”或“背景”如果你的 boundary 边缘又没有做掩膜那些边界外的背景像元也会被统计进来。解决办法是统计前先给分类影像做一次掩膜或者直接用reduceRegions的geometry去限制范围再不行就clip一下var landcoverClip landcover.clip(targetCity); var areaClassImage ee.Image.pixelArea().addBands(landcoverClip.select(lc));clip不会改变像元分辨率只是把边界外的部分裁掉能有效减少背景像元混入。4.4 groups 属性在导出后变成一长串字符串这个问题在 CSV 导出时特别常见groups属性是个List导出后基本就是一个带大括号的字符串。你拿到本地后想用 Excel 透视会比较痛苦。我的习惯是在导出前用第 3.4 节的map代码把宽表转出来。虽然看起来多了一段函数但导出的结果非常干净每个类别一列后续汇总、画图、入库都方便。如果你不是特别在意表格结构也可以把结果输出成 GeoJSONgroups字段在 GeoJSON 里也是可用的结构化属性很多 GIS 软件能直接识别。4.5 大范围请求超时或内存溢出group()在统计大量类别、大面积区域时会比普通sum()消耗更多资源。如果区域特别大或者分类影像分辨率很高建议在reduceRegion/reduceRegions里调大maxPixels和tileScale。maxPixels表示允许 reducer 处理的最大像元数面积大、分辨率高时很容易超过默认值我一般会写1e13。bestEffort: true会在超过maxPixels时自动调整 scale但注意它可能会改变精度如果你的统计结果要用于正式报告最好还是给足maxPixels。tileScale是控制分块并行粒度的参数调大可以缓解内存压力通常1-4够用太大会降低速度。5. 扩展思路与实操体会5.1 从面积统计扩展到占比和图表算出各类面积之后下一步通常是要画占比饼图或柱状图。groups列表本身就是现成的数据源你可以直接用evaluate拿到 JS 数组再送给Chart或者导出到 Excel。画图时注意一件事无效值的类别是否要剔除如果不剔除饼图上会出现一个占比很大的“背景”类很容易误导。5.2 多期影像统计与变化检测如果你有两年或多年的分类影像完全可以对每一期分别跑一次group()然后把结果按年份拼在一起。比如第一期和第二期的建设用地面积都在上涨那就是一个很直观的城市扩张信号。更进一步你还可以在统计面积的同时统计“类别转移”的情况但那个要用的就不是简单group()了而是交叉制表和矩阵计算属于另一个主题。5.3 一次跑多个城市或省份reduceRegions的优点是可以把一个包含多个面要素的FeatureCollection一次处理完。你把每个区县、每个乡镇甚至多个地级市都放到同一个集合里每个面保留一个名称字段跑完之后结果里自然就有名称、类别、面积三个维度的信息。唯一要注意的是面要素数量太多、几何太复杂时建议拆成几个批次跑避免单次任务太重。5.4 一点个人体会用ee.Reducer.sum().group()最大的收益不只是少写几行代码而是把统计逻辑统一了。以前逐类统计时一类一类地跑中间一旦出错很难排查分组统计后每一次结果都是完整的一张表哪一类不对直接看对应条目就行。我在实际项目里踩过最深的坑就是波段顺序。后来我给自己定了一个规矩凡是涉及面积分组统计一律写ee.Image.pixelArea().addBands(...)然后groupField: 1不管后端换了谁接手代码都一目了然。再配合pixelArea()而不是手工乘分辨率面积结果基本都能一次通过质检。最后分享一个小技巧给keyName和keyValue起名字时尽量用class和area这种语义明确的短字段。别用a、b这种缩写因为groups列表在导出后会被很多下游工具读取字段名清晰能省掉很多沟通成本。