Skip to content

Commit

Permalink
Add Luminance to Color
Browse files Browse the repository at this point in the history
To sort an image's colors from darkest to lightest, you can then do:

```handlebars
{{ {{ $colorsByLuminance := sort $image.Colors "Luminance" }}
```

This uses the formula defined here: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance

Fixes #10450
  • Loading branch information
bep committed Apr 16, 2024
1 parent 74e9129 commit e197c7b
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 39 deletions.
14 changes: 14 additions & 0 deletions common/hstrings/strings.go
Expand Up @@ -123,6 +123,20 @@ func InSlicEqualFold(arr []string, el string) bool {
return false
}

// ToString converts the given value to a string.
// Note that this is a more strict version compared to cast.ToString,
// as it will not try to convert numeric values to strings,
// but only accept strings or fmt.Stringer.
func ToString(v any) (string, bool) {
switch vv := v.(type) {
case string:
return vv, true
case fmt.Stringer:
return vv.String(), true
}
return "", false
}

type Tuple struct {
First string
Second string
Expand Down
2 changes: 1 addition & 1 deletion resources/errorResource.go
Expand Up @@ -128,7 +128,7 @@ func (e *errorResource) Exif() *exif.ExifInfo {
panic(e.ResourceError)
}

func (e *errorResource) Colors() ([]string, error) {
func (e *errorResource) Colors() ([]images.Color, error) {
panic(e.ResourceError)
}

Expand Down
6 changes: 3 additions & 3 deletions resources/image.go
Expand Up @@ -67,7 +67,7 @@ type imageResource struct {
meta *imageMeta

dominantColorInit sync.Once
dominantColors []string
dominantColors []images.Color

baseResource
}
Expand Down Expand Up @@ -143,7 +143,7 @@ func (i *imageResource) getExif() *exif.ExifInfo {

// Colors returns a slice of the most dominant colors in an image
// using a simple histogram method.
func (i *imageResource) Colors() ([]string, error) {
func (i *imageResource) Colors() ([]images.Color, error) {
var err error
i.dominantColorInit.Do(func() {
var img image.Image
Expand All @@ -153,7 +153,7 @@ func (i *imageResource) Colors() ([]string, error) {
}
colors := color_extractor.ExtractColors(img)
for _, c := range colors {
i.dominantColors = append(i.dominantColors, images.ColorToHexString(c))
i.dominantColors = append(i.dominantColors, images.ColorGoToColor(c))
}
})
return i.dominantColors, nil
Expand Down
29 changes: 27 additions & 2 deletions resources/image_test.go
Expand Up @@ -85,9 +85,16 @@ func TestImageTransformBasic(t *testing.T) {
assertWidthHeight(c, img, w, h)
}

colors, err := image.Colors()
gotColors, err := image.Colors()
c.Assert(err, qt.IsNil)
c.Assert(colors, qt.DeepEquals, []string{"#2d2f33", "#a49e93", "#d39e59", "#a76936", "#737a84", "#7c838b"})
expectedColors := images.HexStringsToColors("#2d2f33", "#a49e93", "#d39e59", "#a76936", "#737a84", "#7c838b")
c.Assert(len(gotColors), qt.Equals, len(expectedColors))
for i := range gotColors {
c1, c2 := gotColors[i], expectedColors[i]
c.Assert(c1.ColorHex(), qt.Equals, c2.ColorHex())
c.Assert(c1.ColorGo(), qt.DeepEquals, c2.ColorGo())
c.Assert(c1.Luminance(), qt.Equals, c2.Luminance())
}

c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.jpg")
c.Assert(image.ResourceType(), qt.Equals, "image")
Expand Down Expand Up @@ -445,6 +452,24 @@ func TestImageExif(t *testing.T) {
getAndCheckExif(c, image)
}

func TestImageColorsLuminance(t *testing.T) {
c := qt.New(t)

_, image := fetchSunset(c)
c.Assert(image, qt.Not(qt.IsNil))
colors, err := image.Colors()
c.Assert(err, qt.IsNil)
c.Assert(len(colors), qt.Equals, 6)
var prevLuminance float64
for i, color := range colors {
luminance := color.Luminance()
c.Assert(err, qt.IsNil)
c.Assert(luminance > 0, qt.IsTrue)
c.Assert(luminance, qt.Not(qt.Equals), prevLuminance, qt.Commentf("i=%d", i))
prevLuminance = luminance
}
}

func BenchmarkImageExif(b *testing.B) {
getImages := func(c *qt.C, b *testing.B, fs afero.Fs) []images.ImageResource {
spec := newTestResourceSpec(specDescriptor{fs: fs, c: c})
Expand Down
120 changes: 116 additions & 4 deletions resources/images/color.go
Expand Up @@ -16,10 +16,76 @@ package images
import (
"encoding/hex"
"fmt"
"hash/fnv"
"image/color"
"math"
"strings"

"github.com/gohugoio/hugo/common/hstrings"
)

type colorGoProvider interface {
ColorGo() color.Color
}

type Color struct {
// The color.
color color.Color

// The color prefixed with a #.
hex string

// The relative luminance of the color.
luminance float64
}

// Luminance as defined by w3.org.
// See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
func (c Color) Luminance() float64 {
return c.luminance
}

// ColorGo returns the color as a color.Color.
// For internal use only.
func (c Color) ColorGo() color.Color {
return c.color
}

// ColorHex returns the color as a hex string prefixed with a #.
func (c Color) ColorHex() string {
return c.hex
}

// String returns the color as a hex string prefixed with a #.
func (c Color) String() string {
return c.hex
}

// For hashstructure. This struct is used in template func options
// that needs to be able to hash a Color.
// For internal use only.
func (c Color) Hash() (uint64, error) {
h := fnv.New64a()
h.Write([]byte(c.hex))
return h.Sum64(), nil
}

func (c *Color) init() error {
c.hex = ColorGoToHexString(c.color)
r, g, b, _ := c.color.RGBA()
c.luminance = 0.2126*c.toSRGB(uint8(r)) + 0.7152*c.toSRGB(uint8(g)) + 0.0722*c.toSRGB(uint8(b))
return nil
}

func (c Color) toSRGB(i uint8) float64 {
v := float64(i) / 255
if v <= 0.04045 {
return v / 12.92
} else {
return math.Pow((v+0.055)/1.055, 2.4)
}
}

// AddColorToPalette adds c as the first color in p if not already there.
// Note that it does no additional checks, so callers must make sure
// that the palette is valid for the relevant format.
Expand All @@ -45,14 +111,60 @@ func ReplaceColorInPalette(c color.Color, p color.Palette) {
p[p.Index(c)] = c
}

// ColorToHexString converts a color to a hex string.
func ColorToHexString(c color.Color) string {
// ColorGoToHexString converts a color.Color to a hex string.
func ColorGoToHexString(c color.Color) string {
r, g, b, a := c.RGBA()
rgba := color.RGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
return fmt.Sprintf("#%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
if rgba.A == 0xff {
return fmt.Sprintf("#%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
}
return fmt.Sprintf("#%.2x%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B, rgba.A)
}

// ColorGoToColor converts a color.Color to a Color.
func ColorGoToColor(c color.Color) Color {
cc := Color{color: c}
if err := cc.init(); err != nil {
panic(err)
}
return cc
}

func hexStringToColor(s string) Color {
c, err := hexStringToColorGo(s)
if err != nil {
panic(err)
}
return ColorGoToColor(c)
}

// HexStringsToColors converts a slice of hex strings to a slice of Colors.
func HexStringsToColors(s ...string) []Color {
var colors []Color
for _, v := range s {
colors = append(colors, hexStringToColor(v))
}
return colors
}

func toColorGo(v any) (color.Color, bool, error) {
switch vv := v.(type) {
case colorGoProvider:
return vv.ColorGo(), true, nil
default:
s, ok := hstrings.ToString(v)
if !ok {
return nil, false, nil
}
c, err := hexStringToColorGo(s)
if err != nil {
return nil, false, err
}
return c, true, nil
}
}

func hexStringToColor(s string) (color.Color, error) {
func hexStringToColorGo(s string) (color.Color, error) {
s = strings.TrimPrefix(s, "#")

if len(s) != 3 && len(s) != 4 && len(s) != 6 && len(s) != 8 {
Expand Down
25 changes: 19 additions & 6 deletions resources/images/color_test.go
Expand Up @@ -46,7 +46,7 @@ func TestHexStringToColor(t *testing.T) {
c.Run(test.arg, func(c *qt.C) {
c.Parallel()

result, err := hexStringToColor(test.arg)
result, err := hexStringToColorGo(test.arg)

if b, ok := test.expect.(bool); ok && !b {
c.Assert(err, qt.Not(qt.IsNil))
Expand All @@ -70,13 +70,18 @@ func TestColorToHexString(t *testing.T) {
{color.White, "#ffffff"},
{color.Black, "#000000"},
{color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0xff}, "#4287f5"},

// 50% opacity.
// Note that the .Colors (dominant colors) received from the Image resource
// will always have an alpha value of 0xff.
{color.RGBA{R: 0x42, G: 0x87, B: 0xf5, A: 0x80}, "#4287f580"},
} {

test := test
c.Run(test.expect, func(c *qt.C) {
c.Parallel()

result := ColorToHexString(test.arg)
result := ColorGoToHexString(test.arg)

c.Assert(result, qt.Equals, test.expect)
})
Expand All @@ -91,9 +96,9 @@ func TestAddColorToPalette(t *testing.T) {

c.Assert(AddColorToPalette(color.White, palette), qt.HasLen, 2)

blue1, _ := hexStringToColor("34c3eb")
blue2, _ := hexStringToColor("34c3eb")
white, _ := hexStringToColor("fff")
blue1, _ := hexStringToColorGo("34c3eb")
blue2, _ := hexStringToColorGo("34c3eb")
white, _ := hexStringToColorGo("fff")

c.Assert(AddColorToPalette(white, palette), qt.HasLen, 2)
c.Assert(AddColorToPalette(blue1, palette), qt.HasLen, 3)
Expand All @@ -104,10 +109,18 @@ func TestReplaceColorInPalette(t *testing.T) {
c := qt.New(t)

palette := color.Palette{color.White, color.Black}
offWhite, _ := hexStringToColor("fcfcfc")
offWhite, _ := hexStringToColorGo("fcfcfc")

ReplaceColorInPalette(offWhite, palette)

c.Assert(palette, qt.HasLen, 2)
c.Assert(palette[0], qt.Equals, offWhite)
}

func TestColorLuminance(t *testing.T) {
c := qt.New(t)
c.Assert(hexStringToColor("#000000").Luminance(), qt.Equals, 0.0)
c.Assert(hexStringToColor("#768a9a").Luminance(), qt.Equals, 0.24361603589088263)
c.Assert(hexStringToColor("#d5bc9f").Luminance(), qt.Equals, 0.5261577672685374)
c.Assert(hexStringToColor("#ffffff").Luminance(), qt.Equals, 1.0)
}
6 changes: 3 additions & 3 deletions resources/images/config.go
Expand Up @@ -171,7 +171,7 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
return i, nil, err
}

i.BgColor, err = hexStringToColor(i.Imaging.BgColor)
i.BgColor, err = hexStringToColorGo(i.Imaging.BgColor)
if err != nil {
return i, nil, err
}
Expand Down Expand Up @@ -230,7 +230,7 @@ func DecodeImageConfig(action string, options []string, defaults *config.ConfigN
c.Hint = hint
} else if part[0] == '#' {
c.BgColorStr = part[1:]
c.BgColor, err = hexStringToColor(c.BgColorStr)
c.BgColor, err = hexStringToColorGo(c.BgColorStr)
if err != nil {
return c, err
}
Expand Down Expand Up @@ -424,7 +424,7 @@ type ImagingConfigInternal struct {

func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
var err error
i.BgColor, err = hexStringToColor(externalCfg.BgColor)
i.BgColor, err = hexStringToColorGo(externalCfg.BgColor)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion resources/images/config_test.go
Expand Up @@ -132,7 +132,7 @@ func newImageConfig(action string, width, height, quality, rotate int, filter, a
c.qualitySetForImage = quality != 75
c.Rotate = rotate
c.BgColorStr = bgColor
c.BgColor, _ = hexStringToColor(bgColor)
c.BgColor, _ = hexStringToColorGo(bgColor)

if filter != "" {
filter = strings.ToLower(filter)
Expand Down

0 comments on commit e197c7b

Please sign in to comment.