diff options
author | elioat <elioat@tilde.institute> | 2025-03-29 22:57:41 -0400 |
---|---|---|
committer | elioat <elioat@tilde.institute> | 2025-03-29 22:57:41 -0400 |
commit | 771835d9e10686e0a89712af5cd1c3825c1cfc39 (patch) | |
tree | f4debb1b58b9010a86b5b171a3b9981121d8d0c8 | |
parent | 5eeed0ebe09bc27e0cb4c1e5d71d5fad6946fa50 (diff) | |
download | tour-771835d9e10686e0a89712af5cd1c3825c1cfc39.tar.gz |
*
-rw-r--r-- | js/leibovitz/dither.js | 265 | ||||
-rw-r--r-- | js/leibovitz/index.html | 12 |
2 files changed, 258 insertions, 19 deletions
diff --git a/js/leibovitz/dither.js b/js/leibovitz/dither.js index 2a02834..90c4ae2 100644 --- a/js/leibovitz/dither.js +++ b/js/leibovitz/dither.js @@ -6,10 +6,13 @@ const DitherManager = { _currentMode: 'none', _observers: new Set(), _modeSelect: null, + _pixelSizeControl: null, + currentBlockSize: 4, // Initialize the dither manager init() { this._setupEventListeners(); + this._pixelSizeControl = document.getElementById('pixel-size-control'); }, // Private methods @@ -17,8 +20,23 @@ const DitherManager = { this._modeSelect = document.getElementById('dither-select'); this._modeSelect.addEventListener('change', (e) => { this._currentMode = e.target.value; + // Show/hide pixel size control based on dithering mode + this._pixelSizeControl.style.display = + this._currentMode === 'none' ? 'none' : 'flex'; this._notifyObservers(); }); + + // Only add the event listener if the element exists + const blockSizeSlider = document.getElementById('block-size-slider'); + if (blockSizeSlider) { + blockSizeSlider.addEventListener('input', (e) => { + this.currentBlockSize = parseInt(e.target.value); + document.getElementById('block-size-value').textContent = + `${this.currentBlockSize}px`; + // Notify observers instead of directly calling processFrame + this._notifyObservers(); + }); + } }, _notifyObservers() { @@ -65,32 +83,56 @@ const DitherManager = { _floydSteinbergDither(data, width, height) { const newData = new Uint8ClampedArray(data); const threshold = 128; - const levels = 4; // Number of quantization levels + const levels = 4; + const blockSize = this.currentBlockSize; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = (y * width + x) * 4; + // Process in blocks + for (let y = 0; y < height; y += blockSize) { + for (let x = 0; x < width; x += blockSize) { + // Calculate block average + let blockSum = [0, 0, 0]; + let pixelCount = 0; - // Process each color channel + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + for (let c = 0; c < 3; c++) { + blockSum[c] += newData[idx + c]; + } + pixelCount++; + } + } + + // Calculate block average + const blockAvg = blockSum.map(sum => sum / pixelCount); + + // Apply dithering to the block average for (let c = 0; c < 3; c++) { - const oldPixel = newData[idx + c]; + const oldPixel = blockAvg[c]; const quantizedPixel = this._quantize(oldPixel, levels); const newPixel = quantizedPixel > threshold ? 255 : 0; const error = oldPixel - newPixel; - - newData[idx + c] = newPixel; - - // Distribute error to neighboring pixels - if (x + 1 < width) { - newData[idx + 4 + c] += error * 7 / 16; // right + + // Fill the entire block with the new color + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + newData[idx + c] = newPixel; + } } - if (y + 1 === height) continue; - if (x > 0) { - newData[idx + width * 4 - 4 + c] += error * 3 / 16; // bottom left + + // Distribute error to neighboring blocks + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y, c, error * 7/16, width, blockSize, height); } - newData[idx + width * 4 + c] += error * 5 / 16; // bottom - if (x + 1 < width) { - newData[idx + width * 4 + 4 + c] += error * 1 / 16; // bottom right + if (y + blockSize < height) { + if (x - blockSize >= 0) { + this._distributeBlockError(newData, x - blockSize, y + blockSize, c, error * 3/16, width, blockSize, height); + } + this._distributeBlockError(newData, x, y + blockSize, c, error * 5/16, width, blockSize, height); + if (x + blockSize < width) { + this._distributeBlockError(newData, x + blockSize, y + blockSize, c, error * 1/16, width, blockSize, height); + } } } } @@ -99,6 +141,16 @@ const DitherManager = { return new ImageData(newData, width, height); }, + // Helper method to distribute error to blocks + _distributeBlockError(data, x, y, channel, error, width, blockSize, height) { + for (let by = 0; by < blockSize && y + by < height; by++) { + for (let bx = 0; bx < blockSize && x + bx < width; bx++) { + const idx = ((y + by) * width + (x + bx)) * 4; + data[idx + channel] = Math.max(0, Math.min(255, data[idx + channel] + error)); + } + } + }, + // Ordered dithering (Bayer matrix) _orderedDither(data, width, height) { const newData = new Uint8ClampedArray(data); @@ -175,4 +227,179 @@ const DitherManager = { return new ImageData(newData, width, height); } -}; \ No newline at end of file +}; + +// Bayer dithering implementation using 8x8 Bayer matrix +// Pattern: https://en.wikipedia.org/wiki/Ordered_dithering +function bayerDither(imageData, width, blockSize) { + const data = new Uint8ClampedArray(imageData.data); + const height = imageData.data.length / 4 / width; + + // 8x8 Bayer matrix normalized to 0-1 range + const bayerMatrix = [ + [ 0, 48, 12, 60, 3, 51, 15, 63], + [32, 16, 44, 28, 35, 19, 47, 31], + [ 8, 56, 4, 52, 11, 59, 7, 55], + [40, 24, 36, 20, 43, 27, 39, 23], + [ 2, 50, 14, 62, 1, 49, 13, 61], + [34, 18, 46, 30, 33, 17, 45, 29], + [10, 58, 6, 54, 9, 57, 5, 53], + [42, 26, 38, 22, 41, 25, 37, 21] + ].map(row => row.map(x => x / 64)); // Normalize to 0-1 + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + + // Get threshold from Bayer matrix (wrapping around with modulo) + const threshold = bayerMatrix[y % 8][x % 8]; + + // Apply threshold to each color channel + for (let c = 0; c < 3; c++) { + const normalizedPixel = data[i + c] / 255; + data[i + c] = normalizedPixel > threshold ? 255 : 0; + } + } + } + + return data; +} + +function applyDithering(imageData, width) { + const method = document.getElementById('dither-select').value; + const blockSize = DitherManager.currentBlockSize; + + switch (method) { + case 'floyd-steinberg': + return DitherManager._floydSteinbergDither(imageData.data, width, imageData.height); + case 'bayer': + return bayerDither(imageData, width); + case 'atkinson': + return DitherManager._atkinsonDither(imageData.data, width, imageData.height); + case 'ordered': + return DitherManager._orderedDither(imageData.data, width, imageData.height); + default: + return new Uint8ClampedArray(imageData.data); + } +} + +function floydSteinbergDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; // Number of quantization levels + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + // Process each color channel + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + // Distribute error to neighboring pixels + if (x + 1 < width) { + newData[idx + 4 + c] += error * 7 / 16; // right + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error * 3 / 16; // bottom left + } + newData[idx + width * 4 + c] += error * 5 / 16; // bottom + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error * 1 / 16; // bottom right + } + } + } + } + + return new ImageData(newData, width, height); +} + +function atkinsonDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const threshold = 128; + const levels = 4; // Number of quantization levels + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + + // Process each color channel + for (let c = 0; c < 3; c++) { + const oldPixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(oldPixel, levels); + const newPixel = quantizedPixel > threshold ? 255 : 0; + const error = oldPixel - newPixel; + + newData[idx + c] = newPixel; + + // Distribute error to neighboring pixels (Atkinson's algorithm) + if (x + 1 < width) { + newData[idx + 4 + c] += error / 8; // right + } + if (x + 2 < width) { + newData[idx + 8 + c] += error / 8; // right + 1 + } + if (y + 1 === height) continue; + if (x > 0) { + newData[idx + width * 4 - 4 + c] += error / 8; // bottom left + } + newData[idx + width * 4 + c] += error / 8; // bottom + if (x + 1 < width) { + newData[idx + width * 4 + 4 + c] += error / 8; // bottom right + } + if (y + 2 === height) continue; + if (x + 1 < width) { + newData[idx + width * 8 + 4 + c] += error / 8; // bottom + 1 right + } + } + } + } + + return new ImageData(newData, width, height); +} + +function orderedDither(imageData, width, blockSize) { + const { data } = imageData; + const height = imageData.data.length / 4 / width; + + const newData = new Uint8ClampedArray(data); + const matrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5] + ]; + const matrixSize = 4; + const threshold = 128; + const levels = 4; // Number of quantization levels + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const matrixX = x % matrixSize; + const matrixY = y % matrixSize; + const matrixValue = matrix[matrixY][matrixX] * 16; + + // Process each color channel + for (let c = 0; c < 3; c++) { + const pixel = data[idx + c]; + const quantizedPixel = DitherManager._quantize(pixel, levels); + newData[idx + c] = quantizedPixel + matrixValue > threshold ? 255 : 0; + } + } + } + + return new ImageData(newData, width, height); +} \ No newline at end of file diff --git a/js/leibovitz/index.html b/js/leibovitz/index.html index 8af8f4f..acc592a 100644 --- a/js/leibovitz/index.html +++ b/js/leibovitz/index.html @@ -64,6 +64,7 @@ padding: 10px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + min-width: 200px; } .top-controls { display: flex; @@ -130,6 +131,11 @@ width: 100%; } + #block-size-value { + font-size: 12px; + color: #666; + } + </style> </head> <body> @@ -143,6 +149,7 @@ <option value="floyd-steinberg">Floyd-Steinberg</option> <option value="ordered">Ordered</option> <option value="atkinson">Atkinson</option> + <option value="bayer">Bayer</option> </select> <div class="color-controls"> <input type="color" id="color-tint" title="Color Tint"> @@ -153,6 +160,11 @@ <label for="contrast-slider">Contrast</label> <input type="range" id="contrast-slider" min="-255" max="255" value="0" step="1"> </div> + <div class="contrast-control" id="pixel-size-control" style="display: none;"> + <label for="block-size-slider">Pixel Size</label> + <input type="range" id="block-size-slider" min="1" max="12" value="4" step="1"> + <span id="block-size-value">4px</span> + </div> </div> <div id="controls"> |