Messing with images

Where I modify image pixels using JS
Published on Thursday, 16 November 2023

What are we doing?

Messing with pixel colors in CSS can be tricky. I have recently been presented with a tricky challenge, which doesn't seem to have a straightforward solution, but rather, only a lot of partial answers, so I wanted to explore that a little.

In brief: Modify the colors of pixels of an <img> tag or the background-color of a div.

There are various CSS tricks that can be applied to get some of the way there:

  • mix-blend-mode, which lets you control how the background interacts with the foreground through various blending rules
  • filter which lets you apply various transformation to an image
  • mask-image which lets you mask one image using another.

In my instance, I have a grey-scale image with alpha, and I want to tint it with a dynamic color that comes in through some RGB value.

Previously, I only had to tint with 4 static colors, and that was doable through some hackery with CSS filter:

.green {
    filter: invert(58%) sepia(47%) saturate(5640%) hue-rotate(89deg) brightness(123%) contrast(125%);
}
.red {
    filter: invert(20%) sepia(90%) saturate(6471%) hue-rotate(357deg) brightness(68%) contrast(114%);
}
.yellow {
    filter: brightness(20%) saturate(100%) invert(88%) sepia(56%) saturate(1316%) hue-rotate(356deg) brightness(108%) contrast(106%);
}
.orange {
    filter: invert(67%) sepia(93%) saturate(860%) hue-rotate(357deg) brightness(99%) contrast(106%);
}

You can see how that really won't fly when I need to do it with a dynamic RGB value. How would I possibly translate #009DFF into something that fits that syntax?

mix-blend-mode couldn't do what I needed at all. filter would be impossible to make dynamic. mask-image almost did what I needed, because I could fiddle with the alpha values to apply a tint mask, however, it would not respect the alpha of the source image, and so the pixels that were supposed to be invisible would also be tinted, so I would always end up with a coloured rectangle, which is not what I wanted 😣

So as it usually goes when CSS doesn't quite provide me with what I need, I have to turn to JavaScript

So, let's do this

I made a simple function that:

  • targets and image on the page
  • creates an empty canvas
  • draws the image onto the canvas
  • Loops through every pixel and calls a callback function on the pixel

The callback lets me apply transformations per pixel. Not super flexible, but good enough for my needs. The function ended up looking like this:

function tint(selector, imageMutator) {
    // Get the image
    const imgElement = document.querySelector(selector);
    
    // Ensure the image has loaded
    imgElement.addEventListener('load', function () {
        // Create a new canvas
        const canvas = document.createElement('canvas');
        
        // Set the canvas dimensions to the image dimensions
        canvas.width = imgElement.width;
        canvas.height = imgElement.height;

        // Get the 2D canvas context
        const ctx = canvas.getContext('2d');
        
        // Draw the image onto the canvas
        ctx.drawImage(imgElement, 0, 0, imgElement.width, imgElement.height);

        // Get the image data from the canvas
        const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);

        // imgData.data is an array with [r,g,b,a,r,g,b,a,...]
        for (let i = 0; i < imgData.data.length; i += 4) {
            const r = imgData.data[i];
            const g = imgData.data[i + 1];
            const b = imgData.data[i + 2];
            const a = imgData.data[i + 3];

            // Here's where you manipulate the pixels
            const updated = imageMutator(r, g, b, a);

            imgData.data[i] = updated[0]; // red
            imgData.data[i + 1] = updated[1]; // green
            imgData.data[i + 2] = updated[2]; // blue
            imgData.data[i + 3] = updated[3]; // alpha
        }

        // Put the manipulated data back on the canvas
        ctx.putImageData(imgData, 0, 0);
        
        // Append the canvas to body (or any other element)
        imgElement.after(canvas);
        imgElement.remove();
    });
}

Let's take it for a spin. I found a simple "Sample" PNG with alpha online, and I'll be using that as a reference for pixel mutations:

First, let's invert the color of all pixels. Not a big deal; simply subtract the value from the max value.

tint("img[id='image-to-invert']", function(r, g, b, a) {
    return [255 - r, 255 - g, 255 - b, a];
});

Alright great. Now, let's try finding colors that have full alpha and color them blue.

tint("img[id='image-to-alpha-blue']", function(r, g, b, a) {
    if (a === 255) {
        return [0, 0, 255, 255];
    }
    return [r, g, b, a];
});

Easy. Now, let's find all the invisible pixels and give them a light pink color, but leave all of the other pixels along

tint("img[id='image-to-alpha-pink']", function(r, g, b, a) {
    if (a > 0) {
        return [r, g, b, a];
    }
    return [200, 0, 0, 50];
});

Alright, let's see if we can get tinting working then. We will tint all pixels that are not completely transparent. Still, if they have some non-max alpha, we will retain that alpha level.

The tintColor function below is a simple linear interpolation function (LERP), and I'll use it to apply a green tint with a magnitude of `0.35

tint("img[id='image-to-alpha-tint']", function(r, g, b, a) {
    if (a === 0) {
        return [r, g, b, a];
    }
    var tintValue = [0, 255, 0, 255];
    return tintColor([r, g, b, a], tintValue, 0.35);
});

function tintColor(originalColor, tintColor, t) {
    // Ensure t is in the range [0, 1]
    t = Math.max(0, Math.min(1, t));

    // Calculate tinted values for each channel
    const rTinted = originalColor[0] + t * (tintColor[0] - originalColor[0]);
    const gTinted = originalColor[1] + t * (tintColor[1] - originalColor[1]);
    const bTinted = originalColor[2] + t * (tintColor[2] - originalColor[2]);
    const aTinted = originalColor[3] + t * (tintColor[3] - originalColor[3]);

    // Clamp values to the valid range [0, 255]
    const clampedRTinted = Math.max(0, Math.min(255, rTinted));
    const clampedGTinted = Math.max(0, Math.min(255, gTinted));
    const clampedBTinted = Math.max(0, Math.min(255, bTinted));
    const clampedATinted = Math.max(0, Math.min(255, aTinted));

    // Return the tinted color as an array
    return [
        Math.round(clampedRTinted),
        Math.round(clampedGTinted),
        Math.round(clampedBTinted),
        Math.round(clampedATinted)
    ];
}

How pretty is that? 🥳

In conclusion

I would love to be able to just use CSS for this kind of thing, but as far as I've been able to gather, it is not possible.

For the time being, JavaScript seems to do what I need, and it provides a flexible entrypoint to do more interesting things with images.



Blog Logo

Hi! thanks for dropping in to pick my brain

I write on this blog for my own benefit. I write because I like to, and because it helps me process topics. It is also my own little home on the web and a place for me to experiment.

Since you're here though, I'd love to hear your thoughts on what you've read here, so please leave a comment below. Also, if you like what you read and want to give a small tip, fell free to:

Buy Me A Coffee