///|
/// Perceptual color coordinates used by palette auditing and export tooling.
pub(all) struct ChromaticColor {
l : Double
a : Double
b : Double
alpha : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) enum ColorVisionMode {
NormalVision
Protanopia
Deuteranopia
Tritanopia
Achromatopsia
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ColorHistogram {
bins : Int
red : Array[Int]
green : Array[Int]
blue : Array[Int]
luminance : Array[Int]
total : Int
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ContrastAuditEntry {
foreground : Color
background : Color
ratio : Double
normal_text : Bool
large_text : Bool
pass : Bool
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ColorAnalysisReport {
color_count : Int
unique_count : Int
luminance_min : Double
luminance_max : Double
luminance_mean : Double
contrast_min : Double
contrast_max : Double
contrast_mean : Double
saturation_mean : Double
neutral_count : Int
transparent_count : Int
} derive(Eq, Debug, ToJson)
///|
fn ca_clamp(value : Double, low : Double, high : Double) -> Double {
value.max(low).min(high)
}
///|
fn ca_linear(value : Double) -> Double {
if value <= 0.04045 {
value / 12.92
} else {
@math.pow((value + 0.055) / 1.055, 2.4)
}
}
///|
fn ca_srgb(value : Double) -> Double {
if value <= 0.0031308 {
value * 12.92
} else {
1.055 * @math.pow(value.max(0.0), 1.0 / 2.4) - 0.055
}
}
///|
fn ca_rgb_to_xyz(color : Color) -> (Double, Double, Double) {
let r = ca_linear(color.r)
let g = ca_linear(color.g)
let b = ca_linear(color.b)
(
r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
r * 0.2126729 + g * 0.7151522 + b * 0.0721750,
r * 0.0193339 + g * 0.1191920 + b * 0.9503041,
)
}
///|
fn ca_lab_f(value : Double) -> Double {
let delta = 6.0 / 29.0
if value > delta * delta * delta {
@math.pow(value, 1.0 / 3.0)
} else {
value / (3.0 * delta * delta) + 4.0 / 29.0
}
}
///|
fn ca_lab_inv(value : Double) -> Double {
let delta = 6.0 / 29.0
if value > delta {
value * value * value
} else {
3.0 * delta * delta * (value - 4.0 / 29.0)
}
}
///|
pub fn chromatic_from_color(color : Color) -> ChromaticColor {
let (x, y, z) = ca_rgb_to_xyz(color)
let fx = ca_lab_f(x / 0.95047)
let fy = ca_lab_f(y / 1.0)
let fz = ca_lab_f(z / 1.08883)
{
l: 116.0 * fy - 16.0,
a: 500.0 * (fx - fy),
b: 200.0 * (fy - fz),
alpha: color.a,
}
}
///|
pub fn chromatic_to_color(value : ChromaticColor) -> Color {
let fy = (value.l + 16.0) / 116.0
let fx = value.a / 500.0 + fy
let fz = fy - value.b / 200.0
let x = ca_lab_inv(fx) * 0.95047
let y = ca_lab_inv(fy)
let z = ca_lab_inv(fz) * 1.08883
let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314
let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560
let b = x * 0.0556434 + y * -0.2040259 + z * 1.0572252
Color::rgba(
ca_clamp(ca_srgb(r), 0.0, 1.0),
ca_clamp(ca_srgb(g), 0.0, 1.0),
ca_clamp(ca_srgb(b), 0.0, 1.0),
a=ca_clamp(value.alpha, 0.0, 1.0),
)
}
///|
pub fn chromatic_distance(
left : ChromaticColor,
right : ChromaticColor,
) -> Double {
let dl = left.l - right.l
let da = left.a - right.a
let db = left.b - right.b
(dl * dl + da * da + db * db).sqrt()
}
///|
pub fn chromatic_mix(
left : ChromaticColor,
right : ChromaticColor,
amount : Double,
) -> ChromaticColor {
let t = ca_clamp(amount, 0.0, 1.0)
{
l: left.l + (right.l - left.l) * t,
a: left.a + (right.a - left.a) * t,
b: left.b + (right.b - left.b) * t,
alpha: left.alpha + (right.alpha - left.alpha) * t,
}
}
///|
pub fn color_temperature(kelvin : Double) -> Color {
let temperature = ca_clamp(kelvin, 1000.0, 40000.0) / 100.0
let red = if temperature <= 66.0 {
255.0
} else {
329.698727446 * @math.pow(temperature - 60.0, -0.1332047592)
}
let green = if temperature <= 66.0 {
70.0 + temperature * 1.1
} else {
190.0 + (temperature - 66.0) * 0.55
}
let blue = if temperature >= 66.0 {
255.0
} else if temperature <= 19.0 {
0.0
} else {
(temperature - 19.0) * 5.4
}
Color::rgb8(
ca_clamp(red, 0.0, 255.0).to_int(),
ca_clamp(green, 0.0, 255.0).to_int(),
ca_clamp(blue, 0.0, 255.0).to_int(),
)
}
///|
pub fn color_temperature_ramp(
start_kelvin : Double,
end_kelvin : Double,
samples : Int,
) -> Array[Color] {
let result = []
if samples <= 0 {
result
} else if samples == 1 {
[color_temperature(start_kelvin)]
} else {
for index in 0.. (Double, Double, Double) {
let max_value = color.r.max(color.g).max(color.b)
let min_value = color.r.min(color.g).min(color.b)
let delta = max_value - min_value
let hue = if delta == 0.0 {
0.0
} else if max_value == color.r {
2.0 * @math.PI * ((color.g - color.b) / delta % 6.0) / 6.0
} else if max_value == color.g {
2.0 * @math.PI * ((color.b - color.r) / delta + 2.0) / 6.0
} else {
2.0 * @math.PI * ((color.r - color.g) / delta + 4.0) / 6.0
}
(
if hue < 0.0 {
hue + 2.0 * @math.PI
} else {
hue
},
if max_value == 0.0 {
0.0
} else {
delta / max_value
},
max_value,
)
}
///|
pub fn color_to_hsl_analysis(color : Color) -> (Double, Double, Double) {
let max_value = color.r.max(color.g).max(color.b)
let min_value = color.r.min(color.g).min(color.b)
let lightness = (max_value + min_value) / 2.0
let delta = max_value - min_value
let saturation = if delta == 0.0 {
0.0
} else {
delta / (1.0 - (2.0 * lightness - 1.0).abs())
}
let hue = color_to_hsv_analysis(color).0
(hue, ca_clamp(saturation, 0.0, 1.0), lightness)
}
///|
pub fn analysis_hue_distance(left : Color, right : Color) -> Double {
let a = color_to_hsv_analysis(left).0
let b = color_to_hsv_analysis(right).0
let distance = (a - b).abs()
distance.min(2.0 * @math.PI - distance)
}
///|
pub fn analysis_saturation(color : Color) -> Double {
color_to_hsv_analysis(color).1
}
///|
pub fn analysis_value(color : Color) -> Double {
color_to_hsv_analysis(color).2
}
///|
pub fn color_histogram_analysis(raster : Raster, bins : Int) -> ColorHistogram {
let size = bins.max(0)
let red = []
let green = []
let blue = []
let luminance = []
for _ in 0.. 0 {
for color in raster.pixels {
let ri = (color.r * size.to_double()).to_int().min(size - 1).max(0)
let gi = (color.g * size.to_double()).to_int().min(size - 1).max(0)
let bi = (color.b * size.to_double()).to_int().min(size - 1).max(0)
let li = (color.luminance() * size.to_double())
.to_int()
.min(size - 1)
.max(0)
red[ri] += 1
green[gi] += 1
blue[bi] += 1
luminance[li] += 1
}
}
{ bins: size, red, green, blue, luminance, total: raster.pixels.length() }
}
///|
pub fn histogram_peak(values : Array[Int]) -> Int {
if values.length() == 0 {
-1
} else {
let mut best = 0
for index in 1.. values[best] {
best = index
}
}
best
}
}
///|
pub fn histogram_quantile(values : Array[Int], quantile : Double) -> Int {
if values.length() == 0 {
-1
} else {
let total = values.fold(init=0, fn(sum, item) { sum + item })
if total <= 0 {
0
} else {
let target = (ca_clamp(quantile, 0.0, 1.0) * (total - 1).to_double()).to_int()
let mut cumulative = 0
let mut answer = values.length() - 1
for index, item in values {
cumulative += item
if cumulative > target && answer == values.length() - 1 {
answer = index
}
}
answer
}
}
}
///|
pub fn contrast_audit(
foreground : Color,
background : Color,
) -> ContrastAuditEntry {
let ratio = foreground.contrast_ratio(background)
{
foreground,
background,
ratio,
normal_text: ratio >= 4.5,
large_text: ratio >= 3.0,
pass: ratio >= 4.5,
}
}
///|
pub fn contrast_audit_palette(
foreground : Palette,
background : Palette,
) -> Array[ContrastAuditEntry] {
let result = []
for front in foreground.colors {
for back in background.colors {
result.push(contrast_audit(front, back))
}
}
result
}
///|
pub fn strongest_contrast(
background : Color,
candidates : Array[Color],
) -> Color? {
if candidates.length() == 0 {
None
} else {
let mut best = candidates[0]
let mut score = best.contrast_ratio(background)
for candidate in candidates[1:] {
let candidate_score = candidate.contrast_ratio(background)
if candidate_score > score {
best = candidate
score = candidate_score
}
}
Some(best)
}
}
///|
pub fn readable_foreground(
background : Color,
light : Color,
dark : Color,
) -> Color {
if light.contrast_ratio(background) >= dark.contrast_ratio(background) {
light
} else {
dark
}
}
///|
pub fn simulate_color_vision(color : Color, mode : ColorVisionMode) -> Color {
let r = color.r
let g = color.g
let b = color.b
let channels = match mode {
NormalVision => (r, g, b)
Protanopia =>
(
0.56667 * r + 0.43333 * g,
0.55833 * r + 0.44167 * g,
0.24167 * g + 0.75833 * b,
)
Deuteranopia =>
(0.625 * r + 0.375 * g, 0.7 * r + 0.3 * g, 0.3 * g + 0.7 * b)
Tritanopia =>
(0.95 * r + 0.05 * g, 0.43333 * g + 0.56667 * b, 0.475 * g + 0.525 * b)
Achromatopsia => {
let grey = color.luminance()
(grey, grey, grey)
}
}
Color::rgba(
ca_clamp(channels.0, 0.0, 1.0),
ca_clamp(channels.1, 0.0, 1.0),
ca_clamp(channels.2, 0.0, 1.0),
a=color.a,
)
}
///|
fn ca_mode_name(mode : ColorVisionMode) -> String {
match mode {
NormalVision => "normal"
Protanopia => "protanopia"
Deuteranopia => "deuteranopia"
Tritanopia => "tritanopia"
Achromatopsia => "achromatopsia"
}
}
///|
pub fn simulate_palette(palette : Palette, mode : ColorVisionMode) -> Palette {
let colors = []
for color in palette.colors {
colors.push(simulate_color_vision(color, mode))
}
Palette::new(palette.name + "-" + ca_mode_name(mode), colors)
}
///|
pub fn palette_collision_count(palette : Palette, tolerance : Double) -> Int {
let mut count = 0
let threshold = tolerance.max(0.0)
if palette.colors.length() > 1 {
for i in 0..<(palette.colors.length() - 1) {
for j in (i + 1).. Color? {
if palette.colors.length() == 0 {
None
} else {
let target = chromatic_from_color(color)
let mut best = palette.colors[0]
let mut distance = chromatic_distance(target, chromatic_from_color(best))
for candidate in palette.colors[1:] {
let candidate_distance = chromatic_distance(
target,
chromatic_from_color(candidate),
)
if candidate_distance < distance {
best = candidate
distance = candidate_distance
}
}
Some(best)
}
}
///|
pub fn quantize_palette(
colors : Array[Color],
target_count : Int,
iterations : Int,
) -> Palette {
let count = target_count.max(0).min(colors.length())
let centers = []
if count > 0 {
for index in 0.. 0 {
let weight = weights[index].to_double()
let sum = sums[index]
centers[index] = {
l: sum.l / weight,
a: sum.a / weight,
b: sum.b / weight,
alpha: sum.alpha / weight,
}
}
}
}
}
let result = []
for center in centers {
result.push(chromatic_to_color(center))
}
Palette::new("quantized", result)
}
///|
pub fn palette_sort_by_luminance(
palette : Palette,
descending : Bool,
) -> Palette {
let colors = palette.colors.copy()
for i in 1.. 0 {
let previous_score = colors[j - 1].luminance()
let should_move = if descending {
previous_score < score
} else {
previous_score > score
}
if should_move {
colors[j] = colors[j - 1]
j -= 1
} else {
break
}
}
colors[j] = value
}
Palette::new(palette.name + "-luminance", colors)
}
///|
pub fn palette_sort_by_chroma(palette : Palette, descending : Bool) -> Palette {
let colors = palette.colors.copy()
for i in 1.. 0 {
let previous = chromatic_from_color(colors[j - 1])
let previous_chroma = previous.a.abs() + previous.b.abs()
let should_move = if descending {
previous_chroma < value_chroma
} else {
previous_chroma > value_chroma
}
if should_move {
colors[j] = colors[j - 1]
j -= 1
} else {
break
}
}
colors[j] = value
}
Palette::new(palette.name + "-chroma", colors)
}
///|
pub fn palette_expand_complementary(palette : Palette) -> Palette {
let colors = []
for color in palette.colors {
colors.push(color)
colors.push(
Color::rgba(1.0 - color.r, 1.0 - color.g, 1.0 - color.b, a=color.a),
)
}
Palette::new(palette.name + "-complementary", colors)
}
///|
pub fn palette_tints(palette : Palette, steps : Int) -> Palette {
let colors = []
for color in palette.colors {
for index in 0.. Palette {
let colors = []
for color in palette.colors {
for index in 0.. ColorAnalysisReport {
let count = palette.colors.length()
if count == 0 {
{
color_count: 0,
unique_count: 0,
luminance_min: 0.0,
luminance_max: 0.0,
luminance_mean: 0.0,
contrast_min: 0.0,
contrast_max: 0.0,
contrast_mean: 0.0,
saturation_mean: 0.0,
neutral_count: 0,
transparent_count: 0,
}
} else {
let mut lum_min = palette.colors[0].luminance()
let mut lum_max = lum_min
let mut lum_sum = 0.0
let mut sat_sum = 0.0
let mut neutral = 0
let mut transparent = 0
for color in palette.colors {
let lum = color.luminance()
lum_min = lum_min.min(lum)
lum_max = lum_max.max(lum)
lum_sum += lum
sat_sum += analysis_saturation(color)
if analysis_saturation(color) < 0.08 {
neutral += 1
}
if color.a < 0.999 {
transparent += 1
}
}
let mut contrast_min = palette.colors[0].contrast_ratio(palette.colors[0])
let mut contrast_max = contrast_min
let mut contrast_sum = 0.0
let mut pairs = 0
if count > 1 {
for i in 0..<(count - 1) {
for j in (i + 1).. String {
"colors=\{report.color_count} unique=\{report.unique_count} luminance=\{report.luminance_min}..\{report.luminance_max} mean=\{report.luminance_mean} contrast=\{report.contrast_min}..\{report.contrast_max} mean=\{report.contrast_mean} saturation=\{report.saturation_mean} neutral=\{report.neutral_count} transparent=\{report.transparent_count}"
}
///|
pub fn color_analysis_css(palette : Palette, prefix : String) -> String {
let builder = StringBuilder()
builder.write_string(":root {\n")
for index, color in palette.colors {
builder.write_string(" --\{prefix}-\{index}: \{color.to_hex()};\n")
}
builder.write_string("}\n")
builder.to_string()
}
///|
pub fn palette_interpolate_lab(
left : Palette,
right : Palette,
amount : Double,
) -> Palette {
let size = left.colors.length().min(right.colors.length())
let colors = []
for index in 0.. Palette {
let colors = []
for color in palette.colors {
let value = color.luminance()
colors.push(Color::rgba(value, value, value, a=color.a))
}
Palette::new(palette.name + "-gray", colors)
}
///|
pub fn palette_warm_cool(palette : Palette, warm_amount : Double) -> Palette {
let colors = []
let amount = ca_clamp(warm_amount, -1.0, 1.0)
for color in palette.colors {
let warm = if amount >= 0.0 {
Color::rgba(
color.r + amount * 0.12,
color.g - amount * 0.04,
color.b - amount * 0.1,
a=color.a,
)
} else {
Color::rgba(
color.r + amount * 0.1,
color.g - amount * 0.02,
color.b - amount * 0.12,
a=color.a,
)
}
colors.push(warm)
}
Palette::new(palette.name + "-temperature", colors)
}
///|
pub fn palette_alpha_ramp(
palette : Palette,
start : Double,
end : Double,
) -> Palette {
let colors = []
let count = palette.colors.length()
for index, color in palette.colors {
let amount = if count <= 1 {
0.0
} else {
index.to_double() / (count - 1).to_double()
}
colors.push(color.with_alpha(start + (end - start) * amount))
}
Palette::new(palette.name + "-alpha", colors)
}
///|
pub fn palette_dedupe(palette : Palette, tolerance : Double) -> Palette {
let result = []
for color in palette.colors {
let mut duplicate = false
for existing in result {
if chromatic_distance(
chromatic_from_color(color),
chromatic_from_color(existing),
) <=
tolerance.max(0.0) {
duplicate = true
break
}
}
if !duplicate {
result.push(color)
}
}
Palette::new(palette.name + "-dedupe", result)
}
///|
pub fn palette_ensure_contrast(
palette : Palette,
background : Color,
minimum : Double,
) -> Palette {
let colors = []
for color in palette.colors {
if color.contrast_ratio(background) >= minimum {
colors.push(color)
} else {
let target = if background.luminance() > 0.5 {
Color::rgb8(0, 0, 0)
} else {
Color::rgb8(255, 255, 255)
}
let mut adjusted = color
for step in 1..<21 {
let candidate = color.mix(target, step.to_double() / 20.0)
if candidate.contrast_ratio(background) >= minimum {
adjusted = candidate
break
}
}
colors.push(adjusted)
}
}
Palette::new(palette.name + "-accessible", colors)
}
///|
pub fn palette_distance_matrix(palette : Palette) -> Array[Array[Double]] {
let matrix = []
for left in palette.colors {
let row = []
for right in palette.colors {
row.push(
chromatic_distance(
chromatic_from_color(left),
chromatic_from_color(right),
),
)
}
matrix.push(row)
}
matrix
}
///|
pub fn color_sequence_score(colors : Array[Color]) -> Double {
if colors.length() < 2 {
0.0
} else {
let mut total = 0.0
for index in 0..<(colors.length() - 1) {
total += chromatic_distance(
chromatic_from_color(colors[index]),
chromatic_from_color(colors[index + 1]),
)
}
total / (colors.length() - 1).to_double()
}
}
///|
pub fn palette_reorder_maximum_separation(palette : Palette) -> Palette {
if palette.colors.length() <= 2 {
palette
} else {
let remaining = palette.colors.copy()
let ordered = [remaining.remove(0)]
while remaining.length() > 0 {
let mut selected = 0
let mut score = -1.0
for index, candidate in remaining {
let mut nearest = 1000000.0
for existing in ordered {
nearest = nearest.min(
chromatic_distance(
chromatic_from_color(candidate),
chromatic_from_color(existing),
),
)
}
if nearest > score {
selected = index
score = nearest
}
}
ordered.push(remaining.remove(selected))
}
Palette::new(palette.name + "-separated", ordered)
}
}
///|
pub fn palette_report_csv(palette : Palette) -> String {
let builder = StringBuilder()
builder.write_string("index,hex,luminance,hue,saturation,value\n")
for index, color in palette.colors {
let (hue, saturation, value) = color_to_hsv_analysis(color)
builder.write_string(
"\{index},\{color.to_hex()},\{color.luminance()},\{hue},\{saturation},\{value}\n",
)
}
builder.to_string()
}
///|
pub fn color_blend_screen(left : Color, right : Color) -> Color {
Color::rgba(
1.0 - (1.0 - left.r) * (1.0 - right.r),
1.0 - (1.0 - left.g) * (1.0 - right.g),
1.0 - (1.0 - left.b) * (1.0 - right.b),
a=left.a.max(right.a),
)
}
///|
pub fn color_blend_multiply(left : Color, right : Color) -> Color {
Color::rgba(
left.r * right.r,
left.g * right.g,
left.b * right.b,
a=left.a.max(right.a),
)
}
///|
pub fn color_blend_overlay(left : Color, right : Color) -> Color {
fn overlay(a : Double, b : Double) -> Double {
if a < 0.5 {
2.0 * a * b
} else {
1.0 - 2.0 * (1.0 - a) * (1.0 - b)
}
}
Color::rgba(
overlay(left.r, right.r),
overlay(left.g, right.g),
overlay(left.b, right.b),
a=left.a.max(right.a),
)
}
///|
pub fn color_blend_difference(left : Color, right : Color) -> Color {
Color::rgba(
(left.r - right.r).abs(),
(left.g - right.g).abs(),
(left.b - right.b).abs(),
a=left.a.max(right.a),
)
}
///|
pub fn color_blend_mode(left : Color, right : Color, mode : String) -> Color {
if mode == "multiply" {
color_blend_multiply(left, right)
} else if mode == "screen" {
color_blend_screen(left, right)
} else if mode == "difference" {
color_blend_difference(left, right)
} else {
color_blend_overlay(left, right)
}
}
///|
pub fn raster_average_color(raster : Raster) -> Color {
if raster.pixels.length() == 0 {
Color::rgba(0.0, 0.0, 0.0, a=0.0)
} else {
let mut r = 0.0
let mut g = 0.0
let mut b = 0.0
let mut a = 0.0
for color in raster.pixels {
r += color.r
g += color.g
b += color.b
a += color.a
}
let count = raster.pixels.length().to_double()
Color::rgba(r / count, g / count, b / count, a=a / count)
}
}
///|
pub fn raster_dominant_colors(
raster : Raster,
count : Int,
iterations : Int,
) -> Palette {
let colors = raster.pixels.copy()
quantize_palette(colors, count, iterations)
}
///|
pub fn raster_colorfulness(raster : Raster) -> Double {
if raster.pixels.length() == 0 {
0.0
} else {
raster.pixels.fold(init=0.0, fn(sum, color) {
sum + analysis_saturation(color)
}) /
raster.pixels.length().to_double()
}
}
///|
pub fn raster_contrast_span(raster : Raster) -> Double {
if raster.pixels.length() == 0 {
0.0
} else {
let mut low = raster.pixels[0].luminance()
let mut high = low
for color in raster.pixels {
low = low.min(color.luminance())
high = high.max(color.luminance())
}
high - low
}
}
///|
pub fn audit_palette_for_modes(
palette : Palette,
) -> Array[(ColorVisionMode, Int)] {
let result = []
for mode in [Protanopia, Deuteranopia, Tritanopia, Achromatopsia] {
result.push(
(mode, palette_collision_count(simulate_palette(palette, mode), 4.0)),
)
}
result
}
///|
pub fn palette_quality_score(palette : Palette, background : Color) -> Double {
let report = analyze_palette(palette)
let contrast = if report.contrast_max <= 1.0 {
0.0
} else {
(report.contrast_mean - 1.0) / (report.contrast_max - 1.0)
}
let diversity = (report.saturation_mean + report.contrast_max / 21.0).min(1.0)
let readability = palette.colors.fold(init=0.0, fn(sum, color) {
sum + color.contrast_ratio(background).min(7.0) / 7.0
}) /
palette.colors.length().max(1).to_double()
ca_clamp(0.35 * contrast + 0.3 * diversity + 0.35 * readability, 0.0, 1.0)
}