package odometry import ( "fmt" "image" "sync" ) // minConfidence is the threshold below which a frame is rejected (not // accumulated into the orientation). Motion-blurred frames or frames with // too little texture produce confidence near 0 and corrupt the estimate. const minConfidence = 0.15 // Tracker is a stateful visual odometry integrator. It accumulates the // frame-to-frame rotation measured by Estimate into a cumulative pointing // orientation (pan/tilt) relative to wherever the first frame was captured. // // This is what lets a static, un-aligned alt-az telescope know "where it is // pointing now" purely from the wide-angle camera — no absolute encoders, no // plate solving, no polar alignment required. Every time the motors slew (to // track an airplane / satellite), the scene shifts; the tracker measures that // shift and integrates it into the running orientation. // // Convention: PanDeg positive = pointing further right (eastward for a level // alt-az mount); TiltDeg positive = pointing further up. The sign follows the // MOTOR motion, i.e. it is the negative of the raw scene shift, so the values // describe where the optical axis now points. type Tracker struct { mu sync.Mutex fovXDeg, fovYDeg float64 size int // Cumulative orientation in degrees, relative to the first frame. panDeg float64 tiltDeg float64 rotDeg float64 // cumulative field rotation // Number of frames integrated. frames int // Running confidence (exponential moving average of per-frame confidence). confidence float64 // Last grayscale frame, kept so the next Update only needs one new image. prev [][]float64 // Set once the first frame establishes the reference. primed bool } // NewTracker creates a visual-odometry tracker. // fovXDeg/fovYDeg: wide camera horizontal/vertical field of view in degrees. // size: FFT grid side (power of two, e.g. 256 or 512). func NewTracker(fovXDeg, fovYDeg float64, size int) *Tracker { if size <= 0 { size = DefaultSize } if fovXDeg <= 0 { fovXDeg = DefaultFoVH } if fovYDeg <= 0 { fovYDeg = DefaultFoVV } return &Tracker{fovXDeg: fovXDeg, fovYDeg: fovYDeg, size: size} } // Orientation is a snapshot of the tracker's current state. type Orientation struct { PanDeg float64 TiltDeg float64 RotDeg float64 // cumulative field rotation (degrees) Frames int Confidence float64 Primed bool } func (o Orientation) String() string { state := "priming" if o.Primed { state = "tracking" } return fmt.Sprintf("pan=%+.4f° tilt=%+.4f° rot=%+.4f° conf=%.3f frames=%d [%s]", o.PanDeg, o.TiltDeg, o.RotDeg, o.Confidence, o.Frames, state) } // Update feeds the tracker a new frame and returns the updated orientation. // The very first call establishes the reference origin (orientation 0,0) and // performs no measurement. Subsequent calls measure the rotation since the // previous frame and integrate it. func (t *Tracker) Update(img image.Image) (Orientation, error) { if img == nil { return Orientation{}, fmt.Errorf("odometry: nil image") } t.mu.Lock() defer t.mu.Unlock() cur := toGrayDownscaled(img, t.size, t.size) if !t.primed { t.prev = cur t.primed = true return t.snapshot(), nil } dx, dy, confTrans := phaseCorrelation(t.prev, cur, t.size, t.size) rotDeg, confRot := EstimateRotation(t.prev, cur, t.size, t.size) // Use the higher confidence for the EMA. conf := confTrans if confRot > conf { conf = confRot } // Reject low-confidence frames (motion blur, bad texture, etc.). if conf < minConfidence { t.prev = cur // still update the reference frame return t.snapshot(), nil } // De-rotate for accurate translation if rotation is significant. if absf(rotDeg) > 0.3 { curDerot := rotateGray(cur, t.size, rotDeg) dx, dy, confTrans = phaseCorrelation(t.prev, curDerot, t.size, t.size) } // Convert pixel shift to degrees. panStep := -dx * t.fovXDeg / float64(t.size) tiltStep := -dy * t.fovYDeg / float64(t.size) t.panDeg += panStep t.tiltDeg += tiltStep t.rotDeg += rotDeg t.frames++ // Exponential moving average of the confidence. if t.frames == 1 { t.confidence = conf } else { const alpha = 0.2 t.confidence = alpha*conf + (1-alpha)*t.confidence } t.prev = cur return t.snapshot(), nil } // Orientation returns the current cumulative orientation without adding a frame. func (t *Tracker) Orientation() Orientation { t.mu.Lock() defer t.mu.Unlock() return t.snapshot() } // Reset zeros the accumulated orientation and makes the next Update the new // reference origin. func (t *Tracker) Reset() { t.mu.Lock() defer t.mu.Unlock() t.panDeg = 0 t.tiltDeg = 0 t.rotDeg = 0 t.frames = 0 t.confidence = 0 t.prev = nil t.primed = false } // SetFoV overrides the field of view (degrees). Call between frames; the next // Update uses the new values. func (t *Tracker) SetFoV(fovXDeg, fovYDeg float64) { t.mu.Lock() defer t.mu.Unlock() if fovXDeg > 0 { t.fovXDeg = fovXDeg } if fovYDeg > 0 { t.fovYDeg = fovYDeg } } func (t *Tracker) snapshot() Orientation { return Orientation{ PanDeg: t.panDeg, TiltDeg: t.tiltDeg, RotDeg: t.rotDeg, Frames: t.frames, Confidence: t.confidence, Primed: t.primed, } }