/** * Segmentation Tool Manager * * Provides flood fill and eraser tools for the image annotation canvas. * Extends the ImageAnnotationManager with segmentation capabilities. * * Segmentation masks are stored alongside other annotations as: * {type: "mask", label: "road", rle: {counts: [...], size: [h, w]}} * * The mask overlay is rendered on a separate canvas layer above the * annotation canvas, with semi-transparent colored regions. */ (function() { 'use strict'; class SegmentationToolManager { /** * @param {HTMLCanvasElement} canvas - The main annotation canvas * @param {number} width - Canvas width * @param {number} height - Canvas height */ constructor(canvas, width, height) { this.canvas = canvas; this.width = width; this.height = height; // Create mask overlay canvas this.maskCanvas = document.createElement('canvas'); this.maskCanvas.width = width; this.maskCanvas.height = height; this.maskCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;opacity:0.4;'; this.maskCtx = this.maskCanvas.getContext('2d'); // Working canvas for flood fill operations this.workCanvas = document.createElement('canvas'); this.workCanvas.width = width; this.workCanvas.height = height; this.workCtx = this.workCanvas.getContext('2d'); // Mask data: label -> ImageData (binary mask) this.masks = {}; this.activeTool = null; this.activeLabel = null; this.eraserSize = 20; this.fillTolerance = 32; // Insert mask canvas after the main canvas if (canvas.parentElement) { canvas.parentElement.style.position = 'relative'; canvas.parentElement.insertBefore(this.maskCanvas, canvas.nextSibling); } } setTool(tool) { this.activeTool = tool; } setLabel(label, color) { this.activeLabel = label; this.activeColor = color || '#FF0000'; } setEraserSize(size) { this.eraserSize = size; } setFillTolerance(tolerance) { this.fillTolerance = tolerance; } /** * Handle click on canvas for flood fill. */ floodFill(x, y) { if (!this.activeLabel) return null; x = Math.round(x); y = Math.round(y); if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null; // Get the source image data from the main canvas var ctx = this.canvas.getContext('2d'); var imageData = ctx.getImageData(0, 0, this.width, this.height); var pixels = imageData.data; // Target color at click point var idx = (y * this.width + x) * 4; var targetR = pixels[idx]; var targetG = pixels[idx + 1]; var targetB = pixels[idx + 2]; // Create or get mask for this label if (!this.masks[this.activeLabel]) { this.masks[this.activeLabel] = new Uint8Array(this.width * this.height); } var mask = this.masks[this.activeLabel]; // Scanline flood fill var tolerance = this.fillTolerance; var visited = new Uint8Array(this.width * this.height); var stack = [[x, y]]; var filled = 0; while (stack.length > 0) { var point = stack.pop(); var px = point[0]; var py = point[1]; if (px < 0 || px >= this.width || py < 0 || py >= this.height) continue; var pidx = py * this.width + px; if (visited[pidx]) continue; visited[pidx] = 1; var ci = pidx * 4; var dr = Math.abs(pixels[ci] - targetR); var dg = Math.abs(pixels[ci + 1] - targetG); var db = Math.abs(pixels[ci + 2] - targetB); if (dr <= tolerance && dg <= tolerance && db <= tolerance) { mask[pidx] = 1; filled++; stack.push([px + 1, py]); stack.push([px - 1, py]); stack.push([px, py + 1]); stack.push([px, py - 1]); } } this.renderMasks(); return { type: 'mask', label: this.activeLabel, rle: this.encodeRLE(mask, this.width, this.height), pixelsFilled: filled }; } /** * Erase mask pixels at the given canvas coordinates. */ erase(x, y) { x = Math.round(x); y = Math.round(y); var r = Math.round(this.eraserSize / 2); var changed = false; // Erase from all masks for (var label in this.masks) { var mask = this.masks[label]; for (var dy = -r; dy <= r; dy++) { for (var dx = -r; dx <= r; dx++) { if (dx * dx + dy * dy <= r * r) { var px = x + dx; var py = y + dy; if (px >= 0 && px < this.width && py >= 0 && py < this.height) { var idx = py * this.width + px; if (mask[idx]) { mask[idx] = 0; changed = true; } } } } } } if (changed) { this.renderMasks(); } return changed; } /** * Render all mask overlays. */ renderMasks() { this.maskCtx.clearRect(0, 0, this.width, this.height); var imageData = this.maskCtx.createImageData(this.width, this.height); var data = imageData.data; for (var label in this.masks) { var mask = this.masks[label]; var color = this._parseColor(this._getLabelColor(label)); for (var i = 0; i < mask.length; i++) { if (mask[i]) { var pi = i * 4; // Alpha-blend with existing pixel data data[pi] = Math.min(255, data[pi] + color.r); data[pi + 1] = Math.min(255, data[pi + 1] + color.g); data[pi + 2] = Math.min(255, data[pi + 2] + color.b); data[pi + 3] = 200; // Semi-transparent } } } this.maskCtx.putImageData(imageData, 0, 0); } /** * Encode a binary mask to RLE format. */ encodeRLE(mask, width, height) { var counts = []; var currentVal = 0; var currentCount = 0; for (var i = 0; i < mask.length; i++) { var val = mask[i] ? 1 : 0; if (val === currentVal) { currentCount++; } else { counts.push(currentCount); currentVal = val; currentCount = 1; } } counts.push(currentCount); return { counts: counts, size: [height, width] }; } /** * Decode RLE format back to a binary mask. */ decodeRLE(rle) { var size = rle.size; var counts = rle.counts; var mask = new Uint8Array(size[0] * size[1]); var pos = 0; var val = 0; for (var i = 0; i < counts.length; i++) { for (var j = 0; j < counts[i]; j++) { if (pos < mask.length) { mask[pos] = val; pos++; } } val = 1 - val; } return mask; } /** * Load mask data from saved annotations. */ loadMask(label, rle) { this.masks[label] = this.decodeRLE(rle); this.renderMasks(); } /** * Get all mask annotations for saving. */ getMaskAnnotations() { var annotations = []; for (var label in this.masks) { var mask = this.masks[label]; // Check if mask has any filled pixels var hasPixels = false; for (var i = 0; i < mask.length; i++) { if (mask[i]) { hasPixels = true; break; } } if (hasPixels) { annotations.push({ type: 'mask', label: label, rle: this.encodeRLE(mask, this.width, this.height) }); } } return annotations; } /** * Clear all masks. */ clearAll() { this.masks = {}; this.maskCtx.clearRect(0, 0, this.width, this.height); } /** * Clear mask for a specific label. */ clearLabel(label) { delete this.masks[label]; this.renderMasks(); } _getLabelColor(label) { // Try to get color from label buttons on the page var btn = document.querySelector('.label-btn[data-label="' + label + '"]'); if (btn) return btn.dataset.color || '#FF0000'; return this.activeColor || '#FF0000'; } _parseColor(hex) { hex = hex.replace('#', ''); if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } return { r: parseInt(hex.substring(0, 2), 16), g: parseInt(hex.substring(2, 4), 16), b: parseInt(hex.substring(4, 6), 16) }; } } // Expose globally window.SegmentationToolManager = SegmentationToolManager; })();