Spaces:
Running
Running
modularize upload.py
Browse files- py_backend/app/main.py +19 -8
- py_backend/app/routers/images_files.py +196 -0
- py_backend/app/routers/images_listing.py +129 -0
- py_backend/app/routers/images_metadata.py +150 -0
- py_backend/app/routers/images_upload.py +135 -0
- py_backend/app/services/upload_service.py +293 -0
- py_backend/app/utils/__init__.py +1 -0
- py_backend/app/utils/image_utils.py +109 -0
py_backend/app/main.py
CHANGED
|
@@ -30,6 +30,11 @@ from app.routers.images import router as images_router
|
|
| 30 |
from app.routers.prompts import router as prompts_router
|
| 31 |
from app.routers.admin import router as admin_router
|
| 32 |
from app.routers.schemas import router as schemas_router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
app = FastAPI(
|
| 35 |
title="PromptAid Vision",
|
|
@@ -95,14 +100,20 @@ app.add_middleware(
|
|
| 95 |
# --------------------------------------------------------------------
|
| 96 |
# API Routers
|
| 97 |
# --------------------------------------------------------------------
|
| 98 |
-
app.include_router(caption.router,
|
| 99 |
-
app.include_router(metadata.router,
|
| 100 |
-
app.include_router(models.router,
|
| 101 |
-
|
| 102 |
-
app.include_router(
|
| 103 |
-
|
| 104 |
-
app.include_router(
|
| 105 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
# Handle /api/images and /api/prompts without trailing slash (avoid 307)
|
| 108 |
@app.get("/api/images", include_in_schema=False)
|
|
|
|
| 30 |
from app.routers.prompts import router as prompts_router
|
| 31 |
from app.routers.admin import router as admin_router
|
| 32 |
from app.routers.schemas import router as schemas_router
|
| 33 |
+
# New modular image routers
|
| 34 |
+
from app.routers.images_listing import router as images_listing_router
|
| 35 |
+
from app.routers.images_metadata import router as images_metadata_router
|
| 36 |
+
from app.routers.images_files import router as images_files_router
|
| 37 |
+
from app.routers.images_upload import router as images_upload_router
|
| 38 |
|
| 39 |
app = FastAPI(
|
| 40 |
title="PromptAid Vision",
|
|
|
|
| 100 |
# --------------------------------------------------------------------
|
| 101 |
# API Routers
|
| 102 |
# --------------------------------------------------------------------
|
| 103 |
+
app.include_router(caption.router, prefix="/api", tags=["captions"])
|
| 104 |
+
app.include_router(metadata.router, prefix="/api", tags=["metadata"])
|
| 105 |
+
app.include_router(models.router, prefix="/api", tags=["models"])
|
| 106 |
+
# Legacy upload router (to be deprecated)
|
| 107 |
+
app.include_router(upload.router, prefix="/api/images", tags=["images-legacy"])
|
| 108 |
+
# New modular image routers
|
| 109 |
+
app.include_router(images_listing_router, prefix="/api/images", tags=["images-listing"])
|
| 110 |
+
app.include_router(images_metadata_router, prefix="/api/images", tags=["images-metadata"])
|
| 111 |
+
app.include_router(images_files_router, prefix="/api/images", tags=["images-files"])
|
| 112 |
+
app.include_router(images_upload_router, prefix="/api/images", tags=["images-upload"])
|
| 113 |
+
app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
|
| 114 |
+
app.include_router(prompts_router, prefix="/api/prompts", tags=["prompts"])
|
| 115 |
+
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
| 116 |
+
app.include_router(schemas_router, prefix="/api", tags=["schemas"])
|
| 117 |
|
| 118 |
# Handle /api/images and /api/prompts without trailing slash (avoid 307)
|
| 119 |
@app.get("/api/images", include_in_schema=False)
|
py_backend/app/routers/images_files.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image File Operations Router
|
| 3 |
+
Handles file serving, copying, and preprocessing operations
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, Form
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
import logging
|
| 10 |
+
import io
|
| 11 |
+
import os
|
| 12 |
+
import time
|
| 13 |
+
import mimetypes
|
| 14 |
+
|
| 15 |
+
from .. import crud, schemas, database, storage
|
| 16 |
+
from ..config import settings
|
| 17 |
+
from ..services.image_preprocessor import ImagePreprocessor
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
def get_db():
|
| 23 |
+
db = database.SessionLocal()
|
| 24 |
+
try:
|
| 25 |
+
yield db
|
| 26 |
+
finally:
|
| 27 |
+
db.close()
|
| 28 |
+
|
| 29 |
+
class CopyImageRequest(BaseModel):
|
| 30 |
+
source_image_id: str
|
| 31 |
+
source: str
|
| 32 |
+
event_type: str
|
| 33 |
+
countries: str = ""
|
| 34 |
+
epsg: str = ""
|
| 35 |
+
image_type: str = "crisis_map"
|
| 36 |
+
# Drone-specific fields (optional)
|
| 37 |
+
center_lon: Optional[float] = None
|
| 38 |
+
center_lat: Optional[float] = None
|
| 39 |
+
amsl_m: Optional[float] = None
|
| 40 |
+
agl_m: Optional[float] = None
|
| 41 |
+
heading_deg: Optional[float] = None
|
| 42 |
+
yaw_deg: Optional[float] = None
|
| 43 |
+
pitch_deg: Optional[float] = None
|
| 44 |
+
roll_deg: Optional[float] = None
|
| 45 |
+
rtk_fix: Optional[bool] = None
|
| 46 |
+
std_h_m: Optional[float] = None
|
| 47 |
+
std_v_m: Optional[float] = None
|
| 48 |
+
|
| 49 |
+
@router.get("/{image_id}/file")
|
| 50 |
+
async def get_image_file(image_id: str, db: Session = Depends(get_db)):
|
| 51 |
+
"""Serve the actual image file"""
|
| 52 |
+
logger.debug(f"Serving image file for image_id: {image_id}")
|
| 53 |
+
|
| 54 |
+
img = crud.get_image(db, image_id)
|
| 55 |
+
if not img:
|
| 56 |
+
logger.warning(f"Image not found: {image_id}")
|
| 57 |
+
raise HTTPException(404, "Image not found")
|
| 58 |
+
|
| 59 |
+
logger.debug(f"Found image: {img.image_id}, file_key: {img.file_key}")
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
if hasattr(storage, 's3') and settings.STORAGE_PROVIDER != "local":
|
| 63 |
+
logger.debug(f"Using S3 storage - serving file content directly")
|
| 64 |
+
try:
|
| 65 |
+
response = storage.s3.get_object(Bucket=settings.S3_BUCKET, Key=img.file_key)
|
| 66 |
+
content = response['Body'].read()
|
| 67 |
+
logger.debug(f"Read {len(content)} bytes from S3")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Failed to get S3 object: {e}")
|
| 70 |
+
raise HTTPException(500, f"Failed to retrieve image from storage: {e}")
|
| 71 |
+
else:
|
| 72 |
+
logger.debug(f"Using local storage")
|
| 73 |
+
file_path = os.path.join(settings.STORAGE_DIR, img.file_key)
|
| 74 |
+
logger.debug(f"Reading from: {file_path}")
|
| 75 |
+
logger.debug(f"File exists: {os.path.exists(file_path)}")
|
| 76 |
+
|
| 77 |
+
if not os.path.exists(file_path):
|
| 78 |
+
logger.error(f"File not found at: {file_path}")
|
| 79 |
+
raise FileNotFoundError(f"Image file not found: {file_path}")
|
| 80 |
+
|
| 81 |
+
with open(file_path, 'rb') as f:
|
| 82 |
+
content = f.read()
|
| 83 |
+
|
| 84 |
+
logger.debug(f"Read {len(content)} bytes from file")
|
| 85 |
+
|
| 86 |
+
content_type, _ = mimetypes.guess_type(img.file_key)
|
| 87 |
+
if not content_type:
|
| 88 |
+
content_type = 'application/octet-stream'
|
| 89 |
+
|
| 90 |
+
logger.debug(f"Serving image with content-type: {content_type}, size: {len(content)} bytes")
|
| 91 |
+
return Response(content=content, media_type=content_type)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error serving image: {e}")
|
| 94 |
+
import traceback
|
| 95 |
+
logger.debug(f"Full traceback: {traceback.format_exc()}")
|
| 96 |
+
raise HTTPException(500, f"Failed to serve image file: {e}")
|
| 97 |
+
|
| 98 |
+
@router.post("/copy", response_model=schemas.ImageOut)
|
| 99 |
+
async def copy_image_for_contribution(
|
| 100 |
+
request: CopyImageRequest,
|
| 101 |
+
db: Session = Depends(get_db)
|
| 102 |
+
):
|
| 103 |
+
"""Copy an existing image for contribution purposes, creating a new image_id"""
|
| 104 |
+
logger.info(f"Copying image {request.source_image_id} for contribution")
|
| 105 |
+
|
| 106 |
+
source_img = crud.get_image(db, request.source_image_id)
|
| 107 |
+
if not source_img:
|
| 108 |
+
logger.warning(f"Source image not found: {request.source_image_id}")
|
| 109 |
+
raise HTTPException(404, "Source image not found")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
# Get image content from storage
|
| 113 |
+
if hasattr(storage, 's3') and settings.STORAGE_PROVIDER != "local":
|
| 114 |
+
response = storage.s3.get_object(
|
| 115 |
+
Bucket=settings.S3_BUCKET,
|
| 116 |
+
Key=source_img.file_key,
|
| 117 |
+
)
|
| 118 |
+
image_content = response["Body"].read()
|
| 119 |
+
else:
|
| 120 |
+
file_path = os.path.join(settings.STORAGE_DIR, source_img.file_key)
|
| 121 |
+
with open(file_path, 'rb') as f:
|
| 122 |
+
image_content = f.read()
|
| 123 |
+
|
| 124 |
+
# Create new file with unique name
|
| 125 |
+
new_filename = f"contribution_{request.source_image_id}_{int(time.time())}.jpg"
|
| 126 |
+
new_key = storage.upload_fileobj(io.BytesIO(image_content), new_filename)
|
| 127 |
+
|
| 128 |
+
# Parse countries
|
| 129 |
+
countries_list = [c.strip() for c in request.countries.split(',') if c.strip()] if request.countries else []
|
| 130 |
+
|
| 131 |
+
# Create new image record
|
| 132 |
+
new_img = crud.create_image(
|
| 133 |
+
db,
|
| 134 |
+
request.source,
|
| 135 |
+
request.event_type,
|
| 136 |
+
new_key,
|
| 137 |
+
source_img.sha256,
|
| 138 |
+
countries_list,
|
| 139 |
+
request.epsg,
|
| 140 |
+
request.image_type,
|
| 141 |
+
request.center_lon, request.center_lat, request.amsl_m, request.agl_m,
|
| 142 |
+
request.heading_deg, request.yaw_deg, request.pitch_deg, request.roll_deg,
|
| 143 |
+
request.rtk_fix, request.std_h_m, request.std_v_m
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Generate URL
|
| 147 |
+
try:
|
| 148 |
+
url = storage.get_object_url(new_key)
|
| 149 |
+
except Exception as e:
|
| 150 |
+
url = f"/api/images/{new_img.image_id}/file"
|
| 151 |
+
|
| 152 |
+
# Convert to response format
|
| 153 |
+
from ..utils.image_utils import convert_image_to_dict
|
| 154 |
+
img_dict = convert_image_to_dict(new_img, url)
|
| 155 |
+
result = schemas.ImageOut(**img_dict)
|
| 156 |
+
|
| 157 |
+
logger.info(f"Successfully copied image {request.source_image_id} -> {new_img.image_id}")
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"Failed to copy image: {str(e)}")
|
| 162 |
+
raise HTTPException(500, f"Failed to copy image: {str(e)}")
|
| 163 |
+
|
| 164 |
+
@router.post("/preprocess")
|
| 165 |
+
async def preprocess_image(
|
| 166 |
+
file: UploadFile = Form(...),
|
| 167 |
+
db: Session = Depends(get_db)
|
| 168 |
+
):
|
| 169 |
+
"""Preprocess an image file (convert format, optimize, etc.)"""
|
| 170 |
+
logger.info(f"Preprocessing image: {file.filename}")
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
content = await file.read()
|
| 174 |
+
|
| 175 |
+
# Preprocess the image
|
| 176 |
+
processed_content, processed_filename, mime_type = ImagePreprocessor.preprocess_image(
|
| 177 |
+
content,
|
| 178 |
+
file.filename,
|
| 179 |
+
target_format='PNG',
|
| 180 |
+
quality=95
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
logger.info(f"Image preprocessed: {file.filename} -> {processed_filename}")
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"original_filename": file.filename,
|
| 187 |
+
"processed_filename": processed_filename,
|
| 188 |
+
"original_size": len(content),
|
| 189 |
+
"processed_size": len(processed_content),
|
| 190 |
+
"mime_type": mime_type,
|
| 191 |
+
"preprocessing_applied": processed_filename != file.filename
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"Image preprocessing failed: {str(e)}")
|
| 196 |
+
raise HTTPException(500, f"Image preprocessing failed: {str(e)}")
|
py_backend/app/routers/images_listing.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Listing Router
|
| 3 |
+
Handles listing, pagination, and filtering of images
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, Query
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from .. import crud, schemas, database
|
| 11 |
+
from ..utils.image_utils import convert_image_to_dict
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
def get_db():
|
| 17 |
+
db = database.SessionLocal()
|
| 18 |
+
try:
|
| 19 |
+
yield db
|
| 20 |
+
finally:
|
| 21 |
+
db.close()
|
| 22 |
+
|
| 23 |
+
@router.get("/", response_model=List[schemas.ImageOut])
|
| 24 |
+
def list_images(db: Session = Depends(get_db)):
|
| 25 |
+
"""Get all images with their caption data"""
|
| 26 |
+
logger.debug("Listing all images")
|
| 27 |
+
images = crud.get_images(db)
|
| 28 |
+
result = []
|
| 29 |
+
for img in images:
|
| 30 |
+
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
| 31 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 32 |
+
|
| 33 |
+
logger.info(f"Returned {len(result)} images")
|
| 34 |
+
return result
|
| 35 |
+
|
| 36 |
+
@router.get("/grouped", response_model=List[schemas.ImageOut])
|
| 37 |
+
def list_images_grouped(
|
| 38 |
+
page: int = Query(1, ge=1),
|
| 39 |
+
limit: int = Query(10, ge=1, le=100),
|
| 40 |
+
search: str = Query(None),
|
| 41 |
+
source: str = Query(None),
|
| 42 |
+
event_type: str = Query(None),
|
| 43 |
+
region: str = Query(None),
|
| 44 |
+
country: str = Query(None),
|
| 45 |
+
image_type: str = Query(None),
|
| 46 |
+
db: Session = Depends(get_db)
|
| 47 |
+
):
|
| 48 |
+
"""Get paginated and filtered images"""
|
| 49 |
+
logger.debug(f"Listing grouped images - page: {page}, limit: {limit}")
|
| 50 |
+
|
| 51 |
+
# Build filter parameters
|
| 52 |
+
filters = {}
|
| 53 |
+
if search:
|
| 54 |
+
filters['search'] = search
|
| 55 |
+
if source:
|
| 56 |
+
filters['source'] = source
|
| 57 |
+
if event_type:
|
| 58 |
+
filters['event_type'] = event_type
|
| 59 |
+
if region:
|
| 60 |
+
filters['region'] = region
|
| 61 |
+
if country:
|
| 62 |
+
filters['country'] = country
|
| 63 |
+
if image_type:
|
| 64 |
+
filters['image_type'] = image_type
|
| 65 |
+
|
| 66 |
+
logger.debug(f"Applied filters: {filters}")
|
| 67 |
+
|
| 68 |
+
# Get paginated results
|
| 69 |
+
images = crud.get_images_paginated(
|
| 70 |
+
db,
|
| 71 |
+
page=page,
|
| 72 |
+
limit=limit,
|
| 73 |
+
**filters
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
result = []
|
| 77 |
+
for img in images:
|
| 78 |
+
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
| 79 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 80 |
+
|
| 81 |
+
logger.info(f"Returned {len(result)} images for page {page}")
|
| 82 |
+
return result
|
| 83 |
+
|
| 84 |
+
@router.get("/grouped/count")
|
| 85 |
+
def get_images_grouped_count(
|
| 86 |
+
search: str = Query(None),
|
| 87 |
+
source: str = Query(None),
|
| 88 |
+
event_type: str = Query(None),
|
| 89 |
+
region: str = Query(None),
|
| 90 |
+
country: str = Query(None),
|
| 91 |
+
image_type: str = Query(None),
|
| 92 |
+
db: Session = Depends(get_db)
|
| 93 |
+
):
|
| 94 |
+
"""Get total count of images matching filters"""
|
| 95 |
+
logger.debug("Getting images count")
|
| 96 |
+
|
| 97 |
+
# Build filter parameters
|
| 98 |
+
filters = {}
|
| 99 |
+
if search:
|
| 100 |
+
filters['search'] = search
|
| 101 |
+
if source:
|
| 102 |
+
filters['source'] = source
|
| 103 |
+
if event_type:
|
| 104 |
+
filters['event_type'] = event_type
|
| 105 |
+
if region:
|
| 106 |
+
filters['region'] = region
|
| 107 |
+
if country:
|
| 108 |
+
filters['country'] = country
|
| 109 |
+
if image_type:
|
| 110 |
+
filters['image_type'] = image_type
|
| 111 |
+
|
| 112 |
+
count = crud.get_images_count(db, **filters)
|
| 113 |
+
logger.info(f"Total images count: {count}")
|
| 114 |
+
return {"count": count}
|
| 115 |
+
|
| 116 |
+
@router.get("/{image_id}", response_model=schemas.ImageOut)
|
| 117 |
+
def get_image(image_id: str, db: Session = Depends(get_db)):
|
| 118 |
+
"""Get a specific image by ID"""
|
| 119 |
+
logger.debug(f"Getting image: {image_id}")
|
| 120 |
+
|
| 121 |
+
img = crud.get_image(db, image_id)
|
| 122 |
+
if not img:
|
| 123 |
+
logger.warning(f"Image not found: {image_id}")
|
| 124 |
+
from fastapi import HTTPException
|
| 125 |
+
raise HTTPException(404, "Image not found")
|
| 126 |
+
|
| 127 |
+
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
| 128 |
+
logger.info(f"Returned image: {image_id}")
|
| 129 |
+
return schemas.ImageOut(**img_dict)
|
py_backend/app/routers/images_metadata.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Metadata Router
|
| 3 |
+
Handles metadata updates and image deletion
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
import logging
|
| 8 |
+
import datetime
|
| 9 |
+
|
| 10 |
+
from .. import crud, schemas, database, storage
|
| 11 |
+
from ..utils.image_utils import convert_image_to_dict
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
def get_db():
|
| 17 |
+
db = database.SessionLocal()
|
| 18 |
+
try:
|
| 19 |
+
yield db
|
| 20 |
+
finally:
|
| 21 |
+
db.close()
|
| 22 |
+
|
| 23 |
+
@router.put("/{image_id}")
|
| 24 |
+
def update_image_metadata(
|
| 25 |
+
image_id: str,
|
| 26 |
+
metadata: schemas.ImageMetadataUpdate,
|
| 27 |
+
db: Session = Depends(get_db)
|
| 28 |
+
):
|
| 29 |
+
"""Update image metadata (source, type, epsg, image_type, countries)"""
|
| 30 |
+
logger.debug(f"Updating metadata for image {image_id}")
|
| 31 |
+
logger.debug(f"Metadata received: {metadata}")
|
| 32 |
+
|
| 33 |
+
img = crud.get_image(db, image_id)
|
| 34 |
+
if not img:
|
| 35 |
+
logger.warning(f"Image {image_id} not found in database")
|
| 36 |
+
raise HTTPException(404, "Image not found")
|
| 37 |
+
|
| 38 |
+
logger.debug(f"Found image {image_id} in database")
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
# Update basic fields
|
| 42 |
+
if metadata.source is not None:
|
| 43 |
+
img.source = metadata.source
|
| 44 |
+
if metadata.event_type is not None:
|
| 45 |
+
img.event_type = metadata.event_type
|
| 46 |
+
if metadata.epsg is not None:
|
| 47 |
+
img.epsg = metadata.epsg
|
| 48 |
+
if metadata.image_type is not None:
|
| 49 |
+
img.image_type = metadata.image_type
|
| 50 |
+
|
| 51 |
+
# Handle starred field - update the first caption's starred status
|
| 52 |
+
if metadata.starred is not None:
|
| 53 |
+
if img.captions:
|
| 54 |
+
# Update the first caption's starred status
|
| 55 |
+
img.captions[0].starred = metadata.starred
|
| 56 |
+
else:
|
| 57 |
+
# If no captions exist, create a minimal caption with starred status
|
| 58 |
+
from .. import models
|
| 59 |
+
caption = models.Captions(
|
| 60 |
+
title="",
|
| 61 |
+
starred=metadata.starred,
|
| 62 |
+
created_at=datetime.datetime.utcnow()
|
| 63 |
+
)
|
| 64 |
+
db.add(caption)
|
| 65 |
+
img.captions.append(caption)
|
| 66 |
+
|
| 67 |
+
# Update drone-specific fields
|
| 68 |
+
if metadata.center_lon is not None:
|
| 69 |
+
img.center_lon = metadata.center_lon
|
| 70 |
+
if metadata.center_lat is not None:
|
| 71 |
+
img.center_lat = metadata.center_lat
|
| 72 |
+
if metadata.amsl_m is not None:
|
| 73 |
+
img.amsl_m = metadata.amsl_m
|
| 74 |
+
if metadata.agl_m is not None:
|
| 75 |
+
img.agl_m = metadata.agl_m
|
| 76 |
+
if metadata.heading_deg is not None:
|
| 77 |
+
img.heading_deg = metadata.heading_deg
|
| 78 |
+
if metadata.yaw_deg is not None:
|
| 79 |
+
img.yaw_deg = metadata.yaw_deg
|
| 80 |
+
if metadata.pitch_deg is not None:
|
| 81 |
+
img.pitch_deg = metadata.pitch_deg
|
| 82 |
+
if metadata.roll_deg is not None:
|
| 83 |
+
img.roll_deg = metadata.roll_deg
|
| 84 |
+
if metadata.rtk_fix is not None:
|
| 85 |
+
img.rtk_fix = metadata.rtk_fix
|
| 86 |
+
if metadata.std_h_m is not None:
|
| 87 |
+
img.std_h_m = metadata.std_h_m
|
| 88 |
+
if metadata.std_v_m is not None:
|
| 89 |
+
img.std_v_m = metadata.std_v_m
|
| 90 |
+
|
| 91 |
+
# Update countries
|
| 92 |
+
if metadata.countries is not None:
|
| 93 |
+
logger.debug(f"Updating countries to: {metadata.countries}")
|
| 94 |
+
img.countries.clear()
|
| 95 |
+
for country_code in metadata.countries:
|
| 96 |
+
country = crud.get_country(db, country_code)
|
| 97 |
+
if country:
|
| 98 |
+
img.countries.append(country)
|
| 99 |
+
logger.debug(f"Added country: {country_code}")
|
| 100 |
+
|
| 101 |
+
db.commit()
|
| 102 |
+
db.refresh(img)
|
| 103 |
+
logger.info(f"Metadata update successful for image {image_id}")
|
| 104 |
+
|
| 105 |
+
# Generate image URL
|
| 106 |
+
try:
|
| 107 |
+
url = storage.get_object_url(img.file_key)
|
| 108 |
+
except Exception:
|
| 109 |
+
url = f"/api/images/{img.image_id}/file"
|
| 110 |
+
|
| 111 |
+
img_dict = convert_image_to_dict(img, url)
|
| 112 |
+
return schemas.ImageOut(**img_dict)
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
db.rollback()
|
| 116 |
+
logger.error(f"Metadata update failed for image {image_id}: {str(e)}")
|
| 117 |
+
raise HTTPException(500, f"Failed to update image metadata: {str(e)}")
|
| 118 |
+
|
| 119 |
+
@router.delete("/{image_id}")
|
| 120 |
+
def delete_image(image_id: str, db: Session = Depends(get_db), content_management: bool = False):
|
| 121 |
+
"""Delete an image and its associated caption data
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
image_id: The ID of the image to delete
|
| 125 |
+
content_management: If True, this is a content management delete (from map details)
|
| 126 |
+
"""
|
| 127 |
+
logger.info(f"Deleting image {image_id} (content_management={content_management})")
|
| 128 |
+
|
| 129 |
+
img = crud.get_image(db, image_id)
|
| 130 |
+
if not img:
|
| 131 |
+
logger.warning(f"Image {image_id} not found")
|
| 132 |
+
raise HTTPException(404, "Image not found")
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
# Delete associated captions first
|
| 136 |
+
for caption in img.captions:
|
| 137 |
+
db.delete(caption)
|
| 138 |
+
logger.debug(f"Deleted caption {caption.caption_id}")
|
| 139 |
+
|
| 140 |
+
# Delete the image
|
| 141 |
+
db.delete(img)
|
| 142 |
+
db.commit()
|
| 143 |
+
|
| 144 |
+
logger.info(f"Successfully deleted image {image_id}")
|
| 145 |
+
return {"message": "Image deleted successfully", "image_id": image_id}
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
db.rollback()
|
| 149 |
+
logger.error(f"Failed to delete image {image_id}: {str(e)}")
|
| 150 |
+
raise HTTPException(500, f"Failed to delete image: {str(e)}")
|
py_backend/app/routers/images_upload.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified Upload Router
|
| 3 |
+
Handles only the core upload endpoints, delegating to service layer
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, UploadFile, Form, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from .. import schemas, database
|
| 11 |
+
from ..services.upload_service import UploadService
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
def get_db():
|
| 17 |
+
db = database.SessionLocal()
|
| 18 |
+
try:
|
| 19 |
+
yield db
|
| 20 |
+
finally:
|
| 21 |
+
db.close()
|
| 22 |
+
|
| 23 |
+
@router.post("/", response_model=schemas.ImageOut)
|
| 24 |
+
async def upload_image(
|
| 25 |
+
source: Optional[str] = Form(default=None),
|
| 26 |
+
event_type: str = Form(default="OTHER"),
|
| 27 |
+
countries: str = Form(default=""),
|
| 28 |
+
epsg: str = Form(default=""),
|
| 29 |
+
image_type: str = Form(default="crisis_map"),
|
| 30 |
+
file: UploadFile = Form(...),
|
| 31 |
+
title: str = Form(default=""),
|
| 32 |
+
model_name: Optional[str] = Form(default=None),
|
| 33 |
+
# Drone-specific fields (optional)
|
| 34 |
+
center_lon: Optional[float] = Form(default=None),
|
| 35 |
+
center_lat: Optional[float] = Form(default=None),
|
| 36 |
+
amsl_m: Optional[float] = Form(default=None),
|
| 37 |
+
agl_m: Optional[float] = Form(default=None),
|
| 38 |
+
heading_deg: Optional[float] = Form(default=None),
|
| 39 |
+
yaw_deg: Optional[float] = Form(default=None),
|
| 40 |
+
pitch_deg: Optional[float] = Form(default=None),
|
| 41 |
+
roll_deg: Optional[float] = Form(default=None),
|
| 42 |
+
rtk_fix: Optional[bool] = Form(default=None),
|
| 43 |
+
std_h_m: Optional[float] = Form(default=None),
|
| 44 |
+
std_v_m: Optional[float] = Form(default=None),
|
| 45 |
+
db: Session = Depends(get_db)
|
| 46 |
+
):
|
| 47 |
+
"""Upload a single image"""
|
| 48 |
+
logger.info(f"Single image upload request: {file.filename}")
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
result = await UploadService.process_single_upload(
|
| 52 |
+
file=file,
|
| 53 |
+
source=source,
|
| 54 |
+
event_type=event_type,
|
| 55 |
+
countries=countries,
|
| 56 |
+
epsg=epsg,
|
| 57 |
+
image_type=image_type,
|
| 58 |
+
title=title,
|
| 59 |
+
model_name=model_name,
|
| 60 |
+
center_lon=center_lon,
|
| 61 |
+
center_lat=center_lat,
|
| 62 |
+
amsl_m=amsl_m,
|
| 63 |
+
agl_m=agl_m,
|
| 64 |
+
heading_deg=heading_deg,
|
| 65 |
+
yaw_deg=yaw_deg,
|
| 66 |
+
pitch_deg=pitch_deg,
|
| 67 |
+
roll_deg=roll_deg,
|
| 68 |
+
rtk_fix=rtk_fix,
|
| 69 |
+
std_h_m=std_h_m,
|
| 70 |
+
std_v_m=std_v_m,
|
| 71 |
+
db=db
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return result['image']
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(f"Single upload failed: {str(e)}")
|
| 78 |
+
raise HTTPException(500, f"Upload failed: {str(e)}")
|
| 79 |
+
|
| 80 |
+
@router.post("/multi", response_model=dict)
|
| 81 |
+
async def upload_multiple_images(
|
| 82 |
+
files: List[UploadFile] = Form(...),
|
| 83 |
+
source: Optional[str] = Form(default=None),
|
| 84 |
+
event_type: str = Form(default="OTHER"),
|
| 85 |
+
countries: str = Form(default=""),
|
| 86 |
+
epsg: str = Form(default=""),
|
| 87 |
+
image_type: str = Form(default="crisis_map"),
|
| 88 |
+
title: str = Form(default=""),
|
| 89 |
+
model_name: Optional[str] = Form(default=None),
|
| 90 |
+
# Drone-specific fields (optional)
|
| 91 |
+
center_lon: Optional[float] = Form(default=None),
|
| 92 |
+
center_lat: Optional[float] = Form(default=None),
|
| 93 |
+
amsl_m: Optional[float] = Form(default=None),
|
| 94 |
+
agl_m: Optional[float] = Form(default=None),
|
| 95 |
+
heading_deg: Optional[float] = Form(default=None),
|
| 96 |
+
yaw_deg: Optional[float] = Form(default=None),
|
| 97 |
+
pitch_deg: Optional[float] = Form(default=None),
|
| 98 |
+
roll_deg: Optional[float] = Form(default=None),
|
| 99 |
+
rtk_fix: Optional[bool] = Form(default=None),
|
| 100 |
+
std_h_m: Optional[float] = Form(default=None),
|
| 101 |
+
std_v_m: Optional[float] = Form(default=None),
|
| 102 |
+
db: Session = Depends(get_db)
|
| 103 |
+
):
|
| 104 |
+
"""Upload multiple images"""
|
| 105 |
+
logger.info(f"Multi image upload request: {len(files)} files")
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
result = await UploadService.process_multi_upload(
|
| 109 |
+
files=files,
|
| 110 |
+
source=source,
|
| 111 |
+
event_type=event_type,
|
| 112 |
+
countries=countries,
|
| 113 |
+
epsg=epsg,
|
| 114 |
+
image_type=image_type,
|
| 115 |
+
title=title,
|
| 116 |
+
model_name=model_name,
|
| 117 |
+
center_lon=center_lon,
|
| 118 |
+
center_lat=center_lat,
|
| 119 |
+
amsl_m=amsl_m,
|
| 120 |
+
agl_m=agl_m,
|
| 121 |
+
heading_deg=heading_deg,
|
| 122 |
+
yaw_deg=yaw_deg,
|
| 123 |
+
pitch_deg=pitch_deg,
|
| 124 |
+
roll_deg=roll_deg,
|
| 125 |
+
rtk_fix=rtk_fix,
|
| 126 |
+
std_h_m=std_h_m,
|
| 127 |
+
std_v_m=std_v_m,
|
| 128 |
+
db=db
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return result
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Multi upload failed: {str(e)}")
|
| 135 |
+
raise HTTPException(500, f"Multi upload failed: {str(e)}")
|
py_backend/app/services/upload_service.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload Service
|
| 3 |
+
Handles the core business logic for image uploads and processing
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import io
|
| 7 |
+
from typing import Optional, Dict, Any, Tuple
|
| 8 |
+
from fastapi import UploadFile
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from .. import crud, schemas, storage
|
| 12 |
+
from ..services.image_preprocessor import ImagePreprocessor
|
| 13 |
+
from ..services.thumbnail_service import ImageProcessingService
|
| 14 |
+
from ..services.vlm_service import vlm_manager
|
| 15 |
+
from ..utils.image_utils import convert_image_to_dict
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
class UploadService:
|
| 20 |
+
"""Service for handling image upload operations"""
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
async def process_single_upload(
|
| 24 |
+
file: UploadFile,
|
| 25 |
+
source: Optional[str],
|
| 26 |
+
event_type: str,
|
| 27 |
+
countries: str,
|
| 28 |
+
epsg: str,
|
| 29 |
+
image_type: str,
|
| 30 |
+
title: str,
|
| 31 |
+
model_name: Optional[str],
|
| 32 |
+
# Drone-specific fields
|
| 33 |
+
center_lon: Optional[float] = None,
|
| 34 |
+
center_lat: Optional[float] = None,
|
| 35 |
+
amsl_m: Optional[float] = None,
|
| 36 |
+
agl_m: Optional[float] = None,
|
| 37 |
+
heading_deg: Optional[float] = None,
|
| 38 |
+
yaw_deg: Optional[float] = None,
|
| 39 |
+
pitch_deg: Optional[float] = None,
|
| 40 |
+
roll_deg: Optional[float] = None,
|
| 41 |
+
rtk_fix: Optional[bool] = None,
|
| 42 |
+
std_h_m: Optional[float] = None,
|
| 43 |
+
std_v_m: Optional[float] = None,
|
| 44 |
+
db: Session = None
|
| 45 |
+
) -> Dict[str, Any]:
|
| 46 |
+
"""Process a single image upload"""
|
| 47 |
+
logger.info(f"Processing single upload: {file.filename}")
|
| 48 |
+
|
| 49 |
+
# Parse and validate input
|
| 50 |
+
countries_list = [c.strip() for c in countries.split(',') if c.strip()] if countries else []
|
| 51 |
+
|
| 52 |
+
# Set defaults based on image type
|
| 53 |
+
if image_type == "drone_image":
|
| 54 |
+
if not event_type or event_type.strip() == "":
|
| 55 |
+
event_type = "OTHER"
|
| 56 |
+
if not epsg or epsg.strip() == "":
|
| 57 |
+
epsg = "OTHER"
|
| 58 |
+
else:
|
| 59 |
+
if not source or source.strip() == "":
|
| 60 |
+
source = "OTHER"
|
| 61 |
+
if not event_type or event_type.strip() == "":
|
| 62 |
+
event_type = "OTHER"
|
| 63 |
+
if not epsg or epsg.strip() == "":
|
| 64 |
+
epsg = "OTHER"
|
| 65 |
+
|
| 66 |
+
# Clear drone fields for non-drone images
|
| 67 |
+
center_lon = center_lat = amsl_m = agl_m = None
|
| 68 |
+
heading_deg = yaw_deg = pitch_deg = roll_deg = None
|
| 69 |
+
rtk_fix = std_h_m = std_v_m = None
|
| 70 |
+
|
| 71 |
+
if not image_type or image_type.strip() == "":
|
| 72 |
+
image_type = "crisis_map"
|
| 73 |
+
|
| 74 |
+
# Read file content
|
| 75 |
+
content = await file.read()
|
| 76 |
+
|
| 77 |
+
# Preprocess image
|
| 78 |
+
preprocessing_info = await UploadService._preprocess_image(content, file.filename)
|
| 79 |
+
|
| 80 |
+
# Upload to storage
|
| 81 |
+
key, sha = await UploadService._upload_to_storage(
|
| 82 |
+
preprocessing_info['processed_content'],
|
| 83 |
+
preprocessing_info['processed_filename'],
|
| 84 |
+
preprocessing_info['mime_type']
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Generate thumbnails and detail versions
|
| 88 |
+
thumbnail_result, detail_result = await UploadService._generate_image_versions(
|
| 89 |
+
preprocessing_info['processed_content'],
|
| 90 |
+
preprocessing_info['processed_filename']
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Create database record
|
| 94 |
+
img = crud.create_image(
|
| 95 |
+
db, source, event_type, key, sha, countries_list, epsg, image_type,
|
| 96 |
+
center_lon, center_lat, amsl_m, agl_m,
|
| 97 |
+
heading_deg, yaw_deg, pitch_deg, roll_deg,
|
| 98 |
+
rtk_fix, std_h_m, std_v_m,
|
| 99 |
+
thumbnail_key=thumbnail_result[0] if thumbnail_result else None,
|
| 100 |
+
detail_key=detail_result[0] if detail_result else None
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Generate caption if requested
|
| 104 |
+
if title or model_name:
|
| 105 |
+
await UploadService._generate_caption(
|
| 106 |
+
img, preprocessing_info['processed_content'], title, model_name, db
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Generate response
|
| 110 |
+
url = storage.get_object_url(key)
|
| 111 |
+
img_dict = convert_image_to_dict(img, url)
|
| 112 |
+
img_dict['preprocessing_info'] = preprocessing_info
|
| 113 |
+
|
| 114 |
+
logger.info(f"Successfully processed upload: {img.image_id}")
|
| 115 |
+
return {
|
| 116 |
+
'image': schemas.ImageOut(**img_dict),
|
| 117 |
+
'preprocessing_info': preprocessing_info
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
async def process_multi_upload(
|
| 122 |
+
files: list[UploadFile],
|
| 123 |
+
source: Optional[str],
|
| 124 |
+
event_type: str,
|
| 125 |
+
countries: str,
|
| 126 |
+
epsg: str,
|
| 127 |
+
image_type: str,
|
| 128 |
+
title: str,
|
| 129 |
+
model_name: Optional[str],
|
| 130 |
+
# Drone-specific fields
|
| 131 |
+
center_lon: Optional[float] = None,
|
| 132 |
+
center_lat: Optional[float] = None,
|
| 133 |
+
amsl_m: Optional[float] = None,
|
| 134 |
+
agl_m: Optional[float] = None,
|
| 135 |
+
heading_deg: Optional[float] = None,
|
| 136 |
+
yaw_deg: Optional[float] = None,
|
| 137 |
+
pitch_deg: Optional[float] = None,
|
| 138 |
+
roll_deg: Optional[float] = None,
|
| 139 |
+
rtk_fix: Optional[bool] = None,
|
| 140 |
+
std_h_m: Optional[float] = None,
|
| 141 |
+
std_v_m: Optional[float] = None,
|
| 142 |
+
db: Session = None
|
| 143 |
+
) -> Dict[str, Any]:
|
| 144 |
+
"""Process multiple image uploads"""
|
| 145 |
+
logger.info(f"Processing multi upload: {len(files)} files")
|
| 146 |
+
|
| 147 |
+
results = []
|
| 148 |
+
for file in files:
|
| 149 |
+
try:
|
| 150 |
+
result = await UploadService.process_single_upload(
|
| 151 |
+
file, source, event_type, countries, epsg, image_type,
|
| 152 |
+
title, model_name, center_lon, center_lat, amsl_m, agl_m,
|
| 153 |
+
heading_deg, yaw_deg, pitch_deg, roll_deg,
|
| 154 |
+
rtk_fix, std_h_m, std_v_m, db
|
| 155 |
+
)
|
| 156 |
+
results.append(result)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Failed to process file {file.filename}: {str(e)}")
|
| 159 |
+
results.append({
|
| 160 |
+
'error': str(e),
|
| 161 |
+
'filename': file.filename
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
logger.info(f"Multi upload completed: {len(results)} results")
|
| 165 |
+
return {
|
| 166 |
+
'results': results,
|
| 167 |
+
'total_files': len(files),
|
| 168 |
+
'successful': len([r for r in results if 'error' not in r])
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
async def _preprocess_image(content: bytes, filename: str) -> Dict[str, Any]:
|
| 173 |
+
"""Preprocess an image file"""
|
| 174 |
+
logger.debug(f"Preprocessing image: {filename}")
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
processed_content, processed_filename, mime_type = ImagePreprocessor.preprocess_image(
|
| 178 |
+
content,
|
| 179 |
+
filename,
|
| 180 |
+
target_format='PNG',
|
| 181 |
+
quality=95
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
preprocessing_info = {
|
| 185 |
+
"original_filename": filename,
|
| 186 |
+
"processed_filename": processed_filename,
|
| 187 |
+
"original_mime_type": ImagePreprocessor.detect_mime_type(content, filename),
|
| 188 |
+
"processed_mime_type": mime_type,
|
| 189 |
+
"processed_content": processed_content,
|
| 190 |
+
"was_preprocessed": processed_filename != filename
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if processed_filename != filename:
|
| 194 |
+
logger.info(f"Image preprocessed: {filename} -> {processed_filename}")
|
| 195 |
+
|
| 196 |
+
return preprocessing_info
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"Image preprocessing failed: {str(e)}")
|
| 200 |
+
# Fall back to original content
|
| 201 |
+
return {
|
| 202 |
+
"original_filename": filename,
|
| 203 |
+
"processed_filename": filename,
|
| 204 |
+
"original_mime_type": "unknown",
|
| 205 |
+
"processed_mime_type": "image/png",
|
| 206 |
+
"processed_content": content,
|
| 207 |
+
"was_preprocessed": False,
|
| 208 |
+
"error": str(e)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
@staticmethod
|
| 212 |
+
async def _upload_to_storage(content: bytes, filename: str, mime_type: str) -> Tuple[str, str]:
|
| 213 |
+
"""Upload content to storage and return key and SHA256"""
|
| 214 |
+
logger.debug(f"Uploading to storage: {filename}")
|
| 215 |
+
|
| 216 |
+
key = storage.upload_fileobj(
|
| 217 |
+
io.BytesIO(content),
|
| 218 |
+
filename,
|
| 219 |
+
content_type=mime_type
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
sha = crud.hash_bytes(content)
|
| 223 |
+
logger.debug(f"Uploaded to key: {key}, SHA: {sha}")
|
| 224 |
+
|
| 225 |
+
return key, sha
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
async def _generate_image_versions(content: bytes, filename: str) -> Tuple[Optional[Tuple], Optional[Tuple]]:
|
| 229 |
+
"""Generate thumbnail and detail versions of the image"""
|
| 230 |
+
logger.debug(f"Generating image versions: {filename}")
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
thumbnail_result = ImageProcessingService.create_and_upload_thumbnail(
|
| 234 |
+
content, filename
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
detail_result = ImageProcessingService.create_and_upload_detail(
|
| 238 |
+
content, filename
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if thumbnail_result:
|
| 242 |
+
logger.info(f"Thumbnail generated: {thumbnail_result[0]}")
|
| 243 |
+
if detail_result:
|
| 244 |
+
logger.info(f"Detail version generated: {detail_result[0]}")
|
| 245 |
+
|
| 246 |
+
return thumbnail_result, detail_result
|
| 247 |
+
|
| 248 |
+
except Exception as e:
|
| 249 |
+
logger.error(f"Image version generation failed: {str(e)}")
|
| 250 |
+
return None, None
|
| 251 |
+
|
| 252 |
+
@staticmethod
|
| 253 |
+
async def _generate_caption(img, image_content: bytes, title: str, model_name: Optional[str], db: Session):
|
| 254 |
+
"""Generate caption for the uploaded image"""
|
| 255 |
+
if not title and not model_name:
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
logger.debug(f"Generating caption for image {img.image_id}")
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
# Get active prompt for image type
|
| 262 |
+
prompt_obj = crud.get_active_prompt_by_image_type(db, img.image_type)
|
| 263 |
+
if not prompt_obj:
|
| 264 |
+
logger.warning(f"No active prompt found for image type: {img.image_type}")
|
| 265 |
+
return
|
| 266 |
+
|
| 267 |
+
# Generate caption using VLM service
|
| 268 |
+
result = await vlm_manager.generate_caption(
|
| 269 |
+
image_content=image_content,
|
| 270 |
+
prompt=prompt_obj.label,
|
| 271 |
+
metadata_instructions=prompt_obj.metadata_instructions or "",
|
| 272 |
+
model_name=model_name,
|
| 273 |
+
db_session=db
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Create caption record
|
| 277 |
+
crud.create_caption(
|
| 278 |
+
db=db,
|
| 279 |
+
image_id=img.image_id,
|
| 280 |
+
title=title or result.get("title", ""),
|
| 281 |
+
prompt=prompt_obj.p_code,
|
| 282 |
+
model_code=result.get("model", model_name or "STUB_MODEL"),
|
| 283 |
+
raw_json=result.get("raw_response", {}),
|
| 284 |
+
text=result.get("text", ""),
|
| 285 |
+
metadata=result.get("metadata", {}),
|
| 286 |
+
image_count=1
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
logger.info(f"Caption generated for image {img.image_id}")
|
| 290 |
+
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.error(f"Caption generation failed: {str(e)}")
|
| 293 |
+
# Continue without caption if generation fails
|
py_backend/app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package
|
py_backend/app/utils/image_utils.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared utilities for image operations
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from .. import crud, storage
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def convert_image_to_dict(img, image_url: str) -> Dict[str, Any]:
|
| 12 |
+
"""Helper function to convert SQLAlchemy image model to dict for Pydantic"""
|
| 13 |
+
countries_list = []
|
| 14 |
+
if hasattr(img, 'countries') and img.countries is not None:
|
| 15 |
+
try:
|
| 16 |
+
countries_list = [{"c_code": c.c_code, "label": c.label, "r_code": c.r_code} for c in img.countries]
|
| 17 |
+
except Exception as e:
|
| 18 |
+
logger.warning(f"Error processing countries for image {img.image_id}: {e}")
|
| 19 |
+
countries_list = []
|
| 20 |
+
|
| 21 |
+
captions_list = []
|
| 22 |
+
if hasattr(img, 'captions') and img.captions is not None:
|
| 23 |
+
try:
|
| 24 |
+
captions_list = [
|
| 25 |
+
{
|
| 26 |
+
"caption_id": c.caption_id,
|
| 27 |
+
"title": c.title,
|
| 28 |
+
"text": c.text,
|
| 29 |
+
"starred": c.starred,
|
| 30 |
+
"generated": c.generated,
|
| 31 |
+
"model": c.model,
|
| 32 |
+
"created_at": c.created_at,
|
| 33 |
+
"updated_at": c.updated_at
|
| 34 |
+
} for c in img.captions
|
| 35 |
+
]
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.warning(f"Error processing captions for image {img.image_id}: {e}")
|
| 38 |
+
captions_list = []
|
| 39 |
+
|
| 40 |
+
# Get starred status and other caption fields from first caption for backward compatibility
|
| 41 |
+
starred = False
|
| 42 |
+
title = None
|
| 43 |
+
text = None
|
| 44 |
+
generated = False
|
| 45 |
+
model = None
|
| 46 |
+
created_at = None
|
| 47 |
+
updated_at = None
|
| 48 |
+
|
| 49 |
+
if captions_list:
|
| 50 |
+
first_caption = captions_list[0]
|
| 51 |
+
starred = first_caption.get("starred", False)
|
| 52 |
+
title = first_caption.get("title", "")
|
| 53 |
+
text = first_caption.get("text", "")
|
| 54 |
+
generated = first_caption.get("generated", False)
|
| 55 |
+
model = first_caption.get("model", "")
|
| 56 |
+
created_at = first_caption.get("created_at")
|
| 57 |
+
updated_at = first_caption.get("updated_at")
|
| 58 |
+
|
| 59 |
+
# Generate URLs for thumbnail and detail versions
|
| 60 |
+
thumbnail_url = None
|
| 61 |
+
detail_url = None
|
| 62 |
+
|
| 63 |
+
if hasattr(img, 'thumbnail_key') and img.thumbnail_key:
|
| 64 |
+
try:
|
| 65 |
+
thumbnail_url = storage.get_object_url(img.thumbnail_key)
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.warning(f"Error generating thumbnail URL for image {img.image_id}: {e}")
|
| 68 |
+
|
| 69 |
+
if hasattr(img, 'detail_key') and img.detail_key:
|
| 70 |
+
try:
|
| 71 |
+
detail_url = storage.get_object_url(img.detail_key)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.warning(f"Error generating detail URL for image {img.image_id}: {e}")
|
| 74 |
+
|
| 75 |
+
img_dict = {
|
| 76 |
+
"image_id": img.image_id,
|
| 77 |
+
"file_key": img.file_key,
|
| 78 |
+
"sha256": img.sha256,
|
| 79 |
+
"source": img.source,
|
| 80 |
+
"event_type": img.event_type,
|
| 81 |
+
"epsg": img.epsg,
|
| 82 |
+
"image_type": img.image_type,
|
| 83 |
+
"countries": countries_list,
|
| 84 |
+
"captions": captions_list,
|
| 85 |
+
"starred": starred,
|
| 86 |
+
"title": title,
|
| 87 |
+
"text": text,
|
| 88 |
+
"generated": generated,
|
| 89 |
+
"model": model,
|
| 90 |
+
"created_at": created_at,
|
| 91 |
+
"updated_at": updated_at,
|
| 92 |
+
"url": image_url,
|
| 93 |
+
"thumbnail_url": thumbnail_url,
|
| 94 |
+
"detail_url": detail_url,
|
| 95 |
+
# Drone-specific fields
|
| 96 |
+
"center_lon": getattr(img, 'center_lon', None),
|
| 97 |
+
"center_lat": getattr(img, 'center_lat', None),
|
| 98 |
+
"amsl_m": getattr(img, 'amsl_m', None),
|
| 99 |
+
"agl_m": getattr(img, 'agl_m', None),
|
| 100 |
+
"heading_deg": getattr(img, 'heading_deg', None),
|
| 101 |
+
"yaw_deg": getattr(img, 'yaw_deg', None),
|
| 102 |
+
"pitch_deg": getattr(img, 'pitch_deg', None),
|
| 103 |
+
"roll_deg": getattr(img, 'roll_deg', None),
|
| 104 |
+
"rtk_fix": getattr(img, 'rtk_fix', None),
|
| 105 |
+
"std_h_m": getattr(img, 'std_h_m', None),
|
| 106 |
+
"std_v_m": getattr(img, 'std_v_m', None),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return img_dict
|