This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass
What I Built
Garden Rounds is a small offline-first web app that gets people into a garden once a week. It works out its own frost dates for wherever you are from thirty years of historical weather, tells you what to sow, plant out, inspect and harvest this week, prints that as a one-page card you can take outside, and gives a first-look read on a leaf you photograph with your phone.
The screen is the shortest part of the experience. The point is the card, and the leaf.
It's for the gardener with a phone and no signal in a raised bed, and for anyone who has ever wanted to know this week specifically rather than read a seed packet written for a different country.
Three things make it work:
It computes your frost dates instead of looking them up. Type a place name and the app pulls thirty complete calendar years of daily temperatures from the Open-Meteo archive and derives your own first-spring and last-autumn frost. Everything else follows from that. "Last frost is usually April, first fall frost is usually October" is not a fact from a table — it's computed from your location, and you can move the threshold in Settings and watch the whole season shift.
The model is a 6 MB file, not a service. MobileNetV3-Small, fine-tuned on 48,947 photographs of 37 leaf conditions, running in the browser through ONNX Runtime Web's WASM backend. No API key, no account, no upload endpoint — there isn't one in the codebase.
It keeps working with the network off. A service worker precaches the shell, the fonts, the ONNX runtime and the model. After one setup call, the app makes no further requests to anywhere.
Demo
Run it with the network off after setup. That's the demo. Turn off wifi and add a plant, scan a leaf, print the card — all of it still works.
Everything the app does on the network, it does once: your typed place name goes to Open-Meteo geocoding, and a coordinate rounded to one decimal place — about 11 km — goes to the archive API. There is an end-to-end test that asserts no third-party request happens after that, rather than the README just claiming it.
| This week | Garden | Leaf check |
|---|---|---|
![]() |
![]() |
![]() |
Code
Virtualord
/
Garden-Rounds
A small offline-first web app that gets people into a garden once a week.
Garden Rounds
A small offline-first web app that gets people into a garden once a week.
It works out its own frost dates for wherever you are from thirty years of historical weather, tells you what to sow, plant out, inspect and harvest this week, prints that plan as a one-page card you can take outside, and gives a first-look read on a leaf you photograph with your phone.
The screen is the shortest part of the experience. The point is the card, and the leaf.
What it looks like
The printed card is the real output:
More in docs/screens/, including a frost-free climate, the
setup flow and both colour schemes.
Running it
cd web
npm install
npm run dev
Then open http://localhost:5173.
npm run lint # eslint
npm run format:check # prettier
npm test # vitest, 456 tests
npm run build #…MIT licensed. 10,730 lines, 492 unit tests and 30 end-to-end tests.
How I Built It
The model. MobileNetV3-Small (timm, Apache 2.0) fine-tuned on PlantVillage, grouped by leaf so that all four photographs of the same leaf land on the same side of the split. Eight epochs, CPU-only, about two hours.
That grouping was the whole ballgame, and it came from a mirror rather than the original dataset. The Hugging Face mirror geraldmc/plantvillage-full keeps a leaf_id column that upstream discards. 69% of eligible rows are one of just 5,910 leaves, shot four times over. Split those naively and the model sees the same leaf in both train and test. It was not a hypothetical: the audit caught a real bug on its first run, with 3,223 leaves assigned to both validation and test.
So ml/prepare_hf.py groups on leaf_id, and its audit() raises rather than printing reassurance. A split report that quietly says "looks fine" is how a leaked split gets shipped. Final leaf overlap between every pair of splits: 0.
| Split | Images | Leaves | Images per leaf |
|---|---|---|---|
| train | 34,096 | 14,663 | 2.33 |
| val | 7,411 | 3,195 | 2.32 |
| test | 7,440 | 3,223 | 2.31 |
The result: lab top-1 0.9957, macro recall 0.9950, 37 classes. All 32 errors are between two diseases of the same plant, and the correlation between class size and recall is +0.07 — indistinguishable from zero. The 37× class imbalance was the reason to check that rather than assume it, because the small classes are normally what breaks first.
I tried to make it 2 MB and it refused to be. The target was int8. All three quantisation routes produced near-constant output:
| Precision | Size | Lab top-1 | |
|---|---|---|---|
| fp32 | 6.23 MB | 0.9957 | shipped |
| static QDQ, per-channel | 1.90 MB | 0.0887 | refused, −90.7 pts |
| dynamic, per-channel | 1.72 MB | 0.1187 | refused |
dynamic + reduce_range
|
1.72 MB | 0.0000 | refused |
So the export carries a gate: int8 ships only if it costs no more than 1.5 points. It refused, three times, and fp32 shipped. Two candidate causes were tested and eliminated — single-class calibration, and VNNI-less saturation. The wrong answer is still in ml/MODEL_CARD.md with both hypotheses, because I don't know what it is and 6.23 MB is the honest answer.
Everything else is deliberately not AI. There's no language model anywhere in this app. The planner is a pure function of your frost dates and the crop's own needs, with today injected so it's testable. A language model would have made it worse and less trustworthy, and the app's claim — "this is what your dates say" — is only worth making if the thing producing it is inspectable.
The app. React 19, Vite, Tailwind v4, react-router, ONNX Runtime Web, IndexedDB, Vite PWA. The design constraints that shaped it:
- One animation runs forever, and a test asserts there's exactly one. That's what stops a second from being added without noticing.
- One nav landmark. The bottom tab bar restyles into a left rail at ≥64rem rather than being duplicated, so there's never two navs.
- No horizontal scroll at 320, 360, 390, 820, 1024 or 1440px — asserted.
-
The theme is System / Light / Dark, with each colour declared once via
light-dark()and the three options only settingcolor-scheme. Settingcolor-schemerather than repainting also fixes scrollbars, the caret and form controls.
Why Does Open Innovation Matter?
Because it runs in a garden with no signal, and because the photographs never leave the phone.
Both of those are consequences of the licence, not accidents. A closed API would have been easier: a bigger model, better generalisation, someone else's GPUs. What it could not have been is offline, or free of a server holding photographs of someone's plants. Those aren't features I added on top of the open approach. They're what the open approach is for.
Concretely, open weights let me do three things a hosted endpoint would have stopped me doing:
Find the leak myself. I could decode the dataset's structure, discover that 69% of rows were four shots of the same leaf, and fix the split. With an API I would have sent a photo, got an answer back, and had no way to ask why — so the leak would have stayed invisible and I'd have shipped a number I believed.
Set the gate and let it refuse. The quantisation table above is only meaningful because I could run the model locally, badly, a hundred times. The 1.5-point rule rejected all three int8 routes. A hosted service would have returned a working answer and I'd never have known there was a cheaper size I hadn't found.
Publish the failure. MODEL_CARD.md records the licence dispute, the int8 table, the 37× class imbalance, and the one wrong prediction I left in place rather than quietly fix. You can check any of it, because the pipeline that produced it is in the repo.
On privacy specifically: there is no upload endpoint in this codebase. Your leaf photographs are resized and classified in the browser and stored only as a thumbnail on your own device. The Content-Security-Policy in render.yaml makes that enforceable rather than aspirational — connect-src names Open-Meteo and nothing else, so a browser would block a third-party call even if someone added one.
Where this is genuinely worse than a closed approach
The project is an argument, so the argument needs its counter-argument, and I'd rather write it than have a judge find it:
- No open questions. "What's wrong with this leaf and what should I do?" is the real question. This answers "which of 37 conditions does this resemble" — narrower.
- No context across a season. It sees one leaf. It doesn't know the plant has been declining since June, or that the bed waterlogs.
- No coverage outside the label set. Thirty-seven classes from two datasets. A strawberry problem that isn't leaf scorch isn't in the space, and the model will confidently offer the nearest thing it knows.
- Worse photographs. It's trained on photographs most people don't take. A closed API trained on far more varied data would handle a phone-in-the-dark photo better.
- No explanation. It can't tell you why, or what to do next. That's advice, not classification.
What I haven't done yet
Being straight about this, because a 0.9957 in a write-up invites the wrong conclusion:
-
There is no field test. The 0.9957 is on held-out lab photographs.
docs/FIELD_TEST.mdexists and is empty; I have not photographed a real garden with this yet. -
No field split at all —
metrics.jsonrecords"field": {"n": 0, "skipped": "no images in this split"}. The corpus is lab-only. This is a number on a test set, not a product claim. -
The model isn't published yet.
models/leaf.onnxis a gitignored Release asset, so the deployed Scan screen says plainly that no trained model is installed. The rest of the app is fully functional without it. It needs a GPU box to run the export properly — mine issm_61, and PyTorch has droppedsm_61from every wheel. -
26 crop timings still need checking against a local extension guide. Every one is marked
needs_reviewin the plan and on the card. -
A licence question is unresolved. The HF mirror is tagged
cc0-1.0; upstream PlantVillage is CC BY-SA 4.0. Those cannot both be right. No dataset is redistributed here, and the dispute is recorded rather than assumed away.
Best Use of Render — the app is a static Vite build deployed as a Render static site with a Blueprint, an SPA rewrite rule that doesn't swallow the wasm or the model, long-lived immutable caching for both, and a Content-Security-Policy that makes the no-third-party-requests claim something a browser enforces.







Top comments (0)