refactor: reorganize project structure by moving core modules and update import paths in API server

This commit is contained in:
2026-07-18 01:24:30 +07:00
parent abab846884
commit a82b2f6fa5
155 changed files with 25 additions and 370 deletions
View File
+645
View File
@@ -0,0 +1,645 @@
"""
Cloud Removal Module - Hệ thống xử lý mây độc lập
Cung cấp nhiều phương pháp khử mây cho dữ liệu Sentinel-2
"""
import numpy as np
import xarray as xr
from typing import Tuple, Optional, Dict
from sklearn.neighbors import KNeighborsRegressor
from sklearn.ensemble import RandomForestRegressor
import warnings
from pathlib import Path
warnings.filterwarnings('ignore')
class CloudRemovalStrategy:
"""Base class cho các chiến lược xử lý mây"""
def __init__(self, name: str, description: str):
self.name = name
self.description = description
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
"""
Xử lý mây và trả về dữ liệu đã được làm sạch
Returns:
Tuple[xr.Dataset, Dict]: (cleaned_data, metadata)
"""
raise NotImplementedError
class ClassicStrategy(CloudRemovalStrategy):
"""
Chiến lược cổ điển 3 bước:
1. Temporal interpolation (ffill + bfill)
2. Median compositing (nếu >= 3 scenes)
3. Spatial interpolation (nearest neighbor)
"""
def __init__(self):
super().__init__(
name="classic",
description="3-step classical approach: temporal → median → spatial interpolation"
)
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'steps_applied': []
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
# Step 1: Temporal Interpolation
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].ffill(dim='time').bfill(dim='time')
metadata['steps_applied'].append('temporal_interpolation')
# Step 2: Median Compositing (if >= 3 time steps)
if len(s2_data.time) >= 3:
for band in s2_data.data_vars:
if band != "SCL":
median_composite = s2_data[band].median(dim='time', skipna=True)
s2_data[band] = s2_data[band].fillna(median_composite)
metadata['steps_applied'].append('median_compositing')
# Step 3: Spatial Interpolation
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].interpolate_na(dim='x', method='nearest', fill_value='extrapolate')
s2_data[band] = s2_data[band].interpolate_na(dim='y', method='nearest', fill_value='extrapolate')
metadata['steps_applied'].append('spatial_interpolation')
# Final fallback
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
class NoRemovalStrategy(CloudRemovalStrategy):
"""Không xử lý mây - giữ nguyên dữ liệu gốc, chỉ fill NaN bằng 0"""
def __init__(self):
super().__init__(
name="none",
description="No cloud removal - keep original data with NaN filled as 0"
)
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'steps_applied': ['none'],
'note': 'No cloud removal applied, only NaN filling'
}
# Chỉ fill NaN bằng 0, không apply cloud mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
class TemporalOnlyStrategy(CloudRemovalStrategy):
"""Chỉ sử dụng temporal interpolation - nhanh nhất, phù hợp khi có nhiều time steps"""
def __init__(self):
super().__init__(
name="temporal_only",
description="Temporal interpolation only - fast, good for time series with many scenes"
)
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'steps_applied': ['temporal_interpolation']
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
# Temporal interpolation
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].ffill(dim='time').bfill(dim='time')
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
class MedianCompositeStrategy(CloudRemovalStrategy):
"""Ưu tiên median composite - tốt nhất cho giảm noise"""
def __init__(self):
super().__init__(
name="median_composite",
description="Median composite priority - best for noise reduction"
)
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'steps_applied': ['median_compositing', 'spatial_interpolation']
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
# Direct median composite
for band in s2_data.data_vars:
if band != "SCL":
median_composite = s2_data[band].median(dim='time', skipna=True)
# Fill all NaN with median
s2_data[band] = s2_data[band].fillna(median_composite)
# Spatial interpolation for remaining gaps
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].interpolate_na(dim='x', method='nearest')
s2_data[band] = s2_data[band].interpolate_na(dim='y', method='nearest')
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
class MLInpaintingStrategy(CloudRemovalStrategy):
"""
Machine Learning Inpainting - sử dụng KNN hoặc Random Forest
Học từ pixels hợp lệ để dự đoán pixels bị mây
"""
def __init__(self, ml_model: str = "knn"):
"""
Args:
ml_model: 'knn' hoặc 'rf' (random forest)
"""
super().__init__(
name=f"ml_inpainting_{ml_model}",
description=f"ML-based cloud removal using {ml_model.upper()} - learns from valid pixels"
)
self.ml_model = ml_model
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'ml_model': self.ml_model,
'steps_applied': []
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
# ML inpainting cho từng time step
for time_idx in range(len(s2_data.time)):
# Get all bands for this time step
bands_data = []
band_names = []
for band in s2_data.data_vars:
if band != "SCL":
band_data = s2_data[band].isel(time=time_idx).values
bands_data.append(band_data.flatten())
band_names.append(band)
if not bands_data:
continue
# Stack bands: shape (n_pixels, n_bands)
X_all = np.column_stack(bands_data)
# Find valid (non-NaN) and invalid (NaN) pixels
valid_mask = ~np.isnan(X_all).any(axis=1)
if valid_mask.sum() < 10: # Not enough training data
continue
X_valid = X_all[valid_mask]
X_invalid_indices = np.where(~valid_mask)[0]
if len(X_invalid_indices) == 0: # No clouds
continue
# Prepare features: use spatial coordinates + spectral values
y_coords, x_coords = np.meshgrid(
np.arange(s2_data.dims['y']),
np.arange(s2_data.dims['x']),
indexing='ij'
)
coords_flat = np.column_stack([y_coords.flatten(), x_coords.flatten()])
# Train ML model on valid pixels
X_train = coords_flat[valid_mask]
y_train = X_valid
try:
if self.ml_model == "knn":
model = KNeighborsRegressor(n_neighbors=min(5, len(X_train)), weights='distance')
else: # random forest
model = RandomForestRegressor(n_estimators=10, max_depth=10, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
# Predict invalid pixels
X_test = coords_flat[X_invalid_indices]
predictions = model.predict(X_test)
# Fill predictions back
X_all[X_invalid_indices] = predictions
# Reshape and update dataset
for band_idx, band in enumerate(band_names):
filled_data = X_all[:, band_idx].reshape(s2_data.dims['y'], s2_data.dims['x'])
s2_data[band].values[time_idx] = filled_data
metadata['steps_applied'].append(f'ml_inpainting_time_{time_idx}')
except Exception as e:
print(f"[ML INPAINTING] Error at time {time_idx}: {e}")
continue
# Final cleanup
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
class DeepInpaintingStrategy(CloudRemovalStrategy):
"""
Deep Learning Inpainting - sử dụng U-Net CNN
Phức tạp hơn nhưng cho kết quả tốt nhất với large cloud gaps
Note: Yêu cầu pretrained model (train bằng train_cloud_removal.py)
"""
def __init__(self, model_path: Optional[str] = None):
super().__init__(
name="deep_inpainting",
description="Deep Learning U-Net based cloud removal - best quality for large gaps"
)
self.model_path = model_path or "model_train/cloud_removal_unet_best.pth"
self.model = None
self.device = None
# Try to load model if provided
if model_path or Path(self.model_path).exists():
try:
import torch
import torch.nn as nn
# Load checkpoint
checkpoint = torch.load(self.model_path, map_location='cpu')
# Recreate U-Net architecture
from train_cloud_removal import UNet
self.model = UNet(
in_channels=checkpoint.get('in_channels', 4),
out_channels=checkpoint.get('out_channels', 4)
)
self.model.load_state_dict(checkpoint['model_state_dict'])
# Set device
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self.model = self.model.to(self.device)
self.model.eval()
print(f"[DEEP INPAINTING] Loaded U-Net model from {self.model_path}")
print(f"[DEEP INPAINTING] Using device: {self.device}")
except Exception as e:
print(f"[DEEP INPAINTING] Could not load model: {e}")
self.model = None
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'has_model': self.model is not None,
'steps_applied': []
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
if self.model is None:
# Fallback to classical method
print("[DEEP INPAINTING] No model available, falling back to median composite")
for band in s2_data.data_vars:
if band != "SCL":
median_composite = s2_data[band].median(dim='time', skipna=True)
s2_data[band] = s2_data[band].fillna(median_composite)
s2_data[band] = s2_data[band].interpolate_na(dim='x', method='nearest')
s2_data[band] = s2_data[band].interpolate_na(dim='y', method='nearest')
s2_data[band] = s2_data[band].fillna(0)
metadata['steps_applied'].append('fallback_median')
else:
# Use U-Net for cloud removal
print("[DEEP INPAINTING] Applying U-Net cloud removal...")
import torch
try:
# Process each time step
for time_idx in range(len(s2_data.time)):
# Get bands for this time step (B02, B03, B04, B08)
bands_to_process = ['B02', 'B03', 'B04', 'B08']
available_bands = [b for b in bands_to_process if b in s2_data.data_vars]
if len(available_bands) < 4:
print(f"[DEEP INPAINTING] Warning: Not all required bands available, skipping time {time_idx}")
continue
# Stack bands [C, H, W]
input_bands = []
for band in available_bands:
band_data = s2_data[band].isel(time=time_idx).values.astype(np.float32)
# Normalize to [0, 1] (S2 values are typically 0-10000)
band_data = np.clip(band_data / 10000.0, 0, 1)
input_bands.append(band_data)
input_array = np.stack(input_bands, axis=0) # [C, H, W]
# Convert to tensor and add batch dimension
input_tensor = torch.from_numpy(input_array).unsqueeze(0).to(self.device)
if hasattr(self.model, 'encoder'):
# Custom UNet from train_cloud_removal.py
expected_channels = self.model.encoder[0].double_conv[0].in_channels
elif hasattr(self.model, 'inc') and hasattr(self.model.inc.double_conv[0], 'in_channels'):
expected_channels = self.model.inc.double_conv[0].in_channels
elif hasattr(self.model, 'conv1') and hasattr(self.model.conv1, 'in_channels'):
expected_channels = self.model.conv1.in_channels
else:
expected_channels = 6
if expected_channels > input_tensor.shape[1]:
pad_channels = expected_channels - input_tensor.shape[1]
padding = torch.zeros(1, pad_channels, *input_tensor.shape[2:]).to(self.device)
input_tensor = torch.cat([input_tensor, padding], dim=1)
# Run through U-Net
with torch.no_grad():
output_tensor = self.model(input_tensor)
# Convert back to numpy
output_array = output_tensor[0].cpu().numpy() # [C, H, W]
# Denormalize back to original scale
output_array = output_array * 10000.0
# Update dataset with cleaned data
for i, band in enumerate(available_bands):
s2_data[band].values[time_idx] = output_array[i]
metadata['steps_applied'].append(f'unet_time_{time_idx}')
print(f"[DEEP INPAINTING] Processed {len(s2_data.time)} time steps with U-Net")
except Exception as e:
print(f"[DEEP INPAINTING] Error during inference: {e}")
# Fallback to classical method
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].ffill(dim='time').bfill(dim='time')
s2_data[band] = s2_data[band].fillna(0)
metadata['steps_applied'].append('unet_error_fallback')
return s2_data, metadata
class HybridStrategy(CloudRemovalStrategy):
"""
Hybrid Strategy - kết hợp Classical + ML
1. Classical temporal interpolation (nhanh)
2. ML inpainting cho gaps còn lại (chất lượng cao)
3. Spatial interpolation (cleanup)
"""
def __init__(self):
super().__init__(
name="hybrid",
description="Hybrid classical + ML - balanced speed and quality"
)
def remove_clouds(self, s2_data: xr.Dataset, cloud_mask: xr.DataArray) -> Tuple[xr.Dataset, Dict]:
metadata = {
'method': self.name,
'steps_applied': []
}
# Apply mask
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].where(~cloud_mask)
# Step 1: Temporal interpolation (fast)
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].ffill(dim='time').bfill(dim='time')
metadata['steps_applied'].append('temporal_interpolation')
# Step 2: Check remaining NaN percentage
nan_count = 0
total_count = 0
for band in s2_data.data_vars:
if band != "SCL":
nan_count += np.isnan(s2_data[band].values).sum()
total_count += s2_data[band].values.size
nan_percentage = (nan_count / total_count * 100) if total_count > 0 else 0
# Step 3: ML inpainting if still significant gaps (>5%)
if nan_percentage > 5.0:
print(f"[HYBRID] {nan_percentage:.1f}% NaN remaining, applying ML inpainting...")
ml_strategy = MLInpaintingStrategy(ml_model="knn")
s2_data, ml_meta = ml_strategy.remove_clouds(s2_data, cloud_mask)
metadata['steps_applied'].extend(['ml_inpainting_knn'])
metadata['nan_before_ml'] = nan_percentage
else:
# Step 4: Spatial interpolation for small gaps
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].interpolate_na(dim='x', method='nearest')
s2_data[band] = s2_data[band].interpolate_na(dim='y', method='nearest')
metadata['steps_applied'].append('spatial_interpolation')
# Final cleanup
for band in s2_data.data_vars:
if band != "SCL":
s2_data[band] = s2_data[band].fillna(0)
return s2_data, metadata
# ============ FACTORY & UTILITIES ============
def get_available_methods() -> Dict[str, str]:
"""Trả về dictionary của tất cả methods có sẵn"""
return {
"none": "No cloud removal - keep original data (fastest, may have cloud artifacts)",
"classic": "3-step classical: temporal → median → spatial (default, balanced)",
"temporal_only": "Temporal interpolation only (fast, needs many scenes)",
"median_composite": "Median composite priority (best noise reduction)",
"ml_knn": "ML K-Nearest Neighbors inpainting (good quality, medium speed)",
"ml_rf": "ML Random Forest inpainting (high quality, slower)",
"deep": "Deep Learning CNN inpainting (best quality, requires model)",
"hybrid": "Hybrid classical + ML (balanced speed & quality)"
}
def create_cloud_removal_strategy(method: str = "classic", **kwargs) -> CloudRemovalStrategy:
"""
Factory function để tạo strategy từ tên method
Args:
method: Tên method ("classic", "temporal_only", "median_composite",
"ml_knn", "ml_rf", "deep", "hybrid")
**kwargs: Additional parameters cho specific strategies
Returns:
CloudRemovalStrategy instance
"""
method = method.lower()
if method == "none":
return NoRemovalStrategy()
elif method == "classic":
return ClassicStrategy()
elif method == "temporal_only":
return TemporalOnlyStrategy()
elif method == "median_composite":
return MedianCompositeStrategy()
elif method == "ml_knn":
return MLInpaintingStrategy(ml_model="knn")
elif method == "ml_rf":
return MLInpaintingStrategy(ml_model="rf")
elif method == "deep":
model_path = kwargs.get('model_path', None)
return DeepInpaintingStrategy(model_path=model_path)
elif method == "hybrid":
return HybridStrategy()
else:
print(f"[CLOUD REMOVAL] Unknown method '{method}', using 'classic'")
return ClassicStrategy()
def process_cloud_removal(
s2_data: xr.Dataset,
method: str = "classic",
verbose: bool = True,
**kwargs
) -> Tuple[xr.Dataset, Dict]:
"""
Main entry point cho cloud removal
Args:
s2_data: Sentinel-2 dataset với SCL band
method: Cloud removal method name
verbose: Print progress messages
**kwargs: Additional parameters
Returns:
Tuple[xr.Dataset, Dict]: (cleaned_data, metadata)
"""
if verbose:
print(f"[CLOUD REMOVAL] Using method: {method}")
# Detect clouds from SCL
if "SCL" not in s2_data:
if verbose:
print("[CLOUD REMOVAL] Warning: No SCL band, cannot mask clouds")
return s2_data, {'method': 'none', 'warning': 'no_scl_band'}
scl = s2_data["SCL"]
# Create comprehensive cloud mask
cloud_mask = (scl == 3) | (scl == 8) | (scl == 9) | (scl == 10) | (scl == 11)
invalid_mask = (scl == 0) | (scl == 1)
full_mask = cloud_mask | invalid_mask
# Calculate coverage
total_pixels = full_mask.size
masked_pixels = int(full_mask.sum().values)
cloud_coverage_percent = (masked_pixels / total_pixels * 100) if total_pixels > 0 else 0
if verbose:
print(f"[CLOUD REMOVAL] Cloud coverage: {cloud_coverage_percent:.1f}%")
print(f"[CLOUD REMOVAL] Masked pixels: {masked_pixels:,}/{total_pixels:,}")
# Create strategy and process
strategy = create_cloud_removal_strategy(method, **kwargs)
cleaned_data, metadata = strategy.remove_clouds(s2_data.copy(deep=True), full_mask)
# Add coverage info to metadata
metadata['cloud_coverage_percent'] = float(cloud_coverage_percent)
metadata['masked_pixels'] = masked_pixels
metadata['total_pixels'] = total_pixels
if verbose:
print(f"[CLOUD REMOVAL] Completed using {metadata['method']}")
print(f"[CLOUD REMOVAL] Steps: {', '.join(metadata['steps_applied'])}")
return cleaned_data, metadata
# ============ TESTING & COMPARISON ============
def compare_methods(s2_data: xr.Dataset, methods: list = None) -> Dict:
"""
So sánh các methods khác nhau trên cùng dữ liệu
Args:
s2_data: Sentinel-2 dataset
methods: List of method names to compare (default: all)
Returns:
Dict: Comparison results
"""
if methods is None:
methods = ["classic", "temporal_only", "median_composite", "ml_knn", "hybrid"]
results = {}
for method in methods:
try:
print(f"\n{'='*60}")
print(f"Testing: {method}")
print(f"{'='*60}")
cleaned_data, metadata = process_cloud_removal(s2_data, method=method, verbose=True)
# Calculate remaining NaN
nan_count = sum(np.isnan(cleaned_data[band].values).sum()
for band in cleaned_data.data_vars if band != "SCL")
total_count = sum(cleaned_data[band].values.size
for band in cleaned_data.data_vars if band != "SCL")
results[method] = {
'metadata': metadata,
'remaining_nan_percent': (nan_count / total_count * 100) if total_count > 0 else 0,
'success': True
}
except Exception as e:
results[method] = {
'error': str(e),
'success': False
}
print(f"[ERROR] {method}: {e}")
return results
+454
View File
@@ -0,0 +1,454 @@
"""
Feature Extraction Module for Land Classification
Chuẩn hóa việc trích xuất features từ satellite data cho cả training và prediction
"""
import numpy as np
import xarray as xr
from typing import List, Dict, Tuple, Optional
class FeatureExtractor:
"""
Extract features từ Sentinel-2 và Sentinel-1 data
Hỗ trợ 2 modes:
- 'simple': 3 features cơ bản (NDVI_mean, VH_mean, VV_mean)
- 'temporal': 39 features time-series (NDVI + NDWI + NDBI theo thời gian)
"""
FEATURE_MODES = {
'simple': {
'n_features': 3,
'features': ['NDVI_mean', 'VH_db_mean', 'VV_db_mean'],
'description': 'Simple aggregate features (mean only)'
},
'temporal': {
'n_features': 39,
'features': None, # Generated dynamically based on time steps
'description': 'Temporal features with NDVI, NDWI, NDBI time series'
},
'extended': {
'n_features': 15,
'features': [
'NDVI_mean', 'NDVI_std', 'NDVI_min', 'NDVI_max',
'NDWI_mean', 'NDWI_std', 'NDWI_min', 'NDWI_max',
'NDBI_mean', 'NDBI_std', 'NDBI_min', 'NDBI_max',
'VH_db_mean', 'VV_db_mean', 'VH_VV_ratio'
],
'description': 'Extended aggregate features with statistics'
},
'odc': {
'n_features': 8,
'features': [
'ndvi_mean', 'ndvi_min', 'ndvi_max', 'ndvi_std', 'ndvi_range',
'ndwi_mean', 'ndbi_mean', 'evi_mean'
],
'description': 'ODC mode: 8 aggregate features (NDVI stats + NDWI/NDBI/EVI mean) - matches 01.train_ODC.ipynb'
}
}
def __init__(self, mode: str = 'simple'):
"""
Initialize FeatureExtractor
Args:
mode: 'simple', 'temporal', hoặc 'extended'
"""
if mode not in self.FEATURE_MODES:
raise ValueError(f"Invalid mode: {mode}. Choose from {list(self.FEATURE_MODES.keys())}")
self.mode = mode
self.config = self.FEATURE_MODES[mode]
def get_feature_names(self, n_timesteps: Optional[int] = None) -> List[str]:
"""
Lấy danh sách tên features
Args:
n_timesteps: Số timesteps (chỉ cần cho mode='temporal')
Returns:
List tên features
"""
if self.mode == 'temporal':
if n_timesteps is None:
raise ValueError("n_timesteps required for temporal mode")
features = []
# NDVI time series
for t in range(n_timesteps):
features.append(f'NDVI_t{t+1}')
# NDWI time series
for t in range(n_timesteps):
features.append(f'NDWI_t{t+1}')
# NDBI time series
for t in range(n_timesteps):
features.append(f'NDBI_t{t+1}')
# VH/VV radar (mean across time)
features.append('VH_db_mean')
features.append('VV_db_mean')
features.append('VH_VV_ratio')
return features
else:
return self.config['features']
def extract_simple_features(
self,
ndvi_data: xr.DataArray,
vh_data: Optional[xr.DataArray] = None,
vv_data: Optional[xr.DataArray] = None
) -> np.ndarray:
"""
Extract simple features (3 features: NDVI_mean, VH_db_mean, VV_db_mean)
Args:
ndvi_data: NDVI DataArray (có thể có time dimension)
vh_data: VH radar DataArray
vv_data: VV radar DataArray
Returns:
Feature array shape (n_pixels, 3)
"""
# Calculate NDVI mean
if 'time' in ndvi_data.dims:
ndvi_mean = ndvi_data.mean(dim='time')
else:
ndvi_mean = ndvi_data
# Flatten to pixels
ndvi_flat = ndvi_mean.values.flatten()
# Calculate radar features if available
if vh_data is not None and vv_data is not None:
if 'time' in vh_data.dims:
vh_mean = vh_data.mean(dim='time')
vv_mean = vv_data.mean(dim='time')
else:
vh_mean = vh_data
vv_mean = vv_data
vh_flat = vh_mean.values.flatten()
vv_flat = vv_mean.values.flatten()
else:
# If no radar data, use zeros
vh_flat = np.zeros_like(ndvi_flat)
vv_flat = np.zeros_like(ndvi_flat)
# Stack features
features = np.column_stack([ndvi_flat, vh_flat, vv_flat])
return features
def extract_temporal_features(
self,
s2_data: xr.Dataset,
vh_data: Optional[xr.DataArray] = None,
vv_data: Optional[xr.DataArray] = None
) -> np.ndarray:
"""
Extract temporal features (39 features: time series của NDVI, NDWI, NDBI + radar)
Args:
s2_data: Sentinel-2 Dataset với bands B02, B03, B04, B08, B11
vh_data: VH radar DataArray
vv_data: VV radar DataArray
Returns:
Feature array shape (n_pixels, 39)
"""
# Calculate spectral indices
nir = s2_data["B08"].astype('float32')
red = s2_data["B04"].astype('float32')
green = s2_data["B03"].astype('float32')
swir = s2_data["B11"].astype('float32') if "B11" in s2_data else s2_data["B02"] # Fallback to B02
# NDVI = (NIR - Red) / (NIR + Red)
ndvi = (nir - red) / (nir + red + 1e-8)
# NDWI = (Green - NIR) / (Green + NIR)
ndwi = (green - nir) / (green + nir + 1e-8)
# NDBI = (SWIR - NIR) / (SWIR + NIR)
ndbi = (swir - nir) / (swir + nir + 1e-8)
# Resample to monthly if time dimension exists
if 'time' in ndvi.dims:
ndvi_monthly = ndvi.resample(time="1ME").mean()
ndwi_monthly = ndwi.resample(time="1ME").mean()
ndbi_monthly = ndbi.resample(time="1ME").mean()
else:
ndvi_monthly = ndvi
ndwi_monthly = ndwi
ndbi_monthly = ndbi
# Get dimensions
n_times = len(ndvi_monthly.time) if 'time' in ndvi_monthly.dims else 1
y_size = len(ndvi_monthly.y)
x_size = len(ndvi_monthly.x)
n_pixels = y_size * x_size
# Extract temporal features
features_list = []
# NDVI time series
for t in range(n_times):
if 'time' in ndvi_monthly.dims:
ndvi_t = ndvi_monthly.isel(time=t).values.flatten()
else:
ndvi_t = ndvi_monthly.values.flatten()
features_list.append(ndvi_t)
# NDWI time series
for t in range(n_times):
if 'time' in ndwi_monthly.dims:
ndwi_t = ndwi_monthly.isel(time=t).values.flatten()
else:
ndwi_t = ndwi_monthly.values.flatten()
features_list.append(ndwi_t)
# NDBI time series
for t in range(n_times):
if 'time' in ndbi_monthly.dims:
ndbi_t = ndbi_monthly.isel(time=t).values.flatten()
else:
ndbi_t = ndbi_monthly.values.flatten()
features_list.append(ndbi_t)
# Stack all spectral features
features = np.column_stack(features_list)
# Add radar features if available
if vh_data is not None and vv_data is not None:
if 'time' in vh_data.dims:
vh_mean = vh_data.mean(dim='time')
vv_mean = vv_data.mean(dim='time')
else:
vh_mean = vh_data
vv_mean = vv_data
vh_flat = vh_mean.values.flatten()
vv_flat = vv_mean.values.flatten()
vh_vv_ratio = vh_flat / (vv_flat + 1e-8)
# Add radar features
features = np.column_stack([features, vh_flat, vv_flat, vh_vv_ratio])
return features
def extract_odc_features(
self,
s2_data: xr.Dataset,
vh_data: Optional[xr.DataArray] = None,
vv_data: Optional[xr.DataArray] = None
) -> np.ndarray:
"""
Extract ODC aggregate features (8 features matching 01.train_ODC.ipynb):
ndvi_mean, ndvi_min, ndvi_max, ndvi_std, ndvi_range, ndwi_mean, ndbi_mean, evi_mean
Args:
s2_data: Sentinel-2 Dataset with B02, B03, B04, B08, B11
vh_data: Not used in ODC mode
vv_data: Not used in ODC mode
Returns:
Feature array shape (n_pixels, 8)
"""
# Calculate spectral indices
nir = s2_data["B08"].astype('float32')
red = s2_data["B04"].astype('float32')
green = s2_data["B03"].astype('float32')
blue = s2_data["B02"].astype('float32')
swir = s2_data["B11"].astype('float32') if "B11" in s2_data else s2_data["B02"]
# NDVI = (NIR - Red) / (NIR + Red)
ndvi = (nir - red) / (nir + red + 1e-8)
# NDWI = (Green - NIR) / (Green + NIR)
ndwi = (green - nir) / (green + nir + 1e-8)
# NDBI = (SWIR - NIR) / (SWIR + NIR)
ndbi = (swir - nir) / (swir + nir + 1e-8)
# EVI = 2.5 * (NIR - Red) / (NIR + 6*Red - 7.5*Blue + 1)
evi = 2.5 * (nir - red) / (nir + 6*red - 7.5*blue + 1)
features_list = []
# NDVI statistics (5 features)
if 'time' in ndvi.dims:
features_list.append(ndvi.mean(dim='time').values.flatten()) # ndvi_mean
features_list.append(ndvi.min(dim='time').values.flatten()) # ndvi_min
features_list.append(ndvi.max(dim='time').values.flatten()) # ndvi_max
features_list.append(ndvi.std(dim='time').values.flatten()) # ndvi_std
ndvi_range = (ndvi.max(dim='time') - ndvi.min(dim='time')).values.flatten()
features_list.append(ndvi_range) # ndvi_range
else:
ndvi_flat = ndvi.values.flatten()
features_list.extend([ndvi_flat, ndvi_flat, ndvi_flat, np.zeros_like(ndvi_flat), np.zeros_like(ndvi_flat)])
# NDWI mean (1 feature)
if 'time' in ndwi.dims:
features_list.append(ndwi.mean(dim='time').values.flatten()) # ndwi_mean
else:
features_list.append(ndwi.values.flatten())
# NDBI mean (1 feature)
if 'time' in ndbi.dims:
features_list.append(ndbi.mean(dim='time').values.flatten()) # ndbi_mean
else:
features_list.append(ndbi.values.flatten())
# EVI mean (1 feature)
if 'time' in evi.dims:
features_list.append(evi.mean(dim='time').values.flatten()) # evi_mean
else:
features_list.append(evi.values.flatten())
# Stack all features (total: 8 features)
features = np.column_stack(features_list)
return features
def extract_extended_features(
self,
s2_data: xr.Dataset,
vh_data: Optional[xr.DataArray] = None,
vv_data: Optional[xr.DataArray] = None
) -> np.ndarray:
"""
Extract extended aggregate features (15 features: stats của NDVI, NDWI, NDBI + radar)
Args:
s2_data: Sentinel-2 Dataset
vh_data: VH radar DataArray
vv_data: VV radar DataArray
Returns:
Feature array shape (n_pixels, 15)
"""
# Calculate spectral indices
nir = s2_data["B08"].astype('float32')
red = s2_data["B04"].astype('float32')
green = s2_data["B03"].astype('float32')
swir = s2_data["B11"].astype('float32') if "B11" in s2_data else s2_data["B02"]
ndvi = (nir - red) / (nir + red + 1e-8)
ndwi = (green - nir) / (green + nir + 1e-8)
ndbi = (swir - nir) / (swir + nir + 1e-8)
features_list = []
# NDVI statistics
if 'time' in ndvi.dims:
features_list.append(ndvi.mean(dim='time').values.flatten())
features_list.append(ndvi.std(dim='time').values.flatten())
features_list.append(ndvi.min(dim='time').values.flatten())
features_list.append(ndvi.max(dim='time').values.flatten())
else:
ndvi_flat = ndvi.values.flatten()
features_list.extend([ndvi_flat, np.zeros_like(ndvi_flat), ndvi_flat, ndvi_flat])
# NDWI statistics
if 'time' in ndwi.dims:
features_list.append(ndwi.mean(dim='time').values.flatten())
features_list.append(ndwi.std(dim='time').values.flatten())
features_list.append(ndwi.min(dim='time').values.flatten())
features_list.append(ndwi.max(dim='time').values.flatten())
else:
ndwi_flat = ndwi.values.flatten()
features_list.extend([ndwi_flat, np.zeros_like(ndwi_flat), ndwi_flat, ndwi_flat])
# NDBI statistics
if 'time' in ndbi.dims:
features_list.append(ndbi.mean(dim='time').values.flatten())
features_list.append(ndbi.std(dim='time').values.flatten())
features_list.append(ndbi.min(dim='time').values.flatten())
features_list.append(ndbi.max(dim='time').values.flatten())
else:
ndbi_flat = ndbi.values.flatten()
features_list.extend([ndbi_flat, np.zeros_like(ndbi_flat), ndbi_flat, ndbi_flat])
# Stack spectral features
features = np.column_stack(features_list)
# Add radar features
if vh_data is not None and vv_data is not None:
if 'time' in vh_data.dims:
vh_mean = vh_data.mean(dim='time')
vv_mean = vv_data.mean(dim='time')
else:
vh_mean = vh_data
vv_mean = vv_data
vh_flat = vh_mean.values.flatten()
vv_flat = vv_mean.values.flatten()
vh_vv_ratio = vh_flat / (vv_flat + 1e-8)
features = np.column_stack([features, vh_flat, vv_flat, vh_vv_ratio])
return features
def extract(
self,
s2_data: Optional[xr.Dataset] = None,
ndvi_data: Optional[xr.DataArray] = None,
vh_data: Optional[xr.DataArray] = None,
vv_data: Optional[xr.DataArray] = None
) -> np.ndarray:
"""
Extract features theo mode đã chọn
Args:
s2_data: Sentinel-2 Dataset (cần cho temporal, extended, và odc modes)
ndvi_data: NDVI DataArray (cần cho simple mode)
vh_data: VH radar DataArray
vv_data: VV radar DataArray
Returns:
Feature array
"""
if self.mode == 'simple':
if ndvi_data is None:
raise ValueError("ndvi_data required for simple mode")
return self.extract_simple_features(ndvi_data, vh_data, vv_data)
elif self.mode == 'temporal':
if s2_data is None:
raise ValueError("s2_data required for temporal mode")
return self.extract_temporal_features(s2_data, vh_data, vv_data)
elif self.mode == 'extended':
if s2_data is None:
raise ValueError("s2_data required for extended mode")
return self.extract_extended_features(s2_data, vh_data, vv_data)
elif self.mode == 'odc':
if s2_data is None:
raise ValueError("s2_data required for odc mode")
return self.extract_odc_features(s2_data, vh_data, vv_data)
else:
raise ValueError(f"Unknown mode: {self.mode}")
def get_info(self) -> Dict:
"""Lấy thông tin về feature extraction mode"""
return {
'mode': self.mode,
'n_features': self.config['n_features'],
'description': self.config['description']
}
def get_feature_extractor(mode: str = 'simple') -> FeatureExtractor:
"""
Factory function để tạo FeatureExtractor
Args:
mode: 'simple', 'temporal', 'extended', hoặc 'odc'
Returns:
FeatureExtractor instance
"""
return FeatureExtractor(mode=mode)
+361
View File
@@ -0,0 +1,361 @@
"""
Model Manager - Hệ thống quản lý và vận hành tất cả các loại models
Hỗ trợ: XGBoost, Random Forest, Decision Tree, SVM, CNN, và các model khác
"""
import joblib
import json
from pathlib import Path
from typing import Optional, Dict, List, Any, Tuple
from datetime import datetime
import numpy as np
import warnings
# PyTorch for CNN models
try:
import torch
PYTORCH_AVAILABLE = True
except ImportError:
PYTORCH_AVAILABLE = False
warnings.filterwarnings('ignore')
class ModelManager:
"""Quản lý tất cả các models: load, save, list, validate"""
def __init__(self, models_dir: str = "model_train"):
self.models_dir = Path(models_dir)
self.models_dir.mkdir(exist_ok=True)
self.current_model = None
self.current_metadata = None
def list_models(self) -> List[Dict[str, Any]]:
"""
Liệt kê tất cả models có sẵn với metadata
Returns:
List of dicts containing model info
"""
models = []
# Tìm tất cả file .joblib
for model_file in self.models_dir.glob("*.joblib"):
# Skip Zone.Identifier files
if "Zone.Identifier" in model_file.name:
continue
model_info = {
"filename": model_file.name,
"path": str(model_file),
"size_mb": model_file.stat().st_size / (1024 * 1024),
"modified": datetime.fromtimestamp(model_file.stat().st_mtime).isoformat(),
}
# Tìm metadata file tương ứng
metadata_file = model_file.with_suffix('.json')
if not metadata_file.exists():
# Try with _info.json suffix
metadata_file = model_file.parent / (model_file.stem + "_info.json")
if metadata_file.exists():
try:
with open(metadata_file, 'r') as f:
metadata = json.load(f)
model_info["metadata"] = metadata
model_info["has_metadata"] = True
# Extract key info
model_info["model_type"] = metadata.get("model_type", "unknown")
model_info["features"] = metadata.get("features", [])
model_info["n_features"] = metadata.get("n_features", 0)
model_info["n_classes"] = metadata.get("n_classes", 0)
model_info["test_accuracy"] = metadata.get("test_accuracy", None)
model_info["timestamp"] = metadata.get("timestamp", None)
model_info["data_source"] = metadata.get("data_source", "unknown")
except Exception as e:
model_info["has_metadata"] = False
model_info["metadata_error"] = str(e)
else:
model_info["has_metadata"] = False
models.append(model_info)
# Sort by modified time (newest first)
models.sort(key=lambda x: x["modified"], reverse=True)
return models
def load_model(self, model_filename: str) -> Tuple[Any, Optional[Any], Dict[str, Any]]:
"""
Load model từ file
Args:
model_filename: Tên file model (ví dụ: "model_odc.joblib")
Returns:
Tuple of (model, label_encoder, metadata)
"""
model_path = self.models_dir / model_filename
if not model_path.exists():
raise FileNotFoundError(f"Model không tồn tại: {model_filename}")
# Load model
print(f"[MODEL MANAGER] Loading model: {model_filename}")
model_data = joblib.load(model_path)
# Extract model and encoder
if isinstance(model_data, dict):
model = model_data.get('model')
label_encoder = model_data.get('label_encoder')
else:
# Old format: model only
model = model_data
label_encoder = None
# Load metadata
metadata = self._load_metadata(model_filename)
# Store current model
self.current_model = model
self.current_metadata = metadata
# Check if CNN model and set to eval mode
if PYTORCH_AVAILABLE and hasattr(model, '__class__') and 'CNN' in model.__class__.__name__:
model.eval()
print(f"[MODEL MANAGER] PyTorch CNN model detected and set to eval mode")
print(f"[MODEL MANAGER] Model loaded successfully")
print(f" - Type: {metadata.get('model_type', 'unknown')}")
print(f" - Features: {metadata.get('n_features', 'N/A')}")
print(f" - Classes: {metadata.get('n_classes', 'N/A')}")
print(f" - Accuracy: {metadata.get('test_accuracy', 'N/A')}")
return model, label_encoder, metadata
def _load_metadata(self, model_filename: str) -> Dict[str, Any]:
"""Load metadata cho model"""
model_path = self.models_dir / model_filename
# Try multiple metadata file patterns
metadata_files = [
model_path.with_suffix('.json'),
model_path.parent / (model_path.stem + "_info.json"),
]
for metadata_file in metadata_files:
if metadata_file.exists():
try:
with open(metadata_file, 'r') as f:
return json.load(f)
except Exception as e:
print(f"[MODEL MANAGER] Warning: Could not load metadata from {metadata_file}: {e}")
# Return default metadata if not found
print(f"[MODEL MANAGER] Warning: No metadata found for {model_filename}")
return {
"model_type": "unknown",
"features": [],
"n_features": 0,
"n_classes": 0,
"timestamp": None
}
def save_model(self, model: Any, metadata: Dict[str, Any],
model_filename: Optional[str] = None,
label_encoder: Optional[Any] = None) -> str:
"""
Save model với metadata
Args:
model: Model object
metadata: Dict chứa thông tin về model
model_filename: Tên file (optional, sẽ auto-generate nếu không có)
label_encoder: Label encoder (optional)
Returns:
Path to saved model file
"""
# Generate filename if not provided
if model_filename is None:
model_type = metadata.get("model_type", "model")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
model_filename = f"model_{model_type}_{timestamp}.joblib"
model_path = self.models_dir / model_filename
metadata_path = model_path.parent / (model_path.stem + "_info.json")
# Prepare model data
if label_encoder is not None:
model_data = {
'model': model,
'label_encoder': label_encoder
}
else:
model_data = {
'model': model
}
# Save model
print(f"[MODEL MANAGER] Saving model to: {model_path}")
joblib.dump(model_data, model_path)
# Save metadata
print(f"[MODEL MANAGER] Saving metadata to: {metadata_path}")
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"[MODEL MANAGER] Model saved successfully!")
return str(model_path)
def validate_model(self, model_filename: str) -> Dict[str, Any]:
"""
Validate model file và kiểm tra integrity
Returns:
Dict with validation results
"""
result = {
"valid": False,
"errors": [],
"warnings": []
}
model_path = self.models_dir / model_filename
# Check file exists
if not model_path.exists():
result["errors"].append(f"File không tồn tại: {model_filename}")
return result
# Try to load model
try:
model, encoder, metadata = self.load_model(model_filename)
result["valid"] = True
# Check metadata
if not metadata or metadata.get("model_type") == "unknown":
result["warnings"].append("Không có metadata hoặc metadata không đầy đủ")
# Check required features
if not metadata.get("features"):
result["warnings"].append("Danh sách features không có trong metadata")
# Check model object
if model is None:
result["errors"].append("Model object is None")
result["valid"] = False
except Exception as e:
result["errors"].append(f"Lỗi khi load model: {str(e)}")
result["valid"] = False
return result
def get_required_features(self, model_filename: str) -> List[str]:
"""
Lấy danh sách features cần thiết cho model
Returns:
List of feature names
"""
metadata = self._load_metadata(model_filename)
return metadata.get("features", [])
def predict(self, model_filename: str, X: np.ndarray) -> np.ndarray:
"""
Predict using specified model
Args:
model_filename: Model file name
X: Features array (n_samples, n_features)
Returns:
Predictions array
"""
if self.current_model is None or model_filename != getattr(self, '_current_model_filename', None):
model, encoder, metadata = self.load_model(model_filename)
self._current_model_filename = model_filename
else:
model = self.current_model
metadata = self.current_metadata
# Validate input features
expected_features = metadata.get("n_features", 0)
if X.shape[1] != expected_features:
raise ValueError(f"Expected {expected_features} features, got {X.shape[1]}")
# Predict
predictions = model.predict(X)
return predictions
def get_model_info(self, model_filename: str) -> Dict[str, Any]:
"""Get detailed info about a model"""
models = self.list_models()
for model in models:
if model["filename"] == model_filename:
return model
return None
def delete_model(self, model_filename: str) -> bool:
"""
Xóa model và metadata
Returns:
True if successful
"""
model_path = self.models_dir / model_filename
if not model_path.exists():
return False
# Delete model file
model_path.unlink()
# Delete metadata file if exists
metadata_file = model_path.with_suffix('.json')
if metadata_file.exists():
metadata_file.unlink()
# Try alternative metadata file name
metadata_file_alt = model_path.parent / (model_path.stem + "_info.json")
if metadata_file_alt.exists():
metadata_file_alt.unlink()
return True
def get_latest_model(self, model_type: Optional[str] = None) -> Optional[str]:
"""
Lấy model mới nhất (theo thời gian modified)
Args:
model_type: Filter by model type (xgboost, cnn, etc.), None for any
Returns:
Model filename or None
"""
models = self.list_models()
if model_type:
models = [m for m in models if m.get("model_type") == model_type]
if not models:
return None
# Already sorted by modified time
return models[0]["filename"]
# Singleton instance
_model_manager = None
def get_model_manager() -> ModelManager:
"""Get singleton ModelManager instance"""
global _model_manager
if _model_manager is None:
_model_manager = ModelManager()
return _model_manager
+93
View File
@@ -0,0 +1,93 @@
import os
import torch
import numpy as np
import xarray as xr
from torch.utils.data import Dataset
import glob
class NDVITimeSeriesDataset(Dataset):
def __init__(self, sequence_length=3, spatial=False):
"""
Đọc dữ liệu S2 từ cache, tính NDVI và tạo Time-Series.
spatial=False -> Output 1D cho LSTM/ARIMA
spatial=True -> Output 2D cho ConvLSTM
"""
self.sequence_length = sequence_length
self.spatial = spatial
self.data_seqs = []
self.targets = []
# Load from cache
cache_files = glob.glob("dataset_cache/*.nc")
s2_files = [f for f in cache_files if len(os.path.basename(f)) == 35] # S2 cache filenames usually have length 32 + 3 (.nc)
if not s2_files:
print("[WARNING] Không tìm thấy dữ liệu S2 trong cache! Dùng dummy data.")
self._create_dummy()
return
try:
print(f"[DATA] Loading real data from {s2_files[0]}")
ds = xr.open_dataset(s2_files[0], engine='netcdf4')
if 'time' not in ds.dims or len(ds.time) < sequence_length + 1:
self._create_dummy()
return
# Tính NDVI: (B08 - B04) / (B08 + B04)
b8 = ds['B08'].astype(np.float32)
b4 = ds['B04'].astype(np.float32)
ndvi = (b8 - b4) / (b8 + b4 + 1e-8)
ndvi = ndvi.fillna(0).values # shape: (time, y, x)
# Lấy 1 pixel trung tâm hoặc toàn bộ ảnh
if not self.spatial:
# Average pooling over space for 1D time series
ndvi = ndvi.mean(axis=(1, 2)) # shape: (time,)
for i in range(len(ndvi) - sequence_length):
self.data_seqs.append(ndvi[i:i+sequence_length])
self.targets.append(ndvi[i+sequence_length])
else:
# Spatial data for ConvLSTM
# Downsample to 64x64 to avoid OOM
from skimage.transform import resize
T = len(ndvi)
ndvi_resized = np.zeros((T, 64, 64))
for t in range(T):
ndvi_resized[t] = resize(ndvi[t], (64, 64))
for i in range(T - sequence_length):
self.data_seqs.append(ndvi_resized[i:i+sequence_length]) # (seq, 64, 64)
self.targets.append(ndvi_resized[i+sequence_length]) # (64, 64)
except Exception as e:
print(f"[ERROR] {e}. Dùng dummy data.")
self._create_dummy()
def _create_dummy(self):
T = 20
if not self.spatial:
ndvi = np.random.rand(T).astype(np.float32)
for i in range(T - self.sequence_length):
self.data_seqs.append(ndvi[i:i+self.sequence_length])
self.targets.append(ndvi[i+self.sequence_length])
else:
ndvi = np.random.rand(T, 64, 64).astype(np.float32)
for i in range(T - self.sequence_length):
self.data_seqs.append(ndvi[i:i+self.sequence_length])
self.targets.append(ndvi[i+self.sequence_length])
def __len__(self):
return len(self.data_seqs)
def __getitem__(self, idx):
x = torch.tensor(self.data_seqs[idx], dtype=torch.float32)
y = torch.tensor(self.targets[idx], dtype=torch.float32)
if not self.spatial:
x = x.unsqueeze(1) # (seq_len, features=1)
y = y.unsqueeze(0) # (1,)
else:
x = x.unsqueeze(1) # (seq_len, channels=1, H, W)
y = y.unsqueeze(0) # (1, H, W)
return x, y
+6
View File
@@ -0,0 +1,6 @@
import geopandas as gpd
def load_data_geo(path: str):
gdf = gpd.read_file(path)
return gdf
+376
View File
@@ -0,0 +1,376 @@
"""
Vietnam Provinces Boundaries
Ranh giới các tỉnh thành Việt Nam với bbox coordinates
"""
VIETNAM_PROVINCES = {
"Toàn quốc": {
"bbox": [102.14, 8.18, 109.46, 23.39],
"name_en": "Vietnam (Full)",
"region": "Toàn quốc"
},
# Miền Bắc - Northern Region
"Hà Nội": {
"bbox": [105.35, 20.53, 105.92, 21.33],
"name_en": "Hanoi",
"region": "Miền Bắc"
},
"Hải Phòng": {
"bbox": [106.48, 20.70, 107.07, 21.09],
"name_en": "Hai Phong",
"region": "Miền Bắc"
},
"Quảng Ninh": {
"bbox": [106.48, 20.70, 108.26, 21.62],
"name_en": "Quang Ninh",
"region": "Miền Bắc"
},
"Lào Cai": {
"bbox": [103.22, 21.82, 104.45, 22.77],
"name_en": "Lao Cai",
"region": "Miền Bắc"
},
"Điện Biên": {
"bbox": [102.72, 21.09, 103.45, 22.21],
"name_en": "Dien Bien",
"region": "Miền Bắc"
},
"Lai Châu": {
"bbox": [102.72, 21.82, 103.72, 22.77],
"name_en": "Lai Chau",
"region": "Miền Bắc"
},
"Hà Giang": {
"bbox": [104.42, 22.33, 105.59, 23.39],
"name_en": "Ha Giang",
"region": "Miền Bắc"
},
"Cao Bằng": {
"bbox": [105.52, 22.24, 106.70, 23.04],
"name_en": "Cao Bang",
"region": "Miền Bắc"
},
"Bắc Kạn": {
"bbox": [105.48, 21.95, 106.15, 22.52],
"name_en": "Bac Kan",
"region": "Miền Bắc"
},
"Tuyên Quang": {
"bbox": [104.97, 21.65, 105.65, 22.42],
"name_en": "Tuyen Quang",
"region": "Miền Bắc"
},
"Thái Nguyên": {
"bbox": [105.48, 21.27, 106.15, 22.07],
"name_en": "Thai Nguyen",
"region": "Miền Bắc"
},
"Lạng Sơn": {
"bbox": [106.22, 21.40, 107.18, 22.41],
"name_en": "Lang Son",
"region": "Miền Bắc"
},
"Bắc Giang": {
"bbox": [105.97, 21.05, 106.70, 21.68],
"name_en": "Bac Giang",
"region": "Miền Bắc"
},
"Phú Thọ": {
"bbox": [104.83, 21.01, 105.48, 21.82],
"name_en": "Phu Tho",
"region": "Miền Bắc"
},
"Vĩnh Phúc": {
"bbox": [105.31, 21.14, 105.81, 21.61],
"name_en": "Vinh Phuc",
"region": "Miền Bắc"
},
"Bắc Ninh": {
"bbox": [105.83, 20.93, 106.26, 21.32],
"name_en": "Bac Ninh",
"region": "Miền Bắc"
},
"Hải Dương": {
"bbox": [106.14, 20.68, 106.70, 21.07],
"name_en": "Hai Duong",
"region": "Miền Bắc"
},
"Hưng Yên": {
"bbox": [105.83, 20.58, 106.26, 21.03],
"name_en": "Hung Yen",
"region": "Miền Bắc"
},
"Hà Nam": {
"bbox": [105.79, 20.33, 106.14, 20.73],
"name_en": "Ha Nam",
"region": "Miền Bắc"
},
"Nam Định": {
"bbox": [105.98, 20.04, 106.47, 20.64],
"name_en": "Nam Dinh",
"region": "Miền Bắc"
},
"Thái Bình": {
"bbox": [106.23, 20.27, 106.70, 20.76],
"name_en": "Thai Binh",
"region": "Miền Bắc"
},
"Ninh Bình": {
"bbox": [105.70, 20.05, 106.14, 20.50],
"name_en": "Ninh Binh",
"region": "Miền Bắc"
},
"Hòa Bình": {
"bbox": [104.83, 20.35, 105.74, 21.06],
"name_en": "Hoa Binh",
"region": "Miền Bắc"
},
"Sơn La": {
"bbox": [103.22, 20.66, 104.83, 21.82],
"name_en": "Son La",
"region": "Miền Bắc"
},
"Yên Bái": {
"bbox": [103.97, 21.35, 105.20, 22.21],
"name_en": "Yen Bai",
"region": "Miền Bắc"
},
# Miền Trung - Central Region
"Thanh Hóa": {
"bbox": [104.83, 19.33, 106.14, 20.66],
"name_en": "Thanh Hoa",
"region": "Miền Trung"
},
"Nghệ An": {
"bbox": [103.97, 18.34, 105.74, 19.89],
"name_en": "Nghe An",
"region": "Miền Trung"
},
"Hà Tĩnh": {
"bbox": [105.20, 17.98, 106.23, 18.78],
"name_en": "Ha Tinh",
"region": "Miền Trung"
},
"Quảng Bình": {
"bbox": [105.74, 16.97, 107.04, 18.06],
"name_en": "Quang Binh",
"region": "Miền Trung"
},
"Quảng Trị": {
"bbox": [106.48, 16.38, 107.54, 17.20],
"name_en": "Quang Tri",
"region": "Miền Trung"
},
"Thừa Thiên Huế": {
"bbox": [107.04, 16.01, 108.01, 16.95],
"name_en": "Thua Thien Hue",
"region": "Miền Trung"
},
"Đà Nẵng": {
"bbox": [107.77, 15.87, 108.33, 16.28],
"name_en": "Da Nang",
"region": "Miền Trung"
},
"Quảng Nam": {
"bbox": [107.04, 14.93, 108.70, 16.16],
"name_en": "Quang Nam",
"region": "Miền Trung"
},
"Quảng Ngãi": {
"bbox": [108.01, 14.66, 109.18, 15.53],
"name_en": "Quang Ngai",
"region": "Miền Trung"
},
"Bình Định": {
"bbox": [108.33, 13.76, 109.26, 14.72],
"name_en": "Binh Dinh",
"region": "Miền Trung"
},
"Phú Yên": {
"bbox": [108.70, 12.75, 109.46, 13.96],
"name_en": "Phu Yen",
"region": "Miền Trung"
},
"Khánh Hòa": {
"bbox": [108.70, 11.75, 109.46, 12.95],
"name_en": "Khanh Hoa",
"region": "Miền Trung"
},
"Ninh Thuận": {
"bbox": [108.33, 11.27, 109.18, 12.04],
"name_en": "Ninh Thuan",
"region": "Miền Trung"
},
"Bình Thuận": {
"bbox": [107.54, 10.49, 108.70, 11.75],
"name_en": "Binh Thuan",
"region": "Miền Trung"
},
"Kon Tum": {
"bbox": [107.54, 13.95, 108.70, 15.17],
"name_en": "Kon Tum",
"region": "Tây Nguyên"
},
"Gia Lai": {
"bbox": [107.54, 13.17, 108.70, 14.72],
"name_en": "Gia Lai",
"region": "Tây Nguyên"
},
"Đắk Lắk": {
"bbox": [107.54, 12.24, 108.70, 13.40],
"name_en": "Dak Lak",
"region": "Tây Nguyên"
},
"Đắk Nông": {
"bbox": [107.04, 11.75, 108.33, 12.75],
"name_en": "Dak Nong",
"region": "Tây Nguyên"
},
"Lâm Đồng": {
"bbox": [107.04, 10.99, 108.70, 12.52],
"name_en": "Lam Dong",
"region": "Tây Nguyên"
},
# Miền Nam - Southern Region
"Hồ Chí Minh": {
"bbox": [106.36, 10.35, 107.04, 11.16],
"name_en": "Ho Chi Minh City",
"region": "Miền Nam"
},
"Đồng Nai": {
"bbox": [106.70, 10.49, 107.54, 11.51],
"name_en": "Dong Nai",
"region": "Miền Nam"
},
"Bình Dương": {
"bbox": [106.36, 10.87, 106.96, 11.51],
"name_en": "Binh Duong",
"region": "Miền Nam"
},
"Bà Rịa - Vũng Tàu": {
"bbox": [107.04, 10.16, 107.77, 10.87],
"name_en": "Ba Ria - Vung Tau",
"region": "Miền Nam"
},
"Bình Phước": {
"bbox": [106.36, 11.16, 107.54, 12.24],
"name_en": "Binh Phuoc",
"region": "Miền Nam"
},
"Tây Ninh": {
"bbox": [105.74, 10.87, 106.70, 11.75],
"name_en": "Tay Ninh",
"region": "Miền Nam"
},
"Long An": {
"bbox": [105.74, 10.16, 106.70, 11.16],
"name_en": "Long An",
"region": "Miền Nam"
},
"Tiền Giang": {
"bbox": [105.74, 9.99, 106.70, 10.70],
"name_en": "Tien Giang",
"region": "Miền Nam"
},
"Bến Tre": {
"bbox": [105.98, 9.77, 106.70, 10.35],
"name_en": "Ben Tre",
"region": "Miền Nam"
},
"Đồng Tháp": {
"bbox": [105.20, 10.16, 105.98, 11.16],
"name_en": "Dong Thap",
"region": "Miền Nam"
},
"Vĩnh Long": {
"bbox": [105.74, 9.77, 106.36, 10.35],
"name_en": "Vinh Long",
"region": "Miền Nam"
},
"Trà Vinh": {
"bbox": [105.98, 9.33, 106.70, 10.04],
"name_en": "Tra Vinh",
"region": "Miền Nam"
},
"An Giang": {
"bbox": [104.83, 9.99, 105.74, 10.99],
"name_en": "An Giang",
"region": "Miền Nam"
},
"Kiên Giang": {
"bbox": [103.22, 8.68, 105.48, 10.52],
"name_en": "Kien Giang",
"region": "Miền Nam"
},
"Cần Thơ": {
"bbox": [105.48, 9.77, 106.14, 10.35],
"name_en": "Can Tho",
"region": "Miền Nam"
},
"Hậu Giang": {
"bbox": [105.31, 9.33, 105.98, 9.99],
"name_en": "Hau Giang",
"region": "Miền Nam"
},
"Sóc Trăng": {
"bbox": [105.48, 9.16, 106.23, 9.99],
"name_en": "Soc Trang",
"region": "Miền Nam"
},
"Bạc Liêu": {
"bbox": [105.31, 8.93, 105.98, 9.60],
"name_en": "Bac Lieu",
"region": "Miền Nam"
},
"Cà Mau": {
"bbox": [104.58, 8.18, 105.48, 9.60],
"name_en": "Ca Mau",
"region": "Miền Nam"
},
}
def get_all_provinces():
"""Lấy danh sách tất cả các tỉnh thành"""
return list(VIETNAM_PROVINCES.keys())
def get_provinces_by_region():
"""Lấy danh sách tỉnh thành theo vùng miền"""
regions = {}
for province, data in VIETNAM_PROVINCES.items():
region = data["region"]
if region not in regions:
regions[region] = []
regions[region].append({
"name": province,
"name_en": data["name_en"],
"bbox": data["bbox"]
})
return regions
def get_province_bbox(province_name):
"""Lấy bbox của một tỉnh thành"""
if province_name in VIETNAM_PROVINCES:
return VIETNAM_PROVINCES[province_name]["bbox"]
return None
def search_province(query):
"""Tìm kiếm tỉnh thành theo tên"""
query = query.lower()
results = []
for province, data in VIETNAM_PROVINCES.items():
if (query in province.lower() or
query in data["name_en"].lower()):
results.append({
"name": province,
"name_en": data["name_en"],
"bbox": data["bbox"],
"region": data["region"]
})
return results
+461
View File
@@ -0,0 +1,461 @@
"""
Vietnam Provinces After Administrative Merger (32 provinces)
32 tỉnh thành Việt Nam sau sáp nhập theo Nghị quyết 1211/2023
Bbox đã được mở rộng để bao phủ các tỉnh đã hợp nhất
"""
VIETNAM_PROVINCES_32 = {
# Thành phố trực thuộc TW (5)
"Hà Nội": {
"bbox": [105.35, 20.53, 105.92, 21.33],
"name_en": "Hanoi",
"region": "Đồng bằng Bắc Bộ",
"merged_from": None,
"area_km2": 3359
},
"Hải Phòng": {
"bbox": [106.14, 20.68, 107.07, 21.09], # Bao gồm cả Hải Dương
"name_en": "Hai Phong",
"region": "Đồng bằng Bắc Bộ",
"merged_from": ["Hải Phòng", "Hải Dương"],
"area_km2": 2914
},
"Đà Nẵng": {
"bbox": [107.04, 14.93, 108.70, 16.28], # Bao gồm cả Quảng Nam
"name_en": "Da Nang - Quang Nam",
"region": "Duyên hải Nam Trung Bộ",
"merged_from": ["Đà Nẵng", "Quảng Nam"],
"area_km2": 11065
},
"Hồ Chí Minh": {
"bbox": [106.36, 10.35, 107.04, 11.16],
"name_en": "Ho Chi Minh City",
"region": "Đông Nam Bộ",
"merged_from": None,
"area_km2": 2061
},
"Cần Thơ": {
"bbox": [105.48, 9.77, 106.14, 10.35],
"name_en": "Can Tho",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 1402
},
# Các tỉnh sau sáp nhập (27)
# Vùng núi phía Bắc
"Lào Cai": {
"bbox": [103.22, 21.82, 104.45, 22.77],
"name_en": "Lao Cai",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 6384
},
"Điện Biên": {
"bbox": [102.72, 21.09, 103.72, 22.21], # Bao gồm cả Lai Châu
"name_en": "Dien Bien - Lai Chau",
"region": "Vùng núi phía Bắc",
"merged_from": ["Điện Biên", "Lai Châu"],
"area_km2": 15274
},
"Hà Giang": {
"bbox": [104.42, 22.33, 105.59, 23.39],
"name_en": "Ha Giang",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 7946
},
"Cao Bằng": {
"bbox": [105.48, 21.95, 106.70, 23.04], # Bao gồm cả Bắc Kạn
"name_en": "Cao Bang - Bac Kan",
"region": "Vùng núi phía Bắc",
"merged_from": ["Cao Bằng", "Bắc Kạn"],
"area_km2": 11335
},
"Lạng Sơn": {
"bbox": [106.22, 21.40, 107.18, 22.41],
"name_en": "Lang Son",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 8327
},
"Tuyên Quang": {
"bbox": [104.97, 21.65, 105.65, 22.42],
"name_en": "Tuyen Quang",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 5868
},
"Yên Bái": {
"bbox": [103.97, 21.35, 105.20, 22.21],
"name_en": "Yen Bai",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 6899
},
"Thái Nguyên": {
"bbox": [105.48, 21.27, 106.15, 22.07],
"name_en": "Thai Nguyen",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 3534
},
"Phú Thọ": {
"bbox": [104.83, 21.01, 105.48, 21.82],
"name_en": "Phu Tho",
"region": "Vùng núi phía Bắc",
"merged_from": None,
"area_km2": 3533
},
"Hòa Bình": {
"bbox": [103.22, 20.35, 105.74, 21.82], # Bao gồm cả Sơn La
"name_en": "Hoa Binh - Son La",
"region": "Vùng núi phía Bắc",
"merged_from": ["Hòa Bình", "Sơn La"],
"area_km2": 19210
},
# Đồng bằng Bắc Bộ
"Quảng Ninh": {
"bbox": [106.48, 20.70, 108.26, 21.62],
"name_en": "Quang Ninh",
"region": "Đồng bằng Bắc Bộ",
"merged_from": None,
"area_km2": 6102
},
"Bắc Ninh": {
"bbox": [105.83, 20.93, 106.70, 21.68], # Bao gồm cả Bắc Giang
"name_en": "Bac Ninh - Bac Giang",
"region": "Đồng bằng Bắc Bộ",
"merged_from": ["Bắc Ninh", "Bắc Giang"],
"area_km2": 4631
},
"Vĩnh Phúc": {
"bbox": [105.31, 21.14, 105.81, 21.61],
"name_en": "Vinh Phuc",
"region": "Đồng bằng Bắc Bộ",
"merged_from": None,
"area_km2": 1236
},
"Hưng Yên": {
"bbox": [105.83, 20.58, 106.26, 21.03],
"name_en": "Hung Yen",
"region": "Đồng bằng Bắc Bộ",
"merged_from": None,
"area_km2": 926
},
"Nam Định": {
"bbox": [105.79, 20.04, 106.47, 20.73], # Bao gồm cả Hà Nam
"name_en": "Nam Dinh - Ha Nam",
"region": "Đồng bằng Bắc Bộ",
"merged_from": ["Nam Định", "Hà Nam"],
"area_km2": 2442
},
"Thái Bình": {
"bbox": [106.23, 20.27, 106.70, 20.76],
"name_en": "Thai Binh",
"region": "Đồng bằng Bắc Bộ",
"merged_from": None,
"area_km2": 1570
},
# Bắc Trung Bộ
"Thanh Hóa": {
"bbox": [104.83, 19.33, 106.14, 20.66], # Bao gồm cả Ninh Bình
"name_en": "Thanh Hoa - Ninh Binh",
"region": "Bắc Trung Bộ",
"merged_from": ["Thanh Hóa", "Ninh Bình"],
"area_km2": 12490
},
"Nghệ An": {
"bbox": [103.97, 17.98, 106.23, 19.89], # Bao gồm cả Hà Tĩnh
"name_en": "Nghe An - Ha Tinh",
"region": "Bắc Trung Bộ",
"merged_from": ["Nghệ An", "Hà Tĩnh"],
"area_km2": 22793
},
"Quảng Bình": {
"bbox": [105.74, 16.97, 107.04, 18.06],
"name_en": "Quang Binh",
"region": "Bắc Trung Bộ",
"merged_from": None,
"area_km2": 8065
},
"Quảng Trị": {
"bbox": [106.48, 16.38, 107.54, 17.20],
"name_en": "Quang Tri",
"region": "Bắc Trung Bộ",
"merged_from": None,
"area_km2": 4746
},
"Thừa Thiên Huế": {
"bbox": [107.04, 16.01, 108.01, 16.95],
"name_en": "Thua Thien Hue",
"region": "Bắc Trung Bộ",
"merged_from": None,
"area_km2": 5033
},
# Duyên hải Nam Trung Bộ
"Quảng Ngãi": {
"bbox": [108.01, 14.66, 109.18, 15.53],
"name_en": "Quang Ngai",
"region": "Duyên hải Nam Trung Bộ",
"merged_from": None,
"area_km2": 5153
},
"Bình Định": {
"bbox": [108.33, 12.75, 109.46, 14.72], # Bao gồm cả Phú Yên
"name_en": "Binh Dinh - Phu Yen",
"region": "Duyên hải Nam Trung Bộ",
"merged_from": ["Bình Định", "Phú Yên"],
"area_km2": 11092
},
"Khánh Hòa": {
"bbox": [108.70, 11.75, 109.46, 12.95],
"name_en": "Khanh Hoa",
"region": "Duyên hải Nam Trung Bộ",
"merged_from": None,
"area_km2": 5218
},
"Bình Thuận": {
"bbox": [107.54, 10.49, 109.18, 12.04], # Bao gồm cả Ninh Thuận
"name_en": "Binh Thuan - Ninh Thuan",
"region": "Duyên hải Nam Trung Bộ",
"merged_from": ["Bình Thuận", "Ninh Thuận"],
"area_km2": 11234
},
# Tây Nguyên
"Gia Lai": {
"bbox": [107.54, 13.17, 108.70, 15.17], # Bao gồm cả Kon Tum
"name_en": "Gia Lai - Kon Tum",
"region": "Tây Nguyên",
"merged_from": ["Gia Lai", "Kon Tum"],
"area_km2": 25536
},
"Đắk Lắk": {
"bbox": [107.04, 11.75, 108.70, 13.40], # Bao gồm cả Đắk Nông
"name_en": "Dak Lak - Dak Nong",
"region": "Tây Nguyên",
"merged_from": ["Đắk Lắk", "Đắk Nông"],
"area_km2": 19850
},
"Lâm Đồng": {
"bbox": [107.04, 10.99, 108.70, 12.52],
"name_en": "Lam Dong",
"region": "Tây Nguyên",
"merged_from": None,
"area_km2": 9776
},
# Đông Nam Bộ
"Đồng Nai": {
"bbox": [106.36, 10.49, 107.54, 12.24], # Bao gồm cả Bình Phước
"name_en": "Dong Nai - Binh Phuoc",
"region": "Đông Nam Bộ",
"merged_from": ["Đồng Nai", "Bình Phước"],
"area_km2": 13317
},
"Bình Dương": {
"bbox": [106.36, 10.87, 106.96, 11.51],
"name_en": "Binh Duong",
"region": "Đông Nam Bộ",
"merged_from": None,
"area_km2": 2695
},
"Bà Rịa - Vũng Tàu": {
"bbox": [107.04, 10.16, 107.77, 10.87],
"name_en": "Ba Ria - Vung Tau",
"region": "Đông Nam Bộ",
"merged_from": None,
"area_km2": 1990
},
"Tây Ninh": {
"bbox": [105.74, 10.87, 106.70, 11.75],
"name_en": "Tay Ninh",
"region": "Đông Nam Bộ",
"merged_from": None,
"area_km2": 4040
},
# Đồng bằng sông Cửu Long
"Tiền Giang": {
"bbox": [105.74, 9.99, 106.70, 11.16], # Bao gồm cả Long An
"name_en": "Tien Giang - Long An",
"region": "Đồng bằng sông Cửu Long",
"merged_from": ["Tiền Giang", "Long An"],
"area_km2": 6935
},
"Bến Tre": {
"bbox": [105.98, 9.77, 106.70, 10.35],
"name_en": "Ben Tre",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 2360
},
"Vĩnh Long": {
"bbox": [105.74, 9.33, 106.70, 10.35], # Bao gồm cả Trà Vinh
"name_en": "Vinh Long - Tra Vinh",
"region": "Đồng bằng sông Cửu Long",
"merged_from": ["Vĩnh Long", "Trà Vinh"],
"area_km2": 4567
},
"Đồng Tháp": {
"bbox": [105.20, 10.16, 105.98, 11.16],
"name_en": "Dong Thap",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 3377
},
"An Giang": {
"bbox": [104.83, 9.99, 105.74, 10.99],
"name_en": "An Giang",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 3537
},
"Kiên Giang": {
"bbox": [103.22, 8.68, 105.48, 10.52],
"name_en": "Kien Giang",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 6348
},
"Sóc Trăng": {
"bbox": [105.31, 9.16, 106.23, 9.99], # Bao gồm cả Hậu Giang
"name_en": "Soc Trang - Hau Giang",
"region": "Đồng bằng sông Cửu Long",
"merged_from": ["Sóc Trăng", "Hậu Giang"],
"area_km2": 4750
},
"Bạc Liêu": {
"bbox": [105.31, 8.93, 105.98, 9.60],
"name_en": "Bac Lieu",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 2585
},
"Cà Mau": {
"bbox": [104.58, 8.18, 105.48, 9.60],
"name_en": "Ca Mau",
"region": "Đồng bằng sông Cửu Long",
"merged_from": None,
"area_km2": 5332
},
}
def get_all_provinces_32():
"""Lấy danh sách tất cả 32 tỉnh thành sau sáp nhập"""
return list(VIETNAM_PROVINCES_32.keys())
def get_provinces_by_region_32():
"""Lấy danh sách 32 tỉnh thành theo vùng miền"""
regions = {}
for province, data in VIETNAM_PROVINCES_32.items():
region = data["region"]
if region not in regions:
regions[region] = []
regions[region].append({
"name": province,
"name_en": data["name_en"],
"bbox": data["bbox"],
"merged_from": data.get("merged_from"),
"area_km2": data.get("area_km2")
})
return regions
def get_province_bbox_32(province_name):
"""Lấy bbox của một tỉnh thành (32 tỉnh)"""
if province_name in VIETNAM_PROVINCES_32:
return VIETNAM_PROVINCES_32[province_name]["bbox"]
return None
def get_merged_info(province_name):
"""Lấy thông tin sáp nhập của tỉnh"""
if province_name in VIETNAM_PROVINCES_32:
data = VIETNAM_PROVINCES_32[province_name]
return {
"name": province_name,
"bbox": data["bbox"],
"merged_from": data.get("merged_from"),
"region": data["region"],
"area_km2": data.get("area_km2")
}
return None
def search_province_32(query):
"""Tìm kiếm tỉnh thành theo tên (32 tỉnh)"""
query = query.lower()
results = []
for province, data in VIETNAM_PROVINCES_32.items():
if (query in province.lower() or
query in data["name_en"].lower()):
results.append({
"name": province,
"name_en": data["name_en"],
"bbox": data["bbox"],
"region": data["region"],
"merged_from": data.get("merged_from"),
"area_km2": data.get("area_km2")
})
return results
def get_provinces_statistics():
"""Thống kê các tỉnh đã sáp nhập"""
total = len(VIETNAM_PROVINCES_32)
merged = len([p for p in VIETNAM_PROVINCES_32.values() if p.get("merged_from")])
original = total - merged
return {
"total_provinces": total,
"merged_provinces": merged,
"original_provinces": original,
"regions": list(set(p["region"] for p in VIETNAM_PROVINCES_32.values())),
"total_area_km2": sum(p.get("area_km2", 0) for p in VIETNAM_PROVINCES_32.values())
}