This commit is contained in:
Felix Delattre 2026-04-11 19:25:42 +02:00
parent ec3aac3ec3
commit db14a71228
7 changed files with 374 additions and 108 deletions

57
webapp/common.js Normal file
View file

@ -0,0 +1,57 @@
/** Shared GeoTIFF → canvas for RGB (≥3 bands) or grayscale (1 band). Requires global GeoTIFF. */
async function geotiffToCanvasDataUrl(arrayBuffer) {
const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer);
const image = await tiff.getImage();
const rasters = await image.readRasters();
const width = image.getWidth();
const height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey
? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}`
: geoKeys.GeographicTypeGeoKey !== 4326
? `EPSG:${geoKeys.GeographicTypeGeoKey}`
: "EPSG:4326";
const normalize = (arr) => {
let min = Infinity,
max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) {
min = Math.min(min, v);
max = Math.max(max, v);
}
return arr.map((v) => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const spp = typeof image.getSamplesPerPixel === "function" ? image.getSamplesPerPixel() : 0;
const multi = spp >= 3 || (rasters[1] != null && rasters[2] != null);
const b0 = Array.from(rasters[0]);
let rN, gN, bN;
if (multi) {
const blue = b0,
green = Array.from(rasters[1]),
red = Array.from(rasters[2]);
rN = normalize(red);
gN = normalize(green);
bN = normalize(blue);
} else {
const g = normalize(b0);
rN = g;
gN = g;
bN = g;
}
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else {
imgData.data[idx] = rN[i];
imgData.data[idx + 1] = gN[i];
imgData.data[idx + 2] = bN[i];
imgData.data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
return { dataUrl: canvas.toDataURL(), bbox, crsCode };
}

View file

@ -5,6 +5,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<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://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script> <script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="common.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script> <script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<style> <style>
body { margin: 0; font-family: sans-serif; } body { margin: 0; font-family: sans-serif; }
@ -42,6 +43,7 @@
<a href="prepared.html">Prepared</a> <a href="prepared.html">Prepared</a>
<a href="fusion.html" class="active">Fusion</a> <a href="fusion.html" class="active">Fusion</a>
<a href="postprocessed.html">Postprocessed</a> <a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html">Metrics</a>
</div> </div>
<h1 id="siteName">Innsbruck</h1> <h1 id="siteName">Innsbruck</h1>
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div> <div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
@ -60,11 +62,16 @@
<option value="20">σ=20</option> <option value="20">σ=20</option>
<option value="30">σ=30</option> <option value="30">σ=30</option>
</select> </select>
<label>Mode:</label>
<select id="fusionModeSelect" title="BtI = reflectance fusion; ItB = GCC fusion">
<option value="bti">BtI (REFL)</option>
<option value="itb">ItB (GCC)</option>
</select>
</div> </div>
<input type="range" id="dateSlider" min="0" max="365" value="0"> <input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div> <div id="dateDisplay">2024-01-01</div>
</div> </div>
<div class="map-label">Fusion RGB (closest available)</div> <div class="map-label" id="mapLabelFusion">Fusion RGB (closest available)</div>
<div id="mapDate" class="map-date"></div> <div id="mapDate" class="map-date"></div>
<div id="fusionMap"></div> <div id="fusionMap"></div>
<div id="plots"> <div id="plots">
@ -81,7 +88,7 @@
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
let siteName = "innsbruck", season = "2024"; let siteName = "innsbruck", season = "2024";
let strategy = "aggressive", sigma = "20"; let strategy = "aggressive", sigma = "20", fusionMode = "bti";
let sitePosition = [47.116171, 11.320308]; let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1); let start = new Date(2024, 0, 1);
let availableSiteSeasons = {}; let availableSiteSeasons = {};
@ -98,10 +105,13 @@
return Math.floor((new Date(y, m - 1, d) - start) / 86400000); return Math.floor((new Date(y, m - 1, d) - start) / 86400000);
}; };
function getPreparedBase() {
return fusionMode === "itb" ? `prepared_${strategy}_itb` : `prepared_${strategy}`;
}
function getFusionDir() { function getFusionDir() {
return sigma === "30" const sub = sigma === "30" ? "fusion_sigma30" : "fusion";
? `data/${siteName}/${season}/prepared_${strategy}/fusion_sigma30` return `data/${siteName}/${season}/${getPreparedBase()}/${sub}`;
: `data/${siteName}/${season}/prepared_${strategy}/fusion`;
} }
function getFusionTimeseriesDir() { function getFusionTimeseriesDir() {
@ -110,15 +120,28 @@
async function loadTimeseries() { async function loadTimeseries() {
const sub = getFusionTimeseriesDir(); const sub = getFusionTimeseriesDir();
const base = `data/${siteName}/${season}/${getPreparedBase()}`;
try { try {
const base = `data/${siteName}/${season}/prepared_${strategy}`; if (fusionMode === "itb") {
const [n, g, b] = await Promise.all([ const g = await fetch(`${base}/gcc/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : []));
fetch(`${base}/ndvi/${sub}/timeseries.json`).then(r => r.ok ? r.json() : []), ndviTs = [];
fetch(`${base}/gcc/${sub}/timeseries.json`).then(r => r.ok ? r.json() : []), gccTs = g;
fetch(`${base}/bands/${sub}/timeseries.json`).then(r => r.ok ? r.json() : []) bandsTs = [];
]); } else {
ndviTs = n; gccTs = g; bandsTs = b; const [n, g, b] = await Promise.all([
} catch { ndviTs = []; gccTs = []; bandsTs = []; } fetch(`${base}/ndvi/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
fetch(`${base}/gcc/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
fetch(`${base}/bands/${sub}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
]);
ndviTs = n;
gccTs = g;
bandsTs = b;
}
} catch {
ndviTs = [];
gccTs = [];
bandsTs = [];
}
drawPlots(); drawPlots();
updateDownloadLinks(); updateDownloadLinks();
} }
@ -170,7 +193,12 @@
const el = document.getElementById("downloadLinks"); const el = document.getElementById("downloadLinks");
if (!el) return; if (!el) return;
const sub = getFusionTimeseriesDir(); const sub = getFusionTimeseriesDir();
const base = `data/${siteName}/${season}/prepared_${strategy}/export/${sub}`; const prep = `data/${siteName}/${season}/${getPreparedBase()}`;
if (fusionMode === "itb") {
el.innerHTML = `<a href="${prep}/gcc/${sub}/timeseries.json">[GCC JSON]</a>`;
return;
}
const base = `${prep}/export/${sub}`;
const name = `${siteName}_${season}_fusion_${strategy}_${sub}`; const name = `${siteName}_${season}_fusion_${strategy}_${sub}`;
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`; el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
} }
@ -185,7 +213,7 @@
const d = new Date(target.getTime() + dir * offset * 86400000); const d = new Date(target.getTime() + dir * offset * 86400000);
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue; if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
const ds = d.toISOString().split("T")[0].replace(/-/g, ""); const ds = d.toISOString().split("T")[0].replace(/-/g, "");
const filename = `REFL_${ds}.tif`; const filename = (fusionMode === "itb" ? "GCC_" : "REFL_") + `${ds}.tif`;
try { try {
const res = await fetch(`${getFusionDir()}/${filename}`, { method: "HEAD" }); const res = await fetch(`${getFusionDir()}/${filename}`, { method: "HEAD" });
if (res.ok) return filename; if (res.ok) return filename;
@ -203,34 +231,11 @@
async function loadGeotiff(filename) { async function loadGeotiff(filename) {
const path = `${getFusionDir()}/${filename}`; const path = `${getFusionDir()}/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer()); const buf = await (await fetch(path)).arrayBuffer();
const image = await tiff.getImage(); const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
const rasters = await image.readRasters();
const width = image.getWidth(), height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
(geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326");
const [blue, green, red] = [0, 1, 2].map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rN, gN, bN] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[i]; imgData.data[idx + 3] = 255; }
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode); const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
const dateStr = filename.replace("REFL_", "").replace(".tif", ""); const dateStr = filename.replace(/^(REFL|GCC)_/, "").replace(".tif", "");
return { dataUrl: canvas.toDataURL(), bounds, dateStr }; return { dataUrl, bounds, dateStr };
} }
async function updateMap() { async function updateMap() {
@ -281,6 +286,7 @@
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("site", siteName); params.set("site", siteName);
params.set("season", season); params.set("season", season);
params.set("mode", fusionMode);
history.replaceState({}, "", `?${params}`); history.replaceState({}, "", `?${params}`);
const urlDate = params.get("date"); const urlDate = params.get("date");
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate); if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
@ -328,8 +334,12 @@
document.getElementById("seasonSelect").value = initialSeason; document.getElementById("seasonSelect").value = initialSeason;
strategy = urlParams.get("strategy") || "aggressive"; strategy = urlParams.get("strategy") || "aggressive";
sigma = urlParams.get("sigma") || "20"; sigma = urlParams.get("sigma") || "20";
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
document.getElementById("strategySelect").value = strategy; document.getElementById("strategySelect").value = strategy;
document.getElementById("sigmaSelect").value = sigma; document.getElementById("sigmaSelect").value = sigma;
document.getElementById("fusionModeSelect").value = fusionMode;
const ml = document.getElementById("mapLabelFusion");
if (ml) ml.textContent = fusionMode === "itb" ? "Fusion GCC grayscale (closest available)" : "Fusion RGB (closest available)";
const initSite = getSiteBySitename(initialSite); const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) { if (initSite?.geometry?.coordinates) {
@ -362,6 +372,14 @@
history.replaceState({}, "", `?${urlParams}`); history.replaceState({}, "", `?${urlParams}`);
loadTimeseries(); updateMap(); loadTimeseries(); updateMap();
}); });
document.getElementById("fusionModeSelect").addEventListener("change", function() {
fusionMode = this.value;
urlParams.set("mode", fusionMode);
history.replaceState({}, "", `?${urlParams}`);
const ml = document.getElementById("mapLabelFusion");
if (ml) ml.textContent = fusionMode === "itb" ? "Fusion GCC grayscale (closest available)" : "Fusion RGB (closest available)";
loadTimeseries(); updateMap();
});
await setSiteSeason(initialSite, initialSeason); await setSiteSeason(initialSite, initialSeason);
} }

View file

@ -53,6 +53,7 @@
<a href="prepared.html">Prepared</a> <a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a> <a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a> <a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html">Metrics</a>
</div> </div>
<div class="slider-container"> <div class="slider-container">
<input type="range" id="dateSlider" min="0" max="365" value="0"> <input type="range" id="dateSlider" min="0" max="365" value="0">

173
webapp/metrics.html Normal file
View file

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Metrics</title>
<style>
body { margin: 0; font-family: sans-serif; }
.nav { margin-bottom: 15px; font-size: 14px; }
.nav a { margin-right: 12px; color: #0066cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav a.active { font-weight: bold; }
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
.selectors { margin-bottom: 20px; }
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; margin-top: 24px; color: #333; }
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 12px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.empty { color: #666; font-style: italic; }
.err { color: #a00; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="index.html">Full</a>
<a href="preselection.html">Pre-selection</a>
<a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html" class="active">Metrics</a>
</div>
<h1 id="siteName">Metrics</h1>
<div class="selectors">
<label>Site:</label>
<select id="siteSelect"></select>
<label>Season:</label>
<select id="seasonSelect"></select>
</div>
<div id="content"></div>
</div>
<script>
const METRIC_COLS = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse", "n_samples"];
let siteName = "innsbruck", season = "2024";
let availableSiteSeasons = {};
const urlParams = new URLSearchParams(location.search);
async function probeMetrics(sn, s) {
try {
const res = await fetch(`data/${sn}/${s}/metrics.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
function fmt(v) {
if (v == null || typeof v !== "number") return "—";
return Number.isInteger(v) ? String(v) : v.toFixed(4);
}
function tableSection(title, obj) {
if (!obj || typeof obj !== "object" || !Object.keys(obj).length) {
return `<h2>${title}</h2><p class="empty">No data</p>`;
}
const keys = Object.keys(obj).sort();
let head = `<tr><th>Scenario</th>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr>`;
const rows = keys.map((k) => {
const m = obj[k] || {};
return `<tr><td>${k}</td>${METRIC_COLS.map((c) => `<td class="num">${fmt(m[c])}</td>`).join("")}</tr>`;
}).join("");
return `<h2>${title}</h2><table>${head}${rows}</table>`;
}
function render(data) {
const el = document.getElementById("content");
if (!data) {
el.innerHTML = `<p class="err">Could not load metrics.json</p>`;
return;
}
let html = "";
if (data.phenocam_stats) {
html += `<h2>PhenoCam</h2><table><tr><th>mean</th><th>std</th><th>min</th><th>max</th><th>n</th></tr><tr>`;
const p = data.phenocam_stats;
html += `<td class="num">${fmt(p.mean)}</td><td class="num">${fmt(p.std)}</td><td class="num">${fmt(p.min)}</td><td class="num">${fmt(p.max)}</td><td class="num">${fmt(p.n_samples)}</td></tr></table>`;
}
if (data.baseline && data.baseline.s2) {
html += `<h2>Baseline S2 (temporal)</h2><table><tr>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr><tr>`;
const b = data.baseline.s2;
html += METRIC_COLS.map((c) => `<td class="num">${fmt(b[c])}</td>`).join("") + "</tr></table>";
}
html += tableSection("Temporal (vs PhenoCam)", data.temporal);
html += tableSection("Spatial (3×3 fusion mean vs PhenoCam)", data.spatial);
if (data.summary) {
html += `<h2>Summary</h2><pre style="font-size:13px;background:#f9f9f9;padding:10px;">${JSON.stringify(data.summary, null, 2)}</pre>`;
}
el.innerHTML = html || `<p class="empty">Empty metrics file</p>`;
}
async function load() {
try {
const res = await fetch(`data/${siteName}/${season}/metrics.json`);
render(res.ok ? await res.json() : null);
} catch {
render(null);
}
const site = window.sitesData?.features?.find((f) => f.properties?.sitename === siteName);
document.getElementById("siteName").textContent = (site?.properties?.description || siteName) + " — " + season;
urlParams.set("site", siteName);
urlParams.set("season", season);
history.replaceState({}, "", `?${urlParams}`);
}
async function init() {
try {
const res = await fetch("data/sites.geojson");
window.sitesData = res.ok ? await res.json() : { features: [] };
} catch { window.sitesData = { features: [] }; }
const features = window.sitesData.features || [];
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeMetrics(sn, s)) withData.push(s);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
const siteSelect = document.getElementById("siteSelect");
siteSelect.innerHTML = "";
(availableSites.length ? availableSites.sort() : ["innsbruck"]).forEach((sn) => {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
if (!availableSiteSeasons[sn]) availableSiteSeasons[sn] = ["2024"];
});
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = urlSite && availableSiteSeasons[urlSite] ? urlSite : availableSites[0] || "innsbruck";
const initialSeason =
urlSeason && (availableSiteSeasons[initialSite] || []).includes(urlSeason)
? urlSeason
: (availableSiteSeasons[initialSite] || [])[0] || "2024";
siteSelect.value = initialSite;
document.getElementById("seasonSelect").innerHTML = (availableSiteSeasons[initialSite] || [])
.map((s) => `<option value="${s}">${s}</option>`)
.join("");
document.getElementById("seasonSelect").value = initialSeason;
siteName = initialSite;
season = initialSeason;
siteSelect.addEventListener("change", function () {
const sn = this.value;
const seas = availableSiteSeasons[sn] || [];
document.getElementById("seasonSelect").innerHTML = seas.map((s) => `<option value="${s}">${s}</option>`).join("");
document.getElementById("seasonSelect").value = seas[0] || "2024";
siteName = sn;
season = document.getElementById("seasonSelect").value;
load();
});
document.getElementById("seasonSelect").addEventListener("change", function () {
season = this.value;
load();
});
await load();
}
init();
</script>
</body>
</html>

View file

@ -5,6 +5,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<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://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script> <script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="common.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script> <script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<style> <style>
body { margin: 0; font-family: sans-serif; } body { margin: 0; font-family: sans-serif; }
@ -42,6 +43,7 @@
<a href="prepared.html">Prepared</a> <a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a> <a href="fusion.html">Fusion</a>
<a href="postprocessed.html" class="active">Postprocessed</a> <a href="postprocessed.html" class="active">Postprocessed</a>
<a href="metrics.html">Metrics</a>
</div> </div>
<h1 id="siteName">Innsbruck</h1> <h1 id="siteName">Innsbruck</h1>
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div> <div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
@ -66,6 +68,11 @@
<option value="fusion">Fusion</option> <option value="fusion">Fusion</option>
<option value="s3">S3</option> <option value="s3">S3</option>
</select> </select>
<label>Mode:</label>
<select id="fusionModeSelect" title="BtI vs ItB processed paths">
<option value="bti">BtI</option>
<option value="itb">ItB</option>
</select>
</div> </div>
<input type="range" id="dateSlider" min="0" max="365" value="0"> <input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div> <div id="dateDisplay">2024-01-01</div>
@ -87,7 +94,7 @@
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
let siteName = "innsbruck", season = "2024"; let siteName = "innsbruck", season = "2024";
let strategy = "aggressive", sigma = "20", source = "s2"; let strategy = "aggressive", sigma = "20", source = "s2", fusionMode = "bti";
let sitePosition = [47.116171, 11.320308]; let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1); let start = new Date(2024, 0, 1);
let availableSiteSeasons = {}; let availableSiteSeasons = {};
@ -105,18 +112,26 @@
}; };
function getProcessedPath() { function getProcessedPath() {
return `data/${siteName}/${season}/processed_${strategy}_sigma${sigma}`; const mid = fusionMode === "itb" ? `processed_${strategy}_itb_sigma${sigma}` : `processed_${strategy}_sigma${sigma}`;
return `data/${siteName}/${season}/${mid}`;
} }
async function loadTimeseries() { async function loadTimeseries() {
const base = getProcessedPath();
try { try {
const [n, g, b] = await Promise.all([ const [n, g, b] = await Promise.all([
fetch(`${getProcessedPath()}/ndvi/${source}/timeseries.json`).then(r => r.ok ? r.json() : []), fetch(`${base}/ndvi/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
fetch(`${getProcessedPath()}/gcc/${source}/timeseries.json`).then(r => r.ok ? r.json() : []), fetch(`${base}/gcc/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
fetch(`${getProcessedPath()}/bands/${source}/timeseries.json`).then(r => r.ok ? r.json() : []) fetch(`${base}/bands/${source}/timeseries.json`).then((r) => (r.ok ? r.json() : [])),
]); ]);
ndviTs = n; gccTs = g; bandsTs = b; ndviTs = n;
} catch { ndviTs = []; gccTs = []; bandsTs = []; } gccTs = g;
bandsTs = b;
} catch {
ndviTs = [];
gccTs = [];
bandsTs = [];
}
drawPlots(); drawPlots();
updateDownloadLinks(); updateDownloadLinks();
} }
@ -167,7 +182,12 @@
function updateDownloadLinks() { function updateDownloadLinks() {
const el = document.getElementById("downloadLinks"); const el = document.getElementById("downloadLinks");
if (!el) return; if (!el) return;
const base = `data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/export/${source}`; const root = getProcessedPath();
if (fusionMode === "itb") {
el.innerHTML = `<a href="${root}/gcc/${source}/timeseries.json">[GCC JSON]</a>`;
return;
}
const base = `${root}/export/${source}`;
const name = `${siteName}_${season}_postprocessed_${strategy}_sigma${sigma}_${source}`; const name = `${siteName}_${season}_postprocessed_${strategy}_sigma${sigma}_${source}`;
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`; el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
} }
@ -200,34 +220,11 @@
async function loadGeotiff(filename) { async function loadGeotiff(filename) {
const path = `${getProcessedPath()}/${source}/${filename}`; const path = `${getProcessedPath()}/${source}/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer()); const buf = await (await fetch(path)).arrayBuffer();
const image = await tiff.getImage(); const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
const rasters = await image.readRasters();
const width = image.getWidth(), height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
(geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326");
const [blue, green, red] = [0, 1, 2].map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rN, gN, bN] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[i]; imgData.data[idx + 3] = 255; }
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode); const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
const dateStr = filename.replace("_0.geotiff", ""); const dateStr = filename.replace("_0.geotiff", "");
return { dataUrl: canvas.toDataURL(), bounds, dateStr }; return { dataUrl, bounds, dateStr };
} }
async function updateMap() { async function updateMap() {
@ -278,6 +275,7 @@
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("site", siteName); params.set("site", siteName);
params.set("season", season); params.set("season", season);
params.set("mode", fusionMode);
history.replaceState({}, "", `?${params}`); history.replaceState({}, "", `?${params}`);
const urlDate = params.get("date"); const urlDate = params.get("date");
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate); if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
@ -326,9 +324,11 @@
strategy = urlParams.get("strategy") || "aggressive"; strategy = urlParams.get("strategy") || "aggressive";
sigma = urlParams.get("sigma") || "20"; sigma = urlParams.get("sigma") || "20";
source = urlParams.get("source") || "s2"; source = urlParams.get("source") || "s2";
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
document.getElementById("strategySelect").value = strategy; document.getElementById("strategySelect").value = strategy;
document.getElementById("sigmaSelect").value = sigma; document.getElementById("sigmaSelect").value = sigma;
document.getElementById("sourceSelect").value = source; document.getElementById("sourceSelect").value = source;
document.getElementById("fusionModeSelect").value = fusionMode;
const initSite = getSiteBySitename(initialSite); const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) { if (initSite?.geometry?.coordinates) {
@ -367,6 +367,12 @@
history.replaceState({}, "", `?${urlParams}`); history.replaceState({}, "", `?${urlParams}`);
loadTimeseries(); updateMap(); loadTimeseries(); updateMap();
}); });
document.getElementById("fusionModeSelect").addEventListener("change", function() {
fusionMode = this.value;
urlParams.set("mode", fusionMode);
history.replaceState({}, "", `?${urlParams}`);
loadTimeseries(); updateMap();
});
await setSiteSeason(initialSite, initialSeason); await setSiteSeason(initialSite, initialSeason);
} }

View file

@ -5,6 +5,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<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://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script> <script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="common.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script> <script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<style> <style>
body { margin: 0; font-family: sans-serif; } body { margin: 0; font-family: sans-serif; }
@ -42,6 +43,7 @@
<a href="prepared.html" class="active">Prepared</a> <a href="prepared.html" class="active">Prepared</a>
<a href="fusion.html">Fusion</a> <a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a> <a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html">Metrics</a>
</div> </div>
<h1 id="siteName">Innsbruck</h1> <h1 id="siteName">Innsbruck</h1>
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div> <div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>
@ -57,8 +59,13 @@
</select> </select>
<label>Source:</label> <label>Source:</label>
<select id="sourceSelect"> <select id="sourceSelect">
<option value="s2">S2 REFL</option> <option value="s2">S2</option>
<option value="s3">S3 Composite</option> <option value="s3">S3</option>
</select>
<label>Mode:</label>
<select id="fusionModeSelect" title="BtI = REFL/composite; ItB = GCC rasters">
<option value="bti">BtI</option>
<option value="itb">ItB</option>
</select> </select>
</div> </div>
<input type="range" id="dateSlider" min="0" max="365" value="0"> <input type="range" id="dateSlider" min="0" max="365" value="0">
@ -81,7 +88,7 @@
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
let siteName = "innsbruck", season = "2024"; let siteName = "innsbruck", season = "2024";
let strategy = "aggressive", source = "s2"; let strategy = "aggressive", source = "s2", fusionMode = "bti";
let sitePosition = [47.116171, 11.320308]; let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1); let start = new Date(2024, 0, 1);
let availableSiteSeasons = {}; let availableSiteSeasons = {};
@ -99,7 +106,8 @@
}; };
function getPreparedPath() { function getPreparedPath() {
return `data/${siteName}/${season}/prepared_${strategy}`; const mid = fusionMode === "itb" ? `prepared_${strategy}_itb` : `prepared_${strategy}`;
return `data/${siteName}/${season}/${mid}`;
} }
async function loadTimeseries() { async function loadTimeseries() {
@ -161,7 +169,12 @@
function updateDownloadLinks() { function updateDownloadLinks() {
const el = document.getElementById("downloadLinks"); const el = document.getElementById("downloadLinks");
if (!el) return; if (!el) return;
const base = `data/${siteName}/${season}/prepared_${strategy}/export/${source}`; const root = getPreparedPath();
if (fusionMode === "itb") {
el.innerHTML = `<a href="${root}/gcc/${source}/timeseries.json">[GCC JSON]</a>`;
return;
}
const base = `${root}/export/${source}`;
const name = `${siteName}_${season}_prepared_${strategy}_${source}`; const name = `${siteName}_${season}_prepared_${strategy}_${source}`;
el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`; el.innerHTML = `<a href="${base}/timeseries.json" download="${name}.json">[JSON]</a><a href="${base}/timeseries.csv" download="${name}.csv">[CSV]</a>`;
} }
@ -176,7 +189,12 @@
const d = new Date(target.getTime() + dir * offset * 86400000); const d = new Date(target.getTime() + dir * offset * 86400000);
if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue; if (d.getTime() < seasonStart || d.getTime() > seasonEnd) continue;
const ds = d.toISOString().split("T")[0].replace(/-/g, ""); const ds = d.toISOString().split("T")[0].replace(/-/g, "");
const filename = source === "s2" ? `S2A_MSIL2A_${ds}_REFL.tif` : `composite_${ds}.tif`; const filename =
source === "s2"
? fusionMode === "itb"
? `S2A_MSIL2A_${ds}_GCC.tif`
: `S2A_MSIL2A_${ds}_REFL.tif`
: `composite_${ds}.tif`;
try { try {
const res = await fetch(`${getPreparedPath()}/${source}/${filename}`, { method: "HEAD" }); const res = await fetch(`${getPreparedPath()}/${source}/${filename}`, { method: "HEAD" });
if (res.ok) return filename; if (res.ok) return filename;
@ -194,33 +212,12 @@
async function loadGeotiff(filename) { async function loadGeotiff(filename) {
const path = `${getPreparedPath()}/${source}/${filename}`; const path = `${getPreparedPath()}/${source}/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer()); const buf = await (await fetch(path)).arrayBuffer();
const image = await tiff.getImage(); const { dataUrl, bbox, crsCode } = await geotiffToCanvasDataUrl(buf);
const rasters = await image.readRasters();
const width = image.getWidth(), height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
(geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326");
const [blue, green, red] = [0, 1, 2].map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rN, gN, bN] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[i]; imgData.data[idx + 3] = 255; }
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode); const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
return { dataUrl: canvas.toDataURL(), bounds, dateStr: filename.includes("composite") ? filename.replace("composite_", "").replace(".tif", "") : filename.replace("S2A_MSIL2A_", "").replace("_REFL.tif", "") }; const m = filename.match(/(\d{8})/);
const dateStr = m ? m[1] : "";
return { dataUrl, bounds, dateStr };
} }
async function updateMap() { async function updateMap() {
@ -271,6 +268,7 @@
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("site", siteName); params.set("site", siteName);
params.set("season", season); params.set("season", season);
params.set("mode", fusionMode);
history.replaceState({}, "", `?${params}`); history.replaceState({}, "", `?${params}`);
const urlDate = params.get("date"); const urlDate = params.get("date");
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate); if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
@ -318,8 +316,12 @@
document.getElementById("seasonSelect").value = initialSeason; document.getElementById("seasonSelect").value = initialSeason;
strategy = urlParams.get("strategy") || "aggressive"; strategy = urlParams.get("strategy") || "aggressive";
source = urlParams.get("source") || "s2"; source = urlParams.get("source") || "s2";
fusionMode = urlParams.get("mode") === "itb" ? "itb" : "bti";
document.getElementById("strategySelect").value = strategy; document.getElementById("strategySelect").value = strategy;
document.getElementById("sourceSelect").value = source; document.getElementById("sourceSelect").value = source;
document.getElementById("fusionModeSelect").value = fusionMode;
const ml = document.getElementById("mapLabel");
if (ml) ml.textContent = fusionMode === "itb" ? "Prepared GCC grayscale / S3 (closest available)" : "Prepared RGB (closest available)";
const initSite = getSiteBySitename(initialSite); const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) { if (initSite?.geometry?.coordinates) {
@ -352,6 +354,14 @@
history.replaceState({}, "", `?${urlParams}`); history.replaceState({}, "", `?${urlParams}`);
loadTimeseries(); updateMap(); loadTimeseries(); updateMap();
}); });
document.getElementById("fusionModeSelect").addEventListener("change", function() {
fusionMode = this.value;
urlParams.set("mode", fusionMode);
history.replaceState({}, "", `?${urlParams}`);
const ml = document.getElementById("mapLabel");
if (ml) ml.textContent = fusionMode === "itb" ? "Prepared GCC grayscale / S3 (closest available)" : "Prepared RGB (closest available)";
loadTimeseries(); updateMap();
});
await setSiteSeason(initialSite, initialSeason); await setSiteSeason(initialSite, initialSeason);
} }

View file

@ -42,6 +42,7 @@
<a href="prepared.html">Prepared</a> <a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a> <a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a> <a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html">Metrics</a>
</div> </div>
<h1 id="siteName">Innsbruck</h1> <h1 id="siteName">Innsbruck</h1>
<div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div> <div class="season-row"><h2 id="season">2024</h2><span class="download-links" id="downloadLinks"></span></div>