SCGR commited on
Commit
351d460
·
1 Parent(s): 29b7d98

multi upload

Browse files
Files changed (32) hide show
  1. frontend/package-lock.json +171 -0
  2. frontend/package.json +3 -0
  3. frontend/src/components/FilterBar.tsx +47 -12
  4. frontend/src/components/index.ts +7 -0
  5. frontend/src/components/upload/FileUploadSection.tsx +169 -0
  6. frontend/src/components/upload/GeneratedTextSection.tsx +102 -0
  7. frontend/src/components/upload/ImagePreviewSection.tsx +154 -0
  8. frontend/src/components/upload/MetadataFormSection.tsx +451 -0
  9. frontend/src/components/upload/ModalComponents.tsx +380 -0
  10. frontend/src/components/upload/RatingSection.tsx +63 -0
  11. frontend/src/components/upload/index.ts +18 -0
  12. frontend/src/contexts/FilterContext.tsx +6 -0
  13. frontend/src/pages/ExplorePage/ExplorePage.tsx +232 -191
  14. frontend/src/pages/MapDetailsPage/MapDetailPage.module.css +185 -0
  15. frontend/src/pages/MapDetailsPage/MapDetailPage.tsx +413 -257
  16. frontend/src/pages/UploadPage/UploadPage.module.css +219 -0
  17. frontend/src/pages/UploadPage/UploadPage.tsx +0 -0
  18. package-lock.json +39 -13
  19. package.json +2 -0
  20. py_backend/alembic/versions/0017_add_unknown_values_to_lookup_tables.py +40 -0
  21. py_backend/alembic/versions/0018_add_image_count_to_captions.py +26 -0
  22. py_backend/app/crud.py +5 -4
  23. py_backend/app/models.py +2 -1
  24. py_backend/app/routers/caption.py +11 -0
  25. py_backend/app/routers/upload.py +378 -1
  26. py_backend/app/schemas.py +5 -0
  27. py_backend/app/services/gemini_service.py +63 -1
  28. py_backend/app/services/gpt4v_service.py +85 -1
  29. py_backend/app/services/huggingface_service.py +138 -110
  30. py_backend/app/services/stub_vlm_service.py +32 -1
  31. py_backend/app/services/vlm_service.py +130 -74
  32. 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, Reference Examples, Clear Filters */}
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: 5 Filter Bars */}
60
- <div className="flex flex-wrap items-center gap-4">
61
- <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2">
 
 
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="bg-white/20 backdrop-blur-sm rounded-md p-2">
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="bg-white/20 backdrop-blur-sm rounded-md p-2">
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="bg-white/20 backdrop-blur-sm rounded-md p-2">
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="bg-white/20 backdrop-blur-sm rounded-md p-2">
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
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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/captions/legacy')
77
  .then(r => {
78
  if (!r.ok) {
79
- console.error('ExplorePage: Legacy endpoint failed, trying regular images endpoint');
80
- // Fallback to regular images endpoint
81
- return fetch('/api/images').then(r2 => {
82
  if (!r2.ok) {
83
- throw new Error(`HTTP ${r2.status}: ${r2.statusText}`);
 
 
 
 
 
 
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
- const matchesSource = !srcFilter || c.source === srcFilter;
199
- const matchesCategory = !catFilter || c.event_type === catFilter;
 
 
 
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 && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples;
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
- const crisisImagePromises = crisisMaps.map(async (image, index) => {
 
 
 
234
  try {
235
- const response = await fetch(`/api/images/${image.image_id}/file`);
236
- if (!response.ok) throw new Error(`Failed to fetch image ${image.image_id}`);
 
 
 
 
 
 
 
 
237
 
238
  const blob = await response.blob();
239
- const fileExtension = image.file_key.split('.').pop() || 'jpg';
240
- const fileName = `${String(index + 1).padStart(4, '0')}.${fileExtension}`;
241
 
242
  crisisImagesFolder.file(fileName, blob);
243
- return { success: true, fileName, image };
244
  } catch (error) {
245
- console.error(`Failed to process image ${image.image_id}:`, error);
246
- return { success: false, fileName: '', image };
247
  }
248
  });
249
 
250
- const crisisImageResults = await Promise.all(crisisImagePromises);
251
- const successfulCrisisImages = crisisImageResults.filter(result => result.success);
252
 
 
253
  if (mode === 'fine-tuning') {
254
- const crisisTrainData: any[] = [];
255
- const crisisTestData: any[] = [];
256
- const crisisValData: any[] = [];
257
-
258
- const crisisImagesBySource = new Map<string, any[]>();
259
- successfulCrisisImages.forEach(result => {
260
- const source = result.image.source || 'unknown';
261
- if (!crisisImagesBySource.has(source)) {
262
- crisisImagesBySource.set(source, []);
263
- }
264
- crisisImagesBySource.get(source)!.push(result);
265
- });
266
-
267
- crisisImagesBySource.forEach((images, _source) => {
268
- const totalImages = images.length;
269
- const trainCount = Math.floor(totalImages * (80 / 100));
270
- const testCount = Math.floor(totalImages * (10 / 100));
271
-
272
- const shuffledImages = [...images].sort(() => Math.random() - 0.5);
273
-
274
- crisisTrainData.push(...shuffledImages.slice(0, trainCount).map(result => ({
275
- image: `images/${result.fileName}`,
276
- caption: result.image.edited || result.image.generated || '',
277
- metadata: {
278
- image_id: result.image.image_id,
279
- title: result.image.title,
280
- source: result.image.source,
281
- event_type: result.image.event_type,
282
- image_type: result.image.image_type,
283
- countries: result.image.countries,
284
- starred: result.image.starred
285
- }
286
- })));
287
-
288
- crisisTestData.push(...shuffledImages.slice(trainCount, trainCount + testCount).map(result => ({
289
- image: `images/${result.fileName}`,
290
- caption: result.image.edited || result.image.generated || '',
291
- metadata: {
292
- image_id: result.image.image_id,
293
- title: result.image.title,
294
- source: result.image.source,
295
- event_type: result.image.event_type,
296
- image_type: result.image.image_type,
297
- countries: result.image.countries,
298
- starred: result.image.starred
299
- }
300
- })));
301
-
302
- crisisValData.push(...shuffledImages.slice(trainCount + testCount).map(result => ({
303
- image: `images/${result.fileName}`,
304
- caption: result.image.edited || result.image.generated || '',
305
- metadata: {
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
- successfulCrisisImages.forEach((result, index) => {
 
325
  const jsonData = {
326
- image: `images/${result.fileName}`,
327
- caption: result.image.edited || result.image.generated || '',
328
  metadata: {
329
- image_id: result.image.image_id,
330
- title: result.image.title,
331
- source: result.image.source,
332
- event_type: result.image.event_type,
333
- image_type: result.image.image_type,
334
- countries: result.image.countries,
335
- starred: result.image.starred
 
336
  }
337
  };
338
 
339
  if (crisisFolder) {
340
- crisisFolder.file(`${String(index + 1).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
 
 
 
 
341
  }
342
- });
 
 
343
  }
344
  }
345
  }
@@ -350,115 +370,120 @@ export default function ExplorePage() {
350
  const droneImagesFolder = droneFolder?.folder('images');
351
 
352
  if (droneImagesFolder) {
353
- const droneImagePromises = droneImages.map(async (image, index) => {
 
 
 
354
  try {
355
- const response = await fetch(`/api/images/${image.image_id}/file`);
356
- if (!response.ok) throw new Error(`Failed to fetch image ${image.image_id}`);
 
 
 
 
 
 
 
 
357
 
358
  const blob = await response.blob();
359
- const fileExtension = image.file_key.split('.').pop() || 'jpg';
360
- const fileName = `${String(index + 1).padStart(4, '0')}.${fileExtension}`;
361
 
362
  droneImagesFolder.file(fileName, blob);
363
- return { success: true, fileName, image };
364
  } catch (error) {
365
- console.error(`Failed to process image ${image.image_id}:`, error);
366
- return { success: false, fileName: '', image };
367
  }
368
  });
369
 
370
- const droneImageResults = await Promise.all(droneImagePromises);
371
- const successfulDroneImages = droneImageResults.filter(result => result.success);
372
 
 
373
  if (mode === 'fine-tuning') {
374
- const droneTrainData: any[] = [];
375
- const droneTestData: any[] = [];
376
- const droneValData: any[] = [];
377
-
378
- const droneImagesByEventType = new Map<string, any[]>();
379
- successfulDroneImages.forEach(result => {
380
- const eventType = result.image.event_type || 'unknown';
381
- if (!droneImagesByEventType.has(eventType)) {
382
- droneImagesByEventType.set(eventType, []);
383
- }
384
- droneImagesByEventType.get(eventType)!.push(result);
385
- });
386
-
387
- droneImagesByEventType.forEach((images, _eventType) => {
388
- const totalImages = images.length;
389
- const trainCount = Math.floor(totalImages * (80 / 100));
390
- const testCount = Math.floor(totalImages * (10 / 100));
391
-
392
- const shuffledImages = [...images].sort(() => Math.random() - 0.5);
393
-
394
- droneTrainData.push(...shuffledImages.slice(0, trainCount).map(result => ({
395
- image: `images/${result.fileName}`,
396
- caption: result.image.edited || result.image.generated || '',
397
- metadata: {
398
- image_id: result.image.image_id,
399
- title: result.image.title,
400
- source: result.image.source,
401
- event_type: result.image.event_type,
402
- image_type: result.image.image_type,
403
- countries: result.image.countries,
404
- starred: result.image.starred
405
- }
406
- })));
407
-
408
- droneTestData.push(...shuffledImages.slice(trainCount, trainCount + testCount).map(result => ({
409
- image: `images/${result.fileName}`,
410
- caption: result.image.edited || result.image.generated || '',
411
- metadata: {
412
- image_id: result.image.image_id,
413
- title: result.image.title,
414
- source: result.image.source,
415
- event_type: result.image.event_type,
416
- image_type: result.image.image_type,
417
- countries: result.image.countries,
418
- starred: result.image.starred
419
- }
420
- })));
421
-
422
- droneValData.push(...shuffledImages.slice(trainCount + testCount).map(result => ({
423
- image: `images/${result.fileName}`,
424
- caption: result.image.edited || result.image.generated || '',
425
- metadata: {
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
- successfulDroneImages.forEach((result, index) => {
 
444
  const jsonData = {
445
- image: `images/${result.fileName}`,
446
- caption: result.image.edited || result.image.generated || '',
447
  metadata: {
448
- image_id: result.image.image_id,
449
- title: result.image.title,
450
- source: result.image.source,
451
- event_type: result.image.event_type,
452
- image_type: result.image.image_type,
453
- countries: result.image.countries,
454
- starred: result.image.starred
 
455
  }
456
  };
457
 
458
  if (droneFolder) {
459
- droneFolder.file(`${String(index + 1).padStart(4, '0')}.json`, JSON.stringify(jsonData, null, 2));
 
 
 
 
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
- {sources.find(s => s.s_code === c.source)?.label || c.source}
 
 
 
682
  </span>
683
  )}
684
  <span className={styles.metadataTagType}>
685
- {types.find(t => t.t_code === c.event_type)?.label || c.event_type}
 
 
 
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
- const [showContributeConfirm, setShowContributeConfirm] = useState(false);
 
 
 
 
 
 
 
 
 
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
- const [isGenerating, setIsGenerating] = useState(false);
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
- return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples ? map : null;
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
- setIsGenerating(true);
 
 
 
 
 
 
 
 
620
 
 
 
 
 
 
621
  try {
622
- const res = await fetch('/api/contribute/from-url', {
623
- method: 'POST',
624
- headers: { 'Content-Type': 'application/json' },
625
- body: JSON.stringify({
626
- url: map.image_url,
627
- source: map.source,
628
- event_type: map.event_type,
629
- epsg: map.epsg,
630
- image_type: map.image_type,
631
- countries: map.countries.map(c => c.c_code),
632
- }),
633
- });
634
-
635
- if (!res.ok) {
636
- const errorData = await res.json();
637
- throw new Error(errorData.error || 'Failed to create contribution');
638
- }
639
-
640
- const json = await res.json();
641
- const newId = json.image_id as string;
642
-
643
- const modelName = localStorage.getItem('selectedVlmModel');
644
- const capRes = await fetch(`/api/images/${newId}/caption`, {
645
- method: 'POST',
646
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
647
- body: new URLSearchParams({
648
- title: 'Generated Caption',
649
- prompt: 'DEFAULT_CRISIS_MAP',
650
- ...(modelName && { model_name: modelName }),
651
- }),
652
- });
653
-
654
- if (!capRes.ok) {
655
- const errorData = await capRes.json();
656
- throw new Error(errorData.error || 'Failed to generate caption');
657
  }
658
-
659
- // Wait for the VLM response to be processed
660
- const captionData = await capRes.json();
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
- setIsGenerating(false);
672
  }
673
- };
674
 
675
- const handleContributeCancel = () => {
676
- setShowContributeConfirm(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
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.image_id,
 
 
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
- const response = await fetch(`/api/images/${map.image_id}/file`);
710
- if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`);
 
 
711
 
712
- const blob = await response.blob();
713
- const fileExtension = map.file_key.split('.').pop() || 'jpg';
714
- const fileName = `0001.${fileExtension}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
 
716
- crisisImagesFolder.file(fileName, blob);
 
 
717
 
718
  if (mode === 'fine-tuning') {
719
  const trainData: any[] = [];
720
  const testData: any[] = [];
721
  const valData: any[] = [];
722
 
723
- if (String(map?.image_type) === 'crisis_map') {
724
- const random = Math.random();
725
- if (random < trainSplit / 100) {
726
- trainData.push(createImageData(map, '0001'));
727
- } else if (random < (trainSplit + testSplit) / 100) {
728
- testData.push(createImageData(map, '0001'));
729
- } else {
730
- valData.push(createImageData(map, '0001'));
731
- }
732
- } else if (String(map?.image_type) === 'drone_image') {
733
- const random = Math.random();
734
- if (random < trainSplit / 100) {
735
- trainData.push(createImageData(map, '0001'));
736
- } else if (random < (trainSplit + testSplit) / 100) {
737
- testData.push(createImageData(map, '0001'));
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: `images/${fileName}`,
751
  caption: map.edited || map.generated || '',
752
  metadata: {
753
- image_id: map.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.image_id,
 
 
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.image_id,
 
 
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
- {/* Search and Filters */}
1021
- <div className="mb-6 space-y-4">
1022
- {/* Layer 1: Search, Reference Examples, Clear Filters */}
1023
- <div className="flex flex-wrap items-center gap-4">
1024
- <Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2 flex-1 min-w-[300px]">
1025
- <TextInput
1026
- name="search"
1027
- placeholder="Search examples..."
1028
- value={search}
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
- {filteredMap.image_url ? (
1136
- <img
1137
- src={filteredMap.image_url}
1138
- alt={filteredMap.file_key}
1139
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  ) : (
1141
- <div className={styles.imagePlaceholder}>
1142
- No image available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1143
  </div>
1144
  )}
1145
  </div>
1146
- </Container>
1147
-
1148
- {/* Details Section */}
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
- {isGenerating ? (
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
- {/* Contribute Confirmation Modal */}
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": "18.3.1",
237
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
238
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
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": "18.3.1",
250
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
251
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
252
  "license": "MIT",
253
  "peer": true,
254
  "dependencies": {
255
  "loose-envify": "^1.1.0",
256
- "scheduler": "^0.23.2"
 
 
257
  },
258
  "peerDependencies": {
259
- "react": "^18.3.1"
 
 
 
 
 
 
 
 
 
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.23.2",
288
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
289
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
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
- Hugging Face Inference Providers (OpenAI-compatible) service.
15
- This class speaks to https://router.huggingface.co/v1/chat/completions
16
- so you can call many VLMs with the same payload shape.
17
  """
18
-
19
- def __init__(self, api_key: str, model_id: str = "Qwen/Qwen2.5-VL-7B-Instruct"):
20
- super().__init__(f"HF_{model_id.replace('/', '_')}", ModelType.CUSTOM)
21
  self.api_key = api_key
22
  self.model_id = model_id
23
- self.providers_url = "https://router.huggingface.co/v1/chat/completions"
 
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 in ("jpg", "jpeg"):
30
- return "image/jpeg"
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", {}) or {}
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
- # Clean source - default to "OTHER" if not recognized
154
- if metadata.get("source"):
155
- allowed_sources = {"PDC", "GDACS", "WFP", "GFH", "GGC", "USGS", "OTHER"}
156
- if str(metadata["source"]).upper() not in allowed_sources:
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
- "response": result,
187
- "parsed_successfully": bool(metadata),
188
  },
189
  "description": description,
190
  "analysis": analysis,
191
  "recommended_actions": recommended_actions
192
  }
193
 
194
- def _extract_glm_metadata(self, content: str) -> tuple[str, dict]:
 
 
 
 
 
195
  """
196
- Extract metadata from GLM thinking format using simple, robust patterns.
197
- Focus on extracting what we can and rely on defaults for the rest.
198
  """
199
- # Remove <think> tags
200
- content = re.sub(r'<think>|</think>', '', content)
201
-
202
- metadata = {}
203
-
204
- # Simple extraction - just look for key patterns, don't overthink it
205
- # Title: Look for quoted strings after "Maybe" or "Title"
206
- title_match = re.search(r'(?:Maybe|Title).*?["\']([^"\']{5,50})["\']', content, re.IGNORECASE)
207
- if title_match:
208
- metadata["title"] = title_match.group(1).strip()
209
-
210
- # Source: Look for common source names (WFP, PDC, etc.)
211
- source_match = re.search(r'\b(WFP|PDC|GDACS|GFH|GGC|USGS)\b', content, re.IGNORECASE)
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
- # For caption, just use the first part before metadata discussion
238
- lines = content.split('\n')
239
- caption_lines = []
240
- for line in lines:
241
- if any(keyword in line.lower() for keyword in ['metadata:', 'now for the metadata', 'let me double-check']):
242
- break
243
- caption_lines.append(line)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- caption_text = '\n'.join(caption_lines).strip()
246
- if not caption_text:
247
- caption_text = content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
- return caption_text, metadata
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ValueError("No VLM services available")
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
- # If we get here, we've tried all services
198
- raise ValueError("All VLM services failed due to model unavailability")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()