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

Pure Rotation

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

The First Sweep

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

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

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

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

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

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

Confirming the Content-Rotation Hypothesis

Removing the Search Itself

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

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

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

Measuring Content Similarity Directly

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

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

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

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