woltapp / blurhash

A very compact representation of a placeholder for an image.
https://blurha.sh
MIT License
15.86k stars 361 forks source link

Elixir implementation of encoder #44

Closed shinyford closed 2 years ago

shinyford commented 4 years ago

Hi

I'm trying to port the blurhash encoder to Elixir (happy to share when it works) and seem to be getting somewhere. However, it's noticeably slow, which is unusual for Elixir to say the least. (Also tried to implement in Dart/Flutter, and again it appears slow.)

Your Javascript (?) implementation on https://blurha.sh looks like it's pretty much instantaneous. So my question is: am I possibly doing something stupid, or is there something special happening on the website?

Many thanks for this BTW!

Cheers

Nic

DagAgren commented 4 years ago

You are probably applying it on a full-sized image, which is very wasteful. It is much better to scale the image down to a small size first.

shinyford commented 4 years ago

Ah, that makes sense. Thanks. (Actually, I've been scaling down to really small - 24x24 - and it's certainly speeded things up; but I think that may be too far. Not the most accurate blurs for the images behind them.)

shinyford commented 4 years ago

Could I ask what size you resize images to on the website, to make it so fast to encode the blurhash? I've been trying 50x50, and it's still very slow.

kwent commented 4 years ago

You should tell us what do you mean by slow (in ms or seconds). Something is likely wrong in your implementation

shinyford commented 4 years ago

So, for a 500x333 image being compressed to 50x50 and then encoded, it's taking my Elixir implementation c. 9000ms (fetching the image from its url source and scaling to 50x50 using Mogrify takes c. 25ms prior to that).

If I compress the image to 20x20 instead of 50x50, it comes down to c. 250ms.

Is 20x20 a reasonable scaling for an image?

EDIT: Note that this implementation uses xComponents = 6 and yComponents = 6, which will also make it larger and slower than the normal 4x3 I guess.

kwent commented 4 years ago

Why don't you post your implementation so everyone can help ?

shinyford commented 4 years ago

That's a very good idea. :D

defmodule Blurhash do
  @characters "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
  @comp_x 6
  @comp_y 6
  @width 20
  @height 20

  def hash(url) do
    url = URI.encode(url)
    case HTTPoison.get(url) do
      {:ok, %HTTPoison.Response{body: body}} ->
        body
        |> get_rgb_data(@width, @height)
        |> generate_blurhash(@comp_x, @comp_y, @width, @height)

      _ -> "error"
    end
  end

  defp get_rgb_data(body, width, height) do
    {:ok, path} = Temp.path(suffix: ".jpg")
    File.write(path, body)

    %Mogrify.Image{path: rgbpath} =
      Mogrify.open(path)
      |> Mogrify.resize("#{width}x#{height}!")
      |> Mogrify.format("rgb")
      |> Mogrify.save()

    data =
      File.read!(rgbpath)
      |> :binary.bin_to_list()

    File.rm!(path)
    File.rm!(rgbpath)

    data
  end

  defp generate_blurhash(data, xc, yc, width, height) do
    [dc | ac] = get_factors(0, 0, xc, yc, width, height, data)

    qmx = quantised_max_value_of(ac)
    mx = (qmx + 1.0) / 166.0

    [
      encode_value((xc - 1) + (yc - 1) * 9, 1),
      encode_value(qmx, 1),
      encode_dc(dc)
      |
      encode_acs(ac, mx)
    ]
    |> Enum.join()
  end

  defp get_factors(_, yc, _, yc, _, _, _), do: []
  defp get_factors(xc, y, xc, yc, w, h, d), do: get_factors(0, y + 1, xc, yc, w, h, d)
  defp get_factors(x, y, xc, yc, width, height, data) do
    [
      multiply_basis_function(x, y, width, height, data)
      |
      get_factors(x + 1, y, xc, yc, width, height, data)
    ]
  end

  defp multiply_basis_function(x, y, w, h, data) do
    pi_x = :math.pi() * x / w
    pi_y = :math.pi() * y / h
    calc_rgb(0, 0, w, h, pi_x, pi_y, data)
    |> scale(x, y, w, h)
  end

  defp calc_rgb(_, h, _, h, _, _, _), do: {0, 0, 0}
  defp calc_rgb(w, y, w, h, pi_x, pi_y, data), do: calc_rgb(0, y + 1, w, h, pi_x, pi_y, data)
  defp calc_rgb(x, y, w, h, pi_x, pi_y, data) do
    {r, g, b} = calc_rgb(x + 1, y, w, h, pi_x, pi_y, data)

    basis = :math.cos(pi_x * x) * :math.cos(pi_y * y)
    position = 3 * (x + y * w)

    {
      r + basis * s_rgb_to_linear(Enum.at(data, 0 + position)),
      g + basis * s_rgb_to_linear(Enum.at(data, 1 + position)),
      b + basis * s_rgb_to_linear(Enum.at(data, 2 + position)),
    }
  end

  defp scale({r, g, b}, x, y, w, h) do
    scl = normalisation(x, y) / (w * h)
    {r * scl, g * scl, b * scl}
  end

  defp normalisation(0, 0), do: 1
  defp normalisation(_, _), do: 2

  defp encode_dc({r, g, b}) do
    r = linear_to_s_rgb(r)
    g = linear_to_s_rgb(g)
    b = linear_to_s_rgb(b)

    encode_value((r * 0x10000) + (g * 0x100) + b, 4)
  end

  defp encode_acs([], _), do: []
  defp encode_acs([ac | remaining_acs], mx) do
    [encode_ac(ac, mx) | encode_acs(remaining_acs, mx)]
  end

  defp encode_ac({r, g, b}, mx) do
    quant_r = quant(r / mx)
    quant_g = quant(g / mx)
    quant_b = quant(b / mx)

    encode_value((quant_r * 19 * 19) + (quant_g * 19) + quant_b, 2)
  end

  defp quant(value) do
    value
    |> sign_pow(0.5)
    |> fn v -> v * 9 + 9.5 end.()
    |> Kernel.trunc()
    |> min(18)
    |> max(0)
  end

  defp sign_pow(v, exp) do
    av = abs(v)
    :math.pow(av, exp) * (v / av)
  end

  defp quantised_max_value_of(ac) do
    Enum.map(ac, fn {r, g, b} -> [abs(r), abs(g), abs(b)] end)
    |> List.flatten()
    |> Enum.max()
    |> fn x -> Kernel.trunc(x * 166 - 0.5) end.()
    |> min(82)
    |> max(0)
  end

  defp s_rgb_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_s_rgb(value) do
    v = max(0, min(1, value))
    if v <= 0.0031308 do
      v * 12.92 * 255 + 0.5
    else
      (1.055 * :math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5
    end
  end

  defp encode_value(_, 0), do: ""
  defp encode_value(value, length) do
    value = Kernel.trunc(value)
    encode_value(div(value, 83), length - 1) <> String.at(@characters, rem(value, 83))
  end
end

TBH the blurhashes this produces are a bit lurid, so I'm guessing I've messed up the maths somewhere along the line. But I'm pretty sure that's doing what the C and Dart implementations (which I've used as reference) are doing.

shinyford commented 4 years ago

And, having come back to this after six months, finally worked out what was making it lurid: when encoding the dc I was working in floats rather than ints. truncing everything fixes it.

@perzanko Glad to see someone else has got an elixir version out there - nice one