defmodule Qoix do @moduledoc """ Qoix is an Elixir implementation of the [Quite OK Image format](https://qoiformat.org). """ alias Qoix.Image use Bitwise @index_op <<0::2>> @diff_op <<1::2>> @luma_op <<2::2>> @run_op <<3::2>> @rgb_op <<254::8>> @rgba_op <<255::8>> @padding :binary.copy(<<0>>, 7) <> <<1>> @empty_lut for i <- 0..63, into: %{}, do: {i, <<0::32>>} @doc """ Returns true if the binary appears to contain a valid QOI image. """ @spec qoi?(binary) :: boolean def qoi?(<<"qoif", _width::32, _height::32, channels::8, cspace::8, _rest::binary>> = _binary) when channels in [3, 4] and cspace in [0, 1] do true end def qoi?(binary) when is_binary(binary) do false end @doc """ Encodes a `%Qoix.Image{}` using QOI, returning a binary with the encoded image. Returns `{:ok, encoded}` on success, `{:error, reason}` on failure. """ @spec encode(Qoix.Image.t()) :: {:ok, binary} | {:error, any} def encode(%Image{width: w, height: h, pixels: pixels, format: fmt, colorspace: cspace}) when w > 0 and h > 0 and fmt in [:rgb, :rgba] and cspace in [:srgb, :linear] and is_binary(pixels) do channels = channels(fmt) colorspace = encode_colorspace(cspace) chunks = pixels |> encode_pixels(fmt) |> IO.iodata_to_binary() # Return the final binary data = <<"qoif", w::32, h::32, channels::8, colorspace::8, chunks::bits, @padding::bits>> {:ok, data} end defp channels(:rgb), do: 3 defp channels(:rgba), do: 4 defp encode_colorspace(:srgb), do: 0 defp encode_colorspace(:linear), do: 1 defp encode_pixels(<>, format) when format == :rgb or format == :rgba do # Previous pixel is initialized to 0,0,0,255 prev = <<0, 0, 0, 255>> run_length = 0 lut = @empty_lut acc = [] do_encode(pixels, format, prev, run_length, lut, acc) end # Here we go with all the possible cases. Order matters due to pattern matching. # Maximum representable run_length, push out and start a new one defp do_encode(<>, format, prev, run_length, lut, acc) when run_length == 62 do acc = [acc | <<@run_op::bits, bias_run(run_length)::6>>] do_encode(pixels, format, prev, 0, lut, acc) end # Same RGBA pixel as previous, consume and increase run_length defp do_encode(<>, :rgba = format, <>, run_length, lut, acc) do do_encode(rest, format, <>, run_length + 1, lut, acc) end # Same RGB pixel as previous, consume and increase run_length defp do_encode(<>, :rgb = format, <>, run_length, lut, acc) do do_encode(rest, format, <>, run_length + 1, lut, acc) end # Since we didn't match the previous head, the pixel is different from the previous. # We don't have any ongoing run_length, so we just have to handle the pixel. defp do_encode(<>, :rgba = format, prev, 0, lut, acc) do pixel = <> {chunk, new_lut} = handle_non_running_pixel(pixel, prev, lut) acc = [acc | chunk] do_encode(rest, format, pixel, 0, new_lut, acc) end # As above, but for RGB defp do_encode(<>, :rgb = format, prev, 0, lut, acc) do pixel = <> {chunk, new_lut} = handle_non_running_pixel(pixel, prev, lut) acc = [acc | chunk] do_encode(rest, format, pixel, 0, new_lut, acc) end # For the same reason as above, the pixel is different from the previous. # Here we just emit the run length and leave the pixel handling to the next recursion, # that will enter in the previous head. defp do_encode(<>, format, prev, run_length, lut, acc) when run_length > 0 do acc = [acc | <<@run_op::bits, bias_run(run_length)::6>>] do_encode(pixels, format, prev, 0, lut, acc) end # All pixels consumed, no ongoing run: just output the accumulator defp do_encode(<<>>, _format, _prev, 0, _lut, acc) do acc end # All pixels consumed, pending run: output the accumulator and the 5 bit run with its tag defp do_encode(<<>>, _format, _prev, run_length, _lut, acc) do [acc | <<@run_op::bits, bias_run(run_length)::6>>] end # Handle a pixel that is not part of a run, return a {chunk, updated_lut} tuple defp handle_non_running_pixel(<> = pixel, prev, lut) do index = index(r, g, b, a) case lut do %{^index => <<^r::8, ^g::8, ^b::8, ^a::8>>} -> {<<@index_op::bits, index::6>>, lut} _other -> # The value was different from our current pixel chunk = diff_luma_color(pixel, prev) new_lut = Map.put(lut, index, <>) {chunk, new_lut} end end defguardp in_range_2?(val) when val in -2..1 defguardp in_range_4?(val) when val in -8..7 defguardp in_range_6?(val) when val in -32..31 # Check if value can be represented with diff op defguardp diff_op?(dr, dg, db) when in_range_2?(dr) and in_range_2?(dg) and in_range_2?(db) # Check if value can be represented with luma op defguardp luma_op?(dr, dg, db) when in_range_6?(dg) and in_range_4?(dr - dg) and in_range_4?(db - dg) # Emit a diff, luma, rgb or rgba chunk defp diff_luma_color(<> = _pixel, <> = _prev) when diff_op?(r - pr, g - pg, b - pb) do <<@diff_op::bits, bias_diff(r - pr)::2, bias_diff(g - pg)::2, bias_diff(b - pb)::2>> end defp diff_luma_color(<> = _pixel, <> = _prev) when luma_op?(r - pr, g - pg, b - pb) do dg = g - pg dr_dg = r - pr - dg db_dg = b - pb - dg <<@luma_op::bits, bias_luma_dg(dg)::6, bias_luma_dr_db(dr_dg)::4, bias_luma_dr_db(db_dg)::4>> end defp diff_luma_color(<>, <<_prgb::24, a::8>> = _prev) do # Same alpha, emit RGB <<@rgb_op, r::8, g::8, b::8>> end defp diff_luma_color(<>, _prev) do # Last resort, full RGBA color <<@rgba_op, r::8, g::8, b::8, a::8>> end @doc """ Decodes a QOI image, returning an `%Image{}`. Returns `{:ok, %Image{}}` on success, `{:error, reason}` on failure. """ @spec decode(binary) :: {:ok, Qoix.Image.t()} | {:error, any} def decode(<> = _encoded) do case encoded do <<"qoif", width::32, height::32, channels::8, cspace::8, chunks::binary>> -> format = format(channels) colorspace = decode_colorspace(cspace) pixels = chunks |> decode_chunks(format) |> IO.iodata_to_binary() image = %Image{ width: width, height: height, pixels: pixels, format: format, colorspace: colorspace } {:ok, image} _ -> {:error, :invalid_qoi} end end defp format(3), do: :rgb defp format(4), do: :rgba defp decode_colorspace(0), do: :srgb defp decode_colorspace(1), do: :linear defp decode_chunks(<>, format) do # Previous pixel is initialized to 0,0,0,255 prev = <<0, 0, 0, 255>> lut = @empty_lut acc = [] do_decode(chunks, format, prev, lut, acc) end # Let's decode, order matters since 8 bit opcodes have predence over 2 bit opcodes # Final padding, we're done, return the accumulator defp do_decode(@padding, _format, _prev, _lut, acc) do acc end # RGB: take just alpha from previous pixel defp do_decode(<<@rgb_op, r::8, g::8, b::8, rest::bits>>, format, prev, lut, acc) do <<_prgb::24, pa::8>> = prev pixel = <> acc = [acc | maybe_drop_alpha(pixel, format)] do_decode(rest, format, pixel, update_lut(lut, pixel), acc) end # RGBA: pixel encoded with full information defp do_decode(<<@rgba_op, r::8, g::8, b::8, a::8, rest::bits>>, format, _prev, lut, acc) do pixel = <> acc = [acc | maybe_drop_alpha(pixel, format)] do_decode(rest, format, pixel, update_lut(lut, pixel), acc) end # Index: get the pixel from the LUT defp do_decode(<<@index_op, index::6, rest::bits>>, format, _prev, lut, acc) do %{^index => pixel} = lut acc = [acc | maybe_drop_alpha(pixel, format)] do_decode(rest, format, pixel, lut, acc) end # Run: repeat previous pixel defp do_decode(<<@run_op, count::6, rest::bits>>, format, prev, lut, acc) do pixels = maybe_drop_alpha(prev, format) |> :binary.copy(unbias_run(count)) acc = [acc | pixels] do_decode(rest, format, prev, lut, acc) end # Diff: reconstruct pixel from previous + diff defp do_decode(<<@diff_op, dr::2, dg::2, db::2, rest::bits>>, format, prev, lut, acc) do <> = prev r = pr + unbias_diff(dr) g = pg + unbias_diff(dg) b = pb + unbias_diff(db) pixel = <> acc = [acc | maybe_drop_alpha(pixel, format)] do_decode(rest, format, pixel, update_lut(lut, pixel), acc) end # Luma: reconstruct pixel from previous + diff defp do_decode(<<@luma_op, b_dg::6, dr_dg::4, db_dg::4, rest::bits>>, format, prev, lut, acc) do <> = prev dg = unbias_luma_dg(b_dg) r = pr + unbias_luma_dr_db(dr_dg) + dg g = pg + dg b = pb + unbias_luma_dr_db(db_dg) + dg pixel = <> acc = [acc | maybe_drop_alpha(pixel, format)] do_decode(rest, format, pixel, update_lut(lut, pixel), acc) end defp maybe_drop_alpha(pixel, :rgba), do: pixel defp maybe_drop_alpha(<>, :rgb), do: <> defp index(r, g, b, a) do (r * 3 + g * 5 + b * 7 + a * 11) |> rem(64) end defp update_lut(lut, <>) do lut_index = index(r, g, b, a) Map.put(lut, lut_index, <>) end defp bias_run(val), do: val - 1 defp unbias_run(val), do: val + 1 defp bias_diff(val), do: val + 2 defp unbias_diff(val), do: val - 2 defp bias_luma_dg(val), do: val + 32 defp unbias_luma_dg(val), do: val - 32 defp bias_luma_dr_db(val), do: val + 8 defp unbias_luma_dr_db(val), do: val - 8 end