2020-09-19 16:00:50 +00:00
// Largely inspired by the descriptions in http://lab.medialab.sciences-po.fr/iwanthue/
// but written from scratch.
package colorful
import (
"fmt"
"math"
"math/rand"
)
// The algorithm works in L*a*b* color space and converts to RGB in the end.
// L* in [0..1], a* and b* in [-1..1]
type lab_t struct {
L , A , B float64
}
type SoftPaletteSettings struct {
// A function which can be used to restrict the allowed color-space.
CheckColor func ( l , a , b float64 ) bool
// The higher, the better quality but the slower. Usually two figures.
Iterations int
// Use up to 160000 or 8000 samples of the L*a*b* space (and thus calls to CheckColor).
// Set this to true only if your CheckColor shapes the Lab space weirdly.
ManySamples bool
}
// Yeah, windows-stype Foo, FooEx, screw you golang...
// Uses K-means to cluster the color-space and return the means of the clusters
// as a new palette of distinctive colors. Falls back to K-medoid if the mean
// happens to fall outside of the color-space, which can only happen if you
// specify a CheckColor function.
func SoftPaletteEx ( colorsCount int , settings SoftPaletteSettings ) ( [ ] Color , error ) {
// Checks whether it's a valid RGB and also fulfills the potentially provided constraint.
check := func ( col lab_t ) bool {
c := Lab ( col . L , col . A , col . B )
return c . IsValid ( ) && ( settings . CheckColor == nil || settings . CheckColor ( col . L , col . A , col . B ) )
}
// Sample the color space. These will be the points k-means is run on.
dl := 0.05
dab := 0.1
if settings . ManySamples {
dl = 0.01
dab = 0.05
}
samples := make ( [ ] lab_t , 0 , int ( 1.0 / dl * 2.0 / dab * 2.0 / dab ) )
for l := 0.0 ; l <= 1.0 ; l += dl {
for a := - 1.0 ; a <= 1.0 ; a += dab {
for b := - 1.0 ; b <= 1.0 ; b += dab {
if check ( lab_t { l , a , b } ) {
samples = append ( samples , lab_t { l , a , b } )
}
}
}
}
// That would cause some infinite loops down there...
if len ( samples ) < colorsCount {
2021-03-05 10:06:25 +00:00
return nil , fmt . Errorf ( "palettegen: more colors requested (%v) than samples available (%v). Your requested color count may be wrong, you might want to use many samples or your constraint function makes the valid color space too small" , colorsCount , len ( samples ) )
2020-09-19 16:00:50 +00:00
} else if len ( samples ) == colorsCount {
return labs2cols ( samples ) , nil // Oops?
}
// We take the initial means out of the samples, so they are in fact medoids.
// This helps us avoid infinite loops or arbitrary cutoffs with too restrictive constraints.
means := make ( [ ] lab_t , colorsCount )
for i := 0 ; i < colorsCount ; i ++ {
for means [ i ] = samples [ rand . Intn ( len ( samples ) ) ] ; in ( means , i , means [ i ] ) ; means [ i ] = samples [ rand . Intn ( len ( samples ) ) ] {
}
}
clusters := make ( [ ] int , len ( samples ) )
samples_used := make ( [ ] bool , len ( samples ) )
// The actual k-means/medoid iterations
for i := 0 ; i < settings . Iterations ; i ++ {
// Reassing the samples to clusters, i.e. to their closest mean.
// By the way, also check if any sample is used as a medoid and if so, mark that.
for isample , sample := range samples {
samples_used [ isample ] = false
mindist := math . Inf ( + 1 )
for imean , mean := range means {
dist := lab_dist ( sample , mean )
if dist < mindist {
mindist = dist
clusters [ isample ] = imean
}
// Mark samples which are used as a medoid.
if lab_eq ( sample , mean ) {
samples_used [ isample ] = true
}
}
}
// Compute new means according to the samples.
for imean := range means {
// The new mean is the average of all samples belonging to it..
nsamples := 0
newmean := lab_t { 0.0 , 0.0 , 0.0 }
for isample , sample := range samples {
if clusters [ isample ] == imean {
nsamples ++
newmean . L += sample . L
newmean . A += sample . A
newmean . B += sample . B
}
}
if nsamples > 0 {
newmean . L /= float64 ( nsamples )
newmean . A /= float64 ( nsamples )
newmean . B /= float64 ( nsamples )
} else {
// That mean doesn't have any samples? Get a new mean from the sample list!
var inewmean int
for inewmean = rand . Intn ( len ( samples_used ) ) ; samples_used [ inewmean ] ; inewmean = rand . Intn ( len ( samples_used ) ) {
}
newmean = samples [ inewmean ]
samples_used [ inewmean ] = true
}
// But now we still need to check whether the new mean is an allowed color.
if nsamples > 0 && check ( newmean ) {
// It does, life's good (TM)
means [ imean ] = newmean
} else {
// New mean isn't an allowed color or doesn't have any samples!
// Switch to medoid mode and pick the closest (unused) sample.
// This should always find something thanks to len(samples) >= colorsCount
mindist := math . Inf ( + 1 )
for isample , sample := range samples {
if ! samples_used [ isample ] {
dist := lab_dist ( sample , newmean )
if dist < mindist {
mindist = dist
newmean = sample
}
}
}
}
}
}
return labs2cols ( means ) , nil
}
// A wrapper which uses common parameters.
func SoftPalette ( colorsCount int ) ( [ ] Color , error ) {
return SoftPaletteEx ( colorsCount , SoftPaletteSettings { nil , 50 , false } )
}
func in ( haystack [ ] lab_t , upto int , needle lab_t ) bool {
for i := 0 ; i < upto && i < len ( haystack ) ; i ++ {
if haystack [ i ] == needle {
return true
}
}
return false
}
const LAB_DELTA = 1e-6
func lab_eq ( lab1 , lab2 lab_t ) bool {
return math . Abs ( lab1 . L - lab2 . L ) < LAB_DELTA &&
math . Abs ( lab1 . A - lab2 . A ) < LAB_DELTA &&
math . Abs ( lab1 . B - lab2 . B ) < LAB_DELTA
}
// That's faster than using colorful's DistanceLab since we would have to
// convert back and forth for that. Here is no conversion.
func lab_dist ( lab1 , lab2 lab_t ) float64 {
return math . Sqrt ( sq ( lab1 . L - lab2 . L ) + sq ( lab1 . A - lab2 . A ) + sq ( lab1 . B - lab2 . B ) )
}
func labs2cols ( labs [ ] lab_t ) ( cols [ ] Color ) {
cols = make ( [ ] Color , len ( labs ) )
for k , v := range labs {
cols [ k ] = Lab ( v . L , v . A , v . B )
}
return cols
}