template-processor: added M3-Content scheme

This commit is contained in:
Lemmy
2026-01-21 09:14:01 -05:00
parent dd6f041f32
commit d330eab6b9
8 changed files with 294 additions and 52 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ Usage:
./compare-matugen.py <wallpaper_path>
./compare-matugen.py ~/Pictures/Wallpapers/example.png
Compares all M3 schemes (tonal-spot, fruit-salad, rainbow) and shows
Compares all M3 schemes (tonal-spot, fruit-salad, rainbow, content) and shows
a table with hue differences.
"""
@@ -92,7 +92,7 @@ def run_matugen(image_path: Path, scheme: str) -> dict | None:
def compare_schemes(image_path: Path) -> None:
"""Compare all M3 schemes between our processor and matugen."""
schemes = ["tonal-spot", "fruit-salad", "rainbow"]
schemes = ["tonal-spot", "fruit-salad", "rainbow", "content"]
color_keys = ["primary", "secondary", "tertiary", "surface", "on_surface"]
print(f"\nComparing: {image_path.name}\n")
+5 -2
View File
@@ -10,8 +10,8 @@ This package provides:
"""
from .color import Color, rgb_to_hsl, hsl_to_rgb, adjust_surface
from .hct import Hct, Cam16, TonalPalette
from .material import MaterialScheme, harmonize_color
from .hct import Hct, Cam16, TonalPalette, TemperatureCache, fix_if_disliked
from .material import MaterialScheme, SchemeContent, harmonize_color
from .contrast import ensure_contrast, contrast_ratio, is_dark
from .image import read_image, ImageReadError
from .palette import extract_palette
@@ -29,8 +29,11 @@ __all__ = [
"Hct",
"Cam16",
"TonalPalette",
"TemperatureCache",
"fix_if_disliked",
# Material
"MaterialScheme",
"SchemeContent",
"harmonize_color",
# Contrast
"ensure_contrast",
+245
View File
@@ -786,6 +786,251 @@ class Hct:
return Hct(self._hue, self._chroma, tone)
class TemperatureCache:
"""
Color temperature analysis for finding harmonious colors.
Based on Material Color Utilities - calculates relative warmth of colors
and finds analogous colors based on temperature similarity.
"""
def __init__(self, input_hct: Hct):
self.input = input_hct
self._hcts_by_temp: list[Hct] | None = None
self._hcts_by_hue: list[Hct] | None = None
self._temps_by_hct: dict[tuple[float, float, float], float] | None = None
self._input_relative_temp: float | None = None
self._complement: Hct | None = None
@staticmethod
def raw_temperature(hct: Hct) -> float:
"""
Calculate raw temperature of a color using Ou-Woodcock-Wright algorithm.
Based on material-colors Rust implementation.
Uses LAB a* and b* to determine warm-cool factor.
Values below 0 are cool, above 0 are warm.
"""
# Convert HCT to RGB then to LAB
rgb = hct.to_rgb()
x, y, z = rgb_to_xyz(rgb[0], rgb[1], rgb[2])
# XYZ to LAB
def f(t: float) -> float:
delta = 6.0 / 29.0
if t > delta ** 3:
return t ** (1.0 / 3.0)
return t / (3 * delta * delta) + 4.0 / 29.0
xn, yn, zn = 95.047, 100.0, 108.883 # D65 reference
lab_a = 500.0 * (f(x / xn) - f(y / yn))
lab_b = 200.0 * (f(y / yn) - f(z / zn))
# Calculate LAB hue and chroma
lab_hue = math.degrees(math.atan2(lab_b, lab_a))
if lab_hue < 0:
lab_hue += 360.0
lab_chroma = math.hypot(lab_a, lab_b)
# Ou-Woodcock-Wright formula for temperature
# temp = -0.5 + 0.02 * chroma^1.07 * cos(toRadians(hue - 50))
hue_rad = math.radians((lab_hue - 50.0) % 360.0)
return -0.5 + 0.02 * (lab_chroma ** 1.07) * math.cos(hue_rad)
def _get_hcts_by_hue(self) -> list[Hct]:
"""Generate HCT colors at regular hue intervals."""
if self._hcts_by_hue is not None:
return self._hcts_by_hue
hcts = []
for hue in range(360):
color_at_hue = Hct(float(hue), self.input.chroma, self.input.tone)
hcts.append(color_at_hue)
self._hcts_by_hue = hcts
return hcts
def _get_temps_by_hct(self) -> dict[tuple[float, float, float], float]:
"""Cache temperatures for all hue variants."""
if self._temps_by_hct is not None:
return self._temps_by_hct
hcts = self._get_hcts_by_hue()
temps = {}
for hct in hcts:
key = (hct.hue, hct.chroma, hct.tone)
temps[key] = self.raw_temperature(hct)
self._temps_by_hct = temps
return temps
def _get_hcts_by_temp(self) -> list[Hct]:
"""Get HCT colors sorted by temperature."""
if self._hcts_by_temp is not None:
return self._hcts_by_temp
hcts = list(self._get_hcts_by_hue())
temps = self._get_temps_by_hct()
hcts.sort(key=lambda h: temps[(h.hue, h.chroma, h.tone)])
self._hcts_by_temp = hcts
return hcts
def _relative_temperature(self, hct: Hct) -> float:
"""
Calculate relative temperature (0-1) based on position in temperature-sorted list.
"""
temps = self._get_temps_by_hct()
hcts_by_temp = self._get_hcts_by_temp()
key = (hct.hue, hct.chroma, hct.tone)
if key in temps:
raw = temps[key]
else:
raw = self.raw_temperature(hct)
# Find position in sorted list
coldest = self.raw_temperature(hcts_by_temp[0])
warmest = self.raw_temperature(hcts_by_temp[-1])
if warmest == coldest:
return 0.5
return (raw - coldest) / (warmest - coldest)
def _input_relative_temperature_value(self) -> float:
"""Get relative temperature of the input color."""
if self._input_relative_temp is None:
self._input_relative_temp = self._relative_temperature(self.input)
return self._input_relative_temp
def complement(self) -> Hct:
"""
Find the complement: color with opposite temperature.
"""
if self._complement is not None:
return self._complement
input_temp = self._input_relative_temperature_value()
hcts_by_temp = self._get_hcts_by_temp()
temps = self._get_temps_by_hct()
# Target is opposite temperature
target_temp = 1.0 - input_temp
# Find closest match
best_hct = hcts_by_temp[0]
best_diff = float('inf')
for hct in hcts_by_temp:
key = (hct.hue, hct.chroma, hct.tone)
raw = temps.get(key, self.raw_temperature(hct))
rel = self._relative_temperature(hct)
diff = abs(rel - target_temp)
if diff < best_diff:
best_diff = diff
best_hct = hct
self._complement = best_hct
return best_hct
def analogous(self, count: int | None = None, divisions: int | None = None) -> list[Hct]:
"""
Find analogous colors based on temperature.
Uses material-colors algorithm:
1. Build a list of all `divisions` colors at equal temperature steps
2. Pick `count` colors from this list, centered around the input
Args:
count: Number of colors to return (default 5)
divisions: How many divisions of the temperature range (default 12)
Returns:
List of HCT colors including the input, spread by temperature.
"""
if count is None:
count = 5
if divisions is None:
divisions = 12
hcts_by_hue = self._get_hcts_by_hue()
start_hue = round(self.input.hue) % 360
start_hct = hcts_by_hue[start_hue]
# Calculate total absolute temperature delta around the color wheel
last_temp = self._relative_temperature(start_hct)
absolute_total_temp_delta = 0.0
for i in range(360):
hue = (start_hue + i) % 360
hct = hcts_by_hue[hue]
temp = self._relative_temperature(hct)
temp_delta = abs(temp - last_temp)
last_temp = temp
absolute_total_temp_delta += temp_delta
# Build list of all colors at equal temperature steps
temp_step = absolute_total_temp_delta / divisions
all_colors: list[Hct] = [start_hct]
total_temp_delta = 0.0
last_temp = self._relative_temperature(start_hct)
hue_addend = 1
while len(all_colors) < divisions and hue_addend <= 360:
hue = (start_hue + hue_addend) % 360
hct = hcts_by_hue[hue]
temp = self._relative_temperature(hct)
temp_delta = abs(temp - last_temp)
total_temp_delta += temp_delta
desired_total = len(all_colors) * temp_step
# Add this hue until its temperature is insufficient
while total_temp_delta >= desired_total and len(all_colors) < divisions:
all_colors.append(hct)
desired_total = (len(all_colors) + 1) * temp_step
last_temp = temp
hue_addend += 1
# Fill remaining slots if needed
while len(all_colors) < divisions:
all_colors.append(all_colors[-1] if all_colors else start_hct)
# Build final answer list centered around input
answers: list[Hct] = [self.input]
# Counter-clockwise (negative indices)
increase_hue_count = int((count - 1) // 2)
for i in range(1, increase_hue_count + 1):
index = (-i) % len(all_colors)
answers.insert(0, all_colors[index])
# Clockwise (positive indices)
decrease_hue_count = count - increase_hue_count - 1
for i in range(1, decrease_hue_count + 1):
index = i % len(all_colors)
answers.append(all_colors[index])
return answers
def fix_if_disliked(hct: Hct) -> Hct:
"""
Fix colors in the "disliked" hue range (yellow-green).
These colors often look muddy or unpleasant. If detected,
shift the hue slightly to improve appearance.
"""
# Disliked range: roughly 80-110 degrees (yellow-green)
if hct.hue >= 80.0 and hct.hue <= 110.0 and hct.chroma > 16.0:
# Shift towards warmer yellow or cooler green
new_hue = 75.0 if hct.hue < 95.0 else 115.0
return Hct(new_hue, hct.chroma, hct.tone)
return hct
class TonalPalette:
"""
A palette of tones for a single hue and chroma.
+21 -18
View File
@@ -8,10 +8,10 @@ Supported schemes (matching Matugen):
- SchemeTonalSpot: Default Android 12-13 scheme, mid-vibrancy
- SchemeFruitSalad: Bold/playful with -50° hue rotation
- SchemeRainbow: Chromatic accents with grayscale neutrals
- SchemeContent: Preserves source color's chroma (legacy "material" mode)
- SchemeContent: Preserves source color's chroma
"""
from .hct import Hct, TonalPalette
from .hct import Hct, TonalPalette, TemperatureCache, fix_if_disliked
# =============================================================================
@@ -312,35 +312,38 @@ class SchemeContent(_BaseScheme):
"""
Content scheme - preserves source color's chroma.
This is the legacy "material" mode that preserves the extracted
color's characteristics:
- Primary: source hue and chroma (unchanged)
- Secondary: same hue, reduced chroma
- Tertiary: hue +60°, reduced chroma
- Neutrals: low chroma (tinted with source hue)
This is the Material Design 3 "content" scheme that preserves the source
color's characteristics while creating harmonious palettes:
- Primary: source hue and chroma (full preservation)
- Secondary: same hue, reduced chroma: max(chroma - 32, chroma * 0.5)
- Tertiary: analogous color from temperature analysis (warm-cool harmony)
- Neutrals: low chroma (chroma / 8, tinted with source hue)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: preserve source color's hue and chroma
# Primary: preserve source color's hue and chroma (full preservation)
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
# Secondary: same hue, reduced chroma
secondary_chroma = max(source_color.chroma - 24.0, source_color.chroma * 0.6)
# Formula from matugen: max(chroma - 32, chroma * 0.5)
secondary_chroma = max(source_color.chroma - 32.0, source_color.chroma * 0.5)
self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
# Tertiary: 60° hue rotation with reduced chroma
tertiary_hue = (source_color.hue + 60.0) % 360.0
tertiary_chroma = max(source_color.chroma - 24.0, source_color.chroma * 0.6)
self.tertiary_palette = TonalPalette(tertiary_hue, tertiary_chroma)
# Tertiary: use analogous color from temperature analysis
# Get 3 analogous colors with 6 divisions, pick the last one (most different)
temp_cache = TemperatureCache(source_color)
analogous_colors = temp_cache.analogous(3, 6)
tertiary_hct = fix_if_disliked(analogous_colors[-1])
self.tertiary_palette = TonalPalette.from_hct(tertiary_hct)
# Neutral: source hue, low chroma (chroma / 6)
neutral_chroma = source_color.chroma / 6.0
# Neutral: source hue, low chroma (chroma / 8)
neutral_chroma = source_color.chroma / 8.0
self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
# Neutral variant: slightly more chroma
neutral_variant_chroma = (source_color.chroma / 6.0) + 4.0
# Neutral variant: slightly more chroma (chroma / 8 + 4)
neutral_variant_chroma = (source_color.chroma / 8.0) + 4.0
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
+3 -2
View File
@@ -21,14 +21,15 @@ from .palette import find_error_color
# Type aliases
ThemeMode = Literal["dark", "light"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "vibrant", "faithful"]
# Map scheme type strings to classes
SCHEME_CLASSES = {
"tonal-spot": SchemeTonalSpot,
"fruit-salad": SchemeFruitSalad,
"rainbow": SchemeRainbow,
# "vibrant" uses generate_normal_* functions, not a scheme class
"content": SchemeContent,
# "vibrant" and "faithful" uses generate_normal_* functions, not a scheme class
}
@@ -6,6 +6,7 @@ A CLI tool that extracts dominant colors from wallpaper images and generates pal
Supported scheme types:
- tonal-spot: Default Android 12-13 Material You scheme (recommended)
- content: Preserves source color's chroma with temperature-based tertiary (matugen default)
- fruit-salad: Bold/playful with -50° hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Colorful with smooth blended colors
@@ -15,7 +16,7 @@ Usage:
python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
Options:
--scheme-type Scheme type: tonal-spot (default), fruit-salad, rainbow, vibrant
--scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, vibrant, faithful
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
@@ -57,10 +58,11 @@ def parse_args() -> argparse.Namespace:
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 template-processor.py wallpaper.png # default mode, both themes
python3 template-processor.py wallpaper.png --vibrant --dark # vibrant mode, dark only
python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
python3 template-processor.py wallpaper.png -r tpl.txt:out.txt # render template
python3 template-processor.py wallpaper.png # tonal-spot (default), both themes
python3 template-processor.py wallpaper.png --scheme-type content --dark # content scheme, dark only
python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
python3 template-processor.py wallpaper.png -r template.txt:output.txt # render template
python3 template-processor.py wallpaper.png -c config.toml --mode dark # render config, dark only
"""
)
@@ -74,23 +76,11 @@ Examples:
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'fruit-salad', 'rainbow', 'vibrant', 'faithful'],
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'vibrant', 'faithful'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
# Legacy flags for backward compatibility
parser.add_argument(
'--material',
action='store_true',
help='(deprecated) Alias for --scheme-type tonal-spot'
)
parser.add_argument(
'--vibrant',
action='store_true',
help='(deprecated) Alias for --scheme-type vibrant'
)
# Theme mode (mutually exclusive)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
@@ -206,7 +196,7 @@ def main() -> int:
print(f"Error: Image not found: {args.image}", file=sys.stderr)
return 1
# Check if input is a JSON palette (legacy Predefined Scheme bypass)
# Check if input is a JSON palette (Predefined Color Scheme)
if args.image.suffix.lower() == '.json':
try:
with open(args.image, 'r') as f:
@@ -249,12 +239,8 @@ def main() -> int:
print(f"Unexpected error reading image: {e}", file=sys.stderr)
return 1
# Determine scheme type (handle legacy flags)
# Determine scheme type
scheme_type = args.scheme_type
if args.vibrant:
scheme_type = "vibrant"
elif args.material:
scheme_type = "tonal-spot"
# Extract palette with appropriate scoring method
# - vibrant: chroma scoring with centroid averaging (smooth blended colors)