---
title: "Reproducible workflow for visualisation of iNaturalist observations in the Gayini wetlands"
subtitle: "Reproducible workflow using Python"
author:
- name:
given: "José R."
family: "Ferrer-Paris"
email: j.ferrer@unsw.edu.au
orcid: 0000-0002-9554-3395
corresponding: true
affiliations:
- id: ces
name: Centre for Ecosystem Science, University of New South Wales
city: Sydney
country: Australia
- id: udash
name: UNSW Data Science Hub, University of New South Wales
city: Sydney
country: Australia
- id: iucn
name: IUCN Commission on Ecosystem Management
city: Gland
country: Switzerland
date: 19 Feb 2025
date-modified: last-modified
categories: [Python, Altair, Folium, Geopandas, Australia]
citation:
url: https://jrfep.quarto.pub/gayini-inat/
engine: jupyter
format:
html:
code-fold: true
code-summary: "Show the code"
code-tools: true
toc: true
from: markdown
editor_options:
chunk_output_type: console
image: "https://inaturalist-open-data.s3.amazonaws.com/photos/482844507/medium.jpeg"
image-alt: "Gotcha! by José Rafael Ferrer-Paris, en Flickr"
---
This document provides an example visualisation of the iNaturalist observations from the **Gayini wetlands** for the *ARDC Gayini Trusted Environmental Data and Information Supply Chain project*.
> The Nari Nari Tribal Council manages and is actively restoring 80,000 ha of the extensive Gayini wetlands on the Murrumbidgee River. With their consortium partners, they are managing environmental flows, feral animals, cultural burning and grazing of livestock. The area is a key breeding area for waterbird rookeries, including the three ibis species, spoonbills, cormorants, herons and Australian pelicans. It also has extensive areas of lignum, river red gum and blackbox as well as terrestrial ecosystems. Nari Nari are supported by three other consortium partners, The Nature Conservancy, Murray Darling Wetlands Working Group and UNSW’s Centre for Ecosystem Science.
Check out the [the project page](https://www.inaturalist.org/projects/gayini-wetlands) at iNaturalist and the [Gayini TEDISC project](https://ardc.edu.au/project/gayini-trusted-environmental-data-and-information-supply-chain/) at ARDC.
This document brings together data from online resources and is meant to be completely reproducible.
I will show here how to download information from a `region` in iNaturalist, query the observations in that region and visualise the data in three alternative ways: taxonomically, spatially, and temporally.
## Reproducible workflow with Python
For this document I am using the `get_observations` function and the `ICONIC_TAXA` constants from [PyiNaturalist](https://pyinaturalist.readthedocs.io/en/stable/) for query and download of the data, [Altair](https://altair-viz.github.io/altair-tutorial/README.html) and [Folium](https://python-visualization.github.io/folium/latest/index.html) for data visualisation, and some functions from [GeoPandas](https://geopandas.org/en/stable/docs/user_guide.html) and pandas for convenience in reading data as a data frame, as well as selected functions from the _urllib_, _owslib_, _json_ and _datetime_ modules.
```{python}
#| code-summary: "Load modules in python"
from pyinaturalist import get_observations, ICONIC_TAXA
import altair as alt
import folium
import pandas as pd
import geopandas as gpd
from datetime import datetime
from owslib.wms import WebMapService
import urllib.parse, urllib.request, json
from PIL import Image
from io import BytesIO
```
## Data access and download
For this workflow we will load observations records and spatial data from [iNaturalist](https://www.inaturalist.org/home) and map layers from New South Wales Spatial Services and the Central Resource for Sharing and Enabling Environmental Data in NSW (SEED NSW).
### Basemap from NSW Spatial Services
The NSW Base Map layer is available from [NSW Spatial Services](https://www.spatial.nsw.gov.au) as a WMTS layer.
```{python}
#| code-summary: "Information for creating a WebMapTileService request"
NSW_basemap_url = "http://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Base_Map/MapServer/WMTS?"
nsw_base_layer = 'public_NSW_Base_Map'
params = {
'Service': 'WMTS',
'Request': 'GetTile',
'Version': '1.0.0',
'Style': 'default',
'tilematrixset': 'default028mm',
'Format': 'image/png',
'layer': nsw_base_layer,
'TileMatrix': '{z}',
'TileRow': '{y}',
'TileCol': '{x}'
}
NSW_basemap_params = urllib.parse.urlencode(params, safe='{}')
NSW_basemap=NSW_basemap_url + NSW_basemap_params
nsw_base_attrib = u" © State of New South Wales, Department of Customer Service, Spatial Services"
```
### Terrestrial vegetation map
The spatial data for the Vegetation Formations and Classes of NSW Version 3.03 (Keith and Simpson 2012; updated in 2017) is available from the SEED portal. There are several access options documented in the metadata of the dataset, I use here the Web Map Service.
:::{.aside}
Vegetation Formations and Classes of NSW (version 3.03 - 200m Raster) - David A. Keith and Christopher C. Simpson. VIS_ID 3848. Updated in 2017 as version 3.1. Available from [SEED data portal](https://datasets.seed.nsw.gov.au/dataset/vegetation-classes-of-nsw-version-3-03-200m-raster-david-a-keith-and-christopher-c-simpc0917)
:::
```{python}
#| code-summary: "Querying the WebMapService for information"
md_id = '31986103-db62-4994-9702-054949281f56'
md_url = 'https://datasets.seed.nsw.gov.au/api/3/action/package_show?id=' + md_id
with urllib.request.urlopen(md_url) as url:
metadata = json.load(url)
vegmap_url = metadata['result']['resources'][2]['url']
vegmap_wms = WebMapService(vegmap_url, version="1.3.0")
vegmap_layer = "0"
vegmap_attrib = 'Keith & Simpson 2017'
wms = vegmap_wms.contents[vegmap_layer]
legendurl = wms.styles['default']['legend']
with urllib.request.urlopen(legendurl) as url:
vegmap_legend = Image.open(BytesIO(url.read()))
```
### iNaturalist observations
The `pyinaturalist` library in Python provides convenient access to the iNaturalist API. We need the `place_id` that matches the area of interest to query the API with the function `get_observations`.
```{python}
#| code-summary: "Get observations from iNat"
#| eval: true
PLACE_ID = 209778
PLACE_NAME = 'Gayini wetlands'
observations = get_observations(place_id=PLACE_ID,
per_page=0)
n_obs = observations['total_results']
print("Project _{}_ has {} observations in iNaturalist".format(PLACE_NAME,n_obs))
```
### Polygon of region of interest from iNaturalist
iNaturalist also provide access to the spatial information of places that have been contributed by the community. Here we construct a url to access the Gayini wetlands polygon in GeoJSON format.
```{python}
#| code-summary: "Get polygon from iNat"
#| eval: true
gayini_geojson = f'https://www.inaturalist.org/places/geometry/{PLACE_ID}.geojson'
#gayini_polygon = gpd.read_file(path)
```
## Overview of iNaturalist records
The following snippet of code goes through the list of iNat's observations (downloaded as a json object or, in this case, a python dictionary), and filters the *research quality grade* observations to extract records of species names, iconic taxon, date of the observation and the preferred common name, if present. We then summarise the observations grouped by species and count the total number of records per species.
```{python}
#| code-summary: "Summarise observations by species"
#| eval: true
records=list()
j=1
requested=0
while requested < n_obs:
print("Requesting observations from project _{}_: page {}, total of {} observations downloaded".format(PLACE_NAME,j,min(j*200,n_obs)))
observations = get_observations(place_id=PLACE_ID,per_page=200,page=j)
requested = j*200
j=j+1
for obs in observations['results']:
if obs['quality_grade'] == 'research':
if obs['taxon'] is not None:
record = {
'rank': obs['taxon']['rank'],
'species_name': obs['taxon']['name'],
'iconic_taxon': obs['taxon']['iconic_taxon_name'],
'observed_on': datetime.date(obs['observed_on']),
}
if 'preferred_common_name' in obs['taxon'].keys():
record['common_name']= obs['taxon']['preferred_common_name']
records.append(record)
df = pd.DataFrame(records)
colnames=['iconic_taxon','species_name','common_name','rank',]
df.groupby(colnames)['species_name'].agg([ 'count'])
```
We can further group the species and observation by groups of _iconic taxa_.
```{python}
#| code-summary: "Plot observations by iconic taxa"
#| eval: true
iconic_df = df.groupby('iconic_taxon')['species_name'].agg([ 'count', 'nunique']).reset_index()
TAXON_IMAGE_URL = 'https://raw.githubusercontent.com/inaturalist/inaturalist/main/app/assets/images/iconic_taxa/{taxon}-75px.png'
iconic_df['img']=iconic_df.iconic_taxon.apply(lambda x: TAXON_IMAGE_URL.format(taxon=x.lower()))
bar1 = alt.Chart(
iconic_df
).mark_bar(
color='grey', opacity=0.15
).encode(
x=alt.X('iconic_taxon:N', sort='-y'),
y='count:Q'
)
bar2 = alt.Chart(
iconic_df
).mark_bar(
color='blue', opacity=0.15
).encode(
x=alt.X('iconic_taxon:N', sort='-y'),
y='nunique:Q'
)
img = alt.Chart(
iconic_df,
title=f'Research grade observations in {PLACE_NAME} by iconic taxon',
width=500,
height=400,
).mark_image(
baseline='top'
).encode(
x=alt.X('iconic_taxon:N', sort='-y', title='Iconic taxon'),
y=alt.Y('count:Q', title='Number of species/observations'), url='img')
bar1 + bar2 + img
```
:::{.aside}
iNaturalist group species into `iconic taxa`. Since we don't need to get into the details of taxonomic classifications for this project, this will do for this excercise.
:::
## Map of iNat observations
For the spatial visualisation of the data, this code brings together the information from the NSW base map, the Terrestrial Vegetation map, the Gayini wetlands boundary polygon and the iNaturalist observations.
```{python}
#| code-summary: "Visualising the map of observations"
#| eval: true
m = folium.Map(location=[-34.65, 143.583333],tiles = None, zoom_start=9)
folium.TileLayer(tiles=NSW_basemap,
attr=nsw_base_attrib,
name='NSW base map').add_to(m)
folium.WmsTileLayer(
url=vegmap_wms.provider.url,
name='Vegetation Map',
styles='default',
fmt="image/png",
transparent=True,
layers=vegmap_layer,
attr=vegmap_attrib,
overlay=True,
show=False,
).add_to(m)
geo_j = folium.GeoJson(data=gayini_geojson, style_function=lambda x: {"fillColor": "orange"})
folium.Popup('Gayini wetlands').add_to(geo_j)
pol = folium.FeatureGroup(name="Boundaries", control=True).add_to(m)
geo_j.add_to(pol)
fg = folium.FeatureGroup(name="iNaturalist observations", control=True, attribution="observers @ iNaturalist").add_to(m)
popup_text = """<img src='{url}'>
<caption><i>{species}</i> observed on {observed_on} / {attribution}</caption> {desc}
"""
for obs in observations['results']:
if obs['quality_grade'] == 'research':
if obs['description'] is None:
desc = ""
else:
desc = obs['description']
pincolor = 'green'
else:
desc = "Observation is not research quality grade."
pincolor = 'gray'
if len(obs['observation_photos'])>0:
photourl = obs['observation_photos'][0]['photo']['url']
photoattrb = obs['observation_photos'][0]['photo']['attribution']
else:
photourl = "https://upload.wikimedia.org/wikipedia/commons/d/d9/Icon-round-Question_mark.svg"
photoattrb = "no image"
fg.add_child(
folium.Marker(
location=obs['location'],
popup=popup_text.format(
species=obs['species_guess'],
observed_on=obs['observed_on'],
desc=desc,
url = photourl,
attribution = photoattrb),
icon=folium.Icon(color=pincolor),
)
)
folium.LayerControl().add_to(m)
m.fit_bounds(m.get_bounds())
m
```
:::{.aside}
*Legend for the vegetation map*:
```{python}
#| eval: true
display(vegmap_legend)
```
:::
As the iNaturalist project has started recently, there is still a small number of observations that meet the _research quality grade_. Gayini is a great place to observe wildlife, but is also a remote place with few visitor uploading data to iNaturalist.
As the NNTC rangers are trained in the use of the iNat app, we expect to see an increase in the number of records and species detected.
The aim of this code is to be re-used and adapted to track the progress of the project.
## About
### Acknowledgement of country
I acknowledge the Bedegal and Gadigal peoples who are the Traditional Owners of the lands where UNSW Sydney is located, and the Nari-Nari who are the Traditional Custodians of the Gayini Wetlands.
### Acknowledgement of funding
This work was supported by the Ian Potter Foundation and ARDC Gayini TEDISC project.
### This document
This document was created with [quarto](https://quarto.org/), [Jupyter](https://jupyter.org/), [Python](http://python.org), and good quality coffee.
See the code tools in the top right corner of this document for all the source code, and the citation information at the bottom of this document.
### Session information
```{python}
import session_info
session_info.show()
```