From a7f6956ce447adfb0bad35d1589c92efa9c88534 Mon Sep 17 00:00:00 2001 From: calebrob6 Date: Tue, 14 Jul 2026 18:01:23 +0000 Subject: [PATCH 1/7] feat: Advanced panel in interactive labeler (5-fold CV + pre-event swipe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an expandable "Advanced" section to the Interactive Labeler right panel: - Run 5-fold CV: stratified k-fold cross-validation of the in-browser model over the current labels, reporting per-class precision, recall, and one-vs- rest AUC as mean ± stdev across folds. Implemented as a pure, dependency-free crossValidateMetrics(entries, {k}) in interactiveModel.js with a rank-based binaryAUC helper (Mann-Whitney, average ranks for ties). Effective folds are clamped to the smallest class count; degenerate inputs return {ok:false}. - Swipe (pre-event) toggle: overlays an atlas.SwipeMap whose secondary map reveals the layer's pre-event imagery, falling back to a satellite basemap when the layer has no pre-event imagery — mirroring the Visualizer's SwipeMap usage and reusing the loadImagery helper. Lazily constructed on toggle-on and torn down (dispose swipe + secondary map, reset container) on toggle-off. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 344 +++++++++++++++++- .../InteractiveLabeler/interactiveModel.js | 189 ++++++++++ 2 files changed, 529 insertions(+), 4 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index 329bb33..6fe4315 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -26,6 +26,8 @@ import { DefaultButton, PrimaryButton, ProgressIndicator, + Spinner, + SpinnerSize, Text, Toggle, } from "@fluentui/react"; @@ -43,6 +45,7 @@ import { CLASS_DAMAGED, CLASS_INTACT, OvRLogisticRegression, + crossValidateMetrics, holdoutMetricsDamaged, isValidVector, } from "./interactiveModel.js"; @@ -103,6 +106,8 @@ async function fetchArtifactBuffer(url) { // Class colors (match index.html). Index = class number. const CLASS_COLORS = ["#107C10", "#C50F1F", "#5B5FC7"]; // intact, damaged, cloudy +// Human-readable class names, indexed by class number (parallel to CLASS_COLORS). +const CLASS_LABELS = ["Intact", "Damaged", "Cloudy"]; const UNLABELED_COLOR = "#BDBDBD"; // In-browser class -> validation-report vocabulary (Damaged/NotDamaged/Unknown). @@ -231,6 +236,18 @@ async function fetchFeaturesSidecar(url) { return { matrix, n, d }; } +// Format a { mean, std } CV-metric block for the Advanced results table. +// asPercent=true renders precision/recall as "92 ± 3%"; otherwise (AUC) as +// two-decimal "0.92 ± 0.03". Returns an em-dash when the metric is undefined +// (mean == null, e.g. no fold produced a defined value). +function fmtMetric(m, asPercent) { + if (!m || m.mean == null) return "—"; + if (asPercent) { + return `${(m.mean * 100).toFixed(0)} ± ${(m.std * 100).toFixed(0)}%`; + } + return `${m.mean.toFixed(2)} ± ${m.std.toFixed(2)}`; +} + const InteractiveLabeler = () => { const { projectId, imageLayerId, modelId } = useParams(); const navigate = useNavigate(); @@ -279,6 +296,16 @@ const InteractiveLabeler = () => { const labelsDirtyRef = useRef(true); const fullPredictAbortRef = useRef({ cancelled: false }); + // Advanced → Swipe view. The existing labeler map (mapRef.current) is the + // primary; swipeSecondaryMapRef holds a satellite map overlaid exactly on + // top of it, and swipeRef holds the atlas.SwipeMap that draws the divider. + // layerImageryRef caches the layer's imagery URLs (resolved in createMap) so + // the swipe effect can load the pre-event tiles onto the secondary map. + const swipeMapContainerRef = useRef(null); + const swipeSecondaryMapRef = useRef(null); + const swipeRef = useRef(null); + const layerImageryRef = useRef(null); + const [isMapReady, setIsMapReady] = useState(false); const [selectedClass, setSelectedClass] = useState(CLASS_DAMAGED); const [viewMode, setViewMode] = useState("label"); // "label" | "predict" @@ -292,6 +319,11 @@ const InteractiveLabeler = () => { // Progress state for the "Predict all buildings" full-coverage pass. const [fullPredict, setFullPredict] = useState(null); const [backend, setBackend] = useState(null); + // Advanced section: expand/collapse, 5-fold CV result + busy flag, swipe view. + const [advancedOpen, setAdvancedOpen] = useState(false); + const [cvResult, setCvResult] = useState(null); + const [cvRunning, setCvRunning] = useState(false); + const [swipeOn, setSwipeOn] = useState(false); // selectedClass / viewMode are read by long-lived map event handlers. const selectedClassRef = useRef(selectedClass); @@ -352,6 +384,10 @@ const InteractiveLabeler = () => { } catch { // Imagery is optional — labeling works without it. } + // Cache the imagery URLs for the Advanced → Swipe view, which loads the + // pre-event tiles onto its secondary map (falls back to satellite when + // the layer has no pre-event imagery). + layerImageryRef.current = layerData?.imagery || null; // Resolve the model's PMTiles URL. Models are returned by // GetLayerModelsDetails; pick ours by modelId. The pmtilesUrl is @@ -1069,6 +1105,154 @@ const InteractiveLabeler = () => { return () => window.removeEventListener("keydown", onKeyDown); }, []); + // ── Advanced → 5-fold cross-validation ──────────────────────────────────── + // Runs stratified k-fold CV over the current labels via the pure + // crossValidateMetrics helper. The crunch is synchronous CPU work (a few + // hundred ms for large label sets), so we flip cvRunning first and yield to + // the event loop with a 0ms timeout, letting the disabled/spinner state paint + // before the main thread blocks. + async function handleRunCV() { + const entries = Object.values(labeledMapRef.current).filter((e) => + isValidVector(e.features) + ); + if (entries.length === 0) { + setCvResult({ ok: false, reason: "Label some buildings first." }); + return; + } + setCvRunning(true); + setCvResult(null); + await new Promise((resolve) => setTimeout(resolve, 0)); + try { + setCvResult(crossValidateMetrics(entries, { k: 5 })); + } catch (e) { + console.error("Cross-validation failed:", e); + setCvResult({ + ok: false, + reason: `Cross-validation failed: ${e?.message || e}`, + }); + } finally { + setCvRunning(false); + } + } + + // ── Advanced → Swipe view (pre-event imagery reveal) ────────────────────── + // Mirrors the Visualizer's swipe pattern (Visualizer.jsx:140-155, 315-345) + // using the global atlas.SwipeMap loaded from + // /assets/js/azure-maps-swipe-map.min.js in index.html — NOT an npm import. + // + // The existing labeler map (mapRef.current) is the PRIMARY. We create a + // SECONDARY satellite map in an absolutely-positioned overlay that covers the + // exact map footprint, then hand both maps to atlas.SwipeMap. SwipeMap draws + // the draggable divider (appended into the primary container), clips the + // secondary to the revealed side, and keeps the two cameras in sync on every + // 'move'. The secondary loads the layer's pre-event imagery; loadImagery("") + // falls back to Azure's microsoft.imagery satellite tiles when the layer has + // no pre-event imagery (and the secondary's base style is "satellite" too), + // satisfying the "satellite basemap if no pre-event imagery" requirement. + useEffect(() => { + if (!isMapReady || !swipeOn) return undefined; + const primary = mapRef.current; + const container = swipeMapContainerRef.current; + if (!primary || !container || !window.atlas || !window.atlas.SwipeMap) { + return undefined; + } + + // Seed the secondary with the primary's current camera so the two start + // aligned before SwipeMap takes over camera synchronization. + const cam = primary.getCamera(); + const secondary = new window.atlas.Map(container, { + center: cam.center, + zoom: cam.zoom, + bearing: cam.bearing || 0, + pitch: cam.pitch || 0, + maxPitch: 0, + // Match the primary labeler map's style handling: "satellite" shows the + // Azure aerial basemap in real deployments, while local docker dev (no + // Azure Maps subscription) uses "blank" so the map control still fires + // "ready" without a valid token. The pre-event TileLayer is added on top + // in either case; with no pre-event imagery, loadImagery("") falls back + // to the Azure satellite tileset (which renders once a real token exists). + style: isAzureMapsPlaceholder ? "blank" : "satellite", + language: "en-US", + authOptions: getAzureMapsAuthOptions(), + }); + swipeSecondaryMapRef.current = secondary; + + secondary.events.add("ready", () => { + // Match the primary map's interaction constraints (no rotate / pitch). + secondary.setUserInteraction({ + dragRotateInteraction: false, + scrollZoomInteraction: true, + pinchZoomInteraction: true, + pinchRotateInteraction: false, + }); + // Pre-event imagery on the revealed side. loadImagery falls back to the + // Azure satellite tileset when the tile URL is "" (no pre-event imagery). + const preUrl = layerImageryRef.current?.preEventTileUrl || ""; + loadImagery( + toBrowserTitilerUrl(preUrl), + secondary, + { current: null }, + "swipePreEventLayer", + true + ); + // Wire the native swipe control: the divider reveals `secondary` + // (pre-event / satellite) over the primary labeler map. + try { + swipeRef.current = new window.atlas.SwipeMap(primary, secondary); + } catch (e) { + console.warn("atlas.SwipeMap init failed:", e); + } + }); + + // Camera-sync fallback: atlas.SwipeMap already syncs both maps on 'move', + // but guard against any build where it doesn't by copying the camera on + // the primary's 'moveend'. + const syncSecondary = () => { + const sec = swipeSecondaryMapRef.current; + if (!sec) return; + const c = primary.getCamera(); + sec.setCamera({ + center: c.center, + zoom: c.zoom, + bearing: c.bearing, + pitch: c.pitch, + type: "jump", + }); + }; + primary.events.add("moveend", syncSecondary); + + return () => { + primary.events.remove("moveend", syncSecondary); + // Tear down the swipe control first — its dispose() removes the divider + // handle it appended to the primary container — then dispose the + // secondary map and reset the container so the primary labeler map and + // its footprint interactions are untouched. + if (swipeRef.current) { + try { + if (typeof swipeRef.current.dispose === "function") { + swipeRef.current.dispose(); + } + } catch (e) { + console.warn("atlas.SwipeMap dispose failed:", e); + } + swipeRef.current = null; + } + if (swipeSecondaryMapRef.current) { + try { + swipeSecondaryMapRef.current.dispose(); + } catch (e) { + console.warn("swipe secondary map dispose failed:", e); + } + swipeSecondaryMapRef.current = null; + } + // SwipeMap sets an inline `clip` on the secondary container; clear it and + // any leftover atlas DOM so the next toggle starts from a clean slate. + container.style.clip = ""; + container.innerHTML = ""; + }; + }, [isMapReady, swipeOn]); + // ── Save labels ─────────────────────────────────────────────────────────── // Persists the manual labels to the model-scoped interactive-labeler store. // Predictions are persisted by the separate "Predict all buildings" flow @@ -1358,11 +1542,29 @@ const InteractiveLabeler = () => { + {/* Map area: the primary labeler map plus an absolutely-positioned + overlay that hosts the Advanced → Swipe secondary map. The overlay + covers the exact map footprint (inset:0 of this relative wrapper) so + atlas.SwipeMap's clip/divider line up with the primary map. It stays + display:none until the swipe toggle is on. */}
+ style={{ position: "relative", flexGrow: 1, display: "flex" }} + > +
+
+
{isMapReady && (
{ title="Remove every label for this model — both in-session and in the saved store." /> + {/* Advanced: expandable container for the 5-fold CV report and the + swipe (pre-event) comparison view. */} + setAdvancedOpen((v) => !v)} + styles={{ root: { marginTop: 12, paddingLeft: 0, height: 28 } }} + > + Advanced + + + {advancedOpen && ( +
+ + + {cvRunning && ( +
+ +
+ )} + + {cvResult && !cvResult.ok && ( +
+ {cvResult.reason} +
+ )} + + {cvResult && cvResult.ok && ( +
+ + + + + + + + + + + {cvResult.classes.map((c) => { + const pc = cvResult.perClass[c]; + return ( + + + + + + + ); + })} + +
+ Class + + P + + R + + AUC +
+ {CLASS_LABELS[c] ?? `Class ${c}`} + + {fmtMetric(pc.precision, true)} + + {fmtMetric(pc.recall, true)} + + {fmtMetric(pc.auc, false)} +
+
+ k-fold={cvResult.k} + {cvResult.k < cvResult.requestedK && + " (reduced — insufficient per-class samples)"} + {" · "}mean ± stdev across folds +
+
+ )} + + setSwipeOn(!!checked)} + style={{ marginTop: 12 }} + /> +
+ Drag the divider to reveal pre-event imagery (satellite basemap + when the layer has none). +
+
+ )} +
Click a building to label it · right-click to clear ·{" "} Ctrl+drag to box-label · 1/2/ diff --git a/ui/src/Components/InteractiveLabeler/interactiveModel.js b/ui/src/Components/InteractiveLabeler/interactiveModel.js index c9c6b3d..2c8916e 100644 --- a/ui/src/Components/InteractiveLabeler/interactiveModel.js +++ b/ui/src/Components/InteractiveLabeler/interactiveModel.js @@ -312,3 +312,192 @@ export function computeClassMetrics(preds, labels) { preds.filter((p, i) => p === labels[i]).length / (preds.length || 1); return { perClass, acc }; } + +// ── Rank-based binary AUC (Mann–Whitney U statistic) ──────────────────────── +// AUC = probability that a random positive scores above a random negative. +// Computed from ranks with average ranks for ties: +// AUC = (sum_of_ranks_of_positives - nPos*(nPos+1)/2) / (nPos*nNeg) +// Returns null for degenerate inputs (empty, or no positives / no negatives) +// since AUC is undefined without at least one of each class. +export function binaryAUC(scores, labels01) { + const n = scores.length; + if (n === 0) return null; + let nPos = 0; + let nNeg = 0; + for (let i = 0; i < n; i++) { + if (labels01[i] === 1) nPos++; + else nNeg++; + } + if (nPos === 0 || nNeg === 0) return null; + + // Rank scores ascending (1-based), assigning the average rank to ties. + const order = scores.map((_, i) => i).sort((a, b) => scores[a] - scores[b]); + const ranks = new Array(n); + let i = 0; + while (i < n) { + let j = i; + while (j + 1 < n && scores[order[j + 1]] === scores[order[i]]) j++; + const avgRank = (i + j) / 2 + 1; // midpoint of positions i..j, 1-based + for (let k = i; k <= j; k++) ranks[order[k]] = avgRank; + i = j + 1; + } + + let sumRanksPos = 0; + for (let r = 0; r < n; r++) { + if (labels01[r] === 1) sumRanksPos += ranks[r]; + } + return (sumRanksPos - (nPos * (nPos + 1)) / 2) / (nPos * nNeg); +} + +// Fisher–Yates in-place shuffle on a shallow copy (leaves the input intact). +function shuffle(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// Mean + sample (n-1 denominator) standard deviation across `vals`. +// n >= 2 -> { mean, std, n } +// n == 1 -> { mean, std: 0, n } (a single fold has no spread) +// n == 0 -> { mean: null, std: null, n: 0 } +function meanStd(vals) { + const n = vals.length; + if (n === 0) return { mean: null, std: null, n: 0 }; + const mean = vals.reduce((s, v) => s + v, 0) / n; + if (n === 1) return { mean, std: 0, n: 1 }; + const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1); + return { mean, std: Math.sqrt(variance), n }; +} + +// ── Stratified k-fold cross-validated per-class metrics ───────────────────── +// Runs stratified k-fold cross-validation over the labeled `entries` and +// reports per-class precision, recall, and one-vs-rest AUC, each aggregated +// across folds as mean ± sample standard deviation. +// +// Fold-stdev semantics: each fold produces (at most) one value per class per +// metric; we report the mean and the *sample* stdev (n-1 denominator) across +// the folds that yielded a defined value. This estimates the fold-to-fold +// variability of the metric — how sensitive it is to which subset is held +// out — NOT a confidence interval on any single trained model. Folds where a +// metric is undefined (precision when there are no predicted positives, recall +// when the fold has no true positives, or AUC when the held-out fold is +// single-class for that label) are skipped for that metric so one degenerate +// fold does not drag the estimate toward 0. +export function crossValidateMetrics(entries, { k = 5, opts } = {}) { + const usable = (entries || []).filter((e) => isValidVector(e.features)); + const classes = [...new Set(usable.map((e) => e.label))].sort( + (a, b) => a - b + ); + if (classes.length < 2) { + return { + ok: false, + reason: "Need at least 2 labeled classes to run cross-validation.", + }; + } + + // Group members by class, count support, and find the smallest class. + const byClass = {}; + for (const c of classes) byClass[c] = []; + for (const e of usable) byClass[e.label].push(e); + const support = {}; + let minClassCount = Infinity; + for (const c of classes) { + support[c] = byClass[c].length; + if (support[c] < minClassCount) minClassCount = support[c]; + } + + // Effective folds cannot exceed the smallest class's member count, else a + // fold could be missing an entire class. + const effK = Math.min(k, minClassCount); + if (effK < 2) { + return { + ok: false, + reason: `Not enough samples per class for cross-validation (smallest class has ${minClassCount}; need at least 2).`, + }; + } + + const modelOpts = opts || { learningRate: 0.1, numSteps: 500, lambda: 0.01 }; + + // Stratified fold assignment: shuffle each class, then round-robin its + // members across the effK folds. Because every class has >= effK members, + // each fold receives at least one member of every class. + const folds = Array.from({ length: effK }, () => []); + for (const c of classes) { + shuffle(byClass[c]).forEach((e, i) => folds[i % effK].push(e)); + } + + const precVals = {}; + const recVals = {}; + const aucVals = {}; + for (const c of classes) { + precVals[c] = []; + recVals[c] = []; + aucVals[c] = []; + } + + for (let i = 0; i < effK; i++) { + const test = folds[i]; + const train = []; + for (let j = 0; j < effK; j++) if (j !== i) train.push(...folds[j]); + if (train.length === 0 || test.length === 0) continue; + // Guard against the (rare, tiny-data) case where the training split is + // missing a class — OvR needs both classes represented to be meaningful. + if (new Set(train.map((e) => e.label)).size < 2) continue; + + const ovr = new OvRLogisticRegression(modelOpts); + ovr.train( + train.map((e) => e.features), + train.map((e) => e.label) + ); + + const testX = test.map((e) => e.features); + const testTrue = test.map((e) => e.label); + const preds = ovr.predict(testX); + + for (const c of classes) { + // Per-class precision / recall from the argmax predictions. + let tp = 0; + let fp = 0; + let fn = 0; + for (let r = 0; r < testTrue.length; r++) { + const predC = preds[r] === c; + const trueC = testTrue[r] === c; + if (predC && trueC) tp++; + else if (predC && !trueC) fp++; + else if (!predC && trueC) fn++; + } + if (tp + fp > 0) precVals[c].push(tp / (tp + fp)); + if (tp + fn > 0) recVals[c].push(tp / (tp + fn)); + + // Per-class one-vs-rest AUC from the raw (un-normalized) sigmoid scores. + const clf = ovr.classifiers[c]; + if (clf) { + const y01 = testTrue.map((t) => (t === c ? 1 : 0)); + const auc = binaryAUC(clf.predictProba(testX), y01); + if (auc != null) aucVals[c].push(auc); + } + } + } + + const perClass = {}; + for (const c of classes) { + perClass[c] = { + precision: meanStd(precVals[c]), + recall: meanStd(recVals[c]), + auc: meanStd(aucVals[c]), + support: support[c], + }; + } + + return { + ok: true, + k: effK, + requestedK: k, + classes, + perClass, + nTotal: usable.length, + }; +} From b849053d87652d77597998f4fe34fa3a25004989 Mon Sep 17 00:00:00 2001 From: calebrob6 Date: Tue, 14 Jul 2026 22:15:21 +0000 Subject: [PATCH 2/7] fix: correct interactive labeler swipe layout and camera sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the Advanced swipe view: - Panning: removed the redundant primary 'moveend' -> secondary setCamera handler. atlas.SwipeMap already synchronizes both cameras on every 'move'; our extra handler double-updated them, making panning jump/stutter. - Layout: the primary labeler map div was flexGrow/static while the swipe secondary was absolute, so the swipe clip/divider were sized against a mismatched box. Make BOTH map divs position:absolute inset:0 overlapping inside a relative wrapper — the exact layout the Visualizer's working swipe uses (visualizer.css .map) — so the secondary (pre-event, on top) clips cleanly to reveal the primary (post-event) underneath. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index 6fe4315..5a69b7e 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -1197,7 +1197,10 @@ const InteractiveLabeler = () => { true ); // Wire the native swipe control: the divider reveals `secondary` - // (pre-event / satellite) over the primary labeler map. + // (pre-event / satellite) over the primary labeler map. atlas.SwipeMap + // keeps BOTH cameras in sync on every 'move' internally, so we must NOT + // add our own camera-sync handler (doing so double-updates the cameras + // and makes panning jump/stutter). try { swipeRef.current = new window.atlas.SwipeMap(primary, secondary); } catch (e) { @@ -1205,25 +1208,7 @@ const InteractiveLabeler = () => { } }); - // Camera-sync fallback: atlas.SwipeMap already syncs both maps on 'move', - // but guard against any build where it doesn't by copying the camera on - // the primary's 'moveend'. - const syncSecondary = () => { - const sec = swipeSecondaryMapRef.current; - if (!sec) return; - const c = primary.getCamera(); - sec.setCamera({ - center: c.center, - zoom: c.zoom, - bearing: c.bearing, - pitch: c.pitch, - type: "jump", - }); - }; - primary.events.add("moveend", syncSecondary); - return () => { - primary.events.remove("moveend", syncSecondary); // Tear down the swipe control first — its dispose() removes the divider // handle it appended to the primary container — then dispose the // secondary map and reset the container so the primary labeler map and @@ -1542,18 +1527,18 @@ const InteractiveLabeler = () => {
- {/* Map area: the primary labeler map plus an absolutely-positioned - overlay that hosts the Advanced → Swipe secondary map. The overlay - covers the exact map footprint (inset:0 of this relative wrapper) so - atlas.SwipeMap's clip/divider line up with the primary map. It stays - display:none until the swipe toggle is on. */} -
+ {/* Map area: the primary labeler map plus an overlay that hosts the + Advanced → Swipe secondary map. Both map divs are absolutely + positioned and fill this relative wrapper exactly (the same layout + the Visualizer's swipe uses — see Visualizer.jsx + visualizer.css + `.map`), so atlas.SwipeMap's clip/divider on the secondary line up + with the primary underneath. The overlay stays display:none until the + swipe toggle is on. */} +
Date: Wed, 15 Jul 2026 04:22:00 +0000 Subject: [PATCH 3/7] feat: swipe swap/labels + Advanced divider, pinned hotkeys, uncertainty view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swipe refinements: - Put PRE imagery on the LEFT and POST on the RIGHT (make the labeler map the SwipeMap secondary and a new pre-event map the primary, since the module always reveals the secondary on the right). - Add 'Pre imagery' / 'Post imagery' corner labels (top-left / top-right). - Draw the building footprints on the pre pane too (same PMTiles source), so outlines span both panes (per-building label colors still render on the interactive/post pane only — feature-state mirroring is a follow-up). Right-panel layout: - Divider (Separator) between the CV and Swipe blocks in Advanced. - Pin the keyboard-hotkeys hint to the bottom of the panel: the panel is now a flex column with a scrollable content region and a fixed footer. Uncertainty view (Advanced): - New toggle that recolors every scored footprint by the model's predictive uncertainty (normalized Shannon entropy of the OvR class probabilities), driven by a new 'unc' feature-state and a blue-to-red interpolate ramp. Computed for the viewport on each settle (reuses the trained model), with a legend shown while the view is on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 472 +++++++++++++++--- 1 file changed, 402 insertions(+), 70 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index 5a69b7e..dc780f9 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -26,6 +26,7 @@ import { DefaultButton, PrimaryButton, ProgressIndicator, + Separator, Spinner, SpinnerSize, Text, @@ -182,6 +183,53 @@ function fillOpacityExpr(stateKey) { ]; } +// ── Uncertainty view ──────────────────────────────────────────────────────── +// Colors every scored building by the model's predictive uncertainty (0 = +// confident, 1 = maximally uncertain). Stops match UNCERTAINTY_LEGEND_GRADIENT +// used by the panel legend. The driver reads the "unc" feature-state; buildings +// without a computed value coalesce to 0 (and are hidden by the opacity expr). +const UNCERTAINTY_STOPS = [ + [0, "#2c7bb6"], + [0.25, "#abd9e9"], + [0.5, "#ffffbf"], + [0.75, "#fdae61"], + [1, "#d7191c"], +]; +const UNCERTAINTY_LEGEND_GRADIENT = + "linear-gradient(to right, #2c7bb6, #abd9e9, #ffffbf, #fdae61, #d7191c)"; +const UNCERTAINTY_FILL_OPACITY = 0.6; + +function fillColorExprUncertainty() { + return [ + "interpolate", + ["linear"], + ["coalesce", ["feature-state", "unc"], 0], + ...UNCERTAINTY_STOPS.flat(), + ]; +} + +// Only buildings with a computed "unc" value are painted; the rest stay +// transparent (coalesce sentinel -1 marks "no value"). +function fillOpacityExprUncertainty() { + return [ + "case", + ["==", ["coalesce", ["feature-state", "unc"], -1], -1], 0, + UNCERTAINTY_FILL_OPACITY, + ]; +} + +// Normalized Shannon entropy of a probability vector, in [0, 1] (0 = a single +// class has all the mass, 1 = uniform across k classes). Used as the per- +// building uncertainty score for the uncertainty view. +function normalizedEntropy(probs) { + const k = probs.length; + if (k <= 1) return 0; + let h = 0; + for (const p of probs) if (p > 0) h -= p * Math.log(p); + const norm = h / Math.log(k); + return Math.max(0, Math.min(1, norm)); +} + // Binary HFTR sidecar: // bytes 0-3 : magic "HFTR" // bytes 4-7 : u32 LE version (currently 1) @@ -296,15 +344,21 @@ const InteractiveLabeler = () => { const labelsDirtyRef = useRef(true); const fullPredictAbortRef = useRef({ cancelled: false }); - // Advanced → Swipe view. The existing labeler map (mapRef.current) is the - // primary; swipeSecondaryMapRef holds a satellite map overlaid exactly on - // top of it, and swipeRef holds the atlas.SwipeMap that draws the divider. - // layerImageryRef caches the layer's imagery URLs (resolved in createMap) so - // the swipe effect can load the pre-event tiles onto the secondary map. + // Advanced → Swipe view. atlas.SwipeMap always reveals its SECONDARY map on + // the RIGHT of the divider and shows its PRIMARY on the LEFT, so to land PRE + // imagery on the left / POST imagery on the right we make the labeler map + // (mapRef.current: post-event imagery + footprints + interaction) the + // SECONDARY and a freshly-built pre-event map the PRIMARY. swipePreMapRef + // holds that new pre-event map (created in swipeMapContainerRef), swipeRef + // holds the atlas.SwipeMap that draws the divider, layerImageryRef caches the + // layer's imagery URLs (resolved in createMap), and swipePmtilesUrlRef caches + // the PMTiles archive URL so the pre map can draw the same building + // footprints from the same source. const swipeMapContainerRef = useRef(null); - const swipeSecondaryMapRef = useRef(null); + const swipePreMapRef = useRef(null); const swipeRef = useRef(null); const layerImageryRef = useRef(null); + const swipePmtilesUrlRef = useRef(null); const [isMapReady, setIsMapReady] = useState(false); const [selectedClass, setSelectedClass] = useState(CLASS_DAMAGED); @@ -324,6 +378,9 @@ const InteractiveLabeler = () => { const [cvResult, setCvResult] = useState(null); const [cvRunning, setCvRunning] = useState(false); const [swipeOn, setSwipeOn] = useState(false); + // Advanced → Uncertainty view: recolor every scored footprint by the model's + // predictive uncertainty (with a legend). + const [uncertaintyOn, setUncertaintyOn] = useState(false); // selectedClass / viewMode are read by long-lived map event handlers. const selectedClassRef = useRef(selectedClass); @@ -334,6 +391,12 @@ const InteractiveLabeler = () => { useEffect(() => { viewModeRef.current = viewMode; }, [viewMode]); + // Read by long-lived map handlers (hydrateViewport) to decide whether to + // compute per-building uncertainty on each viewport settle. + const uncertaintyOnRef = useRef(uncertaintyOn); + useEffect(() => { + uncertaintyOnRef.current = uncertaintyOn; + }, [uncertaintyOn]); // Detect the compute backend up-front so the panel shows WebGPU vs CPU. useEffect(() => { @@ -548,6 +611,11 @@ const InteractiveLabeler = () => { // then overzooms tiles at z>maxZoom automatically. Setting // maxSourceZoom explicitly capped at 14 makes Atlas treat z>14 as // "source has no data" and stop rendering past that zoom. + // Cache the PMTiles archive URL so the Advanced → Swipe pre map can draw + // the same building footprints from the same source (see the swipe + // effect below). Must match this source's `pmtiles://` exactly so + // both maps route through the same in-memory pmtiles handle. + swipePmtilesUrlRef.current = browserPmtilesUrl; const source = new window.atlas.source.VectorTileSource("buildings", { type: "vector", url: `pmtiles://${browserPmtilesUrl}`, @@ -769,6 +837,65 @@ const InteractiveLabeler = () => { if (viewModeRef.current === "predict") { maybeTrainAndPredict(features); } + + // Uncertainty view — recolor the on-screen buildings by model uncertainty. + if (uncertaintyOnRef.current) { + computeUncertaintyForViewport(features); + } + } + + // Train (or reuse the cached model) and paint per-building uncertainty as the + // "unc" feature-state for every building currently in the viewport. Shares + // trainedModelRef with the predict path; returns silently (with a status + // hint) when there aren't enough labels to train. + function computeUncertaintyForViewport(features) { + const entries = Object.values(labeledMapRef.current).filter((e) => + isValidVector(e.features) + ); + const perClass = {}; + entries.forEach((e) => (perClass[e.label] = (perClass[e.label] || 0) + 1)); + const classesReady = Object.values(perClass).filter( + (n) => n >= MIN_PER_CLASS + ).length; + if (classesReady < 2) { + setStatus( + `Need ${MIN_PER_CLASS}+ labels in at least 2 classes for uncertainty.` + ); + return; + } + // Reuse the cached model unless the label set changed since it was trained. + if (labelsDirtyRef.current || !trainedModelRef.current) { + const ovr = new OvRLogisticRegression({ + learningRate: 0.1, + numSteps: 500, + lambda: 0.01, + }); + ovr.train( + entries.map((e) => e.features), + entries.map((e) => e.label) + ); + trainedModelRef.current = ovr; + labelsDirtyRef.current = false; + } + const model = trainedModelRef.current; + + const ids = []; + const matrix = []; + const sources = []; + for (const f of features) { + if (f.id == null) continue; + const vec = lookupFeatureVector(f.id); + if (!isValidVector(vec)) continue; + ids.push(f.id); + matrix.push(vec); + sources.push(f.source); + } + if (matrix.length === 0) return; + const probs = model.predictProba(matrix); + for (let i = 0; i < ids.length; i++) { + const unc = normalizedEntropy(Object.values(probs[i])); + setFeatureStateUnc(sources[i], ids[i], unc); + } } // ── feature-state helpers (drive renderer paint) ────────────────────────── @@ -798,6 +925,19 @@ const InteractiveLabeler = () => { console.warn("setFeatureState (pred) failed:", err); } } + function setFeatureStateUnc(sourceId, id, unc) { + const gl = glMapRef.current; + if (!gl) return; + try { + gl.setFeatureState( + { source: sourceId, sourceLayer: PMTILES_SOURCE_LAYER, id }, + { unc } + ); + mapRef.current?.triggerRepaint && mapRef.current.triggerRepaint(); + } catch (err) { + console.warn("setFeatureState (unc) failed:", err); + } + } function clearFeatureStateLabel(sourceId, id) { const gl = glMapRef.current; if (!gl) return; @@ -827,12 +967,21 @@ const InteractiveLabeler = () => { if (!map) return; const fill = map.layers.getLayerById?.("embeddingFill"); if (!fill) return; - fill.setOptions({ - fillColor: fillColorExpr(viewMode === "predict" ? "pred" : "label"), - fillOpacity: fillOpacityExpr(viewMode === "predict" ? "pred" : "label"), - }); - if (viewMode === "predict") hydrateViewport(map); - }, [viewMode, isMapReady]); + if (uncertaintyOn) { + // Uncertainty view overrides the label/predict coloring entirely. + fill.setOptions({ + fillColor: fillColorExprUncertainty(), + fillOpacity: fillOpacityExprUncertainty(), + }); + hydrateViewport(map); + } else { + fill.setOptions({ + fillColor: fillColorExpr(viewMode === "predict" ? "pred" : "label"), + fillOpacity: fillOpacityExpr(viewMode === "predict" ? "pred" : "label"), + }); + if (viewMode === "predict") hydrateViewport(map); + } + }, [viewMode, uncertaintyOn, isMapReady]); // Show / hide the buildings layers without unmounting them. Driven by // the panel toggle + spacebar hotkey; the feature-state and the cached @@ -1136,73 +1285,126 @@ const InteractiveLabeler = () => { } // ── Advanced → Swipe view (pre-event imagery reveal) ────────────────────── - // Mirrors the Visualizer's swipe pattern (Visualizer.jsx:140-155, 315-345) + // Mirrors the Visualizer's swipe pattern (Visualizer.jsx:140-155, 466-467) // using the global atlas.SwipeMap loaded from // /assets/js/azure-maps-swipe-map.min.js in index.html — NOT an npm import. // - // The existing labeler map (mapRef.current) is the PRIMARY. We create a - // SECONDARY satellite map in an absolutely-positioned overlay that covers the - // exact map footprint, then hand both maps to atlas.SwipeMap. SwipeMap draws - // the draggable divider (appended into the primary container), clips the - // secondary to the revealed side, and keeps the two cameras in sync on every - // 'move'. The secondary loads the layer's pre-event imagery; loadImagery("") - // falls back to Azure's microsoft.imagery satellite tiles when the layer has - // no pre-event imagery (and the secondary's base style is "satellite" too), - // satisfying the "satellite basemap if no pre-event imagery" requirement. + // atlas.SwipeMap always clips its SECONDARY map to reveal it on the RIGHT of + // the divider and shows its PRIMARY on the LEFT, and syncs both cameras on + // every 'move'. To land PRE imagery on the left / POST imagery on the right + // we therefore wire: + // • PRIMARY = a freshly-built PRE-event map (created in + // swipeMapContainerRef, the FIRST/behind map div), and + // • SECONDARY = the existing labeler map (mapRef.current: post-event + // imagery + building footprints + click/label interaction), + // which sits in the SECOND/on-top map div so its clipped + // right half reveals the pre map underneath on the left. + // The labeler map is ADOPTED as the secondary — SwipeMap only adds 'move' + + // 'resize' handlers and clips its container, so an already-"ready" map + // adopts cleanly and its own click/label/hydrate handlers are untouched. On + // teardown we dispose ONLY the swipe control + the new pre map and clear the + // clip SwipeMap left on the labeler's container; the labeler map itself is + // never disposed here, so toggling swipe off/on repeatedly leaves the + // labeler and its footprint interactions fully intact. useEffect(() => { if (!isMapReady || !swipeOn) return undefined; - const primary = mapRef.current; + const labelerMap = mapRef.current; const container = swipeMapContainerRef.current; - if (!primary || !container || !window.atlas || !window.atlas.SwipeMap) { + // Capture the labeler map's container node up front so the cleanup below + // does not read a ref (mapContainerRef.current) that may have changed by + // teardown — the node is stable for this effect's lifetime. + const labelerContainer = mapContainerRef.current; + if (!labelerMap || !container || !window.atlas || !window.atlas.SwipeMap) { return undefined; } - // Seed the secondary with the primary's current camera so the two start + // Seed the new pre map with the labeler's current camera so the two start // aligned before SwipeMap takes over camera synchronization. - const cam = primary.getCamera(); - const secondary = new window.atlas.Map(container, { + const cam = labelerMap.getCamera(); + const preMap = new window.atlas.Map(container, { center: cam.center, zoom: cam.zoom, bearing: cam.bearing || 0, pitch: cam.pitch || 0, maxPitch: 0, - // Match the primary labeler map's style handling: "satellite" shows the - // Azure aerial basemap in real deployments, while local docker dev (no - // Azure Maps subscription) uses "blank" so the map control still fires - // "ready" without a valid token. The pre-event TileLayer is added on top - // in either case; with no pre-event imagery, loadImagery("") falls back - // to the Azure satellite tileset (which renders once a real token exists). + // Match the labeler map's style handling: "satellite" shows the Azure + // aerial basemap in real deployments, while local docker dev (no Azure + // Maps subscription) uses "blank" so the map control still fires "ready" + // without a valid token. The pre-event TileLayer is added on top in + // either case; with no pre-event imagery, loadImagery("") falls back to + // the Azure satellite tileset (which renders once a real token exists). style: isAzureMapsPlaceholder ? "blank" : "satellite", language: "en-US", authOptions: getAzureMapsAuthOptions(), }); - swipeSecondaryMapRef.current = secondary; + swipePreMapRef.current = preMap; - secondary.events.add("ready", () => { - // Match the primary map's interaction constraints (no rotate / pitch). - secondary.setUserInteraction({ + preMap.events.add("ready", () => { + // Match the labeler map's interaction constraints (no rotate / pitch). + preMap.setUserInteraction({ dragRotateInteraction: false, scrollZoomInteraction: true, pinchZoomInteraction: true, pinchRotateInteraction: false, }); - // Pre-event imagery on the revealed side. loadImagery falls back to the - // Azure satellite tileset when the tile URL is "" (no pre-event imagery). + // Pre-event imagery on the LEFT (primary) pane. loadImagery falls back to + // the Azure satellite tileset when the tile URL is "" (no pre-event + // imagery). const preUrl = layerImageryRef.current?.preEventTileUrl || ""; loadImagery( toBrowserTitilerUrl(preUrl), - secondary, + preMap, { current: null }, "swipePreEventLayer", true ); - // Wire the native swipe control: the divider reveals `secondary` - // (pre-event / satellite) over the primary labeler map. atlas.SwipeMap - // keeps BOTH cameras in sync on every 'move' internally, so we must NOT - // add our own camera-sync handler (doing so double-updates the cameras - // and makes panning jump/stutter). + + // Draw the building footprints on the pre map too, from the SAME PMTiles + // archive + styling helpers the labeler uses, so outlines (and the + // unlabeled fill) span BOTH panes continuously — SwipeMap clips an entire + // map, so a single footprint layer can only appear on one side. + // + // NOTE: per-building label colors are driven by feature-state on the + // labeler's internal GL map (glMapRef), which this pre map does not have, + // so footprints render here in the UNLABELED color while still showing + // outlines. Per-building label colors currently render on the + // post/interactive (right) pane only; mirroring feature-state across the + // two GL maps would be a follow-up. + if (swipePmtilesUrlRef.current) { + try { + preMap.sources.add( + new window.atlas.source.VectorTileSource("buildings", { + type: "vector", + url: `pmtiles://${swipePmtilesUrlRef.current}`, + }) + ); + preMap.layers.add( + new window.atlas.layer.PolygonLayer("buildings", "swipeFill", { + sourceLayer: PMTILES_SOURCE_LAYER, + fillColor: fillColorExpr("label"), + fillOpacity: fillOpacityExpr("label"), + }) + ); + preMap.layers.add( + new window.atlas.layer.LineLayer("buildings", "swipeOutline", { + sourceLayer: PMTILES_SOURCE_LAYER, + strokeColor: "#1a5276", + minZoom: 15, + strokeWidth: ["step", ["zoom"], 1, 16, 2], + }) + ); + } catch (e) { + console.warn("Swipe pre-map footprints failed:", e); + } + } + + // Wire the native swipe control: PRIMARY = pre map (revealed on the + // LEFT), SECONDARY = labeler map (clipped, revealed on the RIGHT). + // atlas.SwipeMap keeps BOTH cameras in sync on every 'move' internally, + // so we must NOT add our own camera-sync handler (doing so + // double-updates the cameras and makes panning jump/stutter). try { - swipeRef.current = new window.atlas.SwipeMap(primary, secondary); + swipeRef.current = new window.atlas.SwipeMap(preMap, labelerMap); } catch (e) { console.warn("atlas.SwipeMap init failed:", e); } @@ -1210,9 +1412,10 @@ const InteractiveLabeler = () => { return () => { // Tear down the swipe control first — its dispose() removes the divider - // handle it appended to the primary container — then dispose the - // secondary map and reset the container so the primary labeler map and - // its footprint interactions are untouched. + // handle it appended to the PRIMARY (pre map) container and detaches the + // 'move'/'resize' sync handlers from BOTH maps — then dispose the new pre + // map. The labeler map (mapRef.current / the SECONDARY) is deliberately + // NOT disposed so its footprint interactions survive toggling swipe off. if (swipeRef.current) { try { if (typeof swipeRef.current.dispose === "function") { @@ -1223,16 +1426,31 @@ const InteractiveLabeler = () => { } swipeRef.current = null; } - if (swipeSecondaryMapRef.current) { + if (swipePreMapRef.current) { try { - swipeSecondaryMapRef.current.dispose(); + swipePreMapRef.current.dispose(); } catch (e) { - console.warn("swipe secondary map dispose failed:", e); + console.warn("swipe pre map dispose failed:", e); } - swipeSecondaryMapRef.current = null; + swipePreMapRef.current = null; } - // SwipeMap sets an inline `clip` on the secondary container; clear it and - // any leftover atlas DOM so the next toggle starts from a clean slate. + // SwipeMap.dispose() does NOT clear the inline `clip` it set on the + // SECONDARY (labeler) map's container, so clear it here or the labeler + // stays clipped to its right half after swipe is turned off. Clear it on + // both the element getMapContainer() reports and the div we passed to the + // Map constructor, to be safe across Atlas builds. + try { + if (labelerMap && typeof labelerMap.getMapContainer === "function") { + labelerMap.getMapContainer().style.clip = ""; + } + } catch (e) { + console.warn("clearing labeler map clip failed:", e); + } + if (labelerContainer) { + labelerContainer.style.clip = ""; + } + // Reset the pre map's container so the next toggle starts from a clean + // slate (any leftover atlas DOM / clip is removed). container.style.clip = ""; container.innerHTML = ""; }; @@ -1527,19 +1745,21 @@ const InteractiveLabeler = () => {
- {/* Map area: the primary labeler map plus an overlay that hosts the - Advanced → Swipe secondary map. Both map divs are absolutely - positioned and fill this relative wrapper exactly (the same layout - the Visualizer's swipe uses — see Visualizer.jsx + visualizer.css - `.map`), so atlas.SwipeMap's clip/divider on the secondary line up - with the primary underneath. The overlay stays display:none until the + {/* Map area: the labeler map plus the Advanced → Swipe view. Both map + divs are absolutely positioned and fill this relative wrapper exactly + (the same overlapping layout the Visualizer's swipe uses — see + Visualizer.jsx + visualizer.css `.map`). atlas.SwipeMap reveals its + SECONDARY on the RIGHT and shows its PRIMARY on the LEFT, so to land + PRE imagery on the left / POST imagery on the right the PRE map + (primary) must sit in the FIRST/behind div and the labeler map (post + + footprints, secondary) in the SECOND/on-top div — its clipped right + half then reveals the pre map underneath on the left. The divider + handle (z-index:1, appended into the primary/pre container) still + paints above both. The pre-map overlay stays display:none until the swipe toggle is on. */}
-
+ {/* FIRST/behind: swipe PRIMARY = the new pre-event map (built on + demand by the swipe effect while the toggle is on). */}
{ display: swipeOn ? "block" : "none", }} /> + {/* SECOND/on-top: the labeler map. When swipe is on it is adopted as + the SwipeMap SECONDARY and clipped to its right half; when swipe is + off it is the only visible map. */} +
+ + {/* Pane labels, shown only while swipe is on. Rendered inside this + relative wrapper so left/right map to the map area (not the window). + "Pre imagery" sits below the Back button badge so the two don't + overlap; "Post imagery" hugs the top-right corner. pointerEvents: + none so they never intercept a divider drag. */} + {swipeOn && ( + <> +
+ Pre imagery +
+
+ Post imagery +
+ + )}
{isMapReady && ( @@ -1558,9 +1834,11 @@ const InteractiveLabeler = () => { padding: 16, background: "#fff", borderLeft: "1px solid #e1e1e1", - overflowY: "auto", + display: "flex", + flexDirection: "column", }} > +
Interactive Labeler @@ -1795,6 +2073,8 @@ const InteractiveLabeler = () => {
)} + + {
- Drag the divider to reveal pre-event imagery (satellite basemap - when the layer has none). + Drag the divider to compare pre-event imagery (left) with + post-event imagery (right). Satellite basemap is used on the pre + side when the layer has no pre-event imagery.
+ + + + setUncertaintyOn(!!checked)} + /> +
+ Recolors every scored footprint by the model's predictive + uncertainty. Needs {MIN_PER_CLASS}+ labels in at least 2 classes. +
+ {uncertaintyOn && ( +
+
+ Model uncertainty +
+
+
+ Low (confident) + High +
+
+ )}
)} +
-
+
Click a building to label it · right-click to clear ·{" "} Ctrl+drag to box-label · 1/2/ 3 set class · P toggle view ·{" "} From 05d3280402346b45a4118ae43bb75420725952fd Mon Sep 17 00:00:00 2001 From: calebrob6 Date: Wed, 15 Jul 2026 17:22:35 +0000 Subject: [PATCH 4/7] feat: on-map legends, non-blocking CV, and a/s/d swipe-divider hotkeys - Legends on the map: add a bottom-right legend overlay (inside the map wrapper so it tracks the map, not the panel). Shows the class colors (Intact/Damaged/Cloudy, titled Labels or Predicted per view mode), or the uncertainty ramp when the uncertainty view is on. Removed the in-panel uncertainty legend. - Fix map blanking during 'Run 5-fold CV': crossValidateMetrics is now async and yields to the event loop between folds, so the main thread stays free and the map keeps rendering instead of disappearing until results return. handleRunCV awaits it. Verified the async version returns identical results. - Swipe divider hotkeys: with swipe on, A snaps the divider full-left, S to center, D full-right (via SwipeMap setOptions sliderPosition). Hotkey hint updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 131 +++++++++++++----- .../InteractiveLabeler/interactiveModel.js | 6 +- 2 files changed, 101 insertions(+), 36 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index dc780f9..b5b7f38 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -1248,6 +1248,21 @@ const InteractiveLabeler = () => { // when the map container doesn't have focus. e.preventDefault(); setShowFootprints((v) => !v); + } else if ( + swipeRef.current && + (e.key === "a" || e.key === "s" || e.key === "d") + ) { + // Snap the swipe divider: 'a' = full left, 's' = middle, 'd' = full + // right. sliderPosition is in pixels from the left of the map; the + // SwipeMap module clamps out-of-range values to [0, width]. + const el = swipeMapContainerRef.current || mapContainerRef.current; + const w = el ? el.getBoundingClientRect().width : 0; + const pos = e.key === "a" ? 0 : e.key === "s" ? w / 2 : w; + try { + swipeRef.current.setOptions({ sliderPosition: pos }); + } catch (err) { + console.warn("swipe setOptions (sliderPosition) failed:", err); + } } } window.addEventListener("keydown", onKeyDown); @@ -1255,11 +1270,10 @@ const InteractiveLabeler = () => { }, []); // ── Advanced → 5-fold cross-validation ──────────────────────────────────── - // Runs stratified k-fold CV over the current labels via the pure - // crossValidateMetrics helper. The crunch is synchronous CPU work (a few - // hundred ms for large label sets), so we flip cvRunning first and yield to - // the event loop with a 0ms timeout, letting the disabled/spinner state paint - // before the main thread blocks. + // Runs stratified k-fold CV over the current labels via crossValidateMetrics. + // It's CPU-bound, so crossValidateMetrics is async and yields between folds — + // this keeps the main thread free enough that the map keeps rendering instead + // of blanking out while the CV runs. async function handleRunCV() { const entries = Object.values(labeledMapRef.current).filter((e) => isValidVector(e.features) @@ -1272,7 +1286,7 @@ const InteractiveLabeler = () => { setCvResult(null); await new Promise((resolve) => setTimeout(resolve, 0)); try { - setCvResult(crossValidateMetrics(entries, { k: 5 })); + setCvResult(await crossValidateMetrics(entries, { k: 5 })); } catch (e) { console.error("Cross-validation failed:", e); setCvResult({ @@ -1825,6 +1839,79 @@ const InteractiveLabeler = () => {
)} + + {/* Legend, bottom-right of the map. Shows the class colors normally + (Intact / Damaged / Cloudy), or the uncertainty ramp when the + uncertainty view is on. Hidden when footprints are hidden. */} + {isMapReady && showFootprints && ( +
+ {uncertaintyOn ? ( + <> +
+ Model uncertainty +
+
+
+ Low (confident) + High +
+ + ) : ( + <> +
+ {viewMode === "predict" ? "Predicted" : "Labels"} +
+ {CLASS_LABELS.map((name, i) => ( +
+ + {name} +
+ ))} + + )} +
+ )}
{isMapReady && ( @@ -2103,35 +2190,8 @@ const InteractiveLabeler = () => {
Recolors every scored footprint by the model's predictive uncertainty. Needs {MIN_PER_CLASS}+ labels in at least 2 classes. + A legend appears on the map.
- {uncertaintyOn && ( -
-
- Model uncertainty -
-
-
- Low (confident) - High -
-
- )}
)}
@@ -2148,7 +2208,8 @@ const InteractiveLabeler = () => { Click a building to label it · right-click to clear ·{" "} Ctrl+drag to box-label · 1/2/ 3 set class · P toggle view ·{" "} - Space show/hide footprints + Space show/hide footprints · with swipe on,{" "} + A/S/D snap divider left/center/right
)} diff --git a/ui/src/Components/InteractiveLabeler/interactiveModel.js b/ui/src/Components/InteractiveLabeler/interactiveModel.js index 2c8916e..a14fa91 100644 --- a/ui/src/Components/InteractiveLabeler/interactiveModel.js +++ b/ui/src/Components/InteractiveLabeler/interactiveModel.js @@ -386,7 +386,7 @@ function meanStd(vals) { // when the fold has no true positives, or AUC when the held-out fold is // single-class for that label) are skipped for that metric so one degenerate // fold does not drag the estimate toward 0. -export function crossValidateMetrics(entries, { k = 5, opts } = {}) { +export async function crossValidateMetrics(entries, { k = 5, opts } = {}) { const usable = (entries || []).filter((e) => isValidVector(e.features)); const classes = [...new Set(usable.map((e) => e.label))].sort( (a, b) => a - b @@ -480,6 +480,10 @@ export function crossValidateMetrics(entries, { k = 5, opts } = {}) { if (auc != null) aucVals[c].push(auc); } } + // Yield to the event loop between folds so a browser caller stays + // responsive (the map keeps repainting) instead of freezing for the whole + // CPU-bound cross-validation. + await new Promise((resolve) => setTimeout(resolve, 0)); } const perClass = {}; From 50b69a246b859a5ea0eb533357b831d3154861a1 Mon Sep 17 00:00:00 2001 From: calebrob6 Date: Wed, 15 Jul 2026 17:45:11 +0000 Subject: [PATCH 5/7] fix: scope panel scroll to the right bar; move Pre-imagery label above Back - Bound the labeler root to the header-offset viewport height (calc(100vh - 40px - footer)), so the right panel scrolls in its own scrollbar when the Advanced tab is expanded instead of growing the page and pushing the on-map legends below the fold. Mirrors the Visualizer container. - With the swipe view on, the 'Pre imagery' label now sits at the top-left and the Back button drops just below it (top:46), so Pre imagery is above the Back button rather than below it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index b5b7f38..57bf295 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -1735,6 +1735,12 @@ const InteractiveLabeler = () => { style={{ display: "flex", flexGrow: 1, + minHeight: 0, + // Pin to the space below the 40px app header so the right panel scrolls + // internally (its own scrollbar) instead of growing the page — which + // would otherwise push the map and its on-map legends below the fold + // when the Advanced tab is expanded. Mirrors the Visualizer container. + height: "calc(100vh - 40px - var(--footer-height, 0px))", position: "relative", overflow: "hidden", }} @@ -1742,7 +1748,9 @@ const InteractiveLabeler = () => {
{ {/* Pane labels, shown only while swipe is on. Rendered inside this relative wrapper so left/right map to the map area (not the window). - "Pre imagery" sits below the Back button badge so the two don't - overlap; "Post imagery" hugs the top-right corner. pointerEvents: - none so they never intercept a divider drag. */} + "Pre imagery" sits at the very top-left (above the Back button, which + drops to top:46 while swiping); "Post imagery" hugs the top-right + corner. pointerEvents: none so they never intercept a divider + drag. */} {swipeOn && ( <>
Date: Wed, 15 Jul 2026 18:01:15 +0000 Subject: [PATCH 6/7] feat: mutually exclusive Predicted/Uncertainty views, gated on trainability - Predicted view and Uncertainty view are now mutually exclusive: turning one on (via toggle or the P hotkey) turns the other off. - Both the View toggle and the Uncertainty toggle are disabled until there are enough labels to train (MIN_PER_CLASS in 2+ classes), with a hint shown; the P hotkey is likewise ignored until then. If labels later drop below the threshold, the map falls back to the Labeled view so it can't get stuck in a disabled Predicted/Uncertainty state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InteractiveLabeler/InteractiveLabeler.jsx | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index 57bf295..f8df76b 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -397,6 +397,9 @@ const InteractiveLabeler = () => { useEffect(() => { uncertaintyOnRef.current = uncertaintyOn; }, [uncertaintyOn]); + // Read by the P hotkey (long-lived listener) so it can't switch to the + // Predicted view before there are enough labels to train. + const canTrainRef = useRef(false); // Detect the compute backend up-front so the panel shows WebGPU vs CPU. useEffect(() => { @@ -1241,9 +1244,15 @@ const InteractiveLabeler = () => { else if (e.key === "3") setSelectedClass(CLASS_CLOUDY); else if (e.key === "t" || e.key === "T") setSelectedClass((c) => (c + 1) % 3); - else if (e.key === "p" || e.key === "P") - setViewMode((v) => (v === "label" ? "predict" : "label")); - else if (e.key === " " || e.code === "Space") { + else if (e.key === "p" || e.key === "P") { + // Predicted view needs a trained model; ignore until we have enough + // labels (matches the disabled View toggle). + if (!canTrainRef.current) return; + const next = viewModeRef.current === "label" ? "predict" : "label"; + setViewMode(next); + // Predicted view and Uncertainty view are mutually exclusive. + if (next === "predict") setUncertaintyOn(false); + } else if (e.key === " " || e.code === "Space") { // preventDefault to stop the browser from scrolling the page // when the map container doesn't have focus. e.preventDefault(); @@ -1729,6 +1738,25 @@ const InteractiveLabeler = () => { } const totalLabeled = counts[0] + counts[1] + counts[2]; + // The Predicted and Uncertainty views both need a trained model, which needs + // at least MIN_PER_CLASS labels in 2+ classes. Gate both toggles on this. + const canTrain = + [ + counts[CLASS_INTACT], + counts[CLASS_DAMAGED], + counts[CLASS_CLOUDY], + ].filter((n) => n >= MIN_PER_CLASS).length >= 2; + + // If labels drop back below the trainable threshold (e.g. after clearing), + // fall back to the Labeled view so we don't sit in a now-disabled Predicted + // or Uncertainty view with no model behind it. + useEffect(() => { + canTrainRef.current = canTrain; + if (!canTrain) { + setUncertaintyOn(false); + setViewMode("label"); + } + }, [canTrain]); return (
{ onText="Predicted" offText="Labeled" checked={viewMode === "predict"} - onChange={(e, checked) => - setViewMode(checked ? "predict" : "label") - } + disabled={!canTrain} + onChange={(e, checked) => { + setViewMode(checked ? "predict" : "label"); + // Predicted view and Uncertainty view are mutually exclusive. + if (checked) setUncertaintyOn(false); + }} style={{ marginTop: 12 }} /> + {!canTrain && ( +
+ Predicted / Uncertainty views need {MIN_PER_CLASS}+ labels in at + least 2 classes. +
+ )} { onText="On" offText="Off" checked={uncertaintyOn} - onChange={(e, checked) => setUncertaintyOn(!!checked)} + disabled={!canTrain} + onChange={(e, checked) => { + setUncertaintyOn(!!checked); + // Uncertainty view and Predicted view are mutually exclusive; + // switching this on drops the map back to the Labeled view. + if (checked) setViewMode("label"); + }} />
Recolors every scored footprint by the model's predictive From 882b416ff69e7d5765bcf0f497af7954fa79e278 Mon Sep 17 00:00:00 2001 From: Meygha Machado <57337661+mgmachado@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:20:51 -0700 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx | 4 ++-- ui/src/Components/InteractiveLabeler/interactiveModel.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx index f8df76b..93a271e 100644 --- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx +++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx @@ -1404,8 +1404,8 @@ const InteractiveLabeler = () => { preMap.layers.add( new window.atlas.layer.PolygonLayer("buildings", "swipeFill", { sourceLayer: PMTILES_SOURCE_LAYER, - fillColor: fillColorExpr("label"), - fillOpacity: fillOpacityExpr("label"), + fillColor: UNLABELED_COLOR, + fillOpacity: 0.15, }) ); preMap.layers.add( diff --git a/ui/src/Components/InteractiveLabeler/interactiveModel.js b/ui/src/Components/InteractiveLabeler/interactiveModel.js index a14fa91..7f811d7 100644 --- a/ui/src/Components/InteractiveLabeler/interactiveModel.js +++ b/ui/src/Components/InteractiveLabeler/interactiveModel.js @@ -321,7 +321,7 @@ export function computeClassMetrics(preds, labels) { // since AUC is undefined without at least one of each class. export function binaryAUC(scores, labels01) { const n = scores.length; - if (n === 0) return null; + if (n === 0 || labels01.length !== n) return null; let nPos = 0; let nNeg = 0; for (let i = 0; i < n; i++) {