template-processor: implemented tonal-sport, fruit-salad, rainbow

This commit is contained in:
Lemmy
2026-01-20 16:11:29 -05:00
parent 1ef5c0eb30
commit cb3f6eb743
11 changed files with 1391 additions and 356 deletions
+1 -1
View File
@@ -390,7 +390,7 @@
"schedulingMode": "off",
"manualSunrise": "06:30",
"manualSunset": "18:30",
"generationMethod": "material"
"generationMethod": "tonal-spot"
},
"templates": {
"activeTemplates": [],
+1 -1
View File
@@ -616,7 +616,7 @@ Singleton {
property string schedulingMode: "off"
property string manualSunrise: "06:30"
property string manualSunset: "18:30"
property string generationMethod: "material"
property string generationMethod: "tonal-spot"
}
// templates toggles
@@ -219,8 +219,16 @@ ColumnLayout {
enabled: Settings.data.colorSchemes.useWallpaperColors
model: [
{
"key": "material",
"name": "Material Design" // Do not translate
"key": "tonal-spot",
"name": "M3-Tonal Spot"
},
{
"key": "fruit-salad",
"name": "M3-Fruit Salad"
},
{
"key": "rainbow",
"name": "M3-Rainbow"
},
{
"key": "vibrant",
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Compare Noctalia's template-processor color extraction with matugen.
Usage:
./compare-matugen.py <wallpaper_path>
./compare-matugen.py ~/Pictures/Wallpapers/example.png
Compares all M3 schemes (tonal-spot, fruit-salad, rainbow) and shows
a table with hue differences.
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
# Add the theming lib to path
SCRIPT_DIR = Path(__file__).parent.resolve()
THEMING_DIR = SCRIPT_DIR.parent / "python" / "src" / "theming"
sys.path.insert(0, str(THEMING_DIR))
from lib.color import Color
from lib.hct import Hct
def hue_diff(h1: float, h2: float) -> float:
"""Calculate circular hue difference."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert hex to RGB tuple."""
h = hex_color.lstrip('#')
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def rgb_distance(hex1: str, hex2: str) -> float:
"""Calculate Euclidean RGB distance (0-441 range)."""
r1, g1, b1 = hex_to_rgb(hex1)
r2, g2, b2 = hex_to_rgb(hex2)
return ((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2) ** 0.5
def get_hct(hex_color: str) -> Hct:
"""Convert hex color to HCT."""
return Color.from_hex(hex_color).to_hct()
def run_our_processor(image_path: Path, scheme: str) -> dict | None:
"""Run our template-processor and return colors."""
cmd = [
sys.executable,
str(THEMING_DIR / "template-processor.py"),
str(image_path),
"--scheme-type", scheme,
"--dark"
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
return data.get("dark", {})
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
print(f"Error running our processor: {e}", file=sys.stderr)
return None
def run_matugen(image_path: Path, scheme: str) -> dict | None:
"""Run matugen and return colors."""
matugen_scheme = f"scheme-{scheme}"
cmd = [
"matugen", "image", str(image_path),
"--json", "hex",
"--dry-run",
"-t", matugen_scheme
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
colors = data.get("colors", {})
# Extract dark mode values
return {k: v.get("dark", v) for k, v in colors.items() if isinstance(v, dict)}
except subprocess.CalledProcessError as e:
print(f"Error running matugen: {e}", file=sys.stderr)
return None
except json.JSONDecodeError as e:
print(f"Error parsing matugen output: {e}", file=sys.stderr)
return None
def compare_schemes(image_path: Path) -> None:
"""Compare all M3 schemes between our processor and matugen."""
schemes = ["tonal-spot", "fruit-salad", "rainbow"]
color_keys = ["primary", "secondary", "tertiary", "surface", "on_surface"]
print(f"\nComparing: {image_path.name}\n")
print("=" * 78)
# Header
print(f"{'Scheme':<12} {'Color':<14} {'Ours':<10} {'Matugen':<10} {'Diff':>10} {'Match':<10}")
print("-" * 78)
for scheme in schemes:
ours = run_our_processor(image_path, scheme)
matugen = run_matugen(image_path, scheme)
if not ours or not matugen:
print(f"{scheme}: Failed to get colors")
continue
for key in color_keys:
our_hex = ours.get(key, "")
mat_hex = matugen.get(key, "")
if not our_hex or not mat_hex:
continue
try:
our_hct = get_hct(our_hex)
mat_hct = get_hct(mat_hex)
avg_chroma = (our_hct.chroma + mat_hct.chroma) / 2
# For low-chroma colors, use RGB distance instead of hue
# (hue is meaningless for near-grayscale colors)
if avg_chroma < 15:
rgb_dist = rgb_distance(our_hex, mat_hex)
# RGB distance: 0-10 excellent, 10-25 good, 25-50 fair
if rgb_dist < 10:
match = "excellent"
elif rgb_dist < 25:
match = "good"
elif rgb_dist < 50:
match = "fair"
else:
match = "poor"
diff_str = f"{rgb_dist:>5.1f} rgb"
else:
diff = hue_diff(our_hct.hue, mat_hct.hue)
if diff < 5:
match = "excellent"
elif diff < 15:
match = "good"
elif diff < 30:
match = "fair"
else:
match = "poor"
diff_str = f"{diff:>5.1f} hue"
print(f"{scheme:<12} {key:<14} {our_hex:<10} {mat_hex:<10} {diff_str:>10} {match:<10}")
except Exception as e:
print(f"{scheme:<12} {key:<14} Error: {e}")
print("-" * 78)
# Also show source color comparison
print("\nSource Color Extraction:")
print("-" * 40)
ours = run_our_processor(image_path, "tonal-spot")
matugen = run_matugen(image_path, "tonal-spot")
if ours and matugen:
# Get source from primary at tone 40 (approximation)
our_primary = ours.get("primary", "")
mat_source = matugen.get("source_color", "")
if our_primary and mat_source:
our_hct = get_hct(our_primary)
mat_hct = get_hct(mat_source)
print(f"Our primary hue: {our_hct.hue:.1f}°")
print(f"Matugen source hue: {mat_hct.hue:.1f}°")
print(f"Difference: {hue_diff(our_hct.hue, mat_hct.hue):.1f}°")
def main() -> int:
parser = argparse.ArgumentParser(
description="Compare Noctalia template-processor with matugen"
)
parser.add_argument(
"wallpaper",
type=Path,
help="Path to wallpaper image"
)
args = parser.parse_args()
if not args.wallpaper.exists():
print(f"Error: File not found: {args.wallpaper}", file=sys.stderr)
return 1
# Check if matugen is available
try:
subprocess.run(["matugen", "--version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: matugen not found. Please install matugen first.", file=sys.stderr)
return 1
compare_schemes(args.wallpaper)
return 0
if __name__ == "__main__":
sys.exit(main())
+129 -1
View File
@@ -2,15 +2,17 @@
Color representation and conversion utilities.
This module provides the Color class and functions for converting between
RGB and HSL color spaces.
RGB, HSL, and Lab color spaces.
"""
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
# Type aliases
RGB = tuple[int, int, int]
HSL = tuple[float, float, float]
LAB = tuple[float, float, float]
if TYPE_CHECKING:
from .hct import Hct
@@ -182,3 +184,129 @@ def saturate(color: Color, amount: float) -> Color:
h, s, l = color.to_hsl()
new_s = max(0.0, min(1.0, s + amount))
return Color.from_hsl(h, new_s, l)
# =============================================================================
# Lab Color Space (CIE L*a*b*)
# =============================================================================
# D65 white point
_WHITE_X = 95.047
_WHITE_Y = 100.0
_WHITE_Z = 108.883
def _linearize(channel: int) -> float:
"""Convert sRGB channel (0-255) to linear RGB (0-1)."""
normalized = channel / 255.0
if normalized <= 0.04045:
return normalized / 12.92
return math.pow((normalized + 0.055) / 1.055, 2.4)
def _delinearize(linear: float) -> int:
"""Convert linear RGB (0-1) to sRGB channel (0-255)."""
if linear <= 0.0031308:
normalized = linear * 12.92
else:
normalized = 1.055 * math.pow(linear, 1.0 / 2.4) - 0.055
return max(0, min(255, round(normalized * 255)))
def _lab_f(t: float) -> float:
"""Lab forward transform function."""
if t > 0.008856:
return math.pow(t, 1.0 / 3.0)
return (903.3 * t + 16.0) / 116.0
def _lab_f_inv(t: float) -> float:
"""Lab inverse transform function."""
if t > 0.206893:
return t * t * t
return (116.0 * t - 16.0) / 903.3
def rgb_to_lab(r: int, g: int, b: int) -> LAB:
"""
Convert sRGB (0-255) to CIE L*a*b*.
Returns:
Tuple of (L*, a*, b*) where L* is 0-100
"""
# sRGB to linear RGB
linear_r = _linearize(r)
linear_g = _linearize(g)
linear_b = _linearize(b)
# Linear RGB to XYZ (D65)
x = 0.4124564 * linear_r + 0.3575761 * linear_g + 0.1804375 * linear_b
y = 0.2126729 * linear_r + 0.7151522 * linear_g + 0.0721750 * linear_b
z = 0.0193339 * linear_r + 0.1191920 * linear_g + 0.9503041 * linear_b
# Scale to 0-100 range
x *= 100.0
y *= 100.0
z *= 100.0
# XYZ to Lab
fx = _lab_f(x / _WHITE_X)
fy = _lab_f(y / _WHITE_Y)
fz = _lab_f(z / _WHITE_Z)
L = 116.0 * fy - 16.0
a = 500.0 * (fx - fy)
b = 200.0 * (fy - fz)
return (L, a, b)
def lab_to_rgb(L: float, a: float, b: float) -> RGB:
"""
Convert CIE L*a*b* to sRGB (0-255).
Args:
L: Lightness (0-100)
a: Green-red component
b: Blue-yellow component
Returns:
Tuple of (r, g, b)
"""
# Lab to XYZ
fy = (L + 16.0) / 116.0
fx = a / 500.0 + fy
fz = fy - b / 200.0
x = _WHITE_X * _lab_f_inv(fx)
y = _WHITE_Y * _lab_f_inv(fy)
z = _WHITE_Z * _lab_f_inv(fz)
# Scale back to 0-1 range
x /= 100.0
y /= 100.0
z /= 100.0
# XYZ to linear RGB
linear_r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
linear_g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
linear_b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
# Clamp and delinearize
return (
_delinearize(max(0.0, min(1.0, linear_r))),
_delinearize(max(0.0, min(1.0, linear_g))),
_delinearize(max(0.0, min(1.0, linear_b)))
)
def lab_distance(lab1: LAB, lab2: LAB) -> float:
"""
Calculate Euclidean distance between two Lab colors.
This is a simple perceptual distance metric.
"""
dL = lab1[0] - lab2[0]
da = lab1[1] - lab2[1]
db = lab1[2] - lab2[2]
return math.sqrt(dL * dL + da * da + db * db)
+443 -71
View File
@@ -94,6 +94,442 @@ def _lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def _sanitize_degrees(degrees: float) -> float:
"""Ensure degrees is in [0, 360) range."""
degrees = degrees % 360.0
if degrees < 0:
degrees += 360.0
return degrees
# =============================================================================
# HCT Solver - Ported from Material Color Utilities
# =============================================================================
# Matrices for chromatic adaptation
_SCALED_DISCOUNT_FROM_LINRGB = [
[0.001200833568784504, 0.002389694492170889, 0.0002795742885861124],
[0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398],
[0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076],
]
_LINRGB_FROM_SCALED_DISCOUNT = [
[1373.2198709594231, -1100.4251190754821, -7.278681089101213],
[-271.815969077903, 559.6580465940733, -32.46047482791194],
[1.9622899599665666, -57.173814538844006, 308.7233197812385],
]
_Y_FROM_LINRGB = [0.2126, 0.7152, 0.0722]
# Critical planes for bisection (precomputed delinearized values 0-254)
_CRITICAL_PLANES = [
0.015176349177441876, 0.045529047532325624, 0.07588174588720938,
0.10623444424209313, 0.13658714259697685, 0.16693984095186062,
0.19729253930674434, 0.2276452376616281, 0.2579979360165119,
0.28835063437139563, 0.3188300904430532, 0.350925934958123,
0.3848314933096426, 0.42057480301049466, 0.458183274052838,
0.4976837250274023, 0.5391024159806381, 0.5824650784040898,
0.6277969426914107, 0.6751227633498623, 0.7244668422128921,
0.775853049866786, 0.829304845476233, 0.8848452951698498,
0.942497089126609, 1.0022825574869039, 1.0642236851973577,
1.1283421258858297, 1.1946592148522128, 1.2631959812511864,
1.3339731595349034, 1.407011200216447, 1.4823302800086415,
1.5599503113873272, 1.6398909516233677, 1.7221716113234105,
1.8068114625156377, 1.8938294463134073, 1.9832442801866852,
2.075074464868551, 2.1693382909216234, 2.2660538449872063,
2.36523901573795, 2.4669114995532007, 2.5710888059345764,
2.6777882626779785, 2.7870270208169257, 2.898822059350997,
3.0131901897720907, 3.1301480604002863, 3.2497121605402226,
3.3718988244681087, 3.4967242352587946, 3.624204428461639,
3.754355295633311, 3.887192587735158, 4.022731918402185,
4.160988767090289, 4.301978482107941, 4.445716283538092,
4.592217266055746, 4.741496401646282, 4.893568542229298,
5.048448422192488, 5.20615066083972, 5.3666897647573375,
5.5300801301023865, 5.696336044816294, 5.865471690767354,
6.037501145825082, 6.212438385869475, 6.390297286737924,
6.571091626112461, 6.7548350853498045, 6.941541251256611,
7.131223617812143, 7.323895587840543, 7.5195704746346665,
7.7182615035334345, 7.919981813454504, 8.124744458384042,
8.332562408825165, 8.543448553206703, 8.757415699253682,
8.974476575321063, 9.194643831691977, 9.417930041841839,
9.644347703669503, 9.873909240696694, 10.106627003236781,
10.342513269534024, 10.58158024687427, 10.8238400726681,
11.069304815507364, 11.317986476196008, 11.569896988756009,
11.825048221409341, 12.083451977536606, 12.345119996613247,
12.610063955123938, 12.878295467455942, 13.149826086772048,
13.42466730586372, 13.702830557985108, 13.984327217668513,
14.269168601521828, 14.55736596900856, 14.848930523210871,
15.143873411576273, 15.44220572664832, 15.743938506781891,
16.04908273684337, 16.35764934889634, 16.66964922287304,
16.985093187232053, 17.30399201960269, 17.62635644741625,
17.95219714852476, 18.281524751807332, 18.614349837764564,
18.95068293910138, 19.290534541298456, 19.633915083172692,
19.98083495742689, 20.331304511189067, 20.685334046541502,
21.042933821039977, 21.404114048223256, 21.76888489811322,
22.137256497705877, 22.50923893145328, 22.884842241736916,
23.264076429332462, 23.6469514538663, 24.033477234264016,
24.42366364919083, 24.817520537484558, 25.21505769858089,
25.61628489293138, 26.021211842414342, 26.429848230738664,
26.842203703840827, 27.258287870275353, 27.678110301598522,
28.10168053274597, 28.529008062403893, 28.96010235337422,
29.39497283293396, 29.83362889318845, 30.276079891419332,
30.722335150426627, 31.172403958865512, 31.62629557157785,
32.08401920991837, 32.54558406207592, 33.010999283389665,
33.4802739966603, 33.953417292456834, 34.430438229418264,
34.911345834551085, 35.39614910352207, 35.88485700094671,
36.37747846067349, 36.87402238606382, 37.37449765026789,
37.87891309649659, 38.38727753828926, 38.89959975977785,
39.41588851594697, 39.93615253289054, 40.460400508064545,
40.98864111053629, 41.520882981230194, 42.05713473317016,
42.597404951718396, 43.141702194811224, 43.6900349931913,
44.24241185063697, 44.798841244188324, 45.35933162437017,
45.92389141541209, 46.49252901546552, 47.065252796817916,
47.64207110610409, 48.22299226451468, 48.808024568002054,
49.3971762874833, 49.9904556690408, 50.587870934119984,
51.189430279724725, 51.79514187861014, 52.40501387947288,
53.0190544071392, 53.637271562750364, 54.259673423945976,
54.88626804504493, 55.517063457223934, 56.15206766869424,
56.79128866487574, 57.43473440856916, 58.08241284012621,
58.734331877617365, 59.39049941699807, 60.05092333227251,
60.715611475655585, 61.38457167773311, 62.057811747619894,
62.7353394731159, 63.417162620860914, 64.10328893648692,
64.79372614476921, 65.48848194977529, 66.18756403501224,
66.89098006357258, 67.59873767827808, 68.31084450182222,
69.02730813691093, 69.74813616640164, 70.47333615344107,
71.20291564160104, 71.93688215501312, 72.67524319850172,
73.41800625771542, 74.16517879925733, 74.9167682708136,
75.67278210128072, 76.43322770089146, 77.1981124613393,
77.96744375590167, 78.74122893956174, 79.51947534912904,
80.30219030335869, 81.08938110306934, 81.88105503125999,
82.67721935322541, 83.4778813166706, 84.28304815182372,
85.09272707154808, 85.90692527145302, 86.72564993000343,
87.54890820862819, 88.3767072518277, 89.2090541872801,
90.04595612594655, 90.88742016217518, 91.73345337380438,
92.58406282226491, 93.43925555268066, 94.29903859396902,
95.16341895893969, 96.03240364439274, 96.9059996312159,
97.78421388448044, 98.6670533535366, 99.55452497210776,
]
class HctSolver:
"""
Solves HCT to RGB conversion with proper gamut mapping.
Ported from Material Color Utilities (Rust/TypeScript).
When the requested chroma is out of gamut, this solver finds
the maximum achievable chroma while preserving the exact hue.
"""
@staticmethod
def _sanitize_radians(angle: float) -> float:
"""Ensure angle is in [0, 2π) range."""
return (angle + math.pi * 8) % (math.pi * 2)
@staticmethod
def _true_delinearized(rgb_component: float) -> float:
"""Delinearize RGB component (0-100) to (0-255)."""
normalized = rgb_component / 100.0
if normalized <= 0.0031308:
delinearized = normalized * 12.92
else:
delinearized = 1.055 * (normalized ** (1.0 / 2.4)) - 0.055
return delinearized * 255.0
@staticmethod
def _chromatic_adaptation(component: float) -> float:
"""Apply chromatic adaptation."""
af = abs(component) ** 0.42
return _signum(component) * 400.0 * af / (af + 27.13)
@staticmethod
def _hue_of(linrgb: list[float]) -> float:
"""Calculate hue of linear RGB color in radians."""
scaled_discount = _matrix_multiply(_SCALED_DISCOUNT_FROM_LINRGB, linrgb)
r_a = HctSolver._chromatic_adaptation(scaled_discount[0])
g_a = HctSolver._chromatic_adaptation(scaled_discount[1])
b_a = HctSolver._chromatic_adaptation(scaled_discount[2])
# redness-greenness
a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0
# yellowness-blueness
b = (r_a + g_a - 2.0 * b_a) / 9.0
return math.atan2(b, a)
@staticmethod
def _are_in_cyclic_order(a: float, b: float, c: float) -> bool:
"""Check if a, b, c are in cyclic order."""
delta_ab = HctSolver._sanitize_radians(b - a)
delta_ac = HctSolver._sanitize_radians(c - a)
return delta_ab < delta_ac
@staticmethod
def _intercept(source: float, mid: float, target: float) -> float:
"""Solve lerp equation: find t such that lerp(source, target, t) = mid."""
return (mid - source) / (target - source)
@staticmethod
def _lerp_point(source: list[float], t: float, target: list[float]) -> list[float]:
"""Linear interpolation between two 3D points."""
return [
source[0] + (target[0] - source[0]) * t,
source[1] + (target[1] - source[1]) * t,
source[2] + (target[2] - source[2]) * t,
]
@staticmethod
def _set_coordinate(source: list[float], coordinate: float,
target: list[float], axis: int) -> list[float]:
"""Find point on segment where axis equals coordinate."""
t = HctSolver._intercept(source[axis], coordinate, target[axis])
return HctSolver._lerp_point(source, t, target)
@staticmethod
def _is_bounded(x: float) -> bool:
"""Check if x is in [0, 100]."""
return 0.0 <= x <= 100.0
@staticmethod
def _nth_vertex(y: float, n: int) -> list[float]:
"""
Get nth vertex of RGB cube intersection with Y plane.
Returns [-1, -1, -1] if vertex is outside cube.
"""
k_r, k_g, k_b = _Y_FROM_LINRGB
coord_a = 0.0 if n % 4 <= 1 else 100.0
coord_b = 0.0 if n % 2 == 0 else 100.0
if n < 4:
g = coord_a
b = coord_b
r = (y - k_g * g - k_b * b) / k_r
if HctSolver._is_bounded(r):
return [r, g, b]
return [-1.0, -1.0, -1.0]
elif n < 8:
b = coord_a
r = coord_b
g = (y - k_r * r - k_b * b) / k_g
if HctSolver._is_bounded(g):
return [r, g, b]
return [-1.0, -1.0, -1.0]
else:
r = coord_a
g = coord_b
b = (y - k_r * r - k_g * g) / k_b
if HctSolver._is_bounded(b):
return [r, g, b]
return [-1.0, -1.0, -1.0]
@staticmethod
def _bisect_to_segment(y: float, target_hue: float) -> list[list[float]]:
"""Find segment on RGB cube containing target hue."""
left = [-1.0, -1.0, -1.0]
right = [-1.0, -1.0, -1.0]
left_hue = 0.0
right_hue = 0.0
initialized = False
uncut = True
for n in range(12):
mid = HctSolver._nth_vertex(y, n)
if mid[0] < 0:
continue
mid_hue = HctSolver._hue_of(mid)
if not initialized:
left = mid
right = mid
left_hue = mid_hue
right_hue = mid_hue
initialized = True
continue
if uncut or HctSolver._are_in_cyclic_order(left_hue, mid_hue, right_hue):
uncut = False
if HctSolver._are_in_cyclic_order(left_hue, target_hue, mid_hue):
right = mid
right_hue = mid_hue
else:
left = mid
left_hue = mid_hue
return [left, right]
@staticmethod
def _mid_point(a: list[float], b: list[float]) -> list[float]:
"""Calculate midpoint of two 3D points."""
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]
@staticmethod
def _critical_plane_below(x: float) -> int:
"""Get critical plane index below x."""
return int(math.floor(x - 0.5))
@staticmethod
def _critical_plane_above(x: float) -> int:
"""Get critical plane index above x."""
return int(math.ceil(x - 0.5))
@staticmethod
def _bisect_to_limit(y: float, target_hue: float) -> list[float]:
"""
Find color on RGB cube boundary with exact target hue.
This is the key function for hue-preserving gamut mapping.
"""
segment = HctSolver._bisect_to_segment(y, target_hue)
left = segment[0]
left_hue = HctSolver._hue_of(left)
right = segment[1]
for axis in range(3):
if abs(left[axis] - right[axis]) > 1e-10:
if left[axis] < right[axis]:
l_plane = HctSolver._critical_plane_below(
HctSolver._true_delinearized(left[axis]))
r_plane = HctSolver._critical_plane_above(
HctSolver._true_delinearized(right[axis]))
else:
l_plane = HctSolver._critical_plane_above(
HctSolver._true_delinearized(left[axis]))
r_plane = HctSolver._critical_plane_below(
HctSolver._true_delinearized(right[axis]))
for _ in range(8):
if abs(r_plane - l_plane) <= 1:
break
m_plane = int((l_plane + r_plane) / 2)
# Clamp to valid index range
m_plane = max(0, min(len(_CRITICAL_PLANES) - 1, m_plane))
mid_plane_coordinate = _CRITICAL_PLANES[m_plane]
mid = HctSolver._set_coordinate(left, mid_plane_coordinate, right, axis)
mid_hue = HctSolver._hue_of(mid)
if HctSolver._are_in_cyclic_order(left_hue, target_hue, mid_hue):
right = mid
r_plane = m_plane
else:
left = mid
left_hue = mid_hue
l_plane = m_plane
return HctSolver._mid_point(left, right)
@staticmethod
def _inverse_chromatic_adaptation(adapted: float) -> float:
"""Inverse of chromatic adaptation."""
adapted_abs = abs(adapted)
base = max(0.0, 27.13 * adapted_abs / (400.0 - adapted_abs))
return _signum(adapted) * (base ** (1.0 / 0.42))
@staticmethod
def _find_result_by_j(hue_radians: float, chroma: float, y: float) -> tuple[int, int, int] | None:
"""
Try to find exact color with given hue, chroma, and Y.
Returns None if out of gamut.
"""
j = math.sqrt(y) * 11.0
t_inner_coeff = 1.0 / ((1.64 - (0.29 ** ViewingConditions.n)) ** 0.73)
e_hue = 0.25 * (math.cos(hue_radians + 2.0) + 3.8)
p1 = e_hue * (50000.0 / 13.0) * ViewingConditions.nc * ViewingConditions.ncb
h_sin = math.sin(hue_radians)
h_cos = math.cos(hue_radians)
for iteration in range(5):
j_normalized = j / 100.0
if chroma == 0 or j == 0:
alpha = 0.0
else:
alpha = chroma / math.sqrt(j_normalized)
t = (alpha * t_inner_coeff) ** (1.0 / 0.9)
ac = ViewingConditions.aw * (j_normalized ** (1.0 / ViewingConditions.c / ViewingConditions.z))
p2 = ac / ViewingConditions.nbb
gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin)
a = gamma * h_cos
b = gamma * h_sin
r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0
g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0
b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0
r_cscaled = HctSolver._inverse_chromatic_adaptation(r_a)
g_cscaled = HctSolver._inverse_chromatic_adaptation(g_a)
b_cscaled = HctSolver._inverse_chromatic_adaptation(b_a)
linrgb = _matrix_multiply(_LINRGB_FROM_SCALED_DISCOUNT,
[r_cscaled, g_cscaled, b_cscaled])
# Check if in gamut
if linrgb[0] < 0 or linrgb[1] < 0 or linrgb[2] < 0:
return None
k_r, k_g, k_b = _Y_FROM_LINRGB
fnj = k_r * linrgb[0] + k_g * linrgb[1] + k_b * linrgb[2]
if fnj <= 0:
return None
if iteration == 4 or abs(fnj - y) < 0.002:
if linrgb[0] > 100.01 or linrgb[1] > 100.01 or linrgb[2] > 100.01:
return None
# Convert linear RGB to sRGB
return (
_delinearize(linrgb[0] / 100.0),
_delinearize(linrgb[1] / 100.0),
_delinearize(linrgb[2] / 100.0),
)
# Newton iteration
j = j - (fnj - y) * j / (2.0 * fnj)
return None
@staticmethod
def solve_to_rgb(hue_degrees: float, chroma: float, tone: float) -> tuple[int, int, int]:
"""
Solve HCT to RGB with proper gamut mapping.
If the exact color is out of gamut, finds the maximum achievable
chroma while preserving the exact hue.
"""
if chroma < 0.0001 or tone < 0.0001 or tone > 99.9999:
# Achromatic - just convert tone to gray
y = lstar_to_y(tone)
gray = _delinearize(y / 100.0)
return (gray, gray, gray)
hue_degrees = _sanitize_degrees(hue_degrees)
hue_radians = math.radians(hue_degrees)
# Y is in 0-100 range (same scale as internal linear RGB in the solver)
y = lstar_to_y(tone)
# Try to find exact solution
exact = HctSolver._find_result_by_j(hue_radians, chroma, y)
if exact is not None:
return exact
# Fall back to bisection - find max chroma that preserves hue
linrgb = HctSolver._bisect_to_limit(y, hue_radians)
return (
_delinearize(linrgb[0] / 100.0),
_delinearize(linrgb[1] / 100.0),
_delinearize(linrgb[2] / 100.0),
)
def rgb_to_xyz(r: int, g: int, b: int) -> tuple[float, float, float]:
"""Convert sRGB to CIE XYZ."""
linear_r = _linearize(r)
@@ -328,78 +764,14 @@ class Hct:
@staticmethod
def _solve_to_rgb(hue: float, chroma: float, tone: float) -> tuple[int, int, int]:
"""Solve for RGB given HCT values."""
if tone <= 0.0:
return (0, 0, 0)
if tone >= 100.0:
return (255, 255, 255)
if chroma < 0.5:
y = lstar_to_y(tone)
return xyz_to_rgb(y, y, y)
"""
Solve for RGB given HCT values using the Material HctSolver.
low_chroma = 0.0
high_chroma = chroma
best_rgb = None
best_chroma = 0.0
for iteration in range(20):
mid_chroma = (low_chroma + high_chroma) / 2.0
rgb = Hct._find_rgb_for_hct(hue, mid_chroma, tone)
if rgb is not None:
r, g, b = rgb
if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
best_rgb = rgb
best_chroma = mid_chroma
low_chroma = mid_chroma
else:
high_chroma = mid_chroma
else:
high_chroma = mid_chroma
if best_rgb is not None:
return best_rgb
y = lstar_to_y(tone)
return xyz_to_rgb(y, y, y)
@staticmethod
def _find_rgb_for_hct(hue: float, chroma: float, tone: float) -> tuple[int, int, int] | None:
"""Find an RGB color for the given HCT values."""
j = tone
for _ in range(5):
cam = Cam16.from_jch(j, chroma, hue)
rgb = cam.to_rgb()
r, g, b = rgb
r_clamped = max(0, min(255, r))
g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b))
if r != r_clamped or g != g_clamped or b != b_clamped:
return None
_, y, _ = rgb_to_xyz(r, g, b)
actual_tone = y_to_lstar(y)
tone_diff = tone - actual_tone
if abs(tone_diff) < 0.5:
return (r, g, b)
j += tone_diff * 0.5
if j <= 0 or j > 100:
return None
cam = Cam16.from_jch(j, chroma, hue)
rgb = cam.to_rgb()
r, g, b = rgb
if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
return (r, g, b)
return None
This uses proper gamut mapping that preserves hue exactly.
When the requested chroma is out of gamut, it finds the maximum
achievable chroma while maintaining the exact target hue.
"""
return HctSolver.solve_to_rgb(hue, chroma, tone)
def set_hue(self, hue: float) -> 'Hct':
"""Return new HCT with different hue."""
+237 -113
View File
@@ -1,137 +1,118 @@
"""
Material Design 3 color scheme implementation.
This module provides the MaterialScheme class for generating MD3 color schemes
This module provides scheme classes for generating MD3 color schemes
from a source color using the HCT color space.
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)
"""
from .hct import Hct, TonalPalette
class MaterialScheme:
"""
Material Design 3 color scheme generator.
# =============================================================================
# Tone Values (shared across all schemes)
# =============================================================================
Implements the official Material Design 3 color system using HCT color space.
Based on SchemeContent variant which preserves the source color's character.
"""
# Tone values for Material Design 3 (dark theme)
DARK_TONES = {
'primary': 80,
'on_primary': 20,
'primary_container': 30,
'on_primary_container': 90,
'secondary': 80,
'on_secondary': 20,
'secondary_container': 30,
'on_secondary_container': 90,
'tertiary': 80,
'on_tertiary': 20,
'tertiary_container': 30,
'on_tertiary_container': 90,
'error': 80,
'on_error': 20,
'error_container': 30,
'on_error_container': 90,
'surface': 6,
'on_surface': 90,
'surface_variant': 30,
'on_surface_variant': 80,
'surface_container_lowest': 4,
'surface_container_low': 10,
'surface_container': 12,
'surface_container_high': 17,
'surface_container_highest': 22,
'outline': 60,
'outline_variant': 30,
'shadow': 0,
'scrim': 0,
'inverse_surface': 90,
'inverse_on_surface': 20,
'inverse_primary': 40,
}
# Tone values for Material Design 3 (dark theme)
DARK_TONES = {
'primary': 80,
'on_primary': 20,
'primary_container': 30,
'on_primary_container': 90,
'secondary': 80,
'on_secondary': 20,
'secondary_container': 30,
'on_secondary_container': 90,
'tertiary': 80,
'on_tertiary': 20,
'tertiary_container': 30,
'on_tertiary_container': 90,
'error': 80,
'on_error': 20,
'error_container': 30,
'on_error_container': 90,
'surface': 6,
'on_surface': 90,
'surface_variant': 30,
'on_surface_variant': 80,
'surface_container_lowest': 4,
'surface_container_low': 10,
'surface_container': 12,
'surface_container_high': 17,
'surface_container_highest': 22,
'outline': 60,
'outline_variant': 30,
'shadow': 0,
'scrim': 0,
'inverse_surface': 90,
'inverse_on_surface': 20,
'inverse_primary': 40,
}
# Tone values for Material Design 3 (light theme)
LIGHT_TONES = {
'primary': 40,
'on_primary': 100,
'primary_container': 90,
'on_primary_container': 10,
'secondary': 40,
'on_secondary': 100,
'secondary_container': 90,
'on_secondary_container': 10,
'tertiary': 40,
'on_tertiary': 100,
'tertiary_container': 90,
'on_tertiary_container': 10,
'error': 40,
'on_error': 100,
'error_container': 90,
'on_error_container': 10,
'surface': 98,
'on_surface': 10,
'surface_variant': 90,
'on_surface_variant': 30,
'surface_container_lowest': 100,
'surface_container_low': 96,
'surface_container': 94,
'surface_container_high': 92,
'surface_container_highest': 90,
'outline': 50,
'outline_variant': 80,
'shadow': 0,
'scrim': 0,
'inverse_surface': 20,
'inverse_on_surface': 95,
'inverse_primary': 80,
}
# Tone values for Material Design 3 (light theme)
LIGHT_TONES = {
'primary': 40,
'on_primary': 100,
'primary_container': 90,
'on_primary_container': 10,
'secondary': 40,
'on_secondary': 100,
'secondary_container': 90,
'on_secondary_container': 10,
'tertiary': 40,
'on_tertiary': 100,
'tertiary_container': 90,
'on_tertiary_container': 10,
'error': 40,
'on_error': 100,
'error_container': 90,
'on_error_container': 10,
'surface': 98,
'on_surface': 10,
'surface_variant': 90,
'on_surface_variant': 30,
'surface_container_lowest': 100,
'surface_container_low': 96,
'surface_container': 94,
'surface_container_high': 92,
'surface_container_highest': 90,
'outline': 50,
'outline_variant': 80,
'shadow': 0,
'scrim': 0,
'inverse_surface': 20,
'inverse_on_surface': 95,
'inverse_primary': 80,
}
# =============================================================================
# Base Scheme Class
# =============================================================================
class _BaseScheme:
"""Base class for all Material Design 3 schemes."""
# Error palette is the same for all schemes
error_palette: TonalPalette
def __init__(self, source_color: Hct):
"""
Create a Material Design 3 scheme from a source color.
Args:
source_color: The source color in HCT space
"""
"""Initialize with source color. Subclasses must set palettes."""
self.source = source_color
# Create tonal palettes for each color role
# SchemeContent-style: preserves source color characteristics
# Primary: source color's hue and chroma (unchanged)
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
# Secondary: same hue, reduced chroma
# Formula: max(chroma - 24, chroma * 0.6) - slightly more vibrant than stock MD3
secondary_chroma = max(source_color.chroma - 24.0, source_color.chroma * 0.6)
self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
# Tertiary: analogous color (simplified as 60° rotation)
# In full implementation this uses TemperatureCache for analogous colors
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)
# Error: red hue with high chroma
self.error_palette = TonalPalette(25.0, 84.0) # Material red
# Neutral: source hue, low chroma (chroma / 6) - slightly tinted surfaces
neutral_chroma = source_color.chroma / 6.0
self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
# Neutral variant: source hue, slightly more chroma than neutral
neutral_variant_chroma = (source_color.chroma / 6.0) + 4.0
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
@classmethod
def from_rgb(cls, r: int, g: int, b: int) -> 'MaterialScheme':
def from_rgb(cls, r: int, g: int, b: int) -> '_BaseScheme':
"""Create scheme from RGB color."""
return cls(Hct.from_rgb(r, g, b))
@classmethod
def from_hex(cls, hex_color: str) -> 'MaterialScheme':
def from_hex(cls, hex_color: str) -> '_BaseScheme':
"""Create scheme from hex color string."""
hex_color = hex_color.lstrip('#')
r = int(hex_color[0:2], 16)
@@ -149,7 +130,7 @@ class MaterialScheme:
def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
"""Generate scheme with appropriate tone values."""
tones = self.DARK_TONES if is_dark else self.LIGHT_TONES
tones = DARK_TONES if is_dark else LIGHT_TONES
scheme = {
# Primary colors
@@ -228,6 +209,149 @@ class MaterialScheme:
return scheme
# =============================================================================
# Scheme Implementations
# =============================================================================
class SchemeTonalSpot(_BaseScheme):
"""
Tonal Spot scheme - the default Android 12-13 Material You scheme.
Uses fixed chroma values for consistent, harmonious palettes:
- Primary: source hue, chroma 48
- Secondary: source hue, chroma 16
- Tertiary: hue +60°, chroma 24
- Neutrals: low chroma (tinted with source hue)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: source hue with fixed chroma 48
self.primary_palette = TonalPalette(source_color.hue, 48.0)
# Secondary: source hue with lower chroma 16
self.secondary_palette = TonalPalette(source_color.hue, 16.0)
# Tertiary: 60° hue rotation with chroma 24
tertiary_hue = (source_color.hue + 60.0) % 360.0
self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
# Neutral: source hue with very low chroma (tinted grays)
self.neutral_palette = TonalPalette(source_color.hue, 4.0)
# Neutral variant: slightly more chroma for contrast
self.neutral_variant_palette = TonalPalette(source_color.hue, 8.0)
class SchemeFruitSalad(_BaseScheme):
"""
Fruit Salad scheme - bold, playful theme with hue rotation.
Designed for expressive, colorful themes:
- Primary: hue -50°, chroma 48
- Secondary: hue -50°, chroma 36
- Tertiary: source hue (original), chroma 36
- Neutrals: tinted (chroma 10-16)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Rotate hue by -50° for primary and secondary
rotated_hue = (source_color.hue - 50.0) % 360.0
# Primary: rotated hue with chroma 48
self.primary_palette = TonalPalette(rotated_hue, 48.0)
# Secondary: rotated hue with chroma 36
self.secondary_palette = TonalPalette(rotated_hue, 36.0)
# Tertiary: original source hue with chroma 36
self.tertiary_palette = TonalPalette(source_color.hue, 36.0)
# Neutral: source hue with higher chroma (tinted)
self.neutral_palette = TonalPalette(source_color.hue, 10.0)
# Neutral variant: even more tinted
self.neutral_variant_palette = TonalPalette(source_color.hue, 16.0)
class SchemeRainbow(_BaseScheme):
"""
Rainbow scheme - chromatic accents with grayscale neutrals.
Same structure as Tonal Spot but with pure grayscale neutrals:
- Primary: source hue, chroma 48
- Secondary: source hue, chroma 16
- Tertiary: hue +60°, chroma 24
- Neutrals: pure grayscale (chroma 0)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: source hue with fixed chroma 48
self.primary_palette = TonalPalette(source_color.hue, 48.0)
# Secondary: source hue with lower chroma 16
self.secondary_palette = TonalPalette(source_color.hue, 16.0)
# Tertiary: 60° hue rotation with chroma 24
tertiary_hue = (source_color.hue + 60.0) % 360.0
self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
# Neutral: pure grayscale (chroma 0)
self.neutral_palette = TonalPalette(0.0, 0.0)
# Neutral variant: also grayscale
self.neutral_variant_palette = TonalPalette(0.0, 0.0)
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)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: preserve source color's hue and chroma
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)
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)
# Neutral: source hue, low chroma (chroma / 6)
neutral_chroma = source_color.chroma / 6.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
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
# Backward compatibility alias
MaterialScheme = SchemeContent
# =============================================================================
# Helper Functions
# =============================================================================
def harmonize_color(design_color: Hct, source_color: Hct, amount: float = 0.5) -> Hct:
"""
Shift a design color's hue towards a source color's hue.
+262 -127
View File
@@ -7,12 +7,13 @@ using perceptual color distance calculations and k-means clustering.
import math
from .color import Color, rgb_to_hsl, hsl_to_rgb, hue_distance
from .color import Color, rgb_to_hsl, hsl_to_rgb, hue_distance, rgb_to_lab, lab_to_rgb, lab_distance
from .hct import Cam16, Hct
# Type aliases
RGB = tuple[int, int, int]
HSL = tuple[float, float, float]
LAB = tuple[float, float, float]
def downsample_pixels(pixels: list[RGB], factor: int = 4) -> list[RGB]:
@@ -30,100 +31,78 @@ def downsample_pixels(pixels: list[RGB], factor: int = 4) -> list[RGB]:
return pixels[::step]
def color_distance_hsl(c1: HSL, c2: HSL) -> float:
"""
Calculate perceptual distance between two colors in HSL space.
Hue is weighted less for low-saturation colors (grays).
"""
h1, s1, l1 = c1
h2, s2, l2 = c2
# Hue distance (circular)
dh = min(abs(h1 - h2), 360 - abs(h1 - h2)) / 180.0
# Weight hue by average saturation (grays have similar hues but shouldn't match)
avg_sat = (s1 + s2) / 2
dh_weighted = dh * avg_sat
ds = abs(s1 - s2)
dl = abs(l1 - l2)
return (dh_weighted ** 2 + ds ** 2 + dl ** 2) ** 0.5
def kmeans_cluster(
colors: list[RGB],
k: int = 5,
iterations: int = 15
iterations: int = 10
) -> list[tuple[RGB, int]]:
"""
Perform K-means clustering on colors.
Perform K-means clustering on colors in Lab color space.
Lab space is perceptually uniform, matching matugen's approach.
Returns list of (centroid_rgb, cluster_size) tuples, sorted by cluster size.
Uses deterministic initialization for reproducible results.
"""
if len(colors) < k:
# Not enough colors, return what we have
unique = list(set(colors))
return [(c, colors.count(c)) for c in unique[:k]]
# Convert to HSL for perceptual clustering
colors_hsl = [rgb_to_hsl(*c) for c in colors]
# Convert to Lab for perceptual clustering (like matugen's WSMeans)
colors_lab = [rgb_to_lab(*c) for c in colors]
# Deterministic initialization: pick evenly spaced colors from sorted list
sorted_indices = sorted(range(len(colors_hsl)), key=lambda i: colors_hsl[i])
# Sort by L (lightness) first for better spread
sorted_indices = sorted(range(len(colors_lab)), key=lambda i: colors_lab[i][0])
step = len(sorted_indices) // k
centroids = [colors_hsl[sorted_indices[i * step]] for i in range(k)]
centroids = [colors_lab[sorted_indices[i * step]] for i in range(k)]
# K-means iterations
assignments = [0] * len(colors_lab)
for _ in range(iterations):
# Assign colors to nearest centroid
clusters: list[list[HSL]] = [[] for _ in range(k)]
for color in colors_hsl:
for idx, color in enumerate(colors_lab):
min_dist = float('inf')
min_idx = 0
min_cluster = 0
for i, centroid in enumerate(centroids):
dist = color_distance_hsl(color, centroid)
dist = lab_distance(color, centroid)
if dist < min_dist:
min_dist = dist
min_idx = i
clusters[min_idx].append(color)
min_cluster = i
assignments[idx] = min_cluster
# Update centroids
# Update centroids (simple mean in Lab space)
new_centroids = []
for i, cluster in enumerate(clusters):
if cluster:
# Circular mean for hue (hue is 0-360, wraps around)
sin_sum = sum(math.sin(math.radians(c[0])) for c in cluster)
cos_sum = sum(math.cos(math.radians(c[0])) for c in cluster)
avg_h = math.degrees(math.atan2(sin_sum, cos_sum)) % 360
avg_s = sum(c[1] for c in cluster) / len(cluster)
avg_l = sum(c[2] for c in cluster) / len(cluster)
new_centroids.append((avg_h, avg_s, avg_l))
for i in range(k):
cluster_colors = [colors_lab[j] for j in range(len(colors_lab)) if assignments[j] == i]
if cluster_colors:
avg_L = sum(c[0] for c in cluster_colors) / len(cluster_colors)
avg_a = sum(c[1] for c in cluster_colors) / len(cluster_colors)
avg_b = sum(c[2] for c in cluster_colors) / len(cluster_colors)
new_centroids.append((avg_L, avg_a, avg_b))
else:
new_centroids.append(centroids[i])
centroids = new_centroids
# Final assignment and counting
# Final assignment and count, also find representative pixel (closest to centroid)
cluster_counts = [0] * k
for color in colors_hsl:
min_dist = float('inf')
min_idx = 0
for i, centroid in enumerate(centroids):
dist = color_distance_hsl(color, centroid)
if dist < min_dist:
min_dist = dist
min_idx = i
cluster_counts[min_idx] += 1
cluster_representatives: list[tuple[RGB, float]] = [(colors[0], float('inf'))] * k
# Convert centroids back to RGB and pair with counts
for idx, color_lab in enumerate(colors_lab):
cluster_idx = assignments[idx]
cluster_counts[cluster_idx] += 1
# Track the pixel closest to the centroid as the representative
dist = lab_distance(color_lab, centroids[cluster_idx])
if dist < cluster_representatives[cluster_idx][1]:
cluster_representatives[cluster_idx] = (colors[idx], dist)
# Use representative pixels (actual image colors) instead of computed centroids
results = []
for i, centroid in enumerate(centroids):
rgb = hsl_to_rgb(*centroid)
results.append((rgb, cluster_counts[i]))
for i in range(k):
if cluster_counts[i] > 0:
rgb = cluster_representatives[i][0]
results.append((rgb, cluster_counts[i]))
# Sort by cluster size (most common first)
results.sort(key=lambda x: -x[1])
@@ -131,105 +110,261 @@ def kmeans_cluster(
return results
def extract_palette(pixels: list[RGB], k: int = 5) -> list[Color]:
"""
Extract K dominant colors from pixel data using CAM16 chroma filtering.
def _hue_distance(h1: float, h2: float) -> float:
"""Calculate circular distance between two hues (0-360)."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
Uses the same approach as matugen: filter by CAM16 chroma >= 5.0 to
ensure we get colorful, usable theme colors.
def _score_colors_chroma(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing chroma (vibrancy).
This is the original scoring algorithm that picks the most colorful colors.
Used for "vibrant" mode.
Args:
pixels: List of RGB tuples
k: Number of colors to extract
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of Color objects, sorted by dominance
List of (Color, score) tuples, sorted by score descending
"""
# Downsample for performance
sampled = downsample_pixels(pixels, factor=4)
# Filter using CAM16 chroma (like matugen does with chroma >= 5.0)
# This is more perceptually accurate than HSL saturation filtering
filtered = []
for p in sampled:
try:
cam = Cam16.from_rgb(p[0], p[1], p[2])
# Keep colors with sufficient chroma (colorfulness)
# matugen uses chroma >= 5.0
if cam.chroma >= 5.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
# Skip invalid colors
continue
# Fall back to tone-based filter if CAM16 filtering removed too many
if len(filtered) < k * 10:
filtered = []
for p in sampled:
try:
hct = Hct.from_rgb(p[0], p[1], p[2])
# Keep colors with reasonable tone (not too dark or bright)
if 15.0 < hct.tone < 85.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
continue
if len(filtered) < k * 10:
filtered = sampled
# Cluster
clusters = kmeans_cluster(filtered, k=k)
# Score colors like Material's Score algorithm
# Prioritizes colors that will work well as theme source colors
result_colors = []
for rgb, count in clusters:
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
# Calculate score based on Material Design principles:
# 1. Chroma contribution - prefer colorful colors
# Chroma contribution - prefer colorful colors
chroma_score = hct.chroma
# 2. Tone penalty - prefer mid-tones (40-60 is ideal)
# Penalize very dark (<20) or very bright (>80) colors
# Tone penalty - prefer mid-tones (40-60 is ideal)
if hct.tone < 20:
tone_penalty = (20 - hct.tone) * 2 # Heavy penalty for dark
tone_penalty = (20 - hct.tone) * 2
elif hct.tone > 80:
tone_penalty = (hct.tone - 80) * 1.5 # Moderate penalty for bright
tone_penalty = (hct.tone - 80) * 1.5
elif hct.tone < 40:
tone_penalty = (40 - hct.tone) * 0.5 # Light penalty for somewhat dark
tone_penalty = (40 - hct.tone) * 0.5
elif hct.tone > 60:
tone_penalty = (hct.tone - 60) * 0.3 # Very light penalty
tone_penalty = (hct.tone - 60) * 0.3
else:
tone_penalty = 0 # Ideal tone range
tone_penalty = 0
# 3. Hue penalty - slight penalty for yellow-green hues (less popular)
hue = hct.hue
if 80 < hue < 110: # Yellow-green range
# Hue penalty - slight penalty for yellow-green hues
if 80 < hct.hue < 110:
hue_penalty = 5
else:
hue_penalty = 0
# Combined score: chroma contribution minus penalties, weighted by count
# Combined score: chroma minus penalties, weighted by count
score = (chroma_score - tone_penalty - hue_penalty) * math.sqrt(count)
result_colors.append((color, score, hct.chroma, hct.tone))
result_colors.append((color, score))
except (ValueError, ZeroDivisionError):
result_colors.append((color, 0.0, 0.0, 50.0))
result_colors.append((color, 0.0))
# Sort by score (highest first)
result_colors.sort(key=lambda x: -x[1])
return result_colors
# Extract just the colors
final_colors = [c[0] for c in result_colors]
def _score_colors_population(
colors_with_counts: list[tuple[RGB, int]],
total_pixels: int
) -> list[tuple[Color, float]]:
"""
Score colors using Material Design's Score algorithm.
This matches matugen's scoring approach exactly:
- Build per-hue population histogram (360 buckets)
- Calculate "excited proportions" (±15° hue window sum)
- Score: proportion * 100 * 0.7 + (chroma - 48) * weight
- Filter by chroma >= 5 and proportion >= 1%
- Deduplicate by maximizing hue distance
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
total_pixels: Total number of pixels in the sample
Returns:
List of (Color, score) tuples, sorted by score descending
"""
# Constants matching Material Score
TARGET_CHROMA = 48.0
WEIGHT_PROPORTION = 0.7
WEIGHT_CHROMA_ABOVE = 0.3
WEIGHT_CHROMA_BELOW = 0.1
CUTOFF_CHROMA = 5.0
CUTOFF_EXCITED_PROPORTION = 0.01
# Build per-hue population histogram (360 buckets)
hue_population = [0] * 360
population_sum = 0
colors_hct: list[tuple[Color, Hct, int]] = []
for rgb, count in colors_with_counts:
try:
color = Color.from_rgb(rgb)
hct = color.to_hct()
hue_bucket = int(hct.hue) % 360
hue_population[hue_bucket] += count
population_sum += count
colors_hct.append((color, hct, count))
except (ValueError, ZeroDivisionError):
continue
if not colors_hct or population_sum == 0:
# Fallback: return colors without scoring
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
return sorted(result, key=lambda x: -x[1])
# Calculate "excited proportions" - sum of proportions in ±15° hue window
hue_excited_proportions = [0.0] * 360
for hue in range(360):
proportion = hue_population[hue] / population_sum
# Spread to neighboring hues (±15°, so 30° total window)
for offset in range(-14, 16):
neighbor_hue = (hue + offset) % 360
hue_excited_proportions[neighbor_hue] += proportion
# Score each color
scored_hcts: list[tuple[Color, Hct, float]] = []
for color, hct, count in colors_hct:
hue_bucket = int(hct.hue) % 360
proportion = hue_excited_proportions[hue_bucket]
# Filter by chroma and proportion
if hct.chroma < CUTOFF_CHROMA:
continue
if proportion <= CUTOFF_EXCITED_PROPORTION:
continue
# Proportion score (70% weight)
proportion_score = proportion * 100.0 * WEIGHT_PROPORTION
# Chroma score: (chroma - target) * weight
# This gives bonus for high chroma, penalty for low chroma
if hct.chroma < TARGET_CHROMA:
chroma_weight = WEIGHT_CHROMA_BELOW
else:
chroma_weight = WEIGHT_CHROMA_ABOVE
chroma_score = (hct.chroma - TARGET_CHROMA) * chroma_weight
score = proportion_score + chroma_score
scored_hcts.append((color, hct, score))
if not scored_hcts:
# Fallback if filtering removed everything
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
return sorted(result, key=lambda x: -x[1])
# Sort by score descending
scored_hcts.sort(key=lambda x: -x[2])
# Deduplicate by hue distance - pick colors maximizing hue diversity
# Start at 90° minimum distance, decrease to 15° if needed
chosen_colors: list[tuple[Color, float]] = []
for min_hue_diff in range(90, 14, -1):
chosen_colors.clear()
for color, hct, score in scored_hcts:
# Check if this hue is far enough from all chosen colors
is_far_enough = True
for chosen_color, _ in chosen_colors:
chosen_hct = chosen_color.to_hct()
if _hue_distance(hct.hue, chosen_hct.hue) < min_hue_diff:
is_far_enough = False
break
if is_far_enough:
chosen_colors.append((color, score))
# Stop if we have enough colors (4 is Material default)
if len(chosen_colors) >= 4:
break
# If we found enough colors, stop decreasing threshold
if len(chosen_colors) >= 4:
break
# If deduplication yielded nothing, fall back to top scored
if not chosen_colors:
chosen_colors = [(c, s) for c, h, s in scored_hcts[:4]]
return chosen_colors
def extract_palette(
pixels: list[RGB],
k: int = 5,
scoring: str = "population"
) -> list[Color]:
"""
Extract K dominant colors from pixel data.
Args:
pixels: List of RGB tuples
k: Number of colors to extract
scoring: Scoring method - "population" (matugen-like, representative colors)
or "chroma" (vibrant, most colorful colors)
Returns:
List of Color objects, sorted by score
"""
# Downsample for performance
sampled = downsample_pixels(pixels, factor=4)
total_sampled = len(sampled)
# For population scoring, we need many clusters then score/filter them
# For chroma scoring, fewer clusters work fine
if scoring == "population":
# Use more clusters for Material scoring (like matugen's 128-256)
cluster_count = min(128, max(k * 10, len(set(sampled)) // 10))
# Don't pre-filter for population scoring - let the Score algorithm filter
# This matches matugen which quantizes all pixels, then filters in scoring
filtered = sampled
else:
cluster_count = k
# For chroma scoring, filter to colorful pixels
filtered = []
for p in sampled:
try:
cam = Cam16.from_rgb(p[0], p[1], p[2])
if cam.chroma >= 5.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
continue
if len(filtered) < cluster_count * 2:
filtered = sampled
# Cluster
clusters = kmeans_cluster(filtered, k=cluster_count)
# Score colors based on method
if scoring == "chroma":
scored = _score_colors_chroma(clusters)
else:
scored = _score_colors_population(clusters, total_sampled)
# Extract colors
final_colors = [c[0] for c in scored]
# Ensure we have enough colors by deriving from primary using HCT
while len(final_colors) < k:
if not final_colors:
final_colors.append(Color.from_hex("#6750A4"))
continue
primary = final_colors[0]
primary_hct = primary.to_hct()
offset = len(final_colors) * 60.0 # 60° hue rotation in HCT
offset = len(final_colors) * 60.0
new_hct = Hct((primary_hct.hue + offset) % 360.0, primary_hct.chroma, primary_hct.tone)
final_colors.append(Color.from_hct(new_hct))
+56 -19
View File
@@ -4,44 +4,69 @@ Theme generation functions for Material and Normal modes.
This module provides functions for generating complete color themes
from a color palette, supporting both Material Design 3 and a more
vibrant "wallust-style" theme.
Supported scheme types:
- tonal-spot: Default Android 12-13 scheme (recommended)
- fruit-salad: Bold/playful with hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Preserves wallpaper colors directly (legacy)
"""
from typing import Literal
from .color import Color, shift_hue, hue_distance, adjust_surface
from .contrast import ensure_contrast
from .material import MaterialScheme
from .material import SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow, SchemeContent
from .palette import find_error_color
# Type alias
# Type aliases
ThemeMode = Literal["dark", "light"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "vibrant"]
# 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
}
def generate_material_dark(palette: list[Color]) -> dict[str, str]:
def generate_material_dark(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
"""
Generate Material Design 3 dark theme from palette using HCT color space.
Uses proper Material Design 3 tonal palettes and tone values for
perceptually accurate and consistent theming.
Args:
palette: List of extracted colors (primary color is index 0)
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
Returns:
Dictionary of color token names to hex values
"""
primary = palette[0] if palette else Color(255, 245, 155)
# Create Material scheme from primary color
scheme = MaterialScheme.from_rgb(primary.r, primary.g, primary.b)
# Get the appropriate scheme class
scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
return scheme.get_dark_scheme()
def generate_material_light(palette: list[Color]) -> dict[str, str]:
def generate_material_light(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
"""
Generate Material Design 3 light theme from palette using HCT color space.
Uses proper Material Design 3 tonal palettes and tone values for
perceptually accurate and consistent theming.
Args:
palette: List of extracted colors (primary color is index 0)
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
Returns:
Dictionary of color token names to hex values
"""
primary = palette[0] if palette else Color(93, 101, 245)
# Create Material scheme from primary color
scheme = MaterialScheme.from_rgb(primary.r, primary.g, primary.b)
# Get the appropriate scheme class
scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
return scheme.get_light_scheme()
@@ -449,14 +474,26 @@ def generate_normal_light(palette: list[Color]) -> dict[str, str]:
def generate_theme(
palette: list[Color],
mode: ThemeMode,
material: bool = True
scheme_type: str = "tonal-spot"
) -> dict[str, str]:
"""Generate theme for specified mode."""
if material:
if mode == "dark":
return generate_material_dark(palette)
return generate_material_light(palette)
else:
"""
Generate theme for specified mode and scheme type.
Args:
palette: List of extracted colors
mode: "dark" or "light"
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow", "vibrant"
Returns:
Dictionary of color token names to hex values
"""
# Handle vibrant mode separately (uses legacy generate_normal_* functions)
if scheme_type == "vibrant":
if mode == "dark":
return generate_normal_dark(palette)
return generate_normal_light(palette)
# All other schemes use Material Design 3 generation
if mode == "dark":
return generate_material_dark(palette, scheme_type)
return generate_material_light(palette, scheme_type)
@@ -2,16 +2,19 @@
"""
Noctalia's Template processor - Wallpaper-based color extraction and theme generation.
A CLI tool that extracts dominant colors from wallpaper images and generates palettes with optional templating:
- Material Design 3 using HCT (Hue, Chroma, Tone) color space.
- Vibrant accent-based using HSL (Hue, Saturation, Lightness) color space.
A CLI tool that extracts dominant colors from wallpaper images and generates palettes with optional templating.
Supported scheme types:
- tonal-spot: Default Android 12-13 Material You scheme (recommended)
- fruit-salad: Bold/playful with -50° hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Preserves wallpaper colors directly
Usage:
python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
Options:
--material Generate Material Design 3 colors (default)
--vibrant Generate vibrant accent-based colors
--scheme-type Scheme type: tonal-spot (default), fruit-salad, rainbow, vibrant
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
@@ -24,7 +27,8 @@ Input:
Can be an image file (PNG/JPG) or a JSON color palette file.
Example:
python3 template-processor.py ~/wallpaper.png --material --both
python3 template-processor.py ~/wallpaper.png --scheme-type tonal-spot
python3 template-processor.py ~/wallpaper.png --scheme-type fruit-salad --dark
python3 template-processor.py ~/wallpaper.jpg --dark -o theme.json
python3 template-processor.py ~/wallpaper.png -r template.txt:output.txt
python3 template-processor.py ~/wallpaper.png -c config.toml --mode dark
@@ -66,17 +70,24 @@ Examples:
help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
)
# Theme style (mutually exclusive)
style_group = parser.add_mutually_exclusive_group()
style_group.add_argument(
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'fruit-salad', 'rainbow', 'vibrant'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
# Legacy flags for backward compatibility
parser.add_argument(
'--material',
action='store_true',
help='Generate Material Design 3 colors (default)'
help='(deprecated) Alias for --scheme-type tonal-spot'
)
style_group.add_argument(
parser.add_argument(
'--vibrant',
action='store_true',
help='Generate vibrant accent-based palette'
help='(deprecated) Alias for --scheme-type vibrant'
)
# Theme mode (mutually exclusive)
@@ -237,18 +248,27 @@ def main() -> int:
print(f"Unexpected error reading image: {e}", file=sys.stderr)
return 1
# Extract palette
# Determine scheme type (handle legacy flags)
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 mode uses chroma-based scoring (picks most colorful colors)
# M3 schemes use population-based scoring (picks most representative colors)
k = 5
palette = extract_palette(pixels, k=k)
scoring = "chroma" if scheme_type == "vibrant" else "population"
palette = extract_palette(pixels, k=k, scoring=scoring)
if not palette:
print("Error: Could not extract colors from image", file=sys.stderr)
return 1
# Generate theme for each mode
use_material = not args.vibrant
for mode in modes:
result[mode] = generate_theme(palette, mode, use_material)
result[mode] = generate_theme(palette, mode, scheme_type)
# Output JSON
json_output = json.dumps(result, indent=2)
+11 -5
View File
@@ -252,6 +252,13 @@ Singleton {
return false;
}
// Get scheme type, defaulting to tonal-spot if not a recognized value
function getSchemeType() {
const method = Settings.data.colorSchemes.generationMethod;
const validTypes = ["tonal-spot", "fruit-salad", "rainbow", "vibrant"];
return validTypes.includes(method) ? method : "tonal-spot";
}
function buildGenerationScript(content, wallpaper, mode) {
const delimiter = "THEME_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9);
const pathEsc = dynamicConfigPath.replace(/'/g, "'\\''");
@@ -262,9 +269,8 @@ Singleton {
script += `NOCTALIA_WP_PATH=$(cat << '${wpDelimiter}'\n${wallpaper}\n${wpDelimiter}\n)\n`;
// Use template-processor.py (Python implementation)
const styleFlag = (Settings.data.colorSchemes.generationMethod === "vibrant") ? "--vibrant" : "--material";
// We pass --type for compatibility but it is ignored by internal logic unless needed
script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" ${styleFlag} --config '${pathEsc}' --mode ${mode} `;
const schemeType = getSchemeType();
script += `python3 "${templateProcessorScript}" "$NOCTALIA_WP_PATH" --scheme-type ${schemeType} --config '${pathEsc}' --mode ${mode} `;
script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode);
@@ -363,8 +369,8 @@ Singleton {
// Otherwise, use single quotes for safety with file paths
const inputQuoted = input.startsWith("$") ? `"${input}"` : `'${input.replace(/'/g, "'\\''")}'`;
const styleFlag = (Settings.data.colorSchemes.generationMethod === "vibrant") ? "--vibrant" : "--material";
script += ` python3 "${templateProcessorScript}" ${inputQuoted} ${styleFlag} --config '${userConfigPath}' --mode ${mode}\n`;
const schemeType = getSchemeType();
script += ` python3 "${templateProcessorScript}" ${inputQuoted} --scheme-type ${schemeType} --config '${userConfigPath}' --mode ${mode}\n`;
script += "fi";
return script;