| |
| |
| |
| |
| |
| |
|
|
| class ImageAnnotationManager { |
| |
| |
| |
| |
| |
| |
| 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'; |
|
|
| |
| this.isDrawing = false; |
| this.drawingObject = null; |
| this.polygonPoints = []; |
| this.isPanning = false; |
| this.lastPosX = 0; |
| this.lastPosY = 0; |
|
|
| |
| this.history = []; |
| this.historyIndex = -1; |
| this.maxHistory = 50; |
|
|
| |
| this.annotations = []; |
|
|
| |
| this.onAnnotationChange = null; |
|
|
| |
| this.maskCanvas = null; |
| this.maskCtx = null; |
| this.masks = {}; |
| this.brushSize = config.brushSize || 20; |
| this.eraserSize = config.eraserSize || 20; |
| this.maskOpacity = config.maskOpacity || 0.5; |
| this.isMaskDrawing = false; |
|
|
| |
| this._initCanvas(); |
| this._initMaskCanvas(); |
| this._setupEventListeners(); |
| this._setupKeyboardShortcuts(); |
| } |
|
|
| |
| |
| |
| _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) { |
| |
| this.maskCanvas = document.createElement('canvas'); |
| this.maskCanvas.id = maskCanvasId; |
| this.maskCanvas.className = 'mask-canvas'; |
| canvasEl.parentElement.appendChild(this.maskCanvas); |
| } |
|
|
| |
| this.maskCanvas.style.position = 'absolute'; |
| this.maskCanvas.style.top = '0'; |
| this.maskCanvas.style.left = '0'; |
| this.maskCanvas.style.pointerEvents = 'none'; |
|
|
| this.maskCtx = this.maskCanvas.getContext('2d'); |
| } |
|
|
| |
| |
| |
| _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(); |
| }); |
| } |
|
|
| |
| |
| |
| _initCanvas() { |
| const canvasEl = document.getElementById(this.canvasId); |
| if (!canvasEl) { |
| console.error('Canvas element not found:', this.canvasId); |
| return; |
| } |
|
|
| |
| 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', |
| }); |
|
|
| |
| this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); |
| } |
|
|
| |
| |
| |
| _setupEventListeners() { |
| if (!this.canvas) return; |
|
|
| |
| this._setupMaskEventListeners(); |
|
|
| |
| this.canvas.on('mouse:down', (opt) => { |
| const evt = opt.e; |
| const pointer = this.canvas.getPointer(evt); |
|
|
| |
| if (evt.altKey || this._spaceKeyDown) { |
| this.isPanning = true; |
| this.canvas.selection = false; |
| this.lastPosX = evt.clientX; |
| this.lastPosY = evt.clientY; |
| return; |
| } |
|
|
| |
| if (opt.target && opt.target !== this.image) { |
| return; |
| } |
|
|
| this._startDrawing(pointer); |
| }); |
|
|
| |
| 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; |
| |
| this._renderAllMasks(); |
| return; |
| } |
|
|
| if (this.isDrawing) { |
| const pointer = this.canvas.getPointer(evt); |
| this._continueDrawing(pointer); |
| } |
| }); |
|
|
| |
| this.canvas.on('mouse:up', (opt) => { |
| if (this.isPanning) { |
| this.isPanning = false; |
| this.canvas.selection = true; |
| return; |
| } |
|
|
| if (this.isDrawing) { |
| this._finishDrawing(); |
| } |
| }); |
|
|
| |
| this.canvas.on('mouse:dblclick', (opt) => { |
| if (this.currentTool === 'polygon' && this.polygonPoints.length > 2) { |
| this._completePolygon(); |
| } |
| }); |
|
|
| |
| this.canvas.on('object:modified', () => { |
| this._saveState(); |
| this._updateAnnotationData(); |
| }); |
|
|
| |
| this.canvas.on('object:removed', (opt) => { |
| if (opt.target && opt.target.annotationData) { |
| this._updateAnnotationData(); |
| } |
| }); |
|
|
| |
| this.canvas.on('selection:created', () => { |
| this._updateDeleteButtonState(); |
| }); |
|
|
| this.canvas.on('selection:cleared', () => { |
| this._updateDeleteButtonState(); |
| }); |
| } |
|
|
| |
| |
| |
| _setupKeyboardShortcuts() { |
| this._spaceKeyDown = false; |
|
|
| document.addEventListener('keydown', (e) => { |
| |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); |
| if (!container || !this._isElementVisible(container)) return; |
|
|
| |
| if (e.code === 'Space' && !this._spaceKeyDown) { |
| this._spaceKeyDown = true; |
| this.canvas.defaultCursor = 'grab'; |
| e.preventDefault(); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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'; |
| } |
| }); |
| } |
|
|
| |
| |
| |
| _isElementVisible(el) { |
| return el.offsetParent !== null; |
| } |
|
|
| |
| |
| |
| _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'); |
| } |
| }); |
| } |
| } |
|
|
| |
| |
| |
| _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'); |
| } |
| }); |
| } |
| } |
|
|
| |
| |
| |
| _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; |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| loadImage(imageUrl) { |
| if (!this.canvas) return; |
|
|
| |
| const container = document.querySelector(`.image-annotation-container[data-schema="${this.config.schemaName}"]`); |
|
|
| |
| if (container) { |
| container.classList.add('loading'); |
| container.classList.remove('error'); |
| } |
|
|
| console.log('Loading image:', imageUrl); |
| fabric.Image.fromURL(imageUrl, (img) => { |
| |
| 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'); |
| } |
| |
| 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; |
|
|
| |
| const canvasWidth = this.canvas.getWidth(); |
| const canvasHeight = this.canvas.getHeight(); |
| const scale = Math.min( |
| canvasWidth / img.width, |
| canvasHeight / img.height, |
| 1 |
| ); |
|
|
| img.set({ |
| scaleX: scale, |
| scaleY: scale, |
| left: (canvasWidth - img.width * scale) / 2, |
| top: (canvasHeight - img.height * scale) / 2, |
| selectable: false, |
| evented: false, |
| hoverCursor: 'default', |
| }); |
|
|
| |
| 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(); |
|
|
| |
| this._resizeMaskCanvas(); |
|
|
| |
| this._loadExistingAnnotations(); |
|
|
| |
| this._loadExistingMasks(); |
| }, { crossOrigin: 'anonymous' }); |
| } |
|
|
| |
| |
| |
| |
| setTool(tool) { |
| this.currentTool = tool; |
| this.isDrawing = false; |
| this.drawingObject = null; |
| this.polygonPoints = []; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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'; |
| } |
| } |
|
|
| |
| 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'; |
| } |
| } |
|
|
| |
| |
| |
| |
| setBrushSize(size) { |
| this.brushSize = size; |
| this.eraserSize = size; |
| } |
|
|
| |
| |
| |
| |
| _showMaskCanvas(show) { |
| if (this.maskCanvas) { |
| this.maskCanvas.style.display = show ? 'block' : 'none'; |
| } |
| } |
|
|
| |
| |
| |
| _hasMasks() { |
| return Object.keys(this.masks).length > 0; |
| } |
|
|
| |
| |
| |
| _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(); |
|
|
| |
| this.maskImgWidth = this.image.width; |
| this.maskImgHeight = this.image.height; |
|
|
| |
| this._renderAllMasks(); |
| } |
|
|
| |
| |
| |
| _startMaskDraw(e) { |
| if (this.currentTool !== 'brush' && this.currentTool !== 'eraser') return; |
| if (!this.currentLabel) return; |
|
|
| this.isMaskDrawing = true; |
| this._drawMaskPoint(e); |
| } |
|
|
| |
| |
| |
| _continueMaskDraw(e) { |
| if (!this.isMaskDrawing) return; |
| this._drawMaskPoint(e); |
| } |
|
|
| |
| |
| |
| _finishMaskDraw() { |
| if (this.isMaskDrawing) { |
| this.isMaskDrawing = false; |
| this._saveState(); |
| this._updateMaskData(); |
| } |
| } |
|
|
| |
| |
| |
| _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; |
|
|
| |
| 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; |
|
|
| |
| const imgCoords = this._screenToImageCoords(x, y); |
| if (!imgCoords) return; |
|
|
| |
| this._drawCircleOnMask(mask, imgCoords.x, imgCoords.y, size / 2, this.currentTool === 'eraser'); |
|
|
| |
| this._renderAllMasks(); |
| } |
|
|
| |
| |
| |
| _screenToImageCoords(screenX, screenY) { |
| if (!this.image) return null; |
|
|
| const vpt = this.canvas.viewportTransform; |
| const zoom = this.canvas.getZoom(); |
|
|
| |
| const canvasX = (screenX - vpt[4]) / zoom; |
| const canvasY = (screenY - vpt[5]) / zoom; |
|
|
| |
| const imgX = (canvasX - this.image.left) / this.image.scaleX; |
| const imgY = (canvasY - this.image.top) / this.image.scaleY; |
|
|
| |
| if (imgX < 0 || imgX >= this.image.width || imgY < 0 || imgY >= this.image.height) { |
| return null; |
| } |
|
|
| return { x: Math.floor(imgX), y: Math.floor(imgY) }; |
| } |
|
|
| |
| |
| |
| _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; |
| } else { |
| |
| 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; |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| _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; |
|
|
| |
| 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); |
|
|
| |
| const width = this.maskImgWidth; |
| const height = this.maskImgHeight; |
| const visited = new Set(); |
| const queue = [[imgCoords.x, imgCoords.y]]; |
|
|
| |
| const startIdx = (imgCoords.y * width + imgCoords.x) * 4; |
| const targetAlpha = mask.data[startIdx + 3]; |
|
|
| |
| 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; |
|
|
| visited.add(key); |
|
|
| |
| mask.data[idx] = color.r; |
| mask.data[idx + 1] = color.g; |
| mask.data[idx + 2] = color.b; |
| mask.data[idx + 3] = 255; |
|
|
| |
| queue.push([px + 1, py]); |
| queue.push([px - 1, py]); |
| queue.push([px, py + 1]); |
| queue.push([px, py - 1]); |
|
|
| |
| if (visited.size > 1000000) break; |
| } |
|
|
| this._renderAllMasks(); |
| this._saveState(); |
| this._updateMaskData(); |
| } |
|
|
| |
| |
| |
| _renderAllMasks() { |
| if (!this.maskCtx || !this.image) return; |
|
|
| |
| this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); |
|
|
| const vpt = this.canvas.viewportTransform; |
| const zoom = this.canvas.getZoom(); |
|
|
| |
| 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; |
|
|
| |
| for (const label in this.masks) { |
| const mask = this.masks[label]; |
|
|
| |
| const imageData = new ImageData( |
| new Uint8ClampedArray(mask.data), |
| this.maskImgWidth, |
| this.maskImgHeight |
| ); |
|
|
| |
| const tempCanvas = document.createElement('canvas'); |
| tempCanvas.width = this.maskImgWidth; |
| tempCanvas.height = this.maskImgHeight; |
| const tempCtx = tempCanvas.getContext('2d'); |
| tempCtx.putImageData(imageData, 0, 0); |
|
|
| |
| this.maskCtx.globalAlpha = this.maskOpacity; |
| this.maskCtx.drawImage(tempCanvas, imgLeft, imgTop, imgWidth, imgHeight); |
| } |
|
|
| this.maskCtx.globalAlpha = 1.0; |
| } |
|
|
| |
| |
| |
| _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 }; |
| } |
|
|
| |
| |
| |
| _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; |
| } |
|
|
| |
| |
| |
| _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; |
| } |
|
|
| |
| |
| |
| _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); |
| } |
|
|
| |
| |
| |
| _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); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| setLabel(label, color) { |
| this.currentLabel = label; |
| this.currentColor = color || '#FF6B6B'; |
|
|
| if (this.canvas.isDrawingMode) { |
| this.canvas.freeDrawingBrush.color = this.currentColor; |
| } |
| } |
|
|
| |
| |
| |
| _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; |
| |
| } |
| } |
|
|
| |
| |
| |
| _continueDrawing(pointer) { |
| switch (this.currentTool) { |
| case 'bbox': |
| this._updateBbox(pointer); |
| break; |
| } |
| } |
|
|
| |
| |
| |
| _finishDrawing() { |
| switch (this.currentTool) { |
| case 'bbox': |
| this._finishBbox(); |
| break; |
| } |
| } |
|
|
| |
| |
| |
| _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); |
| } |
|
|
| |
| |
| |
| _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(); |
| } |
|
|
| |
| |
| |
| _finishBbox() { |
| if (!this.drawingObject) return; |
|
|
| this.isDrawing = false; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| _addPolygonPoint(pointer) { |
| this.polygonPoints.push({ x: pointer.x, y: pointer.y }); |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
| _completePolygon() { |
| if (this.polygonPoints.length < 3) return; |
|
|
| |
| const toRemove = this.canvas.getObjects().filter( |
| obj => obj.polygonMarker || obj.polygonLine |
| ); |
| toRemove.forEach(obj => this.canvas.remove(obj)); |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
| _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, |
| }; |
|
|
| |
| const text = new fabric.Text(this.currentLabel, { |
| left: pointer.x + 12, |
| top: pointer.y - 6, |
| fontSize: 12, |
| fill: this.currentColor, |
| selectable: false, |
| evented: false, |
| }); |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
| _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(); |
| } |
| } |
|
|
| |
| |
| |
| deleteSelected() { |
| const active = this.canvas.getActiveObject(); |
| if (active && active !== this.image) { |
| this.canvas.remove(active); |
| this._saveState(); |
| this._updateAnnotationData(); |
| } |
| } |
|
|
| |
| |
| |
| |
| zoom(factor) { |
| if (!this.canvas) return; |
|
|
| const center = this.canvas.getCenter(); |
| let zoom = this.canvas.getZoom() * factor; |
|
|
| |
| zoom = Math.max(0.1, Math.min(10, zoom)); |
|
|
| this.canvas.zoomToPoint( |
| new fabric.Point(center.left, center.top), |
| zoom |
| ); |
|
|
| |
| this._renderAllMasks(); |
| } |
|
|
| |
| |
| |
| 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 |
| ); |
|
|
| |
| this._renderAllMasks(); |
| } |
|
|
| |
| |
| |
| zoomReset() { |
| if (!this.canvas) return; |
| this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); |
|
|
| |
| this._renderAllMasks(); |
| } |
|
|
| |
| |
| |
| undo() { |
| if (this.historyIndex > 0) { |
| this.historyIndex--; |
| this._restoreState(this.history[this.historyIndex]); |
| } |
| } |
|
|
| |
| |
| |
| redo() { |
| if (this.historyIndex < this.history.length - 1) { |
| this.historyIndex++; |
| this._restoreState(this.history[this.historyIndex]); |
| } |
| } |
|
|
| |
| |
| |
| _saveState() { |
| |
| this.history = this.history.slice(0, this.historyIndex + 1); |
|
|
| |
| const state = this._serializeAnnotations(); |
| this.history.push(state); |
|
|
| |
| if (this.history.length > this.maxHistory) { |
| this.history.shift(); |
| } |
|
|
| this.historyIndex = this.history.length - 1; |
| } |
|
|
| |
| |
| |
| _restoreState(state) { |
| |
| const toRemove = this.canvas.getObjects().filter( |
| obj => obj !== this.image && obj.annotationData |
| ); |
| toRemove.forEach(obj => this.canvas.remove(obj)); |
|
|
| |
| this._deserializeAnnotations(state); |
| this._updateAnnotationData(); |
| } |
|
|
| |
| |
| |
| _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); |
| } |
|
|
| |
| |
| |
| _deserializeAnnotations(json) { |
| const annotations = JSON.parse(json); |
|
|
| annotations.forEach(ann => { |
| this._createAnnotationObject(ann); |
| }); |
|
|
| this.canvas.renderAll(); |
| } |
|
|
| |
| |
| |
| _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': |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| _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); |
| } |
| } |
|
|
| |
| |
| |
| _updateAnnotationData() { |
| const input = document.getElementById(this.inputId); |
| if (input) { |
| input.value = this._serializeAnnotations(); |
| } |
|
|
| |
| const count = this.canvas.getObjects().filter( |
| obj => obj !== this.image && obj.annotationData |
| ).length; |
|
|
| if (this.onAnnotationChange) { |
| this.onAnnotationChange(count); |
| } |
| } |
|
|
| |
| |
| |
| _loadExistingAnnotations() { |
| const input = document.getElementById(this.inputId); |
| if (input && input.value) { |
| try { |
| this._deserializeAnnotations(input.value); |
| this._saveState(); |
| this._updateAnnotationData(); |
| } catch (e) { |
| console.warn('Failed to load existing annotations:', e); |
| } |
| } else { |
| |
| this._updateAnnotationData(); |
| } |
| } |
|
|
| |
| |
| |
| _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})`; |
| } |
|
|
| |
| |
| |
| |
| _showCanvasMessage(message) { |
| if (!this.canvas) return; |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
| getAnnotationCount() { |
| return this.canvas.getObjects().filter( |
| obj => obj !== this.image && obj.annotationData |
| ).length; |
| } |
|
|
| |
| |
| |
| |
| clearAnnotations() { |
| const objects = this.canvas.getObjects(); |
| objects.forEach(obj => { |
| if (obj !== this.image && obj.annotationData) { |
| this.canvas.remove(obj); |
| } |
| }); |
| this.canvas.renderAll(); |
|
|
| |
| this.history = []; |
| this.historyIndex = -1; |
|
|
| |
| this._updateAnnotationData(); |
| } |
|
|
| |
| |
| |
| serialize() { |
| return this._serializeAnnotations(); |
| } |
|
|
| |
| |
| |
| deserialize(json) { |
| this._deserializeAnnotations(json); |
| this._saveState(); |
| this._updateAnnotationData(); |
| } |
| } |
|
|
| |
| if (typeof module !== 'undefined' && module.exports) { |
| module.exports = ImageAnnotationManager; |
| } |
|
|
| |
| if (typeof window !== 'undefined') { |
| window.ImageAnnotationManager = ImageAnnotationManager; |
| } |
|
|