SCGR commited on
Commit
238c51b
·
1 Parent(s): 01c5951

modularize upload.py

Browse files
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, prefix="/api", tags=["captions"])
99
- app.include_router(metadata.router, prefix="/api", tags=["metadata"])
100
- app.include_router(models.router, prefix="/api", tags=["models"])
101
- app.include_router(upload.router, prefix="/api/images", tags=["images"])
102
- app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
103
- app.include_router(prompts_router, prefix="/api/prompts", tags=["prompts"])
104
- app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
105
- app.include_router(schemas_router, prefix="/api", tags=["schemas"])
 
 
 
 
 
 
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