codebook / potato /static /image-annotation.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
48.6 kB
/**
* 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;
}