Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

dictk (Digital Image Correlation Toolkit) is a Python library for digital image correlation (DIC) — comparing images of a specimen before and after deformation to measure displacement and strain fields.

Installation

pip install dictk

Overview

DIC compares a reference image (often of an undeformed specimen) to a subject image (often of a deformed specimen) as a means to optically quantify displacement and strain fields.

Before we discuss the image portion of DIC, it is useful to introduce basic concepts of Continuum Mechanics and the Finite Element Method. This introduction will formalize definitions of displacement and strain and lay the groundwork for motion of discrete points locatable in reference/subject image pairs that are mapped to nodes of a finite element mesh (i.e., nodes).

The basic workflow is as follows:

  • Collect a pair of before and after images that capture an area of interest.
    • The before image, often called the reference image, captures some (possibly undeformed or deformed) configuration of the specimen.
    • The after image, often called the current image or subject image, captures the specimen in a newly deformed state, different from the before state.
  • Within each pair of images, identify points of interest.
  • Use the coordinates of the image points as coordinates of nodes that compose a finite element mesh.
  • Use DIC to quantify the displacement field of each (nodal) point.
  • Use the mesh (nodal) deformation to calculate a discrete strain field at Gauss points.

The following sections explicate this workflow in detail.

Literature Overview

Sutton, Orteu, and Schreier provide the field's most comprehensive reference text: basic image-correlation and computer-vision theory, camera calibration and optics, two-dimensional, stereo, and volumetric correlation methods, and the continuum-mechanics, statistics, and optimization background needed to apply them all.1 For a shorter, more recent introduction alongside an annotated bibliography of the field, see Brown.2 The development of modern Digital Image Correlation (DIC) spans from specialized hardware acquisition to advanced mathematical frameworks and open-source implementations. For high-resolution SEM imaging, Lenthe et al. describe advanced detector signal acquisition and scanning methods,3 while Black et al. demonstrate high-throughput measurements using multi-beam SEM imaging.4 When dealing with plastic localization, the Heaviside-DIC approach offers a robust method for capturing discontinuities.5

Mathematical foundations for the DICe engine are established through the work of Turner, covering gradient-based local formulations,6 stereo correlation and triangulation,7 and the implementation of virtual strain gauges.8 Alternatively, the Augmented Lagrangian DIC (ALDIC) framework provides a global approach to displacement fields, available as both a theoretical framework9 and a MATLAB implementation.10 For complex geometries and large deformations, the SpatioTemporally Adaptive Quadtree (STAQ) mesh offers significant improvements in resolution.11

Software accessibility is primarily supported by the µDIC toolkit, an open-source Python library for DIC tasks.12 The project's evolution is tracked through specific version forks,13 comprehensive online documentation, and its primary repository.14 Finally, looking toward performance optimization, the Rust GPU project provides the infrastructure for hardware-accelerated computations that could benefit future DIC processing pipelines.15

References


  1. Sutton MA, Orteu JJ, Schreier HW. Image Correlation for Shape, Motion and Deformation Measurements: Basic Concepts, Theory and Applications. Springer; 2009. ISBN 978-0-387-78746-6. link

  2. Brown C. Introduction to Digital Image Correlation (DIC) with annotated bibliography. Lawrence Livermore National Laboratory. 2025 Dec. LLNL-TR-2013494. link

  3. Lenthe WC, Stinville JC, Echlin MP, Chen Z, Daly S, Pollock TM. Advanced detector signal acquisition and electron beam scanning for high resolution SEM imaging. Ultramicroscopy. 2018 Dec 1;195:93-100. link

  4. Black RL, Garbowski T, Bean C, Eberle AL, Nickell S, Texier D, Valle V, Stinville JC. High-throughput high-resolution digital image correlation measurements by multi-beam SEM imaging. Experimental Mechanics. 2023 Jun;63(5):939-53. link

  5. Bourdin F, Stinville JC, Echlin MP, Callahan PG, Lenthe WC, Torbet CJ, Texier D, Bridier F, Cormier J, Villechaise P, Pollock TM. Measurements of plastic localization by heaviside-digital image correlation. Acta Materialia. 2018 Sep 15;157:307-25. link

  6. Turner DZ. An overview of the gradient-based local DIC formulation for motion estimation in DICe. 2016 Aug 19. SAND2016-7360R. link

  7. Turner DZ. An overview of the stereo correlation and triangulation formulations used in DICe. 2017 Mar 10. SAND2017-1876R. link

  8. Turner DZ. An overview of the virtual strain gauge formulation in DICe. 2018 May 21. SAND2018-5463R. link

  9. Yang J, Bhattacharya K. Augmented Lagrangian digital image correlation. Experimental Mechanics. 2019 Feb 15;59:187-205. link

  10. Augmented Lagrangian Digital Image Correlation (2D_ALDIC) MATLAB code. link

  11. Yang J, Rubino V, Ma Z, Tao J, Yin Y, McGhee A, Pan W, Franck C. SpatioTemporally adaptive quadtree mesh (STAQ) digital image correlation for resolving large deformations around complex geometries and discontinuities. Experimental Mechanics. 2022 Sep;62(7):1191-215. link

  12. Olufsen SN, Andersen ME, Fagerholt E. μDIC: An open-source toolkit for digital image correlation. SoftwareX. 2020 Jan 1;11:100391. link

  13. Olufsen SN. Fork: µDIC: A Python toolkit for Digital Image Correlation (DIC), GitHub: https://github.com/ElsevierSoftwareX/SOFTX_2019_193 link fork of https://github.com/PolymerGuy/muDIC for the 2020 paper.

  14. Olufsen SN. docs for µDIC: A toolkit for digital image correlation, docs and repo

  15. Rust GPU https://rust-gpu.github.io/ and https://github.com/rust-gpu/rust-gpu

Continuum Mechanics

This section summarizes the kinematics of general (finite) motion, the motion map, the deformation gradient and its Jacobian, the family of finite-strain measures and their linearizations, and the polar and spectral decompositions.

  • Upper case indices denote vector components in the reference configuration and
  • Lower case indices denote vector components in the current configuration.

All vectors are referenced to origin and dextral, orthonormal basis vectors , , (equivalently, , , ).

Motion

Let the arbitrary time interval be defined as , from initial to final time, inclusive.1 Let the motion, a one-parameter family of configurations, , map the material particle (the reference configuration) into the current configuration ,

A motion evaluated at a particular time is referred to as a current configuration or placement. For any placement at time , there is a displacement field ,

Thus, the current configuration is simply a function of the original placement , plus a displacement , which is a function of reference configuration and time ,

The initial condition is found from the initial placement and the reference configuration ,

Deformation Gradient

To each configuration , we define a deformation gradient ,

Real, square matrices of dimension three with positive determinant are denoted . Gradient operations with and without a subscript "" are gradients taken in the reference and current configurations, respectively:

Alternative notations are and , respectively.

Jacobian of the Deformation Gradient

The Jacobian of the deformation gradient,

describes the (generally non-uniform) volumetric expansion or contraction of the motion from the reference configuration . All configurations must be admissible in the sense that the Jacobian of the deformation must be positive . This requirement keeps the deformations from mapping the body to a single, infinitesimally small point or turning the body inside-out .

Isochoric motions preserve the body's total volume. A Jacobian of unity describes an isochoric motion. The table below describes the categories of motions (expansion, volume-preserving, contraction, and inadmissible) by Jacobian measure.

inadmissibleinadmissiblecontractionisochoricexpansion
body has turned inside-outbody has shrunk to zero volumebody's total volume has decreasedbody's total volume is preservedbody's total volume has increased
Table: Jacobian measure to categorize deformations.

Four important isochoric deformations are (1) pure translation, (2) pure rotation, (3) isochoric stretch, and (4) isochoric shear.

Displacement Gradient

From the displacement field defined above, the relationship between the displacement gradient and the deformation gradient is given by

Right Cauchy-Green Deformation

The right Cauchy-Green deformation arises from the inner product of two differential fiber elements in the reference configuration, and , mapped by the deformation gradient to obtain the inner product of the same differential fibers in the current configuration, and ,

where

The right Cauchy-Green deformation tensor :

  1. is defined in the reference configuration,
  2. is symmetric and positive-definite,
  3. gets its name from the location of the deformation gradient in the definition, which is to the right,
  4. is a metric that maps fiber lengths from the reference configuration to the current configuration, and
  5. is second-order in reference displacement gradients, as shown below:

This result can be expected since, by definition, is second-order in the deformation gradient , and the relationship between the deformation gradient and the displacement gradient is linear.

Left Cauchy-Green Deformation

The left Cauchy-Green deformation arises from similar multiplication as with the right Cauchy-Green deformation, but with the stretching going in reverse, from the current configuration back to the reference configuration,

where

The left Cauchy-Green deformation tensor :

  1. is defined in the current configuration,
  2. is symmetric and positive-definite,
  3. gets its name from the location of the deformation gradient in the definition, which is to the left,
  4. is a metric whose inverse maps fiber lengths from the current configuration to the reference configuration, and
  5. is second-order in current displacement gradients.

Green-Lagrange Strain

The Green-Lagrange strain tensor,

is closely related to the right Cauchy-Green deformation tensor and is often used in defining constitutive law relationships because the measure, when linearized about the reference configuration, coincides with the small strain tensor of linear deformation elasticity, denoted and defined in the Infinitesimal Strain section. This relationship can be seen as follows:

where the higher-order (quadratic) term in the first line is set to zero to achieve the linearized second line.

Almansi-Euler Strain

The Almansi-Euler strain tensor,

can likewise be used to approximate the small strain tensor by combining the definitions of the left Cauchy-Green deformation and the deformation gradient as follows:

where the higher-order (quadratic) term is set to zero to achieve the linearized final line.

Small Strain

When displacement gradients are small in the reference configuration,

or in the current configuration,

respectively, the nonlinear gradient terms are negligible and the finite strain theory simplifies to small strain theory, which occurs when finite strain measures are linearized to obtain and in the previous sections.

Note that we have restricted the gradients of displacement, and not the displacement itself. Thus, displacements between the reference and current configurations can be large (finite), but the gradients of the displacement, either in the reference or current configuration, are small.

The Strain Tensors and Finite Rotations section will demonstrate that the small strain tensors are not suitable to describe motion that contains finite rotation. This makes sense because, in finite rotation, gradients of displacement are large, not small. To adequately describe motion that includes finite rotation, a fully nonlinear strain measure, such as the Seth-Hill strain family, must be used.

Infinitesimal Strain

If we further restrict the small strain theory such that the displacement is small compared to unity,

the infinitesimal strain theory is obtained, which has no distinction between Lagrangian and Eulerian strain tensors.

In this case, the two small strain tensors, and , converge to a single definition of strain, called the infinitesimal strain tensor , defined as

Note that the notation has been dropped since the distinction between the reference and current configurations is nonexistent. Also, note that the factor of appears because it then follows that the infinitesimal strain is simply the symmetric part of the displacement gradient,

Finally, note that the finite Lagrangian and Eulerian strain tensors were defined with the factor of so that their expressions, once linearized and subject to a small displacement assumption, simplify to exactly the infinitesimal strain tensor .

Seth-Hill Strain Family

We now return to finite strain definitions. Seth and Hill showed that the Green-Lagrange strain tensor and the Almansi-Euler strain tensor are special cases of the so-called Seth-Hill family of strain measures, defined as

The principal stretches , , allow the strain measure to be written as principal strains, as a function of principal stretch, ,

where the stretch function

For integer values2 of , five common strain measures result, listed in the table below, in their three-dimensional and one-dimensional forms. Similar relationships can be constructed for the spatial tensors using

Name3D1D
Green-Lagrange
engineering (Biot, nominal)
log (Hencky, natural)
true
Almansi-Euler
Table: Strains obtained from the Seth-Hill family.

The one-dimensional strains are illustrated as a function of stretch ratio in the figure below.

stretch_strain
Figure: One-dimensional strain as a function of stretch ratio. Reproduction of Figure E-9 of Terpsma.

Source: Terpsma et al.3

The figure illustrates several results:

  • For small stretches, , (a) the stretch ratio is near unity, , (b) the strain values are small, , and (c) the tangent of the strains with respect to the stretch ratio is near unity, .
  • For elongations, , the strain monotonically increases since when .
  • For extreme compressions, , (a) the Green-Lagrange strain goes to a value of , (b) the engineering (Biot, nominal) strain tensor goes to a value of , and (c) the log, true, and Eulerian strains tend to .
  • The engineering (Biot, nominal) strain is a linear function of stretch ; all other measures are nonlinear functions of stretch .

Neff (2013)4 suggested "reasonable requirements" on , summarized in the table below, wherein a "+" indicates the requirement is satisfied and a "−" indicates the requirement is not satisfied.

Requirement
is smooth+++++
is monotonically increasing+++++
+++++
+++++
as , +++
as , +++
+
for +
Table: Reasonable requirements on the stretch function.

The results above illustrate that the log strain retains more of the desired qualities than any other strain tensor, in the context of finite compression and extension.5

  • For infinitesimal deformation, all tensors converge to the infinitesimal strain tensor .
  • For finite deformation, the Seth-Hill strain measures given by the function diverge quickly for both large compression and large tension.

The figure below illustrates the one-dimensional strains subtracted from the natural logarithmic strain, , as a function of stretch ratio . The log strain is considered as the finite deformation baseline.

The results show, for example, that in compression at , the Green-Lagrange strain tensor underreports the log strain by nearly 5%. Such a result illustrates that for finite deformation:

  1. Strain measures are not interchangeable, and
  2. It is ambiguous and incomplete to simply refer to "strain" as a deformation measure.

For finite deformation, both the strain value and the strain tensor must be specified.

stretch_strain_rel
Figure: One-dimensional strain difference of the strain function minus the natural logarithmic strain as a function of stretch ratio. Reproduction of Figure E-10 of Terpsma.

Source: Terpsma et al.3

Strain Tensors and Finite Rotations

Because it takes on nonzero values under finite rotation, the linearized strain tensor should not be used for geometrically nonlinear analysis. These nonzero values are completely artificial and strictly a result of using a linear strain definition with geometrically nonlinear motions. This result is shown as follows.

Let be a two-dimensional, rigid body rotation parameterized by time and scaled by constant radians per second. Then, the motion of a body can be written as

Then the deformation gradient is a function of time alone,

The linearized strain tensor is found to be

Now, for small angles, , which is for small deviations , , then for rigid body rotations. However, for arbitrary finite angles, , and the linearized strain tensor reports nonzero strain for rigid body rotations, which is nonsensical.

A correct strain tensor will report zero strain for rigid body rotations. One such strain tensor is the fully nonlinear Green-Lagrange strain tensor. This result is shown as follows:

Polar Decomposition

Given the rotation tensor , the material stretch tensor , and the spatial stretch tensor , the deformation gradient has the multiplicative decomposition,

Here we have a slight abuse of notation, where intermediate configurations that have stretched but not yet rotated are denoted with capital letter indices. Thus the "" subscript in is an intermediate stretched but non-rotated configuration.

The stretch tensors and are both symmetric and positive definite. The rotation tensor is non-symmetric and orthogonal. The figure below shows the polar decomposition about a material point and fibers in its vicinity mapped to the spatial point with the same fibers mapped to .

polar_decomposition
Figure: In the vicinity of , mapped to , the polar decomposition of deformation gradient into stretch then rotation ; or, into rotation then stretch : (a) reference configuration, (b) stretched configuration, (c) rotated configuration, (d) current configuration. As shown, the eigenvalues of (and ) are , , and the rotation has a magnitude of about the axis. Source: polar_decomposition.py.

Principal Stretches and Axes

The stretch tensors and have the same eigenvalues, , called principal stretches. For non-trivial rotations, i.e. , and have unique eigenvectors, called principal stretch directions. The principal stretch directions of are . The principal stretch directions of are . The two sets of eigenvectors are related through rotation ,

or generally,

Spectral Representation

The deformation gradient, its polar decomposition, and the Cauchy-Green deformations have spectral decompositions in terms of the principal stretches and stretch directions,

The Green-Lagrange strain tensor and the Almansi-Euler strain tensor , in principal stretches and stretch directions, are

The generalization of the Seth-Hill material strain tensor and spatial strain tensor , in principal stretches and stretch directions, are

and the relationship between the two strain tensors is given through a rotation transformation,

In the case when , the material and spatial logarithmic strain tensors, also known as the Hencky material and spatial strain tensors, and , are obtained as6

Two concrete illustrations follow: Rigid Body Motion works through pure translation as the simplest possible deformation, and Simple Shear works through an isochoric shear in closed form, computing , , , , and explicitly.


  1. Note that , while typically zero, may be any real number less than .

  2. Technically, can be any real number, not just an integer.

  3. Terpsma RJ, Hovey CB. Blunt impact brain injury using cellular injury criterion. Sandia National Lab. (SNL-NM), Albuquerque, NM (United States); 2020 Oct 1. link ↩2

  4. Neff, P. (2013). The Hencky strain measure is the geodesic distance to SO(), at 6.

  5. The Bažant strain, , not considered here, also satisfies .

  6. See Xiao H, Bruhns OT, Meyers A. Hypo-elasticity model based upon the logarithmic stress rate. Journal of Elasticity. 1997 Apr 1;47(1):51-68, at page 54, Eq. (2.2).

Rigid Body Motion

Translation

A translation is a homogeneous deformation of the form

This deformation occurs when the displacement is a constant , and thus not a function of reference position or time .

For the concrete example in the figure below, let . In this case, we see the placement moves right and up on the page, relative to the reference configuration , by an amount of 3 and 2, respectively. The reference configuration is shown in blue. The current configuration is shown in red.

configuration_current
Figure: Illustration of a translational motion.

Simple Shear

Note: The source code for this section is listed below.

In this section, we cover simple shear, a deformation that illustrates several concepts without being too complicated. That said, we will find that simple shear isn't quite as trivial as its name would suggest. In fact, a paper titled Simple shear is not so simple1 reviews some details not discussed here.

The figure below illustrates simple shear, with relative motion of planes normal to the vertical axis. For all configurations (reference and current):

  • Horizontal fibers remain horizontal.
  • The length of horizontal fibers remains constant.
  • The vertical distance between the horizontal fibers remains constant.
  • The body's volume is constant. The deformation is isochoric since .

The relative motion is characterized by the non-dimensional ratio of length per unit height, where . The angle, , subtended by simple shear is . In the limit as , the shear angle . For small values of , the small-angle assumption is valid, with .

simple_shear
Figure: (Left) Simple shear of a unit cube in the reference configuration (gray) and two current configurations (dotted, green and dashed, red) and (right) with the shear angle, , created for all configurations that are parameterized by .

Source: simple_shear.py

Rate-Independent Form

The simple shear deformation in rate-independent form is

The deformation gradient is

Note that the volume remains constant for all deformations since . The deformation thus belongs to the group of isochoric motions. The right Cauchy-Green strain, , in simple shear, is

The principal directions of are given with the three eigenvectors and their respective eigenvalues ,

For simple shear, the eigenvalues of and their corresponding eigenvectors, satisfying , are

The Green-Lagrange strain, , in simple shear is

The left Cauchy-Green strain, , (and its inverse), in simple shear, are

The Almansi-Euler strain, , for simple shear is

Source

simple_shear.py

"""Demonstrate simple shear deformation of a square body."""

from typing import Tuple
import os
import numpy as np
from numpy.typing import NDArray

from matplotlib import rc
import matplotlib.pyplot as plt
from matplotlib.ticker import MultipleLocator

rc("text", usetex=True)
# rc('font', **{'family': 'serif', 'serif': ['Computer Modern Roman']})
rc("font", family="serif")


def rotate(x0: NDArray, y0: NDArray, rotation: float) -> Tuple[NDArray, NDArray]:
    """Given list of reference points (x0, y0), rotate them about the
    z-axis by rotation angle (radians) to the current points (x1, y1).
    """
    x1 = np.cos(rotation) * x0 - np.sin(rotation) * y0
    y1 = np.sin(rotation) * x0 + np.cos(rotation) * y0
    return x1, y1


def simple_shear(x0: NDArray, y0: NDArray, shear: float) -> Tuple[NDArray, NDArray]:
    """Given a list of reference points (x0, y0), simple shear them in
    the x-axis by distance shear_x (Length) to the current points (x1, y1).
    """
    x1 = x0 + shear * y0
    y1 = y0
    return x1, y1


def draw_shape(
    axis,
    xs: np.ndarray,
    ys: np.ndarray,
    color: str = "dimgray",
    linestyle: str = "-",
    alpha: float = 1.0,
):
    """Draw the body and points."""

    # Draw body outline
    axis.plot(
        xs,
        ys,
        color=color,
        linestyle=linestyle,
        alpha=alpha,
    )  # body outline

    # Plot points on the body
    axis.plot(xs, ys, "o", color=color, markersize=5, alpha=alpha)


SHOW = False
SAVE = True

fig = plt.figure(figsize=(6, 3))  # inches, (wide, tall)
ax1 = fig.add_subplot(1, 2, 1)
ax2 = fig.add_subplot(1, 2, 2)

# Subfigure 1

OFFSET_X = 0
OFFSET_Y = 0
RADTODEG = 180.0 / np.pi
DEGTORAD = 1.0 / RADTODEG

# Defined reference configuration path, in a closed circle
xs_0 = np.array([0, 1, 1, 0, 0])
ys_0 = np.array([0, 0, 1, 1, 0])

# Draw reference shape
draw_shape(axis=ax1, xs=xs_0, ys=ys_0, color="dimgray", linestyle="-", alpha=0.5)

# Draw first sheared shape
SHEAR_1 = 0.5
(xs_1, ys_1) = simple_shear(xs_0, ys_0, shear=SHEAR_1)
draw_shape(axis=ax1, xs=xs_1, ys=ys_1, color="green", linestyle=":", alpha=0.9)

# Draw second sheared shape
SHEAR_2 = 1.0
(xs_2, ys_2) = simple_shear(xs_0, ys_0, shear=SHEAR_2)
draw_shape(axis=ax1, xs=xs_2, ys=ys_2, color="red", linestyle="--", alpha=0.9)

# Draw origin point
ax1.plot(0, 0, "o", color="black", label="origin = (0, 0, 0)")
# Draw origin label
ax1.text(0.25, -0.25, r"$O, o$", ha="center", va="center")

# Draw 1:a rise over run lines
SCALE = 0.90  # scale
HAIRLINE_OFFSET_Y = 0.1
epsx, epsy = 0.125, 0.25 + HAIRLINE_OFFSET_Y
slope_x, slope_y = np.array([0, 0, 0.25]) * SCALE, np.array([0, 0.5, 0.5])
ax1.plot(slope_x + epsx, slope_y + epsy, lw=0.5, color="green")
ax1.text(0.125, 0.5, "1", color="green", ha="right", va="center")
ax1.text(0.25, 0.88, r"$a$", color="green", ha="center")


# SHEAR_12 = 0.5  # Length units, shear in the X_1 direction
# draw(ax1, ux=OFFSET_Y, uy=OFFSET_Y, ur=dr, shear=SHEAR_12, t0=False, c="green", ls=":")

# SHEAR_12 = 1.0  # Length units, shear in the X_1 direction, larger shear
# draw(ax1, ux=OFFSET_X, uy=OFFSET_X, ur=dr, shear=SHEAR_12, t0=False, c="red", ls="--")


ax1.axis("equal")
# ax2.axis('equal')
# major axes
ax1.xaxis.set_major_locator(MultipleLocator(1.0))
ax1.yaxis.set_major_locator(MultipleLocator(1.0))
ax2.xaxis.set_major_locator(MultipleLocator(1.0))
ax2.yaxis.set_major_locator(MultipleLocator(1.0))
# minor axes
# ax1.xaxis.set_minor_locator(MultipleLocator(0.5))
# ax1.yaxis.set_minor_locator(MultipleLocator(0.5))

ax1.grid(
    visible=True, which="major", linestyle="solid", linewidth=0.5, color="lightgray"
)  # FIX APPLIED HERE
ax2.grid(
    visible=True, which="major", linestyle="solid", linewidth=0.5, color="lightgray"
)  # FIX APPLIED HERE
#
# ax.grid(b=True, which='minor', linestyle=':')
ax1.set_xlabel(r"configuration $X_1, x_1$")
ax1.set_ylabel(r"configuration $X_2, x_2$")

# Subfigure 2

X_MIN = 0
X_MAX = 10
epsx, epsy = 0.4, np.pi / 16
x = np.linspace(X_MIN, X_MAX)
y = np.arctan(x)
ax2.plot(x, y, linewidth=2, color="blue")
ax2.text(
    X_MAX - epsx,
    np.pi / 2 + epsy / 2,
    r"$\gamma \mapsto \frac{\pi}{2}$",
    ha="right",
    backgroundcolor="white",
)
ax2.plot(
    [X_MIN, X_MAX],
    np.pi / 2 * np.array([1, 1]),
    lw=2,
    alpha=0.5,
    color="black",
    linestyle="--",
    zorder=4,
)

ax2.plot(0, 0, "o", color="dimgray", alpha=0.5, zorder=4)
ax2.text(0 + epsx, 0 - epsy, r"$(0, 0)$", backgroundcolor="white")

ax2.plot(0.5, 0.46, "o", color="green", alpha=0.9, zorder=4)
ax2.text(0.50 + epsx, 0.46 - epsy, r"$(0.50, 0.46)$", backgroundcolor="white")

ax2.plot(1, np.pi / 4, "o", color="red", alpha=0.9, zorder=4)
ax2.text(1 + epsx, np.pi / 4 - epsy, r"$(1, \frac{\pi}{4})$", backgroundcolor="white")

ax2.set_xlabel(r"non-dimensional distance $a\;[l/L]$")
ax2.set_ylabel(r"$\gamma = \arctan(a)$ [rad]")
# https://matplotlib.org/3.1.1/gallery/ticks_and_spines/tick-locators.html
# ax2.xaxis.set_major_locator(ticker.FixedLocator([0, 5, 10]))
ax2.set_xticks([0, 5, 10])
ax2.set_yticks([0, np.pi / 4, np.pi / 2])
ax2.set_yticklabels(["0", r"$\frac{\pi}{4}$", r"$\frac{\pi}{2}$"])

ax1.set_xlim(-epsx, 2 + epsx)
ax2.set_xlim(0 - epsx, X_MAX + epsx)
eps = np.pi / 8
ax2.set_ylim(0 * np.pi / 4 - eps, np.pi / 2 + eps)

if SHOW:
    plt.show()

if SAVE:
    script_name = os.path.basename(__file__)
    figure_name = os.path.splitext(script_name)[0]
    print(f"Saving figure as {figure_name}.pdf")
    fig.savefig(figure_name + ".pdf", bbox_inches="tight")

References


  1. Destrade M, Murphy JG, Saccomandi G. Simple shear is not so simple. International Journal of Non-Linear Mechanics. 2012 Mar 1;47(2):210-4. download

Finite Element Method

A finite element mesh is a collection of nodes (points) connected into elements — small regions used to interpolate a quantity of interest (e.g. displacement) across the whole domain. Multi-Point Motion (covered later in this book) builds exactly this kind of nodal point collection and, in Tracking the Grid, tracks every one of its 12 points to its current position — exactly the per-node displacement data a mesh needs to represent how an object deformed.

Kernel Size First also covers the kernel-size-versus-point-spacing tradeoff involved in getting that per-node data reliably — the same considerations apply whether the points come from a toy grid or a real mesh.

Once every node's current position is known, an actual finite element mesh still needs one more thing this page doesn't provide: element connectivity — which nodes join together into which elements. Building that connectivity is future work, not implemented here; what this page covers below is the element formulation that connectivity would plug into — shape functions, strain, and deformation gradient, for the four-node quadrilateral element.

Four-Noded Quadrilateral Finite Element (Q4)

The four-node quadrilateral element is one of the most commonly used elements in 2D FEA. It has four corner nodes, with each node having two degrees of freedom (DOFs): displacements in the and directions.

quad_isoparametric
Figure: Illustration of isoparametric mapping between (left) an arbitrary quadrilateral element in global (physical) coordinates to (right) the local (natural) coordinates. The local domain is sometimes called the parent quadrilateral element.

Image credit: James et al.1

Note: Since we are using a finite deformation continuum mechanics framework, we will use and (instead of and in the James et al. figure above).

Shape Functions

For the element in local coordinates , the bilinear shape functions are defined:

The shape functions satisfy the following properties:

  • Kronecker delta property: (equals 1 at node , 0 at other nodes)
  • Partition of unity: for all

Local Coordinates

The key concept in finite element analysis is the isoparametric mapping between the local coordinate system and the global coordinate system.

This mapping allows:

  • Integration to be performed on the local domain (parent element)
  • Handling of arbitrarily shaped quadrilaterals
  • Use of the same shape functions for geometry and displacement (isoparametric concept)

The isoparametric coordinates range from to in both the and directions.

The mapping between global coordinates and local coordinates is introduced as a linear combination of local shape functions :

where is the position of node , and .

Shape Function Derivatives in Local Coordinates

The derivatives with respect to the local coordinate system are

These are assembled into a matrix:

Jacobian Matrix

The Jacobian matrix relates derivatives in local coordinates to derivatives in global coordinates. It is important to include the "matrix" part of "Jacobian matrix". It is distinct from the Jacobian of the Deformation Gradient , which is a scalar value (not a matrix). For nodal coordinates organized as:

the Jacobian matrix is computed as:

The individual components (dropping the reference configuration notation to avoid subscript confusion) are:

Shape Function Derivatives in Global Coordinates

The transformation from local to global coordinate derivatives requires the inverse Jacobian matrix through the chain rule. Since

then

In matrix form for all shape functions:

where:

Displacement Field

The displacement is defined as the difference between the current configuration and the reference configuration ,

The displacement field within the element is interpolated using shape functions:

where is the respective displacement of node , and .

Displacement Gradient

Each component is computed using the chain rule:

In compact matrix notation:

where is the nodal displacement matrix:

See Displacement Gradient for more information.

Deformation Gradient

The deformation gradient tensor maps material points in the reference configuration to their positions in the current (deformed) configuration :

Because ,

Explicitly:

The determinant represents the local volume ratio and must be positive for physically admissible deformations. See Deformation Gradient and Jacobian of the Deformation Gradient for more information.

Gauss Points

To evaluate quantities that depend on the displacement field and its gradient, such as strain, we use Gaussian Quadrature. We don't typically calculate quantities at the nodes. Rather, we quantify strain at specific integration points (also known as Gauss points) where mathematical precision is the highest.

For a 2D quadrilateral element, we typically use a Gauss rule. The integration points are located in the local coordinate system at

References


  1. James KA, Lee E, Martins JR. Stress-based topology optimization using an isoparametric level set method. Finite Elements in Analysis and Design. 2012 Oct 1;58:20-30. link

Image Generation

The source for the commands on this page is dictk's own rosta, checkerboard, and astronaut subcommands — see dictk --help.

Note: the images embedded on this page are rendered as PNG (--format png), not dictk's default TIFF. Browsers don't natively render TIFF in <img> tags, so a TIFF embedded here simply wouldn't display.

Among the alternatives, PNG also wins on its own merits: it is lossless, whereas JPG's compression tends to smear hard edges and speckle-pattern detail (for the 200x200 checkerboard on this page: TIFF 40,256 bytes, JPG 6,760 bytes, PNG only 418 bytes — JPG is actually larger than PNG here, because its block-based compression is a poor fit for hard-edged content like a checkerboard). SVG doesn't help either: since there's no vector structure to trace, dictk's SVG output just wraps that same PNG in a base64-encoded XML container, which comes out to 809 bytes here — roughly double the raw PNG for no rendering benefit.

TIFF remains dictk's command-line default, since it's the lossless, uncompressed format conventionally used for DIC and other scientific-imaging workflows.

CLI vs. API: the Command Line Interface (CLI) subcommands on this page (dictk rosta, dictk checkerboard, dictk astronaut) write an image file to disk — that's their whole job. The corresponding Python functions, dictk.rosta, dictk.checkerboard, and dictk.astronaut, take the same parameters but perform no file I/O: they return a NumPy array only. That keeps the Python API composable in a functional style — arrays can be piped through further functions (e.g. combine below) before anything touches disk — and callers who do want a file call dictk.image.write explicitly, as a separate step. See each function's docstring (rendered in the API reference) for details.

Rosta

We create a synthetic example speckle pattern with the built-in rosta image generator. It implements the Rosta algorithm described by Olufsen (Olufsen SN, Andersen ME, Fagerholt E. muDIC: An open-source toolkit for digital image correlation. SoftwareX. 2020 Jan 1;11:100391, Algorithm 1, page 6, repository).

The help text for rosta:

dictk rosta --help

returns

usage: dictk rosta [-h] [--dot-size DOT_SIZE] [--density DENSITY]
                   [--smoothness SMOOTHNESS] [--random-seed RANDOM_SEED]
                   [--output OUTPUT] [--format {tiff,png,jpg,svg}]
                   [width] [height]

positional arguments:
  width                 Image width in pixels (int), default: 200.
  height                Image height in pixels (int), default: 200.

options:
  -h, --help            show this help message and exit
  --dot-size DOT_SIZE, -s DOT_SIZE
                        Dot pattern size factor, 0.0 to 100.0 (float),
                        default: 4.0.
  --density DENSITY, -d DENSITY
                        Dot pattern density, 0.0 to 1.0 (float), default:
                        0.32.
  --smoothness SMOOTHNESS, -m SMOOTHNESS
                        Smoothness factor, 0.0 to 100.0 (float), default: 2.0.
  --random-seed RANDOM_SEED, -r RANDOM_SEED
                        Seed for reproducible pattern generation (int),
                        default: 42.
  --output OUTPUT, -o OUTPUT
                        Output directory (path), default: current directory.
  --format {tiff,png,jpg,svg}, -f {tiff,png,jpg,svg}
                        Output image format (str), default: tiff.

Create a synthetic image, 200 by 200 pixels, 50% dot density:

dictk rosta 200 200 --density 0.5 --format png -o .
Saved image: rosta_200w_by_200h_dot_4.0_den_0.5_smo_2.0.png

Note that the file name is automatically chosen based on the input parameters.

The result:

rosta speckle pattern
Synthetic speckle pattern, 200x200 pixels.

The Python equivalent returns the same pixel data as a NumPy array, with no file written:

import dictk

pattern = dictk.rosta(width=200, height=200, density=0.5)
shape=(200, 200), dtype=uint8

Checkerboard

To make it easier to manually identify discrete points in the speckle pattern, dictk can also generate a checkerboard test image.

The help text for checkerboard:

dictk checkerboard --help

returns

usage: dictk checkerboard [-h] [--count-x COUNT_X] [--count-y COUNT_Y]
                          [--output OUTPUT] [--format {tiff,png,jpg,svg}]
                          [width] [height]

positional arguments:
  width                 Image width in pixels (int), default: 200.
  height                Image height in pixels (int), default: 200.

options:
  -h, --help            show this help message and exit
  --count-x COUNT_X, -x COUNT_X
                        Number of rectangles along the width (int), default:
                        8.
  --count-y COUNT_Y, -y COUNT_Y
                        Number of rectangles along the height (int), default:
                        8.
  --output OUTPUT, -o OUTPUT
                        Output directory (path), default: current directory.
  --format {tiff,png,jpg,svg}, -f {tiff,png,jpg,svg}
                        Output image format (str), default: tiff.

Create a synthetic image, 200 by 200 pixels:

dictk checkerboard 200 200 --format png -o .
Saved image: checkerboard_200w_by_200h_8x8.png
checkerboard
Checkerboard test image, 200x200 pixels, 8x8 squares.

The Python equivalent, again returning an array with no file written:

import dictk

board = dictk.checkerboard(width=200, height=200)
shape=(200, 200), dtype=uint8

Astronaut

Unlike rosta and checkerboard, which procedurally generate a fresh synthetic pattern from parameters, astronaut loads a bundled real-world photograph and converts it to grayscale — useful for exercising dictk's imaging utilities against something other than a synthetic pattern. The source is a NASA portrait of astronaut Eileen Collins, from the NASA Great Images database ("No known copyright restrictions, released into the public domain."). Its native resolution is 512x512; passing width/ height other than that resizes the source image rather than generating a new one at that size.

The help text for astronaut:

dictk astronaut --help

returns

usage: dictk astronaut [-h] [--output OUTPUT] [--format {tiff,png,jpg,svg}]
                       [width] [height]

positional arguments:
  width                 Image width in pixels (int), default: 512.
  height                Image height in pixels (int), default: 512.

options:
  -h, --help            show this help message and exit
  --output OUTPUT, -o OUTPUT
                        Output directory (path), default: current directory.
  --format {tiff,png,jpg,svg}, -f {tiff,png,jpg,svg}
                        Output image format (str), default: tiff.

Save it at 300 by 300 pixels — smaller downscales from the native 512x512 start to lose too much detail:

dictk astronaut 300 300 --format png -o .
Saved image: astronaut_300w_by_300h.png
astronaut
NASA portrait of astronaut Eileen Collins, resized to 300x300 pixels.

The Python equivalent, again returning an array with no file written:

import dictk

photo = dictk.astronaut(width=300, height=300)
shape=(300, 300), dtype=uint8

Combining into a reference image

combine works on any two grayscale images of the same shape, so it isn't limited to combining the two synthetic images below — Speckle + Astronaut further down combines rosta with a real photograph instead.

Speckle + Checkerboard

We combine the rosta speckle pattern with the checkerboard into a reference image checkerboard0 by averaging their pixel values and normalizing back to uint8:

from dictk.image import combine, read, write

speckle = read(path="rosta_200w_by_200h_dot_4.0_den_0.5_smo_2.0.png")
checker = read(path="checkerboard_200w_by_200h_8x8.png")
checkerboard0 = combine(a=speckle, b=checker)
write(arr=checkerboard0, path="checkerboard0.png")
Saved image: checkerboard0.png
reference image checkerboard0
Reference image checkerboard0, 200x200 pixels.

Because both inputs are averaged and rescaled together, the checkerboard's squares stay clearly black or white while the speckle pattern shows up as gray texture within them:

  • Where the checkerboard is black, speckle white maps to gray and speckle black stays black.
  • Where the checkerboard is white, speckle black maps to gray and speckle white stays white.

That trimodal structure is visible in the pixel-intensity histograms below: speckle and checkerboard are both roughly bimodal (dark/light), while checkerboard0 picks up a distinct middle hump from the black/white-speckle-on-opposite checkerboard combinations.

from dictk.image import read
from dictk.plot import histogram_save

speckle = read(path="rosta_200w_by_200h_dot_4.0_den_0.5_smo_2.0.png")
checker = read(path="checkerboard_200w_by_200h_8x8.png")
checkerboard0 = read(path="checkerboard0.png")

histogram_save(arr=speckle, path="rosta_histogram.png")
histogram_save(arr=checker, path="checkerboard_histogram.png")
histogram_save(arr=checkerboard0, path="checkerboard0_histogram.png")
Saved histograms: rosta_histogram.png, checkerboard_histogram.png, checkerboard0_histogram.png
rostacheckerboardcheckerboard0
rosta histogramcheckerboard histogramcheckerboard0 histogram

Speckle + Astronaut

The checkerboard above is a stand-in for an actual specimen — in a real DIC setup, the speckle pattern is applied directly to the surface being measured, not swapped in from another generator. Combining rosta with the astronaut photo instead of the checkerboard is closer to that: a speckle pattern overlaid on a realistic, non-uniform grayscale image.

This time the two source images are never written to disk at all — both dictk.rosta and dictk.astronaut return arrays directly, which combine accepts as-is, so only the combined result astronaut0 is saved:

import dictk
from dictk.image import combine, write

speckle = dictk.rosta(width=300, height=300, density=0.5)
photo = dictk.astronaut(width=300, height=300)
astronaut0 = combine(a=speckle, b=photo)
write(arr=astronaut0, path="astronaut0.png")
Saved image: astronaut0.png
reference image astronaut0: rosta speckle over the astronaut photo
Reference image astronaut0: rosta speckle pattern combined with the astronaut photo, 300x300 pixels.

Both checkerboard0.png and astronaut0.png are also bundled in src/dictk/data/, alongside the source astronaut.png, so later examples can reuse them without regenerating from scratch each time.

Subimage Generation

Now we consider extracting a subimage from a subject image. A subimage is a useful precursor to image computation: narrowing the data down to a region of interest makes the computation more efficient than considering the full image.

Reference Frames

When we have just a single image, we have a single, trivial reference frame : origin (blue dot) at the top-left corner, with the -axis (red) running left-to-right and the -axis (green) running top-to-bottom. origin, and every pixel coordinate on this page, is expressed in this frame — one that's always implicitly present, even in the left panel below where nothing is drawn to show it. astronaut0 here is the same reference image created in Image Generation:

from dictk.image import read
from dictk.plot import reference_frame_plot

astronaut0 = read(path="astronaut0.png")
reference_frame_plot(image=astronaut0, path="reference_frame.png")
Saved: reference_frame.png
left: astronaut0 alone with no annotation; right: the same image with a blue box around its 300x300 bounds, a blue dot at the origin, and red/green arrows marking the x- and y-axes
Left: astronaut0 (300x300 pixels) alone. Right: the same image with its reference frame made explicit and labeled near the origin — origin (blue dot) at the top-left corner, -axis (red), and -axis (green), used throughout this page.

When we extract a subimage from an image, it is useful to be explicit about reference frames: the subimage has its own frame , located within the image's frame . The Python API section below demonstrates this concept.

Python API

dictk.image.subimage extracts a rectangular crop from a source image: a width x height region whose top-left corner sits at origin. origin may place the requested region partially or completely outside the source image — rather than raising an error, subimage fills whatever doesn't overlap with black (zero) pixels, so the result is always a well-formed height x width array. This is the building block later tutorials use to pull a kernel or search area out of a larger reference/current image pair around a point of interest.

dictk.image.PixelCoordinate is a simple (x, y) NamedTuple used for origin. dictk.image.subimage itself returns the cropped array directly, with no file written.

The examples below use subimage_comparison_plot, which saves a two-panel figure: the left panel shows where the region falls relative to the source image (blue/red boxes), and the right panel shows the extracted result on its own, in its own local frame — sharing the same axis limits as the left panel so the two red boxes render at matching scale. It's built from two smaller single-panel functions, also available individually: subimage_bounds_plot (the left panel alone) and subimage_plot (the right panel alone, but zoomed to the subimage's own size rather than sharing the source image's scale).

Square, fully inside

An 80x80 square region entirely within astronaut0's 300x300 bounds. subimage_comparison_plot draws both panels side by side, sharing the same axis limits, so the red box in the right panel renders at identical scale to the one on the left.

from dictk.image import PixelCoordinate
from dictk.plot import subimage_comparison_plot

origin = PixelCoordinate(x=100, y=40)
subimage_comparison_plot(image=astronaut0, origin=origin, width=80, height=80, path="subimage_comparison_80w_by_80h_at_100_40.png")
Saved: subimage_comparison_80w_by_80h_at_100_40.png
square subimage, fully inside, source and extraction side by side at matching scale
Left: image (reference frame , blue), showing square subimage (80x80), origin , lying entirely within the source image bounds. The blue dot is the origin of the source image (0, 0); the red dot is the origin of the subimage in the source image's reference frame (100, 40). Right: subimage (reference frame , red), origin .

Rectangle, fully inside

A 180x70 region — wider than it is tall — also entirely within the source image bounds:

from dictk.image import PixelCoordinate
from dictk.plot import subimage_comparison_plot

origin = PixelCoordinate(x=50, y=200)
subimage_comparison_plot(image=astronaut0, origin=origin, width=180, height=70, path="subimage_comparison_180w_by_70h_at_50_200.png")
Saved: subimage_comparison_180w_by_70h_at_50_200.png
rectangular subimage, fully inside, source and extraction side by side at matching scale
Left: image (reference frame , blue), showing rectangular subimage (180x70), origin , lying entirely within the source image bounds. The blue dot is the origin of the source image (0, 0); the red dot is the origin of the subimage in the source image's reference frame (50, 200). Right: subimage (reference frame , red), origin .

Partially outside

A 120x120 region with a negative origin, straddling the source image's top-left corner. subimage fills the part of the region above and to the left of the source with black:

from dictk.image import PixelCoordinate
from dictk.plot import subimage_comparison_plot

origin = PixelCoordinate(x=-20, y=-40)
subimage_comparison_plot(image=astronaut0, origin=origin, width=120, height=120, path="subimage_comparison_120w_by_120h_at_-20_-40.png")
Saved: subimage_comparison_120w_by_120h_at_-20_-40.png
subimage partially outside bounds, source and extraction side by side at matching scale
Left: image (reference frame , blue), showing subimage (120x120), origin , lying partially outside the source image bounds (straddling its top-left corner). The blue dot is the origin of the source image (0, 0); the red dot is the origin of the subimage in the source image's reference frame (-20, -40). Right: subimage (reference frame , red), origin ; the black band along the top and left is zero-padding, where the requested region fell outside astronaut0.

Completely outside

A 40x100 region entirely beyond the source image's bounds — its x-range (310 to 350) shares no pixels with the source's (0 to 300), so there is no overlap at all and the result is entirely black:

from dictk.image import PixelCoordinate
from dictk.plot import subimage_comparison_plot

origin = PixelCoordinate(x=310, y=250)
subimage_comparison_plot(image=astronaut0, origin=origin, width=40, height=100, path="subimage_comparison_40w_by_100h_at_310_250.png")
Saved: subimage_comparison_40w_by_100h_at_310_250.png
subimage completely outside bounds, source and extraction side by side at matching scale
Left: image (reference frame , blue), showing subimage (40x100), origin , lying entirely outside the source image bounds. The blue dot is the origin of the source image (0, 0); the red dot is the origin of the subimage in the source image's reference frame (310, 250). Right: subimage (reference frame , red), origin ; entirely zero-padded black, since none of the requested region overlapped astronaut0.

Image Preprocessing

Certain preprocessing steps can make digital image correlation more robust to differences between a reference and current image — some in pixel intensity, others in how well an image's content suits an FFT-based technique such as phase correlation.

  • Brightness and Contrast shift and stretch the pixel-intensity histogram, the two most basic differences a reference and current image can have.
  • Windowing tapers an image's edges toward zero before a Fourier transform, reducing spectral leakage.

Brightness and Contrast

This page covers two preprocessing steps that can make digital image correlation more robust to pixel-intensity differences between a reference and deformed image, using the astronaut reference image from Image Generation as an example.

Brightness

Brightness shifts the entire pixel-intensity histogram up or down by a constant amount — the whole image gets lighter or darker together, dark areas included. Pushed too far, dark regions wash out to a flat gray and highlights clip at pure white (255), permanently losing detail.

import dictk
from dictk.image import brightness, write
from dictk.plot import histogram_save

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_original.png")
histogram_save(arr=photo, path="astronaut_original_histogram.png")

bright_1_5 = brightness(arr=photo, factor=1.5)
write(arr=bright_1_5, path="astronaut_brightness_1.5.png")
histogram_save(arr=bright_1_5, path="astronaut_brightness_1.5_histogram.png")

bright_2_0 = brightness(arr=photo, factor=2.0)
write(arr=bright_2_0, path="astronaut_brightness_2.0.png")
histogram_save(arr=bright_2_0, path="astronaut_brightness_2.0_histogram.png")
Saved: astronaut_original.png, astronaut_brightness_1.5.png, astronaut_brightness_2.0.png
factor=1.0 (original)factor=1.5factor=2.0
originalbrightness 1.5brightness 2.0
factor=1.0 (original)factor=1.5factor=2.0
original histogrambrightness 1.5 histogrambrightness 2.0 histogram

At factor=1.5 the histogram shifts right as a whole — midtones move into the brighter half and the mean climbs, with a few highlights starting to clip at 255. At factor=2.0 the shift is large enough that a big share of pixels pile up at that 255 ceiling, visible as a tall spike at the histogram's right edge: real detail that's been clipped away and can't be recovered.

Contrast

Contrast is the spread between an image's darkest and lightest pixels. Increasing contrast stretches the histogram outward from its own mean — darks get darker, lights get lighter — while the mean itself stays roughly where it was.

import dictk
from dictk.image import contrast, write
from dictk.plot import histogram_save

photo = dictk.astronaut(width=300, height=300)

contrast_1_5 = contrast(arr=photo, factor=1.5)
write(arr=contrast_1_5, path="astronaut_contrast_1.5.png")
histogram_save(arr=contrast_1_5, path="astronaut_contrast_1.5_histogram.png")

contrast_2_0 = contrast(arr=photo, factor=2.0)
write(arr=contrast_2_0, path="astronaut_contrast_2.0.png")
histogram_save(arr=contrast_2_0, path="astronaut_contrast_2.0_histogram.png")
Saved: astronaut_contrast_1.5.png, astronaut_contrast_2.0.png
factor=1.0 (original)factor=1.5factor=2.0
originalcontrast 1.5contrast 2.0
factor=1.0 (original)factor=1.5factor=2.0
original histogramcontrast 1.5 histogramcontrast 2.0 histogram

At factor=1.5 the histogram spreads outward from the mean rather than shifting — the astronaut's silhouette and helmet edges get sharper, while the mean barely moves. At factor=2.0 the spread is wide enough that more pixels pile up at both the 0 and 255 ends, crushing fine midtone detail even as high-contrast edges sharpen further.

Key Insight: Contrast stretches the histogram, while brightness translates it.

Windowing

The FFT implicitly treats an image as one period of an infinitely-repeating signal. If the content doesn't tile seamlessly, which is the general case since nothing arranges an image's edges to match up, that discontinuity leaks energy across many frequencies rather than the few the underlying content actually has, an effect called spectral leakage. In a correlation surface, leakage broadens and can shift the peak, hurting the precision of any technique that searches that surface for a match.

Windowing counters this by tapering an image's edges toward zero before transforming it, so the (still discontinuous, but now near-zero) seam contributes far less energy. Two standard 1D windows, applied to an image by taking the outer product of a window with itself along each axis:

for across a window of length . Hann tapers all the way to exactly zero at both ends; Hamming stops short (around ), trading a little residual discontinuity for a narrower main lobe in the transformed signal.

See Harris FJ. "On the use of windows for harmonic analysis with the discrete Fourier transform." Proceedings of the IEEE 1978;66(1):51-83. A U.S. government work, not protected by U.S. copyright.

window()

dictk.correlation.window applies either taper to a 2D array. This reuses kernel from Cross Correlation (CC) and the Fourier Domain section of Correlation Criteria — the same checkerboard0, p0, and kernel_margin — to show what tapering actually does to an image before it's passed to an FFT:

import numpy as np
import matplotlib.pyplot as plt
from dictk.image import read, PixelCoordinate, subimage, write
from dictk.correlation import window, WindowingMethod

reference_image = read(path="checkerboard0.png")
p0 = PixelCoordinate(x=100, y=75)
kernel_margin = 25
kernel = subimage(
    image=reference_image,
    origin=PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin),
    width=2 * kernel_margin,
    height=2 * kernel_margin,
)
write(arr=kernel, path="windowing_kernel_original.png")

kernel_hann = window(arr=kernel, method=WindowingMethod.HANN)
write(arr=kernel_hann.astype(np.uint8), path="windowing_kernel_hann.png")

kernel_hamming = window(arr=kernel, method=WindowingMethod.HAMMING)
write(arr=kernel_hamming.astype(np.uint8), path="windowing_kernel_hamming.png")

# window()'s own weights, isolated from kernel's content: windowing an
# all-ones array leaves exactly the 2D weight array behind. A single row
# at the kernel's mid-height cuts through the row axis's own peak (~1.0),
# so what's left is each method's column-axis taper alone.
mid_row = kernel.shape[0] // 2
ones = np.ones_like(kernel, dtype=np.float64)
weight_profiles = {
    "none": np.ones(kernel.shape[1]),
    "hann": window(arr=ones, method=WindowingMethod.HANN)[mid_row, :],
    "hamming": window(arr=ones, method=WindowingMethod.HAMMING)[mid_row, :],
}
for name, profile in weight_profiles.items():
    fig, ax = plt.subplots(figsize=(4, 2.5), constrained_layout=True)
    ax.plot(profile, color="black")
    ax.set_ylim(-0.05, 1.05)  # shared across all three, for a fair comparison
    ax.set_xlabel("x (pixels)")
    ax.set_ylabel("window weight")
    fig.savefig(f"windowing_kernel_cut_{name}.png", dpi=300)
    plt.close(fig)
Saved: windowing_kernel_original.png, windowing_kernel_hann.png, windowing_kernel_hamming.png, windowing_kernel_cut_none.png, windowing_kernel_cut_hann.png, windowing_kernel_cut_hamming.png
noneHannHamming
original kernelHann-windowed kernelHamming-windowed kernel
none weight cut-throughHann weight cut-throughHamming weight cut-through

Every edge fades toward black; Hann's corners go fully black (tapers to exactly 0), while Hamming's stay a faint gray (tapers to of the original corner pixel, the product of both axes' own edge value).

The bottom row makes each method's own taper precise, independent of checkerboard0's content: a horizontal cut through the window's weight array at the kernel's mid-height, all three sharing the same -axis. none is flat at everywhere -- no taper at all. Hann and Hamming both peak at at that same mid-height (the row axis's own window is near its own peak there), so this cut isolates the column axis's taper alone: Hann reaches exactly at both edges, Hamming levels off at -- not the smaller corner value above, since a corner is where both axes are simultaneously at their own edge, and a mid-height cut only ever passes through one axis's edge at a time.

See Correlation Visualization for windowing shown in action, tapering a real kernel and search area before they're compared.

Image Transformation

Image deformations, also called transformations in the computer vision literature (see Szeliski1), fall into the categories shown below:

2d-planar-transformations
Figure: Categories of 2D planar transformations from Szeliski.

Each category preserves a different, nested set of geometric properties — every property a category preserves is also preserved by every more restrictive category to its left:

PropertyTranslationEuclideanSimilarityAffineProjective
Straight lines stay straightYesYesYesYesYes
Parallel lines stay parallelYesYesYesYesNo
Angles preservedYesYesYesNoNo
Lengths/distances preservedYesYesNoNoNo
Absolute orientation preserved (no rotation)YesNoNoNoNo

Pure Translation (Rigid Body Motion)

As the simplest of the categories above — no change in shape or size — dictk.image.translate shifts every pixel by a fixed displacement. This example shifts the image by dx=-60 pixels in x and dy=+80 pixels in y, representing rigid-body motion where the material moves without deforming.

import dictk
from dictk.image import translate, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_translate_original.png")

translated = translate(arr=photo, dx=-60, dy=80)
write(arr=translated, path="astronaut_translate_rigid_body.png")
Saved: astronaut_translate_original.png, astronaut_translate_rigid_body.png
TranslationImage
Originaloriginal
dx=-60, dy=+80rigid-body translation

Pure Rotation

A 30° counterclockwise rotation, another rigid-body motion that preserves distances and angles. dictk.image.rotate pivots on the image's top-left corner (0, 0), consistent with stretch and translate's pivot choice in this codebase — unlike the more typical "object spins in place" rotation about the center, most content swings away from that fixed corner, similar to a door on a hinge.

import dictk
from dictk.image import rotate, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_rotate_original.png")

rotated = rotate(arr=photo, angle=30.0)
write(arr=rotated, path="astronaut_rotate_30deg.png")
Saved: astronaut_rotate_original.png, astronaut_rotate_30deg.png
RotationImage
Originaloriginal
30° (origin-pivoted)30 degree rotation

X-Axis Stretch (Extension)

As a concrete example of the similarity category above, dictk.image.stretch applies a uniaxial stretch along the x-axis: the image's top-left corner (x=0, y=0) stays fixed, and content grows away from it, using backward mapping with bilinear interpolation so the result has no gaps (unlike naively moving each source pixel forward, which can leave holes). The two stretches below range from a small, realistic deformation (5%, similar in magnitude to a modest tensile strain in a materials test) up to a much larger one (50%).

import dictk
from dictk.image import stretch, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_stretch_original.png")

stretch_5pct = stretch(arr=photo, factor_x=1.05)
write(arr=stretch_5pct, path="astronaut_stretch_x_5pct.png")

stretch_50pct = stretch(arr=photo, factor_x=1.50)
write(arr=stretch_50pct, path="astronaut_stretch_x_50pct.png")
Saved: astronaut_stretch_original.png, astronaut_stretch_x_5pct.png, astronaut_stretch_x_50pct.png
StretchImage
Originaloriginal
5% (factor_x=1.05)5% x-axis stretch
50% (factor_x=1.50)50% x-axis stretch

Y-Axis Stretch (Compression)

The same dictk.image.stretch function compresses along the y-axis with factor_y < 1.0. Pivoting on the origin means the top edge (y=0) stays fixed while content shrinks toward it, leaving a black margin along the bottom — the mirror image of the x-axis stretch case, where growth away from the origin never leaves a gap.

import dictk
from dictk.image import stretch, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_compress_original.png")

compress_neg5pct = stretch(arr=photo, factor_y=0.95)
write(arr=compress_neg5pct, path="astronaut_compress_y_neg5pct.png")

compress_neg50pct = stretch(arr=photo, factor_y=0.50)
write(arr=compress_neg50pct, path="astronaut_compress_y_neg50pct.png")
Saved: astronaut_compress_original.png, astronaut_compress_y_neg5pct.png, astronaut_compress_y_neg50pct.png
CompressionImage
Originaloriginal
-5% (factor_y=0.95)-5% y-axis compression
-50% (factor_y=0.50)-50% y-axis compression

Simple Shear

A shear deformation with γ = 0.5, where horizontal planes slide relative to each other by an amount proportional to their y-coordinate — the higher up a row of pixels, the further it shifts sideways. dictk.image.shear pivots on the image's top-left corner (0, 0), consistent with the other transform functions in this codebase.

import dictk
from dictk.image import shear, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_shear_original.png")

sheared = shear(arr=photo, shear_x=0.5)
write(arr=sheared, path="astronaut_shear_x_0.5.png")
Saved: astronaut_shear_original.png, astronaut_shear_x_0.5.png
ShearImage
Originaloriginal
γ = 0.5 (shear_x=0.5)simple shear

Complex Deformation

Combines rotation (15°) with anisotropic stretching (1.3x in x, 0.8x in y) — realistic loading scenarios where materials experience multiple simultaneous deformation modes, typically the hardest case for correlation algorithms. dictk.image.complex_deform composes the two into a single deformation gradient (stretch applied first, then rotation) and applies it in one backward-mapping pass, so the result isn't blurred by interpolating twice as calling stretch and then rotate separately would.

import dictk
from dictk.image import complex_deform, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_complex_original.png")

combined = complex_deform(arr=photo, factor_x=1.3, factor_y=0.8, angle=15.0)
write(arr=combined, path="astronaut_complex_deform.png")
Saved: astronaut_complex_original.png, astronaut_complex_deform.png
Composed DeformationImage
Originaloriginal
factor_x=1.3, factor_y=0.8, angle=15°composed deformation

Crack Dislocation

A vertical crack splits the image at x = width/2: the left half shifts down 4 pixels and the right half shifts up 4 pixels, producing a displacement field that jumps discontinuously across the crack line — unlike every other example on this page, which deforms smoothly. Standard DIC assumes smooth displacements and cannot capture this jump; cases like this motivate the Heaviside finite-element formulation.

import dictk
from dictk.image import crack_dislocation, write

photo = dictk.astronaut(width=300, height=300)
write(arr=photo, path="astronaut_crack_plain_original.png")

cracked_plain = crack_dislocation(arr=photo, offset=4.0)
write(arr=cracked_plain, path="astronaut_crack_plain_dislocation.png")
Saved: astronaut_crack_plain_original.png, astronaut_crack_plain_dislocation.png
Crack DislocationImage
Originaloriginal
offset=4 pixelscrack dislocation

References


  1. Szeliski R. Computer vision: algorithms and applications, 2nd Edition, Springer Nature; 2022 Jan 3. download (43 MB)

Single Point Motion

Consider a single point , fixed to a physical location on the object being imaged. In the reference image , this point is located at a known pixel location, for example pixels. This vector, from the origin of the reference image frame to the pixel point , locates the reference configuration. For brevity, we will use to denote the fully explicit vector .

Next, the object is moved (e.g., translated, rotated, stretched, or deformed — see Image Transformation). A second image , called the current image, is taken. Where is point from located in ? We label point 's found location in as . For brevity, we will use to denote the fully explicit vector .

Note that the camera itself has not moved, only the object and any point of interest on the object have moved. The origin and the reference frame are the same across the two images and .

The canonical problem solved by digital image correlation (DIC) is as follows:

  • Given a point in image , find the location of that same point in image .

Below, we motivate this canonical problem with a simple example of a single point translation. We first develop a manual solution to serve as the known ground truth. Then, we illustrate how dictk.translation.locate solves this problem numerically via DIC.

Reference Configuration

The examples below reuse checkerboard0, the speckle pattern combined with the checkerboard introduced in Image Generation. This will be the reference_image, matching locate's own parameter name:

from dictk.image import read, PixelCoordinate
from dictk.plot import point_plot, ArrowAnnotation

reference_image = read(path="checkerboard0.png")

p0 = PixelCoordinate(x=100, y=75)
point_plot(
    image=reference_image,
    arrows=[
        ArrowAnnotation(
            tail=PixelCoordinate(x=0, y=0), head=p0, color="orange", label=r"$\boldsymbol{p}_0$"
        )
    ],
    figsize=(6.4, 4.8),
    path="single_point_motion_p0.png",
)
Saved: single_point_motion_p0.png
reference image with reference configuration p0 marked by an orange arrow from the origin
Reference image and reference configuration (orange arrow) pixels.

Current Configuration and Displacement

For this page, the current image is generated with dictk.image.translate (see Image Transformation): every pixel of reference_image shifts by the same (dx, dy), a rigid-body translation. Because the whole image moves together, point 's new location follows directly:

from dictk.image import translate

dx, dy = -6, 8
current_image = translate(arr=reference_image, dx=dx, dy=dy)
p1 = PixelCoordinate(x=p0.x + dx, y=p0.y + dy)  # ground truth, known here by construction

We define the displacement of the point as the relative motion between the reference configuration and the current configuration , such that

so with and ,

point_plot(
    image=current_image,
    arrows=[
        ArrowAnnotation(
            tail=PixelCoordinate(x=0, y=0), head=p0, color="orange", label=r"$\boldsymbol{p}_0$"
        ),
        ArrowAnnotation(
            tail=PixelCoordinate(x=0, y=0), head=p1, color="cyan", label=r"$\boldsymbol{p}_1$"
        ),
        ArrowAnnotation(
            tail=p0, head=p1, color="magenta", label=r"$\delta \boldsymbol{p}$"
        ),
    ],
    figsize=(6.4, 4.8),
    path="single_point_motion_p1_displacement.png",
)
Saved: single_point_motion_p1_displacement.png
current image with reference configuration p0 marked by an orange arrow from the origin, current configuration p1 marked by a cyan arrow from the origin, and displacement marked by a magenta arrow from p0 to p1
Current image with reference configuration (orange arrow) pixels, current configuration (cyan arrow) pixels, and displacement (magenta arrow) pixels. Because the object has moved, the image shows a black margin on the top and right, with height 8 pixels and width 6 pixels, respectively, and cropping of the squares on the left and bottom of the image.

In the example above, p1 was only known in advance because we generated current_image ourselves with a known translate. In practice, the location is unknown and found via DIC of a pair of images.

Below, we illustrate the canonical DIC process:

  • Given a in the reference_image, find in the current_image.

The next page, Cross Correlation (CC), shows how the locate function calculates directly, using the technique its name describes.

Cross Correlation (CC)

Cross-correlation can be used to find where point in the reference_image can be found in the current_image.

There are many different implementations of cross-correlation. We discuss the varied implementations in Correlation Criteria. For now, it is sufficient to know only that cross-correlation is used to locate a point in a current image given a known location of that same point in a reference image. The current focus is to make the subordinate concepts underlying cross-correlation be well-defined and well-illustrated.

Let the kernel (also called a subset, filter, or convolution matrix) be a rectangular region of reference_image centered on , the vector that locates point from origin in the reference_image. The kernel is a small, distinctive patch of the reference image content that we want to locate within a subsequent image.

In the needle in a haystack idiom, the kernel is the needle, and the haystack is current_image. To keep the search tractable, we don't search the entire haystack — we constrain it to a search area (also called the area of interest (AOI), search window, or scanning zone). The search area is a subimage of the current_image, centered on a search_center, which is a guess of roughly where ended up, not the answer itself.

While there are techniques derived from macro deformation metrics that can provide a good first guess for the search_center, for simplicity, and since the deformations are small, we reuse itself as the search_center in this example.

Because the kernel and search area are themselves subimages of a larger image (see Subimage Generation), each subimage has its own local frame:

  • Let be the reference frame of the kernel subimage.
  • Let be the reference frame of the search area subimage.

reference_image, p0, current_image, and p1 are the same as in Single Point Motion:

from dictk.image import read, translate, PixelCoordinate

reference_image = read(path="checkerboard0.png")
p0 = PixelCoordinate(x=100, y=75)
dx, dy = -6, 8
current_image = translate(arr=reference_image, dx=dx, dy=dy)
p1 = PixelCoordinate(x=p0.x + dx, y=p0.y + dy)

Kernel

From reference_image, extract the kernel surrounding , with a 25-pixel margin on every side (50x50 total):

from dictk.plot import subimage_comparison_plot

kernel_margin = 25
kernel_origin = PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin)
subimage_comparison_plot(
    image=reference_image,
    origin=kernel_origin,
    width=2 * kernel_margin,
    height=2 * kernel_margin,
    point=p0,
    point_color="orange",
    point_label="$P$",
    subimage_label="kernel",
    color="green",
    origin_label="$K$",
    source_origin_label="$O$",
    figsize=(6.4, 4.8),
    path="single_point_motion_kernel.png",
)
Saved: single_point_motion_kernel.png
kernel placement in the reference image with point P marked by an orange dot, and the extracted kernel itself with point P marked by an orange dot
Left: the kernel (green box), a 50x50 region of reference_image centered on , with origin pixels (green dot); point itself is the orange dot at . Right: the extracted kernel, in its own local reference frame ; the same point (orange dot) is now at pixels.

The kernel has its own local coordinate system , with origin at its top-left corner. Point 's position is the same in both frames, just expressed relative to a different origin:

Since the kernel is centered on with a 25-pixel margin, pixels. Point always sits at (kernel_margin_width, kernel_margin_height) within the kernel's own frame, regardless of where the kernel came from in reference_image.

Search Area

From current_image, extract the search area surrounding search_center (here, again, since it is currently our best guess), with a 50-pixel margin on every side (100x100 total):

search_margin = 50
search_center = p0
search_origin = PixelCoordinate(
    x=search_center.x - search_margin, y=search_center.y - search_margin
)
subimage_comparison_plot(
    image=current_image,
    origin=search_origin,
    width=2 * search_margin,
    height=2 * search_margin,
    subimage_label="search area",
    origin_label="$S$",
    source_origin_label="$O$",
    figsize=(6.4, 4.8),
    path="single_point_motion_search.png",
)
Saved: single_point_motion_search.png
search area placement in the current image, and the extracted search area itself
Left: the search area (red box), a 100x100 region of current_image centered on search_center, with origin pixels (red dot); the source image's own origin is labeled , the search area's origin is labeled . Right: the extracted search area on its own, in its own local reference frame , with origin .

The search area likewise has its own local frame , origin at its top-left corner. The location of in the current_image is given by:

The goal of the DIC process is to locate by solving for the quantity . The location of in the search area's local frame is the single unknown; all other vectors are known.

Solution

The insight into the solution is to further decompose into the sum of two additional vectors:

The second term, , is a known constant. The first term, , is unknown and can be calculated using cross-correlation. When the kernel and search area subimages align, their cross-correlation is maximized. We find the maximum cross-correlation to determine and thus calculate .

This is exactly what dictk.translation.locate computes internally — via skimage.registration.phase_cross_correlation for . The locate function returns directly (one does not assemble the vector chain manually).

phase_cross_correlation is a Fourier-domain computation — every locate call in this book takes that route under the hood, rather than sliding the kernel across the search area one position at a time. Correlation Criteria examines that Fourier-domain implementation in greater depth, alongside the spatial-domain CC, NCC, ZCC, and ZNCC criteria it complements.

Locating the Point

from dictk.translation import locate

found = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_point=p0,
    search_center=search_center,
    kernel_margin_width=kernel_margin,
    kernel_margin_height=kernel_margin,
    search_margin_width=search_margin,
    search_margin_height=search_margin,
)
print(f"found = {found}")
print(f"displacement = ({found.x - p0.x}, {found.y - p0.y})")
found = PixelCoordinate(x=94, y=83)
displacement = (-6, 8)

found matches the ground-truth pixels from earlier, recovering the known displacement pixels using only the two images and — exactly the information available for a real (not synthetically generated) image pair.

Visualizing the Solution

For illustration, we can back out — the one quantity locate finds via cross-correlation, everything else here being known geometry — from found and the boxed equation above, and draw the full chain on current_image:

from dictk.plot import point_plot, ArrowAnnotation, BoxAnnotation, PointAnnotation

r_sk = PixelCoordinate(
    x=found.x - search_origin.x - kernel_margin,
    y=found.y - search_origin.y - kernel_margin,
)
kernel_found_origin = PixelCoordinate(
    x=search_origin.x + r_sk.x, y=search_origin.y + r_sk.y
)
image_height, image_width = current_image.shape
point_plot(
    image=current_image,
    boxes=[
        BoxAnnotation(
            origin=PixelCoordinate(x=0, y=0),
            width=image_width,
            height=image_height,
            color="blue",
            label="source image",
        ),
        BoxAnnotation(
            origin=search_origin,
            width=2 * search_margin,
            height=2 * search_margin,
            color="red",
            label="search area",
        ),
        BoxAnnotation(
            origin=kernel_found_origin,
            width=2 * kernel_margin,
            height=2 * kernel_margin,
            color="green",
            label="kernel",
        ),
    ],
    points=[
        PointAnnotation(position=PixelCoordinate(x=0, y=0), label="$O$", color="blue"),
        PointAnnotation(position=search_origin, label="$S$", color="red"),
        PointAnnotation(position=kernel_found_origin, label="$K$", color="green"),
        PointAnnotation(position=found, label="$P$", color="black"),
    ],
    arrows=[
        ArrowAnnotation(
            tail=PixelCoordinate(x=0, y=0),
            head=found,
            color="cyan",
            label=r"$\boldsymbol{r}_{OP'/\mathcal{F}}$",
        ),
        ArrowAnnotation(
            tail=PixelCoordinate(x=0, y=0),
            head=search_origin,
            color="blue",
            label=r"$\boldsymbol{r}_{OS/\mathcal{F}}$: search area origin",
        ),
        ArrowAnnotation(
            tail=search_origin,
            head=kernel_found_origin,
            color="red",
            label=r"$\boldsymbol{r}_{SK/\mathcal{S}}$: kernel found in search area",
        ),
        ArrowAnnotation(
            tail=kernel_found_origin,
            head=found,
            color="green",
            label=r"$\boldsymbol{r}_{KP/\mathcal{K}}$: point within kernel",
        ),
    ],
    figsize=(6.4, 4.8),
    path="single_point_motion_solution_vectors.png",
)
Saved: single_point_motion_solution_vectors.png
chained vector solution: cyan shortcut arrow from origin directly to the found point, blue arrow from origin to search area origin, red arrow to the located kernel, green arrow to the found point, with a blue source-image box, a red search-area box, and a green kernel box drawn behind the arrows, and O, S, K, P labels drawn on top
The current configuration (tip of the cyan and green arrows) as the vector chain (blue) (red) (green), equal to the direct shortcut (cyan), drawn on current_image — with the source image (blue box), search area (red box), and the kernel as found within it (green box) shown behind the arrows, each origin labeled: , , , and the found point .
vector, valuedescription
+origin of the search area (blue arrow)
+kernel located within the search area, from cross-correlation (red arrow)
=point's fixed position within the kernel (green arrow)
current position , matching found above (cyan arrow)

NOTE: Cross-correlation may be conceptualized as the sliding dot product of pixel values from the kernel with pixel values from the search area. In this discussion we have described sliding the kernel across a stationary search area. The reverse, sliding the search area across a stationary kernel, is conceptually different but mathematically identical. Both approaches yield the same result: , which locates the kernel frame in the search area frame.

Next: Correlation Criteria defines the four cross-correlation formulas and explains the Fourier-domain route locate actually takes, and Correlation Visualization visualizes each of them on this same kernel and search area.

Correlation Criteria

Cross-correlation itself can be computed two ways: directly in the spatial domain — literally sliding the kernel over the search area and summing a per-position inner product, as shown below — or in the Fourier domain via the fast Fourier transform (FFT), which is what locate actually does (see Fourier Domain, below). Both compute the same underlying quantity, but at very different cost: for the sliding sum, evaluated at every candidate offset, against for the FFT, with the number of pixels — a gap that widens sharply as images grow beyond this page's small teaching examples.

Spatial Domain

Cross Correlation (CC) walks through the geometry of locating a point: the kernel/search-area vector chain, solved by finding where their cross-correlation is maximized. This page covers what "cross-correlation" actually means as a formula — several related criteria are used in the spatial domain, differing in how each responds to brightness and contrast differences between the kernel and a candidate window — a same-sized window of the search area at one particular offset — summed pixelwise over index :

  • Cross-Correlation (CC)

  • Normalized Cross-Correlation (NCC)

  • Zero-mean Cross-Correlation (ZCC)

    where and likewise for .

  • Zero-mean Normalized Cross-Correlation (ZNCC)

    where and .

Invariance and Robustness

Invariance describes whether or not a correlation is robust or insensitive to changes in brightness and/or contrast.

  • For brightness, which is additive, subtracting each side's own mean cancels any constant added to that side, making "Zero-mean" approaches effective.
  • For contrast, which is multiplicative, dividing by each side's own norm cancels any constant scaling of that side, making "Normalized" approaches effective.

Whether a criterion performs each of those two cancellations determines its invariance:

MethodInvariant to brightness (additive)Invariant to contrast (multiplicative)Robustness
CC❌ No❌ NoLeast robust — neither cancellation
NCC❌ No✅ YesOnly robust to contrast changes
ZCC✅ Yes❌ NoOnly robust to brightness changes
ZNCC✅ Yes✅ YesMost robust

ZNCC combines ZCC's mean-subtraction (brightness invariance) with NCC's norm-division (contrast invariance), which is why it's the standard choice in most DIC implementations — including dictk.translation.locate's own underlying skimage.registration.phase_cross_correlation call (see Fourier Domain, below).

Neither cancellation helps against nonlinear or spatially-varying brightness/contrast (a shadow crossing part of the kernel, sensor saturation) — none of the four criteria above address that.

Brightness and Contrast Invariance in Practice

The table above is a formula-level guarantee, verified here on astronaut0 — the speckle-over-photograph image used from Multi-Point Motion onward — rather than taken on faith. Extract a kernel from astronaut0 unmodified, then compare it against a search area from a translated and brightness-shifted copy of the same image, using dictk.image.brightness with a small enough factor that no pixel clips at 255 (clipping is a genuine loss of information no correlation criterion can see past, and would contaminate this test):

from dictk.image import read, translate, brightness, PixelCoordinate, subimage
from dictk.correlation import cc, ncc, zcc, zncc

astronaut0 = read(path="astronaut0.png")
p0 = PixelCoordinate(x=100, y=100)
kernel_margin, search_margin = 25, 50
kernel = subimage(image=astronaut0, origin=PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin), width=2 * kernel_margin, height=2 * kernel_margin)

dx, dy = -6, 8
current_baseline = translate(arr=astronaut0, dx=dx, dy=dy)
current_bright = brightness(arr=current_baseline, factor=1.01)  # +1.275 per pixel, no clipping here

search_origin = PixelCoordinate(x=p0.x - search_margin, y=p0.y - search_margin)
baseline_search = subimage(image=current_baseline, origin=search_origin, width=2 * search_margin, height=2 * search_margin)
bright_search = subimage(image=current_bright, origin=search_origin, width=2 * search_margin, height=2 * search_margin)

for name, fn in [("CC", cc), ("NCC", ncc), ("ZCC", zcc), ("ZNCC", zncc)]:
    baseline_peak = fn(kernel=kernel, search=baseline_search).max()
    bright_peak = fn(kernel=kernel, search=bright_search).max()
    pct_change = (bright_peak - baseline_peak) / abs(baseline_peak) * 100
    print(f"{name}: peak value change under brightness shift = {pct_change:+.4f}%")
CC: peak value change under brightness shift = +0.6431%
NCC: peak value change under brightness shift = -0.0007%
ZCC: peak value change under brightness shift = +0.0000%
ZNCC: peak value change under brightness shift = +0.0000%

ZCC and ZNCC come back at exactly +0.0000% — bit-for-bit unchanged, as the formula guarantees for any brightness shift small enough to avoid clipping. CC and NCC both drift, confirming they are not brightness invariant — even though, on astronaut0's strong, distinctive texture, that drift isn't large enough to move where the peak lands, only its value. That value-only distinction still matters in practice: it's what makes CC's raw magnitude unsafe to compare across different points or lighting conditions in a Multi-Point Motion grid, even on images where its peak still happens to land in the right place for any one point in isolation.

A parallel contrast test — dictk.image.contrast instead of brightness, same astronaut0 kernel/search pair — shows the other pairing:

CC: peak value change under contrast shift = +0.2194%
NCC: peak value change under contrast shift = -0.0052%
ZCC: peak value change under contrast shift = +1.8999%
ZNCC: peak value change under contrast shift = -0.0021%

NCC drifts about 40x less than CC does (-0.0052% vs +0.2194%), and ZNCC about 900x less than ZCC does (-0.0021% vs +1.8999%). Not perfectly bit-exact like the brightness case, because contrast scales around the image's own mean rather than performing a pure multiplicative gain, which mixes in a small secondary additive term — but the qualitative result matches the table: contrast invariance belongs to NCC and ZNCC, not CC or ZCC.

See Pan B, Xie H, Wang Z. "Equivalence of digital image correlation criteria for pattern matching." Applied Optics 2010;49(28):5501-9. [download]

dictk.correlation implements all four as standalone functions (cc, ncc, zcc, zncc), each returning the full correlation surface rather than just its peak — see Correlation Visualization for what those surfaces look like on the kernel and search area established in Cross Correlation (CC).

Next: Correlation Visualization visualizes these four correlation criteria in detail; the Fourier Domain section below explains the route locate itself actually takes.

Fourier Domain

Correlation Visualization computes CC directly in the spatial domain: a literal sliding sum, one value per candidate offset. The convolution theorem gives an equivalent route: multiplying the two images' Fourier transforms (one of them conjugated) and inverse-transforming the product yields that same correlation, all at once, for every offset — without ever explicitly sliding a window. This is exactly what dictk.translation.locate does internally, via skimage.registration.phase_cross_correlation. The appeal isn't a different answer — it's speed: a fast Fourier transform (FFT) costs per image, against the sliding sum's per candidate offset — decisive once images grow beyond this page's small teaching example.

reference_image, p0, current_image, kernel, and search are the same as in Correlation Visualization:

from dictk.image import read, translate, PixelCoordinate, subimage

reference_image = read(path="checkerboard0.png")
p0 = PixelCoordinate(x=100, y=75)
current_image = translate(arr=reference_image, dx=-6, dy=8)

kernel_margin = 25
kernel = subimage(
    image=reference_image,
    origin=PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin),
    width=2 * kernel_margin,
    height=2 * kernel_margin,
)

search_margin = 50
search_center = p0
search = subimage(
    image=current_image,
    origin=PixelCoordinate(
        x=search_center.x - search_margin, y=search_center.y - search_margin
    ),
    width=2 * search_margin,
    height=2 * search_margin,
)

locate pads kernel to search's own shape before comparing them, though not quite with the padding used here — see the note below:

import numpy as np

pad_height = search.shape[0] - kernel.shape[0]
pad_width = search.shape[1] - kernel.shape[1]
kernel_padded = np.pad(kernel.astype(np.float64), ((0, pad_height), (0, pad_width)))

image_product = np.fft.fft2(search.astype(np.float64)) * np.fft.fft2(kernel_padded).conj()
fft_surface = np.fft.ifft2(image_product).real

dy, dx = np.unravel_index(np.argmax(fft_surface), fft_surface.shape)
print(f"FFT-domain peak offset (dx, dy) = ({dx}, {dy})")
FFT-domain peak offset (dx, dy) = (19, 33)

That peak, , matches exactly — the same offset Correlation Visualization's cc() surface and locate itself both find. That agreement is about the peak's location only. fft_surface here and locate's own computation differ in three ways, none of which change where the peak lands here, on this page's small, comfortably-within-bounds displacement:

  1. Shape. fft_surface is the circular correlation over the full padded extent (search's own shape, 100x100). cc() returns valid positions only (a smaller 51x51 array, no wraparound). The two arrays don't share a shape, so np.allclose between them wouldn't be meaningful.
  2. Normalization. fft_surface is a raw, unnormalized cross-power spectrum. locate instead passes normalization="phase" to phase_cross_correlation, dividing that spectrum by its own magnitude at every frequency before inverting it (see the extensive comment in locate's source for why).
  3. Padding anchor. kernel_padded above keeps kernel's content anchored at the padded array's top-left corner (np.pad's own default), matching cc()'s corner-offset convention above. locate centers it instead — a reason worth knowing once you've worked with locate a bit more: see Recoverable Displacement Range.

Correlation Visualization

This page visualizes each of the four spatial-domain correlation criteria from Correlation Criteria — CC, NCC, ZCC, and ZNCC — one at a time, in a four-panel composite reproducing a reference composite-figure layout used in prior DIC tooling, via dictk.plot.spatial_correlation_quadrant_plot: the search area with the found kernel marked (Fixed Image), the kernel itself zero-padded to the search area's shape (Moving Image), the full correlation surface, and a zoomed Solution Vicinity around its peak — closer to how a single registration result is typically inspected in practice than a side-by-side comparison of criteria.

reference_image, p0, current_image, kernel_margin, search_margin, kernel, and search are the same as in Cross Correlation (CC):

from dictk.image import read, translate, PixelCoordinate, subimage

reference_image = read(path="checkerboard0.png")
p0 = PixelCoordinate(x=100, y=75)
current_image = translate(arr=reference_image, dx=-6, dy=8)

kernel_margin = 25
kernel = subimage(
    image=reference_image,
    origin=PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin),
    width=2 * kernel_margin,
    height=2 * kernel_margin,
)

search_margin = 50
search_center = p0
search = subimage(
    image=current_image,
    origin=PixelCoordinate(
        x=search_center.x - search_margin, y=search_center.y - search_margin
    ),
    width=2 * search_margin,
    height=2 * search_margin,
)

The Fixed Image panel below plots the search area in its own pixel frame , with a yellow dashed box marking where the kernel was found and red/green dashed guide lines through that box's origin — the same quantity Cross Correlation (CC) solves for by hand. The Correlation Surface panel plots that same quantity as candidate offset and marks the peak with a red circle of radius vicinity_margin (4 pixels by default) — exactly the region the Solution Vicinity panel zooms into, so the same circle reappears there too, now clipped by that panel's own edges.

Cross-Correlation (CC)

from dictk.correlation import cc
from dictk.plot import spatial_correlation_quadrant_plot

spatial_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    correlation_surface=cc(kernel=kernel, search=search),
    title="Cross-Correlation (CC)",
    path="correlation_visualization_cc.png",
)
Saved: correlation_visualization_cc.png
four-panel composite: fixed image with the found kernel boxed in yellow and red/green guide lines, the zero-padded moving image, the CC correlation surface, and a zoomed solution vicinity around its peak
CC's quadrant composite. The Correlation Surface panel is 51×51 — search's 100×100 minus kernel's 50×50, plus one in each dimension — since a value is only defined where the 50×50 kernel fits entirely inside the 100×100 search area ("valid" positions, no wraparound). checkerboard0's tiled pattern repeats every ~25 pixels, so that panel shows more than one strong local peak within its own (smaller, "valid") range — CC has no way to prefer the true one over its look-alikes beyond raw magnitude, unlike the normalized criteria below. The correct one, boxed in yellow in the Fixed Image panel, sits at pixels — matching the value already found by locate in Cross Correlation (CC).

Normalized Cross-Correlation (NCC)

from dictk.correlation import ncc

spatial_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    correlation_surface=ncc(kernel=kernel, search=search),
    title="Normalized Cross-Correlation (NCC)",
    path="correlation_visualization_ncc.png",
)
Saved: correlation_visualization_ncc.png
four-panel composite for NCC: fixed image with the found kernel boxed in yellow and red/green guide lines, the zero-padded moving image, the NCC correlation surface, and a zoomed solution vicinity around its peak
NCC's quadrant composite, bounded to by construction — visible in the colorbar range compared to CC's arbitrary raw units above. Its Correlation Surface panel is the same 51×51 "valid"-positions-only shape as CC's above. Its peak still lands at pixels, matching the value already found by locate in Cross Correlation (CC).

Zero-mean Cross-Correlation (ZCC)

from dictk.correlation import zcc

spatial_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    correlation_surface=zcc(kernel=kernel, search=search),
    title="Zero-mean Cross-Correlation (ZCC)",
    path="correlation_visualization_zcc.png",
)
Saved: correlation_visualization_zcc.png
four-panel composite for ZCC: fixed image with the found kernel boxed in yellow and red/green guide lines, the zero-padded moving image, the ZCC correlation surface, and a zoomed solution vicinity around its peak
ZCC's quadrant composite — raw units like CC's (mean-subtraction alone doesn't bound the range), but brightness-invariant per Correlation Criteria's table. Its Correlation Surface panel is the same 51×51 "valid"-positions-only shape as CC's and NCC's above. Same peak, pixels, as locate already found in Cross Correlation (CC).

Zero-mean Normalized Cross-Correlation (ZNCC)

from dictk.correlation import zncc

spatial_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    correlation_surface=zncc(kernel=kernel, search=search),
    title="Zero-mean Normalized Cross-Correlation (ZNCC)",
    path="correlation_visualization_zncc.png",
)
Saved: correlation_visualization_zncc.png
four-panel composite for ZNCC: fixed image with the found kernel boxed in yellow and red/green guide lines, the zero-padded moving image, the ZNCC correlation surface, and a zoomed solution vicinity around its peak
ZNCC's quadrant composite — both bounded to and invariant to brightness and contrast, which is why dictk.translation.locate's own underlying skimage.registration.phase_cross_correlation call is built on the same combination (see Correlation Criteria). Its Correlation Surface panel is likewise 51×51, "valid" positions only. Peak still at pixels, matching locate's own result in Cross Correlation (CC).

All four land on the same peak, pixels, since kernel and search here share identical brightness and contrast (both come from checkerboard0.png, only translated). What differs between the four is what each panel's colorbar reveals about how safely that peak can be trusted once brightness or contrast do differ, as Correlation Criteria covers in detail.

Phase Correlation

Every panel above comes from a spatial-domain criterion — dictk.correlation's cc/ncc/zcc/ zncc, sliding kernel over search one window at a time. There's a second way to get an equivalent answer: all at once, in the Fourier domain, via dictk.correlation.phase_correlation — the same computation dictk.translation.locate already runs internally via skimage.registration.phase_cross_correlation. Unlike its spatial-domain siblings, there's only one Fourier-domain flavor here, so phase_correlation_quadrant_plot takes kernel/search directly rather than a pre-computed surface — no method to choose, nothing to compute beforehand. It does, however, take an optional windowing parameter (see Windowing): the three subsections below run this same kernel/search pair through no windowing, Hann windowing, and Hamming windowing in turn, so the effect is directly comparable rather than just described.

No Windowing (default)

windowing defaults to None, applying no tapering — this reproduces exactly what every earlier page in this book that calls phase_correlation/locate already does.

from dictk.plot import phase_correlation_quadrant_plot

phase_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    title="Phase Correlation (No Windowing)",
    path="correlation_visualization_phase_none.png",
)
Saved: correlation_visualization_phase_none.png
four-panel composite for phase correlation with no windowing: fixed image with the found kernel boxed in yellow and red/green guide lines, the zero-padded moving image, a correlation surface that is essentially flat except for one sharp isolated peak, and a zoomed solution vicinity around that peak
Phase correlation's quadrant composite, no windowing. Its Correlation Surface panel is a different size than the four above: 100×100, matching search itself, since kernel is zero-padded up to search's shape before the FFT rather than restricted to "valid" positions — every candidate offset, including circular wraparound ones, gets a value. Same peak, pixels — matching the value already found by locate in Cross Correlation (CC) — as every criterion above, but the correlation-surface panel looks nothing like them: essentially flat/uniform everywhere except one crisp, isolated cell, rather than the broader, multi-peaked terrain CC/NCC/ZCC/ZNCC show on this same tiled checkerboard0.png.

Hann Windowing

from dictk.correlation import WindowingMethod

phase_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    windowing=WindowingMethod.HANN,
    title="Phase Correlation (Hann Windowing)",
    path="correlation_visualization_phase_hann.png",
)
Saved: correlation_visualization_phase_hann.png
four-panel composite for phase correlation with Hann windowing: fixed image and moving image both darken toward their own edges, same correlation surface peak location as no windowing
Same 100×100 Correlation Surface shape and the same peak, pixels, as No Windowing above — window() only tapers kernel/search before the FFT, it doesn't change the surface's shape or relocate the peak. Unlike No Windowing's panels, though, the Fixed Image and Moving Image panels here darken toward their own edges too — the same Hann taper Windowing shows on this exact kernel, now applied to what's actually fed into the FFT rather than left as a stale, untapered view next to a surface that no longer matches it. What windowing changes numerically is the surface's own values — see Peak Prominence below for how much.

Hamming Windowing

phase_correlation_quadrant_plot(
    kernel=kernel,
    search=search,
    windowing=WindowingMethod.HAMMING,
    title="Phase Correlation (Hamming Windowing)",
    path="correlation_visualization_phase_hamming.png",
)
Saved: correlation_visualization_phase_hamming.png
four-panel composite for phase correlation with Hamming windowing: fixed image and moving image both darken toward their own edges but not fully to black, same correlation surface peak location as no windowing
Same shape and peak as No Windowing and Hann Windowing above too, and the same tapered Fixed Image/Moving Image panels — but Hamming's taper stops short of exactly 0 at the edges (around , per Windowing), trading a little residual discontinuity for a narrower main lobe, visible here as a fainter, not-quite-black edge compared to Hann's above. See Peak Prominence below for how that plays out numerically against Hann.

Peak Prominence

That sharpness isn't just a visual impression. Define a correlation surface's peak prominence as how many standard deviations above its own mean the peak sits — a scale-independent way to compare surfaces with very different raw units (CC's arbitrary sums, NCC/ZNCC's -bounded values, phase correlation's own normalized range):

for a correlation surface flattened to its values. By this measure, all three phase correlation surfaces above are dramatically higher than any spatial-domain criterion — and windowing raises that further still, even on this book's clean, noise-free synthetic images:

CC: prominence P = 4.93
NCC: prominence P = 5.60
ZCC: prominence P = 5.48
ZNCC: prominence P = 5.60
Phase correlation (no windowing): prominence P = 38.91
Phase correlation (Hann): prominence P = 56.76
Phase correlation (Hamming): prominence P = 58.95

A histogram of each surface's own values makes the same result visual: each panel's dashed red line is that surface's peak, at the value computed above.

import matplotlib.pyplot as plt
from dictk.correlation import cc, ncc, zcc, zncc, phase_correlation, WindowingMethod

surfaces = {
    "CC": cc(kernel=kernel, search=search),
    "NCC": ncc(kernel=kernel, search=search),
    "ZCC": zcc(kernel=kernel, search=search),
    "ZNCC": zncc(kernel=kernel, search=search),
    "Phase correlation\n(no windowing)": phase_correlation(kernel=kernel, search=search),
    "Phase correlation\n(Hann)": phase_correlation(kernel=kernel, search=search, windowing=WindowingMethod.HANN),
    "Phase correlation\n(Hamming)": phase_correlation(kernel=kernel, search=search, windowing=WindowingMethod.HAMMING),
}

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, axes = plt.subplots(4, 2, figsize=(11, 16), constrained_layout=True)
for ax, (name, surface) in zip(axes.flat, surfaces.items()):
    flat = surface.ravel()
    prominence = (flat.max() - flat.mean()) / flat.std()
    ax.hist(flat, bins=60, color="black", alpha=0.7)
    ax.axvline(flat.max(), color="red", linestyle="--", linewidth=1.5)
    ax.set_yscale("log")
    ax.set_title(f"{name}: $P = {prominence:.1f}$")
    ax.set_xlabel("surface value")
    ax.set_ylabel("frequency")
axes.flat[-1].axis("off")  # 7 panels in a 4x2 grid -- last slot stays empty
fig.savefig("correlation_visualization_prominence.png", dpi=300)
Saved: correlation_visualization_prominence.png
seven histogram panels, one per correlation criterion/windowing combination, each showing the distribution of that surface's own values with a dashed red line marking its peak; the four spatial criteria show a broad bell-like spread with the peak in a modestly separated upper tail, while the three phase correlation panels each show a narrow spike near zero with its peak isolated far to the right, well beyond any other bar, windowed variants more so
Each surface's own value distribution (log-scaled frequency, 60 bins), dashed red line at its peak — every dashed line marks the same -pixel location locate already found in Cross Correlation (CC), just plotted by value here rather than position. CC/NCC/ZCC/ZNCC's peaks sit a short, visible distance beyond their own bulk. All three phase correlation panels sit in a class of their own — an empty gap separates each peak from every other value its surface takes on — and windowing (Hann, Hamming) narrows that surface's own bulk further still, widening the gap even more.

Windowing's effect here isn't about relocating the peak — all seven surfaces, spatial and Fourier alike, land on the same -pixel offset — it's about how far above the rest of the surface that peak stands. No-windowing phase correlation already beats every spatial criterion by a wide margin (prominence 38.91 vs. ZNCC's 5.60, the best of the four); Hann windowing raises that to 56.76 and Hamming to 58.95, by lowering the energy the leaking, untapered edges were contributing everywhere else on the surface, so the same peak stands out further above that now-lower background. Hann and Hamming land close to each other, both clearly above no windowing — a real, measurable benefit even before considering the noisier, less-clean real-world images this book's synthetic ones deliberately simplify away.

Phase correlation's peak already stands roughly seven times taller above its own background, relative to the surface's own spread, than even ZNCC — the most robust of the four spatial criteria — before windowing is even applied. That sharpness, not just brightness/contrast invariance, is a second, independent reason dictk.translation.locate is built on phase correlation rather than a spatial-domain criterion: a sharper peak is easier to locate with confidence and precision, and harder to confuse with a nearby runner-up. locate accepts the same windowing parameter too (see Windowing) — the prominence gain above isn't unique to the surface phase_correlation() exposes for visualization; it applies wherever the same FFT-based comparison runs, locate included.

Multi-Point Motion

Single Point Motion tracked exactly one point, , between a reference and current image. Digital image correlation was illustrated in the context of that single point. Now, we turn to consider many points at once. A collection of tracked points will serve as the nodes of a finite element mesh. This page shows how to track many points simultaneously, and motivates the connection to the Finite Element Method (FEM).

Commercial DIC Context

Commercial DIC software sets up a measurement in a specific order, and it runs opposite to order presented on this page. The reversal is a deliberate choice because we have not yet introduced subpixel accuracy. After subpixel accuracy is discussed, dictk will follow the same order used by commerical DIC software, described next:

Kernel size comes first. A kernel must contain enough distinctive texture to correlate reliably. An image will contain features (e.g., a speckle pattern feature such as a corner or edge). The goal is to get enough (but not too many) pixels to describe a feature. Too few pixels cause the kernel contents to be ambiguous. Too many pixels cause the kernel to be saturated with pixels that do not participate in the feature, resulting in poor-to-no correlation. Too many pixels also can also increase computational cost beyond what is necessary for a successful correlation.

Ultimately, the size of the kernel is based on the speckle pattern's own feature size and the camera's resolution, which dictates the number of pixels per unit length present in the image.

Point spacing comes second. Once kernel size is fixed, point spacing (where to place each kernel center) follows from it.

  • Some practitioners deliberately overlap neighboring kernels: A common convention is 50-75% overlap. So spacing works out to roughly a quarter to a half of the kernel's own side length — to oversample the field for a smoother reconstruction.
  • Others keep kernels non-overlapping, so each point's own measurement stays independent of its neighbors': No two points ever look at the same underlying pixels.

Either way, the same tradeoff governs the choice: Too close, and neighboring kernels duplicate each other's content; too far apart, and the measurement undersamples the field.

The point grid becomes FEA nodes afterward, not before. Once tracking finishes, the resulting grid of measured points is what gets used as finite-element nodes. The mapping between DIC points and mesh points can be direct or indirect. Direct build a mesh directly from the DIC point cloud. Indirect uses the point cloud as an interpolation basis for a separately designed mesh. The FE mesh's density inherits the kernel-and-spacing choice for the correlation.

Let's continue with this example with a rather large choice for a pixel size. Let kernel_margin_width=20 pixel and kernel_margin_height=20 pixel. The kernel's side length is twice its margin: pixels. This kernel is enough to contain plenty of distinctive texture on astronaut0's uniformly-speckled, synthetic surface, where no single location demands special care over another. Heuristically, we typically use kernel sizes of 25 x 25 pixel, up to 35 x 35 pixel (considerably smaller than the 40 x 40 pixel used in this example).

A common rule of thumb (no hard requirement behind it) is to keep a kernel's own side length comfortably inside the point spacing — the geometric floor for zero overlap is exact: with an isotropic kernel (kernel_margin_width=kernel_margin_height), two neighboring kernels start overlapping once the kernel's own full side length exceeds the spacing between their center points. For this example, spacing has to reach at least 40 pixels in both directions to clear that floor; right at exactly 40 pixels, neighboring kernels would touch with zero gap between them.

For now, we choose a point spacing not based on kernel size, but on locations that, given a prescribed stretch factor, will land exactly on an integer location in the deformed configuration. We need integer positions for now because we have not yet introduced subpixel accuracy.

Let spacing_x=50 px, spacing_y=55 px be the point spacing for this page's example, keeping kernels non-overlapping (the second convention named above) — both comfortably above that 40-pixel floor: a 10-pixel gap in and a 15-pixel gap in , so every kernel's own boundary will read as visibly separate from its neighbors', not merely non-overlapping.

The following figure illustrates point spacing in the context the kernel's size:

two green 40x40 pixel kernel boxes centered on points 00 and 01, 50 pixels apart, with a third point 02 shown for context; dimension arrows show 40 px across each kernel, 50 px between point centers, and 10 px in the gap between the two kernels' facing edges
Points 00 and 01, 50 pixels apart, each with its own 40x40 kernel (green). The 10-pixel gap between the two boxes is exactly — the point spacing minus the kernel's own full side length, with nothing left over to round away.
Show the figure-generating code
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from dictk.image import PixelCoordinate

p0, p1, p2 = PixelCoordinate(x=50, y=50), PixelCoordinate(x=100, y=50), PixelCoordinate(x=150, y=50)
kernel_margin = 20

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, ax = plt.subplots(figsize=(7, 3.2), constrained_layout=True)

for p, label in [(p0, "00"), (p1, "01"), (p2, "02")]:
    ax.plot(p.x, p.y, "o", color="black", markersize=4)
    ax.annotate(label, (p.x, p.y), textcoords="offset points", xytext=(6, 6), fontsize=8)

for p in (p0, p1):
    ax.add_patch(patches.Rectangle(
        (p.x - kernel_margin, p.y - kernel_margin),
        2 * kernel_margin, 2 * kernel_margin,
        edgecolor="green", facecolor="none", linewidth=1.5,
    ))

box_top = p0.y - kernel_margin      # 30
box_bottom = p0.y + kernel_margin   # 70

# 50-pixel point spacing. The dashed guide lines start near each point
# (nearly touching its marker) and run down through its kernel box to
# the dimension line just below the boxes. Arrow flush with the dashed
# lines (shrinkA/shrinkB=0); label centered at the true midpoint between
# the box bottom and the dimension line.
dim_y = box_bottom + 8
for p in (p0, p1):
    ax.plot([p.x, p.x], [p.y + 3, dim_y], color="gray", linestyle="--", linewidth=0.8)
ax.annotate("", xy=(p0.x, dim_y), xytext=(p1.x, dim_y), arrowprops=dict(arrowstyle="<->", color="black", shrinkA=0, shrinkB=0))
ax.text((p0.x + p1.x) / 2, (box_bottom + dim_y) / 2, "50 px", ha="center", va="center", fontsize=9)

# 40-pixel kernel width, flush with the box's own left/right edges
# (shrinkA/shrinkB=0 so the arrow isn't inset from those edges).
top_y = box_top - 10
ax.annotate("", xy=(p0.x - kernel_margin, top_y), xytext=(p0.x + kernel_margin, top_y), arrowprops=dict(arrowstyle="<->", color="green", shrinkA=0, shrinkB=0))
ax.text(p0.x, top_y - 4, "40 px", ha="center", va="bottom", fontsize=8, color="green")

# 10-pixel gap between the two kernels' facing edges, moved up to the
# boxes' shared top edge, flush with the box's own edges (shrinkA/
# shrinkB=0), with the label on top of the dimension line.
gap_y = box_top - 2
ax.annotate("", xy=(p0.x + kernel_margin, gap_y), xytext=(p1.x - kernel_margin, gap_y), arrowprops=dict(arrowstyle="<->", color="tab:red", shrinkA=0, shrinkB=0))
ax.text(p0.x + kernel_margin + (p1.x - kernel_margin - (p0.x + kernel_margin)) / 2, gap_y - 4, "10 px", ha="center", va="bottom", fontsize=7, color="tab:red")

ax.set_xlim(15, 175)
ax.set_ylim(84, 8)

# Tick marks: 50/100/150 in x (the point positions), 40 pixels apart
# starting at 20; every 10 pixels in y, but text labels only at
# 30/50/70 (the box's top edge, the point row, and the box's bottom
# edge) -- a tight range with no dead space below the boxes, since the
# dimension line sits just beneath them.
xticks = list(range(20, 161, 10))
ax.set_xticks(xticks)
ax.set_xticklabels([str(v) if v in (50, 100, 150) else "" for v in xticks])
yticks = list(range(20, 81, 10))
ax.set_yticks(yticks)
ax.set_yticklabels([str(v) if v in (30, 50, 70) else "" for v in yticks])

ax.set_xlabel("x (pixels)")
ax.set_ylabel("y (pixels)")
ax.set_aspect("equal")
fig.savefig("multi_point_motion_spacing.png", dpi=300)
Saved: multi_point_motion_spacing.png

With kernel size and the point spacing it implies both settled, the point grid can be generated next.

Point Grid

A grid is an ordered, sequential collection of points, arranged in a rectilinear pattern. The function dictk.grid.generate creates a grid that spans some number of points along and along , with some spacing between adjacent points along each axis. The count of points along and along need not be equal, and the spacing along and along need not be equal either. The grid is a general rectangular collection of points, not necessarily a square or uniformly-spaced one. spacing_x and spacing_y are in pixels.

This page uses astronaut0, the speckle pattern combined with the astronaut photograph introduced in Image Generation.

from dictk.image import read, PixelCoordinate
from dictk.plot import point_grid_plot
from dictk.grid import generate

reference_image = read(path="astronaut0.png")

points = generate(
    origin=PixelCoordinate(x=50, y=50),
    count_x=3,
    count_y=4,
    spacing_x=50,
    spacing_y=55,
)
point_grid_plot(
    image=reference_image,
    points=points,
    color="orange",
    figsize=(6.4, 4.8),
    path="multi_point_motion_grid.png",
)
Saved: multi_point_motion_grid.png
reference image astronaut0 with a 3x4 grid of 12 numbered points overlaid in orange, labeled 00 through 11 in row-major order
Reference image astronaut0 with a 3x4 grid of 12 points (count_x=3, count_y=4), spaced 50 pixels apart along and 55 pixels apart along (spacing_x=50, spacing_y=55), labeled 00-11 in row-major order (top-left to bottom-right).

The reference coordinates in pixels for each point follow:

PointReference Configuration
(pixels) (pixels)
005050
0110050
0215050
0350105
04100105
05150105
0650160
07100160
08150160
0950215
10100215
11150215
from dictk.image import translate

dx, dy = -6, 8
current_image = translate(arr=reference_image, dx=dx, dy=dy)

Tracking the Grid

Every point's own kernel and search area, using the kernel size chosen above, look like this. dictk.plot.point_grid_boxes_plot draws one box type per call, so kernel and search area each get their own figure — each point's own box gets its own color and its own legend entry (kernel 00, kernel 01, ..., kernel 11), cycling through a 12-color palette (using matplotlib's Tableau colormap):

from dictk.plot import point_grid_boxes_plot

point_grid_boxes_plot(
    image=reference_image,
    points=points,
    margin_width=20,
    margin_height=20,
    label_prefix="kernel",
    figsize=(6.4, 4.8),
    path="multi_point_motion_kernels.png",
)
Saved: multi_point_motion_kernels.png
reference image astronaut0 with each of the 12 points' kernel boxes overlaid, each in its own color, labeled kernel 00 through kernel 11
Every point's kernel, each in its own color (margin_width=20, margin_height=20).

The kernel comes from reference_image. The search area comes from current_image instead — still centered on each point's reference position (search_centers defaults to reference_points), since the point's true displacement is exactly what tracking is trying to find:

point_grid_boxes_plot(
    image=current_image,
    points=points,
    margin_width=48,
    margin_height=52,
    label_prefix="search area",
    figsize=(6.4, 4.8),
    path="multi_point_motion_search.png",
)
Saved: multi_point_motion_search.png
current image astronaut0, shifted by (-6, 8) pixels, with each of the 12 points' search-area boxes overlaid, each in its own color, labeled search area 00 through search area 11
Every point's search area, each in its own color (margin_width=48, margin_height=52), drawn on current_image — the region actually searched — and still centered on each point's reference position.

Nothing requires the kernel to be isotropic — dictk supports an independent margin per axis just as easily. The equal 20/20 above is a deliberate choice to illustrate that dictk supports both isotropic and non-isotropic margins, not a consequence of spacing_x and spacing_y being unequal forcing one shape or the other.

The search area, by contrast, keeps a clearly non-isotropic shape: search_margin_width=48, search_margin_height=52 — just under the point spacing itself, comfortably containing the known -pixel displacement with plenty of room to spare, while staying just shy of spacing_x/spacing_y rather than matching them outright.

That much slack still means search areas overlap their neighbors heavily and run off the image at the edges, which is harmless: subimage zero-pads whatever falls outside current_image. Unlike kernels, search areas that overlap cost nothing aside from redundant computation; there's no accuracy downside to searching the same region for two different points.

One important practical detail: phase_cross_correlation requires the kernel and search area to be exactly the same shape. So dictk.translation.locate doesn't crop the search area down to the kernel's size; rather, it zero-pads the kernel up to match the search area's size. Here a 40x40 kernel is zero-padded up to the search area's 96x104 size.

Note: In practice, kernel size has little effect on FFT runtime once a search area is chosen — the transform zero-pads the kernel up to match the search area's own size. Shrinking an already-small kernel further doesn't make the correlation any faster.

Single Point Motion confirmed that a single point's found position matches a known displacement exactly. Reuse current_image from Point Grid — the same -pixel displacement. The same idea, applied to all 12 points in the grid at once, is exactly what a real DIC workflow looks like.

dictk.grid.locate tracks all 12 points in one call. It doesn't do the correlation itself — it calls dictk.translation.locate once per point, and that function is dictk's actual FFT-based DIC engine: for each point it extracts a kernel from reference_image and a search area from current_image, then locates the kernel within the search area via skimage.registration.phase_cross_correlation — FFT-based phase cross-correlation, not a spatial-domain sliding-window search (see Correlation Criteria for the single-point version of this same technique). Twelve points means twelve independent calls into that engine, using the same kernel and search-area sizes visualized above:

from dictk.grid import locate

found = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
)
Point  found        expected     match
00      44,58       44,58       True
01      94,58       94,58       True
02     144,58      144,58       True
03      44,113      44,113      True
04      94,113      94,113      True
05     144,113     144,113      True
06      44,168      44,168      True
07      94,168      94,168      True
08     144,168     144,168      True
09      44,223      44,223      True
10      94,223      94,223      True
11     144,223     144,223      True

Every one of the 12 found positions matches reference_points[i] + (dx, dy) exactly — not approximately, the same exact-integer-pixel guarantee Single Point Motion established for one point, now confirmed across the whole grid at once:

from dictk.plot import point_grid_plot

point_grid_plot(
    image=current_image,
    points=found,
    color="orange",
    figsize=(6.4, 4.8),
    path="multi_point_motion_found.png",
)
Saved: multi_point_motion_found.png
current image astronaut0 shifted by (-6, 8) pixels, with the 12 found points overlaid in orange at their new positions, still labeled 00 through 11
Current image, translated by pixels, with all 12 points' found positions — every one recovers the same known displacement, confirming rigid-body motion across the whole grid at once, not just at a single point.

That every point was found exactly is expected, not a coincidence: the kernel margins above were chosen to roughly follow the rule of thumb, not to violate it. What the rule of thumb actually buys is robustness, not correctness on an easy case like this one — a kernel needs enough distinctive texture to locate reliably, and astronaut0 is a clean, synthetic image with strong texture everywhere and no noise. A smaller, more aggressively undersized kernel would likely still have worked here too; it's on real, noisier imagery, or content with repetitive texture, that a larger kernel's extra context resolves an ambiguity a smaller one can't.

Data Download

Every image this page used is downloadable below, as a TIFF. Download files individually, or all at once: one compressed zip file bundles every full image (reference and current), every kernel, and every search area.

import zipfile
import imageio.v3 as iio

images = {"astronaut0.tiff": reference_image, "astronaut1.tiff": current_image}
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - kernel_margin, y=point.y - kernel_margin)
    images[f"kernel_{i:02d}.tiff"] = subimage(
        image=reference_image, origin=origin, width=2 * kernel_margin, height=2 * kernel_margin
    )
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - search_margin_width, y=point.y - search_margin_height)
    images[f"search_area_{i:02d}.tiff"] = subimage(
        image=current_image, origin=origin, width=2 * search_margin_width, height=2 * search_margin_height
    )

with zipfile.ZipFile("multi_point_motion_data.zip", "w", zipfile.ZIP_DEFLATED) as zf:
    for name, arr in images.items():
        zf.writestr(name, iio.imwrite("<bytes>", arr, extension=".tiff"))

Download all: multi_point_motion_data.zip (26 files, 291 KB)

Full Images

  • astronaut0.tiff is reference_image.
  • astronaut1.tiff is current_imagereference_image displaced down and to the left by pixels, the same displacement Tracking the Grid tracked:
from dictk.image import write

write(arr=reference_image, path="astronaut0.tiff")
write(arr=current_image, path="astronaut1.tiff")
FileDescription
astronaut0.tiffReference image, 300x300 pixels
astronaut1.tiffCurrent image, displaced by (dx, dy) = (-6, 8) pixels

Kernels

Every point's kernel, extracted from reference_image — the same 12 boxes shown in Tracking the Grid (kernel_margin_width=20, kernel_margin_height=20, 40x40 pixels each):

from dictk.image import subimage, write

kernel_margin = 20
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - kernel_margin, y=point.y - kernel_margin)
    kernel = subimage(image=reference_image, origin=origin, width=2 * kernel_margin, height=2 * kernel_margin)
    write(arr=kernel, path=f"kernel_{i:02d}.tiff")
FilePointOrigin (pixels)
kernel_00.tiff00(30, 30)
kernel_01.tiff01(80, 30)
kernel_02.tiff02(130, 30)
kernel_03.tiff03(30, 85)
kernel_04.tiff04(80, 85)
kernel_05.tiff05(130, 85)
kernel_06.tiff06(30, 140)
kernel_07.tiff07(80, 140)
kernel_08.tiff08(130, 140)
kernel_09.tiff09(30, 195)
kernel_10.tiff10(80, 195)
kernel_11.tiff11(130, 195)

Search Areas

Every point's search area, extracted from current_image — not reference_image, since a search area is always a region of the current image (see Tracking the Grid). The same 12 boxes shown there (search_margin_width=48, search_margin_height=52, 96x104 pixels each), still centered on each point's reference position:

search_margin_width, search_margin_height = 48, 52
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - search_margin_width, y=point.y - search_margin_height)
    search_area = subimage(image=current_image, origin=origin, width=2 * search_margin_width, height=2 * search_margin_height)
    write(arr=search_area, path=f"search_area_{i:02d}.tiff")
FilePointOrigin (pixels)
search_area_00.tiff00(2, -2)
search_area_01.tiff01(52, -2)
search_area_02.tiff02(102, -2)
search_area_03.tiff03(2, 53)
search_area_04.tiff04(52, 53)
search_area_05.tiff05(102, 53)
search_area_06.tiff06(2, 108)
search_area_07.tiff07(52, 108)
search_area_08.tiff08(102, 108)
search_area_09.tiff09(2, 163)
search_area_10.tiff10(52, 163)
search_area_11.tiff11(102, 163)

Verification Against VIC-2D

In this section, we run this page's own dx = -6, dy = 8 example through VIC-2D (Correlated Solutions, Inc.), a widely used commercial DIC package, to verify agreement with dictk.

VIC-2D's own verified displacement fields for this page's dx = -6, dy = 8 example (click either image for the full-size version): U, the x-axis displacement, uniformly -6 pixels (left, multi_point_motion_U); V, the y-axis displacement, uniformly -8 pixels (right, multi_point_motion_V) — VIC-2D's own -axis points opposite dictk's, so its sign is flipped from this page's dy = 8 even though both describe the identical physical motion.

Across the 2861 subsets VIC-2D correlated successfully (109 more, all along the image's outer edge, fell outside the shifted current image and were masked out rather than reported), U ranges from exactly to px and V from to px, in VIC-2D's own columns (u_c/v_c in the raw output below). VIC-2D's own V is measured with positive pointing up the page, opposite dictk's downward-positive -axis. Once that sign difference is reconciled, VIC-2D's result matches dictk's own / px ground truth exactly.

The full, subset-by-subset VIC-2D output, multi_point_motion_vic_out.csv, is available for closer inspection: every subset's position, displacement, strain, and correlation quality metrics, not just the two summary fields shown above. It carries two displacement column pairs: u_c/v_c (VIC-2D's own convention, matching the two figures above exactly) and a second u/v pair already expressed with v's sign flipped to match dictk's downward-positive u/v land on / px directly, with no sign reconciliation needed to compare against dictk's ground truth.

Next Steps

This page tracked rigid-body translation. Every point moved by the same amount. Simple Stretch is next. It tracks a stretching deformation, where each point moves by a different amount in the direction.

Simple Stretch

Multi-Point Motion tracked a grid of points under rigid-body translation — every point moves by the same , so Single Point Motion's known-integer-pixel trick (choosing so the ground truth is exact, not a sub-pixel estimate) carried over for free. A stretch is the next step up in complexity: a genuine deformation, not just a rigid shift, where different points move by different amounts. Getting the same exact-integer ground truth here takes more care.

dictk.image.stretch applies a uniaxial or biaxial stretch pivoting at the image's origin : a point at moves to . Fixing isolates the stretch to alone, so every point's stays exactly as-is — the only question is which values keep every point's new an integer too, rather than landing between pixels.

Choosing an Integer-Safe Stretch Factor

Point Grid's 12 points span only three distinct values: 50, 100, and 150. Writing the stretch as a percentage , , and the new is:

For , this is just — always an integer, for any integer . But and both carry a factor of once divided by 100, so itself must be even for those points to land on an integer — which means must be even. Odd percentages (1%, 3%, 5%, ...) always leave and on a half-pixel.

That parity argument is exact in real-number math, but factor_x is a 64-bit float at runtime, and not every value that's mathematically an integer survives that arithmetic unscathed — 1.1, for example, has no exact binary floating-point representation, so 50 * 1.1 doesn't land on exactly 55.0 even though the true product is. Checking every even percentage directly against dictk's actual points, rather than trusting the parity argument alone:

from dictk.image import PixelCoordinate
from dictk.grid import generate

points = generate(
    origin=PixelCoordinate(x=50, y=50),
    count_x=3,
    count_y=4,
    spacing_x=50,
    spacing_y=55,
)
xs = sorted({point.x for point in points})

for p in range(1, 21):
    factor = (100 + p) / 100
    exact = all((x * factor).is_integer() for x in xs)
    print(f"{p:2d}%  factor={factor!r}  all-integer={exact}")
 1%  factor=1.01  all-integer=False
 2%  factor=1.02  all-integer=True
 3%  factor=1.03  all-integer=False
 4%  factor=1.04  all-integer=True
 5%  factor=1.05  all-integer=False
 6%  factor=1.06  all-integer=True
 7%  factor=1.07  all-integer=False
 8%  factor=1.08  all-integer=True
 9%  factor=1.09  all-integer=False
10%  factor=1.1  all-integer=False
11%  factor=1.11  all-integer=False
12%  factor=1.12  all-integer=False
13%  factor=1.13  all-integer=False
14%  factor=1.14  all-integer=False
15%  factor=1.15  all-integer=False
16%  factor=1.16  all-integer=False
17%  factor=1.17  all-integer=False
18%  factor=1.18  all-integer=True
19%  factor=1.19  all-integer=False
20%  factor=1.2  all-integer=True

The parity argument is necessary but not sufficient: every odd percentage fails as predicted, but so do several even ones (10%, 12%, 14%, 16%) purely from floating-point representation error, not the underlying math. Of the percentages that survive both checks, 2% is the smallest — the least aggressive stretch that still keeps every point's ground-truth position an exact pixel, with factor_x = 1.02 giving new values of 51, 102, and 153.

Applying the Stretch

Reuse points and reference_image from Point Grid. dictk.image.stretch builds current_image:

from dictk.image import read, stretch, PixelCoordinate

reference_image = read(path="astronaut0.png")
factor_x = 1.02
current_image = stretch(arr=reference_image, factor_x=factor_x)

factor_y defaults to 1.0. Every point's stays fixed. Only changes, and by a different amount for each point:

expected = [
    PixelCoordinate(x=int(point.x * factor_x), y=point.y)
    for point in points
]
PointReference Configuration Expected
(pixels) (pixels) (pixels) (pixels)
0050505150
011005010250
021505015350
035010551105
04100105102105
05150105153105
065016051160
07100160102160
08150160153160
095021551215
10100215102215
11150215153215

This is a real deformation, not a rigid shift. Multi-Point Motion moved every point by the same . A stretch moves each point by a different amount. A point at moves 1 pixel. A point at moves 3 pixels. The grid spreads apart under the stretch. It does not translate as one block.

Locating the Stretched Grid

dictk.grid.locate tracks the stretched grid the same way it tracked the translated one in Tracking the Grid. Reuse the same kernel and search-area margins:

from dictk.grid import locate

found = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
)
Point  found        expected     match
00       51,50       51,50     True
01      102,50      102,50     True
02      153,50      153,50     True
03       51,105      51,105    True
04      102,105     102,105    True
05      153,105     153,105    True
06       51,160      51,160    True
07      102,160     102,160    True
08      153,160     153,160    True
09       51,215      51,215    True
10      102,215     102,215    True
11      153,215     153,215    True
from dictk.plot import point_grid_plot

point_grid_plot(
    image=current_image,
    points=found,
    color="orange",
    figsize=(6.4, 4.8),
    path="simple_stretch_current.png",
)
Saved: simple_stretch_current.png
stretched current image astronaut0 with the 12 found points overlaid in orange, still labeled 00 through 11
The stretched current image, with all 12 found positions marked. Every found position matches its expected stretched position exactly.

Every found position matches the expected stretched position exactly. The stretch introduces no sub-pixel error at these 12 points. Multi-Point Motion established this exact-integer guarantee for rigid translation. This page confirms it holds under a real deformation too.

Twelve points, twelve independent correlations, whether the underlying motion is a rigid shift or a stretch: Recoverable Displacement Range picks up from here.

Strain

Visualizing strain results is a combination of mathematical accuracy and visual clarity. One might want to plot the "raw" data at the Gauss points, since that is the location within the element where the FEA solver actually calculates strain, making it the most accurate. However, this manner of visualization causes jumps (discontinuities) at element boundaries.

The professional standard is to calculate strain at the Gauss points, extrapolate the results to the nodes, and then report the nodal average from all adjacent elements to create a smooth contour plot.

For now, let's report the strain at the Gauss points.

12-Point Sample

dictk.grid.elements turns the tracked grid's 12 points into 6 Q4 elements, then dictk.element.gauss_point_log_strains and dictk.element.gauss_point_coordinates compute each element's 4 Gauss points' logarithmic (Hencky) strain and their own global position, in the current (found) configuration. Logarithmic strain, matching the Verification Against VIC-2D section below, which reports VIC-2D's own logarithmic/Euler strain:

from dictk.element import gauss_point_coordinates, gauss_point_log_strains
from dictk.grid import elements
from dictk.plot import element_strain_plot

element_indices = elements(count_x=3, count_y=4)
values = []
coordinates = []
for element in element_indices:
    reference_corners = [points[i] for i in element]
    current_corners = [found[i] for i in element]
    strains = gauss_point_log_strains(
        reference_points=reference_corners, current_points=current_corners
    )
    values.extend(strain[0, 0] for strain in strains)
    coordinates.extend(gauss_point_coordinates(points=current_corners))

element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    show_node_numbers=True,
    path="simple_stretch_strain_gauss_points.png",
)
element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    image=current_image,
    show_node_numbers=True,
    path="simple_stretch_strain_on_current.png",
)
the 6-element mesh with node numbers 00 through 11 and 4 Gauss points per element, colored by log strain E11, no background image
The 6-element mesh, node numbers and Gauss points colored by log strain .
the same mesh and colored Gauss points overlaid on current_image, the stretched astronaut photo
The same mesh, overlaid on current_image.
Strain Component: E11
----------------------------------------
Element  0 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  0 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  0 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  0 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
Element  1 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  1 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  1 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  1 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
Element  2 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  2 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  2 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  2 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
Element  3 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  3 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  3 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  3 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
Element  4 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  4 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  4 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  4 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
Element  5 | GP (xi=-0.577, eta=-0.577) | E11: 1.980263e-02
Element  5 | GP (xi=+0.577, eta=-0.577) | E11: 1.980263e-02
Element  5 | GP (xi=+0.577, eta=+0.577) | E11: 1.980263e-02
Element  5 | GP (xi=-0.577, eta=+0.577) | E11: 1.980263e-02
----------------------------------------

All 24 Gauss points report the identical value, — expected here, since factor_x = 1.02 is a uniform, axis-aligned stretch, a globally affine map that Q4's bilinear interpolation reproduces exactly everywhere, not just at element corners. In the general case, where the deformation isn't perfectly uniform, each Gauss point's strain would differ.

Data Download

Every image this page used is downloadable below, as a TIFF. Download files individually, or all at once: one compressed zip file bundles every full image (reference and current), every kernel, and every search area.

import zipfile
import imageio.v3 as iio

images = {"astronaut0.tiff": reference_image, "astronaut2.tiff": current_image}
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - kernel_margin, y=point.y - kernel_margin)
    images[f"kernel_{i:02d}.tiff"] = subimage(
        image=reference_image, origin=origin, width=2 * kernel_margin, height=2 * kernel_margin
    )
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - search_margin_width, y=point.y - search_margin_height)
    images[f"search_area_stretch_{i:02d}.tiff"] = subimage(
        image=current_image, origin=origin, width=2 * search_margin_width, height=2 * search_margin_height
    )

with zipfile.ZipFile("simple_stretch_data.zip", "w", zipfile.ZIP_DEFLATED) as zf:
    for name, arr in images.items():
        zf.writestr(name, iio.imwrite("<bytes>", arr, extension=".tiff"))

Download all: simple_stretch_data.zip (26 files, 299 KB)

Full Images

astronaut0.tiff is reference_image — identical to Multi-Point Motion's copy, since both pages reuse the same reference image. astronaut2.tiff is current_image, stretched by factor_x=1.02 — named astronaut2, not astronaut1, to stay distinct from Multi-Point Motion's translated current image, which is a different file with different content:

from dictk.image import write

write(arr=reference_image, path="astronaut0.tiff")
write(arr=current_image, path="astronaut2.tiff")
FileDescription
astronaut0.tiffReference image, 300x300 pixels (same as Multi-Point Motion)
astronaut2.tiffCurrent image, stretched by factor_x=1.02

Kernels

Kernels are unchanged from Multi-Point Motion: the stretch only ever moves current_image, and a kernel always comes from reference_image. Regenerated here, byte-for-byte identical, for a self-contained download set:

from dictk.image import subimage, write

kernel_margin = 20
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - kernel_margin, y=point.y - kernel_margin)
    kernel = subimage(image=reference_image, origin=origin, width=2 * kernel_margin, height=2 * kernel_margin)
    write(arr=kernel, path=f"kernel_{i:02d}.tiff")
FilePointOrigin (pixels)
kernel_00.tiff00(30, 30)
kernel_01.tiff01(80, 30)
kernel_02.tiff02(130, 30)
kernel_03.tiff03(30, 85)
kernel_04.tiff04(80, 85)
kernel_05.tiff05(130, 85)
kernel_06.tiff06(30, 140)
kernel_07.tiff07(80, 140)
kernel_08.tiff08(130, 140)
kernel_09.tiff09(30, 195)
kernel_10.tiff10(80, 195)
kernel_11.tiff11(130, 195)

Search Areas

Search areas, unlike kernels, are different from Multi-Point Motion's: they come from this page's current_image — the stretched one, not the translated one. Named search_area_stretch_* to keep the two sets of files distinct, still centered on each point's reference position:

search_margin_width, search_margin_height = 48, 52
for i, point in enumerate(points):
    origin = PixelCoordinate(x=point.x - search_margin_width, y=point.y - search_margin_height)
    search_area = subimage(image=current_image, origin=origin, width=2 * search_margin_width, height=2 * search_margin_height)
    write(arr=search_area, path=f"search_area_stretch_{i:02d}.tiff")

Verification Against VIC-2D

Path Forward names a direction worth pursuing: running this book's own synthetic datasets through established DIC software, and comparing directly against dictk's own results. This page's own factor_x = 1.02 stretch was run through VIC-2D (Correlated Solutions, Inc.), independently of dictk.

2682-Point Sample

VIC-2D placed its own kernels (each kernel sized 25 x 25 px) on a regular grid, 5 pixels apart in both directions — 53x54, 2862 candidate positions across the image. 180 of them sit close enough to the image's outer edge that their own correlation window would run off-canvas, so VIC-2D masks those out, leaving 2682 valid subsets.

from dictk.image import read, PixelCoordinate
from dictk.grid import generate
from dictk.plot import point_grid_plot

reference_image = read(path="astronaut0.png")
points = generate(
    origin=PixelCoordinate(x=18, y=16), count_x=53, count_y=54, spacing_x=5, spacing_y=5
)

# Marks exactly the region the zoomed-in figure below crops to -- same
# center and radius drawn there too, where it exactly touches all four
# edges of that figure's own extent.
crop_origin = PixelCoordinate(x=10, y=8)
crop_width, crop_height = 30, 30
circle_center = PixelCoordinate(
    x=crop_origin.x + crop_width // 2, y=crop_origin.y + crop_height // 2
)
circle_radius = crop_width / 2

point_grid_plot(
    image=reference_image,
    points=points,
    color="orange",
    show_node_numbers=False,
    dot_size=0.8,
    circle_center=circle_center,
    circle_radius=circle_radius,
    circle_linewidth=0.8,
    path="simple_stretch_2862_overview.png",
)

Saved: simple_stretch_2862_overview.png

astronaut0 with 2862 orange dots overlaid in a regular 5-pixel grid across the whole image, and a red circle near the top-left corner marking the region the next figure zooms into
All 2862 candidate subset positions (orange dots, 5px spacing in both directions), overlaid on the reference image. The red circle marks the region the zoomed-in figure below crops to.

A zoomed-in corner shows the same 5px grid at true scale, the same red circle now exactly touching all four edges of the crop:

from dictk.image import read, PixelCoordinate, subimage
from dictk.grid import generate
from dictk.plot import point_grid_plot

reference_image = read(path="astronaut0.png")
points = generate(
    origin=PixelCoordinate(x=18, y=16), count_x=53, count_y=54, spacing_x=5, spacing_y=5
)

crop_origin = PixelCoordinate(x=10, y=8)
crop_width, crop_height = 30, 30
circle_center = PixelCoordinate(
    x=crop_origin.x + crop_width // 2, y=crop_origin.y + crop_height // 2
)
circle_radius = crop_width / 2

cropped = subimage(
    image=reference_image, origin=crop_origin, width=crop_width, height=crop_height
)
# points stays in the full image's own frame -- origin=crop_origin tells
# point_grid_plot where cropped sits within it, so the saved figure's
# axes read astronaut0's own pixel numbers, not the crop's local 0-based
# ones. The same point (and the same circle) reads identically here and
# in the overview above.
sample_points = [
    p
    for p in points
    if crop_origin.x <= p.x < crop_origin.x + crop_width
    and crop_origin.y <= p.y < crop_origin.y + crop_height
]
point_grid_plot(
    image=cropped,
    points=sample_points,
    origin=crop_origin,
    color="orange",
    show_node_numbers=False,
    dot_size=6,
    circle_center=circle_center,
    circle_radius=circle_radius,
    circle_linewidth=0.8,
    figsize=(4, 4),
    path="simple_stretch_2862_zoom.png",
)

Saved: simple_stretch_2862_zoom.png

a zoomed 30x30 pixel crop from the top-left corner of astronaut0, showing a 5x5 block of 25 orange dots on the real 5-pixel grid, axes labeled 10 to 40 matching the full image's own coordinates, with a red circle exactly touching all four edges of the crop
A 30x30-pixel crop from the top-left corner (image coordinates 10-40 in both directions), showing every one of the 25 grid points that fall inside it -- the same 5px spacing as the full overview above, just at true scale instead of compressed into a 300x300 thumbnail. The axes read astronaut0's own pixel coordinates, not the crop's local 0-based ones, so a point here reads identically in the overview above -- e.g. the top-left point is (18, 16) in both figures. The same red circle marked in the overview above appears here too, now exactly touching all four edges of this figure's own extent -- the same visual correspondence Correlation Visualization's Solution Vicinity panel uses.

VIC-2D reports logarithmic (Euler) strain, so it's compared here against the Strain section above's own dictk-computed log strain. Across those 2682 valid subsets, averages 19875.8 microstrain — close to, but noisier than, Multi-Point Motion's displacement match, since strain is a spatial derivative of already-noisy per-point displacement data, not a directly measured quantity:

VIC-2D's measured exx (logarithmic Euler strain) field for the factor_x=1.02 stretch example, a striped noisy pattern averaging around 19900 microstrain, with a horizontal extensometer line annotated E0: 19905.2 microstrain
VIC-2D's own measured (logarithmic/Euler strain) field for this page's factor_x = 1.02 stretch (click to enlarge). The horizontal line is VIC-2D's own extensometer annotation, reading 19905.2 microstrain along that path.

The full distribution, not just its mean, shows how noisy those 2682 subsets really are:

import csv
import numpy as np
import matplotlib.pyplot as plt

with open("../verification/simple_stretch_vic_out.csv") as f:
    rows = [{k.strip(' "'): v for k, v in row.items()} for row in csv.DictReader(f)]
exx = np.array([float(r["exx"]) * 1e6 for r in rows if float(r["sigma"]) != -1])
analytical = np.log(1.02) * 1e6

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, ax = plt.subplots(figsize=(7, 4), constrained_layout=True)
ax.hist(exx, bins=60, color="gray", alpha=0.8)
ax.axvline(analytical, color="red", linestyle="--", linewidth=1.5)
ax.set_xlabel(r"Log strain $e_{xx}$ (microstrain)")
ax.set_ylabel("frequency")
fig.savefig("simple_stretch_vic_exx_histogram.png", dpi=300)
Saved: simple_stretch_vic_exx_histogram.png
histogram of VIC-2D's 2682 valid e_xx measurements in microstrain, showing several separated clusters rather than one smooth bell curve, spanning roughly 17300 to 23100 microstrain, with a dashed red vertical line at the analytical value near 19803 microstrain landing inside the central cluster
Distribution of VIC-2D's own across all 2682 valid subsets (gray, 60 bins). The dashed red line marks the analytical value, microstrain. The distribution isn't one smooth bell curve — it separates into several clusters, echoing the striped pattern already visible in the field image above. The analytical line lands inside the central cluster, not at the extremes, but the spread around it is real: individual subsets range from about 17300 to 23100 microstrain, over 5x the true 19803 value's own distance from zero.

Three values agree closely: VIC-2D's own measured mean, 19875.8 microstrain; dictk's own computed from the Strain section above, 19803.0 microstrain (identical at all 24 Gauss points, since this page's stretch is exact and uniform); and the analytical logarithmic (true/Euler) strain a factor_x = 1.02 stretch implies, microstrain.

dictk's own value lands within 0.02% of the analytical one — it's derived from the exact-integer tracked positions established earlier on this page, not a separately measured quantity, so it agrees almost exactly. VIC-2D's own mean, measured from real correlated subsets rather than exact tracked points, lands within 0.4% of the same analytical value.

The full, subset-by-subset VIC-2D output — simple_stretch_vic_out.csv — is available for closer inspection: every subset's position, displacement, strain, and correlation quality metrics, not just the summary field shown above.

VIC-2D sampled this deformation at far higher density than dictk has tried. Simple Stretch Revisited takes that cue next, pushing dictk's own tracked grid past twelve points for the first time.

Simple Stretch Revisited

Every point tracked so far on this page has landed on an exact integer pixel in the deformed configuration. That only works because of how the 12-point grid's own values were chosen. is . A point's stretched only comes out as a whole number when itself is a multiple of 50 — , exactly, but , not exactly. The grid's three distinct values, 50, 100, and 150, are all multiples of 50. That's not a coincidence — it's the same integer-safety check Choosing an Integer-Safe Stretch Factor already ran, just not stated in exactly these terms yet.

A much denser grid doesn't automatically keep that property. Spacing points 5 pixels apart, matching VIC-2D's own subset grid, mostly lands on values that aren't multiples of 50 — most of those points' true stretched position isn't an integer at all, so nothing can land on it exactly, no matter how the tracking works.

250-Point Sample

has no such restriction — every stays fixed, so spacing is free. That leaves one real lever: keep restricted to multiples of 50, and pack the direction as densely as space allows. Within this image, — 5 values, still 50 pixels apart, and (with search_margin_width=48) all comfortably clear of the image's own edges. A much larger, still fully integer-safe grid follows directly:

from dictk.grid import generate

points = generate(
    origin=PixelCoordinate(x=50, y=52),
    count_x=5,
    count_y=50,
    spacing_x=50,
    spacing_y=4,
)

250 points, x values: [50, 100, 150, 200, 250], y range: 52-248

Tracked the same way as every other grid on this page:

from dictk.grid import locate

found = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
)

250/250 points land on their expected integer pixel exactly

Every one of them lands exactly, the same as the 12-point grid — this grid is 20x larger, entirely by choosing values that stay integer-safe, not by luck.

Strain follows the same recipe as the Strain section above: dictk.grid.elements for connectivity, then dictk.element.gauss_point_log_strains and dictk.element.gauss_point_coordinates at each of the resulting 196 elements' Gauss points. Node numbers are left off this time — 250 labels would be clutter, not information, at this density:

from dictk.element import gauss_point_coordinates, gauss_point_log_strains
from dictk.grid import elements
from dictk.plot import element_strain_plot

element_indices = elements(count_x=5, count_y=50)
values = []
coordinates = []
for element in element_indices:
    reference_corners = [points[i] for i in element]
    current_corners = [found[i] for i in element]
    strains = gauss_point_log_strains(
        reference_points=reference_corners, current_points=current_corners
    )
    values.extend(strain[0, 0] for strain in strains)
    coordinates.extend(gauss_point_coordinates(points=current_corners))

element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    path="simple_stretch_revisited_strain_gauss_points.png",
)
element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    image=current_image,
    path="simple_stretch_revisited_strain_on_current.png",
)
a much denser 5x50 mesh with 4 Gauss points per element, colored by log strain E11, no node numbers, no background image
The same log strain , at 196 elements instead of 6 -- close enough to continuous to start looking like a real strain field.
the same dense mesh and colored Gauss points overlaid on current_image, the stretched astronaut photo
The same dense mesh, overlaid on current_image.

is still exactly at all 784 Gauss points — a uniform stretch is still a uniform stretch, regardless of how finely it's sampled. What's new here isn't the number, it's that the method now scales cleanly to a grid closer to VIC-2D's own density, with no tracking failures anywhere in it.

Point count was the free variable throughout this section — 250 here, chosen for exactness, not for speed. How dictk's own tracking time scales as point count grows much larger, and how that scaling compares across sequential, threaded, and multi-process execution, is Parallelization's own question, not this one.

Two things this section deliberately leaves open. Every point here still has to land on an exact integer pixel — real displacements won't. Recovering those is Subpixel Accuracy's own job, not this section's — it picks up exactly this constraint, using this same scenario. And the timing question just raised — how tracking time actually scales once point count grows past 250 — is Parallelization's to answer, not this page's.

Recoverable Displacement Range

Simple Stretch raised a natural follow-up question: how far can astronaut0 be stretched, or compressed, before locate stops finding the exact expected position? The investigation that followed didn't answer that question directly. It found something more fundamental first — a real, silent bug in locate itself, now fixed. This page chronicles how.

The First Sweep

The rest of this page traces a real, silent bug in locate: the kernel content it correlates against gets padded asymmetrically, capping how far a point can move and still be found. Here it is, directly. A point at px, a 60x60 px kernel (kernel_margin = 30), moved by a series of dx values, tracked with a deliberately pre-fix version of locate. locate_uncentered — introduced properly, with the reasoning behind it, in Isolating the Real Variable below — reproduces exactly the padding this page's real, shipped locate no longer has. The fixed version wouldn't reproduce this collapse at all:

from dictk.image import PixelCoordinate, read, translate
from recoverable_displacement_range_uncentered_demo import locate_uncentered

reference_image = read(path="astronaut0.png")
p0 = PixelCoordinate(x=150, y=150)
kernel_margin = 30
search_margin = 150  # generous -- per Root Cause, size won't help here --
# and exactly half of astronaut0's 300px canvas, so the search reads the
# whole image with no extraction margin of its own

for dx in [0, 10, 20, 25, 29, 30, 31, 35, 40, 50]:
    current_image = translate(arr=reference_image, dx=dx, dy=0)
    expected = PixelCoordinate(x=p0.x + dx, y=p0.y)
    found = locate_uncentered(reference_image, current_image, p0, p0, kernel_margin, search_margin)
    print(f"dx={dx}  expected={expected}  found={found}  match={found == expected}")

expected/found below appear in two reference frames side by side: current_image's own absolute frame (what locate_uncentered actually returns, same as the code above), and the local frame of search itself -- labeled "Fixed Image, frame ", matching Seeing the Cliff's quadrant figures just below exactly. expected there always equals the correlation surface's own true peak (that section's yellow box); found always equals what locate_uncentered actually reports (its magenta box):

dxcurrent_image (absolute)Fixed Image, frame match
expectedfoundexpectedfound
0(150,150)(150,150)(120,120)(120,120)True
10(160,150)(160,150)(130,120)(130,120)True
20(170,150)(170,150)(140,120)(140,120)True
25(175,150)(175,150)(145,120)(145,120)True
29(179,150)(179,150)(149,120)(149,120)True
30(180,150)(180,150)(150,120)(150,120)True
31(181,150)(-119,150)(151,120)(-149,120)False
35(185,150)(-115,150)(155,120)(-145,120)False
40(190,150)(-110,150)(160,120)(-140,120)False
50(200,150)(-100,150)(170,120)(-130,120)False

A sharp cliff, right at dx = kernel_margin + 1. search_margin = 150 — five times kernel_margin — makes no difference past that point at all. The rest of this page explains why, and fixes it.

Seeing the Cliff

The correlation surface behind this is never actually wrong -- its own peak lands at the correct position for both dx = 30 and dx = 31, confirmed separately. The bug is downstream: locate_uncentered's skimage-based conversion of that surface into a signed shift, which misreads the answer only past the cliff. recoverable_displacement_range_first_sweep_quadrant.py marks both positions on the same Fixed Image panel phase_correlation_quadrant_plot already draws elsewhere in this book -- the surface's own true peak (yellow, dashed, unchanged from every other use of that function) and where locate_uncentered actually reports the point (magenta). search here reads the entire astronaut0 canvas -- search_margin = 150 is exactly half its 300px width -- so the extraction itself adds no black margin of its own; the only black left is dx's own left-side gap from shifting the image right:

Saved: recoverable_displacement_range_first_sweep_quadrant_dx30.png

Saved: recoverable_displacement_range_first_sweep_quadrant_dx31.png

Phase correlation quadrant plot for dx=30: a 30px black margin on the left edge, exactly matching dx, with no black margin on the right; the yellow dashed correlation-surface-peak box and the dotted magenta locate_uncentered box coincide exactly, both correctly on the visible search image
dx = 30: the black margin on the left is exactly 30px wide -- dx itself, visible directly, not just computed. The two boxes coincide: locate_uncentered reports the same position the surface actually peaks at.
Phase correlation quadrant plot for dx=31: the yellow dashed correlation-surface-peak box sits correctly on the visible search image, but the dotted magenta locate_uncentered box sits entirely outside it, in the blank margin to the left
dx = 31: the yellow box still marks the surface's true (correct) peak. The magenta box -- where locate_uncentered actually reports the point -- lands entirely outside the visible search frame, off by exactly the padded array's own width.

Fixing locate

recoverable_displacement_range_fixing_locate.py (full source at the bottom of this page) re-runs The First Sweep's exact scenario and dx values against the real, shipped dictk.translation.locate -- not locate_uncentered -- before this page walks through why the fix was needed. Same two reference frames as The First Sweep's own table above:

dxcurrent_image (absolute)Fixed Image, frame match
expectedfoundexpectedfound
0(150,150)(150,150)(120,120)(120,120)True
10(160,150)(160,150)(130,120)(130,120)True
20(170,150)(170,150)(140,120)(140,120)True
25(175,150)(175,150)(145,120)(145,120)True
29(179,150)(179,150)(149,120)(149,120)True
30(180,150)(180,150)(150,120)(150,120)True
31(181,150)(181,150)(151,120)(151,120)True
35(185,150)(185,150)(155,120)(155,120)True
40(190,150)(190,150)(160,120)(160,120)True
50(200,150)(200,150)(170,120)(170,120)True

Every row matches now, cliff included.

recoverable_displacement_range_fixing_locate_quadrant.py draws dx = 31 -- the cliff itself -- the same way Seeing the Cliff did, but with centered=True: phase_correlation_quadrant_plot pads the Moving Image panel's kernel the same way locate now does internally, instead of the permanent bottom-right-only padding phase_correlation itself always keeps. Compare the two Moving Image panels directly: Seeing the Cliff's dx = 31 figure shows the kernel's content pinned to the top-left corner of an otherwise-black canvas; this one shows the exact same content centered within it, black on all four sides evenly. That single difference is the entire fix:

Saved: recoverable_displacement_range_fixing_locate_quadrant_dx31.png

Phase correlation quadrant plot for dx=31 with the fixed locate: the Moving Image panel shows the kernel's content centered within the padded canvas, black margins even on all four sides, unlike the pre-fix figure's top-left-anchored content; the yellow dashed correlation-surface-peak box and the dotted magenta locate box coincide exactly on the Fixed Image panel
dx = 31, post-fix. The Moving Image panel's kernel content is centered, not pinned to the top-left corner -- compare directly against Seeing the Cliff's dx = 31 figure above. On the Fixed Image panel, the two boxes coincide again: locate now reports the same position the surface actually peaks at, past the old cliff.

The rest of this page takes a step back and walks through the investigation in full -- the hypotheses that turned out not to explain it, the confound that had to be set aside, isolating the real variable, and exactly why the kernel's padding needed to be centered to fix this.

The Original Stretch Question

That cliff is the real bug this page fixes, but it isn't how the investigation actually started. It began from a different angle: Simple Stretch's own question, how far can astronaut0 be stretched, or compressed, before locate stops finding the exact expected position? Reuse Point Grid's 12 points and sweep factor_x upward, sizing search_margin_width per factor so it always comfortably contains the largest point's displacement — wide enough that "the window was too small" can't explain a failure:

from dictk.image import read, stretch, PixelCoordinate
from dictk.grid import generate, locate

reference_image = read(path="astronaut0.png")
points = generate(
    origin=PixelCoordinate(x=50, y=50), count_x=3, count_y=4, spacing_x=50, spacing_y=55
)
kernel_margin = 20

for p in [2, 4, 6, 8, 18, 20, 40, 80]:
    factor_x = 1 + p / 100
    current_image = stretch(arr=reference_image, factor_x=factor_x)
    max_disp = max(abs(pt.x * (factor_x - 1)) for pt in points)
    search_margin_width = max(int(max_disp) + 15, kernel_margin + 10)
    found = locate(
        reference_image=reference_image, current_image=current_image, reference_points=points,
        kernel_margin_width=kernel_margin, kernel_margin_height=kernel_margin,
        search_margin_width=search_margin_width, search_margin_height=52,
    )
    expected = [PixelCoordinate(x=int(pt.x * factor_x), y=pt.y) for pt in points]
    n_match = sum(1 for f, e in zip(found, expected) if f == e)
    print(f"{p:3d}%  search_margin_width={search_margin_width:4d}  matched={n_match:2d}/12")
Stretchfactor_xsearch_margin_widthMatched
2%1.023012/12
4%1.043012/12
6%1.063010/12
8%1.08306/12
18%1.18411/12
20%1.20441/12
40%1.40740/12
80%1.801350/12

Matching collapses almost immediately — well before 20% stretch. That's surprising: at this book's own 40-pixel kernel scale, a real degradation-driven failure shouldn't set in this early.

This table already runs against locate's real, fixed version — it's live, re-run on every book build. Path Forward already checked whether the fix above changed it, and it doesn't: search_margin_width here is always sized larger than the true displacement, so this sweep never actually hits the cliff bug The First Sweep demonstrated. Something else explains this particular collapse.

Two Hypotheses, Both Ruled Out

Two mechanisms seemed possible: blur or canvas exit.

Hypothesis 1: Blur

stretch uses bilinear interpolation, sampling an increasingly small crop of the original image to fill the same canvas. Whole-image contrast does drop as factor_x grows — but only mildly, from a standard deviation of 63.8 at factor_x=1.0 to 58.1 even at factor_x=3.0. Not a collapse.

That claim is a whole-image statistic. Seeing it directly, side by side with the image it's measuring, makes it concrete rather than just asserted:

import matplotlib.pyplot as plt
from dictk.image import read, stretch

reference_image = read(path="astronaut0.png")
factors = [1.0, 1.5, 2.0, 3.0]

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, axes = plt.subplots(2, len(factors), figsize=(11, 5.8), constrained_layout=True)
for col, factor_x in enumerate(factors):
    img = stretch(arr=reference_image, factor_x=factor_x)
    mean, std = img.mean(), img.std()

    axes[0, col].imshow(img, cmap="gray", vmin=0, vmax=255)
    axes[0, col].set_title(f"factor_x={factor_x:.1f}\nstd={std:.1f}", fontsize=10)
    axes[0, col].set_xticks([])
    axes[0, col].set_yticks([])

    ax_hist = axes[1, col]
    counts, _, _ = ax_hist.hist(img.ravel(), bins=50, range=(0, 255), color="black", alpha=0.7)
    y_bracket = counts.max() * 1.12
    ax_hist.axvline(mean - std, color="tab:red", linestyle="--", linewidth=1)
    ax_hist.axvline(mean + std, color="tab:red", linestyle="--", linewidth=1)
    ax_hist.annotate(
        "",
        xy=(mean - std, y_bracket),
        xytext=(mean + std, y_bracket),
        arrowprops=dict(arrowstyle="<->", color="tab:red"),
    )
    ax_hist.text(mean, y_bracket * 1.06, f"±1 std = {std:.1f}", ha="center", va="bottom", fontsize=8, color="tab:red")
    ax_hist.set_ylim(0, y_bracket * 1.35)
    ax_hist.set_xlim(0, 255)
    ax_hist.set_xlabel("pixel value", fontsize=8)
fig.savefig("recoverable_displacement_range_blur.png", dpi=300)

Saved: recoverable_displacement_range_blur.png

astronaut0 stretched at factor_x 1.0, 1.5, 2.0, and 3.0, with each image's pixel-value histogram below it, each histogram marked with a red bracket showing the ±1 standard deviation span narrowing from 63.8 to 58.2
Top: astronaut0 stretched at four factors. Bottom: each one's own pixel-value histogram, with a red bracket marking the ±1 standard deviation span. The images show where the blur comes from — horizontal streaking, since stretch only resamples along — but the bracket confirms it's mild: the span narrows only slightly as standard deviation drops from 63.8 to 58.2, nowhere near the collapse the first sweep showed at just 6-8%.

There's also a theoretical reason this mild blur shouldn't move the peak at all. locate's phase normalization divides out signal strength at every frequency and keeps only direction. Blurring changes strength, not direction — the same property that already makes locate insensitive to contrast. Only heavy blur eventually breaks that guarantee in practice, since real images pad and round at their edges instead of matching the idealized math exactly. stretch never reaches that regime at these factors.

Hypothesis 2: Canvas Exit

stretch pivots at the origin, so a point far enough from it can be pushed past the image's fixed 300-pixel edge. For (this grid's maximum dimension) that doesn't happen until factor_x=2.0 — 100% stretch, long after the matching collapse above.

Plotting that point's expected position directly on each stretched image makes the exit itself visible, not just computed:

import matplotlib.pyplot as plt
from dictk.image import read, stretch

reference_image = read(path="astronaut0.png")
height, width = reference_image.shape
p_x, p_y = 150, 50  # the grid's farthest point from the origin
factors = [1.0, 1.5, 2.0, 2.5]

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, axes = plt.subplots(1, len(factors), figsize=(11, 3.4), constrained_layout=True)
for ax, factor_x in zip(axes, factors):
    img = stretch(arr=reference_image, factor_x=factor_x)
    x_expected = p_x * factor_x
    on_canvas = x_expected < width

    ax.imshow(img, cmap="gray", vmin=0, vmax=255, extent=[0, width, height, 0])
    ax.axvline(width, color="tab:red", linestyle="--", linewidth=1)
    ax.plot(x_expected, p_y, marker="+", color="tab:orange", markersize=10, markeredgewidth=2.5)
    ax.set_xlim(-20, 400)
    ax.set_ylim(height + 20, -20)
    status = "on canvas" if on_canvas else "OFF CANVAS"
    ax.set_title(f"factor_x={factor_x:.1f}\nx={x_expected:.0f}  ({status})", fontsize=10)
    ax.set_xticks([])
    ax.set_yticks([])
fig.savefig("recoverable_displacement_range_canvas_exit.png", dpi=300)

Saved: recoverable_displacement_range_canvas_exit.png

astronaut0 stretched at factor_x 1.0, 1.5, 2.0, and 2.5, with an orange marker showing where the x=150 point is expected to land; the marker moves right with each stretch, sits exactly on the canvas edge at factor_x=2.0, and floats clearly outside the image at factor_x=2.5
The point's expected position (orange marker), plotted directly on each stretched image. The dashed red line marks the canvas's own right edge. The marker sits exactly on that edge at factor_x=2.0 — the threshold the text above states — and floats clearly outside the image by factor_x=2.5. That threshold sits far past the collapse the first sweep showed at just 6-8%, ruling canvas exit out too.

Neither blur nor canvas exit explains a collapse at 6-8%. Something else is going on, and it isn't image degradation.

An Interpolation Confound, Set Aside

Chasing the real cause directly through stretch turned out to be the wrong tool: even at a percentage chosen so a point's center pixel lands on an exact integer, bilinear interpolation still resamples every other pixel in that point's kernel from a fractional source coordinate. The center matches; the kernel's surrounding texture is subtly blurred anyway, in a way that grows with factor_x. That's a real phenomenon — related to Path Forward's Postponed subpixel-accuracy item — but a second, separate one from whatever is causing the sharp, early collapse above. Isolating the real cause means removing this confound entirely: pure integer-pixel translate instead of stretch, where every pixel maps from an exact integer source coordinate and bilinear interpolation never activates at all.

Isolating the Real Variable

Consider a point in the reference configuration with coordinate px in astronaut0. It moves a displacement of px — 10 px to the right — landing at px in the current configuration. Now consider four kernel margins ( px, small to large) and, for each one, two search margins (kernel_margin + 15 and kernel_margin + 80 px) — eight combinations in total.

  • Question: Does the ratio of kernel size to search-window size explain anything?
  • Answer: It does not.

All eight combinations find the exact expected point — from a comfortable ratio of 0.67 down to a razor-thin 0.10:

from dictk.image import read, translate, PixelCoordinate
from dictk.translation import locate

reference_image = read(path="astronaut0.png")
p0 = PixelCoordinate(x=150, y=150)
dx = 10
current_image = translate(arr=reference_image, dx=dx, dy=0)
expected = PixelCoordinate(x=p0.x + dx, y=p0.y)

for kernel_margin in [15, 20, 25, 30]:
    for search_margin in [kernel_margin + 15, kernel_margin + 80]:
        found = locate(
            reference_image=reference_image, current_image=current_image,
            reference_point=p0, search_center=p0,
            kernel_margin_width=kernel_margin, kernel_margin_height=kernel_margin,
            search_margin_width=search_margin, search_margin_height=search_margin,
        )
        ratio = kernel_margin / search_margin
        print(f"kernel_margin={kernel_margin:2d}  search_margin={search_margin:3d}  ratio={ratio:.2f}  match={found == expected}")
kernel_marginsearch_marginratiomatch
15300.50True
15950.16True
20350.57True
201000.20True
25400.62True
251050.24True
30450.67True
301100.27True

Ratio genuinely doesn't matter. But, raw displacement does matter. locate compares that raw displacement against kernel_margin alone. search_margin plays no role here, no matter how large it is.

The rest of this section demonstrates that failure directly, using recoverable_displacement_range_uncentered_demo.py, a Python script listed at the bottom of this page. That script contains a (now understood to be buggy) version of locate, called locate_uncentered. It calls _kernel_pad(..., centered=False), where the centered=False is the crucial bug-inducing parameter. This script exists because the real, shipped locate has already been fixed to center-pad the kernel. It would no longer reproduce the cliff bug, shown next.

Consider again a point in the reference configuration at px. Let kernel_margin = 30 px, a reasonable size. Let search_margin = 180 px, a generous size (and this size shouldn't matter, per the result above).

Now investigate a series of dx values: kernel_margin , which is . Each dx produces one candidate current configuration. The (right-hand side) cliff appears the moment dx crosses one pixel past kernel_margin, at kernel_margin . There, the found location is predicted at px, not the expected px value.

The tabular output from recoverable_displacement_range_uncentered_demo.py follows:

dxkernel_margin offsetexpectedfoundmatch
27-3(177,150)(177,150)True
29-1(179,150)(179,150)True
30+0(180,150)(180,150)True
31+1(181,150)(-179,150)False
33+3(183,150)(-177,150)False

A sharp (right-side) cliff, exactly at dx == kernel_margin. The search_margin=180, six times larger than kernel_margin, makes no difference at all.

Root Cause

dictk.translation.locate zero-pads the kernel up to the search area's own size before the FFT (see Correlation Criteria). Until this page, that padding placed the kernel's real content at the padded array's top-left corner — everything else, zero. FFT-based phase correlation is circular: the shift it reports is only meaningful modulo the array's own size, wrapping silently past that.

With the kernel anchored at the corner instead of centered, the safe half of that circle landed almost entirely on the negative side. The positive side had almost none of it to spare — capped at exactly kernel_margin, regardless of how large search_margin was set. Past that cap, locate didn't fail visibly. It confidently returned a wrong PixelCoordinate, offset from the true one by exactly the padded array's own width.

The Fix

Now let's use the fixed (updated/shipped) version of locate, which centers the kernel's content within the padded array.

Consider again a point with reference configuration px. Let kernel_margin = 30 and let search_margin = 45.

The recoverable range is now symmetric, bounded by search_margin in both directions, exactly as the parameter's own name implies it always should have been:

dxexpectedfoundmatch
30(180,150)(180,150)True
40(190,150)(190,150)True
44(194,150)(194,150)True
45(195,150)(195,150)True
46(196,150)(106,150)False
-44(106,150)(106,150)True
-45(105,150)(195,150)False

We now have success right up to the search_margin on the right:

  • With dx = 45, locate successfully finds the correct value.
  • With dx = 46, locate cycles back the search_margin, px, predicting , not the expected .

Similarly, on the left side of the search_margin:

  • With dx = -44, locate successfully finds the correct value.
  • With dx = -45, locate cycles forward the search_margin, px, predicting , not the expected .

Look closely at dx = 45 and dx = -45. One succeeds; the other fails. That is not a contradiction of the symmetry claimed above — it is a single, unavoidable edge case. In this circular system, and land on the exact same point: they are px apart, and px is the whole width of the padded array. locate cannot tell them apart. It must pick one interpretation, and it happens to pick the positive one. This one-pixel ambiguity is a property of representing a circle with discrete arithmetic. It is not a bug.

The whole picture — point, kernel, search window, and the two positions one pixel past the edge where locate wraps — drawn by recoverable_displacement_range_the_fix_cliff.py (full source at the bottom of this page):

Saved: recoverable_displacement_range_the_fix_cliff.png

reference point P (150, 150) with a 60x60 green kernel box and a 90x90 red search-window box, both centered on P; two double-headed magenta arrows lie along y=150, one from P to a red x marker at dx=-45 (exactly at the left edge) and one from P to a red x marker at dx=+46 (one pixel past the right edge), each labeled on the line, both marking failing positions where locate wraps
Point , its 60x60 kernel (green), and its 90x90 search window (red). Two magenta lines run from to each failing position — dx = -45 on the left, exactly at the search window's edge, and dx = +46 on the right, one pixel past it. At both (the red × marks), locate wraps and fails.

Scope of the Fix

The old, single _window_and_pad helper did two separable jobs at once: taper kernel/search toward zero (if windowing was given), then zero-pad kernel up to search's own shape. Only the first job ever needed the full search array; the second only ever read its shape. Splitting them makes that honest: _window tapers both arrays (unchanged from before), and _kernel_pad grows kernel up to a given (height, width) — never search itself — gaining the centered parameter this page is about. locate calls _kernel_pad with centered=True. phase_correlation — the surface-visualization function behind every figure in Correlation Visualization — keeps the old, uncentered default. Every peak position already published there, all well within the old safe range regardless of which convention computed it, stays exactly as documented; nothing needed regenerating. Correlation Criteria notes the difference where its own teaching example reimplements this same padding step.

What This Means in Practice

search_margin now means what it always should have: the full range a true displacement can fall within, safely, in every direction. That's progress, but it doesn't remove the underlying cost — a bigger unknown displacement still needs a bigger search_margin, and a bigger search_margin still means a bigger FFT at every point. Search Center Predictions picks up exactly here: a better initial guess than "zero displacement" shrinks how much search_margin has to cover in the first place.

The original question — how far astronaut0 can actually be stretched or compressed before locate breaks — is still open. This page didn't answer it; it found and fixed something that had to be fixed first. The interpolation confound flagged above is still there too. Both are follow-up work, not resolved here.

recoverable_displacement_range_uncentered_demo.py

"""Reproduces `dictk.translation.locate`'s behavior before the fix
documented in Recoverable Displacement Range: kernel content anchored at
the padded array's top-left corner, not centered.

Runs live on every book build, not from a committed snapshot.
"""

from dictk.correlation import _kernel_pad, _window
from dictk.image import PixelCoordinate, read, subimage, translate
from skimage.registration import phase_cross_correlation


def locate_uncentered(
    reference_image,
    current_image,
    reference_point,
    search_center,
    kernel_margin,
    search_margin,
):
    kernel_origin = PixelCoordinate(
        x=reference_point.x - kernel_margin, y=reference_point.y - kernel_margin
    )
    kernel = subimage(
        image=reference_image,
        origin=kernel_origin,
        width=2 * kernel_margin,
        height=2 * kernel_margin,
    )
    search_origin = PixelCoordinate(
        x=search_center.x - search_margin, y=search_center.y - search_margin
    )
    search = subimage(
        image=current_image,
        origin=search_origin,
        width=2 * search_margin,
        height=2 * search_margin,
    )
    kernel, search = _window(kernel=kernel, search=search, windowing=None)
    kernel_padded, _, _ = _kernel_pad(kernel=kernel, shape=search.shape, centered=False)
    shift, _, _ = phase_cross_correlation(
        reference_image=search, moving_image=kernel_padded, normalization="phase"
    )
    return PixelCoordinate(
        x=search_origin.x + int(shift[1]) + kernel_margin,
        y=search_origin.y + int(shift[0]) + kernel_margin,
    )


if __name__ == "__main__":
    reference_image = read(path="astronaut0.png")
    p0 = PixelCoordinate(x=150, y=150)
    kernel_margin = 30
    search_margin = (
        180  # generous, fixed -- shouldn't matter, per the ratio result above
    )

    print("| dx | kernel_margin offset | expected | found | match |")
    print("|---|---|---|---|---|")
    for dx in [
        kernel_margin - 3,
        kernel_margin - 1,
        kernel_margin,
        kernel_margin + 1,
        kernel_margin + 3,
    ]:
        current_image = translate(arr=reference_image, dx=dx, dy=0)
        expected = PixelCoordinate(x=p0.x + dx, y=p0.y)
        found = locate_uncentered(
            reference_image, current_image, p0, p0, kernel_margin, search_margin
        )
        print(
            f"| {dx} | {dx - kernel_margin:+d} | ({expected.x},{expected.y}) | "
            f"({found.x},{found.y}) | {found == expected} |"
        )

recoverable_displacement_range_the_fix_cliff.py

"""Draws the reference point, its kernel (green), and its search window
(red) from The Fix in Recoverable Displacement Range, along with the
two positions one pixel past the search_margin edge where `locate`
wraps and fails.

Runs live on every book build, not from a committed snapshot.
"""

import matplotlib.patches as patches
import matplotlib.pyplot as plt

p0_x, p0_y = 150, 150
kernel_margin = 30
search_margin = 45

plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
fig, ax = plt.subplots(figsize=(7.5, 6.5), constrained_layout=True)

ax.plot(p0_x, p0_y, "o", color="black", markersize=5, zorder=8)
ax.annotate(
    "$P\\ (150, 150)$",
    (p0_x, p0_y),
    textcoords="offset points",
    xytext=(0, 18),
    ha="center",
    fontsize=10,
    zorder=9,
)

# kernel (green) and search window (red), same colors as cross_correlation.md
ax.add_patch(
    patches.Rectangle(
        (p0_x - kernel_margin, p0_y - kernel_margin),
        2 * kernel_margin,
        2 * kernel_margin,
        edgecolor="green",
        facecolor="none",
        linewidth=1.5,
        zorder=3,
    )
)
ax.add_patch(
    patches.Rectangle(
        (p0_x - search_margin, p0_y - search_margin),
        2 * search_margin,
        2 * search_margin,
        edgecolor="red",
        facecolor="none",
        linewidth=1.5,
        zorder=2,
    )
)

# two dx displacement lines, each with arrowheads at both its own ends,
# right at y=150 -- P's own row. Left: P to the dx=-45 marker. Right: P
# to the dx=+46 marker. Labels sit right on the line, in the gap between
# the kernel box and each marker, clear of the kernel box itself.
dx_y = p0_y
for x_start, x_end, label, label_x in [
    (p0_x - 45, p0_x, "dx = -45", 117),
    (p0_x, p0_x + 46, "dx = +46", 184),
]:
    ax.annotate(
        "",
        xy=(x_end, dx_y),
        xytext=(x_start, dx_y),
        arrowprops=dict(
            arrowstyle="<->", color="magenta", linewidth=1.5, shrinkA=0, shrinkB=0
        ),
        zorder=6,
    )
    ax.text(
        label_x,
        dx_y,
        label,
        ha="center",
        va="center",
        fontsize=7.5,
        color="magenta",
        zorder=7,
        bbox=dict(facecolor="white", edgecolor="none", pad=1),
    )

# one pixel past the search_margin edge, both sides -- where locate wraps
ax.plot(
    p0_x + 46, p0_y, "x", color="tab:red", markersize=10, markeredgewidth=2.5, zorder=4
)
ax.plot(
    p0_x - 45, p0_y, "x", color="tab:red", markersize=10, markeredgewidth=2.5, zorder=4
)
ax.annotate(
    "dx=+46\n1 px past the\nsearch_margin edge\n→ wraps, fails",
    (p0_x + 46, p0_y),
    textcoords="offset points",
    xytext=(35, -45),
    fontsize=8,
    ha="left",
    color="tab:red",
    arrowprops=dict(arrowstyle="-", color="gray", linewidth=0.7, shrinkA=3, shrinkB=3),
)
ax.annotate(
    "dx=-45\nright at the\nsearch_margin edge\n→ wraps, fails",
    (p0_x - 45, p0_y),
    textcoords="offset points",
    xytext=(-40, 45),
    fontsize=8,
    ha="right",
    color="tab:red",
    arrowprops=dict(arrowstyle="-", color="gray", linewidth=0.7, shrinkA=3, shrinkB=3),
)

# dimension arrows for both boxes -- kdim_y sits close to the kernel
# box's own top edge; sdim_y stays further out, above the search box
kdim_y, sdim_y = p0_y - kernel_margin - 4, p0_y - search_margin - 8
ax.annotate(
    "",
    xy=(p0_x - kernel_margin, kdim_y),
    xytext=(p0_x + kernel_margin, kdim_y),
    arrowprops=dict(arrowstyle="<->", color="green", shrinkA=0, shrinkB=0),
)
ax.text(
    p0_x,
    kdim_y - 3,
    "60 px (2×kernel_margin)",
    ha="center",
    va="bottom",
    fontsize=8,
    color="green",
)
ax.annotate(
    "",
    xy=(p0_x - search_margin, sdim_y),
    xytext=(p0_x + search_margin, sdim_y),
    arrowprops=dict(arrowstyle="<->", color="red", shrinkA=0, shrinkB=0),
)
ax.text(
    p0_x,
    sdim_y - 3,
    "90 px (2×search_margin)",
    ha="center",
    va="bottom",
    fontsize=8,
    color="red",
)

# guide lines from each search_margin edge down to a caption naming its dx value
caption_y = p0_y + search_margin + 18
for x_edge, sign in [(p0_x - search_margin, "-45"), (p0_x + search_margin, "+45")]:
    ax.plot(
        [x_edge, x_edge],
        [p0_y + search_margin, caption_y - 3],
        color="gray",
        linestyle="--",
        linewidth=0.8,
    )
    ax.text(
        x_edge,
        caption_y,
        f"search_margin edge = dx={sign}",
        ha="center",
        va="top",
        fontsize=7.5,
        color="darkred",
    )

ax.set_xlim(p0_x - search_margin - 55, p0_x + search_margin + 55)
ax.set_ylim(p0_y + search_margin + 35, sdim_y - 12)
ax.set_xlabel("x (pixels)")
ax.set_ylabel("y (pixels)")
ax.set_aspect("equal")

fig.savefig("recoverable_displacement_range_the_fix_cliff.png", dpi=300)
print("Saved: recoverable_displacement_range_the_fix_cliff.png")

recoverable_displacement_range_first_sweep_quadrant.py

"""Illustrates The First Sweep's cliff directly: a phase-correlation
quadrant figure for dx=30 (succeeds) and dx=31 (fails), the same
scenario as recoverable_displacement_range_first_sweep.py.

The correlation surface itself is always correct -- dictk.correlation.
phase_correlation() never wraps, confirmed separately. The bug lives in
locate_uncentered's downstream, skimage-based signed-shift conversion,
not in the surface. So each figure marks two positions on the Fixed
Image panel: the surface's own true peak (yellow dashed, unchanged from
phase_correlation_quadrant_plot's normal behavior), and where
locate_uncentered actually reports the point (magenta,
reported_position) -- for dx=30 the two coincide; for dx=31 the magenta
box lands entirely outside the visible search frame, off by exactly the
padded array's own width, matching Root Cause's description.

Runs live on every book build, not from a committed snapshot.
"""

from dictk.image import PixelCoordinate, read, subimage, translate
from dictk.plot import phase_correlation_quadrant_plot
from recoverable_displacement_range_uncentered_demo import locate_uncentered

if __name__ == "__main__":
    reference_image = read(path="astronaut0.png")
    p0 = PixelCoordinate(x=150, y=150)
    kernel_margin = 30
    search_margin = 150  # exactly half of astronaut0's 300px canvas --
    # search reads the whole image, no extraction-margin black of its
    # own, so the only black left is dx's own left-side gap

    kernel_origin = PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin)
    kernel = subimage(
        image=reference_image,
        origin=kernel_origin,
        width=2 * kernel_margin,
        height=2 * kernel_margin,
    )
    search_origin = PixelCoordinate(x=p0.x - search_margin, y=p0.y - search_margin)

    for dx, label in [(30, "succeeds"), (31, "fails")]:
        current_image = translate(arr=reference_image, dx=dx, dy=0)
        search = subimage(
            image=current_image,
            origin=search_origin,
            width=2 * search_margin,
            height=2 * search_margin,
        )
        found = locate_uncentered(
            reference_image, current_image, p0, p0, kernel_margin, search_margin
        )
        # found is point-center convention (kernel_margin already added
        # back in); convert to the surface's own top-left-corner-of-
        # kernel-box, search-local convention to compare directly against
        # the surface's own peak.
        reported_local = PixelCoordinate(
            x=(found.x - kernel_margin) - search_origin.x,
            y=(found.y - kernel_margin) - search_origin.y,
        )
        path = f"recoverable_displacement_range_first_sweep_quadrant_dx{dx}.png"
        phase_correlation_quadrant_plot(
            kernel=kernel,
            search=search,
            title=f"Phase Correlation, Pre-Fix locate (dx={dx}, {label})",
            path=path,
            reported_position=reported_local,
            reported_position_label="locate_uncentered",
        )
        print(f"Saved: {path}\n")

recoverable_displacement_range_fixing_locate.py

r"""Fixing `locate`: re-runs The First Sweep's exact scenario and dx
values, this time against the real, shipped `dictk.translation.locate`
-- not `locate_uncentered` -- to show the fix directly, before the rest
of this page walks through why it was needed.

Same two reference frames as The First Sweep's own table: `current_image`'s
own absolute frame (what `locate` actually returns), and the local frame
of `search` itself, labeled "Fixed Image, frame $\mathcal{S}$" to match
Seeing the Cliff's quadrant figures above -- those figures aren't
redrawn here (they already show the pre-fix failure; this table shows
the post-fix success, numbers only).

Runs live on every book build, not from a committed snapshot. Raw HTML,
not markdown pipe-table syntax, for the same colspan reason The First
Sweep's own table needs it.
"""

from dictk.image import PixelCoordinate, read, translate
from dictk.translation import locate

if __name__ == "__main__":
    reference_image = read(path="astronaut0.png")
    p0 = PixelCoordinate(x=150, y=150)
    kernel_margin = 30
    search_margin = 150
    search_origin = PixelCoordinate(x=p0.x - search_margin, y=p0.y - search_margin)

    print("<table>")
    print("<thead>")
    print(
        '<tr><th rowspan="2">dx</th>'
        '<th colspan="2">current_image (absolute)</th>'
        '<th colspan="2">Fixed Image, frame $\\mathcal{S}$</th>'
        '<th rowspan="2">match</th></tr>'
    )
    print("<tr><th>expected</th><th>found</th><th>expected</th><th>found</th></tr>")
    print("</thead>")
    print("<tbody>")
    for dx in [0, 10, 20, 25, 29, 30, 31, 35, 40, 50]:
        current_image = translate(arr=reference_image, dx=dx, dy=0)
        expected = PixelCoordinate(x=p0.x + dx, y=p0.y)
        found = locate(
            reference_image=reference_image,
            current_image=current_image,
            reference_point=p0,
            search_center=p0,
            kernel_margin_width=kernel_margin,
            kernel_margin_height=kernel_margin,
            search_margin_width=search_margin,
            search_margin_height=search_margin,
        )
        expected_s = PixelCoordinate(
            x=(expected.x - kernel_margin) - search_origin.x,
            y=(expected.y - kernel_margin) - search_origin.y,
        )
        found_s = PixelCoordinate(
            x=(found.x - kernel_margin) - search_origin.x,
            y=(found.y - kernel_margin) - search_origin.y,
        )
        print(
            f"<tr><td>{dx}</td>"
            f"<td>({expected.x},{expected.y})</td><td>({found.x},{found.y})</td>"
            f"<td>({expected_s.x},{expected_s.y})</td><td>({found_s.x},{found_s.y})</td>"
            f"<td>{found == expected}</td></tr>"
        )
    print("</tbody>")
    print("</table>")

recoverable_displacement_range_fixing_locate_quadrant.py

"""Illustrates Fixing `locate`'s dx=31 row: the same phase-correlation
quadrant figure Seeing the Cliff drew for the pre-fix failure, this time
against the real, shipped `dictk.translation.locate`, with
`centered=True` -- the same centered kernel padding `locate` uses
internally now, via `_kernel_pad(..., centered=True)` -- instead of
`phase_correlation`'s own permanent bottom-right-only default.

Unlike Seeing the Cliff's dx=31 figure, the two boxes coincide here: the
surface's own true peak and locate's actual reported position agree,
since the fix is exactly what makes them agree past the old cliff.

Runs live on every book build, not from a committed snapshot.
"""

from dictk.image import PixelCoordinate, read, subimage, translate
from dictk.plot import phase_correlation_quadrant_plot
from dictk.translation import locate

if __name__ == "__main__":
    reference_image = read(path="astronaut0.png")
    p0 = PixelCoordinate(x=150, y=150)
    kernel_margin = 30
    search_margin = 150
    dx = 31

    kernel_origin = PixelCoordinate(x=p0.x - kernel_margin, y=p0.y - kernel_margin)
    kernel = subimage(
        image=reference_image,
        origin=kernel_origin,
        width=2 * kernel_margin,
        height=2 * kernel_margin,
    )
    search_origin = PixelCoordinate(x=p0.x - search_margin, y=p0.y - search_margin)
    current_image = translate(arr=reference_image, dx=dx, dy=0)
    search = subimage(
        image=current_image,
        origin=search_origin,
        width=2 * search_margin,
        height=2 * search_margin,
    )

    found = locate(
        reference_image=reference_image,
        current_image=current_image,
        reference_point=p0,
        search_center=p0,
        kernel_margin_width=kernel_margin,
        kernel_margin_height=kernel_margin,
        search_margin_width=search_margin,
        search_margin_height=search_margin,
    )
    # Same conversion The First Sweep's own table uses: found is
    # point-center convention (kernel_margin already added back in);
    # convert to the surface's own frame-S, search-local convention.
    reported_local = PixelCoordinate(
        x=(found.x - kernel_margin) - search_origin.x,
        y=(found.y - kernel_margin) - search_origin.y,
    )
    path = "recoverable_displacement_range_fixing_locate_quadrant_dx31.png"
    phase_correlation_quadrant_plot(
        kernel=kernel,
        search=search,
        title=f"Phase Correlation, Fixed locate (dx={dx}, succeeds)",
        path=path,
        reported_position=reported_local,
        reported_position_label="locate",
        centered=True,
    )
    print(f"Saved: {path}")

Pure Rotation

How large a rigid-body rotation angle can dictk's correlation-based tracking actually recover before it breaks down? Rigid Body Motion and the polar decomposition (, see Continuum Mechanics) already separate rotation from stretch in theory — a pure rotation carries zero strain by construction. This page starts checking that against real tracking, not just the closed-form math.

The First Sweep

Reuse Point Grid's 12 points and sweep rotate's angle upward. rotate pivots on the image's top-left corner (0, 0), so each point's expected position after rotation comes from the standard rotation matrix applied to its own coordinate — not a fixed displacement, since points farther from the pivot sweep a wider arc for the same angle. Size search_margin_width/search_margin_height per angle so they always comfortably contain the farthest point's displacement, the same generous-margin approach Recoverable Displacement Range used:

from dictk.image import read, rotate, PixelCoordinate
from dictk.grid import generate, locate
import numpy as np

reference_image = read(path="astronaut0.png")
points = generate(
    origin=PixelCoordinate(x=50, y=50), count_x=3, count_y=4, spacing_x=50, spacing_y=55
)
kernel_margin = 20

def expected_position(pt, angle_deg):
    theta = np.deg2rad(angle_deg)
    c, s = np.cos(theta), np.sin(theta)
    x = c * pt.x - s * pt.y
    y = s * pt.x + c * pt.y
    return PixelCoordinate(x=int(round(x)), y=int(round(y)))

for angle in [0.5, 1, 1.5, 2, 3, 5, 8, 15]:
    current_image = rotate(arr=reference_image, angle=angle)
    expected = [expected_position(pt, angle) for pt in points]
    max_disp = max(max(abs(e.x - pt.x), abs(e.y - pt.y)) for pt, e in zip(points, expected))
    search_margin = max(int(max_disp) + 15, kernel_margin + 10)
    found = locate(
        reference_image=reference_image, current_image=current_image, reference_points=points,
        kernel_margin_width=kernel_margin, kernel_margin_height=kernel_margin,
        search_margin_width=search_margin, search_margin_height=search_margin,
    )
    n_match = sum(1 for f, e in zip(found, expected) if f == e)
    print(f"{angle}deg  search_margin={search_margin}  matched={n_match}/12")
Angle (deg)search_marginMatched
0.53012/12
13012/12
1.53011/12
2309/12
3306/12
5341/12
8460/12
15760/12

Matching collapses even faster than Recoverable Displacement Range's stretch sweep did — well under half the points still match by 2 degrees, and none do by 8 degrees. search_margin is generous at every angle here, so window size isn't the constraint. A likely reason: a large enough rotation doesn't just move a point, it turns the kernel's own content around that point, and a translation-only search can't follow content that's rotating, not just sliding. The next two sections test that directly.

Confirming the Content-Rotation Hypothesis

Removing the Search Itself

The First Sweep's search_margin is generous, but it's still a guess — locate still has to search for the right answer within that margin. Remove that variable entirely: pass search_centers the true expected position directly, so locate doesn't have to search at all, and shrink the margin down to a fixed, minimal size:

Angle (deg)Matched
0.512/12
112/12
1.511/12
29/12
37/12
50/12
80/12
150/12

Nearly the same collapse, at nearly the same angles, as the First Sweep's generous-margin version. Handing locate the exact right answer barely helps. Search mechanics — margin size, centering guesses — were never the constraint.

Measuring Content Similarity Directly

If the search itself isn't the problem, the content being matched is. Set that up as a direct measurement, with no search or locate call at all: extract the kernel from reference_image at each point, extract the same-sized patch from the rotated current_image at that point's exact true position, and score their similarity with dictk.correlation.zncc, which is exactly 1.0 for identical content and falls toward 0 (or negative) as content diverges:

Angle (deg)Mean ZNCCMin ZNCC
01.0001.000
0.50.9680.939
10.9650.948
1.50.9400.879
20.9090.861
30.8600.800
50.7360.669
80.5450.408
150.249-0.184

Similarity falls off steeply and smoothly with angle, with zero search involved at all — this is the exact correct alignment, every time. By 8 degrees, mean similarity has already dropped to about half; by 15, some points score negative, meaning the rotated patch is anti-correlated with the original, not just a weaker match. That confirms the hypothesis directly: a rotated kernel's content genuinely stops resembling itself, at exactly the position where it should match perfectly. This isn't a search, margin, or centering-guess problem — it's that the content itself has changed shape.

One thing this doesn't separate out: rotate uses the same bilinear interpolation as stretch, and Recoverable Displacement Range already found interpolation blur alone can cause a similar-looking near-miss failure. A genuinely rotated feature (say, a straight edge tilted a few degrees) looks different from the original even with perfect, blur-free resampling — so both effects are likely compounding here, not just one. Telling those two contributions apart is a reasonable next step, not done yet.

Search Center Predictions

dictk.grid.locate's search_centers parameter defaults to None, which means each point's own reference_points entry doubles as its search center — a "zero displacement" guess. Every worked example in this book relies on that default. It works well here because every displacement used so far is small relative to the search margin chosen to contain it.

Recoverable Displacement Range establishes that the searchable range is bounded by search_margin itself now, not by kernel_margin. That's good news — it's a real, symmetric bound to design around — but it doesn't remove the underlying tension: a bigger unknown displacement still needs a bigger search_margin to contain it, and a bigger search_margin costs more compute (a larger FFT, at every point). The zero-displacement guess is the worst case for this: it forces search_margin to cover the entire possible displacement, with no help from anything already known about how the specimen is actually deforming.

A Better Guess

If some estimate of the deformation already exists — a coarse global DIC pass, a prior loading step in a finite element analysis, or just a reasonable assumption about how the specimen is expected to move — that estimate can predict roughly where each point ended up, instead of guessing zero displacement. A smaller search_margin then suffices, since it only needs to cover how wrong that prediction might be, not the full displacement itself.

The natural way to express such an estimate is a deformation gradient, the same Continuum Mechanics already defines — extended here to an affine map in homogeneous coordinates, so a single matrix carries both the linear part (stretch, rotation, shear) and a translation:

where is a point's reference position (in homogeneous form) and is its predicted current position — the search center to use, not the answer itself. This is not quite Continuum Mechanics' own : that one is purely linear (no translation, in 2D); this extends it to specifically so one matrix can express a rigid translation too, the same kind of motion Multi-Point Motion tracks.

The default should be — the identity:

Because , an identity predicts zero displacement — exactly today's existing default (search_centers=None ⟹ each point's own reference position). Introducing this way changes nothing for every example already in this book; it only adds a way to do better when a better guess is available.

Not Implemented Yet

This is a real API change, not a small one, and it touches design questions this page doesn't resolve on its own:

  • Where does apply — grid.locate only (a natural fit, since it already computes a search_centers list per call), or does translation.locate's single-point API need an equivalent?
  • If a caller supplies both F and search_centers explicitly, which wins, or is that combination an error?
  • Applying an affine to a PixelCoordinate is itself a small, independently testable piece — likely a new function in dictk.image, alongside translate/stretch, before grid.locate ever calls it.
  • A worked example needs a source for that isn't circular (an estimate close enough to be useful, but not so close it trivializes what locate is finding). Where that estimate comes from in practice is its own open question.

None of this is scoped or scheduled — this page records that the direction exists and sketches its math, not a commitment to build it on any timeline. Build it in pieces, each with its own tests, rather than landing the whole API change at once: the small affine-transform helper first, then wiring it into grid.locate behind the identity default, then a worked example once both exist. See Path Forward for the related "dynamic search-window sizing" direction this connects to — a better -based guess and a smaller search_margin are two sides of the same idea.

Twelve points, twelve independent correlations, each one still sequential so far: Parallelization picks up from here.

Parallelization

Multi-Point Motion just ran 12 independent calls to dictk.translation.locate — one per point, each doing its own FFT-based phase correlation — to verify every point's displacement. We anticipate the need to process a very large number of point-to-point correspondences to support large-scale DIC work — a real finite element mesh (see Finite Element Method) can easily have thousands-to-millions of nodes, not the 12 points in the simple grid above. Each point correspondence is independent of every other: locating point never reads or writes anything locating point touches. That independence isn't just a convenient property to point out — dictk.grid.locate is already written to exploit it. Its entire body is a single map over reference_points, one call to dictk.translation.locate per point, accumulating no shared state between iterations:

return [
    translation.locate(
        reference_image=reference_image,
        current_image=current_image,
        reference_point=reference_point,
        search_center=search_center,
        kernel_margin_width=kernel_margin_width,
        kernel_margin_height=kernel_margin_height,
        search_margin_width=search_margin_width,
        search_margin_height=search_margin_height,
    )
    for reference_point, search_center in zip(reference_points, search_centers)
]

Because every iteration is already independent, parallelizing it is a matter of swapping this list comprehension for a parallel map over the same per-point calls. It is not a redesign. dictk.grid.locate does exactly that today, behind two extra keyword-only parameters: max_workers and executor. Default max_workers=None stays sequential, the loop above, byte-identical to locate's original behavior. A positive integer switches to a worker pool instead.

Which pool, though, is not obvious. It needs its own explanation first.

Threads, Processes, and the GIL

CPython has a Global Interpreter Lock (GIL): only one thread can execute Python bytecode at a time, even on a machine with many cores. A plain Python for loop split across threads would not run any faster. Each thread would still wait its turn for the same lock.

C extensions can release the GIL during their own C-level computation, though. NumPy and SciPy both do this for many operations. The FFT dictk.translation.locate actually runs is one of them — skimage.registration.phase_cross_correlation calls scipy.fft.fftn and scipy.fft.ifftn internally, not the Python-level fallback, and scipy.fft releases the GIL for the duration of its own C computation. So threads can run FFT correlations in true parallel. The GIL is not held the whole time.

Whether that helps depends on scale. A tiny FFT finishes almost instantly. Most of the wall-clock time around it is Python-level overhead: function calls, object construction, array slicing. Releasing the GIL for a few microseconds does not buy much when the thread scheduling and GIL reacquisition around it cost microseconds too. A large FFT is different. Once the C computation itself dominates the call, the GIL-released fraction of wall-clock time dominates too, and threads start to pay off.

Threads vs. Processes: Two Different Costs

A ThreadPoolExecutor shares the caller's own memory. No pickling, no process spawn. Cheap to start. But every task still pays a GIL scheduling cost, and that cost does not shrink as task count grows. Run one task or a million, each one pays it individually.

A ProcessPoolExecutor is different. Each worker is a separate OS process, with its own interpreter and its own GIL. It gets true parallelism regardless of whether the target function releases the GIL at all. The cost moves elsewhere: data has to be pickled across the process boundary, and on macOS (which spawns fresh interpreters rather than forking) each worker re-imports NumPy, SciPy, and scikit-image from scratch before it can do any work. That cost is mostly fixed and paid once, when the pool starts.

That is the real asymmetry: processes pay once, threads pay every time. More tasks amortize a process pool's fixed startup cost. More tasks do not shrink a thread pool's per-task cost. Which one wins depends on both how big each task is and how many tasks there are — not on either alone.

Measuring the Trade Space

Rather than guess, measure. parallelization_bench.py (full source below) times sequential, threaded, and process-pool execution of phase_cross_correlation across three scenarios. Correlation size and point count are not independent in a real DIC problem — a million-point mesh only makes sense with a small subset per point — so this is three targeted scenarios, not one brute-force grid:

  • book_scale: this book's own kernel/search size (40 pixels), point count climbing from 100 to 1,000,000. Does point count alone ever create a crossover, at a size this small?
  • large_subset: only 16 points, correlation size climbing from 200 to 2,000 pixels. Where does the threads crossover sit, as a function of size alone?
  • realistic_mesh: a closer match to an actual finite element mesh — moderate correlation size (100 or 200 pixels), point count climbing from 1,000 to 100,000.

This sweep takes several minutes to run (the book_scale scenario's 1,000,000-point case alone runs over a minute) — far too slow to re-run on every build the way this book's other figures do. Its results are measured once and committed alongside the script that produced them, not regenerated live. The table below still reads live from that committed data, so it always matches the file on disk:

ScenarioSizePointsSequential (s)Threads (s)Threads speedupProcesses (s)Processes speedup
book_scale401000.007520.020570.366x0.776630.01x
book_scale4010000.072770.186210.391x0.996530.073x
book_scale40100000.708661.831650.387x2.065380.343x
book_scale401000007.1652618.328570.391x14.262060.502x
book_scale40100000071.65412169.901650.422x116.624030.614x
large_subset200160.016080.00871.847x0.836880.019x
large_subset500160.108760.026914.041x0.829190.131x
large_subset1000160.469730.107194.382x0.997170.471x
large_subset2000162.25720.508964.435x2.851970.791x
realistic_mesh10010000.267630.240351.114x0.80580.332x
realistic_mesh100100002.68852.453571.096x2.138471.257x
realistic_mesh10010000026.8113424.326671.102x15.240421.759x
realistic_mesh20010000.955220.346622.756x1.025040.932x
realistic_mesh200100009.591743.438562.789x4.418742.171x
realistic_mesh20010000098.6037931.721113.108x36.230092.722x
three stacked panels: book_scale shows sequential always fastest from 100 to 1,000,000 points, with a dashed trend line predicting that holds out to a trillion points; large_subset shows threads reaching over 4x speedup as correlation size grows while processes never beat sequential at only 16 points, not extrapolated; realistic_mesh shows both threads and processes beating sequential, with processes catching up to threads as point count grows, and dashed trend lines predicting each pair levels off close to its last measured value
Speedup vs. sequential, measured once on a 10-core machine (macOS, spawn start method). Solid lines are measured data. Dashed lines are trend extrapolations — a straight-line time-vs-point-count fit, projected out to 106, 109, and 1012 points. Top: at this book's own 40-pixel scale, sequential wins at every point count tested, up to 1,000,000, and the trend predicts it keeps winning — processes plateau near 0.62x, threads near 0.42x, even out to a trillion points. Middle: at only 16 points, threads win decisively once correlations are large enough; processes never recover their fixed startup cost. Not extrapolated: this panel's x-axis is correlation size, not point count, and a subset a billion pixels wide isn't physical. Bottom: with enough points, both help, and processes close the gap on threads as point count grows; the trend predicts each pair levels off close to its last measured value.

Four findings, read directly off that data:

  1. At this book's own scale, sequential always wins. 1,000,000 points at 40 pixels still favors sequential (71.7s) over both threads (169.9s) and processes (116.6s). Point count alone never creates a crossover at this size — not at 100 points, not at a million.
  2. Few points, large correlations: threads win, processes cannot recover. At 2,000 pixels with only 16 points, threads reach 4.4x. Processes reach only 0.79x — still slower than sequential. Sixteen tasks is not enough to amortize a process pool's fixed startup cost, no matter how large each individual task is.
  3. Many points, moderate correlations: processes catch up, and can pass threads. At 100 pixels, processes start behind threads (0.33x vs. 1.11x at 1,000 points) but overtake them by 100,000 points (1.76x vs. 1.10x). More tasks keep amortizing a process pool's fixed cost long after a thread pool's per-task cost has stopped improving.
  4. The trend, extrapolated to Path Forward's north-star scale, predicts a plateau, not a crossover. Fitting a straight line to each method's measured time-vs-point-count and reading off the resulting speedup ratio at 106, 109, and 1012 points: book_scale's ordering never flips (processes settle near 0.62x, threads near 0.42x, both still slower than sequential); realistic_mesh's pairs settle close to their last measured value (size=100: threads 1.10x, processes 1.84x; size=200: threads 3.13x, processes 2.78x). This is a linear extrapolation from a handful of measured points, not a new measurement — a hypothesis worth testing at real scale, not a settled result.

Using max_workers

dictk.grid.locate accepts max_workers and executor directly now, no sketch required. Run it against the same 12-point grid Multi-Point Motion already tracked, sequential and concurrent side by side:

from dictk.grid import Executor, locate

sequential = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
)
threaded = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
    max_workers=4,
    executor=Executor.THREAD,
)
print(f"results match: {sequential == threaded}")
results match: True

The results match, as they must — max_workers changes how the 12 points are tracked, not what answer each one finds. It does not change the runtime in any way worth showing here, either. Twelve points at 40 pixels is deep in the book_scale regime above: sequential wins. Demonstrating correctness at this scale, not speed, is the honest thing to show.

Choosing an Executor

Given the measured trade space, not a guess:

  • This book's own examples (small kernels, small search areas): don't parallelize at all. Leave max_workers=None. Sequential wins here regardless of point count.
  • Few points, each with a large correlation: Executor.THREAD. Processes cannot recover their fixed cost across only a handful of tasks, no matter how large each one is.
  • Many points, each with a moderate-to-large correlation (the closest match to a real finite element mesh): either pool helps; Executor.PROCESS closes the gap on threads as point count grows, and can pass it.
  • Unsure which regime a problem falls in? Executor.THREAD is locate's default for exactly this reason. It is never catastrophically worse than sequential, unlike a process pool at low point counts, even though it is not always the fastest option available.

parallelization_bench.py

"""Benchmark: sequential vs. threads vs. processes for
skimage.registration.phase_cross_correlation, at varying correlation
sizes and call counts.

Not part of the dictk package -- a standalone, one-time measurement
script, matching the convention simple_shear.py already sets. Its
output (parallelization_bench.csv, parallelization_bench.png) is
committed alongside it rather than regenerated on every book build: the
full sweep takes several minutes (the 1,000,000-call case alone runs
over a minute), far too slow for the live cmdrun re-execution every
other figure in this book uses. Parallelization.md prints this script's
full source inline (see its own "parallelization_bench.py" section) so
the numbers stay checkable even though they are not live.

Must be a real module, not `python3 -c` -- ProcessPoolExecutor needs a
real, importable, top-level function to hand to spawned workers, the
same constraint dictk.grid._locate_worker exists for.

Re-run with: python3 parallelization_bench.py
"""

import csv
import os
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

import matplotlib.pyplot as plt
import numpy as np
from skimage.registration import phase_cross_correlation

WORKERS = os.cpu_count()

CSV_PATH = "parallelization_bench.csv"
FIGURE_PATH = "parallelization_bench.png"

# Point count and correlation size are not independent in a real DIC
# problem -- a million-point mesh only makes sense with small subsets
# per point. Three scenarios instead of one brute-force grid, each
# answering a different question:
SCENARIOS = {
    # This book's own teaching scale (kernel/search sizes throughout
    # Single/Multi-Point Motion). Does point count alone ever create a
    # crossover, at a size this small?
    "book_scale": [(40, n) for n in [100, 1_000, 10_000, 100_000, 1_000_000]],
    # Few points, growing correlation size. Where does the threads
    # crossover actually sit, as a function of size alone?
    "large_subset": [(size, 16) for size in [200, 500, 1000, 2000]],
    # A more realistic finite element mesh: moderate subset size,
    # climbing point count. Does the processes-vs-threads balance shift
    # as point count grows?
    "realistic_mesh": [(100, n) for n in [1_000, 10_000, 100_000]]
    + [(200, n) for n in [1_000, 10_000, 100_000]],
}


def one(args: tuple[np.ndarray, np.ndarray]):
    """One correlation. Module-level and single-positional-argument on
    purpose -- see the module docstring."""
    kernel, search = args
    return phase_cross_correlation(kernel, search, normalization="phase")


def make_args(size: int, n_calls: int, seed: int = 42):
    """`n_calls` copies of the same random kernel/search pair at `size`.

    The same pair repeated, not `n_calls` distinct random pairs: this
    benchmark measures call overhead, not correlation accuracy, so
    identical inputs keep every call's own work identical too."""
    rng = np.random.default_rng(seed)
    kernel = rng.random((size, size))
    search = rng.random((size, size))
    return [(kernel, search)] * n_calls


def time_sequential(args) -> float:
    t0 = time.perf_counter()
    for x in args:
        one(x)
    return time.perf_counter() - t0


def time_threads(args) -> float:
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        list(pool.map(one, args))
    return time.perf_counter() - t0


def time_processes(args) -> float:
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=WORKERS) as pool:
        list(pool.map(one, args))
    return time.perf_counter() - t0


def run_case(scenario: str, size: int, n_calls: int, writer: csv.DictWriter) -> None:
    args = make_args(size, n_calls)

    sequential_s = time_sequential(args)
    threads_s = time_threads(args)
    processes_s = time_processes(args)

    writer.writerow(
        {
            "scenario": scenario,
            "size": size,
            "n_calls": n_calls,
            "workers": WORKERS,
            "sequential_s": round(sequential_s, 5),
            "threads_s": round(threads_s, 5),
            "processes_s": round(processes_s, 5),
            "threads_speedup": round(sequential_s / threads_s, 3),
            "processes_speedup": round(sequential_s / processes_s, 3),
        }
    )
    print(
        f"[{scenario}] size={size:5d} n={n_calls:8d}  "
        f"sequential={sequential_s:8.3f}s  "
        f"threads={threads_s:8.3f}s (x{sequential_s / threads_s:5.2f})  "
        f"processes={processes_s:8.3f}s (x{sequential_s / processes_s:5.2f})",
        flush=True,
    )


def run_sweep() -> None:
    fieldnames = [
        "scenario",
        "size",
        "n_calls",
        "workers",
        "sequential_s",
        "threads_s",
        "processes_s",
        "threads_speedup",
        "processes_speedup",
    ]
    with open(CSV_PATH, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for scenario, cases in SCENARIOS.items():
            for size, n_calls in cases:
                run_case(scenario, size, n_calls, writer)
                f.flush()
    print(f"\nWrote {CSV_PATH}")


# Point counts to extrapolate speedup trends out to, tying directly to
# the "north star" scale in Path Forward (billions of correlations,
# staying under a trillion by design). Only scenarios whose x-axis is
# point count (book_scale, realistic_mesh) get this treatment --
# large_subset's x-axis is correlation *size*, and extrapolating a
# subset's side length out to a billion pixels isn't physical.
EXTRAPOLATION_TARGETS = [1_000_000, 1_000_000_000, 1_000_000_000_000]


def _trend_line(ns, seq_times, other_times, targets):
    """Fit a linear time-vs-n trend (time = a*n + b) to `seq_times` and
    `other_times` independently, then extrapolate the *speedup ratio*
    (their fitted-time ratio) out to every target beyond the last real
    data point.

    Returns `(xs, speedups, marks)`: `xs`/`speedups` start at the last
    *measured* point (so a plotted dashed line picks up exactly where
    the solid measured line ends, no visual gap) and run through every
    target; `marks` is just the subset of targets genuinely beyond the
    measured range, for placing "predicted value" markers.
    """
    a_seq, b_seq = np.polyfit(ns, seq_times, 1)
    a_other, b_other = np.polyfit(ns, other_times, 1)
    last_n = ns[-1]
    marks = [t for t in targets if t > last_n]
    xs = [last_n] + marks
    speedups = [(a_seq * n + b_seq) / (a_other * n + b_other) for n in xs]
    return xs, speedups, marks


def _add_trend(ax, ns, seq_times, other_times, color):
    xs, speedups, marks = _trend_line(ns, seq_times, other_times, EXTRAPOLATION_TARGETS)
    ax.plot(xs, speedups, linestyle="--", color=color, linewidth=1.2)
    mark_speedups = speedups[-len(marks) :] if marks else []
    ax.plot(
        marks,
        mark_speedups,
        linestyle="none",
        marker="x",
        color=color,
        markersize=7,
        markeredgewidth=1.5,
    )
    for n, s in zip(marks, mark_speedups):
        ax.annotate(
            f"{s:.2f}x",
            (n, s),
            textcoords="offset points",
            xytext=(4, 4),
            fontsize=7,
            color=color,
        )


def plot_summary() -> None:
    with open(CSV_PATH) as f:
        rows = list(csv.DictReader(f))

    with plt.rc_context({"font.family": "serif", "mathtext.fontset": "cm"}):
        fig, axes = plt.subplots(3, 1, figsize=(7, 15), constrained_layout=True)

        panels = [
            (
                axes[0],
                "book_scale",
                "n_calls",
                "point count (size=40 fixed)",
                "log",
                True,
            ),
            (
                axes[1],
                "large_subset",
                "size",
                "correlation size (n=16 fixed)",
                "linear",
                False,
            ),
            (
                axes[2],
                "realistic_mesh",
                "n_calls",
                "point count (size=100 or 200)",
                "log",
                True,
            ),
        ]
        for ax, scenario, xkey, xlabel, xscale, extrapolate in panels:
            data = [r for r in rows if r["scenario"] == scenario]
            if scenario == "realistic_mesh":
                for size, marker in [("100", "o"), ("200", "s")]:
                    sub = [r for r in data if r["size"] == size]
                    xs = [int(r[xkey]) for r in sub]
                    ax.plot(
                        xs,
                        [float(r["threads_speedup"]) for r in sub],
                        marker=marker,
                        color="tab:blue",
                        label=f"threads (size={size})",
                    )
                    ax.plot(
                        xs,
                        [float(r["processes_speedup"]) for r in sub],
                        marker=marker,
                        color="tab:orange",
                        label=f"processes (size={size})",
                    )
                    if extrapolate:
                        seq = [float(r["sequential_s"]) for r in sub]
                        thr = [float(r["threads_s"]) for r in sub]
                        proc = [float(r["processes_s"]) for r in sub]
                        _add_trend(ax, xs, seq, thr, "tab:blue")
                        _add_trend(ax, xs, seq, proc, "tab:orange")
            else:
                xs = [int(r[xkey]) for r in data]
                ax.plot(
                    xs,
                    [float(r["threads_speedup"]) for r in data],
                    marker="o",
                    color="tab:blue",
                    label="threads",
                )
                ax.plot(
                    xs,
                    [float(r["processes_speedup"]) for r in data],
                    marker="o",
                    color="tab:orange",
                    label="processes",
                )
                if extrapolate:
                    seq = [float(r["sequential_s"]) for r in data]
                    thr = [float(r["threads_s"]) for r in data]
                    proc = [float(r["processes_s"]) for r in data]
                    _add_trend(ax, xs, seq, thr, "tab:blue")
                    _add_trend(ax, xs, seq, proc, "tab:orange")
            ax.axhline(
                1.0,
                color="black",
                linestyle="--",
                linewidth=1,
                label="sequential (baseline)",
            )
            ax.set_xscale(xscale)
            if extrapolate:
                # Headroom so the rightmost "N.NNx" annotation (at the
                # 10^12 target) doesn't clip against the panel edge.
                ax.set_xlim(right=ax.get_xlim()[1] * 3)
            ax.set_xlabel(xlabel)
            ax.set_ylabel("speedup vs sequential")
            ax.set_title(scenario)
            handles, labels = ax.get_legend_handles_labels()
            if extrapolate:
                from matplotlib.lines import Line2D

                handles += [
                    Line2D(
                        [0],
                        [0],
                        color="gray",
                        marker="o",
                        linestyle="-",
                        label="measured",
                    ),
                    Line2D(
                        [0],
                        [0],
                        color="gray",
                        marker="x",
                        linestyle="--",
                        label="trend (extrapolated)",
                    ),
                ]
            ax.legend(handles=handles, fontsize=7)

        fig.savefig(FIGURE_PATH, dpi=300)
        plt.close(fig)
    print(f"Wrote {FIGURE_PATH}")


if __name__ == "__main__":
    run_sweep()
    plot_summary()

Subpixel Accuracy

dictk.translation.locate's own docstring has said this from the start: "Integer-pixel precision only; subpixel refinement is out of scope for now." Simple Stretch Revisited found the concrete case where that limitation actually bites. At VIC-2D's own point density — 5 pixels apart — most points' true stretched position isn't an integer at all. locate can only ever report a whole pixel, so it's necessarily wrong by some amount for those points. That's not a tracking failure. It's the wrong question being asked.

What locate Actually Rounds Away

skimage.registration.phase_cross_correlation, the FFT-based correlation locate runs, doesn't only find whole-pixel peaks. Its own upsample_factor parameter refines that peak to within 1 / upsample_factor of a pixel. locate never uses it — every result gets truncated to the nearest whole pixel with int() before it's returned.

dictk.translation.locate_subpixel and its batch counterpart, dictk.grid.locate_subpixel, are new, separate functions — not a parameter added to locate itself, so locate's own return type never changes shape based on an argument. They pass upsample_factor straight through, and return the refined position directly, undiscarded, as a dictk.image.SubpixelCoordinate — the same (x, y) shape as PixelCoordinate, but float, not int.

Measuring the Difference

Reusing Simple Stretch Revisited's own scenario — astronaut0.png, factor_x = 1.02 — at VIC-2D's own 5-pixel spacing across the whole image, 2862 points:

from dictk.grid import generate, locate, locate_subpixel

points = generate(
    origin=PixelCoordinate(x=18, y=16),
    count_x=53,
    count_y=54,
    spacing_x=5,
    spacing_y=5,
)
true_x = [point.x * factor_x for point in points]

integer_found = locate(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
)
subpixel_found = locate_subpixel(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
    upsample_factor=10,
)
MethodMean abs error (px)Max abs error (px)
locate() (integer, truncated)0.26270.5600
locate_subpixel(upsample_factor=1)0.26270.5600
locate_subpixel(upsample_factor=10)0.09400.2600
locate_subpixel(upsample_factor=100)0.08850.2600

locate() lands on the exact expected integer pixel for 1479/2862 points (52%) — the same interpolation confound Simple Stretch Revisited found and worked around, not a new one.

upsample_factor=1 matches locate's own error exactly — no refinement requested, none applied. upsample_factor=10 cuts the mean error by roughly a third. upsample_factor=100 barely improves on 10 — diminishing returns past that point, for this scenario.

Why This Isn't "Fixing" the Exact-Match Problem

upsample_factor does not make locate's own truncated answer more often correct. If the true target is 64.26, no amount of refinement turns that into a whole number — locate_subpixel reports something close to 64.26 itself, not 64 or 65 more reliably. Simple Stretch Revisited solved a different problem: it kept every point's restricted to values where the true target genuinely is an integer, so locate could report it exactly. This page accepts that most targets, at this density, aren't integers at all, and asks how close tracking gets to the real one instead. Two different, both legitimate, answers to the same density problem.

This closes Path Forward's own Postponed subpixel-accuracy item — real displacements don't land on exact pixels, and now dictk has a way to track them without pretending otherwise. High Point Density picks this up next, at a density Simple Stretch Revisited's own integer-safety constraint couldn't reach.

High Point Density

Simple Stretch Revisited capped out at 250 points — the most x values that stay integer-safe at factor_x = 1.02, within the image's own margins. Subpixel Accuracy removed that ceiling: once tracking doesn't need its answer to be a whole pixel, x doesn't need to be a multiple of 50 either. This page pushes all the way to VIC-2D's own density — 5 pixels apart, the same 53x54, 2862-point grid Verification Against VIC-2D and Subpixel Accuracy both already used.

Tracking at Full Density

from dictk.grid import generate, locate_subpixel

points = generate(
    origin=PixelCoordinate(x=18, y=16),
    count_x=53,
    count_y=54,
    spacing_x=5,
    spacing_y=5,
)
found = locate_subpixel(
    reference_image=reference_image,
    current_image=current_image,
    reference_points=points,
    kernel_margin_width=20,
    kernel_margin_height=20,
    search_margin_width=48,
    search_margin_height=52,
    upsample_factor=10,
)

2862 points tracked

Strain at Full Density

Same recipe as Simple Stretch Revisited: dictk.grid.elements for connectivity (2756 elements this time, not 196), then gauss_point_log_strains/gauss_point_coordinates at each of the resulting 11024 Gauss points. Node numbers stay off — 2862 of them would be unreadable:

from dictk.element import gauss_point_coordinates, gauss_point_log_strains
from dictk.grid import elements
from dictk.plot import element_strain_plot

element_indices = elements(count_x=53, count_y=54)
values = []
coordinates = []
for element in element_indices:
    reference_corners = [points[i] for i in element]
    current_corners = [found[i] for i in element]
    strains = gauss_point_log_strains(
        reference_points=reference_corners, current_points=current_corners
    )
    values.extend(strain[0, 0] for strain in strains)
    coordinates.extend(gauss_point_coordinates(points=current_corners))

element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    path="high_point_density_strain_gauss_points.png",
)
element_strain_plot(
    points=found,
    elements=element_indices,
    coordinates=coordinates,
    values=values,
    label=r"Log Strain, $E_{11}$",
    image=current_image,
    path="high_point_density_strain_on_current.png",
)
a dense 53x54 mesh with 4 Gauss points per element, colored by log strain E11, no node numbers, no background image
The full 2862-point mesh, colored by log strain .
the same dense mesh and colored Gauss points overlaid on current_image, the stretched astronaut photo
The same dense mesh, overlaid on current_image.

A Real Trade-Off, Not a Bug

2862-point, 5px-spacing mesh: mean = 0.0200 (true value 0.0198), but std = 0.0155, range [-0.0160, 0.0771]

The mean is accurate. The spread is not small. Unlike Simple Stretch Revisited's perfectly uniform result, individual elements here scatter well beyond the true value — some report negative strain, some report nearly 4 times the true value.

This isn't a tracking bug. Log strain is, in effect, a finite difference: , a displacement difference divided by element size . Subpixel Accuracy's own measurement found locate_subpixel's residual error is small in absolute terms — a few hundredths of a pixel, on average — but at 5 pixels of element spacing, that same absolute error is a much larger fraction of than it was at Simple Stretch Revisited's 50-pixel spacing. The smaller the element, the more a fixed amount of tracking noise gets amplified into strain noise. Checked directly, not just argued:

Element spacingMean E11Std E11
5px0.019880.01544
10px0.019970.01247
20px0.020030.00988
40px0.019990.00318

Standard deviation falls as element spacing grows — the same tracking noise, spread over a larger , moves less of the resulting strain. This is exactly why VIC-2D and other commercial DIC packages offer a strain window — averaging displacement over several subsets before computing strain, trading spatial resolution for strain precision. dictk doesn't implement that averaging yet. This page's own dense mesh is accurate on average and honestly noisy point to point, not silently smoothed into looking better than the underlying tracking supports.

Point count, tracking accuracy, and now strain precision have all been free variables throughout Simple Stretch, Subpixel Accuracy, and this page. How dictk's own tracking time scales as point count grows — across sequential, threaded, and multi-process execution — is Parallelization's own question, still not attempted here either.

Path Forward

This page is a running log, not a tutorial. It records forward-looking notes as they come up — open questions, scale targets, directions worth exploring — dated as they're written. Nothing here is implemented yet unless the note says so.

2026-08-11

North star. DIC at real finite-element-mesh scale eventually means at least a billion correlations. Realistically, tens of billions. The working assumption is that this stays under a trillion — that's the ceiling to design for, not a target to reach.

Four directions worth exploring, in the order we'll likely take them:

  1. GPU implementations. Parallelization only covers CPU-bound threads and processes so far. GPU work is still explicitly deferred until a documented CPU bottleneck exists (see the parallelism roadmap referenced there) — this note doesn't change that. It's on the list for when that bottleneck shows up.
  2. Dynamic search-window sizing. Every worked example so far uses a fixed, generously-sized search area per point. Parallelization's own measurements found that per-correlation size, not point count alone, is the dominant cost lever. A search window only as large as it needs to be — sized from a prior displacement estimate rather than a fixed worst-case margin — shrinks that per-correlation cost directly, for every point in a mesh, not just the parallelizable part of the problem.
  3. Pure rotation. Rigid Body Motion and the polar decomposition (, see Continuum Mechanics) already separate rotation from stretch: a pure rotation carries zero strain by construction. Two things worth testing empirically, not just assuming from the closed-form math: how large a rigid-body rotation angle dictk's own correlation-based tracking can actually recover before it breaks down — a large enough rotation distorts a kernel's own content beyond what a translation-only search can still match — and confirming a correctly-tracked pure rotation reports zero strain end to end, not just in theory.
  4. Synthetic dataset comparison to XCorrel and VIC-2D. Run the same synthetic datasets this book already uses through both tools, and compare their reported displacements and strain against dictk's own. A synthetic dataset has a known, exact ground truth — the same trick every worked example in this book already relies on — so this is a direct, numeric check against established DIC software, not just a qualitative one.

A practical constraint to design around: real DIC data typically uses kernel sizes from about 35x35 pixels on the small end up to about 50x50 pixels on the large end. Every benchmark or worked example aimed at "realistic" DIC scale should stay inside that range, not the 40-pixel figure this book's own teaching examples happen to use.

Heaviside DIC and XFEM. Every correlation technique this book covers assumes a smooth displacement field. Real specimens don't always deform smoothly — a crack or a material interface can produce a genuine discontinuity, a jump rather than a gradient. Image Transformation already generates a synthetic example of exactly that jump, and names the reason: standard DIC can't capture it, and cases like it motivate the Heaviside finite-element formulation. XFEM handles this on the finite element side by enriching the basis with Heaviside step functions, so the mesh doesn't need to conform to the crack. The DIC-side analogue — enriching the correlation itself to detect and locate a discontinuity, not just generating test images that contain one — is worth exploring. Not scoped yet.

2026-08-14

Re-running The First Sweep after the centered-padding fix: checked, not automatically fixed. Recoverable Displacement Range's own opening sweep sizes search_margin_width generously for every percentage tested — always larger than the true displacement — so it was never hitting the asymmetric-padding bug that page's fix addresses. Re-ran it against the fixed locate() to check directly, rather than assume: the collapse is identical to before the fix — 12/12, 12/12, 10/12, 6/12, 1/12, 1/12, 0/12, 0/12 for . The real cause is still the interpolation confound that page already names: stretch's own bilinear interpolation subtly blurs kernel-surrounding texture even where a point's center pixel lands on an exact integer, producing near-miss failures — not the wraparound cliff the fix resolved. Confirms the Postponed subpixel-accuracy item below is still the right next step here, not something this fix already covered.

Postponed

Noted, not being pursued right now:

  • Heaviside DIC and XFEM — enriching the correlation itself to detect and locate a discontinuity, not just generating test images that contain one (see above).
  • grid.locate() windowing demo. windowing has only ever been demonstrated directly on dictk.correlation.phase_correlation (see Correlation Visualization). Every grid.locate() call across the book so far (Multi-Point Motion, Simple Stretch, Recoverable Displacement Range, Pure Rotation, Parallelization) leaves windowing at its default None — the one parameter of grid.locate's own signature with no live worked example yet.

2026-08-18

Pure Rotation: The First Sweep. New page, Pure Rotation, starts checking direction 3 above empirically. Its First Sweep reuses Point Grid's 12-point grid and sweeps rotate's angle, sizing search_margin generously at every step so window size can't be the limiting factor — the same approach Recoverable Displacement Range used. Matching collapses even faster than that page's stretch sweep did: well under half the points still match by 2 degrees, none by 8 degrees. The likely cause, already named in this page's own direction-3 note above, isn't confirmed yet — a large enough rotation turns a kernel's own content around a point, not just moves it, and a translation-only search can't follow that. Checking that hypothesis directly is the next step here, not started yet.

Pure Rotation: hypothesis confirmed. Same page, new Confirming the Content-Rotation Hypothesis section. Two direct checks: handing locate the exact true search center instead of a generous margin barely changes the collapse, ruling out search mechanics; and a plain zncc similarity score between the reference kernel and the true-aligned current-image patch (no search at all) falls off steeply with angle, confirming the real cause is content, not search. One thing this doesn't separate out yet: rotate shares stretch's bilinear interpolation, and Recoverable Displacement Range already found interpolation blur alone can look similar — genuine geometric content rotation and interpolation blur are likely both compounding here. Telling them apart is the next open step, not started.

2026-08-20

Postponed subpixel accuracy item, resolved. Simple Stretch Revisited found the concrete trigger this Postponed item's own wording anticipated: at factor_x = 1.02, only points whose x is a multiple of 50 land on an integer pixel in the deformed configuration. A denser grid mostly doesn't. New Subpixel Accuracy page: dictk.translation.locate_subpixel and dictk.grid.locate_subpixel, exposing phase_cross_correlation's own upsample_factor — separate functions from locate/grid.locate, not a parameter added to them, returning a new dictk.image.SubpixelCoordinate (float x/y) instead of PixelCoordinate. Measured directly against VIC-2D's own 2862-point grid: upsample_factor doesn't make locate's truncated integer answer more often correct (the true target usually isn't an integer at that density, so no refinement changes that) — but it substantially improves how close the tracked position lands to the true, generally fractional, target (mean absolute error 0.26px at upsample_factor=1, down to 0.09px at 10). Parallelization (9) gains this as its first child, 9.1; a second child, 9.2 High Point Density, picking the same subpixel tooling up at real density, is the planned next step, not started yet.

2026-08-24

9.2 High Point Density, shipped. New page, High Point Density, closes the 9.1/9.2 pair under Parallelization. It pushes grid.locate_subpixel to VIC-2D's own point density: 2862 points, 5px spacing, 2756 elements. No new library code — it composes entirely from already-shipped functions, the same way Simple Stretch Revisited did.

A real finding came out of it, verified before writing anything up. The strain field isn't clean at this density. Mean E11 still tracks the true value closely (0.0199 vs. 0.0198), but individual elements scatter widely (std 0.0155, range -0.016 to 0.077). A live 4-point spacing sweep (5/10/20/40px) confirmed the mechanism directly: strain noise scales with displacement-noise divided by element size, so the same small subpixel tracking residual gets amplified more at smaller spacing. Std shrinks monotonically across the sweep (0.0154/0.0125/0.0099/0.0032). The page names VIC-2D's own strain-window averaging as the standard remedy but doesn't implement it — that stays open.

Contributing to dictk

dictk is developed on GitHub using Git for version control. Git is the tool that tracks changes to the source on your own computer; GitHub is the hosting service that holds the canonical copy of the repository, tracks issues and pull requests, and runs the CI/CD pipeline described below.

Cloning vs. forking

Contributors can get a working copy of dictk by either cloning or forking the repository.

CloningForking
A Git action: it creates a copy of the repository on your own computer.A GitHub action: it creates a personal copy of the entire project under your own GitHub account.
For authorized collaborators who can push changes directly to the main project.For external contributors to make changes without affecting the original repository, then submit a pull request to share those changes.

Getting the source code

Collaborators should clone directly:

git clone git@github.com:hovey/dictk.git
cd dictk

External contributors should first fork the repository to their own GitHub account, then clone their fork locally.

Installation

Install uv if you don't already have it:

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# or via Homebrew
brew install uv

Then, from the repository root:

uv sync --all-extras --dev

This creates a .venv and installs dictk plus its dev dependencies (pytest, pytest-cov, ruff). Run commands inside that environment with the uv run prefix, e.g. uv run pytest.

Using venv and pip (alternative)

python3 -m venv .venv

source .venv/bin/activate       # bash/zsh
source .venv/bin/activate.fish  # fish
.venv\Scripts\activate          # Windows

pip install -e ".[dev]"

Git workflow

Branching model

main and dev are both long-lived: dev is branched from main, and main only moves forward via merges from dev (each push to main is a potential release — see "Releasing" below). Actual development happens one level further out, on dev-feature, a branch cut from dev.

main          ●───────────────●───────────  (releases only, tagged)
                   \                \
dev           ●─────●───●───●───●────●────  (integration branch)
               \       \         \
dev-feature     ●───●   ●─●───●   ●──●      (your work)

dev-feature above is a placeholder — name each branch dev-<short-description> so its purpose is clear at a glance. For example:

  • dev-cicd — CI/CD pipeline or workflow-file changes
  • dev-algorithm-refactor — refactoring an existing algorithm or module
  • dev-imaging — new imaging transformations/workflows
  • dev-docs — documentation-only updates

Starting a dev-feature branch

git checkout dev
git pull origin dev
git checkout -b dev-feature

Keeping your dev-feature branch up to date with dev

Before opening a PR, or periodically during long-lived work, bring in dev's latest changes.

Option 1: Merge (safer, keeps history of both branches)

git checkout dev
git pull origin dev
git checkout dev-feature
git merge dev

If there are conflicts, git will tell you which files — resolve them, then:

git add <resolved-files>
git commit

Option 2: Rebase (cleaner, linear history)

git checkout dev
git pull origin dev
git checkout dev-feature
git rebase dev

If conflicts come up during rebase, fix them then run git add <files> followed by git rebase --continue (repeat until done). To bail out at any point: git rebase --abort.

Pushing after either approach — if dev-feature was already pushed and has commits others might be using:

  • After a merge: git push origin dev-feature
  • After a rebase: git push origin dev-feature --force-with-lease (rebase rewrites history, so you need a force push — --force-with-lease is safer than --force since it won't overwrite someone else's pushed work)

Which to pick

  • Use merge if the branch is shared with others or you want a clear record of when dev's changes came in
  • Use rebase if it's mostly just your own branch and you want a clean, linear commit history without merge bubbles.

Tip — before doing either, it's worth running:

git log dev-feature..dev --oneline

to preview what's coming in, so conflicts aren't a total surprise.

Development workflow

Developers work locally and periodically push to their dev-<feature> branch. Before pushing changes, developers should check code quality locally rather than solely relying on CI to catch problems. This means running tests, linting, format checking (ruff), code coverage, and confirming that the documentation (mdBook + pdoc) builds locally. Catching issues locally is faster than waiting on a CI run, and it keeps the CI pipeline green for everyone else.

Running tests

uv run pytest

With coverage (matches what CI runs):

uv run pytest --cov=src/dictk --cov-report=xml --cov-report=html

Coverage HTML report is written to htmlcov/index.html.

Linting and formatting

ruff handles both formatting and linting.

uv run ruff format          # auto-format
uv run ruff format --check  # verify formatting without changing files (CI runs this)
uv run ruff check           # lint

pyproject.toml has no [tool.ruff.lint] section, so ruff check runs ruff's bare default rule selection — E4/E7/E9 (pycodestyle basics) plus F (pyflakes). This is deliberate, not an oversight. ruff check is a hard CI gate; broader rule sets (D docstring-style, ANN type-annotation-completeness, S security, and the rest) risk fighting conventions already established elsewhere in this codebase (e.g. the Google-style docstrings pdoc --docformat google depends on), or simply duplicating ground pylint already covers informationally, without ruff's same all-or-nothing gating risk.

Building the docs

Documentation is an mdBook under docs/userguide/, with two preprocessors enabled: mdbook-cmdrun, so pages can embed live, always-accurate command output (see the "Image Generation" page for an example) instead of pasted-by-hand output, and mdbook-katex, so pages can include $$...$$ LaTeX math blocks (see the "Single Point Motion" page). Neither is a Python dependency:

# mdbook must be pinned to 0.4.52: mdbook-cmdrun and mdbook-katex's 0.9.x
# line both depend on the mdbook crate's 0.4.x preprocessor JSON schema,
# which changed in mdbook 0.5 and broke compatibility
# (https://github.com/FauconFan/mdbook-cmdrun/issues/22, open as of this
# writing; mdbook-katex made the same jump at its own 0.10.0). Do not
# `brew install mdbook` or `cargo install mdbook`/`mdbook-katex` without a
# --version pin, or the build will fail with "Unable to parse the input".
cargo install mdbook --version 0.4.52
cargo install mdbook-cmdrun
cargo install mdbook-katex --version 0.9.4

If you already have a newer mdbook from Homebrew or elsewhere on your PATH, make sure ~/.cargo/bin comes first (or check mdbook --version reports 0.4.52 before building).

book.toml lives at the repo root (not inside docs/userguide/), with src = "docs/userguide/src" and build-dir = "docs/userguide/book", so both commands below run from the repo root:

uv run mdbook build           # build once, output in docs/userguide/book/
uv run mdbook serve --open    # live preview at http://localhost:3000

uv run puts dictk's own CLI on PATH for the build, since some cmdrun directives invoke dictk directly.

Building the API docs

Python API reference docs (function signatures, docstrings) are generated from source with pdoc, a dev dependency:

uv run pdoc dictk -o docs/api --docformat google --math -t docs/pdoc_templates   # build once, output in docs/api/
uv run pdoc dictk --docformat google --math -t docs/pdoc_templates              # live preview, serves on localhost

--docformat google matters: pdoc defaults to restructuredtext, which doesn't recognize this codebase's Google-style Args:/Returns:/Raises: docstring sections — without it, an Args: section renders as one flat paragraph instead of a proper bulleted list.

--math matters too: several docstrings (dictk.correlation's CC/NCC/ ZCC/ZNCC/phase-correlation formulas) use $...$/$$...$$ LaTeX — without it, no MathJax gets included and the raw LaTeX source shows up literally instead of being rendered. A separate trap in the same area: pdoc treats a docstring as Markdown before MathJax ever sees it, and Markdown's own backslash-escape rule silently strips the backslash off LaTeX commands like \! (a backslash followed by ASCII punctuation). Avoid that pattern in docstring math, or double the backslash (\\!).

No submodules need listing on the command line — bare pdoc dictk discovers all of them, and also builds the "Submodules" links on the dictk.html landing page, because every one of them (image/translation/correlation/grid/cli/rosta) is named directly in dictk/__init__.py's own __all__, alongside the individual functions (astronaut, checkerboard, rosta, __version__) it re-exports: pdoc's __all__ handling treats a name in __all__ that isn't already a bound attribute as a submodule to import and document. Leaving a submodule out of __all__ doesn't fail the build — it silently drops that module from both the generated docs and the landing page's own navigation — so if you add a new top-level submodule, add its name to __all__ too, not to this command.

Output goes to docs/api/ (gitignored, regenerated on demand). CI builds this too and publishes it alongside the mdBook user guide — see "CI/CD architecture" below.

Development note: -t docs/pdoc_templates points pdoc at docs/pdoc_templates/custom.css, pdoc's own supported override point (-t/--template-directory — see pdoc's documentation). It's included last, after theme.css/layout.css/content.css, so it always wins the cascade. This one softens pdoc's default theme: a pure white page background (--pdoc-background: #fff) with code-block/highlighted-box backgrounds only slightly darker (--code: #f8f8f8, --accent: #eee) reads as a stark white glare across the page as a whole. The override shifts all three together — --pdoc-background: #efede7, --code: #e3dfd7, --accent: #d7d3c9 — rather than tinting the background alone, so the page < code-block < accent-box hierarchy pdoc's default theme establishes stays intact, just softer throughout. Tuned in two successive passes, each computed in HSL space (same hue/ saturation per variable, lightness lowered by a measured delta) rather than picked by eye, so the gaps between the three tiers stay even instead of collapsing into each other. --accent2 (the border/ scrollbar gray, #c1c1c1) is untouched — already reads with plenty of contrast against every tone above.

Building the coverage badge

The README's coverage badge is a real SVG generated from coverage.xml with genbadge, a dev dependency — not a static label:

uv run pytest --cov=src/dictk --cov-report=xml --cov-report=html
uv run genbadge coverage -i coverage.xml -o coverage-badge.svg

In CI this runs in the docs job (not test) using the coverage.xml produced by the test job's report-test artifact, so the badge only updates on pushes to main or dev — same cadence as the Docs and API badges, not per-PR. Both coverage-badge.svg and the full htmlcov/ report are staged into the deployed site under that branch's subdirectory (<branch>/badges/coverage.svg and <branch>/coverage/ respectively) — see "CI/CD architecture" below.

Building the tests badge

The README's tests badge (tests: N pass M fail) is a real SVG built from pytest's own JUnit XML report, not a static label:

uv run pytest --cov=src/dictk --cov-report=xml --cov-report=html --junitxml=junit.xml
uv run python .github/scripts/generate_tests_badge.py --input junit.xml --output tests-badge.svg

--junitxml is a builtin pytest flag — no extra plugin needed. generate_tests_badge.py parses that report's pass/fail/skip counts and requests a matching badge from shields.io directly (same service genbadge uses for the coverage badge above, just called directly here rather than through that library, since genbadge's own test-badge format is N/M, not the N pass M fail wording this one matches) — green when nothing fails, red otherwise. Like the coverage badge, this runs in the docs job using the test job's report-test artifact, so it updates on pushes to main/dev only. Staged into the deployed site at <branch>/badges/tests.svg.

Development note: shields.io returns 403 Forbidden for urllib's default User-Agent string (Python-urllib/x.y) — presumably basic bot filtering, since curl (used by the lint badge below) isn't blocked. Fixed by setting an explicit User-Agent header on the request rather than shelling out to curl from a Python script for no other reason.

Running pylint (informational)

ruff (ruff format --check and ruff check) is what actually gates CI — see "Linting and formatting" above. pylint also runs, but only in the docs job, and only informationally: it can't fail the build. It exists purely because ruff has no equivalent of pylint's Your code has been rated at X.XX/10 score, and the README's lint badge wants a score, not just a pass/fail (which the CI badge already covers). Since pylint and ruff check overlapping-but-different rule sets, expect pylint to flag a few things ruff doesn't (and vice versa) — that's expected duplication from running two linters, not a bug in either.

uv run pylint src/dictk --output-format=text --reports=yes > pylint-report.txt
uv run python .github/scripts/render_pylint_report.py \
  --input pylint-report.txt --output pylint-report.html

The badge itself is built by extracting the score from that output and requesting a matching badge from shields.io — see the "Run pylint (informational) and generate lint badge/report" step in ci.yml for the exact score-extraction and color-threshold logic. pylint-report.html is staged into the deployed site at <branch>/reports/lint/, and the badge at <branch>/badges/lint.svg — same cadence as the other gh-pages badges (updates on pushes to main or dev).

Building the status dashboard

<branch>/dashboard/ on the deployed site is a single page linking every badge and report above for that branch, generated by .github/scripts/render_dashboard.py. It exists because the mdBook user guide occupies that branch's subdirectory root, so there's no natural landing page that lists the API reference, coverage report, and lint report together — rather than expecting visitors to already know those paths, or scattering the links across the README only. It doesn't require any of the other artifacts to already exist locally (it only generates links to them, using paths relative to <branch>/dashboard/, e.g. ../coverage/):

uv run python .github/scripts/render_dashboard.py \
  --github-repo hovey/dictk \
  --run-id local \
  --sha "$(git rev-parse HEAD)" \
  --ref-name "$(git rev-parse --abbrev-ref HEAD)" \
  --timestamp "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
  --output dashboard.html

In CI, ${{ github.run_id }}, ${{ github.sha }}, and ${{ github.ref_name }} fill in the run metadata instead. dashboard.html is staged into the deployed site at <branch>/dashboard/.

Building the root landing page

The site root (/) doesn't belong to either branch — main and dev each deploy to their own subdirectory (see "CI/CD architecture" below), so the root is a two-column dashboard (main "Released" in blue, dev "Development" in orange) linking to each branch's user guide, API reference, dashboard, coverage, lint, and tests badges, styled with the Tailwind CDN build — modeled on sandialabs/rattlesnake-vibration-controller's gh-pages dashboard. It's generated by .github/scripts/render_landing.py and regenerated on every deploy from whichever branch ran most recently (only the footer's timestamp/commit/CI-run attribution changes between deploys — the two columns' links are static):

uv run python .github/scripts/render_landing.py \
  --github-repo hovey/dictk \
  --run-id local \
  --sha "$(git rev-parse HEAD)" \
  --ref-name "$(git rev-parse --abbrev-ref HEAD)" \
  --timestamp "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
  --output landing.html

landing.html is staged as index.html at the deployed site's root.

Before pushing

There's no preflight command yet (see rattlesnake-vibration-controller's preflight.py for an example of what that could grow into) — for now, run the checks manually:

uv run ruff format --check
uv run ruff check
uv run pytest --cov=src/dictk --cov-report=xml --cov-report=html --junitxml=junit.xml
uv run mdbook build
uv run pdoc dictk -o docs/api --docformat google --math -t docs/pdoc_templates
uv run genbadge coverage -i coverage.xml -o coverage-badge.svg
uv run python .github/scripts/generate_tests_badge.py --input junit.xml --output tests-badge.svg
uv run pylint src/dictk --output-format=text --reports=yes

These are exactly the checks the test and docs jobs run in CI.

CI/CD architecture

CI and releasing live in two workflows: .github/workflows/ci.yml (checks and docs, on every push/PR) and .github/workflows/release.yml (publishing, on a version tag push).

ci.yml has two jobs, plus a workflow_call trigger so release.yml can invoke it as a reusable workflow:

  • test — runs on every push, pull request, and when called from release.yml. Installs dependencies with uv sync, runs uv build as a build sanity check, ruff format --check, ruff check, and pytest --cov (with --junitxml too). Uploads the coverage and JUnit XML reports as a build artifact (report-test).

  • docs — runs only on pushes to main or dev, after test passes (this if condition also means it's skipped when release.yml calls ci.yml from a tag push, since the ref won't be refs/heads/main or refs/heads/dev). Installs the pinned mdbook 0.4.52, mdbook-cmdrun, and mdbook-katex (cached via actions/cache), downloads the test job's report-test artifact, builds the mdBook user guide with dictk's own CLI on PATH, builds the pdoc API reference, generates a coverage badge from coverage.xml with genbadge and a tests badge from junit.xml (N pass M fail, via generate_tests_badge.py — see "Building the tests badge" above), runs pylint informationally to get a 0-10 score (fetched as a shields.io badge) and a full findings report, renders a status dashboard linking all of the above, and stages all of it into one directory (user guide at the root, API reference under api/, badges under badges/coverage.svg, badges/lint.svg, and badges/tests.svg, full HTML coverage report under coverage/, full pylint report under reports/lint/, dashboard under dashboard/).

    That staged directory becomes the whole subtree for whichever branch triggered the run — deployed to main/ or dev/ on the gh-pages branch (published via GitHub Pages), alongside a regenerated root index.html landing page linking to both (see "Building the root landing page" above). Deployment is a manual clone-of-gh-pages → replace only ${DEPLOY_SUBDIR}/ and index.html → commit → push, not peaceiris/actions-gh-pages: that action replaces the whole publish_dir (or, with keep_files, tries to preserve everything else, which is imprecise if main and dev deploy close together and risks one branch's content clobbering the other's — a problem sandialabs/rattlesnake-vibration-controller's ci.yml hit and solved the same way). The job's concurrency group (gh-pages-deploy, cancel-in-progress: false) serializes main's and dev's deploys so this step never runs for both at once.

release.yml triggers on pushing a tag matching v* and runs, in order:

  • validate_tag — verifies the tag is valid PEP 440, that it's strictly newer than every existing tag, and that its branch matches its prerelease status: a prerelease tag (a/b/rc/.dev suffix) must be reachable from origin/dev; a stable/post tag must be reachable from origin/main specifically. Outputs is_prerelease for the jobs below.
  • testneeds: validate_tag, calls ci.yml's test job fresh at the tagged commit (not reused from an earlier push-to-main run).
  • buildneeds: test. Runs uv build, generates a build-provenance attestation for the dist files, and uploads them as an artifact.
  • github-releaseneeds: [build, validate_tag]. Creates a GitHub Release with auto-generated notes, attaching the dist files, marked prerelease or not per validate_tag's output.
  • publish_testpypi / publish_pypineeds: [build, github-release, validate_tag], gated on is_prerelease being true/false respectively. Publishes to TestPyPI or PyPI. See "Releasing" below.

This is intentionally a minimal setup — no matrix OS/Python testing, no containerized builds. pytribeam's ci.yml and rattlesnake-vibration-controller's ci.yml/release.yml are useful references for growing any of this out later (dictk's release.yml is in fact modeled on rattlesnake-vibration-controller's, with one addition: tying the branch check to prerelease status, described above).

Versioning

Versions are derived automatically from git tags via hatch-vcs — there is no hand-maintained version string anywhere in the source. Tag format is a v-prefixed PEP 440 version, e.g. v0.1.0.

If the current commit isn't exactly at a tag (or the working tree is dirty), hatch-vcs appends a local version segment (e.g. 0.1.dev1+gd975d09). PyPI and TestPyPI reject uploads with a local version segment, so a publishable commit must be exactly the tagged commit.

Tags and semantic versioning

Tags follow PEP 440, which requires version strings to follow this structure:

N.N.N[{a|b|rc}N][.postN][.devN]

Example tags

Prerelease tags:

tagdescription
v1.1.0a1The first alpha for version 1.1.0
v1.1.0b2The second beta for version 1.1.0
v1.1.0rc1The first release candidate for version 1.1.0

A release candidate is made during the final testing stage before a full release.

Stable release tags (e.g., starting from a v1.0.0 release):

tagdescription
v1.0.1Patch release: backwards-compatible bug fixes
v1.1.0Minor release: new features that are backwards-compatible
v2.0.0Major release: significant changes or breaking API updates

Development and post-release tags:

tagdescription
v1.1.0.dev1A version currently under development
v1.0.0.post1A fix for a minor error in the release process, such as a typo in the documentation, without changing the code

Release on tag

Pushing a tag is what triggers release.yml (see "CI/CD architecture" above) — there's no separate commit-message keyword. Which registry it publishes to is decided by the tag's own shape: a prerelease tag (a/b/rc/.dev suffix) publishes to TestPyPI, a stable/post tag publishes to PyPI. The branch the tag is cut from has to match: prerelease tags on dev, stable/post tags on main. See "Merging dev into main" and "Publishing a release" below for the actual commands.

Releasing

Releases are triggered by pushing a git tag matching v* — see "Release on tag" above. validate_tag (the first job in release.yml) checks the tag is valid PEP 440, strictly newer than every existing tag, and cut from the branch its prerelease status requires (dev for prerelease, main for stable/post). Because the release jobs build whatever hatch-vcs resolves at the tagged commit, the tag must point at the exact commit you want published.

Merging dev into main

main is a protected branch — it only accepts changes through a merged pull request, even for repo admins, so git push origin main will be rejected. This step is only needed before a stable release (prereleases tag dev directly — see "Publishing a release" below). Merge dev into main through a PR:

git checkout dev
git pull origin dev
gh pr create --base main --head dev --title "Merge dev into main" --body ""
gh pr merge --merge

No approving review is required, so you can merge your own PR.

One-time setup (already done for this repo)

  1. GitHub → repo Settings → Environments: create testpypi and pypi environments. pypi has a "Required reviewers" rule (you) configured, so a real release needs manual approval in the Actions UI before publishing — testpypi doesn't need this.
  2. On test.pypi.org and pypi.org, under the dictk project's "Publishing" settings, add a trusted publisher: owner hovey, repository dictk, workflow file release.yml (not ci.yml — publishing happens in the tag-triggered workflow), environment name testpypi (for TestPyPI) or pypi (for PyPI).

No API tokens are stored anywhere — publishing uses OIDC trusted publishing via the id-token: write permission.

Publishing a release

Prerelease (TestPyPI) — tag dev directly, no PR needed:

git checkout dev
git pull origin dev
git tag v0.1.0rc1
git push origin v0.1.0rc1

Stable (PyPI) — merge dev into main first (see "Merging dev into main" above), then tag main:

git checkout main
git pull origin main
git tag v0.1.0
git push origin v0.1.0

Either way, watch the Actions tab: validate_tagtestbuildgithub-releasepublish_testpypi/publish_pypi. For a prerelease, check https://test.pypi.org/project/dictk/ once it succeeds. For a stable release, the publish_pypi job pauses for your approval (the pypi environment's required reviewer) before it runs; approve it from the Actions run page, then check https://pypi.org/project/dictk/.

Uploads to PyPI (and TestPyPI) are permanent — a given version's files can never be re-uploaded or deleted, only "yanked". Prefer testing on TestPyPI first, as with v0.1.0rc1 above, before publishing the stable release.