defmodule Butteraugli do @moduledoc """ Butteraugli perceptual image-difference metric for Elixir, backed by the `butteraugli` Rust crate. Butteraugli is a *distance*: lower is better. A `score` below `1.0` means the images are perceptually identical, `1.0`–`2.0` is a subtle/borderline difference, and above `2.0` is a clearly visible difference. (This is the opposite orientation from quality metrics like SSIMULACRA2.) Inputs are packed binaries whose layout is chosen with the `:format` option (default `:rgb888`): | format | element | channels | bytes/pixel | color space | | --- | --- | --- | --- | --- | | `:rgb888` (default) | `u8` | 3 | 3 | sRGB (gamma) | | `:linear_rgb` | `f32` | 3 | 12 | linear RGB | Multi-byte elements (`f32`) are **native-endian** (`<>`). A binary's size must equal `width * height * channels * bytes_per_element`. Comparisons return a `Butteraugli.Result`. ## Cancellation `compare/5` and `Butteraugli.Reference.compare/3` accept `cancel:` (a `Butteraugli.CancelRef`) and `timeout:` (milliseconds); aborted calls return `{:error, :cancelled}` or `{:error, :timeout}`. Create a ref with `Butteraugli.CancelRef.new/0` and trip it with `cancel/1`. The *granularity* differs by path, because the binding polls the ref at different points: * **`compare/5` on images ≥ 8×8** (either format) checks the ref between strips, so it aborts **mid-computation** — a ref cancelled, or a timeout firing, partway through a long compare stops it promptly. * **Sub-8×8 images, and `Butteraugli.Reference.compare/3` with the default `prefer: :speed`** check the ref **once, at the start** of the computation. (Sub-8×8 inputs are padded onto the non-strip path; `prefer: :speed` uses the precomputed reference for the ~2× speedup, which has no strip-wise stop.) These honor a ref that is *already* cancelled when the call begins — including batch cancellation, where cancelling one ref aborts every *subsequent* compare that uses it — but a cancel/timeout arriving *after* the computation is underway will not interrupt it; that call runs to completion. * **`Butteraugli.Reference.compare/3` with `prefer: :memory`** opts into the strip-bounded walker: bounded peak memory and per-strip **mid-computation** cancellation, trading away the precompute speedup. If you must bound the wall-clock of an individual long compare, use `compare/5` on a ≥ 8×8 image, or `Butteraugli.Reference.compare/3` with `prefer: :memory`. """ alias Butteraugli.{Cancellation, CancelRef, Native, Result, Validate} @type image_data :: binary() @type reason :: :invalid_dimensions | :size_mismatch | :dimension_mismatch | :unknown_format | :invalid_cancel | :invalid_timeout | :invalid_prefer | :cancelled | :timeout | {:butteraugli, String.t()} @doc """ Compare a reference and distorted image of the same dimensions. Options: * `:format` — `:rgb888` (default) or `:linear_rgb`. * `:compute_diffmap` — when `true`, the `Result` includes a per-pixel `diffmap` binary (default `false`). * `:intensity_target` — display brightness in nits (crate default if omitted). * `:hf_asymmetry` — high-frequency penalty asymmetry (crate default if omitted). * `:cancel` — a `Butteraugli.CancelRef`; cancelling it from another process aborts the call with `{:error, :cancelled}`. * `:timeout` — positive integer milliseconds; the call returns `{:error, :timeout}` if it exceeds that. Returns `{:ok, %Butteraugli.Result{}}` or `{:error, reason}`. """ @spec compare(image_data(), image_data(), pos_integer(), pos_integer(), keyword()) :: {:ok, Result.t()} | {:error, reason()} def compare(reference, distorted, width, height, opts \\ []) when is_binary(reference) and is_binary(distorted) do format = Keyword.get(opts, :format, :rgb888) cancel = Keyword.get(opts, :cancel) timeout = Keyword.get(opts, :timeout) with :ok <- Validate.format(format), :ok <- Validate.dims(width, height), :ok <- Validate.size(reference, width, height, format), :ok <- Validate.size(distorted, width, height, format), :ok <- Validate.cancel(cancel), :ok <- Validate.timeout(timeout) do Cancellation.run(cancel, timeout, fn resource -> Native.compare( reference, distorted, width, height, format, Keyword.get(opts, :intensity_target), Keyword.get(opts, :hf_asymmetry), Keyword.get(opts, :compute_diffmap, false), resource ) end) |> Result.from_native() end end @doc """ Like `compare/5` but returns the bare `Butteraugli.Result` and raises `Butteraugli.Error` on failure. Accepts the same options. """ @spec compare!(image_data(), image_data(), pos_integer(), pos_integer(), keyword()) :: Result.t() def compare!(reference, distorted, width, height, opts \\ []) do case compare(reference, distorted, width, height, opts) do {:ok, result} -> result {:error, reason} -> raise Butteraugli.Error, reason: reason end end @doc """ Trip a `Butteraugli.CancelRef`, aborting any comparison that uses it. Call from any process to cancel an in-flight `compare/5` or `Butteraugli.Reference.compare/3` that was passed this ref as `cancel:`. Returns `:ok` and is safe to call more than once. """ @spec cancel(CancelRef.t()) :: :ok def cancel(%CancelRef{resource: r}), do: Native.token_cancel(r) end