Chapitre 12
Cinématique inverse
Objectifs du chapitre
- Comprendre ce que résout la cinématique inverse (IK) et pourquoi elle est plus difficile que la cinématique directe.
- Dériver et implémenter la solution analytique exacte du bras planaire 2R (coude haut / coude bas, domaine d'atteignabilité).
- Maîtriser la méthode numérique générale par la jacobienne : Newton, pseudo-inverse, transposée.
- Traiter les singularités par amortissement (moindres carrés amortis / Levenberg–Marquardt).
- Écrire une IK numérique générique en Rust avec
nalgebra, robuste et bornée pour le temps réel.
1. Définition et difficultés
La cinématique directe (FK, chapitre 11) part des angles articulaires θ = (θ1, …, θn) et calcule la pose de l'effecteur x = f(θ) dans l'espace cartésien. La cinématique inverse (IK) résout le problème réciproque : étant donné une pose cible xc, trouver les angles θ tels que f(θ) = xc.
IK : xc ↦ θ = f−1(xc) (espace cartésien → articulaire)
C'est l'IK qu'on utilise en pratique : on veut placer une pince à un endroit précis, on ne connaît pas d'avance les angles moteurs. Mais l'IK est fondamentalement plus dure que la FK :
- Solutions multiples. Plusieurs configurations articulaires atteignent la même pose (coude « haut » et coude « bas », rotations à 2π près). f n'est pas injective.
- Pas toujours de solution. Si la cible est hors de l'espace de travail (trop loin, trop près), f−1 n'existe pas.
- Singularités. À certaines configurations, le bras perd des degrés de liberté instantanés : le problème devient mal conditionné et les vitesses articulaires explosent.
- Non-linéarité. f contient des sinus/cosinus ; il n'existe pas de formule fermée pour un bras arbitraire à 6+ axes.
Deux familles de méthodes : les solutions analytiques (formules fermées, exactes, rapides, mais spécifiques à une géométrie) et les solutions numériques (itératives, générales, basées sur la jacobienne).
2. Solution analytique du bras planaire 2R
Considérons un bras plan à deux articulations rotoïdes (2R), de longueurs de segments L1 et L2, avec les angles θ1 (base) et θ2 (coude). La cinématique directe donne la position de l'effecteur :
y = L1 sin θ1 + L2 sin(θ1 + θ2)
2.1 Résolution de θ2
En additionnant les carrés de x et y et en utilisant cos²+sin²=1 ainsi que cos(a−b)=cos a cos b + sin a sin b, les termes croisés se simplifient en cos θ2 :
D'où l'expression fermée du cosinus de l'angle du coude :
La cible est atteignable si et seulement si −1 ≤ cos θ2 ≤ 1, c'est-à-dire si la distance radiale r = √(x²+y²) vérifie |L1−L2| ≤ r ≤ L1+L2. L'espace de travail est un anneau (couronne) de rayon extérieur L1+L2 et de rayon intérieur |L1−L2|.
Il en découle deux solutions symétriques (le signe du sinus n'est pas déterminé par le cosinus) :
- θ2 > 0 : configuration coude bas (une des deux « équerres »).
- θ2 < 0 : configuration coude haut, image miroir par rapport à la ligne base→effecteur.
Les deux atteignent exactement la même pose (x, y) : c'est l'illustration concrète de la non-unicité de l'IK. En bordure d'espace de travail (r = L1+L2, bras tendu), cos θ2 = 1, les deux solutions fusionnent en θ2=0 : c'est une singularité.
2.2 Résolution de θ1
Une fois θ2 connu, on écrit (x,y) sous forme
x = k1 cos θ1 − k2 sin θ1,
y = k1 sin θ1 + k2 cos θ1 avec
k1 = L1 + L2 cos θ2 et
k2 = L2 sin θ2. Cela se résout proprement avec atan2 :
Toujours utiliser atan2(y, x) et jamais atan(y/x) : atan2 tient compte
des signes des deux composantes et couvre les quatre quadrants sans division par zéro. Le choix
du signe de θ2 propage automatiquement le bon θ1
via le terme k2 = L2 sin θ2.
3. Méthode numérique générale : la jacobienne
Pour un bras quelconque (plus de 2 axes, 3D, orientations), on n'a pas de formule fermée. On linéarise la FK autour de la configuration courante. La matrice jacobienne J(θ) collecte les dérivées partielles de la pose par rapport aux angles :
où m est la dimension de la pose (2 en planaire, 6 en 3D avec orientation) et n le nombre d'articulations. Au premier ordre, un petit déplacement articulaire produit un petit déplacement cartésien :
L'IK numérique inverse cette relation. On part d'une estimation θ(0), on calcule l'erreur de pose e = xc − f(θ), on résout pour un pas Δθ qui réduit l'erreur, on met à jour, on répète.
3.1 Newton / inverse exacte (J carrée)
Si J est carrée et inversible (m = n) :
3.2 Pseudo-inverse (redondance, non carré)
Pour m ≠ n (bras redondant n > m, ou sur-contraint), on utilise la pseudo-inverse de Moore–Penrose. Dans le cas redondant on prend la pseudo-inverse à droite, qui fournit le pas de norme minimale :
3.3 Méthode de la transposée
Astuce peu coûteuse : remplacer l'inverse par la transposée mise à l'échelle. Il n'y a aucune inversion matricielle, donc pas d'explosion aux singularités, au prix d'une convergence plus lente :
On peut montrer que JT e est une direction de descente pour ½‖e‖² tant que e ≠ 0 et J non nulle : la méthode diminue l'erreur de façon monotone pour un α assez petit.
4. Singularités et amortissement
Une singularité survient quand J perd son rang : det(J JT) → 0. Physiquement, l'effecteur ne peut plus se déplacer instantanément dans certaines directions (bras tendu, alignement d'axes). Numériquement, (J JT)−1 explose : un déplacement cartésien minuscule exige des vitesses articulaires énormes — dangereux sur un robot réel.
La parade standard est la méthode des moindres carrés amortis (Damped Least Squares, DLS), équivalente à un pas de Levenberg–Marquardt : on ajoute un terme de régularisation λ² qui borne l'amplitude du pas près des singularités :
Loin des singularités et pour λ → 0, on retrouve la pseudo-inverse (précision maximale). Près d'une singularité, λ² I garde la matrice inversible et lisse la solution (robustesse, au prix d'une légère erreur résiduelle). En pratique on choisit λ petit et fixe, ou adaptatif selon le conditionnement de J.
DLS interpole entre Newton (rapide mais fragile) et la transposée (lent mais stable). Le paramètre λ est le curseur : petit = précis/agressif, grand = stable/prudent. C'est le compromis clé pour un robot réel.
5. Implémentation Rust
5.1 IK analytique du 2R
On renvoie une Option<(f64, f64)> : None si la cible est hors d'atteinte.
On calcule le coude bas (θ2 ≥ 0) ; le coude haut s'obtient en niant
θ2 et en recalculant θ1.
/// IK analytique d'un bras planaire 2R (configuration coude bas, theta2 >= 0).
/// Renvoie `Some((theta1, theta2))` en radians, ou `None` si (x, y) est
/// hors de l'espace de travail atteignable.
fn ik_2r_analytique(x: f64, y: f64, l1: f64, l2: f64) -> Option<(f64, f64)> {
let r2 = x * x + y * y;
// cos(theta2) selon la loi des cosinus.
let cos_t2 = (r2 - l1 * l1 - l2 * l2) / (2.0 * l1 * l2);
// Domaine d'atteignabilité : cos(theta2) doit rester dans [-1, 1].
// Une petite tolerance absorbe les erreurs d'arrondi sur la frontiere.
const EPS: f64 = 1e-9;
if cos_t2 < -1.0 - EPS || cos_t2 > 1.0 + EPS {
return None;
}
// Clamp defensif AVANT acos : sans lui, acos(1.0000000002) => NaN.
let cos_t2 = cos_t2.clamp(-1.0, 1.0);
// Coude bas : on prend la racine positive.
let theta2 = cos_t2.acos();
// theta1 = atan2(y, x) - atan2(k2, k1)
let k1 = l1 + l2 * theta2.cos();
let k2 = l2 * theta2.sin();
let theta1 = y.atan2(x) - k2.atan2(k1);
Some((theta1, theta2))
}
/// Variante renvoyant les deux solutions (coude bas, coude haut) quand elles existent.
fn ik_2r_deux_solutions(
x: f64,
y: f64,
l1: f64,
l2: f64,
) -> Option<[(f64, f64); 2]> {
let (t1_bas, t2_bas) = ik_2r_analytique(x, y, l1, l2)?;
// Coude haut : theta2 negatif, theta1 recalcule avec le signe oppose.
let t2_haut = -t2_bas;
let k1 = l1 + l2 * t2_haut.cos();
let k2 = l2 * t2_haut.sin();
let t1_haut = y.atan2(x) - k2.atan2(k1);
Some([(t1_bas, t2_bas), (t1_haut, t2_haut)])
}
Piège numérique. Même quand la cible est atteignable, les arrondis flottants peuvent
produire cos θ2 = 1.0000000002. acos de cette valeur
renvoie NaN, qui contamine ensuite tous les calculs sans lever d'erreur. On clampe
systématiquement l'argument dans [-1.0, 1.0] avant acos. Et l'appelant
doit toujours gérer le None : ne jamais unwrap() aveuglément sur une cible fournie
par un utilisateur ou une trajectoire.
5.2 IK numérique générique avec nalgebra
On implémente une boucle de Newton amortie (DLS) générique en dimension 2. La jacobienne est fournie par une closure — ici la jacobienne analytique du 2R — mais on donne aussi une version par différences finies pour les cas où la dérivée exacte n'est pas disponible.
use nalgebra::{Matrix2, Vector2};
/// Cinematique directe du 2R : angles -> position (x, y).
fn fk_2r(theta: &Vector2, l1: f64, l2: f64) -> Vector2 {
let (t1, t2) = (theta[0], theta[1]);
Vector2::new(
l1 * t1.cos() + l2 * (t1 + t2).cos(),
l1 * t1.sin() + l2 * (t1 + t2).sin(),
)
}
/// Jacobienne analytique 2x2 du 2R : J[i][j] = d(pos_i) / d(theta_j).
fn jacobienne_2r(theta: &Vector2, l1: f64, l2: f64) -> Matrix2 {
let (t1, t2) = (theta[0], theta[1]);
let s1 = t1.sin();
let c1 = t1.cos();
let s12 = (t1 + t2).sin();
let c12 = (t1 + t2).cos();
// dx/dt1, dx/dt2
// dy/dt1, dy/dt2
Matrix2::new(
-l1 * s1 - l2 * s12, -l2 * s12,
l1 * c1 + l2 * c12, l2 * c12,
)
}
/// Jacobienne par differences finies (centrees), utile quand la FK est une boite noire.
fn jacobienne_num(f: F, theta: &Vector2, h: f64) -> Matrix2
where
F: Fn(&Vector2) -> Vector2,
{
let mut j = Matrix2::zeros();
for col in 0..2 {
let mut tp = *theta;
let mut tm = *theta;
tp[col] += h;
tm[col] -= h;
let d = (f(&tp) - f(&tm)) / (2.0 * h); // colonne = d(pos) / d(theta_col)
j.set_column(col, &d);
}
j
}
/// Resultat d'une resolution IK numerique.
struct IkResult {
theta: Vector2,
converge: bool,
iterations: usize,
erreur: f64,
}
/// IK numerique par moindres carres amortis (Levenberg-Marquardt / DLS).
///
/// - `cible` : pose cartesienne visee.
/// - `theta0` : estimation initiale (warm start).
/// - `lambda` : facteur d'amortissement (stabilite pres des singularites).
/// - `tol` : critere d'arret sur la norme de l'erreur de pose.
/// - `max_iter` : borne dure sur le nombre d'iterations (temps reel).
fn ik_numerique(
cible: Vector2,
theta0: Vector2,
l1: f64,
l2: f64,
lambda: f64,
tol: f64,
max_iter: usize,
) -> IkResult {
let mut theta = theta0;
let ident = Matrix2::identity();
for k in 0..max_iter {
let e = cible - fk_2r(&theta, l1, l2); // erreur de pose
let err_norm = e.norm();
// Critere d'arret : on est assez proche de la cible.
if err_norm < tol {
return IkResult { theta, converge: true, iterations: k, erreur: err_norm };
}
let j = jacobienne_2r(&theta, l1, l2);
// Delta theta = J^T (J J^T + lambda^2 I)^-1 e (DLS)
// En dimension carree on peut aussi tenter J^-1 directement, mais
// (J J^T + lambda^2 I) est toujours inversible : plus robuste.
let jjt = j * j.transpose() + ident * (lambda * lambda);
let delta = match jjt.try_inverse() {
Some(inv) => j.transpose() * inv * e,
None => {
// Repli extreme : methode de la transposee (jamais singuliere).
j.transpose() * e * 0.1
}
};
theta += delta;
}
// Borne d'iterations atteinte : on renvoie la meilleure estimation courante.
let erreur = (cible - fk_2r(&theta, l1, l2)).norm();
IkResult { theta, converge: false, iterations: max_iter, erreur }
}
⏱ Encart temps réel
- Bornez les itérations. Un solveur IT sur boucle de contrôle doit terminer dans le budget
de la période (p. ex. 1 ms à 1 kHz).
max_iterest une garantie de terminaison, pas un accident : on renvoie la meilleure estimation même non convergée, jamais une boucle infinie. - Warm start. Réutilisez la solution de l'itération de contrôle précédente comme
theta0. Entre deux cycles la cible bouge peu : on converge souvent en 1–2 itérations, et on reste sur la même branche (coude haut/bas cohérent, pas de saut brutal d'articulation). - Amortissement pour la stabilité. Un λ non nul évite les commandes de vitesse explosives près des singularités — un pic de couple peut endommager l'actionneur ou déclencher un arrêt de sécurité.
- Pas d'allocation dans la boucle.
nalgebraavec des types de taille fixe (Matrix2,Vector2) reste sur la pile : aucune allocation tas, comportement temporel déterministe.
Exercices
Exercice 1 — IK analytique avec gestion du domaine
Implémentez ik_2r_analytique(x, y, l1, l2) -> Option<(f64, f64)>. Renvoyez
None si la cible est hors de l'anneau atteignable, clampez l'argument de acos,
et utilisez atan2. Testez avec L1=L2=1 sur une
cible atteignable et une cible trop lointaine.
Voir la solution
fn ik_2r_analytique(x: f64, y: f64, l1: f64, l2: f64) -> Option<(f64, f64)> {
let r2 = x * x + y * y;
let cos_t2 = (r2 - l1 * l1 - l2 * l2) / (2.0 * l1 * l2);
// Hors d'atteinte si |cos_t2| > 1 (au-dela de la tolerance).
if cos_t2.abs() > 1.0 + 1e-9 {
return None;
}
// Clamp obligatoire avant acos (sinon NaN aux frontieres).
let theta2 = cos_t2.clamp(-1.0, 1.0).acos(); // coude bas
let k1 = l1 + l2 * theta2.cos();
let k2 = l2 * theta2.sin();
let theta1 = y.atan2(x) - k2.atan2(k1);
Some((theta1, theta2))
}
fn main() {
// Cible atteignable (r = sqrt(2) dans [0, 2]).
println!("{:?}", ik_2r_analytique(1.0, 1.0, 1.0, 1.0)); // Some(...)
// Cible trop lointaine (r = 3 > L1 + L2 = 2).
println!("{:?}", ik_2r_analytique(3.0, 0.0, 1.0, 1.0)); // None
}
Le clamp protège contre le NaN de acos ; le test
abs() > 1 distingue proprement l'infaisabilité (retour None) de la simple
erreur d'arrondi sur la frontière.
Exercice 2 — Vérification par composition avec la FK
En réutilisant fk_2r du chapitre 11, vérifiez que
FK(IK(x, y)) ≈ (x, y) pour un échantillon de cibles atteignables. C'est le test
de cohérence fondamental : l'IK doit être un inverse à droite de la FK.
Voir la solution
fn fk_2r_xy(t1: f64, t2: f64, l1: f64, l2: f64) -> (f64, f64) {
(
l1 * t1.cos() + l2 * (t1 + t2).cos(),
l1 * t1.sin() + l2 * (t1 + t2).sin(),
)
}
fn main() {
let (l1, l2) = (1.5, 1.0);
let cibles = [(1.0, 0.5), (0.6, 1.2), (-0.8, 0.9), (2.0, 0.0)];
for &(x, y) in &cibles {
match ik_2r_analytique(x, y, l1, l2) {
Some((t1, t2)) => {
let (xr, yr) = fk_2r_xy(t1, t2, l1, l2);
let err = ((xr - x).powi(2) + (yr - y).powi(2)).sqrt();
assert!(err < 1e-9, "erreur de round-trip trop grande : {err}");
println!("cible ({x}, {y}) -> theta ({t1:.4}, {t2:.4}), err={err:.2e}");
}
None => println!("cible ({x}, {y}) hors d'atteinte"),
}
}
}
Chaque round-trip revient au point de départ à la précision machine près. Si l'assertion casse, c'est
typiquement un atan2 mal orienté ou un signe erroné dans la formule de
θ1.
Exercice 3 — Jacobienne 2×2 du bras 2R
Dérivez à la main puis implémentez la jacobienne J = ∂(x, y)/∂(θ1, θ2). Vérifiez-la contre une jacobienne par différences finies, et identifiez la configuration singulière (det J = 0).
Voir la solution
use nalgebra::{Matrix2, Vector2};
fn jacobienne_2r(theta: &Vector2, l1: f64, l2: f64) -> Matrix2 {
let (t1, t2) = (theta[0], theta[1]);
let s1 = t1.sin(); let c1 = t1.cos();
let s12 = (t1 + t2).sin(); let c12 = (t1 + t2).cos();
Matrix2::new(
-l1 * s1 - l2 * s12, -l2 * s12,
l1 * c1 + l2 * c12, l2 * c12,
)
}
fn main() {
let (l1, l2) = (1.0, 1.0);
let theta = Vector2::new(0.7, 0.4);
// Verification par differences finies centrees.
let h = 1e-6;
let fk = |th: &Vector2| Vector2::new(
l1 * th[0].cos() + l2 * (th[0] + th[1]).cos(),
l1 * th[0].sin() + l2 * (th[0] + th[1]).sin(),
);
let mut j_num = Matrix2::zeros();
for col in 0..2 {
let (mut tp, mut tm) = (theta, theta);
tp[col] += h; tm[col] -= h;
j_num.set_column(col, &((fk(&tp) - fk(&tm)) / (2.0 * h)));
}
let j_ana = jacobienne_2r(&theta, l1, l2);
println!("ecart max = {:.2e}", (j_ana - j_num).abs().max());
// Singularite : det(J) = l1 * l2 * sin(t2). Nul quand t2 = 0 ou pi (bras
// tendu ou replie) -> le determinant s'annule, J n'est plus inversible.
let sing = Vector2::new(0.7, 0.0);
println!("det a t2=0 : {:.3}", jacobienne_2r(&sing, l1, l2).determinant());
}
On a det J = L1 L2 sin θ2 : la singularité du 2R est exactement θ2 = 0 (bras tendu) ou θ2 = π (bras replié), où l'effecteur ne peut plus se déplacer radialement.
Exercice 4 — Boucle d'IK numérique qui converge
Écrivez une boucle d'IK numérique (Newton amorti) qui, partant d'une estimation initiale, converge vers une cible atteignable. Affichez la norme de l'erreur à chaque itération pour observer la convergence (quadratique près de la solution quand λ est petit).
Voir la solution
use nalgebra::{Matrix2, Vector2};
fn fk_2r(theta: &Vector2, l1: f64, l2: f64) -> Vector2 {
Vector2::new(
l1 * theta[0].cos() + l2 * (theta[0] + theta[1]).cos(),
l1 * theta[0].sin() + l2 * (theta[0] + theta[1]).sin(),
)
}
fn jacobienne_2r(theta: &Vector2, l1: f64, l2: f64) -> Matrix2 {
let (t1, t2) = (theta[0], theta[1]);
Matrix2::new(
-l1 * t1.sin() - l2 * (t1 + t2).sin(), -l2 * (t1 + t2).sin(),
l1 * t1.cos() + l2 * (t1 + t2).cos(), l2 * (t1 + t2).cos(),
)
}
fn main() {
let (l1, l2) = (1.0, 1.0);
let cible = Vector2::new(1.2, 0.8);
let mut theta = Vector2::new(0.1, 0.1); // estimation initiale
let (lambda, tol, max_iter) = (1e-3, 1e-10, 50);
let ident = Matrix2::identity();
for k in 0..max_iter {
let e = cible - fk_2r(&theta, l1, l2);
let err = e.norm();
println!("iter {k:2} : ||e|| = {err:.3e}");
if err < tol {
println!("converge ! theta = ({:.4}, {:.4})", theta[0], theta[1]);
break;
}
let j = jacobienne_2r(&theta, l1, l2);
let jjt = j * j.transpose() + ident * (lambda * lambda);
// DLS : robuste meme si J devient mal conditionnee.
let delta = j.transpose() * jjt.try_inverse().unwrap() * e;
theta += delta;
}
}
La norme de l'erreur chute très vite (chaque itération environ carre l'erreur précédente tant qu'on est
loin d'une singularité). Le terme λ²I garantit que
try_inverse().unwrap() ne panique jamais, même si l'estimation traverse une configuration
proche de θ2=0.
Récapitulatif
- L'IK va du cartésien vers l'articulaire ; plus dure que la FK : solutions multiples, singularités, pas toujours de solution.
- 2R analytique : cos θ2 = (x²+y²−L1²−L2²)/(2L1L2), θ2 = ±acos(…) (coude haut/bas), puis θ1 = atan2(y,x) − atan2(L2sin θ2, L1+L2cos θ2). Atteignable si |L1−L2| ≤ r ≤ L1+L2.
- Numérique : linéarisation Δx = J·Δθ ; Newton Δθ = J−1e, pseudo-inverse J+ = JT(JJT)−1, transposée Δθ = αJTe.
- Singularités : det(JJT)→0 ; amortir avec DLS/Levenberg–Marquardt Δθ = JT(JJT + λ²I)−1e.
- Temps réel : borner
max_iter, warm start avec la solution précédente, amortissement pour la stabilité, types de taille fixe (pas d'allocation). - Pièges : clamper avant
acos(NaN), gérerNone, convergence non garantie — toujours renvoyer un statut.