feat: add Pl@ntNet identification service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ceed3b083
commit
b884852f05
2 changed files with 173 additions and 0 deletions
66
plants/services/plantnet.py
Normal file
66
plants/services/plantnet.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
_API_BASE = 'https://my-api.plantnet.org/v2/identify/all'
|
||||||
|
_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
class PlantNetError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def identify(images):
|
||||||
|
"""
|
||||||
|
Call Pl@ntNet identify API.
|
||||||
|
|
||||||
|
images: list of (image_bytes, organ) tuples
|
||||||
|
organ: 'flower' | 'leaf' | 'fruit' | 'bark' | 'habit' | 'auto'
|
||||||
|
|
||||||
|
Returns list of match dicts (up to 5), each:
|
||||||
|
scientific_name, common_names, score, gbif_id, family, genus
|
||||||
|
|
||||||
|
Raises PlantNetError on any failure.
|
||||||
|
"""
|
||||||
|
if not settings.PLANTNET_API_KEY:
|
||||||
|
raise PlantNetError('API key not configured.')
|
||||||
|
|
||||||
|
files = [
|
||||||
|
('images', (f'image{i}.jpg', img_bytes, 'image/jpeg'))
|
||||||
|
for i, (img_bytes, _) in enumerate(images)
|
||||||
|
]
|
||||||
|
data = [('organs', organ) for _, organ in images]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_API_BASE,
|
||||||
|
params={'api-key': settings.PLANTNET_API_KEY, 'lang': 'en'},
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.Timeout:
|
||||||
|
raise PlantNetError('Pl@ntNet timed out — try again.')
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
raise PlantNetError('Daily limit reached (500/day).')
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.HTTPError:
|
||||||
|
raise PlantNetError(f'Pl@ntNet API error ({resp.status_code}).')
|
||||||
|
|
||||||
|
results = resp.json().get('results', [])
|
||||||
|
if not results:
|
||||||
|
raise PlantNetError('No match found — try a different photo or organ.')
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'scientific_name': r['species']['scientificNameWithoutAuthor'],
|
||||||
|
'common_names': r['species'].get('commonNames', []),
|
||||||
|
'score': r['score'],
|
||||||
|
'gbif_id': (r.get('gbif') or {}).get('id'),
|
||||||
|
'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''),
|
||||||
|
'genus': r['species'].get('genus', {}).get('scientificNameWithoutAuthor', ''),
|
||||||
|
}
|
||||||
|
for r in results[:5]
|
||||||
|
]
|
||||||
107
plants/tests/test_plantnet.py
Normal file
107
plants/tests/test_plantnet.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import pytest
|
||||||
|
import requests as req_lib
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from plants.services.plantnet import identify, PlantNetError
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(results=None, status_code=200):
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.status_code = status_code
|
||||||
|
mock.json.return_value = {'results': results or []}
|
||||||
|
if status_code == 429:
|
||||||
|
mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock)
|
||||||
|
elif status_code >= 400:
|
||||||
|
mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock)
|
||||||
|
else:
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def _api_results():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'score': 0.94,
|
||||||
|
'species': {
|
||||||
|
'scientificNameWithoutAuthor': 'Liatris spicata',
|
||||||
|
'commonNames': ['Blazing star', 'Pronkster'],
|
||||||
|
'family': {'scientificNameWithoutAuthor': 'Asteraceae'},
|
||||||
|
'genus': {'scientificNameWithoutAuthor': 'Liatris'},
|
||||||
|
},
|
||||||
|
'gbif': {'id': '2889739'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'score': 0.04,
|
||||||
|
'species': {
|
||||||
|
'scientificNameWithoutAuthor': 'Liatris pycnostachya',
|
||||||
|
'commonNames': ['Prairie blazing star'],
|
||||||
|
'family': {'scientificNameWithoutAuthor': 'Asteraceae'},
|
||||||
|
'genus': {'scientificNameWithoutAuthor': 'Liatris'},
|
||||||
|
},
|
||||||
|
'gbif': {'id': '1234567'},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_returns_empty_without_api_key(settings):
|
||||||
|
settings.PLANTNET_API_KEY = ''
|
||||||
|
with pytest.raises(PlantNetError, match='API key'):
|
||||||
|
identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_parses_results(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
with patch('plants.services.plantnet.requests.post',
|
||||||
|
return_value=_mock_response(_api_results())):
|
||||||
|
results = identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]['scientific_name'] == 'Liatris spicata'
|
||||||
|
assert results[0]['score'] == pytest.approx(0.94)
|
||||||
|
assert 'Blazing star' in results[0]['common_names']
|
||||||
|
assert results[0]['family'] == 'Asteraceae'
|
||||||
|
assert results[0]['genus'] == 'Liatris'
|
||||||
|
assert results[0]['gbif_id'] == '2889739'
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_raises_on_no_results(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
with patch('plants.services.plantnet.requests.post',
|
||||||
|
return_value=_mock_response([])):
|
||||||
|
with pytest.raises(PlantNetError, match='No match'):
|
||||||
|
identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_raises_on_quota_exceeded(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
with patch('plants.services.plantnet.requests.post',
|
||||||
|
return_value=_mock_response(status_code=429)):
|
||||||
|
with pytest.raises(PlantNetError, match='Daily limit'):
|
||||||
|
identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_raises_on_timeout(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
with patch('plants.services.plantnet.requests.post',
|
||||||
|
side_effect=req_lib.Timeout):
|
||||||
|
with pytest.raises(PlantNetError, match='timed out'):
|
||||||
|
identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_raises_on_other_http_error(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
with patch('plants.services.plantnet.requests.post',
|
||||||
|
return_value=_mock_response(status_code=500)):
|
||||||
|
with pytest.raises(PlantNetError):
|
||||||
|
identify([(b'fake', 'flower')])
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_sends_correct_organs(settings):
|
||||||
|
settings.PLANTNET_API_KEY = 'test-key'
|
||||||
|
mock_post = MagicMock(return_value=_mock_response(_api_results()))
|
||||||
|
with patch('plants.services.plantnet.requests.post', mock_post):
|
||||||
|
identify([(b'img1', 'flower'), (b'img2', 'leaf')])
|
||||||
|
|
||||||
|
call_kwargs = mock_post.call_args
|
||||||
|
data = call_kwargs[1]['data']
|
||||||
|
assert ('organs', 'flower') in data
|
||||||
|
assert ('organs', 'leaf') in data
|
||||||
Loading…
Add table
Reference in a new issue