A global hue slider is the wrong tool when only one part of a picture should change. The blue of an icon, the outline of a logo, or the flat background behind a character are regions. Everything else has to stay where it is, including the light and dark pixels inside the region you did touch.
This post walks through a selection that starts from one pixel, and a recolor that replaces the color without turning the area into a single flat fill. The implementation is the recolor-region package: RGBA bytes in, RGBA bytes out, no DOM and no dependencies.
What counts as the region
Start at the pixel the user clicked. That pixel is the seed. Its red, green, and blue values are the reference color.
From there, walk to neighboring pixels. A neighbor joins the selection only when all of these are true:
- It is inside the image.
- Its alpha is at least 16. Nearly transparent pixels are holes, not paint.
- Its alpha is within 96 of the seed alpha, so a soft shadow does not swallow the opaque shape next to it.
- The squared RGB distance to the seed stays inside the tolerance.
The distance test is:
const dr = pixelR - seedR;
const dg = pixelG - seedG;
const db = pixelB - seedB;
const maxDist = tolerance * tolerance * 3;
if (dr * dr + dg * dg + db * db > maxDist) {
// too far from the clicked color
}
tolerance is an integer from 0 to 255. The default is 28. Because the limit is tolerance² * 3, the cutoff is the Euclidean distance tolerance * √3. At 28 that is about 48.5, which is enough for a flat icon with anti-aliased edges and tight enough that a red logo sitting on blue does not leak into the blue.
The walk is 8-connected: each pixel considers all eight neighbors, not only the four that share an edge. Corners of a diagonal stroke stay attached. Pixels are marked seen when they enter the queue, so each pixel is visited once. The queue is a typed array with a head and a tail index, which avoids the cost of shift() on a large image.
The fringe pass
Anti-aliasing paints a one-pixel halo that is halfway between the shape and whatever is behind it. A strict tolerance often stops just inside that halo, and the recolor leaves a bright or dark rim.
After the flood fill, a second pass looks only at pixels that touch the selection and are not yet selected. Those pixels may be a little farther from the seed: the allowed distance uses tolerance + 18 instead of tolerance. Pixels that are not next to the selection are ignored, so the looser limit cannot jump across the picture and grab a similar color somewhere else.
Repainting without a flat fill
Once the mask exists, the new color is not written as a constant RGB. A logo highlight is the same hue as the shadow under it, just lighter. If every selected pixel becomes #E53935, the highlight disappears.
The package estimates the region's base brightness first. Selected pixels are grouped into buckets of 16 levels per channel (a right shift of 4). The most common bucket is averaged, and that average is the sampled color. Using the dominant bucket, rather than every selected pixel, keeps a few fringe pixels from dragging the base toward the background.
Relative luminance is the usual Rec. 709 weighting:
function lum(r, g, b) {
return r * 0.2126 + g * 0.7152 + b * 0.0722;
}
For each selected pixel:
let scale = 1;
if (keepShading) {
scale = lum(r, g, b) / Math.max(1, lum(base.r, base.g, base.b));
scale = Math.max(0.55, Math.min(1.4, scale));
}
outR = clamp(target.r * scale);
outG = clamp(target.g * scale);
outB = clamp(target.b * scale);
Alpha is copied unchanged. keepShading: false skips the scale and writes the target color as a flat fill, which is what you want for a solid plus icon and the wrong choice for a shaded mark.
The clamp from 0.55 to 1.4 is deliberate. A near-black edge pixel would otherwise map to nearly black and look unpainted. A specular pixel would blow out past the target and clip in a harsh way. The window keeps the shading readable while the hue still reads as the new color.
This is not a full hue rotation. Pixels inside the region lose their original hue and move toward one target. That is the point when the job is "make this logo red." It is the wrong model when the region itself contains several intentional hues.
Calling it
npm install recolor-region
const { recolorRegion } = require('recolor-region');
const result = recolorRegion(pixels, width, height, {
x: 12,
y: 8,
color: '#E53935',
tolerance: 28,
keepShading: true
});
pixels is any array of bytes with length width * height * 4. result.data is a new Uint8ClampedArray. The input buffer is not modified. result.mask is a Uint8Array of 0 and 1, result.selected is the count, and result.sampled is the { r, g, b, hex } base color used for the shading scale.
On a canvas:
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = recolorRegion(image.data, image.width, image.height, {
x: Math.floor(clickX),
y: Math.floor(clickY),
color: '#2563EB'
});
ctx.putImageData(new ImageData(result.data, result.width, result.height), 0, 0);
selectRegion(pixels, width, height, x, y, tolerance) returns only the mask, if you want to preview or edit the selection before painting. You can also pass that mask back in as options.mask and skip the click.
Where it breaks
Flood fill follows connectivity and color distance. It does not know what an object is.
- A JPEG sky is a gradient plus compression noise. One click will not grab the whole sky, and a high tolerance will leak into the subject.
- Two shapes of the same color that do not touch are two regions. Shift-click, or call
selectRegionagain and merge the masks yourself. - The fringe pass only repairs a thin rim. A wide soft shadow still needs a brush, or a higher tolerance plus a careful seed.
- Very large images are just more pixels. The algorithm is linear in the number of pixels, but
getImageDataon a multi-megapixel photo is the slow part. Downscale before editing if the long edge is above something like 1600 pixels.
The useful test is a flat icon and a shaded logo on a transparent background. Click the color you mean to change, leave tolerance near 28, and keep shading on. If the rim is wrong, nudge tolerance before you reach for a brush.
Top comments (0)