defmodule StbImage do
@moduledoc """
Tiny image encoding and decoding.
The following formats are supported and have type u8:
* JPEG baseline & progressive (12 bpc/arithmetic not supported, same as stock IJG lib)
* PNG 1/2/4/8/16-bit-per-channel
* TGA
* BMP non-1bpp, non-RLE
* PSD (composited view only, no extra channels, 8/16 bit-per-channel)
* GIF (always reports as 4-channel)
* PIC (Softimage PIC)
* PNM (PPM and PGM binary only)
The following formats are supported and have type f32:
* HDR (radiance rgbE format) (type is f32)
There are also specific functions for working with GIFs.
"""
@doc """
The `StbImage` struct.
It has the following fields:
* `:data` - a blob with the image bytes in HWC (heigth-width-channels) order
* `:shape` - a tuple with the `{height, width, channels}`
* `:type` - the type unit in the binary (`{:u, 8}` or `{:f, 32}`)
The number of channels correlate directly to the color mode.
1 channel is greyscale, 2 is greyscale+alpha, 3 is RGB, and
4 is RGB+alpha.
"""
defstruct [:data, :shape, :type]
defguardp is_path(path) when is_binary(path) or is_list(path)
defguardp is_dimension(d) when is_integer(d) and d > 0
@doc """
Creates a StbImage directly.
`data` is a binary blob with the image bytes in HWC
(heigth-width-channels) order. `shape` is a tuple
with the `heigth`, `width`, and `channel` dimensions.
## Options
* `:type` - The type of the data. Defaults to `{:u, 8}`.
Must be one of `{:u, 8}` or `{:f, 32}`. The `:u8` and
`:f32` convenience atom syntax is also available.
"""
def new(data, {h, w, c} = shape, opts \\ [])
when is_binary(data) and is_dimension(h) and is_dimension(w) and c in 1..4 do
type = type(opts[:type] || :u8)
if byte_size(data) == h * w * c * bytes(type) do
%StbImage{data: data, shape: shape, type: type}
else
raise ArgumentError,
"cannot create StbImage because number of bytes do not match shape and type"
end
end
@compile {:no_warn_undefined, Nx}
@doc """
Converts a `StbImage` to a Nx tensor.
It accepts the same options as `Nx.from_binary/3`.
"""
def to_nx(%StbImage{data: data, type: type, shape: shape}, opts \\ []) do
data
|> Nx.from_binary(type, opts)
|> Nx.reshape(shape, names: [:height, :width, :channels])
end
@doc """
Creates a `StbImage` from a Nx tensor.
The tensor is expected to have shape `{h, w, c}`
and one of the supported types (u8/f32).
"""
def from_nx(tensor) when is_struct(tensor, Nx.Tensor) do
new(Nx.to_binary(tensor), tensor_shape(Nx.shape(tensor)), type: tensor_type(Nx.type(tensor)))
end
defp tensor_type({:u, 8}), do: {:u, 8}
defp tensor_type({:f, 32}), do: {:f, 32}
defp tensor_type(type),
do: raise(ArgumentError, "unsupported tensor type: #{inspect(type)} (expected u8/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)"
)
@doc """
Reads image from file at `path`.
## Options
* `:channels` - The number of desired channels.
Use `0` for auto-detection. Defaults to 0.
## Example
{:ok, img} = StbImage.read_file("/path/to/image")
{h, w, c} = img.shape
data = img.data
# If you know the image is a 4-channel image and auto-detection failed
{:ok, img} = StbImage.read_file("/path/to/image", channels: 4)
{h, w, c} = img.shape
img = img.data
"""
def read_file(path, opts \\ []) when is_path(path) and is_list(opts) do
channels = opts[:channels] || 0
case StbImage.Nif.read_file(path_to_charlist(path), channels) do
{:ok, img, shape, bytes} ->
{:ok, %StbImage{data: img, shape: shape, type: bytes_to_type(bytes)}}
{:error, reason} ->
{:error, List.to_string(reason)}
end
end
@doc """
Raising version of `read_file/2`.
"""
def read_file!(buffer, opts \\ []) do
case read_file(buffer, opts) do
{:ok, img} -> img
{:error, reason} -> raise ArgumentError, reason
end
end
@doc """
Reads image from `binary` representing an image.
## Options
* `:channels` - The number of desired channels.
Use `0` for auto-detection. Defaults to 0.
## Example
{:ok, buffer} = File.read("/path/to/image")
{:ok, img} = StbImage.read_binary(buffer)
{h, w, c} = img.shape
img = img.data
# If you know the image is a 4-channel image and auto-detection failed
{:ok, img} = StbImage.from_binary(buffer, channels: 4)
{h, w, c} = img.shape
img = img.data
"""
def read_binary(buffer, opts \\ []) when is_binary(buffer) and is_list(opts) do
channels = opts[:channels] || 0
case StbImage.Nif.read_binary(buffer, channels) do
{:ok, img, shape, bytes} ->
{:ok, %StbImage{data: img, shape: shape, type: bytes_to_type(bytes)}}
{:error, reason} ->
{:error, List.to_string(reason)}
end
end
@doc """
Raising version of `read_binary/2`.
"""
def read_binary!(buffer, opts \\ []) do
case read_binary(buffer, opts) do
{:ok, img} -> img
{:error, reason} -> raise ArgumentError, reason
end
end
@doc """
Reads GIF image from file at `path`.
## Example
{:ok, frames, delays} = StbImage.read_gif_file("/path/to/image")
frame = Enum.at(frames, 0)
{h, w, 3} = frame.shape
"""
def read_gif_file(path) when is_binary(path) or is_list(path) do
with {:ok, binary} <- File.read(path) do
read_gif_binary(binary)
end
end
@doc """
Decodes GIF image from a `binary` representing a GIF.
## Example
{:ok, buffer} = File.read("/path/to/image")
{:ok, frames, delays} = StbImage.read_gif_binary(buffer)
frame = Enum.at(frames, 0)
{h, w, 3} = frame.shape
"""
def read_gif_binary(binary) when is_binary(binary) do
with {:ok, frames, shape, delays} <- StbImage.Nif.read_gif_binary(binary) do
stb_frames = for frame <- frames, do: %StbImage{data: frame, shape: shape, type: {:u, 8}}
{:ok, stb_frames, delays}
end
end
@encoding_formats ~w(jpg png bmp tga hdr)a
@encoding_formats_string Enum.map_join(@encoding_formats, ", ", &inspect/1)
@doc """
Writes image to the file at `path`.
The supported formats are #{@encoding_formats_string}.
The format is determined from the file extension if possible,
you can also pass it explicitly via the `:format` option.
Returns `:ok` on success and `{:error, reason}` otherwise.
Make sure the directory you intend to write the file to exists,
otherwise an error is returned.
## Options
* `:format` - one of the supported image formats
"""
def write_file(%StbImage{data: data, shape: shape, type: type}, path, opts \\ []) do
{height, width, channels} = shape
format = opts[:format] || format_from_path!(path)
assert_write_type_and_format!(type, format)
case StbImage.Nif.write_file(path_to_charlist(path), format, data, height, width, channels) do
:ok -> :ok
{:error, reason} -> {:error, List.to_string(reason)}
end
end
@doc """
Raising version of `write_file/3`.
"""
def write_file!(image, path, opts \\ []) do
case write_file(image, path, opts) do
:ok -> :ok
{:error, reason} -> raise ArgumentError, reason
end
end
@doc """
Encodes image to a binary.
The supported formats are #{@encoding_formats_string}.
## Example
img = StbImage.new(raw_img, {h, w, channels})
binary = StbImage.to_binary(img, :png)
"""
def to_binary(%StbImage{data: data, shape: shape, type: type}, format) do
assert_write_type_and_format!(type, format)
{height, width, channels} = shape
case StbImage.Nif.to_binary(format, data, height, width, channels) do
{:ok, binary} -> binary
{:error, reason} -> raise ArgumentError, "#{reason}"
end
end
@doc """
Resizes the image into the given `output_h` and `output_w`.
## Example
img = StbImage.new(raw_img, {h, w, channels})
StbImage.resize(raw_img, div(h, 2), div(w, 2))
"""
def resize(
%StbImage{data: data, shape: {height, width, channels}, type: type},
output_h,
output_w
)
when is_dimension(output_h) and is_dimension(output_w) do
case StbImage.Nif.resize(data, height, width, channels, output_h, output_w, bytes(type)) do
{:ok, output_pixels} ->
%StbImage{data: output_pixels, shape: {output_h, output_w, channels}, type: type}
{:error, reason} ->
raise ArgumentError, "#{reason}"
end
end
defp assert_write_type_and_format!(type, format) when format in [:png, :jpg, :bmp, :tga] do
if type != {:u, 8} do
raise ArgumentError, "incompatible type (#{inspect(type)}) for #{inspect(format)}"
end
end
defp assert_write_type_and_format!(type, format) when format in [:hdr] do
if type != {:f, 32} do
raise ArgumentError, "incompatible type (#{inspect(type)}) for #{inspect(format)}"
end
end
defp assert_write_type_and_format!(_, format) do
raise ArgumentError,
"got an unsupported encoding format #{inspect(format)}, " <>
"the format must be one of #{inspect(@encoding_formats)}"
end
defp format_from_path!(path) do
case Path.extname(path) do
".jpg" ->
:jpg
".jpeg" ->
:jpg
".png" ->
:png
".bmp" ->
:bmp
".tga" ->
:tga
".hdr" ->
:hdr
ext ->
raise "could not determine a supported encoding format for file #{inspect(path)} with extension #{inspect(ext)}, " <>
"please specify a supported :format option explicitly"
end
end
defp path_to_charlist(path) when is_list(path), do: path
defp path_to_charlist(path) when is_binary(path), do: String.to_charlist(path)
defp type(:u8), do: {:u, 8}
defp type(:f32), do: {:f, 32}
defp type({:u, 8}), do: {:u, 8}
defp type({:f, 32}), do: {:f, 32}
defp bytes({_, s}), do: div(s, 8)
defp bytes_to_type(1), do: {:u, 8}
defp bytes_to_type(4), do: {:f, 32}
end