defmodule StbImage do
@moduledoc """
Tiny image encoding and decoding.
The following formats are supported:
* 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)
* HDR (radiance rgbE format)
* PIC (Softimage PIC)
* PNM (PPM and PGM binary only)
There are also specific functions for working with GIFs.
"""
defguardp is_path(path) when is_binary(path) or is_list(path)
@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 (u8/f32)
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]
defguard 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 `:u8`.
Must be one of `:u8` or `:f32`.
"""
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 = 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
defp bytes(:u8), do: 1
defp bytes(:f32), do: 4
@compile {:no_warn_undefined, Nx}
@compile {:no_warn_undefined, Nx.Type}
@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(Nx.Type.normalize!(type), opts)
|> Nx.reshape(shape)
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.
"""
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: :u8
defp tensor_type({:f, 32}), do: :f32
defp tensor_type(type), do: raise(ArgumentError, "unsupported tensor type: #{inspect(type)}")
defp tensor_shape({_, _, _} = shape), do: shape
defp tensor_shape(shape),
do: raise(ArgumentError, "unsupported tensor shape: #{inspect(shape)}")
@doc """
Decodes image from file at `path`.
## Options
* `:channels` - The number of desired channels.
Use `0` for auto-detection. Defaults to 0.
* `:type` - The type of the data. Defaults to `:u8`.
Must be one of `:u8` or `:f32`.
## Example
{:ok, img} = StbImage.from_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.from_file("/path/to/image", channels: 4)
{h, w, c} = img.shape
img = img.data
"""
def from_file(path, opts \\ []) when is_path(path) and is_list(opts) do
type = opts[:type] || :u8
channels = opts[:channels] || 0
with {:ok, img, shape} <- StbImage.Nif.from_file(path_to_charlist(path), channels, bytes(type)) do
{:ok, %StbImage{data: img, shape: shape, type: type}}
end
end
@doc """
Decodes image from `binary` representing an image.
## Options
* `:channels` - The number of desired channels.
Use `0` for auto-detection. Defaults to 0.
* `:type` - The type of the data. Defaults to `:u8`.
Must be one of `:u8` or `:f32`.
## Example
{:ok, buffer} = File.read("/path/to/image")
{:ok, img} = StbImage.from_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_file("/path/to/image", channels: 4)
{h, w, c} = img.shape
img = img.data
"""
def from_binary(buffer, opts \\ []) when is_binary(buffer) and is_list(opts) do
type = opts[:type] || :u8
channels = opts[:channels] || 0
with {:ok, img, shape} <- StbImage.Nif.from_binary(buffer, channels, bytes(type)) do
{:ok, %StbImage{data: img, shape: shape, type: type}}
end
end
@doc """
Decodes GIF image from file at `path`.
## Example
{:ok, frames, delays} = StbImage.gif_from_file("/path/to/image")
frame = Enum.at(frames, 0)
{h, w, 3} = frame.shape
"""
def gif_from_file(path) when is_binary(path) or is_list(path) do
with {:ok, binary} <- File.read(path) do
gif_from_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.gif_from_binary(buffer)
frame = Enum.at(frames, 0)
{h, w, 3} = frame.shape
"""
def gif_from_binary(binary) when is_binary(binary) do
with {:ok, frames, shape, delays} <- StbImage.Nif.gif_from_binary(binary) do
stb_frames = for frame <- frames, do: %StbImage{data: frame, shape: shape, type: :u8}
{:ok, stb_frames, delays}
end
end
@encoding_formats ~w(jpg png bmp tga)a
@encoding_formats_string Enum.map_join(@encoding_formats, ", ", &inspect/1)
@doc """
Saves 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.
Only u8 images can be saved at the moment.
## Options
* `:format` - one of the supported image formats
"""
def to_file(%StbImage{data: data, shape: shape, type: type}, path, opts \\ []) do
assert_write_type!(type)
{height, width, channels} = shape
format = opts[:format] || format_from_path!(path)
assert_encoding_format!(format)
StbImage.Nif.to_file(path_to_charlist(path), format, data, height, width, channels)
end
@doc """
Encodes image to a binary.
The supported formats are #{@encoding_formats_string}.
Returns `{:ok, binary}` on success and `{:error, reason}` otherwise.
Only u8 images can be encoded at the moment.
## Example
img = StbImage.new(raw_img, {h, w, channels})
{:ok, binary} = StbImage.to_binary(img, :png)
"""
def to_binary(%StbImage{data: data, shape: shape, type: type}, format) do
assert_write_type!(type)
{height, width, channels} = shape
assert_encoding_format!(format)
StbImage.Nif.to_binary(format, data, height, width, channels)
end
@doc """
Resizes the image into the given `output_h` and `output_w`.
## Example
img = StbImage.new(raw_img, {h, w, channels})
{:ok, resized_img} = 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
with {:ok, output_pixels} <-
StbImage.Nif.resize(data, height, width, channels, output_h, output_w, bytes(type)) do
{:ok, %StbImage{data: output_pixels, shape: {output_h, output_w, channels}, type: type}}
end
end
defp assert_write_type!(:u8), do: :ok
defp assert_write_type!(type) do
raise ArgumentError,
"StbImage can only write to_file/to_binary/resize u8 type, got: #{inspect(type)}"
end
defp format_from_path!(path) do
case Path.extname(path) do
".jpg" ->
:jpg
".jpeg" ->
:jpg
".png" ->
:png
".bmp" ->
:bmp
".tga" ->
:tga
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 assert_encoding_format!(format) do
unless format in @encoding_formats do
raise ArgumentError,
"got an unsupported encoding format #{inspect(format)}, " <>
"the format must be one of #{inspect(@encoding_formats)}"
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)
end