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