Packages
Pure Elixir implementation of Blurhash algorithm with no additional dependencies. Blurhash is an algorithm by Dag Ă…gren of Wolt that decodes an image to a very compact (~ 20-30 bytes) ASCII string representation, which can be then decoded into a blurred placeholder image.
Current section
Files
Jump to
Current section
Files
lib/blur_hash.ex
defmodule BlurHash do
@moduledoc """
BlurHash implementation in Elixir.
BlurHash is a compact representation of a placeholder for an image.
It applies a DCT transform to the image data and encodes the components
using a base 83 encoding.
## Examples
iex> pixels = BlurHash.decode("LlMF%n00%#MwS|WCWEM{R*bbWBbH", 4, 3)
iex> length(pixels)
36
iex> Enum.all?(pixels, fn x -> x >= 0 and x <= 255 end)
true
"""
# Base83 character set for encoding
@base83_chars "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
@base83_chars_list String.graphemes(@base83_chars)
@base83_chars_map @base83_chars_list
|> Enum.with_index()
|> Enum.into(%{})
@doc """
Encode an image to a BlurHash string.
## Parameters
- `pixels`: List of RGB pixel values [r, g, b, r, g, b, ...]
- `width`: Image width
- `height`: Image height
- `x_components`: Number of components along X axis (1-9)
- `y_components`: Number of components along Y axis (1-9)
## Returns
BlurHash string
## Examples
iex> pixels = [255, 0, 0, 0, 255, 0, 0, 0, 255]
iex> blurhash = BlurHash.encode(pixels, 3, 1, 4, 3)
iex> is_binary(blurhash)
true
iex> String.length(blurhash) > 6
true
"""
def encode(pixels, width, height, x_components, y_components) do
if length(pixels) != width * height * 3 do
raise ArgumentError, "Pixel array size doesn't match dimensions"
end
ac_count = x_components * y_components - 1
# Calculate DCT factors
factors = calculate_factors(pixels, width, height, x_components, y_components)
# Extract DC and AC components
dc = hd(factors)
ac = tl(factors)
# Encode size flag
size_flag = x_components - 1 + (y_components - 1) * 9
hash = encode_base83(size_flag, 1)
# Calculate and encode maximum AC value
{max_ac_encoded, max_ac_value} =
if ac_count > 0 do
actual_max = ac |> Enum.flat_map(&Tuple.to_list/1) |> Enum.map(&abs/1) |> Enum.max()
quantised_max_ac = max(0, min(82, floor(actual_max * 166 - 0.5)))
{quantised_max_ac, (quantised_max_ac + 1) / 166}
else
{0, 1.0}
end
hash = hash <> encode_base83(max_ac_encoded, 1)
# Encode DC component
dc_encoded = encode_dc(dc)
hash = hash <> encode_base83(dc_encoded, 4)
# Encode AC components
ac_encoded = Enum.map(ac, fn component -> encode_ac(component, max_ac_value) end)
ac_hash = Enum.map(ac_encoded, fn value -> encode_base83(value, 2) end) |> Enum.join()
hash <> ac_hash
end
@doc """
Decode a BlurHash string to RGB pixel data.
## Parameters
- `blurhash`: BlurHash string
- `width`: Desired output width
- `height`: Desired output height
- `punch`: Contrast adjustment (default: 1.0)
## Returns
List of RGB pixel values [r, g, b, r, g, b, ...]
## Examples
iex> pixels = BlurHash.decode("LlMF%n00%#MwS|WCWEM{R*bbWBbH", 4, 3)
iex> length(pixels)
36
iex> Enum.all?(pixels, fn x -> x >= 0 and x <= 255 end)
true
"""
def decode(blurhash, width, height, punch \\ 1.0) do
if String.length(blurhash) < 6 do
raise ArgumentError, "BlurHash must be at least 6 characters"
end
# Parse size flag
size_flag = decode_base83(String.slice(blurhash, 0, 1))
num_y = div(size_flag, 9) + 1
num_x = rem(size_flag, 9) + 1
expected_length = 4 + 2 * num_x * num_y
if String.length(blurhash) != expected_length do
raise ArgumentError,
"Invalid BlurHash length: expected #{expected_length}, got #{String.length(blurhash)}"
end
# Parse maximum AC value
max_ac_encoded = decode_base83(String.slice(blurhash, 1, 1))
max_ac = (max_ac_encoded + 1) / 166 * punch
# Parse DC component
dc_encoded = decode_base83(String.slice(blurhash, 2, 4))
dc = decode_dc(dc_encoded)
# Parse AC components
ac_components =
for i <- 1..(num_x * num_y - 1) do
start_pos = 4 + i * 2
ac_encoded = decode_base83(String.slice(blurhash, start_pos, 2))
decode_ac(ac_encoded, max_ac)
end
colors = [dc | ac_components]
# Generate pixel data
for y <- 0..(height - 1), x <- 0..(width - 1) do
{r, g, b} =
colors
|> Enum.with_index()
|> Enum.reduce({0.0, 0.0, 0.0}, fn {{color_r, color_g, color_b}, index},
{acc_r, acc_g, acc_b} ->
j = div(index, num_x)
i = rem(index, num_x)
basis = :math.cos(:math.pi() * x * i / width) * :math.cos(:math.pi() * y * j / height)
{acc_r + color_r * basis, acc_g + color_g * basis, acc_b + color_b * basis}
end)
[linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b)]
end
|> List.flatten()
end
# Private helper functions
defp calculate_factors(pixels, width, height, x_components, y_components) do
for y <- 0..(y_components - 1), x <- 0..(x_components - 1) do
normalisation = if x == 0 and y == 0, do: 1.0, else: 2.0
{r, g, b} = multiply_basis_function(pixels, width, height, x, y)
scale = normalisation / (width * height)
{r * scale, g * scale, b * scale}
end
end
defp multiply_basis_function(pixels, width, height, x_component, y_component) do
pixels
|> Enum.chunk_every(3)
|> Enum.with_index()
|> Enum.reduce({0.0, 0.0, 0.0}, fn {[r, g, b], pixel_index}, {acc_r, acc_g, acc_b} ->
x = rem(pixel_index, width)
y = div(pixel_index, width)
basis =
:math.cos(:math.pi() * x_component * x / width) *
:math.cos(:math.pi() * y_component * y / height)
linear_r = srgb_to_linear(r)
linear_g = srgb_to_linear(g)
linear_b = srgb_to_linear(b)
{acc_r + basis * linear_r, acc_g + basis * linear_g, acc_b + basis * linear_b}
end)
end
defp encode_dc({r, g, b}) do
rounded_r = linear_to_srgb(r)
rounded_g = linear_to_srgb(g)
rounded_b = linear_to_srgb(b)
Bitwise.bsl(rounded_r, 16) + Bitwise.bsl(rounded_g, 8) + rounded_b
end
defp encode_ac({r, g, b}, max_value) do
quant_r = max(0, min(18, floor(sign_pow(r / max_value, 0.5) * 9 + 9.5)))
quant_g = max(0, min(18, floor(sign_pow(g / max_value, 0.5) * 9 + 9.5)))
quant_b = max(0, min(18, floor(sign_pow(b / max_value, 0.5) * 9 + 9.5)))
trunc(quant_r * 19 * 19 + quant_g * 19 + quant_b)
end
defp decode_dc(value) do
r = Bitwise.bsr(value, 16)
g = Bitwise.band(Bitwise.bsr(value, 8), 255)
b = Bitwise.band(value, 255)
{srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)}
end
defp decode_ac(value, max_value) do
quant_r = div(value, 19 * 19)
quant_g = rem(div(value, 19), 19)
quant_b = rem(value, 19)
r = sign_pow((quant_r - 9) / 9, 2.0) * max_value
g = sign_pow((quant_g - 9) / 9, 2.0) * max_value
b = sign_pow((quant_b - 9) / 9, 2.0) * max_value
{r, g, b}
end
defp srgb_to_linear(value) do
v = value / 255.0
if v <= 0.04045 do
v / 12.92
else
:math.pow((v + 0.055) / 1.055, 2.4)
end
end
defp linear_to_srgb(value) do
v = max(0, min(1, value))
result =
if v <= 0.0031308 do
v * 12.92 * 255
else
(1.055 * :math.pow(v, 1 / 2.4) - 0.055) * 255
end
trunc(result)
end
defp sign_pow(value, exp) do
sign = if value < 0, do: -1, else: 1
sign * :math.pow(abs(value), exp)
end
defp encode_base83(value, length) do
{result, _} =
Enum.reduce((length - 1)..0, {[], value}, fn i, {acc, val} ->
power = trunc(:math.pow(83, i))
digit = div(val, power)
new_val = rem(val, power)
{[Enum.at(@base83_chars_list, digit) | acc], new_val}
end)
result |> Enum.reverse() |> Enum.join()
end
defp decode_base83(string) do
string
|> String.graphemes()
|> Enum.reduce(0, fn char, acc ->
acc * 83 + Map.get(@base83_chars_map, char, 0)
end)
end
end