# `Monty.Frameworks.Utils.ObjectModelUtils`
[🔗](https://github.com/d-led/ex.tbp.monty/blob/v0.1.0/packages/ex_tbp_monty_core/lib/monty/frameworks/utils/object_model_utils.ex#L1)

Grid and point-cloud helpers used while building an object model.

Mirrors `tbp/monty/frameworks/utils/object_model_utils.py`. What is ported
here is the part that is arithmetic about observations: deciding whether a new
observation says anything the graph does not already say, and summarising
several observations of the same spot into one.

The rest of the Python file is torch scaffolding — `NumpyGraph`,
`torch_graph_to_numpy`, and the sparse-tensor index helpers — which exists to
move data in and out of `torch_geometric`. Elixir has no such layer: a graph
here is already the map that `NumpyGraph` wraps, so those functions have no
Elixir counterpart to be faithful to rather than an unwritten one.

## Why "is this point new" is a whole function

Sampling the same spot twice must not grow the graph, but "the same spot" is
not just "close by": a point on the other side of a thin object is close in
space and different in what it senses. So a point is redundant only if it is
within the distance threshold *and* no feature differs by more than that
feature's threshold — and if it is redundant with any single close point, it
is redundant.

# `locations`

```elixir
@type locations() :: [Monty.Frameworks.Utils.SpatialArithmetics.vector()]
```

# `already_in_list?`

```elixir
@spec already_in_list?(
  locations(),
  Monty.Frameworks.Utils.SpatialArithmetics.vector(),
  %{optional(String.t()) =&gt; [term()]},
  [non_neg_integer()],
  non_neg_integer(),
  %{optional(String.t()) =&gt; term()}
) :: boolean()
```

Whether `new_point` says nothing that `existing_points` does not already say.

`features` maps a feature name to one value per point, indexed by feature
index; `clean_ids` translates a position among the accepted points into the
feature index, because points rejected earlier still occupy a feature index.

# `build_point_cloud_graph`

```elixir
@spec build_point_cloud_graph(locations(), [list()], map()) :: map()
```

A graph holding observations but no edges yet.

`feature_mapping` says which columns of `features` belong to which named
feature; it is carried along untouched, because only the graph builder knows
how it was laid out. `features` is dense: one row per location, and a row's
columns are named by that mapping, so a graph can hold a pose (nine numbers)
and an HSV (three) side by side.

# `circular_mean`

```elixir
@spec circular_mean([number()]) :: float()
```

The mean of a value that wraps: hue, where 0 and 1 are the same colour.

Averaging 0.01 and 0.99 component-wise gives 0.5, the opposite colour; the
mean has to be taken of the angles and brought back to `[0, 1)`.

# `get_most_common_bool`

```elixir
@spec get_most_common_bool([[boolean()]] | [boolean()]) :: boolean()
```

The majority of a list of booleans, ties going to `true`.

A single boolean per observation is expected; anything deeper is a feature
this function was not written for, and says so rather than guessing a shape.

# `get_most_common_value`

```elixir
@spec get_most_common_value([non_neg_integer()]) :: non_neg_integer()
```

The most common value among non-negative integers, ties going to the smallest.

Monty counts with `np.bincount` and takes the first maximum, which is the same
rule.

# `orthonormal_pose_vectors`

```elixir
@spec orthonormal_pose_vectors(
  Monty.Frameworks.Utils.SpatialArithmetics.vector(),
  Monty.Frameworks.Utils.SpatialArithmetics.vector()
) :: [float()]
```

A right-handed orthonormal pose from a rough normal and curvature direction.

The normal is taken as given (up to length), the curvature direction is made
perpendicular to it, and the second curvature direction is what completes the
frame. A curvature direction with nothing perpendicular to the normal is
meaningless — a flat surface has no preferred direction — so an arbitrary
tangent is used rather than a zero vector.

# `pose_vector_mean`

```elixir
@spec pose_vector_mean([[number()]], [boolean()]) :: {[float()] | nil, boolean()}
```

One representative pose from several observations of the same spot.

Two ambiguities have to be resolved first. A surface can be seen from either
side, so normals are counted by which half-sphere they point into — the one
spanned by the first observation's curvature directions — and the other side
is discarded rather than averaged in. And a curvature direction has no sign
(both point along the same axis), so those pointing the wrong way are flipped
before averaging.

The half-sphere rule inverts the count only when *fewer than half* agree
(`sum < len // 2`), so with three observations split one and two the single
agreeing observation wins. That is Monty's comparison, reproduced rather than
corrected: an experiment that averaged a different side would produce
different numbers from the Python.

Returns the flat nine-number pose, and whether the curvature directions were
used; when the poses are not fully defined they carry no information and the
first observation's direction is kept.

# `pose_vector_merge`

```elixir
@spec pose_vector_merge([number()], [number()], number(), number()) :: [float()]
```

Merge a newly averaged pose into the pose already stored, by weight.

Both are rotations, so they are averaged as rotations — not component-wise,
which would leave a matrix that is no longer a rotation.

# `remove_close_points`

```elixir
@spec remove_close_points(
  locations(),
  %{optional(String.t()) =&gt; [term()]},
  map() | nil,
  non_neg_integer()
) :: {locations(), [non_neg_integer()]}
```

The points from `point_cloud` worth keeping, and their indices.

Everything before `old_graph_index` is kept without checking, because the
earlier points were already compared with each other — that is what makes
extending a graph cost the new observations rather than the whole model.

---

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