Skip to content

Added: beam-skipping to likelihood-field-prob - #577

Open
glpuga wants to merge 3 commits into
mainfrom
feature/beam-skipping-likelihood-field-prob
Open

glpuga wants to merge 3 commits into
mainfrom
feature/beam-skipping-likelihood-field-prob

Conversation

@glpuga

@glpuga glpuga commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed changes

Draft of the beam skipping feature, companion to the likelihood_field_prob sensor model .

Type of change

  • 🐛 Bugfix (change which fixes an issue)
  • 🚀 Feature (change which adds functionality)
  • 📚 Documentation (change which fixes or extends documentation)

💥 Breaking change! Explain why a non-backwards compatible change is necessary or remove this line entirely if not applicable.

Checklist

Put an x in the boxes that apply. This is simply a reminder of what we will require before merging your code.

  • Lint and unit tests (if any) pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have added necessary documentation (if appropriate)
  • All commits have been signed for DCO

Additional comments

Anything worth mentioning to the reviewers.

@glpuga glpuga left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a great first pass @ralcoberro

Comment thread beluga/include/beluga/sensor/likelihood_field_prob_model.hpp Outdated
Comment thread beluga/include/beluga/sensor/likelihood_field_prob_model.hpp
Comment thread beluga/include/beluga/sensor/likelihood_field_prob_model.hpp
Comment thread beluga/include/beluga/sensor/likelihood_field_prob_model.hpp
Comment on lines +193 to +211
double log_weight = 0.0;
for (std::size_t i = 0; i < points.size(); ++i) {
// Skip beams that were masked out by prepare(). When beam skipping is disabled (or
// prepare() was never called) the mask is not consulted and every beam contributes,
// reproducing the plain likelihood field prob behavior.
if (do_beamskip_ && i < beam_mask_.size() && !beam_mask_[i]) {
continue;
}
// Transform the end point of the laser to the grid local coordinate system.
// Not using Eigen/Sophus because they make the routine x10 slower.
// See `benchmark_likelihood_field_model.cpp` for reference.
const auto& point = points[i];
const auto x = point.first * cos_theta - point.second * sin_theta + x_offset;
const auto y = point.first * sin_theta + point.second * cos_theta + y_offset;
const auto pz =
static_cast<double>(this->likelihood_field_.data_near(x, y).value_or(unknown_space_occupancy_prob));
log_weight += std::log(pz);
}
return std::exp(log_weight);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do this without resorting back to for loops and at the same time reduce the amount of changes by zipping together the beam_mask and the points vectors, and returning 0.0 from the lambda if the beam is masked. You don't need to check do_beamskip_ because the mask will be true for all beams in that case anyways.,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. operator() is back to a declarative accumulation: it zips points with the beam mask and the projection returns 0.0 (the neutral element of the log-likelihood sum) for masked beams, so there is no do_beamskip_ check and no index bookkeeping in the hot path.
To make that work I had prepare() always size the mask to the measurement, leaving every beam enabled when skipping is disabled, which is the invariant your comment relies on. operator() also resolves the mask once per update before building the lambda: if prepare() was not called for this measurement (the model used standalone, as in the unit tests) it falls back to an all-enabled mask, so the plain likelihood-field-prob behavior is preserved. That resolution is once per update, not per particle. Covered by a new WithoutPrepareIntegratesEveryBeam test.

Comment thread beluga_amcl/src/amcl_node.cpp Outdated
Comment thread beluga_amcl/src/amcl_node.cpp Outdated
Comment thread beluga_amcl/src/amcl_node.cpp Outdated
Comment thread beluga_example/params/default.ros2.yaml Outdated
Comment thread beluga_example/params/default.ros2.yaml
@glpuga
glpuga requested a review from LaBruma August 3, 2026 20:54
@glpuga

glpuga commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@griswaldbrooks , this is the WIP beam-skipping feature that @ralcoberro has been working on that we mentioned last week.

@hidmic
hidmic self-requested a review August 4, 2026 14:26
const std::size_t num_beams = points.size();
beam_mask_.assign(num_beams, true);
if (num_beams == 0) {
beam_mask_.assign(num_beams, std::uint8_t{1});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a static cast instead.

Comment on lines +143 to +145
// Transform the end point of the laser to the grid local coordinate system.
// Not using Eigen/Sophus because they make the routine x10 slower.
// See `benchmark_likelihood_field_model.cpp` for reference.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a general statement this is dubious to me, since for SE2 Sophus literally store represents the orientation as the sin and cosin values, so the product itself cannot really be calculated that much different from this. If it takes 10x its because we are loading the sophus data and doing an arctan/log at some point along the chain.

Still, not for this PR to fix, but I would remove the comment not to repeat an very broad assertion without contextual verifcation.

const double skipped_ratio = static_cast<double>(skipped) / static_cast<double>(num_beams);
if (skipped_ratio > beam_skip_error_threshold_) {
std::fill(beam_mask_.begin(), beam_mask_.end(), true);
std::fill(beam_mask_.begin(), beam_mask_.end(), std::uint8_t{1});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use static cast.

// plain likelihood field prob behavior.
auto mask = beam_mask_.size() == points.size() //
? beam_mask_
: std::vector<std::uint8_t>(points.size(), std::uint8_t{1});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will materialize an std::vector, allocating memory on each call, substantially slowing down the function. Use a ranges generator that returns the same number on all invocations.

// further bookkeeping. When prepare() was not called for this measurement (e.g. when the model
// is used standalone, outside the two pass update) every beam is integrated, reproducing the
// plain likelihood field prob behavior.
auto mask = beam_mask_.size() == points.size() //

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't prepare ensure that these two are equal length?

const std::size_t num_beams = points.size();
beam_mask_.assign(num_beams, true);
if (num_beams == 0) {
beam_mask_.assign(num_beams, std::uint8_t{1});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear the vector and reserve() instead of using asssing. assign will spend time O(N) setting the whole vector to 1s, only for these to be overwritten in line 164.

Better leave it empty here, reseve the storage to avoid reallocations, and push_back() the values in line 164.

In line 127, do std::fill to ensure these are all 1s before returning if we are not doing beam_skipping.

double beam_skip_threshold_; ///< Fraction of particles that must agree for a beam to be kept.
double beam_skip_error_threshold_; ///< Skipped-beam fraction above which skipping is disabled.
float likelihood_threshold_; ///< Likelihood equivalent of `beam_skip_distance`.
std::vector<std::uint8_t> beam_mask_; ///< Per-beam mask computed by `prepare()` (non-zero means used).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A vector of booleans would work here.

@griswaldbrooks griswaldbrooks Oct 7, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider keeping the std::vector<std::uint8_t> in case you want to parallelize.

prepare is now the only pass in Amcl::update that doesn't take the execution policy, even though it does the same particles × beams work as reweight. A way to parallelize it is per beam, with each worker writing mask[i]. The standard explicitly excludes std::vector<bool> from the guarantee that distinct elements can be modified concurrently ([container.reqmts], data races), because neighboring bits share a word. So with std::vector<bool>, passing policy to prepare later would quietly introduce a data race. With uint8_t it's safe as is.

@glpuga

glpuga commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Please rebase on top of the current main and check that the tests pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants