"""
Color analysis functions for statistical and color space analysis.
This module provides tools for analyzing color distributions, converting
between color spaces, and computing color statistics from artworks.
"""
import numpy as np
from typing import List, Dict, Tuple, Optional, Union
from collections import Counter
import colorsys
try:
from scipy.optimize import linear_sum_assignment
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
[docs]
class ColorAnalyzer:
"""
Analyze color distributions and relationships in artworks.
This class provides methods for statistical analysis of colors,
color space conversions, and comparative analysis across artworks.
Designed for teaching color theory and computational analysis to
art and design students.
"""
def __init__(self):
"""Initialize the ColorAnalyzer."""
pass
[docs]
def rgb_to_hsv(self, rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
"""
Convert RGB color to HSV (Hue, Saturation, Value) color space.
HSV is often more intuitive for artists and designers as it
separates color into hue (color type), saturation (intensity),
and value (brightness).
Args:
rgb: Tuple of (R, G, B) values (0-255)
Returns:
Tuple of (H, S, V) where:
H: Hue in degrees (0-360)
S: Saturation as percentage (0-100)
V: Value as percentage (0-100)
Example:
>>> analyzer = ColorAnalyzer()
>>> hsv = analyzer.rgb_to_hsv((255, 87, 51))
>>> print(f"Hue: {hsv[0]}°, Saturation: {hsv[1]}%, Value: {hsv[2]}%")
"""
# Normalize RGB to 0-1
r, g, b = rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
# Convert using colorsys
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# Convert to standard ranges
return (h * 360, s * 100, v * 100)
[docs]
def hsv_to_rgb(self, hsv: Tuple[float, float, float]) -> Tuple[int, int, int]:
"""
Convert HSV color to RGB color space.
Args:
hsv: Tuple of (H, S, V) where:
H: Hue in degrees (0-360)
S: Saturation as percentage (0-100)
V: Value as percentage (0-100)
Returns:
Tuple of (R, G, B) values (0-255)
Example:
>>> analyzer = ColorAnalyzer()
>>> rgb = analyzer.hsv_to_rgb((10, 80, 100))
>>> print(f"RGB: {rgb}")
"""
# Normalize to 0-1
h, s, v = hsv[0] / 360.0, hsv[1] / 100.0, hsv[2] / 100.0
# Convert using colorsys
r, g, b = colorsys.hsv_to_rgb(h, s, v)
# Convert to 0-255 range
return (int(r * 255), int(g * 255), int(b * 255))
[docs]
def rgb_to_hsl(self, rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
"""
Convert RGB color to HSL (Hue, Saturation, Lightness) color space.
Args:
rgb: Tuple of (R, G, B) values (0-255)
Returns:
Tuple of (H, S, L) where:
H: Hue in degrees (0-360)
S: Saturation as percentage (0-100)
L: Lightness as percentage (0-100)
"""
# Normalize RGB to 0-1
r, g, b = rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
# Convert using colorsys
h, l, s = colorsys.rgb_to_hls(r, g, b)
# Convert to standard ranges
return (h * 360, s * 100, l * 100)
[docs]
def hsl_to_rgb(self, hsl: Tuple[float, float, float]) -> Tuple[int, int, int]:
"""
Convert HSL color to RGB.
Args:
hsl: Tuple of (H, S, L) where:
H: Hue in degrees (0-360)
S: Saturation as percentage (0-100)
L: Lightness as percentage (0-100)
Returns:
Tuple of (R, G, B) values (0-255)
"""
# Validate input
if not isinstance(hsl, (tuple, list)) or len(hsl) != 3:
raise ValueError("HSL must be a tuple of 3 values")
h, s, l = hsl
if not (0 <= h <= 360 and 0 <= s <= 100 and 0 <= l <= 100):
raise ValueError("H must be 0-360, S and L must be 0-100")
# Normalize to 0-1 range
h = h / 360.0
s = s / 100.0
l = l / 100.0
# Convert using colorsys
r, g, b = colorsys.hls_to_rgb(h, l, s)
# Scale to 0-255
return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255)))
[docs]
def analyze_palette_statistics(self, colors: List[Tuple[int, int, int]]) -> Dict:
"""
Compute statistical measures for a color palette.
Educational method for teaching students about color data analysis.
Args:
colors: List of RGB tuples
Returns:
Dictionary containing:
- mean_rgb: Average RGB values
- std_rgb: Standard deviation of RGB values
- hsv_values: HSV representation of each color
- mean_hue: Average hue
- mean_saturation: Average saturation
- mean_value: Average brightness/value
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(255, 87, 51), (100, 200, 150), (50, 100, 200)]
>>> stats = analyzer.analyze_palette_statistics(colors)
>>> print(f"Average hue: {stats['mean_hue']:.1f}°")
"""
if not colors:
return {}
# Convert to numpy array for easy calculation
rgb_array = np.array(colors)
# RGB statistics
mean_rgb = tuple(np.mean(rgb_array, axis=0).astype(int))
std_rgb = tuple(np.std(rgb_array, axis=0).astype(int))
# Convert to HSV for color-space statistics
hsv_values = [self.rgb_to_hsv(color) for color in colors]
hsv_array = np.array(hsv_values)
# Handle circular mean for hue (0-360 degrees)
hues_rad = np.radians(hsv_array[:, 0])
mean_hue_rad = np.arctan2(np.mean(np.sin(hues_rad)), np.mean(np.cos(hues_rad)))
mean_hue = np.degrees(mean_hue_rad) % 360
stats = {
"n_colors": len(colors),
"mean_rgb": mean_rgb,
"std_rgb": std_rgb,
"hsv_values": hsv_values,
"mean_hue": float(mean_hue),
"mean_saturation": float(np.mean(hsv_array[:, 1])),
"mean_value": float(np.mean(hsv_array[:, 2])),
"std_hue": float(np.std(hsv_array[:, 0])),
"std_saturation": float(np.std(hsv_array[:, 1])),
"std_value": float(np.std(hsv_array[:, 2])),
}
return stats
[docs]
def calculate_color_diversity(self, colors: List[Tuple[int, int, int]]) -> float:
"""
Calculate color diversity using hue distribution entropy.
Higher values indicate more diverse color usage.
Useful for comparing artistic styles quantitatively.
Args:
colors: List of RGB tuples
Returns:
Diversity score (0-1, higher = more diverse)
Example:
>>> analyzer = ColorAnalyzer()
>>> monochrome = [(100, 100, 100), (110, 110, 110), (120, 120, 120)]
>>> diverse = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
>>> print(analyzer.calculate_color_diversity(monochrome)) # Low score
>>> print(analyzer.calculate_color_diversity(diverse)) # High score
"""
if len(colors) < 2:
return 0.0
# Convert to HSV
hsv_values = [self.rgb_to_hsv(color) for color in colors]
# Get hue values (0-360)
hues = [hsv[0] for hsv in hsv_values]
# Bin hues into 12 categories (like a color wheel)
bins = np.linspace(0, 360, 13)
hist, _ = np.histogram(hues, bins=bins)
# Calculate Shannon entropy
hist = hist / hist.sum()
hist = hist[hist > 0] # Remove zero bins
entropy = -np.sum(hist * np.log2(hist))
# Normalize to 0-1 (max entropy for 12 bins is log2(12))
max_entropy = np.log2(12)
diversity = entropy / max_entropy
return float(diversity)
[docs]
def calculate_saturation_score(self, colors: List[Tuple[int, int, int]]) -> float:
"""
Calculate average saturation score for a palette.
Useful for characterizing artistic styles:
- High saturation: Bold, vibrant (Fauvism, Pop Art)
- Low saturation: Muted, subtle (Impressionism, Realism)
Args:
colors: List of RGB tuples
Returns:
Average saturation (0-100)
Example:
>>> analyzer = ColorAnalyzer()
>>> vibrant = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
>>> muted = [(200, 180, 170), (150, 140, 130)]
>>> print(analyzer.calculate_saturation_score(vibrant)) # ~100
>>> print(analyzer.calculate_saturation_score(muted)) # ~20
"""
if not colors:
return 0.0
hsv_values = [self.rgb_to_hsv(color) for color in colors]
saturations = [hsv[1] for hsv in hsv_values]
return float(np.mean(saturations))
[docs]
def calculate_brightness_score(self, colors: List[Tuple[int, int, int]]) -> float:
"""
Calculate average brightness/value score for a palette.
Args:
colors: List of RGB tuples
Returns:
Average brightness (0-100)
"""
if not colors:
return 0.0
hsv_values = [self.rgb_to_hsv(color) for color in colors]
values = [hsv[2] for hsv in hsv_values]
return float(np.mean(values))
[docs]
def compare_palettes(
self, palette1: List[Tuple[int, int, int]], palette2: List[Tuple[int, int, int]]
) -> Dict:
"""
Compare two color palettes statistically.
Educational method for teaching comparative color analysis.
Args:
palette1: First list of RGB tuples
palette2: Second list of RGB tuples
Returns:
Dictionary with comparative statistics
Example:
>>> analyzer = ColorAnalyzer()
>>> monet_colors = [(120, 150, 180), (200, 220, 230)]
>>> picasso_colors = [(255, 50, 50), (50, 50, 200)]
>>> comparison = analyzer.compare_palettes(monet_colors, picasso_colors)
>>> print(f"Saturation difference: {comparison['saturation_diff']:.1f}%")
"""
stats1 = self.analyze_palette_statistics(palette1)
stats2 = self.analyze_palette_statistics(palette2)
comparison = {
"palette1_stats": stats1,
"palette2_stats": stats2,
"hue_diff": abs(stats1["mean_hue"] - stats2["mean_hue"]),
"saturation_diff": abs(
stats1["mean_saturation"] - stats2["mean_saturation"]
),
"brightness_diff": abs(stats1["mean_value"] - stats2["mean_value"]),
"diversity_diff": abs(
self.calculate_color_diversity(palette1)
- self.calculate_color_diversity(palette2)
),
}
return comparison
[docs]
def classify_color_temperature(self, rgb: Tuple[int, int, int]) -> str:
"""
Classify a color as warm or cool based on hue.
Educational method for teaching color theory concepts.
Args:
rgb: RGB tuple
Returns:
'warm', 'cool', or 'neutral'
Example:
>>> analyzer = ColorAnalyzer()
>>> print(analyzer.classify_color_temperature((255, 0, 0))) # 'warm'
>>> print(analyzer.classify_color_temperature((0, 0, 255))) # 'cool'
>>> print(analyzer.classify_color_temperature((128, 128, 128))) # 'neutral'
"""
hsv = self.rgb_to_hsv(rgb)
hue = hsv[0]
saturation = hsv[1]
# Low saturation colors are neutral
if saturation < 10:
return "neutral"
# Warm: red-orange-yellow (0-60 and 300-360)
# Cool: green-blue-purple (120-300)
if (hue >= 0 and hue <= 60) or (hue >= 300 and hue <= 360):
return "warm"
elif hue >= 120 and hue <= 300:
return "cool"
else:
return "neutral"
[docs]
def analyze_color_temperature_distribution(
self, colors: List[Tuple[int, int, int]]
) -> Dict:
"""
Analyze the distribution of warm vs. cool colors in a palette.
Args:
colors: List of RGB tuples
Returns:
Dictionary with temperature distribution statistics
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(255, 0, 0), (0, 0, 255), (0, 255, 0)]
>>> temp_dist = analyzer.analyze_color_temperature_distribution(colors)
>>> print(temp_dist)
"""
temperatures = [self.classify_color_temperature(color) for color in colors]
temp_counts = Counter(temperatures)
total = len(colors)
return {
"warm_count": temp_counts["warm"],
"cool_count": temp_counts["cool"],
"neutral_count": temp_counts["neutral"],
"warm_percentage": (temp_counts["warm"] / total) * 100,
"cool_percentage": (temp_counts["cool"] / total) * 100,
"neutral_percentage": (temp_counts["neutral"] / total) * 100,
"dominant_temperature": (
temp_counts.most_common(1)[0][0] if temp_counts else "none"
),
}
[docs]
def detect_complementary_colors(
self, colors: List[Tuple[int, int, int]], tolerance: float = 30
) -> List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]]:
"""
Detect complementary color pairs in a palette.
Complementary colors are opposite on the color wheel (180° apart).
Educational method for teaching color harmony.
Args:
colors: List of RGB tuples
tolerance: Hue difference tolerance in degrees (default: 30)
Returns:
List of complementary color pairs
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(255, 0, 0), (0, 255, 255), (128, 0, 128)]
>>> pairs = analyzer.detect_complementary_colors(colors)
"""
complementary_pairs = []
# Convert all to HSV
hsv_colors = [(color, self.rgb_to_hsv(color)) for color in colors]
# Check each pair
for i, (color1, hsv1) in enumerate(hsv_colors):
for color2, hsv2 in hsv_colors[i + 1 :]:
hue_diff = abs(hsv1[0] - hsv2[0])
# Account for circular nature of hue
if hue_diff > 180:
hue_diff = 360 - hue_diff
# Check if approximately 180° apart
if abs(hue_diff - 180) <= tolerance:
complementary_pairs.append((color1, color2))
return complementary_pairs
[docs]
def calculate_contrast_ratio(
self, color1: Tuple[int, int, int], color2: Tuple[int, int, int]
) -> float:
"""
Calculate WCAG contrast ratio between two colors.
Useful for teaching accessibility in design.
Ratio of 4.5:1 is minimum for normal text (WCAG AA).
Args:
color1: First RGB tuple
color2: Second RGB tuple
Returns:
Contrast ratio (1-21)
Example:
>>> analyzer = ColorAnalyzer()
>>> ratio = analyzer.calculate_contrast_ratio((0, 0, 0), (255, 255, 255))
>>> print(f"Contrast ratio: {ratio:.2f}:1") # 21.00:1
"""
def relative_luminance(rgb):
"""Calculate relative luminance for contrast."""
r, g, b = [x / 255.0 for x in rgb]
# Apply gamma correction
r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4
g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4
b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4
return 0.2126 * r + 0.7152 * g + 0.0722 * b
l1 = relative_luminance(color1)
l2 = relative_luminance(color2)
# Ensure l1 is the lighter color
if l2 > l1:
l1, l2 = l2, l1
ratio = (l1 + 0.05) / (l2 + 0.05)
return float(ratio)
[docs]
def detect_triadic_harmony(
self, colors: List[Tuple[int, int, int]], tolerance: float = 30
) -> List[Tuple[Tuple[int, int, int], Tuple[int, int, int], Tuple[int, int, int]]]:
"""
Detect triadic color harmonies in a palette.
Triadic harmonies are three colors equally spaced on the color wheel
(120° apart). Used by masters like Mondrian and in vibrant designs.
Args:
colors: List of RGB tuples
tolerance: Hue difference tolerance in degrees (default: 30)
Returns:
List of triadic color triplets
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)] # R, G, B
>>> triads = analyzer.detect_triadic_harmony(colors)
>>> print(f"Found {len(triads)} triadic harmonies")
"""
triadic_sets = []
# Convert all to HSV
hsv_colors = [(color, self.rgb_to_hsv(color)) for color in colors]
# Check each triplet
for i, (color1, hsv1) in enumerate(hsv_colors):
for j, (color2, hsv2) in enumerate(hsv_colors[i + 1 :], i + 1):
for color3, hsv3 in hsv_colors[j + 1 :]:
# Calculate hue differences
diff1 = abs(hsv1[0] - hsv2[0])
diff2 = abs(hsv2[0] - hsv3[0])
diff3 = abs(hsv3[0] - hsv1[0])
# Normalize to 0-180 range (account for circular nature)
diffs = []
for diff in [diff1, diff2, diff3]:
if diff > 180:
diff = 360 - diff
diffs.append(diff)
# Check if all approximately 120° apart
if all(abs(d - 120) <= tolerance for d in diffs):
triadic_sets.append((color1, color2, color3))
return triadic_sets
[docs]
def detect_analogous_harmony(
self, colors: List[Tuple[int, int, int]], max_hue_range: float = 60
) -> List[List[Tuple[int, int, int]]]:
"""
Detect analogous color schemes in a palette.
Analogous colors are adjacent on the color wheel (within 60° typically).
Creates harmonious, serene color schemes. Common in nature and landscapes.
Args:
colors: List of RGB tuples
max_hue_range: Maximum hue range in degrees (default: 60)
Returns:
List of analogous color groups (groups of 2+ colors)
Example:
>>> analyzer = ColorAnalyzer()
>>> # Blues and greens (analogous)
>>> colors = [(0, 100, 255), (0, 200, 200), (0, 255, 100)]
>>> groups = analyzer.detect_analogous_harmony(colors)
"""
if len(colors) < 2:
return []
# Convert to HSV and sort by hue
hsv_colors = [(color, self.rgb_to_hsv(color)) for color in colors]
hsv_colors.sort(key=lambda x: x[1][0]) # Sort by hue
analogous_groups = []
current_group = [hsv_colors[0][0]]
base_hue = hsv_colors[0][1][0]
for color, hsv in hsv_colors[1:]:
hue = hsv[0]
hue_diff = abs(hue - base_hue)
# Account for circular nature (e.g., 350° and 10° are close)
if hue_diff > 180:
hue_diff = 360 - hue_diff
if hue_diff <= max_hue_range:
current_group.append(color)
else:
if len(current_group) >= 2:
analogous_groups.append(current_group)
current_group = [color]
base_hue = hue
# Add last group if valid
if len(current_group) >= 2:
analogous_groups.append(current_group)
return analogous_groups
[docs]
def detect_split_complementary(
self, colors: List[Tuple[int, int, int]], tolerance: float = 30
) -> List[Tuple[Tuple[int, int, int], Tuple[int, int, int], Tuple[int, int, int]]]:
"""
Detect split-complementary color schemes.
Split-complementary uses a base color and two colors adjacent to its
complement (instead of the direct complement). Provides high contrast
while being more subtle than complementary. Popular in Renaissance art.
Args:
colors: List of RGB tuples
tolerance: Hue difference tolerance in degrees (default: 30)
Returns:
List of split-complementary triplets (base, complement1, complement2)
Example:
>>> analyzer = ColorAnalyzer()
>>> # Red with blue-green and yellow-green (instead of pure green)
>>> colors = [(255, 0, 0), (0, 200, 100), (100, 200, 0)]
>>> splits = analyzer.detect_split_complementary(colors)
"""
split_comp_sets = []
# Convert all to HSV
hsv_colors = [(color, self.rgb_to_hsv(color)) for color in colors]
# For each color, look for two colors ~150° and ~210° away (or ±150°)
for i, (base_color, base_hsv) in enumerate(hsv_colors):
base_hue = base_hsv[0]
complement_hue = (base_hue + 180) % 360
# Look for colors 30° on either side of complement
target_hue1 = (complement_hue - 30) % 360
target_hue2 = (complement_hue + 30) % 360
candidates1 = []
candidates2 = []
for j, (color, hsv) in enumerate(hsv_colors):
if i == j:
continue
hue = hsv[0]
# Check against target_hue1
diff1 = abs(hue - target_hue1)
if diff1 > 180:
diff1 = 360 - diff1
if diff1 <= tolerance:
candidates1.append((color, hsv))
# Check against target_hue2
diff2 = abs(hue - target_hue2)
if diff2 > 180:
diff2 = 360 - diff2
if diff2 <= tolerance:
candidates2.append((color, hsv))
# Create triplets
for color1, _ in candidates1:
for color2, _ in candidates2:
if color1 != color2:
split_comp_sets.append((base_color, color1, color2))
return split_comp_sets
[docs]
def detect_tetradic_harmony(
self, colors: List[Tuple[int, int, int]], tolerance: float = 30
) -> List[
Tuple[
Tuple[int, int, int],
Tuple[int, int, int],
Tuple[int, int, int],
Tuple[int, int, int],
]
]:
"""
Detect tetradic (double complementary) color harmonies.
Tetradic uses two complementary pairs, forming a rectangle on the
color wheel. Creates rich, diverse palettes. Used in complex
compositions and modern art.
Args:
colors: List of RGB tuples
tolerance: Hue difference tolerance in degrees (default: 30)
Returns:
List of tetradic color quartets
Example:
>>> analyzer = ColorAnalyzer()
>>> # Two complementary pairs
>>> colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
>>> tetrads = analyzer.detect_tetradic_harmony(colors)
"""
tetradic_sets = []
if len(colors) < 4:
return []
# Convert all to HSV
hsv_colors = [(color, self.rgb_to_hsv(color)) for color in colors]
# Check each quartet
for i, (c1, hsv1) in enumerate(hsv_colors):
for j, (c2, hsv2) in enumerate(hsv_colors[i + 1 :], i + 1):
for k, (c3, hsv3) in enumerate(hsv_colors[j + 1 :], j + 1):
for c4, hsv4 in hsv_colors[k + 1 :]:
# Get all hues
hues = sorted([hsv1[0], hsv2[0], hsv3[0], hsv4[0]])
# Calculate differences between consecutive hues
diffs = []
for idx in range(4):
diff = hues[(idx + 1) % 4] - hues[idx]
if idx == 3: # Last to first
diff = (hues[0] + 360) - hues[3]
diffs.append(diff)
# For tetradic: should have two pairs of equal angles
# (rectangle on color wheel)
diffs_sorted = sorted(diffs)
if (
abs(diffs_sorted[0] - diffs_sorted[1]) <= tolerance
and abs(diffs_sorted[2] - diffs_sorted[3]) <= tolerance
):
tetradic_sets.append((c1, c2, c3, c4))
return tetradic_sets
[docs]
def analyze_color_harmony(
self, colors: List[Tuple[int, int, int]]
) -> Dict[str, any]:
"""
Comprehensive analysis of color harmonies present in a palette.
Analyzes all major harmony types and provides statistics.
Educational method for teaching color theory in practice.
Args:
colors: List of RGB tuples
Returns:
Dictionary containing:
- complementary_pairs: List of complementary color pairs
- triadic_sets: List of triadic harmonies
- analogous_groups: List of analogous color groups
- split_complementary_sets: List of split-complementary schemes
- tetradic_sets: List of tetradic harmonies
- harmony_score: Overall harmony score (0-1)
- dominant_harmony: Most prevalent harmony type
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
>>> analysis = analyzer.analyze_color_harmony(colors)
>>> print(f"Dominant harmony: {analysis['dominant_harmony']}")
"""
# Detect all harmony types
complementary = self.detect_complementary_colors(colors)
triadic = self.detect_triadic_harmony(colors)
analogous = self.detect_analogous_harmony(colors)
split_comp = self.detect_split_complementary(colors)
tetradic = self.detect_tetradic_harmony(colors)
# Count harmonies
harmony_counts = {
"complementary": len(complementary),
"triadic": len(triadic),
"analogous": len(analogous),
"split_complementary": len(split_comp),
"tetradic": len(tetradic),
}
# Determine dominant harmony
dominant = max(harmony_counts, key=harmony_counts.get)
if harmony_counts[dominant] == 0:
dominant = "none"
# Calculate harmony score (normalized by palette size)
total_harmonies = sum(harmony_counts.values())
max_possible = len(colors) * (len(colors) - 1) // 2 # Combinations
harmony_score = min(
1.0, total_harmonies / max_possible if max_possible > 0 else 0
)
return {
"complementary_pairs": complementary,
"triadic_sets": triadic,
"analogous_groups": analogous,
"split_complementary_sets": split_comp,
"tetradic_sets": tetradic,
"harmony_counts": harmony_counts,
"total_harmonies": total_harmonies,
"harmony_score": harmony_score,
"dominant_harmony": dominant,
}
def _get_namer(self):
"""Lazy-load a ColorNamer instance for CIEDE2000 calculations."""
if not hasattr(self, "_namer"):
from .namer import ColorNamer
self._namer = ColorNamer()
return self._namer
[docs]
def palette_earth_movers_distance(
self,
palette1: List[Tuple[Tuple[int, int, int], float]],
palette2: List[Tuple[Tuple[int, int, int], float]],
) -> float:
"""
Calculate Palette Earth Mover's Distance (PEMD) between two palettes.
Uses CIEDE2000 as the perceptual ground distance and colour proportions
as weights, solved via optimal transport. This provides a structurally
aware comparison that accounts for both colour similarity and proportion
differences.
Args:
palette1: List of (RGB tuple, proportion) pairs.
Proportions should sum to 1.0.
palette2: List of (RGB tuple, proportion) pairs.
Proportions should sum to 1.0.
Returns:
PEMD distance (lower = more similar). Scale depends on CIEDE2000
units (typically 0–100+, where <2 is imperceptible).
Raises:
ImportError: If scipy is not installed.
ValueError: If palettes are empty.
Example:
>>> analyzer = ColorAnalyzer()
>>> p1 = [((255, 0, 0), 0.6), ((0, 0, 255), 0.4)]
>>> p2 = [((250, 10, 5), 0.5), ((10, 0, 250), 0.5)]
>>> dist = analyzer.palette_earth_movers_distance(p1, p2)
>>> print(f"PEMD: {dist:.2f}")
"""
if not SCIPY_AVAILABLE:
raise ImportError(
"scipy is required for PEMD. Install with: pip install scipy"
)
if not palette1 or not palette2:
raise ValueError("Both palettes must be non-empty")
namer = self._get_namer()
n = len(palette1)
m = len(palette2)
# Build cost matrix using CIEDE2000
cost_matrix = np.zeros((n, m))
for i, (c1, _) in enumerate(palette1):
lab1 = namer._rgb_to_lab(c1)
for j, (c2, _) in enumerate(palette2):
lab2 = namer._rgb_to_lab(c2)
cost_matrix[i, j] = namer._ciede2000(lab1, lab2)
# Extract weights
w1 = np.array([w for _, w in palette1], dtype=float)
w2 = np.array([w for _, w in palette2], dtype=float)
# Normalise weights
w1 = w1 / w1.sum()
w2 = w2 / w2.sum()
# Expand to balanced assignment problem:
# Discretise weights into N units using largest-remainder method so
# totals are guaranteed equal and no positive weight is zeroed out.
resolution = max(n, m) * 10 # granularity
def _largest_remainder(weights: np.ndarray, total: int) -> np.ndarray:
raw = weights * total
floors = np.floor(raw).astype(int)
# Guarantee at least 1 unit for every positive weight
floors = np.where((weights > 0) & (floors == 0), 1, floors)
remainder = total - floors.sum()
if remainder > 0:
fracs = raw - np.floor(raw)
# Indices sorted by descending fractional part
order = np.argsort(-fracs)
for idx in order[:remainder]:
floors[idx] += 1
elif remainder < 0:
# Over-allocated (can happen when forced minimums push sum above total)
fracs = raw - np.floor(raw)
order = np.argsort(fracs) # smallest fracs donated first
for idx in order[:-remainder]:
if floors[idx] > 1:
floors[idx] -= 1
return floors
counts1 = _largest_remainder(w1, resolution)
counts2 = _largest_remainder(w2, resolution)
# Ensure the two totals agree (off-by-one possible when forced minimums kick in)
diff = int(counts1.sum()) - int(counts2.sum())
if diff > 0:
counts2[np.argmax(w2)] += diff
elif diff < 0:
counts1[np.argmax(w1)] += -diff
total = int(counts1.sum())
if total == 0:
return 0.0
# Build expanded cost matrix
expanded_cost = np.zeros((total, total))
row_idx = 0
for i, c1_count in enumerate(counts1):
col_idx = 0
for j, c2_count in enumerate(counts2):
expanded_cost[
row_idx : row_idx + c1_count, col_idx : col_idx + c2_count
] = cost_matrix[i, j]
col_idx += c2_count
row_idx += c1_count
row_ind, col_ind = linear_sum_assignment(expanded_cost)
return float(expanded_cost[row_ind, col_ind].sum() / total)
[docs]
def calculate_color_complexity(
self,
colors: List[Tuple[int, int, int]],
proportions: Optional[List[float]] = None,
weights: Optional[Dict[str, float]] = None,
) -> Dict:
"""
Calculate the Colour Complexity Index (CCI) for a palette.
A multi-dimensional information-theoretic measure combining:
- Hue entropy (spread across the colour wheel)
- Perceptual spread (mean pairwise CIEDE2000 distance)
- Proportion evenness (1 - Gini coefficient)
- Harmony penalty (lower complexity if colours follow harmony rules)
Args:
colors: List of RGB tuples
proportions: Optional list of colour proportions (should sum to 1).
If None, equal proportions are assumed.
weights: Optional dict of component weights with keys:
'hue_entropy', 'perceptual_spread', 'proportion_evenness',
'harmony_penalty'. Defaults to equal weighting.
Returns:
Dictionary containing:
- cci: Composite Colour Complexity Index (0-1)
- hue_entropy: Normalised hue entropy (0-1)
- perceptual_spread: Normalised mean pairwise CIEDE2000 (0-1)
- proportion_evenness: 1 - Gini coefficient (0-1)
- harmony_penalty: Harmony score (0-1, subtracted)
- components: Dict of weighted sub-scores
Example:
>>> analyzer = ColorAnalyzer()
>>> mondrian = [(255, 0, 0), (0, 0, 255), (255, 255, 0),
... (255, 255, 255), (0, 0, 0)]
>>> result = analyzer.calculate_color_complexity(mondrian)
>>> print(f"CCI: {result['cci']:.3f}")
"""
if len(colors) < 2:
return {
"cci": 0.0,
"hue_entropy": 0.0,
"perceptual_spread": 0.0,
"proportion_evenness": 0.0,
"harmony_penalty": 0.0,
"components": {},
}
default_weights = {
"hue_entropy": 0.3,
"perceptual_spread": 0.3,
"proportion_evenness": 0.2,
"harmony_penalty": 0.2,
}
w = weights if weights else default_weights
# 1. Hue entropy (reuse existing method, already normalised 0–1)
hue_entropy = self.calculate_color_diversity(colors)
# 2. Perceptual spread: mean pairwise CIEDE2000, normalised
namer = self._get_namer()
labs = [namer._rgb_to_lab(c) for c in colors]
distances = []
for i in range(len(labs)):
for j in range(i + 1, len(labs)):
distances.append(namer._ciede2000(labs[i], labs[j]))
mean_distance = float(np.mean(distances)) if distances else 0.0
# Normalise: CIEDE2000 of 100 is extreme; cap at 100
perceptual_spread = min(1.0, mean_distance / 100.0)
# 3. Proportion evenness (1 - Gini coefficient)
if proportions is None:
proportions = [1.0 / len(colors)] * len(colors)
props = np.array(sorted(proportions), dtype=float)
n = len(props)
if props.sum() == 0:
gini = 0.0
else:
index = np.arange(1, n + 1)
gini = (2 * np.sum(index * props) - (n + 1) * np.sum(props)) / (
n * np.sum(props)
)
proportion_evenness = 1.0 - gini
# 4. Harmony penalty
harmony = self.analyze_color_harmony(colors)
harmony_score = harmony["harmony_score"]
# Composite CCI
cci = (
w.get("hue_entropy", 0.3) * hue_entropy
+ w.get("perceptual_spread", 0.3) * perceptual_spread
+ w.get("proportion_evenness", 0.2) * proportion_evenness
- w.get("harmony_penalty", 0.2) * harmony_score
)
cci = max(0.0, min(1.0, cci))
return {
"cci": float(cci),
"hue_entropy": float(hue_entropy),
"perceptual_spread": float(perceptual_spread),
"proportion_evenness": float(proportion_evenness),
"harmony_penalty": float(harmony_score),
"components": {
"hue_entropy_weighted": float(w.get("hue_entropy", 0.3) * hue_entropy),
"perceptual_spread_weighted": float(
w.get("perceptual_spread", 0.3) * perceptual_spread
),
"proportion_evenness_weighted": float(
w.get("proportion_evenness", 0.2) * proportion_evenness
),
"harmony_penalty_weighted": float(
w.get("harmony_penalty", 0.2) * harmony_score
),
},
}
[docs]
def colour_provenance_score(
self,
colors: List[Tuple[int, int, int]],
year: int,
proportions: Optional[List[float]] = None,
) -> Dict:
"""
Calculate Colour Provenance Score (CPS) for a palette and attributed date.
Estimates how consistent a palette is with historically available pigments
at the given date. Low scores may indicate anachronistic colour usage.
Requires the artist_pigments vocabulary with historical date fields.
Args:
colors: List of RGB tuples from the artwork
year: Attributed year of the artwork
proportions: Optional colour proportions. If None, equal weights used.
Returns:
Dictionary containing:
- score: Overall provenance score (0–1, higher = more consistent)
- per_color: List of per-colour assessments
- flagged: Colours flagged as potentially anachronistic
Example:
>>> analyzer = ColorAnalyzer()
>>> colors = [(0, 50, 200), (255, 0, 0), (255, 255, 0)]
>>> result = analyzer.colour_provenance_score(colors, year=1780)
>>> print(f"Provenance: {result['score']:.2f}")
>>> for flag in result['flagged']:
... print(f" ⚠ {flag['color']}: {flag['reason']}")
"""
from .namer import ColorNamer
namer = ColorNamer(vocabulary="artist")
if not colors:
raise ValueError("colors must not be empty")
if proportions is None:
proportions = [1.0 / len(colors)] * len(colors)
if len(proportions) != len(colors):
raise ValueError(
f"proportions length ({len(proportions)}) must match "
f"colors length ({len(colors)})"
)
per_color = []
flagged = []
for i, (color, weight) in enumerate(zip(colors, proportions)):
result = namer.historical_pigment_probability(color, year)
# Best match probability
best = result[0] if result else None
prob = best["probability"] if best else 0.0
entry = {
"color": color,
"weight": weight,
"probability": prob,
"best_pigment": best["name"] if best else "Unknown",
"available_pigments": len(result),
}
per_color.append(entry)
# Flag if no pigments available or very low probability
if prob < 0.1:
flagged.append(
{
"color": color,
"reason": (
f"No historically plausible pigment match for year {year}. "
f"Best match: {best['name']} (prob: {prob:.3f})"
if best
else f"No pigments available for year {year}"
),
}
)
# Weighted overall score
total_weight = sum(proportions)
if total_weight > 0:
score = (
sum(e["probability"] * e["weight"] for e in per_color) / total_weight
)
else:
score = 0.0
return {
"score": float(score),
"year": year,
"per_color": per_color,
"flagged": flagged,
}