Allow adjusting, adding, and deleting bounding boxes.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
// A single bbox parsed from an Ideogram-style caption/prompt. Stored coords are
|
// A single bbox parsed from an Ideogram-style caption/prompt. Stored coords are
|
||||||
@@ -11,6 +12,12 @@ export interface OverlayBox {
|
|||||||
type: 'obj' | 'text';
|
type: 'obj' | 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same as OverlayBox but carries the element's index in the caption's element
|
||||||
|
// array, so edits can be written back to the right element.
|
||||||
|
export interface EditableBox extends OverlayBox {
|
||||||
|
elementIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the list of boxes if the text is an Ideogram bbox-JSON caption/prompt
|
// 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).
|
// with at least one bbox, otherwise null (normal captions/prompts get no overlay).
|
||||||
export function parseBoundingBoxes(text: string): OverlayBox[] | null {
|
export function parseBoundingBoxes(text: string): OverlayBox[] | null {
|
||||||
@@ -36,6 +43,251 @@ export function parseBoundingBoxes(text: string): OverlayBox[] | null {
|
|||||||
return boxes.length > 0 ? boxes : null;
|
return boxes.length > 0 ? boxes : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build editable boxes (tagged with their element index) from an already-parsed
|
||||||
|
// caption object. Returns [] if the object has no usable elements/boxes.
|
||||||
|
export function extractBoxes(data: any): EditableBox[] {
|
||||||
|
const elements = data?.compositional_deconstruction?.elements;
|
||||||
|
if (!Array.isArray(elements)) return [];
|
||||||
|
const boxes: EditableBox[] = [];
|
||||||
|
elements.forEach((el: any, idx: number) => {
|
||||||
|
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',
|
||||||
|
elementIndex: idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return boxes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the caption for EDITING: returns the parsed object plus its boxes. null if
|
||||||
|
// the text is not an Ideogram bbox-JSON with at least one box.
|
||||||
|
export function parseCaptionForEditing(text: string): { data: any; boxes: EditableBox[] } | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.startsWith('{')) return null;
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const boxes = extractBoxes(data);
|
||||||
|
return boxes.length > 0 ? { data, boxes } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxCoords {
|
||||||
|
y1: number;
|
||||||
|
x1: number;
|
||||||
|
y2: number;
|
||||||
|
x2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp/round/normalize coords to integer 0-1000 with y1<y2, x1<x2.
|
||||||
|
function normalizeBox(b: { x1: number; y1: number; x2: number; y2: number }): BoxCoords {
|
||||||
|
const cl = (v: number) => Math.max(0, Math.min(1000, Math.round(v)));
|
||||||
|
const x1 = cl(Math.min(b.x1, b.x2));
|
||||||
|
const x2 = cl(Math.max(b.x1, b.x2));
|
||||||
|
const y1 = cl(Math.min(b.y1, b.y2));
|
||||||
|
const y2 = cl(Math.max(b.y1, b.y2));
|
||||||
|
return { y1, x1, y2, x2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
const MIN_SIZE = 8; // minimum normalized box span so boxes can't collapse
|
||||||
|
|
||||||
|
// Editable overlay: click to select, drag a box body to move, drag a corner
|
||||||
|
// handle to resize, ✕ to delete. With `drawing` on, drag on empty space to
|
||||||
|
// create a new box. All geometry commits fire on pointer-up. Place inside the
|
||||||
|
// same `relative` image-wrapping container as BoundingBoxOverlay; coordinate
|
||||||
|
// math uses this layer's own rect, so it's correct at any zoom.
|
||||||
|
export function BoundingBoxEditor({
|
||||||
|
boxes,
|
||||||
|
selectedIndex,
|
||||||
|
drawing,
|
||||||
|
onSelect,
|
||||||
|
onChangeBox,
|
||||||
|
onCreateBox,
|
||||||
|
}: {
|
||||||
|
boxes: EditableBox[];
|
||||||
|
selectedIndex: number | null;
|
||||||
|
drawing: boolean;
|
||||||
|
onSelect: (elementIndex: number | null) => void;
|
||||||
|
onChangeBox: (elementIndex: number, box: BoxCoords) => void;
|
||||||
|
onCreateBox: (box: BoxCoords) => void;
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draft, setDraft] = useState<{ index: number; box: OverlayBox } | null>(null);
|
||||||
|
const [drawDraft, setDrawDraft] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
||||||
|
|
||||||
|
const toNorm = (clientX: number, clientY: number) => {
|
||||||
|
const rect = rootRef.current!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1000, ((clientX - rect.left) / rect.width) * 1000)),
|
||||||
|
y: Math.max(0, Math.min(1000, ((clientY - rect.top) / rect.height) * 1000)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (e: React.PointerEvent, box: EditableBox, mode: DragMode) => {
|
||||||
|
// Stop the event from reaching the zoom/pan wrapper so dragging never pans.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(box.elementIndex);
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const start: OverlayBox = { ...box };
|
||||||
|
let current: OverlayBox = { ...box };
|
||||||
|
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const rect = root.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
const dx = ((ev.clientX - startX) / rect.width) * 1000;
|
||||||
|
const dy = ((ev.clientY - startY) / rect.height) * 1000;
|
||||||
|
let { x1, y1, x2, y2 } = start;
|
||||||
|
if (mode === 'move') {
|
||||||
|
const w = x2 - x1;
|
||||||
|
const h = y2 - y1;
|
||||||
|
const nx1 = Math.max(0, Math.min(1000 - w, x1 + dx));
|
||||||
|
const ny1 = Math.max(0, Math.min(1000 - h, y1 + dy));
|
||||||
|
x1 = nx1;
|
||||||
|
y1 = ny1;
|
||||||
|
x2 = nx1 + w;
|
||||||
|
y2 = ny1 + h;
|
||||||
|
} else {
|
||||||
|
if (mode.includes('w')) x1 = Math.max(0, Math.min(x2 - MIN_SIZE, x1 + dx));
|
||||||
|
if (mode.includes('e')) x2 = Math.min(1000, Math.max(x1 + MIN_SIZE, x2 + dx));
|
||||||
|
if (mode.includes('n')) y1 = Math.max(0, Math.min(y2 - MIN_SIZE, y1 + dy));
|
||||||
|
if (mode.includes('s')) y2 = Math.min(1000, Math.max(y1 + MIN_SIZE, y2 + dy));
|
||||||
|
}
|
||||||
|
current = { ...start, x1, y1, x2, y2 };
|
||||||
|
setDraft({ index: box.elementIndex, box: current });
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
setDraft(null);
|
||||||
|
onChangeBox(box.elementIndex, normalizeBox(current));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pointer-down on the empty root: draw a new box (drawing mode) or deselect.
|
||||||
|
const onRootPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (!drawing) {
|
||||||
|
onSelect(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!rootRef.current) return;
|
||||||
|
const p0 = toNorm(e.clientX, e.clientY);
|
||||||
|
let cur = { x1: p0.x, y1: p0.y, x2: p0.x, y2: p0.y };
|
||||||
|
setDrawDraft(cur);
|
||||||
|
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const p = toNorm(ev.clientX, ev.clientY);
|
||||||
|
cur = { x1: p0.x, y1: p0.y, x2: p.x, y2: p.y };
|
||||||
|
setDrawDraft({ ...cur });
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
setDrawDraft(null);
|
||||||
|
const nb = normalizeBox(cur);
|
||||||
|
if (nb.x2 - nb.x1 >= MIN_SIZE && nb.y2 - nb.y1 >= MIN_SIZE) onCreateBox(nb);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handles: { mode: DragMode; cls: string }[] = [
|
||||||
|
{ mode: 'nw', cls: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||||
|
{ mode: 'ne', cls: 'top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||||
|
{ mode: 'sw', cls: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||||
|
{ mode: 'se', cls: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
onPointerDown={onRootPointerDown}
|
||||||
|
className={classNames('absolute inset-0', drawing ? 'cursor-crosshair' : '')}
|
||||||
|
>
|
||||||
|
{boxes.map(box => {
|
||||||
|
const pos = draft && draft.index === box.elementIndex ? draft.box : box;
|
||||||
|
const isObj = box.type === 'obj';
|
||||||
|
const selected = selectedIndex === box.elementIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={box.elementIndex}
|
||||||
|
onPointerDown={drawing ? undefined : e => startDrag(e, box, 'move')}
|
||||||
|
className={classNames('absolute border-2', {
|
||||||
|
'pointer-events-none': drawing,
|
||||||
|
'cursor-move touch-none': !drawing,
|
||||||
|
'border-white ring-2 ring-blue-400': selected && !drawing,
|
||||||
|
'border-cyan-400': isObj && !(selected && !drawing),
|
||||||
|
'border-amber-400': !isObj && !(selected && !drawing),
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
left: `${pos.x1 / 10}%`,
|
||||||
|
top: `${pos.y1 / 10}%`,
|
||||||
|
width: `${(pos.x2 - pos.x1) / 10}%`,
|
||||||
|
height: `${(pos.y2 - pos.y1) / 10}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{box.label && (
|
||||||
|
<span
|
||||||
|
title={box.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-2 text-gray-900 pointer-events-none',
|
||||||
|
{ 'bg-cyan-400/90': isObj, 'bg-amber-400/90': !isObj },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{box.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected &&
|
||||||
|
!drawing &&
|
||||||
|
handles.map(h => (
|
||||||
|
<div
|
||||||
|
key={h.mode}
|
||||||
|
onPointerDown={e => startDrag(e, { ...box, ...pos }, h.mode)}
|
||||||
|
className={classNames(
|
||||||
|
'absolute w-3 h-3 rounded-sm border border-gray-900 bg-white touch-none',
|
||||||
|
h.cls,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{drawDraft && (
|
||||||
|
<div
|
||||||
|
className="absolute border-2 border-dashed border-white bg-white/10 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${Math.min(drawDraft.x1, drawDraft.x2) / 10}%`,
|
||||||
|
top: `${Math.min(drawDraft.y1, drawDraft.y2) / 10}%`,
|
||||||
|
width: `${Math.abs(drawDraft.x2 - drawDraft.x1) / 10}%`,
|
||||||
|
height: `${Math.abs(drawDraft.y2 - drawDraft.y1) / 10}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Absolute overlay layer of boxes. Place inside a `relative` container that wraps
|
// 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
|
// 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
|
// track the image during zoom/pan. Coords are percentages of the frame, so this
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
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 { Cog, SquareDashed } from 'lucide-react';
|
import { Cog, SquareDashed, Pencil } from 'lucide-react';
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { openConfirm } from './ConfirmModal';
|
import { openConfirm } from './ConfirmModal';
|
||||||
@@ -10,7 +10,15 @@ 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';
|
import BoundingBoxOverlay, { BoundingBoxEditor, parseBoundingBoxes, extractBoxes } from './BoundingBoxOverlay';
|
||||||
|
|
||||||
|
function safeParse(text: string): any {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
imgPath: string | null; // current image path
|
imgPath: string | null; // current image path
|
||||||
@@ -27,6 +35,9 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
const [savedCaption, setSavedCaption] = useState<string>('');
|
const [savedCaption, setSavedCaption] = useState<string>('');
|
||||||
const [isCaptionLoaded, setIsCaptionLoaded] = useState<boolean>(false);
|
const [isCaptionLoaded, setIsCaptionLoaded] = useState<boolean>(false);
|
||||||
const [showBoxes, setShowBoxes] = useState<boolean>(false);
|
const [showBoxes, setShowBoxes] = useState<boolean>(false);
|
||||||
|
const [isEditingBoxes, setIsEditingBoxes] = useState<boolean>(false);
|
||||||
|
const [selectedBoxIndex, setSelectedBoxIndex] = useState<number | null>(null);
|
||||||
|
const [isDrawing, setIsDrawing] = useState<boolean>(false);
|
||||||
const captionRef = useRef<string>('');
|
const captionRef = useRef<string>('');
|
||||||
const savedCaptionRef = useRef<string>('');
|
const savedCaptionRef = useRef<string>('');
|
||||||
const currentImgPathRef = useRef<string | null>(null);
|
const currentImgPathRef = useRef<string | null>(null);
|
||||||
@@ -34,6 +45,13 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
// Leave box-edit mode whenever the image changes, to avoid accidental edits.
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditingBoxes(false);
|
||||||
|
setSelectedBoxIndex(null);
|
||||||
|
setIsDrawing(false);
|
||||||
|
}, [imgPath]);
|
||||||
|
|
||||||
// open/close based on external value
|
// open/close based on external value
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(Boolean(imgPath));
|
setIsOpen(Boolean(imgPath));
|
||||||
@@ -88,6 +106,14 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
[onCaptionSaved],
|
[onCaptionSaved],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stable handle to the latest saveCaptionForPath so the fetch effect doesn't
|
||||||
|
// re-run (and re-fetch, blanking the caption) every time a save changes its
|
||||||
|
// identity via the parent's onCaptionSaved.
|
||||||
|
const saveCaptionForPathRef = useRef(saveCaptionForPath);
|
||||||
|
useEffect(() => {
|
||||||
|
saveCaptionForPathRef.current = saveCaptionForPath;
|
||||||
|
}, [saveCaptionForPath]);
|
||||||
|
|
||||||
const saveCaption = useCallback(() => {
|
const saveCaption = useCallback(() => {
|
||||||
if (!imgPath) return;
|
if (!imgPath) return;
|
||||||
saveCaptionForPath(imgPath, caption, savedCaption);
|
saveCaptionForPath(imgPath, caption, savedCaption);
|
||||||
@@ -97,7 +123,7 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousPath = currentImgPathRef.current;
|
const previousPath = currentImgPathRef.current;
|
||||||
if (previousPath && previousPath !== imgPath) {
|
if (previousPath && previousPath !== imgPath) {
|
||||||
saveCaptionForPath(previousPath, captionRef.current, savedCaptionRef.current);
|
saveCaptionForPathRef.current(previousPath, captionRef.current, savedCaptionRef.current);
|
||||||
}
|
}
|
||||||
currentImgPathRef.current = imgPath;
|
currentImgPathRef.current = imgPath;
|
||||||
|
|
||||||
@@ -137,7 +163,7 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [imgPath, saveCaptionForPath]);
|
}, [imgPath]);
|
||||||
|
|
||||||
// Save any pending caption when the viewer fully unmounts
|
// Save any pending caption when the viewer fully unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,6 +220,82 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
});
|
});
|
||||||
}, [imgPath, onChange, refreshImages]);
|
}, [imgPath, onChange, refreshImages]);
|
||||||
|
|
||||||
|
// Mutate the caption JSON's element array, updating the textarea ONLY (no
|
||||||
|
// network). Persisting happens via the Save button / nav auto-save. Returns
|
||||||
|
// whatever the mutator returns.
|
||||||
|
const editCaption = useCallback(
|
||||||
|
(fn: (elements: any[], data: any) => any): any => {
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(caption);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const elements = data?.compositional_deconstruction?.elements;
|
||||||
|
if (!Array.isArray(elements)) return undefined;
|
||||||
|
const result = fn(elements, data);
|
||||||
|
setCaption(JSON.stringify(data, null, 2));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[caption],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBoxChange = useCallback(
|
||||||
|
(elementIndex: number, box: { y1: number; x1: number; y2: number; x2: number }) => {
|
||||||
|
editCaption(els => {
|
||||||
|
if (els[elementIndex]) els[elementIndex].bbox = [box.y1, box.x1, box.y2, box.x2];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editCaption],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteBox = useCallback(
|
||||||
|
(elementIndex: number) => {
|
||||||
|
editCaption(els => {
|
||||||
|
els.splice(elementIndex, 1);
|
||||||
|
});
|
||||||
|
setSelectedBoxIndex(null);
|
||||||
|
},
|
||||||
|
[editCaption],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateBox = useCallback(
|
||||||
|
(box: { y1: number; x1: number; y2: number; x2: number }) => {
|
||||||
|
const newIndex = editCaption(els => {
|
||||||
|
els.push({ type: 'obj', bbox: [box.y1, box.x1, box.y2, box.x2], desc: '' });
|
||||||
|
return els.length - 1;
|
||||||
|
});
|
||||||
|
setSelectedBoxIndex(typeof newIndex === 'number' ? newIndex : null);
|
||||||
|
setIsDrawing(false);
|
||||||
|
},
|
||||||
|
[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
|
// keyboard events while open — skip nav while caption textarea is focused
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -219,7 +321,12 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
break;
|
break;
|
||||||
case 'Delete':
|
case 'Delete':
|
||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
handleDelete();
|
// While editing boxes, Delete removes the selected box (never the image).
|
||||||
|
if (isEditingBoxes) {
|
||||||
|
if (selectedBoxIndex != null) handleDeleteBox(selectedBoxIndex);
|
||||||
|
} else {
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -227,7 +334,7 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, onCancel, handlePrev, handleNext, handleDelete]);
|
}, [isOpen, onCancel, handlePrev, handleNext, handleDelete, isEditingBoxes, selectedBoxIndex, handleDeleteBox]);
|
||||||
|
|
||||||
// Touch swipe navigation
|
// Touch swipe navigation
|
||||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
@@ -285,6 +392,13 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
const boundingBoxes = useMemo(() => parseBoundingBoxes(caption), [caption]);
|
const boundingBoxes = useMemo(() => parseBoundingBoxes(caption), [caption]);
|
||||||
const canShowBoxes = Boolean(boundingBoxes && imgPath && !isAudio(imgPath) && !isVideo(imgPath));
|
const canShowBoxes = Boolean(boundingBoxes && imgPath && !isAudio(imgPath) && !isVideo(imgPath));
|
||||||
|
|
||||||
|
// Boxes and the selected element are derived from the (locally edited) caption.
|
||||||
|
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;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -323,9 +437,9 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
initialScale={1}
|
initialScale={1}
|
||||||
minScale={1}
|
minScale={1}
|
||||||
maxScale={6}
|
maxScale={6}
|
||||||
doubleClick={{ mode: 'toggle', step: 2 }}
|
doubleClick={{ mode: 'toggle', step: 2, disabled: isEditingBoxes }}
|
||||||
wheel={{ step: 0.2 }}
|
wheel={{ step: 0.2, disabled: isEditingBoxes }}
|
||||||
panning={{ disabled: false, allowRightClickPan: false }}
|
panning={{ disabled: isEditingBoxes, allowRightClickPan: false }}
|
||||||
onTransform={(_ref, state) => {
|
onTransform={(_ref, state) => {
|
||||||
zoomedRef.current = state.scale > 1.01;
|
zoomedRef.current = state.scale > 1.01;
|
||||||
}}
|
}}
|
||||||
@@ -338,7 +452,18 @@ 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 && <BoundingBoxOverlay boxes={boundingBoxes} />}
|
{isEditingBoxes ? (
|
||||||
|
<BoundingBoxEditor
|
||||||
|
boxes={editBoxes}
|
||||||
|
selectedIndex={selectedBoxIndex}
|
||||||
|
drawing={isDrawing}
|
||||||
|
onSelect={setSelectedBoxIndex}
|
||||||
|
onChangeBox={handleBoxChange}
|
||||||
|
onCreateBox={handleCreateBox}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
showBoxes && boundingBoxes && <BoundingBoxOverlay boxes={boundingBoxes} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TransformComponent>
|
</TransformComponent>
|
||||||
</TransformWrapper>
|
</TransformWrapper>
|
||||||
@@ -371,12 +496,106 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
disabled={!isCaptionLoaded}
|
disabled={!isCaptionLoaded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isEditingBoxes && (
|
||||||
|
<div className="rounded border border-gray-700 bg-gray-900 p-2 flex flex-col gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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'}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{isDrawing
|
||||||
|
? 'Drag on the image to draw a new box'
|
||||||
|
: 'Click a box to select; drag to move, handles to resize'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveCaption}
|
||||||
|
disabled={isCaptionCurrent}
|
||||||
|
className={classNames('ml-auto px-3 py-1 rounded border', {
|
||||||
|
'bg-green-600 border-green-500 text-white hover:bg-green-500': !isCaptionCurrent,
|
||||||
|
'border-gray-700 text-gray-500 cursor-default': isCaptionCurrent,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isCaptionCurrent ? 'Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedElement && (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-gray-700 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400">Type:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteBox(selectedBoxIndex!)}
|
||||||
|
className="ml-auto px-2 py-0.5 rounded border border-red-700 text-red-400 hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedElement.type === 'text' && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-gray-400">Text (shown in image)</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-gray-950 text-gray-100 rounded border border-gray-700 p-1 resize-none outline-none focus:border-blue-500"
|
||||||
|
rows={2}
|
||||||
|
value={selectedElement.text ?? ''}
|
||||||
|
onChange={e => handleFieldChange('text', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-gray-400">Description</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-gray-950 text-gray-100 rounded border border-gray-700 p-1 resize-none outline-none focus:border-blue-500"
|
||||||
|
rows={2}
|
||||||
|
value={selectedElement.desc ?? ''}
|
||||||
|
onChange={e => handleFieldChange('desc', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-2 z-20">
|
<div className="absolute top-2 right-2 flex items-center gap-2 z-20">
|
||||||
{canShowBoxes && (
|
{canShowBoxes && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowBoxes(v => !v)}
|
onClick={() => {
|
||||||
|
const next = !showBoxes;
|
||||||
|
setShowBoxes(next);
|
||||||
|
if (!next) {
|
||||||
|
setIsEditingBoxes(false);
|
||||||
|
setSelectedBoxIndex(null);
|
||||||
|
setIsDrawing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={showBoxes ? 'Hide bounding boxes' : 'Show bounding boxes'}
|
title={showBoxes ? 'Hide bounding boxes' : 'Show bounding boxes'}
|
||||||
className={classNames('bg-gray-900 rounded-full p-1 leading-[0px] hover:opacity-100', {
|
className={classNames('bg-gray-900 rounded-full p-1 leading-[0px] hover:opacity-100', {
|
||||||
'opacity-100 text-blue-400': showBoxes,
|
'opacity-100 text-blue-400': showBoxes,
|
||||||
@@ -386,6 +605,26 @@ export default function DatasetImageViewer({ imgPath, imageList, onChange, refre
|
|||||||
<SquareDashed />
|
<SquareDashed />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{((canShowBoxes && showBoxes) || isEditingBoxes) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !isEditingBoxes;
|
||||||
|
setIsEditingBoxes(next);
|
||||||
|
if (!next) {
|
||||||
|
setSelectedBoxIndex(null);
|
||||||
|
setIsDrawing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isEditingBoxes ? 'Done editing boxes' : 'Edit bounding boxes'}
|
||||||
|
className={classNames('bg-gray-900 rounded-full p-1 leading-[0px] hover:opacity-100', {
|
||||||
|
'opacity-100 text-blue-400': isEditingBoxes,
|
||||||
|
'opacity-50': !isEditingBoxes,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100">
|
<div className="bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100">
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton>
|
<MenuButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user