Mark Duppenthaler commited on
Commit
503a577
·
1 Parent(s): 7f6ef8f

Image examples cleanup

Browse files
AudioPlayer.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import WaveSurfer from 'wavesurfer.js'
3
+ // @ts-ignore: No types for timeline.esm.js
4
+ // import Timeline from 'wavesurfer.js/dist/plugins/timeline.esm.js'
5
+ import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js'
6
+ import API from '../API' // Correct import for the API class
7
+
8
+ const AudioPlayer = ({ src }: { src: string }) => {
9
+ const containerRef = useRef<HTMLDivElement>(null)
10
+ const wavesurferRef = useRef<WaveSurfer | null>(null)
11
+ const [isPlaying, setIsPlaying] = useState(false)
12
+ // const plugins = useMemo(() => [TimelinePlugin.create()], [])
13
+
14
+ const bottomTimeline = TimelinePlugin.create({
15
+ height: 16,
16
+ timeInterval: 0.1,
17
+ primaryLabelInterval: 1,
18
+ style: {
19
+ fontSize: '10px',
20
+ // color: '#6A3274',
21
+ },
22
+ })
23
+
24
+ // Initialize WaveSurfer when component mounts
25
+ useEffect(() => {
26
+ if (!containerRef.current) return
27
+
28
+ // Get proxied URL to bypass CORS
29
+ const proxiedUrl = API.getProxiedUrl(src)
30
+
31
+ // Create an instance of WaveSurfer
32
+ wavesurferRef.current = WaveSurfer.create({
33
+ container: containerRef.current,
34
+ waveColor: 'rgb(200, 0, 200)',
35
+ progressColor: 'rgb(100, 0, 100)',
36
+ url: proxiedUrl, // Use the proxied URL
37
+ minPxPerSec: 100,
38
+ barWidth: 10,
39
+ barRadius: 10,
40
+ barGap: 2,
41
+ mediaControls: true,
42
+
43
+ // plugins: [bottomTimeline],
44
+ })
45
+
46
+ // Play on click
47
+ wavesurferRef.current.on('interaction', () => {
48
+ wavesurferRef.current?.play()
49
+ setIsPlaying(true)
50
+ })
51
+
52
+ // Rewind to the beginning on finished playing
53
+ wavesurferRef.current.on('finish', () => {
54
+ wavesurferRef.current?.setTime(0)
55
+ setIsPlaying(false)
56
+ })
57
+
58
+ // Update playing state
59
+ wavesurferRef.current.on('play', () => setIsPlaying(true))
60
+ wavesurferRef.current.on('pause', () => setIsPlaying(false))
61
+
62
+ // Cleanup on unmount
63
+ return () => {
64
+ wavesurferRef.current?.destroy()
65
+ }
66
+ }, [src])
67
+
68
+ const handlePlayPause = () => {
69
+ wavesurferRef.current?.playPause()
70
+ }
71
+
72
+ return (
73
+ <div className="">
74
+ <div ref={containerRef} />
75
+ {/* <button onClick={handlePlayPause}>{isPlaying ? 'Pause' : 'Play'}</button> */}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export default AudioPlayer
backend/examples.py CHANGED
@@ -47,8 +47,7 @@ def build_description(
47
 
48
  if i == 0:
49
  fake_det = data_none["fake_det"]
50
-
51
- return f"detected: {fake_det}"
52
  elif i == 1:
53
  # Fixed metrics
54
  det = data_none["watermark_det"]
@@ -56,30 +55,36 @@ def build_description(
56
  bit_acc = data_none["bit_acc"]
57
 
58
  # Dynamic metrics
59
- metrics_output = []
60
  for metric in quality_metrics:
61
  value = float(data_none[metric])
62
- metrics_output.append(f"{metric}: {value:.2f}")
63
 
64
  # Fixed metrics output
65
- fixed_metrics_output = (
66
- f" detected: {det} p_value: {p_value:.2f} bit_acc: {bit_acc:.2f}"
 
 
 
 
67
  )
68
 
69
- # Combine all outputs
70
- return " ".join(metrics_output) + f"{fixed_metrics_output}"
71
  elif i == 2:
72
  fake_det = data_attack["fake_det"]
73
-
74
- return f"det: {fake_det}"
75
- elif i == 3:
76
  det = data_attack["watermark_det"]
77
-
78
  p_value = float(data_attack["p_value"])
79
  word_acc = data_attack["word_acc"]
80
  bit_acc = data_attack["bit_acc"]
81
 
82
- return f"word_acc: {word_acc:.2f} detected: {det} p_value: {p_value:.2f} bit_acc: {bit_acc:.2f}"
 
 
 
 
 
83
 
84
 
85
  def build_infos(abs_path: Path, datatype: str, dataset_name: str, db_key: str):
@@ -176,7 +181,10 @@ def build_infos(abs_path: Path, datatype: str, dataset_name: str, db_key: str):
176
  files = [
177
  {
178
  "image_url": f,
179
- "description": f"{n}\n{build_description(i, data_none, data_attack, quality_metrics)}",
 
 
 
180
  **(
181
  {"audio_url": f.replace(".png", ".wav")}
182
  if datatype == "audio" and f.endswith(".png")
 
47
 
48
  if i == 0:
49
  fake_det = data_none["fake_det"]
50
+ return {"detected": fake_det}
 
51
  elif i == 1:
52
  # Fixed metrics
53
  det = data_none["watermark_det"]
 
55
  bit_acc = data_none["bit_acc"]
56
 
57
  # Dynamic metrics
58
+ metrics_output = {}
59
  for metric in quality_metrics:
60
  value = float(data_none[metric])
61
+ metrics_output[metric] = round(value, 2)
62
 
63
  # Fixed metrics output
64
+ metrics_output.update(
65
+ {
66
+ "detected": det,
67
+ "p_value": round(p_value, 2),
68
+ "bit_acc": round(bit_acc, 2),
69
+ }
70
  )
71
 
72
+ return metrics_output
 
73
  elif i == 2:
74
  fake_det = data_attack["fake_det"]
75
+ return {"detected": fake_det}
76
+ elif i >= 3: # REVISIT THIS, it used to be == 3
 
77
  det = data_attack["watermark_det"]
 
78
  p_value = float(data_attack["p_value"])
79
  word_acc = data_attack["word_acc"]
80
  bit_acc = data_attack["bit_acc"]
81
 
82
+ return {
83
+ "word_acc": round(word_acc, 2),
84
+ "detected": det,
85
+ "p_value": round(p_value, 2),
86
+ "bit_acc": round(bit_acc, 2),
87
+ }
88
 
89
 
90
  def build_infos(abs_path: Path, datatype: str, dataset_name: str, db_key: str):
 
181
  files = [
182
  {
183
  "image_url": f,
184
+ "name": n,
185
+ "metadata": build_description(
186
+ i, data_none, data_attack, quality_metrics
187
+ ),
188
  **(
189
  {"audio_url": f.replace(".png", ".wav")}
190
  if datatype == "audio" and f.endswith(".png")
frontend/src/components/AudioGallery.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import AudioPlayer from './AudioPlayer'
3
+ import type { ExamplesData } from './Examples'
4
+
5
+ interface GalleryProps {
6
+ selectedModel: string
7
+ selectedAttack: string
8
+ examples: {
9
+ [model: string]: {
10
+ [attack: string]: ExamplesData[]
11
+ }
12
+ }
13
+ }
14
+
15
+ const AudioGallery: React.FC<GalleryProps> = ({ selectedModel, selectedAttack, examples }) => {
16
+ const exampleItems = examples[selectedModel][selectedAttack]
17
+ return (
18
+ <div className="example-display">
19
+ {exampleItems.map((item, index) => (
20
+ <div key={index} className="example-item">
21
+ <p>{item.name}</p>
22
+ {item.audio_url && <AudioPlayer src={item.audio_url} />}
23
+ <img src={item.image_url} alt={item.name} className="example-image" />
24
+ </div>
25
+ ))}
26
+ </div>
27
+ )
28
+ }
29
+
30
+ export default AudioGallery
frontend/src/components/Examples.tsx CHANGED
@@ -1,17 +1,23 @@
1
  import React, { useState, useEffect } from 'react'
2
  import API from '../API'
3
- import AudioPlayer from './AudioPlayer'
4
  import LoadingSpinner from './LoadingSpinner'
 
 
 
5
 
6
  interface ExamplesProps {
7
  fileType: 'image' | 'audio' | 'video'
8
  }
9
 
10
- type ExamplesData = {
 
11
  image_url: string
12
  audio_url?: string
13
  video_url?: string
14
- description: string
 
 
 
15
  }
16
 
17
  const Examples = ({ fileType }: ExamplesProps) => {
@@ -55,45 +61,8 @@ const Examples = ({ fileType }: ExamplesProps) => {
55
  })
56
  }, [fileType])
57
 
58
- // Define the Gallery component within this file
59
- const Gallery = ({
60
- selectedModel,
61
- selectedAttack,
62
- examples,
63
- fileType,
64
- }: {
65
- selectedModel: string
66
- selectedAttack: string
67
- examples: {
68
- [model: string]: {
69
- [attack: string]: ExamplesData[]
70
- }
71
- }
72
- fileType: 'image' | 'audio' | 'video'
73
- }) => {
74
- const exampleItems = examples[selectedModel][selectedAttack]
75
-
76
- return (
77
- <div className="example-display">
78
- {exampleItems.map((item, index) => (
79
- <div key={index} className="example-item">
80
- <p>{item.description}</p>
81
- {fileType === 'image' && (
82
- <img src={item.image_url} alt={item.description} className="example-image" />
83
- )}
84
- {fileType === 'audio' && item.audio_url && (
85
- <>
86
- <AudioPlayer src={item.audio_url} />
87
- <img src={item.image_url} alt={item.description} className="example-image" />
88
- </>
89
- )}
90
- {fileType === 'video' && (
91
- <video controls src={item.video_url} className="example-video" />
92
- )}
93
- </div>
94
- ))}
95
- </div>
96
- )
97
  }
98
 
99
  return (
@@ -132,15 +101,27 @@ const Examples = ({ fileType }: ExamplesProps) => {
132
  )}
133
  </div>
134
 
135
- {loading && <LoadingSpinner />}
136
  {error && <p className="error">Error: {error}</p>}
137
 
138
- {selectedModel && selectedAttack && (
139
- <Gallery
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  selectedModel={selectedModel}
141
  selectedAttack={selectedAttack}
142
  examples={examples}
143
- fileType={fileType}
144
  />
145
  )}
146
  </div>
 
1
  import React, { useState, useEffect } from 'react'
2
  import API from '../API'
 
3
  import LoadingSpinner from './LoadingSpinner'
4
+ import ImageGallery from './ImageGallery'
5
+ import AudioGallery from './AudioGallery'
6
+ import VideoGallery from './VideoGallery'
7
 
8
  interface ExamplesProps {
9
  fileType: 'image' | 'audio' | 'video'
10
  }
11
 
12
+ // Move ExamplesData type export to allow import in Galleries.tsx
13
+ export type ExamplesData = {
14
  image_url: string
15
  audio_url?: string
16
  video_url?: string
17
+ name: string
18
+ metadata: {
19
+ [key: string]: string | boolean
20
+ }
21
  }
22
 
23
  const Examples = ({ fileType }: ExamplesProps) => {
 
61
  })
62
  }, [fileType])
63
 
64
+ if (loading) {
65
+ return <LoadingSpinner />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
67
 
68
  return (
 
101
  )}
102
  </div>
103
 
 
104
  {error && <p className="error">Error: {error}</p>}
105
 
106
+ {selectedModel && selectedAttack && fileType === 'image' && (
107
+ <ImageGallery
108
+ selectedModel={selectedModel}
109
+ selectedAttack={selectedAttack}
110
+ examples={examples}
111
+ />
112
+ )}
113
+ {selectedModel && selectedAttack && fileType === 'audio' && (
114
+ <AudioGallery
115
+ selectedModel={selectedModel}
116
+ selectedAttack={selectedAttack}
117
+ examples={examples}
118
+ />
119
+ )}
120
+ {selectedModel && selectedAttack && fileType === 'video' && (
121
+ <VideoGallery
122
  selectedModel={selectedModel}
123
  selectedAttack={selectedAttack}
124
  examples={examples}
 
125
  />
126
  )}
127
  </div>
frontend/src/components/Galleries.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import ImageGallery from './ImageGallery'
2
+ import AudioGallery from './AudioGallery'
3
+ import VideoGallery from './VideoGallery'
4
+
5
+ export { ImageGallery, AudioGallery, VideoGallery }
frontend/src/components/ImageGallery.tsx ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import type { ExamplesData } from './Examples'
3
+ import { groupByNameAndVariant } from './galleryUtils'
4
+
5
+ interface GalleryProps {
6
+ selectedModel: string
7
+ selectedAttack: string
8
+ examples: {
9
+ [model: string]: {
10
+ [attack: string]: ExamplesData[]
11
+ }
12
+ }
13
+ }
14
+
15
+ const Metadata: React.FC<{ metadata: Record<string, string | number | boolean> }> = ({
16
+ metadata,
17
+ }) => (
18
+ <fieldset className="fieldset w-full p-4 rounded border border-gray-700 bg-base-200 mb-4">
19
+ <legend className="fieldset-legend font-semibold">Metadata</legend>
20
+ <ul className="text-xs flex flex-row gap-4">
21
+ {(() => {
22
+ const entries = Object.entries(metadata || {}).filter(([k]) => k !== 'image_url')
23
+ const detectedIdx = entries.findIndex(([k]) => k === 'detected')
24
+ if (detectedIdx > -1) {
25
+ const [detected] = entries.splice(detectedIdx, 1)
26
+ entries.unshift(detected)
27
+ }
28
+ return entries.map(([k, v]) => (
29
+ <li key={k}>
30
+ <span className="font-mono">{k}</span>: {String(v)}
31
+ </li>
32
+ ))
33
+ })()}
34
+ </ul>
35
+ </fieldset>
36
+ )
37
+
38
+ const VariantSelector: React.FC<{
39
+ variantKeys: string[]
40
+ selectedVariant: string
41
+ setSelectedVariant: (v: string) => void
42
+ }> = ({ variantKeys, selectedVariant, setSelectedVariant }) => (
43
+ <fieldset className="fieldset w-full p-4 rounded border border-gray-700 bg-base-200 mb-4">
44
+ <legend className="fieldset-legend font-semibold">Variants</legend>
45
+ <div className="mb-2 flex gap-4 flex-wrap">
46
+ {variantKeys.map((variant, idx) => (
47
+ <label key={variant} className="flex items-center gap-1 cursor-pointer">
48
+ <input
49
+ type="radio"
50
+ name={`variant-selector`}
51
+ value={variant}
52
+ checked={selectedVariant === variant}
53
+ onChange={() => setSelectedVariant(variant)}
54
+ />
55
+ <span className="text-xs font-semibold">
56
+ {variant} <span className="opacity-60">[{idx + 1}]</span>
57
+ </span>
58
+ </label>
59
+ ))}
60
+ </div>
61
+ </fieldset>
62
+ )
63
+
64
+ const ExampleDetailsSection: React.FC<{ children: React.ReactNode }> = ({ children }) => (
65
+ <fieldset className="fieldset w-full p-4 rounded border border-gray-700 bg-base-200 mt-6">
66
+ {children}
67
+ </fieldset>
68
+ )
69
+
70
+ const ImageGallery: React.FC<GalleryProps> = ({ selectedModel, selectedAttack, examples }) => {
71
+ const exampleItems = examples[selectedModel][selectedAttack]
72
+
73
+ // Group by image name (name after removing variant prefix), and for each, map variant to metrics
74
+ const grouped = groupByNameAndVariant(exampleItems)
75
+
76
+ const imageNames = Object.keys(grouped)
77
+ const [selectedImage, setSelectedImage] = React.useState(imageNames[0] || '')
78
+ const variants = grouped[selectedImage] || {}
79
+ const variantKeys = Object.keys(variants)
80
+ const [selectedVariant, setSelectedVariant] = React.useState(variantKeys[0] || '')
81
+
82
+ const [zoom, setZoom] = React.useState<{ x: number; y: number } | null>(null)
83
+ const [imgDims, setImgDims] = React.useState<{ width: number; height: number }>({
84
+ width: 0,
85
+ height: 0,
86
+ })
87
+ const imgRef = React.useRef<HTMLImageElement | null>(null)
88
+
89
+ // Set a fixed zoom box size for all variants
90
+ const ZOOM_BOX_SIZE = 300
91
+ const [zoomLevel, setZoomLevel] = React.useState(2) // 2x default zoom
92
+
93
+ // Keyboard shortcut for variant switching
94
+ React.useEffect(() => {
95
+ const handler = (e: KeyboardEvent) => {
96
+ if (document.activeElement && (document.activeElement as HTMLElement).tagName === 'INPUT')
97
+ return
98
+ const idx = parseInt(e.key, 10)
99
+ if (!isNaN(idx) && idx > 0 && idx <= variantKeys.length) {
100
+ setSelectedVariant(variantKeys[idx - 1])
101
+ }
102
+ }
103
+ window.addEventListener('keydown', handler)
104
+ return () => window.removeEventListener('keydown', handler)
105
+ }, [variantKeys])
106
+
107
+ // Calculate the minimum width and height across all variants for the selected image
108
+ const variantImages = variantKeys.map((v) => variants[v]?.image_url).filter(Boolean)
109
+ const [naturalDims, setNaturalDims] = React.useState<{ width: number; height: number } | null>(
110
+ null
111
+ )
112
+
113
+ React.useEffect(() => {
114
+ let isMounted = true
115
+ if (variantImages.length > 0) {
116
+ Promise.all(
117
+ variantImages.map(
118
+ (src) =>
119
+ new Promise<{ width: number; height: number }>((resolve) => {
120
+ const img = new window.Image()
121
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
122
+ img.src = src!
123
+ })
124
+ )
125
+ ).then((dimsArr) => {
126
+ if (!isMounted) return
127
+ const first = dimsArr[0]
128
+ const allSame = dimsArr.every((d) => d.width === first.width && d.height === first.height)
129
+ if (!allSame) {
130
+ setNaturalDims(null)
131
+ } else {
132
+ setNaturalDims(first)
133
+ }
134
+ })
135
+ }
136
+ return () => {
137
+ isMounted = false
138
+ }
139
+ }, [variantImages.join(','), selectedImage])
140
+
141
+ // Place the zoom level slider outside of the zoom hover logic
142
+ const zoomSlider = (
143
+ <div className="flex flex-col items-center mb-4 w-full">
144
+ <label className="font-semibold text-xs mb-1">Zoom Level: {zoomLevel}x</label>
145
+ <input
146
+ type="range"
147
+ min={2}
148
+ max={6}
149
+ step={0.1}
150
+ value={zoomLevel}
151
+ onChange={(e) => setZoomLevel(Number(e.target.value))}
152
+ className="range range-xs w-48"
153
+ />
154
+ </div>
155
+ )
156
+
157
+ // If no image is selected, show a message
158
+ if (!variantImages.length) {
159
+ return (
160
+ <div className="w-full mt-12 flex items-center justify-center">
161
+ <div className="text-gray-500">
162
+ No images available. Please select another model and attack.
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ // Ensure the main container allows scrolling and images retain their natural size
169
+ return (
170
+ <div className="w-full overflow-auto" style={{ minHeight: '100vh' }}>
171
+ <div className="example-display">
172
+ <div className="mb-4">
173
+ <fieldset className="fieldset">
174
+ <legend className="fieldset-legend">Image</legend>
175
+ <select
176
+ className="select select-bordered"
177
+ value={selectedImage || ''}
178
+ onChange={(e) => {
179
+ setSelectedImage(e.target.value || '')
180
+ const newVariants = grouped[e.target.value] || {}
181
+ const newVariantKeys = Object.keys(newVariants)
182
+ setSelectedVariant(newVariantKeys[0] || '')
183
+ }}
184
+ >
185
+ {imageNames.map((name) => (
186
+ <option key={name} value={name}>
187
+ {name}
188
+ </option>
189
+ ))}
190
+ </select>
191
+ </fieldset>
192
+ </div>
193
+ {selectedImage && selectedVariant && variants[selectedVariant] && (
194
+ <>
195
+ <VariantSelector
196
+ variantKeys={variantKeys}
197
+ selectedVariant={selectedVariant}
198
+ setSelectedVariant={setSelectedVariant}
199
+ />
200
+ <Metadata metadata={variants[selectedVariant].metadata || {}} />
201
+ {zoomSlider}
202
+ <ExampleDetailsSection>
203
+ <div
204
+ style={{
205
+ width: '100%',
206
+ display: 'flex',
207
+ flexDirection: 'row',
208
+ justifyContent: 'center',
209
+ }}
210
+ >
211
+ <div style={{ position: 'relative', flex: 'none', display: 'inline-block' }}>
212
+ {/* Original image and overlay */}
213
+ {naturalDims ? (
214
+ <img
215
+ ref={imgRef}
216
+ src={variants[selectedVariant].image_url as string}
217
+ alt={selectedImage}
218
+ width={naturalDims.width}
219
+ height={naturalDims.height}
220
+ style={{ display: 'block', cursor: 'zoom-in' }}
221
+ onMouseMove={(e) => {
222
+ const rect = (e.target as HTMLImageElement).getBoundingClientRect()
223
+ const x = e.clientX - rect.left
224
+ const y = e.clientY - rect.top
225
+ setZoom({ x, y })
226
+ }}
227
+ onMouseLeave={() => setZoom(null)}
228
+ />
229
+ ) : (
230
+ <div className="text-red-500 font-bold">
231
+ Image sizes do not match across variants!
232
+ </div>
233
+ )}
234
+ {/* Zoomed box - now overlays on top of the cursor position */}
235
+ {zoom &&
236
+ naturalDims &&
237
+ (() => {
238
+ const { width, height } = naturalDims
239
+ const halfBox = ZOOM_BOX_SIZE / (2 * zoomLevel)
240
+ let centerX = zoom.x
241
+ let centerY = zoom.y
242
+ centerX = Math.max(halfBox, Math.min(width - halfBox, centerX))
243
+ centerY = Math.max(halfBox, Math.min(height - halfBox, centerY))
244
+ const bgX = centerX * zoomLevel - ZOOM_BOX_SIZE / 2
245
+ const bgY = centerY * zoomLevel - ZOOM_BOX_SIZE / 2
246
+ return (
247
+ <div
248
+ style={{
249
+ position: 'absolute',
250
+ left: zoom.x - ZOOM_BOX_SIZE / 2, // use raw cursor position for overlay
251
+ top: zoom.y - ZOOM_BOX_SIZE / 2,
252
+ width: ZOOM_BOX_SIZE,
253
+ height: ZOOM_BOX_SIZE,
254
+ boxSizing: 'border-box',
255
+ backgroundImage: `url(${variants[selectedVariant]?.image_url})`,
256
+ backgroundPosition: `-${bgX}px -${bgY}px`,
257
+ backgroundSize: `${width * zoomLevel}px ${height * zoomLevel}px`,
258
+ backgroundRepeat: 'no-repeat',
259
+ zIndex: 10,
260
+ pointerEvents: 'none',
261
+ cursor: 'zoom-in',
262
+ }}
263
+ />
264
+ )
265
+ })()}
266
+ </div>
267
+ </div>
268
+ </ExampleDetailsSection>
269
+ </>
270
+ )}
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ export default ImageGallery
frontend/src/components/VideoGallery.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import type { ExamplesData } from './Examples'
3
+
4
+ interface GalleryProps {
5
+ selectedModel: string
6
+ selectedAttack: string
7
+ examples: {
8
+ [model: string]: {
9
+ [attack: string]: ExamplesData[]
10
+ }
11
+ }
12
+ }
13
+
14
+ const VideoGallery: React.FC<GalleryProps> = ({ selectedModel, selectedAttack, examples }) => {
15
+ const exampleItems = examples[selectedModel][selectedAttack]
16
+ return (
17
+ <div className="example-display">
18
+ {exampleItems.map((item, index) => (
19
+ <div key={index} className="example-item">
20
+ <p>{item.name}</p>
21
+ <video controls src={item.video_url} className="example-video" />
22
+ </div>
23
+ ))}
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export default VideoGallery
frontend/src/components/galleryUtils.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Common parsing and grouping utilities for galleries
2
+
3
+ export const VARIANT_PREFIX_MAP: { [variant: string]: string } = {
4
+ attacked_wmd: 'attacked_wmd_',
5
+ attacked: 'attacked_',
6
+ wmd: 'wmd_',
7
+ }
8
+
9
+ export function getVariant(name: string): string {
10
+ return (
11
+ Object.entries(VARIANT_PREFIX_MAP).find(([, prefix]) => name.startsWith(prefix))?.[0] ||
12
+ 'original'
13
+ )
14
+ }
15
+
16
+ export function getImageName(name: string, variant: string): string {
17
+ if (variant !== 'original' && variant in VARIANT_PREFIX_MAP) {
18
+ return name.replace(VARIANT_PREFIX_MAP[variant], '')
19
+ }
20
+ return name
21
+ }
22
+
23
+ export function groupByNameAndVariant<T extends { name: string }>(
24
+ items: T[]
25
+ ): {
26
+ [name: string]: { [variant: string]: T }
27
+ } {
28
+ const grouped: { [name: string]: { [variant: string]: T } } = {}
29
+ items.forEach((item) => {
30
+ const variant = getVariant(item.name)
31
+ const imageName = getImageName(item.name, variant)
32
+ if (!grouped[imageName]) grouped[imageName] = {}
33
+ grouped[imageName][variant] = item
34
+ })
35
+ return grouped
36
+ }