Last active
June 5, 2023 22:47
-
-
Save rsalgado/8888b46edaafdf0755a168421cb68650 to your computer and use it in GitHub Desktop.
Floyd-Steinberg Dithering Example (Color)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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"); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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