coderuday21 Cursor commited on
Commit
79d533c
·
1 Parent(s): 5ae5432

Fix dev Space startup: add missing DDA modules referenced by local_routes.

Browse files

Includes detect_service, geo_regions, geotiff load_rgb_pil, and thumb fallbacks
that were omitted from the 5 GB upload commit.

Co-authored-by: Cursor <cursoragent@cursor.com>

Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=30
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=31
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
app/dda/change_type_map.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map internal detection_engine labels → DDA report change types (FR-04)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ DDA_NEW_CONSTRUCTION = "New Construction"
7
+ DDA_DEMOLITION = "Demolition"
8
+ DDA_EXTENSION = "Extension"
9
+ DDA_VEGETATION = "Vegetation Change"
10
+ DDA_OTHER = "Other"
11
+
12
+ _KEYWORDS = [
13
+ (DDA_DEMOLITION, ("demolition", "clearing", "removed", "debris")),
14
+ (DDA_EXTENSION, ("expansion", "widening", "extension", "renovation", "addition")),
15
+ (DDA_VEGETATION, ("vegetation", "tree", "forest", "green", "crop")),
16
+ (DDA_NEW_CONSTRUCTION, (
17
+ "construction", "building", "structure", "road", "pavement",
18
+ "temporary", "roof", "concrete", "foundation",
19
+ )),
20
+ (DDA_OTHER, ("water", "bare", "soil", "land", "unclassified")),
21
+ ]
22
+
23
+
24
+ def map_to_dda_change_type(internal_type: str) -> str:
25
+ """Return canonical DDA change type for a detection_engine object_type string."""
26
+ if not internal_type:
27
+ return DDA_OTHER
28
+ lower = internal_type.lower()
29
+ for dda_type, keywords in _KEYWORDS:
30
+ if any(kw in lower for kw in keywords):
31
+ return dda_type
32
+ if "new" in lower:
33
+ return DDA_NEW_CONSTRUCTION
34
+ return DDA_OTHER
35
+
36
+
37
+ def enrich_region_for_dda(region: dict, *, dda_change_type: Optional[str] = None) -> dict:
38
+ """Add DDA report fields to a serialized region dict."""
39
+ internal = region.get("objectType") or region.get("object_type") or ""
40
+ out = dict(region)
41
+ out["ddaChangeType"] = dda_change_type or map_to_dda_change_type(internal)
42
+ out["internalObjectType"] = internal
43
+ out["reviewStatus"] = region.get("reviewStatus", "pending")
44
+ return out
app/dda/detect_service.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run change detection and persist results (shared by upload + library paths)."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from datetime import timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from PIL import Image
13
+ from sqlalchemy.orm import Session
14
+
15
+ from ..auth import get_or_create_guest_user
16
+ from ..database import DATA_DIR
17
+ from ..models import DetectionRun
18
+ from .geo_regions import bounds_from_image_path, enrich_regions_geo
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ OVERLAYS_DIR = DATA_DIR / "overlays"
23
+ THUMB_MAX_SIZE = 200
24
+
25
+
26
+ def _serialize_regions(change_regions) -> list:
27
+ return [
28
+ {
29
+ "id": int(r["id"]),
30
+ "area": int(r["area"]),
31
+ "center": {"x": int(r["center"][0]), "y": int(r["center"][1])},
32
+ "bbox": {
33
+ "x": int(r["bbox"][0]),
34
+ "y": int(r["bbox"][1]),
35
+ "w": int(r["bbox"][2]),
36
+ "h": int(r["bbox"][3]),
37
+ },
38
+ "objectType": str(r["object_type"]),
39
+ "confidence": float(r["confidence"]),
40
+ "severity": r.get("severity", "minor"),
41
+ "subType": r.get("sub_type"),
42
+ "subTypeConfidence": float(r["sub_type_confidence"])
43
+ if r.get("sub_type_confidence") is not None
44
+ else None,
45
+ "estimatedStories": r.get("estimated_stories"),
46
+ "estimatedHeightM": float(r["estimated_height_m"])
47
+ if r.get("estimated_height_m") is not None
48
+ else None,
49
+ "constructionStage": r.get("construction_stage"),
50
+ }
51
+ for r in change_regions
52
+ ]
53
+
54
+
55
+ def _isoformat_ist(dt):
56
+ from datetime import timedelta
57
+ if dt is None:
58
+ return None
59
+ _IST = timezone(timedelta(hours=5, minutes=30))
60
+ if dt.tzinfo is None:
61
+ from datetime import timezone as tz
62
+ dt = dt.replace(tzinfo=tz.utc)
63
+ return dt.astimezone(_IST).isoformat()
64
+
65
+
66
+ def run_detection_and_save(
67
+ db: Session,
68
+ before_pil: Image.Image,
69
+ after_pil: Image.Image,
70
+ *,
71
+ method: str = "AI-Based Deep Learning",
72
+ title: str = "Untitled run",
73
+ zone: str = "",
74
+ village: str = "",
75
+ enable_registration: bool = True,
76
+ enable_normalization: bool = True,
77
+ detection_sensitivity: float = 0.5,
78
+ min_region_area: Optional[int] = None,
79
+ notify_email: Optional[str] = None,
80
+ max_size: Optional[int] = None,
81
+ geo_bounds_path: Optional[Path] = None,
82
+ ) -> dict:
83
+ from ..detection_engine import run_detection
84
+
85
+ user = get_or_create_guest_user(db)
86
+ detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
87
+ if min_region_area is not None:
88
+ min_region_area = int(max(50, min(10000, min_region_area)))
89
+
90
+ change_mask, result_image, stats, change_regions = run_detection(
91
+ before_pil,
92
+ after_pil,
93
+ method=method,
94
+ enable_registration=enable_registration,
95
+ enable_normalization=enable_normalization,
96
+ detection_sensitivity=detection_sensitivity,
97
+ min_region_area=min_region_area,
98
+ max_size=max_size,
99
+ )
100
+
101
+ # Save before image at detection resolution (matches overlay coordinates for slider)
102
+ from ..detection_engine import preprocess_image, get_detection_max_size
103
+
104
+ before_for_slider = Image.fromarray(
105
+ preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
106
+ )
107
+
108
+ base_name = f"{user.id}_{uuid.uuid4().hex}"
109
+ overlay_filename = base_name + ".png"
110
+ overlay_path = OVERLAYS_DIR / overlay_filename
111
+ overlay_path.parent.mkdir(parents=True, exist_ok=True)
112
+ Image.fromarray(result_image).save(overlay_path)
113
+ relative_overlay = f"overlays/{overlay_filename}"
114
+
115
+ relative_before_full = ""
116
+ relative_before_thumb = ""
117
+ relative_after_thumb = ""
118
+ try:
119
+ before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
120
+ before_for_slider.save(before_full_file)
121
+ relative_before_full = f"overlays/{base_name}_before.png"
122
+ before_thumb_pil = before_pil.copy()
123
+ before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
124
+ before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
125
+ after_thumb_pil = after_pil.copy()
126
+ after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
127
+ after_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_after_thumb.png")
128
+ relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
129
+ relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
130
+ except Exception as exc:
131
+ logger.warning("Failed to save thumbnails: %s", exc)
132
+
133
+ regions_serializable = _serialize_regions(change_regions)
134
+ img_w, img_h = before_for_slider.size
135
+ bounds = bounds_from_image_path(geo_bounds_path) if geo_bounds_path else None
136
+ regions_serializable = enrich_regions_geo(
137
+ regions_serializable,
138
+ img_width=img_w,
139
+ img_height=img_h,
140
+ bounds=bounds,
141
+ )
142
+ total_px = int(stats["total_pixels"])
143
+ changed_px = int(stats["changed_pixels"])
144
+ change_pct = float(stats["change_percentage"])
145
+
146
+ run = DetectionRun(
147
+ user_id=user.id,
148
+ title=title,
149
+ method=method,
150
+ zone=zone,
151
+ village=village,
152
+ total_pixels=total_px,
153
+ changed_pixels=changed_px,
154
+ change_percentage=change_pct,
155
+ regions_count=len(change_regions),
156
+ overlay_path=relative_overlay,
157
+ before_full_path=relative_before_full,
158
+ before_thumb_path=relative_before_thumb,
159
+ after_thumb_path=relative_after_thumb,
160
+ regions_json=json.dumps(regions_serializable),
161
+ )
162
+ db.add(run)
163
+ db.commit()
164
+ db.refresh(run)
165
+
166
+ overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
167
+ notification_sent = False
168
+ notification_error = None
169
+ if notify_email and notify_email.strip():
170
+ notification_sent, notification_error = send_notification(
171
+ recipient=notify_email.strip(),
172
+ title=title,
173
+ method=method,
174
+ zone=zone,
175
+ village=village,
176
+ change_pct=change_pct,
177
+ changed_px=changed_px,
178
+ total_px=total_px,
179
+ regions=regions_serializable,
180
+ )
181
+
182
+ return {
183
+ "id": run.id,
184
+ "title": run.title,
185
+ "method": run.method,
186
+ "zone": run.zone or "",
187
+ "village": run.village or "",
188
+ "statistics": {
189
+ "totalPixels": total_px,
190
+ "changedPixels": changed_px,
191
+ "unchangedPixels": int(stats["unchanged_pixels"]),
192
+ "changePercentage": change_pct,
193
+ "thresholdDebug": stats.get("threshold_debug", {}),
194
+ "params": stats.get("params", {}),
195
+ "alignmentWarning": stats.get("alignment_warning"),
196
+ "registrationOk": stats.get("params", {}).get("registration_ok"),
197
+ },
198
+ "regions": regions_serializable,
199
+ "overlayBase64Png": overlay_b64,
200
+ "overlayUrl": f"/api/overlay/{relative_overlay}",
201
+ "beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
202
+ "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
203
+ "afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
204
+ "notificationSent": notification_sent,
205
+ "notificationError": notification_error,
206
+ "createdAt": _isoformat_ist(run.created_at),
207
+ "detectionMaxSide": max_size,
208
+ }
app/dda/geo_regions.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pixel coordinates → WGS84 lat/lng for geo-referenced images (FR-04, FR-06)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ from .change_type_map import enrich_region_for_dda
10
+ from .geotiff_io import inspect_image
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
15
+
16
+
17
+ def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
18
+ if bounds is None:
19
+ return None
20
+ if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
21
+ return tuple(float(x) for x in bounds)
22
+ if isinstance(bounds, dict):
23
+ try:
24
+ return (
25
+ float(bounds["west"]),
26
+ float(bounds["south"]),
27
+ float(bounds["east"]),
28
+ float(bounds["north"]),
29
+ )
30
+ except (KeyError, TypeError, ValueError):
31
+ return None
32
+ if isinstance(bounds, str) and bounds.strip():
33
+ try:
34
+ data = json.loads(bounds)
35
+ return parse_bounds(data)
36
+ except json.JSONDecodeError:
37
+ parts = [float(x.strip()) for x in bounds.replace("[", "").replace("]", "").split(",")]
38
+ if len(parts) == 4:
39
+ return tuple(parts)
40
+ return None
41
+
42
+
43
+ def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
44
+ try:
45
+ meta = inspect_image(path)
46
+ return meta.bounds_wgs84
47
+ except Exception as exc:
48
+ logger.warning("Could not read bounds for %s: %s", path.name, exc)
49
+ return None
50
+
51
+
52
+ def pixel_to_lat_lng(
53
+ x: float,
54
+ y: float,
55
+ img_width: int,
56
+ img_height: int,
57
+ bounds: BoundsWGS84,
58
+ ) -> Optional[Dict[str, float]]:
59
+ if img_width <= 0 or img_height <= 0 or not bounds:
60
+ return None
61
+ west, south, east, north = bounds
62
+ lng = west + (float(x) / img_width) * (east - west)
63
+ lat = north - (float(y) / img_height) * (north - south)
64
+ return {"lat": round(lat, 6), "lng": round(lng, 6)}
65
+
66
+
67
+ def bbox_area_sq_m(
68
+ bbox: Dict[str, int],
69
+ img_width: int,
70
+ img_height: int,
71
+ bounds: BoundsWGS84,
72
+ ) -> Optional[float]:
73
+ """Approximate region area in square metres using geographic bounds."""
74
+ if img_width <= 0 or img_height <= 0 or not bounds:
75
+ return None
76
+ west, south, east, north = bounds
77
+ m_per_px_x = abs(east - west) / img_width
78
+ m_per_px_y = abs(north - south) / img_height
79
+ # Rough conversion: 1 degree ≈ 111_320 m at equator; scale lng by cos(lat)
80
+ import math
81
+ mid_lat = (north + south) / 2.0
82
+ lat_scale = 111_320.0
83
+ lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
84
+ w_m = bbox.get("w", 0) * m_per_px_x * lng_scale
85
+ h_m = bbox.get("h", 0) * m_per_px_y * lat_scale
86
+ return round(w_m * h_m, 1)
87
+
88
+
89
+ def enrich_regions_geo(
90
+ regions: List[dict],
91
+ *,
92
+ img_width: int,
93
+ img_height: int,
94
+ bounds: Optional[BoundsWGS84],
95
+ ) -> List[dict]:
96
+ """Add latLng, areaSqM, and DDA change type to each region."""
97
+ out = []
98
+ for region in regions:
99
+ enriched = enrich_region_for_dda(region)
100
+ center = region.get("center") or {}
101
+ cx = center.get("x", 0)
102
+ cy = center.get("y", 0)
103
+ if bounds:
104
+ lat_lng = pixel_to_lat_lng(cx, cy, img_width, img_height, bounds)
105
+ if lat_lng:
106
+ enriched["latLng"] = lat_lng
107
+ bbox = region.get("bbox") or {}
108
+ area_sq_m = bbox_area_sq_m(bbox, img_width, img_height, bounds)
109
+ if area_sq_m is not None:
110
+ enriched["areaSqM"] = area_sq_m
111
+ else:
112
+ enriched["latLng"] = None
113
+ out.append(enriched)
114
+ return out
app/dda/geotiff_io.py CHANGED
@@ -9,6 +9,8 @@ from typing import Optional, Tuple
9
 
10
  from PIL import Image
11
 
 
 
12
  logger = logging.getLogger(__name__)
13
 
14
 
@@ -73,47 +75,94 @@ def inspect_image(path: Path) -> IngestResult:
73
  return _read_with_pillow(path)
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
77
  """Create RGB thumbnail/preview — uses decimated read for large GeoTIFFs."""
78
  ext = src_path.suffix.lower()
79
  if ext in (".tif", ".tiff"):
80
  try:
81
- import numpy as np
82
- import rasterio
83
- from rasterio.enums import Resampling
84
-
85
- with rasterio.open(src_path) as src:
86
- count = min(3, src.count)
87
- scale = min(1.0, max_side / max(src.width, src.height, 1))
88
- out_h = max(1, int(src.height * scale))
89
- out_w = max(1, int(src.width * scale))
90
- data = src.read(
91
- indexes=list(range(1, count + 1)),
92
- out_shape=(count, out_h, out_w),
93
- resampling=Resampling.bilinear,
94
- )
95
- if count == 1:
96
- rgb = np.stack([data[0], data[0], data[0]])
97
- else:
98
- rgb = data[:3]
99
- rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
100
- if rgb.max() > 255 or rgb.min() < 0:
101
- lo, hi = np.percentile(rgb, (2, 98))
102
- rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
103
- img = Image.fromarray(rgb.astype("uint8"), mode="RGB")
104
- if max(img.size) > max_side:
105
- img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
106
- dest_path.parent.mkdir(parents=True, exist_ok=True)
107
- img.save(dest_path, format="PNG")
108
- return
109
  except Exception as exc:
110
- logger.warning("GeoTIFF preview via rasterio failed: %s", exc)
111
-
112
- with Image.open(src_path) as img:
113
- img = img.convert("RGB")
114
- img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
115
- dest_path.parent.mkdir(parents=True, exist_ok=True)
116
- img.save(dest_path, format="PNG")
 
 
 
 
 
 
117
 
118
 
119
  def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
 
9
 
10
  from PIL import Image
11
 
12
+ from .config import get_detection_max_side
13
+
14
  logger = logging.getLogger(__name__)
15
 
16
 
 
75
  return _read_with_pillow(path)
76
 
77
 
78
+ def write_placeholder_png(dest_path: Path, label: str = "Image", max_side: int = 256) -> None:
79
+ """Fallback thumb when GeoTIFF is too large or rasterio is unavailable."""
80
+ from PIL import ImageDraw
81
+
82
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
83
+ img = Image.new("RGB", (max_side, max_side), color=(32, 40, 52))
84
+ draw = ImageDraw.Draw(img)
85
+ lines = [label[:28], "preview N/A"]
86
+ y = max_side // 2 - 20
87
+ for line in lines:
88
+ draw.text((12, y), line, fill=(100, 200, 170))
89
+ y += 18
90
+ img.save(dest_path, format="PNG")
91
+
92
+
93
+ def _rasterio_read_rgb(path: Path, max_side: int):
94
+ import numpy as np
95
+ import rasterio
96
+ from rasterio.enums import Resampling
97
+
98
+ with rasterio.open(path) as src:
99
+ count = min(3, src.count)
100
+ scale = min(1.0, max_side / max(src.width, src.height, 1))
101
+ out_h = max(1, int(src.height * scale))
102
+ out_w = max(1, int(src.width * scale))
103
+ data = src.read(
104
+ indexes=list(range(1, count + 1)),
105
+ out_shape=(count, out_h, out_w),
106
+ resampling=Resampling.bilinear,
107
+ )
108
+ if count == 1:
109
+ rgb = np.stack([data[0], data[0], data[0]])
110
+ else:
111
+ rgb = data[:3]
112
+ rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
113
+ if rgb.max() > 255 or rgb.min() < 0:
114
+ lo, hi = np.percentile(rgb, (2, 98))
115
+ rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
116
+ return Image.fromarray(rgb.astype("uint8"), mode="RGB")
117
+
118
+
119
+ def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image:
120
+ """Load image as RGB PIL, downscaling large GeoTIFFs via rasterio."""
121
+ if max_side is None:
122
+ max_side = get_detection_max_side()
123
+ ext = path.suffix.lower()
124
+ if ext in (".tif", ".tiff"):
125
+ try:
126
+ img = _rasterio_read_rgb(path, max_side)
127
+ return img.copy()
128
+ except ImportError as exc:
129
+ raise RuntimeError(
130
+ "GeoTIFF support requires rasterio. Install with: pip install rasterio"
131
+ ) from exc
132
+ except Exception as exc:
133
+ raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc
134
+ with Image.open(path) as img:
135
+ img = img.convert("RGB")
136
+ if max(img.size) > max_side:
137
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
138
+ return img.copy()
139
+
140
+
141
  def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
142
  """Create RGB thumbnail/preview — uses decimated read for large GeoTIFFs."""
143
  ext = src_path.suffix.lower()
144
  if ext in (".tif", ".tiff"):
145
  try:
146
+ img = _rasterio_read_rgb(src_path, max_side)
147
+ if max(img.size) > max_side:
148
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
149
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
150
+ img.save(dest_path, format="PNG")
151
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  except Exception as exc:
153
+ logger.warning("GeoTIFF preview failed for %s: %s", src_path.name, exc)
154
+ write_placeholder_png(dest_path, src_path.name, max_side)
155
+ return
156
+
157
+ try:
158
+ with Image.open(src_path) as img:
159
+ img = img.convert("RGB")
160
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
161
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
162
+ img.save(dest_path, format="PNG")
163
+ except Exception as exc:
164
+ logger.warning("Preview failed for %s: %s", src_path.name, exc)
165
+ write_placeholder_png(dest_path, src_path.name, max_side)
166
 
167
 
168
  def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
app/dda/local_library.py CHANGED
@@ -23,7 +23,7 @@ from urllib.parse import quote
23
  from fastapi import HTTPException
24
 
25
  from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
26
- from .geotiff_io import inspect_image, raster_to_preview_png
27
 
28
  logger = logging.getLogger(__name__)
29
 
@@ -157,11 +157,16 @@ def thumb_cache_path(relative_path: str) -> Path:
157
  def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
158
  full = safe_resolve(relative_path)
159
  cache = thumb_cache_path(relative_path)
160
- if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
 
 
 
 
 
 
 
 
161
  return cache
162
- cache.parent.mkdir(parents=True, exist_ok=True)
163
- raster_to_preview_png(full, cache, max_side=max_side)
164
- return cache
165
 
166
 
167
  def ensure_root() -> None:
 
23
  from fastapi import HTTPException
24
 
25
  from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
26
+ from .geotiff_io import inspect_image, raster_to_preview_png, write_placeholder_png
27
 
28
  logger = logging.getLogger(__name__)
29
 
 
157
  def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
158
  full = safe_resolve(relative_path)
159
  cache = thumb_cache_path(relative_path)
160
+ try:
161
+ if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
162
+ return cache
163
+ cache.parent.mkdir(parents=True, exist_ok=True)
164
+ raster_to_preview_png(full, cache, max_side=max_side)
165
+ return cache
166
+ except Exception as exc:
167
+ logger.warning("Thumb build failed for %s: %s", relative_path, exc)
168
+ write_placeholder_png(cache, Path(relative_path).name, max_side)
169
  return cache
 
 
 
170
 
171
 
172
  def ensure_root() -> None: