template-processor: dysfunctional scheme

This commit is contained in:
Lemmy
2026-02-02 21:18:22 -05:00
parent feb65ad8b2
commit cdf0a5dd44
21 changed files with 170 additions and 34 deletions
-1
View File
@@ -398,7 +398,6 @@
"events": "Ereignisse",
"execute": "Ausführen",
"faithful": "Originalgetreu",
"faithful-alt": "Treue (Alternativ)",
"focus": "Fokus",
"frequency": "Frequenz",
"gateway": "Gateway",
+2 -1
View File
@@ -393,12 +393,12 @@
"disconnecting": "Disconnecting...",
"download": "Download",
"duration": "Duration",
"dysfunctional": "Dysfunctional",
"edit": "Edit",
"enabled": "Enabled",
"events": "Events",
"execute": "Execute",
"faithful": "Faithful",
"faithful-alt": "Faithful (Alternate)",
"focus": "Focus",
"frequency": "Frequency",
"gateway": "Gateway",
@@ -814,6 +814,7 @@
"download-title": "Download Color Schemes",
"method-description": {
"content": "Material Design scheme with high-fidelity color extraction that closely matches the source content's actual colors.",
"dysfunctional": "Like Faithful but picks the second most dominant color family as primary.",
"faithful": "Attempts to stay close to the source color while still generating a harmonious palette.",
"fruit-salad": "Material Design scheme that produces a playful, vibrant palette with diverse and varied hues.",
"monochrome": "Material Design scheme using a single-hue grayscale with minimal chromatic content.",
-1
View File
@@ -398,7 +398,6 @@
"events": "Eventos",
"execute": "Ejecutar",
"faithful": "Fiel",
"faithful-alt": "Fiel (Alternativo)",
"focus": "Enfoque",
"frequency": "Frecuencia",
"gateway": "Puerta de enlace",
-1
View File
@@ -398,7 +398,6 @@
"events": "Événements",
"execute": "Exécuter",
"faithful": "Fidèle",
"faithful-alt": "Fidèle (Alternatif)",
"focus": "Concentration",
"frequency": "Fréquence",
"gateway": "Passerelle",
-1
View File
@@ -398,7 +398,6 @@
"events": "Események",
"execute": "Végrehajt",
"faithful": "Hű",
"faithful-alt": "Hűséges (Alternatív)",
"focus": "Fókusz",
"frequency": "Frekvencia",
"gateway": "Átjáró",
-1
View File
@@ -398,7 +398,6 @@
"events": "イベント",
"execute": "実行",
"faithful": "忠実",
"faithful-alt": "忠実 (代替)",
"focus": "集中",
"frequency": "頻度",
"gateway": "ゲートウェイ",
-1
View File
@@ -398,7 +398,6 @@
"events": "일정",
"execute": "실행",
"faithful": "충실하게",
"faithful-alt": "충실한 (대체)",
"focus": "포커스",
"frequency": "빈도",
"gateway": "게이트웨이",
-1
View File
@@ -398,7 +398,6 @@
"events": "Evenementen",
"execute": "Uitvoeren",
"faithful": "Getrouw",
"faithful-alt": "Trouw (Alternatief)",
"focus": "Focus",
"frequency": "Frequentie",
"gateway": "Poort",
-1
View File
@@ -398,7 +398,6 @@
"events": "Wydarzenia",
"execute": "Wykonaj",
"faithful": "Wierny",
"faithful-alt": "Wierny (Alternatywny)",
"focus": "Skupienie",
"frequency": "Częstotliwość",
"gateway": "Brama",
-1
View File
@@ -398,7 +398,6 @@
"events": "Eventos",
"execute": "Executar",
"faithful": "Fiel",
"faithful-alt": "Fiel (Alternativo)",
"focus": "Foco",
"frequency": "Frequência",
"gateway": "Porta de entrada",
-1
View File
@@ -398,7 +398,6 @@
"events": "События",
"execute": "Выполнить",
"faithful": "Верный",
"faithful-alt": "Верный (Альтернативный)",
"focus": "Фокус",
"frequency": "Частота",
"gateway": "Шлюз",
-1
View File
@@ -398,7 +398,6 @@
"events": "Händelser",
"execute": "Exekvera",
"faithful": "Trogen",
"faithful-alt": "Trofast (Alternativ)",
"focus": "Fokus",
"frequency": "Frekvens",
"gateway": "Gateway",
-1
View File
@@ -398,7 +398,6 @@
"events": "Etkinlikler",
"execute": "Yürüt",
"faithful": "Sadık",
"faithful-alt": "Sadık (Alternatif)",
"focus": "Odaklanma",
"frequency": "Sıklık",
"gateway": "Geçit",
-1
View File
@@ -398,7 +398,6 @@
"events": "Події",
"execute": "Виконати",
"faithful": "Вірний",
"faithful-alt": "Вірний (Альтернативний)",
"focus": "Зосередженість",
"frequency": "Частота",
"gateway": "Шлюз",
-1
View File
@@ -398,7 +398,6 @@
"events": "事件",
"execute": "执行",
"faithful": "忠实",
"faithful-alt": "忠实 (备用)",
"focus": "专注",
"frequency": "频率",
"gateway": "网关",
-1
View File
@@ -398,7 +398,6 @@
"events": "事件",
"execute": "執行",
"faithful": "忠實",
"faithful-alt": "忠實 (備用)",
"focus": "關注",
"frequency": "頻率",
"gateway": "網路閘道",
+27 -14
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
- dysfunctional: Like faithful but picks the 2nd most dominant color family
- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
"""
@@ -115,20 +116,21 @@ def run_matugen(image_path: Path, scheme: str) -> dict | None:
def analyze_vibrant_faithful_muted(image_path: Path) -> None:
"""Analyze vibrant, faithful, and muted mode outputs."""
"""Analyze vibrant, faithful, dysfunctional, and muted mode outputs."""
print("\n" + "=" * 78)
print("VIBRANT vs FAITHFUL vs MUTED COMPARISON")
print("VIBRANT vs FAITHFUL vs DYSFUNCTIONAL 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("Vibrant: Prioritizes the most saturated colors regardless of area")
print("Faithful: Prioritizes dominant colors by area coverage")
print("Dysfunctional: Like faithful but picks 2nd most dominant color family")
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(f"{'Mode':<14} {'Color':<12} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
print("-" * 78)
for scheme in ["vibrant", "faithful", "muted"]:
for scheme in ["vibrant", "faithful", "dysfunctional", "muted"]:
colors = run_our_processor(image_path, scheme)
if not colors:
print(f"{scheme}: Failed to get colors")
@@ -142,40 +144,51 @@ def analyze_vibrant_faithful_muted(image_path: Path) -> None:
try:
hct = get_hct(hex_color)
name = hue_to_name(hct.hue)
print(f"{scheme:<12} {key:<12} {hex_color:<10} {hct.hue:>7.1f}° {hct.chroma:>7.1f} {name:<10}")
print(f"{scheme:<14} {key:<12} {hex_color:<10} {hct.hue:>7.1f}° {hct.chroma:>7.1f} {name:<10}")
except Exception as e:
print(f"{scheme:<12} {key:<12} Error: {e}")
print(f"{scheme:<14} {key:<12} Error: {e}")
print("-" * 78)
# Summary comparison
vibrant = run_our_processor(image_path, "vibrant")
faithful = run_our_processor(image_path, "faithful")
dysfunctional = run_our_processor(image_path, "dysfunctional")
muted = run_our_processor(image_path, "muted")
if vibrant and faithful and muted:
if vibrant and faithful and dysfunctional and muted:
print()
print("Summary:")
v_hct = get_hct(vibrant.get("primary", "#000000"))
f_hct = get_hct(faithful.get("primary", "#000000"))
d_hct = get_hct(dysfunctional.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)
d_name = hue_to_name(d_hct.hue)
m_name = hue_to_name(m_hct.hue)
vf_diff = hue_diff(v_hct.hue, f_hct.hue)
fd_diff = hue_diff(f_hct.hue, d_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" 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}°")
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" Dysfunctional primary:{dysfunctional.get('primary')} ({d_name}, hue {d_hct.hue:.0f}°, chroma {d_hct.chroma:.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}°")
print(f" F-D hue diff: {fd_diff:.1f}°")
if vf_diff > 60:
print(f" → Vibrant/Faithful picked DIFFERENT color families ({v_name} vs {f_name})")
else:
print(f" → Vibrant/Faithful picked SIMILAR colors")
if fd_diff > 30:
print(f" → Faithful/Dysfunctional picked DIFFERENT color families ({f_name} vs {d_name})")
else:
print(f" → Faithful/Dysfunctional picked SIMILAR colors (may only have 1 dominant family)")
# Note the muted chroma reduction
if m_hct.chroma < 20:
print(f" → Muted successfully reduced chroma to {m_hct.chroma:.1f}")
+3 -2
View File
@@ -248,10 +248,11 @@ def _read_image_imagemagick(path: Path) -> list[RGB]:
# -resize: downsample for performance (we don't need full resolution for color extraction)
# ppm: output as PPM format (easy to parse)
# Resize to 112x112 to match matugen's color extraction
# Resize to fit within 112x112 while maintaining aspect ratio
# This matches matugen's approach and preserves color proportions
# Use -filter Box for consistent results across ImageMagick versions
# Use -depth 8 -colorspace sRGB -strip to reduce variance between HDRI/non-HDRI builds
resize_spec = "112x112!"
resize_spec = "112x112"
try:
# Try 'magick' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6)
+126
View File
@@ -262,6 +262,123 @@ def _score_colors_count(
return result_colors
def _family_center_hue(family: int) -> float:
"""Get the center hue for a family index."""
# Family centers based on _hue_to_family ranges:
# 0: RED (330-30°, wraps) -> center 0°
# 1: ORANGE (30-60°) -> center 45°
# 2: YELLOW (60-105°) -> center 82.5°
# 3: GREEN (105-190°) -> center 147.5°
# 4: BLUE (190-270°) -> center 230°
# 5: PURPLE (270-330°) -> center 300°
centers = [0.0, 45.0, 82.5, 147.5, 230.0, 300.0]
return centers[family]
def _circular_hue_diff(h1: float, h2: float) -> float:
"""Calculate circular hue difference (0-180)."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
def _score_colors_dysfunctional(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing the 2nd most dominant hue family.
Like count scoring but skips the dominant family (and any families
too close to it) to pick a visually distinct secondary color.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by family dominance then count
"""
MIN_CHROMA = 10.0 # Filter out near-gray colors
MIN_HUE_DISTANCE = 45.0 # Minimum hue distance from dominant family
MIN_COUNT_RATIO = 0.02 # Distant family must have at least 2% of total colorful pixels
# First pass: collect colorful colors and group by hue family
hue_families: dict[int, list[tuple[Color, float, float, int]]] = {} # family -> [(color, hue, chroma, count), ...]
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
if hct.chroma >= MIN_CHROMA:
family = _hue_to_family(hct.hue)
if family not in hue_families:
hue_families[family] = []
hue_families[family].append((color, hct.hue, hct.chroma, count))
except (ValueError, ZeroDivisionError):
pass
# If no colorful colors found, fall back to all colors
if not hue_families:
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
# Calculate total count per hue family
family_totals: list[tuple[int, int]] = []
for family, colors in hue_families.items():
total = sum(c[3] for c in colors)
family_totals.append((family, total))
# Sort families by total count (dominant family first)
family_totals.sort(key=lambda x: -x[1])
# Find the dominant family and its center hue
dominant_family, dominant_count = family_totals[0]
dominant_center = _family_center_hue(dominant_family)
total_colorful_pixels = sum(count for _, count in family_totals)
min_count = total_colorful_pixels * MIN_COUNT_RATIO
# Find families that are far enough from the dominant one AND have enough pixels
distant_families = []
close_families = [dominant_family]
for family, count in family_totals[1:]:
family_center = _family_center_hue(family)
hue_diff = _circular_hue_diff(dominant_center, family_center)
if hue_diff >= MIN_HUE_DISTANCE and count >= min_count:
distant_families.append((family, count, hue_diff))
else:
close_families.append(family)
# Build result: colors from distant families first
result_colors = []
# Sort distant families by hue distance (most different first), then by count
distant_families.sort(key=lambda x: (-x[2], -x[1]))
for family, _, _ in distant_families:
family_colors = hue_families[family]
# Sort by count descending, chroma as tiebreaker
family_colors.sort(key=lambda x: (-x[3], -x[2]))
for color, hue, chroma, count in family_colors:
# Score encodes family rank + count for proper ordering
family_rank = next(i for i, (f, _, _) in enumerate(distant_families) if f == family)
score = (len(distant_families) - family_rank) * 1000000 + count * 1000 + chroma
result_colors.append((color, score))
# Add colors from close families (including dominant) at lower priority
for family in close_families:
family_colors = hue_families[family]
family_colors.sort(key=lambda x: (-x[3], -x[2]))
for color, hue, chroma, count in family_colors:
# Lower score than all distant-family colors
score = count * 1000 + chroma
result_colors.append((color, score))
result_colors.sort(key=lambda x: -x[1])
return result_colors
def _score_colors_muted(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
@@ -434,6 +551,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)
- "dysfunctional": picks 2nd most dominant color family
- "muted": like count but without chroma filtering (monochrome wallpapers)
Returns:
@@ -456,6 +574,10 @@ def extract_palette(
# Scoring will filter to colorful colors and pick by count
cluster_count = 48
filtered = sampled
elif scoring == "dysfunctional":
# Dysfunctional mode: same as count but picks 2nd dominant family
cluster_count = 48
filtered = sampled
elif scoring == "muted":
# Muted mode: similar to count but accepts low-chroma colors
# For monochrome/monotonal wallpapers
@@ -494,6 +616,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 == "dysfunctional":
# Use representative colors with dysfunctional scoring (2nd dominant family)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_dysfunctional(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]
@@ -12,13 +12,14 @@ Supported scheme types:
- monochrome: Pure grayscale M3 scheme (chroma = 0, only error has color)
- vibrant: Prioritizes the most saturated colors regardless of area coverage
- faithful: Prioritizes dominant colors by area, what you see is what you get
- dysfunctional: Like faithful but picks the 2nd most dominant color family
- 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, monochrome, vibrant, faithful, muted
--scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, monochrome, vibrant, faithful, dysfunctional, muted
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
@@ -83,7 +84,7 @@ Examples:
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'monochrome', 'vibrant', 'faithful', 'muted'],
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'monochrome', 'vibrant', 'faithful', 'dysfunctional', 'muted'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
@@ -267,6 +268,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
# - dysfunctional: Like faithful but picks 2nd most dominant color family
# - muted: Like count but without chroma filtering (for monochrome wallpapers)
if scheme_type == "vibrant":
# K-means with chroma scoring for vibrant, blended colors
@@ -275,6 +277,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 == "dysfunctional":
# K-means with dysfunctional scoring - picks 2nd most dominant color family
# For when the dominant color is not what you want as primary
palette = extract_palette(pixels, k=5, scoring="dysfunctional")
elif scheme_type == "muted":
# K-means with muted scoring - accepts low/zero chroma colors
# For monochrome/monotonal wallpapers where dominant color has low saturation
+4
View File
@@ -51,6 +51,10 @@ Singleton {
"key": "faithful",
"name": I18n.tr("common.faithful")
},
{
"key": "dysfunctional",
"name": I18n.tr("common.dysfunctional")
},
{
"key": "muted",
"name": I18n.tr("common.color-muted")