Spaces:
Running
Running
multi upload
Browse files- frontend/package-lock.json +171 -0
- frontend/package.json +3 -0
- frontend/src/components/FilterBar.tsx +47 -12
- frontend/src/components/index.ts +7 -0
- frontend/src/components/upload/FileUploadSection.tsx +169 -0
- frontend/src/components/upload/GeneratedTextSection.tsx +102 -0
- frontend/src/components/upload/ImagePreviewSection.tsx +154 -0
- frontend/src/components/upload/MetadataFormSection.tsx +451 -0
- frontend/src/components/upload/ModalComponents.tsx +380 -0
- frontend/src/components/upload/RatingSection.tsx +63 -0
- frontend/src/components/upload/index.ts +18 -0
- frontend/src/contexts/FilterContext.tsx +6 -0
- frontend/src/pages/ExplorePage/ExplorePage.tsx +232 -191
- frontend/src/pages/MapDetailsPage/MapDetailPage.module.css +185 -0
- frontend/src/pages/MapDetailsPage/MapDetailPage.tsx +413 -257
- frontend/src/pages/UploadPage/UploadPage.module.css +219 -0
- frontend/src/pages/UploadPage/UploadPage.tsx +0 -0
- package-lock.json +39 -13
- package.json +2 -0
- py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py +40 -0
- py_backend/alembic/versions/0018_add_image_count_to_captions.py +26 -0
- py_backend/app/crud.py +5 -4
- py_backend/app/models.py +2 -1
- py_backend/app/routers/caption.py +11 -0
- py_backend/app/routers/upload.py +378 -1
- py_backend/app/schemas.py +5 -0
- py_backend/app/services/gemini_service.py +63 -1
- py_backend/app/services/gpt4v_service.py +85 -1
- py_backend/app/services/huggingface_service.py +138 -110
- py_backend/app/services/stub_vlm_service.py +32 -1
- py_backend/app/services/vlm_service.py +130 -74
- py_backend/fix_image_counts.py +77 -0
frontend/package-lock.json
CHANGED
|
@@ -11,10 +11,13 @@
|
|
| 11 |
"@ifrc-go/icons": "^2.0.1",
|
| 12 |
"@ifrc-go/ui": "^1.3.0",
|
| 13 |
"@types/jszip": "^3.4.0",
|
|
|
|
| 14 |
"jszip": "^3.10.1",
|
| 15 |
"lucide-react": "^0.525.0",
|
| 16 |
"react": "^18.2.0",
|
|
|
|
| 17 |
"react-dom": "^18.2.0",
|
|
|
|
| 18 |
"react-router-dom": "^6.30.1",
|
| 19 |
"vite": "^7.1.3"
|
| 20 |
},
|
|
@@ -420,6 +423,126 @@
|
|
| 420 |
"node": ">=6.9.0"
|
| 421 |
}
|
| 422 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
"node_modules/@csstools/color-helpers": {
|
| 424 |
"version": "5.1.0",
|
| 425 |
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
|
@@ -3077,6 +3200,16 @@
|
|
| 3077 |
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
| 3078 |
"license": "MIT"
|
| 3079 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3080 |
"node_modules/cross-spawn": {
|
| 3081 |
"version": "7.0.6",
|
| 3082 |
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
@@ -4617,6 +4750,12 @@
|
|
| 4617 |
"node": ">=0.10.0"
|
| 4618 |
}
|
| 4619 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4620 |
"node_modules/nwsapi": {
|
| 4621 |
"version": "2.2.21",
|
| 4622 |
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz",
|
|
@@ -4945,6 +5084,24 @@
|
|
| 4945 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
| 4946 |
}
|
| 4947 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4948 |
"node_modules/react-dom": {
|
| 4949 |
"version": "18.2.0",
|
| 4950 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
|
@@ -4958,6 +5115,20 @@
|
|
| 4958 |
"react": "^18.2.0"
|
| 4959 |
}
|
| 4960 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4961 |
"node_modules/react-focus-lock": {
|
| 4962 |
"version": "2.13.6",
|
| 4963 |
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.6.tgz",
|
|
|
|
| 11 |
"@ifrc-go/icons": "^2.0.1",
|
| 12 |
"@ifrc-go/ui": "^1.3.0",
|
| 13 |
"@types/jszip": "^3.4.0",
|
| 14 |
+
"cropperjs": "^2.0.1",
|
| 15 |
"jszip": "^3.10.1",
|
| 16 |
"lucide-react": "^0.525.0",
|
| 17 |
"react": "^18.2.0",
|
| 18 |
+
"react-cropper": "^2.3.3",
|
| 19 |
"react-dom": "^18.2.0",
|
| 20 |
+
"react-easy-crop": "^5.5.0",
|
| 21 |
"react-router-dom": "^6.30.1",
|
| 22 |
"vite": "^7.1.3"
|
| 23 |
},
|
|
|
|
| 423 |
"node": ">=6.9.0"
|
| 424 |
}
|
| 425 |
},
|
| 426 |
+
"node_modules/@cropper/element": {
|
| 427 |
+
"version": "2.0.1",
|
| 428 |
+
"resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.0.1.tgz",
|
| 429 |
+
"integrity": "sha512-Jn1hR7XWzWQM/QfXRGMGzdkJ2gG/UcLdQPZQ7OKs0JiFfRzKpzu4u/nYrXHeH3MM2iOslLqh2kqYju6mjZLMJQ==",
|
| 430 |
+
"license": "MIT",
|
| 431 |
+
"dependencies": {
|
| 432 |
+
"@cropper/utils": "^2.0.1"
|
| 433 |
+
}
|
| 434 |
+
},
|
| 435 |
+
"node_modules/@cropper/element-canvas": {
|
| 436 |
+
"version": "2.0.1",
|
| 437 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.0.1.tgz",
|
| 438 |
+
"integrity": "sha512-OKxq/O0HL9W2JegOsc2zh1NRpERZcLM5+M8aQ/eXdmMcfi1lzosPftag3Irp6pTsVpwV6B6ypIxKESzJ4ci9Fw==",
|
| 439 |
+
"license": "MIT",
|
| 440 |
+
"dependencies": {
|
| 441 |
+
"@cropper/element": "^2.0.1",
|
| 442 |
+
"@cropper/utils": "^2.0.1"
|
| 443 |
+
}
|
| 444 |
+
},
|
| 445 |
+
"node_modules/@cropper/element-crosshair": {
|
| 446 |
+
"version": "2.0.1",
|
| 447 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.0.1.tgz",
|
| 448 |
+
"integrity": "sha512-bS5msU9cTU/jf1/kDw+QJmEM9/rw8IgOdpolR85iMVUCR8sRcLa0wgom42MBHcpBYB6hvL5YfiOeXZ7lHIYMpw==",
|
| 449 |
+
"license": "MIT",
|
| 450 |
+
"dependencies": {
|
| 451 |
+
"@cropper/element": "^2.0.1",
|
| 452 |
+
"@cropper/utils": "^2.0.1"
|
| 453 |
+
}
|
| 454 |
+
},
|
| 455 |
+
"node_modules/@cropper/element-grid": {
|
| 456 |
+
"version": "2.0.1",
|
| 457 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.0.1.tgz",
|
| 458 |
+
"integrity": "sha512-ayqCvYQJ+GVT31HhFpttzHabW1T/LsIwLJY5PLTMG0cEZLw/E8ihg8mxctjZbo852D7oEePbz6/2SeuCb1018Q==",
|
| 459 |
+
"license": "MIT",
|
| 460 |
+
"dependencies": {
|
| 461 |
+
"@cropper/element": "^2.0.1",
|
| 462 |
+
"@cropper/utils": "^2.0.1"
|
| 463 |
+
}
|
| 464 |
+
},
|
| 465 |
+
"node_modules/@cropper/element-handle": {
|
| 466 |
+
"version": "2.0.1",
|
| 467 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.0.1.tgz",
|
| 468 |
+
"integrity": "sha512-fdifyyPIaR9S2eQ7qPHuM8fX8uToAfBsi8vQlR9EM+oJkDNil0uO4rWyArLWEtlr0/q7U0OvsufcuJ7ffqfmpg==",
|
| 469 |
+
"license": "MIT",
|
| 470 |
+
"dependencies": {
|
| 471 |
+
"@cropper/element": "^2.0.1",
|
| 472 |
+
"@cropper/utils": "^2.0.1"
|
| 473 |
+
}
|
| 474 |
+
},
|
| 475 |
+
"node_modules/@cropper/element-image": {
|
| 476 |
+
"version": "2.0.1",
|
| 477 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.0.1.tgz",
|
| 478 |
+
"integrity": "sha512-gPj5Sl2T8Cno198Cz3F3TDfcYoALW3yJ3fV6PHXmhMnX8sBkL7J441do7Vwkg0mEd2CogCtTLAf+p7ljdV0kgA==",
|
| 479 |
+
"license": "MIT",
|
| 480 |
+
"dependencies": {
|
| 481 |
+
"@cropper/element": "^2.0.1",
|
| 482 |
+
"@cropper/element-canvas": "^2.0.1",
|
| 483 |
+
"@cropper/utils": "^2.0.1"
|
| 484 |
+
}
|
| 485 |
+
},
|
| 486 |
+
"node_modules/@cropper/element-selection": {
|
| 487 |
+
"version": "2.0.1",
|
| 488 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.0.1.tgz",
|
| 489 |
+
"integrity": "sha512-atv+Aeq2N2eWawelIRPGh1kYFdNrpb0QkUPPheGxz1ImfxpLdcHO9gb9T5noQijizUW2G0pNvts4ZaITQ0I71Q==",
|
| 490 |
+
"license": "MIT",
|
| 491 |
+
"dependencies": {
|
| 492 |
+
"@cropper/element": "^2.0.1",
|
| 493 |
+
"@cropper/element-canvas": "^2.0.1",
|
| 494 |
+
"@cropper/element-image": "^2.0.1",
|
| 495 |
+
"@cropper/utils": "^2.0.1"
|
| 496 |
+
}
|
| 497 |
+
},
|
| 498 |
+
"node_modules/@cropper/element-shade": {
|
| 499 |
+
"version": "2.0.1",
|
| 500 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.0.1.tgz",
|
| 501 |
+
"integrity": "sha512-YIYgJ690NdFQ6wJLRFh/EySNVxGFKArncQ4FrsJ3yHU+ShgtOKz4FpjFLpqJRJB9swoVbD3WKTimGyzXrwjZrQ==",
|
| 502 |
+
"license": "MIT",
|
| 503 |
+
"dependencies": {
|
| 504 |
+
"@cropper/element": "^2.0.1",
|
| 505 |
+
"@cropper/element-canvas": "^2.0.1",
|
| 506 |
+
"@cropper/element-selection": "^2.0.1",
|
| 507 |
+
"@cropper/utils": "^2.0.1"
|
| 508 |
+
}
|
| 509 |
+
},
|
| 510 |
+
"node_modules/@cropper/element-viewer": {
|
| 511 |
+
"version": "2.0.1",
|
| 512 |
+
"resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.0.1.tgz",
|
| 513 |
+
"integrity": "sha512-HDj25l08pWi/AO6El/OqfQHBpBC4Lh5NEnQN1SOldsmxEwt27Ubv6ndDsF8LkTK7XPwjjZRpyQPyfig4w8L2JQ==",
|
| 514 |
+
"license": "MIT",
|
| 515 |
+
"dependencies": {
|
| 516 |
+
"@cropper/element": "^2.0.1",
|
| 517 |
+
"@cropper/element-canvas": "^2.0.1",
|
| 518 |
+
"@cropper/element-image": "^2.0.1",
|
| 519 |
+
"@cropper/element-selection": "^2.0.1",
|
| 520 |
+
"@cropper/utils": "^2.0.1"
|
| 521 |
+
}
|
| 522 |
+
},
|
| 523 |
+
"node_modules/@cropper/elements": {
|
| 524 |
+
"version": "2.0.1",
|
| 525 |
+
"resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.0.1.tgz",
|
| 526 |
+
"integrity": "sha512-paFbBLXTKXNngn1yDi2ZIf+FO1pIEQXyBntmqOjuxqtG73KuEKv633wsJPFpj958bgcfSakgBbF80j+3nHbPug==",
|
| 527 |
+
"license": "MIT",
|
| 528 |
+
"dependencies": {
|
| 529 |
+
"@cropper/element": "^2.0.1",
|
| 530 |
+
"@cropper/element-canvas": "^2.0.1",
|
| 531 |
+
"@cropper/element-crosshair": "^2.0.1",
|
| 532 |
+
"@cropper/element-grid": "^2.0.1",
|
| 533 |
+
"@cropper/element-handle": "^2.0.1",
|
| 534 |
+
"@cropper/element-image": "^2.0.1",
|
| 535 |
+
"@cropper/element-selection": "^2.0.1",
|
| 536 |
+
"@cropper/element-shade": "^2.0.1",
|
| 537 |
+
"@cropper/element-viewer": "^2.0.1"
|
| 538 |
+
}
|
| 539 |
+
},
|
| 540 |
+
"node_modules/@cropper/utils": {
|
| 541 |
+
"version": "2.0.1",
|
| 542 |
+
"resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.0.1.tgz",
|
| 543 |
+
"integrity": "sha512-A9RnAFmgNF5aZk5q2VZnFnHtXWu1kPyEN0LVsX8wJ2LBRu2nyETKwz+ZXVsVWliktToCaYojHKrS+6/HODyEZA==",
|
| 544 |
+
"license": "MIT"
|
| 545 |
+
},
|
| 546 |
"node_modules/@csstools/color-helpers": {
|
| 547 |
"version": "5.1.0",
|
| 548 |
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
|
|
|
| 3200 |
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
| 3201 |
"license": "MIT"
|
| 3202 |
},
|
| 3203 |
+
"node_modules/cropperjs": {
|
| 3204 |
+
"version": "2.0.1",
|
| 3205 |
+
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.0.1.tgz",
|
| 3206 |
+
"integrity": "sha512-hiJwk2SCPZqxMA7aR3byzLpYUqOrQo+ihMk8k/WRm/xe/LX8wNzAIzMwEB/NEGJYA6sbewxW9TUlrRUYi/2Ipg==",
|
| 3207 |
+
"license": "MIT",
|
| 3208 |
+
"dependencies": {
|
| 3209 |
+
"@cropper/elements": "^2.0.1",
|
| 3210 |
+
"@cropper/utils": "^2.0.1"
|
| 3211 |
+
}
|
| 3212 |
+
},
|
| 3213 |
"node_modules/cross-spawn": {
|
| 3214 |
"version": "7.0.6",
|
| 3215 |
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
|
|
| 4750 |
"node": ">=0.10.0"
|
| 4751 |
}
|
| 4752 |
},
|
| 4753 |
+
"node_modules/normalize-wheel": {
|
| 4754 |
+
"version": "1.0.1",
|
| 4755 |
+
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
| 4756 |
+
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
| 4757 |
+
"license": "BSD-3-Clause"
|
| 4758 |
+
},
|
| 4759 |
"node_modules/nwsapi": {
|
| 4760 |
"version": "2.2.21",
|
| 4761 |
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz",
|
|
|
|
| 5084 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
| 5085 |
}
|
| 5086 |
},
|
| 5087 |
+
"node_modules/react-cropper": {
|
| 5088 |
+
"version": "2.3.3",
|
| 5089 |
+
"resolved": "https://registry.npmjs.org/react-cropper/-/react-cropper-2.3.3.tgz",
|
| 5090 |
+
"integrity": "sha512-zghiEYkUb41kqtu+2jpX2Ntigf+Jj1dF9ew4lAobPzI2adaPE31z0p+5TcWngK6TvmWQUwK3lj4G+NDh1PDQ1w==",
|
| 5091 |
+
"license": "MIT",
|
| 5092 |
+
"dependencies": {
|
| 5093 |
+
"cropperjs": "^1.5.13"
|
| 5094 |
+
},
|
| 5095 |
+
"peerDependencies": {
|
| 5096 |
+
"react": ">=17.0.2"
|
| 5097 |
+
}
|
| 5098 |
+
},
|
| 5099 |
+
"node_modules/react-cropper/node_modules/cropperjs": {
|
| 5100 |
+
"version": "1.6.2",
|
| 5101 |
+
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
| 5102 |
+
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
| 5103 |
+
"license": "MIT"
|
| 5104 |
+
},
|
| 5105 |
"node_modules/react-dom": {
|
| 5106 |
"version": "18.2.0",
|
| 5107 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
|
|
|
| 5115 |
"react": "^18.2.0"
|
| 5116 |
}
|
| 5117 |
},
|
| 5118 |
+
"node_modules/react-easy-crop": {
|
| 5119 |
+
"version": "5.5.0",
|
| 5120 |
+
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.0.tgz",
|
| 5121 |
+
"integrity": "sha512-OZzU+yXMhe69vLkDex+5QxcfT94FdcgVCyW2dBUw35ZoC3Is42TUxUy04w8nH1mfMKaizVdC3rh/wUfNW1mK4w==",
|
| 5122 |
+
"license": "MIT",
|
| 5123 |
+
"dependencies": {
|
| 5124 |
+
"normalize-wheel": "^1.0.1",
|
| 5125 |
+
"tslib": "^2.0.1"
|
| 5126 |
+
},
|
| 5127 |
+
"peerDependencies": {
|
| 5128 |
+
"react": ">=16.4.0",
|
| 5129 |
+
"react-dom": ">=16.4.0"
|
| 5130 |
+
}
|
| 5131 |
+
},
|
| 5132 |
"node_modules/react-focus-lock": {
|
| 5133 |
"version": "2.13.6",
|
| 5134 |
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.6.tgz",
|
frontend/package.json
CHANGED
|
@@ -44,10 +44,13 @@
|
|
| 44 |
"@ifrc-go/icons": "^2.0.1",
|
| 45 |
"@ifrc-go/ui": "^1.3.0",
|
| 46 |
"@types/jszip": "^3.4.0",
|
|
|
|
| 47 |
"jszip": "^3.10.1",
|
| 48 |
"lucide-react": "^0.525.0",
|
| 49 |
"react": "^18.2.0",
|
|
|
|
| 50 |
"react-dom": "^18.2.0",
|
|
|
|
| 51 |
"react-router-dom": "^6.30.1",
|
| 52 |
"vite": "^7.1.3"
|
| 53 |
}
|
|
|
|
| 44 |
"@ifrc-go/icons": "^2.0.1",
|
| 45 |
"@ifrc-go/ui": "^1.3.0",
|
| 46 |
"@types/jszip": "^3.4.0",
|
| 47 |
+
"cropperjs": "^2.0.1",
|
| 48 |
"jszip": "^3.10.1",
|
| 49 |
"lucide-react": "^0.525.0",
|
| 50 |
"react": "^18.2.0",
|
| 51 |
+
"react-cropper": "^2.3.3",
|
| 52 |
"react-dom": "^18.2.0",
|
| 53 |
+
"react-easy-crop": "^5.5.0",
|
| 54 |
"react-router-dom": "^6.30.1",
|
| 55 |
"vite": "^7.1.3"
|
| 56 |
}
|
frontend/src/components/FilterBar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
import { Container, TextInput, SelectInput, MultiSelectInput, Button } from '@ifrc-go/ui';
|
|
|
|
| 3 |
import { useFilterContext } from '../hooks/useFilterContext';
|
| 4 |
|
| 5 |
interface FilterBarProps {
|
|
@@ -19,6 +20,8 @@ export default function FilterBar({
|
|
| 19 |
imageTypes,
|
| 20 |
isLoadingFilters = false
|
| 21 |
}: FilterBarProps) {
|
|
|
|
|
|
|
| 22 |
const {
|
| 23 |
search, setSearch,
|
| 24 |
srcFilter, setSrcFilter,
|
|
@@ -26,14 +29,27 @@ export default function FilterBar({
|
|
| 26 |
regionFilter, setRegionFilter,
|
| 27 |
countryFilter, setCountryFilter,
|
| 28 |
imageTypeFilter, setImageTypeFilter,
|
|
|
|
| 29 |
showReferenceExamples, setShowReferenceExamples,
|
| 30 |
clearAllFilters
|
| 31 |
} = useFilterContext();
|
| 32 |
|
| 33 |
return (
|
| 34 |
<div className="mb-6 space-y-4">
|
| 35 |
-
{/* Layer 1: Search,
|
| 36 |
<div className="flex flex-wrap items-center gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2 flex-1 min-w-[300px]">
|
| 38 |
<TextInput
|
| 39 |
name="search"
|
|
@@ -43,8 +59,6 @@ export default function FilterBar({
|
|
| 43 |
/>
|
| 44 |
</Container>
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 49 |
<Button
|
| 50 |
name="clear-filters"
|
|
@@ -56,9 +70,11 @@ export default function FilterBar({
|
|
| 56 |
</Container>
|
| 57 |
</div>
|
| 58 |
|
| 59 |
-
{/* Layer 2:
|
| 60 |
-
|
| 61 |
-
<
|
|
|
|
|
|
|
| 62 |
<SelectInput
|
| 63 |
name="source"
|
| 64 |
placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
|
|
@@ -72,7 +88,7 @@ export default function FilterBar({
|
|
| 72 |
/>
|
| 73 |
</Container>
|
| 74 |
|
| 75 |
-
<Container withInternalPadding className="
|
| 76 |
<SelectInput
|
| 77 |
name="category"
|
| 78 |
placeholder={isLoadingFilters ? "Loading..." : "All Categories"}
|
|
@@ -86,7 +102,7 @@ export default function FilterBar({
|
|
| 86 |
/>
|
| 87 |
</Container>
|
| 88 |
|
| 89 |
-
<Container withInternalPadding className="
|
| 90 |
<SelectInput
|
| 91 |
name="region"
|
| 92 |
placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
|
|
@@ -100,7 +116,7 @@ export default function FilterBar({
|
|
| 100 |
/>
|
| 101 |
</Container>
|
| 102 |
|
| 103 |
-
<Container withInternalPadding className="
|
| 104 |
<MultiSelectInput
|
| 105 |
name="country"
|
| 106 |
placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
|
|
@@ -113,7 +129,7 @@ export default function FilterBar({
|
|
| 113 |
/>
|
| 114 |
</Container>
|
| 115 |
|
| 116 |
-
<Container withInternalPadding className="
|
| 117 |
<SelectInput
|
| 118 |
name="imageType"
|
| 119 |
placeholder={isLoadingFilters ? "Loading..." : "All Image Types"}
|
|
@@ -126,7 +142,26 @@ export default function FilterBar({
|
|
| 126 |
disabled={isLoadingFilters}
|
| 127 |
/>
|
| 128 |
</Container>
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
);
|
| 132 |
}
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
import { Container, TextInput, SelectInput, MultiSelectInput, Button } from '@ifrc-go/ui';
|
| 3 |
+
import { FilterLineIcon } from '@ifrc-go/icons';
|
| 4 |
import { useFilterContext } from '../hooks/useFilterContext';
|
| 5 |
|
| 6 |
interface FilterBarProps {
|
|
|
|
| 20 |
imageTypes,
|
| 21 |
isLoadingFilters = false
|
| 22 |
}: FilterBarProps) {
|
| 23 |
+
const [showFilters, setShowFilters] = useState(false);
|
| 24 |
+
|
| 25 |
const {
|
| 26 |
search, setSearch,
|
| 27 |
srcFilter, setSrcFilter,
|
|
|
|
| 29 |
regionFilter, setRegionFilter,
|
| 30 |
countryFilter, setCountryFilter,
|
| 31 |
imageTypeFilter, setImageTypeFilter,
|
| 32 |
+
uploadTypeFilter, setUploadTypeFilter,
|
| 33 |
showReferenceExamples, setShowReferenceExamples,
|
| 34 |
clearAllFilters
|
| 35 |
} = useFilterContext();
|
| 36 |
|
| 37 |
return (
|
| 38 |
<div className="mb-6 space-y-4">
|
| 39 |
+
{/* Layer 1: Search, Filter Button, Clear Filters */}
|
| 40 |
<div className="flex flex-wrap items-center gap-4">
|
| 41 |
+
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 42 |
+
<Button
|
| 43 |
+
name="toggle-filters"
|
| 44 |
+
variant="secondary"
|
| 45 |
+
onClick={() => setShowFilters(!showFilters)}
|
| 46 |
+
className="whitespace-nowrap"
|
| 47 |
+
title={showFilters ? 'Hide Filters' : 'Show Filters'}
|
| 48 |
+
>
|
| 49 |
+
<FilterLineIcon className="w-4 h-4" />
|
| 50 |
+
</Button>
|
| 51 |
+
</Container>
|
| 52 |
+
|
| 53 |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2 flex-1 min-w-[300px]">
|
| 54 |
<TextInput
|
| 55 |
name="search"
|
|
|
|
| 59 |
/>
|
| 60 |
</Container>
|
| 61 |
|
|
|
|
|
|
|
| 62 |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 63 |
<Button
|
| 64 |
name="clear-filters"
|
|
|
|
| 70 |
</Container>
|
| 71 |
</div>
|
| 72 |
|
| 73 |
+
{/* Layer 2: Filter Dropdown */}
|
| 74 |
+
{showFilters && (
|
| 75 |
+
<div className="bg-white/20 backdrop-blur-sm rounded-md p-4">
|
| 76 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
| 77 |
+
<Container withInternalPadding className="p-2">
|
| 78 |
<SelectInput
|
| 79 |
name="source"
|
| 80 |
placeholder={isLoadingFilters ? "Loading..." : "All Sources"}
|
|
|
|
| 88 |
/>
|
| 89 |
</Container>
|
| 90 |
|
| 91 |
+
<Container withInternalPadding className="p-2">
|
| 92 |
<SelectInput
|
| 93 |
name="category"
|
| 94 |
placeholder={isLoadingFilters ? "Loading..." : "All Categories"}
|
|
|
|
| 102 |
/>
|
| 103 |
</Container>
|
| 104 |
|
| 105 |
+
<Container withInternalPadding className="p-2">
|
| 106 |
<SelectInput
|
| 107 |
name="region"
|
| 108 |
placeholder={isLoadingFilters ? "Loading..." : "All Regions"}
|
|
|
|
| 116 |
/>
|
| 117 |
</Container>
|
| 118 |
|
| 119 |
+
<Container withInternalPadding className="p-2">
|
| 120 |
<MultiSelectInput
|
| 121 |
name="country"
|
| 122 |
placeholder={isLoadingFilters ? "Loading..." : "All Countries"}
|
|
|
|
| 129 |
/>
|
| 130 |
</Container>
|
| 131 |
|
| 132 |
+
<Container withInternalPadding className="p-2">
|
| 133 |
<SelectInput
|
| 134 |
name="imageType"
|
| 135 |
placeholder={isLoadingFilters ? "Loading..." : "All Image Types"}
|
|
|
|
| 142 |
disabled={isLoadingFilters}
|
| 143 |
/>
|
| 144 |
</Container>
|
| 145 |
+
|
| 146 |
+
<Container withInternalPadding className="p-2">
|
| 147 |
+
<SelectInput
|
| 148 |
+
name="uploadType"
|
| 149 |
+
placeholder="All Upload Types"
|
| 150 |
+
options={[
|
| 151 |
+
{ key: 'single', label: 'Single Upload' },
|
| 152 |
+
{ key: 'multiple', label: 'Multiple Upload' }
|
| 153 |
+
]}
|
| 154 |
+
value={uploadTypeFilter || null}
|
| 155 |
+
onChange={(v) => setUploadTypeFilter(v as string || '')}
|
| 156 |
+
keySelector={(o) => o.key}
|
| 157 |
+
labelSelector={(o) => o.label}
|
| 158 |
+
required={false}
|
| 159 |
+
disabled={false}
|
| 160 |
+
/>
|
| 161 |
+
</Container>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
</div>
|
| 166 |
);
|
| 167 |
}
|
frontend/src/components/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Main components
|
| 2 |
+
export { default as FilterBar } from './FilterBar';
|
| 3 |
+
export { default as HeaderNav } from './HeaderNav';
|
| 4 |
+
export { default as ExportModal } from './ExportModal';
|
| 5 |
+
|
| 6 |
+
// Upload components
|
| 7 |
+
export * from './upload';
|
frontend/src/components/upload/FileUploadSection.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import type { DragEvent } from 'react';
|
| 3 |
+
import { Button, Container, SegmentInput, IconButton } from '@ifrc-go/ui';
|
| 4 |
+
import { UploadCloudLineIcon, ArrowRightLineIcon, DeleteBinLineIcon } from '@ifrc-go/icons';
|
| 5 |
+
import { Link } from 'react-router-dom';
|
| 6 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 7 |
+
|
| 8 |
+
interface FileUploadSectionProps {
|
| 9 |
+
files: File[];
|
| 10 |
+
file: File | null;
|
| 11 |
+
preview: string | null;
|
| 12 |
+
imageType: string;
|
| 13 |
+
onFileChange: (file: File | undefined) => void;
|
| 14 |
+
onRemoveImage: (index: number) => void;
|
| 15 |
+
onAddImage: () => void;
|
| 16 |
+
onImageTypeChange: (value: string | undefined) => void;
|
| 17 |
+
|
| 18 |
+
onChangeFile?: (file: File | undefined) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function FileUploadSection({
|
| 22 |
+
files,
|
| 23 |
+
file,
|
| 24 |
+
preview,
|
| 25 |
+
imageType,
|
| 26 |
+
onFileChange,
|
| 27 |
+
onRemoveImage,
|
| 28 |
+
onAddImage,
|
| 29 |
+
onImageTypeChange,
|
| 30 |
+
|
| 31 |
+
onChangeFile,
|
| 32 |
+
}: FileUploadSectionProps) {
|
| 33 |
+
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
| 34 |
+
e.preventDefault();
|
| 35 |
+
const dropped = e.dataTransfer.files?.[0];
|
| 36 |
+
if (dropped) {
|
| 37 |
+
onFileChange(dropped);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="space-y-6">
|
| 43 |
+
<p className="text-gray-700 leading-relaxed max-w-2xl mx-auto">
|
| 44 |
+
This app evaluates how well multimodal AI models analyze and describe
|
| 45 |
+
crisis maps and drone imagery. Upload an image and the AI will generate a description.
|
| 46 |
+
Then you can review and rate the result based on your expertise.
|
| 47 |
+
</p>
|
| 48 |
+
|
| 49 |
+
{/* "More »" link */}
|
| 50 |
+
<div className={styles.helpLink}>
|
| 51 |
+
<Link
|
| 52 |
+
to="/help"
|
| 53 |
+
className={styles.helpLink}
|
| 54 |
+
>
|
| 55 |
+
More <ArrowRightLineIcon className="w-3 h-3" />
|
| 56 |
+
</Link>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* Image Type Selection */}
|
| 60 |
+
<div className="flex justify-center">
|
| 61 |
+
<Container withInternalPadding className="bg-transparent border-none shadow-none">
|
| 62 |
+
<SegmentInput
|
| 63 |
+
name="image-type"
|
| 64 |
+
value={imageType}
|
| 65 |
+
onChange={(value) => onImageTypeChange(value as string)}
|
| 66 |
+
options={[
|
| 67 |
+
{ key: 'crisis_map', label: 'Crisis Maps' },
|
| 68 |
+
{ key: 'drone_image', label: 'Drone Imagery' }
|
| 69 |
+
]}
|
| 70 |
+
keySelector={(o) => o.key}
|
| 71 |
+
labelSelector={(o) => o.label}
|
| 72 |
+
/>
|
| 73 |
+
</Container>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div
|
| 77 |
+
className={`${styles.dropZone} ${file ? styles.hasFile : ''}`}
|
| 78 |
+
onDragOver={(e) => e.preventDefault()}
|
| 79 |
+
onDrop={onDrop}
|
| 80 |
+
>
|
| 81 |
+
{files.length > 1 ? (
|
| 82 |
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-4">
|
| 83 |
+
{files.map((file, index) => (
|
| 84 |
+
<div key={index} className="relative">
|
| 85 |
+
<img
|
| 86 |
+
src={URL.createObjectURL(file)}
|
| 87 |
+
alt={`Image ${index + 1}`}
|
| 88 |
+
className="w-full h-32 object-cover rounded"
|
| 89 |
+
/>
|
| 90 |
+
<IconButton
|
| 91 |
+
name="remove-image"
|
| 92 |
+
variant="tertiary"
|
| 93 |
+
onClick={() => onRemoveImage(index)}
|
| 94 |
+
title="Remove image"
|
| 95 |
+
ariaLabel="Remove image"
|
| 96 |
+
className="absolute top-2 right-2 bg-white/90 hover:bg-white shadow-md hover:shadow-lg border border-gray-200 hover:border-red-300 transition-all duration-200 backdrop-blur-sm"
|
| 97 |
+
>
|
| 98 |
+
<DeleteBinLineIcon className="w-4 h-4" />
|
| 99 |
+
</IconButton>
|
| 100 |
+
<div className="text-xs text-center mt-1">{file.name}</div>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
) : file && preview ? (
|
| 105 |
+
<div className={styles.filePreview}>
|
| 106 |
+
<div className={styles.filePreviewImage}>
|
| 107 |
+
<img
|
| 108 |
+
src={preview}
|
| 109 |
+
alt="File preview"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
<p className={styles.fileName}>
|
| 113 |
+
{file.name}
|
| 114 |
+
</p>
|
| 115 |
+
<p className={styles.fileInfo}>
|
| 116 |
+
{(file.size / 1024 / 1024).toFixed(2)} MB
|
| 117 |
+
</p>
|
| 118 |
+
</div>
|
| 119 |
+
) : (
|
| 120 |
+
<>
|
| 121 |
+
<UploadCloudLineIcon className={styles.dropZoneIcon} />
|
| 122 |
+
<p className={styles.dropZoneText}>Drag & Drop any file here</p>
|
| 123 |
+
<p className={styles.dropZoneSubtext}>or</p>
|
| 124 |
+
</>
|
| 125 |
+
)}
|
| 126 |
+
|
| 127 |
+
<div className="flex gap-2">
|
| 128 |
+
<label className="inline-block cursor-pointer">
|
| 129 |
+
<input
|
| 130 |
+
type="file"
|
| 131 |
+
className="sr-only"
|
| 132 |
+
accept=".jpg,.jpeg,.png,.tiff,.tif,.heic,.heif,.webp,.gif,.pdf"
|
| 133 |
+
onChange={e => {
|
| 134 |
+
if (file && onChangeFile) {
|
| 135 |
+
// If there's already a file, use onChangeFile to replace it
|
| 136 |
+
onChangeFile(e.target.files?.[0]);
|
| 137 |
+
} else {
|
| 138 |
+
// If no file exists, use onFileChange to add it
|
| 139 |
+
onFileChange(e.target.files?.[0]);
|
| 140 |
+
}
|
| 141 |
+
}}
|
| 142 |
+
/>
|
| 143 |
+
<Button
|
| 144 |
+
name="upload"
|
| 145 |
+
variant="secondary"
|
| 146 |
+
size={1}
|
| 147 |
+
onClick={() => (document.querySelector('input[type="file"]') as HTMLInputElement)?.click()}
|
| 148 |
+
>
|
| 149 |
+
{file ? 'Change Image' : 'Browse Files'}
|
| 150 |
+
</Button>
|
| 151 |
+
</label>
|
| 152 |
+
|
| 153 |
+
{file && files.length < 5 && (
|
| 154 |
+
<Button
|
| 155 |
+
name="add-image"
|
| 156 |
+
variant="secondary"
|
| 157 |
+
size={1}
|
| 158 |
+
onClick={onAddImage}
|
| 159 |
+
>
|
| 160 |
+
Add Image
|
| 161 |
+
</Button>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
frontend/src/components/upload/GeneratedTextSection.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Container, TextArea, Button, IconButton } from '@ifrc-go/ui';
|
| 2 |
+
import { DeleteBinLineIcon } from '@ifrc-go/icons';
|
| 3 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 4 |
+
|
| 5 |
+
interface GeneratedTextSectionProps {
|
| 6 |
+
description: string;
|
| 7 |
+
analysis: string;
|
| 8 |
+
recommendedActions: string;
|
| 9 |
+
onDescriptionChange: (value: string | undefined) => void;
|
| 10 |
+
onAnalysisChange: (value: string | undefined) => void;
|
| 11 |
+
onRecommendedActionsChange: (value: string | undefined) => void;
|
| 12 |
+
onBack: () => void;
|
| 13 |
+
onDelete: () => void;
|
| 14 |
+
onSubmit: () => void;
|
| 15 |
+
onEditRatings?: () => void;
|
| 16 |
+
isPerformanceConfirmed?: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function GeneratedTextSection({
|
| 20 |
+
description,
|
| 21 |
+
analysis,
|
| 22 |
+
recommendedActions,
|
| 23 |
+
onDescriptionChange,
|
| 24 |
+
onAnalysisChange,
|
| 25 |
+
onRecommendedActionsChange,
|
| 26 |
+
onBack,
|
| 27 |
+
onDelete,
|
| 28 |
+
onSubmit,
|
| 29 |
+
onEditRatings,
|
| 30 |
+
isPerformanceConfirmed = false,
|
| 31 |
+
}: GeneratedTextSectionProps) {
|
| 32 |
+
const handleTextChange = (value: string | undefined) => {
|
| 33 |
+
if (value) {
|
| 34 |
+
const lines = value.split('\n');
|
| 35 |
+
const descIndex = lines.findIndex(line => line.startsWith('Description:'));
|
| 36 |
+
const analysisIndex = lines.findIndex(line => line.startsWith('Analysis:'));
|
| 37 |
+
const actionsIndex = lines.findIndex(line => line.startsWith('Recommended Actions:'));
|
| 38 |
+
|
| 39 |
+
if (descIndex !== -1 && analysisIndex !== -1 && actionsIndex !== -1) {
|
| 40 |
+
onDescriptionChange(lines.slice(descIndex + 1, analysisIndex).join('\n').trim());
|
| 41 |
+
onAnalysisChange(lines.slice(analysisIndex + 1, actionsIndex).join('\n').trim());
|
| 42 |
+
onRecommendedActionsChange(lines.slice(actionsIndex + 1).join('\n').trim());
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<Container
|
| 49 |
+
heading="Generated Text"
|
| 50 |
+
headingLevel={3}
|
| 51 |
+
withHeaderBorder
|
| 52 |
+
withInternalPadding
|
| 53 |
+
>
|
| 54 |
+
<div className="text-left space-y-4">
|
| 55 |
+
<div>
|
| 56 |
+
<TextArea
|
| 57 |
+
name="generatedContent"
|
| 58 |
+
value={`Description:\n${description || 'AI-generated description will appear here...'}\n\nAnalysis:\n${analysis || 'AI-generated analysis will appear here...'}\n\nRecommended Actions:\n${recommendedActions || 'AI-generated recommended actions will appear here...'}`}
|
| 59 |
+
onChange={handleTextChange}
|
| 60 |
+
rows={12}
|
| 61 |
+
placeholder="AI-generated content will appear here..."
|
| 62 |
+
/>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* ────── SUBMIT BUTTONS ────── */}
|
| 67 |
+
<div className={styles.submitSection}>
|
| 68 |
+
<Button
|
| 69 |
+
name="back"
|
| 70 |
+
variant="secondary"
|
| 71 |
+
onClick={onBack}
|
| 72 |
+
>
|
| 73 |
+
Back
|
| 74 |
+
</Button>
|
| 75 |
+
{isPerformanceConfirmed && onEditRatings && (
|
| 76 |
+
<Button
|
| 77 |
+
name="edit-ratings"
|
| 78 |
+
variant="secondary"
|
| 79 |
+
onClick={onEditRatings}
|
| 80 |
+
>
|
| 81 |
+
Edit Ratings
|
| 82 |
+
</Button>
|
| 83 |
+
)}
|
| 84 |
+
<IconButton
|
| 85 |
+
name="delete"
|
| 86 |
+
variant="tertiary"
|
| 87 |
+
onClick={onDelete}
|
| 88 |
+
title="Delete"
|
| 89 |
+
ariaLabel="Delete uploaded image"
|
| 90 |
+
>
|
| 91 |
+
<DeleteBinLineIcon />
|
| 92 |
+
</IconButton>
|
| 93 |
+
<Button
|
| 94 |
+
name="submit"
|
| 95 |
+
onClick={onSubmit}
|
| 96 |
+
>
|
| 97 |
+
Submit
|
| 98 |
+
</Button>
|
| 99 |
+
</div>
|
| 100 |
+
</Container>
|
| 101 |
+
);
|
| 102 |
+
}
|
frontend/src/components/upload/ImagePreviewSection.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button, Container } from '@ifrc-go/ui';
|
| 2 |
+
import { ChevronLeftLineIcon, ChevronRightLineIcon } from '@ifrc-go/icons';
|
| 3 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 4 |
+
|
| 5 |
+
interface ImagePreviewSectionProps {
|
| 6 |
+
files: File[];
|
| 7 |
+
imageUrl: string | null;
|
| 8 |
+
preview: string | null;
|
| 9 |
+
onViewFullSize: (imageData?: { file: File; index: number }) => void;
|
| 10 |
+
// Carousel props for step 2b
|
| 11 |
+
currentImageIndex?: number;
|
| 12 |
+
onGoToPrevious?: () => void;
|
| 13 |
+
onGoToNext?: () => void;
|
| 14 |
+
onGoToImage?: (index: number) => void;
|
| 15 |
+
showCarousel?: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function ImagePreviewSection({
|
| 19 |
+
files,
|
| 20 |
+
imageUrl,
|
| 21 |
+
preview,
|
| 22 |
+
onViewFullSize,
|
| 23 |
+
currentImageIndex = 0,
|
| 24 |
+
onGoToPrevious,
|
| 25 |
+
onGoToNext,
|
| 26 |
+
onGoToImage,
|
| 27 |
+
showCarousel = false,
|
| 28 |
+
}: ImagePreviewSectionProps) {
|
| 29 |
+
// If carousel is enabled and multiple files, show carousel
|
| 30 |
+
if (showCarousel && files.length > 1) {
|
| 31 |
+
return (
|
| 32 |
+
<Container heading="Uploaded Images" headingLevel={3} withHeaderBorder withInternalPadding>
|
| 33 |
+
<div className={styles.carouselContainer}>
|
| 34 |
+
<div className={styles.carouselImageWrapper}>
|
| 35 |
+
{files[currentImageIndex] ? (
|
| 36 |
+
<img
|
| 37 |
+
src={URL.createObjectURL(files[currentImageIndex])}
|
| 38 |
+
alt={`Image ${currentImageIndex + 1}`}
|
| 39 |
+
className={styles.carouselImage}
|
| 40 |
+
/>
|
| 41 |
+
) : (
|
| 42 |
+
<div className={styles.imagePlaceholder}>
|
| 43 |
+
No image available
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
{/* Carousel Navigation */}
|
| 49 |
+
<div className={styles.carouselNavigation}>
|
| 50 |
+
<Button
|
| 51 |
+
name="previous-image"
|
| 52 |
+
variant="tertiary"
|
| 53 |
+
size={1}
|
| 54 |
+
onClick={onGoToPrevious}
|
| 55 |
+
className={styles.carouselButton}
|
| 56 |
+
>
|
| 57 |
+
<ChevronLeftLineIcon className="w-4 h-4" />
|
| 58 |
+
</Button>
|
| 59 |
+
|
| 60 |
+
<div className={styles.carouselIndicators}>
|
| 61 |
+
{files.map((_, index) => (
|
| 62 |
+
<button
|
| 63 |
+
key={index}
|
| 64 |
+
onClick={() => onGoToImage?.(index)}
|
| 65 |
+
className={`${styles.carouselIndicator} ${
|
| 66 |
+
index === currentImageIndex ? styles.carouselIndicatorActive : ''
|
| 67 |
+
}`}
|
| 68 |
+
>
|
| 69 |
+
{index + 1}
|
| 70 |
+
</button>
|
| 71 |
+
))}
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<Button
|
| 75 |
+
name="next-image"
|
| 76 |
+
variant="tertiary"
|
| 77 |
+
size={1}
|
| 78 |
+
onClick={onGoToNext}
|
| 79 |
+
className={styles.carouselButton}
|
| 80 |
+
>
|
| 81 |
+
<ChevronRightLineIcon className="w-4 h-4" />
|
| 82 |
+
</Button>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* View Image Button for Carousel */}
|
| 86 |
+
<div className={styles.viewImageButtonContainer}>
|
| 87 |
+
<Button
|
| 88 |
+
name="view-full-size-carousel"
|
| 89 |
+
variant="secondary"
|
| 90 |
+
size={1}
|
| 91 |
+
onClick={() => onViewFullSize({ file: files[currentImageIndex], index: currentImageIndex })}
|
| 92 |
+
disabled={!files[currentImageIndex]}
|
| 93 |
+
>
|
| 94 |
+
View Image
|
| 95 |
+
</Button>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</Container>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Original implementation for single image or non-carousel mode
|
| 103 |
+
if (files.length > 1) {
|
| 104 |
+
return (
|
| 105 |
+
<div className="space-y-6">
|
| 106 |
+
{files.map((file, index) => (
|
| 107 |
+
<Container key={index} heading={`Image ${index + 1}: ${file.name}`} headingLevel={3} withHeaderBorder withInternalPadding>
|
| 108 |
+
<div className={styles.uploadedMapContainer}>
|
| 109 |
+
<div className={styles.uploadedMapImage}>
|
| 110 |
+
<img
|
| 111 |
+
src={URL.createObjectURL(file)}
|
| 112 |
+
alt={`Image ${index + 1}`}
|
| 113 |
+
/>
|
| 114 |
+
</div>
|
| 115 |
+
<div className={styles.viewFullSizeButton}>
|
| 116 |
+
<Button
|
| 117 |
+
name={`view-full-size-${index}`}
|
| 118 |
+
variant="secondary"
|
| 119 |
+
size={1}
|
| 120 |
+
onClick={() => onViewFullSize({ file, index })}
|
| 121 |
+
>
|
| 122 |
+
View Image
|
| 123 |
+
</Button>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</Container>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<Container heading="Uploaded Image" headingLevel={3} withHeaderBorder withInternalPadding>
|
| 134 |
+
<div className={styles.uploadedMapContainer}>
|
| 135 |
+
<div className={styles.uploadedMapImage}>
|
| 136 |
+
<img
|
| 137 |
+
src={imageUrl || preview || undefined}
|
| 138 |
+
alt="Uploaded image preview"
|
| 139 |
+
/>
|
| 140 |
+
</div>
|
| 141 |
+
<div className={styles.viewFullSizeButton}>
|
| 142 |
+
<Button
|
| 143 |
+
name="view-full-size"
|
| 144 |
+
variant="secondary"
|
| 145 |
+
size={1}
|
| 146 |
+
onClick={() => onViewFullSize()}
|
| 147 |
+
>
|
| 148 |
+
View Image
|
| 149 |
+
</Button>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</Container>
|
| 153 |
+
);
|
| 154 |
+
}
|
frontend/src/components/upload/MetadataFormSection.tsx
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Container, TextInput, SelectInput, MultiSelectInput } from '@ifrc-go/ui';
|
| 2 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 3 |
+
|
| 4 |
+
interface MetadataFormSectionProps {
|
| 5 |
+
files: File[];
|
| 6 |
+
imageType: string;
|
| 7 |
+
title: string;
|
| 8 |
+
source: string;
|
| 9 |
+
eventType: string;
|
| 10 |
+
epsg: string;
|
| 11 |
+
countries: string[];
|
| 12 |
+
centerLon: string;
|
| 13 |
+
centerLat: string;
|
| 14 |
+
amslM: string;
|
| 15 |
+
aglM: string;
|
| 16 |
+
headingDeg: string;
|
| 17 |
+
yawDeg: string;
|
| 18 |
+
pitchDeg: string;
|
| 19 |
+
rollDeg: string;
|
| 20 |
+
rtkFix: boolean;
|
| 21 |
+
stdHM: string;
|
| 22 |
+
stdVM: string;
|
| 23 |
+
metadataArray: Array<{
|
| 24 |
+
source: string;
|
| 25 |
+
eventType: string;
|
| 26 |
+
epsg: string;
|
| 27 |
+
countries: string[];
|
| 28 |
+
centerLon: string;
|
| 29 |
+
centerLat: string;
|
| 30 |
+
amslM: string;
|
| 31 |
+
aglM: string;
|
| 32 |
+
headingDeg: string;
|
| 33 |
+
yawDeg: string;
|
| 34 |
+
pitchDeg: string;
|
| 35 |
+
rollDeg: string;
|
| 36 |
+
rtkFix: boolean;
|
| 37 |
+
stdHM: string;
|
| 38 |
+
stdVM: string;
|
| 39 |
+
}>;
|
| 40 |
+
sources: {s_code: string, label: string}[];
|
| 41 |
+
types: {t_code: string, label: string}[];
|
| 42 |
+
spatialReferences: {epsg: string, srid: string, proj4: string, wkt: string}[];
|
| 43 |
+
imageTypes: {image_type: string, label: string}[];
|
| 44 |
+
countriesOptions: {c_code: string, label: string, r_code: string}[];
|
| 45 |
+
onTitleChange: (value: string | undefined) => void;
|
| 46 |
+
onSourceChange: (value: string | undefined) => void;
|
| 47 |
+
onEventTypeChange: (value: string | undefined) => void;
|
| 48 |
+
onEpsgChange: (value: string | undefined) => void;
|
| 49 |
+
onCountriesChange: (value: string[] | undefined) => void;
|
| 50 |
+
onCenterLonChange: (value: string | undefined) => void;
|
| 51 |
+
onCenterLatChange: (value: string | undefined) => void;
|
| 52 |
+
onAmslMChange: (value: string | undefined) => void;
|
| 53 |
+
onAglMChange: (value: string | undefined) => void;
|
| 54 |
+
onHeadingDegChange: (value: string | undefined) => void;
|
| 55 |
+
onYawDegChange: (value: string | undefined) => void;
|
| 56 |
+
onPitchDegChange: (value: string | undefined) => void;
|
| 57 |
+
onRollDegChange: (value: string | undefined) => void;
|
| 58 |
+
onRtkFixChange: (value: boolean | undefined) => void;
|
| 59 |
+
onStdHMChange: (value: string | undefined) => void;
|
| 60 |
+
onStdVMChange: (value: string | undefined) => void;
|
| 61 |
+
onImageTypeChange: (value: string | undefined) => void;
|
| 62 |
+
updateMetadataForImage: (index: number, field: string, value: any) => void;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export default function MetadataFormSection({
|
| 66 |
+
files,
|
| 67 |
+
imageType,
|
| 68 |
+
title,
|
| 69 |
+
source,
|
| 70 |
+
eventType,
|
| 71 |
+
epsg,
|
| 72 |
+
countries,
|
| 73 |
+
centerLon,
|
| 74 |
+
centerLat,
|
| 75 |
+
amslM,
|
| 76 |
+
aglM,
|
| 77 |
+
headingDeg,
|
| 78 |
+
yawDeg,
|
| 79 |
+
pitchDeg,
|
| 80 |
+
rollDeg,
|
| 81 |
+
rtkFix,
|
| 82 |
+
stdHM,
|
| 83 |
+
stdVM,
|
| 84 |
+
metadataArray,
|
| 85 |
+
sources,
|
| 86 |
+
types,
|
| 87 |
+
spatialReferences,
|
| 88 |
+
imageTypes,
|
| 89 |
+
countriesOptions,
|
| 90 |
+
onTitleChange,
|
| 91 |
+
onSourceChange,
|
| 92 |
+
onEventTypeChange,
|
| 93 |
+
onEpsgChange,
|
| 94 |
+
onCountriesChange,
|
| 95 |
+
onCenterLonChange,
|
| 96 |
+
onCenterLatChange,
|
| 97 |
+
onAmslMChange,
|
| 98 |
+
onAglMChange,
|
| 99 |
+
onHeadingDegChange,
|
| 100 |
+
onYawDegChange,
|
| 101 |
+
onPitchDegChange,
|
| 102 |
+
onRollDegChange,
|
| 103 |
+
onRtkFixChange,
|
| 104 |
+
onStdHMChange,
|
| 105 |
+
onStdVMChange,
|
| 106 |
+
onImageTypeChange,
|
| 107 |
+
updateMetadataForImage,
|
| 108 |
+
}: MetadataFormSectionProps) {
|
| 109 |
+
if (files.length > 1) {
|
| 110 |
+
return (
|
| 111 |
+
<div>
|
| 112 |
+
<div className="mb-4">
|
| 113 |
+
<TextInput
|
| 114 |
+
label="Shared Title"
|
| 115 |
+
name="title"
|
| 116 |
+
value={title}
|
| 117 |
+
onChange={onTitleChange}
|
| 118 |
+
placeholder="Enter a title for all images..."
|
| 119 |
+
required
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
{files.map((file, index) => (
|
| 123 |
+
<div key={index} className="mb-6">
|
| 124 |
+
<Container
|
| 125 |
+
heading={`Image ${index + 1}: ${file.name}`}
|
| 126 |
+
headingLevel={4}
|
| 127 |
+
withHeaderBorder
|
| 128 |
+
withInternalPadding
|
| 129 |
+
>
|
| 130 |
+
<div className={styles.formGrid}>
|
| 131 |
+
{imageType !== 'drone_image' && (
|
| 132 |
+
<SelectInput
|
| 133 |
+
label="Source"
|
| 134 |
+
name={`source_${index}`}
|
| 135 |
+
value={metadataArray[index]?.source || ''}
|
| 136 |
+
onChange={(value) => updateMetadataForImage(index, 'source', value)}
|
| 137 |
+
options={sources}
|
| 138 |
+
keySelector={(o) => o.s_code}
|
| 139 |
+
labelSelector={(o) => o.label}
|
| 140 |
+
required
|
| 141 |
+
/>
|
| 142 |
+
)}
|
| 143 |
+
<SelectInput
|
| 144 |
+
label="Event Type"
|
| 145 |
+
name={`event_type_${index}`}
|
| 146 |
+
value={metadataArray[index]?.eventType || ''}
|
| 147 |
+
onChange={(value) => updateMetadataForImage(index, 'eventType', value)}
|
| 148 |
+
options={types}
|
| 149 |
+
keySelector={(o) => o.t_code}
|
| 150 |
+
labelSelector={(o) => o.label}
|
| 151 |
+
required={imageType !== 'drone_image'}
|
| 152 |
+
/>
|
| 153 |
+
<SelectInput
|
| 154 |
+
label="EPSG"
|
| 155 |
+
name={`epsg_${index}`}
|
| 156 |
+
value={metadataArray[index]?.epsg || ''}
|
| 157 |
+
onChange={(value) => updateMetadataForImage(index, 'epsg', value)}
|
| 158 |
+
options={spatialReferences}
|
| 159 |
+
keySelector={(o) => o.epsg}
|
| 160 |
+
labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
|
| 161 |
+
placeholder="EPSG"
|
| 162 |
+
required={imageType !== 'drone_image'}
|
| 163 |
+
/>
|
| 164 |
+
<MultiSelectInput
|
| 165 |
+
label="Countries (optional)"
|
| 166 |
+
name={`countries_${index}`}
|
| 167 |
+
value={metadataArray[index]?.countries || []}
|
| 168 |
+
onChange={(value) => updateMetadataForImage(index, 'countries', value)}
|
| 169 |
+
options={countriesOptions}
|
| 170 |
+
keySelector={(o) => o.c_code}
|
| 171 |
+
labelSelector={(o) => o.label}
|
| 172 |
+
placeholder="Select one or more"
|
| 173 |
+
/>
|
| 174 |
+
|
| 175 |
+
{imageType === 'drone_image' && (
|
| 176 |
+
<>
|
| 177 |
+
<div className={styles.droneMetadataSection}>
|
| 178 |
+
<h4 className={styles.droneMetadataHeading}>Drone Flight Data</h4>
|
| 179 |
+
<div className={styles.droneMetadataGrid}>
|
| 180 |
+
<TextInput
|
| 181 |
+
label="Center Longitude"
|
| 182 |
+
name={`center_lon_${index}`}
|
| 183 |
+
value={metadataArray[index]?.centerLon || ''}
|
| 184 |
+
onChange={(value) => updateMetadataForImage(index, 'centerLon', value)}
|
| 185 |
+
placeholder="e.g., -122.4194"
|
| 186 |
+
step="any"
|
| 187 |
+
/>
|
| 188 |
+
<TextInput
|
| 189 |
+
label="Center Latitude"
|
| 190 |
+
name={`center_lat_${index}`}
|
| 191 |
+
value={metadataArray[index]?.centerLat || ''}
|
| 192 |
+
onChange={(value) => updateMetadataForImage(index, 'centerLat', value)}
|
| 193 |
+
placeholder="e.g., 37.7749"
|
| 194 |
+
step="any"
|
| 195 |
+
/>
|
| 196 |
+
<TextInput
|
| 197 |
+
label="Altitude AMSL (m)"
|
| 198 |
+
name={`amsl_m_${index}`}
|
| 199 |
+
value={metadataArray[index]?.amslM || ''}
|
| 200 |
+
onChange={(value) => updateMetadataForImage(index, 'amslM', value)}
|
| 201 |
+
placeholder="e.g., 100.5"
|
| 202 |
+
step="any"
|
| 203 |
+
/>
|
| 204 |
+
<TextInput
|
| 205 |
+
label="Altitude AGL (m)"
|
| 206 |
+
name={`agl_m_${index}`}
|
| 207 |
+
value={metadataArray[index]?.aglM || ''}
|
| 208 |
+
onChange={(value) => updateMetadataForImage(index, 'aglM', value)}
|
| 209 |
+
placeholder="e.g., 50.2"
|
| 210 |
+
step="any"
|
| 211 |
+
/>
|
| 212 |
+
<TextInput
|
| 213 |
+
label="Heading (degrees)"
|
| 214 |
+
name={`heading_deg_${index}`}
|
| 215 |
+
value={metadataArray[index]?.headingDeg || ''}
|
| 216 |
+
onChange={(value) => updateMetadataForImage(index, 'headingDeg', value)}
|
| 217 |
+
placeholder="e.g., 180.0"
|
| 218 |
+
step="any"
|
| 219 |
+
/>
|
| 220 |
+
<TextInput
|
| 221 |
+
label="Yaw (degrees)"
|
| 222 |
+
name={`yaw_deg_${index}`}
|
| 223 |
+
value={metadataArray[index]?.yawDeg || ''}
|
| 224 |
+
onChange={(value) => updateMetadataForImage(index, 'yawDeg', value)}
|
| 225 |
+
placeholder="e.g., 90.0"
|
| 226 |
+
step="any"
|
| 227 |
+
/>
|
| 228 |
+
<TextInput
|
| 229 |
+
label="Pitch (degrees)"
|
| 230 |
+
name={`pitch_deg_${index}`}
|
| 231 |
+
value={metadataArray[index]?.pitchDeg || ''}
|
| 232 |
+
onChange={(value) => updateMetadataForImage(index, 'pitchDeg', value)}
|
| 233 |
+
placeholder="e.g., 0.0"
|
| 234 |
+
step="any"
|
| 235 |
+
/>
|
| 236 |
+
<TextInput
|
| 237 |
+
label="Roll (degrees)"
|
| 238 |
+
name={`roll_deg_${index}`}
|
| 239 |
+
value={metadataArray[index]?.rollDeg || ''}
|
| 240 |
+
onChange={(value) => updateMetadataForImage(index, 'rollDeg', value)}
|
| 241 |
+
placeholder="e.g., 0.0"
|
| 242 |
+
step="any"
|
| 243 |
+
/>
|
| 244 |
+
<div className={styles.rtkFixContainer}>
|
| 245 |
+
<label className={styles.rtkFixLabel}>
|
| 246 |
+
<input
|
| 247 |
+
type="checkbox"
|
| 248 |
+
checked={metadataArray[index]?.rtkFix || false}
|
| 249 |
+
onChange={(e) => updateMetadataForImage(index, 'rtkFix', e.target.checked)}
|
| 250 |
+
className={styles.rtkFixCheckbox}
|
| 251 |
+
/>
|
| 252 |
+
RTK Fix Available
|
| 253 |
+
</label>
|
| 254 |
+
</div>
|
| 255 |
+
<TextInput
|
| 256 |
+
label="Horizontal Std Dev (m)"
|
| 257 |
+
name={`std_h_m_${index}`}
|
| 258 |
+
value={metadataArray[index]?.stdHM || ''}
|
| 259 |
+
onChange={(value) => updateMetadataForImage(index, 'stdHM', value)}
|
| 260 |
+
placeholder="e.g., 0.1"
|
| 261 |
+
step="any"
|
| 262 |
+
/>
|
| 263 |
+
<TextInput
|
| 264 |
+
label="Vertical Std Dev (m)"
|
| 265 |
+
name={`std_v_m_${index}`}
|
| 266 |
+
value={metadataArray[index]?.stdVM || ''}
|
| 267 |
+
onChange={(value) => updateMetadataForImage(index, 'stdVM', value)}
|
| 268 |
+
placeholder="e.g., 0.2"
|
| 269 |
+
step="any"
|
| 270 |
+
/>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
</>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
</Container>
|
| 277 |
+
</div>
|
| 278 |
+
))}
|
| 279 |
+
</div>
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
return (
|
| 284 |
+
<div className={styles.formGrid}>
|
| 285 |
+
<div className={styles.titleField}>
|
| 286 |
+
<TextInput
|
| 287 |
+
label="Title"
|
| 288 |
+
name="title"
|
| 289 |
+
value={title}
|
| 290 |
+
onChange={onTitleChange}
|
| 291 |
+
placeholder="Enter a title for this map..."
|
| 292 |
+
required
|
| 293 |
+
/>
|
| 294 |
+
</div>
|
| 295 |
+
{imageType !== 'drone_image' && (
|
| 296 |
+
<SelectInput
|
| 297 |
+
label="Source"
|
| 298 |
+
name="source"
|
| 299 |
+
value={source}
|
| 300 |
+
onChange={onSourceChange}
|
| 301 |
+
options={sources}
|
| 302 |
+
keySelector={(o) => o.s_code}
|
| 303 |
+
labelSelector={(o) => o.label}
|
| 304 |
+
required
|
| 305 |
+
/>
|
| 306 |
+
)}
|
| 307 |
+
<SelectInput
|
| 308 |
+
label="Event Type"
|
| 309 |
+
name="event_type"
|
| 310 |
+
value={eventType}
|
| 311 |
+
onChange={onEventTypeChange}
|
| 312 |
+
options={types}
|
| 313 |
+
keySelector={(o) => o.t_code}
|
| 314 |
+
labelSelector={(o) => o.label}
|
| 315 |
+
required={imageType !== 'drone_image'}
|
| 316 |
+
/>
|
| 317 |
+
<SelectInput
|
| 318 |
+
label="EPSG"
|
| 319 |
+
name="epsg"
|
| 320 |
+
value={epsg}
|
| 321 |
+
onChange={onEpsgChange}
|
| 322 |
+
options={spatialReferences}
|
| 323 |
+
keySelector={(o) => o.epsg}
|
| 324 |
+
labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
|
| 325 |
+
placeholder="EPSG"
|
| 326 |
+
required={imageType !== 'drone_image'}
|
| 327 |
+
/>
|
| 328 |
+
<SelectInput
|
| 329 |
+
label="Image Type"
|
| 330 |
+
name="image_type"
|
| 331 |
+
value={imageType}
|
| 332 |
+
onChange={onImageTypeChange}
|
| 333 |
+
options={imageTypes}
|
| 334 |
+
keySelector={(o) => o.image_type}
|
| 335 |
+
labelSelector={(o) => o.label}
|
| 336 |
+
required
|
| 337 |
+
/>
|
| 338 |
+
<MultiSelectInput
|
| 339 |
+
label="Countries (optional)"
|
| 340 |
+
name="countries"
|
| 341 |
+
value={countries}
|
| 342 |
+
onChange={onCountriesChange}
|
| 343 |
+
options={countriesOptions}
|
| 344 |
+
keySelector={(o) => o.c_code}
|
| 345 |
+
labelSelector={(o) => o.label}
|
| 346 |
+
placeholder="Select one or more"
|
| 347 |
+
/>
|
| 348 |
+
|
| 349 |
+
{imageType === 'drone_image' && (
|
| 350 |
+
<>
|
| 351 |
+
<div className={styles.droneMetadataSection}>
|
| 352 |
+
<h4 className={styles.droneMetadataHeading}>Drone Flight Data</h4>
|
| 353 |
+
<div className={styles.droneMetadataGrid}>
|
| 354 |
+
<TextInput
|
| 355 |
+
label="Center Longitude"
|
| 356 |
+
name="center_lon"
|
| 357 |
+
value={centerLon}
|
| 358 |
+
onChange={onCenterLonChange}
|
| 359 |
+
placeholder="e.g., -122.4194"
|
| 360 |
+
step="any"
|
| 361 |
+
/>
|
| 362 |
+
<TextInput
|
| 363 |
+
label="Center Latitude"
|
| 364 |
+
name="center_lat"
|
| 365 |
+
value={centerLat}
|
| 366 |
+
onChange={onCenterLatChange}
|
| 367 |
+
placeholder="e.g., 37.7749"
|
| 368 |
+
step="any"
|
| 369 |
+
/>
|
| 370 |
+
<TextInput
|
| 371 |
+
label="Altitude AMSL (m)"
|
| 372 |
+
name="amsl_m"
|
| 373 |
+
value={amslM}
|
| 374 |
+
onChange={onAmslMChange}
|
| 375 |
+
placeholder="e.g., 100.5"
|
| 376 |
+
step="any"
|
| 377 |
+
/>
|
| 378 |
+
<TextInput
|
| 379 |
+
label="Altitude AGL (m)"
|
| 380 |
+
name="agl_m"
|
| 381 |
+
value={aglM}
|
| 382 |
+
onChange={onAglMChange}
|
| 383 |
+
placeholder="e.g., 50.2"
|
| 384 |
+
step="any"
|
| 385 |
+
/>
|
| 386 |
+
<TextInput
|
| 387 |
+
label="Heading (degrees)"
|
| 388 |
+
name="heading_deg"
|
| 389 |
+
value={headingDeg}
|
| 390 |
+
onChange={onHeadingDegChange}
|
| 391 |
+
placeholder="e.g., 180.0"
|
| 392 |
+
step="any"
|
| 393 |
+
/>
|
| 394 |
+
<TextInput
|
| 395 |
+
label="Yaw (degrees)"
|
| 396 |
+
name="yaw_deg"
|
| 397 |
+
value={yawDeg}
|
| 398 |
+
onChange={onYawDegChange}
|
| 399 |
+
placeholder="e.g., 90.0"
|
| 400 |
+
step="any"
|
| 401 |
+
/>
|
| 402 |
+
<TextInput
|
| 403 |
+
label="Pitch (degrees)"
|
| 404 |
+
name="pitch_deg"
|
| 405 |
+
value={pitchDeg}
|
| 406 |
+
onChange={onPitchDegChange}
|
| 407 |
+
placeholder="e.g., 0.0"
|
| 408 |
+
step="any"
|
| 409 |
+
/>
|
| 410 |
+
<TextInput
|
| 411 |
+
label="Roll (degrees)"
|
| 412 |
+
name="roll_deg"
|
| 413 |
+
value={rollDeg}
|
| 414 |
+
onChange={onRollDegChange}
|
| 415 |
+
placeholder="e.g., 0.0"
|
| 416 |
+
step="any"
|
| 417 |
+
/>
|
| 418 |
+
<div className={styles.rtkFixContainer}>
|
| 419 |
+
<label className={styles.rtkFixLabel}>
|
| 420 |
+
<input
|
| 421 |
+
type="checkbox"
|
| 422 |
+
checked={rtkFix}
|
| 423 |
+
onChange={(e) => onRtkFixChange(e.target.checked)}
|
| 424 |
+
className={styles.rtkFixCheckbox}
|
| 425 |
+
/>
|
| 426 |
+
RTK Fix Available
|
| 427 |
+
</label>
|
| 428 |
+
</div>
|
| 429 |
+
<TextInput
|
| 430 |
+
label="Horizontal Std Dev (m)"
|
| 431 |
+
name="std_h_m"
|
| 432 |
+
value={stdHM}
|
| 433 |
+
onChange={onStdHMChange}
|
| 434 |
+
placeholder="e.g., 0.1"
|
| 435 |
+
step="any"
|
| 436 |
+
/>
|
| 437 |
+
<TextInput
|
| 438 |
+
label="Vertical Std Dev (m)"
|
| 439 |
+
name="std_v_m"
|
| 440 |
+
value={stdVM}
|
| 441 |
+
onChange={onStdVMChange}
|
| 442 |
+
placeholder="e.g., 0.2"
|
| 443 |
+
step="any"
|
| 444 |
+
/>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
</>
|
| 448 |
+
)}
|
| 449 |
+
</div>
|
| 450 |
+
);
|
| 451 |
+
}
|
frontend/src/components/upload/ModalComponents.tsx
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button, Spinner } from '@ifrc-go/ui';
|
| 2 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 3 |
+
|
| 4 |
+
interface FullSizeImageModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
imageUrl: string | null;
|
| 7 |
+
preview: string | null;
|
| 8 |
+
selectedImageData?: { file: File; index: number } | null;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function FullSizeImageModal({ isOpen, imageUrl, preview, selectedImageData, onClose }: FullSizeImageModalProps) {
|
| 13 |
+
if (!isOpen) return null;
|
| 14 |
+
|
| 15 |
+
// Determine which image to show
|
| 16 |
+
let imageSrc: string | undefined;
|
| 17 |
+
let imageAlt: string;
|
| 18 |
+
|
| 19 |
+
if (selectedImageData) {
|
| 20 |
+
// Show specific image from multi-upload
|
| 21 |
+
imageSrc = URL.createObjectURL(selectedImageData.file);
|
| 22 |
+
imageAlt = `Image ${selectedImageData.index + 1}: ${selectedImageData.file.name}`;
|
| 23 |
+
} else {
|
| 24 |
+
// Show single image (backward compatibility)
|
| 25 |
+
imageSrc = imageUrl || preview || undefined;
|
| 26 |
+
imageAlt = "Full size map";
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className={styles.fullSizeModalOverlay} onClick={onClose}>
|
| 31 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 32 |
+
<div className={styles.fullSizeModalHeader}>
|
| 33 |
+
<Button
|
| 34 |
+
name="close-modal"
|
| 35 |
+
variant="tertiary"
|
| 36 |
+
size={1}
|
| 37 |
+
onClick={onClose}
|
| 38 |
+
>
|
| 39 |
+
✕
|
| 40 |
+
</Button>
|
| 41 |
+
</div>
|
| 42 |
+
<div className={styles.fullSizeModalImage}>
|
| 43 |
+
<img
|
| 44 |
+
src={imageSrc}
|
| 45 |
+
alt={imageAlt}
|
| 46 |
+
/>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
interface RatingWarningModalProps {
|
| 54 |
+
isOpen: boolean;
|
| 55 |
+
onClose: () => void;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function RatingWarningModal({ isOpen, onClose }: RatingWarningModalProps) {
|
| 59 |
+
if (!isOpen) return null;
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className={styles.fullSizeModalOverlay} onClick={onClose}>
|
| 63 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 64 |
+
<div className={styles.ratingWarningContent}>
|
| 65 |
+
<h3 className={styles.ratingWarningTitle}>Please Confirm Your Ratings</h3>
|
| 66 |
+
<p className={styles.ratingWarningText}>
|
| 67 |
+
You must confirm your performance ratings before submitting. Please go back to the rating section and click "Confirm Ratings".
|
| 68 |
+
</p>
|
| 69 |
+
<div className={styles.ratingWarningButtons}>
|
| 70 |
+
<Button
|
| 71 |
+
name="close-warning"
|
| 72 |
+
variant="secondary"
|
| 73 |
+
onClick={onClose}
|
| 74 |
+
>
|
| 75 |
+
Close
|
| 76 |
+
</Button>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
interface DeleteConfirmModalProps {
|
| 85 |
+
isOpen: boolean;
|
| 86 |
+
onConfirm: () => void;
|
| 87 |
+
onCancel: () => void;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export function DeleteConfirmModal({ isOpen, onConfirm, onCancel }: DeleteConfirmModalProps) {
|
| 91 |
+
if (!isOpen) return null;
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div className={styles.fullSizeModalOverlay} onClick={onCancel}>
|
| 95 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 96 |
+
<div className={styles.ratingWarningContent}>
|
| 97 |
+
<h3 className={styles.ratingWarningTitle}>Delete Image?</h3>
|
| 98 |
+
<p className={styles.ratingWarningText}>
|
| 99 |
+
This action cannot be undone. Are you sure you want to delete this uploaded image?
|
| 100 |
+
</p>
|
| 101 |
+
<div className={styles.ratingWarningButtons}>
|
| 102 |
+
<Button
|
| 103 |
+
name="confirm-delete"
|
| 104 |
+
variant="secondary"
|
| 105 |
+
onClick={onConfirm}
|
| 106 |
+
>
|
| 107 |
+
Delete
|
| 108 |
+
</Button>
|
| 109 |
+
<Button
|
| 110 |
+
name="cancel-delete"
|
| 111 |
+
variant="tertiary"
|
| 112 |
+
onClick={onCancel}
|
| 113 |
+
>
|
| 114 |
+
Cancel
|
| 115 |
+
</Button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
interface NavigationConfirmModalProps {
|
| 124 |
+
isOpen: boolean;
|
| 125 |
+
onConfirm: () => void;
|
| 126 |
+
onCancel: () => void;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export function NavigationConfirmModal({ isOpen, onConfirm, onCancel }: NavigationConfirmModalProps) {
|
| 130 |
+
if (!isOpen) return null;
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<div className={styles.fullSizeModalOverlay} onClick={onCancel}>
|
| 134 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 135 |
+
<div className={styles.ratingWarningContent}>
|
| 136 |
+
<h3 className={styles.ratingWarningTitle}>Leave Page?</h3>
|
| 137 |
+
<p className={styles.ratingWarningText}>
|
| 138 |
+
Your uploaded image will be deleted if you leave this page. Are you sure you want to continue?
|
| 139 |
+
</p>
|
| 140 |
+
<div className={styles.ratingWarningButtons}>
|
| 141 |
+
<Button
|
| 142 |
+
name="confirm-navigation"
|
| 143 |
+
variant="secondary"
|
| 144 |
+
onClick={onConfirm}
|
| 145 |
+
>
|
| 146 |
+
Leave Page
|
| 147 |
+
</Button>
|
| 148 |
+
<Button
|
| 149 |
+
name="cancel-navigation"
|
| 150 |
+
variant="tertiary"
|
| 151 |
+
onClick={onCancel}
|
| 152 |
+
>
|
| 153 |
+
Stay
|
| 154 |
+
</Button>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
interface FallbackNotificationModalProps {
|
| 163 |
+
isOpen: boolean;
|
| 164 |
+
fallbackInfo: {
|
| 165 |
+
originalModel: string;
|
| 166 |
+
fallbackModel: string;
|
| 167 |
+
reason: string;
|
| 168 |
+
} | null;
|
| 169 |
+
onClose: () => void;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
export function FallbackNotificationModal({ isOpen, fallbackInfo, onClose }: FallbackNotificationModalProps) {
|
| 173 |
+
if (!isOpen || !fallbackInfo) return null;
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<div className={styles.fullSizeModalOverlay} onClick={onClose}>
|
| 177 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 178 |
+
<div className={styles.ratingWarningContent}>
|
| 179 |
+
<h3 className={styles.ratingWarningTitle}>Model Changed</h3>
|
| 180 |
+
<p className={styles.ratingWarningText}>
|
| 181 |
+
{fallbackInfo.originalModel} is currently unavailable.
|
| 182 |
+
We've automatically switched to {fallbackInfo.fallbackModel} to complete your request.
|
| 183 |
+
</p>
|
| 184 |
+
<div className={styles.ratingWarningButtons}>
|
| 185 |
+
<Button
|
| 186 |
+
name="close-fallback"
|
| 187 |
+
variant="secondary"
|
| 188 |
+
onClick={onClose}
|
| 189 |
+
>
|
| 190 |
+
Got it
|
| 191 |
+
</Button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
interface PreprocessingNotificationModalProps {
|
| 200 |
+
isOpen: boolean;
|
| 201 |
+
preprocessingInfo: {
|
| 202 |
+
original_filename: string;
|
| 203 |
+
processed_filename: string;
|
| 204 |
+
original_mime_type: string;
|
| 205 |
+
processed_mime_type: string;
|
| 206 |
+
was_preprocessed: boolean;
|
| 207 |
+
error?: string;
|
| 208 |
+
} | null;
|
| 209 |
+
onClose: () => void;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
export function PreprocessingNotificationModal({ isOpen, preprocessingInfo, onClose }: PreprocessingNotificationModalProps) {
|
| 213 |
+
if (!isOpen || !preprocessingInfo) return null;
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<div className={styles.fullSizeModalOverlay} onClick={onClose}>
|
| 217 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 218 |
+
<div className={styles.ratingWarningContent}>
|
| 219 |
+
<h3 className={styles.ratingWarningTitle}>File Converted</h3>
|
| 220 |
+
<p className={styles.ratingWarningText}>
|
| 221 |
+
Your file <strong>{preprocessingInfo.original_filename}</strong> has been converted from
|
| 222 |
+
<strong> {preprocessingInfo.original_mime_type}</strong> to
|
| 223 |
+
<strong> {preprocessingInfo.processed_mime_type}</strong> for optimal processing.
|
| 224 |
+
<br /><br />
|
| 225 |
+
This conversion ensures your file is in the best format for our AI models to analyze.
|
| 226 |
+
</p>
|
| 227 |
+
<div className={styles.ratingWarningButtons}>
|
| 228 |
+
<Button
|
| 229 |
+
name="close-preprocessing"
|
| 230 |
+
variant="secondary"
|
| 231 |
+
onClick={onClose}
|
| 232 |
+
>
|
| 233 |
+
Got it
|
| 234 |
+
</Button>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
interface PreprocessingModalProps {
|
| 243 |
+
isOpen: boolean;
|
| 244 |
+
preprocessingFile: File | null;
|
| 245 |
+
isPreprocessing: boolean;
|
| 246 |
+
preprocessingProgress: string;
|
| 247 |
+
onConfirm: () => void;
|
| 248 |
+
onCancel: () => void;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
export function PreprocessingModal({
|
| 252 |
+
isOpen,
|
| 253 |
+
preprocessingFile,
|
| 254 |
+
isPreprocessing,
|
| 255 |
+
preprocessingProgress,
|
| 256 |
+
onConfirm,
|
| 257 |
+
onCancel
|
| 258 |
+
}: PreprocessingModalProps) {
|
| 259 |
+
if (!isOpen) return null;
|
| 260 |
+
|
| 261 |
+
return (
|
| 262 |
+
<div className={styles.fullSizeModalOverlay} onClick={isPreprocessing ? undefined : onCancel}>
|
| 263 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 264 |
+
<div className={styles.ratingWarningContent}>
|
| 265 |
+
<h3 className={styles.ratingWarningTitle}>File Conversion Required</h3>
|
| 266 |
+
<p className={styles.ratingWarningText}>
|
| 267 |
+
The file you selected will be converted to PNG format.
|
| 268 |
+
This ensures optimal compatibility and processing by our AI models.
|
| 269 |
+
</p>
|
| 270 |
+
{!isPreprocessing && (
|
| 271 |
+
<div className={styles.ratingWarningButtons}>
|
| 272 |
+
<Button
|
| 273 |
+
name="confirm-preprocessing"
|
| 274 |
+
variant="secondary"
|
| 275 |
+
onClick={onConfirm}
|
| 276 |
+
>
|
| 277 |
+
Convert File
|
| 278 |
+
</Button>
|
| 279 |
+
<Button
|
| 280 |
+
name="cancel-preprocessing"
|
| 281 |
+
variant="tertiary"
|
| 282 |
+
onClick={onCancel}
|
| 283 |
+
>
|
| 284 |
+
Cancel
|
| 285 |
+
</Button>
|
| 286 |
+
</div>
|
| 287 |
+
)}
|
| 288 |
+
{isPreprocessing && (
|
| 289 |
+
<div className={styles.preprocessingProgress}>
|
| 290 |
+
<p>{preprocessingProgress}</p>
|
| 291 |
+
<Spinner className="text-ifrcRed" />
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
interface UnsupportedFormatModalProps {
|
| 301 |
+
isOpen: boolean;
|
| 302 |
+
unsupportedFile: File | null;
|
| 303 |
+
onClose: () => void;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
export function UnsupportedFormatModal({ isOpen, unsupportedFile, onClose }: UnsupportedFormatModalProps) {
|
| 307 |
+
if (!isOpen || !unsupportedFile) return null;
|
| 308 |
+
|
| 309 |
+
return (
|
| 310 |
+
<div className={styles.fullSizeModalOverlay} onClick={onClose}>
|
| 311 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 312 |
+
<div className={styles.ratingWarningContent}>
|
| 313 |
+
<h3 className={styles.ratingWarningTitle}>Unsupported File Format</h3>
|
| 314 |
+
<p className={styles.ratingWarningText}>
|
| 315 |
+
The file <strong>{unsupportedFile.name}</strong> is not supported for upload.
|
| 316 |
+
<br /><br />
|
| 317 |
+
<strong>Supported formats:</strong>
|
| 318 |
+
<br />• Images: JPEG, PNG, TIFF, HEIC, WebP, GIF
|
| 319 |
+
<br />• Documents: PDF (will be converted to image)
|
| 320 |
+
<br /><br />
|
| 321 |
+
<strong>Recommendation:</strong> Convert your file to JPEG or PNG format for best compatibility.
|
| 322 |
+
</p>
|
| 323 |
+
<div className={styles.ratingWarningButtons}>
|
| 324 |
+
<Button
|
| 325 |
+
name="close-unsupported"
|
| 326 |
+
variant="secondary"
|
| 327 |
+
onClick={onClose}
|
| 328 |
+
>
|
| 329 |
+
Got it
|
| 330 |
+
</Button>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
interface FileSizeWarningModalProps {
|
| 339 |
+
isOpen: boolean;
|
| 340 |
+
oversizedFile: File | null;
|
| 341 |
+
onClose: () => void;
|
| 342 |
+
onCancel: () => void;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
export function FileSizeWarningModal({ isOpen, oversizedFile, onClose, onCancel }: FileSizeWarningModalProps) {
|
| 346 |
+
if (!isOpen || !oversizedFile) return null;
|
| 347 |
+
|
| 348 |
+
return (
|
| 349 |
+
<div className={styles.lightModalOverlay} onClick={onCancel}>
|
| 350 |
+
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 351 |
+
<div className={styles.ratingWarningContent}>
|
| 352 |
+
<h3 className={styles.ratingWarningTitle}>File Size Warning</h3>
|
| 353 |
+
<p className={styles.ratingWarningText}>
|
| 354 |
+
The file <strong>{oversizedFile.name}</strong> is large ({(oversizedFile.size / (1024 * 1024)).toFixed(1)}MB).
|
| 355 |
+
<br /><br />
|
| 356 |
+
<strong>Warning:</strong> This file size might exceed the limits of the AI models we use.
|
| 357 |
+
<br /><br />
|
| 358 |
+
You can still proceed, but consider using a smaller file if you encounter issues.
|
| 359 |
+
</p>
|
| 360 |
+
<div className={styles.ratingWarningButtons}>
|
| 361 |
+
<Button
|
| 362 |
+
name="continue-size-warning"
|
| 363 |
+
variant="secondary"
|
| 364 |
+
onClick={onClose}
|
| 365 |
+
>
|
| 366 |
+
Continue
|
| 367 |
+
</Button>
|
| 368 |
+
<Button
|
| 369 |
+
name="cancel-size-warning"
|
| 370 |
+
variant="tertiary"
|
| 371 |
+
onClick={onCancel}
|
| 372 |
+
>
|
| 373 |
+
Cancel
|
| 374 |
+
</Button>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
);
|
| 380 |
+
}
|
frontend/src/components/upload/RatingSection.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Button, Container } from '@ifrc-go/ui';
|
| 2 |
+
import styles from '../../pages/UploadPage/UploadPage.module.css';
|
| 3 |
+
|
| 4 |
+
interface RatingSectionProps {
|
| 5 |
+
isPerformanceConfirmed: boolean;
|
| 6 |
+
scores: {
|
| 7 |
+
accuracy: number;
|
| 8 |
+
context: number;
|
| 9 |
+
usability: number;
|
| 10 |
+
};
|
| 11 |
+
onScoreChange: (key: 'accuracy' | 'context' | 'usability', value: number) => void;
|
| 12 |
+
onConfirmRatings: () => void;
|
| 13 |
+
onEditRatings: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function RatingSection({
|
| 17 |
+
isPerformanceConfirmed,
|
| 18 |
+
scores,
|
| 19 |
+
onScoreChange,
|
| 20 |
+
onConfirmRatings,
|
| 21 |
+
onEditRatings,
|
| 22 |
+
}: RatingSectionProps) {
|
| 23 |
+
// Don't render anything if ratings are confirmed
|
| 24 |
+
if (isPerformanceConfirmed) {
|
| 25 |
+
return null;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<Container
|
| 30 |
+
heading="AI Performance Rating"
|
| 31 |
+
headingLevel={3}
|
| 32 |
+
withHeaderBorder
|
| 33 |
+
withInternalPadding
|
| 34 |
+
>
|
| 35 |
+
<div className={styles.ratingContent}>
|
| 36 |
+
<p className={styles.ratingDescription}>How well did the AI perform on the task?</p>
|
| 37 |
+
{(['accuracy', 'context', 'usability'] as const).map((k) => (
|
| 38 |
+
<div key={k} className={styles.ratingSlider}>
|
| 39 |
+
<label className={styles.ratingLabel}>{k}</label>
|
| 40 |
+
<input
|
| 41 |
+
type="range"
|
| 42 |
+
min={0}
|
| 43 |
+
max={100}
|
| 44 |
+
value={scores[k]}
|
| 45 |
+
onChange={(e) => onScoreChange(k, Number(e.target.value))}
|
| 46 |
+
className={styles.ratingInput}
|
| 47 |
+
/>
|
| 48 |
+
<span className={styles.ratingValue}>{scores[k]}</span>
|
| 49 |
+
</div>
|
| 50 |
+
))}
|
| 51 |
+
<div className={styles.confirmButtonContainer}>
|
| 52 |
+
<Button
|
| 53 |
+
name="confirm-ratings"
|
| 54 |
+
variant="secondary"
|
| 55 |
+
onClick={onConfirmRatings}
|
| 56 |
+
>
|
| 57 |
+
Confirm Ratings
|
| 58 |
+
</Button>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</Container>
|
| 62 |
+
);
|
| 63 |
+
}
|
frontend/src/components/upload/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { default as FileUploadSection } from './FileUploadSection';
|
| 2 |
+
export { default as ImagePreviewSection } from './ImagePreviewSection';
|
| 3 |
+
export { default as MetadataFormSection } from './MetadataFormSection';
|
| 4 |
+
export { default as RatingSection } from './RatingSection';
|
| 5 |
+
export { default as GeneratedTextSection } from './GeneratedTextSection';
|
| 6 |
+
export {
|
| 7 |
+
FullSizeImageModal,
|
| 8 |
+
RatingWarningModal,
|
| 9 |
+
DeleteConfirmModal,
|
| 10 |
+
NavigationConfirmModal,
|
| 11 |
+
FallbackNotificationModal,
|
| 12 |
+
PreprocessingNotificationModal,
|
| 13 |
+
PreprocessingModal,
|
| 14 |
+
UnsupportedFormatModal,
|
| 15 |
+
FileSizeWarningModal,
|
| 16 |
+
} from './ModalComponents';
|
| 17 |
+
|
| 18 |
+
|
frontend/src/contexts/FilterContext.tsx
CHANGED
|
@@ -9,6 +9,7 @@ interface FilterContextType {
|
|
| 9 |
regionFilter: string;
|
| 10 |
countryFilter: string;
|
| 11 |
imageTypeFilter: string;
|
|
|
|
| 12 |
showReferenceExamples: boolean;
|
| 13 |
|
| 14 |
// Setter functions
|
|
@@ -18,6 +19,7 @@ interface FilterContextType {
|
|
| 18 |
setRegionFilter: (value: string) => void;
|
| 19 |
setCountryFilter: (value: string) => void;
|
| 20 |
setImageTypeFilter: (value: string) => void;
|
|
|
|
| 21 |
setShowReferenceExamples: (value: boolean) => void;
|
| 22 |
|
| 23 |
// Utility function to clear all filters
|
|
@@ -37,6 +39,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ children }) => {
|
|
| 37 |
const [regionFilter, setRegionFilter] = useState('');
|
| 38 |
const [countryFilter, setCountryFilter] = useState('');
|
| 39 |
const [imageTypeFilter, setImageTypeFilter] = useState('');
|
|
|
|
| 40 |
const [showReferenceExamples, setShowReferenceExamples] = useState(false);
|
| 41 |
|
| 42 |
const clearAllFilters = () => {
|
|
@@ -46,6 +49,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ children }) => {
|
|
| 46 |
setRegionFilter('');
|
| 47 |
setCountryFilter('');
|
| 48 |
setImageTypeFilter('');
|
|
|
|
| 49 |
setShowReferenceExamples(false);
|
| 50 |
};
|
| 51 |
|
|
@@ -56,6 +60,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ children }) => {
|
|
| 56 |
regionFilter,
|
| 57 |
countryFilter,
|
| 58 |
imageTypeFilter,
|
|
|
|
| 59 |
showReferenceExamples,
|
| 60 |
setSearch,
|
| 61 |
setSrcFilter,
|
|
@@ -63,6 +68,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ children }) => {
|
|
| 63 |
setRegionFilter,
|
| 64 |
setCountryFilter,
|
| 65 |
setImageTypeFilter,
|
|
|
|
| 66 |
setShowReferenceExamples,
|
| 67 |
clearAllFilters,
|
| 68 |
};
|
|
|
|
| 9 |
regionFilter: string;
|
| 10 |
countryFilter: string;
|
| 11 |
imageTypeFilter: string;
|
| 12 |
+
uploadTypeFilter: string;
|
| 13 |
showReferenceExamples: boolean;
|
| 14 |
|
| 15 |
// Setter functions
|
|
|
|
| 19 |
setRegionFilter: (value: string) => void;
|
| 20 |
setCountryFilter: (value: string) => void;
|
| 21 |
setImageTypeFilter: (value: string) => void;
|
| 22 |
+
setUploadTypeFilter: (value: string) => void;
|
| 23 |
setShowReferenceExamples: (value: boolean) => void;
|
| 24 |
|
| 25 |
// Utility function to clear all filters
|
|
|
|
| 39 |
const [regionFilter, setRegionFilter] = useState('');
|
| 40 |
const [countryFilter, setCountryFilter] = useState('');
|
| 41 |
const [imageTypeFilter, setImageTypeFilter] = useState('');
|
| 42 |
+
const [uploadTypeFilter, setUploadTypeFilter] = useState('');
|
| 43 |
const [showReferenceExamples, setShowReferenceExamples] = useState(false);
|
| 44 |
|
| 45 |
const clearAllFilters = () => {
|
|
|
|
| 49 |
setRegionFilter('');
|
| 50 |
setCountryFilter('');
|
| 51 |
setImageTypeFilter('');
|
| 52 |
+
setUploadTypeFilter('');
|
| 53 |
setShowReferenceExamples(false);
|
| 54 |
};
|
| 55 |
|
|
|
|
| 60 |
regionFilter,
|
| 61 |
countryFilter,
|
| 62 |
imageTypeFilter,
|
| 63 |
+
uploadTypeFilter,
|
| 64 |
showReferenceExamples,
|
| 65 |
setSearch,
|
| 66 |
setSrcFilter,
|
|
|
|
| 68 |
setRegionFilter,
|
| 69 |
setCountryFilter,
|
| 70 |
setImageTypeFilter,
|
| 71 |
+
setUploadTypeFilter,
|
| 72 |
setShowReferenceExamples,
|
| 73 |
clearAllFilters,
|
| 74 |
};
|
frontend/src/pages/ExplorePage/ExplorePage.tsx
CHANGED
|
@@ -30,6 +30,9 @@ interface ImageWithCaptionOut {
|
|
| 30 |
epsg: string;
|
| 31 |
image_type: string;
|
| 32 |
countries: {c_code: string, label: string, r_code: string}[];
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export default function ExplorePage() {
|
|
@@ -46,6 +49,7 @@ export default function ExplorePage() {
|
|
| 46 |
regionFilter,
|
| 47 |
countryFilter,
|
| 48 |
imageTypeFilter,
|
|
|
|
| 49 |
showReferenceExamples,
|
| 50 |
setShowReferenceExamples
|
| 51 |
} = useFilterContext();
|
|
@@ -73,14 +77,20 @@ export default function ExplorePage() {
|
|
| 73 |
|
| 74 |
const fetchCaptions = () => {
|
| 75 |
setIsLoadingContent(true);
|
| 76 |
-
fetch('/api/
|
| 77 |
.then(r => {
|
| 78 |
if (!r.ok) {
|
| 79 |
-
console.error('ExplorePage:
|
| 80 |
-
// Fallback to
|
| 81 |
-
return fetch('/api/
|
| 82 |
if (!r2.ok) {
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
return r2.json();
|
| 86 |
});
|
|
@@ -195,18 +205,24 @@ export default function ExplorePage() {
|
|
| 195 |
c.source?.toLowerCase().includes(search.toLowerCase()) ||
|
| 196 |
c.event_type?.toLowerCase().includes(search.toLowerCase());
|
| 197 |
|
| 198 |
-
|
| 199 |
-
const
|
|
|
|
|
|
|
|
|
|
| 200 |
const matchesRegion = !regionFilter ||
|
| 201 |
c.countries.some(country => country.r_code === regionFilter);
|
| 202 |
const matchesCountry = !countryFilter ||
|
| 203 |
c.countries.some(country => country.c_code === countryFilter);
|
| 204 |
const matchesImageType = !imageTypeFilter || c.image_type === imageTypeFilter;
|
|
|
|
|
|
|
|
|
|
| 205 |
const matchesReferenceExamples = !showReferenceExamples || c.starred === true;
|
| 206 |
|
| 207 |
-
return matchesSearch &&
|
| 208 |
});
|
| 209 |
-
}, [captions, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples]);
|
| 210 |
|
| 211 |
const exportDataset = async (images: ImageWithCaptionOut[], mode: 'standard' | 'fine-tuning' = 'fine-tuning') => {
|
| 212 |
if (images.length === 0) {
|
|
@@ -230,116 +246,120 @@ export default function ExplorePage() {
|
|
| 230 |
const crisisImagesFolder = crisisFolder?.folder('images');
|
| 231 |
|
| 232 |
if (crisisImagesFolder) {
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
| 234 |
try {
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
const blob = await response.blob();
|
| 239 |
-
|
| 240 |
-
|
| 241 |
|
| 242 |
crisisImagesFolder.file(fileName, blob);
|
| 243 |
-
|
| 244 |
} catch (error) {
|
| 245 |
-
|
| 246 |
-
|
| 247 |
}
|
| 248 |
});
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
|
|
|
|
| 253 |
if (mode === 'fine-tuning') {
|
| 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 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
image_id: result.image.image_id,
|
| 307 |
-
title: result.image.title,
|
| 308 |
-
source: result.image.source,
|
| 309 |
-
event_type: result.image.event_type,
|
| 310 |
-
image_type: result.image.image_type,
|
| 311 |
-
countries: result.image.countries,
|
| 312 |
-
starred: result.image.starred
|
| 313 |
-
}
|
| 314 |
-
})));
|
| 315 |
-
});
|
| 316 |
-
|
| 317 |
-
// Add JSONL files to crisis folder
|
| 318 |
-
if (crisisFolder) {
|
| 319 |
-
crisisFolder.file('train.jsonl', JSON.stringify(crisisTrainData, null, 2));
|
| 320 |
-
crisisFolder.file('test.jsonl', JSON.stringify(crisisTestData, null, 2));
|
| 321 |
-
crisisFolder.file('val.jsonl', JSON.stringify(crisisValData, null, 2));
|
| 322 |
}
|
| 323 |
} else {
|
| 324 |
-
|
|
|
|
| 325 |
const jsonData = {
|
| 326 |
-
|
| 327 |
-
|
| 328 |
metadata: {
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
| 336 |
}
|
| 337 |
};
|
| 338 |
|
| 339 |
if (crisisFolder) {
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
}
|
| 342 |
-
})
|
|
|
|
|
|
|
| 343 |
}
|
| 344 |
}
|
| 345 |
}
|
|
@@ -350,115 +370,120 @@ export default function ExplorePage() {
|
|
| 350 |
const droneImagesFolder = droneFolder?.folder('images');
|
| 351 |
|
| 352 |
if (droneImagesFolder) {
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
| 354 |
try {
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
const blob = await response.blob();
|
| 359 |
-
|
| 360 |
-
|
| 361 |
|
| 362 |
droneImagesFolder.file(fileName, blob);
|
| 363 |
-
|
| 364 |
} catch (error) {
|
| 365 |
-
|
| 366 |
-
|
| 367 |
}
|
| 368 |
});
|
| 369 |
|
| 370 |
-
|
| 371 |
-
|
| 372 |
|
|
|
|
| 373 |
if (mode === 'fine-tuning') {
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
image_id: result.image.image_id,
|
| 427 |
-
title: result.image.title,
|
| 428 |
-
source: result.image.source,
|
| 429 |
-
event_type: result.image.event_type,
|
| 430 |
-
image_type: result.image.image_type,
|
| 431 |
-
countries: result.image.countries,
|
| 432 |
-
starred: result.image.starred
|
| 433 |
-
}
|
| 434 |
-
})));
|
| 435 |
-
});
|
| 436 |
-
|
| 437 |
-
if (droneFolder) {
|
| 438 |
-
droneFolder.file('train.jsonl', JSON.stringify(droneTrainData, null, 2));
|
| 439 |
-
droneFolder.file('test.jsonl', JSON.stringify(droneTestData, null, 2));
|
| 440 |
-
droneFolder.file('val.jsonl', JSON.stringify(droneValData, null, 2));
|
| 441 |
}
|
| 442 |
} else {
|
| 443 |
-
|
|
|
|
| 444 |
const jsonData = {
|
| 445 |
-
|
| 446 |
-
|
| 447 |
metadata: {
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
| 455 |
}
|
| 456 |
};
|
| 457 |
|
| 458 |
if (droneFolder) {
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
}
|
| 461 |
-
})
|
|
|
|
|
|
|
| 462 |
}
|
| 463 |
}
|
| 464 |
}
|
|
@@ -678,15 +703,31 @@ export default function ExplorePage() {
|
|
| 678 |
<div className={styles.metadataTags}>
|
| 679 |
{c.image_type !== 'drone_image' && (
|
| 680 |
<span className={styles.metadataTagSource}>
|
| 681 |
-
{
|
|
|
|
|
|
|
|
|
|
| 682 |
</span>
|
| 683 |
)}
|
| 684 |
<span className={styles.metadataTagType}>
|
| 685 |
-
{
|
|
|
|
|
|
|
|
|
|
| 686 |
</span>
|
| 687 |
<span className={styles.metadataTag}>
|
| 688 |
{imageTypes.find(it => it.image_type === c.image_type)?.label || c.image_type}
|
| 689 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
{c.countries && c.countries.length > 0 && (
|
| 691 |
<>
|
| 692 |
<span className={styles.metadataTag}>
|
|
@@ -787,7 +828,7 @@ export default function ExplorePage() {
|
|
| 787 |
}}
|
| 788 |
filteredCount={filtered.length}
|
| 789 |
totalCount={captions.length}
|
| 790 |
-
hasFilters={!!(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || showReferenceExamples)}
|
| 791 |
crisisMapsCount={filtered.filter(img => img.image_type === 'crisis_map').length}
|
| 792 |
droneImagesCount={filtered.filter(img => img.image_type === 'drone_image').length}
|
| 793 |
isLoading={isExporting}
|
|
|
|
| 30 |
epsg: string;
|
| 31 |
image_type: string;
|
| 32 |
countries: {c_code: string, label: string, r_code: string}[];
|
| 33 |
+
// Multi-upload fields
|
| 34 |
+
all_image_ids?: string[];
|
| 35 |
+
image_count?: number;
|
| 36 |
}
|
| 37 |
|
| 38 |
export default function ExplorePage() {
|
|
|
|
| 49 |
regionFilter,
|
| 50 |
countryFilter,
|
| 51 |
imageTypeFilter,
|
| 52 |
+
uploadTypeFilter,
|
| 53 |
showReferenceExamples,
|
| 54 |
setShowReferenceExamples
|
| 55 |
} = useFilterContext();
|
|
|
|
| 77 |
|
| 78 |
const fetchCaptions = () => {
|
| 79 |
setIsLoadingContent(true);
|
| 80 |
+
fetch('/api/images/grouped')
|
| 81 |
.then(r => {
|
| 82 |
if (!r.ok) {
|
| 83 |
+
console.error('ExplorePage: Grouped endpoint failed, trying legacy endpoint');
|
| 84 |
+
// Fallback to legacy endpoint for backward compatibility
|
| 85 |
+
return fetch('/api/captions/legacy').then(r2 => {
|
| 86 |
if (!r2.ok) {
|
| 87 |
+
console.error('ExplorePage: Legacy endpoint failed, trying regular images endpoint');
|
| 88 |
+
return fetch('/api/images').then(r3 => {
|
| 89 |
+
if (!r3.ok) {
|
| 90 |
+
throw new Error(`HTTP ${r3.status}: ${r3.statusText}`);
|
| 91 |
+
}
|
| 92 |
+
return r3.json();
|
| 93 |
+
});
|
| 94 |
}
|
| 95 |
return r2.json();
|
| 96 |
});
|
|
|
|
| 205 |
c.source?.toLowerCase().includes(search.toLowerCase()) ||
|
| 206 |
c.event_type?.toLowerCase().includes(search.toLowerCase());
|
| 207 |
|
| 208 |
+
// Handle combined metadata from multi-upload items
|
| 209 |
+
const sourceMatches = !srcFilter ||
|
| 210 |
+
(c.source && c.source.split(', ').some(s => s.trim() === srcFilter));
|
| 211 |
+
const categoryMatches = !catFilter ||
|
| 212 |
+
(c.event_type && c.event_type.split(', ').some(e => e.trim() === catFilter));
|
| 213 |
const matchesRegion = !regionFilter ||
|
| 214 |
c.countries.some(country => country.r_code === regionFilter);
|
| 215 |
const matchesCountry = !countryFilter ||
|
| 216 |
c.countries.some(country => country.c_code === countryFilter);
|
| 217 |
const matchesImageType = !imageTypeFilter || c.image_type === imageTypeFilter;
|
| 218 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 219 |
+
(uploadTypeFilter === 'single' && (!c.image_count || c.image_count <= 1)) ||
|
| 220 |
+
(uploadTypeFilter === 'multiple' && c.image_count && c.image_count > 1);
|
| 221 |
const matchesReferenceExamples = !showReferenceExamples || c.starred === true;
|
| 222 |
|
| 223 |
+
return matchesSearch && sourceMatches && categoryMatches && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
| 224 |
});
|
| 225 |
+
}, [captions, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples]);
|
| 226 |
|
| 227 |
const exportDataset = async (images: ImageWithCaptionOut[], mode: 'standard' | 'fine-tuning' = 'fine-tuning') => {
|
| 228 |
if (images.length === 0) {
|
|
|
|
| 246 |
const crisisImagesFolder = crisisFolder?.folder('images');
|
| 247 |
|
| 248 |
if (crisisImagesFolder) {
|
| 249 |
+
// Process each caption (which may contain multiple images)
|
| 250 |
+
let jsonIndex = 1;
|
| 251 |
+
|
| 252 |
+
for (const caption of crisisMaps) {
|
| 253 |
try {
|
| 254 |
+
// Get all image IDs for this caption
|
| 255 |
+
const imageIds = caption.image_count && caption.image_count > 1
|
| 256 |
+
? caption.all_image_ids || [caption.image_id]
|
| 257 |
+
: [caption.image_id];
|
| 258 |
+
|
| 259 |
+
// Fetch all images for this caption
|
| 260 |
+
const imagePromises = imageIds.map(async (imageId, imgIndex) => {
|
| 261 |
+
try {
|
| 262 |
+
const response = await fetch(`/api/images/${imageId}/file`);
|
| 263 |
+
if (!response.ok) throw new Error(`Failed to fetch image ${imageId}`);
|
| 264 |
|
| 265 |
const blob = await response.blob();
|
| 266 |
+
const fileExtension = caption.file_key.split('.').pop() || 'jpg';
|
| 267 |
+
const fileName = `${String(jsonIndex).padStart(4, '0')}_${String(imgIndex + 1).padStart(2, '0')}.${fileExtension}`;
|
| 268 |
|
| 269 |
crisisImagesFolder.file(fileName, blob);
|
| 270 |
+
return { success: true, fileName, imageId };
|
| 271 |
} catch (error) {
|
| 272 |
+
console.error(`Failed to process image ${imageId}:`, error);
|
| 273 |
+
return { success: false, fileName: '', imageId };
|
| 274 |
}
|
| 275 |
});
|
| 276 |
|
| 277 |
+
const imageResults = await Promise.all(imagePromises);
|
| 278 |
+
const successfulImages = imageResults.filter(result => result.success);
|
| 279 |
|
| 280 |
+
if (successfulImages.length > 0) {
|
| 281 |
if (mode === 'fine-tuning') {
|
| 282 |
+
// For fine-tuning, create one entry per caption with all images
|
| 283 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 284 |
+
|
| 285 |
+
const random = Math.random();
|
| 286 |
+
const entry = {
|
| 287 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 288 |
+
caption: caption.edited || caption.generated || '',
|
| 289 |
+
metadata: {
|
| 290 |
+
image_id: imageIds,
|
| 291 |
+
title: caption.title,
|
| 292 |
+
source: caption.source,
|
| 293 |
+
event_type: caption.event_type,
|
| 294 |
+
image_type: caption.image_type,
|
| 295 |
+
countries: caption.countries,
|
| 296 |
+
starred: caption.starred,
|
| 297 |
+
image_count: caption.image_count || 1
|
| 298 |
+
}
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
// Store the entry for later processing
|
| 302 |
+
if (!crisisFolder) continue;
|
| 303 |
+
|
| 304 |
+
if (random < 0.8) {
|
| 305 |
+
// Add to train data
|
| 306 |
+
const trainFile = crisisFolder.file('train.jsonl');
|
| 307 |
+
if (trainFile) {
|
| 308 |
+
const existingData = await trainFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 309 |
+
existingData.push(entry);
|
| 310 |
+
crisisFolder.file('train.jsonl', JSON.stringify(existingData, null, 2));
|
| 311 |
+
} else {
|
| 312 |
+
crisisFolder.file('train.jsonl', JSON.stringify([entry], null, 2));
|
| 313 |
+
}
|
| 314 |
+
} else if (random < 0.9) {
|
| 315 |
+
// Add to test data
|
| 316 |
+
const testFile = crisisFolder.file('test.jsonl');
|
| 317 |
+
if (testFile) {
|
| 318 |
+
const existingData = await testFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 319 |
+
existingData.push(entry);
|
| 320 |
+
crisisFolder.file('test.jsonl', JSON.stringify(existingData, null, 2));
|
| 321 |
+
} else {
|
| 322 |
+
crisisFolder.file('test.jsonl', JSON.stringify([entry], null, 2));
|
| 323 |
+
}
|
| 324 |
+
} else {
|
| 325 |
+
// Add to validation data
|
| 326 |
+
const valFile = crisisFolder.file('val.jsonl');
|
| 327 |
+
if (valFile) {
|
| 328 |
+
const existingData = await valFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 329 |
+
existingData.push(entry);
|
| 330 |
+
crisisFolder.file('val.jsonl', JSON.stringify(existingData, null, 2));
|
| 331 |
+
} else {
|
| 332 |
+
crisisFolder.file('val.jsonl', JSON.stringify([entry], null, 2));
|
| 333 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
}
|
| 335 |
} else {
|
| 336 |
+
// For standard mode, create one JSON file per caption
|
| 337 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 338 |
const jsonData = {
|
| 339 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 340 |
+
caption: caption.edited || caption.generated || '',
|
| 341 |
metadata: {
|
| 342 |
+
image_id: imageIds,
|
| 343 |
+
title: caption.title,
|
| 344 |
+
source: caption.source,
|
| 345 |
+
event_type: caption.event_type,
|
| 346 |
+
image_type: caption.image_type,
|
| 347 |
+
countries: caption.countries,
|
| 348 |
+
starred: caption.starred,
|
| 349 |
+
image_count: caption.image_count || 1
|
| 350 |
}
|
| 351 |
};
|
| 352 |
|
| 353 |
if (crisisFolder) {
|
| 354 |
+
crisisFolder.file(`${String(jsonIndex).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
jsonIndex++;
|
| 359 |
}
|
| 360 |
+
} catch (error) {
|
| 361 |
+
console.error(`Failed to process caption ${caption.image_id}:`, error);
|
| 362 |
+
}
|
| 363 |
}
|
| 364 |
}
|
| 365 |
}
|
|
|
|
| 370 |
const droneImagesFolder = droneFolder?.folder('images');
|
| 371 |
|
| 372 |
if (droneImagesFolder) {
|
| 373 |
+
// Process each caption (which may contain multiple images)
|
| 374 |
+
let jsonIndex = 1;
|
| 375 |
+
|
| 376 |
+
for (const caption of droneImages) {
|
| 377 |
try {
|
| 378 |
+
// Get all image IDs for this caption
|
| 379 |
+
const imageIds = caption.image_count && caption.image_count > 1
|
| 380 |
+
? caption.all_image_ids || [caption.image_id]
|
| 381 |
+
: [caption.image_id];
|
| 382 |
+
|
| 383 |
+
// Fetch all images for this caption
|
| 384 |
+
const imagePromises = imageIds.map(async (imageId, imgIndex) => {
|
| 385 |
+
try {
|
| 386 |
+
const response = await fetch(`/api/images/${imageId}/file`);
|
| 387 |
+
if (!response.ok) throw new Error(`Failed to fetch image ${imageId}`);
|
| 388 |
|
| 389 |
const blob = await response.blob();
|
| 390 |
+
const fileExtension = caption.file_key.split('.').pop() || 'jpg';
|
| 391 |
+
const fileName = `${String(jsonIndex).padStart(4, '0')}_${String(imgIndex + 1).padStart(2, '0')}.${fileExtension}`;
|
| 392 |
|
| 393 |
droneImagesFolder.file(fileName, blob);
|
| 394 |
+
return { success: true, fileName, imageId };
|
| 395 |
} catch (error) {
|
| 396 |
+
console.error(`Failed to process image ${imageId}:`, error);
|
| 397 |
+
return { success: false, fileName: '', imageId };
|
| 398 |
}
|
| 399 |
});
|
| 400 |
|
| 401 |
+
const imageResults = await Promise.all(imagePromises);
|
| 402 |
+
const successfulImages = imageResults.filter(result => result.success);
|
| 403 |
|
| 404 |
+
if (successfulImages.length > 0) {
|
| 405 |
if (mode === 'fine-tuning') {
|
| 406 |
+
// For fine-tuning, create one entry per caption with all images
|
| 407 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 408 |
+
|
| 409 |
+
const random = Math.random();
|
| 410 |
+
const entry = {
|
| 411 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 412 |
+
caption: caption.edited || caption.generated || '',
|
| 413 |
+
metadata: {
|
| 414 |
+
image_id: imageIds,
|
| 415 |
+
title: caption.title,
|
| 416 |
+
source: caption.source,
|
| 417 |
+
event_type: caption.event_type,
|
| 418 |
+
image_type: caption.image_type,
|
| 419 |
+
countries: caption.countries,
|
| 420 |
+
starred: caption.starred,
|
| 421 |
+
image_count: caption.image_count || 1
|
| 422 |
+
}
|
| 423 |
+
};
|
| 424 |
+
|
| 425 |
+
// Store the entry for later processing
|
| 426 |
+
if (!droneFolder) continue;
|
| 427 |
+
|
| 428 |
+
if (random < 0.8) {
|
| 429 |
+
// Add to train data
|
| 430 |
+
const trainFile = droneFolder.file('train.jsonl');
|
| 431 |
+
if (trainFile) {
|
| 432 |
+
const existingData = await trainFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 433 |
+
existingData.push(entry);
|
| 434 |
+
droneFolder.file('train.jsonl', JSON.stringify(existingData, null, 2));
|
| 435 |
+
} else {
|
| 436 |
+
droneFolder.file('train.jsonl', JSON.stringify([entry], null, 2));
|
| 437 |
+
}
|
| 438 |
+
} else if (random < 0.9) {
|
| 439 |
+
// Add to test data
|
| 440 |
+
const testFile = droneFolder.file('test.jsonl');
|
| 441 |
+
if (testFile) {
|
| 442 |
+
const existingData = await testFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 443 |
+
existingData.push(entry);
|
| 444 |
+
droneFolder.file('test.jsonl', JSON.stringify(existingData, null, 2));
|
| 445 |
+
} else {
|
| 446 |
+
droneFolder.file('test.jsonl', JSON.stringify([entry], null, 2));
|
| 447 |
+
}
|
| 448 |
+
} else {
|
| 449 |
+
// Add to validation data
|
| 450 |
+
const valFile = droneFolder.file('val.jsonl');
|
| 451 |
+
if (valFile) {
|
| 452 |
+
const existingData = await valFile.async('string').then(data => JSON.parse(data || '[]')).catch(() => []);
|
| 453 |
+
existingData.push(entry);
|
| 454 |
+
droneFolder.file('val.jsonl', JSON.stringify(existingData, null, 2));
|
| 455 |
+
} else {
|
| 456 |
+
droneFolder.file('val.jsonl', JSON.stringify([entry], null, 2));
|
| 457 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
}
|
| 459 |
} else {
|
| 460 |
+
// For standard mode, create one JSON file per caption
|
| 461 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 462 |
const jsonData = {
|
| 463 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 464 |
+
caption: caption.edited || caption.generated || '',
|
| 465 |
metadata: {
|
| 466 |
+
image_id: imageIds,
|
| 467 |
+
title: caption.title,
|
| 468 |
+
source: caption.source,
|
| 469 |
+
event_type: caption.event_type,
|
| 470 |
+
image_type: caption.image_type,
|
| 471 |
+
countries: caption.countries,
|
| 472 |
+
starred: caption.starred,
|
| 473 |
+
image_count: caption.image_count || 1
|
| 474 |
}
|
| 475 |
};
|
| 476 |
|
| 477 |
if (droneFolder) {
|
| 478 |
+
droneFolder.file(`${String(jsonIndex).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
jsonIndex++;
|
| 483 |
}
|
| 484 |
+
} catch (error) {
|
| 485 |
+
console.error(`Failed to process caption ${caption.image_id}:`, error);
|
| 486 |
+
}
|
| 487 |
}
|
| 488 |
}
|
| 489 |
}
|
|
|
|
| 703 |
<div className={styles.metadataTags}>
|
| 704 |
{c.image_type !== 'drone_image' && (
|
| 705 |
<span className={styles.metadataTagSource}>
|
| 706 |
+
{c.source && c.source.includes(', ')
|
| 707 |
+
? c.source.split(', ').map(s => sources.find(src => src.s_code === s.trim())?.label || s.trim()).join(', ')
|
| 708 |
+
: sources.find(s => s.s_code === c.source)?.label || c.source
|
| 709 |
+
}
|
| 710 |
</span>
|
| 711 |
)}
|
| 712 |
<span className={styles.metadataTagType}>
|
| 713 |
+
{c.event_type && c.event_type.includes(', ')
|
| 714 |
+
? c.event_type.split(', ').map(e => types.find(t => t.t_code === e.trim())?.label || e.trim()).join(', ')
|
| 715 |
+
: types.find(t => t.t_code === c.event_type)?.label || c.event_type
|
| 716 |
+
}
|
| 717 |
</span>
|
| 718 |
<span className={styles.metadataTag}>
|
| 719 |
{imageTypes.find(it => it.image_type === c.image_type)?.label || c.image_type}
|
| 720 |
</span>
|
| 721 |
+
{c.image_count && c.image_count > 1 && (
|
| 722 |
+
<span className={styles.metadataTag} title={`Multi-upload with ${c.image_count} images`}>
|
| 723 |
+
📷 {c.image_count}
|
| 724 |
+
</span>
|
| 725 |
+
)}
|
| 726 |
+
{(!c.image_count || c.image_count <= 1) && (
|
| 727 |
+
<span className={styles.metadataTag} title="Single Upload">
|
| 728 |
+
Single
|
| 729 |
+
</span>
|
| 730 |
+
)}
|
| 731 |
{c.countries && c.countries.length > 0 && (
|
| 732 |
<>
|
| 733 |
<span className={styles.metadataTag}>
|
|
|
|
| 828 |
}}
|
| 829 |
filteredCount={filtered.length}
|
| 830 |
totalCount={captions.length}
|
| 831 |
+
hasFilters={!!(search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || uploadTypeFilter || showReferenceExamples)}
|
| 832 |
crisisMapsCount={filtered.filter(img => img.image_type === 'crisis_map').length}
|
| 833 |
droneImagesCount={filtered.filter(img => img.image_type === 'drone_image').length}
|
| 834 |
isLoading={isExporting}
|
frontend/src/pages/MapDetailsPage/MapDetailPage.module.css
CHANGED
|
@@ -140,6 +140,18 @@
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
.detailsSection {
|
| 144 |
display: flex;
|
| 145 |
flex-direction: column;
|
|
@@ -348,3 +360,176 @@
|
|
| 348 |
text-align: center;
|
| 349 |
margin-bottom: var(--go-ui-spacing-lg);
|
| 350 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
|
| 143 |
+
/* Left column container for image and tags */
|
| 144 |
+
.leftColumn {
|
| 145 |
+
display: flex;
|
| 146 |
+
flex-direction: column;
|
| 147 |
+
gap: var(--go-ui-spacing-lg);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* Invisible wrapper for tags */
|
| 151 |
+
.tagsWrapper {
|
| 152 |
+
/* No visual styling - just a logical container */
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
.detailsSection {
|
| 156 |
display: flex;
|
| 157 |
flex-direction: column;
|
|
|
|
| 360 |
text-align: center;
|
| 361 |
margin-bottom: var(--go-ui-spacing-lg);
|
| 362 |
}
|
| 363 |
+
|
| 364 |
+
/* Carousel styles for multi-upload */
|
| 365 |
+
.carouselContainer {
|
| 366 |
+
position: relative;
|
| 367 |
+
width: 100%;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.carouselImageWrapper {
|
| 371 |
+
position: relative;
|
| 372 |
+
width: 100%;
|
| 373 |
+
background-color: var(--go-ui-color-gray-20);
|
| 374 |
+
border-radius: var(--go-ui-border-radius-lg);
|
| 375 |
+
overflow: hidden;
|
| 376 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 377 |
+
box-shadow: var(--go-ui-box-shadow-sm);
|
| 378 |
+
transition: box-shadow var(--go-ui-duration-transition-medium) ease;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.carouselImageWrapper:hover {
|
| 382 |
+
box-shadow: var(--go-ui-box-shadow-md);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.carouselImage {
|
| 386 |
+
width: 100%;
|
| 387 |
+
height: auto;
|
| 388 |
+
object-fit: contain;
|
| 389 |
+
image-rendering: pixelated;
|
| 390 |
+
display: block;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.carouselNavigation {
|
| 394 |
+
display: flex;
|
| 395 |
+
align-items: center;
|
| 396 |
+
justify-content: center;
|
| 397 |
+
gap: var(--go-ui-spacing-md);
|
| 398 |
+
margin-top: var(--go-ui-spacing-md);
|
| 399 |
+
padding: var(--go-ui-spacing-sm);
|
| 400 |
+
background-color: var(--go-ui-color-gray-10);
|
| 401 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 402 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.carouselButton {
|
| 406 |
+
background-color: var(--go-ui-color-white);
|
| 407 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 408 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 409 |
+
padding: var(--go-ui-spacing-sm);
|
| 410 |
+
transition: all var(--go-ui-duration-transition-fast) ease;
|
| 411 |
+
min-width: 40px;
|
| 412 |
+
height: 40px;
|
| 413 |
+
display: flex;
|
| 414 |
+
align-items: center;
|
| 415 |
+
justify-content: center;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.carouselButton:hover:not(:disabled) {
|
| 419 |
+
background-color: var(--go-ui-color-gray-20);
|
| 420 |
+
border-color: var(--go-ui-color-gray-40);
|
| 421 |
+
transform: translateY(-1px);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.carouselButton:disabled {
|
| 425 |
+
opacity: 0.5;
|
| 426 |
+
cursor: not-allowed;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.carouselIndicators {
|
| 430 |
+
display: flex;
|
| 431 |
+
gap: var(--go-ui-spacing-xs);
|
| 432 |
+
align-items: center;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.carouselIndicator {
|
| 436 |
+
background-color: var(--go-ui-color-gray-30);
|
| 437 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 438 |
+
border-radius: var(--go-ui-border-radius-sm);
|
| 439 |
+
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 440 |
+
font-size: var(--go-ui-font-size-sm);
|
| 441 |
+
font-weight: var(--go-ui-font-weight-medium);
|
| 442 |
+
color: var(--go-ui-color-gray-70);
|
| 443 |
+
cursor: pointer;
|
| 444 |
+
transition: all var(--go-ui-duration-transition-fast) ease;
|
| 445 |
+
min-width: 32px;
|
| 446 |
+
height: 32px;
|
| 447 |
+
display: flex;
|
| 448 |
+
align-items: center;
|
| 449 |
+
justify-content: center;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.carouselIndicator:hover:not(:disabled) {
|
| 453 |
+
background-color: var(--go-ui-color-gray-40);
|
| 454 |
+
border-color: var(--go-ui-color-gray-50);
|
| 455 |
+
color: var(--go-ui-color-gray-90);
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.carouselIndicatorActive {
|
| 459 |
+
background-color: var(--go-ui-color-red-90);
|
| 460 |
+
border-color: var(--go-ui-color-red-90);
|
| 461 |
+
color: var(--go-ui-color-white);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.carouselIndicatorActive:hover:not(:disabled) {
|
| 465 |
+
background-color: var(--go-ui-color-red-hover);
|
| 466 |
+
border-color: var(--go-ui-color-red-hover);
|
| 467 |
+
color: var(--go-ui-color-white);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.carouselIndicator:disabled {
|
| 471 |
+
opacity: 0.5;
|
| 472 |
+
cursor: not-allowed;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.imageCounter {
|
| 476 |
+
text-align: center;
|
| 477 |
+
margin-top: var(--go-ui-spacing-sm);
|
| 478 |
+
font-size: var(--go-ui-font-size-sm);
|
| 479 |
+
font-weight: var(--go-ui-font-weight-medium);
|
| 480 |
+
color: var(--go-ui-color-gray-70);
|
| 481 |
+
background-color: var(--go-ui-color-gray-10);
|
| 482 |
+
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 483 |
+
border-radius: var(--go-ui-border-radius-sm);
|
| 484 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
/* Single image container */
|
| 488 |
+
.singleImageContainer {
|
| 489 |
+
position: relative;
|
| 490 |
+
width: 100%;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
/* View image button container */
|
| 494 |
+
.viewImageButtonContainer {
|
| 495 |
+
display: flex;
|
| 496 |
+
justify-content: center;
|
| 497 |
+
margin-top: var(--go-ui-spacing-md);
|
| 498 |
+
padding: var(--go-ui-spacing-sm);
|
| 499 |
+
background-color: var(--go-ui-color-gray-10);
|
| 500 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 501 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/* Responsive adjustments for carousel */
|
| 505 |
+
@media (max-width: 768px) {
|
| 506 |
+
.carouselNavigation {
|
| 507 |
+
flex-direction: column;
|
| 508 |
+
gap: var(--go-ui-spacing-sm);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.carouselIndicators {
|
| 512 |
+
order: -1;
|
| 513 |
+
margin-bottom: var(--go-ui-spacing-sm);
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.carouselButton {
|
| 517 |
+
min-width: 36px;
|
| 518 |
+
height: 36px;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.carouselIndicator {
|
| 522 |
+
min-width: 28px;
|
| 523 |
+
height: 28px;
|
| 524 |
+
font-size: var(--go-ui-font-size-xs);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.imageCounter {
|
| 528 |
+
font-size: var(--go-ui-font-size-xs);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.viewImageButtonContainer {
|
| 532 |
+
margin-top: var(--go-ui-spacing-sm);
|
| 533 |
+
padding: var(--go-ui-spacing-xs);
|
| 534 |
+
}
|
| 535 |
+
}
|
frontend/src/pages/MapDetailsPage/MapDetailPage.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import styles from './MapDetailPage.module.css';
|
|
| 6 |
import { useFilterContext } from '../../hooks/useFilterContext';
|
| 7 |
import { useAdmin } from '../../hooks/useAdmin';
|
| 8 |
import ExportModal from '../../components/ExportModal';
|
|
|
|
|
|
|
| 9 |
|
| 10 |
interface MapOut {
|
| 11 |
image_id: string;
|
|
@@ -43,6 +45,9 @@ interface MapOut {
|
|
| 43 |
starred?: boolean;
|
| 44 |
created_at?: string;
|
| 45 |
updated_at?: string;
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
export default function MapDetailPage() {
|
|
@@ -107,7 +112,16 @@ export default function MapDetailPage() {
|
|
| 107 |
const [droneImagesSelected, setDroneImagesSelected] = useState(true);
|
| 108 |
|
| 109 |
const [isDeleting, setIsDeleting] = useState(false);
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
const {
|
| 113 |
search, setSearch,
|
|
@@ -116,6 +130,7 @@ export default function MapDetailPage() {
|
|
| 116 |
regionFilter, setRegionFilter,
|
| 117 |
countryFilter, setCountryFilter,
|
| 118 |
imageTypeFilter, setImageTypeFilter,
|
|
|
|
| 119 |
showReferenceExamples, setShowReferenceExamples,
|
| 120 |
clearAllFilters
|
| 121 |
} = useFilterContext();
|
|
@@ -158,6 +173,39 @@ export default function MapDetailPage() {
|
|
| 158 |
const data = await response.json();
|
| 159 |
setMap(data);
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
await checkNavigationAvailability(id);
|
| 162 |
} catch (err: unknown) {
|
| 163 |
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
|
@@ -167,6 +215,64 @@ export default function MapDetailPage() {
|
|
| 167 |
}
|
| 168 |
}, []);
|
| 169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
useEffect(() => {
|
| 171 |
console.log('MapDetailsPage: mapId from useParams:', mapId);
|
| 172 |
console.log('MapDetailsPage: mapId type:', typeof mapId);
|
|
@@ -313,7 +419,7 @@ export default function MapDetailPage() {
|
|
| 313 |
}
|
| 314 |
|
| 315 |
try {
|
| 316 |
-
const response = await fetch('/api/images');
|
| 317 |
if (response.ok) {
|
| 318 |
const images = await response.json();
|
| 319 |
|
|
@@ -331,9 +437,12 @@ export default function MapDetailPage() {
|
|
| 331 |
const matchesCountry = !countryFilter ||
|
| 332 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 333 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
|
|
|
|
|
|
|
|
|
| 334 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 335 |
|
| 336 |
-
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
|
| 337 |
});
|
| 338 |
|
| 339 |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === currentId);
|
|
@@ -351,7 +460,7 @@ export default function MapDetailPage() {
|
|
| 351 |
|
| 352 |
setIsNavigating(true);
|
| 353 |
try {
|
| 354 |
-
const response = await fetch('/api/images');
|
| 355 |
if (response.ok) {
|
| 356 |
const images = await response.json();
|
| 357 |
|
|
@@ -369,9 +478,12 @@ export default function MapDetailPage() {
|
|
| 369 |
const matchesCountry = !countryFilter ||
|
| 370 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 371 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
|
|
|
|
|
|
|
|
|
| 372 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 373 |
|
| 374 |
-
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
|
| 375 |
});
|
| 376 |
|
| 377 |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === mapId);
|
|
@@ -421,6 +533,13 @@ export default function MapDetailPage() {
|
|
| 421 |
}
|
| 422 |
};
|
| 423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
useEffect(() => {
|
| 425 |
Promise.all([
|
| 426 |
fetch('/api/sources').then(r => r.json()),
|
|
@@ -437,7 +556,7 @@ export default function MapDetailPage() {
|
|
| 437 |
}).catch(console.error);
|
| 438 |
}, []);
|
| 439 |
|
| 440 |
-
|
| 441 |
|
| 442 |
// delete function
|
| 443 |
const handleDelete = async () => {
|
|
@@ -485,7 +604,7 @@ export default function MapDetailPage() {
|
|
| 485 |
setShowDeleteConfirm(false);
|
| 486 |
|
| 487 |
try {
|
| 488 |
-
const response = await fetch('/api/images');
|
| 489 |
if (response.ok) {
|
| 490 |
const images = await response.json();
|
| 491 |
|
|
@@ -503,9 +622,12 @@ export default function MapDetailPage() {
|
|
| 503 |
const matchesCountry = !countryFilter ||
|
| 504 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 505 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
|
|
|
|
|
|
|
|
|
| 506 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 507 |
|
| 508 |
-
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
|
| 509 |
});
|
| 510 |
|
| 511 |
const remainingImages = filteredImages.filter((img: any) => img.image_id !== map.image_id);
|
|
@@ -586,7 +708,7 @@ export default function MapDetailPage() {
|
|
| 586 |
const filteredMap = useMemo(() => {
|
| 587 |
if (!map) return null;
|
| 588 |
|
| 589 |
-
if (!search && !srcFilter && !catFilter && !regionFilter && !countryFilter && !imageTypeFilter && !showReferenceExamples) {
|
| 590 |
return map;
|
| 591 |
}
|
| 592 |
|
|
@@ -603,90 +725,104 @@ export default function MapDetailPage() {
|
|
| 603 |
const matchesCountry = !countryFilter ||
|
| 604 |
map.countries.some(country => country.c_code === countryFilter);
|
| 605 |
const matchesImageType = !imageTypeFilter || map.image_type === imageTypeFilter;
|
|
|
|
|
|
|
|
|
|
| 606 |
const matchesReferenceExamples = !showReferenceExamples || map.starred === true;
|
| 607 |
|
| 608 |
-
|
| 609 |
-
}, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples]);
|
| 610 |
-
|
| 611 |
-
const handleContribute = () => {
|
| 612 |
-
if (!map) return;
|
| 613 |
-
setShowContributeConfirm(true);
|
| 614 |
-
};
|
| 615 |
-
|
| 616 |
-
const handleContributeConfirm = async () => {
|
| 617 |
-
if (!map) return;
|
| 618 |
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
try {
|
| 622 |
-
const
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
}
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
console.log('Caption generation response:', captionData);
|
| 662 |
-
|
| 663 |
-
// Now navigate to the upload page with the processed data
|
| 664 |
-
const url = `/upload?imageUrl=${encodeURIComponent(json.image_url)}&isContribution=true&step=2a&imageId=${newId}&imageType=${map.image_type}`;
|
| 665 |
-
navigate(url);
|
| 666 |
-
|
| 667 |
-
} catch (error: unknown) {
|
| 668 |
-
console.error('Contribution failed:', error);
|
| 669 |
-
alert(`Contribution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
| 670 |
} finally {
|
| 671 |
-
|
| 672 |
}
|
| 673 |
-
};
|
| 674 |
|
| 675 |
-
const
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
};
|
| 678 |
|
| 679 |
const createImageData = (map: any, fileName: string) => ({
|
| 680 |
image: `images/${fileName}`,
|
| 681 |
caption: map.edited || map.generated || '',
|
| 682 |
metadata: {
|
| 683 |
-
image_id: map.
|
|
|
|
|
|
|
| 684 |
title: map.title,
|
| 685 |
source: map.source,
|
| 686 |
event_type: map.event_type,
|
| 687 |
image_type: map.image_type,
|
| 688 |
countries: map.countries,
|
| 689 |
-
starred: map.starred
|
|
|
|
| 690 |
}
|
| 691 |
});
|
| 692 |
|
|
@@ -706,38 +842,65 @@ export default function MapDetailPage() {
|
|
| 706 |
|
| 707 |
if (crisisImagesFolder) {
|
| 708 |
try {
|
| 709 |
-
|
| 710 |
-
|
|
|
|
|
|
|
| 711 |
|
| 712 |
-
|
| 713 |
-
const
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
|
| 716 |
-
|
|
|
|
|
|
|
| 717 |
|
| 718 |
if (mode === 'fine-tuning') {
|
| 719 |
const trainData: any[] = [];
|
| 720 |
const testData: any[] = [];
|
| 721 |
const valData: any[] = [];
|
| 722 |
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
} else {
|
| 739 |
-
valData.push(createImageData(map, '0001'));
|
| 740 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
}
|
| 742 |
|
| 743 |
if (crisisFolder) {
|
|
@@ -746,17 +909,19 @@ export default function MapDetailPage() {
|
|
| 746 |
crisisFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
|
| 747 |
}
|
| 748 |
} else {
|
|
|
|
| 749 |
const jsonData = {
|
| 750 |
-
image:
|
| 751 |
caption: map.edited || map.generated || '',
|
| 752 |
metadata: {
|
| 753 |
-
image_id:
|
| 754 |
title: map.title,
|
| 755 |
source: map.source,
|
| 756 |
event_type: map.event_type,
|
| 757 |
image_type: map.image_type,
|
| 758 |
countries: map.countries,
|
| 759 |
-
starred: map.starred
|
|
|
|
| 760 |
}
|
| 761 |
};
|
| 762 |
|
|
@@ -819,13 +984,16 @@ export default function MapDetailPage() {
|
|
| 819 |
image: `images/${fileName}`,
|
| 820 |
caption: map.edited || map.generated || '',
|
| 821 |
metadata: {
|
| 822 |
-
image_id: map.
|
|
|
|
|
|
|
| 823 |
title: map.title,
|
| 824 |
source: map.source,
|
| 825 |
event_type: map.event_type,
|
| 826 |
image_type: map.image_type,
|
| 827 |
countries: map.countries,
|
| 828 |
-
starred: map.starred
|
|
|
|
| 829 |
}
|
| 830 |
};
|
| 831 |
|
|
@@ -888,13 +1056,16 @@ export default function MapDetailPage() {
|
|
| 888 |
image: `images/${fileName}`,
|
| 889 |
caption: map.edited || map.generated || '',
|
| 890 |
metadata: {
|
| 891 |
-
image_id: map.
|
|
|
|
|
|
|
| 892 |
title: map.title,
|
| 893 |
source: map.source,
|
| 894 |
event_type: map.event_type,
|
| 895 |
image_type: map.image_type,
|
| 896 |
countries: map.countries,
|
| 897 |
-
starred: map.starred
|
|
|
|
| 898 |
}
|
| 899 |
};
|
| 900 |
|
|
@@ -1017,99 +1188,15 @@ export default function MapDetailPage() {
|
|
| 1017 |
</div>
|
| 1018 |
</div>
|
| 1019 |
|
| 1020 |
-
{/*
|
| 1021 |
-
<
|
| 1022 |
-
{
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
onChange={(v) => setSearch(v || '')}
|
| 1030 |
-
/>
|
| 1031 |
-
</Container>
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1036 |
-
<Button
|
| 1037 |
-
name="clear-filters"
|
| 1038 |
-
variant="secondary"
|
| 1039 |
-
onClick={clearAllFilters}
|
| 1040 |
-
>
|
| 1041 |
-
Clear Filters
|
| 1042 |
-
</Button>
|
| 1043 |
-
</Container>
|
| 1044 |
-
</div>
|
| 1045 |
-
|
| 1046 |
-
{/* Layer 2: 5 Filter Bars */}
|
| 1047 |
-
<div className="flex flex-wrap items-center gap-4">
|
| 1048 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1049 |
-
<SelectInput
|
| 1050 |
-
name="source"
|
| 1051 |
-
placeholder="All Sources"
|
| 1052 |
-
options={sources}
|
| 1053 |
-
value={srcFilter || null}
|
| 1054 |
-
onChange={(v) => setSrcFilter(v as string || '')}
|
| 1055 |
-
keySelector={(o) => o.s_code}
|
| 1056 |
-
labelSelector={(o) => o.label}
|
| 1057 |
-
required={false}
|
| 1058 |
-
/>
|
| 1059 |
-
</Container>
|
| 1060 |
-
|
| 1061 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1062 |
-
<SelectInput
|
| 1063 |
-
name="category"
|
| 1064 |
-
placeholder="All Categories"
|
| 1065 |
-
options={types}
|
| 1066 |
-
value={catFilter || null}
|
| 1067 |
-
onChange={(v) => setCatFilter(v as string || '')}
|
| 1068 |
-
keySelector={(o) => o.t_code}
|
| 1069 |
-
labelSelector={(o) => o.label}
|
| 1070 |
-
required={false}
|
| 1071 |
-
/>
|
| 1072 |
-
</Container>
|
| 1073 |
-
|
| 1074 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1075 |
-
<SelectInput
|
| 1076 |
-
name="region"
|
| 1077 |
-
placeholder="All Regions"
|
| 1078 |
-
options={regions}
|
| 1079 |
-
value={regionFilter || null}
|
| 1080 |
-
onChange={(v) => setRegionFilter(v as string || '')}
|
| 1081 |
-
keySelector={(o) => o.r_code}
|
| 1082 |
-
labelSelector={(o) => o.label}
|
| 1083 |
-
required={false}
|
| 1084 |
-
/>
|
| 1085 |
-
</Container>
|
| 1086 |
-
|
| 1087 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1088 |
-
<MultiSelectInput
|
| 1089 |
-
name="country"
|
| 1090 |
-
placeholder="All Countries"
|
| 1091 |
-
options={countries}
|
| 1092 |
-
value={countryFilter ? [countryFilter] : []}
|
| 1093 |
-
onChange={(v) => setCountryFilter((v as string[])[0] || '')}
|
| 1094 |
-
keySelector={(o) => o.c_code}
|
| 1095 |
-
labelSelector={(o) => o.label}
|
| 1096 |
-
/>
|
| 1097 |
-
</Container>
|
| 1098 |
-
|
| 1099 |
-
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
| 1100 |
-
<SelectInput
|
| 1101 |
-
name="imageType"
|
| 1102 |
-
placeholder="All Image Types"
|
| 1103 |
-
options={imageTypes}
|
| 1104 |
-
value={imageTypeFilter || null}
|
| 1105 |
-
onChange={(v) => setImageTypeFilter(v as string || '')}
|
| 1106 |
-
keySelector={(o) => o.image_type}
|
| 1107 |
-
labelSelector={(o) => o.label}
|
| 1108 |
-
required={false}
|
| 1109 |
-
/>
|
| 1110 |
-
</Container>
|
| 1111 |
-
</div>
|
| 1112 |
-
</div>
|
| 1113 |
|
| 1114 |
{view === 'mapDetails' ? (
|
| 1115 |
<div className="relative">
|
|
@@ -1132,28 +1219,115 @@ export default function MapDetailPage() {
|
|
| 1132 |
spacing="comfortable"
|
| 1133 |
>
|
| 1134 |
<div className={styles.imageContainer}>
|
| 1135 |
-
{
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
) : (
|
| 1141 |
-
|
| 1142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1143 |
</div>
|
| 1144 |
)}
|
| 1145 |
</div>
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
<div className={styles.detailsSection}>
|
| 1150 |
-
<Container
|
| 1151 |
-
heading="Tags"
|
| 1152 |
-
headingLevel={3}
|
| 1153 |
-
withHeaderBorder
|
| 1154 |
-
withInternalPadding
|
| 1155 |
-
spacing="comfortable"
|
| 1156 |
-
>
|
| 1157 |
<div className={styles.metadataTags}>
|
| 1158 |
{filteredMap.image_type !== 'drone_image' && (
|
| 1159 |
<span className={styles.metadataTag}>
|
|
@@ -1176,8 +1350,22 @@ export default function MapDetailPage() {
|
|
| 1176 |
</span>
|
| 1177 |
</>
|
| 1178 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
</div>
|
| 1180 |
</Container>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
|
| 1182 |
{/* Combined Analysis Structure */}
|
| 1183 |
{(filteredMap.edited && filteredMap.edited.includes('Description:')) ||
|
|
@@ -1275,13 +1463,8 @@ export default function MapDetailPage() {
|
|
| 1275 |
<Button
|
| 1276 |
name="contribute"
|
| 1277 |
onClick={handleContribute}
|
| 1278 |
-
disabled={isGenerating}
|
| 1279 |
>
|
| 1280 |
-
|
| 1281 |
-
<span>Generating...</span>
|
| 1282 |
-
) : (
|
| 1283 |
-
'Contribute'
|
| 1284 |
-
)}
|
| 1285 |
</Button>
|
| 1286 |
</Container>
|
| 1287 |
|
|
@@ -1384,43 +1567,7 @@ export default function MapDetailPage() {
|
|
| 1384 |
</div>
|
| 1385 |
)}
|
| 1386 |
|
| 1387 |
-
|
| 1388 |
-
{showContributeConfirm && (
|
| 1389 |
-
<div className={styles.fullSizeModalOverlay} onClick={handleContributeCancel}>
|
| 1390 |
-
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}>
|
| 1391 |
-
<div className={styles.ratingWarningContent}>
|
| 1392 |
-
<p className={styles.ratingWarningText}>
|
| 1393 |
-
This will start a new independent upload with just the image.
|
| 1394 |
-
</p>
|
| 1395 |
-
{!isGenerating && (
|
| 1396 |
-
<div className={styles.ratingWarningButtons}>
|
| 1397 |
-
<Button
|
| 1398 |
-
name="confirm-contribute"
|
| 1399 |
-
variant="secondary"
|
| 1400 |
-
onClick={handleContributeConfirm}
|
| 1401 |
-
>
|
| 1402 |
-
Continue
|
| 1403 |
-
</Button>
|
| 1404 |
-
<Button
|
| 1405 |
-
name="cancel-contribute"
|
| 1406 |
-
variant="tertiary"
|
| 1407 |
-
onClick={handleContributeCancel}
|
| 1408 |
-
>
|
| 1409 |
-
Cancel
|
| 1410 |
-
</Button>
|
| 1411 |
-
</div>
|
| 1412 |
-
)}
|
| 1413 |
-
{isGenerating && (
|
| 1414 |
-
<div className="flex flex-col items-center gap-2 mt-4">
|
| 1415 |
-
<Spinner className="text-ifrcRed" />
|
| 1416 |
-
<div className="text-sm font-medium">Generating...</div>
|
| 1417 |
-
<div className="text-xs text-gray-600">This might take a few seconds</div>
|
| 1418 |
-
</div>
|
| 1419 |
-
)}
|
| 1420 |
-
</div>
|
| 1421 |
-
</div>
|
| 1422 |
-
</div>
|
| 1423 |
-
)}
|
| 1424 |
|
| 1425 |
{/* Export Selection Modal */}
|
| 1426 |
{showExportModal && (
|
|
@@ -1454,6 +1601,15 @@ export default function MapDetailPage() {
|
|
| 1454 |
}}
|
| 1455 |
/>
|
| 1456 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1457 |
</PageContainer>
|
| 1458 |
);
|
| 1459 |
}
|
|
|
|
| 6 |
import { useFilterContext } from '../../hooks/useFilterContext';
|
| 7 |
import { useAdmin } from '../../hooks/useAdmin';
|
| 8 |
import ExportModal from '../../components/ExportModal';
|
| 9 |
+
import { FullSizeImageModal } from '../../components/upload/ModalComponents';
|
| 10 |
+
import FilterBar from '../../components/FilterBar';
|
| 11 |
|
| 12 |
interface MapOut {
|
| 13 |
image_id: string;
|
|
|
|
| 45 |
starred?: boolean;
|
| 46 |
created_at?: string;
|
| 47 |
updated_at?: string;
|
| 48 |
+
// Multi-upload fields
|
| 49 |
+
all_image_ids?: string[];
|
| 50 |
+
image_count?: number;
|
| 51 |
}
|
| 52 |
|
| 53 |
export default function MapDetailPage() {
|
|
|
|
| 112 |
const [droneImagesSelected, setDroneImagesSelected] = useState(true);
|
| 113 |
|
| 114 |
const [isDeleting, setIsDeleting] = useState(false);
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
// Full-size image modal state
|
| 118 |
+
const [showFullSizeModal, setShowFullSizeModal] = useState(false);
|
| 119 |
+
const [selectedImageForModal, setSelectedImageForModal] = useState<MapOut | null>(null);
|
| 120 |
+
|
| 121 |
+
// Carousel state for multi-upload
|
| 122 |
+
const [allImages, setAllImages] = useState<MapOut[]>([]);
|
| 123 |
+
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
| 124 |
+
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
| 125 |
|
| 126 |
const {
|
| 127 |
search, setSearch,
|
|
|
|
| 130 |
regionFilter, setRegionFilter,
|
| 131 |
countryFilter, setCountryFilter,
|
| 132 |
imageTypeFilter, setImageTypeFilter,
|
| 133 |
+
uploadTypeFilter, setUploadTypeFilter,
|
| 134 |
showReferenceExamples, setShowReferenceExamples,
|
| 135 |
clearAllFilters
|
| 136 |
} = useFilterContext();
|
|
|
|
| 173 |
const data = await response.json();
|
| 174 |
setMap(data);
|
| 175 |
|
| 176 |
+
// If this is a multi-upload item, fetch all images
|
| 177 |
+
if (data.all_image_ids && data.all_image_ids.length > 1) {
|
| 178 |
+
await fetchAllImages(data.all_image_ids);
|
| 179 |
+
} else if (data.image_count && data.image_count > 1) {
|
| 180 |
+
// Multi-upload but no all_image_ids, try to fetch from grouped endpoint
|
| 181 |
+
console.log('Multi-upload detected but no all_image_ids, trying grouped endpoint');
|
| 182 |
+
try {
|
| 183 |
+
const groupedResponse = await fetch('/api/images/grouped');
|
| 184 |
+
if (groupedResponse.ok) {
|
| 185 |
+
const groupedData = await groupedResponse.json();
|
| 186 |
+
const matchingItem = groupedData.find((item: any) =>
|
| 187 |
+
item.all_image_ids && item.all_image_ids.includes(data.image_id)
|
| 188 |
+
);
|
| 189 |
+
if (matchingItem && matchingItem.all_image_ids) {
|
| 190 |
+
await fetchAllImages(matchingItem.all_image_ids);
|
| 191 |
+
} else {
|
| 192 |
+
setAllImages([data]);
|
| 193 |
+
setCurrentImageIndex(0);
|
| 194 |
+
}
|
| 195 |
+
} else {
|
| 196 |
+
setAllImages([data]);
|
| 197 |
+
setCurrentImageIndex(0);
|
| 198 |
+
}
|
| 199 |
+
} catch (err) {
|
| 200 |
+
console.error('Failed to fetch from grouped endpoint:', err);
|
| 201 |
+
setAllImages([data]);
|
| 202 |
+
setCurrentImageIndex(0);
|
| 203 |
+
}
|
| 204 |
+
} else {
|
| 205 |
+
setAllImages([data]);
|
| 206 |
+
setCurrentImageIndex(0);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
await checkNavigationAvailability(id);
|
| 210 |
} catch (err: unknown) {
|
| 211 |
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
|
|
|
| 215 |
}
|
| 216 |
}, []);
|
| 217 |
|
| 218 |
+
const fetchAllImages = useCallback(async (imageIds: string[]) => {
|
| 219 |
+
console.log('fetchAllImages called with imageIds:', imageIds);
|
| 220 |
+
setIsLoadingImages(true);
|
| 221 |
+
|
| 222 |
+
try {
|
| 223 |
+
const imagePromises = imageIds.map(async (imageId) => {
|
| 224 |
+
const response = await fetch(`/api/images/${imageId}`);
|
| 225 |
+
if (!response.ok) {
|
| 226 |
+
throw new Error(`Failed to fetch image ${imageId}`);
|
| 227 |
+
}
|
| 228 |
+
return response.json();
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
const images = await Promise.all(imagePromises);
|
| 232 |
+
setAllImages(images);
|
| 233 |
+
setCurrentImageIndex(0);
|
| 234 |
+
console.log('fetchAllImages: Loaded', images.length, 'images');
|
| 235 |
+
} catch (err: unknown) {
|
| 236 |
+
console.error('fetchAllImages error:', err);
|
| 237 |
+
setError(err instanceof Error ? err.message : 'Failed to load all images');
|
| 238 |
+
} finally {
|
| 239 |
+
setIsLoadingImages(false);
|
| 240 |
+
}
|
| 241 |
+
}, []);
|
| 242 |
+
|
| 243 |
+
// Carousel navigation functions
|
| 244 |
+
const goToPrevious = useCallback(() => {
|
| 245 |
+
if (allImages.length > 1) {
|
| 246 |
+
setCurrentImageIndex((prev) => (prev > 0 ? prev - 1 : allImages.length - 1));
|
| 247 |
+
}
|
| 248 |
+
}, [allImages.length]);
|
| 249 |
+
|
| 250 |
+
const goToNext = useCallback(() => {
|
| 251 |
+
if (allImages.length > 1) {
|
| 252 |
+
setCurrentImageIndex((prev) => (prev < allImages.length - 1 ? prev + 1 : 0));
|
| 253 |
+
}
|
| 254 |
+
}, [allImages.length]);
|
| 255 |
+
|
| 256 |
+
const goToImage = useCallback((index: number) => {
|
| 257 |
+
if (index >= 0 && index < allImages.length) {
|
| 258 |
+
setCurrentImageIndex(index);
|
| 259 |
+
}
|
| 260 |
+
}, [allImages.length]);
|
| 261 |
+
|
| 262 |
+
// Full-size image modal functions
|
| 263 |
+
const handleViewFullSize = useCallback((image?: MapOut) => {
|
| 264 |
+
const imageToShow = image || (allImages.length > 0 ? allImages[currentImageIndex] : map);
|
| 265 |
+
if (imageToShow) {
|
| 266 |
+
setSelectedImageForModal(imageToShow);
|
| 267 |
+
setShowFullSizeModal(true);
|
| 268 |
+
}
|
| 269 |
+
}, [allImages, currentImageIndex, map]);
|
| 270 |
+
|
| 271 |
+
const handleCloseFullSizeModal = useCallback(() => {
|
| 272 |
+
setShowFullSizeModal(false);
|
| 273 |
+
setSelectedImageForModal(null);
|
| 274 |
+
}, []);
|
| 275 |
+
|
| 276 |
useEffect(() => {
|
| 277 |
console.log('MapDetailsPage: mapId from useParams:', mapId);
|
| 278 |
console.log('MapDetailsPage: mapId type:', typeof mapId);
|
|
|
|
| 419 |
}
|
| 420 |
|
| 421 |
try {
|
| 422 |
+
const response = await fetch('/api/images/grouped');
|
| 423 |
if (response.ok) {
|
| 424 |
const images = await response.json();
|
| 425 |
|
|
|
|
| 437 |
const matchesCountry = !countryFilter ||
|
| 438 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 439 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
| 440 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 441 |
+
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
|
| 442 |
+
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
|
| 443 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 444 |
|
| 445 |
+
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
| 446 |
});
|
| 447 |
|
| 448 |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === currentId);
|
|
|
|
| 460 |
|
| 461 |
setIsNavigating(true);
|
| 462 |
try {
|
| 463 |
+
const response = await fetch('/api/images/grouped');
|
| 464 |
if (response.ok) {
|
| 465 |
const images = await response.json();
|
| 466 |
|
|
|
|
| 478 |
const matchesCountry = !countryFilter ||
|
| 479 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 480 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
| 481 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 482 |
+
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
|
| 483 |
+
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
|
| 484 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 485 |
|
| 486 |
+
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
| 487 |
});
|
| 488 |
|
| 489 |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === mapId);
|
|
|
|
| 533 |
}
|
| 534 |
};
|
| 535 |
|
| 536 |
+
// Check navigation availability when filters change
|
| 537 |
+
useEffect(() => {
|
| 538 |
+
if (map && mapId && !loading && !isDeleting) {
|
| 539 |
+
checkNavigationAvailability(mapId);
|
| 540 |
+
}
|
| 541 |
+
}, [map, mapId, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, loading, isDeleting, checkNavigationAvailability]);
|
| 542 |
+
|
| 543 |
useEffect(() => {
|
| 544 |
Promise.all([
|
| 545 |
fetch('/api/sources').then(r => r.json()),
|
|
|
|
| 556 |
}).catch(console.error);
|
| 557 |
}, []);
|
| 558 |
|
| 559 |
+
|
| 560 |
|
| 561 |
// delete function
|
| 562 |
const handleDelete = async () => {
|
|
|
|
| 604 |
setShowDeleteConfirm(false);
|
| 605 |
|
| 606 |
try {
|
| 607 |
+
const response = await fetch('/api/images/grouped');
|
| 608 |
if (response.ok) {
|
| 609 |
const images = await response.json();
|
| 610 |
|
|
|
|
| 622 |
const matchesCountry = !countryFilter ||
|
| 623 |
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 624 |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
| 625 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 626 |
+
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
|
| 627 |
+
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
|
| 628 |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 629 |
|
| 630 |
+
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
| 631 |
});
|
| 632 |
|
| 633 |
const remainingImages = filteredImages.filter((img: any) => img.image_id !== map.image_id);
|
|
|
|
| 708 |
const filteredMap = useMemo(() => {
|
| 709 |
if (!map) return null;
|
| 710 |
|
| 711 |
+
if (!search && !srcFilter && !catFilter && !regionFilter && !countryFilter && !imageTypeFilter && !uploadTypeFilter && !showReferenceExamples) {
|
| 712 |
return map;
|
| 713 |
}
|
| 714 |
|
|
|
|
| 725 |
const matchesCountry = !countryFilter ||
|
| 726 |
map.countries.some(country => country.c_code === countryFilter);
|
| 727 |
const matchesImageType = !imageTypeFilter || map.image_type === imageTypeFilter;
|
| 728 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 729 |
+
(uploadTypeFilter === 'single' && (!map.image_count || map.image_count <= 1)) ||
|
| 730 |
+
(uploadTypeFilter === 'multiple' && map.image_count && map.image_count > 1);
|
| 731 |
const matchesReferenceExamples = !showReferenceExamples || map.starred === true;
|
| 732 |
|
| 733 |
+
const matches = matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
|
| 735 |
+
// If current map doesn't match filters, navigate to a matching image
|
| 736 |
+
if (!matches && (search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || uploadTypeFilter || showReferenceExamples)) {
|
| 737 |
+
// Navigate to a matching image after a short delay to avoid infinite loops
|
| 738 |
+
setTimeout(() => {
|
| 739 |
+
navigateToMatchingImage();
|
| 740 |
+
}, 100);
|
| 741 |
+
// Return the current map while loading to show loading state instead of "no match found"
|
| 742 |
+
return map;
|
| 743 |
+
}
|
| 744 |
|
| 745 |
+
return matches ? map : null;
|
| 746 |
+
}, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples]);
|
| 747 |
+
|
| 748 |
+
const navigateToMatchingImage = useCallback(async () => {
|
| 749 |
+
setLoading(true);
|
| 750 |
try {
|
| 751 |
+
const response = await fetch('/api/images/grouped');
|
| 752 |
+
if (response.ok) {
|
| 753 |
+
const images = await response.json();
|
| 754 |
+
|
| 755 |
+
const filteredImages = images.filter((img: any) => {
|
| 756 |
+
const matchesSearch = !search ||
|
| 757 |
+
img.title?.toLowerCase().includes(search.toLowerCase()) ||
|
| 758 |
+
img.generated?.toLowerCase().includes(search.toLowerCase()) ||
|
| 759 |
+
img.source?.toLowerCase().includes(search.toLowerCase()) ||
|
| 760 |
+
img.event_type?.toLowerCase().includes(search.toLowerCase());
|
| 761 |
+
|
| 762 |
+
const matchesSource = !srcFilter || img.source === srcFilter;
|
| 763 |
+
const matchesCategory = !catFilter || img.event_type === catFilter;
|
| 764 |
+
const matchesRegion = !regionFilter ||
|
| 765 |
+
img.countries?.some((country: any) => country.r_code === regionFilter);
|
| 766 |
+
const matchesCountry = !countryFilter ||
|
| 767 |
+
img.countries?.some((country: any) => country.c_code === countryFilter);
|
| 768 |
+
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter;
|
| 769 |
+
const matchesUploadType = !uploadTypeFilter ||
|
| 770 |
+
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) ||
|
| 771 |
+
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1);
|
| 772 |
+
const matchesReferenceExamples = !showReferenceExamples || img.starred === true;
|
| 773 |
+
|
| 774 |
+
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples;
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
if (filteredImages.length > 0) {
|
| 778 |
+
const firstMatchingImage = filteredImages[0];
|
| 779 |
+
if (firstMatchingImage && firstMatchingImage.image_id) {
|
| 780 |
+
navigate(`/map/${firstMatchingImage.image_id}`);
|
| 781 |
+
}
|
| 782 |
+
} else {
|
| 783 |
+
// No matching images, go back to explore
|
| 784 |
+
navigate('/explore');
|
| 785 |
+
}
|
| 786 |
}
|
| 787 |
+
} catch (error) {
|
| 788 |
+
console.error('Failed to navigate to matching image:', error);
|
| 789 |
+
navigate('/explore');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
} finally {
|
| 791 |
+
setLoading(false);
|
| 792 |
}
|
| 793 |
+
}, [search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, navigate]);
|
| 794 |
|
| 795 |
+
const handleContribute = () => {
|
| 796 |
+
if (!map) return;
|
| 797 |
+
|
| 798 |
+
// For single image contribution
|
| 799 |
+
if (!map.all_image_ids || map.all_image_ids.length <= 1) {
|
| 800 |
+
const imageIds = [map.image_id];
|
| 801 |
+
const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`;
|
| 802 |
+
navigate(url);
|
| 803 |
+
return;
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
// For multi-upload contribution
|
| 807 |
+
const imageIds = map.all_image_ids;
|
| 808 |
+
const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`;
|
| 809 |
+
navigate(url);
|
| 810 |
};
|
| 811 |
|
| 812 |
const createImageData = (map: any, fileName: string) => ({
|
| 813 |
image: `images/${fileName}`,
|
| 814 |
caption: map.edited || map.generated || '',
|
| 815 |
metadata: {
|
| 816 |
+
image_id: map.image_count && map.image_count > 1
|
| 817 |
+
? map.all_image_ids || [map.image_id]
|
| 818 |
+
: map.image_id,
|
| 819 |
title: map.title,
|
| 820 |
source: map.source,
|
| 821 |
event_type: map.event_type,
|
| 822 |
image_type: map.image_type,
|
| 823 |
countries: map.countries,
|
| 824 |
+
starred: map.starred,
|
| 825 |
+
image_count: map.image_count || 1
|
| 826 |
}
|
| 827 |
});
|
| 828 |
|
|
|
|
| 842 |
|
| 843 |
if (crisisImagesFolder) {
|
| 844 |
try {
|
| 845 |
+
// Get all image IDs for this map
|
| 846 |
+
const imageIds = map.image_count && map.image_count > 1
|
| 847 |
+
? map.all_image_ids || [map.image_id]
|
| 848 |
+
: [map.image_id];
|
| 849 |
|
| 850 |
+
// Fetch all images for this map
|
| 851 |
+
const imagePromises = imageIds.map(async (imageId, imgIndex) => {
|
| 852 |
+
try {
|
| 853 |
+
const response = await fetch(`/api/images/${imageId}/file`);
|
| 854 |
+
if (!response.ok) throw new Error(`Failed to fetch image ${imageId}`);
|
| 855 |
+
|
| 856 |
+
const blob = await response.blob();
|
| 857 |
+
const fileExtension = map.file_key.split('.').pop() || 'jpg';
|
| 858 |
+
const fileName = `0001_${String(imgIndex + 1).padStart(2, '0')}.${fileExtension}`;
|
| 859 |
+
|
| 860 |
+
crisisImagesFolder.file(fileName, blob);
|
| 861 |
+
return { success: true, fileName, imageId };
|
| 862 |
+
} catch (error) {
|
| 863 |
+
console.error(`Failed to process image ${imageId}:`, error);
|
| 864 |
+
return { success: false, fileName: '', imageId };
|
| 865 |
+
}
|
| 866 |
+
});
|
| 867 |
+
|
| 868 |
+
const imageResults = await Promise.all(imagePromises);
|
| 869 |
+
const successfulImages = imageResults.filter(result => result.success);
|
| 870 |
|
| 871 |
+
if (successfulImages.length === 0) {
|
| 872 |
+
throw new Error('No images could be processed');
|
| 873 |
+
}
|
| 874 |
|
| 875 |
if (mode === 'fine-tuning') {
|
| 876 |
const trainData: any[] = [];
|
| 877 |
const testData: any[] = [];
|
| 878 |
const valData: any[] = [];
|
| 879 |
|
| 880 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 881 |
+
const random = Math.random();
|
| 882 |
+
|
| 883 |
+
const entry = {
|
| 884 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 885 |
+
caption: map.edited || map.generated || '',
|
| 886 |
+
metadata: {
|
| 887 |
+
image_id: imageIds,
|
| 888 |
+
title: map.title,
|
| 889 |
+
source: map.source,
|
| 890 |
+
event_type: map.event_type,
|
| 891 |
+
image_type: map.image_type,
|
| 892 |
+
countries: map.countries,
|
| 893 |
+
starred: map.starred,
|
| 894 |
+
image_count: map.image_count || 1
|
|
|
|
|
|
|
| 895 |
}
|
| 896 |
+
};
|
| 897 |
+
|
| 898 |
+
if (random < trainSplit / 100) {
|
| 899 |
+
trainData.push(entry);
|
| 900 |
+
} else if (random < (trainSplit + testSplit) / 100) {
|
| 901 |
+
testData.push(entry);
|
| 902 |
+
} else {
|
| 903 |
+
valData.push(entry);
|
| 904 |
}
|
| 905 |
|
| 906 |
if (crisisFolder) {
|
|
|
|
| 909 |
crisisFolder.file('val.jsonl', JSON.stringify(valData, null, 2));
|
| 910 |
}
|
| 911 |
} else {
|
| 912 |
+
const imageFiles = successfulImages.map(result => `images/${result.fileName}`);
|
| 913 |
const jsonData = {
|
| 914 |
+
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles,
|
| 915 |
caption: map.edited || map.generated || '',
|
| 916 |
metadata: {
|
| 917 |
+
image_id: imageIds,
|
| 918 |
title: map.title,
|
| 919 |
source: map.source,
|
| 920 |
event_type: map.event_type,
|
| 921 |
image_type: map.image_type,
|
| 922 |
countries: map.countries,
|
| 923 |
+
starred: map.starred,
|
| 924 |
+
image_count: map.image_count || 1
|
| 925 |
}
|
| 926 |
};
|
| 927 |
|
|
|
|
| 984 |
image: `images/${fileName}`,
|
| 985 |
caption: map.edited || map.generated || '',
|
| 986 |
metadata: {
|
| 987 |
+
image_id: map.image_count && map.image_count > 1
|
| 988 |
+
? map.all_image_ids || [map.image_id]
|
| 989 |
+
: map.image_id,
|
| 990 |
title: map.title,
|
| 991 |
source: map.source,
|
| 992 |
event_type: map.event_type,
|
| 993 |
image_type: map.image_type,
|
| 994 |
countries: map.countries,
|
| 995 |
+
starred: map.starred,
|
| 996 |
+
image_count: map.image_count || 1
|
| 997 |
}
|
| 998 |
};
|
| 999 |
|
|
|
|
| 1056 |
image: `images/${fileName}`,
|
| 1057 |
caption: map.edited || map.generated || '',
|
| 1058 |
metadata: {
|
| 1059 |
+
image_id: map.image_count && map.image_count > 1
|
| 1060 |
+
? map.all_image_ids || [map.image_id]
|
| 1061 |
+
: map.image_id,
|
| 1062 |
title: map.title,
|
| 1063 |
source: map.source,
|
| 1064 |
event_type: map.event_type,
|
| 1065 |
image_type: map.image_type,
|
| 1066 |
countries: map.countries,
|
| 1067 |
+
starred: map.starred,
|
| 1068 |
+
image_count: map.image_count || 1
|
| 1069 |
}
|
| 1070 |
};
|
| 1071 |
|
|
|
|
| 1188 |
</div>
|
| 1189 |
</div>
|
| 1190 |
|
| 1191 |
+
{/* Filter Bar */}
|
| 1192 |
+
<FilterBar
|
| 1193 |
+
sources={sources}
|
| 1194 |
+
types={types}
|
| 1195 |
+
regions={regions}
|
| 1196 |
+
countries={countries}
|
| 1197 |
+
imageTypes={imageTypes}
|
| 1198 |
+
isLoadingFilters={false}
|
| 1199 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
|
| 1201 |
{view === 'mapDetails' ? (
|
| 1202 |
<div className="relative">
|
|
|
|
| 1219 |
spacing="comfortable"
|
| 1220 |
>
|
| 1221 |
<div className={styles.imageContainer}>
|
| 1222 |
+
{(map?.image_count && map.image_count > 1) || allImages.length > 1 ? (
|
| 1223 |
+
// Multi-upload carousel
|
| 1224 |
+
<div className={styles.carouselContainer}>
|
| 1225 |
+
<div className={styles.carouselImageWrapper}>
|
| 1226 |
+
{isLoadingImages ? (
|
| 1227 |
+
<div className={styles.imagePlaceholder}>
|
| 1228 |
+
<Spinner className="text-ifrcRed" />
|
| 1229 |
+
<div>Loading images...</div>
|
| 1230 |
+
</div>
|
| 1231 |
+
) : allImages[currentImageIndex]?.image_url ? (
|
| 1232 |
+
<img
|
| 1233 |
+
src={allImages[currentImageIndex].image_url}
|
| 1234 |
+
alt={allImages[currentImageIndex].file_key}
|
| 1235 |
+
className={styles.carouselImage}
|
| 1236 |
+
/>
|
| 1237 |
+
) : (
|
| 1238 |
+
<div className={styles.imagePlaceholder}>
|
| 1239 |
+
No image available
|
| 1240 |
+
</div>
|
| 1241 |
+
)}
|
| 1242 |
+
</div>
|
| 1243 |
+
|
| 1244 |
+
{/* Carousel Navigation */}
|
| 1245 |
+
<div className={styles.carouselNavigation}>
|
| 1246 |
+
<Button
|
| 1247 |
+
name="previous-image"
|
| 1248 |
+
variant="tertiary"
|
| 1249 |
+
size={1}
|
| 1250 |
+
onClick={goToPrevious}
|
| 1251 |
+
disabled={isLoadingImages}
|
| 1252 |
+
className={styles.carouselButton}
|
| 1253 |
+
>
|
| 1254 |
+
<ChevronLeftLineIcon className="w-4 h-4" />
|
| 1255 |
+
</Button>
|
| 1256 |
+
|
| 1257 |
+
<div className={styles.carouselIndicators}>
|
| 1258 |
+
{allImages.map((_, index) => (
|
| 1259 |
+
<button
|
| 1260 |
+
key={index}
|
| 1261 |
+
onClick={() => goToImage(index)}
|
| 1262 |
+
className={`${styles.carouselIndicator} ${
|
| 1263 |
+
index === currentImageIndex ? styles.carouselIndicatorActive : ''
|
| 1264 |
+
}`}
|
| 1265 |
+
disabled={isLoadingImages}
|
| 1266 |
+
>
|
| 1267 |
+
{index + 1}
|
| 1268 |
+
</button>
|
| 1269 |
+
))}
|
| 1270 |
+
</div>
|
| 1271 |
+
|
| 1272 |
+
<Button
|
| 1273 |
+
name="next-image"
|
| 1274 |
+
variant="tertiary"
|
| 1275 |
+
size={1}
|
| 1276 |
+
onClick={goToNext}
|
| 1277 |
+
disabled={isLoadingImages}
|
| 1278 |
+
className={styles.carouselButton}
|
| 1279 |
+
>
|
| 1280 |
+
<ChevronRightLineIcon className="w-4 h-4" />
|
| 1281 |
+
</Button>
|
| 1282 |
+
</div>
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
|
| 1286 |
+
{/* View Image Button for Carousel */}
|
| 1287 |
+
<div className={styles.viewImageButtonContainer}>
|
| 1288 |
+
<Button
|
| 1289 |
+
name="view-full-size-carousel"
|
| 1290 |
+
variant="secondary"
|
| 1291 |
+
size={1}
|
| 1292 |
+
onClick={() => handleViewFullSize(allImages[currentImageIndex])}
|
| 1293 |
+
disabled={isLoadingImages || !allImages[currentImageIndex]?.image_url}
|
| 1294 |
+
>
|
| 1295 |
+
View Image
|
| 1296 |
+
</Button>
|
| 1297 |
+
</div>
|
| 1298 |
+
</div>
|
| 1299 |
) : (
|
| 1300 |
+
// Single image display
|
| 1301 |
+
<div className={styles.singleImageContainer}>
|
| 1302 |
+
{filteredMap.image_url ? (
|
| 1303 |
+
<img
|
| 1304 |
+
src={filteredMap.image_url}
|
| 1305 |
+
alt={filteredMap.file_key}
|
| 1306 |
+
/>
|
| 1307 |
+
) : (
|
| 1308 |
+
<div className={styles.imagePlaceholder}>
|
| 1309 |
+
No image available
|
| 1310 |
+
</div>
|
| 1311 |
+
)}
|
| 1312 |
+
|
| 1313 |
+
{/* View Image Button for Single Image */}
|
| 1314 |
+
<div className={styles.viewImageButtonContainer}>
|
| 1315 |
+
<Button
|
| 1316 |
+
name="view-full-size-single"
|
| 1317 |
+
variant="secondary"
|
| 1318 |
+
size={1}
|
| 1319 |
+
onClick={() => handleViewFullSize(filteredMap)}
|
| 1320 |
+
disabled={!filteredMap.image_url}
|
| 1321 |
+
>
|
| 1322 |
+
View Image
|
| 1323 |
+
</Button>
|
| 1324 |
+
</div>
|
| 1325 |
</div>
|
| 1326 |
)}
|
| 1327 |
</div>
|
| 1328 |
+
|
| 1329 |
+
{/* Tags Section - Inside Image Container */}
|
| 1330 |
+
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
<div className={styles.metadataTags}>
|
| 1332 |
{filteredMap.image_type !== 'drone_image' && (
|
| 1333 |
<span className={styles.metadataTag}>
|
|
|
|
| 1350 |
</span>
|
| 1351 |
</>
|
| 1352 |
)}
|
| 1353 |
+
{filteredMap.image_count && filteredMap.image_count > 1 && (
|
| 1354 |
+
<span className={styles.metadataTag} title={`Multi-upload with ${filteredMap.image_count} images`}>
|
| 1355 |
+
📷 {filteredMap.image_count}
|
| 1356 |
+
</span>
|
| 1357 |
+
)}
|
| 1358 |
+
{(!filteredMap.image_count || filteredMap.image_count <= 1) && (
|
| 1359 |
+
<span className={styles.metadataTag} title="Single Upload">
|
| 1360 |
+
Single
|
| 1361 |
+
</span>
|
| 1362 |
+
)}
|
| 1363 |
</div>
|
| 1364 |
</Container>
|
| 1365 |
+
</Container>
|
| 1366 |
+
|
| 1367 |
+
{/* Details Section */}
|
| 1368 |
+
<div className={styles.detailsSection}>
|
| 1369 |
|
| 1370 |
{/* Combined Analysis Structure */}
|
| 1371 |
{(filteredMap.edited && filteredMap.edited.includes('Description:')) ||
|
|
|
|
| 1463 |
<Button
|
| 1464 |
name="contribute"
|
| 1465 |
onClick={handleContribute}
|
|
|
|
| 1466 |
>
|
| 1467 |
+
Contribute
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1468 |
</Button>
|
| 1469 |
</Container>
|
| 1470 |
|
|
|
|
| 1567 |
</div>
|
| 1568 |
)}
|
| 1569 |
|
| 1570 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1571 |
|
| 1572 |
{/* Export Selection Modal */}
|
| 1573 |
{showExportModal && (
|
|
|
|
| 1601 |
}}
|
| 1602 |
/>
|
| 1603 |
)}
|
| 1604 |
+
|
| 1605 |
+
{/* Full Size Image Modal */}
|
| 1606 |
+
<FullSizeImageModal
|
| 1607 |
+
isOpen={showFullSizeModal}
|
| 1608 |
+
imageUrl={selectedImageForModal?.image_url || null}
|
| 1609 |
+
preview={null}
|
| 1610 |
+
selectedImageData={null}
|
| 1611 |
+
onClose={handleCloseFullSizeModal}
|
| 1612 |
+
/>
|
| 1613 |
</PageContainer>
|
| 1614 |
);
|
| 1615 |
}
|
frontend/src/pages/UploadPage/UploadPage.module.css
CHANGED
|
@@ -417,6 +417,11 @@
|
|
| 417 |
align-items: start;
|
| 418 |
}
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
.imageSection {
|
| 421 |
position: sticky;
|
| 422 |
top: var(--go-ui-spacing-lg);
|
|
@@ -482,6 +487,10 @@
|
|
| 482 |
gap: var(--go-ui-spacing-lg);
|
| 483 |
}
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
.mapColumn {
|
| 486 |
position: static;
|
| 487 |
}
|
|
@@ -660,4 +669,214 @@
|
|
| 660 |
font-weight: var(--go-ui-font-weight-medium);
|
| 661 |
}
|
| 662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
|
|
|
|
| 417 |
align-items: start;
|
| 418 |
}
|
| 419 |
|
| 420 |
+
/* When rating section is hidden, image section takes full width */
|
| 421 |
+
.topRow.ratingHidden {
|
| 422 |
+
grid-template-columns: 1fr;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
.imageSection {
|
| 426 |
position: sticky;
|
| 427 |
top: var(--go-ui-spacing-lg);
|
|
|
|
| 487 |
gap: var(--go-ui-spacing-lg);
|
| 488 |
}
|
| 489 |
|
| 490 |
+
.topRow.ratingHidden {
|
| 491 |
+
grid-template-columns: 1fr;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
.mapColumn {
|
| 495 |
position: static;
|
| 496 |
}
|
|
|
|
| 669 |
font-weight: var(--go-ui-font-weight-medium);
|
| 670 |
}
|
| 671 |
|
| 672 |
+
/* Crop modal styles */
|
| 673 |
+
.cropZoomSlider {
|
| 674 |
+
flex: 1;
|
| 675 |
+
height: 0.5rem;
|
| 676 |
+
background-color: var(--go-ui-color-gray-30);
|
| 677 |
+
border-radius: var(--go-ui-border-radius-lg);
|
| 678 |
+
appearance: none;
|
| 679 |
+
cursor: pointer;
|
| 680 |
+
outline: none;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.cropZoomSlider::-webkit-slider-thumb {
|
| 684 |
+
appearance: none;
|
| 685 |
+
width: 1.25rem;
|
| 686 |
+
height: 1.25rem;
|
| 687 |
+
background-color: var(--go-ui-color-red-90);
|
| 688 |
+
border-radius: 50%;
|
| 689 |
+
cursor: pointer;
|
| 690 |
+
border: 2px solid var(--go-ui-color-white);
|
| 691 |
+
box-shadow: var(--go-ui-box-shadow-sm);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.cropZoomSlider::-moz-range-thumb {
|
| 695 |
+
width: 1.25rem;
|
| 696 |
+
height: 1.25rem;
|
| 697 |
+
background-color: var(--go-ui-color-red-90);
|
| 698 |
+
border-radius: 50%;
|
| 699 |
+
cursor: pointer;
|
| 700 |
+
border: 2px solid var(--go-ui-color-white);
|
| 701 |
+
box-shadow: var(--go-ui-box-shadow-sm);
|
| 702 |
+
border: none;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.cropZoomSlider:focus {
|
| 706 |
+
outline: none;
|
| 707 |
+
box-shadow: 0 0 0 2px var(--go-ui-color-red-40);
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
/* Carousel styles for multi-upload */
|
| 711 |
+
.carouselContainer {
|
| 712 |
+
position: relative;
|
| 713 |
+
width: 100%;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.carouselImageWrapper {
|
| 717 |
+
position: relative;
|
| 718 |
+
width: 100%;
|
| 719 |
+
background-color: var(--go-ui-color-gray-20);
|
| 720 |
+
border-radius: var(--go-ui-border-radius-lg);
|
| 721 |
+
overflow: hidden;
|
| 722 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 723 |
+
box-shadow: var(--go-ui-box-shadow-sm);
|
| 724 |
+
transition: box-shadow var(--go-ui-duration-transition-medium) ease;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.carouselImageWrapper:hover {
|
| 728 |
+
box-shadow: var(--go-ui-box-shadow-md);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.carouselImage {
|
| 732 |
+
width: 100%;
|
| 733 |
+
height: auto;
|
| 734 |
+
object-fit: contain;
|
| 735 |
+
image-rendering: pixelated;
|
| 736 |
+
display: block;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.carouselNavigation {
|
| 740 |
+
display: flex;
|
| 741 |
+
align-items: center;
|
| 742 |
+
justify-content: center;
|
| 743 |
+
gap: var(--go-ui-spacing-md);
|
| 744 |
+
margin-top: var(--go-ui-spacing-md);
|
| 745 |
+
padding: var(--go-ui-spacing-sm);
|
| 746 |
+
background-color: var(--go-ui-color-gray-10);
|
| 747 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 748 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.carouselButton {
|
| 752 |
+
background-color: var(--go-ui-color-white);
|
| 753 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 754 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 755 |
+
padding: var(--go-ui-spacing-sm);
|
| 756 |
+
transition: all var(--go-ui-duration-transition-fast) ease;
|
| 757 |
+
min-width: 40px;
|
| 758 |
+
height: 40px;
|
| 759 |
+
display: flex;
|
| 760 |
+
align-items: center;
|
| 761 |
+
justify-content: center;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.carouselButton:hover:not(:disabled) {
|
| 765 |
+
background-color: var(--go-ui-color-gray-20);
|
| 766 |
+
border-color: var(--go-ui-color-gray-40);
|
| 767 |
+
transform: translateY(-1px);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.carouselButton:disabled {
|
| 771 |
+
opacity: 0.5;
|
| 772 |
+
cursor: not-allowed;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.carouselIndicators {
|
| 776 |
+
display: flex;
|
| 777 |
+
gap: var(--go-ui-spacing-xs);
|
| 778 |
+
align-items: center;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.carouselIndicator {
|
| 782 |
+
background-color: var(--go-ui-color-gray-30);
|
| 783 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 784 |
+
border-radius: var(--go-ui-border-radius-sm);
|
| 785 |
+
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 786 |
+
font-size: var(--go-ui-font-size-sm);
|
| 787 |
+
font-weight: var(--go-ui-font-weight-medium);
|
| 788 |
+
color: var(--go-ui-color-gray-70);
|
| 789 |
+
cursor: pointer;
|
| 790 |
+
transition: all var(--go-ui-duration-transition-fast) ease;
|
| 791 |
+
min-width: 32px;
|
| 792 |
+
height: 32px;
|
| 793 |
+
display: flex;
|
| 794 |
+
align-items: center;
|
| 795 |
+
justify-content: center;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
.carouselIndicator:hover:not(:disabled) {
|
| 799 |
+
background-color: var(--go-ui-color-gray-40);
|
| 800 |
+
border-color: var(--go-ui-color-gray-50);
|
| 801 |
+
color: var(--go-ui-color-gray-90);
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.carouselIndicatorActive {
|
| 805 |
+
background-color: var(--go-ui-color-red-90);
|
| 806 |
+
border-color: var(--go-ui-color-red-90);
|
| 807 |
+
color: var(--go-ui-color-white);
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.carouselIndicatorActive:hover:not(:disabled) {
|
| 811 |
+
background-color: var(--go-ui-color-red-hover);
|
| 812 |
+
border-color: var(--go-ui-color-red-hover);
|
| 813 |
+
color: var(--go-ui-color-white);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.carouselIndicator:disabled {
|
| 817 |
+
opacity: 0.5;
|
| 818 |
+
cursor: not-allowed;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.imageCounter {
|
| 822 |
+
text-align: center;
|
| 823 |
+
margin-top: var(--go-ui-spacing-sm);
|
| 824 |
+
font-size: var(--go-ui-font-size-sm);
|
| 825 |
+
font-weight: var(--go-ui-font-weight-medium);
|
| 826 |
+
color: var(--go-ui-color-gray-70);
|
| 827 |
+
background-color: var(--go-ui-color-gray-10);
|
| 828 |
+
padding: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
|
| 829 |
+
border-radius: var(--go-ui-border-radius-sm);
|
| 830 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
/* Single image container */
|
| 834 |
+
.singleImageContainer {
|
| 835 |
+
position: relative;
|
| 836 |
+
width: 100%;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
/* View image button container */
|
| 840 |
+
.viewImageButtonContainer {
|
| 841 |
+
display: flex;
|
| 842 |
+
justify-content: center;
|
| 843 |
+
margin-top: var(--go-ui-spacing-md);
|
| 844 |
+
padding: var(--go-ui-spacing-sm);
|
| 845 |
+
background-color: var(--go-ui-color-gray-10);
|
| 846 |
+
border-radius: var(--go-ui-border-radius-md);
|
| 847 |
+
border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator);
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
/* Responsive adjustments for carousel */
|
| 851 |
+
@media (max-width: 768px) {
|
| 852 |
+
.carouselNavigation {
|
| 853 |
+
flex-direction: column;
|
| 854 |
+
gap: var(--go-ui-spacing-sm);
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.carouselIndicators {
|
| 858 |
+
order: -1;
|
| 859 |
+
margin-bottom: var(--go-ui-spacing-sm);
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.carouselButton {
|
| 863 |
+
min-width: 36px;
|
| 864 |
+
height: 36px;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.carouselIndicator {
|
| 868 |
+
min-width: 28px;
|
| 869 |
+
height: 28px;
|
| 870 |
+
font-size: var(--go-ui-font-size-xs);
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
.imageCounter {
|
| 874 |
+
font-size: var(--go-ui-font-size-xs);
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.viewImageButtonContainer {
|
| 878 |
+
margin-top: var(--go-ui-spacing-sm);
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
|
frontend/src/pages/UploadPage/UploadPage.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package-lock.json
CHANGED
|
@@ -6,6 +6,8 @@
|
|
| 6 |
"": {
|
| 7 |
"dependencies": {
|
| 8 |
"@types/react-simple-maps": "^3.0.6",
|
|
|
|
|
|
|
| 9 |
"react-simple-maps": "^3.0.0"
|
| 10 |
}
|
| 11 |
},
|
|
@@ -233,30 +235,43 @@
|
|
| 233 |
}
|
| 234 |
},
|
| 235 |
"node_modules/react": {
|
| 236 |
-
"version": "
|
| 237 |
-
"resolved": "https://registry.npmjs.org/react/-/react-
|
| 238 |
-
"integrity": "sha512-
|
| 239 |
"license": "MIT",
|
| 240 |
"peer": true,
|
| 241 |
"dependencies": {
|
| 242 |
-
"loose-envify": "^1.1.0"
|
|
|
|
|
|
|
| 243 |
},
|
| 244 |
"engines": {
|
| 245 |
"node": ">=0.10.0"
|
| 246 |
}
|
| 247 |
},
|
| 248 |
"node_modules/react-dom": {
|
| 249 |
-
"version": "
|
| 250 |
-
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-
|
| 251 |
-
"integrity": "sha512-
|
| 252 |
"license": "MIT",
|
| 253 |
"peer": true,
|
| 254 |
"dependencies": {
|
| 255 |
"loose-envify": "^1.1.0",
|
| 256 |
-
"
|
|
|
|
|
|
|
| 257 |
},
|
| 258 |
"peerDependencies": {
|
| 259 |
-
"react": "^
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
},
|
| 262 |
"node_modules/react-is": {
|
|
@@ -266,6 +281,16 @@
|
|
| 266 |
"license": "MIT",
|
| 267 |
"peer": true
|
| 268 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
"node_modules/react-simple-maps": {
|
| 270 |
"version": "3.0.0",
|
| 271 |
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
|
@@ -284,13 +309,14 @@
|
|
| 284 |
}
|
| 285 |
},
|
| 286 |
"node_modules/scheduler": {
|
| 287 |
-
"version": "0.
|
| 288 |
-
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.
|
| 289 |
-
"integrity": "sha512-
|
| 290 |
"license": "MIT",
|
| 291 |
"peer": true,
|
| 292 |
"dependencies": {
|
| 293 |
-
"loose-envify": "^1.1.0"
|
|
|
|
| 294 |
}
|
| 295 |
},
|
| 296 |
"node_modules/topojson-client": {
|
|
|
|
| 6 |
"": {
|
| 7 |
"dependencies": {
|
| 8 |
"@types/react-simple-maps": "^3.0.6",
|
| 9 |
+
"react-image-crop": "^11.0.10",
|
| 10 |
+
"react-simple-crop": "^1.0.2",
|
| 11 |
"react-simple-maps": "^3.0.0"
|
| 12 |
}
|
| 13 |
},
|
|
|
|
| 235 |
}
|
| 236 |
},
|
| 237 |
"node_modules/react": {
|
| 238 |
+
"version": "16.14.0",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
|
| 240 |
+
"integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
|
| 241 |
"license": "MIT",
|
| 242 |
"peer": true,
|
| 243 |
"dependencies": {
|
| 244 |
+
"loose-envify": "^1.1.0",
|
| 245 |
+
"object-assign": "^4.1.1",
|
| 246 |
+
"prop-types": "^15.6.2"
|
| 247 |
},
|
| 248 |
"engines": {
|
| 249 |
"node": ">=0.10.0"
|
| 250 |
}
|
| 251 |
},
|
| 252 |
"node_modules/react-dom": {
|
| 253 |
+
"version": "16.14.0",
|
| 254 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
|
| 255 |
+
"integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
|
| 256 |
"license": "MIT",
|
| 257 |
"peer": true,
|
| 258 |
"dependencies": {
|
| 259 |
"loose-envify": "^1.1.0",
|
| 260 |
+
"object-assign": "^4.1.1",
|
| 261 |
+
"prop-types": "^15.6.2",
|
| 262 |
+
"scheduler": "^0.19.1"
|
| 263 |
},
|
| 264 |
"peerDependencies": {
|
| 265 |
+
"react": "^16.14.0"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"node_modules/react-image-crop": {
|
| 269 |
+
"version": "11.0.10",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz",
|
| 271 |
+
"integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==",
|
| 272 |
+
"license": "ISC",
|
| 273 |
+
"peerDependencies": {
|
| 274 |
+
"react": ">=16.13.1"
|
| 275 |
}
|
| 276 |
},
|
| 277 |
"node_modules/react-is": {
|
|
|
|
| 281 |
"license": "MIT",
|
| 282 |
"peer": true
|
| 283 |
},
|
| 284 |
+
"node_modules/react-simple-crop": {
|
| 285 |
+
"version": "1.0.2",
|
| 286 |
+
"resolved": "https://registry.npmjs.org/react-simple-crop/-/react-simple-crop-1.0.2.tgz",
|
| 287 |
+
"integrity": "sha512-7cKyU8/M+qR1f0mi1Xk8hFw1SWi2F6kiG3rOnDXaq6BtAL0Kx7d5jOs4F26f4ii0xlnZw43K1WB+A6dKfVBW7Q==",
|
| 288 |
+
"license": "MIT",
|
| 289 |
+
"peerDependencies": {
|
| 290 |
+
"react": "^16.8.6",
|
| 291 |
+
"react-dom": "^16.8.6"
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
"node_modules/react-simple-maps": {
|
| 295 |
"version": "3.0.0",
|
| 296 |
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
|
|
|
| 309 |
}
|
| 310 |
},
|
| 311 |
"node_modules/scheduler": {
|
| 312 |
+
"version": "0.19.1",
|
| 313 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
|
| 314 |
+
"integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
|
| 315 |
"license": "MIT",
|
| 316 |
"peer": true,
|
| 317 |
"dependencies": {
|
| 318 |
+
"loose-envify": "^1.1.0",
|
| 319 |
+
"object-assign": "^4.1.1"
|
| 320 |
}
|
| 321 |
},
|
| 322 |
"node_modules/topojson-client": {
|
package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
{
|
| 2 |
"dependencies": {
|
| 3 |
"@types/react-simple-maps": "^3.0.6",
|
|
|
|
|
|
|
| 4 |
"react-simple-maps": "^3.0.0"
|
| 5 |
}
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"dependencies": {
|
| 3 |
"@types/react-simple-maps": "^3.0.6",
|
| 4 |
+
"react-image-crop": "^11.0.10",
|
| 5 |
+
"react-simple-crop": "^1.0.2",
|
| 6 |
"react-simple-maps": "^3.0.0"
|
| 7 |
}
|
| 8 |
}
|
py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add UNKNOWN values to lookup tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 0017
|
| 4 |
+
Revises: 0016
|
| 5 |
+
Create Date: 2024-01-01 00:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
# revision identifiers, used by Alembic.
|
| 12 |
+
revision = '0017'
|
| 13 |
+
down_revision = '0016'
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
def upgrade():
|
| 18 |
+
# Add UNKNOWN value to sources table
|
| 19 |
+
op.execute("""
|
| 20 |
+
INSERT INTO sources (s_code, label) VALUES ('UNKNOWN', 'Unknown')
|
| 21 |
+
ON CONFLICT (s_code) DO NOTHING
|
| 22 |
+
""")
|
| 23 |
+
|
| 24 |
+
# Add UNKNOWN value to event_types table
|
| 25 |
+
op.execute("""
|
| 26 |
+
INSERT INTO event_types (t_code, label) VALUES ('UNKNOWN', 'Unknown')
|
| 27 |
+
ON CONFLICT (t_code) DO NOTHING
|
| 28 |
+
""")
|
| 29 |
+
|
| 30 |
+
# Add UNKNOWN value to spatial_references table
|
| 31 |
+
op.execute("""
|
| 32 |
+
INSERT INTO spatial_references (epsg, srid, proj4, wkt) VALUES ('UNKNOWN', 'UNKNOWN', 'UNKNOWN', 'UNKNOWN')
|
| 33 |
+
ON CONFLICT (epsg) DO NOTHING
|
| 34 |
+
""")
|
| 35 |
+
|
| 36 |
+
def downgrade():
|
| 37 |
+
# Remove UNKNOWN values from lookup tables
|
| 38 |
+
op.execute("DELETE FROM sources WHERE s_code = 'UNKNOWN'")
|
| 39 |
+
op.execute("DELETE FROM event_types WHERE t_code = 'UNKNOWN'")
|
| 40 |
+
op.execute("DELETE FROM spatial_references WHERE epsg = 'UNKNOWN'")
|
py_backend/alembic/versions/0018_add_image_count_to_captions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add image_count to captions table
|
| 2 |
+
|
| 3 |
+
Revision ID: 0018
|
| 4 |
+
Revises: 0017
|
| 5 |
+
Create Date: 2024-01-01 00:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '0018'
|
| 14 |
+
down_revision = '0017'
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# Add image_count column to captions table with default value of 1
|
| 21 |
+
op.add_column('captions', sa.Column('image_count', sa.Integer(), nullable=True, server_default='1'))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def downgrade() -> None:
|
| 25 |
+
# Remove image_count column from captions table
|
| 26 |
+
op.drop_column('captions', 'image_count')
|
py_backend/app/crud.py
CHANGED
|
@@ -67,7 +67,7 @@ def get_images(db: Session):
|
|
| 67 |
db.query(models.Images)
|
| 68 |
.options(
|
| 69 |
joinedload(models.Images.countries),
|
| 70 |
-
joinedload(models.Images.captions),
|
| 71 |
)
|
| 72 |
.all()
|
| 73 |
)
|
|
@@ -78,13 +78,13 @@ def get_image(db: Session, image_id: str):
|
|
| 78 |
db.query(models.Images)
|
| 79 |
.options(
|
| 80 |
joinedload(models.Images.countries),
|
| 81 |
-
joinedload(models.Images.captions),
|
| 82 |
)
|
| 83 |
.filter(models.Images.image_id == image_id)
|
| 84 |
.first()
|
| 85 |
)
|
| 86 |
|
| 87 |
-
def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None):
|
| 88 |
print(f"Creating caption for image_id: {image_id}")
|
| 89 |
print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
|
| 90 |
print(f"Database session ID: {id(db)}")
|
|
@@ -109,7 +109,8 @@ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, t
|
|
| 109 |
schema_id=schema_id,
|
| 110 |
raw_json=raw_json,
|
| 111 |
generated=text,
|
| 112 |
-
edited=text
|
|
|
|
| 113 |
)
|
| 114 |
|
| 115 |
db.add(caption)
|
|
|
|
| 67 |
db.query(models.Images)
|
| 68 |
.options(
|
| 69 |
joinedload(models.Images.countries),
|
| 70 |
+
joinedload(models.Images.captions).joinedload(models.Captions.images),
|
| 71 |
)
|
| 72 |
.all()
|
| 73 |
)
|
|
|
|
| 78 |
db.query(models.Images)
|
| 79 |
.options(
|
| 80 |
joinedload(models.Images.countries),
|
| 81 |
+
joinedload(models.Images.captions).joinedload(models.Captions.images),
|
| 82 |
)
|
| 83 |
.filter(models.Images.image_id == image_id)
|
| 84 |
.first()
|
| 85 |
)
|
| 86 |
|
| 87 |
+
def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text, metadata=None, image_count=None):
|
| 88 |
print(f"Creating caption for image_id: {image_id}")
|
| 89 |
print(f"Caption data: title={title}, prompt={prompt}, model={model_code}")
|
| 90 |
print(f"Database session ID: {id(db)}")
|
|
|
|
| 109 |
schema_id=schema_id,
|
| 110 |
raw_json=raw_json,
|
| 111 |
generated=text,
|
| 112 |
+
edited=text,
|
| 113 |
+
image_count=image_count
|
| 114 |
)
|
| 115 |
|
| 116 |
db.add(caption)
|
py_backend/app/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from sqlalchemy import (
|
| 2 |
Column, String, DateTime, SmallInteger, Table, ForeignKey, Boolean,
|
| 3 |
-
CheckConstraint, UniqueConstraint, Text
|
| 4 |
)
|
| 5 |
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR, JSONB
|
| 6 |
from sqlalchemy.orm import relationship
|
|
@@ -160,6 +160,7 @@ class Captions(Base):
|
|
| 160 |
context = Column(SmallInteger)
|
| 161 |
usability = Column(SmallInteger)
|
| 162 |
starred = Column(Boolean, default=False)
|
|
|
|
| 163 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 164 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 165 |
|
|
|
|
| 1 |
from sqlalchemy import (
|
| 2 |
Column, String, DateTime, SmallInteger, Table, ForeignKey, Boolean,
|
| 3 |
+
CheckConstraint, UniqueConstraint, Text, Integer
|
| 4 |
)
|
| 5 |
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR, JSONB
|
| 6 |
from sqlalchemy.orm import relationship
|
|
|
|
| 160 |
context = Column(SmallInteger)
|
| 161 |
usability = Column(SmallInteger)
|
| 162 |
starred = Column(Boolean, default=False)
|
| 163 |
+
image_count = Column(Integer, nullable=True)
|
| 164 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 165 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 166 |
|
py_backend/app/routers/caption.py
CHANGED
|
@@ -124,6 +124,7 @@ async def create_caption(
|
|
| 124 |
print(f"Using metadata instructions: '{metadata_instructions[:100]}...'")
|
| 125 |
|
| 126 |
try:
|
|
|
|
| 127 |
if hasattr(storage, 's3') and settings.STORAGE_PROVIDER != "local":
|
| 128 |
response = storage.s3.get_object(
|
| 129 |
Bucket=settings.S3_BUCKET,
|
|
@@ -159,6 +160,9 @@ async def create_caption(
|
|
| 159 |
db_session=db,
|
| 160 |
)
|
| 161 |
|
|
|
|
|
|
|
|
|
|
| 162 |
# Get the raw response for validation
|
| 163 |
raw = result.get("raw_response", {})
|
| 164 |
|
|
@@ -184,6 +188,13 @@ async def create_caption(
|
|
| 184 |
# Use the actual model that was used, not the requested model_name
|
| 185 |
used_model = result.get("model", model_name) or "STUB_MODEL"
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
# Check if fallback was used
|
| 188 |
fallback_used = result.get("fallback_used", False)
|
| 189 |
original_model = result.get("original_model", None)
|
|
|
|
| 124 |
print(f"Using metadata instructions: '{metadata_instructions[:100]}...'")
|
| 125 |
|
| 126 |
try:
|
| 127 |
+
print(f"DEBUG: About to call VLM service with model_name: {model_name}")
|
| 128 |
if hasattr(storage, 's3') and settings.STORAGE_PROVIDER != "local":
|
| 129 |
response = storage.s3.get_object(
|
| 130 |
Bucket=settings.S3_BUCKET,
|
|
|
|
| 160 |
db_session=db,
|
| 161 |
)
|
| 162 |
|
| 163 |
+
print(f"DEBUG: VLM service result: {result}")
|
| 164 |
+
print(f"DEBUG: Result model field: {result.get('model', 'NOT_FOUND')}")
|
| 165 |
+
|
| 166 |
# Get the raw response for validation
|
| 167 |
raw = result.get("raw_response", {})
|
| 168 |
|
|
|
|
| 188 |
# Use the actual model that was used, not the requested model_name
|
| 189 |
used_model = result.get("model", model_name) or "STUB_MODEL"
|
| 190 |
|
| 191 |
+
# Ensure we never use 'random' as the model name in the database
|
| 192 |
+
if used_model == "random":
|
| 193 |
+
print(f"WARNING: VLM service returned 'random' as model name, using STUB_MODEL fallback")
|
| 194 |
+
used_model = "STUB_MODEL"
|
| 195 |
+
|
| 196 |
+
print(f"DEBUG: Final used_model for database: {used_model}")
|
| 197 |
+
|
| 198 |
# Check if fallback was used
|
| 199 |
fallback_used = result.get("fallback_used", False)
|
| 200 |
original_model = result.get("original_model", None)
|
py_backend/app/routers/upload.py
CHANGED
|
@@ -163,6 +163,101 @@ def list_images(db: Session = Depends(get_db)):
|
|
| 163 |
|
| 164 |
return result
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
@router.get("/{image_id}", response_model=schemas.ImageOut)
|
| 167 |
def get_image(image_id: str, db: Session = Depends(get_db)):
|
| 168 |
"""Get a single image by ID"""
|
|
@@ -176,11 +271,41 @@ def get_image(image_id: str, db: Session = Depends(get_db)):
|
|
| 176 |
if not uuid_pattern.match(image_id):
|
| 177 |
raise HTTPException(400, "Invalid image ID format")
|
| 178 |
|
| 179 |
-
img = crud.get_image(db, image_id)
|
| 180 |
if not img:
|
| 181 |
raise HTTPException(404, "Image not found")
|
| 182 |
|
| 183 |
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return schemas.ImageOut(**img_dict)
|
| 185 |
|
| 186 |
|
|
@@ -192,6 +317,8 @@ async def upload_image(
|
|
| 192 |
epsg: str = Form(default=""),
|
| 193 |
image_type: str = Form(default="crisis_map"),
|
| 194 |
file: UploadFile = Form(...),
|
|
|
|
|
|
|
| 195 |
# Drone-specific fields (optional)
|
| 196 |
center_lon: Optional[float] = Form(default=None),
|
| 197 |
center_lat: Optional[float] = Form(default=None),
|
|
@@ -301,12 +428,262 @@ async def upload_image(
|
|
| 301 |
except Exception as e:
|
| 302 |
url = f"/api/images/{img.image_id}/file"
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
img_dict = convert_image_to_dict(img, url)
|
| 305 |
# Add preprocessing info to the response
|
| 306 |
img_dict['preprocessing_info'] = preprocessing_info
|
| 307 |
result = schemas.ImageOut(**img_dict)
|
| 308 |
return result
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
@router.post("/copy", response_model=schemas.ImageOut)
|
| 311 |
async def copy_image_for_contribution(
|
| 312 |
request: CopyImageRequest,
|
|
|
|
| 163 |
|
| 164 |
return result
|
| 165 |
|
| 166 |
+
@router.get("/grouped", response_model=List[schemas.ImageOut])
|
| 167 |
+
def list_images_grouped(db: Session = Depends(get_db)):
|
| 168 |
+
"""Get images grouped by shared captions for multi-upload items"""
|
| 169 |
+
# Get all captions with their associated images
|
| 170 |
+
captions = crud.get_all_captions_with_images(db)
|
| 171 |
+
result = []
|
| 172 |
+
|
| 173 |
+
for caption in captions:
|
| 174 |
+
if not caption.images:
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
# Determine the effective image count for this caption
|
| 178 |
+
# Use caption.image_count if available and valid, otherwise infer from linked images
|
| 179 |
+
effective_image_count = caption.image_count if caption.image_count is not None and caption.image_count > 0 else len(caption.images)
|
| 180 |
+
|
| 181 |
+
if effective_image_count > 1:
|
| 182 |
+
# This is a multi-upload item, group them together
|
| 183 |
+
first_img = caption.images[0]
|
| 184 |
+
|
| 185 |
+
# Combine metadata from all images
|
| 186 |
+
combined_source = set()
|
| 187 |
+
combined_event_type = set()
|
| 188 |
+
combined_epsg = set()
|
| 189 |
+
|
| 190 |
+
for img in caption.images:
|
| 191 |
+
if img.source:
|
| 192 |
+
combined_source.add(img.source)
|
| 193 |
+
if img.event_type:
|
| 194 |
+
combined_event_type.add(img.event_type)
|
| 195 |
+
if img.epsg:
|
| 196 |
+
combined_epsg.add(img.epsg)
|
| 197 |
+
|
| 198 |
+
# Create a combined image dict using the first image as a template
|
| 199 |
+
img_dict = convert_image_to_dict(first_img, f"/api/images/{first_img.image_id}/file")
|
| 200 |
+
|
| 201 |
+
# Override with combined metadata
|
| 202 |
+
img_dict["source"] = ", ".join(sorted(list(combined_source))) if combined_source else "OTHER"
|
| 203 |
+
img_dict["event_type"] = ", ".join(sorted(list(combined_event_type))) if combined_event_type else "OTHER"
|
| 204 |
+
img_dict["epsg"] = ", ".join(sorted(list(combined_epsg))) if combined_epsg else "OTHER"
|
| 205 |
+
|
| 206 |
+
# Update countries to include all unique countries
|
| 207 |
+
all_countries = []
|
| 208 |
+
for img in caption.images:
|
| 209 |
+
for country in img.countries:
|
| 210 |
+
if not any(c["c_code"] == country.c_code for c in all_countries):
|
| 211 |
+
all_countries.append({"c_code": country.c_code, "label": country.label, "r_code": country.r_code})
|
| 212 |
+
img_dict["countries"] = all_countries
|
| 213 |
+
|
| 214 |
+
# Add all image IDs for reference
|
| 215 |
+
img_dict["all_image_ids"] = [str(img.image_id) for img in caption.images]
|
| 216 |
+
img_dict["image_count"] = effective_image_count # Use the effective count here
|
| 217 |
+
|
| 218 |
+
# Also ensure the caption-level fields are correctly set from the main caption
|
| 219 |
+
img_dict["title"] = caption.title
|
| 220 |
+
img_dict["prompt"] = caption.prompt
|
| 221 |
+
img_dict["model"] = caption.model
|
| 222 |
+
img_dict["schema_id"] = caption.schema_id
|
| 223 |
+
img_dict["raw_json"] = caption.raw_json
|
| 224 |
+
img_dict["generated"] = caption.generated
|
| 225 |
+
img_dict["edited"] = caption.edited
|
| 226 |
+
img_dict["accuracy"] = caption.accuracy
|
| 227 |
+
img_dict["context"] = caption.context
|
| 228 |
+
img_dict["usability"] = caption.usability
|
| 229 |
+
img_dict["starred"] = caption.starred
|
| 230 |
+
img_dict["created_at"] = caption.created_at
|
| 231 |
+
img_dict["updated_at"] = caption.updated_at
|
| 232 |
+
|
| 233 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 234 |
+
else:
|
| 235 |
+
# For single images, add them as usual
|
| 236 |
+
# Ensure image_count is explicitly 1 for single uploads
|
| 237 |
+
for img in caption.images: # Even for single, caption.images will be a list of 1
|
| 238 |
+
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
| 239 |
+
img_dict["all_image_ids"] = [str(img.image_id)]
|
| 240 |
+
img_dict["image_count"] = 1 # Explicitly set to 1
|
| 241 |
+
|
| 242 |
+
# Also ensure the caption-level fields are correctly set from the main caption
|
| 243 |
+
img_dict["title"] = caption.title
|
| 244 |
+
img_dict["prompt"] = caption.prompt
|
| 245 |
+
img_dict["model"] = caption.model
|
| 246 |
+
img_dict["schema_id"] = caption.schema_id
|
| 247 |
+
img_dict["raw_json"] = caption.raw_json
|
| 248 |
+
img_dict["generated"] = caption.generated
|
| 249 |
+
img_dict["edited"] = caption.edited
|
| 250 |
+
img_dict["accuracy"] = caption.accuracy
|
| 251 |
+
img_dict["context"] = caption.context
|
| 252 |
+
img_dict["usability"] = caption.usability
|
| 253 |
+
img_dict["starred"] = caption.starred
|
| 254 |
+
img_dict["created_at"] = caption.created_at
|
| 255 |
+
img_dict["updated_at"] = caption.updated_at
|
| 256 |
+
|
| 257 |
+
result.append(schemas.ImageOut(**img_dict))
|
| 258 |
+
|
| 259 |
+
return result
|
| 260 |
+
|
| 261 |
@router.get("/{image_id}", response_model=schemas.ImageOut)
|
| 262 |
def get_image(image_id: str, db: Session = Depends(get_db)):
|
| 263 |
"""Get a single image by ID"""
|
|
|
|
| 271 |
if not uuid_pattern.match(image_id):
|
| 272 |
raise HTTPException(400, "Invalid image ID format")
|
| 273 |
|
| 274 |
+
img = crud.get_image(db, image_id) # This loads captions
|
| 275 |
if not img:
|
| 276 |
raise HTTPException(404, "Image not found")
|
| 277 |
|
| 278 |
img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file")
|
| 279 |
+
|
| 280 |
+
# Enhance img_dict with multi-upload specific fields if applicable
|
| 281 |
+
if img.captions:
|
| 282 |
+
# Assuming an image is primarily associated with one "grouping" caption for multi-uploads
|
| 283 |
+
# We take the first caption and check its linked images
|
| 284 |
+
main_caption = img.captions[0]
|
| 285 |
+
|
| 286 |
+
# Refresh the caption to ensure its images relationship is loaded if not already
|
| 287 |
+
db.refresh(main_caption)
|
| 288 |
+
|
| 289 |
+
if main_caption.images:
|
| 290 |
+
all_linked_image_ids = [str(linked_img.image_id) for linked_img in main_caption.images]
|
| 291 |
+
effective_image_count = main_caption.image_count if main_caption.image_count is not None and main_caption.image_count > 0 else len(main_caption.images)
|
| 292 |
+
|
| 293 |
+
if effective_image_count > 1:
|
| 294 |
+
img_dict["all_image_ids"] = all_linked_image_ids
|
| 295 |
+
img_dict["image_count"] = effective_image_count
|
| 296 |
+
else:
|
| 297 |
+
# Even for single images, explicitly set image_count to 1
|
| 298 |
+
img_dict["image_count"] = 1
|
| 299 |
+
img_dict["all_image_ids"] = [str(img.image_id)] # Ensure it's an array for consistency
|
| 300 |
+
else:
|
| 301 |
+
# If caption has no linked images (shouldn't happen for valid data, but for robustness)
|
| 302 |
+
img_dict["image_count"] = 1
|
| 303 |
+
img_dict["all_image_ids"] = [str(img.image_id)]
|
| 304 |
+
else:
|
| 305 |
+
# If image has no captions, it's a single image by default
|
| 306 |
+
img_dict["image_count"] = 1
|
| 307 |
+
img_dict["all_image_ids"] = [str(img.image_id)]
|
| 308 |
+
|
| 309 |
return schemas.ImageOut(**img_dict)
|
| 310 |
|
| 311 |
|
|
|
|
| 317 |
epsg: str = Form(default=""),
|
| 318 |
image_type: str = Form(default="crisis_map"),
|
| 319 |
file: UploadFile = Form(...),
|
| 320 |
+
title: str = Form(default=""),
|
| 321 |
+
model_name: Optional[str] = Form(default=None),
|
| 322 |
# Drone-specific fields (optional)
|
| 323 |
center_lon: Optional[float] = Form(default=None),
|
| 324 |
center_lat: Optional[float] = Form(default=None),
|
|
|
|
| 428 |
except Exception as e:
|
| 429 |
url = f"/api/images/{img.image_id}/file"
|
| 430 |
|
| 431 |
+
# Create caption using VLM
|
| 432 |
+
prompt_obj = crud.get_active_prompt_by_image_type(db, image_type)
|
| 433 |
+
|
| 434 |
+
if not prompt_obj:
|
| 435 |
+
raise HTTPException(400, f"No active prompt found for image type '{image_type}'")
|
| 436 |
+
|
| 437 |
+
prompt_text = prompt_obj.label
|
| 438 |
+
metadata_instructions = prompt_obj.metadata_instructions or ""
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
from ..services.vlm_service import vlm_manager
|
| 442 |
+
result = await vlm_manager.generate_caption(
|
| 443 |
+
image_bytes=processed_content,
|
| 444 |
+
prompt=prompt_text,
|
| 445 |
+
metadata_instructions=metadata_instructions,
|
| 446 |
+
model_name=model_name,
|
| 447 |
+
db_session=db,
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
raw = result.get("raw_response", {})
|
| 451 |
+
text = result.get("caption", "")
|
| 452 |
+
metadata = result.get("metadata", {})
|
| 453 |
+
|
| 454 |
+
# Use the actual model that was used by the VLM service
|
| 455 |
+
actual_model = result.get("model", model_name)
|
| 456 |
+
|
| 457 |
+
# Ensure we never use 'random' as the model name in the database
|
| 458 |
+
final_model_name = actual_model if actual_model != "random" else "STUB_MODEL"
|
| 459 |
+
|
| 460 |
+
# Create caption linked to the image
|
| 461 |
+
caption = crud.create_caption(
|
| 462 |
+
db,
|
| 463 |
+
image_id=img.image_id,
|
| 464 |
+
title=title,
|
| 465 |
+
prompt=prompt_obj.p_code,
|
| 466 |
+
model_code=final_model_name,
|
| 467 |
+
raw_json=raw,
|
| 468 |
+
text=text,
|
| 469 |
+
metadata=metadata,
|
| 470 |
+
image_count=1
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
except Exception as e:
|
| 474 |
+
print(f"VLM caption generation failed: {str(e)}")
|
| 475 |
+
# Continue without caption if VLM fails
|
| 476 |
+
|
| 477 |
img_dict = convert_image_to_dict(img, url)
|
| 478 |
# Add preprocessing info to the response
|
| 479 |
img_dict['preprocessing_info'] = preprocessing_info
|
| 480 |
result = schemas.ImageOut(**img_dict)
|
| 481 |
return result
|
| 482 |
|
| 483 |
+
@router.post("/multi", response_model=schemas.ImageOut)
|
| 484 |
+
async def upload_multiple_images(
|
| 485 |
+
files: List[UploadFile] = Form(...),
|
| 486 |
+
source: Optional[str] = Form(default=None),
|
| 487 |
+
event_type: str = Form(default="OTHER"),
|
| 488 |
+
countries: str = Form(default=""),
|
| 489 |
+
epsg: str = Form(default=""),
|
| 490 |
+
image_type: str = Form(default="crisis_map"),
|
| 491 |
+
title: str = Form(...),
|
| 492 |
+
model_name: Optional[str] = Form(default=None),
|
| 493 |
+
# Drone-specific fields (optional)
|
| 494 |
+
center_lon: Optional[float] = Form(default=None),
|
| 495 |
+
center_lat: Optional[float] = Form(default=None),
|
| 496 |
+
amsl_m: Optional[float] = Form(default=None),
|
| 497 |
+
agl_m: Optional[float] = Form(default=None),
|
| 498 |
+
heading_deg: Optional[float] = Form(default=None),
|
| 499 |
+
yaw_deg: Optional[float] = Form(default=None),
|
| 500 |
+
pitch_deg: Optional[float] = Form(default=None),
|
| 501 |
+
roll_deg: Optional[float] = Form(default=None),
|
| 502 |
+
rtk_fix: Optional[bool] = Form(default=None),
|
| 503 |
+
std_h_m: Optional[float] = Form(default=None),
|
| 504 |
+
std_v_m: Optional[float] = Form(default=None),
|
| 505 |
+
db: Session = Depends(get_db)
|
| 506 |
+
):
|
| 507 |
+
"""Upload multiple images and create a single caption for all of them"""
|
| 508 |
+
|
| 509 |
+
if len(files) > 5:
|
| 510 |
+
raise HTTPException(400, "Maximum 5 images allowed")
|
| 511 |
+
|
| 512 |
+
if len(files) < 1:
|
| 513 |
+
raise HTTPException(400, "At least one image required")
|
| 514 |
+
|
| 515 |
+
countries_list = [c.strip() for c in countries.split(',') if c.strip()] if countries else []
|
| 516 |
+
|
| 517 |
+
if image_type == "drone_image":
|
| 518 |
+
if not event_type or event_type.strip() == "":
|
| 519 |
+
event_type = "OTHER"
|
| 520 |
+
if not epsg or epsg.strip() == "":
|
| 521 |
+
epsg = "OTHER"
|
| 522 |
+
else:
|
| 523 |
+
if not source or source.strip() == "":
|
| 524 |
+
source = "OTHER"
|
| 525 |
+
if not event_type or event_type.strip() == "":
|
| 526 |
+
event_type = "OTHER"
|
| 527 |
+
if not epsg or epsg.strip() == "":
|
| 528 |
+
epsg = "OTHER"
|
| 529 |
+
|
| 530 |
+
if not image_type or image_type.strip() == "":
|
| 531 |
+
image_type = "crisis_map"
|
| 532 |
+
|
| 533 |
+
if image_type != "drone_image":
|
| 534 |
+
center_lon = None
|
| 535 |
+
center_lat = None
|
| 536 |
+
amsl_m = None
|
| 537 |
+
agl_m = None
|
| 538 |
+
heading_deg = None
|
| 539 |
+
yaw_deg = None
|
| 540 |
+
pitch_deg = None
|
| 541 |
+
roll_deg = None
|
| 542 |
+
rtk_fix = None
|
| 543 |
+
std_h_m = None
|
| 544 |
+
std_v_m = None
|
| 545 |
+
|
| 546 |
+
uploaded_images = []
|
| 547 |
+
image_bytes_list = []
|
| 548 |
+
|
| 549 |
+
# Process each file
|
| 550 |
+
for file in files:
|
| 551 |
+
content = await file.read()
|
| 552 |
+
|
| 553 |
+
# Preprocess image if needed
|
| 554 |
+
try:
|
| 555 |
+
processed_content, processed_filename, mime_type = ImagePreprocessor.preprocess_image(
|
| 556 |
+
content,
|
| 557 |
+
file.filename,
|
| 558 |
+
target_format='PNG',
|
| 559 |
+
quality=95
|
| 560 |
+
)
|
| 561 |
+
except Exception as e:
|
| 562 |
+
print(f"Image preprocessing failed: {str(e)}")
|
| 563 |
+
processed_content = content
|
| 564 |
+
processed_filename = file.filename
|
| 565 |
+
mime_type = 'image/png'
|
| 566 |
+
|
| 567 |
+
sha = crud.hash_bytes(processed_content)
|
| 568 |
+
key = storage.upload_fileobj(io.BytesIO(processed_content), processed_filename)
|
| 569 |
+
|
| 570 |
+
# Create image record
|
| 571 |
+
img = crud.create_image(
|
| 572 |
+
db, source, event_type, key, sha, countries_list, epsg, image_type,
|
| 573 |
+
center_lon, center_lat, amsl_m, agl_m, heading_deg, yaw_deg, pitch_deg, roll_deg,
|
| 574 |
+
rtk_fix, std_h_m, std_v_m
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
uploaded_images.append(img)
|
| 578 |
+
image_bytes_list.append(processed_content)
|
| 579 |
+
|
| 580 |
+
# Get the first image for URL generation (they all share the same metadata)
|
| 581 |
+
first_img = uploaded_images[0]
|
| 582 |
+
|
| 583 |
+
try:
|
| 584 |
+
url = storage.get_object_url(first_img.file_key)
|
| 585 |
+
except Exception as e:
|
| 586 |
+
url = f"/api/images/{first_img.image_id}/file"
|
| 587 |
+
|
| 588 |
+
# Create caption for all images
|
| 589 |
+
# Use the model_name parameter from the request, or let VLM manager choose the best available model
|
| 590 |
+
prompt_obj = crud.get_active_prompt_by_image_type(db, image_type)
|
| 591 |
+
|
| 592 |
+
if not prompt_obj:
|
| 593 |
+
raise HTTPException(400, f"No active prompt found for image type '{image_type}'")
|
| 594 |
+
|
| 595 |
+
prompt_text = prompt_obj.label
|
| 596 |
+
metadata_instructions = prompt_obj.metadata_instructions or ""
|
| 597 |
+
|
| 598 |
+
# Add system instruction for multiple images
|
| 599 |
+
multi_image_instruction = f"\n\nIMPORTANT: You are analyzing {len(image_bytes_list)} images. Please provide a combined analysis that covers all images together. In your metadata section, provide separate metadata for each image:\n- 'title': ONE shared title for all images\n- 'metadata_images': an object containing individual metadata for each image:\n - 'image1': {{ 'source': 'data source', 'type': 'event type', 'countries': ['country codes'], 'epsg': 'spatial reference' }}\n - 'image2': {{ 'source': 'data source', 'type': 'event type', 'countries': ['country codes'], 'epsg': 'spatial reference' }}\n - etc. for each image\n\nEach image should have its own source, type, countries, and epsg values based on what that specific image shows."
|
| 600 |
+
metadata_instructions += multi_image_instruction
|
| 601 |
+
|
| 602 |
+
try:
|
| 603 |
+
from ..services.vlm_service import vlm_manager
|
| 604 |
+
result = await vlm_manager.generate_multi_image_caption(
|
| 605 |
+
image_bytes_list=image_bytes_list,
|
| 606 |
+
prompt=prompt_text,
|
| 607 |
+
metadata_instructions=metadata_instructions,
|
| 608 |
+
model_name=model_name,
|
| 609 |
+
db_session=db,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
raw = result.get("raw_response", {})
|
| 613 |
+
text = result.get("caption", "")
|
| 614 |
+
metadata = result.get("metadata", {})
|
| 615 |
+
|
| 616 |
+
# Use the actual model that was used by the VLM service
|
| 617 |
+
actual_model = result.get("model", model_name)
|
| 618 |
+
|
| 619 |
+
# Update individual image metadata if VLM provided it
|
| 620 |
+
metadata_images = metadata.get("metadata_images", {})
|
| 621 |
+
if metadata_images and isinstance(metadata_images, dict):
|
| 622 |
+
for i, img in enumerate(uploaded_images):
|
| 623 |
+
image_key = f"image{i+1}"
|
| 624 |
+
if image_key in metadata_images:
|
| 625 |
+
img_metadata = metadata_images[image_key]
|
| 626 |
+
if isinstance(img_metadata, dict):
|
| 627 |
+
# Update image with individual metadata
|
| 628 |
+
img.source = img_metadata.get("source", img.source)
|
| 629 |
+
img.event_type = img_metadata.get("type", img.event_type)
|
| 630 |
+
img.epsg = img_metadata.get("epsg", img.epsg)
|
| 631 |
+
img.countries = img_metadata.get("countries", img.countries)
|
| 632 |
+
|
| 633 |
+
# Ensure we never use 'random' as the model name in the database
|
| 634 |
+
final_model_name = actual_model if actual_model != "random" else "STUB_MODEL"
|
| 635 |
+
|
| 636 |
+
# Create caption linked to the first image
|
| 637 |
+
caption = crud.create_caption(
|
| 638 |
+
db,
|
| 639 |
+
image_id=first_img.image_id,
|
| 640 |
+
title=title,
|
| 641 |
+
prompt=prompt_obj.p_code,
|
| 642 |
+
model_code=final_model_name,
|
| 643 |
+
raw_json=raw,
|
| 644 |
+
text=text,
|
| 645 |
+
metadata=metadata,
|
| 646 |
+
image_count=len(image_bytes_list)
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
# Link caption to all images
|
| 650 |
+
for img in uploaded_images[1:]:
|
| 651 |
+
img.captions.append(caption)
|
| 652 |
+
|
| 653 |
+
db.commit()
|
| 654 |
+
|
| 655 |
+
except Exception as e:
|
| 656 |
+
print(f"VLM error: {e}")
|
| 657 |
+
# Create fallback caption
|
| 658 |
+
fallback_text = f"Analysis of {len(image_bytes_list)} images"
|
| 659 |
+
caption = crud.create_caption(
|
| 660 |
+
db,
|
| 661 |
+
image_id=first_img.image_id,
|
| 662 |
+
title=title,
|
| 663 |
+
prompt=prompt_obj.p_code,
|
| 664 |
+
model_code="FALLBACK",
|
| 665 |
+
raw_json={"error": str(e), "fallback": True},
|
| 666 |
+
text=fallback_text,
|
| 667 |
+
metadata={},
|
| 668 |
+
image_count=len(image_bytes_list)
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
# Link caption to all images
|
| 672 |
+
for img in uploaded_images[1:]:
|
| 673 |
+
img.captions.append(caption)
|
| 674 |
+
|
| 675 |
+
db.commit()
|
| 676 |
+
|
| 677 |
+
img_dict = convert_image_to_dict(first_img, url)
|
| 678 |
+
|
| 679 |
+
# Add all image IDs to the response for multi-image uploads
|
| 680 |
+
if len(uploaded_images) > 1:
|
| 681 |
+
img_dict["all_image_ids"] = [str(img.image_id) for img in uploaded_images]
|
| 682 |
+
img_dict["image_count"] = len(uploaded_images)
|
| 683 |
+
|
| 684 |
+
result = schemas.ImageOut(**img_dict)
|
| 685 |
+
return result
|
| 686 |
+
|
| 687 |
@router.post("/copy", response_model=schemas.ImageOut)
|
| 688 |
async def copy_image_for_contribution(
|
| 689 |
request: CopyImageRequest,
|
py_backend/app/schemas.py
CHANGED
|
@@ -57,6 +57,7 @@ class CaptionOut(BaseModel):
|
|
| 57 |
context: Optional[int] = None
|
| 58 |
usability: Optional[int] = None
|
| 59 |
starred: bool = False
|
|
|
|
| 60 |
created_at: Optional[datetime] = None
|
| 61 |
updated_at: Optional[datetime] = None
|
| 62 |
|
|
@@ -106,6 +107,10 @@ class ImageOut(BaseModel):
|
|
| 106 |
rtk_fix: Optional[bool] = None
|
| 107 |
std_h_m: Optional[float] = None
|
| 108 |
std_v_m: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
class Config:
|
| 111 |
from_attributes = True
|
|
|
|
| 57 |
context: Optional[int] = None
|
| 58 |
usability: Optional[int] = None
|
| 59 |
starred: bool = False
|
| 60 |
+
image_count: Optional[int] = None
|
| 61 |
created_at: Optional[datetime] = None
|
| 62 |
updated_at: Optional[datetime] = None
|
| 63 |
|
|
|
|
| 107 |
rtk_fix: Optional[bool] = None
|
| 108 |
std_h_m: Optional[float] = None
|
| 109 |
std_v_m: Optional[float] = None
|
| 110 |
+
|
| 111 |
+
# Multi-upload fields
|
| 112 |
+
all_image_ids: Optional[List[str]] = None
|
| 113 |
+
image_count: Optional[int] = None
|
| 114 |
|
| 115 |
class Config:
|
| 116 |
from_attributes = True
|
py_backend/app/services/gemini_service.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
-
from typing import Dict, Any
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
import re
|
|
@@ -73,4 +73,66 @@ class GeminiService(VLMService):
|
|
| 73 |
"recommended_actions": recommended_actions
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
|
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
import asyncio
|
| 4 |
import time
|
| 5 |
import re
|
|
|
|
| 73 |
"recommended_actions": recommended_actions
|
| 74 |
}
|
| 75 |
|
| 76 |
+
async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> Dict[str, Any]:
|
| 77 |
+
"""Generate caption for multiple images using Google Gemini Vision"""
|
| 78 |
+
instruction = prompt + "\n\n" + metadata_instructions
|
| 79 |
+
|
| 80 |
+
# Create content list with instruction and multiple images
|
| 81 |
+
content = [instruction]
|
| 82 |
+
for image_bytes in image_bytes_list:
|
| 83 |
+
image_part = {
|
| 84 |
+
"mime_type": "image/jpeg",
|
| 85 |
+
"data": image_bytes,
|
| 86 |
+
}
|
| 87 |
+
content.append(image_part)
|
| 88 |
+
|
| 89 |
+
start = time.time()
|
| 90 |
+
response = await asyncio.to_thread(self.model.generate_content, content)
|
| 91 |
+
elapsed = time.time() - start
|
| 92 |
+
|
| 93 |
+
content = getattr(response, "text", None) or ""
|
| 94 |
+
|
| 95 |
+
cleaned_content = content
|
| 96 |
+
if cleaned_content.startswith("```json"):
|
| 97 |
+
cleaned_content = re.sub(r"^```json\s*", "", cleaned_content)
|
| 98 |
+
cleaned_content = re.sub(r"\s*```$", "", cleaned_content)
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
parsed = json.loads(cleaned_content)
|
| 102 |
+
description = parsed.get("description", "")
|
| 103 |
+
analysis = parsed.get("analysis", "")
|
| 104 |
+
recommended_actions = parsed.get("recommended_actions", "")
|
| 105 |
+
metadata = parsed.get("metadata", {})
|
| 106 |
+
|
| 107 |
+
# Combine all three parts for backward compatibility
|
| 108 |
+
caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
|
| 109 |
+
|
| 110 |
+
if metadata.get("epsg"):
|
| 111 |
+
epsg_value = metadata["epsg"]
|
| 112 |
+
allowed_epsg = ["4326", "3857", "32617", "32633", "32634", "OTHER"]
|
| 113 |
+
if epsg_value not in allowed_epsg:
|
| 114 |
+
metadata["epsg"] = "OTHER"
|
| 115 |
+
except json.JSONDecodeError:
|
| 116 |
+
description = ""
|
| 117 |
+
analysis = content
|
| 118 |
+
recommended_actions = ""
|
| 119 |
+
caption_text = content
|
| 120 |
+
metadata = {}
|
| 121 |
+
|
| 122 |
+
raw_response: Dict[str, Any] = {
|
| 123 |
+
"model": self.model_id,
|
| 124 |
+
"image_count": len(image_bytes_list)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"caption": caption_text,
|
| 129 |
+
"metadata": metadata,
|
| 130 |
+
"confidence": None,
|
| 131 |
+
"processing_time": elapsed,
|
| 132 |
+
"raw_response": raw_response,
|
| 133 |
+
"description": description,
|
| 134 |
+
"analysis": analysis,
|
| 135 |
+
"recommended_actions": recommended_actions
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
|
py_backend/app/services/gpt4v_service.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
-
from typing import Dict, Any
|
| 3 |
import openai
|
| 4 |
import base64
|
| 5 |
import asyncio
|
|
@@ -90,5 +90,89 @@ class GPT4VService(VLMService):
|
|
| 90 |
"recommended_actions": recommended_actions
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
except Exception as e:
|
| 94 |
raise Exception(f"GPT-4 Vision API error: {str(e)}")
|
|
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
import openai
|
| 4 |
import base64
|
| 5 |
import asyncio
|
|
|
|
| 90 |
"recommended_actions": recommended_actions
|
| 91 |
}
|
| 92 |
|
| 93 |
+
except Exception as e:
|
| 94 |
+
raise Exception(f"GPT-4 Vision API error: {str(e)}")
|
| 95 |
+
|
| 96 |
+
async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> Dict[str, Any]:
|
| 97 |
+
"""Generate caption for multiple images using GPT-4 Vision"""
|
| 98 |
+
try:
|
| 99 |
+
# Create content array with text and multiple images
|
| 100 |
+
content = [{"type": "text", "text": prompt + "\n\n" + metadata_instructions}]
|
| 101 |
+
|
| 102 |
+
# Add each image to the content
|
| 103 |
+
for i, image_bytes in enumerate(image_bytes_list):
|
| 104 |
+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
| 105 |
+
content.append({
|
| 106 |
+
"type": "image_url",
|
| 107 |
+
"image_url": {
|
| 108 |
+
"url": f"data:image/jpeg;base64,{image_base64}"
|
| 109 |
+
}
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
response = await asyncio.to_thread(
|
| 113 |
+
self.client.chat.completions.create,
|
| 114 |
+
model="gpt-4o",
|
| 115 |
+
messages=[
|
| 116 |
+
{
|
| 117 |
+
"role": "user",
|
| 118 |
+
"content": content
|
| 119 |
+
}
|
| 120 |
+
],
|
| 121 |
+
max_tokens=1200 # Increased for multiple images
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
content = response.choices[0].message.content
|
| 125 |
+
|
| 126 |
+
cleaned_content = content.strip()
|
| 127 |
+
if cleaned_content.startswith("```json"):
|
| 128 |
+
cleaned_content = cleaned_content[7:]
|
| 129 |
+
if cleaned_content.endswith("```"):
|
| 130 |
+
cleaned_content = cleaned_content[:-3]
|
| 131 |
+
cleaned_content = cleaned_content.strip()
|
| 132 |
+
|
| 133 |
+
metadata = {}
|
| 134 |
+
try:
|
| 135 |
+
metadata = json.loads(cleaned_content)
|
| 136 |
+
except json.JSONDecodeError:
|
| 137 |
+
if "```json" in content:
|
| 138 |
+
json_start = content.find("```json") + 7
|
| 139 |
+
json_end = content.find("```", json_start)
|
| 140 |
+
if json_end > json_start:
|
| 141 |
+
json_str = content[json_start:json_end].strip()
|
| 142 |
+
try:
|
| 143 |
+
metadata = json.loads(json_str)
|
| 144 |
+
except json.JSONDecodeError as e:
|
| 145 |
+
print(f"JSON parse error: {e}")
|
| 146 |
+
else:
|
| 147 |
+
import re
|
| 148 |
+
json_match = re.search(r'\{[^{}]*"metadata"[^{}]*\{[^{}]*\}', content)
|
| 149 |
+
if json_match:
|
| 150 |
+
try:
|
| 151 |
+
metadata = json.loads(json_match.group())
|
| 152 |
+
except json.JSONDecodeError:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
# Extract the three parts from the parsed JSON
|
| 156 |
+
description = metadata.get("description", "")
|
| 157 |
+
analysis = metadata.get("analysis", "")
|
| 158 |
+
recommended_actions = metadata.get("recommended_actions", "")
|
| 159 |
+
|
| 160 |
+
# Combine all three parts for backward compatibility
|
| 161 |
+
combined_content = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"caption": combined_content,
|
| 165 |
+
"raw_response": {
|
| 166 |
+
"content": content,
|
| 167 |
+
"metadata": metadata,
|
| 168 |
+
"extracted_metadata": metadata,
|
| 169 |
+
"image_count": len(image_bytes_list)
|
| 170 |
+
},
|
| 171 |
+
"metadata": metadata,
|
| 172 |
+
"description": description,
|
| 173 |
+
"analysis": analysis,
|
| 174 |
+
"recommended_actions": recommended_actions
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
except Exception as e:
|
| 178 |
raise Exception(f"GPT-4 Vision API error: {str(e)}")
|
py_backend/app/services/huggingface_service.py
CHANGED
|
@@ -1,33 +1,35 @@
|
|
| 1 |
# services/huggingface_service.py
|
| 2 |
from .vlm_service import VLMService, ModelType
|
| 3 |
-
from typing import Dict, Any
|
| 4 |
import aiohttp
|
| 5 |
import base64
|
| 6 |
-
import json
|
| 7 |
import time
|
| 8 |
import re
|
|
|
|
| 9 |
import imghdr
|
| 10 |
|
| 11 |
|
| 12 |
class HuggingFaceService(VLMService):
|
| 13 |
"""
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
so you can call many VLMs with the same payload shape.
|
| 17 |
"""
|
| 18 |
-
|
| 19 |
-
def __init__(self, api_key: str, model_id: str
|
| 20 |
-
super().__init__(
|
| 21 |
self.api_key = api_key
|
| 22 |
self.model_id = model_id
|
| 23 |
-
self.providers_url =
|
|
|
|
| 24 |
|
| 25 |
def _guess_mime(self, image_bytes: bytes) -> str:
|
| 26 |
kind = imghdr.what(None, h=image_bytes)
|
|
|
|
|
|
|
| 27 |
if kind == "png":
|
| 28 |
return "image/png"
|
| 29 |
-
if kind
|
| 30 |
-
return "image/
|
| 31 |
if kind == "webp":
|
| 32 |
return "image/webp"
|
| 33 |
return "image/jpeg"
|
|
@@ -127,55 +129,15 @@ class HuggingFaceService(VLMService):
|
|
| 127 |
description = parsed.get("description", "")
|
| 128 |
analysis = parsed.get("analysis", cleaned)
|
| 129 |
recommended_actions = parsed.get("recommended_actions", "")
|
| 130 |
-
metadata = parsed.get("metadata", {})
|
| 131 |
-
except json.JSONDecodeError:
|
| 132 |
-
# If not JSON, try to extract metadata from GLM thinking format
|
| 133 |
-
if "<think>" in cleaned:
|
| 134 |
-
analysis, metadata = self._extract_glm_metadata(cleaned)
|
| 135 |
-
else:
|
| 136 |
-
# Fallback: try to extract any structured information
|
| 137 |
-
analysis = cleaned
|
| 138 |
-
metadata = {}
|
| 139 |
-
|
| 140 |
-
# Combine all three parts for backward compatibility
|
| 141 |
-
caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
|
| 142 |
-
|
| 143 |
-
# Validate and clean metadata fields with sensible defaults
|
| 144 |
-
if isinstance(metadata, dict):
|
| 145 |
-
# Clean EPSG - default to "OTHER" if not in allowed values
|
| 146 |
-
if metadata.get("epsg"):
|
| 147 |
-
allowed = {"4326", "3857", "32617", "32633", "32634", "OTHER"}
|
| 148 |
-
if str(metadata["epsg"]) not in allowed:
|
| 149 |
-
metadata["epsg"] = "OTHER"
|
| 150 |
-
else:
|
| 151 |
-
metadata["epsg"] = "OTHER" # Default when missing
|
| 152 |
|
| 153 |
-
#
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
metadata["source"] = "OTHER"
|
| 158 |
-
else:
|
| 159 |
-
metadata["source"] = "OTHER"
|
| 160 |
-
|
| 161 |
-
# Clean event type - default to "OTHER" if not recognized
|
| 162 |
-
if metadata.get("type"):
|
| 163 |
-
allowed_types = {"BIOLOGICAL_EMERGENCY", "CHEMICAL_EMERGENCY", "CIVIL_UNREST",
|
| 164 |
-
"COLD_WAVE", "COMPLEX_EMERGENCY", "CYCLONE", "DROUGHT", "EARTHQUAKE",
|
| 165 |
-
"EPIDEMIC", "FIRE", "FLOOD", "FLOOD_INSECURITY", "HEAT_WAVE",
|
| 166 |
-
"INSECT_INFESTATION", "LANDSLIDE", "OTHER", "PLUVIAL",
|
| 167 |
-
"POPULATION_MOVEMENT", "RADIOLOGICAL_EMERGENCY", "STORM",
|
| 168 |
-
"TRANSPORTATION_EMERGENCY", "TSUNAMI", "VOLCANIC_ERUPTION"}
|
| 169 |
-
if str(metadata["type"]).upper() not in allowed_types:
|
| 170 |
-
metadata["type"] = "OTHER"
|
| 171 |
-
else:
|
| 172 |
-
metadata["type"] = "OTHER"
|
| 173 |
-
|
| 174 |
-
# Ensure countries is always a list
|
| 175 |
-
if not metadata.get("countries") or not isinstance(metadata.get("countries"), list):
|
| 176 |
-
metadata["countries"] = []
|
| 177 |
|
| 178 |
elapsed = time.time() - start_time
|
|
|
|
| 179 |
return {
|
| 180 |
"caption": caption_text,
|
| 181 |
"metadata": metadata,
|
|
@@ -183,70 +145,136 @@ class HuggingFaceService(VLMService):
|
|
| 183 |
"processing_time": elapsed,
|
| 184 |
"raw_response": {
|
| 185 |
"model": self.model_id,
|
| 186 |
-
"
|
| 187 |
-
"
|
| 188 |
},
|
| 189 |
"description": description,
|
| 190 |
"analysis": analysis,
|
| 191 |
"recommended_actions": recommended_actions
|
| 192 |
}
|
| 193 |
|
| 194 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
"""
|
| 196 |
-
|
| 197 |
-
Focus on extracting what we can and rely on defaults for the rest.
|
| 198 |
"""
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
#
|
| 211 |
-
|
| 212 |
-
if source_match:
|
| 213 |
-
metadata["source"] = source_match.group(1).upper()
|
| 214 |
-
|
| 215 |
-
# Type: Look for disaster types
|
| 216 |
-
disaster_types = ["EARTHQUAKE", "FLOOD", "CYCLONE", "DROUGHT", "FIRE", "STORM", "TSUNAMI", "VOLCANIC"]
|
| 217 |
-
for disaster_type in disaster_types:
|
| 218 |
-
if re.search(rf'\b{disaster_type}\b', content, re.IGNORECASE):
|
| 219 |
-
metadata["type"] = disaster_type
|
| 220 |
-
break
|
| 221 |
-
|
| 222 |
-
# Countries: Look for 2-letter country codes
|
| 223 |
-
country_matches = re.findall(r'\b([A-Z]{2})\b', content)
|
| 224 |
-
valid_countries = []
|
| 225 |
-
for match in country_matches:
|
| 226 |
-
# Basic validation - exclude common false positives
|
| 227 |
-
if match not in ["SO", "IS", "OR", "IN", "ON", "TO", "OF", "AT", "BY", "NO", "GO", "UP", "US"]:
|
| 228 |
-
valid_countries.append(match)
|
| 229 |
-
if valid_countries:
|
| 230 |
-
metadata["countries"] = list(set(valid_countries)) # Remove duplicates
|
| 231 |
-
|
| 232 |
-
# EPSG: Look for 4-digit numbers that could be EPSG codes
|
| 233 |
-
epsg_match = re.search(r'\b(4326|3857|32617|32633|32634)\b', content)
|
| 234 |
-
if epsg_match:
|
| 235 |
-
metadata["epsg"] = epsg_match.group(1)
|
| 236 |
|
| 237 |
-
#
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
-
|
| 246 |
-
if not
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
# --- Generic Model Wrapper for Dynamic Registration ---
|
|
|
|
| 1 |
# services/huggingface_service.py
|
| 2 |
from .vlm_service import VLMService, ModelType
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
import aiohttp
|
| 5 |
import base64
|
|
|
|
| 6 |
import time
|
| 7 |
import re
|
| 8 |
+
import json
|
| 9 |
import imghdr
|
| 10 |
|
| 11 |
|
| 12 |
class HuggingFaceService(VLMService):
|
| 13 |
"""
|
| 14 |
+
HuggingFace Inference Providers service implementation.
|
| 15 |
+
Supports OpenAI-compatible APIs.
|
|
|
|
| 16 |
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, api_key: str, model_id: str, providers_url: str):
|
| 19 |
+
super().__init__("HuggingFace", ModelType.HUGGINGFACE)
|
| 20 |
self.api_key = api_key
|
| 21 |
self.model_id = model_id
|
| 22 |
+
self.providers_url = providers_url
|
| 23 |
+
self.model_name = model_id
|
| 24 |
|
| 25 |
def _guess_mime(self, image_bytes: bytes) -> str:
|
| 26 |
kind = imghdr.what(None, h=image_bytes)
|
| 27 |
+
if kind == "jpeg":
|
| 28 |
+
return "image/jpeg"
|
| 29 |
if kind == "png":
|
| 30 |
return "image/png"
|
| 31 |
+
if kind == "gif":
|
| 32 |
+
return "image/gif"
|
| 33 |
if kind == "webp":
|
| 34 |
return "image/webp"
|
| 35 |
return "image/jpeg"
|
|
|
|
| 129 |
description = parsed.get("description", "")
|
| 130 |
analysis = parsed.get("analysis", cleaned)
|
| 131 |
recommended_actions = parsed.get("recommended_actions", "")
|
| 132 |
+
metadata = parsed.get("metadata", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
# Combine all three parts for backward compatibility
|
| 135 |
+
caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
|
| 136 |
+
except json.JSONDecodeError:
|
| 137 |
+
caption_text = cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
elapsed = time.time() - start_time
|
| 140 |
+
|
| 141 |
return {
|
| 142 |
"caption": caption_text,
|
| 143 |
"metadata": metadata,
|
|
|
|
| 145 |
"processing_time": elapsed,
|
| 146 |
"raw_response": {
|
| 147 |
"model": self.model_id,
|
| 148 |
+
"content": content,
|
| 149 |
+
"parsed": parsed if 'parsed' in locals() else None
|
| 150 |
},
|
| 151 |
"description": description,
|
| 152 |
"analysis": analysis,
|
| 153 |
"recommended_actions": recommended_actions
|
| 154 |
}
|
| 155 |
|
| 156 |
+
async def generate_multi_image_caption(
|
| 157 |
+
self,
|
| 158 |
+
image_bytes_list: List[bytes],
|
| 159 |
+
prompt: str,
|
| 160 |
+
metadata_instructions: str = "",
|
| 161 |
+
) -> Dict[str, Any]:
|
| 162 |
"""
|
| 163 |
+
Generate caption for multiple images using HF Inference Providers (OpenAI-style).
|
|
|
|
| 164 |
"""
|
| 165 |
+
start_time = time.time()
|
| 166 |
+
|
| 167 |
+
instruction = (prompt or "").strip()
|
| 168 |
+
if metadata_instructions:
|
| 169 |
+
instruction += "\n\n" + metadata_instructions.strip()
|
| 170 |
+
|
| 171 |
+
headers = {
|
| 172 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 173 |
+
"Content-Type": "application/json",
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
# Create content array with text and multiple images
|
| 177 |
+
content = [{"type": "text", "text": instruction}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
# Add each image to the content
|
| 180 |
+
for image_bytes in image_bytes_list:
|
| 181 |
+
mime = self._guess_mime(image_bytes)
|
| 182 |
+
data_url = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
|
| 183 |
+
content.append({"type": "image_url", "image_url": {"url": data_url}})
|
| 184 |
+
|
| 185 |
+
# OpenAI-compatible chat payload with one text + multiple image blocks.
|
| 186 |
+
payload = {
|
| 187 |
+
"model": self.model_id,
|
| 188 |
+
"messages": [
|
| 189 |
+
{
|
| 190 |
+
"role": "user",
|
| 191 |
+
"content": content,
|
| 192 |
+
}
|
| 193 |
+
],
|
| 194 |
+
"max_tokens": 800, # Increased for multiple images
|
| 195 |
+
"temperature": 0.2,
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
async with aiohttp.ClientSession() as session:
|
| 200 |
+
async with session.post(
|
| 201 |
+
self.providers_url,
|
| 202 |
+
headers=headers,
|
| 203 |
+
json=payload,
|
| 204 |
+
timeout=aiohttp.ClientTimeout(total=180),
|
| 205 |
+
) as resp:
|
| 206 |
+
raw_text = await resp.text()
|
| 207 |
+
if resp.status != 200:
|
| 208 |
+
# Any non-200 status - throw generic error for fallback handling
|
| 209 |
+
raise Exception(f"MODEL_UNAVAILABLE: {self.model_name} is currently unavailable (HTTP {resp.status}). Switching to another model.")
|
| 210 |
+
result = await resp.json()
|
| 211 |
+
except Exception as e:
|
| 212 |
+
if "MODEL_UNAVAILABLE" in str(e):
|
| 213 |
+
raise # Re-raise model unavailable exceptions as-is
|
| 214 |
+
# Catch any other errors (network, timeout, parsing, etc.) and treat as model unavailable
|
| 215 |
+
raise Exception(f"MODEL_UNAVAILABLE: {self.model_name} is currently unavailable due to an error. Switching to another model.")
|
| 216 |
+
|
| 217 |
+
# Extract model output (string or list-of-blocks)
|
| 218 |
+
message = (result.get("choices") or [{}])[0].get("message", {})
|
| 219 |
+
content = message.get("content", "")
|
| 220 |
|
| 221 |
+
# GLM models sometimes put content in reasoning_content field
|
| 222 |
+
if not content and message.get("reasoning_content"):
|
| 223 |
+
content = message.get("reasoning_content", "")
|
| 224 |
+
|
| 225 |
+
if isinstance(content, list):
|
| 226 |
+
# Some providers may return a list of output blocks (e.g., {"type":"output_text","text":...})
|
| 227 |
+
parts = []
|
| 228 |
+
for block in content:
|
| 229 |
+
if isinstance(block, dict):
|
| 230 |
+
parts.append(block.get("text") or block.get("content") or "")
|
| 231 |
+
else:
|
| 232 |
+
parts.append(str(block))
|
| 233 |
+
content = "\n".join([p for p in parts if p])
|
| 234 |
+
|
| 235 |
+
caption = content or ""
|
| 236 |
+
cleaned = caption.strip()
|
| 237 |
+
|
| 238 |
+
# Strip accidental fenced JSON
|
| 239 |
+
if cleaned.startswith("```json"):
|
| 240 |
+
cleaned = re.sub(r"^```json\s*", "", cleaned)
|
| 241 |
+
cleaned = re.sub(r"\s*```$", "", cleaned)
|
| 242 |
+
|
| 243 |
+
# Best-effort JSON protocol
|
| 244 |
+
metadata = {}
|
| 245 |
+
description = ""
|
| 246 |
+
analysis = cleaned
|
| 247 |
+
recommended_actions = ""
|
| 248 |
|
| 249 |
+
try:
|
| 250 |
+
parsed = json.loads(cleaned)
|
| 251 |
+
description = parsed.get("description", "")
|
| 252 |
+
analysis = parsed.get("analysis", cleaned)
|
| 253 |
+
recommended_actions = parsed.get("recommended_actions", "")
|
| 254 |
+
metadata = parsed.get("metadata", {})
|
| 255 |
+
|
| 256 |
+
# Combine all three parts for backward compatibility
|
| 257 |
+
caption_text = f"Description: {description}\n\nAnalysis: {analysis}\n\nRecommended Actions: {recommended_actions}"
|
| 258 |
+
except json.JSONDecodeError:
|
| 259 |
+
caption_text = cleaned
|
| 260 |
+
|
| 261 |
+
elapsed = time.time() - start_time
|
| 262 |
+
|
| 263 |
+
return {
|
| 264 |
+
"caption": caption_text,
|
| 265 |
+
"metadata": metadata,
|
| 266 |
+
"confidence": None,
|
| 267 |
+
"processing_time": elapsed,
|
| 268 |
+
"raw_response": {
|
| 269 |
+
"model": self.model_id,
|
| 270 |
+
"content": content,
|
| 271 |
+
"parsed": parsed if 'parsed' in locals() else None,
|
| 272 |
+
"image_count": len(image_bytes_list)
|
| 273 |
+
},
|
| 274 |
+
"description": description,
|
| 275 |
+
"analysis": analysis,
|
| 276 |
+
"recommended_actions": recommended_actions
|
| 277 |
+
}
|
| 278 |
|
| 279 |
|
| 280 |
# --- Generic Model Wrapper for Dynamic Registration ---
|
py_backend/app/services/stub_vlm_service.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
-
from typing import Dict, Any
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
class StubVLMService(VLMService):
|
|
@@ -33,4 +33,35 @@ class StubVLMService(VLMService):
|
|
| 33 |
"countries": [],
|
| 34 |
"epsg": "OTHER"
|
| 35 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
|
|
|
| 1 |
from .vlm_service import VLMService, ModelType
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
class StubVLMService(VLMService):
|
|
|
|
| 33 |
"countries": [],
|
| 34 |
"epsg": "OTHER"
|
| 35 |
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "") -> dict:
|
| 39 |
+
"""Generate a stub caption for multiple images for testing purposes."""
|
| 40 |
+
caption = f"This is a stub multi-image caption for testing. Number of images: {len(image_bytes_list)}. Total size: {sum(len(img) for img in image_bytes_list)} bytes. Prompt: {prompt[:50]}..."
|
| 41 |
+
|
| 42 |
+
# Create individual metadata for each image
|
| 43 |
+
metadata_images = {}
|
| 44 |
+
for i, img_bytes in enumerate(image_bytes_list):
|
| 45 |
+
metadata_images[f"image{i+1}"] = {
|
| 46 |
+
"source": "OTHER",
|
| 47 |
+
"type": "OTHER",
|
| 48 |
+
"countries": [],
|
| 49 |
+
"epsg": "OTHER"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Return data in the format expected by schema validator
|
| 53 |
+
return {
|
| 54 |
+
"caption": caption,
|
| 55 |
+
"raw_response": {
|
| 56 |
+
"stub": True,
|
| 57 |
+
"analysis": caption,
|
| 58 |
+
"metadata": {
|
| 59 |
+
"title": "Stub Multi-Image Generated Title",
|
| 60 |
+
"metadata_images": metadata_images
|
| 61 |
+
}
|
| 62 |
+
},
|
| 63 |
+
"metadata": {
|
| 64 |
+
"title": "Stub Multi-Image Generated Title",
|
| 65 |
+
"metadata_images": metadata_images
|
| 66 |
+
}
|
| 67 |
}
|
py_backend/app/services/vlm_service.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from abc import ABC, abstractmethod
|
| 2 |
-
from typing import Dict, Any, Optional
|
| 3 |
import logging
|
| 4 |
from enum import Enum
|
| 5 |
|
|
@@ -65,6 +65,112 @@ class VLMServiceManager:
|
|
| 65 |
async def generate_caption(self, image_bytes: bytes, prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
|
| 66 |
"""Generate caption using the specified model or fallback to available service."""
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
service = None
|
| 69 |
if model_name and model_name != "random":
|
| 70 |
service = self.services.get(model_name)
|
|
@@ -118,83 +224,33 @@ class VLMServiceManager:
|
|
| 118 |
service = random.choice(available_services)
|
| 119 |
print(f"Randomly selected service: {service.model_name}")
|
| 120 |
else:
|
| 121 |
-
# Fallback to any service
|
| 122 |
service = next(iter(self.services.values()))
|
| 123 |
print(f"Using fallback service: {service.model_name}")
|
| 124 |
|
| 125 |
if not service:
|
| 126 |
-
raise
|
| 127 |
-
|
| 128 |
-
# Track attempts to avoid infinite loops
|
| 129 |
-
attempted_services = set()
|
| 130 |
-
max_attempts = len(self.services)
|
| 131 |
-
|
| 132 |
-
while len(attempted_services) < max_attempts:
|
| 133 |
-
try:
|
| 134 |
-
result = await service.generate_caption(image_bytes, prompt, metadata_instructions)
|
| 135 |
-
if isinstance(result, dict):
|
| 136 |
-
result["model"] = service.model_name
|
| 137 |
-
result["fallback_used"] = len(attempted_services) > 0
|
| 138 |
-
if len(attempted_services) > 0:
|
| 139 |
-
result["original_model"] = model_name
|
| 140 |
-
result["fallback_reason"] = "model_unavailable"
|
| 141 |
-
return result
|
| 142 |
-
except Exception as e:
|
| 143 |
-
error_str = str(e)
|
| 144 |
-
print(f"Error with service {service.model_name}: {error_str}")
|
| 145 |
-
|
| 146 |
-
# Check if it's a model unavailable error (any type of error)
|
| 147 |
-
if "MODEL_UNAVAILABLE" in error_str:
|
| 148 |
-
attempted_services.add(service.model_name)
|
| 149 |
-
print(f"Model {service.model_name} is unavailable, trying another service...")
|
| 150 |
-
|
| 151 |
-
# Try to find another available service
|
| 152 |
-
if db_session:
|
| 153 |
-
try:
|
| 154 |
-
from .. import crud
|
| 155 |
-
available_models = crud.get_models(db_session)
|
| 156 |
-
available_model_codes = [m.m_code for m in available_models if m.is_available]
|
| 157 |
-
|
| 158 |
-
# Find next available service that hasn't been attempted
|
| 159 |
-
for next_service in self.services.values():
|
| 160 |
-
if (next_service.model_name in available_model_codes and
|
| 161 |
-
next_service.model_name not in attempted_services):
|
| 162 |
-
service = next_service
|
| 163 |
-
print(f"Switching to fallback service: {service.model_name}")
|
| 164 |
-
break
|
| 165 |
-
else:
|
| 166 |
-
# No more available services, use any untried service
|
| 167 |
-
for next_service in self.services.values():
|
| 168 |
-
if next_service.model_name not in attempted_services:
|
| 169 |
-
service = next_service
|
| 170 |
-
print(f"Using untried service as fallback: {service.model_name}")
|
| 171 |
-
break
|
| 172 |
-
except Exception as db_error:
|
| 173 |
-
print(f"Error checking database availability: {db_error}")
|
| 174 |
-
# Fallback to any untried service
|
| 175 |
-
for next_service in self.services.values():
|
| 176 |
-
if next_service.model_name not in attempted_services:
|
| 177 |
-
service = next_service
|
| 178 |
-
print(f"Using untried service as fallback: {service.model_name}")
|
| 179 |
-
break
|
| 180 |
-
else:
|
| 181 |
-
# No database session, use any untried service
|
| 182 |
-
for next_service in self.services.values():
|
| 183 |
-
if next_service.model_name not in attempted_services:
|
| 184 |
-
service = next_service
|
| 185 |
-
print(f"Using untried service as fallback: {service.model_name}")
|
| 186 |
-
break
|
| 187 |
-
|
| 188 |
-
if not service:
|
| 189 |
-
raise ValueError("No more VLM services available after model failures")
|
| 190 |
-
|
| 191 |
-
continue # Try again with new service
|
| 192 |
-
else:
|
| 193 |
-
# Non-model-unavailable error, don't retry
|
| 194 |
-
print(f"Non-model-unavailable error, not retrying: {error_str}")
|
| 195 |
-
raise
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
vlm_manager = VLMServiceManager()
|
|
|
|
| 1 |
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, Any, Optional, List
|
| 3 |
import logging
|
| 4 |
from enum import Enum
|
| 5 |
|
|
|
|
| 65 |
async def generate_caption(self, image_bytes: bytes, prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
|
| 66 |
"""Generate caption using the specified model or fallback to available service."""
|
| 67 |
|
| 68 |
+
service = None
|
| 69 |
+
if model_name and model_name != "random":
|
| 70 |
+
service = self.services.get(model_name)
|
| 71 |
+
if not service:
|
| 72 |
+
print(f"Model '{model_name}' not found, using fallback")
|
| 73 |
+
|
| 74 |
+
if not service and self.services:
|
| 75 |
+
# If random is selected or no specific model, choose a random available service
|
| 76 |
+
if db_session:
|
| 77 |
+
# Check database availability for random selection
|
| 78 |
+
try:
|
| 79 |
+
from .. import crud
|
| 80 |
+
available_models = crud.get_models(db_session)
|
| 81 |
+
available_model_codes = [m.m_code for m in available_models if m.is_available]
|
| 82 |
+
|
| 83 |
+
print(f"DEBUG: Available models in database: {available_model_codes}")
|
| 84 |
+
print(f"DEBUG: Registered services: {list(self.services.keys())}")
|
| 85 |
+
|
| 86 |
+
# Filter services to only those marked as available in database
|
| 87 |
+
available_services = [s for s in self.services.values() if s.model_name in available_model_codes]
|
| 88 |
+
|
| 89 |
+
print(f"DEBUG: Available services after filtering: {[s.model_name for s in available_services]}")
|
| 90 |
+
print(f"DEBUG: Service model names: {[s.model_name for s in self.services.values()]}")
|
| 91 |
+
print(f"DEBUG: Database model codes: {available_model_codes}")
|
| 92 |
+
print(f"DEBUG: Intersection check: {[s.model_name for s in self.services.values() if s.model_name in available_model_codes]}")
|
| 93 |
+
|
| 94 |
+
if available_services:
|
| 95 |
+
import random
|
| 96 |
+
import time
|
| 97 |
+
# Use current time as seed for better randomness
|
| 98 |
+
random.seed(int(time.time() * 1000000) % 1000000)
|
| 99 |
+
|
| 100 |
+
# Shuffle the list first for better randomization
|
| 101 |
+
shuffled_services = available_services.copy()
|
| 102 |
+
random.shuffle(shuffled_services)
|
| 103 |
+
|
| 104 |
+
service = shuffled_services[0]
|
| 105 |
+
print(f"Randomly selected service: {service.model_name} (from {len(available_services)} available)")
|
| 106 |
+
print(f"DEBUG: All available services were: {[s.model_name for s in available_services]}")
|
| 107 |
+
print(f"DEBUG: Shuffled order: {[s.model_name for s in shuffled_services]}")
|
| 108 |
+
else:
|
| 109 |
+
# Fallback to any available service, prioritizing STUB_MODEL
|
| 110 |
+
print(f"WARNING: No services found in database intersection, using fallback")
|
| 111 |
+
if "STUB_MODEL" in self.services:
|
| 112 |
+
service = self.services["STUB_MODEL"]
|
| 113 |
+
print(f"Using STUB_MODEL fallback service: {service.model_name}")
|
| 114 |
+
else:
|
| 115 |
+
service = next(iter(self.services.values()))
|
| 116 |
+
print(f"Using first available fallback service: {service.model_name}")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"Error checking database availability: {e}, using fallback")
|
| 119 |
+
if "STUB_MODEL" in self.services:
|
| 120 |
+
service = self.services["STUB_MODEL"]
|
| 121 |
+
print(f"Using STUB_MODEL fallback service: {service.model_name}")
|
| 122 |
+
else:
|
| 123 |
+
service = next(iter(self.services.values()))
|
| 124 |
+
print(f"Using fallback service: {service.model_name}")
|
| 125 |
+
else:
|
| 126 |
+
# No database session, use service property
|
| 127 |
+
available_services = [s for s in self.services.values() if s.is_available]
|
| 128 |
+
if available_services:
|
| 129 |
+
import random
|
| 130 |
+
service = random.choice(available_services)
|
| 131 |
+
print(f"Randomly selected service: {service.model_name}")
|
| 132 |
+
else:
|
| 133 |
+
# Fallback to any available service, prioritizing STUB_MODEL
|
| 134 |
+
if "STUB_MODEL" in self.services:
|
| 135 |
+
service = self.services["STUB_MODEL"]
|
| 136 |
+
print(f"Using STUB_MODEL fallback service: {service.model_name}")
|
| 137 |
+
else:
|
| 138 |
+
service = next(iter(self.services.values()))
|
| 139 |
+
print(f"Using fallback service: {service.model_name}")
|
| 140 |
+
|
| 141 |
+
if not service:
|
| 142 |
+
raise Exception("No VLM service available")
|
| 143 |
+
|
| 144 |
+
print(f"DEBUG: Selected service for caption generation: {service.model_name}")
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
print(f"DEBUG: Calling service {service.model_name} for caption generation")
|
| 148 |
+
result = await service.generate_caption(image_bytes, prompt, metadata_instructions)
|
| 149 |
+
result["model"] = service.model_name
|
| 150 |
+
print(f"DEBUG: Service {service.model_name} returned result with model: {result.get('model', 'NOT_FOUND')}")
|
| 151 |
+
return result
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"Error with {service.model_name}: {e}")
|
| 154 |
+
# Try other services
|
| 155 |
+
for other_service in self.services.values():
|
| 156 |
+
if other_service != service:
|
| 157 |
+
try:
|
| 158 |
+
result = await other_service.generate_caption(image_bytes, prompt, metadata_instructions)
|
| 159 |
+
result["model"] = other_service.model_name
|
| 160 |
+
result["fallback_used"] = True
|
| 161 |
+
result["original_model"] = service.model_name
|
| 162 |
+
result["fallback_reason"] = str(e)
|
| 163 |
+
return result
|
| 164 |
+
except Exception as fallback_error:
|
| 165 |
+
print(f"Fallback service {other_service.model_name} also failed: {fallback_error}")
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
# All services failed
|
| 169 |
+
raise Exception(f"All VLM services failed. Last error: {str(e)}")
|
| 170 |
+
|
| 171 |
+
async def generate_multi_image_caption(self, image_bytes_list: List[bytes], prompt: str, metadata_instructions: str = "", model_name: str | None = None, db_session = None) -> dict:
|
| 172 |
+
"""Generate caption for multiple images using the specified model or fallback to available service."""
|
| 173 |
+
|
| 174 |
service = None
|
| 175 |
if model_name and model_name != "random":
|
| 176 |
service = self.services.get(model_name)
|
|
|
|
| 224 |
service = random.choice(available_services)
|
| 225 |
print(f"Randomly selected service: {service.model_name}")
|
| 226 |
else:
|
|
|
|
| 227 |
service = next(iter(self.services.values()))
|
| 228 |
print(f"Using fallback service: {service.model_name}")
|
| 229 |
|
| 230 |
if not service:
|
| 231 |
+
raise Exception("No VLM service available")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
+
try:
|
| 234 |
+
result = await service.generate_multi_image_caption(image_bytes_list, prompt, metadata_instructions)
|
| 235 |
+
result["model"] = service.model_name
|
| 236 |
+
return result
|
| 237 |
+
except Exception as e:
|
| 238 |
+
print(f"Error with {service.model_name}: {e}")
|
| 239 |
+
# Try other services
|
| 240 |
+
for other_service in self.services.values():
|
| 241 |
+
if other_service != service:
|
| 242 |
+
try:
|
| 243 |
+
result = await other_service.generate_multi_image_caption(image_bytes_list, prompt, metadata_instructions)
|
| 244 |
+
result["model"] = other_service.model_name
|
| 245 |
+
result["fallback_used"] = True
|
| 246 |
+
result["original_model"] = service.model_name
|
| 247 |
+
result["fallback_reason"] = str(e)
|
| 248 |
+
return result
|
| 249 |
+
except Exception as fallback_error:
|
| 250 |
+
print(f"Fallback service {other_service.model_name} also failed: {fallback_error}")
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
# All services failed
|
| 254 |
+
raise Exception(f"All VLM services failed. Last error: {str(e)}")
|
| 255 |
|
| 256 |
vlm_manager = VLMServiceManager()
|
py_backend/fix_image_counts.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
sys.path.append('.')
|
| 6 |
+
|
| 7 |
+
from app.database import SessionLocal
|
| 8 |
+
from app import crud, models
|
| 9 |
+
|
| 10 |
+
def fix_image_counts():
|
| 11 |
+
"""Update image_count for all existing captions based on their linked images"""
|
| 12 |
+
db = SessionLocal()
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
print("Starting image_count fix for existing multi-uploads...")
|
| 16 |
+
|
| 17 |
+
# Get all captions with their linked images
|
| 18 |
+
captions = crud.get_all_captions_with_images(db)
|
| 19 |
+
print(f"Found {len(captions)} captions to process")
|
| 20 |
+
|
| 21 |
+
updated_count = 0
|
| 22 |
+
skipped_count = 0
|
| 23 |
+
|
| 24 |
+
for caption in captions:
|
| 25 |
+
# Skip if image_count is already set correctly
|
| 26 |
+
if caption.image_count is not None and caption.image_count > 0:
|
| 27 |
+
if caption.image_count == len(caption.images):
|
| 28 |
+
skipped_count += 1
|
| 29 |
+
continue
|
| 30 |
+
|
| 31 |
+
# Calculate the correct image count
|
| 32 |
+
correct_image_count = len(caption.images)
|
| 33 |
+
|
| 34 |
+
if correct_image_count == 0:
|
| 35 |
+
print(f"Warning: Caption {caption.caption_id} has no linked images")
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
# Update the image_count
|
| 39 |
+
old_count = caption.image_count
|
| 40 |
+
caption.image_count = correct_image_count
|
| 41 |
+
|
| 42 |
+
print(f"Updated caption {caption.caption_id}: {old_count} -> {correct_image_count} (title: '{caption.title}')")
|
| 43 |
+
updated_count += 1
|
| 44 |
+
|
| 45 |
+
# Commit all changes
|
| 46 |
+
db.commit()
|
| 47 |
+
print(f"\nDatabase update complete!")
|
| 48 |
+
print(f"Updated: {updated_count} captions")
|
| 49 |
+
print(f"Skipped: {skipped_count} captions (already correct)")
|
| 50 |
+
|
| 51 |
+
# Verify the changes
|
| 52 |
+
print("\nVerifying changes...")
|
| 53 |
+
captions_after = crud.get_all_captions_with_images(db)
|
| 54 |
+
|
| 55 |
+
multi_uploads = [c for c in captions_after if c.image_count and c.image_count > 1]
|
| 56 |
+
single_uploads = [c for c in captions_after if c.image_count == 1]
|
| 57 |
+
null_counts = [c for c in captions_after if c.image_count is None or c.image_count == 0]
|
| 58 |
+
|
| 59 |
+
print(f"Multi-uploads (image_count > 1): {len(multi_uploads)}")
|
| 60 |
+
print(f"Single uploads (image_count = 1): {len(single_uploads)}")
|
| 61 |
+
print(f"Captions with null/zero image_count: {len(null_counts)}")
|
| 62 |
+
|
| 63 |
+
if null_counts:
|
| 64 |
+
print("\nCaptions still with null/zero image_count:")
|
| 65 |
+
for c in null_counts[:5]: # Show first 5
|
| 66 |
+
print(f" - {c.caption_id}: {len(c.images)} linked images, image_count={c.image_count}")
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"Error: {e}")
|
| 70 |
+
import traceback
|
| 71 |
+
traceback.print_exc()
|
| 72 |
+
db.rollback()
|
| 73 |
+
finally:
|
| 74 |
+
db.close()
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
fix_image_counts()
|