From 45007dcac2551ae8284c096672835c1bdb3464dc Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 3 Apr 2026 13:22:26 -0400 Subject: [PATCH] Add crop manager for staging and training image lifecycle Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_crop_manager.py | 48 +++++++++++++++++++++++++++++++ vigilar/detection/crop_manager.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/unit/test_crop_manager.py create mode 100644 vigilar/detection/crop_manager.py diff --git a/tests/unit/test_crop_manager.py b/tests/unit/test_crop_manager.py new file mode 100644 index 0000000..09de943 --- /dev/null +++ b/tests/unit/test_crop_manager.py @@ -0,0 +1,48 @@ +"""Tests for detection crop saving and staging cleanup.""" + +import time +from pathlib import Path + +import numpy as np + +from vigilar.detection.crop_manager import CropManager + + +class TestCropManager: + def test_save_crop(self, tmp_path): + manager = CropManager(staging_dir=str(tmp_path / "staging"), + training_dir=str(tmp_path / "training")) + crop = np.zeros((100, 80, 3), dtype=np.uint8) + path = manager.save_staging_crop(crop, species="cat", camera_id="kitchen") + assert Path(path).exists() + assert "cat" in path + assert "kitchen" in path + + def test_promote_to_training(self, tmp_path): + manager = CropManager(staging_dir=str(tmp_path / "staging"), + training_dir=str(tmp_path / "training")) + crop = np.zeros((100, 80, 3), dtype=np.uint8) + staging_path = manager.save_staging_crop(crop, species="cat", camera_id="kitchen") + training_path = manager.promote_to_training(staging_path, pet_name="angel") + assert Path(training_path).exists() + assert "angel" in training_path + assert not Path(staging_path).exists() + + def test_cleanup_old_crops(self, tmp_path): + staging = tmp_path / "staging" + staging.mkdir(parents=True) + + old_file = staging / "old_crop.jpg" + old_file.write_bytes(b"fake") + old_time = time.time() - 10 * 86400 + import os + os.utime(old_file, (old_time, old_time)) + + new_file = staging / "new_crop.jpg" + new_file.write_bytes(b"fake") + + manager = CropManager(staging_dir=str(staging), training_dir=str(tmp_path / "training")) + deleted = manager.cleanup_expired(retention_days=7) + assert deleted == 1 + assert not old_file.exists() + assert new_file.exists() diff --git a/vigilar/detection/crop_manager.py b/vigilar/detection/crop_manager.py new file mode 100644 index 0000000..b58db83 --- /dev/null +++ b/vigilar/detection/crop_manager.py @@ -0,0 +1,48 @@ +"""Manage detection crop images for training and staging.""" + +import logging +import shutil +import time +from pathlib import Path + +import cv2 +import numpy as np + +log = logging.getLogger(__name__) + + +class CropManager: + def __init__(self, staging_dir: str, training_dir: str): + self._staging_dir = Path(staging_dir) + self._training_dir = Path(training_dir) + + def save_staging_crop(self, crop: np.ndarray, species: str, camera_id: str) -> str: + self._staging_dir.mkdir(parents=True, exist_ok=True) + timestamp = int(time.time() * 1000) + filename = f"{species}_{camera_id}_{timestamp}.jpg" + filepath = self._staging_dir / filename + cv2.imwrite(str(filepath), crop) + return str(filepath) + + def promote_to_training(self, staging_path: str, pet_name: str) -> str: + pet_dir = self._training_dir / pet_name.lower() + pet_dir.mkdir(parents=True, exist_ok=True) + src = Path(staging_path) + dst = pet_dir / src.name + shutil.move(str(src), str(dst)) + return str(dst) + + def cleanup_expired(self, retention_days: int = 7) -> int: + if not self._staging_dir.exists(): + return 0 + + cutoff = time.time() - retention_days * 86400 + deleted = 0 + for filepath in self._staging_dir.iterdir(): + if filepath.is_file() and filepath.stat().st_mtime < cutoff: + filepath.unlink() + deleted += 1 + + if deleted: + log.info("Cleaned up %d expired staging crops", deleted) + return deleted