add noctalia and fuzzel
This commit is contained in:
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
import gi
|
||||
gi.require_version('EDataServer', '1.2')
|
||||
gi.require_version('ECal', '2.0')
|
||||
gi.require_version('ICalGLib', "3.0")
|
||||
|
||||
import json, sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from gi.repository import ECal, EDataServer, ICalGLib
|
||||
|
||||
start_time = int(sys.argv[1])
|
||||
end_time = int(sys.argv[2])
|
||||
|
||||
print(f"Starting with time range: {start_time} to {end_time}", file=sys.stderr)
|
||||
|
||||
all_events = []
|
||||
|
||||
def safe_get_time(ical_time):
|
||||
if not ical_time:
|
||||
return None, False
|
||||
try:
|
||||
year, month, day = ical_time.get_year(), ical_time.get_month(), ical_time.get_day()
|
||||
is_all_day = hasattr(ical_time, "is_date") and ical_time.is_date()
|
||||
if is_all_day:
|
||||
# All-day events (birthdays, holidays) should not need
|
||||
# to be timezone converted
|
||||
return int(datetime(year, month, day).timestamp()), True
|
||||
|
||||
hour, minute, second = ical_time.get_hour(), ical_time.get_minute(), ical_time.get_second()
|
||||
|
||||
# Determine timezone for proper conversion
|
||||
tz_obj = ical_time.get_timezone() if hasattr(ical_time, 'get_timezone') else None
|
||||
tzid = tz_obj.get_tzid() if tz_obj else None
|
||||
tz = None
|
||||
if ical_time.is_utc() if hasattr(ical_time, 'is_utc') else False:
|
||||
tz = timezone.utc # Explicit UTC time
|
||||
elif tzid:
|
||||
# Evolution uses non-standard format: /freeassociation.sourceforge.net/America/Los_Angeles
|
||||
# Strip prefix to get IANA name: America/Los_Angeles
|
||||
iana = tzid.replace('/freeassociation.sourceforge.net/', '') if tzid.startswith('/') else tzid
|
||||
try: tz = ZoneInfo(iana)
|
||||
except: pass
|
||||
|
||||
# Create timezone-aware datetime
|
||||
dt = datetime(year, month, day, hour, minute, second, tzinfo=tz)
|
||||
return int(dt.timestamp()), False
|
||||
except:
|
||||
return None, False
|
||||
|
||||
def add_event(summary, calendar_name, start_ts, end_ts, location="", description="", all_day=False, calendar_uid="", uid=""):
|
||||
all_events.append({
|
||||
'calendar': calendar_name,
|
||||
'summary': summary,
|
||||
'start': start_ts,
|
||||
'end': end_ts,
|
||||
'location': location,
|
||||
'description': description,
|
||||
'calendar_uid': calendar_uid,
|
||||
'uid': uid
|
||||
})
|
||||
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
|
||||
|
||||
for source in sources:
|
||||
if not source.get_enabled():
|
||||
continue
|
||||
|
||||
calendar_name = source.get_display_name()
|
||||
print(f"\nProcessing calendar: {calendar_name}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
client = ECal.Client.connect_sync(source, ECal.ClientSourceType.EVENTS, 5, None)
|
||||
|
||||
start_dt = datetime.fromtimestamp(start_time)
|
||||
end_dt = datetime.fromtimestamp(end_time)
|
||||
start_str = start_dt.strftime("%Y%m%dT%H%M%S")
|
||||
end_str = end_dt.strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
query = f'(occur-in-time-range? (make-time "{start_str}") (make-time "{end_str}"))'
|
||||
success, raw_events = client.get_object_list_sync(query, None)
|
||||
|
||||
if not success or not raw_events:
|
||||
continue
|
||||
|
||||
for raw_obj in raw_events:
|
||||
obj = raw_obj[1] if isinstance(raw_obj, tuple) else raw_obj
|
||||
comp = None
|
||||
|
||||
if isinstance(obj, ICalGLib.Component):
|
||||
comp = obj
|
||||
elif isinstance(obj, ECal.Component):
|
||||
try:
|
||||
ical_str = obj.to_string()
|
||||
temp_comp = ICalGLib.Component.new_from_string(ical_str)
|
||||
if temp_comp.getName() == "VEVENT":
|
||||
comp = temp_comp
|
||||
except Exception:
|
||||
comp = None
|
||||
|
||||
if not comp:
|
||||
summary = getattr(obj, "get_summary", lambda: "(No title)")()
|
||||
dtstart = getattr(obj, "get_dtstart", lambda: None)()
|
||||
dtend = getattr(obj, "get_dtend", lambda: None)()
|
||||
location = getattr(obj, "get_location", lambda: "")() or ""
|
||||
description = getattr(obj, "get_description", lambda: "")() or ""
|
||||
start_ts, all_day = safe_get_time(dtstart)
|
||||
end_ts, _ = safe_get_time(dtend)
|
||||
if start_ts:
|
||||
if end_ts is None:
|
||||
end_ts = start_ts + 3600
|
||||
event_uid = getattr(obj, "get_uid", lambda: "")() or ""
|
||||
add_event(summary, calendar_name, start_ts, end_ts, location, description,
|
||||
calendar_uid=source.get_uid(), uid=event_uid)
|
||||
continue
|
||||
|
||||
summary = getattr(comp, "get_summary", lambda: "(No title)")()
|
||||
location = getattr(comp, "get_location", lambda: "")() or ""
|
||||
description = getattr(comp, "get_description", lambda: "")() or ""
|
||||
dtstart = getattr(comp, "get_dtstart", lambda: None)()
|
||||
dtend = getattr(comp, "get_dtend", lambda: None)()
|
||||
start_ts, all_day = safe_get_time(dtstart)
|
||||
end_ts, _ = safe_get_time(dtend)
|
||||
if end_ts is None and start_ts is not None:
|
||||
end_ts = start_ts + 3600
|
||||
|
||||
rrule_getter = getattr(comp, "get_first_property", None)
|
||||
if rrule_getter:
|
||||
rrule_prop = comp.get_first_property(73) # ICAL_RRULE_PROPERTY
|
||||
if rrule_prop:
|
||||
rrule_value = rrule_prop.get_value() # ICalGLib.Value
|
||||
|
||||
try:
|
||||
recurrence = rrule_value.get_recur() # -> ICalGLib.Recurrence
|
||||
|
||||
except AttributeError:
|
||||
rrule_str = str(rrule_value)
|
||||
recurrence = ICalGLib.Recurrence.new_from_string(rrule_str)
|
||||
|
||||
if recurrence:
|
||||
freq = recurrence.get_freq()
|
||||
|
||||
rdates = getattr(comp, "get_rdate_list", lambda: [])()
|
||||
exdates = getattr(comp, "get_exdate_list", lambda: [])()
|
||||
|
||||
# --- normal event ---
|
||||
if not rrule_prop and not rdates:
|
||||
add_event(summary, calendar_name, start_ts, end_ts, location, description,
|
||||
calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
|
||||
continue
|
||||
|
||||
# --- recurrent events ---
|
||||
if freq:
|
||||
summary = comp.get_summary() or "(No title)"
|
||||
dtstart = comp.get_dtstart()
|
||||
dtend = comp.get_dtend()
|
||||
start_ts, all_day = safe_get_time(dtstart)
|
||||
end_ts, _ = safe_get_time(dtend)
|
||||
if end_ts is None and start_ts is not None:
|
||||
end_ts = start_ts + 3600 # 1h default
|
||||
|
||||
interval = recurrence.get_interval() or 1
|
||||
count = recurrence.get_count()
|
||||
until_dt = recurrence.get_until()
|
||||
until_ts, _ = safe_get_time(until_dt) if until_dt else (None, False)
|
||||
if until_ts is None:
|
||||
until_ts = end_time
|
||||
|
||||
occurrences = []
|
||||
current_ts = start_ts
|
||||
added = 0
|
||||
|
||||
match freq:
|
||||
case 0: #SECONDLY
|
||||
delta = timedelta(seconds=interval)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
current_ts += int(delta.total_seconds())
|
||||
added += 1
|
||||
|
||||
case 1: #MINUTELY
|
||||
delta = timedelta(minutes=interval)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
current_ts += int(delta.total_seconds())
|
||||
added += 1
|
||||
|
||||
case 2: #HOURLY
|
||||
delta = timedelta(hours=interval)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
current_ts += int(delta.total_seconds())
|
||||
added += 1
|
||||
|
||||
case 3: # DAILY
|
||||
delta = timedelta(days=interval)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
current_ts += int(delta.total_seconds())
|
||||
added += 1
|
||||
|
||||
case 4: # WEEKLY
|
||||
delta = timedelta(weeks=interval)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
current_ts += int(delta.total_seconds())
|
||||
added += 1
|
||||
|
||||
case 5: # MONTHLY
|
||||
from dateutil.relativedelta import relativedelta
|
||||
dt = datetime.fromtimestamp(current_ts)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
dt += relativedelta(months=interval)
|
||||
current_ts = int(dt.timestamp())
|
||||
added += 1
|
||||
|
||||
case 6: # YEARLY
|
||||
from dateutil.relativedelta import relativedelta
|
||||
dt = datetime.fromtimestamp(current_ts)
|
||||
while (current_ts <= until_ts) and (not count or added < count):
|
||||
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
|
||||
dt += relativedelta(years=interval)
|
||||
current_ts = int(dt.timestamp())
|
||||
added += 1
|
||||
|
||||
case _: # NONE
|
||||
occurrences.append((start_ts, end_ts))
|
||||
|
||||
# --- add occurences to all_events ---
|
||||
for occ_start, occ_end in occurrences:
|
||||
add_event(summary, calendar_name, occ_start, occ_end, location, description,
|
||||
calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error for {calendar_name}: {e}", file=sys.stderr)
|
||||
|
||||
all_events.sort(key=lambda x: x['start'])
|
||||
print(json.dumps(all_events, indent=4))
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import gi
|
||||
|
||||
gi.require_version('EDataServer', '1.2')
|
||||
gi.require_version('ECal', '2.0')
|
||||
|
||||
try:
|
||||
from gi.repository import ECal, EDataServer
|
||||
print("available")
|
||||
except ImportError as e:
|
||||
print(f"unavailable: {e}")
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
def get_khal_date_format():
|
||||
"""Read the khal config and extract the longdatetimeformat."""
|
||||
xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
|
||||
config_path = Path(xdg_config) / 'khal' / 'config'
|
||||
|
||||
if not config_path.exists():
|
||||
return '%c'
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
for line in f:
|
||||
if m := re.match(r'^longdatetimeformat\s?=\s?(.+?)\s*$', line):
|
||||
date_format = m.group(1).strip()
|
||||
return date_format
|
||||
|
||||
return '%c'
|
||||
|
||||
|
||||
def to_khal(date_str, khal_format):
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return dt.strftime(khal_format)
|
||||
|
||||
def from_khal(date_str, khal_format):
|
||||
if not date_str:
|
||||
return ''
|
||||
dt = datetime.strptime(date_str, khal_format)
|
||||
return dt.isoformat()
|
||||
|
||||
def convert_event(event, khal_format):
|
||||
event['start-long-full'] = from_khal(event.get('start-long-full', ''), khal_format)
|
||||
event['end-long-full'] = from_khal(event.get('end-long-full', ''), khal_format)
|
||||
return event
|
||||
|
||||
def main():
|
||||
start_date = sys.argv[1]
|
||||
duration = sys.argv[2]
|
||||
|
||||
khal_format = get_khal_date_format()
|
||||
khal_start = to_khal(start_date, khal_format)
|
||||
|
||||
cmd = [
|
||||
'khal', 'list',
|
||||
'--json', 'uid',
|
||||
'--json', 'title',
|
||||
'--json', 'start-long-full',
|
||||
'--json', 'end-long-full',
|
||||
'--json', 'calendar',
|
||||
'--json', 'description',
|
||||
'--json', 'location',
|
||||
'--json', 'repeat-pattern',
|
||||
khal_start,
|
||||
duration
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
output = result.stdout.strip()
|
||||
|
||||
for line in output.split('\n'):
|
||||
day_events = json.loads(line)
|
||||
print(json.dumps([convert_event(e, khal_format) for e in day_events]))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
import gi
|
||||
|
||||
gi.require_version('EDataServer', '1.2')
|
||||
import json
|
||||
|
||||
from gi.repository import EDataServer
|
||||
|
||||
registry = EDataServer.SourceRegistry.new_sync(None)
|
||||
sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
|
||||
|
||||
calendars = []
|
||||
for source in sources:
|
||||
if source.get_enabled():
|
||||
calendars.append({
|
||||
'uid': source.get_uid(),
|
||||
'name': source.get_display_name(),
|
||||
'enabled': True
|
||||
})
|
||||
|
||||
print(json.dumps(calendars))
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import errno
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
# flake8: noqa: E501 # Line too long
|
||||
version = "0.0.2-1"
|
||||
|
||||
|
||||
def log(msg) -> None:
|
||||
sys.stdout.write(f"[pair] {msg}\n")
|
||||
sys.stdout.flush() # Flush to ensure the message is passed
|
||||
|
||||
|
||||
def pair_fast():
|
||||
if len(sys.argv) < 5:
|
||||
log("Usage: bluetooth-pair.py <addr> <pairWaitSeconds> <attempts> <intervalSec>")
|
||||
sys.exit(2)
|
||||
|
||||
addr = sys.argv[1]
|
||||
# We won't use pair_wait_seconds in the same way, but we'll respect the timeout logic.
|
||||
pair_wait_seconds = float(sys.argv[2])
|
||||
if pair_wait_seconds < 30:
|
||||
log(f"Warning: pairWaitSeconds ({pair_wait_seconds}s) is too short. Enforcing 45s minimum.")
|
||||
pair_wait_seconds = 45.0
|
||||
|
||||
attempts = int(sys.argv[3])
|
||||
interval_sec = float(sys.argv[4])
|
||||
|
||||
if not addr or len(addr) < 17:
|
||||
# Basic MAC address length check
|
||||
log(f"Invalid Bluetooth address: '{addr}'")
|
||||
sys.exit(2)
|
||||
|
||||
# m/s PTY for interactive control
|
||||
mfd, sfd = pty.openpty()
|
||||
|
||||
# Start bluetoothctl
|
||||
subprocess.Popen(['bluetoothctl'], stdin=sfd, stdout=sfd, stderr=sfd, close_fds=True, text=True)
|
||||
|
||||
os.close(sfd)
|
||||
|
||||
def send_command(cmd):
|
||||
log(f"Sending cmd: {cmd}")
|
||||
os.write(mfd, (cmd + "\n").encode('utf-8'))
|
||||
|
||||
def read_output(timeout=1.0):
|
||||
# Reads available output from mfd
|
||||
output = b""
|
||||
end_time = time.time() + timeout
|
||||
while time.time() < end_time:
|
||||
r, _, _ = select.select([mfd], [], [], 0.1)
|
||||
if mfd in r:
|
||||
try:
|
||||
data = os.read(mfd, 1024)
|
||||
if not data:
|
||||
break
|
||||
output += data
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO:
|
||||
break
|
||||
raise
|
||||
else:
|
||||
pass
|
||||
return output.decode('utf-8', errors='replace')
|
||||
|
||||
log("Initializing bluetoothctl...")
|
||||
time.sleep(1) # Wait for startup
|
||||
# initial_out = read_output(timeout=1)
|
||||
# print(initial_out) # Debug
|
||||
|
||||
send_command("agent on")
|
||||
send_command("default-agent")
|
||||
# send_command("power on") # If we are pairing bluetooth is already powered on
|
||||
time.sleep(0.5)
|
||||
|
||||
# Pair directly since the device is already discovered in the UI/Panel (Removed previous scan/wait part)
|
||||
log(f"Attempting to pair with {addr}...")
|
||||
send_command(f"pair {addr}")
|
||||
|
||||
# Loop to watch for confirmation or success
|
||||
start_time = time.time()
|
||||
paired = False
|
||||
|
||||
log("Waiting for pairing sequence start...")
|
||||
while time.time() - start_time < pair_wait_seconds:
|
||||
out = read_output(timeout=0.5)
|
||||
if out:
|
||||
print(out, end='')
|
||||
# Device not found yet
|
||||
device_not_discovered: list[str] = [f"Device {addr} not available"]
|
||||
if any(e in out for e in device_not_discovered):
|
||||
log(f"Device {addr} is discovered yet...")
|
||||
pair_wait_seconds += 30 # Add additional time for device discovery
|
||||
|
||||
# Confirm Passkey
|
||||
# Numberic Comparison (NC) 1 of 4 - Tested pairing with my iPhone.
|
||||
expected_confirmation: list[str] = ["Confirm passkey", "yes/no", "Request confirmation"]
|
||||
if any(e in out for e in expected_confirmation):
|
||||
log("Detected passkey prompt. Sending 'yes'.")
|
||||
send_command("yes")
|
||||
|
||||
# Authorization Request
|
||||
expected_auth: list[str] = ["Authorize service", "Request authorization"]
|
||||
if any(e in out for e in expected_auth):
|
||||
log("Detected authorization request. Sending 'yes'.")
|
||||
send_command("yes")
|
||||
|
||||
# Interactive PIN/Passkey Entry (Device displays code, User must enter on PC)
|
||||
expected_pin: list[str] = ["Enter passkey", "Enter PIN code", "Passkey: "]
|
||||
if any(e in out for e in expected_pin):
|
||||
log("Device requested PIN/Passkey. Waiting for user input...")
|
||||
log("PIN_REQUIRED") # Signal to service, to prompt user.
|
||||
|
||||
try:
|
||||
# Read PIN from stdin (blocking)
|
||||
user_pin = sys.stdin.readline().strip()
|
||||
if user_pin:
|
||||
log(f"Received PIN: {user_pin}, relaying to bluetoothctl...")
|
||||
send_command(user_pin)
|
||||
except Exception as e:
|
||||
log(f"Error reading stdin: {e}")
|
||||
break
|
||||
|
||||
# Just Works (JW) is implicit (no prompt)
|
||||
expected_success: list[str] = ["Pairing successful", "Paired: yes", "Bonded: yes"]
|
||||
if any(e in out for e in expected_success):
|
||||
paired = True
|
||||
log("Pairing successful detected in stream.")
|
||||
break
|
||||
|
||||
if "Failed to pair" in out:
|
||||
log("Pairing failed explicitly.")
|
||||
break
|
||||
|
||||
expected_already_paired: list[str] = ["Already joined", "Already exists"]
|
||||
if any(e in out for e in expected_already_paired):
|
||||
paired = True
|
||||
log("Device already paired.")
|
||||
break
|
||||
|
||||
# Double check pairing status via info command if not sure
|
||||
if not paired:
|
||||
send_command(f"info {addr}")
|
||||
time.sleep(1)
|
||||
out = read_output(timeout=1)
|
||||
if "Paired: yes" in out:
|
||||
paired = True
|
||||
|
||||
if paired:
|
||||
log("Device is paired. Trusting...")
|
||||
send_command(f"trust {addr}")
|
||||
time.sleep(1)
|
||||
|
||||
log("Connecting...")
|
||||
connected = False
|
||||
for i in range(attempts):
|
||||
send_command(f"connect {addr}")
|
||||
# Wait a bit for connection
|
||||
time.sleep(interval_sec)
|
||||
|
||||
# Check status
|
||||
send_command(f"info {addr}")
|
||||
time.sleep(1)
|
||||
out = read_output(timeout=1)
|
||||
if "Connected: yes" in out:
|
||||
log("Connected successfully, we are done here.")
|
||||
connected = True
|
||||
break
|
||||
else:
|
||||
log(f"Connection attempt {i + 1}/{attempts} failed. Retrying...")
|
||||
|
||||
if connected:
|
||||
send_command("quit")
|
||||
sys.exit(0)
|
||||
else:
|
||||
log("Failed to connect after all attempts.")
|
||||
send_command("quit")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
log("Failed to pair within timeout.")
|
||||
send_command("quit")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pair_fast()
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def run_command(*args):
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
print(f"Error running {' '.join(args)}: {stderr.decode().strip()}", file=sys.stderr)
|
||||
return stdout.decode().strip()
|
||||
|
||||
|
||||
def theme_exists(theme_name: str) -> bool:
|
||||
"""Check if a GTK theme exists in common locations."""
|
||||
search_paths = [
|
||||
Path.home() / ".themes",
|
||||
Path.home() / ".local/share/themes",
|
||||
Path("/usr/share/themes"),
|
||||
Path("/usr/local/share/themes"),
|
||||
]
|
||||
|
||||
# Add paths from XDG_DATA_DIRS
|
||||
xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "")
|
||||
if xdg_data_dirs:
|
||||
for path in xdg_data_dirs.split(":"):
|
||||
if path:
|
||||
search_paths.append(Path(path) / "themes")
|
||||
|
||||
for base_path in search_paths:
|
||||
if (base_path / theme_name).is_dir():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
GTK_IMPORT = '@import url("noctalia.css");'
|
||||
|
||||
|
||||
def ensure_gtk_css_import(gtk_css: Path, colors_file: Path, label: str) -> bool:
|
||||
"""
|
||||
Append the noctalia.css import to gtk.css if not already present.
|
||||
If gtk.css doesn't exist, create it with the import.
|
||||
Does not overwrite user modifications (similar to niri template).
|
||||
"""
|
||||
if not colors_file.exists():
|
||||
print(f"Error: {label} noctalia.css not found at {colors_file}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if gtk_css.exists() or gtk_css.is_symlink():
|
||||
content = gtk_css.read_text()
|
||||
# Already has the import (flexible: allow optional whitespace / different quoting)
|
||||
if "noctalia.css" in content and "@import" in content:
|
||||
return True
|
||||
# Need to modify — handle symlinks carefully
|
||||
target = gtk_css
|
||||
if gtk_css.is_symlink():
|
||||
resolved = gtk_css.resolve()
|
||||
if os.access(resolved, os.W_OK):
|
||||
# Writable symlink (e.g. dotfiles): edit the target directly
|
||||
target = resolved
|
||||
else:
|
||||
# Read-only symlink (e.g. NixOS): convert to local file
|
||||
gtk_css.unlink()
|
||||
gtk_css.write_text(resolved.read_text())
|
||||
# Append import to the end
|
||||
new_content = content.rstrip()
|
||||
if new_content and not new_content.endswith("\n"):
|
||||
new_content += "\n"
|
||||
new_content += "\n" + GTK_IMPORT + "\n"
|
||||
target.write_text(new_content)
|
||||
print(f"Appended {label} noctalia.css import to gtk.css")
|
||||
else:
|
||||
gtk_css.write_text(GTK_IMPORT + "\n")
|
||||
print(f"Created {label} gtk.css with noctalia.css import")
|
||||
return True
|
||||
|
||||
|
||||
async def apply_gtk3_colors(config_dir: Path):
|
||||
gtk3_dir = config_dir / "gtk-3.0"
|
||||
colors_file = gtk3_dir / "noctalia.css"
|
||||
gtk_css = gtk3_dir / "gtk.css"
|
||||
return ensure_gtk_css_import(gtk_css, colors_file, "GTK3")
|
||||
|
||||
|
||||
async def apply_gtk4_colors(config_dir: Path):
|
||||
gtk4_dir = config_dir / "gtk-4.0"
|
||||
colors_file = gtk4_dir / "noctalia.css"
|
||||
gtk_css = gtk4_dir / "gtk.css"
|
||||
return ensure_gtk_css_import(gtk_css, colors_file, "GTK4")
|
||||
|
||||
|
||||
async def sync_system_appearance(mode: str, *, update_gtk_theme: bool = True) -> None:
|
||||
"""
|
||||
Push light/dark to org.gnome.desktop.interface (gsettings or dconf fallback).
|
||||
Used by the GTK template post-hook and ColorSchemeService when "Sync system theme"
|
||||
is on (both set color-scheme and gtk-theme when themes exist). --appearance-only
|
||||
skips CSS and only updates color-scheme for narrow tooling use.
|
||||
"""
|
||||
has_gsettings = shutil.which("gsettings")
|
||||
has_dconf = shutil.which("dconf")
|
||||
|
||||
if not has_gsettings and not has_dconf:
|
||||
print("No gsettings or dconf found, skip system appearance sync")
|
||||
return
|
||||
|
||||
target_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark"
|
||||
theme_available = update_gtk_theme and theme_exists(target_theme)
|
||||
if update_gtk_theme and not theme_available:
|
||||
print(f"Theme '{target_theme}' not found, skipping GTK theme set")
|
||||
|
||||
if has_gsettings:
|
||||
schemas = await run_command("gsettings", "list-schemas")
|
||||
if schemas and "org.gnome.desktop.interface" in schemas:
|
||||
await run_command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", f"prefer-{mode}")
|
||||
if theme_available:
|
||||
await run_command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", f"{target_theme}")
|
||||
return
|
||||
|
||||
if has_dconf:
|
||||
await run_command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'")
|
||||
if theme_available:
|
||||
await run_command("dconf", "write", "/org/gnome/desktop/interface/gtk-theme", f"'{target_theme}'")
|
||||
|
||||
|
||||
async def get_config_dir() -> Path:
|
||||
# Returns the XDG config home (e.g. ~/.config)
|
||||
# GTK config lives at ~/.config/gtk-3.0/ and ~/.config/gtk-4.0/.
|
||||
|
||||
# 1. XDG standard
|
||||
if value := os.environ.get("XDG_CONFIG_HOME"):
|
||||
return Path(value).expanduser()
|
||||
|
||||
# 2. fallback
|
||||
return Path.home() / ".config"
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv[1:]
|
||||
appearance_only = False
|
||||
if argv and argv[0] == "--appearance-only":
|
||||
appearance_only = True
|
||||
argv = argv[1:]
|
||||
if len(argv) != 1 or argv[0] not in ("dark", "light"):
|
||||
print(
|
||||
"Usage: gtk-refresh.py [--appearance-only] (dark|light)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return appearance_only, argv[0]
|
||||
|
||||
|
||||
async def main():
|
||||
appearance_only, mode = parse_args()
|
||||
|
||||
if appearance_only:
|
||||
await sync_system_appearance(mode, update_gtk_theme=False)
|
||||
return
|
||||
|
||||
config_dir = await get_config_dir()
|
||||
|
||||
if not config_dir.is_dir():
|
||||
print(f"Error: Config directory not found: {config_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
(config_dir / "gtk-3.0").mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "gtk-4.0").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = await asyncio.gather(apply_gtk3_colors(config_dir), apply_gtk4_colors(config_dir))
|
||||
|
||||
if all(results):
|
||||
await sync_system_appearance(mode, update_gtk_theme=True)
|
||||
print("GTK colors applied successfully")
|
||||
else:
|
||||
# Still push light/dark preference so portal/GTK apps follow the shell even when
|
||||
# gtk.css / noctalia.css setup failed.
|
||||
await sync_system_appearance(mode, update_gtk_theme=False)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Theming library - Color extraction and theme generation.
|
||||
|
||||
This package provides:
|
||||
- HCT color space implementation (CAM16, Hct, TonalPalette)
|
||||
- Material Design 3 scheme generation
|
||||
- Color utilities (RGB, HSL conversions)
|
||||
- Image reading and palette extraction
|
||||
- Template rendering (Matugen compatible)
|
||||
"""
|
||||
|
||||
from .color import Color, rgb_to_hsl, hsl_to_rgb, adjust_surface
|
||||
from .hct import Hct, Cam16, TonalPalette, TemperatureCache, fix_if_disliked
|
||||
from .material import MaterialScheme, SchemeContent, harmonize_color
|
||||
from .contrast import ensure_contrast, contrast_ratio, is_dark
|
||||
from .image import read_image, ImageReadError
|
||||
from .palette import extract_palette
|
||||
from .quantizer import extract_source_color, source_color_to_rgb
|
||||
from .theme import generate_theme
|
||||
from .renderer import TemplateRenderer
|
||||
from .scheme import expand_predefined_scheme, inject_terminal_colors
|
||||
|
||||
__all__ = [
|
||||
# Color
|
||||
"Color",
|
||||
"rgb_to_hsl",
|
||||
"hsl_to_rgb",
|
||||
"adjust_surface",
|
||||
# HCT
|
||||
"Hct",
|
||||
"Cam16",
|
||||
"TonalPalette",
|
||||
"TemperatureCache",
|
||||
"fix_if_disliked",
|
||||
# Material
|
||||
"MaterialScheme",
|
||||
"SchemeContent",
|
||||
"harmonize_color",
|
||||
# Contrast
|
||||
"ensure_contrast",
|
||||
"contrast_ratio",
|
||||
"is_dark",
|
||||
# Image
|
||||
"read_image",
|
||||
"ImageReadError",
|
||||
# Palette
|
||||
"extract_palette",
|
||||
# Quantizer (Wu + Score algorithm matching matugen)
|
||||
"extract_source_color",
|
||||
"source_color_to_rgb",
|
||||
# Theme
|
||||
"generate_theme",
|
||||
# Renderer
|
||||
"TemplateRenderer",
|
||||
# Scheme
|
||||
"expand_predefined_scheme",
|
||||
"inject_terminal_colors",
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Color representation and conversion utilities.
|
||||
|
||||
This module provides the Color class and functions for converting between
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class Color:
|
||||
"""Represents a color with RGB values (0-255)."""
|
||||
r: int
|
||||
g: int
|
||||
b: int
|
||||
|
||||
@classmethod
|
||||
def from_rgb(cls, rgb: RGB) -> 'Color':
|
||||
return cls(rgb[0], rgb[1], rgb[2])
|
||||
|
||||
@classmethod
|
||||
def from_hex(cls, hex_str: str) -> 'Color':
|
||||
"""Parse hex color string (#RRGGBB or RRGGBB)."""
|
||||
hex_str = hex_str.lstrip('#')
|
||||
return cls(
|
||||
int(hex_str[0:2], 16),
|
||||
int(hex_str[2:4], 16),
|
||||
int(hex_str[4:6], 16)
|
||||
)
|
||||
|
||||
def to_rgb(self) -> RGB:
|
||||
return (self.r, self.g, self.b)
|
||||
|
||||
def to_hex(self) -> str:
|
||||
"""Convert to hex string (#RRGGBB)."""
|
||||
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
|
||||
|
||||
def to_hsl(self) -> HSL:
|
||||
"""Convert RGB to HSL."""
|
||||
return rgb_to_hsl(self.r, self.g, self.b)
|
||||
|
||||
def to_hct(self) -> 'Hct':
|
||||
"""Convert to HCT color space."""
|
||||
from .hct import Hct
|
||||
return Hct.from_rgb(self.r, self.g, self.b)
|
||||
|
||||
@classmethod
|
||||
def from_hsl(cls, h: float, s: float, l: float) -> 'Color':
|
||||
"""Create Color from HSL values."""
|
||||
r, g, b = hsl_to_rgb(h, s, l)
|
||||
return cls(r, g, b)
|
||||
|
||||
@classmethod
|
||||
def from_hct(cls, hct: 'Hct') -> 'Color':
|
||||
"""Create Color from HCT."""
|
||||
r, g, b = hct.to_rgb()
|
||||
return cls(r, g, b)
|
||||
|
||||
|
||||
def rgb_to_hsl(r: int, g: int, b: int) -> HSL:
|
||||
"""
|
||||
Convert RGB (0-255) to HSL (0-360, 0-1, 0-1).
|
||||
|
||||
Args:
|
||||
r: Red component (0-255)
|
||||
g: Green component (0-255)
|
||||
b: Blue component (0-255)
|
||||
|
||||
Returns:
|
||||
Tuple of (hue, saturation, lightness)
|
||||
"""
|
||||
r_norm = r / 255.0
|
||||
g_norm = g / 255.0
|
||||
b_norm = b / 255.0
|
||||
|
||||
max_c = max(r_norm, g_norm, b_norm)
|
||||
min_c = min(r_norm, g_norm, b_norm)
|
||||
delta = max_c - min_c
|
||||
|
||||
# Lightness
|
||||
l = (max_c + min_c) / 2.0
|
||||
|
||||
if delta == 0:
|
||||
h = 0.0
|
||||
s = 0.0
|
||||
else:
|
||||
# Saturation
|
||||
s = delta / (1 - abs(2 * l - 1)) if l != 0 and l != 1 else 0
|
||||
|
||||
# Hue
|
||||
if max_c == r_norm:
|
||||
h = 60.0 * (((g_norm - b_norm) / delta) % 6)
|
||||
elif max_c == g_norm:
|
||||
h = 60.0 * (((b_norm - r_norm) / delta) + 2)
|
||||
else:
|
||||
h = 60.0 * (((r_norm - g_norm) / delta) + 4)
|
||||
|
||||
return (h, s, l)
|
||||
|
||||
|
||||
def hsl_to_rgb(h: float, s: float, l: float) -> RGB:
|
||||
"""
|
||||
Convert HSL (0-360, 0-1, 0-1) to RGB (0-255).
|
||||
|
||||
Args:
|
||||
h: Hue (0-360)
|
||||
s: Saturation (0-1)
|
||||
l: Lightness (0-1)
|
||||
|
||||
Returns:
|
||||
Tuple of (r, g, b)
|
||||
"""
|
||||
if s == 0:
|
||||
# Achromatic (gray)
|
||||
v = int(round(l * 255))
|
||||
return (v, v, v)
|
||||
|
||||
def hue_to_rgb(p: float, q: float, t: float) -> float:
|
||||
if t < 0:
|
||||
t += 1
|
||||
if t > 1:
|
||||
t -= 1
|
||||
if t < 1/6:
|
||||
return p + (q - p) * 6 * t
|
||||
if t < 1/2:
|
||||
return q
|
||||
if t < 2/3:
|
||||
return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
|
||||
q = l * (1 + s) if l < 0.5 else l + s - l * s
|
||||
p = 2 * l - q
|
||||
h_norm = h / 360.0
|
||||
|
||||
r = hue_to_rgb(p, q, h_norm + 1/3)
|
||||
g = hue_to_rgb(p, q, h_norm)
|
||||
b = hue_to_rgb(p, q, h_norm - 1/3)
|
||||
|
||||
return (
|
||||
int(round(r * 255)),
|
||||
int(round(g * 255)),
|
||||
int(round(b * 255))
|
||||
)
|
||||
|
||||
|
||||
def adjust_lightness(color: Color, target_l: float) -> Color:
|
||||
"""Adjust a color's lightness to a target value (0-1)."""
|
||||
h, s, _ = color.to_hsl()
|
||||
return Color.from_hsl(h, s, target_l)
|
||||
|
||||
|
||||
def shift_hue(color: Color, degrees: float) -> Color:
|
||||
"""Shift a color's hue by specified degrees."""
|
||||
h, s, l = color.to_hsl()
|
||||
new_h = (h + degrees) % 360
|
||||
return Color.from_hsl(new_h, s, l)
|
||||
|
||||
|
||||
def hue_distance(h1: float, h2: float) -> float:
|
||||
"""Calculate minimum angular distance between two hues (0-180)."""
|
||||
diff = abs(h1 - h2)
|
||||
return min(diff, 360 - diff)
|
||||
|
||||
|
||||
def adjust_surface(color: Color, s_max: float, l_target: float) -> Color:
|
||||
"""Derive a surface color from a base color with saturation limit and target lightness."""
|
||||
h, s, _ = color.to_hsl()
|
||||
return Color.from_hsl(h, min(s, s_max), l_target)
|
||||
|
||||
|
||||
def saturate(color: Color, amount: float) -> Color:
|
||||
"""Adjust saturation by amount (-1 to 1)."""
|
||||
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)
|
||||
|
||||
|
||||
def find_closest_color(
|
||||
compare_to: str,
|
||||
colors: list[dict[str, str]]
|
||||
) -> str:
|
||||
"""
|
||||
Find the closest named color from a list (matugen-compatible).
|
||||
|
||||
Uses Lab color space Euclidean distance for perceptual color matching.
|
||||
|
||||
Args:
|
||||
compare_to: Hex color to compare (e.g., "#ff5500")
|
||||
colors: List of {"name": "...", "color": "#..."} dicts
|
||||
|
||||
Returns:
|
||||
Name of the closest color, or empty string if no colors provided
|
||||
"""
|
||||
if not colors:
|
||||
return ""
|
||||
|
||||
# Parse target color
|
||||
target = Color.from_hex(compare_to)
|
||||
target_lab = rgb_to_lab(target.r, target.g, target.b)
|
||||
|
||||
closest_name = ""
|
||||
closest_dist = float('inf')
|
||||
|
||||
for entry in colors:
|
||||
try:
|
||||
entry_color = Color.from_hex(entry["color"])
|
||||
entry_lab = rgb_to_lab(entry_color.r, entry_color.g, entry_color.b)
|
||||
dist = lab_distance(target_lab, entry_lab)
|
||||
if dist < closest_dist:
|
||||
closest_dist = dist
|
||||
closest_name = entry["name"]
|
||||
except (KeyError, ValueError):
|
||||
# Skip invalid entries
|
||||
continue
|
||||
|
||||
return closest_name
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Contrast calculation utilities (WCAG luminance and contrast).
|
||||
|
||||
This module provides functions for calculating relative luminance,
|
||||
contrast ratios, and ensuring accessible color combinations.
|
||||
"""
|
||||
|
||||
from .color import Color
|
||||
|
||||
|
||||
def relative_luminance(r: int, g: int, b: int) -> float:
|
||||
"""
|
||||
Calculate relative luminance per WCAG 2.1.
|
||||
|
||||
The formula converts sRGB to linear RGB, then applies the luminance formula:
|
||||
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
||||
|
||||
Args:
|
||||
r, g, b: RGB components (0-255)
|
||||
|
||||
Returns:
|
||||
Relative luminance (0-1)
|
||||
"""
|
||||
def linearize(c: int) -> float:
|
||||
c_norm = c / 255.0
|
||||
if c_norm <= 0.03928:
|
||||
return c_norm / 12.92
|
||||
return ((c_norm + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r_lin = linearize(r)
|
||||
g_lin = linearize(g)
|
||||
b_lin = linearize(b)
|
||||
|
||||
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
|
||||
|
||||
|
||||
def contrast_ratio(color1: Color, color2: Color) -> float:
|
||||
"""
|
||||
Calculate WCAG contrast ratio between two colors.
|
||||
|
||||
Returns a value between 1:1 (identical) and 21:1 (black/white).
|
||||
"""
|
||||
l1 = relative_luminance(color1.r, color1.g, color1.b)
|
||||
l2 = relative_luminance(color2.r, color2.g, color2.b)
|
||||
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
def is_dark(color: Color) -> bool:
|
||||
"""Determine if a color is perceptually dark."""
|
||||
return relative_luminance(color.r, color.g, color.b) < 0.179
|
||||
|
||||
|
||||
def ensure_contrast(
|
||||
foreground: Color,
|
||||
background: Color,
|
||||
min_ratio: float = 4.5,
|
||||
prefer_light: bool | None = None
|
||||
) -> Color:
|
||||
"""
|
||||
Adjust foreground color to meet minimum contrast ratio against background.
|
||||
|
||||
Args:
|
||||
foreground: The color to adjust
|
||||
background: The background color (not modified)
|
||||
min_ratio: Minimum contrast ratio (default 4.5 for WCAG AA)
|
||||
prefer_light: If True, prefer lightening; if False, prefer darkening;
|
||||
if None, auto-detect based on background
|
||||
|
||||
Returns:
|
||||
Adjusted foreground color meeting contrast requirements
|
||||
"""
|
||||
current_ratio = contrast_ratio(foreground, background)
|
||||
if current_ratio >= min_ratio:
|
||||
return foreground
|
||||
|
||||
h, s, l = foreground.to_hsl()
|
||||
bg_dark = is_dark(background)
|
||||
|
||||
# Determine direction to adjust
|
||||
if prefer_light is None:
|
||||
prefer_light = bg_dark
|
||||
|
||||
# Binary search for the right lightness
|
||||
if prefer_light:
|
||||
low, high = l, 1.0
|
||||
else:
|
||||
low, high = 0.0, l
|
||||
|
||||
best_color = foreground
|
||||
for _ in range(20): # Max iterations
|
||||
mid = (low + high) / 2
|
||||
test_color = Color.from_hsl(h, s, mid)
|
||||
ratio = contrast_ratio(test_color, background)
|
||||
|
||||
if ratio >= min_ratio:
|
||||
best_color = test_color
|
||||
if prefer_light:
|
||||
high = mid
|
||||
else:
|
||||
low = mid
|
||||
else:
|
||||
if prefer_light:
|
||||
low = mid
|
||||
else:
|
||||
high = mid
|
||||
|
||||
return best_color
|
||||
|
||||
|
||||
def get_contrasting_color(background: Color, min_ratio: float = 4.5) -> Color:
|
||||
"""Get a contrasting foreground color (black or white variant)."""
|
||||
if is_dark(background):
|
||||
# Light foreground for dark background
|
||||
fg = Color(243, 237, 247) # Off-white
|
||||
else:
|
||||
# Dark foreground for light background
|
||||
fg = Color(14, 14, 67) # Dark blue-black
|
||||
|
||||
return ensure_contrast(fg, background, min_ratio)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Image reading utilities for PNG and JPEG files.
|
||||
|
||||
This module provides functions for extracting RGB pixels from image files
|
||||
without external dependencies (except ImageMagick for fallback).
|
||||
"""
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
# Type alias
|
||||
RGB = tuple[int, int, int]
|
||||
|
||||
|
||||
class ImageReadError(Exception):
|
||||
"""Raised when image cannot be read or parsed."""
|
||||
pass
|
||||
|
||||
|
||||
def read_png(path: Path) -> list[RGB]:
|
||||
"""
|
||||
Parse a PNG file and extract RGB pixels.
|
||||
|
||||
Supports 8-bit RGB and RGBA color types (most common for wallpapers).
|
||||
Uses zlib for IDAT decompression and handles PNG filters.
|
||||
"""
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Verify PNG signature
|
||||
if data[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise ImageReadError("Invalid PNG signature")
|
||||
|
||||
pos = 8
|
||||
width = 0
|
||||
height = 0
|
||||
bit_depth = 0
|
||||
color_type = 0
|
||||
idat_chunks: list[bytes] = []
|
||||
|
||||
while pos < len(data):
|
||||
# Read chunk length and type
|
||||
chunk_len = struct.unpack('>I', data[pos:pos+4])[0]
|
||||
chunk_type = data[pos+4:pos+8]
|
||||
chunk_data = data[pos+8:pos+8+chunk_len]
|
||||
pos += 12 + chunk_len # length + type + data + crc
|
||||
|
||||
if chunk_type == b'IHDR':
|
||||
width = struct.unpack('>I', chunk_data[0:4])[0]
|
||||
height = struct.unpack('>I', chunk_data[4:8])[0]
|
||||
bit_depth = chunk_data[8]
|
||||
color_type = chunk_data[9]
|
||||
|
||||
if bit_depth != 8:
|
||||
raise ImageReadError(f"Unsupported bit depth: {bit_depth}")
|
||||
if color_type not in (2, 6): # RGB or RGBA
|
||||
raise ImageReadError(f"Unsupported color type: {color_type}")
|
||||
|
||||
elif chunk_type == b'IDAT':
|
||||
idat_chunks.append(chunk_data)
|
||||
|
||||
elif chunk_type == b'IEND':
|
||||
break
|
||||
|
||||
if not idat_chunks or width == 0:
|
||||
raise ImageReadError("Missing image data")
|
||||
|
||||
# Decompress all IDAT chunks
|
||||
compressed = b''.join(idat_chunks)
|
||||
raw_data = zlib.decompress(compressed)
|
||||
|
||||
# Calculate bytes per pixel and row
|
||||
bpp = 3 if color_type == 2 else 4 # RGB or RGBA
|
||||
stride = width * bpp + 1 # +1 for filter byte
|
||||
|
||||
pixels: list[RGB] = []
|
||||
prev_row: list[int] = [0] * (width * bpp)
|
||||
|
||||
for y in range(height):
|
||||
row_start = y * stride
|
||||
filter_type = raw_data[row_start]
|
||||
row_data = list(raw_data[row_start + 1:row_start + stride])
|
||||
|
||||
# Apply PNG filter reconstruction
|
||||
unfiltered = _png_unfilter(row_data, prev_row, bpp, filter_type)
|
||||
prev_row = unfiltered
|
||||
|
||||
# Extract RGB values (skip alpha if present)
|
||||
for x in range(width):
|
||||
idx = x * bpp
|
||||
r, g, b = unfiltered[idx], unfiltered[idx+1], unfiltered[idx+2]
|
||||
pixels.append((r, g, b))
|
||||
|
||||
return pixels
|
||||
|
||||
|
||||
def _png_unfilter(
|
||||
row: list[int],
|
||||
prev_row: list[int],
|
||||
bpp: int,
|
||||
filter_type: int
|
||||
) -> list[int]:
|
||||
"""Apply PNG filter reconstruction."""
|
||||
result = [0] * len(row)
|
||||
|
||||
for i in range(len(row)):
|
||||
x = row[i]
|
||||
a = result[i - bpp] if i >= bpp else 0
|
||||
b = prev_row[i]
|
||||
c = prev_row[i - bpp] if i >= bpp else 0
|
||||
|
||||
if filter_type == 0: # None
|
||||
result[i] = x
|
||||
elif filter_type == 1: # Sub
|
||||
result[i] = (x + a) & 0xFF
|
||||
elif filter_type == 2: # Up
|
||||
result[i] = (x + b) & 0xFF
|
||||
elif filter_type == 3: # Average
|
||||
result[i] = (x + (a + b) // 2) & 0xFF
|
||||
elif filter_type == 4: # Paeth
|
||||
result[i] = (x + _paeth_predictor(a, b, c)) & 0xFF
|
||||
else:
|
||||
raise ImageReadError(f"Unknown PNG filter type: {filter_type}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _paeth_predictor(a: int, b: int, c: int) -> int:
|
||||
"""Paeth predictor for PNG filter reconstruction."""
|
||||
p = a + b - c
|
||||
pa = abs(p - a)
|
||||
pb = abs(p - b)
|
||||
pc = abs(p - c)
|
||||
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
elif pb <= pc:
|
||||
return b
|
||||
return c
|
||||
|
||||
|
||||
def read_jpeg(path: Path) -> list[RGB]:
|
||||
"""
|
||||
Parse a JPEG file and extract RGB pixels.
|
||||
|
||||
Supports baseline (SOF0), extended (SOF1), and progressive (SOF2) JPEG.
|
||||
This is a simplified decoder that extracts dimensions then samples colors.
|
||||
"""
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Verify JPEG signature (SOI marker)
|
||||
if data[:2] != b'\xff\xd8':
|
||||
raise ImageReadError("Invalid JPEG signature")
|
||||
|
||||
pos = 2
|
||||
width = 0
|
||||
height = 0
|
||||
|
||||
# SOF markers that contain image dimensions
|
||||
# SOF0=Baseline, SOF1=Extended, SOF2=Progressive, SOF3=Lossless
|
||||
# SOF5-7=Differential variants, SOF9-11=Arithmetic coding variants
|
||||
sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
||||
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
||||
|
||||
# Standalone markers (no length field)
|
||||
standalone_markers = {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, # RST0-7
|
||||
0xD8, # SOI
|
||||
0xD9, # EOI
|
||||
0x01} # TEM
|
||||
|
||||
while pos < len(data) - 1:
|
||||
# Find next marker
|
||||
if data[pos] != 0xFF:
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
# Skip padding 0xFF bytes
|
||||
while pos < len(data) and data[pos] == 0xFF:
|
||||
pos += 1
|
||||
|
||||
if pos >= len(data):
|
||||
break
|
||||
|
||||
marker = data[pos]
|
||||
pos += 1
|
||||
|
||||
# Check for SOF marker (contains dimensions)
|
||||
if marker in sof_markers:
|
||||
if pos + 7 <= len(data):
|
||||
# Skip segment length (2 bytes), precision (1 byte)
|
||||
height = struct.unpack('>H', data[pos+3:pos+5])[0]
|
||||
width = struct.unpack('>H', data[pos+5:pos+7])[0]
|
||||
break
|
||||
|
||||
# End of image
|
||||
if marker == 0xD9:
|
||||
break
|
||||
|
||||
# Skip segment data for markers with length field
|
||||
if marker not in standalone_markers and marker != 0x00:
|
||||
if pos + 2 <= len(data):
|
||||
seg_len = struct.unpack('>H', data[pos:pos+2])[0]
|
||||
pos += seg_len
|
||||
|
||||
if width == 0 or height == 0:
|
||||
raise ImageReadError("Could not parse JPEG dimensions")
|
||||
|
||||
# Since full JPEG decoding is extremely complex without external libraries,
|
||||
# we fall back to sampling the raw data for color approximation.
|
||||
return _sample_jpeg_colors(data, width, height)
|
||||
|
||||
|
||||
def _sample_jpeg_colors(data: bytes, width: int, height: int) -> list[RGB]:
|
||||
"""
|
||||
Sample colors from JPEG data without full decoding.
|
||||
|
||||
This is a rough approximation that samples byte triplets from the
|
||||
compressed data. Not accurate, but provides some color information.
|
||||
For accurate results, use ImageMagick via read_image().
|
||||
"""
|
||||
# Sample every Nth byte triplet from the image data
|
||||
# Skip headers and look for image data after SOS marker
|
||||
pixels: list[RGB] = []
|
||||
step = max(1, len(data) // (width * height // 16))
|
||||
|
||||
for i in range(0, len(data) - 2, step):
|
||||
r, g, b = data[i], data[i+1], data[i+2]
|
||||
# Filter out obviously non-image data (markers, etc.)
|
||||
if not (r == 0xFF and g in (0xD8, 0xD9, 0xE0, 0xE1)):
|
||||
pixels.append((r, g, b))
|
||||
|
||||
return pixels if pixels else [(128, 128, 128)]
|
||||
|
||||
|
||||
def _read_image_imagemagick(path: Path, resize_filter: str = "Triangle") -> list[RGB]:
|
||||
"""
|
||||
Read image using ImageMagick's convert command.
|
||||
|
||||
Converts image to PPM format (trivial to parse) and extracts RGB pixels.
|
||||
This method works accurately for any image format ImageMagick supports.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# Use magick or convert command
|
||||
# -depth 8: 8 bits per channel
|
||||
# -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
|
||||
# Use -filter Triangle (bilinear) for M3 schemes to match matugen's FilterType::Triangle default
|
||||
# Use -filter Box for k-means schemes (sharper, preserves distinct color regions)
|
||||
# Use -depth 8 -colorspace sRGB -strip to reduce variance between HDRI/non-HDRI builds
|
||||
resize_spec = "112x112!"
|
||||
|
||||
try:
|
||||
# Try 'magick' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['magick', str(path), '-filter', resize_filter, '-resize', resize_spec,
|
||||
'-depth', '8', '-colorspace', 'sRGB', '-strip', 'ppm:-'],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
except FileNotFoundError:
|
||||
result = subprocess.run(
|
||||
['convert', str(path), '-filter', resize_filter, '-resize', resize_spec,
|
||||
'-depth', '8', '-colorspace', 'sRGB', '-strip', 'ppm:-'],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise ImageReadError(f"ImageMagick failed: {e.stderr.decode()}")
|
||||
except FileNotFoundError:
|
||||
raise ImageReadError("ImageMagick not found. Please install imagemagick.")
|
||||
|
||||
ppm_data = result.stdout
|
||||
return _parse_ppm(ppm_data)
|
||||
|
||||
|
||||
def _parse_ppm(data: bytes) -> list[RGB]:
|
||||
"""
|
||||
Parse PPM (Portable Pixmap) binary format.
|
||||
|
||||
PPM P6 format:
|
||||
P6
|
||||
width height
|
||||
maxval
|
||||
<binary RGB data>
|
||||
"""
|
||||
pos = 0
|
||||
tokens: list[str] = []
|
||||
|
||||
# Read header tokens (need 4: P6, width, height, maxval)
|
||||
while len(tokens) < 4 and pos < len(data):
|
||||
# Skip whitespace
|
||||
while pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'):
|
||||
pos += 1
|
||||
|
||||
# Skip comments
|
||||
if pos < len(data) and data[pos:pos+1] == b'#':
|
||||
while pos < len(data) and data[pos:pos+1] != b'\n':
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
# Read token
|
||||
token_start = pos
|
||||
while pos < len(data) and data[pos:pos+1] not in (b' ', b'\t', b'\n', b'\r', b'#'):
|
||||
pos += 1
|
||||
|
||||
if pos > token_start:
|
||||
tokens.append(data[token_start:pos].decode('ascii'))
|
||||
|
||||
if len(tokens) < 4 or tokens[0] != 'P6':
|
||||
raise ImageReadError(f"Invalid PPM format: {tokens}")
|
||||
|
||||
width = int(tokens[1])
|
||||
height = int(tokens[2])
|
||||
maxval = int(tokens[3])
|
||||
|
||||
# Skip exactly one whitespace character after maxval (per PPM spec)
|
||||
if pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'):
|
||||
pos += 1
|
||||
|
||||
pixel_data = data[pos:]
|
||||
|
||||
# Parse RGB triplets
|
||||
pixels: list[RGB] = []
|
||||
scale = 255.0 / maxval if maxval != 255 else 1.0
|
||||
|
||||
for i in range(0, min(len(pixel_data), width * height * 3), 3):
|
||||
if i + 2 < len(pixel_data):
|
||||
r = int(pixel_data[i] * scale)
|
||||
g = int(pixel_data[i + 1] * scale)
|
||||
b = int(pixel_data[i + 2] * scale)
|
||||
pixels.append((r, g, b))
|
||||
|
||||
if not pixels:
|
||||
raise ImageReadError("No pixels extracted from PPM data")
|
||||
|
||||
return pixels
|
||||
|
||||
|
||||
def read_image(path: Path, resize_filter: str = "Triangle") -> list[RGB]:
|
||||
"""
|
||||
Read an image file and return its pixels as RGB tuples.
|
||||
|
||||
Uses ImageMagick for accurate color extraction from any format.
|
||||
Falls back to native PNG parsing if ImageMagick is unavailable.
|
||||
|
||||
Args:
|
||||
path: Path to the image file.
|
||||
resize_filter: ImageMagick resize filter. "Triangle" for M3 schemes
|
||||
(matches matugen), "Box" for k-means schemes.
|
||||
"""
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
# Try ImageMagick first (works for any format)
|
||||
try:
|
||||
return _read_image_imagemagick(path, resize_filter)
|
||||
except ImageReadError:
|
||||
# Fall back to native parsing for PNG
|
||||
if suffix == '.png':
|
||||
return read_png(path)
|
||||
raise
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
Material Design 3 color scheme implementation.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
from .hct import Hct, TonalPalette, TemperatureCache, fix_if_disliked
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tone Values (shared across all schemes)
|
||||
# =============================================================================
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
# Monochrome scheme uses different tone values (from material-colors library)
|
||||
# Primary/tertiary get special treatment for higher contrast in grayscale
|
||||
MONOCHROME_DARK_TONES = {
|
||||
**DARK_TONES,
|
||||
'primary': 100, # White (was 80)
|
||||
'on_primary': 10, # Near-black (was 20)
|
||||
'primary_container': 85, # Light gray (was 30)
|
||||
'on_primary_container': 0, # Black (was 90)
|
||||
'tertiary': 90, # Light gray (was 80)
|
||||
'on_tertiary': 10, # Near-black (was 20)
|
||||
'tertiary_container': 60, # Mid gray (was 30)
|
||||
'on_tertiary_container': 0, # Black (was 90)
|
||||
'secondary_container': 30, # Same as normal
|
||||
}
|
||||
|
||||
MONOCHROME_LIGHT_TONES = {
|
||||
**LIGHT_TONES,
|
||||
'primary': 0, # Black (was 40)
|
||||
'on_primary': 90, # Light gray (was 100)
|
||||
'primary_container': 25, # Dark gray (was 90)
|
||||
'on_primary_container': 100, # White (was 10)
|
||||
'tertiary': 25, # Dark gray (was 40)
|
||||
'on_tertiary': 90, # Light gray (was 100)
|
||||
'tertiary_container': 49, # Mid gray (was 90)
|
||||
'on_tertiary_container': 100, # White (was 10)
|
||||
'secondary_container': 90, # Same as normal
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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):
|
||||
"""Initialize with source color. Subclasses must set palettes."""
|
||||
self.source = source_color
|
||||
self.error_palette = TonalPalette(25.0, 84.0) # Material red
|
||||
|
||||
@classmethod
|
||||
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) -> '_BaseScheme':
|
||||
"""Create scheme from hex color string."""
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
return cls.from_rgb(r, g, b)
|
||||
|
||||
def get_dark_scheme(self) -> dict[str, str]:
|
||||
"""Generate dark theme color dictionary."""
|
||||
return self._generate_scheme(is_dark=True)
|
||||
|
||||
def get_light_scheme(self) -> dict[str, str]:
|
||||
"""Generate light theme color dictionary."""
|
||||
return self._generate_scheme(is_dark=False)
|
||||
|
||||
def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
|
||||
"""Generate scheme with appropriate tone values."""
|
||||
tones = DARK_TONES if is_dark else LIGHT_TONES
|
||||
|
||||
scheme = {
|
||||
# Primary colors
|
||||
'primary': self.primary_palette.get_hex(tones['primary']),
|
||||
'on_primary': self.primary_palette.get_hex(tones['on_primary']),
|
||||
'primary_container': self.primary_palette.get_hex(tones['primary_container']),
|
||||
'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
|
||||
|
||||
# Surface tint (same as primary, used for M3 elevation tinting)
|
||||
'surface_tint': self.primary_palette.get_hex(tones['primary']),
|
||||
|
||||
# Secondary colors
|
||||
'secondary': self.secondary_palette.get_hex(tones['secondary']),
|
||||
'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
|
||||
'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
|
||||
'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
|
||||
|
||||
# Tertiary colors
|
||||
'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
|
||||
'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
|
||||
'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
|
||||
'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
|
||||
|
||||
# Error colors
|
||||
'error': self.error_palette.get_hex(tones['error']),
|
||||
'on_error': self.error_palette.get_hex(tones['on_error']),
|
||||
'error_container': self.error_palette.get_hex(tones['error_container']),
|
||||
'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
|
||||
|
||||
# Surface colors
|
||||
'surface': self.neutral_palette.get_hex(tones['surface']),
|
||||
'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
|
||||
'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
|
||||
'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
|
||||
|
||||
# Surface containers
|
||||
'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
|
||||
'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
|
||||
'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
|
||||
'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
|
||||
'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
|
||||
|
||||
# Outline and other
|
||||
'outline': self.neutral_variant_palette.get_hex(tones['outline']),
|
||||
'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
|
||||
'shadow': self.neutral_palette.get_hex(tones['shadow']),
|
||||
'scrim': self.neutral_palette.get_hex(tones['scrim']),
|
||||
|
||||
# Inverse colors
|
||||
'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
|
||||
'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
|
||||
'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
|
||||
|
||||
# Background (alias for surface)
|
||||
'background': self.neutral_palette.get_hex(tones['surface']),
|
||||
'on_background': self.neutral_palette.get_hex(tones['on_surface']),
|
||||
|
||||
# Surface dim and bright
|
||||
'surface_dim': self.neutral_palette.get_hex(87 if not is_dark else 6),
|
||||
'surface_bright': self.neutral_palette.get_hex(98 if not is_dark else 24),
|
||||
|
||||
# Fixed colors - consistent across light/dark modes (MD3 spec)
|
||||
'primary_fixed': self.primary_palette.get_hex(90),
|
||||
'primary_fixed_dim': self.primary_palette.get_hex(80),
|
||||
'on_primary_fixed': self.primary_palette.get_hex(10),
|
||||
'on_primary_fixed_variant': self.primary_palette.get_hex(30),
|
||||
|
||||
'secondary_fixed': self.secondary_palette.get_hex(90),
|
||||
'secondary_fixed_dim': self.secondary_palette.get_hex(80),
|
||||
'on_secondary_fixed': self.secondary_palette.get_hex(10),
|
||||
'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
|
||||
|
||||
'tertiary_fixed': self.tertiary_palette.get_hex(90),
|
||||
'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
|
||||
'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
|
||||
'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
|
||||
}
|
||||
|
||||
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 Material Design 3 "content" scheme that preserves the source
|
||||
color's characteristics while creating harmonious palettes:
|
||||
- Primary: source hue and chroma (full preservation)
|
||||
- Secondary: same hue, reduced chroma: max(chroma - 32, chroma * 0.5)
|
||||
- Tertiary: analogous color from temperature analysis (warm-cool harmony)
|
||||
- Neutrals: low chroma (chroma / 8, tinted with source hue)
|
||||
"""
|
||||
|
||||
def __init__(self, source_color: Hct):
|
||||
super().__init__(source_color)
|
||||
|
||||
# Primary: preserve source color's hue and chroma (full preservation)
|
||||
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
|
||||
|
||||
# Secondary: same hue, reduced chroma
|
||||
# Formula from matugen: max(chroma - 32, chroma * 0.5)
|
||||
secondary_chroma = max(source_color.chroma - 32.0, source_color.chroma * 0.5)
|
||||
self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
|
||||
|
||||
# Tertiary: use analogous color from temperature analysis
|
||||
# Get 3 analogous colors with 6 divisions, pick the last one (most different)
|
||||
temp_cache = TemperatureCache(source_color)
|
||||
analogous_colors = temp_cache.analogous(3, 6)
|
||||
tertiary_hct = fix_if_disliked(analogous_colors[-1])
|
||||
self.tertiary_palette = TonalPalette.from_hct(tertiary_hct)
|
||||
|
||||
# Neutral: source hue, low chroma (chroma / 8)
|
||||
neutral_chroma = source_color.chroma / 8.0
|
||||
self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
|
||||
|
||||
# Neutral variant: slightly more chroma (chroma / 8 + 4)
|
||||
neutral_variant_chroma = (source_color.chroma / 8.0) + 4.0
|
||||
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
|
||||
|
||||
|
||||
class SchemeMonochrome(_BaseScheme):
|
||||
"""
|
||||
Material Design 3 Monochrome scheme.
|
||||
|
||||
All color palettes use chroma=0.0, producing a pure grayscale theme.
|
||||
Only the error color retains saturation for accessibility.
|
||||
|
||||
Uses special tone mappings (different from other M3 schemes) for higher
|
||||
contrast in grayscale - e.g., primary is tone 100 (white) in dark mode.
|
||||
|
||||
Palette configuration:
|
||||
- Primary: chroma 0.0 (grayscale)
|
||||
- Secondary: chroma 0.0 (grayscale)
|
||||
- Tertiary: chroma 0.0 (grayscale)
|
||||
- Neutral: chroma 0.0 (grayscale)
|
||||
- Neutral variant: chroma 0.0 (grayscale)
|
||||
- Error: hue 25°, chroma 84 (vibrant red)
|
||||
"""
|
||||
|
||||
def __init__(self, source_color: Hct):
|
||||
super().__init__(source_color)
|
||||
|
||||
# All palettes use chroma=0 (grayscale)
|
||||
# Source hue is preserved but irrelevant at chroma 0
|
||||
self.primary_palette = TonalPalette(source_color.hue, 0.0)
|
||||
self.secondary_palette = TonalPalette(source_color.hue, 0.0)
|
||||
self.tertiary_palette = TonalPalette(source_color.hue, 0.0)
|
||||
self.neutral_palette = TonalPalette(source_color.hue, 0.0)
|
||||
self.neutral_variant_palette = TonalPalette(source_color.hue, 0.0)
|
||||
|
||||
# Error palette keeps vibrant red for accessibility
|
||||
self.error_palette = TonalPalette(25.0, 84.0)
|
||||
|
||||
def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
|
||||
"""Generate scheme with monochrome-specific tone values."""
|
||||
# Monochrome uses different tones for higher contrast in grayscale
|
||||
tones = MONOCHROME_DARK_TONES if is_dark else MONOCHROME_LIGHT_TONES
|
||||
|
||||
scheme = {
|
||||
# Primary colors
|
||||
'primary': self.primary_palette.get_hex(tones['primary']),
|
||||
'on_primary': self.primary_palette.get_hex(tones['on_primary']),
|
||||
'primary_container': self.primary_palette.get_hex(tones['primary_container']),
|
||||
'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
|
||||
|
||||
# Surface tint (same as primary, used for M3 elevation tinting)
|
||||
'surface_tint': self.primary_palette.get_hex(tones['primary']),
|
||||
|
||||
# Secondary colors
|
||||
'secondary': self.secondary_palette.get_hex(tones['secondary']),
|
||||
'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
|
||||
'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
|
||||
'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
|
||||
|
||||
# Tertiary colors
|
||||
'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
|
||||
'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
|
||||
'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
|
||||
'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
|
||||
|
||||
# Error colors
|
||||
'error': self.error_palette.get_hex(tones['error']),
|
||||
'on_error': self.error_palette.get_hex(tones['on_error']),
|
||||
'error_container': self.error_palette.get_hex(tones['error_container']),
|
||||
'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
|
||||
|
||||
# Surface colors
|
||||
'surface': self.neutral_palette.get_hex(tones['surface']),
|
||||
'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
|
||||
'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
|
||||
'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
|
||||
|
||||
# Surface containers
|
||||
'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
|
||||
'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
|
||||
'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
|
||||
'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
|
||||
'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
|
||||
|
||||
# Outline and other
|
||||
'outline': self.neutral_variant_palette.get_hex(tones['outline']),
|
||||
'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
|
||||
'shadow': self.neutral_palette.get_hex(tones['shadow']),
|
||||
'scrim': self.neutral_palette.get_hex(tones['scrim']),
|
||||
|
||||
# Inverse colors
|
||||
'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
|
||||
'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
|
||||
'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
|
||||
|
||||
# Background (same as surface in MD3)
|
||||
'background': self.neutral_palette.get_hex(tones['surface']),
|
||||
'on_background': self.neutral_palette.get_hex(tones['on_surface']),
|
||||
|
||||
# Surface dim and bright
|
||||
'surface_dim': self.neutral_palette.get_hex(tones['surface']),
|
||||
'surface_bright': self.neutral_palette.get_hex(tones['surface_container_highest'] + 5),
|
||||
|
||||
# Fixed colors
|
||||
'primary_fixed': self.primary_palette.get_hex(90),
|
||||
'primary_fixed_dim': self.primary_palette.get_hex(80),
|
||||
'on_primary_fixed': self.primary_palette.get_hex(10),
|
||||
'on_primary_fixed_variant': self.primary_palette.get_hex(30),
|
||||
'secondary_fixed': self.secondary_palette.get_hex(90),
|
||||
'secondary_fixed_dim': self.secondary_palette.get_hex(80),
|
||||
'on_secondary_fixed': self.secondary_palette.get_hex(10),
|
||||
'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
|
||||
'tertiary_fixed': self.tertiary_palette.get_hex(90),
|
||||
'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
|
||||
'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
|
||||
'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
|
||||
}
|
||||
|
||||
return scheme
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
Used to make custom colors feel more cohesive with the theme.
|
||||
|
||||
Args:
|
||||
design_color: The color to adjust
|
||||
source_color: The reference color to harmonize towards
|
||||
amount: How much to shift (0-1, default 0.5)
|
||||
|
||||
Returns:
|
||||
Harmonized HCT color
|
||||
"""
|
||||
diff = _hue_difference(source_color.hue, design_color.hue)
|
||||
rotation = min(diff * amount, 15.0) # Max 15° rotation
|
||||
if _shorter_rotation(source_color.hue, design_color.hue) < 0:
|
||||
rotation = -rotation
|
||||
new_hue = (design_color.hue + rotation) % 360.0
|
||||
return Hct(new_hue, design_color.chroma, design_color.tone)
|
||||
|
||||
|
||||
def _hue_difference(hue1: float, hue2: float) -> float:
|
||||
"""Calculate the absolute difference between two hues."""
|
||||
diff = abs(hue1 - hue2)
|
||||
return min(diff, 360.0 - diff)
|
||||
|
||||
|
||||
def _shorter_rotation(from_hue: float, to_hue: float) -> float:
|
||||
"""Calculate the shorter rotation direction between hues."""
|
||||
diff = to_hue - from_hue
|
||||
if diff > 180.0:
|
||||
return diff - 360.0
|
||||
elif diff < -180.0:
|
||||
return diff + 360.0
|
||||
return diff
|
||||
@@ -0,0 +1,672 @@
|
||||
"""
|
||||
Palette extraction using K-means clustering.
|
||||
|
||||
This module provides functions for extracting dominant colors from images
|
||||
using perceptual color distance calculations and k-means clustering.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
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]:
|
||||
"""
|
||||
Downsample pixels for faster processing.
|
||||
|
||||
Takes every Nth pixel to reduce dataset size while maintaining
|
||||
color distribution characteristics.
|
||||
"""
|
||||
if factor <= 1:
|
||||
return pixels
|
||||
|
||||
# Calculate step based on factor squared (for 2D image)
|
||||
step = factor * factor
|
||||
return pixels[::step]
|
||||
|
||||
|
||||
def kmeans_cluster(
|
||||
colors: list[RGB],
|
||||
k: int = 5,
|
||||
iterations: int = 10
|
||||
) -> list[tuple[RGB, RGB, int]]:
|
||||
"""
|
||||
Perform K-means clustering on colors in Lab color space.
|
||||
|
||||
Lab space is perceptually uniform, matching matugen's approach.
|
||||
Returns list of (centroid_rgb, representative_rgb, cluster_size) tuples,
|
||||
sorted by cluster size.
|
||||
|
||||
- centroid_rgb: averaged color from the cluster (smoother, blended)
|
||||
- representative_rgb: actual image pixel closest to centroid
|
||||
"""
|
||||
if len(colors) < k:
|
||||
# Not enough colors, return what we have (same color for centroid and representative)
|
||||
unique = list(set(colors))
|
||||
return [(c, c, colors.count(c)) for c in unique[:k]]
|
||||
|
||||
# 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
|
||||
# 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_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
|
||||
for idx, color in enumerate(colors_lab):
|
||||
min_dist = float('inf')
|
||||
min_cluster = 0
|
||||
for i, centroid in enumerate(centroids):
|
||||
dist = lab_distance(color, centroid)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
min_cluster = i
|
||||
assignments[idx] = min_cluster
|
||||
|
||||
# Update centroids (simple mean in Lab space)
|
||||
new_centroids = []
|
||||
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 count, also find representative pixel (closest to centroid)
|
||||
cluster_counts = [0] * k
|
||||
cluster_representatives: list[tuple[RGB, float]] = [(colors[0], float('inf'))] * k
|
||||
|
||||
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)
|
||||
|
||||
# Return both centroid (averaged) and representative (actual pixel) colors
|
||||
results = []
|
||||
for i in range(k):
|
||||
if cluster_counts[i] > 0:
|
||||
# Convert Lab centroid back to RGB
|
||||
centroid_rgb = lab_to_rgb(*centroids[i])
|
||||
representative_rgb = cluster_representatives[i][0]
|
||||
results.append((centroid_rgb, representative_rgb, cluster_counts[i]))
|
||||
|
||||
# Sort by cluster size (most common first)
|
||||
results.sort(key=lambda x: -x[2])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _score_colors_chroma(
|
||||
colors_with_counts: list[tuple[RGB, int]],
|
||||
) -> list[tuple[Color, float]]:
|
||||
"""
|
||||
Score colors prioritizing chroma (vibrancy) over area coverage.
|
||||
|
||||
Uses count^0.3 weighting so saturated colors win even with small area.
|
||||
Used for "vibrant" mode to find the most eye-catching colors.
|
||||
|
||||
Args:
|
||||
colors_with_counts: List of (RGB, count) tuples from clustering
|
||||
|
||||
Returns:
|
||||
List of (Color, score) tuples, sorted by score descending
|
||||
"""
|
||||
result_colors = []
|
||||
for rgb, count in colors_with_counts:
|
||||
color = Color.from_rgb(rgb)
|
||||
try:
|
||||
hct = color.to_hct()
|
||||
|
||||
# Chroma contribution - prefer colorful colors
|
||||
chroma_score = hct.chroma
|
||||
|
||||
# Tone penalty - prefer mid-tones (40-60 is ideal)
|
||||
if hct.tone < 20:
|
||||
tone_penalty = (20 - hct.tone) * 2
|
||||
elif hct.tone > 80:
|
||||
tone_penalty = (hct.tone - 80) * 1.5
|
||||
elif hct.tone < 40:
|
||||
tone_penalty = (40 - hct.tone) * 0.5
|
||||
elif hct.tone > 60:
|
||||
tone_penalty = (hct.tone - 60) * 0.3
|
||||
else:
|
||||
tone_penalty = 0
|
||||
|
||||
# Hue penalty - slight penalty for yellow-green hues
|
||||
if 80 < hct.hue < 110:
|
||||
hue_penalty = 5
|
||||
else:
|
||||
hue_penalty = 0
|
||||
|
||||
# Combined score: chroma minus penalties, balanced with count
|
||||
# Using count^0.3 so chroma dominates while still considering area
|
||||
score = (chroma_score - tone_penalty - hue_penalty) * (count ** 0.3)
|
||||
result_colors.append((color, score))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
result_colors.append((color, 0.0))
|
||||
|
||||
result_colors.sort(key=lambda x: -x[1])
|
||||
return result_colors
|
||||
|
||||
|
||||
def _hue_to_family(hue: float) -> int:
|
||||
"""
|
||||
Map hue to perceptual color family.
|
||||
|
||||
Uses non-uniform ranges that match human color perception:
|
||||
- 0: RED (330-30°, wraps around)
|
||||
- 1: ORANGE (30-60°)
|
||||
- 2: YELLOW (60-105°)
|
||||
- 3: GREEN (105-190°, includes green-leaning teal)
|
||||
- 4: BLUE (190-270°, includes cyan)
|
||||
- 5: PURPLE (270-330°)
|
||||
"""
|
||||
if hue >= 330 or hue < 30:
|
||||
return 0 # RED
|
||||
elif hue < 60:
|
||||
return 1 # ORANGE
|
||||
elif hue < 105:
|
||||
return 2 # YELLOW
|
||||
elif hue < 190:
|
||||
return 3 # GREEN (includes green-leaning teal)
|
||||
elif hue < 270:
|
||||
return 4 # BLUE (includes cyan)
|
||||
else:
|
||||
return 5 # PURPLE
|
||||
|
||||
|
||||
def _score_colors_count(
|
||||
colors_with_counts: list[tuple[RGB, int]],
|
||||
) -> list[tuple[Color, float]]:
|
||||
"""
|
||||
Score colors prioritizing pixel count (area coverage) by hue family.
|
||||
|
||||
Groups colors into perceptual hue families, sums counts per family,
|
||||
then picks the dominant family. This is more faithful to human perception
|
||||
where we see "green" as a category, not individual shades.
|
||||
|
||||
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
|
||||
|
||||
# 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])
|
||||
|
||||
# Build result: colors from dominant families first, sorted by count within each family
|
||||
result_colors = []
|
||||
for family, _ in family_totals:
|
||||
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(family_totals) if f == family)
|
||||
score = (len(family_totals) - family_rank) * 1000000 + count * 1000 + chroma
|
||||
result_colors.append((color, score))
|
||||
|
||||
result_colors.sort(key=lambda x: -x[1])
|
||||
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:
|
||||
# Get max chroma in this family - we want families with vibrant colors
|
||||
max_chroma = max(c[2] for c in hue_families[family])
|
||||
distant_families.append((family, count, hue_diff, max_chroma))
|
||||
else:
|
||||
close_families.append(family)
|
||||
|
||||
# Build result: colors from distant families first
|
||||
result_colors = []
|
||||
|
||||
# Sort distant families by weighted score: hue_distance * max_chroma
|
||||
# This balances visual distinctness (hue distance) with color quality (chroma)
|
||||
# A family that's far away AND has good colors beats one that's close with great colors
|
||||
distant_families.sort(key=lambda x: -(x[2] * x[3]))
|
||||
|
||||
for family, _, _, _ in distant_families:
|
||||
family_colors = hue_families[family]
|
||||
# Sort by chroma descending - we want the most vibrant color from this family
|
||||
# Count is tiebreaker to avoid picking tiny noise clusters
|
||||
family_colors.sort(key=lambda x: (-x[2], -x[3]))
|
||||
for color, hue, chroma, count in family_colors:
|
||||
# Score encodes family rank + chroma for proper ordering
|
||||
# Chroma is primary (we want vibrant), count is tiebreaker
|
||||
family_rank = next(i for i, (f, _, _, _) in enumerate(distant_families) if f == family)
|
||||
score = (len(distant_families) - family_rank) * 1000000 + chroma * 1000 + count
|
||||
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]]:
|
||||
"""
|
||||
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
|
||||
) -> 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 (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:
|
||||
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
|
||||
elif scoring == "count":
|
||||
# Faithful mode: many clusters to capture color diversity, no pre-filtering
|
||||
# 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
|
||||
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
|
||||
cluster_count = 20
|
||||
# Filter to colorful pixels for smoother averaged results
|
||||
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 - returns (centroid_rgb, representative_rgb, count) tuples
|
||||
clusters = kmeans_cluster(filtered, k=cluster_count)
|
||||
|
||||
# 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)
|
||||
colors_for_scoring = [(c[0], c[2]) for c in clusters]
|
||||
scored = _score_colors_chroma(colors_for_scoring)
|
||||
elif scoring == "count":
|
||||
# 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]
|
||||
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]
|
||||
scored = _score_colors_population(colors_for_scoring, 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
|
||||
new_hct = Hct((primary_hct.hue + offset) % 360.0, primary_hct.chroma, primary_hct.tone)
|
||||
final_colors.append(Color.from_hct(new_hct))
|
||||
|
||||
return final_colors[:k]
|
||||
|
||||
|
||||
def find_error_color(palette: list[Color]) -> Color:
|
||||
"""
|
||||
Find or generate an error color (red-biased).
|
||||
|
||||
Looks for existing red in palette, otherwise returns a default.
|
||||
"""
|
||||
# Look for a red-ish color in the palette
|
||||
for color in palette:
|
||||
h, s, l = color.to_hsl()
|
||||
# Red hues: 0-30 or 330-360
|
||||
if (h <= 30 or h >= 330) and s > 0.4 and 0.3 < l < 0.7:
|
||||
return color
|
||||
|
||||
# Default error red
|
||||
return Color.from_hex("#FD4663")
|
||||
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
"""
|
||||
Wu and WSMeans quantizer implementations matching material-color-utilities.
|
||||
|
||||
Wu implements Xiaolin Wu's color quantization algorithm from Graphics Gems II (1991).
|
||||
WSMeans refines Wu's output via weighted k-means in Lab space (QuantizerCelebi pipeline).
|
||||
Together they match the QuantizerCelebi pipeline used by matugen/material-color-utilities.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .color import rgb_to_lab, lab_to_rgb
|
||||
|
||||
# Constants matching material-color-utilities
|
||||
INDEX_BITS = 5
|
||||
SIDE_LENGTH = 33 # (1 << INDEX_BITS) + 1
|
||||
TOTAL_SIZE = 35937 # SIDE_LENGTH^3
|
||||
|
||||
# Direction constants
|
||||
DIR_RED = 0
|
||||
DIR_GREEN = 1
|
||||
DIR_BLUE = 2
|
||||
|
||||
|
||||
class Box:
|
||||
"""Represents a box in RGB color space."""
|
||||
__slots__ = ('r0', 'r1', 'g0', 'g1', 'b0', 'b1', 'vol')
|
||||
|
||||
def __init__(self):
|
||||
self.r0 = 0
|
||||
self.r1 = 0
|
||||
self.g0 = 0
|
||||
self.g1 = 0
|
||||
self.b0 = 0
|
||||
self.b1 = 0
|
||||
self.vol = 0
|
||||
|
||||
|
||||
def _get_index(r: int, g: int, b: int) -> int:
|
||||
"""Calculate 3D array index from RGB coordinates."""
|
||||
return (r << (INDEX_BITS * 2)) + (r << (INDEX_BITS + 1)) + r + (g << INDEX_BITS) + g + b
|
||||
|
||||
|
||||
def _argb_from_rgb(r: int, g: int, b: int) -> int:
|
||||
"""Convert RGB to ARGB integer format."""
|
||||
return (255 << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
|
||||
|
||||
|
||||
def _rgb_from_argb(argb: int) -> Tuple[int, int, int]:
|
||||
"""Extract RGB from ARGB integer."""
|
||||
return ((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF)
|
||||
|
||||
|
||||
class QuantizerWu:
|
||||
"""
|
||||
Wu color quantizer implementation.
|
||||
|
||||
Divides image pixels into clusters by recursively cutting an RGB cube,
|
||||
based on the weight of pixels in each area of the cube.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.weights: List[int] = []
|
||||
self.moments_r: List[int] = []
|
||||
self.moments_g: List[int] = []
|
||||
self.moments_b: List[int] = []
|
||||
self.moments: List[float] = []
|
||||
self.cubes: List[Box] = []
|
||||
|
||||
def quantize(self, pixels: List[int], max_colors: int) -> List[int]:
|
||||
"""
|
||||
Quantize pixels to a reduced color palette.
|
||||
|
||||
Args:
|
||||
pixels: List of colors in ARGB integer format
|
||||
max_colors: Maximum number of colors to return
|
||||
|
||||
Returns:
|
||||
List of colors in ARGB format
|
||||
"""
|
||||
self._construct_histogram(pixels)
|
||||
self._compute_moments()
|
||||
result_count = self._create_boxes(max_colors)
|
||||
return self._create_result(result_count)
|
||||
|
||||
def _construct_histogram(self, pixels: List[int]):
|
||||
"""Build histogram of pixel colors."""
|
||||
self.weights = [0] * TOTAL_SIZE
|
||||
self.moments_r = [0] * TOTAL_SIZE
|
||||
self.moments_g = [0] * TOTAL_SIZE
|
||||
self.moments_b = [0] * TOTAL_SIZE
|
||||
self.moments = [0.0] * TOTAL_SIZE
|
||||
|
||||
# Count pixels by color
|
||||
count_by_color: Dict[int, int] = {}
|
||||
for pixel in pixels:
|
||||
# Only count fully opaque pixels
|
||||
if (pixel >> 24) & 0xFF == 255:
|
||||
count_by_color[pixel] = count_by_color.get(pixel, 0) + 1
|
||||
|
||||
bits_to_remove = 8 - INDEX_BITS
|
||||
for pixel, count in count_by_color.items():
|
||||
red = (pixel >> 16) & 0xFF
|
||||
green = (pixel >> 8) & 0xFF
|
||||
blue = pixel & 0xFF
|
||||
|
||||
i_r = (red >> bits_to_remove) + 1
|
||||
i_g = (green >> bits_to_remove) + 1
|
||||
i_b = (blue >> bits_to_remove) + 1
|
||||
index = _get_index(i_r, i_g, i_b)
|
||||
|
||||
self.weights[index] += count
|
||||
self.moments_r[index] += count * red
|
||||
self.moments_g[index] += count * green
|
||||
self.moments_b[index] += count * blue
|
||||
self.moments[index] += count * (red * red + green * green + blue * blue)
|
||||
|
||||
def _compute_moments(self):
|
||||
"""Compute cumulative moments for efficient volume calculations."""
|
||||
for r in range(1, SIDE_LENGTH):
|
||||
area = [0] * SIDE_LENGTH
|
||||
area_r = [0] * SIDE_LENGTH
|
||||
area_g = [0] * SIDE_LENGTH
|
||||
area_b = [0] * SIDE_LENGTH
|
||||
area2 = [0.0] * SIDE_LENGTH
|
||||
|
||||
for g in range(1, SIDE_LENGTH):
|
||||
line = 0
|
||||
line_r = 0
|
||||
line_g = 0
|
||||
line_b = 0
|
||||
line2 = 0.0
|
||||
|
||||
for b in range(1, SIDE_LENGTH):
|
||||
index = _get_index(r, g, b)
|
||||
line += self.weights[index]
|
||||
line_r += self.moments_r[index]
|
||||
line_g += self.moments_g[index]
|
||||
line_b += self.moments_b[index]
|
||||
line2 += self.moments[index]
|
||||
|
||||
area[b] += line
|
||||
area_r[b] += line_r
|
||||
area_g[b] += line_g
|
||||
area_b[b] += line_b
|
||||
area2[b] += line2
|
||||
|
||||
prev_index = _get_index(r - 1, g, b)
|
||||
self.weights[index] = self.weights[prev_index] + area[b]
|
||||
self.moments_r[index] = self.moments_r[prev_index] + area_r[b]
|
||||
self.moments_g[index] = self.moments_g[prev_index] + area_g[b]
|
||||
self.moments_b[index] = self.moments_b[prev_index] + area_b[b]
|
||||
self.moments[index] = self.moments[prev_index] + area2[b]
|
||||
|
||||
def _create_boxes(self, max_colors: int) -> int:
|
||||
"""Create color boxes by recursive cutting."""
|
||||
self.cubes = [Box() for _ in range(max_colors)]
|
||||
volume_variance = [0.0] * max_colors
|
||||
|
||||
# Initialize first box to cover entire color space
|
||||
self.cubes[0].r1 = SIDE_LENGTH - 1
|
||||
self.cubes[0].g1 = SIDE_LENGTH - 1
|
||||
self.cubes[0].b1 = SIDE_LENGTH - 1
|
||||
|
||||
generated_color_count = max_colors
|
||||
next_box = 0
|
||||
i = 1
|
||||
|
||||
while i < max_colors:
|
||||
if self._cut(self.cubes[next_box], self.cubes[i]):
|
||||
volume_variance[next_box] = (
|
||||
self._variance(self.cubes[next_box])
|
||||
if self.cubes[next_box].vol > 1 else 0.0
|
||||
)
|
||||
volume_variance[i] = (
|
||||
self._variance(self.cubes[i])
|
||||
if self.cubes[i].vol > 1 else 0.0
|
||||
)
|
||||
else:
|
||||
volume_variance[next_box] = 0.0
|
||||
i -= 1
|
||||
|
||||
# Find box with maximum variance
|
||||
next_box = 0
|
||||
temp = volume_variance[0]
|
||||
for j in range(1, i + 1):
|
||||
if volume_variance[j] > temp:
|
||||
temp = volume_variance[j]
|
||||
next_box = j
|
||||
|
||||
if temp <= 0.0:
|
||||
generated_color_count = i + 1
|
||||
break
|
||||
|
||||
i += 1
|
||||
|
||||
return generated_color_count
|
||||
|
||||
def _create_result(self, color_count: int) -> List[int]:
|
||||
"""Extract final colors from boxes."""
|
||||
colors = []
|
||||
for i in range(color_count):
|
||||
cube = self.cubes[i]
|
||||
weight = self._volume(cube, self.weights)
|
||||
if weight > 0:
|
||||
r = int(self._volume(cube, self.moments_r) / weight)
|
||||
g = int(self._volume(cube, self.moments_g) / weight)
|
||||
b = int(self._volume(cube, self.moments_b) / weight)
|
||||
color = _argb_from_rgb(r, g, b)
|
||||
colors.append(color)
|
||||
return colors
|
||||
|
||||
def _variance(self, cube: Box) -> float:
|
||||
"""Calculate variance within a box."""
|
||||
dr = self._volume(cube, self.moments_r)
|
||||
dg = self._volume(cube, self.moments_g)
|
||||
db = self._volume(cube, self.moments_b)
|
||||
|
||||
xx = (
|
||||
self.moments[_get_index(cube.r1, cube.g1, cube.b1)]
|
||||
- self.moments[_get_index(cube.r1, cube.g1, cube.b0)]
|
||||
- self.moments[_get_index(cube.r1, cube.g0, cube.b1)]
|
||||
+ self.moments[_get_index(cube.r1, cube.g0, cube.b0)]
|
||||
- self.moments[_get_index(cube.r0, cube.g1, cube.b1)]
|
||||
+ self.moments[_get_index(cube.r0, cube.g1, cube.b0)]
|
||||
+ self.moments[_get_index(cube.r0, cube.g0, cube.b1)]
|
||||
- self.moments[_get_index(cube.r0, cube.g0, cube.b0)]
|
||||
)
|
||||
|
||||
hypotenuse = dr * dr + dg * dg + db * db
|
||||
volume = self._volume(cube, self.weights)
|
||||
if volume == 0:
|
||||
return 0.0
|
||||
return xx - hypotenuse / volume
|
||||
|
||||
def _cut(self, one: Box, two: Box) -> bool:
|
||||
"""Cut a box into two boxes along the optimal axis."""
|
||||
whole_r = self._volume(one, self.moments_r)
|
||||
whole_g = self._volume(one, self.moments_g)
|
||||
whole_b = self._volume(one, self.moments_b)
|
||||
whole_w = self._volume(one, self.weights)
|
||||
|
||||
max_r_cut, max_r = self._maximize(
|
||||
one, DIR_RED, one.r0 + 1, one.r1, whole_r, whole_g, whole_b, whole_w
|
||||
)
|
||||
max_g_cut, max_g = self._maximize(
|
||||
one, DIR_GREEN, one.g0 + 1, one.g1, whole_r, whole_g, whole_b, whole_w
|
||||
)
|
||||
max_b_cut, max_b = self._maximize(
|
||||
one, DIR_BLUE, one.b0 + 1, one.b1, whole_r, whole_g, whole_b, whole_w
|
||||
)
|
||||
|
||||
if max_r >= max_g and max_r >= max_b:
|
||||
if max_r_cut < 0:
|
||||
return False
|
||||
direction = DIR_RED
|
||||
cut_location = max_r_cut
|
||||
elif max_g >= max_r and max_g >= max_b:
|
||||
direction = DIR_GREEN
|
||||
cut_location = max_g_cut
|
||||
else:
|
||||
direction = DIR_BLUE
|
||||
cut_location = max_b_cut
|
||||
|
||||
two.r1 = one.r1
|
||||
two.g1 = one.g1
|
||||
two.b1 = one.b1
|
||||
|
||||
if direction == DIR_RED:
|
||||
one.r1 = cut_location
|
||||
two.r0 = one.r1
|
||||
two.g0 = one.g0
|
||||
two.b0 = one.b0
|
||||
elif direction == DIR_GREEN:
|
||||
one.g1 = cut_location
|
||||
two.r0 = one.r0
|
||||
two.g0 = one.g1
|
||||
two.b0 = one.b0
|
||||
else: # DIR_BLUE
|
||||
one.b1 = cut_location
|
||||
two.r0 = one.r0
|
||||
two.g0 = one.g0
|
||||
two.b0 = one.b1
|
||||
|
||||
one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0)
|
||||
two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0)
|
||||
return True
|
||||
|
||||
def _maximize(
|
||||
self,
|
||||
cube: Box,
|
||||
direction: int,
|
||||
first: int,
|
||||
last: int,
|
||||
whole_r: int,
|
||||
whole_g: int,
|
||||
whole_b: int,
|
||||
whole_w: int,
|
||||
) -> Tuple[int, float]:
|
||||
"""Find the optimal cut position along an axis."""
|
||||
bottom_r = self._bottom(cube, direction, self.moments_r)
|
||||
bottom_g = self._bottom(cube, direction, self.moments_g)
|
||||
bottom_b = self._bottom(cube, direction, self.moments_b)
|
||||
bottom_w = self._bottom(cube, direction, self.weights)
|
||||
|
||||
max_val = 0.0
|
||||
cut = -1
|
||||
|
||||
for i in range(first, last):
|
||||
half_r = bottom_r + self._top(cube, direction, i, self.moments_r)
|
||||
half_g = bottom_g + self._top(cube, direction, i, self.moments_g)
|
||||
half_b = bottom_b + self._top(cube, direction, i, self.moments_b)
|
||||
half_w = bottom_w + self._top(cube, direction, i, self.weights)
|
||||
|
||||
if half_w == 0:
|
||||
continue
|
||||
|
||||
temp = (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
|
||||
|
||||
half_r = whole_r - half_r
|
||||
half_g = whole_g - half_g
|
||||
half_b = whole_b - half_b
|
||||
half_w = whole_w - half_w
|
||||
|
||||
if half_w == 0:
|
||||
continue
|
||||
|
||||
temp += (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
|
||||
|
||||
if temp > max_val:
|
||||
max_val = temp
|
||||
cut = i
|
||||
|
||||
return cut, max_val
|
||||
|
||||
def _volume(self, cube: Box, moment: List) -> int:
|
||||
"""Calculate volume sum using inclusion-exclusion."""
|
||||
return (
|
||||
moment[_get_index(cube.r1, cube.g1, cube.b1)]
|
||||
- moment[_get_index(cube.r1, cube.g1, cube.b0)]
|
||||
- moment[_get_index(cube.r1, cube.g0, cube.b1)]
|
||||
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
|
||||
- moment[_get_index(cube.r0, cube.g1, cube.b1)]
|
||||
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
|
||||
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
|
||||
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
|
||||
)
|
||||
|
||||
def _bottom(self, cube: Box, direction: int, moment: List) -> int:
|
||||
"""Calculate bottom sum for maximize."""
|
||||
if direction == DIR_RED:
|
||||
return (
|
||||
-moment[_get_index(cube.r0, cube.g1, cube.b1)]
|
||||
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
|
||||
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
|
||||
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
|
||||
)
|
||||
elif direction == DIR_GREEN:
|
||||
return (
|
||||
-moment[_get_index(cube.r1, cube.g0, cube.b1)]
|
||||
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
|
||||
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
|
||||
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
|
||||
)
|
||||
else: # DIR_BLUE
|
||||
return (
|
||||
-moment[_get_index(cube.r1, cube.g1, cube.b0)]
|
||||
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
|
||||
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
|
||||
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
|
||||
)
|
||||
|
||||
def _top(self, cube: Box, direction: int, position: int, moment: List) -> int:
|
||||
"""Calculate top sum for maximize."""
|
||||
if direction == DIR_RED:
|
||||
return (
|
||||
moment[_get_index(position, cube.g1, cube.b1)]
|
||||
- moment[_get_index(position, cube.g1, cube.b0)]
|
||||
- moment[_get_index(position, cube.g0, cube.b1)]
|
||||
+ moment[_get_index(position, cube.g0, cube.b0)]
|
||||
)
|
||||
elif direction == DIR_GREEN:
|
||||
return (
|
||||
moment[_get_index(cube.r1, position, cube.b1)]
|
||||
- moment[_get_index(cube.r1, position, cube.b0)]
|
||||
- moment[_get_index(cube.r0, position, cube.b1)]
|
||||
+ moment[_get_index(cube.r0, position, cube.b0)]
|
||||
)
|
||||
else: # DIR_BLUE
|
||||
return (
|
||||
moment[_get_index(cube.r1, cube.g1, position)]
|
||||
- moment[_get_index(cube.r1, cube.g0, position)]
|
||||
- moment[_get_index(cube.r0, cube.g1, position)]
|
||||
+ moment[_get_index(cube.r0, cube.g0, position)]
|
||||
)
|
||||
|
||||
|
||||
def quantize_wu(pixels: List[Tuple[int, int, int]], max_colors: int = 128) -> Dict[int, int]:
|
||||
"""
|
||||
Quantize RGB pixels using Wu algorithm.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
max_colors: Maximum colors to extract
|
||||
|
||||
Returns:
|
||||
Dictionary mapping ARGB colors to pixel counts
|
||||
"""
|
||||
# Convert RGB tuples to ARGB integers
|
||||
argb_pixels = [_argb_from_rgb(r, g, b) for r, g, b in pixels]
|
||||
|
||||
# Run Wu quantizer
|
||||
quantizer = QuantizerWu()
|
||||
result_colors = quantizer.quantize(argb_pixels, max_colors)
|
||||
|
||||
# Build color to count mapping in box order (matching Rust's IndexMap insertion order)
|
||||
# Wu returns colors with count 0; WSMeans uses only the keys as starting clusters
|
||||
color_to_count: Dict[int, int] = {c: 0 for c in result_colors}
|
||||
|
||||
return color_to_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WSMeans Quantizer - weighted k-means refinement in Lab space
|
||||
# =============================================================================
|
||||
|
||||
# Mask for 48-bit LCG state
|
||||
_LCG_MASK = (1 << 48) - 1
|
||||
|
||||
|
||||
class _Random:
|
||||
"""LCG matching Java's java.util.Random / material-color-utilities."""
|
||||
|
||||
def __init__(self, seed: int):
|
||||
self._seed = (seed ^ 0x5DEECE66D) & _LCG_MASK
|
||||
|
||||
def _next(self, bits: int) -> int:
|
||||
self._seed = (self._seed * 0x5DEECE66D + 0xB) & _LCG_MASK
|
||||
# Unsigned right shift: treat as unsigned 48-bit, shift, return as signed 32-bit
|
||||
val = self._seed >> (48 - bits)
|
||||
# Convert to signed 32-bit int to match Java behavior
|
||||
if val >= (1 << 31):
|
||||
val -= (1 << 32)
|
||||
return val
|
||||
|
||||
def next_range(self, range_val: int) -> int:
|
||||
if (range_val & -range_val) == range_val:
|
||||
# Power of 2
|
||||
return (range_val * self._next(31)) >> 31
|
||||
while True:
|
||||
bits = self._next(31)
|
||||
val = bits % range_val
|
||||
if bits - val + (range_val - 1) >= 0:
|
||||
return val
|
||||
|
||||
|
||||
def _lab_distance_squared(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> float:
|
||||
"""Squared Euclidean distance in Lab space (no sqrt)."""
|
||||
dL = a[0] - b[0]
|
||||
da = a[1] - b[1]
|
||||
db = a[2] - b[2]
|
||||
return dL * dL + da * da + db * db
|
||||
|
||||
|
||||
def quantize_wsmeans(
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
max_colors: int,
|
||||
starting_clusters: List[int],
|
||||
) -> Dict[int, int]:
|
||||
"""
|
||||
Refine quantized colors via weighted k-means in Lab space.
|
||||
|
||||
Port of QuantizerWsmeans from material-colors-0.4.2 Rust crate.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples (original image pixels)
|
||||
max_colors: Maximum number of colors
|
||||
starting_clusters: List of ARGB colors from Wu quantizer
|
||||
|
||||
Returns:
|
||||
Dictionary mapping ARGB colors to pixel counts
|
||||
"""
|
||||
# Deduplicate pixels, build count map and Lab points
|
||||
pixel_to_count: Dict[int, int] = {}
|
||||
unique_pixels: List[int] = [] # ARGB values in insertion order
|
||||
points: List[Tuple[float, float, float]] = [] # Lab coordinates
|
||||
|
||||
for r, g, b in pixels:
|
||||
argb = _argb_from_rgb(r, g, b)
|
||||
if argb in pixel_to_count:
|
||||
pixel_to_count[argb] += 1
|
||||
else:
|
||||
unique_pixels.append(argb)
|
||||
points.append(rgb_to_lab(r, g, b))
|
||||
pixel_to_count[argb] = 1
|
||||
|
||||
cluster_count = min(max_colors, len(points))
|
||||
if cluster_count == 0:
|
||||
return {}
|
||||
|
||||
# Convert starting clusters from ARGB to Lab
|
||||
clusters: List[Tuple[float, float, float]] = []
|
||||
for argb in starting_clusters:
|
||||
cr, cg, cb = _rgb_from_argb(argb)
|
||||
clusters.append(rgb_to_lab(cr, cg, cb))
|
||||
|
||||
# Fill remaining clusters with actual image pixels using seeded LCG
|
||||
additional_needed = cluster_count - len(clusters)
|
||||
if additional_needed > 0:
|
||||
rng = _Random(0x42688)
|
||||
indices: List[int] = []
|
||||
for _ in range(additional_needed):
|
||||
index = rng.next_range(len(points))
|
||||
while index in indices:
|
||||
index = rng.next_range(len(points))
|
||||
indices.append(index)
|
||||
for index in indices:
|
||||
clusters.append(points[index])
|
||||
|
||||
# Initialize assignments
|
||||
cluster_indices = [i % cluster_count for i in range(len(points))]
|
||||
|
||||
# Distance matrix and sorted index matrix
|
||||
distance_to_index_matrix: List[List[List]] = [
|
||||
[[0.0, j] for j in range(cluster_count)]
|
||||
for _ in range(cluster_count)
|
||||
]
|
||||
pixel_count_sums = [0] * cluster_count
|
||||
|
||||
for iteration in range(10):
|
||||
points_moved = 0
|
||||
|
||||
# Compute inter-cluster distance matrix
|
||||
for i in range(cluster_count):
|
||||
for j in range(i + 1, cluster_count):
|
||||
dist = _lab_distance_squared(clusters[i], clusters[j])
|
||||
distance_to_index_matrix[j][i][0] = dist
|
||||
distance_to_index_matrix[j][i][1] = i
|
||||
distance_to_index_matrix[i][j][0] = dist
|
||||
distance_to_index_matrix[i][j][1] = j
|
||||
|
||||
# Sort row by distance
|
||||
distance_to_index_matrix[i].sort(key=lambda x: x[0])
|
||||
|
||||
# Assignment step: find nearest cluster for each point
|
||||
for i in range(len(points)):
|
||||
point = points[i]
|
||||
prev_idx = cluster_indices[i]
|
||||
prev_dist = _lab_distance_squared(point, clusters[prev_idx])
|
||||
|
||||
min_dist = prev_dist
|
||||
new_idx = -1
|
||||
|
||||
for j in range(cluster_count):
|
||||
# Triangle inequality: skip if inter-cluster dist >= 4 * current dist
|
||||
if distance_to_index_matrix[prev_idx][j][0] >= 4.0 * prev_dist:
|
||||
continue
|
||||
|
||||
dist = _lab_distance_squared(point, clusters[j])
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
new_idx = j
|
||||
|
||||
if new_idx != -1:
|
||||
points_moved += 1
|
||||
cluster_indices[i] = new_idx
|
||||
|
||||
# Early stop
|
||||
if points_moved == 0 and iteration > 0:
|
||||
break
|
||||
|
||||
# Update step: compute new centroids as weighted mean in Lab space
|
||||
component_l = [0.0] * cluster_count
|
||||
component_a = [0.0] * cluster_count
|
||||
component_b = [0.0] * cluster_count
|
||||
for k in range(cluster_count):
|
||||
pixel_count_sums[k] = 0
|
||||
|
||||
for i in range(len(points)):
|
||||
cidx = cluster_indices[i]
|
||||
pt = points[i]
|
||||
count = pixel_to_count[unique_pixels[i]]
|
||||
pixel_count_sums[cidx] += count
|
||||
component_l[cidx] += pt[0] * count
|
||||
component_a[cidx] += pt[1] * count
|
||||
component_b[cidx] += pt[2] * count
|
||||
|
||||
for i in range(cluster_count):
|
||||
count = pixel_count_sums[i]
|
||||
if count == 0:
|
||||
clusters[i] = (0.0, 0.0, 0.0)
|
||||
else:
|
||||
clusters[i] = (
|
||||
component_l[i] / count,
|
||||
component_a[i] / count,
|
||||
component_b[i] / count,
|
||||
)
|
||||
|
||||
# Build result: convert cluster centroids from Lab to ARGB with populations
|
||||
cluster_argbs: List[int] = []
|
||||
cluster_populations: List[int] = []
|
||||
|
||||
for i in range(cluster_count):
|
||||
count = pixel_count_sums[i]
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
lab = clusters[i]
|
||||
cr, cg, cb = lab_to_rgb(lab[0], lab[1], lab[2])
|
||||
argb = _argb_from_rgb(cr, cg, cb)
|
||||
|
||||
if argb in cluster_argbs:
|
||||
continue
|
||||
|
||||
cluster_argbs.append(argb)
|
||||
cluster_populations.append(count)
|
||||
|
||||
color_to_count: Dict[int, int] = {}
|
||||
for i in range(len(cluster_argbs)):
|
||||
color_to_count[cluster_argbs[i]] = cluster_populations[i]
|
||||
|
||||
return color_to_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Score Algorithm - ranks colors for UI theme suitability
|
||||
# =============================================================================
|
||||
|
||||
# Score constants matching material-color-utilities
|
||||
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
|
||||
FALLBACK_COLOR_ARGB = 0xFF4285F4 # Google Blue
|
||||
|
||||
|
||||
def _sanitize_degrees(degrees: float) -> int:
|
||||
"""Sanitize degrees to 0-359 range."""
|
||||
return int(degrees) % 360
|
||||
|
||||
|
||||
def _difference_degrees(a: float, b: float) -> float:
|
||||
"""Calculate the shortest distance between two angles."""
|
||||
diff = abs(a - b)
|
||||
return min(diff, 360.0 - diff)
|
||||
|
||||
|
||||
def score_colors(
|
||||
color_to_population: Dict[int, int],
|
||||
desired: int = 4,
|
||||
fallback_color: int = FALLBACK_COLOR_ARGB,
|
||||
filter_colors: bool = True,
|
||||
) -> List[int]:
|
||||
"""
|
||||
Rank colors based on suitability for UI themes.
|
||||
|
||||
Given a map of colors to population counts, removes unsuitable colors
|
||||
and ranks the rest based on chroma and proportion.
|
||||
|
||||
Args:
|
||||
color_to_population: Dict mapping ARGB colors to pixel counts
|
||||
desired: Maximum number of colors to return
|
||||
fallback_color: Color to return if no suitable colors found
|
||||
filter_colors: Whether to filter out low-chroma/low-proportion colors
|
||||
|
||||
Returns:
|
||||
List of ARGB colors sorted by suitability (best first)
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .hct import Cam16, Hct
|
||||
|
||||
# Build HCT colors and hue population histogram
|
||||
colors_hct: List[Tuple[int, Hct]] = []
|
||||
hue_population = [0] * 360
|
||||
population_sum = 0
|
||||
|
||||
for argb, population in color_to_population.items():
|
||||
r = (argb >> 16) & 0xFF
|
||||
g = (argb >> 8) & 0xFF
|
||||
b = argb & 0xFF
|
||||
|
||||
try:
|
||||
hct = Hct.from_rgb(r, g, b)
|
||||
colors_hct.append((argb, hct))
|
||||
hue = _sanitize_degrees(hct.hue)
|
||||
hue_population[hue] += population
|
||||
population_sum += population
|
||||
except (ValueError, ZeroDivisionError):
|
||||
continue
|
||||
|
||||
if not colors_hct or population_sum == 0:
|
||||
return [fallback_color]
|
||||
|
||||
# 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
|
||||
for offset in range(-14, 16):
|
||||
neighbor_hue = _sanitize_degrees(hue + offset)
|
||||
hue_excited_proportions[neighbor_hue] += proportion
|
||||
|
||||
# Score each color
|
||||
scored_hct: List[Tuple[int, Hct, float]] = []
|
||||
for argb, hct in colors_hct:
|
||||
hue = _sanitize_degrees(round(hct.hue))
|
||||
proportion = hue_excited_proportions[hue]
|
||||
|
||||
# Filter by chroma and proportion
|
||||
if filter_colors:
|
||||
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
|
||||
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_hct.append((argb, hct, score))
|
||||
|
||||
if not scored_hct:
|
||||
return [fallback_color]
|
||||
|
||||
# Sort by score descending
|
||||
scored_hct.sort(key=lambda x: -x[2])
|
||||
|
||||
# Deduplicate by hue distance - maximize hue diversity
|
||||
# Start at 90° (max for 4 colors), decrease to 15° minimum
|
||||
chosen_colors: List[Tuple[int, Hct]] = []
|
||||
|
||||
for diff_degrees in range(90, 14, -1):
|
||||
chosen_colors.clear()
|
||||
for argb, hct, score in scored_hct:
|
||||
# Check if this hue is far enough from all chosen colors
|
||||
is_duplicate = False
|
||||
for chosen_argb, chosen_hct in chosen_colors:
|
||||
if _difference_degrees(hct.hue, chosen_hct.hue) < diff_degrees:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
chosen_colors.append((argb, hct))
|
||||
|
||||
if len(chosen_colors) >= desired:
|
||||
break
|
||||
|
||||
if len(chosen_colors) >= desired:
|
||||
break
|
||||
|
||||
if not chosen_colors:
|
||||
return [fallback_color]
|
||||
|
||||
return [argb for argb, hct in chosen_colors]
|
||||
|
||||
|
||||
def extract_source_color(
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
fallback_color: int = FALLBACK_COLOR_ARGB,
|
||||
) -> int:
|
||||
"""
|
||||
Extract the primary source color from image pixels.
|
||||
|
||||
Uses Wu + WSMeans quantizer (QuantizerCelebi) + Score algorithm matching
|
||||
matugen/material-color-utilities.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
fallback_color: Color to return if extraction fails
|
||||
|
||||
Returns:
|
||||
Source color in ARGB format
|
||||
"""
|
||||
from .hct import Cam16
|
||||
|
||||
if not pixels:
|
||||
return fallback_color
|
||||
|
||||
# Quantize using Wu + WSMeans (QuantizerCelebi pipeline like matugen)
|
||||
wu_result = quantize_wu(pixels, max_colors=128)
|
||||
starting_clusters = list(wu_result.keys())
|
||||
color_to_count = quantize_wsmeans(pixels, 128, starting_clusters)
|
||||
|
||||
# Filter out low-chroma colors before scoring (like matugen)
|
||||
filtered = {}
|
||||
for argb, count in color_to_count.items():
|
||||
r = (argb >> 16) & 0xFF
|
||||
g = (argb >> 8) & 0xFF
|
||||
b = argb & 0xFF
|
||||
try:
|
||||
cam = Cam16.from_rgb(r, g, b)
|
||||
if cam.chroma >= 5.0:
|
||||
filtered[argb] = count
|
||||
except (ValueError, ZeroDivisionError):
|
||||
continue
|
||||
|
||||
if not filtered:
|
||||
filtered = color_to_count
|
||||
|
||||
# Score and rank colors
|
||||
ranked = score_colors(filtered, desired=4, fallback_color=fallback_color)
|
||||
|
||||
return ranked[0] if ranked else fallback_color
|
||||
|
||||
|
||||
def source_color_to_rgb(argb: int) -> Tuple[int, int, int]:
|
||||
"""Convert ARGB integer to RGB tuple."""
|
||||
return _rgb_from_argb(argb)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Predefined scheme expansion - Convert 14-color schemes to full palette.
|
||||
|
||||
This module expands predefined color schemes (like Tokyo-Night) from their
|
||||
14 core colors to the full 48-color palette used by templates.
|
||||
|
||||
Input format (14 colors):
|
||||
mPrimary, mOnPrimary, mSecondary, mOnSecondary, mTertiary, mOnTertiary,
|
||||
mError, mOnError, mSurface, mOnSurface, mSurfaceVariant, mOnSurfaceVariant,
|
||||
mOutline, mHover
|
||||
|
||||
Output: Full 48-color palette matching generate_theme() output.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from .color import Color
|
||||
from .contrast import ensure_contrast
|
||||
|
||||
ThemeMode = Literal["dark", "light"]
|
||||
|
||||
|
||||
def _hex_to_color(hex_str: str) -> Color:
|
||||
"""Convert hex string to Color object."""
|
||||
hex_str = hex_str.lstrip("#")
|
||||
r = int(hex_str[0:2], 16)
|
||||
g = int(hex_str[2:4], 16)
|
||||
b = int(hex_str[4:6], 16)
|
||||
return Color(r, g, b)
|
||||
|
||||
|
||||
def _make_container_dark(base: Color) -> Color:
|
||||
"""Generate container color for dark mode."""
|
||||
h, s, l = base.to_hsl()
|
||||
return Color.from_hsl(h, min(s + 0.15, 1.0), max(l - 0.35, 0.15))
|
||||
|
||||
|
||||
def _make_container_light(base: Color) -> Color:
|
||||
"""Generate container color for light mode."""
|
||||
h, s, l = base.to_hsl()
|
||||
return Color.from_hsl(h, max(s - 0.20, 0.30), min(l + 0.35, 0.85))
|
||||
|
||||
|
||||
def _make_fixed_dark(base: Color) -> tuple[Color, Color]:
|
||||
"""Generate fixed and fixed_dim colors for dark mode."""
|
||||
h, s, _ = base.to_hsl()
|
||||
fixed = Color.from_hsl(h, max(s, 0.70), 0.85)
|
||||
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75)
|
||||
return fixed, fixed_dim
|
||||
|
||||
|
||||
def _make_fixed_light(base: Color) -> tuple[Color, Color]:
|
||||
"""Generate fixed and fixed_dim colors for light mode."""
|
||||
h, s, _ = base.to_hsl()
|
||||
fixed = Color.from_hsl(h, max(s, 0.70), 0.40)
|
||||
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30)
|
||||
return fixed, fixed_dim
|
||||
|
||||
|
||||
def _interpolate_color(c1: Color, c2: Color, t: float) -> Color:
|
||||
"""Interpolate between two colors. t=0 returns c1, t=1 returns c2."""
|
||||
r = int(c1.r + (c2.r - c1.r) * t)
|
||||
g = int(c1.g + (c2.g - c1.g) * t)
|
||||
b = int(c1.b + (c2.b - c1.b) * t)
|
||||
return Color(max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
|
||||
|
||||
|
||||
def expand_predefined_scheme(scheme_data: dict[str, str], mode: ThemeMode) -> dict[str, str]:
|
||||
"""
|
||||
Expand 14-color predefined scheme to full 48-color palette.
|
||||
|
||||
Args:
|
||||
scheme_data: Dictionary with keys like mPrimary, mSecondary, etc.
|
||||
mode: "dark" or "light"
|
||||
|
||||
Returns:
|
||||
Dictionary with all 48 color names mapped to hex values.
|
||||
"""
|
||||
is_dark = mode == "dark"
|
||||
|
||||
# Parse input colors
|
||||
primary = _hex_to_color(scheme_data["mPrimary"])
|
||||
on_primary = _hex_to_color(scheme_data["mOnPrimary"])
|
||||
secondary = _hex_to_color(scheme_data["mSecondary"])
|
||||
on_secondary = _hex_to_color(scheme_data["mOnSecondary"])
|
||||
tertiary = _hex_to_color(scheme_data["mTertiary"])
|
||||
on_tertiary = _hex_to_color(scheme_data["mOnTertiary"])
|
||||
error = _hex_to_color(scheme_data["mError"])
|
||||
on_error = _hex_to_color(scheme_data["mOnError"])
|
||||
surface = _hex_to_color(scheme_data["mSurface"])
|
||||
on_surface = _hex_to_color(scheme_data["mOnSurface"])
|
||||
surface_variant = _hex_to_color(scheme_data["mSurfaceVariant"])
|
||||
on_surface_variant = _hex_to_color(scheme_data["mOnSurfaceVariant"])
|
||||
outline_raw = _hex_to_color(scheme_data["mOutline"])
|
||||
shadow = _hex_to_color(scheme_data.get("mShadow", scheme_data["mSurface"]))
|
||||
|
||||
# Generate container colors
|
||||
if is_dark:
|
||||
primary_container = _make_container_dark(primary)
|
||||
secondary_container = _make_container_dark(secondary)
|
||||
tertiary_container = _make_container_dark(tertiary)
|
||||
error_container = _make_container_dark(error)
|
||||
else:
|
||||
primary_container = _make_container_light(primary)
|
||||
secondary_container = _make_container_light(secondary)
|
||||
tertiary_container = _make_container_light(tertiary)
|
||||
error_container = _make_container_light(error)
|
||||
|
||||
# Generate "on container" colors with proper contrast
|
||||
primary_h, primary_s, _ = primary.to_hsl()
|
||||
secondary_h, secondary_s, _ = secondary.to_hsl()
|
||||
tertiary_h, tertiary_s, _ = tertiary.to_hsl()
|
||||
error_h, error_s, _ = error.to_hsl()
|
||||
|
||||
if is_dark:
|
||||
# Light text on dark containers
|
||||
on_primary_container = ensure_contrast(
|
||||
Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5
|
||||
)
|
||||
on_secondary_container = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, secondary_s, 0.90), secondary_container, 4.5
|
||||
)
|
||||
on_tertiary_container = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, tertiary_s, 0.90), tertiary_container, 4.5
|
||||
)
|
||||
on_error_container = ensure_contrast(
|
||||
Color.from_hsl(error_h, error_s, 0.90), error_container, 4.5
|
||||
)
|
||||
else:
|
||||
# Dark text on light containers
|
||||
on_primary_container = ensure_contrast(
|
||||
Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5
|
||||
)
|
||||
on_secondary_container = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, secondary_s, 0.15), secondary_container, 4.5
|
||||
)
|
||||
on_tertiary_container = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, tertiary_s, 0.15), tertiary_container, 4.5
|
||||
)
|
||||
on_error_container = ensure_contrast(
|
||||
Color.from_hsl(error_h, error_s, 0.15), error_container, 4.5
|
||||
)
|
||||
|
||||
# Generate fixed colors
|
||||
if is_dark:
|
||||
primary_fixed, primary_fixed_dim = _make_fixed_dark(primary)
|
||||
secondary_fixed, secondary_fixed_dim = _make_fixed_dark(secondary)
|
||||
tertiary_fixed, tertiary_fixed_dim = _make_fixed_dark(tertiary)
|
||||
else:
|
||||
primary_fixed, primary_fixed_dim = _make_fixed_light(primary)
|
||||
secondary_fixed, secondary_fixed_dim = _make_fixed_light(secondary)
|
||||
tertiary_fixed, tertiary_fixed_dim = _make_fixed_light(tertiary)
|
||||
|
||||
# Generate "on fixed" colors
|
||||
if is_dark:
|
||||
on_primary_fixed = ensure_contrast(
|
||||
Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5
|
||||
)
|
||||
on_primary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5
|
||||
)
|
||||
on_secondary_fixed = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, 0.15, 0.15), secondary_fixed, 4.5
|
||||
)
|
||||
on_secondary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, 0.15, 0.20), secondary_fixed_dim, 4.5
|
||||
)
|
||||
on_tertiary_fixed = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, 0.15, 0.15), tertiary_fixed, 4.5
|
||||
)
|
||||
on_tertiary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, 0.15, 0.20), tertiary_fixed_dim, 4.5
|
||||
)
|
||||
else:
|
||||
on_primary_fixed = ensure_contrast(
|
||||
Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5
|
||||
)
|
||||
on_primary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5
|
||||
)
|
||||
on_secondary_fixed = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, 0.15, 0.90), secondary_fixed, 4.5
|
||||
)
|
||||
on_secondary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(secondary_h, 0.15, 0.85), secondary_fixed_dim, 4.5
|
||||
)
|
||||
on_tertiary_fixed = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, 0.15, 0.90), tertiary_fixed, 4.5
|
||||
)
|
||||
on_tertiary_fixed_variant = ensure_contrast(
|
||||
Color.from_hsl(tertiary_h, 0.15, 0.85), tertiary_fixed_dim, 4.5
|
||||
)
|
||||
|
||||
# Generate surface containers using mSurfaceVariant as the middle container
|
||||
# This respects the scheme author's color choices
|
||||
surface_h, surface_s, surface_l = surface.to_hsl()
|
||||
sv_h, sv_s, sv_l = surface_variant.to_hsl()
|
||||
|
||||
# surface_container = mSurfaceVariant (direct assignment)
|
||||
surface_container = surface_variant
|
||||
|
||||
if is_dark:
|
||||
# Dark mode: surface is darkest, surface_variant is the middle container
|
||||
# Lower containers interpolate between surface and surface_variant
|
||||
surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
|
||||
surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
|
||||
# Higher containers go beyond surface_variant (lighter)
|
||||
surface_container_high = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.04, 0.40))
|
||||
surface_container_highest = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.08, 0.45))
|
||||
# Dim is darker than surface, bright is lighter than highest container
|
||||
surface_dim = Color.from_hsl(surface_h, surface_s, max(surface_l - 0.04, 0.02))
|
||||
surface_bright = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.12, 0.50))
|
||||
else:
|
||||
# Light mode: surface is lightest, surface_variant is the middle container
|
||||
# Lower containers interpolate between surface and surface_variant
|
||||
surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
|
||||
surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
|
||||
# Higher containers go beyond surface_variant (darker)
|
||||
surface_container_high = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.04, 0.60))
|
||||
surface_container_highest = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.08, 0.55))
|
||||
# Dim is darker than highest, bright is lighter than surface
|
||||
surface_dim = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.12, 0.50))
|
||||
surface_bright = Color.from_hsl(surface_h, surface_s, min(surface_l + 0.03, 0.98))
|
||||
|
||||
# Ensure outline has sufficient contrast against surface (3:1 minimum for UI)
|
||||
outline = ensure_contrast(outline_raw, surface, 3.0)
|
||||
|
||||
# Generate outline variant
|
||||
outline_h, outline_s, outline_l = outline.to_hsl()
|
||||
if is_dark:
|
||||
outline_variant = Color.from_hsl(outline_h, outline_s, max(outline_l - 0.15, 0.1))
|
||||
else:
|
||||
outline_variant = Color.from_hsl(outline_h, outline_s, min(outline_l + 0.15, 0.9))
|
||||
|
||||
# Scrim is always black
|
||||
scrim = Color(0, 0, 0)
|
||||
|
||||
# Inverse colors
|
||||
if is_dark:
|
||||
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.90)
|
||||
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.15)
|
||||
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
|
||||
else:
|
||||
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
|
||||
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
|
||||
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
|
||||
|
||||
# Background is same as surface in MD3
|
||||
background = surface
|
||||
on_background = on_surface
|
||||
|
||||
return {
|
||||
# Primary
|
||||
"primary": primary.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.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.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 inject_terminal_colors(result: dict[str, str], scheme_mode_data: dict) -> dict[str, str]:
|
||||
"""Flatten scheme's terminal section into template-ready color keys.
|
||||
|
||||
Adds keys like terminal_foreground, terminal_normal_black, terminal_bright_red, etc.
|
||||
so predefined terminal templates can reference them as
|
||||
{{colors.terminal_foreground.default.hex_stripped}}.
|
||||
|
||||
Args:
|
||||
result: Expanded color palette dict to augment.
|
||||
scheme_mode_data: Raw scheme JSON mode data (e.g., scheme_data["dark"]).
|
||||
|
||||
Returns:
|
||||
The same result dict with terminal_ keys added.
|
||||
"""
|
||||
terminal = scheme_mode_data.get("terminal")
|
||||
if not terminal:
|
||||
return result
|
||||
|
||||
# Map of JSON keys to flattened key names
|
||||
direct_keys = {
|
||||
"foreground": "terminal_foreground",
|
||||
"background": "terminal_background",
|
||||
"cursor": "terminal_cursor",
|
||||
"cursorText": "terminal_cursor_text",
|
||||
"selectionFg": "terminal_selection_fg",
|
||||
"selectionBg": "terminal_selection_bg",
|
||||
}
|
||||
|
||||
for json_key, flat_key in direct_keys.items():
|
||||
if json_key in terminal:
|
||||
result[flat_key] = terminal[json_key]
|
||||
|
||||
# ANSI normal/bright color groups
|
||||
for group in ("normal", "bright"):
|
||||
if group in terminal:
|
||||
for name, hex_val in terminal[group].items():
|
||||
result[f"terminal_{group}_{name}"] = hex_val
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,878 @@
|
||||
"""
|
||||
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
|
||||
- monochrome: Pure grayscale M3 scheme (chroma = 0)
|
||||
- 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
|
||||
|
||||
from .color import Color, shift_hue, hue_distance, adjust_surface
|
||||
from .contrast import ensure_contrast
|
||||
from .material import SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow, SchemeContent, SchemeMonochrome
|
||||
from .palette import find_error_color
|
||||
|
||||
# Type aliases
|
||||
ThemeMode = Literal["dark", "light"]
|
||||
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "monochrome", "vibrant", "faithful", "muted"]
|
||||
|
||||
# Map scheme type strings to classes
|
||||
SCHEME_CLASSES = {
|
||||
"tonal-spot": SchemeTonalSpot,
|
||||
"fruit-salad": SchemeFruitSalad,
|
||||
"rainbow": SchemeRainbow,
|
||||
"content": SchemeContent,
|
||||
"monochrome": SchemeMonochrome,
|
||||
# "vibrant", "faithful", and "muted" use generate_*_* functions, not a scheme class
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
# 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], scheme_type: str = "tonal-spot") -> dict[str, str]:
|
||||
"""
|
||||
Generate Material Design 3 light theme from palette using HCT color space.
|
||||
|
||||
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)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
def generate_normal_dark(palette: list[Color]) -> dict[str, str]:
|
||||
"""
|
||||
Generate wallust-style dark theme from palette.
|
||||
|
||||
More vibrant than Material - uses palette colors directly and keeps
|
||||
surfaces saturated with the primary hue. Outputs same keys as Material.
|
||||
"""
|
||||
# Use extracted colors directly (wallust style)
|
||||
# But check if colors are distinct enough - if not, derive from primary
|
||||
primary = palette[0] if palette else Color(255, 245, 155)
|
||||
primary_h, primary_s, primary_l = primary.to_hsl()
|
||||
|
||||
# Secondary: use palette[1] only if hue is >30° different, otherwise derive
|
||||
MIN_HUE_DISTANCE = 30
|
||||
if len(palette) > 1:
|
||||
sec_h, _, _ = palette[1].to_hsl()
|
||||
if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
|
||||
secondary = palette[1]
|
||||
else:
|
||||
# Colors too similar - shift hue by 30° to stay in same color family
|
||||
secondary = shift_hue(primary, 30)
|
||||
else:
|
||||
secondary = shift_hue(primary, 30)
|
||||
|
||||
# Tertiary: use palette[2] only if hue is >30° different from both primary and secondary
|
||||
if len(palette) > 2:
|
||||
ter_h, _, _ = palette[2].to_hsl()
|
||||
sec_h, _, _ = secondary.to_hsl()
|
||||
if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
|
||||
tertiary = palette[2]
|
||||
else:
|
||||
# Colors too similar - shift hue by 60° from primary to stay closer to original
|
||||
tertiary = shift_hue(primary, 60)
|
||||
else:
|
||||
tertiary = shift_hue(primary, 60)
|
||||
|
||||
error = find_error_color(palette)
|
||||
|
||||
# Keep colors vibrant - preserve saturation
|
||||
h, s, l = primary.to_hsl()
|
||||
primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(l, 0.65))
|
||||
|
||||
h, s, l = secondary.to_hsl()
|
||||
secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(l, 0.60))
|
||||
|
||||
h, s, l = tertiary.to_hsl()
|
||||
tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(l, 0.60))
|
||||
|
||||
# Container colors - darker, more saturated versions of accent colors
|
||||
def make_container_dark(base: Color) -> Color:
|
||||
h, s, l = base.to_hsl()
|
||||
return Color.from_hsl(h, min(s + 0.15, 1.0), 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: COLORFUL dark - a deep, saturated version of primary
|
||||
# Heuristic: Shift Cyan (160-200) slightly towards Blue (+10) to avoid "Teal" look
|
||||
surface_hue, s, _ = palette[0].to_hsl()
|
||||
if 160 <= surface_hue <= 200:
|
||||
surface_hue = (surface_hue + 10) % 360
|
||||
|
||||
# Reduce saturation for warm hues (red/orange/yellow) - they feel overwhelming as surfaces
|
||||
# Warm hues: 0-60 and 300-360
|
||||
if surface_hue < 60 or surface_hue > 300:
|
||||
surface_saturation_cap = 0.35 # More desaturated for warm colors
|
||||
elif 60 <= surface_hue < 120:
|
||||
surface_saturation_cap = 0.50 # Moderate for yellow-greens
|
||||
else:
|
||||
surface_saturation_cap = 0.90 # Keep cool colors vibrant
|
||||
|
||||
base_surface = Color.from_hsl(surface_hue, min(s, surface_saturation_cap), 0.5)
|
||||
|
||||
# Preserving saturation (up to the cap) to be true to primary color
|
||||
surface = adjust_surface(base_surface, surface_saturation_cap, 0.12)
|
||||
surface_variant = adjust_surface(base_surface, min(0.80, surface_saturation_cap), 0.16)
|
||||
|
||||
# Surface containers - progressive lightness for visual hierarchy (keep primary hue)
|
||||
surface_container_lowest = adjust_surface(base_surface, 0.85, 0.06)
|
||||
surface_container_low = adjust_surface(base_surface, 0.85, 0.10)
|
||||
surface_container = adjust_surface(base_surface, 0.70, 0.20)
|
||||
surface_container_high = adjust_surface(base_surface, 0.75, 0.18)
|
||||
surface_container_highest = adjust_surface(base_surface, 0.70, 0.22)
|
||||
|
||||
# Text colors - desaturated
|
||||
text_h, _, _ = palette[0].to_hsl()
|
||||
base_on_surface = Color.from_hsl(text_h, 0.05, 0.95)
|
||||
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
|
||||
|
||||
base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.70)
|
||||
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
|
||||
|
||||
outline = ensure_contrast(adjust_surface(palette[0], 0.10, 0.30), surface, 3.0)
|
||||
outline_variant = ensure_contrast(adjust_surface(palette[0], 0.10, 0.40), surface, 3.0)
|
||||
|
||||
# Contrasting foregrounds - dark text on bright accent colors
|
||||
dark_fg = Color.from_hsl(palette[0].to_hsl()[0], 0.20, 0.12) # Darker for better contrast
|
||||
on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0) # Higher contrast target
|
||||
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 - light text on dark containers, tinted with respective color
|
||||
# Explicitly prefer_light=True since containers in dark mode are dark
|
||||
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5, prefer_light=True)
|
||||
sec_h, sec_s, _ = secondary.to_hsl()
|
||||
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.90), secondary_container, 4.5, prefer_light=True)
|
||||
ter_h, ter_s, _ = tertiary.to_hsl()
|
||||
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.90), tertiary_container, 4.5, prefer_light=True)
|
||||
err_h, err_s, _ = error.to_hsl()
|
||||
on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.90), error_container, 4.5, prefer_light=True)
|
||||
|
||||
# Shadow and scrim
|
||||
shadow = surface
|
||||
scrim = Color(0, 0, 0) # Pure black
|
||||
|
||||
# Inverse colors - for inverted surfaces (light surface on dark theme)
|
||||
inv_h = palette[0].to_hsl()[0]
|
||||
inverse_surface = Color.from_hsl(inv_h, 0.08, 0.90)
|
||||
inverse_on_surface = Color.from_hsl(inv_h, 0.05, 0.15)
|
||||
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
|
||||
|
||||
# Background aliases (same as surface in MD3)
|
||||
background = surface
|
||||
on_background = on_surface
|
||||
|
||||
# Fixed colors - high-chroma accents consistent across light/dark
|
||||
# In dark mode: lighter versions of accent colors
|
||||
def make_fixed_dark(base: Color) -> tuple[Color, Color]:
|
||||
h, s, _ = base.to_hsl()
|
||||
fixed = Color.from_hsl(h, max(s, 0.70), 0.85) # Light, saturated
|
||||
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75) # Slightly darker
|
||||
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 - dark text on light fixed colors
|
||||
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5)
|
||||
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5)
|
||||
on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.15), secondary_fixed, 4.5)
|
||||
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.20), secondary_fixed_dim, 4.5)
|
||||
on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.15), tertiary_fixed, 4.5)
|
||||
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.20), tertiary_fixed_dim, 4.5)
|
||||
|
||||
# Surface dim - darker than surface for dimmed areas
|
||||
surface_dim = adjust_surface(base_surface, 0.85, 0.08)
|
||||
# Surface bright - lighter than surface
|
||||
surface_bright = adjust_surface(base_surface, 0.75, 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(),
|
||||
"surface_tint": primary_adjusted.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_normal_light(palette: list[Color]) -> dict[str, str]:
|
||||
"""
|
||||
Generate wallust-style light theme from palette.
|
||||
|
||||
More vibrant than Material - uses palette colors directly and keeps
|
||||
surfaces saturated with the primary hue. Outputs same keys as Material.
|
||||
"""
|
||||
# Use extracted colors directly, but check if distinct enough
|
||||
primary = palette[0] if palette else Color(93, 101, 245)
|
||||
primary_h, _, _ = primary.to_hsl()
|
||||
|
||||
# Secondary: use palette[1] only if hue is >30° different
|
||||
MIN_HUE_DISTANCE = 30
|
||||
if len(palette) > 1:
|
||||
sec_h, _, _ = palette[1].to_hsl()
|
||||
if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
|
||||
secondary = palette[1]
|
||||
else:
|
||||
secondary = shift_hue(primary, 30)
|
||||
else:
|
||||
secondary = shift_hue(primary, 30)
|
||||
|
||||
# Tertiary: use palette[2] only if hue is >30° different from both
|
||||
if len(palette) > 2:
|
||||
ter_h, _, _ = palette[2].to_hsl()
|
||||
sec_h, _, _ = secondary.to_hsl()
|
||||
if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
|
||||
tertiary = palette[2]
|
||||
else:
|
||||
tertiary = shift_hue(primary, 60)
|
||||
else:
|
||||
tertiary = shift_hue(primary, 60)
|
||||
|
||||
error = find_error_color(palette)
|
||||
|
||||
# Keep colors vibrant - darken for visibility on light bg
|
||||
# Clamp lightness to [0.25, 0.45] so colors are never near-black nor washed out
|
||||
h, s, l = primary.to_hsl()
|
||||
primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(min(l, 0.45), 0.25))
|
||||
|
||||
h, s, l = secondary.to_hsl()
|
||||
secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(min(l, 0.40), 0.22))
|
||||
|
||||
h, s, l = tertiary.to_hsl()
|
||||
tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(min(l, 0.35), 0.20))
|
||||
|
||||
# Container colors - lighter, less saturated versions of accent colors for light mode
|
||||
def make_container_light(base: Color) -> Color:
|
||||
h, s, l = base.to_hsl()
|
||||
return Color.from_hsl(h, max(s - 0.20, 0.30), 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: COLORFUL light - a pastel, saturated version of primary
|
||||
# Preserving saturation (up to 0.9) to be true to primary color
|
||||
surface = adjust_surface(palette[0], 0.90, 0.90)
|
||||
surface_variant = adjust_surface(palette[0], 0.80, 0.78) # Darker than surface
|
||||
|
||||
# Surface containers - progressive darkening for light mode (keep primary hue)
|
||||
surface_container_lowest = adjust_surface(palette[0], 0.85, 0.96) # Lightest
|
||||
surface_container_low = adjust_surface(palette[0], 0.85, 0.92)
|
||||
surface_container = adjust_surface(palette[0], 0.80, 0.86)
|
||||
surface_container_high = adjust_surface(palette[0], 0.75, 0.84)
|
||||
surface_container_highest = adjust_surface(palette[0], 0.70, 0.80) # Darkest
|
||||
|
||||
# Foreground colors - tinted with primary hue
|
||||
text_h, _, _ = palette[0].to_hsl()
|
||||
base_on_surface = Color.from_hsl(text_h, 0.05, 0.10)
|
||||
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
|
||||
|
||||
base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.35)
|
||||
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
|
||||
|
||||
# Contrasting foregrounds - light text on dark accent colors
|
||||
light_fg = Color.from_hsl(text_h, 0.1, 0.98) # Brighter for better contrast
|
||||
on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0) # Higher contrast target
|
||||
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 - dark text on light containers, tinted with respective color
|
||||
# Explicitly prefer_light=False since containers in light mode are light
|
||||
primary_h, primary_s, _ = primary.to_hsl()
|
||||
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5, prefer_light=False)
|
||||
sec_h, sec_s, _ = secondary.to_hsl()
|
||||
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.15), secondary_container, 4.5, prefer_light=False)
|
||||
ter_h, ter_s, _ = tertiary.to_hsl()
|
||||
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.15), tertiary_container, 4.5, prefer_light=False)
|
||||
err_h, err_s, _ = error.to_hsl()
|
||||
on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.15), error_container, 4.5, prefer_light=False)
|
||||
|
||||
# Fixed colors - high-chroma accents consistent across light/dark
|
||||
# In light mode: darker versions of accent colors
|
||||
def make_fixed_light(base: Color) -> tuple[Color, Color]:
|
||||
h, s, _ = base.to_hsl()
|
||||
fixed = Color.from_hsl(h, max(s, 0.70), 0.40) # Darker, saturated
|
||||
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30) # Even darker
|
||||
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 - light text on dark fixed colors
|
||||
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5)
|
||||
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5)
|
||||
on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.90), secondary_fixed, 4.5)
|
||||
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.85), secondary_fixed_dim, 4.5)
|
||||
on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.90), tertiary_fixed, 4.5)
|
||||
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.85), tertiary_fixed_dim, 4.5)
|
||||
|
||||
# Surface dim - slightly darker than surface
|
||||
surface_dim = adjust_surface(palette[0], 0.85, 0.82)
|
||||
# Surface bright - brighter than surface
|
||||
surface_bright = adjust_surface(palette[0], 0.90, 0.95)
|
||||
|
||||
# Outline uses primary hue, more saturated
|
||||
surface_h, surface_s, _ = palette[0].to_hsl()
|
||||
outline = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.4, 0.25), 0.65), surface, 3.0)
|
||||
outline_variant = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.3, 0.20), 0.75), surface, 3.0)
|
||||
shadow = Color.from_hsl(surface_h, max(surface_s * 0.3, 0.15), 0.80)
|
||||
scrim = Color(0, 0, 0) # Pure black
|
||||
|
||||
# Inverse colors - for inverted surfaces (dark surface on light theme)
|
||||
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
|
||||
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
|
||||
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
|
||||
|
||||
# Background aliases (same as surface in MD3)
|
||||
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(),
|
||||
"surface_tint": primary_adjusted.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_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)
|
||||
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.70)
|
||||
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(),
|
||||
"surface_tint": primary_adjusted.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)
|
||||
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.35)
|
||||
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(),
|
||||
"surface_tint": primary_adjusted.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,
|
||||
scheme_type: str = "tonal-spot"
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
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", "faithful", "dysfunctional", "muted"
|
||||
|
||||
Returns:
|
||||
Dictionary of color token names to hex values
|
||||
"""
|
||||
# Handle vibrant/faithful/dysfunctional modes (use generate_normal_* functions)
|
||||
# All three use same theme generation, but different color extraction (handled in palette.py)
|
||||
if scheme_type in ("vibrant", "faithful", "dysfunctional"):
|
||||
if mode == "dark":
|
||||
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)
|
||||
return generate_material_light(palette, scheme_type)
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
# Registry URL for color schemes
|
||||
REGISTRY_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/registry.json"
|
||||
RAW_BASE_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/"
|
||||
|
||||
def is_valid_format(data):
|
||||
"""Check if the scheme data has the new terminal format."""
|
||||
for variant in ['dark', 'light']:
|
||||
if variant in data:
|
||||
v_data = data[variant]
|
||||
if isinstance(v_data, dict) and 'terminal' in v_data:
|
||||
term = v_data['terminal']
|
||||
if isinstance(term, dict) and 'normal' in term:
|
||||
if isinstance(term['normal'], dict) and 'black' in term['normal']:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_registry():
|
||||
"""Fetch the remote registry to get correct paths for schemes."""
|
||||
try:
|
||||
with urllib.request.urlopen(REGISTRY_URL) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Error fetching registry: {e}")
|
||||
return None
|
||||
|
||||
def migrate(config_dir):
|
||||
colorschemes_dir = Path(config_dir) / "colorschemes"
|
||||
if not colorschemes_dir.exists():
|
||||
return
|
||||
|
||||
registry = get_registry()
|
||||
if not registry:
|
||||
return
|
||||
|
||||
# Map name to path from registry
|
||||
theme_map = {t['name']: t['path'] for t in registry.get('themes', [])}
|
||||
|
||||
for scheme_dir in colorschemes_dir.iterdir():
|
||||
if not scheme_dir.is_dir():
|
||||
continue
|
||||
|
||||
scheme_name = scheme_dir.name
|
||||
json_file = scheme_dir / f"{scheme_name}.json"
|
||||
|
||||
if not json_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(json_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not is_valid_format(data):
|
||||
print(f"Scheme '{scheme_name}' has old format. Attempting to redownload...")
|
||||
|
||||
# Use registry path if available, otherwise fallback to name
|
||||
remote_path = theme_map.get(scheme_name, scheme_name)
|
||||
|
||||
# Encode URL parts to handle spaces and special characters
|
||||
encoded_path = urllib.parse.quote(remote_path)
|
||||
encoded_name = urllib.parse.quote(scheme_name)
|
||||
remote_url = f"{RAW_BASE_URL}{encoded_path}/{encoded_name}.json"
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(remote_url) as response:
|
||||
new_data = json.loads(response.read().decode())
|
||||
with open(json_file, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
|
||||
print(f"Successfully migrated '{scheme_name}'")
|
||||
except Exception as e:
|
||||
print(f"Failed to migrate '{scheme_name}': {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: migrate-colorschemes.py <config_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
migrate(sys.argv[1])
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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.
|
||||
|
||||
Supported scheme types:
|
||||
- tonal-spot: Default Android 12-13 Material You scheme (recommended)
|
||||
- content: Preserves source color's chroma with temperature-based tertiary (matugen default)
|
||||
- fruit-salad: Bold/playful with -50° hue rotation
|
||||
- rainbow: Chromatic accents with grayscale neutrals
|
||||
- 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, dysfunctional, muted
|
||||
--dark Generate dark theme only
|
||||
--light Generate light theme only
|
||||
--both Generate both themes (default)
|
||||
-o, --output Write JSON output to file (stdout if omitted)
|
||||
-r, --render Render a template (input_path:output_path)
|
||||
-c, --config Path to TOML configuration file with template definitions
|
||||
--mode Theme mode: dark or light
|
||||
|
||||
Input:
|
||||
Can be an image file (PNG/JPG) or a JSON color palette file.
|
||||
|
||||
Example:
|
||||
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
|
||||
|
||||
Author: Noctalia Team
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import from lib package
|
||||
from lib import (
|
||||
read_image, ImageReadError, extract_palette, generate_theme,
|
||||
TemplateRenderer, expand_predefined_scheme,
|
||||
extract_source_color, source_color_to_rgb, Color,
|
||||
)
|
||||
from lib.scheme import inject_terminal_colors
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='template-processor',
|
||||
description='Extract color palettes from wallpapers and generate themes',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python3 template-processor.py wallpaper.png # tonal-spot (default), both themes
|
||||
python3 template-processor.py wallpaper.png --scheme-type content --dark # content scheme, dark only
|
||||
python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
|
||||
python3 template-processor.py wallpaper.png -r template.txt:output.txt # render template
|
||||
python3 template-processor.py wallpaper.png -c config.toml --mode dark # render config, dark only
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'image',
|
||||
type=Path,
|
||||
nargs='?',
|
||||
help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
|
||||
)
|
||||
|
||||
# Scheme type selection
|
||||
parser.add_argument(
|
||||
'--scheme-type',
|
||||
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'monochrome', 'vibrant', 'faithful', 'dysfunctional', 'muted'],
|
||||
default='tonal-spot',
|
||||
help='Color scheme type (default: tonal-spot)'
|
||||
)
|
||||
|
||||
# Theme mode (mutually exclusive)
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument(
|
||||
'--dark',
|
||||
action='store_true',
|
||||
help='Generate dark theme only'
|
||||
)
|
||||
mode_group.add_argument(
|
||||
'--light',
|
||||
action='store_true',
|
||||
help='Generate light theme only'
|
||||
)
|
||||
mode_group.add_argument(
|
||||
'--both',
|
||||
action='store_true',
|
||||
default=True,
|
||||
help='Generate both dark and light themes (default)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output', '-o',
|
||||
type=Path,
|
||||
help='Write JSON output to file (stdout if omitted)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--render', '-r',
|
||||
action='append',
|
||||
help='Render a template (input_path:output_path)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config', '-c',
|
||||
type=Path,
|
||||
help='Path to TOML configuration file with template definitions'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--mode',
|
||||
choices=['dark', 'light'],
|
||||
help='Theme mode: dark or light'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--scheme',
|
||||
type=Path,
|
||||
help='Path to predefined scheme JSON file (bypasses image extraction)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--default-mode',
|
||||
choices=['dark', 'light'],
|
||||
default='dark',
|
||||
help='Theme mode to use for "default" in templates (default: dark)'
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
args = parse_args()
|
||||
|
||||
# Initialize result dictionary
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
|
||||
# Determine mode from arguments
|
||||
if args.mode == 'dark':
|
||||
modes = ["dark"]
|
||||
elif args.mode == 'light':
|
||||
modes = ["light"]
|
||||
elif args.dark:
|
||||
modes = ["dark"]
|
||||
elif args.light:
|
||||
modes = ["light"]
|
||||
else:
|
||||
modes = ["dark", "light"]
|
||||
|
||||
# Path 1: Predefined scheme (--scheme flag)
|
||||
if args.scheme:
|
||||
if not args.scheme.exists():
|
||||
print(f"Error: Scheme file not found: {args.scheme}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(args.scheme, 'r') as f:
|
||||
scheme_data = json.load(f)
|
||||
|
||||
# Scheme format: {"dark": {"mPrimary": "#...", ...}, "light": {...}}
|
||||
# or single mode: {"mPrimary": "#...", ...}
|
||||
for mode in modes:
|
||||
if mode in scheme_data:
|
||||
# Multi-mode format
|
||||
result[mode] = expand_predefined_scheme(scheme_data[mode], mode)
|
||||
inject_terminal_colors(result[mode], scheme_data[mode])
|
||||
elif "mPrimary" in scheme_data:
|
||||
# Single-mode format - use same colors for requested mode
|
||||
result[mode] = expand_predefined_scheme(scheme_data, mode)
|
||||
inject_terminal_colors(result[mode], scheme_data)
|
||||
else:
|
||||
print(f"Error: Invalid scheme format - missing '{mode}' or 'mPrimary'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing scheme JSON: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyError as e:
|
||||
print(f"Error: Missing required color in scheme: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error processing scheme: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Path 2: Image-based extraction (default)
|
||||
else:
|
||||
# Validate image argument is provided
|
||||
if args.image is None:
|
||||
print("Error: Image path is required (unless --scheme is used)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate image path
|
||||
if not args.image.exists():
|
||||
print(f"Error: Image not found: {args.image}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Check if input is a JSON palette (Predefined Color Scheme)
|
||||
if args.image.suffix.lower() == '.json':
|
||||
try:
|
||||
with open(args.image, 'r') as f:
|
||||
input_data = json.load(f)
|
||||
|
||||
# Expect {"colors": ...} or direct dict
|
||||
colors_data = input_data.get("colors", input_data)
|
||||
|
||||
# Flatten QML-style object structure if needed
|
||||
# structure: key -> { default: { hex: "#..." } } or key -> "#..."
|
||||
flat_colors = {}
|
||||
for k, v in colors_data.items():
|
||||
if isinstance(v, dict) and 'default' in v and 'hex' in v['default']:
|
||||
flat_colors[k] = v['default']['hex']
|
||||
elif isinstance(v, str):
|
||||
flat_colors[k] = v
|
||||
else:
|
||||
# Best effort fallback
|
||||
flat_colors[k] = str(v)
|
||||
|
||||
# Assign to requested modes
|
||||
for mode in modes:
|
||||
result[mode] = flat_colors
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading JSON palette: {e}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
# Standard Image Extraction
|
||||
if not args.image.is_file():
|
||||
print(f"Error: Not a file: {args.image}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Determine scheme type
|
||||
scheme_type = args.scheme_type
|
||||
|
||||
# M3 schemes use Triangle filter (matches matugen), others use Box
|
||||
# (sharper downscale preserves distinct color regions for k-means)
|
||||
m3_schemes = {"tonal-spot", "content", "fruit-salad", "rainbow", "monochrome"}
|
||||
resize_filter = "Triangle" if scheme_type in m3_schemes else "Box"
|
||||
|
||||
try:
|
||||
pixels = read_image(args.image, resize_filter)
|
||||
except ImageReadError as e:
|
||||
print(f"Error reading image: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Unexpected error reading image: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Extract palette based on scheme type:
|
||||
# - M3 schemes (tonal-spot, fruit-salad, rainbow, content): Use Wu quantizer + Score
|
||||
# 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
|
||||
palette = extract_palette(pixels, k=5, scoring="chroma")
|
||||
elif scheme_type == "faithful":
|
||||
# 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
|
||||
palette = extract_palette(pixels, k=5, scoring="muted")
|
||||
else:
|
||||
# Wu quantizer + Score algorithm (matches matugen)
|
||||
source_argb = extract_source_color(pixels)
|
||||
r, g, b = source_color_to_rgb(source_argb)
|
||||
palette = [Color(r, g, b)]
|
||||
|
||||
if not palette:
|
||||
print("Error: Could not extract colors from image", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Generate theme for each mode
|
||||
for mode in modes:
|
||||
result[mode] = generate_theme(palette, mode, scheme_type)
|
||||
|
||||
# Output JSON
|
||||
json_output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
try:
|
||||
args.output.write_text(json_output)
|
||||
print(f"Theme written to: {args.output}", file=sys.stderr)
|
||||
except IOError as e:
|
||||
print(f"Error writing output: {e}", file=sys.stderr)
|
||||
return 1
|
||||
elif not args.render and not args.config:
|
||||
print(json_output)
|
||||
|
||||
# Process templates
|
||||
if args.render or args.config:
|
||||
image_path = str(args.image) if args.image else None
|
||||
renderer = TemplateRenderer(result, default_mode=args.default_mode, image_path=image_path, scheme_type=args.scheme_type)
|
||||
|
||||
if args.render:
|
||||
for render_spec in args.render:
|
||||
if ':' not in render_spec:
|
||||
print(f"Error: Invalid render spec (must be input:output): {render_spec}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
input_str, output_str = render_spec.split(':', 1)
|
||||
input_path = Path(input_str).expanduser()
|
||||
output_path = Path(output_str).expanduser()
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: Template not found: {input_path}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
renderer.render_file(input_path, output_path)
|
||||
|
||||
if args.config:
|
||||
if not args.config.exists():
|
||||
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
|
||||
else:
|
||||
renderer.process_config_file(args.config)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
# Finds all installed Noctalia theme extensions for VSCode/VSCodium.
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_all_noctalia_themes(extensions_dir: Path, prefix: str) -> list[str]:
|
||||
# Bail early if the extensions directory doesn't exist
|
||||
if not extensions_dir.is_dir():
|
||||
return []
|
||||
# Collect all directories matching the extension prefix
|
||||
candidates = [d for d in extensions_dir.iterdir() if d.is_dir() and d.name.startswith(prefix)]
|
||||
# Return theme file paths for all matching extensions
|
||||
return [str(d / "themes" / "NoctaliaTheme-color-theme.json") for d in candidates]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Resolve ~ in the provided extensions directory path
|
||||
extensions_dir = Path(sys.argv[1]).expanduser()
|
||||
prefix = sys.argv[2] if len(sys.argv) > 2 else "noctalia.noctaliatheme-"
|
||||
|
||||
# Print the resolved paths to stdout for the QML Process to capture
|
||||
results = find_all_noctalia_themes(extensions_dir, prefix)
|
||||
if results:
|
||||
for path in results:
|
||||
print(path)
|
||||
else:
|
||||
print(f"No matching extension found in {extensions_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user