Skip to content

Commit

Permalink
Add Encoding support to match C version
Browse files Browse the repository at this point in the history
  • Loading branch information
bbrks committed May 13, 2019
1 parent adadfe2 commit 5191751
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 7 deletions.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# go-blurhash [![Build Status](https://travis-ci.org/bbrks/go-blurhash.svg)](https://travis-ci.org/bbrks/go-blurhash) [![codecov](https://codecov.io/gh/bbrks/go-blurhash/branch/master/graph/badge.svg)](https://codecov.io/gh/bbrks/go-blurhash) [![GoDoc](https://godoc.org/github.com/bbrks/go-blurhash?status.svg)](https://godoc.org/github.com/bbrks/go-blurhash) [![Go Report Card](https://goreportcard.com/badge/github.com/bbrks/go-blurhash)](https://goreportcard.com/report/github.com/bbrks/go-blurhash) [![GitHub tag](https://img.shields.io/github/tag/bbrks/go-blurhash.svg)](https://github.com/bbrks/go-blurhash/releases) [![license](https://img.shields.io/github/license/bbrks/go-blurhash.svg)](https://github.com/bbrks/go-blurhash/blob/master/LICENSE)

A pure Go implementation of Blurhash. Right now, almost a straight up port of the [C](https://github.com/Gargron/blurhash) and [TypeScript](https://github.com/Gargron/blurhash.js) versions, slightly adapted to Go.
A pure Go implementation of Blurhash. The API is stable, however the hashing function in either direction may not be.

Blurhash is an algorithm that encodes an image into a short (~20-30 byte) ASCII string. When you decode the string back into an image, you get a gradient of colors that represent the original image. This can be useful for scenarios where you want an image placeholder before loading, or even to censor the contents of an image [a la Mastodon](https://blog.joinmastodon.org/2019/05/improving-support-for-adult-content-on-mastodon/).
Blurhash is an algorithm written by [Dag Ågren](https://github.com/DagAgren) that encodes an image into a short (~20-30 byte) ASCII string. When you decode the string back into an image, you get a gradient of colors that represent the original image. This can be useful for scenarios where you want an image placeholder before loading, or even to censor the contents of an image [a la Mastodon](https://blog.joinmastodon.org/2019/05/improving-support-for-adult-content-on-mastodon/).

Blurhash is written by [Dag Ågren](https://github.com/DagAgren).
Under the covers, this library is almost a straight port of the [C version](https://github.com/Gargron/blurhash).

## Contributing

Expand All @@ -13,4 +13,3 @@ Issues, feature requests or improvements welcome!
## Licence

This project is licensed under the [MIT License](LICENSE).

32 changes: 31 additions & 1 deletion decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestDecode(t *testing.T) {
func TestComponents(t *testing.T) {
for _, test := range testFixtures {
// skip tests without expected component values
if test.xComp == 0 || test.yComp == 0 {
if test.hash == "" || test.xComp == 0 || test.yComp == 0 {
continue
}

Expand All @@ -46,3 +46,33 @@ func TestComponents(t *testing.T) {
})
}
}

func BenchmarkComponents(b *testing.B) {
for _, test := range testFixtures {
// skip tests without hashes
if test.hash == "" {
continue
}

b.Run(test.hash, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _, _ = blurhash.Components(test.hash)
}
})
}
}

func BenchmarkDecode(b *testing.B) {
for _, test := range testFixtures {
// skip tests without hashes
if test.hash == "" {
continue
}

b.Run(test.hash, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = blurhash.Decode(test.hash, 32, 32, 1)
}
})
}
}
144 changes: 144 additions & 0 deletions encode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package blurhash

import (
"image"
"image/color"
"math"
"strings"

"github.com/bbrks/go-blurhash/base83"
)

const (
minComponents = 1
maxComponents = 9
)

// Encode returns the blurhash for the given image.
func Encode(xComponents, yComponents int, img image.Image) (hash string, err error) {
if xComponents < minComponents || xComponents > maxComponents ||
yComponents < minComponents || yComponents > maxComponents {
return "", ErrInvalidComponents
}

b := strings.Builder{}

sizeFlag := (xComponents - 1) + (yComponents-1)*9
sizeFlagEncoded, err := base83.Encode(sizeFlag, 1)
if err != nil {
return "", err
}

_, err = b.WriteString(sizeFlagEncoded)
if err != nil {
return "", err
}

// vector of yComponents*xComponents*(RGB)
factors := make([][][3]float64, yComponents, yComponents)
for y := 0; y < yComponents; y++ {
factors[y] = make([][3]float64, xComponents, xComponents)
for x := 0; x < xComponents; x++ {
factor := multiplyBasisFunction(x, y, img)
factors[y][x][0] = factor[0]
factors[y][x][1] = factor[1]
factors[y][x][2] = factor[2]
}
}

maximumValue := 0.0
if xComponents*yComponents-1 > 0 {
actualMaximumValue := 0.0
for y := 0; y < yComponents; y++ {
for x := 0; x < xComponents; x++ {
if y == 0 && x == 0 {
continue
}
actualMaximumValue = math.Max(math.Abs(factors[y][x][0]), actualMaximumValue)
actualMaximumValue = math.Max(math.Abs(factors[y][x][1]), actualMaximumValue)
actualMaximumValue = math.Max(math.Abs(factors[y][x][2]), actualMaximumValue)
}
}

quantisedMaximumValue := math.Max(0, math.Min(82, math.Floor(actualMaximumValue*166-0.5)))
maximumValue = (quantisedMaximumValue + 1) / 166
str, err := base83.Encode(int(quantisedMaximumValue), 1)
if err != nil {
return "", err
}
b.WriteString(str)
} else {
maximumValue = 1
str, err := base83.Encode(0, 1)
if err != nil {
return "", err
}
b.WriteString(str)
}

dc := factors[0][0]
str, err := base83.Encode(encodeDC(dc[0], dc[1], dc[2]), 4)
if err != nil {
return "", err
}
b.WriteString(str)

for y := 0; y < yComponents; y++ {
for x := 0; x < xComponents; x++ {
if y == 0 && x == 0 {
continue
}
str, err := base83.Encode(encodeAC(factors[y][x][0], factors[y][x][1], factors[y][x][2], maximumValue), 2)
if err != nil {
return "", err
}
b.WriteString(str)
}
}

return b.String(), nil
}

func encodeDC(r, g, b float64) int {
return (linearTosRGB(r) << 16) + (linearTosRGB(g) << 8) + linearTosRGB(b)
}

func encodeAC(r, g, b, maximumValue float64) int {
quantR := math.Max(0, math.Min(18, math.Floor(signPow(r/maximumValue, 0.5)*9+9.5)))
quantG := math.Max(0, math.Min(18, math.Floor(signPow(g/maximumValue, 0.5)*9+9.5)))
quantB := math.Max(0, math.Min(18, math.Floor(signPow(b/maximumValue, 0.5)*9+9.5)))

return int(quantR*19*19 + quantG*19 + quantB)
}

func multiplyBasisFunction(xComponents, yComponents int, img image.Image) [3]float64 {
var r, g, b float64
width, height := float64(img.Bounds().Dx()), float64(img.Bounds().Dy())

normalisation := 2.0
if xComponents == 0 && yComponents == 0 {
normalisation = 1.0
}

for x := 0; x < img.Bounds().Dx(); x++ {
for y := 0; y < img.Bounds().Max.Y; y++ {
//cR, cG, cB, _ := img.At(x, y).RGBA()
c, ok := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
if !ok {
panic("not color.NRGBA")
}
basis := math.Cos(math.Pi*float64(xComponents)*float64(x)/width) *
math.Cos(math.Pi*float64(yComponents)*float64(y)/height)
r += basis * sRGBToLinear(int(c.R))
g += basis * sRGBToLinear(int(c.G))
b += basis * sRGBToLinear(int(c.B))
}
}

scale := normalisation / (width * height)
return [3]float64{
r * scale,
g * scale,
b * scale,
}
}
67 changes: 67 additions & 0 deletions encode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package blurhash_test

import (
"image"
"os"
"path/filepath"
"testing"

"github.com/matryer/is"

"github.com/bbrks/go-blurhash"
)

func TestEncode(t *testing.T) {
for _, test := range testFixtures {
if test.file == "" {
// skip tests without files
continue
}

t.Run(test.hash, func(t *testing.T) {
is := is.New(t)

f, err := os.Open(filepath.FromSlash(test.file))
is.NoErr(err) // error opening test fixture file
defer f.Close()

is.True(f != nil) // file should not be nil

img, _, err := image.Decode(f)
is.NoErr(err) // error decoding image from test fixture
is.True(img != nil) // image should not be nil

hash, err := blurhash.Encode(test.xComp, test.yComp, img)
is.NoErr(err) // error hashing test fixture image
is.Equal(hash, test.hash) // blurhash mismatch
})
}
}

func BenchmarkEncode(b *testing.B) {
for _, test := range testFixtures {
if test.file == "" {
// skip tests without files
continue
}

b.Run(test.hash, func(b *testing.B) {
is := is.New(b)

f, err := os.Open(filepath.FromSlash(test.file))
is.NoErr(err) // error opening test fixture file
defer f.Close()

is.True(f != nil) // file should not be nil

img, _, err := image.Decode(f)
is.NoErr(err) // error decoding image from test fixture
is.True(img != nil) // image should not be nil

b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = blurhash.Encode(test.xComp, test.yComp, img)
}
})
}
}
4 changes: 4 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package blurhash

import "errors"

// ErrInvalidComponents is returned when components passed to Encode are invalid.
var ErrInvalidComponents = errors.New("blurhash: must have between 1 and 9 components")

// ErrInvalidHash is returned when the library encounters a hash it can't recognise.
var ErrInvalidHash = errors.New("blurhash: invalid hash")

func lengthError(expectedLength, actualLength int) error {
// No stdlib support for wrapped errors, so return as-is pre-1.13
return ErrInvalidHash
}
Binary file added fixtures/octocat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var testFixtures = []struct {
xComp, yComp int
}{
{"fixtures/test.png", "LFE.@D9F01_2%L%MIVD*9Goe-;WB", 4, 3},
{"fixtures/octocat.png", "LNAdApj[00aymkj[TKay9}ay-Sj[", 4, 3},
{"", "LNMF%n00%#MwS|WCWEM{R*bbWBbH", 4, 3},
{"", "KJG8_@Dgx]_4V?xuyE%NRj", 3, 3},
}
4 changes: 2 additions & 2 deletions util.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func sRGBToLinear(val int) float64 {
func linearTosRGB(val float64) int {
v := math.Max(0, math.Min(1, val))
if v <= 0.0031308 {
return int(math.Round(v * 12.92 * 255 * 0.5))
return int(v * 12.92 * 255 * 0.5)
}
return int(math.Round((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5))
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}

0 comments on commit 5191751

Please sign in to comment.