Spaces:
Paused
Paused
File size: 9,916 Bytes
0127c21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """
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."
),
)
|