How to Build a Lightweight Canvas Mosaic Tool for Image Redaction
This article explains how to implement an in‑platform image masking solution using a small JavaScript mosaic library, covering the background problem, design choices, core canvas algorithms, key code snippets, dynamic brush size handling, and practical integration steps.
Background
In the backend management system, the business needs to mask user‑uploaded documents, vehicle license plate photos, and other sensitive images.
Pain point : The business currently uses external image‑processing tools to apply mosaics, then re‑uploads the processed images. When handling many images, this workflow becomes time‑consuming, error‑prone, and requires manual matching of before‑and‑after pictures.
Expectation : Users should be able to view images in the management platform, click to apply mosaics directly on the page, and save the result without switching between multiple tools, ensuring the original and processed images stay linked.
Solution
To avoid reinventing the wheel, two types of open‑source tools are considered:
All‑in‑one editors such as tui.image-editor.
Lightweight single‑function mosaic tools like image-mosaic.
Given the functional requirements, UI style, code size, and integration complexity, the single‑function mosaic library was chosen. Its source is under 200 lines, making it easy to understand and debug.
Principle Overview
The library works by drawing the target image onto a canvas, dividing the canvas into rows and columns of tiles, and then calculating the average RGBA values of each tile to produce a mosaic effect.
Core methods in the library :
class Mosaic {
constructor(context, { tileWidth = 10, tileHeight = 10, brushSize = 3 } = {}) {
const { canvas } = context;
this.context = context;
this.brushSize = brushSize;
this.width = canvas.width;
this.height = canvas.height;
this.tileWidth = tileWidth;
this.tileHeight = tileHeight;
const { width, height } = this;
this.imageData = context.getImageData(0, 0, width, height).data;
this.tileRowSize = Math.ceil(height / this.tileHeight);
this.tileColumnSize = Math.ceil(width / this.tileWidth);
this.tiles = []; // All image tiles.
// Set tiles.
for (let i = 0; i < this.tileRowSize; i++) {
for (let j = 0; j < this.tileColumnSize; j++) {
const tile = {
row: i,
column: j,
pixelWidth: tileWidth,
pixelHeight: tileHeight,
};
if (j === this.column - 1) {
// Last column
tile.pixelWidth = width - j * tileWidth;
}
if (i === this.row - 1) {
// Last row
tile.pixelHeight = height - i * tileHeight;
}
const data = [];
const pixelPosition = this.width * 4 * this.tileHeight * tile.row + tile.column * this.tileWidth * 4;
for (let i = 0, j = tile.pixelHeight; i < j; i++) {
const position = pixelPosition + this.width * 4 * i;
data.push.apply(data, this.imageData.slice(position, position + tile.pixelWidth * 4));
}
tile.data = data;
this.tiles.push(tile);
}
}
}
// ...
}2. Drawing Tiles (Applying Mosaic)
class Mosaic {
// ...
drawTile(tiles) {
tiles = [].concat(tiles);
tiles.forEach((tile) => {
if (tile.isFilled) {
return false; // Already filled.
}
if (!tile.color) {
let dataLen = tile.data.length;
let r = 0, g = 0, b = 0, a = 0;
for (let i = 0; i < dataLen; i += 4) {
r += tile.data[i];
g += tile.data[i + 1];
b += tile.data[i + 2];
a += tile.data[i + 3];
}
const pixelLen = dataLen / 4;
tile.color = {
r: parseInt(r / pixelLen, 10),
g: parseInt(g / pixelLen, 10),
b: parseInt(b / pixelLen, 10),
a: parseInt(a / pixelLen, 10),
};
}
const color = tile.color;
this.context.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a / 255})`;
const x = tile.column * this.tileWidth;
const y = tile.row * this.tileHeight;
const w = tile.pixelWidth;
const h = tile.pixelHeight;
this.context.clearRect(x, y, w, h);
this.context.fillRect(x, y, w, h);
tile.isFilled = true;
});
}
// ...
}3. Getting Tiles by Mouse Position
class Mosaic {
// ...
getTilesByPoint(x, y, isBrushSize = true) {
const tiles = [];
if (isBrushSize) {
let brushSize = this.brushSize;
let startRow = Math.max(0, Math.floor(y / this.tileHeight) - Math.floor(brushSize / 2));
let startColumn = Math.max(0, Math.floor(x / this.tileWidth) - Math.floor(brushSize / 2));
let endRow = Math.min(this.tileRowSize, startRow + brushSize);
let endColumn = Math.min(this.tileColumnSize, startColumn + brushSize);
while (startRow < endRow) {
let column = startColumn;
while (column < endColumn) {
tiles.push(this.tiles[startRow * this.tileColumnSize + column]);
column += 1;
}
startRow += 1;
}
}
return tiles;
}
// ...
}4. Erasing (Restoring Original Pixels)
class Mosaic {
// ...
eraseTile(tiles) {
[].concat(tiles).forEach((tile) => {
const x = tile.column * tile.pixelWidth;
const y = tile.row * tile.pixelHeight;
const w = tile.pixelWidth;
const h = tile.pixelHeight;
var imgData = this.context.createImageData(w, h);
tile.data.forEach((val, i) => {
imgData.data[i] = val;
});
this.context.clearRect(x, y, w, h);
this.context.putImageData(imgData, x, y);
tile.isFilled = false;
});
}
// ...
}Dynamic Brush Size Switching
To change the mosaic block size at runtime, a new Mosaic instance can be created with the desired tileWidth and tileHeight:
function changeSize(value) {
const canvas = this.$refs.canvas;
const ctx = canvas.getContext('2d');
const newInstance = new Mosaic(ctx, {
tileWidth: value,
tileHeight: value,
});
this.mosaic = newInstance;
}Two issues arise when switching instances:
The new instance lacks the original tiles' pixel data, causing previously applied mosaics to become non‑restorable. The fix is to merge the old instance's tiles into the new one:
newInstance.tiles = newInstance.tiles.concat(this.mosaic.tiles);The original eraseTile method calculates coordinates using the instance's tileWidth and tileHeight. After switching, these values differ from the stored tile dimensions, leading to mis‑aligned restoration. Updating the method to use each tile's own pixelWidth and pixelHeight resolves the problem:
eraseTile(tiles) {
[].concat(tiles).forEach((tile) => {
const x = tile.column * tile.pixelWidth;
const y = tile.row * tile.pixelHeight;
// ...rest of the logic remains the same
});
}With these two adjustments, the brush size can be changed dynamically without losing the ability to erase previously applied mosaics.
Finally, a simple UI with a range bar can control the brush size, and mouse events (mousedown, mousemove, mouseup) can drive the drawing workflow, keeping the implementation lightweight and maintainable. Additional image‑processing features such as brightness or contrast adjustments can be added by integrating other processing classes and toggling them via a control panel.
References
tui.image-editor – https://github.com/nhn/tui.image-editor
image-mosaic – https://www.npmjs.com/package/image-mosaic
CanvasRenderingContext2D.getContext – https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/getContext
CanvasRenderingContext2D.getImageData – https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/getImageData
CanvasRenderingContext2D.createImageData – https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/createImageData
CanvasRenderingContext2D.putImageData – https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/putImageData
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
