diff --git a/ui/src/components/BoundingBoxOverlay.tsx b/ui/src/components/BoundingBoxOverlay.tsx
index 44ee961..b5a5130 100644
--- a/ui/src/components/BoundingBoxOverlay.tsx
+++ b/ui/src/components/BoundingBoxOverlay.tsx
@@ -1,6 +1,22 @@
import { useRef, useState } from 'react';
import classNames from 'classnames';
+// Per-box color so multiple boxes are individually distinguishable (not all one
+// color). Hue is spread by the golden-angle so adjacent indices look distinct;
+// stable per index so the same box keeps its color across the editor/overlay and
+// matches its #n chip in the sidebar.
+export function boxColor(index: number): string {
+ const hue = (index * 137.508) % 360;
+ return `hsl(${hue.toFixed(1)}, 80%, 60%)`;
+}
+
+// Same hue as boxColor but with an alpha, for tinting backgrounds (e.g. the
+// sidebar element cards) so they visually match their box on the image.
+export function boxColorAlpha(index: number, alpha: number): string {
+ const hue = (index * 137.508) % 360;
+ return `hsla(${hue.toFixed(1)}, 80%, 60%, ${alpha})`;
+}
+
// 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 {
@@ -227,8 +243,9 @@ export function BoundingBoxEditor({
>
{boxes.map(box => {
const pos = draft && draft.index === box.elementIndex ? draft.box : box;
- const isObj = box.type === 'obj';
const selected = selectedIndex === box.elementIndex;
+ const showSelected = selected && !drawing;
+ const color = boxColor(box.elementIndex);
return (
{box.label && (
{box.label}
@@ -295,36 +309,32 @@ export function BoundingBoxEditor({
export default function BoundingBoxOverlay({ boxes }: { boxes: OverlayBox[] }) {
return (
- {boxes.map((b, i) => (
-
- {b.label && (
-
- {b.label}
-
- )}
-
- ))}
+ {boxes.map((b, i) => {
+ const color = boxColor(i);
+ return (
+
+ {b.label && (
+
+ {b.label}
+
+ )}
+
+ );
+ })}
);
}
diff --git a/ui/src/components/DatasetImageViewer.tsx b/ui/src/components/DatasetImageViewer.tsx
index 1a86561..e7324da 100644
--- a/ui/src/components/DatasetImageViewer.tsx
+++ b/ui/src/components/DatasetImageViewer.tsx
@@ -11,6 +11,7 @@ import { isVideo, isAudio } from '@/utils/basic';
import AudioPlayer from './AudioPlayer';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { BoundingBoxEditor, parseBoundingBoxes, extractBoxes } from './BoundingBoxOverlay';
+import IdeogramCaptionSidebar, { isIdeogramCaption } from './IdeogramCaptionSidebar';
function safeParse(text: string): any {
try {
@@ -42,6 +43,8 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
const currentImgPathRef = useRef
(null);
const captionAbortRef = useRef(null);
+ const isIdeogram = useMemo(() => isIdeogramCaption(caption), [caption]);
+
useEffect(() => setMounted(true), []);
// Clear box selection / draw mode whenever the image changes.
@@ -50,6 +53,11 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
setIsDrawing(false);
}, [imgPath]);
+ // Default to showing the editable boxes when an Ideogram caption is present.
+ useEffect(() => {
+ setShowBoxes(isIdeogram);
+ }, [isIdeogram, imgPath]);
+
// open/close based on external value
useEffect(() => {
setIsOpen(Boolean(imgPath));
@@ -269,31 +277,6 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
[editCaption],
);
- const handleFieldChange = useCallback(
- (field: 'desc' | 'text', value: string) => {
- editCaption(els => {
- if (selectedBoxIndex != null && els[selectedBoxIndex]) els[selectedBoxIndex][field] = value;
- });
- },
- [editCaption, selectedBoxIndex],
- );
-
- const handleTypeChange = useCallback(
- (type: 'obj' | 'text') => {
- editCaption(els => {
- const el = selectedBoxIndex != null ? els[selectedBoxIndex] : null;
- if (!el) return;
- el.type = type;
- if (type === 'text') {
- if (el.text == null) el.text = '';
- } else {
- delete el.text;
- }
- });
- },
- [editCaption, selectedBoxIndex],
- );
-
// keyboard events while open — skip nav while caption textarea is focused
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -390,12 +373,8 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
const boundingBoxes = useMemo(() => parseBoundingBoxes(caption), [caption]);
const canShowBoxes = Boolean(boundingBoxes && imgPath && !isAudio(imgPath) && !isVideo(imgPath));
- // Boxes and the selected element are derived from the (locally edited) caption.
+ // Boxes are derived from the (locally edited) caption for the image overlay.
const editBoxes = useMemo(() => extractBoxes(safeParse(caption)), [caption]);
- const selectedElement = useMemo(() => {
- if (selectedBoxIndex == null) return null;
- return safeParse(caption)?.compositional_deconstruction?.elements?.[selectedBoxIndex] ?? null;
- }, [caption, selectedBoxIndex]);
if (!mounted) return null;
@@ -530,106 +509,36 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
{currentIndex >= 0 ? `${currentIndex + 1} / ${imageList.length}` : ''}
-
-
- {showBoxes && (
-
-
- setIsDrawing(d => !d)}
- className={classNames('px-2 py-1 rounded border', {
- 'bg-blue-600 border-blue-500 text-white': isDrawing,
- 'border-gray-600 text-gray-300 hover:bg-gray-800': !isDrawing,
- })}
- >
- {isDrawing ? 'Cancel' : '+ Add Box'}
-
-
- {isCaptionCurrent ? 'Saved' : 'Save'}
-
-
-
- {isDrawing
- ? 'Drag on the image to draw a new box'
- : 'Click a box to select; drag to move, handles to resize'}
-
- {selectedElement && (
-
-
- Type:
- handleTypeChange('obj')}
- className={classNames('px-2 py-0.5 rounded border', {
- 'bg-cyan-600 border-cyan-500 text-white': selectedElement.type !== 'text',
- 'border-gray-600 text-gray-300 hover:bg-gray-800': selectedElement.type === 'text',
- })}
- >
- Object
-
- handleTypeChange('text')}
- className={classNames('px-2 py-0.5 rounded border', {
- 'bg-amber-600 border-amber-500 text-white': selectedElement.type === 'text',
- 'border-gray-600 text-gray-300 hover:bg-gray-800': selectedElement.type !== 'text',
- })}
- >
- Text
-
- handleDeleteBox(selectedBoxIndex!)}
- className="ml-auto px-2 py-0.5 rounded border border-red-700 text-red-400 hover:bg-red-900/40"
- >
- Delete
-
-
- {selectedElement.type === 'text' && (
-
- Text (shown in image)
-
- )}
-
- Description
-
-
- )}
+ ) : (
+
+
)}
diff --git a/ui/src/components/IdeogramCaptionSidebar.tsx b/ui/src/components/IdeogramCaptionSidebar.tsx
new file mode 100644
index 0000000..292d948
--- /dev/null
+++ b/ui/src/components/IdeogramCaptionSidebar.tsx
@@ -0,0 +1,513 @@
+'use client';
+import { useMemo, useState } from 'react';
+import classNames from 'classnames';
+import { Plus, Trash2, X, SquareDashed } from 'lucide-react';
+import { boxColor, boxColorAlpha } from './BoundingBoxOverlay';
+
+// Detects an Ideogram structured caption (the distinctive marker is the
+// compositional_deconstruction block). Used by the viewer to decide whether to
+// show this form editor instead of a plain textarea.
+export function isIdeogramCaption(text: string): boolean {
+ const t = text.trim();
+ if (!t.startsWith('{')) return false;
+ try {
+ const d = JSON.parse(t);
+ return !!d && typeof d === 'object' && typeof d.compositional_deconstruction === 'object';
+ } catch {
+ return false;
+ }
+}
+
+// Normalize an arbitrary color string to a #rrggbb value usable by .
+function toHex6(c: string): string {
+ const s = (c || '').trim();
+ if (/^#[0-9a-fA-F]{6}$/.test(s)) return s;
+ if (/^#[0-9a-fA-F]{3}$/.test(s)) {
+ return (
+ '#' +
+ s
+ .slice(1)
+ .split('')
+ .map(ch => ch + ch)
+ .join('')
+ );
+ }
+ return '#000000';
+}
+
+const clamp1000 = (n: number) => Math.max(0, Math.min(1000, Math.round(isNaN(n) ? 0 : n)));
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function TextField({
+ label,
+ value,
+ onChange,
+ placeholder,
+}: {
+ label?: string;
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+}) {
+ return (
+
+ {label && {label} }
+ onChange(e.target.value)}
+ className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-100 outline-none focus:border-blue-500"
+ />
+
+ );
+}
+
+function TextAreaField({
+ label,
+ value,
+ onChange,
+ rows = 3,
+ placeholder,
+}: {
+ label?: string;
+ value: string;
+ onChange: (v: string) => void;
+ rows?: number;
+ placeholder?: string;
+}) {
+ return (
+
+ {label && {label} }
+
+ );
+}
+
+function ColorPalette({ colors, max, onChange }: { colors: string[]; max: number; onChange: (c: string[]) => void }) {
+ const setAt = (i: number, v: string) => onChange(colors.map((c, idx) => (idx === i ? v : c)));
+ const removeAt = (i: number) => onChange(colors.filter((_, idx) => idx !== i));
+ return (
+
+ );
+}
+
+interface Props {
+ caption: string;
+ onChange: (next: string) => void;
+ selectedIndex: number | null;
+ onSelectIndex: (i: number | null) => void;
+ isDrawing: boolean;
+ onToggleDrawing: () => void;
+ onSave: () => void;
+ isDirty: boolean;
+}
+
+export default function IdeogramCaptionSidebar({
+ caption,
+ onChange,
+ selectedIndex,
+ onSelectIndex,
+ isDrawing,
+ onToggleDrawing,
+ onSave,
+ isDirty,
+}: Props) {
+ const [showRaw, setShowRaw] = useState(false);
+
+ const data = useMemo(() => {
+ try {
+ return JSON.parse(caption);
+ } catch {
+ return null;
+ }
+ }, [caption]);
+
+ // Parse fresh from the current caption, mutate, and emit pretty JSON so the
+ // string remains the single source of truth (shared with the image editor).
+ const update = (mutator: (d: any) => void) => {
+ let d: any;
+ try {
+ d = JSON.parse(caption);
+ } catch {
+ return;
+ }
+ mutator(d);
+ onChange(JSON.stringify(d, null, 2));
+ };
+
+ if (!data) {
+ return (
+
+ Caption is not valid JSON, so the form editor is unavailable. Fix it in the raw text below.
+
+ );
+ }
+
+ const style = data.style_description || {};
+ const decon = data.compositional_deconstruction || {};
+ const elements: any[] = Array.isArray(decon.elements) ? decon.elements : [];
+
+ const setStyle = (key: string, value: string) =>
+ update(d => {
+ d.style_description = { ...(d.style_description || {}), [key]: value };
+ });
+
+ const setStylePalette = (cols: string[]) =>
+ update(d => {
+ const sd = { ...(d.style_description || {}) };
+ if (cols.length) sd.color_palette = cols;
+ else delete sd.color_palette;
+ d.style_description = sd;
+ });
+
+ const setElement = (i: number, mutator: (el: any) => void) =>
+ update(d => {
+ const els = d?.compositional_deconstruction?.elements;
+ if (Array.isArray(els) && els[i]) mutator(els[i]);
+ });
+
+ const addElement = () => {
+ update(d => {
+ if (!d.compositional_deconstruction || typeof d.compositional_deconstruction !== 'object') {
+ d.compositional_deconstruction = {};
+ }
+ if (!Array.isArray(d.compositional_deconstruction.elements)) {
+ d.compositional_deconstruction.elements = [];
+ }
+ d.compositional_deconstruction.elements.push({ type: 'obj', desc: '' });
+ });
+ onSelectIndex(elements.length);
+ };
+
+ const removeElement = (i: number) => {
+ update(d => {
+ d?.compositional_deconstruction?.elements?.splice(i, 1);
+ });
+ onSelectIndex(null);
+ };
+
+ return (
+
+ {/* Header — stays pinned while the form scrolls */}
+
+ Ideogram Caption
+ {isDirty && (
+
+ )}
+
+ {isDirty ? 'Save' : 'Saved'}
+
+
+
+
update(d => (d.high_level_description = v))}
+ rows={3}
+ placeholder="One-sentence summary of the image..."
+ />
+
+
+ setStyle('aesthetics', v)} />
+ setStyle('lighting', v)} />
+ setStyle('photo', v)} />
+ setStyle('medium', v)}
+ placeholder="Photograph."
+ />
+
+ Color palette (max 16)
+
+
+
+
+
+
+ update(d => {
+ d.compositional_deconstruction = { ...(d.compositional_deconstruction || {}), background: v };
+ })
+ }
+ rows={4}
+ placeholder="The scene shell: walls, floor, sky, ambient light..."
+ />
+
+
+
+
+
+ Add element
+
+
+ {isDrawing ? 'Drawing… (esc to cancel)' : 'Draw box'}
+
+
+
+ {elements.map((el, i) => {
+ const isText = el.type === 'text';
+ const selected = selectedIndex === i;
+ const bbox = Array.isArray(el.bbox) && el.bbox.length === 4 ? el.bbox : null;
+ const palette = Array.isArray(el.color_palette) ? el.color_palette : [];
+ return (
+
+
+
onSelectIndex(selected ? null : i)}
+ className={classNames(
+ 'flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-md transition-colors',
+ {
+ 'bg-blue-600 text-white': selected,
+ 'bg-gray-800 text-gray-400 hover:text-gray-200': !selected,
+ },
+ )}
+ title="Select (highlights its box on the image)"
+ >
+
+ #{i + 1}
+
+
+
+ setElement(i, e => {
+ e.type = 'obj';
+ delete e.text;
+ })
+ }
+ className={classNames('text-[11px] px-3 py-1 transition-colors', {
+ 'bg-cyan-600 text-white': !isText,
+ 'text-gray-300 hover:bg-gray-800': isText,
+ })}
+ >
+ obj
+
+
+ setElement(i, e => {
+ e.type = 'text';
+ if (e.text == null) e.text = '';
+ })
+ }
+ className={classNames('text-[11px] px-3 py-1 border-l border-gray-700 transition-colors', {
+ 'bg-amber-600 text-white': isText,
+ 'text-gray-300 hover:bg-gray-800': !isText,
+ })}
+ >
+ text
+
+
+
removeElement(i)}
+ className="ml-auto text-gray-500 hover:text-rose-400 transition-colors"
+ title="Delete element"
+ >
+
+
+
+
+ {isText && (
+
setElement(i, e => (e.text = v))}
+ rows={2}
+ />
+ )}
+
+ setElement(i, e => (e.desc = v))}
+ rows={3}
+ />
+
+
+
+ Bounding box {bbox ? '' : '(none)'}
+ {bbox ? (
+ setElement(i, e => delete e.bbox)}
+ className="text-[10px] text-rose-400 hover:text-rose-300"
+ >
+ remove
+
+ ) : (
+ setElement(i, e => (e.bbox = [250, 250, 750, 750]))}
+ className="text-[10px] text-blue-400 hover:text-blue-300"
+ >
+ + add box
+
+ )}
+
+ {bbox && (
+
+ {['y1', 'x1', 'y2', 'x2'].map((lbl, k) => (
+
+ {lbl}
+
+ setElement(i, el2 => {
+ const bb = [...el2.bbox];
+ bb[k] = clamp1000(parseInt(e.target.value, 10));
+ el2.bbox = bb;
+ })
+ }
+ className="bg-gray-800 border border-gray-700 rounded px-1 py-0.5 text-[11px] text-gray-100 outline-none focus:border-blue-500"
+ />
+
+ ))}
+
+ )}
+
+
+
+ Colors (max 5)
+
+ setElement(i, e => {
+ if (cols.length) e.color_palette = cols;
+ else delete e.color_palette;
+ })
+ }
+ />
+
+
+ );
+ })}
+
+
+ {/* Advanced raw-JSON escape hatch */}
+
+ setShowRaw(s => !s)}
+ className="text-[11px] text-gray-500 hover:text-gray-300"
+ >
+ {showRaw ? '▾ Hide raw JSON' : '▸ Raw JSON'}
+
+ {showRaw && (
+
+
+ );
+}