();
- successfulDroneImages.forEach(result => {
- const eventType = result.image.event_type || 'unknown';
- if (!droneImagesByEventType.has(eventType)) {
- droneImagesByEventType.set(eventType, []);
- }
- droneImagesByEventType.get(eventType)!.push(result);
- });
-
- droneImagesByEventType.forEach((images, _eventType) => {
- const totalImages = images.length;
- const trainCount = Math.floor(totalImages * (80 / 100));
- const testCount = Math.floor(totalImages * (10 / 100));
-
- const shuffledImages = [...images].sort(() => Math.random() - 0.5);
-
- droneTrainData.push(...shuffledImages.slice(0, trainCount).map(result => ({
- image: `images/${result.fileName}`,
- caption: result.image.edited || result.image.generated || '',
- metadata: {
- image_id: result.image.image_id,
- title: result.image.title,
- source: result.image.source,
- event_type: result.image.event_type,
- image_type: result.image.image_type,
- countries: result.image.countries,
- starred: result.image.starred
- }
- })));
-
- droneTestData.push(...shuffledImages.slice(trainCount, trainCount + testCount).map(result => ({
- image: `images/${result.fileName}`,
- caption: result.image.edited || result.image.generated || '',
- metadata: {
- image_id: result.image.image_id,
- title: result.image.title,
- source: result.image.source,
- event_type: result.image.event_type,
- image_type: result.image.image_type,
- countries: result.image.countries,
- starred: result.image.starred
- }
- })));
-
- droneValData.push(...shuffledImages.slice(trainCount + testCount).map(result => ({
- image: `images/${result.fileName}`,
- caption: result.image.edited || result.image.generated || '',
- metadata: {
- image_id: result.image.image_id,
- title: result.image.title,
- source: result.image.source,
- event_type: result.image.event_type,
- image_type: result.image.image_type,
- countries: result.image.countries,
- starred: result.image.starred
- }
- })));
- });
-
- if (droneFolder) {
- droneFolder.file('train.jsonl', JSON.stringify(droneTrainData, null, 2));
- droneFolder.file('test.jsonl', JSON.stringify(droneTestData, null, 2));
- droneFolder.file('val.jsonl', JSON.stringify(droneValData, null, 2));
+ // For fine-tuning, create one entry per caption with all images
+ const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
+
+ const random = Math.random();
+ const entry = {
+ image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
+ caption: caption.edited || caption.generated || '',
+ metadata: {
+ image_id: imageIds,
+ title: caption.title,
+ source: caption.source,
+ event_type: caption.event_type,
+ image_type: caption.image_type,
+ countries: caption.countries,
+ starred: caption.starred,
+ image_count: caption.image_count || 1
+ }
+ };
+
+ // Store the entry for later processing
+ if (!droneFolder) continue;
+
+ if (random < 0.8) {
+ // Add to train data
+ const trainFile = droneFolder.file('train.jsonl');
+ if (trainFile) {
+ const existingData = await trainFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
+ existingData.push(entry);
+ droneFolder.file('train.jsonl', JSON.stringify(existingData, null, 2));
+ } else {
+ droneFolder.file('train.jsonl', JSON.stringify([entry], null, 2));
+ }
+ } else if (random < 0.9) {
+ // Add to test data
+ const testFile = droneFolder.file('test.jsonl');
+ if (testFile) {
+ const existingData = await testFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
+ existingData.push(entry);
+ droneFolder.file('test.jsonl', JSON.stringify(existingData, null, 2));
+ } else {
+ droneFolder.file('test.jsonl', JSON.stringify([entry], null, 2));
+ }
+ } else {
+ // Add to validation data
+ const valFile = droneFolder.file('val.jsonl');
+ if (valFile) {
+ const existingData = await valFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
+ existingData.push(entry);
+ droneFolder.file('val.jsonl', JSON.stringify(existingData, null, 2));
+ } else {
+ droneFolder.file('val.jsonl', JSON.stringify([entry], null, 2));
+ }
}
} else {
- successfulDroneImages.forEach((result, index) => {
+ // For standard mode, create one JSON file per caption
+ const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
const jsonData = {
- image: `images/${result.fileName}`,
- caption: result.image.edited || result.image.generated || '',
+ image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
+ caption: caption.edited || caption.generated || '',
metadata: {
- image_id: result.image.image_id,
- title: result.image.title,
- source: result.image.source,
- event_type: result.image.event_type,
- image_type: result.image.image_type,
- countries: result.image.countries,
- starred: result.image.starred
+ image_id: imageIds,
+ title: caption.title,
+ source: caption.source,
+ event_type: caption.event_type,
+ image_type: caption.image_type,
+ countries: caption.countries,
+ starred: caption.starred,
+ image_count: caption.image_count || 1
}
};
if (droneFolder) {
- droneFolder.file(`${String(index + 1).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
+ droneFolder.file(`${String(jsonIndex).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
+ }
+ }
+
+ jsonIndex++;
}
- });
+ } catch (error) {
+ console.error(`Failed to process caption ${caption.image_id}:`, error);
+ }
}
}
}
@@ -678,15 +703,31 @@ export default function ExplorePage() {
{c.image_type !== 'drone_image' && (
- {sources.find(s => s.s_code === c.source)?.label || c.source}
+ {c.source && c.source.includes(', ')
+ ? c.source.split(', ').map(s => sources.find(src => src.s_code === s.trim())?.label || s.trim()).join(', ')
+ : sources.find(s => s.s_code === c.source)?.label || c.source
+ }
)}
- {types.find(t => t.t_code === c.event_type)?.label || c.event_type}
+ {c.event_type && c.event_type.includes(', ')
+ ? c.event_type.split(', ').map(e => types.find(t => t.t_code === e.trim())?.label || e.trim()).join(', ')
+ : types.find(t => t.t_code === c.event_type)?.label || c.event_type
+ }
{imageTypes.find(it => it.image_type === c.image_type)?.label || c.image_type}
+ {c.image_count && c.image_count > 1 && (
+
+ 📷 {c.image_count}
+
+ )}
+ {(!c.image_count || c.image_count <= 1) && (
+
+ Single
+
+ )}
{c.countries && c.countries.length > 0 && (
<>
@@ -787,7 +828,7 @@ export default function ExplorePage() {
}}
filteredCount={filtered.length}
totalCount={captions.length}
- hasFilters={!!(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || showReferenceExamples)}
+ hasFilters={!!(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || uploadTypeFilter || showReferenceExamples)}
crisisMapsCount={filtered.filter(img => img.image_type === 'crisis_map').length}
droneImagesCount={filtered.filter(img => img.image_type === 'drone_image').length}
isLoading={isExporting}
diff --git a/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css b/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css
index 466de9321dea5cbab7d2e4dd372edae896252e25..c7c99752373119f6f83d5427bdbf107a764bd245 100644
--- a/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css
+++ b/frontend/src/pages/MapDetailsPage/MapDetailPage.module.css
@@ -140,6 +140,18 @@
}
}
+/* Left column container for image and tags */
+.leftColumn {
+ display: flex;
+ flex-direction: column;
+ gap: var(--go-ui-spacing-lg);
+}
+
+/* Invisible wrapper for tags */
+.tagsWrapper {
+ /* No visual styling - just a logical container */
+}
+
.detailsSection {
display: flex;
flex-direction: column;
@@ -348,3 +360,176 @@
text-align: center;
margin-bottom: var(--go-ui-spacing-lg);
}
+
+/* Carousel styles for multi-upload */
+.carouselContainer {
+ position: relative;
+ width: 100%;
+}
+
+.carouselImageWrapper {
+ position: relative;
+ width: 100%;
+ background-color: var(--go-ui-color-gray-20);
+ border-radius: var(--go-ui-border-radius-lg);
+ overflow: hidden;
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ box-shadow: var(--go-ui-box-shadow-sm);
+ transition: box-shadow var(--go-ui-duration-transition-medium) ease;
+}
+
+.carouselImageWrapper:hover {
+ box-shadow: var(--go-ui-box-shadow-md);
+}
+
+.carouselImage {
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ image-rendering: pixelated;
+ display: block;
+}
+
+.carouselNavigation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--go-ui-spacing-md);
+ margin-top: var(--go-ui-spacing-md);
+ padding: var(--go-ui-spacing-sm);
+ background-color: var(--go-ui-color-gray-10);
+ border-radius: var(--go-ui-border-radius-md);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+.carouselButton {
+ background-color: var(--go-ui-color-white);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ border-radius: var(--go-ui-border-radius-md);
+ padding: var(--go-ui-spacing-sm);
+ transition: all var(--go-ui-duration-transition-fast) ease;
+ min-width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.carouselButton:hover:not(:disabled) {
+ background-color: var(--go-ui-color-gray-20);
+ border-color: var(--go-ui-color-gray-40);
+ transform: translateY(-1px);
+}
+
+.carouselButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.carouselIndicators {
+ display: flex;
+ gap: var(--go-ui-spacing-xs);
+ align-items: center;
+}
+
+.carouselIndicator {
+ background-color: var(--go-ui-color-gray-30);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ border-radius: var(--go-ui-border-radius-sm);
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
+ font-size: var(--go-ui-font-size-sm);
+ font-weight: var(--go-ui-font-weight-medium);
+ color: var(--go-ui-color-gray-70);
+ cursor: pointer;
+ transition: all var(--go-ui-duration-transition-fast) ease;
+ min-width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.carouselIndicator:hover:not(:disabled) {
+ background-color: var(--go-ui-color-gray-40);
+ border-color: var(--go-ui-color-gray-50);
+ color: var(--go-ui-color-gray-90);
+}
+
+.carouselIndicatorActive {
+ background-color: var(--go-ui-color-red-90);
+ border-color: var(--go-ui-color-red-90);
+ color: var(--go-ui-color-white);
+}
+
+.carouselIndicatorActive:hover:not(:disabled) {
+ background-color: var(--go-ui-color-red-hover);
+ border-color: var(--go-ui-color-red-hover);
+ color: var(--go-ui-color-white);
+}
+
+.carouselIndicator:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.imageCounter {
+ text-align: center;
+ margin-top: var(--go-ui-spacing-sm);
+ font-size: var(--go-ui-font-size-sm);
+ font-weight: var(--go-ui-font-weight-medium);
+ color: var(--go-ui-color-gray-70);
+ background-color: var(--go-ui-color-gray-10);
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
+ border-radius: var(--go-ui-border-radius-sm);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+/* Single image container */
+.singleImageContainer {
+ position: relative;
+ width: 100%;
+}
+
+/* View image button container */
+.viewImageButtonContainer {
+ display: flex;
+ justify-content: center;
+ margin-top: var(--go-ui-spacing-md);
+ padding: var(--go-ui-spacing-sm);
+ background-color: var(--go-ui-color-gray-10);
+ border-radius: var(--go-ui-border-radius-md);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+/* Responsive adjustments for carousel */
+@media (max-width: 768px) {
+ .carouselNavigation {
+ flex-direction: column;
+ gap: var(--go-ui-spacing-sm);
+ }
+
+ .carouselIndicators {
+ order: -1;
+ margin-bottom: var(--go-ui-spacing-sm);
+ }
+
+ .carouselButton {
+ min-width: 36px;
+ height: 36px;
+ }
+
+ .carouselIndicator {
+ min-width: 28px;
+ height: 28px;
+ font-size: var(--go-ui-font-size-xs);
+ }
+
+ .imageCounter {
+ font-size: var(--go-ui-font-size-xs);
+ }
+
+ .viewImageButtonContainer {
+ margin-top: var(--go-ui-spacing-sm);
+ padding: var(--go-ui-spacing-xs);
+ }
+}
diff --git a/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx b/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx
index b9cff142865e85afbd3344a3d4a85da74d8001bc..1c3a737273a12938c45e3319e77ba1ba13f6c6b5 100644
--- a/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx
+++ b/frontend/src/pages/MapDetailsPage/MapDetailPage.tsx
@@ -6,6 +6,8 @@ import styles from './MapDetailPage.module.css';
import { useFilterContext } from '../../hooks/useFilterContext';
import { useAdmin } from '../../hooks/useAdmin';
import ExportModal from '../../components/ExportModal';
+import { FullSizeImageModal } from '../../components/upload/ModalComponents';
+import FilterBar from '../../components/FilterBar';
interface MapOut {
image_id: string;
@@ -43,6 +45,9 @@ interface MapOut {
starred?: boolean;
created_at?: string;
updated_at?: string;
+ // Multi-upload fields
+ all_image_ids?: string[];
+ image_count?: number;
}
export default function MapDetailPage() {
@@ -107,7 +112,16 @@ export default function MapDetailPage() {
const [droneImagesSelected, setDroneImagesSelected] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
- const [showContributeConfirm, setShowContributeConfirm] = useState(false);
+
+
+ // Full-size image modal state
+ const [showFullSizeModal, setShowFullSizeModal] = useState(false);
+ const [selectedImageForModal, setSelectedImageForModal] = useState(null);
+
+ // Carousel state for multi-upload
+ const [allImages, setAllImages] = useState([]);
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
+ const [isLoadingImages, setIsLoadingImages] = useState(false);
const {
search, setSearch,
@@ -116,6 +130,7 @@ export default function MapDetailPage() {
regionFilter, setRegionFilter,
countryFilter, setCountryFilter,
imageTypeFilter, setImageTypeFilter,
+ uploadTypeFilter, setUploadTypeFilter,
showReferenceExamples, setShowReferenceExamples,
clearAllFilters
} = useFilterContext();
@@ -158,6 +173,39 @@ export default function MapDetailPage() {
const data = await response.json();
setMap(data);
+ // If this is a multi-upload item, fetch all images
+ if (data.all_image_ids && data.all_image_ids.length > 1) {
+ await fetchAllImages(data.all_image_ids);
+ } else if (data.image_count && data.image_count > 1) {
+ // Multi-upload but no all_image_ids, try to fetch from grouped endpoint
+ console.log('Multi-upload detected but no all_image_ids, trying grouped endpoint');
+ try {
+ const groupedResponse = await fetch('/api/images/grouped');
+ if (groupedResponse.ok) {
+ const groupedData = await groupedResponse.json();
+ const matchingItem = groupedData.find((item: any) =>
+ item.all_image_ids && item.all_image_ids.includes(data.image_id)
+ );
+ if (matchingItem && matchingItem.all_image_ids) {
+ await fetchAllImages(matchingItem.all_image_ids);
+ } else {
+ setAllImages([data]);
+ setCurrentImageIndex(0);
+ }
+ } else {
+ setAllImages([data]);
+ setCurrentImageIndex(0);
+ }
+ } catch (err) {
+ console.error('Failed to fetch from grouped endpoint:', err);
+ setAllImages([data]);
+ setCurrentImageIndex(0);
+ }
+ } else {
+ setAllImages([data]);
+ setCurrentImageIndex(0);
+ }
+
await checkNavigationAvailability(id);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
@@ -167,6 +215,64 @@ export default function MapDetailPage() {
}
}, []);
+ const fetchAllImages = useCallback(async (imageIds: string[]) => {
+ console.log('fetchAllImages called with imageIds:', imageIds);
+ setIsLoadingImages(true);
+
+ try {
+ const imagePromises = imageIds.map(async (imageId) => {
+ const response = await fetch(`/api/images/${imageId}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image ${imageId}`);
+ }
+ return response.json();
+ });
+
+ const images = await Promise.all(imagePromises);
+ setAllImages(images);
+ setCurrentImageIndex(0);
+ console.log('fetchAllImages: Loaded', images.length, 'images');
+ } catch (err: unknown) {
+ console.error('fetchAllImages error:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load all images');
+ } finally {
+ setIsLoadingImages(false);
+ }
+ }, []);
+
+ // Carousel navigation functions
+ const goToPrevious = useCallback(() => {
+ if (allImages.length > 1) {
+ setCurrentImageIndex((prev) => (prev > 0 ? prev - 1 : allImages.length - 1));
+ }
+ }, [allImages.length]);
+
+ const goToNext = useCallback(() => {
+ if (allImages.length > 1) {
+ setCurrentImageIndex((prev) => (prev < allImages.length - 1 ? prev + 1 : 0));
+ }
+ }, [allImages.length]);
+
+ const goToImage = useCallback((index: number) => {
+ if (index >= 0 && index < allImages.length) {
+ setCurrentImageIndex(index);
+ }
+ }, [allImages.length]);
+
+ // Full-size image modal functions
+ const handleViewFullSize = useCallback((image?: MapOut) => {
+ const imageToShow = image || (allImages.length > 0 ? allImages[currentImageIndex] : map);
+ if (imageToShow) {
+ setSelectedImageForModal(imageToShow);
+ setShowFullSizeModal(true);
+ }
+ }, [allImages, currentImageIndex, map]);
+
+ const handleCloseFullSizeModal = useCallback(() => {
+ setShowFullSizeModal(false);
+ setSelectedImageForModal(null);
+ }, []);
+
useEffect(() => {
console.log('MapDetailsPage: mapId from useParams:', mapId);
console.log('MapDetailsPage: mapId type:', typeof mapId);
@@ -313,7 +419,7 @@ export default function MapDetailPage() {
}
try {
- const response = await fetch('/api/images');
+ const response = await fetch('/api/images/grouped');
if (response.ok) {
const images = await response.json();
@@ -331,9 +437,12 @@ export default function MapDetailPage() {
const matchesCountry = !countryFilter ||
img.countries?.some((country: any) => country.c_code === countryFilter);
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
+ const matchesUploadType = !uploadTypeFilter ||
+ (uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
+ (uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
- return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
+ return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
});
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === currentId);
@@ -351,7 +460,7 @@ export default function MapDetailPage() {
setIsNavigating(true);
try {
- const response = await fetch('/api/images');
+ const response = await fetch('/api/images/grouped');
if (response.ok) {
const images = await response.json();
@@ -369,9 +478,12 @@ export default function MapDetailPage() {
const matchesCountry = !countryFilter ||
img.countries?.some((country: any) => country.c_code === countryFilter);
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
+ const matchesUploadType = !uploadTypeFilter ||
+ (uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
+ (uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
- return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
+ return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
});
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === mapId);
@@ -421,6 +533,13 @@ export default function MapDetailPage() {
}
};
+ // Check navigation availability when filters change
+ useEffect(() => {
+ if (map && mapId && !loading && !isDeleting) {
+ checkNavigationAvailability(mapId);
+ }
+ }, [map, mapId, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, loading, isDeleting, checkNavigationAvailability]);
+
useEffect(() => {
Promise.all([
fetch('/api/sources').then(r => r.json()),
@@ -437,7 +556,7 @@ export default function MapDetailPage() {
}).catch(console.error);
}, []);
- const [isGenerating, setIsGenerating] = useState(false);
+
// delete function
const handleDelete = async () => {
@@ -485,7 +604,7 @@ export default function MapDetailPage() {
setShowDeleteConfirm(false);
try {
- const response = await fetch('/api/images');
+ const response = await fetch('/api/images/grouped');
if (response.ok) {
const images = await response.json();
@@ -503,9 +622,12 @@ export default function MapDetailPage() {
const matchesCountry = !countryFilter ||
img.countries?.some((country: any) => country.c_code === countryFilter);
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
+ const matchesUploadType = !uploadTypeFilter ||
+ (uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
+ (uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
- return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
+ return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
});
const remainingImages = filteredImages.filter((img: any) => img.image_id !== map.image_id);
@@ -586,7 +708,7 @@ export default function MapDetailPage() {
const filteredMap = useMemo(() => {
if (!map) return null;
- if (!search && !srcFilter && !catFilter && !regionFilter && !countryFilter && !imageTypeFilter && !showReferenceExamples) {
+ if (!search && !srcFilter && !catFilter && !regionFilter && !countryFilter && !imageTypeFilter && !uploadTypeFilter && !showReferenceExamples) {
return map;
}
@@ -603,90 +725,104 @@ export default function MapDetailPage() {
const matchesCountry = !countryFilter ||
map.countries.some(country => country.c_code === countryFilter);
const matchesImageType = !imageTypeFilter || map.image_type === imageTypeFilter;
+ const matchesUploadType = !uploadTypeFilter ||
+ (uploadTypeFilter === 'single' && (!map.image_count || map.image_count <= 1)) ||
+ (uploadTypeFilter === 'multiple' && map.image_count && map.image_count > 1);
const matchesReferenceExamples = !showReferenceExamples || map.starred === true;
- return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples ? map : null;
- }, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples]);
-
- const handleContribute = () => {
- if (!map) return;
- setShowContributeConfirm(true);
- };
-
- const handleContributeConfirm = async () => {
- if (!map) return;
+ const matches = matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
- setIsGenerating(true);
+ // If current map doesn't match filters, navigate to a matching image
+ if (!matches && (search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || uploadTypeFilter || showReferenceExamples)) {
+ // Navigate to a matching image after a short delay to avoid infinite loops
+ setTimeout(() => {
+ navigateToMatchingImage();
+ }, 100);
+ // Return the current map while loading to show loading state instead of "no match found"
+ return map;
+ }
+ return matches ? map : null;
+ }, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples]);
+
+ const navigateToMatchingImage = useCallback(async () => {
+ setLoading(true);
try {
- const res = await fetch('/api/contribute/from-url', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- url: map.image_url,
- source: map.source,
- event_type: map.event_type,
- epsg: map.epsg,
- image_type: map.image_type,
- countries: map.countries.map(c => c.c_code),
- }),
- });
-
- if (!res.ok) {
- const errorData = await res.json();
- throw new Error(errorData.error || 'Failed to create contribution');
- }
-
- const json = await res.json();
- const newId = json.image_id as string;
-
- const modelName = localStorage.getItem('selectedVlmModel');
- const capRes = await fetch(`/api/images/${newId}/caption`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({
- title: 'Generated Caption',
- prompt: 'DEFAULT_CRISIS_MAP',
- ...(modelName && { model_name: modelName }),
- }),
- });
-
- if (!capRes.ok) {
- const errorData = await capRes.json();
- throw new Error(errorData.error || 'Failed to generate caption');
+ const response = await fetch('/api/images/grouped');
+ if (response.ok) {
+ const images = await response.json();
+
+ const filteredImages = images.filter((img: any) => {
+ const matchesSearch = !search ||
+ img.title?.toLowerCase().includes(search.toLowerCase()) ||
+ img.generated?.toLowerCase().includes(search.toLowerCase()) ||
+ img.source?.toLowerCase().includes(search.toLowerCase()) ||
+ img.event_type?.toLowerCase().includes(search.toLowerCase());
+
+ const matchesSource = !srcFilter || img.source === srcFilter;
+ const matchesCategory = !catFilter || img.event_type === catFilter;
+ const matchesRegion = !regionFilter ||
+ img.countries?.some((country: any) => country.r_code === regionFilter);
+ const matchesCountry = !countryFilter ||
+ img.countries?.some((country: any) => country.c_code === countryFilter);
+ const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
+ const matchesUploadType = !uploadTypeFilter ||
+ (uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
+ (uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
+ const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
+
+ return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
+ });
+
+ if (filteredImages.length > 0) {
+ const firstMatchingImage = filteredImages[0];
+ if (firstMatchingImage && firstMatchingImage.image_id) {
+ navigate(`/map/${firstMatchingImage.image_id}`);
+ }
+ } else {
+ // No matching images, go back to explore
+ navigate('/explore');
+ }
}
-
- // Wait for the VLM response to be processed
- const captionData = await capRes.json();
- console.log('Caption generation response:', captionData);
-
- // Now navigate to the upload page with the processed data
- const url = `/upload?imageUrl=${encodeURIComponent(json.image_url)}&isContribution=true&step=2a&imageId=${newId}&imageType=${map.image_type}`;
- navigate(url);
-
- } catch (error: unknown) {
- console.error('Contribution failed:', error);
- alert(`Contribution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } catch (error) {
+ console.error('Failed to navigate to matching image:', error);
+ navigate('/explore');
} finally {
- setIsGenerating(false);
+ setLoading(false);
}
- };
+ }, [search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, navigate]);
- const handleContributeCancel = () => {
- setShowContributeConfirm(false);
+ const handleContribute = () => {
+ if (!map) return;
+
+ // For single image contribution
+ if (!map.all_image_ids || map.all_image_ids.length <= 1) {
+ const imageIds = [map.image_id];
+ const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`;
+ navigate(url);
+ return;
+ }
+
+ // For multi-upload contribution
+ const imageIds = map.all_image_ids;
+ const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`;
+ navigate(url);
};
const createImageData = (map: any, fileName: string) => ({
image: `images/${fileName}`,
caption: map.edited || map.generated || '',
metadata: {
- image_id: map.image_id,
+ image_id: map.image_count && map.image_count > 1
+ ? map.all_image_ids || [map.image_id]
+ : map.image_id,
title: map.title,
source: map.source,
event_type: map.event_type,
image_type: map.image_type,
countries: map.countries,
- starred: map.starred
+ starred: map.starred,
+ image_count: map.image_count || 1
}
});
@@ -706,38 +842,65 @@ export default function MapDetailPage() {
if (crisisImagesFolder) {
try {
- const response = await fetch(`/api/images/${map.image_id}/file`);
- if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`);
+ // Get all image IDs for this map
+ const imageIds = map.image_count && map.image_count > 1
+ ? map.all_image_ids || [map.image_id]
+ : [map.image_id];
- const blob = await response.blob();
- const fileExtension = map.file_key.split('.').pop() || 'jpg';
- const fileName = `0001.${fileExtension}`;
+ // Fetch all images for this map
+ const imagePromises = imageIds.map(async (imageId, imgIndex) => {
+ try {
+ const response = await fetch(`/api/images/${imageId}/file`);
+ if (!response.ok) throw new Error(`Failed to fetch image ${imageId}`);
+
+ const blob = await response.blob();
+ const fileExtension = map.file_key.split('.').pop() || 'jpg';
+ const fileName = `0001_${String(imgIndex + 1).padStart(2, '0')}.${fileExtension}`;
+
+ crisisImagesFolder.file(fileName, blob);
+ return { success: true, fileName, imageId };
+ } catch (error) {
+ console.error(`Failed to process image ${imageId}:`, error);
+ return { success: false, fileName: '', imageId };
+ }
+ });
+
+ const imageResults = await Promise.all(imagePromises);
+ const successfulImages = imageResults.filter(result => result.success);
- crisisImagesFolder.file(fileName, blob);
+ if (successfulImages.length === 0) {
+ throw new Error('No images could be processed');
+ }
if (mode === 'fine-tuning') {
const trainData: any[] = [];
const testData: any[] = [];
const valData: any[] = [];
- if (String(map?.image_type) === 'crisis_map') {
- const random = Math.random();
- if (random < trainSplit / 100) {
- trainData.push(createImageData(map, '0001'));
- } else if (random < (trainSplit + testSplit) / 100) {
- testData.push(createImageData(map, '0001'));
- } else {
- valData.push(createImageData(map, '0001'));
- }
- } else if (String(map?.image_type) === 'drone_image') {
- const random = Math.random();
- if (random < trainSplit / 100) {
- trainData.push(createImageData(map, '0001'));
- } else if (random < (trainSplit + testSplit) / 100) {
- testData.push(createImageData(map, '0001'));
- } else {
- valData.push(createImageData(map, '0001'));
+ const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
+ const random = Math.random();
+
+ const entry = {
+ image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
+ caption: map.edited || map.generated || '',
+ metadata: {
+ image_id: imageIds,
+ title: map.title,
+ source: map.source,
+ event_type: map.event_type,
+ image_type: map.image_type,
+ countries: map.countries,
+ starred: map.starred,
+ image_count: map.image_count || 1
}
+ };
+
+ if (random < trainSplit / 100) {
+ trainData.push(entry);
+ } else if (random < (trainSplit + testSplit) / 100) {
+ testData.push(entry);
+ } else {
+ valData.push(entry);
}
if (crisisFolder) {
@@ -746,17 +909,19 @@ export default function MapDetailPage() {
crisisFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
}
} else {
+ const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
const jsonData = {
- image: `images/${fileName}`,
+ image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
caption: map.edited || map.generated || '',
metadata: {
- image_id: map.image_id,
+ image_id: imageIds,
title: map.title,
source: map.source,
event_type: map.event_type,
image_type: map.image_type,
countries: map.countries,
- starred: map.starred
+ starred: map.starred,
+ image_count: map.image_count || 1
}
};
@@ -819,13 +984,16 @@ export default function MapDetailPage() {
image: `images/${fileName}`,
caption: map.edited || map.generated || '',
metadata: {
- image_id: map.image_id,
+ image_id: map.image_count && map.image_count > 1
+ ? map.all_image_ids || [map.image_id]
+ : map.image_id,
title: map.title,
source: map.source,
event_type: map.event_type,
image_type: map.image_type,
countries: map.countries,
- starred: map.starred
+ starred: map.starred,
+ image_count: map.image_count || 1
}
};
@@ -888,13 +1056,16 @@ export default function MapDetailPage() {
image: `images/${fileName}`,
caption: map.edited || map.generated || '',
metadata: {
- image_id: map.image_id,
+ image_id: map.image_count && map.image_count > 1
+ ? map.all_image_ids || [map.image_id]
+ : map.image_id,
title: map.title,
source: map.source,
event_type: map.event_type,
image_type: map.image_type,
countries: map.countries,
- starred: map.starred
+ starred: map.starred,
+ image_count: map.image_count || 1
}
};
@@ -1017,99 +1188,15 @@ export default function MapDetailPage() {
- {/* Search and Filters */}
-
- {/* Layer 1: Search, Reference Examples, Clear Filters */}
-
-
- setSearch(v || '')}
- />
-
-
-
-
-
-
- Clear Filters
-
-
-
-
- {/* Layer 2: 5 Filter Bars */}
-
-
- setSrcFilter(v as string || '')}
- keySelector={(o) => o.s_code}
- labelSelector={(o) => o.label}
- required={false}
- />
-
-
-
- setCatFilter(v as string || '')}
- keySelector={(o) => o.t_code}
- labelSelector={(o) => o.label}
- required={false}
- />
-
-
-
- setRegionFilter(v as string || '')}
- keySelector={(o) => o.r_code}
- labelSelector={(o) => o.label}
- required={false}
- />
-
-
-
- setCountryFilter((v as string[])[0] || '')}
- keySelector={(o) => o.c_code}
- labelSelector={(o) => o.label}
- />
-
-
-
- setImageTypeFilter(v as string || '')}
- keySelector={(o) => o.image_type}
- labelSelector={(o) => o.label}
- required={false}
- />
-
-
-
+ {/* Filter Bar */}
+
{view === 'mapDetails' ? (
@@ -1132,28 +1219,115 @@ export default function MapDetailPage() {
spacing="comfortable"
>
- {filteredMap.image_url ? (
-
+ {(map?.image_count && map.image_count > 1) || allImages.length > 1 ? (
+ // Multi-upload carousel
+
+
+ {isLoadingImages ? (
+
+ ) : allImages[currentImageIndex]?.image_url ? (
+
+ ) : (
+
+ No image available
+
+ )}
+
+
+ {/* Carousel Navigation */}
+
+
+
+
+
+
+ {allImages.map((_, index) => (
+ goToImage(index)}
+ className={`${styles.carouselIndicator} ${
+ index === currentImageIndex ? styles.carouselIndicatorActive : ''
+ }`}
+ disabled={isLoadingImages}
+ >
+ {index + 1}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* View Image Button for Carousel */}
+
+ handleViewFullSize(allImages[currentImageIndex])}
+ disabled={isLoadingImages || !allImages[currentImageIndex]?.image_url}
+ >
+ View Image
+
+
+
) : (
-
- No image available
+ // Single image display
+
+ {filteredMap.image_url ? (
+
+ ) : (
+
+ No image available
+
+ )}
+
+ {/* View Image Button for Single Image */}
+
+ handleViewFullSize(filteredMap)}
+ disabled={!filteredMap.image_url}
+ >
+ View Image
+
+
)}
-
-
- {/* Details Section */}
-
-
+
+ {/* Tags Section - Inside Image Container */}
+
{filteredMap.image_type !== 'drone_image' && (
@@ -1176,8 +1350,22 @@ export default function MapDetailPage() {
>
)}
+ {filteredMap.image_count && filteredMap.image_count > 1 && (
+
+ 📷 {filteredMap.image_count}
+
+ )}
+ {(!filteredMap.image_count || filteredMap.image_count <= 1) && (
+
+ Single
+
+ )}
+
+
+ {/* Details Section */}
+
{/* Combined Analysis Structure */}
{(filteredMap.edited && filteredMap.edited.includes('Description:')) ||
@@ -1275,13 +1463,8 @@ export default function MapDetailPage() {
- {isGenerating ? (
- Generating...
- ) : (
- 'Contribute'
- )}
+ Contribute
@@ -1384,43 +1567,7 @@ export default function MapDetailPage() {
)}
- {/* Contribute Confirmation Modal */}
- {showContributeConfirm && (
-
-
e.stopPropagation()}>
-
-
- This will start a new independent upload with just the image.
-
- {!isGenerating && (
-
-
- Continue
-
-
- Cancel
-
-
- )}
- {isGenerating && (
-
-
-
Generating...
-
This might take a few seconds
-
- )}
-
-
-
- )}
+
{/* Export Selection Modal */}
{showExportModal && (
@@ -1454,6 +1601,15 @@ export default function MapDetailPage() {
}}
/>
)}
+
+ {/* Full Size Image Modal */}
+
);
}
\ No newline at end of file
diff --git a/frontend/src/pages/UploadPage/UploadPage.module.css b/frontend/src/pages/UploadPage/UploadPage.module.css
index bfef61374ad42c7bf15fc8777992e1f99c79a4d4..42a84234ebe16384b6e0aac797ad6c6e87898ddf 100644
--- a/frontend/src/pages/UploadPage/UploadPage.module.css
+++ b/frontend/src/pages/UploadPage/UploadPage.module.css
@@ -417,6 +417,11 @@
align-items: start;
}
+/* When rating section is hidden, image section takes full width */
+.topRow.ratingHidden {
+ grid-template-columns: 1fr;
+}
+
.imageSection {
position: sticky;
top: var(--go-ui-spacing-lg);
@@ -482,6 +487,10 @@
gap: var(--go-ui-spacing-lg);
}
+ .topRow.ratingHidden {
+ grid-template-columns: 1fr;
+ }
+
.mapColumn {
position: static;
}
@@ -660,4 +669,214 @@
font-weight: var(--go-ui-font-weight-medium);
}
+/* Crop modal styles */
+.cropZoomSlider {
+ flex: 1;
+ height: 0.5rem;
+ background-color: var(--go-ui-color-gray-30);
+ border-radius: var(--go-ui-border-radius-lg);
+ appearance: none;
+ cursor: pointer;
+ outline: none;
+}
+
+.cropZoomSlider::-webkit-slider-thumb {
+ appearance: none;
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: var(--go-ui-color-red-90);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--go-ui-color-white);
+ box-shadow: var(--go-ui-box-shadow-sm);
+}
+
+.cropZoomSlider::-moz-range-thumb {
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: var(--go-ui-color-red-90);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--go-ui-color-white);
+ box-shadow: var(--go-ui-box-shadow-sm);
+ border: none;
+}
+
+.cropZoomSlider:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--go-ui-color-red-40);
+}
+
+/* Carousel styles for multi-upload */
+.carouselContainer {
+ position: relative;
+ width: 100%;
+}
+
+.carouselImageWrapper {
+ position: relative;
+ width: 100%;
+ background-color: var(--go-ui-color-gray-20);
+ border-radius: var(--go-ui-border-radius-lg);
+ overflow: hidden;
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ box-shadow: var(--go-ui-box-shadow-sm);
+ transition: box-shadow var(--go-ui-duration-transition-medium) ease;
+}
+
+.carouselImageWrapper:hover {
+ box-shadow: var(--go-ui-box-shadow-md);
+}
+
+.carouselImage {
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ image-rendering: pixelated;
+ display: block;
+}
+
+.carouselNavigation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--go-ui-spacing-md);
+ margin-top: var(--go-ui-spacing-md);
+ padding: var(--go-ui-spacing-sm);
+ background-color: var(--go-ui-color-gray-10);
+ border-radius: var(--go-ui-border-radius-md);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+.carouselButton {
+ background-color: var(--go-ui-color-white);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ border-radius: var(--go-ui-border-radius-md);
+ padding: var(--go-ui-spacing-sm);
+ transition: all var(--go-ui-duration-transition-fast) ease;
+ min-width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.carouselButton:hover:not(:disabled) {
+ background-color: var(--go-ui-color-gray-20);
+ border-color: var(--go-ui-color-gray-40);
+ transform: translateY(-1px);
+}
+
+.carouselButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.carouselIndicators {
+ display: flex;
+ gap: var(--go-ui-spacing-xs);
+ align-items: center;
+}
+
+.carouselIndicator {
+ background-color: var(--go-ui-color-gray-30);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+ border-radius: var(--go-ui-border-radius-sm);
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
+ font-size: var(--go-ui-font-size-sm);
+ font-weight: var(--go-ui-font-weight-medium);
+ color: var(--go-ui-color-gray-70);
+ cursor: pointer;
+ transition: all var(--go-ui-duration-transition-fast) ease;
+ min-width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.carouselIndicator:hover:not(:disabled) {
+ background-color: var(--go-ui-color-gray-40);
+ border-color: var(--go-ui-color-gray-50);
+ color: var(--go-ui-color-gray-90);
+}
+
+.carouselIndicatorActive {
+ background-color: var(--go-ui-color-red-90);
+ border-color: var(--go-ui-color-red-90);
+ color: var(--go-ui-color-white);
+}
+
+.carouselIndicatorActive:hover:not(:disabled) {
+ background-color: var(--go-ui-color-red-hover);
+ border-color: var(--go-ui-color-red-hover);
+ color: var(--go-ui-color-white);
+}
+
+.carouselIndicator:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.imageCounter {
+ text-align: center;
+ margin-top: var(--go-ui-spacing-sm);
+ font-size: var(--go-ui-font-size-sm);
+ font-weight: var(--go-ui-font-weight-medium);
+ color: var(--go-ui-color-gray-70);
+ background-color: var(--go-ui-color-gray-10);
+ padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
+ border-radius: var(--go-ui-border-radius-sm);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+/* Single image container */
+.singleImageContainer {
+ position: relative;
+ width: 100%;
+}
+
+/* View image button container */
+.viewImageButtonContainer {
+ display: flex;
+ justify-content: center;
+ margin-top: var(--go-ui-spacing-md);
+ padding: var(--go-ui-spacing-sm);
+ background-color: var(--go-ui-color-gray-10);
+ border-radius: var(--go-ui-border-radius-md);
+ border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
+}
+
+/* Responsive adjustments for carousel */
+@media (max-width: 768px) {
+ .carouselNavigation {
+ flex-direction: column;
+ gap: var(--go-ui-spacing-sm);
+ }
+
+ .carouselIndicators {
+ order: -1;
+ margin-bottom: var(--go-ui-spacing-sm);
+ }
+
+ .carouselButton {
+ min-width: 36px;
+ height: 36px;
+ }
+
+ .carouselIndicator {
+ min-width: 28px;
+ height: 28px;
+ font-size: var(--go-ui-font-size-xs);
+ }
+
+ .imageCounter {
+ font-size: var(--go-ui-font-size-xs);
+ }
+
+ .viewImageButtonContainer {
+ margin-top: var(--go-ui-spacing-sm);
+ }
+}
+
diff --git a/frontend/src/pages/UploadPage/UploadPage.tsx b/frontend/src/pages/UploadPage/UploadPage.tsx
index df310803dc083e56e475e3b78be3b5017afb8c58..e4855bd5790adeb85cb0e297e304658d00165579 100644
--- a/frontend/src/pages/UploadPage/UploadPage.tsx
+++ b/frontend/src/pages/UploadPage/UploadPage.tsx
@@ -1,16 +1,25 @@
import { useState, useEffect, useRef, useCallback } from 'react';
-import type { DragEvent } from 'react';
-import {
- PageContainer, Heading, Button,
- SelectInput, MultiSelectInput, Container, IconButton, TextInput, TextArea, Spinner, SegmentInput,
-} from '@ifrc-go/ui';
-import {
- UploadCloudLineIcon,
- ArrowRightLineIcon,
- DeleteBinLineIcon,
-} from '@ifrc-go/icons';
-import { Link, useSearchParams, useNavigate } from 'react-router-dom';
+import { PageContainer, Heading, Button, Spinner, IconButton } from '@ifrc-go/ui';
+import { DeleteBinLineIcon, ChevronLeftLineIcon, ChevronRightLineIcon } from '@ifrc-go/icons';
+import { useSearchParams, useNavigate } from 'react-router-dom';
import styles from './UploadPage.module.css';
+import {
+ FileUploadSection,
+ ImagePreviewSection,
+ MetadataFormSection,
+ RatingSection,
+ GeneratedTextSection,
+ FullSizeImageModal,
+ RatingWarningModal,
+ DeleteConfirmModal,
+ NavigationConfirmModal,
+ FallbackNotificationModal,
+ PreprocessingNotificationModal,
+ PreprocessingModal,
+ UnsupportedFormatModal,
+ FileSizeWarningModal,
+
+} from '../../components';
const SELECTED_MODEL_KEY = 'selectedVlmModel';
@@ -25,7 +34,8 @@ export default function UploadPage() {
const [preview, setPreview] = useState
(null);
const [file, setFile] = useState(null);
- const [source, setSource] = useState('');
+ const [files, setFiles] = useState([]);
+ const [source, setSource] = useState('');
const [eventType, setEventType] = useState('');
const [epsg, setEpsg] = useState('');
const [imageType, setImageType] = useState('crisis_map');
@@ -45,6 +55,25 @@ export default function UploadPage() {
const [stdHM, setStdHM] = useState('');
const [stdVM, setStdVM] = useState('');
+ // Multi-image metadata arrays
+ const [metadataArray, setMetadataArray] = useState>([]);
+
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
@@ -52,277 +81,17 @@ export default function UploadPage() {
const [countriesOptions, setCountriesOptions] = useState<{c_code: string, label: string, r_code: string}[]>([]);
const [uploadedImageId, setUploadedImageId] = useState(null);
-
- stepRef.current = step;
- uploadedImageIdRef.current = uploadedImageId;
-
- const handleSourceChange = (value: string | undefined) => setSource(value || '');
- const handleEventTypeChange = (value: string | undefined) => setEventType(value || '');
- const handleEpsgChange = (value: string | undefined) => setEpsg(value || '');
- const handleImageTypeChange = (value: string | undefined) => setImageType(value || '');
- const handleCountriesChange = (value: string[] | undefined) => setCountries(Array.isArray(value) ? value : []);
-
- // Drone metadata handlers
- const handleCenterLonChange = (value: string | undefined) => setCenterLon(value || '');
- const handleCenterLatChange = (value: string | undefined) => setCenterLat(value || '');
- const handleAmslMChange = (value: string | undefined) => setAmslM(value || '');
- const handleAglMChange = (value: string | undefined) => setAglM(value || '');
- const handleHeadingDegChange = (value: string | undefined) => setHeadingDeg(value || '');
- const handleYawDegChange = (value: string | undefined) => setYawDeg(value || '');
- const handlePitchDegChange = (value: string | undefined) => setPitchDeg(value || '');
- const handleRollDegChange = (value: string | undefined) => setRollDeg(value || '');
- const handleRtkFixChange = (value: boolean | undefined) => setRtkFix(value || false);
- const handleStdHMChange = (value: string | undefined) => setStdHM(value || '');
- const handleStdVMChange = (value: string | undefined) => setStdVM(value || '');
-
- const handleStepChange = (newStep: 1 | '2a' | '2b' | 3) => setStep(newStep);
-
- useEffect(() => {
- Promise.all([
- fetch('/api/sources').then(r => r.json()),
- fetch('/api/types').then(r => r.json()),
- fetch('/api/spatial-references').then(r => r.json()),
- fetch('/api/image-types').then(r => r.json()),
- fetch('/api/countries').then(r => r.json()),
- fetch('/api/models').then(r => r.json())
- ]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData, modelsData]) => {
- if (!localStorage.getItem(SELECTED_MODEL_KEY) && modelsData?.length) {
- localStorage.setItem(SELECTED_MODEL_KEY, modelsData[0].m_code);
- }
- setSources(sourcesData);
- setTypes(typesData);
- setSpatialReferences(spatialData);
- setImageTypes(imageTypesData);
- setCountriesOptions(countriesData);
-
- if (sourcesData.length > 0) setSource(sourcesData[0].s_code);
- setEventType('OTHER');
- setEpsg('OTHER');
- if (imageTypesData.length > 0 && !searchParams.get('imageType') && !imageType) {
- setImageType(imageTypesData[0].image_type);
- }
- });
- }, [searchParams, imageType]);
-
- const handleNavigation = useCallback((to: string) => {
- if (to === '/upload' || to === '/') {
- return;
- }
-
- if (uploadedImageIdRef.current) {
- setPendingNavigation(to);
- setShowNavigationConfirm(true);
- } else {
- navigate(to);
- }
- }, [navigate]);
-
- useEffect(() => {
- window.confirmNavigationIfNeeded = (to: string) => {
- handleNavigation(to);
- };
-
- return () => {
- delete window.confirmNavigationIfNeeded;
- };
- }, [handleNavigation]);
-
- useEffect(() => {
- const handleBeforeUnload = (event: BeforeUnloadEvent) => {
- if (uploadedImageIdRef.current) {
- const message = 'You have an uploaded image that will be deleted if you leave this page. Are you sure you want to leave?';
- event.preventDefault();
- event.returnValue = message;
- return message;
- }
- };
-
- const handleCleanup = () => {
- if (uploadedImageIdRef.current) {
- fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
- }
- };
-
- const handleGlobalClick = (event: MouseEvent) => {
- const target = event.target as HTMLElement;
- const link = target.closest('a[href]') || target.closest('[data-navigate]');
-
- if (link && uploadedImageIdRef.current) {
- const href = link.getAttribute('href') || link.getAttribute('data-navigate');
- if (href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
- event.preventDefault();
- event.stopPropagation();
- handleNavigation(href);
- }
- }
- };
-
- window.addEventListener('beforeunload', handleBeforeUnload);
- document.addEventListener('click', handleGlobalClick, true);
-
- return () => {
- window.removeEventListener('beforeunload', handleBeforeUnload);
- document.removeEventListener('click', handleGlobalClick, true);
- handleCleanup();
- };
- }, [handleNavigation]);
-
+ const [uploadedImageIds, setUploadedImageIds] = useState([]);
const [imageUrl, setImageUrl] = useState(null);
const [draft, setDraft] = useState('');
const [description, setDescription] = useState('');
const [analysis, setAnalysis] = useState('');
const [recommendedActions, setRecommendedActions] = useState('');
-
- useEffect(() => {
- const imageUrlParam = searchParams.get('imageUrl');
- const stepParam = searchParams.get('step');
- const imageIdParam = searchParams.get('imageId');
- const imageTypeParam = searchParams.get('imageType');
-
- if (imageUrlParam) {
- setImageUrl(imageUrlParam);
-
- if (stepParam === '2a' && imageIdParam) {
- setIsLoadingContribution(true);
- setUploadedImageId(imageIdParam);
-
- if (imageTypeParam) {
- console.log('Setting imageType from URL parameter:', imageTypeParam);
- setImageType(imageTypeParam);
- }
-
- fetch(`/api/images/${imageIdParam}`)
- .then(res => res.json())
- .then(data => {
- console.log('API response data.image_type:', data.image_type);
- if (data.image_type && !imageTypeParam) {
- console.log('Setting imageType from API response:', data.image_type);
- setImageType(data.image_type);
- }
-
- if (data.generated) {
- // Extract the three parts from raw_json.metadata (same as regular upload flow)
- const extractedMetadataForParts = data.raw_json?.metadata;
- if (extractedMetadataForParts) {
- if (extractedMetadataForParts.description) {
- setDescription(extractedMetadataForParts.description);
- }
- if (extractedMetadataForParts.analysis) {
- setAnalysis(extractedMetadataForParts.analysis);
- }
- if (extractedMetadataForParts.recommended_actions) {
- setRecommendedActions(extractedMetadataForParts.recommended_actions);
- }
- }
-
- // Set draft with the generated content for backward compatibility
- setDraft(data.generated);
- }
-
- let extractedMetadata = data.raw_json?.metadata;
- console.log('Raw metadata:', extractedMetadata);
-
- if (!extractedMetadata && data.generated) {
- try {
- const parsedGenerated = JSON.parse(data.generated);
- console.log('Parsed generated field:', parsedGenerated);
- if (parsedGenerated.metadata) {
- extractedMetadata = parsedGenerated;
- console.log('Using metadata from generated field');
- }
- } catch (e) {
- console.log('Could not parse generated field as JSON:', e);
- }
- }
-
- if (extractedMetadata) {
- const metadata = extractedMetadata.metadata || extractedMetadata;
- console.log('Final metadata to apply:', metadata);
- if (metadata.title) {
- console.log('Setting title to:', metadata.title);
- setTitle(metadata.title);
- }
- if (metadata.source) {
- console.log('Setting source to:', metadata.source);
- setSource(metadata.source);
- }
- if (metadata.type) {
- console.log('Setting event type to:', metadata.type);
- setEventType(metadata.type);
- }
- if (metadata.epsg) {
- console.log('Setting EPSG to:', metadata.epsg);
- setEpsg(metadata.epsg);
- }
- if (metadata.countries && Array.isArray(metadata.countries)) {
- console.log('Setting countries to:', metadata.countries);
- setCountries(metadata.countries);
- }
- } else {
- console.log('No metadata found to extract');
- }
-
- setStep('2a');
- setIsLoadingContribution(false);
- })
- .catch(console.error)
- .finally(() => setIsLoadingContribution(false));
- }
- }
- }, [searchParams]);
-
- useEffect(() => {
- console.log('imageType changed to:', imageType);
- }, [imageType]);
-
- const resetToStep1 = () => {
- setIsPerformanceConfirmed(false);
- setStep(1);
- setFile(null);
- setPreview(null);
- setUploadedImageId(null);
- setImageUrl(null);
- setTitle('');
- setSource('');
- setEventType('');
- setEpsg('');
- setCountries([]);
- setCenterLon('');
- setCenterLat('');
- setAmslM('');
- setAglM('');
- setHeadingDeg('');
- setYawDeg('');
- setPitchDeg('');
- setRollDeg('');
- setRtkFix(false);
- setStdHM('');
- setStdVM('');
- setScores({ accuracy: 50, context: 50, usability: 50 });
- setDraft('');
- setDescription('');
- setAnalysis('');
- setRecommendedActions('');
- setShowFallbackNotification(false);
- setFallbackInfo(null);
- setShowPreprocessingNotification(false);
- setPreprocessingInfo(null);
- setShowPreprocessingModal(false);
- setPreprocessingFile(null);
- setIsPreprocessing(false);
- setPreprocessingProgress('');
- setShowUnsupportedFormatModal(false);
- setUnsupportedFile(null);
- setShowFileSizeWarningModal(false);
- setOversizedFile(null);
- };
- const [scores, setScores] = useState({
- accuracy: 50,
- context: 50,
- usability: 50,
- });
+ const [scores, setScores] = useState({ accuracy: 50, context: 50, usability: 50 });
+ // Modal states
const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
+ const [selectedImageData, setSelectedImageData] = useState<{ file: File; index: number } | null>(null);
const [isPerformanceConfirmed, setIsPerformanceConfirmed] = useState(false);
const [showRatingWarning, setShowRatingWarning] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -334,7 +103,6 @@ export default function UploadPage() {
fallbackModel: string;
reason: string;
} | null>(null);
-
const [showPreprocessingNotification, setShowPreprocessingNotification] = useState(false);
const [preprocessingInfo, setPreprocessingInfo] = useState<{
original_filename: string;
@@ -344,62 +112,107 @@ export default function UploadPage() {
was_preprocessed: boolean;
error?: string;
} | null>(null);
-
const [showPreprocessingModal, setShowPreprocessingModal] = useState(false);
const [preprocessingFile, setPreprocessingFile] = useState(null);
const [isPreprocessing, setIsPreprocessing] = useState(false);
const [preprocessingProgress, setPreprocessingProgress] = useState('');
-
- // unsupported format popup
const [showUnsupportedFormatModal, setShowUnsupportedFormatModal] = useState(false);
const [unsupportedFile, setUnsupportedFile] = useState(null);
-
- // New state for file size warning popup
const [showFileSizeWarningModal, setShowFileSizeWarningModal] = useState(false);
const [oversizedFile, setOversizedFile] = useState(null);
+ // Carousel state for multi-upload in step 2b
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
+
+ stepRef.current = step;
+ uploadedImageIdRef.current = uploadedImageId;
+
+ // Event handlers
+ const handleSourceChange = (value: string | undefined) => setSource(value || '');
+ const handleEventTypeChange = (value: string | undefined) => setEventType(value || '');
+ const handleEpsgChange = (value: string | undefined) => setEpsg(value || '');
+ const handleImageTypeChange = (value: string | undefined) => setImageType(value || '');
+ const handleCountriesChange = (value: string[] | undefined) => setCountries(Array.isArray(value) ? value : []);
+ const handleCenterLonChange = (value: string | undefined) => setCenterLon(value || '');
+ const handleCenterLatChange = (value: string | undefined) => setCenterLat(value || '');
+ const handleAmslMChange = (value: string | undefined) => setAmslM(value || '');
+ const handleAglMChange = (value: string | undefined) => setAglM(value || '');
+ const handleHeadingDegChange = (value: string | undefined) => setHeadingDeg(value || '');
+ const handleYawDegChange = (value: string | undefined) => setYawDeg(value || '');
+ const handlePitchDegChange = (value: string | undefined) => setPitchDeg(value || '');
+ const handleRollDegChange = (value: string | undefined) => setRollDeg(value || '');
+ const handleRtkFixChange = (value: boolean | undefined) => setRtkFix(value || false);
+ const handleStdHMChange = (value: string | undefined) => setStdHM(value || '');
+ const handleStdVMChange = (value: string | undefined) => setStdVM(value || '');
+ const handleStepChange = (newStep: 1 | '2a' | '2b' | 3) => setStep(newStep);
+
+ // Carousel navigation functions for step 2b
+ const goToPrevious = useCallback(() => {
+ if (files.length > 1) {
+ setCurrentImageIndex((prev: number) => (prev > 0 ? prev - 1 : files.length - 1));
+ }
+ }, [files.length]);
+
+ const goToNext = useCallback(() => {
+ if (files.length > 1) {
+ setCurrentImageIndex((prev: number) => (prev < files.length - 1 ? prev + 1 : 0));
+ }
+ }, [files.length]);
- const onDrop = (e: DragEvent) => {
- e.preventDefault();
- const dropped = e.dataTransfer.files?.[0];
- if (dropped) {
- onFileChange(dropped);
+ const goToImage = useCallback((index: number) => {
+ if (index >= 0 && index < files.length) {
+ setCurrentImageIndex(index);
+ }
+ }, [files.length]);
+
+ // Multi-image functions
+ const addImage = () => {
+ if (files.length < 5) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.jpg,.jpeg,.png,.tiff,.tif,.heic,.heif,.webp,.gif,.pdf';
+ input.onchange = (e) => {
+ const target = e.target as HTMLInputElement;
+ if (target.files && target.files[0]) {
+ const newFile = target.files[0];
+ onFileChange(newFile);
+ }
+ };
+ input.click();
}
};
- const onFileChange = (file: File | undefined) => {
- if (file) {
- console.log('File selected:', file.name, 'Type:', file.type, 'Size:', file.size);
-
- // Check if file is too large (5MB limit) - show warning but don't block
- const fileSizeMB = file.size / (1024 * 1024);
- if (fileSizeMB > 5) {
- console.log('File too large, showing size warning modal');
- setOversizedFile(file);
- setShowFileSizeWarningModal(true);
- // Don't return - continue with normal processing
- }
-
- // Check if file is completely unsupported
- if (isCompletelyUnsupported(file)) {
- console.log('File format not supported at all, showing unsupported format modal');
- setUnsupportedFile(file);
- setShowUnsupportedFormatModal(true);
- return;
+ const removeImage = (index: number) => {
+ setFiles(prev => {
+ const newFiles = prev.filter((_, i) => i !== index);
+ // If we're back to single file, update the single file state
+ if (newFiles.length === 1) {
+ setFile(newFiles[0]);
+ } else if (newFiles.length === 0) {
+ setFile(null);
}
-
- // Check if file needs preprocessing
- if (needsPreprocessing(file)) {
- console.log('File needs preprocessing, showing modal');
- setPreprocessingFile(file);
- setShowPreprocessingModal(true);
- } else {
- console.log('File does not need preprocessing, setting directly');
- setFile(file);
+ return newFiles;
+ });
+ setMetadataArray(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const updateMetadataForImage = (index: number, field: string, value: any) => {
+ setMetadataArray(prev => {
+ const newArray = [...prev];
+ if (!newArray[index]) {
+ newArray[index] = {
+ source: '', eventType: '', epsg: '', countries: [],
+ centerLon: '', centerLat: '', amslM: '', aglM: '',
+ headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
+ rtkFix: false, stdHM: '', stdVM: ''
+ };
}
- }
+ newArray[index] = { ...newArray[index], [field]: value };
+ return newArray;
+ });
};
+ // File handling functions
const needsPreprocessing = (file: File): boolean => {
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
const supportedExtensions = ['.jpg', '.jpeg', '.png'];
@@ -415,38 +228,22 @@ export default function UploadPage() {
};
const isCompletelyUnsupported = (file: File): boolean => {
- // List of formats that are completely unsupported (cannot be converted)
const completelyUnsupportedTypes = [
- 'text/html',
- 'text/css',
- 'application/javascript',
- 'application/json',
- 'text/plain',
- 'application/xml',
- 'text/xml',
- 'application/zip',
- 'application/x-zip-compressed',
- 'application/x-rar-compressed',
- 'application/x-7z-compressed',
- 'audio/',
- 'video/',
- 'text/csv',
- 'application/vnd.ms-excel',
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'application/vnd.ms-powerpoint',
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- 'application/msword',
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ 'text/html', 'text/css', 'application/javascript', 'application/json',
+ 'text/plain', 'application/xml', 'text/xml', 'application/zip',
+ 'application/x-zip-compressed', 'application/x-rar-compressed',
+ 'application/x-7z-compressed', 'audio/', 'video/', 'text/csv',
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
- // Check if the MIME type starts with any of the unsupported types
for (const unsupportedType of completelyUnsupportedTypes) {
if (file.type.startsWith(unsupportedType)) {
return true;
}
}
- // Check file extension for additional unsupported formats
if (file.name) {
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
const unsupportedExtensions = [
@@ -463,88 +260,87 @@ export default function UploadPage() {
return false;
};
- const handlePreprocessingConfirm = async () => {
- if (!preprocessingFile) return;
-
- setIsPreprocessing(true);
- setPreprocessingProgress('Starting file conversion...');
-
- try {
- const formData = new FormData();
- formData.append('file', preprocessingFile);
- formData.append('preprocess_only', 'true');
-
- setPreprocessingProgress('Converting file format...');
-
- const response = await fetch('/api/images/preprocess', {
- method: 'POST',
- body: formData
- });
+ const onFileChange = (file: File | undefined) => {
+ if (file) {
+ console.log('File selected:', file.name, 'Type:', file.type, 'Size:', file.size);
- if (!response.ok) {
- throw new Error('Preprocessing failed');
+ const fileSizeMB = file.size / (1024 * 1024);
+ if (fileSizeMB > 5) {
+ console.log('File too large, showing size warning modal');
+ setOversizedFile(file);
+ setShowFileSizeWarningModal(true);
}
- const result = await response.json();
-
- setPreprocessingProgress('Finalizing conversion...');
-
- const processedContent = atob(result.processed_content);
- const processedBytes = new Uint8Array(processedContent.length);
- for (let i = 0; i < processedContent.length; i++) {
- processedBytes[i] = processedContent.charCodeAt(i);
+ if (isCompletelyUnsupported(file)) {
+ console.log('File format not supported at all, showing unsupported format modal');
+ setUnsupportedFile(file);
+ setShowUnsupportedFormatModal(true);
+ return;
+ }
+
+ if (needsPreprocessing(file)) {
+ console.log('File needs preprocessing, showing modal');
+ setPreprocessingFile(file);
+ setShowPreprocessingModal(true);
+ } else {
+ console.log('File does not need preprocessing, setting directly');
+ // If this is the first file, set it as the single file
+ if (files.length === 0) {
+ setFile(file);
+ setFiles([file]);
+ } else {
+ // If files already exist, add to the array (multi-upload mode)
+ setFiles(prev => [...prev, file]);
+ }
}
-
- const processedFile = new File(
- [processedBytes],
- result.processed_filename,
- { type: result.processed_mime_type }
- );
-
- const previewUrl = URL.createObjectURL(processedFile);
-
- setFile(processedFile);
- setPreview(previewUrl);
-
- setPreprocessingProgress('Conversion complete!');
-
- setTimeout(() => {
- setShowPreprocessingModal(false);
- setPreprocessingFile(null);
- setIsPreprocessing(false);
- setPreprocessingProgress('');
- }, 1000);
-
- } catch (error) {
- console.error('Preprocessing error:', error);
- setPreprocessingProgress('Conversion failed. Please try again.');
- setTimeout(() => {
- setShowPreprocessingModal(false);
- setPreprocessingFile(null);
- setIsPreprocessing(false);
- setPreprocessingProgress('');
- }, 2000);
}
};
- const handlePreprocessingCancel = () => {
- setShowPreprocessingModal(false);
- setPreprocessingFile(null);
- setIsPreprocessing(false);
- setPreprocessingProgress('');
- };
+ const onChangeFile = (file: File | undefined) => {
+ if (file) {
+ console.log('File changed:', file.name, 'Type:', file.type, 'Size:', file.size);
+
+ const fileSizeMB = file.size / (1024 * 1024);
+ if (fileSizeMB > 5) {
+ console.log('File too large, showing size warning modal');
+ setOversizedFile(file);
+ setShowFileSizeWarningModal(true);
+ }
+
+ if (isCompletelyUnsupported(file)) {
+ console.log('File format not supported at all, showing unsupported format modal');
+ setUnsupportedFile(file);
+ setShowUnsupportedFormatModal(true);
+ return;
+ }
- useEffect(() => {
- if (!file) {
- setPreview(null);
- return;
+ if (needsPreprocessing(file)) {
+ console.log('File needs preprocessing, showing modal');
+ setPreprocessingFile(file);
+ setShowPreprocessingModal(true);
+ } else {
+ console.log('File does not need preprocessing, replacing last file');
+ // Replace only the last file in the array
+ if (files.length > 1) {
+ setFiles(prev => {
+ const newFiles = [...prev];
+ newFiles[newFiles.length - 1] = file;
+ return newFiles;
+ });
+ // Update single file state if it's a single upload
+ if (files.length === 1) {
+ setFile(file);
+ }
+ } else {
+ // If only one file, replace it normally
+ setFile(file);
+ setFiles([file]);
+ }
+ }
}
- const url = URL.createObjectURL(file);
- setPreview(url);
- return () => URL.revokeObjectURL(url);
- }, [file]);
-
+ };
+ // API functions
async function readJsonSafely(res: Response): Promise> {
const text = await res.text();
try {
@@ -560,18 +356,39 @@ export default function UploadPage() {
}
async function handleGenerate() {
- if (!file) return;
+ if (files.length === 0) return;
setIsLoading(true);
- console.log('DEBUG: handleGenerate called - starting regular upload flow');
+ try {
+ if (files.length === 1) {
+ await handleSingleUpload();
+ } else {
+ await handleMultiUpload();
+ }
+ } catch (err) {
+ handleApiError(err, 'Upload');
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ async function handleSingleUpload() {
+ console.log('DEBUG: Starting single image upload');
const fd = new FormData();
- fd.append('file', file);
-
+ fd.append('file', files[0]);
+ fd.append('title', title);
+ fd.append('image_type', imageType);
+
+ // Add metadata for single image
+ if (source) fd.append('source', source);
+ if (eventType) fd.append('event_type', eventType);
+ if (epsg) fd.append('epsg', epsg);
+ if (countries.length > 0) {
+ countries.forEach(c => fd.append('countries', c));
+ }
if (imageType === 'drone_image') {
- fd.append('event_type', eventType || 'OTHER');
- fd.append('epsg', epsg || 'OTHER');
if (centerLon) fd.append('center_lon', centerLon);
if (centerLat) fd.append('center_lat', centerLat);
if (amslM) fd.append('amsl_m', amslM);
@@ -583,24 +400,66 @@ export default function UploadPage() {
if (rtkFix) fd.append('rtk_fix', rtkFix.toString());
if (stdHM) fd.append('std_h_m', stdHM);
if (stdVM) fd.append('std_v_m', stdVM);
- } else {
- fd.append('source', source || 'OTHER');
- fd.append('event_type', eventType || 'OTHER');
- fd.append('epsg', epsg || 'OTHER');
- }
-
+ }
+
+ const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
+ if (modelName) {
+ fd.append('model_name', modelName);
+ }
+
+ const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
+ const mapJson = await readJsonSafely(mapRes);
+ if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
+ console.log('DEBUG: Single upload response:', mapJson);
+
+ await processUploadResponse(mapJson, false);
+ }
+
+ async function handleMultiUpload() {
+ console.log('DEBUG: Starting multi-image upload');
+
+ const fd = new FormData();
+ files.forEach(file => fd.append('files', file));
+ fd.append('title', title);
fd.append('image_type', imageType);
- countries.forEach((c) => fd.append('countries', c));
+
+ // Add metadata for each image
+ metadataArray.forEach((metadata, index) => {
+ if (metadata.source) fd.append(`source_${index}`, metadata.source);
+ if (metadata.eventType) fd.append(`event_type_${index}`, metadata.eventType);
+ if (metadata.epsg) fd.append(`epsg_${index}`, metadata.epsg);
+ if (metadata.countries.length > 0) {
+ metadata.countries.forEach(c => fd.append(`countries_${index}`, c));
+ }
+ if (imageType === 'drone_image') {
+ if (metadata.centerLon) fd.append(`center_lon_${index}`, metadata.centerLon);
+ if (metadata.centerLat) fd.append(`center_lat_${index}`, metadata.centerLat);
+ if (metadata.amslM) fd.append(`amsl_m_${index}`, metadata.amslM);
+ if (metadata.aglM) fd.append(`agl_m_${index}`, metadata.aglM);
+ if (metadata.headingDeg) fd.append(`heading_deg_${index}`, metadata.headingDeg);
+ if (metadata.yawDeg) fd.append(`yaw_deg_${index}`, metadata.yawDeg);
+ if (metadata.pitchDeg) fd.append(`pitch_deg_${index}`, metadata.pitchDeg);
+ if (metadata.rollDeg) fd.append(`roll_deg_${index}`, metadata.rollDeg);
+ if (metadata.rtkFix) fd.append(`rtk_fix_${index}`, metadata.rtkFix.toString());
+ if (metadata.stdHM) fd.append(`std_h_m_${index}`, metadata.stdHM);
+ if (metadata.stdVM) fd.append(`std_v_m_${index}`, metadata.stdVM);
+ }
+ });
const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
if (modelName) {
fd.append('model_name', modelName);
}
- try {
- const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
+ const mapRes = await fetch('/api/images/multi', { method: 'POST', body: fd });
const mapJson = await readJsonSafely(mapRes);
if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
+ console.log('DEBUG: Multi upload response:', mapJson);
+
+ await processUploadResponse(mapJson, true);
+ }
+
+ async function processUploadResponse(mapJson: Record, isMultiUpload: boolean) {
setImageUrl(mapJson.image_url as string);
if (mapJson.preprocessing_info &&
@@ -615,142 +474,23 @@ export default function UploadPage() {
if (!mapIdVal) throw new Error('Upload failed: image_id not found');
setUploadedImageId(mapIdVal);
- const capRes = await fetch(
- `/api/images/${mapIdVal}/caption`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- },
- body: new URLSearchParams({
- title: 'Generated Caption',
- ...(modelName && { model_name: modelName })
- })
- },
- );
- const capJson = await readJsonSafely(capRes);
- if (!capRes.ok) throw new Error((capJson.error as string) || 'Caption failed');
- setUploadedImageId(mapIdVal);
-
- const fallbackInfo = (capJson.raw_json as Record)?.fallback_info;
- if (fallbackInfo) {
- setFallbackInfo({
- originalModel: (fallbackInfo as Record).original_model as string,
- fallbackModel: (fallbackInfo as Record).fallback_model as string,
- reason: (fallbackInfo as Record).reason as string
- });
- setShowFallbackNotification(true);
- }
-
- const extractedMetadata = (capJson.raw_json as Record)?.metadata;
- if (extractedMetadata) {
- const metadata = (extractedMetadata as Record).metadata || extractedMetadata;
- if ((metadata as Record).title) setTitle((metadata as Record).title as string);
- if ((metadata as Record).source) setSource((metadata as Record).source as string);
- if ((metadata as Record).type) setEventType((metadata as Record).type as string);
- if ((metadata as Record).epsg) setEpsg((metadata as Record).epsg as string);
- if ((metadata as Record).countries && Array.isArray((metadata as Record).countries)) {
- setCountries((metadata as Record).countries as string[]);
- }
- // Extract drone metadata if available
- if (imageType === 'drone_image') {
- if ((metadata as Record).center_lon) setCenterLon((metadata as Record).center_lon as string);
- if ((metadata as Record).center_lat) setCenterLat((metadata as Record).center_lat as string);
- if ((metadata as Record).amsl_m) setAmslM((metadata as Record).amsl_m as string);
- if ((metadata as Record).agl_m) setAglM((metadata as Record).agl_m as string);
- if ((metadata as Record).heading_deg) setHeadingDeg((metadata as Record).heading_deg as string);
- if ((metadata as Record).yaw_deg) setYawDeg((metadata as Record).yaw_deg as string);
- if ((metadata as Record).pitch_deg) setPitchDeg((metadata as Record).pitch_deg as string);
- if ((metadata as Record).roll_deg) setRollDeg((metadata as Record).roll_deg as string);
- if ((metadata as Record).rtk_fix !== undefined) setRtkFix((metadata as Record).rtk_fix as boolean);
- if ((metadata as Record).std_h_m) setStdHM((metadata as Record).std_h_m as string);
- if ((metadata as Record).std_v_m) setStdVM((metadata as Record).std_v_m as string);
- }
- }
-
- // Extract the three parts from raw_json.metadata
- const extractedMetadataForParts = (capJson.raw_json as Record)?.metadata;
- if (extractedMetadataForParts) {
- if ((extractedMetadataForParts as Record).description) {
- setDescription((extractedMetadataForParts as Record).description as string);
- }
- if ((extractedMetadataForParts as Record).analysis) {
- setAnalysis((extractedMetadataForParts as Record).analysis as string);
- }
- if ((extractedMetadataForParts as Record).recommended_actions) {
- setRecommendedActions((extractedMetadataForParts as Record).recommended_actions as string);
- }
- }
-
- // Set draft with the generated content for backward compatibility
- if (capJson.generated) {
- setDraft(capJson.generated as string);
- }
- handleStepChange('2a');
- } catch (err) {
- handleApiError(err, 'Upload');
- } finally {
- setIsLoading(false);
- }
- }
+ // Store image IDs
+ if (isMultiUpload) {
+ if (mapJson.image_ids && Array.isArray(mapJson.image_ids)) {
+ const imageIds = mapJson.image_ids as string[];
+ console.log('DEBUG: Storing image IDs for multi-upload:', imageIds);
+ setUploadedImageIds(imageIds);
+ } else {
+ console.log('DEBUG: Multi-upload but no image_ids found, using single ID');
+ setUploadedImageIds([mapIdVal]);
+ }
+ } else {
+ console.log('DEBUG: Storing single image ID:', mapIdVal);
+ setUploadedImageIds([mapIdVal]);
+ }
+
+ const capJson = mapJson;
- async function handleGenerateFromUrl() {
- if (!imageUrl) return;
- setIsLoading(true);
- try {
- const res = await fetch('/api/contribute/from-url', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- url: imageUrl,
- source: imageType === 'drone_image' ? undefined : (source || 'OTHER'),
- event_type: eventType || 'OTHER',
- epsg: epsg || 'OTHER',
- image_type: imageType,
- countries,
- ...(imageType === 'drone_image' && {
- center_lon: centerLon || undefined,
- center_lat: centerLat || undefined,
- amsl_m: amslM || undefined,
- agl_m: aglM || undefined,
- heading_deg: headingDeg || undefined,
- yaw_deg: yawDeg || undefined,
- pitch_deg: pitchDeg || undefined,
- roll_deg: rollDeg || undefined,
- rtk_fix: rtkFix || undefined,
- std_h_m: stdHM || undefined,
- std_v_m: stdVM || undefined,
- }),
- }),
- });
- const json = await readJsonSafely(res);
- if (!res.ok) throw new Error((json.error as string) || 'Upload failed');
-
- if (json.preprocessing_info &&
- typeof json.preprocessing_info === 'object' &&
- 'was_preprocessed' in json.preprocessing_info &&
- json.preprocessing_info.was_preprocessed === true) {
- setPreprocessingInfo(json.preprocessing_info as any);
- setShowPreprocessingNotification(true);
- }
-
- const newId = json.image_id as string;
- setUploadedImageId(newId);
- setImageUrl(json.image_url as string);
-
- const modelName = localStorage.getItem(SELECTED_MODEL_KEY) || undefined;
- const capRes = await fetch(`/api/images/${newId}/caption`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({
- title: 'Generated Caption',
- // No prompt specified - backend will use active prompt for image type
- ...(modelName && { model_name: modelName }),
- }),
- });
- const capJson = await readJsonSafely(capRes);
- if (!capRes.ok) throw new Error((capJson.error as string) || 'Caption failed');
-
const fallbackInfo = (capJson.raw_json as Record)?.fallback_info;
if (fallbackInfo) {
setFallbackInfo({
@@ -764,29 +504,107 @@ export default function UploadPage() {
const extractedMetadata = (capJson.raw_json as Record)?.metadata;
if (extractedMetadata) {
const metadata = (extractedMetadata as Record).metadata || extractedMetadata;
- if ((metadata as Record).title) setTitle((metadata as Record).title as string);
- if ((metadata as Record).source) setSource((metadata as Record).source as string);
- if ((metadata as Record).type) setEventType((metadata as Record).type as string);
- if ((metadata as Record).epsg) setEpsg((metadata as Record).epsg as string);
- if ((metadata as Record).countries && Array.isArray((metadata as Record).countries)) {
- setCountries((metadata as Record).countries as string[]);
- }
+
+ if (metadata && typeof metadata === 'object') {
+ const newMetadataArray = [];
+
+ if (isMultiUpload) {
+ // Try to get individual image metadata first
+ const metadataImages = (metadata as Record).metadata_images;
+ if (metadataImages && typeof metadataImages === 'object') {
+ // Parse individual image metadata
+ for (let i = 1; i <= files.length; i++) {
+ const imageKey = `image${i}`;
+ const imageMetadata = (metadataImages as Record)[imageKey];
+
+ if (imageMetadata && typeof imageMetadata === 'object') {
+ const imgMeta = imageMetadata as Record;
+ newMetadataArray.push({
+ source: imgMeta.source as string || '',
+ eventType: imgMeta.type as string || '',
+ epsg: imgMeta.epsg as string || '',
+ countries: Array.isArray(imgMeta.countries) ? imgMeta.countries as string[] : [],
+ centerLon: '', centerLat: '', amslM: '', aglM: '',
+ headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
+ rtkFix: false, stdHM: '', stdVM: ''
+ });
+ } else {
+ // Fallback to empty metadata for this image
+ newMetadataArray.push({
+ source: '', eventType: '', epsg: '', countries: [],
+ centerLon: '', centerLat: '', amslM: '', aglM: '',
+ headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
+ rtkFix: false, stdHM: '', stdVM: ''
+ });
+ }
+ }
+ } else {
+ // Fallback to shared metadata if no individual metadata found
+ const sharedMetadata = {
+ source: (metadata as Record).source as string || '',
+ eventType: (metadata as Record).type as string || '',
+ epsg: (metadata as Record).epsg as string || '',
+ countries: Array.isArray((metadata as Record).countries)
+ ? (metadata as Record).countries as string[]
+ : [],
+ centerLon: '', centerLat: '', amslM: '', aglM: '',
+ headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
+ rtkFix: false, stdHM: '', stdVM: ''
+ };
+
+ // Create metadata array with shared data for all images
+ for (let i = 0; i < files.length; i++) {
+ newMetadataArray.push({ ...sharedMetadata });
+ }
+ }
+ } else {
+ // Single upload: use shared metadata
+ const sharedMetadata = {
+ source: (metadata as Record).source as string || '',
+ eventType: (metadata as Record).type as string || '',
+ epsg: (metadata as Record).epsg as string || '',
+ countries: Array.isArray((metadata as Record).countries)
+ ? (metadata as Record).countries as string[]
+ : [],
+ centerLon: '', centerLat: '', amslM: '', aglM: '',
+ headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
+ rtkFix: false, stdHM: '', stdVM: ''
+ };
+ newMetadataArray.push(sharedMetadata);
+ }
+
+ setMetadataArray(newMetadataArray);
+
+ if (newMetadataArray.length > 0) {
+ const firstMeta = newMetadataArray[0];
+ // Set shared title from metadata
+ if (metadata && typeof metadata === 'object') {
+ const sharedTitle = (metadata as Record).title;
+ if (sharedTitle) {
+ setTitle(sharedTitle as string || '');
+ }
+ }
+ setSource(firstMeta.source || '');
+ setEventType(firstMeta.eventType || '');
+ setEpsg(firstMeta.epsg || '');
+ setCountries(firstMeta.countries || []);
if (imageType === 'drone_image') {
- if ((metadata as Record).center_lon) setCenterLon((metadata as Record).center_lon as string);
- if ((metadata as Record).center_lat) setCenterLat((metadata as Record).center_lat as string);
- if ((metadata as Record).amsl_m) setAmslM((metadata as Record).amsl_m as string);
- if ((metadata as Record).agl_m) setAglM((metadata as Record).agl_m as string);
- if ((metadata as Record).heading_deg) setHeadingDeg((metadata as Record).heading_deg as string);
- if ((metadata as Record).yaw_deg) setYawDeg((metadata as Record).yaw_deg as string);
- if ((metadata as Record).pitch_deg) setPitchDeg((metadata as Record).pitch_deg as string);
- if ((metadata as Record).roll_deg) setRollDeg((metadata as Record).roll_deg as string);
- if ((metadata as Record).rtk_fix !== undefined) setRtkFix((metadata as Record).rtk_fix as boolean);
- if ((metadata as Record).std_h_m) setStdHM((metadata as Record).std_h_m as string);
- if ((metadata as Record).std_v_m) setStdVM((metadata as Record).std_v_m as string);
- }
- }
+ setCenterLon(firstMeta.centerLon || '');
+ setCenterLat(firstMeta.centerLat || '');
+ setAmslM(firstMeta.amslM || '');
+ setAglM(firstMeta.aglM || '');
+ setHeadingDeg(firstMeta.headingDeg || '');
+ setYawDeg(firstMeta.yawDeg || '');
+ setPitchDeg(firstMeta.pitchDeg || '');
+ setRollDeg(firstMeta.rollDeg || '');
+ setRtkFix(firstMeta.rtkFix || false);
+ setStdHM(firstMeta.stdHM || '');
+ setStdVM(firstMeta.stdVM || '');
+ }
+ }
+ }
+ }
- // Extract the three parts from raw_json.metadata
const extractedMetadataForParts = (capJson.raw_json as Record)?.metadata;
if (extractedMetadataForParts) {
if ((extractedMetadataForParts as Record).description) {
@@ -800,18 +618,11 @@ export default function UploadPage() {
}
}
- // Set draft with the generated content for backward compatibility
if (capJson.generated) {
setDraft(capJson.generated as string);
}
handleStepChange('2a');
- } catch (err) {
- handleApiError(err, 'Upload');
- } finally {
- setIsLoading(false);
- }
}
-
async function handleSubmit() {
console.log('handleSubmit called with:', { uploadedImageId, title, draft });
@@ -823,23 +634,40 @@ export default function UploadPage() {
}
try {
- const metadataBody = {
- source: imageType === 'drone_image' ? undefined : (source || 'OTHER'),
- event_type: eventType || 'OTHER',
+ // Use stored image IDs for multi-image uploads
+ const imageIds = uploadedImageIds.length > 0 ? uploadedImageIds : [uploadedImageId!];
+ console.log('DEBUG: Submit - Using image IDs:', imageIds);
+ console.log('DEBUG: Submit - uploadedImageIds:', uploadedImageIds);
+ console.log('DEBUG: Submit - uploadedImageId:', uploadedImageId);
+
+ // Update metadata for each image
+ for (let i = 0; i < imageIds.length; i++) {
+ const imageId = imageIds[i];
+ const metadata = metadataArray[i] || {
+ source: source || 'OTHER',
+ eventType: eventType || 'OTHER',
epsg: epsg || 'OTHER',
+ countries: countries || []
+ };
+
+ const metadataBody = {
+ source: imageType === 'drone_image' ? undefined : (metadata.source || 'OTHER'),
+ event_type: metadata.eventType || 'OTHER',
+ epsg: metadata.epsg || 'OTHER',
image_type: imageType,
- countries: countries,
+ countries: metadata.countries || [],
};
- console.log('Updating metadata:', metadataBody);
- const metadataRes = await fetch(`/api/images/${uploadedImageId}`, {
+
+ console.log(`Updating metadata for image ${i + 1}:`, metadataBody);
+ const metadataRes = await fetch(`/api/images/${imageId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(metadataBody),
});
const metadataJson = await readJsonSafely(metadataRes);
- if (!metadataRes.ok) throw new Error((metadataJson.error as string) || "Metadata update failed");
+ if (!metadataRes.ok) throw new Error((metadataJson.error as string) || `Metadata update failed for image ${i + 1}`);
+ }
- // Combine the three parts for submission
const combinedContent = `Description: ${description}\n\nAnalysis: ${analysis}\n\nRecommended Actions: ${recommendedActions}`;
const captionBody = {
@@ -859,6 +687,7 @@ export default function UploadPage() {
if (!captionRes.ok) throw new Error((captionJson.error as string) || "Caption update failed");
setUploadedImageId(null);
+ setUploadedImageIds([]);
handleStepChange(3);
} catch (err) {
handleApiError(err, 'Submit');
@@ -888,16 +717,74 @@ export default function UploadPage() {
}
setShowDeleteConfirm(false);
- if (searchParams.get('isContribution') === 'true') {
- navigate('/explore');
- } else {
resetToStep1();
- }
} catch (err) {
handleApiError(err, 'Delete');
}
}
+ const resetToStep1 = () => {
+ setIsPerformanceConfirmed(false);
+ setStep(1);
+ setFile(null);
+ setFiles([]);
+ setPreview(null);
+ setUploadedImageId(null);
+ setUploadedImageIds([]);
+ setImageUrl(null);
+ setTitle('');
+ setSource('');
+ setEventType('');
+ setEpsg('');
+ setCountries([]);
+ setCenterLon('');
+ setCenterLat('');
+ setAmslM('');
+ setAglM('');
+ setHeadingDeg('');
+ setYawDeg('');
+ setPitchDeg('');
+ setRollDeg('');
+ setRtkFix(false);
+ setStdHM('');
+ setStdVM('');
+ setScores({ accuracy: 50, context: 50, usability: 50 });
+ setDraft('');
+ setDescription('');
+ setAnalysis('');
+ setRecommendedActions('');
+ setMetadataArray([]);
+ setShowFallbackNotification(false);
+ setFallbackInfo(null);
+ setShowPreprocessingNotification(false);
+ setPreprocessingInfo(null);
+ setShowPreprocessingModal(false);
+ setPreprocessingFile(null);
+ setIsPreprocessing(false);
+ setPreprocessingProgress('');
+ setShowUnsupportedFormatModal(false);
+ setUnsupportedFile(null);
+ setShowFileSizeWarningModal(false);
+ setOversizedFile(null);
+
+ // Clear URL parameters to prevent re-triggering contribute workflow
+ navigate('/upload', { replace: true });
+ };
+
+ // Navigation handling
+ const handleNavigation = useCallback((to: string) => {
+ if (to === '/upload' || to === '/') {
+ return;
+ }
+
+ if (uploadedImageIdRef.current) {
+ setPendingNavigation(to);
+ setShowNavigationConfirm(true);
+ } else {
+ navigate(to);
+ }
+ }, [navigate]);
+
async function confirmNavigation() {
if (pendingNavigation && uploadedImageIdRef.current) {
try {
@@ -914,97 +801,290 @@ export default function UploadPage() {
}
}
+ // Preprocessing handlers
+ const handlePreprocessingConfirm = async () => {
+ if (!preprocessingFile) return;
+
+ setIsPreprocessing(true);
+ setPreprocessingProgress('Starting file conversion...');
+
+ try {
+ const formData = new FormData();
+ formData.append('file', preprocessingFile);
+ formData.append('preprocess_only', 'true');
+
+ setPreprocessingProgress('Converting file format...');
+
+ const response = await fetch('/api/images/preprocess', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ throw new Error('Preprocessing failed');
+ }
+
+ const result = await response.json();
+
+ setPreprocessingProgress('Finalizing conversion...');
+
+ const processedContent = atob(result.processed_content);
+ const processedBytes = new Uint8Array(processedContent.length);
+ for (let i = 0; i < processedContent.length; i++) {
+ processedBytes[i] = processedContent.charCodeAt(i);
+ }
+
+ const processedFile = new File(
+ [processedBytes],
+ result.processed_filename,
+ { type: result.processed_mime_type }
+ );
+
+ const previewUrl = URL.createObjectURL(processedFile);
+
+ // If this is the first file, set it as the single file
+ if (files.length === 0) {
+ setFile(processedFile);
+ setFiles([processedFile]);
+ } else {
+ // If files already exist, add to the array (multi-upload mode)
+ setFiles(prev => [...prev, processedFile]);
+ }
+ setPreview(previewUrl);
+
+ setPreprocessingProgress('Conversion complete!');
+
+ setTimeout(() => {
+ setShowPreprocessingModal(false);
+ setPreprocessingFile(null);
+ setIsPreprocessing(false);
+ setPreprocessingProgress('');
+ }, 1000);
+
+ } catch (error) {
+ console.error('Preprocessing error:', error);
+ setPreprocessingProgress('Conversion failed. Please try again.');
+ setTimeout(() => {
+ setShowPreprocessingModal(false);
+ setPreprocessingFile(null);
+ setIsPreprocessing(false);
+ setPreprocessingProgress('');
+ }, 2000);
+ }
+ };
+
+ const handlePreprocessingCancel = () => {
+ setShowPreprocessingModal(false);
+ setPreprocessingFile(null);
+ setIsPreprocessing(false);
+ setPreprocessingProgress('');
+ };
+
+ // Fetch contributed images from database and convert to File objects
+ const fetchContributedImages = async (imageIds: string[]) => {
+ setIsLoadingContribution(true);
+ try {
+ const filePromises = imageIds.map(async (imageId) => {
+ // Fetch image data from the API
+ const response = await fetch(`/api/images/${imageId}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image ${imageId}`);
+ }
+ const imageData = await response.json();
+
+ // Fetch the actual image file
+ const fileResponse = await fetch(`/api/images/${imageId}/file`);
+ if (!fileResponse.ok) {
+ throw new Error(`Failed to fetch image file ${imageId}`);
+ }
+ const blob = await fileResponse.blob();
+
+ // Create a File object from the blob
+ const fileName = imageData.file_key.split('/').pop() || `contributed_${imageId}.png`;
+ const file = new File([blob], fileName, { type: blob.type });
+
+ return { file, imageData };
+ });
+
+ const contributedResults = await Promise.all(filePromises);
+ const contributedFiles = contributedResults.map(result => result.file);
+ const firstImageData = contributedResults[0]?.imageData;
+
+ setFiles(contributedFiles);
+
+ // Set the image IDs for submit process
+ setUploadedImageIds(imageIds);
+ if (imageIds.length === 1) {
+ setUploadedImageId(imageIds[0]);
+ }
+
+ // Set the first file as the main file for single upload compatibility
+ if (contributedFiles.length >= 1) {
+ setFile(contributedFiles[0]);
+ }
+
+ // Set the image type based on the contributed image's type
+ if (firstImageData?.image_type) {
+ setImageType(firstImageData.image_type);
+ }
+
+ // Stay on step 1 to show the images in the file upload section
+
+ } catch (error) {
+ console.error('Failed to fetch contributed images:', error);
+ alert(`Failed to load contributed images: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setIsLoadingContribution(false);
+ }
+ };
+
+ // Effects
+ useEffect(() => {
+ Promise.all([
+ fetch('/api/sources').then(r => r.json()),
+ fetch('/api/types').then(r => r.json()),
+ fetch('/api/spatial-references').then(r => r.json()),
+ fetch('/api/image-types').then(r => r.json()),
+ fetch('/api/countries').then(r => r.json()),
+ fetch('/api/models').then(r => r.json())
+ ]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData, modelsData]) => {
+ if (!localStorage.getItem(SELECTED_MODEL_KEY) && modelsData?.length) {
+ localStorage.setItem(SELECTED_MODEL_KEY, modelsData[0].m_code);
+ }
+ setSources(sourcesData);
+ setTypes(typesData);
+ setSpatialReferences(spatialData);
+ setImageTypes(imageTypesData);
+ setCountriesOptions(countriesData);
+
+ if (sourcesData.length > 0) setSource(sourcesData[0].s_code);
+ setEventType('OTHER');
+ setEpsg('OTHER');
+ if (imageTypesData.length > 0 && !searchParams.get('imageType') && !imageType) {
+ setImageType(imageTypesData[0].image_type);
+ }
+ });
+ }, [searchParams, imageType]);
+
+ useEffect(() => {
+ window.confirmNavigationIfNeeded = (to: string) => {
+ handleNavigation(to);
+ };
+
+ return () => {
+ delete window.confirmNavigationIfNeeded;
+ };
+ }, [handleNavigation]);
+
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (uploadedImageIdRef.current) {
+ const message = 'You have an uploaded image that will be deleted if you leave this page. Are you sure you want to leave?';
+ event.preventDefault();
+ event.returnValue = message;
+ return message;
+ }
+ };
+
+ const handleCleanup = () => {
+ if (uploadedImageIdRef.current) {
+ fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
+ }
+ };
+
+ const handleGlobalClick = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ const link = target.closest('a[href]') || target.closest('[data-navigate]');
+
+ if (link && uploadedImageIdRef.current) {
+ const href = link.getAttribute('href') || link.getAttribute('data-navigate');
+ if (href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
+ event.preventDefault();
+ event.stopPropagation();
+ handleNavigation(href);
+ }
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ document.addEventListener('click', handleGlobalClick, true);
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ document.removeEventListener('click', handleGlobalClick, true);
+ handleCleanup();
+ };
+ }, [handleNavigation]);
+
+ useEffect(() => {
+ if (!file) {
+ setPreview(null);
+ return;
+ }
+ const url = URL.createObjectURL(file);
+ setPreview(url);
+ return () => URL.revokeObjectURL(url);
+ }, [file]);
+
+ // Handle contribute parameter - fetch images from database
+ useEffect(() => {
+ const contribute = searchParams.get('contribute');
+ const imageIds = searchParams.get('imageIds');
+
+ if (contribute === 'true' && imageIds) {
+ const ids = imageIds.split(',').filter(id => id.trim());
+ if (ids.length > 0) {
+ fetchContributedImages(ids);
+ }
+ }
+ }, [searchParams]);
+
+ // Reset carousel index when entering step 2b
+ useEffect(() => {
+ if (step === '2b') {
+ setCurrentImageIndex(0);
+ }
+ }, [step]);
+
+ // Render
return (
{step !== 3 && (
- {/* Drop-zone */}
- {step === 1 && !searchParams.get('step') && (
-
-
- This app evaluates how well multimodal AI models analyze and describe
- crisis maps and drone imagery. Upload an image and the AI will generate a description.
- Then you can review and rate the result based on your expertise.
-
-
- {/* "More »" link */}
-
-
- {/* Image Type Selection */}
-
-
- handleImageTypeChange(value as string)}
- options={[
- { key: 'crisis_map', label: 'Crisis Maps' },
- { key: 'drone_image', label: 'Drone Imagery' }
- ]}
- keySelector={(o) => o.key}
- labelSelector={(o) => o.label}
- />
-
-
-
-
e.preventDefault()}
- onDrop={onDrop}
- >
- {file && preview ? (
-
-
-
-
-
- {file.name}
-
-
- {(file.size / 1024 / 1024).toFixed(2)} MB
-
-
- ) : (
- <>
-
-
Drag & Drop any file here
-
or
- >
- )}
-
-
- onFileChange(e.target.files?.[0])}
- />
- (document.querySelector('input[type="file"]') as HTMLInputElement)?.click()}
- >
- {file ? 'Change File' : 'Browse Files'}
-
-
-
-
- )}
+ {/* Step 1: File Upload */}
+ {step === 1 && !searchParams.get('step') && !isLoadingContribution && (
+
+ )}
+
+ {/* Step 1: Contributed Images Display */}
+ {step === 1 && searchParams.get('contribute') === 'true' && !isLoadingContribution && files.length > 0 && (
+
+ )}
+ {/* Loading States */}
{isLoading && (
@@ -1019,19 +1099,20 @@ export default function UploadPage() {
)}
- {step === 1 && !isLoading && (
+ {/* Generate Button */}
+ {((step === 1 && !isLoading && !isLoadingContribution) || (step === 1 && searchParams.get('contribute') === 'true' && !isLoading && !isLoadingContribution && files.length > 0)) && (
{imageUrl ? (
Generate Caption
) : (
Generate
@@ -1040,205 +1121,67 @@ export default function UploadPage() {
)}
+ {/* Step 2A: Metadata */}
{step === '2a' && (
-
-
-
-
-
-
- setIsFullSizeModalOpen(true)}
- >
- View Image
-
-
-
-
+
{
+ setSelectedImageData(imageData || null);
+ setIsFullSizeModalOpen(true);
+ }}
+ />
-
-
-
- setTitle(value || '')}
- placeholder="Enter a title for this map..."
- required
- />
-
- {imageType !== 'drone_image' && (
-
o.s_code}
- labelSelector={(o) => o.label}
- required
- />
- )}
- o.t_code}
- labelSelector={(o) => o.label}
- required={imageType !== 'drone_image'}
- />
- o.epsg}
- labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
- placeholder="EPSG"
- required={imageType !== 'drone_image'}
- />
- o.image_type}
- labelSelector={(o) => o.label}
- required
- />
- o.c_code}
- labelSelector={(o) => o.label}
- placeholder="Select one or more"
+ setTitle(value || '')}
+ onSourceChange={handleSourceChange}
+ onEventTypeChange={handleEventTypeChange}
+ onEpsgChange={handleEpsgChange}
+ onCountriesChange={handleCountriesChange}
+ onCenterLonChange={handleCenterLonChange}
+ onCenterLatChange={handleCenterLatChange}
+ onAmslMChange={handleAmslMChange}
+ onAglMChange={handleAglMChange}
+ onHeadingDegChange={handleHeadingDegChange}
+ onYawDegChange={handleYawDegChange}
+ onPitchDegChange={handlePitchDegChange}
+ onRollDegChange={handleRollDegChange}
+ onRtkFixChange={handleRtkFixChange}
+ onStdHMChange={handleStdHMChange}
+ onStdVMChange={handleStdVMChange}
+ onImageTypeChange={handleImageTypeChange}
+ updateMetadataForImage={updateMetadataForImage}
/>
-
- {imageType === 'drone_image' && (
- <>
-
-
Drone Flight Data
-
-
-
-
-
-
-
-
-
-
-
- handleRtkFixChange(e.target.checked)}
- className={styles.rtkFixCheckbox}
- />
- RTK Fix Available
-
-
-
-
-
-
- >
- )}
-
{
- if (imageUrl && !uploadedImageId) {
- await handleGenerateFromUrl();
- } else if (imageUrl && !file) {
- handleStepChange('2b');
- } else {
- handleStepChange('2b');
- }
- }}
+ onClick={() => handleStepChange('2b')}
>
Next
-
)}
+ {/* Step 2B: Rating and Generated Text */}
{step === '2b' && (
- {/* Top Row - Image and Rating horizontally aligned */}
-
+
-
-
-
-
-
-
- setIsFullSizeModalOpen(true)}
- >
- View Image
-
-
-
-
-
-
- {/* Rating Section */}
-
-
-
- {!isPerformanceConfirmed && (
- <>
-
How well did the AI perform on the task?
- {(['accuracy', 'context', 'usability'] as const).map((k) => (
-
- {k}
-
- setScores((s) => ({ ...s, [k]: Number(e.target.value) }))
- }
- className={styles.ratingInput}
- />
- {scores[k]}
-
- ))}
-
- setIsPerformanceConfirmed(true)}
- >
- Confirm Ratings
-
-
- >
- )}
- {isPerformanceConfirmed && (
-
- setIsPerformanceConfirmed(false)}
- >
- Edit Ratings
-
-
- )}
-
-
-
-
-
- {/* Bottom Row - Generated Text spanning full width */}
-
-
-
-
- {/* ────── SUBMIT BUTTONS ────── */}
-
- handleStepChange('2a')}
- >
- Back
-
-
-
-
-
- Submit
-
-
-
-
-
- )}
-
- {/* Full Size Image Modal */}
- {isFullSizeModalOpen && (
-
setIsFullSizeModalOpen(false)}>
-
e.stopPropagation()}>
-
- setIsFullSizeModalOpen(false)}
- >
- ✕
-
-
-
-
-
-
-
- )}
-
- {/* Rating Confirmation Warning Modal */}
- {showRatingWarning && (
-
setShowRatingWarning(false)}>
-
e.stopPropagation()}>
-
-
Please Confirm Your Ratings
-
- You must confirm your performance ratings before submitting. Please go back to the rating section and click "Confirm Ratings".
-
-
- setShowRatingWarning(false)}
- >
- Close
-
-
-
-
-
- )}
-
- {/* Delete Confirmation Modal */}
- {showDeleteConfirm && (
-
setShowDeleteConfirm(false)}>
-
e.stopPropagation()}>
-
-
Delete Image?
-
- This action cannot be undone. Are you sure you want to delete this uploaded image?
-
-
-
- Delete
-
- setShowDeleteConfirm(false)}
- >
- Cancel
-
-
-
-
- )}
- {/* Navigation Confirmation Modal */}
- {showNavigationConfirm && (
-
setShowNavigationConfirm(false)}>
-
e.stopPropagation()}>
-
-
Leave Page?
-
- Your uploaded image will be deleted if you leave this page. Are you sure you want to continue?
-
-
-
- Leave Page
-
- setShowNavigationConfirm(false)}
- >
- Stay
-
-
-
-
-
- )}
-
- {/* Model Fallback Notification Modal */}
- {showFallbackNotification && fallbackInfo && (
-
setShowFallbackNotification(false)}>
-
e.stopPropagation()}>
-
-
Model Changed
-
- {fallbackInfo.originalModel} is currently unavailable.
- We've automatically switched to {fallbackInfo.fallbackModel} to complete your request.
-
-
- setShowFallbackNotification(false)}
- >
- Got it
-
-
-
-
-
- )}
-
- {/* Image Preprocessing Notification Modal */}
- {showPreprocessingNotification && preprocessingInfo && (
-
setShowPreprocessingNotification(false)}>
-
e.stopPropagation()}>
-
-
File Converted
-
- Your file {preprocessingInfo.original_filename} has been converted from
- {preprocessingInfo.original_mime_type} to
- {preprocessingInfo.processed_mime_type} for optimal processing.
-
- This conversion ensures your file is in the best format for our AI models to analyze.
-
-
- setShowPreprocessingNotification(false)}
- >
- Got it
-
-
-
-
-
- )}
-
- {/* Preprocessing Modal */}
- {showPreprocessingModal && (
-
-
e.stopPropagation()}>
-
-
File Conversion Required
-
- The file you selected will be converted to a web-compatible format (PNG or JPEG).
- This ensures optimal compatibility and processing by our AI models.
-
-
-
- Convert File
-
-
- Cancel
-
-
- {isPreprocessing && (
-
-
{preprocessingProgress}
-
-
- )}
-
-
-
- )}
-
- {/* Unsupported Format Modal */}
- {showUnsupportedFormatModal && unsupportedFile && (
-
setShowUnsupportedFormatModal(false)}>
-
e.stopPropagation()}>
-
-
Unsupported File Format
-
- The file {unsupportedFile.name} is not supported for upload.
-
- Supported formats:
- • Images: JPEG, PNG, TIFF, HEIC, WebP, GIF
- • Documents: PDF (will be converted to image)
-
- Recommendation: Convert your file to JPEG or PNG format for best compatibility.
-
-
-
setShowUnsupportedFormatModal(false)}
- >
- Got it
-
+ {!isPerformanceConfirmed && (
+
+ setScores(prev => ({ ...prev, [key]: value }))}
+ onConfirmRatings={() => setIsPerformanceConfirmed(true)}
+ onEditRatings={() => setIsPerformanceConfirmed(false)}
+ />
-
+ )}
-
- )}
- {/* File Size Warning Modal */}
- {showFileSizeWarningModal && oversizedFile && (
-
setShowFileSizeWarningModal(false)}>
-
e.stopPropagation()}>
-
-
File Size Warning
-
- The file {oversizedFile.name} is large ({(oversizedFile.size / (1024 * 1024)).toFixed(1)}MB).
-
- Warning: This file size might exceed the limits of the AI models we use.
-
- You can still proceed, but consider using a smaller file if you encounter issues.
-
-
- setShowFileSizeWarningModal(false)}
- >
- Continue Anyway
-
-
-
+
+ setDescription(value || '')}
+ onAnalysisChange={(value) => setAnalysis(value || '')}
+ onRecommendedActionsChange={(value) => setRecommendedActions(value || '')}
+ onBack={() => handleStepChange('2a')}
+ onDelete={handleDelete}
+ onSubmit={handleSubmit}
+ onEditRatings={() => setIsPerformanceConfirmed(false)}
+ isPerformanceConfirmed={isPerformanceConfirmed}
+ />
)}
-
)}
@@ -1676,27 +1264,88 @@ export default function UploadPage() {
Saved!
- {searchParams.get('isContribution') === 'true'
+ {searchParams.get('contribute') === 'true'
? 'Your contribution has been successfully saved.'
: 'Your caption has been successfully saved.'
}
{
- if (searchParams.get('isContribution') === 'true') {
- navigate('/explore');
- } else {
resetToStep1();
- }
}}
>
- {searchParams.get('isContribution') === 'true' ? 'Back to Explore' : 'Upload Another'}
+ Upload Another
)}
+
+ {/* Modals */}
+
{
+ setIsFullSizeModalOpen(false);
+ setSelectedImageData(null);
+ }}
+ />
+
+ setShowRatingWarning(false)}
+ />
+
+ setShowDeleteConfirm(false)}
+ />
+
+ setShowNavigationConfirm(false)}
+ />
+
+ setShowFallbackNotification(false)}
+ />
+
+ setShowPreprocessingNotification(false)}
+ />
+
+
+
+ setShowUnsupportedFormatModal(false)}
+ />
+
+ setShowFileSizeWarningModal(false)}
+ onCancel={() => setShowFileSizeWarningModal(false)}
+ />
+
+
);
}
diff --git a/package-lock.json b/package-lock.json
index 0ee46b33dd1bb2a121f82a74adfdb8e060d13592..9a2e2ae2e4068dca3f263aae4916a70c1148a48a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,8 @@
"": {
"dependencies": {
"@types/react-simple-maps": "^3.0.6",
+ "react-image-crop": "^11.0.10",
+ "react-simple-crop": "^1.0.2",
"react-simple-maps": "^3.0.0"
}
},
@@ -233,30 +235,43 @@
}
},
"node_modules/react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
+ "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
"license": "MIT",
"peer": true,
"dependencies": {
- "loose-envify": "^1.1.0"
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "version": "16.14.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
+ "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "scheduler": "^0.19.1"
},
"peerDependencies": {
- "react": "^18.3.1"
+ "react": "^16.14.0"
+ }
+ },
+ "node_modules/react-image-crop": {
+ "version": "11.0.10",
+ "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz",
+ "integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": ">=16.13.1"
}
},
"node_modules/react-is": {
@@ -266,6 +281,16 @@
"license": "MIT",
"peer": true
},
+ "node_modules/react-simple-crop": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/react-simple-crop/-/react-simple-crop-1.0.2.tgz",
+ "integrity": "sha512-7cKyU8/M+qR1f0mi1Xk8hFw1SWi2F6kiG3rOnDXaq6BtAL0Kx7d5jOs4F26f4ii0xlnZw43K1WB+A6dKfVBW7Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.6",
+ "react-dom": "^16.8.6"
+ }
+ },
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
@@ -284,13 +309,14 @@
}
},
"node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
+ "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
"license": "MIT",
"peer": true,
"dependencies": {
- "loose-envify": "^1.1.0"
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
}
},
"node_modules/topojson-client": {
diff --git a/package.json b/package.json
index fe1214acb2e517cf139ea7d70ce3c3130321e53c..23fdffa35da144e854800e1335baf45302ac6d49 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,8 @@
{
"dependencies": {
"@types/react-simple-maps": "^3.0.6",
+ "react-image-crop": "^11.0.10",
+ "react-simple-crop": "^1.0.2",
"react-simple-maps": "^3.0.0"
}
}
diff --git a/py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py b/py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ed436dce7562d3b07e8edc4974e7e2228fd40f1
--- /dev/null
+++ b/py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py
@@ -0,0 +1,40 @@
+"""Add UNKNOWN values to lookup tables
+
+Revision ID: 0017
+Revises: 0016
+Create Date: 2024-01-01 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '0017'
+down_revision = '0016'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # Add UNKNOWN value to sources table
+ op.execute("""
+ INSERT INTO sources (s_code, label) VALUES ('UNKNOWN', 'Unknown')
+ ON CONFLICT (s_code) DO NOTHING
+ """)
+
+ # Add UNKNOWN value to event_types table
+ op.execute("""
+ INSERT INTO event_types (t_code, label) VALUES ('UNKNOWN', 'Unknown')
+ ON CONFLICT (t_code) DO NOTHING
+ """)
+
+ # Add UNKNOWN value to spatial_references table
+ op.execute("""
+ INSERT INTO spatial_references (epsg, srid, proj4, wkt) VALUES ('UNKNOWN', 'UNKNOWN', 'UNKNOWN', 'UNKNOWN')
+ ON CONFLICT (epsg) DO NOTHING
+ """)
+
+def downgrade():
+ # Remove UNKNOWN values from lookup tables
+ op.execute("DELETE FROM sources WHERE s_code = 'UNKNOWN'")
+ op.execute("DELETE FROM event_types WHERE t_code = 'UNKNOWN'")
+ op.execute("DELETE FROM spatial_references WHERE epsg = 'UNKNOWN'")
diff --git a/py_backend/alembic/versions/0018_add_image_count_to_captions.py b/py_backend/alembic/versions/0018_add_image_count_to_captions.py
new file mode 100644
index 0000000000000000000000000000000000000000..e75ef44d468c2338e28c2518eea832e9dade1114
--- /dev/null
+++ b/py_backend/alembic/versions/0018_add_image_count_to_captions.py
@@ -0,0 +1,26 @@
+"""Add image_count to captions table
+
+Revision ID: 0018
+Revises: 0017
+Create Date: 2024-01-01 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0018'
+down_revision = '0017'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add image_count column to captions table with default value of 1
+ op.add_column('captions', sa.Column('image_count', sa.Integer(), nullable=True, server_default='1'))
+
+
+def downgrade() -> None:
+ # Remove image_count column from captions table
+ op.drop_column('captions', 'image_count')
diff --git a/py_backend/app/crud.py b/py_backend/app/crud.py
index cad50dd23eb34bce4d1f545176d1ec2e2fa7f1e2..d3431a240c88b88fc691e44c89499daced1893ef 100644
--- a/py_backend/app/crud.py
+++ b/py_backend/app/crud.py
@@ -67,7 +67,7 @@ def get_images(db: Session):
db.query(models.Images)
.options(
joinedload(models.Images.countries),
- joinedload(models.Images.captions),
+ joinedload(models.Images.captions).joinedload(models.Captions.images),
)
.all()
)
@@ -78,13 +78,13 @@ def get_image(db: Session, image_id: str):
db.query(models.Images)
.options(
joinedload(models.Images.countries),
- joinedload(models.Images.captions),
+ joinedload(models.Images.captions).joinedload(models.Captions.images),
)
.filter(models.Images.image_id == image_id)
.first()
)
-def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
+def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None, image_count=None):
print(f"Creating caption for image_id: {image_id}")
print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
print(f"Database session ID: {id(db)}")
@@ -109,7 +109,8 @@ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, t
schema_id=schema_id,
raw_json=raw_json,
generated=text,
- edited=text
+ edited=text,
+ image_count=image_count
)
db.add(caption)
diff --git a/py_backend/app/models.py b/py_backend/app/models.py
index 29cf3fd00642f60ead54c50103c44f06d228c3fd..a999129b04d7b7da749936e73c97f04e90eb2bb2 100644
--- a/py_backend/app/models.py
+++ b/py_backend/app/models.py
@@ -1,6 +1,6 @@
from sqlalchemy import (
Column, String, DateTime, SmallInteger, Table, ForeignKey, Boolean,
- CheckConstraint, UniqueConstraint, Text
+ CheckConstraint, UniqueConstraint, Text, Integer
)
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR, JSONB
from sqlalchemy.orm import relationship
@@ -160,6 +160,7 @@ class Captions(Base):
context = Column(SmallInteger)
usability = Column(SmallInteger)
starred = Column(Boolean, default=False)
+ image_count = Column(Integer, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
diff --git a/py_backend/app/routers/caption.py b/py_backend/app/routers/caption.py
index b84918a0e60d38d770c7a0e86309fa84b1829fe1..1ea4961d42c6d76636a2817b45ee8de871a8dc69 100644
--- a/py_backend/app/routers/caption.py
+++ b/py_backend/app/routers/caption.py
@@ -124,6 +124,7 @@ async def create_caption(
print(f"Using metadata instructions: '{metadata_instructions[:100]}...'")
try:
+ print(f"DEBUG: About to call VLM service with model_name: {model_name}")
if hasattr(storage, 's3') and settings.STORAGE_PROVIDER != "local":
response = storage.s3.get_object(
Bucket=settings.S3_BUCKET,
@@ -159,6 +160,9 @@ async def create_caption(
db_session=db,
)
+ print(f"DEBUG: VLM service result: {result}")
+ print(f"DEBUG: Result model field: {result.get('model', 'NOT_FOUND')}")
+
# Get the raw response for validation
raw = result.get("raw_response", {})
@@ -184,6 +188,13 @@ async def create_caption(
# Use the actual model that was used, not the requested model_name
used_model = result.get("model", model_name) or "STUB_MODEL"
+ # Ensure we never use 'random' as the model name in the database
+ if used_model == "random":
+ print(f"WARNING: VLM service returned 'random' as model name, using STUB_MODEL fallback")
+ used_model = "STUB_MODEL"
+
+ print(f"DEBUG: Final used_model for database: {used_model}")
+
# Check if fallback was used
fallback_used = result.get("fallback_used", False)
original_model = result.get("original_model", None)
diff --git a/py_backend/app/routers/upload.py b/py_backend/app/routers/upload.py
index 9203a2fe3a6b870c418911f7769d2e72d167860b..b6e54e65acc3f99d94752653001e5c4fe7a041f1 100644
--- a/py_backend/app/routers/upload.py
+++ b/py_backend/app/routers/upload.py
@@ -163,6 +163,101 @@ def list_images(db: Session = Depends(get_db)):
return result
+@router.get("/grouped", response_model=List[schemas.ImageOut])
+def list_images_grouped(db: Session = Depends(get_db)):
+ """Get images grouped by shared captions for multi-upload items"""
+ # Get all captions with their associated images
+ captions = crud.get_all_captions_with_images(db)
+ result = []
+
+ for caption in captions:
+ if not caption.images:
+ continue
+
+ # Determine the effective image count for this caption
+ # Use caption.image_count if available and valid, otherwise infer from linked images
+ effective_image_count = caption.image_count if caption.image_count is not None and caption.image_count > 0 else len(caption.images)
+
+ if effective_image_count > 1:
+ # This is a multi-upload item, group them together
+ first_img = caption.images[0]
+
+ # Combine metadata from all images
+ combined_source = set()
+ combined_event_type = set()
+ combined_epsg = set()
+
+ for img in caption.images:
+ if img.source:
+ combined_source.add(img.source)
+ if img.event_type:
+ combined_event_type.add(img.event_type)
+ if img.epsg:
+ combined_epsg.add(img.epsg)
+
+ # Create a combined image dict using the first image as a template
+ img_dict = convert_image_to_dict(first_img, f"/api/images/{first_img.image_id}/file")
+
+ # Override with combined metadata
+ img_dict["source"] = ", ".join(sorted(list(combined_source))) if combined_source else "OTHER"
+ img_dict["event_type"] = ", ".join(sorted(list(combined_event_type))) if combined_event_type else "OTHER"
+ img_dict["epsg"] = ", ".join(sorted(list(combined_epsg))) if combined_epsg else "OTHER"
+
+ # Update countries to include all unique countries
+ all_countries = []
+ for img in caption.images:
+ for country in img.countries:
+ if not any(c["c_code"] == country.c_code for c in all_countries):
+ all_countries.append({"c_code": country.c_code, "label": country.label, "r_code": country.r_code})
+ img_dict["countries"] = all_countries
+
+ # Add all image IDs for reference
+ img_dict["all_image_ids"] = [str(img.image_id) for img in caption.images]
+ img_dict["image_count"] = effective_image_count # Use the effective count here
+
+ # Also ensure the caption-level fields are correctly set from the main caption
+ img_dict["title"] = caption.title
+ img_dict["prompt"] = caption.prompt
+ img_dict["model"] = caption.model
+ img_dict["schema_id"] = caption.schema_id
+ img_dict["raw_json"] = caption.raw_json
+ img_dict["generated"] = caption.generated
+ img_dict["edited"] = caption.edited
+ img_dict["accuracy"] = caption.accuracy
+ img_dict["context"] = caption.context
+ img_dict["usability"] = caption.usability
+ img_dict["starred"] = caption.starred
+ img_dict["created_at"] = caption.created_at
+ img_dict["updated_at"] = caption.updated_at
+
+ result.append(schemas.ImageOut(**img_dict))
+ else:
+ # For single images, add them as usual
+ # Ensure image_count is explicitly 1 for single uploads
+ for img in caption.images: # Even for single, caption.images will be a list of 1
+ img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
+ img_dict["all_image_ids"] = [str(img.image_id)]
+ img_dict["image_count"] = 1 # Explicitly set to 1
+
+ # Also ensure the caption-level fields are correctly set from the main caption
+ img_dict["title"] = caption.title
+ img_dict["prompt"] = caption.prompt
+ img_dict["model"] = caption.model
+ img_dict["schema_id"] = caption.schema_id
+ img_dict["raw_json"] = caption.raw_json
+ img_dict["generated"] = caption.generated
+ img_dict["edited"] = caption.edited
+ img_dict["accuracy"] = caption.accuracy
+ img_dict["context"] = caption.context
+ img_dict["usability"] = caption.usability
+ img_dict["starred"] = caption.starred
+ img_dict["created_at"] = caption.created_at
+ img_dict["updated_at"] = caption.updated_at
+
+ result.append(schemas.ImageOut(**img_dict))
+
+ return result
+
@router.get("/{image_id}", response_model=schemas.ImageOut)
def get_image(image_id: str, db: Session = Depends(get_db)):
"""Get a single image by ID"""
@@ -176,11 +271,41 @@ def get_image(image_id: str, db: Session = Depends(get_db)):
if not uuid_pattern.match(image_id):
raise HTTPException(400, "Invalid image ID format")
- img = crud.get_image(db, image_id)
+ img = crud.get_image(db, image_id) # This loads captions
if not img:
raise HTTPException(404, "Image not found")
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
+
+ # Enhance img_dict with multi-upload specific fields if applicable
+ if img.captions:
+ # Assuming an image is primarily associated with one "grouping" caption for multi-uploads
+ # We take the first caption and check its linked images
+ main_caption = img.captions[0]
+
+ # Refresh the caption to ensure its images relationship is loaded if not already
+ db.refresh(main_caption)
+
+ if main_caption.images:
+ all_linked_image_ids = [str(linked_img.image_id) for linked_img in main_caption.images]
+ effective_image_count = main_caption.image_count if main_caption.image_count is not None and main_caption.image_count > 0 else len(main_caption.images)
+
+ if effective_image_count > 1:
+ img_dict["all_image_ids"] = all_linked_image_ids
+ img_dict["image_count"] = effective_image_count
+ else:
+ # Even for single images, explicitly set image_count to 1
+ img_dict["image_count"] = 1
+ img_dict["all_image_ids"] = [str(img.image_id)] # Ensure it's an array for consistency
+ else:
+ # If caption has no linked images (shouldn't happen for valid data, but for robustness)
+ img_dict["image_count"] = 1
+ img_dict["all_image_ids"] = [str(img.image_id)]
+ else:
+ # If image has no captions, it's a single image by default
+ img_dict["image_count"] = 1
+ img_dict["all_image_ids"] = [str(img.image_id)]
+
return schemas.ImageOut(**img_dict)
@@ -192,6 +317,8 @@ async def upload_image(
epsg: str = Form(default=""),
image_type: str = Form(default="crisis_map"),
file: UploadFile = Form(...),
+ title: str = Form(default=""),
+ model_name: Optional[str] = Form(default=None),
# Drone-specific fields (optional)
center_lon: Optional[float] = Form(default=None),
center_lat: Optional[float] = Form(default=None),
@@ -301,12 +428,262 @@ async def upload_image(
except Exception as e:
url = f"/api/images/{img.image_id}/file"
+ # Create caption using VLM
+ prompt_obj = crud.get_active_prompt_by_image_type(db, image_type)
+
+ if not prompt_obj:
+ raise HTTPException(400, f"No active prompt found for image type '{image_type}'")
+
+ prompt_text = prompt_obj.label
+ metadata_instructions = prompt_obj.metadata_instructions or ""
+
+ try:
+ from ..services.vlm_service import vlm_manager
+ result = await vlm_manager.generate_caption(
+ image_bytes=processed_content,
+ prompt=prompt_text,
+ metadata_instructions=metadata_instructions,
+ model_name=model_name,
+ db_session=db,
+ )
+
+ raw = result.get("raw_response", {})
+ text = result.get("caption", "")
+ metadata = result.get("metadata", {})
+
+ # Use the actual model that was used by the VLM service
+ actual_model = result.get("model", model_name)
+
+ # Ensure we never use 'random' as the model name in the database
+ final_model_name = actual_model if actual_model != "random" else "STUB_MODEL"
+
+ # Create caption linked to the image
+ caption = crud.create_caption(
+ db,
+ image_id=img.image_id,
+ title=title,
+ prompt=prompt_obj.p_code,
+ model_code=final_model_name,
+ raw_json=raw,
+ text=text,
+ metadata=metadata,
+ image_count=1
+ )
+
+ except Exception as e:
+ print(f"VLM caption generation failed: {str(e)}")
+ # Continue without caption if VLM fails
+
img_dict = convert_image_to_dict(img, url)
# Add preprocessing info to the response
img_dict['preprocessing_info'] = preprocessing_info
result = schemas.ImageOut(**img_dict)
return result
+@router.post("/multi", response_model=schemas.ImageOut)
+async def upload_multiple_images(
+ files: List[UploadFile] = Form(...),
+ source: Optional[str] = Form(default=None),
+ event_type: str = Form(default="OTHER"),
+ countries: str = Form(default=""),
+ epsg: str = Form(default=""),
+ image_type: str = Form(default="crisis_map"),
+ title: str = Form(...),
+ model_name: Optional[str] = Form(default=None),
+ # Drone-specific fields (optional)
+ center_lon: Optional[float] = Form(default=None),
+ center_lat: Optional[float] = Form(default=None),
+ amsl_m: Optional[float] = Form(default=None),
+ agl_m: Optional[float] = Form(default=None),
+ heading_deg: Optional[float] = Form(default=None),
+ yaw_deg: Optional[float] = Form(default=None),
+ pitch_deg: Optional[float] = Form(default=None),
+ roll_deg: Optional[float] = Form(default=None),
+ rtk_fix: Optional[bool] = Form(default=None),
+ std_h_m: Optional[float] = Form(default=None),
+ std_v_m: Optional[float] = Form(default=None),
+ db: Session = Depends(get_db)
+):
+ """Upload multiple images and create a single caption for all of them"""
+
+ if len(files) > 5:
+ raise HTTPException(400, "Maximum 5 images allowed")
+
+ if len(files) < 1:
+ raise HTTPException(400, "At least one image required")
+
+ countries_list = [c.strip() for c in countries.split(',') if c.strip()] if countries else []
+
+ if image_type == "drone_image":
+ if not event_type or event_type.strip() == "":
+ event_type = "OTHER"
+ if not epsg or epsg.strip() == "":
+ epsg = "OTHER"
+ else:
+ if not source or source.strip() == "":
+ source = "OTHER"
+ if not event_type or event_type.strip() == "":
+ event_type = "OTHER"
+ if not epsg or epsg.strip() == "":
+ epsg = "OTHER"
+
+ if not image_type or image_type.strip() == "":
+ image_type = "crisis_map"
+
+ if image_type != "drone_image":
+ center_lon = None
+ center_lat = None
+ amsl_m = None
+ agl_m = None
+ heading_deg = None
+ yaw_deg = None
+ pitch_deg = None
+ roll_deg = None
+ rtk_fix = None
+ std_h_m = None
+ std_v_m = None
+
+ uploaded_images = []
+ image_bytes_list = []
+
+ # Process each file
+ for file in files:
+ content = await file.read()
+
+ # Preprocess image if needed
+ try:
+ processed_content, processed_filename, mime_type = ImagePreprocessor.preprocess_image(
+ content,
+ file.filename,
+ target_format='PNG',
+ quality=95
+ )
+ except Exception as e:
+ print(f"Image preprocessing failed: {str(e)}")
+ processed_content = content
+ processed_filename = file.filename
+ mime_type = 'image/png'
+
+ sha = crud.hash_bytes(processed_content)
+ key = storage.upload_fileobj(io.BytesIO(processed_content), processed_filename)
+
+ # Create image record
+ img = crud.create_image(
+ db, source, event_type, key, sha, countries_list, epsg, image_type,
+ center_lon, center_lat, amsl_m, agl_m, heading_deg, yaw_deg, pitch_deg, roll_deg,
+ rtk_fix, std_h_m, std_v_m
+ )
+
+ uploaded_images.append(img)
+ image_bytes_list.append(processed_content)
+
+ # Get the first image for URL generation (they all share the same metadata)
+ first_img = uploaded_images[0]
+
+ try:
+ url = storage.get_object_url(first_img.file_key)
+ except Exception as e:
+ url = f"/api/images/{first_img.image_id}/file"
+
+ # Create caption for all images
+ # Use the model_name parameter from the request, or let VLM manager choose the best available model
+ prompt_obj = crud.get_active_prompt_by_image_type(db, image_type)
+
+ if not prompt_obj:
+ raise HTTPException(400, f"No active prompt found for image type '{image_type}'")
+
+ prompt_text = prompt_obj.label
+ metadata_instructions = prompt_obj.metadata_instructions or ""
+
+ # Add system instruction for multiple images
+ multi_image_instruction = f"\n\nIMPORTANT: You are analyzing {len(image_bytes_list)} images. Please provide a combined analysis that covers all images together. In your metadata section, provide separate metadata for each image:\n- 'title': ONE shared title for all images\n- 'metadata_images': an object containing individual metadata for each image:\n - 'image1': {{ 'source': 'data source', 'type': 'event type', 'countries': ['country codes'], 'epsg': 'spatial reference' }}\n - 'image2': {{ 'source': 'data source', 'type': 'event type', 'countries': ['country codes'], 'epsg': 'spatial reference' }}\n - etc. for each image\n\nEach image should have its own source, type, countries, and epsg values based on what that specific image shows."
+ metadata_instructions += multi_image_instruction
+
+ try:
+ from ..services.vlm_service import vlm_manager
+ result = await vlm_manager.generate_multi_image_caption(
+ image_bytes_list=image_bytes_list,
+ prompt=prompt_text,
+ metadata_instructions=metadata_instructions,
+ model_name=model_name,
+ db_session=db,
+ )
+
+ raw = result.get("raw_response", {})
+ text = result.get("caption", "")
+ metadata = result.get("metadata", {})
+
+ # Use the actual model that was used by the VLM service
+ actual_model = result.get("model", model_name)
+
+ # Update individual image metadata if VLM provided it
+ metadata_images = metadata.get("metadata_images", {})
+ if metadata_images and isinstance(metadata_images, dict):
+ for i, img in enumerate(uploaded_images):
+ image_key = f"image{i+1}"
+ if image_key in metadata_images:
+ img_metadata = metadata_images[image_key]
+ if isinstance(img_metadata, dict):
+ # Update image with individual metadata
+ img.source = img_metadata.get("source", img.source)
+ img.event_type = img_metadata.get("type", img.event_type)
+ img.epsg = img_metadata.get("epsg", img.epsg)
+ img.countries = img_metadata.get("countries", img.countries)
+
+ # Ensure we never use 'random' as the model name in the database
+ final_model_name = actual_model if actual_model != "random" else "STUB_MODEL"
+
+ # Create caption linked to the first image
+ caption = crud.create_caption(
+ db,
+ image_id=first_img.image_id,
+ title=title,
+ prompt=prompt_obj.p_code,
+ model_code=final_model_name,
+ raw_json=raw,
+ text=text,
+ metadata=metadata,
+ image_count=len(image_bytes_list)
+ )
+
+ # Link caption to all images
+ for img in uploaded_images[1:]:
+ img.captions.append(caption)
+
+ db.commit()
+
+ except Exception as e:
+ print(f"VLM error: {e}")
+ # Create fallback caption
+ fallback_text = f"Analysis of {len(image_bytes_list)} images"
+ caption = crud.create_caption(
+ db,
+ image_id=first_img.image_id,
+ title=title,
+ prompt=prompt_obj.p_code,
+ model_code="FALLBACK",
+ raw_json={"error": str(e), "fallback": True},
+ text=fallback_text,
+ metadata={},
+ image_count=len(image_bytes_list)
+ )
+
+ # Link caption to all images
+ for img in uploaded_images[1:]:
+ img.captions.append(caption)
+
+ db.commit()
+
+ img_dict = convert_image_to_dict(first_img, url)
+
+ # Add all image IDs to the response for multi-image uploads
+ if len(uploaded_images) > 1:
+ img_dict["all_image_ids"] = [str(img.image_id) for img in uploaded_images]
+ img_dict["image_count"] = len(uploaded_images)
+
+ result = schemas.ImageOut(**img_dict)
+ return result
+
@router.post("/copy", response_model=schemas.ImageOut)
async def copy_image_for_contribution(
request: CopyImageRequest,
diff --git a/py_backend/app/schemas.py b/py_backend/app/schemas.py
index bcce70747065ec1d8de3a5bb573743d97b72da4e..df376c582b5e38987ab21e76e8a13f6b576cf8f9 100644
--- a/py_backend/app/schemas.py
+++ b/py_backend/app/schemas.py
@@ -57,6 +57,7 @@ class CaptionOut(BaseModel):
context: Optional[int] = None
usability: Optional[int] = None
starred: bool = False
+ image_count: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -106,6 +107,10 @@ class ImageOut(BaseModel):
rtk_fix: Optional[bool] = None
std_h_m: Optional[float] = None
std_v_m: Optional[float] = None
+
+ # Multi-upload fields
+ all_image_ids: Optional[List[str]] = None
+ image_count: Optional[int] = None
class Config:
from_attributes = True
diff --git a/py_backend/app/services/gemini_service.py b/py_backend/app/services/gemini_service.py
index 372162d4a9eb2aee912109301aeb6a8ebfcf9ae4..35896629e33dadf480ebc6a8a5d8f574d7d14cf3 100644
--- a/py_backend/app/services/gemini_service.py
+++ b/py_backend/app/services/gemini_service.py
@@ -1,5 +1,5 @@
from .vlm_service import VLMService, ModelType
-from typing import Dict, Any
+from typing import Dict, Any, List
import asyncio
import time
import re
@@ -73,4 +73,66 @@ class GeminiService(VLMService):
"recommended_actions": recommended_actions
}
+ async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> Dict[str, Any]:
+ """Generate caption for multiple images using Google Gemini Vision"""
+ instruction = prompt + "\n\n" + metadata_instructions
+
+ # Create content list with instruction and multiple images
+ content = [instruction]
+ for image_bytes in image_bytes_list:
+ image_part = {
+ "mime_type": "image/jpeg",
+ "data": image_bytes,
+ }
+ content.append(image_part)
+
+ start = time.time()
+ response = await asyncio.to_thread(self.model.generate_content, content)
+ elapsed = time.time() - start
+
+ content = getattr(response, "text", None) or ""
+
+ cleaned_content = content
+ if cleaned_content.startswith("```json"):
+ cleaned_content = re.sub(r"^```json\s*", "", cleaned_content)
+ cleaned_content = re.sub(r"\s*```$", "", cleaned_content)
+
+ try:
+ parsed = json.loads(cleaned_content)
+ description = parsed.get("description", "")
+ analysis = parsed.get("analysis", "")
+ recommended_actions = parsed.get("recommended_actions", "")
+ metadata = parsed.get("metadata", {})
+
+ # Combine all three parts for backward compatibility
+ caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
+
+ if metadata.get("epsg"):
+ epsg_value = metadata["epsg"]
+ allowed_epsg = ["4326", "3857", "32617", "32633", "32634", "OTHER"]
+ if epsg_value not in allowed_epsg:
+ metadata["epsg"] = "OTHER"
+ except json.JSONDecodeError:
+ description = ""
+ analysis = content
+ recommended_actions = ""
+ caption_text = content
+ metadata = {}
+
+ raw_response: Dict[str, Any] = {
+ "model": self.model_id,
+ "image_count": len(image_bytes_list)
+ }
+
+ return {
+ "caption": caption_text,
+ "metadata": metadata,
+ "confidence": None,
+ "processing_time": elapsed,
+ "raw_response": raw_response,
+ "description": description,
+ "analysis": analysis,
+ "recommended_actions": recommended_actions
+ }
+
diff --git a/py_backend/app/services/gpt4v_service.py b/py_backend/app/services/gpt4v_service.py
index c6e2acf11f7d9ca66043ae26662d00b79b1fa5ac..37d583bfbe6619efa9b69295ad2fdcdd4ec1da8d 100644
--- a/py_backend/app/services/gpt4v_service.py
+++ b/py_backend/app/services/gpt4v_service.py
@@ -1,5 +1,5 @@
from .vlm_service import VLMService, ModelType
-from typing import Dict, Any
+from typing import Dict, Any, List
import openai
import base64
import asyncio
@@ -90,5 +90,89 @@ class GPT4VService(VLMService):
"recommended_actions": recommended_actions
}
+ except Exception as e:
+ raise Exception(f"GPT-4 Vision API error: {str(e)}")
+
+ async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> Dict[str, Any]:
+ """Generate caption for multiple images using GPT-4 Vision"""
+ try:
+ # Create content array with text and multiple images
+ content = [{"type": "text", "text": prompt + "\n\n" + metadata_instructions}]
+
+ # Add each image to the content
+ for i, image_bytes in enumerate(image_bytes_list):
+ image_base64 = base64.b64encode(image_bytes).decode('utf-8')
+ content.append({
+ "type": "image_url",
+ "image_url": {
+ "url": f"data:image/jpeg;base64,{image_base64}"
+ }
+ })
+
+ response = await asyncio.to_thread(
+ self.client.chat.completions.create,
+ model="gpt-4o",
+ messages=[
+ {
+ "role": "user",
+ "content": content
+ }
+ ],
+ max_tokens=1200 # Increased for multiple images
+ )
+
+ content = response.choices[0].message.content
+
+ cleaned_content = content.strip()
+ if cleaned_content.startswith("```json"):
+ cleaned_content = cleaned_content[7:]
+ if cleaned_content.endswith("```"):
+ cleaned_content = cleaned_content[:-3]
+ cleaned_content = cleaned_content.strip()
+
+ metadata = {}
+ try:
+ metadata = json.loads(cleaned_content)
+ except json.JSONDecodeError:
+ if "```json" in content:
+ json_start = content.find("```json") + 7
+ json_end = content.find("```", json_start)
+ if json_end > json_start:
+ json_str = content[json_start:json_end].strip()
+ try:
+ metadata = json.loads(json_str)
+ except json.JSONDecodeError as e:
+ print(f"JSON parse error: {e}")
+ else:
+ import re
+ json_match = re.search(r'\{[^{}]*"metadata"[^{}]*\{[^{}]*\}', content)
+ if json_match:
+ try:
+ metadata = json.loads(json_match.group())
+ except json.JSONDecodeError:
+ pass
+
+ # Extract the three parts from the parsed JSON
+ description = metadata.get("description", "")
+ analysis = metadata.get("analysis", "")
+ recommended_actions = metadata.get("recommended_actions", "")
+
+ # Combine all three parts for backward compatibility
+ combined_content = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
+
+ return {
+ "caption": combined_content,
+ "raw_response": {
+ "content": content,
+ "metadata": metadata,
+ "extracted_metadata": metadata,
+ "image_count": len(image_bytes_list)
+ },
+ "metadata": metadata,
+ "description": description,
+ "analysis": analysis,
+ "recommended_actions": recommended_actions
+ }
+
except Exception as e:
raise Exception(f"GPT-4 Vision API error: {str(e)}")
\ No newline at end of file
diff --git a/py_backend/app/services/huggingface_service.py b/py_backend/app/services/huggingface_service.py
index 0737178d4a5c85a5a054234fa2528c20a5dbbc4f..1021f7472f08c70c6697a913de454bf0e8eec4ba 100644
--- a/py_backend/app/services/huggingface_service.py
+++ b/py_backend/app/services/huggingface_service.py
@@ -1,33 +1,35 @@
# services/huggingface_service.py
from .vlm_service import VLMService, ModelType
-from typing import Dict, Any
+from typing import Dict, Any, List
import aiohttp
import base64
-import json
import time
import re
+import json
import imghdr
class HuggingFaceService(VLMService):
"""
- Hugging Face Inference Providers (OpenAI-compatible) service.
- This class speaks to https://router.huggingface.co/v1/chat/completions
- so you can call many VLMs with the same payload shape.
+ HuggingFace Inference Providers service implementation.
+ Supports OpenAI-compatible APIs.
"""
-
- def __init__(self, api_key: str, model_id: str = "Qwen/Qwen2.5-VL-7B-Instruct"):
- super().__init__(f"HF_{model_id.replace('/', '_')}", ModelType.CUSTOM)
+
+ def __init__(self, api_key: str, model_id: str, providers_url: str):
+ super().__init__("HuggingFace", ModelType.HUGGINGFACE)
self.api_key = api_key
self.model_id = model_id
- self.providers_url = "https://router.huggingface.co/v1/chat/completions"
+ self.providers_url = providers_url
+ self.model_name = model_id
def _guess_mime(self, image_bytes: bytes) -> str:
kind = imghdr.what(None, h=image_bytes)
+ if kind == "jpeg":
+ return "image/jpeg"
if kind == "png":
return "image/png"
- if kind in ("jpg", "jpeg"):
- return "image/jpeg"
+ if kind == "gif":
+ return "image/gif"
if kind == "webp":
return "image/webp"
return "image/jpeg"
@@ -127,55 +129,15 @@ class HuggingFaceService(VLMService):
description = parsed.get("description", "")
analysis = parsed.get("analysis", cleaned)
recommended_actions = parsed.get("recommended_actions", "")
- metadata = parsed.get("metadata", {}) or {}
- except json.JSONDecodeError:
- # If not JSON, try to extract metadata from GLM thinking format
- if "" in cleaned:
- analysis, metadata = self._extract_glm_metadata(cleaned)
- else:
- # Fallback: try to extract any structured information
- analysis = cleaned
- metadata = {}
-
- # Combine all three parts for backward compatibility
- caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
-
- # Validate and clean metadata fields with sensible defaults
- if isinstance(metadata, dict):
- # Clean EPSG - default to "OTHER" if not in allowed values
- if metadata.get("epsg"):
- allowed = {"4326", "3857", "32617", "32633", "32634", "OTHER"}
- if str(metadata["epsg"]) not in allowed:
- metadata["epsg"] = "OTHER"
- else:
- metadata["epsg"] = "OTHER" # Default when missing
+ metadata = parsed.get("metadata", {})
- # Clean source - default to "OTHER" if not recognized
- if metadata.get("source"):
- allowed_sources = {"PDC", "GDACS", "WFP", "GFH", "GGC", "USGS", "OTHER"}
- if str(metadata["source"]).upper() not in allowed_sources:
- metadata["source"] = "OTHER"
- else:
- metadata["source"] = "OTHER"
-
- # Clean event type - default to "OTHER" if not recognized
- if metadata.get("type"):
- allowed_types = {"BIOLOGICAL_EMERGENCY", "CHEMICAL_EMERGENCY", "CIVIL_UNREST",
- "COLD_WAVE", "COMPLEX_EMERGENCY", "CYCLONE", "DROUGHT", "EARTHQUAKE",
- "EPIDEMIC", "FIRE", "FLOOD", "FLOOD_INSECURITY", "HEAT_WAVE",
- "INSECT_INFESTATION", "LANDSLIDE", "OTHER", "PLUVIAL",
- "POPULATION_MOVEMENT", "RADIOLOGICAL_EMERGENCY", "STORM",
- "TRANSPORTATION_EMERGENCY", "TSUNAMI", "VOLCANIC_ERUPTION"}
- if str(metadata["type"]).upper() not in allowed_types:
- metadata["type"] = "OTHER"
- else:
- metadata["type"] = "OTHER"
-
- # Ensure countries is always a list
- if not metadata.get("countries") or not isinstance(metadata.get("countries"), list):
- metadata["countries"] = []
+ # Combine all three parts for backward compatibility
+ caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
+ except json.JSONDecodeError:
+ caption_text = cleaned
elapsed = time.time() - start_time
+
return {
"caption": caption_text,
"metadata": metadata,
@@ -183,70 +145,136 @@ class HuggingFaceService(VLMService):
"processing_time": elapsed,
"raw_response": {
"model": self.model_id,
- "response": result,
- "parsed_successfully": bool(metadata),
+ "content": content,
+ "parsed": parsed if 'parsed' in locals() else None
},
"description": description,
"analysis": analysis,
"recommended_actions": recommended_actions
}
- def _extract_glm_metadata(self, content: str) -> tuple[str, dict]:
+ async def generate_multi_image_caption(
+ self,
+ image_bytes_list: List[bytes],
+ prompt: str,
+ metadata_instructions: str = "",
+ ) -> Dict[str, Any]:
"""
- Extract metadata from GLM thinking format using simple, robust patterns.
- Focus on extracting what we can and rely on defaults for the rest.
+ Generate caption for multiple images using HF Inference Providers (OpenAI-style).
"""
- # Remove tags
- content = re.sub(r'| ', '', content)
-
- metadata = {}
-
- # Simple extraction - just look for key patterns, don't overthink it
- # Title: Look for quoted strings after "Maybe" or "Title"
- title_match = re.search(r'(?:Maybe|Title).*?["\']([^"\']{5,50})["\']', content, re.IGNORECASE)
- if title_match:
- metadata["title"] = title_match.group(1).strip()
-
- # Source: Look for common source names (WFP, PDC, etc.)
- source_match = re.search(r'\b(WFP|PDC|GDACS|GFH|GGC|USGS)\b', content, re.IGNORECASE)
- if source_match:
- metadata["source"] = source_match.group(1).upper()
-
- # Type: Look for disaster types
- disaster_types = ["EARTHQUAKE", "FLOOD", "CYCLONE", "DROUGHT", "FIRE", "STORM", "TSUNAMI", "VOLCANIC"]
- for disaster_type in disaster_types:
- if re.search(rf'\b{disaster_type}\b', content, re.IGNORECASE):
- metadata["type"] = disaster_type
- break
-
- # Countries: Look for 2-letter country codes
- country_matches = re.findall(r'\b([A-Z]{2})\b', content)
- valid_countries = []
- for match in country_matches:
- # Basic validation - exclude common false positives
- if match not in ["SO", "IS", "OR", "IN", "ON", "TO", "OF", "AT", "BY", "NO", "GO", "UP", "US"]:
- valid_countries.append(match)
- if valid_countries:
- metadata["countries"] = list(set(valid_countries)) # Remove duplicates
-
- # EPSG: Look for 4-digit numbers that could be EPSG codes
- epsg_match = re.search(r'\b(4326|3857|32617|32633|32634)\b', content)
- if epsg_match:
- metadata["epsg"] = epsg_match.group(1)
+ start_time = time.time()
+
+ instruction = (prompt or "").strip()
+ if metadata_instructions:
+ instruction += "\n\n" + metadata_instructions.strip()
+
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ # Create content array with text and multiple images
+ content = [{"type": "text", "text": instruction}]
- # For caption, just use the first part before metadata discussion
- lines = content.split('\n')
- caption_lines = []
- for line in lines:
- if any(keyword in line.lower() for keyword in ['metadata:', 'now for the metadata', 'let me double-check']):
- break
- caption_lines.append(line)
+ # Add each image to the content
+ for image_bytes in image_bytes_list:
+ mime = self._guess_mime(image_bytes)
+ data_url = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
+ content.append({"type": "image_url", "image_url": {"url": data_url}})
+
+ # OpenAI-compatible chat payload with one text + multiple image blocks.
+ payload = {
+ "model": self.model_id,
+ "messages": [
+ {
+ "role": "user",
+ "content": content,
+ }
+ ],
+ "max_tokens": 800, # Increased for multiple images
+ "temperature": 0.2,
+ }
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ self.providers_url,
+ headers=headers,
+ json=payload,
+ timeout=aiohttp.ClientTimeout(total=180),
+ ) as resp:
+ raw_text = await resp.text()
+ if resp.status != 200:
+ # Any non-200 status - throw generic error for fallback handling
+ raise Exception(f"MODEL_UNAVAILABLE: {self.model_name} is currently unavailable (HTTP {resp.status}). Switching to another model.")
+ result = await resp.json()
+ except Exception as e:
+ if "MODEL_UNAVAILABLE" in str(e):
+ raise # Re-raise model unavailable exceptions as-is
+ # Catch any other errors (network, timeout, parsing, etc.) and treat as model unavailable
+ raise Exception(f"MODEL_UNAVAILABLE: {self.model_name} is currently unavailable due to an error. Switching to another model.")
+
+ # Extract model output (string or list-of-blocks)
+ message = (result.get("choices") or [{}])[0].get("message", {})
+ content = message.get("content", "")
- caption_text = '\n'.join(caption_lines).strip()
- if not caption_text:
- caption_text = content
+ # GLM models sometimes put content in reasoning_content field
+ if not content and message.get("reasoning_content"):
+ content = message.get("reasoning_content", "")
+
+ if isinstance(content, list):
+ # Some providers may return a list of output blocks (e.g., {"type":"output_text","text":...})
+ parts = []
+ for block in content:
+ if isinstance(block, dict):
+ parts.append(block.get("text") or block.get("content") or "")
+ else:
+ parts.append(str(block))
+ content = "\n".join([p for p in parts if p])
+
+ caption = content or ""
+ cleaned = caption.strip()
+
+ # Strip accidental fenced JSON
+ if cleaned.startswith("```json"):
+ cleaned = re.sub(r"^```json\s*", "", cleaned)
+ cleaned = re.sub(r"\s*```$", "", cleaned)
+
+ # Best-effort JSON protocol
+ metadata = {}
+ description = ""
+ analysis = cleaned
+ recommended_actions = ""
- return caption_text, metadata
+ try:
+ parsed = json.loads(cleaned)
+ description = parsed.get("description", "")
+ analysis = parsed.get("analysis", cleaned)
+ recommended_actions = parsed.get("recommended_actions", "")
+ metadata = parsed.get("metadata", {})
+
+ # Combine all three parts for backward compatibility
+ caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
+ except json.JSONDecodeError:
+ caption_text = cleaned
+
+ elapsed = time.time() - start_time
+
+ return {
+ "caption": caption_text,
+ "metadata": metadata,
+ "confidence": None,
+ "processing_time": elapsed,
+ "raw_response": {
+ "model": self.model_id,
+ "content": content,
+ "parsed": parsed if 'parsed' in locals() else None,
+ "image_count": len(image_bytes_list)
+ },
+ "description": description,
+ "analysis": analysis,
+ "recommended_actions": recommended_actions
+ }
# --- Generic Model Wrapper for Dynamic Registration ---
diff --git a/py_backend/app/services/stub_vlm_service.py b/py_backend/app/services/stub_vlm_service.py
index bd95ba82a7e7d79d2745142082682df84f5f207d..dadb78ad0e5e4ae7dd116d163e5e7e813b64ade3 100644
--- a/py_backend/app/services/stub_vlm_service.py
+++ b/py_backend/app/services/stub_vlm_service.py
@@ -1,5 +1,5 @@
from .vlm_service import VLMService, ModelType
-from typing import Dict, Any
+from typing import Dict, Any, List
import asyncio
class StubVLMService(VLMService):
@@ -33,4 +33,35 @@ class StubVLMService(VLMService):
"countries": [],
"epsg": "OTHER"
}
+ }
+
+ async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> dict:
+ """Generate a stub caption for multiple images for testing purposes."""
+ caption = f"This is a stub multi-image caption for testing. Number of images: {len(image_bytes_list)}. Total size: {sum(len(img) for img in image_bytes_list)} bytes. Prompt: {prompt[:50]}..."
+
+ # Create individual metadata for each image
+ metadata_images = {}
+ for i, img_bytes in enumerate(image_bytes_list):
+ metadata_images[f"image{i+1}"] = {
+ "source": "OTHER",
+ "type": "OTHER",
+ "countries": [],
+ "epsg": "OTHER"
+ }
+
+ # Return data in the format expected by schema validator
+ return {
+ "caption": caption,
+ "raw_response": {
+ "stub": True,
+ "analysis": caption,
+ "metadata": {
+ "title": "Stub Multi-Image Generated Title",
+ "metadata_images": metadata_images
+ }
+ },
+ "metadata": {
+ "title": "Stub Multi-Image Generated Title",
+ "metadata_images": metadata_images
+ }
}
\ No newline at end of file
diff --git a/py_backend/app/services/vlm_service.py b/py_backend/app/services/vlm_service.py
index dd0a5e475ef37bdbb287604217e61ce15f279377..ee19b4ee66901c7e47ebcc612c3882beada2bab3 100644
--- a/py_backend/app/services/vlm_service.py
+++ b/py_backend/app/services/vlm_service.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
import logging
from enum import Enum
@@ -65,6 +65,112 @@ class VLMServiceManager:
async def generate_caption(self, image_bytes: bytes, prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
"""Generate caption using the specified model or fallback to available service."""
+ service = None
+ if model_name and model_name != "random":
+ service = self.services.get(model_name)
+ if not service:
+ print(f"Model '{model_name}' not found, using fallback")
+
+ if not service and self.services:
+ # If random is selected or no specific model, choose a random available service
+ if db_session:
+ # Check database availability for random selection
+ try:
+ from .. import crud
+ available_models = crud.get_models(db_session)
+ available_model_codes = [m.m_code for m in available_models if m.is_available]
+
+ print(f"DEBUG: Available models in database: {available_model_codes}")
+ print(f"DEBUG: Registered services: {list(self.services.keys())}")
+
+ # Filter services to only those marked as available in database
+ available_services = [s for s in self.services.values() if s.model_name in available_model_codes]
+
+ print(f"DEBUG: Available services after filtering: {[s.model_name for s in available_services]}")
+ print(f"DEBUG: Service model names: {[s.model_name for s in self.services.values()]}")
+ print(f"DEBUG: Database model codes: {available_model_codes}")
+ print(f"DEBUG: Intersection check: {[s.model_name for s in self.services.values() if s.model_name in available_model_codes]}")
+
+ if available_services:
+ import random
+ import time
+ # Use current time as seed for better randomness
+ random.seed(int(time.time() * 1000000) % 1000000)
+
+ # Shuffle the list first for better randomization
+ shuffled_services = available_services.copy()
+ random.shuffle(shuffled_services)
+
+ service = shuffled_services[0]
+ print(f"Randomly selected service: {service.model_name} (from {len(available_services)} available)")
+ print(f"DEBUG: All available services were: {[s.model_name for s in available_services]}")
+ print(f"DEBUG: Shuffled order: {[s.model_name for s in shuffled_services]}")
+ else:
+ # Fallback to any available service, prioritizing STUB_MODEL
+ print(f"WARNING: No services found in database intersection, using fallback")
+ if "STUB_MODEL" in self.services:
+ service = self.services["STUB_MODEL"]
+ print(f"Using STUB_MODEL fallback service: {service.model_name}")
+ else:
+ service = next(iter(self.services.values()))
+ print(f"Using first available fallback service: {service.model_name}")
+ except Exception as e:
+ print(f"Error checking database availability: {e}, using fallback")
+ if "STUB_MODEL" in self.services:
+ service = self.services["STUB_MODEL"]
+ print(f"Using STUB_MODEL fallback service: {service.model_name}")
+ else:
+ service = next(iter(self.services.values()))
+ print(f"Using fallback service: {service.model_name}")
+ else:
+ # No database session, use service property
+ available_services = [s for s in self.services.values() if s.is_available]
+ if available_services:
+ import random
+ service = random.choice(available_services)
+ print(f"Randomly selected service: {service.model_name}")
+ else:
+ # Fallback to any available service, prioritizing STUB_MODEL
+ if "STUB_MODEL" in self.services:
+ service = self.services["STUB_MODEL"]
+ print(f"Using STUB_MODEL fallback service: {service.model_name}")
+ else:
+ service = next(iter(self.services.values()))
+ print(f"Using fallback service: {service.model_name}")
+
+ if not service:
+ raise Exception("No VLM service available")
+
+ print(f"DEBUG: Selected service for caption generation: {service.model_name}")
+
+ try:
+ print(f"DEBUG: Calling service {service.model_name} for caption generation")
+ result = await service.generate_caption(image_bytes, prompt, metadata_instructions)
+ result["model"] = service.model_name
+ print(f"DEBUG: Service {service.model_name} returned result with model: {result.get('model', 'NOT_FOUND')}")
+ return result
+ except Exception as e:
+ print(f"Error with {service.model_name}: {e}")
+ # Try other services
+ for other_service in self.services.values():
+ if other_service != service:
+ try:
+ result = await other_service.generate_caption(image_bytes, prompt, metadata_instructions)
+ result["model"] = other_service.model_name
+ result["fallback_used"] = True
+ result["original_model"] = service.model_name
+ result["fallback_reason"] = str(e)
+ return result
+ except Exception as fallback_error:
+ print(f"Fallback service {other_service.model_name} also failed: {fallback_error}")
+ continue
+
+ # All services failed
+ raise Exception(f"All VLM services failed. Last error: {str(e)}")
+
+ async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
+ """Generate caption for multiple images using the specified model or fallback to available service."""
+
service = None
if model_name and model_name != "random":
service = self.services.get(model_name)
@@ -118,83 +224,33 @@ class VLMServiceManager:
service = random.choice(available_services)
print(f"Randomly selected service: {service.model_name}")
else:
- # Fallback to any service
service = next(iter(self.services.values()))
print(f"Using fallback service: {service.model_name}")
if not service:
- raise ValueError("No VLM services available")
-
- # Track attempts to avoid infinite loops
- attempted_services = set()
- max_attempts = len(self.services)
-
- while len(attempted_services) < max_attempts:
- try:
- result = await service.generate_caption(image_bytes, prompt, metadata_instructions)
- if isinstance(result, dict):
- result["model"] = service.model_name
- result["fallback_used"] = len(attempted_services) > 0
- if len(attempted_services) > 0:
- result["original_model"] = model_name
- result["fallback_reason"] = "model_unavailable"
- return result
- except Exception as e:
- error_str = str(e)
- print(f"Error with service {service.model_name}: {error_str}")
-
- # Check if it's a model unavailable error (any type of error)
- if "MODEL_UNAVAILABLE" in error_str:
- attempted_services.add(service.model_name)
- print(f"Model {service.model_name} is unavailable, trying another service...")
-
- # Try to find another available service
- if db_session:
- try:
- from .. import crud
- available_models = crud.get_models(db_session)
- available_model_codes = [m.m_code for m in available_models if m.is_available]
-
- # Find next available service that hasn't been attempted
- for next_service in self.services.values():
- if (next_service.model_name in available_model_codes and
- next_service.model_name not in attempted_services):
- service = next_service
- print(f"Switching to fallback service: {service.model_name}")
- break
- else:
- # No more available services, use any untried service
- for next_service in self.services.values():
- if next_service.model_name not in attempted_services:
- service = next_service
- print(f"Using untried service as fallback: {service.model_name}")
- break
- except Exception as db_error:
- print(f"Error checking database availability: {db_error}")
- # Fallback to any untried service
- for next_service in self.services.values():
- if next_service.model_name not in attempted_services:
- service = next_service
- print(f"Using untried service as fallback: {service.model_name}")
- break
- else:
- # No database session, use any untried service
- for next_service in self.services.values():
- if next_service.model_name not in attempted_services:
- service = next_service
- print(f"Using untried service as fallback: {service.model_name}")
- break
-
- if not service:
- raise ValueError("No more VLM services available after model failures")
-
- continue # Try again with new service
- else:
- # Non-model-unavailable error, don't retry
- print(f"Non-model-unavailable error, not retrying: {error_str}")
- raise
+ raise Exception("No VLM service available")
- # If we get here, we've tried all services
- raise ValueError("All VLM services failed due to model unavailability")
+ try:
+ result = await service.generate_multi_image_caption(image_bytes_list, prompt, metadata_instructions)
+ result["model"] = service.model_name
+ return result
+ except Exception as e:
+ print(f"Error with {service.model_name}: {e}")
+ # Try other services
+ for other_service in self.services.values():
+ if other_service != service:
+ try:
+ result = await other_service.generate_multi_image_caption(image_bytes_list, prompt, metadata_instructions)
+ result["model"] = other_service.model_name
+ result["fallback_used"] = True
+ result["original_model"] = service.model_name
+ result["fallback_reason"] = str(e)
+ return result
+ except Exception as fallback_error:
+ print(f"Fallback service {other_service.model_name} also failed: {fallback_error}")
+ continue
+
+ # All services failed
+ raise Exception(f"All VLM services failed. Last error: {str(e)}")
vlm_manager = VLMServiceManager()
\ No newline at end of file
diff --git a/py_backend/fix_image_counts.py b/py_backend/fix_image_counts.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d63facb5b544c6eec84e4d0ac4bf5feaca81e9b
--- /dev/null
+++ b/py_backend/fix_image_counts.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+
+import sys
+import os
+sys.path.append('.')
+
+from app.database import SessionLocal
+from app import crud, models
+
+def fix_image_counts():
+ """Update image_count for all existing captions based on their linked images"""
+ db = SessionLocal()
+
+ try:
+ print("Starting image_count fix for existing multi-uploads...")
+
+ # Get all captions with their linked images
+ captions = crud.get_all_captions_with_images(db)
+ print(f"Found {len(captions)} captions to process")
+
+ updated_count = 0
+ skipped_count = 0
+
+ for caption in captions:
+ # Skip if image_count is already set correctly
+ if caption.image_count is not None and caption.image_count > 0:
+ if caption.image_count == len(caption.images):
+ skipped_count += 1
+ continue
+
+ # Calculate the correct image count
+ correct_image_count = len(caption.images)
+
+ if correct_image_count == 0:
+ print(f"Warning: Caption {caption.caption_id} has no linked images")
+ continue
+
+ # Update the image_count
+ old_count = caption.image_count
+ caption.image_count = correct_image_count
+
+ print(f"Updated caption {caption.caption_id}: {old_count} -> {correct_image_count} (title: '{caption.title}')")
+ updated_count += 1
+
+ # Commit all changes
+ db.commit()
+ print(f"\nDatabase update complete!")
+ print(f"Updated: {updated_count} captions")
+ print(f"Skipped: {skipped_count} captions (already correct)")
+
+ # Verify the changes
+ print("\nVerifying changes...")
+ captions_after = crud.get_all_captions_with_images(db)
+
+ multi_uploads = [c for c in captions_after if c.image_count and c.image_count > 1]
+ single_uploads = [c for c in captions_after if c.image_count == 1]
+ null_counts = [c for c in captions_after if c.image_count is None or c.image_count == 0]
+
+ print(f"Multi-uploads (image_count > 1): {len(multi_uploads)}")
+ print(f"Single uploads (image_count = 1): {len(single_uploads)}")
+ print(f"Captions with null/zero image_count: {len(null_counts)}")
+
+ if null_counts:
+ print("\nCaptions still with null/zero image_count:")
+ for c in null_counts[:5]: # Show first 5
+ print(f" - {c.caption_id}: {len(c.images)} linked images, image_count={c.image_count}")
+
+ except Exception as e:
+ print(f"Error: {e}")
+ import traceback
+ traceback.print_exc()
+ db.rollback()
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ fix_image_counts()