Skip to main content

lib/ssimulacra2.ex

defmodule Ssimulacra2 do
  @moduledoc """
  SSIMULACRA2 perceptual image-quality metric for Elixir, backed by the
  `fast-ssim2` Rust crate.

  Inputs are packed binaries whose layout is chosen with the `:format` option
  (default `:rgb888`). The score is on the native SSIMULACRA2 0–100 scale: 100
  is pixel-identical, ~90+ is visually lossless, and low/negative values
  indicate large perceptual differences.

  ## Formats

  | format | element | channels | bytes/pixel | color space |
  | --- | --- | --- | --- | --- |
  | `:rgb888` (default) | `u8` | 3 | 3 | sRGB (gamma) |
  | `:rgb16` | `u16` | 3 | 6 | sRGB (gamma) |
  | `:linear_rgb` | `f32` | 3 | 12 | linear RGB |
  | `:gray8` | `u8` | 1 | 1 | sRGB grayscale |
  | `:linear_gray` | `f32` | 1 | 4 | linear grayscale |

  Convention: integer elements are sRGB (gamma-encoded); float elements are
  linear RGB. Grayscale is expanded to RGB (R=G=B). Multi-byte elements
  (`u16`, `f32`) are **native-endian** — e.g. `<<v::native-16>>` /
  `<<v::native-float-32>>`. A binary's size must equal
  `width * height * channels * bytes_per_element` for its format.

  ## Cancellation

  `compare/5` and `Ssimulacra2.Reference.compare/3` accept `cancel:` (an
  `Ssimulacra2.CancelRef`) and `timeout:` (milliseconds) to abort a long
  comparison mid-computation, returning `{:error, :cancelled}` or
  `{:error, :timeout}`. Create a ref with `Ssimulacra2.CancelRef.new/0` and trip
  it with `cancel/1`.
  """

  alias Ssimulacra2.{Cancellation, CancelRef, Native, Validate}

  @type image_data :: binary()
  @type reason ::
          :invalid_dimensions
          | :size_mismatch
          | :dimension_mismatch
          | :unknown_format
          | :invalid_cancel
          | :invalid_timeout
          | :cancelled
          | :timeout
          | {:ssimulacra2, String.t()}

  @doc """
  Compare a reference and distorted image of the same dimensions.

  Pass `format:` to select the packed pixel layout; it defaults to `:rgb888`
  (packed 8-bit sRGB, `byte_size == width * height * 3`). Other supported
  formats are `:rgb16`, `:linear_rgb`, `:gray8`, and `:linear_gray`.

  Returns `{:ok, score}` or `{:error, reason}`.

  ## Cancellation

  Pass `cancel:` an `Ssimulacra2.CancelRef` to abort the comparison from
  another process (e.g. on client disconnect) — the call returns
  `{:error, :cancelled}`. Pass `timeout:` a positive integer of milliseconds to
  bound the wall-clock time — the call returns `{:error, :timeout}` if it
  exceeds that. Both may be combined; cancellation is checked at strip
  boundaries, so the CPU is freed promptly without leaving the dirty scheduler.

  Invalid options return `{:error, :invalid_cancel}` / `{:error, :invalid_timeout}`.
  """
  @spec compare(image_data(), image_data(), pos_integer(), pos_integer(), keyword()) ::
          {:ok, float()} | {: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, resource)
      end)
    end
  end

  @doc """
  Like `compare/5` but returns the bare score and raises `Ssimulacra2.Error`
  on failure. Accepts the same `format:` option, defaulting to `:rgb888`.
  """
  @spec compare!(image_data(), image_data(), pos_integer(), pos_integer(), keyword()) :: float()
  def compare!(reference, distorted, width, height, opts \\ []) do
    case compare(reference, distorted, width, height, opts) do
      {:ok, score} -> score
      {:error, reason} -> raise Ssimulacra2.Error, reason: reason
    end
  end

  @doc """
  Trip an `Ssimulacra2.CancelRef`, aborting any comparison that uses it.

  Call from any process to cancel an in-flight `compare/5` or
  `Ssimulacra2.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