làm mịn các điểm ảnh
This commit is contained in:
+574
-185
@@ -96,14 +96,14 @@ DEFAULT_LABEL_MAPPING = {
|
||||
}
|
||||
|
||||
DEFAULT_LABEL_NAMES = {
|
||||
"0": "Lua tom",
|
||||
"1": "Lua",
|
||||
"2": "CHN",
|
||||
"3": "CLN",
|
||||
"4": "TS",
|
||||
"5": "Song",
|
||||
"6": "Dat xay dung",
|
||||
"7": "Rung",
|
||||
0: "Lua tom",
|
||||
1: "Lua",
|
||||
2: "CHN",
|
||||
3: "CLN",
|
||||
4: "TS",
|
||||
5: "Song",
|
||||
6: "Dat xay dung",
|
||||
7: "Rung",
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class TrainingConfig(BaseModel):
|
||||
resolution: int # 10m hoặc 20m
|
||||
|
||||
# Model parameters
|
||||
model_type: str # xgboost, random_forest, decision_tree, svm, cnn, swin-unet
|
||||
model_type: str # xgboost, random_forest, decision_tree, svm, cnn, swin-unet, mobilenet-lraspp
|
||||
n_estimators: int
|
||||
max_depth: int
|
||||
learning_rate: float
|
||||
@@ -1151,9 +1151,9 @@ async def run_prediction(config: PredictionConfig):
|
||||
# Initialize FeatureExtractor với đúng mode như lúc training
|
||||
extractor = get_feature_extractor(mode=feature_mode)
|
||||
|
||||
# Check if it's a PyTorch model (CNN, Swin-UNet, etc.)
|
||||
# Check if it's a PyTorch model (CNN, Swin-UNet, MobileNet, etc.)
|
||||
is_pytorch_model = hasattr(model, '__class__') and any(
|
||||
name in model.__class__.__name__ for name in ['CNN', 'SwinUNet']
|
||||
name in model.__class__.__name__ for name in ['CNN', 'SwinUNet', 'MobileNet']
|
||||
)
|
||||
if is_pytorch_model:
|
||||
model_class_name = model.__class__.__name__
|
||||
@@ -1167,43 +1167,78 @@ async def run_prediction(config: PredictionConfig):
|
||||
bbox = [config.min_lon, config.min_lat, config.max_lon, config.max_lat]
|
||||
time_range = f"{config.start_date}/{config.end_date}"
|
||||
|
||||
# ============ CHECK PREDICTION CACHE FIRST ============
|
||||
cached_s2_data = None
|
||||
cache_key = f"{config.min_lon:.2f}_{config.min_lat:.2f}_{config.max_lon:.2f}_{config.max_lat:.2f}"
|
||||
|
||||
# Try to find matching cache
|
||||
cache_dir = Path("prediction_cache")
|
||||
if cache_dir.exists():
|
||||
for cache_file in cache_dir.glob(f"pred_{cache_key}_*.json"):
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Check if cache matches current config
|
||||
if (cache_data.get('start_date') == config.start_date and
|
||||
cache_data.get('end_date') == config.end_date and
|
||||
cache_data.get('resolution') == config.resolution and
|
||||
cache_data.get('data_file')):
|
||||
|
||||
data_file = cache_dir / cache_data['data_file']
|
||||
if data_file.exists():
|
||||
prediction_status["progress"] = "Đang load dữ liệu từ cache..."
|
||||
import joblib
|
||||
cached_s2_data = joblib.load(data_file)
|
||||
print(f"[CACHE HIT] Using cached Sentinel-2 data from {cache_file.name}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[CACHE] Error loading cache {cache_file}: {e}")
|
||||
|
||||
# ============ LOAD SENTINEL-2 DATA ============
|
||||
prediction_status["progress"] = "Đang kết nối Microsoft Planetary Computer..."
|
||||
import pystac_client
|
||||
import planetary_computer
|
||||
from odc.stac import load
|
||||
if cached_s2_data is not None:
|
||||
s2_data = cached_s2_data
|
||||
s2_items = [] # Empty list when using cache
|
||||
prediction_status["progress"] = "Đã load dữ liệu từ cache, đang xử lý..."
|
||||
else:
|
||||
prediction_status["progress"] = "Đang kết nối Microsoft Planetary Computer..."
|
||||
import pystac_client
|
||||
import planetary_computer
|
||||
from odc.stac import load
|
||||
|
||||
catalog = pystac_client.Client.open(
|
||||
"https://planetarycomputer.microsoft.com/api/stac/v1",
|
||||
modifier=planetary_computer.sign_inplace,
|
||||
)
|
||||
catalog = pystac_client.Client.open(
|
||||
"https://planetarycomputer.microsoft.com/api/stac/v1",
|
||||
modifier=planetary_computer.sign_inplace,
|
||||
)
|
||||
|
||||
prediction_status["progress"] = "Đang tải dữ liệu Sentinel-2..."
|
||||
s2_search = catalog.search(
|
||||
collections=["sentinel-2-l2a"],
|
||||
bbox=bbox,
|
||||
datetime=time_range,
|
||||
query={"eo:cloud_cover": {"lt": config.cloud_cover}}
|
||||
)
|
||||
s2_items = list(s2_search.items())
|
||||
prediction_status["progress"] = "Đang tải dữ liệu Sentinel-2..."
|
||||
s2_search = catalog.search(
|
||||
collections=["sentinel-2-l2a"],
|
||||
bbox=bbox,
|
||||
datetime=time_range,
|
||||
query={"eo:cloud_cover": {"lt": config.cloud_cover}}
|
||||
)
|
||||
s2_items = list(s2_search.items())
|
||||
|
||||
if not s2_items:
|
||||
raise ValueError("Không tìm thấy dữ liệu Sentinel-2 cho khu vực và thời gian này")
|
||||
if not s2_items:
|
||||
raise ValueError("Không tìm thấy dữ liệu Sentinel-2 cho khu vực và thời gian này")
|
||||
|
||||
s2_items = s2_items[:config.max_scenes]
|
||||
prediction_status["progress"] = f"Đang xử lý {len(s2_items)} scenes Sentinel-2..."
|
||||
s2_items = s2_items[:config.max_scenes]
|
||||
|
||||
# Load different bands based on feature mode
|
||||
if feature_mode == 'simple':
|
||||
bands_to_load = ["B04", "B08", "SCL"]
|
||||
else: # temporal or extended
|
||||
bands_to_load = ["B02", "B03", "B04", "B08", "B11", "SCL"]
|
||||
prediction_status["progress"] = f"Đang xử lý dữ liệu Sentinel-2..."
|
||||
|
||||
s2_data = load(
|
||||
s2_items,
|
||||
bbox=bbox,
|
||||
bands=bands_to_load,
|
||||
chunks={"time": 1, "x": 2048, "y": 2048},
|
||||
# Load different bands based on feature mode (skip if using cache)
|
||||
if cached_s2_data is None:
|
||||
if feature_mode == 'simple':
|
||||
bands_to_load = ["B04", "B08", "SCL"]
|
||||
else: # temporal or extended
|
||||
bands_to_load = ["B02", "B03", "B04", "B08", "B11", "SCL"]
|
||||
|
||||
s2_data = load(
|
||||
s2_items,
|
||||
bbox=bbox,
|
||||
bands=bands_to_load,
|
||||
chunks={"time": 1, "x": 2048, "y": 2048},
|
||||
groupby="solar_day",
|
||||
resolution=config.resolution
|
||||
).compute()
|
||||
@@ -1245,15 +1280,90 @@ async def run_prediction(config: PredictionConfig):
|
||||
except Exception as e:
|
||||
prediction_status["progress"] = f"Lỗi load Sentinel-1: {str(e)}, bỏ qua radar features"
|
||||
|
||||
# ============ APPLY CLOUD MASK ============
|
||||
prediction_status["progress"] = "Đang xử lý mây..."
|
||||
# ============ ADVANCED CLOUD MASKING & REMOVAL ============
|
||||
prediction_status["progress"] = "Đang xử lý mây nâng cao..."
|
||||
|
||||
cloud_coverage_percent = 0
|
||||
if "SCL" in s2_data:
|
||||
scl = s2_data["SCL"]
|
||||
# SCL values: 3=cloud shadow, 8=cloud medium, 9=cloud high, 10=cirrus
|
||||
cloud_mask = (scl == 3) | (scl == 8) | (scl == 9) | (scl == 10)
|
||||
|
||||
# SCL classification values (Sentinel-2 Scene Classification):
|
||||
# 0: No data, 1: Saturated/Defective, 2: Dark Area Pixels
|
||||
# 3: Cloud shadows, 4: Vegetation, 5: Not vegetated, 6: Water
|
||||
# 7: Unclassified, 8: Cloud medium probability, 9: Cloud high probability
|
||||
# 10: Thin cirrus, 11: Snow/Ice
|
||||
|
||||
# Comprehensive cloud mask (clouds, shadows, cirrus, snow)
|
||||
cloud_mask = (scl == 3) | (scl == 8) | (scl == 9) | (scl == 10) | (scl == 11)
|
||||
|
||||
# Also mask no-data and saturated pixels
|
||||
invalid_mask = (scl == 0) | (scl == 1)
|
||||
full_mask = cloud_mask | invalid_mask
|
||||
|
||||
# Calculate cloud coverage percentage
|
||||
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
|
||||
|
||||
print(f"[CLOUD MASK] Cloud coverage: {cloud_coverage_percent:.1f}%")
|
||||
print(f"[CLOUD MASK] Masked pixels: {masked_pixels}/{total_pixels}")
|
||||
|
||||
# Apply mask to all bands
|
||||
for band in s2_data.data_vars:
|
||||
if band != "SCL":
|
||||
s2_data[band] = s2_data[band].where(~cloud_mask)
|
||||
s2_data[band] = s2_data[band].where(~full_mask)
|
||||
|
||||
# ============ CLOUD REMOVAL STRATEGIES ============
|
||||
|
||||
# Strategy 1: Temporal Interpolation (fill gaps between time steps)
|
||||
prediction_status["progress"] = "Đang khử mây bằng temporal interpolation..."
|
||||
for band in s2_data.data_vars:
|
||||
if band != "SCL":
|
||||
# Forward fill then backward fill along time dimension
|
||||
s2_data[band] = s2_data[band].ffill(dim='time').bfill(dim='time')
|
||||
|
||||
print(f"[CLOUD REMOVAL] Applied temporal interpolation")
|
||||
|
||||
# Strategy 2: Median Compositing (if multiple time steps available)
|
||||
if len(s2_data.time) >= 3:
|
||||
prediction_status["progress"] = "Đang tạo median composite để giảm nhiễu mây..."
|
||||
|
||||
# Create median composite for each band
|
||||
for band in s2_data.data_vars:
|
||||
if band != "SCL":
|
||||
# Median reduces cloud noise better than mean
|
||||
median_composite = s2_data[band].median(dim='time', skipna=True)
|
||||
|
||||
# Fill remaining NaN with median
|
||||
s2_data[band] = s2_data[band].fillna(median_composite)
|
||||
|
||||
print(f"[CLOUD REMOVAL] Applied median compositing from {len(s2_data.time)} scenes")
|
||||
|
||||
# Strategy 3: Spatial Interpolation (fill small gaps)
|
||||
prediction_status["progress"] = "Đang khử mây bằng spatial interpolation..."
|
||||
for band in s2_data.data_vars:
|
||||
if band != "SCL":
|
||||
# Use nearest neighbor interpolation for remaining small gaps
|
||||
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')
|
||||
|
||||
print(f"[CLOUD REMOVAL] Applied spatial interpolation")
|
||||
|
||||
# Final check: replace any remaining NaN with 0
|
||||
for band in s2_data.data_vars:
|
||||
if band != "SCL":
|
||||
s2_data[band] = s2_data[band].fillna(0)
|
||||
|
||||
print(f"[CLOUD REMOVAL] Completed - all NaN values handled")
|
||||
|
||||
# Quality warning if cloud coverage too high
|
||||
if cloud_coverage_percent > 30:
|
||||
print(f"[WARNING] High cloud coverage ({cloud_coverage_percent:.1f}%) - prediction quality may be affected")
|
||||
prediction_status["progress"] = f"⚠️ Cảnh báo: Độ phủ mây cao ({cloud_coverage_percent:.1f}%)"
|
||||
|
||||
else:
|
||||
print("[WARNING] No SCL band available - skipping cloud masking")
|
||||
prediction_status["progress"] = "⚠️ Không có SCL band - bỏ qua khử mây"
|
||||
|
||||
# ============ EXTRACT FEATURES ============
|
||||
prediction_status["progress"] = f"Đang trích xuất features (mode={feature_mode})..."
|
||||
@@ -1332,6 +1442,17 @@ async def run_prediction(config: PredictionConfig):
|
||||
pred_shape = (y_size, x_size)
|
||||
predictions_2d = predictions.reshape(pred_shape)
|
||||
|
||||
# Smooth classification map to reduce salt-and-pepper noise
|
||||
try:
|
||||
from scipy import ndimage
|
||||
smoothed = ndimage.median_filter(predictions_2d, size=3)
|
||||
# Keep invalid/nodata pixels (-1) untouched
|
||||
smoothed[predictions_2d < 0] = -1
|
||||
predictions_2d = smoothed
|
||||
print("[SMOOTH] Applied 3x3 median filter to classification map")
|
||||
except Exception as smooth_err:
|
||||
print(f"[SMOOTH WARNING] Failed to smooth classification map: {smooth_err}")
|
||||
|
||||
# ============ CREATE OUTPUT ============
|
||||
prediction_status["progress"] = "Đang tạo bản đồ phân loại..."
|
||||
|
||||
@@ -1370,82 +1491,61 @@ async def run_prediction(config: PredictionConfig):
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # Non-interactive backend
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Patch
|
||||
|
||||
# Create a figure with prediction result
|
||||
fig, ax = plt.subplots(figsize=(12, 10), dpi=150)
|
||||
# Create a figure with prediction result and legend
|
||||
fig, ax = plt.subplots(figsize=(14, 10), dpi=150)
|
||||
|
||||
# Plot prediction with colormap
|
||||
im = ax.imshow(predictions_2d, cmap='tab20', interpolation='nearest')
|
||||
ax.set_title(f'Prediction Result - {timestamp}', fontsize=14, fontweight='bold')
|
||||
ax.set_xlabel('X (pixels)', fontsize=10)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=10)
|
||||
ax.set_title(f'Land Classification - {timestamp}', fontsize=16, fontweight='bold', pad=20)
|
||||
ax.set_xlabel('X (pixels)', fontsize=11)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=11)
|
||||
|
||||
# Build mapping from numeric class value -> display label
|
||||
class_map = None
|
||||
try:
|
||||
# 1) Try label_encoder (preferred)
|
||||
if label_encoder is not None:
|
||||
try:
|
||||
# label_encoder.classes_ may be strings or numbers
|
||||
le_classes = list(label_encoder.classes_)
|
||||
# If classes are strings like names, we'll map indices -> names
|
||||
if all(isinstance(x, str) for x in le_classes):
|
||||
class_map = {i: name for i, name in enumerate(le_classes)}
|
||||
else:
|
||||
# If classes are numeric labels matching values, map value->str(value)
|
||||
class_map = {int(v): str(v) for v in le_classes}
|
||||
except Exception:
|
||||
class_map = None
|
||||
except Exception:
|
||||
class_map = None
|
||||
# ALWAYS use DEFAULT_LABEL_NAMES - it has the correct Vietnamese names
|
||||
class_map = DEFAULT_LABEL_NAMES.copy()
|
||||
|
||||
# 2) Try model metadata 'class_names' (list ordered by class code)
|
||||
if class_map is None and isinstance(model_metadata, dict):
|
||||
print(f"[LEGEND DEBUG] DEFAULT_LABEL_NAMES: {DEFAULT_LABEL_NAMES}")
|
||||
print(f"[LEGEND DEBUG] Model metadata: {model_metadata.get('label_mapping') if isinstance(model_metadata, dict) else 'No metadata'}")
|
||||
print(f"[LEGEND DEBUG] Final class_map: {class_map}")
|
||||
|
||||
# Get unique classes in prediction to show only relevant legend items
|
||||
unique_pred_classes = np.unique(predictions_2d)
|
||||
unique_pred_classes = unique_pred_classes[~np.isnan(unique_pred_classes)]
|
||||
|
||||
# Create custom legend with color patches
|
||||
legend_elements = []
|
||||
cmap = plt.cm.get_cmap('tab20')
|
||||
|
||||
for cls_val in sorted(unique_pred_classes):
|
||||
try:
|
||||
cn = model_metadata.get('class_names')
|
||||
if isinstance(cn, list):
|
||||
class_map = {i: str(name) for i, name in enumerate(cn)}
|
||||
except Exception:
|
||||
cls_int = int(cls_val)
|
||||
# Get color from colormap (normalize to 0-1 range)
|
||||
color = cmap(cls_int / 20.0) # tab20 has 20 colors
|
||||
# Get label name
|
||||
label_name = class_map.get(cls_int, f"Class {cls_int}")
|
||||
# Create patch for legend
|
||||
legend_elements.append(
|
||||
Patch(facecolor=color, edgecolor='black', linewidth=0.5,
|
||||
label=f"{cls_int}: {label_name}")
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3) Try invert label_mapping in metadata if exists (name->code)
|
||||
if class_map is None and isinstance(model_metadata, dict):
|
||||
try:
|
||||
lm = model_metadata.get('label_mapping') or model_metadata.get('labels')
|
||||
if isinstance(lm, dict):
|
||||
# invert mapping: code -> name
|
||||
inv = {}
|
||||
for k, v in lm.items():
|
||||
try:
|
||||
key_int = int(v)
|
||||
except Exception:
|
||||
continue
|
||||
inv[key_int] = str(k)
|
||||
if inv:
|
||||
class_map = inv
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create colorbar
|
||||
cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||
cbar.set_label('Class', rotation=270, labelpad=15)
|
||||
|
||||
# If we have a class_map, set ticks and labels
|
||||
try:
|
||||
if class_map:
|
||||
vals = np.array(sorted(class_map.keys()))
|
||||
cbar.set_ticks(vals)
|
||||
cbar.set_ticklabels([class_map[int(v)] for v in vals])
|
||||
else:
|
||||
# fallback: label numeric ticks from min..max
|
||||
if np.issubdtype(predictions_2d.dtype, np.number):
|
||||
minv = int(np.nanmin(predictions_2d))
|
||||
maxv = int(np.nanmax(predictions_2d))
|
||||
ticks = np.arange(minv, maxv + 1)
|
||||
cbar.set_ticks(ticks)
|
||||
cbar.set_ticklabels([str(t) for t in ticks])
|
||||
except Exception:
|
||||
pass
|
||||
# Add legend outside plot area
|
||||
if legend_elements:
|
||||
legend = ax.legend(
|
||||
handles=legend_elements,
|
||||
loc='center left',
|
||||
bbox_to_anchor=(1.02, 0.5),
|
||||
fontsize=10,
|
||||
title='Land Classes',
|
||||
title_fontsize=11,
|
||||
framealpha=0.9,
|
||||
edgecolor='black'
|
||||
)
|
||||
legend.get_title().set_fontweight('bold')
|
||||
|
||||
# Add grid
|
||||
ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5)
|
||||
@@ -1491,6 +1591,42 @@ async def run_prediction(config: PredictionConfig):
|
||||
print(f"[PREDICTION REPORT ERROR] Failed to generate report: {e}")
|
||||
prediction_status["progress"] = "Hoàn thành! (Không thể tạo báo cáo)"
|
||||
|
||||
# Auto-save prediction cache (including Sentinel-2 data)
|
||||
print(f"[DEBUG] Starting cache save process...")
|
||||
print(f"[DEBUG] cached_s2_data is None: {cached_s2_data is None}")
|
||||
print(f"[DEBUG] s2_data type: {type(s2_data)}")
|
||||
print(f"[DEBUG] s2_data is None: {s2_data is None}")
|
||||
|
||||
try:
|
||||
cache_config = {
|
||||
"min_lon": config.min_lon,
|
||||
"min_lat": config.min_lat,
|
||||
"max_lon": config.max_lon,
|
||||
"max_lat": config.max_lat,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
"max_scenes": config.max_scenes,
|
||||
"cloud_cover": config.cloud_cover,
|
||||
"resolution": config.resolution,
|
||||
"model_filename": config.model_filename
|
||||
}
|
||||
|
||||
print(f"[DEBUG] cache_config created: {cache_config}")
|
||||
|
||||
# Save cache with Sentinel-2 data (only if not from cache)
|
||||
data_to_save = None if cached_s2_data is not None else s2_data
|
||||
print(f"[DEBUG] data_to_save is None: {data_to_save is None}")
|
||||
print(f"[DEBUG] Calling save_prediction_cache_sync...")
|
||||
|
||||
result = save_prediction_cache_sync(cache_config, data_to_save)
|
||||
|
||||
print(f"[CACHE] Prediction cache saved: {result.get('message')} (with data: {data_to_save is not None})")
|
||||
print(f"[CACHE] Result: {result}")
|
||||
except Exception as cache_error:
|
||||
print(f"[CACHE ERROR] Failed to auto-save cache: {cache_error}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
prediction_status["end_time"] = dt.now().isoformat()
|
||||
|
||||
except Exception as e:
|
||||
@@ -1608,6 +1744,216 @@ async def preview_prediction_png(filename: str):
|
||||
)
|
||||
|
||||
|
||||
# ============ PREDICTION CACHE API (Auto-save) ============
|
||||
|
||||
def save_prediction_cache_sync(config: dict, s2_data=None):
|
||||
"""Tự động lưu prediction cache sau khi predict thành công (bao gồm cả dữ liệu Sentinel-2)"""
|
||||
print(f"[DEBUG save_prediction_cache_sync] Called with s2_data is None: {s2_data is None}")
|
||||
print(f"[DEBUG save_prediction_cache_sync] Config: {config}")
|
||||
|
||||
try:
|
||||
cache_dir = Path("prediction_cache")
|
||||
print(f"[DEBUG save_prediction_cache_sync] Cache dir: {cache_dir.absolute()}")
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
print(f"[DEBUG save_prediction_cache_sync] Cache dir created/exists")
|
||||
|
||||
# Create cache filename from bbox and timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
bbox_str = f"{config['min_lon']:.2f}_{config['min_lat']:.2f}_{config['max_lon']:.2f}_{config['max_lat']:.2f}"
|
||||
base_filename = f"pred_{bbox_str}_{timestamp}"
|
||||
config_file = cache_dir / f"{base_filename}.json"
|
||||
data_file = cache_dir / f"{base_filename}_data.joblib"
|
||||
|
||||
print(f"[DEBUG save_prediction_cache_sync] Config file: {config_file}")
|
||||
print(f"[DEBUG save_prediction_cache_sync] Data file: {data_file}")
|
||||
|
||||
# Prepare cache data with full metadata
|
||||
cache_data = {
|
||||
"bbox": [config['min_lon'], config['min_lat'], config['max_lon'], config['max_lat']],
|
||||
"min_lon": config['min_lon'],
|
||||
"min_lat": config['min_lat'],
|
||||
"max_lon": config['max_lon'],
|
||||
"max_lat": config['max_lat'],
|
||||
"start_date": config['start_date'],
|
||||
"end_date": config['end_date'],
|
||||
"max_scenes": config['max_scenes'],
|
||||
"cloud_cover": config['cloud_cover'],
|
||||
"resolution": config['resolution'],
|
||||
"model_filename": config.get('model_filename'),
|
||||
"created": timestamp,
|
||||
"type": "prediction_cache",
|
||||
"auto_saved": True,
|
||||
"has_data": s2_data is not None,
|
||||
"data_file": f"{base_filename}_data.joblib" if s2_data is not None else None
|
||||
}
|
||||
|
||||
print(f"[DEBUG save_prediction_cache_sync] Saving config JSON...")
|
||||
# Save config to JSON file
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, indent=2, ensure_ascii=False)
|
||||
print(f"[DEBUG save_prediction_cache_sync] Config JSON saved to {config_file}")
|
||||
|
||||
# Save Sentinel-2 data if provided
|
||||
if s2_data is not None:
|
||||
print(f"[DEBUG save_prediction_cache_sync] Saving Sentinel-2 data with joblib...")
|
||||
import joblib
|
||||
joblib.dump(s2_data, data_file)
|
||||
data_size_mb = data_file.stat().st_size / 1024 / 1024
|
||||
cache_data['data_size_mb'] = round(data_size_mb, 2)
|
||||
print(f"[CACHE] Saved Sentinel-2 data: {data_size_mb:.2f} MB to {data_file}")
|
||||
else:
|
||||
print(f"[DEBUG save_prediction_cache_sync] No s2_data to save")
|
||||
|
||||
print(f"[CACHE SUCCESS] Cache saved successfully: {base_filename}.json")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Đã lưu cache tự động" + (" (bao gồm dữ liệu Sentinel-2)" if s2_data is not None else ""),
|
||||
"filename": f"{base_filename}.json",
|
||||
"cache": cache_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[CACHE ERROR save_prediction_cache_sync] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@app.get("/api/prediction/cache/list")
|
||||
async def list_prediction_cache():
|
||||
"""Liệt kê các prediction cache đã lưu (bao gồm thông tin về dữ liệu Sentinel-2)"""
|
||||
try:
|
||||
cache_dir = Path("prediction_cache")
|
||||
if not cache_dir.exists():
|
||||
return {"caches": [], "count": 0}
|
||||
|
||||
caches = []
|
||||
for cache_file in cache_dir.glob("pred_*.json"):
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Check if data file exists
|
||||
data_filename = cache_data.get("data_file")
|
||||
has_data = False
|
||||
data_size_mb = 0
|
||||
|
||||
if data_filename:
|
||||
data_file_path = cache_dir / data_filename
|
||||
if data_file_path.exists():
|
||||
has_data = True
|
||||
data_size_mb = round(data_file_path.stat().st_size / 1024 / 1024, 2)
|
||||
|
||||
# Create display name from metadata
|
||||
bbox = cache_data.get("bbox", [])
|
||||
time_range = f"{cache_data.get('start_date', 'N/A')} → {cache_data.get('end_date', 'N/A')}"
|
||||
data_badge = f" [💾 {data_size_mb}MB]" if has_data else " [⚙️ Config only]"
|
||||
display_name = f"[{bbox[0]:.2f},{bbox[1]:.2f}→{bbox[2]:.2f},{bbox[3]:.2f}] {time_range}{data_badge}"
|
||||
|
||||
caches.append({
|
||||
"filename": cache_file.name,
|
||||
"display_name": display_name,
|
||||
"bbox": bbox,
|
||||
"created": cache_data.get("created"),
|
||||
"has_data": has_data,
|
||||
"data_size_mb": data_size_mb,
|
||||
"data_file": data_filename,
|
||||
"config": cache_data
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error loading cache {cache_file}: {e}")
|
||||
continue
|
||||
|
||||
# Sort by created time (newest first)
|
||||
caches.sort(key=lambda x: x.get("created", ""), reverse=True)
|
||||
|
||||
return {
|
||||
"caches": caches,
|
||||
"count": len(caches)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Lỗi khi load cache: {str(e)}")
|
||||
|
||||
@app.get("/api/prediction/cache/load-data/{filename}")
|
||||
async def load_cached_data(filename: str):
|
||||
"""Load Sentinel-2 data từ cache"""
|
||||
try:
|
||||
cache_dir = Path("prediction_cache")
|
||||
|
||||
# Security check
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
# Load config to get data filename
|
||||
config_file = cache_dir / filename
|
||||
if not config_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Cache config not found")
|
||||
|
||||
with open(config_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
data_filename = cache_data.get("data_file")
|
||||
if not data_filename:
|
||||
raise HTTPException(status_code=404, detail="No data file in cache")
|
||||
|
||||
data_file = cache_dir / data_filename
|
||||
if not data_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Data file not found")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"has_data": True,
|
||||
"data_file": data_filename,
|
||||
"data_size_mb": round(data_file.stat().st_size / 1024 / 1024, 2)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Lỗi: {str(e)}")
|
||||
|
||||
@app.delete("/api/prediction/cache/delete/{filename}")
|
||||
async def delete_prediction_cache(filename: str):
|
||||
"""Xóa một prediction cache (bao gồm cả file data nếu có)"""
|
||||
try:
|
||||
cache_dir = Path("prediction_cache")
|
||||
cache_file = cache_dir / filename
|
||||
|
||||
# Security check
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
if not cache_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Cache not found")
|
||||
|
||||
# Load config to check for data file
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
data_filename = cache_data.get("data_file")
|
||||
if data_filename:
|
||||
data_file = cache_dir / data_filename
|
||||
if data_file.exists():
|
||||
data_file.unlink()
|
||||
print(f"[CACHE] Deleted data file: {data_filename}")
|
||||
except Exception as e:
|
||||
print(f"[CACHE] Error deleting data file: {e}")
|
||||
|
||||
# Delete config file
|
||||
cache_file.unlink()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Đã xóa cache: {filename}"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Lỗi khi xóa cache: {str(e)}")
|
||||
|
||||
|
||||
# ============ DASHBOARD & VISUALIZATION API ============
|
||||
|
||||
@app.get("/api/dashboard/accuracy-trends")
|
||||
@@ -1956,9 +2302,9 @@ def run_batch_prediction(job: dict, config: PredictionConfig):
|
||||
model_manager = get_model_manager()
|
||||
model, label_encoder, model_metadata = model_manager.load_model(config.model_filename)
|
||||
|
||||
# Check if it's a PyTorch model (CNN, Swin-UNet, etc.)
|
||||
# Check if it's a PyTorch model (CNN, Swin-UNet, MobileNet, etc.)
|
||||
is_pytorch_model = hasattr(model, '__class__') and any(
|
||||
name in model.__class__.__name__ for name in ['CNN', 'SwinUNet']
|
||||
name in model.__class__.__name__ for name in ['CNN', 'SwinUNet', 'MobileNet']
|
||||
)
|
||||
|
||||
job["progress"] = 20
|
||||
@@ -2101,59 +2447,54 @@ def run_batch_prediction(job: dict, config: PredictionConfig):
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Patch
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 10), dpi=150)
|
||||
fig, ax = plt.subplots(figsize=(14, 10), dpi=150)
|
||||
im = ax.imshow(predictions_2d, cmap='tab20', interpolation='nearest')
|
||||
ax.set_title(f"{job['name']} - Batch {job['job_id']}", fontsize=14, fontweight='bold')
|
||||
ax.set_xlabel('X (pixels)', fontsize=10)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=10)
|
||||
ax.set_title(f"{job['name']} - Batch {job['job_id']}", fontsize=16, fontweight='bold', pad=20)
|
||||
ax.set_xlabel('X (pixels)', fontsize=11)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=11)
|
||||
|
||||
cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||
cbar.set_label('Class', rotation=270, labelpad=15)
|
||||
try:
|
||||
# Build mapping from numeric class value -> label (reuse logic from above)
|
||||
class_map = None
|
||||
if label_encoder is not None:
|
||||
try:
|
||||
le_classes = list(label_encoder.classes_)
|
||||
if all(isinstance(x, str) for x in le_classes):
|
||||
class_map = {i: name for i, name in enumerate(le_classes)}
|
||||
else:
|
||||
class_map = {int(v): str(v) for v in le_classes}
|
||||
except Exception:
|
||||
class_map = None
|
||||
# Build mapping - ALWAYS use DEFAULT_LABEL_NAMES
|
||||
class_map = DEFAULT_LABEL_NAMES.copy()
|
||||
|
||||
if class_map is None and isinstance(model_metadata, dict):
|
||||
cn = model_metadata.get('class_names')
|
||||
if isinstance(cn, list):
|
||||
class_map = {i: str(name) for i, name in enumerate(cn)}
|
||||
# Get unique classes in prediction to show only relevant legend items
|
||||
unique_pred_classes = np.unique(predictions_2d)
|
||||
unique_pred_classes = unique_pred_classes[~np.isnan(unique_pred_classes)]
|
||||
|
||||
if class_map is None and isinstance(model_metadata, dict):
|
||||
lm = model_metadata.get('label_mapping') or model_metadata.get('labels')
|
||||
if isinstance(lm, dict):
|
||||
inv = {}
|
||||
for k, v in lm.items():
|
||||
try:
|
||||
key_int = int(v)
|
||||
except Exception:
|
||||
continue
|
||||
inv[key_int] = str(k)
|
||||
if inv:
|
||||
class_map = inv
|
||||
# Create custom legend with color patches
|
||||
legend_elements = []
|
||||
cmap = plt.cm.get_cmap('tab20')
|
||||
|
||||
for cls_val in sorted(unique_pred_classes):
|
||||
try:
|
||||
cls_int = int(cls_val)
|
||||
# Get color from colormap (normalize to 0-1 range)
|
||||
color = cmap(cls_int / 20.0) # tab20 has 20 colors
|
||||
# Get label name
|
||||
label_name = class_map.get(cls_int, f"Class {cls_int}")
|
||||
# Create patch for legend
|
||||
legend_elements.append(
|
||||
Patch(facecolor=color, edgecolor='black', linewidth=0.5,
|
||||
label=f"{cls_int}: {label_name}")
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add legend outside plot area
|
||||
if legend_elements:
|
||||
legend = ax.legend(
|
||||
handles=legend_elements,
|
||||
loc='center left',
|
||||
bbox_to_anchor=(1.02, 0.5),
|
||||
fontsize=10,
|
||||
title='Land Classes',
|
||||
title_fontsize=11,
|
||||
framealpha=0.9,
|
||||
edgecolor='black'
|
||||
)
|
||||
legend.get_title().set_fontweight('bold')
|
||||
|
||||
if class_map:
|
||||
vals = np.array(sorted(class_map.keys()))
|
||||
cbar.set_ticks(vals)
|
||||
cbar.set_ticklabels([class_map[int(v)] for v in vals])
|
||||
else:
|
||||
if np.issubdtype(predictions_2d.dtype, np.number):
|
||||
minv = int(np.nanmin(predictions_2d))
|
||||
maxv = int(np.nanmax(predictions_2d))
|
||||
ticks = np.arange(minv, maxv + 1)
|
||||
cbar.set_ticks(ticks)
|
||||
cbar.set_ticklabels([str(t) for t in ticks])
|
||||
except Exception:
|
||||
pass
|
||||
ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5)
|
||||
|
||||
plt.tight_layout()
|
||||
@@ -3095,7 +3436,7 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
||||
print(f"[PREDICT+NDVI] Features range: [{features_clean.min():.3f}, {features_clean.max():.3f}]")
|
||||
|
||||
# Check if model is PyTorch/deep learning model and use GPU if available
|
||||
is_pytorch_model = hasattr(model, '__class__') and ('CNN' in model.__class__.__name__ or 'Swin' in model.__class__.__name__ or 'UNet' in model.__class__.__name__)
|
||||
is_pytorch_model = hasattr(model, '__class__') and ('CNN' in model.__class__.__name__ or 'Swin' in model.__class__.__name__ or 'UNet' in model.__class__.__name__ or 'MobileNet' in model.__class__.__name__)
|
||||
|
||||
if is_pytorch_model and config.use_gpu:
|
||||
try:
|
||||
@@ -3152,6 +3493,16 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
||||
prediction_raster[valid_mask] = predictions
|
||||
prediction_raster = prediction_raster.reshape(height, width)
|
||||
|
||||
# Smooth classification map to make output cleaner (reduce speckle)
|
||||
try:
|
||||
from scipy import ndimage
|
||||
smoothed = ndimage.median_filter(prediction_raster, size=3)
|
||||
smoothed[prediction_raster < 0] = -1 # keep nodata
|
||||
prediction_raster = smoothed
|
||||
print("[PREDICT+NDVI][SMOOTH] Applied 3x3 median filter to classification")
|
||||
except Exception as smooth_err:
|
||||
print(f"[PREDICT+NDVI][SMOOTH WARNING] {smooth_err}")
|
||||
|
||||
# --- CHANGE DETECTION ---
|
||||
change_summary = None
|
||||
change_map = None
|
||||
@@ -3282,35 +3633,51 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Patch
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 10), dpi=150)
|
||||
fig, ax = plt.subplots(figsize=(14, 10), dpi=150)
|
||||
im = ax.imshow(prediction_raster, cmap='tab20', interpolation='nearest')
|
||||
ax.set_title(f'Land Classification - {timestamp}', fontsize=14, fontweight='bold')
|
||||
ax.set_xlabel('X (pixels)', fontsize=10)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=10)
|
||||
ax.set_title(f'Land Classification - {timestamp}', fontsize=16, fontweight='bold', pad=20)
|
||||
ax.set_xlabel('X (pixels)', fontsize=11)
|
||||
ax.set_ylabel('Y (pixels)', fontsize=11)
|
||||
|
||||
# Try to get class labels
|
||||
class_map = None
|
||||
if label_encoder is not None:
|
||||
# Try to get class labels - ALWAYS use DEFAULT_LABEL_NAMES
|
||||
class_map = DEFAULT_LABEL_NAMES.copy()
|
||||
|
||||
# Get unique classes in prediction
|
||||
unique_pred_classes = np.unique(prediction_raster)
|
||||
unique_pred_classes = unique_pred_classes[~np.isnan(unique_pred_classes)]
|
||||
unique_pred_classes = unique_pred_classes[unique_pred_classes >= 0] # Exclude -1
|
||||
|
||||
# Create custom legend with color patches
|
||||
legend_elements = []
|
||||
cmap = plt.cm.get_cmap('tab20')
|
||||
|
||||
for cls_val in sorted(unique_pred_classes):
|
||||
try:
|
||||
le_classes = list(label_encoder.classes_)
|
||||
if all(isinstance(x, str) for x in le_classes):
|
||||
class_map = {i: name for i, name in enumerate(le_classes)}
|
||||
else:
|
||||
class_map = {int(v): str(v) for v in le_classes}
|
||||
cls_int = int(cls_val)
|
||||
color = cmap(cls_int / 20.0)
|
||||
label_name = class_map.get(cls_int, f"Class {cls_int}")
|
||||
legend_elements.append(
|
||||
Patch(facecolor=color, edgecolor='black', linewidth=0.5,
|
||||
label=f"{cls_int}: {label_name}")
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||
cbar.set_label('Class', rotation=270, labelpad=15)
|
||||
|
||||
if class_map:
|
||||
try:
|
||||
vals = np.array(sorted(class_map.keys()))
|
||||
cbar.set_ticks(vals)
|
||||
cbar.set_ticklabels([class_map[int(v)] for v in vals])
|
||||
except:
|
||||
pass
|
||||
# Add legend outside plot area
|
||||
if legend_elements:
|
||||
legend = ax.legend(
|
||||
handles=legend_elements,
|
||||
loc='center left',
|
||||
bbox_to_anchor=(1.02, 0.5),
|
||||
fontsize=10,
|
||||
title='Land Classes',
|
||||
title_fontsize=11,
|
||||
framealpha=0.9,
|
||||
edgecolor='black'
|
||||
)
|
||||
legend.get_title().set_fontweight('bold')
|
||||
|
||||
ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5)
|
||||
|
||||
@@ -3337,6 +3704,28 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
||||
int(cls): int(count) for cls, count in zip(unique_classes, counts)
|
||||
}
|
||||
|
||||
# Auto-save prediction cache (Sentinel-2 data + metadata) for this NDVI workflow
|
||||
try:
|
||||
cache_config = {
|
||||
"min_lon": config.min_lon,
|
||||
"min_lat": config.min_lat,
|
||||
"max_lon": config.max_lon,
|
||||
"max_lat": config.max_lat,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
"max_scenes": config.max_scenes,
|
||||
"cloud_cover": config.cloud_cover,
|
||||
"resolution": config.resolution,
|
||||
"model_filename": config.model_filename
|
||||
}
|
||||
|
||||
# Always save data for predict_with_ndvi (so lần sau không phải tải lại)
|
||||
data_to_save = data
|
||||
cache_result = save_prediction_cache_sync(cache_config, data_to_save)
|
||||
print(f"[PREDICT+NDVI][CACHE] Saved cache: {cache_result.get('filename')} (with data: {data_to_save is not None})")
|
||||
except Exception as cache_exc:
|
||||
print(f"[PREDICT+NDVI][CACHE ERROR] {cache_exc}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Prediction with NDVI completed",
|
||||
@@ -3526,7 +3915,7 @@ async def ndvi_predict_timeseries(config: NDVIPredictionConfig):
|
||||
print(f" Expected features: {n_features_expected}")
|
||||
|
||||
# Check if PyTorch model for GPU
|
||||
is_pytorch_model = hasattr(model, 'forward') or str(type(model).__name__) in ['SwinUnet', 'CNN']
|
||||
is_pytorch_model = hasattr(model, 'forward') or str(type(model).__name__) in ['SwinUnet', 'CNN', 'MobileNetLRASPPClassifier']
|
||||
|
||||
if is_pytorch_model and config.use_gpu:
|
||||
import torch
|
||||
|
||||
@@ -353,6 +353,20 @@
|
||||
<p>🔄 Có thể chỉnh sửa sau khi vẽ</p>
|
||||
</div>
|
||||
<div id="predictMap"></div>
|
||||
|
||||
<!-- Prediction Cache Section -->
|
||||
<div class="section" style="margin-top: 20px;">
|
||||
<h3>💾 Cache Predictions (Tự động lưu)</h3>
|
||||
<p style="color: #666; font-size: 0.9em; margin-bottom: 10px;">Cache được tự động tạo sau mỗi lần predict thành công</p>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
|
||||
<select id="predCacheSelect" style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 5px;">
|
||||
<option value="">-- Chọn cache để phục hồi cấu hình --</option>
|
||||
</select>
|
||||
<button onclick="loadPredictionCache()" class="btn btn-success">📂 Phục hồi</button>
|
||||
<button onclick="refreshPredCache()" class="btn btn-secondary" title="Làm mới danh sách">🔄</button>
|
||||
</div>
|
||||
<div id="predCacheList" style="max-height: 200px; overflow-y: auto; background: white; border-radius: 5px; padding: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection -->
|
||||
@@ -1174,6 +1188,162 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ============ PREDICTION CACHE FUNCTIONS (Auto-save) ============
|
||||
|
||||
// Load prediction cache list
|
||||
async function loadPredCacheList() {
|
||||
try {
|
||||
const response = await fetch('/api/prediction/cache/list');
|
||||
const data = await response.json();
|
||||
|
||||
const select = document.getElementById('predCacheSelect');
|
||||
const listDiv = document.getElementById('predCacheList');
|
||||
|
||||
select.innerHTML = '<option value="">-- Chọn cache để phục hồi cấu hình --</option>';
|
||||
listDiv.innerHTML = '';
|
||||
|
||||
if (data.caches && data.caches.length > 0) {
|
||||
data.caches.forEach(cache => {
|
||||
// Add to select dropdown
|
||||
const option = document.createElement('option');
|
||||
option.value = cache.filename;
|
||||
option.textContent = cache.display_name;
|
||||
option.dataset.config = JSON.stringify(cache.config);
|
||||
select.appendChild(option);
|
||||
|
||||
// Add to list
|
||||
const item = document.createElement('div');
|
||||
item.style.cssText = 'background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid #4facfe;';
|
||||
const cfg = cache.config;
|
||||
item.innerHTML = `
|
||||
<div style="flex: 1;">
|
||||
<strong style="color: #667eea;">📍 [${cfg.bbox[0].toFixed(2)}, ${cfg.bbox[1].toFixed(2)} → ${cfg.bbox[2].toFixed(2)}, ${cfg.bbox[3].toFixed(2)}]</strong><br>
|
||||
<small style="color: #666;">📅 ${cfg.start_date} → ${cfg.end_date}</small><br>
|
||||
<small style="color: #999;">🔧 Resolution: ${cfg.resolution}m | Scenes: ${cfg.max_scenes} | Cloud: ${cfg.cloud_cover}%</small><br>
|
||||
<small style="color: #999;">⏰ ${cache.created || 'N/A'}</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<button onclick="applyPredCache('${cache.filename}')" class="btn btn-success" style="padding: 5px 10px; font-size: 0.9em;">📂</button>
|
||||
<button onclick="deletePredCache('${cache.filename}')" class="btn btn-secondary" style="padding: 5px 10px; font-size: 0.9em;">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
listDiv.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
listDiv.innerHTML = '<p style="color: #999; text-align: center; padding: 20px;">Chưa có cache nào. Cache sẽ tự động được tạo sau khi predict thành công.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading prediction cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh prediction cache list
|
||||
function refreshPredCache() {
|
||||
loadPredCacheList();
|
||||
}
|
||||
|
||||
// Apply prediction cache from filename
|
||||
function applyPredCache(filename) {
|
||||
const select = document.getElementById('predCacheSelect');
|
||||
|
||||
// Find option by filename
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === filename) {
|
||||
select.selectedIndex = i;
|
||||
loadPredictionCache();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load prediction cache and restore all parameters
|
||||
function loadPredictionCache() {
|
||||
const select = document.getElementById('predCacheSelect');
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
|
||||
if (!selectedOption || !selectedOption.value || !selectedOption.dataset.config) {
|
||||
alert('⚠️ Vui lòng chọn cache từ danh sách!');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(selectedOption.dataset.config);
|
||||
|
||||
// Update selectedBbox
|
||||
selectedBbox = {
|
||||
min_lon: config.min_lon,
|
||||
min_lat: config.min_lat,
|
||||
max_lon: config.max_lon,
|
||||
max_lat: config.max_lat
|
||||
};
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('prediction_bbox', JSON.stringify(selectedBbox));
|
||||
|
||||
// Update ALL form fields
|
||||
if (config.start_date) document.getElementById('predStartDate').value = config.start_date;
|
||||
if (config.end_date) document.getElementById('predEndDate').value = config.end_date;
|
||||
if (config.max_scenes) document.getElementById('predMaxScenes').value = config.max_scenes;
|
||||
if (config.cloud_cover) document.getElementById('predCloudCover').value = config.cloud_cover;
|
||||
if (config.resolution) document.getElementById('predResolution').value = config.resolution;
|
||||
|
||||
// Select model if available
|
||||
if (config.model_filename) {
|
||||
const modelSelect = document.getElementById('modelSelect');
|
||||
for (let i = 0; i < modelSelect.options.length; i++) {
|
||||
if (modelSelect.options[i].value === config.model_filename) {
|
||||
modelSelect.selectedIndex = i;
|
||||
updateModelInfo();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw rectangle on map
|
||||
const bbox = config.bbox;
|
||||
const bounds = [[bbox[1], bbox[0]], [bbox[3], bbox[2]]];
|
||||
|
||||
// Remove previous rectangle
|
||||
drawnItems.clearLayers();
|
||||
|
||||
// Add new rectangle
|
||||
const rectangle = L.rectangle(bounds, {
|
||||
color: '#4facfe',
|
||||
weight: 3,
|
||||
fillOpacity: 0.2
|
||||
});
|
||||
drawnItems.addLayer(rectangle);
|
||||
|
||||
// Fit map to bounds
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
|
||||
alert(`✅ Đã phục hồi cấu hình từ cache!\n\n📍 Bbox: [${bbox.map(n => n.toFixed(4)).join(', ')}]\n📅 Time: ${config.start_date} → ${config.end_date}\n🔧 Resolution: ${config.resolution}m, Scenes: ${config.max_scenes}, Cloud: ${config.cloud_cover}%`);
|
||||
}
|
||||
|
||||
// Delete prediction cache
|
||||
async function deletePredCache(filename) {
|
||||
if (!confirm(`Bạn có chắc muốn xóa cache này?\n\nFile: ${filename}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/prediction/cache/delete/${filename}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}`);
|
||||
loadPredCacheList(); // Reload list
|
||||
} else {
|
||||
alert(`❌ Lỗi: ${result.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting cache:', error);
|
||||
alert(`❌ Lỗi khi xóa cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cache preset - auto fill bbox and other params
|
||||
function applyCachePreset() {
|
||||
const selectValue = document.getElementById('cacheSelect').value;
|
||||
@@ -1698,6 +1868,7 @@
|
||||
loadModels();
|
||||
loadPredictionsList();
|
||||
loadCacheList();
|
||||
loadPredCacheList(); // Load prediction cache list
|
||||
loadPredProvinces(); // Load provinces list
|
||||
loadNDVIProvinces(); // Load NDVI provinces list
|
||||
loadNDVIModels(); // Load models for NDVI
|
||||
|
||||
+209
-7
@@ -254,6 +254,96 @@ class SwinUNetClassifier(nn.Module):
|
||||
return np.mean(predictions == y)
|
||||
|
||||
|
||||
# MobileNetV3 + LR-ASPP Classifier
|
||||
class MobileNetLRASPPClassifier(nn.Module):
|
||||
"""
|
||||
MobileNetV3 backbone with LR-ASPP (Lite Reduced Atrous Spatial Pyramid Pooling) for semantic segmentation
|
||||
Lightweight architecture optimized for efficiency and speed
|
||||
"""
|
||||
def __init__(self, n_features, n_classes):
|
||||
super(MobileNetLRASPPClassifier, self).__init__()
|
||||
self.n_features = n_features
|
||||
self.n_classes = n_classes
|
||||
|
||||
# Feature extraction layers (MobileNetV3-inspired)
|
||||
self.feature_extractor = nn.Sequential(
|
||||
nn.Linear(n_features, 128),
|
||||
nn.BatchNorm1d(128),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Dropout(0.2),
|
||||
|
||||
nn.Linear(128, 256),
|
||||
nn.BatchNorm1d(256),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Dropout(0.3),
|
||||
|
||||
nn.Linear(256, 512),
|
||||
nn.BatchNorm1d(512),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Dropout(0.3),
|
||||
)
|
||||
|
||||
# LR-ASPP head (simplified for feature vectors)
|
||||
# Branch 1: Global average pooling
|
||||
self.global_pool = nn.AdaptiveAvgPool1d(1)
|
||||
self.global_conv = nn.Sequential(
|
||||
nn.Linear(512, 128),
|
||||
nn.ReLU(inplace=True)
|
||||
)
|
||||
|
||||
# Branch 2: 1x1 convolution equivalent
|
||||
self.branch_conv = nn.Sequential(
|
||||
nn.Linear(512, 128),
|
||||
nn.BatchNorm1d(128),
|
||||
nn.ReLU(inplace=True)
|
||||
)
|
||||
|
||||
# Fusion and classification
|
||||
self.classifier = nn.Sequential(
|
||||
nn.Linear(256, 128), # 128 from global + 128 from branch
|
||||
nn.BatchNorm1d(128),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Dropout(0.4),
|
||||
nn.Linear(128, n_classes)
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
# x shape: (batch, n_features)
|
||||
features = self.feature_extractor(x)
|
||||
|
||||
# LR-ASPP head
|
||||
# Branch 1: Global pooling
|
||||
global_feat = self.global_pool(features.unsqueeze(-1)).squeeze(-1)
|
||||
global_feat = self.global_conv(global_feat)
|
||||
|
||||
# Branch 2: Direct features
|
||||
branch_feat = self.branch_conv(features)
|
||||
|
||||
# Concatenate branches
|
||||
fused = torch.cat([global_feat, branch_feat], dim=1)
|
||||
|
||||
# Classification
|
||||
output = self.classifier(fused)
|
||||
return output
|
||||
|
||||
def predict(self, X):
|
||||
"""Scikit-learn style predict"""
|
||||
self.eval()
|
||||
with torch.no_grad():
|
||||
if isinstance(X, np.ndarray):
|
||||
X = torch.FloatTensor(X)
|
||||
outputs = self(X)
|
||||
_, predicted = torch.max(outputs, 1)
|
||||
return predicted.cpu().numpy()
|
||||
|
||||
def score(self, X, y):
|
||||
"""Scikit-learn style score"""
|
||||
predictions = self.predict(X)
|
||||
if isinstance(y, torch.Tensor):
|
||||
y = y.cpu().numpy()
|
||||
return np.mean(predictions == y)
|
||||
|
||||
|
||||
# Microsoft Planetary Computer imports
|
||||
import planetary_computer
|
||||
from pystac_client import Client
|
||||
@@ -1055,16 +1145,128 @@ def train_model(
|
||||
model = model.cpu()
|
||||
model.device_used = str(device)
|
||||
|
||||
elif model_type == 'mobilenet-lraspp':
|
||||
if not PYTORCH_AVAILABLE:
|
||||
raise ImportError("PyTorch is required for MobileNetV3 + LR-ASPP. Install: pip install torch torchvision")
|
||||
|
||||
n_features = X_train.shape[1]
|
||||
n_classes = len(np.unique(y_train))
|
||||
|
||||
device = torch.device('cuda' if torch.cuda.is_available() and use_gpu else 'cpu')
|
||||
update_status(f"Building MobileNetV3 + LR-ASPP model on {device}...", 75)
|
||||
|
||||
model = MobileNetLRASPPClassifier(n_features, n_classes).to(device)
|
||||
|
||||
# Convert to PyTorch tensors
|
||||
X_train_tensor = torch.FloatTensor(X_train)
|
||||
y_train_tensor = torch.LongTensor(y_train)
|
||||
X_test_tensor = torch.FloatTensor(X_test)
|
||||
y_test_tensor = torch.LongTensor(y_test)
|
||||
|
||||
# Create data loaders
|
||||
train_dataset = TensorDataset(X_train_tensor, y_train_tensor)
|
||||
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) # Larger batch for efficiency
|
||||
|
||||
# Calculate class weights for imbalanced data
|
||||
class_counts = np.bincount(y_train)
|
||||
class_weights = 1.0 / (class_counts + 1e-6)
|
||||
class_weights = class_weights / class_weights.sum() * len(class_counts)
|
||||
class_weights_tensor = torch.FloatTensor(class_weights).to(device)
|
||||
|
||||
print(f"[MOBILENET] Class distribution: {class_counts}")
|
||||
print(f"[MOBILENET] Class weights: {class_weights}")
|
||||
|
||||
# Loss with class weights
|
||||
criterion = nn.CrossEntropyLoss(weight=class_weights_tensor)
|
||||
optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=0.0001)
|
||||
|
||||
# LR scheduler
|
||||
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5)
|
||||
|
||||
# Early stopping
|
||||
best_val_loss = float('inf')
|
||||
patience = 10
|
||||
patience_counter = 0
|
||||
|
||||
# Train MobileNetV3 + LR-ASPP
|
||||
update_status("Training MobileNetV3 + LR-ASPP model with PyTorch...", 80)
|
||||
epochs = min(60, n_estimators // 2)
|
||||
|
||||
# Validation dataset
|
||||
val_dataset = TensorDataset(X_test_tensor, y_test_tensor)
|
||||
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
|
||||
|
||||
model.train()
|
||||
for epoch in range(epochs):
|
||||
# Training phase
|
||||
model.train()
|
||||
epoch_loss = 0.0
|
||||
for batch_X, batch_y in train_loader:
|
||||
batch_X, batch_y = batch_X.to(device), batch_y.to(device)
|
||||
|
||||
optimizer.zero_grad()
|
||||
outputs = model(batch_X)
|
||||
loss = criterion(outputs, batch_y)
|
||||
loss.backward()
|
||||
|
||||
# Gradient clipping
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
||||
|
||||
optimizer.step()
|
||||
epoch_loss += loss.item()
|
||||
|
||||
# Validation phase
|
||||
model.eval()
|
||||
val_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
with torch.no_grad():
|
||||
for batch_X, batch_y in val_loader:
|
||||
batch_X, batch_y = batch_X.to(device), batch_y.to(device)
|
||||
outputs = model(batch_X)
|
||||
loss = criterion(outputs, batch_y)
|
||||
val_loss += loss.item()
|
||||
|
||||
_, predicted = torch.max(outputs, 1)
|
||||
total += batch_y.size(0)
|
||||
correct += (predicted == batch_y).sum().item()
|
||||
|
||||
avg_train_loss = epoch_loss / len(train_loader)
|
||||
avg_val_loss = val_loss / len(val_loader)
|
||||
val_acc = 100 * correct / total
|
||||
|
||||
# Update learning rate
|
||||
scheduler.step(avg_val_loss)
|
||||
lr = optimizer.param_groups[0]['lr']
|
||||
|
||||
if (epoch + 1) % 5 == 0:
|
||||
update_status(f"MobileNet Epoch {epoch+1}/{epochs}, Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%, LR: {lr:.6f}", 80 + (epoch / epochs) * 10)
|
||||
print(f"[MOBILENET] Epoch {epoch+1}/{epochs} - Train Loss: {avg_train_loss:.4f}, Val Loss: {avg_val_loss:.4f}, Val Acc: {val_acc:.2f}%")
|
||||
|
||||
# Early stopping
|
||||
if avg_val_loss < best_val_loss:
|
||||
best_val_loss = avg_val_loss
|
||||
patience_counter = 0
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= patience:
|
||||
print(f"[MOBILENET] Early stopping at epoch {epoch+1} (best val loss: {best_val_loss:.4f})")
|
||||
update_status(f"MobileNet early stopped at epoch {epoch+1}", 90)
|
||||
break
|
||||
|
||||
model = model.cpu()
|
||||
model.device_used = str(device)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown model type: {model_type}. Choose: xgboost, random_forest, decision_tree, svm, cnn, swin-unet")
|
||||
raise ValueError(f"Unknown model type: {model_type}. Choose: xgboost, random_forest, decision_tree, svm, cnn, swin-unet, mobilenet-lraspp")
|
||||
|
||||
# Fit non-neural-network models
|
||||
if model_type not in ['cnn', 'swin-unet']:
|
||||
if model_type not in ['cnn', 'swin-unet', 'mobilenet-lraspp']:
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# Evaluate
|
||||
update_status("Evaluating model...", 90)
|
||||
if model_type in ['cnn', 'swin-unet']:
|
||||
if model_type in ['cnn', 'swin-unet', 'mobilenet-lraspp']:
|
||||
# PyTorch models evaluation
|
||||
train_score = model.score(X_train, y_train)
|
||||
test_score = model.score(X_test, y_test)
|
||||
@@ -1111,10 +1313,10 @@ def train_model(
|
||||
"test_accuracy": float(test_score),
|
||||
"model_type": model_type,
|
||||
"device": device if model_type == 'xgboost' else 'cpu',
|
||||
"n_estimators": n_estimators if model_type in ['xgboost', 'random_forest', 'cnn', 'swin-unet'] else None,
|
||||
"max_depth": max_depth if model_type not in ['cnn', 'swin-unet'] else None,
|
||||
"learning_rate": learning_rate if model_type in ['xgboost', 'swin-unet'] else None,
|
||||
"epochs": min(50, n_estimators // 2) if model_type == 'cnn' else (min(60, n_estimators // 2) if model_type == 'swin-unet' else None),
|
||||
"n_estimators": n_estimators if model_type in ['xgboost', 'random_forest', 'cnn', 'swin-unet', 'mobilenet-lraspp'] else None,
|
||||
"max_depth": max_depth if model_type not in ['cnn', 'swin-unet', 'mobilenet-lraspp'] else None,
|
||||
"learning_rate": learning_rate if model_type in ['xgboost', 'swin-unet', 'mobilenet-lraspp'] else None,
|
||||
"epochs": min(50, n_estimators // 2) if model_type == 'cnn' else (min(60, n_estimators // 2) if model_type in ['swin-unet', 'mobilenet-lraspp'] else None),
|
||||
"n_features": X_train.shape[1],
|
||||
"n_classes": len(np.unique(y_train)),
|
||||
"class_names": class_names,
|
||||
|
||||
+14
-3
@@ -507,6 +507,7 @@
|
||||
<option value="svm">🎯 SVM (Chính xác, Chậm với dữ liệu lớn)</option>
|
||||
<option value="cnn">🧠 CNN - Deep Learning (PyTorch, Tốt với ảnh vệ tinh, Hỗ trợ GPU)</option>
|
||||
<option value="swin-unet">🌟 Swin-UNet (Transformer + U-Net, Độ chính xác cao, Hỗ trợ GPU)</option>
|
||||
<option value="mobilenet-lraspp">📱 MobileNetV3 + LR-ASPP (Nhẹ, Nhanh, Semantic Segmentation, Hỗ trợ GPU)</option>
|
||||
</select>
|
||||
<div style="margin-top: 8px; padding: 10px; background: #e7f3ff; border-radius: 5px; font-size: 12px;">
|
||||
<span id="modelTypeDesc" style="color: #1976d2;">
|
||||
@@ -1399,7 +1400,8 @@
|
||||
'decision_tree': '✓ Decision Tree: Đơn giản nhất, nhanh nhất, dễ hiểu, phù hợp để test nhanh',
|
||||
'svm': '✓ SVM: Chính xác cao với dữ liệu nhỏ, chậm với dữ liệu lớn',
|
||||
'cnn': '✓ CNN PyTorch: Mạnh nhất với ảnh vệ tinh, tự học features, tương thích GPU tốt, cần pip install torch',
|
||||
'swin-unet': '✓ Swin-UNet: Kết hợp Transformer + U-Net, độ chính xác cao nhất, phù hợp dataset lớn, tốc độ training trung bình'
|
||||
'swin-unet': '✓ Swin-UNet: Kết hợp Transformer + U-Net, độ chính xác cao nhất, phù hợp dataset lớn, tốc độ training trung bình',
|
||||
'mobilenet-lraspp': '✓ MobileNetV3 + LR-ASPP: Kiến trúc nhẹ cho semantic segmentation, nhanh, hiệu quả, phù hợp edge devices, hỗ trợ GPU'
|
||||
};
|
||||
|
||||
desc.textContent = descriptions[modelType];
|
||||
@@ -1437,10 +1439,19 @@
|
||||
document.querySelector('#learningRateGroup label').textContent = 'Learning Rate (mặc định: 0.0005):';
|
||||
document.getElementById('learningRate').value = 0.0005;
|
||||
useGpuGroup.style.display = ''; // Show GPU option for Swin-UNet
|
||||
} else if (modelType === 'mobilenet-lraspp') {
|
||||
// MobileNetV3 + LR-ASPP uses n_estimators as epochs and supports GPU
|
||||
nEstimatorsGroup.style.display = '';
|
||||
document.querySelector('#nEstimatorsGroup label').textContent = 'Epochs (số lần training):';
|
||||
document.getElementById('nEstimators').value = 60;
|
||||
learningRateGroup.style.display = ''; // Show learning rate for MobileNet
|
||||
document.querySelector('#learningRateGroup label').textContent = 'Learning Rate (mặc định: 0.001):';
|
||||
document.getElementById('learningRate').value = 0.001;
|
||||
useGpuGroup.style.display = ''; // Show GPU option for MobileNet
|
||||
}
|
||||
|
||||
// Reset n_estimators label for non-CNN/non-Swin-UNet
|
||||
if (modelType !== 'cnn' && modelType !== 'swin-unet' && modelType !== 'decision_tree' && modelType !== 'svm') {
|
||||
// Reset n_estimators label for non-CNN/non-Swin-UNet/non-MobileNet
|
||||
if (modelType !== 'cnn' && modelType !== 'swin-unet' && modelType !== 'mobilenet-lraspp' && modelType !== 'decision_tree' && modelType !== 'svm') {
|
||||
document.querySelector('#nEstimatorsGroup label').textContent = 'N Estimators:';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user