Spaces:
Sleeping
Sleeping
| """GeoTIFF ingest and preview generation (FR-02). Rasterio optional at import time.""" | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Optional, Tuple | |
| from PIL import Image, ImageOps | |
| from .config import get_detection_max_side | |
| logger = logging.getLogger(__name__) | |
| # World-file extensions per image type (ESRI convention + generic .wld) | |
| _WORLD_FILE_EXTS = { | |
| ".tif": (".tfw", ".tifw", ".wld"), | |
| ".tiff": (".tfw", ".tifw", ".wld"), | |
| ".jpg": (".jgw", ".jpgw", ".wld"), | |
| ".jpeg": (".jgw", ".jpgw", ".wld"), | |
| ".png": (".pgw", ".pngw", ".wld"), | |
| } | |
| class IngestResult: | |
| width: int | |
| height: int | |
| has_georef: bool | |
| crs: str | |
| bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north | |
| format: str | |
| georef_source: str = "none" # embedded | worldfile | none | |
| def _find_world_file(path: Path) -> Optional[Path]: | |
| """Locate an ESRI world file or generic .wld sidecar next to an image.""" | |
| ext = path.suffix.lower() | |
| candidates = [] | |
| for wext in _WORLD_FILE_EXTS.get(ext, (".wld",)): | |
| candidates.append(path.with_suffix(wext)) | |
| # e.g. photo.jpg.wld | |
| candidates.append(Path(str(path) + ".wld")) | |
| for cand in candidates: | |
| if cand.is_file(): | |
| return cand | |
| return None | |
| def _read_prj_crs(path: Path): | |
| """Read a .prj sidecar (WKT) and return a rasterio CRS, or default EPSG:4326.""" | |
| try: | |
| from rasterio.crs import CRS | |
| except Exception: | |
| return None | |
| prj = path.with_suffix(".prj") | |
| if prj.is_file(): | |
| try: | |
| return CRS.from_wkt(prj.read_text(encoding="utf-8", errors="ignore").strip()) | |
| except Exception as exc: | |
| logger.warning("Could not parse .prj for %s: %s", path.name, exc) | |
| try: | |
| return CRS.from_epsg(4326) | |
| except Exception: | |
| return None | |
| def _world_file_georef(path: Path, width: int, height: int): | |
| """Build (transform, crs, bounds_wgs84) from a world file sidecar, or None.""" | |
| wf = _find_world_file(path) | |
| if wf is None: | |
| return None | |
| try: | |
| from rasterio.transform import Affine, array_bounds | |
| from rasterio.warp import transform_bounds | |
| nums = [float(x.strip()) for x in wf.read_text().split() if x.strip()] | |
| if len(nums) < 6: | |
| return None | |
| a, d, b, e, c, f = nums[:6] | |
| # World file stores center of top-left pixel; shift to corner for GDAL/affine | |
| gt_c = c - a / 2.0 - b / 2.0 | |
| gt_f = f - d / 2.0 - e / 2.0 | |
| transform = Affine(a, b, gt_c, d, e, gt_f) | |
| crs = _read_prj_crs(path) | |
| west, south, east, north = array_bounds(height, width, transform) | |
| if crs is not None and str(crs) not in ("EPSG:4326", "OGC:CRS84"): | |
| west, south, east, north = transform_bounds(crs, "EPSG:4326", west, south, east, north) | |
| return transform, crs, (float(west), float(south), float(east), float(north)) | |
| except Exception as exc: | |
| logger.warning("World-file georef failed for %s: %s", path.name, exc) | |
| return None | |
| def _read_with_rasterio(path: Path) -> IngestResult: | |
| import rasterio | |
| from rasterio.warp import transform_bounds | |
| with rasterio.open(path) as src: | |
| crs = str(src.crs) if src.crs else "" | |
| has_georef = src.crs is not None | |
| bounds = None | |
| if has_georef and src.bounds: | |
| try: | |
| w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds) | |
| bounds = (float(w), float(s), float(e), float(n)) | |
| except Exception as exc: | |
| logger.warning("Could not transform bounds to WGS84: %s", exc) | |
| ext = path.suffix.lower() | |
| return IngestResult( | |
| width=int(src.width), | |
| height=int(src.height), | |
| has_georef=has_georef, | |
| crs=crs, | |
| bounds_wgs84=bounds, | |
| format="geotiff" if ext in (".tif", ".tiff") else "image", | |
| georef_source="embedded" if has_georef else "none", | |
| ) | |
| def _read_with_pillow(path: Path) -> IngestResult: | |
| with Image.open(path) as img: | |
| w, h = img.size | |
| ext = path.suffix.lower() | |
| fmt = "geotiff" if ext in (".tif", ".tiff") else "image" | |
| # Try a world-file sidecar for plain images / non-georeferenced TIFFs | |
| wf = _world_file_georef(path, w, h) | |
| if wf is not None: | |
| _, crs, bounds = wf | |
| return IngestResult( | |
| width=w, height=h, has_georef=bounds is not None, | |
| crs=str(crs) if crs else "", bounds_wgs84=bounds, | |
| format=fmt, georef_source="worldfile" if bounds else "none", | |
| ) | |
| return IngestResult( | |
| width=w, | |
| height=h, | |
| has_georef=False, | |
| crs="", | |
| bounds_wgs84=None, | |
| format=fmt, | |
| georef_source="none", | |
| ) | |
| def inspect_image(path: Path) -> IngestResult: | |
| """Read dimensions + georeferencing for any raster (TIFF/PNG/JPEG). | |
| Tries rasterio first (honors embedded CRS, world files and GDAL .aux.xml for | |
| all formats), then falls back to Pillow + explicit world-file parsing. | |
| """ | |
| try: | |
| res = _read_with_rasterio(path) | |
| # rasterio opened but found no embedded georef: try explicit world file | |
| if not res.has_georef: | |
| wf = _world_file_georef(path, res.width, res.height) | |
| if wf is not None and wf[2] is not None: | |
| _, crs, bounds = wf | |
| res.has_georef = True | |
| res.crs = str(crs) if crs else "" | |
| res.bounds_wgs84 = bounds | |
| res.georef_source = "worldfile" | |
| return res | |
| except ImportError: | |
| logger.warning("rasterio not installed — georef metadata limited") | |
| except Exception as exc: | |
| logger.warning("rasterio read failed (%s), falling back to Pillow", exc) | |
| return _read_with_pillow(path) | |
| def write_placeholder_png(dest_path: Path, label: str = "Image", max_side: int = 256) -> None: | |
| """Fallback thumb when GeoTIFF is too large or rasterio is unavailable.""" | |
| from PIL import ImageDraw | |
| dest_path.parent.mkdir(parents=True, exist_ok=True) | |
| img = Image.new("RGB", (max_side, max_side), color=(32, 40, 52)) | |
| draw = ImageDraw.Draw(img) | |
| lines = [label[:28], "preview N/A"] | |
| y = max_side // 2 - 20 | |
| for line in lines: | |
| draw.text((12, y), line, fill=(100, 200, 170)) | |
| y += 18 | |
| img.save(dest_path, format="PNG") | |
| def _rasterio_read_rgb(path: Path, max_side: int): | |
| import numpy as np | |
| import rasterio | |
| from rasterio.enums import Resampling | |
| with rasterio.open(path) as src: | |
| count = min(3, src.count) | |
| scale = min(1.0, max_side / max(src.width, src.height, 1)) | |
| out_h = max(1, int(src.height * scale)) | |
| out_w = max(1, int(src.width * scale)) | |
| data = src.read( | |
| indexes=list(range(1, count + 1)), | |
| out_shape=(count, out_h, out_w), | |
| resampling=Resampling.bilinear, | |
| ) | |
| if count == 1: | |
| rgb = np.stack([data[0], data[0], data[0]]) | |
| else: | |
| rgb = data[:3] | |
| rgb = np.transpose(rgb, (1, 2, 0)).astype("float32") | |
| if rgb.max() > 255 or rgb.min() < 0: | |
| lo, hi = np.percentile(rgb, (2, 98)) | |
| rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255 | |
| return Image.fromarray(rgb.astype("uint8"), mode="RGB") | |
| def read_native_size(path: Path) -> Optional[Tuple[int, int]]: | |
| """Return (width, height) of a GeoTIFF without decoding pixels, or None.""" | |
| ext = path.suffix.lower() | |
| if ext not in (".tif", ".tiff"): | |
| return None | |
| try: | |
| import rasterio | |
| with rasterio.open(path) as src: | |
| return int(src.width), int(src.height) | |
| except Exception: | |
| return None | |
| def _normalize_window_rgb(data): | |
| """Convert a rasterio (bands, h, w) window read to RGB uint8 (h, w, 3).""" | |
| import numpy as np | |
| count = data.shape[0] | |
| if count == 1: | |
| rgb = np.stack([data[0], data[0], data[0]]) | |
| else: | |
| rgb = data[:3] | |
| rgb = np.transpose(rgb, (1, 2, 0)).astype("float32") | |
| if rgb.max() > 255 or rgb.min() < 0: | |
| lo, hi = np.percentile(rgb, (2, 98)) | |
| rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255 | |
| return rgb.astype("uint8") | |
| def iter_geotiff_window_pairs(path_a: Path, path_b: Path, | |
| tile_size: int = 512, overlap: float = 0.25): | |
| """Stream paired native-resolution RGB windows from two same-size GeoTIFFs. | |
| Reads corresponding pixel windows from disk via rasterio so very large | |
| rasters never load fully into RAM. Yields | |
| ``(tile_a, tile_b, y0, x0, full_h, full_w)`` where tiles are RGB uint8. | |
| Requires both rasters to share native pixel dimensions; raises ValueError | |
| otherwise so the caller can fall back to the in-memory path. | |
| """ | |
| import rasterio | |
| from rasterio.windows import Window | |
| tile_size = max(64, int(tile_size)) | |
| ov = min(0.5, max(0.0, float(overlap))) | |
| step = max(1, int(round(tile_size * (1.0 - ov)))) | |
| with rasterio.open(path_a) as src_a, rasterio.open(path_b) as src_b: | |
| if (src_a.width, src_a.height) != (src_b.width, src_b.height): | |
| raise ValueError( | |
| f"GeoTIFF dimensions differ: {src_a.width}x{src_a.height} " | |
| f"vs {src_b.width}x{src_b.height}" | |
| ) | |
| full_w, full_h = int(src_a.width), int(src_a.height) | |
| bands_a = list(range(1, min(3, src_a.count) + 1)) | |
| bands_b = list(range(1, min(3, src_b.count) + 1)) | |
| ys = list(range(0, max(1, full_h - tile_size + 1), step)) | |
| xs = list(range(0, max(1, full_w - tile_size + 1), step)) | |
| if ys[-1] != full_h - tile_size and full_h > tile_size: | |
| ys.append(full_h - tile_size) | |
| if xs[-1] != full_w - tile_size and full_w > tile_size: | |
| xs.append(full_w - tile_size) | |
| for y0 in ys: | |
| for x0 in xs: | |
| wh = min(tile_size, full_h - y0) | |
| ww = min(tile_size, full_w - x0) | |
| win = Window(x0, y0, ww, wh) | |
| da = src_a.read(indexes=bands_a, window=win) | |
| db = src_b.read(indexes=bands_b, window=win) | |
| yield (_normalize_window_rgb(da), _normalize_window_rgb(db), | |
| y0, x0, full_h, full_w) | |
| def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image: | |
| """Load image as RGB PIL, downscaling large GeoTIFFs via rasterio.""" | |
| if max_side is None: | |
| max_side = get_detection_max_side() | |
| ext = path.suffix.lower() | |
| if ext in (".tif", ".tiff"): | |
| try: | |
| img = _rasterio_read_rgb(path, max_side) | |
| return img.copy() | |
| except ImportError as exc: | |
| raise RuntimeError( | |
| "GeoTIFF support requires rasterio. Install with: pip install rasterio" | |
| ) from exc | |
| except Exception as exc: | |
| raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc | |
| with Image.open(path) as img: | |
| img = ImageOps.exif_transpose(img) | |
| img = img.convert("RGB") | |
| if max(img.size) > max_side: | |
| img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) | |
| return img.copy() | |
| def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None: | |
| """Create RGB thumbnail/preview — uses decimated read for large GeoTIFFs.""" | |
| ext = src_path.suffix.lower() | |
| if ext in (".tif", ".tiff"): | |
| try: | |
| img = _rasterio_read_rgb(src_path, max_side) | |
| if max(img.size) > max_side: | |
| img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) | |
| dest_path.parent.mkdir(parents=True, exist_ok=True) | |
| img.save(dest_path, format="PNG") | |
| return | |
| except Exception as exc: | |
| logger.warning("GeoTIFF preview failed for %s: %s", src_path.name, exc) | |
| write_placeholder_png(dest_path, src_path.name, max_side) | |
| return | |
| try: | |
| with Image.open(src_path) as img: | |
| img = ImageOps.exif_transpose(img) | |
| img = img.convert("RGB") | |
| img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) | |
| dest_path.parent.mkdir(parents=True, exist_ok=True) | |
| img.save(dest_path, format="PNG") | |
| except Exception as exc: | |
| logger.warning("Preview failed for %s: %s", src_path.name, exc) | |
| write_placeholder_png(dest_path, src_path.name, max_side) | |
| class GeorefInfo: | |
| transform: Any | |
| crs: Any | |
| width: int | |
| height: int | |
| bounds_wgs84: Optional[Tuple[float, float, float, float]] | |
| source: str = "embedded" # embedded | worldfile | |
| def read_georef(path: Path) -> Optional[GeorefInfo]: | |
| """Read raster affine transform + WGS84 bounds for any raster format. | |
| Honors embedded CRS (GeoTIFF), GDAL .aux.xml sidecars, and ESRI world files | |
| for TIFF/PNG/JPEG. Returns None when no georeferencing can be resolved. | |
| """ | |
| width = height = 0 | |
| try: | |
| import rasterio | |
| from rasterio.warp import transform_bounds | |
| with rasterio.open(path) as src: | |
| width, height = int(src.width), int(src.height) | |
| if src.crs is not None: | |
| bounds = None | |
| try: | |
| w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds) | |
| bounds = (float(w), float(s), float(e), float(n)) | |
| except Exception as exc: | |
| logger.warning("Could not transform bounds for %s: %s", path.name, exc) | |
| return GeorefInfo( | |
| transform=src.transform, crs=src.crs, | |
| width=width, height=height, bounds_wgs84=bounds, source="embedded", | |
| ) | |
| except ImportError: | |
| return None | |
| except Exception as exc: | |
| logger.warning("read_georef rasterio open failed for %s: %s", path.name, exc) | |
| # No embedded CRS — try an explicit world-file sidecar | |
| if width <= 0 or height <= 0: | |
| try: | |
| with Image.open(path) as img: | |
| width, height = img.size | |
| except Exception: | |
| return None | |
| wf = _world_file_georef(path, width, height) | |
| if wf is not None and wf[2] is not None: | |
| transform, crs, bounds = wf | |
| return GeorefInfo( | |
| transform=transform, crs=crs, | |
| width=width, height=height, bounds_wgs84=bounds, source="worldfile", | |
| ) | |
| return None | |
| def pixel_to_geo_wgs84( | |
| x: float, | |
| y: float, | |
| georef: GeorefInfo, | |
| *, | |
| detection_width: int, | |
| detection_height: int, | |
| ) -> Optional[Tuple[float, float]]: | |
| """Map detection pixel (x=col, y=row) to WGS84 (lng, lat).""" | |
| if detection_width <= 0 or detection_height <= 0: | |
| return None | |
| try: | |
| from rasterio.transform import xy as transform_xy | |
| from rasterio.warp import transform as warp_transform | |
| scale_x = georef.width / float(detection_width) | |
| scale_y = georef.height / float(detection_height) | |
| col = float(x) * scale_x | |
| row = float(y) * scale_y | |
| geo_x, geo_y = transform_xy(georef.transform, row, col, offset="center") | |
| if georef.crs and str(georef.crs) != "EPSG:4326": | |
| lngs, lats = warp_transform(georef.crs, "EPSG:4326", [geo_x], [geo_y]) | |
| return float(lngs[0]), float(lats[0]) | |
| return float(geo_x), float(geo_y) | |
| except Exception as exc: | |
| logger.warning("pixel_to_geo_wgs84 failed: %s", exc) | |
| return None | |
| def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str: | |
| if not bounds: | |
| return "" | |
| return json.dumps({"west": bounds[0], "south": bounds[1], "east": bounds[2], "north": bounds[3]}) | |