Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/release-notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Auto-generate Release Notes

on:
push:
tags:
- 'v*'

permissions:
contents: write
pull-requests: read

jobs:
release-notes:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Generate release notes
id: release_notes
uses: actions/github-script@v7
with:
script: |
const tag = context.ref.replace('refs/tags/', '');
const previousTag = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
}).then(res => res.data.tag_name).catch(() => null);

const compareUrl = previousTag
? `https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${previousTag}...${tag}`
: `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/tag/${tag}`;

const commits = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: previousTag || tag + '^',
head: tag,
}).then(res => res.data.commits).catch(() => []);

let releaseNotes = `## Changes in ${tag}\n\n`;

if (commits.length > 0) {
const features = commits.filter(c => c.commit.message.toLowerCase().includes('feat'));
const fixes = commits.filter(c => c.commit.message.toLowerCase().includes('fix'));
const other = commits.filter(c => !c.commit.message.toLowerCase().includes('feat') && !c.commit.message.toLowerCase().includes('fix'));

if (features.length > 0) {
releaseNotes += '### Features\n';
features.forEach(c => {
const msg = c.commit.message.split('\n')[0];
releaseNotes += `- ${msg} ([${c.sha.substring(0, 7)}](${c.html_url}))\n`;
});
releaseNotes += '\n';
}

if (fixes.length > 0) {
releaseNotes += '### Bug Fixes\n';
fixes.forEach(c => {
const msg = c.commit.message.split('\n')[0];
releaseNotes += `- ${msg} ([${c.sha.substring(0, 7)}](${c.html_url}))\n`;
});
releaseNotes += '\n';
}

if (other.length > 0) {
releaseNotes += '### Other Changes\n';
other.forEach(c => {
const msg = c.commit.message.split('\n')[0];
releaseNotes += `- ${msg} ([${c.sha.substring(0, 7)}](${c.html_url}))\n`;
});
}
}

core.setOutput('release_notes', releaseNotes);

- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: ${{ steps.release_notes.outputs.release_notes }}
draft: false
prerelease: false
39 changes: 39 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Tests

on:
pull_request:
branches: [ main, dev ]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov

- name: Run pytest
run: |
pytest tests/ -v --cov=animl --cov-report=xml --cov-report=term

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
verbose: true
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
examples/
tests/

# Byte-compiled / optimized / DLL files
__pycache__/
Expand All @@ -21,7 +20,6 @@ lib/
lib64/
parts/
sdist/
tests/
var/
wheels/
pip-wheel-metadata/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# animl-py 3.3.0
# animl-py 3.3.1
AniML comprises a variety of machine learning tools for analyzing ecological data. This Python package includes a set of functions to classify subjects within camera trap field data and can handle both images and videos.
This package is also available in R: [animl](https://github.com/conservationtechlab/animl)

Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "animl-lite"
version = "3.3.0"
version = "3.3.1"
description = "Tools for classifying camera trap images"

readme = "README.md"
Expand All @@ -13,8 +13,9 @@ requires-python = ">=3.12,<3.14"
dependencies = [
"numpy>=2.0.2",
"pandas>=2.2.2,<3.0.0",
"pillow>=11.0.0",
"pillow>=12.3.0",
"pyexiftool>=0.5.6",
"pyyaml>=6.0.3",
"opencv-python>=4.12.0.88",
"tqdm>=4.66.5",
]
Expand Down
32 changes: 17 additions & 15 deletions src/animl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = '3.3.0'
__version__ = '3.3.1'

from animl import classification
from animl import detection
Expand Down Expand Up @@ -36,22 +36,24 @@
check_exiftool, check_onnx_cuda, general,
get_onnx_device, get_version, plot_all_bounding_boxes,
plot_box, plot_from_file, softmax, visualization,)
from animl.video_processing import (extract_frames, get_frame_as_image,)
from animl.video_processing import (extract_frames, get_frame_as_image,
get_images, get_videos,)

__all__ = ['IMAGE_EXTENSIONS', 'MD_COLORS', 'MD_LABELS', 'MEGADETECTORv5_SIZE',
'MODEL_TYPES', 'ManifestGenerator', 'SDZWA_CLASSIFIER_SIZE',
'VALID_EXTENSIONS', 'VIDEO_EXTENSIONS', 'WorkingDirectory',
'active_times', 'animlr', 'build_file_manifest', 'check_exiftool',
'check_file', 'check_onnx_cuda', 'class_list_to_dict',
'classification', 'classify', 'compute_batched_distance_matrix',
'compute_distance_matrix', 'cosine_distance', 'detect', 'detection',
'distance', 'euclidean_squared_distance', 'export',
'export_camptrapdp', 'export_camtrapR', 'export_coco',
'export_folders', 'export_megadetector', 'export_timelapse',
'export_yolo', 'extract_frames', 'extract_miew_embeddings',
'file_management', 'from_config', 'from_paths', 'general',
'generator', 'get_animals', 'get_empty', 'get_frame_as_image',
'get_onnx_device', 'get_version', 'inference', 'load_class_list',
'MIEWID_SIZE', 'MODEL_TYPES', 'ManifestGenerator',
'SDZWA_CLASSIFIER_SIZE', 'VALID_EXTENSIONS', 'VIDEO_EXTENSIONS',
'WorkingDirectory', 'active_times', 'animlr', 'build_file_manifest',
'check_exiftool', 'check_file', 'check_onnx_cuda',
'class_list_to_dict', 'classification', 'classify',
'compute_batched_distance_matrix', 'compute_distance_matrix',
'cosine_distance', 'detect', 'detection', 'distance',
'euclidean_squared_distance', 'export', 'export_camptrapdp',
'export_camtrapR', 'export_coco', 'export_folders',
'export_megadetector', 'export_timelapse', 'export_yolo',
'extract_frames', 'extract_miew_embeddings', 'file_management',
'from_config', 'from_paths', 'general', 'generator', 'get_animals',
'get_empty', 'get_frame_as_image', 'get_images', 'get_onnx_device',
'get_version', 'get_videos', 'inference', 'load_class_list',
'load_classifier', 'load_data', 'load_detector', 'load_json',
'load_miew', 'load_yaml', 'manifest_dataloader', 'parse_detections',
'pipeline', 'plot_all_bounding_boxes', 'plot_box', 'plot_from_file',
Expand Down
39 changes: 32 additions & 7 deletions src/animl/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from shutil import copyfile
from typing import Optional
import time
from animl.utils.visualization import MD_LABELS
import numpy as np
import pandas as pd
from pathlib import Path
Expand All @@ -17,7 +16,7 @@

from animl import file_management
from animl.generator import manifest_dataloader
from animl.utils.general import _normalize_boxes, _xyxy2xywh, _scale_letterbox, get_onnx_device
from animl.utils.general import _normalize_boxes, _xyxy2xywh, _scale_letterbox, get_onnx_device, _laplacian_variance
from animl.utils.visualization import MD_LABELS


Expand Down Expand Up @@ -49,6 +48,7 @@ def detect(detector,
letterbox: bool = True,
category_map: Optional[dict] = MD_LABELS,
confidence_threshold: float = 0.1,
calculate_clarity: bool = False,
file_col: str = 'filepath',
checkpoint_path: Optional[str] = None,
checkpoint_frequency: int = -1) -> list[dict]:
Expand All @@ -64,6 +64,7 @@ def detect(detector,
letterbox (bool): if True, resize and pad image to keep aspect ratio, else resize without padding
category_map (dict): mapping of category IDs to human-readable labels
confidence_threshold (float): only detections above this threshold are returned
calculate_clarity (bool): if True, calculate image clarity using Laplacian variance
file_col (str): column name containing file paths
device (str): specify to run on cpu or gpu
checkpoint_path (str): path to checkpoint file
Expand Down Expand Up @@ -152,6 +153,11 @@ def detect(detector,
for _, batch in tqdm(enumerate(dataloader), total=len(manifest)):
count += 1

# handle bad batches (eg. empty images, corrupted files)
if batch is None:
print(f"Warning: batch {count} is None, skipping.")
continue

# ONNX Runtime inference
input_name = detector.get_inputs()[0].name
outputs = detector.run(None, {input_name: batch[0]})[0]
Expand All @@ -160,7 +166,8 @@ def detect(detector,
letterbox,
confidence_threshold=confidence_threshold,
category_map=category_map,
model_type=detector.model_type)
model_type=detector.model_type,
calculate_clarity=calculate_clarity)
# Process outputs to match expected format
results.extend(outputs)

Expand All @@ -181,7 +188,8 @@ def _convert_detections(predictions: list,
letterbox: bool,
confidence_threshold: float = 0.1,
model_type: str = 'megadetector',
category_map: dict = MD_LABELS) -> pd.DataFrame:
category_map: dict = MD_LABELS,
calculate_clarity=False) -> pd.DataFrame:
# Converts output into nested list with categories, conf, and bboxes in expected format for parsing function.
# Supports YOLOv5/MDv5, YOLOv6+, and ONNX models with either relative or absolute bounding box outputs.
# If letterbox=True, rescales bboxes back to original image size.
Expand All @@ -192,7 +200,7 @@ def _convert_detections(predictions: list,
image_frames = batch_from_dataloader[2]
image_sizes = batch_from_dataloader[3]

# if no category map provided, default to MD_LABELS
# if no category map provided, default to MD_LABELS
if category_map is None:
print("No category map provided, defaulting to MD_LABELS. ",
"This may lead to incorrect category labels if using a custom model.")
Expand Down Expand Up @@ -246,6 +254,15 @@ def _convert_detections(predictions: list,
'bbox_y': float(round(bbox[1], 4)),
'bbox_w': float(round(bbox[2], 4)),
'bbox_h': float(round(bbox[3], 4))}

if calculate_clarity:
# calculate image clarity using Laplacian variance
clarity = _laplacian_variance(image_tensors[i].transpose(1, 2, 0))
detection['clarity'] = min(clarity / 500, 1.0)
detection['score'] = (0.4 * detection['conf'] +
0.3 * detection['bbox_w'] * detection['bbox_h'] +
0.3 * detection['clarity'])

detections.append(detection)

data = {'filepath': str(image_paths[i]),
Expand All @@ -261,7 +278,8 @@ def parse_detections(detections: list[dict],
manifest: Optional[pd.DataFrame] = None,
out_file: Optional[str] = None,
threshold: float = 0,
file_col: str = "filepath"):
file_col: str = "filepath",
score: bool = False) -> pd.DataFrame:
"""
Converts listed output from detector to DataFrame.

Expand All @@ -271,6 +289,7 @@ def parse_detections(detections: list[dict],
out_file (str): path to save dataframe
threshold (float): parse only detections above given confidence threshold
file_col (str): if manifest, merge results onto file_col
score (bool): if True, calculate a score for each detection based on confidence, bbox size, and clarity

Returns:
df (pd.DataFrame): formatted md outputs, one row per detection
Expand Down Expand Up @@ -307,6 +326,9 @@ def parse_detections(detections: list[dict],
'category': frame['category'] if 'category' in frame else None,
'category_label': frame['category_label'] if 'category_label' in frame else 'empty',
'conf': None, 'bbox_x': None, 'bbox_y': None, 'bbox_w': None, 'bbox_h': None}
if score:
data['score'] = None
data['clarity'] = None
lst.append(data)

else:
Expand All @@ -322,6 +344,10 @@ def parse_detections(detections: list[dict],
'bbox_y': np.clip(detection['bbox_y'], 0, 1),
'bbox_w': np.clip(detection['bbox_w'], 0, 1),
'bbox_h': np.clip(detection['bbox_h'], 0, 1)}

if score:
data['score'] = detection.get('score', None)
data['clarity'] = detection.get('clarity', None)
lst.append(data)

df = pd.DataFrame(lst)
Expand Down Expand Up @@ -362,7 +388,6 @@ def _save_detection_checkpoint(checkpoint_path: str, results: dict) -> None:
Path(checkpoint_tmp_path).unlink()



def get_animals(manifest: pd.DataFrame):
"""
Pulls MD animal detections for classification
Expand Down
1 change: 0 additions & 1 deletion src/animl/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

@ Kyra Swanson 2023
"""
import json
import os
import pandas as pd
from typing import Optional, Union
Expand Down
11 changes: 5 additions & 6 deletions src/animl/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ def __getitem__(self, idx: int) -> Optional[Tuple[np.ndarray, str, int, np.ndarr
# Normalize
if isinstance(self.normalize, dict):
img_arr = self.Normalize(img_arr,
mean=self.normalize.get("mean", [0.485, 0.456, 0.406]),
std=self.normalize.get("std", [0.229, 0.224, 0.225]))
mean=self.normalize.get("mean", [0.485, 0.456, 0.406]),
std=self.normalize.get("std", [0.229, 0.224, 0.225]))
elif self.normalize is False:
# unnormalize back to [0,255] if needed
img_arr = img_arr * 255.0
Expand All @@ -150,7 +150,7 @@ def extract_frames(self, idx: int, filepath: str) -> Optional[Image.Image]:
cap.release()
cv2.destroyAllWindows()
return img

def _pil_to_numpy_array(self, img: Image.Image) -> np.ndarray:
"""
Convert PIL RGB image to numpy array with shape (C, H, W), dtype float32 scaled to [0,1].
Expand All @@ -163,7 +163,7 @@ def _pil_to_numpy_array(self, img: Image.Image) -> np.ndarray:
arr = arr / 255.0
return arr

def Letterbox(self,
def Letterbox(self,
resize_height: int,
resize_width: int,
image: Image.Image) -> Image.Image:
Expand Down Expand Up @@ -196,8 +196,7 @@ def Letterbox(self,

return padded.resize((target_w, target_h), Image.BILINEAR)


def Normalize(self,
def Normalize(self,
img: np.ndarray,
mean: Sequence[float],
std: Sequence[float],
Expand Down
Loading
Loading