Add camera streaming guide with all access methods
Comprehensive documentation for accessing the DWARF camera streams:
- RTSP URLs and channels (ch0=tele, ch1=wide)
- The critical prerequisite: camera must be opened via WebSocket first
- ffmpeg commands for frame capture, timelapse, and video recording
- mpv and VLC usage with TCP transport
- Python/OpenCV integration example
- Troubleshooting common issues (black image, connection refused, VLC delay)
- Comparison of RTSP vs MJPEG modes across device models
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
@ -11,6 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/antitbone/dwarfctl/internal/api"
|
||||
"github.com/antitbone/dwarfctl/internal/daemon"
|
||||
"github.com/antitbone/dwarfctl/internal/odometry"
|
||||
pb "github.com/antitbone/dwarfctl/proto"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@ -20,6 +25,7 @@ var (
|
||||
flagIP string
|
||||
flagClientID string
|
||||
flagDebug bool
|
||||
flagDaemon string // daemon addr, e.g. "127.0.0.1:7777"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -31,8 +37,10 @@ func main() {
|
||||
rootCmd.PersistentFlags().StringVarP(&flagIP, "ip", "i", "", "telescope IP address (required)")
|
||||
rootCmd.PersistentFlags().StringVar(&flagClientID, "client-id", "dwarfctl", "WebSocket client_id")
|
||||
rootCmd.PersistentFlags().BoolVarP(&flagDebug, "debug", "d", false, "verbose WebSocket traffic log")
|
||||
rootCmd.PersistentFlags().StringVar(&flagDaemon, "daemon", "", "daemon addr (e.g. 127.0.0.1:7777)")
|
||||
|
||||
rootCmd.AddCommand(
|
||||
cmdServe(),
|
||||
cmdHealth(),
|
||||
cmdState(),
|
||||
cmdCamera(),
|
||||
@ -46,9 +54,15 @@ func main() {
|
||||
cmdInteractive(),
|
||||
cmdPano(),
|
||||
cmdPreview(),
|
||||
cmdOrient(),
|
||||
)
|
||||
|
||||
rootCmd.MarkPersistentFlagRequired("ip")
|
||||
// --ip is required only when not using --daemon
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
|
||||
if flagDaemon == "" && flagIP == "" && cmd.Name() != "serve" && cmd.Name() != "help" && cmd.Name() != "completion" {
|
||||
die("--ip is required (or use --daemon to connect to a running daemon)")
|
||||
}
|
||||
}
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -687,8 +701,8 @@ func cmdPreview() *cobra.Command {
|
||||
out = args[0]
|
||||
}
|
||||
scope, cleanup := dial()
|
||||
defer cleanup()
|
||||
must(scope.OpenCamera(cam))
|
||||
cleanup()
|
||||
time.Sleep(2 * time.Second)
|
||||
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
|
||||
ffmpegCmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
||||
@ -817,6 +831,222 @@ func cmdInteractive() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// --- orient (visual odometry) ---
|
||||
|
||||
func cmdOrient() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "orient",
|
||||
Short: "Image-based orientation (visual odometry via wide-angle frames)",
|
||||
Long: "Estimate telescope pointing rotation by comparing wide-angle frames.\n" +
|
||||
"Uses FFT phase correlation — works on any scene (sky, horizon, clouds)\n" +
|
||||
"without star identification or plate solving.\n\n" +
|
||||
"Designed for a static alt-az mount tracking airplanes (day) or satellites (night).",
|
||||
}
|
||||
c.AddCommand(
|
||||
&cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get current orientation from the daemon (requires 'serve' running)",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
dc := daemonClient()
|
||||
if dc == nil {
|
||||
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
|
||||
}
|
||||
o, err := dc.Orientation()
|
||||
must(err)
|
||||
fmt.Println(o)
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Reset the odometry tracker (requires daemon)",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
dc := daemonClient()
|
||||
if dc == nil {
|
||||
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
|
||||
}
|
||||
must(dc.Reset())
|
||||
fmt.Println("orientation reset")
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "grab [output.jpg]",
|
||||
Short: "Grab a frame via the daemon (camera stays open)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
dc := daemonClient()
|
||||
if dc == nil {
|
||||
die("no daemon: use --daemon ADDR or start 'dwarfctl serve'")
|
||||
}
|
||||
out := ""
|
||||
if len(args) > 0 {
|
||||
out = args[0]
|
||||
}
|
||||
path, err := dc.Grab(out)
|
||||
must(err)
|
||||
fmt.Printf("frame saved: %s\n", path)
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "compare <img1.jpg> <img2.jpg>",
|
||||
Short: "Measure rotation between two image files",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fovH, _ := cmd.Flags().GetFloat64("fov-h")
|
||||
fovV, _ := cmd.Flags().GetFloat64("fov-v")
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
r, err := odometry.EstimateFiles(args[0], args[1], fovH, fovV, size)
|
||||
must(err)
|
||||
fmt.Printf("Scene shift (img1 -> img2):\n %s\n", r)
|
||||
fmt.Printf(" grid=%dx%d fov=%.2f°x%.2f°\n", r.Size, r.Size, fovH, fovV)
|
||||
if r.Confidence < 0.05 {
|
||||
fmt.Println(" ⚠ low confidence (frames too similar or motion too large)")
|
||||
}
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "live",
|
||||
Short: "Continuously track pointing from RTSP frames (Ctrl-C to stop)",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
cam := mustCam(cmd.Flag("cam").Value.String())
|
||||
fovH, _ := cmd.Flags().GetFloat64("fov-h")
|
||||
fovV, _ := cmd.Flags().GetFloat64("fov-v")
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
interval, _ := cmd.Flags().GetDuration("interval")
|
||||
|
||||
ch := "ch0"
|
||||
if cam == api.CameraWide {
|
||||
ch = "ch1"
|
||||
}
|
||||
|
||||
scope, cleanup := dial()
|
||||
defer cleanup()
|
||||
must(scope.OpenCamera(cam))
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
url := fmt.Sprintf("rtsp://%s:554/%s/stream0", flagIP, ch)
|
||||
tracker := odometry.NewTracker(fovH, fovV, size)
|
||||
tmpA := "/tmp/dwarf_orient_a.jpg"
|
||||
tmpB := "/tmp/dwarf_orient_b.jpg"
|
||||
useA := true
|
||||
|
||||
fmt.Printf("Live visual odometry on %s (fov %.1f°x%.1f°, grid %d, every %s)\n",
|
||||
url, fovH, fovV, size, interval)
|
||||
fmt.Println("Ctrl-C to stop.")
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
grab := func(out string) error {
|
||||
return exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
||||
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out).Run()
|
||||
}
|
||||
|
||||
// Prime with first frame.
|
||||
first := "/tmp/dwarf_orient_prime.jpg"
|
||||
if err := grab(first); err != nil {
|
||||
die("ffmpeg grab: %v (is ffmpeg installed?)", err)
|
||||
}
|
||||
img, err := decodeJPEG(first)
|
||||
if err != nil {
|
||||
die("decode: %v", err)
|
||||
}
|
||||
o, _ := tracker.Update(img)
|
||||
fmt.Printf("[prime] %s\n", o)
|
||||
|
||||
for range ticker.C {
|
||||
out := tmpA
|
||||
if !useA {
|
||||
out = tmpB
|
||||
}
|
||||
useA = !useA
|
||||
if err := grab(out); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " grab error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
img, err := decodeJPEG(out)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " decode error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
o, err := tracker.Update(img)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " odometry error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
ts := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] %s\n", ts, o)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
for _, sub := range c.Commands() {
|
||||
sub.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal field of view (degrees)")
|
||||
sub.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical field of view (degrees)")
|
||||
sub.Flags().Int("size", odometry.DefaultSize, "FFT grid size (power of 2: 128/256/512)")
|
||||
if sub.Name() == "live" {
|
||||
sub.Flags().String("cam", "wide", "camera: tele|wide")
|
||||
sub.Flags().Duration("interval", 3*time.Second, "time between frames")
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// decodeJPEG reads a JPEG/PNG file into an image.Image.
|
||||
func decodeJPEG(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
|
||||
}
|
||||
|
||||
// --- serve (daemon) ---
|
||||
|
||||
func cmdServe() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start persistent telescope daemon (keeps WS + camera + odometry alive)",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
addr, _ := cmd.Flags().GetString("addr")
|
||||
cam, _ := cmd.Flags().GetString("cam")
|
||||
fovH, _ := cmd.Flags().GetFloat64("fov-h")
|
||||
fovV, _ := cmd.Flags().GetFloat64("fov-v")
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
interval, _ := cmd.Flags().GetDuration("interval")
|
||||
|
||||
srv := daemon.New(daemon.Config{
|
||||
IP: flagIP,
|
||||
Addr: addr,
|
||||
FoVH: fovH,
|
||||
FoVV: fovV,
|
||||
GridSize: size,
|
||||
Camera: cam,
|
||||
Interval: interval,
|
||||
Debug: flagDebug,
|
||||
})
|
||||
if err := srv.Run(); err != nil {
|
||||
die("daemon: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
c.Flags().String("addr", daemon.DefaultAddr, "HTTP listen address")
|
||||
c.Flags().String("cam", "wide", "camera to open: tele|wide")
|
||||
c.Flags().Float64("fov-h", odometry.DefaultFoVH, "horizontal FoV (degrees)")
|
||||
c.Flags().Float64("fov-v", odometry.DefaultFoVV, "vertical FoV (degrees)")
|
||||
c.Flags().Int("size", odometry.DefaultSize, "FFT grid size")
|
||||
c.Flags().Duration("interval", 5*time.Second, "odometry frame interval")
|
||||
return c
|
||||
}
|
||||
|
||||
// daemonClient returns a daemon client if --daemon is set, else nil.
|
||||
func daemonClient() *daemon.Client {
|
||||
if flagDaemon == "" {
|
||||
return nil
|
||||
}
|
||||
return daemon.NewClient(flagDaemon)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func flagCam(cmd *cobra.Command) string {
|
||||
|
||||
71
dwarfctl/cmd/rtsp-test/main.go
Normal file
71
dwarfctl/cmd/rtsp-test/main.go
Normal file
@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/antitbone/dwarfctl/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ip := "192.168.88.1"
|
||||
if len(os.Args) > 1 {
|
||||
ip = os.Args[1]
|
||||
}
|
||||
|
||||
scope := api.New("rtsp-test")
|
||||
scope.SetDebug(true)
|
||||
fmt.Println("Connecting to", ip)
|
||||
if err := scope.Connect(ip); err != nil {
|
||||
die("connect: %v", err)
|
||||
}
|
||||
defer scope.Close()
|
||||
|
||||
fmt.Println("Opening wide camera...")
|
||||
if err := scope.OpenCamera(api.CameraWide); err != nil {
|
||||
die("open camera: %v", err)
|
||||
}
|
||||
fmt.Println("Wide camera opened. Waiting 3s for stream to initialize...")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
url := fmt.Sprintf("rtsp://%s:554/ch1/stream0", ip)
|
||||
out := "/tmp/rtsp_test_frame.jpg"
|
||||
fmt.Println("Grabbing frame via ffmpeg...")
|
||||
cmd := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
||||
"-i", url, "-frames:v", "1", "-q:v", "2", "-y", out)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
die("ffmpeg: %v", err)
|
||||
}
|
||||
|
||||
info, _ := os.Stat(out)
|
||||
if info != nil {
|
||||
fmt.Printf("SUCCESS: frame saved (%d bytes)\n", info.Size())
|
||||
}
|
||||
|
||||
// Also try tele
|
||||
fmt.Println("\nOpening tele camera...")
|
||||
if err := scope.OpenCamera(api.CameraTele); err != nil {
|
||||
die("open tele: %v", err)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
url0 := fmt.Sprintf("rtsp://%s:554/ch0/stream0", ip)
|
||||
out0 := "/tmp/rtsp_test_tele.jpg"
|
||||
fmt.Println("Grabbing tele frame...")
|
||||
cmd0 := exec.Command("ffmpeg", "-rtsp_transport", "tcp",
|
||||
"-i", url0, "-frames:v", "1", "-q:v", "2", "-y", out0)
|
||||
cmd0.Stdout = os.Stdout
|
||||
cmd0.Stderr = os.Stderr
|
||||
cmd0.Run()
|
||||
if i, _ := os.Stat(out0); i != nil {
|
||||
fmt.Printf("SUCCESS: tele frame saved (%d bytes)\n", i.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user