|
@@ -0,0 +1,365 @@
|
|
|
+import { Component, Inject, OnDestroy, OnInit, ViewChild, ElementRef } from '@angular/core';
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+
|
|
|
+export interface ProjectFileLike {
|
|
|
+ id?: string;
|
|
|
+ name?: string;
|
|
|
+ originalName?: string;
|
|
|
+ url: string;
|
|
|
+ type?: string;
|
|
|
+ size?: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface PaletteColor {
|
|
|
+ rgb: { r: number; g: number; b: number };
|
|
|
+ hex: string;
|
|
|
+ percentage: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface ColorAnalysisReport {
|
|
|
+ palette: PaletteColor[];
|
|
|
+ mosaicUrl?: string;
|
|
|
+ metrics: {
|
|
|
+ warmCoolBalance: number; // -100(冷) 到 100(暖)
|
|
|
+ averageBrightness: number; // 0-100
|
|
|
+ averageSaturation: number; // 0-100
|
|
|
+ diversity: number; // 色彩多样度:主色数量
|
|
|
+ };
|
|
|
+ histogram: {
|
|
|
+ brightnessBins: number[]; // 0..100
|
|
|
+ saturationBins: number[]; // 0..100
|
|
|
+ };
|
|
|
+ splitPoints: Array<{ temp: number; brightness: number; size: number; color: string }>;
|
|
|
+}
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-color-get-dialog',
|
|
|
+ standalone: true,
|
|
|
+ imports: [CommonModule, MatDialogModule],
|
|
|
+ templateUrl: './color-get-dialog.component.html',
|
|
|
+ styleUrls: ['./color-get-dialog.component.scss']
|
|
|
+})
|
|
|
+export class ColorGetDialogComponent implements OnInit, OnDestroy {
|
|
|
+ @ViewChild('mosaicCanvas') mosaicCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
+ @ViewChild('histogramChart') histogramChartRef!: ElementRef<HTMLDivElement>;
|
|
|
+ @ViewChild('splitChart') splitChartRef!: ElementRef<HTMLDivElement>;
|
|
|
+
|
|
|
+ imageUrl = '';
|
|
|
+ imageName = '';
|
|
|
+ imageSize?: number;
|
|
|
+ loading = true;
|
|
|
+ progress = 0;
|
|
|
+
|
|
|
+ pixelSize = 100; // 参考图马赛克像素块大小
|
|
|
+ report?: ColorAnalysisReport;
|
|
|
+
|
|
|
+ private histogramChart?: echarts.ECharts;
|
|
|
+ private splitChart?: echarts.ECharts;
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private dialogRef: MatDialogRef<ColorGetDialogComponent>,
|
|
|
+ @Inject(MAT_DIALOG_DATA) public data: { file?: ProjectFileLike; url?: string; name?: string }
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ ngOnInit(): void {
|
|
|
+ const file = this.data?.file;
|
|
|
+ this.imageUrl = file?.url || this.data?.url || '';
|
|
|
+ this.imageName = file?.originalName || file?.name || this.data?.name || '未命名图片';
|
|
|
+ this.imageSize = file?.size;
|
|
|
+
|
|
|
+ if (!this.imageUrl) {
|
|
|
+ this.loading = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开始分析
|
|
|
+ this.loadAndAnalyze(this.imageUrl).catch(err => {
|
|
|
+ console.error('色彩分析失败:', err);
|
|
|
+ this.loading = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ if (this.histogramChart) {
|
|
|
+ this.histogramChart.dispose();
|
|
|
+ }
|
|
|
+ if (this.splitChart) {
|
|
|
+ this.splitChart.dispose();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ close(): void {
|
|
|
+ this.dialogRef.close();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadAndAnalyze(url: string): Promise<void> {
|
|
|
+ this.progress = 5;
|
|
|
+ const img = await this.loadImage(url);
|
|
|
+ this.progress = 20;
|
|
|
+
|
|
|
+ const { mosaicCanvas, blockColors } = this.createMosaic(img, this.pixelSize);
|
|
|
+ this.progress = 50;
|
|
|
+
|
|
|
+ // 显示马赛克图
|
|
|
+ const canvas = this.mosaicCanvasRef.nativeElement;
|
|
|
+ canvas.width = mosaicCanvas.width;
|
|
|
+ canvas.height = mosaicCanvas.height;
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (ctx) ctx.drawImage(mosaicCanvas, 0, 0);
|
|
|
+
|
|
|
+ // 构建色彩报告
|
|
|
+ this.report = this.buildReport(blockColors);
|
|
|
+ this.progress = 80;
|
|
|
+
|
|
|
+ // 渲染图表
|
|
|
+ setTimeout(() => {
|
|
|
+ this.renderHistogram();
|
|
|
+ this.renderSplitChart();
|
|
|
+ this.progress = 100;
|
|
|
+ this.loading = false;
|
|
|
+ }, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private loadImage(url: string): Promise<HTMLImageElement> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const img = new Image();
|
|
|
+ img.crossOrigin = 'anonymous';
|
|
|
+ img.onload = () => resolve(img);
|
|
|
+ img.onerror = reject;
|
|
|
+ img.src = url;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private createMosaic(image: HTMLImageElement, pixelSize: number): { mosaicCanvas: HTMLCanvasElement; blockColors: { r: number; g: number; b: number }[] } {
|
|
|
+ const width = image.width;
|
|
|
+ const height = image.height;
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
+ canvas.width = width;
|
|
|
+ canvas.height = height;
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
+ ctx.drawImage(image, 0, 0, width, height);
|
|
|
+
|
|
|
+ const blockColors: { r: number; g: number; b: number }[] = [];
|
|
|
+
|
|
|
+ for (let y = 0; y < height; y += pixelSize) {
|
|
|
+ for (let x = 0; x < width; x += pixelSize) {
|
|
|
+ const bw = Math.min(pixelSize, width - x);
|
|
|
+ const bh = Math.min(pixelSize, height - y);
|
|
|
+ const data = ctx.getImageData(x, y, bw, bh).data;
|
|
|
+
|
|
|
+ let r = 0, g = 0, b = 0;
|
|
|
+ const count = bw * bh;
|
|
|
+ for (let i = 0; i < data.length; i += 4) {
|
|
|
+ r += data[i];
|
|
|
+ g += data[i + 1];
|
|
|
+ b += data[i + 2];
|
|
|
+ }
|
|
|
+ r = Math.round(r / count);
|
|
|
+ g = Math.round(g / count);
|
|
|
+ b = Math.round(b / count);
|
|
|
+
|
|
|
+ blockColors.push({ r, g, b });
|
|
|
+
|
|
|
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
|
+ ctx.fillRect(x, y, bw, bh);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { mosaicCanvas: canvas, blockColors };
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildReport(blockColors: { r: number; g: number; b: number }[]): ColorAnalysisReport {
|
|
|
+ // 统计颜色频率
|
|
|
+ const colorMap = new Map<string, number>();
|
|
|
+ const totalBlocks = blockColors.length;
|
|
|
+
|
|
|
+ blockColors.forEach(({ r, g, b }) => {
|
|
|
+ const key = `${r},${g},${b}`; // 精确块平均色
|
|
|
+ colorMap.set(key, (colorMap.get(key) || 0) + 1);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 合并近似色(降低抖动),按 16 级量化
|
|
|
+ const quantMap = new Map<string, number>();
|
|
|
+ colorMap.forEach((count, key) => {
|
|
|
+ const [r, g, b] = key.split(',').map(Number);
|
|
|
+ const qr = Math.round(r / 16) * 16;
|
|
|
+ const qg = Math.round(g / 16) * 16;
|
|
|
+ const qb = Math.round(b / 16) * 16;
|
|
|
+ const qkey = `${qr},${qg},${qb}`;
|
|
|
+ quantMap.set(qkey, (quantMap.get(qkey) || 0) + count);
|
|
|
+ });
|
|
|
+
|
|
|
+ const palette = Array.from(quantMap.entries())
|
|
|
+ .map(([k, c]) => {
|
|
|
+ const [r, g, b] = k.split(',').map(Number);
|
|
|
+ const hex = this.rgbToHex(r, g, b);
|
|
|
+ return {
|
|
|
+ rgb: { r, g, b },
|
|
|
+ hex,
|
|
|
+ percentage: +(c / totalBlocks * 100).toFixed(2)
|
|
|
+ } as PaletteColor;
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.percentage - a.percentage)
|
|
|
+ .slice(0, 20);
|
|
|
+
|
|
|
+ // 计算指标与分布
|
|
|
+ const brightnessBins = new Array(10).fill(0); // 10等分
|
|
|
+ const saturationBins = new Array(10).fill(0);
|
|
|
+ let sumBrightness = 0;
|
|
|
+ let sumSaturation = 0;
|
|
|
+ let sumTempScore = 0;
|
|
|
+
|
|
|
+ const splitPoints: Array<{ temp: number; brightness: number; size: number; color: string }> = [];
|
|
|
+
|
|
|
+ blockColors.forEach(({ r, g, b }) => {
|
|
|
+ const { h, s, v } = this.rgbToHsv(r, g, b);
|
|
|
+ const brightness = v * 100;
|
|
|
+ const saturation = s * 100;
|
|
|
+ const temp = this.warmCoolScoreFromHue(h); // -100..100
|
|
|
+
|
|
|
+ sumBrightness += brightness;
|
|
|
+ sumSaturation += saturation;
|
|
|
+ sumTempScore += temp;
|
|
|
+
|
|
|
+ const bIdx = Math.min(9, Math.floor(brightness / 10));
|
|
|
+ const sIdx = Math.min(9, Math.floor(saturation / 10));
|
|
|
+ brightnessBins[bIdx]++;
|
|
|
+ saturationBins[sIdx]++;
|
|
|
+
|
|
|
+ splitPoints.push({ temp, brightness, size: 1, color: `rgb(${r},${g},${b})` });
|
|
|
+ });
|
|
|
+
|
|
|
+ const avgBrightness = +(sumBrightness / totalBlocks).toFixed(1);
|
|
|
+ const avgSaturation = +(sumSaturation / totalBlocks).toFixed(1);
|
|
|
+ const warmCoolBalance = +((sumTempScore / totalBlocks)).toFixed(1);
|
|
|
+
|
|
|
+ return {
|
|
|
+ palette,
|
|
|
+ metrics: {
|
|
|
+ warmCoolBalance,
|
|
|
+ averageBrightness: avgBrightness,
|
|
|
+ averageSaturation: avgSaturation,
|
|
|
+ diversity: palette.length
|
|
|
+ },
|
|
|
+ histogram: {
|
|
|
+ brightnessBins: brightnessBins.map(v => Math.round(v)),
|
|
|
+ saturationBins: saturationBins.map(v => Math.round(v))
|
|
|
+ },
|
|
|
+ splitPoints
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private renderHistogram(): void {
|
|
|
+ if (!this.report) return;
|
|
|
+ const el = this.histogramChartRef.nativeElement;
|
|
|
+ this.histogramChart = echarts.init(el);
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: {},
|
|
|
+ grid: { left: 40, right: 20, top: 30, bottom: 40 },
|
|
|
+ legend: { data: ['亮度', '饱和度'] },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: ['0-10', '10-20', '20-30', '30-40', '40-50', '50-60', '60-70', '70-80', '80-90', '90-100']
|
|
|
+ },
|
|
|
+ yAxis: { type: 'value' },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '亮度',
|
|
|
+ type: 'bar',
|
|
|
+ data: this.report.histogram.brightnessBins,
|
|
|
+ itemStyle: { color: '#4ECDC4' }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '饱和度',
|
|
|
+ type: 'bar',
|
|
|
+ data: this.report.histogram.saturationBins,
|
|
|
+ itemStyle: { color: '#FF6B6B' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ this.histogramChart.setOption(option);
|
|
|
+ }
|
|
|
+
|
|
|
+ private renderSplitChart(): void {
|
|
|
+ if (!this.report) return;
|
|
|
+ const el = this.splitChartRef.nativeElement;
|
|
|
+ this.splitChart = echarts.init(el);
|
|
|
+
|
|
|
+ const data = this.report.splitPoints.map(p => [p.temp, p.brightness, p.size, p.color]);
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const [temp, bright, , color] = params.data;
|
|
|
+ return `冷暖: ${temp.toFixed(1)}<br/>亮度: ${bright.toFixed(1)}<br/><span style="display:inline-block;width:12px;height:12px;background:${color};margin-right:6px"></span>${color}`;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ min: -100,
|
|
|
+ max: 100,
|
|
|
+ name: '冷 ← → 暖'
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ min: 0,
|
|
|
+ max: 100,
|
|
|
+ name: '暗 ↑ ↓ 亮'
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '色彩拆分',
|
|
|
+ type: 'scatter',
|
|
|
+ symbolSize: 8,
|
|
|
+ data,
|
|
|
+ itemStyle: {
|
|
|
+ color: (p: any) => p.data[3]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ this.splitChart.setOption(option);
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- 色彩工具函数 ---
|
|
|
+ private rgbToHex(r: number, g: number, b: number): string {
|
|
|
+ const toHex = (n: number) => n.toString(16).padStart(2, '0');
|
|
|
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ private rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
|
|
+ r /= 255; g /= 255; b /= 255;
|
|
|
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
|
+ const v = max;
|
|
|
+ const d = max - min;
|
|
|
+ const s = max === 0 ? 0 : d / max;
|
|
|
+ let h = 0;
|
|
|
+ if (max === min) {
|
|
|
+ h = 0;
|
|
|
+ } else {
|
|
|
+ switch (max) {
|
|
|
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
|
|
+ case g: h = (b - r) / d + 2; break;
|
|
|
+ case b: h = (r - g) / d + 4; break;
|
|
|
+ }
|
|
|
+ h /= 6;
|
|
|
+ }
|
|
|
+ return { h: h * 360, s, v };
|
|
|
+ }
|
|
|
+
|
|
|
+ private warmCoolScoreFromHue(hue: number): number {
|
|
|
+ // 简化冷暖评分:红橙黄(0-60)与洋红(300-360)偏暖;青蓝紫(180-300)偏冷;绿色(60-180)中性略冷
|
|
|
+ if (hue >= 0 && hue < 60) return 80 - (60 - hue) * (80 / 60); // 80→0
|
|
|
+ if (hue >= 60 && hue < 120) return 20 - (120 - hue) * (20 / 60); // 20→0
|
|
|
+ if (hue >= 120 && hue < 180) return -20 + (hue - 120) * (20 / 60); // -20→0
|
|
|
+ if (hue >= 180 && hue < 240) return -60 + (hue - 180) * (40 / 60); // -60→-20
|
|
|
+ if (hue >= 240 && hue < 300) return -80 + (hue - 240) * (20 / 60); // -80→-60
|
|
|
+ if (hue >= 300 && hue <= 360) return 80 - (360 - hue) * (80 / 60); // 80→0
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+}
|