384 lines
20 KiB
HTML
Executable File
384 lines
20 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="vi">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Change Detection - Compare Current vs Future Land Use</title>
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }
|
|
.container { max-width: 1400px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden; }
|
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
|
.header h1 { font-size: 32px; margin-bottom: 10px; }
|
|
.header p { font-size: 16px; opacity: 0.9; }
|
|
.content { padding: 30px; display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
|
.left-panel, .right-panel { display: flex; flex-direction: column; gap: 20px; }
|
|
#map { width: 100%; height: 400px; border-radius: 8px; border: 2px solid #e0e0e0; }
|
|
.section { background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #667eea; }
|
|
.section h2 { color: #333; font-size: 18px; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }
|
|
.form-group { margin-bottom: 15px; }
|
|
.form-group label { display: block; margin-bottom: 6px; color: #555; font-weight: 500; font-size: 14px; }
|
|
.form-group input[type="text"], .form-group input[type="date"], .form-group input[type="number"], .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; font-family: inherit; transition: all 0.3s ease; }
|
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); }
|
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
|
.bbox-display { background: white; padding: 12px; border-radius: 6px; font-size: 13px; color: #666; font-family: monospace; border: 1px dashed #667eea; word-break: break-all; }
|
|
.btn { padding: 12px 24px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; }
|
|
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
|
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
|
.result { background: white; border: 2px solid #e0e0e0; border-radius: 8px; padding: 20px; display: none; animation: slideIn 0.3s ease; max-height: 600px; overflow-y: auto; }
|
|
.result.success { border-color: #4caf50; background: #f1f8f5; }
|
|
.result.error { border-color: #f44336; background: #fdf5f4; }
|
|
.result.processing { border-color: #2196f3; background: #f3f8fd; }
|
|
.result h3 { margin-bottom: 15px; color: #333; }
|
|
.result table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
|
.result table th, .result table td { padding: 10px; text-align: left; border-bottom: 1px solid #e0e0e0; }
|
|
.result table th { background: #f0f0f0; font-weight: 600; color: #333; }
|
|
.result pre { background: #f5f5f5; padding: 15px; border-radius: 6px; overflow-x: auto; font-size: 12px; color: #333; max-height: 300px; overflow-y: auto; border-left: 4px solid #667eea; }
|
|
.error-text { color: #f44336; font-weight: 500; }
|
|
.success-text { color: #4caf50; font-weight: 500; }
|
|
.processing-text { color: #2196f3; font-weight: 500; }
|
|
.progress { width: 100%; height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin: 10px 0; }
|
|
.progress-bar { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); width: 0%; transition: width 0.3s ease; }
|
|
.stat-box { background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #667eea; margin: 10px 0; }
|
|
.stat-label { font-size: 12px; color: #999; text-transform: uppercase; margin-bottom: 5px; }
|
|
.stat-value { font-size: 20px; font-weight: 600; color: #333; }
|
|
.info-box { background: #e3f2fd; padding: 12px; border-radius: 6px; border-left: 4px solid #2196f3; font-size: 13px; color: #1565c0; }
|
|
@keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
|
@media (max-width: 1024px) { .content { grid-template-columns: 1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🔍 Change Detection - Land Use Analysis</h1>
|
|
<p>Compare current land use with predicted future changes</p>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- Left Panel -->
|
|
<div class="left-panel">
|
|
<div class="section">
|
|
<h2><span>🗺️</span>Select Area on Map</h2>
|
|
<p style="color: #999; font-size: 13px; margin-bottom: 10px;">Click on map to select bounding box</p>
|
|
<div id="map"></div>
|
|
<div class="form-group" style="margin-top: 10px;">
|
|
<label>BBox (min_lon, min_lat, max_lon, max_lat)</label>
|
|
<div class="bbox-display" id="bboxDisplay">Click on map to select area</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2><span>📅</span>Current Period (Baseline)</h2>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Start Date</label>
|
|
<input type="date" id="currentStartDate" value="2022-01-01">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>End Date</label>
|
|
<input type="date" id="currentEndDate" value="2022-03-31">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2><span>🔮</span>Prediction Period (Future)</h2>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Start Date</label>
|
|
<input type="date" id="predictionStartDate" value="2023-01-01">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>End Date</label>
|
|
<input type="date" id="predictionEndDate" value="2023-03-31">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2><span>⚙️</span>Parameters</h2>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Max Scenes</label>
|
|
<input type="number" id="maxScenes" value="12" min="1" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Cloud Cover %</label>
|
|
<input type="number" id="cloudCover" value="30" min="0" max="100">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Resolution (m)</label>
|
|
<input type="number" id="resolution" value="20" min="10" max="100" step="10">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Panel -->
|
|
<div class="right-panel">
|
|
<div class="section">
|
|
<h2><span>🤖</span>Select Trained Model</h2>
|
|
<div class="form-group">
|
|
<label>Trained Model</label>
|
|
<select id="modelSelect">
|
|
<option value="">Loading models...</option>
|
|
</select>
|
|
</div>
|
|
<div id="modelInfo" style="font-size: 12px; color: #999; margin-top: 10px;"></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2><span>�</span>Workflow</h2>
|
|
<div class="info-box">
|
|
1️⃣ Classify current period satellite data<br>
|
|
2️⃣ Classify future period satellite data<br>
|
|
3️⃣ Compare to detect land use changes
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<button class="btn btn-primary" id="runBtn" onclick="runChangeDetection()" disabled>
|
|
<span>▶️</span>Compare Periods
|
|
</button>
|
|
</div>
|
|
|
|
<div id="resultDiv" class="result"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
const API_BASE = 'http://localhost:8000/api';
|
|
let map, rectangle;
|
|
let bbox = null;
|
|
|
|
function initMap() {
|
|
map = L.map('map').setView([9.8, 105.85], 10);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
const defaultBbox = [105.6, 9.3, 106.2, 9.8];
|
|
drawBboxRectangle(defaultBbox);
|
|
|
|
map.on('click', function(e) {
|
|
const size = 0.3;
|
|
const bounds = L.latLngBounds([
|
|
[e.latlng.lat - size, e.latlng.lng - size],
|
|
[e.latlng.lat + size, e.latlng.lng + size]
|
|
]);
|
|
drawBboxRectangle([bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]);
|
|
});
|
|
}
|
|
|
|
function drawBboxRectangle(bboxArray) {
|
|
const [minLon, minLat, maxLon, maxLat] = bboxArray;
|
|
if (rectangle) map.removeLayer(rectangle);
|
|
|
|
rectangle = L.rectangle([[minLat, minLon], [maxLat, maxLon]], {
|
|
color: '#667eea', weight: 2, fillColor: '#667eea', fillOpacity: 0.1
|
|
}).addTo(map);
|
|
|
|
map.fitBounds(rectangle.getBounds());
|
|
bbox = bboxArray;
|
|
document.getElementById('bboxDisplay').textContent =
|
|
`[${minLon.toFixed(4)}, ${minLat.toFixed(4)}, ${maxLon.toFixed(4)}, ${maxLat.toFixed(4)}]`;
|
|
updateRunButtonState();
|
|
}
|
|
|
|
async function loadModels() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/models/list`);
|
|
const data = await response.json();
|
|
|
|
const modelSelect = document.getElementById('modelSelect');
|
|
modelSelect.innerHTML = '<option value="">-- Select a model --</option>';
|
|
|
|
if (data.models && data.models.length > 0) {
|
|
data.models.forEach(model => {
|
|
const option = document.createElement('option');
|
|
option.value = model.filename;
|
|
option.textContent = `${model.filename} (${model.size_mb}MB)`;
|
|
modelSelect.appendChild(option);
|
|
});
|
|
} else {
|
|
modelSelect.innerHTML = '<option value="">No trained models found</option>';
|
|
}
|
|
|
|
modelSelect.addEventListener('change', () => {
|
|
updateModelInfo();
|
|
updateRunButtonState();
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading models:', error);
|
|
document.getElementById('modelSelect').innerHTML = '<option value="">Error loading models</option>';
|
|
}
|
|
}
|
|
|
|
function updateModelInfo() {
|
|
const modelName = document.getElementById('modelSelect').value;
|
|
document.getElementById('modelInfo').textContent = modelName ? `Selected: ${modelName}` : '';
|
|
}
|
|
|
|
function updateRunButtonState() {
|
|
const runBtn = document.getElementById('runBtn');
|
|
runBtn.disabled = !bbox || !document.getElementById('modelSelect').value;
|
|
}
|
|
|
|
async function runChangeDetection() {
|
|
const resultDiv = document.getElementById('resultDiv');
|
|
const runBtn = document.getElementById('runBtn');
|
|
|
|
if (!bbox) {
|
|
showResult('error', 'Error', 'Please select an area on the map');
|
|
return;
|
|
}
|
|
|
|
const modelFilename = document.getElementById('modelSelect').value;
|
|
if (!modelFilename) {
|
|
showResult('error', 'Error', 'Please select a trained model');
|
|
return;
|
|
}
|
|
|
|
runBtn.disabled = true;
|
|
showResult('processing', 'Processing', 'Analyzing land use changes...');
|
|
|
|
try {
|
|
const [minLon, minLat, maxLon, maxLat] = bbox;
|
|
const currentStartDate = document.getElementById('currentStartDate').value;
|
|
const currentEndDate = document.getElementById('currentEndDate').value;
|
|
const predictionStartDate = document.getElementById('predictionStartDate').value;
|
|
const predictionEndDate = document.getElementById('predictionEndDate').value;
|
|
const maxScenes = parseInt(document.getElementById('maxScenes').value);
|
|
const cloudCover = parseInt(document.getElementById('cloudCover').value);
|
|
const resolution = parseInt(document.getElementById('resolution').value);
|
|
|
|
showResult('processing', 'Step 1/3', 'Classifying current period (baseline)...');
|
|
|
|
const payload = {
|
|
model_filename: modelFilename,
|
|
min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat,
|
|
current_period: {
|
|
start_date: currentStartDate,
|
|
end_date: currentEndDate
|
|
},
|
|
prediction_period: {
|
|
start_date: predictionStartDate,
|
|
end_date: predictionEndDate
|
|
},
|
|
max_scenes: maxScenes,
|
|
cloud_cover: cloudCover,
|
|
resolution: resolution,
|
|
export_ndvi: true,
|
|
export_classification: true
|
|
};
|
|
|
|
const changeResponse = await fetch(`${API_BASE}/change-detection/compare-periods`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!changeResponse.ok) {
|
|
const errorData = await changeResponse.json();
|
|
throw new Error(errorData.detail || 'Analysis failed');
|
|
}
|
|
|
|
const changeResult = await changeResponse.json();
|
|
displayResults(changeResult);
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showResult('error', 'Error', error.message);
|
|
} finally {
|
|
runBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function displayResults(result) {
|
|
const resultDiv = document.getElementById('resultDiv');
|
|
let html = '<h3 class="success-text">✓ Change Detection Completed</h3>';
|
|
|
|
// Current period classification
|
|
if (result.current_classification) {
|
|
const curr = result.current_classification;
|
|
html += '<div class="stat-box"><div class="stat-label">📊 Current Period Classification</div>';
|
|
html += `<div style="color: #666; font-size: 12px; margin-bottom: 10px;">Scenes: ${curr.n_scenes} | Resolution: ${curr.resolution}m</div>`;
|
|
|
|
if (curr.class_distribution) {
|
|
html += '<table>';
|
|
Object.entries(curr.class_distribution).forEach(([cls, count]) => {
|
|
const percentage = ((count / Object.values(curr.class_distribution).reduce((a,b) => a+b, 0)) * 100).toFixed(1);
|
|
html += `<tr><td>Class ${cls}:</td><td><strong>${count}</strong> (${percentage}%)</td></tr>`;
|
|
});
|
|
html += '</table>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
// Prediction period classification
|
|
if (result.prediction_classification) {
|
|
const pred = result.prediction_classification;
|
|
html += '<div class="stat-box"><div class="stat-label">🔮 Prediction Period Classification</div>';
|
|
html += `<div style="color: #666; font-size: 12px; margin-bottom: 10px;">Scenes: ${pred.n_scenes} | Resolution: ${pred.resolution}m</div>`;
|
|
|
|
if (pred.class_distribution) {
|
|
html += '<table>';
|
|
Object.entries(pred.class_distribution).forEach(([cls, count]) => {
|
|
const percentage = ((count / Object.values(pred.class_distribution).reduce((a,b) => a+b, 0)) * 100).toFixed(1);
|
|
html += `<tr><td>Class ${cls}:</td><td><strong>${count}</strong> (${percentage}%)</td></tr>`;
|
|
});
|
|
html += '</table>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
// Change detection
|
|
if (result.change_detection) {
|
|
const cd = result.change_detection;
|
|
html += '<div class="stat-box"><div class="stat-label">🔄 Change Detection Summary</div>';
|
|
html += `<div class="stat-value" style="color: #e74c3c;">${(cd.change_rate * 100).toFixed(2)}% Changed</div>`;
|
|
html += '<table>';
|
|
html += '<tr><td>Changed Pixels:</td><td><strong>' + cd.n_changed_pixels.toLocaleString() + '</strong></td></tr>';
|
|
html += '<tr><td>Total Pixels:</td><td><strong>' + cd.n_total_pixels.toLocaleString() + '</strong></td></tr>';
|
|
html += '</table>';
|
|
|
|
if (Object.keys(cd.change_matrix).length > 0) {
|
|
html += '<div style="margin-top: 10px;"><strong>Transitions (Current → Prediction):</strong></div>';
|
|
html += '<pre>' + JSON.stringify(cd.change_matrix, null, 2) + '</pre>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
resultDiv.innerHTML = html;
|
|
resultDiv.className = 'result success';
|
|
resultDiv.style.display = 'block';
|
|
}
|
|
|
|
function showResult(type, title, message) {
|
|
const resultDiv = document.getElementById('resultDiv');
|
|
const typeClass = type === 'error' ? 'error' : (type === 'processing' ? 'processing' : 'success');
|
|
const textClass = type === 'error' ? 'error-text' : (type === 'processing' ? 'processing-text' : 'success-text');
|
|
|
|
resultDiv.innerHTML = `<h3 class="${textClass}">${title}</h3><p>${message}</p>` +
|
|
(type === 'processing' ? '<div class="progress"><div class="progress-bar" style="animation: progress 2s infinite;"></div></div>' : '');
|
|
resultDiv.className = `result ${typeClass}`;
|
|
resultDiv.style.display = 'block';
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initMap();
|
|
loadModels();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|