Spaces:
Running
Running
| """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" | |
| 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 | |
| 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 | |
| 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() | |