Skip to main content

lib/butteraugli.ex

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** (`<<v::native-float-32>>`).
  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