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.
Summary
Functions
Whether new_point says nothing that existing_points does not already say.
A graph holding observations but no edges yet.
The mean of a value that wraps: hue, where 0 and 1 are the same colour.
The majority of a list of booleans, ties going to true.
The most common value among non-negative integers, ties going to the smallest.
A right-handed orthonormal pose from a rough normal and curvature direction.
One representative pose from several observations of the same spot.
Merge a newly averaged pose into the pose already stored, by weight.
The points from point_cloud worth keeping, and their indices.
Types
@type locations() :: [Monty.Frameworks.Utils.SpatialArithmetics.vector()]
Functions
@spec already_in_list?( locations(), Monty.Frameworks.Utils.SpatialArithmetics.vector(), %{optional(String.t()) => [term()]}, [non_neg_integer()], non_neg_integer(), %{optional(String.t()) => 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.
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.
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).
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.
@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.
@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.
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.
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.
@spec remove_close_points( locations(), %{optional(String.t()) => [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.