Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Image Dithering Editor</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| .canvas-container { | |
| position: relative; | |
| margin: 0 auto; | |
| box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); | |
| border-radius: 0.5rem; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| .loading-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.7); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| color: white; | |
| font-size: 1.5rem; | |
| z-index: 10; | |
| border-radius: 0.5rem; | |
| } | |
| .range-slider { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 8px; | |
| border-radius: 4px; | |
| background: #e2e8f0; | |
| outline: none; | |
| } | |
| .range-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #4f46e5; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .range-slider::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| background: #6366f1; | |
| } | |
| .range-slider::-moz-range-thumb { | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #4f46e5; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .range-slider::-moz-range-thumb:hover { | |
| transform: scale(1.2); | |
| background: #6366f1; | |
| } | |
| .algorithm-btn.active { | |
| background-color: #4f46e5; | |
| color: white; | |
| } | |
| .dropzone { | |
| border: 2px dashed #cbd5e1; | |
| transition: all 0.3s; | |
| } | |
| .dropzone:hover, .dropzone.dragover { | |
| border-color: #4f46e5; | |
| background-color: #f8fafc; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="text-center mb-8"> | |
| <h1 class="text-4xl font-bold text-gray-800 mb-2">Pixel Art Dithering Editor</h1> | |
| <p class="text-gray-600 max-w-2xl mx-auto">Transform your images with customizable dithering effects. Upload an image, adjust the settings, and download your pixel-perfect creation.</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| <!-- Left Panel - Controls --> | |
| <div class="bg-white p-6 rounded-xl shadow-md"> | |
| <h2 class="text-xl font-semibold text-gray-800 mb-4">Controls</h2> | |
| <!-- Image Upload --> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Upload Image</label> | |
| <div id="dropzone" class="dropzone rounded-lg p-6 text-center cursor-pointer"> | |
| <input type="file" id="imageInput" accept="image/*" class="hidden"> | |
| <div class="flex flex-col items-center justify-center"> | |
| <i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-2"></i> | |
| <p class="text-gray-500">Drag & drop an image or click to browse</p> | |
| <p class="text-xs text-gray-400 mt-1">Supports JPG, PNG, GIF</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Dithering Algorithm --> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Dithering Algorithm</label> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <button data-algorithm="none" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">None</button> | |
| <button data-algorithm="floydSteinberg" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">Floyd-Steinberg</button> | |
| <button data-algorithm="atkinson" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">Atkinson</button> | |
| <button data-algorithm="bayer" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">Bayer</button> | |
| <button data-algorithm="random" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">Random</button> | |
| <button data-algorithm="threshold" class="algorithm-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition">Threshold</button> | |
| </div> | |
| </div> | |
| <!-- Color Palette --> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Color Palette</label> | |
| <div class="grid grid-cols-5 gap-2 mb-3"> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background-color: #000000;" data-colors="1"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background-color: #ffffff;" data-colors="2"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #000000, #ffffff);" data-colors="2"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #000000, #808080, #ffffff);" data-colors="3"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #000000, #555555, #aaaaaa, #ffffff);" data-colors="4"></div> | |
| </div> | |
| <div class="grid grid-cols-4 gap-2"> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #ff0000, #00ff00, #0000ff);" data-colors="3"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff);" data-colors="6"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #e6194b, #3cb44b, #ffe119, #4363d8, #f58231, #911eb4);" data-colors="6"></div> | |
| <div class="color-option h-8 rounded cursor-pointer border border-gray-300" style="background: linear-gradient(to right, #000000, #654321, #8b0000, #ff8c00, #ffd700, #ffffff);" data-colors="6"></div> | |
| </div> | |
| </div> | |
| <!-- Customization Sliders --> | |
| <div class="mb-4"> | |
| <label for="thresholdSlider" class="block text-sm font-medium text-gray-700 mb-1">Threshold</label> | |
| <input type="range" id="thresholdSlider" min="0" max="255" value="128" class="range-slider"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>0</span> | |
| <span>128</span> | |
| <span>255</span> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="intensitySlider" class="block text-sm font-medium text-gray-700 mb-1">Dither Intensity</label> | |
| <input type="range" id="intensitySlider" min="0" max="100" value="50" class="range-slider"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>Low</span> | |
| <span>Medium</span> | |
| <span>High</span> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="pixelSizeSlider" class="block text-sm font-medium text-gray-700 mb-1">Pixel Size</label> | |
| <input type="range" id="pixelSizeSlider" min="1" max="20" value="1" class="range-slider"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>1px</span> | |
| <span>10px</span> | |
| <span>20px</span> | |
| </div> | |
| </div> | |
| <!-- Download Button --> | |
| <button id="downloadBtn" class="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-md transition flex items-center justify-center"> | |
| <i class="fas fa-download mr-2"></i> Download Image | |
| </button> | |
| </div> | |
| <!-- Middle Panel - Canvas --> | |
| <div class="lg:col-span-2"> | |
| <div class="canvas-container bg-white p-4 rounded-xl shadow-md"> | |
| <div id="loadingOverlay" class="loading-overlay hidden"> | |
| <div class="text-center"> | |
| <i class="fas fa-spinner fa-spin mb-2"></i> | |
| <p>Processing image...</p> | |
| </div> | |
| </div> | |
| <canvas id="canvas"></canvas> | |
| </div> | |
| <!-- Presets --> | |
| <div class="mt-6 bg-white p-6 rounded-xl shadow-md"> | |
| <h2 class="text-xl font-semibold text-gray-800 mb-4">Quick Presets</h2> | |
| <div class="grid grid-cols-2 md:grid-cols-3 gap-3"> | |
| <button data-preset="retro" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-gamepad mr-2 text-purple-500"></i> Retro Game | |
| </button> | |
| <button data-preset="newsprint" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-newspaper mr-2 text-gray-500"></i> Newsprint | |
| </button> | |
| <button data-preset="poster" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-palette mr-2 text-red-500"></i> Color Poster | |
| </button> | |
| <button data-preset="sketch" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-pencil-alt mr-2 text-blue-500"></i> Pencil Sketch | |
| </button> | |
| <button data-preset="xray" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-x-ray mr-2 text-green-500"></i> X-Ray | |
| </button> | |
| <button data-preset="vintage" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-camera-retro mr-2 text-yellow-600"></i> Vintage | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // Canvas setup | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const loadingOverlay = document.getElementById('loadingOverlay'); | |
| // State variables | |
| let originalImage = null; | |
| let currentAlgorithm = 'floydSteinberg'; | |
| let currentColors = ['#000000', '#ffffff']; | |
| let threshold = 128; | |
| let intensity = 50; | |
| let pixelSize = 1; | |
| // UI Elements | |
| const algorithmBtns = document.querySelectorAll('.algorithm-btn'); | |
| const colorOptions = document.querySelectorAll('.color-option'); | |
| const thresholdSlider = document.getElementById('thresholdSlider'); | |
| const intensitySlider = document.getElementById('intensitySlider'); | |
| const pixelSizeSlider = document.getElementById('pixelSizeSlider'); | |
| const downloadBtn = document.getElementById('downloadBtn'); | |
| const imageInput = document.getElementById('imageInput'); | |
| const dropzone = document.getElementById('dropzone'); | |
| const presetBtns = document.querySelectorAll('.preset-btn'); | |
| // Event Listeners | |
| imageInput.addEventListener('change', handleImageUpload); | |
| dropzone.addEventListener('click', () => imageInput.click()); | |
| dropzone.addEventListener('dragover', handleDragOver); | |
| dropzone.addEventListener('dragleave', handleDragLeave); | |
| dropzone.addEventListener('drop', handleDrop); | |
| algorithmBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| algorithmBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentAlgorithm = btn.dataset.algorithm; | |
| applyDithering(); | |
| }); | |
| }); | |
| colorOptions.forEach(option => { | |
| option.addEventListener('click', () => { | |
| const colorCount = parseInt(option.dataset.colors); | |
| if (colorCount === 1) { | |
| currentColors = [option.style.backgroundColor]; | |
| } else if (colorCount === 2 && option.style.background.includes('gradient')) { | |
| currentColors = ['#000000', '#ffffff']; | |
| } else if (colorCount > 2) { | |
| // Extract colors from gradient (simplified) | |
| const gradientColors = option.style.background.match(/#[0-9a-f]{3,6}/gi); | |
| currentColors = gradientColors || ['#000000', '#ffffff']; | |
| } | |
| applyDithering(); | |
| }); | |
| }); | |
| thresholdSlider.addEventListener('input', () => { | |
| threshold = parseInt(thresholdSlider.value); | |
| applyDithering(); | |
| }); | |
| intensitySlider.addEventListener('input', () => { | |
| intensity = parseInt(intensitySlider.value); | |
| applyDithering(); | |
| }); | |
| pixelSizeSlider.addEventListener('input', () => { | |
| pixelSize = parseInt(pixelSizeSlider.value); | |
| applyDithering(); | |
| }); | |
| downloadBtn.addEventListener('click', downloadImage); | |
| presetBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const preset = btn.dataset.preset; | |
| applyPreset(preset); | |
| }); | |
| }); | |
| // Initialize with Floyd-Steinberg active | |
| document.querySelector('[data-algorithm="floydSteinberg"]').classList.add('active'); | |
| // Functions | |
| function handleImageUpload(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const img = new Image(); | |
| img.onload = function() { | |
| originalImage = img; | |
| resetCanvas(); | |
| applyDithering(); | |
| }; | |
| img.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function handleDragOver(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.add('dragover'); | |
| } | |
| function handleDragLeave(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove('dragover'); | |
| } | |
| function handleDrop(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove('dragover'); | |
| const file = e.dataTransfer.files[0]; | |
| if (!file.type.match('image.*')) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const img = new Image(); | |
| img.onload = function() { | |
| originalImage = img; | |
| resetCanvas(); | |
| applyDithering(); | |
| }; | |
| img.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function resetCanvas() { | |
| if (!originalImage) return; | |
| // Set canvas dimensions (max 800px width/height while maintaining aspect ratio) | |
| const maxSize = 800; | |
| let width = originalImage.width; | |
| let height = originalImage.height; | |
| if (width > height && width > maxSize) { | |
| height = Math.round((maxSize / width) * height); | |
| width = maxSize; | |
| } else if (height > maxSize) { | |
| width = Math.round((maxSize / height) * width); | |
| height = maxSize; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| // Draw original image | |
| ctx.drawImage(originalImage, 0, 0, width, height); | |
| } | |
| function applyDithering() { | |
| if (!originalImage) return; | |
| showLoading(); | |
| // Use setTimeout to allow UI to update before heavy processing | |
| setTimeout(() => { | |
| try { | |
| // Create image data | |
| const width = canvas.width; | |
| const height = canvas.height; | |
| // Get original image data | |
| ctx.drawImage(originalImage, 0, 0, width, height); | |
| const imageData = ctx.getImageData(0, 0, width, height); | |
| const data = imageData.data; | |
| // Convert to grayscale first | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const gray = 0.299 * r + 0.587 * g + 0.114 * b; | |
| data[i] = data[i + 1] = data[i + 2] = gray; | |
| } | |
| // Apply selected dithering algorithm | |
| switch (currentAlgorithm) { | |
| case 'floydSteinberg': | |
| floydSteinbergDither(data, width, height); | |
| break; | |
| case 'atkinson': | |
| atkinsonDither(data, width, height); | |
| break; | |
| case 'bayer': | |
| bayerDither(data, width, height); | |
| break; | |
| case 'random': | |
| randomDither(data, width, height); | |
| break; | |
| case 'threshold': | |
| thresholdDither(data, width, height); | |
| break; | |
| default: | |
| // No dithering | |
| break; | |
| } | |
| // Apply color palette | |
| applyColorPalette(data, currentColors); | |
| // Apply pixelation if needed | |
| if (pixelSize > 1) { | |
| pixelateImage(data, width, height, pixelSize); | |
| } | |
| // Put the modified data back | |
| ctx.putImageData(imageData, 0, 0); | |
| } catch (error) { | |
| console.error('Error applying dithering:', error); | |
| } finally { | |
| hideLoading(); | |
| } | |
| }, 100); | |
| } | |
| function floydSteinbergDither(data, width, height) { | |
| const adjustedThreshold = threshold * (intensity / 50); | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| const idx = (y * width + x) * 4; | |
| const oldPixel = data[idx]; | |
| const newPixel = oldPixel < adjustedThreshold ? 0 : 255; | |
| data[idx] = data[idx + 1] = data[idx + 2] = newPixel; | |
| const quantError = oldPixel - newPixel; | |
| // Distribute error to neighboring pixels | |
| if (x + 1 < width) { | |
| const idxRight = idx + 4; | |
| data[idxRight] += quantError * 7 / 16; | |
| } | |
| if (x > 0 && y + 1 < height) { | |
| const idxDownLeft = idx + width * 4 - 4; | |
| data[idxDownLeft] += quantError * 3 / 16; | |
| } | |
| if (y + 1 < height) { | |
| const idxDown = idx + width * 4; | |
| data[idxDown] += quantError * 5 / 16; | |
| } | |
| if (x + 1 < width && y + 1 < height) { | |
| const idxDownRight = idx + width * 4 + 4; | |
| data[idxDownRight] += quantError * 1 / 16; | |
| } | |
| } | |
| } | |
| } | |
| function atkinsonDither(data, width, height) { | |
| const adjustedThreshold = threshold * (intensity / 50); | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| const idx = (y * width + x) * 4; | |
| const oldPixel = data[idx]; | |
| const newPixel = oldPixel < adjustedThreshold ? 0 : 255; | |
| data[idx] = data[idx + 1] = data[idx + 2] = newPixel; | |
| const quantError = oldPixel - newPixel; | |
| const errorPart = quantError / 8; | |
| // Distribute error to neighboring pixels (Atkinson's algorithm) | |
| if (x + 1 < width) { | |
| const idxRight = idx + 4; | |
| data[idxRight] += errorPart; | |
| } | |
| if (x + 2 < width) { | |
| const idxRight2 = idx + 8; | |
| data[idxRight2] += errorPart; | |
| } | |
| if (x > 0 && y + 1 < height) { | |
| const idxDownLeft = idx + width * 4 - 4; | |
| data[idxDownLeft] += errorPart; | |
| } | |
| if (y + 1 < height) { | |
| const idxDown = idx + width * 4; | |
| data[idxDown] += errorPart; | |
| } | |
| if (x + 1 < width && y + 1 < height) { | |
| const idxDownRight = idx + width * 4 + 4; | |
| data[idxDownRight] += errorPart; | |
| } | |
| if (y + 2 < height) { | |
| const idxDown2 = idx + width * 8; | |
| data[idxDown2] += errorPart; | |
| } | |
| } | |
| } | |
| } | |
| function bayerDither(data, width, height) { | |
| // 4x4 Bayer matrix | |
| const bayerMatrix = [ | |
| [ 1, 9, 3, 11 ], | |
| [ 13, 5, 15, 7 ], | |
| [ 4, 12, 2, 10 ], | |
| [ 16, 8, 14, 6 ] | |
| ]; | |
| const adjustedThreshold = threshold * (intensity / 50) * (16 / 255); | |
| for (let y = 0; y < height; y++) { | |
| for (let x = 0; x < width; x++) { | |
| const idx = (y * width + x) * 4; | |
| const gray = data[idx]; | |
| const thresholdValue = bayerMatrix[y % 4][x % 4] * adjustedThreshold; | |
| data[idx] = data[idx + 1] = data[idx + 2] = gray > thresholdValue ? 255 : 0; | |
| } | |
| } | |
| } | |
| function randomDither(data, width, height) { | |
| const adjustedThreshold = threshold * (intensity / 50); | |
| for (let i = 0; i < data.length; i += 4) { | |
| const gray = data[i]; | |
| const randomThreshold = adjustedThreshold + (Math.random() * 50 - 25); | |
| data[i] = data[i + 1] = data[i + 2] = gray < randomThreshold ? 0 : 255; | |
| } | |
| } | |
| function thresholdDither(data, width, height) { | |
| const adjustedThreshold = threshold * (intensity / 50); | |
| for (let i = 0; i < data.length; i += 4) { | |
| const gray = data[i]; | |
| data[i] = data[i + 1] = data[i + 2] = gray < adjustedThreshold ? 0 : 255; | |
| } | |
| } | |
| function applyColorPalette(data, colors) { | |
| if (colors.length === 1) return; // No color change needed | |
| // Convert hex colors to RGB | |
| const rgbColors = colors.map(hexToRgb); | |
| for (let i = 0; i < data.length; i += 4) { | |
| const gray = data[i]; | |
| // Find closest color in palette based on grayscale value | |
| const colorIndex = Math.floor((gray / 255) * (rgbColors.length - 1)); | |
| const color = rgbColors[colorIndex]; | |
| data[i] = color.r; | |
| data[i + 1] = color.g; | |
| data[i + 2] = color.b; | |
| } | |
| } | |
| function pixelateImage(data, width, height, pixelSize) { | |
| if (pixelSize <= 1) return; | |
| // Create a temporary canvas for pixelation | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = width; | |
| tempCanvas.height = height; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| // Put the current image data to temp canvas | |
| const tempImageData = new ImageData(new Uint8ClampedArray(data), width, height); | |
| tempCtx.putImageData(tempImageData, 0, 0); | |
| // Calculate new dimensions | |
| const pixelatedWidth = Math.ceil(width / pixelSize); | |
| const pixelatedHeight = Math.ceil(height / pixelSize); | |
| // Draw small version | |
| tempCtx.imageSmoothingEnabled = false; | |
| tempCtx.drawImage(tempCanvas, 0, 0, pixelatedWidth, pixelatedHeight); | |
| // Draw back to original size | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.drawImage(tempCanvas, 0, 0, pixelatedWidth, pixelatedHeight, 0, 0, width, height); | |
| // Get the pixelated data | |
| const pixelatedData = ctx.getImageData(0, 0, width, height).data; | |
| // Copy back to original data array | |
| for (let i = 0; i < data.length; i++) { | |
| data[i] = pixelatedData[i]; | |
| } | |
| } | |
| function hexToRgb(hex) { | |
| // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") | |
| const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; | |
| hex = hex.replace(shorthandRegex, function(m, r, g, b) { | |
| return r + r + g + g + b + b; | |
| }); | |
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return result ? { | |
| r: parseInt(result[1], 16), | |
| g: parseInt(result[2], 16), | |
| b: parseInt(result[3], 16) | |
| } : { r: 0, g: 0, b: 0 }; | |
| } | |
| function downloadImage() { | |
| if (!originalImage) { | |
| alert('Please upload an image first!'); | |
| return; | |
| } | |
| const link = document.createElement('a'); | |
| link.download = 'dithered-image.png'; | |
| link.href = canvas.toDataURL('image/png'); | |
| link.click(); | |
| } | |
| function showLoading() { | |
| loadingOverlay.classList.remove('hidden'); | |
| } | |
| function hideLoading() { | |
| loadingOverlay.classList.add('hidden'); | |
| } | |
| function applyPreset(preset) { | |
| switch (preset) { | |
| case 'retro': | |
| currentAlgorithm = 'floydSteinberg'; | |
| currentColors = ['#000000', '#ffffff']; | |
| threshold = 128; | |
| intensity = 60; | |
| pixelSize = 4; | |
| break; | |
| case 'newsprint': | |
| currentAlgorithm = 'bayer'; | |
| currentColors = ['#000000', '#ffffff']; | |
| threshold = 100; | |
| intensity = 70; | |
| pixelSize = 1; | |
| break; | |
| case 'poster': | |
| currentAlgorithm = 'atkinson'; | |
| currentColors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff']; | |
| threshold = 128; | |
| intensity = 80; | |
| pixelSize = 2; | |
| break; | |
| case 'sketch': | |
| currentAlgorithm = 'random'; | |
| currentColors = ['#000000', '#ffffff']; | |
| threshold = 150; | |
| intensity = 40; | |
| pixelSize = 1; | |
| break; | |
| case 'xray': | |
| currentAlgorithm = 'threshold'; | |
| currentColors = ['#000000', '#ffffff']; | |
| threshold = 200; | |
| intensity = 90; | |
| pixelSize = 1; | |
| break; | |
| case 'vintage': | |
| currentAlgorithm = 'floydSteinberg'; | |
| currentColors = ['#654321', '#8b0000', '#ff8c00', '#ffd700', '#ffffff']; | |
| threshold = 100; | |
| intensity = 50; | |
| pixelSize = 3; | |
| break; | |
| } | |
| // Update UI to match preset | |
| algorithmBtns.forEach(btn => { | |
| btn.classList.remove('active'); | |
| if (btn.dataset.algorithm === currentAlgorithm) { | |
| btn.classList.add('active'); | |
| } | |
| }); | |
| thresholdSlider.value = threshold; | |
| intensitySlider.value = intensity; | |
| pixelSizeSlider.value = pixelSize; | |
| applyDithering(); | |
| } | |
| // Load a sample image on startup | |
| const sampleImage = new Image(); | |
| sampleImage.onload = function() { | |
| originalImage = sampleImage; | |
| resetCanvas(); | |
| applyDithering(); | |
| }; | |
| sampleImage.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MDAiIGhlaWdodD0iNjAwIiB2aWV3Qm94PSIwIDAgODAwIDYwMCI+PHJlY3Qgd2lkdGg9IjgwMCIgaGVpZ2h0PSI2MDAiIGZpbGw9IiNmNWY1ZjUiLz48Y2lyY2xlIGN4PSI0MDAiIGN5PSIzMDAiIHI9IjI1MCIgZmlsbD0iI2Q0ZDVkNCIvPjxjaXJjbGUgY3g9IjMwMCIgY3k9IjIwMCIgcj0iNTAiIGZpbGw9IiNhYWEiLz48Y2lyY2xlIGN4PSI1MDAiIGN5PSIyMDAiIHI9IjUwIiBmaWxsPSIjYWFhIi8+PGNpcmNsZSBjeD0iMzUwIiBjeT0iMjUwIiByPSIxMDAiIGZpbGw9IiNhYWEiLz48Y2lyY2xlIGN4PSI0NTAiIGN5PSIyNTAiIHI9IjEwMCIgZmlsbD0iI2FhYSIvPjxwYXRoIGQ9Ik0zMDAgNDAwIHEgMTAwIDEwMCAyMDAgMCIgc3Ryb2tlPSIjYWFhIiBzdHJva2Utd2lkdGg9IjEwIiBmaWxsPSJub25lIi8+PC9zdmc+'; | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=pjayofficial/image-dither" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body> | |
| </html> |