Garden AI: Monitoring My Garden with a Raspberry Pi and GCP

I built an automated system that photographs my garden every 15 minutes, analyzes vegetation health using a green-pixel heuristic, stores everything locally in a k3s cluster, and selectively syncs to Google Cloud for long-term analytics. Here’s how it works.

        ┌─────────────────────── k3s home lab (core) ───────────────────────┐
Pi ──▶  │ capture ▶ MinIO (images) ▶ analyzer (green-pixel) ▶ Postgres ▶ Grafana │
        └───────────────────────────────┬───────────────────────────────────┘
                                         │  sync contract (metrics + sampled images)
                                         ▼
        ┌──────────────────────── GCP (analytics/ML) ───────────────────────┐
        │ GCS (sampled images) ▶ BigQuery (metrics) ▶ Looker Studio          │
        │                         └▶ Vertex AI (classification — planned)     │
        └────────────────────────────────────────────────────────────────────┘

The problem

I wanted to track how my garden changes over time without manually checking it every day. A Raspberry Pi with a camera can capture images on a schedule, but raw photos don’t tell you much unless you process them. I wanted a number — a simple ratio that answers “how green is the garden right now?” — and I wanted it tracked over time with dashboards and the option to layer on real ML later.

Hardware

The capture node is a Raspberry Pi Model A+ — single-core ARMv6, 512MB RAM, one USB port. It runs a Naturebytes Wildlife Camera Kit with an OmniVision OV5647 5MP sensor connected via CSI ribbon cable. The whole thing sits in an IP55-rated weatherproof enclosure and is wall-powered through a weatherproof micro-USB cable.

A cron job runs rpicam-still every 15 minutes during daylight hours (6am–8pm UTC). Each image is keyed YYYY/MM/DD/HHMMSS.jpg and written over NFS to a landing zone on a Synology NAS.

The local tier

The processing backbone is a three-node k3s cluster running on Intel NUC7i7BNHs (32GB RAM each, Ubuntu 24.04). This is the system of record — the local tier never depends on cloud availability.

The pipeline works like this:

  1. Ingest — A Kubernetes CronJob runs every 5 minutes, using mc mirror (MinIO client) to move images from the NAS landing zone into a MinIO bucket. Source files are deleted after transfer.

  2. Analyze — MinIO fires a webhook on every ObjectCreated:* event, POSTing to the analyzer service. The analyzer fetches the image, computes a green-pixel ratio, and writes the result to PostgreSQL.

  3. Dashboard — Grafana pulls from Postgres and displays time-series charts of the green-pixel ratio, daily averages, a live gauge, and capture counts.

The green-pixel heuristic

The analysis itself is intentionally simple. For each image:

  • Convert to RGB and load as a NumPy array
  • A pixel counts as “green” when the Green channel exceeds both Red and Blue by at least 15 (this margin prevents grey and white pixels from being misclassified)
  • The ratio is green_pixels / total_pixels, yielding a float between 0.0 and 1.0

That’s it. No model, no training data, no GPU. The function is pure — zero I/O dependencies — so it’s trivially unit-testable. It’s a baseline measurement that’s surprisingly useful for tracking vegetation health over time.

Cloud sync

Not everything goes to the cloud. Metrics rows all get pushed to BigQuery, but images are sampled:

  • One image per day at solar noon (18:00 UTC / 12pm CST)
  • Any image where the green-pixel ratio changes by more than 0.05 compared to the previous reading

This transition-biased sampling is deliberate — it captures the interesting state changes and will serve as training data when I add real ML classification via Vertex AI.

Sync is best-effort and async. If GCP is unreachable, the local tier keeps working and a background retry loop (every 5 minutes, batch of 50) catches up when connectivity returns. Sampled images land in a GCS bucket with the same YYYY/MM/DD/HHMMSS.jpg key convention for clean cross-tier joins.

Infrastructure as Code

Everything is Terraform, split into two configurations:

Cloud (cloud/terraform/) provisions the GCS bucket (with a lifecycle rule transitioning to NEARLINE after 365 days), BigQuery dataset and partitioned table, and a least-privilege service account with only objectCreator, dataEditor, and jobUser permissions. State lives in a GCS backend.

Local (local/terraform/) bootstraps what ArgoCD can’t self-bootstrap: the garden namespace (with Pod Security Standards), NFS-backed PersistentVolumes for MinIO and the landing zone, and the ArgoCD Application resource itself. From there, ArgoCD takes over and syncs everything in local/manifests/ with automated prune and self-heal.

Security

I tried to get this right:

  • Network Policies lock down pod-to-pod traffic. MinIO only accepts connections from the analyzer and ingest pods. Postgres only accepts connections from the analyzer and Grafana. The analyzer only accepts webhook POSTs from MinIO.
  • External Secrets Operator with a 1Password ClusterSecretStore handles all credentials — MinIO, Postgres, GCP service account key, GitHub PAT for ArgoCD. No secrets in the repo.
  • Pod Security Standards are enforced at the namespace level (baseline enforced, restricted warned/audited).
  • GCS has uniform bucket-level access and public access prevention enforced.

CI/CD

GitHub Actions runs four parallel checks on every push and PR:

  • Lint — Ruff for Python style and formatting
  • Test — pytest against the analyzer’s pure analysis functions
  • Terraform validate — fmt check + validate on both cloud and local configs
  • Docker build — builds the analyzer image without pushing (cache warming)

A separate workflow publishes the analyzer Docker image to GHCR when files under local/analyzer/ change, tagged with both the commit SHA and latest. ArgoCD picks up the new image and rolls it out automatically.

What’s next

The green-pixel heuristic is a starting point. The sampled images accumulating in GCS are specifically curated for training a real classifier — the transition bias means the dataset is rich in state changes rather than redundant steady-state captures. The plan is Vertex AI AutoML for image classification, though I’m also considering running inference locally on the k3s cluster with KServe.

The stack

  • Edge: Raspberry Pi Model A+, OV5647 camera, cron + rpicam-still
  • Local compute: 3x Intel NUC, k3s, MinIO, PostgreSQL, Grafana
  • Cloud: GCS, BigQuery, Terraform
  • GitOps: ArgoCD, GitHub Actions, GHCR
  • Secrets: External Secrets Operator + 1Password
  • Code: github.com/dirtmerchant/garden-ai