Automated wildlife identification from ALA camera-trap images using SpeciesNet

The process of detecting and identifying organisms in camera trap images is a common task in wildlife monitoring. Here, we show how to use a species identification model, SpeciesNet, to detect and classify images downloaded from the Atlas of Living Australia with {galah-python} and {speciesnet}.

Eukaryota
Animalia
Mammalia
Summaries
Python
Authors

Dr Renuka Sharma

Xiang Zhao

Published

July 31, 2026

Author

Dr Renuka Sharma
Xiang Zhao

Date

31 July 2026

Wildlife 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.

! pip install galah-python speciesnet Pillow natsort matplotlib pathlib session_info --quiet

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()

Interpreting the results

Confidence scores represent how certain the model is about a prediction, on a scale of 0 to 1. A score of 0.93 means the model assigns 93% of its probability mass to that label. As a rough guide:

  • > 0.9 — high confidence; likely a reliable prediction
  • 0.6–0.9 — moderate confidence; worth a visual check
  • < 0.6 — low confidence; treat with caution

When the model is uncertain at the species level, it often predicts at a higher taxonomic rank instead—for example, predicting the order "Artiodactyla" rather than Sus scrofa. This is a deliberate and sensible fallback: a broad but correct label is more useful than a confident but wrong species name.

What our results show

Most of the images in this run are identified as Sus scrofa (feral pig) in the ALA, but SpeciesNet returned several other species-level predictions: large-antlered muntjac and domestic dog. This is instructive.

Two of our three species-level predictions—wild boar and large-antlered munjac—belong to the same order, Artiodactyla. The third prediction of domestic dog belongs to the same class, Mammalia. Despite varied species-level guesses, the model identified when it could not confidently distinguish between species in that group and appropriately abstained from a species-level call as the final prediction, demonstrating the model’s graceful degradation under uncertainty.

These predictions illustrate an important point: SpeciesNet’s output should be treated as a probabilistic first-pass label, not a definitive identification. For research applications, predictions—especially those below ~0.9 confidence or at a higher taxonomic level—benefit from expert validation or a second-pass review.

Limitations of our results

  • Image quality matters. SpeciesNet was trained on camera-trap images and performs best on those. Images taken at night, in heavy rain, or with severe motion blur will produce lower-confidence and less reliable predictions. We can see this in some of our image prediction results.
  • The detector and classifier are independent. The detector can miss an animal and still have the classifier produce a correct species label and vice versa. Always look at both the bounding box and the prediction string together.
  • Closely related species are harder to distinguish. Species within the same family or order (e.g. Sus scrofa vs Sus barbatus) are more likely to be confused with each other than with distantly related animals.
  • “Blank” predictions are useful. If SpeciesNet predicts blank, neither the detector nor the classifier found strong evidence of an animal—valuable for automatically filtering empty frames from large datasets. However, as we saw in our results, the model can still get this wrong!
  • Geofencing can suppress correct predictions. If a species is present but outside its expected geographic range (e.g. an escaped or introduced animal), geofencing may filter it out. Disable geofencing with geofence=False in those cases.

SpeciesNet’s classifier and detector can be called independently, which can be a faster alternative when we don’t need both processes. Running the classifier without the detector produces a species label, but gives no information about where the animal is located in the frame. This is useful for large-scale screening where location is not needed. Alternatively, running the detector without the classifier returns bounding boxes and confidence scores for any animal found, but does not identify the species—each detection is simply labelled "animal". This is useful for quickly checking occupancy (is an animal present?) or filtering blank frames, without the computational overhead of species classification.

Classifier only with model.classify():

# Classifier only — no bounding boxes, just species labels for the full image
predictions_dict = model.classify(filepaths=[image_paths[0], image_paths[1]])

print("Classifier-only results (top prediction per image):")
print_predictions(predictions_dict=predictions_dict)
Classifier-only results (top prediction per image):
                               Image ID     Class          Order        Family     Genus  Species Species Epithet  Prediction Score Final Prediction Final Prediction Method
0  f1856211-cfb7-4a5b-9158-c0f72fd09ee6                                                                     blank          0.968359             None                    None
1  3afdd398-39f2-430c-9301-74659d81044e  mammalia  diprotodontia  macropodidae  wallabia  bicolor   swamp wallaby          0.450076             None                    None

Detector only with model.detect():

# Detector only — returns bounding boxes with confidence scores, no species labels
predictions_dict = model.detect(filepaths=[image_paths[0], image_paths[1]])

print("Detector-only results (bounding boxes per image):")
for pred in predictions_dict["predictions"]:
    fname      = Path(pred["filepath"]).name
    detections = pred.get("detections", [])
    if detections:
        for d in detections:
            print(f"  {fname}  =>  {d['label']}  conf: {d['conf']:.2f}  bbox: {d['bbox']}")
    else:
        print(f"  {fname}  =>  no detections above threshold")

# Uncomment to see the full raw output:
# display(JSON(predictions_dict))
Detector-only results (bounding boxes per image):
  0223bf6e-f15b-44ba-b881-286a58a41116.jpg  =>  animal  conf: 0.94  bbox: [0.422805055975914, 0.36772488057613373, 0.181733638048172, 0.1964285671710968]
  0a36189d-df6d-4b29-b1c2-ffad1a084c0d.jpg  =>  animal  conf: 0.96  bbox: [0.37939453125, 0.3945312574505806, 0.203125, 0.1608072966337204]

We suggest using geofencing, but it can be useful to disable geofencing if you are working with images from multiple countries in a single batch, or you we want to inspect the model’s raw confidence scores before geographic filtering. To disable geofencing entirely, reload the model with geofence=False:

model_no_geo = SpeciesNet(DEFAULT_MODEL, geofence=False)
predictions_dict = model_no_geo.predict(folders=[image_folder])

Final thoughts

This notebook has shown how to use Google’s SpeciesNet model with data from the Atlas of Living Australia to automatically identify animal species in camera-trap images. Starting from a simple species query, we retrieved image records from ALA, downloaded a sample, and ran SpeciesNet to produce species labels and bounding boxes for each photograph.

Automated image classification won’t replace expert review for all use cases, but it can dramatically reduce the manual effort involved in processing large camera-trap datasets—particularly for filtering blank frames, flagging images likely to contain a species of interest, or generating a first-pass label for subsequent expert validation.

To take this further, try:

  • Replacing the taxa or geographic filter to work with a different study system
  • Create a function based on print_predictions that returns a CSV instead; you can then write to a CSV with prections_dataframe.to_csv("results.csv")
  • Joining predictions back to subset_df on the image filename to add location, date, and data-resource context

Resources: