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_margin | Matched |
|---|---|---|
| 0.5 | 30 | 12/12 |
| 1 | 30 | 12/12 |
| 1.5 | 30 | 11/12 |
| 2 | 30 | 9/12 |
| 3 | 30 | 6/12 |
| 5 | 34 | 1/12 |
| 8 | 46 | 0/12 |
| 15 | 76 | 0/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.5 | 12/12 |
| 1 | 12/12 |
| 1.5 | 11/12 |
| 2 | 9/12 |
| 3 | 7/12 |
| 5 | 0/12 |
| 8 | 0/12 |
| 15 | 0/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 ZNCC | Min ZNCC |
|---|---|---|
| 0 | 1.000 | 1.000 |
| 0.5 | 0.968 | 0.939 |
| 1 | 0.965 | 0.948 |
| 1.5 | 0.940 | 0.879 |
| 2 | 0.909 | 0.861 |
| 3 | 0.860 | 0.800 |
| 5 | 0.736 | 0.669 |
| 8 | 0.545 | 0.408 |
| 15 | 0.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.