File size: 15,619 Bytes
80e070e
 
 
 
820fab0
 
 
 
 
 
 
 
 
 
80e070e
820fab0
 
 
 
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820fab0
 
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820fab0
 
80e070e
 
 
 
 
 
 
820fab0
 
80e070e
 
 
820fab0
 
 
80e070e
 
 
820fab0
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820fab0
 
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820fab0
 
 
 
 
 
80e070e
 
 
 
 
820fab0
 
 
80e070e
 
 
 
 
 
820fab0
 
 
 
80e070e
 
 
 
 
 
820fab0
 
 
 
80e070e
 
 
 
820fab0
 
80e070e
 
 
 
 
 
 
 
820fab0
80e070e
 
 
 
 
 
 
 
 
 
 
 
 
 
820fab0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80e070e
820fab0
 
80e070e
820fab0
 
80e070e
 
 
 
 
 
 
 
 
 
 
820fab0
80e070e
820fab0
 
 
 
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
import os
import numpy as np
np.Inf = np.inf  # Patch for Keras/Numpy 2.0 compatibility
import tensorflow as tf

# Enable GPU Memory Growth to prevent TF from hoarding all 48GB of VRAM immediately
gpus = tf.config.list_physical_devices('GPU')
if gpus:
    try:
        for gpu in gpus:
            tf.config.experimental.set_memory_growth(gpu, True)
    except RuntimeError as e:
        print(e)

from data_generator import RockGlacierDataGenerator
from models.swin_unet import build_model as build_swin_unet
from models.attention_unet import build_attention_unet
from models.unet import build_unet
from models.segformer import build_segformer
from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, CSVLogger, ReduceLROnPlateau

# ─────────────────────────────────────────────────────────────────────────────
# Data + band selection  (all env-driven; patch size auto-detected)
#   DATA_DIR  : patches root (default all_data/patches) -> {train,val}/{features,masks}
#   BAND_LIST : comma-separated band names, used as-is        (highest priority)
#   BANDS     : a named preset in BAND_SETS below
#   RUN_NAME  : names the checkpoint dir (all_data/checkpoint_<RUN_NAME>)
# Patch size is read from the data, so the SAME script trains the 64 px (30 m)
# set and the 128 px (10 m) set with no edits.
# ─────────────────────────────────────────────────────────────────────────────
import json as _json, glob as _glob

DATA_DIR   = os.environ.get("DATA_DIR", "all_data/patches")
TRAIN_FEAT = f"{DATA_DIR}/train/features"
TRAIN_MASK = f"{DATA_DIR}/train/masks"
VAL_FEAT   = os.environ.get("VAL_FEAT",  f"{DATA_DIR}/val/features")
VAL_MASK   = os.environ.get("VAL_MASK",  f"{DATA_DIR}/val/masks")

BAND_SETS = {
    "14": ["TPI", "ROUGHNESS", "LST_winter", "NDWI", "LST_summer", "REL_RELIEF",
           "ASPECT", "LST_anomaly", "ELEV_COP", "B2", "HILLSHADE", "B8A", "NDMI", "B12"],
    "17": ["TPI", "ROUGHNESS", "LST_winter", "NDWI", "LST_summer", "REL_RELIEF",
           "ASPECT", "LST_anomaly", "ELEV_COP", "B2", "HILLSHADE", "B8A", "NDMI", "B12",
           "B3", "B4", "B8"],
    "opt_idx": ["B2", "B3", "B4", "B8", "B8A", "B11", "B12", "NDVI", "NDWI", "NDMI", "NDSI", "BSI", "ALBEDO", "ELEV_COP"],
    "terr_therm": ["TPI", "ROUGHNESS", "SLOPE", "ASPECT", "REL_RELIEF", "HILLSHADE", "CURVATURE", "ELEV_COP", "LST_winter", "LST_summer", "LST_annual", "LST_anomaly"],
    "mixed_16": ["TPI", "ROUGHNESS", "REL_RELIEF", "ELEV_COP", "SLOPE", "LST_winter", "LST_summer", "NDWI", "NDVI", "B2", "B3", "B4", "B8", "B12", "NDMI", "BSI"],
    "top_20": ["TPI", "ROUGHNESS", "LST_winter", "NDWI", "LST_summer", "REL_RELIEF", "ASPECT", "LST_anomaly", "ELEV_COP", "B2", "HILLSHADE", "B8A", "NDMI", "B12", "B3", "B4", "B8", "NDVI", "SLOPE", "CURVATURE"],
}

_band_names = _json.load(open(f"{DATA_DIR}/metadata/norm_stats.json"))["band_names"]
_blist = os.environ.get("BAND_LIST")
_sel   = os.environ.get("BANDS", "all")
if _blist:
    _bands, RUN_NAME = [b.strip() for b in _blist.split(",") if b.strip()], os.environ.get("RUN_NAME", "custom")
elif _sel in BAND_SETS:
    _bands, RUN_NAME = BAND_SETS[_sel], os.environ.get("RUN_NAME", _sel + "band")
else:
    _bands, RUN_NAME = None, os.environ.get("RUN_NAME", "all")

if _bands is not None:
    _missing = [b for b in _bands if b not in _band_names]
    assert not _missing, f"Unknown band(s) {_missing}\nAvailable: {_band_names}"
    BAND_INDICES, N_BANDS = [_band_names.index(b) for b in _bands], len(_bands)
else:
    BAND_INDICES, N_BANDS, _bands = None, len(_band_names), list(_band_names)

CKPT_DIR = os.environ.get("CKPT_DIR",
    "all_data/checkpoint" if RUN_NAME == "all" else f"all_data/checkpoint_{RUN_NAME}")
os.makedirs(CKPT_DIR, exist_ok=True)

# Patch size auto-detected from a sample patch (64 px now, 128 px for 10 m later).
_ph, _pw = np.load(sorted(_glob.glob(f"{TRAIN_FEAT}/*.npy"))[0]).shape[:2]
print(f"[train] run={RUN_NAME}  bands={N_BANDS}  patch={_ph}x{_pw}  data={DATA_DIR}")

# ─────────────────────────────────────────────────────────────────────────────
# Hyperparameters
# ─────────────────────────────────────────────────────────────────────────────
BATCH_SIZE  = int(os.environ.get("BATCH_SIZE", 32))
EPOCHS      = int(os.environ.get("EPOCHS", 150))
LR          = 1e-4
INPUT_SHAPE = (_ph, _pw, N_BANDS)

# ─────────────────────────────────────────────────────────────────────────────
# Generators
# ─────────────────────────────────────────────────────────────────────────────
print("Initializing Data Generators...")
_COPY_PASTE = os.environ.get("COPY_PASTE", "0") == "1"
_CP_PROB    = float(os.environ.get("COPY_PASTE_PROB", 0.5))
train_gen = RockGlacierDataGenerator(TRAIN_FEAT, TRAIN_MASK, batch_size=BATCH_SIZE,
                                      target_size=INPUT_SHAPE[:2], augment=True,
                                      return_weight=True, radiometric=True,
                                      band_indices=BAND_INDICES,
                                      copy_paste=_COPY_PASTE, copy_paste_prob=_CP_PROB)
                                      # use ALL patches; COPY_PASTE=1 enables copy-paste aug
val_gen   = RockGlacierDataGenerator(VAL_FEAT, VAL_MASK, batch_size=BATCH_SIZE,
                                      target_size=INPUT_SHAPE[:2], augment=False, shuffle=False,
                                      return_weight=True, band_indices=BAND_INDICES)
                                      # val keeps ALL patches for honest evaluation

# Record the exact normalization stats used, so evaluate.py matches at test time.
import json
with open(os.path.join(CKPT_DIR, 'norm_stats.json'), 'w') as f:
    json.dump({'mean': np.asarray(train_gen.GLOBAL_MEAN).reshape(-1).tolist(),
               'std':  np.asarray(train_gen.GLOBAL_STD).reshape(-1).tolist()}, f, indent=2)

# Record the exact run config (bands, patch size) so evaluate.py reproduces it
# with zero chance of a band/order mismatch.
with open(os.path.join(CKPT_DIR, 'run_config.json'), 'w') as f:
    json.dump({'run_name': RUN_NAME, 'bands': _bands, 'n_bands': N_BANDS,
               'patch_size': [int(_ph), int(_pw)], 'data_dir': DATA_DIR}, f, indent=2)


# ─────────────────────────────────────────────────────────────────────────────
# Loss / metrics  β€” PADDING-MASKED.
# y_true is (B,H,W,2): channel 0 = label, channel 1 = validity weight (1=real
# pixel, 0=zero-padding). All sums below are restricted to valid pixels so the
# model is never trained or scored on padded regions.
# ─────────────────────────────────────────────────────────────────────────────
def _split_label_weight(y_true):
    yt = tf.cast(y_true[..., 0:1], tf.float32)
    w  = tf.cast(y_true[..., 1:2], tf.float32)
    return yt, w


# Focal-Tversky hyperparameters.
#   Tversky index TI = TP / (TP + ALPHA*FP + BETA*FN)
#   ALPHA>BETA penalizes false positives harder -> raises precision (the model
#   currently over-segments: FP > FN). GAMMA focuses the loss on hard pixels.
ALPHA_FP     = 0.4   # slightly higher β€” new dataset has real background patches in training
BETA_FN      = 0.6   # balanced β€” new dataset has much better class quality
GAMMA_FT     = 1.3333
BCE_WEIGHT   = 0.5     # light masked BCE term for stable early gradients
LABEL_SMOOTH = 0.05


def focal_tversky_loss(y_true, y_pred):
    smooth = 1e-6
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred, tf.float32) * w          # soft probs, padded pixels zeroed
    yt = yt * w
    tp = tf.reduce_sum(yt * yp, axis=[1, 2, 3])
    fp = tf.reduce_sum((1.0 - yt) * yp, axis=[1, 2, 3])
    fn = tf.reduce_sum(yt * (1.0 - yp), axis=[1, 2, 3])
    ti = (tp + smooth) / (tp + ALPHA_FP * fp + BETA_FN * fn + smooth)
    return tf.reduce_mean(tf.pow(1.0 - ti, GAMMA_FT))


def combined_loss(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yt_s = yt * (1.0 - LABEL_SMOOTH) + 0.5 * LABEL_SMOOTH      # binary label smoothing
    bce = tf.keras.backend.binary_crossentropy(yt_s, tf.cast(y_pred, tf.float32))
    bce = tf.reduce_sum(bce * w) / (tf.reduce_sum(w) + 1e-6)   # mean over valid pixels
    return BCE_WEIGHT * bce + focal_tversky_loss(y_true, y_pred)


# ─────────────────────────────────────────────────────────────────────────────
# Metrics  (all at threshold 0.5, valid pixels only; logged by CSVLogger)
#
# IMPORTANT: each metric uses tf.cond to return 0.0 when the batch contains
# no positive pixels, instead of the previous 1e-6/1e-6 = 1.0 artifact.
# With 52% background-only patches, the old smoothed formulas returned 1.0 for
# those batches and inflated the epoch-averaged val_dice by ~0.4–0.5 points,
# causing ModelCheckpoint to save the wrong epoch as "best".
# ─────────────────────────────────────────────────────────────────────────────
def dice_coef(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred > 0.5, tf.float32) * w
    yt = tf.cast(yt > 0.5, tf.float32) * w
    inter = tf.reduce_sum(yt * yp)
    denom = tf.reduce_sum(yt) + tf.reduce_sum(yp)
    return tf.cond(denom < 0.5, lambda: 0.0, lambda: (2. * inter) / denom)


def precision_m(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred > 0.5, tf.float32) * w
    yt = tf.cast(yt > 0.5, tf.float32) * w
    tp = tf.reduce_sum(yt * yp)
    fp = tf.reduce_sum(yp * (1.0 - yt))
    denom = tp + fp
    return tf.cond(denom < 0.5, lambda: 0.0, lambda: tp / denom)


def recall_m(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred > 0.5, tf.float32) * w
    yt = tf.cast(yt > 0.5, tf.float32) * w
    tp = tf.reduce_sum(yt * yp)
    fn = tf.reduce_sum(yt * (1.0 - yp))
    denom = tp + fn
    return tf.cond(denom < 0.5, lambda: 0.0, lambda: tp / denom)


def f1_m(y_true, y_pred):
    p = precision_m(y_true, y_pred); r = recall_m(y_true, y_pred)
    denom = p + r
    return tf.cond(denom < 1e-6, lambda: 0.0, lambda: (2.0 * p * r) / denom)


def iou_m(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred > 0.5, tf.float32) * w
    yt = tf.cast(yt > 0.5, tf.float32) * w
    inter = tf.reduce_sum(yt * yp)
    union = tf.reduce_sum(yt) + tf.reduce_sum(yp) - inter
    return tf.cond(union < 0.5, lambda: 0.0, lambda: inter / union)


def acc_m(y_true, y_pred):
    yt, w = _split_label_weight(y_true)
    yp = tf.cast(y_pred > 0.5, tf.float32)
    yt_b = tf.cast(yt > 0.5, tf.float32)
    correct = tf.cast(tf.equal(yp, yt_b), tf.float32) * w
    return tf.reduce_sum(correct) / (tf.reduce_sum(w) + 1e-6)


# ─────────────────────────────────────────────────────────────────────────────
# Build Model
# ─────────────────────────────────────────────────────────────────────────────
print("Building Model...")
MODEL_TYPE = os.environ.get("MODEL_NAME", "swin_unet")
if MODEL_TYPE == "attention_unet":
    model = build_attention_unet(input_shape=INPUT_SHAPE, num_classes=1)
elif MODEL_TYPE == "unet":
    model = build_unet(input_shape=INPUT_SHAPE, num_classes=1)
elif MODEL_TYPE == "segformer":
    model = build_segformer(input_shape=INPUT_SHAPE, num_classes=1)
else:
    model = build_swin_unet(input_shape=INPUT_SHAPE, num_classes=1, num_heads=8)
# Cosine annealing: smoothly decay LR from 1e-4 β†’ 1e-6 over all epochs.
# Replaces ReduceLROnPlateau β€” no sudden drops, lets the model explore longer.
_total_steps = len(train_gen) * EPOCHS
cosine_lr = tf.keras.optimizers.schedules.CosineDecay(
    initial_learning_rate=LR,
    decay_steps=_total_steps,
    alpha=1e-2,   # minimum LR = LR * alpha = 1e-4 * 1e-2 = 1e-6
)
_WD = float(os.environ.get("WEIGHT_DECAY", 1e-4))   # raise (e.g. 5e-4) to regularize more
try:
    optimizer = tf.keras.optimizers.AdamW(learning_rate=cosine_lr, weight_decay=_WD)
    print(f"Optimizer: AdamW + CosineDecay  ({_total_steps} steps, wd={_WD})")
except Exception as e:
    optimizer = tf.keras.optimizers.Adam(learning_rate=cosine_lr)
    print(f"Optimizer: Adam + CosineDecay  ({_total_steps} steps)")
model.compile(optimizer=optimizer,
              loss=combined_loss,
              metrics=[acc_m, dice_coef, precision_m, recall_m, f1_m, iou_m])

model.summary()

# ─────────────────────────────────────────────────────────────────────────────
# Callbacks
# ─────────────────────────────────────────────────────────────────────────────
callbacks = [
    ModelCheckpoint(os.path.join(CKPT_DIR, 'best_model.weights.h5'),
                    monitor='val_loss', mode='min',
                    save_best_only=True, save_weights_only=True, verbose=1),
    # Patience=35: cosine LR decays smoothly so the model needs more room to
    # naturally improve without the crutch of sudden LR reductions.
    EarlyStopping(monitor='val_loss', patience=35, restore_best_weights=True, verbose=1),
    # ReduceLROnPlateau removed β€” cosine annealing handles LR schedule.
    CSVLogger(os.path.join(CKPT_DIR, 'training_history.csv')),
]

# ─────────────────────────────────────────────────────────────────────────────
# Train
# ─────────────────────────────────────────────────────────────────────────────
print("Starting Training...")
history = model.fit(
    train_gen,
    validation_data=val_gen,
    epochs=EPOCHS,
    callbacks=callbacks,
)
print("Training Complete.")