Skip to content

Commit

Permalink
Add Image.Blurhash.encode/2
Browse files Browse the repository at this point in the history
  • Loading branch information
kipcole9 committed Apr 8, 2024
1 parent 36634c5 commit 68883a4
Show file tree
Hide file tree
Showing 10 changed files with 464 additions and 1 deletion.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Image 0.44.0

This is the changelog for Image version 0.44.0 released on ______, 2024. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-image/image/tags)

### Enhancements

* Adds `Image.Blurhash.encode/2` and `Image.Blurhash.decode/1` to encode and decode [blurhashes](https://blurha.sh). Based upon a fork of [rinpatch_blurhash](https://github.com/rinpatch/blurhash). Thanks to @stiang for the suggestion.

## Image 0.43.2

This is the changelog for Image version 0.43.2 released on April 2nd, 2024. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-image/image/tags)
Expand Down
92 changes: 92 additions & 0 deletions lib/image/blurhash.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
defmodule Image.Blurhash do
@moduledoc """
BlurHash is an algorithm developed by [Wolt](https://github.com/woltapp)
that allows encoding of an image into a compact string representation
called a blurhash. This string can then be decoded back into
an image, providing a low-resolution placeholder that can be
displayed quickly while the actual image is being loaded.
It combines the benefits of data compression and perceptual
hashing to create visually pleasing representations of images.
The blurhash string consists of a short sequence of characters
that represents the image's colors and their distribution. By
adjusting the length of the blurhash, you can control the level
of detail and the amount of data required to represent the image.
The encode and decoder in this implementation are a fork of
the [rinpatch_blurhash](https://github.com/rinpatch/blurhash) library
by @rinpatch.
"""

alias Vix.Vips.Image, as: Vimage

@doc """
Encodes an image as a [blurhash](https://blurha.sh).
`Image.Blurhash.encode/2` takes an image and returns a short string
(only 20-30 characters) that represents the placeholder
for this image.
It is intended that calculating a blurhash is performed
in a background process and stored for retrieval on demand
when rendering a page.
### Arguments
* `image` is any `t:Vix.Vips.Image.t/0`.
* `options` is a keyword list of options. The default is
`[x_components: 4, y_components: 3]`.
### Options
* `:x_components` represents the number of horizontal blocks used
to calculate the blurhash.
* `:y_components` represents the number of vertical blocks used
to calculate the blurhash.
### Returns
* `{:ok, blurhash}` or
* `{:error, reason}`
### Selecting the number of X and Y components
A higher `:x_components` and `:y_components` value will result in
more details in the blurhash in the X and Y direction respectively.
A lower value will create a more abstract representation.
By adjusting the X and Y components, you can control the level of
granularity and complexity in the generated blurhash. However, it's
important to note that increasing the X and Y values also increases
the size of the blurhash string, which may impact performance and
bandwidth usage.
The default of `[x_components: 4, y_components: 3]` is a good starting
points but if the the image aspect ratio is portrait, a higher
`:y_compnents` value may be appropriate.
### Example
iex> image = Image.open!("./test/support/images/Kip_small.jpg")
iex> Image.Blurhash.encode(image)
{:ok, "LBA,zk9F00~qofWBt7t700%M?bD%"}
"""
@doc subject: "Operation", since: "0.44.0"

@spec encode(image :: Vimage.t(), options :: Keyword.t()) ::
{:ok, String.t} | {:error, Image.error_message()}

def encode(%Vimage{} = image, options \\ []) do
with {:ok, options} <- Image.Options.Blurhash.validate_options(image, options),
{:ok, binary} <- Vimage.write_to_binary(image) do
{width, height, _bands} = Image.shape(image)
Image.Blurhash.Encoder.encode(binary, width, height, options.x_components, options.y_components)
end
end
end
39 changes: 39 additions & 0 deletions lib/image/blurhash/base83.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule Image.Blurhash.Base83 do
@moduledoc false

alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'

for {encoded, value} <- Enum.with_index(alphabet) do
def encode_char(unquote(value)) do
unquote(encoded)
end

def decode_char(unquote(encoded)) do
unquote(value)
end
end

def decode_number(string, length, acc \\ 0)

def decode_number(rest, 0, acc) do
{:ok, acc, rest}
end

def decode_number(<<>>, _, _) do
{:error, :unexpected_end}
end

def decode_number(<<char, rest::binary>>, length, acc) do
decode_number(rest, length - 1, acc * 83 + decode_char(char))
end

def encode_number(_, 0), do: ""

def encode_number(number, length) do
divisor = floor(:math.pow(83, length - 1))
remainder = rem(number, divisor)
quotient = floor(number / divisor)

<<encode_char(quotient)>> <> encode_number(remainder, length - 1)
end
end
109 changes: 109 additions & 0 deletions lib/image/blurhash/decoder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
defmodule Image.Blurhash.Decoder do
@moduledoc false

import Image.Blurhash.Utils
alias Image.Blurhash.Base83

import Bitwise

defp size_flag(blurhash) do
with {:ok, encoded_flag, rest} <- Base83.decode_number(blurhash, 1) do
x = rem(encoded_flag, 9) + 1
y = floor(encoded_flag / 9) + 1
{:ok, {x, y}, rest}
end
end

defp max_ac(blurhash) do
with {:ok, quantized_max, rest} <- Base83.decode_number(blurhash, 1) do
{:ok, (quantized_max + 1) / 166, rest}
end
end

defp average_color_and_dc(blurhash) do
with {:ok, raw, rest} <- Base83.decode_number(blurhash, 4) do
{r, g, b} = color = {bsr(raw, 16), band(bsr(raw, 8), 255), band(raw, 255)}
dc = {srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)}
{:ok, {color, dc}, rest}
end
end

def construct_matrix(encoded_ac, max_ac, x, y, dc) do
size = x * y - 1

try do
# We start with 1 because {0, 0} is the DC
{ac_values, rest} =
Enum.map_reduce(1..size, encoded_ac, fn index, rest ->
case Base83.decode_number(rest, 2) do
{:ok, value, rest} ->
# add matrix position with the color since we will need it for
# inverse dct later
matrix_pos = {rem(index, x), floor(index / x)}

quantized_r = floor(value / (19 * 19))
quantized_g = floor(rem(floor(value / 19), 19))
quantized_b = rem(value, 19)

r = unquantize_color(quantized_r, max_ac)
g = unquantize_color(quantized_g, max_ac)
b = unquantize_color(quantized_b, max_ac)

{{matrix_pos, {r, g, b}}, rest}

# Haven't found a more elegant solution to throwing in this case
error ->
throw(error)
end
end)

if rest != "" do
{:error, :unexpected_components}
else
{r, g, b} = dc
matrix = [{{0, 0}, {r, g, b}} | ac_values]
{:ok, matrix}
end
catch
error -> error
end
end

def construct_pixel_iodata(width, height, matrix) do
Enum.reduce((height - 1)..0, [], fn y, acc ->
pixel_row =
Enum.reduce((width - 1)..0, [], fn x, acc ->
{linear_r, linear_g, linear_b} =
Enum.reduce(matrix, {0, 0, 0}, fn {{component_x, component_y},
{current_red, current_green, current_blue}},
{red, green, blue} ->
idct_basis =
:math.cos(:math.pi() * x * component_x / width) *
:math.cos(:math.pi() * y * component_y / height)

{red + current_red * idct_basis, green + current_green * idct_basis,
blue + current_blue * idct_basis}
end)

r = linear_to_srgb(linear_r)
g = linear_to_srgb(linear_g)
b = linear_to_srgb(linear_b)

[<<r::8, g::8, b::8>> | acc]
end)

[pixel_row | acc]
end)
end

def decode(blurhash, width, height) do
with {:ok, {components_x, components_y}, rest} <- size_flag(blurhash),
{:ok, max_ac, rest} <- max_ac(rest),
{:ok, {average_color, dc}, rest} <- average_color_and_dc(rest),
{:ok, matrix} <- construct_matrix(rest, max_ac, components_x, components_y, dc) do
pixels = construct_pixel_iodata(width, height, matrix)

{:ok, pixels, average_color}
end
end
end
103 changes: 103 additions & 0 deletions lib/image/blurhash/encoder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defmodule Image.Blurhash.Encoder do
@moduledoc false

import Image.Blurhash.Utils
alias Image.Blurhash.Base83

import Bitwise

def encode(pixels, width, height, components_x, components_y) do
components = calculate_components(pixels, components_x, components_y, width, height)
{:ok, encode_blurhash(components, components_x, components_y)}
end

defp encode_blurhash([dc | ac], x, y) do
size_flag = encode_size_flag(x, y)
dc = encode_dc(dc)
{max_ac, ac} = encode_ac(ac)

size_flag <> max_ac <> dc <> ac
end

defp calculate_component(pixels, component_x, component_y, width, height, acc \\ {{0, 0, 0}, 0})

defp calculate_component(
<<r::8, g::8, b::8, rest::binary>>,
component_x,
component_y,
width,
height,
{{acc_r, acc_g, acc_b}, index}
) do
pixel_x = rem(index, width)
pixel_y = floor(index / width)

# DC
normalization_factor =
unless component_x == 0 and component_y == 0,
do: 2,
else: 1

basis =
normalization_factor * :math.cos(:math.pi() * pixel_x * component_x / width) *
:math.cos(:math.pi() * pixel_y * component_y / height)

linear_r = srgb_to_linear(r)
linear_g = srgb_to_linear(g)
linear_b = srgb_to_linear(b)

acc_r = acc_r + basis * linear_r
acc_g = acc_g + basis * linear_g
acc_b = acc_b + basis * linear_b

acc = {{acc_r, acc_g, acc_b}, index + 1}
calculate_component(rest, component_x, component_y, width, height, acc)
end

defp calculate_component(_, _, _, width, height, {{r, g, b}, _}) do
scale = 1 / (width * height)
{r * scale, g * scale, b * scale}
end

defp calculate_components(pixels, x, y, width, height) do
for y <- 0..(y - 1),
x <- 0..(x - 1) do
{{x, y}, calculate_component(pixels, x, y, width, height)}
end
end

defp encode_size_flag(x, y) do
Base83.encode_number(x - 1 + (y - 1) * 9, 1)
end

defp encode_dc({_, {linear_r, linear_g, linear_b}}) do
r = linear_to_srgb(linear_r)
g = linear_to_srgb(linear_g)
b = linear_to_srgb(linear_b)

Base83.encode_number(bsl(r, 16) + bsl(g, 8) + b, 4)
end

defp encode_ac([]) do
{Base83.encode_number(0, 1), ""}
end

defp encode_ac(ac) do
max_ac = Enum.reduce(ac, -2, fn {_, {r, g, b}}, max_ac -> Enum.max([max_ac, r, g, b]) end)

quantized_max_ac = floor(max(0, min(82, floor(max_ac * 166 - 0.5))))
max_ac_for_quantization = (quantized_max_ac + 1) / 166

encoded_max_ac = Base83.encode_number(quantized_max_ac, 1)

encoded_components =
Enum.reduce(ac, "", fn {_, {r, g, b}}, hash ->
r = quantize_color(r, max_ac_for_quantization)
g = quantize_color(g, max_ac_for_quantization)
b = quantize_color(b, max_ac_for_quantization)
hash <> Base83.encode_number(r * 19 * 19 + g * 19 + b, 2)
end)

{encoded_max_ac, encoded_components}
end
end
Loading

0 comments on commit 68883a4

Please sign in to comment.