Created
January 3, 2026 07:11
-
-
Save yell0wsuit/9933474f33f9ad4dc8ef60ce99fc8e62 to your computer and use it in GitHub Desktop.
Python script to extract accent colors from images using K-means clustering in perceptually uniform color spaces (Lab/Oklab). Outputs colors in hex, RGB, and Oklch formats
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Extract accent colors from images using perceptual color spaces. | |
| Provides hex, RGB, and Oklch color representations for UI theming. | |
| """ | |
| import colorsys | |
| import sys | |
| from typing import cast | |
| from PIL import Image | |
| import numpy as np | |
| from sklearn.cluster import KMeans | |
| from colormath.color_objects import sRGBColor, LabColor | |
| from colormath.color_conversions import convert_color | |
| import colour | |
| def load_pixels(path, max_size=128, alpha_threshold=10): | |
| """ | |
| Load and downsample image, filtering transparent pixels. | |
| Args: | |
| max_size: Maximum dimension for downsampling (performance optimization) | |
| alpha_threshold: Minimum alpha value to consider pixel (0-255) | |
| """ | |
| img = Image.open(path).convert("RGBA") | |
| img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) | |
| data = np.array(img) | |
| rgb = data[..., :3] | |
| alpha = data[..., 3] | |
| mask = alpha > alpha_threshold | |
| pixels = rgb[mask] | |
| if len(pixels) == 0: | |
| raise ValueError("No visible pixels found (all transparent)") | |
| return pixels.astype(np.float32) / 255.0 | |
| def is_near_gray(rgb, threshold=0.08): | |
| """Check if RGB color is near grayscale using standard deviation.""" | |
| return np.std(rgb) < threshold | |
| def perceptual_average_color(pixels): | |
| """Compute average color in perceptually uniform Lab color space.""" | |
| lab_pixels = [] | |
| for r, g, b in pixels: | |
| srgb = sRGBColor(r, g, b) | |
| lab = cast(LabColor, convert_color(srgb, LabColor, target_illuminant="d65")) | |
| lab_pixels.append([lab.lab_l, lab.lab_a, lab.lab_b]) | |
| lab_avg = np.mean(lab_pixels, axis=0) | |
| lab_result = LabColor(*lab_avg) | |
| rgb = cast(sRGBColor, convert_color(lab_result, sRGBColor)) | |
| return np.clip(rgb.get_value_tuple(), 0, 1) | |
| def dominant_color(pixels, k=4, gray_threshold=0.08): | |
| """ | |
| Find dominant non-gray color using K-means clustering. | |
| Args: | |
| k: Number of clusters (should be < number of pixels) | |
| gray_threshold: Standard deviation threshold for gray detection | |
| """ | |
| n_clusters = min(k, len(pixels)) | |
| kmeans = KMeans(n_clusters=n_clusters, n_init=10, random_state=None) | |
| labels = kmeans.fit_predict(pixels) | |
| centers = kmeans.cluster_centers_ | |
| counts = np.bincount(labels) | |
| sorted_idx = np.argsort(counts)[::-1] | |
| for idx in sorted_idx: | |
| color = centers[idx] | |
| if not is_near_gray(color, gray_threshold): | |
| return np.clip(color, 0, 1) | |
| return np.clip(centers[sorted_idx[0]], 0, 1) | |
| def clamp_accent(rgb, sat_range=(0.55, 0.8), light_range=(0.4, 0.65)): | |
| """ | |
| Clamp color to accent-friendly saturation and lightness ranges. | |
| Uses HLS color space (Hue, Lightness, Saturation). | |
| """ | |
| hue, lightness, saturation = colorsys.rgb_to_hls(*rgb) | |
| saturation = np.clip(saturation, sat_range[0], sat_range[1]) | |
| lightness = np.clip(lightness, light_range[0], light_range[1]) | |
| return colorsys.hls_to_rgb(hue, lightness, saturation) | |
| def rgb_to_oklch(rgb): | |
| """ | |
| Convert sRGB to Oklch using colour-science library. | |
| Returns: | |
| tuple: (lightness, chroma, hue) where lightness is 0-1, | |
| chroma is unbounded, hue is 0-360 degrees | |
| """ | |
| # colour-science expects RGB in 0-1 range | |
| oklab = colour.convert(rgb, "sRGB", "Oklab") | |
| # Convert Oklab to Oklch (polar coordinates) | |
| lightness = oklab[0] | |
| a_axis = oklab[1] | |
| b_axis = oklab[2] | |
| chroma = np.sqrt(a_axis**2 + b_axis**2) | |
| hue = np.arctan2(b_axis, a_axis) * 180 / np.pi | |
| if hue < 0: | |
| hue += 360 | |
| return lightness, chroma, hue | |
| def format_color_info(name, rgb): | |
| """Format color with hex, RGB, and Oklch representations.""" | |
| r, g, b = [int(np.clip(c, 0, 1) * 255) for c in rgb] | |
| hex_color = f"#{r:02X}{g:02X}{b:02X}" | |
| lightness, chroma, hue = rgb_to_oklch(rgb) | |
| return { | |
| "name": name, | |
| "hex": hex_color, | |
| "rgb": f"rgb({r}, {g}, {b})", | |
| "oklch": f"oklch({lightness:.4f} {chroma:.4f} {hue:.2f})", | |
| } | |
| def extract_colors(image_path, max_size=128, n_clusters=4): | |
| """ | |
| Extract accent colors from an image using multiple methods. | |
| Returns: | |
| list: Color info dictionaries with hex, RGB, and Oklch formats | |
| """ | |
| pixels = load_pixels(image_path, max_size=max_size) | |
| avg = perceptual_average_color(pixels) | |
| dom = dominant_color(pixels, k=n_clusters) | |
| accent = clamp_accent(dom) | |
| return [ | |
| format_color_info("Perceptual average", avg), | |
| format_color_info("Dominant color", dom), | |
| format_color_info("Accent color", accent), | |
| ] | |
| if __name__ == "__main__": | |
| # Requirements: pip install pillow numpy scikit-learn colormath colour-science | |
| if len(sys.argv) < 2: | |
| print("Usage: python script.py <image_path>") | |
| sys.exit(1) | |
| img_path = sys.argv[1] | |
| colors = extract_colors(img_path) | |
| for color_info in colors: | |
| print(f"\n{color_info['name']}:") | |
| print(f" HEX: {color_info['hex']}") | |
| print(f" RGB: {color_info['rgb']}") | |
| print(f" OKLCH: {color_info['oklch']}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment