🦀 Rust pour la robotique · temps réel

Chapitre 12
Cinématique inverse

Objectifs du chapitre

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.

FK : θ ↦ x = f(θ) (espace articulaire → cartésien)
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 :

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 :

x = L1 cos θ1 + L2 cos(θ1 + θ2)
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 :

x² + y² = L1² + L2² + 2 L1 L2 cos θ2

D'où l'expression fermée du cosinus de l'angle du coude :

cos θ2 = (x² + y² − L1² − L2²) / (2 L1 L2)

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 = ± acos( (x² + y² − L1² − L2²) / (2 L1 L2) )

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 :

θ1 = atan2(y, x) − atan2( L2 sin θ2, L1 + L2 cos θ2 )

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 :

J(θ) = ∂f/∂θ, Jij = ∂xi / ∂θj (dimension m × n)

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 :

Δx ≈ J(θ) · Δθ

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) :

Δθ = J−1 · e , θ(k+1) = θ(k) + Δθ

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 :

J+ = JT (J JT)−1 , Δθ = J+ · e

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 :

Δθ = α · JT · e , α > 0 (petit)

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 :

Δθ = JT (J JT + λ² I)−1 · e

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

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