diff --git a/ui/src/components/BoundingBoxOverlay.tsx b/ui/src/components/BoundingBoxOverlay.tsx
new file mode 100644
index 0000000..669541b
--- /dev/null
+++ b/ui/src/components/BoundingBoxOverlay.tsx
@@ -0,0 +1,78 @@
+import classNames from 'classnames';
+
+// A single bbox parsed from an Ideogram-style caption/prompt. Stored coords are
+// normalized 0-1000 in [y1, x1, y2, x2] order (top-left origin).
+export interface OverlayBox {
+ y1: number;
+ x1: number;
+ y2: number;
+ x2: number;
+ label: string;
+ type: 'obj' | 'text';
+}
+
+// Returns the list of boxes if the text is an Ideogram bbox-JSON caption/prompt
+// with at least one bbox, otherwise null (normal captions/prompts get no overlay).
+export function parseBoundingBoxes(text: string): OverlayBox[] | null {
+ const trimmed = text.trim();
+ if (!trimmed.startsWith('{')) return null;
+ let data: any;
+ try {
+ data = JSON.parse(trimmed);
+ } catch {
+ return null;
+ }
+ const elements = data?.compositional_deconstruction?.elements;
+ if (!Array.isArray(elements)) return null;
+ const boxes: OverlayBox[] = [];
+ for (const el of elements) {
+ const bb = el?.bbox;
+ if (Array.isArray(bb) && bb.length === 4 && bb.every((n: any) => typeof n === 'number')) {
+ const isText = el.type === 'text';
+ const label = (isText ? el.text : el.desc) ?? '';
+ boxes.push({ y1: bb[0], x1: bb[1], y2: bb[2], x2: bb[3], label: `${label}`, type: isText ? 'text' : 'obj' });
+ }
+ }
+ return boxes.length > 0 ? boxes : null;
+}
+
+// Absolute overlay layer of boxes. Place inside a `relative` container that wraps
+// the image (e.g. inside react-zoom-pan-pinch's TransformComponent) so boxes
+// track the image during zoom/pan. Coords are percentages of the frame, so this
+// is resolution-independent. pointer-events-none keeps zoom/pan working through it.
+export default function BoundingBoxOverlay({ boxes }: { boxes: OverlayBox[] }) {
+ return (
+
+ {boxes.map((b, i) => (
+
+ {b.label && (
+
+ {b.label}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/ui/src/components/DatasetImageViewer.tsx b/ui/src/components/DatasetImageViewer.tsx
index c185b0e..5619564 100644
--- a/ui/src/components/DatasetImageViewer.tsx
+++ b/ui/src/components/DatasetImageViewer.tsx
@@ -10,42 +10,7 @@ import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
-
-// A single bbox parsed from an Ideogram-style caption. Stored coords are
-// normalized 0-1000 in [y1, x1, y2, x2] order (top-left origin).
-interface OverlayBox {
- y1: number;
- x1: number;
- y2: number;
- x2: number;
- label: string;
- type: 'obj' | 'text';
-}
-
-// Returns the list of boxes if the caption is an Ideogram bbox-JSON caption with
-// at least one bbox, otherwise null (normal captions get no overlay).
-function parseBoundingBoxes(caption: string): OverlayBox[] | null {
- const trimmed = caption.trim();
- if (!trimmed.startsWith('{')) return null;
- let data: any;
- try {
- data = JSON.parse(trimmed);
- } catch {
- return null;
- }
- const elements = data?.compositional_deconstruction?.elements;
- if (!Array.isArray(elements)) return null;
- const boxes: OverlayBox[] = [];
- for (const el of elements) {
- const bb = el?.bbox;
- if (Array.isArray(bb) && bb.length === 4 && bb.every((n: any) => typeof n === 'number')) {
- const isText = el.type === 'text';
- const label = (isText ? el.text : el.desc) ?? '';
- boxes.push({ y1: bb[0], x1: bb[1], y2: bb[2], x2: bb[3], label: `${label}`, type: isText ? 'text' : 'obj' });
- }
- }
- return boxes.length > 0 ? boxes : null;
-}
+import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
interface Props {
imgPath: string | null; // current image path
@@ -373,40 +338,7 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
draggable={false}
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[70vh] object-contain select-none !pointer-events-auto"
/>
- {showBoxes && boundingBoxes && (
-
- {boundingBoxes.map((b, i) => (
-
- {b.label && (
-
- {b.label}
-
- )}
-
- ))}
-
- )}
+ {showBoxes && boundingBoxes && }
diff --git a/ui/src/components/SampleImageViewer.tsx b/ui/src/components/SampleImageViewer.tsx
index 886ca7e..9d973ca 100644
--- a/ui/src/components/SampleImageViewer.tsx
+++ b/ui/src/components/SampleImageViewer.tsx
@@ -3,13 +3,15 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { SampleConfig, SampleItem } from '@/types';
-import { Cog } from 'lucide-react';
+import { Cog, SquareDashed } from 'lucide-react';
+import classNames from 'classnames';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
+import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
interface Props {
imgPath: string | null; // current image path
@@ -31,6 +33,7 @@ export default function SampleImageViewer({
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(Boolean(imgPath));
const [showingControlIdx, setShowingControlIdx] = useState(null);
+ const [showBoxes, setShowBoxes] = useState(false);
useEffect(() => setMounted(true), []);
@@ -196,6 +199,20 @@ export default function SampleImageViewer({
return imgPath;
}, [showingControlIdx, controlImages, imgPath]);
+ // The sample's prompt is what generated it; if it's an Ideogram bbox-JSON we can
+ // overlay the boxes on the generated image. Only on the main image (not controls).
+ const boundingBoxes = useMemo(
+ () => (sampleItem?.prompt ? parseBoundingBoxes(sampleItem.prompt) : null),
+ [sampleItem],
+ );
+ const canShowBoxes = Boolean(
+ boundingBoxes &&
+ showingControlIdx === null &&
+ displayedImgPath &&
+ !isAudio(displayedImgPath) &&
+ !isVideo(displayedImgPath),
+ );
+
// keyboard events while open
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -326,12 +343,15 @@ export default function SampleImageViewer({
}}
>
-
+
+
}`})
+ {showBoxes && canShowBoxes && boundingBoxes &&
}
+
))}
@@ -386,33 +406,48 @@ export default function SampleImageViewer({
-
-