Deluan Quintão fd4b3256e4
perf(artwork): compute the blurhash DCT separably (#5989)
The cosine basis factors into cosX[i][x] * cosY[j][y], so the pixel loop
does not need to visit every (i,j) pair. Each row now collapses to xComp
dot products, folded over yComp once per row: w*h*xComp + h*xComp*yComp
multiply-accumulates instead of w*h*xComp*yComp.

Encoding is ~60% faster at every input size, and ~80% faster at the 128px
size the artwork pipeline actually feeds it (263us -> 53us). Hashes are
byte-identical, so the existing golden-value specs cover the rewrite.
2026-08-19 16:20:38 -04:00

209 lines
5.9 KiB
Go

// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash),
// parameterized to match Jellyfin so clients see equivalent hashes.
package blurhash
import (
"errors"
"image"
"image/draw"
"math"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// maxInputSize: larger inputs are slower with no visible difference in the result.
const maxInputSize = 128
// components picks x/y component counts targeting ~16 near-square tiles.
func components(width, height int) (int, int) {
xf := math.Sqrt(16.0 * float64(width) / float64(height))
yf := xf * float64(height) / float64(width)
return min(int(xf)+1, 9), min(int(yf)+1, 9)
}
// Encode returns the blurhash of img, deriving the component counts from its aspect ratio.
func Encode(img image.Image) (string, error) {
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
return "", errors.New("blurhash: empty image")
}
// Pre-downscale: its rounding can flip a component count, and the hash is a client cache key.
xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy())
src := pixelsOf(downscale(img))
w, h := src.w, src.h
cosX := make([][]float64, xComp)
for i := range cosX {
cosX[i] = make([]float64, w)
for x := range cosX[i] {
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
}
}
cosY := make([][]float64, yComp)
for j := range cosY {
cosY[j] = make([]float64, h)
for y := range cosY[j] {
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
}
}
lin := srgbToLinearTable()
factors := make([][3]float64, xComp*yComp)
linR := make([]float64, w)
linG := make([]float64, w)
linB := make([]float64, w)
rowR := make([]float64, xComp)
rowG := make([]float64, xComp)
rowB := make([]float64, xComp)
for y := range h {
row := src.pix[y*src.stride:]
for x := range w {
p := x * 4
r, g, b := row[p], row[p+1], row[p+2]
if src.straight {
r, g, b = premultiply(r, g, b, row[p+3])
}
linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
}
// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
// rather than xComp*yComp multiply-accumulates per pixel.
for i := range xComp {
var sr, sg, sb float64
for x, c := range cosX[i] {
sr += c * linR[x]
sg += c * linG[x]
sb += c * linB[x]
}
rowR[i], rowG[i], rowB[i] = sr, sg, sb
}
for j := range yComp {
cy := cosY[j][y]
for i := range xComp {
f := &factors[j*xComp+i]
f[0] += cy * rowR[i]
f[1] += cy * rowG[i]
f[2] += cy * rowB[i]
}
}
}
for idx := range factors {
norm := 2.0
if idx == 0 {
norm = 1.0
}
scale := norm / float64(w*h)
factors[idx][0] *= scale
factors[idx][1] *= scale
factors[idx][2] *= scale
}
var sb strings.Builder
sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1))
// Derived counts are at least 1x9, so there is always at least one AC factor.
ac := factors[1:]
actualMax := 0.0
for _, f := range ac {
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
}
quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5))))
maxVal := float64(quantMax+1) / 166
sb.WriteString(encode83(quantMax, 1))
dc := factors[0]
sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
for _, f := range ac {
sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
}
return sb.String(), nil
}
// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At.
type pixels struct {
pix []uint8
stride int
w, h int
// straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash
// identical to the one an equivalent *image.RGBA produces.
straight bool
}
// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts
// anything else.
func pixelsOf(img image.Image) pixels {
b := img.Bounds()
switch src := img.(type) {
case *image.RGBA:
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()}
case *image.NRGBA:
return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true}
}
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()}
}
func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) {
if a == 255 {
return r, g, b
}
return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255)
}
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
var t [256]float64
for i := range t {
t[i] = srgbToLinear(i)
}
return &t
})
func downscale(img image.Image) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxInputSize && h <= maxInputSize {
return img
}
scale := float64(maxInputSize) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
return dst
}
func quantAC(v, maxVal float64) int {
return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
}
func signPow(v, exp float64) float64 {
return math.Copysign(math.Pow(math.Abs(v), exp), v)
}
func srgbToLinear(v int) float64 {
f := float64(v) / 255
if f <= 0.04045 {
return f / 12.92
}
return math.Pow((f+0.055)/1.055, 2.4)
}
func linearToSRGB(v float64) int {
v = min(max(0, v), 1)
if v <= 0.0031308 {
return int(v*12.92*255 + 0.5)
}
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}
// encode83 encodes value as a fixed-width, big-endian base83 string of the given length.
func encode83(value, length int) string {
b := make([]byte, length)
for i := length - 1; i >= 0; i-- {
b[i] = alphabet[value%83]
value /= 83
}
return string(b)
}