Discontinuity Localization
Synthetic Dislocation and Experimental Dislocation both found the same signature: a window straddling a crack shows two comparably-tall correlation peaks, not one. Both stopped there. A person centered the window on the crack first. Neither page found the crack on its own. This page tries three ways to find a crack on its own, compares them, and ships the one that wins.
A Naive Baseline: Grid Anomaly
The cheapest thing to try uses no new code at all. Run standard,
single-peak DIC across the crack with
dictk.grid.locate, the same way every
earlier chapter does, and see what a displacement field recovered while
ignoring the crack actually looks like:
import dictk
from dictk.grid import generate, locate
from dictk.image import PixelCoordinate, combine, crack_dislocation
speckle = dictk.rosta(width=300, height=300, density=0.5)
photo = dictk.astronaut(width=300, height=300)
reference_image = combine(a=speckle, b=photo)
current_image = crack_dislocation(arr=reference_image, offset=4.0)
points = generate(
origin=PixelCoordinate(x=100, y=150), count_x=11, count_y=1,
spacing_x=10, spacing_y=1,
)
found = locate(
reference_image=reference_image, current_image=current_image,
reference_points=points,
kernel_margin_width=25, kernel_margin_height=25,
search_margin_width=45, search_margin_height=45,
)
Grid spacing: 10 px, 11 points, 11 correlation evaluations
Largest displacement jump: 140 -> 150px
Estimated crack position: x=145.0
Error vs. known x=150: 5.0px
Saved: discontinuity_localization_baseline.png
It works, in the sense that it flags roughly the right neighborhood. It's also a proxy, not a measurement of the thing itself. Nothing here looks at peak structure. A large stretch, not a crack, would produce the same kind of jump. Resolution is capped at the grid's own spacing. Tighten the grid and the jump narrows, but so does how many points a DIC run at that spacing can afford to place.
A Peak-Ratio Metric
Synthetic Dislocation
already measured a straddling window's two peaks by hand: heights 0.528
and 0.519 at x=150. Turning that into a number that needs no ground
truth: dictk.discontinuity.peak_ratio
divides the second-tallest peak by the tallest, along the correlation
surface's own argmax column.
from dictk.correlation import zncc
from dictk.discontinuity import peak_ratio
from dictk.image import PixelCoordinate, subimage
for x in (150, 100):
p0 = PixelCoordinate(x=x, y=150)
kernel = subimage(
image=reference_image,
origin=PixelCoordinate(x=p0.x - 25, y=p0.y - 25),
width=50, height=50,
)
search = subimage(
image=current_image,
origin=PixelCoordinate(x=p0.x - 45, y=p0.y - 45),
width=90, height=90,
)
ratio = peak_ratio(surface=zncc(kernel=kernel, search=search))
print(f"x={x}: peak_ratio={ratio:.3f}")
x=150: peak_ratio=0.983
x=100: peak_ratio=0.000
Centered on the crack, peak_ratio is 0.983, close to the 1.0 two
perfectly equal peaks would give. At x=100, well clear of the crack,
it's 0.0: one peak resolvable, nothing to divide against. A single
number now stands in for "does this window straddle a discontinuity,"
with no offset to already know in advance.
A Dense Sweep and Subpixel Refinement
dictk.discontinuity.sweep
evaluates peak_ratio at many window-center positions along a line, and
dictk.discontinuity.locate
takes that sweep's tallest value and refines it to subpixel precision
with a 3-point parabolic fit. Sweeping the same x=100 to x=200 range
Synthetic Dislocation
already swept by hand:
Evaluations: 101 Located crack position: x=149.804 Error vs. known x=150: 0.196px
Saved: discontinuity_localization_sweep.png
101 evaluations, one per swept position, land 0.2 pixels from the known crack. No offset, no hand-picked center. The sweep finds the crossing on its own.
A Faster Alternative: Golden-Section Search
A dense sweep evaluates every candidate position, even the ones far
from any discontinuity. peak_ratio rises to a single maximum and falls
away on both sides of the crack. That's exactly the shape a
derivative-free optimizer can search without visiting every point.
Golden-section search narrows a bracket toward a unimodal function's
maximum in evaluations instead of sweep's :
Golden-section result: x=150.25, evaluations=12 Error vs. known x=150: 0.251px
| Approach | Evaluations | Localization Error (px) | Notes |
|---|---|---|---|
| Grid anomaly (baseline) | 11 | 5.0 | resolution capped at 10px grid spacing |
| Dense peak-ratio sweep (winner) | 101 | 0.196 | robust to bracket width, see below |
| Golden-section peak-ratio search | 12 | 0.251 | fast here, breaks on real data -- see below |
Saved: discontinuity_localization_bisection.png
12 evaluations instead of 101, landing 0.25 pixels from the known crack. That's barely worse than the dense sweep, for roughly a tenth of the cost. On this dataset, it looks like a strictly better trade.
Applying It to Real Data
Experimental Dislocation
already centered a window by eye on the real crack, at x=218, y=186.
This runs the dense sweep across a much wider range, x=100 to
x=350, assuming nothing about roughly where the crack sits. It also
runs golden-section search at three brackets: one already centered
tightly on the crack, one moderately wide, and one as wide as the
dense sweep's own range.
Dense sweep (wide range 100-350, 126 evaluations): located crack position: x=218.74 (Experimental Dislocation's own x=218)
golden-section, bracket (178, 258): x=217.55, evaluations=12 golden-section, bracket (150, 300): x=217.25, evaluations=13 golden-section, bracket (100, 350): x=308.11, evaluations=14
The tight and moderate brackets land within 1px of the dense sweep's own answer. The wide bracket -- the one that assumes no prior knowledge of roughly where the crack is -- converges instead to a smaller, secondary peak_ratio bump far from the real crack, confidently and silently. Saved: discontinuity_localization_experimental.png
The dense sweep's global maximum lands within a pixel of the known
location, no matter how wide a range it searches. Golden-section search
does too, but only at the two brackets already narrowed toward the
crack. Given the full, uncommitted range, it converges instead to the
bump near x=308, over 90 pixels from the real crack, confidently and
silently. A fast local search only works once you already roughly know
where to look. That's most of the problem this page set out to solve
in the first place.
Declaring a Winner
The dense sweep, with subpixel parabolic refinement, ships as
dictk.discontinuity. It needs no
prior estimate of where a crack sits, its accuracy doesn't depend on
how wide a range it searches, and it holds up on both synthetic and
real data. The grid-anomaly baseline and golden-section search stay as
illustrations on this page, not library code: the baseline only ever
offers grid-spacing resolution, and golden-section search's speed comes
at the cost of needing the answer roughly in hand before it can find it.
| Approach | Evaluations (synthetic) | Localization Error (synthetic) | Real Data |
|---|---|---|---|
| Grid anomaly (baseline) | 11 | 5.0 px | weaker, noisier jump signal |
| Dense peak-ratio sweep (winner) | 101 | 0.2 px | 0.7 px from by-eye estimate |
| Golden-section search | 12 | 0.25 px | fails on a wide, honest bracket |
What This Still Doesn't Do
This locates a crossing along one already-chosen line, not a discontinuity anywhere in a 2D field. Something still has to decide where to sweep. And a located position still isn't consumed by anything: Path Forward's Heaviside DIC/XFEM item asked for detection and localization, not a finite-element formulation that acts on the result. That half stays open.
Continue to Path Forward for where this leaves it.
discontinuity_localization_baseline.py
"""A naive baseline: run standard single-peak DIC across the crack,
ignoring it, and look for a jump in the recovered displacement field
between neighboring grid points. Zero new library code -- entirely
`dictk.grid.generate`/`dictk.grid.locate`.
"""
import matplotlib.pyplot as plt
import numpy as np
import dictk
from dictk.grid import generate, locate
from dictk.image import PixelCoordinate, combine, crack_dislocation
plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
WIDTH = HEIGHT = 300
OFFSET = 4.0
KERNEL_MARGIN = 25
SEARCH_MARGIN = 45
Y = 150
SPACING = 10
speckle = dictk.rosta(width=WIDTH, height=HEIGHT, density=0.5)
photo = dictk.astronaut(width=WIDTH, height=HEIGHT)
reference_image = combine(a=speckle, b=photo)
current_image = crack_dislocation(arr=reference_image, offset=OFFSET)
points = generate(
origin=PixelCoordinate(x=100, y=Y),
count_x=11,
count_y=1,
spacing_x=SPACING,
spacing_y=1,
)
found = locate(
reference_image=reference_image,
current_image=current_image,
reference_points=points,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
displacements = [f.y - p.y for f, p in zip(found, points)]
jumps = [
abs(displacements[i + 1] - displacements[i]) for i in range(len(displacements) - 1)
]
jump_index = int(np.argmax(jumps))
crack_estimate = (points[jump_index].x + points[jump_index + 1].x) / 2
print(
f"Grid spacing: {SPACING} px, {len(points)} points, {len(points)} correlation evaluations"
)
print(
f"Largest displacement jump: {points[jump_index].x} -> {points[jump_index + 1].x}px"
)
print(f"Estimated crack position: x={crack_estimate}")
print(f"Error vs. known x=150: {abs(crack_estimate - 150)}px")
print()
fig, ax = plt.subplots(figsize=(6.0, 4.5), constrained_layout=True)
xs = [p.x for p in points]
ax.step(xs, displacements, where="mid", color="tab:blue", marker="o")
ax.axvline(150, color="gray", linestyle="--", linewidth=1, label="known crack (x=150)")
ax.axvline(
crack_estimate,
color="tab:red",
linestyle=":",
linewidth=1.5,
label=f"estimated crack (x={crack_estimate:.0f})",
)
ax.set_xlabel("grid point x (pixels)")
ax.set_ylabel("recovered y-displacement (pixels)")
ax.set_title("Grid-Anomaly Baseline: Displacement Jump")
ax.legend(loc="center right")
fig.savefig("discontinuity_localization_baseline.png", dpi=300)
print("Saved: discontinuity_localization_baseline.png")
discontinuity_localization_sweep.py
"""Locate the crack with a dense peak-ratio sweep and subpixel parabolic
refinement, no ground truth required -- `dictk.discontinuity.sweep()`
and `.locate()`, generalizing Synthetic Dislocation's own known-offset
x-sweep into a real detector.
"""
import matplotlib.pyplot as plt
import dictk
from dictk.discontinuity import locate, sweep
from dictk.image import PixelCoordinate, combine, crack_dislocation
plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
WIDTH = HEIGHT = 300
OFFSET = 4.0
KERNEL_MARGIN = 25
SEARCH_MARGIN = 45
Y = 150
SAMPLES = 101
speckle = dictk.rosta(width=WIDTH, height=HEIGHT, density=0.5)
photo = dictk.astronaut(width=WIDTH, height=HEIGHT)
reference_image = combine(a=speckle, b=photo)
current_image = crack_dislocation(arr=reference_image, offset=OFFSET)
result = sweep(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=100, y=Y),
end=PixelCoordinate(x=200, y=Y),
samples=SAMPLES,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
xs = [p.x for p in result.positions]
found = locate(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=100, y=Y),
end=PixelCoordinate(x=200, y=Y),
samples=SAMPLES,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
print(f"Evaluations: {SAMPLES}")
print(f"Located crack position: x={found.x:.3f}")
print(f"Error vs. known x=150: {abs(found.x - 150):.3f}px")
print()
fig, ax = plt.subplots(figsize=(7.0, 5.0), constrained_layout=True)
ax.plot(xs, result.peak_ratios, color="tab:blue")
ax.axvline(150, color="gray", linestyle="--", linewidth=1, label="known crack (x=150)")
ax.axvline(
found.x,
color="tab:red",
linestyle=":",
linewidth=1.5,
label=f"located crack (x={found.x:.1f})",
)
ax.set_xlabel("kernel window center x (pixels)")
ax.set_ylabel("peak_ratio")
ax.set_title("Dense Peak-Ratio Sweep")
ax.legend(loc="lower right")
fig.savefig("discontinuity_localization_sweep.png", dpi=300)
print("Saved: discontinuity_localization_sweep.png")
discontinuity_localization_bisection.py
"""A faster alternative: instead of sweeping every position, search for
`peak_ratio`'s maximum with golden-section search, which needs only
O(log n) evaluations if the metric is unimodal within the search
bracket. Not shipped as library code -- see Applying It to Real Data for
why.
"""
import matplotlib.pyplot as plt
import numpy as np
import dictk
from dictk.correlation import zncc
from dictk.discontinuity import locate as discontinuity_locate
from dictk.discontinuity import peak_ratio
from dictk.discontinuity import sweep as discontinuity_sweep
from dictk.grid import generate
from dictk.grid import locate as grid_locate
from dictk.image import PixelCoordinate, combine, crack_dislocation, subimage
plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
WIDTH = HEIGHT = 300
OFFSET = 4.0
KERNEL_MARGIN = 25
SEARCH_MARGIN = 45
Y = 150
speckle = dictk.rosta(width=WIDTH, height=HEIGHT, density=0.5)
photo = dictk.astronaut(width=WIDTH, height=HEIGHT)
reference_image = combine(a=speckle, b=photo)
current_image = crack_dislocation(arr=reference_image, offset=OFFSET)
def _evaluate(x):
p0 = PixelCoordinate(x=int(round(x)), y=Y)
kernel = subimage(
image=reference_image,
origin=PixelCoordinate(x=p0.x - KERNEL_MARGIN, y=p0.y - KERNEL_MARGIN),
width=2 * KERNEL_MARGIN,
height=2 * KERNEL_MARGIN,
)
search = subimage(
image=current_image,
origin=PixelCoordinate(x=p0.x - SEARCH_MARGIN, y=p0.y - SEARCH_MARGIN),
width=2 * SEARCH_MARGIN,
height=2 * SEARCH_MARGIN,
)
return peak_ratio(surface=zncc(kernel=kernel, search=search))
def golden_section_max(f, a, b, tol=1.0, max_iterations=50):
"""Golden-section search for a unimodal function's maximum on [a, b].
Returns (x, evaluations, sampled_x) -- the found maximizer, how many
times `f` was called, and every x actually sampled, in call order.
"""
ratio = (5**0.5 - 1) / 2
c = b - ratio * (b - a)
d = a + ratio * (b - a)
sampled = [c, d]
fc, fd = f(c), f(d)
for _ in range(max_iterations):
if abs(b - a) <= tol:
break
if fc > fd:
b, d, fd = d, c, fc
c = b - ratio * (b - a)
sampled.append(c)
fc = f(c)
else:
a, c, fc = c, d, fd
d = a + ratio * (b - a)
sampled.append(d)
fd = f(d)
return (a + b) / 2, len(sampled), sampled
bisection_x, bisection_evaluations, bisection_samples = golden_section_max(
_evaluate, 100, 200, tol=1.0
)
print(
f"Golden-section result: x={bisection_x:.2f}, evaluations={bisection_evaluations}"
)
print(f"Error vs. known x=150: {abs(bisection_x - 150):.3f}px")
print()
# Full comparison, all three approaches, same synthetic dataset.
baseline_points = generate(
origin=PixelCoordinate(x=100, y=Y), count_x=11, count_y=1, spacing_x=10, spacing_y=1
)
baseline_found = grid_locate(
reference_image=reference_image,
current_image=current_image,
reference_points=baseline_points,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
baseline_dy = [f.y - p.y for f, p in zip(baseline_found, baseline_points)]
baseline_jumps = [
abs(baseline_dy[i + 1] - baseline_dy[i]) for i in range(len(baseline_dy) - 1)
]
baseline_i = int(np.argmax(baseline_jumps))
baseline_x = (baseline_points[baseline_i].x + baseline_points[baseline_i + 1].x) / 2
sweep_found = discontinuity_locate(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=100, y=Y),
end=PixelCoordinate(x=200, y=Y),
samples=101,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
print("| Approach | Evaluations | Localization Error (px) | Notes |")
print("|---|---|---|---|")
print(
f"| Grid anomaly (baseline) | {len(baseline_points)} | "
f"{abs(baseline_x - 150):.1f} | resolution capped at {10}px grid spacing |"
)
print(
f"| Dense peak-ratio sweep (winner) | 101 | "
f"{abs(sweep_found.x - 150):.3f} | robust to bracket width, see below |"
)
print(
f"| Golden-section peak-ratio search | {bisection_evaluations} | "
f"{abs(bisection_x - 150):.3f} | fast here, breaks on real data -- see below |"
)
print()
dense = discontinuity_sweep(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=100, y=Y),
end=PixelCoordinate(x=200, y=Y),
samples=101,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
fig, ax = plt.subplots(figsize=(7.0, 5.0), constrained_layout=True)
ax.plot(
[p.x for p in dense.positions],
dense.peak_ratios,
color="tab:blue",
linewidth=1,
label="dense sweep (101 evaluations, for reference)",
)
ax.plot(
bisection_samples,
[_evaluate(x) for x in bisection_samples],
marker="x",
linestyle="none",
color="tab:red",
markersize=8,
label=f"golden-section samples ({bisection_evaluations} evaluations)",
)
ax.axvline(150, color="gray", linestyle="--", linewidth=1)
ax.set_xlabel("kernel window center x (pixels)")
ax.set_ylabel("peak_ratio")
ax.set_title("Golden-Section Search vs. the Dense Sweep")
ax.legend(loc="lower right")
fig.savefig("discontinuity_localization_bisection.png", dpi=300)
print("Saved: discontinuity_localization_bisection.png")
discontinuity_localization_experimental.py
"""Apply the winning dense peak-ratio sweep, and the rejected
golden-section variant, to the real crack image pair Experimental
Dislocation already introduced. No exact ground truth exists here --
Experimental Dislocation's own straddling-window example already
centered on `x=218, y=186` by inspection, so that value is a visual
sanity check, not a precise target.
"""
import matplotlib.pyplot as plt
from dictk.correlation import zncc
from dictk.discontinuity import locate as discontinuity_locate
from dictk.discontinuity import peak_ratio
from dictk.discontinuity import sweep as discontinuity_sweep
from dictk.image import PixelCoordinate, read, subimage
plt.rcParams.update({"font.family": "serif", "mathtext.fontset": "cm"})
KERNEL_MARGIN = 25
SEARCH_MARGIN = 65
Y = 186
reference_image = read(path="experimental_dislocation_reference.tiff")
current_image = read(path="experimental_dislocation_current.tiff")
def _evaluate(x):
p0 = PixelCoordinate(x=int(round(x)), y=Y)
kernel = subimage(
image=reference_image,
origin=PixelCoordinate(x=p0.x - KERNEL_MARGIN, y=p0.y - KERNEL_MARGIN),
width=2 * KERNEL_MARGIN,
height=2 * KERNEL_MARGIN,
)
search = subimage(
image=current_image,
origin=PixelCoordinate(x=p0.x - SEARCH_MARGIN, y=p0.y - SEARCH_MARGIN),
width=2 * SEARCH_MARGIN,
height=2 * SEARCH_MARGIN,
)
return peak_ratio(surface=zncc(kernel=kernel, search=search))
def golden_section_max(f, a, b, tol=1.0, max_iterations=50):
"""Golden-section search for a unimodal function's maximum on [a, b]."""
ratio = (5**0.5 - 1) / 2
c = b - ratio * (b - a)
d = a + ratio * (b - a)
evaluations = 2
fc, fd = f(c), f(d)
for _ in range(max_iterations):
if abs(b - a) <= tol:
break
if fc > fd:
b, d, fd = d, c, fc
c = b - ratio * (b - a)
fc = f(c)
else:
a, c, fc = c, d, fd
d = a + ratio * (b - a)
fd = f(d)
evaluations += 1
return (a + b) / 2, evaluations
# The winner: a dense sweep over a wide, uncommitted range. No bracket
# to get right -- it visits every candidate position and reports the
# tallest peak_ratio wherever it actually is.
wide_start, wide_end = 100, 350
dense = discontinuity_sweep(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=wide_start, y=Y),
end=PixelCoordinate(x=wide_end, y=Y),
samples=126,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
found = discontinuity_locate(
reference_image=reference_image,
current_image=current_image,
start=PixelCoordinate(x=wide_start, y=Y),
end=PixelCoordinate(x=wide_end, y=Y),
samples=126,
kernel_margin_width=KERNEL_MARGIN,
kernel_margin_height=KERNEL_MARGIN,
search_margin_width=SEARCH_MARGIN,
search_margin_height=SEARCH_MARGIN,
)
print(f"Dense sweep (wide range {wide_start}-{wide_end}, 126 evaluations):")
print(
f" located crack position: x={found.x:.2f} (Experimental Dislocation's own x=218)"
)
print()
# The rejected alternative, at three brackets: one already centered
# tightly on the crack, one moderately wide, one as wide as the dense
# sweep's own range -- the bracket a person without a rough answer
# already in hand would have to use.
for bracket in [(178, 258), (150, 300), (100, 350)]:
x, evaluations = golden_section_max(_evaluate, *bracket, tol=1.0)
print(f" golden-section, bracket {bracket}: x={x:.2f}, evaluations={evaluations}")
print()
print(
"The tight and moderate brackets land within 1px of the dense sweep's "
"own answer. The wide bracket -- the one that assumes no prior "
"knowledge of roughly where the crack is -- converges instead to a "
"smaller, secondary peak_ratio bump far from the real crack, "
"confidently and silently."
)
fig, ax = plt.subplots(figsize=(7.5, 5.0), constrained_layout=True)
ax.plot([p.x for p in dense.positions], dense.peak_ratios, color="tab:blue")
ax.axvline(
218,
color="gray",
linestyle="--",
linewidth=1,
label="x=218 (Experimental Dislocation)",
)
ax.axvline(
found.x,
color="tab:red",
linestyle=":",
linewidth=1.5,
label=f"located (x={found.x:.1f})",
)
ax.set_xlabel("kernel window center x (pixels)")
ax.set_ylabel("peak_ratio")
ax.set_title("Dense Peak-Ratio Sweep on Real Data")
ax.legend(loc="upper right")
fig.savefig("discontinuity_localization_experimental.png", dpi=300)
print("Saved: discontinuity_localization_experimental.png")