"""
Color naming module for evocative, artist-friendly color identification.
This module provides tools for converting RGB/hex colors to memorable,
evocative names like "Burnt Sienna" or "Prussian Blue" rather than
technical codes. Uses perceptually accurate color matching (CIEDE2000).
"""
import json
import os
from typing import List, Dict, Tuple, Optional, Union
from pathlib import Path
import numpy as np
[docs]
class ColorNamer:
"""
Convert colors to evocative, artist-friendly names.
This class provides paint manufacturer-style color naming using
perceptually accurate color matching. Supports multiple naming
vocabularies from traditional artist pigments to modern design colors.
Attributes:
vocabulary: Currently active color vocabulary
_colors: Cached color data for current vocabulary
_lab_cache: Cache of Lab color space conversions
Example:
>>> from renoir.color import ColorNamer
>>> namer = ColorNamer(vocabulary="artist")
>>> result = namer.name((255, 87, 51))
>>> print(result['name'])
'Cadmium Orange'
"""
# Class-level vocabulary registry
_VOCABULARIES = {
"artist": "artist_pigments.json",
"resene": "resene.json",
"natural": "werner.json",
"werner": "werner.json", # Alias
"xkcd": "xkcd.json",
}
def __init__(self, vocabulary: str = "artist"):
"""
Initialize the ColorNamer with a specific vocabulary.
Args:
vocabulary: Name of the color vocabulary to use.
Options: 'artist', 'resene', 'natural'/'werner', 'xkcd'
(default: 'artist')
Raises:
ValueError: If vocabulary is not recognized
Example:
>>> namer = ColorNamer(vocabulary="artist")
>>> # or
>>> namer = ColorNamer(vocabulary="xkcd")
"""
if vocabulary not in self._VOCABULARIES:
available = ", ".join(self._VOCABULARIES.keys())
raise ValueError(
f"Unknown vocabulary '{vocabulary}'. "
f"Available vocabularies: {available}"
)
self.vocabulary = vocabulary
self._colors: Optional[List[Dict]] = None
self._lab_cache: Dict[Tuple[int, int, int], Tuple[float, float, float]] = {}
self._data_dir = Path(__file__).parent.parent / "data" / "colors"
[docs]
@classmethod
def available_vocabularies(cls) -> List[str]:
"""
Get list of available color vocabularies.
Returns:
List of vocabulary names that can be used
Example:
>>> vocabs = ColorNamer.available_vocabularies()
>>> print(vocabs)
['artist', 'resene', 'natural', 'werner', 'xkcd']
"""
# Return unique vocabulary names (excluding aliases)
return ["artist", "resene", "natural", "xkcd"]
def _load_colors(self) -> List[Dict]:
"""
Lazy-load color data from JSON file.
Returns:
List of color dictionaries with name, hex, rgb, etc.
"""
if self._colors is not None:
return self._colors
filename = self._VOCABULARIES[self.vocabulary]
filepath = self._data_dir / filename
if not filepath.exists():
raise FileNotFoundError(
f"Color data file not found: {filepath}. "
f"Please ensure the data files are properly installed."
)
try:
with open(filepath, "r", encoding="utf-8") as f:
self._colors = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in color data file {filepath}: {e}")
return self._colors
def _rgb_to_lab(self, rgb: Tuple[int, int, int]) -> Tuple[float, float, float]:
"""
Convert RGB color to CIE Lab color space for perceptual matching.
Uses D65 illuminant and 2° standard observer.
Args:
rgb: Tuple of (R, G, B) values (0-255)
Returns:
Tuple of (L, a, b) values in CIE Lab space
"""
# Check cache first
if rgb in self._lab_cache:
return self._lab_cache[rgb]
# Normalize RGB to 0-1
r, g, b = rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0
# Apply gamma correction (sRGB to linear RGB)
def gamma_correct(channel):
if channel <= 0.04045:
return channel / 12.92
else:
return ((channel + 0.055) / 1.055) ** 2.4
r_linear = gamma_correct(r)
g_linear = gamma_correct(g)
b_linear = gamma_correct(b)
# Convert to XYZ using D65 illuminant matrix
x = r_linear * 0.4124564 + g_linear * 0.3575761 + b_linear * 0.1804375
y = r_linear * 0.2126729 + g_linear * 0.7151522 + b_linear * 0.0721750
z = r_linear * 0.0193339 + g_linear * 0.1191920 + b_linear * 0.9503041
# Normalize by D65 white point
x = x / 0.95047
y = y / 1.00000
z = z / 1.08883
# Convert to Lab
def f(t):
delta = 6.0 / 29.0
if t > delta**3:
return t ** (1.0 / 3.0)
else:
return t / (3 * delta**2) + 4.0 / 29.0
fx = f(x)
fy = f(y)
fz = f(z)
L = 116.0 * fy - 16.0
a = 500.0 * (fx - fy)
b_lab = 200.0 * (fy - fz)
lab = (L, a, b_lab)
self._lab_cache[rgb] = lab
return lab
def _ciede2000(
self,
lab1: Tuple[float, float, float],
lab2: Tuple[float, float, float],
) -> float:
"""
Calculate CIEDE2000 color difference between two Lab colors.
CIEDE2000 is a perceptually uniform color difference metric that
better matches human color perception than simple Euclidean distance.
Args:
lab1: First color in Lab space (L, a, b)
lab2: Second color in Lab space (L, a, b)
Returns:
Color difference value (lower = more similar)
"""
# Unpack Lab values
L1, a1, b1 = lab1
L2, a2, b2 = lab2
# Calculate Chroma
C1 = np.sqrt(a1**2 + b1**2)
C2 = np.sqrt(a2**2 + b2**2)
# Calculate average Chroma
C_bar = (C1 + C2) / 2.0
# Calculate G factor
G = 0.5 * (1 - np.sqrt(C_bar**7 / (C_bar**7 + 25**7)))
# Calculate adjusted a values
a1_prime = a1 * (1 + G)
a2_prime = a2 * (1 + G)
# Calculate adjusted Chroma
C1_prime = np.sqrt(a1_prime**2 + b1**2)
C2_prime = np.sqrt(a2_prime**2 + b2**2)
# Calculate adjusted Hue
def calc_h_prime(a_prime, b_val):
if a_prime == 0 and b_val == 0:
return 0
h = np.degrees(np.arctan2(b_val, a_prime))
if h < 0:
h += 360
return h
h1_prime = calc_h_prime(a1_prime, b1)
h2_prime = calc_h_prime(a2_prime, b2)
# Calculate delta values
delta_L_prime = L2 - L1
delta_C_prime = C2_prime - C1_prime
# Calculate delta H prime
if C1_prime * C2_prime == 0:
delta_h_prime = 0
elif abs(h2_prime - h1_prime) <= 180:
delta_h_prime = h2_prime - h1_prime
elif h2_prime - h1_prime > 180:
delta_h_prime = h2_prime - h1_prime - 360
else:
delta_h_prime = h2_prime - h1_prime + 360
delta_H_prime = (
2 * np.sqrt(C1_prime * C2_prime) * np.sin(np.radians(delta_h_prime / 2))
)
# Calculate average L', C', H'
L_bar_prime = (L1 + L2) / 2
C_bar_prime = (C1_prime + C2_prime) / 2
if C1_prime * C2_prime == 0:
H_bar_prime = h1_prime + h2_prime
elif abs(h1_prime - h2_prime) <= 180:
H_bar_prime = (h1_prime + h2_prime) / 2
elif h1_prime + h2_prime < 360:
H_bar_prime = (h1_prime + h2_prime + 360) / 2
else:
H_bar_prime = (h1_prime + h2_prime - 360) / 2
# Calculate weighting functions
T = (
1
- 0.17 * np.cos(np.radians(H_bar_prime - 30))
+ 0.24 * np.cos(np.radians(2 * H_bar_prime))
+ 0.32 * np.cos(np.radians(3 * H_bar_prime + 6))
- 0.20 * np.cos(np.radians(4 * H_bar_prime - 63))
)
delta_theta = 30 * np.exp(-(((H_bar_prime - 275) / 25) ** 2))
R_C = 2 * np.sqrt(C_bar_prime**7 / (C_bar_prime**7 + 25**7))
S_L = 1 + (0.015 * (L_bar_prime - 50) ** 2) / np.sqrt(
20 + (L_bar_prime - 50) ** 2
)
S_C = 1 + 0.045 * C_bar_prime
S_H = 1 + 0.015 * C_bar_prime * T
R_T = -np.sin(np.radians(2 * delta_theta)) * R_C
# Calculate final CIEDE2000 difference
delta_E = np.sqrt(
(delta_L_prime / S_L) ** 2
+ (delta_C_prime / S_C) ** 2
+ (delta_H_prime / S_H) ** 2
+ R_T * (delta_C_prime / S_C) * (delta_H_prime / S_H)
)
return delta_E
[docs]
def name(
self,
color: Union[Tuple[int, int, int], str],
return_metadata: bool = False,
) -> Union[Dict[str, any], str]:
"""
Find the closest color name for an RGB or hex color.
Uses CIEDE2000 perceptual color difference for accurate matching.
Args:
color: RGB tuple (R, G, B) or hex string ('#RRGGBB')
return_metadata: If True, return full metadata dictionary,
otherwise just the color name (default: False)
Returns:
If return_metadata=False: Color name string
If return_metadata=True: Dictionary with keys:
- name: Color name
- hex: Hex color code
- rgb: RGB tuple
- distance: CIEDE2000 distance from input
- vocabulary: Active vocabulary name
- family: Color family (if available)
- ci_name: Color Index name (if available)
- description: Color description (if available)
Raises:
ValueError: If color format is invalid
Example:
>>> namer = ColorNamer(vocabulary="artist")
>>> namer.name((255, 87, 51))
'Cadmium Orange'
>>> namer.name("#FF5733", return_metadata=True)
{'name': 'Cadmium Orange', 'hex': '#FF6103', ...}
"""
# Convert hex to RGB if needed
if isinstance(color, str):
rgb = self._hex_to_rgb(color)
else:
rgb = color
# Validate RGB
if (
not isinstance(rgb, (tuple, list))
or len(rgb) != 3
or not all(isinstance(c, int) and 0 <= c <= 255 for c in rgb)
):
raise ValueError(
"Color must be RGB tuple (0-255) or hex string. " f"Got: {color}"
)
# Convert input to Lab
input_lab = self._rgb_to_lab(rgb)
# Find closest match
colors = self._load_colors()
best_match = None
best_distance = float("inf")
for color_data in colors:
color_rgb = tuple(color_data["rgb"])
color_lab = self._rgb_to_lab(color_rgb)
distance = self._ciede2000(input_lab, color_lab)
if distance < best_distance:
best_distance = distance
best_match = color_data
if best_match is None:
raise RuntimeError("No colors found in vocabulary")
# Build result
if return_metadata:
result = {
"name": best_match["name"],
"hex": best_match["hex"],
"rgb": tuple(best_match["rgb"]),
"distance": round(best_distance, 3),
"vocabulary": self.vocabulary,
"family": best_match.get("family"),
}
# Add optional fields if present
if "ci_name" in best_match:
result["ci_name"] = best_match["ci_name"]
if "description" in best_match:
result["description"] = best_match["description"]
return result
else:
return best_match["name"]
[docs]
def name_palette(
self,
colors: List[Tuple[int, int, int]],
return_metadata: bool = False,
) -> List[Union[str, Dict]]:
"""
Name multiple colors in a palette.
Args:
colors: List of RGB tuples
return_metadata: If True, return metadata dictionaries
Returns:
List of color names or metadata dictionaries
Example:
>>> namer = ColorNamer()
>>> palette = [(255, 87, 51), (100, 200, 150), (50, 100, 200)]
>>> names = namer.name_palette(palette)
>>> print(names)
['Cadmium Orange', 'Mountain Meadow', 'Denim']
"""
return [self.name(color, return_metadata) for color in colors]
[docs]
def closest_pigment(
self, color: Union[Tuple[int, int, int], str]
) -> Dict[str, any]:
"""
Find the closest actual artist pigment (with Color Index name).
Useful for digital-to-physical color matching. Only searches colors
that have Color Index names in the artist vocabulary.
Args:
color: RGB tuple or hex string
Returns:
Dictionary with pigment name, CI name, and color info
Example:
>>> namer = ColorNamer()
>>> result = namer.closest_pigment((45, 82, 128))
>>> print(f"{result['name']} ({result['ci_name']})")
'Prussian Blue (PB27)'
"""
# Temporarily switch to artist vocabulary if needed
original_vocab = self.vocabulary
if self.vocabulary != "artist":
self.vocabulary = "artist"
self._colors = None # Force reload
try:
# Convert hex to RGB if needed
if isinstance(color, str):
rgb = self._hex_to_rgb(color)
else:
rgb = color
# Get full metadata
result = self.name(rgb, return_metadata=True)
# Filter for colors with CI names only
input_lab = self._rgb_to_lab(rgb)
colors = self._load_colors()
pigments = [c for c in colors if c.get("ci_name")]
if not pigments:
raise ValueError("No pigments with Color Index names found")
best_match = None
best_distance = float("inf")
for pigment in pigments:
pigment_rgb = tuple(pigment["rgb"])
pigment_lab = self._rgb_to_lab(pigment_rgb)
distance = self._ciede2000(input_lab, pigment_lab)
if distance < best_distance:
best_distance = distance
best_match = pigment
return {
"name": best_match["name"],
"ci_name": best_match["ci_name"],
"hex": best_match["hex"],
"rgb": tuple(best_match["rgb"]),
"distance": round(best_distance, 3),
"family": best_match.get("family"),
"description": best_match.get("description"),
}
finally:
# Restore original vocabulary
self.vocabulary = original_vocab
self._colors = None # Force reload on next use
def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
"""
Convert hex color string to RGB tuple.
Args:
hex_color: Hex string like '#FF5733' or 'FF5733'
Returns:
RGB tuple (R, G, B)
Raises:
ValueError: If hex format is invalid
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
raise ValueError(
f"Hex color must be 6 characters (got {len(hex_color)}): {hex_color}"
)
try:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return (r, g, b)
except ValueError:
raise ValueError(f"Invalid hex color format: {hex_color}")
def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str:
"""
Convert RGB tuple to hex string.
Args:
rgb: RGB tuple (R, G, B)
Returns:
Hex string like '#FF5733'
"""
return "#{:02X}{:02X}{:02X}".format(*rgb)
[docs]
def set_vocabulary(self, vocabulary: str) -> None:
"""
Switch to a different color vocabulary.
Args:
vocabulary: Name of vocabulary to switch to
Raises:
ValueError: If vocabulary is not recognized
Example:
>>> namer = ColorNamer(vocabulary="artist")
>>> namer.set_vocabulary("xkcd")
>>> namer.name((255, 87, 51))
'orange pink'
"""
if vocabulary not in self._VOCABULARIES:
available = ", ".join(self._VOCABULARIES.keys())
raise ValueError(
f"Unknown vocabulary '{vocabulary}'. "
f"Available vocabularies: {available}"
)
self.vocabulary = vocabulary
self._colors = None # Clear cache to force reload
[docs]
def get_vocabulary_info(self) -> Dict[str, any]:
"""
Get information about the current vocabulary.
Returns:
Dictionary with vocabulary metadata
Example:
>>> namer = ColorNamer(vocabulary="artist")
>>> info = namer.get_vocabulary_info()
>>> print(f"{info['name']}: {info['count']} colors")
'artist: 48 colors'
"""
colors = self._load_colors()
# Count colors by family
families = {}
for color in colors:
family = color.get("family", "Unknown")
families[family] = families.get(family, 0) + 1
# Count colors with CI names
ci_count = sum(1 for c in colors if c.get("ci_name"))
return {
"name": self.vocabulary,
"count": len(colors),
"families": families,
"ci_names": ci_count,
"file": self._VOCABULARIES[self.vocabulary],
}
[docs]
def translate(
self,
color_name: str,
from_vocabulary: Optional[str] = None,
to_vocabulary: str = "xkcd",
k: int = 3,
) -> Dict:
"""
Translate a colour name from one vocabulary to another.
Creates a "colour Rosetta Stone" by finding the perceptually closest
names in the target vocabulary via CIEDE2000 matching in Lab space.
Args:
color_name: Name of the colour to translate (case-insensitive)
from_vocabulary: Source vocabulary. If None, uses current vocabulary.
to_vocabulary: Target vocabulary name.
k: Number of closest matches to return (default: 3).
Returns:
Dictionary containing:
- source_name: Original colour name
- source_vocabulary: Source vocabulary
- source_rgb: RGB of the source colour
- translations: List of dicts with name, rgb, hex, distance
- target_vocabulary: Target vocabulary name
Raises:
ValueError: If colour name not found or vocabulary is invalid.
Example:
>>> namer = ColorNamer(vocabulary="werner")
>>> result = namer.translate("Gamboge Yellow", to_vocabulary="xkcd")
>>> for t in result['translations']:
... print(f" {t['name']} (ΔE={t['distance']:.1f})")
"""
src_vocab = from_vocabulary or self.vocabulary
# Validate vocabularies
if src_vocab not in self._VOCABULARIES:
raise ValueError(f"Unknown source vocabulary '{src_vocab}'")
if to_vocabulary not in self._VOCABULARIES:
raise ValueError(f"Unknown target vocabulary '{to_vocabulary}'")
# Save state
original_vocab = self.vocabulary
original_colors = self._colors
try:
# Load source vocabulary and find the colour
self.vocabulary = src_vocab
self._colors = None
src_colors = self._load_colors()
source = None
for c in src_colors:
if c["name"].lower() == color_name.lower():
source = c
break
if source is None:
available = [c["name"] for c in src_colors]
raise ValueError(
f"Colour '{color_name}' not found in {src_vocab} vocabulary. "
f"Available: {', '.join(available[:10])}..."
)
source_rgb = tuple(source["rgb"])
source_lab = self._rgb_to_lab(source_rgb)
# Load target vocabulary and find k closest
self.vocabulary = to_vocabulary
self._colors = None
tgt_colors = self._load_colors()
scored = []
for c in tgt_colors:
tgt_rgb = tuple(c["rgb"])
tgt_lab = self._rgb_to_lab(tgt_rgb)
dist = self._ciede2000(source_lab, tgt_lab)
scored.append(
{
"name": c["name"],
"rgb": tgt_rgb,
"hex": c.get("hex", self._rgb_to_hex(tgt_rgb)),
"distance": round(dist, 3),
}
)
scored.sort(key=lambda x: x["distance"])
return {
"source_name": source["name"],
"source_vocabulary": src_vocab,
"source_rgb": source_rgb,
"translations": scored[:k],
"target_vocabulary": to_vocabulary,
}
finally:
self.vocabulary = original_vocab
self._colors = original_colors
[docs]
def translate_all_vocabularies(
self,
color_name: str,
from_vocabulary: Optional[str] = None,
k: int = 1,
) -> Dict:
"""
Translate a colour name to all other vocabularies at once.
Args:
color_name: Name of the colour to translate
from_vocabulary: Source vocabulary. If None, uses current.
k: Number of matches per vocabulary (default: 1)
Returns:
Dictionary mapping vocabulary names to translation results.
Example:
>>> namer = ColorNamer(vocabulary="artist")
>>> result = namer.translate_all_vocabularies("Prussian Blue")
>>> for vocab, trans in result.items():
... print(f" {vocab}: {trans['translations'][0]['name']}")
"""
src_vocab = from_vocabulary or self.vocabulary
all_vocabs = [v for v in self.available_vocabularies() if v != src_vocab]
results = {}
for vocab in all_vocabs:
results[vocab] = self.translate(
color_name, from_vocabulary=src_vocab, to_vocabulary=vocab, k=k
)
return results
[docs]
def historical_pigment_probability(
self,
color: Union[Tuple[int, int, int], str],
year: int,
temperature: float = 15.0,
top_k: int = 5,
) -> List[Dict]:
"""
Estimate probability of historical pigments for a colour at a given date.
Uses Bayesian reasoning: P(pigment|colour,date) is proportional to
perceptual match (CIEDE2000) multiplied by historical availability.
Pigments with year_introduced/year_discontinued fields in the artist
vocabulary are used. Pigments without date fields are assumed always
available.
Args:
color: RGB tuple or hex string
year: Year to evaluate pigment availability
temperature: Softmax temperature for perceptual match (lower = stricter).
Default 15.0 gives reasonable discrimination.
top_k: Number of top pigments to return (default: 5)
Returns:
List of dicts sorted by probability, each containing:
- name: Pigment name
- ci_name: Color Index name (if available)
- rgb: RGB tuple
- probability: Normalised probability (0–1)
- ciede2000: Raw CIEDE2000 distance
- available: Whether pigment was available at given year
Example:
>>> namer = ColorNamer()
>>> results = namer.historical_pigment_probability((0, 50, 200), year=1780)
>>> for r in results:
... print(f" {r['name']}: prob={r['probability']:.3f}, available={r['available']}")
"""
# Convert hex to RGB if needed
if isinstance(color, str):
rgb = self._hex_to_rgb(color)
else:
rgb = color
input_lab = self._rgb_to_lab(rgb)
# Save state and switch to artist vocabulary
original_vocab = self.vocabulary
original_colors = self._colors
try:
self.vocabulary = "artist"
self._colors = None
pigments = self._load_colors()
scored = []
for p in pigments:
p_rgb = tuple(p["rgb"])
p_lab = self._rgb_to_lab(p_rgb)
distance = self._ciede2000(input_lab, p_lab)
# Historical availability
introduced = p.get("year_introduced")
discontinued = p.get("year_discontinued")
available = True
if introduced is not None and year < introduced:
available = False
if discontinued is not None and year > discontinued:
available = False
# Perceptual match score (softmax-style)
match_score = np.exp(-distance / temperature)
# Historical prior: 1.0 if available, 0.01 if not (small epsilon)
historical_prior = 1.0 if available else 0.01
raw_score = match_score * historical_prior
scored.append(
{
"name": p["name"],
"ci_name": p.get("ci_name"),
"rgb": p_rgb,
"raw_score": raw_score,
"ciede2000": round(distance, 3),
"available": available,
}
)
# Normalise to probabilities
total = sum(s["raw_score"] for s in scored)
if total > 0:
for s in scored:
s["probability"] = round(s["raw_score"] / total, 6)
else:
for s in scored:
s["probability"] = 0.0
# Sort by probability descending
scored.sort(key=lambda x: x["probability"], reverse=True)
# Clean up and return top_k
for s in scored:
del s["raw_score"]
return scored[:top_k]
finally:
self.vocabulary = original_vocab
self._colors = original_colors