When an admin is cataloguing a tree they don’t recognize, the photo field on
เพิ่ม/แก้ไขชนิดพันธุ์ (admin/species_form.php) and เพิ่ม/แก้ไขต้นไม้
(admin/tree_form.php) has a “🔍 ให้ AI ช่วยระบุชนิดต้นไม้จากรูปนี้” button. It
sends the photo to a local Ollama vision model (default qwen2.5vl:7b) and
shows a best guess. No API key, no paid service, the photo never leaves the machine
running Ollama.
It is only ever a suggestion — nothing is written to the database until the admin reviews it and saves the form.
http://localhost:11434).ollama pull qwen2.5vl:7b (about 6 GB) for photo identification, and
ollama pull qwen3:8b for translation (see
multilingual-and-ai-translation.md).Settings (config/local.php constants or environment variables; see
config/local.example.php): OLLAMA_URL, OLLAMA_MODEL (translation; empty
string turns all AI off), OLLAMA_VISION_MODEL. The “ตั้งค่า” page shows what is
active. Any Ollama vision model works, e.g. llama3.2-vision.
| Piece | File |
|---|---|
| Button, client-side downscale (max 1024px JPEG), result panel | public/assets/js/ai-identify-button.js |
JSON endpoint (POST, login + species.manage/tree.create/tree.update, CSRF, image validation) |
admin/identify_tree.php |
| Prompt, response normalization, catalogue matching | includes/plant_identify.php |
| Shared Ollama HTTP call (also used by translation) | includes/ollama.php |
The model’s JSON is never trusted as-is: normalizePlantIdentification()
coerces types, caps lengths and the alternatives list, and treats
is_plant: true with no name as “not identified”.
One model call per click (not per photo taken) — deliberately a button, not
automatic. A local vision model is slow and occupies the machine, so each admin
is limited to AI_IDENTIFY_MAX_PER_HOUR (default 30) calls per hour. Upload
limit 10 MB (the browser shrinks photos well below that first). The endpoint
releases the PHP session lock before the AI call so other admin tabs stay
responsive.
The request is cut off at AI_IDENTIFY_MAX_SECONDS (default 120, minimum 2),
measured from the moment the server has received the photo. On timeout the admin
gets HTTP 504 and “AI ตอบช้าเกินเวลาที่กำหนด … กรุณาลองใหม่” instead of waiting.
The first call after Ollama starts is slower while the model loads into memory.
If the machine has no GPU, raise the limit or use a smaller vision model.
Opening the species/tree form hands the endpoint the category/subtype/species
lists (a small cache file, AI_IDENTIFY_CACHE_SECONDS, default 600), so the click
doesn’t re-read them (warmIdentifyRequest()). The admin’s permission is checked
against the database on every click, so a revoked role stops working at once.
The nursery’s own species (scientific names from the cached list) are offered to the model as candidates, which settles look-alikes in favour of species the nursery stocks; the wording tells the model to ignore the list unless the photo clearly matches. An answer of “Unknown …” / “ไม่ทราบ” is treated as “could not tell” instead of being shown as a name.
Whichever engine names the plant - tree in the browser, Qwen, Pl@ntNet or Gemini - the answer always comes with the
long write-up (description, care, characteristics, properties, benefits, cautions, uses of each part). The engines now
only have to give the name (a short, quick answer, so Qwen is faster than when it also wrote everything);
completeWithDetails() in includes/species_details.php then fills the details, looking in this order:
species_details_cache, created on first use) of what the AI wrote for that scientific name before;GEMINI_API_KEY is set (about 5 s), else the local text model (measured at about
80 s on a 4 GB GPU, so with only Ollama it may not finish inside the time limit), then cached.A write-up describes the species, not the photo, so it is reused for every photo of it. The in-browser model gets its
details through admin/identify_match.php?want_details=1 (the cache makes repeat species instant). If no engine can write
the details, the answer is still returned with details_status: "failed" and the page says so.
Things to know: the text is AI-written and can be wrong (a trial of the local model said teak leaves are used as
herbal medicine - doubtful), and a cached text is reused for every later photo of that species, so a mistake spreads;
the page tells the reader to check it before saving, and a species saved in the system takes precedence over the cache.
To discard a bad cached text, delete its row: DELETE FROM species_details_cache WHERE name_scientific = 'Tectona grandis'.
Tests: php tests/species_details_test.php (21 checks) and the details cases in tests/failover_test.php.
Before any server AI is asked, the button lets a small model named tree look at the photo inside the visitor’s
own browser (public/assets/js/tree-model.js, ONNX Runtime Web; files in public/assets/models/). If it is at
least 90 % sure (app_threshold in tree.labels.json) and the species exists in the system (looked up by
admin/identify_match.php, no AI), that answer is shown straight away: no upload, no cost, no server AI. Otherwise
the photo goes to the server AI as described below. A form that asks for the full write-up (data-detail="full")
accepts the local answer only when the species is already catalogued, since tree knows names, not care texts.
tree_v2; it includes the 8 species actually growing in the garden,
which the first version did not know). On held-out GBIF photos, answers at >= 90 % confidence were right 97.9 % of the time
but covered only 9.3 % of photos - so most requests still reach the server AI. Per garden species (top-1 on unseen photos):
Adenium 90 %, Buxus 93 %, Olea 87 %, Aechmea 77 %, Monstera 73 %, Bauhinia 67 %, Ligustrum 53 %, Euonymus 50 %, Mangifera 33 %;
the two look-alike hedge shrubs (Ligustrum, Euonymus) are the weak spot.
Photos taken in the exhibition may differ from GBIF’s, so these numbers can be optimistic.python tests/tree_browser_check.py (browser vs Python give the same answer; 60/60 and a probability
difference of at most 0.004 when written) and python tests/tree_button_check.py (the button’s four paths).
Both need ml/data_sea and ml/models, so they are run by hand. The photo preparation in tree-model.js
deliberately reproduces PIL’s filters: a plain browser canvas shrink moved probabilities by up to 0.15.public/assets/models/credits.csv lists the photographers (CC-BY); the result panel links to it.The local model (Qwen on Ollama) can be off, unreachable (your PC asleep, a tunnel that died), too slow or give nothing
usable. If Pl@ntNet and/or Gemini are configured, identifyWithoutLocalModel() takes over instead of failing:
| Configured | What answers |
|---|---|
| Gemini + Pl@ntNet | Gemini is the main answer (it can write the full Thai write-up); Pl@ntNet is the second opinion on the name, merged like the normal flow (agree: higher + 10, max 95; differ: the surer one wins and the other is listed) |
| Gemini only | Gemini alone |
| Pl@ntNet only | the name only; the page says the Thai name and write-up must be filled in by hand |
| neither | the original error (e.g. “cannot connect to Ollama”) |
The result carries main_source (qwen / gemini / plantnet) and fallback_note, which the page shows as a warning.
With a backup present the local model is given about 60 % of the time budget (at least 15 s), so a hang cannot starve
the backup. Translation does the same: Ollama first (60 s when Gemini can back it up), then Gemini; with neither, the
page keeps the Thai text. AI is reported enabled when any engine is configured; OLLAMA_MODEL=off switches only the
local one off. Tested over real HTTP against mock services with Ollama pointed at a closed port: php tests/failover_test.php.
When tree is not sure and the server AI is asked, the outcome can become a training photo (includes/training_samples.php,
table training_samples, created on first use):
| Situation | Stored as |
|---|---|
| Qwen and the second teacher (Pl@ntNet, else Gemini) name the same species | approved, automatically |
| they disagree | pending - a person decides on admin/training_samples.php (nav: ตัวอย่างสอน AI) |
| an admin presses “use this result” | approved (the photo is re-sent to admin/training_sample_add.php) |
| only one teacher answered | not stored |
Photos tree answered by itself are not stored (no new information). Pending queue is capped at 300. Approved photos
are downloaded with ml/pull_feedback.py through admin/training_samples_export.php, which is off unless the host sets
TRAINING_EXPORT_TOKEN (24+ characters) and answers 404 without it. Retraining and publishing are manual, and
ml/publish_model.py refuses a model whose answers at the 90 % gate are not right at least 90 % of the time - see
ml/README.md. Risk to know: two teachers can agree and still be wrong, and an admin who does not know the plant can
approve a mistake; wrong labels make tree worse, so reject what you are unsure of.
The local model reports confidence_pct (0-100); the page shows it as e.g.
“ความมั่นใจ: 82%”. It is the model’s own estimate, not a calibrated probability.
When it is below AI_CONFIDENCE_MIN (default 70), a second opinion is asked for
(askSecondOpinion() in includes/plant_identify.php):
PLANTNET_API_KEY, includes/plantnet.php) - a plant-identification
service, preferred because it is far better than a general model on look-alike
species. In a quick check it got Cassia fistula and Tectona grandis right, the two
species the local model and the trained student kept missing. The free key allows
about 500 identifications per day. It returns only scientific/common names.GEMINI_API_KEY, GEMINI_MODEL, includes/second_opinion.php) - used
only if Pl@ntNet is not configured or fails; it writes a full answer like the local model.applySecondOpinion() then merges the two:
| Result | What happens |
|---|---|
| Same species | confidence = higher of the two + 10 (max 95); the local write-up is kept |
| Different species | the more confident answer is the main one, the other goes to the alternatives, confidence - 20. If the winner is Pl@ntNet, the Thai name and write-up are empty and a note says to fill them in by hand |
| No answer | second_opinion_status is no_key, no_time (under ~6 s of the budget left) or failed; the local answer is kept |
needs_review is true while the final confidence is still under the threshold, and
the page then asks for a person who knows plants to check it, or a better photo.
The photo only leaves the machine when a second opinion is actually requested.
Two sources agreeing is a good sign, not a guarantee. Keys are server-side only
(Railway variables); never commit them.
php tests/plant_identify_test.php runs the app and a mock Ollama server locally (OLLAMA_URL points at it), so it needs no real model. It checks auth/permission/CSRF/upload validation, the request actually sent to Ollama (vision model, base64 photo, JSON format), success, no-match, not-a-plant, malformed output, upstream HTTP errors, the time cap and the hourly quota. The endpoint part needs the project’s database (PHP’s pdo_pgsql extension).