diff --git a/lib/image.ex b/lib/image.ex index b510d35..e636940 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -5899,22 +5899,62 @@ 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 @@ -5922,15 +5962,14 @@ defmodule Image do 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 @@ -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 @@ -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 diff --git a/lib/image/options/rotate.ex b/lib/image/options/rotate.ex index 4195f34..f863134 100644 --- a/lib/image/options/rotate.ex +++ b/lib/image/options/rotate.ex @@ -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} @@ -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 diff --git a/test/rotate_test.exs b/test/rotate_test.exs new file mode 100644 index 0000000..9e6fd91 --- /dev/null +++ b/test/rotate_test.exs @@ -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