From b295cf9372225ef56f51fb6faa8cbad7ea60e271 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:27:13 +0000 Subject: [PATCH 1/7] Initial plan From f16ea03c3ef729daef5a5b62462db553e8c6f81b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:30:19 +0000 Subject: [PATCH 2/7] Add tests for video processing, detection, and classification Co-authored-by: tkswanson <4371698+tkswanson@users.noreply.github.com> --- tests/conftest.py | 38 ++++++ tests/test_classification.py | 213 +++++++++++++++++++++++++++++++ tests/test_detection.py | 224 +++++++++++++++++++++++++++++++++ tests/test_video_processing.py | 142 +++++++++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 tests/test_classification.py create mode 100644 tests/test_detection.py create mode 100644 tests/test_video_processing.py diff --git a/tests/conftest.py b/tests/conftest.py index 9998d67..09fca0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,44 @@ def _install_optional_stubs() -> None: + try: + import cv2 # noqa: F401 + except Exception: + cv2 = types.ModuleType("cv2") + cv2.CAP_PROP_FRAME_COUNT = 7 + cv2.CAP_PROP_FPS = 5 + cv2.CAP_PROP_POS_FRAMES = 1 + cv2.COLOR_BGR2RGB = 4 + + class _FakeCapture: + def __init__(self, *args, **kwargs): + self._opened = True + + def isOpened(self): + return self._opened + + def get(self, prop): + if prop == cv2.CAP_PROP_FRAME_COUNT: + return 30 + if prop == cv2.CAP_PROP_FPS: + return 10.0 + return 0 + + def set(self, prop, val): + pass + + def read(self): + frame = np.zeros((24, 32, 3), dtype=np.uint8) + return True, frame + + def release(self): + pass + + cv2.VideoCapture = _FakeCapture + cv2.cvtColor = lambda img, code: img + cv2.destroyAllWindows = lambda: None + sys.modules["cv2"] = cv2 + try: import onnxruntime # noqa: F401 except Exception: diff --git a/tests/test_classification.py b/tests/test_classification.py new file mode 100644 index 0000000..24d6432 --- /dev/null +++ b/tests/test_classification.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from animl.classification import ( + classify, + load_class_list, + load_classifier, + single_classification, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def class_list_file(tmp_path: Path) -> Path: + f = tmp_path / "classes.csv" + f.write_text("class\njaguar\nocelot\npuma\n") + return f + + +@pytest.fixture +def class_list_df() -> pd.DataFrame: + return pd.DataFrame({"class": ["jaguar", "ocelot", "puma"]}) + + +@pytest.fixture +def animals_df(tmp_path: Path) -> pd.DataFrame: + from PIL import Image + + img = tmp_path / "animal.jpg" + Image.new("RGB", (32, 32), color=(100, 50, 10)).save(img) + return pd.DataFrame( + { + "filepath": [str(img)], + "frame": [0], + "conf": [0.9], + "category": [1], + "category_label": ["animal"], + "max_detection_conf": [0.9], + "bbox_x": [0.1], + "bbox_y": [0.1], + "bbox_w": [0.5], + "bbox_h": [0.5], + } + ) + + +@pytest.fixture +def empty_df() -> pd.DataFrame: + return pd.DataFrame( + { + "filepath": ["empty.jpg"], + "frame": [0], + "conf": [None], + "category": [0], + "category_label": ["empty"], + "max_detection_conf": [None], + "prediction": ["empty"], + "confidence": [1.0], + } + ) + + +# --------------------------------------------------------------------------- +# load_class_list +# --------------------------------------------------------------------------- + +def test_load_class_list_returns_dataframe(class_list_file: Path): + df = load_class_list(str(class_list_file)) + assert isinstance(df, pd.DataFrame) + assert "class" in df.columns + assert len(df) == 3 + + +def test_load_class_list_raises_for_missing_file(tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_class_list(str(tmp_path / "nonexistent.csv")) + + +# --------------------------------------------------------------------------- +# load_classifier +# --------------------------------------------------------------------------- + +def test_load_classifier_raises_for_missing_model(tmp_path: Path): + with pytest.raises(Exception): + load_classifier(str(tmp_path / "nonexistent.onnx")) + + +def test_load_classifier_returns_model_and_class_list(tmp_path: Path, class_list_file: Path): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, class_list = load_classifier(str(model_file), classes=str(class_list_file)) + assert model is not None + assert model.model_type == "classifier" + assert class_list is not None + + +def test_load_classifier_accepts_dataframe_classes(tmp_path: Path, class_list_df: pd.DataFrame): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, class_list = load_classifier(str(model_file), classes=class_list_df) + assert class_list is not None + + +def test_load_classifier_no_classes_returns_none_class_list(tmp_path: Path): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, class_list = load_classifier(str(model_file)) + # class list is None when no classes arg and no metadata + assert class_list is None + + +# --------------------------------------------------------------------------- +# classify +# --------------------------------------------------------------------------- + +def test_classify_returns_numpy_array(tmp_path: Path, animals_df: pd.DataFrame): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + result = classify(model, animals_df, resize_width=32, resize_height=32, crop=False) + assert isinstance(result, np.ndarray) + + +def test_classify_raises_on_invalid_input(tmp_path: Path): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + with pytest.raises(AssertionError): + classify(model, 12345) + + +def test_classify_accepts_string_filepath(tmp_path: Path, animals_df: pd.DataFrame): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + filepath = animals_df["filepath"].iloc[0] + result = classify(model, filepath, resize_width=32, resize_height=32) + assert isinstance(result, np.ndarray) + + +def test_classify_accepts_list_of_filepaths(tmp_path: Path, animals_df: pd.DataFrame): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + filepaths = animals_df["filepath"].tolist() + result = classify(model, filepaths, resize_width=32, resize_height=32) + assert isinstance(result, np.ndarray) + + +def test_classify_saves_to_file(tmp_path: Path, animals_df: pd.DataFrame): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + out = tmp_path / "classifications.csv" + classify(model, animals_df, resize_width=32, resize_height=32, crop=False, out_file=str(out)) + assert out.exists() + + +def test_classify_raises_on_missing_file_col(tmp_path: Path): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model, _ = load_classifier(str(model_file)) + df = pd.DataFrame({"other_col": ["img.jpg"]}) + with pytest.raises(ValueError): + classify(model, df, file_col="filepath") + + +# --------------------------------------------------------------------------- +# single_classification +# --------------------------------------------------------------------------- + +def test_single_classification_adds_prediction_column(animals_df: pd.DataFrame): + predictions_raw = np.array([[0.1, 0.8, 0.1]]) + class_list = ["jaguar", "ocelot", "puma"] + result = single_classification(animals_df, None, predictions_raw, class_list) + assert "prediction" in result.columns + assert "confidence" in result.columns + assert result["prediction"].iloc[0] == "ocelot" + + +def test_single_classification_includes_empty(animals_df: pd.DataFrame, empty_df: pd.DataFrame): + predictions_raw = np.array([[0.1, 0.8, 0.1]]) + class_list = ["jaguar", "ocelot", "puma"] + result = single_classification(animals_df, empty_df, predictions_raw, class_list) + assert len(result) == 2 + assert "empty" in result["prediction"].values + + +def test_single_classification_best_returns_one_per_file(animals_df: pd.DataFrame): + predictions_raw = np.array([[0.1, 0.8, 0.1]]) + class_list = ["jaguar", "ocelot", "puma"] + result = single_classification(animals_df, None, predictions_raw, class_list, best=True) + assert len(result) == 1 + + +def test_single_classification_empty_animals_still_returns(empty_df: pd.DataFrame): + animals = pd.DataFrame( + columns=["filepath", "frame", "conf", "category", "category_label", + "max_detection_conf", "bbox_x", "bbox_y", "bbox_w", "bbox_h"] + ) + predictions_raw = np.zeros((0, 3)) + class_list = ["jaguar", "ocelot", "puma"] + result = single_classification(animals, empty_df, predictions_raw, class_list) + assert isinstance(result, pd.DataFrame) + assert not result.empty diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..d8a6d69 --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import numpy as np +import pandas as pd +import pytest + +from animl.detection import ( + _convert_detections, + get_animals, + get_empty, + load_detector, + parse_detections, +) + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def sample_detections_with_animal() -> list[dict]: + return [ + { + "filepath": "img_a.jpg", + "frame": 0, + "max_detection_conf": 0.9, + "category": 1, + "category_label": "animal", + "detections": [ + { + "category": 1, + "category_label": "animal", + "conf": 0.9, + "bbox_x": 0.1, + "bbox_y": 0.2, + "bbox_w": 0.3, + "bbox_h": 0.4, + } + ], + } + ] + + +@pytest.fixture +def sample_detections_empty() -> list[dict]: + return [ + { + "filepath": "img_b.jpg", + "frame": 0, + "max_detection_conf": None, + "category": 0, + "category_label": "empty", + "detections": [], + } + ] + + +@pytest.fixture +def mixed_detections( + sample_detections_with_animal, sample_detections_empty +) -> list[dict]: + return sample_detections_with_animal + sample_detections_empty + + +# --------------------------------------------------------------------------- +# load_detector +# --------------------------------------------------------------------------- + +def test_load_detector_raises_for_missing_file(tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_detector(str(tmp_path / "nonexistent.onnx")) + + +def test_load_detector_returns_model_with_type(tmp_path: Path): + model_file = tmp_path / "model.onnx" + model_file.write_bytes(b"fake") + model = load_detector(str(model_file), model_type="megadetector") + assert model.model_type == "megadetector" + + +def test_load_detector_sets_custom_model_type(tmp_path: Path): + model_file = tmp_path / "custom.onnx" + model_file.write_bytes(b"fake") + model = load_detector(str(model_file), model_type="yolo") + assert model.model_type == "yolo" + + +# --------------------------------------------------------------------------- +# parse_detections +# --------------------------------------------------------------------------- + +def test_parse_detections_returns_dataframe(sample_detections_with_animal): + df = parse_detections(sample_detections_with_animal) + assert isinstance(df, pd.DataFrame) + assert not df.empty + + +def test_parse_detections_contains_expected_columns(sample_detections_with_animal): + df = parse_detections(sample_detections_with_animal) + for col in ("filepath", "frame", "category", "conf", "bbox_x", "bbox_y", "bbox_w", "bbox_h"): + assert col in df.columns, f"Missing column: {col}" + + +def test_parse_detections_empty_detections(sample_detections_empty): + df = parse_detections(sample_detections_empty) + assert isinstance(df, pd.DataFrame) + assert len(df) == 1 + assert df.iloc[0]["category_label"] == "empty" + + +def test_parse_detections_raises_on_empty_list(): + with pytest.raises(AssertionError): + parse_detections([]) + + +def test_parse_detections_raises_on_non_list(): + with pytest.raises(TypeError): + parse_detections({"not": "a list"}) + + +def test_parse_detections_threshold_filters(sample_detections_with_animal): + df = parse_detections(sample_detections_with_animal, threshold=0.95) + # All detections below 0.95 should be filtered out. + animal_rows = df[df["conf"].notna()] + assert all(animal_rows["conf"] > 0.95) + + +def test_parse_detections_merges_manifest(sample_detections_with_animal, tmp_path: Path): + manifest = pd.DataFrame( + {"filepath": ["img_a.jpg"], "frame": [0], "station": ["s1"]} + ) + df = parse_detections(sample_detections_with_animal, manifest=manifest) + assert "station" in df.columns + + +def test_parse_detections_raises_invalid_manifest_col(sample_detections_with_animal): + manifest = pd.DataFrame({"other_col": ["img_a.jpg"]}) + with pytest.raises(ValueError): + parse_detections(sample_detections_with_animal, manifest=manifest, file_col="filepath") + + +def test_parse_detections_saves_to_file(tmp_path: Path, sample_detections_with_animal): + out = tmp_path / "detections.csv" + df = parse_detections(sample_detections_with_animal, out_file=str(out)) + assert out.exists() + assert isinstance(df, pd.DataFrame) + + +# --------------------------------------------------------------------------- +# get_animals / get_empty +# --------------------------------------------------------------------------- + +@pytest.fixture +def manifest_with_mixed_categories() -> pd.DataFrame: + return pd.DataFrame( + { + "filepath": ["a.jpg", "b.jpg", "c.jpg"], + "frame": [0, 0, 0], + "category": [1, 0, 2], + "category_label": ["animal", "empty", "vehicle"], + } + ) + + +def test_get_animals_returns_only_animals(manifest_with_mixed_categories): + animals = get_animals(manifest_with_mixed_categories) + assert not animals.empty + assert all(animals["category_label"] == "animal") + + +def test_get_animals_returns_empty_df_when_no_animals(): + manifest = pd.DataFrame( + {"category_label": ["empty", "vehicle"], "filepath": ["a.jpg", "b.jpg"]} + ) + animals = get_animals(manifest) + assert animals.empty + + +def test_get_animals_uses_category_when_no_label_col(): + manifest = pd.DataFrame( + {"category": [1, 0, 1], "filepath": ["a.jpg", "b.jpg", "c.jpg"]} + ) + animals = get_animals(manifest) + assert len(animals) == 2 + + +def test_get_empty_returns_non_animals(manifest_with_mixed_categories): + others = get_empty(manifest_with_mixed_categories) + assert not others.empty + assert all(others["category_label"] != "animal") + + +def test_get_empty_returns_empty_df_when_all_animals(): + manifest = pd.DataFrame( + { + "category": [1, 1], + "category_label": ["animal", "animal"], + "filepath": ["a.jpg", "b.jpg"], + } + ) + others = get_empty(manifest) + assert others.empty + + +# --------------------------------------------------------------------------- +# _convert_detections (internal helper) +# --------------------------------------------------------------------------- + +def test_convert_detections_no_detection_produces_empty_result(): + # Simulate a batch with one image that has zero predictions above threshold. + image_tensor = np.zeros((1, 3, 32, 32), dtype=np.float32) + image_paths = ["img.jpg"] + image_frames = [0] + image_sizes = np.array([[32, 32]]) + # pred with no detections (empty rows) + predictions = [np.zeros((0, 6), dtype=np.float32)] + batch = (image_tensor, image_paths, image_frames, image_sizes) + results = _convert_detections(predictions, batch, letterbox=False) + assert len(results) == 1 + assert results[0]["detections"] == [] + assert results[0]["filepath"] == "img.jpg" diff --git a/tests/test_video_processing.py b/tests/test_video_processing.py new file mode 100644 index 0000000..40eb618 --- /dev/null +++ b/tests/test_video_processing.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from animl import file_management +from animl.video_processing import ( + _count_frames, + extract_frames, + get_images, + get_videos, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mixed_manifest(tmp_path: Path) -> pd.DataFrame: + img = tmp_path / "photo.jpg" + vid = tmp_path / "clip.mp4" + img.write_bytes(b"fake-image") + vid.write_bytes(b"fake-video") + return pd.DataFrame({"filepath": [str(img), str(vid)]}) + + +@pytest.fixture +def image_manifest(tmp_path: Path) -> pd.DataFrame: + img = tmp_path / "photo.jpg" + img.write_bytes(b"fake-image") + return pd.DataFrame({"filepath": [str(img)]}) + + +@pytest.fixture +def video_manifest(tmp_path: Path) -> pd.DataFrame: + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake-video") + return pd.DataFrame({"filepath": [str(vid)]}) + + +# --------------------------------------------------------------------------- +# get_images / get_videos +# --------------------------------------------------------------------------- + +def test_get_images_returns_only_images(mixed_manifest: pd.DataFrame): + images = get_images(mixed_manifest) + assert not images.empty + assert all( + Path(p).suffix.lower() in file_management.IMAGE_EXTENSIONS + for p in images["filepath"] + ) + + +def test_get_images_assigns_frame_zero(image_manifest: pd.DataFrame): + images = get_images(image_manifest) + assert list(images["frame"]) == [0] + + +def test_get_videos_returns_only_videos(mixed_manifest: pd.DataFrame): + videos = get_videos(mixed_manifest) + assert not videos.empty + assert all( + Path(p).suffix.lower() in file_management.VIDEO_EXTENSIONS + for p in videos["filepath"] + ) + + +def test_get_images_empty_when_no_images(video_manifest: pd.DataFrame): + images = get_images(video_manifest) + assert images.empty + + +def test_get_videos_empty_when_no_videos(image_manifest: pd.DataFrame): + videos = get_videos(image_manifest) + assert videos.empty + + +# --------------------------------------------------------------------------- +# extract_frames +# --------------------------------------------------------------------------- + +def test_extract_frames_raises_without_fps_and_frames(mixed_manifest: pd.DataFrame): + with pytest.raises(AssertionError): + extract_frames(mixed_manifest, frames=None, fps=None) + + +def test_extract_frames_raises_missing_file_col(mixed_manifest: pd.DataFrame): + with pytest.raises(ValueError): + extract_frames(mixed_manifest, frames=3, file_col="nonexistent_col") + + +def test_extract_frames_images_only_returns_manifest(image_manifest: pd.DataFrame): + result = extract_frames(image_manifest, frames=3) + assert isinstance(result, pd.DataFrame) + assert "filepath" in result.columns + assert "frame" in result.columns + assert list(result["frame"]) == [0] + + +def test_extract_frames_includes_videos(video_manifest: pd.DataFrame): + result = extract_frames(video_manifest, frames=3, parallel=False) + assert isinstance(result, pd.DataFrame) + assert "frame" in result.columns + assert len(result) >= 1 + + +def test_extract_frames_saves_to_file(tmp_path: Path, image_manifest: pd.DataFrame): + out = tmp_path / "frames.csv" + result = extract_frames(image_manifest, frames=1, out_file=str(out)) + assert out.exists() + assert isinstance(result, pd.DataFrame) + + +# --------------------------------------------------------------------------- +# _count_frames +# --------------------------------------------------------------------------- + +def test_count_frames_missing_file_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + _count_frames(str(tmp_path / "nonexistent.mp4"), frames=5) + + +def test_count_frames_returns_list_of_pairs(tmp_path: Path): + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + result = _count_frames(str(vid), frames=3) + assert result is not None + assert isinstance(result, list) + for pair in result: + assert len(pair) == 2 + + +def test_count_frames_with_fps(tmp_path: Path): + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + result = _count_frames(str(vid), fps=2) + assert result is not None + assert isinstance(result, list) From d8989938325d2858c76ea153158311abf633f91f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:31:08 +0000 Subject: [PATCH 3/7] Address code review: fix threshold test assertion, add cv2 constant comments Co-authored-by: tkswanson <4371698+tkswanson@users.noreply.github.com> --- tests/conftest.py | 2 ++ tests/test_detection.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 09fca0b..04fcdca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,8 @@ def _install_optional_stubs() -> None: import cv2 # noqa: F401 except Exception: cv2 = types.ModuleType("cv2") + # Values match the actual OpenCV constants so that _FakeCapture.get() + # dispatches correctly without the real library present. cv2.CAP_PROP_FRAME_COUNT = 7 cv2.CAP_PROP_FPS = 5 cv2.CAP_PROP_POS_FRAMES = 1 diff --git a/tests/test_detection.py b/tests/test_detection.py index d8a6d69..59b81ae 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -123,9 +123,10 @@ def test_parse_detections_raises_on_non_list(): def test_parse_detections_threshold_filters(sample_detections_with_animal): df = parse_detections(sample_detections_with_animal, threshold=0.95) - # All detections below 0.95 should be filtered out. + # The only detection has conf=0.9, which is below the threshold of 0.95, + # so it should be filtered out leaving no animal rows with a non-null conf. animal_rows = df[df["conf"].notna()] - assert all(animal_rows["conf"] > 0.95) + assert animal_rows.empty def test_parse_detections_merges_manifest(sample_detections_with_animal, tmp_path: Path): From 1d90f38d63251758f161052bb469352ed3fef5b1 Mon Sep 17 00:00:00 2001 From: Kyra Swanson Date: Thu, 20 Aug 2026 13:58:50 -0700 Subject: [PATCH 4/7] Update _count_frames tests with proper mocking for invalid and valid video files - Replace invalid assertions that expect non-None for fake files - Add tests expecting None for corrupted/invalid video files - Add comprehensive mocked tests for valid video processing - Test both frames and fps parameters - Verify frame indices are within valid bounds - Test edge cases (zero frames, fps detection failure) --- tests/test_video_processing.py | 125 ++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/tests/test_video_processing.py b/tests/test_video_processing.py index 40eb618..b152c42 100644 --- a/tests/test_video_processing.py +++ b/tests/test_video_processing.py @@ -1,7 +1,9 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import MagicMock, patch +import cv2 import numpy as np import pandas as pd import pytest @@ -124,19 +126,136 @@ def test_count_frames_missing_file_raises(tmp_path: Path): _count_frames(str(tmp_path / "nonexistent.mp4"), frames=5) -def test_count_frames_returns_list_of_pairs(tmp_path: Path): +def test_count_frames_invalid_file_returns_none(tmp_path: Path): + """Test that _count_frames returns None for invalid/corrupted video files.""" vid = tmp_path / "clip.mp4" vid.write_bytes(b"fake") result = _count_frames(str(vid), frames=3) + assert result is None + + +def test_count_frames_with_fps_invalid_file_returns_none(tmp_path: Path): + """Test that _count_frames returns None for invalid files even with fps parameter.""" + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + result = _count_frames(str(vid), fps=2) + assert result is None + + +def test_count_frames_with_frames_parameter_returns_list(tmp_path: Path): + """Test that _count_frames returns a list of frame pairs when using frames parameter.""" + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.side_effect = lambda prop: { + cv2.CAP_PROP_FRAME_COUNT: 100.0, + }.get(prop, 30.0) + + with patch("cv2.VideoCapture", return_value=mock_cap): + result = _count_frames(str(vid), frames=3) + assert result is not None assert isinstance(result, list) + assert len(result) == 3 for pair in result: assert len(pair) == 2 + assert pair[0] == str(vid) + assert isinstance(pair[1], int) -def test_count_frames_with_fps(tmp_path: Path): +def test_count_frames_with_fps_parameter_returns_list(tmp_path: Path): + """Test that _count_frames returns a list of frame pairs when using fps parameter.""" vid = tmp_path / "clip.mp4" vid.write_bytes(b"fake") - result = _count_frames(str(vid), fps=2) + + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.side_effect = lambda prop: { + cv2.CAP_PROP_FRAME_COUNT: 300.0, # 10 seconds at 30 fps + cv2.CAP_PROP_FPS: 30.0, + }.get(prop, 30.0) + + with patch("cv2.VideoCapture", return_value=mock_cap): + result = _count_frames(str(vid), fps=2) + + assert result is not None + assert isinstance(result, list) + assert len(result) > 0 + for pair in result: + assert len(pair) == 2 + assert pair[0] == str(vid) + assert isinstance(pair[1], int) + + +def test_count_frames_corrupted_video_returns_none(tmp_path: Path): + """Test that _count_frames returns None when VideoCapture fails to open.""" + vid = tmp_path / "corrupted.mp4" + vid.write_bytes(b"fake") + + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False + + with patch("cv2.VideoCapture", return_value=mock_cap): + result = _count_frames(str(vid), frames=5) + + assert result is None + + +def test_count_frames_zero_frame_count_returns_none(tmp_path: Path): + """Test that _count_frames returns None when frame count is 0.""" + vid = tmp_path / "empty.mp4" + vid.write_bytes(b"fake") + + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.side_effect = lambda prop: { + cv2.CAP_PROP_FRAME_COUNT: 0.0, # No frames + }.get(prop, 30.0) + + with patch("cv2.VideoCapture", return_value=mock_cap): + result = _count_frames(str(vid), frames=5) + + assert result is None + + +def test_count_frames_fps_zero_falls_back_to_default(tmp_path: Path): + """Test that _count_frames handles fps=0 by falling back to ffmpeg or default.""" + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.side_effect = lambda prop: { + cv2.CAP_PROP_FRAME_COUNT: 300.0, + cv2.CAP_PROP_FPS: 0.0, # fps detection fails + }.get(prop, 0.0) + + with patch("cv2.VideoCapture", return_value=mock_cap): + with patch("animl.video_processing.get_fps_from_ffmpeg", return_value=None): + result = _count_frames(str(vid), fps=2) + assert result is not None assert isinstance(result, list) + + +def test_count_frames_frame_indices_within_bounds(tmp_path: Path): + """Test that returned frame indices are within the valid range.""" + vid = tmp_path / "clip.mp4" + vid.write_bytes(b"fake") + + frame_count = 100 + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.get.side_effect = lambda prop: { + cv2.CAP_PROP_FRAME_COUNT: float(frame_count), + }.get(prop, 30.0) + + with patch("cv2.VideoCapture", return_value=mock_cap): + result = _count_frames(str(vid), frames=5) + + assert result is not None + for pair in result: + frame_idx = pair[1] + assert 0 <= frame_idx < frame_count From 2b2306165def72dc33dee2f81903f2fafd26c4e4 Mon Sep 17 00:00:00 2001 From: Kyra Swanson Date: Thu, 20 Aug 2026 14:59:06 -0700 Subject: [PATCH 5/7] fix tests to work with real models --- tests/test_classification.py | 84 ++++++++++++++++------------------ tests/test_detection.py | 33 +++++++++---- tests/test_video_processing.py | 1 - 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/tests/test_classification.py b/tests/test_classification.py index 24d6432..f23c04d 100644 --- a/tests/test_classification.py +++ b/tests/test_classification.py @@ -35,7 +35,7 @@ def animals_df(tmp_path: Path) -> pd.DataFrame: from PIL import Image img = tmp_path / "animal.jpg" - Image.new("RGB", (32, 32), color=(100, 50, 10)).save(img) + Image.new("RGB", (299, 299), color=(100, 50, 10)).save(img) return pd.DataFrame( { "filepath": [str(img)], @@ -68,6 +68,21 @@ def empty_df() -> pd.DataFrame: ) +@pytest.fixture +def real_classifier_model() -> Path: + """ + Use an existing real ONNX model file for testing. + Point this to your actual model location. + """ + # Option 1: Copy from a known location in your repo/test data + real_model_path = Path(__file__).parent / "fixtures" / "sdzwa_southwest_v3.onnx" + + if real_model_path.exists(): + return real_model_path + + pytest.skip("Real classifier model not found") + + # --------------------------------------------------------------------------- # load_class_list # --------------------------------------------------------------------------- @@ -93,81 +108,62 @@ def test_load_classifier_raises_for_missing_model(tmp_path: Path): load_classifier(str(tmp_path / "nonexistent.onnx")) -def test_load_classifier_returns_model_and_class_list(tmp_path: Path, class_list_file: Path): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, class_list = load_classifier(str(model_file), classes=str(class_list_file)) +def test_load_classifier_returns_model_and_class_list(real_classifier_model: Path, class_list_file: Path): + model, class_list = load_classifier(str(real_classifier_model), classes=class_list_file) assert model is not None assert model.model_type == "classifier" assert class_list is not None -def test_load_classifier_accepts_dataframe_classes(tmp_path: Path, class_list_df: pd.DataFrame): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, class_list = load_classifier(str(model_file), classes=class_list_df) +def test_load_classifier_accepts_dataframe_classes(real_classifier_model: Path, class_list_df: pd.DataFrame): + model, class_list = load_classifier(str(real_classifier_model), classes=class_list_df) assert class_list is not None -def test_load_classifier_no_classes_returns_none_class_list(tmp_path: Path): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, class_list = load_classifier(str(model_file)) - # class list is None when no classes arg and no metadata - assert class_list is None +def test_load_classifier_no_classes_returns_none_class_list(real_classifier_model: Path): + model, class_list = load_classifier(str(real_classifier_model)) + # class list is should load from onnx model + assert class_list is not None # --------------------------------------------------------------------------- # classify # --------------------------------------------------------------------------- -def test_classify_returns_numpy_array(tmp_path: Path, animals_df: pd.DataFrame): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) - result = classify(model, animals_df, resize_width=32, resize_height=32, crop=False) +def test_classify_returns_numpy_array(real_classifier_model: Path, animals_df: pd.DataFrame): + model, _ = load_classifier(str(real_classifier_model)) + result = classify(model, animals_df, resize_width=299, resize_height=299, crop=False) assert isinstance(result, np.ndarray) -def test_classify_raises_on_invalid_input(tmp_path: Path): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) +def test_classify_raises_on_invalid_input(real_classifier_model: Path): + model, _ = load_classifier(str(real_classifier_model)) with pytest.raises(AssertionError): classify(model, 12345) -def test_classify_accepts_string_filepath(tmp_path: Path, animals_df: pd.DataFrame): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) +def test_classify_accepts_string_filepath(real_classifier_model: Path, animals_df: pd.DataFrame): + model, _ = load_classifier(str(real_classifier_model)) filepath = animals_df["filepath"].iloc[0] - result = classify(model, filepath, resize_width=32, resize_height=32) + result = classify(model, filepath, resize_width=299, resize_height=299) assert isinstance(result, np.ndarray) -def test_classify_accepts_list_of_filepaths(tmp_path: Path, animals_df: pd.DataFrame): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) - filepaths = animals_df["filepath"].tolist() - result = classify(model, filepaths, resize_width=32, resize_height=32) +def test_classify_accepts_list_of_filepaths(real_classifier_model: Path, animals_df: pd.DataFrame): + model, _ = load_classifier(str(real_classifier_model)) + result = classify(model, animals_df, resize_width=299, resize_height=299) assert isinstance(result, np.ndarray) -def test_classify_saves_to_file(tmp_path: Path, animals_df: pd.DataFrame): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) +def test_classify_saves_to_file(real_classifier_model: Path, tmp_path: Path, animals_df: pd.DataFrame): + model, _ = load_classifier(str(real_classifier_model)) out = tmp_path / "classifications.csv" - classify(model, animals_df, resize_width=32, resize_height=32, crop=False, out_file=str(out)) + classify(model, animals_df, resize_width=299, resize_height=299, crop=False, out_file=str(out)) assert out.exists() -def test_classify_raises_on_missing_file_col(tmp_path: Path): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model, _ = load_classifier(str(model_file)) +def test_classify_raises_on_missing_file_col(real_classifier_model: Path): + model, _ = load_classifier(str(real_classifier_model)) df = pd.DataFrame({"other_col": ["img.jpg"]}) with pytest.raises(ValueError): classify(model, df, file_col="filepath") diff --git a/tests/test_detection.py b/tests/test_detection.py index 59b81ae..afc290d 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -65,6 +65,23 @@ def mixed_detections( return sample_detections_with_animal + sample_detections_empty +@pytest.fixture +def real_detector_model() -> Path: + """ + Use an existing real ONNX model file for testing. + Point this to your actual model location. + """ + # Option 1: Copy from a known location in your repo/test data + real_model_path = Path(__file__).parent / "fixtures" / "md_v1000.0.0-sorrel.onnx" + + if real_model_path.exists(): + return real_model_path + + pytest.skip("Real detector model not found") + + + + # --------------------------------------------------------------------------- # load_detector # --------------------------------------------------------------------------- @@ -74,17 +91,13 @@ def test_load_detector_raises_for_missing_file(tmp_path: Path): load_detector(str(tmp_path / "nonexistent.onnx")) -def test_load_detector_returns_model_with_type(tmp_path: Path): - model_file = tmp_path / "model.onnx" - model_file.write_bytes(b"fake") - model = load_detector(str(model_file), model_type="megadetector") +def test_load_detector_returns_model_with_type(real_detector_model: Path): + model = load_detector(str(real_detector_model), model_type="megadetector") assert model.model_type == "megadetector" -def test_load_detector_sets_custom_model_type(tmp_path: Path): - model_file = tmp_path / "custom.onnx" - model_file.write_bytes(b"fake") - model = load_detector(str(model_file), model_type="yolo") +def test_load_detector_sets_custom_model_type(real_detector_model: Path): + model = load_detector(str(real_detector_model), model_type="yolo") assert model.model_type == "yolo" @@ -125,8 +138,7 @@ def test_parse_detections_threshold_filters(sample_detections_with_animal): df = parse_detections(sample_detections_with_animal, threshold=0.95) # The only detection has conf=0.9, which is below the threshold of 0.95, # so it should be filtered out leaving no animal rows with a non-null conf. - animal_rows = df[df["conf"].notna()] - assert animal_rows.empty + assert df.empty def test_parse_detections_merges_manifest(sample_detections_with_animal, tmp_path: Path): @@ -161,6 +173,7 @@ def manifest_with_mixed_categories() -> pd.DataFrame: "filepath": ["a.jpg", "b.jpg", "c.jpg"], "frame": [0, 0, 0], "category": [1, 0, 2], + "conf": [0.9, 1, 0.8], "category_label": ["animal", "empty", "vehicle"], } ) diff --git a/tests/test_video_processing.py b/tests/test_video_processing.py index b152c42..a2a6dc2 100644 --- a/tests/test_video_processing.py +++ b/tests/test_video_processing.py @@ -107,7 +107,6 @@ def test_extract_frames_includes_videos(video_manifest: pd.DataFrame): result = extract_frames(video_manifest, frames=3, parallel=False) assert isinstance(result, pd.DataFrame) assert "frame" in result.columns - assert len(result) >= 1 def test_extract_frames_saves_to_file(tmp_path: Path, image_manifest: pd.DataFrame): From 0ac3a82c6d9c958d7b151f3b0db57555da7d8d9f Mon Sep 17 00:00:00 2001 From: Kyra Swanson Date: Thu, 20 Aug 2026 15:00:27 -0700 Subject: [PATCH 6/7] ignore model files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 045bddd..cc90407 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +tests/fixtures/*.onnx # Translations *.mo From 970bb7140e8402d21951b68195915ef94849e823 Mon Sep 17 00:00:00 2001 From: Kyra Swanson Date: Thu, 20 Aug 2026 15:20:57 -0700 Subject: [PATCH 7/7] add filenotfounderror to load_classifier --- src/animl/classification.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/animl/classification.py b/src/animl/classification.py index 85918f2..425b849 100644 --- a/src/animl/classification.py +++ b/src/animl/classification.py @@ -34,6 +34,10 @@ def load_classifier(model_path: str, ''' class_list = None model_path = Path(model_path) + + if not model_path.exists(): + raise FileNotFoundError(f"Model file not found at {model_path}") + # check to make sure GPU is available if chosen providers = get_onnx_device(user_set=device)