SPFASmoothing.cpp
Go to the documentation of this file.
1/**
2 * This file is part of ArmarX.
3 *
4 * ArmarX is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License version 2 as
6 * published by the Free Software Foundation.
7 *
8 * ArmarX is distributed in the hope that it will be useful, but
9 * WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16
17#include "SPFASmoothing.h"
18
19#include <array>
20#include <cmath>
21#include <iomanip>
22#include <limits>
23#include <string>
24#include <vector>
25
26#include <Eigen/Geometry>
27
30
39
41{
42 namespace
43 {
45
46 core::GlobalTrajectory toGlobalTrajectory(const std::vector<CenterPoint>& traj)
47 {
48 core::GlobalTrajectory gtraj;
49 for (const auto& p : traj)
50 {
51 core::GlobalTrajectoryPoint gp;
52 gp.waypoint.pose =
53 Eigen::Translation3f{Eigen::Vector3f(static_cast<float>(p.x),
54 static_cast<float>(p.y),
55 0.0f)} *
56 Eigen::AngleAxisf{static_cast<float>(p.theta), Eigen::Vector3f::UnitZ()};
57 gp.velocity = static_cast<float>(p.v);
58 gtraj.mutablePoints().push_back(gp);
59 }
60 return gtraj;
61 }
62
63 std::vector<CenterPoint> toCenterTrajectory(const core::GlobalTrajectory& gtraj)
64 {
65 std::vector<CenterPoint> traj;
66 for (const auto& gp : gtraj.points())
67 {
69 p.x = gp.waypoint.pose.translation().x();
70 p.y = gp.waypoint.pose.translation().y();
71 const auto& R = gp.waypoint.pose.rotation();
72 p.theta = std::atan2(R(1, 0), R(0, 0));
73 p.v = gp.velocity;
74 traj.push_back(p);
75 }
76 return traj;
77 }
78
79 double queryDistance(const Costmap2DWrapper& wrapper, double x, double y)
80 {
81 double out;
82 wrapper(&x, &y, &out);
83 return out;
84 }
85
86 /**
87 * One-shot escape hatch for waypoints the optimizer cannot move.
88 *
89 * The costmap is an *unsigned* distance field: everything at or inside the
90 * robot-radius-inflated obstacle is clamped to zero and masked invalid, which the
91 * wrapper reports as a constant sentinel. A waypoint sitting there sees a flat
92 * field, so no gradient method -- Ceres or otherwise -- can push it out. Only a
93 * discrete jump can.
94 *
95 * Waypoints that merely violate the clearance are deliberately left alone: the
96 * obstacle barrier has a real gradient there and moves them with the smoothness and
97 * spacing terms in the loop, which a per-waypoint projection cannot do. This ran as
98 * a 100-iteration hill climb before, and that was the source of the folds -- each
99 * waypoint was free to travel up to `tracking_max_deviation` independently of its
100 * neighbours, which is most of a segment length on a 200 mm-spaced path, so two
101 * adjacent waypoints could swap order outright.
102 *
103 * @return Indices that could not be projected within the deviation bound.
104 */
105 std::vector<std::size_t> projectTrappedWaypoints(std::vector<CenterPoint>& traj,
106 const Costmap2DWrapper& wrapper,
107 const algorithms::Costmap& costmap,
108 const Params& params)
109 {
110 std::vector<std::size_t> unresolved;
111
112 for (std::size_t idx = 1; idx + 1 < traj.size(); idx++)
113 {
114 CenterPoint& p = traj.at(idx);
115
116 if (queryDistance(wrapper, p.x, p.y) > 0.0)
117 {
118 continue; // the barrier has a gradient here; let the optimizer work
119 }
120
121 // Only far enough to clear the gate. Demanding the full target here would fail
122 // in exactly the tight places the escape hatch exists for, and it is not
123 // needed: once the waypoint is out of the flat interior the barrier has a
124 // gradient again and pulls it the rest of the way.
125 const auto vertex = costmap.findClosestCollisionFreeVertex(
126 Eigen::Vector2f{static_cast<float>(p.x), static_cast<float>(p.y)},
127 static_cast<float>(params.tracking_max_deviation),
128 static_cast<float>(params.min_clearance));
129
130 if (not vertex.has_value() or
131 std::hypot(vertex->position.x() - p.x, vertex->position.y() - p.y) >
132 params.tracking_max_deviation + 1e-3)
133 {
134 unresolved.push_back(idx);
135 continue;
136 }
137
138 p.x = vertex->position.x();
139 p.y = vertex->position.y();
140 }
141
142 return unresolved;
143 }
144
145 /// Interior obstacle samples for a segment, chosen so the optimizer never evaluates a
146 /// segment more coarsely than the trajectory checker tests it.
147 int segmentSampleCount(const CenterPoint& a, const CenterPoint& b, const Params& params)
148 {
149 const double length = std::hypot(b.x - a.x, b.y - a.y);
150 const int needed =
151 static_cast<int>(std::ceil(length / std::max(params.segment_sample_spacing, 1.0))) - 1;
152
153 return std::clamp(
154 needed, params.segment_obstacle_samples, params.max_segment_obstacle_samples);
155 }
156
157 /// Smallest clearance [mm] anywhere on a trajectory, sampled as the checker samples it.
158 float minimumClearance(const core::GlobalTrajectory& trajectory,
159 const algorithms::Costmap& costmap)
160 {
161 constexpr float sampleSpacing = 50.F;
162
163 float smallest = std::numeric_limits<float>::max();
164
165 const auto sample = [&](const Eigen::Vector2f& position)
166 {
167 const auto value = costmap.value(position);
168 // Unknown or masked reads as collision, matching the checker.
169 smallest = std::min(smallest, value.has_value() ? value.value() : 0.F);
170 };
171
172 const auto& points = trajectory.points();
173
174 for (std::size_t i = 0; i < points.size(); i++)
175 {
176 sample(points[i].waypoint.pose.translation().head<2>());
177
178 if (i + 1 >= points.size())
179 {
180 break;
181 }
182
183 const Eigen::Vector2f a = points[i].waypoint.pose.translation().head<2>();
184 const Eigen::Vector2f b = points[i + 1].waypoint.pose.translation().head<2>();
185 const int steps =
186 std::max(1, static_cast<int>(std::ceil((b - a).norm() / sampleSpacing)));
187
188 for (int step = 1; step < steps; step++)
189 {
190 sample(a + (b - a) * (static_cast<float>(step) / static_cast<float>(steps)));
191 }
192 }
193
194 return (smallest == std::numeric_limits<float>::max()) ? 0.F : smallest;
195 }
196
197 bool validateTrajectory(const std::vector<CenterPoint>& traj, const std::string& label)
198 {
199 bool valid = true;
200 for (int i = 0; i < static_cast<int>(traj.size()); ++i)
201 {
202 if (!std::isfinite(traj[i].x) || !std::isfinite(traj[i].y) ||
203 !std::isfinite(traj[i].theta) || !std::isfinite(traj[i].v))
204 {
205 ARMARX_WARNING << "[" << label << "] NaN/Inf in traj[" << i
206 << "] : x=" << traj[i].x << " y=" << traj[i].y
207 << " theta=" << traj[i].theta << " v=" << traj[i].v;
208 valid = false;
209 }
210 }
211 return valid;
212 }
213
214 ceres::Solver::Options makeSolverOptions(const Params& params, int maxIterations)
215 {
216 ceres::Solver::Options options;
217 options.max_num_iterations = maxIterations;
218 options.linear_solver_type = ceres::SPARSE_NORMAL_CHOLESKY;
219 options.minimizer_progress_to_stdout = false;
220 options.num_threads = params.num_threads;
221 options.function_tolerance = 1e-6;
222 options.parameter_tolerance = 1e-8;
223 options.trust_region_strategy_type = ceres::LEVENBERG_MARQUARDT;
224 options.initial_trust_region_radius = params.initial_trust_region_radius;
225 options.max_trust_region_radius = params.max_trust_region_radius;
226 options.min_trust_region_radius = params.min_trust_region_radius;
227 options.min_relative_decrease = 1e-4;
228 options.use_nonmonotonic_steps = true;
229 return options;
230 }
231
232 struct PassWeights
233 {
234 double w_obs;
235 double w_pose_smooth;
236 double w_spacing;
237 double w_tracking;
238 double w_safe_tracking;
239 };
240
241 // Unweighted, so the breakdown compares terms on a common footing. Delegates to the
242 // residual's own barrier so the log cannot drift from what the solver minimises.
243 double evalObstacleCost(const Costmap2DWrapper& wrapper,
244 double x,
245 double y,
246 double max_dist,
247 double clearance)
248 {
249 double d_raw;
250 wrapper(&x, &y, &d_raw);
251 return obstacleBarrier(d_raw, 1.0, max_dist, clearance);
252 }
253
254 void logResidualBreakdown(const std::vector<CenterPoint>& traj,
255 const Costmap2DWrapper& wrapper,
256 const std::vector<CenterPoint>& originalTargets,
257 const Params& params,
258 const std::string& label)
259 {
260 const int N = static_cast<int>(traj.size());
261 if (N < 4)
262 return;
263
264 double c_obs_wp = 0.0;
265 double c_obs_seg = 0.0;
266 double c_smooth = 0.0;
267 double c_tracking = 0.0;
268 double c_safe_tracking = 0.0;
269 double c_spacing = 0.0;
270
271 // Obstacle (waypoints)
272 for (int i = 0; i < N; ++i)
273 {
274 double r = evalObstacleCost(wrapper,
275 traj[i].x,
276 traj[i].y,
277 params.obs_max_distance,
278 params.clearance);
279 c_obs_wp += r * r;
280 }
281
282 // Obstacle (segment samples)
283 for (int i = 0; i < N - 1; ++i)
284 {
285 const int samples = segmentSampleCount(traj[i], traj[i + 1], params);
286 for (int s = 1; s <= samples; ++s)
287 {
288 double t = static_cast<double>(s) / (samples + 1);
289 double mx = (1.0 - t) * traj[i].x + t * traj[i + 1].x;
290 double my = (1.0 - t) * traj[i].y + t * traj[i + 1].y;
291 double r = evalObstacleCost(wrapper,
292 mx,
293 my,
294 params.obs_max_distance,
295 params.clearance);
296 c_obs_seg += r * r;
297 }
298 }
299
300 // Position smoothness
301 for (int i = 1; i <= N - 2; ++i)
302 {
303 double rx = traj[i - 1].x - 2.0 * traj[i].x + traj[i + 1].x;
304 double ry = traj[i - 1].y - 2.0 * traj[i].y + traj[i + 1].y;
305 c_smooth += rx * rx + ry * ry;
306 }
307
308 // Bounded tracking to the reference path
309 for (int i = 1; i <= N - 2; ++i)
310 {
311 double dx = traj[i].x - originalTargets[i].x;
312 double dy = traj[i].y - originalTargets[i].y;
313 double d = std::hypot(dx, dy);
314 double excess = d - params.tracking_deadzone;
315 if (excess > 0)
316 {
317 if (excess <= params.tracking_max_deviation - params.tracking_deadzone)
318 {
319 double r = params.w_tracking_in_bounds * excess;
320 c_tracking += r * r;
321 }
322 else
323 {
324 double over = excess - (params.tracking_max_deviation - params.tracking_deadzone);
325 double r = params.w_tracking_in_bounds * (params.tracking_max_deviation - params.tracking_deadzone)
326 + params.w_tracking_hard * over;
327 c_tracking += r * r;
328 }
329 }
330 }
331
332 // Soft tracking to the reference path
333 for (int i = 1; i <= N - 2; ++i)
334 {
335 double rx = traj[i].x - originalTargets[i].x;
336 double ry = traj[i].y - originalTargets[i].y;
337 // Use pass3 weight for breakdown (representative)
338 double w = params.pass3_w_tracking;
339 c_safe_tracking += (w * rx) * (w * rx) + (w * ry) * (w * ry);
340 }
341
342 // Spacing
343 double d_avg = computeAverageSpacing(traj);
344 if (d_avg <= 1e-9)
345 d_avg = 0.1;
346 for (int i = 0; i < N - 1; ++i)
347 {
348 double dx = traj[i + 1].x - traj[i].x;
349 double dy = traj[i + 1].y - traj[i].y;
350 double dist_sq = dx * dx + dy * dy;
351 double r = params.pass3_w_spacing * (dist_sq - d_avg * d_avg);
352 c_spacing += r * r;
353 }
354
355 double total = c_obs_wp + c_obs_seg + c_smooth + c_tracking + c_safe_tracking + c_spacing;
356
357 ARMARX_INFO << "=== Residual breakdown [" << label << "] ===";
358 ARMARX_INFO << "Obstacle (waypoints): " << std::scientific << std::setprecision(3) << c_obs_wp;
359 ARMARX_INFO << "Obstacle (segments): " << std::scientific << std::setprecision(3) << c_obs_seg;
360 ARMARX_INFO << "Position smoothness: " << std::scientific << std::setprecision(3) << c_smooth;
361 ARMARX_INFO << "Bounded tracking: " << std::scientific << std::setprecision(3) << c_tracking;
362 ARMARX_INFO << "Soft tracking: " << std::scientific << std::setprecision(3) << c_safe_tracking;
363 ARMARX_INFO << "Spacing: " << std::scientific << std::setprecision(3) << c_spacing;
364 ARMARX_INFO << "TOTAL: " << std::scientific << std::setprecision(3) << total;
365 }
366
367 void runPositionPass(std::vector<CenterPoint>& traj,
368 const Costmap2DWrapper& wrapper,
369 const std::vector<CenterPoint>& originalTargets,
370 const PassWeights& w,
371 const Params& params,
372 int maxIterations,
373 bool pinTightWaypoints = false)
374 {
375 const int N = static_cast<int>(traj.size());
376 if (N < 4)
377 {
378 ARMARX_INFO << "[SPFA smoothing] need at least 4 points for position pass";
379 return;
380 }
381
382 normalizeTrajectoryAngles(traj);
383
384 // Avoid zero-length segments.
385 for (int i = 1; i < N - 1; ++i)
386 {
387 double dx = traj[i].x - traj[i - 1].x;
388 double dy = traj[i].y - traj[i - 1].y;
389 if (std::hypot(dx, dy) < 1.0)
390 {
391 double ndx = (i + 1 < N) ? (traj[i + 1].x - traj[i].x) : 1.0;
392 double ndy = (i + 1 < N) ? (traj[i + 1].y - traj[i].y) : 0.0;
393 double nlen = std::hypot(ndx, ndy);
394 if (nlen < 1e-6)
395 {
396 ndx = 1.0;
397 ndy = 0.0;
398 nlen = 1.0;
399 }
400 traj[i].x += 1.0 * ndx / nlen;
401 traj[i].y += 1.0 * ndy / nlen;
402 }
403 }
404
405 ceres::Problem problem;
406 for (int i = 0; i < N; ++i)
407 problem.AddParameterBlock(&traj[i].x, 4);
408
409 problem.SetParameterBlockConstant(&traj.front().x);
410 problem.SetParameterBlockConstant(&traj.back().x);
411
412 // Position pass: only x, y are free.
413 //
414 // Optionally pin interior waypoints that are already close to an obstacle. The
415 // count is logged because a threshold that pins most of a tight corridor leaves
416 // nothing for the solver to do, and that is invisible from the result alone.
417 int frozen = 0;
418
419 for (int i = 1; i < N - 1; ++i)
420 {
421 const bool freeze =
422 (params.use_obstacle_freeze or pinTightWaypoints) and
423 queryDistance(wrapper, traj[i].x, traj[i].y) <= params.obstacle_freeze_threshold;
424
425 if (freeze)
426 {
427 problem.SetParameterBlockConstant(&traj[i].x);
428 frozen++;
429 }
430 else
431 {
432 std::vector<int> constant_indices = {2, 3}; // theta, v
433 SET_CONSTRAINT(problem, &traj[i].x, new SubsetConstraint(4, constant_indices));
434 }
435 }
436
437 if (params.use_obstacle_freeze or pinTightWaypoints)
438 {
439 ARMARX_INFO << "[SPFA smoothing] Pinned " << frozen << " of " << (N - 2)
440 << " interior waypoints below "
441 << params.obstacle_freeze_threshold << " mm clearance.";
442 }
443
444 // 1) Position smoothness
445 for (int i = 1; i <= N - 2; ++i)
446 {
447 problem.AddResidualBlock(
448 new ceres::AutoDiffCostFunction<PositionSmoothResidual, 2, 4, 4, 4>(
449 new PositionSmoothResidual(w.w_pose_smooth)),
450 nullptr,
451 &traj[i - 1].x,
452 &traj[i].x,
453 &traj[i + 1].x);
454 }
455
456 // 2) Spacing
457 double d_avg = computeAverageSpacing(traj);
458 if (d_avg <= 1e-9)
459 d_avg = 0.1;
460 for (int i = 0; i < N - 1; ++i)
461 {
462 problem.AddResidualBlock(
463 new ceres::AutoDiffCostFunction<SpacingResidual, 1, 4, 4>(
464 new SpacingResidual(d_avg, w.w_spacing)),
465 nullptr,
466 &traj[i].x,
467 &traj[i + 1].x);
468 }
469
470 // 3) Obstacle residuals (waypoints)
471 for (int i = 0; i < N; ++i)
472 {
473 problem.AddResidualBlock(
474 new ceres::AutoDiffCostFunction<ObstacleResidual2D, 1, 4>(
475 new ObstacleResidual2D(wrapper,
476 w.w_obs,
477 params.obs_max_distance,
478 params.clearance)),
479 nullptr,
480 &traj[i].x);
481 }
482
483 // 4) Obstacle residuals along segments (interior samples)
484 for (int i = 0; i < N - 1; ++i)
485 {
486 // Sized from the segment as it stands when the problem is built. The optimizer
487 // can lengthen it, so the bound below is deliberately generous.
488 const int samples = segmentSampleCount(traj[i], traj[i + 1], params);
489 for (int s = 1; s <= samples; ++s)
490 {
491 double t = static_cast<double>(s) / (samples + 1);
492 problem.AddResidualBlock(
493 new ceres::AutoDiffCostFunction<SegmentObstacleResidual2D, 1, 4, 4>(
494 new SegmentObstacleResidual2D(wrapper,
495 w.w_obs,
496 params.obs_max_distance,
497 params.clearance,
498 t)),
499 nullptr,
500 &traj[i].x,
501 &traj[i + 1].x);
502 }
503 }
504
505 // Per-waypoint leash strength from the clearance at its reference position:
506 // 1 where room is scarce, fading to 0 where it is plentiful. Evaluated on the
507 // reference rather than the current iterate so the weights are fixed for the
508 // solve -- a weight that moved with the parameters would make the problem
509 // non-stationary and the trust region meaningless.
510 std::vector<double> trackingScale(N, 1.0);
511
512 if (params.use_tracking_clearance_adaptive)
513 {
514 for (int i = 0; i < N; ++i)
515 {
516 trackingScale[i] = trackingScaleFromClearance(
517 queryDistance(wrapper, originalTargets[i].x, originalTargets[i].y),
518 params.tracking_fade_start_clearance,
519 params.tracking_fade_end_clearance);
520 }
521 }
522
523 // 5) Bounded tracking: hard cap on how far a waypoint may leave the
524 // reference path, which is what the final deviation check measures.
525 //
526 // Deliberately not scaled. This one enforces `tracking_max_deviation`, which the
527 // caller checks afterwards, so relaxing it in open space would let the optimizer
528 // produce a path the deviation check then rejects.
529 for (int i = 1; i <= N - 2; ++i)
530 {
531 problem.AddResidualBlock(
532 new ceres::AutoDiffCostFunction<BoundedTrackingResidual, 1, 4>(
533 new BoundedTrackingResidual(originalTargets[i].x,
534 originalTargets[i].y,
535 params.tracking_deadzone,
536 params.tracking_max_deviation,
537 params.w_tracking_in_bounds,
538 params.w_tracking_hard)),
539 nullptr,
540 &traj[i].x);
541 }
542
543 // 6) Soft tracking to the original SPFA path (weak, keeps deviation small)
544 if (w.w_tracking > 0.0)
545 {
546 for (int i = 1; i <= N - 2; ++i)
547 {
548 const double weight = w.w_tracking * trackingScale[i];
549
550 // A zero weight is a residual block that can only cost time.
551 if (weight <= 0.0)
552 {
553 continue;
554 }
555
556 problem.AddResidualBlock(
557 new ceres::AutoDiffCostFunction<SoftTrackingResidual, 2, 4>(
559 originalTargets[i].x, originalTargets[i].y, weight)),
560 nullptr,
561 &traj[i].x);
562 }
563 }
564
565 ceres::Solver::Options options = makeSolverOptions(params, maxIterations);
566 ceres::Solver::Summary summary;
567 ceres::Solve(options, &problem, &summary);
568
569 normalizeTrajectoryAngles(traj);
570
571 ARMARX_VERBOSE << summary.FullReport();
572 ARMARX_INFO << "[Position pass] Final cost: " << summary.final_cost
573 << " iterations: " << summary.num_successful_steps
574 << " termination: " << summary.termination_type;
575 }
576
577 bool repairWaypoints(std::vector<CenterPoint>& traj,
578 const algorithms::Costmap& costmap,
579 const Costmap2DWrapper& wrapper,
580 const std::vector<CenterPoint>& originalTargets,
581 const Params& params)
582 {
583 const int N = static_cast<int>(traj.size());
584
585 for (int i = 1; i < N - 1; ++i)
586 {
587 // Repair what the gate refuses, not everything short of the target: a waypoint
588 // between the two is one the optimizer chose and the gate accepts, so snapping
589 // it elsewhere would undo the optimization for nothing.
590 double d = queryDistance(wrapper, traj[i].x, traj[i].y);
591 if (d > params.min_clearance)
592 continue;
593
594 // Search around where the waypoint *is*, not around where it started. Its
595 // neighbours keep their optimized positions, so snapping this one back to the
596 // reference path drags it against them -- which is a fold, manufactured by the
597 // repair itself. The deviation bound still applies to the reference, but as a
598 // constraint on the result rather than as the search origin.
599 const auto vertex = costmap.findClosestCollisionFreeVertex(
600 Eigen::Vector2f{static_cast<float>(traj[i].x),
601 static_cast<float>(traj[i].y)},
602 static_cast<float>(params.tracking_max_deviation),
603 static_cast<float>(params.min_clearance));
604
605 if (!vertex)
606 return false;
607
608 const double dx = vertex->position.x() - originalTargets[i].x;
609 const double dy = vertex->position.y() - originalTargets[i].y;
610 if (std::hypot(dx, dy) > params.tracking_max_deviation + 1e-3)
611 return false;
612
613 traj[i].x = vertex->position.x();
614 traj[i].y = vertex->position.y();
615 }
616
617 return true;
618 }
619
620 double computeMaxDeviation(const std::vector<CenterPoint>& traj,
621 const std::vector<CenterPoint>& originalTargets)
622 {
623 double maxDev = 0.0;
624 for (std::size_t i = 0; i < traj.size(); ++i)
625 {
626 double dx = traj[i].x - originalTargets[i].x;
627 double dy = traj[i].y - originalTargets[i].y;
628 maxDev = std::max(maxDev, std::hypot(dx, dy));
629 }
630 return maxDev;
631 }
632 } // namespace
633
635 const algorithms::Costmap& costmap,
636 const Params& params) :
637 trajectory(trajectory),
638 costmap(costmap),
639 params(params)
640 {
641 }
642
644 {
645 Costmap2DWrapper wrapper{costmap};
646
647 std::vector<CenterPoint> traj = toCenterTrajectory(trajectory);
648 const std::vector<CenterPoint> originalTargets = traj;
649
650 if (!validateTrajectory(traj, "pre-processing"))
651 {
652 ARMARX_WARNING << "[SPFA smoothing] Invalid trajectory before pre-processing. Aborting.";
653 return {std::nullopt, std::nullopt, false, false, {}};
654 }
655
656 const std::vector<std::size_t> trapped =
657 projectTrappedWaypoints(traj, wrapper, costmap, params);
658
659 if (not trapped.empty())
660 {
661 ARMARX_WARNING << "[SPFA smoothing] " << trapped.size()
662 << " waypoint(s) sit inside an obstacle and could not be projected "
663 "clear within "
664 << params.tracking_max_deviation
665 << " mm; first at index " << trapped.front()
666 << ". The optimizer sees no gradient there, so the result will very "
667 "likely fail the collision check.";
668 }
669
670 // The path after the escape hatch, kept for the caller and as the fallback if a pass
671 // produces non-finite values.
672 const std::vector<CenterPoint> preprocessed = traj;
673
674 if (!validateTrajectory(traj, "post-projection"))
675 {
676 ARMARX_WARNING << "[SPFA smoothing] Invalid trajectory after pre-processing. Aborting.";
677 return {std::nullopt, std::nullopt, false, false, {}};
678 }
679
680 logResidualBreakdown(traj, wrapper, originalTargets, params, "after pre-processing");
681
682 // Multi-pass position optimization.
683 const std::array<PassWeights, 3> passes = {
684 PassWeights{params.pass1_w_obs,
685 params.pass1_w_pose_smooth,
686 params.pass1_w_spacing,
687 params.pass1_w_tracking,
688 params.pass1_w_safe_tracking},
689 PassWeights{params.pass2_w_obs,
690 params.pass2_w_pose_smooth,
691 params.pass2_w_spacing,
692 params.pass2_w_tracking,
693 params.pass2_w_safe_tracking},
694 PassWeights{params.pass3_w_obs,
695 params.pass3_w_pose_smooth,
696 params.pass3_w_spacing,
697 params.pass3_w_tracking,
698 params.pass3_w_safe_tracking}};
699
700 for (size_t pass = 0; pass < passes.size(); ++pass)
701 {
702 ARMARX_INFO << "[SPFA smoothing] Starting position pass " << (pass + 1);
703 runPositionPass(traj, wrapper, originalTargets, passes[pass], params, params.max_iterations);
704 logResidualBreakdown(traj, wrapper, originalTargets, params,
705 "after position pass " + std::to_string(pass + 1));
706
707 if (!validateTrajectory(traj, "post-position-pass-" + std::to_string(pass + 1)))
708 {
709 ARMARX_WARNING << "[SPFA smoothing] NaN/Inf after position pass " << (pass + 1)
710 << ". Reverting to the pre-processed path.";
711 traj = preprocessed;
712 break;
713 }
714 }
715
716 normalizeTrajectoryAngles(traj);
717
718 // Collision repair if needed.
719 // The gate enforces the hard limit; the target is what the barrier aims for and is
720 // deliberately not enforced. Rejecting a path merely for missing the target discards it
721 // in favour of the unsmoothed one, which is rougher and no safer.
722 //
723 // And when the input is *itself* inside the limit, the same argument applies to the
724 // limit: no smoothed path can clear a floor the route it was given does not clear, so
725 // enforcing it guarantees the unsmoothed path is driven -- at the very same distance
726 // from obstacles, only rougher. In that case the floor becomes what the input already
727 // had, which asks the smoother not to make the worst point worse rather than to fix
728 // something outside its reach.
729 const float inputMinClearance = minimumClearance(trajectory, costmap);
730
731 const bool judgeAgainstInput =
732 params.allow_degraded_input and
733 inputMinClearance < static_cast<float>(params.min_clearance);
734
735
736 // Never below a positive floor, whatever the input did. The relative rule is for a
737 // path that is *tight* -- the robot fits, with less room than we would like. A path
738 // the robot does not fit through at all reads 0 here, and taking that as the floor
739 // would make the gate vacuous: nothing is below zero, so every path would pass,
740 // including one straight through a wall. An input like that is a recovery problem,
741 // not something for the smoother to be judged lenient about.
742 constexpr double absoluteFloor = 1.0;
743
744 const double effectiveLimit =
745 judgeAgainstInput ? std::max(absoluteFloor, static_cast<double>(inputMinClearance))
746 : params.min_clearance;
747
748 if (judgeAgainstInput)
749 {
750 ARMARX_INFO << "[SPFA smoothing] The path handed in already reaches "
751 << inputMinClearance << " mm, inside the " << params.min_clearance
752 << " mm limit. Judging the result against that instead, so the "
753 "alternative is not simply a rougher path at the same clearance.";
754 }
755
756 TrajectoryChecker2D checker(costmap, effectiveLimit);
757 core::GlobalTrajectory smoothedTrajectory = toGlobalTrajectory(traj);
758 bool collisionFree = checker.check(smoothedTrajectory);
759
760 if (!collisionFree)
761 {
762 ARMARX_WARNING << "[SPFA smoothing] Initial smoothing has collisions. Running repair pass.";
763
764 if (!repairWaypoints(traj, costmap, wrapper, originalTargets, params))
765 {
766 ARMARX_ERROR << "[SPFA smoothing] Repair failed: could not project colliding waypoints "
767 "to collision-free positions within max deviation.";
768 }
769 else
770 {
771 PassWeights repairWeights{params.repair_w_obs,
772 params.repair_w_pose_smooth,
773 params.repair_w_spacing,
774 params.repair_w_tracking,
775 params.repair_w_safe_tracking};
776 runPositionPass(traj, wrapper, originalTargets, repairWeights, params, params.repair_max_iterations);
777 logResidualBreakdown(traj, wrapper, originalTargets, params, "after repair pass");
778
779 smoothedTrajectory = toGlobalTrajectory(traj);
780 collisionFree = checker.check(smoothedTrajectory);
781 }
782 }
783
784 // Last resort: pin the waypoints that have no room left and solve once more.
785 //
786 // Pinning is a poor policy but a good fallback. Applied to every request it roughly
787 // doubles the worst-case corner, because the kinks worth removing sit exactly where
788 // clearance is scarce and pinning protects them. Applied only after a request has
789 // already failed, it costs nothing on the requests that succeeded and converts most of
790 // the rest -- and a pinned, accepted path is still far straighter than the unsmoothed
791 // one it would otherwise fall back to.
792 //
793 // Deliberately outside the repair branch above: this is the response to "still
794 // colliding", regardless of whether the projection that preceded it worked.
795 if (not collisionFree and not params.use_obstacle_freeze)
796 {
797 ARMARX_WARNING << "[SPFA smoothing] Still colliding. Retrying with tight waypoints "
798 "pinned.";
799
800 // Restart from the planner's own path rather than the optimized one. Pinning
801 // decides what to hold from where the waypoints currently are, and by this point
802 // some of them are exactly the ones that moved into collision -- pinning those
803 // guarantees the retry fails. The grid path is collision-free by construction, so
804 // pinning its tight waypoints holds them somewhere valid and lets the rest move.
805 //
806 // The full pass schedule, not just the repair weights: this is a fresh attempt
807 // from the original path, so it needs the same progression from gentle to firm
808 // that the first attempt had.
809 std::vector<CenterPoint> pinned = originalTargets;
810
811 for (const PassWeights& pass : passes)
812 {
813 runPositionPass(pinned,
814 wrapper,
815 originalTargets,
816 pass,
817 params,
818 params.max_iterations,
819 /*pinTightWaypoints=*/true);
820 }
821
822 const core::GlobalTrajectory candidate = toGlobalTrajectory(pinned);
823
824 if (checker.check(candidate))
825 {
826 ARMARX_WARNING << "[SPFA smoothing] Pinned retry succeeded.";
827 traj = pinned;
828 smoothedTrajectory = candidate;
829 collisionFree = true;
830 }
831 }
832
833 const double maxDeviation = computeMaxDeviation(traj, originalTargets);
834 const bool deviationOk = maxDeviation <= params.tracking_max_deviation + 1e-3;
835
836 if (collisionFree && deviationOk)
837 {
838 ARMARX_INFO << "[SPFA smoothing] Smoothed trajectory is collision-free. Max deviation: "
839 << maxDeviation << " mm";
840 }
841 else
842 {
843 if (!collisionFree)
844 ARMARX_WARNING << "[SPFA smoothing] Smoothed trajectory still has collisions after repair.";
845 if (!deviationOk)
846 ARMARX_WARNING << "[SPFA smoothing] Max deviation too large: " << maxDeviation
847 << " mm (limit: " << params.tracking_max_deviation << " mm).";
848 }
849
850 for (int i = 0; i < static_cast<int>(traj.size()); ++i)
851 {
852 ARMARX_DEBUG << std::fixed << std::setprecision(3) << "i=" << i << " : x=" << traj[i].x
853 << " y=" << traj[i].y << " theta=" << traj[i].theta << " v=" << traj[i].v;
854 }
855
856 // Geometry check, after the collision repair so it sees the path that would actually be
857 // handed on. A fold is invisible to every residual in this optimizer -- squared distances
858 // are sign-blind and the second difference vanishes for a point placed behind its
859 // predecessor -- and it is invisible to the collision check too, so this is the only
860 // place it can be caught.
861 std::vector<std::size_t> repairedWaypoints;
862 bool geometryValid = true;
863
864 const std::vector<std::size_t> violations = findGeometryViolations(smoothedTrajectory);
865
866 if (not violations.empty())
867 {
868 ARMARX_WARNING << "[nav-guard] path-fold-detected: the smoothed path reverses "
869 "direction at "
870 << violations.size() << " waypoint(s), first at index "
871 << violations.front()
872 << ". Repairing by removing them; a fold collapses the spline tangent "
873 "downstream and stalls the reparametrization there.";
874
875 const core::GlobalTrajectory candidate =
876 repairGeometry(smoothedTrajectory, repairedWaypoints);
877
878 // Removing a waypoint creates a new segment between its neighbours, and the straight
879 // line between them can cut a corner the original path went around.
880 const bool repairedIsCollisionFree = checker.check(candidate);
881 const bool repairedIsMonotone = findGeometryViolations(candidate).empty();
882
883 if (repairedIsCollisionFree and repairedIsMonotone)
884 {
885 ARMARX_WARNING << "[nav-guard] path-fold-repaired: removed "
886 << repairedWaypoints.size() << " waypoint(s), "
887 << smoothedTrajectory.points().size() << " -> "
888 << candidate.points().size()
889 << " points, still collision-free.";
890
891 smoothedTrajectory = candidate;
892 }
893 else
894 {
895 ARMARX_WARNING << "[nav-guard] path-fold-unrepairable: removing the folded "
896 "waypoint(s) would "
897 << (repairedIsCollisionFree ? "leave the path folded"
898 : "cut a corner into an obstacle")
899 << ". Rejecting the smoothed path; the caller falls back to the "
900 "unsmoothed one.";
901
902 repairedWaypoints.clear();
903 geometryValid = false;
904 }
905 }
906
907 const core::GlobalTrajectory preprocessedTrajectory = toGlobalTrajectory(preprocessed);
908 const bool success = collisionFree && deviationOk;
909
910 return {smoothedTrajectory,
911 preprocessedTrajectory,
912 success,
913 geometryValid,
914 repairedWaypoints,
915 inputMinClearance,
916 minimumClearance(smoothedTrajectory, costmap),
917 judgeAgainstInput};
918 }
919
920} // namespace armarx::navigation::algorithms::spfa::smoothing
Differentiable wrapper around the standard 2-D distance-to-obstacle costmap.
SPFASmoothing(const core::GlobalTrajectory &trajectory, const algorithms::Costmap &costmap, const Params &params)
bool check(const core::GlobalTrajectory &trajectory, bool logDetails=false) const
const std::vector< GlobalTrajectoryPoint > & points() const
#define ARMARX_INFO
The normal logging level.
Definition Logging.h:179
#define ARMARX_ERROR
The logging level for unexpected behaviour, that must be fixed.
Definition Logging.h:194
#define ARMARX_DEBUG
The logging level for output that is only interesting while debugging.
Definition Logging.h:182
#define ARMARX_WARNING
The logging level for unexpected behaviour, but not a serious problem.
Definition Logging.h:191
#define ARMARX_VERBOSE
The logging level for verbose information.
Definition Logging.h:185
double s(double t, double s0, double v0, double a0, double j)
Definition CtrlUtil.h:33
double a(double t, double a0, double j)
Definition CtrlUtil.h:45
std::vector< CenterPoint > toCenterTrajectory(const armarx::navigation::core::GlobalTrajectory &gtraj)
Definition Smoothing.cpp:50
armarx::navigation::core::GlobalTrajectory toGlobalTrajectory(const std::vector< CenterPoint > &traj)
Definition Smoothing.cpp:32
std::vector< std::size_t > findGeometryViolations(const core::GlobalTrajectory &trajectory, const GeometryLimits &limits)
Indices of waypoints that make the path double back on itself.
core::GlobalTrajectory repairGeometry(const core::GlobalTrajectory &trajectory, std::vector< std::size_t > &removed, const GeometryLimits &limits)
Drop the offending waypoints, repeating until the path is monotone.
T obstacleBarrier(const T &d_raw, const double weight, const double max_dist, const double clearance)
Obstacle cost: a single parabola in the distance to the nearest obstacle.
Definition residuals.h:155
double trackingScaleFromClearance(double clearance, double fadeStartClearance, double fadeEndClearance)
Scale for the tracking weight, from the clearance at a waypoint's reference position.
Definition residuals.h:109
client::detail::GlobalPlanningStrategy freeze(const client::GlobalPlanningStrategy &strategy)
std::shared_ptr< Value > value()
Definition cxxopts.hpp:855
std::multimap< std::string, std::string > Params
Definition httplib.h:510
bool isfinite(const std::vector< T, Ts... > &v)
Definition algorithm.h:366
ceres::SubsetParameterization SubsetConstraint
This file is part of ArmarX.
Definition residuals.h:24
#define SET_CONSTRAINT(problem, param, constraint)
Definition residuals.h:25
double norm(const Point &a)
Definition point.hpp:102