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.
This guide is a work in progress. See the following pages for what dictk currently provides, starting with generating a synthetic reference image.
Installation
pip install dictk
Image Generation
The source for the commands on this page is
dictk's ownrosta,checkerboard, andastronautsubcommands — seedictk --help.
Note: the images embedded on this page are rendered as PNG (
--format png), notdictk'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, anddictk.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.combinebelow) before anything touches disk — and callers who do want a file calldictk.image.writeexplicitly, 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:
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
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
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
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, 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
| rosta | checkerboard | checkerboard0 |
|---|---|---|
![]() | ![]() | ![]() |
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
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
dictk.image.subimage extracts a
rectangular crop from a source image: a width x height region whose
top-left corner sits at origin, expressed in the source image's own
pixel reference frame (top-left corner (0, 0)). 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.
Reference image
The examples below reuse astronaut0, the speckle pattern combined with
the astronaut photo introduced in Image
Generation:
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
Python API
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 reference frame —
top-left corner (0, 0) — 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 — instead of the right panel zooming in to fit just the 80x80
crop:
from dictk.image import PixelCoordinate, 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
Rectangle, fully inside
A 180x70 region — wider than it is tall — also entirely within the source image bounds:
from dictk.image import PixelCoordinate, 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
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, 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
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, 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
astronaut0.Image Preprocessing
Certain preprocessing steps can make digital image correlation more robust to differences in brightness and contrast between a reference and deformed image. This page covers two of them, using the astronaut reference image from the previous page 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, histogram_save, write
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.5 | factor=2.0 |
|---|---|---|
![]() | ![]() | ![]() |
| factor=1.0 (original) | factor=1.5 | factor=2.0 |
|---|---|---|
![]() | ![]() | ![]() |
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, histogram_save, write
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.5 | factor=2.0 |
|---|---|---|
![]() | ![]() | ![]() |
| factor=1.0 (original) | factor=1.5 | factor=2.0 |
|---|---|---|
![]() | ![]() | ![]() |
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.
Image Transformation
Image deformations, also called transformations in the computer vision literature (see Szeliski1), fall into the categories shown below:
Each category preserves a different, nested set of geometric properties — every property a more restrictive category preserves is also preserved by every category to its left:
| Property | Translation | Euclidean | Similarity | Affine | Projective |
|---|---|---|---|---|---|
| Straight lines stay straight | Yes | Yes | Yes | Yes | Yes |
| Parallel lines stay parallel | Yes | Yes | Yes | Yes | No |
| Angles preserved | Yes | Yes | Yes | No | No |
| Lengths/distances preserved | Yes | Yes | No | No | No |
| Absolute orientation preserved (no rotation) | Yes | No | No | No | No |
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
| Translation | Image |
|---|---|
| Original | ![]() |
| dx=-60, dy=+80 | ![]() |
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
| Rotation | Image |
|---|---|
| Original | ![]() |
| 30° (origin-pivoted) | ![]() |
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
| Stretch | Image |
|---|---|
| Original | ![]() |
| 5% (factor_x=1.05) | ![]() |
| 50% (factor_x=1.50) | ![]() |
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
| Compression | Image |
|---|---|
| Original | ![]() |
| -5% (factor_y=0.95) | ![]() |
| -50% (factor_y=0.50) | ![]() |
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
| Shear | Image |
|---|---|
| Original | ![]() |
| γ = 0.5 (shear_x=0.5) | ![]() |
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 Deformation | Image |
|---|---|
| Original | ![]() |
| factor_x=1.3, factor_y=0.8, angle=15° | ![]() |
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 Dislocation | Image |
|---|---|
| Original | ![]() |
| offset=4 pixels | ![]() |
References
-
Szeliski R. Computer vision: algorithms and applications, 2nd Edition, Springer Nature; 2022 Jan 3. download (43 MB) ↩
Single Point Motion
Let origin be located at the top, left of the image. We attach reference frame to the origin, with the -axis running left-to-right and the -axis running top-to-bottom — the same pixel reference frame used throughout this guide (see Subimage Generation).
Consider a single point , fixed to a physical location on the object
being imaged. In the reference image , this point is at a known
pixel location pixels — we
use the shorthand for this reference configuration.
Between the reference image and a later current image , the
object (and therefore ) may move. Finding 's new location
— its current configuration — in , given only
, is the problem this page works through, using
dictk.translation.locate.
Reference Configuration
The examples below reuse astronaut0, the speckle pattern combined with
the astronaut photo introduced in Image
Generation and used again in
Subimage Generation — here called
reference_image, matching locate's own parameter name:
import dictk
from dictk.image import combine, PixelCoordinate, point_plot, ArrowAnnotation
speckle = dictk.rosta(width=300, height=300, density=0.5)
photo = dictk.astronaut(width=300, height=300)
reference_image = combine(a=speckle, b=photo)
p0 = PixelCoordinate(x=100, y=75)
point_plot(
image=reference_image,
arrows=[
ArrowAnnotation(
tail=PixelCoordinate(x=0, y=0), head=p0, color="gold", label="p0"
)
],
path="single_point_motion_p0.png",
)
Saved: single_point_motion_p0.png
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 — one of the simplest deformation categories (see Image
Transformation for the full set). Because the whole
image moves together, point 's new location follows directly:
from dictk.image import translate
current_image = translate(arr=reference_image, dx=-6, dy=8)
p1 = PixelCoordinate(x=p0.x - 6, y=p0.y + 8) # 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=p1, color="cyan", label="p1"
),
ArrowAnnotation(tail=p0, head=p1, color="magenta", label="displacement"),
],
path="single_point_motion_p1_displacement.png",
)
Saved: single_point_motion_p1_displacement.png
Of course, p1 above was only known in advance because we generated
current_image ourselves with a known translate. In practice — a real
pair of DIC images — is exactly what's unknown and
needs to be found. The rest of this page finds it using only
reference_image, current_image, and , the same
information available in the real case, to demonstrate that locate
recovers it correctly.
Cross-Correlation
We use cross-correlation to find where point moved to. Let the
kernel — also called a subset, filter, or convolution
matrix — be a rectangular region of reference_image centered on
. The kernel is a small, distinctive patch of 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 a search window, scanning zone, or area of interest
(AOI)), a larger rectangular region of current_image centered on a
search_center — a guess of roughly where ended up, not the answer
itself. Here, with no better guess available, we reuse
itself as search_center.
Kernel
From reference_image, extract the kernel surrounding ,
with a 25-pixel margin on every side (50x50 total):
from dictk.image 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,
path="single_point_motion_kernel.png",
)
Saved: single_point_motion_kernel.png
reference_image centered on , with origin pixels (red 'o'). Right: the extracted kernel on its own, in its own local reference frame .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), 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,
path="single_point_motion_search.png",
)
Saved: single_point_motion_search.png
current_image centered on search_center, with origin pixels (red 'o'). Right: the extracted search area on its own, in its own local reference frame .The search area likewise has its own local frame , origin at its top-left corner:
where is point 's current position — what we're trying to find. , its position within the search area's local frame, is still unknown at this point.
Solution
Cross-correlation fixes the search area and finds where the kernel's content best aligns within it — the kernel's own origin , located relative to the search area's origin : . Once is located, 's position within the search area follows from the same kernel-local offset found above:
Substituting into the search area's equation gives the full chain from the shared origin to :
This is exactly what
dictk.translation.locate computes
internally — via
skimage.registration.phase_cross_correlation
for — so as a caller, you don't assemble
this chain by hand; locate returns directly.
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:
r_sk = PixelCoordinate(
x=found.x - search_origin.x - kernel_margin,
y=found.y - search_origin.y - kernel_margin,
)
point_plot(
image=current_image,
arrows=[
ArrowAnnotation(
tail=PixelCoordinate(x=0, y=0),
head=search_origin,
color="blue",
label="r_OS: search area origin",
),
ArrowAnnotation(
tail=search_origin,
head=PixelCoordinate(x=search_origin.x + r_sk.x, y=search_origin.y + r_sk.y),
color="orange",
label="r_SK: kernel found in search area",
),
ArrowAnnotation(
tail=PixelCoordinate(x=search_origin.x + r_sk.x, y=search_origin.y + r_sk.y),
head=found,
color="black",
label="r_KP: point within kernel",
),
],
path="single_point_motion_solution_vectors.png",
)
Saved: single_point_motion_solution_vectors.png
current_image.| vector, value | description |
|---|---|
| + | origin of the search area (blue arrow) |
| + | kernel located within the search area, from cross-correlation (orange arrow) |
| = | point's fixed position within the kernel (black arrow) |
current position , matching found above |
Image Processing
(placeholder — coming soon)
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.
| Cloning | Forking |
|---|---|
| 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
Using uv (recommended)
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 changesdev-algorithm-refactor— refactoring an existing algorithm or moduledev-imaging— new imaging transformations/workflowsdev-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-leaseis safer than--forcesince 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
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).
# mdbook-cmdrun resolves each cmdrun command's working directory relative
# to the process's cwd, so you must `cd` into docs/userguide first — running
# `mdbook build docs/userguide` from the repo root will fail with
# "Fail to run shell".
cd docs/userguide
uv run mdbook build # build once, output in docs/userguide/book/
uv run mdbook serve # 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 dictk.image dictk.translation dictk.cli -o docs/api
dictk.rosta doesn't need to be listed explicitly — pdoc's submodule
discovery respects a package's __all__, and rosta is exported there (see
below), so it's picked up automatically. The other submodules (image,
translation, cli) aren't in __all__ — dictk/__init__.py only lists
the individual functions it re-exports, not module names — so pdoc's
automatic package walk skips them unless named explicitly on the command
line, per
pdoc's __all__ handling. If you add a new top-level submodule, add it to this
command too, or it will silently go undocumented.
uv run pdoc dictk dictk.image dictk.translation dictk.cli # live preview, serves on localhost
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.
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-coverage 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.
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, and lint
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
(cd docs/userguide && uv run mdbook build)
uv run pdoc dictk dictk.image dictk.translation dictk.cli -o docs/api
uv run genbadge coverage -i coverage.xml -o coverage-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 fromrelease.yml. Installs dependencies withuv sync, runsuv buildas a build sanity check,ruff format --check,ruff check, andpytest --cov. Uploads the coverage report as a build artifact. -
docs— runs only on pushes tomainordev, aftertestpasses (thisifcondition also means it's skipped whenrelease.ymlcallsci.ymlfrom a tag push, since the ref won't berefs/heads/mainorrefs/heads/dev). Installs the pinnedmdbook0.4.52,mdbook-cmdrun, andmdbook-katex(cached viaactions/cache), downloads thetestjob's coverage artifact, builds the mdBook user guide with dictk's own CLI onPATH, builds the pdoc API reference, generates a coverage badge fromcoverage.xmlwith genbadge, 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 underapi/, badges underbadges/coverage.svgandbadges/lint.svg, full HTML coverage report undercoverage/, full pylint report underreports/lint/, dashboard underdashboard/).That staged directory becomes the whole subtree for whichever branch triggered the run — deployed to
main/ordev/on thegh-pagesbranch (published via GitHub Pages), alongside a regenerated rootindex.htmllanding page linking to both (see "Building the root landing page" above). Deployment is a manual clone-of-gh-pages→ replace only${DEPLOY_SUBDIR}/andindex.html→ commit → push, notpeaceiris/actions-gh-pages: that action replaces the wholepublish_dir(or, withkeep_files, tries to preserve everything else, which is imprecise ifmainanddevdeploy close together and risks one branch's content clobbering the other's — a problem sandialabs/rattlesnake-vibration-controller'sci.ymlhit and solved the same way). The job'sconcurrencygroup (gh-pages-deploy,cancel-in-progress: false) serializesmain's anddev'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/.devsuffix) must be reachable fromorigin/dev; a stable/post tag must be reachable fromorigin/mainspecifically. Outputsis_prereleasefor the jobs below.test—needs: validate_tag, callsci.yml'stestjob fresh at the tagged commit (not reused from an earlier push-to-main run).build—needs: test. Runsuv build, generates a build-provenance attestation for the dist files, and uploads them as an artifact.github-release—needs: [build, validate_tag]. Creates a GitHub Release with auto-generated notes, attaching the dist files, marked prerelease or not pervalidate_tag's output.publish_testpypi/publish_pypi—needs: [build, github-release, validate_tag], gated onis_prereleasebeingtrue/falserespectively. 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:
| tag | description |
|---|---|
v1.1.0a1 | The first alpha for version 1.1.0 |
v1.1.0b2 | The second beta for version 1.1.0 |
v1.1.0rc1 | The 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):
| tag | description |
|---|---|
v1.0.1 | Patch release: backwards-compatible bug fixes |
v1.1.0 | Minor release: new features that are backwards-compatible |
v2.0.0 | Major release: significant changes or breaking API updates |
Development and post-release tags:
| tag | description |
|---|---|
v1.1.0.dev1 | A version currently under development |
v1.0.0.post1 | Fix 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)
- GitHub → repo Settings → Environments: create
testpypiandpypienvironments.pypihas a "Required reviewers" rule (you) configured, so a real release needs manual approval in the Actions UI before publishing —testpypidoesn't need this. - On test.pypi.org and
pypi.org, under the
dictkproject's "Publishing" settings, add a trusted publisher: ownerhovey, repositorydictk, workflow filerelease.yml(notci.yml— publishing happens in the tag-triggered workflow), environment nametestpypi(for TestPyPI) orpypi(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_tag → test → build →
github-release → publish_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.




























