From 6d87ae492866095673893495e3c4df668fb8e1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Fri, 3 Jul 2026 09:38:29 +0200 Subject: [PATCH] add Image.BackgroundColor and use it in rotate/affine/warp --- lib/image/background_color.ex | 74 ++++++++++++++++ lib/image/options/affine.ex | 24 +---- lib/image/options/rotate.ex | 23 +---- lib/image/options/warp_perspective.ex | 22 +---- test/affine_test.exs | 8 ++ test/background_color_test.exs | 123 ++++++++++++++++++++++++++ test/rotate_test.exs | 8 ++ 7 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 lib/image/background_color.ex create mode 100644 test/background_color_test.exs diff --git a/lib/image/background_color.ex b/lib/image/background_color.ex new file mode 100644 index 0000000..8ff7041 --- /dev/null +++ b/lib/image/background_color.ex @@ -0,0 +1,74 @@ +defmodule Image.BackgroundColor do + @moduledoc """ + Resolves a `Image.Pixel.t()` / `:average` value into a concrete pixel in the + image's colorspace. + + A background color specification is either the atom `:average` (the average + color of the image), or any color accepted by + `Image.Pixel.to_pixel/2` (a `Color` struct, a hex string, a CSS named color, + an atom or a list of numbers). + + In both cases the resolved pixel matches `image`'s number of bands. + """ + + alias Image.Pixel + alias Vix.Vips.Image, as: Vimage + + @typedoc "A background color specification: the image's average color, or any color." + @type spec :: Pixel.t() | :average + + @doc """ + Resolves a background color `spec` into a pixel matching `image`'s + interpretation and band layout. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. + + * `spec` is `:average` (the image's average color) or any color + accepted by `Image.Pixel.to_pixel/2`. + + ### Returns + + * `{:ok, [number()]}` - the resolved pixel, whose band count matches + `image` (an opaque alpha band is appended for `:average` when the + image has alpha), or + + * `{:error, t:Image.Error.t/0}` + """ + @spec resolve(Vimage.t(), spec()) :: {:ok, [number()]} | {:error, Image.Error.t()} + def resolve(%Vimage{} = image, :average) do + case Image.average(image) do + color when is_list(color) -> + if Image.has_alpha?(image) do + # Append an opaque alpha band in the interpretation's own scale via `to_pixel` + case Pixel.to_pixel(image, :black, alpha: :opaque) do + {:ok, opaque_pixel} -> + {:ok, color ++ [List.last(opaque_pixel)]} + + {:error, reason} -> + {:error, error("Could not construct alpha #{inspect(color)}", reason)} + end + else + {:ok, color} + end + + {:error, reason} -> + {:error, error("Could not compute the image average", reason)} + end + end + + def resolve(%Vimage{} = image, color) do + case Pixel.to_pixel(image, color) do + {:ok, pixel} -> + {:ok, pixel} + + {:error, reason} -> + {:error, error("Invalid background color #{inspect(color)}", reason)} + end + end + + defp error(message, reason) do + %Image.Error{message: "#{message}: #{inspect(reason)}", reason: reason} + end +end diff --git a/lib/image/options/affine.ex b/lib/image/options/affine.ex index c62e0ea..ecfa392 100644 --- a/lib/image/options/affine.ex +++ b/lib/image/options/affine.ex @@ -105,28 +105,10 @@ defmodule Image.Options.Affine do 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 + defp validate_option({:background, background}, image, options) do + case Image.BackgroundColor.resolve(image, background) do {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} - _other -> {:halt, {:error, invalid_option(option)}} + {:error, reason} -> {:halt, {:error, reason}} end end diff --git a/lib/image/options/rotate.ex b/lib/image/options/rotate.ex index f863134..20e6b82 100644 --- a/lib/image/options/rotate.ex +++ b/lib/image/options/rotate.ex @@ -81,27 +81,10 @@ defmodule Image.Options.Rotate do end end - # `: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 + defp validate_option({:background, background}, image, options) do + case Image.BackgroundColor.resolve(image, background) do {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} - _other -> {:halt, {:error, invalid_option(option)}} + {:error, reason} -> {:halt, {:error, reason}} end end diff --git a/lib/image/options/warp_perspective.ex b/lib/image/options/warp_perspective.ex index 892f44f..b1291d8 100644 --- a/lib/image/options/warp_perspective.ex +++ b/lib/image/options/warp_perspective.ex @@ -53,26 +53,10 @@ defmodule Image.Options.WarpPerspective do end end - defp validate_option({:background, :average}, image, options) do - case Image.average(image) do - color when is_list(color) -> - options = Keyword.put(options, :background, color) - {:cont, options} - - {: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 + defp validate_option({:background, background}, image, options) do + case Image.BackgroundColor.resolve(image, background) do {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} - _other -> {:halt, {:error, invalid_option(option)}} + {:error, reason} -> {:halt, {:error, reason}} end end diff --git a/test/affine_test.exs b/test/affine_test.exs index 20d8f7f..a4e7574 100644 --- a/test/affine_test.exs +++ b/test/affine_test.exs @@ -170,6 +170,14 @@ defmodule Image.Affine.Test do assert Image.get_pixel!(result, 0, 0) == [10, 20, 30] end + test "resolves :average against an image with an alpha band" do + image = Image.new!(20, 20, color: [10, 20, 30, 255]) + + assert {:ok, result} = Image.translate(image, 5, 0, background: :average) + assert Image.bands(result) == 4 + assert Image.get_pixel!(result, 0, 0) == [10, 20, 30, 255] + 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) diff --git a/test/background_color_test.exs b/test/background_color_test.exs new file mode 100644 index 0000000..1bd48c1 --- /dev/null +++ b/test/background_color_test.exs @@ -0,0 +1,123 @@ +defmodule Image.BackgroundColorTest do + use ExUnit.Case, async: true + + alias Image.BackgroundColor + + defp solid(color), do: Image.new!(4, 4, color: color) + + defp solid_in(color, colorspace), + do: Image.to_colorspace!(solid(color), colorspace) + + describe "resolve/2 with :average" do + test "resolves to the image's average color" do + image = solid([10, 20, 30]) + assert BackgroundColor.resolve(image, :average) == {:ok, [10, 20, 30]} + end + + test "matches a non-alpha image's band count" do + image = solid([10, 20, 30]) + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + assert length(pixel) == Image.bands(image) + end + + test "appends an opaque alpha band for an image with alpha" do + image = solid([10, 20, 30, 255]) + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + + # The average is computed on the flattened image, so the alpha band is + # re-appended as fully opaque to match the image's band layout exactly. + assert pixel == [10, 20, 30, 255] + assert length(pixel) == Image.bands(image) + assert List.last(pixel) == 255 + end + end + + describe "resolve/2 with :average in a non-sRGB interpretation" do + test "preserves LAB color bands verbatim (no sRGB re-interpretation)" do + image = solid_in([120, 80, 40], :lab) + raw = Image.average!(image) + + assert BackgroundColor.resolve(image, :average) == {:ok, raw} + end + + test "LAB with alpha keeps the raw average and appends a float opaque alpha" do + image = solid_in([120, 80, 40, 255], :lab) + raw = Image.average!(image) + + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + assert pixel == raw ++ [1.0] + assert length(pixel) == Image.bands(image) + end + + test "CMYK with alpha keeps the raw average and appends a uchar opaque alpha" do + image = solid_in([120, 80, 40, 255], :cmyk) + raw = Image.average!(image) + + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + assert pixel == raw ++ [255] + assert length(pixel) == Image.bands(image) + end + + test "16-bit RGB with alpha appends a ushort opaque alpha" do + image = solid_in([120, 80, 40, 255], :rgb16) + raw = Image.average!(image) + + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + assert pixel == raw ++ [65_535] + assert length(pixel) == Image.bands(image) + end + + test "signed-short LAB (labs) with alpha uses libvips' 65_535 opaque alpha" do + # `:labs` bands are signed shorts ({:s, 16}), but libvips stores the + # alpha band in the unsigned 0..65_535 range. The opaque alpha must + # therefore follow the interpretation (65_535), not the band format's + # signed maximum (32_767), matching what `Image.Pixel.to_pixel/3` does. + image = solid_in([120, 80, 40, 255], :labs) + raw = Image.average!(image) + + assert {:ok, pixel} = BackgroundColor.resolve(image, :average) + assert pixel == raw ++ [65_535] + assert length(pixel) == Image.bands(image) + end + end + + describe "resolve/2 with a color spec" do + test "resolves a named color into the image's band layout" do + image = solid([0, 0, 0]) + assert BackgroundColor.resolve(image, :red) == {:ok, [255, 0, 0]} + end + + test "appends an opaque alpha band when the image has alpha" do + image = solid([0, 0, 0, 255]) + assert {:ok, pixel} = BackgroundColor.resolve(image, :red) + + assert pixel == [255, 0, 0, 255] + assert length(pixel) == Image.bands(image) + end + + test "forwards other spec forms (hex string) to Image.Pixel" do + image = solid([0, 0, 0]) + assert BackgroundColor.resolve(image, "#00ff00") == {:ok, [0, 255, 0]} + end + end + + describe "resolve/2 error handling" do + test "wraps an invalid color in an Image.Error" do + image = solid([0, 0, 0]) + + assert {:error, %Image.Error{} = error} = + BackgroundColor.resolve(image, :definitely_not_a_color) + + assert error.message =~ "Invalid background color :definitely_not_a_color" + end + + test "preserves the underlying reason from Image.Pixel" do + image = solid([0, 0, 0]) + + assert {:error, %Image.Error{reason: reason}} = + BackgroundColor.resolve(image, "not-a-hex") + + assert reason == %Color.UnknownColorNameError{name: "not-a-hex"} + end + end +end diff --git a/test/rotate_test.exs b/test/rotate_test.exs index 9e6fd91..30b88f7 100644 --- a/test/rotate_test.exs +++ b/test/rotate_test.exs @@ -50,6 +50,14 @@ defmodule Image.Rotate.Test do assert {:ok, %Vimage{}} = Image.rotate(image, 45, background: :average) end + test "resolves :average against an image with an alpha band" do + image = Image.new!(20, 20, color: [10, 20, 30, 255]) + + assert {:ok, result} = Image.rotate(image, 45, background: :average) + assert Image.bands(result) == 4 + assert Image.get_pixel!(result, 0, 0) == [10, 20, 30, 255] + 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)