新增像素化模式选择功能,优化颜色映射逻辑,使用calculatePixelGrid替代原有实现,提升性能和可读性,同时更新状态管理以支持新功能。

This commit is contained in:
Zylan
2025-04-26 11:47:02 +08:00
parent 6d8dc83354
commit 3c5c4ec9b3
2 changed files with 394 additions and 258 deletions

View File

@@ -1,37 +1,50 @@
'use client';
import React, { useState, useRef, ChangeEvent, DragEvent, TouchEvent, useEffect, useMemo } from 'react';
// Image component from next/image might not be strictly needed if you only use canvas and basic elements,
// but keep it if you plan to add other images later or use the SVG icon below.
// Removed unused Image import
import Script from 'next/script'; // ++ 导入 Script 组件 ++
import Script from 'next/script';
import ColorPalette from '../components/ColorPalette';
// 导入像素化工具和类型
import {
PixelationMode,
calculatePixelGrid,
RgbColor,
PaletteColor,
MappedPixel,
hexToRgb,
colorDistance,
findClosestPaletteColor
} from '../utils/pixelation';
import beadPaletteData from './beadPaletteData.json';
// Helper function to convert Hex to RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
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)
} : null;
// Helper function to get contrasting text color (simple version) - 保留原有实现因为未在utils中导出
function getContrastColor(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return '#000000'; // Default to black
// Simple brightness check (Luma formula Y = 0.2126 R + 0.7152 G + 0.0722 B)
const luma = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luma > 0.5 ? '#000000' : '#FFFFFF'; // Dark background -> white text, Light background -> black text
}
// Helper function to calculate Euclidean distance in RGB space
function colorDistance(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }): number {
const dr = rgb1.r - rgb2.r;
const dg = rgb1.g - rgb2.g;
const db = rgb1.b - rgb2.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
// Helper function for sorting color keys - 保留原有实现因为未在utils中导出
function sortColorKeys(a: string, b: string): number {
const regex = /^([A-Z]+)(\d+)$/;
const matchA = a.match(regex);
const matchB = b.match(regex);
// Interface for our palette colors
interface PaletteColor {
key: string;
hex: string;
rgb: { r: number; g: number; b: number };
if (matchA && matchB) {
const prefixA = matchA[1];
const numA = parseInt(matchA[2], 10);
const prefixB = matchB[1];
const numB = parseInt(matchB[2], 10);
if (prefixA !== prefixB) {
return prefixA.localeCompare(prefixB); // Sort by prefix first (A, B, C...)
}
return numA - numB; // Then sort by number (1, 2, 10...)
}
// Fallback for keys that don't match the standard pattern (e.g., T1, ZG1)
return a.localeCompare(b);
}
// --- Define available palette key sets ---
@@ -89,75 +102,14 @@ const transparentColorData: MappedPixel = { key: TRANSPARENT_KEY, color: '#FFFFF
// ++ Add definition for background color keys ++
const BACKGROUND_COLOR_KEYS = ['T1', 'H1', 'H2']; // 可以根据需要调整
// Helper function to find the closest color in the *selected* palette
function findClosestPaletteColor(
avgRgb: { r: number; g: number; b: number },
palette: PaletteColor[]
): PaletteColor {
let minDistance = Infinity;
let closestColor = palette[0];
if (!closestColor) {
console.error("Selected bead palette is empty or invalid!");
const t1Fallback = fullBeadPalette.find(p => p.key === 'T1');
const blackFallback = fullBeadPalette.find(p => p.hex === '#000000') || { key: 'ERR', hex: '#000000', rgb: { r: 0, g: 0, b: 0 } };
return t1Fallback || blackFallback;
}
for (const paletteColor of palette) {
const distance = colorDistance(avgRgb, paletteColor.rgb);
if (distance < minDistance) {
minDistance = distance;
closestColor = paletteColor;
}
if (distance === 0) break;
}
return closestColor;
}
// Helper to get contrasting text color (simple version)
function getContrastColor(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return '#000000'; // Default to black
// Simple brightness check (Luma formula Y = 0.2126 R + 0.7152 G + 0.0722 B)
const luma = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luma > 0.5 ? '#000000' : '#FFFFFF'; // Dark background -> white text, Light background -> black text
}
// Helper function for sorting color keys (e.g., A1, A2, A10, B1)
function sortColorKeys(a: string, b: string): number {
const regex = /^([A-Z]+)(\d+)$/;
const matchA = a.match(regex);
const matchB = b.match(regex);
if (matchA && matchB) {
const prefixA = matchA[1];
const numA = parseInt(matchA[2], 10);
const prefixB = matchB[1];
const numB = parseInt(matchB[2], 10);
if (prefixA !== prefixB) {
return prefixA.localeCompare(prefixB); // Sort by prefix first (A, B, C...)
}
return numA - numB; // Then sort by number (1, 2, 10...)
}
// Fallback for keys that don't match the standard pattern (e.g., T1, ZG1)
return a.localeCompare(b);
}
// ++ Interface for mapped pixel data needs updating ++
interface MappedPixel {
key: string;
color: string;
isExternal?: boolean; // Keep this optional or ensure it's always present
}
export default function Home() {
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [granularity, setGranularity] = useState<number>(50); // Example default
const [granularityInput, setGranularityInput] = useState<string>("50"); // ++ 新增:输入框状态 ++
const [similarityThreshold, setSimilarityThreshold] = useState<number>(30); // Example default for merging
const [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState<PaletteOptionKey>('all'); // Use 'all' or another valid key
const [granularity, setGranularity] = useState<number>(50);
const [granularityInput, setGranularityInput] = useState<string>("50");
const [similarityThreshold, setSimilarityThreshold] = useState<number>(30);
// 添加像素化模式状态
const [pixelationMode, setPixelationMode] = useState<PixelationMode>(PixelationMode.Dominant); // 默认为卡通模式
const [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState<PaletteOptionKey>('all');
const [activeBeadPalette, setActiveBeadPalette] = useState<PaletteColor[]>(() => {
const initialKey = 'all'; // Match the key used above
const options = paletteOptions[initialKey];
@@ -325,10 +277,22 @@ export default function Home() {
setSelectedColor(null);
};
// 添加像素化模式切换处理函数
const handlePixelationModeChange = (event: ChangeEvent<HTMLSelectElement>) => {
const newMode = event.target.value as PixelationMode;
if (Object.values(PixelationMode).includes(newMode)) {
setPixelationMode(newMode);
setRemapTrigger(prev => prev + 1); // 触发重新映射
setIsManualColoringMode(false); // 退出手动模式
setSelectedColor(null);
} else {
console.warn(`无效的像素化模式: ${newMode}`);
}
};
// Core function: Pixelate the image
const pixelateImage = (imageSrc: string, detailLevel: number, threshold: number, currentPalette: PaletteColor[]) => {
console.log(`Attempting to pixelate with detail: ${detailLevel}, threshold: ${threshold}`); // ++ 修改日志添加detail参数 ++
// 修改pixelateImage函数接收模式参数
const pixelateImage = (imageSrc: string, detailLevel: number, threshold: number, currentPalette: PaletteColor[], mode: PixelationMode) => {
console.log(`Attempting to pixelate with detail: ${detailLevel}, threshold: ${threshold}, mode: ${mode}`);
const originalCanvas = originalCanvasRef.current;
const pixelatedCanvas = pixelatedCanvasRef.current;
@@ -352,7 +316,7 @@ export default function Home() {
}
const t1FallbackColor = currentPalette.find(p => p.key === 'T1')
|| currentPalette.find(p => p.hex.toUpperCase() === '#FFFFFF')
|| currentPalette[0]; // Use the first available color as fallback
|| currentPalette[0]; // 使用第一个可用颜色作为备用
console.log("Using fallback color for empty cells:", t1FallbackColor);
const img = new window.Image();
@@ -373,75 +337,25 @@ export default function Home() {
originalCtx.drawImage(img, 0, 0, img.width, img.height);
console.log("Original image drawn.");
const cellWidthOriginal = img.width / N; const cellHeightOriginal = img.height / M;
// const cellWidthOutput = outputWidth / N; const cellHeightOutput = outputHeight / M; // ++ REMOVED unused variables ++
console.log("Starting initial color mapping...");
let processedCells = 0;
const initialMappedData: { key: string; color: string }[][] = Array(M).fill(null).map(() => Array(N).fill({ key: t1FallbackColor.key, color: t1FallbackColor.hex }));
// --- First Loop: Map Colors and Data (using DOMINANT color) ---
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const startXOriginal = Math.floor(i * cellWidthOriginal);
const startYOriginal = Math.floor(j * cellHeightOriginal);
const endXOriginal = Math.min(img.width, Math.ceil((i + 1) * cellWidthOriginal));
const endYOriginal = Math.min(img.height, Math.ceil((j + 1) * cellHeightOriginal));
const currentCellWidth = Math.max(1, endXOriginal - startXOriginal);
const currentCellHeight = Math.max(1, endYOriginal - startYOriginal);
if (currentCellWidth <= 0 || currentCellHeight <= 0) { continue; }
let imageData;
try { imageData = originalCtx.getImageData(startXOriginal, startYOriginal, currentCellWidth, currentCellHeight); }
catch (e) { console.error(`Failed getImageData at (${i},${j}):`, e); continue; }
const data = imageData.data;
// ++ Use an object to count color frequencies ++
const colorCountsInCell: { [key: string]: number } = {};
let dominantColorRgb: { r: number; g: number; b: number } | null = null;
let maxCount = 0;
let totalPixelCount = 0; // Count valid pixels in the cell
// ++ Count frequency of each color ++
for (let p = 0; p < data.length; p += 4) {
if (data[p + 3] < 128) continue; // Ignore transparent/semi-transparent pixels
const r = data[p];
const g = data[p + 1];
const b = data[p + 2];
const colorKey = `${r},${g},${b}`;
colorCountsInCell[colorKey] = (colorCountsInCell[colorKey] || 0) + 1;
totalPixelCount++;
// ++ Keep track of the dominant color found so far ++
if (colorCountsInCell[colorKey] > maxCount) {
maxCount = colorCountsInCell[colorKey];
dominantColorRgb = { r, g, b };
}
}
let finalCellColorData: { key: string; color: string };
// ++ Map based on dominant color if found, else use fallback ++
if (totalPixelCount > 0 && dominantColorRgb) {
const closestBead = findClosestPaletteColor(dominantColorRgb, currentPalette);
finalCellColorData = { key: closestBead.key, color: closestBead.hex };
} else {
// Use fallback if cell was empty or only contained transparent pixels
finalCellColorData = { key: t1FallbackColor.key, color: t1FallbackColor.hex };
}
initialMappedData[j][i] = finalCellColorData; // Store in initial data
processedCells++;
}
}
console.log(`Initial data mapping complete (using dominant cell color). Processed ${processedCells} cells. Starting region merging...`);
// 使用calculatePixelGrid替换原来的颜色映射逻辑
console.log("Starting initial color mapping using calculatePixelGrid...");
const initialMappedData = calculatePixelGrid(
originalCtx,
img.width,
img.height,
N,
M,
currentPalette,
mode,
t1FallbackColor
);
console.log(`Initial data mapping complete using mode ${mode}. Starting region merging...`);
// --- Region Merging Step ---
const keyToRgbMap = new Map<string, { r: number; g: number; b: number }>();
const keyToRgbMap = new Map<string, RgbColor>();
currentPalette.forEach(p => keyToRgbMap.set(p.key, p.rgb));
const visited: boolean[][] = Array(M).fill(null).map(() => Array(N).fill(false));
const mergedData: { key: string; color: string; isExternal: boolean }[][] = Array(M).fill(null).map(() => Array(N).fill({ key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }));
const mergedData: MappedPixel[][] = Array(M).fill(null).map(() => Array(N).fill({ key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }));
const similarityThresholdValue = threshold;
for (let j = 0; j < M; j++) {
@@ -452,10 +366,10 @@ export default function Home() {
const startRgb = keyToRgbMap.get(startCellData.key);
if (!startRgb) {
console.warn(`RGB not found for key ${startCellData.key} at (${j},${i}) during merging (might be excluded?). Using fallback for this cell.`);
console.warn(`RGB not found for key ${startCellData.key} at (${j},${i}) during merging. Using fallback.`);
visited[j][i] = true;
mergedData[j][i] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false };
continue; // Skip BFS starting from this invalid cell
continue;
}
const currentRegionCells: { r: number; c: number }[] = [];
@@ -469,7 +383,7 @@ export default function Home() {
const currentRgb = keyToRgbMap.get(currentCellData.key);
if (!currentRgb) {
console.warn(`RGB not found for key ${currentCellData.key} at (${r},${c}) during BFS. Skipping neighbor.`);
console.warn(`RGB not found for key ${currentCellData.key} at (${r},${c}) during BFS. Skipping.`);
continue;
}
@@ -482,7 +396,6 @@ export default function Home() {
const neighbors = [ { nr: r + 1, nc: c }, { nr: r - 1, nc: c }, { nr: r, nc: c + 1 }, { nr: r, nc: c - 1 } ];
for (const { nr, nc } of neighbors) {
if (nr >= 0 && nr < M && nc >= 0 && nc < N && !visited[nr][nc]) {
// Check similarity *before* adding to queue to prevent exploring unrelated branches that happen to be near the start cell
const neighborCellData = initialMappedData[nr][nc];
const neighborRgb = keyToRgbMap.get(neighborCellData.key);
if (neighborRgb && colorDistance(startRgb, neighborRgb) < similarityThresholdValue) {
@@ -492,7 +405,7 @@ export default function Home() {
}
}
}
} // End of while loop (BFS for one region)
}
// --- Determine Dominant Color and Recolor the Region ---
if (currentRegionCells.length > 0) {
@@ -504,9 +417,9 @@ export default function Home() {
dominantKey = key;
}
}
if (!dominantKey) { // Fallback if region was empty or only had issues
if (!dominantKey) {
dominantKey = startCellData.key;
console.warn(`No dominant key found for region starting at (${j},${i}), using start cell key: ${dominantKey}`);
console.warn(`No dominant key found for region starting at (${j},${i}), using start cell key: ${dominantKey}`);
}
const dominantColorData = currentPalette.find(p => p.key === dominantKey);
@@ -516,112 +429,92 @@ export default function Home() {
mergedData[r][c] = { key: dominantKey, color: dominantColorHex, isExternal: false };
});
} else {
console.warn(`Dominant key "${dominantKey}" determined but not found in *active* palette during merge. Using fallback.`);
console.warn(`Dominant key "${dominantKey}" determined but not found in *active* palette during merge. Using fallback.`);
currentRegionCells.forEach(({ r, c }) => {
mergedData[r][c] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false };
});
}
} else {
// If the region only contained the start cell and it had issues
mergedData[j][i] = { ...startCellData, isExternal: false };
mergedData[j][i] = { ...startCellData, isExternal: false };
}
} // End of inner loop (i)
} // End of outer loop (j) for region merging
}
}
console.log("Region merging complete. Starting background removal.");
// --- Flood Fill: Mark External Background (Uses mergedData) ---
// --- Flood Fill Background Process ---
// ... 保持洪水填充算法不变,但在mergedData上操作 ...
const visitedForFloodFill: boolean[][] = Array(M).fill(null).map(() => Array(N).fill(false));
const floodFill = (r: number, c: number) => {
// ++ Check mergedData for background key and update its isExternal flag ++
if (r < 0 || r >= M || c < 0 || c >= N || visitedForFloodFill[r][c] || !BACKGROUND_COLOR_KEYS.includes(mergedData[r][c].key)) {
const cell = mergedData[r]?.[c];
if (r < 0 || r >= M || c < 0 || c >= N || visitedForFloodFill[r][c] || !cell || !BACKGROUND_COLOR_KEYS.includes(cell.key)) {
return;
}
visitedForFloodFill[r][c] = true;
mergedData[r][c].isExternal = true; // Mark as external background in mergedData
cell.isExternal = true;
floodFill(r + 1, c);
floodFill(r - 1, c);
floodFill(r, c + 1);
floodFill(r, c - 1);
};
// Start flood fill from all border cells using mergedData
for (let i = 0; i < N; i++) {
if (!visitedForFloodFill[0][i] && BACKGROUND_COLOR_KEYS.includes(mergedData[0][i].key)) floodFill(0, i);
if (!visitedForFloodFill[M - 1][i] && BACKGROUND_COLOR_KEYS.includes(mergedData[M - 1][i].key)) floodFill(M - 1, i);
if (!visitedForFloodFill[0][i] && mergedData[0]?.[i] && BACKGROUND_COLOR_KEYS.includes(mergedData[0][i].key)) floodFill(0, i);
if (!visitedForFloodFill[M - 1][i] && mergedData[M - 1]?.[i] && BACKGROUND_COLOR_KEYS.includes(mergedData[M - 1][i].key)) floodFill(M - 1, i);
}
for (let j = 0; j < M; j++) {
if (!visitedForFloodFill[j][0] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][0].key)) floodFill(j, 0);
if (!visitedForFloodFill[j][N - 1] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][N - 1].key)) floodFill(j, N - 1);
if (!visitedForFloodFill[j][0] && mergedData[j]?.[0] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][0].key)) floodFill(j, 0);
if (!visitedForFloodFill[j][N - 1] && mergedData[j]?.[N - 1] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][N - 1].key)) floodFill(j, N - 1);
}
console.log("Background flood fill marking complete.");
// --- Second Loop: Draw Cells and Borders using mergedData ---
// console.log("Starting final drawing loop on pixelated canvas..."); // ++ 移除日志 ++
// pixelatedCtx.clearRect(0, 0, outputWidth, outputHeight); // Clear canvas before drawing // ++ 移除 ++
// pixelatedCtx.lineWidth = 1; // Set line width once // ++ 移除 ++
/* ++ 移除整个绘制循环 ++
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
// ... (original drawing code) ...
}
}
*/
// console.log("Final drawing loop complete."); // ++ 移除日志 ++
// ++ 在设置状态之前调用新的绘制函数 ++
if (pixelatedCanvasRef.current) { // ++ 添加检查 ++
// --- 绘制和状态更新 ---
if (pixelatedCanvasRef.current) {
drawPixelatedCanvas(mergedData, pixelatedCanvasRef, { N, M });
} else {
console.error("Pixelated canvas ref is null, skipping draw call in pixelateImage.");
}
// Update state and counts using mergedData (excluding external)
setMappedPixelData(mergedData);
setGridDimensions({ N, M });
const counts: { [key: string]: { count: number; color: string } } = {};
let totalCount = 0; // ++ 初始化总数计数器 ++
// ++ Iterate over mergedData for final counts ++
let totalCount = 0;
mergedData.flat().forEach(cell => {
// Only count cells that are not marked as external background
if (cell && cell.key && !cell.isExternal) {
if (!counts[cell.key]) {
// Use the color from mergedData which corresponds to the dominant key
counts[cell.key] = { count: 0, color: cell.color };
}
counts[cell.key].count++;
totalCount++; // ++ 累加总数 ++
totalCount++;
}
});
setColorCounts(counts);
setTotalBeadCount(totalCount); // ++ 更新总数状态 ++
setInitialGridColorKeys(new Set(Object.keys(counts))); // ++ 存储初始颜色键 ++
setTotalBeadCount(totalCount);
setInitialGridColorKeys(new Set(Object.keys(counts)));
console.log("Color counts updated based on merged data (excluding external background):", counts);
console.log("Total bead count (excluding background):", totalCount); // ++ 打印总数 ++
console.log("Stored initial grid color keys:", Object.keys(counts)); // ++ 记录初始键日志 ++
console.log("Total bead count (excluding background):", totalCount);
console.log("Stored initial grid color keys:", Object.keys(counts));
};
img.onerror = (error: Event | string) => {
console.error("Image loading failed:", error); alert("无法加载图片。");
setOriginalImageSrc(null); setMappedPixelData(null); setGridDimensions(null); setColorCounts(null); setInitialGridColorKeys(null); // ++ 清空初始键 ++
};
console.log("Setting image source...");
img.src = imageSrc;
// Ensure manual mode is off after pixelation completes
setIsManualColoringMode(false);
setSelectedColor(null);
};
// Use useEffect to trigger pixelation
// 修改useEffect中的pixelateImage调用加入模式参数
useEffect(() => {
if (originalImageSrc && activeBeadPalette.length > 0) { // Keep activeBeadPalette check here to prevent running if empty
if (originalImageSrc && activeBeadPalette.length > 0) {
const timeoutId = setTimeout(() => {
// Add internal check for activeBeadPalette length again just before calling pixelate
if (originalImageSrc && originalCanvasRef.current && pixelatedCanvasRef.current && activeBeadPalette.length > 0) {
console.log("useEffect triggered: Processing image due to src, granularity, threshold, palette selection, or remap trigger.");
pixelateImage(originalImageSrc, granularity, similarityThreshold, activeBeadPalette); // Pass activeBeadPalette here
console.log("useEffect triggered: Processing image due to src, granularity, threshold, palette selection, mode or remap trigger.");
pixelateImage(originalImageSrc, granularity, similarityThreshold, activeBeadPalette, pixelationMode);
} else {
console.warn("useEffect check failed inside timeout: Refs or active palette not ready/empty.");
}
@@ -646,7 +539,7 @@ export default function Home() {
// setTotalBeadCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originalImageSrc, granularity, similarityThreshold, selectedPaletteKeySet, remapTrigger]); // Dependencies controlling full remap
}, [originalImageSrc, granularity, similarityThreshold, selectedPaletteKeySet, pixelationMode, remapTrigger]); // 添加pixelationMode到依赖数组
// --- Download function (ensure filename includes palette) ---
const handleDownloadImage = () => {
@@ -1232,49 +1125,77 @@ export default function Home() {
<div className="w-full flex flex-col items-center space-y-5 sm:space-y-6">
{/* ++ HIDE Control Row in manual mode ++ */}
{!isManualColoringMode && (
<div className="w-full max-w-lg grid grid-cols-1 sm:grid-cols-3 gap-4 bg-white p-3 sm:p-4 rounded-lg shadow">
{/* ++ 修改:Granularity 输入框和按钮 ++ */}
<div className="flex-1">
<label htmlFor="granularityInput" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">
(10-100):
</label>
<div className="flex items-center gap-2">
<input
type="number"
id="granularityInput"
min="10"
max="1000"
step="1"
value={granularityInput}
onChange={handleGranularityInputChange}
onKeyDown={(e) => e.key === 'Enter' && handleConfirmGranularity()}
className="w-full p-1.5 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9"
/>
<button
onClick={handleConfirmGranularity}
className="px-3 py-1.5 bg-blue-600 text-white text-xs sm:text-sm rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 transition-colors whitespace-nowrap h-9"
>
</button>
</div>
</div>
{/* Similarity Threshold Slider */}
<div className="flex-1">
<label htmlFor="similarityThreshold" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">
: <span className="font-semibold text-purple-600">{similarityThreshold}</span>
</label>
<input type="range" id="similarityThreshold" min="0" max="200" step="1" value={similarityThreshold} onChange={handleSimilarityChange} className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-purple-600" />
<div className="flex justify-between text-xs text-gray-500 mt-0.5 px-1"><span></span><span></span></div>
<div className="w-full max-w-xl grid grid-cols-1 sm:grid-cols-4 gap-4 bg-white p-3 sm:p-4 rounded-lg shadow">
{/* Granularity Input */}
<div className="flex-1">
<label htmlFor="granularityInput" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">
(10-1000):
</label>
<div className="flex items-center gap-2">
<input
type="number"
id="granularityInput"
value={granularityInput}
onChange={handleGranularityInputChange}
className="w-full p-1.5 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9"
min="10"
max="100"
/>
<button
onClick={handleConfirmGranularity}
className="h-9 bg-blue-500 hover:bg-blue-600 text-white text-sm px-2 rounded-md whitespace-nowrap"
></button>
</div>
{/* Palette Selector */}
<div className="flex-1">
<label htmlFor="paletteSelect" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">:</label>
<select id="paletteSelect" value={selectedPaletteKeySet} onChange={handlePaletteChange} className="w-full p-1.5 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9">
{(Object.keys(paletteOptions) as PaletteOptionKey[]).map(key => (
<option key={key} value={key}>{paletteOptions[key].name}</option>
))}
</select>
</div>
</div>
{/* Similarity Threshold Slider */}
<div className="flex-1">
<label htmlFor="similarityThreshold" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">
: <span className="font-semibold text-purple-600">{similarityThreshold}</span>
</label>
<input
type="range"
id="similarityThreshold"
min="0"
max="100"
value={similarityThreshold}
onChange={handleSimilarityChange}
className="w-full h-9"
/>
<div className="flex justify-between text-xs text-gray-500 -mt-1">
<span></span>
<span></span>
</div>
</div>
{/* Palette Selector */}
<div className="flex-1">
<label htmlFor="paletteSelect" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">:</label>
<select
id="paletteSelect"
value={selectedPaletteKeySet}
onChange={handlePaletteChange}
className="w-full p-1.5 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9"
>
{(Object.keys(paletteOptions) as PaletteOptionKey[]).map(key => (
<option key={key} value={key}>{paletteOptions[key].name}</option>
))}
</select>
</div>
{/* 添加像素化模式选择 */}
<div className="flex-1">
<label htmlFor="pixelationModeSelect" className="block text-xs sm:text-sm font-medium text-gray-700 mb-1 sm:mb-1.5">:</label>
<select
id="pixelationModeSelect"
value={pixelationMode}
onChange={handlePixelationModeChange}
className="w-full p-1.5 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9"
>
<option value={PixelationMode.Dominant}> ()</option>
<option value={PixelationMode.Average}> ()</option>
</select>
</div>
</div>
)} {/* ++ End of HIDE Control Row ++ */}

215
src/utils/pixelation.ts Normal file
View File

@@ -0,0 +1,215 @@
// 定义像素化模式
export enum PixelationMode {
Dominant = 'dominant', // 卡通模式(主色)
Average = 'average', // 真实模式(平均色)
}
// --- 必要的类型定义 ---
export interface RgbColor {
r: number;
g: number;
b: number;
}
export interface PaletteColor {
key: string;
hex: string;
rgb: RgbColor;
}
export interface MappedPixel {
key: string;
color: string;
isExternal?: boolean;
}
// --- 辅助函数 ---
// 转换 Hex 到 RGB
export function hexToRgb(hex: string): RgbColor | null {
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)
} : null;
}
// 计算颜色距离
export function colorDistance(rgb1: RgbColor, rgb2: RgbColor): number {
const dr = rgb1.r - rgb2.r;
const dg = rgb1.g - rgb2.g;
const db = rgb1.b - rgb2.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
// 查找最接近的颜色
export function findClosestPaletteColor(
targetRgb: RgbColor,
palette: PaletteColor[]
): PaletteColor {
if (!palette || palette.length === 0) {
console.error("findClosestPaletteColor: Palette is empty or invalid!");
// 提供一个健壮的回退
return { key: 'ERR', hex: '#000000', rgb: { r: 0, g: 0, b: 0 } };
}
let minDistance = Infinity;
let closestColor = palette[0];
for (const paletteColor of palette) {
const distance = colorDistance(targetRgb, paletteColor.rgb);
if (distance < minDistance) {
minDistance = distance;
closestColor = paletteColor;
}
if (distance === 0) break; // 完全匹配,提前退出
}
return closestColor;
}
// --- 核心像素化计算逻辑 ---
/**
* 计算图像指定区域的代表色(根据所选模式)
* @param imageData 包含像素数据的 ImageData 对象
* @param startX 区域起始 X 坐标
* @param startY 区域起始 Y 坐标
* @param width 区域宽度
* @param height 区域高度
* @param mode 计算模式 ('dominant' 或 'average')
* @returns 代表色的 RGB 对象,或 null如果区域无效或全透明
*/
function calculateCellRepresentativeColor(
imageData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
mode: PixelationMode
): RgbColor | null {
const data = imageData.data;
const imgWidth = imageData.width;
let rSum = 0, gSum = 0, bSum = 0;
let pixelCount = 0;
const colorCountsInCell: { [key: string]: number } = {};
let dominantColorRgb: RgbColor | null = null;
let maxCount = 0;
const endX = startX + width;
const endY = startY + height;
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const index = (y * imgWidth + x) * 4;
// 检查 alpha 通道,忽略完全透明的像素
if (data[index + 3] < 128) continue;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
pixelCount++;
if (mode === PixelationMode.Average) {
rSum += r;
gSum += g;
bSum += b;
} else { // Dominant mode
const colorKey = `${r},${g},${b}`;
colorCountsInCell[colorKey] = (colorCountsInCell[colorKey] || 0) + 1;
if (colorCountsInCell[colorKey] > maxCount) {
maxCount = colorCountsInCell[colorKey];
dominantColorRgb = { r, g, b };
}
}
}
}
if (pixelCount === 0) {
return null; // 区域内没有不透明像素
}
if (mode === PixelationMode.Average) {
return {
r: Math.round(rSum / pixelCount),
g: Math.round(gSum / pixelCount),
b: Math.round(bSum / pixelCount),
};
} else { // Dominant mode
return dominantColorRgb; // 可能为 null 如果只有一个透明像素
}
}
/**
* 根据原始图像数据、网格尺寸、调色板和模式计算像素化网格数据。
* @param originalCtx 原始图像的 Canvas 2D Context
* @param imgWidth 原始图像宽度
* @param imgHeight 原始图像高度
* @param N 网格横向数量
* @param M 网格纵向数量
* @param palette 当前使用的调色板
* @param mode 像素化模式 (Dominant/Average)
* @param t1FallbackColor T1 或其他备用颜色数据
* @returns 计算后的 MappedPixel 网格数据
*/
export function calculatePixelGrid(
originalCtx: CanvasRenderingContext2D,
imgWidth: number,
imgHeight: number,
N: number,
M: number,
palette: PaletteColor[],
mode: PixelationMode,
t1FallbackColor: PaletteColor // 传入备用色
): MappedPixel[][] {
console.log(`Calculating pixel grid with mode: ${mode}`);
const mappedData: MappedPixel[][] = Array(M).fill(null).map(() => Array(N).fill({ key: t1FallbackColor.key, color: t1FallbackColor.hex }));
const cellWidthOriginal = imgWidth / N;
const cellHeightOriginal = imgHeight / M;
let fullImageData: ImageData | null = null;
try {
fullImageData = originalCtx.getImageData(0, 0, imgWidth, imgHeight);
} catch (e) {
console.error("Failed to get full image data:", e);
// 如果无法获取图像数据,返回一个空的或默认的网格
return mappedData;
}
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const startXOriginal = Math.floor(i * cellWidthOriginal);
const startYOriginal = Math.floor(j * cellHeightOriginal);
// 计算精确的单元格结束位置,避免超出图像边界
const endXOriginal = Math.min(imgWidth, Math.ceil((i + 1) * cellWidthOriginal));
const endYOriginal = Math.min(imgHeight, Math.ceil((j + 1) * cellHeightOriginal));
// 计算实际的单元格宽高
const currentCellWidth = Math.max(1, endXOriginal - startXOriginal);
const currentCellHeight = Math.max(1, endYOriginal - startYOriginal);
// 使用提取的函数计算代表色
const representativeRgb = calculateCellRepresentativeColor(
fullImageData,
startXOriginal,
startYOriginal,
currentCellWidth,
currentCellHeight,
mode
);
let finalCellColorData: MappedPixel;
if (representativeRgb) {
const closestBead = findClosestPaletteColor(representativeRgb, palette);
finalCellColorData = { key: closestBead.key, color: closestBead.hex };
} else {
// 如果单元格为空或全透明,使用备用色
finalCellColorData = { key: t1FallbackColor.key, color: t1FallbackColor.hex };
}
mappedData[j][i] = finalCellColorData;
}
}
console.log(`Pixel grid calculation complete for mode: ${mode}`);
return mappedData;
}