Spaces:
Running
Running
contribute flow & UI refine
Browse files- frontend/src/components/Card.tsx +0 -20
- frontend/src/components/HeaderNav.tsx +51 -24
- frontend/src/pages/DevPage.tsx +5 -5
- frontend/src/pages/ExplorePage/ExplorePage.module.css +13 -10
- frontend/src/pages/ExplorePage/ExplorePage.tsx +152 -176
- frontend/src/pages/MapDetailsPage/MapDetailPage.module.css +7 -4
- frontend/src/pages/MapDetailsPage/MapDetailPage.tsx +201 -126
- frontend/src/pages/UploadPage/UploadPage.module.css +2 -0
- frontend/src/pages/UploadPage/UploadPage.tsx +265 -173
- frontend/src/types.ts +0 -17
- py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py +21 -31
- py_backend/app/crud.py +76 -73
- py_backend/app/database.py +1 -3
- py_backend/app/images.py +15 -0
- py_backend/app/main.py +8 -1
- py_backend/app/models.py +22 -31
- py_backend/app/routers/caption.py +99 -43
- py_backend/app/routers/images.py +95 -0
- py_backend/app/routers/metadata.py +2 -2
- py_backend/app/routers/models.py +1 -1
- py_backend/app/routers/upload.py +101 -25
- py_backend/app/schemas.py +11 -41
- py_backend/app/services/gpt4v_service.py +33 -35
- py_backend/app/services/stub_vlm_service.py +5 -17
- py_backend/app/services/vlm_service.py +17 -27
- py_backend/app/storage.py +121 -12
- py_backend/tests/test_explore_page.py +4 -7
- py_backend/tests/test_upload_flow.py +82 -158
frontend/src/components/Card.tsx
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
|
| 3 |
-
export interface CardProps {
|
| 4 |
-
className?: string
|
| 5 |
-
children: React.ReactNode
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
export default function Card({ children, className = '' }: CardProps) {
|
| 9 |
-
return (
|
| 10 |
-
<div
|
| 11 |
-
className={
|
| 12 |
-
`bg-white rounded-lg shadow p-6 ` +
|
| 13 |
-
className
|
| 14 |
-
}
|
| 15 |
-
>
|
| 16 |
-
{children}
|
| 17 |
-
</div>
|
| 18 |
-
)
|
| 19 |
-
}
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/HeaderNav.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useLocation, useNavigate } from "react-router-dom";
|
| 2 |
-
import { Button, PageContainer } from "@ifrc-go/ui";
|
| 3 |
import {
|
| 4 |
UploadCloudLineIcon,
|
| 5 |
AnalysisIcon,
|
|
@@ -28,46 +28,62 @@ export default function HeaderNav() {
|
|
| 28 |
>
|
| 29 |
<div
|
| 30 |
className="flex items-center gap-4 min-w-0 cursor-pointer group transition-all duration-200 hover:scale-105"
|
| 31 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
>
|
| 33 |
<div className="p-2 rounded-lg bg-gradient-to-br from-ifrcRed/10 to-ifrcRed/20 group-hover:from-ifrcRed/20 group-hover:to-ifrcRed/30 transition-all duration-200">
|
| 34 |
<GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
|
| 35 |
</div>
|
| 36 |
<div className="flex flex-col">
|
| 37 |
<span className="font-bold text-xl text-gray-900 leading-tight">PromptAid Vision</span>
|
| 38 |
-
<span className="text-sm text-gray-500 font-medium">AI-Powered Image Analysis</span>
|
| 39 |
</div>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
-
<nav className="flex items-center space-x-
|
| 43 |
{navItems.map(({ to, label, Icon }) => {
|
| 44 |
-
const isActive = location.pathname === to
|
|
|
|
| 45 |
return (
|
| 46 |
<div key={to} className="relative">
|
| 47 |
-
<
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
if (location.pathname === "/upload") {
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
return;
|
| 61 |
}
|
| 62 |
}
|
| 63 |
navigate(to);
|
| 64 |
}}
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
{isActive && (
|
| 72 |
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-ifrcRed rounded-full animate-pulse"></div>
|
| 73 |
)}
|
|
@@ -81,7 +97,18 @@ export default function HeaderNav() {
|
|
| 81 |
variant="tertiary"
|
| 82 |
size={1}
|
| 83 |
className="transition-all duration-200 hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105"
|
| 84 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
>
|
| 86 |
<QuestionLineIcon className="w-4 h-4" />
|
| 87 |
<span className="inline ml-2 font-semibold">Help & Support</span>
|
|
|
|
| 1 |
import { useLocation, useNavigate } from "react-router-dom";
|
| 2 |
+
import { Button, PageContainer, Container } from "@ifrc-go/ui";
|
| 3 |
import {
|
| 4 |
UploadCloudLineIcon,
|
| 5 |
AnalysisIcon,
|
|
|
|
| 28 |
>
|
| 29 |
<div
|
| 30 |
className="flex items-center gap-4 min-w-0 cursor-pointer group transition-all duration-200 hover:scale-105"
|
| 31 |
+
onClick={() => {
|
| 32 |
+
if (location.pathname === "/upload") {
|
| 33 |
+
if ((window as any).confirmNavigationIfNeeded) {
|
| 34 |
+
(window as any).confirmNavigationIfNeeded('/');
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
navigate('/');
|
| 42 |
+
}}
|
| 43 |
>
|
| 44 |
<div className="p-2 rounded-lg bg-gradient-to-br from-ifrcRed/10 to-ifrcRed/20 group-hover:from-ifrcRed/20 group-hover:to-ifrcRed/30 transition-all duration-200">
|
| 45 |
<GoMainIcon className="h-8 w-8 flex-shrink-0 text-ifrcRed" />
|
| 46 |
</div>
|
| 47 |
<div className="flex flex-col">
|
| 48 |
<span className="font-bold text-xl text-gray-900 leading-tight">PromptAid Vision</span>
|
|
|
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
|
| 52 |
+
<nav className="flex items-center space-x-4 bg-gray-50/80 rounded-xl p-2 backdrop-blur-sm">
|
| 53 |
{navItems.map(({ to, label, Icon }) => {
|
| 54 |
+
const isActive = location.pathname === to ||
|
| 55 |
+
(to === '/explore' && location.pathname.startsWith('/map/'));
|
| 56 |
return (
|
| 57 |
<div key={to} className="relative">
|
| 58 |
+
<Container withInternalPadding className="p-2">
|
| 59 |
+
<Button
|
| 60 |
+
name={label.toLowerCase()}
|
| 61 |
+
variant={isActive ? "primary" : "tertiary"}
|
| 62 |
+
size={1}
|
| 63 |
+
className={`transition-all duration-200 ${
|
| 64 |
+
isActive
|
| 65 |
+
? 'shadow-lg shadow-ifrcRed/20 transform scale-105'
|
| 66 |
+
: 'hover:bg-white hover:shadow-md hover:scale-105'
|
| 67 |
+
}`}
|
| 68 |
+
onClick={() => {
|
| 69 |
if (location.pathname === "/upload") {
|
| 70 |
+
if ((window as any).confirmNavigationIfNeeded) {
|
| 71 |
+
(window as any).confirmNavigationIfNeeded(to);
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 75 |
return;
|
| 76 |
}
|
| 77 |
}
|
| 78 |
navigate(to);
|
| 79 |
}}
|
| 80 |
+
>
|
| 81 |
+
<Icon className={`w-4 h-4 transition-transform duration-200 ${
|
| 82 |
+
isActive ? 'scale-110' : 'group-hover:scale-110'
|
| 83 |
+
}`} />
|
| 84 |
+
<span className="inline ml-2 font-semibold">{label}</span>
|
| 85 |
+
</Button>
|
| 86 |
+
</Container>
|
| 87 |
{isActive && (
|
| 88 |
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-ifrcRed rounded-full animate-pulse"></div>
|
| 89 |
)}
|
|
|
|
| 97 |
variant="tertiary"
|
| 98 |
size={1}
|
| 99 |
className="transition-all duration-200 hover:bg-blue-50 hover:text-blue-600 hover:shadow-md hover:scale-105"
|
| 100 |
+
onClick={() => {
|
| 101 |
+
if (location.pathname === "/upload") {
|
| 102 |
+
if ((window as any).confirmNavigationIfNeeded) {
|
| 103 |
+
(window as any).confirmNavigationIfNeeded('/help');
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
if (!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
navigate('/help');
|
| 111 |
+
}}
|
| 112 |
>
|
| 113 |
<QuestionLineIcon className="w-4 h-4" />
|
| 114 |
<span className="inline ml-2 font-semibold">Help & Support</span>
|
frontend/src/pages/DevPage.tsx
CHANGED
|
@@ -35,7 +35,7 @@ export default function DevPage() {
|
|
| 35 |
}
|
| 36 |
}
|
| 37 |
})
|
| 38 |
-
.catch(
|
| 39 |
|
| 40 |
});
|
| 41 |
};
|
|
@@ -183,10 +183,10 @@ export default function DevPage() {
|
|
| 183 |
onClick={() => {
|
| 184 |
fetch('/api/models')
|
| 185 |
.then(r => r.json())
|
| 186 |
-
.then(
|
| 187 |
alert('Models API response received successfully');
|
| 188 |
})
|
| 189 |
-
.catch(
|
| 190 |
alert('Models API error occurred');
|
| 191 |
});
|
| 192 |
}}
|
|
@@ -202,10 +202,10 @@ export default function DevPage() {
|
|
| 202 |
if (!selectedModel) return;
|
| 203 |
fetch(`/api/models/${selectedModel}/test`)
|
| 204 |
.then(r => r.json())
|
| 205 |
-
.then(
|
| 206 |
alert('Model test completed successfully');
|
| 207 |
})
|
| 208 |
-
.catch(
|
| 209 |
alert('Model test failed');
|
| 210 |
});
|
| 211 |
}}
|
|
|
|
| 35 |
}
|
| 36 |
}
|
| 37 |
})
|
| 38 |
+
.catch(() => {
|
| 39 |
|
| 40 |
});
|
| 41 |
};
|
|
|
|
| 183 |
onClick={() => {
|
| 184 |
fetch('/api/models')
|
| 185 |
.then(r => r.json())
|
| 186 |
+
.then(() => {
|
| 187 |
alert('Models API response received successfully');
|
| 188 |
})
|
| 189 |
+
.catch(() => {
|
| 190 |
alert('Models API error occurred');
|
| 191 |
});
|
| 192 |
}}
|
|
|
|
| 202 |
if (!selectedModel) return;
|
| 203 |
fetch(`/api/models/${selectedModel}/test`)
|
| 204 |
.then(r => r.json())
|
| 205 |
+
.then(() => {
|
| 206 |
alert('Model test completed successfully');
|
| 207 |
})
|
| 208 |
+
.catch(() => {
|
| 209 |
alert('Model test failed');
|
| 210 |
});
|
| 211 |
}}
|
frontend/src/pages/ExplorePage/ExplorePage.module.css
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
.metadataTags {
|
| 2 |
display: flex;
|
| 3 |
flex-wrap: wrap;
|
|
@@ -7,7 +11,7 @@
|
|
| 7 |
|
| 8 |
.metadataTag {
|
| 9 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 10 |
-
background-color: var(--go-ui-color-red-
|
| 11 |
color: var(--go-ui-color-red-90);
|
| 12 |
font-size: var(--go-ui-font-size-xs);
|
| 13 |
border-radius: var(--go-ui-border-radius-md);
|
|
@@ -18,30 +22,29 @@
|
|
| 18 |
}
|
| 19 |
|
| 20 |
.metadataTag:hover {
|
| 21 |
-
background-color: var(--go-ui-color-red-
|
| 22 |
-
|
| 23 |
-
box-shadow: var(--go-ui-box-shadow-xs);
|
| 24 |
}
|
| 25 |
|
| 26 |
.metadataTagSource {
|
| 27 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 28 |
-
background-color: var(--go-ui-color-
|
| 29 |
-
color: var(--go-ui-color-
|
| 30 |
font-size: var(--go-ui-font-size-xs);
|
| 31 |
border-radius: var(--go-ui-border-radius-md);
|
| 32 |
font-weight: var(--go-ui-font-weight-medium);
|
| 33 |
-
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-
|
| 34 |
white-space: nowrap;
|
| 35 |
}
|
| 36 |
|
| 37 |
.metadataTagType {
|
| 38 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 39 |
-
background-color: var(--go-ui-color-red-
|
| 40 |
-
color: var(--go-ui-color-
|
| 41 |
font-size: var(--go-ui-font-size-xs);
|
| 42 |
border-radius: var(--go-ui-border-radius-md);
|
| 43 |
font-weight: var(--go-ui-font-weight-medium);
|
| 44 |
-
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-
|
| 45 |
white-space: nowrap;
|
| 46 |
}
|
| 47 |
|
|
|
|
| 1 |
+
.tabSelector {
|
| 2 |
+
margin-bottom: var(--go-ui-spacing-lg);
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
.metadataTags {
|
| 6 |
display: flex;
|
| 7 |
flex-wrap: wrap;
|
|
|
|
| 11 |
|
| 12 |
.metadataTag {
|
| 13 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 14 |
+
background-color: var(--go-ui-color-red-5);
|
| 15 |
color: var(--go-ui-color-red-90);
|
| 16 |
font-size: var(--go-ui-font-size-xs);
|
| 17 |
border-radius: var(--go-ui-border-radius-md);
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
.metadataTag:hover {
|
| 25 |
+
background-color: var(--go-ui-color-red-10);
|
| 26 |
+
border-color: var(--go-ui-color-red-30);
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
.metadataTagSource {
|
| 30 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 31 |
+
background-color: var(--go-ui-color-red-5);
|
| 32 |
+
color: var(--go-ui-color-red-90);
|
| 33 |
font-size: var(--go-ui-font-size-xs);
|
| 34 |
border-radius: var(--go-ui-border-radius-md);
|
| 35 |
font-weight: var(--go-ui-font-weight-medium);
|
| 36 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
|
| 37 |
white-space: nowrap;
|
| 38 |
}
|
| 39 |
|
| 40 |
.metadataTagType {
|
| 41 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 42 |
+
background-color: var(--go-ui-color-red-5);
|
| 43 |
+
color: var(--go-ui-color-red-90);
|
| 44 |
font-size: var(--go-ui-font-size-xs);
|
| 45 |
border-radius: var(--go-ui-border-radius-md);
|
| 46 |
font-weight: var(--go-ui-font-weight-medium);
|
| 47 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-red-20);
|
| 48 |
white-space: nowrap;
|
| 49 |
}
|
| 50 |
|
frontend/src/pages/ExplorePage/ExplorePage.tsx
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
-
import { PageContainer, TextInput, SelectInput, MultiSelectInput,
|
| 2 |
import { useState, useEffect, useMemo } from 'react';
|
| 3 |
import { useNavigate } from 'react-router-dom';
|
| 4 |
-
import { StarLineIcon } from '@ifrc-go/icons';
|
| 5 |
import styles from './ExplorePage.module.css';
|
| 6 |
|
| 7 |
-
interface
|
| 8 |
-
cap_id: string;
|
| 9 |
image_id: string;
|
| 10 |
title: string;
|
| 11 |
prompt: string;
|
|
@@ -31,19 +29,25 @@ interface CaptionWithImageOut {
|
|
| 31 |
|
| 32 |
export default function ExplorePage() {
|
| 33 |
const navigate = useNavigate();
|
| 34 |
-
const [
|
|
|
|
| 35 |
const [search, setSearch] = useState('');
|
| 36 |
const [srcFilter, setSrcFilter] = useState('');
|
| 37 |
const [catFilter, setCatFilter] = useState('');
|
| 38 |
const [regionFilter, setRegionFilter] = useState('');
|
| 39 |
const [countryFilter, setCountryFilter] = useState('');
|
| 40 |
-
const [showStarredOnly, setShowStarredOnly] = useState(false);
|
| 41 |
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
|
| 42 |
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
|
| 43 |
const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
|
| 44 |
const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
|
|
|
|
| 45 |
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
const fetchCaptions = () => {
|
| 48 |
setIsLoadingFilters(true);
|
| 49 |
fetch('/api/captions')
|
|
@@ -55,10 +59,12 @@ export default function ExplorePage() {
|
|
| 55 |
})
|
| 56 |
.then(data => {
|
| 57 |
if (Array.isArray(data)) {
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
} else {
|
| 61 |
-
|
| 62 |
setCaptions([]);
|
| 63 |
}
|
| 64 |
})
|
|
@@ -107,191 +113,147 @@ export default function ExplorePage() {
|
|
| 107 |
fetch('/api/countries').then(r => {
|
| 108 |
if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
|
| 109 |
return r.json();
|
| 110 |
-
})
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
if (Array.isArray(typesData)) {
|
| 122 |
-
setTypes(typesData);
|
| 123 |
-
} else {
|
| 124 |
-
|
| 125 |
-
setTypes([]);
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
if (Array.isArray(regionsData)) {
|
| 129 |
-
setRegions(regionsData);
|
| 130 |
-
} else {
|
| 131 |
-
|
| 132 |
-
setRegions([]);
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
if (Array.isArray(countriesData)) {
|
| 136 |
-
setCountries(countriesData);
|
| 137 |
-
} else {
|
| 138 |
-
|
| 139 |
-
setCountries([]);
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
setIsLoadingFilters(false);
|
| 143 |
}).catch(() => {
|
| 144 |
-
|
| 145 |
-
setSources([]);
|
| 146 |
-
setTypes([]);
|
| 147 |
-
setRegions([]);
|
| 148 |
-
setCountries([]);
|
| 149 |
setIsLoadingFilters(false);
|
| 150 |
});
|
| 151 |
}, []);
|
| 152 |
|
| 153 |
const filtered = useMemo(() => {
|
| 154 |
-
if (!Array.isArray(captions)) {
|
| 155 |
-
|
| 156 |
-
return [];
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
return captions.filter(c => {
|
| 160 |
-
const
|
| 161 |
-
|
| 162 |
-
c.
|
| 163 |
-
c.source
|
| 164 |
-
c.event_type
|
| 165 |
-
c.title.toLowerCase().includes(searchLower) ||
|
| 166 |
-
(c.edited && c.edited.toLowerCase().includes(searchLower)) ||
|
| 167 |
-
(c.generated && c.generated.toLowerCase().includes(searchLower));
|
| 168 |
|
| 169 |
-
const
|
| 170 |
-
const
|
| 171 |
-
const
|
| 172 |
-
|
| 173 |
-
const
|
|
|
|
| 174 |
|
| 175 |
-
return
|
| 176 |
});
|
| 177 |
}, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]);
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
return (
|
| 180 |
<PageContainer>
|
| 181 |
<Container
|
| 182 |
-
heading="Explore
|
| 183 |
headingLevel={2}
|
| 184 |
withHeaderBorder
|
| 185 |
withInternalPadding
|
| 186 |
className="max-w-7xl mx-auto"
|
| 187 |
>
|
| 188 |
-
<div className=
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
variant="secondary"
|
| 206 |
-
onClick={() => {
|
| 207 |
-
const data = {
|
| 208 |
-
captions: captions,
|
| 209 |
-
filters: {
|
| 210 |
-
sources: sources,
|
| 211 |
-
types: types,
|
| 212 |
-
regions: regions,
|
| 213 |
-
countries: countries
|
| 214 |
-
},
|
| 215 |
-
timestamp: new Date().toISOString()
|
| 216 |
-
};
|
| 217 |
-
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
| 218 |
-
const url = URL.createObjectURL(blob);
|
| 219 |
-
const a = document.createElement('a');
|
| 220 |
-
a.href = url;
|
| 221 |
-
a.download = `promptaid-vision-captions-${new Date().toISOString().split('T')[0]}.json`;
|
| 222 |
-
document.body.appendChild(a);
|
| 223 |
-
a.click();
|
| 224 |
-
document.body.removeChild(a);
|
| 225 |
-
URL.revokeObjectURL(url);
|
| 226 |
-
}}
|
| 227 |
-
>
|
| 228 |
-
Export
|
| 229 |
-
</Button>
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
|
| 233 |
-
|
| 234 |
-
<
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
| 290 |
</div>
|
| 291 |
-
</Container>
|
| 292 |
|
| 293 |
-
|
| 294 |
-
<Container heading="Results" headingLevel={3} withHeaderBorder withInternalPadding>
|
| 295 |
<div className="space-y-4">
|
| 296 |
<div className="flex justify-between items-center">
|
| 297 |
<p className="text-sm text-gray-600">
|
|
@@ -302,7 +264,7 @@ export default function ExplorePage() {
|
|
| 302 |
{/* List */}
|
| 303 |
<div className="space-y-4">
|
| 304 |
{filtered.map(c => (
|
| 305 |
-
<div key={c.
|
| 306 |
<div className={styles.mapItemImage} style={{ width: '120px', height: '80px' }}>
|
| 307 |
{c.image_url ? (
|
| 308 |
<img
|
|
@@ -325,17 +287,24 @@ export default function ExplorePage() {
|
|
| 325 |
<div className={styles.mapItemMetadata}>
|
| 326 |
<div className={styles.metadataTags}>
|
| 327 |
<span className={styles.metadataTagSource}>
|
| 328 |
-
{c.source}
|
| 329 |
</span>
|
| 330 |
<span className={styles.metadataTagType}>
|
| 331 |
-
{c.event_type}
|
| 332 |
-
</span>
|
| 333 |
-
<span className={styles.metadataTag}>
|
| 334 |
-
{c.epsg}
|
| 335 |
</span>
|
| 336 |
<span className={styles.metadataTag}>
|
| 337 |
-
{c.image_type}
|
| 338 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
</div>
|
| 340 |
</div>
|
| 341 |
</div>
|
|
@@ -349,8 +318,15 @@ export default function ExplorePage() {
|
|
| 349 |
)}
|
| 350 |
</div>
|
| 351 |
</div>
|
| 352 |
-
</
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
</Container>
|
| 355 |
</PageContainer>
|
| 356 |
);
|
|
|
|
| 1 |
+
import { PageContainer, TextInput, SelectInput, MultiSelectInput, Container, SegmentInput } from '@ifrc-go/ui';
|
| 2 |
import { useState, useEffect, useMemo } from 'react';
|
| 3 |
import { useNavigate } from 'react-router-dom';
|
|
|
|
| 4 |
import styles from './ExplorePage.module.css';
|
| 5 |
|
| 6 |
+
interface ImageWithCaptionOut {
|
|
|
|
| 7 |
image_id: string;
|
| 8 |
title: string;
|
| 9 |
prompt: string;
|
|
|
|
| 29 |
|
| 30 |
export default function ExplorePage() {
|
| 31 |
const navigate = useNavigate();
|
| 32 |
+
const [view, setView] = useState<'explore' | 'mapDetails'>('explore');
|
| 33 |
+
const [captions, setCaptions] = useState<ImageWithCaptionOut[]>([]);
|
| 34 |
const [search, setSearch] = useState('');
|
| 35 |
const [srcFilter, setSrcFilter] = useState('');
|
| 36 |
const [catFilter, setCatFilter] = useState('');
|
| 37 |
const [regionFilter, setRegionFilter] = useState('');
|
| 38 |
const [countryFilter, setCountryFilter] = useState('');
|
|
|
|
| 39 |
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
|
| 40 |
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
|
| 41 |
const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
|
| 42 |
const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]);
|
| 43 |
+
const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
|
| 44 |
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
| 45 |
|
| 46 |
+
const viewOptions = [
|
| 47 |
+
{ key: 'explore' as const, label: 'Explore' },
|
| 48 |
+
{ key: 'mapDetails' as const, label: 'Map Details' }
|
| 49 |
+
];
|
| 50 |
+
|
| 51 |
const fetchCaptions = () => {
|
| 52 |
setIsLoadingFilters(true);
|
| 53 |
fetch('/api/captions')
|
|
|
|
| 59 |
})
|
| 60 |
.then(data => {
|
| 61 |
if (Array.isArray(data)) {
|
| 62 |
+
const imagesWithCaptions = data.filter((item: any) => {
|
| 63 |
+
const hasCaption = item.title && item.generated && item.model;
|
| 64 |
+
return hasCaption;
|
| 65 |
+
});
|
| 66 |
+
setCaptions(imagesWithCaptions);
|
| 67 |
} else {
|
|
|
|
| 68 |
setCaptions([]);
|
| 69 |
}
|
| 70 |
})
|
|
|
|
| 113 |
fetch('/api/countries').then(r => {
|
| 114 |
if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
|
| 115 |
return r.json();
|
| 116 |
+
}),
|
| 117 |
+
fetch('/api/image-types').then(r => {
|
| 118 |
+
if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
|
| 119 |
+
return r.json();
|
| 120 |
+
}),
|
| 121 |
+
]).then(([sourcesData, typesData, regionsData, countriesData, imageTypesData]) => {
|
| 122 |
+
setSources(sourcesData);
|
| 123 |
+
setTypes(typesData);
|
| 124 |
+
setRegions(regionsData);
|
| 125 |
+
setCountries(countriesData);
|
| 126 |
+
setImageTypes(imageTypesData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}).catch(() => {
|
| 128 |
+
}).finally(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
setIsLoadingFilters(false);
|
| 130 |
});
|
| 131 |
}, []);
|
| 132 |
|
| 133 |
const filtered = useMemo(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
return captions.filter(c => {
|
| 135 |
+
const matchesSearch = !search ||
|
| 136 |
+
c.title?.toLowerCase().includes(search.toLowerCase()) ||
|
| 137 |
+
c.generated?.toLowerCase().includes(search.toLowerCase()) ||
|
| 138 |
+
c.source?.toLowerCase().includes(search.toLowerCase()) ||
|
| 139 |
+
c.event_type?.toLowerCase().includes(search.toLowerCase());
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
const matchesSource = !srcFilter || c.source === srcFilter;
|
| 142 |
+
const matchesCategory = !catFilter || c.event_type === catFilter;
|
| 143 |
+
const matchesRegion = !regionFilter ||
|
| 144 |
+
c.countries.some(country => country.r_code === regionFilter);
|
| 145 |
+
const matchesCountry = !countryFilter ||
|
| 146 |
+
c.countries.some(country => country.c_code === countryFilter);
|
| 147 |
|
| 148 |
+
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry;
|
| 149 |
});
|
| 150 |
}, [captions, search, srcFilter, catFilter, regionFilter, countryFilter]);
|
| 151 |
|
| 152 |
+
const toggleStarred = (imageId: string) => {
|
| 153 |
+
setCaptions(prev => prev.map(c =>
|
| 154 |
+
c.image_id === imageId ? { ...c, starred: !c.starred } : c
|
| 155 |
+
));
|
| 156 |
+
|
| 157 |
+
fetch(`/api/images/${imageId}/caption`, {
|
| 158 |
+
method: 'PUT',
|
| 159 |
+
headers: { 'Content-Type': 'application/json' },
|
| 160 |
+
body: JSON.stringify({ starred: !captions.find(c => c.image_id === imageId)?.starred })
|
| 161 |
+
}).catch(() => {
|
| 162 |
+
setCaptions(prev => prev.map(c =>
|
| 163 |
+
c.image_id === imageId ? { ...c, starred: !c.starred } : c
|
| 164 |
+
));
|
| 165 |
+
});
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
return (
|
| 169 |
<PageContainer>
|
| 170 |
<Container
|
| 171 |
+
heading="Explore"
|
| 172 |
headingLevel={2}
|
| 173 |
withHeaderBorder
|
| 174 |
withInternalPadding
|
| 175 |
className="max-w-7xl mx-auto"
|
| 176 |
>
|
| 177 |
+
<div className={styles.tabSelector}>
|
| 178 |
+
<SegmentInput
|
| 179 |
+
name="explore-view"
|
| 180 |
+
value={view}
|
| 181 |
+
onChange={(value) => {
|
| 182 |
+
if (value === 'explore' || value === 'mapDetails') {
|
| 183 |
+
setView(value);
|
| 184 |
+
if (value === 'mapDetails' && captions.length > 0) {
|
| 185 |
+
navigate(`/map/${captions[0].image_id}`);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}}
|
| 189 |
+
options={viewOptions}
|
| 190 |
+
keySelector={(o) => o.key}
|
| 191 |
+
labelSelector={(o) => o.label}
|
| 192 |
+
/>
|
| 193 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
{view === 'explore' ? (
|
| 196 |
+
<div className="space-y-6">
|
| 197 |
+
{/* Search and Filters */}
|
| 198 |
+
<div className="space-y-4">
|
| 199 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 200 |
+
<TextInput
|
| 201 |
+
name="search"
|
| 202 |
+
placeholder="Search examples..."
|
| 203 |
+
value={search}
|
| 204 |
+
onChange={(v) => setSearch(v || '')}
|
| 205 |
+
/>
|
| 206 |
|
| 207 |
+
<SelectInput
|
| 208 |
+
name="source"
|
| 209 |
+
placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
|
| 210 |
+
options={sources}
|
| 211 |
+
value={srcFilter || null}
|
| 212 |
+
onChange={(v) => setSrcFilter(v as string || '')}
|
| 213 |
+
keySelector={(o) => o.s_code}
|
| 214 |
+
labelSelector={(o) => o.label}
|
| 215 |
+
required={false}
|
| 216 |
+
disabled={isLoadingFilters}
|
| 217 |
+
/>
|
| 218 |
|
| 219 |
+
<SelectInput
|
| 220 |
+
name="category"
|
| 221 |
+
placeholder={isLoadingFilters ? "Loading..." : "All Categories"}
|
| 222 |
+
options={types}
|
| 223 |
+
value={catFilter || null}
|
| 224 |
+
onChange={(v) => setCatFilter(v as string || '')}
|
| 225 |
+
keySelector={(o) => o.t_code}
|
| 226 |
+
labelSelector={(o) => o.label}
|
| 227 |
+
required={false}
|
| 228 |
+
disabled={isLoadingFilters}
|
| 229 |
+
/>
|
| 230 |
|
| 231 |
+
<SelectInput
|
| 232 |
+
name="region"
|
| 233 |
+
placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
|
| 234 |
+
options={regions}
|
| 235 |
+
value={regionFilter || null}
|
| 236 |
+
onChange={(v) => setRegionFilter(v as string || '')}
|
| 237 |
+
keySelector={(o) => o.r_code}
|
| 238 |
+
labelSelector={(o) => o.label}
|
| 239 |
+
required={false}
|
| 240 |
+
disabled={isLoadingFilters}
|
| 241 |
+
/>
|
| 242 |
|
| 243 |
+
<MultiSelectInput
|
| 244 |
+
name="country"
|
| 245 |
+
placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
|
| 246 |
+
options={countries}
|
| 247 |
+
value={countryFilter ? [countryFilter] : []}
|
| 248 |
+
onChange={(v) => setCountryFilter((v as string[])[0] || '')}
|
| 249 |
+
keySelector={(o) => o.c_code}
|
| 250 |
+
labelSelector={(o) => o.label}
|
| 251 |
+
disabled={isLoadingFilters}
|
| 252 |
+
/>
|
| 253 |
+
</div>
|
| 254 |
</div>
|
|
|
|
| 255 |
|
| 256 |
+
{/* Results Section */}
|
|
|
|
| 257 |
<div className="space-y-4">
|
| 258 |
<div className="flex justify-between items-center">
|
| 259 |
<p className="text-sm text-gray-600">
|
|
|
|
| 264 |
{/* List */}
|
| 265 |
<div className="space-y-4">
|
| 266 |
{filtered.map(c => (
|
| 267 |
+
<div key={c.image_id} className={styles.mapItem} onClick={() => navigate(`/map/${c.image_id}`)}>
|
| 268 |
<div className={styles.mapItemImage} style={{ width: '120px', height: '80px' }}>
|
| 269 |
{c.image_url ? (
|
| 270 |
<img
|
|
|
|
| 287 |
<div className={styles.mapItemMetadata}>
|
| 288 |
<div className={styles.metadataTags}>
|
| 289 |
<span className={styles.metadataTagSource}>
|
| 290 |
+
{sources.find(s => s.s_code === c.source)?.label || c.source}
|
| 291 |
</span>
|
| 292 |
<span className={styles.metadataTagType}>
|
| 293 |
+
{types.find(t => t.t_code === c.event_type)?.label || c.event_type}
|
|
|
|
|
|
|
|
|
|
| 294 |
</span>
|
| 295 |
<span className={styles.metadataTag}>
|
| 296 |
+
{imageTypes.find(it => it.image_type === c.image_type)?.label || c.image_type}
|
| 297 |
</span>
|
| 298 |
+
{c.countries && c.countries.length > 0 && (
|
| 299 |
+
<>
|
| 300 |
+
<span className={styles.metadataTag}>
|
| 301 |
+
{regions.find(r => r.r_code === c.countries[0].r_code)?.label || 'Unknown Region'}
|
| 302 |
+
</span>
|
| 303 |
+
<span className={styles.metadataTag}>
|
| 304 |
+
{c.countries.map(country => country.label).join(', ')}
|
| 305 |
+
</span>
|
| 306 |
+
</>
|
| 307 |
+
)}
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
</div>
|
|
|
|
| 318 |
)}
|
| 319 |
</div>
|
| 320 |
</div>
|
| 321 |
+
</div>
|
| 322 |
+
) : (
|
| 323 |
+
<div className="space-y-6">
|
| 324 |
+
<div className="text-center py-12">
|
| 325 |
+
<p className="text-gray-500">Map Details view coming soon...</p>
|
| 326 |
+
<p className="text-sm text-gray-400 mt-2">This will show detailed information about individual maps</p>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
)}
|
| 330 |
</Container>
|
| 331 |
</PageContainer>
|
| 332 |
);
|
frontend/src/pages/MapDetailsPage/MapDetailPage.module.css
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
.backButton {
|
| 2 |
margin-bottom: var(--go-ui-spacing-lg);
|
| 3 |
}
|
|
@@ -43,7 +47,7 @@
|
|
| 43 |
|
| 44 |
.metadataTag {
|
| 45 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 46 |
-
background-color: var(--go-ui-color-red-
|
| 47 |
color: var(--go-ui-color-red-90);
|
| 48 |
font-size: var(--go-ui-font-size-sm);
|
| 49 |
border-radius: var(--go-ui-border-radius-md);
|
|
@@ -53,9 +57,8 @@
|
|
| 53 |
}
|
| 54 |
|
| 55 |
.metadataTag:hover {
|
| 56 |
-
background-color: var(--go-ui-color-red-
|
| 57 |
-
|
| 58 |
-
box-shadow: var(--go-ui-box-shadow-xs);
|
| 59 |
}
|
| 60 |
|
| 61 |
.captionContainer {
|
|
|
|
| 1 |
+
.tabSelector {
|
| 2 |
+
margin-bottom: var(--go-ui-spacing-lg);
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
.backButton {
|
| 6 |
margin-bottom: var(--go-ui-spacing-lg);
|
| 7 |
}
|
|
|
|
| 47 |
|
| 48 |
.metadataTag {
|
| 49 |
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 50 |
+
background-color: var(--go-ui-color-red-5);
|
| 51 |
color: var(--go-ui-color-red-90);
|
| 52 |
font-size: var(--go-ui-font-size-sm);
|
| 53 |
border-radius: var(--go-ui-border-radius-md);
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
.metadataTag:hover {
|
| 60 |
+
background-color: var(--go-ui-color-red-10);
|
| 61 |
+
border-color: var(--go-ui-color-red-30);
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
.captionContainer {
|
frontend/src/pages/MapDetailsPage/MapDetailPage.tsx
CHANGED
|
@@ -1,38 +1,53 @@
|
|
| 1 |
-
import { PageContainer,
|
|
|
|
| 2 |
import { useState, useEffect } from 'react';
|
| 3 |
-
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
| 4 |
import styles from './MapDetailPage.module.css';
|
| 5 |
|
| 6 |
interface MapOut {
|
| 7 |
image_id: string;
|
| 8 |
file_key: string;
|
| 9 |
-
|
| 10 |
source: string;
|
| 11 |
event_type: string;
|
| 12 |
epsg: string;
|
| 13 |
image_type: string;
|
| 14 |
-
|
|
|
|
| 15 |
c_code: string;
|
| 16 |
label: string;
|
| 17 |
r_code: string;
|
| 18 |
}>;
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function MapDetailPage() {
|
| 28 |
const { mapId } = useParams<{ mapId: string }>();
|
| 29 |
const navigate = useNavigate();
|
| 30 |
-
const [
|
| 31 |
-
const captionId = searchParams.get('captionId');
|
| 32 |
const [map, setMap] = useState<MapOut | null>(null);
|
| 33 |
const [loading, setLoading] = useState(true);
|
| 34 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
if (!mapId) {
|
|
@@ -58,14 +73,76 @@ export default function MapDetailPage() {
|
|
| 58 |
});
|
| 59 |
}, [mapId]);
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
if (!map) return;
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
};
|
|
|
|
| 69 |
|
| 70 |
if (loading) {
|
| 71 |
return (
|
|
@@ -103,123 +180,121 @@ export default function MapDetailPage() {
|
|
| 103 |
|
| 104 |
return (
|
| 105 |
<PageContainer>
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
{
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
/>
|
| 131 |
-
) : (
|
| 132 |
-
<div className={styles.imagePlaceholder}>
|
| 133 |
-
No image available
|
| 134 |
-
</div>
|
| 135 |
-
)}
|
| 136 |
-
</div>
|
| 137 |
-
</Container>
|
| 138 |
|
| 139 |
-
{
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
key={index}
|
| 188 |
-
className={`${styles.captionText} ${
|
| 189 |
-
captionId && map.captions && map.captions[index] &&
|
| 190 |
-
'cap_id' in map.captions[index] &&
|
| 191 |
-
map.captions[index].cap_id === captionId ?
|
| 192 |
-
styles.highlightedCaption : ''
|
| 193 |
-
}`}
|
| 194 |
-
>
|
| 195 |
-
<p>{caption.edited || caption.generated}</p>
|
| 196 |
-
{captionId && map.captions && map.captions[index] &&
|
| 197 |
-
'cap_id' in map.captions[index] &&
|
| 198 |
-
map.captions[index].cap_id === captionId && (
|
| 199 |
-
<div className={styles.captionHighlight}>
|
| 200 |
-
← This is the caption you selected
|
| 201 |
</div>
|
|
|
|
|
|
|
| 202 |
)}
|
| 203 |
</div>
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
<p>— no caption yet —</p>
|
| 207 |
-
)}
|
| 208 |
</div>
|
| 209 |
-
</Container>
|
| 210 |
-
</div>
|
| 211 |
-
</div>
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
| 223 |
</PageContainer>
|
| 224 |
);
|
| 225 |
}
|
|
|
|
| 1 |
+
import { PageContainer, Container, Button, Spinner, SegmentInput } from '@ifrc-go/ui';
|
| 2 |
+
import { useParams, useNavigate } from 'react-router-dom';
|
| 3 |
import { useState, useEffect } from 'react';
|
|
|
|
| 4 |
import styles from './MapDetailPage.module.css';
|
| 5 |
|
| 6 |
interface MapOut {
|
| 7 |
image_id: string;
|
| 8 |
file_key: string;
|
| 9 |
+
sha256: string;
|
| 10 |
source: string;
|
| 11 |
event_type: string;
|
| 12 |
epsg: string;
|
| 13 |
image_type: string;
|
| 14 |
+
image_url: string;
|
| 15 |
+
countries: Array<{
|
| 16 |
c_code: string;
|
| 17 |
label: string;
|
| 18 |
r_code: string;
|
| 19 |
}>;
|
| 20 |
+
title?: string;
|
| 21 |
+
prompt?: string;
|
| 22 |
+
model?: string;
|
| 23 |
+
schema_id?: string;
|
| 24 |
+
raw_json?: any;
|
| 25 |
+
generated?: string;
|
| 26 |
+
edited?: string;
|
| 27 |
+
accuracy?: number;
|
| 28 |
+
context?: number;
|
| 29 |
+
usability?: number;
|
| 30 |
+
starred?: boolean;
|
| 31 |
+
created_at?: string;
|
| 32 |
+
updated_at?: string;
|
| 33 |
}
|
| 34 |
|
| 35 |
export default function MapDetailPage() {
|
| 36 |
const { mapId } = useParams<{ mapId: string }>();
|
| 37 |
const navigate = useNavigate();
|
| 38 |
+
const [view, setView] = useState<'explore' | 'mapDetails'>('mapDetails');
|
|
|
|
| 39 |
const [map, setMap] = useState<MapOut | null>(null);
|
| 40 |
const [loading, setLoading] = useState(true);
|
| 41 |
const [error, setError] = useState<string | null>(null);
|
| 42 |
+
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
|
| 43 |
+
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
|
| 44 |
+
const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
|
| 45 |
+
const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]);
|
| 46 |
|
| 47 |
+
const viewOptions = [
|
| 48 |
+
{ key: 'explore' as const, label: 'Explore' },
|
| 49 |
+
{ key: 'mapDetails' as const, label: 'Map Details' }
|
| 50 |
+
];
|
| 51 |
|
| 52 |
useEffect(() => {
|
| 53 |
if (!mapId) {
|
|
|
|
| 73 |
});
|
| 74 |
}, [mapId]);
|
| 75 |
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
Promise.all([
|
| 78 |
+
fetch('/api/sources').then(r => r.json()),
|
| 79 |
+
fetch('/api/types').then(r => r.json()),
|
| 80 |
+
fetch('/api/image-types').then(r => r.json()),
|
| 81 |
+
fetch('/api/regions').then(r => r.json()),
|
| 82 |
+
]).then(([sourcesData, typesData, imageTypesData, regionsData]) => {
|
| 83 |
+
setSources(sourcesData);
|
| 84 |
+
setTypes(typesData);
|
| 85 |
+
setImageTypes(imageTypesData);
|
| 86 |
+
setRegions(regionsData);
|
| 87 |
+
}).catch(console.error);
|
| 88 |
+
}, []);
|
| 89 |
+
|
| 90 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 91 |
+
|
| 92 |
+
const handleContribute = async () => {
|
| 93 |
if (!map) return;
|
| 94 |
|
| 95 |
+
setIsGenerating(true);
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const res = await fetch('/api/contribute/from-url', {
|
| 99 |
+
method: 'POST',
|
| 100 |
+
headers: { 'Content-Type': 'application/json' },
|
| 101 |
+
body: JSON.stringify({
|
| 102 |
+
url: map.image_url,
|
| 103 |
+
source: map.source,
|
| 104 |
+
event_type: map.event_type,
|
| 105 |
+
epsg: map.epsg,
|
| 106 |
+
image_type: map.image_type,
|
| 107 |
+
countries: map.countries.map(c => c.c_code),
|
| 108 |
+
}),
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
if (!res.ok) {
|
| 112 |
+
const errorData = await res.json();
|
| 113 |
+
throw new Error(errorData.error || 'Failed to create contribution');
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const json = await res.json();
|
| 117 |
+
const newId = json.image_id as string;
|
| 118 |
+
|
| 119 |
+
const modelName = localStorage.getItem('selectedVlmModel');
|
| 120 |
+
const capRes = await fetch(`/api/images/${newId}/caption`, {
|
| 121 |
+
method: 'POST',
|
| 122 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 123 |
+
body: new URLSearchParams({
|
| 124 |
+
title: 'Generated Caption',
|
| 125 |
+
prompt: 'Analyze this crisis map and provide a detailed description of the emergency situation, affected areas, and key information shown in the map.',
|
| 126 |
+
...(modelName && { model_name: modelName }),
|
| 127 |
+
}),
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
if (!capRes.ok) {
|
| 131 |
+
const errorData = await capRes.json();
|
| 132 |
+
throw new Error(errorData.error || 'Failed to generate caption');
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const url = `/upload?imageUrl=${encodeURIComponent(json.image_url)}&isContribution=true&step=2a&imageId=${newId}`;
|
| 136 |
+
navigate(url);
|
| 137 |
+
|
| 138 |
+
} catch (error: any) {
|
| 139 |
+
console.error('Contribution failed:', error);
|
| 140 |
+
alert(`Contribution failed: ${error.message || 'Unknown error'}`);
|
| 141 |
+
} finally {
|
| 142 |
+
setIsGenerating(false);
|
| 143 |
+
}
|
| 144 |
};
|
| 145 |
+
|
| 146 |
|
| 147 |
if (loading) {
|
| 148 |
return (
|
|
|
|
| 180 |
|
| 181 |
return (
|
| 182 |
<PageContainer>
|
| 183 |
+
<Container
|
| 184 |
+
heading="Explore"
|
| 185 |
+
headingLevel={2}
|
| 186 |
+
withHeaderBorder
|
| 187 |
+
withInternalPadding
|
| 188 |
+
className="max-w-7xl mx-auto"
|
| 189 |
+
>
|
| 190 |
+
<div className={styles.tabSelector}>
|
| 191 |
+
<SegmentInput
|
| 192 |
+
name="map-details-view"
|
| 193 |
+
value={view}
|
| 194 |
+
onChange={(value) => {
|
| 195 |
+
if (value === 'mapDetails' || value === 'explore') {
|
| 196 |
+
setView(value);
|
| 197 |
+
if (value === 'explore') {
|
| 198 |
+
navigate('/explore');
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}}
|
| 202 |
+
options={viewOptions}
|
| 203 |
+
keySelector={(o) => o.key}
|
| 204 |
+
labelSelector={(o) => o.label}
|
| 205 |
+
/>
|
| 206 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
+
{view === 'mapDetails' ? (
|
| 209 |
+
<>
|
| 210 |
+
<div className={styles.gridLayout}>
|
| 211 |
+
{/* Image Section */}
|
| 212 |
+
<Container
|
| 213 |
+
heading={map.title || "Map Image"}
|
| 214 |
+
headingLevel={2}
|
| 215 |
+
withHeaderBorder
|
| 216 |
+
withInternalPadding
|
| 217 |
+
spacing="comfortable"
|
| 218 |
+
>
|
| 219 |
+
<div className={styles.imageContainer}>
|
| 220 |
+
{map.image_url ? (
|
| 221 |
+
<img
|
| 222 |
+
src={map.image_url}
|
| 223 |
+
alt={map.file_key}
|
| 224 |
+
/>
|
| 225 |
+
) : (
|
| 226 |
+
<div className={styles.imagePlaceholder}>
|
| 227 |
+
No image available
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
</Container>
|
| 232 |
|
| 233 |
+
{/* Details Section */}
|
| 234 |
+
<div className={styles.detailsSection}>
|
| 235 |
+
<Container
|
| 236 |
+
heading="Tags"
|
| 237 |
+
headingLevel={3}
|
| 238 |
+
withHeaderBorder
|
| 239 |
+
withInternalPadding
|
| 240 |
+
spacing="comfortable"
|
| 241 |
+
>
|
| 242 |
+
<div className={styles.metadataTags}>
|
| 243 |
+
<span className={styles.metadataTag}>
|
| 244 |
+
{sources.find(s => s.s_code === map.source)?.label || map.source}
|
| 245 |
+
</span>
|
| 246 |
+
<span className={styles.metadataTag}>
|
| 247 |
+
{types.find(t => t.t_code === map.event_type)?.label || map.event_type}
|
| 248 |
+
</span>
|
| 249 |
+
<span className={styles.metadataTag}>
|
| 250 |
+
{imageTypes.find(it => it.image_type === map.image_type)?.label || map.image_type}
|
| 251 |
+
</span>
|
| 252 |
+
{map.countries && map.countries.length > 0 && (
|
| 253 |
+
<>
|
| 254 |
+
<span className={styles.metadataTag}>
|
| 255 |
+
{regions.find(r => r.r_code === map.countries[0].r_code)?.label || 'Unknown Region'}
|
| 256 |
+
</span>
|
| 257 |
+
<span className={styles.metadataTag}>
|
| 258 |
+
{map.countries.map(country => country.label).join(', ')}
|
| 259 |
+
</span>
|
| 260 |
+
</>
|
| 261 |
+
)}
|
| 262 |
+
</div>
|
| 263 |
+
</Container>
|
| 264 |
|
| 265 |
+
<Container
|
| 266 |
+
heading="Description"
|
| 267 |
+
headingLevel={3}
|
| 268 |
+
withHeaderBorder
|
| 269 |
+
withInternalPadding
|
| 270 |
+
spacing="comfortable"
|
| 271 |
+
>
|
| 272 |
+
<div className={styles.captionContainer}>
|
| 273 |
+
{map.generated ? (
|
| 274 |
+
<div className={styles.captionText}>
|
| 275 |
+
<p>{map.edited || map.generated}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
</div>
|
| 277 |
+
) : (
|
| 278 |
+
<p>— no caption yet —</p>
|
| 279 |
)}
|
| 280 |
</div>
|
| 281 |
+
</Container>
|
| 282 |
+
</div>
|
|
|
|
|
|
|
| 283 |
</div>
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
{/* Contribute Section */}
|
| 286 |
+
<div className="flex justify-center mt-8">
|
| 287 |
+
<Button
|
| 288 |
+
name="contribute"
|
| 289 |
+
onClick={handleContribute}
|
| 290 |
+
disabled={isGenerating}
|
| 291 |
+
>
|
| 292 |
+
{isGenerating ? 'Generating...' : 'Contribute'}
|
| 293 |
+
</Button>
|
| 294 |
+
</div>
|
| 295 |
+
</>
|
| 296 |
+
) : null}
|
| 297 |
+
</Container>
|
| 298 |
</PageContainer>
|
| 299 |
);
|
| 300 |
}
|
frontend/src/pages/UploadPage/UploadPage.module.css
CHANGED
|
@@ -493,3 +493,5 @@
|
|
| 493 |
padding: var(--go-ui-spacing-lg);
|
| 494 |
box-shadow: var(--go-ui-box-shadow-xs);
|
| 495 |
}
|
|
|
|
|
|
|
|
|
| 493 |
padding: var(--go-ui-spacing-lg);
|
| 494 |
box-shadow: var(--go-ui-box-shadow-xs);
|
| 495 |
}
|
| 496 |
+
|
| 497 |
+
|
frontend/src/pages/UploadPage/UploadPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import type { DragEvent } from 'react';
|
| 3 |
import {
|
| 4 |
PageContainer, Heading, Button,
|
|
@@ -9,15 +9,17 @@ import {
|
|
| 9 |
ArrowRightLineIcon,
|
| 10 |
DeleteBinLineIcon,
|
| 11 |
} from '@ifrc-go/icons';
|
| 12 |
-
import { Link, useSearchParams } from 'react-router-dom';
|
| 13 |
import styles from './UploadPage.module.css';
|
| 14 |
|
| 15 |
const SELECTED_MODEL_KEY = 'selectedVlmModel';
|
| 16 |
|
| 17 |
export default function UploadPage() {
|
| 18 |
const [searchParams] = useSearchParams();
|
|
|
|
| 19 |
const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
|
| 20 |
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
| 21 |
const stepRef = useRef(step);
|
| 22 |
const uploadedImageIdRef = useRef<string | null>(null);
|
| 23 |
const [preview, setPreview] = useState<string | null>(null);
|
|
@@ -59,7 +61,10 @@ export default function UploadPage() {
|
|
| 59 |
fetch('/api/image-types').then(r => r.json()),
|
| 60 |
fetch('/api/countries').then(r => r.json()),
|
| 61 |
fetch('/api/models').then(r => r.json())
|
| 62 |
-
]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData]) => {
|
|
|
|
|
|
|
|
|
|
| 63 |
setSources(sourcesData);
|
| 64 |
setTypes(typesData);
|
| 65 |
setSpatialReferences(spatialData);
|
|
@@ -73,73 +78,143 @@ export default function UploadPage() {
|
|
| 73 |
});
|
| 74 |
}, []);
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
useEffect(() => {
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
|
|
|
| 84 |
return () => {
|
| 85 |
window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
}
|
| 89 |
};
|
| 90 |
}, []);
|
| 91 |
|
| 92 |
-
const [captionId, setCaptionId] = useState<string | null>(null);
|
| 93 |
const [imageUrl, setImageUrl] = useState<string|null>(null);
|
| 94 |
const [draft, setDraft] = useState('');
|
| 95 |
|
| 96 |
useEffect(() => {
|
| 97 |
-
const
|
| 98 |
const stepParam = searchParams.get('step');
|
| 99 |
-
const
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}, [searchParams]);
|
| 144 |
|
| 145 |
const resetToStep1 = () => {
|
|
@@ -147,7 +222,7 @@ export default function UploadPage() {
|
|
| 147 |
setFile(null);
|
| 148 |
setPreview(null);
|
| 149 |
setImageUrl(null);
|
| 150 |
-
|
| 151 |
setDraft('');
|
| 152 |
setTitle('');
|
| 153 |
setScores({ accuracy: 50, context: 50, usability: 50 });
|
|
@@ -162,15 +237,15 @@ export default function UploadPage() {
|
|
| 162 |
const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
|
| 163 |
|
| 164 |
|
| 165 |
-
const onDrop =
|
| 166 |
e.preventDefault();
|
| 167 |
const dropped = e.dataTransfer.files?.[0];
|
| 168 |
if (dropped) setFile(dropped);
|
| 169 |
-
}
|
| 170 |
|
| 171 |
-
const onFileChange =
|
| 172 |
if (file) setFile(file);
|
| 173 |
-
}
|
| 174 |
|
| 175 |
useEffect(() => {
|
| 176 |
if (!file) {
|
|
@@ -242,16 +317,18 @@ export default function UploadPage() {
|
|
| 242 |
);
|
| 243 |
const capJson = await readJsonSafely(capRes);
|
| 244 |
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
| 245 |
-
|
|
|
|
| 246 |
|
| 247 |
const extractedMetadata = capJson.raw_json?.extracted_metadata;
|
| 248 |
if (extractedMetadata) {
|
| 249 |
-
|
| 250 |
-
if (
|
| 251 |
-
if (
|
| 252 |
-
if (
|
| 253 |
-
if (
|
| 254 |
-
|
|
|
|
| 255 |
}
|
| 256 |
}
|
| 257 |
|
|
@@ -264,8 +341,69 @@ export default function UploadPage() {
|
|
| 264 |
}
|
| 265 |
}
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
async function handleSubmit() {
|
| 268 |
-
|
|
|
|
| 269 |
|
| 270 |
try {
|
| 271 |
const metadataBody = {
|
|
@@ -275,6 +413,7 @@ export default function UploadPage() {
|
|
| 275 |
image_type: imageType,
|
| 276 |
countries: countries,
|
| 277 |
};
|
|
|
|
| 278 |
const metadataRes = await fetch(`/api/images/${uploadedImageId}`, {
|
| 279 |
method: "PUT",
|
| 280 |
headers: { "Content-Type": "application/json" },
|
|
@@ -290,7 +429,8 @@ export default function UploadPage() {
|
|
| 290 |
context: scores.context,
|
| 291 |
usability: scores.usability,
|
| 292 |
};
|
| 293 |
-
|
|
|
|
| 294 |
method: "PUT",
|
| 295 |
headers: { "Content-Type": "application/json" },
|
| 296 |
body: JSON.stringify(captionBody),
|
|
@@ -306,115 +446,50 @@ export default function UploadPage() {
|
|
| 306 |
}
|
| 307 |
|
| 308 |
async function handleDelete() {
|
|
|
|
| 309 |
if (!uploadedImageId) {
|
| 310 |
-
|
| 311 |
-
alert('No caption to delete. Please try refreshing the page.');
|
| 312 |
return;
|
| 313 |
}
|
| 314 |
|
| 315 |
-
if (confirm("
|
| 316 |
try {
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
| 319 |
|
| 320 |
-
if (
|
| 321 |
-
const
|
| 322 |
-
|
| 323 |
}
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
method: "DELETE",
|
| 329 |
-
});
|
| 330 |
-
if (!capRes.ok) {
|
| 331 |
-
throw new Error('Failed to delete caption');
|
| 332 |
-
}
|
| 333 |
-
}
|
| 334 |
} else {
|
| 335 |
-
|
| 336 |
-
method: "DELETE",
|
| 337 |
-
});
|
| 338 |
-
|
| 339 |
-
if (!res.ok) {
|
| 340 |
-
const json = await readJsonSafely(res);
|
| 341 |
-
|
| 342 |
-
throw new Error(json.error || `Delete failed with status ${res.status}`);
|
| 343 |
-
}
|
| 344 |
}
|
| 345 |
-
|
| 346 |
-
resetToStep1();
|
| 347 |
} catch (err) {
|
| 348 |
-
|
| 349 |
handleApiError(err, 'Delete');
|
| 350 |
}
|
| 351 |
}
|
| 352 |
}
|
| 353 |
|
| 354 |
-
const handleProcessCaption = useCallback(async () => {
|
| 355 |
-
if (!uploadedImageId) {
|
| 356 |
-
alert('No image ID available to create a new caption.');
|
| 357 |
-
return;
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
setIsLoading(true);
|
| 361 |
|
| 362 |
-
try {
|
| 363 |
-
if (captionId) {
|
| 364 |
-
const captionBody = {
|
| 365 |
-
title: title,
|
| 366 |
-
edited: draft || '',
|
| 367 |
-
};
|
| 368 |
-
const captionRes = await fetch(`/api/captions/${captionId}`, {
|
| 369 |
-
method: "PUT",
|
| 370 |
-
headers: { "Content-Type": "application/json" },
|
| 371 |
-
body: JSON.stringify(captionBody),
|
| 372 |
-
});
|
| 373 |
-
if (!captionRes.ok) throw new Error('Failed to update caption');
|
| 374 |
-
} else {
|
| 375 |
-
const capRes = await fetch(
|
| 376 |
-
`/api/images/${uploadedImageId}/caption`,
|
| 377 |
-
{
|
| 378 |
-
method: 'POST',
|
| 379 |
-
headers: {
|
| 380 |
-
'Content-Type': 'application/x-www-form-urlencoded',
|
| 381 |
-
},
|
| 382 |
-
body: new URLSearchParams({
|
| 383 |
-
title: 'New Contribution Caption',
|
| 384 |
-
prompt: 'Describe this crisis map in detail',
|
| 385 |
-
...(localStorage.getItem(SELECTED_MODEL_KEY) && {
|
| 386 |
-
model_name: localStorage.getItem(SELECTED_MODEL_KEY)!
|
| 387 |
-
})
|
| 388 |
-
})
|
| 389 |
-
}
|
| 390 |
-
);
|
| 391 |
-
const capJson = await readJsonSafely(capRes);
|
| 392 |
-
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
| 393 |
-
|
| 394 |
-
setCaptionId(capJson.cap_id);
|
| 395 |
-
setDraft(capJson.generated);
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
handleStepChange('2b');
|
| 399 |
-
} catch (err) {
|
| 400 |
-
handleApiError(err, 'Create New Caption');
|
| 401 |
-
} finally {
|
| 402 |
-
setIsLoading(false);
|
| 403 |
-
}
|
| 404 |
-
}, [uploadedImageId, title, captionId, draft]);
|
| 405 |
|
| 406 |
return (
|
| 407 |
<PageContainer>
|
| 408 |
-
<
|
| 409 |
-
|
| 410 |
-
{
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
<div className="space-y-6">
|
| 419 |
<p className="text-gray-700 leading-relaxed max-w-2xl mx-auto">
|
| 420 |
This app evaluates how well multimodal AI models turn emergency maps
|
|
@@ -479,8 +554,7 @@ export default function UploadPage() {
|
|
| 479 |
</label>
|
| 480 |
</div>
|
| 481 |
</div>
|
| 482 |
-
|
| 483 |
-
)}
|
| 484 |
|
| 485 |
{/* Loading state */}
|
| 486 |
{isLoading && (
|
|
@@ -490,16 +564,33 @@ export default function UploadPage() {
|
|
| 490 |
</div>
|
| 491 |
)}
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
{/* Generate button */}
|
| 494 |
{step === 1 && !isLoading && (
|
| 495 |
<div className={styles.generateButtonContainer}>
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
</div>
|
| 504 |
)}
|
| 505 |
|
|
@@ -612,18 +703,17 @@ export default function UploadPage() {
|
|
| 612 |
</IconButton>
|
| 613 |
<Button
|
| 614 |
name="confirm-metadata"
|
| 615 |
-
onClick={() => {
|
| 616 |
-
if (imageUrl && !
|
| 617 |
-
|
|
|
|
|
|
|
| 618 |
} else {
|
| 619 |
handleStepChange('2b');
|
| 620 |
}
|
| 621 |
}}
|
| 622 |
>
|
| 623 |
-
|
| 624 |
-
(captionId ? 'Edit Caption' : 'Create New Caption') :
|
| 625 |
-
'Next'
|
| 626 |
-
}
|
| 627 |
</Button>
|
| 628 |
</div>
|
| 629 |
</Container>
|
|
@@ -778,7 +868,9 @@ export default function UploadPage() {
|
|
| 778 |
</div>
|
| 779 |
</div>
|
| 780 |
)}
|
| 781 |
-
|
|
|
|
|
|
|
| 782 |
</PageContainer>
|
| 783 |
);
|
| 784 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
import type { DragEvent } from 'react';
|
| 3 |
import {
|
| 4 |
PageContainer, Heading, Button,
|
|
|
|
| 9 |
ArrowRightLineIcon,
|
| 10 |
DeleteBinLineIcon,
|
| 11 |
} from '@ifrc-go/icons';
|
| 12 |
+
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
| 13 |
import styles from './UploadPage.module.css';
|
| 14 |
|
| 15 |
const SELECTED_MODEL_KEY = 'selectedVlmModel';
|
| 16 |
|
| 17 |
export default function UploadPage() {
|
| 18 |
const [searchParams] = useSearchParams();
|
| 19 |
+
const navigate = useNavigate();
|
| 20 |
const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
|
| 21 |
const [isLoading, setIsLoading] = useState(false);
|
| 22 |
+
const [isLoadingContribution, setIsLoadingContribution] = useState(false);
|
| 23 |
const stepRef = useRef(step);
|
| 24 |
const uploadedImageIdRef = useRef<string | null>(null);
|
| 25 |
const [preview, setPreview] = useState<string | null>(null);
|
|
|
|
| 61 |
fetch('/api/image-types').then(r => r.json()),
|
| 62 |
fetch('/api/countries').then(r => r.json()),
|
| 63 |
fetch('/api/models').then(r => r.json())
|
| 64 |
+
]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData, modelsData]) => {
|
| 65 |
+
if (!localStorage.getItem(SELECTED_MODEL_KEY) && modelsData?.length) {
|
| 66 |
+
localStorage.setItem(SELECTED_MODEL_KEY, modelsData[0].m_code);
|
| 67 |
+
}
|
| 68 |
setSources(sourcesData);
|
| 69 |
setTypes(typesData);
|
| 70 |
setSpatialReferences(spatialData);
|
|
|
|
| 78 |
});
|
| 79 |
}, []);
|
| 80 |
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
const handleNavigation = (to: string) => {
|
| 84 |
+
if (uploadedImageIdRef.current) {
|
| 85 |
+
if (confirm("Leave page? Your uploaded image will be deleted.")) {
|
| 86 |
+
fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" })
|
| 87 |
+
.then(() => {
|
| 88 |
+
navigate(to);
|
| 89 |
+
})
|
| 90 |
+
.catch(console.error);
|
| 91 |
+
}
|
| 92 |
+
} else {
|
| 93 |
+
navigate(to);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
useEffect(() => {
|
| 98 |
+
(window as any).confirmNavigationIfNeeded = (to: string) => {
|
| 99 |
+
handleNavigation(to);
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
return () => {
|
| 103 |
+
delete (window as any).confirmNavigationIfNeeded;
|
| 104 |
+
};
|
| 105 |
+
}, []);
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
| 109 |
+
if (uploadedImageIdRef.current) {
|
| 110 |
+
const message = 'You have an uploaded image that will be deleted if you leave this page. Are you sure you want to leave?';
|
| 111 |
+
event.preventDefault();
|
| 112 |
+
event.returnValue = message;
|
| 113 |
+
return message;
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const handleCleanup = () => {
|
| 118 |
+
if (uploadedImageIdRef.current) {
|
| 119 |
fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
|
| 120 |
}
|
| 121 |
};
|
| 122 |
|
| 123 |
+
const handleGlobalClick = (event: MouseEvent) => {
|
| 124 |
+
const target = event.target as HTMLElement;
|
| 125 |
+
const link = target.closest('a[href]') || target.closest('[data-navigate]');
|
| 126 |
+
|
| 127 |
+
if (link && uploadedImageIdRef.current) {
|
| 128 |
+
const href = link.getAttribute('href') || link.getAttribute('data-navigate');
|
| 129 |
+
if (href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
|
| 130 |
+
event.preventDefault();
|
| 131 |
+
event.stopPropagation();
|
| 132 |
+
handleNavigation(href);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
window.addEventListener('beforeunload', handleBeforeUnload);
|
| 138 |
+
document.addEventListener('click', handleGlobalClick, true);
|
| 139 |
+
|
| 140 |
return () => {
|
| 141 |
window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 142 |
+
document.removeEventListener('click', handleGlobalClick, true);
|
| 143 |
+
handleCleanup();
|
|
|
|
| 144 |
};
|
| 145 |
}, []);
|
| 146 |
|
|
|
|
| 147 |
const [imageUrl, setImageUrl] = useState<string|null>(null);
|
| 148 |
const [draft, setDraft] = useState('');
|
| 149 |
|
| 150 |
useEffect(() => {
|
| 151 |
+
const imageUrlParam = searchParams.get('imageUrl');
|
| 152 |
const stepParam = searchParams.get('step');
|
| 153 |
+
const imageIdParam = searchParams.get('imageId');
|
| 154 |
|
| 155 |
+
if (imageUrlParam) {
|
| 156 |
+
setImageUrl(imageUrlParam);
|
| 157 |
+
|
| 158 |
+
if (stepParam === '2a' && imageIdParam) {
|
| 159 |
+
setIsLoadingContribution(true);
|
| 160 |
+
setUploadedImageId(imageIdParam);
|
| 161 |
+
fetch(`/api/images/${imageIdParam}`)
|
| 162 |
+
.then(res => res.json())
|
| 163 |
+
.then(data => {
|
| 164 |
+
if (data.image_type) setImageType(data.image_type);
|
| 165 |
+
|
| 166 |
+
if (data.generated) setDraft(data.generated);
|
| 167 |
+
|
| 168 |
+
let extractedMetadata = data.raw_json?.extracted_metadata;
|
| 169 |
+
console.log('Raw extracted_metadata:', extractedMetadata);
|
| 170 |
+
|
| 171 |
+
if (!extractedMetadata && data.generated) {
|
| 172 |
+
try {
|
| 173 |
+
const parsedGenerated = JSON.parse(data.generated);
|
| 174 |
+
console.log('Parsed generated field:', parsedGenerated);
|
| 175 |
+
if (parsedGenerated.metadata) {
|
| 176 |
+
extractedMetadata = parsedGenerated;
|
| 177 |
+
console.log('Using metadata from generated field');
|
| 178 |
+
}
|
| 179 |
+
} catch (e) {
|
| 180 |
+
console.log('Could not parse generated field as JSON:', e);
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if (extractedMetadata) {
|
| 185 |
+
const metadata = extractedMetadata.metadata || extractedMetadata;
|
| 186 |
+
console.log('Final metadata to apply:', metadata);
|
| 187 |
+
if (metadata.title) {
|
| 188 |
+
console.log('Setting title to:', metadata.title);
|
| 189 |
+
setTitle(metadata.title);
|
| 190 |
+
}
|
| 191 |
+
if (metadata.source) {
|
| 192 |
+
console.log('Setting source to:', metadata.source);
|
| 193 |
+
setSource(metadata.source);
|
| 194 |
+
}
|
| 195 |
+
if (metadata.type) {
|
| 196 |
+
console.log('Setting event type to:', metadata.type);
|
| 197 |
+
setEventType(metadata.type);
|
| 198 |
+
}
|
| 199 |
+
if (metadata.epsg) {
|
| 200 |
+
console.log('Setting EPSG to:', metadata.epsg);
|
| 201 |
+
setEpsg(metadata.epsg);
|
| 202 |
+
}
|
| 203 |
+
if (metadata.countries && Array.isArray(metadata.countries)) {
|
| 204 |
+
console.log('Setting countries to:', metadata.countries);
|
| 205 |
+
setCountries(metadata.countries);
|
| 206 |
+
}
|
| 207 |
+
} else {
|
| 208 |
+
console.log('No metadata found to extract');
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
setStep('2a');
|
| 212 |
+
setIsLoadingContribution(false);
|
| 213 |
+
})
|
| 214 |
+
.catch(console.error)
|
| 215 |
+
.finally(() => setIsLoadingContribution(false));
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
}, [searchParams]);
|
| 219 |
|
| 220 |
const resetToStep1 = () => {
|
|
|
|
| 222 |
setFile(null);
|
| 223 |
setPreview(null);
|
| 224 |
setImageUrl(null);
|
| 225 |
+
|
| 226 |
setDraft('');
|
| 227 |
setTitle('');
|
| 228 |
setScores({ accuracy: 50, context: 50, usability: 50 });
|
|
|
|
| 237 |
const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
|
| 238 |
|
| 239 |
|
| 240 |
+
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
| 241 |
e.preventDefault();
|
| 242 |
const dropped = e.dataTransfer.files?.[0];
|
| 243 |
if (dropped) setFile(dropped);
|
| 244 |
+
};
|
| 245 |
|
| 246 |
+
const onFileChange = (file: File | undefined, _name: string) => {
|
| 247 |
if (file) setFile(file);
|
| 248 |
+
};
|
| 249 |
|
| 250 |
useEffect(() => {
|
| 251 |
if (!file) {
|
|
|
|
| 317 |
);
|
| 318 |
const capJson = await readJsonSafely(capRes);
|
| 319 |
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
| 320 |
+
setUploadedImageId(mapIdVal);
|
| 321 |
+
|
| 322 |
|
| 323 |
const extractedMetadata = capJson.raw_json?.extracted_metadata;
|
| 324 |
if (extractedMetadata) {
|
| 325 |
+
const metadata = extractedMetadata.metadata || extractedMetadata;
|
| 326 |
+
if (metadata.title) setTitle(metadata.title);
|
| 327 |
+
if (metadata.source) setSource(metadata.source);
|
| 328 |
+
if (metadata.type) setEventType(metadata.type);
|
| 329 |
+
if (metadata.epsg) setEpsg(metadata.epsg);
|
| 330 |
+
if (metadata.countries && Array.isArray(metadata.countries)) {
|
| 331 |
+
setCountries(metadata.countries);
|
| 332 |
}
|
| 333 |
}
|
| 334 |
|
|
|
|
| 341 |
}
|
| 342 |
}
|
| 343 |
|
| 344 |
+
async function handleGenerateFromUrl() {
|
| 345 |
+
if (!imageUrl) return;
|
| 346 |
+
setIsLoading(true);
|
| 347 |
+
try {
|
| 348 |
+
// 1) Create a NEW image from server-side URL fetch
|
| 349 |
+
const res = await fetch('/api/contribute/from-url', {
|
| 350 |
+
method: 'POST',
|
| 351 |
+
headers: { 'Content-Type': 'application/json' },
|
| 352 |
+
body: JSON.stringify({
|
| 353 |
+
url: imageUrl,
|
| 354 |
+
source,
|
| 355 |
+
event_type: eventType,
|
| 356 |
+
epsg,
|
| 357 |
+
image_type: imageType,
|
| 358 |
+
countries,
|
| 359 |
+
}),
|
| 360 |
+
});
|
| 361 |
+
const json = await readJsonSafely(res);
|
| 362 |
+
if (!res.ok) throw new Error(json.error || 'Upload failed');
|
| 363 |
+
|
| 364 |
+
const newId = json.image_id as string;
|
| 365 |
+
setUploadedImageId(newId);
|
| 366 |
+
setImageUrl(json.image_url);
|
| 367 |
+
|
| 368 |
+
const modelName = localStorage.getItem(SELECTED_MODEL_KEY) || undefined;
|
| 369 |
+
const capRes = await fetch(`/api/images/${newId}/caption`, {
|
| 370 |
+
method: 'POST',
|
| 371 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 372 |
+
body: new URLSearchParams({
|
| 373 |
+
title: 'Generated Caption',
|
| 374 |
+
prompt:
|
| 375 |
+
'Analyze this crisis map and provide a detailed description of the emergency situation, affected areas, and key information shown in the map.',
|
| 376 |
+
...(modelName && { model_name: modelName }),
|
| 377 |
+
}),
|
| 378 |
+
});
|
| 379 |
+
const capJson = await readJsonSafely(capRes);
|
| 380 |
+
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
| 381 |
+
|
| 382 |
+
const extractedMetadata = capJson.raw_json?.extracted_metadata;
|
| 383 |
+
if (extractedMetadata) {
|
| 384 |
+
const metadata = extractedMetadata.metadata || extractedMetadata;
|
| 385 |
+
if (metadata.title) setTitle(metadata.title);
|
| 386 |
+
if (metadata.source) setSource(metadata.source);
|
| 387 |
+
if (metadata.type) setEventType(metadata.type);
|
| 388 |
+
if (metadata.epsg) setEpsg(metadata.epsg);
|
| 389 |
+
if (metadata.countries && Array.isArray(metadata.countries)) {
|
| 390 |
+
setCountries(metadata.countries);
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
setDraft(capJson.generated || '');
|
| 395 |
+
handleStepChange('2a');
|
| 396 |
+
} catch (err) {
|
| 397 |
+
handleApiError(err, 'Upload');
|
| 398 |
+
} finally {
|
| 399 |
+
setIsLoading(false);
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
async function handleSubmit() {
|
| 405 |
+
console.log('handleSubmit called with:', { uploadedImageId, title, draft });
|
| 406 |
+
if (!uploadedImageId) return alert("No image to submit");
|
| 407 |
|
| 408 |
try {
|
| 409 |
const metadataBody = {
|
|
|
|
| 413 |
image_type: imageType,
|
| 414 |
countries: countries,
|
| 415 |
};
|
| 416 |
+
console.log('Updating metadata:', metadataBody);
|
| 417 |
const metadataRes = await fetch(`/api/images/${uploadedImageId}`, {
|
| 418 |
method: "PUT",
|
| 419 |
headers: { "Content-Type": "application/json" },
|
|
|
|
| 429 |
context: scores.context,
|
| 430 |
usability: scores.usability,
|
| 431 |
};
|
| 432 |
+
console.log('Updating caption:', captionBody);
|
| 433 |
+
const captionRes = await fetch(`/api/images/${uploadedImageId}/caption`, {
|
| 434 |
method: "PUT",
|
| 435 |
headers: { "Content-Type": "application/json" },
|
| 436 |
body: JSON.stringify(captionBody),
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
async function handleDelete() {
|
| 449 |
+
console.log('handleDelete called with uploadedImageId:', uploadedImageId);
|
| 450 |
if (!uploadedImageId) {
|
| 451 |
+
alert('No image to delete. Please try refreshing the page.');
|
|
|
|
| 452 |
return;
|
| 453 |
}
|
| 454 |
|
| 455 |
+
if (confirm("Delete this image? This cannot be undone.")) {
|
| 456 |
try {
|
| 457 |
+
console.log('Deleting image with ID:', uploadedImageId);
|
| 458 |
+
const res = await fetch(`/api/images/${uploadedImageId}`, {
|
| 459 |
+
method: "DELETE",
|
| 460 |
+
});
|
| 461 |
|
| 462 |
+
if (!res.ok) {
|
| 463 |
+
const json = await readJsonSafely(res);
|
| 464 |
+
throw new Error(json.error || `Delete failed with status ${res.status}`);
|
| 465 |
}
|
| 466 |
|
| 467 |
+
// If this was a contribution, navigate to explore page
|
| 468 |
+
if (searchParams.get('isContribution') === 'true') {
|
| 469 |
+
navigate('/explore');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
} else {
|
| 471 |
+
resetToStep1();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
}
|
|
|
|
|
|
|
| 473 |
} catch (err) {
|
|
|
|
| 474 |
handleApiError(err, 'Delete');
|
| 475 |
}
|
| 476 |
}
|
| 477 |
}
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
return (
|
| 482 |
<PageContainer>
|
| 483 |
+
<Container
|
| 484 |
+
heading="Upload Your Image"
|
| 485 |
+
headingLevel={2}
|
| 486 |
+
withHeaderBorder
|
| 487 |
+
withInternalPadding
|
| 488 |
+
className="max-w-7xl mx-auto"
|
| 489 |
+
>
|
| 490 |
+
<div className={styles.uploadContainer} data-step={step}>
|
| 491 |
+
{/* Drop-zone */}
|
| 492 |
+
{step === 1 && !searchParams.get('step') && (
|
| 493 |
<div className="space-y-6">
|
| 494 |
<p className="text-gray-700 leading-relaxed max-w-2xl mx-auto">
|
| 495 |
This app evaluates how well multimodal AI models turn emergency maps
|
|
|
|
| 554 |
</label>
|
| 555 |
</div>
|
| 556 |
</div>
|
| 557 |
+
)}
|
|
|
|
| 558 |
|
| 559 |
{/* Loading state */}
|
| 560 |
{isLoading && (
|
|
|
|
| 564 |
</div>
|
| 565 |
)}
|
| 566 |
|
| 567 |
+
{/* Loading contribution data */}
|
| 568 |
+
{isLoadingContribution && (
|
| 569 |
+
<div className={styles.loadingContainer}>
|
| 570 |
+
<Spinner className="text-ifrcRed" />
|
| 571 |
+
<p className={styles.loadingText}>Loading contribution...</p>
|
| 572 |
+
</div>
|
| 573 |
+
)}
|
| 574 |
+
|
| 575 |
{/* Generate button */}
|
| 576 |
{step === 1 && !isLoading && (
|
| 577 |
<div className={styles.generateButtonContainer}>
|
| 578 |
+
{imageUrl ? (
|
| 579 |
+
<Button
|
| 580 |
+
name="generate-from-url"
|
| 581 |
+
onClick={handleGenerateFromUrl}
|
| 582 |
+
>
|
| 583 |
+
Generate Caption
|
| 584 |
+
</Button>
|
| 585 |
+
) : (
|
| 586 |
+
<Button
|
| 587 |
+
name="generate"
|
| 588 |
+
disabled={!file}
|
| 589 |
+
onClick={handleGenerate}
|
| 590 |
+
>
|
| 591 |
+
Generate
|
| 592 |
+
</Button>
|
| 593 |
+
)}
|
| 594 |
</div>
|
| 595 |
)}
|
| 596 |
|
|
|
|
| 703 |
</IconButton>
|
| 704 |
<Button
|
| 705 |
name="confirm-metadata"
|
| 706 |
+
onClick={async () => {
|
| 707 |
+
if (imageUrl && !uploadedImageId) {
|
| 708 |
+
await handleGenerateFromUrl();
|
| 709 |
+
} else if (imageUrl && !file) {
|
| 710 |
+
handleStepChange('2b');
|
| 711 |
} else {
|
| 712 |
handleStepChange('2b');
|
| 713 |
}
|
| 714 |
}}
|
| 715 |
>
|
| 716 |
+
Next
|
|
|
|
|
|
|
|
|
|
| 717 |
</Button>
|
| 718 |
</div>
|
| 719 |
</Container>
|
|
|
|
| 868 |
</div>
|
| 869 |
</div>
|
| 870 |
)}
|
| 871 |
+
|
| 872 |
+
</div>
|
| 873 |
+
</Container>
|
| 874 |
</PageContainer>
|
| 875 |
);
|
| 876 |
}
|
frontend/src/types.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
export interface MapOut {
|
| 2 |
-
map_id: string;
|
| 3 |
-
file_key: string;
|
| 4 |
-
sha256: string;
|
| 5 |
-
source: string;
|
| 6 |
-
region: string;
|
| 7 |
-
category: string;
|
| 8 |
-
caption?: {
|
| 9 |
-
cap_id: string;
|
| 10 |
-
map_id: string;
|
| 11 |
-
generated: string;
|
| 12 |
-
edited?: string;
|
| 13 |
-
accuracy?: number;
|
| 14 |
-
context?: number;
|
| 15 |
-
usability?: number;
|
| 16 |
-
};
|
| 17 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py
CHANGED
|
@@ -42,6 +42,7 @@ def _guess_region(alpha2: str) -> str:
|
|
| 42 |
def upgrade():
|
| 43 |
op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
|
| 44 |
|
|
|
|
| 45 |
op.execute("DROP TABLE IF EXISTS captions CASCADE;")
|
| 46 |
op.execute("DROP TABLE IF EXISTS image_countries CASCADE;")
|
| 47 |
op.execute("DROP TABLE IF EXISTS images CASCADE;")
|
|
@@ -188,7 +189,6 @@ def upgrade():
|
|
| 188 |
('BLIP2_OPT_2_7B','BLIP Image Captioning','custom',true,'{"provider":"huggingface","model_id":"Salesforce/blip-image-captioning-base"}'),
|
| 189 |
('VIT_GPT2','Vit gpt2 image captioning','custom',true,'{"provider":"huggingface","model_id":"nlpconnect/vit-gpt2-image-captioning"}')
|
| 190 |
""")
|
| 191 |
-
|
| 192 |
op.execute("""
|
| 193 |
INSERT INTO json_schemas (schema_id,title,schema,version) VALUES
|
| 194 |
('[email protected]','Default Caption Schema',
|
|
@@ -205,6 +205,7 @@ def upgrade():
|
|
| 205 |
)
|
| 206 |
op.execute("INSERT INTO countries (c_code,label,r_code) VALUES ('XX','Not Applicable','OTHER')")
|
| 207 |
|
|
|
|
| 208 |
op.create_table(
|
| 209 |
'images',
|
| 210 |
sa.Column('image_id', postgresql.UUID(as_uuid=True),
|
|
@@ -218,7 +219,26 @@ def upgrade():
|
|
| 218 |
sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
|
| 219 |
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
| 220 |
sa.Column('captured_at', sa.TIMESTAMP(timezone=True), nullable=True),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
)
|
|
|
|
| 222 |
op.create_table(
|
| 223 |
'image_countries',
|
| 224 |
sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
|
|
@@ -227,42 +247,12 @@ def upgrade():
|
|
| 227 |
sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
|
| 228 |
sa.ForeignKeyConstraint(['c_code'], ['countries.c_code'])
|
| 229 |
)
|
| 230 |
-
op.create_table(
|
| 231 |
-
'captions',
|
| 232 |
-
sa.Column('cap_id', postgresql.UUID(as_uuid=True),
|
| 233 |
-
server_default=sa.text('gen_random_uuid()'),
|
| 234 |
-
primary_key=True),
|
| 235 |
-
sa.Column('image_id', postgresql.UUID(as_uuid=True),
|
| 236 |
-
sa.ForeignKey('images.image_id', ondelete='CASCADE'),
|
| 237 |
-
nullable=False),
|
| 238 |
-
sa.Column('title', sa.String(), nullable=False),
|
| 239 |
-
sa.Column('prompt', sa.String(), nullable=False),
|
| 240 |
-
sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=False),
|
| 241 |
-
sa.Column('schema_id', sa.String(), sa.ForeignKey('json_schemas.schema_id'), nullable=False),
|
| 242 |
-
sa.Column('raw_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
| 243 |
-
sa.Column('generated', sa.Text(), nullable=False),
|
| 244 |
-
sa.Column('edited', sa.Text(), nullable=True),
|
| 245 |
-
sa.Column('accuracy', sa.SmallInteger()),
|
| 246 |
-
sa.Column('context', sa.SmallInteger()),
|
| 247 |
-
sa.Column('usability', sa.SmallInteger()),
|
| 248 |
-
sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
|
| 249 |
-
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
| 250 |
-
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
|
| 251 |
-
sa.CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'),
|
| 252 |
-
sa.CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'),
|
| 253 |
-
sa.CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability')
|
| 254 |
-
)
|
| 255 |
|
| 256 |
op.create_index('ix_images_created_at', 'images', ['created_at'])
|
| 257 |
-
op.create_index('ix_captions_created_at', 'captions', ['created_at'])
|
| 258 |
-
op.create_index('ix_captions_image_id', 'captions', ['image_id'])
|
| 259 |
|
| 260 |
|
| 261 |
def downgrade():
|
| 262 |
-
op.drop_index('ix_captions_image_id', table_name='captions')
|
| 263 |
-
op.drop_index('ix_captions_created_at', table_name='captions')
|
| 264 |
op.drop_index('ix_images_created_at', table_name='images')
|
| 265 |
-
op.drop_table('captions')
|
| 266 |
op.drop_table('image_countries')
|
| 267 |
op.drop_table('images')
|
| 268 |
op.drop_table('json_schemas')
|
|
|
|
| 42 |
def upgrade():
|
| 43 |
op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
|
| 44 |
|
| 45 |
+
# Drop any old tables if they exist (idempotent for initial setup)
|
| 46 |
op.execute("DROP TABLE IF EXISTS captions CASCADE;")
|
| 47 |
op.execute("DROP TABLE IF EXISTS image_countries CASCADE;")
|
| 48 |
op.execute("DROP TABLE IF EXISTS images CASCADE;")
|
|
|
|
| 189 |
('BLIP2_OPT_2_7B','BLIP Image Captioning','custom',true,'{"provider":"huggingface","model_id":"Salesforce/blip-image-captioning-base"}'),
|
| 190 |
('VIT_GPT2','Vit gpt2 image captioning','custom',true,'{"provider":"huggingface","model_id":"nlpconnect/vit-gpt2-image-captioning"}')
|
| 191 |
""")
|
|
|
|
| 192 |
op.execute("""
|
| 193 |
INSERT INTO json_schemas (schema_id,title,schema,version) VALUES
|
| 194 |
('[email protected]','Default Caption Schema',
|
|
|
|
| 205 |
)
|
| 206 |
op.execute("INSERT INTO countries (c_code,label,r_code) VALUES ('XX','Not Applicable','OTHER')")
|
| 207 |
|
| 208 |
+
# ---- Images table now includes the single caption/interpretation fields ----
|
| 209 |
op.create_table(
|
| 210 |
'images',
|
| 211 |
sa.Column('image_id', postgresql.UUID(as_uuid=True),
|
|
|
|
| 219 |
sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
|
| 220 |
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
| 221 |
sa.Column('captured_at', sa.TIMESTAMP(timezone=True), nullable=True),
|
| 222 |
+
|
| 223 |
+
# --- merged caption fields ---
|
| 224 |
+
sa.Column('title', sa.String(), nullable=True),
|
| 225 |
+
sa.Column('prompt', sa.String(), nullable=True),
|
| 226 |
+
sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=True),
|
| 227 |
+
sa.Column('schema_id', sa.String(), sa.ForeignKey('json_schemas.schema_id'), nullable=True),
|
| 228 |
+
sa.Column('raw_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
| 229 |
+
sa.Column('generated', sa.Text(), nullable=True),
|
| 230 |
+
sa.Column('edited', sa.Text(), nullable=True),
|
| 231 |
+
sa.Column('accuracy', sa.SmallInteger()),
|
| 232 |
+
sa.Column('context', sa.SmallInteger()),
|
| 233 |
+
sa.Column('usability', sa.SmallInteger()),
|
| 234 |
+
sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
|
| 235 |
+
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
|
| 236 |
+
|
| 237 |
+
sa.CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_images_accuracy'),
|
| 238 |
+
sa.CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_images_context'),
|
| 239 |
+
sa.CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_images_usability')
|
| 240 |
)
|
| 241 |
+
|
| 242 |
op.create_table(
|
| 243 |
'image_countries',
|
| 244 |
sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
|
|
|
|
| 247 |
sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
|
| 248 |
sa.ForeignKeyConstraint(['c_code'], ['countries.c_code'])
|
| 249 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
op.create_index('ix_images_created_at', 'images', ['created_at'])
|
|
|
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
def downgrade():
|
|
|
|
|
|
|
| 255 |
op.drop_index('ix_images_created_at', table_name='images')
|
|
|
|
| 256 |
op.drop_table('image_countries')
|
| 257 |
op.drop_table('images')
|
| 258 |
op.drop_table('json_schemas')
|
py_backend/app/crud.py
CHANGED
|
@@ -2,6 +2,7 @@ import io, hashlib
|
|
| 2 |
from typing import Optional
|
| 3 |
from sqlalchemy.orm import Session, joinedload
|
| 4 |
from . import models, schemas
|
|
|
|
| 5 |
|
| 6 |
def hash_bytes(data: bytes) -> str:
|
| 7 |
"""Compute SHA-256 hex digest of the data."""
|
|
@@ -26,30 +27,26 @@ def create_image(db: Session, src, type_code, key, sha, countries: list[str], ep
|
|
| 26 |
return img
|
| 27 |
|
| 28 |
def get_images(db: Session):
|
| 29 |
-
"""Get all images with their
|
| 30 |
return (
|
| 31 |
db.query(models.Images)
|
| 32 |
.options(
|
| 33 |
-
joinedload(models.Images.captions),
|
| 34 |
joinedload(models.Images.countries),
|
| 35 |
)
|
| 36 |
.all()
|
| 37 |
)
|
| 38 |
|
| 39 |
def get_image(db: Session, image_id: str):
|
| 40 |
-
"""Get a single image by ID with its
|
| 41 |
return (
|
| 42 |
db.query(models.Images)
|
| 43 |
.options(
|
| 44 |
-
joinedload(models.Images.captions),
|
| 45 |
joinedload(models.Images.countries),
|
| 46 |
)
|
| 47 |
.filter(models.Images.image_id == image_id)
|
| 48 |
.first()
|
| 49 |
)
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
|
| 54 |
print(f"Creating caption for image_id: {image_id}")
|
| 55 |
print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
|
|
@@ -59,93 +56,99 @@ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, t
|
|
| 59 |
if metadata:
|
| 60 |
raw_json["extracted_metadata"] = metadata
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
print(f"About to commit caption to database...")
|
| 74 |
db.commit()
|
| 75 |
print(f"Caption commit successful!")
|
| 76 |
-
db.refresh(
|
| 77 |
-
print(f"Caption created successfully
|
| 78 |
-
return
|
| 79 |
|
| 80 |
-
def get_caption(db: Session,
|
| 81 |
-
|
|
|
|
| 82 |
|
| 83 |
def get_captions_by_image(db: Session, image_id: str):
|
| 84 |
-
"""Get
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
def get_all_captions_with_images(db: Session):
|
| 88 |
-
"""Get all
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
models.Images.
|
| 96 |
-
).
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
).all()
|
| 99 |
|
| 100 |
-
|
| 101 |
-
for
|
| 102 |
-
|
| 103 |
-
"cap_id": caption.cap_id,
|
| 104 |
-
"image_id": caption.image_id,
|
| 105 |
-
"title": caption.title,
|
| 106 |
-
"prompt": caption.prompt,
|
| 107 |
-
"model": caption.model,
|
| 108 |
-
"schema_id": caption.schema_id,
|
| 109 |
-
"raw_json": caption.raw_json,
|
| 110 |
-
"generated": caption.generated,
|
| 111 |
-
"edited": caption.edited,
|
| 112 |
-
"accuracy": caption.accuracy,
|
| 113 |
-
"context": caption.context,
|
| 114 |
-
"usability": caption.usability,
|
| 115 |
-
"starred": caption.starred,
|
| 116 |
-
"created_at": caption.created_at,
|
| 117 |
-
"updated_at": caption.updated_at,
|
| 118 |
-
"file_key": file_key,
|
| 119 |
-
"image_url": f"/api/images/{caption.image_id}/file",
|
| 120 |
-
"source": source,
|
| 121 |
-
"event_type": event_type,
|
| 122 |
-
"epsg": epsg,
|
| 123 |
-
"image_type": image_type,
|
| 124 |
-
"countries": []
|
| 125 |
-
}
|
| 126 |
-
captions_with_images.append(caption_dict)
|
| 127 |
|
| 128 |
-
return
|
| 129 |
|
| 130 |
-
def update_caption(db: Session,
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
return None
|
| 134 |
|
| 135 |
for field, value in update.dict(exclude_unset=True).items():
|
| 136 |
-
setattr(
|
| 137 |
|
| 138 |
db.commit()
|
| 139 |
-
db.refresh(
|
| 140 |
-
return
|
| 141 |
|
| 142 |
-
def delete_caption(db: Session,
|
| 143 |
-
"""Delete
|
| 144 |
-
|
| 145 |
-
if not
|
| 146 |
return False
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
db.commit()
|
| 150 |
return True
|
| 151 |
|
|
|
|
| 2 |
from typing import Optional
|
| 3 |
from sqlalchemy.orm import Session, joinedload
|
| 4 |
from . import models, schemas
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
|
| 7 |
def hash_bytes(data: bytes) -> str:
|
| 8 |
"""Compute SHA-256 hex digest of the data."""
|
|
|
|
| 27 |
return img
|
| 28 |
|
| 29 |
def get_images(db: Session):
|
| 30 |
+
"""Get all images with their countries"""
|
| 31 |
return (
|
| 32 |
db.query(models.Images)
|
| 33 |
.options(
|
|
|
|
| 34 |
joinedload(models.Images.countries),
|
| 35 |
)
|
| 36 |
.all()
|
| 37 |
)
|
| 38 |
|
| 39 |
def get_image(db: Session, image_id: str):
|
| 40 |
+
"""Get a single image by ID with its countries"""
|
| 41 |
return (
|
| 42 |
db.query(models.Images)
|
| 43 |
.options(
|
|
|
|
| 44 |
joinedload(models.Images.countries),
|
| 45 |
)
|
| 46 |
.filter(models.Images.image_id == image_id)
|
| 47 |
.first()
|
| 48 |
)
|
| 49 |
|
|
|
|
|
|
|
| 50 |
def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
|
| 51 |
print(f"Creating caption for image_id: {image_id}")
|
| 52 |
print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
|
|
|
|
| 56 |
if metadata:
|
| 57 |
raw_json["extracted_metadata"] = metadata
|
| 58 |
|
| 59 |
+
img = db.get(models.Images, image_id)
|
| 60 |
+
if not img:
|
| 61 |
+
raise HTTPException(404, "Image not found")
|
| 62 |
+
|
| 63 |
+
img.title = title
|
| 64 |
+
img.prompt = prompt
|
| 65 |
+
img.model = model_code
|
| 66 |
+
img.schema_id = "[email protected]"
|
| 67 |
+
img.raw_json = raw_json
|
| 68 |
+
img.generated = text
|
| 69 |
+
img.edited = text
|
| 70 |
+
|
| 71 |
print(f"About to commit caption to database...")
|
| 72 |
db.commit()
|
| 73 |
print(f"Caption commit successful!")
|
| 74 |
+
db.refresh(img)
|
| 75 |
+
print(f"Caption created successfully for image: {img.image_id}")
|
| 76 |
+
return img
|
| 77 |
|
| 78 |
+
def get_caption(db: Session, image_id: str):
|
| 79 |
+
"""Get caption data for a specific image"""
|
| 80 |
+
return db.get(models.Images, image_id)
|
| 81 |
|
| 82 |
def get_captions_by_image(db: Session, image_id: str):
|
| 83 |
+
"""Get caption data for a specific image (now just returns the image)"""
|
| 84 |
+
img = db.get(models.Images, image_id)
|
| 85 |
+
if img and img.title:
|
| 86 |
+
return [img]
|
| 87 |
+
return []
|
| 88 |
|
| 89 |
def get_all_captions_with_images(db: Session):
|
| 90 |
+
"""Get all images that have caption data"""
|
| 91 |
+
print(f"DEBUG: Querying database for images with caption data...")
|
| 92 |
+
|
| 93 |
+
total_images = db.query(models.Images).count()
|
| 94 |
+
print(f"DEBUG: Total images in database: {total_images}")
|
| 95 |
+
|
| 96 |
+
images_with_title = db.query(models.Images).filter(
|
| 97 |
+
models.Images.title.isnot(None)
|
| 98 |
+
).count()
|
| 99 |
+
print(f"DEBUG: Images with title field: {images_with_title}")
|
| 100 |
+
|
| 101 |
+
images_with_generated = db.query(models.Images).filter(
|
| 102 |
+
models.Images.generated.isnot(None)
|
| 103 |
+
).count()
|
| 104 |
+
print(f"DEBUG: Images with generated field: {images_with_generated}")
|
| 105 |
+
|
| 106 |
+
images_with_model = db.query(models.Images).filter(
|
| 107 |
+
models.Images.model.isnot(None)
|
| 108 |
+
).count()
|
| 109 |
+
print(f"DEBUG: Images with model field: {images_with_model}")
|
| 110 |
+
|
| 111 |
+
results = db.query(models.Images).filter(
|
| 112 |
+
models.Images.title.isnot(None)
|
| 113 |
).all()
|
| 114 |
|
| 115 |
+
print(f"DEBUG: Query returned {len(results)} results")
|
| 116 |
+
for img in results:
|
| 117 |
+
print(f"DEBUG: Image {img.image_id}: title='{img.title}', generated='{img.generated}', model='{img.model}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
+
return results
|
| 120 |
|
| 121 |
+
def update_caption(db: Session, image_id: str, update: schemas.CaptionUpdate):
|
| 122 |
+
"""Update caption data for an image"""
|
| 123 |
+
img = db.get(models.Images, image_id)
|
| 124 |
+
if not img:
|
| 125 |
return None
|
| 126 |
|
| 127 |
for field, value in update.dict(exclude_unset=True).items():
|
| 128 |
+
setattr(img, field, value)
|
| 129 |
|
| 130 |
db.commit()
|
| 131 |
+
db.refresh(img)
|
| 132 |
+
return img
|
| 133 |
|
| 134 |
+
def delete_caption(db: Session, image_id: str):
|
| 135 |
+
"""Delete caption data for an image (sets caption fields to None)"""
|
| 136 |
+
img = db.get(models.Images, image_id)
|
| 137 |
+
if not img:
|
| 138 |
return False
|
| 139 |
|
| 140 |
+
img.title = None
|
| 141 |
+
img.prompt = None
|
| 142 |
+
img.model = None
|
| 143 |
+
img.schema_id = None
|
| 144 |
+
img.raw_json = None
|
| 145 |
+
img.generated = None
|
| 146 |
+
img.edited = None
|
| 147 |
+
img.accuracy = None
|
| 148 |
+
img.context = None
|
| 149 |
+
img.usability = None
|
| 150 |
+
img.starred = False
|
| 151 |
+
|
| 152 |
db.commit()
|
| 153 |
return True
|
| 154 |
|
py_backend/app/database.py
CHANGED
|
@@ -7,10 +7,8 @@ from sqlalchemy.orm import sessionmaker, declarative_base
|
|
| 7 |
from .config import settings
|
| 8 |
|
| 9 |
raw_db_url = settings.DATABASE_URL
|
| 10 |
-
logging.getLogger().warning(f"Raw DATABASE_URL = {raw_db_url!r}")
|
| 11 |
-
|
| 12 |
clean_db_url = raw_db_url.split("?", 1)[0]
|
| 13 |
-
|
| 14 |
|
| 15 |
engine = create_engine(
|
| 16 |
clean_db_url,
|
|
|
|
| 7 |
from .config import settings
|
| 8 |
|
| 9 |
raw_db_url = settings.DATABASE_URL
|
|
|
|
|
|
|
| 10 |
clean_db_url = raw_db_url.split("?", 1)[0]
|
| 11 |
+
print(f"database: {clean_db_url.split('@')[-1].split('/')[-1]}")
|
| 12 |
|
| 13 |
engine = create_engine(
|
| 14 |
clean_db_url,
|
py_backend/app/images.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/app/schemas/images.py
|
| 2 |
+
from pydantic import BaseModel, AnyHttpUrl, Field
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
class CreateImageFromUrlIn(BaseModel):
|
| 6 |
+
url: str
|
| 7 |
+
source: str
|
| 8 |
+
event_type: str
|
| 9 |
+
epsg: str
|
| 10 |
+
image_type: str
|
| 11 |
+
countries: List[str] = Field(default_factory=list)
|
| 12 |
+
|
| 13 |
+
class CreateImageFromUrlOut(BaseModel):
|
| 14 |
+
image_id: str
|
| 15 |
+
image_url: str
|
py_backend/app/main.py
CHANGED
|
@@ -2,6 +2,9 @@ from fastapi import FastAPI
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from app.routers import upload, caption, metadata, models
|
| 4 |
from app.config import settings
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
app = FastAPI(title="PromptAid Vision")
|
| 7 |
|
|
@@ -16,10 +19,14 @@ app.add_middleware(
|
|
| 16 |
allow_headers=["*"],
|
| 17 |
)
|
| 18 |
|
| 19 |
-
app.include_router(upload.router, prefix="/api/images", tags=["images"])
|
| 20 |
app.include_router(caption.router, prefix="/api", tags=["captions"])
|
| 21 |
app.include_router(metadata.router, prefix="/api", tags=["metadata"])
|
| 22 |
app.include_router(models.router, prefix="/api", tags=["models"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
|
|
|
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from app.routers import upload, caption, metadata, models
|
| 4 |
from app.config import settings
|
| 5 |
+
from app.routers.images import router as images_router
|
| 6 |
+
|
| 7 |
+
|
| 8 |
|
| 9 |
app = FastAPI(title="PromptAid Vision")
|
| 10 |
|
|
|
|
| 19 |
allow_headers=["*"],
|
| 20 |
)
|
| 21 |
|
|
|
|
| 22 |
app.include_router(caption.router, prefix="/api", tags=["captions"])
|
| 23 |
app.include_router(metadata.router, prefix="/api", tags=["metadata"])
|
| 24 |
app.include_router(models.router, prefix="/api", tags=["models"])
|
| 25 |
+
app.include_router(upload.router, prefix="/api/images", tags=["images"])
|
| 26 |
+
app.include_router(images_router, prefix="/api/contribute", tags=["contribute"])
|
| 27 |
+
|
| 28 |
+
print("🚀 PromptAid Vision API server ready")
|
| 29 |
+
print("📊 Available endpoints: /api/images, /api/captions, /api/metadata, /api/models")
|
| 30 |
|
| 31 |
|
| 32 |
|
py_backend/app/models.py
CHANGED
|
@@ -74,44 +74,35 @@ class JSONSchema(Base):
|
|
| 74 |
|
| 75 |
class Images(Base):
|
| 76 |
__tablename__ = "images"
|
| 77 |
-
|
| 78 |
-
image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 79 |
-
file_key = Column(String, nullable=False)
|
| 80 |
-
sha256 = Column(String, nullable=False)
|
| 81 |
-
source = Column(String, ForeignKey("sources.s_code"), nullable=False)
|
| 82 |
-
event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
|
| 83 |
-
epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=True)
|
| 84 |
-
image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
|
| 85 |
-
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 86 |
-
captured_at = Column(TIMESTAMP(timezone=True))
|
| 87 |
-
|
| 88 |
-
countries = relationship("Country", secondary=image_countries, backref="images")
|
| 89 |
-
captions = relationship("Captions", back_populates="image", cascade="all, delete-orphan")
|
| 90 |
-
|
| 91 |
-
class Captions(Base):
|
| 92 |
-
__tablename__ = "captions"
|
| 93 |
__table_args__ = (
|
| 94 |
-
CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='
|
| 95 |
-
CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='
|
| 96 |
-
CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='
|
| 97 |
)
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
edited = Column(Text)
|
| 108 |
accuracy = Column(SmallInteger)
|
| 109 |
context = Column(SmallInteger)
|
| 110 |
usability = Column(SmallInteger)
|
| 111 |
starred = Column(Boolean, default=False)
|
| 112 |
-
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 113 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 114 |
|
| 115 |
-
|
| 116 |
-
schema
|
| 117 |
-
model_r
|
|
|
|
| 74 |
|
| 75 |
class Images(Base):
|
| 76 |
__tablename__ = "images"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
__table_args__ = (
|
| 78 |
+
CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_images_accuracy'),
|
| 79 |
+
CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_images_context'),
|
| 80 |
+
CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_images_usability'),
|
| 81 |
)
|
| 82 |
|
| 83 |
+
image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 84 |
+
file_key = Column(String, nullable=False)
|
| 85 |
+
sha256 = Column(String, nullable=False)
|
| 86 |
+
source = Column(String, ForeignKey("sources.s_code"), nullable=False)
|
| 87 |
+
event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
|
| 88 |
+
epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
|
| 89 |
+
image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
|
| 90 |
+
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 91 |
+
captured_at = Column(TIMESTAMP(timezone=True))
|
| 92 |
+
|
| 93 |
+
title = Column(String, nullable=True)
|
| 94 |
+
prompt = Column(String, nullable=True)
|
| 95 |
+
model = Column(String, ForeignKey("models.m_code"), nullable=True)
|
| 96 |
+
schema_id = Column(String, ForeignKey("json_schemas.schema_id"), nullable=True)
|
| 97 |
+
raw_json = Column(JSONB, nullable=True)
|
| 98 |
+
generated = Column(Text, nullable=True)
|
| 99 |
edited = Column(Text)
|
| 100 |
accuracy = Column(SmallInteger)
|
| 101 |
context = Column(SmallInteger)
|
| 102 |
usability = Column(SmallInteger)
|
| 103 |
starred = Column(Boolean, default=False)
|
|
|
|
| 104 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 105 |
|
| 106 |
+
countries = relationship("Country", secondary=image_countries, backref="images")
|
| 107 |
+
schema = relationship("JSONSchema")
|
| 108 |
+
model_r = relationship("Models", foreign_keys=[model])
|
py_backend/app/routers/caption.py
CHANGED
|
@@ -17,47 +17,43 @@ if settings.OPENAI_API_KEY:
|
|
| 17 |
try:
|
| 18 |
gpt4v_service = GPT4VService(settings.OPENAI_API_KEY)
|
| 19 |
vlm_manager.register_service(gpt4v_service)
|
| 20 |
-
print(f"
|
| 21 |
except Exception as e:
|
| 22 |
-
print(f"
|
| 23 |
else:
|
| 24 |
-
print("
|
| 25 |
|
| 26 |
if settings.GOOGLE_API_KEY:
|
| 27 |
try:
|
| 28 |
gemini_service = GeminiService(settings.GOOGLE_API_KEY)
|
| 29 |
vlm_manager.register_service(gemini_service)
|
| 30 |
-
print(f"
|
| 31 |
except Exception as e:
|
| 32 |
-
print(f"
|
| 33 |
else:
|
| 34 |
-
print("
|
| 35 |
|
| 36 |
if settings.HF_API_KEY:
|
| 37 |
-
print(f"DEBUG: Hugging Face API key found: {settings.HF_API_KEY[:10]}...")
|
| 38 |
try:
|
| 39 |
llava_service = LLaVAService(settings.HF_API_KEY)
|
| 40 |
vlm_manager.register_service(llava_service)
|
| 41 |
-
print(f"DEBUG: Registered LLaVA service: {llava_service.model_name}")
|
| 42 |
|
| 43 |
blip2_service = BLIP2Service(settings.HF_API_KEY)
|
| 44 |
vlm_manager.register_service(blip2_service)
|
| 45 |
-
print(f"DEBUG: Registered BLIP2 service: {blip2_service.model_name}")
|
| 46 |
|
| 47 |
instructblip_service = InstructBLIPService(settings.HF_API_KEY)
|
| 48 |
vlm_manager.register_service(instructblip_service)
|
| 49 |
-
print(f"DEBUG: Registered InstructBLIP service: {instructblip_service.model_name}")
|
| 50 |
|
| 51 |
-
print("
|
| 52 |
except Exception as e:
|
| 53 |
-
print(f"
|
| 54 |
import traceback
|
| 55 |
traceback.print_exc()
|
| 56 |
else:
|
| 57 |
-
print("
|
| 58 |
|
| 59 |
-
print(f"
|
| 60 |
-
print(f"
|
| 61 |
|
| 62 |
router = APIRouter()
|
| 63 |
|
|
@@ -70,7 +66,7 @@ def get_db():
|
|
| 70 |
|
| 71 |
@router.post(
|
| 72 |
"/images/{image_id}/caption",
|
| 73 |
-
response_model=schemas.
|
| 74 |
)
|
| 75 |
async def create_caption(
|
| 76 |
image_id: str,
|
|
@@ -98,20 +94,17 @@ async def create_caption(
|
|
| 98 |
|
| 99 |
metadata = {}
|
| 100 |
try:
|
| 101 |
-
print(f"DEBUG: calling VLM with model={model_name}")
|
| 102 |
result = await vlm_manager.generate_caption(
|
| 103 |
image_bytes=img_bytes,
|
| 104 |
prompt=prompt,
|
| 105 |
model_name=model_name,
|
| 106 |
)
|
| 107 |
text = result.get("caption", "")
|
| 108 |
-
used_model =
|
| 109 |
raw = result.get("raw_response", {})
|
| 110 |
metadata = result.get("metadata", {})
|
| 111 |
-
print(f"DEBUG: got caption: {text[:100]}… (model={used_model})")
|
| 112 |
-
print(f"DEBUG: extracted metadata: {metadata}")
|
| 113 |
except Exception as e:
|
| 114 |
-
print(f"
|
| 115 |
text = "This is a fallback caption due to VLM service error."
|
| 116 |
used_model = "STUB_MODEL"
|
| 117 |
raw = {"error": str(e), "fallback": True}
|
|
@@ -127,67 +120,130 @@ async def create_caption(
|
|
| 127 |
text=text,
|
| 128 |
metadata=metadata,
|
| 129 |
)
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
@router.get(
|
| 133 |
-
"/
|
| 134 |
-
response_model=schemas.
|
| 135 |
)
|
| 136 |
def get_caption(
|
| 137 |
-
|
| 138 |
db: Session = Depends(get_db),
|
| 139 |
):
|
| 140 |
-
caption = crud.get_caption(db,
|
| 141 |
-
if not caption:
|
| 142 |
raise HTTPException(404, "caption not found")
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
@router.get(
|
| 146 |
"/images/{image_id}/captions",
|
| 147 |
-
response_model=List[schemas.
|
| 148 |
)
|
| 149 |
def get_captions_by_image(
|
| 150 |
image_id: str,
|
| 151 |
db: Session = Depends(get_db),
|
| 152 |
):
|
| 153 |
-
"""Get
|
| 154 |
captions = crud.get_captions_by_image(db, image_id)
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
@router.get(
|
| 158 |
"/captions",
|
| 159 |
-
response_model=List[schemas.
|
| 160 |
)
|
| 161 |
def get_all_captions_with_images(
|
| 162 |
db: Session = Depends(get_db),
|
| 163 |
):
|
| 164 |
-
"""Get all
|
|
|
|
| 165 |
captions = crud.get_all_captions_with_images(db)
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
@router.put(
|
| 169 |
-
"/
|
| 170 |
-
response_model=schemas.
|
| 171 |
)
|
| 172 |
def update_caption(
|
| 173 |
-
|
| 174 |
update: schemas.CaptionUpdate,
|
| 175 |
db: Session = Depends(get_db),
|
| 176 |
):
|
| 177 |
-
caption = crud.update_caption(db,
|
| 178 |
if not caption:
|
| 179 |
raise HTTPException(404, "caption not found")
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
@router.delete(
|
| 183 |
-
"/
|
| 184 |
)
|
| 185 |
def delete_caption(
|
| 186 |
-
|
| 187 |
db: Session = Depends(get_db),
|
| 188 |
):
|
| 189 |
-
"""Delete
|
| 190 |
-
success = crud.delete_caption(db,
|
| 191 |
if not success:
|
| 192 |
raise HTTPException(404, "caption not found")
|
| 193 |
return {"message": "Caption deleted successfully"}
|
|
|
|
| 17 |
try:
|
| 18 |
gpt4v_service = GPT4VService(settings.OPENAI_API_KEY)
|
| 19 |
vlm_manager.register_service(gpt4v_service)
|
| 20 |
+
print(f"✓ GPT-4 Vision service registered")
|
| 21 |
except Exception as e:
|
| 22 |
+
print(f"✗ GPT-4 Vision service failed: {e}")
|
| 23 |
else:
|
| 24 |
+
print("○ GPT-4 Vision service not configured")
|
| 25 |
|
| 26 |
if settings.GOOGLE_API_KEY:
|
| 27 |
try:
|
| 28 |
gemini_service = GeminiService(settings.GOOGLE_API_KEY)
|
| 29 |
vlm_manager.register_service(gemini_service)
|
| 30 |
+
print(f"✓ Gemini service registered")
|
| 31 |
except Exception as e:
|
| 32 |
+
print(f"✗ Gemini service failed: {e}")
|
| 33 |
else:
|
| 34 |
+
print("○ Gemini service not configured")
|
| 35 |
|
| 36 |
if settings.HF_API_KEY:
|
|
|
|
| 37 |
try:
|
| 38 |
llava_service = LLaVAService(settings.HF_API_KEY)
|
| 39 |
vlm_manager.register_service(llava_service)
|
|
|
|
| 40 |
|
| 41 |
blip2_service = BLIP2Service(settings.HF_API_KEY)
|
| 42 |
vlm_manager.register_service(blip2_service)
|
|
|
|
| 43 |
|
| 44 |
instructblip_service = InstructBLIPService(settings.HF_API_KEY)
|
| 45 |
vlm_manager.register_service(instructblip_service)
|
|
|
|
| 46 |
|
| 47 |
+
print(f"✓ Hugging Face services registered (LLaVA, BLIP2, InstructBLIP)")
|
| 48 |
except Exception as e:
|
| 49 |
+
print(f"✗ Hugging Face services failed: {e}")
|
| 50 |
import traceback
|
| 51 |
traceback.print_exc()
|
| 52 |
else:
|
| 53 |
+
print("○ Hugging Face services not configured")
|
| 54 |
|
| 55 |
+
print(f"✓ Available models: {', '.join(vlm_manager.get_available_models())}")
|
| 56 |
+
print(f"✓ Total services: {len(vlm_manager.services)}")
|
| 57 |
|
| 58 |
router = APIRouter()
|
| 59 |
|
|
|
|
| 66 |
|
| 67 |
@router.post(
|
| 68 |
"/images/{image_id}/caption",
|
| 69 |
+
response_model=schemas.ImageOut,
|
| 70 |
)
|
| 71 |
async def create_caption(
|
| 72 |
image_id: str,
|
|
|
|
| 94 |
|
| 95 |
metadata = {}
|
| 96 |
try:
|
|
|
|
| 97 |
result = await vlm_manager.generate_caption(
|
| 98 |
image_bytes=img_bytes,
|
| 99 |
prompt=prompt,
|
| 100 |
model_name=model_name,
|
| 101 |
)
|
| 102 |
text = result.get("caption", "")
|
| 103 |
+
used_model = model_name or "STUB_MODEL"
|
| 104 |
raw = result.get("raw_response", {})
|
| 105 |
metadata = result.get("metadata", {})
|
|
|
|
|
|
|
| 106 |
except Exception as e:
|
| 107 |
+
print(f"VLM error, using fallback: {e}")
|
| 108 |
text = "This is a fallback caption due to VLM service error."
|
| 109 |
used_model = "STUB_MODEL"
|
| 110 |
raw = {"error": str(e), "fallback": True}
|
|
|
|
| 120 |
text=text,
|
| 121 |
metadata=metadata,
|
| 122 |
)
|
| 123 |
+
|
| 124 |
+
db.refresh(c)
|
| 125 |
+
|
| 126 |
+
from .upload import convert_image_to_dict
|
| 127 |
+
try:
|
| 128 |
+
url = storage.generate_presigned_url(c.file_key, expires_in=3600)
|
| 129 |
+
except Exception:
|
| 130 |
+
url = f"/api/images/{c.image_id}/file"
|
| 131 |
+
|
| 132 |
+
img_dict = convert_image_to_dict(c, url)
|
| 133 |
+
return schemas.ImageOut(**img_dict)
|
| 134 |
|
| 135 |
@router.get(
|
| 136 |
+
"/images/{image_id}/caption",
|
| 137 |
+
response_model=schemas.ImageOut,
|
| 138 |
)
|
| 139 |
def get_caption(
|
| 140 |
+
image_id: str,
|
| 141 |
db: Session = Depends(get_db),
|
| 142 |
):
|
| 143 |
+
caption = crud.get_caption(db, image_id)
|
| 144 |
+
if not caption or not caption.title:
|
| 145 |
raise HTTPException(404, "caption not found")
|
| 146 |
+
|
| 147 |
+
db.refresh(caption)
|
| 148 |
+
|
| 149 |
+
from .upload import convert_image_to_dict
|
| 150 |
+
try:
|
| 151 |
+
url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
|
| 152 |
+
except Exception:
|
| 153 |
+
url = f"/api/images/{caption.image_id}/file"
|
| 154 |
+
|
| 155 |
+
img_dict = convert_image_to_dict(caption, url)
|
| 156 |
+
return schemas.ImageOut(**img_dict)
|
| 157 |
|
| 158 |
@router.get(
|
| 159 |
"/images/{image_id}/captions",
|
| 160 |
+
response_model=List[schemas.ImageOut],
|
| 161 |
)
|
| 162 |
def get_captions_by_image(
|
| 163 |
image_id: str,
|
| 164 |
db: Session = Depends(get_db),
|
| 165 |
):
|
| 166 |
+
"""Get caption data for a specific image"""
|
| 167 |
captions = crud.get_captions_by_image(db, image_id)
|
| 168 |
+
|
| 169 |
+
from .upload import convert_image_to_dict
|
| 170 |
+
result = []
|
| 171 |
+
for caption in captions:
|
| 172 |
+
db.refresh(caption)
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
|
| 176 |
+
except Exception:
|
| 177 |
+
url = f"/api/images/{caption.image_id}/file"
|
| 178 |
+
|
| 179 |
+
img_dict = convert_image_to_dict(caption, url)
|
| 180 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 181 |
+
|
| 182 |
+
return result
|
| 183 |
|
| 184 |
@router.get(
|
| 185 |
"/captions",
|
| 186 |
+
response_model=List[schemas.ImageOut],
|
| 187 |
)
|
| 188 |
def get_all_captions_with_images(
|
| 189 |
db: Session = Depends(get_db),
|
| 190 |
):
|
| 191 |
+
"""Get all images that have caption data"""
|
| 192 |
+
print(f"DEBUG: Fetching all captions with images...")
|
| 193 |
captions = crud.get_all_captions_with_images(db)
|
| 194 |
+
print(f"DEBUG: Found {len(captions)} images with caption data")
|
| 195 |
+
|
| 196 |
+
from .upload import convert_image_to_dict
|
| 197 |
+
result = []
|
| 198 |
+
for caption in captions:
|
| 199 |
+
print(f"DEBUG: Processing image {caption.image_id}, title: {caption.title}, generated: {caption.generated}, model: {caption.model}")
|
| 200 |
+
|
| 201 |
+
db.refresh(caption)
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
|
| 205 |
+
except Exception:
|
| 206 |
+
url = f"/api/images/{caption.image_id}/file"
|
| 207 |
+
|
| 208 |
+
img_dict = convert_image_to_dict(caption, url)
|
| 209 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 210 |
+
|
| 211 |
+
print(f"DEBUG: Returning {len(result)} formatted results")
|
| 212 |
+
return result
|
| 213 |
|
| 214 |
@router.put(
|
| 215 |
+
"/images/{image_id}/caption",
|
| 216 |
+
response_model=schemas.ImageOut,
|
| 217 |
)
|
| 218 |
def update_caption(
|
| 219 |
+
image_id: str,
|
| 220 |
update: schemas.CaptionUpdate,
|
| 221 |
db: Session = Depends(get_db),
|
| 222 |
):
|
| 223 |
+
caption = crud.update_caption(db, image_id, update)
|
| 224 |
if not caption:
|
| 225 |
raise HTTPException(404, "caption not found")
|
| 226 |
+
|
| 227 |
+
db.refresh(caption)
|
| 228 |
+
|
| 229 |
+
from .upload import convert_image_to_dict
|
| 230 |
+
try:
|
| 231 |
+
url = storage.generate_presigned_url(caption.file_key, expires_in=3600)
|
| 232 |
+
except Exception:
|
| 233 |
+
url = f"/api/images/{caption.image_id}/file"
|
| 234 |
+
|
| 235 |
+
img_dict = convert_image_to_dict(caption, url)
|
| 236 |
+
return schemas.ImageOut(**img_dict)
|
| 237 |
|
| 238 |
@router.delete(
|
| 239 |
+
"/images/{image_id}/caption",
|
| 240 |
)
|
| 241 |
def delete_caption(
|
| 242 |
+
image_id: str,
|
| 243 |
db: Session = Depends(get_db),
|
| 244 |
):
|
| 245 |
+
"""Delete caption data for an image"""
|
| 246 |
+
success = crud.delete_caption(db, image_id)
|
| 247 |
if not success:
|
| 248 |
raise HTTPException(404, "caption not found")
|
| 249 |
return {"message": "Caption deleted successfully"}
|
py_backend/app/routers/images.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import mimetypes
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from ..database import SessionLocal
|
| 7 |
+
from ..models import Images, image_countries
|
| 8 |
+
from ..images import CreateImageFromUrlIn, CreateImageFromUrlOut
|
| 9 |
+
from .. import storage
|
| 10 |
+
from ..storage import upload_bytes, get_object_url
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
def get_db():
|
| 15 |
+
db = SessionLocal()
|
| 16 |
+
try:
|
| 17 |
+
yield db
|
| 18 |
+
finally:
|
| 19 |
+
db.close()
|
| 20 |
+
|
| 21 |
+
@router.post("/from-url", response_model=CreateImageFromUrlOut)
|
| 22 |
+
async def create_image_from_url(payload: CreateImageFromUrlIn, db: Session = Depends(get_db)):
|
| 23 |
+
print(f"DEBUG: Received payload: {payload}")
|
| 24 |
+
try:
|
| 25 |
+
if payload.url.startswith('/api/images/') and '/file' in payload.url:
|
| 26 |
+
image_id = payload.url.split('/api/images/')[1].split('/file')[0]
|
| 27 |
+
print(f"DEBUG: Extracted image_id: {image_id}")
|
| 28 |
+
else:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Invalid image URL format")
|
| 30 |
+
|
| 31 |
+
existing_image = db.query(Images).filter(Images.image_id == image_id).first()
|
| 32 |
+
if not existing_image:
|
| 33 |
+
raise HTTPException(status_code=404, detail="Source image not found")
|
| 34 |
+
print(f"DEBUG: Found existing image: {existing_image.image_id}")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
response = storage.s3.get_object(
|
| 38 |
+
Bucket=storage.settings.S3_BUCKET,
|
| 39 |
+
Key=existing_image.file_key,
|
| 40 |
+
)
|
| 41 |
+
data = response["Body"].read()
|
| 42 |
+
content_type = "image/jpeg"
|
| 43 |
+
print(f"DEBUG: Downloaded image data: {len(data)} bytes")
|
| 44 |
+
except Exception as e:
|
| 45 |
+
raise HTTPException(status_code=400, detail=f"Failed to fetch image from storage: {e}")
|
| 46 |
+
|
| 47 |
+
if len(data) > 25 * 1024 * 1024:
|
| 48 |
+
raise HTTPException(status_code=413, detail="Image too large")
|
| 49 |
+
|
| 50 |
+
ext = mimetypes.guess_extension(content_type) or ".jpg"
|
| 51 |
+
key = upload_bytes(data, filename=f"contributed{ext}", content_type=content_type)
|
| 52 |
+
image_url = get_object_url(key, expires_in=86400)
|
| 53 |
+
print(f"DEBUG: Uploaded new image with key: {key}")
|
| 54 |
+
|
| 55 |
+
sha = hashlib.sha256(data).hexdigest()
|
| 56 |
+
print(f"DEBUG: Creating new Images object...")
|
| 57 |
+
|
| 58 |
+
img = Images(
|
| 59 |
+
file_key=key,
|
| 60 |
+
sha256=sha,
|
| 61 |
+
source=payload.source,
|
| 62 |
+
event_type=payload.event_type,
|
| 63 |
+
epsg=payload.epsg,
|
| 64 |
+
image_type=payload.image_type,
|
| 65 |
+
title="no title",
|
| 66 |
+
prompt="",
|
| 67 |
+
model="STUB_MODEL",
|
| 68 |
+
schema_id="[email protected]",
|
| 69 |
+
raw_json={},
|
| 70 |
+
generated="",
|
| 71 |
+
edited="",
|
| 72 |
+
accuracy=50,
|
| 73 |
+
context=50,
|
| 74 |
+
usability=50,
|
| 75 |
+
starred=False
|
| 76 |
+
)
|
| 77 |
+
print(f"DEBUG: Images object created: {img}")
|
| 78 |
+
db.add(img)
|
| 79 |
+
db.flush() # get image_id
|
| 80 |
+
print(f"DEBUG: New image_id: {img.image_id}")
|
| 81 |
+
|
| 82 |
+
for c in payload.countries:
|
| 83 |
+
db.execute(image_countries.insert().values(image_id=img.image_id, c_code=c))
|
| 84 |
+
|
| 85 |
+
db.commit()
|
| 86 |
+
print(f"DEBUG: Database commit successful")
|
| 87 |
+
|
| 88 |
+
result = CreateImageFromUrlOut(image_id=str(img.image_id), image_url=image_url)
|
| 89 |
+
print(f"DEBUG: Returning result: {result}")
|
| 90 |
+
return result
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"DEBUG: Exception occurred: {type(e).__name__}: {str(e)}")
|
| 94 |
+
db.rollback()
|
| 95 |
+
raise HTTPException(status_code=500, detail=f"Failed to create image: {str(e)}")
|
py_backend/app/routers/metadata.py
CHANGED
|
@@ -12,13 +12,13 @@ def get_db():
|
|
| 12 |
finally:
|
| 13 |
db.close()
|
| 14 |
|
| 15 |
-
@router.put("/maps/{map_id}/metadata", response_model=schemas.
|
| 16 |
def update_metadata(
|
| 17 |
map_id: str,
|
| 18 |
update: schemas.CaptionUpdate,
|
| 19 |
db: Session = Depends(get_db)
|
| 20 |
):
|
| 21 |
-
c = crud.update_caption(db, map_id,
|
| 22 |
if not c:
|
| 23 |
raise HTTPException(404, "caption not found")
|
| 24 |
return c
|
|
|
|
| 12 |
finally:
|
| 13 |
db.close()
|
| 14 |
|
| 15 |
+
@router.put("/maps/{map_id}/metadata", response_model=schemas.ImageOut)
|
| 16 |
def update_metadata(
|
| 17 |
map_id: str,
|
| 18 |
update: schemas.CaptionUpdate,
|
| 19 |
db: Session = Depends(get_db)
|
| 20 |
):
|
| 21 |
+
c = crud.update_caption(db, map_id, update)
|
| 22 |
if not c:
|
| 23 |
raise HTTPException(404, "caption not found")
|
| 24 |
return c
|
py_backend/app/routers/models.py
CHANGED
|
@@ -57,7 +57,7 @@ def get_model_info(model_code: str, db: Session = Depends(get_db)):
|
|
| 57 |
async def test_model(model_code: str, db: Session = Depends(get_db)):
|
| 58 |
"""Test a specific model with a sample image"""
|
| 59 |
try:
|
| 60 |
-
service = vlm_manager.
|
| 61 |
if not service:
|
| 62 |
raise HTTPException(404, "Model service not found")
|
| 63 |
|
|
|
|
| 57 |
async def test_model(model_code: str, db: Session = Depends(get_db)):
|
| 58 |
"""Test a specific model with a sample image"""
|
| 59 |
try:
|
| 60 |
+
service = vlm_manager.services.get(model_code)
|
| 61 |
if not service:
|
| 62 |
raise HTTPException(404, "Model service not found")
|
| 63 |
|
py_backend/app/routers/upload.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
from fastapi import APIRouter, UploadFile, Form, Depends, HTTPException, Response
|
|
|
|
| 2 |
import io
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from .. import crud, schemas, storage, database
|
| 5 |
from typing import List
|
| 6 |
import boto3
|
|
|
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def get_db():
|
| 11 |
db = database.SessionLocal()
|
| 12 |
try:
|
|
@@ -17,6 +27,14 @@ def get_db():
|
|
| 17 |
|
| 18 |
def convert_image_to_dict(img, image_url):
|
| 19 |
"""Helper function to convert SQLAlchemy image model to dict for Pydantic"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
img_dict = {
|
| 21 |
"image_id": img.image_id,
|
| 22 |
"file_key": img.file_key,
|
|
@@ -26,35 +44,28 @@ def convert_image_to_dict(img, image_url):
|
|
| 26 |
"epsg": img.epsg,
|
| 27 |
"image_type": img.image_type,
|
| 28 |
"image_url": image_url,
|
| 29 |
-
"countries":
|
| 30 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
-
if img.captions and len(img.captions) > 0:
|
| 34 |
-
img_dict["captions"] = [{
|
| 35 |
-
"cap_id": caption.cap_id,
|
| 36 |
-
"image_id": caption.image_id,
|
| 37 |
-
"title": caption.title,
|
| 38 |
-
"prompt": caption.prompt,
|
| 39 |
-
"model": caption.model,
|
| 40 |
-
"schema_id": caption.schema_id,
|
| 41 |
-
"raw_json": caption.raw_json,
|
| 42 |
-
"generated": caption.generated,
|
| 43 |
-
"edited": caption.edited,
|
| 44 |
-
"accuracy": caption.accuracy,
|
| 45 |
-
"context": caption.context,
|
| 46 |
-
"usability": caption.usability,
|
| 47 |
-
"starred": caption.starred,
|
| 48 |
-
"created_at": caption.created_at,
|
| 49 |
-
"updated_at": caption.updated_at
|
| 50 |
-
} for caption in img.captions]
|
| 51 |
-
|
| 52 |
return img_dict
|
| 53 |
|
| 54 |
|
| 55 |
@router.get("/", response_model=List[schemas.ImageOut])
|
| 56 |
def list_images(db: Session = Depends(get_db)):
|
| 57 |
-
"""Get all images with their
|
| 58 |
images = crud.get_images(db)
|
| 59 |
result = []
|
| 60 |
for img in images:
|
|
@@ -115,6 +126,51 @@ async def upload_image(
|
|
| 115 |
result = schemas.ImageOut(**img_dict)
|
| 116 |
return result
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
@router.get("/{image_id}/file")
|
| 119 |
async def get_image_file(image_id: str, db: Session = Depends(get_db)):
|
| 120 |
"""Serve the actual image file"""
|
|
@@ -142,10 +198,16 @@ def update_image_metadata(
|
|
| 142 |
db: Session = Depends(get_db)
|
| 143 |
):
|
| 144 |
"""Update image metadata (source, type, epsg, image_type, countries)"""
|
|
|
|
|
|
|
|
|
|
| 145 |
img = crud.get_image(db, image_id)
|
| 146 |
if not img:
|
|
|
|
| 147 |
raise HTTPException(404, "Image not found")
|
| 148 |
|
|
|
|
|
|
|
| 149 |
try:
|
| 150 |
if metadata.source is not None:
|
| 151 |
img.source = metadata.source
|
|
@@ -155,22 +217,36 @@ def update_image_metadata(
|
|
| 155 |
img.epsg = metadata.epsg
|
| 156 |
if metadata.image_type is not None:
|
| 157 |
img.image_type = metadata.image_type
|
|
|
|
| 158 |
if metadata.countries is not None:
|
| 159 |
-
|
|
|
|
| 160 |
for country_code in metadata.countries:
|
| 161 |
country = crud.get_country(db, country_code)
|
| 162 |
if country:
|
| 163 |
img.countries.append(country)
|
|
|
|
| 164 |
|
| 165 |
db.commit()
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
except Exception as e:
|
| 168 |
db.rollback()
|
|
|
|
| 169 |
raise HTTPException(500, f"Failed to update image metadata: {str(e)}")
|
| 170 |
|
| 171 |
@router.delete("/{image_id}")
|
| 172 |
def delete_image(image_id: str, db: Session = Depends(get_db)):
|
| 173 |
-
"""Delete an image and its associated caption"""
|
| 174 |
img = crud.get_image(db, image_id)
|
| 175 |
if not img:
|
| 176 |
raise HTTPException(404, "Image not found")
|
|
|
|
| 1 |
from fastapi import APIRouter, UploadFile, Form, Depends, HTTPException, Response
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
import io
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
from .. import crud, schemas, storage, database
|
| 6 |
from typing import List
|
| 7 |
import boto3
|
| 8 |
+
import time
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
+
class CopyImageRequest(BaseModel):
|
| 13 |
+
source_image_id: str
|
| 14 |
+
source: str
|
| 15 |
+
event_type: str
|
| 16 |
+
countries: str = ""
|
| 17 |
+
epsg: str = ""
|
| 18 |
+
image_type: str = "crisis_map"
|
| 19 |
+
|
| 20 |
def get_db():
|
| 21 |
db = database.SessionLocal()
|
| 22 |
try:
|
|
|
|
| 27 |
|
| 28 |
def convert_image_to_dict(img, image_url):
|
| 29 |
"""Helper function to convert SQLAlchemy image model to dict for Pydantic"""
|
| 30 |
+
countries_list = []
|
| 31 |
+
if hasattr(img, 'countries') and img.countries is not None:
|
| 32 |
+
try:
|
| 33 |
+
countries_list = [{"c_code": c.c_code, "label": c.label, "r_code": c.r_code} for c in img.countries]
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Warning: Error processing countries for image {img.image_id}: {e}")
|
| 36 |
+
countries_list = []
|
| 37 |
+
|
| 38 |
img_dict = {
|
| 39 |
"image_id": img.image_id,
|
| 40 |
"file_key": img.file_key,
|
|
|
|
| 44 |
"epsg": img.epsg,
|
| 45 |
"image_type": img.image_type,
|
| 46 |
"image_url": image_url,
|
| 47 |
+
"countries": countries_list,
|
| 48 |
+
"title": img.title,
|
| 49 |
+
"prompt": img.prompt,
|
| 50 |
+
"model": img.model,
|
| 51 |
+
"schema_id": img.schema_id,
|
| 52 |
+
"raw_json": img.raw_json,
|
| 53 |
+
"generated": img.generated,
|
| 54 |
+
"edited": img.edited,
|
| 55 |
+
"accuracy": img.accuracy,
|
| 56 |
+
"context": img.context,
|
| 57 |
+
"usability": img.usability,
|
| 58 |
+
"starred": img.starred if img.starred is not None else False,
|
| 59 |
+
"created_at": img.created_at,
|
| 60 |
+
"updated_at": img.updated_at
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
return img_dict
|
| 64 |
|
| 65 |
|
| 66 |
@router.get("/", response_model=List[schemas.ImageOut])
|
| 67 |
def list_images(db: Session = Depends(get_db)):
|
| 68 |
+
"""Get all images with their caption data"""
|
| 69 |
images = crud.get_images(db)
|
| 70 |
result = []
|
| 71 |
for img in images:
|
|
|
|
| 126 |
result = schemas.ImageOut(**img_dict)
|
| 127 |
return result
|
| 128 |
|
| 129 |
+
@router.post("/copy", response_model=schemas.ImageOut)
|
| 130 |
+
async def copy_image_for_contribution(
|
| 131 |
+
request: CopyImageRequest,
|
| 132 |
+
db: Session = Depends(get_db)
|
| 133 |
+
):
|
| 134 |
+
"""Copy an existing image for contribution purposes, creating a new image_id"""
|
| 135 |
+
source_img = crud.get_image(db, request.source_image_id)
|
| 136 |
+
if not source_img:
|
| 137 |
+
raise HTTPException(404, "Source image not found")
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
response = storage.s3.get_object(
|
| 141 |
+
Bucket=storage.settings.S3_BUCKET,
|
| 142 |
+
Key=source_img.file_key,
|
| 143 |
+
)
|
| 144 |
+
image_content = response["Body"].read()
|
| 145 |
+
|
| 146 |
+
new_filename = f"contribution_{request.source_image_id}_{int(time.time())}.jpg"
|
| 147 |
+
new_key = storage.upload_fileobj(io.BytesIO(image_content), new_filename)
|
| 148 |
+
|
| 149 |
+
countries_list = [c.strip() for c in request.countries.split(',') if c.strip()] if request.countries else []
|
| 150 |
+
|
| 151 |
+
new_img = crud.create_image(
|
| 152 |
+
db,
|
| 153 |
+
request.source,
|
| 154 |
+
request.event_type,
|
| 155 |
+
new_key,
|
| 156 |
+
source_img.sha256,
|
| 157 |
+
countries_list,
|
| 158 |
+
request.epsg,
|
| 159 |
+
request.image_type
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
url = storage.generate_presigned_url(new_key, expires_in=3600)
|
| 164 |
+
except Exception as e:
|
| 165 |
+
url = f"/api/images/{new_img.image_id}/file"
|
| 166 |
+
|
| 167 |
+
img_dict = convert_image_to_dict(new_img, url)
|
| 168 |
+
result = schemas.ImageOut(**img_dict)
|
| 169 |
+
return result
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
raise HTTPException(500, f"Failed to copy image: {str(e)}")
|
| 173 |
+
|
| 174 |
@router.get("/{image_id}/file")
|
| 175 |
async def get_image_file(image_id: str, db: Session = Depends(get_db)):
|
| 176 |
"""Serve the actual image file"""
|
|
|
|
| 198 |
db: Session = Depends(get_db)
|
| 199 |
):
|
| 200 |
"""Update image metadata (source, type, epsg, image_type, countries)"""
|
| 201 |
+
print(f"DEBUG: Updating metadata for image {image_id}")
|
| 202 |
+
print(f"DEBUG: Metadata received: {metadata}")
|
| 203 |
+
|
| 204 |
img = crud.get_image(db, image_id)
|
| 205 |
if not img:
|
| 206 |
+
print(f"DEBUG: Image {image_id} not found in database")
|
| 207 |
raise HTTPException(404, "Image not found")
|
| 208 |
|
| 209 |
+
print(f"DEBUG: Found image {image_id} in database")
|
| 210 |
+
|
| 211 |
try:
|
| 212 |
if metadata.source is not None:
|
| 213 |
img.source = metadata.source
|
|
|
|
| 217 |
img.epsg = metadata.epsg
|
| 218 |
if metadata.image_type is not None:
|
| 219 |
img.image_type = metadata.image_type
|
| 220 |
+
|
| 221 |
if metadata.countries is not None:
|
| 222 |
+
print(f"DEBUG: Updating countries to: {metadata.countries}")
|
| 223 |
+
img.countries.clear()
|
| 224 |
for country_code in metadata.countries:
|
| 225 |
country = crud.get_country(db, country_code)
|
| 226 |
if country:
|
| 227 |
img.countries.append(country)
|
| 228 |
+
print(f"DEBUG: Added country: {country_code}")
|
| 229 |
|
| 230 |
db.commit()
|
| 231 |
+
db.refresh(img)
|
| 232 |
+
print(f"DEBUG: Metadata update successful for image {image_id}")
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
url = storage.generate_presigned_url(img.file_key, expires_in=3600)
|
| 236 |
+
except Exception:
|
| 237 |
+
url = f"/api/images/{img.image_id}/file"
|
| 238 |
+
|
| 239 |
+
img_dict = convert_image_to_dict(img, url)
|
| 240 |
+
return schemas.ImageOut(**img_dict)
|
| 241 |
+
|
| 242 |
except Exception as e:
|
| 243 |
db.rollback()
|
| 244 |
+
print(f"DEBUG: Metadata update failed for image {image_id}: {str(e)}")
|
| 245 |
raise HTTPException(500, f"Failed to update image metadata: {str(e)}")
|
| 246 |
|
| 247 |
@router.delete("/{image_id}")
|
| 248 |
def delete_image(image_id: str, db: Session = Depends(get_db)):
|
| 249 |
+
"""Delete an image and its associated caption data"""
|
| 250 |
img = crud.get_image(db, image_id)
|
| 251 |
if not img:
|
| 252 |
raise HTTPException(404, "Image not found")
|
py_backend/app/schemas.py
CHANGED
|
@@ -27,40 +27,12 @@ class ImageOut(BaseModel):
|
|
| 27 |
image_type: str
|
| 28 |
image_url: str
|
| 29 |
countries: List["CountryOut"] = []
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
cap_id: UUID
|
| 37 |
-
image_id: UUID
|
| 38 |
-
title: str
|
| 39 |
-
prompt: str
|
| 40 |
-
model: str
|
| 41 |
-
schema_id: str
|
| 42 |
-
raw_json: dict
|
| 43 |
-
generated: str
|
| 44 |
-
edited: Optional[str] = None
|
| 45 |
-
accuracy: Optional[int] = None
|
| 46 |
-
context: Optional[int] = None
|
| 47 |
-
usability: Optional[int] = None
|
| 48 |
-
starred: bool = False
|
| 49 |
-
created_at: Optional[datetime] = None
|
| 50 |
-
updated_at: Optional[datetime] = None
|
| 51 |
-
|
| 52 |
-
class Config:
|
| 53 |
-
from_attributes = True
|
| 54 |
-
|
| 55 |
-
class CaptionWithImageOut(BaseModel):
|
| 56 |
-
cap_id: UUID
|
| 57 |
-
image_id: UUID
|
| 58 |
-
title: str
|
| 59 |
-
prompt: str
|
| 60 |
-
model: str
|
| 61 |
-
schema_id: str
|
| 62 |
-
raw_json: dict
|
| 63 |
-
generated: str
|
| 64 |
edited: Optional[str] = None
|
| 65 |
accuracy: Optional[int] = None
|
| 66 |
context: Optional[int] = None
|
|
@@ -68,19 +40,17 @@ class CaptionWithImageOut(BaseModel):
|
|
| 68 |
starred: bool = False
|
| 69 |
created_at: Optional[datetime] = None
|
| 70 |
updated_at: Optional[datetime] = None
|
| 71 |
-
file_key: str
|
| 72 |
-
image_url: str
|
| 73 |
-
source: str
|
| 74 |
-
event_type: str
|
| 75 |
-
epsg: str
|
| 76 |
-
image_type: str
|
| 77 |
-
countries: List["CountryOut"] = []
|
| 78 |
|
| 79 |
class Config:
|
| 80 |
from_attributes = True
|
| 81 |
|
| 82 |
class CaptionUpdate(BaseModel):
|
| 83 |
title: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
edited: Optional[str] = None
|
| 85 |
accuracy: Optional[int] = None
|
| 86 |
context: Optional[int] = None
|
|
|
|
| 27 |
image_type: str
|
| 28 |
image_url: str
|
| 29 |
countries: List["CountryOut"] = []
|
| 30 |
+
title: Optional[str] = None
|
| 31 |
+
prompt: Optional[str] = None
|
| 32 |
+
model: Optional[str] = None
|
| 33 |
+
schema_id: Optional[str] = None
|
| 34 |
+
raw_json: Optional[dict] = None
|
| 35 |
+
generated: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
edited: Optional[str] = None
|
| 37 |
accuracy: Optional[int] = None
|
| 38 |
context: Optional[int] = None
|
|
|
|
| 40 |
starred: bool = False
|
| 41 |
created_at: Optional[datetime] = None
|
| 42 |
updated_at: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
class Config:
|
| 45 |
from_attributes = True
|
| 46 |
|
| 47 |
class CaptionUpdate(BaseModel):
|
| 48 |
title: Optional[str] = None
|
| 49 |
+
prompt: Optional[str] = None
|
| 50 |
+
model: Optional[str] = None
|
| 51 |
+
schema_id: Optional[str] = None
|
| 52 |
+
raw_json: Optional[dict] = None
|
| 53 |
+
generated: Optional[str] = None
|
| 54 |
edited: Optional[str] = None
|
| 55 |
accuracy: Optional[int] = None
|
| 56 |
context: Optional[int] = None
|
py_backend/app/services/gpt4v_service.py
CHANGED
|
@@ -2,9 +2,8 @@ from .vlm_service import VLMService, ModelType
|
|
| 2 |
from typing import Dict, Any
|
| 3 |
import openai
|
| 4 |
import base64
|
| 5 |
-
import io
|
| 6 |
import asyncio
|
| 7 |
-
|
| 8 |
|
| 9 |
class GPT4VService(VLMService):
|
| 10 |
"""GPT-4 Vision service implementation"""
|
|
@@ -56,45 +55,44 @@ class GPT4VService(VLMService):
|
|
| 56 |
)
|
| 57 |
|
| 58 |
content = response.choices[0].message.content
|
| 59 |
-
print(f"DEBUG: Raw AI response: {content[:200]}...")
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
cleaned_content
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
cleaned_content = re.sub(r'\s*```$', '', cleaned_content)
|
| 68 |
-
print(f"DEBUG: Cleaned content: {cleaned_content[:200]}...")
|
| 69 |
|
|
|
|
| 70 |
try:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
return {
|
| 89 |
-
"caption":
|
| 90 |
-
"metadata": metadata,
|
| 91 |
-
"confidence": 0.9,
|
| 92 |
-
"processing_time": 0.0,
|
| 93 |
"raw_response": {
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
}
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
except Exception as e:
|
|
|
|
| 2 |
from typing import Dict, Any
|
| 3 |
import openai
|
| 4 |
import base64
|
|
|
|
| 5 |
import asyncio
|
| 6 |
+
import json
|
| 7 |
|
| 8 |
class GPT4VService(VLMService):
|
| 9 |
"""GPT-4 Vision service implementation"""
|
|
|
|
| 55 |
)
|
| 56 |
|
| 57 |
content = response.choices[0].message.content
|
|
|
|
| 58 |
|
| 59 |
+
cleaned_content = content.strip()
|
| 60 |
+
if cleaned_content.startswith("```json"):
|
| 61 |
+
cleaned_content = cleaned_content[7:]
|
| 62 |
+
if cleaned_content.endswith("```"):
|
| 63 |
+
cleaned_content = cleaned_content[:-3]
|
| 64 |
+
cleaned_content = cleaned_content.strip()
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
metadata = {}
|
| 67 |
try:
|
| 68 |
+
metadata = json.loads(cleaned_content)
|
| 69 |
+
except json.JSONDecodeError:
|
| 70 |
+
if "```json" in content:
|
| 71 |
+
json_start = content.find("```json") + 7
|
| 72 |
+
json_end = content.find("```", json_start)
|
| 73 |
+
if json_end > json_start:
|
| 74 |
+
json_str = content[json_start:json_end].strip()
|
| 75 |
+
try:
|
| 76 |
+
metadata = json.loads(json_str)
|
| 77 |
+
except json.JSONDecodeError as e:
|
| 78 |
+
print(f"JSON parse error: {e}")
|
| 79 |
+
else:
|
| 80 |
+
import re
|
| 81 |
+
json_match = re.search(r'\{[^{}]*"metadata"[^{}]*\{[^{}]*\}', content)
|
| 82 |
+
if json_match:
|
| 83 |
+
try:
|
| 84 |
+
metadata = json.loads(json_match.group())
|
| 85 |
+
except json.JSONDecodeError:
|
| 86 |
+
pass
|
| 87 |
|
| 88 |
return {
|
| 89 |
+
"caption": cleaned_content,
|
|
|
|
|
|
|
|
|
|
| 90 |
"raw_response": {
|
| 91 |
+
"content": content,
|
| 92 |
+
"metadata": metadata,
|
| 93 |
+
"extracted_metadata": metadata
|
| 94 |
+
},
|
| 95 |
+
"metadata": metadata
|
| 96 |
}
|
| 97 |
|
| 98 |
except Exception as e:
|
py_backend/app/services/stub_vlm_service.py
CHANGED
|
@@ -8,24 +8,12 @@ class StubVLMService(VLMService):
|
|
| 8 |
def __init__(self):
|
| 9 |
super().__init__("STUB_MODEL", ModelType.CUSTOM)
|
| 10 |
|
| 11 |
-
async def generate_caption(self, image_bytes: bytes, prompt: str) ->
|
| 12 |
-
"""Generate a stub caption"""
|
| 13 |
-
|
| 14 |
-
print(f"DEBUG: StubVLMService: Image size: {len(image_bytes)}")
|
| 15 |
-
print(f"DEBUG: StubVLMService: Prompt: {prompt[:100]}...")
|
| 16 |
-
|
| 17 |
-
await asyncio.sleep(0.1)
|
| 18 |
-
|
| 19 |
-
caption = "This is a stub caption generated for testing purposes. The image appears to contain geographic or crisis-related information."
|
| 20 |
-
print(f"DEBUG: StubVLMService: Generated caption: {caption}")
|
| 21 |
|
| 22 |
return {
|
| 23 |
"caption": caption,
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"raw_response": {
|
| 27 |
-
"stub": True,
|
| 28 |
-
"prompt": prompt,
|
| 29 |
-
"image_size": len(image_bytes)
|
| 30 |
-
}
|
| 31 |
}
|
|
|
|
| 8 |
def __init__(self):
|
| 9 |
super().__init__("STUB_MODEL", ModelType.CUSTOM)
|
| 10 |
|
| 11 |
+
async def generate_caption(self, image_bytes: bytes, prompt: str) -> dict:
|
| 12 |
+
"""Generate a stub caption for testing purposes."""
|
| 13 |
+
caption = f"This is a stub caption for testing. Image size: {len(image_bytes)} bytes. Prompt: {prompt[:50]}..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
return {
|
| 16 |
"caption": caption,
|
| 17 |
+
"raw_response": {"stub": True, "caption": caption},
|
| 18 |
+
"metadata": {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
py_backend/app/services/vlm_service.py
CHANGED
|
@@ -58,45 +58,35 @@ class VLMServiceManager:
|
|
| 58 |
return self.services.get(self.default_service)
|
| 59 |
return None
|
| 60 |
|
| 61 |
-
def get_available_models(self) ->
|
| 62 |
-
"""Get
|
| 63 |
-
return
|
| 64 |
-
name: service.get_model_info()
|
| 65 |
-
for name, service in self.services.items()
|
| 66 |
-
}
|
| 67 |
|
| 68 |
-
async def generate_caption(self, image_bytes: bytes, prompt: str, model_name: str = None) ->
|
| 69 |
-
"""Generate caption using specified or
|
| 70 |
-
print(f"DEBUG: VLM Manager: Looking for model_name: {model_name}")
|
| 71 |
-
print(f"DEBUG: VLM Manager: Available services: {list(self.services.keys())}")
|
| 72 |
|
|
|
|
| 73 |
service = None
|
| 74 |
if model_name:
|
| 75 |
-
service = self.
|
| 76 |
-
print(f"DEBUG: VLM Manager: Found service for {model_name}: {service is not None}")
|
| 77 |
-
if not service:
|
| 78 |
-
raise ValueError(f"Model {model_name} not found")
|
| 79 |
-
else:
|
| 80 |
-
service = self.get_default_service()
|
| 81 |
if not service:
|
| 82 |
-
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
try:
|
| 90 |
-
print(f"DEBUG: VLM Manager: Calling generate_caption on {service.model_name}")
|
| 91 |
result = await service.generate_caption(image_bytes, prompt)
|
| 92 |
-
result
|
| 93 |
-
|
| 94 |
return result
|
| 95 |
except Exception as e:
|
| 96 |
-
print(f"
|
| 97 |
-
import traceback
|
| 98 |
-
traceback.print_exc()
|
| 99 |
-
logger.error(f"Error generating caption with {service.model_name}: {str(e)}")
|
| 100 |
raise
|
| 101 |
|
| 102 |
vlm_manager = VLMServiceManager()
|
|
|
|
| 58 |
return self.services.get(self.default_service)
|
| 59 |
return None
|
| 60 |
|
| 61 |
+
def get_available_models(self) -> list:
|
| 62 |
+
"""Get list of available model names"""
|
| 63 |
+
return list(self.services.keys())
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
async def generate_caption(self, image_bytes: bytes, prompt: str, model_name: str | None = None) -> dict:
|
| 66 |
+
"""Generate caption using the specified model or fallback to available service."""
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
# Find appropriate service
|
| 69 |
service = None
|
| 70 |
if model_name:
|
| 71 |
+
service = self.services.get(model_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if not service:
|
| 73 |
+
print(f"Model '{model_name}' not found, using fallback")
|
| 74 |
|
| 75 |
+
# Fallback to first available service
|
| 76 |
+
if not service and self.services:
|
| 77 |
+
service = next(iter(self.services.values()))
|
| 78 |
+
print(f"Using fallback service: {service.model_name}")
|
| 79 |
|
| 80 |
+
if not service:
|
| 81 |
+
raise ValueError("No VLM services available")
|
| 82 |
|
| 83 |
try:
|
|
|
|
| 84 |
result = await service.generate_caption(image_bytes, prompt)
|
| 85 |
+
if isinstance(result, dict):
|
| 86 |
+
result["model"] = service.model_name
|
| 87 |
return result
|
| 88 |
except Exception as e:
|
| 89 |
+
print(f"Error generating caption: {str(e)}")
|
|
|
|
|
|
|
|
|
|
| 90 |
raise
|
| 91 |
|
| 92 |
vlm_manager = VLMServiceManager()
|
py_backend/app/storage.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
import io
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import boto3
|
| 3 |
import botocore
|
| 4 |
-
|
| 5 |
-
from typing import BinaryIO
|
| 6 |
from .config import settings
|
| 7 |
|
| 8 |
s3 = boto3.client(
|
|
@@ -10,25 +13,39 @@ s3 = boto3.client(
|
|
| 10 |
endpoint_url=settings.S3_ENDPOINT,
|
| 11 |
aws_access_key_id=settings.S3_ACCESS_KEY,
|
| 12 |
aws_secret_access_key=settings.S3_SECRET_KEY,
|
|
|
|
| 13 |
)
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
-
|
| 18 |
-
automatically creating the bucket if it doesn't exist.
|
| 19 |
-
Returns the object key.
|
| 20 |
"""
|
| 21 |
-
key = f"maps/{uuid4()}_{filename}"
|
| 22 |
-
|
| 23 |
try:
|
| 24 |
s3.head_bucket(Bucket=settings.S3_BUCKET)
|
| 25 |
except botocore.exceptions.ClientError as e:
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
fileobj.seek(0)
|
| 29 |
-
s3.upload_fileobj(fileobj, settings.S3_BUCKET, key)
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
|
| 34 |
"""
|
|
@@ -39,3 +56,95 @@ def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
|
|
| 39 |
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
| 40 |
ExpiresIn=expires_in,
|
| 41 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import io
|
| 2 |
+
import mimetypes
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from typing import BinaryIO, Optional
|
| 5 |
+
|
| 6 |
import boto3
|
| 7 |
import botocore
|
| 8 |
+
|
|
|
|
| 9 |
from .config import settings
|
| 10 |
|
| 11 |
s3 = boto3.client(
|
|
|
|
| 13 |
endpoint_url=settings.S3_ENDPOINT,
|
| 14 |
aws_access_key_id=settings.S3_ACCESS_KEY,
|
| 15 |
aws_secret_access_key=settings.S3_SECRET_KEY,
|
| 16 |
+
region_name=getattr(settings, "S3_REGION", None),
|
| 17 |
)
|
| 18 |
|
| 19 |
+
# Optional settings you can add to your config:
|
| 20 |
+
# - S3_PUBLIC_URL_BASE: str | None (e.g. "https://cdn.example.com" or bucket website endpoint)
|
| 21 |
+
# - S3_PUBLIC_READ: bool (True if the bucket/objects are world-readable)
|
| 22 |
+
|
| 23 |
+
def _ensure_bucket() -> None:
|
| 24 |
"""
|
| 25 |
+
Make sure the bucket exists. Safe to call on every upload.
|
|
|
|
|
|
|
| 26 |
"""
|
|
|
|
|
|
|
| 27 |
try:
|
| 28 |
s3.head_bucket(Bucket=settings.S3_BUCKET)
|
| 29 |
except botocore.exceptions.ClientError as e:
|
| 30 |
+
# Create bucket. Some providers need LocationConstraint; MinIO typically doesn't.
|
| 31 |
+
create_kwargs = {"Bucket": settings.S3_BUCKET}
|
| 32 |
+
region = getattr(settings, "S3_REGION", None)
|
| 33 |
+
# For AWS S3 outside us-east-1 you must pass LocationConstraint.
|
| 34 |
+
if region and (settings.S3_ENDPOINT is None or "amazonaws.com" in str(settings.S3_ENDPOINT).lower()):
|
| 35 |
+
create_kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region}
|
| 36 |
+
s3.create_bucket(**create_kwargs)
|
| 37 |
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
def get_object_url(key: str, *, expires_in: int = 3600) -> str:
|
| 40 |
+
"""
|
| 41 |
+
Return a browser-usable URL for the object.
|
| 42 |
+
If S3_PUBLIC_URL_BASE is set, return a public URL. Otherwise, return a presigned URL.
|
| 43 |
+
"""
|
| 44 |
+
public_base = getattr(settings, "S3_PUBLIC_URL_BASE", None)
|
| 45 |
+
if public_base:
|
| 46 |
+
return f"{public_base.rstrip('/')}/{key}"
|
| 47 |
+
return generate_presigned_url(key, expires_in=expires_in)
|
| 48 |
+
|
| 49 |
|
| 50 |
def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
|
| 51 |
"""
|
|
|
|
| 56 |
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
| 57 |
ExpiresIn=expires_in,
|
| 58 |
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def upload_fileobj(
|
| 62 |
+
fileobj: BinaryIO,
|
| 63 |
+
filename: str,
|
| 64 |
+
*,
|
| 65 |
+
content_type: Optional[str] = None,
|
| 66 |
+
cache_control: Optional[str] = "public, max-age=31536000, immutable",
|
| 67 |
+
) -> str:
|
| 68 |
+
"""
|
| 69 |
+
Upload a file-like object to the configured S3 bucket.
|
| 70 |
+
Returns the object key (not the URL).
|
| 71 |
+
"""
|
| 72 |
+
_ensure_bucket()
|
| 73 |
+
|
| 74 |
+
# Build a namespaced key; keep original filename tail if helpful
|
| 75 |
+
safe_name = filename or "upload.bin"
|
| 76 |
+
key = f"maps/{uuid4()}_{safe_name}"
|
| 77 |
+
|
| 78 |
+
# Guess content type if not provided
|
| 79 |
+
ct = content_type or (mimetypes.guess_type(safe_name)[0] or "application/octet-stream")
|
| 80 |
+
|
| 81 |
+
# Make sure we read from the start
|
| 82 |
+
try:
|
| 83 |
+
fileobj.seek(0)
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
extra_args = {"ContentType": ct}
|
| 88 |
+
if cache_control:
|
| 89 |
+
extra_args["CacheControl"] = cache_control
|
| 90 |
+
if getattr(settings, "S3_PUBLIC_READ", False):
|
| 91 |
+
# Only set this if your bucket policy allows public read
|
| 92 |
+
extra_args["ACL"] = "public-read"
|
| 93 |
+
|
| 94 |
+
s3.upload_fileobj(fileobj, settings.S3_BUCKET, key, ExtraArgs=extra_args)
|
| 95 |
+
return key
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def upload_bytes(
|
| 99 |
+
data: bytes,
|
| 100 |
+
filename: str,
|
| 101 |
+
*,
|
| 102 |
+
content_type: Optional[str] = None,
|
| 103 |
+
cache_control: Optional[str] = "public, max-age=31536000, immutable",
|
| 104 |
+
) -> str:
|
| 105 |
+
"""
|
| 106 |
+
Convenience helper to upload raw bytes (e.g., after server-side URL download).
|
| 107 |
+
"""
|
| 108 |
+
buf = io.BytesIO(data)
|
| 109 |
+
return upload_fileobj(buf, filename, content_type=content_type, cache_control=cache_control)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def copy_object(
|
| 113 |
+
src_key: str,
|
| 114 |
+
*,
|
| 115 |
+
new_filename: Optional[str] = None,
|
| 116 |
+
cache_control: Optional[str] = "public, max-age=31536000, immutable",
|
| 117 |
+
) -> str:
|
| 118 |
+
"""
|
| 119 |
+
Server-side copy within the same bucket (no download/upload round-trip).
|
| 120 |
+
Useful for 'duplicate' endpoints if you already know the source key.
|
| 121 |
+
Returns the NEW object key.
|
| 122 |
+
"""
|
| 123 |
+
_ensure_bucket()
|
| 124 |
+
tail = new_filename or src_key.split("/")[-1]
|
| 125 |
+
dest_key = f"maps/{uuid4()}_{tail}"
|
| 126 |
+
|
| 127 |
+
extra_args = {}
|
| 128 |
+
if cache_control:
|
| 129 |
+
extra_args["CacheControl"] = cache_control
|
| 130 |
+
if getattr(settings, "S3_PUBLIC_READ", False):
|
| 131 |
+
extra_args["ACL"] = "public-read"
|
| 132 |
+
|
| 133 |
+
s3.copy(
|
| 134 |
+
{"Bucket": settings.S3_BUCKET, "Key": src_key},
|
| 135 |
+
settings.S3_BUCKET,
|
| 136 |
+
dest_key,
|
| 137 |
+
ExtraArgs=extra_args or None,
|
| 138 |
+
)
|
| 139 |
+
return dest_key
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def delete_object(key: str) -> None:
|
| 143 |
+
"""
|
| 144 |
+
Delete an object (best-effort).
|
| 145 |
+
"""
|
| 146 |
+
try:
|
| 147 |
+
s3.delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
| 148 |
+
except botocore.exceptions.ClientError:
|
| 149 |
+
# Swallow to keep deletes idempotent for callers
|
| 150 |
+
pass
|
py_backend/tests/test_explore_page.py
CHANGED
|
@@ -176,9 +176,9 @@ def test_database_consistency():
|
|
| 176 |
images = db.query(models.Images).all()
|
| 177 |
print(f" + Total images in database: {len(images)}")
|
| 178 |
|
| 179 |
-
# Check if
|
| 180 |
-
|
| 181 |
-
print(f" +
|
| 182 |
|
| 183 |
# Check metadata tables
|
| 184 |
sources = db.query(models.Source).all()
|
|
@@ -191,9 +191,6 @@ def test_database_consistency():
|
|
| 191 |
print(f" + Total countries: {len(countries)}")
|
| 192 |
|
| 193 |
# Check relationships
|
| 194 |
-
images_with_captions = db.query(models.Images).join(models.Captions).all()
|
| 195 |
-
print(f" + Images with captions: {len(images_with_captions)}")
|
| 196 |
-
|
| 197 |
images_with_countries = db.query(models.Images).join(models.Images.countries).all()
|
| 198 |
print(f" + Images with countries: {len(images_with_countries)}")
|
| 199 |
|
|
@@ -234,7 +231,7 @@ def create_test_data():
|
|
| 234 |
raw_json={"test": True},
|
| 235 |
text="This is a test caption for the explore page."
|
| 236 |
)
|
| 237 |
-
print(f" + Created test caption: {caption.
|
| 238 |
|
| 239 |
return img.image_id
|
| 240 |
|
|
|
|
| 176 |
images = db.query(models.Images).all()
|
| 177 |
print(f" + Total images in database: {len(images)}")
|
| 178 |
|
| 179 |
+
# Check if images have caption data
|
| 180 |
+
images_with_captions = db.query(models.Images).filter(models.Images.title.isnot(None)).all()
|
| 181 |
+
print(f" + Images with caption data: {len(images_with_captions)}")
|
| 182 |
|
| 183 |
# Check metadata tables
|
| 184 |
sources = db.query(models.Source).all()
|
|
|
|
| 191 |
print(f" + Total countries: {len(countries)}")
|
| 192 |
|
| 193 |
# Check relationships
|
|
|
|
|
|
|
|
|
|
| 194 |
images_with_countries = db.query(models.Images).join(models.Images.countries).all()
|
| 195 |
print(f" + Images with countries: {len(images_with_countries)}")
|
| 196 |
|
|
|
|
| 231 |
raw_json={"test": True},
|
| 232 |
text="This is a test caption for the explore page."
|
| 233 |
)
|
| 234 |
+
print(f" + Created test caption: {caption.image_id}")
|
| 235 |
|
| 236 |
return img.image_id
|
| 237 |
|
py_backend/tests/test_upload_flow.py
CHANGED
|
@@ -14,39 +14,34 @@ from app.database import SessionLocal
|
|
| 14 |
from app import crud, models
|
| 15 |
|
| 16 |
def test_database_connection():
|
| 17 |
-
"""Test
|
| 18 |
db = SessionLocal()
|
| 19 |
try:
|
| 20 |
print("Testing database connection...")
|
| 21 |
|
| 22 |
-
# Test
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
test_img = models.Images(
|
| 24 |
-
source="OTHER",
|
| 25 |
-
type="OTHER",
|
| 26 |
file_key="test_key",
|
| 27 |
-
sha256="
|
|
|
|
|
|
|
| 28 |
epsg="4326",
|
| 29 |
-
image_type="crisis_map"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
db.add(test_img)
|
| 33 |
-
db.flush()
|
| 34 |
-
print(f"Created test image with ID: {test_img.image_id}")
|
| 35 |
-
|
| 36 |
-
# Create a test caption
|
| 37 |
-
test_caption = models.Captions(
|
| 38 |
-
image_id=test_img.image_id,
|
| 39 |
-
title="Test Caption",
|
| 40 |
prompt="Test prompt",
|
| 41 |
model="STUB_MODEL",
|
|
|
|
| 42 |
raw_json={"test": True},
|
| 43 |
-
generated="This is a test caption"
|
| 44 |
-
edited="This is a test caption"
|
| 45 |
)
|
| 46 |
|
| 47 |
-
db.add(
|
| 48 |
db.commit()
|
| 49 |
-
print(f"Created test
|
| 50 |
|
| 51 |
# Clean up
|
| 52 |
db.delete(test_img)
|
|
@@ -88,7 +83,7 @@ def test_crud_functions():
|
|
| 88 |
raw_json={"test_crud": True},
|
| 89 |
text="This is a test CRUD caption"
|
| 90 |
)
|
| 91 |
-
print(f"CRUD create_caption successful: {caption.
|
| 92 |
|
| 93 |
# Clean up
|
| 94 |
db.delete(img)
|
|
@@ -144,20 +139,21 @@ def test_complete_upload_flow():
|
|
| 144 |
|
| 145 |
if caption_response.status_code == 200:
|
| 146 |
caption_result = caption_response.json()
|
| 147 |
-
caption_id = caption_result['
|
| 148 |
-
print(f"Caption created! Caption ID: {caption_id}")
|
| 149 |
|
| 150 |
# Step 3: Submit caption via API
|
| 151 |
print("3. Submitting caption via API...")
|
| 152 |
submit_data = {
|
| 153 |
-
'
|
| 154 |
-
'
|
| 155 |
-
'
|
| 156 |
-
'
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
submit_response = requests.put(
|
| 160 |
-
f'http://localhost:8080/api/
|
| 161 |
json=submit_data
|
| 162 |
)
|
| 163 |
print(f"Submit response status: {submit_response.status_code}")
|
|
@@ -165,113 +161,64 @@ def test_complete_upload_flow():
|
|
| 165 |
if submit_response.status_code == 200:
|
| 166 |
print("Caption submitted successfully!")
|
| 167 |
|
| 168 |
-
#
|
| 169 |
-
print("4.
|
| 170 |
-
time.sleep(1) # Give a moment for database to settle
|
| 171 |
-
|
| 172 |
-
list_response = requests.get('http://localhost:8080/api/images/')
|
| 173 |
-
print(f"List response status: {list_response.status_code}")
|
| 174 |
-
|
| 175 |
-
if list_response.status_code == 200:
|
| 176 |
-
images = list_response.json()
|
| 177 |
-
print(f"Total images in list: {len(images)}")
|
| 178 |
-
|
| 179 |
-
# Check if our uploaded image is in the list
|
| 180 |
-
uploaded_image = next((img for img in images if img['image_id'] == image_id), None)
|
| 181 |
-
if uploaded_image:
|
| 182 |
-
print("SUCCESS: Uploaded image found in list!")
|
| 183 |
-
print(f"Image details: {uploaded_image}")
|
| 184 |
-
else:
|
| 185 |
-
print("ERROR: Uploaded image NOT found in list!")
|
| 186 |
-
print("Available image IDs:", [img['image_id'] for img in images])
|
| 187 |
-
|
| 188 |
-
# Step 5: Check database directly
|
| 189 |
-
print("5. Checking database directly...")
|
| 190 |
db = SessionLocal()
|
| 191 |
try:
|
| 192 |
-
|
| 193 |
-
db_image = db.query(models.Images).filter(models.Images.image_id == image_id).first()
|
| 194 |
-
if db_image:
|
| 195 |
-
print(f"SUCCESS: Image found in database: {db_image.image_id}")
|
| 196 |
-
print(f"Image source: {db_image.source}, event_type: {db_image.event_type}")
|
| 197 |
-
else:
|
| 198 |
-
print("ERROR: Image NOT found in database!")
|
| 199 |
-
|
| 200 |
-
# Check captions table
|
| 201 |
-
db_caption = db.query(models.Captions).filter(models.Captions.image_id == image_id).first()
|
| 202 |
if db_caption:
|
| 203 |
-
print(f"SUCCESS: Caption found in database: {db_caption.
|
|
|
|
|
|
|
|
|
|
| 204 |
else:
|
| 205 |
-
print("ERROR: Caption
|
| 206 |
-
|
| 207 |
-
# Check total counts
|
| 208 |
-
total_images = db.query(models.Images).count()
|
| 209 |
-
total_captions = db.query(models.Captions).count()
|
| 210 |
-
print(f"Total images in database: {total_images}")
|
| 211 |
-
print(f"Total captions in database: {total_captions}")
|
| 212 |
-
|
| 213 |
finally:
|
| 214 |
db.close()
|
| 215 |
-
|
| 216 |
-
# Clean up
|
| 217 |
-
print("6. Cleaning up...")
|
| 218 |
-
delete_response = requests.delete(f'http://localhost:8080/api/images/{image_id}')
|
| 219 |
-
print(f"Delete status: {delete_response.status_code}")
|
| 220 |
-
|
| 221 |
else:
|
| 222 |
-
print(f"
|
| 223 |
else:
|
| 224 |
-
print(f"
|
| 225 |
else:
|
| 226 |
-
print(f"
|
| 227 |
|
| 228 |
except Exception as e:
|
| 229 |
-
print(f"
|
|
|
|
|
|
|
| 230 |
|
| 231 |
def test_deletion_logic():
|
| 232 |
-
"""Test
|
| 233 |
-
print("
|
| 234 |
|
| 235 |
-
#
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
| 239 |
data = {
|
| 240 |
'source': 'OTHER',
|
| 241 |
-
'type': 'OTHER',
|
| 242 |
'countries': ['US'],
|
| 243 |
'epsg': '4326',
|
| 244 |
'image_type': 'crisis_map'
|
| 245 |
}
|
| 246 |
|
| 247 |
try:
|
| 248 |
-
|
| 249 |
-
print(f"
|
| 250 |
|
| 251 |
-
if
|
| 252 |
-
upload_result =
|
| 253 |
-
image_id = upload_result
|
| 254 |
-
print(f"
|
| 255 |
-
|
| 256 |
-
# Test 2: Verify image exists in database
|
| 257 |
-
print("2. Verifying image exists in database...")
|
| 258 |
-
time.sleep(0.5)
|
| 259 |
-
|
| 260 |
-
list_response = requests.get('http://localhost:8080/api/images/', timeout=10)
|
| 261 |
-
if list_response.status_code == 200:
|
| 262 |
-
images = list_response.json()
|
| 263 |
-
uploaded_image = next((img for img in images if img.get('image_id') == image_id), None)
|
| 264 |
-
|
| 265 |
-
if uploaded_image:
|
| 266 |
-
print(" ✓ Image found in database!")
|
| 267 |
-
else:
|
| 268 |
-
print(" ✗ ERROR: Image NOT found in database!")
|
| 269 |
-
return False
|
| 270 |
|
| 271 |
-
#
|
| 272 |
-
print("
|
| 273 |
caption_data = {
|
| 274 |
-
'title': '
|
| 275 |
'prompt': 'Describe this test image'
|
| 276 |
}
|
| 277 |
|
|
@@ -279,66 +226,43 @@ def test_deletion_logic():
|
|
| 279 |
f'http://localhost:8080/api/images/{image_id}/caption',
|
| 280 |
data=caption_data
|
| 281 |
)
|
| 282 |
-
print(f"
|
| 283 |
|
| 284 |
if caption_response.status_code == 200:
|
| 285 |
-
|
| 286 |
-
caption_id = caption_result.get('cap_id')
|
| 287 |
-
print(f" ✓ Caption created! Caption ID: {caption_id}")
|
| 288 |
|
| 289 |
-
#
|
| 290 |
-
print("
|
| 291 |
-
|
| 292 |
-
'
|
| 293 |
-
'accuracy': 85,
|
| 294 |
-
'context': 90,
|
| 295 |
-
'usability': 80
|
| 296 |
-
}
|
| 297 |
-
|
| 298 |
-
submit_response = requests.put(
|
| 299 |
-
f'http://localhost:8080/api/captions/{caption_id}',
|
| 300 |
-
json=submit_data
|
| 301 |
)
|
| 302 |
-
print(f"
|
| 303 |
|
| 304 |
-
if
|
| 305 |
-
print("
|
| 306 |
-
|
| 307 |
-
# Test 5: Verify image still exists after submission (should NOT be deleted)
|
| 308 |
-
print("5. Verifying image still exists after submission...")
|
| 309 |
-
time.sleep(1)
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
if
|
| 317 |
-
print("
|
| 318 |
-
caption = submitted_image.get('caption')
|
| 319 |
-
if caption:
|
| 320 |
-
print(f" ✓ Caption edited: {caption.get('edited', 'N/A')}")
|
| 321 |
-
print(f" ✓ Accuracy: {caption.get('accuracy', 'N/A')}")
|
| 322 |
-
else:
|
| 323 |
-
print(" ✗ ERROR: Caption not found in submitted image!")
|
| 324 |
else:
|
| 325 |
-
print("
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
# Clean up
|
| 329 |
-
print("6. Cleaning up...")
|
| 330 |
-
delete_response = requests.delete(f'http://localhost:8080/api/images/{image_id}', timeout=10)
|
| 331 |
-
print(f" Delete status: {delete_response.status_code}")
|
| 332 |
-
|
| 333 |
else:
|
| 334 |
-
print(f"
|
| 335 |
else:
|
| 336 |
-
print(f"
|
| 337 |
else:
|
| 338 |
-
print(f"
|
| 339 |
|
| 340 |
except Exception as e:
|
| 341 |
-
print(f"
|
|
|
|
|
|
|
| 342 |
|
| 343 |
if __name__ == "__main__":
|
| 344 |
test_database_connection()
|
|
|
|
| 14 |
from app import crud, models
|
| 15 |
|
| 16 |
def test_database_connection():
|
| 17 |
+
"""Test basic database connectivity and table creation"""
|
| 18 |
db = SessionLocal()
|
| 19 |
try:
|
| 20 |
print("Testing database connection...")
|
| 21 |
|
| 22 |
+
# Test basic query
|
| 23 |
+
sources = db.query(models.Source).all()
|
| 24 |
+
print(f"Found {len(sources)} sources in database")
|
| 25 |
+
|
| 26 |
+
# Test image creation
|
| 27 |
test_img = models.Images(
|
|
|
|
|
|
|
| 28 |
file_key="test_key",
|
| 29 |
+
sha256="test_sha",
|
| 30 |
+
source="OTHER",
|
| 31 |
+
event_type="OTHER",
|
| 32 |
epsg="4326",
|
| 33 |
+
image_type="crisis_map",
|
| 34 |
+
title="Test Title",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
prompt="Test prompt",
|
| 36 |
model="STUB_MODEL",
|
| 37 |
+
schema_id="[email protected]",
|
| 38 |
raw_json={"test": True},
|
| 39 |
+
generated="This is a test caption"
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
+
db.add(test_img)
|
| 43 |
db.commit()
|
| 44 |
+
print(f"Created test image with ID: {test_img.image_id}")
|
| 45 |
|
| 46 |
# Clean up
|
| 47 |
db.delete(test_img)
|
|
|
|
| 83 |
raw_json={"test_crud": True},
|
| 84 |
text="This is a test CRUD caption"
|
| 85 |
)
|
| 86 |
+
print(f"CRUD create_caption successful for image: {caption.image_id}")
|
| 87 |
|
| 88 |
# Clean up
|
| 89 |
db.delete(img)
|
|
|
|
| 139 |
|
| 140 |
if caption_response.status_code == 200:
|
| 141 |
caption_result = caption_response.json()
|
| 142 |
+
caption_id = caption_result['image_id'] # Now using image_id instead of cap_id
|
| 143 |
+
print(f"Caption created successfully! Caption ID: {caption_id}")
|
| 144 |
|
| 145 |
# Step 3: Submit caption via API
|
| 146 |
print("3. Submitting caption via API...")
|
| 147 |
submit_data = {
|
| 148 |
+
'title': 'Test Caption',
|
| 149 |
+
'edited': 'This is an edited test caption',
|
| 150 |
+
'accuracy': 85,
|
| 151 |
+
'context': 90,
|
| 152 |
+
'usability': 80
|
| 153 |
}
|
| 154 |
|
| 155 |
submit_response = requests.put(
|
| 156 |
+
f'http://localhost:8080/api/images/{image_id}/caption',
|
| 157 |
json=submit_data
|
| 158 |
)
|
| 159 |
print(f"Submit response status: {submit_response.status_code}")
|
|
|
|
| 161 |
if submit_response.status_code == 200:
|
| 162 |
print("Caption submitted successfully!")
|
| 163 |
|
| 164 |
+
# Verify in database
|
| 165 |
+
print("4. Verifying in database...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
db = SessionLocal()
|
| 167 |
try:
|
| 168 |
+
db_caption = crud.get_caption(db, image_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
if db_caption:
|
| 170 |
+
print(f"SUCCESS: Caption found in database for image: {db_caption.image_id}")
|
| 171 |
+
print(f"Title: {db_caption.title}")
|
| 172 |
+
print(f"Edited: {db_caption.edited}")
|
| 173 |
+
print(f"Accuracy: {db_caption.accuracy}")
|
| 174 |
else:
|
| 175 |
+
print("ERROR: Caption not found in database")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
finally:
|
| 177 |
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
else:
|
| 179 |
+
print(f"Caption submission failed: {submit_response.text}")
|
| 180 |
else:
|
| 181 |
+
print(f"Caption creation failed: {caption_response.text}")
|
| 182 |
else:
|
| 183 |
+
print(f"Upload failed: {response.text}")
|
| 184 |
|
| 185 |
except Exception as e:
|
| 186 |
+
print(f"Upload flow test failed: {e}")
|
| 187 |
+
import traceback
|
| 188 |
+
traceback.print_exc()
|
| 189 |
|
| 190 |
def test_deletion_logic():
|
| 191 |
+
"""Test the deletion logic for images"""
|
| 192 |
+
print("=== Testing Deletion Logic ===")
|
| 193 |
|
| 194 |
+
# Create test image data
|
| 195 |
+
test_content = b"test image data for deletion test"
|
| 196 |
+
test_filename = "test_deletion.jpg"
|
| 197 |
+
|
| 198 |
+
# Step 1: Upload image via API
|
| 199 |
+
print("1. Uploading image via API...")
|
| 200 |
+
files = {'file': (test_filename, io.BytesIO(test_content), 'image/jpeg')}
|
| 201 |
data = {
|
| 202 |
'source': 'OTHER',
|
| 203 |
+
'type': 'OTHER',
|
| 204 |
'countries': ['US'],
|
| 205 |
'epsg': '4326',
|
| 206 |
'image_type': 'crisis_map'
|
| 207 |
}
|
| 208 |
|
| 209 |
try:
|
| 210 |
+
response = requests.post('http://localhost:8080/api/images/', files=files, data=data)
|
| 211 |
+
print(f"Upload response status: {response.status_code}")
|
| 212 |
|
| 213 |
+
if response.status_code == 200:
|
| 214 |
+
upload_result = response.json()
|
| 215 |
+
image_id = upload_result['image_id']
|
| 216 |
+
print(f"Upload successful! Image ID: {image_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
# Step 2: Create caption via API
|
| 219 |
+
print("2. Creating caption via API...")
|
| 220 |
caption_data = {
|
| 221 |
+
'title': 'Test Caption for Deletion',
|
| 222 |
'prompt': 'Describe this test image'
|
| 223 |
}
|
| 224 |
|
|
|
|
| 226 |
f'http://localhost:8080/api/images/{image_id}/caption',
|
| 227 |
data=caption_data
|
| 228 |
)
|
| 229 |
+
print(f"Caption response status: {caption_response.status_code}")
|
| 230 |
|
| 231 |
if caption_response.status_code == 200:
|
| 232 |
+
print("Caption created successfully!")
|
|
|
|
|
|
|
| 233 |
|
| 234 |
+
# Step 3: Test image deletion
|
| 235 |
+
print("3. Testing image deletion...")
|
| 236 |
+
delete_response = requests.delete(
|
| 237 |
+
f'http://localhost:8080/api/images/{image_id}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
)
|
| 239 |
+
print(f"Delete response status: {delete_response.status_code}")
|
| 240 |
|
| 241 |
+
if delete_response.status_code == 200:
|
| 242 |
+
print("Image deleted successfully!")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
# Verify image is completely removed
|
| 245 |
+
print("4. Verifying image deletion...")
|
| 246 |
+
db = SessionLocal()
|
| 247 |
+
try:
|
| 248 |
+
db_image = crud.get_image(db, image_id)
|
| 249 |
+
if db_image:
|
| 250 |
+
print("ERROR: Image still exists when it should have been deleted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
else:
|
| 252 |
+
print("SUCCESS: Image completely removed from database")
|
| 253 |
+
finally:
|
| 254 |
+
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
else:
|
| 256 |
+
print(f"Image deletion failed: {delete_response.text}")
|
| 257 |
else:
|
| 258 |
+
print(f"Caption creation failed: {caption_response.text}")
|
| 259 |
else:
|
| 260 |
+
print(f"Upload failed: {response.text}")
|
| 261 |
|
| 262 |
except Exception as e:
|
| 263 |
+
print(f"Deletion logic test failed: {e}")
|
| 264 |
+
import traceback
|
| 265 |
+
traceback.print_exc()
|
| 266 |
|
| 267 |
if __name__ == "__main__":
|
| 268 |
test_database_connection()
|