Show bounding boxes on the sample image card if we have them in the prompt

This commit is contained in:
Jaret Burkett
2026-06-04 15:59:02 -06:00
parent edcc7415d1
commit bb60f6d1d1
3 changed files with 146 additions and 101 deletions

View File

@@ -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 (
<div className="absolute inset-0 pointer-events-none">
{boxes.map((b, i) => (
<div
key={i}
className={classNames('absolute border', {
'border-cyan-400': b.type === 'obj',
'border-amber-400': b.type === 'text',
})}
style={{
left: `${b.x1 / 10}%`,
top: `${b.y1 / 10}%`,
width: `${(b.x2 - b.x1) / 10}%`,
height: `${(b.y2 - b.y1) / 10}%`,
}}
>
{b.label && (
<span
title={b.label}
className={classNames(
'absolute top-0 left-0 max-w-full px-1 py-0.5 text-[9px] leading-tight font-medium whitespace-pre-line break-words line-clamp-3 text-gray-900',
{
'bg-cyan-400/90': b.type === 'obj',
'bg-amber-400/90': b.type === 'text',
},
)}
>
{b.label}
</span>
)}
</div>
))}
</div>
);
}

View File

@@ -10,42 +10,7 @@ import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic'; import { isVideo, isAudio } from '@/utils/basic';
import AudioPlayer from './AudioPlayer'; import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
// 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;
}
interface Props { interface Props {
imgPath: string | null; // current image path imgPath: string | null; // current image path
@@ -373,40 +338,7 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
draggable={false} draggable={false}
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[70vh] object-contain select-none !pointer-events-auto" className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[70vh] object-contain select-none !pointer-events-auto"
/> />
{showBoxes && boundingBoxes && ( {showBoxes && boundingBoxes && <BoundingBoxOverlay boxes={boundingBoxes} />}
<div className="absolute inset-0 pointer-events-none">
{boundingBoxes.map((b, i) => (
<div
key={i}
className={classNames('absolute border', {
'border-cyan-400': b.type === 'obj',
'border-amber-400': b.type === 'text',
})}
style={{
left: `${b.x1 / 10}%`,
top: `${b.y1 / 10}%`,
width: `${(b.x2 - b.x1) / 10}%`,
height: `${(b.y2 - b.y1) / 10}%`,
}}
>
{b.label && (
<span
title={b.label}
className={classNames(
'absolute top-0 left-0 max-w-full px-1 py-0.5 text-[9px] leading-tight font-medium whitespace-pre-line break-words line-clamp-3 text-gray-900',
{
'bg-cyan-400/90': b.type === 'obj',
'bg-amber-400/90': b.type === 'text',
},
)}
>
{b.label}
</span>
)}
</div>
))}
</div>
)}
</div> </div>
</TransformComponent> </TransformComponent>
</TransformWrapper> </TransformWrapper>

View File

@@ -3,13 +3,15 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { SampleConfig, SampleItem } from '@/types'; 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 { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { openConfirm } from './ConfirmModal'; import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
import { isVideo, isAudio } from '@/utils/basic'; import { isVideo, isAudio } from '@/utils/basic';
import AudioPlayer from './AudioPlayer'; import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
interface Props { interface Props {
imgPath: string | null; // current image path imgPath: string | null; // current image path
@@ -31,6 +33,7 @@ export default function SampleImageViewer({
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(Boolean(imgPath)); const [isOpen, setIsOpen] = useState(Boolean(imgPath));
const [showingControlIdx, setShowingControlIdx] = useState<number | null>(null); const [showingControlIdx, setShowingControlIdx] = useState<number | null>(null);
const [showBoxes, setShowBoxes] = useState<boolean>(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
@@ -196,6 +199,20 @@ export default function SampleImageViewer({
return imgPath; return imgPath;
}, [showingControlIdx, controlImages, 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 // keyboard events while open
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -326,12 +343,15 @@ export default function SampleImageViewer({
}} }}
> >
<TransformComponent> <TransformComponent>
<img <div className="relative">
src={`/api/img/${encodeURIComponent(displayedImgPath)}`} <img
alt="Sample Image" src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
draggable={false} alt="Sample Image"
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain select-none !pointer-events-auto" draggable={false}
/> className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain select-none !pointer-events-auto"
/>
{showBoxes && canShowBoxes && boundingBoxes && <BoundingBoxOverlay boxes={boundingBoxes} />}
</div>
</TransformComponent> </TransformComponent>
</TransformWrapper> </TransformWrapper>
))} ))}
@@ -386,33 +406,48 @@ export default function SampleImageViewer({
</div> </div>
</div> </div>
</div> </div>
<div className="absolute top-2 right-2 bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100 z-20"> <div className="absolute top-2 right-2 flex items-center gap-2 z-20">
<Menu> {canShowBoxes && (
<MenuButton> <button
<Cog /> type="button"
</MenuButton> onClick={() => setShowBoxes(v => !v)}
<MenuItems title={showBoxes ? 'Hide bounding boxes' : 'Show bounding boxes'}
anchor="bottom end" className={classNames('bg-gray-900 rounded-full p-1 leading-[0px] hover:opacity-100', {
className="bg-gray-900 border border-gray-700 rounded shadow-lg w-48 px-2 py-2 mt-1 z-50" 'opacity-100 text-blue-400': showBoxes,
'opacity-50': !showBoxes,
})}
> >
{imgPath && isAudio(imgPath) && ( <SquareDashed />
</button>
)}
<div className="bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100">
<Menu>
<MenuButton>
<Cog />
</MenuButton>
<MenuItems
anchor="bottom end"
className="bg-gray-900 border border-gray-700 rounded shadow-lg w-48 px-2 py-2 mt-1 z-50"
>
{imgPath && isAudio(imgPath) && (
<MenuItem>
<a
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block"
href={`/api/img/${encodeURIComponent(imgPath)}`}
download={imgPath.replace(/^.*[\\/]/, '')}
>
Download
</a>
</MenuItem>
)}
<MenuItem> <MenuItem>
<a <div className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded" onClick={handleDelete}>
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block" Delete Sample
href={`/api/img/${encodeURIComponent(imgPath)}`} </div>
download={imgPath.replace(/^.*[\\/]/, '')}
>
Download
</a>
</MenuItem> </MenuItem>
)} </MenuItems>
<MenuItem> </Menu>
<div className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded" onClick={handleDelete}> </div>
Delete Sample
</div>
</MenuItem>
</MenuItems>
</Menu>
</div> </div>
</DialogPanel> </DialogPanel>
</div> </div>