Skip to content

Accept only keyword lists in Image.Options.* validators - #220

Merged
hlindset merged 1 commit into
elixir-image:mainfrom
hlindset:fix/options-map-contract
Jul 29, 2026
Merged

Accept only keyword lists in Image.Options.* validators#220
hlindset merged 1 commit into
elixir-image:mainfrom
hlindset:fix/options-map-contract

Conversation

@hlindset

Copy link
Copy Markdown
Collaborator

Ten Image.Options.* modules had a validate_options function that took an already-validated map and returned it unchanged. The intent was to allow a validated map to be constructed once, and then reused without paying for the validation cost on each call. Benchmarking showed that the performance gain was marginal (see script/results below), so removing the map-taking functions removes potential footguns from public entry points:

Image.Draw.rect(img, 0, 0, 5, 5, %{color: :green})
# ** (MatchError) — defaults never merged

Image.chroma_mask(img, %{color: :green, threshold: 20})
# ** (Protocol.UndefinedError) — the color is never converted to a pixel

Image.embed(img, 40, 40, %{x: 10, y: 10, extend_mode: :VIPS_EXTEND_BACKGROUND, backgroud: [255, 0, 0]})
# {:ok, image} — :background is optional, so the misspelled key leaves a map that still matches.
  • Removed the map clause from Blur, ChromaKey, Draw, Embed, Meme, Modulate, Sharpen, Trim, Vibrance and Vignette
  • Added missing is_list guards to Affine and Rotate, which accepted maps via an Enum.reject/2 that converted them to keyword lists
  • Added missing is_list guards to Resize and Thumbnail, which accepted %{}, and raised on any populated map (because reducing over an empty map returns it unchanged)
  • Removed map() from 22 option union types. Seven of those already rejected maps at runtime, so their types were inaccurate
  • Removed Image.Draw.maybe_add_alpha/2: every caller passes a color from Pixel.to_pixel/3, which always returns exactly bands(image) elements
  • Removed List.wrap/1 (it's always a resolved color list now) in the chroma mask path
  • Removed Image.Draw.maybe_wrap/1, which was just an identity function
  • Added tests for the band fitting Pixel.to_pixel/3 took over from maybe_add_alpha/2

Breaking changes

  • Image.Options.*.validate_options accepts keyword lists only
    Passing a map now raises FunctionClauseError. Affects Blur, ChromaKey, Draw, Embed, Meme, Modulate, Sharpen, Trim, Vibrance, Vignette, Affine, Rotate, Resize and Thumbnail
  • Public functions that accepted a map and now raise FunctionClauseError
    Image.blur/2, Image.feather/2, Image.sharpen/2, Image.modulate/2, Image.vignette/2, Image.embed/4, Image.chroma_mask/2, Image.chroma_key/2, Image.Draw.point/4, rect/6, circle/5, line/6, flood/4, mask/5, image/5, smudge/6
  • Public functions that accepted a map by accident (through Enum.reject/2) and now raise FunctionClauseError
    Image.rotate/3, Image.affine/3, Image.shear/4
  • Public functions that only accepted an empty map and now always raise FunctionClauseError
    Image.resize/3, Image.thumbnail/3
  • The ! variants of all of the above raise the same error
  • map() removed from 22 option union type declarations
    Specs referencing e.g. Image.Options.Blur.blur_options/0 no longer permit a map. Seven of the 22 were already inaccurate, rejecting maps at runtime
  • Image.Options.Meme.validate_options/1 is removed
  • Image.Draw.maybe_add_alpha/2 is removed
    @doc false, so it was public but not documented

Benchmark script

Mix.install([
  {:image, "== 0.72.0"},
  {:benchee, "~> 1.0"}
])

image = Image.new!(100, 100, color: :white)
cheap_kw = [color: [255, 0, 204], fill: true]
costly_kw = [color: "color-mix(in oklch, red, blue)", fill: true]
{:ok, prevalidated_map} = Image.Options.Draw.validate_options(image, :rect, cheap_kw)

Benchee.run(%{
  "cheap_kw" => fn -> Image.Draw.rect(image, 10, 10, 50, 50, cheap_kw) end,
  "costly_kw" => fn -> Image.Draw.rect(image, 10, 10, 50, 50, costly_kw) end,
  "prevalidated_map" => fn -> Image.Draw.rect(image, 10, 10, 50, 50, prevalidated_map) end
}, time: 10, warmup: 1, exclude_outliers: true, print: [fast_warning: true])

Benchmark result

Operating System: macOS
CPU Information: Apple M2 Pro
Number of Available Cores: 12
Available memory: 16 GB
Elixir 1.20.2
Erlang 29.0.2
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 1 s
time: 10 s
memory time: 0 ns
reduction time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 33 s
Excluding outliers: true

Benchmarking cheap_kw ...
Benchmarking costly_kw ...
Benchmarking prevalidated_map ...
Calculating statistics...
Formatting results...

Name                       ips        average  deviation         median         99th %
prevalidated_map        3.48 K      287.50 μs    ±11.57%      285.71 μs      371.54 μs
cheap_kw                3.44 K      290.87 μs    ±11.83%         289 μs      379.08 μs
costly_kw               2.99 K      334.79 μs    ±12.29%      332.46 μs      435.88 μs

Comparison:
prevalidated_map        3.48 K
cheap_kw                3.44 K - 1.01x slower +3.37 μs
costly_kw               2.99 K - 1.16x slower +47.29 μs

Resolving the color is the biggest cost, so in a tight loop the win comes from hoisting the color resolution and using it directly in the keyword list, not from full prevalidation.

@hlindset
hlindset force-pushed the fix/options-map-contract branch from 0984d21 to 212618f Compare July 29, 2026 07:28
@hlindset
hlindset force-pushed the fix/options-map-contract branch from 212618f to ec113ce Compare July 29, 2026 07:28
@hlindset
hlindset merged commit 84875f7 into elixir-image:main Jul 29, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant