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."
        ),
    )