Capture analysis
Draft. This page describes intended behaviour that has not been verified on a real Gardyn yet.
Every still the unit takes is scored per pod by one or more analysers.
Each analyser says how much of each pod’s crop looks like plant (the green
fraction, 0–1), and the trend of that number drives the growing / stalled /
declining verdicts on the Grow screen and the pod_health MCP tool.
Pods 1–15 are seen by the upper camera and 16–30 by the lower one, in a 3 × 5 grid.
On the unit
Two analysers run after every capture, on a worker thread so the app stays responsive while a Pi decodes the photo:
| Analyser | What it does |
|---|---|
green-threshold@1 | Excess-green index (2G − R − B) above a fixed cut-off. The original baseline; drifts with grow-light colour and exposure. |
exg-otsu@1 | The same index with the cut-off chosen per photo by Otsu’s method (the PlantCV approach), so a lighting change moves the threshold rather than the result. |
Open Analysis in the sidebar to:
- see every analyser, how many photos it has scored, and which one is primary (the one pod trends follow). Make primary switches it.
- Re-run on stored photos to score the photos still on the unit (the last 30 days) with one analyser, for example after an update brings a new version. New versions are stored next to old ones; nothing is overwritten.
- Compare on one pod: one line per analyser over time.
- Latest photo, side by side: the newest still with every analyser’s number for each pod.
Keep analysis masks (Settings → Cameras) also stores exg-otsu’s
plant/not-plant mask for each photo as a 1-bit PBM image, about 115 kB each.
It is off by default to spare the SD card.
Export a dataset
Analysis → Export a dataset downloads a tar for the last 7, 30 or 90 days,
with or without the photos. Scripts can use
GET /api/datasets/export?from=…&to=…&images=0|1 directly. The archive holds:
manifest.json: window, unit, core version, analysers, and row counts.captures.jsonl,analyses.jsonlandtelemetry.jsonl: one record per line.pods.jsonandgrow_cycles.json: what was planted where, and when.images/<capture id>.jpg: the photos.README.md: the layout above, in the archive itself.
This is what you pull to a laptop to check an analyser against what you see, recalibrate a threshold, or train a model.
Mirror photos off the unit (opt-in)
The unit keeps 30 days of photos. To keep a whole season, set up an S3-compatible bucket (Cloudflare R2, Backblaze B2, MinIO, a NAS) under Settings → Off-device storage and analysis:
- Fill in the endpoint, bucket, region (
autofor R2), an optional key prefix such askitchen/, and an access key pair that can read, write, list and delete in that bucket (listing and deleting are for the nightly backups). Save. - Press Test. It writes, reads back and deletes a probe object.
- Press Copy existing photos to upload what is already on the unit.
From then on each new photo (and mask) is copied in the background. The unit
still prunes its own copy after 30 days; the bucket keeps everything, and the
app reads from it when the local copy is gone. If uploads keep failing, an
Off-device copy failed alert says why; it clears on the next success.
The same bucket also gets a nightly backup of the unit under backups/
(Maintenance → Backups).
Nothing is sent anywhere until these fields are filled in. See Privacy.
The mirror is checked against a real S3 server (MinIO) by
src/camera/tests/mirror.s3.test.ts. That test runs only when you point it at
a server:
MIRROR_TEST_ENDPOINT=http://127.0.0.1:9000 MIRROR_TEST_ACCESS_KEY=… MIRROR_TEST_SECRET=… \
pnpm exec vitest run src/camera/tests/mirror.s3.test.tsIt creates its own bucket and removes it afterwards. With
MIRROR_TEST_BUCKET it uses that existing bucket instead (an R2 token scoped
to one bucket can’t create buckets), under a fresh prefix. Either way it
deletes everything it wrote. In CI, Mirror (real S3)
(.github/workflows/mirror-s3.yml) runs it weekly and whenever the mirror
changes, once the MIRROR_TEST_* secrets hold a real bucket (see
seedypod/core#6).
Plug in an off-device analyser
A heavier model can run on a laptop or GPU box and post its results back under its own name. They appear on the Analysis screen next to the on-device analysers, and can be made primary.
-
Settings → Off-device storage and analysis → Result ingest token → Generate, copy the token, and save.
-
Run a worker.
scripts/analysis-worker.mjsis a reference you can copy:SEEDYPOD_URL=http://seedypod.local:3000 \ SEEDYPOD_INGEST_TOKEN=<token> \ node scripts/analysis-worker.mjs --id my-model --version 1 --limit 48It reads
GET /api/capturesand the JPEGs (addSEEDYPOD_API_KEYif the unit has an API key), scores each pod, and posts:POST /api/analyses/results Authorization: Bearer <token> { "analyzer": "my-model", "version": 1, "results": [ { "captureId": "upper-2026-10-31T10-00-00-000Z", "slot": 3, "metric": "green_fraction", "value": 0.42, "detail": { "confidence": 0.9 } } ] }
Rules the endpoint enforces:
- Names. The analyser id is lowercase letters, digits,
.,_or-. The on-device ids are refused (409), so a worker can’t overwrite them. - Metrics.
green_fractionis what pod trends use. Anything else, such asleaf_countorhealth_score, is stored and exported too. - Replace, not append. Posting the same capture, analyser, version, pod and metric again replaces the value.
- Pruned photos. For a photo the unit has already pruned (one the worker
read from the mirror), include its
tsandcamera. Otherwise it is reported back underskipped. - Size. Up to 10,000 results and 5 MB per request.
- Token. Ingest is off (404) while the token is empty. The token is separate from the API key, which still guards every other endpoint.
Sources: ADR 0019: Pluggable capture analysis · Privacy
Imported from docs/ANALYSIS.md in seedypod/core. Edit it there, not here.