package odometry import ( "image" "math" "testing" ) // makeTextured makes a deterministic, highly-textured synthetic image of the // given size. It mixes multiple sine gratings at different orientations so that // phase correlation has rich frequency content to lock onto. func makeTextured(w, h int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { // Sum of 3 gratings — pseudo-random but deterministic texture. v := 0.0 v += 0.5 + 0.5*math.Sin(float64(x)*0.13+float64(y)*0.07) v += 0.5 + 0.5*math.Sin(float64(x)*0.05-float64(y)*0.11+1.3) v += 0.5 + 0.5*math.Sin(float64(x)*0.21+2.1) v /= 3.0 b := uint8(v * 255) // Add some discrete structure (a grid of bright dots) too. if x%32 < 3 && y%32 < 3 { b = 255 } off := img.PixOffset(x, y) img.Pix[off+0] = b img.Pix[off+1] = b img.Pix[off+2] = b img.Pix[off+3] = 255 } } return img } // shiftImage returns a copy of src translated by (dx,dy) pixels, with wrapped // (toroidal) borders — this models a pure translation that phase correlation // can recover exactly (no edge artifacts). func shiftImage(src *image.RGBA, dx, dy int) *image.RGBA { w, h := src.Bounds().Dx(), src.Bounds().Dy() dst := image.NewRGBA(src.Bounds()) for y := 0; y < h; y++ { for x := 0; x < w; x++ { sx := ((x-dx)%w + w) % w sy := ((y-dy)%h + h) % h so := src.PixOffset(sx, sy) do := dst.PixOffset(x, y) dst.Pix[do+0] = src.Pix[so+0] dst.Pix[do+1] = src.Pix[so+1] dst.Pix[do+2] = src.Pix[so+2] dst.Pix[do+3] = 255 } } return dst } func TestFFT_RoundTrip(t *testing.T) { // FFT followed by inverse FFT must recover the original signal. const n = 64 a := make([]complex128, n) for i := range a { a[i] = complex(math.Sin(float64(i)*0.3)+0.5*math.Cos(float64(i)*0.7), 0) } orig := make([]complex128, n) copy(orig, a) fft(a, false) fft(a, true) for i := range a { if math.Abs(real(a[i])-real(orig[i])) > 1e-9 || math.Abs(imag(a[i])-imag(orig[i])) > 1e-9 { t.Fatalf("FFT round-trip failed at %d: got %v want %v", i, a[i], orig[i]) } } } func TestIsPow2(t *testing.T) { cases := map[int]bool{0: false, 1: true, 2: true, 3: false, 4: true, 16: true, 255: false, 256: true, 257: false} for n, want := range cases { if got := isPow2(n); got != want { t.Errorf("isPow2(%d)=%v want %v", n, got, want) } } } func TestEstimate_KnownShift(t *testing.T) { const size = 128 const fovX, fovY = 10.0, 8.0 base := makeTextured(size, size) cases := []struct{ dx, dy int }{ {5, 0}, {0, 4}, {-6, 3}, {10, -7}, {0, 0}, // identical => zero shift } for _, c := range cases { shifted := shiftImage(base, c.dx, c.dy) r, err := Estimate(base, shifted, fovX, fovY, size) if err != nil { t.Fatalf("Estimate(dx=%d,dy=%d): %v", c.dx, c.dy, err) } // We expect the scene shift to equal the translation we applied. Positive // PanPx = content moved right. shifting the image by +dx moves content // to the right by dx pixels, so PanPx should be ≈ +dx. tolPx := 1.5 // sub-pixel estimator can be off by ~1px on a 128 grid. if math.Abs(r.PanPx-float64(c.dx)) > tolPx { t.Errorf("dx=%d dy=%d: PanPx=%.3f want ≈%d (±%.1f)", c.dx, c.dy, r.PanPx, c.dx, tolPx) } if math.Abs(r.TiltPx-float64(c.dy)) > tolPx { t.Errorf("dx=%d dy=%d: TiltPx=%.3f want ≈%d (±%.1f)", c.dx, c.dy, r.TiltPx, c.dy, tolPx) } // Degree conversion: px * fov / size. wantPanDeg := float64(c.dx) * fovX / size if math.Abs(r.PanDeg-wantPanDeg) > (tolPx*fovX/size) { t.Errorf("dx=%d: PanDeg=%.4f want ≈%.4f", c.dx, r.PanDeg, wantPanDeg) } } } func TestEstimate_IdenticalImages_HighConfidence(t *testing.T) { const size = 128 a := makeTextured(size, size) r, err := Estimate(a, a, 10, 8, size) if err != nil { t.Fatal(err) } if math.Abs(r.PanPx) > 0.5 || math.Abs(r.TiltPx) > 0.5 { t.Errorf("identical images should give ~0 shift, got pan=%.2f tilt=%.2f", r.PanPx, r.TiltPx) } } func TestEstimate_RejectsBadSize(t *testing.T) { img := makeTextured(16, 16) if _, err := Estimate(img, img, 10, 8, 100); err == nil { // 100 not pow2 t.Error("expected error for non-power-of-two size") } if _, err := Estimate(img, img, 0, 0, 64); err == nil { // zero fov t.Error("expected error for zero fov") } } func TestTracker_IntegratesRotation(t *testing.T) { const size = 128 const fovX, fovY = 10.0, 8.0 tr := NewTracker(fovX, fovY, size) base := makeTextured(size, size) // Frame 0: primes the reference; orientation should be zero. if _, err := tr.Update(base); err != nil { t.Fatal(err) } o0 := tr.Orientation() if o0.PanDeg != 0 || o0.TiltDeg != 0 || !o0.Primed { t.Fatalf("after prime: %+v", o0) } // Frame 1: shift right by 5 px (content moves right). The telescope is // static, so the motor-equivalent pan is the NEGATIVE of the scene shift. // Scene +5px right => tracker pan = -5*fov/size. f1 := shiftImage(base, 5, 0) if _, err := tr.Update(f1); err != nil { t.Fatal(err) } o1 := tr.Orientation() wantPan := -5.0 * fovX / size if math.Abs(o1.PanDeg-wantPan) > 0.3 { t.Errorf("frame1 pan=%.4f want≈%.4f", o1.PanDeg, wantPan) } // Frame 2: shift another 4 px right (cumulative content shift 9 px). f2 := shiftImage(base, 9, 0) if _, err := tr.Update(f2); err != nil { t.Fatal(err) } o2 := tr.Orientation() wantPan2 := -9.0 * fovX / size if math.Abs(o2.PanDeg-wantPan2) > 0.5 { t.Errorf("frame2 cumulative pan=%.4f want≈%.4f", o2.PanDeg, wantPan2) } if o2.Frames != 2 { t.Errorf("frames=%d want 2", o2.Frames) } } func TestTracker_Reset(t *testing.T) { tr := NewTracker(10, 8, 64) base := makeTextured(64, 64) tr.Update(base) tr.Update(shiftImage(base, 4, 0)) if tr.Orientation().PanDeg == 0 { t.Fatal("expected nonzero pan before reset") } tr.Reset() o := tr.Orientation() if o.PanDeg != 0 || o.Primed || o.Frames != 0 { t.Errorf("after reset: %+v", o) } } func TestPhaseCorrelation_NoTexture(t *testing.T) { // Flat images: no signal. Should not panic and should return low confidence. const n = 64 flat := make([][]float64, n) for i := range flat { flat[i] = make([]float64, n) } dx, dy, conf := phaseCorrelation(flat, flat, n, n) if conf > 0.01 { t.Errorf("flat images confidence=%.4f, expected ~0", conf) } if dx != 0 || dy != 0 { t.Errorf("flat images shift=(%.1f,%.1f) want (0,0)", dx, dy) } } func TestSignedShift(t *testing.T) { cases := map[int]int{0: 0, 1: 1, 3: 3, 4: -4, 5: -3, 7: -1, 8: 0} for in, want := range cases { if got := signedShift(in, 8); got != want { t.Errorf("signedShift(%d,8)=%d want %d", in, got, want) } } } // Ensure float comparison helpers behave sensibly on this toolchain. func TestFloatHelpers(t *testing.T) { if math.Abs(-1.5) != 1.5 { t.Fatal("math.Abs broken") } }