File size: 7,357 Bytes
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""
RockGlacierNet β€” Stage 1: feature/mask COLLECTION (Option A: enlarged window).

For every rock-glacier polygon in the inventory shapefile:
  1. compute its bbox in UTM and pad by CONTEXT_BUFFER_M on every side,
  2. download a 12-band Sentinel-2 + terrain feature stack over that window,
  3. rasterize the polygon to the EXACT grid of the downloaded feature tile,
     so feature and mask are co-registered by construction (no harmonize step).

Outputs (and nothing outside patch_dataset/):
  patch_dataset/raw/features/feature_XXXX.tif   (12-band, float32, 10 m, UTM45N)
  patch_dataset/raw/masks/mask_XXXX.tif         (1-band uint8, same grid)

ENVIRONMENT: needs `earthengine-api`, `geemap`, `geopandas`, `rasterio`.
Run on the machine that has them (your Windows box):
    python preprocess/collect_features.py
    python preprocess/collect_features.py --limit 5     # smoke test
    python preprocess/collect_features.py --shapefile /path/to/your.shp

All tunables live in patch_dataset/config.py.
"""

import os
import sys
import argparse

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from patch_dataset import config as C

import numpy as np
import geopandas as gpd
import rasterio
from rasterio.features import rasterize
import ee
import geemap
from shapely.geometry import box


# ─────────────────────────────────────────────────────────────────────────────
# Earth Engine image builders  (band order MUST match C.BAND_NAMES)
# ─────────────────────────────────────────────────────────────────────────────

def get_s2_image(roi):
    coll = (ee.ImageCollection(C.S2_COLLECTION)
            .filterBounds(roi)
            .filterDate(C.S2_DATE_START, C.S2_DATE_END))

    def mask_clouds(image):
        qa = image.select("QA60")
        cloud  = 1 << 10
        cirrus = 1 << 11
        m = qa.bitwiseAnd(cloud).eq(0).And(qa.bitwiseAnd(cirrus).eq(0))
        return image.updateMask(m).divide(C.S2_SR_DIVISOR)

    s2 = coll.map(mask_clouds).median().clip(roi)
    ndvi = s2.normalizedDifference(["B8", "B4"]).rename("NDVI")
    ndwi = s2.normalizedDifference(["B3", "B8"]).rename("NDWI")
    ndsi = s2.normalizedDifference(["B3", "B11"]).rename("NDSI")
    # B2,B3,B4,B8,B11,B12,NDVI,NDWI,NDSI
    return s2.select(["B2", "B3", "B4", "B8", "B11", "B12"]).addBands([ndvi, ndwi, ndsi])


def get_terrain_image(roi):
    # Compute slope/aspect BEFORE mosaicking so the native 30 m grid is preserved.
    def add_terrain(img):
        slope  = ee.Terrain.slope(img).rename("slope")
        aspect = ee.Terrain.aspect(img).rename("aspect")
        return img.addBands([slope, aspect])

    terrain = (ee.ImageCollection(C.DEM_COLLECTION).select("DEM")
               .map(add_terrain).mosaic().clip(roi))
    elevation = terrain.select("DEM").rename("elevation")   # Elev
    slope     = terrain.select("slope")                     # Slope
    aspect    = terrain.select("aspect")                    # Aspect
    return elevation.addBands([slope, aspect])


def build_feature_image(roi):
    """12-band stack in the contractual order C.BAND_NAMES."""
    return get_s2_image(roi).addBands(get_terrain_image(roi))


# ─────────────────────────────────────────────────────────────────────────────
# Per-polygon processing
# ─────────────────────────────────────────────────────────────────────────────

def padded_roi(geom_utm):
    """Return (ee_roi_4326, bounds_utm) for a single UTM geometry + buffer."""
    minx, miny, maxx, maxy = geom_utm.bounds
    b = C.CONTEXT_BUFFER_M
    minx, miny, maxx, maxy = minx - b, miny - b, maxx + b, maxy + b
    box_utm   = gpd.GeoDataFrame(geometry=[box(minx, miny, maxx, maxy)], crs=C.TARGET_CRS)
    box_4326  = box_utm.to_crs("EPSG:4326").total_bounds
    roi = ee.Geometry.BBox(box_4326[0], box_4326[1], box_4326[2], box_4326[3])
    return roi, (minx, miny, maxx, maxy)


def rasterize_aligned_mask(geom_utm, feat_tif_path, out_mask_path):
    """Rasterize geom onto the EXACT grid of the downloaded feature tile."""
    with rasterio.open(feat_tif_path) as src:
        transform, height, width, crs = src.transform, src.height, src.width, src.crs
    mask = rasterize(
        [(geom_utm, 1)],
        out_shape=(height, width),
        transform=transform,
        fill=0,
        dtype="uint8",
    )
    with rasterio.open(
        out_mask_path, "w", driver="GTiff",
        height=height, width=width, count=1, dtype="uint8",
        crs=crs, transform=transform, nodata=0,
    ) as dst:
        dst.write(mask, 1)
    return int(mask.sum())


def main():
    ap = argparse.ArgumentParser(description="Collect enlarged-window feature/mask tiles.")
    ap.add_argument("--shapefile", default=str(C.SHAPEFILE_PATH))
    ap.add_argument("--limit", type=int, default=None, help="process only first N polygons")
    args = ap.parse_args()

    # EE init
    try:
        ee.Initialize(project=C.GEE_PROJECT)
    except Exception:
        ee.Authenticate()
        ee.Initialize(project=C.GEE_PROJECT)

    C.RAW_FEATURES_DIR.mkdir(parents=True, exist_ok=True)
    C.RAW_MASKS_DIR.mkdir(parents=True, exist_ok=True)

    gdf = gpd.read_file(args.shapefile).to_crs(C.TARGET_CRS)
    n = len(gdf) if args.limit is None else min(args.limit, len(gdf))
    print(f"Loaded {len(gdf)} polygons from {args.shapefile} | processing {n} "
          f"| buffer={C.CONTEXT_BUFFER_M:.0f} m | scale={C.SCALE_M:.0f} m")

    n_done = n_skip = n_fail = 0
    for idx in range(n):
        geom = gdf.geometry.iloc[idx]
        if geom is None or geom.is_empty:
            print(f"  [{idx:04d}] empty geometry β€” skipped")
            n_skip += 1
            continue

        feat_path = C.RAW_FEATURES_DIR / f"feature_{idx:04d}.tif"
        mask_path = C.RAW_MASKS_DIR / f"mask_{idx:04d}.tif"
        if feat_path.exists() and mask_path.exists():
            n_skip += 1
            continue

        try:
            roi, _ = padded_roi(geom)
            img = build_feature_image(roi)
            geemap.ee_export_image(
                img, filename=str(feat_path), scale=C.SCALE_M,
                region=roi, crs=C.TARGET_CRS, file_per_band=False,
            )
            fg = rasterize_aligned_mask(geom, str(feat_path), str(mask_path))
            n_done += 1
            if n_done % 25 == 0 or idx == n - 1:
                print(f"  [{idx:04d}] done={n_done} skip={n_skip} fail={n_fail} "
                      f"(last fg px={fg})")
        except Exception as e:
            n_fail += 1
            print(f"  [{idx:04d}] FAILED: {e}")

    print(f"\nCollection complete. done={n_done} skipped={n_skip} failed={n_fail}")
    print(f"Features -> {C.RAW_FEATURES_DIR}")
    print(f"Masks    -> {C.RAW_MASKS_DIR}")


if __name__ == "__main__":
    main()