Created
July 28, 2026 16:26
-
-
Save s3thi/04f3fcc81910ef70feb64a45ac7e06bc to your computer and use it in GitHub Desktop.
Dithering
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
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <link rel="stylesheet" href="./style.css" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Dithering</title> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>Dithering</h1> | |
| <p> | |
| This pen implements the | |
| <a href="https://en.wikipedia.org/wiki/Floyd–Steinberg_dithering" | |
| >Floyd-Steinberg algorithm</a | |
| > | |
| for <a href="https://en.wikipedia.org/wiki/Dither">image dithering</a>. | |
| I don't have this working <em>quite</em> right yet, but it looks good | |
| just the same. | |
| </p> | |
| <p> | |
| Use the file-picker button to select an image from your computer. For | |
| best results, pick a relatively small image. If you pick a larger image, | |
| you'll have to zoom in to see the effect. | |
| </p> | |
| <p> | |
| All code runs locally in your browser. View source to see how the | |
| algorithm is implemented. | |
| </p> | |
| <form> | |
| <input type="file" accept="image/*" id="image" name="image" required /> | |
| <input type="submit" value="Dither!" /> | |
| </form> | |
| <div class="preview"></div> | |
| </main> | |
| <footer> | |
| <p>A pen by <a href="https://ankursethi.com">Ankur Sethi</a>.</p> | |
| </footer> | |
| <script src="./script.js"></script> | |
| </body> | |
| </html> |
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
| // Grab some elements from the DOM for later. | |
| const imageFormElt = document.querySelector("form"); | |
| const fileInputElt = document.querySelector("input[type='file']"); | |
| const canvasContainerElt = document.querySelector(".preview"); | |
| let canvasElt = null; | |
| /** | |
| * Handles the `change` event on the file input. | |
| */ | |
| async function onFileInputChanged() { | |
| // If the input has no files, bail out. | |
| if (fileInputElt.files.length === 0) { | |
| return; | |
| } | |
| // Turn the selected file into an ImageBitmap. We need this so we can draw it | |
| // to a <canvas> later. | |
| const bitmap = await createImageBitmap(fileInputElt.files[0]); | |
| // Create a fresh <canvas> that matches the dimensions of the selected image. | |
| // Then replace any existing <canvas> with the new one. | |
| canvasElt = document.createElement("canvas"); | |
| canvasElt.width = bitmap.width; | |
| canvasElt.height = bitmap.height; | |
| canvasContainerElt.replaceChildren(canvasElt); | |
| // Draw the image into our newly-created <canvas>. There might be a less | |
| // roundabout way of getting an image from a user into a <canvas>, but I don't | |
| // know what it is. | |
| const ctx = canvasElt.getContext("2d"); | |
| ctx.drawImage(bitmap, 0, 0); | |
| } | |
| /** | |
| * Handles the `submit` event on the image upload form. | |
| */ | |
| function onImageFormSubmitted(e) { | |
| e.preventDefault(); | |
| // If the user tried to submit an empty form, bail out. | |
| if (fileInputElt.files.length === 0) { | |
| return; | |
| } | |
| // Let's go! | |
| dither(canvasElt); | |
| } | |
| /** | |
| * Dithers an image that's been drawn into a <canvas> element using the | |
| * Floyd-Steinberg algorithm. Modifies the image in-place. | |
| */ | |
| function dither(canvasElt) { | |
| const ctx = canvasElt.getContext("2d"); | |
| const width = canvasElt.width; | |
| const height = canvasElt.height; | |
| // Get pixel data out of the <canvas>. | |
| const imageData = ctx.getImageData(0, 0, width, height); | |
| const pixels = imageData.data; | |
| // Iterate over the pixel data. Note that the image data we get from the | |
| // <canvas> context is a one-dimensional array. It's easier apply the | |
| // dithering algorithm when we're working in terms of (x, y) coordinates, so | |
| // that's what our loop uses. When we want to index into our ImageData, we do | |
| // a bit of math to turn (x, y) coordinates into an index we can use. | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| // Use the (x, y) coordinates to get an index into the one-dimensional | |
| // ImageData array. | |
| const imageIdx = mapCoordinatesToImageData(x, y, width); | |
| const red = pixels[imageIdx]; | |
| const green = pixels[imageIdx + 1]; | |
| const blue = pixels[imageIdx + 2]; | |
| // We have our (r, g, b) components from above, but we want to turn them | |
| // into a grayscale value. For this, we use the HSL luminance formula. | |
| const luminance = | |
| (Math.max(red, green, blue) + Math.min(red, green, blue)) / 2; | |
| let quantizeError; | |
| if (luminance > 127) { | |
| // If the luminance is above 127, turn the pixels at this coordinate | |
| // white. | |
| pixels[imageIdx] = 255; | |
| pixels[imageIdx + 1] = 255; | |
| pixels[imageIdx + 2] = 255; | |
| // Make note of the quantization error. | |
| quantizeError = luminance - 255; | |
| } else { | |
| // If the luminance is below 127, turn the pixels at this coordinate | |
| // black. | |
| pixels[imageIdx] = 0; | |
| pixels[imageIdx + 1] = 0; | |
| pixels[imageIdx + 2] = 0; | |
| // Make note of the quantization error. | |
| quantizeError = luminance; | |
| } | |
| // Now diffuse the quantization error to the surrounding pixels, as | |
| // described by Floyd-Steinberg's error diffusion formula. We have a bunch | |
| // of conditionals here to make sure we're not writing outside our pixel | |
| // array. | |
| let idx; | |
| if (x < width - 1) { | |
| idx = mapCoordinatesToImageData(x + 1, y, width); | |
| pixels[idx] = pixels[idx] + (quantizeError / 16) * 7; | |
| } | |
| if (y < height - 1) { | |
| if (x > 0) { | |
| idx = mapCoordinatesToImageData(x - 1, y + 1, width); | |
| pixels[idx] = pixels[idx] + (quantizeError / 16) * 3; | |
| } | |
| idx = mapCoordinatesToImageData(x, y + 1, width); | |
| pixels[idx] = pixels[idx] + (quantizeError / 16) * 5; | |
| if (x < width - 1) { | |
| idx = mapCoordinatesToImageData(x + 1, y + 1, width); | |
| pixels[idx] = pixels[idx] + (quantizeError / 16) * 1; | |
| } | |
| } | |
| } | |
| } | |
| // Finally, draw the modified image data back into the <canvas>. | |
| ctx.putImageData(imageData, 0, 0); | |
| } | |
| /** | |
| * Turns a (x, y) coordinate into an index that can be used to index into a | |
| * one-dimensional ImageData array. | |
| */ | |
| const mapCoordinatesToImageData = (x, y, width) => 4 * (y * width + x); | |
| // Register events. | |
| fileInputElt.addEventListener("change", onFileInputChanged); | |
| imageFormElt.addEventListener("submit", onImageFormSubmitted); |
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
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| } | |
| html { | |
| --section-gap: 1.5rem; | |
| font-size: 112.5%; | |
| } | |
| body { | |
| font-family: | |
| Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif; | |
| font-weight: normal; | |
| line-height: 1.65; | |
| margin: 0; | |
| padding: 1.5rem; | |
| height: 100dvh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| body > * { | |
| width: min(100%, 48rem); | |
| } | |
| main { | |
| flex-grow: 1; | |
| } | |
| footer { | |
| font-size: 0.875rem; | |
| text-align: center; | |
| } | |
| h1 { | |
| margin: 0; | |
| } | |
| h1 + p { | |
| margin-block-start: 0.25rem; | |
| } | |
| form { | |
| margin-block-start: 1.5rem; | |
| display: grid; | |
| grid-template-columns: 3fr 1fr; | |
| gap: 0.5rem; | |
| } | |
| input[type="submit"], | |
| input[type="file"], | |
| input[type="file"]::file-selector-button { | |
| font-size: inherit; | |
| padding: 0.25rem; | |
| min-width: 8rem; | |
| } | |
| input[type="file"] { | |
| border: solid 1px silver; | |
| border-radius: 4px; | |
| } | |
| input[type="file"]:user-invalid { | |
| border-color: red; | |
| } | |
| .preview { | |
| width: 100%; | |
| margin-block-start: 1.5rem; | |
| display: flex; | |
| justify-content: center; | |
| } | |
| .preview canvas { | |
| max-width: 100%; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment