Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Stencil Overlay Tool</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| /* Checkerboard background for transparency visualization */ | |
| .checkerboard { | |
| background-color: #e5e5f7; | |
| background-image: repeating-linear-gradient(45deg, #d5d5f2 25%, transparent 25%, transparent 75%, #d5d5f2 75%, #d5d5f2), repeating-linear-gradient(45deg, #d5d5f2 25%, #e5e5f7 25%, #e5e5f7 75%, #d5d5f2 75%, #d5d5f2); | |
| background-position: 0 0, 10px 10px; | |
| background-size: 20px 20px; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen p-8 text-gray-800"> | |
| <div class="max-w-5xl mx-auto space-y-6"> | |
| <!-- Header --> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-200"> | |
| <h1 class="text-2xl font-bold mb-2">Stencil Overlay Composer</h1> | |
| <p class="text-sm text-gray-600">Upload a background image and a stencil. The stencil will have its white background removed using luminance-to-alpha conversion.</p> | |
| </div> | |
| <!-- Controls --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <!-- Uploads --> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-200 space-y-4"> | |
| <h2 class="font-semibold text-lg border-b pb-2">1. Upload Images</h2> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Base Image (Background)</label> | |
| <input type="file" id="baseInput" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"/> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Stencil Image (Overlay)</label> | |
| <input type="file" id="stencilInput" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"/> | |
| </div> | |
| </div> | |
| <!-- Settings --> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-200 space-y-4"> | |
| <h2 class="font-semibold text-lg border-b pb-2">2. Adjustments</h2> | |
| <div> | |
| <div class="flex justify-between"> | |
| <label for="color" class="block text-sm font-medium text-gray-700">Overlay Color</label> | |
| <span id="colorVal" class="text-sm text-gray-500">#ff0000</span> | |
| </div> | |
| <input type="color" id="color" value="#ff0000" class="w-full h-8 mt-1 rounded cursor-pointer"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between"> | |
| <label for="gamma" class="block text-sm font-medium text-gray-700">Gamma (Line Thickness)</label> | |
| <span id="gammaVal" class="text-sm text-gray-500">0.9</span> | |
| </div> | |
| <input type="range" id="gamma" min="0.1" max="3.0" step="0.1" value="0.9" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
| <p class="text-xs text-gray-400 mt-1">< 1 thickens lines, > 1 thins them.</p> | |
| </div> | |
| <div> | |
| <div class="flex justify-between"> | |
| <label for="opacity" class="block text-sm font-medium text-gray-700">Stencil Global Opacity</label> | |
| <span id="opacityVal" class="text-sm text-gray-500">100%</span> | |
| </div> | |
| <input type="range" id="opacity" min="0" max="100" step="1" value="100" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Preview Area --> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-200 flex flex-col items-center"> | |
| <h2 class="font-semibold text-lg border-b pb-4 w-full mb-4">Result</h2> | |
| <!-- Dimension Info & Warning --> | |
| <div id="imageInfo" class="hidden w-full flex-col items-center mb-4 text-sm bg-gray-50 p-3 rounded-lg border border-gray-200"> | |
| <div class="flex space-x-6 text-gray-600"> | |
| <p>Background: <span id="bgDims" class="font-mono font-medium">N/A</span></p> | |
| <p>Stencil: <span id="stencilDims" class="font-mono font-medium">N/A</span></p> | |
| </div> | |
| <p id="dimWarning" class="hidden mt-2 text-red-600 font-bold bg-red-50 px-3 py-1 rounded border border-red-200"> | |
| ⚠️ Warning: Image dimensions do not match. The stencil will be stretched. | |
| </p> | |
| </div> | |
| <div id="canvasContainer" class="checkerboard rounded shadow-inner overflow-hidden max-w-full"> | |
| <canvas id="mainCanvas" class="max-w-full h-auto"></canvas> | |
| </div> | |
| <div id="placeholder" class="py-12 text-gray-400 italic"> | |
| Upload both images to see the result | |
| </div> | |
| <button id="downloadBtn" class="mt-4 hidden bg-gray-800 text-white px-6 py-2 rounded-lg hover:bg-black transition-colors"> | |
| Download Result | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| // State | |
| const state = { | |
| baseImage: null, | |
| stencilImage: null, | |
| gamma: 0.9, | |
| opacity: 1.0, | |
| color: '#ff0000' | |
| }; | |
| // DOM Elements | |
| const baseInput = document.getElementById('baseInput'); | |
| const stencilInput = document.getElementById('stencilInput'); | |
| const colorPicker = document.getElementById('color'); | |
| const colorVal = document.getElementById('colorVal'); | |
| const gammaSlider = document.getElementById('gamma'); | |
| const gammaVal = document.getElementById('gammaVal'); | |
| const opacitySlider = document.getElementById('opacity'); | |
| const opacityVal = document.getElementById('opacityVal'); | |
| const canvas = document.getElementById('mainCanvas'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const downloadBtn = document.getElementById('downloadBtn'); | |
| const placeholder = document.getElementById('placeholder'); | |
| const canvasContainer = document.getElementById('canvasContainer'); | |
| // New DOM Elements for Dimensions | |
| const imageInfo = document.getElementById('imageInfo'); | |
| const bgDims = document.getElementById('bgDims'); | |
| const stencilDims = document.getElementById('stencilDims'); | |
| const dimWarning = document.getElementById('dimWarning'); | |
| // Event Listeners | |
| baseInput.addEventListener('change', (e) => handleImageUpload(e, 'baseImage')); | |
| stencilInput.addEventListener('change', (e) => handleImageUpload(e, 'stencilImage')); | |
| colorPicker.addEventListener('input', (e) => { | |
| state.color = e.target.value; | |
| colorVal.textContent = state.color; | |
| render(); | |
| }); | |
| gammaSlider.addEventListener('input', (e) => { | |
| state.gamma = parseFloat(e.target.value); | |
| gammaVal.textContent = state.gamma; | |
| render(); | |
| }); | |
| opacitySlider.addEventListener('input', (e) => { | |
| state.opacity = parseInt(e.target.value) / 100; | |
| opacityVal.textContent = e.target.value + '%'; | |
| render(); | |
| }); | |
| downloadBtn.addEventListener('click', () => { | |
| const link = document.createElement('a'); | |
| link.download = 'stencil-overlay.png'; | |
| link.href = canvas.toDataURL(); | |
| link.click(); | |
| }); | |
| // Image Loading Logic | |
| function handleImageUpload(event, key) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| state[key] = img; | |
| render(); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // Helper to convert Hex to RGB | |
| function hexToRgb(hex) { | |
| const bigint = parseInt(hex.slice(1), 16); | |
| return { | |
| r: (bigint >> 16) & 255, | |
| g: (bigint >> 8) & 255, | |
| b: bigint & 255 | |
| }; | |
| } | |
| // Logic Ported from the provided Node.js snippet | |
| function processStencil(img, width, height, gamma, hexColor) { | |
| // Create a temporary canvas to read pixel data | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = width; | |
| tempCanvas.height = height; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| // Draw the raw stencil image | |
| tempCtx.drawImage(img, 0, 0, width, height); | |
| // Get raw pixel data | |
| const imageData = tempCtx.getImageData(0, 0, width, height); | |
| const data = imageData.data; | |
| // Get target RGB values | |
| const targetColor = hexToRgb(hexColor); | |
| // --- CORE ALGORITHM START --- | |
| // Adapted from sharp logic to Canvas API | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const a = data[i + 3]; | |
| // Calculate luminance (perceived brightness) | |
| const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; | |
| // Convert luminance to alpha: bright = transparent, dark = opaque | |
| // Gamma curve allows fine-tuning | |
| const alphaFactor = 1 - Math.pow(luminance, gamma); | |
| const alpha = Math.round(alphaFactor * (a / 255) * 255); | |
| // Apply the target RGB color instead of clearing it to black | |
| data[i] = targetColor.r; | |
| data[i + 1] = targetColor.g; | |
| data[i + 2] = targetColor.b; | |
| data[i + 3] = alpha; | |
| } | |
| // --- CORE ALGORITHM END --- | |
| tempCtx.putImageData(imageData, 0, 0); | |
| return tempCanvas; | |
| } | |
| // Main Render Loop | |
| function render() { | |
| // Update Dimension Text & Validation | |
| if (state.baseImage || state.stencilImage) { | |
| imageInfo.classList.remove('hidden'); | |
| imageInfo.classList.add('flex'); | |
| if (state.baseImage) bgDims.textContent = `${state.baseImage.width}x${state.baseImage.height}px`; | |
| if (state.stencilImage) stencilDims.textContent = `${state.stencilImage.width}x${state.stencilImage.height}px`; | |
| // Check for mismatch | |
| if (state.baseImage && state.stencilImage) { | |
| if (state.baseImage.width !== state.stencilImage.width || state.baseImage.height !== state.stencilImage.height) { | |
| dimWarning.classList.remove('hidden'); | |
| } else { | |
| dimWarning.classList.add('hidden'); | |
| } | |
| } | |
| } | |
| if (!state.baseImage) return; | |
| // Hide placeholder, show canvas/button | |
| placeholder.style.display = 'none'; | |
| canvas.style.display = 'block'; | |
| // Set canvas size to match base image | |
| canvas.width = state.baseImage.width; | |
| canvas.height = state.baseImage.height; | |
| // 1. Draw Base Image | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(state.baseImage, 0, 0); | |
| if (state.stencilImage) { | |
| // 2. Process Stencil (Remove Background) | |
| const processedStencil = processStencil( | |
| state.stencilImage, | |
| canvas.width, | |
| canvas.height, | |
| state.gamma, | |
| state.color | |
| ); | |
| // 3. Draw Processed Stencil Overlay | |
| ctx.globalAlpha = state.opacity; | |
| ctx.drawImage(processedStencil, 0, 0); | |
| ctx.globalAlpha = 1.0; // Reset | |
| downloadBtn.classList.remove('hidden'); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |