diff --git a/lib/image.ex b/lib/image.ex index 8d0d87b..5f9059c 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -10708,6 +10708,366 @@ defmodule Image do end end + @doc """ + Applies an affine transformation to an image. + + This is the shared primitive of the affine family: `Image.translate/4` + and `Image.shear/4` are thin wrappers that call it with a fixed matrix. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `matrix` is a four-element list of numbers `[a, b, c, d]` + describing the linear part of the transformation: scale, + rotation, shear (and reflection via negative scale). An + input pixel at `(x, y)` is mapped to `(a*x + b*y, c*x + d*y)`. + + Translation is not part of the matrix - use the + `:idx`/`:idy`/`:odx`/`:ody` displacement options. + + Examples: + + * Identity: `[1, 0, 0, 1]` (the identity matrix with `:idx` + and `:idy` is `Image.translate/4`) + * Scale: + `(sx, sy)`: `[sx, 0, 0, sy]` + * Rotate by theta: + `[:math.cos(t), -:math.sin(t), :math.sin(t), :math.cos(t)]` + * Shear: + `[1, sx, sy, 1]` (this is exactly what `Image.shear/3` builds) + * Horizontal flip: + `[-1, 0, 0, 1]` + + * `options` is a keyword list of options. + + ### Options + + * `:idx` and `:idy` are the horizontal and vertical *input*-space + displacements (default `0.0`). Applied before the matrix, so the + shift is itself transformed by it. + + * `:odx` and `:ody` are the horizontal and vertical *output*-space + displacements (default `0.0`). Applied after the matrix. + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Affine.interpolate/0` for more information + about the available options. + + * `:background` defines the color of any generated background + pixels. 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. 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. The default is `:black`. + + See also `Image.Pixel.to_pixel/2`. + + * `:extend_mode` controls how the interpolator synthesises the + thin band of pixels just beyond the source edge when resampling + boundary pixels. It is visible only for transforms that sample + between pixels (rotation, scaling, sub-pixel translation), as a + one-pixel fringe along the content edge. It does *not* fill the + blank canvas left uncovered by the transformation - that area is + always filled with `:background`. The values are: + + * `:background` (the default) means the synthesised pixels use + the `:background` color. + * `:black` means the synthesised pixels are black. + * `:white` means the synthesised pixels are white. + * `:copy` means the synthesised pixels copy the nearest edge + pixel of the base image. + * `:repeat` means the base image is tiled. + * `:mirror` means the base image is reflected. + + * `:output_area` is a four-element list of integers `[left, top, + width, height]` selecting the rectangle of output space to + render, overriding the default bounding-box sizing. The result + is `width` * `height` pixels where local pixel `(x, y)` maps to + output coordinate `(left + x, top + y)`. Any part of the window + not covered by the transformed image is filled with `:background`. + + For example, `output_area: [0, 0, Image.width(image), + Image.height(image)]` keeps the output the same size as the + input, anchored at the origin. + + ### Notes + + The output canvas is sized to the bounding box of the + transformed image and is *not* affected by the displacement + options. Content displaced beyond the canvas is clipped. + + ### Returns + + * `{:ok, transformed_image}` or + + * `{:error, reason}` + + ### Examples + + Rotate 30 degrees and scale 1.5x in a single resample: + + iex> image = Image.open!("./test/support/images/jose.png") + iex> angle = :math.pi() / 6 + iex> matrix = [1.5 * :math.cos(angle), -1.5 * :math.sin(angle), 1.5 * :math.sin(angle), 1.5 * :math.cos(angle)] + iex> {:ok, _rotated_and_scaled} = Image.affine(image, matrix) + + Flip horizontally with a negative `x` scale: + + iex> image = Image.open!("./test/support/images/jose.png") + iex> {:ok, _flipped} = Image.affine(image, [-1, 0, 0, 1]) + + Stretch horizontally only (anisotropic scale): + + iex> image = Image.open!("./test/support/images/jose.png") + iex> {:ok, _stretched} = Image.affine(image, [2, 0, 0, 1]) + + """ + @doc subject: "Distortion" + + @spec affine( + image :: Vimage.t(), + matrix :: [number()], + options :: Options.Affine.affine_options() + ) :: + {:ok, Vimage.t()} | {:error, error()} + + def affine(image, matrix, options \\ []) + + def affine(%Vimage{} = image, [a, b, c, d], options) + when is_number(a) and is_number(b) and is_number(c) and is_number(d) do + with {:ok, options} <- Options.Affine.validate_options(image, options) do + Operation.affine(image, [a, b, c, d], options) + end + end + + def affine(%Vimage{}, matrix, _options) do + {:error, + %Image.Error{ + reason: :invalid_affine_matrix, + operation: :affine, + value: matrix, + message: "Invalid affine matrix. Expected a four-element list of numbers [a, b, c, d]." + }} + end + + @doc """ + Applies an affine transformation to an image or raises + an exception. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `matrix` is a four-element list of numbers `[a, b, c, d]`. + See `Image.affine/3`. + + * `options` is a keyword list of options. See `Image.affine/3`. + + ### Returns + + * `transformed_image` or + + * raises an exception + + """ + @doc subject: "Distortion" + + @spec affine!( + image :: Vimage.t(), + matrix :: [number()], + options :: Options.Affine.affine_options() + ) :: + Vimage.t() | no_return() + + def affine!(%Vimage{} = image, matrix, options \\ []) do + case affine(image, matrix, options) do + {:ok, image} -> image + {:error, reason} -> raise Image.Error, reason + end + end + + @doc """ + Translates (shifts) an image within a same-size canvas. + + Translation is an affine transformation with an identity + matrix and an input displacement of `(dx, dy)`. The canvas + is not resized, so content shifted beyond an edge is clipped. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `dx` is the number of pixels to shift the image content to + the right. Negative values shift to the left. + + * `dy` is the number of pixels to shift the image content + down. Negative values shift up. + + * `options` is a keyword list of options. + + ### Options + + * Any option accepted by `Image.affine/3` (such as `:interpolate`, + `:background` or `:extend_mode`) is supported. + + Because the canvas is not resized, `:background` fills the area + vacated by the shift; `:extend_mode` only affects the antialiased + edge fringe on fractional (sub-pixel) shifts. + + ### Returns + + * `{:ok, translated_image}` or + + * `{:error, reason}` + + """ + @doc subject: "Distortion" + + @spec translate( + image :: Vimage.t(), + dx :: number(), + dy :: number(), + options :: Options.Affine.affine_options() + ) :: + {:ok, Vimage.t()} | {:error, error()} + + def translate(%Vimage{} = image, dx, dy, options \\ []) + when is_number(dx) and is_number(dy) do + affine(image, [1, 0, 0, 1], Keyword.merge(options, idx: dx, idy: dy)) + end + + @doc """ + Translates (shifts) an image within a same-size canvas or + raises an exception. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `dx` is the number of pixels to shift the image content to + the right. Negative values shift to the left. + + * `dy` is the number of pixels to shift the image content + down. Negative values shift up. + + * `options` is a keyword list of options. See + `Image.translate/4`. + + ### Returns + + * `translated_image` or + + * raises an exception + + """ + @doc subject: "Distortion" + + @spec translate!( + image :: Vimage.t(), + dx :: number(), + dy :: number(), + options :: Options.Affine.affine_options() + ) :: + Vimage.t() | no_return() + + def translate!(%Vimage{} = image, dx, dy, options \\ []) do + case translate(image, dx, dy, options) do + {:ok, image} -> image + {:error, reason} -> raise Image.Error, reason + end + end + + @doc """ + Shears an image. + + Shearing is an affine transformation with the matrix + `[1, sx, sy, 1]`. `sx` is the horizontal shear proportional + to `y` and `sy` is the vertical shear proportional to `x`. + The canvas is sized to the bounding box of the sheared image. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `sx` is the horizontal shear factor applied in proportion + to the `y` coordinate. + + * `sy` is the vertical shear factor applied in proportion to + the `x` coordinate. + + * `options` is a keyword list of options. + + ### Options + + * Any option accepted by `Image.affine/3` (such as + `:interpolate`, `:background` or `:extend_mode`) is supported. + + ### Returns + + * `{:ok, sheared_image}` or + + * `{:error, reason}` + + """ + @doc subject: "Distortion" + + @spec shear( + image :: Vimage.t(), + sx :: number(), + sy :: number(), + options :: Options.Affine.affine_options() + ) :: + {:ok, Vimage.t()} | {:error, error()} + + def shear(%Vimage{} = image, sx, sy, options \\ []) + when is_number(sx) and is_number(sy) do + affine(image, [1, sx, sy, 1], options) + end + + @doc """ + Shears an image or raises an exception. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `sx` is the horizontal shear factor applied in proportion + to the `y` coordinate. + + * `sy` is the vertical shear factor applied in proportion to + the `x` coordinate. + + * `options` is a keyword list of options. See `Image.shear/4`. + + ### Returns + + * `sheared_image` or + + * raises an exception + + """ + @doc subject: "Distortion" + + @spec shear!( + image :: Vimage.t(), + sx :: number(), + sy :: number(), + options :: Options.Affine.affine_options() + ) :: + Vimage.t() | no_return() + + def shear!(%Vimage{} = image, sx, sy, options \\ []) do + case shear(image, sx, sy, options) do + {:ok, image} -> image + {:error, reason} -> raise Image.Error, reason + end + end + if match?({:module, _module}, Code.ensure_compiled(Nx)) do @doc """ Converts an image into an [Nx](https://hex.pm/packages/nx) diff --git a/lib/image/options/affine.ex b/lib/image/options/affine.ex new file mode 100644 index 0000000..23021eb --- /dev/null +++ b/lib/image/options/affine.ex @@ -0,0 +1,168 @@ +defmodule Image.Options.Affine do + @moduledoc """ + Options and option validation for `Image.affine/3`. + + These options are shared by the affine family of + transformations: `Image.affine/3`, `Image.translate/4` + and `Image.shear/4`. + + """ + + alias Vix.Vips.Image, as: Vimage + 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 an affine transformation. + + """ + @type affine_options :: [ + {:idx, number()} + | {:idy, number()} + | {:odx, number()} + | {:ody, number()} + | {:interpolate, interpolate()} + | {:background, Pixel.t() | :average} + | {:output_area, [integer()]} + | {:extend_mode, Image.ExtendMode.t()} + ] + + # 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 + + @displacement_options [:idx, :idy, :odx, :ody] + + @doc """ + Validate the options for `Image.affine/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.Affine.affine_options/0`. + + """ + @spec validate_options(Vimage.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} + + options -> + {:ok, options} + end + end + + 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 + + # The public option is `:extend_mode`, renamed internally to `:extend` for `libvips` + defp validate_option({:extend_mode, extend}, _image, options) + when is_atom(extend) or is_binary(extend) do + case Image.ExtendMode.validate_extend(extend) do + {:ok, extend} -> + options = + options + |> Keyword.delete(:extend_mode) + |> Keyword.put(:extend, extend) + + {:cont, options} + + {:error, reason} -> + {:halt, + {:error, %Image.Error{reason: :invalid_extend_mode, value: extend, message: reason}}} + end + end + + # Background handling mirrors `Image.warp_perspective/4`: `: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, 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 + + # The public option is `:output_area`, libvips names it `oarea`. + defp validate_option({:output_area, [left, top, width, height] = area}, _image, options) + when is_integer(left) and is_integer(top) and is_integer(width) and is_integer(height) do + options = + options + |> Keyword.delete(:output_area) + |> Keyword.put(:oarea, area) + + {:cont, options} + end + + defp validate_option({option, value}, _image, options) + when option in @displacement_options and is_number(value) do + {:cont, Keyword.put(options, option, value * 1.0)} + end + + defp validate_option(option, _image, _options) do + {:halt, {:error, invalid_option(option)}} + end + + defp invalid_option(option) do + %Image.Error{ + reason: :invalid_option, + value: option, + message: "Invalid option or option value: #{inspect(option)}" + } + end + + # `:extend_mode` defaults to `:background` rather than `:black`: since + # extend only governs the antialiased edge fringe (not the canvas fill), + # `:background` blends the fringe toward the fill color, whereas `:black` + # would leave a dark fringe on a non-black background. + defp default_options do + [extend_mode: :background, background: :black, interpolate: :bilinear] + end +end diff --git a/test/affine_test.exs b/test/affine_test.exs new file mode 100644 index 0000000..73dd1d5 --- /dev/null +++ b/test/affine_test.exs @@ -0,0 +1,206 @@ +defmodule Image.Affine.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 an affine transformation. + 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.affine/3" do + test "the identity matrix is a no-op" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.affine(image, [1, 0, 0, 1]) + + assert Image.shape(result) == {20, 20, 3} + assert Image.get_pixel!(result, 2, 3) == [255, 255, 255] + end + + test "a scaling matrix produces the expected geometry" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.affine(image, [2, 0, 0, 2]) + + # The canvas is sized to the transformed bounding box and the input + # point (2, 3) maps to (a*x + b*y, c*x + d*y) = (4, 6). + assert Image.shape(result) == {40, 40, 3} + assert Image.get_pixel!(result, 4, 6) == [255, 255, 255] + end + + 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.affine(image, [2, 0, 0, 2], 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.affine(image, [1, 0, 0, 1], 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.affine(image, [1, 0, 0, 1], not_an_option: 1) + end + + test "accepts the :extend_mode and :output_area options" do + image = white_dot(20, 20, 2, 3) + assert {:ok, %Vimage{}} = Image.affine(image, [1, 0, 0, 1], extend_mode: :mirror) + + {:ok, cropped} = Image.affine(image, [1, 0, 0, 1], output_area: [0, 0, 10, 10]) + assert Image.shape(cropped) == {10, 10, 3} + end + + test "rejects an invalid :extend_mode with a structured error" do + image = white_dot(20, 20, 2, 3) + + assert {:error, %Image.Error{reason: :invalid_extend_mode, value: :bogus}} = + Image.affine(image, [1, 0, 0, 1], extend_mode: :bogus) + end + + test "rejects a non-atom/binary :extend_mode without crashing" do + image = white_dot(20, 20, 2, 3) + + assert {:error, %Image.Error{reason: :invalid_option, value: {:extend_mode, 123}}} = + Image.affine(image, [1, 0, 0, 1], extend_mode: 123) + end + + test "applies output displacements :odx/:ody after the matrix" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.affine(image, [1, 0, 0, 1], odx: 4, ody: 1) + + assert Image.shape(result) == {20, 20, 3} + assert Image.get_pixel!(result, 6, 4) == [255, 255, 255] + end + + test "rejects a matrix that is not four numbers" do + image = white_dot(20, 20, 2, 3) + + assert {:error, %Image.Error{reason: :invalid_affine_matrix, value: [1, 0, 0]}} = + Image.affine(image, [1, 0, 0]) + + assert {:error, %Image.Error{reason: :invalid_affine_matrix, value: [1, 0, 0, :x]}} = + Image.affine(image, [1, 0, 0, :x]) + end + + test "affine!/3 returns an image on success" do + image = white_dot(20, 20, 2, 3) + assert %Vimage{} = Image.affine!(image, [1, 0, 0, 1]) + end + + test "affine!/3 raises on error" do + image = white_dot(20, 20, 2, 3) + + assert_raise Image.Error, fn -> + Image.affine!(image, [1, 0, 0, 1], interpolate: :unknown) + end + end + + test "applies the documented default :extend_mode, :background and :interpolate" do + image = white_dot(20, 20, 2, 3) + {:ok, options} = Image.Options.Affine.validate_options(image, []) + + assert Keyword.get(options, :extend) == :VIPS_EXTEND_BACKGROUND + assert Keyword.get(options, :background) == [0, 0, 0] + assert %Vix.Vips.Interpolate{} = Keyword.get(options, :interpolate) + end + end + + describe "Image.translate/4" do + test "shifts content right and down without resizing the canvas" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.translate(image, 5, 4) + + assert Image.shape(result) == {20, 20, 3} + assert Image.get_pixel!(result, 7, 7) == [255, 255, 255] + assert Image.get_pixel!(result, 2, 3) == [0, 0, 0] + end + + test "clips content shifted off the canvas" do + # The dot is near the right edge. Shifting it further right pushes it + # off the (unchanged) canvas entirely. + image = white_dot(20, 20, 18, 10) + {:ok, result} = Image.translate(image, 5, 0) + + assert Image.shape(result) == {20, 20, 3} + # the dot ran off the right edge, so the canvas is entirely background + assert Vix.Vips.Operation.avg!(result) == 0.0 + end + + test "fills the vacated area with the background colour" do + image = white_dot(20, 20, 10, 10) + + {:ok, result} = + Image.translate(image, 5, 0, background: [100, 100, 100], extend_mode: :background) + + assert Image.get_pixel!(result, 0, 0) == [100, 100, 100] + end + + test "accepts a CSS colour name as the :background fill" do + image = white_dot(20, 20, 10, 10) + {:ok, result} = Image.translate(image, 5, 0, background: :red) + + assert Image.get_pixel!(result, 0, 0) == [255, 0, 0] + end + + test "accepts :average as the :background fill" do + image = Image.new!(20, 20, color: [10, 20, 30]) + {:ok, result} = Image.translate(image, 5, 0, background: :average) + + assert Image.get_pixel!(result, 0, 0) == [10, 20, 30] + end + + test "rejects an invalid :background" do + image = white_dot(20, 20, 10, 10) + assert {:error, _reason} = Image.translate(image, 5, 0, background: :not_a_colour) + end + + test "translate!/4 returns an image on success" do + image = white_dot(20, 20, 2, 3) + assert %Vimage{} = Image.translate!(image, 5, 4) + end + + test "translate!/4 raises on error" do + image = white_dot(20, 20, 2, 3) + assert_raise Image.Error, fn -> Image.translate!(image, 5, 4, interpolate: :unknown) end + end + end + + describe "Image.shear/4" do + test "horizontal shear (proportional to y) widens the canvas" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.shear(image, 0.5, 0) + + {width, height, _bands} = Image.shape(result) + assert width == 30 + assert height == 20 + end + + test "vertical shear (proportional to x) heightens the canvas" do + image = white_dot(20, 20, 2, 3) + {:ok, result} = Image.shear(image, 0, 0.5) + + {width, height, _bands} = Image.shape(result) + assert width == 20 + assert height == 30 + end + + test "shear!/4 returns an image on success" do + image = white_dot(20, 20, 2, 3) + assert %Vimage{} = Image.shear!(image, 0.5, 0) + end + + test "shear!/4 raises on error" do + image = white_dot(20, 20, 2, 3) + assert_raise Image.Error, fn -> Image.shear!(image, 0.5, 0, interpolate: :unknown) end + end + end +end