Spaces:
Sleeping
Sleeping
File size: 7,616 Bytes
79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79b0691 15e9574 79d533c 15e9574 79b0691 15e9574 79b0691 15e9574 79b0691 15e9574 79b0691 15e9574 79b0691 15e9574 79b0691 15e9574 79d533c 15e9574 79d533c 15e9574 79b0691 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79d533c 15e9574 79b0691 15e9574 79d533c 15e9574 79d533c bec7397 | 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 | """Pixel coordinates → WGS84 lat/lng for geo-referenced images (FR-04, FR-06)."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from .change_type_map import enrich_region_for_dda
from .geotiff_io import GeorefInfo, inspect_image, pixel_to_geo_wgs84, read_georef
logger = logging.getLogger(__name__)
BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
@dataclass
class GeoContext:
bounds: Optional[BoundsWGS84]
georef: Optional[GeorefInfo]
georef_width: int
georef_height: int
source: str = "none" # embedded | worldfile | manual | linear | none
def _bounds_in_range(b: Optional[BoundsWGS84]) -> bool:
if not b:
return False
w, s, e, n = b
return abs(w) <= 180 and abs(e) <= 180 and abs(s) <= 90 and abs(n) <= 90
def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
if bounds is None:
return None
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
return tuple(float(x) for x in bounds)
if isinstance(bounds, dict):
try:
return (
float(bounds["west"]),
float(bounds["south"]),
float(bounds["east"]),
float(bounds["north"]),
)
except (KeyError, TypeError, ValueError):
return None
if isinstance(bounds, str) and bounds.strip():
try:
data = json.loads(bounds)
return parse_bounds(data)
except json.JSONDecodeError:
parts = [float(x.strip()) for x in bounds.replace("[", "").replace("]", "").split(",")]
if len(parts) == 4:
return tuple(parts)
return None
def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
try:
meta = inspect_image(path)
return meta.bounds_wgs84
except Exception as exc:
logger.warning("Could not read bounds for %s: %s", path.name, exc)
return None
def resolve_geo_context(
db: Session,
base_path: str,
base_file: Path,
) -> GeoContext:
"""Resolve bounds and affine georef for detection geo enrichment.
Priority: embedded/world-file affine georef → DB manual bounds → linear
bounds inferred from the image sidecars. Tracks which source was used.
"""
georef = read_georef(base_file)
bounds = georef.bounds_wgs84 if georef else None
georef_width = georef.width if georef else 0
georef_height = georef.height if georef else 0
source = getattr(georef, "source", "embedded") if georef else "none"
if not bounds:
from .tree.image_service import get_image_by_file_path
rel = base_path.replace("\\", "/").strip().lstrip("/")
img = get_image_by_file_path(db, rel)
if img and img.bounds_json:
db_bounds = parse_bounds(img.bounds_json)
if db_bounds:
bounds = db_bounds
source = "manual"
if not bounds:
bounds = bounds_from_image_path(base_file)
if bounds:
source = "linear"
if (georef_width <= 0 or georef_height <= 0) and bounds:
meta = inspect_image(base_file)
georef_width = meta.width or georef_width
georef_height = meta.height or georef_height
return GeoContext(
bounds=bounds,
georef=georef,
georef_width=georef_width or 0,
georef_height=georef_height or 0,
source=source if bounds else "none",
)
def pixel_to_lat_lng(
x: float,
y: float,
img_width: int,
img_height: int,
bounds: BoundsWGS84,
*,
geo: Optional[GeoContext] = None,
) -> Optional[Dict[str, float]]:
if img_width <= 0 or img_height <= 0:
return None
if geo and geo.georef:
coords = pixel_to_geo_wgs84(
x, y, geo.georef,
detection_width=img_width,
detection_height=img_height,
)
if coords:
lng, lat = coords
# Guard against bad transforms emitting impossible coordinates
if abs(lat) <= 90 and abs(lng) <= 180:
return {"lat": round(lat, 6), "lng": round(lng, 6)}
logger.warning("Affine produced out-of-range lat/lng (%.3f,%.3f); using linear", lat, lng)
if not bounds:
return None
west, south, east, north = bounds
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
scale_x = ref_w / float(img_width)
scale_y = ref_h / float(img_height)
px = float(x) * scale_x
py = float(y) * scale_y
lng = west + (px / ref_w) * (east - west)
lat = north - (py / ref_h) * (north - south)
return {"lat": round(lat, 6), "lng": round(lng, 6)}
def bbox_area_sq_m(
bbox: Dict[str, int],
img_width: int,
img_height: int,
bounds: BoundsWGS84,
*,
geo: Optional[GeoContext] = None,
) -> Optional[float]:
"""Approximate region area in square metres using geographic bounds."""
if img_width <= 0 or img_height <= 0 or not bounds:
return None
west, south, east, north = bounds
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
scale_x = ref_w / float(img_width)
scale_y = ref_h / float(img_height)
m_per_px_x = abs(east - west) / ref_w
m_per_px_y = abs(north - south) / ref_h
import math
mid_lat = (north + south) / 2.0
lat_scale = 111_320.0
lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
w_m = bbox.get("w", 0) * scale_x * m_per_px_x * lng_scale
h_m = bbox.get("h", 0) * scale_y * m_per_px_y * lat_scale
return round(w_m * h_m, 1)
def enrich_regions_geo(
regions: List[dict],
*,
img_width: int,
img_height: int,
bounds: Optional[BoundsWGS84],
geo: Optional[GeoContext] = None,
) -> List[dict]:
"""Add latLng, areaSqM, and DDA change type to each region."""
effective_bounds = bounds or (geo.bounds if geo else None)
out = []
for region in regions:
enriched = enrich_region_for_dda(region)
center = region.get("center") or {}
cx = center.get("x", 0)
cy = center.get("y", 0)
if effective_bounds or (geo and geo.georef):
lat_lng = pixel_to_lat_lng(
cx, cy, img_width, img_height, effective_bounds,
geo=geo,
)
if lat_lng:
enriched["latLng"] = lat_lng
bbox = region.get("bbox") or {}
if effective_bounds:
area_sq_m = bbox_area_sq_m(
bbox, img_width, img_height, effective_bounds, geo=geo,
)
if area_sq_m is not None:
enriched["areaSqM"] = area_sq_m
else:
enriched["latLng"] = None
out.append(enriched)
return out
def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
"""Read lat/lng from region dict (supports latLng object or flat keys)."""
ll = region.get("latLng") or {}
lat = region.get("latitude", ll.get("lat") if isinstance(ll, dict) else None)
lng = region.get("longitude", ll.get("lng") if isinstance(ll, dict) else None)
if lat is None or lng is None:
return None, None
try:
return float(lat), float(lng)
except (TypeError, ValueError):
return None, None
|