! pip install galah-python speciesnet Pillow natsort matplotlib pathlib session_info --quietWildlife monitoring at scale is one of ecology’s most data-intensive challenges. Camera traps deployed across remote landscapes can accumulate thousands of images in a single survey season — far more than any team can manually review in a reasonable time. Automatically identifying the species in each photograph would free researchers to focus on analysis rather than image sorting, but doing this accurately requires a model trained on a large and diverse set of wildlife images.
This post shows how to combine two open tools to tackle this problem: the Atlas of Living Australia (ALA), Australia’s national biodiversity data platform, and SpeciesNet, a deep learning model developed by Google specifically for wildlife image classification. We query ALA for camera-trap images of wild cats (Felis catus) and feral pigs (Sus Scrofa) in Queensland using {galah-python} and download a subset of images. Once the images are downloaded, we will run the SpeciesNet package ({speciesnet}) to automatically identify animals in each image, and then visualise the results with the {Pillow} package.
Running this script for the first time may require you to download these packages.
Download images
The Atlas of Living Australia (ALA) is Australia’s national biodiversity data infrastructure, aggregating over 180 million occurrence records from museums, herbaria, citizen science platforms (e.g. iNaturalist), and government surveys. Many of these records include photographs taken in the field — including images from camera-trap deployments.
In this example, we will be downloading images of wild cats (Felis catus) and feral pigs (Sus Scrofa), two widespread invasive species in Australia.
Create a query filtered for camera trap images
We can use the galah-python package to download ALA data in Python and retrieve image metadata for our target species. galah requires a registered ALA email address in order to download data. Registration is free at ala.org.au.
If you want to run this code multiple times with different parameters, and want a straightforward way to remove images from your download folder, this function will remove them from your image folder.
from pathlib import Path
def empty_directory(directory: Path):
"""Delete all files and sub-directories inside *directory* without
removing the directory itself. Creates it if it does not yet exist."""
if not directory.exists():
directory.mkdir(parents=True)
return
for item in directory.iterdir():
if item.is_file() or item.is_symlink():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)import galah
galah.galah_config(
atlas="Australia", # Australia is the default atlas
email="your-email@example.com" # ← replace with ALA-registered email
)Let’s first save the scientific names of wild pigs (Sus scrofa) and cats (Felis catus) in a list named taxa.
taxa = ['Felis catus', 'Sus scrofa']Before fetching metadata and downloading images, it is worth checking how many image records exist for wild pigs and feral cats in Queensland. This will give us a sense of dataset size so we can decide whether to narrow our query further.
galah.atlas_counts(
taxa=taxa,
filters=["stateProvince=Queensland"],
group_by="multimedia"
)| multimedia | count | |
|---|---|---|
| 0 | Image | 3388 |
Our result tells us there are more than 3,000 available images. However, not all images stored on the ALA are equally suitable for SpeciesNet, as SpeciesNet is suited to camera-trap images and not field photos, museum specimens, or heavily cropped images. Here, we can add a field to the group_by argument called dataResourceName to return the name of the source of each image. With this information, we can determine which datasets contain suitable camera trap images.
galah.atlas_counts(
taxa=taxa,
filters=["stateProvince=Queensland"],
group_by=["multimedia","dataResourceName"]
)| multimedia | dataResourceName | count | |
|---|---|---|---|
| 0 | Image | Camera trap surveys in Queensland's Wet Tropic... | 2508 |
| 1 | Image | iNaturalist Australia | 854 |
| 2 | Image | Earth Guardians Weekly Feed | 17 |
| 3 | Image | ALA species sightings and OzAtlas | 5 |
| 4 | Image | NatureMapr | 3 |
| 5 | Image | BowerBird | 1 |
Our result tells us that one dataset - Camera trap surveys in Queensland's Wet Tropics 2022-2023 - contains camera trap images. Let’s specify this dataset in a filter. For example, purposes, we’ll also restrict our query to see how images captured on December 17, 2022.
galah.atlas_counts(
taxa=taxa,
filters=["stateProvince=Queensland",
"multimedia=Image",
"dataResourceName=Camera trap surveys in Queensland's Wet Tropics 2022-2023",
"year=2022",
"month=12",
"day=17"]
)| totalRecords | |
|---|---|
| 0 | 4 |
Download images
Now that we have a small subset of images, we can retrieve image metadata as well as download the images using galah.atlas_media(). This function returns a table of records that includes the image URL, scientific name, data resource name, and observation coordinates for every matching record.
media_df = galah.atlas_media(
taxa=taxa,
filters=["stateProvince=Queensland",
"multimedia=Image",
"dataResourceName=Camera trap surveys in Queensland's Wet Tropics 2022-2023",
"year=2022",
"month=12",
"day=19"],
collect=False
)
len(media_df)13
The results show that there are several available images on the ALA, with some records containing multiple images each. If we are happy with this result, we can now set collect=True to download these images and specify that downloads are saved in the EC_images folder.
media_df = galah.atlas_media(
taxa=taxa,
filters=["stateProvince=Queensland",
"multimedia=Image",
"dataResourceName=Camera trap surveys in Queensland's Wet Tropics 2022-2023",
"year=2022",
"month=12",
"day=19"],
collect=True,
path="EC_images",
progress_bar=False # set to True to see progress bar
)Media written to EC_images
Preview images
Before running the model, let’s display the downloaded images as a grid to confirm they look as expected. This is a good moment to spot any blank frames, setup photos, or corrupted files that might produce unreliable predictions and should be excluded before analysis.
import matplotlib.pyplot as plt
%matplotlib inline
from PIL import Image
from pathlib import Path
# set a path to images
image_folder = Path("EC_images")
image_paths = sorted(image_folder.glob("*.jpg"))
print(f"Found {len(image_paths)} images.")
# choose how many images to show and the dimensions of the figure
n_show = min(len(image_paths), 15) # max 15 images
fig, axes = plt.subplots(7, 2, figsize=(8, 12))
# loop over all images to show them
for ax, img_path in zip(axes.flatten(), image_paths[:n_show]):
img = Image.open(img_path)
ax.imshow(img)
ax.set_title(img_path.name, fontsize=10)
ax.axis("off")
# Hide unused subplot panels
for ax in axes.flatten()[n_show:]:
ax.set_visible(False)
# add title and change layout
plt.suptitle("Downloaded images from ALA", fontsize=16, y=1.01)
plt.tight_layout()
plt.show()Found 13 images.
Run SpeciesNet to identify species
SpeciesNet is a deep learning model developed by Google for automated wildlife identification in camera-trap images. It was trained on over 65 million camera-trap images from the Wildlife Insights platform, making it one of the most extensively trained wildlife classifiers available.
The model works in two stages. First, a detector scans the image and draws a bounding box around any animal it finds, along with a confidence score indicating how certain it is that an animal is present (0 for no confidence, 1 for complete confidence). Second, a classifier examines the content inside the bounding box — or the full image if no box was found — and assigns the most likely species label from a vocabulary of over 2,000 labels. These labels span individual species (e.g. Sus scrofa, feral pig), broader taxonomic groups (e.g. Felidae, or the cat family), and non-animal classes (blank, vehicle, human).
Load the model
The cell below loads the SpeciesNet library and SpeciesNet. DEFAULT_MODEL is the recommended choice for most use cases.The first time it runs, it will download the model weights (approximately 1–2 GB), which can take a few minutes. Subsequent runs in the same session use the cached parameteres instantly.
SpeciesNet includes a geofencing step that filters out predictions for species known not to occur in a given country. For example, if we supply country="AUS", the model will not predict “lion” or “elephant” for an Australian image, even if the raw classifier score for those labels is high. Geofencing is enabled by default and is strongly recommended whenever you are working within a defined geographic region — it meaningfully reduces false positives.
from speciesnet import DEFAULT_MODEL, SUPPORTED_MODELS
from speciesnet import draw_bboxes, load_rgb_image, SpeciesNet
model = SpeciesNet(DEFAULT_MODEL) # geofencing ON (default)Run predictions
Pass the image folder to model.predict(). SpeciesNet will find every .jpg, .jpeg, and .png file in the folder, run the detector and classifier on each one, and return a dictionary of results—one entry per image.
Each prediction is a semicolon-separated string with 7 fields:
1b30ddf8-22fb-40ff-9df6-6a8a0b6ccaa1 ; mammalia ; monotremata ; tachyglossidae ; tachyglossus ; aculeatus ; short-beaked echidna
UUID; Class; Order; Family; Genus; Species; Common name
- UUID — unique label identifier in the SpeciesNet taxonomy
- Class → Species — full taxonomic hierarchy from class down to species epithet
- Common name — plain-language species name (last field, easiest to read)
If the model is not confident about a species, it may predict at a higher taxonomic level (e.g. "felidae;;; cat family" with blank genus/species fields).
First we’ll define a small helper function that prints predictions in a readable format. Our defined output will return:
- The complete taxonomic results of the model’s best guess to the species level, and its prediction score for that best guess
- The final prediction of the model, which can be a higher taxonomic level
- The method the model chose to make this final prediction
def print_predictions(predictions_dict: dict) -> None:
"""Print a human-readable summary of SpeciesNet predictions."""
# declare column names and initialise a dictionary for data - this will be used to create a pandas dataframe
column_names = ["Image ID","Class","Order","Family","Genus","Species","Species Epithet","Prediction Score", "Final Prediction", "Final Prediction Method"]
dict_for_table = {y: [None for x in range(len(predictions_dict["predictions"]))] for y in column_names}
# loop over all predictions to save them into dictionary
for i,prediction in enumerate(predictions_dict["predictions"]):
classifications = prediction.get("classifications")
if classifications and classifications.get("scores"):
dict_for_table["Prediction Score"][i] = classifications["scores"][0]
if prediction.get("prediction"):
dict_for_table["Final Prediction"][i] = prediction["prediction"].rsplit(';', 1)[-1]
dict_for_table["Final Prediction Method"][i] = prediction.get("prediction_source")
if classifications and classifications.get("classes"):
id_and_classification = classifications["classes"][0].split(";")
for j,entry in enumerate(id_and_classification):
dict_for_table[column_names[j]][i] = entry
# convert dictionary into dataframe and print resulting dataframe
df = pd.DataFrame(dict_for_table)
print(df)Then we can run model.predict() and interpret the results using our helper function.
predictions_dict = model.predict(folders=[image_folder])
print_predictions(predictions_dict=predictions_dict) Image ID Class Order Family Genus Species Species Epithet Prediction Score Final Prediction Final Prediction Method
0 f4d0d1cd-61f8-4f08-ab8e-e2edc1672231 mammalia artiodactyla cervidae muntiacus vuquangensis large-antlered muntjac 0.205733 artiodactyla order classifier+rollup_to_order
1 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.962340 wild boar classifier
2 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.621680 suidae family classifier+rollup_to_family
3 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.984189 wild boar classifier
4 3d80f1d6-b1df-4966-9ff4-94053c7a902a mammalia carnivora canidae canis familiaris domestic dog 0.437872 mammal classifier+rollup_to_class
5 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.867206 wild boar classifier
6 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.994094 wild boar classifier
7 f1856211-cfb7-4a5b-9158-c0f72fd09ee6 blank 0.718439 no cv result classifier
8 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.655761 wild boar classifier
9 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.990173 wild boar classifier
10 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.404250 artiodactyla order classifier+rollup_to_order
11 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.990917 wild boar classifier
12 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.985287 wild boar classifier
Instead of running predictions on a whole folder, we can also pass a list of specific file paths. This is useful if we want to quickly test the model on a single image or a hand-picked selection without re-processing the entire folder. Here we’ve extracted all image paths in our folder that end with .jpg, .jpeg or .png, then we selected the first 2 paths to run our model on.
image_paths = sorted(
p for p in image_folder.iterdir()
if p.suffix.lower() in [".jpg", ".jpeg", ".png"]
)
predictions_dict = model.predict(filepaths=[image_paths[0], image_paths[1]])
print_predictions(predictions_dict) Image ID Class Order Family Genus Species Species Epithet Prediction Score Final Prediction Final Prediction Method
0 f4d0d1cd-61f8-4f08-ab8e-e2edc1672231 mammalia artiodactyla cervidae muntiacus vuquangensis large-antlered muntjac 0.205732 artiodactyla order classifier+rollup_to_order
1 d372cda5-a8ca-4b7b-97ed-4e4fab9c9b4b mammalia artiodactyla suidae sus scrofa wild boar 0.962340 wild boar classifier
Visualise results
The cell below displays each image with its predictions overlaid. Where the detector found an animal, a red bounding box is drawn around it. The detector’s confidence score appears in the top-left corner of the box (e.g. animal: 0.88), and the classifier’s species label with its confidence score is shown in the bottom-right corner (e.g. domestic cat: 0.93). The exact labels and scores we see will depend on the images and taxa.
Where the detector found nothing — which can happen with blurry, fast-moving, or partially visible animals — the classifier’s prediction is shown as a banner at the bottom of the image. The classifier still runs on the full image in these cases, so a species label is always produced.
%matplotlib inline
from PIL import ImageDraw, ImageFont
# Run SpeciesNet on all images in the folder
predictions_dict = model.predict(folders=[image_folder])
# Load a font
font = ImageFont.load_default(size=16)
# set plot parameters
n_show = min(len(image_paths), 15) # max of 15 images
fig, axes = plt.subplots(5, 3, figsize=(16, 20))
# loop over all images to display
for ax,pred_item in zip(axes.flatten(),predictions_dict["predictions"][:n_show]):
# set some info for the individual image
fname = Path(pred_item["filepath"]).name
pred_text = pred_item.get("prediction", "")
detections = pred_item.get("detections", [])
# Split the prediction string by a semicolon to get the common name for labelling
# UUID ; class ; order ; family ; genus ; species ; common-name
species_name = pred_text.split(";")[-1] if pred_text else "unknown"
# Classifier confidence: classifications = {"classes": [...], "scores": [...]}
# scores[0] is the top prediction's confidence (0–1)
scores = pred_item.get("classifications", {}).get("scores", [])
conf = scores[0] if scores else None
species_label = f"{species_name}: {conf:.2f}" if conf is not None else species_name
# load the image and start creating the title text
img = load_rgb_image(pred_item["filepath"])
img.thumbnail(size=(800, 800)) # try this
img_title = f"File: {img_path.name}\nClassification: {species_label}\nPrediction Score: {conf}"
# if speciesnet has detected an animal, draw bounding boxes around the detection
if detections:
# draw_bboxes draws a red box + "animal: conf" label for each detection.
# It returns a NEW annotated image — the return value must be captured.
img = draw_bboxes(img, detections)
# Overlay the classifier's species label at the bottom-right of each box
draw = ImageDraw.Draw(img)
for det in detections:
xmin, ymin, bw, bh = det["bbox"]
x_right = int((xmin + bw) * img.width)
y_bottom = int((ymin + bh) * img.height)
x1, y1, x2, y2 = draw.textbbox((0, 0), species_label, font=font)
x_px = x_right - (x2 - x1) # right-align to box edge
y_px = y_bottom - (y2 - y1) # bottom-align to box edge
draw.rectangle([x_px - 3, y_px - 3, x_right + 3, y_bottom + 3], fill=(0, 0, 0))
draw.text((x_px, y_px), species_label, fill=(255, 255, 0), font=font)
img_title += (f"\n{len(detections)} bounding box(es) drawn.")
else:
# No detection: the animal was not located above the detector's confidence
# threshold (common with motion blur or partially visible subjects).
# Draw the classifier result as a banner at the bottom of the image.
draw = ImageDraw.Draw(img)
w, h = img.size
img_title += f"Classifier: {species_label} (no detection bbox)"
draw.rectangle([0, h - 30, w, h], fill=(0, 0, 0))
draw.text((6, h - 24), banner, fill=(255, 255, 255), font=font)
print(" No bounding boxes — classifier label shown as banner.")
ax.imshow(img)
ax.set_title(img_title, fontsize=10)
ax.axis("off")
for ax in axes.flatten()[n_show:]:
ax.set_visible(False)
plt.suptitle("Image Classification and Prediction", fontsize=20, y=1.01)
plt.tight_layout()
plt.show()