File size: 7,861 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
"""
Text-guided region grounding specialist model.

Phase 6 (P0). Satisfies PS requirement:
- "Each solution must additionally implement either captioning/scene
   description OR text-guided region grounding." β€” we chose grounding
   (see README Β§4 rationale: stronger map/demo fit, produces the
   bounding-box evidence the PS explicitly calls for: "Highlight the
   water body referred to in the query.")

MODEL CHOICE β€” google/owlvit-base-patch32
- Verified supports: open-vocabulary object detection. Give it an image
  + arbitrary text phrase(s), it returns bounding boxes + a match score
  per phrase. This is exactly "text-guided region grounding" β€” no
  detection-head retraining needed to add new object classes.
- Modality: input = 1 RGB image + 1-N text phrases. Output = boxes
  (pixel coords) + confidence score per phrase.
- Integration complexity: LOW. Pure `transformers`
  (OwlViTProcessor/OwlViTForObjectDetection), same dependency footprint
  as Phase 5's VQA model β€” no new packages required.
- License: Apache 2.0 (OWL-ViT / Google). Free for hackathon/research use.
- NOT remote-sensing-native. It was trained on natural-image/web data,
  so expect it to do well on generic categories ("water", "building",
  "road", "vehicle", "vegetation") and poorly on RS-specific phrasing
  ("built-up area", "paddy field") until adapted. This model is a
  strong Phase-6 candidate for the SAME LoRA-style domain adaptation
  approach as Phase 5's VQA model β€” not yet built; would be a Phase 6.5
  follow-up if time allows, not required for the PS baseline.
- Confidence returned is the detector's own softmax match score for the
  best box, NOT a validated grounding-accuracy benchmark figure.
"""
from __future__ import annotations

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

from preprocessing.raster_to_rgb import load_as_rgb

MODEL_ID = "google/owlvit-base-patch32"

# Same confidence-bucket policy as Phase 5 (master brief Β§10) β€” kept
# consistent across specialist models so the UI doesn't need per-task
# thresholds.
_HIGH_THRESHOLD = 0.30   # OWL-ViT match scores run lower than VQA token
_MEDIUM_THRESHOLD = 0.12  # probs by nature of open-vocab detection; these
                           # are a prototype policy, not a validated cutoff.

# Rule-based phrase extraction β€” strips grounding-intent scaffolding off
# the user's natural-language query so OWL-ViT gets a clean noun phrase.
# Deterministic on purpose, consistent with agent/router.py's approach.
_STRIP_PREFIXES = [
    "where is the", "where is", "highlight the", "highlight", "locate the",
    "locate", "point to the", "point to", "show me the", "show me",
    "draw a box around the", "draw a box around", "mark the", "mark",
    "find the", "find",
]
_STRIP_SUFFIXES = ["referred to in the query", "in this image", "in the image"]


@dataclass
class BoundingBox:
    x_min: float
    y_min: float
    x_max: float
    y_max: float


@dataclass
class GroundingResult:
    phrase: str
    found: bool
    box: Optional[BoundingBox]
    score: float
    confidence_level: str  # "high" | "medium" | "insufficient"
    confidence_note: str
    image_width: int
    image_height: int
    model_id: str
    adapted: bool


def extract_referring_phrase(query: str) -> str:
    """Turn a grounding question into a short noun phrase for the detector.

    e.g. "Where is the water body?" -> "water body"
         "Highlight the flooded area." -> "flooded area"

    Purely rule-based (strip known prefixes/suffixes + punctuation) β€”
    no LLM call, consistent with the router's deterministic classification.
    If nothing matches, falls back to the raw query so grounding still
    attempts something rather than failing silently.
    """
    q = query.strip().rstrip("?.!").lower()

    for prefix in sorted(_STRIP_PREFIXES, key=len, reverse=True):
        if q.startswith(prefix):
            q = q[len(prefix):].strip()
            break

    for suffix in _STRIP_SUFFIXES:
        if q.endswith(suffix):
            q = q[: -len(suffix)].strip()

    q = re.sub(r"^(the|a|an)\s+", "", q).strip()
    return q or query.strip().rstrip("?.!")


class _GroundingModel:
    """Lazy-loaded singleton, mirrors models/vqa.py's pattern."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._processor = None
        self._model = None
        self._device = "cpu"
        self._adapted = False  # no grounding adapter exists yet β€” see docstring

    def _ensure_loaded(self) -> None:
        if self._model is not None:
            return
        with self._lock:
            if self._model is not None:
                return

            import torch
            from transformers import OwlViTForObjectDetection, OwlViTProcessor

            self._device = "cuda" if torch.cuda.is_available() else "cpu"
            self._processor = OwlViTProcessor.from_pretrained(MODEL_ID)
            self._model = OwlViTForObjectDetection.from_pretrained(MODEL_ID).to(self._device).eval()

    def ground(self, image_path: Path, phrase: str) -> GroundingResult:
        self._ensure_loaded()
        import torch

        image = load_as_rgb(image_path)
        width, height = image.size

        inputs = self._processor(text=[[phrase]], images=image, return_tensors="pt").to(self._device)
        with torch.no_grad():
            outputs = self._model(**inputs)

        target_sizes = torch.tensor([[height, width]])
        results = self._processor.post_process_grounded_object_detection(
            outputs=outputs, target_sizes=target_sizes, threshold=0.0
        )[0]

        boxes, scores = results["boxes"], results["scores"]
        if len(scores) == 0:
            level, note = "insufficient", "Detector returned no candidate region at all for this phrase."
            return GroundingResult(
                phrase=phrase, found=False, box=None, score=0.0,
                confidence_level=level, confidence_note=note,
                image_width=width, image_height=height,
                model_id=self._model_id_label(), adapted=self._adapted,
            )

        best_idx = int(torch.argmax(scores))
        best_score = float(scores[best_idx])
        x_min, y_min, x_max, y_max = (float(v) for v in boxes[best_idx])
        level, note = _bucket_confidence(best_score)

        return GroundingResult(
            phrase=phrase,
            found=True,
            box=BoundingBox(x_min=x_min, y_min=y_min, x_max=x_max, y_max=y_max),
            score=best_score,
            confidence_level=level,
            confidence_note=note,
            image_width=width,
            image_height=height,
            model_id=self._model_id_label(),
            adapted=self._adapted,
        )

    def _model_id_label(self) -> str:
        return MODEL_ID + (" + RS LoRA adapter" if self._adapted else " (base, not RS-adapted)")


def _bucket_confidence(score: float) -> tuple[str, str]:
    if score >= _HIGH_THRESHOLD:
        return "high", "Detector was strongly confident this region matches the phrase."
    if score >= _MEDIUM_THRESHOLD:
        return "medium", "Detector found a plausible region but with moderate uncertainty β€” verify visually."
    return "insufficient", "Best candidate region had low match confidence β€” treat as unreliable."


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


def get_grounding_model() -> _GroundingModel:
    global _singleton
    if _singleton is None:
        with _singleton_lock:
            if _singleton is None:
                _singleton = _GroundingModel()
    return _singleton


def run_grounding(image_path: Path, query: str) -> GroundingResult:
    phrase = extract_referring_phrase(query)
    return get_grounding_model().ground(image_path, phrase)