File size: 21,730 Bytes
2407511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""Conservative local semantic interpretation of existing bi-temporal evidence.

This module does not inspect model internals, alter masks, or invoke inference.  It
turns already-published change geometry, deterministic differences, and optional
SVE scene-prior deltas into a structured intermediate representation and prose.
"""

from __future__ import annotations

import math
import os
import re
from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional, Sequence

from ..models import BuiltUpChangeAssessment, ChangeAnalysisResponse, SemanticChangeSummary, SemanticTransition


SEMANTIC_INTERPRETER_VERSION = "bitemporal-semantic-1.0"


def _float_env(name: str, default: float, minimum: float, maximum: float) -> float:
    try:
        return min(maximum, max(minimum, float(os.getenv(name, str(default)))))
    except (TypeError, ValueError):
        return default


@dataclass(frozen=True)
class SemanticInterpreterConfig:
    minimal_change_percent: float = 0.5
    localized_change_percent: float = 2.0
    noticeable_change_percent: float = 10.0
    widespread_change_percent: float = 30.0
    semantic_delta_threshold: float = 0.035
    strong_semantic_delta: float = 0.08
    disagreement_gap_percent: float = 10.0
    disagreement_ratio: float = 4.0


DEFAULT_CONFIG = SemanticInterpreterConfig(
    semantic_delta_threshold=_float_env("SATQUERY_SEMANTIC_DELTA_THRESHOLD", 0.035, 0.001, 0.5),
    strong_semantic_delta=_float_env("SATQUERY_SEMANTIC_STRONG_DELTA", 0.08, 0.001, 1.0),
    disagreement_gap_percent=_float_env("SATQUERY_CHANGE_DISAGREEMENT_GAP", 10.0, 0.0, 100.0),
    disagreement_ratio=_float_env("SATQUERY_CHANGE_DISAGREEMENT_RATIO", 4.0, 1.0, 100.0),
)


_FAMILIES: Dict[str, tuple[str, ...]] = {
    "built_up": ("urban or built-up area", "residential area", "industrial area"),
    "vegetation": ("vegetation", "forest", "grassland"),
    "water": ("inland water", "marine water", "wetland"),
    "infrastructure": ("road network", "airport", "harbor"),
}


def classify_change_intent(question: str) -> str:
    """Classify a bounded semantic intent without changing agent routing."""

    normalized = re.sub(r"[^a-z0-9]+", " ", (question or "").lower()).strip()
    if not normalized:
        return "unknown"
    if any(term in normalized for term in ("what caused", "why did", "who caused", "reason for")):
        return "unknown"
    if "largest" in normalized and any(term in normalized for term in ("where", "location", "occur")):
        return "largest_change"
    if any(term in normalized for term in ("how much", "percentage", "percent", "amount of change")):
        return "amount_of_change"
    if any(term in normalized for term in ("built up", "builtup", "urban", "building", "construction", "developed")):
        return "built_up_change"
    if "structural" in normalized:
        return "structural_change"
    if any(term in normalized for term in ("vegetation", "forest", "trees", "green cover", "grassland")):
        return "vegetation_change"
    if any(term in normalized for term in ("water", "river", "lake", "coast", "shoreline", "wetland")):
        return "water_change"
    if any(term in normalized for term in ("road", "infrastructure", "airport", "harbor")):
        return "infrastructure_change"
    if any(term in normalized for term in ("no change", "unchanged", "remain the same", "stayed the same", "mostly similar")):
        return "no_change_check"
    if any(term in normalized for term in ("where", "location", "which part", "which area")) and "change" in normalized:
        return "change_location"
    if any(term in normalized for term in ("compare", "difference between", "how different")):
        return "general_comparison"
    if any(term in normalized for term in ("what changed", "describe the change", "change between", "changes between")):
        return "change_summary"
    return "unknown"


def _requested_direction(question: str) -> Optional[str]:
    normalized = (question or "").lower()
    if any(term in normalized for term in ("increase", "increased", "expand", "expanded", "growth", "grew", "more")):
        return "increase"
    if any(term in normalized for term in ("decrease", "decreased", "shrink", "shrunk", "loss", "lost", "less", "reduced")):
        return "decrease"
    return None


def _finite_number(value: Any) -> Optional[float]:
    try:
        parsed = float(value)
    except (TypeError, ValueError):
        return None
    return parsed if math.isfinite(parsed) else None


def _location(box: Any, width: int, height: int) -> Optional[str]:
    try:
        x = (float(box.left) + float(box.right)) / (2.0 * max(width, 1))
        y = (float(box.top) + float(box.bottom)) / (2.0 * max(height, 1))
    except (AttributeError, TypeError, ValueError):
        return None
    horizontal = "left" if x < 1 / 3 else "right" if x > 2 / 3 else "central"
    vertical = "upper" if y < 1 / 3 else "lower" if y > 2 / 3 else "central"
    if horizontal == vertical == "central":
        return "center"
    if horizontal == "central":
        return f"{vertical}-central"
    if vertical == "central":
        return f"central-{horizontal}"
    return f"{vertical}-{horizontal}"


def _magnitude(value: float, config: SemanticInterpreterConfig) -> tuple[str, str]:
    if value < config.minimal_change_percent:
        return "minimal", "very limited change"
    if value < config.localized_change_percent:
        return "localized", "localized change"
    if value < config.noticeable_change_percent:
        return "moderate", "noticeable but limited change"
    if value < config.widespread_change_percent:
        return "moderate", "substantial change"
    return "widespread", "widespread visual difference"


def _prior_changes(change: ChangeAnalysisResponse) -> list[dict[str, Any]]:
    sve = getattr(change, "sve_result", None)
    comparison = getattr(sve, "semantic_comparison", None) if sve and getattr(sve, "available", False) else None
    if comparison is None or getattr(comparison, "status", "") != "supporting_evidence":
        return []
    output = []
    for item in getattr(comparison, "prior_changes", []) or []:
        if not isinstance(item, dict) or not isinstance(item.get("label"), str):
            continue
        difference = _finite_number(item.get("difference"))
        if difference is None:
            continue
        output.append({"label": item["label"], "difference": difference})
    return output


def _transition_for_family(
    family: str,
    changes: Sequence[dict[str, Any]],
    config: SemanticInterpreterConfig,
) -> Optional[SemanticTransition]:
    labels = set(_FAMILIES[family])
    matching = [item for item in changes if item["label"] in labels and abs(item["difference"]) >= config.semantic_delta_threshold]
    if not matching:
        return None
    strongest = max(matching, key=lambda item: abs(item["difference"]))
    direction = "increase" if strongest["difference"] > 0 else "decrease"
    aligned = [item for item in matching if (item["difference"] > 0) == (strongest["difference"] > 0)]
    confidence = "strong" if len(aligned) >= 2 and abs(strongest["difference"]) >= config.strong_semantic_delta else "moderate" if len(aligned) >= 2 or abs(strongest["difference"]) >= config.strong_semantic_delta else "weak"
    transition_type = "infrastructure_change" if family == "infrastructure" else f"{family}_{direction}"
    evidence = [
        f"SVE relative scene evidence for {item['label']} moved {'up' if item['difference'] > 0 else 'down'} between observations."
        for item in aligned[:3]
    ]
    return SemanticTransition(type=transition_type, confidence=confidence, evidence=evidence)


def _claim_sentence(transition: SemanticTransition) -> str:
    prefix = {"strong": "indicates", "moderate": "appears to show", "weak": "may indicate"}[transition.confidence]
    sentences = {
        "built_up_increase": f"Scene-level evidence {prefix} stronger built-up characteristics in the later observation.",
        "built_up_decrease": f"Scene-level evidence {prefix} weaker built-up characteristics in the later observation.",
        "vegetation_increase": f"Scene-level evidence {prefix} more vegetation in the later observation.",
        "vegetation_decrease": f"Scene-level evidence {prefix} less vegetation in the later observation.",
        "water_increase": f"Scene-level evidence {prefix} greater water-related presence in the later observation.",
        "water_decrease": f"Scene-level evidence {prefix} reduced water-related presence in the later observation.",
        "infrastructure_change": f"Scene-level evidence {prefix} a change in infrastructure-related characteristics.",
    }
    return sentences.get(transition.type, "The semantic type of change remains uncertain.")


def _family_for_intent(intent: str) -> Optional[str]:
    return {
        "built_up_change": "built_up",
        "vegetation_change": "vegetation",
        "water_change": "water",
        "infrastructure_change": "infrastructure",
    }.get(intent)


def _stable_regions(changed_locations: Iterable[str], changed_percent: float) -> list[str]:
    locations = set(changed_locations)
    if changed_percent >= 30:
        return []
    candidates = ["central", "left", "right", "upper", "lower"]
    blocked = set()
    for location in locations:
        if "left" in location:
            blocked.add("left")
        if "right" in location:
            blocked.add("right")
        if "upper" in location:
            blocked.add("upper")
        if "lower" in location:
            blocked.add("lower")
        if "center" in location or "central" in location:
            blocked.add("central")
    return [item for item in candidates if item not in blocked][:2]


def interpret_change(
    question: str,
    change: ChangeAnalysisResponse,
    *,
    config: SemanticInterpreterConfig = DEFAULT_CONFIG,
    built_up_assessment: Optional[BuiltUpChangeAssessment] = None,
) -> SemanticChangeSummary:
    """Build structured facts first, then deterministic query-aware prose."""

    intent = classify_change_intent(question)
    statistics = getattr(change, "statistics", None)
    engine = getattr(change, "change_engine", None)
    learned = bool(engine and getattr(engine, "mode", "") in {"hybrid", "ttp"})
    fallback = bool(engine and getattr(engine, "fallback_used", False))
    caveats = ["The binary change detector does not identify the real-world cause of change."]
    supporting: list[str] = []
    if statistics is None:
        answer = "The image pair could not be compared reliably, so no change interpretation was generated."
        return SemanticChangeSummary(
            short_answer=answer,
            expanded_answer=answer,
            query_intent=intent,
            overall_change_level="minimal",
            evidence_strength="limited",
            caveats=caveats + ["Comparable-pixel change statistics were unavailable."],
        )

    changed_percent = _finite_number(getattr(statistics, "percentage_changed", None))
    changed_percent = min(100.0, max(0.0, changed_percent or 0.0))
    level, magnitude_phrase = _magnitude(changed_percent, config)
    regions = list(getattr(statistics, "regions", None) or [])
    regions.sort(key=lambda item: getattr(item, "area_pixels", 0), reverse=True)
    locations = [value for value in (_location(getattr(region, "bounding_box", None), statistics.analysis_width, statistics.analysis_height) for region in regions) if value]
    dominant_location = locations[0] if locations else None
    changed_regions = list(dict.fromkeys(locations[:4]))
    stable_regions = _stable_regions(changed_regions, changed_percent)
    where = f"the {dominant_location} part of the scene" if dominant_location and dominant_location != "center" else "the center of the scene" if dominant_location else None

    if changed_percent <= 0 or not regions:
        supporting.append("No connected changed region was present in the authoritative change statistics.")
    else:
        supporting.append(f"The primary change evidence identifies {magnitude_phrase}.")
        if where:
            supporting.append(f"The largest connected region is centered in {where}.")

    deterministic = getattr(change, "deterministic_statistics", None)
    deterministic_percent = _finite_number(getattr(deterministic, "percentage_changed", None)) if deterministic else None
    disagreement = bool(
        learned
        and deterministic_percent is not None
        and deterministic_percent - changed_percent >= config.disagreement_gap_percent
        and deterministic_percent >= max(changed_percent, 0.01) * config.disagreement_ratio
    )
    if disagreement:
        supporting.append("The deterministic analyzer reports much broader low-level visual difference than the learned mask.")

    changes = _prior_changes(change)
    transitions = [value for family in _FAMILIES if (value := _transition_for_family(family, changes, config))]
    # A semantic transition requires both spatial change evidence and compatible scene-level evidence.
    if not learned or changed_percent <= 0:
        transitions = []
    requested_family = _family_for_intent(intent)
    selected = next((item for item in transitions if item.type.startswith(requested_family or "__none__")), None)
    if selected is None and intent in {"change_summary", "general_comparison", "unknown"} and transitions:
        order = {"strong": 2, "moderate": 1, "weak": 0}
        selected = max(transitions, key=lambda item: order[item.confidence])
    if changes:
        caveats.append("SVE similarities are relative scene-level evidence, not calibrated probabilities or spatial segmentation.")
    else:
        caveats.append("No usable before/after scene-level semantic evidence was available, so no land-cover transition was assigned.")
    if fallback:
        caveats.append("The learned change specialist was unavailable; interpretation is limited to deterministic visual difference.")
    before_metadata = getattr(change, "before_metadata", None)
    if before_metadata is not None and not getattr(before_metadata, "is_georeferenced", False):
        caveats.append("Locations are coarse image directions, not geographic coordinates.")

    stable_summary = None
    if changed_percent < config.noticeable_change_percent:
        stable_summary = "Most of the scene remains comparatively stable outside the detected change areas."
        if stable_regions:
            stable_summary = f"The {', '.join(stable_regions)} parts show no comparable concentration of detected change."

    if changed_percent <= 0 or not regions:
        base = "The two observations are largely consistent, with no significant localized change detected."
    elif learned:
        base = f"The learned evidence identifies {magnitude_phrase}{f' concentrated in {where}' if where else ''}."
    else:
        base = f"The available visual-difference evidence identifies {magnitude_phrase}{f' concentrated in {where}' if where else ''}."

    semantic_sentence = _claim_sentence(selected) if selected else "The available evidence is not strong enough to determine a reliable land-cover transition."
    requested_direction = _requested_direction(question)
    if requested_family and selected:
        actual_direction = "increase" if selected.type.endswith("increase") else "decrease" if selected.type.endswith("decrease") else None
        if requested_direction and actual_direction and requested_direction != actual_direction:
            semantic_sentence = f"The available evidence does not support a {requested_direction}; it instead {('suggests' if selected.confidence != 'strong' else 'indicates')} a {actual_direction} in {requested_family.replace('_', '-')} characteristics."

    disagreement_sentence = "The images differ much more in overall appearance than in the learned structural mask; seasonal, illumination, sensor, or registration effects may contribute to the broader visual difference." if disagreement else None
    location_answer = f"The most significant detected change is in {where}." if where else "No dominant change location can be stated from the available geometry."

    if intent in {"largest_change", "change_location"}:
        short = location_answer
    elif intent == "amount_of_change":
        short = f"The primary evidence indicates {magnitude_phrase}, covering approximately {changed_percent:.1f}% of the analysis grid."
    elif intent == "no_change_check":
        short = "The observations are largely consistent, with no significant change detected." if changed_percent < config.minimal_change_percent else f"No. The evidence identifies {magnitude_phrase}{f' in {where}' if where else ''}."
    elif requested_family:
        short = semantic_sentence
        if where and selected:
            short = f"{short.rstrip('.')} The main detected change is concentrated in {where}."
    else:
        short = " ".join(item for item in (base, semantic_sentence if selected else None) if item)

    expanded_parts = [base]
    if selected:
        expanded_parts.append(semantic_sentence)
        supporting.extend(selected.evidence)
    elif intent not in {"amount_of_change", "largest_change", "change_location", "no_change_check"}:
        expanded_parts.append(semantic_sentence)
    if stable_summary:
        expanded_parts.append(stable_summary)
    if disagreement_sentence:
        expanded_parts.append(disagreement_sentence)
    expanded = " ".join(dict.fromkeys(expanded_parts))

    evidence_strength = "limited"
    if learned:
        evidence_strength = "moderate"
    if selected and selected.confidence == "strong":
        evidence_strength = "strong"
    likely_type = selected.type if selected else "unknown_change" if changed_percent > 0 else None

    if built_up_assessment is not None and intent in {"built_up_change", "structural_change"}:
        assessment = built_up_assessment
        relevant = [region for region in assessment.regions if region.semantic_support_level == "supported"] or assessment.regions
        region_names = list(dict.fromkeys(region.relative_location for region in relevant))
        where_text = " and ".join(region_names[:2]) if region_names else None
        strongest = assessment.regions[0] if assessment.regions else None
        if intent == "structural_change":
            short = (
                f"The strongest structural change is in the {strongest.relative_location} part of the scene."
                if strongest else "No meaningful structural change region can be localized from the available evidence."
            )
            likely_type = "structural_change" if strongest else None
        elif assessment.state == "INCREASE_SUPPORTED":
            short = f"Built-up expansion is suggested{f' in the {where_text} parts of the scene' if where_text else ''}."
            likely_type = "built_up_increase"
        elif assessment.state == "DECREASE_SUPPORTED":
            short = f"A decrease in built-up-like structural evidence is suggested{f' in the {where_text} parts of the scene' if where_text else ''}."
            likely_type = "built_up_decrease"
        elif assessment.state == "MIXED_CHANGE":
            short = f"Built-up-like evidence shows mixed directional change{f' across the {where_text} regions' if where_text else ''}."
            likely_type = "built_up_mixed_change"
        elif assessment.state == "NO_MEANINGFUL_EVIDENCE":
            short = "No meaningful evidence of built-up-region change was found."
            likely_type = None
        else:
            short = "Change is present, but the evidence is insufficient to establish whether built-up area increased or decreased."
            likely_type = "unknown_change"
        where_sentence = (
            f"The largest relevant changed cluster is in the {strongest.relative_location} part of the image; up to {len(assessment.regions)} meaningful region{'s were' if len(assessment.regions) != 1 else ' was'} compared."
            if strongest else "No meaningful connected region passed the region-size filter."
        )
        magnitude_sentence = f"The pattern is {assessment.magnitude}, based on changed-area share and region distribution."
        why_sentence = (
            "This direction combines ChangerEx geometry, independent deterministic overlap, and before/after edge-texture structural evidence. "
            "It is a heuristic interpretation consistent with built-up terrain, not a ground-truth land-cover measurement."
        )
        expanded = " ".join((short, where_sentence, magnitude_sentence, why_sentence))
        dominant_location = strongest.relative_location if strongest else dominant_location
        changed_regions = [region.relative_location for region in assessment.regions]
        evidence_strength = assessment.confidence
        supporting = list(dict.fromkeys(supporting + assessment.confidence_factors))
        caveats = list(dict.fromkeys(caveats + assessment.limitations))

    return SemanticChangeSummary(
        short_answer=short,
        expanded_answer=expanded,
        query_intent=intent,
        overall_change_level=level,
        dominant_location=dominant_location,
        likely_change_type=likely_type,
        evidence_strength=evidence_strength,
        stable_area_summary=stable_summary,
        changed_regions=changed_regions,
        stable_regions=stable_regions,
        likely_transitions=transitions,
        supporting_facts=list(dict.fromkeys(supporting)),
        caveats=list(dict.fromkeys(caveats)),
        visual_structural_disagreement=disagreement,
        built_up_assessment=built_up_assessment,
    )