update chức năng ndvi time seriese

This commit is contained in:
Victor Phan
2025-12-22 07:20:41 +07:00
parent 46da481029
commit f3ff5d81a6
12 changed files with 3558 additions and 66 deletions
+202
View File
@@ -0,0 +1,202 @@
# Hướng Dẫn Sử Dụng Chức Năng Predict NDVI
## Tổng Quan
Chức năng mới cho phép dự đoán phân loại đất (land classification) **kết hợp** với việc xuất ra raster NDVI cho cùng một khu vực.
## Cách Sử Dụng
### 1. Truy cập Prediction Interface
- Mở trình duyệt: `http://localhost:8000/prediction`
- Hoặc từ trang chủ, click vào **Prediction**
### 2. Chọn Model
- Chọn model đã được train từ dropdown "Select Model"
- Model phải tồn tại trong thư mục `model_train/`
### 3. Vẽ Khu Vực (Bbox)
- Sử dụng công cụ vẽ hình chữ nhật trên bản đồ
- Khu vực này sẽ được dùng để:
- Load dữ liệu vệ tinh
- Tính NDVI
- Predict land classification
### 4. Cấu Hình Thời Gian & Dữ Liệu
- **Từ ngày / Đến ngày**: Khoảng thời gian lấy ảnh vệ tinh
- **Max Scenes**: Số lượng ảnh tối đa (khuyến nghị: 12)
- **Cloud Cover**: % mây tối đa (khuyến nghị: 30%)
- **Resolution**: Độ phân giải (10m hoặc 20m)
### 5. Bật Export NDVI
- ✅ Check vào "🌿 Export NDVI Raster"
- Khi bật, hệ thống sẽ:
- Tính NDVI từ Sentinel-2 (NIR - Red) / (NIR + Red)
- Xuất ra file `ndvi_YYYYMMDD_HHMMSS.tif`
- Xuất ra file `classification_YYYYMMDD_HHMMSS.tif`
### 6. Chạy Prediction
- Click "🚀 Start Prediction (với NDVI)"
- Hệ thống sẽ:
1. Load dữ liệu Sentinel-2 (bands: B02, B03, B04, B08)
2. Tính toán các spectral indices (NDVI, NDWI, NDBI)
3. Dùng model để predict land classification
4. Xuất kết quả
## Kết Quả
### Output Files
Sau khi hoàn thành, bạn sẽ nhận được 2 file trong thư mục `predictions/`:
1. **`ndvi_YYYYMMDD_HHMMSS.tif`**
- GeoTIFF chứa giá trị NDVI
- Giá trị: -1 đến +1
- CRS: EPSG:4326 (WGS84)
- Có thể mở bằng QGIS, ArcGIS, hoặc Python
2. **`classification_YYYYMMDD_HHMMSS.tif`**
- GeoTIFF chứa kết quả phân loại đất
- Giá trị: class labels (ví dụ: 0, 1, 2, 3...)
- CRS: EPSG:4326 (WGS84)
### Thống Kê Hiển Thị
Sau khi predict xong, giao diện sẽ hiển thị:
- **NDVI Statistics**:
- Mean: Giá trị NDVI trung bình
- Min: Giá trị NDVI nhỏ nhất
- Max: Giá trị NDVI lớn nhất
- Std: Độ lệch chuẩn
- **Class Distribution**: Số lượng pixel cho mỗi class
- **N Scenes**: Số ảnh vệ tinh đã sử dụng
## API Endpoint
### POST `/api/predict/with-ndvi`
**Request Body:**
```json
{
"model_filename": "model_xgboost_20231221_120000.joblib",
"min_lon": 105.6,
"min_lat": 9.3,
"max_lon": 106.2,
"max_lat": 9.8,
"start_date": "2023-03-01",
"end_date": "2023-05-31",
"max_scenes": 12,
"cloud_cover": 30,
"resolution": 20,
"export_ndvi": true,
"export_classification": true
}
```
**Response:**
```json
{
"success": true,
"message": "Prediction with NDVI completed",
"output_files": [
{"type": "ndvi", "path": "predictions/ndvi_20231221_120000.tif"},
{"type": "classification", "path": "predictions/classification_20231221_120000.tif"}
],
"ndvi_stats": {
"mean": 0.456,
"min": -0.123,
"max": 0.789,
"std": 0.234
},
"class_distribution": {
"0": 12345,
"1": 23456,
"2": 34567
},
"n_scenes": 12,
"resolution": 20,
"bbox": [105.6, 9.3, 106.2, 9.8]
}
```
## Download Files
Sau khi prediction hoàn thành, có thể download files qua:
- **UI**: Click "💾 Download GeoTIFF" trong kết quả
- **API**: `GET /api/predictions/download/ndvi_YYYYMMDD_HHMMSS.tif`
- **API**: `GET /api/predictions/download/classification_YYYYMMDD_HHMMSS.tif`
## Sử Dụng Kết Quả với Python
```python
import rasterio
import matplotlib.pyplot as plt
import numpy as np
# Read NDVI raster
with rasterio.open('predictions/ndvi_20231221_120000.tif') as src:
ndvi = src.read(1)
# Visualize
plt.figure(figsize=(10, 8))
plt.imshow(ndvi, cmap='RdYlGn', vmin=-1, vmax=1)
plt.colorbar(label='NDVI')
plt.title('NDVI Map')
plt.show()
# Read classification raster
with rasterio.open('predictions/classification_20231221_120000.tif') as src:
classification = src.read(1)
# Visualize
plt.figure(figsize=(10, 8))
plt.imshow(classification, cmap='tab10')
plt.colorbar(label='Land Class')
plt.title('Land Classification')
plt.show()
```
## Sử Dụng Kết Quả với QGIS
1. Mở QGIS
2. **Layer → Add Layer → Add Raster Layer**
3. Chọn file `ndvi_*.tif` hoặc `classification_*.tif`
4. Styling:
- NDVI: Singleband pseudocolor, min=-1, max=1, color ramp=RdYlGn
- Classification: Paletted/Unique values
## Lưu Ý
- **Thời gian xử lý**: Tùy thuộc vào kích thước bbox và số scenes (thường 2-5 phút)
- **Bộ nhớ**: Khu vực lớn + resolution cao = RAM cao
- **NDVI values**:
- < 0: Nước, đất trống
- 0 - 0.2: Đất có ít thực vật
- 0.2 - 0.5: Cây cỏ, cây trồng
- > 0.5: Rừng rậm, thực vật dày đặc
## So Sánh với NDVI Time Series
| Feature | Predict NDVI | NDVI Time Series |
|---------|-------------|------------------|
| **Mục đích** | Xuất raster NDVI + land classification | Xem xu hướng NDVI theo thời gian |
| **Output** | GeoTIFF files | Chart, CSV |
| **Dùng model** | Có (predict land class) | Không (chỉ tính NDVI) |
| **Visualize** | Bản đồ raster | Biểu đồ đường |
| **Use case** | Phân tích không gian | Phân tích thời gian |
## Troubleshooting
**Q: Lỗi "Model không tồn tại"?**
- Kiểm tra model đã được train và lưu trong `model_train/`
- Refresh danh sách model
**Q: Kết quả NDVI toàn NaN?**
- Check cloud cover (giảm xuống)
- Mở rộng time range
- Kiểm tra bbox có nằm trong phạm vi Sentinel-2 coverage
**Q: File GeoTIFF không mở được?**
- Đảm bảo file download hoàn chỉnh
- Dùng QGIS hoặc rasterio để kiểm tra
**Q: Prediction chậm?**
- Giảm resolution (20m thay vì 10m)
- Giảm max_scenes
- Thu nhỏ bbox
+654 -20
View File
@@ -122,6 +122,31 @@ class TrainingStatus(BaseModel):
end_time: Optional[str]
class NDVIConfig(BaseModel):
"""Cấu hình tính NDVI time series"""
bbox: List[float] # [min_lon, min_lat, max_lon, max_lat]
start_date: str
end_date: str
max_cloud_cover: int = 30
resolution: int = 20
class PredictionWithNDVIConfig(BaseModel):
"""Cấu hình predict kết hợp land classification và NDVI"""
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
export_ndvi: bool = True # Export NDVI raster
export_classification: bool = True # Export classification raster
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve main index page with tabs"""
@@ -173,6 +198,26 @@ async def dashboard():
raise HTTPException(status_code=404, detail="Dashboard không tồn tại")
@app.get("/batch", response_class=HTMLResponse)
async def batch_page():
"""Serve batch processing interface"""
html_file = Path(__file__).parent / "batch_interface.html"
if html_file.exists():
return FileResponse(html_file)
else:
raise HTTPException(status_code=404, detail="Batch interface không tồn tại")
@app.get("/ndvi", response_class=HTMLResponse)
async def ndvi_page():
"""Serve NDVI time series interface"""
html_file = Path(__file__).parent / "ndvi_interface.html"
if html_file.exists():
return FileResponse(html_file)
else:
raise HTTPException(status_code=404, detail="NDVI interface không tồn tại")
@app.get("/api/config/presets")
async def get_presets():
"""Lấy các preset cấu hình sẵn"""
@@ -398,14 +443,39 @@ async def list_reports():
else:
report_type = "unknown"
reports.append({
report_info = {
"filename": report_file.name,
"type": report_type,
"created": datetime.fromtimestamp(report_file.stat().st_mtime).isoformat(),
"size_kb": round(report_file.stat().st_size / 1024, 2),
"view_url": f"/api/reports/view/{report_file.name}",
"download_url": f"/api/reports/download/{report_file.name}"
})
"download_url": f"/api/reports/download/{report_file.name}",
"is_batch_job": False,
"batch_metadata": None
}
# Check if this is a batch job report
if report_type == "prediction":
predictions_dir = Path("predictions")
# Look for batch metadata JSON files that reference this report
for json_file in predictions_dir.glob("batch_*.json"):
try:
import json
with open(json_file, 'r') as f:
metadata = json.load(f)
if metadata.get("report_filename") == report_file.name or \
(metadata.get("batch_job_id") and report_file.name.endswith('.html')):
report_info["is_batch_job"] = True
report_info["batch_metadata"] = {
"batch_job_id": metadata.get("batch_job_id"),
"batch_name": metadata.get("batch_name"),
"batch_timestamp": metadata.get("batch_timestamp")
}
break
except Exception as e:
pass
reports.append(report_info)
# Sort by creation time (newest first)
reports.sort(key=lambda x: x["created"], reverse=True)
@@ -1025,12 +1095,37 @@ async def list_predictions():
predictions = []
for pred_file in predictions_dir.glob("*.tif"):
predictions.append({
pred_info = {
"filename": pred_file.name,
"created": datetime.fromtimestamp(pred_file.stat().st_mtime).isoformat(),
"size_mb": round(pred_file.stat().st_size / 1024 / 1024, 2),
"download_url": f"/api/predictions/download/{pred_file.name}"
})
"download_url": f"/api/predictions/download/{pred_file.name}",
"is_batch_job": pred_file.name.startswith("batch_"),
"batch_metadata": None
}
# Try to load batch metadata from JSON sidecar if exists
json_file = pred_file.with_suffix('.json')
if json_file.exists():
try:
import json
with open(json_file, 'r') as f:
metadata = json.load(f)
pred_info["batch_metadata"] = {
"batch_job_id": metadata.get("batch_job_id"),
"batch_name": metadata.get("batch_name"),
"batch_timestamp": metadata.get("batch_timestamp")
}
except Exception as e:
print(f"[METADATA ERROR] Failed to load {json_file}: {e}")
# Check PNG preview
png_file = pred_file.with_suffix('.png')
pred_info["has_preview"] = png_file.exists()
if png_file.exists():
pred_info["preview_url"] = f"/api/predictions/preview/{png_file.name}"
predictions.append(pred_info)
# Sort by creation time (newest first)
predictions.sort(key=lambda x: x["created"], reverse=True)
@@ -1366,6 +1461,8 @@ async def process_batch_queue():
"""Process batch prediction queue"""
global batch_queue, batch_results
import asyncio
while batch_queue:
# Get next job
job = None
@@ -1379,26 +1476,27 @@ async def process_batch_queue():
# Mark as running
job["status"] = "running"
job["progress"] = 0
job["started_at"] = datetime.now().isoformat()
try:
# Create PredictionConfig from job config
pred_config = PredictionConfig(**job["config"])
# Run prediction (simplified version)
# In real implementation, call the actual prediction function
print(f"[BATCH] Processing job: {job['name']}")
print(f"[BATCH] Processing job {job['job_id']}: {job['name']}")
job["progress"] = 5
# Simulate prediction (replace with actual prediction call)
# await run_prediction(pred_config)
# Run prediction synchronously (in the same thread to avoid conflicts)
await asyncio.to_thread(run_batch_prediction, job, pred_config)
# For now, mark as completed
job["status"] = "completed"
job["completed_at"] = datetime.now().isoformat()
job["result"] = {
"output_file": f"predictions/batch_{job['job_id']}.tif",
"message": "Prediction completed successfully"
}
# Check if prediction was successful
if job.get("result") and not job.get("error"):
job["status"] = "completed"
job["progress"] = 100
job["completed_at"] = datetime.now().isoformat()
print(f"[BATCH] Job {job['job_id']} completed successfully")
else:
raise Exception(job.get("error", "Unknown error during prediction"))
except Exception as e:
job["error"] = str(e)
@@ -1407,12 +1505,14 @@ async def process_batch_queue():
if job["retries"] < job["max_retries"]:
job["retries"] += 1
job["status"] = "queued" # Retry
print(f"[BATCH] Job {job['name']} failed, retrying ({job['retries']}/{job['max_retries']})")
job["progress"] = 0
print(f"[BATCH] Job {job['job_id']} ({job['name']}) failed, retrying ({job['retries']}/{job['max_retries']}): {e}")
continue
else:
job["status"] = "failed"
job["progress"] = 0
job["completed_at"] = datetime.now().isoformat()
print(f"[BATCH] Job {job['name']} failed permanently: {e}")
print(f"[BATCH] Job {job['job_id']} ({job['name']}) failed permanently: {e}")
# Move to results
batch_queue.remove(job)
@@ -1423,6 +1523,540 @@ async def process_batch_queue():
batch_results = batch_results[-100:]
def run_batch_prediction(job: dict, config: PredictionConfig):
"""Run prediction for a single batch job"""
try:
job["progress"] = 10
# Import required libraries
import xarray as xr
import numpy as np
from datetime import datetime as dt
import rioxarray
import dask.array as da
job["progress"] = 15
# Load model
model_path = Path("model_train") / config.model_filename
if not model_path.exists():
raise FileNotFoundError(f"Model không tồn tại: {config.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
job["progress"] = 20
# Check if CNN model
is_cnn_model = hasattr(model, '__class__') and 'CNN' in model.__class__.__name__
# Load data from 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,
)
bbox = [config.min_lon, config.min_lat, config.max_lon, config.max_lat]
time_range = f"{config.start_date}/{config.end_date}"
job["progress"] = 25
# Search 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")
s2_items = s2_items[:config.max_scenes]
job["progress"] = 35
# Load Sentinel-2 data
s2_data = load(
s2_items,
bbox=bbox,
chunks={"time": 1, "x": 2048, "y": 2048},
groupby="solar_day",
resolution=config.resolution
)
job["progress"] = 50
# Calculate NDVI
nir = s2_data["B08"].astype('float32')
red = s2_data["B04"].astype('float32')
ndvi = (nir - red) / (nir + red + 1e-8)
# Mask clouds if SCL available
if "SCL" in s2_data:
scl = s2_data["SCL"]
cloud_mask = (scl == 3) | (scl == 8) | (scl == 9) | (scl == 10)
ndvi = ndvi.where(~cloud_mask)
# Fill NaN and resample
ndvi_filled = ndvi.ffill(dim='time').bfill(dim='time')
ndvi_monthly = ndvi_filled.resample(time="1ME").mean().compute()
job["progress"] = 70
# Prepare features
n_times_ndvi = len(ndvi_monthly.time)
y_size = len(ndvi_monthly.y)
x_size = len(ndvi_monthly.x)
n_pixels = y_size * x_size
ndvi_features = []
for t in range(n_times_ndvi):
ndvi_t = ndvi_monthly.isel(time=t).values.flatten()
ndvi_features.append(ndvi_t)
features = np.column_stack(ndvi_features)
features = np.nan_to_num(features, nan=0.0)
job["progress"] = 80
# Adjust features to match model expectations
try:
if is_cnn_model:
expected_features = model.n_features
elif hasattr(model, 'n_features_in_'):
expected_features = model.n_features_in_
else:
try:
expected_features = model.get_booster().num_features()
except:
expected_features = features.shape[1]
if features.shape[1] > expected_features:
features = features[:, :expected_features]
elif features.shape[1] < expected_features:
n_missing = expected_features - features.shape[1]
padding = np.tile(features[:, -1:], (1, n_missing))
features = np.column_stack([features, padding])
except:
pass
# Predict
if is_cnn_model:
predictions = model.predict(features)
else:
predictions = model.predict(features)
# Decode labels
if label_encoder is not None:
try:
predictions = label_encoder.inverse_transform(predictions)
except:
pass
job["progress"] = 90
# Reshape and create output
pred_shape = (y_size, x_size)
predictions_2d = predictions.reshape(pred_shape)
prediction_da = xr.DataArray(
predictions_2d,
coords={"y": ndvi_monthly.y, "x": ndvi_monthly.x},
dims=["y", "x"],
name="classification"
)
# Save output
output_dir = Path("predictions")
output_dir.mkdir(exist_ok=True)
output_file = output_dir / f"batch_{job['job_id']}_{job['name'].replace(' ', '_')}.tif"
if hasattr(s2_data, 'rio') and s2_data.rio.crs is not None:
prediction_da.rio.write_crs(s2_data.rio.crs, inplace=True)
else:
prediction_da.rio.write_crs("EPSG:4326", inplace=True)
prediction_da.rio.to_raster(str(output_file), driver="GTiff")
# Generate PNG preview
png_file = output_dir / f"batch_{job['job_id']}_{job['name'].replace(' ', '_')}.png"
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 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)
cbar = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
cbar.set_label('Class', rotation=270, labelpad=15)
ax.grid(True, alpha=0.3, linestyle='--', linewidth=0.5)
plt.tight_layout()
plt.savefig(str(png_file), dpi=150, bbox_inches='tight')
plt.close(fig)
except Exception as e:
print(f"[BATCH PNG ERROR] {e}")
png_file = None
# Get unique classes
unique_classes = np.unique(predictions_2d)
unique_classes = unique_classes[~np.isnan(unique_classes)].tolist()
# Store result in job with batch metadata
job["result"] = {
"output_file": str(output_file),
"png_file": str(png_file) if png_file else None,
"shape": list(pred_shape),
"unique_classes": unique_classes,
"bbox": bbox,
"time_range": time_range,
"n_features": features.shape[1],
"n_times_ndvi": n_times_ndvi,
"model_used": config.model_filename,
"batch_job_id": job["job_id"],
"batch_name": job["name"],
"batch_timestamp": datetime.now().isoformat()
}
# Save batch metadata to JSON sidecar file for persistence
metadata_file = output_file.with_suffix('.json')
try:
import json
with open(metadata_file, 'w') as f:
json.dump(job["result"], f, indent=2, default=str)
print(f"[BATCH METADATA] Saved to {metadata_file}")
except Exception as e:
print(f"[BATCH METADATA ERROR] Failed to save metadata: {e}")
# Auto generate prediction report for batch job
try:
from report_generator import generate_prediction_report
report_path, _ = generate_prediction_report(job["result"])
job["result"]["report_path"] = report_path
job["result"]["report_filename"] = Path(report_path).name
print(f"[BATCH REPORT] Generated prediction report: {report_path}")
except Exception as e:
print(f"[BATCH REPORT ERROR] Failed to generate report: {e}")
job["progress"] = 100
except Exception as e:
job["error"] = str(e)
import traceback
print(f"[BATCH ERROR] Job {job['job_id']}: {traceback.format_exc()}")
# ============ PREDICTION WITH NDVI API ============
@app.post("/api/predict/with-ndvi")
async def predict_with_ndvi(config: PredictionWithNDVIConfig, background_tasks: BackgroundTasks):
"""Predict land classification và NDVI cho một khu vực"""
try:
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
# Load model
model_path = Path(f"model_train/{config.model_filename}")
if not model_path.exists():
raise HTTPException(status_code=404, detail=f"Model {config.model_filename} không tồn tại")
model = joblib.load(model_path)
print(f"[PREDICT+NDVI] Loaded model: {config.model_filename}")
# Connect to Microsoft Planetary Computer
catalog = Client.open(
"https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace
)
bbox = [config.min_lon, config.min_lat, config.max_lon, config.max_lat]
time_range = f"{config.start_date}/{config.end_date}"
# Search for Sentinel-2 data
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=bbox,
datetime=time_range,
query={"eo:cloud_cover": {"lt": config.cloud_cover}}
)
items = list(search.items())[:config.max_scenes]
print(f"[PREDICT+NDVI] Found {len(items)} Sentinel-2 scenes")
if len(items) == 0:
raise HTTPException(status_code=404, detail="Không tìm thấy dữ liệu vệ tinh")
# Load all bands needed for features
data = odc.stac.load(
items,
bbox=bbox,
bands=["B02", "B03", "B04", "B08"], # Blue, Green, Red, NIR
resolution=config.resolution,
chunks={"x": 2048, "y": 2048}
).compute()
print(f"[PREDICT+NDVI] Loaded data shape: {data.dims}")
# Calculate NDVI and other indices
blue = data["B02"].values
green = data["B03"].values
red = data["B04"].values
nir = data["B08"].values
# Calculate indices
# NDVI = (NIR - Red) / (NIR + Red)
ndvi = (nir - red) / (nir + red + 1e-8)
# NDWI = (Green - NIR) / (Green + NIR)
ndwi = (green - nir) / (green + nir + 1e-8)
# NDBI = (SWIR - NIR) / (SWIR + NIR) - we use Red as proxy
ndbi = (red - nir) / (red + nir + 1e-8)
# Prepare features for prediction
# Assuming model was trained with [NDVI, NDWI, NDBI] features
height, width = ndvi.shape[1:3] # Skip time dimension
n_pixels = height * width
# Average over time dimension
ndvi_mean = np.nanmean(ndvi, axis=0)
ndwi_mean = np.nanmean(ndwi, axis=0)
ndbi_mean = np.nanmean(ndbi, axis=0)
# Reshape for prediction
features = np.stack([ndvi_mean.flatten(), ndwi_mean.flatten(), ndbi_mean.flatten()], axis=1)
# Handle NaN values
valid_mask = ~np.isnan(features).any(axis=1)
features_clean = features[valid_mask]
print(f"[PREDICT+NDVI] Predicting {features_clean.shape[0]} valid pixels...")
# Predict
predictions = model.predict(features_clean)
# Reshape back to raster
prediction_raster = np.full(n_pixels, -1, dtype=np.int16)
prediction_raster[valid_mask] = predictions
prediction_raster = prediction_raster.reshape(height, width)
# Prepare outputs
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_dir = Path("predictions")
output_dir.mkdir(exist_ok=True)
output_files = []
# Export NDVI if requested
if config.export_ndvi:
ndvi_file = output_dir / f"ndvi_{timestamp}.tif"
transform = from_bounds(bbox[0], bbox[1], bbox[2], bbox[3], width, height)
with rasterio.open(
ndvi_file, 'w',
driver='GTiff',
height=height,
width=width,
count=1,
dtype=ndvi_mean.dtype,
crs='EPSG:4326',
transform=transform
) as dst:
dst.write(ndvi_mean, 1)
output_files.append({"type": "ndvi", "path": str(ndvi_file)})
print(f"[PREDICT+NDVI] Saved NDVI to {ndvi_file}")
# Export classification if requested
if config.export_classification:
class_file = output_dir / f"classification_{timestamp}.tif"
transform = from_bounds(bbox[0], bbox[1], bbox[2], bbox[3], width, height)
with rasterio.open(
class_file, 'w',
driver='GTiff',
height=height,
width=width,
count=1,
dtype=prediction_raster.dtype,
crs='EPSG:4326',
transform=transform
) as dst:
dst.write(prediction_raster, 1)
output_files.append({"type": "classification", "path": str(class_file)})
print(f"[PREDICT+NDVI] Saved classification to {class_file}")
# Calculate statistics
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))
}
# Count classes
unique_classes, counts = np.unique(predictions, return_counts=True)
class_distribution = {
int(cls): int(count) for cls, count in zip(unique_classes, counts)
}
return {
"success": True,
"message": "Prediction with NDVI completed",
"output_files": output_files,
"ndvi_stats": ndvi_stats,
"class_distribution": class_distribution,
"n_scenes": len(items),
"resolution": config.resolution,
"bbox": bbox
}
except Exception as e:
print(f"[PREDICT+NDVI ERROR] {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ============ NDVI TIME SERIES API ============
@app.post("/api/ndvi/timeseries")
async def calculate_ndvi_timeseries(config: NDVIConfig):
"""Tính NDVI time series cho một khu vực"""
try:
import numpy as np
import xarray as xr
from pystac_client import Client
import planetary_computer
import odc.stac
print(f"[NDVI] Starting calculation for bbox: {config.bbox}, time: {config.start_date} to {config.end_date}")
# Connect to Microsoft Planetary Computer STAC API
catalog = Client.open(
"https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace
)
bbox = config.bbox
time_range = f"{config.start_date}/{config.end_date}"
# Search for Sentinel-2 data
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=bbox,
datetime=time_range,
query={"eo:cloud_cover": {"lt": config.max_cloud_cover}}
)
items = list(search.items())
print(f"[NDVI] Found {len(items)} Sentinel-2 scenes")
if len(items) == 0:
raise HTTPException(status_code=404, detail="Không tìm thấy dữ liệu Sentinel-2 cho khu vực và thời gian này")
# Load data for each time step
ndvi_timeseries = []
for item in items:
try:
# Load NIR (B08) and Red (B04) bands
data = odc.stac.load(
[item],
bbox=bbox,
bands=["B04", "B08"], # Red and NIR
resolution=config.resolution,
chunks={"x": 2048, "y": 2048}
).compute()
if data is None or len(data.keys()) == 0:
continue
# Calculate NDVI = (NIR - Red) / (NIR + Red)
nir = data["B08"].values
red = data["B04"].values
# Avoid division by zero
denominator = nir + red
denominator = np.where(denominator == 0, np.nan, denominator)
ndvi = (nir - red) / denominator
# Calculate mean NDVI (ignore NaN values)
mean_ndvi = float(np.nanmean(ndvi))
# Get date from item
date_str = item.datetime.strftime("%Y-%m-%d")
ndvi_timeseries.append({
"date": date_str,
"ndvi": mean_ndvi
})
print(f"[NDVI] {date_str}: NDVI = {mean_ndvi:.3f}")
except Exception as e:
print(f"[NDVI WARNING] Failed to process item {item.id}: {e}")
continue
if len(ndvi_timeseries) == 0:
raise HTTPException(status_code=500, detail="Không thể tính NDVI cho bất kỳ ảnh nào")
# Sort by date
ndvi_timeseries.sort(key=lambda x: x["date"])
# Calculate statistics
ndvi_values = [item["ndvi"] for item in ndvi_timeseries]
mean_ndvi = float(np.mean(ndvi_values))
min_ndvi = float(np.min(ndvi_values))
max_ndvi = float(np.max(ndvi_values))
result = {
"timeseries": ndvi_timeseries,
"n_images": len(ndvi_timeseries),
"mean_ndvi": mean_ndvi,
"min_ndvi": min_ndvi,
"max_ndvi": max_ndvi,
"bbox": bbox,
"time_range": time_range
}
print(f"[NDVI] Calculation complete. Mean NDVI: {mean_ndvi:.3f}, Images: {len(ndvi_timeseries)}")
return result
except HTTPException:
raise
except Exception as e:
print(f"[NDVI ERROR] {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Lỗi khi tính NDVI: {str(e)}")
if __name__ == "__main__":
print("=" * 70)
print("🚀 LAND CLASSIFICATION TRAINING API SERVER")
+788
View File
@@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Batch Processing - Land Classification</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.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%);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 20px;
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: 2.5em;
margin-bottom: 10px;
}
.content {
padding: 30px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.section {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
}
.section h2 {
color: #667eea;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-weight: 600;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 1em;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-right: 10px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.batch-item {
background: white;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #667eea;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.batch-item.completed {
border-left-color: #28a745;
}
.batch-item.failed {
border-left-color: #dc3545;
}
.batch-item.running {
border-left-color: #ffc107;
}
.progress {
height: 25px;
background: #e0e0e0;
border-radius: 12px;
overflow: hidden;
margin-top: 10px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.9em;
}
.alert {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.alert-info {
background: #d1ecf1;
border-left: 4px solid #0c5460;
color: #0c5460;
}
.alert-success {
background: #d4edda;
border-left: 4px solid #155724;
color: #155724;
}
.jobs-list {
max-height: 500px;
overflow-y: auto;
}
#batchMap {
height: 400px;
border-radius: 10px;
margin-top: 15px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 Batch Processing</h1>
<p>Xử lý nhiều khu vực cùng lúc với model đã train</p>
</div>
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ</a>
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing (Active)</a>
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis</a>
</div>
<div class="content">
<!-- Configuration Section -->
<div class="section">
<h2>⚙️ Cấu hình Batch</h2>
<div class="form-group">
<label for="batchModelSelect">Model:</label>
<select id="batchModelSelect">
<option value="">Đang tải...</option>
</select>
</div>
<div class="form-group">
<label for="batchName">Tên khu vực:</label>
<input type="text" id="batchName" placeholder="Ví dụ: Khu vực A">
</div>
<div class="form-group">
<label>Bbox (từ bản đồ hoặc nhập thủ công):</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="number" id="batchMinLon" placeholder="Min Lon" step="0.0001">
<input type="number" id="batchMinLat" placeholder="Min Lat" step="0.0001">
<input type="number" id="batchMaxLon" placeholder="Max Lon" step="0.0001">
<input type="number" id="batchMaxLat" placeholder="Max Lat" step="0.0001">
</div>
</div>
<div class="form-group">
<label>Thời gian:</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="date" id="batchStartDate" value="2023-03-01">
<input type="date" id="batchEndDate" value="2023-05-31">
</div>
</div>
<button class="btn btn-primary" onclick="addBatchItem()">
Thêm vào Batch
</button>
<!-- Map for selecting bbox -->
<div id="batchMap"></div>
</div>
<!-- Batch Queue Section -->
<div class="section">
<h2>📋 Batch Queue (<span id="queueCount">0</span> items)</h2>
<div id="batchQueue" class="jobs-list">
<p style="text-align: center; color: #666;">Chưa có item nào. Thêm khu vực từ bên trái.</p>
</div>
<div style="margin-top: 20px;">
<button class="btn btn-success" onclick="startBatch()" id="startBatchBtn" disabled>
🚀 Start Batch Processing
</button>
<button class="btn btn-danger" onclick="clearBatchQueue()">
🗑️ Clear Queue
</button>
</div>
</div>
<!-- Status Section -->
<div class="section" style="grid-column: 1 / -1;">
<h2>📊 Batch Status</h2>
<div id="batchStatus" style="display: none;">
<div class="alert alert-info">
<p><strong>Batch ID:</strong> <span id="currentBatchId"></span></p>
<p><strong>Status:</strong> Queued: <span id="statusQueued">0</span> | Running: <span id="statusRunning">0</span> | Completed: <span id="statusCompleted">0</span> | Failed: <span id="statusFailed">0</span></p>
</div>
</div>
<div id="runningJobs" class="jobs-list">
<!-- Running jobs will appear here -->
</div>
</div>
<!-- Results Section -->
<div class="section" style="grid-column: 1 / -1;">
<h2>✅ Completed Results</h2>
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
<button class="btn btn-primary" onclick="loadAllBatchResults()" style="padding: 8px 20px;">
🔄 Refresh Results
</button>
<button class="btn btn-success" onclick="downloadAllResults()" style="padding: 8px 20px;">
📦 Download All (Bulk)
</button>
</div>
<div id="completedResults" class="jobs-list">
<p style="text-align: center; color: #666;">Chưa có kết quả nào</p>
</div>
</div>
</div>
</div>
<!-- Modal for large preview -->
<div id="previewModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 10000; padding: 20px;">
<div style="position: relative; height: 100%; display: flex; align-items: center; justify-content: center;">
<button onclick="closePreviewModal()" style="position: absolute; top: 20px; right: 20px; background: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-size: 18px; font-weight: bold;">
✕ Close
</button>
<img id="previewImage" style="max-width: 90%; max-height: 90%; border-radius: 10px;">
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script>
let map, drawnItems, drawControl;
let batchQueue = [];
let currentBatchId = null;
let statusCheckInterval = null;
// Initialize map
function initMap() {
map = L.map('batchMap').setView([9.5, 105.9], 9);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
drawControl = new L.Control.Draw({
draw: {
rectangle: true,
polygon: false,
circle: false,
marker: false,
polyline: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function(event) {
drawnItems.clearLayers();
const layer = event.layer;
drawnItems.addLayer(layer);
const bounds = layer.getBounds();
document.getElementById('batchMinLon').value = bounds.getWest().toFixed(4);
document.getElementById('batchMinLat').value = bounds.getSouth().toFixed(4);
document.getElementById('batchMaxLon').value = bounds.getEast().toFixed(4);
document.getElementById('batchMaxLat').value = bounds.getNorth().toFixed(4);
});
}
// Load models
async function loadModels() {
try {
const response = await fetch('/api/models/list');
const data = await response.json();
const select = document.getElementById('batchModelSelect');
select.innerHTML = '<option value="">Chọn model...</option>';
data.models.filter(m => m.filename.endsWith('.joblib')).forEach(model => {
const option = document.createElement('option');
option.value = model.filename;
option.textContent = `${model.filename} - ${model.created}`;
select.appendChild(option);
});
if (data.models.length > 0) {
select.value = data.models[0].filename;
}
} catch (error) {
console.error('Error loading models:', error);
}
}
// Add item to batch queue
function addBatchItem() {
const name = document.getElementById('batchName').value;
const minLon = parseFloat(document.getElementById('batchMinLon').value);
const minLat = parseFloat(document.getElementById('batchMinLat').value);
const maxLon = parseFloat(document.getElementById('batchMaxLon').value);
const maxLat = parseFloat(document.getElementById('batchMaxLat').value);
const startDate = document.getElementById('batchStartDate').value;
const endDate = document.getElementById('batchEndDate').value;
if (!name || isNaN(minLon) || isNaN(minLat) || isNaN(maxLon) || isNaN(maxLat)) {
alert('❌ Vui lòng điền đầy đủ thông tin!');
return;
}
const item = {
name,
min_lon: minLon,
min_lat: minLat,
max_lon: maxLon,
max_lat: maxLat,
start_date: startDate,
end_date: endDate,
max_scenes: 12,
cloud_cover: 30,
resolution: 20
};
batchQueue.push(item);
updateBatchQueueDisplay();
// Clear form
document.getElementById('batchName').value = '';
drawnItems.clearLayers();
}
// Update batch queue display
function updateBatchQueueDisplay() {
const queueDiv = document.getElementById('batchQueue');
const countSpan = document.getElementById('queueCount');
countSpan.textContent = batchQueue.length;
if (batchQueue.length === 0) {
queueDiv.innerHTML = '<p style="text-align: center; color: #666;">Chưa có item nào. Thêm khu vực từ bên trái.</p>';
document.getElementById('startBatchBtn').disabled = true;
return;
}
document.getElementById('startBatchBtn').disabled = false;
queueDiv.innerHTML = batchQueue.map((item, idx) => `
<div class="batch-item">
<div>
<strong>${item.name}</strong><br>
<small>Bbox: (${item.min_lon.toFixed(2)}, ${item.min_lat.toFixed(2)}) → (${item.max_lon.toFixed(2)}, ${item.max_lat.toFixed(2)})</small><br>
<small>Time: ${item.start_date}${item.end_date}</small>
</div>
<button class="btn btn-danger" style="padding: 5px 15px;" onclick="removeBatchItem(${idx})">
</button>
</div>
`).join('');
}
// Remove item from queue
function removeBatchItem(index) {
batchQueue.splice(index, 1);
updateBatchQueueDisplay();
}
// Clear batch queue
function clearBatchQueue() {
if (!confirm('Xóa tất cả items trong queue?')) return;
batchQueue = [];
updateBatchQueueDisplay();
}
// Start batch processing
async function startBatch() {
const modelFilename = document.getElementById('batchModelSelect').value;
if (!modelFilename) {
alert('❌ Vui lòng chọn model!');
return;
}
if (batchQueue.length === 0) {
alert('❌ Batch queue trống!');
return;
}
const config = {
model_filename: modelFilename,
items: batchQueue,
auto_retry: true,
max_retries: 3
};
try {
const response = await fetch('/api/batch/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (response.ok) {
currentBatchId = result.batch_id;
document.getElementById('currentBatchId').textContent = currentBatchId;
document.getElementById('batchStatus').style.display = 'block';
// Clear local queue
batchQueue = [];
updateBatchQueueDisplay();
// Start monitoring
startStatusCheck();
alert(`✅ Đã bắt đầu batch processing với ${result.total_jobs} jobs!`);
} else {
throw new Error(result.detail || 'Lỗi khi bắt đầu batch');
}
} catch (error) {
console.error('Error starting batch:', error);
alert('❌ Lỗi: ' + error.message);
}
}
// Check batch status
async function checkBatchStatus() {
try {
const response = await fetch('/api/batch/status');
const status = await response.json();
// Update status counts
document.getElementById('statusQueued').textContent = status.queue.queued;
document.getElementById('statusRunning').textContent = status.queue.running;
document.getElementById('statusCompleted').textContent = status.queue.completed;
document.getElementById('statusFailed').textContent = status.queue.failed;
// Update running jobs
const runningDiv = document.getElementById('runningJobs');
if (status.jobs.running.length > 0) {
runningDiv.innerHTML = status.jobs.running.map(job => {
const outputFile = job.result?.output_file || '';
const pngFile = job.result?.png_file || '';
const outputFilename = outputFile ? outputFile.split('/').pop() : '';
const pngFilename = pngFile ? pngFile.split('/').pop() : '';
return `
<div class="batch-item running">
<div style="flex: 1;">
<strong>${job.name}</strong> - <span style="color: #ffc107;">Running</span><br>
<small>Job ID: ${job.job_id}</small>
<div class="progress">
<div class="progress-bar" style="width: ${job.progress}%">${job.progress}%</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 200px; flex-shrink: 0;">
<button class="btn btn-success" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadResult('${outputFilename}')"
${outputFilename ? '' : 'disabled'}>
💾 Download GeoTIFF
</button>
<button class="btn btn-primary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadPNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🖼️ Download PNG
</button>
<button class="btn btn-secondary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="viewLargePNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🔍 View Preview
</button>
</div>
</div>
`;
}).join('');
} else {
runningDiv.innerHTML = '<p style="text-align: center; color: #666;">Không có job nào đang chạy</p>';
}
// Update completed results
const completedDiv = document.getElementById('completedResults');
if (status.jobs.recent_completed.length > 0) {
completedDiv.innerHTML = status.jobs.recent_completed.map(job => {
const outputFile = job.result?.output_file || '';
const pngFile = job.result?.png_file || '';
const outputFilename = outputFile ? outputFile.split('/').pop() : '';
const pngFilename = pngFile ? pngFile.split('/').pop() : '';
return `
<div class="batch-item completed">
<div style="flex: 1;">
<strong>${job.name}</strong> - <span style="color: #28a745;">✓ Completed</span><br>
<small>Job ID: ${job.job_id}</small><br>
<small>Completed: ${new Date(job.completed_at).toLocaleString()}</small><br>
<small><strong>Shape:</strong> ${job.result?.shape ? job.result.shape.join(' x ') : 'N/A'}</small><br>
<small><strong>Classes:</strong> ${job.result?.unique_classes ? job.result.unique_classes.join(', ') : 'N/A'}</small><br>
<small><strong>Features:</strong> ${job.result?.n_features || 'N/A'}</small><br>
<small><strong>Output:</strong> ${outputFilename || 'N/A'}</small><br>
<div style="margin-top: 10px;">
<img src="/api/predictions/preview/${pngFilename}"
style="max-width: 100%; max-height: 300px; border-radius: 5px; cursor: pointer; ${pngFilename ? '' : 'display:none;'}"
onclick="viewLargePNG('${pngFilename}')"
title="Click để xem lớn hơn"
onerror="this.style.display='none'">
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 200px; flex-shrink: 0;">
<button class="btn btn-success" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadResult('${outputFilename}')"
${outputFilename ? '' : 'disabled'}>
💾 Download GeoTIFF
</button>
<button class="btn btn-primary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadPNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🖼️ Download PNG
</button>
<button class="btn btn-secondary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="viewLargePNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🔍 View Preview
</button>
</div>
</div>
`;
}).join('');
} else {
completedDiv.innerHTML = '<p style="text-align: center; color: #666;">Chưa có kết quả nào</p>';
}
// Show failed jobs if any
if (status.jobs.recent_failed.length > 0) {
const failedHTML = status.jobs.recent_failed.map(job => {
const outputFile = job.result?.output_file || '';
const pngFile = job.result?.png_file || '';
const outputFilename = outputFile ? outputFile.split('/').pop() : '';
const pngFilename = pngFile ? pngFile.split('/').pop() : '';
return `
<div class="batch-item failed">
<div style="flex: 1;">
<strong>${job.name}</strong> - <span style="color: #dc3545;">✗ Failed</span><br>
<small>Job ID: ${job.job_id}</small><br>
<small style="color: #dc3545;">${job.error || 'Unknown error'}</small>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 200px; flex-shrink: 0;">
<button class="btn btn-success" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadResult('${outputFilename}')"
${outputFilename ? '' : 'disabled'}>
💾 Download GeoTIFF
</button>
<button class="btn btn-primary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="downloadPNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🖼️ Download PNG
</button>
<button class="btn btn-secondary" style="padding: 10px 20px; margin: 0; white-space: nowrap;"
onclick="viewLargePNG('${pngFilename}')"
${pngFilename ? '' : 'disabled'}>
🔍 View Preview
</button>
</div>
</div>
`;
}).join('');
completedDiv.innerHTML += '<h3 style="margin-top: 20px; color: #dc3545;">❌ Failed Jobs</h3>' + failedHTML;
}
// Stop checking if all done
if (status.queue.running === 0 && status.queue.queued === 0 && currentBatchId) {
stopStatusCheck();
alert('✅ Batch processing hoàn thành!');
}
} catch (error) {
console.error('Error checking batch status:', error);
}
}
// Download result
function downloadResult(filename) {
window.location.href = `/api/predictions/download/${filename}`;
}
// Download PNG
function downloadPNG(filename) {
window.location.href = `/api/predictions/preview/${filename}`;
}
// View large PNG in new window
function viewLargePNG(filename) {
const modal = document.getElementById('previewModal');
const img = document.getElementById('previewImage');
img.src = `/api/predictions/preview/${filename}`;
modal.style.display = 'block';
}
// Close preview modal
function closePreviewModal() {
document.getElementById('previewModal').style.display = 'none';
}
// Load all batch results
async function loadAllBatchResults() {
try {
const response = await fetch('/api/batch/status');
const status = await response.json();
const completedDiv = document.getElementById('completedResults');
// Combine recent_completed from status
const allCompleted = status.jobs.recent_completed || [];
if (allCompleted.length === 0) {
completedDiv.innerHTML = '<p style="text-align: center; color: #666;">Chưa có kết quả nào</p>';
return;
}
completedDiv.innerHTML = allCompleted.map(job => `
<div class="batch-item completed">
<div style="flex: 1;">
<strong>${job.name}</strong> - <span style="color: #28a745;">✓ Completed</span><br>
<small>Job ID: ${job.job_id}</small><br>
<small>Completed: ${new Date(job.completed_at).toLocaleString()}</small><br>
${job.result ? `
<small><strong>Shape:</strong> ${job.result.shape.join(' x ')}</small><br>
<small><strong>Classes:</strong> ${job.result.unique_classes.join(', ')}</small><br>
<small><strong>Features:</strong> ${job.result.n_features}</small><br>
<small><strong>Model:</strong> ${job.result.model_used}</small><br>
${job.result.png_file ? `
<div style="margin-top: 10px;">
<img src="/api/predictions/preview/${job.result.png_file.split('/').pop()}"
style="max-width: 100%; border-radius: 5px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);"
onclick="viewLargePNG('${job.result.png_file.split('/').pop()}')"
title="Click để xem lớn hơn">
</div>
` : ''}
` : ''}
</div>
<div style="display: flex; flex-direction: column; gap: 5px; min-width: 200px;">
${job.result && job.result.output_file ? `
<button class="btn btn-success" style="padding: 8px 20px;" onclick="downloadResult('${job.result.output_file.split('/').pop()}')">
💾 Download GeoTIFF
</button>
${job.result.png_file ? `
<button class="btn btn-primary" style="padding: 8px 20px;" onclick="downloadPNG('${job.result.png_file.split('/').pop()}')">
🖼️ Download PNG
</button>
<button class="btn btn-secondary" style="padding: 8px 20px;" onclick="viewLargePNG('${job.result.png_file.split('/').pop()}')">
🔍 View Preview
</button>
` : ''}
` : ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading batch results:', error);
}
}
// Download all results as ZIP (placeholder)
function downloadAllResults() {
alert('💡 Tính năng download tất cả batch results sẽ được thêm trong phiên bản tiếp theo.\\nHiện tại vui lòng download từng file riêng lẻ.');
}
// Start/stop status monitoring
function startStatusCheck() {
if (statusCheckInterval) clearInterval(statusCheckInterval);
statusCheckInterval = setInterval(checkBatchStatus, 3000);
}
function stopStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
}
// Initialize on load
window.onload = function() {
initMap();
loadModels();
};
// Cleanup on unload
window.onbeforeunload = function() {
stopStatusCheck();
};
</script>
</body>
</html>
+32 -23
View File
@@ -427,28 +427,12 @@
</div>
<!-- Navigation Tabs -->
<div class="nav-tabs">
<button class="nav-tab active" onclick="switchTab('home')">
🏠 Trang Chủ
</button>
<button class="nav-tab" onclick="switchTab('train')">
🎓 Training
</button>
<button class="nav-tab" onclick="switchTab('predict')">
🗺️ Prediction
</button>
<button class="nav-tab" onclick="switchTab('dashboard')">
📊 Dashboard
</button>
<button class="nav-tab" onclick="switchTab('models')">
🤖 Models
</button>
<button class="nav-tab" onclick="switchTab('reports')">
📄 Reports
</button>
<button class="nav-tab" onclick="switchTab('batch')">
🔄 Batch Processing
</button>
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ (Active)</a>
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis</a>
</div>
<!-- Tab Content: Home -->
@@ -810,8 +794,30 @@
const row = document.createElement('tr');
const typeIcon = report.type === 'training' ? '🎓' : '🗺️';
// Check if this is a batch job
const isBatchJob = report.is_batch_job;
const batchInfo = report.batch_metadata;
let batchLabel = '';
if (isBatchJob && batchInfo) {
const timestamp = batchInfo.batch_timestamp ?
new Date(batchInfo.batch_timestamp).toLocaleString('vi-VN') :
'N/A';
batchLabel = `
<div style="background: #fff3cd; padding: 5px 8px; border-radius: 4px; margin-top: 5px; font-size: 0.85em;">
<strong>🚀 Batch:</strong> ${batchInfo.batch_name || 'N/A'} |
<strong>ID:</strong> ${batchInfo.batch_job_id || 'N/A'}<br>
<strong>Thời gian:</strong> ${timestamp}
</div>
`;
}
row.innerHTML = `
<td><strong>${report.filename}</strong></td>
<td>
<strong>${report.filename}</strong>
${isBatchJob ? '<span style="background: #ffc107; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-left: 5px;">BATCH</span>' : ''}
${batchLabel}
</td>
<td>${typeIcon} ${report.type}</td>
<td>${new Date(report.created).toLocaleString('vi-VN')}</td>
<td>${report.size_kb} KB</td>
@@ -826,6 +832,9 @@
</button>
</td>
`;
if (isBatchJob) {
row.style.borderLeft = '4px solid #ffc107';
}
tbody.appendChild(row);
});
+568
View File
@@ -0,0 +1,568 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NDVI Time Series Analysis</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.nav-buttons {
background: white;
padding: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
border-bottom: 2px solid #e0e0e0;
}
.nav-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.content {
padding: 30px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.section {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
}
.section h2 {
color: #27ae60;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-weight: 600;
}
.form-group input, .form-group select {
width: 100%;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 1em;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#map {
height: 400px;
border-radius: 10px;
margin-top: 15px;
}
#chartContainer {
background: white;
padding: 20px;
border-radius: 10px;
grid-column: 1 / -1;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.stat-card {
background: white;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #27ae60;
}
.stat-card h3 {
color: #666;
font-size: 0.9em;
margin-bottom: 5px;
}
.stat-card p {
color: #27ae60;
font-size: 1.5em;
font-weight: bold;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.alert {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.alert-info {
background: #d1ecf1;
border-left: 4px solid #0c5460;
color: #0c5460;
}
.alert-warning {
background: #fff3cd;
border-left: 4px solid #856404;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌿 NDVI Time Series Analysis</h1>
<p>Phân tích chỉ số thực vật theo thời gian từ dữ liệu Sentinel-2</p>
</div>
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ</a>
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis (Active)</a>
</div>
<div class="content">
<!-- Configuration Section -->
<div class="section">
<h2>⚙️ Cấu hình phân tích</h2>
<div class="form-group">
<label>Bbox (vẽ trên bản đồ hoặc nhập thủ công):</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="number" id="minLon" placeholder="Min Longitude" step="0.0001" value="105.8">
<input type="number" id="minLat" placeholder="Min Latitude" step="0.0001" value="9.4">
<input type="number" id="maxLon" placeholder="Max Longitude" step="0.0001" value="106.0">
<input type="number" id="maxLat" placeholder="Max Latitude" step="0.0001" value="9.6">
</div>
</div>
<div class="form-group">
<label>Khoảng thời gian:</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="date" id="startDate" value="2023-01-01">
<input type="date" id="endDate" value="2023-12-31">
</div>
</div>
<div class="form-group">
<label for="maxCloudCover">Cloud Cover tối đa (%):</label>
<input type="number" id="maxCloudCover" value="30" min="0" max="100">
</div>
<div class="form-group">
<label for="resolution">Độ phân giải (m):</label>
<select id="resolution">
<option value="10">10m (chi tiết cao)</option>
<option value="20" selected>20m (cân bằng)</option>
<option value="60">60m (nhanh)</option>
</select>
</div>
<div class="alert alert-info">
<strong>💡 Lưu ý:</strong> NDVI = (NIR - Red) / (NIR + Red)<br>
Giá trị từ -1 đến 1. Giá trị cao = thực vật xanh tốt.
</div>
<button class="btn btn-primary" onclick="calculateNDVI()" id="calculateBtn">
📊 Tính NDVI Time Series
</button>
<!-- Map for selecting bbox -->
<div id="map"></div>
</div>
<!-- Results Section -->
<div class="section">
<h2>📈 Kết quả phân tích</h2>
<div id="loadingIndicator" style="display: none;" class="loading">
<p>⏳ Đang tải dữ liệu và tính toán NDVI...</p>
<div style="margin-top: 10px;">
<div style="width: 100%; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden;">
<div id="progressBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #2ecc71, #27ae60); transition: width 0.3s;"></div>
</div>
<p id="progressText" style="margin-top: 5px; font-size: 0.9em;"></p>
</div>
</div>
<div id="resultsContainer" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<h3>Số lượng ảnh</h3>
<p id="statImages">-</p>
</div>
<div class="stat-card">
<h3>NDVI trung bình</h3>
<p id="statAvgNDVI">-</p>
</div>
<div class="stat-card">
<h3>NDVI min</h3>
<p id="statMinNDVI">-</p>
</div>
<div class="stat-card">
<h3>NDVI max</h3>
<p id="statMaxNDVI">-</p>
</div>
</div>
<div style="margin-top: 20px;">
<button class="btn btn-success" onclick="downloadData()">
💾 Download CSV
</button>
<button class="btn btn-primary" onclick="downloadChart()" style="margin-left: 10px;">
📸 Download Chart (PNG)
</button>
</div>
</div>
<div id="errorContainer" style="display: none;" class="alert alert-warning">
<strong>⚠️ Lỗi:</strong> <span id="errorMessage"></span>
</div>
</div>
<!-- Chart Section -->
<div id="chartContainer" style="display: none;">
<h2 style="color: #27ae60; margin-bottom: 20px;">📊 NDVI Time Series</h2>
<canvas id="ndviChart"></canvas>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
let map, drawnItems, drawControl;
let ndviData = null;
let ndviChart = null;
// Initialize map
function initMap() {
map = L.map('map').setView([9.5, 105.9], 9);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
drawControl = new L.Control.Draw({
draw: {
rectangle: true,
polygon: false,
circle: false,
marker: false,
polyline: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, function(event) {
drawnItems.clearLayers();
const layer = event.layer;
drawnItems.addLayer(layer);
const bounds = layer.getBounds();
document.getElementById('minLon').value = bounds.getWest().toFixed(4);
document.getElementById('minLat').value = bounds.getSouth().toFixed(4);
document.getElementById('maxLon').value = bounds.getEast().toFixed(4);
document.getElementById('maxLat').value = bounds.getNorth().toFixed(4);
});
}
// Calculate NDVI time series
async function calculateNDVI() {
const minLon = parseFloat(document.getElementById('minLon').value);
const minLat = parseFloat(document.getElementById('minLat').value);
const maxLon = parseFloat(document.getElementById('maxLon').value);
const maxLat = parseFloat(document.getElementById('maxLat').value);
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const maxCloudCover = parseInt(document.getElementById('maxCloudCover').value);
const resolution = parseInt(document.getElementById('resolution').value);
// Validate inputs
if (isNaN(minLon) || isNaN(minLat) || isNaN(maxLon) || isNaN(maxLat)) {
showError('Vui lòng nhập đầy đủ tọa độ bbox!');
return;
}
if (!startDate || !endDate) {
showError('Vui lòng chọn khoảng thời gian!');
return;
}
// Show loading
document.getElementById('loadingIndicator').style.display = 'block';
document.getElementById('resultsContainer').style.display = 'none';
document.getElementById('chartContainer').style.display = 'none';
document.getElementById('errorContainer').style.display = 'none';
document.getElementById('calculateBtn').disabled = true;
const config = {
bbox: [minLon, minLat, maxLon, maxLat],
start_date: startDate,
end_date: endDate,
max_cloud_cover: maxCloudCover,
resolution: resolution
};
try {
const response = await fetch('/api/ndvi/timeseries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (response.ok) {
ndviData = result;
displayResults(result);
} else {
throw new Error(result.detail || 'Lỗi khi tính NDVI');
}
} catch (error) {
console.error('Error calculating NDVI:', error);
showError(error.message);
} finally {
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('calculateBtn').disabled = false;
}
}
// Display results
function displayResults(data) {
document.getElementById('resultsContainer').style.display = 'block';
document.getElementById('chartContainer').style.display = 'block';
// Update stats
document.getElementById('statImages').textContent = data.n_images;
document.getElementById('statAvgNDVI').textContent = data.mean_ndvi.toFixed(3);
document.getElementById('statMinNDVI').textContent = data.min_ndvi.toFixed(3);
document.getElementById('statMaxNDVI').textContent = data.max_ndvi.toFixed(3);
// Create chart
createChart(data);
}
// Create NDVI time series chart
function createChart(data) {
const ctx = document.getElementById('ndviChart').getContext('2d');
// Destroy existing chart if any
if (ndviChart) {
ndviChart.destroy();
}
const dates = data.timeseries.map(item => new Date(item.date).toLocaleDateString('vi-VN'));
const ndviValues = data.timeseries.map(item => item.ndvi);
ndviChart = new Chart(ctx, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: 'NDVI',
data: ndviValues,
borderColor: '#27ae60',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#27ae60',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
title: {
display: true,
text: 'NDVI Time Series - Biến động chỉ số thực vật theo thời gian',
font: {
size: 16,
weight: 'bold'
}
},
legend: {
display: true,
position: 'top'
},
tooltip: {
callbacks: {
label: function(context) {
return `NDVI: ${context.parsed.y.toFixed(3)}`;
}
}
}
},
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: 'NDVI Value'
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
title: {
display: true,
text: 'Ngày'
},
grid: {
display: false
}
}
}
}
});
}
// Download data as CSV
function downloadData() {
if (!ndviData) return;
let csv = 'Date,NDVI\n';
ndviData.timeseries.forEach(item => {
csv += `${item.date},${item.ndvi}\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ndvi_timeseries_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
// Download chart as PNG
function downloadChart() {
if (!ndviChart) return;
const url = ndviChart.toBase64Image();
const a = document.createElement('a');
a.href = url;
a.download = `ndvi_chart_${new Date().toISOString().split('T')[0]}.png`;
a.click();
}
// Show error message
function showError(message) {
document.getElementById('errorContainer').style.display = 'block';
document.getElementById('errorMessage').textContent = message;
}
// Initialize on page load
window.onload = function() {
initMap();
};
</script>
</body>
</html>
+426 -23
View File
@@ -292,7 +292,26 @@
<p>Phân loại đất cho khu vực mới sử dụng model đã train</p>
</div>
<div class="content">
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ</a>
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction (Active)</a>
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis</a>
</div>
<!-- Tab Navigation -->
<div style="background: white; padding: 15px; border-bottom: 2px solid #e0e0e0; display: flex; gap: 10px; justify-content: center;">
<button onclick="switchPredictTab('prediction')" id="tabPrediction" style="padding: 10px 20px; background: #4facfe; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;">
🗺️ Prediction
</button>
<button onclick="switchPredictTab('ndvi')" id="tabNDVI" style="padding: 10px 20px; background: #ccc; color: #666; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;">
🌿 NDVI Analysis
</button>
</div>
<!-- Prediction Content -->
<div id="predictionContent" class="content">
<!-- Map Section -->
<div class="map-container">
<div class="map-instructions">
@@ -362,8 +381,18 @@
</select>
</div>
<button class="btn btn-primary" onclick="startPrediction()" id="predictBtn">
🚀 Start Prediction
<div class="form-group" style="margin-top: 20px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #2196F3;">
<label style="display: flex; align-items: center; cursor: pointer; margin: 0;">
<input type="checkbox" id="exportNDVI" checked style="width: 18px; height: 18px; margin-right: 10px;">
<span style="font-weight: 600; color: #1976d2;">🌿 Export NDVI Raster</span>
</label>
<div style="font-size: 12px; color: #1976d2; margin-top: 8px; margin-left: 28px;">
✅ Xuất ra file GeoTIFF chứa giá trị NDVI cho toàn bộ khu vực
</div>
</div>
<button class="btn btn-primary" onclick="startPrediction()" id="predictBtn" style="margin-top: 15px;">
🚀 Start Prediction (với NDVI)
</button>
</div>
@@ -417,11 +446,103 @@
</div>
</div>
</div>
<!-- NDVI Analysis Content -->
<div id="ndviContent" class="content" style="display: none;">
<!-- NDVI Configuration -->
<div class="section">
<h2>⚙️ Cấu hình NDVI</h2>
<div class="form-group">
<label>Bbox (dùng bbox từ prediction hoặc nhập mới):</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<input type="number" id="ndviMinLon" placeholder="Min Lon" step="0.0001">
<input type="number" id="ndviMinLat" placeholder="Min Lat" step="0.0001">
<input type="number" id="ndviMaxLon" placeholder="Max Lon" step="0.0001">
<input type="number" id="ndviMaxLat" placeholder="Max Lat" step="0.0001">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="ndviStartDate">Từ ngày:</label>
<input type="date" id="ndviStartDate" value="2023-01-01">
</div>
<div class="form-group">
<label for="ndviEndDate">Đến ngày:</label>
<input type="date" id="ndviEndDate" value="2023-12-31">
</div>
</div>
<div class="form-group">
<label for="ndviCloudCover">Cloud Cover tối đa (%):</label>
<input type="number" id="ndviCloudCover" value="30" min="0" max="100">
</div>
<button class="btn btn-primary" onclick="calculateNDVI()" id="ndviCalculateBtn">
📊 Tính NDVI Time Series
</button>
<button class="btn btn-secondary" onclick="usePredictionBbox()" style="margin-left: 10px;">
📍 Dùng Bbox từ Prediction
</button>
</div>
<!-- NDVI Results -->
<div class="section">
<h2>📈 Kết quả NDVI</h2>
<div id="ndviLoading" style="display: none; text-align: center; padding: 20px;">
<p>⏳ Đang tải dữ liệu và tính toán NDVI...</p>
<div style="margin-top: 10px; width: 100%; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden;">
<div id="ndviProgressBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #2ecc71, #27ae60); transition: width 0.3s;"></div>
</div>
</div>
<div id="ndviResults" style="display: none;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 20px;">
<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;">
<h4 style="color: #666; font-size: 0.9em; margin-bottom: 5px;">Số ảnh</h4>
<p id="ndviStatImages" style="color: #2ecc71; font-size: 1.5em; font-weight: bold;">-</p>
</div>
<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;">
<h4 style="color: #666; font-size: 0.9em; margin-bottom: 5px;">NDVI TB</h4>
<p id="ndviStatAvg" style="color: #2ecc71; font-size: 1.5em; font-weight: bold;">-</p>
</div>
<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;">
<h4 style="color: #666; font-size: 0.9em; margin-bottom: 5px;">NDVI Min</h4>
<p id="ndviStatMin" style="color: #2ecc71; font-size: 1.5em; font-weight: bold;">-</p>
</div>
<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;">
<h4 style="color: #666; font-size: 0.9em; margin-bottom: 5px;">NDVI Max</h4>
<p id="ndviStatMax" style="color: #2ecc71; font-size: 1.5em; font-weight: bold;">-</p>
</div>
</div>
<div style="margin-top: 15px;">
<button class="btn btn-success" onclick="downloadNDVIData()">💾 Download CSV</button>
<button class="btn btn-primary" onclick="downloadNDVIChart()" style="margin-left: 10px;">📸 Download Chart</button>
</div>
</div>
<div id="ndviError" class="alert alert-danger" style="display: none;">
<h3>⚠️ Lỗi</h3>
<p id="ndviErrorMessage"></p>
</div>
</div>
<!-- NDVI Chart -->
<div class="section" id="ndviChartSection" style="grid-column: 1 / -1; display: none;">
<h2 style="color: #2ecc71; margin-bottom: 20px;">📊 NDVI Time Series</h2>
<canvas id="ndviChart"></canvas>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
// Map setup
@@ -430,6 +551,8 @@
let currentPredictionFile = null;
let currentReportFile = null;
let statusCheckInterval = null;
let ndviData = null;
let ndviChart = null;
// Initialize map
function initMap() {
@@ -594,6 +717,9 @@
return;
}
// Check if export NDVI is enabled
const exportNDVI = document.getElementById('exportNDVI').checked;
const config = {
model_filename: modelFilename,
min_lon: selectedBbox.min_lon,
@@ -604,7 +730,9 @@
end_date: document.getElementById('predEndDate').value,
max_scenes: parseInt(document.getElementById('predMaxScenes').value),
cloud_cover: parseInt(document.getElementById('predCloudCover').value),
resolution: parseInt(document.getElementById('predResolution').value)
resolution: parseInt(document.getElementById('predResolution').value),
export_ndvi: exportNDVI,
export_classification: true
};
try {
@@ -613,7 +741,10 @@
document.getElementById('predictionResult').style.display = 'none';
document.getElementById('predictionError').style.display = 'none';
const response = await fetch('/api/prediction/start', {
// Choose API endpoint based on NDVI export option
const endpoint = exportNDVI ? '/api/predict/with-ndvi' : '/api/prediction/start';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
@@ -622,8 +753,40 @@
const result = await response.json();
if (response.ok) {
// Start monitoring status
startStatusCheck();
if (exportNDVI) {
// Direct result for NDVI prediction
document.getElementById('predictionStatus').style.display = 'none';
document.getElementById('predictionResult').style.display = 'block';
// Display results
const outputFiles = result.output_files.map(f => f.path).join(', ');
document.getElementById('resultFile').textContent = outputFiles;
// Show NDVI stats
const statsHTML = `
<h4>🌿 NDVI Statistics:</h4>
<p>Mean: ${result.ndvi_stats.mean.toFixed(3)}</p>
<p>Min: ${result.ndvi_stats.min.toFixed(3)}</p>
<p>Max: ${result.ndvi_stats.max.toFixed(3)}</p>
<p>Std: ${result.ndvi_stats.std.toFixed(3)}</p>
<h4>📊 Class Distribution:</h4>
<p>${JSON.stringify(result.class_distribution, null, 2)}</p>
`;
document.getElementById('resultShape').innerHTML = statsHTML;
document.getElementById('resultClasses').textContent = `${result.n_scenes} scenes used`;
currentPredictionFile = result.output_files.find(f => f.type === 'classification')?.path || '';
currentReportFile = null;
document.getElementById('predictBtn').disabled = false;
// Reload predictions list
await loadPredictionsList();
} else {
// Start monitoring status for regular prediction
startStatusCheck();
}
} else {
throw new Error(result.detail || 'Lỗi khi bắt đầu prediction');
}
@@ -736,23 +899,52 @@
return;
}
listDiv.innerHTML = data.predictions.map(pred => `
<div class="prediction-item">
<div>
<strong>${pred.filename}</strong>
<br>
<small style="color: #666;">
${new Date(pred.created).toLocaleString('vi-VN')} - ${pred.size_mb} MB
</small>
listDiv.innerHTML = data.predictions.map(pred => {
const isBatchJob = pred.is_batch_job;
const batchInfo = pred.batch_metadata;
let batchLabel = '';
if (isBatchJob && batchInfo) {
const timestamp = batchInfo.batch_timestamp ?
new Date(batchInfo.batch_timestamp).toLocaleString('vi-VN') :
'N/A';
batchLabel = `
<div style="background: #fff3cd; padding: 5px 10px; border-radius: 5px; margin-top: 5px; border-left: 3px solid #ffc107;">
<small style="color: #856404;">
<strong>🚀 Batch Job:</strong> ${batchInfo.batch_name || 'N/A'}<br>
<strong>ID:</strong> ${batchInfo.batch_job_id || 'N/A'} |
<strong>Thời gian:</strong> ${timestamp}
</small>
</div>
`;
}
return `
<div class="prediction-item" style="border-left: ${isBatchJob ? '4px solid #ffc107' : '4px solid #667eea'};">
<div style="flex: 1;">
<strong>${pred.filename}</strong>
${isBatchJob ? '<span style="background: #ffc107; color: white; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; margin-left: 8px;">BATCH</span>' : ''}
<br>
<small style="color: #666;">
${new Date(pred.created).toLocaleString('vi-VN')} - ${pred.size_mb} MB
</small>
${batchLabel}
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="btn btn-success" style="padding: 8px 16px; font-size: 0.9em;"
onclick="window.location.href='${pred.download_url}'">
💾 Download GeoTIFF
</button>
${pred.has_preview ? `
<button class="btn btn-primary" style="padding: 8px 16px; font-size: 0.9em;"
onclick="window.open('${pred.preview_url}', '_blank')">
🖼️ View PNG
</button>
` : ''}
</div>
</div>
<div>
<button class="btn btn-success" style="padding: 8px 16px; font-size: 0.9em;"
onclick="window.location.href='${pred.download_url}'">
💾 Download
</button>
</div>
</div>
`).join('');
`;
}).join('');
} catch (error) {
console.error('Error loading predictions:', error);
}
@@ -798,6 +990,217 @@
window.onbeforeunload = function() {
stopStatusCheck();
};
// ============ NDVI FUNCTIONS ============
// Switch between prediction and NDVI tabs
function switchPredictTab(tab) {
if (tab === 'prediction') {
document.getElementById('predictionContent').style.display = 'grid';
document.getElementById('ndviContent').style.display = 'none';
document.getElementById('tabPrediction').style.background = '#4facfe';
document.getElementById('tabPrediction').style.color = 'white';
document.getElementById('tabNDVI').style.background = '#ccc';
document.getElementById('tabNDVI').style.color = '#666';
} else {
document.getElementById('predictionContent').style.display = 'none';
document.getElementById('ndviContent').style.display = 'grid';
document.getElementById('tabPrediction').style.background = '#ccc';
document.getElementById('tabPrediction').style.color = '#666';
document.getElementById('tabNDVI').style.background = '#2ecc71';
document.getElementById('tabNDVI').style.color = 'white';
}
}
// Use bbox from prediction form
function usePredictionBbox() {
if (!selectedBbox) {
alert('⚠️ Chưa chọn bbox trong phần Prediction. Vui lòng vẽ bbox trên bản đồ trước!');
return;
}
document.getElementById('ndviMinLon').value = selectedBbox[0].toFixed(4);
document.getElementById('ndviMinLat').value = selectedBbox[1].toFixed(4);
document.getElementById('ndviMaxLon').value = selectedBbox[2].toFixed(4);
document.getElementById('ndviMaxLat').value = selectedBbox[3].toFixed(4);
alert('✅ Đã copy bbox từ Prediction!');
}
// Calculate NDVI time series
async function calculateNDVI() {
const minLon = parseFloat(document.getElementById('ndviMinLon').value);
const minLat = parseFloat(document.getElementById('ndviMinLat').value);
const maxLon = parseFloat(document.getElementById('ndviMaxLon').value);
const maxLat = parseFloat(document.getElementById('ndviMaxLat').value);
const startDate = document.getElementById('ndviStartDate').value;
const endDate = document.getElementById('ndviEndDate').value;
const maxCloudCover = parseInt(document.getElementById('ndviCloudCover').value);
// Validate
if (isNaN(minLon) || isNaN(minLat) || isNaN(maxLon) || isNaN(maxLat)) {
showNDVIError('Vui lòng nhập đầy đủ tọa độ bbox!');
return;
}
if (!startDate || !endDate) {
showNDVIError('Vui lòng chọn khoảng thời gian!');
return;
}
// Show loading
document.getElementById('ndviLoading').style.display = 'block';
document.getElementById('ndviResults').style.display = 'none';
document.getElementById('ndviChartSection').style.display = 'none';
document.getElementById('ndviError').style.display = 'none';
document.getElementById('ndviCalculateBtn').disabled = true;
const config = {
bbox: [minLon, minLat, maxLon, maxLat],
start_date: startDate,
end_date: endDate,
max_cloud_cover: maxCloudCover,
resolution: 20
};
try {
const response = await fetch('/api/ndvi/timeseries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (response.ok) {
ndviData = result;
displayNDVIResults(result);
} else {
throw new Error(result.detail || 'Lỗi khi tính NDVI');
}
} catch (error) {
console.error('Error calculating NDVI:', error);
showNDVIError(error.message);
} finally {
document.getElementById('ndviLoading').style.display = 'none';
document.getElementById('ndviCalculateBtn').disabled = false;
}
}
// Display NDVI results
function displayNDVIResults(data) {
document.getElementById('ndviResults').style.display = 'block';
document.getElementById('ndviChartSection').style.display = 'block';
// Update stats
document.getElementById('ndviStatImages').textContent = data.n_images;
document.getElementById('ndviStatAvg').textContent = data.mean_ndvi.toFixed(3);
document.getElementById('ndviStatMin').textContent = data.min_ndvi.toFixed(3);
document.getElementById('ndviStatMax').textContent = data.max_ndvi.toFixed(3);
// Create chart
createNDVIChart(data);
}
// Create NDVI chart
function createNDVIChart(data) {
const ctx = document.getElementById('ndviChart').getContext('2d');
// Destroy existing chart
if (ndviChart) {
ndviChart.destroy();
}
const dates = data.timeseries.map(item => new Date(item.date).toLocaleDateString('vi-VN'));
const ndviValues = data.timeseries.map(item => item.ndvi);
ndviChart = new Chart(ctx, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: 'NDVI',
data: ndviValues,
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#2ecc71',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
title: {
display: true,
text: 'NDVI Time Series - Biến động chỉ số thực vật theo thời gian',
font: { size: 16, weight: 'bold' }
},
legend: {
display: true,
position: 'top'
},
tooltip: {
callbacks: {
label: function(context) {
return `NDVI: ${context.parsed.y.toFixed(3)}`;
}
}
}
},
scales: {
y: {
beginAtZero: false,
title: { display: true, text: 'NDVI Value' },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
title: { display: true, text: 'Ngày' },
grid: { display: false }
}
}
}
});
}
// Download NDVI data as CSV
function downloadNDVIData() {
if (!ndviData) return;
let csv = 'Date,NDVI\\n';
ndviData.timeseries.forEach(item => {
csv += `${item.date},${item.ndvi}\\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ndvi_timeseries_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
// Download NDVI chart as PNG
function downloadNDVIChart() {
if (!ndviChart) return;
const url = ndviChart.toBase64Image();
const a = document.createElement('a');
a.href = url;
a.download = `ndvi_chart_${new Date().toISOString().split('T')[0]}.png`;
a.click();
}
// Show NDVI error
function showNDVIError(message) {
document.getElementById('ndviError').style.display = 'block';
document.getElementById('ndviErrorMessage').textContent = message;
}
</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 - 20251221_181414</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 - 21/12/2025 18:14:14</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">1,222,118</div>
<div class="label">Tổng số Pixels</div>
</div>
<div class="stat-card">
<div class="value">1109x1102</div>
<div class="label">Kích thước (px)</div>
</div>
<div class="stat-card">
<div class="value">418.9</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.6, 9.3, 105.8, 9.5]</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/batch_20251221_181400_0_Region_1.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: 21/12/2025 18:14:14</p>
</div>
</div>
</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 - 20251221_181430</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 - 21/12/2025 18:14:30</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">1,223,220</div>
<div class="label">Tổng số Pixels</div>
</div>
<div class="stat-card">
<div class="value">1110x1102</div>
<div class="label">Kích thước (px)</div>
</div>
<div class="stat-card">
<div class="value">418.9</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.8, 9.3, 106.0, 9.5]</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/batch_20251221_181400_1_Region_2.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: 21/12/2025 18:14:30</p>
</div>
</div>
</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 - 20251221_181438</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 - 21/12/2025 18:14:38</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">1,223,220</div>
<div class="label">Tổng số Pixels</div>
</div>
<div class="stat-card">
<div class="value">1110x1102</div>
<div class="label">Kích thước (px)</div>
</div>
<div class="stat-card">
<div class="value">418.9</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>[106.0, 9.3, 106.2, 9.5]</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/batch_20251221_181400_2_Region_3.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: 21/12/2025 18:14:38</p>
</div>
</div>
</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 - 20251221_181454</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 - 21/12/2025 18:14:54</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">1,221,009</div>
<div class="label">Tổng số Pixels</div>
</div>
<div class="stat-card">
<div class="value">1109x1101</div>
<div class="label">Kích thước (px)</div>
</div>
<div class="stat-card">
<div class="value">418.9</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.6, 9.5, 105.8, 9.7]</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/batch_20251221_181400_3_Region_4.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: 21/12/2025 18:14:54</p>
</div>
</div>
</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 - 20251221_181504</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 - 21/12/2025 18:15:04</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">1,223,220</div>
<div class="label">Tổng số Pixels</div>
</div>
<div class="stat-card">
<div class="value">1110x1102</div>
<div class="label">Kích thước (px)</div>
</div>
<div class="stat-card">
<div class="value">418.9</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.8, 9.5, 106.0, 9.7]</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/batch_20251221_181400_4_Region_5.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: 21/12/2025 18:15:04</p>
</div>
</div>
</body>
</html>
+8
View File
@@ -290,6 +290,14 @@
<p>Giao diện training model phân loại đất từ ảnh vệ tinh</p>
</div>
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ</a>
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training (Active)</a>
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis</a>
</div>
<div class="content">
<!-- Status Section -->
<div class="section">