- Add rotation estimation via log-polar phase correlation (EstimateRotation,
rotateGray) — de-rotate before translation to avoid aliasing
- Track cumulative field rotation in Tracker, reject low-confidence frames
- Extract RTSP grabber into internal/rtspgrab module
- Daemon: pause odometry during slew (motion blur), auto-resume after settle
delay, add /pause and /resume HTTP endpoints, switch shooting mode before
opening camera, use ReqOpenCamera with rtsp_encode_type=1
- WebSocket heartbeat (ping/pong every 10s) to prevent idle disconnects
- FFmpeg low-latency flags (-fflags nobuffer, -probesize, -analyzeduration)
- Add SendRaw API for custom payloads, increase daemon HTTP timeout to 60s
💘 Generated with Crush
Assisted-by: Crush:/models/Qwen3.6-27B-uncensored-heretic-v2-Native-MTP-Preserved-Q4_K_M.gguf
302 lines
8.6 KiB
Go
302 lines
8.6 KiB
Go
// Package odometry estimates the pointing rotation of the telescope between two
|
||
// wide-angle frames using image-based phase correlation.
|
||
//
|
||
// It is deliberately NOT star/plate-solving based: phase correlation works on
|
||
// any textured scene (landscape, horizon, daytime sky, clouds, ...), which is
|
||
// exactly what the wide camera sees. For small telescope rotations the image
|
||
// content undergoes an almost pure translation (pan -> horizontal shift,
|
||
// tilt -> vertical shift), so the recovered image-plane shift maps directly to
|
||
// angular pan/tilt via the camera field of view.
|
||
package odometry
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"math"
|
||
"math/cmplx"
|
||
"os"
|
||
)
|
||
|
||
// Result holds the motion estimated between two frames.
|
||
type Result struct {
|
||
// PanPx / TiltPx are the image-plane translation (in resized pixels).
|
||
PanPx float64
|
||
TiltPx float64
|
||
// PanDeg / TiltDeg convert the pixel shift to degrees using the supplied FoV.
|
||
PanDeg float64
|
||
TiltDeg float64
|
||
// RotDeg is the estimated field rotation (degrees). Positive = scene rotated
|
||
// counter-clockwise. Dominant when the telescope points near zenith on an
|
||
// alt-az mount (azimuth rotation → field rotation, not translation).
|
||
RotDeg float64
|
||
// Confidence in [0,1].
|
||
Confidence float64
|
||
// Size is the FFT grid dimension actually used.
|
||
Size int
|
||
}
|
||
|
||
func (r Result) String() string {
|
||
return fmt.Sprintf(
|
||
"pan=%+.3fpx (%+.4f°) tilt=%+.3fpx (%+.4f°) conf=%.3f",
|
||
r.PanPx, r.PanDeg, r.TiltPx, r.TiltDeg, r.Confidence)
|
||
}
|
||
|
||
// DefaultSize is the default FFT grid (power of two). 256 is fast and accurate
|
||
// to ~0.1px; 512 improves sub-pixel resolution at ~4x the CPU cost.
|
||
const DefaultSize = 256
|
||
|
||
// DefaultFoVH/V are the documented DWARF Mini "wide" display FoV (degrees).
|
||
// NOTE: firmware-reported live FoV can differ widely (see analysis/DEVICE_MODELS.md),
|
||
// so these are only a starting point — calibrate with a known motor move.
|
||
const (
|
||
DefaultFoVH = 8.0
|
||
DefaultFoVV = 6.5
|
||
)
|
||
|
||
// Estimate computes the rotation between two decoded images.
|
||
//
|
||
// fovXDeg/fovYDeg are the camera horizontal/vertical field of view in degrees
|
||
// (they only scale the pixel shift into degrees; pass DefaultFoVH/V if unsure).
|
||
// size is the square FFT grid (must be a power of two, e.g. 256 or 512).
|
||
func Estimate(img1, img2 image.Image, fovXDeg, fovYDeg float64, size int) (Result, error) {
|
||
if img1 == nil || img2 == nil {
|
||
return Result{}, fmt.Errorf("odometry: nil image")
|
||
}
|
||
if !isPow2(size) {
|
||
return Result{}, fmt.Errorf("odometry: size %d is not a power of two", size)
|
||
}
|
||
if size < 8 {
|
||
return Result{}, fmt.Errorf("odometry: size %d too small", size)
|
||
}
|
||
if fovXDeg <= 0 || fovYDeg <= 0 {
|
||
return Result{}, fmt.Errorf("odometry: fov must be positive (got %.3f x %.3f)", fovXDeg, fovYDeg)
|
||
}
|
||
|
||
g1 := toGrayDownscaled(img1, size, size)
|
||
g2 := toGrayDownscaled(img2, size, size)
|
||
|
||
// Pass 1: estimate rotation (log-polar phase correlation).
|
||
rotDeg, confRot := EstimateRotation(g1, g2, size, size)
|
||
|
||
// Pass 2: de-rotate g2, then estimate translation on de-rotated images.
|
||
// This removes the rotation-induced aliasing that corrupts translation.
|
||
var dx, dy, confTrans float64
|
||
if absf(rotDeg) > 0.3 {
|
||
g2Derot := rotateGray(g2, size, rotDeg)
|
||
dx, dy, confTrans = phaseCorrelation(g1, g2Derot, size, size)
|
||
} else {
|
||
dx, dy, confTrans = phaseCorrelation(g1, g2, size, size)
|
||
}
|
||
|
||
// Use the higher-confidence metric as the overall confidence.
|
||
conf := confTrans
|
||
if confRot > conf {
|
||
conf = confRot
|
||
}
|
||
|
||
r := Result{
|
||
PanPx: dx,
|
||
TiltPx: dy,
|
||
PanDeg: dx * fovXDeg / float64(size),
|
||
TiltDeg: dy * fovYDeg / float64(size),
|
||
RotDeg: rotDeg,
|
||
Confidence: conf,
|
||
Size: size,
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// EstimateFiles decodes two image files and runs Estimate.
|
||
func EstimateFiles(path1, path2 string, fovXDeg, fovYDeg float64, size int) (Result, error) {
|
||
img1, err := decodeImage(path1)
|
||
if err != nil {
|
||
return Result{}, fmt.Errorf("decode %s: %w", path1, err)
|
||
}
|
||
img2, err := decodeImage(path2)
|
||
if err != nil {
|
||
return Result{}, fmt.Errorf("decode %s: %w", path2, err)
|
||
}
|
||
return Estimate(img1, img2, fovXDeg, fovYDeg, size)
|
||
}
|
||
|
||
func decodeImage(path string) (image.Image, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
img, _, err := image.Decode(f)
|
||
return img, err
|
||
}
|
||
|
||
// phaseCorrelation returns the integer+subpixel shift (dx,dy) of the scene from
|
||
// g1 to g2, plus a confidence metric. The shift is expressed so that
|
||
// g2(x,y) ≈ g1(x-dx, y-dy), i.e. positive dx => content moved to the right.
|
||
func phaseCorrelation(g1, g2 [][]float64, rows, cols int) (dx, dy, conf float64) {
|
||
a := windowedComplex(g1, rows, cols)
|
||
b := windowedComplex(g2, rows, cols)
|
||
|
||
fft2(a, false)
|
||
fft2(b, false)
|
||
|
||
// Normalized cross-power spectrum. conj(A)*B peaks at the shift that maps
|
||
// image A (g1) onto image B (g2): positive dx = content moved right.
|
||
r := make([][]complex128, rows)
|
||
for i := 0; i < rows; i++ {
|
||
r[i] = make([]complex128, cols)
|
||
for j := 0; j < cols; j++ {
|
||
cross := cmplx.Conj(a[i][j]) * b[i][j]
|
||
mag := cmplx.Abs(cross)
|
||
if mag > 1e-12 {
|
||
r[i][j] = cross / complex(mag, 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
fft2(r, true) // inverse transform -> correlation surface
|
||
|
||
// Peak of the real part.
|
||
px, py, peak := 0, 0, math.Inf(-1)
|
||
mean := 0.0
|
||
for i := 0; i < rows; i++ {
|
||
for j := 0; j < cols; j++ {
|
||
v := real(r[i][j])
|
||
mean += v
|
||
if v > peak {
|
||
peak = v
|
||
px, py = j, i
|
||
}
|
||
}
|
||
}
|
||
mean /= float64(rows * cols)
|
||
|
||
// Wrap-around to signed shift.
|
||
dx = float64(signedShift(px, cols))
|
||
dy = float64(signedShift(py, rows))
|
||
|
||
// Sub-pixel refinement via 3-point parabolic interpolation along each axis.
|
||
dx += parabola(at2(r, py, px-1, cols), peak, at2(r, py, px+1, cols))
|
||
dy += parabola(at2(r, py-1, px, rows), peak, at2(r, py+1, px, rows))
|
||
|
||
// Confidence: peak height relative to the surface mean (clamped to [0,1]).
|
||
denom := peak - mean
|
||
if denom > 1 {
|
||
denom = 1
|
||
}
|
||
if denom < 0 {
|
||
denom = 0
|
||
}
|
||
conf = denom
|
||
return dx, dy, conf
|
||
}
|
||
|
||
// windowedComplex converts a grayscale matrix to complex, subtracts the DC
|
||
// component and applies a 2-D Hann window to reduce spectral leakage at the
|
||
// image borders.
|
||
func windowedComplex(g [][]float64, rows, cols int) [][]complex128 {
|
||
// Mean (DC) removal.
|
||
dc := 0.0
|
||
for i := 0; i < rows; i++ {
|
||
for j := 0; j < cols; j++ {
|
||
dc += g[i][j]
|
||
}
|
||
}
|
||
dc /= float64(rows * cols)
|
||
|
||
m := make([][]complex128, rows)
|
||
for i := 0; i < rows; i++ {
|
||
m[i] = make([]complex128, cols)
|
||
// Hann along rows.
|
||
wy := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(rows-1)))
|
||
for j := 0; j < cols; j++ {
|
||
wx := 0.5 * (1 - math.Cos(2*math.Pi*float64(j)/float64(cols-1)))
|
||
m[i][j] = complex((g[i][j]-dc)*wx*wy, 0)
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// at2 reads r[y][x] with horizontal (column) wrap-around.
|
||
func at2(r [][]complex128, y, x, cols int) float64 {
|
||
x = ((x % cols) + cols) % cols
|
||
if y < 0 {
|
||
y += len(r)
|
||
}
|
||
if y >= len(r) {
|
||
y -= len(r)
|
||
}
|
||
return real(r[y][x])
|
||
}
|
||
|
||
// parabola returns the sub-pixel offset of the true peak given the values at
|
||
// (left, center, right). Standard 3-point parabolic interpolation.
|
||
func parabola(left, center, right float64) float64 {
|
||
denom := left - 2*center + right
|
||
if math.Abs(denom) < 1e-12 {
|
||
return 0
|
||
}
|
||
return 0.5 * (left - right) / denom
|
||
}
|
||
|
||
// signedShift converts a correlation peak index in [0,n) to a signed shift in
|
||
// [-n/2, n/2).
|
||
func signedShift(idx, n int) int {
|
||
if idx >= n/2 {
|
||
return idx - n
|
||
}
|
||
return idx
|
||
}
|
||
|
||
func isPow2(n int) bool {
|
||
return n > 0 && (n&(n-1)) == 0
|
||
}
|
||
|
||
// toGrayDownscaled converts an image to a rows×cols grayscale matrix using
|
||
// area-averaging (box filter) downscaling. This preserves the full field of
|
||
// view so pixel shifts scale directly to the original FoV.
|
||
func toGrayDownscaled(img image.Image, rows, cols int) [][]float64 {
|
||
b := img.Bounds()
|
||
srcW := b.Dx()
|
||
srcH := b.Dy()
|
||
|
||
out := make([][]float64, rows)
|
||
for i := range out {
|
||
out[i] = make([]float64, cols)
|
||
}
|
||
|
||
xRatio := float64(srcW) / float64(cols)
|
||
yRatio := float64(srcH) / float64(rows)
|
||
|
||
for oy := 0; oy < rows; oy++ {
|
||
y0 := b.Min.Y + int(math.Floor(float64(oy)*yRatio))
|
||
y1 := b.Min.Y + int(math.Floor(float64(oy+1)*yRatio))
|
||
if y1 <= y0 {
|
||
y1 = y0 + 1
|
||
}
|
||
for ox := 0; ox < cols; ox++ {
|
||
x0 := b.Min.X + int(math.Floor(float64(ox)*xRatio))
|
||
x1 := b.Min.X + int(math.Floor(float64(ox+1)*xRatio))
|
||
if x1 <= x0 {
|
||
x1 = x0 + 1
|
||
}
|
||
sum := 0.0
|
||
cnt := 0
|
||
for y := y0; y < y1 && y < b.Max.Y; y++ {
|
||
for x := x0; x < x1 && x < b.Max.X; x++ {
|
||
r, g, bl, _ := img.At(x, y).RGBA()
|
||
// 16-bit RGBA -> 8-bit luminance (Rec. 601).
|
||
lum := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(bl)
|
||
sum += lum / 257.0
|
||
cnt++
|
||
}
|
||
}
|
||
if cnt > 0 {
|
||
out[oy][ox] = sum / float64(cnt)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|