Monty.Frameworks.Utils.ObjectModelUtils (ExTbpMonty.Core v0.1.0)

Copy Markdown View Source

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

locations()

Functions

already_in_list?(existing_points, new_point, features, clean_ids, query_id, thresholds)

@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.

build_point_cloud_graph(locations, features, feature_mapping)

@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(values)

@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(booleans)

@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(values)

@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(surface_normal, curvature_direction)

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(pose_vecs, pose_fully_defined)

@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(new_pose_vecs, previous_pose_vecs, num_new_obs, num_previous_obs)

@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(point_cloud, features, graph_delta_thresholds, old_graph_index)

@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.