Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save rsalgado/8888b46edaafdf0755a168421cb68650 to your computer and use it in GitHub Desktop.
Save rsalgado/8888b46edaafdf0755a168421cb68650 to your computer and use it in GitHub Desktop.
Floyd-Steinberg Dithering Example (Color)
<h1 class="title">Color Image Dithering Example</h1>
<div id="description">
<p>This is an example implementation of dithering, using the Floyd-Steinberg algorithm. The original image is taken from Wikipedia and dithered in the output canvas.</p>
<p>The dithering depth can be adjusted by modifying the values of the <span class="code">palette</span> constant in the JS code. I am currently using 6 values; they will be used for each channel (red, green and blue), leading to a total of 6*6*6=216 possible colors, in this particular case. The class <span class="code">CanvasImageBuffer</span> provides some convenience code for manipulating image pixels and rendering the resulting image into a canvas. The image is downscaled by half to make it easier to visualize, but feel free to remove that downscaling or using a different image, by modifying the original image's source.</p>
<p>For understanding and implementing the algorithm, I took as reference the <a href="https://en.wikipedia.org/wiki/Floyd%E2%80%93Steinberg_dithering">Wikipedia entry</a>, as well as <a href="https://blog.ivank.net/floyd-steinberg-dithering-in-javascript.html">this blog post</a> by Ivan Kustkir and a bit of <a href="https://www.youtube.com/watch?v=0L2n8Tg2FwI">this Youtube video</a>. This pen was copied from the grayscale dithering one, and I took the chance to rewrite and refactor some parts a bit while generalizing the idea to color images.</p>
</div>
<div class="sections">
<div class="section">
<h2>Original Image</h2>
<canvas id="input-canvas"></canvas>
</div>
<div class="section">
<h2>Dithered Image (Floyd-Steinberg Algorithm)</h2>
<canvas id="output-canvas"></canvas>
</div>
</div>
// Utility class for manipulating images and image data more easily in the canvas
class CanvasImageBuffer {
constructor(width, height) {
this.width = width;
this.height = height;
this.arrayData = new Uint8ClampedArray(width * height * 4);
}
#setArrayData(arrayData) {
this.arrayData = arrayData;
}
static fromImageData(imageData) {
const result = new CanvasImageBuffer(imageData.width, imageData.height);
result.#setArrayData(imageData.data.slice()); // NOTE: Is the slice necessary?
return result;
}
getPixel(i, j) {
const index = 4 * (this.width * i + j);
const r = this.arrayData[index];
const g = this.arrayData[index + 1];
const b = this.arrayData[index + 2];
const a = this.arrayData[index + 3];
return [r, g, b, a];
}
setPixel(i, j, value) {
let r, g, b, a;
if (typeof value === "number") {
r = g = b = value;
a = 255;
} else if (Array.isArray(value)) {
if (value.length === 4) {
[r, g, b, a] = value;
} else if (value.length === 3) {
[r, g, b] = value;
a = 255;
}
}
const index = 4 * (this.width * i + j);
this.arrayData[index] = r;
this.arrayData[index + 1] = g;
this.arrayData[index + 2] = b;
this.arrayData[index + 3] = a;
}
map(func) {
const result = new CanvasImageBuffer(this.width, this.height);
for (let i = 0; i < this.height; i++) {
for (let j = 0; j < this.width; j++) {
result.setPixel(i, j, func(this.getPixel(i, j), [i, j]));
}
}
return result;
}
forEach(func) {
for (let i = 0; i < this.height; i++) {
for (let j = 0; j < this.width; j++) {
func(this.getPixel(i, j), [i, j]);
}
}
}
async drawImageInCanvas(selector, scale = 1) {
const canvas = document.querySelector(selector);
const context = canvas.getContext("2d");
const outputWidth = scale * this.width;
const outputHeight = scale * this.height;
[canvas.width, canvas.height] = [outputWidth, outputHeight];
// Build a bitmap at the desired scale from an ImageData created from the array.
const imageData = new ImageData(this.arrayData, this.width, this.height);
const options = {
resizeWidth: outputWidth,
resizeHeight: outputHeight,
resizeQuality: "pixelated"
};
const bitmap = await createImageBitmap(imageData, options);
const [dx, dy] = [0, 0];
context.drawImage(bitmap, dx, dy);
}
}
// Main code.
const originalImg = new Image();
originalImg.crossOrigin = "anonymous";
originalImg.src =
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/Blue_tiger_%28Tirumala_limniace_exoticus%29_male_underside.jpg/700px-Blue_tiger_%28Tirumala_limniace_exoticus%29_male_underside.jpg";
//"https://upload.wikimedia.org/wikipedia/commons/7/71/Michelangelo%27s_David_-_63_grijswaarden.png";
originalImg.addEventListener("load", async () => {
const inputCanvas = document.querySelector("#input-canvas");
const inputContext = inputCanvas.getContext("2d");
// Downscale the size of the original image, to avoid having an image too big.
const width = (originalImg.width / 2) | 0;
const height = (originalImg.height / 2) | 0;
inputCanvas.width = width;
inputCanvas.height = height;
inputContext.imageSmoothingEnabled = true;
inputContext.drawImage(
originalImg,
0,
0,
originalImg.width,
originalImg.height,
0,
0,
width,
height
);
const outputBuffer = CanvasImageBuffer.fromImageData(
inputContext.getImageData(0, 0, inputCanvas.width, inputCanvas.height)
);
// Apply the Floyd-Steinberg algorithm using the given palette.
const palette = [0, 51, 102, 153, 204, 255];
outputBuffer.forEach((color, [i, j]) => {
const indices = [0, 1, 2];
// Quantize color channels to their corresponding values in the palette.
const finalColor = color
.map((c) => ((c / 256) * palette.length) | 0)
.map((idx) => palette[idx]);
// Calculate the error for all the channels (to be used later) and update the current pixel
const error = indices.map((idx) => color[idx] - finalColor[idx]);
outputBuffer.setPixel(i, j, finalColor);
// Use the Floyd-Steinberg algorithm to propagate the error.
// NOTE: To simplify the integer division by 16, we are using bit shifting (`>>4`).
// The `>>` operator has LOW precedence; if you use it in a sum, wrap it in parentheses.
// Convenient function to group the repeated code for each neighbor pixel
function updateNeighbor(row, col, errFraction) {
let rgbValues = outputBuffer.getPixel(row, col);
rgbValues = indices.map(
(idx) => rgbValues[idx] + ((error[idx] * errFraction) >> 4)
);
outputBuffer.setPixel(row, col, rgbValues);
}
// Update right pixel
if (j < outputBuffer.width - 1) updateNeighbor(i, j + 1, 7);
// Update bottom left pixel
if (j > 0 && i < outputBuffer.height - 1)
updateNeighbor(i + 1, j - 1, 3);
// Update bottom pixel
if (i < outputBuffer.height - 1) updateNeighbor(i + 1, j, 5);
// Update bottom right pixel
if (i < outputBuffer.height - 1 && j < outputBuffer.width - 1)
updateNeighbor(i + 1, j + 1, 1);
});
await outputBuffer.drawImageInCanvas("#output-canvas");
});
h1 {
margin-left: 1rem;
}
#description {
padding: 1rem;
}
span.code {
font-family: monospace;
font-size: 1rem;
font-weight: bold;
background: #eaeaea;
display: inline-block;
padding: 0 0.25rem;
border-radius: 0.25rem;
}
canvas {
border: 1px solid orange;
background: orange;
}
.sections {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.section {
padding: 1rem;
}
.section h2 {
font-size: 1.25rem;
height: 4rem;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment