diff --git a/lib/image.ex b/lib/image.ex index 83fd7ea3..8aebddff 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -6960,6 +6960,37 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. + * `options` is a keyword list of options. + + ### Options + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.interpolate/0` for more information + about the available options. + + * `:background` defines the color of pixels whose mapped coordinates + fall outside the source image. This can be specified as a single + integer 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`), a hex + string, or `:average`. Wrap it as `{color, alpha: a}` for a + transparent or semi-transparent fill. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. + + If omitted, `libvips`' native all-zeros fill is used: transparent + for images with an alpha band, black otherwise. + + ## Transparent backgrounds + + An alpha band passes through the transformation. A partially + transparent `:background` is reproduced exactly. The one exception + is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + color cannot be recovered from under zero alpha, so it is rendered as + transparent black rather than the declared color. + ### Returns * `{:ok, image_with_ripple}` or @@ -6977,36 +7008,40 @@ defmodule Image do @dialyzer {:nowarn_function, {:ripple, 1}} @doc subject: "Operation" - @spec ripple(Vimage.t()) :: {:ok, Vimage.t()} | {:error, error()} - def ripple(%Vimage{} = image) do + @spec ripple(Vimage.t(), Options.Mapim.background_options()) :: + {:ok, Vimage.t()} | {:error, error()} + def ripple(%Vimage{} = image, options \\ []) do use Image.Math - width = width(image) - height = height(image) + with {:ok, options} <- + Options.Mapim.validate_options(image, options, [:interpolate, :background]) do + width = width(image) + height = height(image) - # this makes an image where pixel (0, 0) (at the top-left) has value [0, 0], - # and pixel (image.width, image.height) at the bottom-right has value - # [image.width, image.height] - {:ok, index} = Operation.xyz(width, height) + # this makes an image where pixel (0, 0) (at the top-left) has value [0, 0], + # and pixel (image.width, image.height) at the bottom-right has value + # [image.width, image.height] + {:ok, index} = Operation.xyz(width, height) - # make a version with (0, 0) at the centre, negative values up and left, - # positive down and right - center = index - [width / 2, height / 2] + # make a version with (0, 0) at the centre, negative values up and left, + # positive down and right + center = index - [width / 2, height / 2] - # to polar space, so each pixel is now distance and angle in degrees - {:ok, polar} = Complex.polar(center) + # to polar space, so each pixel is now distance and angle in degrees + {:ok, polar} = Complex.polar(center) - # scale sin(distance) by 1/distance to make a wavey pattern - d = 10_000 * sin!(polar[0] * 3) / (1 + polar[0]) + # scale sin(distance) by 1/distance to make a wavey pattern + d = 10_000 * sin!(polar[0] * 3) / (1 + polar[0]) - # and back to rectangular coordinates again to make a set of vectors we can - # apply to the original index image - {:ok, joined} = Operation.bandjoin([d, polar[1]]) - {:ok, rectangular} = Complex.rectangular(joined) - index = index + rectangular + # and back to rectangular coordinates again to make a set of vectors we can + # apply to the original index image + {:ok, joined} = Operation.bandjoin([d, polar[1]]) + {:ok, rectangular} = Complex.rectangular(joined) + index = index + rectangular - # finally, use our modified index image to distort the input! - Operation.mapim(image, index) + # finally, use our modified index image to distort the input! + mapim(image, index, options) + end end @doc """ @@ -7017,6 +7052,8 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. + * `options` is a keyword list of options. See `Image.ripple/2`. + ### Returns * `image_with_ripple` or @@ -7034,9 +7071,9 @@ defmodule Image do @dialyzer {:nowarn_function, {:ripple!, 1}} @doc subject: "Operation" - @spec ripple!(Vimage.t()) :: Vimage.t() | no_return() - def ripple!(%Vimage{} = image) do - case ripple(image) do + @spec ripple!(Vimage.t(), Options.Mapim.background_options()) :: Vimage.t() | no_return() + def ripple!(%Vimage{} = image, options \\ []) do + case ripple(image, options) do {:ok, image} -> image {:error, reason} -> raise Image.Error, reason end @@ -9413,6 +9450,37 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. + * `options` is a keyword list of options. + + ### Options + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.interpolate/0` for more information + about the available options. + + * `:background` defines the color of pixels whose mapped coordinates + fall outside the source image. This can be specified as a single + integer 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`), a hex + string, or `:average`. Wrap it as `{color, alpha: a}` for a + transparent or semi-transparent fill. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. + + If omitted, `libvips`' native all-zeros fill is used: transparent + for images with an alpha band, black otherwise. + + ## Transparent backgrounds + + An alpha band passes through the transformation. A partially + transparent `:background` is reproduced exactly. The one exception + is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + color cannot be recovered from under zero alpha, so it is rendered as + transparent black rather than the declared color. + ### Returns * `{:ok, image_in_polar_coordinates}` or @@ -9430,23 +9498,27 @@ defmodule Image do @dialyzer {:nowarn_function, {:to_polar_coordinates, 1}} @doc subject: "Operation" - @spec to_polar_coordinates(Vimage.t()) :: {:ok, Vimage.t()} | {:error, error()} - def to_polar_coordinates(%Vimage{} = image) do + @spec to_polar_coordinates(Vimage.t(), Options.Mapim.background_options()) :: + {:ok, Vimage.t()} | {:error, error()} + def to_polar_coordinates(%Vimage{} = image, options \\ []) do use Image.Math - width = width(image) - height = height(image) - min = min(width, height) - scale = Kernel./(min, width) + with {:ok, options} <- + Options.Mapim.validate_options(image, options, [:interpolate, :background]) do + width = width(image) + height = height(image) + min = min(width, height) + scale = Kernel./(min, width) - xy = Operation.xyz!(width, height) - xy = xy - [width / 2.0, height / 2.0] - xy = xy * 2.0 / scale + xy = Operation.xyz!(width, height) + xy = xy - [width / 2.0, height / 2.0] + xy = xy * 2.0 / scale - {:ok, index} = Complex.polar(xy) - index = index * [1.0, height / 360.0] + {:ok, index} = Complex.polar(xy) + index = index * [1.0, height / 360.0] - Operation.mapim(image, index) + mapim(image, index, options) + end end @doc """ @@ -9457,6 +9529,9 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. + * `options` is a keyword list of options. See + `Image.to_polar_coordinates/2`. + ### Returns * `image_in_polar_coordinates` or @@ -9474,9 +9549,10 @@ defmodule Image do @dialyzer {:nowarn_function, {:to_polar_coordinates!, 1}} @doc subject: "Operation" - @spec to_polar_coordinates!(Vimage.t()) :: Vimage.t() | no_return() - def to_polar_coordinates!(%Vimage{} = image) do - case to_polar_coordinates(image) do + @spec to_polar_coordinates!(Vimage.t(), Options.Mapim.background_options()) :: + Vimage.t() | no_return() + def to_polar_coordinates!(%Vimage{} = image, options \\ []) do + case to_polar_coordinates(image, options) do {:ok, image} -> image {:error, reason} -> raise Image.Error, reason end @@ -9489,12 +9565,20 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. + * `options` is a keyword list of options. + + ### Options + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.interpolate/0` for more information + about the available options. + ## Notes - Roundtrip to polar and back to rectangular - coordinates displays some image distortion, - likely due to rounding errors in float - arithmetic. Further study is required. + Interpolation samples immediately outside the source are clamped + to the nearest source pixel. ### Returns @@ -9514,24 +9598,28 @@ defmodule Image do @dialyzer {:nowarn_function, {:to_rectangular_coordinates, 1}} @doc subject: "Operation" - @spec to_rectangular_coordinates(Vimage.t()) :: {:ok, Vimage.t()} | {:error, error()} - def to_rectangular_coordinates(%Vimage{} = image) do + @spec to_rectangular_coordinates(Vimage.t(), Options.Mapim.interpolate_options()) :: + {:ok, Vimage.t()} | {:error, error()} + def to_rectangular_coordinates(%Vimage{} = image, options \\ []) do use Image.Math - width = width(image) - height = height(image) - min = min(width, height) - scale = Kernel./(min, width) + with {:ok, options} <- Options.Mapim.validate_options(image, options, [:interpolate]) do + width = width(image) + height = height(image) + min = min(width, height) + scale = Kernel./(min, width) - xy = Operation.xyz!(width, height) - xy = xy * [1.0, 360.0 / height] + xy = Operation.xyz!(width, height) + xy = xy * [1.0, 360.0 / height] - {:ok, index} = Complex.rectangular(xy) + {:ok, index} = Complex.rectangular(xy) - index = index * scale / 2.0 - index = index + [width / 2.0, height / 2.0] + index = index * scale / 2.0 + index = index + [width / 2.0, height / 2.0] - Operation.mapim(image, index) + options = Keyword.put(options, :extend, :VIPS_EXTEND_COPY) + mapim(image, index, options) + end end @doc """ @@ -9542,12 +9630,8 @@ defmodule Image do * `image` is any `t:Vix.Vips.Image.t/0`. - ## Notes - - Roundtrip to polar and back to rectangular - coordinates displays some image distortion, - likely due to rounding errors in float - arithmetic. Further study is required. + * `options` is a keyword list of options. See + `Image.to_rectangular_coordinates/2`. ### Returns @@ -9567,9 +9651,10 @@ defmodule Image do @dialyzer {:nowarn_function, {:to_rectangular_coordinates!, 1}} @doc subject: "Operation" - @spec to_rectangular_coordinates!(Vimage.t()) :: Vimage.t() | no_return() - def to_rectangular_coordinates!(%Vimage{} = image) do - case to_rectangular_coordinates(image) do + @spec to_rectangular_coordinates!(Vimage.t(), Options.Mapim.interpolate_options()) :: + Vimage.t() | no_return() + def to_rectangular_coordinates!(%Vimage{} = image, options \\ []) do + case to_rectangular_coordinates(image, options) do {:ok, image} -> image {:error, reason} -> raise Image.Error, reason end @@ -11905,20 +11990,17 @@ defmodule Image do * `: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 (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. + 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`), a hex + string, or `:average`. Wrap it as `{color, alpha: a}` for a + transparent or semi-transparent fill. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. If omitted, `libvips`' native all-zeros fill is used: transparent for images with an alpha band, black otherwise. - See `Image.Pixel.to_pixel/2` for the full range of accepted - color forms. - * `:extend_mode` controls how the interpolator synthesizes the one-pixel fringe just beyond the content edge when resampling boundary pixels. It does *not* fill the canvas left uncovered by @@ -12023,12 +12105,18 @@ defmodule Image do }} end + defp premultiplied_affine(%Vimage{} = image, matrix, options) do + premultiplied_transform(image, options, &Operation.affine(&1, matrix, &2)) + end + # `libvips` resamples alpha images in premultiplied-alpha space and # injects the `:background` fill raw into that space, so the fill's # color bands come back scaled by `max_alpha / alpha`. Premultiplying # the image and the background ourselves (with `premultiplied: true`) - # makes the fill round-trip exactly. - defp premultiplied_affine(%Vimage{} = image, matrix, options) do + # makes the fill round-trip exactly. `transform` is a fun receiving + # the (possibly premultiplied) image and options, returning + # `{:ok, image}` or `{:error, reason}`. + defp premultiplied_transform(%Vimage{} = image, options, transform) do if has_alpha?(image) do band_format = Vix.Vips.Image.format(image) @@ -12038,12 +12126,30 @@ defmodule Image do |> Keyword.put(:premultiplied, true) with {:ok, premultiplied} <- Operation.premultiply(image), - {:ok, transformed} <- Operation.affine(premultiplied, matrix, options), + {:ok, transformed} <- transform.(premultiplied, options), {:ok, unpremultiplied} <- Operation.unpremultiply(transformed) do Operation.cast(unpremultiplied, band_format) end else - Operation.affine(image, matrix, options) + transform.(image, options) + end + end + + defp mapim(%Vimage{} = image, coordinate_map, options) do + # libvips normally handles alpha premultiplication for mapim. With an + # explicit non-opaque background, however, it injects the background + # without premultiplying its color bands, so handle that path explicitly. + if premultiply_explicitly?(image, options) do + premultiplied_transform(image, options, &Operation.mapim(&1, coordinate_map, &2)) + else + Operation.mapim(image, coordinate_map, options) + end + end + + defp premultiply_explicitly?(image, options) do + case Keyword.fetch(options, :background) do + {:ok, background} -> has_alpha?(image) and List.last(background) != opaque_alpha(image) + :error -> false end end @@ -12051,14 +12157,16 @@ defmodule Image do # The background premultiplication must use the same alpha scale as the # operations above. `Image.Pixel` encodes that scale as the opaque alpha # value, so read it from there. - max_alpha = - case Pixel.to_pixel(image, :black, alpha: :opaque) do - {:ok, pixel} -> List.last(pixel) - {:error, _reason} -> 255 - end + opaque_alpha = opaque_alpha(image) {color_bands, [alpha]} = Enum.split(pixel, -1) - Enum.map(color_bands, &(&1 * alpha / max_alpha)) ++ [alpha] + Enum.map(color_bands, &(&1 * alpha / opaque_alpha)) ++ [alpha] + end + + defp opaque_alpha(image) do + image + |> Pixel.to_pixel!(:black, alpha: :opaque) + |> List.last() end @doc """ @@ -12627,22 +12735,29 @@ defmodule Image do four corners of the destination image into which the subject-of-interest is transformed. - * `options` is a keyword list of options. The default - is `[]`. + * `options` is a keyword list of options. ### Options + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.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`. - The default is `:black`. `:background` can also be set to `:average` - in which case the background will be the average color of the base - image. See `Image.Pixel.to_pixel/2` for the full range of accepted + CSS color name as a string or atom (for example + `:misty_rose`), a hex string, or `:average`. Wrap it as + `{color, alpha: a}` for a transparent or semi-transparent fill. + See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. + * `:extend_mode` controls how the one-pixel fringe just beyond the warped content edge is synthesized during interpolation. The uncovered canvas is always filled with `:background`. The @@ -12652,11 +12767,13 @@ defmodule Image do content covers the whole output). See `Image.affine/3` for more detail. - ### Notes + ## Transparent backgrounds - * The image is flattened before warping and therefore any - alpha band will be multiplied into to the image data and - removed. + An alpha band passes through the warp. A partially transparent + `:background` is reproduced exactly. The one exception is a + *fully* transparent fill (`alpha: 0`) with non-zero color bands: + color cannot be recovered from under zero alpha, so it is + rendered as transparent black rather than the declared color. ### Returns @@ -12680,15 +12797,14 @@ defmodule Image do Vimage.t(), source :: quadrilateral(), destination :: quadrilateral(), - Options.WarpPerspective.t() + Options.Mapim.t() ) :: {:ok, Vimage.t()} | {:error, error()} def warp_perspective(%Vimage{} = image, source, destination, options \\ []) do - with {:ok, flattened} <- flatten(image), - {:ok, options} <- Options.WarpPerspective.validate_options(flattened, options), - {:ok, transform_map} <- transform_matrix(flattened, source, destination) do - Operation.mapim(flattened, transform_map, options) + with {:ok, options} <- Options.Mapim.validate_options(image, options), + {:ok, transform_map} <- transform_matrix(image, source, destination) do + mapim(image, transform_map, options) end end @@ -12709,25 +12825,8 @@ defmodule Image do four corners of the destination image into which the subject-of-interest is transformed. - * `options` is a keyword list of options. The default - is `[]`. - - ### 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`. The default is `:black`. `:background` can - also be set to `:average` in which case the background will be - the average color of the base image. See `Image.Pixel.to_pixel/2` - for the full range of accepted color forms. - - The one-pixel antialiased fringe along the warped content edge - blends toward `:background` by default. See `Image.warp_perspective/4` - for the `:extend_mode` option. + * `options` is a keyword list of options. See + `Image.warp_perspective/4`. ### Returns @@ -12735,12 +12834,6 @@ defmodule Image do * raises an exception. - ### Notes - - * The image is flattened before warping and therefore any - alpha band will be multiplied into to the image data and - removed. - ### Examples iex> image = Image.new!(100, 100, color: :white) @@ -12757,7 +12850,7 @@ defmodule Image do Vimage.t(), source :: quadrilateral(), destination :: quadrilateral(), - Options.WarpPerspective.t() + Options.Mapim.t() ) :: Vimage.t() | no_return() @@ -12781,21 +12874,29 @@ defmodule Image do * `source` is a list of four 2-tuples representing the four corners of the subject-of-interest in `image`. - * `options` is a keyword list of options. The default - is `[]`. + * `options` is a keyword list of options. ### Options + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.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`. The default is `:black`. `:background` can - also be set to `:average` in which case the background will be - the average color of the base image. See `Image.Pixel.to_pixel/2` - for the full range of accepted color forms. + CSS color name as a string or atom (for example + `:misty_rose`), a hex string, or `:average`. Wrap it as + `{color, alpha: a}` for a transparent or semi-transparent fill. + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. + See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms and + `Image.warp_perspective/4` for how transparent backgrounds + are handled. The one-pixel antialiased fringe along the warped content edge blends toward `:background` by default. See `Image.warp_perspective/4` @@ -12809,10 +12910,6 @@ defmodule Image do ### Notes - * The image is flattened before warping and therefore any - alpha band will be multiplied into to the image data and - removed. - * The returned `destination` is a four element list of 2-tuples representing the four points to which the `source` points were transformed. `destination` can be passed as @@ -12835,7 +12932,7 @@ defmodule Image do @spec straighten_perspective( Vimage.t(), source :: quadrilateral(), - Options.WarpPerspective.t() + Options.Mapim.t() ) :: {:ok, quadrilateral(), Vimage.t()} | {:error, error()} @@ -12868,21 +12965,8 @@ defmodule Image do * `source` is a list of four 2-tuples representing the four corners of the subject-of-interest in `image`. - * `options` is a keyword list of options. The default - is `[]`. - - ### 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`. The default is `:black`. `:background` can - also be set to `:average` in which case the background will be - the average color of the base image. See `Image.Pixel.to_pixel/2` - for the full range of accepted color forms. + * `options` is a keyword list of options. See + `Image.straighten_perspective/3`. ### Returns @@ -12892,10 +12976,6 @@ defmodule Image do ### Notes - * The image is flattened before warping and therefore any - alpha band will be multiplied into to the image data and - removed. - * The returned `destination` is a four element list of 2-tuples representing the four points to which the `source` points were transformed. `destination` can be passed as @@ -12916,7 +12996,7 @@ defmodule Image do @spec straighten_perspective!( Vimage.t(), source :: quadrilateral(), - Options.WarpPerspective.t() + Options.Mapim.t() ) :: Vimage.t() | no_return() @@ -12952,6 +13032,44 @@ defmodule Image do the destination points into which the image is transformed. + * `options` is a keyword list of options. + + ### Options + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear`, `:bicubic` (the default), + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.interpolate/0` for more information + about the available options. + + * `:background` defines the color of pixels whose mapped coordinates + fall outside the source image. This can be specified as a single + integer 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`), a hex + string, or `:average`. Wrap it as `{color, alpha: a}` for a + transparent or semi-transparent fill. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. + + If omitted, `libvips`' native all-zeros fill is used: transparent + for images with an alpha band, black otherwise. + + * `:extend_mode` controls how the one-pixel fringe just beyond the + source edge is synthesized during interpolation. The values are + `:background` (the default), which blends the fringe toward the + background, and `:copy`, which clamps it to the nearest source + pixel. Pixels mapped farther outside the source still use + `:background`. + + ## Transparent backgrounds + + An alpha band passes through the transformation. A partially + transparent `:background` is reproduced exactly. The one exception + is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + color cannot be recovered from under zero alpha, so it is rendered as + transparent black rather than the declared color. + ### Example In this example the points around `{30,11}` are distorted to `{20,11}` and @@ -12963,33 +13081,46 @@ defmodule Image do """ @doc subject: "Distortion", since: "0.57.0" - @spec distort(image :: Vimage.t(), source :: list(point()), destination :: list(point())) :: + @spec distort( + image :: Vimage.t(), + source :: list(point()), + destination :: list(point()), + options :: Options.Mapim.t() + ) :: {:ok, Vimage.t()} | {:error, error()} - def distort(%Vimage{} = image, [{_x1, _y1} | _] = source, [{_x2, _y2} | _] = destination) + def distort( + %Vimage{} = image, + [{_x1, _y1} | _] = source, + [{_x2, _y2} | _] = destination, + options \\ [] + ) when length(source) == length(destination) do use Image.Math - index = Operation.xyz!(Image.width(image), Image.height(image)) - couples = Enum.zip(source, destination) + options = Keyword.put_new(options, :interpolate, :bicubic) - {deltas, weights} = - Enum.reduce(couples, {[], []}, fn {p1, p2}, {deltas, weights} -> - {p1x, p1y} = p1 - {p2x, p2y} = p2 + with {:ok, options} <- Options.Mapim.validate_options(image, options) do + index = Operation.xyz!(Image.width(image), Image.height(image)) + couples = Enum.zip(source, destination) - diff = index - Tuple.to_list(p2) - distance = diff[0] ** 2 + diff[1] ** 2 + {deltas, weights} = + Enum.reduce(couples, {[], []}, fn {p1, p2}, {deltas, weights} -> + {p1x, p1y} = p1 + {p2x, p2y} = p2 - weight = Image.if_then_else!(distance < 1.0, 1.0, 1.0 / distance) - delta = weight * [p1x - p2x, p1y - p2y] + diff = index - Tuple.to_list(p2) + distance = diff[0] ** 2 + diff[1] ** 2 - {[delta | deltas], [weight | weights]} - end) + weight = Image.if_then_else!(distance < 1.0, 1.0, 1.0 / distance) + delta = weight * [p1x - p2x, p1y - p2y] + + {[delta | deltas], [weight | weights]} + end) - index = index + Operation.sum!(deltas) / Operation.sum!(weights) - bicubic_interpolator = Vix.Vips.Interpolate.new!("bicubic") - Operation.mapim(image, index, interpolate: bicubic_interpolator) + index = index + Operation.sum!(deltas) / Operation.sum!(weights) + mapim(image, index, options) + end end @doc """ @@ -13259,6 +13390,44 @@ defmodule Image do * `transform_matrix` is a matrix returned by `Image.transform_matrix/3`. + * `options` is a keyword list of options. + + ### Options + + * `:interpolate` selects the interpolator used to resample + pixels: `:nearest`, `:bilinear` (the default), `:bicubic`, + `:lbb`, `:nohalo` or `:vsqbs`. See + `t:Image.Options.Mapim.interpolate/0` for more information + about the available options. + + * `:background` defines the color of pixels whose mapped coordinates + fall outside the source image. This can be specified as a single + integer 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`), a hex + string, or `:average`. Wrap it as `{color, alpha: a}` for a + transparent or semi-transparent fill. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. + + If omitted, `libvips`' native all-zeros fill is used: transparent + for images with an alpha band, black otherwise. + + * `:extend_mode` controls how the one-pixel fringe just beyond the + source edge is synthesized during interpolation. The values are + `:background` (the default), which blends the fringe toward the + background, and `:copy`, which clamps it to the nearest source + pixel. Pixels mapped farther outside the source still use + `:background`. + + ## Transparent backgrounds + + An alpha band passes through the transformation. A partially + transparent `:background` is reproduced exactly. The one exception + is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + color cannot be recovered from under zero alpha, so it is rendered as + transparent black rather than the declared color. + ### Returns * `{:ok, mapped_image}` or @@ -13277,10 +13446,11 @@ defmodule Image do """ @doc subject: "Operation", since: "0.28.0" - @spec map(Vimage.t(), Vimage.t(), Keyword.t()) :: {:ok, Vimage.t()} | {:error, error()} + @spec map(Vimage.t(), Vimage.t(), Options.Mapim.t()) :: + {:ok, Vimage.t()} | {:error, error()} def map(%Vimage{} = image, %Vimage{} = transformation_matrix, options \\ []) do - with {:ok, options} <- Options.WarpPerspective.validate_options(image, options) do - Operation.mapim(image, transformation_matrix, options) + with {:ok, options} <- Options.Mapim.validate_options(image, options) do + mapim(image, transformation_matrix, options) end end diff --git a/lib/image/options/mapim.ex b/lib/image/options/mapim.ex new file mode 100644 index 00000000..48d5fc73 --- /dev/null +++ b/lib/image/options/mapim.ex @@ -0,0 +1,163 @@ +defmodule Image.Options.Mapim do + @moduledoc """ + Options shared by transformations implemented with `Vix.Vips.Operation.mapim/3`. + + """ + + alias Image.BackgroundColor + alias Vix.Vips.Image, as: Vimage + alias Vix.Vips.Interpolate + + @extend_modes [background: :VIPS_EXTEND_BACKGROUND, copy: :VIPS_EXTEND_COPY] + + @typedoc """ + The interpolators that may be selected with the `:interpolate` + option (descriptions from `vips -l interpolate`): + + * `:nearest` - nearest-neighbour interpolation + * `:bilinear` - bilinear interpolation + * `:bicubic` - bicubic interpolation (Catmull-Rom) + * `:lbb` - reduced halo bicubic + * `:nohalo` - edge sharpening resampler with halo reduction + * `:vsqbs` - B-Splines with antialiasing smoothing + + `:bilinear` is the default except for `Image.distort/4`, which + retains its existing `:bicubic` default. + + """ + @type interpolate :: + :nearest + | :bilinear + | :bicubic + | :lbb + | :nohalo + | :vsqbs + + @typedoc "An interpolation option for a mapim-based transformation." + @type interpolate_option :: {:interpolate, interpolate()} + + @typedoc "A background fill option for a mapim-based transformation." + @type background_option :: {:background, BackgroundColor.spec() | nil} + + @typedoc """ + How the interpolator synthesizes the one-pixel fringe just beyond + the content edge when resampling boundary pixels. + + * `:background` (the default) blends the fringe toward the + `:background` color. This is correct whenever the transform + exposes canvas. + * `:copy` clamps to the nearest content pixel. Use it when the + content fills the whole canvas, where the default would leave a + faint border along the outermost row and column. + + """ + @type extend_mode :: :background | :copy + + @typedoc "An interpolation-boundary option for a mapim-based transformation." + @type extend_option :: {:extend_mode, extend_mode()} + + @typedoc "Interpolation and background options for transformations that expose canvas." + @type background_options :: [interpolate_option() | background_option()] + + @typedoc "Interpolation options for transformations with fixed boundary handling." + @type interpolate_options :: [interpolate_option()] + + @typedoc "Interpolation, background and boundary options for a mapim-based transformation." + @type t :: [interpolate_option() | background_option() | extend_option()] + + @typep option_name :: :interpolate | :background | :extend_mode + + @option_names [:interpolate, :background, :extend_mode] + + # 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 """ + Validates the allowed options for a mapim-based transformation. + + All mapim options are allowed by default. + """ + @spec validate_options(Vimage.t(), Keyword.t(), [option_name()]) :: + {:ok, Keyword.t()} | {:error, Image.error()} + def validate_options(image, options, allowed_options \\ @option_names) when is_list(options) do + options = + Keyword.merge( + default_options(allowed_options), + maybe_drop_nil_background(options, allowed_options) + ) + + case Enum.reduce_while(options, options, &validate_option(&1, image, &2, allowed_options)) do + {:error, value} -> {:error, value} + options -> {:ok, options} + end + end + + defp validate_option( + {:interpolate, interpolate} = option, + _image, + options, + allowed_options + ) + when interpolate in @valid_interpolators do + if :interpolate in allowed_options do + case Interpolate.new(Atom.to_string(interpolate)) do + {:ok, interpolator} -> + {:cont, Keyword.put(options, :interpolate, interpolator)} + + {:error, reason} -> + {:halt, {:error, reason}} + end + else + {:halt, {:error, invalid_option(option)}} + end + end + + defp validate_option({:background, background} = option, image, options, allowed_options) do + if :background in allowed_options do + case BackgroundColor.resolve(image, background) do + {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} + {:error, reason} -> {:halt, {:error, reason}} + end + else + {:halt, {:error, invalid_option(option)}} + end + end + + defp validate_option({:extend_mode, extend_mode} = option, _image, options, allowed_options) do + if :extend_mode in allowed_options and Keyword.has_key?(@extend_modes, extend_mode) do + options = + options + |> Keyword.delete(:extend_mode) + |> Keyword.put(:extend, Keyword.fetch!(@extend_modes, extend_mode)) + + {:cont, options} + else + {:halt, {:error, invalid_option(option)}} + end + end + + defp validate_option(option, _image, _options, _allowed_options) do + {:halt, {:error, invalid_option(option)}} + end + + defp maybe_drop_nil_background(options, allowed_options) do + if :background in allowed_options do + Enum.reject(options, &match?({:background, nil}, &1)) + else + options + end + end + + defp default_options(allowed_options) do + Keyword.take([interpolate: :bilinear, extend_mode: :background], allowed_options) + end + + defp invalid_option(option) do + %Image.Error{ + reason: :invalid_option, + value: option, + message: "Invalid option or option value: #{inspect(option)}" + } + end +end diff --git a/lib/image/options/warp_perspective.ex b/lib/image/options/warp_perspective.ex deleted file mode 100644 index 2ecfdb97..00000000 --- a/lib/image/options/warp_perspective.ex +++ /dev/null @@ -1,82 +0,0 @@ -defmodule Image.Options.WarpPerspective do - @moduledoc """ - Options and option validation for `Image.warp_perspective/4`. - - """ - alias Vix.Vips.Image, as: Vimage - alias Image.BackgroundColor - - @extend_modes [background: :VIPS_EXTEND_BACKGROUND, copy: :VIPS_EXTEND_COPY] - - @typedoc """ - Options for Image.warp_perspective/4. - - """ - @type t :: [warp_perspective_option()] - - @typedoc """ - Options applicable to `Image.warp_perspective/4`. - - """ - @type warp_perspective_option :: - {:background, BackgroundColor.spec() | nil} - | {:extend_mode, Image.Options.Affine.extend_mode()} - - @doc """ - Validate the options for `Image.warp_perspective/4`. - - """ - @spec validate_options(Vimage.t(), Keyword.t()) :: - {:ok, Keyword.t()} | {:error, Image.error()} - def validate_options(image, options) when is_list(options) do - # A nil `:background` means "unset", i.e. it falls back to the default. - options = Enum.reject(options, &match?({:background, nil}, &1)) - 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({:background, background}, image, options) do - case Image.BackgroundColor.resolve(image, background) do - {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} - {:error, reason} -> {:halt, {:error, reason}} - end - end - - # The public option is `:extend_mode`, renamed to `:extend` for `libvips`. - # Only `:background` and `:copy` are exposed. See the `@extend_modes` - # comment in `Image.Options.Affine` for the rationale. - defp validate_option({:extend_mode, extend_mode}, _image, options) - when extend_mode in [:background, :copy] do - options = - options - |> Keyword.delete(:extend_mode) - |> Keyword.put(:extend, Keyword.fetch!(@extend_modes, extend_mode)) - - {:cont, options} - 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` so the edge fringe blends - # into the canvas fill. - defp default_options do - [background: :black, extend_mode: :background] - end -end diff --git a/test/coverage_wave3_options_test.exs b/test/coverage_wave3_options_test.exs index e7250598..458b3ab8 100644 --- a/test/coverage_wave3_options_test.exs +++ b/test/coverage_wave3_options_test.exs @@ -6,7 +6,7 @@ defmodule Image.CoverageWave3.Options.Test do defp rgb(size \\ 20), do: Image.new!(size, size, color: [10, 20, 30]) defp rgba(size \\ 20), do: Image.new!(size, size, color: [10, 20, 30, 255]) - describe "Image.Options.WarpPerspective" do + describe "Image.Options.Mapim" do test "an unknown option returns an error" do from = [{10, 10}, {90, 12}, {88, 90}, {12, 88}] to = [{0, 0}, {100, 0}, {100, 100}, {0, 100}] @@ -23,13 +23,30 @@ defmodule Image.CoverageWave3.Options.Test do Image.warp_perspective(rgb(100), from, to, background_color: :not_a_color) end - test ":extend defaults to background and :extend_mode is renamed for libvips" do - assert {:ok, options} = Image.Options.WarpPerspective.validate_options(rgb(100), []) + test ":extend defaults to background" do + assert {:ok, options} = Image.Options.Mapim.validate_options(rgb(100), []) + assert Keyword.get(options, :extend) == :VIPS_EXTEND_BACKGROUND refute Keyword.has_key?(options, :extend_mode) + end + + test ":interpolate selects an interpolator from the public vocabulary" do + for interpolator <- [:nearest, :bilinear, :bicubic, :lbb, :nohalo, :vsqbs] do + assert {:ok, options} = + Image.Options.Mapim.validate_options(rgb(100), interpolate: interpolator) + + assert %Vix.Vips.Interpolate{} = Keyword.get(options, :interpolate) + end + end + + test "rejects an unknown :interpolate value" do + assert {:error, %Image.Error{reason: :invalid_option, value: {:interpolate, :unknown}}} = + Image.Options.Mapim.validate_options(rgb(100), interpolate: :unknown) + end + test ":extend_mode is renamed for libvips" do assert {:ok, options} = - Image.Options.WarpPerspective.validate_options(rgb(100), extend_mode: :copy) + Image.Options.Mapim.validate_options(rgb(100), extend_mode: :copy) assert Keyword.get(options, :extend) == :VIPS_EXTEND_COPY refute Keyword.has_key?(options, :extend_mode) @@ -38,7 +55,7 @@ defmodule Image.CoverageWave3.Options.Test do test "only :background and :copy are valid extend modes" do for extend_mode <- [:repeat, :mirror, :black, :white] do assert {:error, %Image.Error{reason: :invalid_option}} = - Image.Options.WarpPerspective.validate_options(rgb(100), extend_mode: extend_mode) + Image.Options.Mapim.validate_options(rgb(100), extend_mode: extend_mode) end end end diff --git a/test/distortion_test.exs b/test/distortion_test.exs index 08bf63cb..f181e889 100644 --- a/test/distortion_test.exs +++ b/test/distortion_test.exs @@ -2,6 +2,8 @@ defmodule Image.Distortion.Test do use ExUnit.Case, async: true import Image.TestSupport + @background {[10, 20, 30], alpha: 40} + test "Image.distort/3" do image_file = "koala.gif" validate_file = "koala_distorted.png" @@ -15,4 +17,26 @@ defmodule Image.Distortion.Test do # {:ok, _image} = Image.write(distorted, validate_path) assert_images_equal(distorted, validate_path) end + + test "Image.distort/4 fills coordinates outside the source with background" do + image = rgba_image() + + assert {:ok, background} = + Image.distort(image, [{0, 0}], [{50, 0}], background: @background) + + assert Image.get_pixel!(background, 0, 50) == [10, 20, 30, 40] + end + + test "Image.distort/4 can copy the interpolation fringe" do + image = rgba_image() + + assert {:ok, copied} = + Image.distort(image, [{0, 0}], [{1, 0}], extend_mode: :copy) + + assert Image.get_pixel!(copied, 0, 50) == [255, 0, 0, 255] + end + + defp rgba_image do + Image.new!(100, 100, color: [255, 0, 0, 255]) + end end diff --git a/test/image_test.exs b/test/image_test.exs index af8cb146..0da00101 100644 --- a/test/image_test.exs +++ b/test/image_test.exs @@ -4,6 +4,8 @@ defmodule Image.Test do alias Vix.Vips.Operation alias Vix.Vips.Image, as: Vimage + @mapim_background {[10, 20, 30], alpha: 40} + doctest Image doctest Image.BandFormat doctest Image.Blurhash @@ -296,6 +298,13 @@ defmodule Image.Test do assert_images_equal(out_path, validate_path("polar.jpg")) end + test "Convert to polar coordinates with a background" do + image = rgba_image() + + assert {:ok, polar} = Image.to_polar_coordinates(image, background: @mapim_background) + assert Image.get_pixel!(polar, 0, 0) == [10, 20, 30, 40] + end + test "Convert to rectangular coordinates", %{dir: dir} do image = validate_path("polar.jpg") {:ok, image} = Vimage.new_from_file(image) @@ -307,6 +316,27 @@ defmodule Image.Test do assert_images_equal(out_path, validate_path("rectangular.jpg")) end + test "Convert to rectangular coordinates by copying the interpolation fringe" do + image = rgba_image() + + assert {:ok, rectangular} = + Image.to_rectangular_coordinates(image, interpolate: :bicubic) + + assert minimum_alpha(rectangular) == 255 + end + + test "map/3 keeps the alpha band and reproduces a transparent background exactly" do + image = rgba_image() + source = [{0, 0}, {99, 0}, {99, 99}, {0, 99}] + destination = [{30, 30}, {70, 30}, {70, 70}, {30, 70}] + {:ok, matrix} = Image.transform_matrix(image, source, destination) + + {:ok, mapped} = Image.map(image, matrix, background: [10, 20, 30, 40]) + + assert Image.shape(mapped) == {100, 100, 4} + assert Image.get_pixel!(mapped, 1, 1) == [10, 20, 30, 40] + end + test "Ripple Effect", %{dir: dir} do image = image_path("San-Francisco-2018-04-2549.jpg") {:ok, image} = Vimage.new_from_file(image) @@ -318,6 +348,15 @@ defmodule Image.Test do assert_images_equal(out_path, validate_path("ripple.jpg")) end + test "Ripple effect with a background" do + image = rgba_image() + + assert {:ok, rippled} = + Image.ripple(image, background: @mapim_background, interpolate: :nearest) + + assert Image.get_pixel!(rippled, 0, 50) == [10, 20, 30, 40] + end + test "Autorotate an image", %{dir: dir} do image = image_path("Kip_small_rotated.jpg") {:ok, image} = Vimage.new_from_file(image) @@ -438,4 +477,15 @@ defmodule Image.Test do end end end + + defp rgba_image do + Image.new!(100, 100, color: [255, 0, 0, 255]) + end + + defp minimum_alpha(image) do + image + |> Operation.extract_band!(Image.alpha_band(image)) + |> Image.Math.min!() + |> trunc() + end end diff --git a/test/perspective_test.exs b/test/perspective_test.exs index ed52911c..fdc3d135 100644 --- a/test/perspective_test.exs +++ b/test/perspective_test.exs @@ -97,6 +97,49 @@ defmodule Image.Perspective.Test do assert_images_equal(result, validate_path) end + describe "alpha pass-through" do + # expose canvas at the corners, so (1, 1) is pure background fill + @inset_source [{0, 0}, {99, 0}, {99, 99}, {0, 99}] + @inset_destination [{30, 30}, {70, 30}, {70, 70}, {30, 70}] + + test "warp_perspective/4 keeps the alpha band" do + image = Image.new!(100, 100, color: [255, 0, 0, 255]) + + {:ok, warped} = Image.warp_perspective(image, @inset_source, @inset_destination) + + assert Image.shape(warped) == {100, 100, 4} + assert Image.get_pixel!(warped, 50, 50) == [255, 0, 0, 255] + end + + test "warp_perspective/4 fills exposed canvas with transparent black by default" do + image = Image.new!(100, 100, color: [255, 0, 0, 255]) + + {:ok, warped} = Image.warp_perspective(image, @inset_source, @inset_destination) + + assert Image.get_pixel!(warped, 1, 1) == [0, 0, 0, 0] + end + + test "warp_perspective/4 reproduces a partially transparent background exactly" do + image = Image.new!(100, 100, color: [255, 0, 0, 255]) + + {:ok, warped} = + Image.warp_perspective(image, @inset_source, @inset_destination, + background: [10, 20, 30, 40] + ) + + assert Image.get_pixel!(warped, 1, 1) == [10, 20, 30, 40] + end + + test "warp_perspective/4 still zero-fills an image without an alpha band" do + image = Image.new!(100, 100, color: :red) + + {:ok, warped} = Image.warp_perspective(image, @inset_source, @inset_destination) + + assert Image.shape(warped) == {100, 100, 3} + assert Image.get_pixel!(warped, 1, 1) == [0, 0, 0] + end + end + describe "straighten_perspective/3 source validation" do # Regression: a malformed source fell through a `with` that had no else, # returning the source list itself instead of an error (or an image from diff --git a/test/support/validate/warp/warped_image_with_alpha2.png b/test/support/validate/warp/warped_image_with_alpha2.png index 49e0251d..959a4120 100644 Binary files a/test/support/validate/warp/warped_image_with_alpha2.png and b/test/support/validate/warp/warped_image_with_alpha2.png differ