Spaces:
Sleeping
Sleeping
Commit ·
c9672b1
1
Parent(s): 41d16b3
Support 2GB GeoTIFF library uploads with streamed writes and progress UI
Browse files- DEPLOYMENT.md +3 -0
- Dockerfile +2 -2
- app/dda/config.py +11 -2
- app/dda/geotiff_io.py +12 -3
- app/dda/library_routes.py +10 -13
- app/dda/upload_io.py +49 -0
- static/css/dda.css +16 -0
- static/js/dda/app.js +3 -1
- static/js/dda/library.js +43 -1
- templates/index_dda.html +11 -7
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 |
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 (
|
| 14 |
-
MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
| 225 |
-
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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=
|
| 123 |
-
<script src="/static/js/dda/library.js?v=
|
| 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>
|