| |
| |
| |
| |
| |
| |
|
|
| (function() { |
| 'use strict'; |
|
|
| |
| const boundingBoxes = {}; |
| let selectedBox = null; |
| let currentMode = 'draw'; |
| let isDrawing = false; |
| let isResizing = false; |
| let resizeHandle = null; |
| let drawStart = null; |
| let resizeStart = null; |
| let currentContainer = null; |
| const HANDLE_SIZE = 8; |
|
|
| |
| const LABEL_COLORS = [ |
| '#e74c3c', |
| '#3498db', |
| '#2ecc71', |
| '#9b59b6', |
| '#f39c12', |
| '#1abc9c', |
| '#e91e63', |
| '#00bcd4', |
| '#ff5722', |
| '#607d8b', |
| '#8bc34a', |
| '#673ab7', |
| ]; |
|
|
| |
| const labelColorCache = {}; |
|
|
| |
| |
| |
| |
| function getLabelColor(label) { |
| if (!label) return '#0066cc'; |
|
|
| |
| if (labelColorCache[label]) { |
| return labelColorCache[label]; |
| } |
|
|
| |
| let hash = 0; |
| for (let i = 0; i < label.length; i++) { |
| const char = label.charCodeAt(i); |
| hash = ((hash << 5) - hash) + char; |
| hash = hash & hash; |
| } |
|
|
| |
| const colorIndex = Math.abs(hash) % LABEL_COLORS.length; |
| const color = LABEL_COLORS[colorIndex]; |
|
|
| |
| labelColorCache[label] = color; |
| return color; |
| } |
|
|
| |
| |
| |
| function hexToRgba(hex, alpha) { |
| const r = parseInt(hex.slice(1, 3), 16); |
| const g = parseInt(hex.slice(3, 5), 16); |
| const b = parseInt(hex.slice(5, 7), 16); |
| return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
| } |
|
|
| |
| |
| |
| function getCurrentSelectedLabel() { |
| |
| const selectedRadio = document.querySelector('input[type="radio"]:checked'); |
| if (selectedRadio) { |
| return selectedRadio.value || selectedRadio.getAttribute('data-label') || selectedRadio.getAttribute('data-name'); |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| function initDocumentBoundingBoxes() { |
| const displays = document.querySelectorAll('.document-display[data-annotation-mode="bounding_box"]'); |
| displays.forEach(initDisplay); |
| } |
|
|
| |
| |
| |
| function initDisplay(container) { |
| currentContainer = container; |
| const fieldKey = container.getAttribute('data-field-key'); |
| const minSize = parseInt(container.getAttribute('data-bbox-min-size') || '10', 10); |
| const showLabels = container.getAttribute('data-show-bbox-labels') !== 'false'; |
|
|
| |
| if (!boundingBoxes[fieldKey]) { |
| boundingBoxes[fieldKey] = []; |
| } |
|
|
| |
| const bboxCanvas = container.querySelector('.document-bbox-canvas'); |
| const contentEl = container.querySelector('.document-bbox-content'); |
|
|
| if (bboxCanvas && contentEl) { |
| initDrawingCanvas(bboxCanvas, contentEl, container, minSize, showLabels); |
| } |
|
|
| |
| initToolButtons(container); |
|
|
| |
| loadExistingAnnotations(container, fieldKey); |
|
|
| |
| const resizeObserver = new ResizeObserver(() => { |
| syncCanvasSize(container); |
| }); |
| resizeObserver.observe(contentEl); |
| } |
|
|
| |
| |
| |
| |
| function getResizeHandleAtPoint(point, box, canvas) { |
| if (!box) return null; |
|
|
| const pixelBbox = box.bbox_pixels || [ |
| box.bbox[0] * canvas.width, |
| box.bbox[1] * canvas.height, |
| box.bbox[2] * canvas.width, |
| box.bbox[3] * canvas.height |
| ]; |
|
|
| const [x, y, w, h] = pixelBbox; |
| const handles = { |
| 'nw': { x: x, y: y }, |
| 'ne': { x: x + w, y: y }, |
| 'sw': { x: x, y: y + h }, |
| 'se': { x: x + w, y: y + h } |
| }; |
|
|
| for (const [handle, pos] of Object.entries(handles)) { |
| if (Math.abs(point.x - pos.x) <= HANDLE_SIZE && |
| Math.abs(point.y - pos.y) <= HANDLE_SIZE) { |
| return handle; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| function updateCursor(canvas, point, box) { |
| const handle = getResizeHandleAtPoint(point, box, canvas); |
| if (handle) { |
| |
| if (handle === 'nw' || handle === 'se') { |
| canvas.style.cursor = 'nwse-resize'; |
| } else { |
| canvas.style.cursor = 'nesw-resize'; |
| } |
| } else { |
| |
| canvas.style.cursor = currentMode === 'draw' ? 'crosshair' : 'pointer'; |
| } |
| } |
|
|
| |
| |
| |
| function initDrawingCanvas(canvas, contentEl, container, minSize, showLabels) { |
| const ctx = canvas.getContext('2d'); |
|
|
| |
| syncCanvasSize(container); |
|
|
| |
| canvas.addEventListener('mousedown', function(e) { |
| const rect = canvas.getBoundingClientRect(); |
| const mousePos = { |
| x: e.clientX - rect.left, |
| y: e.clientY - rect.top |
| }; |
|
|
| |
| |
| if (selectedBox) { |
| const handle = getResizeHandleAtPoint(mousePos, selectedBox, canvas); |
| if (handle) { |
| isResizing = true; |
| resizeHandle = handle; |
| resizeStart = mousePos; |
| e.preventDefault(); |
| return; |
| } |
| } |
|
|
| |
| if (currentMode === 'draw') { |
| isDrawing = true; |
| drawStart = mousePos; |
| } |
| }); |
|
|
| canvas.addEventListener('mousemove', function(e) { |
| const rect = canvas.getBoundingClientRect(); |
| const currentPos = { |
| x: e.clientX - rect.left, |
| y: e.clientY - rect.top |
| }; |
|
|
| |
| if (selectedBox) { |
| updateCursor(canvas, currentPos, selectedBox); |
| } else if (currentMode === 'draw') { |
| canvas.style.cursor = 'crosshair'; |
| } else { |
| canvas.style.cursor = 'pointer'; |
| } |
|
|
| |
| if (isResizing && selectedBox) { |
| resizeSelectedBox(container, canvas, currentPos); |
| return; |
| } |
|
|
| |
| if (isDrawing && currentMode === 'draw') { |
| redrawCanvas(container); |
| drawTemporaryRect(ctx, drawStart, currentPos); |
| } |
| }); |
|
|
| canvas.addEventListener('mouseup', function(e) { |
| const rect = canvas.getBoundingClientRect(); |
| const endPos = { |
| x: e.clientX - rect.left, |
| y: e.clientY - rect.top |
| }; |
|
|
| |
| if (isResizing) { |
| isResizing = false; |
| resizeHandle = null; |
| resizeStart = null; |
| |
| if (selectedBox) { |
| updateNormalizedBbox(selectedBox, canvas); |
| triggerBoundingBoxEvent(container, 'document-bbox:resized', selectedBox); |
| } |
| redrawCanvas(container); |
| return; |
| } |
|
|
| |
| if (isDrawing && currentMode === 'draw') { |
| isDrawing = false; |
|
|
| |
| const width = Math.abs(endPos.x - drawStart.x); |
| const height = Math.abs(endPos.y - drawStart.y); |
|
|
| if (width >= minSize && height >= minSize) { |
| createBoundingBox(container, drawStart, endPos); |
| } |
|
|
| redrawCanvas(container); |
| } |
| }); |
|
|
| canvas.addEventListener('mouseleave', function() { |
| if (isDrawing) { |
| isDrawing = false; |
| redrawCanvas(container); |
| } |
| if (isResizing) { |
| isResizing = false; |
| resizeHandle = null; |
| if (selectedBox) { |
| updateNormalizedBbox(selectedBox, canvas); |
| } |
| redrawCanvas(container); |
| } |
| }); |
|
|
| |
| canvas.addEventListener('click', function(e) { |
| if (currentMode !== 'select') return; |
| if (isResizing) return; |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const clickPos = { |
| x: e.clientX - rect.left, |
| y: e.clientY - rect.top |
| }; |
|
|
| |
| if (selectedBox && getResizeHandleAtPoint(clickPos, selectedBox, canvas)) { |
| return; |
| } |
|
|
| selectBoxAtPoint(container, clickPos); |
| }); |
| } |
|
|
| |
| |
| |
| function resizeSelectedBox(container, canvas, currentPos) { |
| if (!selectedBox || !resizeHandle) return; |
|
|
| const pixelBbox = selectedBox.bbox_pixels; |
| let [x, y, w, h] = pixelBbox; |
|
|
| const minSize = parseInt(container.getAttribute('data-bbox-min-size') || '10', 10); |
|
|
| switch (resizeHandle) { |
| case 'nw': |
| const newW_nw = (x + w) - currentPos.x; |
| const newH_nw = (y + h) - currentPos.y; |
| if (newW_nw >= minSize && newH_nw >= minSize) { |
| x = currentPos.x; |
| y = currentPos.y; |
| w = newW_nw; |
| h = newH_nw; |
| } |
| break; |
| case 'ne': |
| const newW_ne = currentPos.x - x; |
| const newH_ne = (y + h) - currentPos.y; |
| if (newW_ne >= minSize && newH_ne >= minSize) { |
| y = currentPos.y; |
| w = newW_ne; |
| h = newH_ne; |
| } |
| break; |
| case 'sw': |
| const newW_sw = (x + w) - currentPos.x; |
| const newH_sw = currentPos.y - y; |
| if (newW_sw >= minSize && newH_sw >= minSize) { |
| x = currentPos.x; |
| w = newW_sw; |
| h = newH_sw; |
| } |
| break; |
| case 'se': |
| const newW_se = currentPos.x - x; |
| const newH_se = currentPos.y - y; |
| if (newW_se >= minSize && newH_se >= minSize) { |
| w = newW_se; |
| h = newH_se; |
| } |
| break; |
| } |
|
|
| |
| selectedBox.bbox_pixels = [x, y, w, h]; |
|
|
| redrawCanvas(container); |
| } |
|
|
| |
| |
| |
| function updateNormalizedBbox(box, canvas) { |
| if (!box.bbox_pixels) return; |
|
|
| const [x, y, w, h] = box.bbox_pixels; |
| box.bbox = [ |
| x / canvas.width, |
| y / canvas.height, |
| w / canvas.width, |
| h / canvas.height |
| ]; |
| } |
|
|
| |
| |
| |
| function syncCanvasSize(container) { |
| const canvas = container.querySelector('.document-bbox-canvas'); |
| const contentEl = container.querySelector('.document-bbox-content'); |
|
|
| if (canvas && contentEl) { |
| canvas.width = contentEl.offsetWidth; |
| canvas.height = contentEl.offsetHeight; |
| redrawCanvas(container); |
| } |
| } |
|
|
| |
| |
| |
| function drawTemporaryRect(ctx, start, end) { |
| |
| const currentLabel = getCurrentSelectedLabel(); |
| const color = getLabelColor(currentLabel); |
|
|
| ctx.strokeStyle = color; |
| ctx.fillStyle = hexToRgba(color, 0.15); |
| ctx.lineWidth = 2; |
| ctx.setLineDash([5, 5]); |
|
|
| const x = Math.min(start.x, end.x); |
| const y = Math.min(start.y, end.y); |
| const w = Math.abs(end.x - start.x); |
| const h = Math.abs(end.y - start.y); |
|
|
| ctx.fillRect(x, y, w, h); |
| ctx.strokeRect(x, y, w, h); |
| ctx.setLineDash([]); |
| } |
|
|
| |
| |
| |
| function createBoundingBox(container, start, end) { |
| const fieldKey = container.getAttribute('data-field-key'); |
| const canvas = container.querySelector('.document-bbox-canvas'); |
|
|
| |
| const x = Math.min(start.x, end.x) / canvas.width; |
| const y = Math.min(start.y, end.y) / canvas.height; |
| const width = Math.abs(end.x - start.x) / canvas.width; |
| const height = Math.abs(end.y - start.y) / canvas.height; |
|
|
| |
| const boxId = `bbox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
|
|
| |
| const currentLabel = getCurrentSelectedLabel(); |
|
|
| |
| const box = { |
| id: boxId, |
| bbox: [x, y, width, height], |
| bbox_pixels: [ |
| Math.min(start.x, end.x), |
| Math.min(start.y, end.y), |
| Math.abs(end.x - start.x), |
| Math.abs(end.y - start.y) |
| ], |
| label: currentLabel, |
| created_at: new Date().toISOString() |
| }; |
|
|
| |
| boundingBoxes[fieldKey].push(box); |
|
|
| |
| redrawCanvas(container); |
| updateBoxCount(container); |
|
|
| |
| triggerBoundingBoxEvent(container, 'document-bbox:created', box); |
|
|
| |
| promptForLabel(container, box); |
| } |
|
|
| |
| |
| |
| |
| function promptForLabel(container, box) { |
| |
| selectedBox = box; |
| redrawCanvas(container); |
|
|
| |
| if (box.label) { |
| return; |
| } |
|
|
| const labelButtons = document.querySelectorAll('.span-label-btn, .annotation-label, input[type="radio"]'); |
|
|
| if (labelButtons.length > 0) { |
| |
| const targetBox = box; |
|
|
| const labelHandler = function(e) { |
| const label = e.target.getAttribute('data-label') || |
| e.target.getAttribute('data-name') || |
| e.target.value || |
| e.target.textContent.trim(); |
|
|
| if (label && targetBox) { |
| targetBox.label = label; |
| redrawCanvas(container); |
| triggerBoundingBoxEvent(container, 'document-bbox:labeled', targetBox); |
| } |
|
|
| labelButtons.forEach(btn => { |
| btn.removeEventListener('click', labelHandler); |
| btn.removeEventListener('change', labelHandler); |
| }); |
| }; |
|
|
| labelButtons.forEach(btn => { |
| btn.addEventListener('click', labelHandler); |
| btn.addEventListener('change', labelHandler); |
| }); |
| } |
| } |
|
|
| |
| |
| |
| function selectBoxAtPoint(container, point) { |
| const fieldKey = container.getAttribute('data-field-key'); |
| const boxes = boundingBoxes[fieldKey] || []; |
| const canvas = container.querySelector('.document-bbox-canvas'); |
|
|
| selectedBox = null; |
|
|
| |
| for (let i = boxes.length - 1; i >= 0; i--) { |
| const box = boxes[i]; |
| const pixelBbox = box.bbox_pixels || [ |
| box.bbox[0] * canvas.width, |
| box.bbox[1] * canvas.height, |
| box.bbox[2] * canvas.width, |
| box.bbox[3] * canvas.height |
| ]; |
|
|
| if (point.x >= pixelBbox[0] && |
| point.x <= pixelBbox[0] + pixelBbox[2] && |
| point.y >= pixelBbox[1] && |
| point.y <= pixelBbox[1] + pixelBbox[3]) { |
| selectedBox = box; |
| break; |
| } |
| } |
|
|
| redrawCanvas(container); |
| triggerBoundingBoxEvent(container, 'document-bbox:selected', selectedBox); |
| } |
|
|
| |
| |
| |
| function deleteSelectedBox(container) { |
| if (!selectedBox) return; |
|
|
| const fieldKey = container.getAttribute('data-field-key'); |
| const boxes = boundingBoxes[fieldKey] || []; |
|
|
| const index = boxes.findIndex(b => b.id === selectedBox.id); |
| if (index !== -1) { |
| boxes.splice(index, 1); |
| triggerBoundingBoxEvent(container, 'document-bbox:deleted', selectedBox); |
| selectedBox = null; |
| redrawCanvas(container); |
| updateBoxCount(container); |
| } |
| } |
|
|
| |
| |
| |
| function redrawCanvas(container) { |
| const fieldKey = container.getAttribute('data-field-key'); |
| const canvas = container.querySelector('.document-bbox-canvas'); |
| if (!canvas) return; |
|
|
| const ctx = canvas.getContext('2d'); |
| const showLabels = container.getAttribute('data-show-bbox-labels') !== 'false'; |
|
|
| |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
| |
| const boxes = boundingBoxes[fieldKey] || []; |
|
|
| boxes.forEach(box => { |
| const isSelected = selectedBox && selectedBox.id === box.id; |
| drawBoundingBox(ctx, box, canvas.width, canvas.height, isSelected, showLabels); |
| }); |
| } |
|
|
| |
| |
| |
| function drawBoundingBox(ctx, box, canvasWidth, canvasHeight, isSelected, showLabels) { |
| const pixelBbox = box.bbox_pixels || [ |
| box.bbox[0] * canvasWidth, |
| box.bbox[1] * canvasHeight, |
| box.bbox[2] * canvasWidth, |
| box.bbox[3] * canvasHeight |
| ]; |
|
|
| const [x, y, w, h] = pixelBbox; |
|
|
| |
| const labelColor = getLabelColor(box.label); |
|
|
| |
| if (isSelected) { |
| ctx.strokeStyle = labelColor; |
| ctx.fillStyle = hexToRgba(labelColor, 0.25); |
| ctx.lineWidth = 3; |
| } else { |
| ctx.strokeStyle = labelColor; |
| ctx.fillStyle = hexToRgba(labelColor, 0.1); |
| ctx.lineWidth = 2; |
| } |
|
|
| ctx.fillRect(x, y, w, h); |
| ctx.strokeRect(x, y, w, h); |
|
|
| |
| if (showLabels && box.label) { |
| ctx.font = 'bold 12px sans-serif'; |
| const textWidth = ctx.measureText(box.label).width; |
| const labelHeight = 20; |
| const labelY = y - labelHeight - 2; |
|
|
| |
| ctx.fillStyle = labelColor; |
| ctx.fillRect(x, Math.max(0, labelY), textWidth + 10, labelHeight); |
|
|
| |
| ctx.fillStyle = 'white'; |
| ctx.fillText(box.label, x + 5, Math.max(14, labelY + 14)); |
| } |
|
|
| |
| if (isSelected) { |
| ctx.fillStyle = labelColor; |
| ctx.strokeStyle = 'white'; |
| ctx.lineWidth = 1; |
|
|
| |
| const handles = [ |
| [x - HANDLE_SIZE/2, y - HANDLE_SIZE/2], |
| [x + w - HANDLE_SIZE/2, y - HANDLE_SIZE/2], |
| [x - HANDLE_SIZE/2, y + h - HANDLE_SIZE/2], |
| [x + w - HANDLE_SIZE/2, y + h - HANDLE_SIZE/2] |
| ]; |
|
|
| handles.forEach(([hx, hy]) => { |
| ctx.fillRect(hx, hy, HANDLE_SIZE, HANDLE_SIZE); |
| ctx.strokeRect(hx, hy, HANDLE_SIZE, HANDLE_SIZE); |
| }); |
| } |
| } |
|
|
| |
| |
| |
| function initToolButtons(container) { |
| const drawBtn = container.querySelector('.document-bbox-draw-btn'); |
| const selectBtn = container.querySelector('.document-bbox-select-btn'); |
| const deleteBtn = container.querySelector('.document-bbox-delete-btn'); |
|
|
| if (drawBtn) { |
| drawBtn.addEventListener('click', function() { |
| setMode(container, 'draw'); |
| }); |
| } |
|
|
| if (selectBtn) { |
| selectBtn.addEventListener('click', function() { |
| setMode(container, 'select'); |
| }); |
| } |
|
|
| if (deleteBtn) { |
| deleteBtn.addEventListener('click', function() { |
| deleteSelectedBox(container); |
| }); |
| } |
| } |
|
|
| |
| |
| |
| function setMode(container, mode) { |
| currentMode = mode; |
|
|
| const drawBtn = container.querySelector('.document-bbox-draw-btn'); |
| const selectBtn = container.querySelector('.document-bbox-select-btn'); |
| const bboxContainer = container.querySelector('.document-bbox-container'); |
|
|
| if (drawBtn) drawBtn.classList.toggle('active', mode === 'draw'); |
| if (selectBtn) selectBtn.classList.toggle('active', mode === 'select'); |
| if (bboxContainer) bboxContainer.classList.toggle('selecting', mode === 'select'); |
| } |
|
|
| |
| |
| |
| function updateBoxCount(container) { |
| const fieldKey = container.getAttribute('data-field-key'); |
| const boxes = boundingBoxes[fieldKey] || []; |
|
|
| const countEl = container.querySelector('.document-bbox-count .count'); |
| if (countEl) { |
| countEl.textContent = boxes.length; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function getDataInput(container) { |
| return container.querySelector('input.annotation-data-input') |
| || container.querySelector('input[name="bbox_annotations"]'); |
| } |
|
|
| |
| |
| |
| |
| function persistBoxes(container) { |
| const input = getDataInput(container); |
| if (!input) return; |
| const fieldKey = container.getAttribute('data-field-key'); |
| input.value = JSON.stringify(boundingBoxes[fieldKey] || []); |
| |
| input.setAttribute('data-modified', 'true'); |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| } |
|
|
| |
| |
| |
| function loadExistingAnnotations(container, fieldKey) { |
| const existingData = getDataInput(container); |
| if (existingData && existingData.value) { |
| try { |
| const annotations = JSON.parse(existingData.value); |
| if (Array.isArray(annotations)) { |
| boundingBoxes[fieldKey] = annotations; |
| } |
| } catch (e) { |
| console.error('Error loading existing document bbox annotations:', e); |
| } |
| } |
|
|
| redrawCanvas(container); |
| updateBoxCount(container); |
| } |
|
|
| |
| |
| |
| |
| function triggerBoundingBoxEvent(container, eventName, data) { |
| persistBoxes(container); |
| const event = new CustomEvent(eventName, { |
| detail: { |
| fieldKey: container.getAttribute('data-field-key'), |
| data: data, |
| allBoxes: getAllBoundingBoxes(container) |
| }, |
| bubbles: true |
| }); |
| container.dispatchEvent(event); |
| } |
|
|
| |
| |
| |
| function getAllBoundingBoxes(container) { |
| const fieldKey = container.getAttribute('data-field-key'); |
| const boxes = boundingBoxes[fieldKey] || []; |
|
|
| return boxes.map(box => ({ |
| ...box, |
| format_coords: { |
| format: 'bounding_box', |
| bbox: box.bbox, |
| bbox_pixels: box.bbox_pixels, |
| label: box.label |
| } |
| })); |
| } |
|
|
| |
| |
| |
| function exportBoundingBoxes(container) { |
| return getAllBoundingBoxes(container); |
| } |
|
|
| |
| window.DocumentBoundingBox = { |
| init: initDocumentBoundingBoxes, |
| initDisplay: initDisplay, |
| getAllBoxes: getAllBoundingBoxes, |
| exportBoxes: exportBoundingBoxes, |
| syncCanvasSize: syncCanvasSize, |
| deleteSelected: function(container) { deleteSelectedBox(container); }, |
| setMode: setMode |
| }; |
|
|
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', initDocumentBoundingBoxes); |
| } else { |
| initDocumentBoundingBoxes(); |
| } |
|
|
| })(); |
|
|