# `Monty.Frameworks.Models.Salience.Strategies.Vocus2.OrientationSalience`
[🔗](https://github.com/d-led/ex.tbp.monty/blob/v0.1.0/packages/ex_tbp_monty_core/lib/monty/frameworks/models/salience/strategies/vocus2/orientation_salience.ex#L1)

How much a patch looks like an edge, at several orientations.

Mirrors
`tbp/monty/frameworks/models/salience/strategies/vocus2/vocus2.py::OrientationSalience`.

Four Gabor filters at 0°, 45°, 90° and 135° are run over the detail of the
lightness pyramid, and the absolute response is kept. A Gabor filter answers
strongly where the image has structure at its wavelength, so "this patch is an
edge" becomes a number — and the four orientations together make an edge
salient whichever way it runs. Filtering the Laplacian rather than the image
keeps a response local to the scale it was found at.

Each kernel has its mean subtracted before use, so a large flat region answers
nothing instead of answering in proportion to its brightness: the filter is
meant to detect structure, not light.

## Measured, not recalled

`cv2.getGaborKernel` is not a dependency of the port, so both of its halves
were measured against OpenCV 4.10 and reproduced: the Gaussian envelope with
`sigma_y = sigma / gamma`, the rotation `x_r = x cos θ + y sin θ` and
`y_r = -x sin θ + y cos θ`, and — the part that is easy to get wrong — the
modulation entering as `cos(2π x_r / λ - ψ)`, with a *minus* psi. A plus psi
agrees when `psi` is zero and is wrong for every other phase, which is how the
mistake survived a first reading.

Filtering is correlation, not convolution, with OpenCV's default
`BORDER_REFLECT_101`; measured to `1.0e-6` on random images.

# `t`

```elixir
@type t() :: %Monty.Frameworks.Models.Salience.Strategies.Vocus2.OrientationSalience{
  collapse:
    (Monty.Frameworks.Models.Salience.Strategies.Vocus2.Pyramids.Pyramid.t() -&gt;
       [[float()]]),
  combine:
    (Monty.Frameworks.Models.Salience.Strategies.Vocus2.MapCombine.maps() -&gt;
       [[float()]]),
  gamma: float(),
  kernels: %{optional(String.t()) =&gt; [[float()]]},
  n_orientations: pos_integer(),
  period: float(),
  phase: float(),
  sigma: float()
}
```

# `correlate`

```elixir
@spec correlate([[float()]], [[float()]]) :: [[float()]]
```

Correlates a plane with a kernel, as `cv2.filter2D` does.

The kernel is laid over the pixel unflipped — correlation, not convolution —
and the border is reflected without repeating the edge pixel
(`BORDER_REFLECT_101`), so `[a b c d]` continues `[c b | a b c d | c b]`.

The padded plane and the kernel are turned into tuples first, because every
pixel reads the same neighbourhood many times and a list is a walk from the
front each time.

# `kernels`

```elixir
@spec kernels(keyword()) :: %{optional(String.t()) =&gt; [[float()]]}
```

The Gabor kernels: one per orientation, each centred on zero mean.

The kernel is `int(7*sigma + 1) | 1` pixels square — Monty's arithmetic, and
OpenCV's rule for a kernel size it is not given.

# `new`

```elixir
@spec new(keyword()) :: t()
```

Orientation salience from `:n_orientations` Gabor filters of `:period`.

`:sigma` defaults to `0.3 * period`, which is Monty's rule of thumb, and
`:phase` to a quarter turn, which finds edges; a phase of zero finds strips.

# `process`

```elixir
@spec process(
  t(),
  Monty.Context.t(),
  Monty.Frameworks.Models.Salience.Strategies.Vocus2.Pyramids.Pyramid.t()
) :: [[float()]]
```

The orientation salience map for one pyramid of lightness.

Each orientation answers one map — the detail of the pyramid filtered and
rectified — and the maps are combined by `:combine`, whose default is their
mean.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
