package main import ( "encoding/json" "fmt" "io" "log" "net/http" "sync" "time" "gocv.io/x/gocv" ) type CloudResult struct { URL string `json:"url"` CloudCover float64 `json:"cloud_cover"` LastUpdated string `json:"last_updated"` Error string `json:"error,omitempty"` } var ( mu sync.Mutex cache = make(map[string]CloudResult) refreshFreq = 30 * time.Second // refresh interval ) func estimateCloudCover(url string) (float64, error) { // Fetch image resp, err := http.Get(url) if err != nil { return 0, fmt.Errorf("failed to fetch image: %v", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("failed to read image data: %v", err) } img, err := gocv.IMDecode(data, gocv.IMReadColor) if err != nil { return 0, fmt.Errorf("failed to decode image: %v", err) } defer img.Close() // Convert to HSV hsv := gocv.NewMat() defer hsv.Close() gocv.CvtColor(img, &hsv, gocv.ColorBGRToHSV) channels := gocv.Split(hsv) s, v := channels[1], channels[2] defer func() { for _, ch := range channels { ch.Close() } }() satMask := gocv.NewMat() valMask := gocv.NewMat() cloudMask := gocv.NewMat() defer satMask.Close() defer valMask.Close() defer cloudMask.Close() lowerS := gocv.NewMatFromScalar(gocv.NewScalar(0, 0, 0, 0), gocv.MatTypeCV8U) upperS := gocv.NewMatFromScalar(gocv.NewScalar(60, 0, 0, 0), gocv.MatTypeCV8U) lowerV := gocv.NewMatFromScalar(gocv.NewScalar(120, 0, 0, 0), gocv.MatTypeCV8U) upperV := gocv.NewMatFromScalar(gocv.NewScalar(255, 0, 0, 0), gocv.MatTypeCV8U) defer lowerS.Close() defer upperS.Close() defer lowerV.Close() defer upperV.Close() gocv.InRange(s, lowerS, upperS, &satMask) gocv.InRange(v, lowerV, upperV, &valMask) gocv.BitwiseAnd(satMask, valMask, &cloudMask) totalPixels := cloudMask.Rows() * cloudMask.Cols() if totalPixels == 0 { return 0, fmt.Errorf("no pixels found in image") } cloudPixels := gocv.CountNonZero(cloudMask) cloudCover := 100 * float64(cloudPixels) / float64(totalPixels) return cloudCover, nil } func updateCache(url string) { cover, err := estimateCloudCover(url) mu.Lock() defer mu.Unlock() result := CloudResult{ URL: url, CloudCover: cover, LastUpdated: time.Now().Format(time.RFC3339), } if err != nil { result.Error = err.Error() } cache[url] = result } func startAutoRefresh(url string) { go func() { for { updateCache(url) time.Sleep(refreshFreq) } }() } func handleCloudCover(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("url") if url == "" { url = "http://clouds.service:8081/snapshot" } mu.Lock() result, ok := cache[url] mu.Unlock() if !ok { // first-time fetch triggers background updates updateCache(url) startAutoRefresh(url) mu.Lock() result = cache[url] mu.Unlock() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func main() { http.HandleFunc("/cloudcover", handleCloudCover) http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) fmt.Printf("☁️ Cloud cover API running on http://localhost:8080\n") fmt.Printf(" Example: curl 'http://localhost:8080/cloudcover?url=http://clouds.local:8082/snapshot'\n") log.Fatal(http.ListenAndServe(":8080", nil)) }