Spaces:
Paused
Paused
Download backend/evidence/confidence_map.py from Sahil-nith/SatQuery-api: direct link, hf CLI and curl.
- Browser
- Download file 9.92 kB
-
https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/evidence/confidence_map.py
- Command line
-
hf download hf://spaces/Sahil-nith/SatQuery-api/backend/evidence/confidence_map.py
-
curl -L -o confidence_map.py https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/evidence/confidence_map.py
9.92 kB
| """ | |
| Confidence/evidence map rendering β turns a task's underlying evidence | |
| array into a small base64 PNG heatmap attached to the API response, | |
| plus a "source" attribution block naming which uploaded image(s) and | |
| tool produced it. | |
| Phase 13 (P0/P1 β closes a gap flagged during review: query responses | |
| returned scalar confidence + a bounding box/change-map summary, but no | |
| attached spatial confidence visualization, and no explicit per-result | |
| "source" record separate from the free-text note). | |
| HONESTY CONTRACT: | |
| - change_detection / change_vqa / optical_sar already compute a real | |
| per-pixel array (diff z-score magnitude; agreement/optical-only/ | |
| sar-only masks). Their confidence maps ARE that real array, just | |
| colorized β not a fabricated visualization. | |
| - grounding's map is a synthetic Gaussian falloff centered on the | |
| returned box, scaled by the detector's score. It looks like a | |
| heatmap but is NOT a learned saliency map β the note says so. | |
| - vqa has no spatial output at all (single free-text answer over the | |
| whole image). Its "confidence map" is a flat color tint of the | |
| answer-level confidence bucket (green/amber/red) β explicitly labeled | |
| as an answer-level indicator, NOT a pixel-level map, so it is never | |
| mistaken for spatial evidence that doesn't exist. | |
| No matplotlib dependency β colormap is a small hand-rolled | |
| blue -> green -> yellow -> red ramp using only numpy + Pillow (both | |
| already in requirements.txt). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| from dataclasses import dataclass | |
| from typing import Literal, Optional | |
| import numpy as np | |
| from PIL import Image | |
| # Simple 4-stop diverging ramp: low evidence -> blue, mid -> green/yellow, | |
| # high -> red. Values in [0, 1]. | |
| _STOPS = [ | |
| (0.00, (30, 60, 150)), # low confidence / no evidence β blue | |
| (0.35, (40, 160, 90)), # green | |
| (0.65, (230, 190, 40)), # amber | |
| (1.00, (210, 40, 40)), # red β high-magnitude evidence / change | |
| ] | |
| def _colorize(values01: np.ndarray) -> np.ndarray: | |
| """values01: float array in [0,1]. Returns uint8 HxWx3.""" | |
| v = np.clip(values01, 0.0, 1.0) | |
| out = np.zeros(v.shape + (3,), dtype="float32") | |
| for i in range(len(_STOPS) - 1): | |
| lo_t, lo_c = _STOPS[i] | |
| hi_t, hi_c = _STOPS[i + 1] | |
| band = (v >= lo_t) & (v <= hi_t) | |
| if not band.any(): | |
| continue | |
| span = (hi_t - lo_t) or 1e-6 | |
| frac = ((v[band] - lo_t) / span)[..., None] | |
| lo_arr = np.array(lo_c, dtype="float32") | |
| hi_arr = np.array(hi_c, dtype="float32") | |
| out[band] = lo_arr + frac * (hi_arr - lo_arr) | |
| return out.astype("uint8") | |
| def _to_base64_png(rgb_array: np.ndarray, max_dim: int = 512) -> str: | |
| img = Image.fromarray(rgb_array, mode="RGB") | |
| if max(img.size) > max_dim: | |
| scale = max_dim / max(img.size) | |
| img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale)))) | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| return base64.b64encode(buf.getvalue()).decode("ascii") | |
| class ConfidenceMap: | |
| png_base64: str | |
| kind: Literal["pixel_evidence", "synthetic_heatmap", "answer_level_tint"] | |
| note: str | |
| def from_change_diff(diff: np.ndarray, threshold_mask: np.ndarray) -> ConfidenceMap: | |
| """Real per-pixel evidence: normalized diff magnitude, with the | |
| thresholded 'changed' mask pushed toward red so it's visually obvious | |
| which pixels actually crossed the change threshold vs. background noise.""" | |
| d = diff.astype("float32") | |
| rng = float(d.max() - d.min()) or 1.0 | |
| norm = (d - d.min()) / rng | |
| norm = np.where(threshold_mask, np.clip(norm * 0.5 + 0.5, 0.5, 1.0), norm * 0.5) | |
| rgb = _colorize(norm) | |
| return ConfidenceMap( | |
| png_base64=_to_base64_png(rgb), | |
| kind="pixel_evidence", | |
| note=( | |
| "Real per-pixel evidence: normalized before/after intensity-difference " | |
| "magnitude. Pixels that crossed the statistical change threshold are " | |
| "pushed toward red; this is the same array the change_map percentage " | |
| "was computed from, not a separate/approximated visualization." | |
| ), | |
| ) | |
| def from_optical_sar_masks(agreement: np.ndarray, optical_only: np.ndarray, sar_only: np.ndarray) -> ConfidenceMap: | |
| """Real per-pixel evidence: 1.0 where both modalities agree (highest | |
| confidence), 0.6 where only one modality flags the target, 0.0 elsewhere.""" | |
| score = np.zeros(agreement.shape, dtype="float32") | |
| score[optical_only | sar_only] = 0.55 | |
| score[agreement] = 1.0 | |
| rgb = _colorize(score) | |
| return ConfidenceMap( | |
| png_base64=_to_base64_png(rgb), | |
| kind="pixel_evidence", | |
| note=( | |
| "Real per-pixel evidence: red = optical AND SAR agree on the target " | |
| "(highest confidence), amber = only one modality flagged it, blue = " | |
| "neither. Same masks used to compute agreement_pct/optical_only_pct/" | |
| "sar_only_pct β this is not a separate/approximated visualization." | |
| ), | |
| ) | |
| def from_grounding_box( | |
| width: int, height: int, box: Optional[tuple], score: float | |
| ) -> ConfidenceMap: | |
| """Synthetic Gaussian falloff centered on the detected box, scaled by | |
| the detector's own match score. NOT a learned saliency/attention map β | |
| the returned note says so explicitly.""" | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| if box is None: | |
| score_field = np.zeros((height, width), dtype="float32") | |
| else: | |
| x_min, y_min, x_max, y_max = box | |
| cx, cy = (x_min + x_max) / 2, (y_min + y_max) / 2 | |
| sx = max((x_max - x_min) / 2, 5.0) | |
| sy = max((y_max - y_min) / 2, 5.0) | |
| gauss = np.exp(-(((xx - cx) ** 2) / (2 * sx ** 2) + ((yy - cy) ** 2) / (2 * sy ** 2))) | |
| score_field = np.clip(gauss * min(max(score, 0.0), 1.0) * 1.5, 0, 1).astype("float32") | |
| rgb = _colorize(score_field) | |
| return ConfidenceMap( | |
| png_base64=_to_base64_png(rgb), | |
| kind="synthetic_heatmap", | |
| note=( | |
| "Synthetic visualization only: a Gaussian falloff centered on the " | |
| "detector's returned box, scaled by its match score. This is NOT a " | |
| "learned attention/saliency map from the detection model itself β it " | |
| "exists to make the box's location and confidence visually legible, " | |
| "not to claim pixel-level model attribution." | |
| ), | |
| ) | |
| def _load_gray(path) -> np.ndarray: | |
| """Mirrors the grayscale loader used in models/change.py and | |
| models/optical_sar.py exactly (same percentile stretch), so the | |
| confidence map reflects the SAME pixels those heuristics reasoned | |
| over β not a separately-approximated recomputation.""" | |
| import rasterio # deferred: heavy dependency, only needed when actually reading a raster | |
| from preprocessing.raster_to_rgb import to_uint8 | |
| with rasterio.open(path) as src: | |
| band = src.read(1).astype("float32") | |
| if src.count >= 3: | |
| g = src.read(2).astype("float32") | |
| b = src.read(3).astype("float32") | |
| band = (band + g + b) / 3.0 | |
| return to_uint8(band) | |
| def _resize_to_match(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]: | |
| if a.shape == b.shape: | |
| return a, b | |
| h, w = min(a.shape[0], b.shape[0]), min(a.shape[1], b.shape[1]) | |
| a2 = np.array(Image.fromarray(a.astype("uint8")).resize((w, h))) | |
| b2 = np.array(Image.fromarray(b.astype("uint8")).resize((w, h))) | |
| return a2, b2 | |
| def render_change_confidence_map(before_path, after_path, diff_z_threshold: float = 1.5) -> ConfidenceMap: | |
| """Path-based convenience wrapper: recomputes the same diff/threshold | |
| logic as models/change.py::compute_change_map so the confidence map | |
| stays a real (not fabricated) evidence array without requiring | |
| change.py's internal function signature to change.""" | |
| before = _load_gray(before_path) | |
| after = _load_gray(after_path) | |
| before, after = _resize_to_match(before, after) | |
| diff = np.abs(after.astype("float32") - before.astype("float32")) | |
| mean, std = float(diff.mean()), float(diff.std() or 1.0) | |
| mask = diff > (mean + diff_z_threshold * std) | |
| return from_change_diff(diff, mask) | |
| def render_optical_sar_confidence_map(optical_path, sar_path, target: str) -> ConfidenceMap: | |
| """Path-based convenience wrapper: recomputes the same percentile- | |
| threshold agreement masks as models/optical_sar.py::run_optical_sar.""" | |
| optical = _load_gray(optical_path).astype("float32") | |
| sar = _load_gray(sar_path).astype("float32") | |
| optical, sar = _resize_to_match(optical, sar) | |
| if target == "water": | |
| opt_thresh, sar_thresh = np.percentile(optical, 25), np.percentile(sar, 25) | |
| opt_mask, sar_mask = optical <= opt_thresh, sar <= sar_thresh | |
| else: | |
| opt_thresh, sar_thresh = np.percentile(optical, 85), np.percentile(sar, 85) | |
| opt_mask, sar_mask = optical >= opt_thresh, sar >= sar_thresh | |
| agreement = opt_mask & sar_mask | |
| optical_only = opt_mask & ~sar_mask | |
| sar_only = sar_mask & ~opt_mask | |
| return from_optical_sar_masks(agreement, optical_only, sar_only) | |
| def from_answer_confidence(width: int, height: int, confidence: float, level: str) -> ConfidenceMap: | |
| """VQA/captioning have no spatial output. Flat tint communicates the | |
| answer-level confidence bucket only β explicitly NOT a spatial map.""" | |
| field = np.full((height, width), float(np.clip(confidence, 0.0, 1.0)), dtype="float32") | |
| rgb = _colorize(field) | |
| return ConfidenceMap( | |
| png_base64=_to_base64_png(rgb), | |
| kind="answer_level_tint", | |
| note=( | |
| f"This task has no spatial output β the flat {level.upper()} tint " | |
| "represents the single answer-level confidence score for the whole " | |
| "image, NOT a pixel-level confidence map. Do not present this as " | |
| "spatial evidence." | |
| ), | |
| ) | |