SatQuery-api / backend /evidence /confidence_map.py
Sahil-nith's picture
Upload 47 files
0127c21 verified
Raw History Blame Contribute Delete
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")
@dataclass
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."
),
)