From a0ca7781ca50924ce89a1a6187c6f7cabef70a7f Mon Sep 17 00:00:00 2001 From: elioat Date: Sun, 30 Jun 2024 16:23:29 -0400 Subject: * --- js/dither-video/index.html | 19 +++++++++ js/dither-video/video.js | 101 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 js/dither-video/index.html create mode 100644 js/dither-video/video.js diff --git a/js/dither-video/index.html b/js/dither-video/index.html new file mode 100644 index 0000000..f6918c7 --- /dev/null +++ b/js/dither-video/index.html @@ -0,0 +1,19 @@ + + + + + + Webcam with Dithering + + + + + + + + + \ No newline at end of file diff --git a/js/dither-video/video.js b/js/dither-video/video.js new file mode 100644 index 0000000..0da0157 --- /dev/null +++ b/js/dither-video/video.js @@ -0,0 +1,101 @@ +const toggleCameraButton = document.getElementById('toggleCamera'); +const video = document.getElementById('webcam'); +const canvas = document.getElementById('ditheredOutput'); +const context = canvas.getContext('2d'); + +let stream = null; +let isCameraOn = false; + +// FIXME: Sort out how to crop the image rather than squish and distort it. +const lowResWidth = 160; // Gameboy camera resolution: 160×144 +const lowResHeight = 120; // If I use the GB camera resolution the image gets distorted + +toggleCameraButton.addEventListener('click', async () => { + if (!isCameraOn) { + try { + stream = await navigator.mediaDevices.getUserMedia({ video: true }); + video.srcObject = stream; + video.style.display = 'block'; + isCameraOn = true; + toggleCameraButton.textContent = 'Turn Camera Off'; + video.addEventListener('loadeddata', () => { + canvas.width = lowResWidth; + canvas.height = lowResHeight; + requestAnimationFrame(processFrame); + }); + } catch (err) { + console.error('Failed to access the camera: ', err); + } + } else { + stream.getTracks().forEach(track => track.stop()); + video.style.display = 'none'; + isCameraOn = false; + toggleCameraButton.textContent = 'Turn Camera On'; + } +}); + +function processFrame() { + if (!isCameraOn) return; + + context.drawImage(video, 0, 0, lowResWidth, lowResHeight); + const frame = context.getImageData(0, 0, lowResWidth, lowResHeight); + applyFloydSteinbergDithering(frame); + context.putImageData(frame, 0, 0); + requestAnimationFrame(processFrame); +} + + +function applyFloydSteinbergDithering(imageData) { + const { data, width, height } = imageData; + + // Helpers to access and set pixel values + const getPixelIndex = (x, y) => (y * width + x) * 4; + const getPixelValue = (x, y) => data[getPixelIndex(x, y)]; + const setPixelValue = (x, y, value) => { + const index = getPixelIndex(x, y); + data[index] = value; + data[index + 1] = value; + data[index + 2] = value; + }; + + // Helper that clamps a value between 0 and 255 + const clamp = (value) => Math.max(0, Math.min(255, value)); + + // Process a single pixel + const processPixel = (x, y) => { + const oldPixel = getPixelValue(x, y); + const newPixel = oldPixel < 128 ? 0 : 255; // Threshold of the pixel value + setPixelValue(x, y, newPixel); + const quantError = oldPixel - newPixel; + + // Spreads the error to neighboring pixels + if (x + 1 < width) { + const idx = getPixelIndex(x + 1, y); + data[idx] = clamp(data[idx] + quantError * 7 / 16); + } + if (x - 1 >= 0 && y + 1 < height) { + const idx = getPixelIndex(x - 1, y + 1); + data[idx] = clamp(data[idx] + quantError * 3 / 16); + } + if (y + 1 < height) { + const idx = getPixelIndex(x, y + 1); + data[idx] = clamp(data[idx] + quantError * 5 / 16); + } + if (x + 1 < width && y + 1 < height) { + const idx = getPixelIndex(x + 1, y + 1); + data[idx] = clamp(data[idx] + quantError * 1 / 16); + } + }; + + // TODO: This seems like a mad inefficient way to do this, but it does work. Noodle on if it is worth making this more efficient. + // Process all of the pixels + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + processPixel(x, y); + } + } +} + +function clamp(value) { + return Math.max(0, Math.min(255, value)); +} -- cgit 1.4.1-2-gfad0