Spaces:
Running on Zero
Running on Zero
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=[],
)
|