File size: 7,792 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
"""
Single-image VQA specialist model.

Phase 5 (P0). Satisfies PS requirement:
- "Single-image visual question answering shall be mandatory."
- "At least one visual or vision-language component must be fine-tuned
   or otherwise adapted using BigEarthNet.txt or any open source
   training data." (this is that component)

MODEL CHOICE β€” Salesforce/blip-vqa-base
- Verified supports: general open-domain VQA (image + free-text
  question -> free-text answer). It is NOT remote-sensing-native out of
  the box β€” that's why the PS-mandated adaptation step in
  backend/adaptation/ exists (see adaptation/README.md).
- Modality: input = 1 RGB image + 1 text question. Output = short text
  answer (BLIP-VQA is trained as generative, not classification-head).
- Integration complexity: LOW. Pure `transformers`, no custom CUDA ops,
  ~385M params β€” runs on CPU (slow, seconds/query) or any small GPU.
  This is why it was chosen over a 7B-parameter RS-VLM (e.g. GeoChat):
  those need real GPU infra we cannot assume the team has in 4 days.
- License: BSD-3-Clause (Salesforce/BLIP). Free for hackathon/research
  use. Verify before any commercial deployment.
- We are NOT claiming this is a validated remote-sensing benchmark
  model. Confidence returned below is a decoding-confidence proxy from
  the model's own generation, not a calibrated accuracy figure β€” see
  VQAResult.confidence_note.

ADAPTATION
If a LoRA adapter trained by backend/adaptation/train_lora_vqa.py exists
at VQA_LORA_ADAPTER_PATH, it is loaded on top of the base model. If not,
the base (non-adapted) model is used and `adapted=False` is reported
honestly in every response β€” never silently claim adaptation happened.
"""
from __future__ import annotations

import os
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from preprocessing.raster_to_rgb import load_as_rgb

BASE_MODEL_ID = "Salesforce/blip-vqa-base"
DEFAULT_ADAPTER_DIR = Path(__file__).resolve().parent / "checkpoints" / "vqa_lora_bigearthnet"
ADAPTER_PATH = Path(os.environ.get("VQA_LORA_ADAPTER_PATH", DEFAULT_ADAPTER_DIR))

# Buckets mirror master brief Β§10: HIGH / MEDIUM / INSUFFICIENT confidence.
# Thresholds are a prototype policy, not a scientifically validated cutoff
# β€” state that explicitly wherever this is surfaced.
_HIGH_THRESHOLD = 0.75
_MEDIUM_THRESHOLD = 0.40


@dataclass
class VQAResult:
    answer: str
    confidence: float
    confidence_level: str  # "high" | "medium" | "insufficient"
    confidence_note: str
    model_id: str
    adapted: bool


def _load_adapter_info(adapter_path: Path) -> Optional[dict]:
    """Phase 14: read the honest per-epoch/best-checkpoint metrics that
    train_lora_vqa.py wrote alongside the adapter (adapter_info.json).
    Missing/unreadable file is not an error β€” older adapters trained
    before this existed simply won't have one."""
    info_path = adapter_path / "adapter_info.json"
    if not info_path.exists():
        return None
    try:
        import json
        return json.loads(info_path.read_text())
    except Exception:  # noqa: BLE001
        return None


class _VQAModel:
    """Lazy-loaded singleton so the model loads once per process, not per request."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._processor = None
        self._model = None
        self._adapted = False
        self._adapter_info: Optional[dict] = None
        self._device = "cpu"

    def _ensure_loaded(self) -> None:
        if self._model is not None:
            return
        with self._lock:
            if self._model is not None:  # re-check inside the lock
                return

            import torch
            from transformers import BlipForQuestionAnswering, BlipProcessor

            self._device = "cuda" if torch.cuda.is_available() else "cpu"
            self._processor = BlipProcessor.from_pretrained(BASE_MODEL_ID)
            model = BlipForQuestionAnswering.from_pretrained(BASE_MODEL_ID)

            if ADAPTER_PATH.exists():
                try:
                    from peft import PeftModel

                    model = PeftModel.from_pretrained(model, str(ADAPTER_PATH))
                    self._adapted = True
                    self._adapter_info = _load_adapter_info(ADAPTER_PATH)
                except Exception as exc:  # noqa: BLE001
                    # Fail open to the base model β€” a broken adapter should
                    # never take the whole VQA endpoint down mid-hackathon.
                    print(f"[vqa] Failed to load LoRA adapter at {ADAPTER_PATH}: {exc}. "
                          f"Falling back to base {BASE_MODEL_ID}.")
                    self._adapted = False

            self._model = model.to(self._device).eval()

    def answer(self, image_path: Path, question: str) -> VQAResult:
        self._ensure_loaded()
        import torch

        image = load_as_rgb(image_path)
        inputs = self._processor(image, question, return_tensors="pt").to(self._device)

        with torch.no_grad():
            out = self._model.generate(
                **inputs,
                max_new_tokens=30,
                output_scores=True,
                return_dict_in_generate=True,
            )

        answer_text = self._processor.decode(out.sequences[0], skip_special_tokens=True).strip()
        confidence = _decode_confidence(out)
        level, note = _bucket_confidence(confidence)

        return VQAResult(
            answer=answer_text or "(model returned an empty answer)",
            confidence=confidence,
            confidence_level=level,
            confidence_note=note,
            model_id=f"{BASE_MODEL_ID}" + (
                f" + BigEarthNet LoRA adapter (best val eval_loss={self._adapter_info['best_eval_loss']:.4f}, "
                f"epoch {self._adapter_info.get('best_epoch')})"
                if self._adapted and self._adapter_info and self._adapter_info.get("best_eval_loss") is not None
                else " + BigEarthNet LoRA adapter" if self._adapted else " (base, not yet adapted)"
            ),
            adapted=self._adapted,
        )


def _decode_confidence(generate_output) -> float:
    """Mean per-token generation probability, normalized to [0,1].

    This is a decoding-confidence proxy (how sure the model was about the
    tokens it emitted), NOT a calibrated task-accuracy estimate. Do not
    present it as validated benchmark performance.
    """
    import torch

    scores = getattr(generate_output, "scores", None)
    if not scores:
        return 0.5  # no score info available β€” neutral default, not a real signal

    token_probs = []
    for step_logits in scores:
        probs = torch.softmax(step_logits[0], dim=-1)
        token_probs.append(probs.max().item())

    if not token_probs:
        return 0.5
    return float(sum(token_probs) / len(token_probs))


def _bucket_confidence(confidence: float) -> tuple[str, str]:
    if confidence >= _HIGH_THRESHOLD:
        return "high", "Model was consistently confident across generated tokens."
    if confidence >= _MEDIUM_THRESHOLD:
        return "medium", "Model showed moderate token-level uncertainty β€” treat as indicative, not definitive."
    return "insufficient", "Model showed high token-level uncertainty β€” reliable analysis cannot be assumed from this alone."


_singleton: Optional[_VQAModel] = None
_singleton_lock = threading.Lock()


def get_vqa_model() -> _VQAModel:
    global _singleton
    if _singleton is None:
        with _singleton_lock:
            if _singleton is None:
                _singleton = _VQAModel()
    return _singleton


def run_vqa(image_path: Path, question: str) -> VQAResult:
    return get_vqa_model().answer(image_path, question)