update chức năng ndvi time seriese
This commit is contained in:
@@ -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
@@ -122,6 +122,31 @@ class TrainingStatus(BaseModel):
|
|||||||
end_time: Optional[str]
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root():
|
async def root():
|
||||||
"""Serve main index page with tabs"""
|
"""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")
|
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")
|
@app.get("/api/config/presets")
|
||||||
async def get_presets():
|
async def get_presets():
|
||||||
"""Lấy các preset cấu hình sẵn"""
|
"""Lấy các preset cấu hình sẵn"""
|
||||||
@@ -398,14 +443,39 @@ async def list_reports():
|
|||||||
else:
|
else:
|
||||||
report_type = "unknown"
|
report_type = "unknown"
|
||||||
|
|
||||||
reports.append({
|
report_info = {
|
||||||
"filename": report_file.name,
|
"filename": report_file.name,
|
||||||
"type": report_type,
|
"type": report_type,
|
||||||
"created": datetime.fromtimestamp(report_file.stat().st_mtime).isoformat(),
|
"created": datetime.fromtimestamp(report_file.stat().st_mtime).isoformat(),
|
||||||
"size_kb": round(report_file.stat().st_size / 1024, 2),
|
"size_kb": round(report_file.stat().st_size / 1024, 2),
|
||||||
"view_url": f"/api/reports/view/{report_file.name}",
|
"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)
|
# Sort by creation time (newest first)
|
||||||
reports.sort(key=lambda x: x["created"], reverse=True)
|
reports.sort(key=lambda x: x["created"], reverse=True)
|
||||||
@@ -1025,12 +1095,37 @@ async def list_predictions():
|
|||||||
|
|
||||||
predictions = []
|
predictions = []
|
||||||
for pred_file in predictions_dir.glob("*.tif"):
|
for pred_file in predictions_dir.glob("*.tif"):
|
||||||
predictions.append({
|
pred_info = {
|
||||||
"filename": pred_file.name,
|
"filename": pred_file.name,
|
||||||
"created": datetime.fromtimestamp(pred_file.stat().st_mtime).isoformat(),
|
"created": datetime.fromtimestamp(pred_file.stat().st_mtime).isoformat(),
|
||||||
"size_mb": round(pred_file.stat().st_size / 1024 / 1024, 2),
|
"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)
|
# Sort by creation time (newest first)
|
||||||
predictions.sort(key=lambda x: x["created"], reverse=True)
|
predictions.sort(key=lambda x: x["created"], reverse=True)
|
||||||
@@ -1366,6 +1461,8 @@ async def process_batch_queue():
|
|||||||
"""Process batch prediction queue"""
|
"""Process batch prediction queue"""
|
||||||
global batch_queue, batch_results
|
global batch_queue, batch_results
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
while batch_queue:
|
while batch_queue:
|
||||||
# Get next job
|
# Get next job
|
||||||
job = None
|
job = None
|
||||||
@@ -1379,26 +1476,27 @@ async def process_batch_queue():
|
|||||||
|
|
||||||
# Mark as running
|
# Mark as running
|
||||||
job["status"] = "running"
|
job["status"] = "running"
|
||||||
|
job["progress"] = 0
|
||||||
job["started_at"] = datetime.now().isoformat()
|
job["started_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create PredictionConfig from job config
|
# Create PredictionConfig from job config
|
||||||
pred_config = PredictionConfig(**job["config"])
|
pred_config = PredictionConfig(**job["config"])
|
||||||
|
|
||||||
# Run prediction (simplified version)
|
print(f"[BATCH] Processing job {job['job_id']}: {job['name']}")
|
||||||
# In real implementation, call the actual prediction function
|
job["progress"] = 5
|
||||||
print(f"[BATCH] Processing job: {job['name']}")
|
|
||||||
|
|
||||||
# Simulate prediction (replace with actual prediction call)
|
# Run prediction synchronously (in the same thread to avoid conflicts)
|
||||||
# await run_prediction(pred_config)
|
await asyncio.to_thread(run_batch_prediction, job, pred_config)
|
||||||
|
|
||||||
# For now, mark as completed
|
# Check if prediction was successful
|
||||||
job["status"] = "completed"
|
if job.get("result") and not job.get("error"):
|
||||||
job["completed_at"] = datetime.now().isoformat()
|
job["status"] = "completed"
|
||||||
job["result"] = {
|
job["progress"] = 100
|
||||||
"output_file": f"predictions/batch_{job['job_id']}.tif",
|
job["completed_at"] = datetime.now().isoformat()
|
||||||
"message": "Prediction completed successfully"
|
print(f"[BATCH] Job {job['job_id']} completed successfully")
|
||||||
}
|
else:
|
||||||
|
raise Exception(job.get("error", "Unknown error during prediction"))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
job["error"] = str(e)
|
job["error"] = str(e)
|
||||||
@@ -1407,12 +1505,14 @@ async def process_batch_queue():
|
|||||||
if job["retries"] < job["max_retries"]:
|
if job["retries"] < job["max_retries"]:
|
||||||
job["retries"] += 1
|
job["retries"] += 1
|
||||||
job["status"] = "queued" # Retry
|
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
|
continue
|
||||||
else:
|
else:
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
|
job["progress"] = 0
|
||||||
job["completed_at"] = datetime.now().isoformat()
|
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
|
# Move to results
|
||||||
batch_queue.remove(job)
|
batch_queue.remove(job)
|
||||||
@@ -1423,6 +1523,540 @@ async def process_batch_queue():
|
|||||||
batch_results = batch_results[-100:]
|
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__":
|
if __name__ == "__main__":
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print("🚀 LAND CLASSIFICATION TRAINING API SERVER")
|
print("🚀 LAND CLASSIFICATION TRAINING API SERVER")
|
||||||
|
|||||||
@@ -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
@@ -427,28 +427,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Tabs -->
|
<!-- Navigation Tabs -->
|
||||||
<div class="nav-tabs">
|
<div style="background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;">
|
||||||
<button class="nav-tab active" onclick="switchTab('home')">
|
<a href="/" style="padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🏠 Trang Chủ (Active)</a>
|
||||||
🏠 Trang Chủ
|
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
|
||||||
</button>
|
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
|
||||||
<button class="nav-tab" onclick="switchTab('train')">
|
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
|
||||||
🎓 Training
|
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis</a>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content: Home -->
|
<!-- Tab Content: Home -->
|
||||||
@@ -810,8 +794,30 @@
|
|||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
const typeIcon = report.type === 'training' ? '🎓' : '🗺️';
|
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 = `
|
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>${typeIcon} ${report.type}</td>
|
||||||
<td>${new Date(report.created).toLocaleString('vi-VN')}</td>
|
<td>${new Date(report.created).toLocaleString('vi-VN')}</td>
|
||||||
<td>${report.size_kb} KB</td>
|
<td>${report.size_kb} KB</td>
|
||||||
@@ -826,6 +832,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
if (isBatchJob) {
|
||||||
|
row.style.borderLeft = '4px solid #ffc107';
|
||||||
|
}
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -292,7 +292,26 @@
|
|||||||
<p>Phân loại đất cho khu vực mới sử dụng model đã train</p>
|
<p>Phân loại đất cho khu vực mới sử dụng model đã train</p>
|
||||||
</div>
|
</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 -->
|
<!-- Map Section -->
|
||||||
<div class="map-container">
|
<div class="map-container">
|
||||||
<div class="map-instructions">
|
<div class="map-instructions">
|
||||||
@@ -362,8 +381,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="startPrediction()" id="predictBtn">
|
<div class="form-group" style="margin-top: 20px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #2196F3;">
|
||||||
🚀 Start Prediction
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,11 +446,103 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<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://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>
|
<script>
|
||||||
// Map setup
|
// Map setup
|
||||||
@@ -430,6 +551,8 @@
|
|||||||
let currentPredictionFile = null;
|
let currentPredictionFile = null;
|
||||||
let currentReportFile = null;
|
let currentReportFile = null;
|
||||||
let statusCheckInterval = null;
|
let statusCheckInterval = null;
|
||||||
|
let ndviData = null;
|
||||||
|
let ndviChart = null;
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
function initMap() {
|
function initMap() {
|
||||||
@@ -594,6 +717,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if export NDVI is enabled
|
||||||
|
const exportNDVI = document.getElementById('exportNDVI').checked;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
model_filename: modelFilename,
|
model_filename: modelFilename,
|
||||||
min_lon: selectedBbox.min_lon,
|
min_lon: selectedBbox.min_lon,
|
||||||
@@ -604,7 +730,9 @@
|
|||||||
end_date: document.getElementById('predEndDate').value,
|
end_date: document.getElementById('predEndDate').value,
|
||||||
max_scenes: parseInt(document.getElementById('predMaxScenes').value),
|
max_scenes: parseInt(document.getElementById('predMaxScenes').value),
|
||||||
cloud_cover: parseInt(document.getElementById('predCloudCover').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 {
|
try {
|
||||||
@@ -613,7 +741,10 @@
|
|||||||
document.getElementById('predictionResult').style.display = 'none';
|
document.getElementById('predictionResult').style.display = 'none';
|
||||||
document.getElementById('predictionError').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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
@@ -622,8 +753,40 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Start monitoring status
|
if (exportNDVI) {
|
||||||
startStatusCheck();
|
// 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 {
|
} else {
|
||||||
throw new Error(result.detail || 'Lỗi khi bắt đầu prediction');
|
throw new Error(result.detail || 'Lỗi khi bắt đầu prediction');
|
||||||
}
|
}
|
||||||
@@ -736,23 +899,52 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
listDiv.innerHTML = data.predictions.map(pred => `
|
listDiv.innerHTML = data.predictions.map(pred => {
|
||||||
<div class="prediction-item">
|
const isBatchJob = pred.is_batch_job;
|
||||||
<div>
|
const batchInfo = pred.batch_metadata;
|
||||||
<strong>${pred.filename}</strong>
|
|
||||||
<br>
|
let batchLabel = '';
|
||||||
<small style="color: #666;">
|
if (isBatchJob && batchInfo) {
|
||||||
${new Date(pred.created).toLocaleString('vi-VN')} - ${pred.size_mb} MB
|
const timestamp = batchInfo.batch_timestamp ?
|
||||||
</small>
|
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>
|
||||||
<div>
|
`;
|
||||||
<button class="btn btn-success" style="padding: 8px 16px; font-size: 0.9em;"
|
}).join('');
|
||||||
onclick="window.location.href='${pred.download_url}'">
|
|
||||||
💾 Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading predictions:', error);
|
console.error('Error loading predictions:', error);
|
||||||
}
|
}
|
||||||
@@ -798,6 +990,217 @@
|
|||||||
window.onbeforeunload = function() {
|
window.onbeforeunload = function() {
|
||||||
stopStatusCheck();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
||||||
@@ -290,6 +290,14 @@
|
|||||||
<p>Giao diện training model phân loại đất từ ảnh vệ tinh</p>
|
<p>Giao diện training model phân loại đất từ ảnh vệ tinh</p>
|
||||||
</div>
|
</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">
|
<div class="content">
|
||||||
<!-- Status Section -->
|
<!-- Status Section -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
Reference in New Issue
Block a user