Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Animated 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; | |
| } | |
| .animation-preview { | |
| width: 100%; | |
| height: 60px; | |
| background: repeating-linear-gradient( | |
| 45deg, | |
| #f8fafc, | |
| #f8fafc 10px, | |
| #e2e8f0 10px, | |
| #e2e8f0 20px | |
| ); | |
| border-radius: 0.5rem; | |
| margin-top: 0.5rem; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .animation-indicator { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 20%; | |
| height: 100%; | |
| background-color: rgba(79, 70, 229, 0.3); | |
| border-radius: 0.5rem; | |
| transition: left 0.5s ease; | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 0.5; } | |
| 50% { opacity: 1; } | |
| 100% { opacity: 0.5; } | |
| } | |
| .pulse { | |
| animation: pulse 2s infinite; | |
| } | |
| </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">Animated Pixel Art Editor</h1> | |
| <p class="text-gray-600 max-w-2xl mx-auto">Transform your images with customizable dithering and animation effects. Create dynamic pixel art with ease.</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> | |
| <!-- Animation Controls --> | |
| <div class="mb-6 border-t pt-4"> | |
| <h3 class="text-lg font-medium text-gray-800 mb-3 flex items-center"> | |
| <i class="fas fa-play-circle mr-2 text-indigo-600"></i> Animation Effects | |
| </h3> | |
| <div class="mb-4"> | |
| <label for="animationType" class="block text-sm font-medium text-gray-700 mb-1">Animation Type</label> | |
| <select id="animationType" class="w-full p-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"> | |
| <option value="none">None</option> | |
| <option value="thresholdPulse">Threshold Pulse</option> | |
| <option value="colorCycle">Color Cycle</option> | |
| <option value="pixelShift">Pixel Shift</option> | |
| <option value="scanlines">Scanlines</option> | |
| </select> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="animationSpeed" class="block text-sm font-medium text-gray-700 mb-1">Animation Speed</label> | |
| <input type="range" id="animationSpeed" min="1" max="10" value="5" class="range-slider"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>Slow</span> | |
| <span>Medium</span> | |
| <span>Fast</span> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="animationIntensity" class="block text-sm font-medium text-gray-700 mb-1">Animation Intensity</label> | |
| <input type="range" id="animationIntensity" min="1" max="100" value="50" class="range-slider"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>Subtle</span> | |
| <span>Medium</span> | |
| <span>Strong</span> | |
| </div> | |
| </div> | |
| <div class="animation-preview"> | |
| <div id="animationIndicator" class="animation-indicator"></div> | |
| </div> | |
| </div> | |
| <!-- Action Buttons --> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <button id="previewBtn" class="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-play mr-2"></i> Preview | |
| </button> | |
| <button id="downloadBtn" class="py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-medium rounded-md transition flex items-center justify-center"> | |
| <i class="fas fa-download mr-2"></i> Download | |
| </button> | |
| </div> | |
| </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> | |
| <button data-preset="glitch" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-bolt mr-2 text-pink-500"></i> Glitch Effect | |
| </button> | |
| <button data-preset="cyberpunk" class="preset-btn py-2 px-3 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> | |
| <i class="fas fa-robot mr-2 text-cyan-500"></i> Cyberpunk | |
| </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; | |
| let animationType = 'none'; | |
| let animationSpeed = 5; | |
| let animationIntensity = 50; | |
| let animationInterval = null; | |
| let animationFrame = 0; | |
| let isAnimating = false; | |
| // 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 animationTypeSelect = document.getElementById('animationType'); | |
| const animationSpeedSlider = document.getElementById('animationSpeed'); | |
| const animationIntensitySlider = document.getElementById('animationIntensity'); | |
| const animationIndicator = document.getElementById('animationIndicator'); | |
| const previewBtn = document.getElementById('previewBtn'); | |
| 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(); | |
| }); | |
| animationTypeSelect.addEventListener('change', () => { | |
| animationType = animationTypeSelect.value; | |
| updateAnimationPreview(); | |
| if (isAnimating) { | |
| stopAnimation(); | |
| startAnimation(); | |
| } | |
| }); | |
| animationSpeedSlider.addEventListener('input', () => { | |
| animationSpeed = parseInt(animationSpeedSlider.value); | |
| updateAnimationPreview(); | |
| if (isAnimating) { | |
| stopAnimation(); | |
| startAnimation(); | |
| } | |
| }); | |
| animationIntensitySlider.addEventListener('input', () => { | |
| animationIntensity = parseInt(animationIntensitySlider.value); | |
| if (isAnimating) { | |
| stopAnimation(); | |
| startAnimation(); | |
| } | |
| }); | |
| previewBtn.addEventListener('click', toggleAnimation); | |
| 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'); | |
| // Animation preview indicator | |
| function updateAnimationPreview() { | |
| if (animationType === 'none') { | |
| animationIndicator.style.display = 'none'; | |
| return; | |
| } | |
| animationIndicator.style.display = 'block'; | |
| const speed = (11 - animationSpeed) * 100; // Invert speed for timing | |
| let animationName; | |
| switch (animationType) { | |
| case 'thresholdPulse': | |
| animationName = 'pulse'; | |
| break; | |
| case 'colorCycle': | |
| animationName = 'color-cycle'; | |
| break; | |
| case 'pixelShift': | |
| animationName = 'pixel-shift'; | |
| break; | |
| case 'scanlines': | |
| animationName = 'scanlines'; | |
| break; | |
| default: | |
| animationName = 'move'; | |
| } | |
| // Update indicator position based on animation type | |
| if (animationType === 'thresholdPulse') { | |
| animationIndicator.style.width = '100%'; | |
| animationIndicator.style.left = '0'; | |
| animationIndicator.style.animation = `pulse ${speed}ms infinite`; | |
| } else { | |
| animationIndicator.style.width = '20%'; | |
| animationIndicator.style.animation = 'none'; | |
| animationIndicator.style.transition = `left ${speed}ms linear`; | |
| animationIndicator.style.left = '0'; | |
| setTimeout(() => { | |
| animationIndicator.style.left = '80%'; | |
| }, 10); | |
| const interval = setInterval(() => { | |
| animationIndicator.style.left = '0'; | |
| setTimeout(() => { | |
| animationIndicator.style.left = '80%'; | |
| }, 10); | |
| }, speed); | |
| // Store interval ID to clear later | |
| animationIndicator.dataset.interval = interval; | |
| } | |
| } | |
| // Clear animation preview when changing types | |
| animationTypeSelect.addEventListener('focus', () => { | |
| const interval = animationIndicator.dataset.interval; | |
| if (interval) clearInterval(interval); | |
| }); | |
| // 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, pixelated | |
| </html> |