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:
-
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. -
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. -
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