hoàn thành chức năng change detection
This commit is contained in:
+773
-9
@@ -3,7 +3,7 @@ API Server for Land Classification Model Training
|
|||||||
Cho phép chọn dữ liệu và cấu hình training qua giao diện web
|
Cho phép chọn dữ liệu và cấu hình training qua giao diện web
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI, BackgroundTasks, HTTPException
|
from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
@@ -15,10 +15,27 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import xarray as xr
|
||||||
|
import rasterio
|
||||||
|
from rasterio.transform import from_bounds
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import traceback
|
||||||
|
|
||||||
# Import report generator
|
# Import report generator
|
||||||
from report_generator import generate_training_report, generate_prediction_report
|
from report_generator import generate_training_report, generate_prediction_report
|
||||||
|
|
||||||
|
# Import planetary computer libraries (conditional)
|
||||||
|
try:
|
||||||
|
from pystac_client import Client
|
||||||
|
import planetary_computer
|
||||||
|
import odc.stac
|
||||||
|
except ImportError:
|
||||||
|
Client = None
|
||||||
|
planetary_computer = None
|
||||||
|
odc = None
|
||||||
|
|
||||||
app = FastAPI(title="Land Classification Training API", version="1.0.0")
|
app = FastAPI(title="Land Classification Training API", version="1.0.0")
|
||||||
|
|
||||||
# Enable CORS
|
# Enable CORS
|
||||||
@@ -131,6 +148,28 @@ class NDVIConfig(BaseModel):
|
|||||||
resolution: int = 20
|
resolution: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeDetectionWorkflowRequest(BaseModel):
|
||||||
|
"""Request for change detection workflow"""
|
||||||
|
prediction_result: dict
|
||||||
|
bbox: List[float]
|
||||||
|
|
||||||
|
|
||||||
|
class ComparePeriodsPredictionConfig(BaseModel):
|
||||||
|
"""Compare predictions between two time periods"""
|
||||||
|
model_filename: str
|
||||||
|
min_lon: float
|
||||||
|
min_lat: float
|
||||||
|
max_lon: float
|
||||||
|
max_lat: float
|
||||||
|
current_period: dict # {start_date, end_date}
|
||||||
|
prediction_period: dict # {start_date, end_date}
|
||||||
|
max_scenes: int = 12
|
||||||
|
cloud_cover: int = 30
|
||||||
|
resolution: int = 20
|
||||||
|
export_ndvi: bool = True
|
||||||
|
export_classification: bool = True
|
||||||
|
|
||||||
|
|
||||||
class PredictionWithNDVIConfig(BaseModel):
|
class PredictionWithNDVIConfig(BaseModel):
|
||||||
"""Cấu hình predict kết hợp land classification và NDVI"""
|
"""Cấu hình predict kết hợp land classification và NDVI"""
|
||||||
model_filename: str
|
model_filename: str
|
||||||
@@ -147,6 +186,16 @@ class PredictionWithNDVIConfig(BaseModel):
|
|||||||
export_classification: bool = True # Export classification raster
|
export_classification: bool = True # Export classification raster
|
||||||
|
|
||||||
|
|
||||||
|
# Serve change detection interface page (moved here after app is defined)
|
||||||
|
@app.get("/change-detection", response_class=HTMLResponse)
|
||||||
|
async def change_detection_page():
|
||||||
|
html_file = Path(__file__).parent / "change_detection_interface.html"
|
||||||
|
if html_file.exists():
|
||||||
|
return FileResponse(html_file)
|
||||||
|
else:
|
||||||
|
return HTMLResponse("<h2>Change Detection Interface not found.</h2>")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root():
|
async def root():
|
||||||
"""Serve main index page with tabs"""
|
"""Serve main index page with tabs"""
|
||||||
@@ -1846,6 +1895,683 @@ def run_batch_prediction(job: dict, config: PredictionConfig):
|
|||||||
print(f"[BATCH ERROR] Job {job['job_id']}: {traceback.format_exc()}")
|
print(f"[BATCH ERROR] Job {job['job_id']}: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ CHANGE DETECTION API ============
|
||||||
|
|
||||||
|
def rasterize_ground_truth(shapefile_path, out_shape, bbox, class_column="class"):
|
||||||
|
"""Rasterize ground truth shapefile to match prediction raster shape."""
|
||||||
|
try:
|
||||||
|
import geopandas as gpd
|
||||||
|
from rasterio import features as rio_features
|
||||||
|
|
||||||
|
gdf = gpd.read_file(shapefile_path)
|
||||||
|
minx, miny, maxx, maxy = bbox
|
||||||
|
|
||||||
|
# Crop to bbox
|
||||||
|
gdf = gdf.cx[minx:maxx, miny:maxy]
|
||||||
|
|
||||||
|
if class_column not in gdf.columns:
|
||||||
|
raise ValueError(f"Shapefile missing '{class_column}' column. Available: {list(gdf.columns)}")
|
||||||
|
|
||||||
|
# Create transform for rasterization
|
||||||
|
transform = from_bounds(minx, miny, maxx, maxy, out_shape[1], out_shape[0])
|
||||||
|
|
||||||
|
# Prepare geometries and values for rasterization
|
||||||
|
shapes = zip(gdf.geometry, gdf[class_column])
|
||||||
|
|
||||||
|
# Rasterize
|
||||||
|
gt_raster = rio_features.rasterize(
|
||||||
|
shapes,
|
||||||
|
out_shape=out_shape,
|
||||||
|
fill=-1,
|
||||||
|
transform=transform,
|
||||||
|
dtype="int16"
|
||||||
|
)
|
||||||
|
|
||||||
|
return gt_raster
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[RASTERIZE ERROR] {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/change-detection/predict")
|
||||||
|
async def change_detection_predict_workflow(
|
||||||
|
model_filename: str,
|
||||||
|
min_lon: float,
|
||||||
|
min_lat: float,
|
||||||
|
max_lon: float,
|
||||||
|
max_lat: float,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
max_scenes: int = 12,
|
||||||
|
cloud_cover: int = 30,
|
||||||
|
resolution: int = 20
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Complete workflow: Predict + Compare with Ground Truth
|
||||||
|
1. Load Sentinel-2 data for bbox and date range
|
||||||
|
2. Run prediction using trained model
|
||||||
|
3. Rasterize ground truth from training shapefile
|
||||||
|
4. Compare and generate change detection results
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# --- STEP 1: LOAD MODEL ---
|
||||||
|
model_path = Path("model_train") / model_filename
|
||||||
|
if not model_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model not found: {model_filename}")
|
||||||
|
|
||||||
|
model_data = joblib.load(model_path)
|
||||||
|
|
||||||
|
if isinstance(model_data, dict):
|
||||||
|
model = model_data.get('model')
|
||||||
|
label_encoder = model_data.get('label_encoder')
|
||||||
|
else:
|
||||||
|
model = model_data
|
||||||
|
label_encoder = None
|
||||||
|
|
||||||
|
print(f"[CHANGE DETECTION] Loaded model: {model_filename}")
|
||||||
|
|
||||||
|
# --- STEP 2: LOAD SENTINEL-2 DATA ---
|
||||||
|
bbox = [min_lon, min_lat, max_lon, max_lat]
|
||||||
|
time_range = f"{start_date}/{end_date}"
|
||||||
|
|
||||||
|
catalog = Client.open(
|
||||||
|
"https://planetarycomputer.microsoft.com/api/stac/v1",
|
||||||
|
modifier=planetary_computer.sign_inplace
|
||||||
|
)
|
||||||
|
|
||||||
|
search = catalog.search(
|
||||||
|
collections=["sentinel-2-l2a"],
|
||||||
|
bbox=bbox,
|
||||||
|
datetime=time_range,
|
||||||
|
query={"eo:cloud_cover": {"lt": cloud_cover}}
|
||||||
|
)
|
||||||
|
|
||||||
|
items = list(search.items())[:max_scenes]
|
||||||
|
print(f"[CHANGE DETECTION] Found {len(items)} Sentinel-2 scenes")
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="No Sentinel-2 data found for the given area and date range")
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
signed_items = [planetary_computer.sign(item) for item in items]
|
||||||
|
data = odc.stac.load(
|
||||||
|
signed_items,
|
||||||
|
bbox=bbox,
|
||||||
|
bands=["B02", "B03", "B04", "B08"],
|
||||||
|
resolution=resolution,
|
||||||
|
chunks={"x": 2048, "y": 2048}
|
||||||
|
).compute()
|
||||||
|
|
||||||
|
# --- STEP 3: CALCULATE NDVI ---
|
||||||
|
print("[CHANGE DETECTION] Calculating NDVI...")
|
||||||
|
nir = data["B08"].astype('float32')
|
||||||
|
red = data["B04"].astype('float32')
|
||||||
|
ndvi = (nir - red) / (nir + red + 1e-8)
|
||||||
|
|
||||||
|
# Handle clouds if SCL available
|
||||||
|
if "SCL" in data:
|
||||||
|
scl = data["SCL"]
|
||||||
|
cloud_mask = (scl == 3) | (scl == 8) | (scl == 9) | (scl == 10)
|
||||||
|
ndvi = ndvi.where(~cloud_mask)
|
||||||
|
|
||||||
|
# --- STEP 4: PREPARE FEATURES ---
|
||||||
|
ndvi_filled = ndvi.ffill(dim='time').bfill(dim='time')
|
||||||
|
ndvi_mean = np.nanmean(ndvi_filled.values, axis=0)
|
||||||
|
|
||||||
|
height, width = ndvi_mean.shape
|
||||||
|
n_pixels = height * width
|
||||||
|
|
||||||
|
# Prepare features
|
||||||
|
features = ndvi_mean.flatten().reshape(-1, 1)
|
||||||
|
valid_mask = ~np.isnan(features[:, 0])
|
||||||
|
features_clean = features[valid_mask]
|
||||||
|
|
||||||
|
# --- STEP 5: PREDICT ---
|
||||||
|
print("[CHANGE DETECTION] Running prediction...")
|
||||||
|
predictions = model.predict(features_clean)
|
||||||
|
|
||||||
|
# Decode labels if needed
|
||||||
|
if label_encoder is not None:
|
||||||
|
try:
|
||||||
|
predictions = label_encoder.inverse_transform(predictions)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reshape to raster
|
||||||
|
prediction_raster = np.full(n_pixels, -1, dtype=np.int16)
|
||||||
|
prediction_raster[valid_mask] = predictions.astype(np.int16)
|
||||||
|
prediction_raster = prediction_raster.reshape(height, width)
|
||||||
|
|
||||||
|
# --- STEP 6: COMPARE WITH GROUND TRUTH ---
|
||||||
|
print("[CHANGE DETECTION] Comparing with ground truth...")
|
||||||
|
gt_shapefile = "train/ST_training data_updated_1130points_new.shp"
|
||||||
|
gt_raster = rasterize_ground_truth(gt_shapefile, (height, width), bbox, class_column="class")
|
||||||
|
|
||||||
|
# Calculate changes
|
||||||
|
mask_valid = (gt_raster >= 0) & (prediction_raster >= 0)
|
||||||
|
changes = gt_raster[mask_valid] != prediction_raster[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
|
||||||
|
# Create change matrix
|
||||||
|
from collections import Counter
|
||||||
|
change_pairs = list(zip(gt_raster[mask_valid][changes], prediction_raster[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(gt)}->{int(pred)}": int(cnt) for (gt, pred), cnt in change_counter.items()}
|
||||||
|
|
||||||
|
# Create change map
|
||||||
|
change_map = np.full((height, width), -1, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
|
||||||
|
# --- STEP 7: SAVE RESULTS ---
|
||||||
|
output_dir = Path("predictions")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
change_file = output_dir / f"change_map_{timestamp}.tif"
|
||||||
|
|
||||||
|
transform = from_bounds(min_lon, min_lat, max_lon, max_lat, width, height)
|
||||||
|
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=height,
|
||||||
|
width=width,
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs='EPSG:4326',
|
||||||
|
transform=transform
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
|
||||||
|
print(f"[CHANGE DETECTION] Saved change map to {change_file}")
|
||||||
|
|
||||||
|
# --- RETURN RESULTS ---
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"n_scenes": len(items),
|
||||||
|
"ndvi_stats": {
|
||||||
|
"mean": float(np.nanmean(ndvi_mean)),
|
||||||
|
"min": float(np.nanmin(ndvi_mean)),
|
||||||
|
"max": float(np.nanmax(ndvi_mean)),
|
||||||
|
"std": float(np.nanstd(ndvi_mean))
|
||||||
|
},
|
||||||
|
"class_distribution": {
|
||||||
|
int(cls): int(count)
|
||||||
|
for cls, count in zip(*np.unique(predictions, return_counts=True))
|
||||||
|
},
|
||||||
|
"change_detection": {
|
||||||
|
"n_total_pixels": int(n_total),
|
||||||
|
"n_changed_pixels": int(n_changed),
|
||||||
|
"change_rate": float(n_changed) / n_total if n_total > 0 else 0.0,
|
||||||
|
"change_matrix": change_matrix,
|
||||||
|
"message": f"Detected {n_changed} changes out of {n_total} valid pixels ({(n_changed/n_total*100):.1f}%)" if n_total > 0 else "No valid pixels for comparison"
|
||||||
|
},
|
||||||
|
"change_map_file": str(change_file),
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CHANGE DETECTION ERROR] {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Change detection workflow failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/change-detection/workflow")
|
||||||
|
async def change_detection_workflow(request: ChangeDetectionWorkflowRequest):
|
||||||
|
"""Workflow: compare prediction with ground truth training data."""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
try:
|
||||||
|
prediction_result = request.prediction_result
|
||||||
|
bbox = request.bbox
|
||||||
|
|
||||||
|
if not prediction_result or "output_files" not in prediction_result:
|
||||||
|
raise ValueError("Invalid prediction result")
|
||||||
|
|
||||||
|
# Get classification raster from prediction
|
||||||
|
class_file = None
|
||||||
|
for f in prediction_result.get("output_files", []):
|
||||||
|
if f.get("type") == "classification":
|
||||||
|
class_file = f.get("path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not class_file:
|
||||||
|
raise ValueError("No classification raster in prediction result")
|
||||||
|
|
||||||
|
# Load prediction raster
|
||||||
|
with rasterio.open(class_file) as pred_ds:
|
||||||
|
pred_arr = pred_ds.read(1)
|
||||||
|
pred_crs = pred_ds.crs
|
||||||
|
pred_transform = pred_ds.transform
|
||||||
|
|
||||||
|
# Rasterize ground truth training data
|
||||||
|
gt_shapefile = "train/ST_training data_updated_1130points_new.shp"
|
||||||
|
gt_raster = rasterize_ground_truth(gt_shapefile, pred_arr.shape, bbox, class_column="class")
|
||||||
|
|
||||||
|
# Calculate change detection
|
||||||
|
mask_valid = (gt_raster >= 0) & (pred_arr >= 0) & ~np.isnan(gt_raster) & ~np.isnan(pred_arr)
|
||||||
|
changes = gt_raster[mask_valid] != pred_arr[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
|
||||||
|
# Create change pairs matrix
|
||||||
|
change_pairs = list(zip(gt_raster[mask_valid][changes], pred_arr[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(gt)}->{int(pred)}": int(cnt) for (gt, pred), cnt in change_counter.items()}
|
||||||
|
|
||||||
|
# Create change map
|
||||||
|
change_map = np.full(pred_arr.shape, -1, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
|
||||||
|
# Change rate
|
||||||
|
change_rate = float(n_changed) / n_total if n_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Save change map
|
||||||
|
change_dir = Path("predictions")
|
||||||
|
change_dir.mkdir(exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
change_file = change_dir / f"change_map_{timestamp}.tif"
|
||||||
|
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=change_map.shape[0],
|
||||||
|
width=change_map.shape[1],
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs=pred_crs,
|
||||||
|
transform=pred_transform
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"change_detection": {
|
||||||
|
"n_total_pixels": int(n_total),
|
||||||
|
"n_changed_pixels": int(n_changed),
|
||||||
|
"change_rate": change_rate,
|
||||||
|
"change_matrix": change_matrix,
|
||||||
|
"message": f"Detected {n_changed} changes out of {n_total} valid pixels ({change_rate*100:.1f}%)"
|
||||||
|
},
|
||||||
|
"change_map_file": str(change_file),
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CHANGE DETECTION WORKFLOW ERROR] {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Change detection workflow failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/change-detection/compare-periods")
|
||||||
|
async def compare_periods(request: ComparePeriodsPredictionConfig, background_tasks: BackgroundTasks):
|
||||||
|
"""Compare land use classification between two time periods."""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract parameters
|
||||||
|
model_filename = request.model_filename
|
||||||
|
bbox = [request.min_lon, request.min_lat, request.max_lon, request.max_lat]
|
||||||
|
|
||||||
|
current_start = request.current_period["start_date"]
|
||||||
|
current_end = request.current_period["end_date"]
|
||||||
|
pred_start = request.prediction_period["start_date"]
|
||||||
|
pred_end = request.prediction_period["end_date"]
|
||||||
|
|
||||||
|
max_scenes = request.max_scenes
|
||||||
|
cloud_cover = request.cloud_cover
|
||||||
|
resolution = request.resolution
|
||||||
|
|
||||||
|
print(f"[COMPARE PERIODS] Current: {current_start} to {current_end} | Prediction: {pred_start} to {pred_end}")
|
||||||
|
|
||||||
|
# Step 1: Predict on current period
|
||||||
|
print(f"[COMPARE PERIODS] Step 1: Predicting current period...")
|
||||||
|
current_result = await predict_with_ndvi(PredictionWithNDVIConfig(
|
||||||
|
model_filename=model_filename,
|
||||||
|
min_lon=request.min_lon,
|
||||||
|
min_lat=request.min_lat,
|
||||||
|
max_lon=request.max_lon,
|
||||||
|
max_lat=request.max_lat,
|
||||||
|
start_date=current_start,
|
||||||
|
end_date=current_end,
|
||||||
|
max_scenes=max_scenes,
|
||||||
|
cloud_cover=cloud_cover,
|
||||||
|
resolution=resolution,
|
||||||
|
export_classification=True,
|
||||||
|
export_ndvi=False
|
||||||
|
), background_tasks)
|
||||||
|
|
||||||
|
# Get current classification raster
|
||||||
|
current_class_file = None
|
||||||
|
for f in current_result.get("output_files", []):
|
||||||
|
if f.get("type") == "classification":
|
||||||
|
current_class_file = f.get("path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not current_class_file:
|
||||||
|
raise ValueError("No classification raster for current period")
|
||||||
|
|
||||||
|
# Step 2: Predict on prediction period
|
||||||
|
print(f"[COMPARE PERIODS] Step 2: Predicting future period...")
|
||||||
|
pred_result = await predict_with_ndvi(PredictionWithNDVIConfig(
|
||||||
|
model_filename=model_filename,
|
||||||
|
min_lon=request.min_lon,
|
||||||
|
min_lat=request.min_lat,
|
||||||
|
max_lon=request.max_lon,
|
||||||
|
max_lat=request.max_lat,
|
||||||
|
start_date=pred_start,
|
||||||
|
end_date=pred_end,
|
||||||
|
max_scenes=max_scenes,
|
||||||
|
cloud_cover=cloud_cover,
|
||||||
|
resolution=resolution,
|
||||||
|
export_classification=True,
|
||||||
|
export_ndvi=False
|
||||||
|
), background_tasks)
|
||||||
|
|
||||||
|
# Get prediction classification raster
|
||||||
|
pred_class_file = None
|
||||||
|
for f in pred_result.get("output_files", []):
|
||||||
|
if f.get("type") == "classification":
|
||||||
|
pred_class_file = f.get("path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not pred_class_file:
|
||||||
|
raise ValueError("No classification raster for prediction period")
|
||||||
|
|
||||||
|
# Step 3: Load both rasters
|
||||||
|
print(f"[COMPARE PERIODS] Step 3: Comparing classifications...")
|
||||||
|
with rasterio.open(current_class_file) as src:
|
||||||
|
current_arr = src.read(1)
|
||||||
|
crs = src.crs
|
||||||
|
transform = src.transform
|
||||||
|
|
||||||
|
with rasterio.open(pred_class_file) as src:
|
||||||
|
pred_arr = src.read(1)
|
||||||
|
|
||||||
|
# Ensure same shape
|
||||||
|
if current_arr.shape != pred_arr.shape:
|
||||||
|
raise ValueError(f"Shape mismatch: current {current_arr.shape} vs prediction {pred_arr.shape}")
|
||||||
|
|
||||||
|
# Calculate changes
|
||||||
|
mask_valid = ~np.isnan(current_arr) & ~np.isnan(pred_arr)
|
||||||
|
changes = current_arr[mask_valid] != pred_arr[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
|
||||||
|
# Create change pairs
|
||||||
|
change_pairs = list(zip(current_arr[mask_valid][changes], pred_arr[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(curr)}->{int(pred)}": int(cnt)
|
||||||
|
for (curr, pred), cnt in change_counter.items()}
|
||||||
|
|
||||||
|
# Change rate
|
||||||
|
change_rate = float(n_changed) / n_total if n_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Save change map
|
||||||
|
change_dir = Path("predictions")
|
||||||
|
change_dir.mkdir(exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
change_file = change_dir / f"change_map_{timestamp}.tif"
|
||||||
|
|
||||||
|
change_map = np.zeros(current_arr.shape, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=change_map.shape[0],
|
||||||
|
width=change_map.shape[1],
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs=crs,
|
||||||
|
transform=transform
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
|
||||||
|
# Extract class distributions
|
||||||
|
current_classes = np.unique(current_arr[~np.isnan(current_arr)]).astype(int)
|
||||||
|
current_dist = {int(c): int(np.count_nonzero(current_arr == c)) for c in current_classes}
|
||||||
|
|
||||||
|
pred_classes = np.unique(pred_arr[~np.isnan(pred_arr)]).astype(int)
|
||||||
|
pred_dist = {int(c): int(np.count_nonzero(pred_arr == c)) for c in pred_classes}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"current_classification": {
|
||||||
|
"n_scenes": current_result.get("n_scenes"),
|
||||||
|
"resolution": current_result.get("resolution"),
|
||||||
|
"class_distribution": current_dist
|
||||||
|
},
|
||||||
|
"prediction_classification": {
|
||||||
|
"n_scenes": pred_result.get("n_scenes"),
|
||||||
|
"resolution": pred_result.get("resolution"),
|
||||||
|
"class_distribution": pred_dist
|
||||||
|
},
|
||||||
|
"change_detection": {
|
||||||
|
"n_total_pixels": int(n_total),
|
||||||
|
"n_changed_pixels": int(n_changed),
|
||||||
|
"change_rate": change_rate,
|
||||||
|
"change_matrix": change_matrix,
|
||||||
|
"message": f"Detected {n_changed} changes out of {n_total} valid pixels ({change_rate*100:.1f}%)"
|
||||||
|
},
|
||||||
|
"change_map_file": str(change_file),
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[COMPARE PERIODS ERROR] {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Period comparison failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/change-detection")
|
||||||
|
async def change_detection_api(
|
||||||
|
prediction_file: UploadFile = File(...),
|
||||||
|
gt_file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""Detect changes between two raster files (prediction and ground truth)."""
|
||||||
|
import tempfile
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded files temporarily
|
||||||
|
pred_tmp = tempfile.NamedTemporaryFile(suffix='.tif', delete=False)
|
||||||
|
gt_tmp = tempfile.NamedTemporaryFile(suffix='.tif', delete=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write uploaded files to temp
|
||||||
|
pred_content = await prediction_file.read()
|
||||||
|
gt_content = await gt_file.read()
|
||||||
|
|
||||||
|
pred_tmp.write(pred_content)
|
||||||
|
gt_tmp.write(gt_content)
|
||||||
|
pred_tmp.close()
|
||||||
|
gt_tmp.close()
|
||||||
|
|
||||||
|
# Read prediction raster
|
||||||
|
with rasterio.open(pred_tmp.name) as pred_ds:
|
||||||
|
pred_arr = pred_ds.read(1)
|
||||||
|
pred_crs = pred_ds.crs
|
||||||
|
pred_transform = pred_ds.transform
|
||||||
|
|
||||||
|
# Read ground truth raster
|
||||||
|
with rasterio.open(gt_tmp.name) as gt_ds:
|
||||||
|
gt_arr = gt_ds.read(1)
|
||||||
|
|
||||||
|
# Ensure same shape
|
||||||
|
if pred_arr.shape != gt_arr.shape:
|
||||||
|
raise ValueError(f"Raster shapes don't match: prediction {pred_arr.shape} vs ground truth {gt_arr.shape}")
|
||||||
|
|
||||||
|
# Calculate change detection
|
||||||
|
mask_valid = (gt_arr >= 0) & (pred_arr >= 0) & ~np.isnan(gt_arr) & ~np.isnan(pred_arr)
|
||||||
|
changes = gt_arr[mask_valid] != pred_arr[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
|
||||||
|
# Create change pairs matrix
|
||||||
|
change_pairs = list(zip(gt_arr[mask_valid][changes], pred_arr[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(gt)}->{int(pred)}": int(cnt) for (gt, pred), cnt in change_counter.items()}
|
||||||
|
|
||||||
|
# Create change map (0=same, 1=changed, -1=invalid)
|
||||||
|
change_map = np.full(pred_arr.shape, -1, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
|
||||||
|
# Change rate
|
||||||
|
change_rate = float(n_changed) / n_total if n_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Save change map as GeoTIFF
|
||||||
|
change_dir = Path("predictions")
|
||||||
|
change_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
change_file = change_dir / f"change_map_{timestamp}.tif"
|
||||||
|
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=change_map.shape[0],
|
||||||
|
width=change_map.shape[1],
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs=pred_crs,
|
||||||
|
transform=pred_transform
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
|
||||||
|
# Return results
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"change_detection": {
|
||||||
|
"n_total_pixels": int(n_total),
|
||||||
|
"n_changed_pixels": int(n_changed),
|
||||||
|
"change_rate": change_rate,
|
||||||
|
"change_matrix": change_matrix,
|
||||||
|
"message": f"Detected {n_changed} changes out of {n_total} valid pixels ({change_rate*100:.1f}%)"
|
||||||
|
},
|
||||||
|
"change_map_file": str(change_file),
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp files
|
||||||
|
try:
|
||||||
|
Path(pred_tmp.name).unlink()
|
||||||
|
Path(gt_tmp.name).unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CHANGE DETECTION ERROR] {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Change detection failed: {str(e)}")
|
||||||
|
async def change_detection_api(
|
||||||
|
prediction_file: UploadFile = File(...),
|
||||||
|
gt_file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""Detect changes between prediction raster and ground truth raster."""
|
||||||
|
import tempfile
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded files temporarily
|
||||||
|
pred_tmp = tempfile.NamedTemporaryFile(suffix='.tif', delete=False)
|
||||||
|
gt_tmp = tempfile.NamedTemporaryFile(suffix='.tif', delete=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write uploaded files to temp
|
||||||
|
pred_content = await prediction_file.read()
|
||||||
|
gt_content = await gt_file.read()
|
||||||
|
|
||||||
|
pred_tmp.write(pred_content)
|
||||||
|
gt_tmp.write(gt_content)
|
||||||
|
pred_tmp.close()
|
||||||
|
gt_tmp.close()
|
||||||
|
|
||||||
|
# Read prediction raster
|
||||||
|
import rasterio
|
||||||
|
with rasterio.open(pred_tmp.name) as pred_ds:
|
||||||
|
pred_arr = pred_ds.read(1)
|
||||||
|
pred_crs = pred_ds.crs
|
||||||
|
pred_transform = pred_ds.transform
|
||||||
|
|
||||||
|
# Read ground truth raster
|
||||||
|
with rasterio.open(gt_tmp.name) as gt_ds:
|
||||||
|
gt_arr = gt_ds.read(1)
|
||||||
|
|
||||||
|
# Ensure same shape
|
||||||
|
if pred_arr.shape != gt_arr.shape:
|
||||||
|
raise ValueError(f"Raster shapes don't match: prediction {pred_arr.shape} vs ground truth {gt_arr.shape}")
|
||||||
|
|
||||||
|
# Calculate change detection
|
||||||
|
mask_valid = (gt_arr >= 0) & (pred_arr >= 0) & ~np.isnan(gt_arr) & ~np.isnan(pred_arr)
|
||||||
|
changes = gt_arr[mask_valid] != pred_arr[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
|
||||||
|
# Create change pairs matrix
|
||||||
|
change_pairs = list(zip(gt_arr[mask_valid][changes], pred_arr[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(gt)}->{int(pred)}": int(cnt) for (gt, pred), cnt in change_counter.items()}
|
||||||
|
|
||||||
|
# Create change map (0=same, 1=changed, -1=invalid)
|
||||||
|
change_map = np.full(pred_arr.shape, -1, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
|
||||||
|
# Change rate
|
||||||
|
change_rate = float(n_changed) / n_total if n_total > 0 else 0.0
|
||||||
|
|
||||||
|
# Save change map as GeoTIFF
|
||||||
|
change_dir = Path("predictions")
|
||||||
|
change_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
change_file = change_dir / f"change_map_{timestamp}.tif"
|
||||||
|
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=change_map.shape[0],
|
||||||
|
width=change_map.shape[1],
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs=pred_crs,
|
||||||
|
transform=pred_transform
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
|
||||||
|
# Return results
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"change_detection": {
|
||||||
|
"n_total_pixels": int(n_total),
|
||||||
|
"n_changed_pixels": int(n_changed),
|
||||||
|
"change_rate": change_rate,
|
||||||
|
"change_matrix": change_matrix,
|
||||||
|
"message": f"Detected {n_changed} changes out of {n_total} valid pixels ({change_rate*100:.1f}%)"
|
||||||
|
},
|
||||||
|
"change_map_file": str(change_file),
|
||||||
|
"timestamp": timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp files
|
||||||
|
try:
|
||||||
|
Path(pred_tmp.name).unlink()
|
||||||
|
Path(gt_tmp.name).unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CHANGE DETECTION ERROR] {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Change detection failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ============ PREDICTION WITH NDVI API ============
|
# ============ PREDICTION WITH NDVI API ============
|
||||||
|
|
||||||
@app.post("/api/predict/with-ndvi")
|
@app.post("/api/predict/with-ndvi")
|
||||||
@@ -1853,13 +2579,6 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
|||||||
"""Predict land classification và NDVI cho một khu vực"""
|
"""Predict land classification và NDVI cho một khu vực"""
|
||||||
try:
|
try:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import xarray as xr
|
|
||||||
from pystac_client import Client
|
|
||||||
import planetary_computer
|
|
||||||
import odc.stac
|
|
||||||
import rasterio
|
|
||||||
from rasterio.transform import from_bounds
|
|
||||||
import hashlib, os
|
|
||||||
|
|
||||||
# Load model
|
# Load model
|
||||||
model_path = Path(f"model_train/{config.model_filename}")
|
model_path = Path(f"model_train/{config.model_filename}")
|
||||||
@@ -1985,6 +2704,50 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
|||||||
prediction_raster = np.full(n_pixels, -1, dtype=np.int16)
|
prediction_raster = np.full(n_pixels, -1, dtype=np.int16)
|
||||||
prediction_raster[valid_mask] = predictions
|
prediction_raster[valid_mask] = predictions
|
||||||
prediction_raster = prediction_raster.reshape(height, width)
|
prediction_raster = prediction_raster.reshape(height, width)
|
||||||
|
|
||||||
|
# --- CHANGE DETECTION ---
|
||||||
|
change_summary = None
|
||||||
|
change_map = None
|
||||||
|
try:
|
||||||
|
# Use training shapefile as ground truth
|
||||||
|
gt_shapefile = "train/ST_training data_updated_1130points_new.shp"
|
||||||
|
gt_raster = rasterize_ground_truth(gt_shapefile, (height, width), bbox, class_column="class")
|
||||||
|
# Compare prediction and ground truth
|
||||||
|
mask_valid = (gt_raster >= 0) & (prediction_raster >= 0)
|
||||||
|
changes = gt_raster[mask_valid] != prediction_raster[mask_valid]
|
||||||
|
n_total = np.count_nonzero(mask_valid)
|
||||||
|
n_changed = np.count_nonzero(changes)
|
||||||
|
# Per-class change matrix
|
||||||
|
from collections import Counter
|
||||||
|
change_pairs = list(zip(gt_raster[mask_valid][changes], prediction_raster[mask_valid][changes]))
|
||||||
|
change_counter = Counter(change_pairs)
|
||||||
|
change_matrix = {f"{int(gt)}->{int(pred)}": int(cnt) for (gt, pred), cnt in change_counter.items()}
|
||||||
|
change_summary = {
|
||||||
|
"n_total": int(n_total),
|
||||||
|
"n_changed": int(n_changed),
|
||||||
|
"change_rate": float(n_changed) / n_total if n_total > 0 else 0.0,
|
||||||
|
"change_matrix": change_matrix
|
||||||
|
}
|
||||||
|
# Optionally, create a change map (1=changed, 0=same, -1=invalid)
|
||||||
|
change_map = np.full((height, width), -1, dtype=np.int8)
|
||||||
|
change_map[mask_valid] = changes.astype(np.int8)
|
||||||
|
# Save change map as GeoTIFF
|
||||||
|
change_file = output_dir / f"change_map_{timestamp}.tif"
|
||||||
|
with rasterio.open(
|
||||||
|
change_file, 'w',
|
||||||
|
driver='GTiff',
|
||||||
|
height=height,
|
||||||
|
width=width,
|
||||||
|
count=1,
|
||||||
|
dtype=change_map.dtype,
|
||||||
|
crs='EPSG:4326',
|
||||||
|
transform=from_bounds(bbox[0], bbox[1], bbox[2], bbox[3], width, height)
|
||||||
|
) as dst:
|
||||||
|
dst.write(change_map, 1)
|
||||||
|
output_files.append({"type": "change_map", "path": str(change_file)})
|
||||||
|
print(f"[CHANGE DETECTION] Saved change map to {change_file}")
|
||||||
|
except Exception as change_exc:
|
||||||
|
print(f"[CHANGE DETECTION] Warning: {change_exc}")
|
||||||
|
|
||||||
# Prepare outputs
|
# Prepare outputs
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
@@ -2055,7 +2818,8 @@ async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks:
|
|||||||
"class_distribution": class_distribution,
|
"class_distribution": class_distribution,
|
||||||
"n_scenes": len(items),
|
"n_scenes": len(items),
|
||||||
"resolution": config.resolution,
|
"resolution": config.resolution,
|
||||||
"bbox": bbox
|
"bbox": bbox,
|
||||||
|
"change_detection": change_summary
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Change Detection - Compare Current vs Future Land Use</title>
|
||||||
|
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden; }
|
||||||
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
||||||
|
.header h1 { font-size: 32px; margin-bottom: 10px; }
|
||||||
|
.header p { font-size: 16px; opacity: 0.9; }
|
||||||
|
.content { padding: 30px; display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
|
.left-panel, .right-panel { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
#map { width: 100%; height: 400px; border-radius: 8px; border: 2px solid #e0e0e0; }
|
||||||
|
.section { background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #667eea; }
|
||||||
|
.section h2 { color: #333; font-size: 18px; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
.form-group label { display: block; margin-bottom: 6px; color: #555; font-weight: 500; font-size: 14px; }
|
||||||
|
.form-group input[type="text"], .form-group input[type="date"], .form-group input[type="number"], .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; font-family: inherit; transition: all 0.3s ease; }
|
||||||
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
||||||
|
.bbox-display { background: white; padding: 12px; border-radius: 6px; font-size: 13px; color: #666; font-family: monospace; border: 1px dashed #667eea; word-break: break-all; }
|
||||||
|
.btn { padding: 12px 24px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||||
|
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
.result { background: white; border: 2px solid #e0e0e0; border-radius: 8px; padding: 20px; display: none; animation: slideIn 0.3s ease; max-height: 600px; overflow-y: auto; }
|
||||||
|
.result.success { border-color: #4caf50; background: #f1f8f5; }
|
||||||
|
.result.error { border-color: #f44336; background: #fdf5f4; }
|
||||||
|
.result.processing { border-color: #2196f3; background: #f3f8fd; }
|
||||||
|
.result h3 { margin-bottom: 15px; color: #333; }
|
||||||
|
.result table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
||||||
|
.result table th, .result table td { padding: 10px; text-align: left; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
.result table th { background: #f0f0f0; font-weight: 600; color: #333; }
|
||||||
|
.result pre { background: #f5f5f5; padding: 15px; border-radius: 6px; overflow-x: auto; font-size: 12px; color: #333; max-height: 300px; overflow-y: auto; border-left: 4px solid #667eea; }
|
||||||
|
.error-text { color: #f44336; font-weight: 500; }
|
||||||
|
.success-text { color: #4caf50; font-weight: 500; }
|
||||||
|
.processing-text { color: #2196f3; font-weight: 500; }
|
||||||
|
.progress { width: 100%; height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin: 10px 0; }
|
||||||
|
.progress-bar { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); width: 0%; transition: width 0.3s ease; }
|
||||||
|
.stat-box { background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #667eea; margin: 10px 0; }
|
||||||
|
.stat-label { font-size: 12px; color: #999; text-transform: uppercase; margin-bottom: 5px; }
|
||||||
|
.stat-value { font-size: 20px; font-weight: 600; color: #333; }
|
||||||
|
.info-box { background: #e3f2fd; padding: 12px; border-radius: 6px; border-left: 4px solid #2196f3; font-size: 13px; color: #1565c0; }
|
||||||
|
@keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@media (max-width: 1024px) { .content { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔍 Change Detection - Land Use Analysis</h1>
|
||||||
|
<p>Compare current land use with predicted future changes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Left Panel -->
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>🗺️</span>Select Area on Map</h2>
|
||||||
|
<p style="color: #999; font-size: 13px; margin-bottom: 10px;">Click on map to select bounding box</p>
|
||||||
|
<div id="map"></div>
|
||||||
|
<div class="form-group" style="margin-top: 10px;">
|
||||||
|
<label>BBox (min_lon, min_lat, max_lon, max_lat)</label>
|
||||||
|
<div class="bbox-display" id="bboxDisplay">Click on map to select area</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>📅</span>Current Period (Baseline)</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Start Date</label>
|
||||||
|
<input type="date" id="currentStartDate" value="2022-01-01">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>End Date</label>
|
||||||
|
<input type="date" id="currentEndDate" value="2022-03-31">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>🔮</span>Prediction Period (Future)</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Start Date</label>
|
||||||
|
<input type="date" id="predictionStartDate" value="2023-01-01">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>End Date</label>
|
||||||
|
<input type="date" id="predictionEndDate" value="2023-03-31">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>⚙️</span>Parameters</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max Scenes</label>
|
||||||
|
<input type="number" id="maxScenes" value="12" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Cloud Cover %</label>
|
||||||
|
<input type="number" id="cloudCover" value="30" min="0" max="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Resolution (m)</label>
|
||||||
|
<input type="number" id="resolution" value="20" min="10" max="100" step="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel -->
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>🤖</span>Select Trained Model</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Trained Model</label>
|
||||||
|
<select id="modelSelect">
|
||||||
|
<option value="">Loading models...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="modelInfo" style="font-size: 12px; color: #999; margin-top: 10px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><span>�</span>Workflow</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
1️⃣ Classify current period satellite data<br>
|
||||||
|
2️⃣ Classify future period satellite data<br>
|
||||||
|
3️⃣ Compare to detect land use changes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<button class="btn btn-primary" id="runBtn" onclick="runChangeDetection()" disabled>
|
||||||
|
<span>▶️</span>Compare Periods
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultDiv" class="result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
const API_BASE = 'http://localhost:8000/api';
|
||||||
|
let map, rectangle;
|
||||||
|
let bbox = null;
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
map = L.map('map').setView([9.8, 105.85], 10);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const defaultBbox = [105.6, 9.3, 106.2, 9.8];
|
||||||
|
drawBboxRectangle(defaultBbox);
|
||||||
|
|
||||||
|
map.on('click', function(e) {
|
||||||
|
const size = 0.3;
|
||||||
|
const bounds = L.latLngBounds([
|
||||||
|
[e.latlng.lat - size, e.latlng.lng - size],
|
||||||
|
[e.latlng.lat + size, e.latlng.lng + size]
|
||||||
|
]);
|
||||||
|
drawBboxRectangle([bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBboxRectangle(bboxArray) {
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bboxArray;
|
||||||
|
if (rectangle) map.removeLayer(rectangle);
|
||||||
|
|
||||||
|
rectangle = L.rectangle([[minLat, minLon], [maxLat, maxLon]], {
|
||||||
|
color: '#667eea', weight: 2, fillColor: '#667eea', fillOpacity: 0.1
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
map.fitBounds(rectangle.getBounds());
|
||||||
|
bbox = bboxArray;
|
||||||
|
document.getElementById('bboxDisplay').textContent =
|
||||||
|
`[${minLon.toFixed(4)}, ${minLat.toFixed(4)}, ${maxLon.toFixed(4)}, ${maxLat.toFixed(4)}]`;
|
||||||
|
updateRunButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/models/list`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
|
modelSelect.innerHTML = '<option value="">-- Select a model --</option>';
|
||||||
|
|
||||||
|
if (data.models && data.models.length > 0) {
|
||||||
|
data.models.forEach(model => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = model.filename;
|
||||||
|
option.textContent = `${model.filename} (${model.size_mb}MB)`;
|
||||||
|
modelSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
modelSelect.innerHTML = '<option value="">No trained models found</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
modelSelect.addEventListener('change', () => {
|
||||||
|
updateModelInfo();
|
||||||
|
updateRunButtonState();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading models:', error);
|
||||||
|
document.getElementById('modelSelect').innerHTML = '<option value="">Error loading models</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModelInfo() {
|
||||||
|
const modelName = document.getElementById('modelSelect').value;
|
||||||
|
document.getElementById('modelInfo').textContent = modelName ? `Selected: ${modelName}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRunButtonState() {
|
||||||
|
const runBtn = document.getElementById('runBtn');
|
||||||
|
runBtn.disabled = !bbox || !document.getElementById('modelSelect').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChangeDetection() {
|
||||||
|
const resultDiv = document.getElementById('resultDiv');
|
||||||
|
const runBtn = document.getElementById('runBtn');
|
||||||
|
|
||||||
|
if (!bbox) {
|
||||||
|
showResult('error', 'Error', 'Please select an area on the map');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelFilename = document.getElementById('modelSelect').value;
|
||||||
|
if (!modelFilename) {
|
||||||
|
showResult('error', 'Error', 'Please select a trained model');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runBtn.disabled = true;
|
||||||
|
showResult('processing', 'Processing', 'Analyzing land use changes...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bbox;
|
||||||
|
const currentStartDate = document.getElementById('currentStartDate').value;
|
||||||
|
const currentEndDate = document.getElementById('currentEndDate').value;
|
||||||
|
const predictionStartDate = document.getElementById('predictionStartDate').value;
|
||||||
|
const predictionEndDate = document.getElementById('predictionEndDate').value;
|
||||||
|
const maxScenes = parseInt(document.getElementById('maxScenes').value);
|
||||||
|
const cloudCover = parseInt(document.getElementById('cloudCover').value);
|
||||||
|
const resolution = parseInt(document.getElementById('resolution').value);
|
||||||
|
|
||||||
|
showResult('processing', 'Step 1/3', 'Classifying current period (baseline)...');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
model_filename: modelFilename,
|
||||||
|
min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat,
|
||||||
|
current_period: {
|
||||||
|
start_date: currentStartDate,
|
||||||
|
end_date: currentEndDate
|
||||||
|
},
|
||||||
|
prediction_period: {
|
||||||
|
start_date: predictionStartDate,
|
||||||
|
end_date: predictionEndDate
|
||||||
|
},
|
||||||
|
max_scenes: maxScenes,
|
||||||
|
cloud_cover: cloudCover,
|
||||||
|
resolution: resolution,
|
||||||
|
export_ndvi: true,
|
||||||
|
export_classification: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeResponse = await fetch(`${API_BASE}/change-detection/compare-periods`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changeResponse.ok) {
|
||||||
|
const errorData = await changeResponse.json();
|
||||||
|
throw new Error(errorData.detail || 'Analysis failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeResult = await changeResponse.json();
|
||||||
|
displayResults(changeResult);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showResult('error', 'Error', error.message);
|
||||||
|
} finally {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(result) {
|
||||||
|
const resultDiv = document.getElementById('resultDiv');
|
||||||
|
let html = '<h3 class="success-text">✓ Change Detection Completed</h3>';
|
||||||
|
|
||||||
|
// Current period classification
|
||||||
|
if (result.current_classification) {
|
||||||
|
const curr = result.current_classification;
|
||||||
|
html += '<div class="stat-box"><div class="stat-label">📊 Current Period Classification</div>';
|
||||||
|
html += `<div style="color: #666; font-size: 12px; margin-bottom: 10px;">Scenes: ${curr.n_scenes} | Resolution: ${curr.resolution}m</div>`;
|
||||||
|
|
||||||
|
if (curr.class_distribution) {
|
||||||
|
html += '<table>';
|
||||||
|
Object.entries(curr.class_distribution).forEach(([cls, count]) => {
|
||||||
|
const percentage = ((count / Object.values(curr.class_distribution).reduce((a,b) => a+b, 0)) * 100).toFixed(1);
|
||||||
|
html += `<tr><td>Class ${cls}:</td><td><strong>${count}</strong> (${percentage}%)</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prediction period classification
|
||||||
|
if (result.prediction_classification) {
|
||||||
|
const pred = result.prediction_classification;
|
||||||
|
html += '<div class="stat-box"><div class="stat-label">🔮 Prediction Period Classification</div>';
|
||||||
|
html += `<div style="color: #666; font-size: 12px; margin-bottom: 10px;">Scenes: ${pred.n_scenes} | Resolution: ${pred.resolution}m</div>`;
|
||||||
|
|
||||||
|
if (pred.class_distribution) {
|
||||||
|
html += '<table>';
|
||||||
|
Object.entries(pred.class_distribution).forEach(([cls, count]) => {
|
||||||
|
const percentage = ((count / Object.values(pred.class_distribution).reduce((a,b) => a+b, 0)) * 100).toFixed(1);
|
||||||
|
html += `<tr><td>Class ${cls}:</td><td><strong>${count}</strong> (${percentage}%)</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change detection
|
||||||
|
if (result.change_detection) {
|
||||||
|
const cd = result.change_detection;
|
||||||
|
html += '<div class="stat-box"><div class="stat-label">🔄 Change Detection Summary</div>';
|
||||||
|
html += `<div class="stat-value" style="color: #e74c3c;">${(cd.change_rate * 100).toFixed(2)}% Changed</div>`;
|
||||||
|
html += '<table>';
|
||||||
|
html += '<tr><td>Changed Pixels:</td><td><strong>' + cd.n_changed_pixels.toLocaleString() + '</strong></td></tr>';
|
||||||
|
html += '<tr><td>Total Pixels:</td><td><strong>' + cd.n_total_pixels.toLocaleString() + '</strong></td></tr>';
|
||||||
|
html += '</table>';
|
||||||
|
|
||||||
|
if (Object.keys(cd.change_matrix).length > 0) {
|
||||||
|
html += '<div style="margin-top: 10px;"><strong>Transitions (Current → Prediction):</strong></div>';
|
||||||
|
html += '<pre>' + JSON.stringify(cd.change_matrix, null, 2) + '</pre>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
resultDiv.className = 'result success';
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(type, title, message) {
|
||||||
|
const resultDiv = document.getElementById('resultDiv');
|
||||||
|
const typeClass = type === 'error' ? 'error' : (type === 'processing' ? 'processing' : 'success');
|
||||||
|
const textClass = type === 'error' ? 'error-text' : (type === 'processing' ? 'processing-text' : 'success-text');
|
||||||
|
|
||||||
|
resultDiv.innerHTML = `<h3 class="${textClass}">${title}</h3><p>${message}</p>` +
|
||||||
|
(type === 'processing' ? '<div class="progress"><div class="progress-bar" style="animation: progress 2s infinite;"></div></div>' : '');
|
||||||
|
resultDiv.className = `result ${typeClass}`;
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initMap();
|
||||||
|
loadModels();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Prediction Report - 20251222_155233</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-bottom: 3px solid #ff6b6b;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b15 0%, #ee5a6f15 100%);
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #ff6b6b30;
|
||||||
|
}
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
.stat-card .label {
|
||||||
|
color: #666;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 5px solid #ff6b6b;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 200px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.class-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🗺️ Báo Cáo Dự Đoán</h1>
|
||||||
|
<p>Land Classification Prediction - 22/12/2025 15:52:33</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="section">
|
||||||
|
<h2>📈 Tóm Tắt Kết Quả</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">165</div>
|
||||||
|
<div class="label">Tổng số Pixels</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">11x15</div>
|
||||||
|
<div class="label">Kích thước (px)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">0.1</div>
|
||||||
|
<div class="label">Diện tích (km²)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">2</div>
|
||||||
|
<div class="label">Số Classes</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">3</div>
|
||||||
|
<div class="label">Số Features</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="value">✅</div>
|
||||||
|
<div class="label">Sử dụng Radar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>⚙️ Thông Tin Chi Tiết</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">🤖 Model sử dụng:</span>
|
||||||
|
<span>model_xgboost_20251221_172351.joblib</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">📍 Khu vực (bbox):</span>
|
||||||
|
<span>[105.47426033018384, 9.250032954766686, 105.47683525083814, 9.251917848893436]</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">📅 Thời gian:</span>
|
||||||
|
<span>2023-03-01/2023-05-31</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">💾 Output file:</span>
|
||||||
|
<span>predictions/prediction_20251222_155232.tif</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>🏷️ Các Classes Phát Hiện</h2>
|
||||||
|
<div>
|
||||||
|
<span class="class-badge">3</span><span class="class-badge">6</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>🌍 Land Classification System | Generated: 22/12/2025 15:52:33</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
|
|
||||||
#uvicorn api_server:app --reload --host 0.0.0.0 --port 8000
|
uvicorn api_server:app --reload --host 0.0.0.0 --port 8000
|
||||||
pkill -f "uvicorn api_server:app" && sleep 1 && nohup uvicorn api_server:app --host 0.0.0.0 --port 8000 > server.log 2>&1 &
|
#pkill -f "uvicorn api_server:app" && sleep 1 && nohup uvicorn api_server:app --host 0.0.0.0 --port 8000 > server.log 2>&1 &
|
||||||
|
|||||||
Reference in New Issue
Block a user