cookAIware / tests /vision /test_processors.py
jimenezcarrero's picture
Upload folder using huggingface_hub
702939b verified
"""Tests for the vision processing module."""
import time
from typing import Any
from unittest.mock import Mock, MagicMock, patch
import numpy as np
import pytest
from cookAIware.vision.processors import (
VisionConfig,
VisionManager,
VisionProcessor,
initialize_vision_manager,
)
def test_vision_config_defaults() -> None:
"""Test VisionConfig has sensible defaults."""
config = VisionConfig()
assert config.vision_interval == 5.0
assert config.max_new_tokens == 64
assert config.jpeg_quality == 85
assert config.max_retries == 3
assert config.retry_delay == 1.0
assert config.device_preference == "auto"
def test_vision_config_custom_values() -> None:
"""Test VisionConfig accepts custom values."""
config = VisionConfig(
model_path="/custom/path",
vision_interval=10.0,
max_new_tokens=128,
jpeg_quality=95,
max_retries=5,
retry_delay=2.0,
device_preference="cpu",
)
assert config.model_path == "/custom/path"
assert config.vision_interval == 10.0
assert config.max_new_tokens == 128
assert config.jpeg_quality == 95
assert config.max_retries == 5
assert config.retry_delay == 2.0
assert config.device_preference == "cpu"
@pytest.fixture
def mock_torch() -> Any:
"""Mock torch module to avoid loading actual models."""
with patch("cookAIware.vision.processors.torch") as mock:
mock.cuda.is_available.return_value = False
mock.backends.mps.is_available.return_value = False
mock.float32 = "float32"
mock.bfloat16 = "bfloat16"
yield mock
@pytest.fixture
def mock_transformers() -> Any:
"""Mock transformers module."""
with patch("cookAIware.vision.processors.AutoProcessor") as proc, \
patch("cookAIware.vision.processors.AutoModelForImageTextToText") as model:
# Mock processor
mock_processor = MagicMock()
mock_processor.apply_chat_template.return_value = {
"input_ids": MagicMock(to=lambda x: MagicMock()),
"attention_mask": MagicMock(to=lambda x: MagicMock()),
"pixel_values": MagicMock(to=lambda x: MagicMock()),
}
mock_processor.batch_decode.return_value = ["assistant\nThis is a test description."]
mock_processor.tokenizer.eos_token_id = 2
proc.from_pretrained.return_value = mock_processor
# Mock model
mock_model_instance = MagicMock()
mock_model_instance.eval.return_value = None
mock_model_instance.generate.return_value = [[1, 2, 3]]
mock_model_instance.to.return_value = mock_model_instance
model.from_pretrained.return_value = mock_model_instance
yield {"processor": proc, "model": model}
def test_vision_processor_device_selection_cpu(mock_torch: Any) -> None:
"""Test VisionProcessor selects CPU when specified."""
config = VisionConfig(device_preference="cpu")
processor = VisionProcessor(config)
assert processor.device == "cpu"
def test_vision_processor_device_selection_cuda_unavailable(mock_torch: Any) -> None:
"""Test VisionProcessor falls back to CPU when CUDA unavailable."""
mock_torch.cuda.is_available.return_value = False
config = VisionConfig(device_preference="cuda")
processor = VisionProcessor(config)
assert processor.device == "cpu"
def test_vision_processor_device_selection_cuda_available(mock_torch: Any) -> None:
"""Test VisionProcessor selects CUDA when available."""
mock_torch.cuda.is_available.return_value = True
config = VisionConfig(device_preference="cuda")
processor = VisionProcessor(config)
assert processor.device == "cuda"
def test_vision_processor_device_selection_mps_available(mock_torch: Any) -> None:
"""Test VisionProcessor selects MPS when available on Apple Silicon."""
mock_torch.backends.mps.is_available.return_value = True
config = VisionConfig(device_preference="mps")
processor = VisionProcessor(config)
assert processor.device == "mps"
def test_vision_processor_device_selection_auto_prefers_mps(mock_torch: Any) -> None:
"""Test VisionProcessor auto mode prefers MPS on Apple Silicon."""
mock_torch.backends.mps.is_available.return_value = True
mock_torch.cuda.is_available.return_value = False
config = VisionConfig(device_preference="auto")
processor = VisionProcessor(config)
assert processor.device == "mps"
def test_vision_processor_device_selection_auto_prefers_cuda_over_cpu(mock_torch: Any) -> None:
"""Test VisionProcessor auto mode prefers CUDA over CPU."""
mock_torch.backends.mps.is_available.return_value = False
mock_torch.cuda.is_available.return_value = True
config = VisionConfig(device_preference="auto")
processor = VisionProcessor(config)
assert processor.device == "cuda"
def test_vision_processor_initialization(mock_torch: Any, mock_transformers: Any) -> None:
"""Test VisionProcessor initializes successfully."""
config = VisionConfig(model_path="test/model")
processor = VisionProcessor(config)
assert not processor._initialized
result = processor.initialize()
assert result is True
assert processor._initialized
mock_transformers["processor"].from_pretrained.assert_called_once_with("test/model")
mock_transformers["model"].from_pretrained.assert_called_once()
def test_vision_processor_initialization_failure(mock_torch: Any) -> None:
"""Test VisionProcessor handles initialization failure gracefully."""
with patch("cookAIware.vision.processors.AutoProcessor") as mock_proc:
mock_proc.from_pretrained.side_effect = Exception("Model not found")
config = VisionConfig(model_path="invalid/model")
processor = VisionProcessor(config)
result = processor.initialize()
assert result is False
assert not processor._initialized
def test_vision_processor_process_image_not_initialized(mock_torch: Any) -> None:
"""Test process_image returns error when model not initialized."""
processor = VisionProcessor()
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
result = processor.process_image(test_image)
assert result == "Vision model not initialized"
def test_vision_processor_process_image_success(mock_torch: Any, mock_transformers: Any) -> None:
"""Test process_image processes an image successfully."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
# Mock cv2.imencode to return success
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
processor = VisionProcessor()
processor.initialize()
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
result = processor.process_image(test_image, "Describe this image.")
assert isinstance(result, str)
assert result == "This is a test description."
def test_vision_processor_process_image_encode_failure(mock_torch: Any, mock_transformers: Any) -> None:
"""Test process_image handles image encoding failure."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (False, None)
mock_cv2.IMWRITE_JPEG_QUALITY = 1
processor = VisionProcessor()
processor.initialize()
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
result = processor.process_image(test_image)
assert result == "Failed to encode image"
def test_vision_processor_process_image_with_retry(mock_torch: Any, mock_transformers: Any) -> None:
"""Test process_image retries on failure."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
# Set up the OutOfMemoryError to be a proper exception
mock_torch.cuda.OutOfMemoryError = type("OutOfMemoryError", (Exception,), {})
processor = VisionProcessor(VisionConfig(max_retries=3, retry_delay=0.01))
processor.initialize()
# Make the model generate fail twice, then succeed
call_count = [0]
assert processor.model is not None
original_generate = processor.model.generate
def failing_generate(*args: Any, **kwargs: Any) -> Any:
call_count[0] += 1
if call_count[0] < 3:
raise Exception("Temporary failure")
return original_generate(*args, **kwargs)
processor.model.generate = failing_generate
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
result = processor.process_image(test_image)
assert isinstance(result, str)
assert call_count[0] == 3
def test_vision_processor_extract_response_variants() -> None:
"""Test _extract_response handles different response formats."""
processor = VisionProcessor()
# Test with "assistant\n" marker
result = processor._extract_response("user prompt\nassistant\nThe response text")
assert result == "The response text"
# Test with "Assistant:" marker
result = processor._extract_response("User: prompt\nAssistant: Another response")
assert result == "Another response"
# Test fallback to full text
result = processor._extract_response("Just some text without markers")
assert result == "Just some text without markers"
def test_vision_processor_get_model_info(mock_torch: Any, mock_transformers: Any) -> None:
"""Test get_model_info returns correct information."""
mock_torch.cuda.is_available.return_value = True
mock_torch.cuda.get_device_properties.return_value.total_memory = 8 * 1024**3
processor = VisionProcessor(VisionConfig(model_path="test/model", device_preference="cpu"))
processor.initialize()
info = processor.get_model_info()
assert info["initialized"] is True
assert info["device"] == "cpu"
assert info["model_path"] == "test/model"
assert "cuda_available" in info
@pytest.fixture
def mock_camera() -> Mock:
"""Create a mock camera object."""
camera = Mock()
camera.get_latest_frame.return_value = np.zeros((480, 640, 3), dtype=np.uint8)
return camera
def test_vision_manager_initialization(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager initializes successfully."""
config = VisionConfig(vision_interval=2.0)
manager = VisionManager(mock_camera, config)
assert manager.vision_interval == 2.0
assert manager.processor._initialized
def test_vision_manager_initialization_failure(mock_torch: Any, mock_camera: Mock) -> None:
"""Test VisionManager raises error when processor initialization fails."""
with patch("cookAIware.vision.processors.AutoProcessor") as mock_proc:
mock_proc.from_pretrained.side_effect = Exception("Model not found")
with pytest.raises(RuntimeError, match="Vision processor initialization failed"):
VisionManager(mock_camera, VisionConfig())
def test_vision_manager_start_stop(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager can start and stop."""
manager = VisionManager(mock_camera, VisionConfig())
manager.start()
assert manager._thread is not None
assert manager._thread.is_alive()
assert not manager._stop_event.is_set()
time.sleep(0.1) # Let thread run briefly
manager.stop()
assert manager._stop_event.is_set()
assert not manager._thread.is_alive()
def test_vision_manager_processes_frames(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager processes frames at intervals."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
config = VisionConfig(vision_interval=0.1) # Fast interval for testing
manager = VisionManager(mock_camera, config)
manager.start()
time.sleep(0.3) # Wait for at least 2 processing cycles
manager.stop()
# Camera should have been called at least once
assert mock_camera.get_latest_frame.call_count >= 1
def test_vision_manager_handles_none_frame(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager handles None frame gracefully."""
mock_camera.get_latest_frame.return_value = None
config = VisionConfig(vision_interval=0.1)
manager = VisionManager(mock_camera, config)
manager.start()
time.sleep(0.2)
manager.stop()
# Verify camera was called but no crashes occurred
assert mock_camera.get_latest_frame.called
def test_vision_manager_handles_processing_error(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager handles processing errors gracefully."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.side_effect = Exception("Processing error")
mock_cv2.IMWRITE_JPEG_QUALITY = 1
config = VisionConfig(vision_interval=0.1)
manager = VisionManager(mock_camera, config)
manager.start()
time.sleep(0.2)
manager.stop()
# Verify thread stopped gracefully despite errors
assert manager._stop_event.is_set()
def test_vision_manager_get_status(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager get_status returns correct information."""
manager = VisionManager(mock_camera, VisionConfig(vision_interval=5.0))
status = manager.get_status()
assert "last_processed" in status
assert "processor_info" in status
assert "config" in status
assert status["config"]["interval"] == 5.0
def test_vision_manager_skips_invalid_responses(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager doesn't update timestamp for invalid responses."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
# Make processor return invalid response
config = VisionConfig(vision_interval=0.1)
manager = VisionManager(mock_camera, config)
# Mock the processor's process_image method to return invalid response
with patch.object(manager.processor, 'process_image', return_value="Vision model not initialized"):
initial_time = manager._last_processed_time
manager.start()
time.sleep(0.2)
manager.stop()
# Last processed time should not have been updated
assert manager._last_processed_time == initial_time
def test_initialize_vision_manager_success(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test initialize_vision_manager creates VisionManager successfully."""
with patch("cookAIware.vision.processors.snapshot_download") as mock_download, \
patch("cookAIware.vision.processors.os.makedirs"), \
patch("cookAIware.vision.processors.config") as mock_config:
mock_config.LOCAL_VISION_MODEL = "test/model"
mock_config.HF_HOME = "/tmp/hf_cache"
result = initialize_vision_manager(mock_camera)
assert result is not None
assert isinstance(result, VisionManager)
mock_download.assert_called_once()
def test_initialize_vision_manager_download_failure(mock_torch: Any, mock_camera: Mock) -> None:
"""Test initialize_vision_manager handles download failure."""
with patch("cookAIware.vision.processors.snapshot_download") as mock_download, \
patch("cookAIware.vision.processors.os.makedirs"), \
patch("cookAIware.vision.processors.config") as mock_config:
mock_config.LOCAL_VISION_MODEL = "test/model"
mock_config.HF_HOME = "/tmp/hf_cache"
mock_download.side_effect = Exception("Network error")
result = initialize_vision_manager(mock_camera)
assert result is None
def test_initialize_vision_manager_processor_failure(mock_torch: Any, mock_camera: Mock) -> None:
"""Test initialize_vision_manager handles processor initialization failure."""
with patch("cookAIware.vision.processors.snapshot_download"), \
patch("cookAIware.vision.processors.os.makedirs"), \
patch("cookAIware.vision.processors.config") as mock_config, \
patch("cookAIware.vision.processors.AutoProcessor") as mock_proc:
mock_config.LOCAL_VISION_MODEL = "test/model"
mock_config.HF_HOME = "/tmp/hf_cache"
mock_proc.from_pretrained.side_effect = Exception("Model load error")
result = initialize_vision_manager(mock_camera)
assert result is None
def test_vision_processor_cuda_oom_recovery(mock_torch: Any, mock_transformers: Any) -> None:
"""Test VisionProcessor recovers from CUDA OOM errors."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
processor = VisionProcessor(VisionConfig(max_retries=2, retry_delay=0.01))
processor.initialize()
processor.device = "cuda" # Force CUDA for this test
# Make generate raise OOM error
mock_torch.cuda.OutOfMemoryError = type("OutOfMemoryError", (Exception,), {})
assert processor.model is not None
processor.model.generate.side_effect = mock_torch.cuda.OutOfMemoryError("OOM")
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
result = processor.process_image(test_image)
assert "GPU out of memory" in result
mock_torch.cuda.empty_cache.assert_called()
def test_vision_processor_cache_cleanup_mps(mock_torch: Any, mock_transformers: Any) -> None:
"""Test VisionProcessor cleans up MPS cache after processing."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
processor = VisionProcessor()
processor.initialize()
processor.device = "mps" # Force MPS for this test
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
processor.process_image(test_image)
# Should call mps empty_cache
mock_torch.mps.empty_cache.assert_called()
def test_vision_manager_thread_safety(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
"""Test VisionManager thread safety with multiple start/stop cycles."""
with patch("cookAIware.vision.processors.cv2") as mock_cv2:
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
mock_cv2.IMWRITE_JPEG_QUALITY = 1
config = VisionConfig(vision_interval=0.05)
manager = VisionManager(mock_camera, config)
# Multiple start/stop cycles
for _ in range(3):
manager.start()
time.sleep(0.1)
manager.stop()
time.sleep(0.05)
# Should not crash or leave dangling threads
assert manager._stop_event.is_set()