[{"file": "AOD_气溶胶_S5P.py", "name": "AOD_气溶胶_S5P", "desc": "Sentinel-5P 吸收性气溶胶指数（紫外气溶胶 AI）", "lines": 11, "code": "# @gee-desc: Sentinel-5P 吸收性气溶胶指数（紫外气溶胶 AI）\nimport ee\n\ns5p = (ee.ImageCollection('COPERNICUS/S5P/OFFL/L3_AER_AI')\n       .filterDate('2025-06-01', '2025-09-30')\n       .filterBounds(aoi)\n       .select('absorbing_aerosol_index'))\nm.check(s5p.size(), '匹配影像数')\naod = s5p.mean().rename('AOD')\nm.add_layer(aod, {'min': -1, 'max': 3,\n                  'palette': ['313695', '74add1', 'ffffbf', 'f46d43', 'a50026']}, '吸收性气溶胶指数')"}, {"file": "AWEI_自动水体指数.py", "name": "AWEI_自动水体指数", "desc": "AWEI 自动水体指数（对阴影、暗色地表比 NDWI 更稳）", "lines": 26, "code": "# @gee-desc: AWEI 自动水体指数（对阴影、暗色地表比 NDWI 更稳）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\ncomp = s2.map(clean).median()\n\n# AWEIsh（无阴影自动版）：4*(绿-SWIR1) - (0.25*近红外 + 2.75*SWIR2)，>0 为水\nawei = comp.expression(\n    '4 * (GREEN - SWIR1) - (0.25 * NIR + 2.75 * SWIR2)',\n    {'GREEN': comp.select('B3'), 'SWIR1': comp.select('B11'),\n     'NIR': comp.select('B8'), 'SWIR2': comp.select('B12')}\n).rename('AWEI')\n\nm.add_layer(awei, {'min': -0.5, 'max': 0.5,\n                   'palette': ['8c510a', 'f6e8c3', 'ffffff', '80cdc1', '01665e']},\n            'AWEI')\nm.add_layer(awei.gt(0).selfMask(), {'palette': ['1d91c0']}, '水体(AWEI>0)')"}, {"file": "CO_一氧化碳_S5P.py", "name": "CO_一氧化碳_S5P", "desc": "Sentinel-5P CO 柱浓度（mol/m²；生物质燃烧与不完全燃烧的指示）", "lines": 13, "code": "# @gee-desc: Sentinel-5P CO 柱浓度（mol/m²；生物质燃烧与不完全燃烧的指示）\nimport ee\n\nco = (ee.ImageCollection('COPERNICUS/S5P/NRTI/L3_CO')\n      .filterDate('2024-06-01', '2024-09-01')\n      .filterBounds(aoi))\nm.check(co.size(), '匹配影像数')\n\ncol = co.select('CO_column_number_density').median()\n\nm.add_layer(col, {'min': 0, 'max': 0.05,\n                  'palette': ['000004', '3b0f70', '8c2981', 'de4968', 'fe9f6d', 'fcfdbf']},\n            'CO 柱浓度(mol/m²)')"}, {"file": "EVI_增强植被指数.py", "name": "EVI_增强植被指数", "desc": "EVI 增强植被指数（蓝波段参与去气溶胶，高生物量区比 NDVI 不易饱和）", "lines": 25, "code": "# @gee-desc: EVI 增强植被指数（蓝波段参与去气溶胶，高生物量区比 NDVI 不易饱和）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\ncomp = s2.map(clean).median()\n\n# ★ EVI 的常数项 \"+1\" 是反射率单位下的，所以必须先除以 10000 定标；\n#   拿裸 DN 直接套这个公式，结果会接近 0（这也是\"比值指数不用定标、EVI 要定标\"的由来）\nevi = comp.expression(\n    '2.5 * (NIR - RED) / (NIR + 6 * RED - 7.5 * BLUE + 1)',\n    {'NIR': comp.select('B8'), 'RED': comp.select('B4'), 'BLUE': comp.select('B2')}\n).rename('EVI')\n\nm.add_layer(evi, {'min': -0.2, 'max': 0.8,\n                  'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']},\n            'EVI')"}, {"file": "FVC_像元二分模型.py", "name": "FVC_像元二分模型", "desc": "植被覆盖度 FVC（像元二分模型）：NDVI 归一化到 [0,1]", "lines": 20, "code": "# @gee-desc: 植被覆盖度 FVC（像元二分模型）：NDVI 归一化到 [0,1]\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nndvi = s2.median().normalizedDifference(['B8', 'B4']).rename('NDVI')\n\n# 端元 NDVI_soil / NDVI_veg：理论上取研究区 NDVI 直方图的 5% / 95% 分位数，\n# 这里给的是北方地区的常用经验值，按自己研究区改（改完点「保存」：内置样板是只读的，会自动转存到「历史」）。\nNDVI_SOIL, NDVI_VEG = 0.05, 0.85\n\nfvc = (ndvi.subtract(NDVI_SOIL)\n       .divide(NDVI_VEG - NDVI_SOIL)\n       .clamp(0, 1)\n       .rename('FVC'))\nm.check(fvc.bandNames(), '输出波段')\nm.add_layer(fvc, {'min': 0, 'max': 1,\n                  'palette': ['ffffff', 'd9f0c0', '7cc35a', '2f8f3f', '0b5d2a']}, 'FVC')"}, {"file": "GPP_植被生产力.py", "name": "GPP_植被生产力", "desc": "MODIS 年总初级生产力 GPP（MOD17A2H：8 天累计 × 1e-4，年求和）", "lines": 15, "code": "# @gee-desc: MODIS 年总初级生产力 GPP（MOD17A2H：8 天累计 × 1e-4，年求和）\nimport ee\n\ngpp = (ee.ImageCollection('MODIS/061/MOD17A2H')\n       .filterDate('2024-01-01', '2025-01-01')\n       .filterBounds(aoi))\nm.check(gpp.size(), '匹配影像数')\n\n# ★ Gpp 的比例因子是 1e-4，单位 kg C/m²，且每景是 **8 天累计**（一年 46 景）。\n#   所以「年总量」= sum × 1e-4；用 mean 得到的是\"平均 8 天\"，量级差 46 倍。\nannual = gpp.select('Gpp').sum().multiply(0.0001)\n\nm.add_layer(annual, {'min': 0, 'max': 2,\n                     'palette': ['ffffff', 'c7e9c0', '74c476', '238b45', '00441b']},\n            '2024 年 GPP(kg C/m²)')"}, {"file": "HLS_跨传感器合成.py", "name": "HLS_跨传感器合成", "desc": "HLS 跨传感器（Landsat+Sentinel-2 统一到 30m NBAR）；窄带近红外 L30=B5 / S30=B8A", "lines": 34, "code": "# @gee-desc: HLS 跨传感器（Landsat+Sentinel-2 统一到 30m NBAR）；窄带近红外 L30=B5 / S30=B8A\nimport ee\n\nl30 = (ee.ImageCollection('NASA/HLS/HLSL30/v002')\n       .filterDate('2024-05-01', '2024-09-30').filterBounds(aoi))\ns30 = (ee.ImageCollection('NASA/HLS/HLSS30/v002')\n       .filterDate('2024-05-01', '2024-09-30').filterBounds(aoi))\nm.check(l30.size(), 'Landsat 侧景数')\nm.check(s30.size(), 'Sentinel-2 侧景数')\n\n\ndef clear(img):\n    # Fmask：bit1=云、bit2=邻云、bit3=云影、bit4=雪 —— 这四个都要掩掉\n    fm = img.select('Fmask')\n    return img.updateMask(fm.bitwiseAnd(0b11110).eq(0))\n\n\ndef ndvi_l(img):\n    return clear(img).normalizedDifference(['B5', 'B4']).rename('NDVI')\n\n\ndef ndvi_s(img):\n    # ★ HLS 的一致性在于\"都用窄带近红外\"：L30 是 B5、S30 是 B8A。\n    #   图省事在 S30 上用宽带 B8，等于把两种光谱响应混在一个序列里。\n    return clear(img).normalizedDifference(['B8A', 'B4']).rename('NDVI')\n\n\nboth = l30.map(ndvi_l).merge(s30.map(ndvi_s))\nm.check(both.size(), '合并后总景数')\ncomp = both.median().clip(aoi)\n\nm.add_layer(comp, {'min': 0, 'max': 0.9,\n                   'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']},\n            'HLS 跨传感器 NDVI')"}, {"file": "LAI_MODIS_叶面积指数.py", "name": "LAI_MODIS_叶面积指数", "desc": "MODIS LAI 叶面积指数（8 天 500m），生态质量与长势的常用指标", "lines": 19, "code": "# @gee-desc: MODIS LAI 叶面积指数（8 天 500m），生态质量与长势的常用指标\nimport ee\n\nSTART, END = '2025-06-01', '2025-09-30'\n\ncol = (ee.ImageCollection('MODIS/061/MOD15A2H')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), '8 天合成期数')\n\n# ★ 必须先掩掉填充值：Lai_500m 的无效值是 249~255，直接求均值会被暗抬一截\ndef valid_lai(img):\n    lai = img.select('Lai_500m')\n    return lai.updateMask(lai.lt(100))\n\nlai = col.map(valid_lai).mean().multiply(0.1).rename('LAI')      # 比例因子 0.1\nm.add_layer(lai, {'min': 0, 'max': 6,\n                  'palette': ['ffffff', 'c7e9c0', '74c476', '238b45', '00441b']},\n            'LAI(叶面积指数)')"}, {"file": "LST_地表温度_Landsat.py", "name": "LST_地表温度_Landsat", "desc": "Landsat 8/9 L2 地表温度（ST_B10 定标 → 摄氏度；L2 已做发射率校正，不需 K1/K2）", "lines": 17, "code": "# @gee-desc: Landsat 8/9 L2 地表温度（ST_B10 定标 → 摄氏度；L2 已做发射率校正，不需 K1/K2）\nimport ee\n\nl8 = (ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUD_COVER', 20)))\nm.check(l8.size(), '匹配影像数')\n\n# L2 产品的 ST_B10 是定标后的 DN：K = DN * 0.00341802 + 149.0\nlst = (l8.median().select('ST_B10')\n       .multiply(0.00341802).add(149.0)\n       .subtract(273.15)\n       .rename('LST'))\nm.add_layer(lst, {'min': 15, 'max': 45,\n                  'palette': ['040274', '3f7fb5', 'b7e5b0', 'f7e7a0', 'e8703a', 'a50026']},\n            'LST(摄氏度)')"}, {"file": "LST_夜间地温.py", "name": "LST_夜间地温", "desc": "MODIS 夜间地表温度（夜 LST 受太阳照射影响小，更适合看城市热岛强度）", "lines": 14, "code": "# @gee-desc: MODIS 夜间地表温度（夜 LST 受太阳照射影响小，更适合看城市热岛强度）\nimport ee\n\nmod = (ee.ImageCollection('MODIS/061/MOD11A2')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(mod.size(), '匹配影像数')\n\n# ★ MOD11A2 的比例因子是 0.02，单位 K；减 273.15 得 ℃\nnight = mod.select('LST_Night_1km').mean().multiply(0.02).subtract(273.15)\n\nm.add_layer(night, {'min': 10, 'max': 35,\n                    'palette': ['040274', '2473b5', '7cb8a2', 'f2d349', 'd64a34', 'a50026']},\n            '夜间地表温度(℃)')"}, {"file": "Landsat_反射率_云掩膜.py", "name": "Landsat_反射率_云掩膜", "desc": "Landsat 8/9 L2 地表反射率 + QA_PIXEL 云掩膜（定标 2.75e-05 / 偏移 -0.2）", "lines": 23, "code": "# @gee-desc: Landsat 8/9 L2 地表反射率 + QA_PIXEL 云掩膜（定标 2.75e-05 / 偏移 -0.2）\nimport ee\n\nl8 = (ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUD_COVER', 40)))\nm.check(l8.size(), '匹配影像数')\n\ndef mask_qa(img):\n    # 官方 QA_PIXEL 位定义：bit3=云、bit4=云影、bit5=雪\n    qa = img.select('QA_PIXEL')\n    bad = (qa.bitwiseAnd(1 << 3).neq(0)\n           .Or(qa.bitwiseAnd(1 << 4).neq(0))\n           .Or(qa.bitwiseAnd(1 << 5).neq(0)))\n    return img.updateMask(bad.Not())\n\n# C2 L2 的 SR 是 DN：反射率 = DN * 2.75e-05 - 0.2（不是 Sentinel-2 的 0.0001）\nsr = (l8.map(mask_qa).median()\n      .select(['SR_B4', 'SR_B3', 'SR_B2'])\n      .multiply(0.0000275).add(-0.2))\nm.add_layer(sr, {'bands': ['SR_B4', 'SR_B3', 'SR_B2'], 'min': 0.0, 'max': 0.3},\n            '真彩色(地表反射率)')"}, {"file": "MNDWI_改进水体.py", "name": "MNDWI_改进水体", "desc": "MNDWI 改进水体指数（绿 vs 短波红外，抗建筑阴影），默认 >0.1 判水", "lines": 16, "code": "# @gee-desc: MNDWI 改进水体指数（绿 vs 短波红外，抗建筑阴影），默认 >0.1 判水\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nm.check(s2.size(), '匹配影像数')\ncomp = s2.median()\n\n# 比 NDWI(B3/B8) 强的地方：短波红外把建筑物和裸地压下去，阴影不再被误判成水\nmndwi = comp.normalizedDifference(['B3', 'B11']).rename('MNDWI')\nwater = mndwi.gt(0.1).selfMask()\n\nm.add_layer(comp, {'bands': ['B4', 'B3', 'B2'], 'min': 0, 'max': 3000}, '真彩色')\nm.add_layer(water, {'palette': ['0066ff']}, '水体(MNDWI>0.1)')"}, {"file": "MODIS_NDVI长时序.py", "name": "MODIS_NDVI长时序", "desc": "MODIS 16 天 NDVI 长时序（MOD13A2，×1e-4 并按 SummaryQA 去云）", "lines": 20, "code": "# @gee-desc: MODIS 16 天 NDVI 长时序（MOD13A2，×1e-4 并按 SummaryQA 去云）\nimport ee\n\nmod = (ee.ImageCollection('MODIS/061/MOD13A2')\n       .filterDate('2024-01-01', '2025-01-01')\n       .filterBounds(aoi))\nm.check(mod.size(), '匹配影像数')\n\ndef clean(img):\n    # SummaryQA：0=好、1=勉强可用、2=雪/冰、3=云 —— 只留前两档\n    qa = img.select('SummaryQA')\n    return img.updateMask(qa.lte(1)).select('NDVI').multiply(0.0001)\n\n# MODIS 的 NDVI 是 1e-4 定标（和 Landsat C2 的 2.75e-05、Sentinel-2 的 1e-4 都不同，\n# 三个数据源三套系数，混用是最常见的量级错误）\nndvi = mod.map(clean).median()\n\nm.add_layer(ndvi, {'min': 0, 'max': 0.9,\n                   'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']},\n            'MODIS NDVI(2024 中值)')"}, {"file": "MODIS_地表温度.py", "name": "MODIS_地表温度", "desc": "MODIS 8 天地表温度（MOD11A2，LST_Day_1km × 0.02 K → 摄氏度）", "lines": 15, "code": "# @gee-desc: MODIS 8 天地表温度（MOD11A2，LST_Day_1km × 0.02 K → 摄氏度）\nimport ee\n\nmod = (ee.ImageCollection('MODIS/061/MOD11A2')\n       .filterDate('2025-06-01', '2025-08-31')\n       .filterBounds(aoi))\nm.check(mod.size(), '匹配影像数')\n\n# MOD11A2 的 LST_Day_1km 是 0.02 K 定标的整数，和 Landsat 的 0.00341802 完全是两套\nlst = (mod.select('LST_Day_1km').median()\n       .multiply(0.02).subtract(273.15).rename('LST'))\n\nm.add_layer(lst, {'min': 10, 'max': 45,\n                  'palette': ['040274', '3f7fb5', 'b7e5b0', 'f7e7a0', 'e8703a', 'a50026']},\n            'MODIS 地表温度(摄氏度)')"}, {"file": "NBR_火烧迹地.py", "name": "NBR_火烧迹地", "desc": "NBR 归一化燃烧指数（B8/B12），低值=过火/裸地", "lines": 10, "code": "# @gee-desc: NBR 归一化燃烧指数（B8/B12），低值=过火/裸地\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nnbr = s2.median().normalizedDifference(['B8', 'B12']).rename('NBR')\nm.add_layer(nbr, {'min': -0.5, 'max': 1,\n                  'palette': ['d7191c', 'fdae61', 'ffffbf', 'a6d96a', '1a9641']}, 'NBR')"}, {"file": "NDBI_不透水面.py", "name": "NDBI_不透水面", "desc": "NDBI 不透水面/建成区（短波红外 vs 近红外，高值=建筑）", "lines": 10, "code": "# @gee-desc: NDBI 不透水面/建成区（短波红外 vs 近红外，高值=建筑）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nndbi = s2.median().normalizedDifference(['B11', 'B8']).rename('NDBI')\nm.add_layer(ndbi, {'min': -0.5, 'max': 0.5,\n                   'palette': ['1a9850', 'ffffbf', 'd73027']}, 'NDBI 不透水面')"}, {"file": "NDMI_植被水分.py", "name": "NDMI_植被水分", "desc": "NDMI 归一化水分指数（B8 vs B11），干旱与可燃物含水量的常用指标", "lines": 15, "code": "# @gee-desc: NDMI 归一化水分指数（B8 vs B11），干旱与可燃物含水量的常用指标\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nm.check(s2.size(), '匹配影像数')\n\n# NDMI = (NIR - SWIR1) / (NIR + SWIR1)；SWIR 对叶片含水量的变化比 NDVI 更敏感\nndmi = s2.median().normalizedDifference(['B8', 'B11']).rename('NDMI')\n\nm.add_layer(ndmi, {'min': -0.2, 'max': 0.6,\n                   'palette': ['a50026', 'f46d43', 'fee08b', 'd9ef8b', '66bd63', '1a9850']},\n            'NDMI 植被水分')"}, {"file": "NDVI_五级分类.py", "name": "NDVI_五级分类", "desc": "NDVI 五级分等（裸地/稀疏/中等/较密/茂密），出图可直接当专题底图", "lines": 26, "code": "# @gee-desc: NDVI 五级分等（裸地/稀疏/中等/较密/茂密），出图可直接当专题底图\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\nndvi = s2.map(clean).median().normalizedDifference(['B8', 'B4'])\n\n# ★ where 是**按顺序覆盖**的：阈值必须从低到高写，否则最后一段会把前面全盖掉\ngrade = (ee.Image(0)\n         .where(ndvi.gt(0.2), 1)\n         .where(ndvi.gt(0.4), 2)\n         .where(ndvi.gt(0.6), 3)\n         .where(ndvi.gt(0.75), 4)\n         .rename('grade'))\n\nm.add_layer(grade, {'min': 0, 'max': 4,\n                    'palette': ['d9d9d9', 'ffffcc', 'c2e699', '78c679', '006837']},\n            'NDVI 五级(0~4)')"}, {"file": "NDVI_年度最大值合成.py", "name": "NDVI_年度最大值合成", "desc": "NDVI 年度最大值合成（MVC）：压住残余云与阴影，长时序对比的标准做法", "lines": 17, "code": "# @gee-desc: NDVI 年度最大值合成（MVC）：压住残余云与阴影，长时序对比的标准做法\nimport ee\n\nYEAR = 2025                       # 换年份只改这一行\nSTART, END = f'{YEAR}-01-01', f'{YEAR}-12-31'\n\ncol = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n       .filterDate(START, END)\n       .filterBounds(aoi)\n       .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 30)))\nm.check(col.size(), f'{YEAR} 年影像数')\n\n# 最大值合成：先逐景算 NDVI，再在时间维上取每个像元的最大值\nndvi = col.map(lambda i: i.normalizedDifference(['B8', 'B4']).rename('NDVI')).max()\nm.add_layer(ndvi, {'min': -0.2, 'max': 0.95,\n                   'palette': ['a50026', 'f46d43', 'fee08b', 'd9ef8b', '1a9850']},\n            f'NDVI {YEAR} 年度最大')"}, {"file": "NDVI_快速预览.py", "name": "NDVI_快速预览", "desc": "Sentinel-2 计算 NDVI（红→黄→绿），一步到位的最小样例", "lines": 22, "code": "# @gee-desc: Sentinel-2 计算 NDVI（红→黄→绿），一步到位的最小样例\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nm.check(s2.size(), '匹配影像数')          # 0 = 日期或范围不对，先改这里再看下面\nndvi = s2.median().normalizedDifference(['B8', 'B4']).rename('NDVI')\n\n# 拉伸区间按研究区真实数据的 p2~p98 算（与对话里的预览、保存的代码同一口径）——\n# 写死 −1~1 会把县域 NDVI 全挤进色带中段，图看着一片单色、看不出内部差异。\nrng = ndvi.reduceRegion(reducer=ee.Reducer.percentile([2, 98]), geometry=aoi,\n                        scale=60, maxPixels=1e13, bestEffort=True).getInfo() or {}\nvals = []\nfor _v in rng.values():\n    if _v is not None:\n        vals.append(_v)\nlo, hi = (min(vals), max(vals)) if vals else (-1.0, 1.0)\n\nm.add_layer(ndvi, {'min': lo, 'max': hi,\n                   'palette': ['a50026', 'f46d43', 'fee08b', 'd9ef8b', '1a9850']}, 'NDVI')"}, {"file": "NDWI_水体提取.py", "name": "NDWI_水体提取", "desc": "NDWI 阈值提取水体（默认 >0.1），自掩膜后只显示水面", "lines": 12, "code": "# @gee-desc: NDWI 阈值提取水体（默认 >0.1），自掩膜后只显示水面\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2025-06-01', '2025-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nndwi = s2.median().normalizedDifference(['B3', 'B8']).rename('NDWI')\n\nTHRESHOLD = 0.1\nwater = ndwi.gt(THRESHOLD).selfMask().rename('water')\nm.add_layer(water, {'palette': ['0571b0']}, '水体(NDWI>' + str(THRESHOLD) + ')')"}, {"file": "NO2_二氧化氮_S5P.py", "name": "NO2_二氧化氮_S5P", "desc": "Sentinel-5P 对流层 NO2 柱浓度（mol/m²；工业与交通排放的常用代理指标）", "lines": 19, "code": "# @gee-desc: Sentinel-5P 对流层 NO2 柱浓度（mol/m²；工业与交通排放的常用代理指标）\nimport ee\n\nno2 = (ee.ImageCollection('COPERNICUS/S5P/NRTI/L3_NO2')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(no2.size(), '匹配影像数')\n\n\ndef clean(img):\n    # ★ 云量高的像元反演不可靠；官方建议按 cloud_fraction 掩膜\n    return img.updateMask(img.select('cloud_fraction').lt(0.3))\n\n\ncol = no2.map(clean).select('tropospheric_NO2_column_number_density').median()\n\nm.add_layer(col, {'min': 0, 'max': 0.0002,\n                  'palette': ['000004', '3b0f70', '8c2981', 'de4968', 'fe9f6d', 'fcfdbf']},\n            'NO2 柱浓度(mol/m²)')"}, {"file": "PCA_主成分.py", "name": "PCA_主成分", "desc": "主成分分析（PCA）降维：把 6 个反射率波段压成互不相关的 PC1~3", "lines": 41, "code": "# @gee-desc: 主成分分析（PCA）降维：把 6 个反射率波段压成互不相关的 PC1~3\nimport ee\n\n\ndef prep(img):\n    qa = img.select('QA_PIXEL').bitwiseAnd(0b11111).eq(0)\n    sr = img.select('SR_B.*').multiply(0.0000275).add(-0.2)\n    return img.addBands(sr, None, True).updateMask(qa)\n\n\nimg = (ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')\n       .filterDate('2024-05-01', '2024-09-30')\n       .filterBounds(aoi).map(prep).median().clip(aoi))\nbands = ['SR_B2', 'SR_B3', 'SR_B4', 'SR_B5', 'SR_B6', 'SR_B7']\ndata = img.select(bands)\nm.check(data.bandNames().length(), '输入波段数')\n\n# ① 去均值（PCA 以协方差为输入，均值不参与，但去均值能让数值更稳）\nmean = data.reduceRegion(reducer=ee.Reducer.mean(), geometry=aoi,\n                         scale=30, maxPixels=1e9)\ncentered = data.subtract(ee.Image.constant(mean.values(bands)))\n\n# ② 协方差 → 特征分解（特征值=各主成分方差，特征向量=变换矩阵）\ncovar = centered.toArray().reduceRegion(\n    reducer=ee.Reducer.centeredCovariance(), geometry=aoi,\n    scale=30, maxPixels=1e9)\neigens = ee.Array(covar.get('array')).eigen()\nvalues = eigens.slice(1, 0, 1)      # 特征值（列向量）\nvectors = eigens.slice(1, 1)        # 特征向量（每行一个）\n\n# ③ 投影 + 按标准差归一化，得到可比的 PC 波段\npcs = (ee.Image(vectors).matrixMultiply(centered.toArray().toArray(1))\n       .arrayProject([0])\n       .arrayFlatten([['pc1', 'pc2', 'pc3', 'pc4', 'pc5', 'pc6']]))\nsd = (ee.Image(values.sqrt()).arrayProject([0])\n      .arrayFlatten([['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6']]))\npcs = pcs.divide(sd)\n\n# 按位置取第 1 个主成分（波段名是按顺序 pc1..pc6 命名的，用位置取可避免与数据集波段名混淆）\nm.add_layer(pcs.select(0), {'min': -2, 'max': 2}, 'PC1')\nm.add_layer(pcs.select([0, 1, 2]), {'min': -2, 'max': 2}, 'PC1~PC3 假彩色')"}, {"file": "S2_云掩膜SCL_中值合成.py", "name": "S2_云掩膜SCL_中值合成", "desc": "Sentinel-2 按 SCL 场景分类剔云/云影/卷云/雪后中值合成（比裸中值干净得多）", "lines": 27, "code": "# @gee-desc: Sentinel-2 按 SCL 场景分类剔云/云影/卷云/雪后中值合成（比裸中值干净得多）\nimport ee\n\nSTART, END = '2025-06-01', '2025-09-30'\nMAX_CLOUD = 30\n\n# SCL 里这几个值代表「不可用」：3=云影 8=云(中概率) 9=云(高概率) 10=卷云 11=雪\n# ★ 不要写 scl.eq(3) or scl.eq(8) —— Python 的 or 是布尔短路，对 EE 对象直接用会报错\ndef mask_scl(img):\n    scl = img.select('SCL')\n    bad = (scl.eq(3).Or(scl.eq(8)).Or(scl.eq(9))\n           .Or(scl.eq(10)).Or(scl.eq(11)))\n    return img.updateMask(bad.Not())\n\ncol = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n       .filterDate(START, END)\n       .filterBounds(aoi)\n       .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', MAX_CLOUD)))\nm.check(col.size(), '区间内影像数')       # 0 = 日期或研究区不对，先改这里\n\n# 先掩膜、再只留 4 个波段做中值：整幅 26 波段在中等县域也会撞内存上限\nimg = col.map(mask_scl).select(['B2', 'B3', 'B4', 'B8']).median()\nndvi = img.normalizedDifference(['B8', 'B4']).rename('NDVI')\nm.check(ndvi.bandNames(), '输出波段')\nm.add_layer(ndvi, {'min': -0.2, 'max': 0.9,\n                   'palette': ['a50026', 'f46d43', 'fee08b', 'd9ef8b', '1a9850']},\n            'NDVI(SCL去云)')"}, {"file": "S2_红边_云概率掩膜.py", "name": "S2_红边_云概率掩膜", "desc": "Sentinel-2 红边 NDRE（用 MSK_CLDPRB 云概率掩膜，比 QA60 位掩膜稳）", "lines": 21, "code": "# @gee-desc: Sentinel-2 红边 NDRE（用 MSK_CLDPRB 云概率掩膜，比 QA60 位掩膜稳）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    # ★ MSK_CLDPRB 是云概率(%)：<20 当晴空。新版基线下 QA60 常整幅为空，\n    #   只靠 QA60 位掩膜会把云全留下 —— 这是 S2 云掩膜最常见的坑。\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\ncomp = s2.map(clean).median()\nndre = comp.normalizedDifference(['B8', 'B5']).rename('NDRE')\n\nm.add_layer(ndre, {'min': 0, 'max': 0.6,\n                   'palette': ['ffffff', 'd9f0a3', '78c679', '238443', '004529']},\n            '红边 NDRE')"}, {"file": "SAR_水体_去斑.py", "name": "SAR_水体_去斑", "desc": "Sentinel-1 雷达水体（先逐景去斑再阈值；VV 已是 dB，别当线性值）", "lines": 21, "code": "# @gee-desc: Sentinel-1 雷达水体（先逐景去斑再阈值；VV 已是 dB，别当线性值）\nimport ee\n\ns1 = (ee.ImageCollection('COPERNICUS/S1_GRD')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi)\n      .filter(ee.Filter.eq('instrumentMode', 'IW'))\n      .filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VV')))\nm.check(s1.size(), '匹配影像数')\n\n\ndef despeckle(img):\n    # ★ 斑点噪声(乘性)会让单景阈值法满图碎点，必须先滤波；50m 圆核是中值去斑常用参数\n    return img.select('VV').focal_median(50, 'circle', 'meters')\n\n\nvv = s1.map(despeckle).mean()\nwater = vv.lt(-15).selfMask()    # 水体在 C 波段后向散射很弱，< -15dB 是经验阈值\n\nm.add_layer(vv, {'min': -25, 'max': 0}, 'VV 后向散射(dB)')\nm.add_layer(water, {'palette': ['0b5394']}, '水体(VV<-15dB)')"}, {"file": "SAVI_土壤调节指数.py", "name": "SAVI_土壤调节指数", "desc": "SAVI 土壤调节植被指数（L=0.5；稀疏植被区比 NDVI 少受裸土干扰）", "lines": 26, "code": "# @gee-desc: SAVI 土壤调节植被指数（L=0.5；稀疏植被区比 NDVI 少受裸土干扰）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-06-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\ncomp = s2.map(clean).median()\n\n# ★ 公式里的 0.5 与 L 同量纲，必须是反射率（0~1）。定标漏做 = 分母被 0.5 主导，图会失效。\nsavi = comp.expression(\n    '1.5 * ((NIR - RED) / (NIR + RED + 0.5))',\n    {'NIR': comp.select('B8'), 'RED': comp.select('B4')}\n).rename('SAVI')\n\nndvi = comp.normalizedDifference(['B8', 'B4']).rename('NDVI')\nm.add_layer(savi, {'min': 0, 'max': 0.7,\n                   'palette': ['ffffff', 'c7e9c0', '74c476', '006d2c']}, 'SAVI')\nm.add_layer(ndvi, {'min': 0, 'max': 0.9,\n                   'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']}, 'NDVI(对照)')"}, {"file": "SO2_二氧化硫_S5P.py", "name": "SO2_二氧化硫_S5P", "desc": "Sentinel-5P SO2 柱浓度（mol/m²；火电、冶炼、火山排放的信号）", "lines": 18, "code": "# @gee-desc: Sentinel-5P SO2 柱浓度（mol/m²；火电、冶炼、火山排放的信号）\nimport ee\n\nso2 = (ee.ImageCollection('COPERNICUS/S5P/NRTI/L3_SO2')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(so2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('cloud_fraction').lt(0.3))\n\n\ncol = so2.map(clean).select('SO2_column_number_density').median()\n\nm.add_layer(col, {'min': 0, 'max': 0.0005,\n                  'palette': ['000004', '3b0f70', '8c2981', 'de4968', 'fe9f6d', 'fcfdbf']},\n            'SO2 柱浓度(mol/m²)')"}, {"file": "kNDVI_核植被指数.py", "name": "kNDVI_核植被指数", "desc": "kNDVI 核归一化植被指数（tanh(NDVI²)，高植被区比 NDVI 更不饱和）", "lines": 22, "code": "# @gee-desc: kNDVI 核归一化植被指数（tanh(NDVI²)，高植被区比 NDVI 更不饱和）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-05-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\nndvi = s2.map(clean).median().normalizedDifference(['B8', 'B4'])\n\n# kNDVI = tanh(NDVI²)（σ=1 的简化核形式）；取值范围仍是 0~1，但高值段被拉开\nkndvi = ndvi.pow(2).tanh().rename('kNDVI')\n\nm.add_layer(ndvi, {'min': 0, 'max': 1,\n                   'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']}, 'NDVI(对照)')\nm.add_layer(kndvi, {'min': 0, 'max': 1,\n                    'palette': ['ffffff', 'bdd7e7', '6baed6', '3182bd', '08519c']}, 'kNDVI')"}, {"file": "qualityMosaic_最优像元.py", "name": "qualityMosaic_最优像元", "desc": "最优像元合成（qualityMosaic：逐像元取 NDVI 最高的那一景，不是时间中值）", "lines": 27, "code": "# @gee-desc: 最优像元合成（qualityMosaic：逐像元取 NDVI 最高的那一景，不是时间中值）\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-05-01', '2024-09-30')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clean(img):\n    return img.updateMask(img.select('MSK_CLDPRB').lt(20)).divide(10000)\n\n\ndef add_ndvi(img):\n    return img.normalizedDifference(['B8', 'B4']).rename('NDVI')\n\n\ncol = s2.map(clean).map(add_ndvi)\n\n# ★ qualityMosaic 与 median 的差别：中值可能取到\"半云半地\"的像元；\n#   qualityMosaic 逐像元挑 NDVI 最高那景 ⇒ 植被最茂盛、云最少的时刻，\n#   代价是**每个像元的时间戳不同**（做时序分析时不能用它）\nbest = col.qualityMosaic('NDVI').clip(aoi)\n\nm.add_layer(best.select('NDVI'), {'min': 0, 'max': 0.9,\n                                  'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']},\n            'NDVI 最优像元合成')"}, {"file": "人口_WorldPop.py", "name": "人口_WorldPop", "desc": "WorldPop 100m 人口格网 + 研究区人口总量估算", "lines": 21, "code": "# @gee-desc: WorldPop 100m 人口格网 + 研究区人口总量估算\nimport ee\n\nYEAR = 2020                       # WorldPop 覆盖 2000~2020\nSTART, END = f'{YEAR}-01-01', f'{YEAR + 1}-01-01'\n\ncol = (ee.ImageCollection('WorldPop/GP/100m/pop')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), f'{YEAR} 年影像数')\n\npop = col.mosaic().select('population').rename('pop')     # 每个 100m 像元上的人数\nm.add_layer(pop, {'min': 0, 'max': 300,\n                  'palette': ['ffffe5', 'fff7bc', 'fec44f', 'd95f0e', '993404']},\n            '人口(人/100m像元)')\n\n# 总量估算：逐像元求和。scale 放粗 + bestEffort，大区域就不会撞像元上限\ntotal = pop.reduceRegion(reducer=ee.Reducer.sum(), geometry=aoi,\n                         scale=100, maxPixels=1e9, bestEffort=True).getInfo() or {}\nn = total.get('pop')\nprint('研究区总人口(约) =', round(n) if n else '该范围内没有人口像元')"}, {"file": "人口变化_WorldPop.py", "name": "人口变化_WorldPop", "desc": "人口变化（WorldPop 100m 网格，2020 减 2015，正值=人口流入）", "lines": 15, "code": "# @gee-desc: 人口变化（WorldPop 100m 网格，2020 减 2015，正值=人口流入）\nimport ee\n\nwp = ee.ImageCollection('WorldPop/GP/100m/pop').filterBounds(aoi)\n\np2015 = wp.filterDate('2015-01-01', '2016-01-01').mosaic().select('population')\np2020 = wp.filterDate('2020-01-01', '2021-01-01').mosaic().select('population')\nm.check(p2015.bandNames().length(), '2015 波段数')\n\n# 每格是该 100m 网格的人口数（人），不是密度；差值单位同样是\"人/网格\"\nchange = p2020.subtract(p2015).rename('delta')\n\nm.add_layer(change, {'min': -50, 'max': 50,\n                     'palette': ['2166ac', '67a9cf', 'f7f7f7', 'ef8a62', 'b2182b']},\n            '人口变化(人/100m)')"}, {"file": "区域均值_逐年统计.py", "name": "区域均值_逐年统计", "desc": "逐年 NDVI 区域均值（把时序压成一张数字表，趋势先看数再看图）", "lines": 31, "code": "# @gee-desc: 逐年 NDVI 区域均值（把时序压成一张数字表，趋势先看数再看图）\nimport ee\n\n\ndef yearly(y):\n    y = ee.Number(y)\n    col = (ee.ImageCollection('MODIS/061/MOD13A1')\n           .filterDate(ee.Date.fromYMD(y, 1, 1),\n                       ee.Date.fromYMD(y, 1, 1).advance(1, 'year'))\n           .filterBounds(aoi).select('NDVI'))\n    return col.mean().multiply(0.0001).toFloat()\n\n\ndef label(y):\n    return ee.String(ee.Number(y).int())\n\n\nyears = ee.List.sequence(2015, 2024)\ntable = ee.Dictionary.fromLists(years.map(label), years.map(yearly))\nm.check(table, '逐年 NDVI 均值')\n\n# 同期多年均值当底图，和上面的数字表互相印证\n# ★ 先 select('NDVI') 再 mean：直接对整景求均值会得到 12 个波段，\n#   add_layer 时给 palette 会报 \"Cannot provide a palette when visualizing more than one band\"\nmean_all = (ee.ImageCollection('MODIS/061/MOD13A1')\n            .filterDate('2015-01-01', '2025-01-01')\n            .filterBounds(aoi).select('NDVI').mean().multiply(0.0001))\n\nm.add_layer(mean_all, {'min': 0, 'max': 0.9,\n                       'palette': ['ffffff', 'cee1c1', '74c476', '238b45', '00441b']},\n            '2015~2024 NDVI 均值')"}, {"file": "变化检测_两期NDVI差值.py", "name": "变化检测_两期NDVI差值", "desc": "两期 Sentinel-2 NDVI 相减（红=变差 绿=变好），一眼看出植被变化", "lines": 18, "code": "# @gee-desc: 两期 Sentinel-2 NDVI 相减（红=变差 绿=变好），一眼看出植被变化\nimport ee\n\nP1 = ('2020-06-01', '2020-09-30')      # 前期\nP2 = ('2025-06-01', '2025-09-30')      # 后期\n\ndef ndvi_composite(period):\n    col = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n           .filterDate(period[0], period[1])\n           .filterBounds(aoi)\n           .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 30)))\n    m.check(col.size(), f'{period[0]} ~ {period[1]} 影像数')   # 两期各报一次，谁空了一眼看出\n    return col.select(['B4', 'B8']).median().normalizedDifference(['B8', 'B4'])\n\nd = ndvi_composite(P2).subtract(ndvi_composite(P1)).rename('dNDVI')\nm.add_layer(d, {'min': -0.4, 'max': 0.4,\n                'palette': ['d73027', 'fdae61', 'ffffbf', 'a6d96a', '1a9850']},\n            'NDVI 变化(后期-前期)')"}, {"file": "土地覆盖_DynamicWorld.py", "name": "土地覆盖_DynamicWorld", "desc": "Dynamic World 10m 土地覆盖（众数合成），不需要样本点的现成分类产品", "lines": 19, "code": "# @gee-desc: Dynamic World 10m 土地覆盖（众数合成），不需要样本点的现成分类产品\nimport ee\n\nSTART, END = '2025-06-01', '2025-09-30'\n\ncol = (ee.ImageCollection('GOOGLE/DYNAMICWORLD/V1')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), '区间内影像数')\n\n# label 是 0~8 的分类码；时间维取众数 = 每个像元出现最多的那一类\nlabel = col.select('label').reduce(ee.Reducer.mode())\nm.check(label.bandNames(), '输出波段')\n\n# 官方类目色带：水/树/草/湿地/农田/灌丛/建成区/裸地/冰雪\nDW_VIS = {'min': 0, 'max': 8,\n          'palette': ['419bdf', '397d49', '88b053', '7a87c6', 'e49635',\n                      'dfc35a', 'c4281b', 'a59b8f', 'b39fe1']}\nm.add_layer(label, DW_VIS, '土地覆盖(DW 众数)')"}, {"file": "土壤湿度_ERA5.py", "name": "土壤湿度_ERA5", "desc": "ERA5-Land 表层土壤体积含水量（m³/m³，0~1 之间）", "lines": 14, "code": "# @gee-desc: ERA5-Land 表层土壤体积含水量（m³/m³，0~1 之间）\nimport ee\n\nera = (ee.ImageCollection('ECMWF/ERA5_LAND/DAILY_AGGR')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(era.size(), '匹配影像数')\n\n# 第 1 层（0~7cm）；m³/m³ 是体积比，**不是百分比**，不要乘 100 再和 0.3 比\nsm = era.select('volumetric_soil_water_layer_1').mean()\n\nm.add_layer(sm, {'min': 0.05, 'max': 0.45,\n                 'palette': ['ffffd4', 'c7e9c0', '74c476', '238b45', '00441b']},\n            '表层土壤含水量(m³/m³)')"}, {"file": "土壤质地_OpenLandMap.py", "name": "土壤质地_OpenLandMap", "desc": "表层土壤质地分级（USDA 12 级，OpenLandMap 250m）", "lines": 13, "code": "# @gee-desc: 表层土壤质地分级（USDA 12 级，OpenLandMap 250m）\nimport ee\n\ntex = ee.Image('OpenLandMap/SOL/SOL_TEXTURE-CLASS_USDA-TT_M/v02').select('b0')\nm.check(tex.bandNames().length(), '波段数')\n\n# ★ 这是**类别**数据（1~12 = 砂土…黏土，0 = 无数据），\n#   用连续色带会让\"砂土\"和\"黏土\"看起来像量的差异 —— 必须离散配色\nm.add_layer(tex, {'min': 1, 'max': 12,\n                  'palette': ['d9d9d9', 'fdd49e', 'fdbb84', 'fc8d59', 'ef6548',\n                              'd7301f', 'b30000', '7f0000', 'a6bddb', '3690c0',\n                              '0570b0', '034e7b']},\n            '土壤质地(USDA 1~12)')"}, {"file": "地形_坡度坡向_SRTM.py", "name": "地形_坡度坡向_SRTM", "desc": "SRTM DEM 派生坡度/坡向（不依赖日期与云量）", "lines": 9, "code": "# @gee-desc: SRTM DEM 派生坡度/坡向（不依赖日期与云量）\nimport ee\n\ndem = ee.Image('USGS/SRTMGL1_003')\nterrain = ee.Terrain.products(dem)\nm.check(terrain.bandNames(), '可用波段')\nslope = terrain.select('slope').rename('slope')\nm.add_layer(slope, {'min': 0, 'max': 45,\n                    'palette': ['1a9850', 'd9ef8b', 'fee08b', 'f46d43', 'a50026']}, '坡度(度)')"}, {"file": "地形_山体阴影_SRTM.py", "name": "地形_山体阴影_SRTM", "desc": "SRTM 派生山体阴影（hillshade），出图时压在地表上面做立体感", "lines": 10, "code": "# @gee-desc: SRTM 派生山体阴影（hillshade），出图时压在地表上面做立体感\nimport ee\n\ndem = ee.Image('USGS/SRTMGL1_003').select('elevation')\n\n# 方位角 315°（西北光）、高度角 45°：制图上最常用的两个默认值\nhs = ee.Terrain.hillshade(dem, 315, 45).rename('hillshade')\nm.add_layer(hs, {'min': 0, 'max': 255,\n                 'palette': ['000000', '404040', '808080', 'c0c0c0', 'ffffff']},\n            '山体阴影')"}, {"file": "地形起伏度_NASADEM.py", "name": "地形起伏度_NASADEM", "desc": "地形起伏度（窗口内最高-最低高程），比标准差更直观的地表破碎度指标", "lines": 16, "code": "# @gee-desc: 地形起伏度（窗口内最高-最低高程），比标准差更直观的地表破碎度指标\nimport ee\n\ndem = ee.Image('NASA/NASADEM_HGT/001').select('elevation')\n\n# 5×5 窗口（30m 数据约 150m 见方）；窗口越大起伏度越大，换窗口要同步改阈值\nkernel = ee.Kernel.square(5)\nmx = dem.reduceNeighborhood(reducer=ee.Reducer.max(), kernel=kernel)\nmn = dem.reduceNeighborhood(reducer=ee.Reducer.min(), kernel=kernel)\nrelief = mx.subtract(mn).rename('relief')\n\nm.add_layer(dem, {'min': 700, 'max': 2000,\n                  'palette': ['006633', 'e5ffcc', '662a00', 'd8d8d8', 'ffffff']}, '高程(m)')\nm.add_layer(relief, {'min': 0, 'max': 200,\n                     'palette': ['ffffff', 'fee0d2', 'fc9272', 'de2d26', '67000d']},\n            '起伏度(m/150m)')"}, {"file": "地表反照率_MODIS.py", "name": "地表反照率_MODIS", "desc": "MODIS 短波地表反照率（MCD43A3，比例因子 1e-3；白空/黑空要看太阳几何）", "lines": 21, "code": "# @gee-desc: MODIS 短波地表反照率（MCD43A3，比例因子 1e-3；白空/黑空要看太阳几何）\nimport ee\n\nalb = (ee.ImageCollection('MODIS/061/MCD43A3')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(alb.size(), '匹配影像数')\n\n# ★ 比例因子是 1e-3（不是 1e-4 —— 那是别的 MODIS 产品的口径，混用差十倍）。\n#   WSA = 白空反照率（各向同性入射），BSA = 黑空反照率（直射）。\n#   严格的\"真实反照率\"要按太阳天顶角在两者间插值；这里取 WSA 作常用近似。\n#   ★ 注意：对 ImageCollection 调 .select() 拿到的还是集合，不能直接 .multiply()\n#   （会报 'ImageCollection' object has no attribute 'multiply'）—— 必须 map 到每一景上。\ndef to_albedo(img):\n    return img.select('Albedo_WSA_shortwave').multiply(0.001)\n\nwsa = alb.map(to_albedo).mean().rename('albedo')\n\nm.add_layer(wsa, {'min': 0.05, 'max': 0.45,\n                  'palette': ['ffffff', 'd9d9d9', '969696', '525252', '000000']},\n            '短波反照率(WSA)')"}, {"file": "地表水变化_JRC.py", "name": "地表水变化_JRC", "desc": "JRC 地表水转变类型：新增常年水体 vs 消失的常年水体", "lines": 16, "code": "# @gee-desc: JRC 地表水转变类型：新增常年水体 vs 消失的常年水体\nimport ee\n\ngsw = ee.Image('JRC/GSW1_4/GlobalSurfaceWater')\ntr = gsw.select('transition')\nm.check(tr.bandNames().length(), '波段数')\n\n# transition 类别（官方图例）：2=新增常年水体，3=消失的常年水体，\n# 7=季节性→常年，8=常年→季节性；其余为无变化/短时水体\nnew_perm = tr.eq(2).selfMask()\nlost_perm = tr.eq(3).selfMask()\nto_seasonal = tr.eq(8).selfMask()\n\nm.add_layer(new_perm, {'palette': ['2166ac']}, '新增常年水体')\nm.add_layer(lost_perm, {'palette': ['b2182b']}, '消失的常年水体')\nm.add_layer(to_seasonal, {'palette': ['fddbc7']}, '常年→季节性')"}, {"file": "城市热岛_强度.py", "name": "城市热岛_强度", "desc": "城市热岛强度（LST 减去全区均值），配 NDVI 对照看\"高温低植被\"", "lines": 26, "code": "# @gee-desc: 城市热岛强度（LST 减去全区均值），配 NDVI 对照看\"高温低植被\"\nimport ee\n\ncol = (ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')\n       .filterDate('2025-06-01', '2025-09-30')\n       .filterBounds(aoi)\n       .filter(ee.Filter.lt('CLOUD_COVER', 20)))\nm.check(col.size(), '匹配影像数')\ncomp = col.median()\n\n# 与官方示例同一套定标：K = DN * 0.00341802 + 149.0\nlst = comp.select('ST_B10').multiply(0.00341802).add(149.0).subtract(273.15).rename('LST')\n\n# 热岛强度就是\"比全区平均热多少度\"，正值 = 热岛核心\nmean = lst.reduceRegion(reducer=ee.Reducer.mean(), geometry=aoi, scale=120,\n                        maxPixels=1e9, bestEffort=True)\nbase = ee.Number(mean.get('LST'))          # lst 已减过 273.15，这里就是摄氏度\nm.check(base, '全区平均地表温度(摄氏度)')\n\nuhi = lst.subtract(base).rename('UHI')\nndvi = comp.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI')\n\nm.add_layer(uhi, {'min': -5, 'max': 5,\n                  'palette': ['2166ac', '67a9cf', 'f7f7f7', 'ef8a62', 'b2182b']},\n            '热岛强度(摄氏度)')\nm.add_layer(ndvi, {'min': 0, 'max': 0.9, 'palette': ['ffffff', 'c7e9c0', '238b45']}, 'NDVI')"}, {"file": "夜光增长率_VIIRS.py", "name": "夜光增长率_VIIRS", "desc": "夜间灯光增长率（2019→2023，V22 年度合成；看城市扩张与经济活跃度变化）", "lines": 18, "code": "# @gee-desc: 夜间灯光增长率（2019→2023，V22 年度合成；看城市扩张与经济活跃度变化）\nimport ee\n\nviirs = ee.ImageCollection('NOAA/VIIRS/DNB/ANNUAL_V22').filterBounds(aoi)\n\n# ★ V22 年度合集实测只有 4 期（system:index = 20220101/20230101/20240101/20250101），\n#   没有 2019 —— 写 2019 会得到 null 影像，报 \"Image.select: 'input' is required\"。\na = viirs.filterDate('2022-01-01', '2023-01-01').first().select('average_masked')\nb = viirs.filterDate('2024-01-01', '2025-01-01').first().select('average_masked')\nm.check(a.bandNames().length(), '2022 波段数')\n\n# ★ 波段名是 average_masked（V22）；avg_rad 是 V1 的叫法，在 V22 里不存在\n#   分母加 0.5 避免\"无灯区\"除零炸出无穷大\ngrowth = b.subtract(a).divide(a.add(0.5)).rename('growth')\n\nm.add_layer(growth, {'min': -1, 'max': 1,\n                     'palette': ['2166ac', '92c5de', 'f7f7f7', 'f4a582', 'b2182b']},\n            '夜光增长率(2022→2024)')"}, {"file": "夜间灯光_VIIRS年度.py", "name": "夜间灯光_VIIRS年度", "desc": "VIIRS 年度夜间灯光（背景已掩膜），看城市扩张与人类活动强度的经典底图", "lines": 16, "code": "# @gee-desc: VIIRS 年度夜间灯光（背景已掩膜），看城市扩张与人类活动强度的经典底图\nimport ee\n\nYEAR = 2023\nSTART, END = f'{YEAR}-01-01', f'{YEAR + 1}-01-01'\n\ncol = (ee.ImageCollection('NOAA/VIIRS/DNB/ANNUAL_V22')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), f'{YEAR} 年影像数')\n\n# 用 average_masked（已去背景噪声）；直接average 在无人区会留一层底噪\nrad = col.select('average_masked').mosaic().rename('rad')\nm.add_layer(rad, {'min': 0, 'max': 60,\n                  'palette': ['000004', '1f0c48', '550f6d', '88226a', 'e35933', 'ffd700']},\n            f'夜间灯光 {YEAR}')"}, {"file": "建成区扩张_WorldCover.py", "name": "建成区扩张_WorldCover", "desc": "两年建成区之差（WorldCover 2020 vs 2021，class 50 = 建成区）", "lines": 15, "code": "# @gee-desc: 两年建成区之差（WorldCover 2020 vs 2021，class 50 = 建成区）\nimport ee\n\nw2020 = ee.ImageCollection('ESA/WorldCover/v100').filterBounds(aoi).first().select('Map').eq(50)\nw2021 = ee.ImageCollection('ESA/WorldCover/v200').filterBounds(aoi).first().select('Map').eq(50)\nm.check(ee.Number(1), '固定两年产品')\n\n# ★ 用 eq 得到布尔再相减：False/True 当 0/1 用，比直接拿类别码相减安全\n#   （类别码相减会得到 50-80=-30 这类无意义的值）\ngrow = w2021.And(w2020.Not()).selfMask()\nlost = w2020.And(w2021.Not()).selfMask()\n\nm.add_layer(w2021.selfMask(), {'palette': ['8c510a']}, '2021 建成区')\nm.add_layer(grow, {'palette': ['e31a1c']}, '新增建成区')\nm.add_layer(lost, {'palette': ['1f78b4']}, '减少(分类翻转)')"}, {"file": "昼夜温差_LST.py", "name": "昼夜温差_LST", "desc": "地表温度昼夜差（DTR）：白天减夜间，同一景数据，**不需要减 273.15**", "lines": 19, "code": "# @gee-desc: 地表温度昼夜差（DTR）：白天减夜间，同一景数据，**不需要减 273.15**\nimport ee\n\nmod = (ee.ImageCollection('MODIS/061/MOD11A2')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(mod.size(), '匹配影像数')\n\nmean = mod.mean()\nday = mean.select('LST_Day_1km').multiply(0.02)\nnight = mean.select('LST_Night_1km').multiply(0.02)\n\n# ★ 温差：开尔文差值 == 摄氏度差值，所以这里**故意不减 273.15**\n#   （两个都减一遍结果不变，但只减一个就会得到 -273 左右的荒谬值）\ndtr = day.subtract(night).rename('DTR')\n\nm.add_layer(dtr, {'min': 5, 'max': 30,\n                  'palette': ['313695', '74add1', 'ffffbf', 'f46d43', 'a50026']},\n            '昼夜温差(K)')"}, {"file": "有效观测次数_S2.py", "name": "有效观测次数_S2", "desc": "有效观测次数（每像元在时段内有多少景是晴空）—— 判断时序结果可不可信", "lines": 20, "code": "# @gee-desc: 有效观测次数（每像元在时段内有多少景是晴空）—— 判断时序结果可不可信\nimport ee\n\ns2 = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n      .filterDate('2024-01-01', '2024-12-31')\n      .filterBounds(aoi))\nm.check(s2.size(), '匹配影像数')\n\n\ndef clear(img):\n    # 布尔影像：1=晴空，0=有云；CloudProb 阈值不同，次数就不同，这里用 20%\n    return img.select('MSK_CLDPRB').lt(20).rename('clear')\n\n\ncount = s2.map(clear).sum()\n\n# ★ 这个图比 NDVI 本身更重要：整片只有 1~2 次观测的地方，年际比较没有意义\nm.add_layer(count, {'min': 0, 'max': 30,\n                    'palette': ['ffffff', 'c6dbef', '6baed6', '2171b5', '08306b']},\n            '晴空观测次数(景)')"}, {"file": "树冠覆盖_MOD44B.py", "name": "树冠覆盖_MOD44B", "desc": "MODIS 植被连续面树冠覆盖度（MOD44B，250m 年产品，0~100%）", "lines": 13, "code": "# @gee-desc: MODIS 植被连续面树冠覆盖度（MOD44B，250m 年产品，0~100%）\nimport ee\n\ntcc = (ee.ImageCollection('MODIS/061/MOD44B')\n       .filterDate('2020-01-01', '2021-06-01')\n       .filterBounds(aoi).mosaic().select('Percent_Tree_Cover'))\nm.check(tcc.bandNames().length(), '波段数')\n\n# ★ 该波段本身已是 0~100 的百分比，**不要再乘 0.01 或 1e-4**（乘了会得到 0~1 的\"比例\"，\n#   和阈值经验值对不上）\nm.add_layer(tcc, {'min': 0, 'max': 100,\n                  'palette': ['ffffff', 'c7e9c0', '74c476', '238b45', '00441b']},\n            '树冠覆盖(%)')"}, {"file": "森林变化_Hansen.py", "name": "森林变化_Hansen", "desc": "Hansen 全球森林变化：2000 年树冠覆盖 + 累计森林损失（30m 长时序）", "lines": 13, "code": "# @gee-desc: Hansen 全球森林变化：2000 年树冠覆盖 + 累计森林损失（30m 长时序）\nimport ee\n\nfc = ee.Image('UMD/hansen/global_forest_change_2023_v1_11')\ntree = fc.select('treecover2000').rename('tree')     # 2000 年树冠覆盖率 %\nloss = fc.select('loss').selfMask()                  # 2001 年以来的累计损失（1=loss）\n\nm.add_layer(tree, {'min': 0, 'max': 100,\n                   'palette': ['ffffff', 'c7e9c0', '74c476', '238b45', '00441b']},\n            '树冠覆盖率 2000(%)')\nm.add_layer(loss, {'palette': ['ff0000']}, '森林损失(累计)')\n\n# 想逐年看损失：fc.select('lossyear')，编码 1=2001 … 23=2023，0=无损失"}, {"file": "气温_ERA5.py", "name": "气温_ERA5", "desc": "ERA5-Land 近地面气温（K → ℃），日尺度的区域气温底图", "lines": 14, "code": "# @gee-desc: ERA5-Land 近地面气温（K → ℃），日尺度的区域气温底图\nimport ee\n\nera = (ee.ImageCollection('ECMWF/ERA5_LAND/DAILY_AGGR')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(era.size(), '匹配影像数')\n\n# ★ temperature_2m 单位是**开尔文**，不减 273.15 会得到 300 上下的\"气温\"\nt = era.select('temperature_2m').mean().subtract(273.15)\n\nm.add_layer(t, {'min': 10, 'max': 32,\n                'palette': ['313695', '74add1', 'ffffbf', 'f46d43', 'a50026']},\n            '夏季均温(℃)')"}, {"file": "气溶胶指数_S5P.py", "name": "气溶胶指数_S5P", "desc": "Sentinel-5P 吸收性气溶胶指数（AAI；沙尘与烟羽是正值，云是负值）", "lines": 18, "code": "# @gee-desc: Sentinel-5P 吸收性气溶胶指数（AAI；沙尘与烟羽是正值，云是负值）\nimport ee\n\naai = (ee.ImageCollection('COPERNICUS/S5P/NRTI/L3_AER_AI')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(aai.size(), '匹配影像数')\n\n\ncol = aai.select('absorbing_aerosol_index').median()\n\n# ★ 这个产品**没有 cloud_fraction 波段**（别照抄 NO2/SO2 的掩膜写法，会报 Band not found）；\n#   云在 AAI 上是**负值**，所以反过来用 AAI>-1 把云掩掉\ncol = col.updateMask(col.gt(-1))\n\nm.add_layer(col, {'min': -1, 'max': 3,\n                  'palette': ['313695', '74add1', 'fed976', 'fd8d3c', 'd73027']},\n            '吸收性气溶胶指数')"}, {"file": "水体频率_JRC_GSW.py", "name": "水体频率_JRC_GSW", "desc": "JRC 全球地表水：1984-2021 水面出现频率 + 常年水体（不受月份影响的长期底图）", "lines": 14, "code": "# @gee-desc: JRC 全球地表水：1984-2021 水面出现频率 + 常年水体（不受月份影响的长期底图）\nimport ee\n\ngsw = ee.Image('JRC/GSW1_4/GlobalSurfaceWater')\nocc = gsw.select('occurrence').rename('occ')      # 0~100：该像元被水覆盖的时间占比\n\nm.add_layer(occ, {'min': 0, 'max': 100,\n                  'palette': ['ffffff', 'c7e9f1', '7bccc4', '2b8cbe', '084081']},\n            '水面出现频率(%)')\n\n# 阈值是本模块的口径（不是 JRC 官方定义：官方以 occurrence=100 记为常年水面）。\n# 75% 比 100 宽容一点，能留住季节性漫灌的库塘；想连季节性水体一起看就把阈值调到 25\nperm = occ.gt(75).selfMask()\nm.add_layer(perm, {'palette': ['0571b0']}, '常年水体(>75%)')"}, {"file": "汇流累积_MERIT.py", "name": "汇流累积_MERIT", "desc": "MERIT-Hydro 汇流累积面积（upa，km²）—— 找主干河道、划子流域的基础量", "lines": 12, "code": "# @gee-desc: MERIT-Hydro 汇流累积面积（upa，km²）—— 找主干河道、划子流域的基础量\nimport ee\n\nupa = ee.Image('MERIT/Hydro/v1_0_1').select('upa')\n\n# ★ upa 是上游汇水面积（km²），量级从 0 到几十万，线性配色会把小支流全压黑，\n#   所以取 log10 显示；**算面积/阈值时请用原始 upa，不要用这个 log 图**\nlog_upa = upa.add(1).log10().rename('log_upa')\n\nm.add_layer(log_upa, {'min': 0, 'max': 6,\n                      'palette': ['ffffff', 'c6dbef', '6baed6', '2171b5', '08306b']},\n            'log10(汇流面积 km²)')"}, {"file": "洪水淹没_两期对比.py", "name": "洪水淹没_两期对比", "desc": "两期水面之差 = 新增淹没区（JRC 月度水历史，8 月有水而 1 月没有）", "lines": 18, "code": "# @gee-desc: 两期水面之差 = 新增淹没区（JRC 月度水历史，8 月有水而 1 月没有）\nimport ee\n\nmh = ee.ImageCollection('JRC/GSW1_4/MonthlyHistory').filterBounds(aoi)\n\n# ★ 两期必须**同一季节**。实测本区 2021-01 整期 water 全是 class 0\n#   （该月没有观测），拿它做对比会得到\"一片空白\"，看着像没算出来。\naug21 = mh.filterDate('2021-08-01', '2021-09-01').first().select('water')\naug20 = mh.filterDate('2020-08-01', '2020-09-01').first().select('water')\nm.check(aug21.bandNames().length(), '8 月波段数')\n\n# ★ water 是类别值（0/1/2），不要拿它做减法的\"连续值\"：\n#   2-2=0 看着像\"没水\"，所以只能用 eq/neq 组合出布尔，再 selfMask。\npermanent = aug21.eq(2).And(aug20.eq(2)).selfMask()\nchanged = aug21.eq(2).neq(aug20.eq(2)).selfMask()      # 新增或消失都算变化\n\nm.add_layer(permanent, {'palette': ['08306b']}, '两期都是常年水面')\nm.add_layer(changed, {'palette': ['e31a1c']}, '同期水面变化(新增/消失)')"}, {"file": "湖泊水面_月度JRC.py", "name": "湖泊水面_月度JRC", "desc": "JRC 月度水历史：统计一年里有几个月是常年水面（2=常年，1=季节性，0=无）", "lines": 19, "code": "# @gee-desc: JRC 月度水历史：统计一年里有几个月是常年水面（2=常年，1=季节性，0=无）\nimport ee\n\nmh = (ee.ImageCollection('JRC/GSW1_4/MonthlyHistory')\n      .filterDate('2021-01-01', '2022-01-01')\n      .filterBounds(aoi))\nm.check(mh.size(), '匹配月份数')\n\n\ndef is_permanent(img):\n    # water 波段是**类别值**：0=无观测/陆地，1=季节性水体，2=常年水体\n    return img.select('water').eq(2)\n\n\nmonths = mh.map(is_permanent).sum()      # 每像元：一年中有几个月是常年水面\n\nm.add_layer(months, {'min': 0, 'max': 12,\n                     'palette': ['ffffff', 'c6dbef', '6baed6', '2171b5', '08306b']},\n            '常年水面月数(0~12)')"}, {"file": "监督分类_随机森林.py", "name": "监督分类_随机森林", "desc": "随机森林监督分类（WorldCover 当标签、Landsat 定标反射率当特征，换任何研究区都能跑）", "lines": 40, "code": "# @gee-desc: 随机森林监督分类（WorldCover 当标签、Landsat 定标反射率当特征，换任何研究区都能跑）\nimport ee\n\n\ndef prep(img):\n    # 云：QA_PIXEL 低 5 位全 0 才是晴空；饱和：QA_RADSAT 为 0\n    qa = img.select('QA_PIXEL').bitwiseAnd(0b11111).eq(0)\n    sr = img.select('SR_B.*').multiply(0.0000275).add(-0.2)\n    return img.addBands(sr, None, True).updateMask(qa)\n\n\nimg = (ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')\n       .filterDate('2024-05-01', '2024-09-30')\n       .filterBounds(aoi).map(prep).median().clip(aoi))\nm.check(img.bandNames().length(), '波段数')\n\nbands = ['SR_B2', 'SR_B3', 'SR_B4', 'SR_B5', 'SR_B6', 'SR_B7']\n\n# ★ 官方那个监督分类示例用的是加州固定样本点（GOOGLE/EE/DEMOS/demo_landcover_labels），\n#   换到别的研究区一个点都没有 → 训练集为空。这里改用公开土地覆盖产品当标签，\n#   样本从研究区内现采，所以任何研究区都成立。\nlabels = ee.ImageCollection('ESA/WorldCover/v200').filterBounds(aoi).first().select('Map')\n\ntraining = labels.addBands(img.select(bands)).sample(\n    region=aoi, scale=30, numPixels=3000, seed=42)\nm.check(training.size(), '训练样本数')\n\ntrained = ee.Classifier.smileRandomForest(30).train(training, 'Map', bands)\nclassified = img.select(bands).classify(trained)\n\nm.add_layer(labels, {'min': 10, 'max': 100,\n                         'palette': ['006400', 'ffbb22', 'ffff4c', 'f096ff',\n                                     'fa0000', 'b4b4b4', 'f0f0f0', '0064c8',\n                                     '0096a0', '00cf75', 'fae6a0']},\n            'WorldCover 标签')\nm.add_layer(classified, {'min': 10, 'max': 100,\n                         'palette': ['006400', 'ffbb22', 'ffff4c', 'f096ff',\n                                     'fa0000', 'b4b4b4', 'f0f0f0', '0064c8',\n                                     '0096a0', '00cf75', 'fae6a0']},\n            '随机森林分类结果')"}, {"file": "积雪_NDSI_阈值.py", "name": "积雪_NDSI_阈值", "desc": "Sentinel-2 NDSI 阈值提取积雪（NDSI=(B3-B11)/(B3+B11)，>0.4 判为雪）", "lines": 15, "code": "# @gee-desc: Sentinel-2 NDSI 阈值提取积雪（NDSI=(B3-B11)/(B3+B11)，>0.4 判为雪）\nimport ee\n\nSTART, END = '2025-01-01', '2025-03-31'      # 雪季；夏季提取会得到一片空\nNDSI_THRESHOLD = 0.4\n\ncol = (ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')\n       .filterDate(START, END)\n       .filterBounds(aoi)\n       .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20)))\nm.check(col.size(), '雪季影像数')            # 0 = 换年份或把日期放宽\n\nndsi = col.select(['B3', 'B11']).median().normalizedDifference(['B3', 'B11']).rename('NDSI')\nsnow = ndsi.gt(NDSI_THRESHOLD).selfMask().rename('snow')\nm.add_layer(snow, {'palette': ['2b8cbe']}, f'积雪(NDSI>{NDSI_THRESHOLD})')"}, {"file": "类别面积统计_频数.py", "name": "类别面积统计_频数", "desc": "按类别统计像元数与面积（频数直方图；面积 = 像元数 × 像元面积）", "lines": 16, "code": "# @gee-desc: 按类别统计像元数与面积（频数直方图；面积 = 像元数 × 像元面积）\nimport ee\n\nlc = ee.ImageCollection('ESA/WorldCover/v200').filterBounds(aoi).first().select('Map')\nm.check(lc.bandNames().length(), '波段数')\n\n# 100m 分辨率 ⇒ 单像元 0.01 km²。reduceRegion 只返回\"像元数\"，面积要自己乘\nhist = lc.reduceRegion(reducer=ee.Reducer.frequencyHistogram(),\n                       geometry=aoi, scale=100, maxPixels=1e9)\nm.check(hist, '各类别像元数')\n\nm.add_layer(lc, {'min': 10, 'max': 100,\n                 'palette': ['006400', 'ffbb22', 'ffff4c', 'f096ff', 'fa0000',\n                             'b4b4b4', 'f0f0f0', '0064c8', '0096a0', '00cf75',\n                             'fae6a0']},\n            'WorldCover 类别')"}, {"file": "耕地占比_ProbaV.py", "name": "耕地占比_ProbaV", "desc": "耕地覆盖百分比（Proba-V 100m 连续面，0~100%，看种植强度而非仅耕地掩膜）", "lines": 13, "code": "# @gee-desc: 耕地覆盖百分比（Proba-V 100m 连续面，0~100%，看种植强度而非仅耕地掩膜）\nimport ee\n\nlc = (ee.ImageCollection('COPERNICUS/Landcover/100m/Proba-V-C3/Global')\n      .filterDate('2015-01-01', '2020-01-01')\n      .filterBounds(aoi))\nm.check(lc.size(), '匹配影像数')\n\ncrop = lc.select('crops-coverfraction').mean()\n\nm.add_layer(crop, {'min': 0, 'max': 100,\n                   'palette': ['ffffff', 'fff7bc', 'fec44f', 'd95f0e', '993404']},\n            '耕地占比(%)')"}, {"file": "聚类_KMeans.py", "name": "聚类_KMeans", "desc": "无监督聚类（KMeans 8 类）—— 没有训练样本时先看地物能不能自动分开", "lines": 16, "code": "# @gee-desc: 无监督聚类（KMeans 8 类）—— 没有训练样本时先看地物能不能自动分开\nimport ee\n\ncomp = (ee.ImageCollection('LANDSAT/COMPOSITES/C02/T1_L2_32DAY')\n        .filterDate('2024-01-01', '2024-12-31')\n        .filterBounds(aoi).median().clip(aoi))\nm.check(comp.bandNames().length(), '波段数')\n\ntrain = comp.sample(region=aoi, scale=60, numPixels=3000, seed=7)\nm.check(train.size(), '训练像元数')\n\nclusterer = ee.Clusterer.wekaKMeans(8).train(train)\nclusters = comp.cluster(clusterer)\n\n# 随机配色：类别号本身没有含义，别把颜色当\"地物代号\"去解读\nm.add_layer(clusters.randomVisualizer(), {}, 'KMeans 8 类(随机配色)')"}, {"file": "草地退化_NDVI趋势.py", "name": "草地退化_NDVI趋势", "desc": "逐年 NDVI 的线性趋势（线性拟合斜率 = 每年变化量，负值即退化）", "lines": 31, "code": "# @gee-desc: 逐年 NDVI 的线性趋势（线性拟合斜率 = 每年变化量，负值即退化）\nimport ee\n\n\ndef yearly(y):\n    y = ee.Number(y)\n    col = (ee.ImageCollection('MODIS/061/MOD13A1')\n           .filterDate(ee.Date.fromYMD(y, 1, 1),\n                       ee.Date.fromYMD(y, 1, 1).advance(1, 'year'))\n           .filterBounds(aoi).select('NDVI'))\n    ndvi = col.mean().multiply(0.0001).rename('ndvi')\n    # ★ linearFit 要的是**两波段**影像（第 1 波段 x=时间，第 2 波段 y=值）。\n    #   只放一个 NDVI 波段会报 \"Need 2 bands for Reducer.linearFit, has 1\"。\n    #   所以把年份做成常量波段，和 NDVI 一起放进每一景。\n    # ★ 常量波段的像素类型是 Float<2015,2015>，十景放一起会被判\"非同质集合\"\n    #   （Expected type: Float<2015.0,2015.0>）；必须 toFloat() 抹掉常量区间\n    return ee.Image.constant(y).toFloat().rename('year').addBands(ndvi)\n\n\nyears = ee.List.sequence(2015, 2024)\nstack = ee.ImageCollection(years.map(yearly))\nm.check(stack.size(), '参与拟合的年数')\n\n# linearFit 返回两波段：scale=斜率、offset=截距；斜率单位是 \"NDVI/年\"\nfit = stack.reduce(ee.Reducer.linearFit())\nslope = fit.select(0).rename('slope')     # 按位置取斜率（'scale' 是派生名，不写死）\n\nm.add_layer(slope, {'min': -0.01, 'max': 0.01,\n                    'palette': ['a50026', 'f46d43', 'fee090', 'ffffff',\n                                'd9f0d3', '66bd63', '006837']},\n            'NDVI 年际斜率(NDVI/年)')"}, {"file": "蒸散_八天_MOD16A2.py", "name": "蒸散_八天_MOD16A2", "desc": "MODIS 蒸散发（MOD16A2：每景是 8 天累计，比例因子 0.1，单位 mm/8天）", "lines": 15, "code": "# @gee-desc: MODIS 蒸散发（MOD16A2：每景是 8 天累计，比例因子 0.1，单位 mm/8天）\nimport ee\n\net = (ee.ImageCollection('MODIS/061/MOD16A2')\n      .filterDate('2024-05-01', '2024-09-01')\n      .filterBounds(aoi))\nm.check(et.size(), '匹配影像数')\n\n# ★ 两个坑：① 比例因子是 0.1（不是 1e-4）；② 每景本身就是 8 天累计，\n#   所以时段总量用 sum，用 mean 得到的是\"平均 8 天\"而不是\"整季 mm\"\ntotal = et.select('ET').sum().multiply(0.1).rename('ET')\n\nm.add_layer(total, {'min': 0, 'max': 500,\n                    'palette': ['ffffff', 'c7e9c0', '74c476', '238b45', '00441b']},\n            '5~8 月累计蒸散(mm)')"}, {"file": "蒸散发_MODIS_累计.py", "name": "蒸散发_MODIS_累计", "desc": "MODIS 蒸散发 ET 区间累计（mm），水资源与干旱评估常用", "lines": 15, "code": "# @gee-desc: MODIS 蒸散发 ET 区间累计（mm），水资源与干旱评估常用\nimport ee\n\nSTART, END = '2025-06-01', '2025-09-30'\n\ncol = (ee.ImageCollection('MODIS/061/MOD16A2')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), '8 天合成期数')\n\n# ET 单位 kg/m²/8天（等价 mm/8天），比例因子 0.1；逐期相加即区间累计\net = col.select('ET').sum().multiply(0.1).rename('ET')\nm.add_layer(et, {'min': 0, 'max': 400,\n                 'palette': ['ffffcc', 'c2e699', '78c679', '31a354', '006837']},\n            '蒸散发累计(mm)')"}, {"file": "降水_CHIRPS_月累计.py", "name": "降水_CHIRPS_月累计", "desc": "CHIRPS 日降水累加成月降水（mm/月），干旱与水文分析的起点", "lines": 16, "code": "# @gee-desc: CHIRPS 日降水累加成月降水（mm/月），干旱与水文分析的起点\nimport ee\n\nMONTH = '2025-07'\nSTART, END = MONTH + '-01', '2025-08-01'      # 换月份时 END 要一起改（跨年写全年份）\n\ncol = (ee.ImageCollection('UCSB-CHG/CHIRPS/DAILY')\n       .filterDate(START, END)\n       .filterBounds(aoi))\nm.check(col.size(), '当月日降水影像数（正常 28~31）')\n\n# 日降水单位 mm/day，同一月逐日相加 = 月累计（mm）\nprecip = col.select('precipitation').sum().rename('precip')\nm.add_layer(precip, {'min': 0, 'max': 300,\n                     'palette': ['ffffcc', 'a1dab4', '41b6c4', '2c7fb8', '253494']},\n            f'{MONTH} 累计降水(mm)')"}, {"file": "降水_GPM_月累计.py", "name": "降水_GPM_月累计", "desc": "GPM IMERG 月降水累计（注意：product 是 mm/hr 的月均降水率，要乘小时数）", "lines": 20, "code": "# @gee-desc: GPM IMERG 月降水累计（注意：product 是 mm/hr 的月均降水率，要乘小时数）\nimport ee\n\ngpm = (ee.ImageCollection('NASA/GPM_L3/IMERG_MONTHLY_V07')\n       .filterDate('2024-06-01', '2024-09-01')\n       .filterBounds(aoi))\nm.check(gpm.size(), '匹配影像数')\n\ndef to_mm(img):\n    # ★ 这是最容易错的一步：`precipitation` 的单位是 **mm/hr**（月平均降水率），\n    #   直接 sum 等于把\"每小时毫米\"当\"每月毫米\"，结果会小到只有几毫米。\n    #   必须先乘当月小时数（天数 × 24）才是月累计 mm。\n    t0 = ee.Date(img.get('system:time_start'))\n    hours = t0.advance(1, 'month').difference(t0, 'day').multiply(24)\n    return img.select('precipitation').multiply(hours).rename('mm')\n\ntotal = gpm.map(to_mm).sum()      # 6~8 月累计\nm.add_layer(total, {'min': 0, 'max': 600,\n                    'palette': ['ffffff', 'c7e9f1', '41b6c4', '1d91c0', '0c2c84']},\n            '2024 夏季累计降水(mm)')"}, {"file": "雪盖_八天_MOD10A2.py", "name": "雪盖_八天_MOD10A2", "desc": "MODIS 八天雪盖（MOD10A2：0~100 是覆盖百分比，200=云、255=填充）", "lines": 20, "code": "# @gee-desc: MODIS 八天雪盖（MOD10A2：0~100 是覆盖百分比，200=云、255=填充）\nimport ee\n\nsnow = (ee.ImageCollection('MODIS/061/MOD10A2')\n        .filterDate('2024-01-01', '2024-04-01')\n        .filterBounds(aoi))\nm.check(snow.size(), '匹配影像数')\n\n\ndef clean(img):\n    b = img.select('Eight_Day_Snow_Cover')\n    # ★ 200=云、255=填充，必须掩掉，否则\"云\"会被当成 200% 的雪\n    return b.updateMask(b.lt(200))\n\n\ncover = snow.map(clean).median()\n\nm.add_layer(cover, {'min': 0, 'max': 100,\n                    'palette': ['ffffff', 'e0f3f8', 'abd9e9', '74add1', '4575b4']},\n            '积雪覆盖(%)')"}]