template-processor: added a new "muted" scheme, very desaturated and monotonal

This commit is contained in:
Lemmy
2026-01-22 13:04:19 -05:00
parent 2cb1daf9ac
commit 7bdcbe515a
21 changed files with 454 additions and 18 deletions
+1
View File
@@ -354,6 +354,7 @@
"clear": "Löschen",
"clipboard": "Zwischenablage",
"close": "Schließen",
"color-muted": "Stummgeschaltet",
"colors": "Farben",
"command": "Befehl",
"connect": "Verbinden",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Clear",
"clipboard": "Clipboard",
"close": "Close",
"color-muted": "Muted",
"colors": "Colors",
"command": "Command",
"connect": "Connect",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Borrar",
"clipboard": "Portapapeles",
"close": "Cerrar",
"color-muted": "Silenciado",
"colors": "Colores",
"command": "Comando",
"connect": "Conectar",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Effacer",
"clipboard": "Presse-papiers",
"close": "Fermer",
"color-muted": "Muet",
"colors": "Couleurs",
"command": "Commande",
"connect": "Connecter",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Törlés",
"clipboard": "Vágólap",
"close": "Bezárás",
"color-muted": "Némítva",
"colors": "Színek",
"command": "Parancs",
"connect": "Csatlakozás",
+1
View File
@@ -354,6 +354,7 @@
"clear": "クリア",
"clipboard": "クリップボード",
"close": "閉じる",
"color-muted": "ミュート",
"colors": "色",
"command": "コマンド",
"connect": "接続",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Paqqij bike",
"clipboard": "Klîpbir",
"close": "Bigire",
"color-muted": "Bêdengkirî",
"colors": "Rengan",
"command": "Ferman",
"connect": "Girêdan",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Wissen",
"clipboard": "Klembord",
"close": "Sluiten",
"color-muted": "Gedempt",
"colors": "Kleuren",
"command": "Commando",
"connect": "Verbinden",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Wyczyść",
"clipboard": "Schowek",
"close": "Zamknij",
"color-muted": "Wyciszony",
"colors": "Kolory",
"command": "Komenda",
"connect": "Połącz",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Limpar",
"clipboard": "Área de transferência",
"close": "Fechar",
"color-muted": "Silenciado",
"colors": "Cores",
"command": "Comando",
"connect": "Conectar",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Очистить",
"clipboard": "Буфер обмена",
"close": "Закрыть",
"color-muted": "Выключено",
"colors": "Цвета",
"command": "Команда",
"connect": "Соединить",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Temizle",
"clipboard": "Panoya",
"close": "Kapat",
"color-muted": "Sessiz",
"colors": "Renkler",
"command": "Komut",
"connect": "Bağlan",
+1
View File
@@ -354,6 +354,7 @@
"clear": "Очистити",
"clipboard": "Буфер обміну",
"close": "Закрити",
"color-muted": "Вимкнено звук",
"colors": "Кольори",
"command": "Команда",
"connect": "Підключити",
+1
View File
@@ -354,6 +354,7 @@
"clear": "清除",
"clipboard": "剪贴板",
"close": "关闭",
"color-muted": "已静音",
"colors": "颜色",
"command": "命令",
"connect": "连接",
+1
View File
@@ -350,6 +350,7 @@
"clear": "清空",
"clipboard": "剪貼簿",
"close": "關閉",
"color-muted": "已靜音",
"colors": "顏色",
"command": "指令",
"connect": "連接",
@@ -272,6 +272,10 @@ ColumnLayout {
"key": "faithful",
"name": I18n.tr("common.faithful")
},
{
"key": "muted",
"name": I18n.tr("common.color-muted")
},
]
currentKey: Settings.data.colorSchemes.generationMethod
onSelected: key => {
+24 -12
View File
@@ -12,6 +12,7 @@ Scheme types:
- M3 schemes (tonal-spot, fruit-salad, rainbow, content): Compared with matugen
- vibrant: Prioritizes the most saturated colors regardless of area
- faithful: Prioritizes dominant colors by area coverage
- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
"""
import argparse
@@ -113,20 +114,21 @@ def run_matugen(image_path: Path, scheme: str) -> dict | None:
return None
def analyze_vibrant_faithful(image_path: Path) -> None:
"""Analyze vibrant and faithful mode outputs."""
def analyze_vibrant_faithful_muted(image_path: Path) -> None:
"""Analyze vibrant, faithful, and muted mode outputs."""
print("\n" + "=" * 78)
print("VIBRANT vs FAITHFUL COMPARISON")
print("VIBRANT vs FAITHFUL vs MUTED COMPARISON")
print("=" * 78)
print()
print("Vibrant: Prioritizes the most saturated colors regardless of area")
print("Faithful: Prioritizes dominant colors by area coverage")
print("Muted: Preserves hue but caps saturation low (monochrome wallpapers)")
print()
print("-" * 78)
print(f"{'Mode':<12} {'Color':<12} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
print("-" * 78)
for scheme in ["vibrant", "faithful"]:
for scheme in ["vibrant", "faithful", "muted"]:
colors = run_our_processor(image_path, scheme)
if not colors:
print(f"{scheme}: Failed to get colors")
@@ -149,26 +151,36 @@ def analyze_vibrant_faithful(image_path: Path) -> None:
# Summary comparison
vibrant = run_our_processor(image_path, "vibrant")
faithful = run_our_processor(image_path, "faithful")
muted = run_our_processor(image_path, "muted")
if vibrant and faithful:
if vibrant and faithful and muted:
print()
print("Summary:")
v_hct = get_hct(vibrant.get("primary", "#000000"))
f_hct = get_hct(faithful.get("primary", "#000000"))
m_hct = get_hct(muted.get("primary", "#000000"))
v_name = hue_to_name(v_hct.hue)
f_name = hue_to_name(f_hct.hue)
m_name = hue_to_name(m_hct.hue)
diff = hue_diff(v_hct.hue, f_hct.hue)
vf_diff = hue_diff(v_hct.hue, f_hct.hue)
print(f" Vibrant primary: {vibrant.get('primary')} ({v_name}, hue {v_hct.hue:.0f}°, chroma {v_hct.chroma:.1f})")
print(f" Faithful primary: {faithful.get('primary')} ({f_name}, hue {f_hct.hue:.0f}°, chroma {f_hct.chroma:.1f})")
print(f" Hue difference: {diff:.1f}°")
print(f" Muted primary: {muted.get('primary')} ({m_name}, hue {m_hct.hue:.0f}°, chroma {m_hct.chroma:.1f})")
print(f" V-F hue diff: {vf_diff:.1f}°")
if diff > 60:
print(f"Modes picked DIFFERENT color families ({v_name} vs {f_name})")
if vf_diff > 60:
print(f"Vibrant/Faithful picked DIFFERENT color families ({v_name} vs {f_name})")
else:
print(f"Modes picked SIMILAR colors")
print(f"Vibrant/Faithful picked SIMILAR colors")
# Note the muted chroma reduction
if m_hct.chroma < 20:
print(f" → Muted successfully reduced chroma to {m_hct.chroma:.1f}")
else:
print(f" → Muted chroma still moderately high ({m_hct.chroma:.1f})")
def compare_m3_schemes(image_path: Path, has_matugen: bool) -> None:
@@ -293,8 +305,8 @@ def main() -> int:
except (subprocess.CalledProcessError, FileNotFoundError):
print("Note: matugen not found, skipping M3 comparison")
# Always show vibrant vs faithful first (most useful)
analyze_vibrant_faithful(args.wallpaper)
# Always show vibrant vs faithful vs muted first (most useful)
analyze_vibrant_faithful_muted(args.wallpaper)
# Then show M3 schemes
compare_m3_schemes(args.wallpaper, has_matugen)
+36
View File
@@ -268,6 +268,31 @@ def _score_colors_count(
return result_colors
def _score_colors_muted(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors for muted mode - pure pixel count without chroma filtering.
Unlike count scoring which filters to chroma >= 10, this accepts all colors
including grayscale. Designed for monochrome/monotonal wallpapers where
the dominant color may have very low or zero saturation.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by count descending
"""
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
result.sort(key=lambda x: -x[1])
return result
def _score_colors_population(
colors_with_counts: list[tuple[RGB, int]],
total_pixels: int
@@ -415,6 +440,7 @@ def extract_palette(
- "population": matugen-like, representative colors (M3 schemes)
- "chroma": vibrant, chroma-prioritized with centroid averaging
- "count": area-dominant, picks by pixel count (faithful mode)
- "muted": like count but without chroma filtering (monochrome wallpapers)
Returns:
List of Color objects, sorted by score
@@ -436,6 +462,11 @@ def extract_palette(
# Scoring will filter to colorful colors and pick by count
cluster_count = 48
filtered = sampled
elif scoring == "muted":
# Muted mode: similar to count but accepts low-chroma colors
# For monochrome/monotonal wallpapers
cluster_count = 24
filtered = sampled
else:
# Vibrant mode: more clusters to capture high-chroma colors that might
# otherwise get averaged away, with colorfulness pre-filter
@@ -459,6 +490,7 @@ def extract_palette(
# Score colors based on method
# - chroma: centroid colors (averaged, smoother - vibrant mode)
# - count: representative pixels by area dominance (faithful mode)
# - muted: like count but accepts low/zero chroma (monochrome wallpapers)
# - population: representative colors with Material scoring (M3 schemes)
if scoring == "chroma":
# Use centroid colors for vibrant mode (smoother, blended)
@@ -468,6 +500,10 @@ def extract_palette(
# Use representative colors with count scoring (faithful mode)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_count(colors_for_scoring)
elif scoring == "muted":
# Use representative colors with muted scoring (no chroma filter)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_muted(colors_for_scoring)
else:
# Use representative colors for M3 schemes
colors_for_scoring = [(c[1], c[2]) for c in clusters]
+366 -3
View File
@@ -11,6 +11,7 @@ Supported scheme types:
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Prioritizes the most saturated colors regardless of area
- faithful: Prioritizes dominant colors by area coverage
- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
"""
from typing import Literal
@@ -22,7 +23,7 @@ from .palette import find_error_color
# Type aliases
ThemeMode = Literal["dark", "light"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "vibrant", "faithful"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "vibrant", "faithful", "muted"]
# Map scheme type strings to classes
SCHEME_CLASSES = {
@@ -30,7 +31,7 @@ SCHEME_CLASSES = {
"fruit-salad": SchemeFruitSalad,
"rainbow": SchemeRainbow,
"content": SchemeContent,
# "vibrant" and "faithful" uses generate_normal_* functions, not a scheme class
# "vibrant", "faithful", and "muted" use generate_*_* functions, not a scheme class
}
@@ -484,6 +485,362 @@ def generate_normal_light(palette: list[Color]) -> dict[str, str]:
}
def generate_muted_dark(palette: list[Color]) -> dict[str, str]:
"""
Generate muted dark theme from palette.
Designed for monochrome/monotonal wallpapers - preserves the dominant hue
but caps saturation to very low values for a subtle, understated look.
Outputs same keys as Material for compatibility.
"""
# Use primary color's hue but with very low saturation
primary = palette[0] if palette else Color(128, 128, 128)
primary_h, primary_s, primary_l = primary.to_hsl()
# Derive secondary and tertiary with subtle hue shifts (monochromatic feel)
# Much smaller shifts than normal mode since we want cohesion
secondary = shift_hue(primary, 15)
tertiary = shift_hue(primary, 30)
quaternary = shift_hue(primary, 180)
error = find_error_color(palette)
# Cap saturation low - this is the key difference from normal mode
MUTED_SAT_PRIMARY = 0.15
MUTED_SAT_SECONDARY = 0.12
MUTED_SAT_TERTIARY = 0.10
MUTED_SAT_SURFACE = 0.08
h, s, l = primary.to_hsl()
primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), max(l, 0.65))
h, s, l = secondary.to_hsl()
secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), max(l, 0.60))
h, s, l = tertiary.to_hsl()
tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), max(l, 0.60))
# Container colors - darker, slightly saturated versions
def make_container_dark(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, min(s + 0.05, MUTED_SAT_PRIMARY), max(l - 0.35, 0.15))
primary_container = make_container_dark(primary_adjusted)
secondary_container = make_container_dark(secondary_adjusted)
tertiary_container = make_container_dark(tertiary_adjusted)
error_container = make_container_dark(error)
# Surface: very low saturation, preserving hue for subtle tint
surface_hue = primary_h
base_surface = Color.from_hsl(surface_hue, MUTED_SAT_SURFACE, 0.5)
surface = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.12)
surface_variant = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.16)
# Surface containers - progressive lightness with minimal saturation
surface_container_lowest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.06)
surface_container_low = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.10)
surface_container = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.20)
surface_container_high = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.18)
surface_container_highest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.22)
# Text colors - near-neutral with slight hue tint
base_on_surface = Color.from_hsl(primary_h, 0.03, 0.95)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.80)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.30), surface, 3.0)
outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.40), surface, 3.0)
# Contrasting foregrounds
dark_fg = Color.from_hsl(primary_h, 0.10, 0.12)
on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0)
on_secondary = ensure_contrast(dark_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(dark_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(dark_fg, error, 7.0)
# "On" colors for containers
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_container, 4.5, prefer_light=True)
sec_h, _, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_container, 4.5, prefer_light=True)
ter_h, _, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_container, 4.5, prefer_light=True)
err_h, _, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.90), error_container, 4.5, prefer_light=True)
# Shadow and scrim
shadow = surface
scrim = Color(0, 0, 0)
# Inverse colors
inverse_surface = Color.from_hsl(primary_h, 0.05, 0.90)
inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.15)
inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.40)
# Background aliases
background = surface
on_background = on_surface
# Fixed colors - still muted
def make_fixed_dark(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.85)
fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.75)
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_dark(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_dark(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_dark(tertiary_adjusted)
# "On" colors for fixed
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.20), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.20), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.20), tertiary_fixed_dim, 4.5)
# Surface dim and bright
surface_dim = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.08)
surface_bright = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.24)
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def generate_muted_light(palette: list[Color]) -> dict[str, str]:
"""
Generate muted light theme from palette.
Designed for monochrome/monotonal wallpapers - preserves the dominant hue
but caps saturation to very low values for a subtle, understated look.
Outputs same keys as Material for compatibility.
"""
primary = palette[0] if palette else Color(128, 128, 128)
primary_h, primary_s, _ = primary.to_hsl()
# Derive secondary and tertiary with subtle hue shifts
secondary = shift_hue(primary, 15)
tertiary = shift_hue(primary, 30)
quaternary = shift_hue(primary, 180)
error = find_error_color(palette)
# Cap saturation low
MUTED_SAT_PRIMARY = 0.15
MUTED_SAT_SECONDARY = 0.12
MUTED_SAT_TERTIARY = 0.10
MUTED_SAT_SURFACE = 0.08
h, s, l = primary.to_hsl()
primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), min(l, 0.45))
h, s, l = secondary.to_hsl()
secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), min(l, 0.40))
h, s, l = tertiary.to_hsl()
tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), min(l, 0.35))
# Container colors - lighter, less saturated
def make_container_light(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, max(s - 0.05, 0.05), min(l + 0.35, 0.85))
primary_container = make_container_light(primary_adjusted)
secondary_container = make_container_light(secondary_adjusted)
tertiary_container = make_container_light(tertiary_adjusted)
error_container = make_container_light(error)
# Surface: very low saturation, preserving hue for subtle tint
surface = adjust_surface(primary, MUTED_SAT_SURFACE, 0.90)
surface_variant = adjust_surface(primary, MUTED_SAT_SURFACE, 0.78)
# Surface containers - progressive darkening with minimal saturation
surface_container_lowest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.96)
surface_container_low = adjust_surface(primary, MUTED_SAT_SURFACE, 0.92)
surface_container = adjust_surface(primary, MUTED_SAT_SURFACE, 0.86)
surface_container_high = adjust_surface(primary, MUTED_SAT_SURFACE, 0.84)
surface_container_highest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.80)
# Text colors - near-neutral with slight hue tint
base_on_surface = Color.from_hsl(primary_h, 0.03, 0.10)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.90)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
# Contrasting foregrounds
light_fg = Color.from_hsl(primary_h, 0.05, 0.98)
on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0)
on_secondary = ensure_contrast(light_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(light_fg, error, 7.0)
# "On" colors for containers
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_container, 4.5, prefer_light=False)
sec_h, _, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_container, 4.5, prefer_light=False)
ter_h, _, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_container, 4.5, prefer_light=False)
err_h, _, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.15), error_container, 4.5, prefer_light=False)
# Fixed colors - still muted
def make_fixed_light(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.40)
fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.30)
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_light(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_light(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_light(tertiary_adjusted)
# "On" colors for fixed
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.85), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.85), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.85), tertiary_fixed_dim, 4.5)
# Surface dim and bright
surface_dim = adjust_surface(primary, MUTED_SAT_SURFACE, 0.82)
surface_bright = adjust_surface(primary, MUTED_SAT_SURFACE, 0.95)
# Outline
outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.65), surface, 3.0)
outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.75), surface, 3.0)
shadow = Color.from_hsl(primary_h, 0.05, 0.80)
scrim = Color(0, 0, 0)
# Inverse colors
inverse_surface = Color.from_hsl(primary_h, 0.05, 0.15)
inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.90)
inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.70)
# Background aliases
background = surface
on_background = on_surface
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def generate_theme(
palette: list[Color],
mode: ThemeMode,
@@ -495,7 +852,7 @@ def generate_theme(
Args:
palette: List of extracted colors
mode: "dark" or "light"
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful"
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful", "muted"
Returns:
Dictionary of color token names to hex values
@@ -507,6 +864,12 @@ def generate_theme(
return generate_normal_dark(palette)
return generate_normal_light(palette)
# Handle muted mode (low saturation, monochrome wallpapers)
if scheme_type == "muted":
if mode == "dark":
return generate_muted_dark(palette)
return generate_muted_light(palette)
# All other schemes use Material Design 3 generation
if mode == "dark":
return generate_material_dark(palette, scheme_type)
@@ -11,12 +11,13 @@ Supported scheme types:
- rainbow: Chromatic accents with grayscale neutrals
- vibrant: Prioritizes the most saturated colors regardless of area coverage
- faithful: Prioritizes dominant colors by area, what you see is what you get
- muted: Preserves hue but caps saturation low (for monochrome/monotonal wallpapers)
Usage:
python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
Options:
--scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, vibrant, faithful
--scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, vibrant, faithful, muted
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
@@ -81,7 +82,7 @@ Examples:
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'vibrant', 'faithful'],
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'vibrant', 'faithful', 'muted'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
@@ -265,6 +266,7 @@ def main() -> int:
# This matches matugen's color extraction exactly
# - vibrant: Use k-means clustering for colorful/blended colors
# - faithful: Use Wu quantizer for primary (dominant by area), k-means for accents
# - muted: Like count but without chroma filtering (for monochrome wallpapers)
if scheme_type == "vibrant":
# K-means with chroma scoring for vibrant, blended colors
palette = extract_palette(pixels, k=5, scoring="chroma")
@@ -272,6 +274,10 @@ def main() -> int:
# K-means with count scoring - picks dominant color by area coverage
# This ensures primary reflects what you actually see in the image
palette = extract_palette(pixels, k=5, scoring="count")
elif scheme_type == "muted":
# K-means with muted scoring - accepts low/zero chroma colors
# For monochrome/monotonal wallpapers where dominant color has low saturation
palette = extract_palette(pixels, k=5, scoring="muted")
else:
# Wu quantizer + Score algorithm (matches matugen)
source_argb = extract_source_color(pixels)
+1 -1
View File
@@ -279,7 +279,7 @@ Singleton {
// Get scheme type, defaulting to tonal-spot if not a recognized value
function getSchemeType() {
const method = Settings.data.colorSchemes.generationMethod;
const validTypes = ["tonal-spot", "content", "fruit-salad", "rainbow", "vibrant", "faithful"];
const validTypes = ["tonal-spot", "content", "fruit-salad", "rainbow", "vibrant", "faithful", "muted"];
return validTypes.includes(method) ? method : "tonal-spot";
}