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