|
|
<!DOCTYPE html> |
|
|
<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; |
|
|
} |
|
|
.animate-btn.active { |
|
|
background-color: #10b981; |
|
|
color: white; |
|
|
} |
|
|
@keyframes pulse { |
|
|
0% { transform: scale(1); } |
|
|
50% { transform: scale(1.05); } |
|
|
100% { transform: scale(1); } |
|
|
} |
|
|
.pulse { |
|
|
animation: pulse 1.5s 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">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"> |
|
|
|
|
|
<div class="bg-white p-6 rounded-xl shadow-md"> |
|
|
<h2 class="text-xl font-semibold text-gray-800 mb-4">Controls</h2> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div class="mb-6 p-4 bg-gray-50 rounded-lg"> |
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Animation</label> |
|
|
<div class="flex items-center space-x-4"> |
|
|
<button id="animateBtn" class="animate-btn py-2 px-4 rounded-md bg-gray-100 hover:bg-gray-200 transition flex items-center"> |
|
|
<i class="fas fa-play mr-2"></i> Animate Threshold |
|
|
</button> |
|
|
<div class="flex-1"> |
|
|
<label for="speedSlider" class="block text-xs text-gray-500 mb-1">Speed</label> |
|
|
<input type="range" id="speedSlider" min="1" max="10" value="5" class="range-slider"> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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; |
|
|
let isAnimating = false; |
|
|
let animationId = null; |
|
|
let animationSpeed = 5; |
|
|
let animationDirection = 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'); |
|
|
const animateBtn = document.getElementById('animateBtn'); |
|
|
const speedSlider = document.getElementById('speedSlider'); |
|
|
|
|
|
// 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); |
|
|
}); |
|
|
}); |
|
|
|
|
|
animateBtn.addEventListener('click', toggleAnimation); |
|
|
|
|
|
speedSlider.addEventListener('input', () => { |
|
|
animationSpeed = parseInt(speedSlider.value); |
|
|
}); |
|
|
|
|
|
// 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(); |
|
|
} |
|
|
|
|
|
function toggleAnimation() { |
|
|
if (isAnimating) { |
|
|
stopAnimation(); |
|
|
} else { |
|
|
startAnimation(); |
|
|
} |
|
|
} |
|
|
|
|
|
function startAnimation() { |
|
|
if (!originalImage) { |
|
|
alert('Please upload an image first!'); |
|
|
return; |
|
|
} |
|
|
|
|
|
isAnimating = true; |
|
|
animateBtn.classList.add('active', 'pulse'); |
|
|
animateBtn.innerHTML = '<i class="fas fa-stop mr-2"></i> Stop Animation'; |
|
|
|
|
|
// Start animation loop |
|
|
animateThreshold(); |
|
|
} |
|
|
|
|
|
function stopAnimation() { |
|
|
isAnimating = false; |
|
|
cancelAnimationFrame(animationId); |
|
|
animateBtn.classList.remove('active', 'pulse'); |
|
|
animateBtn.innerHTML = '<i class="fas fa-play mr-2"></i> Animate Threshold'; |
|
|
} |
|
|
|
|
|
function animateThreshold() { |
|
|
if (!isAnimating) return; |
|
|
|
|
|
// Update threshold value |
|
|
threshold += animationDirection * (animationSpeed / 2); |
|
|
|
|
|
// Reverse direction at boundaries |
|
|
if (threshold >= 255) { |
|
|
threshold = 255; |
|
|
animationDirection = -1; |
|
|
} else if (threshold <= 0) { |
|
|
threshold = 0; |
|
|
animationDirection = 1; |
|
|
} |
|
|
|
|
|
// Update UI |
|
|
thresholdSlider.value = threshold; |
|
|
|
|
|
// Apply dithering with new threshold |
|
|
applyDithering(); |
|
|
|
|
|
// Continue animation |
|
|
animationId = requestAnimationFrame(animateThreshold); |
|
|
} |
|
|
|
|
|
// 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/asasad" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body> |
|
|
</html> |