mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
template-processor: m3 now uses proper Wu quantizer to better match matugen
This commit is contained in:
@@ -15,6 +15,7 @@ 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
|
||||
@@ -44,6 +45,9 @@ __all__ = [
|
||||
"ImageReadError",
|
||||
# Palette
|
||||
"extract_palette",
|
||||
# Quantizer (Wu + Score algorithm matching matugen)
|
||||
"extract_source_color",
|
||||
"source_color_to_rgb",
|
||||
# Theme
|
||||
"generate_theme",
|
||||
# Renderer
|
||||
|
||||
@@ -248,8 +248,8 @@ def _read_image_imagemagick(path: Path) -> list[RGB]:
|
||||
# -resize: downsample for performance (we don't need full resolution for color extraction)
|
||||
# ppm: output as PPM format (easy to parse)
|
||||
|
||||
# Downsample to max 200x200 for performance
|
||||
resize_spec = "200x200>"
|
||||
# Resize to 112x112 to match matugen's color extraction
|
||||
resize_spec = "112x112!"
|
||||
|
||||
try:
|
||||
# Try 'magick convert' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6)
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""
|
||||
Wu quantizer implementation matching material-color-utilities.
|
||||
|
||||
This implements Xiaolin Wu's color quantization algorithm from Graphics Gems II (1991),
|
||||
which is the same algorithm used by matugen/material-color-utilities for extracting
|
||||
dominant colors from images.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# 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].r0 = 0
|
||||
self.cubes[0].g0 = 0
|
||||
self.cubes[0].b0 = 0
|
||||
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
|
||||
|
||||
for i in range(1, 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
|
||||
|
||||
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 = round(self._volume(cube, self.moments_r) / weight)
|
||||
g = round(self._volume(cube, self.moments_g) / weight)
|
||||
b = round(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
|
||||
# Count original pixels that map to each quantized color
|
||||
color_to_count: Dict[int, int] = {}
|
||||
|
||||
# For Wu quantizer, we approximate counts based on the quantizer's weights
|
||||
# Since Wu gives us representative colors, we need to map original pixels
|
||||
bits_to_remove = 8 - INDEX_BITS
|
||||
for argb in argb_pixels:
|
||||
if (argb >> 24) & 0xFF != 255:
|
||||
continue
|
||||
|
||||
r = (argb >> 16) & 0xFF
|
||||
g = (argb >> 8) & 0xFF
|
||||
b = argb & 0xFF
|
||||
|
||||
# Find closest quantized color
|
||||
min_dist = float('inf')
|
||||
closest = result_colors[0] if result_colors else argb
|
||||
|
||||
for qcolor in result_colors:
|
||||
qr = (qcolor >> 16) & 0xFF
|
||||
qg = (qcolor >> 8) & 0xFF
|
||||
qb = qcolor & 0xFF
|
||||
dist = (r - qr) ** 2 + (g - qg) ** 2 + (b - qb) ** 2
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
closest = qcolor
|
||||
|
||||
color_to_count[closest] = color_to_count.get(closest, 0) + 1
|
||||
|
||||
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 quantizer + 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 algorithm (128 colors like matugen)
|
||||
color_to_count = quantize_wu(pixels, max_colors=128)
|
||||
|
||||
# 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)
|
||||
@@ -47,7 +47,11 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import from lib package
|
||||
from lib import read_image, ImageReadError, extract_palette, generate_theme, TemplateRenderer, expand_predefined_scheme
|
||||
from lib import (
|
||||
read_image, ImageReadError, extract_palette, generate_theme,
|
||||
TemplateRenderer, expand_predefined_scheme,
|
||||
extract_source_color, source_color_to_rgb, Color
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -242,18 +246,20 @@ def main() -> int:
|
||||
# Determine scheme type
|
||||
scheme_type = args.scheme_type
|
||||
|
||||
# Extract palette with appropriate scoring method
|
||||
# - vibrant: chroma scoring with centroid averaging (smooth blended colors)
|
||||
# - faithful: chroma scoring with representative pixels (actual wallpaper colors)
|
||||
# - M3 schemes: population scoring (most representative colors)
|
||||
k = 5
|
||||
if scheme_type == "vibrant":
|
||||
scoring = "chroma"
|
||||
elif scheme_type == "faithful":
|
||||
scoring = "chroma-representative"
|
||||
# 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/faithful: Use k-means clustering for colorful/representative colors
|
||||
if scheme_type in ("vibrant", "faithful"):
|
||||
# K-means based extraction for vibrant/faithful modes
|
||||
k = 5
|
||||
scoring = "chroma" if scheme_type == "vibrant" else "chroma-representative"
|
||||
palette = extract_palette(pixels, k=k, scoring=scoring)
|
||||
else:
|
||||
scoring = "population"
|
||||
palette = extract_palette(pixels, k=k, scoring=scoring)
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user