lib/image_rs.ex

defmodule ImageRs do
  @moduledoc """

  """

  defstruct [
    :width,
    :height,
    :channels,
    color_type: nil,
    dtype: nil,
    shape: nil,
    resource: nil
  ]

  @type t :: %__MODULE__{
          resource: reference(),
          width: non_neg_integer(),
          height: non_neg_integer(),
          color_type: :l | :la | :rgb | :rgba | :unknown,
          dtype: :u8 | :u16 | :f32,
          shape: [non_neg_integer()],
          channels: non_neg_integer()
        }

  @type output_format ::
          :png
          | :jpeg
          | :pnm
          | :gif
          | :ico
          | :bmp
          | :farbfeld
          | :tga
          | :exr
          | :tiff
          | :avif
          | :qoi
          | :webp

  @doc """
  Decode image from a given file

  - **filename**. Path to the image.

  ## Example
  ```elixir
  {:ok, image} = ImageRs.from_file("/path/to/image")
  width = image.width
  height = image.height
  channels = image.channels
  shape = image.shape
  {^height, ^width, ^channels} = shape
  color_type = image.color_type
  type = image.type
  ```
  """
  @spec from_file(Path.t()) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def from_file(filename) do
    ImageRs.Nif.from_file(filename)
  end

  @doc """
  Similar to from_file/1 but raises on errors
  """
  @spec from_file!(Path.t()) :: ImageRs.t()
  def from_file!(filename) do
    with {:ok, image} <- ImageRs.Nif.from_file(filename) do
      image
    else
      {:error, msg} ->
        raise RuntimeError, msg
    end
  end

  @doc """
  Decode image from buffer in memory

  - **data**. Image data in memory.

  ## Example
  ```elixir
  # image buffer from a file or perhaps download from the Internet
  {:ok, data} = File.read("/path/to/image")

  # decode the image from memory
  {:ok, image} = ImageRs.from_binary(data)
  width = image.width
  height = image.height
  channels = image.channels
  shape = image.shape
  {^height, ^width, ^channels} = shape
  color_type = image.color_type
  type = image.type
  ```
  """
  @spec from_binary(binary()) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def from_binary(data) when is_binary(data) do
    ImageRs.Nif.from_binary(data)
  end

  @doc """
  Similar to from_binary/1 but raises on errors
  """
  @spec from_binary!(binary()) :: ImageRs.t()
  def from_binary!(data) when is_binary(data) do
    with {:ok, image} <- ImageRs.Nif.from_binary(data) do
      image
    else
      {:error, msg} ->
        raise RuntimeError, msg
    end
  end

  @doc """
  Create a new `ImageRs` from given binary with corresponding parameters.
  """
  @spec new(pos_integer(), pos_integer(), :l | :la | :rgb | :rgba, :u8 | :u16 | :f32, binary()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def new(height, width, color_type, dtype, data) do
    ImageRs.Nif.new(height, width, color_type, dtype, data)
  end

  @doc """
  Get binary representation of pixels.
  """
  @spec to_binary(ImageRs.t()) :: {:ok, binary()} | {:error, String.t()}
  def to_binary(image) do
    ImageRs.Nif.to_binary(image)
  end

  @doc """
  Resize this image using the specified filter algorithm.

  Returns a new image. Does not preserve aspect ratio.

  `height` and `width` are the new image's dimensions.
  """
  @spec resize(
          ImageRs.t(),
          non_neg_integer(),
          non_neg_integer(),
          :nearest | :triangle | :catmull_rom | :gaussian | :lanczos3
        ) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def resize(image, height, width, filter_type \\ :lanczos3) do
    ImageRs.Nif.resize(image, height, width, filter_type)
  end

  @doc """
  Resize this image using the specified filter algorithm.

  Returns a new image. The image's aspect ratio is preserved.

  The image is scaled to the maximum possible size that fits within the bounds specified by `width` and `height`.
  """
  @spec resize_preserve_ratio(
          ImageRs.t(),
          non_neg_integer(),
          non_neg_integer(),
          :nearest | :triangle | :catmull_rom | :gaussian | :lanczos3
        ) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def resize_preserve_ratio(image, height, width, filter_type \\ :lanczos3) do
    ImageRs.Nif.resize_preserve_ratio(image, height, width, filter_type)
  end

  @doc """
  Resize this image using the specified filter algorithm.

  Returns a new image. The image's aspect ratio is preserved.

  The image is scaled to the maximum possible size that fits within the larger
  (relative to aspect ratio) of the bounds specified by `height` and `width`,
  then cropped to fit within the other bound.
  """
  @spec resize_to_fill(
          ImageRs.t(),
          non_neg_integer(),
          non_neg_integer(),
          :nearest | :triangle | :catmull_rom | :gaussian | :lanczos3
        ) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def resize_to_fill(image, height, width, filter_type \\ :lanczos3) do
    ImageRs.Nif.resize_to_fill(image, height, width, filter_type)
  end

  @doc """
  Return a cut-out of this image delimited by the bounding rectangle.
  """
  @spec crop(
          ImageRs.t(),
          non_neg_integer(),
          non_neg_integer(),
          non_neg_integer(),
          non_neg_integer()
        ) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def crop(image, x, y, height, width) do
    ImageRs.Nif.crop(image, x, y, height, width)
  end

  @doc """
  Return a grayscale version of this image.

  Returns Luma images in most cases. However, for f32 images, this will return a grayscale Rgb/Rgba image instead.
  """
  @spec grayscale(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def grayscale(image) do
    ImageRs.Nif.grayscale(image)
  end

  @doc """
  Invert the colors of this image.
  """
  @spec invert(ImageRs.t()) :: {:ok, ImageRs.t()} | {:error, String.t()}
  def invert(image) do
    ImageRs.Nif.invert(image)
  end

  @doc """
  Performs a Gaussian blur on this image.

  `sigma` is a measure of how much to blur by.
  """
  @spec blur(ImageRs.t(), float()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def blur(image, sigma) do
    ImageRs.Nif.blur(image, sigma * 1.0)
  end

  @doc """
  Performs an unsharpen mask on this image.

  `sigma` is the amount to blur the image by.

  `threshold` is a control of how much to sharpen.
  """
  @spec unsharpen(ImageRs.t(), float(), integer()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def unsharpen(image, sigma, threshold) do
    ImageRs.Nif.unsharpen(image, sigma * 1.0, threshold)
  end

  @doc """
  Filters this image with the specified 3x3 kernel.
  """
  @spec filter3x3(
          ImageRs.t(),
          [number()]
        ) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def filter3x3(image, kernel) when is_list(kernel) do
    kernel = List.flatten(kernel)

    if Enum.count(kernel) == 9 do
      try do
        kernel = Enum.map(kernel, &(&1 * 1.0))
        ImageRs.Nif.filter3x3(image, List.flatten(kernel))
      catch
        _ ->
          {:error, "kernel must be a list of 9 floats or a matrix of 3x3 float type"}
      end
    else
      {:error, "kernel must be a list of 9 floats or a matrix of 3x3 float type"}
    end
  end

  @doc """
  Adjust the contrast of this image.

  `contrast` is the amount to adjust the contrast by.

  Negative values decrease the contrast and positive values increase the contrast.
  """
  @spec adjust_contrast(ImageRs.t(), float()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def adjust_contrast(image, contrast) do
    ImageRs.Nif.adjust_contrast(image, contrast * 1.0)
  end

  @doc """
  Brighten the pixels of this image.

  `value` is the amount to brighten each pixel by.

  Negative values decrease the brightness and positive values increase it.
  """
  @spec brighten(ImageRs.t(), integer()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def brighten(image, value) do
    ImageRs.Nif.brighten(image, value)
  end

  @doc """
  Hue rotate the supplied image.

  `value` is the degrees to rotate each pixel by.

  0 and 360 do nothing, the rest rotates by the given degree value.

  just like the css webkit filter hue-rotate(180)
  """
  @spec huerotate(ImageRs.t(), integer()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def huerotate(image, value) do
    ImageRs.Nif.huerotate(image, value)
  end

  @doc """
  Flip this image vertically
  """
  @spec flipv(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def flipv(image) do
    ImageRs.Nif.flipv(image)
  end

  @doc """
  Flip this image horizontally
  """
  @spec fliph(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def fliph(image) do
    ImageRs.Nif.fliph(image)
  end

  @doc """
  Rotate this image 90 degrees clockwise.
  """
  @spec rotate90(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def rotate90(image) do
    ImageRs.Nif.rotate90(image)
  end

  @doc """
  Rotate this image 180 degrees clockwise.
  """
  @spec rotate180(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def rotate180(image) do
    ImageRs.Nif.rotate180(image)
  end

  @doc """
  Rotate this image 270 degrees clockwise.
  """
  @spec rotate270(ImageRs.t()) ::
          {:ok, ImageRs.t()} | {:error, String.t()}
  def rotate270(image) do
    ImageRs.Nif.rotate270(image)
  end

  @doc """
  Encode this image as format.
  """
  @spec encode_as(ImageRs.t(), output_format(), Keyword.t()) ::
          {:ok, binary()} | {:error, String.t()}
  def encode_as(image, format, options \\ []) do
    with {:ok, checked_options} <- validate_output_format_and_options(format, options) do
      ImageRs.Nif.encode_as(image, format, checked_options)
    end
  end

  @doc """
  Saves the buffer to a file at the path specified.
  """
  @spec save(ImageRs.t(), Path.t()) :: :ok | {:error, String.t()}
  def save(image, path) do
    ImageRs.Nif.save(image, path)
  end

  @doc """
  Saves the buffer to a file at the path specified in the specified format.
  """
  @spec save_with_format(ImageRs.t(), Path.t(), output_format()) :: :ok | {:error, String.t()}
  def save_with_format(image, path, format) do
    supported_formats = supported_formats()

    if format in supported_formats do
      ImageRs.Nif.save_with_format(image, path, format)
    else
      {:error, "`:format` parameter must be one of #{inspect(supported_formats)}"}
    end
  end

  defp validate_output_format_and_options(:jpeg, options) do
    q = options[:quality]

    case q do
      nil ->
        {:error, "`:quality` parameter for `:jpeg` output format must be an integer in [0, 100]"}

      q when is_integer(q) ->
        if 0 <= q and q <= 100 do
          {:ok, %{"quality" => "#{q}"}}
        else
          {:error,
           "`:quality` parameter for `:jpeg` output format must be an integer in [0, 100]"}
        end

      q when is_binary(q) ->
        case Integer.parse(q, 10) do
          {q, ""} ->
            if 0 <= q and q <= 100 do
              {:ok, %{"quality" => "#{q}"}}
            else
              {:error,
               "`:quality` parameter for `:jpeg` output format must be an integer in [0, 100]"}
            end

          :error ->
            {:error,
             "`:quality` parameter for `:jpeg` output format must be an integer in [0, 100]"}
        end

      _ ->
        {:error, "`:quality` parameter for `:jpeg` output format must be an integer in [0, 100]"}
    end
  end

  defp validate_output_format_and_options(:pnm, options) do
    subtype = options[:subtype]

    if subtype in [:bitmap, :graymap, :pixmap, :arbitrarymap] do
      if subtype != :arbitarymap do
        encoding = options[:encoding]

        if encoding in [:binary, :ascii] do
          {:ok, %{"subtype" => to_string(subtype), "encoding" => to_string(encoding)}}
        else
          {:error,
           "`:encoding` parameter for output format `:pnm` with subtype `#{subtype}` must be either `:binary` or `:ascii`"}
        end
      else
        {:ok, %{"subtype" => to_string(subtype)}}
      end
    else
      {:error,
       "`:subtype` parameter for output format `:pnm` must exist and is one of `:bitmap`, `:graymap`, `:pixmap` or `:arbitrarymap`"}
    end
  end

  defp validate_output_format_and_options(format, _options) do
    supported_formats = supported_formats()

    if !(format in supported_formats) do
      {:error, "`:format` parameter must be one of #{inspect(supported_formats)}"}
    else
      {:ok, %{}}
    end
  end

  defp supported_formats do
    [
      :png,
      :jpeg,
      :pnm,
      :gif,
      :ico,
      :bmp,
      :farbfeld,
      :tga,
      :exr,
      :tiff,
      :avif,
      :qoi,
      :webp
    ]
  end

  if Code.ensure_loaded?(Nx) do
    @doc """
    Converts an `ImageRs` to a Nx tensor.

    It accepts the same options as `Nx.from_binary/3`.
    """
    def to_nx(%ImageRs{dtype: dtype, shape: shape} = image, opts \\ []) do
      case ImageRs.to_binary(image) do
        data when is_binary(data) ->
          Nx.from_binary(data, dtype, opts)
          |> Nx.reshape(List.to_tuple(shape), names: [:height, :width, :channels])

        error ->
          error
      end
    end

    @doc """
    Creates an `ImageRs` from a Nx tensor.

    The tensor is expected to have the shape `{h, w, c}`
    and one of the supported types (u8/u16/f32).
    """
    def from_nx(tensor) when is_struct(tensor, Nx.Tensor) do
      data = Nx.to_binary(tensor)
      dtype = tensor_type(Nx.type(tensor))

      {h, w, color_type} =
        case tensor_shape(Nx.shape(tensor)) do
          {h, w, c} ->
            {h, w, channel_to_image_rs_color_type(c)}

          {h, w} ->
            {h, w, :l}
        end

      new(h, w, color_type, dtype, data)
    end

    defp channel_to_image_rs_color_type(1) do
      :l
    end

    defp channel_to_image_rs_color_type(2) do
      :la
    end

    defp channel_to_image_rs_color_type(3) do
      :rgb
    end

    defp channel_to_image_rs_color_type(4) do
      :rgba
    end

    defp channel_to_image_rs_color_type(c) do
      raise RuntimeError, """
      Unsupported number of channels: `#{inspect(c)}`.
      Valid number of channels should be in [1,2,3,4].
      """
    end

    defp tensor_type({:u, 8}), do: :u8
    defp tensor_type({:u, 16}), do: :u16
    defp tensor_type({:f, 32}), do: :f32

    defp tensor_type(type),
      do: raise(ArgumentError, "unsupported tensor type: #{inspect(type)} (expected u8/u16/f32)")

    defp tensor_shape({_, _, c} = shape) when c in 1..4,
      do: shape

    defp tensor_shape(shape),
      do:
        raise(
          ArgumentError,
          "unsupported tensor shape: #{inspect(shape)} (expected height-width-channel)"
        )

    defimpl Nx.LazyContainer do
      def traverse(%ImageRs{dtype: dtype, shape: shape} = image, acc, fun) do
        fun.(Nx.template(List.to_tuple(shape), dtype), fn -> ImageRs.to_nx(image) end, acc)
      end
    end
  end

  if Code.ensure_loaded?(Kino.Render) do
    defimpl Kino.Render do
      defp within_maximum_size(image) do
        max_size = Application.fetch_env!(:image_rs, :kino_render_max_size)

        case max_size do
          {max_height, max_width} when is_integer(max_height) and is_integer(max_width) ->
            [h, w, _c] = image.shape
            h <= max_height and w <= max_width

          _ ->
            raise """
            invalid :kino_render_max_size configuration. Expected a 2-tuple, {height, width},
            where height and width are both integers. Got: #{inspect(max_size)}
            """
        end
      end

      def to_livebook(image) when is_struct(image, ImageRs) do
        render_types = Application.fetch_env!(:image_rs, :kino_render_tab_order)

        Enum.map(render_types, fn
          :raw ->
            {"Raw", Kino.Inspect.new(image)}

          :numerical ->
            if Code.ensure_loaded?(Nx) do
              {"Numerical", Kino.Inspect.new(ImageRs.to_nx(image))}
            else
              {"Numerical",
               Kino.Markdown.new("""
               The `Numerical` tab requires application `:nx`, please add `{:nx, "~> 0.4"}` to the dependency list.
               """)}
            end

          :image ->
            render_encoding = Application.fetch_env!(:image_rs, :kino_render_encoding)

            {image_format, kino_format} =
              case render_encoding do
                :jpg ->
                  {:jpg, :jpeg}

                :jpeg ->
                  {:jpg, :jpeg}

                :png ->
                  {:png, :png}

                _ ->
                  raise "invalid :kino_render_encoding configuration. Expected one of :png, :jpg, or :jpeg. Got: #{inspect(render_encoding)}"
              end

            with true <- within_maximum_size(image),
                 {:ok, encoded} <- ImageRs.encode_as(image, image_format),
                 true <- is_binary(encoded) do
              {"Image", Kino.Image.new(encoded, kino_format)}
            else
              _ ->
                nil
            end

          type ->
            raise """
            invalid :kino_render_tab_order configuration. The set of supported types are [:image, :raw, :numerical].
            Got: #{inspect(type)}
            """
        end)
        |> Enum.reject(&is_nil/1)
        |> to_livebook_tabs(render_types, image)
      end

      defp to_livebook_tabs([], [:image], image) do
        Kino.Layout.tabs([{"Raw", Kino.Inspect.new(image)}])
        |> Kino.Render.to_livebook()
      end

      defp to_livebook_tabs(_tabs, [], image) do
        Kino.Inspect.new(image)
        |> Kino.Render.to_livebook()
      end

      defp to_livebook_tabs(tabs, _types, _mat) do
        Kino.Layout.tabs(tabs)
        |> Kino.Render.to_livebook()
      end
    end
  end
end