← Back to project

Chameleon Tongue — Learning to Catch Prey

Chameleon Tongue — Learning to Catch Prey

Module: 7CCEMIAA Intelligence and Autonomy (MSc Robotics) — coursework 1, 2026.

A chameleon launches its tongue at insects with remarkable accuracy. This project models the tongue as a simple 2-DOF planar arm and uses machine learning and system identification to reproduce the targeting and dynamics behaviour.

The tongue is described by two joint variables: an angle q₁ (relative to horizontal) and a length q₂ (extension from the mouth). The tip is modelled as a point mass m. The forward kinematics are

r₁ = q₂ · cos(q₁)
r₂ = q₂ · sin(q₁)

which makes the inverse mapping (r₁, r₂) → (q₁, q₂) the interesting problem: given where an insect is, what tongue configuration should the chameleon fire?


What the project covers

Sub-task Method
1. Inverse kinematics Multi-layer perceptron (MLPRegressor) trained on observed (r, q) pairs.
2. Targeting accuracy RMSE between predicted tip locations and ground-truth insect positions.
3. Catch probability Fraction of predicted tips landing inside a 0.01 m insect radius.
4. Mass identification Linear-in-parameters regression on the manipulator dynamics equation.

Constraints from the brief: only numpy, scikit-learn, and the provided chameleon helper class are permitted — no extra libraries.


1. Inverse kinematics — neural network

The closed-form inverse is straightforward here (q₁ = atan2(r₂, r₁), q₂ = √(r₁² + r₂²)), but the brief requires a learnt model, which mirrors how a real chameleon would acquire the skill from experience.

Approach: an MLP mapping R → Q with two hidden layers, ReLU activations, Adam optimiser, and early stopping via n_iter_no_change. The non-default architecture and longer training horizon were needed because the default MLPRegressor converges far too early on this data.

model.set_params(
    hidden_layer_sizes=(1000, 100),
    max_iter=4500,
    activation='relu',
    solver='adam',
    learning_rate_init=0.001,
    random_state=1,
    tol=1e-6,
    n_iter_no_change=50,
)
model.fit(R.T, Q.T)   # rows are samples, columns are dimensions

Note on data layout: the CSVs are stored as (dim, n_samples), so they are transposed before fitting — a small but easy-to-miss detail that the brief does not state explicitly.


2. RMSE on insect targets

Accuracy is measured by sending the predicted q back through forward kinematics and comparing the resulting tip position to the requested insect location. RMSE is taken over the full target set in Rd.csv.

predictedQ = model.predict(Rd.T)
predicted_tip = np.array([cham.forward_kinematics(q) for q in predictedQ])
rmse = np.sqrt(np.mean((predicted_tip - Rd.T) ** 2))

This composition — predict → forward-kinematics → compare in task space — is more meaningful than measuring error in q directly, because joint-space errors can be magnified or shrunk by the geometry.


3. Catch probability

An insect has a radius of 0.01 m. The chameleon catches it whenever the predicted tip lands within that radius of the centre. Probability is simply the fraction of hits:

distances = np.linalg.norm(predicted_tip - Rd.T, axis=1)
probability = np.sum(distances < 0.01) / len(distances)

Why this matters separately from RMSE: RMSE squares and averages errors, so a model can have a very small RMSE but still miss the prey radius on a few outlier targets. Conversely, a noisier model that keeps every error under 1 cm catches every insect. The two metrics measure different things.


4. Mass identification from dynamics

The provided dynamics are

M(q) q̈ + C(q, q̇) + g(q) = τ

with M, C, g given in terms of the unknown mass m. Inspecting the equations, every term on the left is linear in m, which means the system can be rewritten in linear-in-parameters form:

Φ(q, q̇, q̈) · θ = τ        with θ = m

This is the standard form used in robot parameter identification — once in this form, the unknown can be recovered with ordinary least squares over all samples.

Phi_list, tau_list = [], []
for i in range(Q.shape[1]):
    q, qd, qdd, tau = Q[:, i], Qdot[:, i], Qddot[:, i], Tau[:, i]
    M, C, G = cham.get_MCG(q, qd)
    rhs = tau - C @ qd - G
    Phi_list += [q[1]**2 * qdd[0], qdd[1]]
    tau_list += [rhs[0], rhs[1]]

Phi = np.array(Phi_list).reshape(-1, 1)
mass = float(np.linalg.lstsq(Phi, np.array(tau_list), rcond=None)[0])

For a noise-free model the recovered mass matches the true 0.1 kg to several decimals.


What I took away


Files

Methodology write-up. Full solution code is not published to protect the integrity of the coursework while the module is still running.