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

Raise library GeoTIFF upload limit to 5 GB on dev.

Browse files

Default MAX_GEOTIFF_MB to 5120, stream uploads with GB-aware errors,
client-side size check, and longer keep-alive for large file transfers.

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

DEPLOYMENT.md CHANGED
@@ -134,7 +134,7 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
134
  |----------|---------|
135
  | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
136
  | `LOCAL_LIBRARY_ROOT` | Path to year folders (default: `library_sources/` in project) |
137
- | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **2048** = 2 GB on dev) |
138
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
139
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
140
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
 
134
  |----------|---------|
135
  | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
136
  | `LOCAL_LIBRARY_ROOT` | Path to year folders (default: `library_sources/` in project) |
137
+ | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
138
  | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
139
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
140
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
Dockerfile CHANGED
@@ -21,7 +21,8 @@ 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=29
 
25
  ENV APP_BUILD=${APP_BUILD}
26
  ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
 
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
28
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
app/dda/config.py CHANGED
@@ -58,8 +58,8 @@ THUMBS_DIR = LIBRARY_DIR / "thumbs"
58
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
59
  LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
60
 
61
- # GeoTIFF library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
62
- MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
63
 
64
  # Raster sidecar formats (PNG/JPEG) — smaller cap for library uploads
65
  MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_MB", "50")) * 1024 * 1024
@@ -104,3 +104,13 @@ def geotiff_io_available() -> bool:
104
  return True
105
  except ImportError:
106
  return False
 
 
 
 
 
 
 
 
 
 
 
58
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
59
  LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
60
 
61
+ # GeoTIFF library upload limit (default 5 GB; override with MAX_GEOTIFF_MB on HF dev Space)
62
+ MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "5120")) * 1024 * 1024
63
 
64
  # Raster sidecar formats (PNG/JPEG) — smaller cap for library uploads
65
  MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_MB", "50")) * 1024 * 1024
 
104
  return True
105
  except ImportError:
106
  return False
107
+
108
+
109
+ def get_detection_max_side() -> int:
110
+ """Max pixel dimension for GeoTIFF load + detection pipeline (higher = sharper, more RAM)."""
111
+ default = "2048" if is_hf_hosted() else "4096"
112
+ try:
113
+ value = int(os.environ.get("DETECTION_MAX_SIDE", default))
114
+ except ValueError:
115
+ value = int(default)
116
+ return max(1024, min(8192, value))
app/dda/library_routes.py CHANGED
@@ -72,6 +72,7 @@ def dda_config():
72
  "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
73
  "maxUploadGb": round(max_gb, 2),
74
  "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
 
75
  "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
76
  "geotiffEnabled": geotiff_io_available(),
77
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
 
72
  "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
73
  "maxUploadGb": round(max_gb, 2),
74
  "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
75
+ "maxGeotiffBytes": MAX_GEOTIFF_BYTES,
76
  "maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
77
  "geotiffEnabled": geotiff_io_available(),
78
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
app/dda/local_routes.py CHANGED
@@ -3,12 +3,20 @@ import logging
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
- from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
7
  from fastapi.responses import FileResponse
 
 
 
 
 
 
8
 
9
  from .config import (
10
  IS_DDA_MODE,
 
11
  geotiff_io_available,
 
12
  get_library_roots,
13
  get_writable_library_root,
14
  is_hf_hosted,
@@ -69,6 +77,10 @@ def local_library_config():
69
  "writablePath": writable,
70
  "instructions": instructions,
71
  "geotiffEnabled": geotiff_io_available(),
 
 
 
 
72
  }
73
 
74
 
@@ -117,7 +129,13 @@ def local_thumb(path: str = Query(...)):
117
  except HTTPException:
118
  raise
119
  except Exception as exc:
120
- raise HTTPException(status_code=500, detail=f"Thumbnail failed: {exc}") from exc
 
 
 
 
 
 
121
 
122
 
123
  @router.post("/local/upload")
@@ -177,3 +195,74 @@ def local_rescan():
177
  "writablePath": str(get_writable_library_root()),
178
  "debug": info,
179
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from pathlib import Path
4
  from typing import Optional
5
 
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
7
  from fastapi.responses import FileResponse
8
+ from PIL import Image
9
+ from sqlalchemy.orm import Session
10
+
11
+ from ..database import get_db
12
+ from .detect_service import run_detection_and_save
13
+ from .geotiff_io import load_rgb_pil
14
 
15
  from .config import (
16
  IS_DDA_MODE,
17
+ MAX_GEOTIFF_BYTES,
18
  geotiff_io_available,
19
+ get_detection_max_side,
20
  get_library_roots,
21
  get_writable_library_root,
22
  is_hf_hosted,
 
77
  "writablePath": writable,
78
  "instructions": instructions,
79
  "geotiffEnabled": geotiff_io_available(),
80
+ "detectionMaxSide": get_detection_max_side(),
81
+ "maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
82
+ "maxGeotiffBytes": MAX_GEOTIFF_BYTES,
83
+ "maxUploadGb": round(MAX_GEOTIFF_BYTES / (1024 ** 3), 2),
84
  }
85
 
86
 
 
129
  except HTTPException:
130
  raise
131
  except Exception as exc:
132
+ logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
133
+ from .config import LOCAL_THUMB_CACHE
134
+ from .geotiff_io import write_placeholder_png
135
+
136
+ cache = LOCAL_THUMB_CACHE / "fallback.png"
137
+ write_placeholder_png(cache, Path(path).name)
138
+ return FileResponse(cache, media_type="image/png")
139
 
140
 
141
  @router.post("/local/upload")
 
195
  "writablePath": str(get_writable_library_root()),
196
  "debug": info,
197
  }
198
+
199
+
200
+ @router.post("/detect/from-library")
201
+ async def detect_from_library(
202
+ base_path: str = Form(...),
203
+ comparison_path: str = Form(...),
204
+ method: str = Form("AI-Based Deep Learning"),
205
+ title: str = Form("Untitled run"),
206
+ zone: str = Form(""),
207
+ village: str = Form(""),
208
+ enable_registration: bool = Form(True),
209
+ enable_normalization: bool = Form(True),
210
+ detection_sensitivity: float = Form(0.5),
211
+ min_region_area: Optional[int] = Form(150),
212
+ notify_email: Optional[str] = Form(None),
213
+ db: Session = Depends(get_db),
214
+ ):
215
+ """Run change detection on two library images by relative path (e.g. 2025/aerial.tif)."""
216
+ _require_dda()
217
+ base_norm = base_path.replace("\\", "/").strip()
218
+ comp_norm = comparison_path.replace("\\", "/").strip()
219
+ if not base_norm or not comp_norm:
220
+ raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
221
+ if base_norm == comp_norm:
222
+ raise HTTPException(status_code=400, detail="Base and comparison images must be different")
223
+
224
+ try:
225
+ base_file = safe_resolve(base_norm)
226
+ comp_file = safe_resolve(comp_norm)
227
+ except HTTPException:
228
+ raise
229
+ except Exception as exc:
230
+ raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
231
+
232
+ try:
233
+ before_pil = load_rgb_pil(base_file, max_side=get_detection_max_side())
234
+ after_pil = load_rgb_pil(comp_file, max_side=get_detection_max_side())
235
+ except RuntimeError as exc:
236
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
237
+ except Exception as exc:
238
+ raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
239
+
240
+ # Match dimensions so registration and overlay align with the before image
241
+ if before_pil.size != after_pil.size:
242
+ after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
243
+
244
+ max_side = get_detection_max_side()
245
+
246
+ if title == "Untitled run":
247
+ title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
248
+
249
+ try:
250
+ return run_detection_and_save(
251
+ db,
252
+ before_pil,
253
+ after_pil,
254
+ method=method,
255
+ title=title,
256
+ zone=zone,
257
+ village=village,
258
+ enable_registration=enable_registration,
259
+ enable_normalization=enable_normalization,
260
+ detection_sensitivity=detection_sensitivity,
261
+ min_region_area=min_region_area,
262
+ notify_email=notify_email,
263
+ max_size=max_side,
264
+ geo_bounds_path=base_file,
265
+ )
266
+ except Exception as exc:
267
+ logger.exception("Library detection failed for %s vs %s", base_norm, comp_norm)
268
+ raise HTTPException(status_code=500, detail=f"Detection failed: {exc}") from exc
app/dda/upload_io.py CHANGED
@@ -30,9 +30,11 @@ async def stream_upload_to_file(
30
  break
31
  total += len(chunk)
32
  if total > max_bytes:
 
 
33
  raise HTTPException(
34
  status_code=400,
35
- detail=f"File too large (max {max_bytes // (1024 * 1024)} MB)",
36
  )
37
  out.write(chunk)
38
  except HTTPException:
 
30
  break
31
  total += len(chunk)
32
  if total > max_bytes:
33
+ max_mb = max_bytes // (1024 * 1024)
34
+ limit = f"{max_mb // 1024} GB" if max_mb >= 1024 else f"{max_mb} MB"
35
  raise HTTPException(
36
  status_code=400,
37
+ detail=f"File too large (max {limit})",
38
  )
39
  out.write(chunk)
40
  except HTTPException:
library_sources/README.md CHANGED
@@ -44,7 +44,7 @@ library_sources/
44
 
45
  ## Large files
46
 
47
- GeoTIFF files up to **2 GB** are supported when read from disk. Copy files via Explorer/Finder — much faster than browser upload.
48
 
49
  ## Custom location
50
 
 
44
 
45
  ## Large files
46
 
47
+ GeoTIFF files up to **5 GB** are supported when read from disk. Copy files via Explorer/Finder — much faster than browser upload.
48
 
49
  ## Custom location
50
 
run.py CHANGED
@@ -54,7 +54,13 @@ def main():
54
 
55
  # reload=False keeps it simple and IDLE-friendly. For live-reload during
56
  # development, run instead: uvicorn app.main:app --reload --port 8000
57
- uvicorn.run("app.main:app", host=HOST, port=PORT, reload=False)
 
 
 
 
 
 
58
 
59
 
60
  if __name__ == "__main__":
 
54
 
55
  # reload=False keeps it simple and IDLE-friendly. For live-reload during
56
  # development, run instead: uvicorn app.main:app --reload --port 8000
57
+ uvicorn.run(
58
+ "app.main:app",
59
+ host=HOST,
60
+ port=PORT,
61
+ reload=False,
62
+ timeout_keep_alive=600,
63
+ )
64
 
65
 
66
  if __name__ == "__main__":
static/js/dda/app.js CHANGED
@@ -57,6 +57,9 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
57
  btn.classList.add('active');
58
  const tab = btn.dataset.tab;
59
  document.getElementById('tab-' + tab)?.classList.add('active');
 
 
 
60
  });
61
  });
62
 
@@ -100,6 +103,16 @@ async function initDda() {
100
  const hfUpload = document.getElementById('hf-upload-card');
101
  if (hfUpload) hfUpload.classList.toggle('hidden', !localCfg.isHosted);
102
 
 
 
 
 
 
 
 
 
 
 
103
  if (!localCfg.isHosted && ddaConfig.appMode !== 'dda') {
104
  showDdaError('DDA mode is off. Run locally with: python run.py');
105
  }
@@ -126,6 +139,7 @@ async function loadLibraryImages() {
126
  }
127
  try {
128
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
 
129
  if (!items.length) {
130
  const hf = window.ddaState?.localCfg?.isHosted;
131
  grid.innerHTML = hf
@@ -151,6 +165,7 @@ async function loadLibraryImages() {
151
  e.dataTransfer.setData('text/plain', card.dataset.imagePath);
152
  });
153
  });
 
154
  } catch (err) {
155
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
156
  }
 
57
  btn.classList.add('active');
58
  const tab = btn.dataset.tab;
59
  document.getElementById('tab-' + tab)?.classList.add('active');
60
+ if (tab === 'detect' && typeof loadCompareLibraryGrid === 'function') {
61
+ loadCompareLibraryGrid();
62
+ }
63
  });
64
  });
65
 
 
103
  const hfUpload = document.getElementById('hf-upload-card');
104
  if (hfUpload) hfUpload.classList.toggle('hidden', !localCfg.isHosted);
105
 
106
+ const resHint = document.getElementById('dda-detect-res-hint');
107
+ if (resHint && localCfg.detectionMaxSide) {
108
+ resHint.textContent = `Detection runs at up to ${localCfg.detectionMaxSide}px per side for sharper results (set DETECTION_MAX_SIDE to change).`;
109
+ }
110
+
111
+ const uploadLimit = document.getElementById('hf-upload-limit');
112
+ if (uploadLimit && localCfg.maxUploadGb) {
113
+ uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
114
+ }
115
+
116
  if (!localCfg.isHosted && ddaConfig.appMode !== 'dda') {
117
  showDdaError('DDA mode is off. Run locally with: python run.py');
118
  }
 
139
  }
140
  try {
141
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
142
+ window.ddaState.libraryItems = items;
143
  if (!items.length) {
144
  const hf = window.ddaState?.localCfg?.isHosted;
145
  grid.innerHTML = hf
 
165
  e.dataTransfer.setData('text/plain', card.dataset.imagePath);
166
  });
167
  });
168
+ if (typeof loadCompareLibraryGrid === 'function') loadCompareLibraryGrid();
169
  } catch (err) {
170
  grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
171
  }
static/js/dda/library.js CHANGED
@@ -67,6 +67,13 @@ document.getElementById('form-hf-upload')?.addEventListener('submit', async (e)
67
  return;
68
  }
69
 
 
 
 
 
 
 
 
70
  const form = new FormData();
71
  form.append('file', file);
72
  form.append('year', document.getElementById('hf-year').value);
 
67
  return;
68
  }
69
 
70
+ const maxBytes = window.ddaState?.localCfg?.maxGeotiffBytes
71
+ || (window.ddaState?.localCfg?.maxGeotiffMb || 5120) * 1024 * 1024;
72
+ if (file.size > maxBytes) {
73
+ showDdaError?.(`File is ${formatBytes(file.size)} — maximum upload size is ${formatBytes(maxBytes)}.`);
74
+ return;
75
+ }
76
+
77
  const form = new FormData();
78
  form.append('file', file);
79
  form.append('year', document.getElementById('hf-year').value);