Spaces:
Paused
Paused
| /** | |
| * Image Annotation Manager | |
| * | |
| * Provides canvas-based image annotation capabilities using Fabric.js. | |
| * Supports bounding boxes, polygons, freeform drawing, and landmark points. | |
| */ | |
| class ImageAnnotationManager { | |
| /** | |
| * Create an ImageAnnotationManager. | |
| * @param {string} canvasId - ID of the canvas element | |
| * @param {string} inputId - ID of the hidden input for storing annotation data | |
| * @param {Object} config - Configuration object | |
| */ | |
| constructor(canvasId, inputId, config) { | |
| this.canvasId = canvasId; | |
| this.inputId = inputId; | |
| this.config = config; | |
| this.canvas = null; | |
| this.image = null; | |
| this.currentTool = null; | |
| this.currentLabel = null; | |
| this.currentColor = '#FF6B6B'; | |
| // Drawing state | |
| this.isDrawing = false; | |
| this.drawingObject = null; | |
| this.polygonPoints = []; | |
| this.isPanning = false; | |
| this.lastPosX = 0; | |
| this.lastPosY = 0; | |
| // History for undo/redo | |
| this.history = []; | |
| this.historyIndex = -1; | |
| this.maxHistory = 50; | |
| // Annotations storage | |
| this.annotations = []; | |
| // Callback for annotation count changes | |
| this.onAnnotationChange = null; | |
| // Segmentation mask state | |
| this.maskCanvas = null; | |
| this.maskCtx = null; | |
| this.masks = {}; // label -> ImageData | |
| this.brushSize = config.brushSize || 20; | |
| this.eraserSize = config.eraserSize || 20; | |
| this.maskOpacity = config.maskOpacity || 0.5; | |
| this.isMaskDrawing = false; | |
| // Initialize canvas | |
| this._initCanvas(); | |
| this._initMaskCanvas(); | |
| this._setupEventListeners(); | |
| this._setupKeyboardShortcuts(); | |
| } | |
| /** | |
| * Initialize the mask canvas for segmentation. | |
| */ | |
| _initMaskCanvas() { | |
| const canvasEl = document.getElementById(this.canvasId); | |
| if (!canvasEl) return; | |
| const maskCanvasId = this.canvasId.replace('canvas-', 'mask-canvas-'); | |
| this.maskCanvas = document.getElementById(maskCanvasId); | |
| if (!this.maskCanvas) { | |
| // Create mask canvas if it doesn't exist | |
| this.maskCanvas = document.createElement('canvas'); | |
| this.maskCanvas.id = maskCanvasId; | |
| this.maskCanvas.className = 'mask-canvas'; | |
| canvasEl.parentElement.appendChild(this.maskCanvas); | |
| } | |
| // Position mask canvas over the main canvas | |
| this.maskCanvas.style.position = 'absolute'; | |
| this.maskCanvas.style.top = '0'; | |
| this.maskCanvas.style.left = '0'; | |
| this.maskCanvas.style.pointerEvents = 'none'; // Let events pass through to Fabric canvas | |
| this.maskCtx = this.maskCanvas.getContext('2d'); | |
| } | |
| /** | |
| * Set up mask canvas event listeners. | |
| */ | |
| _setupMaskEventListeners() { | |
| if (!this.maskCanvas) return; | |
| this.maskCanvas.addEventListener('mousedown', (e) => { | |
| if (this.currentTool === 'fill') { | |
| this._floodFill(e); | |
| } else if (this.currentTool === 'brush' || this.currentTool === 'eraser') { | |
| this._startMaskDraw(e); | |
| } | |
| }); | |
| this.maskCanvas.addEventListener('mousemove', (e) => { | |
| this._continueMaskDraw(e); | |
| }); | |
| this.maskCanvas.addEventListener('mouseup', () => { | |
| this._finishMaskDraw(); | |
| }); | |
| this.maskCanvas.addEventListener('mouseleave', () => { | |
| this._finishMaskDraw(); | |
| }); | |
| } | |
| /** | |
| * Initialize the Fabric.js canvas. | |
| */ | |
| _initCanvas() { | |
| const canvasEl = document.getElementById(this.canvasId); | |
| if (!canvasEl) { | |
| console.error('Canvas element not found:', this.canvasId); | |
| return; | |
| } | |
| // Get parent container dimensions | |
| const container = canvasEl.parentElement; | |
| const width = container.clientWidth || 800; | |
| const height = 600; | |
| this.canvas = new fabric.Canvas(this.canvasId, { | |
| width: width, | |
| height: height, | |
| selection: true, | |
| preserveObjectStacking: true, | |
| backgroundColor: '#f8f9fa', // Light gray background | |
| }); | |
| // Set initial viewport | |
| this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); | |
| } | |
| /** | |
| * Set up canvas event listeners for drawing. | |
| */ | |
| _setupEventListeners() { | |
| if (!this.canvas) return; | |
| // Set up mask canvas event listeners | |
| this._setupMaskEventListeners(); | |
| // Mouse down - start drawing | |
| this.canvas.on('mouse:down', (opt) => { | |
| const evt = opt.e; | |
| const pointer = this.canvas.getPointer(evt); | |
| // Handle pan with space key | |
| if (evt.altKey || this._spaceKeyDown) { | |
| this.isPanning = true; | |
| this.canvas.selection = false; | |
| this.lastPosX = evt.clientX; | |
| this.lastPosY = evt.clientY; | |
| return; | |
| } | |
| // If clicking on existing object, don't start new drawing | |
| if (opt.target && opt.target !== this.image) { | |
| return; | |
| } | |
| this._startDrawing(pointer); | |
| }); | |
| // Mouse move - continue drawing or pan | |
| this.canvas.on('mouse:move', (opt) => { | |
| const evt = opt.e; | |
| if (this.isPanning) { | |
| const vpt = this.canvas.viewportTransform; | |
| vpt[4] += evt.clientX - this.lastPosX; | |
| vpt[5] += evt.clientY - this.lastPosY; | |
| this.canvas.requestRenderAll(); | |
| this.lastPosX = evt.clientX; | |
| this.lastPosY = evt.clientY; | |
| // Re-render masks to follow pan | |
| this._renderAllMasks(); | |
| return; | |
| } | |
| if (this.isDrawing) { | |
| const pointer = this.canvas.getPointer(evt); | |
| this._continueDrawing(pointer); | |
| } | |
| }); | |
| // Mouse up - finish drawing | |
| this.canvas.on('mouse:up', (opt) => { | |
| if (this.isPanning) { | |
| this.isPanning = false; | |
| this.canvas.selection = true; | |
| return; | |
| } | |
| if (this.isDrawing) { | |
| this._finishDrawing(); | |
| } | |
| }); | |
| // Double click - complete polygon | |
| this.canvas.on('mouse:dblclick', (opt) => { | |
| if (this.currentTool === 'polygon' && this.polygonPoints.length > 2) { | |
| this._completePolygon(); | |
| } | |
| }); | |
| // Object modified - save state | |
| this.canvas.on('object:modified', () => { | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| }); | |
| // Object removed | |
| this.canvas.on('object:removed', (opt) => { | |
| if (opt.target && opt.target.annotationData) { | |
| this._updateAnnotationData(); | |
| } | |
| }); | |
| // Selection events | |
| this.canvas.on('selection:created', () => { | |
| this._updateDeleteButtonState(); | |
| }); | |
| this.canvas.on('selection:cleared', () => { | |
| this._updateDeleteButtonState(); | |
| }); | |
| } | |
| /** | |
| * Set up keyboard shortcuts. | |
| */ | |
| _setupKeyboardShortcuts() { | |
| this._spaceKeyDown = false; | |
| document.addEventListener('keydown', (e) => { | |
| // Only handle if canvas container is focused or visible | |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); | |
| if (!container || !this._isElementVisible(container)) return; | |
| // Space for pan | |
| if (e.code === 'Space' && !this._spaceKeyDown) { | |
| this._spaceKeyDown = true; | |
| this.canvas.defaultCursor = 'grab'; | |
| e.preventDefault(); | |
| } | |
| // Tool shortcuts | |
| if (!e.ctrlKey && !e.metaKey) { | |
| switch (e.key.toLowerCase()) { | |
| case 'b': | |
| if (this.config.tools.includes('bbox')) { | |
| this._selectTool('bbox'); | |
| } | |
| break; | |
| case 'p': | |
| if (this.config.tools.includes('polygon')) { | |
| this._selectTool('polygon'); | |
| } | |
| break; | |
| case 'f': | |
| if (this.config.tools.includes('freeform')) { | |
| this._selectTool('freeform'); | |
| } | |
| break; | |
| case 'l': | |
| if (this.config.tools.includes('landmark')) { | |
| this._selectTool('landmark'); | |
| } | |
| break; | |
| case 'delete': | |
| case 'backspace': | |
| this.deleteSelected(); | |
| e.preventDefault(); | |
| break; | |
| case '+': | |
| case '=': | |
| this.zoom(1.2); | |
| e.preventDefault(); | |
| break; | |
| case '-': | |
| this.zoom(0.8); | |
| e.preventDefault(); | |
| break; | |
| case '0': | |
| this.zoomFit(); | |
| e.preventDefault(); | |
| break; | |
| } | |
| // Label shortcuts | |
| for (const label of this.config.labels) { | |
| if (label.key_value && e.key === label.key_value) { | |
| this.setLabel(label.name, label.color); | |
| this._updateLabelButtonState(label.name); | |
| } | |
| } | |
| } | |
| // Undo/Redo | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'z') { | |
| if (e.shiftKey) { | |
| this.redo(); | |
| } else { | |
| this.undo(); | |
| } | |
| e.preventDefault(); | |
| } | |
| }); | |
| document.addEventListener('keyup', (e) => { | |
| if (e.code === 'Space') { | |
| this._spaceKeyDown = false; | |
| this.canvas.defaultCursor = 'default'; | |
| } | |
| }); | |
| } | |
| /** | |
| * Check if element is visible. | |
| */ | |
| _isElementVisible(el) { | |
| return el.offsetParent !== null; | |
| } | |
| /** | |
| * Select a tool programmatically. | |
| */ | |
| _selectTool(tool) { | |
| this.setTool(tool); | |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); | |
| if (container) { | |
| container.querySelectorAll('.tool-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| if (btn.dataset.tool === tool) { | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| } | |
| } | |
| /** | |
| * Update label button state. | |
| */ | |
| _updateLabelButtonState(labelName) { | |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); | |
| if (container) { | |
| container.querySelectorAll('.label-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| if (btn.dataset.label === labelName) { | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| } | |
| } | |
| /** | |
| * Update delete button enabled state. | |
| */ | |
| _updateDeleteButtonState() { | |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); | |
| if (container) { | |
| const deleteBtn = container.querySelector('.delete-btn'); | |
| if (deleteBtn) { | |
| const hasSelection = this.canvas.getActiveObject() !== null; | |
| deleteBtn.disabled = !hasSelection; | |
| } | |
| } | |
| } | |
| /** | |
| * Load an image onto the canvas. | |
| * @param {string} imageUrl - URL of the image to load | |
| */ | |
| loadImage(imageUrl) { | |
| if (!this.canvas) return; | |
| // Get the container element for status updates | |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); | |
| // Show loading state | |
| if (container) { | |
| container.classList.add('loading'); | |
| container.classList.remove('error'); | |
| } | |
| console.log('Loading image:', imageUrl); | |
| fabric.Image.fromURL(imageUrl, (img) => { | |
| // Remove loading state | |
| if (container) { | |
| container.classList.remove('loading'); | |
| } | |
| if (!img || !img.width || !img.height) { | |
| console.error('Failed to load image:', imageUrl); | |
| if (container) { | |
| container.classList.add('error'); | |
| } | |
| // Show error message on canvas | |
| this._showCanvasMessage('Failed to load image. Check the URL or CORS settings.'); | |
| return; | |
| } | |
| console.log('Image loaded successfully:', img.width, 'x', img.height); | |
| this.image = img; | |
| // Scale image to fit canvas while maintaining aspect ratio | |
| const canvasWidth = this.canvas.getWidth(); | |
| const canvasHeight = this.canvas.getHeight(); | |
| const scale = Math.min( | |
| canvasWidth / img.width, | |
| canvasHeight / img.height, | |
| 1 // Don't scale up | |
| ); | |
| img.set({ | |
| scaleX: scale, | |
| scaleY: scale, | |
| left: (canvasWidth - img.width * scale) / 2, | |
| top: (canvasHeight - img.height * scale) / 2, | |
| selectable: false, | |
| evented: false, | |
| hoverCursor: 'default', | |
| }); | |
| // Store original dimensions for coordinate normalization | |
| this.imageOriginalWidth = img.width; | |
| this.imageOriginalHeight = img.height; | |
| this.imageScale = scale; | |
| this.imageLeft = img.left; | |
| this.imageTop = img.top; | |
| this.canvas.add(img); | |
| this.canvas.sendToBack(img); | |
| this.canvas.renderAll(); | |
| // Initialize mask canvas dimensions | |
| this._resizeMaskCanvas(); | |
| // Load any existing annotations | |
| this._loadExistingAnnotations(); | |
| // Load any existing masks | |
| this._loadExistingMasks(); | |
| }, { crossOrigin: 'anonymous' }); | |
| } | |
| /** | |
| * Set the current drawing tool. | |
| * @param {string} tool - Tool name (bbox, polygon, freeform, landmark, brush, eraser, fill) | |
| */ | |
| setTool(tool) { | |
| this.currentTool = tool; | |
| this.isDrawing = false; | |
| this.drawingObject = null; | |
| this.polygonPoints = []; | |
| // Update canvas mode | |
| if (tool === 'freeform') { | |
| this.canvas.isDrawingMode = true; | |
| this.canvas.freeDrawingBrush.color = this.currentColor; | |
| this.canvas.freeDrawingBrush.width = this.config.freeformBrushSize || 5; | |
| this._showMaskCanvas(false); | |
| } else { | |
| this.canvas.isDrawingMode = false; | |
| } | |
| // Show/hide mask canvas for mask tools | |
| if (tool === 'brush' || tool === 'eraser' || tool === 'fill') { | |
| this._showMaskCanvas(true); | |
| this.maskCanvas.style.pointerEvents = 'auto'; | |
| } else { | |
| this._showMaskCanvas(this._hasMasks()); | |
| if (this.maskCanvas) { | |
| this.maskCanvas.style.pointerEvents = 'none'; | |
| } | |
| } | |
| // Update cursor | |
| switch (tool) { | |
| case 'bbox': | |
| case 'polygon': | |
| case 'landmark': | |
| this.canvas.defaultCursor = 'crosshair'; | |
| break; | |
| case 'brush': | |
| case 'eraser': | |
| this.canvas.defaultCursor = 'crosshair'; | |
| break; | |
| case 'fill': | |
| this.canvas.defaultCursor = 'crosshair'; | |
| break; | |
| default: | |
| this.canvas.defaultCursor = 'default'; | |
| } | |
| } | |
| /** | |
| * Set the brush/eraser size. | |
| * @param {number} size - Brush size in pixels | |
| */ | |
| setBrushSize(size) { | |
| this.brushSize = size; | |
| this.eraserSize = size; | |
| } | |
| /** | |
| * Show or hide the mask canvas. | |
| * @param {boolean} show - Whether to show the mask canvas | |
| */ | |
| _showMaskCanvas(show) { | |
| if (this.maskCanvas) { | |
| this.maskCanvas.style.display = show ? 'block' : 'none'; | |
| } | |
| } | |
| /** | |
| * Check if there are any masks. | |
| */ | |
| _hasMasks() { | |
| return Object.keys(this.masks).length > 0; | |
| } | |
| /** | |
| * Resize mask canvas to match image dimensions. | |
| */ | |
| _resizeMaskCanvas() { | |
| if (!this.maskCanvas || !this.image) return; | |
| const imgWidth = this.image.width * this.image.scaleX; | |
| const imgHeight = this.image.height * this.image.scaleY; | |
| this.maskCanvas.width = this.canvas.getWidth(); | |
| this.maskCanvas.height = this.canvas.getHeight(); | |
| // Store mask dimensions relative to image | |
| this.maskImgWidth = this.image.width; | |
| this.maskImgHeight = this.image.height; | |
| // Re-render masks | |
| this._renderAllMasks(); | |
| } | |
| /** | |
| * Start mask drawing (brush/eraser). | |
| */ | |
| _startMaskDraw(e) { | |
| if (this.currentTool !== 'brush' && this.currentTool !== 'eraser') return; | |
| if (!this.currentLabel) return; | |
| this.isMaskDrawing = true; | |
| this._drawMaskPoint(e); | |
| } | |
| /** | |
| * Continue mask drawing. | |
| */ | |
| _continueMaskDraw(e) { | |
| if (!this.isMaskDrawing) return; | |
| this._drawMaskPoint(e); | |
| } | |
| /** | |
| * Finish mask drawing. | |
| */ | |
| _finishMaskDraw() { | |
| if (this.isMaskDrawing) { | |
| this.isMaskDrawing = false; | |
| this._saveState(); | |
| this._updateMaskData(); | |
| } | |
| } | |
| /** | |
| * Draw a point on the mask canvas. | |
| */ | |
| _drawMaskPoint(e) { | |
| if (!this.image || !this.currentLabel) return; | |
| const rect = this.maskCanvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| // Get or create mask for current label | |
| if (!this.masks[this.currentLabel]) { | |
| this.masks[this.currentLabel] = { | |
| color: this.currentColor, | |
| data: new Uint8ClampedArray(this.maskImgWidth * this.maskImgHeight * 4) | |
| }; | |
| } | |
| const mask = this.masks[this.currentLabel]; | |
| const size = this.currentTool === 'eraser' ? this.eraserSize : this.brushSize; | |
| // Convert screen coordinates to image coordinates | |
| const imgCoords = this._screenToImageCoords(x, y); | |
| if (!imgCoords) return; | |
| // Draw circle on mask data | |
| this._drawCircleOnMask(mask, imgCoords.x, imgCoords.y, size / 2, this.currentTool === 'eraser'); | |
| // Re-render the visible mask | |
| this._renderAllMasks(); | |
| } | |
| /** | |
| * Convert screen coordinates to image coordinates. | |
| */ | |
| _screenToImageCoords(screenX, screenY) { | |
| if (!this.image) return null; | |
| const vpt = this.canvas.viewportTransform; | |
| const zoom = this.canvas.getZoom(); | |
| // Account for viewport transform | |
| const canvasX = (screenX - vpt[4]) / zoom; | |
| const canvasY = (screenY - vpt[5]) / zoom; | |
| // Convert to image coordinates | |
| const imgX = (canvasX - this.image.left) / this.image.scaleX; | |
| const imgY = (canvasY - this.image.top) / this.image.scaleY; | |
| // Check bounds | |
| if (imgX < 0 || imgX >= this.image.width || imgY < 0 || imgY >= this.image.height) { | |
| return null; | |
| } | |
| return { x: Math.floor(imgX), y: Math.floor(imgY) }; | |
| } | |
| /** | |
| * Draw a circle on the mask data. | |
| */ | |
| _drawCircleOnMask(mask, cx, cy, radius, erase) { | |
| const width = this.maskImgWidth; | |
| const height = this.maskImgHeight; | |
| const r = Math.floor(radius); | |
| for (let dy = -r; dy <= r; dy++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| if (dx * dx + dy * dy <= r * r) { | |
| const x = cx + dx; | |
| const y = cy + dy; | |
| if (x >= 0 && x < width && y >= 0 && y < height) { | |
| const idx = (y * width + x) * 4; | |
| if (erase) { | |
| mask.data[idx + 3] = 0; // Set alpha to 0 | |
| } else { | |
| // Parse color | |
| const color = this._hexToRgb(mask.color); | |
| mask.data[idx] = color.r; | |
| mask.data[idx + 1] = color.g; | |
| mask.data[idx + 2] = color.b; | |
| mask.data[idx + 3] = 255; // Full alpha in data | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Flood fill on mask. | |
| */ | |
| _floodFill(e) { | |
| if (!this.image || !this.currentLabel) return; | |
| const rect = this.maskCanvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const imgCoords = this._screenToImageCoords(x, y); | |
| if (!imgCoords) return; | |
| // Get or create mask for current label | |
| if (!this.masks[this.currentLabel]) { | |
| this.masks[this.currentLabel] = { | |
| color: this.currentColor, | |
| data: new Uint8ClampedArray(this.maskImgWidth * this.maskImgHeight * 4) | |
| }; | |
| } | |
| const mask = this.masks[this.currentLabel]; | |
| const color = this._hexToRgb(mask.color); | |
| // Simple flood fill using a queue | |
| const width = this.maskImgWidth; | |
| const height = this.maskImgHeight; | |
| const visited = new Set(); | |
| const queue = [[imgCoords.x, imgCoords.y]]; | |
| // Get the target color (what we're filling over) | |
| const startIdx = (imgCoords.y * width + imgCoords.x) * 4; | |
| const targetAlpha = mask.data[startIdx + 3]; | |
| // Only fill if starting on an empty area | |
| if (targetAlpha > 128) return; | |
| while (queue.length > 0) { | |
| const [px, py] = queue.shift(); | |
| const key = `${px},${py}`; | |
| if (visited.has(key)) continue; | |
| if (px < 0 || px >= width || py < 0 || py >= height) continue; | |
| const idx = (py * width + px) * 4; | |
| if (mask.data[idx + 3] > 128) continue; // Already filled | |
| visited.add(key); | |
| // Fill this pixel | |
| mask.data[idx] = color.r; | |
| mask.data[idx + 1] = color.g; | |
| mask.data[idx + 2] = color.b; | |
| mask.data[idx + 3] = 255; | |
| // Add neighbors (4-connected) | |
| queue.push([px + 1, py]); | |
| queue.push([px - 1, py]); | |
| queue.push([px, py + 1]); | |
| queue.push([px, py - 1]); | |
| // Limit fill size to prevent browser hang | |
| if (visited.size > 1000000) break; | |
| } | |
| this._renderAllMasks(); | |
| this._saveState(); | |
| this._updateMaskData(); | |
| } | |
| /** | |
| * Render all masks to the mask canvas. | |
| */ | |
| _renderAllMasks() { | |
| if (!this.maskCtx || !this.image) return; | |
| // Clear canvas | |
| this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); | |
| const vpt = this.canvas.viewportTransform; | |
| const zoom = this.canvas.getZoom(); | |
| // Calculate image position on screen | |
| const imgLeft = this.image.left * zoom + vpt[4]; | |
| const imgTop = this.image.top * zoom + vpt[5]; | |
| const imgWidth = this.image.width * this.image.scaleX * zoom; | |
| const imgHeight = this.image.height * this.image.scaleY * zoom; | |
| // Render each mask | |
| for (const label in this.masks) { | |
| const mask = this.masks[label]; | |
| // Create ImageData from mask data | |
| const imageData = new ImageData( | |
| new Uint8ClampedArray(mask.data), | |
| this.maskImgWidth, | |
| this.maskImgHeight | |
| ); | |
| // Create temporary canvas for scaling | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = this.maskImgWidth; | |
| tempCanvas.height = this.maskImgHeight; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| tempCtx.putImageData(imageData, 0, 0); | |
| // Draw scaled mask with opacity | |
| this.maskCtx.globalAlpha = this.maskOpacity; | |
| this.maskCtx.drawImage(tempCanvas, imgLeft, imgTop, imgWidth, imgHeight); | |
| } | |
| this.maskCtx.globalAlpha = 1.0; | |
| } | |
| /** | |
| * Convert hex color to RGB. | |
| */ | |
| _hexToRgb(hex) { | |
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return result ? { | |
| r: parseInt(result[1], 16), | |
| g: parseInt(result[2], 16), | |
| b: parseInt(result[3], 16) | |
| } : { r: 255, g: 0, b: 0 }; | |
| } | |
| /** | |
| * Encode mask data as RLE (Run-Length Encoding). | |
| */ | |
| _encodeMaskRLE(maskData) { | |
| const rle = []; | |
| let count = 0; | |
| let currentVal = 0; | |
| for (let i = 3; i < maskData.length; i += 4) { | |
| const val = maskData[i] > 128 ? 1 : 0; | |
| if (val === currentVal) { | |
| count++; | |
| } else { | |
| if (count > 0) rle.push(count); | |
| currentVal = val; | |
| count = 1; | |
| } | |
| } | |
| if (count > 0) rle.push(count); | |
| return rle; | |
| } | |
| /** | |
| * Decode RLE to mask data. | |
| */ | |
| _decodeMaskRLE(rle, width, height, color) { | |
| const data = new Uint8ClampedArray(width * height * 4); | |
| let idx = 0; | |
| let val = 0; | |
| const rgb = this._hexToRgb(color); | |
| for (const count of rle) { | |
| for (let i = 0; i < count && idx < width * height; i++) { | |
| const dataIdx = idx * 4; | |
| if (val === 1) { | |
| data[dataIdx] = rgb.r; | |
| data[dataIdx + 1] = rgb.g; | |
| data[dataIdx + 2] = rgb.b; | |
| data[dataIdx + 3] = 255; | |
| } | |
| idx++; | |
| } | |
| val = 1 - val; | |
| } | |
| return data; | |
| } | |
| /** | |
| * Update the hidden input with mask data. | |
| */ | |
| _updateMaskData() { | |
| const maskInputId = this.inputId.replace('input-', 'mask-input-'); | |
| const input = document.getElementById(maskInputId); | |
| if (!input) return; | |
| const masksData = {}; | |
| for (const label in this.masks) { | |
| const mask = this.masks[label]; | |
| masksData[label] = { | |
| color: mask.color, | |
| rle: this._encodeMaskRLE(mask.data), | |
| width: this.maskImgWidth, | |
| height: this.maskImgHeight | |
| }; | |
| } | |
| input.value = JSON.stringify(masksData); | |
| } | |
| /** | |
| * Load existing mask data. | |
| */ | |
| _loadExistingMasks() { | |
| const maskInputId = this.inputId.replace('input-', 'mask-input-'); | |
| const input = document.getElementById(maskInputId); | |
| if (!input || !input.value) return; | |
| try { | |
| const masksData = JSON.parse(input.value); | |
| for (const label in masksData) { | |
| const maskInfo = masksData[label]; | |
| this.masks[label] = { | |
| color: maskInfo.color, | |
| data: this._decodeMaskRLE(maskInfo.rle, maskInfo.width, maskInfo.height, maskInfo.color) | |
| }; | |
| } | |
| this._renderAllMasks(); | |
| } catch (e) { | |
| console.warn('Failed to load existing masks:', e); | |
| } | |
| } | |
| /** | |
| * Set the current label and color. | |
| * @param {string} label - Label name | |
| * @param {string} color - Color hex code | |
| */ | |
| setLabel(label, color) { | |
| this.currentLabel = label; | |
| this.currentColor = color || '#FF6B6B'; | |
| if (this.canvas.isDrawingMode) { | |
| this.canvas.freeDrawingBrush.color = this.currentColor; | |
| } | |
| } | |
| /** | |
| * Start drawing based on current tool. | |
| */ | |
| _startDrawing(pointer) { | |
| if (!this.currentTool || !this.currentLabel) return; | |
| switch (this.currentTool) { | |
| case 'bbox': | |
| this._startBbox(pointer); | |
| break; | |
| case 'polygon': | |
| this._addPolygonPoint(pointer); | |
| break; | |
| case 'landmark': | |
| this._addLandmark(pointer); | |
| break; | |
| // Freeform handled by Fabric's drawing mode | |
| } | |
| } | |
| /** | |
| * Continue drawing based on current tool. | |
| */ | |
| _continueDrawing(pointer) { | |
| switch (this.currentTool) { | |
| case 'bbox': | |
| this._updateBbox(pointer); | |
| break; | |
| } | |
| } | |
| /** | |
| * Finish drawing based on current tool. | |
| */ | |
| _finishDrawing() { | |
| switch (this.currentTool) { | |
| case 'bbox': | |
| this._finishBbox(); | |
| break; | |
| } | |
| } | |
| /** | |
| * Start drawing a bounding box. | |
| */ | |
| _startBbox(pointer) { | |
| this.isDrawing = true; | |
| this.startX = pointer.x; | |
| this.startY = pointer.y; | |
| this.drawingObject = new fabric.Rect({ | |
| left: pointer.x, | |
| top: pointer.y, | |
| width: 0, | |
| height: 0, | |
| fill: this._colorWithAlpha(this.currentColor, 0.2), | |
| stroke: this.currentColor, | |
| strokeWidth: 2, | |
| selectable: true, | |
| hasControls: true, | |
| hasBorders: true, | |
| }); | |
| this.canvas.add(this.drawingObject); | |
| } | |
| /** | |
| * Update bounding box while drawing. | |
| */ | |
| _updateBbox(pointer) { | |
| if (!this.drawingObject) return; | |
| const left = Math.min(this.startX, pointer.x); | |
| const top = Math.min(this.startY, pointer.y); | |
| const width = Math.abs(pointer.x - this.startX); | |
| const height = Math.abs(pointer.y - this.startY); | |
| this.drawingObject.set({ | |
| left: left, | |
| top: top, | |
| width: width, | |
| height: height, | |
| }); | |
| this.canvas.renderAll(); | |
| } | |
| /** | |
| * Finish drawing bounding box. | |
| */ | |
| _finishBbox() { | |
| if (!this.drawingObject) return; | |
| this.isDrawing = false; | |
| // Only keep if it has reasonable size | |
| if (this.drawingObject.width > 5 && this.drawingObject.height > 5) { | |
| this.drawingObject.annotationData = { | |
| type: 'bbox', | |
| label: this.currentLabel, | |
| color: this.currentColor, | |
| }; | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } else { | |
| this.canvas.remove(this.drawingObject); | |
| } | |
| this.drawingObject = null; | |
| } | |
| /** | |
| * Add a point to the current polygon. | |
| */ | |
| _addPolygonPoint(pointer) { | |
| this.polygonPoints.push({ x: pointer.x, y: pointer.y }); | |
| // Draw point marker | |
| const point = new fabric.Circle({ | |
| left: pointer.x - 4, | |
| top: pointer.y - 4, | |
| radius: 4, | |
| fill: this.currentColor, | |
| stroke: '#fff', | |
| strokeWidth: 1, | |
| selectable: false, | |
| evented: false, | |
| polygonMarker: true, | |
| }); | |
| this.canvas.add(point); | |
| // Draw line to previous point | |
| if (this.polygonPoints.length > 1) { | |
| const prev = this.polygonPoints[this.polygonPoints.length - 2]; | |
| const line = new fabric.Line( | |
| [prev.x, prev.y, pointer.x, pointer.y], | |
| { | |
| stroke: this.currentColor, | |
| strokeWidth: 2, | |
| selectable: false, | |
| evented: false, | |
| polygonLine: true, | |
| } | |
| ); | |
| this.canvas.add(line); | |
| } | |
| this.canvas.renderAll(); | |
| } | |
| /** | |
| * Complete the polygon shape. | |
| */ | |
| _completePolygon() { | |
| if (this.polygonPoints.length < 3) return; | |
| // Remove temporary markers and lines | |
| const toRemove = this.canvas.getObjects().filter( | |
| obj => obj.polygonMarker || obj.polygonLine | |
| ); | |
| toRemove.forEach(obj => this.canvas.remove(obj)); | |
| // Create polygon | |
| const polygon = new fabric.Polygon(this.polygonPoints, { | |
| fill: this._colorWithAlpha(this.currentColor, 0.2), | |
| stroke: this.currentColor, | |
| strokeWidth: 2, | |
| selectable: true, | |
| hasControls: true, | |
| hasBorders: true, | |
| }); | |
| polygon.annotationData = { | |
| type: 'polygon', | |
| label: this.currentLabel, | |
| color: this.currentColor, | |
| }; | |
| this.canvas.add(polygon); | |
| this.polygonPoints = []; | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } | |
| /** | |
| * Add a landmark point. | |
| */ | |
| _addLandmark(pointer) { | |
| const landmark = new fabric.Circle({ | |
| left: pointer.x - 8, | |
| top: pointer.y - 8, | |
| radius: 8, | |
| fill: this.currentColor, | |
| stroke: '#fff', | |
| strokeWidth: 2, | |
| selectable: true, | |
| hasControls: false, | |
| hasBorders: true, | |
| originX: 'center', | |
| originY: 'center', | |
| }); | |
| landmark.annotationData = { | |
| type: 'landmark', | |
| label: this.currentLabel, | |
| color: this.currentColor, | |
| }; | |
| // Add label text | |
| const text = new fabric.Text(this.currentLabel, { | |
| left: pointer.x + 12, | |
| top: pointer.y - 6, | |
| fontSize: 12, | |
| fill: this.currentColor, | |
| selectable: false, | |
| evented: false, | |
| }); | |
| // Group landmark and label | |
| const group = new fabric.Group([landmark, text], { | |
| left: pointer.x - 8, | |
| top: pointer.y - 8, | |
| selectable: true, | |
| hasControls: false, | |
| }); | |
| group.annotationData = landmark.annotationData; | |
| this.canvas.add(group); | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } | |
| /** | |
| * Handle freeform path completion. | |
| */ | |
| _handleFreeformPath() { | |
| const path = this.canvas.getObjects().find( | |
| obj => obj.type === 'path' && !obj.annotationData | |
| ); | |
| if (path) { | |
| path.annotationData = { | |
| type: 'freeform', | |
| label: this.currentLabel, | |
| color: this.currentColor, | |
| }; | |
| path.set({ | |
| stroke: this.currentColor, | |
| fill: this._colorWithAlpha(this.currentColor, 0.1), | |
| }); | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } | |
| } | |
| /** | |
| * Delete the currently selected annotation. | |
| */ | |
| deleteSelected() { | |
| const active = this.canvas.getActiveObject(); | |
| if (active && active !== this.image) { | |
| this.canvas.remove(active); | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } | |
| } | |
| /** | |
| * Zoom the canvas. | |
| * @param {number} factor - Zoom factor (>1 to zoom in, <1 to zoom out) | |
| */ | |
| zoom(factor) { | |
| if (!this.canvas) return; | |
| const center = this.canvas.getCenter(); | |
| let zoom = this.canvas.getZoom() * factor; | |
| // Clamp zoom | |
| zoom = Math.max(0.1, Math.min(10, zoom)); | |
| this.canvas.zoomToPoint( | |
| new fabric.Point(center.left, center.top), | |
| zoom | |
| ); | |
| // Re-render masks to match new viewport | |
| this._renderAllMasks(); | |
| } | |
| /** | |
| * Zoom to fit the image. | |
| */ | |
| zoomFit() { | |
| if (!this.canvas || !this.image) return; | |
| const canvasWidth = this.canvas.getWidth(); | |
| const canvasHeight = this.canvas.getHeight(); | |
| const scale = Math.min( | |
| canvasWidth / (this.image.width * this.image.scaleX), | |
| canvasHeight / (this.image.height * this.image.scaleY), | |
| 1 | |
| ); | |
| this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); | |
| this.canvas.zoomToPoint( | |
| new fabric.Point(canvasWidth / 2, canvasHeight / 2), | |
| scale | |
| ); | |
| // Re-render masks to match new viewport | |
| this._renderAllMasks(); | |
| } | |
| /** | |
| * Reset zoom to 100%. | |
| */ | |
| zoomReset() { | |
| if (!this.canvas) return; | |
| this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); | |
| // Re-render masks to match new viewport | |
| this._renderAllMasks(); | |
| } | |
| /** | |
| * Undo the last action. | |
| */ | |
| undo() { | |
| if (this.historyIndex > 0) { | |
| this.historyIndex--; | |
| this._restoreState(this.history[this.historyIndex]); | |
| } | |
| } | |
| /** | |
| * Redo the last undone action. | |
| */ | |
| redo() { | |
| if (this.historyIndex < this.history.length - 1) { | |
| this.historyIndex++; | |
| this._restoreState(this.history[this.historyIndex]); | |
| } | |
| } | |
| /** | |
| * Save current state to history. | |
| */ | |
| _saveState() { | |
| // Remove future history if we're not at the end | |
| this.history = this.history.slice(0, this.historyIndex + 1); | |
| // Save current state | |
| const state = this._serializeAnnotations(); | |
| this.history.push(state); | |
| // Trim history if too long | |
| if (this.history.length > this.maxHistory) { | |
| this.history.shift(); | |
| } | |
| this.historyIndex = this.history.length - 1; | |
| } | |
| /** | |
| * Restore a saved state. | |
| */ | |
| _restoreState(state) { | |
| // Remove all annotation objects (keep image) | |
| const toRemove = this.canvas.getObjects().filter( | |
| obj => obj !== this.image && obj.annotationData | |
| ); | |
| toRemove.forEach(obj => this.canvas.remove(obj)); | |
| // Restore annotations | |
| this._deserializeAnnotations(state); | |
| this._updateAnnotationData(); | |
| } | |
| /** | |
| * Serialize all annotations to JSON. | |
| */ | |
| _serializeAnnotations() { | |
| const annotations = []; | |
| this.canvas.getObjects().forEach(obj => { | |
| if (obj.annotationData) { | |
| const ann = { | |
| type: obj.annotationData.type, | |
| label: obj.annotationData.label, | |
| color: obj.annotationData.color, | |
| coordinates: this._getObjectCoordinates(obj), | |
| }; | |
| annotations.push(ann); | |
| } | |
| }); | |
| return JSON.stringify(annotations); | |
| } | |
| /** | |
| * Deserialize annotations from JSON and add to canvas. | |
| */ | |
| _deserializeAnnotations(json) { | |
| const annotations = JSON.parse(json); | |
| annotations.forEach(ann => { | |
| this._createAnnotationObject(ann); | |
| }); | |
| this.canvas.renderAll(); | |
| } | |
| /** | |
| * Get normalized coordinates for an object. | |
| */ | |
| _getObjectCoordinates(obj) { | |
| if (!this.image) return null; | |
| const imgWidth = this.image.width * this.image.scaleX; | |
| const imgHeight = this.image.height * this.image.scaleY; | |
| const imgLeft = this.image.left; | |
| const imgTop = this.image.top; | |
| const normalize = (x, y) => ({ | |
| x: (x - imgLeft) / imgWidth, | |
| y: (y - imgTop) / imgHeight, | |
| }); | |
| switch (obj.annotationData.type) { | |
| case 'bbox': | |
| const tl = normalize(obj.left, obj.top); | |
| return { | |
| x: tl.x, | |
| y: tl.y, | |
| width: (obj.width * obj.scaleX) / imgWidth, | |
| height: (obj.height * obj.scaleY) / imgHeight, | |
| }; | |
| case 'polygon': | |
| return obj.points.map(p => { | |
| const absX = obj.left + p.x - obj.pathOffset.x; | |
| const absY = obj.top + p.y - obj.pathOffset.y; | |
| return normalize(absX, absY); | |
| }); | |
| case 'landmark': | |
| if (obj.type === 'group') { | |
| const centerX = obj.left + obj.width / 2; | |
| const centerY = obj.top + obj.height / 2; | |
| return normalize(centerX, centerY); | |
| } | |
| return normalize(obj.left + 8, obj.top + 8); | |
| case 'freeform': | |
| // Serialize path data | |
| return { | |
| path: obj.path, | |
| left: (obj.left - imgLeft) / imgWidth, | |
| top: (obj.top - imgTop) / imgHeight, | |
| scaleX: obj.scaleX / (imgWidth / this.image.width), | |
| scaleY: obj.scaleY / (imgHeight / this.image.height), | |
| }; | |
| default: | |
| return null; | |
| } | |
| } | |
| /** | |
| * Create annotation object from serialized data. | |
| */ | |
| _createAnnotationObject(ann) { | |
| if (!this.image) return; | |
| const imgWidth = this.image.width * this.image.scaleX; | |
| const imgHeight = this.image.height * this.image.scaleY; | |
| const imgLeft = this.image.left; | |
| const imgTop = this.image.top; | |
| const denormalize = (nx, ny) => ({ | |
| x: nx * imgWidth + imgLeft, | |
| y: ny * imgHeight + imgTop, | |
| }); | |
| let obj; | |
| switch (ann.type) { | |
| case 'bbox': | |
| const pos = denormalize(ann.coordinates.x, ann.coordinates.y); | |
| obj = new fabric.Rect({ | |
| left: pos.x, | |
| top: pos.y, | |
| width: ann.coordinates.width * imgWidth, | |
| height: ann.coordinates.height * imgHeight, | |
| fill: this._colorWithAlpha(ann.color, 0.2), | |
| stroke: ann.color, | |
| strokeWidth: 2, | |
| selectable: true, | |
| hasControls: true, | |
| }); | |
| break; | |
| case 'polygon': | |
| const points = ann.coordinates.map(c => { | |
| const p = denormalize(c.x, c.y); | |
| return { x: p.x, y: p.y }; | |
| }); | |
| obj = new fabric.Polygon(points, { | |
| fill: this._colorWithAlpha(ann.color, 0.2), | |
| stroke: ann.color, | |
| strokeWidth: 2, | |
| selectable: true, | |
| hasControls: true, | |
| }); | |
| break; | |
| case 'landmark': | |
| const lpos = denormalize(ann.coordinates.x, ann.coordinates.y); | |
| const circle = new fabric.Circle({ | |
| left: 0, | |
| top: 0, | |
| radius: 8, | |
| fill: ann.color, | |
| stroke: '#fff', | |
| strokeWidth: 2, | |
| originX: 'center', | |
| originY: 'center', | |
| }); | |
| const text = new fabric.Text(ann.label, { | |
| left: 12, | |
| top: -6, | |
| fontSize: 12, | |
| fill: ann.color, | |
| }); | |
| obj = new fabric.Group([circle, text], { | |
| left: lpos.x - 8, | |
| top: lpos.y - 8, | |
| selectable: true, | |
| hasControls: false, | |
| }); | |
| break; | |
| case 'freeform': | |
| const coords = ann.coordinates; | |
| obj = new fabric.Path(coords.path, { | |
| left: coords.left * imgWidth + imgLeft, | |
| top: coords.top * imgHeight + imgTop, | |
| scaleX: coords.scaleX * (imgWidth / this.image.width), | |
| scaleY: coords.scaleY * (imgHeight / this.image.height), | |
| stroke: ann.color, | |
| fill: this._colorWithAlpha(ann.color, 0.1), | |
| strokeWidth: 2, | |
| selectable: true, | |
| }); | |
| break; | |
| } | |
| if (obj) { | |
| obj.annotationData = { | |
| type: ann.type, | |
| label: ann.label, | |
| color: ann.color, | |
| }; | |
| this.canvas.add(obj); | |
| } | |
| } | |
| /** | |
| * Update the hidden input with current annotation data. | |
| */ | |
| _updateAnnotationData() { | |
| const input = document.getElementById(this.inputId); | |
| if (input) { | |
| input.value = this._serializeAnnotations(); | |
| } | |
| // Update annotation count | |
| const count = this.canvas.getObjects().filter( | |
| obj => obj !== this.image && obj.annotationData | |
| ).length; | |
| if (this.onAnnotationChange) { | |
| this.onAnnotationChange(count); | |
| } | |
| } | |
| /** | |
| * Load existing annotations from the hidden input. | |
| */ | |
| _loadExistingAnnotations() { | |
| const input = document.getElementById(this.inputId); | |
| if (input && input.value) { | |
| try { | |
| this._deserializeAnnotations(input.value); | |
| this._saveState(); | |
| this._updateAnnotationData(); // Update count display after loading | |
| } catch (e) { | |
| console.warn('Failed to load existing annotations:', e); | |
| } | |
| } else { | |
| // No existing annotations - still update count to show 0 | |
| this._updateAnnotationData(); | |
| } | |
| } | |
| /** | |
| * Convert hex color to rgba with alpha. | |
| */ | |
| _colorWithAlpha(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})`; | |
| } | |
| /** | |
| * Show a message on the canvas (for errors/loading states). | |
| * @param {string} message - Message to display | |
| */ | |
| _showCanvasMessage(message) { | |
| if (!this.canvas) return; | |
| // Clear canvas and show message | |
| this.canvas.clear(); | |
| this.canvas.setBackgroundColor('#f8f9fa', this.canvas.renderAll.bind(this.canvas)); | |
| const text = new fabric.Text(message, { | |
| left: this.canvas.getWidth() / 2, | |
| top: this.canvas.getHeight() / 2, | |
| fontSize: 16, | |
| fill: '#dc3545', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| originX: 'center', | |
| originY: 'center', | |
| textAlign: 'center', | |
| selectable: false, | |
| evented: false, | |
| }); | |
| this.canvas.add(text); | |
| this.canvas.renderAll(); | |
| } | |
| /** | |
| * Get current annotation count. | |
| */ | |
| getAnnotationCount() { | |
| return this.canvas.getObjects().filter( | |
| obj => obj !== this.image && obj.annotationData | |
| ).length; | |
| } | |
| /** | |
| * Clear all annotations from the canvas. | |
| * Used when switching to a new instance. | |
| */ | |
| clearAnnotations() { | |
| const objects = this.canvas.getObjects(); | |
| objects.forEach(obj => { | |
| if (obj !== this.image && obj.annotationData) { | |
| this.canvas.remove(obj); | |
| } | |
| }); | |
| this.canvas.renderAll(); | |
| // Reset history | |
| this.history = []; | |
| this.historyIndex = -1; | |
| // Update the hidden input and count display | |
| this._updateAnnotationData(); | |
| } | |
| /** | |
| * Serialize annotations for form submission. | |
| */ | |
| serialize() { | |
| return this._serializeAnnotations(); | |
| } | |
| /** | |
| * Load annotations from JSON. | |
| */ | |
| deserialize(json) { | |
| this._deserializeAnnotations(json); | |
| this._saveState(); | |
| this._updateAnnotationData(); | |
| } | |
| } | |
| // Export for use in modules if needed | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = ImageAnnotationManager; | |
| } | |
| // Make available in browser environments | |
| if (typeof window !== 'undefined') { | |
| window.ImageAnnotationManager = ImageAnnotationManager; | |
| } | |