coderuday21 Cursor commited on
Commit
c9672b1
·
1 Parent(s): 41d16b3

Support 2GB GeoTIFF library uploads with streamed writes and progress UI

Browse files
DEPLOYMENT.md CHANGED
@@ -132,6 +132,9 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
132
 
133
  | Variable | Purpose |
134
  |----------|---------|
 
 
 
135
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
136
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
137
  | `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
 
132
 
133
  | Variable | Purpose |
134
  |----------|---------|
135
+ | `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
136
+ | `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **2048** = 2 GB on dev) |
137
+ | `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
138
  | `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
139
  | `DATABASE_URL` | PostgreSQL instead of SQLite (optional) |
140
  | `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
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=25
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
@@ -51,4 +51,4 @@ ENV PORT=7860
51
  EXPOSE 7860
52
 
53
  # Bind to runtime PORT so health checks always reach the server.
54
- CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info"]
 
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=26
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
 
51
  EXPOSE 7860
52
 
53
  # Bind to runtime PORT so health checks always reach the server.
54
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info --timeout-keep-alive 600"]
app/dda/config.py CHANGED
@@ -10,8 +10,11 @@ LIBRARY_DIR = DATA_DIR / "library"
10
  THUMBS_DIR = LIBRARY_DIR / "thumbs"
11
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
12
 
13
- # GeoTIFF upload limit (DDA responsible for suitable resolution per SOW)
14
- MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "500")) * 1024 * 1024
 
 
 
15
 
16
  ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
17
 
@@ -24,6 +27,12 @@ def ensure_library_dirs() -> None:
24
  pass
25
 
26
 
 
 
 
 
 
 
27
  def geotiff_io_available() -> bool:
28
  try:
29
  import rasterio # noqa: F401
 
10
  THUMBS_DIR = LIBRARY_DIR / "thumbs"
11
  PREVIEWS_DIR = LIBRARY_DIR / "previews"
12
 
13
+ # GeoTIFF library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
14
+ MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
15
+
16
+ # Raster sidecar formats (PNG/JPEG) — smaller cap for library uploads
17
+ MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_MB", "50")) * 1024 * 1024
18
 
19
  ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
20
 
 
27
  pass
28
 
29
 
30
+ def max_upload_bytes_for_extension(ext: str) -> int:
31
+ if ext in (".tif", ".tiff"):
32
+ return MAX_GEOTIFF_BYTES
33
+ return MAX_IMAGE_BYTES
34
+
35
+
36
  def geotiff_io_available() -> bool:
37
  try:
38
  import rasterio # noqa: F401
app/dda/geotiff_io.py CHANGED
@@ -74,16 +74,24 @@ def inspect_image(path: Path) -> IngestResult:
74
 
75
 
76
  def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
77
- """Create RGB thumbnail/preview from GeoTIFF or raster image."""
78
  ext = src_path.suffix.lower()
79
  if ext in (".tif", ".tiff"):
80
  try:
81
  import numpy as np
82
  import rasterio
 
83
 
84
  with rasterio.open(src_path) as src:
85
  count = min(3, src.count)
86
- data = src.read(indexes=list(range(1, count + 1)))
 
 
 
 
 
 
 
87
  if count == 1:
88
  rgb = np.stack([data[0], data[0], data[0]])
89
  else:
@@ -93,7 +101,8 @@ def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512)
93
  lo, hi = np.percentile(rgb, (2, 98))
94
  rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
95
  img = Image.fromarray(rgb.astype("uint8"), mode="RGB")
96
- img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
 
97
  dest_path.parent.mkdir(parents=True, exist_ok=True)
98
  img.save(dest_path, format="PNG")
99
  return
 
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:
 
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
app/dda/library_routes.py CHANGED
@@ -17,14 +17,15 @@ from .config import (
17
  ALLOWED_EXTENSIONS,
18
  IS_DDA_MODE,
19
  LIBRARY_DIR,
20
- MAX_GEOTIFF_BYTES,
21
  PREVIEWS_DIR,
22
  THUMBS_DIR,
23
  ensure_library_dirs,
24
  geotiff_io_available,
 
25
  )
26
  from .geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png
27
  from .models import DdaVillage, DdaZone, ImageAsset
 
28
 
29
  logger = logging.getLogger(__name__)
30
  router = APIRouter()
@@ -64,9 +65,14 @@ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "
64
  @router.get("/config")
65
  def dda_config():
66
  _require_dda()
 
 
67
  return {
68
  "mode": "dda",
69
  "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
 
 
 
70
  "geotiffEnabled": geotiff_io_available(),
71
  "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
72
  "hierarchyMode": "admin",
@@ -201,15 +207,6 @@ async def upload_image(
201
  detail=f"Unsupported format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
202
  )
203
 
204
- raw = await file.read()
205
- if not raw:
206
- raise HTTPException(status_code=400, detail="File is empty")
207
- if len(raw) > MAX_GEOTIFF_BYTES:
208
- raise HTTPException(
209
- status_code=400,
210
- detail=f"File too large (max {MAX_GEOTIFF_BYTES // (1024 * 1024)} MB)",
211
- )
212
-
213
  try:
214
  cap_date = date.fromisoformat(capture_date.strip())
215
  except ValueError:
@@ -221,8 +218,8 @@ async def upload_image(
221
  asset_uuid = uuid.uuid4().hex
222
  stored_name = f"{asset_uuid}{ext}"
223
  dest_file = LIBRARY_DIR / str(year) / stored_name
224
- dest_file.parent.mkdir(parents=True, exist_ok=True)
225
- dest_file.write_bytes(raw)
226
 
227
  ingest = inspect_image(dest_file)
228
  has_georef = ingest.has_georef
@@ -277,7 +274,7 @@ async def upload_image(
277
  manual_location_json=manual_location,
278
  width=ingest.width,
279
  height=ingest.height,
280
- file_size_bytes=len(raw),
281
  uploaded_by=user.id,
282
  )
283
  db.add(asset)
 
17
  ALLOWED_EXTENSIONS,
18
  IS_DDA_MODE,
19
  LIBRARY_DIR,
 
20
  PREVIEWS_DIR,
21
  THUMBS_DIR,
22
  ensure_library_dirs,
23
  geotiff_io_available,
24
+ max_upload_bytes_for_extension,
25
  )
26
  from .geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png
27
  from .models import DdaVillage, DdaZone, ImageAsset
28
+ from .upload_io import stream_upload_to_file
29
 
30
  logger = logging.getLogger(__name__)
31
  router = APIRouter()
 
65
  @router.get("/config")
66
  def dda_config():
67
  _require_dda()
68
+ from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES
69
+ max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
70
  return {
71
  "mode": "dda",
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),
78
  "hierarchyMode": "admin",
 
207
  detail=f"Unsupported format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
208
  )
209
 
 
 
 
 
 
 
 
 
 
210
  try:
211
  cap_date = date.fromisoformat(capture_date.strip())
212
  except ValueError:
 
218
  asset_uuid = uuid.uuid4().hex
219
  stored_name = f"{asset_uuid}{ext}"
220
  dest_file = LIBRARY_DIR / str(year) / stored_name
221
+ max_bytes = max_upload_bytes_for_extension(ext)
222
+ file_size = await stream_upload_to_file(file, dest_file, max_bytes)
223
 
224
  ingest = inspect_image(dest_file)
225
  has_georef = ingest.has_georef
 
274
  manual_location_json=manual_location,
275
  width=ingest.width,
276
  height=ingest.height,
277
+ file_size_bytes=file_size,
278
  uploaded_by=user.id,
279
  )
280
  db.add(asset)
app/dda/upload_io.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stream large uploads to disk without loading into memory."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from fastapi import HTTPException, UploadFile
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB
12
+
13
+
14
+ async def stream_upload_to_file(
15
+ upload: UploadFile,
16
+ dest_path: Path,
17
+ max_bytes: int,
18
+ ) -> int:
19
+ """
20
+ Write upload body to dest_path in chunks. Returns total bytes written.
21
+ Removes partial file on failure or oversize.
22
+ """
23
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
24
+ total = 0
25
+ try:
26
+ with dest_path.open("wb") as out:
27
+ while True:
28
+ chunk = await upload.read(CHUNK_SIZE)
29
+ if not chunk:
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:
39
+ dest_path.unlink(missing_ok=True)
40
+ raise
41
+ except Exception as exc:
42
+ dest_path.unlink(missing_ok=True)
43
+ logger.exception("Upload stream failed")
44
+ raise HTTPException(status_code=500, detail=f"Upload failed: {exc}") from exc
45
+
46
+ if total == 0:
47
+ dest_path.unlink(missing_ok=True)
48
+ raise HTTPException(status_code=400, detail="File is empty")
49
+ return total
static/css/dda.css CHANGED
@@ -137,3 +137,19 @@
137
  .dda-slot-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
138
 
139
  .dda-upload-form .location-row { margin-bottom: 0.5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  .dda-slot-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
138
 
139
  .dda-upload-form .location-row { margin-bottom: 0.5rem; }
140
+
141
+ .dda-upload-progress { margin: 0.75rem 0 1rem; }
142
+ .dda-progress-bar {
143
+ height: 8px;
144
+ background: var(--bg);
145
+ border-radius: 4px;
146
+ overflow: hidden;
147
+ border: 1px solid var(--border);
148
+ margin-bottom: 0.35rem;
149
+ }
150
+ .dda-progress-fill {
151
+ height: 100%;
152
+ width: 0%;
153
+ background: linear-gradient(90deg, var(--grad-start), var(--grad-end, #10b981));
154
+ transition: width 0.15s ease;
155
+ }
static/js/dda/app.js CHANGED
@@ -63,7 +63,9 @@ async function initDda() {
63
  ddaConfig = await ddaApi('GET', '/api/dda/config');
64
  const hint = document.getElementById('lib-config-hint');
65
  if (hint) {
66
- hint.textContent = `Max ${ddaConfig.maxUploadMb} MB · GeoTIFF: ${ddaConfig.geotiffEnabled ? 'yes' : 'limited'}`;
 
 
67
  }
68
  ddaHierarchy = await ddaApi('GET', '/api/dda/hierarchy');
69
  if (typeof renderHierarchy === 'function') renderHierarchy(ddaHierarchy);
 
63
  ddaConfig = await ddaApi('GET', '/api/dda/config');
64
  const hint = document.getElementById('lib-config-hint');
65
  if (hint) {
66
+ const gb = ddaConfig.maxUploadGb;
67
+ const label = gb >= 1 ? `${gb} GB GeoTIFF` : `${ddaConfig.maxGeotiffMb || ddaConfig.maxUploadMb} MB`;
68
+ hint.textContent = `Max ${label} · GeoTIFF engine: ${ddaConfig.geotiffEnabled ? 'yes' : 'limited'}`;
69
  }
70
  ddaHierarchy = await ddaApi('GET', '/api/dda/hierarchy');
71
  if (typeof renderHierarchy === 'function') renderHierarchy(ddaHierarchy);
static/js/dda/library.js CHANGED
@@ -79,6 +79,32 @@ function populateUploadSelects(data) {
79
  }
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  document.getElementById('form-upload')?.addEventListener('submit', async (e) => {
83
  e.preventDefault();
84
  hideDdaError?.();
@@ -100,14 +126,29 @@ document.getElementById('form-upload')?.addEventListener('submit', async (e) =>
100
  form.append('manual_bounds_json', document.getElementById('up-manual-bounds').value || '');
101
 
102
  const btn = document.getElementById('btn-upload');
 
 
 
 
103
  btn.disabled = true;
104
  btn.textContent = 'Uploading…';
 
 
 
 
105
  try {
106
- const data = await ddaApi('POST', '/api/dda/images/upload', { body: form });
 
 
 
 
 
107
  showDdaSuccess?.(data?.status === 'success' ? 'Image uploaded to library.' : 'Upload complete.');
108
  document.getElementById('form-upload').reset();
109
  document.getElementById('up-year').value = '2025';
110
  fileInput.value = '';
 
 
111
  window.ddaState.hierarchy = await ddaApi('GET', '/api/dda/hierarchy');
112
  renderHierarchy(window.ddaState.hierarchy);
113
  populateUploadSelects(window.ddaState.hierarchy);
@@ -117,6 +158,7 @@ document.getElementById('form-upload')?.addEventListener('submit', async (e) =>
117
  } finally {
118
  btn.disabled = false;
119
  btn.textContent = 'Upload to Library';
 
120
  }
121
  });
122
 
 
79
  }
80
  }
81
 
82
+ function formatBytes(n) {
83
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
84
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
85
+ return (n / 1024).toFixed(0) + ' KB';
86
+ }
87
+
88
+ function uploadWithProgress(url, formData, onProgress) {
89
+ return new Promise((resolve, reject) => {
90
+ const xhr = new XMLHttpRequest();
91
+ xhr.open('POST', url);
92
+ xhr.withCredentials = true;
93
+ xhr.upload.addEventListener('progress', (e) => {
94
+ if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
95
+ });
96
+ xhr.addEventListener('load', () => {
97
+ let data = null;
98
+ try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (_) {}
99
+ if (xhr.status >= 200 && xhr.status < 300) resolve(data);
100
+ else reject(new Error(data?.detail || xhr.statusText || 'Upload failed'));
101
+ });
102
+ xhr.addEventListener('error', () => reject(new Error('Network error during upload')));
103
+ xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
104
+ xhr.send(formData);
105
+ });
106
+ }
107
+
108
  document.getElementById('form-upload')?.addEventListener('submit', async (e) => {
109
  e.preventDefault();
110
  hideDdaError?.();
 
126
  form.append('manual_bounds_json', document.getElementById('up-manual-bounds').value || '');
127
 
128
  const btn = document.getElementById('btn-upload');
129
+ const progWrap = document.getElementById('upload-progress');
130
+ const progFill = document.getElementById('upload-progress-fill');
131
+ const progLabel = document.getElementById('upload-progress-label');
132
+
133
  btn.disabled = true;
134
  btn.textContent = 'Uploading…';
135
+ progWrap?.classList.remove('hidden');
136
+ if (progFill) progFill.style.width = '0%';
137
+ if (progLabel) progLabel.textContent = `Uploading ${file.name} (${formatBytes(file.size)})… 0%`;
138
+
139
  try {
140
+ const data = await uploadWithProgress('/api/dda/images/upload', form, (loaded, total) => {
141
+ const pct = total ? Math.round((loaded / total) * 100) : 0;
142
+ if (progFill) progFill.style.width = pct + '%';
143
+ if (progLabel) progLabel.textContent = `Uploading… ${pct}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
144
+ });
145
+ if (progFill) progFill.style.width = '100%';
146
  showDdaSuccess?.(data?.status === 'success' ? 'Image uploaded to library.' : 'Upload complete.');
147
  document.getElementById('form-upload').reset();
148
  document.getElementById('up-year').value = '2025';
149
  fileInput.value = '';
150
+ const dateInput = document.getElementById('up-date');
151
+ if (dateInput) dateInput.value = new Date().toISOString().slice(0, 10);
152
  window.ddaState.hierarchy = await ddaApi('GET', '/api/dda/hierarchy');
153
  renderHierarchy(window.ddaState.hierarchy);
154
  populateUploadSelects(window.ddaState.hierarchy);
 
158
  } finally {
159
  btn.disabled = false;
160
  btn.textContent = 'Upload to Library';
161
+ setTimeout(() => progWrap?.classList.add('hidden'), 1500);
162
  }
163
  });
164
 
templates/index_dda.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
- <link rel="stylesheet" href="/static/css/dda.css?v=1" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
@@ -67,11 +67,15 @@
67
  <label for="up-source">Source</label>
68
  <select id="up-source"><option value="satellite">Satellite</option><option value="drone">Drone</option></select>
69
  </div>
70
- <div class="form-group">
71
- <label for="up-file">Image file</label>
72
- <input type="file" id="up-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
73
- </div>
74
  </div>
 
 
 
 
 
75
  <div class="form-group">
76
  <label for="up-manual-bounds">Manual bounds (WGS84)</label>
77
  <input type="text" id="up-manual-bounds" placeholder="west,south,east,north — required if GeoTIFF has no georef" />
@@ -119,7 +123,7 @@
119
  </section>
120
  </div>
121
 
122
- <script src="/static/js/dda/app.js?v=1"></script>
123
- <script src="/static/js/dda/library.js?v=1"></script>
124
  </body>
125
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=2" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
 
67
  <label for="up-source">Source</label>
68
  <select id="up-source"><option value="satellite">Satellite</option><option value="drone">Drone</option></select>
69
  </div>
70
+ <div class="form-group">
71
+ <label for="up-file">Image file (.tif up to 2 GB)</label>
72
+ <input type="file" id="up-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
 
73
  </div>
74
+ </div>
75
+ <div id="upload-progress" class="dda-upload-progress hidden">
76
+ <div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
77
+ <span id="upload-progress-label" class="dim">Uploading… 0%</span>
78
+ </div>
79
  <div class="form-group">
80
  <label for="up-manual-bounds">Manual bounds (WGS84)</label>
81
  <input type="text" id="up-manual-bounds" placeholder="west,south,east,north — required if GeoTIFF has no georef" />
 
123
  </section>
124
  </div>
125
 
126
+ <script src="/static/js/dda/app.js?v=2"></script>
127
+ <script src="/static/js/dda/library.js?v=2"></script>
128
  </body>
129
  </html>