File size: 13,692 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
"""Truthful pair-compatibility reporting without alignment or reprojection."""

from __future__ import annotations

import math
from datetime import date
from typing import List, Optional, Sequence, Tuple

from .models import AlignmentLevel, ImageMetadata, InputMode, Modality, PairCompatibility, PairCompatibilityClass, RasterBounds, ObservationRole


MINIMUM_USEFUL_OVERLAP = 0.5


def _nearly_equal(left: Sequence[float], right: Sequence[float], tolerance: float = 1e-7) -> bool:
    return len(left) == len(right) and all(math.isclose(a, b, rel_tol=tolerance, abs_tol=tolerance) for a, b in zip(left, right))


def _bounds_equal(left: RasterBounds, right: RasterBounds) -> bool:
    return _nearly_equal(
        [left.left, left.bottom, left.right, left.top],
        [right.left, right.bottom, right.right, right.top],
    )


def _overlap(left: RasterBounds, right: RasterBounds) -> Tuple[bool, float]:
    intersection_width = max(0.0, min(left.right, right.right) - max(left.left, right.left))
    intersection_height = max(0.0, min(left.top, right.top) - max(left.bottom, right.bottom))
    intersection_area = intersection_width * intersection_height
    left_area = max(0.0, left.right - left.left) * max(0.0, left.top - left.bottom)
    right_area = max(0.0, right.right - right.left) * max(0.0, right.top - right.bottom)
    denominator = min(left_area, right_area)
    ratio = intersection_area / denominator if denominator > 0 else 0.0
    return intersection_area > 0, round(ratio, 6)


def _resolution(transform: Optional[Sequence[float]]) -> Optional[List[float]]:
    if transform is None or len(transform) != 6:
        return None
    x_size = math.hypot(float(transform[0]), float(transform[3]))
    y_size = math.hypot(float(transform[1]), float(transform[4]))
    if not all(math.isfinite(value) and value > 0 for value in (x_size, y_size)):
        return None
    return [round(x_size, 12), round(y_size, 12)]


def _orientation(transform: Optional[Sequence[float]]) -> Optional[List[float]]:
    resolution = _resolution(transform)
    if resolution is None or transform is None:
        return None
    return [
        round(float(transform[0]) / resolution[0], 9),
        round(float(transform[3]) / resolution[0], 9),
        round(float(transform[1]) / resolution[1], 9),
        round(float(transform[4]) / resolution[1], 9),
    ]


def _scientific_details(primary: ImageMetadata, secondary: ImageMetadata) -> dict:
    primary_resolution = _resolution(primary.transform)
    secondary_resolution = _resolution(secondary.transform)
    primary_orientation = _orientation(primary.transform)
    secondary_orientation = _orientation(secondary.transform)
    return {
        "primary_resolution": primary_resolution,
        "secondary_resolution": secondary_resolution,
        "same_resolution": None if primary_resolution is None or secondary_resolution is None else _nearly_equal(primary_resolution, secondary_resolution),
        "same_orientation": None if primary_orientation is None or secondary_orientation is None else _nearly_equal(primary_orientation, secondary_orientation),
        "nodata_compatible": (
            None if primary.nodata is None or secondary.nodata is None
            else math.isclose(primary.nodata, secondary.nodata, rel_tol=1e-7, abs_tol=1e-7)
        ),
    }


def _date_errors(primary_date: Optional[str], secondary_date: Optional[str]) -> List[str]:
    if not primary_date or not secondary_date:
        return ["Two distinct temporal date labels are required."]
    try:
        earlier = date.fromisoformat(primary_date)
        later = date.fromisoformat(secondary_date)
    except ValueError:
        return ["Temporal dates must use YYYY-MM-DD format."]
    if earlier >= later:
        return ["The earlier-date image must have a date before the later-date image."]
    return []


def validate_pair_compatibility(
    input_mode: InputMode,
    primary_modality: Modality,
    secondary_modality: Optional[Modality],
    primary: ImageMetadata,
    secondary: ImageMetadata,
    primary_date: Optional[str] = None,
    secondary_date: Optional[str] = None,
) -> PairCompatibility:
    errors: List[str] = []
    warnings: List[str] = []
    same_dimensions = primary.width == secondary.width and primary.height == secondary.height
    scientific_details = _scientific_details(primary, secondary)

    if input_mode == InputMode.CROSS_MODAL:
        from .image_ingestion import coarse_modality
        role_details = {}
        if primary.observation_role is not None or secondary.observation_role is not None:
            roles = [primary.observation_role, secondary.observation_role]
            if set(roles) != {ObservationRole.OPTICAL, ObservationRole.SAR}:
                errors.append("Cross-modal inputs require distinct explicit Optical and SAR observation roles.")
            ambiguous = False
            for metadata in (primary, secondary):
                role = metadata.observation_role
                detected = coarse_modality(metadata.auto_detected_modality)
                if role is None:
                    continue
                role_details[f"{role.value}_slot_detected_modality"] = metadata.auto_detected_modality
                if detected == Modality.UNKNOWN:
                    ambiguous = True
                    errors.append(f"The file placed in the {role.value.title()} observation slot has ambiguous modality. Replace it with identifiable imagery; slot roles do not confirm content.")
                elif (role == ObservationRole.SAR) != (detected == Modality.SAR):
                    errors.append(f"The file placed in the {role.value.title()} observation slot appears to be {detected.value} imagery. Replace it or explicitly swap observations.")
            role_details.update(role_validation_status="ambiguous" if ambiguous else "mismatch" if errors else "match", role_match=not errors, pair_valid=not errors)
            scientific_details.update(role_details)
        modalities = {primary_modality, secondary_modality}
        optical_family = bool(modalities.intersection({Modality.OPTICAL, Modality.MULTISPECTRAL}))
        if not (optical_family and Modality.SAR in modalities):
            errors.append("Cross-modal analysis requires one optical or multispectral image and one SAR image.")
    elif input_mode == InputMode.BI_TEMPORAL:
        optical_family = {Modality.OPTICAL, Modality.MULTISPECTRAL}
        modalities_compatible = (
            secondary_modality is not None
            and (primary_modality == secondary_modality or {primary_modality, secondary_modality}.issubset(optical_family))
        )
        if not modalities_compatible:
            errors.append("Bi-temporal comparison requires matching modalities or an optical/multispectral pair.")
        errors.extend(_date_errors(primary_date, secondary_date))

    same_crs = None if not primary.crs or not secondary.crs else primary.crs == secondary.crs
    same_transform = None if primary.transform is None or secondary.transform is None else _nearly_equal(primary.transform, secondary.transform)
    bounds_overlap: Optional[bool] = None
    overlap_ratio: Optional[float] = None
    if primary.bounds is not None and secondary.bounds is not None and same_crs is True:
        bounds_overlap, overlap_ratio = _overlap(primary.bounds, secondary.bounds)

    if errors:
        return PairCompatibility(
            compatible=False,
            alignment_level=AlignmentLevel.INCOMPATIBLE,
            same_dimensions=same_dimensions,
            same_crs=same_crs,
            same_transform=same_transform,
            bounds_overlap=bounds_overlap,
            overlap_ratio=overlap_ratio,
            resampling_required=not same_dimensions,
            scientific_classification=PairCompatibilityClass.UNVERIFIABLE,
            recommended_action="Correct the workflow modality/date validation errors before comparing the pair.",
            **scientific_details,
            warnings=warnings,
            errors=list(dict.fromkeys(errors)),
        )

    if primary.crs and secondary.crs and same_crs is False:
        errors.append("The raster CRS values differ; overlap cannot be established without reprojection.")
    elif same_crs is True and primary.bounds is not None and secondary.bounds is not None:
        if not bounds_overlap:
            errors.append("The raster bounds do not overlap in their shared CRS.")
        else:
            bounds_match = _bounds_equal(primary.bounds, secondary.bounds)
            exact = same_dimensions and same_transform is True and bounds_match
            if exact:
                return PairCompatibility(
                    compatible=True,
                    alignment_level=AlignmentLevel.EXACT,
                    same_dimensions=True,
                    same_crs=True,
                    same_transform=True,
                    bounds_overlap=True,
                    overlap_ratio=overlap_ratio,
                    resampling_required=False,
                    scientific_classification=PairCompatibilityClass.EXACT_GRID_MATCH,
                    recommended_action="The grids match exactly; pixel-level analysis is permitted.",
                    **scientific_details,
                    warnings=[],
                    errors=[],
                )
            warnings.append("The images overlap geographically but are not pixel-aligned.")
            bounds_match = _bounds_equal(primary.bounds, secondary.bounds)
            if bounds_match:
                scientific_classification = PairCompatibilityClass.SAME_AREA_DIFFERENT_GRID
                action = "Use an explicit, provenance-recorded resampling step before pixel-level analysis."
            elif overlap_ratio is not None and overlap_ratio < MINIMUM_USEFUL_OVERLAP:
                scientific_classification = PairCompatibilityClass.INSUFFICIENT_OVERLAP
                action = "Reject pixel-level comparison or provide a better-overlapping pair."
            else:
                scientific_classification = PairCompatibilityClass.PARTIAL_OVERLAP
                action = "Clip to the shared footprint and align explicitly before pixel-level analysis."
            return PairCompatibility(
                compatible=True,
                alignment_level=AlignmentLevel.GEOSPATIAL_OVERLAP,
                same_dimensions=same_dimensions,
                same_crs=True,
                same_transform=same_transform,
                bounds_overlap=True,
                overlap_ratio=overlap_ratio,
                resampling_required=True,
                scientific_classification=scientific_classification,
                recommended_action=action,
                **scientific_details,
                warnings=warnings,
                errors=[],
            )

    if errors:
        return PairCompatibility(
            compatible=False,
            alignment_level=AlignmentLevel.INCOMPATIBLE,
            same_dimensions=same_dimensions,
            same_crs=same_crs,
            same_transform=same_transform,
            bounds_overlap=bounds_overlap,
            overlap_ratio=overlap_ratio,
            resampling_required=True,
            scientific_classification=(
                PairCompatibilityClass.REPROJECTION_REQUIRED
                if same_crs is False else PairCompatibilityClass.INSUFFICIENT_OVERLAP
            ),
            recommended_action=(
                "Reproject explicitly into a documented target CRS before overlap analysis."
                if same_crs is False else "Provide rasters with a usable shared footprint."
            ),
            **scientific_details,
            warnings=warnings,
            errors=errors,
        )

    aspect_primary = primary.width / primary.height
    aspect_secondary = secondary.width / secondary.height
    aspect_difference = abs(aspect_primary - aspect_secondary) / max(aspect_primary, aspect_secondary)
    if aspect_difference > 0.05:
        errors.append("Without georeferencing, the image aspect ratios differ too much for a reliable visual comparison.")
        return PairCompatibility(
            compatible=False,
            alignment_level=AlignmentLevel.INCOMPATIBLE,
            same_dimensions=same_dimensions,
            same_crs=same_crs,
            same_transform=same_transform,
            bounds_overlap=None,
            overlap_ratio=None,
            resampling_required=True,
            scientific_classification=PairCompatibilityClass.UNVERIFIABLE,
            recommended_action="Provide georeferencing or a documented benchmark pairing with compatible pixel geometry.",
            **scientific_details,
            warnings=warnings,
            errors=errors,
        )

    warnings.append("Pixel alignment is assumed from the supplied pairing; geospatial co-registration cannot be independently verified.")
    if not same_dimensions:
        warnings.append("Image dimensions differ; later visual comparison would require explicit resampling.")
    return PairCompatibility(
        compatible=True,
        alignment_level=AlignmentLevel.VISUAL_ONLY,
        same_dimensions=same_dimensions,
        same_crs=same_crs,
        same_transform=same_transform,
        bounds_overlap=None,
        overlap_ratio=None,
        resampling_required=not same_dimensions,
        scientific_classification=PairCompatibilityClass.UNVERIFIABLE,
        recommended_action="Same-size PNG/JPEG pairs can use disclosed qualitative native-source comparison. Provide verified CRS and affine transforms for quantitative fusion.",
        **scientific_details,
        warnings=warnings,
        errors=[],
    )