micropython-samples/QUATERNIONS.md

470 wiersze
19 KiB
Markdown

# 1. 3D Rotation (quaternions without the maths)
This repo contains a `Quaternion` class and a rudimentary 3D vector graphics
module. The aim is to present quaternions as an efficient way to manipulate 3d
objects and data from inertial measurement units (IMUs). As such they have
applications in 3D graphics and robotics.
Quaternions have a reputation for being mathematically difficult. This repo
aims to make them usable with no mathematics beyond a basic grasp of Cartesian
(x, y, z) coordinates. They are actually easier to use than the traditional
Euler angles (heading, pitch and roll), and avoid the mathematical problems
associated with Euler angles.
The following fragment shows a classic demo where a wireframe cube rotates to
match rotations of an IMU:
```python
# dobj is a display list, imu is a BNo055
# Create a unit cube, scale to 0.8 and move to centre on origin
cube = Cube(RED, BLUE, GREEN) * ( 0.8, 0.8, 0.8) - (0.4, 0.4, 0.4)
rot = Rotator() # Rotation quaternion
while True:
rot[:] = imu.quaternion() # Read IMU and assign to Rotator
dobj['cube'] = cube @ rot # Rotate cube to match IMU
dobj.show() # Display on screen
time.sleep(0.1)
```
In this instance the IMU (a Bosch BNO055) produces quaternion data. Similar
data may be computed from other IMUs by means of the
[sensor fusion module](https://github.com/micropython-IMU/micropython-fusion).
Section 2 of this doc aims to describe basic usage of the repo with an absolute
minimum of mathematics. Section 3 provides more detail for those wishing to
expand the usage of quaternions beyond basic 3D transformations.
The 3D graphics is of demo quality: it is not a serious attempt at a graphics
library. Its speed demonstrates that a 3D graphics library should be written in
C for adequate performance. It also has known limitations and shortcomings. Its
purpose is to prove and demonstrate the quaternion handling: all graphics
elements use quaternions in their definitions.
# 2 Introduction
The graphics module enables various wireframe objects to be rendered from a
"camera" view with an illusion of perspective. Objects may be moved, scaled,
distorted and rotated using arithmetic operators. Thus if `cube` is an instance
of a `Cube`,
`cube + (0, 0.2, 0)` will move it along the Y axis.
`cube * (1, 0.2, 0.2)` will turn it into a cuboid.
`cube @ Rotator(0.1, 1, 0, 0)` will rotate it around the X axis by 0.1 radians.
Any axis may be specified.
I wrote this to experiment with and test `quat.py` rather than as a serious
attempt at 3D graphics. It is written in Python so speed is an issue. Realtime
display of orientation is feasible. Playable games aren't. A C port would be
awesome.
## 2.1 Files
The library provides the following files which should be copied to the target's
filesystem.
Core files:
1. `quat.py` Provides classes for creating and rotating points.
2. `graph3d.py` Provides wireframe 3D graphics classes.
Setup files. There should be a file `setup3d.py` defining graphics and
(optionally) IMU hardware:
1. `setup3d.py` Assumes an SSD1351 OLED display and BNo055 IMU.
2. `setup3d_lcd160cr` Setup file for the official LCD. On the target rename to
`setup3d.py` to use. Uncomment the BNo055 code if required.
Demo scripts:
1. `test3d.py` Test script displays various moving objects.
2. `test_imu.py` Wireframe cube moves to match IMU data.
Test:
1. `quat_test.py` Unit test for `quat.py`. Run under Unix build.
## 2.2 Display hardware
The `setup3d.py` script assumes an SSD1351 128*128 bit display such as
[this one](https://www.adafruit.com/product/1431) - it may be adapted for other
displays. The driver is [here](https://github.com/peterhinch/micropython-nano-gui/blob/master/drivers/ssd1351/ssd1351_16bit.py)
The setup file must define the following data values:
1. Color values in a format suitable for its `line` and `fill` functions (see
below).
2. `DIMENSION = 64` This defines the number of pixels moved by a value of 1.0.
By default a 128*128 pixel space represents values defined by -1.0 <= v <= 1.0.
3. `imu` Required for `test_imu`. An inertial measuring unit. It must have a
`quaternion` method returning `w`, `x`, `y`, `z`. It may also have an `euler`
method returning `heading`, `roll`, `pitch`. If `euler` is unsupported comment
out the line in the test script that reports on those values.
The setup file must define these functions:
1. `fill(color)` Clear the display when called with `BLACK`.
2. `line(xs, ys, xe, ye, color)` Draw a line with args specified in absolute
pixels.
3. `show()` Display all lines.
By default the `imu` object is a BNo055 however it can be created for other IMU
types by means of the
[sensor fusion module](https://github.com/micropython-IMU/micropython-fusion)
which provides access to quaternion, heading, pitch and roll.
The `test_imu` rotating cube demo uses the
[Adafruit BNO055 breakout](https://www.adafruit.com/product/2472). The driver
comprises these files:
[bno055.py](https://github.com/micropython-IMU/micropython-bno055/blob/master/bno055.py)
and [bno055_base.py](https://github.com/micropython-IMU/micropython-bno055/blob/master/bno055_base.py).
The `test3d.py` script ends by drawing a moving sphere. This is RAM-intensive
and will cause a memory error unless run on a Pyboard D.
## 2.3 Getting started
The module `quat.py` provides functions producing two types of object:
1. A `Point` representing a point (x, y, z) in 3D space.
2. A `Rotator` representing an axis and an angle of rotation.
The following fragment instantiates a point and rotates it 45° around the X
axis:
```python
from quat import Point, Rotator
from math import pi
point = Point(0, 0, 1)
rot = Rotator(pi/4, 1, 0, 0) # Rotate 45° around x axis
point @= rot # perform the rotation in-place
```
This can be extended to wireframe shapes, which can also be moved and scaled.
Moving is done by adding a 3-tuple representing `(dx, dy, dz)` distances to
move. Scaling may be done by multiplying by a value to make the object larger
or smaller while preserving its shape. Alternatively the object can be
multiplied by a 3-tuple to scale it differently along each axis:
```python
from quat import Rotator
from math import pi
from graph3d import Cube, DisplayDict, Axes
from setup3d import * # Hardware setup and colors
# Objects for display are put in a `DisplayDict` which specifies the camera
# location and distance for the perspective view
dobj = DisplayDict(setup(), pi/6, 5)
dobj['axes'] = Axes(WHITE) # Show x, y, z axes
# By default the cube is from the origin to (1.0, 1.0, 1.0). Scale it
# to length of side 0.8 and centre it at origin
cube = Cube(RED, BLUE, GREEN) * (0.8, 0.8, 0.8) - (0.4, 0.4, 0.4)
# Rotate 45° around an axis from (0, 0, 0) to (1, 1, 1)
rot = Rotator(pi/4, 1, 1, 1)
dobj['cube'] = cube @ rot
dobj.show() # Update the physical display
```
## 2.4 The quat module
This provides the following:
1. `Point` Represents a point in space. Constructor takes `x`, `y` and `z`
floating point args where `-1.0 <= arg <= 1.0`.
2. `Rotator` An object which can rotate `Point` instances around an axis. The
axis is defined by a line from the origin to a point. Constructor args:
`theta=0, x=0, y=0, z=0` where `theta` is the amount of rotation in radians.
3. `Euler` Returns a `Rotator` defined by Euler angles. Constructor args:
`heading, pitch, roll` specified in radians.
## 2.5 The 3D graphics module
Code in this module uses `Point` and `Rotator` instances to create graphics
primitives. Constructors create unit sized objects at a fixed location: objects
may be scaled, distorted, moved or rotated prior to display.
In all cases `color` is a color value defined in `setup3d.py`.
The module provides the following objects:
1. `Line` Constructor args: `p0, p1, color` where `p0` and `p1` are `Point`
instances.
2. `Axes` Draws the three axes. Constructor arg: `color`.
3. `Square` Draws a unit square located in the +ve quadrant. Arg: `color`.
4. `Circle` Unit circle centred on origin. Args: `color`, `segments=12`.
5. `Sphere` Unit sphere centred on origin. Args: `color`, `segments=12`.
6. `Cone` Unit cone with apex at origin. Args: `color`, `segments=12`.
7. `Cube` Unit cube in +ve quadrant. Args: `color, front=None, sides=None`.
These represent color values. If color values are passed it allows front, back
and sides to have different colors enabling orientation to readily be seen.
8. `DisplayDict` A dictionary of objects for display.
The `Sphere` object is demanding of RAM. I have only succeeded in displaying it
on Pyboard D hosts (SF2 and SF6).
### 2.5.1 The DisplayDict class
This enables objects to be transformed or deleted at run time, and provides for
display refresh.
Constructor:
This takes the following args: `ssd, angle, distance`. `ssd` is the display
instance. The other args specify the camera angle and distance. Typical values
are pi/6 and 5.
Method:
1. `show` Clears and refreshes the display.
# 3. Quaternions
The mathematics of quaternions are interesting. I don't propose to elaborate on
it here as there are many excellent online texts and demonstrations. The ones I
found helpful are listed [below](./quaternions.md#Appendix 1: references). The
following summarises the maths necessary to read the rest of this section; it
uses Python notation rather than trying to represent mathematical equations.
A quaternion comprises four numbers `w, x, y, z`. These define an instance
`q = w + x*i + y*j + z*k` where `i, j, k` are square roots of -1 as per
Hamilton's equation:
`i**2 == j**2 == k**2 == i*j*k == -1`
A quaternion's `w` value is its scalar part, with `x`, `y` and `z` defining its
vector part. A quaternion with `w == 0` is a vector quaternion and is created
by the `Vector` or `Point` constructors in the code. It may be regarded as a
point in space or a vector between the origin and that point, depending on
context.
Quaternions have a magnitude represented by
`magnitude = sqrt(sum((w*w + x*x + y*y + z*z)))` This may be accessed by
issuing `magnitude = abs(q)`.
A quaternion whose vector part is 0 (i.e. x, y, z are 0) is a scalar. If w is 1
it is known as a unit quaternion.
A quaternion with a nonzero vector part and a magnitude of 1 is a rotation
quaternion. These may be produced by the `Rotator` constructor. If its `x y z`
values are considered as a vector starting at the origin, `w` represents the
amount of rotation about that axis.
Multiplication of quaternions is not commutative: in general
`q1 * q2 != q2 * q1`
Multiplication of quaternions produces a Hamilton product.
Quaternions have a conjugate analogous to the conjugate of a complex. If `q` is
a quaternion its conjugate may be found with:
`conj = Quaternion(q.w, -q.x, -q.y, -q.z)` In code `conj = q.conjugate()`.
A `Point` (vector quaternion) `P` may be rotated around an arbitrary axis by
defining a rotation quaternion `R` and performing the following
`P = R * P * conjugate(P)`
This is performed using the matmul `@` operator, e.g.:
`P @= R`
The use of this operator allows rotations to be used in arithmetic expressions.
Division of quaternions is covered in [section 3.9](./QUATERNION.md#39-division).
## 3.1 The Quaternion class
This uses Python special ("dunder" or magic) methods to enable arithmetic and
comparison operators and to support the iterator protocol.
```python
q = Quaternion(5, 6, 7, 8)
q2 = q * 2
q2 = 2 * q # See NOTE on operand ordering
print(q * 2 + (20, 30, 40))
```
Special constructors are provided to produce vector and rotation quaternions,
with the class constructor enabling arbitrary quaternions to be created.
NOTE: Unless firmware is compiled with `MICROPY_PY_REVERSE_SPECIAL_METHODS`
arithmetic expressions should have the quaternion on the left hand side. Thus
`q * 2` will work, but `2 * q` will throw an exception on such builds. See
[build notes](./QUATERNION.md#313-build-notes).
## 3.2 Quaternion constructors
There are constructors producing vector and rotation quaternions. In addition
the actual class constructor enables any quaternion to be instantiated.
### 3.2.1 Vector or Point
These take 3 args `x, y, z`. The constructor returns a vector quaternion with
`w == 0`.
### 3.2.2 Rotator
This takes 4 args `theta=0, x=0, y=0, z=0`. It returns a rotation quaternion
with magnitude 1. The vector from the origin to `x, y, z` is the axis of
rotation and `theta` is the angle in radians.
### 3.2.3 Euler
This takes 3 args `heading, pitch, roll` and returns a rotation quaternion. It
aims to be based on Tait-Bryan angles, however this implementation assumes that
positive `z` is upwards: some assume it is downwards. Euler angles have more
standards than you can shake a stick at:
[Euler angles are horrible](https://github.com/moble/quaternion/wiki/Euler-angles-are-horrible).
### 3.2.4 The Quaternion constructor
This takes four args `w=1, x=0, y=0, z=0`. The default is the unit quaternion.
## 3.3 Quaternion properties
Quaternion instances support `w, x, y, z` read/write properties.
## 3.4 Iterator protocol
Quaternion instances support this. Thus
```python
q = Quaternion(5, 6, 7, 8)
print(q[1:]) # Prints the vector part array('f', [6.0, 7.0, 8.0])
q[1:] = (9, 10, 11)
print(q[1:]) # array('f', [9.0, 10.0, 11.0])
for x in q:
print(x) # It is possible to iterate over the elements
```
## 3.5 abs() len() and str()
```python
q = Quaternion(5, 6, 7, 8)
abs(q) # Returns its magnitude 13.19090595827292
len(q) # Always 4
str(q) # 'w = 5.00 x = 6.00 y = 7.00 z = 8.00'
```
## 3.6 Comparison operators
Two `Quaternion` instances are equal if all their `w, x, y, z` values match.
Otherwise their maginitudes are compared. Thus `q1 == q2` tests for exact
equality. `q1 >= q2` will return `True` if they are exactly equal or if
`abs(q1) > abs(q2)`.
The test for "exact" equality is subject to the issues around comparison of
floating point numbers. The `math.isclose` method is used with a value of 0.001
(0.1%). This may be adjusted (`mdelta` and `adelta` variables).
## 3.7 Addition and subtraction
A `Quaternion` instance may have the following types added to it or subtracted
from it, resulting in a new `Quaternion` instance being returned.
1. Another Quaternion.
2. A scalar. This modifies the `w` value.
3. A 4-tuple or list: modifies `w, x, y, z`.
4. A 3-tuple or list: modifies the vector part `x, y, z` and sets the scalar
part `w` to zero returning a vector quaternion. Intended for moving `Point`
instances.
The typical application of addition of a 3-tuple is the movement of a `Point`.
## 3.8 Multiplication
A `Quaternion` instance may be mutliplied by the following types resulting in a
new `Quaternion` instance being returned.
1. Another Quaternion. Returns the Hamilton product. Multiplication is not
commutative.
2. A scalar. This multiplies `w, x, y, z`.
3. A 4-tuple or list: multiplies `w, x, y, z` by each element.
4. A 3-tuple or list: multiplies the vector part `x, y, z` by each element and
sets the scalar part `w` to zero returning a vector quaternion. Intended for
scaling `Point` instances.
Multiplication by a scalar or a tuple is commutative. If a list of points
comprises a shape, multiplication by a scalar can be used to resize the shape.
Multiplication by a 3-tuple can transform a shape, for example changing a cube
to a cuboid.
## 3.9 Division
It is possible to divide a scalar by a quaternion: `1/q` produces the inverse
of quaternion `q`. This may also be acquired by issuing `q.inverse()`. The
following statements are equivalent:
```python
q1 = (1, 2, 3) / Quaternion(4, 5, 6, 7)
q2 = Quaternion(4, 5, 6, 7).inverse() * (1, 2, 3)
```
Note that multiplication of a quaternion by a scalar or by a tuple is
commutative, while multiplication of quaternions is not. Consequently the
notation `p/q` where `p` and `q` are quaternions is ambiguous because it is
unclear whether it corresponds to `p*q.inverse()` or `q.inverse()*p`. The
`Quaternion` class disallows this, raising a `ValueError`.
See [build notes](./QUATERNION.md#313-build-notes).
## 3.10 Rotation
Rotations are not commutative. Place two books side by side. Rotate one 90°
clockwise, then flip it 180° about an axis facing away from you. Repeat with
the other book in the opposite order.
Rotation is implemented using the matmul `@` operator. This is normally applied
with a `Point` or `Vector` quaternion on the LHS and a `Rotator` (rotation
quaternion) on the RHS.
```python
point = Point(1, 0, 0)
rot = Rotator(pi/6, 0, 1, 0)
new_point = point @ rot
point @= rot # Rotate in place
```
There is an `rrot` method performing order-reversed rotation:
```
def __matmul__(self, rot):
return rot * self * rot.conjugate()
def rrot(self, rot):
return rot.conjugate() * self * rot
```
### 3.10.1 Rotation direction
Consider these `Rotator` instances:
```python
rotx = Rotator(0.1, 1, 0, 0) # About X
roty = Rotator(0.1, 0, 1, 0) # Y
rotz = Rotator(0.1, 0, 0, 1) # Z
point = Point(1, 1, 1)
```
Applying a rotator with a positive angle to a point will cause anticlockwise
rotation as seen from a positive location on the relevant axis looking towards
the origin. This is analogous to 2d rotation on the complex plane where
multiplying by (say) 1 + 1j will produce an anticlockwise rotation.
## 3.11 Other methods
1. `conjugate` No args, returns a new `Quaternion` being its conjugate.
2. `inverse` No args, returns a new `Quaternion` being its inverse.
3. `to_angle_axis` No args. Intended for rotation quaternions. Returns
`[theta, x, y, z]` where `theta` is the rotation angle and the vector from the
origin to `x, y, z` is the axis.
4. `copy` No args. Returns a copy of the instance.
5. `normalise` Aims to produce a rotation quaternion: returns a new quaternion
instance whose maginitude is 1. Intended to compensate for any accumulation of
errors in a rotation quaternion.
6. `isvec` No args. Returns `True` if it is a vector quaternion i.e. `w == 0`.
7. `isrot` No args. Returns `True` if it is a rotation quaternion i.e.
`abs(q) == 1`.
## 3.12 The euler function
This takes a `Quaternion` instance as an arg. Returns `heading, pitch, roll`.
See [section 3.2.3](./quaternions.md#323-euler).
## 3.13 Build notes
Not all ports provide `math.isclose` and the reverse special methods. To some
extent the lack of reverse special methods can be worked around by changing the
operand order in expressions to ensure that the left hand side of a binary
operator is a `Quaternion` instance. This cannot be applied to division and the
`.inverse()` method must be used.
Bettrr, edit `ports/port/mpconfigport.h` to ensure these lines are included:
```C
#define MICROPY_PY_MATH_ISCLOSE (1)
#define MICROPY_PY_ALL_SPECIAL_METHODS (1) // May already be present
#define MICROPY_PY_REVERSE_SPECIAL_METHODS (1) // Add this line
```
and rebuild the firmware.
# Appendix 1: references
[Visalising quaternions](https://www.youtube.com/watch?v=d4EgbgTm0Bg)
[Visalising quaternions - interactive](https://eater.net/quaternions/video/doublecover)
[Using Quaternion to Perform 3D rotations](https://www.cprogramming.com/tutorial/3d/uses.html)
The Wikipedia article is maths-heavy.
[Wikpedia](https://en.wikipedia.org/wiki/Quaternion)
The following ref is good, but its formula for multiplication reverses the
order of its arguments compared to the other sources. The q1*q2 from
other sources corresponds to q2*q1 from this paper.
[Rotation Quaternions, and How to Use Them](http://danceswithcode.net/engineeringnotes/quaternions/quaternions.html)
From the youtube video: p -> (q2(q1pq1**-1)q2**-1)
Why to use quaternions rather than [Euler angles](https://github.com/moble/quaternion/wiki/Euler-angles-are-horrible).