Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>图片色卡提取工具</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> | |
| .color-card { | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .color-card:hover { | |
| transform: translateY(-5px); | |
| box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); | |
| } | |
| .color-value { | |
| font-family: 'Courier New', monospace; | |
| cursor: pointer; | |
| } | |
| .copied-message { | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| } | |
| .show-copied { | |
| opacity: 1; | |
| } | |
| .color-preview { | |
| height: 120px; | |
| border-top-left-radius: 0.5rem; | |
| border-top-right-radius: 0.5rem; | |
| } | |
| #imagePreview { | |
| max-height: 400px; | |
| max-width: 100%; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| #dropZone { | |
| border: 2px dashed #cbd5e0; | |
| transition: all 0.3s ease; | |
| } | |
| #dropZone.drag-over { | |
| border-color: #4299e1; | |
| background-color: #ebf8ff; | |
| } | |
| .progress-bar { | |
| height: 6px; | |
| background-color: #e2e8f0; | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background-color: #4299e1; | |
| transition: width 0.3s ease; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-8 text-center"> | |
| <h1 class="text-4xl font-bold text-gray-800 mb-2">图片色卡提取工具</h1> | |
| <p class="text-gray-600 max-w-2xl mx-auto">上传图片自动提取主要颜色,生成美观的色卡</p> | |
| </header> | |
| <div class="bg-white rounded-xl shadow-md p-6 mb-8"> | |
| <div id="dropZone" class="flex flex-col items-center justify-center p-8 rounded-lg mb-4"> | |
| <i class="fas fa-cloud-upload-alt text-4xl text-blue-500 mb-3"></i> | |
| <p class="text-gray-700 mb-2">拖放图片到此处或</p> | |
| <label for="imageUpload" class="cursor-pointer bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg font-medium transition-colors inline-block"> | |
| <i class="fas fa-folder-open mr-2"></i>选择图片 | |
| </label> | |
| <input type="file" id="imageUpload" accept="image/*" class="hidden"> | |
| <p class="text-gray-500 text-sm mt-3">支持 JPG, PNG, GIF 等格式</p> | |
| </div> | |
| <div class="flex items-center mb-4 hidden" id="progressContainer"> | |
| <div class="progress-bar flex-1 mr-2"> | |
| <div class="progress-fill" id="progressFill" style="width: 0%"></div> | |
| </div> | |
| <span class="text-sm text-gray-600" id="progressText">0%</span> | |
| </div> | |
| <div class="flex flex-col md:flex-row gap-6"> | |
| <div class="flex-1"> | |
| <h3 class="text-lg font-semibold mb-3 text-gray-800">图片预览</h3> | |
| <div class="flex items-center justify-center bg-gray-100 rounded-lg p-4 min-h-48"> | |
| <img id="imagePreview" class="hidden" alt="图片预览"> | |
| <p id="noImageText" class="text-gray-500">图片将显示在这里</p> | |
| </div> | |
| </div> | |
| <div class="flex-1"> | |
| <h3 class="text-lg font-semibold mb-3 text-gray-800">提取设置</h3> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="colorCount" class="block text-sm font-medium text-gray-700 mb-1">提取颜色数量</label> | |
| <select id="colorCount" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="5">5种</option> | |
| <option value="8" selected>8种</option> | |
| <option value="12">12种</option> | |
| <option value="16">16种</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="algorithm" class="block text-sm font-medium text-gray-700 mb-1">提取算法</label> | |
| <select id="algorithm" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="kmeans">K-means聚类</option> | |
| <option value="medianCut" selected>中值切割</option> | |
| <option value="simple">简单采样</option> | |
| </select> | |
| </div> | |
| <div class="flex items-center"> | |
| <input type="checkbox" id="ignoreWhite" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> | |
| <label for="ignoreWhite" class="ml-2 block text-sm text-gray-700">忽略白色/接近白色</label> | |
| </div> | |
| <div class="flex items-center"> | |
| <input type="checkbox" id="ignoreBlack" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> | |
| <label for="ignoreBlack" class="ml-2 block text-sm text-gray-700">忽略黑色/接近黑色</label> | |
| </div> | |
| <button id="extractBtn" class="w-full bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center disabled:opacity-50" disabled> | |
| <i class="fas fa-palette mr-2"></i>提取颜色 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="resultsSection" class="hidden"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-2xl font-bold text-gray-800">提取的颜色</h2> | |
| <div class="flex gap-2"> | |
| <button id="copyAllBtn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors"> | |
| <i class="fas fa-copy mr-2"></i>复制全部 | |
| </button> | |
| <button id="exportBtn" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors"> | |
| <i class="fas fa-file-export mr-2"></i>导出色卡 | |
| </button> | |
| </div> | |
| </div> | |
| <div id="colorGrid" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> | |
| <!-- 颜色卡片将在这里生成 --> | |
| </div> | |
| </div> | |
| <div id="toast" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg hidden"> | |
| 颜色已复制到剪贴板! | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const dropZone = document.getElementById('dropZone'); | |
| const imageUpload = document.getElementById('imageUpload'); | |
| const imagePreview = document.getElementById('imagePreview'); | |
| const noImageText = document.getElementById('noImageText'); | |
| const extractBtn = document.getElementById('extractBtn'); | |
| const colorGrid = document.getElementById('colorGrid'); | |
| const resultsSection = document.getElementById('resultsSection'); | |
| const colorCount = document.getElementById('colorCount'); | |
| const algorithm = document.getElementById('algorithm'); | |
| const ignoreWhite = document.getElementById('ignoreWhite'); | |
| const ignoreBlack = document.getElementById('ignoreBlack'); | |
| const copyAllBtn = document.getElementById('copyAllBtn'); | |
| const exportBtn = document.getElementById('exportBtn'); | |
| const toast = document.getElementById('toast'); | |
| const progressContainer = document.getElementById('progressContainer'); | |
| const progressFill = document.getElementById('progressFill'); | |
| const progressText = document.getElementById('progressText'); | |
| let uploadedImage = null; | |
| let extractedColors = []; | |
| // 拖放功能 | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, preventDefaults, false); | |
| }); | |
| function preventDefaults(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, highlight, false); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| dropZone.addEventListener(eventName, unhighlight, false); | |
| }); | |
| function highlight() { | |
| dropZone.classList.add('drag-over'); | |
| } | |
| function unhighlight() { | |
| dropZone.classList.remove('drag-over'); | |
| } | |
| dropZone.addEventListener('drop', handleDrop, false); | |
| function handleDrop(e) { | |
| const dt = e.dataTransfer; | |
| const files = dt.files; | |
| if (files.length > 0 && files[0].type.match('image.*')) { | |
| handleImageUpload(files[0]); | |
| } | |
| } | |
| // 文件上传处理 | |
| imageUpload.addEventListener('change', function() { | |
| if (this.files && this.files[0]) { | |
| handleImageUpload(this.files[0]); | |
| } | |
| }); | |
| function handleImageUpload(file) { | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| imagePreview.src = e.target.result; | |
| imagePreview.classList.remove('hidden'); | |
| noImageText.classList.add('hidden'); | |
| // 创建新的Image对象以便后续处理 | |
| uploadedImage = new Image(); | |
| uploadedImage.onload = function() { | |
| extractBtn.disabled = false; | |
| }; | |
| uploadedImage.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // 提取颜色按钮点击事件 | |
| extractBtn.addEventListener('click', function() { | |
| if (!uploadedImage) return; | |
| const count = parseInt(colorCount.value); | |
| const algo = algorithm.value; | |
| const ignoreW = ignoreWhite.checked; | |
| const ignoreB = ignoreBlack.checked; | |
| progressContainer.classList.remove('hidden'); | |
| progressFill.style.width = '0%'; | |
| progressText.textContent = '0%'; | |
| // 使用setTimeout让UI有时间更新 | |
| setTimeout(() => { | |
| extractedColors = extractColorsFromImage(uploadedImage, count, algo, ignoreW, ignoreB); | |
| displayColorCards(extractedColors); | |
| resultsSection.classList.remove('hidden'); | |
| progressContainer.classList.add('hidden'); | |
| }, 100); | |
| }); | |
| // 从图片中提取颜色的函数 | |
| function extractColorsFromImage(image, colorCount, algorithm, ignoreWhite, ignoreBlack) { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const width = Math.min(image.width, 200); // 缩小尺寸以提高性能 | |
| const height = Math.min(image.height, 200); | |
| canvas.width = width; | |
| canvas.height = height; | |
| // 绘制图片到canvas | |
| ctx.drawImage(image, 0, 0, width, height); | |
| // 获取像素数据 | |
| const imageData = ctx.getImageData(0, 0, width, height).data; | |
| const pixels = []; | |
| // 收集所有像素颜色 | |
| for (let i = 0; i < imageData.length; i += 4) { | |
| const r = imageData[i]; | |
| const g = imageData[i + 1]; | |
| const b = imageData[i + 2]; | |
| const a = imageData[i + 3]; | |
| // 忽略透明度低的像素 | |
| if (a < 128) continue; | |
| // 检查是否忽略白色/黑色 | |
| if (ignoreWhite && isNearWhite(r, g, b)) continue; | |
| if (ignoreBlack && isNearBlack(r, g, b)) continue; | |
| pixels.push([r, g, b]); | |
| // 更新进度 | |
| if (i % 400 === 0) { | |
| const progress = Math.min(100, Math.floor((i / imageData.length) * 100)); | |
| progressFill.style.width = `${progress}%`; | |
| progressText.textContent = `${progress}%`; | |
| } | |
| } | |
| // 根据选择的算法提取主要颜色 | |
| let colors; | |
| switch (algorithm) { | |
| case 'kmeans': | |
| colors = kMeansClustering(pixels, colorCount); | |
| break; | |
| case 'medianCut': | |
| colors = medianCut(pixels, colorCount); | |
| break; | |
| case 'simple': | |
| colors = simpleSampling(pixels, colorCount); | |
| break; | |
| default: | |
| colors = medianCut(pixels, colorCount); | |
| } | |
| // 转换为十六进制并命名 | |
| return colors.map(color => { | |
| const hex = rgbToHex(color[0], color[1], color[2]); | |
| return { | |
| name: `Color ${hex}`, | |
| value: hex | |
| }; | |
| }); | |
| } | |
| // 辅助函数:判断是否接近白色 | |
| function isNearWhite(r, g, b, threshold = 220) { | |
| return r > threshold && g > threshold && b > threshold; | |
| } | |
| // 辅助函数:判断是否接近黑色 | |
| function isNearBlack(r, g, b, threshold = 30) { | |
| return r < threshold && g < threshold && b < threshold; | |
| } | |
| // RGB转十六进制 | |
| function rgbToHex(r, g, b) { | |
| return '#' + [r, g, b].map(x => { | |
| const hex = x.toString(16); | |
| return hex.length === 1 ? '0' + hex : hex; | |
| }).join(''); | |
| } | |
| // 简单采样算法 | |
| function simpleSampling(pixels, count) { | |
| const step = Math.floor(pixels.length / count); | |
| const colors = []; | |
| for (let i = 0; i < pixels.length && colors.length < count; i += step) { | |
| colors.push(pixels[i]); | |
| } | |
| return colors; | |
| } | |
| // 中值切割算法 | |
| function medianCut(pixels, count) { | |
| let buckets = [pixels]; | |
| while (buckets.length < count) { | |
| const newBuckets = []; | |
| for (const bucket of buckets) { | |
| if (bucket.length === 0) continue; | |
| // 找到颜色范围最大的通道 | |
| let rMin = 255, rMax = 0; | |
| let gMin = 255, gMax = 0; | |
| let bMin = 255, bMax = 0; | |
| for (const [r, g, b] of bucket) { | |
| rMin = Math.min(rMin, r); | |
| rMax = Math.max(rMax, r); | |
| gMin = Math.min(gMin, g); | |
| gMax = Math.max(gMax, g); | |
| bMin = Math.min(bMin, b); | |
| bMax = Math.max(bMax, b); | |
| } | |
| const rRange = rMax - rMin; | |
| const gRange = gMax - gMin; | |
| const bRange = bMax - bMin; | |
| const maxRange = Math.max(rRange, gRange, bRange); | |
| const channel = maxRange === rRange ? 0 : (maxRange === gRange ? 1 : 2); | |
| // 按选定通道排序 | |
| bucket.sort((a, b) => a[channel] - b[channel]); | |
| // 找到中值并分割 | |
| const medianIndex = Math.floor(bucket.length / 2); | |
| newBuckets.push(bucket.slice(0, medianIndex)); | |
| newBuckets.push(bucket.slice(medianIndex)); | |
| } | |
| buckets = newBuckets; | |
| if (buckets.length >= count) break; | |
| } | |
| // 计算每个桶的平均颜色 | |
| return buckets.map(bucket => { | |
| if (bucket.length === 0) return [0, 0, 0]; | |
| let rSum = 0, gSum = 0, bSum = 0; | |
| for (const [r, g, b] of bucket) { | |
| rSum += r; | |
| gSum += g; | |
| bSum += b; | |
| } | |
| return [ | |
| Math.round(rSum / bucket.length), | |
| Math.round(gSum / bucket.length), | |
| Math.round(bSum / bucket.length) | |
| ]; | |
| }); | |
| } | |
| // K-means聚类算法 | |
| function kMeansClustering(pixels, k, maxIterations = 10) { | |
| // 随机选择初始中心点 | |
| let centroids = []; | |
| const step = Math.floor(pixels.length / k); | |
| for (let i = 0; i < k; i++) { | |
| centroids.push(pixels[i * step]); | |
| } | |
| let clusters; | |
| for (let iter = 0; iter < maxIterations; iter++) { | |
| // 分配每个点到最近的中心点 | |
| clusters = Array(k).fill().map(() => []); | |
| for (const pixel of pixels) { | |
| let minDist = Infinity; | |
| let closestCentroid = 0; | |
| for (let i = 0; i < centroids.length; i++) { | |
| const dist = colorDistance(pixel, centroids[i]); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| closestCentroid = i; | |
| } | |
| } | |
| clusters[closestCentroid].push(pixel); | |
| } | |
| // 计算新的中心点 | |
| const newCentroids = []; | |
| for (const cluster of clusters) { | |
| if (cluster.length === 0) { | |
| // 如果集群为空,保留旧的中心点 | |
| newCentroids.push(centroids[newCentroids.length]); | |
| continue; | |
| } | |
| let rSum = 0, gSum = 0, bSum = 0; | |
| for (const [r, g, b] of cluster) { | |
| rSum += r; | |
| gSum += g; | |
| bSum += b; | |
| } | |
| newCentroids.push([ | |
| Math.round(rSum / cluster.length), | |
| Math.round(gSum / cluster.length), | |
| Math.round(bSum / cluster.length) | |
| ]); | |
| } | |
| // 检查是否收敛 | |
| let converged = true; | |
| for (let i = 0; i < centroids.length; i++) { | |
| if (colorDistance(centroids[i], newCentroids[i]) > 5) { | |
| converged = false; | |
| break; | |
| } | |
| } | |
| centroids = newCentroids; | |
| if (converged) break; | |
| } | |
| return centroids; | |
| } | |
| // 计算两个颜色之间的距离 | |
| function colorDistance(c1, c2) { | |
| const dr = c1[0] - c2[0]; | |
| const dg = c1[1] - c2[1]; | |
| const db = c1[2] - c2[2]; | |
| return Math.sqrt(dr * dr + dg * dg + db * db); | |
| } | |
| // 显示颜色卡片 | |
| function displayColorCards(colors) { | |
| colorGrid.innerHTML = ''; | |
| colors.forEach(color => { | |
| const colorCard = document.createElement('div'); | |
| colorCard.className = 'color-card bg-white rounded-lg overflow-hidden'; | |
| // 计算文本颜色基于背景亮度 | |
| const hex = color.value.replace('#', ''); | |
| const r = parseInt(hex.substring(0, 2), 16); | |
| const g = parseInt(hex.substring(2, 4), 16); | |
| const b = parseInt(hex.substring(4, 6), 16); | |
| const brightness = (r * 299 + g * 587 + b * 114) / 1000; | |
| const textColor = brightness > 128 ? 'text-gray-900' : 'text-white'; | |
| colorCard.innerHTML = ` | |
| <div class="color-preview" style="background-color: ${color.value};"></div> | |
| <div class="p-4"> | |
| <h3 class="font-semibold text-lg mb-1">${color.name}</h3> | |
| <div class="flex items-center justify-between mt-2"> | |
| <span class="color-value ${textColor} bg-black bg-opacity-20 px-2 py-1 rounded" data-value="${color.value}">${color.value}</span> | |
| <div class="copied-message text-sm text-gray-600">已复制!</div> | |
| </div> | |
| </div> | |
| `; | |
| colorGrid.appendChild(colorCard); | |
| }); | |
| // 添加点击事件到所有颜色值 | |
| document.querySelectorAll('.color-value').forEach(el => { | |
| el.addEventListener('click', function() { | |
| const colorValue = this.getAttribute('data-value'); | |
| navigator.clipboard.writeText(colorValue).then(() => { | |
| // 显示通知 | |
| toast.classList.remove('hidden'); | |
| setTimeout(() => { | |
| toast.classList.add('hidden'); | |
| }, 2000); | |
| // 显示特定卡片上的复制消息 | |
| const copiedMsg = this.nextElementSibling; | |
| copiedMsg.classList.add('show-copied'); | |
| setTimeout(() => { | |
| copiedMsg.classList.remove('show-copied'); | |
| }, 1000); | |
| }); | |
| }); | |
| }); | |
| } | |
| // 复制全部按钮 | |
| copyAllBtn.addEventListener('click', function() { | |
| const allColors = extractedColors.map(c => c.value).join('\n'); | |
| navigator.clipboard.writeText(allColors).then(() => { | |
| toast.textContent = '所有颜色已复制到剪贴板!'; | |
| toast.classList.remove('hidden'); | |
| setTimeout(() => { | |
| toast.classList.add('hidden'); | |
| }, 2000); | |
| }); | |
| }); | |
| // 导出色卡按钮 | |
| exportBtn.addEventListener('click', function() { | |
| if (!extractedColors.length) return; | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const cardWidth = 200; | |
| const cardHeight = 150; | |
| const margin = 10; | |
| // 计算画布大小 | |
| const cols = Math.min(4, extractedColors.length); | |
| const rows = Math.ceil(extractedColors.length / cols); | |
| canvas.width = cols * (cardWidth + margin) + margin; | |
| canvas.height = rows * (cardHeight + margin) + margin + 40; // 额外空间用于标题 | |
| // 绘制背景 | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // 添加标题 | |
| ctx.fillStyle = '#333333'; | |
| ctx.font = 'bold 20px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('提取的颜色色卡', canvas.width / 2, 30); | |
| // 绘制颜色卡片 | |
| ctx.font = '14px Arial'; | |
| ctx.textAlign = 'left'; | |
| for (let i = 0; i < extractedColors.length; i++) { | |
| const color = extractedColors[i]; | |
| const col = i % cols; | |
| const row = Math.floor(i / cols); | |
| const x = margin + col * (cardWidth + margin); | |
| const y = 40 + margin + row * (cardHeight + margin); | |
| // 绘制颜色块 | |
| ctx.fillStyle = color.value; | |
| ctx.fillRect(x, y, cardWidth, cardHeight * 0.7); | |
| // 绘制信息区域 | |
| ctx.fillStyle = '#f8f8f8'; | |
| ctx.fillRect(x, y + cardHeight * 0.7, cardWidth, cardHeight * 0.3); | |
| // 绘制颜色信息 | |
| ctx.fillStyle = '#333333'; | |
| ctx.fillText(color.name, x + 10, y + cardHeight * 0.7 + 25); | |
| ctx.fillText(color.value, x + 10, y + cardHeight * 0.7 + 45); | |
| } | |
| // 导出为PNG | |
| const link = document.createElement('a'); | |
| link.download = 'color-palette.png'; | |
| link.href = canvas.toDataURL('image/png'); | |
| link.click(); | |
| }); | |
| }); | |
| </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=ccookie/pic-generate" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |