UNPKG

img-to-text-computational

Version:

High-performance image-to-text analyzer using pure computational methods. Convert images to structured text descriptions with 99.9% accuracy, zero AI dependencies, and complete offline processing.

543 lines (457 loc) 15.5 kB
const sharp = require('sharp'); const quantize = require('quantize'); const { Stats } = require('fast-stats'); class ColorAnalyzer { constructor(options = {}) { this.options = { maxColors: options.maxColors || 16, minPixelThreshold: options.minPixelThreshold || 0.01, // 1% minimum contrastThreshold: options.contrastThreshold || 4.5, // WCAG AA ...options }; } /** * Analyze image colors comprehensively * @param {Buffer} imageBuffer - Image buffer * @returns {Promise<Object>} Complete color analysis */ async analyze(imageBuffer) { try { // Get image metadata const metadata = await sharp(imageBuffer).metadata(); // Get raw pixel data const { data, info } = await sharp(imageBuffer) .raw() .toBuffer({ resolveWithObject: true }); const analysis = { metadata: { width: info.width, height: info.height, channels: info.channels, total_pixels: info.width * info.height }, color_palette: await this.extractColorPalette(data, info), dominant_colors: await this.findDominantColors(data, info), color_statistics: await this.calculateColorStatistics(data, info), color_harmony: await this.analyzeColorHarmony(data, info), accessibility: await this.analyzeAccessibility(data, info) }; return analysis; } catch (error) { throw new Error(`Color analysis failed: ${error.message}`); } } /** * Extract complete color palette from image */ async extractColorPalette(pixelData, info) { const pixels = []; const step = info.channels; // Sample pixels (every 4th pixel for performance on large images) const sampleRate = Math.max(1, Math.floor(info.width * info.height / 100000)); for (let i = 0; i < pixelData.length; i += step * sampleRate) { const r = pixelData[i]; const g = pixelData[i + 1]; const b = pixelData[i + 2]; if (r !== undefined && g !== undefined && b !== undefined) { pixels.push([r, g, b]); } } // Quantize colors to reduce palette size const cmap = quantize(pixels, this.options.maxColors); const quantizedColors = cmap ? cmap.palette() : []; // Count occurrences of each color const colorCounts = this.countColors(pixelData, info, quantizedColors); // Create palette with metadata const palette = quantizedColors.map((color, index) => { const hex = this.rgbToHex(color[0], color[1], color[2]); const count = colorCounts[index] || 0; const percentage = (count / (info.width * info.height)) * 100; return { rgb: { r: color[0], g: color[1], b: color[2] }, hex, hsl: this.rgbToHsl(color[0], color[1], color[2]), count, percentage, name: this.getColorName(color[0], color[1], color[2]) }; }); return palette .filter(color => color.percentage >= this.options.minPixelThreshold) .sort((a, b) => b.percentage - a.percentage); } /** * Find dominant colors with clustering */ async findDominantColors(pixelData, info) { const palette = await this.extractColorPalette(pixelData, info); // Get top 5 most dominant colors const dominant = palette.slice(0, 5); // Classify colors by usage const primary = dominant[0]; const secondary = dominant[1] || primary; const accent = dominant.find(color => this.getColorBrightness(color.rgb) > 0.7 || this.getColorSaturation(color.hsl) > 0.8 ) || dominant[2]; // Find background color (usually most common neutral) const background = dominant.find(color => this.getColorSaturation(color.hsl) < 0.2 && (this.getColorBrightness(color.rgb) > 0.8 || this.getColorBrightness(color.rgb) < 0.2) ) || primary; // Find text colors (high contrast with background) const textColors = dominant.filter(color => this.calculateContrast(color.rgb, background.rgb) >= this.options.contrastThreshold ); return { primary, secondary, accent, background, text_colors: textColors, all_dominant: dominant }; } /** * Calculate comprehensive color statistics */ async calculateColorStatistics(pixelData, info) { const step = info.channels; const rValues = []; const gValues = []; const bValues = []; const brightnessValues = []; const saturationValues = []; // Sample every 100th pixel for performance for (let i = 0; i < pixelData.length; i += step * 100) { const r = pixelData[i]; const g = pixelData[i + 1]; const b = pixelData[i + 2]; if (r !== undefined && g !== undefined && b !== undefined) { rValues.push(r); gValues.push(g); bValues.push(b); brightnessValues.push(this.getColorBrightness({ r, g, b })); const hsl = this.rgbToHsl(r, g, b); saturationValues.push(hsl.s); } } const rStats = new Stats().push(rValues); const gStats = new Stats().push(gValues); const bStats = new Stats().push(bValues); const brightnessStats = new Stats().push(brightnessValues); const saturationStats = new Stats().push(saturationValues); return { rgb_means: { r: Math.round(rStats.amean()), g: Math.round(gStats.amean()), b: Math.round(bStats.amean()) }, rgb_std: { r: Math.round(rStats.stddev()), g: Math.round(gStats.stddev()), b: Math.round(bStats.stddev()) }, brightness: { mean: brightnessStats.amean(), std: brightnessStats.stddev(), min: brightnessStats.range()[0], max: brightnessStats.range()[1] }, saturation: { mean: saturationStats.amean(), std: saturationStats.stddev(), min: saturationStats.range()[0], max: saturationStats.range()[1] }, color_diversity: this.calculateColorDiversity(rStats, gStats, bStats) }; } /** * Analyze color harmony and relationships */ async analyzeColorHarmony(pixelData, info) { const palette = await this.extractColorPalette(pixelData, info); const dominant = palette.slice(0, 8); const harmony = { scheme_type: this.detectColorScheme(dominant), temperature: this.analyzeColorTemperature(dominant), contrast_level: this.analyzeContrastLevel(dominant), color_relationships: this.findColorRelationships(dominant) }; return harmony; } /** * Analyze accessibility compliance */ async analyzeAccessibility(pixelData, info) { const dominant = await this.findDominantColors(pixelData, info); const accessibility = { contrast_ratios: [], wcag_aa_compliant: false, wcag_aaa_compliant: false, recommendations: [] }; // Check contrast between potential text and background colors const textCandidates = dominant.all_dominant.filter(color => this.getColorBrightness(color.rgb) < 0.3 || this.getColorBrightness(color.rgb) > 0.7 ); const backgroundCandidates = dominant.all_dominant.filter(color => this.getColorSaturation(color.hsl) < 0.3 ); for (const textColor of textCandidates) { for (const bgColor of backgroundCandidates) { const contrast = this.calculateContrast(textColor.rgb, bgColor.rgb); accessibility.contrast_ratios.push({ text_color: textColor.hex, background_color: bgColor.hex, contrast_ratio: contrast, wcag_aa: contrast >= 4.5, wcag_aaa: contrast >= 7 }); } } // Determine overall compliance accessibility.wcag_aa_compliant = accessibility.contrast_ratios.some(ratio => ratio.wcag_aa); accessibility.wcag_aaa_compliant = accessibility.contrast_ratios.some(ratio => ratio.wcag_aaa); // Generate recommendations if (!accessibility.wcag_aa_compliant) { accessibility.recommendations.push('Increase contrast between text and background colors'); } return accessibility; } /** * Count color occurrences */ countColors(pixelData, info, quantizedColors) { const counts = new Array(quantizedColors.length).fill(0); const step = info.channels; for (let i = 0; i < pixelData.length; i += step) { const r = pixelData[i]; const g = pixelData[i + 1]; const b = pixelData[i + 2]; if (r !== undefined && g !== undefined && b !== undefined) { // Find closest quantized color const closestIndex = this.findClosestColorIndex([r, g, b], quantizedColors); counts[closestIndex]++; } } return counts; } /** * Find closest color in quantized palette */ findClosestColorIndex(targetColor, palette) { let minDistance = Infinity; let closestIndex = 0; for (let i = 0; i < palette.length; i++) { const distance = this.colorDistance(targetColor, palette[i]); if (distance < minDistance) { minDistance = distance; closestIndex = i; } } return closestIndex; } /** * Calculate Euclidean distance between colors */ colorDistance(color1, color2) { const dr = color1[0] - color2[0]; const dg = color1[1] - color2[1]; const db = color1[2] - color2[2]; return Math.sqrt(dr * dr + dg * dg + db * db); } /** * Convert RGB to Hex */ rgbToHex(r, g, b) { return `#${ ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } /** * Convert RGB to HSL */ rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 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, l }; } /** * Get color brightness (0-1) */ getColorBrightness(rgb) { return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; } /** * Get color saturation from HSL */ getColorSaturation(hsl) { return hsl.s; } /** * Calculate contrast ratio between two colors */ calculateContrast(color1, color2) { const l1 = this.getLuminance(color1); const l2 = this.getLuminance(color2); const bright = Math.max(l1, l2); const dark = Math.min(l1, l2); return (bright + 0.05) / (dark + 0.05); } /** * Calculate relative luminance */ getLuminance(rgb) { const rsRGB = rgb.r / 255; const gsRGB = rgb.g / 255; const bsRGB = rgb.b / 255; const r = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); const g = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); const b = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * Detect color scheme type */ detectColorScheme(colors) { if (colors.length < 2) return 'monochromatic'; const hues = colors.map(color => color.hsl.h); const saturations = colors.map(color => color.hsl.s); // Check for monochromatic (similar hues) const hueRange = Math.max(...hues) - Math.min(...hues); if (hueRange < 30) return 'monochromatic'; // Check for analogous (adjacent hues) if (hueRange < 90) return 'analogous'; // Check for complementary (opposite hues) const hasComplementary = hues.some(h1 => hues.some(h2 => Math.abs(h1 - h2) > 150 && Math.abs(h1 - h2) < 210) ); if (hasComplementary) return 'complementary'; // Check for triadic (120° apart) const hasTriadic = hues.some(h1 => hues.some(h2 => hues.some(h3 => Math.abs(h1 - h2) > 110 && Math.abs(h1 - h2) < 130 && Math.abs(h2 - h3) > 110 && Math.abs(h2 - h3) < 130 )) ); if (hasTriadic) return 'triadic'; return 'complex'; } /** * Analyze color temperature */ analyzeColorTemperature(colors) { let warmCount = 0; let coolCount = 0; colors.forEach(color => { const hue = color.hsl.h; if ((hue >= 0 && hue <= 60) || (hue >= 300 && hue <= 360)) { warmCount++; // Red, orange, yellow } else if (hue >= 180 && hue <= 240) { coolCount++; // Blue, cyan } }); if (warmCount > coolCount * 1.5) return 'warm'; if (coolCount > warmCount * 1.5) return 'cool'; return 'neutral'; } /** * Analyze overall contrast level */ analyzeContrastLevel(colors) { const brightnesses = colors.map(color => this.getColorBrightness(color.rgb)); const minBrightness = Math.min(...brightnesses); const maxBrightness = Math.max(...brightnesses); const range = maxBrightness - minBrightness; if (range > 0.7) return 'high'; if (range > 0.4) return 'medium'; return 'low'; } /** * Find color relationships */ findColorRelationships(colors) { const relationships = []; for (let i = 0; i < colors.length; i++) { for (let j = i + 1; j < colors.length; j++) { const color1 = colors[i]; const color2 = colors[j]; const relationship = this.getColorRelationship(color1.hsl, color2.hsl); if (relationship !== 'unrelated') { relationships.push({ color1: color1.hex, color2: color2.hex, relationship }); } } } return relationships; } /** * Determine relationship between two colors */ getColorRelationship(hsl1, hsl2) { const hueDiff = Math.abs(hsl1.h - hsl2.h); const satDiff = Math.abs(hsl1.s - hsl2.s); const lightDiff = Math.abs(hsl1.l - hsl2.l); if (hueDiff < 15 && satDiff < 0.2 && lightDiff < 0.2) return 'identical'; if (hueDiff < 30) return 'analogous'; if (hueDiff > 150 && hueDiff < 210) return 'complementary'; if (Math.abs(hueDiff - 120) < 15 || Math.abs(hueDiff - 240) < 15) return 'triadic'; if (Math.abs(hueDiff - 90) < 15 || Math.abs(hueDiff - 270) < 15) return 'square'; return 'unrelated'; } /** * Calculate color diversity score */ calculateColorDiversity(rStats, gStats, bStats) { // Use standard deviation instead of variance, which is more readily available const rStd = rStats.stddev(); const gStd = gStats.stddev(); const bStd = bStats.stddev(); const avgStd = (rStd + gStd + bStd) / 3; const maxStd = 255; // Maximum possible standard deviation return avgStd / maxStd; // Normalized 0-1 } /** * Get approximate color name */ getColorName(r, g, b) { const hsl = this.rgbToHsl(r, g, b); const { h, s, l } = hsl; // Very basic color naming if (s < 0.1) { if (l > 0.9) return 'white'; if (l < 0.1) return 'black'; if (l > 0.7) return 'light_gray'; if (l < 0.3) return 'dark_gray'; return 'gray'; } if (h < 15 || h > 345) return 'red'; if (h < 45) return 'orange'; if (h < 75) return 'yellow'; if (h < 150) return 'green'; if (h < 210) return 'blue'; if (h < 270) return 'purple'; if (h < 330) return 'pink'; return 'unknown'; } } module.exports = ColorAnalyzer;