From 172e9a19031d90750e737fca50cd81b67080e266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Mon, 3 Aug 2026 23:06:41 +0200 Subject: [PATCH 1/3] Correct the inverted chroma key color range documentation --- CHANGELOG.md | 2 ++ lib/image.ex | 70 ++++++++++++++++++++++++++++------------ test/chroma_key_test.exs | 17 ++++++++++ 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 892bf9e1..2bf9136f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ * Fix the specs for `Image.Math.cos/1` and `Image.Math.sin/1`, which omitted `{:error, Image.error()}`, and for eight math operators, which omitted the `Vimage.t()` they already accepted. All `@dialyzer` opt-outs and the `.dialyzer_ignore_warnings` file are removed. ([#221](https://github.com/elixir-image/image/pull/221)) +* Fix the `:greater_than` and `:less_than` documentation for `Image.chroma_mask/2`, `Image.chroma_key/2` and `Image.replace_color/2`, which described the bounds the wrong way round in all six places they appeared. The mask covers the range between the two, so `:greater_than` is the lower bound and `:less_than` the upper. ([#224](https://github.com/elixir-image/image/pull/224)) + ### Removed * **Breaking:** Removes `Image.Options.WarpPerspective`, replaced by `Image.Options.Mapim`. ([#216](https://github.com/elixir-image/image/pull/216)) diff --git a/lib/image.ex b/lib/image.ex index f2c4e0e7..2f3ba010 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -2332,7 +2332,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2346,13 +2348,16 @@ defmodule Image do #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2437,7 +2442,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2451,13 +2458,16 @@ defmodule Image do #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2508,7 +2518,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2522,13 +2534,16 @@ defmodule Image do #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2583,7 +2598,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -2597,13 +2614,16 @@ defmodule Image do #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be masked. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -5865,7 +5885,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -5873,19 +5895,22 @@ defmodule Image do `:auto` in which the average of the top left `10x10` pixels of the image is used. - * `:threshold`is a positive integer to indicate the + * `:threshold` is a positive integer to indicate the threshold around `:color` when calculating the mask. The default is `20`. #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -5955,7 +5980,9 @@ defmodule Image do #### Threshold strategy - * `:color` is an RGB color which represents the the + Masks colors within a threshold of a single color. + + * `:color` is an RGB color which represents the chroma key to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom @@ -5963,19 +5990,22 @@ defmodule Image do `:auto` in which the average of the top left `10x10` pixels of the image is used. - * `:threshold`is a positive integer to indicate the + * `:threshold` is a positive integer to indicate the threshold around `:color` when calculating the mask. The default is `20`. #### Color range strategy - * `:greater_than` is an RGB color which represents the upper + Masks colors falling between two bounds. Both options + are required. + + * `:greater_than` is an RGB color which represents the lower end of the color range to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom representing a CSS color name. - * `:less_than` is an RGB color which represents the lower + * `:less_than` is an RGB color which represents the upper end of the color range to be selected. The color can be an integer between `0..255`, a three-element list of integers representing an RGB color or an atom diff --git a/test/chroma_key_test.exs b/test/chroma_key_test.exs index b5a4b71c..b745fe26 100644 --- a/test/chroma_key_test.exs +++ b/test/chroma_key_test.exs @@ -22,4 +22,21 @@ defmodule Image.ChromaKey.Test do # Image.write!(meme, validate_path) assert_images_equal out_path, validate_path end + + describe "color range bounds" do + test ":greater_than is the lower bound and :less_than the upper bound" do + dark = Image.new!(1, 1, color: [0, 0, 0]) + mid = Image.new!(1, 1, color: [100, 100, 100]) + light = Image.new!(1, 1, color: [200, 200, 200]) + + {:ok, image} = Image.join([dark, mid, light], across: 3) + + {:ok, mask} = + Image.chroma_mask(image, greater_than: [50, 50, 50], less_than: [150, 150, 150]) + + # Only the pixel inside the range is masked out. The mask keeps (255) + # everything outside it. + assert Enum.map(0..2, &Image.get_pixel!(mask, &1, 0)) == [[255], [0], [255]] + end + end end From c4ebb64d896dea0ca2c0605241f401be0e580e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Mon, 3 Aug 2026 23:06:41 +0200 Subject: [PATCH 2/3] Make conflicting chroma key strategies an error --- CHANGELOG.md | 2 + lib/image.ex | 38 +++++++++++++---- lib/image/options/chroma_key.ex | 73 ++++++++++++++++++++------------ test/chroma_key_test.exs | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf9136f..5bb41f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ * **Breaking:** `Image.drop_shadow/2` returns `{:error, %Image.Error{}}` for a non-numeric `:sigma` instead of raising `ArithmeticError`, and its `:opacity` and `:sigma` errors now carry `reason: :invalid_option` with `value` set to `{:opacity, value}` or `{:sigma, value}`. Validation still covers only `:opacity` and `:sigma`. `:dx`, `:dy` and unknown options remain unvalidated pending a move to an `Image.Options.DropShadow` module. ([#221](https://github.com/elixir-image/image/pull/221)) +* **Breaking:** `Image.chroma_mask/2`, `Image.chroma_key/2`, `Image.replace_color/2` and their `!` variants now return `{:error, %Image.Error{reason: :invalid_option}}` when options from both masking strategies are supplied, or when only one of `:greater_than` and `:less_than` is supplied. The two strategies were always documented as mutually exclusive, but nothing enforced it: the color range strategy won and `:color` and `:threshold` were silently discarded, and a lone bound was discarded and replaced by auto chroma detection with the default threshold. ([#224](https://github.com/elixir-image/image/pull/224)) + * `Image.affine/3` and `Image.rotate/3` now premultiply alpha explicitly only when the background is non-opaque, since libvips handles the other cases itself. `Image.shear/4` and `Image.translate/4` inherit this. ([#217](https://github.com/elixir-image/image/pull/217)) ### Fixed diff --git a/lib/image.ex b/lib/image.ex index 2f3ba010..609c94cb 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -2328,7 +2328,10 @@ defmodule Image do There are two masking strategies available: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy @@ -2387,7 +2390,7 @@ defmodule Image do end end - defp do_chroma_mask(%Vimage{} = image, %{color: color, threshold: threshold}) do + defp do_chroma_mask(%Vimage{} = image, %{strategy: :threshold, color: color, threshold: threshold}) do alias Image.Math # The mask is computed from the color bands only so any alpha @@ -2406,7 +2409,11 @@ defmodule Image do end end - defp do_chroma_mask(%Vimage{} = image, %{greater_than: greater_than, less_than: less_than}) do + defp do_chroma_mask(%Vimage{} = image, %{ + strategy: :range, + greater_than: greater_than, + less_than: less_than + }) do alias Image.Math with {:ok, greater} <- Math.greater_than(image, greater_than), @@ -2438,7 +2445,10 @@ defmodule Image do There are two masking strategies available: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy @@ -2514,7 +2524,10 @@ defmodule Image do There are two masking strategies available: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy @@ -2594,7 +2607,10 @@ defmodule Image do There are two masking strategies available: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy @@ -5881,7 +5897,10 @@ defmodule Image do There are two strategies available for selecting the color or color range to be replaced: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy @@ -5976,7 +5995,10 @@ defmodule Image do There are two strategies available for selecting the color or color range to be replaced: the thresholding strategy (default) and the color - range strategy. + range strategy. They are mutually exclusive: + combining options from both strategies, or + supplying only one of `:greater_than` and + `:less_than`, returns an error. #### Threshold strategy diff --git a/lib/image/options/chroma_key.ex b/lib/image/options/chroma_key.ex index d31138fc..19dcb712 100644 --- a/lib/image/options/chroma_key.ex +++ b/lib/image/options/chroma_key.ex @@ -5,6 +5,9 @@ defmodule Image.Options.ChromaKey do """ alias Image.Pixel + @threshold_keys [:color, :threshold] + @range_keys [:greater_than, :less_than] + @typedoc """ Options applicable to Image.chroma_key/2 @@ -24,14 +27,16 @@ defmodule Image.Options.ChromaKey do """ def validate_options(image, options) when is_list(options) do - options = Keyword.merge(default_options(), options) + with {:ok, strategy} <- select_strategy(Keyword.keys(options)) do + options = Keyword.merge(default_options(strategy), options) - case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do - {:error, value} -> - {:error, value} + case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do + {:error, value} -> + {:error, value} - options -> - select_strategy(Map.new(options)) + options -> + {:ok, options |> Map.new() |> Map.put(:strategy, strategy)} + end end end @@ -73,33 +78,49 @@ defmodule Image.Options.ChromaKey do } end - defp select_strategy(%{greater_than: _, less_than: _} = options) do - options = - options - |> Map.delete(:color) - |> Map.delete(:threshold) + # The strategy depends only on which keys the caller explicitly sets, so it is + # resolved before the option values are validated and before any defaults are + # merged. + defp select_strategy(explicit_keys) do + threshold = Enum.filter(@threshold_keys, &(&1 in explicit_keys)) + range = Enum.filter(@range_keys, &(&1 in explicit_keys)) - {:ok, options} - end + cond do + threshold != [] and range != [] -> + {:error, conflicting_strategies_error(threshold, range)} + + range == [] -> + {:ok, :threshold} - defp select_strategy(%{color: _, threshold: _} = options) do - options = - options - |> Map.delete(:greater_than) - |> Map.delete(:less_than) + range == @range_keys -> + {:ok, :range} - {:ok, options} + true -> + {:error, incomplete_range_error(range)} + end end - defp select_strategy(options) do - { - :error, - "Invalid options #{inspect(options)}. Options need to have either :greater_than " <> - " and :less_than or :color and :threshold." + defp conflicting_strategies_error(threshold, range) do + %Image.Error{ + reason: :invalid_option, + value: threshold ++ range, + message: + "The threshold strategy options #{inspect(threshold)} cannot be combined with " <> + "the color range options #{inspect(range)}. The two masking strategies are " <> + "mutually exclusive, pass the options for one or the other." } end - defp default_options do - [color: :auto, threshold: 20] + defp incomplete_range_error(supplied) do + %Image.Error{ + reason: :invalid_option, + value: supplied, + message: + "The color range strategy requires both :greater_than and :less_than. " <> + "Only #{Enum.map_join(supplied, ", ", &inspect/1)} was supplied." + } end + + defp default_options(:threshold), do: [color: :auto, threshold: 20] + defp default_options(:range), do: [] end diff --git a/test/chroma_key_test.exs b/test/chroma_key_test.exs index b745fe26..769bda57 100644 --- a/test/chroma_key_test.exs +++ b/test/chroma_key_test.exs @@ -23,6 +23,81 @@ defmodule Image.ChromaKey.Test do assert_images_equal out_path, validate_path end + describe "strategy selection" do + setup do + {:ok, %{image: Image.new!(10, 10, color: [0, 255, 0])}} + end + + test "no strategy options selects the threshold strategy", %{image: image} do + assert {:ok, %{strategy: :threshold, color: :auto, threshold: 20}} = + Image.Options.ChromaKey.validate_options(image, []) + end + + test ":color and :threshold select the threshold strategy", %{image: image} do + assert {:ok, %{strategy: :threshold, color: [0, 255, 0], threshold: 30}} = + Image.Options.ChromaKey.validate_options(image, color: [0, 255, 0], threshold: 30) + end + + test ":greater_than and :less_than select the color range strategy", %{image: image} do + assert {:ok, %{strategy: :range, greater_than: [0, 200, 0], less_than: [50, 255, 50]}} = + Image.Options.ChromaKey.validate_options(image, + greater_than: [0, 200, 0], + less_than: [50, 255, 50] + ) + end + + test "options from both strategies are an error", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option} = error} = + Image.chroma_mask(image, + color: [0, 255, 0], + threshold: 30, + greater_than: [0, 200, 0], + less_than: [50, 255, 50] + ) + + assert error.value == [:color, :threshold, :greater_than, :less_than] + end + + test ":threshold combined with a complete color range is an error", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option} = error} = + Image.chroma_mask(image, + threshold: 30, + greater_than: [0, 200, 0], + less_than: [50, 255, 50] + ) + + assert error.value == [:threshold, :greater_than, :less_than] + end + + test ":greater_than without :less_than is an error", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option} = error} = + Image.chroma_mask(image, greater_than: [0, 200, 0]) + + assert error.value == [:greater_than] + end + + test ":less_than without :greater_than is an error", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option} = error} = + Image.chroma_mask(image, less_than: [50, 255, 50]) + + assert error.value == [:less_than] + end + + test "chroma_key/2 reports the strategy conflict", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option}} = + Image.chroma_key(image, color: [0, 255, 0], greater_than: [0, 200, 0]) + end + + test "chroma_mask!/2 raises on an incomplete color range", %{image: image} do + assert_raise Image.Error, fn -> Image.chroma_mask!(image, less_than: [50, 255, 50]) end + end + + test "the strategy is resolved before the option values are validated", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option, value: [:greater_than]}} = + Image.chroma_mask(image, greater_than: :not_a_color) + end + end + describe "color range bounds" do test ":greater_than is the lower bound and :less_than the upper bound" do dark = Image.new!(1, 1, color: [0, 0, 0]) From 8799326585f42a9d6be9d9009608b6027d1a643f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Mon, 3 Aug 2026 23:06:41 +0200 Subject: [PATCH 3/3] Remove the unused sigma and min_amplitude chroma key options --- CHANGELOG.md | 2 ++ lib/image/options/chroma_key.ex | 11 ----------- test/chroma_key_test.exs | 11 +++++++++++ test/coverage_wave3_options_test.exs | 5 ----- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb41f25..336f107a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ * **Breaking:** Removes the `Image.Error` tuple constructors. `raise Image.Error, {:enoent, path}` and `raise Image.Error, {message, path}` now fall through to the catch-all clause. ([#218](https://github.com/elixir-image/image/pull/218)) +* **Breaking:** Removes the `:sigma` and `:min_amplitude` options from `Image.chroma_mask/2`, `Image.chroma_key/2` and `Image.replace_color/2`. They were accepted and validated, but never read by any code path. ([#224](https://github.com/elixir-image/image/pull/224)) + ## Image 0.72.0 This is the changelog for Image version 0.72.0 released on July 22nd, 2026. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-image/image/tags) diff --git a/lib/image/options/chroma_key.ex b/lib/image/options/chroma_key.ex index 19dcb712..8af3b78d 100644 --- a/lib/image/options/chroma_key.ex +++ b/lib/image/options/chroma_key.ex @@ -18,8 +18,6 @@ defmodule Image.Options.ChromaKey do | {:threshold, non_neg_integer()} | {:greater_than, Pixel.t()} | {:less_than, Pixel.t()} - | {:sigma, float()} - | {:min_amplitude, float()} ] @doc """ @@ -57,15 +55,6 @@ defmodule Image.Options.ChromaKey do {:cont, options} end - defp validate_option({:sigma, sigma}, _image, options) when is_number(sigma) and sigma > 0 do - {:cont, options} - end - - defp validate_option({:min_amplitude, min_amplitude}, _image, options) - when is_float(min_amplitude) do - {:cont, Keyword.put(options, :min_amplitude, min_amplitude)} - end - defp validate_option(option, _image, _options) do {:halt, {:error, invalid_option(option)}} end diff --git a/test/chroma_key_test.exs b/test/chroma_key_test.exs index 769bda57..59fade8d 100644 --- a/test/chroma_key_test.exs +++ b/test/chroma_key_test.exs @@ -98,6 +98,17 @@ defmodule Image.ChromaKey.Test do end end + describe "unknown options" do + setup do + {:ok, %{image: Image.new!(10, 10, color: [0, 255, 0])}} + end + + test "an unknown option is an error", %{image: image} do + assert {:error, %Image.Error{reason: :invalid_option, value: {:unknown, 1}}} = + Image.chroma_mask(image, unknown: 1) + end + end + describe "color range bounds" do test ":greater_than is the lower bound and :less_than the upper bound" do dark = Image.new!(1, 1, color: [0, 0, 0]) diff --git a/test/coverage_wave3_options_test.exs b/test/coverage_wave3_options_test.exs index 3d51e107..377e9344 100644 --- a/test/coverage_wave3_options_test.exs +++ b/test/coverage_wave3_options_test.exs @@ -280,11 +280,6 @@ defmodule Image.CoverageWave3.Options.Test do assert {:ok, options} = Image.Options.Trim.validate_options(canvas, background: :red) assert options.background == [255, 0, 0] end - - test "chroma_key :sigma and :min_amplitude options" do - green = Image.new!(20, 20, color: [0, 255, 0]) - assert {:ok, _} = Image.chroma_key(green, sigma: 2.0, min_amplitude: 0.05) - end end describe "Image.Options.New" do