SatQuery-AI / satquery_agent /compatibility.py
AnirudhShashikumar's picture
Deploy SatQuery AI ZeroGPU bridge
2407511
Raw History Blame Contribute Delete
13.7 kB
"""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=[],
)