mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
template-processor: implemented tonal-sport, fruit-salad, rainbow
This commit is contained in:
@@ -390,7 +390,7 @@
|
||||
"schedulingMode": "off",
|
||||
"manualSunrise": "06:30",
|
||||
"manualSunset": "18:30",
|
||||
"generationMethod": "material"
|
||||
"generationMethod": "tonal-spot"
|
||||
},
|
||||
"templates": {
|
||||
"activeTemplates": [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+205
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user