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.

This guide is a work in progress. For now, it covers the one primitive the toolkit ships with.

Installation

pip install dictk

Getting started

dictk currently provides a single building block: zero-normalized cross-correlation (ZNCC), the similarity metric DIC template matching is built on.

import numpy as np
from dictk import zero_normalized_cross_correlation

a = np.array([[1.0, 2.0], [3.0, 4.0]])
b = np.array([[2.0, 4.0], [6.0, 8.0]])

score = zero_normalized_cross_correlation(a, b)
print(score)  # 1.0 -- correlated up to a brightness/contrast rescale

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_images below) before anything touches disk — and callers who do want a file call dictk.imaging.write_image 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(200, 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(200, 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(300, 300)
shape=(300, 300), dtype=uint8

Combining into a reference image

combine_images 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.imaging import combine_images, read_image, write_image

speckle = read_image("rosta_200w_by_200h_dot_4.0_den_0.5_smo_2.0.png")
checker = read_image("checkerboard_200w_by_200h_8x8.png")
checkerboard0 = combine_images(speckle, checker)
write_image(checkerboard0, "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.imaging import read_image, save_histogram

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

save_histogram(speckle, "rosta_histogram.png")
save_histogram(checker, "checkerboard_histogram.png")
save_histogram(checkerboard0, "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_images accepts as-is, so only the combined result astronaut0 is saved:

import dictk
from dictk.imaging import combine_images, write_image

speckle = dictk.rosta(300, 300, density=0.5)
photo = dictk.astronaut(300, 300)
astronaut0 = combine_images(speckle, photo)
write_image(astronaut0, "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.

Image Processing

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.imaging import brightness, save_histogram, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_original.png")
save_histogram(photo, "astronaut_original_histogram.png")

bright_1_5 = brightness(photo, 1.5)
write_image(bright_1_5, "astronaut_brightness_1.5.png")
save_histogram(bright_1_5, "astronaut_brightness_1.5_histogram.png")

bright_2_0 = brightness(photo, 2.0)
write_image(bright_2_0, "astronaut_brightness_2.0.png")
save_histogram(bright_2_0, "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.imaging import contrast, save_histogram, write_image

photo = dictk.astronaut(300, 300)

contrast_1_5 = contrast(photo, 1.5)
write_image(contrast_1_5, "astronaut_contrast_1.5.png")
save_histogram(contrast_1_5, "astronaut_contrast_1.5_histogram.png")

contrast_2_0 = contrast(photo, 2.0)
write_image(contrast_2_0, "astronaut_contrast_2.0.png")
save_histogram(contrast_2_0, "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.

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 more restrictive category preserves is also preserved by every 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.imaging.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.imaging import translate, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_translate_original.png")

translated = translate(photo, dx=-60, dy=80)
write_image(translated, "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.imaging.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.imaging import rotate, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_rotate_original.png")

rotated = rotate(photo, 30.0)
write_image(rotated, "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.imaging.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.imaging import stretch, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_stretch_original.png")

stretch_5pct = stretch(photo, factor_x=1.05)
write_image(stretch_5pct, "astronaut_stretch_x_5pct.png")

stretch_50pct = stretch(photo, factor_x=1.50)
write_image(stretch_50pct, "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.imaging.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.imaging import stretch, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_compress_original.png")

compress_neg5pct = stretch(photo, factor_y=0.95)
write_image(compress_neg5pct, "astronaut_compress_y_neg5pct.png")

compress_neg50pct = stretch(photo, factor_y=0.50)
write_image(compress_neg50pct, "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.imaging.shear pivots on the image's top-left corner (0, 0), consistent with the other transform functions in this codebase.

import dictk
from dictk.imaging import shear, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_shear_original.png")

sheared = shear(photo, shear_x=0.5)
write_image(sheared, "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.imaging.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.imaging import complex_deform, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_complex_original.png")

combined = complex_deform(photo, factor_x=1.3, factor_y=0.8, angle=15.0)
write_image(combined, "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.imaging import crack_dislocation, write_image

photo = dictk.astronaut(300, 300)
write_image(photo, "astronaut_crack_plain_original.png")

cracked_plain = crack_dislocation(photo, offset=4.0)
write_image(cracked_plain, "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)

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

Building the docs

Documentation is an mdBook under docs/userguide/, with the mdbook-cmdrun preprocessor enabled so pages can embed live, always-accurate command output (see the "Image Generation" page for an example) instead of pasted-by-hand output. Neither is a Python dependency:

# mdbook must be pinned to 0.4.52: mdbook-cmdrun depends 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). Do not `brew install mdbook` or
# `cargo install mdbook` without a --version pin, or cmdrun pages will fail
# to build with "Unable to parse the input".
cargo install mdbook --version 0.4.52
cargo install mdbook-cmdrun

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.core dictk.imaging 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 (core, imaging, 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.core dictk.imaging 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.core dictk.imaging 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 from release.yml. Installs dependencies with uv sync, runs uv build as a build sanity check, ruff format --check, ruff check, and pytest --cov. Uploads the coverage report as a build artifact.

  • 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 and mdbook-cmdrun (cached via actions/cache), downloads the test job's coverage 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, 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 and badges/lint.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.post1Fix 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.