|
527 | 527 | let minDistance = Infinity; |
528 | 528 | let nearestColor = palette[0]; |
529 | 529 |
|
| 530 | + // Convert pixel to LAB for perceptual distance |
| 531 | + const pixelLab = this.rgbToLab(pixel); |
| 532 | + |
530 | 533 | for (const color of palette) { |
531 | | - const distance = Math.sqrt( |
532 | | - Math.pow(pixel[0] - color[0], 2) + |
533 | | - Math.pow(pixel[1] - color[1], 2) + |
534 | | - Math.pow(pixel[2] - color[2], 2) |
535 | | - ); |
| 534 | + const colorLab = this.rgbToLab(color); |
| 535 | + const distance = this.deltaE2000(pixelLab, colorLab); |
536 | 536 |
|
537 | 537 | if (distance < minDistance) { |
538 | 538 | minDistance = distance; |
|
542 | 542 |
|
543 | 543 | return nearestColor; |
544 | 544 | } |
| 545 | + |
| 546 | + // RGB to LAB color space conversion |
| 547 | + rgbToLab(rgb) { |
| 548 | + // Normalize RGB values |
| 549 | + let r = rgb[0] / 255; |
| 550 | + let g = rgb[1] / 255; |
| 551 | + let b = rgb[2] / 255; |
| 552 | + |
| 553 | + // Apply gamma correction |
| 554 | + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; |
| 555 | + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; |
| 556 | + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; |
| 557 | + |
| 558 | + // Convert to XYZ (D65 illuminant) |
| 559 | + let x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100; |
| 560 | + let y = (r * 0.2126729 + g * 0.7151522 + b * 0.0721750) * 100; |
| 561 | + let z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) * 100; |
| 562 | + |
| 563 | + // Normalize for D65 illuminant |
| 564 | + x = x / 95.047; |
| 565 | + y = y / 100.000; |
| 566 | + z = z / 108.883; |
| 567 | + |
| 568 | + // Apply transformation |
| 569 | + x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x + 16/116); |
| 570 | + y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y + 16/116); |
| 571 | + z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z + 16/116); |
| 572 | + |
| 573 | + // Calculate LAB values |
| 574 | + const L = 116 * y - 16; |
| 575 | + const a = 500 * (x - y); |
| 576 | + const b = 200 * (y - z); |
| 577 | + |
| 578 | + return [L, a, b]; |
| 579 | + } |
| 580 | + |
| 581 | + // CIEDE2000 color difference formula |
| 582 | + deltaE2000(lab1, lab2) { |
| 583 | + // Simplified CIEDE2000 - for full implementation, use specialized library |
| 584 | + // This is a reasonable approximation for our use case |
| 585 | + const [L1, a1, b1] = lab1; |
| 586 | + const [L2, a2, b2] = lab2; |
| 587 | + |
| 588 | + const dL = L2 - L1; |
| 589 | + const da = a2 - a1; |
| 590 | + const db = b2 - b1; |
| 591 | + |
| 592 | + const C1 = Math.sqrt(a1 * a1 + b1 * b1); |
| 593 | + const C2 = Math.sqrt(a2 * a2 + b2 * b2); |
| 594 | + const dC = C2 - C1; |
| 595 | + |
| 596 | + const dH = Math.sqrt(Math.max(0, da * da + db * db - dC * dC)); |
| 597 | + |
| 598 | + // Weighting factors (simplified) |
| 599 | + const kL = 1.0; |
| 600 | + const kC = 1.0; |
| 601 | + const kH = 1.0; |
| 602 | + |
| 603 | + const SL = 1.0; |
| 604 | + const SC = 1 + 0.045 * ((C1 + C2) / 2); |
| 605 | + const SH = 1 + 0.015 * ((C1 + C2) / 2); |
| 606 | + |
| 607 | + const dLp = dL / (kL * SL); |
| 608 | + const dCp = dC / (kC * SC); |
| 609 | + const dHp = dH / (kH * SH); |
| 610 | + |
| 611 | + return Math.sqrt(dLp * dLp + dCp * dCp + dHp * dHp); |
| 612 | + } |
545 | 613 |
|
546 | 614 | enhanceEdges(canvas) { |
547 | 615 | const ctx = canvas.getContext('2d'); |
|
660 | 728 | const ctx = canvas.getContext('2d'); |
661 | 729 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
662 | 730 |
|
663 | | - // Use K-means clustering for better color selection |
664 | | - const palette = this.kMeansClustering(imageData, colorCount); |
| 731 | + // Select algorithm based on quality mode |
| 732 | + const qualityMode = document.getElementById('qualityMode')?.value || 'standard'; |
| 733 | + let palette; |
| 734 | + |
| 735 | + switch(qualityMode) { |
| 736 | + case 'fast': |
| 737 | + palette = this.simpleQuantization(imageData, colorCount); |
| 738 | + break; |
| 739 | + case 'high': |
| 740 | + palette = this.medianCutQuantization(imageData, colorCount); |
| 741 | + break; |
| 742 | + case 'best': |
| 743 | + palette = this.spatialQuantization(imageData, colorCount); |
| 744 | + break; |
| 745 | + case 'standard': |
| 746 | + default: |
| 747 | + palette = this.kMeansClustering(imageData, colorCount); |
| 748 | + break; |
| 749 | + } |
| 750 | + |
665 | 751 | const quantizedData = this.applyPaletteToImageData(imageData, palette); |
666 | 752 |
|
667 | 753 | const resultCanvas = document.createElement('canvas'); |
|
673 | 759 | return resultCanvas; |
674 | 760 | } |
675 | 761 |
|
| 762 | + // Simple uniform quantization for fast mode |
| 763 | + simpleQuantization(imageData, colorCount) { |
| 764 | + const factor = Math.cbrt(colorCount); |
| 765 | + const step = Math.floor(256 / factor); |
| 766 | + const palette = []; |
| 767 | + |
| 768 | + for (let r = 0; r < 256; r += step) { |
| 769 | + for (let g = 0; g < 256; g += step) { |
| 770 | + for (let b = 0; b < 256; b += step) { |
| 771 | + palette.push([r, g, b]); |
| 772 | + if (palette.length >= colorCount) { |
| 773 | + return palette.slice(0, colorCount); |
| 774 | + } |
| 775 | + } |
| 776 | + } |
| 777 | + } |
| 778 | + |
| 779 | + return palette; |
| 780 | + } |
| 781 | + |
676 | 782 | kMeansClustering(imageData, k) { |
677 | 783 | const data = imageData.data; |
678 | 784 | const pixels = []; |
|
773 | 879 | return dr * dr + dg * dg + db * db; |
774 | 880 | } |
775 | 881 |
|
| 882 | + // Median Cut Quantization Algorithm |
| 883 | + medianCutQuantization(imageData, colorCount) { |
| 884 | + const data = imageData.data; |
| 885 | + const pixels = []; |
| 886 | + |
| 887 | + // Sample pixels for performance |
| 888 | + const step = Math.max(1, Math.floor(data.length / (4 * 50000))); |
| 889 | + for (let i = 0; i < data.length; i += 4 * step) { |
| 890 | + if (data[i + 3] > 0) { // Only opaque pixels |
| 891 | + pixels.push([data[i], data[i + 1], data[i + 2]]); |
| 892 | + } |
| 893 | + } |
| 894 | + |
| 895 | + if (pixels.length === 0) return [[128, 128, 128]]; |
| 896 | + |
| 897 | + // Create initial box containing all pixels |
| 898 | + const boxes = [this.createColorBox(pixels)]; |
| 899 | + |
| 900 | + // Recursively split boxes |
| 901 | + while (boxes.length < colorCount && boxes.length < pixels.length) { |
| 902 | + // Find box with largest volume or pixel count |
| 903 | + let maxScore = 0; |
| 904 | + let boxToSplit = 0; |
| 905 | + |
| 906 | + for (let i = 0; i < boxes.length; i++) { |
| 907 | + const score = boxes[i].pixels.length * boxes[i].volume; |
| 908 | + if (score > maxScore) { |
| 909 | + maxScore = score; |
| 910 | + boxToSplit = i; |
| 911 | + } |
| 912 | + } |
| 913 | + |
| 914 | + // Split the selected box |
| 915 | + const box = boxes[boxToSplit]; |
| 916 | + const [box1, box2] = this.splitBox(box); |
| 917 | + |
| 918 | + if (box2.pixels.length > 0) { |
| 919 | + boxes.splice(boxToSplit, 1, box1, box2); |
| 920 | + } else { |
| 921 | + break; // Can't split anymore |
| 922 | + } |
| 923 | + } |
| 924 | + |
| 925 | + // Extract palette from boxes |
| 926 | + return boxes.map(box => { |
| 927 | + const pixels = box.pixels; |
| 928 | + const r = Math.round(pixels.reduce((sum, p) => sum + p[0], 0) / pixels.length); |
| 929 | + const g = Math.round(pixels.reduce((sum, p) => sum + p[1], 0) / pixels.length); |
| 930 | + const b = Math.round(pixels.reduce((sum, p) => sum + p[2], 0) / pixels.length); |
| 931 | + return [r, g, b]; |
| 932 | + }); |
| 933 | + } |
| 934 | + |
| 935 | + createColorBox(pixels) { |
| 936 | + let minR = 255, maxR = 0; |
| 937 | + let minG = 255, maxG = 0; |
| 938 | + let minB = 255, maxB = 0; |
| 939 | + |
| 940 | + for (const [r, g, b] of pixels) { |
| 941 | + minR = Math.min(minR, r); |
| 942 | + maxR = Math.max(maxR, r); |
| 943 | + minG = Math.min(minG, g); |
| 944 | + maxG = Math.max(maxG, g); |
| 945 | + minB = Math.min(minB, b); |
| 946 | + maxB = Math.max(maxB, b); |
| 947 | + } |
| 948 | + |
| 949 | + const rangeR = maxR - minR; |
| 950 | + const rangeG = maxG - minG; |
| 951 | + const rangeB = maxB - minB; |
| 952 | + |
| 953 | + return { |
| 954 | + pixels: pixels, |
| 955 | + minR, maxR, minG, maxG, minB, maxB, |
| 956 | + volume: rangeR * rangeG * rangeB, |
| 957 | + largestDimension: rangeR >= rangeG && rangeR >= rangeB ? 'r' : |
| 958 | + rangeG >= rangeB ? 'g' : 'b' |
| 959 | + }; |
| 960 | + } |
| 961 | + |
| 962 | + splitBox(box) { |
| 963 | + const { pixels, largestDimension } = box; |
| 964 | + |
| 965 | + // Sort pixels along the largest dimension |
| 966 | + let sortedPixels; |
| 967 | + if (largestDimension === 'r') { |
| 968 | + sortedPixels = [...pixels].sort((a, b) => a[0] - b[0]); |
| 969 | + } else if (largestDimension === 'g') { |
| 970 | + sortedPixels = [...pixels].sort((a, b) => a[1] - b[1]); |
| 971 | + } else { |
| 972 | + sortedPixels = [...pixels].sort((a, b) => a[2] - b[2]); |
| 973 | + } |
| 974 | + |
| 975 | + // Split at median |
| 976 | + const median = Math.floor(sortedPixels.length / 2); |
| 977 | + const pixels1 = sortedPixels.slice(0, median); |
| 978 | + const pixels2 = sortedPixels.slice(median); |
| 979 | + |
| 980 | + return [ |
| 981 | + this.createColorBox(pixels1), |
| 982 | + this.createColorBox(pixels2) |
| 983 | + ]; |
| 984 | + } |
| 985 | + |
| 986 | + // Spatial Color Quantization (simplified version) |
| 987 | + spatialQuantization(imageData, colorCount) { |
| 988 | + // First get initial palette using median cut |
| 989 | + const initialPalette = this.medianCutQuantization(imageData, colorCount); |
| 990 | + |
| 991 | + // Apply spatial consideration |
| 992 | + const width = imageData.width; |
| 993 | + const height = imageData.height; |
| 994 | + const data = imageData.data; |
| 995 | + |
| 996 | + // Create spatial weight map |
| 997 | + const weights = new Float32Array(width * height); |
| 998 | + |
| 999 | + // Detect edges using simple gradient |
| 1000 | + for (let y = 1; y < height - 1; y++) { |
| 1001 | + for (let x = 1; x < width - 1; x++) { |
| 1002 | + const idx = (y * width + x) * 4; |
| 1003 | + const idxLeft = (y * width + (x - 1)) * 4; |
| 1004 | + const idxRight = (y * width + (x + 1)) * 4; |
| 1005 | + const idxUp = ((y - 1) * width + x) * 4; |
| 1006 | + const idxDown = ((y + 1) * width + x) * 4; |
| 1007 | + |
| 1008 | + // Calculate gradient |
| 1009 | + const gradX = Math.abs(data[idxRight] - data[idxLeft]) + |
| 1010 | + Math.abs(data[idxRight + 1] - data[idxLeft + 1]) + |
| 1011 | + Math.abs(data[idxRight + 2] - data[idxLeft + 2]); |
| 1012 | + |
| 1013 | + const gradY = Math.abs(data[idxDown] - data[idxUp]) + |
| 1014 | + Math.abs(data[idxDown + 1] - data[idxUp + 1]) + |
| 1015 | + Math.abs(data[idxDown + 2] - data[idxUp + 2]); |
| 1016 | + |
| 1017 | + weights[y * width + x] = 1.0 + (gradX + gradY) / 765.0; // Normalize to [1, 2] |
| 1018 | + } |
| 1019 | + } |
| 1020 | + |
| 1021 | + // Refine palette considering spatial weights |
| 1022 | + const refinedPalette = this.refinePaletteWithWeights(imageData, initialPalette, weights); |
| 1023 | + |
| 1024 | + return refinedPalette; |
| 1025 | + } |
| 1026 | + |
| 1027 | + refinePaletteWithWeights(imageData, palette, weights) { |
| 1028 | + const data = imageData.data; |
| 1029 | + const width = imageData.width; |
| 1030 | + const refinedPalette = [...palette]; |
| 1031 | + |
| 1032 | + // Iterate to refine colors based on weighted assignment |
| 1033 | + for (let iter = 0; iter < 3; iter++) { |
| 1034 | + const clusters = Array(palette.length).fill(null).map(() => ({ sum: [0, 0, 0], weight: 0 })); |
| 1035 | + |
| 1036 | + // Assign pixels to clusters with weights |
| 1037 | + for (let i = 0, w = 0; i < data.length; i += 4, w++) { |
| 1038 | + const pixel = [data[i], data[i + 1], data[i + 2]]; |
| 1039 | + const weight = weights[w] || 1.0; |
| 1040 | + |
| 1041 | + let minDist = Infinity; |
| 1042 | + let bestCluster = 0; |
| 1043 | + |
| 1044 | + for (let j = 0; j < refinedPalette.length; j++) { |
| 1045 | + const dist = this.colorDistanceSquared(pixel, refinedPalette[j]); |
| 1046 | + if (dist < minDist) { |
| 1047 | + minDist = dist; |
| 1048 | + bestCluster = j; |
| 1049 | + } |
| 1050 | + } |
| 1051 | + |
| 1052 | + // Add weighted contribution |
| 1053 | + clusters[bestCluster].sum[0] += pixel[0] * weight; |
| 1054 | + clusters[bestCluster].sum[1] += pixel[1] * weight; |
| 1055 | + clusters[bestCluster].sum[2] += pixel[2] * weight; |
| 1056 | + clusters[bestCluster].weight += weight; |
| 1057 | + } |
| 1058 | + |
| 1059 | + // Update palette colors |
| 1060 | + for (let j = 0; j < refinedPalette.length; j++) { |
| 1061 | + if (clusters[j].weight > 0) { |
| 1062 | + refinedPalette[j] = [ |
| 1063 | + Math.round(clusters[j].sum[0] / clusters[j].weight), |
| 1064 | + Math.round(clusters[j].sum[1] / clusters[j].weight), |
| 1065 | + Math.round(clusters[j].sum[2] / clusters[j].weight) |
| 1066 | + ]; |
| 1067 | + } |
| 1068 | + } |
| 1069 | + } |
| 1070 | + |
| 1071 | + return refinedPalette; |
| 1072 | + } |
| 1073 | + |
776 | 1074 | applyPaletteToImageData(imageData, palette) { |
777 | 1075 | const data = new Uint8ClampedArray(imageData.data); |
778 | 1076 |
|
|
0 commit comments