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
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
110 changes: 110 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

import sys
import types
from pathlib import Path

import numpy as np
import pandas as pd
import pytest
from PIL import Image


def _install_optional_stubs() -> None:
try:
import onnxruntime # noqa: F401
except Exception:
ort = types.ModuleType("onnxruntime")

class _FakeSession:
def __init__(self, *args, **kwargs):
self.model_type = "mock"

def get_inputs(self):
return [types.SimpleNamespace(name="input")]

def run(self, *_args, **_kwargs):
return [np.zeros((1, 1), dtype=np.float32)]

def get_modelmeta(self):
return types.SimpleNamespace(custom_metadata_map={})

ort.InferenceSession = _FakeSession
ort.get_available_providers = lambda: ["CPUExecutionProvider"]
sys.modules["onnxruntime"] = ort

try:
import exiftool # noqa: F401
except Exception:
exiftool = types.ModuleType("exiftool")

class _FakeExifToolHelper:
version = "0.0"

def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return False

def get_metadata(self, _filepath):
return [{}]

exiftool.ExifToolHelper = _FakeExifToolHelper
sys.modules["exiftool"] = exiftool


_install_optional_stubs()


@pytest.fixture
def image_dir(tmp_path: Path) -> Path:
root = tmp_path / "images"
(root / "station_a" / "cam_1").mkdir(parents=True)
(root / "station_b" / "cam_2").mkdir(parents=True)

for idx, rel in enumerate([
Path("station_a/cam_1/a.jpg"),
Path("station_a/cam_1/b.png"),
Path("station_b/cam_2/c.jpeg"),
]):
out = root / rel
out.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (32 + idx, 24 + idx), color=(idx * 10, 20, 30)).save(out)

# Supported extension placeholder; exif=False tests do not open it.
(root / "station_b" / "cam_2" / "clip.mp4").write_bytes(b"fake-video")
# Unsupported file
(root / "station_b" / "cam_2" / "notes.txt").write_text("ignore me")
return root


@pytest.fixture
def required_results_manifest(tmp_path: Path) -> pd.DataFrame:
img = tmp_path / "img.jpg"
Image.new("RGB", (100, 50), color=(255, 255, 255)).save(img)
return pd.DataFrame(
[
{
"filepath": str(img),
"filename": img.name,
"filemodifydate": "2026-01-01 00:00:00",
"frame": 0,
"max_detection_conf": 0.9,
"category": 1,
"category_label": "animal",
"conf": 0.9,
"bbox_x": 0.1,
"bbox_y": 0.2,
"bbox_w": 0.3,
"bbox_h": 0.4,
"prediction": "jaguar",
"confidence": 0.8,
"width": 100,
"height": 50,
"datetime": "2026-01-01 00:00:00",
"station": "station_a",
"extension": ".jpg",
}
]
)
158 changes: 158 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from __future__ import annotations

import json
from pathlib import Path

import pandas as pd
import pytest

from animl import export


def test_export_folders_creates_links(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "sorted"
out_dir.mkdir()
manifest = required_results_manifest.copy()
out = export.export_folders(manifest, out_dir, label_col="prediction", copy=False)
assert "link" in out.columns
link = Path(out.loc[0, "link"])
assert link.exists()


def test_export_folders_copy_mode(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "sorted_copy"
out_dir.mkdir()
manifest = required_results_manifest.copy()
out = export.export_folders(manifest, out_dir, label_col="prediction", copy=True)
link = Path(out.loc[0, "link"])
assert link.exists()


def test_export_folders_requires_label_col(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "sorted_missing"
out_dir.mkdir()
manifest = required_results_manifest.drop(columns=["prediction"])
with pytest.raises(AssertionError):
export.export_folders(manifest, out_dir, label_col="prediction")


def test_remove_link_deletes_file_and_column(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "sorted_remove"
out_dir.mkdir()
manifest = export.export_folders(required_results_manifest.copy(), out_dir, label_col="prediction")
link_path = Path(manifest.loc[0, "link"])
assert link_path.exists()
out = export.remove_link(manifest)
assert "link" not in out.columns
assert not link_path.exists()


def test_remove_link_requires_column(required_results_manifest: pd.DataFrame):
with pytest.raises(AssertionError):
export.remove_link(required_results_manifest.copy(), link_col="link")


def test_update_labels_from_folders_reads_label_from_parent_dir(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "review"
out_dir.mkdir()
sorted_manifest = export.export_folders(required_results_manifest.copy(), out_dir, label_col="prediction")
updated = export.update_labels_from_folders(sorted_manifest, out_dir)
assert "label" in updated.columns
assert updated.loc[0, "label"] == "jaguar"


def test_export_coco_writes_expected_shape(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "out_coco.json"
class_dict = {7: "jaguar"}
coco = export.export_coco(required_results_manifest.copy(), class_dict, out_file)
assert out_file.exists()
assert set(coco.keys()) == {"info", "licenses", "images", "annotations", "categories"}
assert len(coco["images"]) == 1
assert len(coco["categories"]) == 1
assert coco["categories"][0]["id"] == 7


def test_export_coco_skips_nan_bbox(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "out_coco_nan.json"
manifest = required_results_manifest.copy()
manifest.loc[0, "bbox_x"] = float("nan")
coco = export.export_coco(manifest, {1: "jaguar"}, out_file)
assert len(coco["annotations"]) == 0


@pytest.mark.parametrize(
"missing_col",
[
"filepath",
"filename",
"filemodifydate",
"frame",
"max_detection_conf",
"category",
"conf",
"bbox_x",
"bbox_y",
"bbox_w",
"bbox_h",
"prediction",
"confidence",
],
)
def test_export_coco_requires_all_columns(tmp_path: Path, required_results_manifest: pd.DataFrame, missing_col: str):
out_file = tmp_path / "out.json"
manifest = required_results_manifest.drop(columns=[missing_col])
with pytest.raises(AssertionError):
export.export_coco(manifest, {1: "jaguar"}, out_file)


def test_export_timelapse_animals_only(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "timelapse1"
csv_loc = export.export_timelapse(required_results_manifest.copy(), out_dir, only_animal=True)
assert csv_loc.exists()
assert (out_dir / "animals.csv").exists()
assert (out_dir / "manifest.csv").exists()


def test_export_timelapse_with_non_animals(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_dir = tmp_path / "timelapse2"
manifest = pd.concat(
[
required_results_manifest.copy(),
required_results_manifest.assign(category=2, category_label="human", prediction="human"),
],
ignore_index=True,
)
csv_loc = export.export_timelapse(manifest, out_dir, only_animal=False)
assert csv_loc.exists()
assert (out_dir / "non-animals.csv").exists()


def test_export_megadetector_writes_expected_json(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "md.json"
md = export.export_megadetector(required_results_manifest.copy(), out_file=out_file, prompt=False)
assert out_file.exists()
assert "images" in md
assert "detection_categories" in md
assert md["images"][0]["detections"][0]["category"] == 1


def test_export_megadetector_skips_empty_rows(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "md_empty.json"
manifest = required_results_manifest.copy()
manifest.loc[0, "category"] = 0
md = export.export_megadetector(manifest, out_file=out_file, prompt=False)
assert md["images"][0]["detections"] == []


def test_export_megadetector_requires_columns(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "bad.json"
manifest = required_results_manifest.drop(columns=["bbox_w"])
with pytest.raises(ValueError):
export.export_megadetector(manifest, out_file=out_file, prompt=False)


def test_export_megadetector_output_is_valid_json(tmp_path: Path, required_results_manifest: pd.DataFrame):
out_file = tmp_path / "md2.json"
export.export_megadetector(required_results_manifest.copy(), out_file=out_file, prompt=False)
loaded = json.loads(out_file.read_text())
assert loaded["info"]["format_version"] == "3.0"
Loading
Loading