From 3202d5f54e63d49aae7d636b27c86195a6c8c2fb Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 24 Oct 2025 10:37:55 -0400 Subject: [PATCH] Initial commit --- go.mod | 5 ++ go.sum | 2 + main.go | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7562431 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module cloudcover + +go 1.24.4 + +require gocv.io/x/gocv v0.42.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f377ab5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI= +gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..61b7d0b --- /dev/null +++ b/main.go @@ -0,0 +1,150 @@ +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)) +}