Introduction

Hunters and conservationists alike delight in understanding the health and wellness of the wildlife around them. Since capturing animals in the wild can prove difficult, tracking wellness of species and individuals is typically performed by photographing and diagnosing images of the wildlife. From the images, the age, stature, and wellness of different species can be discerned by wildlife experts.

Understanding the data

Ironically, there are thousands of trail camera images taken by hunters across the United States each year, but very few of them are seen by deer aging professionals. Of the images that are seen, even fewer see the light of day online or in publications. To alleviate the data drought, images used in this project are taken from a wide variety of sources including the National Deer Association (NDA), Field & Stream (F&S), state agencies, and other conservation resources.

In each case, the image of a male whitetail deer is captured along with the professional estimate of the deer’s age. Age and other information is stored in the image’s metadata, and ultimately used to create the truth labels within the convolution neural network (CNN) model. Although using images from a single source (ex. NDA) would ensure consistency across the panel of experts, utilization of multiple sources quickly buys us more data and allows for a wider swath of professionals to weigh in on the variables within an image that indicate the deer’s age.

Aspects of Aging

Interestingly, deer ages are always estimated in half years (ex. 1.5 years, 2.5 years, etc.) due to the life cycle of deer in hunting. Deer are typically born in the spring and harvested in the fall (deer hunting season), roughly half-way through their current age year. Newborns (fawns) are easy to identify — they are small, awkward looking, and have spots on their sides for the first 3-4 months of their lives. Since many hunters are interested in aging deer beyond the newborn stage, many images will feature deer at 1.5 year or older. Furthermore, the average lifetime of a deer in the wild ranges between three to six years. Taken together, we expect the labels for our supervised learning to lie within the set of [1.5, 2.5, 3.5, 4.5, 5.5] years, a very small discrete set of “correct” answers.

Along with each age ruling, the NDA provides a bit of justification; although not directly applicable to the ML model, their insights are helpful in building intuition into which features or patterns the ML model may eventually learn. Much like humans, each deer’s body grows and changes with age. For comparison, I’ve included a general comparison of features that are seen on young bucks versus mature males.

FeatureYoungMature
Hind quarter widthThinWide
BellyAbove the brisketBelow the brisket
Muscular definitionLittle definitionSignificant definition
Antler spreadWidth of the earsWider than the ears
Tine lengthShortLong
Relative leg lengthLongShort
Neck widthThinWide
Table 1. Feature comparison of young male deer versus mature male deer.

Understanding the data

Image Standardization

Trail cameras are often equipped with two viewing modes, “daytime” and “nighttime”. The daytime mode captures colored images containing red, green, and blue (RGB) pixel values, while the nighttime mode captures infrared grayscale images in grayscale. Furthermore, trail cameras are not created equal — they differ in their aspect ratios, pixel resolution, memory, motion sensitivity, and other features.

Before we expose our machine learning (ML) algorithm to the dataset, we want to make sure the dataset is as consistent as possible. Specifically, we want to remove variation between images (aspect ratio, coloration, etc.) within our dataset, so we begin by cropping out additional information in each picture. For landscape imagery, this means removing data tags at the bottom (the same information is stored in the filename anyways) and cropping dead space out of the image. If necessary, the still-rectangular image is combined with a white square to make the final image square, as shown in Figure 1.

Figure 1. Pre-conditioning the images

Once shaped, each image is stored with a five-part filename characterizing the image. Each image is assigned a filename of the format XXXXXX_ZZZZZZ_SS_NpN_P, where XXXXXX and ZZZZZZ denote the date the image was collected and the date the image was taken. Both dates are of the format YYMMDD (ex. March 31, 2025 would be represented as 250331). SS denotes the state the image was taken in (ex. Kentucky would be represented as KY), and NpN stands for the age of the deer (ex. 3p5 indicates an age of 3.5 years). Lastly, P represents the provider name (ex. “RLT” for Realtree, “NDA” for National Deer Association, etc.). Our images are now ready to be ingested.

Image Ingestion / formatting

We begin by using glob to identify portable networks graphics (PNG) files within the image database, and we eliminate datafiles with no associated age (ex. files ending in NpN); this occurs when an image is gathered from one source (ex. NDA), but has not had its age revealed.

from glob import glob
from generic.analysis.basics import extract_labels
from generic.analysis.basics import ingest_resize_stack

# Find/ingest files in folder; force square & b/w
files = glob("..\\images\\squared\\*.png")
files = [s for s in files if "xpx" not in s]
print(len(files), "images found")

# Ingest images
images = ingest_resize_stack(files)
_,_,_,ages,_ = extract_labels(files)
print('Sample size:',images.shape)

The identified valid files are ingested and stacked in a 3D array via ingest_resize_stack, and the deer ages are extracted from the respective filenames. The output, images.shape displays the number of images we’ve collected for our ML model. At the time of writing, this returns:

40 images found
Sample size: (40, 288, 288)

Which tells us we have 40 individual images, each image reshaped and interpolated to a 288 x 288 two-dimensional image. Ideally, we would like the dataset to consist of thousands of images but this relates to the problem of availability we discussed earlier: many images of deer exist on trail cameras, but very few of those images have been analyzed by wildlife experts to determine the likely age of the deer. Furthermore, there is a natural “maximum” for which most deer experts agree; although whitetail deer have been known to live as long as 22 years, many experts label any length after 5.5 years as “mature” and move on.

import keras
import numpy as np
from sklearn.model_selection import train_test_split

# Convert labels to integers from 0 to 5 for proper one-hot encoding
# Create a mapping from your floating-point labels to integers
label_mapping = {label: i for i, label in enumerate(np.unique(labels))}
print("Label mapping:", label_mapping)

# Apply the mapping to convert labels to integers
integer_labels = np.array([label_mapping[l] for l in labels])
print("Converted labels:", integer_labels)

# Use a regular train_test_split without stratification
X_train, X_test, y_train, y_test = train_test_split(
    images, integer_labels, test_size=0.2, random_state=42
)

# Check the distribution after splitting
print("\nTraining set label distribution:")
unique_train_labels = np.unique(y_train)
for label in unique_train_labels:
    count = np.sum(y_train == label)
    print(f"Label {label} ({list(label_mapping.keys())[list(label_mapping.values()).index(label)]}): {count} samples ({count/len(y_train)*100:.2f}%)")

print("\nTest set label distribution:")
unique_test_labels = np.unique(y_test)
for label in unique_test_labels:
    count = np.sum(y_test == label)
    print(f"Label {label} ({list(label_mapping.keys())[list(label_mapping.values()).index(label)]}): {count} samples ({count/len(y_test)*100:.2f}%)")

(in progress…)

# Normalize the images
X_train = X_train.astype("float32") / 255.0
X_test = X_test.astype("float32") / 255.0

# One-hot encode labels with the correct number of classes
num_classes = len(label_mapping)
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)

# Create a validation set (without stratification)
X_train, X_valid, y_train, y_valid = train_test_split(
    X_train, y_train, test_size=0.2, random_state=42
)

# Reshape data to add channel dimension
X_train = X_train.reshape(X_train.shape[0], 288, 288, 1)
X_valid = X_valid.reshape(X_valid.shape[0], 288, 288, 1)
X_test = X_test.reshape(X_test.shape[0], 288, 288, 1)

print(X_train.shape[0], "train samples")
print(X_test.shape[0], "test samples")
print(X_valid.shape[0], "validation samples")

(in progress…)

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout

# First, let's check how many unique classes you actually have
num_classes = len(np.unique(labels))
print(f"Number of unique classes: {num_classes}")

# Make sure your final Dense layer matches this number
model = Sequential()
model.add(Conv2D(filters=16, kernel_size=3, padding='same', activation='relu', input_shape=(288, 288, 1)))
model.add(MaxPooling2D(pool_size=2))
model.add(Conv2D(filters=32, kernel_size=2, padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=2))
model.add(Conv2D(filters=64, kernel_size=2, padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=2))
model.add(Dropout(0.3))
model.add(Flatten())
model.add(Dense(500, activation='relu'))
model.add(Dropout(0.4))
# Change this line to match your actual number of classes (6)
model.add(Dense(6, activation='softmax'))  # Change from 10 to 6
model.summary()

(in progress…)

model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])

(in progress…)

from keras.callbacks import ModelCheckpoint

checkpointer = ModelCheckpoint(filepath='model.weights.best.hdf5.keras', verbose=1, save_best_only=True)
hist = model.fit(X_train, y_train, batch_size=5, epochs=100, validation_data=(X_valid,y_valid), callbacks=[checkpointer], verbose=4, shuffle=True)

(in progress…)

from keras.callbacks import ModelCheckpoint

checkpointer = ModelCheckpoint(filepath='model.weights.best.hdf5.keras', verbose=1, save_best_only=True)
hist = model.fit(X_train, y_train, batch_size=5, epochs=100, validation_data=(X_valid,y_valid), callbacks=[checkpointer], verbose=4, shuffle=True)

(in progress…)

Conclusion

(In progress…)