Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 62 additions & 28 deletions lib/image.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5899,38 +5899,77 @@ defmodule Image do

### Options

* `:idy` is the vertical input displacement which
defaults to `0.0`
* `:idx`, `:idy`, `:odx` and `:ody` displace the image relative to
the rotation. See the *Displacements* section below.

* `:idx` is the horizontal input displacement which
defaults to `0.0`
* `:interpolate` selects the interpolator used to resample
pixels: `:nearest`, `:bilinear` (the default), `:bicubic`,
`:lbb`, `:nohalo` or `:vsqbs`. See
`t:Image.Options.Rotate.interpolate/0` for more information
about the available options. This option has no effect for
discrete rotations (see below) which copy source pixels
directly.

* `:background` defines the color used to fill the blank areas
of the canvas exposed by the rotation. This can be specified as
a single integer which will be applied to all bands, or a list
of integers representing the color for each band (including an
optional alpha band). The color can also be supplied as a CSS
color name as a string or atom. For example: `:misty_rose`.
It can also be supplied as a hex string of the form `#rrggbb`.
Can also be set to `:average` in which case the background will
be the average color of the base image.

* `:ody` is the vertical output displacement
which defaults to `0.0`
When omitted, `libvips` uses its native fill: transparent for
images with an alpha band, black otherwise.

* `:odx` is the horizontal output displacement
which defaults to `0.0`
See also `Image.Pixel.to_pixel/2`.

* `:background` is the background color to be used for filling
the blank areas of the image. The background is specified as
a list of 3 or 4 float values depending on the image
color space.
## Partially-transparent backgrounds

A fully opaque or fully transparent `:background` is reproduced
exactly. A *partially* transparent `:background` (an alpha band
value somewhere between fully opaque and fully transparent) is
not. To interpolate correctly `libvips` works in premultiplied-alpha
space, and it injects the fill color directly into that space and
then unpremultiplies the whole result on output. The fill therefore
sees only that one unpremultiply step, which scales its color bands
by `max / alpha`. For example, on an 8-bit image (where `max` is
`255`) a declared `[10, 20, 30, 40]` is filled as `[63, 127, 191,
40]`: the alpha is preserved, but each color band is multiplied by
`255 / 40`.

If you need an exact partially-transparent fill, don't pass it as
`:background`. Instead rotate over a transparent background and
composite the result onto a canvas of the desired color:

iex> image = Image.open!("./test/support/images/dice_transparent.png")
iex> {:ok, rotated} = Image.rotate(image, 45, background: [0, 0, 0, 0])
iex> canvas = Image.new!(rotated, color: [10, 20, 30, 40])
iex> {:ok, _filled} = Image.compose(canvas, rotated)

The color is applied by the composite rather than passed through the
rotate, so it is reproduced exactly. Note that this backs the
*entire* image: it fills every transparent pixel, not just the canvas
exposed by the rotate. When the source content is fully opaque the
two are the same region; for a source that carries its own
transparency (such as the dice image above) the composite also fills
those areas, which `:background` would leave untouched.

## Discrete rotation

When `angle` is a multiple of 90, and all displacement options
are unset, `nil`, `0` or `0.0`, the rotation will be done as a
discrete operation in order to preserve source pixel values.

## Notes
## Displacements

The displacement parameters cause the image canvas to be
expanded and the image displaced, relative to the top left
corner of the image, by the amount specified.
* `:idx` and `:idy` are the horizontal and vertical *input*-space
displacements (default `0.0`). Applied before the rotation, so the
shift is itself rotated.

The rules defining how the image canvas is expanded
is not known to the author of `Image`. Experimentation will
be required if you explore these options.
* `:odx` and `:ody` are the horizontal and vertical *output*-space
displacements (default `0.0`). Applied after the rotation.

### Returns

Expand All @@ -5949,7 +5988,7 @@ defmodule Image do
{:ok, Vimage.t()} | {:error, error()}

def rotate(%Vimage{} = image, angle, options \\ []) when is_number(angle) do
with {:ok, options} <- Options.Rotate.validate_options(options) do
with {:ok, options} <- Options.Rotate.validate_options(image, options) do
rot_angle = rot_angle(angle, options)

if rot_angle do
Expand Down Expand Up @@ -5999,15 +6038,10 @@ defmodule Image do
* `options` is a keyword list of options.
See `Image.rotate/3`.

## Notes

The displacement parameters cause the image canvas to be
expanded and the image displaced, relative to the top left
corner of the image, by the amount specified.
## Displacements

The rules defining how the image canvas is expanded
is not known to the author of `Image`. Experimentation will
be required if you explore these options.
The `:idx`, `:idy`, `:odx` and `:ody` displacement options behave
as documented for `Image.rotate/3`.

### Returns

Expand Down
111 changes: 88 additions & 23 deletions lib/image/options/rotate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,64 @@ defmodule Image.Options.Rotate do

"""

alias Vix.Vips.Interpolate
alias Image.Pixel

@typedoc """
The interpolators that may be selected with the `:interpolate`
option (descriptions from `vips -l interpolate`):

* `:nearest` - nearest-neighbour interpolation
* `:bilinear` (default) - bilinear interpolation
* `:bicubic` - bicubic interpolation (Catmull-Rom)
* `:lbb` - reduced halo bicubic
* `:nohalo` - edge sharpening resampler with halo reduction
* `:vsqbs` - B-Splines with antialiasing smoothing

"""
@type interpolate ::
:nearest
| :bilinear
| :bicubic
| :lbb
| :nohalo
| :vsqbs

@typedoc """
The options applicable to rotating an
image.

"""
@type rotation_options :: [
{:idy, float()}
| {:idx, float()}
| {:ody, float()}
| {:odx, float()}
| {:background, Image.pixel()}
{:idy, number()}
| {:idx, number()}
| {:ody, number()}
| {:odx, number()}
| {:interpolate, interpolate()}
| {:background, Pixel.t() | :average}
]

@valid_options [:idy, :idx, :ody, :odx]
# The libvips nickname for each interpolator is identical to the
# public atom, so resolution is a simple `Atom.to_string/1`.
@valid_interpolators ~w(nearest bilinear bicubic lbb nohalo vsqbs)a

@doc false
defguard is_color(color) when is_number(color) and color >= 0
@displacement_options [:idy, :idx, :ody, :odx]

@doc """
Validate the options for `Image.rotate/3`.

The `image` is required to resolve the `:background` option
into a pixel matching the image's bands and color space.

See `t:Image.Options.Rotate.rotation_options/0`.

"""
def validate_options(options) do
case Enum.reduce_while(options, options, &validate_option(&1, &2)) do
@spec validate_options(Vix.Vips.Image.t(), Keyword.t()) ::
{:ok, Keyword.t()} | {:error, Image.error()}
def validate_options(image, options) do
options = Keyword.merge(default_options(), options)

case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do
{:error, value} ->
{:error, value}

Expand All @@ -38,31 +70,64 @@ defmodule Image.Options.Rotate do
end
end

defp validate_option({:background, background}, options) when is_color(background) do
options = Keyword.put(options, :background, List.wrap(background))
{:cont, options}
defp validate_option({:interpolate, interpolate}, _image, options)
when interpolate in @valid_interpolators do
case Interpolate.new(Atom.to_string(interpolate)) do
{:ok, interpolator} ->
{:cont, Keyword.put(options, :interpolate, interpolator)}

{:error, reason} ->
{:halt, {:error, reason}}
end
end

defp validate_option({:background, [r, g, b]}, options)
when is_color(r) and is_color(g) and is_color(b) do
{:cont, options}
# `:average` uses the image's average color and any other value is resolved
# by `Image.Pixel.to_pixel/2` (numbers, lists, CSS names, hex strings).
defp validate_option({:background, :average}, image, options) do
case Image.average(image) do
color when is_list(color) ->
{:cont, Keyword.put(options, :background, color)}

{:error, reason} ->
{:halt,
{:error,
%Image.Error{
message: "Could not get the image average: #{inspect(reason)}",
reason: "Could not get the image average: #{inspect(reason)}"
}}}
end
end

defp validate_option({:background, [c]}, options) when is_color(c) do
{:cont, options}
defp validate_option({:background, color} = option, image, options) do
case Pixel.to_pixel(image, color) do
{:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)}
_other -> {:halt, {:error, invalid_option(option)}}
end
end

defp validate_option({option, value}, options)
when option in @valid_options and is_number(value) do
{:cont, options}
defp validate_option({option, value}, _image, options)
when option in @displacement_options and is_number(value) do
{:cont, Keyword.put(options, option, value)}
end

defp validate_option(option, _options) do
defp validate_option(option, _image, _options) do
{:halt, {:error, invalid_option(option)}}
end

defp invalid_option(option) do
"Invalid option or option value: #{inspect(option)}"
%Image.Error{
reason: :invalid_option,
value: option,
message: "Invalid option or option value: #{inspect(option)}"
}
end

# No default `:background` is injected: when the caller omits it,
# `libvips` keeps its own native fill (transparent for images with
# an alpha band, black otherwise). `:interpolate` defaults to
# `:bilinear`, which is also `libvips`' own default.
defp default_options do
[interpolate: :bilinear]
end

@doc false
Expand Down
88 changes: 88 additions & 0 deletions test/rotate_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
defmodule Image.Rotate.Test do
use ExUnit.Case, async: true

alias Vix.Vips.Image, as: Vimage

# A black canvas with a single white pixel drawn at (x, y). Gives us a
# deterministic feature to track through a rotation.
defp white_dot(width, height, x, y) do
Image.new!(width, height, color: [0, 0, 0])
|> Image.Draw.rect!(x, y, 1, 1, color: [255, 255, 255])
end

describe "Image.rotate/3 options" do
test ":interpolate selects an interpolator from the public vocabulary" do
image = white_dot(20, 20, 2, 3)

for interpolator <- [:nearest, :bilinear, :bicubic, :lbb, :nohalo, :vsqbs] do
assert {:ok, %Vimage{}} = Image.rotate(image, 45, interpolate: interpolator)
end
end

test "rejects an unknown :interpolate value" do
image = white_dot(20, 20, 2, 3)

assert {:error, %Image.Error{reason: :invalid_option, value: {:interpolate, :unknown}}} =
Image.rotate(image, 45, interpolate: :unknown)
end

test "rejects an unknown option" do
image = white_dot(20, 20, 2, 3)

assert {:error, %Image.Error{reason: :invalid_option, value: {:not_an_option, 1}}} =
Image.rotate(image, 45, not_an_option: 1)
end

test "routes :background through to_pixel and fills the exposed canvas with it" do
# A four-element (RGBA) background also exercises the alpha case the
# original API could not express.
image =
Image.new!(20, 20, color: [0, 0, 0, 255])
|> Image.Draw.rect!(2, 3, 1, 1, color: [255, 255, 255, 255])

{:ok, result} = Image.rotate(image, 45, background: [10, 20, 30, 255])
assert Image.bands(result) == 4
assert Image.get_pixel!(result, 0, 0) == [10, 20, 30, 255]
end

test "accepts :average as the :background fill" do
image = white_dot(20, 20, 2, 3)
assert {:ok, %Vimage{}} = Image.rotate(image, 45, background: :average)
end

test "rejects an invalid :background" do
image = white_dot(20, 20, 2, 3)
assert {:error, _reason} = Image.rotate(image, 45, background: :not_a_colour)
end

test "defaults :interpolate and leaves :background to libvips when unset" do
image = white_dot(20, 20, 2, 3)
{:ok, options} = Image.Options.Rotate.validate_options(image, [])

assert %Vix.Vips.Interpolate{} = Keyword.get(options, :interpolate)
# No default :background is injected so libvips keeps its native
# fill (transparent for alpha images, black otherwise).
refute Keyword.has_key?(options, :background)
end

test "preserves libvips' transparent fill for alpha images when :background is unset" do
image =
Image.new!(20, 20, color: [0, 0, 0, 255])
|> Image.Draw.rect!(2, 3, 1, 1, color: [255, 255, 255, 255])

{:ok, rotated} = Image.rotate(image, 45)
assert Image.get_pixel!(rotated, 0, 0) == [0, 0, 0, 0]
end

test "rotate!/3 raises on error" do
image = white_dot(20, 20, 2, 3)
assert_raise Image.Error, fn -> Image.rotate!(image, 45, interpolate: :unknown) end
end

test "a 90-degree rotation still uses the discrete path despite options" do
image = white_dot(20, 20, 2, 3)
# No crash even though :interpolate/:background are resolved but unused.
assert {:ok, %Vimage{}} = Image.rotate(image, 90, interpolate: :nearest, background: :red)
end
end
end