How can I perform flood fill with HTML Canvas? - javascript
Has anyone implemented a flood fill algorithm in javascript for use with HTML Canvas?
My requirements are simple: flood with a single color starting from a single point, where the boundary color is any color greater than a certain delta of the color at the specified point.
var r1, r2; // red values
var g1, g2; // green values
var b1, b2; // blue values
var actualColorDelta = Math.sqrt((r1 - r2)*(r1 - r2) + (g1 - g2)*(g1 - g2) + (b1 - b2)*(b1 - b2))
function floodFill(canvas, x, y, fillColor, borderColorDelta) {
...
}
Update:
I wrote my own implementation of flood fill, which follows. It is slow, but accurate. About 37% of the time is taken up in two low-level array functions that are part of the prototype framework. They are called by push and pop, I presume. Most of the rest of the time is spent in the main loop.
var ImageProcessing;
ImageProcessing = {
/* Convert HTML color (e.g. "#rrggbb" or "#rrggbbaa") to object with properties r, g, b, a.
* If no alpha value is given, 255 (0xff) will be assumed.
*/
toRGB: function (color) {
var r, g, b, a, html;
html = color;
// Parse out the RGBA values from the HTML Code
if (html.substring(0, 1) === "#")
{
html = html.substring(1);
}
if (html.length === 3 || html.length === 4)
{
r = html.substring(0, 1);
r = r + r;
g = html.substring(1, 2);
g = g + g;
b = html.substring(2, 3);
b = b + b;
if (html.length === 4) {
a = html.substring(3, 4);
a = a + a;
}
else {
a = "ff";
}
}
else if (html.length === 6 || html.length === 8)
{
r = html.substring(0, 2);
g = html.substring(2, 4);
b = html.substring(4, 6);
a = html.length === 6 ? "ff" : html.substring(6, 8);
}
// Convert from Hex (Hexidecimal) to Decimal
r = parseInt(r, 16);
g = parseInt(g, 16);
b = parseInt(b, 16);
a = parseInt(a, 16);
return {r: r, g: g, b: b, a: a};
},
/* Get the color at the given x,y location from the pixels array, assuming the array has a width and height as given.
* This interprets the 1-D array as a 2-D array.
*
* If useColor is defined, its values will be set. This saves on object creation.
*/
getColor: function (pixels, x, y, width, height, useColor) {
var redIndex = y * width * 4 + x * 4;
if (useColor === undefined) {
useColor = { r: pixels[redIndex], g: pixels[redIndex + 1], b: pixels[redIndex + 2], a: pixels[redIndex + 3] };
}
else {
useColor.r = pixels[redIndex];
useColor.g = pixels[redIndex + 1]
useColor.b = pixels[redIndex + 2];
useColor.a = pixels[redIndex + 3];
}
return useColor;
},
setColor: function (pixels, x, y, width, height, color) {
var redIndex = y * width * 4 + x * 4;
pixels[redIndex] = color.r;
pixels[redIndex + 1] = color.g,
pixels[redIndex + 2] = color.b;
pixels[redIndex + 3] = color.a;
},
/*
* fill: Flood a canvas with the given fill color.
*
* Returns a rectangle { x, y, width, height } that defines the maximum extent of the pixels that were changed.
*
* canvas .................... Canvas to modify.
* fillColor ................. RGBA Color to fill with.
* This may be a string ("#rrggbbaa") or an object of the form { r: red, g: green, b: blue, a: alpha }.
* x, y ...................... Coordinates of seed point to start flooding.
* bounds .................... Restrict flooding to this rectangular region of canvas.
* This object has these attributes: { x, y, width, height }.
* If undefined or null, use the whole of the canvas.
* stopFunction .............. Function that decides if a pixel is a boundary that should cause
* flooding to stop. If omitted, any pixel that differs from seedColor
* will cause flooding to stop. seedColor is the color under the seed point (x,y).
* Parameters: stopFunction(fillColor, seedColor, pixelColor).
* Returns true if flooding shoud stop.
* The colors are objects of the form { r: red, g: green, b: blue, a: alpha }
*/
fill: function (canvas, fillColor, x, y, bounds, stopFunction) {
// Supply default values if necessary.
var ctx, minChangedX, minChangedY, maxChangedX, maxChangedY, wasTested, shouldTest, imageData, pixels, currentX, currentY, currentColor, currentIndex, seedColor, tryX, tryY, tryIndex, boundsWidth, boundsHeight, pixelStart, fillRed, fillGreen, fillBlue, fillAlpha;
if (Object.isString(fillColor)) {
fillColor = ImageProcessing.toRGB(fillColor);
}
x = Math.round(x);
y = Math.round(y);
if (bounds === null || bounds === undefined) {
bounds = { x: 0, y: 0, width: canvas.width, height: canvas.height };
}
else {
bounds = { x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.y), height: Math.round(bounds.height) };
}
if (stopFunction === null || stopFunction === undefined) {
stopFunction = new function (fillColor, seedColor, pixelColor) {
return pixelColor.r != seedColor.r || pixelColor.g != seedColor.g || pixelColor.b != seedColor.b || pixelColor.a != seedColor.a;
}
}
minChangedX = maxChangedX = x - bounds.x;
minChangedY = maxChangedY = y - bounds.y;
boundsWidth = bounds.width;
boundsHeight = bounds.height;
// Initialize wasTested to false. As we check each pixel to decide if it should be painted with the new color,
// we will mark it with a true value at wasTested[row = y][column = x];
wasTested = new Array(boundsHeight * boundsWidth);
/*
$R(0, bounds.height - 1).each(function (row) {
var subArray = new Array(bounds.width);
wasTested[row] = subArray;
});
*/
// Start with a single point that we know we should test: (x, y).
// Convert (x,y) to image data coordinates by subtracting the bounds' origin.
currentX = x - bounds.x;
currentY = y - bounds.y;
currentIndex = currentY * boundsWidth + currentX;
shouldTest = [ currentIndex ];
ctx = canvas.getContext("2d");
//imageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height);
imageData = ImageProcessing.getImageData(ctx, bounds.x, bounds.y, bounds.width, bounds.height);
pixels = imageData.data;
seedColor = ImageProcessing.getColor(pixels, currentX, currentY, boundsWidth, boundsHeight);
currentColor = { r: 0, g: 0, b: 0, a: 1 };
fillRed = fillColor.r;
fillGreen = fillColor.g;
fillBlue = fillColor.b;
fillAlpha = fillColor.a;
while (shouldTest.length > 0) {
currentIndex = shouldTest.pop();
currentX = currentIndex % boundsWidth;
currentY = (currentIndex - currentX) / boundsWidth;
if (! wasTested[currentIndex]) {
wasTested[currentIndex] = true;
//currentColor = ImageProcessing.getColor(pixels, currentX, currentY, boundsWidth, boundsHeight, currentColor);
// Inline getColor for performance.
pixelStart = currentIndex * 4;
currentColor.r = pixels[pixelStart];
currentColor.g = pixels[pixelStart + 1]
currentColor.b = pixels[pixelStart + 2];
currentColor.a = pixels[pixelStart + 3];
if (! stopFunction(fillColor, seedColor, currentColor)) {
// Color the pixel with the fill color.
//ImageProcessing.setColor(pixels, currentX, currentY, boundsWidth, boundsHeight, fillColor);
// Inline setColor for performance
pixels[pixelStart] = fillRed;
pixels[pixelStart + 1] = fillGreen;
pixels[pixelStart + 2] = fillBlue;
pixels[pixelStart + 3] = fillAlpha;
if (minChangedX < currentX) { minChangedX = currentX; }
else if (maxChangedX > currentX) { maxChangedX = currentX; }
if (minChangedY < currentY) { minChangedY = currentY; }
else if (maxChangedY > currentY) { maxChangedY = currentY; }
// Add the adjacent four pixels to the list to be tested, unless they have already been tested.
tryX = currentX - 1;
tryY = currentY;
tryIndex = tryY * boundsWidth + tryX;
if (tryX >= 0 && ! wasTested[tryIndex]) {
shouldTest.push(tryIndex);
}
tryX = currentX;
tryY = currentY + 1;
tryIndex = tryY * boundsWidth + tryX;
if (tryY < boundsHeight && ! wasTested[tryIndex]) {
shouldTest.push(tryIndex);
}
tryX = currentX + 1;
tryY = currentY;
tryIndex = tryY * boundsWidth + tryX;
if (tryX < boundsWidth && ! wasTested[tryIndex]) {
shouldTest.push(tryIndex);
}
tryX = currentX;
tryY = currentY - 1;
tryIndex = tryY * boundsWidth + tryX;
if (tryY >= 0 && ! wasTested[tryIndex]) {
shouldTest.push(tryIndex);
}
}
}
}
//ctx.putImageData(imageData, bounds.x, bounds.y);
ImageProcessing.putImageData(ctx, imageData, bounds.x, bounds.y);
return { x: minChangedX + bounds.x, y: minChangedY + bounds.y, width: maxChangedX - minChangedX + 1, height: maxChangedY - minChangedY + 1 };
},
getImageData: function (ctx, x, y, w, h) {
return ctx.getImageData(x, y, w, h);
},
putImageData: function (ctx, data, x, y) {
ctx.putImageData(data, x, y);
}
};
BTW, when I call this, I use a custom stopFunction:
stopFill : function (fillColor, seedColor, pixelColor) {
// Ignore alpha difference for now.
return Math.abs(pixelColor.r - seedColor.r) > this.colorTolerance || Math.abs(pixelColor.g - seedColor.g) > this.colorTolerance || Math.abs(pixelColor.b - seedColor.b) > this.colorTolerance;
},
If anyone can see a way to improve performance of this code, I would appreciate it. The basic idea is:
1) Seed color is the initial color at the point to start flooding.
2) Try four adjacent points: up, right, down and left one pixel.
3) If point is out of range or has been visited already, skip it.
4) Otherwise push point onto to the stack of interesting points.
5) Pop the next interesting point off the stack.
6) If the color at that point is a stop color (as defined in the stopFunction) then stop processing that point and skip to step 5.
7) Otherwise, skip to step 2.
8) When there are no more interesting points to visit, stop looping.
Remembering that a point has been visited requires an array with the same number of elements as there are pixels.
To create a flood fill you need to be able to look at the pixels that are there already and check they aren't the color you started with so something like this.
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, [255, 0, 0, 255]);
function getPixel(imageData, x, y) {
if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
return [-1, -1, -1, -1]; // impossible color
} else {
const offset = (y * imageData.width + x) * 4;
return imageData.data.slice(offset, offset + 4);
}
}
function setPixel(imageData, x, y, color) {
const offset = (y * imageData.width + x) * 4;
imageData.data[offset + 0] = color[0];
imageData.data[offset + 1] = color[1];
imageData.data[offset + 2] = color[2];
imageData.data[offset + 3] = color[0];
}
function colorsMatch(a, b) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}
function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// get the color we're filling
const targetColor = getPixel(imageData, x, y);
// check we are actually filling a different color
if (!colorsMatch(targetColor, fillColor)) {
fillPixel(imageData, x, y, targetColor, fillColor);
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}
function fillPixel(imageData, x, y, targetColor, fillColor) {
const currentColor = getPixel(imageData, x, y);
if (colorsMatch(currentColor, targetColor)) {
setPixel(imageData, x, y, fillColor);
fillPixel(imageData, x + 1, y, targetColor, fillColor);
fillPixel(imageData, x - 1, y, targetColor, fillColor);
fillPixel(imageData, x, y + 1, targetColor, fillColor);
fillPixel(imageData, x, y - 1, targetColor, fillColor);
}
}
<canvas></canvas>
There's at least 2 problems with this code though.
It's deeply recursive.
So you might run out of stack space
It's slow.
No idea if it's too slow but JavaScript in the browser is mostly single threaded so while this code is running the browser is frozen. For a large canvas that frozen time might make the page really slow and if it's frozen too long the browser will ask if the user wants to kill the page.
The solution to running out of stack space is to implement our own stack. For example instead of recursively calling fillPixel we could keep an array of positions we want to look at. We'd add the 4 positions to that array and then pop things off the array until it's empty
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, [255, 0, 0, 255]);
function getPixel(imageData, x, y) {
if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
return [-1, -1, -1, -1]; // impossible color
} else {
const offset = (y * imageData.width + x) * 4;
return imageData.data.slice(offset, offset + 4);
}
}
function setPixel(imageData, x, y, color) {
const offset = (y * imageData.width + x) * 4;
imageData.data[offset + 0] = color[0];
imageData.data[offset + 1] = color[1];
imageData.data[offset + 2] = color[2];
imageData.data[offset + 3] = color[0];
}
function colorsMatch(a, b) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}
function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// get the color we're filling
const targetColor = getPixel(imageData, x, y);
// check we are actually filling a different color
if (!colorsMatch(targetColor, fillColor)) {
const pixelsToCheck = [x, y];
while (pixelsToCheck.length > 0) {
const y = pixelsToCheck.pop();
const x = pixelsToCheck.pop();
const currentColor = getPixel(imageData, x, y);
if (colorsMatch(currentColor, targetColor)) {
setPixel(imageData, x, y, fillColor);
pixelsToCheck.push(x + 1, y);
pixelsToCheck.push(x - 1, y);
pixelsToCheck.push(x, y + 1);
pixelsToCheck.push(x, y - 1);
}
}
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}
<canvas></canvas>
The solution to it being too slow is either to make it run a little at a time OR to move it to a worker. I think that's a little too much to show in the same answer though here's an example.
I tested the code above on a 4096x4096 canvas and it took 16 seconds to fill a blank canvas on my machine so yes it's arguably too slow but putting it in a worker brings new problems which is that the result will be asynchronous so even though the browser wouldn't freeze you'd probably want to prevent the user from doing something until it finishes.
Another issue is you'll see the lines are antialiased and so filling with a solid color fills close the the line but not all the way up to it. To fix that you can change colorsMatch to check for close enough but then you have a new problem that if targetColor and fillColor are also close enough it will keep trying to fill itself. You could solve that by making another array, one byte or one bit per pixel to track places you've ready checked.
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, [255, 0, 0, 255], 128);
function getPixel(imageData, x, y) {
if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
return [-1, -1, -1, -1]; // impossible color
} else {
const offset = (y * imageData.width + x) * 4;
return imageData.data.slice(offset, offset + 4);
}
}
function setPixel(imageData, x, y, color) {
const offset = (y * imageData.width + x) * 4;
imageData.data[offset + 0] = color[0];
imageData.data[offset + 1] = color[1];
imageData.data[offset + 2] = color[2];
imageData.data[offset + 3] = color[0];
}
function colorsMatch(a, b, rangeSq) {
const dr = a[0] - b[0];
const dg = a[1] - b[1];
const db = a[2] - b[2];
const da = a[3] - b[3];
return dr * dr + dg * dg + db * db + da * da < rangeSq;
}
function floodFill(ctx, x, y, fillColor, range = 1) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// flags for if we visited a pixel already
const visited = new Uint8Array(imageData.width, imageData.height);
// get the color we're filling
const targetColor = getPixel(imageData, x, y);
// check we are actually filling a different color
if (!colorsMatch(targetColor, fillColor)) {
const rangeSq = range * range;
const pixelsToCheck = [x, y];
while (pixelsToCheck.length > 0) {
const y = pixelsToCheck.pop();
const x = pixelsToCheck.pop();
const currentColor = getPixel(imageData, x, y);
if (!visited[y * imageData.width + x] &&
colorsMatch(currentColor, targetColor, rangeSq)) {
setPixel(imageData, x, y, fillColor);
visited[y * imageData.width + x] = 1; // mark we were here already
pixelsToCheck.push(x + 1, y);
pixelsToCheck.push(x - 1, y);
pixelsToCheck.push(x, y + 1);
pixelsToCheck.push(x, y - 1);
}
}
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}
<canvas></canvas>
Note that this version of colorsMatch is kind of naive. It might be better to convert to HSV or something or maybe you want to weight by alpha. I don't know what a good metric is for matching colors.
Update
Another way to speed things up is of course to just optimize the code. Kaiido pointed out an obvious speedup which is to use a Uint32Array view on the pixels. That way looking up a pixel and setting a pixel there's just one 32bit value to read or write. Just that change makes it about 4x faster. It still takes 4 seconds to fill a 4096x4096 canvas though. There might be other optimizations like instead of calling getPixels make that inline but don't push a new pixel on our list of pixels to check if they are out of range. It might be 10% speed up (no idea) but won't make it fast enough to be an interactive speed.
There are other speedups like checking across a row at a time since rows are cache friendly and you can compute the offset to a row once and use that while checking the entire row whereas now for every pixel we have to compute the offset multiple times.
Those will complicate the algorithm so they are best left for you to figure out.
Let me also add, given the answer above freezes the browser while the fill is happening and that on a larger canvas that freeze could be too long, you can easily make the algorithm span over time using ES6 async/await. You need to choose how much work to give each segment of time. Choose too small and it will take a long time to fill. Choose too large and you'll get jank as the browser freezes.
Here's an example. Set ticksPerUpdate to speed up or slow down the fill rate
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(100, 145);
ctx.lineTo(110, 105);
ctx.lineTo(130, 125);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, 0xFF0000FF);
function getPixel(pixelData, x, y) {
if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) {
return -1; // impossible color
} else {
return pixelData.data[y * pixelData.width + x];
}
}
async function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// make a Uint32Array view on the pixels so we can manipulate pixels
// one 32bit value at a time instead of as 4 bytes per pixel
const pixelData = {
width: imageData.width,
height: imageData.height,
data: new Uint32Array(imageData.data.buffer),
};
// get the color we're filling
const targetColor = getPixel(pixelData, x, y);
// check we are actually filling a different color
if (targetColor !== fillColor) {
const ticksPerUpdate = 50;
let tickCount = 0;
const pixelsToCheck = [x, y];
while (pixelsToCheck.length > 0) {
const y = pixelsToCheck.pop();
const x = pixelsToCheck.pop();
const currentColor = getPixel(pixelData, x, y);
if (currentColor === targetColor) {
pixelData.data[y * pixelData.width + x] = fillColor;
// put the data back
ctx.putImageData(imageData, 0, 0);
++tickCount;
if (tickCount % ticksPerUpdate === 0) {
await wait();
}
pixelsToCheck.push(x + 1, y);
pixelsToCheck.push(x - 1, y);
pixelsToCheck.push(x, y + 1);
pixelsToCheck.push(x, y - 1);
}
}
}
}
function wait(delay = 0) {
return new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
<canvas></canvas>
update update
Instead of setTimeout which is throttled by the browser, you can abuse postMessage which is not
function makeExposedPromise() {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { promise, resolve, reject };
}
const resolveFns = [];
window.addEventListener('message', (e) => {
const resolve = resolveFns.shift();
resolve();
});
function wait() {
const {resolve, promise} = makeExposedPromise();
resolveFns.push(resolve);
window.postMessage('');
return promise;
}
If you use that it there's less need to choose a number of operations. Also note: the slowest part is calling putImageData. The reason it's inside the loop above is only so we can see the progress. Move that to the end and it will run much faster
function makeExposedPromise() {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { promise, resolve, reject };
}
const resolveFns = [];
window.addEventListener('message', (e) => {
const resolve = resolveFns.shift();
resolve();
});
function wait() {
const {resolve, promise} = makeExposedPromise();
resolveFns.push(resolve);
window.postMessage('');
return promise;
}
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(100, 145);
ctx.lineTo(110, 105);
ctx.lineTo(130, 125);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, 0xFF0000FF);
function getPixel(pixelData, x, y) {
if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) {
return -1; // impossible color
} else {
return pixelData.data[y * pixelData.width + x];
}
}
async function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// make a Uint32Array view on the pixels so we can manipulate pixels
// one 32bit value at a time instead of as 4 bytes per pixel
const pixelData = {
width: imageData.width,
height: imageData.height,
data: new Uint32Array(imageData.data.buffer),
};
// get the color we're filling
const targetColor = getPixel(pixelData, x, y);
// check we are actually filling a different color
if (targetColor !== fillColor) {
const pixelsToCheck = [x, y];
while (pixelsToCheck.length > 0) {
const y = pixelsToCheck.pop();
const x = pixelsToCheck.pop();
const currentColor = getPixel(pixelData, x, y);
if (currentColor === targetColor) {
pixelData.data[y * pixelData.width + x] = fillColor;
await wait();
pixelsToCheck.push(x + 1, y);
pixelsToCheck.push(x - 1, y);
pixelsToCheck.push(x, y + 1);
pixelsToCheck.push(x, y - 1);
}
}
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}
<canvas></canvas>
It's still better to choose a number of operations per call to wait
There are also faster algorithms. The issue with the one above is there is for every pixel that matches, 4 are added to the stack of things to pixels to check. That's lots of allocations and multiple checking. A faster way is to it by span.
For a given span, check as far left as you can, then as far right as you can, now fill that span. Then, check the pixels above and/or below the span you just found and add the spans you find to your stack. Pop the top span off, and try to expand it left and right. There's no need to check the pixels in the middle since you already checked them. Further, if this span was generated from one below then you don't need to check the pixels below the starting sub-span of this span since you know that area was already filled. Similarly if this pan was generated from one above then you don't need to check the pixels above the starting sub-span of this span for the same reason.
function main() {
const ctx = document.querySelector("canvas").getContext("2d");
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(100, 145);
ctx.lineTo(110, 105);
ctx.lineTo(130, 125);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();
floodFill(ctx, 40, 50, 0xFF0000FF);
}
main();
function getPixel(pixelData, x, y) {
if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) {
return -1; // impossible color
} else {
return pixelData.data[y * pixelData.width + x];
}
}
function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
// make a Uint32Array view on the pixels so we can manipulate pixels
// one 32bit value at a time instead of as 4 bytes per pixel
const pixelData = {
width: imageData.width,
height: imageData.height,
data: new Uint32Array(imageData.data.buffer),
};
// get the color we're filling
const targetColor = getPixel(pixelData, x, y);
// check we are actually filling a different color
if (targetColor !== fillColor) {
const spansToCheck = [];
function addSpan(left, right, y, direction) {
spansToCheck.push({left, right, y, direction});
}
function checkSpan(left, right, y, direction) {
let inSpan = false;
let start;
let x;
for (x = left; x < right; ++x) {
const color = getPixel(pixelData, x, y);
if (color === targetColor) {
if (!inSpan) {
inSpan = true;
start = x;
}
} else {
if (inSpan) {
inSpan = false;
addSpan(start, x - 1, y, direction);
}
}
}
if (inSpan) {
inSpan = false;
addSpan(start, x - 1, y, direction);
}
}
addSpan(x, x, y, 0);
while (spansToCheck.length > 0) {
const {left, right, y, direction} = spansToCheck.pop();
// do left until we hit something, while we do this check above and below and add
let l = left;
for (;;) {
--l;
const color = getPixel(pixelData, l, y);
if (color !== targetColor) {
break;
}
}
++l
let r = right;
for (;;) {
++r;
const color = getPixel(pixelData, r, y);
if (color !== targetColor) {
break;
}
}
const lineOffset = y * pixelData.width;
pixelData.data.fill(fillColor, lineOffset + l, lineOffset + r);
if (direction <= 0) {
checkSpan(l, r, y - 1, -1);
} else {
checkSpan(l, left, y - 1, -1);
checkSpan(right, r, y - 1, -1);
}
if (direction >= 0) {
checkSpan(l, r, y + 1, +1);
} else {
checkSpan(l, left, y + 1, +1);
checkSpan(right, r, y + 1, +1);
}
}
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}
<canvas></canvas>
Note: I didn't test this well, there might be an off by 1 error or other issue. I'm 99% sure I wrote the span method in 1993 for My Paint but don't remember if I have the source. But in any case, it's fast enough there's no need for wait
Here's an implementation that I've been working on. It can get really slow if the replacement color is too close to the original color. It's quite a bit faster in Chrome than Firefox (I haven't tested it in any other browsers).
I also haven't done exhaustive testing yet, so there may be edge cases where it doesn't work.
function getPixel(pixelData, x, y) {
if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) {
return NaN;
}
var pixels = pixelData.data;
var i = (y * pixelData.width + x) * 4;
return ((pixels[i + 0] & 0xFF) << 24) |
((pixels[i + 1] & 0xFF) << 16) |
((pixels[i + 2] & 0xFF) << 8) |
((pixels[i + 3] & 0xFF) << 0);
}
function setPixel(pixelData, x, y, color) {
var i = (y * pixelData.width + x) * 4;
var pixels = pixelData.data;
pixels[i + 0] = (color >>> 24) & 0xFF;
pixels[i + 1] = (color >>> 16) & 0xFF;
pixels[i + 2] = (color >>> 8) & 0xFF;
pixels[i + 3] = (color >>> 0) & 0xFF;
}
function diff(c1, c2) {
if (isNaN(c1) || isNaN(c2)) {
return Infinity;
}
var dr = ((c1 >>> 24) & 0xFF) - ((c2 >>> 24) & 0xFF);
var dg = ((c1 >>> 16) & 0xFF) - ((c2 >>> 16) & 0xFF);
var db = ((c1 >>> 8) & 0xFF) - ((c2 >>> 8) & 0xFF);
var da = ((c1 >>> 0) & 0xFF) - ((c2 >>> 0) & 0xFF);
return dr*dr + dg*dg + db*db + da*da;
}
function floodFill(canvas, x, y, replacementColor, delta) {
var current, w, e, stack, color, cx, cy;
var context = canvas.getContext("2d");
var pixelData = context.getImageData(0, 0, canvas.width, canvas.height);
var done = [];
for (var i = 0; i < canvas.width; i++) {
done[i] = [];
}
var targetColor = getPixel(pixelData, x, y);
delta *= delta;
stack = [ [x, y] ];
done[x][y] = true;
while ((current = stack.pop())) {
cx = current[0];
cy = current[1];
if (diff(getPixel(pixelData, cx, cy), targetColor) <= delta) {
setPixel(pixelData, cx, cy, replacementColor);
w = e = cx;
while (w > 0 && diff(getPixel(pixelData, w - 1, cy), targetColor) <= delta) {
--w;
if (done[w][cy]) break;
setPixel(pixelData, w, cy, replacementColor);
}
while (e < pixelData.width - 1 && diff(getPixel(pixelData, e + 1, cy), targetColor) <= delta) {
++e;
if (done[e][cy]) break;
setPixel(pixelData, e, cy, replacementColor);
}
for (cx = w; cx <= e; cx++) {
if (cy > 0) {
color = getPixel(pixelData, cx, cy - 1);
if (diff(color, targetColor) <= delta) {
if (!done[cx][cy - 1]) {
stack.push([cx, cy - 1]);
done[cx][cy - 1] = true;
}
}
}
if (cy < canvas.height - 1) {
color = getPixel(pixelData, cx, cy + 1);
if (diff(color, targetColor) <= delta) {
if (!done[cx][cy + 1]) {
stack.push([cx, cy + 1]);
done[cx][cy + 1] = true;
}
}
}
}
}
}
context.putImageData(pixelData, 0, 0, 0, 0, canvas.width, canvas.height);
}
I would not treat the canvas as a bitmap image.
Instead I would keep a collection of painting-objects and modify that collection.
Then for example you can fill a path or shape or add a new shape that has the boundaries of the objects you are trying to fill.
I can't see how "normal" floodFill makes sense in vector drawing..
Related
KonvaJS/Canvas Dynamic fog of war reveal with obstacles
I have a 2D board made with KonvaJS and tokens that can move on a square grid. I can already add fog of war and remove it manually. However, I would like to make it so, when each token moves, it reveals a certain around it, taking into account walls. Most of the work is done, however it's not entirely accurate. Basically for each wall, I'm checking if the token is on the top/right/bottom/left of it. And then depending on which one it is, I reduce the width/height of the revealing area so it doesn't go beyond the wall. Here is an image explaining what I have and what I need Legend: Gray is fog of war Red area is the wall/obstacle Token is the movable token Blue area is the revealed area Blue lines inside red area is where it intersects Purple lines are squares that should be revealed (aka, it should be blue) Basically, in this case, an intersection was detected and the token is on the right side of the obstacle. So I got the right side of the wall (the x coordinate), and made the blue area starting point be that x coordinate and removed from the total width of the blue area the intersection width(the blue lines, so 1 square of width was removed). However, because of that, the purple lines don't get filled in. Unfortunately, I can't just check the intersection points between blue and red and only remove those, because if the blue area is bigger than the red area, it would reveal the other side of the obstacle(which I don't want). Here is the code I'm using to iterate the walls, checking if there is an intersection, checking where the token is, and then removing the width or height according to the intersection. const tokenPosition = { x: 10, y: 10 }; const haveIntersection = (r1, r2) => !( r2.x > r1.x + r1.width || // Compares top left with top right r2.x + r2.width < r1.x || // Compares top right with top left r2.y > r1.y + r1.height || // Compare bottom left with bottom right r2.y + r2.height < r1.y // Compare bottom right with bottom left ); walls.forEach(wall => { const redArea = { x: wall.x, y: wall.y, width: wall.width, height: wall.height }; // blueArea has the same properties as redArea if (haveIntersection(blueArea, redArea)) { const tokenToTheRight = tokenPosition.x > wall.x + wall.width; const tokenToTheLeft = tokenPosition.x < wall.x; const tokenToTheTop = tokenPosition.y < wall.y; const tokenToTheBottom = tokenPosition.y > wall.y + wall.height; if (tokenToTheRight) { let diff = wall.x + wall.width - blueArea.x; blueArea.x = wall.x + wall.width; blueArea.width = blueArea.width - diff; } if (tokenToTheLeft) { let diff = blueArea.x + blueArea.width - wall.x; blueArea.width = blueArea.width - diff; } if (tokenToTheTop) { let diff = blueArea.y + blueArea.height - wall.y; blueArea.height = blueArea.height - diff; } if (tokenToTheBottom) { let diff = wall.y + wall.height - blueArea.y; blueArea.y = wall.y + wall.height; blueArea.height = blueArea.height - diff; } } }); Any idea on how to fix this or if I should be taking a different approach?
You'll have to do something ray-tracing like to get this to work. In the snippet below, I: Loop over each cell in your token's field-of-view Check for that cell center whether it is in a box, or a line between the token and the cell center intersects with a wall of a box Color the cell based on whether it intersects Note: the occlusion from the boxes is quite aggressive because we only check the center for quite a large grid cell. You can play around with some of the settings to see if it matches your requirements. Let me know if it doesn't. Legend: Red: box Light blue: in field of view Orange: blocked field of view because box-overlap Yellow: blocked field of view because behind box // Setup const cvs = document.createElement("canvas"); cvs.width = 480; cvs.height = 360; const ctx = cvs.getContext("2d"); document.body.appendChild(cvs); // Game state const GRID = 40; const H_GRID = GRID / 2; const token = { x: 7.5, y: 3.5, fow: 2 }; const boxes = [ { x: 2, y: 3, w: 4, h: 4 }, { x: 8, y: 4, w: 1, h: 1 }, ]; const getBoxSides = ({ x, y, w, h }) => [ [ [x + 0, y + 0], [x + w, y + 0]], [ [x + w, y + 0], [x + w, y + h]], [ [x + w, y + h], [x + 0, y + h]], [ [x + 0, y + h], [x + 0, y + 0]], ]; const renderToken = ({ x, y, fow }) => { const cx = x * GRID; const cy = y * GRID; // Render FOV for (let ix = x - fow; ix <= x + fow; ix += 1) { for (let iy = y - fow; iy <= y + fow; iy += 1) { let intersectionFound = false; for (const box of boxes) { if ( // Check within boxes pointInBox(ix, iy, box) || // Check walls // Warning: SLOW getBoxSides(box).some( ([[ x1, y1], [x2, y2]]) => intersects(x, y, ix, iy, x1, y1, x2, y2) ) ) { intersectionFound = true; break; } } if (!intersectionFound) { renderBox({ x: ix - .5, y: iy - .5, w: 1, h: 1 }, "rgba(0, 255, 255, 0.5)", 0); ctx.fillStyle = "lime"; ctx.fillRect(ix * GRID - 2, iy * GRID - 2, 4, 4); } else { renderBox({ x: ix - .5, y: iy - .5, w: 1, h: 1 }, "rgba(255, 255, 0, 0.5)", 0); ctx.fillStyle = "red"; ctx.fillRect(ix * GRID - 2, iy * GRID - 2, 4, 4); } } } ctx.lineWidth = 5; ctx.fillStyle = "#efefef"; ctx.beginPath(); ctx.arc(cx, cy, GRID / 2, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } const renderBox = ({ x, y, w, h }, color = "red", strokeWidth = 5) => { ctx.fillStyle = color; ctx.strokeWidth = strokeWidth; ctx.beginPath(); ctx.rect(x * GRID, y * GRID, w * GRID, h * GRID); ctx.closePath(); ctx.fill(); if (strokeWidth) ctx.stroke(); } const renderGrid = () => { ctx.lineWidth = 1; ctx.beginPath(); let x = 0; while(x < cvs.width) { ctx.moveTo(x, 0); ctx.lineTo(x, cvs.height); x += GRID; } let y = 0; while(y < cvs.height) { ctx.moveTo(0, y); ctx.lineTo(cvs.width, y); y += GRID; } ctx.stroke(); } boxes.forEach(box => renderBox(box)); renderToken(token); renderGrid(); // Utils // https://errorsandanswers.com/test-if-two-lines-intersect-javascript-function/ function intersects(a,b,c,d,p,q,r,s) { var det, gamma, lambda; det = (c - a) * (s - q) - (r - p) * (d - b); if (det === 0) { return false; } else { lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det; gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det; return (0 <= lambda && lambda <= 1) && (0 <= gamma && gamma <= 1); } } function pointInBox(x, y, box) { return ( x > box.x && x < box.x + box.w && y > box.y && y < box.bottom ); } canvas { border: 1px solid black; }
How to add paint bucket fill in HTML5 canvas [duplicate]
Has anyone implemented a flood fill algorithm in javascript for use with HTML Canvas? My requirements are simple: flood with a single color starting from a single point, where the boundary color is any color greater than a certain delta of the color at the specified point. var r1, r2; // red values var g1, g2; // green values var b1, b2; // blue values var actualColorDelta = Math.sqrt((r1 - r2)*(r1 - r2) + (g1 - g2)*(g1 - g2) + (b1 - b2)*(b1 - b2)) function floodFill(canvas, x, y, fillColor, borderColorDelta) { ... } Update: I wrote my own implementation of flood fill, which follows. It is slow, but accurate. About 37% of the time is taken up in two low-level array functions that are part of the prototype framework. They are called by push and pop, I presume. Most of the rest of the time is spent in the main loop. var ImageProcessing; ImageProcessing = { /* Convert HTML color (e.g. "#rrggbb" or "#rrggbbaa") to object with properties r, g, b, a. * If no alpha value is given, 255 (0xff) will be assumed. */ toRGB: function (color) { var r, g, b, a, html; html = color; // Parse out the RGBA values from the HTML Code if (html.substring(0, 1) === "#") { html = html.substring(1); } if (html.length === 3 || html.length === 4) { r = html.substring(0, 1); r = r + r; g = html.substring(1, 2); g = g + g; b = html.substring(2, 3); b = b + b; if (html.length === 4) { a = html.substring(3, 4); a = a + a; } else { a = "ff"; } } else if (html.length === 6 || html.length === 8) { r = html.substring(0, 2); g = html.substring(2, 4); b = html.substring(4, 6); a = html.length === 6 ? "ff" : html.substring(6, 8); } // Convert from Hex (Hexidecimal) to Decimal r = parseInt(r, 16); g = parseInt(g, 16); b = parseInt(b, 16); a = parseInt(a, 16); return {r: r, g: g, b: b, a: a}; }, /* Get the color at the given x,y location from the pixels array, assuming the array has a width and height as given. * This interprets the 1-D array as a 2-D array. * * If useColor is defined, its values will be set. This saves on object creation. */ getColor: function (pixels, x, y, width, height, useColor) { var redIndex = y * width * 4 + x * 4; if (useColor === undefined) { useColor = { r: pixels[redIndex], g: pixels[redIndex + 1], b: pixels[redIndex + 2], a: pixels[redIndex + 3] }; } else { useColor.r = pixels[redIndex]; useColor.g = pixels[redIndex + 1] useColor.b = pixels[redIndex + 2]; useColor.a = pixels[redIndex + 3]; } return useColor; }, setColor: function (pixels, x, y, width, height, color) { var redIndex = y * width * 4 + x * 4; pixels[redIndex] = color.r; pixels[redIndex + 1] = color.g, pixels[redIndex + 2] = color.b; pixels[redIndex + 3] = color.a; }, /* * fill: Flood a canvas with the given fill color. * * Returns a rectangle { x, y, width, height } that defines the maximum extent of the pixels that were changed. * * canvas .................... Canvas to modify. * fillColor ................. RGBA Color to fill with. * This may be a string ("#rrggbbaa") or an object of the form { r: red, g: green, b: blue, a: alpha }. * x, y ...................... Coordinates of seed point to start flooding. * bounds .................... Restrict flooding to this rectangular region of canvas. * This object has these attributes: { x, y, width, height }. * If undefined or null, use the whole of the canvas. * stopFunction .............. Function that decides if a pixel is a boundary that should cause * flooding to stop. If omitted, any pixel that differs from seedColor * will cause flooding to stop. seedColor is the color under the seed point (x,y). * Parameters: stopFunction(fillColor, seedColor, pixelColor). * Returns true if flooding shoud stop. * The colors are objects of the form { r: red, g: green, b: blue, a: alpha } */ fill: function (canvas, fillColor, x, y, bounds, stopFunction) { // Supply default values if necessary. var ctx, minChangedX, minChangedY, maxChangedX, maxChangedY, wasTested, shouldTest, imageData, pixels, currentX, currentY, currentColor, currentIndex, seedColor, tryX, tryY, tryIndex, boundsWidth, boundsHeight, pixelStart, fillRed, fillGreen, fillBlue, fillAlpha; if (Object.isString(fillColor)) { fillColor = ImageProcessing.toRGB(fillColor); } x = Math.round(x); y = Math.round(y); if (bounds === null || bounds === undefined) { bounds = { x: 0, y: 0, width: canvas.width, height: canvas.height }; } else { bounds = { x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.y), height: Math.round(bounds.height) }; } if (stopFunction === null || stopFunction === undefined) { stopFunction = new function (fillColor, seedColor, pixelColor) { return pixelColor.r != seedColor.r || pixelColor.g != seedColor.g || pixelColor.b != seedColor.b || pixelColor.a != seedColor.a; } } minChangedX = maxChangedX = x - bounds.x; minChangedY = maxChangedY = y - bounds.y; boundsWidth = bounds.width; boundsHeight = bounds.height; // Initialize wasTested to false. As we check each pixel to decide if it should be painted with the new color, // we will mark it with a true value at wasTested[row = y][column = x]; wasTested = new Array(boundsHeight * boundsWidth); /* $R(0, bounds.height - 1).each(function (row) { var subArray = new Array(bounds.width); wasTested[row] = subArray; }); */ // Start with a single point that we know we should test: (x, y). // Convert (x,y) to image data coordinates by subtracting the bounds' origin. currentX = x - bounds.x; currentY = y - bounds.y; currentIndex = currentY * boundsWidth + currentX; shouldTest = [ currentIndex ]; ctx = canvas.getContext("2d"); //imageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height); imageData = ImageProcessing.getImageData(ctx, bounds.x, bounds.y, bounds.width, bounds.height); pixels = imageData.data; seedColor = ImageProcessing.getColor(pixels, currentX, currentY, boundsWidth, boundsHeight); currentColor = { r: 0, g: 0, b: 0, a: 1 }; fillRed = fillColor.r; fillGreen = fillColor.g; fillBlue = fillColor.b; fillAlpha = fillColor.a; while (shouldTest.length > 0) { currentIndex = shouldTest.pop(); currentX = currentIndex % boundsWidth; currentY = (currentIndex - currentX) / boundsWidth; if (! wasTested[currentIndex]) { wasTested[currentIndex] = true; //currentColor = ImageProcessing.getColor(pixels, currentX, currentY, boundsWidth, boundsHeight, currentColor); // Inline getColor for performance. pixelStart = currentIndex * 4; currentColor.r = pixels[pixelStart]; currentColor.g = pixels[pixelStart + 1] currentColor.b = pixels[pixelStart + 2]; currentColor.a = pixels[pixelStart + 3]; if (! stopFunction(fillColor, seedColor, currentColor)) { // Color the pixel with the fill color. //ImageProcessing.setColor(pixels, currentX, currentY, boundsWidth, boundsHeight, fillColor); // Inline setColor for performance pixels[pixelStart] = fillRed; pixels[pixelStart + 1] = fillGreen; pixels[pixelStart + 2] = fillBlue; pixels[pixelStart + 3] = fillAlpha; if (minChangedX < currentX) { minChangedX = currentX; } else if (maxChangedX > currentX) { maxChangedX = currentX; } if (minChangedY < currentY) { minChangedY = currentY; } else if (maxChangedY > currentY) { maxChangedY = currentY; } // Add the adjacent four pixels to the list to be tested, unless they have already been tested. tryX = currentX - 1; tryY = currentY; tryIndex = tryY * boundsWidth + tryX; if (tryX >= 0 && ! wasTested[tryIndex]) { shouldTest.push(tryIndex); } tryX = currentX; tryY = currentY + 1; tryIndex = tryY * boundsWidth + tryX; if (tryY < boundsHeight && ! wasTested[tryIndex]) { shouldTest.push(tryIndex); } tryX = currentX + 1; tryY = currentY; tryIndex = tryY * boundsWidth + tryX; if (tryX < boundsWidth && ! wasTested[tryIndex]) { shouldTest.push(tryIndex); } tryX = currentX; tryY = currentY - 1; tryIndex = tryY * boundsWidth + tryX; if (tryY >= 0 && ! wasTested[tryIndex]) { shouldTest.push(tryIndex); } } } } //ctx.putImageData(imageData, bounds.x, bounds.y); ImageProcessing.putImageData(ctx, imageData, bounds.x, bounds.y); return { x: minChangedX + bounds.x, y: minChangedY + bounds.y, width: maxChangedX - minChangedX + 1, height: maxChangedY - minChangedY + 1 }; }, getImageData: function (ctx, x, y, w, h) { return ctx.getImageData(x, y, w, h); }, putImageData: function (ctx, data, x, y) { ctx.putImageData(data, x, y); } }; BTW, when I call this, I use a custom stopFunction: stopFill : function (fillColor, seedColor, pixelColor) { // Ignore alpha difference for now. return Math.abs(pixelColor.r - seedColor.r) > this.colorTolerance || Math.abs(pixelColor.g - seedColor.g) > this.colorTolerance || Math.abs(pixelColor.b - seedColor.b) > this.colorTolerance; }, If anyone can see a way to improve performance of this code, I would appreciate it. The basic idea is: 1) Seed color is the initial color at the point to start flooding. 2) Try four adjacent points: up, right, down and left one pixel. 3) If point is out of range or has been visited already, skip it. 4) Otherwise push point onto to the stack of interesting points. 5) Pop the next interesting point off the stack. 6) If the color at that point is a stop color (as defined in the stopFunction) then stop processing that point and skip to step 5. 7) Otherwise, skip to step 2. 8) When there are no more interesting points to visit, stop looping. Remembering that a point has been visited requires an array with the same number of elements as there are pixels.
To create a flood fill you need to be able to look at the pixels that are there already and check they aren't the color you started with so something like this. const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, [255, 0, 0, 255]); function getPixel(imageData, x, y) { if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) { return [-1, -1, -1, -1]; // impossible color } else { const offset = (y * imageData.width + x) * 4; return imageData.data.slice(offset, offset + 4); } } function setPixel(imageData, x, y, color) { const offset = (y * imageData.width + x) * 4; imageData.data[offset + 0] = color[0]; imageData.data[offset + 1] = color[1]; imageData.data[offset + 2] = color[2]; imageData.data[offset + 3] = color[0]; } function colorsMatch(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } function floodFill(ctx, x, y, fillColor) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // get the color we're filling const targetColor = getPixel(imageData, x, y); // check we are actually filling a different color if (!colorsMatch(targetColor, fillColor)) { fillPixel(imageData, x, y, targetColor, fillColor); // put the data back ctx.putImageData(imageData, 0, 0); } } function fillPixel(imageData, x, y, targetColor, fillColor) { const currentColor = getPixel(imageData, x, y); if (colorsMatch(currentColor, targetColor)) { setPixel(imageData, x, y, fillColor); fillPixel(imageData, x + 1, y, targetColor, fillColor); fillPixel(imageData, x - 1, y, targetColor, fillColor); fillPixel(imageData, x, y + 1, targetColor, fillColor); fillPixel(imageData, x, y - 1, targetColor, fillColor); } } <canvas></canvas> There's at least 2 problems with this code though. It's deeply recursive. So you might run out of stack space It's slow. No idea if it's too slow but JavaScript in the browser is mostly single threaded so while this code is running the browser is frozen. For a large canvas that frozen time might make the page really slow and if it's frozen too long the browser will ask if the user wants to kill the page. The solution to running out of stack space is to implement our own stack. For example instead of recursively calling fillPixel we could keep an array of positions we want to look at. We'd add the 4 positions to that array and then pop things off the array until it's empty const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, [255, 0, 0, 255]); function getPixel(imageData, x, y) { if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) { return [-1, -1, -1, -1]; // impossible color } else { const offset = (y * imageData.width + x) * 4; return imageData.data.slice(offset, offset + 4); } } function setPixel(imageData, x, y, color) { const offset = (y * imageData.width + x) * 4; imageData.data[offset + 0] = color[0]; imageData.data[offset + 1] = color[1]; imageData.data[offset + 2] = color[2]; imageData.data[offset + 3] = color[0]; } function colorsMatch(a, b) { return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } function floodFill(ctx, x, y, fillColor) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // get the color we're filling const targetColor = getPixel(imageData, x, y); // check we are actually filling a different color if (!colorsMatch(targetColor, fillColor)) { const pixelsToCheck = [x, y]; while (pixelsToCheck.length > 0) { const y = pixelsToCheck.pop(); const x = pixelsToCheck.pop(); const currentColor = getPixel(imageData, x, y); if (colorsMatch(currentColor, targetColor)) { setPixel(imageData, x, y, fillColor); pixelsToCheck.push(x + 1, y); pixelsToCheck.push(x - 1, y); pixelsToCheck.push(x, y + 1); pixelsToCheck.push(x, y - 1); } } // put the data back ctx.putImageData(imageData, 0, 0); } } <canvas></canvas> The solution to it being too slow is either to make it run a little at a time OR to move it to a worker. I think that's a little too much to show in the same answer though here's an example. I tested the code above on a 4096x4096 canvas and it took 16 seconds to fill a blank canvas on my machine so yes it's arguably too slow but putting it in a worker brings new problems which is that the result will be asynchronous so even though the browser wouldn't freeze you'd probably want to prevent the user from doing something until it finishes. Another issue is you'll see the lines are antialiased and so filling with a solid color fills close the the line but not all the way up to it. To fix that you can change colorsMatch to check for close enough but then you have a new problem that if targetColor and fillColor are also close enough it will keep trying to fill itself. You could solve that by making another array, one byte or one bit per pixel to track places you've ready checked. const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, [255, 0, 0, 255], 128); function getPixel(imageData, x, y) { if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) { return [-1, -1, -1, -1]; // impossible color } else { const offset = (y * imageData.width + x) * 4; return imageData.data.slice(offset, offset + 4); } } function setPixel(imageData, x, y, color) { const offset = (y * imageData.width + x) * 4; imageData.data[offset + 0] = color[0]; imageData.data[offset + 1] = color[1]; imageData.data[offset + 2] = color[2]; imageData.data[offset + 3] = color[0]; } function colorsMatch(a, b, rangeSq) { const dr = a[0] - b[0]; const dg = a[1] - b[1]; const db = a[2] - b[2]; const da = a[3] - b[3]; return dr * dr + dg * dg + db * db + da * da < rangeSq; } function floodFill(ctx, x, y, fillColor, range = 1) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // flags for if we visited a pixel already const visited = new Uint8Array(imageData.width, imageData.height); // get the color we're filling const targetColor = getPixel(imageData, x, y); // check we are actually filling a different color if (!colorsMatch(targetColor, fillColor)) { const rangeSq = range * range; const pixelsToCheck = [x, y]; while (pixelsToCheck.length > 0) { const y = pixelsToCheck.pop(); const x = pixelsToCheck.pop(); const currentColor = getPixel(imageData, x, y); if (!visited[y * imageData.width + x] && colorsMatch(currentColor, targetColor, rangeSq)) { setPixel(imageData, x, y, fillColor); visited[y * imageData.width + x] = 1; // mark we were here already pixelsToCheck.push(x + 1, y); pixelsToCheck.push(x - 1, y); pixelsToCheck.push(x, y + 1); pixelsToCheck.push(x, y - 1); } } // put the data back ctx.putImageData(imageData, 0, 0); } } <canvas></canvas> Note that this version of colorsMatch is kind of naive. It might be better to convert to HSV or something or maybe you want to weight by alpha. I don't know what a good metric is for matching colors. Update Another way to speed things up is of course to just optimize the code. Kaiido pointed out an obvious speedup which is to use a Uint32Array view on the pixels. That way looking up a pixel and setting a pixel there's just one 32bit value to read or write. Just that change makes it about 4x faster. It still takes 4 seconds to fill a 4096x4096 canvas though. There might be other optimizations like instead of calling getPixels make that inline but don't push a new pixel on our list of pixels to check if they are out of range. It might be 10% speed up (no idea) but won't make it fast enough to be an interactive speed. There are other speedups like checking across a row at a time since rows are cache friendly and you can compute the offset to a row once and use that while checking the entire row whereas now for every pixel we have to compute the offset multiple times. Those will complicate the algorithm so they are best left for you to figure out. Let me also add, given the answer above freezes the browser while the fill is happening and that on a larger canvas that freeze could be too long, you can easily make the algorithm span over time using ES6 async/await. You need to choose how much work to give each segment of time. Choose too small and it will take a long time to fill. Choose too large and you'll get jank as the browser freezes. Here's an example. Set ticksPerUpdate to speed up or slow down the fill rate const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(100, 145); ctx.lineTo(110, 105); ctx.lineTo(130, 125); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, 0xFF0000FF); function getPixel(pixelData, x, y) { if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) { return -1; // impossible color } else { return pixelData.data[y * pixelData.width + x]; } } async function floodFill(ctx, x, y, fillColor) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // make a Uint32Array view on the pixels so we can manipulate pixels // one 32bit value at a time instead of as 4 bytes per pixel const pixelData = { width: imageData.width, height: imageData.height, data: new Uint32Array(imageData.data.buffer), }; // get the color we're filling const targetColor = getPixel(pixelData, x, y); // check we are actually filling a different color if (targetColor !== fillColor) { const ticksPerUpdate = 50; let tickCount = 0; const pixelsToCheck = [x, y]; while (pixelsToCheck.length > 0) { const y = pixelsToCheck.pop(); const x = pixelsToCheck.pop(); const currentColor = getPixel(pixelData, x, y); if (currentColor === targetColor) { pixelData.data[y * pixelData.width + x] = fillColor; // put the data back ctx.putImageData(imageData, 0, 0); ++tickCount; if (tickCount % ticksPerUpdate === 0) { await wait(); } pixelsToCheck.push(x + 1, y); pixelsToCheck.push(x - 1, y); pixelsToCheck.push(x, y + 1); pixelsToCheck.push(x, y - 1); } } } } function wait(delay = 0) { return new Promise((resolve) => { setTimeout(resolve, delay); }); } <canvas></canvas> update update Instead of setTimeout which is throttled by the browser, you can abuse postMessage which is not function makeExposedPromise() { let resolve; let reject; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); return { promise, resolve, reject }; } const resolveFns = []; window.addEventListener('message', (e) => { const resolve = resolveFns.shift(); resolve(); }); function wait() { const {resolve, promise} = makeExposedPromise(); resolveFns.push(resolve); window.postMessage(''); return promise; } If you use that it there's less need to choose a number of operations. Also note: the slowest part is calling putImageData. The reason it's inside the loop above is only so we can see the progress. Move that to the end and it will run much faster function makeExposedPromise() { let resolve; let reject; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); return { promise, resolve, reject }; } const resolveFns = []; window.addEventListener('message', (e) => { const resolve = resolveFns.shift(); resolve(); }); function wait() { const {resolve, promise} = makeExposedPromise(); resolveFns.push(resolve); window.postMessage(''); return promise; } const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(100, 145); ctx.lineTo(110, 105); ctx.lineTo(130, 125); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, 0xFF0000FF); function getPixel(pixelData, x, y) { if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) { return -1; // impossible color } else { return pixelData.data[y * pixelData.width + x]; } } async function floodFill(ctx, x, y, fillColor) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // make a Uint32Array view on the pixels so we can manipulate pixels // one 32bit value at a time instead of as 4 bytes per pixel const pixelData = { width: imageData.width, height: imageData.height, data: new Uint32Array(imageData.data.buffer), }; // get the color we're filling const targetColor = getPixel(pixelData, x, y); // check we are actually filling a different color if (targetColor !== fillColor) { const pixelsToCheck = [x, y]; while (pixelsToCheck.length > 0) { const y = pixelsToCheck.pop(); const x = pixelsToCheck.pop(); const currentColor = getPixel(pixelData, x, y); if (currentColor === targetColor) { pixelData.data[y * pixelData.width + x] = fillColor; await wait(); pixelsToCheck.push(x + 1, y); pixelsToCheck.push(x - 1, y); pixelsToCheck.push(x, y + 1); pixelsToCheck.push(x, y - 1); } } // put the data back ctx.putImageData(imageData, 0, 0); } } <canvas></canvas> It's still better to choose a number of operations per call to wait There are also faster algorithms. The issue with the one above is there is for every pixel that matches, 4 are added to the stack of things to pixels to check. That's lots of allocations and multiple checking. A faster way is to it by span. For a given span, check as far left as you can, then as far right as you can, now fill that span. Then, check the pixels above and/or below the span you just found and add the spans you find to your stack. Pop the top span off, and try to expand it left and right. There's no need to check the pixels in the middle since you already checked them. Further, if this span was generated from one below then you don't need to check the pixels below the starting sub-span of this span since you know that area was already filled. Similarly if this pan was generated from one above then you don't need to check the pixels above the starting sub-span of this span for the same reason. function main() { const ctx = document.querySelector("canvas").getContext("2d"); ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(250, 70); ctx.lineTo(270, 120); ctx.lineTo(170, 140); ctx.lineTo(100, 145); ctx.lineTo(110, 105); ctx.lineTo(130, 125); ctx.lineTo(190, 80); ctx.lineTo(100, 60); ctx.lineTo(50, 130); ctx.lineTo(20, 20); ctx.stroke(); floodFill(ctx, 40, 50, 0xFF0000FF); } main(); function getPixel(pixelData, x, y) { if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) { return -1; // impossible color } else { return pixelData.data[y * pixelData.width + x]; } } function floodFill(ctx, x, y, fillColor) { // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); // make a Uint32Array view on the pixels so we can manipulate pixels // one 32bit value at a time instead of as 4 bytes per pixel const pixelData = { width: imageData.width, height: imageData.height, data: new Uint32Array(imageData.data.buffer), }; // get the color we're filling const targetColor = getPixel(pixelData, x, y); // check we are actually filling a different color if (targetColor !== fillColor) { const spansToCheck = []; function addSpan(left, right, y, direction) { spansToCheck.push({left, right, y, direction}); } function checkSpan(left, right, y, direction) { let inSpan = false; let start; let x; for (x = left; x < right; ++x) { const color = getPixel(pixelData, x, y); if (color === targetColor) { if (!inSpan) { inSpan = true; start = x; } } else { if (inSpan) { inSpan = false; addSpan(start, x - 1, y, direction); } } } if (inSpan) { inSpan = false; addSpan(start, x - 1, y, direction); } } addSpan(x, x, y, 0); while (spansToCheck.length > 0) { const {left, right, y, direction} = spansToCheck.pop(); // do left until we hit something, while we do this check above and below and add let l = left; for (;;) { --l; const color = getPixel(pixelData, l, y); if (color !== targetColor) { break; } } ++l let r = right; for (;;) { ++r; const color = getPixel(pixelData, r, y); if (color !== targetColor) { break; } } const lineOffset = y * pixelData.width; pixelData.data.fill(fillColor, lineOffset + l, lineOffset + r); if (direction <= 0) { checkSpan(l, r, y - 1, -1); } else { checkSpan(l, left, y - 1, -1); checkSpan(right, r, y - 1, -1); } if (direction >= 0) { checkSpan(l, r, y + 1, +1); } else { checkSpan(l, left, y + 1, +1); checkSpan(right, r, y + 1, +1); } } // put the data back ctx.putImageData(imageData, 0, 0); } } <canvas></canvas> Note: I didn't test this well, there might be an off by 1 error or other issue. I'm 99% sure I wrote the span method in 1993 for My Paint but don't remember if I have the source. But in any case, it's fast enough there's no need for wait
Here's an implementation that I've been working on. It can get really slow if the replacement color is too close to the original color. It's quite a bit faster in Chrome than Firefox (I haven't tested it in any other browsers). I also haven't done exhaustive testing yet, so there may be edge cases where it doesn't work. function getPixel(pixelData, x, y) { if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) { return NaN; } var pixels = pixelData.data; var i = (y * pixelData.width + x) * 4; return ((pixels[i + 0] & 0xFF) << 24) | ((pixels[i + 1] & 0xFF) << 16) | ((pixels[i + 2] & 0xFF) << 8) | ((pixels[i + 3] & 0xFF) << 0); } function setPixel(pixelData, x, y, color) { var i = (y * pixelData.width + x) * 4; var pixels = pixelData.data; pixels[i + 0] = (color >>> 24) & 0xFF; pixels[i + 1] = (color >>> 16) & 0xFF; pixels[i + 2] = (color >>> 8) & 0xFF; pixels[i + 3] = (color >>> 0) & 0xFF; } function diff(c1, c2) { if (isNaN(c1) || isNaN(c2)) { return Infinity; } var dr = ((c1 >>> 24) & 0xFF) - ((c2 >>> 24) & 0xFF); var dg = ((c1 >>> 16) & 0xFF) - ((c2 >>> 16) & 0xFF); var db = ((c1 >>> 8) & 0xFF) - ((c2 >>> 8) & 0xFF); var da = ((c1 >>> 0) & 0xFF) - ((c2 >>> 0) & 0xFF); return dr*dr + dg*dg + db*db + da*da; } function floodFill(canvas, x, y, replacementColor, delta) { var current, w, e, stack, color, cx, cy; var context = canvas.getContext("2d"); var pixelData = context.getImageData(0, 0, canvas.width, canvas.height); var done = []; for (var i = 0; i < canvas.width; i++) { done[i] = []; } var targetColor = getPixel(pixelData, x, y); delta *= delta; stack = [ [x, y] ]; done[x][y] = true; while ((current = stack.pop())) { cx = current[0]; cy = current[1]; if (diff(getPixel(pixelData, cx, cy), targetColor) <= delta) { setPixel(pixelData, cx, cy, replacementColor); w = e = cx; while (w > 0 && diff(getPixel(pixelData, w - 1, cy), targetColor) <= delta) { --w; if (done[w][cy]) break; setPixel(pixelData, w, cy, replacementColor); } while (e < pixelData.width - 1 && diff(getPixel(pixelData, e + 1, cy), targetColor) <= delta) { ++e; if (done[e][cy]) break; setPixel(pixelData, e, cy, replacementColor); } for (cx = w; cx <= e; cx++) { if (cy > 0) { color = getPixel(pixelData, cx, cy - 1); if (diff(color, targetColor) <= delta) { if (!done[cx][cy - 1]) { stack.push([cx, cy - 1]); done[cx][cy - 1] = true; } } } if (cy < canvas.height - 1) { color = getPixel(pixelData, cx, cy + 1); if (diff(color, targetColor) <= delta) { if (!done[cx][cy + 1]) { stack.push([cx, cy + 1]); done[cx][cy + 1] = true; } } } } } } context.putImageData(pixelData, 0, 0, 0, 0, canvas.width, canvas.height); }
I would not treat the canvas as a bitmap image. Instead I would keep a collection of painting-objects and modify that collection. Then for example you can fill a path or shape or add a new shape that has the boundaries of the objects you are trying to fill. I can't see how "normal" floodFill makes sense in vector drawing..
How to draw arc on canvas HTML5 without interpolation?
I am using the following code to draw on HTML5 canvas: const context = canvas.getContext('2d'); context.beginPath(); context.arc(x, y, radius, 0, 2 * Math.PI, false); context.fillStyle = color; context.fill(); context.closePath(); However, if I print unique values: console.log(new Set(context.getImageData(0, 0, canvas.width, canvas.height).data)) I can see that the color that I use in fillStyle gets interpolated. I tried to disable interpolation/smoothing by adding the following flags: context.imageSmoothingEnabled = false; context.webkitImageSmoothingEnabled = false; context.mozImageSmoothingEnabled = false; However, it does not help. I would highly appreciate if you could advise me how to fix the issue.
The is no native way to draw circles that are pixelated. To do that you must render each pixel manually. There are several methods you can use to do this. The most common have some additional artifacts (like inconsistent line width) that are hard to avoid. The following function draw a circle using a modification of the Berzingham line algorithm (also good for rendering pixelated lines) called the Midpoint circle algorithm Unfortunately most of the methods that can draw arbitrary lines and circle are slow. The two mentioned above are the fastest standard methods I know about. Example The example defines 3 functions to draw pixelated circles pixelPixelatedCircle (Red outer circles and single blue in example) draws a single pixel wide circle using the current fill style fillPixelatedCircle (Red inner circle in example) draws a a solid circle using the current fill style strokePixelatedCircle (Black circles in example) draws a circle line with a width. Not the width only works when it is >= 2. If you want a single pixel width use the first function. Also not that this function uses a second canvas to render the circle The example draws all three types The outer red circle drawn using pixelPixelatedCircle are to demonstrate that the quality of the circles are consistent. There should be alternating 1 pixel width circles, red and dark red. and an outer blue just touching the canvas edge circles. For circles less than radius of 2 use ctx.rect as the outcome will be the same. Note the circle radius is an integer thus a circle radius 1000 will be identical to circle radius 1000.9 The sample applies to the circle center. To be able to have sub pixel positioning and radius will need another algorithm which is slower and has lower quality lines. Note I added a simple zoom canvas so I could see the results better, I was going to remove it but left it in just for interested people. It is not crucial to the answer. const ctx = canvas.getContext("2d"); const w = canvas.width; const h = canvas.height; const size = Math.min(w, h); const circleWorkCanvas = document.createElement("canvas"); const cCtx = circleWorkCanvas.getContext("2d"); function resizeCircleCanvas(ctx) { if (circleWorkCanvas.width !== ctx.canvas.width || circleWorkCanvas.height !== ctx.canvas.height) { circleWorkCanvas.width = ctx.canvas.width; circleWorkCanvas.height = ctx.canvas.height; } } strokePixelatedCircle(ctx, w / 2 | 0, h / 2 | 0, size * 0.35, 5); strokePixelatedCircle(ctx, w / 2 | 0, h / 2 | 0, size * 0.3, 4); strokePixelatedCircle(ctx, w / 2 | 0, h / 2 | 0, size * 0.25, 3); strokePixelatedCircle(ctx, w / 2 | 0, h / 2 | 0, size * 0.2, 2); ctx.fillStyle = "red"; fillPixelatedCircle(ctx, w / 2, h / 2, size * 0.15); ctx.fillStyle = "blue"; pixelPixelatedCircle(ctx, w / 2, h / 2, size * 0.38); ctx.fillStyle = "blue"; pixelPixelatedCircle(ctx, w / 2, h / 2, size * 0.5); ctx.fillStyle = "red"; for(let v = 0.40; v < 0.49; v += 1 / size) { ctx.fillStyle = "#600" pixelPixelatedCircle(ctx, w / 2, h / 2, size * v); ctx.fillStyle = "#F00" v += 1 / size; pixelPixelatedCircle(ctx, w / 2, h / 2, size * v ); } function strokePixelatedCircle(ctx, cx, cy, r, lineWidth) { resizeCircleCanvas(ctx); cCtx.clearRect(0, 0, cCtx.canvas.width, cCtx.canvas.height); cCtx.globalCompositeOperation = "source-over"; cCtx.fillStyle = ctx.strokeStyle; fillPixelatedCircle(cCtx, cx, cy, r + lineWidth / 2); cCtx.globalCompositeOperation = "destination-out"; fillPixelatedCircle(cCtx, cx, cy, r - lineWidth / 2); cCtx.globalCompositeOperation = "source-over"; ctx.drawImage(cCtx.canvas, 0, 0); } function fillPixelatedCircle(ctx, cx, cy, r){ r |= 0; // floor radius ctx.setTransform(1,0,0,1,0,0); // ensure default transform var x = r, y = 0, dx = 1, dy = 1; var err = dx - (r << 1); var x0 = cx - 1| 0, y0 = cy | 0; var lx = x,ly = y; ctx.beginPath(); while (x >= y) { ctx.rect(x0 - x, y0 + y, x * 2 + 2, 1); ctx.rect(x0 - x, y0 - y, x * 2 + 2, 1); if (x !== lx){ ctx.rect(x0 - ly, y0 - lx, ly * 2 + 2, 1); ctx.rect(x0 - ly, y0 + lx, ly * 2 + 2, 1); } lx = x; ly = y; y++; err += dy; dy += 2; if (err > 0) { x--; dx += 2; err += (-r << 1) + dx; } } if (x !== lx) { ctx.rect(x0 - ly, y0 - lx, ly * 2 + 1, 1); ctx.rect(x0 - ly, y0 + lx, ly * 2 + 1, 1); } ctx.fill(); } function pixelPixelatedCircle(ctx, cx, cy, r){ r |= 0; ctx.setTransform(1,0,0,1,0,0); // ensure default transform var x = r, y = 0, dx = 1, dy = 1; var err = dx - (r << 1); var x0 = cx | 0, y0 = cy | 0; var lx = x,ly = y; var w = 1, px = x0; ctx.beginPath(); var rendering = 2; while (rendering) { const yy = y0 - y; const yy1 = y0 + y - 1; const xx = x0 - x; const xx1 = x0 + x - 1; ctx.rect(xx, yy1, 1, 1); ctx.rect(xx, yy, 1, 1); ctx.rect(xx1, yy1, 1, 1); ctx.rect(xx1, yy, 1, 1); if (x !== lx){ const yy = y0 - lx; const yy1 = y0 + lx - 1; const xx = x0 - ly; w = px - xx; const xx1 = x0 + ly - w; ctx.rect(xx, yy, w, 1); ctx.rect(xx, yy1, w, 1); ctx.rect(xx1, yy, w, 1); ctx.rect(xx1, yy1, w, 1); px = xx; } lx = x; ly = y; y++; err += dy; dy += 2; if (err > 0) { x--; dx += 2; err += (-r << 1) + dx; } if (x < y) { rendering -- } } ctx.fill(); } const ctxZ = canvasZoom.getContext("2d"); canvas.addEventListener("mousemove",(event) => { ctxZ.clearRect(0,0,30,30); ctxZ.drawImage(canvas, -(event.pageX-10), -(event.pageY-10)); }); canvas {border: 1px solid black} #canvasZoom { width: 300px; height: 300px; image-rendering: pixelated; } <canvas id="canvas" width="300" height="300"></canvas> <canvas id="canvasZoom" width="30" height="30"></canvas>
There doesn't appear to be a built-in setting that I can find, but you can loop through the image data and set the individual pixels if they are within some threshold of what you want. const canvas = document.getElementById('canvas'); const context = canvas.getContext('2d'); context.beginPath(); context.arc(250, 250, 250, 0, 2 * Math.PI, false); context.fillStyle = 'rgb(255, 0, 0)'; context.fill(); context.closePath(); console.log(getDistinctColors(context).length + " distinct colors before filter"); solidifyColor(context, 255, 0, 0); console.log(getDistinctColors(context).length + " distinct colors aftrer filter"); function solidifyColor(context, r, g, b, threshold = 3) { const imageData = context.getImageData(0, 0, context.canvas.width, context.canvas.height); for (let i = 0; i < imageData.data.length; i += 4) { var rDif = Math.abs(imageData.data[i + 0] - r); var bDif = Math.abs(imageData.data[i + 1] - b); var gDif = Math.abs(imageData.data[i + 2] - g); if (rDif <= threshold && bDif <= threshold && gDif <= threshold) { imageData.data[i + 0] = r; imageData.data[i + 1] = g; imageData.data[i + 2] = b; imageData.data[i + 3] = 255; // remove alpha } } context.putImageData(imageData, 0, 0); } function getDistinctColors(context) { var colors = []; const imageData = context.getImageData(0, 0, context.canvas.width, context.canvas.height); for (let i = 0; i < imageData.data.length; i += 4) { colors.push([ imageData.data[i + 0], // R value imageData.data[i + 1], // G value imageData.data[i + 2], // B value imageData.data[i + 3] // A value ]); } return [...new Set(colors.map(a => JSON.stringify(a)))].map(a => JSON.parse(a)); } <canvas id=canvas width=500 height=500></canvas>
Strategy to optimize javascript
I have written a javascript program that uses a genetic algorithm to recreate an image only using triangles. Here's the strategy: generate a random pool of models, each model having an array of triangles (3 points and a color) evaluate the fitness of each model. To do so, I compare the original image's pixel array with my model's. I use Cosine Similarity to compare arrays keep the best models, and mate them to create new models randomly mutate some of the models evaluate the new pool and continue It works quite well after some iterations as you can see here: The problem I have, is that it is very slow, most of the time is spent getting model's pixels (converting list of triangles (color + points) to a pixel array). Here's how I do so now: My pixel-array is a 1D array, I need to be able to convert x,y coordinates to index: static getIndex(x, y, width) { return 4 * (width * y + x); } Then I am able to draw a point: static plot(x, y, color, img) { let idx = this.getIndex(x, y, img.width); let added = [color.r, color.g, color.b, map(color.a, 0, 255, 0, 1)]; let base = [img.pixels[idx], img.pixels[idx + 1], img.pixels[idx + 2], map(img.pixels[idx + 3], 0, 255, 0, 1)]; let a01 = 1 - (1 - added[3]) * (1 - base[3]); img.pixels[idx + 0] = Math.round((added[0] * added[3] / a01) + (base[0] * base[3] * (1 - added[3]) / a01)); // red img.pixels[idx + 1] = Math.round((added[1] * added[3] / a01) + (base[1] * base[3] * (1 - added[3]) / a01)); // green img.pixels[idx + 2] = Math.round((added[2] * added[3] / a01) + (base[2] * base[3] * (1 - added[3]) / a01)); // blue img.pixels[idx + 3] = Math.round(map(a01, 0, 1, 0, 255)); } Then a line: static line(x0, y0, x1, y1, img, color) { x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1); let dx = Math.abs(x1 - x0); let dy = Math.abs(y1 - y0); let sx = x0 < x1 ? 1 : -1; let sy = y0 < y1 ? 1 : -1; let err = dx - dy; do { this.plot(x0, y0, color, img); let e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } while (x0 != x1 || y0 != y1); } And finally, a triangle: static drawTriangle(triangle, img) { for (let i = 0; i < triangle.points.length; i++) { let point = triangle.points[i]; let p1 = i === triangle.points.length - 1 ? triangle.points[0] : triangle.points[i + 1]; this.line(point.x, point.y, p1.x, p1.y, img, triangle.color); } this.fillTriangle(triangle, img); } static fillTriangle(triangle, img) { let vertices = Array.from(triangle.points); vertices.sort((a, b) => a.y > b.y); if (vertices[1].y == vertices[2].y) { this.fillBottomFlatTriangle(vertices[0], vertices[1], vertices[2], img, triangle.color); } else if (vertices[0].y == vertices[1].y) { this.fillTopFlatTriangle(vertices[0], vertices[1], vertices[2], img, triangle.color); } else { let v4 = { x: vertices[0].x + float(vertices[1].y - vertices[0].y) / float(vertices[2].y - vertices[0].y) * (vertices[2].x - vertices[0].x), y: vertices[1].y }; this.fillBottomFlatTriangle(vertices[0], vertices[1], v4, img, triangle.color); this.fillTopFlatTriangle(vertices[1], v4, vertices[2], img, triangle.color); } } static fillBottomFlatTriangle(v1, v2, v3, img, color) { let invslope1 = (v2.x - v1.x) / (v2.y - v1.y); let invslope2 = (v3.x - v1.x) / (v3.y - v1.y); let curx1 = v1.x; let curx2 = v1.x; for (let scanlineY = v1.y; scanlineY <= v2.y; scanlineY++) { this.line(curx1, scanlineY, curx2, scanlineY, img, color); curx1 += invslope1; curx2 += invslope2; } } static fillTopFlatTriangle(v1, v2, v3, img, color) { let invslope1 = (v3.x - v1.x) / (v3.y - v1.y); let invslope2 = (v3.x - v2.x) / (v3.y - v2.y); let curx1 = v3.x; let curx2 = v3.x; for (let scanlineY = v3.y; scanlineY > v1.y; scanlineY--) { this.line(curx1, scanlineY, curx2, scanlineY, img, color); curx1 -= invslope1; curx2 -= invslope2; } } You can see full code in action here So, I would like to know: is it possible to optimize this code ? if yes, what would be the best way to do so ? Maybe there is a library doing all of the drawing stuff way better than I did ? Or by using workers ? Thanks !
I have tested your suggestions, here's the results: Use RMS instead of Cosine Similarity: I am not sur if the measure of similarity is better, but it is definitively not worse. It seems to run a little bit faster too. Use UInt8Array: It surely have an impact, but does not runs a lot faster. Not slower though. Draw to invisible canvas: Definitively faster and easier! I can remove all of my drawing functions and replace it with a few lines of code, and it runs a lot faster ! Here's the code to draw to an invisible canvas: var canvas = document.createElement('canvas'); canvas.id = "CursorLayer"; canvas.width = this.width; canvas.height = this.height; canvas.display = "none"; var body = document.getElementsByTagName("body")[0]; body.appendChild(canvas); var ctx = canvas.getContext("2d"); ctx.fillStyle = "rgba(0, 0, 0, 1)"; ctx.fillRect(0, 0, this.width, this.height); for (let i = 0; i < this.items.length; i++) { let item = this.items[i]; ctx.fillStyle = "rgba(" +item.color.r + ','+item.color.g+','+item.color.b+','+map(item.color.a, 0, 255, 0, 1)+")"; ctx.beginPath(); ctx.moveTo(item.points[0].x, item.points[0].y); ctx.lineTo(item.points[1].x, item.points[1].y); ctx.lineTo(item.points[2].x, item.points[2].y); ctx.fill(); } let pixels = ctx.getImageData(0, 0, this.width, this.height).data; //delete canvas body.removeChild(canvas); return pixels; Before those changements, my code were running at about 1.68 iterations per second. Now it runs at about 16.45 iterations per second ! See full code here. Thanks again !
Canvas flood fill stroke color
I'm building a small application using canvas. My application will have an option to fill a black and white image. I downloaded a code and is working fine, but It only works when the image stroke is black. All images that I am going to use have grey stroke. So, I would like to know what do I need to change to put the code working with grey strokes, instead of black strokes. Here the code: https://jsfiddle.net/mx0fmdh3/ HTML: <canvas id="canvas" width=250 height=243></canvas> JavaScript var canvas = document.getElementById("canvas"); var context = canvas.getContext("2d"); var $canvas = $("#canvas"); var canvasOffset = $canvas.offset(); var offsetX = canvasOffset.left; var offsetY = canvasOffset.top; var canvasWidth = canvas.width; var canvasHeight = canvas.height; var strokeColor = { r: 152, g: 152, b: 152 }; var fillColor = { r: 101, g: 155, b: 65 }; var fillData; var strokeData; // load image var img = new Image(); img.onload = function () { start(); } img.crossOrigin = "anonymous"; img.src = "http://i.imgur.com/kjY1kiE.png"; function matchstrokeColor(r, g, b, a) { // never recolor the initial black divider strokes // must check for near black because of anti-aliasing return (r + g + b < 100 && a === 155); } function matchStartColor(pixelPos, startR, startG, startB) { // get the color to be matched var r = strokeData.data[pixelPos], g = strokeData.data[pixelPos + 1], b = strokeData.data[pixelPos + 2], a = strokeData.data[pixelPos + 3]; // If current pixel of the outline image is black-ish if (matchstrokeColor(r, g, b, a)) { return false; } // get the potential replacement color r = fillData.data[pixelPos]; g = fillData.data[pixelPos + 1]; b = fillData.data[pixelPos + 2]; // If the current pixel matches the clicked color if (r === startR && g === startG && b === startB) { return true; } // If current pixel matches the new color if (r === fillColor.r && g === fillColor.g && b === fillColor.b) { return false; } return true; } // Thank you William Malone! function floodFill(startX, startY, startR, startG, startB) { var newPos; var x; var y; var pixelPos; var neighborLeft; var neighborRight; var pixelStack = [ [startX, startY] ]; while (pixelStack.length) { newPos = pixelStack.pop(); x = newPos[0]; y = newPos[1]; // Get current pixel position pixelPos = (y * canvasWidth + x) * 4; // Go up as long as the color matches and are inside the canvas while (y >= 0 && matchStartColor(pixelPos, startR, startG, startB)) { y -= 1; pixelPos -= canvasWidth * 4; } pixelPos += canvasWidth * 4; y += 1; neighborLeft = false; neighborRight = false; // Go down as long as the color matches and in inside the canvas while (y <= (canvasHeight - 1) && matchStartColor(pixelPos, startR, startG, startB)) { y += 1; fillData.data[pixelPos] = fillColor.r; fillData.data[pixelPos + 1] = fillColor.g; fillData.data[pixelPos + 2] = fillColor.b; fillData.data[pixelPos + 3] = 255; if (x > 0) { if (matchStartColor(pixelPos - 4, startR, startG, startB)) { if (!neighborLeft) { // Add pixel to stack pixelStack.push([x - 1, y]); neighborLeft = true; } } else if (neighborLeft) { neighborLeft = false; } } if (x < (canvasWidth - 1)) { if (matchStartColor(pixelPos + 4, startR, startG, startB)) { if (!neighborRight) { // Add pixel to stack pixelStack.push([x + 1, y]); neighborRight = true; } } else if (neighborRight) { neighborRight = false; } } pixelPos += canvasWidth * 4; } } } // Start a floodfill // 1. Get the color under the mouseclick // 2. Replace all of that color with the new color // 3. But respect bounding areas! Replace only contiguous color. function paintAt(startX, startY) { // get the clicked pixel's [r,g,b,a] color data var pixelPos = (startY * canvasWidth + startX) * 4, r = fillData.data[pixelPos], g = fillData.data[pixelPos + 1], b = fillData.data[pixelPos + 2], a = fillData.data[pixelPos + 3]; // this pixel's already filled if (r === fillColor.r && g === fillColor.g && b === fillColor.b) { return; } // this pixel is part of the original black image--don't fill if (matchstrokeColor(r, g, b, a)) { return; } // execute the floodfill floodFill(startX, startY, r, g, b); // put the colorized data back on the canvas context.clearRect(0, 0, canvasWidth, canvasHeight); context.putImageData(fillData, 0, 0); context.drawImage(img, 0, 0); } // create a random color object {red,green,blue} function randomColorRGB() { var hex = Math.floor(Math.random() * 16777215).toString(16); //var r = parseInt(hex.substring(0, 2), 16); var r = 155; var g = 155; var b = 255; //var g = parseInt(hex.substring(2, 4), 16); //var b = parseInt(hex.substring(4, 6), 16); return ({ r: r, g: g, b: b }); } // draw the image to the canvas and get its pixel array // listen for mouse clicks and do floodfill when clicked function start() { context.drawImage(img, 0, 0); strokeData = context.getImageData(0, 0, canvasWidth, canvasHeight); context.clearRect(0, 0, context.canvas.width, context.canvas.height); fillData = context.getImageData(0, 0, canvasWidth, canvasHeight); context.drawImage(img, 0, 0); $('#canvas').mousedown(function (e) { // Mouse down location var mouseX = parseInt(e.clientX - offsetX); var mouseY = parseInt(e.clientY - offsetY); // set a new random fillColor fillColor = randomColorRGB(); // floodfill paintAt(mouseX, mouseY); }); Thank you.
The match stroke function: function matchstrokeColor(r, g, b, a) { // never recolor the initial black divider strokes // must check for near black because of anti-aliasing return (r + g + b < 100 && a === 155); } is only check whether rgb is a small number and is a painted stroke, as you directly paint your image on that canvas, rgb should now become something else, and alpha is now 255(or unpredictable if your image has alpha). Try change it to something that is aware of the storke's color, like sqrt distance: // A small threshold would make it fill closer to stroke. var strokeThreshold = 1; function matchstrokeColor(r, g, b, a) { // Use sqrt difference to decide its storke or not. var diffr = r - strokeColor.r; var diffg = g - strokeColor.g; var diffb= b - strokeColor.b; var diff = Math.sqrt(diffr * diffr + diffg * diffg + diffb * diffb) / 3; return (diff < strokeThreshold); } See Example jsfiddle