DEV Community

shaojie gong
shaojie gong

Posted on

GrowBook: the plant classifier, a Simplified/Traditional Chinese table, and a lesson in honesty

"Snap a photo, we'll tell you what plant it is." Every plant app promises this. Building it, I hit a fork that turned out to be the most interesting decision in the whole project: does the AI run in the cloud, or on the phone?

The cloud way is easy. There are great plant-ID APIs. You upload the photo, they send back an answer, done in an afternoon. But it means every identification is a network round-trip, a per-call cost that scales with users, someone else's uptime, and — the part that bugged me most — a user's plant photo leaving their phone and going to a server. For an app whose whole pitch is "works offline, no account required," bolting on a feature that silently needs the internet and phones home a picture felt like a betrayal of the premise.

So I did it the hard way. GrowBook runs a TensorFlow Lite model on the device. The photo never leaves the phone. Identification works on a plane, in a greenhouse with no signal, forever, for free. The core of it is small:

_interpreter = await Interpreter.fromAsset('assets/models/plant_model.tflite');
Enter fullscreen mode Exit fullscreen mode

Load the model from assets, load a labels file next to it, feed in a resized image, get back a list of confidence scores, take the top 5. The model ships inside the app, so there's nothing to fetch and nothing to pay for per call.

Being honest about the tradeoff, because build-in-public means the downside too: an on-device model is smaller and dumber than a cloud model. It knows the common houseplants well and gets confidently wrong on the rare stuff. So I don't present it as an oracle. It returns the top few guesses with confidence scores, framed as "is it one of these?" — a helpful starting point, not a verdict. Overclaiming accuracy is how you lose a user's trust on the first wrong answer.

Then the bug I never saw coming, which had nothing to do with machine learning. GrowBook ships in 12 languages, and my label translations are keyed by scientific name. For Chinese, I stored Simplified. But I also support Traditional Chinese (zh_Hant), and I did not have a separate Traditional translation for every single plant. So a Traditional-Chinese user identifying a plant would get… the Simplified characters. Small, but wrong, and exactly the kind of thing that makes an app feel careless in a language you don't speak.

I didn't want to hand-translate hundreds of plant names twice. So I wrote a tiny Simplified→Traditional conversion table — just the characters that actually appear in my plant names — and a function that walks each label character by character and swaps them. It's about sixty character pairs (芦→蘆, 龟→龜, 树→樹, 龙→龍…), covering exactly my dataset and not one character more. Not a general-purpose converter — a purpose-built one that's small, offline, and correct for the words I actually ship. When a real zh_Hant translation exists I use it; otherwise I fall back to converting the Simplified one on the fly.

What I keep taking away: "just call an API" is often the right answer, and here it was the wrong one — not for technical reasons but for what the app is supposed to be. The cloud version would've shipped weeks sooner and quietly contradicted the entire premise. And the messiest fix in the whole feature (a hand-built character table) came not from the AI at all, but from taking a language seriously that I can't even read.

If you've shipped on-device ML: where did you draw the line between on-device and cloud, and did you regret it? I'm still not 100% sure I called it right — I just know I called it on purpose.

https://growbookcare.com/

— building GrowBook in public, #4

Top comments (0)