Linear transformations on the two-dimensional plane

(0 comments)

A linear transformation in two dimensions can be visualized through its effect on the two orthonormal basis vectors $\hat{\imath}$ and $\hat{\jmath}$. In general, it can be represented by a $2 \times 2$ matrix, $\boldsymbol{T}$, which acts on a vector $v$ to map it from a vector space spanned by one basis onto a different vector space spanned by another basis: $\boldsymbol{v'} = \boldsymbol{T}\boldsymbol{v}$. This change of basis can be visualized by drawing the basis vectors in the two-dimensional plane, along with equally-spaced "grid lines" parallel to each of them. A linear transformation keeps the grid lines evenly spaced, and the origin fixed.

The following code produces a visualization of the effect of a linear transformation by plotting the grid lines, basis vectors and the unit square (or whatever parallelogram it ends up as) before and after the transformation.

Example 1: Rotation by $\theta = 10^\circ$: $$ T = \left( \begin{array}{rr} \cos\theta & -\sin\theta\\ \sin\theta & \cos\theta \end{array} \right) $$

enter image description here

Example 2: Rotation by 10° followed by a shear: $$ T = \left( \begin{array}{rr} \frac{6}{5} & \frac{1}{2}\\ \frac{1}{2} & \frac{6}{5} \end{array} \right) \left( \begin{array}{rr} \cos\theta & -\sin\theta\\ \sin\theta & \cos\theta \end{array} \right) $$ enter image description here

The code allows the appearance of the plot to be altered by customizing the constants defined at the start.

import numpy as np
import matplotlib.pyplot as plt

# Figure settings: background, reference grid, translated grid.
BACKGROUND_COLOUR = '#fff5c8'
BACKGROUND_COLOUR = '#f5f0c0'
REF_COLOUR = '#324851'
TRANS_COLOUR = '#8d230f'
# Reference grid limits (-XMAX, XMAX), (-YMAX, YMAX); number of minor grids
# per unit reference grid interval.
XMAX, YMAX = np.array((3, 2))
NMINOR = 5

# Styles for the major and minor grid lines.
major_styles = {'lw': 1}
minor_styles = {'lw': 0.5, 'alpha': 0.3}

# Set up the plot, turning off the axis labels and ensuring squares are square.
DPI = 72
WIDTH_PIXELS = 700
width_inches = WIDTH_PIXELS / DPI
height_inches = width_inches * YMAX / XMAX

# The reference orthonormal basis vectors, i and j.
ivec, jvec = np.array((1,0)), np.array((0,1))

def get_intersection(p, r, q, s):
    """Determine the intersection point of two lines, if any.

    The lines are defined by the vector equations p + αr and q + βs, where
    r and s are parallel to the lines. α may take any (real) value; but
    0 ≤ β ≤ 1: we are only interested in the intersection of the first line
    with a segment of the second, representing a boundary line of the plot
    rectangle. 
    Returns either the position of the intersection, or None if there is none.

    """

    def _cross(v, w):
        """A two-dimensional "cross product" of vectors v and w."""
        return v[0]*w[1] - v[1]*w[0]

    rxs = _cross(r, s)
    if rxs == 0:
        # The lines are parallel.
        return None
    u = _cross(q-p, r) / rxs
    if 0 <= u <= 1:
        # Intersection with the line segment
        return q + u*s
    # Fall through and return None if the intersection is outside the segment.

def get_intersections(p, r):
    """Get all the intersections of the line p + αr with the boundary."""

    # The vectors q and s for the boundary line segments, q +  βs for 0 ≤ β ≤ 1
    xvec, yvec = XMAX * ivec, YMAX * jvec
    boundary_lines = [np.array((xvec - yvec, 2*yvec)),
                      np.array((xvec + yvec, -2*xvec)),
                      np.array((-xvec + yvec, -2*yvec)),
                      np.array((-xvec - yvec, 2*xvec))]

    # Find all the interesections: we expect either none or 2.
    intersections = []
    for q, s in boundary_lines:
        t = get_intersection(p, r, q, s)
        if t is not None:
            intersections.append(t)
    return intersections

def plot_grid(ax, ivec, jvec, fac, c=REF_COLOUR, **kwargs):
    """Plot a the grid defined by the basis vectors ivec and jvec.

    fac determines how many grid lines to draw per multiple of ivec and jvec.
    c is the line colour; other arguments are handed on to ax.plot.

    """

    def plot_grid_lines(v, w, c=REF_COLOUR, **kwargs):
        """Plot the grid lines corresponding to kv + w and -kv + w.

        Keep incrementing k until a grid line no longer intersects the plot
        boundary. c is the line colour; other arguments get passed to ax.plot.

        """
        k = 0
        while True:
            intersections = get_intersections(k * v, w)
            if len(intersections) < 2:
                return
            ax.plot(*np.array(intersections).T, c, **kwargs)
            # Plot this grid line's "mirror image" for k -> -k, unless k=0.
            if k:
                intersections = get_intersections(-k * v, w)
                ax.plot(*np.array(intersections).T, c, **kwargs)
            k += 1

    # Plot the grid lines parallel to ivec and jvec.
    plot_grid_lines(jvec / fac, ivec, c, **kwargs)
    plot_grid_lines(ivec / fac, jvec, c, **kwargs)

def show_vector(ax, tip, tail=(0,0), c='k'):
    """Display a vector from tail to tip as an arrow with colour c."""
    arrowprops={'color': c, 'headwidth': 8, 'width': 2}
    ax.annotate(s='', xy=tip, xytext=tail, arrowprops=arrowprops)

def show_unit_square(ax, ivec, jvec, c='k'):
    """Display the unit "square" (parallelogram) defined by ivec, jvec."""

    kwargs = {'edgecolor': 'none', 'facecolor': c, 'alpha': 0.4}
    path = [(0,0), ivec, ivec+jvec, jvec]
    sq = plt.Polygon(path, **kwargs)
    ax.add_patch(sq)

def transform_basis(T, ivec, jvec):
    """Return a transformed basis by applying the matrix transformation T."""
    return T @ np.vstack((ivec, jvec))

def rotate_basis(theta, ivec, jvec):
    """A special case of a linear transformation: rotation by theta radians."""
    c, s = np.cos(theta), np.sin(theta)
    R = np.array(((c, -s),(s, c)))
    return transform_basis(R, ivec, jvec)

def plot_grids(ivec, jvec, ivecp, jvecp, draw_basis=True,
               draw_unit_square=True, filename='grids.png'):

    fig, ax = plt.subplots(figsize=(width_inches, height_inches))
    fig.patch.set_facecolor(BACKGROUND_COLOUR)
    ax.set_facecolor(BACKGROUND_COLOUR)
    ax.axis('off')
    ax.set_aspect('equal')

    # Plot the reference grid.
    plot_grid(ax, ivec, jvec, NMINOR, **minor_styles)
    plot_grid(ax, ivec, jvec, 1, **major_styles)

    # Plot the transformed basis grid.
    plot_grid(ax, ivecp, jvecp, NMINOR, c=TRANS_COLOUR, **minor_styles)
    plot_grid(ax, ivecp, jvecp, 1, c=TRANS_COLOUR, **major_styles)

    # Show the basis vectors and unit square for the reference grid.
    if draw_basis:
        show_vector(ax, ivec, c=REF_COLOUR)
        show_vector(ax, jvec, c=REF_COLOUR)
    if draw_unit_square:
        show_unit_square(ax, ivec, jvec, c=REF_COLOUR)

    # Show the basis vectors and their parallelogram for the transformed grid.
    if draw_basis:
        show_vector(ax, ivecp, c=TRANS_COLOUR)
        show_vector(ax, jvecp, c=TRANS_COLOUR)
    if draw_unit_square:
        show_unit_square(ax, ivecp, jvecp, c=TRANS_COLOUR)

    # Set the Axes limits, and remove all padding from the figure.
    ax.set_xlim(-XMAX, XMAX)
    ax.set_ylim(-YMAX, YMAX)
    plt.subplots_adjust(left=0, right=1, top=1, bottom=0)

    plt.savefig(filename, dpi=DPI, facecolor=BACKGROUND_COLOUR)
    plt.show()

# Rotate the basis vectors and then transform by an addition matrix, T.
ivecp, jvecp = rotate_basis(np.radians(10), ivec, jvec)
plot_grids(ivec, jvec, ivecp, jvecp, filename='grids2.png')

T = np.array(((1.2, 0.5),(0.5, 1.2)))
ivecp, jvecp = transform_basis(T, ivecp, jvecp)
plot_grids(ivec, jvec, ivecp, jvecp, filename='grids1.png')
Current rating: 3.2

Comments

Comments are pre-moderated. Please be patient and your comment will appear soon.

There are currently no comments

New Comment

required

required (not published)

optional

required