513 lines
16 KiB
Python
513 lines
16 KiB
Python
"""
|
|
Train Cloud Removal Model using SEN12MS-CR Dataset
|
|
Huấn luyện model Deep Learning để khử mây từ ảnh Sentinel-2
|
|
|
|
Dataset: SEN12MS-CR (Sentinel-12 Multi-Seasonal Cloud Removal)
|
|
- Input: S2 cloudy images (ảnh Sentinel-2 bị mây)
|
|
- Target: S2 clean images (ảnh Sentinel-2 sạch)
|
|
- Optional: S1 SAR data (radar data không bị ảnh hưởng bởi mây)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.optim as optim
|
|
from torch.utils.data import Dataset, DataLoader
|
|
from pathlib import Path
|
|
import matplotlib.pyplot as plt
|
|
from tqdm import tqdm
|
|
|
|
# Add winter_dataset to path
|
|
sys.path.insert(0, str(Path(__file__).parent / "winter_dataset"))
|
|
from sen12ms_cr_dataLoader import SEN12MSCRDataset, Seasons, S1Bands, S2Bands
|
|
|
|
|
|
# ============ DATASET WRAPPER ============
|
|
|
|
class CloudRemovalDataset(Dataset):
|
|
"""
|
|
PyTorch Dataset wrapper cho SEN12MS-CR
|
|
Input: S2 cloudy + S1 (optional)
|
|
Target: S2 clean
|
|
"""
|
|
|
|
def __init__(self, base_dir, season=Seasons.WINTER, use_s1=True,
|
|
s2_bands=S2Bands.ALL, normalize=True):
|
|
"""
|
|
Args:
|
|
base_dir: Đường dẫn đến thư mục chứa dữ liệu
|
|
season: Mùa (SPRING, SUMMER, FALL, WINTER)
|
|
use_s1: Có sử dụng dữ liệu S1 (radar) không
|
|
s2_bands: Các band S2 cần dùng
|
|
normalize: Normalize dữ liệu về [0, 1]
|
|
"""
|
|
self.dataset = SEN12MSCRDataset(base_dir)
|
|
self.season = season
|
|
self.use_s1 = use_s1
|
|
self.s2_bands = s2_bands
|
|
self.normalize = normalize
|
|
|
|
# Lấy tất cả scene và patch IDs
|
|
season_ids = self.dataset.get_season_ids(season)
|
|
|
|
# Tạo list of (scene_id, patch_id) pairs
|
|
self.samples = []
|
|
for scene_id, patch_ids in season_ids.items():
|
|
for patch_id in patch_ids:
|
|
self.samples.append((scene_id, patch_id))
|
|
|
|
# Get band count
|
|
n_s2_bands = len(s2_bands.value) if hasattr(s2_bands, 'value') else len(s2_bands)
|
|
|
|
print(f"[DATASET] Loaded {len(self.samples)} samples from {season.value}")
|
|
print(f"[DATASET] Use S1: {use_s1}, S2 bands: {n_s2_bands}")
|
|
|
|
def __len__(self):
|
|
return len(self.samples)
|
|
|
|
def __getitem__(self, idx):
|
|
scene_id, patch_id = self.samples[idx]
|
|
|
|
# Load triplet: S1, S2 clean, S2 cloudy
|
|
s1, s2_clean, s2_cloudy, bounds = self.dataset.get_s1s2s2cloudy_triplet(
|
|
self.season,
|
|
scene_id,
|
|
patch_id,
|
|
s1_bands=S1Bands.ALL if self.use_s1 else S1Bands.NONE,
|
|
s2_bands=self.s2_bands,
|
|
s2cloudy_bands=self.s2_bands
|
|
)
|
|
|
|
# Normalize to [0, 1] if needed
|
|
if self.normalize:
|
|
s2_clean = s2_clean.astype(np.float32) / 10000.0 # S2 values are in [0, 10000]
|
|
s2_cloudy = s2_cloudy.astype(np.float32) / 10000.0
|
|
if self.use_s1:
|
|
# S1 values need different normalization (dB scale)
|
|
s1 = (s1.astype(np.float32) + 30) / 50.0 # Normalize from [-30, 20] to [0, 1]
|
|
s1 = np.clip(s1, 0, 1)
|
|
|
|
# Convert to torch tensors
|
|
s2_clean = torch.from_numpy(s2_clean).float()
|
|
s2_cloudy = torch.from_numpy(s2_cloudy).float()
|
|
|
|
# Input: S2 cloudy + S1 (if enabled)
|
|
if self.use_s1:
|
|
s1 = torch.from_numpy(s1).float()
|
|
input_data = torch.cat([s2_cloudy, s1], dim=0)
|
|
else:
|
|
input_data = s2_cloudy
|
|
|
|
return input_data, s2_clean
|
|
|
|
|
|
# ============ U-NET ARCHITECTURE ============
|
|
|
|
class DoubleConv(nn.Module):
|
|
"""(Conv2d -> BatchNorm -> ReLU) x 2"""
|
|
|
|
def __init__(self, in_channels, out_channels):
|
|
super().__init__()
|
|
self.double_conv = nn.Sequential(
|
|
nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1),
|
|
nn.BatchNorm2d(out_channels),
|
|
nn.ReLU(inplace=True),
|
|
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1),
|
|
nn.BatchNorm2d(out_channels),
|
|
nn.ReLU(inplace=True)
|
|
)
|
|
|
|
def forward(self, x):
|
|
return self.double_conv(x)
|
|
|
|
|
|
class UNet(nn.Module):
|
|
"""
|
|
U-Net architecture cho cloud removal
|
|
Input: S2 cloudy (+ S1 optional) [B, C_in, H, W]
|
|
Output: S2 clean [B, C_out, H, W]
|
|
"""
|
|
|
|
def __init__(self, in_channels, out_channels, features=[64, 128, 256, 512]):
|
|
super().__init__()
|
|
self.encoder = nn.ModuleList()
|
|
self.decoder = nn.ModuleList()
|
|
self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
|
|
|
|
# Encoder (downsampling)
|
|
for feature in features:
|
|
self.encoder.append(DoubleConv(in_channels, feature))
|
|
in_channels = feature
|
|
|
|
# Bottleneck
|
|
self.bottleneck = DoubleConv(features[-1], features[-1] * 2)
|
|
|
|
# Decoder (upsampling)
|
|
for feature in reversed(features):
|
|
self.decoder.append(
|
|
nn.ConvTranspose2d(feature * 2, feature, kernel_size=2, stride=2)
|
|
)
|
|
self.decoder.append(DoubleConv(feature * 2, feature))
|
|
|
|
# Final output layer
|
|
self.final_conv = nn.Conv2d(features[0], out_channels, kernel_size=1)
|
|
|
|
def forward(self, x):
|
|
skip_connections = []
|
|
|
|
# Encoder
|
|
for encode in self.encoder:
|
|
x = encode(x)
|
|
skip_connections.append(x)
|
|
x = self.pool(x)
|
|
|
|
# Bottleneck
|
|
x = self.bottleneck(x)
|
|
|
|
# Decoder
|
|
skip_connections = skip_connections[::-1]
|
|
|
|
for idx in range(0, len(self.decoder), 2):
|
|
x = self.decoder[idx](x) # Upsample
|
|
skip_connection = skip_connections[idx // 2]
|
|
|
|
# Handle size mismatch
|
|
if x.shape != skip_connection.shape:
|
|
x = nn.functional.interpolate(x, size=skip_connection.shape[2:])
|
|
|
|
concat_skip = torch.cat((skip_connection, x), dim=1)
|
|
x = self.decoder[idx + 1](concat_skip) # Double conv
|
|
|
|
return self.final_conv(x)
|
|
|
|
|
|
# ============ TRAINING FUNCTIONS ============
|
|
|
|
def train_epoch(model, dataloader, criterion, optimizer, device):
|
|
"""Train for one epoch"""
|
|
model.train()
|
|
total_loss = 0
|
|
|
|
pbar = tqdm(dataloader, desc="Training")
|
|
for batch_idx, (inputs, targets) in enumerate(pbar):
|
|
inputs = inputs.to(device)
|
|
targets = targets.to(device)
|
|
|
|
# Forward pass
|
|
optimizer.zero_grad()
|
|
outputs = model(inputs)
|
|
loss = criterion(outputs, targets)
|
|
|
|
# Backward pass
|
|
loss.backward()
|
|
optimizer.step()
|
|
|
|
total_loss += loss.item()
|
|
pbar.set_postfix({'loss': loss.item()})
|
|
|
|
return total_loss / len(dataloader)
|
|
|
|
|
|
def validate(model, dataloader, criterion, device):
|
|
"""Validate model"""
|
|
model.eval()
|
|
total_loss = 0
|
|
|
|
with torch.no_grad():
|
|
for inputs, targets in tqdm(dataloader, desc="Validation"):
|
|
inputs = inputs.to(device)
|
|
targets = targets.to(device)
|
|
|
|
outputs = model(inputs)
|
|
loss = criterion(outputs, targets)
|
|
total_loss += loss.item()
|
|
|
|
return total_loss / len(dataloader)
|
|
|
|
|
|
def visualize_results(model, dataset, device, num_samples=3):
|
|
"""Visualize cloud removal results"""
|
|
model.eval()
|
|
|
|
fig, axes = plt.subplots(num_samples, 3, figsize=(15, 5 * num_samples))
|
|
|
|
with torch.no_grad():
|
|
for i in range(num_samples):
|
|
idx = np.random.randint(0, len(dataset))
|
|
input_data, target = dataset[idx]
|
|
|
|
input_data = input_data.unsqueeze(0).to(device)
|
|
output = model(input_data)
|
|
|
|
# Convert to numpy
|
|
input_rgb = input_data[0, :3, :, :].cpu().numpy().transpose(1, 2, 0)
|
|
target_rgb = target[:3, :, :].cpu().numpy().transpose(1, 2, 0)
|
|
output_rgb = output[0, :3, :, :].cpu().numpy().transpose(1, 2, 0)
|
|
|
|
# Clip to [0, 1]
|
|
input_rgb = np.clip(input_rgb * 3, 0, 1) # Enhance for visualization
|
|
target_rgb = np.clip(target_rgb * 3, 0, 1)
|
|
output_rgb = np.clip(output_rgb * 3, 0, 1)
|
|
|
|
if num_samples == 1:
|
|
axes[0].imshow(input_rgb)
|
|
axes[0].set_title("Input (Cloudy)")
|
|
axes[0].axis('off')
|
|
|
|
axes[1].imshow(output_rgb)
|
|
axes[1].set_title("Output (Predicted)")
|
|
axes[1].axis('off')
|
|
|
|
axes[2].imshow(target_rgb)
|
|
axes[2].set_title("Target (Clean)")
|
|
axes[2].axis('off')
|
|
else:
|
|
axes[i, 0].imshow(input_rgb)
|
|
axes[i, 0].set_title(f"Sample {i+1}: Input (Cloudy)")
|
|
axes[i, 0].axis('off')
|
|
|
|
axes[i, 1].imshow(output_rgb)
|
|
axes[i, 1].set_title(f"Sample {i+1}: Output (Predicted)")
|
|
axes[i, 1].axis('off')
|
|
|
|
axes[i, 2].imshow(target_rgb)
|
|
axes[i, 2].set_title(f"Sample {i+1}: Target (Clean)")
|
|
axes[i, 2].axis('off')
|
|
|
|
plt.tight_layout()
|
|
return fig
|
|
|
|
|
|
# ============ MAIN TRAINING SCRIPT ============
|
|
|
|
def train_cloud_removal_model(
|
|
data_dir="winter_dataset",
|
|
use_s1=True,
|
|
batch_size=8,
|
|
num_epochs=50,
|
|
learning_rate=1e-4,
|
|
device="cuda" if torch.cuda.is_available() else "cpu",
|
|
save_dir="model_train"
|
|
):
|
|
"""
|
|
Train cloud removal model
|
|
|
|
Args:
|
|
data_dir: Thư mục chứa dữ liệu SEN12MS-CR
|
|
use_s1: Có sử dụng S1 radar data không
|
|
batch_size: Batch size
|
|
num_epochs: Số epochs
|
|
learning_rate: Learning rate
|
|
device: 'cuda' hoặc 'cpu'
|
|
save_dir: Thư mục lưu model
|
|
"""
|
|
|
|
print("=" * 70)
|
|
print("🌥️ CLOUD REMOVAL MODEL TRAINING")
|
|
print("=" * 70)
|
|
print(f"Data directory: {data_dir}")
|
|
print(f"Use S1 (SAR): {use_s1}")
|
|
print(f"Device: {device}")
|
|
print(f"Batch size: {batch_size}")
|
|
print(f"Epochs: {num_epochs}")
|
|
print(f"Learning rate: {learning_rate}")
|
|
print("=" * 70)
|
|
|
|
# Create dataset
|
|
print("\n📂 Loading dataset...")
|
|
|
|
# Use RGB + NIR bands for training (B02, B03, B04, B08)
|
|
s2_bands = [S2Bands.B02, S2Bands.B03, S2Bands.B04, S2Bands.B08]
|
|
|
|
dataset = CloudRemovalDataset(
|
|
base_dir=data_dir,
|
|
season=Seasons.WINTER,
|
|
use_s1=use_s1,
|
|
s2_bands=s2_bands,
|
|
normalize=True
|
|
)
|
|
|
|
# Split train/val
|
|
train_size = int(0.8 * len(dataset))
|
|
val_size = len(dataset) - train_size
|
|
train_dataset, val_dataset = torch.utils.data.random_split(
|
|
dataset, [train_size, val_size]
|
|
)
|
|
|
|
print(f"Train samples: {len(train_dataset)}")
|
|
print(f"Val samples: {len(val_dataset)}")
|
|
|
|
# Create dataloaders
|
|
train_loader = DataLoader(
|
|
train_dataset,
|
|
batch_size=batch_size,
|
|
shuffle=True,
|
|
num_workers=4,
|
|
pin_memory=True if device == "cuda" else False
|
|
)
|
|
|
|
val_loader = DataLoader(
|
|
val_dataset,
|
|
batch_size=batch_size,
|
|
shuffle=False,
|
|
num_workers=4,
|
|
pin_memory=True if device == "cuda" else False
|
|
)
|
|
|
|
# Create model
|
|
print("\n🏗️ Creating U-Net model...")
|
|
in_channels = len(s2_bands) + (2 if use_s1 else 0) # S2 + S1 (VV, VH)
|
|
out_channels = len(s2_bands)
|
|
|
|
model = UNet(in_channels=in_channels, out_channels=out_channels)
|
|
model = model.to(device)
|
|
|
|
print(f"Input channels: {in_channels}")
|
|
print(f"Output channels: {out_channels}")
|
|
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
|
|
|
|
# Loss and optimizer
|
|
criterion = nn.L1Loss() # MAE loss
|
|
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
|
|
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
|
|
optimizer, mode='min', factor=0.5, patience=5
|
|
)
|
|
|
|
# Training loop
|
|
print("\n🚀 Starting training...")
|
|
best_val_loss = float('inf')
|
|
train_losses = []
|
|
val_losses = []
|
|
|
|
for epoch in range(num_epochs):
|
|
print(f"\n{'='*70}")
|
|
print(f"Epoch {epoch + 1}/{num_epochs}")
|
|
print(f"{'='*70}")
|
|
|
|
# Train
|
|
train_loss = train_epoch(model, train_loader, criterion, optimizer, device)
|
|
train_losses.append(train_loss)
|
|
|
|
# Validate
|
|
val_loss = validate(model, val_loader, criterion, device)
|
|
val_losses.append(val_loss)
|
|
|
|
# Update learning rate
|
|
scheduler.step(val_loss)
|
|
|
|
print(f"\nEpoch {epoch + 1} Summary:")
|
|
print(f" Train Loss: {train_loss:.6f}")
|
|
print(f" Val Loss: {val_loss:.6f}")
|
|
|
|
# Save best model
|
|
if val_loss < best_val_loss:
|
|
best_val_loss = val_loss
|
|
save_path = Path(save_dir) / "cloud_removal_unet_best.pth"
|
|
save_path.parent.mkdir(exist_ok=True)
|
|
|
|
torch.save({
|
|
'epoch': epoch,
|
|
'model_state_dict': model.state_dict(),
|
|
'optimizer_state_dict': optimizer.state_dict(),
|
|
'train_loss': train_loss,
|
|
'val_loss': val_loss,
|
|
'use_s1': use_s1,
|
|
'in_channels': in_channels,
|
|
'out_channels': out_channels
|
|
}, save_path)
|
|
|
|
print(f" 💾 Saved best model: {save_path}")
|
|
|
|
# Visualize every 10 epochs
|
|
if (epoch + 1) % 10 == 0:
|
|
print("\n📊 Generating visualizations...")
|
|
fig = visualize_results(model, val_dataset, device, num_samples=3)
|
|
|
|
viz_path = Path(save_dir) / f"cloud_removal_epoch_{epoch+1}.png"
|
|
fig.savefig(viz_path, dpi=150, bbox_inches='tight')
|
|
plt.close(fig)
|
|
|
|
print(f" 💾 Saved visualization: {viz_path}")
|
|
|
|
# Plot training curves
|
|
print("\n📈 Plotting training curves...")
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
ax.plot(train_losses, label='Train Loss')
|
|
ax.plot(val_losses, label='Val Loss')
|
|
ax.set_xlabel('Epoch')
|
|
ax.set_ylabel('Loss (MAE)')
|
|
ax.set_title('Cloud Removal Training Progress')
|
|
ax.legend()
|
|
ax.grid(True)
|
|
|
|
curve_path = Path(save_dir) / "training_curves.png"
|
|
fig.savefig(curve_path, dpi=150, bbox_inches='tight')
|
|
plt.close(fig)
|
|
|
|
print(f" 💾 Saved training curves: {curve_path}")
|
|
|
|
# Final summary
|
|
print("\n" + "=" * 70)
|
|
print("✅ TRAINING COMPLETED!")
|
|
print("=" * 70)
|
|
print(f"Best validation loss: {best_val_loss:.6f}")
|
|
print(f"Model saved to: {Path(save_dir) / 'cloud_removal_unet_best.pth'}")
|
|
print("=" * 70)
|
|
|
|
return model, train_losses, val_losses
|
|
|
|
|
|
# ============ INFERENCE FUNCTION ============
|
|
|
|
def apply_cloud_removal(model_path, cloudy_image, s1_data=None, device="cuda"):
|
|
"""
|
|
Áp dụng model để khử mây cho một ảnh
|
|
|
|
Args:
|
|
model_path: Đường dẫn đến model đã train
|
|
cloudy_image: Ảnh S2 bị mây [C, H, W]
|
|
s1_data: Dữ liệu S1 (optional) [2, H, W]
|
|
device: 'cuda' hoặc 'cpu'
|
|
|
|
Returns:
|
|
cleaned_image: Ảnh đã khử mây [C, H, W]
|
|
"""
|
|
# Load model
|
|
checkpoint = torch.load(model_path, map_location=device)
|
|
|
|
model = UNet(
|
|
in_channels=checkpoint['in_channels'],
|
|
out_channels=checkpoint['out_channels']
|
|
)
|
|
model.load_state_dict(checkpoint['model_state_dict'])
|
|
model = model.to(device)
|
|
model.eval()
|
|
|
|
# Prepare input
|
|
input_tensor = torch.from_numpy(cloudy_image).float().unsqueeze(0).to(device)
|
|
|
|
if checkpoint['use_s1'] and s1_data is not None:
|
|
s1_tensor = torch.from_numpy(s1_data).float().unsqueeze(0).to(device)
|
|
input_tensor = torch.cat([input_tensor, s1_tensor], dim=1)
|
|
|
|
# Inference
|
|
with torch.no_grad():
|
|
output = model(input_tensor)
|
|
|
|
cleaned_image = output[0].cpu().numpy()
|
|
|
|
return cleaned_image
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Train model
|
|
model, train_losses, val_losses = train_cloud_removal_model(
|
|
data_dir="winter_dataset",
|
|
use_s1=True,
|
|
batch_size=8,
|
|
num_epochs=50,
|
|
learning_rate=1e-4
|
|
)
|