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 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) diff --git a/tests/conftest.py b/tests/conftest.py index 9998d67..04fcdca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,46 @@ def _install_optional_stubs() -> None: + try: + 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 + 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..f23c04d --- /dev/null +++ b/tests/test_classification.py @@ -0,0 +1,209 @@ +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", (299, 299), 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], + } + ) + + +@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 +# --------------------------------------------------------------------------- + +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(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(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(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(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(real_classifier_model: Path): + model, _ = load_classifier(str(real_classifier_model)) + with pytest.raises(AssertionError): + classify(model, 12345) + + +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=299, resize_height=299) + assert isinstance(result, np.ndarray) + + +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(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=299, resize_height=299, crop=False, out_file=str(out)) + assert out.exists() + + +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") + + +# --------------------------------------------------------------------------- +# 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..afc290d --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,238 @@ +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 + + +@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 +# --------------------------------------------------------------------------- + +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(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(real_detector_model: Path): + model = load_detector(str(real_detector_model), 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) + # 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. + assert df.empty + + +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], + "conf": [0.9, 1, 0.8], + "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..a2a6dc2 --- /dev/null +++ b/tests/test_video_processing.py @@ -0,0 +1,260 @@ +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 + +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 + + +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_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_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") + + 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