From 4372294b3017acfa255b77986c226eb6d5fc6616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 15:30:03 +0200 Subject: [PATCH 01/52] add normalize_images method as an abstract method in image_processors.py --- extractor_service/app/image_processors.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extractor_service/app/image_processors.py b/extractor_service/app/image_processors.py index 7fd253a..4f95391 100644 --- a/extractor_service/app/image_processors.py +++ b/extractor_service/app/image_processors.py @@ -44,6 +44,21 @@ def save_image(cls, image: np.ndarray, output_directory: Path, output_extension: Path: Path where image was saved. """ + @staticmethod + @abstractmethod + def normalize_images(images: list[np.ndarray], + target_size: tuple[int] | None = (224, 224)) -> np.array: + """ + Resize a batch of images and convert them to a normalized numpy array. + + Args: + images (list[np.ndarray]): List of numpy ndarray images to be normalized. + target_size (tuple | None): Target size to which the images will be resized. + Default is (224, 224). + + Returns: + np.ndarray: Normalized numpy array containing the resized images. + """ class OpenCVImage(ImageProcessor): """Image processor implementation using OpenCV library.""" From 180115a371b902cb894bd62eb7f61ec7b287d82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 15:39:19 +0200 Subject: [PATCH 02/52] fix: change OpenCVVideo method name get_next_video_frames -> get_next_frames --- extractor_service/app/extractors.py | 2 +- .../app/tests/unit/best_frames_extractor_test.py | 8 ++++---- extractor_service/app/tests/unit/video_processors_test.py | 2 +- extractor_service/app/video_processors.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index bc7b485..4bb0ee5 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -221,7 +221,7 @@ def _extract_best_frames(self, video_path: Path) -> list[np.ndarray]: list[np.ndarray]: List of best images(frames) from the given video. """ best_frames = [] - frames_batch_generator = OpenCVVideo.get_next_video_frames(video_path, self._config.batch_size) + frames_batch_generator = OpenCVVideo.get_next_frames(video_path, self._config.batch_size) for frames in frames_batch_generator: if not frames: continue diff --git a/extractor_service/app/tests/unit/best_frames_extractor_test.py b/extractor_service/app/tests/unit/best_frames_extractor_test.py index d0fe03a..2e1fe5d 100644 --- a/extractor_service/app/tests/unit/best_frames_extractor_test.py +++ b/extractor_service/app/tests/unit/best_frames_extractor_test.py @@ -42,14 +42,14 @@ def test_process(extractor, caplog, config): assert f"Starting frames extraction process from '{config.input_directory}'." in caplog.text -@patch("app.video_processors.OpenCVVideo.get_next_video_frames") -def test_extract_best_frames(mock_get_next_video_frames, extractor, caplog): +@patch("app.video_processors.OpenCVVideo.get_next_frames") +def test_extract_best_frames(mock_get_next_frames, extractor, caplog): video_path = Path("/fake/video.mp4") frames_batch = [MagicMock() for _ in range(10)] frames_batch_1 = frames_batch frames_batch_2 = [] frames_batch_3 = frames_batch - mock_get_next_video_frames.return_value = iter([frames_batch_1, frames_batch_2, frames_batch_3]) + mock_get_next_frames.return_value = iter([frames_batch_1, frames_batch_2, frames_batch_3]) test_ratings = [5, 6, 3, 8, 5, 2, 9, 1, 4, 7] extractor._evaluate_images = MagicMock(return_value=test_ratings) extractor._get_best_frames = MagicMock( @@ -58,7 +58,7 @@ def test_extract_best_frames(mock_get_next_video_frames, extractor, caplog): with caplog.at_level(logging.DEBUG): best_frames = extractor._extract_best_frames(video_path) - mock_get_next_video_frames.assert_called_once_with(video_path, extractor._config.batch_size) + mock_get_next_frames.assert_called_once_with(video_path, extractor._config.batch_size) assert extractor._evaluate_images.call_count == 2 assert extractor._get_best_frames.call_count == 2 assert len(best_frames) == 4 diff --git a/extractor_service/app/tests/unit/video_processors_test.py b/extractor_service/app/tests/unit/video_processors_test.py index 1ef3dea..a76b69b 100644 --- a/extractor_service/app/tests/unit/video_processors_test.py +++ b/extractor_service/app/tests/unit/video_processors_test.py @@ -62,7 +62,7 @@ def test_get_next_video_frames(mock_read, mock_get_attribute, mock_video_cap, mock_read.side_effect = lambda video, idx: f"frame{idx // 30}" with caplog.at_level(logging.DEBUG): - frames_generator = OpenCVVideo.get_next_video_frames(video_path, batch_size) + frames_generator = OpenCVVideo.get_next_frames(video_path, batch_size) batches = list(frames_generator) assert len(batches) == expected_num_batches, "Number of batches does not match expected" diff --git a/extractor_service/app/video_processors.py b/extractor_service/app/video_processors.py index 5c97f32..133495e 100644 --- a/extractor_service/app/video_processors.py +++ b/extractor_service/app/video_processors.py @@ -18,7 +18,7 @@ class VideoProcessor(ABC): """Abstract class for creating video processors used for managing video operations.""" @abstractmethod - def get_next_video_frames(self, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: + def get_next_frames(self, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: """ Abstract generator method to generate batches of frames from a video file. @@ -68,7 +68,7 @@ def _video_capture(video_path: Path) -> cv2.VideoCapture: video_cap.release() @classmethod - def get_next_video_frames(cls, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: + def get_next_frames(cls, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: """ Generates batches of frames from the specified video using OpenCV. From 4f6387a00f429df064d4f66a79389c8c07684541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 18:04:23 +0200 Subject: [PATCH 03/52] move out normalizing images from evalutor to extractors --- extractor_service/app/extractors.py | 29 +++++++++++++++---- extractor_service/app/image_evaluators.py | 14 ++++----- extractor_service/app/image_processors.py | 7 ++--- extractor_service/app/schemas.py | 2 ++ ...xtractor_and_evaluator_integration_test.py | 3 +- .../tests/unit/best_frames_extractor_test.py | 16 ++++++---- .../app/tests/unit/extractor_test.py | 10 +++++++ .../app/tests/unit/image_evaluators_test.py | 13 ++++----- .../tests/unit/top_images_extractor_test.py | 9 ++++-- 9 files changed, 68 insertions(+), 35 deletions(-) diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 4bb0ee5..1522606 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -88,17 +88,17 @@ def _list_input_directory_files(self, extensions: tuple[str], logger.debug("Listed file paths: %s", files) return files - def _evaluate_images(self, images: list[np.ndarray]) -> np.array: + def _evaluate_images(self, normalized_images: np.ndarray) -> np.array: """ Rating all images in provided images batch using already initialized image evaluator. Args: - images (list[np.ndarray]): List of images in numpy ndarrays. + normalized_images (list[np.ndarray]): Already normalized images np.ndarray for evaluating. Returns: np.array: Array with images scores in given images order. """ - scores = np.array(self._image_evaluator.evaluate_images(images)) + scores = np.array(self._image_evaluator.evaluate_images(normalized_images)) return scores @staticmethod @@ -139,6 +139,21 @@ def _save_images(self, images: list[np.ndarray]) -> None: for future in futures: future.result() + @staticmethod + def _normalize_images(images: list[np.ndarray], target_size: tuple[int, int]) -> np.ndarray: + """ + Normalize all images in given list to target size for further operations. + + Args: + images (list[np.ndarray]): List of np.ndarray images to normalize. + target_size (tuple[int, int]): Images will be normalized to this size. + + Returns: + np.ndarray: All images as a one numpy array. + """ + normalized_images = OpenCVImage.normalize_images(images, target_size) + return normalized_images + @staticmethod def _add_prefix(prefix: str, path: Path) -> Path: """ @@ -225,8 +240,9 @@ def _extract_best_frames(self, video_path: Path) -> list[np.ndarray]: for frames in frames_batch_generator: if not frames: continue - logger.debug("Frames pack generated.") - scores = self._evaluate_images(frames) + logger.debug("Frames batch generated.") + noramalized_frames = self._normalize_images(frames, self._config.target_image_size) + scores = self._evaluate_images(noramalized_frames) selected_frames = self._get_best_frames(frames, scores, self._config.compering_group_size) best_frames.extend(selected_frames) @@ -268,7 +284,8 @@ def process(self) -> None: for batch_index in range(0, len(images_paths), self._config.batch_size): batch = images_paths[batch_index:batch_index + self._config.batch_size] images = self._read_images(batch) - scores = self._evaluate_images(images) + noramalized_images = self._normalize_images(images, self._config.target_image_size) + scores = self._evaluate_images(noramalized_images) top_images = self._get_top_percent_images(images, scores, self._config.top_images_percent) self._save_images(top_images) diff --git a/extractor_service/app/image_evaluators.py b/extractor_service/app/image_evaluators.py index b5cc93b..71c792e 100644 --- a/extractor_service/app/image_evaluators.py +++ b/extractor_service/app/image_evaluators.py @@ -32,7 +32,7 @@ def __init__(self, config: ExtractorConfig) -> None: """ @abstractmethod - def evaluate_images(self, images: list[np.ndarray]) -> list[float]: + def evaluate_images(self, images: np.ndarray) -> list[float]: """ Evaluates images batch and returns it. @@ -79,20 +79,20 @@ def __init__(self, config: ExtractorConfig) -> None: """ self._model = _ResNetModel.get_model(config) - def evaluate_images(self, images: list[np.ndarray]) -> list[float]: + def evaluate_images(self, images: np.ndarray) -> list[float]: """ Evaluate a batch of images using the NIMA model, and return the results. Args: - images (list[np.ndarray]): Batch of numpy array images to be evaluated. + images (np.ndarray): Batch of numpy ndarray images to be evaluated. Returns: list[float]: List of scores corresponding to the input images. """ logger.info("Evaluating images...") - img_array = OpenCVImage.normalize_images(images) - tensor = convert_to_tensor(img_array) - predictions = self._model.predict(tensor, batch_size=len(images), verbose=0) + tensor = convert_to_tensor(images) + batch_size = images.shape[0] + predictions = self._model.predict(tensor, batch_size=batch_size, verbose=0) weights = _ResNetModel.get_prediction_weights() scores = [self._calculate_weighted_mean(prediction, weights) for prediction in predictions] self._check_scores(images, scores) @@ -131,7 +131,7 @@ class DownloadingModelWeightsError(Exception): _model = None @classmethod - def reset(cls): + def reset(cls) -> None: """Resets class for using new model and config.""" cls._model = None cls._config = None diff --git a/extractor_service/app/image_processors.py b/extractor_service/app/image_processors.py index 4f95391..ddcf086 100644 --- a/extractor_service/app/image_processors.py +++ b/extractor_service/app/image_processors.py @@ -46,8 +46,7 @@ def save_image(cls, image: np.ndarray, output_directory: Path, output_extension: @staticmethod @abstractmethod - def normalize_images(images: list[np.ndarray], - target_size: tuple[int] | None = (224, 224)) -> np.array: + def normalize_images(images: list[np.ndarray], target_size: tuple[int]) -> np.array: """ Resize a batch of images and convert them to a normalized numpy array. @@ -112,15 +111,13 @@ def _generate_filename() -> str: return filename @staticmethod - def normalize_images(images: list[np.ndarray], - target_size: tuple[int] | None = (224, 224)) -> np.array: + def normalize_images(images: list[np.ndarray], target_size: tuple[int]) -> np.array: """ Resize a batch of images and convert them to a normalized numpy array. Args: images (list[np.ndarray]): List of numpy ndarray images to be normalized. target_size (tuple | None): Target size to which the images will be resized. - Default is (224, 224). Returns: np.ndarray: Normalized numpy array containing the resized images. diff --git a/extractor_service/app/schemas.py b/extractor_service/app/schemas.py index f101ef8..82dfdaa 100644 --- a/extractor_service/app/schemas.py +++ b/extractor_service/app/schemas.py @@ -29,6 +29,7 @@ class ExtractorConfig(BaseModel): compering_group_size (int): Maximum number of images in a group to compare for finding the best one. top_images_percent (float): Percentage threshold to determine the top images based on scores. images_output_format (str): Format for saving output images, e.g., '.jpg', '.png'. + target_image_size (tuple[int, int]): Images will be normalized to this size. weights_directory (Path | str): Directory path where model weights are stored. weights_filename (str): The filename of the model weights file to be loaded. weights_repo_url (str): URL to the repository where model weights can be downloaded. @@ -42,6 +43,7 @@ class ExtractorConfig(BaseModel): compering_group_size: int = 5 top_images_percent: float = 90.0 images_output_format: str = ".jpg" + target_image_size: tuple[int, int] = (224, 224) weights_directory: Path | str = Path.home() / ".cache" / "huggingface" weights_filename: str = "weights.h5" weights_repo_url: str = "https://huggingface.co/BKDDFS/nima_weights/resolve/main/" diff --git a/extractor_service/app/tests/integration/extractor_and_evaluator_integration_test.py b/extractor_service/app/tests/integration/extractor_and_evaluator_integration_test.py index 314b9e9..541e562 100644 --- a/extractor_service/app/tests/integration/extractor_and_evaluator_integration_test.py +++ b/extractor_service/app/tests/integration/extractor_and_evaluator_integration_test.py @@ -23,6 +23,7 @@ def test_evaluate_images(extractor, config): files = extractor._list_input_directory_files(config.images_extensions) images = extractor._read_images(files) extractor._get_image_evaluator() - result = extractor._evaluate_images(images) + normalized_images = extractor._normalize_images(images, config.target_image_size) + result = extractor._evaluate_images(normalized_images) assert isinstance(result, np.ndarray) diff --git a/extractor_service/app/tests/unit/best_frames_extractor_test.py b/extractor_service/app/tests/unit/best_frames_extractor_test.py index 2e1fe5d..5fc4c7f 100644 --- a/extractor_service/app/tests/unit/best_frames_extractor_test.py +++ b/extractor_service/app/tests/unit/best_frames_extractor_test.py @@ -6,6 +6,7 @@ import pytest from app.extractors import BestFramesExtractor +from app.video_processors import OpenCVVideo @pytest.fixture(scope="function") @@ -42,14 +43,18 @@ def test_process(extractor, caplog, config): assert f"Starting frames extraction process from '{config.input_directory}'." in caplog.text -@patch("app.video_processors.OpenCVVideo.get_next_frames") -def test_extract_best_frames(mock_get_next_frames, extractor, caplog): +@patch.object(OpenCVVideo, "get_next_frames") +@patch.object(BestFramesExtractor, "_normalize_images") +def test_extract_best_frames(mock_normalize, mock_get_next_frames, extractor, caplog): video_path = Path("/fake/video.mp4") frames_batch = [MagicMock() for _ in range(10)] frames_batch_1 = frames_batch frames_batch_2 = [] frames_batch_3 = frames_batch mock_get_next_frames.return_value = iter([frames_batch_1, frames_batch_2, frames_batch_3]) + normalized_frames_1 = MagicMock(spec=np.ndarray) + normalized_frames_2 = MagicMock(spec=np.ndarray) + mock_normalize.side_effect = [normalized_frames_1, normalized_frames_2] test_ratings = [5, 6, 3, 8, 5, 2, 9, 1, 4, 7] extractor._evaluate_images = MagicMock(return_value=test_ratings) extractor._get_best_frames = MagicMock( @@ -60,17 +65,18 @@ def test_extract_best_frames(mock_get_next_frames, extractor, caplog): mock_get_next_frames.assert_called_once_with(video_path, extractor._config.batch_size) assert extractor._evaluate_images.call_count == 2 + assert extractor._normalize_images.call_count == 2 assert extractor._get_best_frames.call_count == 2 assert len(best_frames) == 4 - extractor._evaluate_images.assert_any_call(frames_batch_1) - extractor._evaluate_images.assert_any_call(frames_batch_3) + extractor._evaluate_images.assert_any_call(normalized_frames_1) + extractor._evaluate_images.assert_any_call(normalized_frames_2) for batch in [frames_batch_1, frames_batch_3]: extractor._get_best_frames.assert_any_call( batch, test_ratings, extractor._config.compering_group_size ) - assert caplog.text.count("Frames pack generated.") == 2 + assert caplog.text.count("Frames batch generated.") == 2 def test_get_best_frames(caplog, extractor): diff --git a/extractor_service/app/tests/unit/extractor_test.py b/extractor_service/app/tests/unit/extractor_test.py index 94e6b60..e47025d 100644 --- a/extractor_service/app/tests/unit/extractor_test.py +++ b/extractor_service/app/tests/unit/extractor_test.py @@ -5,6 +5,7 @@ import numpy as np import pytest +from app.image_processors import OpenCVImage from app.extractors import (Extractor, ExtractorFactory, BestFramesExtractor, @@ -91,6 +92,15 @@ def test_save_images(mock_executor, mock_save_image, extractor, config): assert mock_executor.submit.return_value.result.call_count == len(images) +@patch.object(OpenCVImage, "normalize_images") +def test_normalize_images(mock_normalize, extractor, config): + images = [MagicMock() for _ in range(3)] + + extractor._normalize_images(images, config.target_image_size) + + mock_normalize.assert_called_once_with(images, config.target_image_size) + + @patch.object(Path, "iterdir") @patch.object(Path, "is_file") def test_list_input_directory_files(mock_is_file, mock_iterdir, extractor, caplog, config): diff --git a/extractor_service/app/tests/unit/image_evaluators_test.py b/extractor_service/app/tests/unit/image_evaluators_test.py index 40c62cf..21a8c18 100644 --- a/extractor_service/app/tests/unit/image_evaluators_test.py +++ b/extractor_service/app/tests/unit/image_evaluators_test.py @@ -26,17 +26,15 @@ def test_evaluator_initialization(mock_get_model, config): assert instance._model == test_model -@patch.object(OpenCVImage, "normalize_images") @patch("app.image_evaluators.convert_to_tensor") @patch.object(InceptionResNetNIMA, "_calculate_weighted_mean") @patch.object(InceptionResNetNIMA, "_check_scores") -def test_evaluate_images(mock_check, mock_calculate, mock_convert_to_tensor, mock_normalize_images, evaluator, caplog): - fake_images = [MagicMock(np.ndarray) for _ in range(3)] - img_array = "some_array" +def test_evaluate_images(mock_check, mock_calculate, mock_convert_to_tensor, evaluator, caplog): + fake_images = MagicMock(spec=np.ndarray) + fake_images.shape = (3, 2, 2) tensor = "some_tensor" predictions = [1.0, 2.0, 3.0] expected_scores = [10.0, 20.0, 30.0] - mock_normalize_images.return_value = img_array mock_convert_to_tensor.return_value = tensor mock_calculate.side_effect = expected_scores evaluator._model.predict.return_value = predictions @@ -44,9 +42,8 @@ def test_evaluate_images(mock_check, mock_calculate, mock_convert_to_tensor, moc with caplog.at_level(logging.INFO): result = evaluator.evaluate_images(fake_images) - mock_normalize_images.assert_called_once_with(fake_images) - mock_convert_to_tensor.assert_called_once_with(img_array) - evaluator._model.predict.assert_called_once_with(tensor, batch_size=len(fake_images), verbose=0) + mock_convert_to_tensor.assert_called_once_with(fake_images) + evaluator._model.predict.assert_called_once_with(tensor, batch_size=fake_images.shape[0], verbose=0) mock_calculate.assert_has_calls([ call(prediction, _ResNetModel._prediction_weights) for prediction in predictions], any_order=True diff --git a/extractor_service/app/tests/unit/top_images_extractor_test.py b/extractor_service/app/tests/unit/top_images_extractor_test.py index 645f8b8..b35bfed 100644 --- a/extractor_service/app/tests/unit/top_images_extractor_test.py +++ b/extractor_service/app/tests/unit/top_images_extractor_test.py @@ -5,6 +5,7 @@ import pytest from app.extractors import TopImagesExtractor +from app.image_processors import OpenCVImage @pytest.fixture() @@ -13,8 +14,9 @@ def extractor(config): return extractor -@patch("app.image_processors.OpenCVImage.read_image") -def test_process_with_images(mock_read_image, extractor, caplog, config): +@patch.object(OpenCVImage, "read_image") +@patch.object(TopImagesExtractor, "_normalize_images") +def test_process_with_images(mock_normalize, mock_read_image, extractor, caplog, config): # Setup test_images = [ "/fake/directory/image1.jpg", "/fake/directory/image2.jpg", "/fake/directory/image3.jpg"] @@ -37,7 +39,8 @@ def test_process_with_images(mock_read_image, extractor, caplog, config): extractor._list_input_directory_files.assert_called_once_with( extractor._config.images_extensions) mock_read_image.assert_has_calls([call(path) for path in test_images]) - extractor._evaluate_images.assert_called_once_with([mock_read_image.return_value]*3) + mock_normalize.assert_called_once_with([mock_read_image.return_value]*3, extractor._config.target_image_size) + extractor._evaluate_images.assert_called_once_with(mock_normalize.return_value) extractor._get_top_percent_images.assert_called_once_with( [mock_read_image.return_value]*3, test_ratings, extractor._config.top_images_percent) extractor._save_images.assert_called_once_with(best_image) From 0327064558b097ab23a6eeff6420d93d07ff45e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 21:31:55 +0200 Subject: [PATCH 04/52] rename get_extractor() -> create_extractor() in ExtractorFactory --- extractor_service/app/extractor_manager.py | 2 +- extractor_service/app/extractors.py | 2 +- extractor_service/app/image_processors.py | 3 ++- .../app/tests/unit/extractor_manager_test.py | 8 ++++---- extractor_service/app/tests/unit/extractor_test.py | 10 +++++----- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/extractor_service/app/extractor_manager.py b/extractor_service/app/extractor_manager.py index cbba330..835f699 100644 --- a/extractor_service/app/extractor_manager.py +++ b/extractor_service/app/extractor_manager.py @@ -49,7 +49,7 @@ def start_extractor(cls, background_tasks: BackgroundTasks, config: ExtractorCon """ cls._config = config cls._check_is_already_extracting() - extractor_class = ExtractorFactory.get_extractor(extractor_name) + extractor_class = ExtractorFactory.create_extractor(extractor_name) background_tasks.add_task(cls.__run_extractor, extractor_class, extractor_name) message = f"'{extractor_name}' started." return message diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 1522606..46eecdb 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -184,7 +184,7 @@ def _signal_readiness_for_shutdown() -> None: class ExtractorFactory: """Extractor factory for getting extractors class by their names.""" @staticmethod - def get_extractor(extractor_name: str) -> Type[Extractor]: + def create_extractor(extractor_name: str) -> Type[Extractor]: """ Match extractor class by its name and return its class. diff --git a/extractor_service/app/image_processors.py b/extractor_service/app/image_processors.py index ddcf086..0491155 100644 --- a/extractor_service/app/image_processors.py +++ b/extractor_service/app/image_processors.py @@ -59,6 +59,7 @@ def normalize_images(images: list[np.ndarray], target_size: tuple[int]) -> np.ar np.ndarray: Normalized numpy array containing the resized images. """ + class OpenCVImage(ImageProcessor): """Image processor implementation using OpenCV library.""" @staticmethod @@ -111,7 +112,7 @@ def _generate_filename() -> str: return filename @staticmethod - def normalize_images(images: list[np.ndarray], target_size: tuple[int]) -> np.array: + def normalize_images(images: list[np.ndarray], target_size: tuple[int, int]) -> np.array: """ Resize a batch of images and convert them to a normalized numpy array. diff --git a/extractor_service/app/tests/unit/extractor_manager_test.py b/extractor_service/app/tests/unit/extractor_manager_test.py index 9e3f938..69a84f2 100644 --- a/extractor_service/app/tests/unit/extractor_manager_test.py +++ b/extractor_service/app/tests/unit/extractor_manager_test.py @@ -11,18 +11,18 @@ def test_get_active_extractor(): assert ExtractorManager.get_active_extractor() is None -@patch.object(ExtractorFactory, "get_extractor") +@patch.object(ExtractorFactory, "create_extractor") @patch.object(ExtractorManager, "_check_is_already_extracting") -def test_start_extractor(mock_checking, mock_get_extractor, config): +def test_start_extractor(mock_checking, mock_create_extractor, config): extractor_name = "some_extractor" mock_extractor_class = MagicMock() mock_background_tasks = MagicMock(spec=BackgroundTasks) - mock_get_extractor.return_value = mock_extractor_class + mock_create_extractor.return_value = mock_extractor_class message = ExtractorManager.start_extractor(mock_background_tasks, config, extractor_name) mock_checking.assert_called_once() - mock_get_extractor.assert_called_once_with(extractor_name) + mock_create_extractor.assert_called_once_with(extractor_name) mock_background_tasks.add_task.assert_called_once_with( ExtractorManager._ExtractorManager__run_extractor, mock_extractor_class, diff --git a/extractor_service/app/tests/unit/extractor_test.py b/extractor_service/app/tests/unit/extractor_test.py index e47025d..3844226 100644 --- a/extractor_service/app/tests/unit/extractor_test.py +++ b/extractor_service/app/tests/unit/extractor_test.py @@ -157,17 +157,17 @@ def test_signal_readiness_for_shutdown(extractor, caplog): assert "Service ready for shutdown" in caplog.text -def test_get_extractor_known_extractors(): - assert ExtractorFactory.get_extractor("best_frames_extractor") is BestFramesExtractor - assert ExtractorFactory.get_extractor("top_images_extractor") is TopImagesExtractor +def test_create_extractor_known_extractors(): + assert ExtractorFactory.create_extractor("best_frames_extractor") is BestFramesExtractor + assert ExtractorFactory.create_extractor("top_images_extractor") is TopImagesExtractor -def test_get_extractor_unknown_extractor_raises(caplog): +def test_create_extractor_unknown_extractor_raises(caplog): unknown_extractor_name = "unknown_extractor" expected_massage = f"Provided unknown extractor name: {unknown_extractor_name}" with pytest.raises(ValueError, match=expected_massage), \ caplog.at_level(logging.ERROR): - ExtractorFactory.get_extractor(unknown_extractor_name) + ExtractorFactory.create_extractor(unknown_extractor_name) assert expected_massage in caplog.text From 64f46110dc9052f5946563324a786d5131fc1246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 21:54:31 +0200 Subject: [PATCH 05/52] add license to docstrings --- config.py | 20 +++++++++++++++++- extractor_service/app/extractor_manager.py | 16 +++++++++++++++ extractor_service/app/extractors.py | 16 +++++++++++++++ extractor_service/app/image_evaluators.py | 24 +++++++++++++++++----- extractor_service/app/image_processors.py | 16 +++++++++++++++ extractor_service/app/schemas.py | 16 +++++++++++++++ extractor_service/app/video_processors.py | 16 +++++++++++++++ extractor_service/main.py | 16 +++++++++++++++ service_manager/docker_manager.py | 16 +++++++++++++++ service_manager/service_initializer.py | 20 +++++++++++++++++- start.py | 16 +++++++++++++++ 11 files changed, 185 insertions(+), 7 deletions(-) diff --git a/config.py b/config.py index 279ddce..a56db9a 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,22 @@ -"""Main configuration dataclass for extractor service manager tool.""" +""" +Main configuration dataclass for extractor service manager tool. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" from dataclasses import dataclass from pathlib import Path diff --git a/extractor_service/app/extractor_manager.py b/extractor_service/app/extractor_manager.py index 835f699..b4aeec0 100644 --- a/extractor_service/app/extractor_manager.py +++ b/extractor_service/app/extractor_manager.py @@ -1,6 +1,22 @@ """ This module provides manager class for running extractors and managing extraction process lifecycle. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging from typing import Type diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 46eecdb..1497f46 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -5,6 +5,22 @@ - Extractors: - BestFramesExtractor: For extracting best frames from all videos from any directory. - TopImagesExtractor: For extracting images with top percent evaluating from any directory. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ from concurrent.futures import ThreadPoolExecutor from pathlib import Path diff --git a/extractor_service/app/image_evaluators.py b/extractor_service/app/image_evaluators.py index 71c792e..2ae2543 100644 --- a/extractor_service/app/image_evaluators.py +++ b/extractor_service/app/image_evaluators.py @@ -1,7 +1,23 @@ """ This module provides abstract class for creating image evaluators and image evaluators. Image evaluators: - - NeuralImageAssessment: NIMA model based on the InceptionResNetV2 architecture. + - InceptionResNetNIMA: NIMA model with helper classes. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging from abc import ABC, abstractmethod @@ -14,7 +30,6 @@ from tensorflow.keras.layers import Dense, Dropout from tensorflow.keras.applications.inception_resnet_v2 import InceptionResNetV2 -from .image_processors import OpenCVImage from .schemas import ExtractorConfig logger = logging.getLogger(__name__) @@ -67,8 +82,6 @@ class InceptionResNetNIMA(ImageEvaluator): """ NeuralImageAssessment model based image evaluator. It uses NIMA for evaluating aesthetics of images. - NIMA google research: - https://research.google/blog/introducing-nima-neural-image-assessment/ """ def __init__(self, config: ExtractorConfig) -> None: """ @@ -120,7 +133,8 @@ def _calculate_weighted_mean(prediction: np.array, weights: np.array = None) -> class _NIMAModel(ABC): - """Abstract base class for the NIMA models. Uses a singleton pattern + """ + Abstract base class for the NIMA models. Uses a singleton pattern to manage a unique instance of the models. This is helper class for NeuralImageAssessment class. """ diff --git a/extractor_service/app/image_processors.py b/extractor_service/app/image_processors.py index 0491155..5ea30fb 100644 --- a/extractor_service/app/image_processors.py +++ b/extractor_service/app/image_processors.py @@ -2,6 +2,22 @@ This module provides abstract class for creating image processors and image processors. Image processors: - OpenCVImage: using OpenCV library to manage operations on images. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging import uuid diff --git a/extractor_service/app/schemas.py b/extractor_service/app/schemas.py index 82dfdaa..8378af1 100644 --- a/extractor_service/app/schemas.py +++ b/extractor_service/app/schemas.py @@ -4,6 +4,22 @@ - ExtractorConfig: Model containing the extractors configuration parameters. - Message: Model for encapsulating messages returned by the application. - ExtractorStatus: Model representing the status of the currently working extractor in the system. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging from pathlib import Path diff --git a/extractor_service/app/video_processors.py b/extractor_service/app/video_processors.py index 133495e..d270d2b 100644 --- a/extractor_service/app/video_processors.py +++ b/extractor_service/app/video_processors.py @@ -2,6 +2,22 @@ This module provides abstract class for creating video processors and video processors. Video processors: - OpenCVVideo: using OpenCV library to manage operations on videos. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging from abc import ABC, abstractmethod diff --git a/extractor_service/main.py b/extractor_service/main.py index 43fdcbc..c733f3a 100644 --- a/extractor_service/main.py +++ b/extractor_service/main.py @@ -6,6 +6,22 @@ For checking is some extractor already running. POST /extractors/{extractor_name}: For running chosen extractor. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging import sys diff --git a/service_manager/docker_manager.py b/service_manager/docker_manager.py index 5bf70cb..4d21d84 100644 --- a/service_manager/docker_manager.py +++ b/service_manager/docker_manager.py @@ -5,6 +5,22 @@ This module defines a DockerManager class to handle Docker operations like building images, managing container lifecycle, and monitoring container logs. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import subprocess import sys diff --git a/service_manager/service_initializer.py b/service_manager/service_initializer.py index 03d8b7f..bd316ae 100644 --- a/service_manager/service_initializer.py +++ b/service_manager/service_initializer.py @@ -1,4 +1,22 @@ -"""This module provide tool for starting extractor service.""" +""" +This module provide tool for starting extractor service. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" import argparse import json import logging diff --git a/start.py b/start.py index 3575ea0..bbed700 100644 --- a/start.py +++ b/start.py @@ -1,6 +1,22 @@ """ This module provide script for starting extraction process with given arguments in fast and easy way. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . """ import logging import argparse From 2cb771e7acbbb1976fc523554154d3ccb135bdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 21:59:07 +0200 Subject: [PATCH 06/52] move ServiceShutdownSignal inside DockerManager --- service_manager/docker_manager.py | 10 ++++------ service_manager/tests/unit/docker_manager_test.py | 11 +++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/service_manager/docker_manager.py b/service_manager/docker_manager.py index 4d21d84..ad83276 100644 --- a/service_manager/docker_manager.py +++ b/service_manager/docker_manager.py @@ -29,15 +29,13 @@ logger = logging.getLogger(__name__) -class ServiceShutdownSignal(Exception): - """Exception raised when the service signals it is ready to be shut down.""" - - class DockerManager: """ Manages Docker containers and images, including operations like building, starting, stopping, and logging containers. """ + class ServiceShutdownSignal(Exception): + """Exception raised when the service signals it is ready to be shut down.""" def __init__(self, container_name: str, input_dir: str, output_dir: str, port: int, force_build: bool) -> None: @@ -183,10 +181,10 @@ def follow_container_logs(self) -> None: for line in iter(process.stdout.readline, ''): sys.stdout.write(line) if "Service ready for shutdown" in line: - raise ServiceShutdownSignal("Service has signaled readiness for shutdown.") + raise self.ServiceShutdownSignal("Service has signaled readiness for shutdown.") except KeyboardInterrupt: logger.info("Process stopped by user.") - except ServiceShutdownSignal: + except self.ServiceShutdownSignal: logger.info("Service has signaled readiness for shutdown.") finally: self.__stop_log_process(process) diff --git a/service_manager/tests/unit/docker_manager_test.py b/service_manager/tests/unit/docker_manager_test.py index fc1ceb1..7bbf575 100644 --- a/service_manager/tests/unit/docker_manager_test.py +++ b/service_manager/tests/unit/docker_manager_test.py @@ -4,7 +4,7 @@ import pytest -from service_manager.docker_manager import DockerManager, ServiceShutdownSignal +from service_manager.docker_manager import DockerManager def test_docker_manager_init(caplog, config): @@ -251,15 +251,18 @@ def test_follow_container_logs_stopped_by_user(mock_stop, mock_run_log, mock_std @patch("service_manager.docker_manager.sys.stdout.write") @patch.object(DockerManager, "_run_log_process") @patch.object(DockerManager, "_stop_container") -def test_follow_container_logs_stopped_automatically(mock_stop, mock_run_log, mock_stdout, docker, caplog): +def test_follow_container_logs_stopped_automatically(mock_stop, mock_run_log, + mock_stdout, docker, caplog): mock_process = MagicMock() - mock_process.stdout.readline.side_effect = ["log line 1\n", "log line 2\n", ServiceShutdownSignal()] + mock_process.stdout.readline.side_effect = [ + "log line 1\n", "log line 2\n", DockerManager.ServiceShutdownSignal() + ] mock_run_log.return_value = mock_process mock_process.terminate = MagicMock() mock_process.wait = MagicMock() with caplog.at_level(logging.INFO), \ - patch.object(subprocess, "Popen", autospec=True) as pop: + patch.object(subprocess, "Popen", autospec=True): docker.follow_container_logs() mock_run_log.assert_called_once() From ad56ad89b22d7bc90b4118bccc584af6c53520de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Thu, 16 May 2024 22:27:04 +0200 Subject: [PATCH 07/52] make ServiceInitializer attributes protected --- service_manager/service_initializer.py | 10 +++++----- service_manager/tests/unit/service_initializer_test.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/service_manager/service_initializer.py b/service_manager/service_initializer.py index bd316ae..6053bd7 100644 --- a/service_manager/service_initializer.py +++ b/service_manager/service_initializer.py @@ -36,10 +36,10 @@ class ServiceInitializer: """ def __init__(self, user_input: argparse.Namespace) -> None: """Initializes the service initializer by taking and validating user input.""" - self.input_directory = self._check_directory(user_input.input_dir) - self.output_directory = self._check_directory(user_input.output_dir) - self.extractor_name = user_input.extractor_name - self.port = user_input.port + self._input_directory = self._check_directory(user_input.input_dir) + self._output_directory = self._check_directory(user_input.output_dir) + self._extractor_name = user_input.extractor_name + self._port = user_input.port @staticmethod def _check_directory(directory: str) -> Path: @@ -65,7 +65,7 @@ def _check_directory(directory: str) -> Path: def run_extractor(self, extractor_url: Union[str, None] = None) -> None: """Send POST request to local port extractor service to start chosen extractor.""" if not extractor_url: - extractor_url = f"http://localhost:{self.port}/extractors/{self.extractor_name}" + extractor_url = f"http://localhost:{self._port}/extractors/{self._extractor_name}" req = Request(extractor_url, method="POST") start_time = time.time() while True: diff --git a/service_manager/tests/unit/service_initializer_test.py b/service_manager/tests/unit/service_initializer_test.py index 0bb4e48..d68dba0 100644 --- a/service_manager/tests/unit/service_initializer_test.py +++ b/service_manager/tests/unit/service_initializer_test.py @@ -47,10 +47,10 @@ def test_start_various_args(mock_check_directory, arg_set): service = ServiceInitializer(user_input) - assert service.extractor_name == arg_set["extractor_name"] - assert service.input_directory == arg_set["input"] - assert service.output_directory == arg_set["output"] - assert service.port == arg_set["port"] + assert service._extractor_name == arg_set["extractor_name"] + assert service._input_directory == arg_set["input"] + assert service._output_directory == arg_set["output"] + assert service._port == arg_set["port"] mock_check_directory.assert_any_call(arg_set["input"]) mock_check_directory.assert_any_call(arg_set["output"]) @@ -67,7 +67,7 @@ def test_check_valid_directory(): @patch.object(time, "time") def test_run_extractor(mock_time, service): - test_url = f"http://localhost:{service.port}/extractors/{service.extractor_name}" + test_url = f"http://localhost:{service._port}/extractors/{service._extractor_name}" test_method = "POST" start_time = 100 mock_time.side_effect = [start_time, start_time + 1, start_time + 2, start_time + 3] From bd2e5e867a26d43d0e0782d3d084047469c1a8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Fri, 17 May 2024 12:14:45 +0200 Subject: [PATCH 08/52] add architecture section to README --- .github/README.pl.md | 47 +++++++++++++++++++++++++--------------- README.md | 47 +++++++++++++++++++++++++--------------- static/architecture.jpg | Bin 0 -> 187179 bytes 3 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 static/architecture.jpg diff --git a/.github/README.pl.md b/.github/README.pl.md index 6fe5b2c..54eb5eb 100644 --- a/.github/README.pl.md +++ b/.github/README.pl.md @@ -199,23 +199,32 @@

💡O projekcie:

📐 Jak to działa

@@ -372,6 +381,10 @@
+
+

Architektura

+ +

🛠️ Użyte technologie

+
+

Architecture

+ +

🛠️ Built with

+

Lowest tested specs - i5-4300U, 8GB RAM (ThinkPad T440) - 4k video, default 100img/batch.

+

Remember you can always decrease images batch size in schemas.py if you out of RAM.

Install Docker: Docker Desktop: https://www.docker.com/products/docker-desktop/
- Install Python v3.10+: + Install Python v3.7+: MS Store: https://apps.microsoft.com/detail/9ncvdn91xzqp?hl=en-US&gl=US
Python.org: https://www.python.org/downloads/
@@ -148,7 +150,9 @@ You can modify the default values in config.py to adjust the application to your needs.
Warning!
Please note that when running the .bat file, - Windows Defender may flag it as dangerous because it comes from an unknown source. + Windows Defender may flag it as dangerous. + This happens because obtaining a code-signing certificate + to prevent this warning requires a paid certificate...

Run start.py from the terminal.

From 170b280851ab9cf1c8d3e2e31748d337e8766381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Fri, 24 May 2024 18:55:02 +0200 Subject: [PATCH 14/52] fix paths in docker-compose.yaml and change method 2 in readme --- README.md | 1 + docker-compose.yaml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b99701..9c836d6 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@

Does not require Python. Run using Docker Compose.

Docker Compose Docs: https://docs.docker.com/compose/

+

Remember to delete GPU part in docker-compose.yaml if you don't have GPU!

  1. Run the service:
    docker-compose up --build -d
  2. Send a request to the chosen endpoint. diff --git a/docker-compose.yaml b/docker-compose.yaml index e56e2a4..18d47c5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,8 +6,8 @@ services: ports: - "8100:8100" volumes: - - "B:/frames_evaluators/input_directory:/app/input_directory" - - "B:/frames_evaluators/output_directory:/app/output_directory" + - "./input_directory:/app/input_directory" + - "./output_directory:/app/output_directory" environment: - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility From 2fa494dbdcfe5ea4d3b9c871a3772709f4c53d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 19:01:30 +0200 Subject: [PATCH 15/52] add 'created at' badge --- .github/README.pl.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/README.pl.md b/.github/README.pl.md index fa39288..4247c71 100644 --- a/.github/README.pl.md +++ b/.github/README.pl.md @@ -3,6 +3,7 @@

+ Github Created At GitHub Downloads (all assets, all releases) GitHub License GitHub Release diff --git a/README.md b/README.md index 9c836d6..9f245dc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

+ Github Created At GitHub last commit GitHub License GitHub Tag From 783e577e6b67ff1ab74e91a828160f5cef0dd33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 21:34:10 +0200 Subject: [PATCH 16/52] change _get_image_evaluator() to returns ImageEvaluator insted of specific evaluator --- extractor_service/app/extractors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 71227f1..78fa6eb 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -34,7 +34,7 @@ from .schemas import ExtractorConfig from .video_processors import OpenCVVideo from .image_processors import OpenCVImage -from .image_evaluators import InceptionResNetNIMA +from .image_evaluators import InceptionResNetNIMA, ImageEvaluator logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def __init__(self, config: ExtractorConfig) -> None: def process(self) -> None: """Abstract main method for extraction process implementation.""" - def _get_image_evaluator(self) -> InceptionResNetNIMA: + def _get_image_evaluator(self) -> ImageEvaluator: """ Initializes one of image evaluators (currently NIMA) and adds it to extractor instance parameters. From a37a879bcaf4adc4259bc1565991e1bc683cd718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 21:59:58 +0200 Subject: [PATCH 17/52] fix DIP in extractors --- extractor_service/app/extractors.py | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 78fa6eb..86e48ba 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -32,9 +32,9 @@ import numpy as np from .schemas import ExtractorConfig -from .video_processors import OpenCVVideo -from .image_processors import OpenCVImage -from .image_evaluators import InceptionResNetNIMA, ImageEvaluator +from .video_processors import VideoProcessor +from .image_processors import ImageProcessor +from .image_evaluators import ImageEvaluator logger = logging.getLogger(__name__) @@ -44,15 +44,24 @@ class Extractor(ABC): class EmptyInputDirectoryError(Exception): """Error appear when extractor can't get any input to extraction.""" - def __init__(self, config: ExtractorConfig) -> None: + def __init__(self, config: ExtractorConfig, + image_processor: Type[ImageProcessor], + video_processor: Type[VideoProcessor], + image_evaluator_class: Type[ImageEvaluator]) -> None: """ Initializes the manager with the given extractor configuration. Args: config (ExtractorConfig): A Pydantic model with configuration parameters for the extractor. + image_processor (Type[ImageProcessor]): The class for processing images. + video_processor (Type[VideoProcessor]): The class for processing videos. + image_evaluator_class (Type[ImageEvaluator]): The class for evaluating images. """ self._config = config + self._image_processor = image_processor + self._video_processor = video_processor + self._image_evaluator_class = image_evaluator_class self._image_evaluator = None @abstractmethod @@ -67,7 +76,7 @@ def _get_image_evaluator(self) -> ImageEvaluator: Returns: PyIQA: Image evaluator class instance for evaluating images. """ - self._image_evaluator = InceptionResNetNIMA(self._config) + self._image_evaluator = self._image_evaluator_class(self._config) return self._image_evaluator def _list_input_directory_files(self, extensions: tuple[str], @@ -118,8 +127,7 @@ def _evaluate_images(self, normalized_images: np.ndarray) -> np.array: scores = np.array(self._image_evaluator.evaluate_images(normalized_images)) return scores - @staticmethod - def _read_images(paths: list[Path]) -> list[np.ndarray]: + def _read_images(self, paths: list[Path]) -> list[np.ndarray]: """ Read all images from given paths synonymously. @@ -132,7 +140,7 @@ def _read_images(paths: list[Path]) -> list[np.ndarray]: with ThreadPoolExecutor() as executor: images = [] futures = [executor.submit( - OpenCVImage.read_image, path, + self._image_processor.read_image, path, ) for path in paths] for future in futures: image = future.result() @@ -149,15 +157,15 @@ def _save_images(self, images: list[np.ndarray]) -> None: """ with ThreadPoolExecutor() as executor: futures = [executor.submit( - OpenCVImage.save_image, image, + self._image_processor.save_image, image, self._config.output_directory, self._config.images_output_format ) for image in images] for future in futures: future.result() - @staticmethod - def _normalize_images(images: list[np.ndarray], target_size: tuple[int, int]) -> np.ndarray: + def _normalize_images(self, images: list[np.ndarray], + target_size: tuple[int, int]) -> np.ndarray: """ Normalize all images in given list to target size for further operations. @@ -168,7 +176,7 @@ def _normalize_images(images: list[np.ndarray], target_size: tuple[int, int]) -> Returns: np.ndarray: All images as a one numpy array. """ - normalized_images = OpenCVImage.normalize_images(images, target_size) + normalized_images = self._image_processor.normalize_images(images, target_size) return normalized_images @staticmethod @@ -249,7 +257,9 @@ def _extract_best_frames(self, video_path: Path) -> None: Args: video_path (Path): Path of the video that will be extracted. """ - frames_batch_generator = OpenCVVideo.get_next_frames(video_path, self._config.batch_size) + frames_batch_generator = self._video_processor.get_next_frames( + video_path, self._config.batch_size + ) for frames in frames_batch_generator: if not frames: continue From 60c679fa4250566d7d6ffe5d9f0548f42dfc0a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 22:00:47 +0200 Subject: [PATCH 18/52] add pylint to dependendcies --- poetry.lock | 115 +++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 1 + 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2e4da0e..250ed31 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,6 +44,20 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "astroid" +version = "3.2.2" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "astroid-3.2.2-py3-none-any.whl", hash = "sha256:e8a0083b4bb28fcffb6207a3bfc9e5d0a68be951dd7e336d5dcf639c682388c0"}, + {file = "astroid-3.2.2.tar.gz", hash = "sha256:8ead48e31b92b2e217b6c9733a21afafe479d52d6e164dd25fb1a770c7c3cf94"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} + [[package]] name = "astunparse" version = "1.6.3" @@ -261,6 +275,21 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "dill" +version = "0.3.8" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "dnspython" version = "2.6.1" @@ -629,6 +658,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + [[package]] name = "jinja2" version = "3.1.4" @@ -792,6 +835,17 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -831,8 +885,8 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.3", markers = "python_version >= \"3.11\""}, {version = ">=1.21.2", markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.23.3", markers = "python_version >= \"3.11\""}, ] [package.extras] @@ -912,9 +966,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.5", markers = "python_version >= \"3.11\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\""}, ] [[package]] @@ -1061,6 +1115,22 @@ files = [ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -1220,6 +1290,34 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pylint" +version = "3.2.2" +description = "python code static checker" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "pylint-3.2.2-py3-none-any.whl", hash = "sha256:3f8788ab20bb8383e06dd2233e50f8e08949cfd9574804564803441a4946eab4"}, + {file = "pylint-3.2.2.tar.gz", hash = "sha256:d068ca1dfd735fb92a07d33cb8f288adc0f6bc1287a139ca2425366f7cbe38f8"}, +] + +[package.dependencies] +astroid = ">=3.2.2,<=3.3.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, +] +isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + [[package]] name = "pytest" version = "8.2.0" @@ -1639,6 +1737,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "tomlkit" +version = "0.12.5" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, + {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, +] + [[package]] name = "typer" version = "0.12.3" @@ -2109,4 +2218,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "cbe22020ea3dea5c64425f8078184d478cd0fb0f99a06589b9174bcef8fa9f68" +content-hash = "234f30d82590835a3828e9a898fac4bb6bfac0f00406e3849f6174d4fa19c537" diff --git a/pyproject.toml b/pyproject.toml index f0d5139..090e2ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ requests = "^2.32.2" tensorflow = "^2.16.1" tensorflow-io-gcs-filesystem = "0.31.0" docker = "^7.1.0" +pylint = "^3.2.2" [build-system] requires = ["poetry-core"] From 6db699c73d5a3782450bb30a679df5043a7b0437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 22:35:26 +0200 Subject: [PATCH 19/52] move extractor service tests to root dir and fix imports --- .gitignore | 4 ++-- .../frames_extracted_test_video.mp4 | Bin 35238 -> 0 bytes .../__inti__.py | 0 .../app/tests/integration/conftest.py | 13 ------------ extractor_service/app/tests/unit/conftest.py | 2 -- extractor_service/main.py | 4 ++-- .../app/tests => tests}/__init__.py | 0 {common => tests}/common.py | 0 .../extractor_service}/__init__.py | 0 .../extractor_service}/common.py | 12 +---------- .../extractor_service/e2e}/__init__.py | 0 .../e2e/best_frames_extractor_api_test.py | 0 .../extractor_service}/e2e/conftest.py | 7 ++++--- .../e2e/frames_extractor_test.py | 0 .../e2e/top_images_extractor_api_test.py | 0 .../integration}/__init__.py | 0 .../integration/best_frames_extrator_test.py | 4 ++-- .../extractor_service/integration/conftest.py | 14 +++++++++++++ ...xtractor_and_evaluator_integration_test.py | 2 +- ...or_and_image_processor_integration_test.py | 0 ...or_and_video_processor_integration_test.py | 0 .../manager_and_fastapi_integration_test.py | 6 +++--- .../integration/top_images_extractor_test.py | 4 ++-- tests/extractor_service/unit/__init__.py | 0 .../unit/best_frames_extractor_test.py | 8 ++++---- tests/extractor_service/unit/conftest.py | 3 +++ .../unit/extractor_manager_test.py | 6 +++--- .../extractor_service}/unit/extractor_test.py | 19 +++++++++--------- .../unit/image_evaluators_test.py | 5 ++--- .../unit/image_processors_test.py | 2 +- .../unit/nima_models_test.py | 12 +++++------ .../extractor_service}/unit/schemas_test.py | 2 +- .../unit/top_images_extractor_test.py | 4 ++-- .../unit/video_processors_test.py | 2 +- ...e_3e4aa2ce-7f83-45fd-b56f-e3bed645224e.jpg | Bin 35 files changed, 63 insertions(+), 72 deletions(-) delete mode 100644 common/test_files/frames_extracted_test_video.mp4 rename common/__init__.py => extractor_service/__inti__.py (100%) delete mode 100644 extractor_service/app/tests/integration/conftest.py delete mode 100644 extractor_service/app/tests/unit/conftest.py rename {extractor_service/app/tests => tests}/__init__.py (100%) rename {common => tests}/common.py (100%) rename {extractor_service/app/tests/e2e => tests/extractor_service}/__init__.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/common.py (55%) rename {extractor_service/app/tests/integration => tests/extractor_service/e2e}/__init__.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/e2e/best_frames_extractor_api_test.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/e2e/conftest.py (67%) rename {extractor_service/app/tests => tests/extractor_service}/e2e/frames_extractor_test.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/e2e/top_images_extractor_api_test.py (100%) rename {extractor_service/app/tests/unit => tests/extractor_service/integration}/__init__.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/integration/best_frames_extrator_test.py (86%) create mode 100644 tests/extractor_service/integration/conftest.py rename {extractor_service/app/tests => tests/extractor_service}/integration/extractor_and_evaluator_integration_test.py (93%) rename {extractor_service/app/tests => tests/extractor_service}/integration/extractor_and_image_processor_integration_test.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/integration/extractor_and_video_processor_integration_test.py (100%) rename {extractor_service/app/tests => tests/extractor_service}/integration/manager_and_fastapi_integration_test.py (75%) rename {extractor_service/app/tests => tests/extractor_service}/integration/top_images_extractor_test.py (84%) create mode 100644 tests/extractor_service/unit/__init__.py rename {extractor_service/app/tests => tests/extractor_service}/unit/best_frames_extractor_test.py (95%) create mode 100644 tests/extractor_service/unit/conftest.py rename {extractor_service/app/tests => tests/extractor_service}/unit/extractor_manager_test.py (91%) rename {extractor_service/app/tests => tests/extractor_service}/unit/extractor_test.py (90%) rename {extractor_service/app/tests => tests/extractor_service}/unit/image_evaluators_test.py (95%) rename {extractor_service/app/tests => tests/extractor_service}/unit/image_processors_test.py (97%) rename {extractor_service/app/tests => tests/extractor_service}/unit/nima_models_test.py (94%) rename {extractor_service/app/tests => tests/extractor_service}/unit/schemas_test.py (95%) rename {extractor_service/app/tests => tests/extractor_service}/unit/top_images_extractor_test.py (95%) rename {extractor_service/app/tests => tests/extractor_service}/unit/video_processors_test.py (98%) rename {common => tests}/test_files/image_3e4aa2ce-7f83-45fd-b56f-e3bed645224e.jpg (100%) diff --git a/.gitignore b/.gitignore index 87f821c..f6ea5df 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,5 @@ output_directory/* !output_directory/.gitkeep test_video.mp4 nima.h5 -common/test_files/best_frames/* -common/test_files/top_images/* \ No newline at end of file +tests/test_files/best_frames/* +tests/test_files/top_images/* \ No newline at end of file diff --git a/common/test_files/frames_extracted_test_video.mp4 b/common/test_files/frames_extracted_test_video.mp4 deleted file mode 100644 index fd4a35d73a054535ea2a4128abd49b7b00261a65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35238 zcmeFZd011|*EXyOm4FK25D*!J04ER-nL!K)Apyid5(qIOS`(lkpn{4G;24AC1ep|+ zF&G975Y!a$hpn~(htUDiK+#$Y0Tt0!qN3EIemnMg-uL^i?~nJo-oGD*OCZzUYp->$ zd);eo7cE+3{oVeo^qu?m>{_%)Ymw$(@YkJtAl_|Px~JQsMT>O5+nboUXpw-mD}lRz z(UMI!!Ou*S$TwG)73&B8wm3ygYuV97i{C9;G_!*Y9@0*}Rb;(rkq$kvLtDq9{M@45 zVeH8N`Sm{*_#X@Wj|Kk60{^!bpaW>t<&OJeIu|NWY!wR;hZ|L@m3pmeSJoBw`& z^uNE3{`ySSf4vsG z=>Pv^t^ub?^LGE=N0R>QLsWdVAIi2Z{qXg}|KCrOL~Fj|Qx=~8_f!A(rwks^yq#`s z^=CrcC|JV(`hX@nA1oaE4|t)|%`afxdPAIAXsm_7%CduqXgcQz)X4?gDg^sE7^;&b zrz7Lb!oh~_a>vJ^^gtM5kbzn9Ax}e$#w4N%MQqUq&Du^3F`r+L;2n(UTvQWAh+d7( zSE93lN3beC|9Q(FYSH-$YZ2IlDWbNrE&fa>g7CR?mw&B83Jp~U*b0I*E3E}4#cE73 zA@x*P;p^zCJzp(z9a!IM88Qm1N&lMtzs;3C0M3gI|#mAU4N5WQ>LMrZaez z_9K*EDfL0F(}q=voLU^CqY8b@2Mz)H#~db1ZbS|VORRPDG^xi<;ZjLNQD&vWf>#w;FDm5KLXq}JrbNeDHIvMGN&U* zWGstYv}iG%J7KNyMV32!2Cmnh+4q=H#VK@-aHgv4fNLl`9MsWGDA;O|#_%E)1sD`8 z;*{qbIc=Di_`khVMOP&t5V3lJ`@hx>-6n7}FQb(mUwHl_Bx~ze_!xa%`rFlC1L>H% zL#Ml(QjTP}^Ab@d^EEU`Fmxdx zSXYd~w%G3HXCW7SI0WOj40U3kq60d4Y9H{|%Qau&sj$eHSCE1}LeZFZa44&V!bj-r z@C>xUX5L=o&mklRFUz?fXB3cPi8ZHQ)Rqg50a$%V_@^ z^=qg~&;jHu^iX3&FJxlL9T9@^3(vrXPeUl?O!U0Ojy2qs#HoQ1l~wRzm4Y*NN3;}*BVB?#`%2lP_$Je=_=k9+o@Y#W;4a9 zNP82Ojk1iaILCqi@e%9dkL90XVES{HK}cGGa83`*Mn4>1;C_TU$0{|ZxCA^nRP@ZB zf+)Fv1^JamI0K(I6{+1-shUNKoNmWDp?a7z@gSH{ zs|H9;1sH8$#UXwTYY-P|K?0}H44f~&QUe+Lv(fFgC`*IPAml-?@k=}vrsPbKoEl8H z9-*<{K@j|>N8#zu8jvr<9R-Kz%bnN(9f18A6+Nni^!^H#;5+7pAE6B1E}rkOr-CH- zE*C{LI1)f8jc~%rLwT9Vq=6UGq(hNS_!ZCDyao%~GT(MbVo%F1grcl~eXfTBm=XO% zqIko~jSCTDHM~9ks`G^#bXPO$lB90N1V$LY3~7d%{lkbG(J1tBS0W=Mn;F^>7rHWA zN+KPB?jcG>#W~^UBczT(y&*V@i*XK(BRatgcGU3rYy5d@fiHj)2o+tYl<*RBtwA+Z zz@0b>z_TD4gK+)@w^3LD4hDFH#{R(Cz_q6%oFLD@DmH;lj9HbHMhC~iBT+hvurNLj zJi(pkf}*?wxw;y!9iBpPsn~t1fky*qjf>dw8bm=wWDZl1w*3gi|JS3YX=6pu@N;z3 zwwiW^z|35NhE)v#@aX^_nF_IvKpcr38@LO7-HQ-RJ2}UHcust#h*OCU@OW(7LdYG> z=sFmh^orJzkKRFQA-lY@(EMt*r4_X7(uk&>R4nTVwm%1K=(*ZMm4MC+@oS+BGQKph zyi6B!8l*B(M}?a|@G@9!M3rpR9+Z91wbE>RJJ_~lNR25 zXogZDRHC*7F(SeG^T2{D(OV)l&b7h~y!frgZR?c|fJS`XM*w~k6z&QFlhl=osC2>= zMl@LdnNS^BR|GuyHr)0E(2^qTU{;2e?FsD&03|AZ;2iQ47w)m-3)VRZAk7{uY>Tq- zaiJ!#f^=ab{s}y_L7SZE_7dvP0nyk{8mZX)H9y9PM+s6Nk($&u!Sr&RDFQj@4cL+G z*Mt0ui+sc$3I`dwLjxUOF)M&j|30UD4ueq&K!k-N*K|_A!oY!A5zzGEzk&ynu7N|* z_>wn4;fKD_1i^*C z7^`H&<{U3!ox8Gt#WWhiF*>}RJm1B-zTa23sl#v9%|e1@tYuiyY4B8zZ{d&Bb$fQ$f5(fq;bBuVsy z_uG{EOB+RJ^ywVH3De2M(#8Cn;6qyXucX`0C@^E70oX@=n02! zK(0riR-3SEtVWNJD5R_mQ;RHVr#GMn)o&<^V9kA);2V=&Q0y?Xm!s zT8IG5ViX@{gG;dn_RVl}!rE~vLBwP%Rg;`ywU0=pIa|>w>jh6(DcBWGKt~B8h?R}T z8Cr4HkjnGX+1SH6i#=pcI6MM{YpsktcO~K`YzXib!&NOZ$sluMzy^Wi8v?xXmO}k? z=v{e81B6F6iCS;~5!RC^J1UerD%g0nMO~Hw3|w`Eq;ANLcqo`PJ6^!!hsDSMQE0(! zf>3dph6v$fkkbGXuuLtyF{45QdrMD|)V>vU+Wpc__omAmlHaJltA8UIkx=!((J{_} zglkM(0}#wG%grBY!~g@B5Y7 zP>cWZ*!e|^maO2ata1BcFJ1!RQ-du_{($_@x0pucI^Ff((!vWF0l&pzVO4B6X5CZt z$=9F&?3shOS>YQ=GePdoG==IrX>9oiWQqiWoL@fxQk>dfBtoCyr$gisjyh~xPKC+< zKp}vBU`-fh^_?ji1k@hYJCz%&@Q~BSs-!-SPc_&;83VbqfR_yxvK>0R6=&K0cIs<+c zKn;+wGTcKYvKW;eN8&&OVQycGzL97$@SG%$R`DE!8>cYHEeg|)oQb|;#IFWG0pgpV zkIsX4=j8Y7KLUa+LbVpBvQxNo0NMWFS_Mo6*d2JDC`zwM`w0O+FNh7kgc}Qv*7}2CHu}32 zfVj7sEwsG@3(qQ2WdZx&#mD%T=Nd@1P@_uckZV1$;7NNiBf6tbs8Y3yB5q%)31l|B z6$)qi6-V?TWxzRWMIb0*G?tYe^Am$?ICu!qprju+h*X*)jj6?_d?u8?>A{q7%0=Ph*a+2XPCq6TIeUy;eEkhop>`SWx+B4tWCWMvMmOIRIau z%P$-P7%h^qJ|tZaU6rkI5h{3&F&rc6=pOiLIQ7?>8kT_)RGrlEKyz{$V5b5?x4{e` zVl`J4Xp(+r=@S@2=WBT1`&KdzWR*S#(GV(ndh=60;Cjm>3a}{myWm~bMiDn-nICbo z{gDb&QO6|#mdVN~^q12v&z6ghMFZ~*-8N${np?N3ffq>!G}M4AQ7) z^=fWa_6Z?HPDL?d53M3+MrCv@2g$IwP{VVA?9xS|5)YX@)?EPIt=GQeTcI>T)E;tb zs8ynGD~})dR^>bF5zJc?mQxy8H^kIRI3c`6-bImw9rMzY#H^clgoq-XyCC@Pxufr- zL9uB1O-YcSkls&JGN+hb{7$1TUPA$t;>>E%)MY55#D1kYYQat(pC18A^3I*}Yc`10 z7C7Wbe+@GnKldS2qZPnZ5yzh`{ZG`+k$hL;0anYPwW`mqn8r$B|Z-8N6y zudt{~;uOXYAU|1#-wOn?LNV=>)w!b)O>`H$!U!Ed+)WTO34q>Y2Y|q16 z%NCXns94&rHG%1fQkw+U*Tz+yTM4o<8!&u1oxF>efX<4_MGRIfx^J}@YNFhix)m3f zQ+amiMxP}p=V43(4rGSn$|%V}O1=hWL4Ax6Kwf3T;gF2=hDRu$6Uk|jwu0lmVMicR z#tR0r;~Z5^QNq)-d-rOndhJGVDydnQ8i$7y_4R6kGF}AjoUb1eRqD~9P!S{Q5aZe| za)ThqTZ;8Ne$4OKe%g#a1styi)>XvC9l)%MpL10=S7z|IUc5|eNsy3i(i%3FsDvpo9KS%zVy{bR&k+#i*I?RXln^uZ@N>5 zVvp?zq;0cG1Y9ms9wMJhs^FzC-J3UvrXzS+Ifa_W`*hV-^7N%|~Flzo&}TzO|4&<~sGBtiKcN+Z2?O7`3HD?K8fiLp-EPLVGBynObYe z07Xvr*psvoVL>Qb-nN&@A>SIi@6YBl5xiZY-7IIUh;K2`!bM}$gVeL!A)1~gbAV~H)H13Bd% zfZtL9hZ(w?oe#)o7zH4rWQZdXzIWmPH77|axRwi|%+m;|3Byy3;nF%U(S#I|h0%&T z{{&T7vv$N$R&gc zJbVAJLp(bXDQ@QsIwDcC8aE)4V3bi=yjfD9x;A8Ll3lOnufB3vQ{V3=M2SyX(^52N z#s(2D6r!fZZ&Z8+B{EClgV8-DjI)DB@so7#L*ZG<3zbHI)I+;XEbV{R`wGVeKk@v` zTR>Lfc{${BB%Tqn?g1lM_Cb@RN+Wt#*FV8O!x_pyglhCc@ENN05L-h*=om~#{S#Xt z34>}T)V0E4p$`aLJ3fe}KY`<7D=Y+dZEtEdEaU)D8Ia}t!EqsiIsf*SYfTuO2sAJY zPGuC*ZU`$Kva8v3MGWN6$Q(AFY6XgurX8Yp3X(p^-bL6xq$C)B0eqoKv5u_op@A)F zq)cEsyKF`{F-G(pV&h9SI$jNTa*=6BQGs}`}P@>(%aukPT*8z1i*FLw?Tg5 zNVp!S6+o6aXfOu$I`qdqk=Wd*H3?r#xrLK)XyGUt#;*>MZF`CBx7WM4SX=7eq!T4| z=ZNzIN{Wo+bNP)swNa3y@A6Ml<|`ax^y}}Vv&BsqQC#d5<^rG=)?>M;>vU8(kf8!3 ziSumyD13&{v(GA168DBtYsNl(Y`_xWx?>m-zl!u``JX&HkhS~(*B5cBG?2dysfl0D zkX+7?dINS>5l`v)#!;Y6k<%SPG03x}pene2$U)Y)oz-4Q+D-sj*NRa8j=;_qK@a~j z^Y4>Td9Su9EQ~P4)m{HEE<&1;rqF1EbyfWxBtIYPx${p(O%SJ2O6qGzo{@@U;fV*c zyew9jm{HtH)obK^l>?DvT6qzcG4MBffB>N6IvwcTfG^{T)KytYpe}uh?H2e3ekSBm zQ$Pw)TLJOW=v>2FGEiEausxU#n+-NICV)4mle3JuPmisPa4@t6*(8}eX>FFyJK#mt zDJnez}OPunD1}pbyGKAR=#5fiiP+}@65fJoD++eGjOYBsVD^&zQwbvE(0M9)kg4p1U^EfHP`|SZT>t`c>CygHY=;~qN6Yot)qr|M- zo~479^OEKp^Q|c@3q~U`neRQ8XGsC^cm_E$n%eql;>ULUc0-UdnzZp%csPtxC3U}7 zLxYupx7a94wG+6$b9cgMmTeJeYYce;U?02I8p@kyvAPmLZ8Fi;!XsM0<)uVo)Hnl) zckj$_o_O|0A+X-mNHGw zS?7i~;KFqc@Ti*1W5IBuLk;=DnH9p6mDuzxARk`fP99yE5zqB%fx?9|CmvnZNDJ0! zysLz7wNBvUXoaLt`T1M#0wgSAWo5X5y2)A4$QfAUJ9g|B0&^=3XzV?br?^d6NTjv| z%Km9{wzTb#DDE@fpNDtY*1{tt+LU!Ba-Pyc|-T zpx(side<&M*l%YoS0zSSDICw5{b5+PJU{kpt!Y=R*2|@;8qtj&m#QhZHn<|ww5a;W z7vz*Ci_d=aMl*j|6tS1$L=hKrH~%=wgnE%vxhc6{lFERaNAs9z`^i^{2NVA>tQ46W zyWGzzOK||m%?HYjB;f+6!h5b}d7$@Rl*uAgI~^*~q1xU7Ipx^-EuzuyIn^zcTfX@s zf6y>s6N=wJrJKn~#gt;?Su|1r(i$kc$={U%?cZOramDa8G+Z9j2U7eKd^k3Gb=&Py z+7%mPx?VP;cwm=_cVY@CPu+ZFq6fDuyff26*S+8wfkM4{lPaP9b{$Z`8Q-;v)IU4| zyb4t>O+arKcX|L2!rmn+>rx?p)ky$(&z}YW1Xq~U3*Tj53UcDb{nm zR)(0c?kSt7ksLKR4g_?!83;rx203GdP08@!`JR5vTmX8QDJZn!R0+kD=4en~ni^$` z0~$!pLkF@!>Wr>Ef!>!!%R`gop`oXWRK7M4n_n>m6A*ug5^ov19uQAO0A69Ksk7EK~BaNU&kbrb>hC6xAEIkZA!DP-jYxshQ zT3C@|C(VF#iez+t(<}OR1m2{~vA2aq<78vr|MPhW5iJwij^IH^=m9V6X zeR$2L5HdwyF?+DTg;DZnsMR#(ZJ}6q(C=kmM2u>7>X5b;Z+f#1$FnoznP6gBnu(~8 z^LpA2N9pM7e@hbxJGFaJt?&M9g{oXOD<^k`$Gn=6%^o|jni8S@n zJVz$!!@q-Cb=B#kPo!~myTTo@!76)BzS!LTN~l3M-abmyG-R(!qZ=Y;Y)=5{;{|ZR zZsSW+!_`e=Ri5~ZE6qcCi`n(sHYq_&&ciuGX4iS9FLrL3$P~!#Go$bpN`DBx*%&M9 zWI937NQJH+Q60DOucGjSI1>y1yRc%jrE2*$eq2IBFJk8V6Aw<%%e~)zvX&8hzy)&| z-r*xe1LZ`eA6%;A07PEGF>6-%(PKvebZ_HMj+;(b!qo9axV;|~ z&3#V?RVhH&ODff%14#t#011!r=uG*0|L z|Mc`QlL#vKD1n<_@4(JHdWyx>`W^>c2y&3+ijz?O&^@+O4;z;4|9IRjW6`$3ws8nS z7_x^W3ZtN%e>tnem*+)&%ng+-G146KBkq*7+4sjI;mmm6=o`!kqCBo4J)r(eh{n_> zP>PQ|lFB~-#tAkHNX2nQmkhh(q7n@Mxk~14L?j?sIwE9x_`_~#dT?=3^lk?Krwo^tpOC_^~P>8X5R~zOvB2i5`CWkm7XJ zs_4gE39~Cdj=v8c2=Dwb`w{L`yC$Pi&pv^UqXJw%b-m0NH)r-CIerO-su3Qq6W=$&*-r&;$CU>{tNk&cM{9 zGK~tSG9Yo9t7*j|Np8l{Gy7)HSb>L9bQjW9HAdk+l3K`}6 zO7 zYc@4sQ?EmDCi|fTLwM)q9K~im%XYVCic_n`dJ;Od2%MIljlNYZd`vJ*c;xyK?HxEa zNv8~P8ze9$E3xjmX& z(Qw0vxR(ojw>VV4=0{U(Dpt3Php(rnWMt@#9*8xg^f#ELPVZ;^perMgW`t-^G7!u! zlu@myhInPx3$iMSBgw$&kaumFbEsf-H3h3udgAhw0xtlAZzxur(=$Ov4o1{XZZD`B z+^Al-pfsvwbky+Hg8~x>lT-%`YJ&QwI}f9e10Pw`2N@zPQ=}1*Fqz*h z*fsR*h&1z46ja2T828CIX$Q{Xh4JD=tr{1BxKroMGPL4jK+{n3TiLaXf|QS8P5N6M zWM$S>pkpnkjlxvWoBu)2hV7n8KW56~ePe6~lmi4kvZP(ICFGpoA1DA12R(3#x7#jd zNl%((AB;A2e2s;D+Vv`U>vSGnbw7I_S-?%i-#B?j9p4y>ZYQQfZtgktqX!z^j4Cd4 zFrrM8PI~BUApAIc`Spvu$W|-&9V2cHmV&>dLnKOpBag3eBclCv!eZg8wL;(LUoK5M z?;M|9dyk;aOO5@!y1VOn>=5jN{DXyjkw#NmCC>EHd0flQ#_hfM7WV31T}m!GyTirh zfo=_T#YuK`jLc_!N`}d~bMG2S#fldcO#7l*48m9`fEnupfmr}d+%(%;kbl1(Qx*x2 z`hYUv9RY-nmoNB>5hKi`DYKNZ=H3Geik!Jj79}S;^DA&Q>XZMVx})xM#xB(NHfj5B zDPctA&|zdGyo=8a1z}0d_;D1!kxJq1!QHC~-H|-}l=Fx!jd3k+N2WiPs|T%Fmqib8 z7yJv8ET5d1y4&*+42V$1s*3mLzX&y)8EbNgiEkX;FAa*Tvn!6}kNr_P>A2NctkCUf zvI8W)giNTPe>kf6DMB=5+QNKS-V*5n6ujJ-{$Y=1tb29b@mQANBJW+N=J#kILL#DE$}MCP-RPwg2qw-#qjW3EPks;OCTFZul5peY zbS`PF@bfRR^KRYsB&M%tfNiw?V1%@DohS3DAoe&Zp;9n`oz-!HOI`8FI5hU;QLxhA zxK9_E?L!KfOETqjWA71#+sfY$Arh@zKjp-8(kGTv`)m<0Y;< z(f{+^QftCV%NCwtrKMq$q6QI$G=*$Tt~O9s;oL<~MqU&3QTxfpv}g#_N3LT%J9R>u zC@2|ZJe|Q-Yh={7@ZQaGkjzb(%C!p-lb0C%h;iKy@n&lpO&~%W6>;#&-9;{%e@FaPOGpw#WK)d>zA&#gPh< z76%^DM4g!X)Y@#n5B9FR5L3V+L?M+-P1K(^3!egLOc|6Prnk+>(;_e6WF^MuEyCNy zCGl&Oc)AXMm=bQyij|T!K11o&D(qHE_c^^0jO>_46XlMPb1t-SNDl!XYDGW?3|$h_ zzDs*I=7frs-b8yw6HL0*y|?*Q#5_&i{oAVKsc+AhOTA@j`O_ua51jGa#;FX?UdDcM z?2r5|LC5g0(|yq??p`f6Gk(j4FFH5)KRfwYcKFyW(zA1 zZpg>^|KZRnOn&t8(F;HhzTIjF z(`br$_Z|rt&7quL22?gx3zpIOP{s;JDU6n45XA@FH&OIr;_FPMOoyv$uLv$MlQf_g zflRo0G(`^hipl_4IK4K~2TlyW#E=O~EedkNY{3kdLz9iv;<){lzD7_-%gS>=rikiH^#G7*ofoTCKh|B zOoDq>u$Ai|*Brzp?IN#GRt0_c5oq?y|sbsZd6+C^Id{sF+s% zXoO38k|@tR<2>j3BU|x(s%c9IrY@c^pEUjjHt+7Tms-%5&Lf!TcI#=OueJHK%?>}s zmcCa+4dsp5VSoQ+P4@W5%iA|}feI0M^&vp7$;*l_wTsyiv!c!uhLw|*?4Dg!t4zz^ zbS^4nUZKetF){^^75XZ3(MNaDZ}!_PjC|=Me=i)eTnSHge#V)Fk4ZANa^0eMe1a+Q z3Y^XCN<>UA)d@%ODim~d!HlprIgy#9k!)_3XQSyhdL3_Xt+1kM+9bv>u{zD9L)av7l{f;Xv%rsD7$bEX$EW5q_0Ge&W`%C)LmV^M zew0wUzxyG~II$|)^kZ?-3(8=<_J9#F$LK%DHm!0AT0K0uCRX^^8Yb^W8Vw}RZw^Sk z9I6CvUIlj^?+u}21nm7Ht8N*BjyaVo5@d@zOwI@7^$C@ItxF>yj! zIOL2TT~61z&_M!1eY&H#!%GH#CngY4rf66@dh*mjX zXdo*wk#Q{>49N}(h*5jlQDS5nK87S>{i@?COPNMhIupssz~-pls|0ep9-dP;>vmt3 zrk<}9@nDjFE?K&=ceO#)ugCwyl!ZHq zpyTs70L@K-VwGlC$G1aRxqX?s<-Lsw^GEeNTVat4=##mWU9fa> zJKy=Cb?d6oEc&1aJ5T53y`y(xt*NKM#tzbk`;g-bH|?81bv6;naA=R5a>hx(^ll`T zs1iBiJj!GEQ1ImXpyaAtov?GP4cc0*!FV9Q1LMQor#HlDXjuiNewf&pqA3GsR6h#^xHy_6PpL!)9#kBH%eHCq@(K z5ybcQuAs0lYQJJpF%#HFSKU@~D!U-(s`vB1DGJV1xM}*VVqP8f`?#$6(+b~wkh@CQ zJ;eyl`edl}^=ry3R$f!CVT7Jo>fB;FV`@}pRjS%;o2L-(xi?m6FlCWb|Ii%>j0M&= zTNLiRr2KA{%>^vU<*@bs!QrGDPR-`h!Jj4vE`r+z*Zwqiu@12+PHf?bBgAFWj`h7( zoMZc99byc!PM&2%5sGQCx!OHJ$rqRFq<@z!0NM=^#^|KzMBhg@+C1k-Fz9Roe8&CH z#h>9!t=qs-bKIxn8mweHgSml}6EC9*Mj9bmiJQ^i8=J+(q2sq4I?7}Tlo7%8E+Pq_ z<^fyK>0NVA>GYBmTfLv@Hl6md=w?m6C6wyVYv}#qP-qJ&?s&uYV%FG+?bn21b-8z2 zdFStvo6Yc7nA5+D3((BGE8Y&}&({C!F@llaPXF0~a=H9kK>7YyWgZV1tLllO_V#Rk zxqT0#_|NS4-}T!02CtHv7S$#%`Dfx@F??r~{j=O84@}T^#+{GtMfy7G-X1`=mcLh! z>NcWpg$?U}`;=Pn_SS^eE91ahvwdp2ZJ^Z^-#hRs)X;lWp&y)+R7Zxls)Zw&mu8YC zW*Kt6;oU9Q0)7`)%{q4DFUA>WkiIQr%XUZX?q0M>NGBFTcpf@?t|HNVdrt}uo35Dp z=DTFt6_=Rgk_W(3yYUU^Hc-30N<57(rB1HLUKl^R4TyBUCGO3*9;@@9`IZVzt_PzT zw{D1E=1oW3YqHt*sB~9$QJ-EJRrhJa5*R{QGB0t6ph)<~2Mhpa19tjsky%DpJdoPbMiH!$RH|&Z zZi!kQwY*|0_O>IAp)!l*;`C;HIY2LXW_7~*w=b)PQp_$XOhdziyf}2dLg|LSLH=X9 zv5VWGlWp;gX3k%;)1nbDMqx}$wlRg}5wa3WdI(HinXUN!wLr!RiL~DHil&}?Mo9W7 z?AUFLCB>@w6F}1b!I{x7Yq!1zW>zSQd5g<8JAl&Lh5~A>=?n7+rL@7d%bDhwbpe+a z3=v62+O2!EtjXDh^CN**fsRedVwoW0_@eNhlqLItBN%Gnx0w6P+AIUl135mhisi$W z{@}#pm-zLf-w5~c00Pv-h`|t{g-S#foi*%eu1<6UL?BZzTbifjoXE63FLjINR2*D= z+?nipD;Vnx>9ur!#HEmSt!?PMnt@H}?OhwneY^unTpb*-_2bUsU3Ms6rOz(ixZGyy z`dXi@-FxTqLh&s;?DT%-`mw6Bn6UOwFSnZp5$Bra>(xiamoEzG#`vAzyc|xVq8FXm zwzEm4amWufoihC!@&8<=E~y>Lo2^A7I{W~A>7CF{gX!f~oD$=6#WDRZneVXUuUEp3L5zyz zIpkG@qrqvdrDx2bsQkLD5D6H+BMB}VV_}$MO=|XJ730P2$i(s4CU6xYeXJ%}wI)!Q z4TL!`6P+F@#gYt{wI@HA-3eUo9rszFS^Ck6ksU{*{)jScOlmQy{=sRIM?b6Z%QN6S zJHmrc8aJEZ@LiVfpE{)NE+UJ<*7)Eo50{OUNtKb$(tAj4~JGWtsQx%h;L(%B=&r_&N2?R!+bdG&<*=Gg9o z-Zh+-OK!M*{7bRV37@rooxNPaCN`S7$Y|viNze1>cAIYIy<(p$-Ddt#@0;JH!|X^+ zGmvisF+qb*g4a=d$Vy1*Eo@>bOb|?1xPpyWKk)7v>Ps_U3F8YD`zZC+IjY1zJoay-14O+U);S8jo|&% zd5I+K%qv1Sx{fuzFxYT>4RUJJQs2N#h(zytIrhn({&&ys3T5l}OYb9RdctU&?R(g> zwBv75S;SvYWRQ2;W!rrJ+h^u455(=b-1w1{_SX{?*`uppyKY=I`=y&7w!PK$^{cH8 zH<)wQr$`ZO^M3!5<%bvS1qM~VwAH^BX&)I~HA)O0UB5iR;?mfkL}~V6bm}E0l0CM@(+u!3d618P;{7G@Rs|g&$%bJhbDl0_VM#q{JoL zjmLx0{CZ zxqrwo3tSe62__DcRRN0sq*{h_Nw09%`6jSvlRVS=%Rdne`rX>yIcGtqd=2^W+6`JC zZ5iQF2QM8dgqTYbKjPMVuAH~O_~RvM=iW;)GEthAe=lxL^0rb(jD~u$$FBrp4LYIQx~}uUG8gsfhFQ(l|bnoX|Wx79d@!{T$qc z=#-fHtFn6fTKe7BWfccfyKEaaHkkWe37V2ts^2V&Nv6cLOh?~NltX&Y%>S4;m~y}e^aj3GC#0&=rv%JyKWi7gup@lW=7{cI!a^wM0A zMWA%)eD|nr{S$EOGw5mlhNM%5;0g$eX=6pd7RzOP65Ric1K(kapNfV!c5 zdP+lGnUiHpa8gGg)xp`kB@zs|6c+0%mJOFN$Ghg9vB7;bOFvw5sS(b*N{8)F1;AcL0^Kq z3FHwy8~N5)A!WvQSFBx3iv=0_9JEc;-+fpw=+rx3?|lb~w>ga!VuSWs zKHT;=bLEFRz8!yc{9Dn$v5fmIA>1{R;Pn1$n@7E4=JZZ^eAAs;B-DvTH?BO8O*#b{ z9pd|13^4i2XkkR<2J%=p%$z!;OXc0^fc$M$a5*F&OoN8#i!%CJ_u1?YXd3`OUc#0tUq9p%7^?t3DtE%TQ>)@RWk zbh-Fra1XcyF5j=fv<{6Ed|Eq{4_b}3WDO}0i!eQCUn7GA>mrBg?~5T3>5sGnar z{_e<$JBX@>$7il{oZZxX@#;`hPg^TTScbsXw$?joNiVl{cfLK{`D^#Nvt5Ggd|$cQ zgv}!*@Rf_0f6U!)%r{?(eO32T>u8bQ8AaYXiT0ntg??Y=IU$PL23$Z^6>{d+!|& z7va2wX3?9!KqhrdLx~CImc(5~cxuqWeH&hUIbY5@fY%;_W`J?V$R zwP@FDj)du1kKX#GZWqtj8wxkYGP@eV%$dsf*gmIl?=m1-0P+lxXmtrpt)>vQ&Nh|aIzh{)_UpjM#Sx8U7|zg+Hp~Y z>{zr)B$Y;oh_CTez|7(a?G&i~Xz7W(jj|%6me>g!BYAlGd_~jW7ymHetR0-q5vneq z>HS`eGpeWB=w+BFuFe^(7^&~E@%Hn0vl{DHy4C$1xPRpV*}1NV-Iie@^HT27?32u& z=o&aX6H5)nC83xy9^V|jTTfvbt!5aib_LF4m73oK6I3wwIiSe`9_S?n{>GMiIMaFG zVY^Ru{#yO=S+lCo@2#f)X(D4wU9^#0O1M*zWq1X7^S8gDlzu$8!Qz)1ZePPtQfDlh z?<39F?WPO*OkTpgd*8Mat-1`;&~`)q3tzaL7U|u-3NPQ?bm^NdV4jU`7~n?z^@tey z0b$D1pY-heJ9SI1L+b67k7L7Ym!>{^m`rI-Gi99UiW9tki#cmL6EptH?3cXZjWOHJ zPy6%}IqE3L#7gyNJnzjjUd9&J?mcXmO=I8c>~s3^&zl@8yzQr)%c0ix*l#b1N1^o2 zCzr2A4>okt;KW%NJUQtt#=!glpnN@eb#rpFZn-HXroPOg`zga6U#J zxwYUE&$D8#MtOYS0Vy=EXI9{nYHqeI^Vb==PFZaDnNJs$5WD>|DKIRuy_K(+OL+dN z2se5|+-~et9pTLSkDygMA?Cr~28WmO=YBf7`omh6%eLcZgrD7hY|c&&FbH|MZ0Brz zM%~kYASNeOBvb!LpWc~@7UZ)L=vJ;Qn&)L*U145eVs&b< zoE}<+h!{t!HUk`7%zYmCZ{HMh=n5@4d5CzPR#`oFj8XPuhd_jd9uujE++hQYztAPFc9nHtdmB)ys5uB=M z{gSN~`8##q-|a8m-722QHg>n$Y~GboSI47L_P_g}-L$pS?D~dMaIfeTWSrYVA}iXP zwwt={I3h4{YQFSSLcrziw_bMdc8(;R{BE`0cRPFW1zB}M=b~@8Unbt1j@$jEWW4{_ z#DVH(m%6N8uibEjbI3o%=bO%_JC}Z6^qc5_5trFAR(s1k?nB`pfBaO4^ZVKSB*en2 zo=C?|M|}LXCV)Qq8RJH7{^J2?@uEA~5Z2ixyL9&rUCCtbMs0jjX$%YKMLCCpbis3UFC;@!GAl!^U@(V zC_K6zn~}AieH#fMj0h%xd0uMBNzrD7XMNgX=0lHO!#kAxkGvH_NJ;3)8}SKBMnSN^ zp3z^6-EOeto=6x&j^PX{S8moJ+vlA?oyYjfzUTODRm(7C( zpV^P>*}wF9RiCbJtN&hZa{RTlxWuC8bkBaU z2iKRP3LBFb4%noBZ~5kl*}aAnCk)(17J|F@IuDf>TK8oC=eh~8qb=)lU{(39wUmO- zqVBn@tf|BI?qn})OL*_`{UvPlVOpH$+BKURJFT4h*v&Vxw81dU`O*m0ZefbvP#2$Y z53!yKT^zn%8Fp)X9_8*i4(J$cZ(XeCp7D>bs zFyTJGX_k%=JlEf0(>!h&#B{4mS$tVzn@`;5<7Vk01i4vmT^^Kd-B})f*?9boFfQoJhfX3Vj{LGrC*zv#wP*cnEog1slM5S!vuFOFM&1M- z>hF6Pw+zN^jD25c>?9${GRD4(D8d_+WEuOCEMpyeRFoy8D6&*aWgBBj%O@n1%8b;< z7Ne9&nCDKP@9+0~f8XbM{r}H>InLeQ_uPB#y=UITci%jA;33Mm3!bV_%@bqCbxn6l~kA5KsAh1}WMLBRS*x~g>$3>1v zJNKl-u}Mjo7$;5h6rJyW>myKOqlu)YCdj|U8|xW)V++4hzDHQ`@7OHqJWbmF!qO*P zVmSZ#t!slGv2WNTFUrzS+)X8&rClRI-+s8GD9M^dDZ(b-#q^TA;t(7Urr?;}87)IW zs`<-f-+PAHx&sH#xwwgoAOxRmQE=;pF0M0e9v%j*oEGXMS^GYGV!zqL54rxH63GE) zewq^45QCiNv0Sh)bX47z$&hw}*A^_}z@j4jO$u-Xf+rdk2pjVRoUzl#_UPo}BaMX% z`pluvnL{>p*QpxRRMw|J*Lf!2b>NiCaf9th;9AJ)Ch`^;@JJY11Se09*;LMnHoPUp zhc!e!O>PksP@zSC#?Hc`Vg*qlB0upwrpJc_c|WpM__5*9Z^-7vusf8Vyc@FkWwa&q zB2HF{`~HvPTlgahd7BkG`?5MJ_yjutt~~nYGv@?}Ow$)JI6=XsXzX!CR(SK&Ol`EE zv>l9VJ!(uNL`JddHhjn-`FrTaNeppE$6-XE6agNiJ%l^e&c$YbLWP&;*O$D3)_!%y z(9rGM)5w8d+}VOBQ}0cO23P4)Wyu&0Xt7otXP<#vs6Uy(ESte2KzSpQG(0h>VdN3qZPMgH^p&2uaub=tgByEjU~j_J zS*0THd7P%`D@h9_$~?ZkG*gLB7P+LoOij}q{OdQH+;;nVyH57TegU(4f7QL$Y|(ml zvZVqm(`+or+G4-LHL^4l$5DHR{4+wE(u>Xm76*?v|32yVtH62e+DH}R$0IG-DI_nA zTqCkh_rn{TwkGad?DQ+-?d{5MNybcdX7793-V~>PdMDLqu5IADT*Il57(;a%ppxa# zs&hiSw#w}tB?UNFP}#pJoJZL(z{E-c{a}G7zUe+#VDkj5X^zM=9MVDsD;{o229}IuLZF=(-09H&%t={!>o4exitoQ;pTuE9KN2Fkuci)3-!M-CoeS>b@wkt}o8 z(6}(6l}HOQwAVO%mJP2Nk7R4ib@fVxv4p)N_0v8?_J2-L7pFvy-Q;RK(fC_Zvko!A zcPd{(v`luWO!Ux}{|mlLIU^CJobx!xoU-&WS-xLHN0~sKF6(ffbUE?DvK?ts$JiIq z5lEWFl=n@yyz<#&L0xSD$}a6!xZR}JU-m{G))G@~mPOu)?N&$JzC(4}sB`@gIRtzF zS#bH^<4sP1bUBi_iz^L-lWX8-J))EXn#~ocsj%}Y1k>E(2%JIQLqbWFuGX76u$MS5 zvtRI#KuvPkTsf+k?(>692aH+*T4EB%Ij^^H2b@mp9BC5iNMNsx8Qd@vhykTvp(Q!6 zd-{l{iG0GuQ?j_ryuDoQQ>Nk>4s$$|uRP`NMy9C8Wv&v}6e&YcZM*m!KK2u9L6Q3T zv-9C_4wHIZQC4RNk)ITStSlhCBOVnR=)~a-GI@evH)E zG07eG0c|H9+-S?aaVBXad?WnG7l!mP>R z#>;SE?lkES?$VEga~7kb3&**!)cat-h0v0jIIh3qlH3&nGyZgrr9F$>LRk-Q#Rok& z`elEKvWj4h|4VZ|?v9Ic$pSJStY|i>Sqg7vLmjYH_Hr2Odi0g6=qk8~v7A4dZ~RPD zuncy)^=AFlWzJmPF4I>pV832FC4{ery` z&Us)Ic*OO2W>4t7mseM_y-gZraJnM?y?_wr*De)V+3N<}D;?)zGZMBl{AmlZkf+QJwQL$R(4a5EoJMtDI zB#x7=_4C>s18d^}H(UeW3fGxG+E?;LdSKuY%ojMOk;iYiATZ(V&;j1UC*~VJ*c3u| zuvDxX*%A=~ZVi|kprw9s;ABrTAVEdg#aCHmc)HG2V&AoANwI! znQ>viWClX_1u#f)Yf>-vHz~xWkvix4`a~JUTBHTSb|j|@IvYyH_jUWURR`he*y8b%)l3_Zy1z- z6C@>^g_AQ+XEW1_J@DNE0r)xb7}+`g@lULFqW&-7ytKu>h~w?39%v?7C*K9@oGk0& zxi#QSNx1lBy_5FF8BSCFT;g+cx4A-K=kzyZB~^VOspZ;`J#Wn=avPFzAf=B3qO%nV z4^+I@E;Md$x+Nq6ZjPgM^6=3=)ia0f7lThE$vLxEs1k8Su=LmOV307gF*Tc6R}HF9V(hLe{i!sg50!<Pd!fDit47EfjebMT zLyts^g*uLz4!+zv!R}}jAG&l6(IQb{P0xmFHXG%)kXj3mrEIK_qlA22(BgaLVu5vXUNCQI%gqF?3 zalkSUo)yh`os1Zs+%|NuD>Glz86p>R%wQdc$&GJC@h!({QN_fEJR@9}d5dwU)ao{b znJW|++VYx6m#&^99_+FtGIHRSWzA*9bS5VUU?J#^#@7kM0ul}}$aF)?;4Lkyc~NnM zFHhuSZ9eZj>=hsR=wFJ{#YCN}sEU|g>P!e1Y|xhImpOp(2>9_)*jT7=x})?-f}(H_ zZ-62-EL-QJxj}cG#U|B^ue^}ssFAZJS^T2%kud49gf zd$Ke|J>;ov?q=b1Sa?(Vcz=`fv0L9euct@DDz*%MvA)3@Fzczk-<0Aa>T|V=RH!1L0p9Q!TdO$=Yp!O(IV_(nl#+Mwj+yc817vWqE@X1wB?v`I6l zk-#DEjzj79cLR$#PYu-13oa@O3)bEb+vY14xW++)LxRNIXWeortA3}v8otbB7;!o{ z*{Sw8BC=j7GxI%n&MmCZ$RrBr&`L9S)e9}xisPWHb_@T%*&6Q?!)1BQ173+va|;=3 zp5F82Zl~E3C00%G-N6>JQR0Fr#j&~(Xi1a#^4d#bv3GD-aF<1aEt$5 z5r1zP#t?iMXt`fJ9$&zHI?$iQ@BbS%^U+3u^fq&l-B`tgWQ-OkQAJo(&AyX#6yaT7 z?gS1*-(Tt-tz zh;(KXOO#kRvjX}g1PA9npD?$QnK{jS#B{IIk&=u$e7gAcjykz$WzRu-gR;(Br0v0J z$`U8wE6e*)l;)|GT5)iGQ2VH;G#6A5Kq7I}guYHH6A-4h4R*;7-}1}eACRJg%&UC) zx^eIwzDlVyLdryX1lp%tmW8Ei)!2RVoMHr7D*90Y+=1|fEgU(F^xeha^g5F3SnYQ*+z=( zC%dQ_2$kp1+4mW05yj^(x>z4g4LAHgFhkwUAveMTVR@-NkWanBybd*>Yth|8=QD36 zK4I3eTdO-B`4pAPy2gpS-J)hkGM%}xfgI>sMqQ`H{S@A~##ZZs4I$C|qzUMCneK0t zjfu$ELYRqu$Tv8_#UjHL&ojqW6YL*z%NUi|tyw^#DGLq~Pw`V1KT^q86_ND`f!HyCP6D^Vz}BF%j0L+y|4}z z8LQF(Ij9~V<1({cU9+d7+eE9(FXIV@^?IE3-vdiVxScc8rQ!}DNPAKvp-OF>z@Zy^ zx^d8p$4~x7GuBoEJnypOSQ&61;+zM|=sZF80nwjrq!Ajm_^zTR#x{@6SIGS93*6lOO z&ZGhoD5yb@Yo4-<7`4#b3WiN-s;oeS&)5iogA)%!dq3@^dbymK}JbI+NJz%edQ?;=KG(6w~`BCIy) zZ$ja~Sue{hX1-shS#*`W+xW{AIS5IS%|*3WD8Ml$Jsy^m_yFiQ*GVai`bempO4ZwG ze+yW>_6jZ86c&*YF8?-Du;m>?X~Cn;DK{$AEWLEAy7x8n0z zaaF`bEBtAIcH>iWBd4m)?i-vq{*K%M_~mTdU|6oZ16R(JVus>-ejMm|F|^Vz2FzTN z4wyBqX7;-)cybKPEh5{gix+e)$5v;Kz;oa0Xx`ffj%t?LPo|Hsd6T>x98Pl9Abt7o zLaPy zI#`jY@>GKa^}+me5%Al$BRE>QHkfx$sj9t8&TQGwR@38o-;L%bO?o$++mcpBwY}Zk zEs^n5ne?G+9|sXGKfSb%w3W#W&uZbUrAd5$uM_7XoViu};(dL8^LJQ$hEAFyzlE4+ zhj_8`P*I!eflbGs_z#uJ>6vF;{fOD{j$rw@6G`7aVy7YO@{=!R?56ybWYsL3QfgHE z0BhKB0VK{)`ZCPK#P}p??5OS?b{0(?E*%0{-yaMp6?!&7caLnqcSI(iO zh#&^MR$XR#$cuKuT-=?(;6*2kgNmZGrm#g$Ms$n%FPd`89D{|hj~k@{ZeIMdxQ)Y2@_h#fF6#?sbTG&mXcY);O?V;Bambq7WFVVvuf?U)h?jSwKV9XV z33hT~b330>>o!cOy(mJc9!j_%eKup?3&+~xp-SJ5>o>IA!}#VpxVq59R+wv(V?CRT z+6;4Gj+|5fojgHnuDnuk#_7vlTl;m0&?%xome_C)GwV|(y{k30hvS93qLWz|V(R)xBH0aF`Vd^en_ z+xi9a+o!?03ts0W^K(77>W*yDj@|R?MObvB1Zj#TSn5r|q51ENsRi5#ntZftd4urz zMUmuF`>&p;7>*yUy#tkOWljogSOVYE5$7+eQqCK@VsE#{&1YG@%+Wz$>gApT+kSV2 z(cGZMk*rL2A^$$P8YJ;3+lC0a3R}c7>Fxs)$itiOZxX2mgSrf@0;!s$kuJI^LI{E5 zf@CmPd8}3z6JuppW8`}IOPAVzMq-gHW>0JTgwdJ19ym9783R3mGH+~R_x*x)Lesfd zI}KA(64r}BkYY-=X2$A1$ihw`0VV9lc@O8M9yy~=^m^n`Yqr7ic)6}h%)i9_Hk`l7 z2-&28z-6(na^lfx<-$r5HMLJ2yVQ|R<^~)JT8vk@C1{l)Y{#rz&5Hsvb|de7gtvoF zL<>CeK@YhlMnk2-oBa&e&nk&*ZhWZlshQnbAxnzO7U`x(8yn?w2+-839j_M~0@g`s8T()syPU@K%N%V2flN9`iKD?D?im5Ua@4^!+i5NsD8&|+zFZ$|_Mq4dts zXkf7Cq1ApdoD!u~K}!aSOwp;NqO2zwJUHY5FZj)}| zyaLh(N(4X8wbzMRv4}E$0%4BrI;)JKP=)6S{ITeOmt8bZL`D(1P&DLCRi8 zPhj<44(C+!n}Vlm%Gh)Ix2Z!~xdQ_?q%6w1Gj1(=6n{g~2ga|~|K`eAMrsH_FF zBc}U^THDj(6~VEDaG$u2ikgT(li;ipWYI_X3sudt11znOfsdwOyBw@3^Y&nHOhtWy zBP}^*e>yh{oIo}gcC@6xe>r8rG}z6)`lj&LcBaBe%9@hod7f8IumulE=3s))K`_=V z=qr;LP7QuKG=j(Cx}xV8l+TIf#$=7(7bK5Lx}XV7s-)rk{+9(KMF#Q5hRjO!cZJjq zq%qXAw_)ro?>fONli_I5%#v>%g{xcEf1S0&4aTfj+wL)7s74H~N0Q8&G$iXDo9mJ= zYAOz`eA%{(THdJ+*ZPh$7BpJx-(AO6DkWabP}k0FbuI9@^ycGRGU z1Mh$Gm>7x+?k-8*j-PpZdW$wL=I8a76xEBjfi%Zg@$t(SbRQ?*hT1*WN|IqvOn2jX zmXaf=!Sg4EgZAjW+q^qCdx0{WSdO=8PyKiyV>!XzU|Xtoq~evyQP_+o@f3G7MFT0* zK;F2Jph0O8#0~)qcYzsG(YUm1goZ@nbge;`UOEcQNF4OlOca+kGwxS+VZSQQf%AHM z7+y;vU&tCZ@YW!+{UoryCu#}Z7$Ju57ceYL##n?+_L2W=$cFXiB`!94M`KR0fC5j{F$-|ft8 zWhAC1Qss!{ED3StaPdF+1bK2tMXDwK6Z*^C@Rjfti`Hkl-?eqxiiWV|e2TfkUhg)Q zN0E~i6qk}mu;}r(`E1?tFol+GNz=TvzYxYOdg!!0u=1F~p|8N7recv?XB77c7AXVI znrVqMiF{pYbjZ*S>=@XyjHhdYnf$gH8ehO`Oq1R>t~KD4zrgrz;5`au%r9LnCW#K5 zX1$l%DA}Kv7oNh&Z!}1y-60;Qp0~<_!<;!-v{)`W!wHjI0a`3vV6BTmUu(ruB%00j zxh(p#PJAGD;(o)%AMj+9>=DZ|oyl$51W}Zh_ES}HWyll8HOR}OExy_7=dKMD4(^eG&$1cY}6|S73K?& zbAB&`wEBcPZ`pp~cS@uXrTr2|F-;5ICufM&MTle<6zX>KBW}nM%1bF8*@B*?bypFi zI~?S0HgB)_VDPe8_1@X_?QR z&wgQYN1&aG{)AH;f1Io$K_K5w;-tJ+3ARm+fhY6Av~Ek>s?A~17fA7MQ8Vz(rR3HK zO;i6ej5#~Z@tBe;gjJ0o=N5aHEH+hmUu84luS*w}Iq)qLJEh>IEUvK0+5u)t1^P-B zi_JEC`mkHj|Dx!=V&W-7O$v2$CRqtedf8u)xeq25+=~fDrKG5Ry%OZSYm61hf zkpzDcCU@e8oO4u@_Gc`1Jh?)V&2@hTF-OLteu|_)X2j8XT_Vy-qq%jhz zs*;tjh8dzpB5l@A44GR=fFs4P=Y^tFgxEbwU^u!bKS zRA~ArmdW0EwtsgBVND4U8>23MKMf45_cd0#m@K~JCR8in&T#w3%<*9?l(gL0K0Cbh zsqztx#$ay1c|&=JIOM2U>H`^G^9Wwd-;L}FVODu1-R%9jUEJov92{L-41?VSXiZKu z;l2Hc7@1ufVMHKN)+Gl=b>>6y4oV`_zr9H2ur76JjFzah-5i^1fB2K;Pmoyl$L;y4c6S^YMrJh()${|%a}Kr z6^LdyMpDvFmF11usVA~j)gw;$2p^T<#d2TV)z=E32Ov&KS-fi>3qfa=j9h*u(rqL~ zAX1OZ`Pj7H{M2OCD^bppBaA}n(~^9U8EV7JBP=@`?un;KZ*kNl2Nifd)_X9V>L6f& zREpZieZ*GEga;{W74LNh)-WU_(AmrvwJA!c@sXSZ zVi2Yx^su0!1T{U1{py2@K6_P>f_$M4D!YNyOC4GuTyv9dA~Q&NS#)Vd_-T^XHU1p+tR123 zCais5riKpZ6P?GDWrp?r`>KPzimS$)jl?FsW0OLP#pqA|p%G zIfA&47+ZMfIyY{Lg!ZVp$zmY`DKBA5dp}nNs=a+Bd93qhtjNzCWI1{{JFtgZX1Y}e zV3ssOb;#EdU{t5nu7)+=Y@ZH`@Xlfpo{Z8Lt~{W}T4 zQHmG~_{(c@8M3;|8#6@sZO)YnM{{d7m#0(HW4TvO2~=hzn`WdRAdtx(W_MMq`LHg>H+DR)VO`R*V`mjO*MhO|-{&OR}SjSshD?|#@ zW;x@cDVH^c{82cCM^YWpTmcGs&i+)9IL=o-hYd$x7Uw!D8DW6K9HLN$KAbO577gJt#)t&+gYJep2gg;3IR1`n2N&PVlAGk)DN@(gIX{o^(VkM0w!4yEg=`S*2GTPh z3%ro(H8S?OJ#AiKNqWs4cAlU^I3!%KG2et=d+We!ZyZyHmF;l zYVTvr3)=Mssx%IS0#$%Cc{o@aF3=kIO+QU4QQ)p#78BO>cd{exm}M18zf+273z3#< z?G?-9*ASx)c14SmN{S-K9&$`XjKoFA zn9BhQd58-$rK+7_Ybd3HtuaI-;4}yy3i!2?I9(b{sBiBa9L`~>kvdSLWNkel`+$ONYV>0;1^`OuB}aMH?HdH!yo^Y`9|70Q&C zi;6P3yI#S@N9Ph@C{Us@W}S=(hD-J1&>WBzwg4m$x>G%Z|}iz zW4hNqnTQM20M`y*ezCj#2*`NNrI^#+27js(o;v51Ah2^D?mV{qR0?l?pkV#IsR1!w zn#om%iHRrgWETswCnC$-b*7(7JdYcP3? zO9~B>*q^0G38QvScKWhMb1$4n$obT6S{C0ej;amEe1ERHHW9bpyM@5`B+&ZvuhMrR z2UdgmAG>|K6bJZn^z?RW_@HGjMeKLXbnarQKl`pT;t2Y)}s*jRvqTD&IV<`c(T=Zz<-Ub1-MyE3?U$ zXgv9Fcb{&jUS(ZTn0j-^EG>=ZTTt`E95ARwg@~$e-Lg>%E#C{9>5krcp}t{CzY3LfQ=k zJ#}ug;G4Xz!@J-^W|*wi8s&&%zK`?A*)ecz>Dk2Dl`)9Y{qsXRL5z~yvItj*DQIY7heRA0(RTzt{MWn&{KPqeah03H%63ZG?svnk9$iMw-a?6cs&0%0YM}gQ7cX)5qj%$H-={h#rz0nv=|7z4 zX*ZZ851*ZdKTXlDp>Mt<+@YqRGf3-l%we);;F{v}I@*>bYTJGnaL z-gmHX(D85LPXA4(FAX|oP3pzjT+*~1jn(%3*H=2dZ&@Dy;EjdaV6T-@5`ot(`#F4Ys$m%2Z5qkgWc)lG1tZm4xwVNv1!1_vCkAIQ~0X$w~ly zsgb@_ns_kr;DPfSyQcJo%fEA3PEB;F)CtzVil9%kbya!9nZf?X zB{mhg_|Vk7Ypp1OE5-EbtE9Rs}4IldjE?JT1QaRut#r}zNu|y zv$?eQHhyVodN#ZQTjIIBVsiDocf`%3Zq#dA^mSP?9_MMky|(q=14Xc2nc*}t5+PfsNo3hYa6qo0_b%fu~a zSMJg;T=IDQ=%A*p=P1_3novHWtgNa>u$mYru;KoypQC>gqdAU5(Uh{=tDuY~S+n$@>baUPKhpXYP_*uEPbb&IlVSh#f%U22ne|7v;7o#aw}%%vAZZ*{Ku!|pd0 z7Cz^3O6ejs*(pO|XXXa2Ki()+GjXO*J4w+#Ohkn{*(>+3%C^0x&mreVH!LhH9Mjj0@_2;~wtQbj(8qWwK`&7s@+zmtzo|C|jOKcXdkEc?7EeR?)Rb)TwF>YHZ(jS2eZeW!a7sa0%;Fg;dvTw|C*`)yLGLqnoXrob z9oGNW^312(;<8iO1*=|<@|z0_j}GdWqt-jiZQeQ94(@MjSZla({+l-j)qU}bKH^Ya zJ_qSJ&DY^flcoGL7oBd?Dz6*(ZtzC;J!W->wkxZOr^|xpP51f)Ff-yxylPqlh#Jm* z>qm*3RtdTe+uz95=rYG|v)FZq59_6#$)&dUP!}GY8Gmv?a6C|adizv`;Wr31ldXJS zL21G*?Zfo!=htY|@Y;EF84A9Ri0|wj=ggSoc%yO6(R4?yUdoUDDar5k;NOFbR%KHs zOu`4P)T0!`CJvyJtq%QEdi?jxLd{1qod}hlA+@JcXy0gWoj zKib`GMM_k5s47RJm5QuUf{pGqzm8howKG?EFlE`0!w%1_Uch=pzc3C@)B?+f!P0|j zPQMq{J9~W>Dg%R;C9LP@BY_{<=!2AWugmiH%tue?9&fb`T3aZ+_h|yHZ2PfnVZHLP z$E~m~Jfg5(VpCP_+>I6bvQ^?i1GF+@U4sxmI{JL{S(&)V6i^T9qi@~~ca)dxE(5d< zs{JxrShK$eC19Xc`q#a$3eE@KBL243t6o#wtPEN&a|=V3m9CVPf<^r3pxWnBTP-L1 zQd{M+(qo{XPIixbf6wbZ-ik?%Rl0Yg;qpS1$HZB*wog^{nv=teRy(&?26;yCss0w_ zQ&l=TI(L5SgQrqy^w07hk7=}C>o-Sb@vKKi3lGsJ-j~|XosSB}{$YdB!ouy#PENMr zRox8kDSImA*_Ua%H`M<)9CdPGq4XBdlc0r_Cr?W47uL&K!5iyPk%Ugs1a zNtc}-9Sl14==bPom}sx#oM|FsR&0Yx&&8MZ(u%&>D&2Jl3_yIejbJ?1YggI^#u)xc zb@Fc1hM@1G1Bi%eFuRNp73FSyZsC^Ell8}CWw!vOhsw5~$SuUKtQMcp&1K7h4%ptI%p^W4&}}0nEmC z9jCS5f3Kh4noF*Z^4OX_53UwD)WRYn;=l)KXjoiu5Wr0H-ZWMQFflPya1+6I<)1%B z(LVvC|3~r$;Ix7XH=lKWz!cM4dbZ@_Ayy0vTgx0WGwI1)YIF zTrMo=|IQDOK~T!Xq!}C(8ucdz{>7aMI{9C520jYX5jIeGQ0PfU9ahhlqAem@zl* z05^iE1@)MOqGJLh0A>WnnDqc^|NoF94E}%smdAev#?${E{v&Pn?`2F({||D6S>^vv zX8fy3oqv}Z|B^ZXP0jsG*h!0spp*YwY%q=eOKk9j|06aSb^jm41yjYp#D(5}hzq5E zqxo;6HU`Zy|B?0}@}GMZ)ceE3|J$Bqw7~!{H~+mSVgEbtf6Ny+`C>XB8x#Wp92<7> z4+c>t`G4mL;A0eo!!g$1e+Qd0{|IoiTQL!g1z;Ei{dt%{m Date: Sat, 25 May 2024 22:59:56 +0200 Subject: [PATCH 20/52] move service_manager tests to root folder tests and fix paths --- extractor_service/Dockerfile | 1 + .../tests => extractor_service}/__init__.py | 0 extractor_service/main.py | 13 +++++++++---- .../tests/e2e => tests/service_manager}/__init__.py | 0 .../service_manager/e2e}/__init__.py | 0 .../e2e/best_frames_extractor_test.py | 0 .../tests => tests/service_manager}/e2e/conftest.py | 6 ++---- .../e2e/top_images_extractor_test.py | 0 .../service_manager/integration}/__init__.py | 0 .../service_manager}/integration/conftest.py | 2 +- .../integration/docker_container_test.py | 0 .../integration/docker_image_test.py | 0 .../integration/service_initializer_test.py | 0 .../service_manager/unit/__init__.py | 0 .../service_manager}/unit/conftest.py | 0 .../service_manager}/unit/docker_manager_test.py | 0 .../unit/service_initializer_test.py | 0 17 files changed, 13 insertions(+), 9 deletions(-) rename {service_manager/tests => extractor_service}/__init__.py (100%) rename {service_manager/tests/e2e => tests/service_manager}/__init__.py (100%) rename {service_manager/tests/integration => tests/service_manager/e2e}/__init__.py (100%) rename {service_manager/tests => tests/service_manager}/e2e/best_frames_extractor_test.py (100%) rename {service_manager/tests => tests/service_manager}/e2e/conftest.py (72%) rename {service_manager/tests => tests/service_manager}/e2e/top_images_extractor_test.py (100%) rename {service_manager/tests/unit => tests/service_manager/integration}/__init__.py (100%) rename {service_manager/tests => tests/service_manager}/integration/conftest.py (88%) rename {service_manager/tests => tests/service_manager}/integration/docker_container_test.py (100%) rename {service_manager/tests => tests/service_manager}/integration/docker_image_test.py (100%) rename {service_manager/tests => tests/service_manager}/integration/service_initializer_test.py (100%) rename extractor_service/__inti__.py => tests/service_manager/unit/__init__.py (100%) rename {service_manager/tests => tests/service_manager}/unit/conftest.py (100%) rename {service_manager/tests => tests/service_manager}/unit/docker_manager_test.py (100%) rename {service_manager/tests => tests/service_manager}/unit/service_initializer_test.py (100%) diff --git a/extractor_service/Dockerfile b/extractor_service/Dockerfile index 683edd6..7782f5b 100644 --- a/extractor_service/Dockerfile +++ b/extractor_service/Dockerfile @@ -29,6 +29,7 @@ RUN pip install --no-cache-dir -r requirements.txt ENV NVIDIA_VISIBLE_DEVICES all ENV NVIDIA_DRIVER_CAPABILITIES compute,video,utility ENV TF_CPP_MIN_LOG_LEVEL 3 +ENV DOCKER_ENV=1 COPY . . diff --git a/service_manager/tests/__init__.py b/extractor_service/__init__.py similarity index 100% rename from service_manager/tests/__init__.py rename to extractor_service/__init__.py diff --git a/extractor_service/main.py b/extractor_service/main.py index a26d3ec..58cafd3 100644 --- a/extractor_service/main.py +++ b/extractor_service/main.py @@ -24,17 +24,22 @@ along with this program. If not, see . """ import logging +import os import sys import uvicorn from fastapi import FastAPI, BackgroundTasks -from .app.schemas import ExtractorConfig, Message, ExtractorStatus -from .app.extractor_manager import ExtractorManager +if os.getenv("DOCKER_ENV"): + from app.schemas import ExtractorConfig, Message, ExtractorStatus + from app.extractor_manager import ExtractorManager +else: + from .app.schemas import ExtractorConfig, Message, ExtractorStatus + from .app.extractor_manager import ExtractorManager logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", handlers=[logging.StreamHandler(sys.stdout)]) logger = logging.getLogger(__name__) diff --git a/service_manager/tests/e2e/__init__.py b/tests/service_manager/__init__.py similarity index 100% rename from service_manager/tests/e2e/__init__.py rename to tests/service_manager/__init__.py diff --git a/service_manager/tests/integration/__init__.py b/tests/service_manager/e2e/__init__.py similarity index 100% rename from service_manager/tests/integration/__init__.py rename to tests/service_manager/e2e/__init__.py diff --git a/service_manager/tests/e2e/best_frames_extractor_test.py b/tests/service_manager/e2e/best_frames_extractor_test.py similarity index 100% rename from service_manager/tests/e2e/best_frames_extractor_test.py rename to tests/service_manager/e2e/best_frames_extractor_test.py diff --git a/service_manager/tests/e2e/conftest.py b/tests/service_manager/e2e/conftest.py similarity index 72% rename from service_manager/tests/e2e/conftest.py rename to tests/service_manager/e2e/conftest.py index 7a7b380..04b2ec8 100644 --- a/service_manager/tests/e2e/conftest.py +++ b/tests/service_manager/e2e/conftest.py @@ -1,11 +1,8 @@ -import sys from pathlib import Path import pytest -common_path = Path(__file__).parent.parent.parent.parent / "common" -sys.path.insert(0, str(common_path)) -from common import ( +from tests.common import ( files_dir, best_frames_dir, top_images_dir, setup_top_images_extractor_env, setup_best_frames_extractor_env ) @@ -14,5 +11,6 @@ @pytest.fixture(scope="module") def start_script_path(): base_path = Path(__file__).parent.parent.parent.parent + print(base_path) start_script_path = base_path / "start.py" return start_script_path diff --git a/service_manager/tests/e2e/top_images_extractor_test.py b/tests/service_manager/e2e/top_images_extractor_test.py similarity index 100% rename from service_manager/tests/e2e/top_images_extractor_test.py rename to tests/service_manager/e2e/top_images_extractor_test.py diff --git a/service_manager/tests/unit/__init__.py b/tests/service_manager/integration/__init__.py similarity index 100% rename from service_manager/tests/unit/__init__.py rename to tests/service_manager/integration/__init__.py diff --git a/service_manager/tests/integration/conftest.py b/tests/service_manager/integration/conftest.py similarity index 88% rename from service_manager/tests/integration/conftest.py rename to tests/service_manager/integration/conftest.py index 2a013a2..597cd64 100644 --- a/service_manager/tests/integration/conftest.py +++ b/tests/service_manager/integration/conftest.py @@ -2,7 +2,7 @@ import pytest from config import Config -from ...docker_manager import DockerManager +from service_manager.docker_manager import DockerManager @pytest.fixture(scope="package") diff --git a/service_manager/tests/integration/docker_container_test.py b/tests/service_manager/integration/docker_container_test.py similarity index 100% rename from service_manager/tests/integration/docker_container_test.py rename to tests/service_manager/integration/docker_container_test.py diff --git a/service_manager/tests/integration/docker_image_test.py b/tests/service_manager/integration/docker_image_test.py similarity index 100% rename from service_manager/tests/integration/docker_image_test.py rename to tests/service_manager/integration/docker_image_test.py diff --git a/service_manager/tests/integration/service_initializer_test.py b/tests/service_manager/integration/service_initializer_test.py similarity index 100% rename from service_manager/tests/integration/service_initializer_test.py rename to tests/service_manager/integration/service_initializer_test.py diff --git a/extractor_service/__inti__.py b/tests/service_manager/unit/__init__.py similarity index 100% rename from extractor_service/__inti__.py rename to tests/service_manager/unit/__init__.py diff --git a/service_manager/tests/unit/conftest.py b/tests/service_manager/unit/conftest.py similarity index 100% rename from service_manager/tests/unit/conftest.py rename to tests/service_manager/unit/conftest.py diff --git a/service_manager/tests/unit/docker_manager_test.py b/tests/service_manager/unit/docker_manager_test.py similarity index 100% rename from service_manager/tests/unit/docker_manager_test.py rename to tests/service_manager/unit/docker_manager_test.py diff --git a/service_manager/tests/unit/service_initializer_test.py b/tests/service_manager/unit/service_initializer_test.py similarity index 100% rename from service_manager/tests/unit/service_initializer_test.py rename to tests/service_manager/unit/service_initializer_test.py From 15b226766cd3de81c89ba383bf8d63180e5a1c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sat, 25 May 2024 23:41:48 +0200 Subject: [PATCH 21/52] fix extractors unit tests after fixing DIP in extractors --- extractor_service/app/extractors.py | 2 +- extractor_service/app/image_processors.py | 2 +- .../unit/best_frames_extractor_test.py | 6 +++- tests/extractor_service/unit/conftest.py | 11 +++++++ .../extractor_service/unit/extractor_test.py | 31 +++++++++++-------- .../unit/top_images_extractor_test.py | 6 +++- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 86e48ba..bc23b00 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -79,7 +79,7 @@ def _get_image_evaluator(self) -> ImageEvaluator: self._image_evaluator = self._image_evaluator_class(self._config) return self._image_evaluator - def _list_input_directory_files(self, extensions: tuple[str], + def _list_input_directory_files(self, extensions: tuple[str, ...], prefix: str | None = None) -> list[Path]: """ List all files with given extensions except files with given filename prefix form diff --git a/extractor_service/app/image_processors.py b/extractor_service/app/image_processors.py index 5ea30fb..f75718a 100644 --- a/extractor_service/app/image_processors.py +++ b/extractor_service/app/image_processors.py @@ -62,7 +62,7 @@ def save_image(cls, image: np.ndarray, output_directory: Path, output_extension: @staticmethod @abstractmethod - def normalize_images(images: list[np.ndarray], target_size: tuple[int]) -> np.array: + def normalize_images(images: list[np.ndarray], target_size: tuple[int, int]) -> np.array: """ Resize a batch of images and convert them to a normalized numpy array. diff --git a/tests/extractor_service/unit/best_frames_extractor_test.py b/tests/extractor_service/unit/best_frames_extractor_test.py index 5a219d9..9881a72 100644 --- a/tests/extractor_service/unit/best_frames_extractor_test.py +++ b/tests/extractor_service/unit/best_frames_extractor_test.py @@ -6,6 +6,8 @@ import pytest from extractor_service.app.extractors import BestFramesExtractor +from extractor_service.app.image_evaluators import InceptionResNetNIMA +from extractor_service.app.image_processors import OpenCVImage from extractor_service.app.video_processors import OpenCVVideo @@ -18,7 +20,9 @@ def all_frames_extractor(extractor): @pytest.fixture(scope="function") def extractor(config): - extractor = BestFramesExtractor(config) + extractor = BestFramesExtractor( + config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA + ) return extractor diff --git a/tests/extractor_service/unit/conftest.py b/tests/extractor_service/unit/conftest.py index 8c41df5..b82c130 100644 --- a/tests/extractor_service/unit/conftest.py +++ b/tests/extractor_service/unit/conftest.py @@ -1,3 +1,14 @@ +import pytest + +from extractor_service.app.extractors import BestFramesExtractor from extractor_service.app.schemas import ExtractorConfig from tests.extractor_service.common import config from tests.common import files_dir, best_frames_dir + + +@pytest.fixture(scope="function") +def extractor(config): + extractor = BestFramesExtractor( + config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA + ) + return extractor diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index 60081b9..72650f0 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -6,34 +6,39 @@ import pytest from extractor_service.app.image_processors import OpenCVImage +from extractor_service.app.video_processors import OpenCVVideo +from extractor_service.app.image_evaluators import InceptionResNetNIMA from extractor_service.app.extractors import (ExtractorFactory, BestFramesExtractor, TopImagesExtractor) def test_extractor_initialization(config): - extractor = BestFramesExtractor(config) + extractor = BestFramesExtractor(config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA) assert extractor is not None assert extractor._config == config assert extractor._image_evaluator is None -@pytest.fixture +@pytest.fixture(scope="function") def extractor(config): - return BestFramesExtractor(config) + extractor = BestFramesExtractor( + config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA + ) + return extractor -@patch("extractor_service.app.extractors.InceptionResNetNIMA") -def test_get_image_evaluator(mock_evaluator, extractor, config): - expected_evaluator = MagicMock() - mock_evaluator.return_value = expected_evaluator +def test_get_image_evaluator(extractor, config): + expected = "value" + mock_class = MagicMock(return_value=expected) + extractor._image_evaluator_class = mock_class result = extractor._get_image_evaluator() - mock_evaluator.assert_called_once_with(config) - assert result == expected_evaluator, \ + mock_class.assert_called_once_with(config) + assert result == expected, \ "The method did not return the correct ImageEvaluator instance." - assert extractor._image_evaluator == expected_evaluator, \ + assert extractor._image_evaluator == expected, \ "The ImageEvaluator instance was not stored correctly in the extractor." @@ -51,7 +56,7 @@ def test_evaluate_images(extractor): @pytest.mark.parametrize("image", ("some_image", None)) -@patch("extractor_service.app.extractors.OpenCVImage.read_image", return_value=None) +@patch.object(OpenCVImage, "read_image", return_value=None) @patch("extractor_service.app.extractors.ThreadPoolExecutor") def test_read_images(mock_executor, mock_read_image, image, extractor): mock_paths = [MagicMock(spec=Path) for _ in range(3)] @@ -73,14 +78,14 @@ def test_read_images(mock_executor, mock_read_image, image, extractor): assert not result -@patch("extractor_service.app.extractors.OpenCVImage.save_image", return_value=None) +@patch.object(OpenCVImage, "read_image", return_value=None) @patch("extractor_service.app.extractors.ThreadPoolExecutor") def test_save_images(mock_executor, mock_save_image, extractor, config): images = [MagicMock(spec=np.ndarray) for _ in range(3)] mock_executor.return_value.__enter__.return_value = mock_executor mock_executor.submit.return_value.result.return_value = None calls = [ - ((mock_save_image, image, config.output_directory, config.images_output_format),) + ((OpenCVImage.save_image, image, config.output_directory, config.images_output_format),) for image in images ] diff --git a/tests/extractor_service/unit/top_images_extractor_test.py b/tests/extractor_service/unit/top_images_extractor_test.py index ce1cd99..f48f5ea 100644 --- a/tests/extractor_service/unit/top_images_extractor_test.py +++ b/tests/extractor_service/unit/top_images_extractor_test.py @@ -5,12 +5,16 @@ import pytest from extractor_service.app.extractors import TopImagesExtractor +from extractor_service.app.image_evaluators import InceptionResNetNIMA from extractor_service.app.image_processors import OpenCVImage +from extractor_service.app.video_processors import OpenCVVideo @pytest.fixture() def extractor(config): - extractor = TopImagesExtractor(config) + extractor = TopImagesExtractor( + config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA + ) return extractor From c2dd62b7e3c31980395fb01e59b82d32e0be5057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Sun, 26 May 2024 14:35:55 +0200 Subject: [PATCH 22/52] move dependency injection to extractors manager --- extractor_service/app/extractor_manager.py | 12 +++++++++--- extractor_service/app/extractors.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/extractor_service/app/extractor_manager.py b/extractor_service/app/extractor_manager.py index b4aeec0..a5acff0 100644 --- a/extractor_service/app/extractor_manager.py +++ b/extractor_service/app/extractor_manager.py @@ -24,7 +24,10 @@ from fastapi import HTTPException, BackgroundTasks from .extractors import Extractor, ExtractorFactory +from .image_evaluators import InceptionResNetNIMA +from .image_processors import OpenCVImage from .schemas import ExtractorConfig +from .video_processors import OpenCVVideo logger = logging.getLogger(__name__) @@ -71,17 +74,20 @@ def start_extractor(cls, background_tasks: BackgroundTasks, config: ExtractorCon return message @classmethod - def __run_extractor(cls, extractor: Type[Extractor], extractor_name: str) -> None: + def __run_extractor(cls, extractor_class: Type[Extractor], extractor_name: str) -> None: """ Run extraction process and clean after it's done. Args: - extractor (Extractor): Extractor that will be used for extraction. + extractor_class (Type[Extractor]): Extractor that will be used for extraction. extractor_name (str): The name of the extractor that will be used. """ try: cls._active_extractor = extractor_name - extractor(cls._config).process() + extractor = extractor_class( + cls._config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA + ) + extractor.process() finally: cls._active_extractor = None cls._config = None diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index bc23b00..4e9e50a 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -16,11 +16,11 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License -along with this program. If not, see . +along with this program. If not, see . """ from concurrent.futures import ThreadPoolExecutor from pathlib import Path From 971b5361f5d8a5dbaf4e351628408b2f315f6b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Mon, 27 May 2024 20:18:23 +0200 Subject: [PATCH 23/52] add dependencies module --- extractor_service/app/dependencies.py | 17 +++++++++++++++++ extractor_service/main.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 extractor_service/app/dependencies.py diff --git a/extractor_service/app/dependencies.py b/extractor_service/app/dependencies.py new file mode 100644 index 0000000..9aa3c04 --- /dev/null +++ b/extractor_service/app/dependencies.py @@ -0,0 +1,17 @@ +from typing import Type + +from .image_evaluators import InceptionResNetNIMA +from .image_processors import OpenCVImage +from .video_processors import OpenCVVideo + + +def get_image_processor() -> Type[OpenCVImage]: + return OpenCVImage + + +def get_video_processor() -> Type[OpenCVVideo]: + return OpenCVVideo + + +def get_evaluator() -> Type[InceptionResNetNIMA]: + return InceptionResNetNIMA diff --git a/extractor_service/main.py b/extractor_service/main.py index 58cafd3..69c4a34 100644 --- a/extractor_service/main.py +++ b/extractor_service/main.py @@ -28,7 +28,7 @@ import sys import uvicorn -from fastapi import FastAPI, BackgroundTasks +from fastapi import FastAPI, BackgroundTasks, Depends if os.getenv("DOCKER_ENV"): from app.schemas import ExtractorConfig, Message, ExtractorStatus From 3f657a9396cdbf7cadd72b5c67270e00dc3053a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Mon, 27 May 2024 20:20:31 +0200 Subject: [PATCH 24/52] change README files tests sections after moving tests to root folder --- .github/README.pl.md | 5 ----- README.md | 5 ----- 2 files changed, 10 deletions(-) diff --git a/.github/README.pl.md b/.github/README.pl.md index 4247c71..9510494 100644 --- a/.github/README.pl.md +++ b/.github/README.pl.md @@ -453,11 +453,6 @@ Testy możesz uruchomić instalując zależności z pyproject.toml i wpisując w terminal w lokalizacj projektu - pytest.

-
- Proszę zwrócić uwagę, że w projekcie są dwa foldery tests/. - extractor_service i service_initializer mają testy osobno. - W pliku common.py znajdują się pliki wpółdzielone przez testy i potrzebne do ich działania. -
jednostkowe

diff --git a/README.md b/README.md index 9f245dc..a500001 100644 --- a/README.md +++ b/README.md @@ -445,11 +445,6 @@ You can run the tests by installing the dependencies from pyproject.toml and typing in the terminal in the project location - pytest.

-
- Please note that there are two tests/ folders in the project. - extractor_service and service_initializer have separate tests. - The common.py file contains shared files for the tests and necessary for their operation. -
unit

From 5ad9cb0f9ea592124ccfd759d3ce91fc2e215340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 18:16:55 +0200 Subject: [PATCH 25/52] use Depends() from FastAPI for better dependencies handling and refactor code and tests --- extractor_service/app/dependencies.py | 22 ++++++++++++++ extractor_service/app/extractor_manager.py | 29 +++++++------------ extractor_service/app/extractors.py | 22 ++++++++++---- extractor_service/main.py | 20 ++++++++----- tests/extractor_service/common.py | 27 +++++++++++++++++ .../integration/best_frames_extrator_test.py | 5 ++-- .../extractor_service/integration/conftest.py | 8 +---- .../manager_and_fastapi_integration_test.py | 6 ++-- .../integration/top_images_extractor_test.py | 5 ++-- tests/extractor_service/unit/conftest.py | 10 +------ .../unit/extractor_manager_test.py | 17 +++++------ .../extractor_service/unit/extractor_test.py | 14 +++++---- 12 files changed, 115 insertions(+), 70 deletions(-) diff --git a/extractor_service/app/dependencies.py b/extractor_service/app/dependencies.py index 9aa3c04..d151a29 100644 --- a/extractor_service/app/dependencies.py +++ b/extractor_service/app/dependencies.py @@ -1,10 +1,20 @@ +from dataclasses import dataclass from typing import Type +from fastapi import Depends + from .image_evaluators import InceptionResNetNIMA from .image_processors import OpenCVImage from .video_processors import OpenCVVideo +@dataclass +class ExtractorDependencies: + image_processor: Type[OpenCVImage] + video_processor: Type[OpenCVVideo] + evaluator: Type[InceptionResNetNIMA] + + def get_image_processor() -> Type[OpenCVImage]: return OpenCVImage @@ -15,3 +25,15 @@ def get_video_processor() -> Type[OpenCVVideo]: def get_evaluator() -> Type[InceptionResNetNIMA]: return InceptionResNetNIMA + + +def get_extractor_dependencies( + image_processor=Depends(get_image_processor), + video_processor=Depends(get_video_processor), + evaluator=Depends(get_evaluator) +) -> ExtractorDependencies: + return ExtractorDependencies( + image_processor=image_processor, + video_processor=video_processor, + evaluator=evaluator + ) diff --git a/extractor_service/app/extractor_manager.py b/extractor_service/app/extractor_manager.py index a5acff0..6cfe32d 100644 --- a/extractor_service/app/extractor_manager.py +++ b/extractor_service/app/extractor_manager.py @@ -23,11 +23,9 @@ from fastapi import HTTPException, BackgroundTasks +from .dependencies import ExtractorDependencies from .extractors import Extractor, ExtractorFactory -from .image_evaluators import InceptionResNetNIMA -from .image_processors import OpenCVImage from .schemas import ExtractorConfig -from .video_processors import OpenCVVideo logger = logging.getLogger(__name__) @@ -38,7 +36,6 @@ class ExtractorManager: maintaining system stability. """ _active_extractor = None - _config = None @classmethod def get_active_extractor(cls) -> str: @@ -51,46 +48,40 @@ def get_active_extractor(cls) -> str: return cls._active_extractor @classmethod - def start_extractor(cls, background_tasks: BackgroundTasks, config: ExtractorConfig, - extractor_name: str) -> str: + def start_extractor(cls, extractor_name: str, background_tasks: BackgroundTasks, + config: ExtractorConfig, dependencies: ExtractorDependencies) -> str: """ Initializes the extractor class and runs the extraction process in the background. Args: - config (ExtractorConfig): A Pydantic model with configuration - parameters for the extractor. - background_tasks: A FastAPI tool for running tasks in background, - which allows non-blocking operation of long-running tasks. extractor_name (str): The name of the extractor that will be used. + background_tasks (BackgroundTasks): A FastAPI tool for running tasks in background. + config (ExtractorConfig): A Pydantic model with extractor configuration. + dependencies(ExtractorDependencies): Dependencies that will be used in extractor. Returns: str: Endpoint feedback message with started extractor name. """ - cls._config = config cls._check_is_already_extracting() - extractor_class = ExtractorFactory.create_extractor(extractor_name) - background_tasks.add_task(cls.__run_extractor, extractor_class, extractor_name) + extractor = ExtractorFactory.create_extractor(extractor_name, config, dependencies) + background_tasks.add_task(cls.__run_extractor, extractor, extractor_name) message = f"'{extractor_name}' started." return message @classmethod - def __run_extractor(cls, extractor_class: Type[Extractor], extractor_name: str) -> None: + def __run_extractor(cls, extractor: Extractor, extractor_name: str) -> None: """ Run extraction process and clean after it's done. Args: - extractor_class (Type[Extractor]): Extractor that will be used for extraction. + extractor (Type[Extractor]): Extractor that will be used for extraction. extractor_name (str): The name of the extractor that will be used. """ try: cls._active_extractor = extractor_name - extractor = extractor_class( - cls._config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA - ) extractor.process() finally: cls._active_extractor = None - cls._config = None @classmethod def _check_is_already_extracting(cls) -> None: diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 4e9e50a..2f27a69 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -31,6 +31,7 @@ import numpy as np +from .dependencies import ExtractorDependencies from .schemas import ExtractorConfig from .video_processors import VideoProcessor from .image_processors import ImageProcessor @@ -41,6 +42,7 @@ class Extractor(ABC): """Abstract class for creating extractors.""" + class EmptyInputDirectoryError(Exception): """Error appear when extractor can't get any input to extraction.""" @@ -97,8 +99,8 @@ def _list_input_directory_files(self, extensions: tuple[str, ...], files = [ entry for entry in entries if entry.is_file() - and entry.suffix in extensions - and (prefix is None or not entry.name.startswith(prefix)) + and entry.suffix in extensions + and (prefix is None or not entry.name.startswith(prefix)) ] if not files: prefix = prefix if prefix else "Prefix not provided" @@ -208,22 +210,28 @@ def _signal_readiness_for_shutdown() -> None: class ExtractorFactory: """Extractor factory for getting extractors class by their names.""" + @staticmethod - def create_extractor(extractor_name: str) -> Type[Extractor]: + def create_extractor(extractor_name: str, config: ExtractorConfig, + dependencies: ExtractorDependencies) -> Extractor: """ Match extractor class by its name and return its class. Args: - extractor_name (str): Name of the extractor that class will be returned. + extractor_name (str): Name of the extractor. + config (ExtractorConfig): A Pydantic model with extractor configuration. + dependencies(ExtractorDependencies): Dependencies that will be used in extractor. Returns: Extractor: Chosen extractor class. """ match extractor_name: case "best_frames_extractor": - return BestFramesExtractor + return BestFramesExtractor(config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator) case "top_images_extractor": - return TopImagesExtractor + return TopImagesExtractor(config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator) case _: error_massage = f"Provided unknown extractor name: {extractor_name}" logger.error(error_massage) @@ -232,6 +240,7 @@ def create_extractor(extractor_name: str) -> Type[Extractor]: class BestFramesExtractor(Extractor): """Extractor for extracting best frames from videos in any input directory.""" + def process(self) -> None: """ Rate all videos in given config input directory and @@ -297,6 +306,7 @@ def _get_best_frames(self, frames: list[np.ndarray]) -> list[np.ndarray]: class TopImagesExtractor(Extractor): """Extractor for extracting images that are in top percent of images in config input directory.""" + def process(self) -> None: """ Rate all images in given config input directory and diff --git a/extractor_service/main.py b/extractor_service/main.py index 69c4a34..beaa16b 100644 --- a/extractor_service/main.py +++ b/extractor_service/main.py @@ -33,9 +33,11 @@ if os.getenv("DOCKER_ENV"): from app.schemas import ExtractorConfig, Message, ExtractorStatus from app.extractor_manager import ExtractorManager + from app.dependencies import ExtractorDependencies, get_extractor_dependencies else: from .app.schemas import ExtractorConfig, Message, ExtractorStatus from .app.extractor_manager import ExtractorManager + from .app.dependencies import ExtractorDependencies, get_extractor_dependencies logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s", @@ -58,22 +60,26 @@ def get_extractors_status() -> ExtractorStatus: @app.post("/v2/extractors/{extractor_name}") -def run_extractor(background_tasks: BackgroundTasks, extractor_name: str, - config: ExtractorConfig = ExtractorConfig()) -> Message: +def run_extractor( + extractor_name: str, + background_tasks: BackgroundTasks, + config: ExtractorConfig = ExtractorConfig(), + dependencies: ExtractorDependencies = Depends(get_extractor_dependencies) +) -> Message: """ Runs provided extractor. Args: - background_tasks (BackgroundTasks): A FastAPI tool for running tasks in background, - which allows non-blocking operation of long-running tasks. extractor_name (str): The name of the extractor that will be used. - config (ExtractorConfig): A Pydantic model with configuration - parameters for the extractor. + background_tasks (BackgroundTasks): A FastAPI tool for running tasks in background. + dependencies(ExtractorDependencies): Dependencies that will be used in extractor. + config (ExtractorConfig): A Pydantic model with extractor configuration. Returns: Message: Contains the operation status. """ - message = ExtractorManager.start_extractor(background_tasks, config, extractor_name) + message = ExtractorManager.start_extractor(extractor_name, background_tasks, + config, dependencies) return Message(message=message) diff --git a/tests/extractor_service/common.py b/tests/extractor_service/common.py index 033ef21..7cf2dd7 100644 --- a/tests/extractor_service/common.py +++ b/tests/extractor_service/common.py @@ -1,7 +1,34 @@ """Common fixtures for all conftest files.""" import pytest +from extractor_service.app.extractors import BestFramesExtractor from extractor_service.app.schemas import ExtractorConfig +from extractor_service.app.dependencies import ( + ExtractorDependencies, get_image_processor, + get_video_processor, get_evaluator +) + + +@pytest.fixture(scope="package") +def dependencies(): + image_processor = get_image_processor() + video_processor = get_video_processor() + evaluator = get_evaluator() + + return ExtractorDependencies( + image_processor=image_processor, + video_processor=video_processor, + evaluator=evaluator + ) + + +@pytest.fixture(scope="package") +def extractor(config, dependencies): + extractor = BestFramesExtractor( + config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator + ) + return extractor @pytest.fixture(scope="package") diff --git a/tests/extractor_service/integration/best_frames_extrator_test.py b/tests/extractor_service/integration/best_frames_extrator_test.py index 5c8f8b5..ae3d2ee 100644 --- a/tests/extractor_service/integration/best_frames_extrator_test.py +++ b/tests/extractor_service/integration/best_frames_extrator_test.py @@ -4,11 +4,12 @@ # @pytest.mark.skip(reason="Test time-consuming and dependent on hardware performance") -def test_best_frames_extractor(setup_best_frames_extractor_env): +def test_best_frames_extractor(setup_best_frames_extractor_env, dependencies): input_directory, output_directory, expected_video_path = setup_best_frames_extractor_env config = ExtractorConfig(input_directory=input_directory, output_directory=output_directory) - extractor = BestFramesExtractor(config) + extractor = BestFramesExtractor(config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator) extractor.process() found_best_frame_files = [ diff --git a/tests/extractor_service/integration/conftest.py b/tests/extractor_service/integration/conftest.py index 6c029e8..fc2e724 100644 --- a/tests/extractor_service/integration/conftest.py +++ b/tests/extractor_service/integration/conftest.py @@ -1,14 +1,8 @@ import pytest -from tests.extractor_service.common import config +from tests.extractor_service.common import extractor, config, dependencies from tests.common import ( files_dir, best_frames_dir, top_images_dir, setup_top_images_extractor_env, setup_best_frames_extractor_env ) from extractor_service.app.extractors import BestFramesExtractor - - -@pytest.fixture -def extractor(config): - extractor = BestFramesExtractor(config) - return extractor diff --git a/tests/extractor_service/integration/manager_and_fastapi_integration_test.py b/tests/extractor_service/integration/manager_and_fastapi_integration_test.py index 93a84b6..0b72f71 100644 --- a/tests/extractor_service/integration/manager_and_fastapi_integration_test.py +++ b/tests/extractor_service/integration/manager_and_fastapi_integration_test.py @@ -2,18 +2,16 @@ from starlette.testclient import TestClient from extractor_service.app.extractor_manager import ExtractorManager -from extractor_service.app.schemas import ExtractorConfig from extractor_service.main import app client = TestClient(app) -def test_extractor_start_and_stop(): +def test_extractor_start_and_stop(config, dependencies): extractor_name = "best_frames_extractor" background_tasks = BackgroundTasks() - config = ExtractorConfig(parameters="example_parameters") - response = ExtractorManager.start_extractor(background_tasks, config, extractor_name) + response = ExtractorManager.start_extractor(extractor_name, background_tasks, config, dependencies) assert response == f"'{extractor_name}' started." assert ExtractorManager.get_active_extractor() is None diff --git a/tests/extractor_service/integration/top_images_extractor_test.py b/tests/extractor_service/integration/top_images_extractor_test.py index 300f06e..5f0112e 100644 --- a/tests/extractor_service/integration/top_images_extractor_test.py +++ b/tests/extractor_service/integration/top_images_extractor_test.py @@ -3,11 +3,12 @@ # @pytest.mark.skip(reason="Test time-consuming and dependent on hardware performance") -def test_top_frames_extractor(setup_top_images_extractor_env): +def test_top_frames_extractor(setup_top_images_extractor_env, dependencies): input_directory, output_directory = setup_top_images_extractor_env config = ExtractorConfig(input_directory=input_directory, output_directory=output_directory) - selector = TopImagesExtractor(config) + selector = TopImagesExtractor(config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator) selector.process() found_top_frame_files = [ diff --git a/tests/extractor_service/unit/conftest.py b/tests/extractor_service/unit/conftest.py index b82c130..4cecfdf 100644 --- a/tests/extractor_service/unit/conftest.py +++ b/tests/extractor_service/unit/conftest.py @@ -2,13 +2,5 @@ from extractor_service.app.extractors import BestFramesExtractor from extractor_service.app.schemas import ExtractorConfig -from tests.extractor_service.common import config +from tests.extractor_service.common import extractor, config, dependencies from tests.common import files_dir, best_frames_dir - - -@pytest.fixture(scope="function") -def extractor(config): - extractor = BestFramesExtractor( - config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA - ) - return extractor diff --git a/tests/extractor_service/unit/extractor_manager_test.py b/tests/extractor_service/unit/extractor_manager_test.py index 8762b76..d2d0449 100644 --- a/tests/extractor_service/unit/extractor_manager_test.py +++ b/tests/extractor_service/unit/extractor_manager_test.py @@ -3,6 +3,7 @@ import pytest from fastapi import HTTPException, BackgroundTasks + from extractor_service.app.extractor_manager import ExtractorManager from extractor_service.app.extractors import ExtractorFactory @@ -13,19 +14,19 @@ def test_get_active_extractor(): @patch.object(ExtractorFactory, "create_extractor") @patch.object(ExtractorManager, "_check_is_already_extracting") -def test_start_extractor(mock_checking, mock_create_extractor, config): +def test_start_extractor(mock_checking, mock_create_extractor, config, dependencies): extractor_name = "some_extractor" - mock_extractor_class = MagicMock() + mock_extractor = MagicMock() mock_background_tasks = MagicMock(spec=BackgroundTasks) - mock_create_extractor.return_value = mock_extractor_class + mock_create_extractor.return_value = mock_extractor - message = ExtractorManager.start_extractor(mock_background_tasks, config, extractor_name) + message = ExtractorManager.start_extractor(extractor_name, mock_background_tasks, config, dependencies) mock_checking.assert_called_once() - mock_create_extractor.assert_called_once_with(extractor_name) + mock_create_extractor.assert_called_once_with(extractor_name, config, dependencies) mock_background_tasks.add_task.assert_called_once_with( ExtractorManager._ExtractorManager__run_extractor, - mock_extractor_class, + mock_extractor, extractor_name ) expected_message = f"'{extractor_name}' started." @@ -35,12 +36,10 @@ def test_start_extractor(mock_checking, mock_create_extractor, config): @patch("extractor_service.app.extractors.BestFramesExtractor") def test_run_extractor(mock_extractor): extractor_name = "some_extractor" - mock_extractor.return_value.process = MagicMock() - mock_extractor.__name__ = MagicMock() ExtractorManager._ExtractorManager__run_extractor(mock_extractor, extractor_name) - mock_extractor.assert_called_once() + mock_extractor.process.assert_called_once() def test_check_is_already_evaluating_true(): diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index 72650f0..bae7d6b 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -161,17 +161,21 @@ def test_signal_readiness_for_shutdown(extractor, caplog): assert "Service ready for shutdown" in caplog.text -def test_create_extractor_known_extractors(): - assert ExtractorFactory.create_extractor("best_frames_extractor") is BestFramesExtractor - assert ExtractorFactory.create_extractor("top_images_extractor") is TopImagesExtractor +@pytest.mark.parametrize("extractor_name, extractor", ( + ("best_frames_extractor", BestFramesExtractor), + ("top_images_extractor", TopImagesExtractor) +)) +def test_create_extractor_known_extractors(extractor_name, extractor, config, dependencies): + extractor_instance = ExtractorFactory.create_extractor(extractor_name, config, dependencies) + assert isinstance(extractor_instance, extractor) -def test_create_extractor_unknown_extractor_raises(caplog): +def test_create_extractor_unknown_extractor_raises(caplog, config, dependencies): unknown_extractor_name = "unknown_extractor" expected_massage = f"Provided unknown extractor name: {unknown_extractor_name}" with pytest.raises(ValueError, match=expected_massage), \ caplog.at_level(logging.ERROR): - ExtractorFactory.create_extractor(unknown_extractor_name) + ExtractorFactory.create_extractor(unknown_extractor_name, config, dependencies) assert expected_massage in caplog.text From fd048e81e662fa2580b0e614d651c7f54aae2f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 18:45:11 +0200 Subject: [PATCH 26/52] add tests for dependencies.py --- extractor_service/app/dependencies.py | 37 +++++++++++++++++++ .../unit/dependencies_test.py | 32 ++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/extractor_service/unit/dependencies_test.py diff --git a/extractor_service/app/dependencies.py b/extractor_service/app/dependencies.py index d151a29..84344c0 100644 --- a/extractor_service/app/dependencies.py +++ b/extractor_service/app/dependencies.py @@ -10,20 +10,46 @@ @dataclass class ExtractorDependencies: + """ + Data class to hold dependencies for the extractor. + + Attributes: + image_processor (Type[OpenCVImage]): Processor for image processing. + video_processor (Type[OpenCVVideo]): Processor for video processing. + evaluator (Type[InceptionResNetNIMA]): Evaluator for image quality. + """ image_processor: Type[OpenCVImage] video_processor: Type[OpenCVVideo] evaluator: Type[InceptionResNetNIMA] def get_image_processor() -> Type[OpenCVImage]: + """ + Provides the image processor dependency. + + Returns: + Type[OpenCVImage]: The image processor class. + """ return OpenCVImage def get_video_processor() -> Type[OpenCVVideo]: + """ + Provides the video processor dependency. + + Returns: + Type[OpenCVVideo]: The video processor class. + """ return OpenCVVideo def get_evaluator() -> Type[InceptionResNetNIMA]: + """ + Provides the image evaluator dependency. + + Returns: + Type[InceptionResNetNIMA]: The image evaluator class. + """ return InceptionResNetNIMA @@ -32,6 +58,17 @@ def get_extractor_dependencies( video_processor=Depends(get_video_processor), evaluator=Depends(get_evaluator) ) -> ExtractorDependencies: + """ + Provides the dependencies required for the extractor. + + Args: + image_processor (Type[OpenCVImage], optional): Dependency injection for image processor. + video_processor (Type[OpenCVVideo], optional): Dependency injection for video processor. + evaluator (Type[InceptionResNetNIMA], optional): Dependency injection for image evaluator. + + Returns: + ExtractorDependencies: All necessary dependencies for the extractor. + """ return ExtractorDependencies( image_processor=image_processor, video_processor=video_processor, diff --git a/tests/extractor_service/unit/dependencies_test.py b/tests/extractor_service/unit/dependencies_test.py new file mode 100644 index 0000000..77f3f4b --- /dev/null +++ b/tests/extractor_service/unit/dependencies_test.py @@ -0,0 +1,32 @@ +from extractor_service.app.image_processors import OpenCVImage +from extractor_service.app.video_processors import OpenCVVideo +from extractor_service.app.image_evaluators import InceptionResNetNIMA +from extractor_service.app.dependencies import ( + get_image_processor, get_video_processor, + get_evaluator, get_extractor_dependencies, ExtractorDependencies +) + + +def test_get_image_processor(): + assert get_image_processor() == OpenCVImage + + +def test_get_video_processor(): + assert get_video_processor() == OpenCVVideo + + +def test_get_evaluator(): + assert get_evaluator() == InceptionResNetNIMA + + +def test_get_extractor_dependencies(): + dependencies = get_extractor_dependencies( + image_processor=get_image_processor(), + video_processor=get_video_processor(), + evaluator=get_evaluator() + ) + + assert isinstance(dependencies, ExtractorDependencies) + assert dependencies.image_processor == OpenCVImage + assert dependencies.video_processor == OpenCVVideo + assert dependencies.evaluator == InceptionResNetNIMA From d95c03a6b2f7d227f079798547cda609e5a5b81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 20:46:22 +0200 Subject: [PATCH 27/52] small changes in docstings, small refactor code changes --- extractor_service/app/dependencies.py | 19 ++++++++ extractor_service/app/extractor_manager.py | 3 +- extractor_service/app/extractors.py | 6 +-- extractor_service/app/image_evaluators.py | 24 ++++++---- extractor_service/app/schemas.py | 10 ++-- extractor_service/app/video_processors.py | 17 +++++-- service_manager/docker_manager.py | 45 +++++++++++++----- .../unit/nima_models_test.py | 9 ++-- .../unit/docker_manager_test.py | 6 +-- .../frames_extracted_test_video.mp4 | Bin 0 -> 35238 bytes 10 files changed, 95 insertions(+), 44 deletions(-) create mode 100644 tests/test_files/frames_extracted_test_video.mp4 diff --git a/extractor_service/app/dependencies.py b/extractor_service/app/dependencies.py index 84344c0..74c7605 100644 --- a/extractor_service/app/dependencies.py +++ b/extractor_service/app/dependencies.py @@ -1,3 +1,22 @@ +""" +This module provides dependency management for extractors using FastAPI's dependency injection. +LICENSE +======= +Copyright (C) 2024 Bartłomiej Flis + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" from dataclasses import dataclass from typing import Type diff --git a/extractor_service/app/extractor_manager.py b/extractor_service/app/extractor_manager.py index 6cfe32d..da0de85 100644 --- a/extractor_service/app/extractor_manager.py +++ b/extractor_service/app/extractor_manager.py @@ -19,7 +19,6 @@ along with this program. If not, see . """ import logging -from typing import Type from fastapi import HTTPException, BackgroundTasks @@ -74,7 +73,7 @@ def __run_extractor(cls, extractor: Extractor, extractor_name: str) -> None: Run extraction process and clean after it's done. Args: - extractor (Type[Extractor]): Extractor that will be used for extraction. + extractor (Extractor): Extractor that will be used for extraction. extractor_name (str): The name of the extractor that will be used. """ try: diff --git a/extractor_service/app/extractors.py b/extractor_service/app/extractors.py index 2f27a69..4919fd0 100644 --- a/extractor_service/app/extractors.py +++ b/extractor_service/app/extractors.py @@ -112,7 +112,7 @@ def _list_input_directory_files(self, extensions: tuple[str, ...], ) logger.error(error_massage) raise self.EmptyInputDirectoryError(error_massage) - logger.info(f"Directory '%s' files listed.", str(directory)) + logger.info("Directory '%s' files listed.", str(directory)) logger.debug("Listed file paths: %s", files) return files @@ -121,7 +121,7 @@ def _evaluate_images(self, normalized_images: np.ndarray) -> np.array: Rating all images in provided images batch using already initialized image evaluator. Args: - normalized_images (list[np.ndarray]): Already normalized images np.ndarray for evaluating. + normalized_images (list[np.ndarray]): Already normalized images for evaluating. Returns: np.array: Array with images scores in given images order. @@ -305,7 +305,7 @@ def _get_best_frames(self, frames: list[np.ndarray]) -> list[np.ndarray]: class TopImagesExtractor(Extractor): - """Extractor for extracting images that are in top percent of images in config input directory.""" + """Images extractor for extracting top percent of images in config input directory.""" def process(self) -> None: """ diff --git a/extractor_service/app/image_evaluators.py b/extractor_service/app/image_evaluators.py index 2ae2543..1a917a9 100644 --- a/extractor_service/app/image_evaluators.py +++ b/extractor_service/app/image_evaluators.py @@ -26,9 +26,9 @@ import requests import numpy as np from tensorflow import convert_to_tensor -from tensorflow.keras.models import Model +from tensorflow.keras import Model from tensorflow.keras.layers import Dense, Dropout -from tensorflow.keras.applications.inception_resnet_v2 import InceptionResNetV2 +import tensorflow as tf from .schemas import ExtractorConfig @@ -189,35 +189,39 @@ def _get_model_weights(cls) -> Path: Path: Path to the model weights. """ model_weights_directory = cls._config.weights_directory - logger.info("Searching for model weights in weights directory: %s", model_weights_directory) + logger.info("Searching for model weights in weights directory: %s", + model_weights_directory) model_weights_path = Path(model_weights_directory) / cls._config.weights_filename if not model_weights_path.is_file(): - logger.debug("Can't find model weights in weights directory: %s", model_weights_directory) + logger.debug("Can't find model weights in weights directory: %s", + model_weights_directory) cls._download_model_weights(model_weights_path) else: - logger.debug(f"Model weights loaded from: {model_weights_path}") + logger.debug("Model weights loaded from: %s", model_weights_path) return model_weights_path @classmethod - def _download_model_weights(cls, weights_path: Path) -> None: + def _download_model_weights(cls, weights_path: Path, timeout: int = 10) -> None: """ Download the model weights from the specified URL. Args: weights_path (Path): Path to save the downloaded weights. + timeout (int): Timeout for the request in seconds. Raises: cls.DownloadingModelWeightsError: If there's an issue downloading the weights. """ url = f"{cls._config.weights_repo_url}{cls._config.weights_filename}" logger.debug("Downloading model weights from ulr: %s", url) - response = requests.get(url, allow_redirects=True) + response = requests.get(url, allow_redirects=True, timeout=timeout) if response.status_code == 200: weights_path.parent.mkdir(parents=True, exist_ok=True) weights_path.write_bytes(response.content) - logger.debug(f"Model weights downloaded and saved to %s", weights_path) + logger.debug("Model weights downloaded and saved to %s", weights_path) else: - error_message = f"Failed to download the weights: HTTP status code {response.status_code}" + error_message = (f"Failed to download the weights: HTTP status code " + f"{response.status_code}") logger.error(error_message) raise cls.DownloadingModelWeightsError(error_message) @@ -251,7 +255,7 @@ def _create_model(cls, model_weights_path: Path) -> Model: Returns: Model: NIMA model instance. """ - base_model = InceptionResNetV2( + base_model = tf.keras.applications.InceptionResNetV2( input_shape=cls._input_shape, include_top=False, pooling="avg", weights=None ) diff --git a/extractor_service/app/schemas.py b/extractor_service/app/schemas.py index 9460969..df2f484 100644 --- a/extractor_service/app/schemas.py +++ b/extractor_service/app/schemas.py @@ -3,7 +3,7 @@ Models: - ExtractorConfig: Model containing the extractors configuration parameters. - Message: Model for encapsulating messages returned by the application. - - ExtractorStatus: Model representing the status of the currently working extractor in the system. + - ExtractorStatus: Model representing the status of the working extractor in the system. LICENSE ======= Copyright (C) 2024 Bartłomiej Flis @@ -36,14 +36,14 @@ class ExtractorConfig(BaseModel): Attributes: input_directory (DirectoryPath): Input directory path containing entries for extraction. By default, it sets value for docker container volume. - output_directory (DirectoryPath): Output directory path where extraction results will be saved. + output_directory (DirectoryPath): Output directory path for extraction results. By default, it sets value for docker container volume. video_extensions (tuple[str]): Supported videos' extensions in service for reading videos. images_extensions (tuple[str]): Supported images' extensions in service for reading images. - processed_video_prefix (str): Prefix that will be added to processed video filename after extraction. + processed_video_prefix (str): Prefix will be added to processed video after extraction. batch_size (int): Maximum number of images processed in a single batch. - compering_group_size (int): Maximum number of images in a group to compare for finding the best one. - top_images_percent (float): Percentage threshold to determine the top images based on scores. + compering_group_size (int): Images group number to compare for finding the best one. + top_images_percent (float): Percentage threshold to determine the top images. images_output_format (str): Format for saving output images, e.g., '.jpg', '.png'. target_image_size (tuple[int, int]): Images will be normalized to this size. weights_directory (Path | str): Directory path where model weights are stored. diff --git a/extractor_service/app/video_processors.py b/extractor_service/app/video_processors.py index d270d2b..ee48bfa 100644 --- a/extractor_service/app/video_processors.py +++ b/extractor_service/app/video_processors.py @@ -33,8 +33,10 @@ class VideoProcessor(ABC): """Abstract class for creating video processors used for managing video operations.""" + @classmethod @abstractmethod - def get_next_frames(self, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: + def get_next_frames(cls, video_path: Path, + batch_size: int) -> Generator[list[np.ndarray], None, None]: """ Abstract generator method to generate batches of frames from a video file. @@ -51,6 +53,7 @@ def get_next_frames(self, video_path: Path, batch_size: int) -> Generator[list[n class OpenCVVideo(VideoProcessor): + """Video processor based on OpenCV with FFMPEG extension.""" class CantOpenVideoCapture(Exception): """Exception raised when the video file cannot be opened.""" @@ -84,7 +87,8 @@ def _video_capture(video_path: Path) -> cv2.VideoCapture: video_cap.release() @classmethod - def get_next_frames(cls, video_path: Path, batch_size: int) -> Generator[list[np.ndarray], None, None]: + def get_next_frames(cls, video_path: Path, + batch_size: int) -> Generator[list[np.ndarray], None, None]: """ Generates batches of frames from the specified video using OpenCV. @@ -99,8 +103,10 @@ def get_next_frames(cls, video_path: Path, batch_size: int) -> Generator[list[np list[np.ndarray]: A batch of video frames. """ with cls._video_capture(video_path) as video: - frame_rate = cls._get_video_attribute(video, cv2.CAP_PROP_FPS, "frame rate") - total_frames = cls._get_video_attribute(video, cv2.CAP_PROP_FRAME_COUNT, "total frames") + frame_rate = cls._get_video_attribute( + video, cv2.CAP_PROP_FPS, "frame rate") + total_frames = cls._get_video_attribute( + video, cv2.CAP_PROP_FRAME_COUNT, "total frames") frames_batch = [] logger.info("Getting frames batch...") for frame_index in range(0, total_frames, frame_rate): @@ -136,7 +142,8 @@ def _read_next_frame(cls, video: cv2.VideoCapture, frame_index: int) -> np.ndarr return frame @classmethod - def _get_video_attribute(cls, video: cv2.VideoCapture, attribute_id: int, display_name: str) -> int: + def _get_video_attribute(cls, video: cv2.VideoCapture, + attribute_id: int, display_name: str) -> int: """ Retrieves a specified attribute value from the video capture object and validates it. diff --git a/service_manager/docker_manager.py b/service_manager/docker_manager.py index beb66d8..379465c 100644 --- a/service_manager/docker_manager.py +++ b/service_manager/docker_manager.py @@ -25,6 +25,7 @@ import subprocess import sys import logging +from typing import Optional logger = logging.getLogger(__name__) @@ -59,6 +60,12 @@ def __init__(self, container_name: str, input_dir: str, @property def image_name(self): + """ + Returns the name of the image. + + Returns: + str: The name of the image. + """ return self._image_name def __log_input(self) -> None: @@ -73,16 +80,27 @@ def __log_input(self) -> None: @property def docker_image_existence(self) -> bool: + """ + Checks if the Docker image exists. + + This property calls a method that checks for the existence of the Docker + image associated with this instance. + + Returns: + bool: True if the Docker image exists, False otherwise. + """ return self._check_image_exists() def _check_image_exists(self) -> bool: - """Checks whether the Docker image already exists in the system. + """ + Checks whether the Docker image already exists in the system. Returns: bool: True if the image exists, False otherwise. """ command = ["docker", "images", "-q", self._image_name] - process_output = subprocess.run(command, capture_output=True, text=True).stdout.strip() + process_output = subprocess.run(command, capture_output=True, + text=True, check=True).stdout.strip() is_exists = process_output != "" return is_exists @@ -96,7 +114,7 @@ def build_image(self, dockerfile_path: str) -> None: if not self.docker_image_existence or self._force_build: logging.info("Building Docker image...") command = ["docker", "build", "-t", self._image_name, dockerfile_path] - subprocess.run(command) + subprocess.run(command, check=True) else: logger.info("Image is already created. Using existing one.") @@ -110,7 +128,7 @@ def container_status(self) -> str: """ return self._check_container_status() - def _check_container_status(self) -> str: + def _check_container_status(self) -> Optional[str]: """ Check the status of the container. @@ -118,9 +136,10 @@ def _check_container_status(self) -> str: str: The status of the container. """ command = ["docker", "inspect", "--format='{{.State.Status}}'", self._container_name] - result = subprocess.run(command, capture_output=True, text=True) + result = subprocess.run(command, capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout.strip().replace("'", "") + return None def deploy_container(self, container_port: int, container_input_directory: str, container_output_directory: str) -> None: @@ -134,19 +153,21 @@ def deploy_container(self, container_port: int, container_input_directory: str, status = self.container_status if status is None: logging.info("No existing container found. Running a new container.") - self._run_container(container_port, container_input_directory, container_output_directory) + self._run_container(container_port, container_input_directory, + container_output_directory) elif self._force_build: logging.info("Force rebuild initiated.") if status in ["running", "paused"]: self._stop_container() self._delete_container() - self._run_container(container_port, container_input_directory, container_output_directory) + self._run_container(container_port, container_input_directory, + container_output_directory) elif status in ["exited", "created"]: self._start_container() elif status == "running": - logging.info(f"Container is already running.") + logging.info("Container is already running.") else: - logging.warning(f"Container in unsupported status: %s. Fix container on your own.", + logging.warning("Container in unsupported status: %s. Fix container on your own.", status) def _start_container(self) -> None: @@ -199,7 +220,7 @@ def _run_log_process(self) -> subprocess.Popen: Returns: subprocess.Popen: The process object for the log following command. """ - logger.info(f"Following logs for {self._container_name}...") + logger.info("Following logs for %s...", self._container_name) command = ["docker", "logs", "-f", "--since", "1s", self._container_name] process = subprocess.Popen( command, stdout=subprocess.PIPE, @@ -220,14 +241,14 @@ def __stop_log_process(self, process: subprocess.Popen) -> None: def _stop_container(self) -> None: """Stops the running Docker container.""" - logger.info(f"Stopping container %s...", self._container_name) + logger.info("Stopping container %s...", self._container_name) command = ["docker", "stop", self._container_name] subprocess.run(command, check=True, capture_output=True) logger.info("Container stopped.") def _delete_container(self) -> None: """Deletes the Docker container.""" - logger.info(f"Deleting container %s...", self._container_name) + logger.info("Deleting container %s...", self._container_name) command = ["docker", "rm", self._container_name] subprocess.run(command, check=True, capture_output=True) logger.info("Container deleted.") diff --git a/tests/extractor_service/unit/nima_models_test.py b/tests/extractor_service/unit/nima_models_test.py index 5d1106e..665505b 100644 --- a/tests/extractor_service/unit/nima_models_test.py +++ b/tests/extractor_service/unit/nima_models_test.py @@ -21,7 +21,7 @@ def test_get_prediction_weights(): assert result is _ResNetModel._prediction_weights -@patch("extractor_service.app.image_evaluators.InceptionResNetV2") +@patch("extractor_service.app.image_evaluators.tf.keras.applications.InceptionResNetV2") @patch("extractor_service.app.image_evaluators.Dropout") @patch("extractor_service.app.image_evaluators.Dense") @patch("extractor_service.app.image_evaluators.Model") @@ -140,6 +140,7 @@ def test_download_model_weights_success(mock_mkdir, mock_get, mock_write_bytes, test_path = Path("/fake/path/to/weights.h5") _ResNetModel._config = MagicMock(weights_repo_url="http://example.com/", weights_filename="weights.h5") weights_data = b"weights data" + timeout = 12 mock_response = MagicMock() mock_response.status_code = status_code @@ -148,7 +149,7 @@ def test_download_model_weights_success(mock_mkdir, mock_get, mock_write_bytes, if status_code == 200: with caplog.at_level(logging.DEBUG): - _ResNetModel._download_model_weights(test_path) + _ResNetModel._download_model_weights(test_path, timeout) mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) mock_write_bytes.assert_called_once_with(weights_data) assert f"Model weights downloaded and saved to {test_path}" in caplog.text @@ -156,7 +157,7 @@ def test_download_model_weights_success(mock_mkdir, mock_get, mock_write_bytes, error_message = f"Failed to download the weights: HTTP status code {status_code}" with caplog.at_level(logging.DEBUG), \ pytest.raises(_ResNetModel.DownloadingModelWeightsError, match=error_message): - _ResNetModel._download_model_weights(test_path) + _ResNetModel._download_model_weights(test_path, timeout) assert "Failed to download the weights: HTTP status code 404" in caplog.text assert f"Downloading model weights from ulr: {test_url}" in caplog.text - mock_get.assert_called_once_with(test_url, allow_redirects=True) + mock_get.assert_called_once_with(test_url, allow_redirects=True, timeout=timeout) diff --git a/tests/service_manager/unit/docker_manager_test.py b/tests/service_manager/unit/docker_manager_test.py index 4fdf798..e84b846 100644 --- a/tests/service_manager/unit/docker_manager_test.py +++ b/tests/service_manager/unit/docker_manager_test.py @@ -60,7 +60,7 @@ def test_check_image_exists(mock_image, is_exists, docker, mock_run): mock_run.return_value = MagicMock(stdout=mock_image) assert docker.docker_image_existence is is_exists - mock_run.assert_called_with(expected_command, capture_output=True, text=True) + mock_run.assert_called_with(expected_command, capture_output=True, text=True, check=True) @patch.object(DockerManager, "_check_image_exists") @@ -70,7 +70,7 @@ def test_build_image(mock_check_image_exists, docker, mock_run, caplog, config): docker.build_image(config.dockerfile) - mock_run.assert_called_once_with(expected_command) + mock_run.assert_called_once_with(expected_command, check=True) @patch.object(DockerManager, "_check_image_exists") @@ -109,7 +109,7 @@ def test_container_status(code, output, status, docker, mock_run): status = docker.container_status - mock_run.assert_called_once_with(expected_command, capture_output=True, text=True) + mock_run.assert_called_once_with(expected_command, capture_output=True, text=True, check=False) assert status == status diff --git a/tests/test_files/frames_extracted_test_video.mp4 b/tests/test_files/frames_extracted_test_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fd4a35d73a054535ea2a4128abd49b7b00261a65 GIT binary patch literal 35238 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 Date: Tue, 28 May 2024 20:50:08 +0200 Subject: [PATCH 28/52] change logging level to INFO --- extractor_service/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extractor_service/main.py b/extractor_service/main.py index beaa16b..a3ec1b6 100644 --- a/extractor_service/main.py +++ b/extractor_service/main.py @@ -39,7 +39,7 @@ from .app.extractor_manager import ExtractorManager from .app.dependencies import ExtractorDependencies, get_extractor_dependencies -logging.basicConfig(level=logging.DEBUG, +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[logging.StreamHandler(sys.stdout)]) From 6b575b494bc3e6641c1833802b2515b9ae8f7874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 22:06:48 +0200 Subject: [PATCH 29/52] change cpu_only to True in integration tests in service_manager --- tests/service_manager/integration/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service_manager/integration/conftest.py b/tests/service_manager/integration/conftest.py index 597cd64..eab8e5a 100644 --- a/tests/service_manager/integration/conftest.py +++ b/tests/service_manager/integration/conftest.py @@ -16,7 +16,7 @@ def manager(config): manager = DockerManager( config.service_name, config.input_directory, config.output_directory, config.port, - False, False + False, True ) return manager From b060c933553185b96ff533aa2bf8b8fe3caba407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 23:03:36 +0200 Subject: [PATCH 30/52] add --cpu flag to service_manager e2e tests --- .../integration/extractor_and_evaluator_integration_test.py | 2 +- tests/service_manager/e2e/best_frames_extractor_test.py | 3 ++- tests/service_manager/e2e/top_images_extractor_test.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py b/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py index 6bab8e7..d0d5542 100644 --- a/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py +++ b/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py @@ -1,6 +1,6 @@ import numpy as np import pytest -from tensorflow.keras.models import Model +from tensorflow.keras import Model from extractor_service.app.image_evaluators import InceptionResNetNIMA diff --git a/tests/service_manager/e2e/best_frames_extractor_test.py b/tests/service_manager/e2e/best_frames_extractor_test.py index 9c6fa6e..f82496c 100644 --- a/tests/service_manager/e2e/best_frames_extractor_test.py +++ b/tests/service_manager/e2e/best_frames_extractor_test.py @@ -8,7 +8,8 @@ def test_best_frames_extractor(setup_best_frames_extractor_env, start_script_pat sys.executable, str(start_script_path), "best_frames_extractor", "--input_dir", str(input_directory), "--output_dir", str(output_directory), - "--build" + "--build", + "--cpu" ] subprocess.run(command) diff --git a/tests/service_manager/e2e/top_images_extractor_test.py b/tests/service_manager/e2e/top_images_extractor_test.py index 79a10b9..bc8e36c 100644 --- a/tests/service_manager/e2e/top_images_extractor_test.py +++ b/tests/service_manager/e2e/top_images_extractor_test.py @@ -8,7 +8,8 @@ def test_top_images_extractor(setup_top_images_extractor_env, start_script_path) sys.executable, str(start_script_path), "top_images_extractor", "--input_dir", input_directory, "--output_dir", output_directory, - "--build" + "--build", + "--cpu" ] subprocess.run(command) From af04c1d216122500146621ddf22688d3ae246f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Tue, 28 May 2024 23:52:59 +0200 Subject: [PATCH 31/52] add .gitkeep to test_files --- .gitignore | 4 +++- .../extractor_service/unit/extractor_test.py | 19 +++++++----------- .../frames_extracted_test_video.mp4 | Bin 35238 -> 0 bytes 3 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 tests/test_files/frames_extracted_test_video.mp4 diff --git a/.gitignore b/.gitignore index f6ea5df..e7eb738 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ output_directory/* test_video.mp4 nima.h5 tests/test_files/best_frames/* -tests/test_files/top_images/* \ No newline at end of file +tests/test_files/top_images/* +!tests/test_files/best_frames/.gitkeep +!tests/test_files/top_images/.gitkeep \ No newline at end of file diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index bae7d6b..4db9a74 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -13,21 +13,16 @@ TopImagesExtractor) -def test_extractor_initialization(config): - extractor = BestFramesExtractor(config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA) +def test_extractor_initialization(config, dependencies): + extractor = BestFramesExtractor( + config, dependencies.image_processor, + dependencies.video_processor, dependencies.evaluator + ) assert extractor is not None assert extractor._config == config assert extractor._image_evaluator is None -@pytest.fixture(scope="function") -def extractor(config): - extractor = BestFramesExtractor( - config, OpenCVImage, OpenCVVideo, InceptionResNetNIMA - ) - return extractor - - def test_get_image_evaluator(extractor, config): expected = "value" mock_class = MagicMock(return_value=expected) @@ -162,8 +157,8 @@ def test_signal_readiness_for_shutdown(extractor, caplog): @pytest.mark.parametrize("extractor_name, extractor", ( - ("best_frames_extractor", BestFramesExtractor), - ("top_images_extractor", TopImagesExtractor) + ("best_frames_extractor", BestFramesExtractor), + ("top_images_extractor", TopImagesExtractor) )) def test_create_extractor_known_extractors(extractor_name, extractor, config, dependencies): extractor_instance = ExtractorFactory.create_extractor(extractor_name, config, dependencies) diff --git a/tests/test_files/frames_extracted_test_video.mp4 b/tests/test_files/frames_extracted_test_video.mp4 deleted file mode 100644 index fd4a35d73a054535ea2a4128abd49b7b00261a65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35238 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 Date: Tue, 28 May 2024 23:55:47 +0200 Subject: [PATCH 32/52] fix test_video --- .gitignore | 1 - tests/test_files/best_frames/.gitkeep | 0 tests/test_files/test_video.mp4 | Bin 0 -> 35238 bytes tests/test_files/top_images/.gitkeep | 0 4 files changed, 1 deletion(-) create mode 100644 tests/test_files/best_frames/.gitkeep create mode 100644 tests/test_files/test_video.mp4 create mode 100644 tests/test_files/top_images/.gitkeep diff --git a/.gitignore b/.gitignore index e7eb738..bcae2c1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ input_directory/* output_directory/* !input_directory/.gitkeep !output_directory/.gitkeep -test_video.mp4 nima.h5 tests/test_files/best_frames/* tests/test_files/top_images/* diff --git a/tests/test_files/best_frames/.gitkeep b/tests/test_files/best_frames/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_files/test_video.mp4 b/tests/test_files/test_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fd4a35d73a054535ea2a4128abd49b7b00261a65 GIT binary patch literal 35238 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 Date: Wed, 29 May 2024 00:00:08 +0200 Subject: [PATCH 33/52] fix paths in test_add_prefix --- tests/extractor_service/unit/extractor_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index 4db9a74..fb7f447 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -137,8 +137,8 @@ def test_list_input_directory_files_no_files_found(mock_iterdir, extractor, capl def test_add_prefix(extractor, caplog): test_prefix = "prefix_" - test_path = Path("test_path\\file.mp4") - test_new_path = Path("test_path\\prefix_file.mp4") + test_path = Path("test_path/file.mp4") + test_new_path = Path("test_path/prefix_file.mp4") expected_massage = f"Prefix '{test_prefix}' added to file '{test_path}'. New path: {test_new_path}" with patch("pathlib.Path.rename") as mock_rename, \ From 2af63808e7f07239435e2264f9f1cb621bb66323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= <97804977+BKDDFS@users.noreply.github.com> Date: Wed, 29 May 2024 00:25:01 +0200 Subject: [PATCH 34/52] Add local server to run_tests.yml --- .github/workflows/run_tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index cf77cf9..821afd7 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -45,6 +45,11 @@ jobs: - name: Install dependencies run: | poetry install + + - name: Start local server + run: | + nohup python -m http.server 8000 & + shell: bash - name: Run tests env: From 5b0790ea0648e1d9a33440e3b0bea7bcdc14f4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 00:39:33 +0200 Subject: [PATCH 35/52] add skipif in service_manager e2e tests --- tests/common.py | 3 --- tests/service_manager/e2e/best_frames_extractor_test.py | 3 +++ tests/service_manager/e2e/top_images_extractor_test.py | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/common.py b/tests/common.py index 875abc2..167d338 100644 --- a/tests/common.py +++ b/tests/common.py @@ -24,9 +24,6 @@ def top_images_dir(files_dir): def setup_top_images_extractor_env(files_dir, top_images_dir) -> tuple[Path, Path]: assert files_dir.is_dir() - # found_files = [file for file in files_dir.iterdir() if file.suffix == ".jpg"] - # assert len(found_files) > 0, "No JPG files found in test directory" - if top_images_dir.is_dir(): shutil.rmtree(top_images_dir) assert not top_images_dir.is_dir(), "Output directory was not removed" diff --git a/tests/service_manager/e2e/best_frames_extractor_test.py b/tests/service_manager/e2e/best_frames_extractor_test.py index f82496c..fad7f05 100644 --- a/tests/service_manager/e2e/best_frames_extractor_test.py +++ b/tests/service_manager/e2e/best_frames_extractor_test.py @@ -1,7 +1,10 @@ import subprocess import sys +import pytest +import os +@pytest.mark.skipif("CI" in os.environ, reason="Test skipped in GitHub Actions.") def test_best_frames_extractor(setup_best_frames_extractor_env, start_script_path): input_directory, output_directory, expected_video_path = setup_best_frames_extractor_env command = [ diff --git a/tests/service_manager/e2e/top_images_extractor_test.py b/tests/service_manager/e2e/top_images_extractor_test.py index bc8e36c..af515ec 100644 --- a/tests/service_manager/e2e/top_images_extractor_test.py +++ b/tests/service_manager/e2e/top_images_extractor_test.py @@ -1,7 +1,10 @@ import subprocess import sys +import pytest +import os +@pytest.mark.skipif("CI" in os.environ, reason="Test skipped in GitHub Actions.") def test_top_images_extractor(setup_top_images_extractor_env, start_script_path): input_directory, output_directory = setup_top_images_extractor_env command = [ From 9705389aad2784f15460be850ed48031c1b267bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 00:41:15 +0200 Subject: [PATCH 36/52] remove additional server in github actions --- .github/workflows/run_tests.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 821afd7..cf77cf9 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -45,11 +45,6 @@ jobs: - name: Install dependencies run: | poetry install - - - name: Start local server - run: | - nohup python -m http.server 8000 & - shell: bash - name: Run tests env: From 0812ff5b8a7a1145add4c62471ef614d9bd747be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:36:36 +0200 Subject: [PATCH 37/52] change http to https --- tests/extractor_service/unit/nima_models_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/extractor_service/unit/nima_models_test.py b/tests/extractor_service/unit/nima_models_test.py index 665505b..93237f7 100644 --- a/tests/extractor_service/unit/nima_models_test.py +++ b/tests/extractor_service/unit/nima_models_test.py @@ -136,9 +136,9 @@ def test_get_model_weights(mock_download, mock_is_file, file_exists, caplog): @patch("extractor_service.app.image_evaluators.requests.get") @patch.object(Path, "mkdir") def test_download_model_weights_success(mock_mkdir, mock_get, mock_write_bytes, status_code, caplog): - test_url = "http://example.com/weights.h5" + test_url = "https://example.com/weights.h5" test_path = Path("/fake/path/to/weights.h5") - _ResNetModel._config = MagicMock(weights_repo_url="http://example.com/", weights_filename="weights.h5") + _ResNetModel._config = MagicMock(weights_repo_url="https://example.com/", weights_filename="weights.h5") weights_data = b"weights data" timeout = 12 From a6585897613b638f368620b992ad8babbf320215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:37:29 +0200 Subject: [PATCH 38/52] add assert before isinstance() --- .../integration/extractor_and_evaluator_integration_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py b/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py index d0d5542..3b8b5c1 100644 --- a/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py +++ b/tests/extractor_service/integration/extractor_and_evaluator_integration_test.py @@ -14,8 +14,8 @@ def test_get_image_evaluator_download_weights_and_create_model(extractor, config evaluator = extractor._get_image_evaluator() - isinstance(evaluator, InceptionResNetNIMA) - isinstance(evaluator._model, Model) + assert isinstance(evaluator, InceptionResNetNIMA) + assert isinstance(evaluator._model, Model) assert weights_path.exists() From 4d11a160dfc51acb9484b66dad62de0fff0d5a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:39:28 +0200 Subject: [PATCH 39/52] change 'is not None' to just 'assert extractor' --- tests/extractor_service/unit/extractor_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index fb7f447..d1d0027 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -18,7 +18,7 @@ def test_extractor_initialization(config, dependencies): config, dependencies.image_processor, dependencies.video_processor, dependencies.evaluator ) - assert extractor is not None + assert extractor assert extractor._config == config assert extractor._image_evaluator is None From 14d5021b5d7c4293ea588de19587fe4b7f49c1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:40:32 +0200 Subject: [PATCH 40/52] fix logging check in test_list_input_directory_files --- tests/extractor_service/unit/extractor_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/extractor_service/unit/extractor_test.py b/tests/extractor_service/unit/extractor_test.py index d1d0027..ca86994 100644 --- a/tests/extractor_service/unit/extractor_test.py +++ b/tests/extractor_service/unit/extractor_test.py @@ -113,7 +113,7 @@ def test_list_input_directory_files(mock_is_file, mock_iterdir, extractor, caplo assert result == mock_files assert f"Directory '{config.input_directory}' files listed." in caplog.text - assert f"Listed file paths: {mock_files}" + assert f"Listed file paths: {mock_files}" in caplog.text @patch.object(Path, "iterdir") From 244bfc818dea63134483f42ec20821c2baeea29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:42:40 +0200 Subject: [PATCH 41/52] Fix assertion for _dropout_rate to float with tolerance --- tests/extractor_service/unit/nima_models_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/extractor_service/unit/nima_models_test.py b/tests/extractor_service/unit/nima_models_test.py index 93237f7..1f25ace 100644 --- a/tests/extractor_service/unit/nima_models_test.py +++ b/tests/extractor_service/unit/nima_models_test.py @@ -64,7 +64,7 @@ def test_class_arguments(): assert model._model is None assert list(model._prediction_weights) == list(np.arange(1, 11)) assert model._input_shape == (224, 224, 3) - assert model._dropout_rate == 0.75 + assert np.isclose(model._dropout_rate, 0.75, rtol=1e-9) assert model._num_classes == 10 From a005b05837ba68c793f54415b6e2a01600800d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 09:45:39 +0200 Subject: [PATCH 42/52] Add comment before pass in test_get_video_capture_failure for clarity --- tests/extractor_service/unit/video_processors_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/extractor_service/unit/video_processors_test.py b/tests/extractor_service/unit/video_processors_test.py index 1ec8fb3..1c940c9 100644 --- a/tests/extractor_service/unit/video_processors_test.py +++ b/tests/extractor_service/unit/video_processors_test.py @@ -30,6 +30,7 @@ def test_get_video_capture_failure(mock_cap): with pytest.raises(OpenCVVideo.CantOpenVideoCapture): with OpenCVVideo._video_capture(test_path): + # No additional operations are needed here, we are just testing the exception pass mock_video.release.assert_called_once() From ab5149820a910ee2614fc06a2cc4b9752e3aeab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:09:37 +0200 Subject: [PATCH 43/52] remove magic values in test_get_next_video_frames --- .../extractor_service/unit/video_processors_test.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/extractor_service/unit/video_processors_test.py b/tests/extractor_service/unit/video_processors_test.py index 1c940c9..a7df3ea 100644 --- a/tests/extractor_service/unit/video_processors_test.py +++ b/tests/extractor_service/unit/video_processors_test.py @@ -7,6 +7,8 @@ from extractor_service.app.video_processors import OpenCVVideo +TOTAL_FRAMES_ATTR = "total frames" + @patch.object(cv2, "VideoCapture") def test_get_video_capture_success(mock_cap): @@ -54,11 +56,12 @@ def mock_video(): @patch.object(OpenCVVideo, '_read_next_frame') def test_get_next_video_frames(mock_read, mock_get_attribute, mock_video_cap, batch_size, expected_num_batches, caplog): + frame_rate_attr = "frame rate" video_path = MagicMock() mock_video = MagicMock() frames_number = 3 mock_get_attribute.side_effect = lambda video, attribute_id, value_name: \ - frames_number if "total frames" in value_name else 1 + frames_number if TOTAL_FRAMES_ATTR in value_name else 1 mock_video_cap.return_value.__enter__.return_value = mock_video mock_read.side_effect = lambda video, idx: f"frame{idx // 30}" @@ -71,8 +74,8 @@ def test_get_next_video_frames(mock_read, mock_get_attribute, mock_video_cap, assert len(batch) <= batch_size, "Batch size is larger than expected" assert mock_video_cap.called assert mock_get_attribute.call_count == 2 - mock_get_attribute.assert_any_call(mock_video, cv2.CAP_PROP_FPS, "frame rate") - mock_get_attribute.assert_any_call(mock_video, cv2.CAP_PROP_FRAME_COUNT, "total frames") + mock_get_attribute.assert_any_call(mock_video, cv2.CAP_PROP_FPS, frame_rate_attr) + mock_get_attribute.assert_any_call(mock_video, cv2.CAP_PROP_FRAME_COUNT, TOTAL_FRAMES_ATTR) assert mock_read.call_count == 3 assert "Frame appended to frames batch." in caplog.text @@ -104,7 +107,7 @@ def test_read_next_frame(mock_check_cap, read_return, caplog): def test_get_video_attribute(mock_check_cap, caplog): mock_cap = MagicMock(spec=cv2.VideoCapture) attribute_id = cv2.CAP_PROP_FRAME_COUNT - value_name = "total frames" + value_name = TOTAL_FRAMES_ATTR total_frames = 24.6 mock_cap.get.return_value = total_frames @@ -120,7 +123,7 @@ def test_get_video_attribute(mock_check_cap, caplog): def test_get_video_attribute_invalid(mock_check_cap, caplog): mock_cap = MagicMock(spec=cv2.VideoCapture) attribute_id = cv2.CAP_PROP_FRAME_COUNT - value_name = "total frames" + value_name = TOTAL_FRAMES_ATTR total_frames = -24.6 mock_cap.get.return_value = total_frames expected_message = f"Invalid {value_name} retrieved: {total_frames}." From 0d97801282196fdec547826c821fcd957a60c853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:17:14 +0200 Subject: [PATCH 44/52] remove overwriting client variable in image fixture --- .../integration/docker_container_test.py | 1 - tests/test_files/best_frames/.gitkeep | 0 tests/test_files/test_video.mp4 | Bin 35238 -> 0 bytes tests/test_files/top_images/.gitkeep | 0 4 files changed, 1 deletion(-) delete mode 100644 tests/test_files/best_frames/.gitkeep delete mode 100644 tests/test_files/test_video.mp4 delete mode 100644 tests/test_files/top_images/.gitkeep diff --git a/tests/service_manager/integration/docker_container_test.py b/tests/service_manager/integration/docker_container_test.py index f25e143..9acef0c 100644 --- a/tests/service_manager/integration/docker_container_test.py +++ b/tests/service_manager/integration/docker_container_test.py @@ -5,7 +5,6 @@ @pytest.fixture def image(client, manager, config): image_name = "image_name" - client = docker.from_env() image = client.images.pull("busybox") image.tag(image_name) manager._image_name = image_name diff --git a/tests/test_files/best_frames/.gitkeep b/tests/test_files/best_frames/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_files/test_video.mp4 b/tests/test_files/test_video.mp4 deleted file mode 100644 index fd4a35d73a054535ea2a4128abd49b7b00261a65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35238 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 Date: Wed, 29 May 2024 10:29:09 +0200 Subject: [PATCH 45/52] fix removing test folders after tests --- .gitignore | 4 +++- tests/common.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index bcae2c1..9da8414 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ nima.h5 tests/test_files/best_frames/* tests/test_files/top_images/* !tests/test_files/best_frames/.gitkeep -!tests/test_files/top_images/.gitkeep \ No newline at end of file +!tests/test_files/top_images/.gitkeep +tests/test_files/test_video.mp4 +tests/test_files/frames_extracted_test_video.mp4 diff --git a/tests/common.py b/tests/common.py index 167d338..5c713fe 100644 --- a/tests/common.py +++ b/tests/common.py @@ -20,7 +20,7 @@ def top_images_dir(files_dir): return files_dir / "top_images" -@pytest.fixture(scope="function") +@pytest.fixture def setup_top_images_extractor_env(files_dir, top_images_dir) -> tuple[Path, Path]: assert files_dir.is_dir() @@ -29,10 +29,14 @@ def setup_top_images_extractor_env(files_dir, top_images_dir) -> tuple[Path, Pat assert not top_images_dir.is_dir(), "Output directory was not removed" top_images_dir.mkdir() - return files_dir, top_images_dir + yield files_dir, top_images_dir + gitkeep_file = top_images_dir / ".gitkeep" + gitkeep_file.touch() + assert gitkeep_file.exists() -@pytest.fixture(scope="function") + +@pytest.fixture def setup_best_frames_extractor_env(files_dir, best_frames_dir) -> tuple[Path, Path, Path]: video_filename = "test_video.mp4" expected_video_path = files_dir / f"frames_extracted_{video_filename}" @@ -47,4 +51,8 @@ def setup_best_frames_extractor_env(files_dir, best_frames_dir) -> tuple[Path, P best_frames_dir.mkdir() assert best_frames_dir.is_dir(), "Output dir was not created after cleaning." - return files_dir, best_frames_dir, expected_video_path + yield files_dir, best_frames_dir, expected_video_path + + gitkeep_file = best_frames_dir / ".gitkeep" + gitkeep_file.touch() + assert gitkeep_file.exists() \ No newline at end of file From 9dc02b640c592c789c82445de4d4e058f8809833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:32:11 +0200 Subject: [PATCH 46/52] add sleep 300 command as a constant for handling DRY --- .../integration/docker_container_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/service_manager/integration/docker_container_test.py b/tests/service_manager/integration/docker_container_test.py index 9acef0c..637f79b 100644 --- a/tests/service_manager/integration/docker_container_test.py +++ b/tests/service_manager/integration/docker_container_test.py @@ -1,6 +1,8 @@ import docker import pytest +COMMAND = "sleep 300" + @pytest.fixture def image(client, manager, config): @@ -42,7 +44,7 @@ def test_run_container(manager, config, client, cleanup_container, image): def test_start_container(manager, cleanup_container, client, image): - container = client.containers.create(image, command="sleep 300", detach=True, name=manager._container_name) + container = client.containers.create(image, command=COMMAND, detach=True, name=manager._container_name) assert container.status == "created" manager._start_container() container.reload() @@ -50,7 +52,7 @@ def test_start_container(manager, cleanup_container, client, image): def test_stop_container(manager, cleanup_container, client, image): - container = client.containers.create(image, command="sleep 300", detach=True, name=manager._container_name) + container = client.containers.create(image, command=COMMAND, detach=True, name=manager._container_name) assert container.status == "created" container.start() container.reload() @@ -61,7 +63,7 @@ def test_stop_container(manager, cleanup_container, client, image): def test_delete_container(manager, cleanup_container, client, image): - container = client.containers.create(image, command="sleep 300", detach=True, name=manager._container_name) + container = client.containers.create(image, command=COMMAND, detach=True, name=manager._container_name) assert container.status == "created" manager._delete_container() with pytest.raises(docker.errors.NotFound): @@ -69,7 +71,7 @@ def test_delete_container(manager, cleanup_container, client, image): def test_container_status(manager, cleanup_container, client, image): - container = client.containers.create(image, command="sleep 300", detach=True, name=manager._container_name) + container = client.containers.create(image, command=COMMAND, detach=True, name=manager._container_name) assert container.status == "created" assert manager.container_status == "created" container.start() From e0dbc4d8e242e1534b5c4855df575326f9a09703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:40:59 +0200 Subject: [PATCH 47/52] fix test_container_status --- tests/service_manager/unit/docker_manager_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/service_manager/unit/docker_manager_test.py b/tests/service_manager/unit/docker_manager_test.py index e84b846..c005e8d 100644 --- a/tests/service_manager/unit/docker_manager_test.py +++ b/tests/service_manager/unit/docker_manager_test.py @@ -99,18 +99,18 @@ def test_build_image_when_image_exists_and_force_build( assert "Building Docker image..." in caplog.text -@pytest.mark.parametrize("code, output, status", ((1, "", None), (0, "'running'", "'running'"))) +@pytest.mark.parametrize("code, output, status", ((1, "", None), (0, "'running'", "running"))) def test_container_status(code, output, status, docker, mock_run): command_output = MagicMock() command_output.returncode = code command_output.stdout = output - mock_subprocess_run.return_value = command_output + mock_run.return_value = command_output expected_command = ["docker", "inspect", "--format='{{.State.Status}}'", docker._container_name] - status = docker.container_status + result_status = docker.container_status mock_run.assert_called_once_with(expected_command, capture_output=True, text=True, check=False) - assert status == status + assert status == result_status @pytest.mark.parametrize("build", (True, False)) From 9b20828f374a9313d8271a9a42eff2db011edca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:45:28 +0200 Subject: [PATCH 48/52] save log_lines as constants for handling DRY --- tests/service_manager/unit/docker_manager_test.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/service_manager/unit/docker_manager_test.py b/tests/service_manager/unit/docker_manager_test.py index c005e8d..f74e82b 100644 --- a/tests/service_manager/unit/docker_manager_test.py +++ b/tests/service_manager/unit/docker_manager_test.py @@ -6,6 +6,9 @@ from service_manager.docker_manager import DockerManager +LOG_LINE_1 = "log line 1\n" +LOG_LINE_2 = "log line 2\n" + def test_docker_manager_init(caplog, config): image_name = f"{config.service_name}_image" @@ -241,7 +244,7 @@ def test_delete_container_success(docker, mock_run, caplog): @patch.object(DockerManager, "_stop_container") def test_follow_container_logs_stopped_by_user(mock_stop, mock_run_log, mock_stdout, docker, caplog): mock_process = MagicMock() - mock_process.stdout.readline.side_effect = ["log line 1\n", "log line 2\n", KeyboardInterrupt()] + mock_process.stdout.readline.side_effect = [LOG_LINE_1, LOG_LINE_2, KeyboardInterrupt()] mock_run_log.return_value = mock_process mock_process.terminate = MagicMock() mock_process.wait = MagicMock() @@ -255,7 +258,7 @@ def test_follow_container_logs_stopped_by_user(mock_stop, mock_run_log, mock_std mock_process.wait.assert_called_once() mock_stop.assert_called_once() - calls = [call("log line 1\n"), call("log line 2\n")] + calls = [call(LOG_LINE_1), call(LOG_LINE_2)] mock_stdout.assert_has_calls(calls, any_order=True) assert "Process stopped by user." in caplog.text assert "Following container logs stopped." in caplog.text @@ -268,7 +271,7 @@ def test_follow_container_logs_stopped_automatically(mock_stop, mock_run_log, mock_stdout, docker, caplog): mock_process = MagicMock() mock_process.stdout.readline.side_effect = [ - "log line 1\n", "log line 2\n", DockerManager.ServiceShutdownSignal() + LOG_LINE_1, LOG_LINE_2, DockerManager.ServiceShutdownSignal() ] mock_run_log.return_value = mock_process mock_process.terminate = MagicMock() @@ -283,7 +286,7 @@ def test_follow_container_logs_stopped_automatically(mock_stop, mock_run_log, mock_process.wait.assert_called_once() mock_stop.assert_called_once() - calls = [call("log line 1\n"), call("log line 2\n")] + calls = [call(LOG_LINE_1), call(LOG_LINE_2)] mock_stdout.assert_has_calls(calls, any_order=True) assert "Service has signaled readiness for shutdown." in caplog.text assert "Following container logs stopped." in caplog.text From 6073c0103a7b4650dccc89c99c9c1bed659e54ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:46:32 +0200 Subject: [PATCH 49/52] remove unused variable pop --- tests/service_manager/unit/docker_manager_test.py | 2 +- tests/test_files/best_frames/.gitkeep | 0 tests/test_files/top_images/.gitkeep | 0 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/test_files/best_frames/.gitkeep create mode 100644 tests/test_files/top_images/.gitkeep diff --git a/tests/service_manager/unit/docker_manager_test.py b/tests/service_manager/unit/docker_manager_test.py index f74e82b..e41ed38 100644 --- a/tests/service_manager/unit/docker_manager_test.py +++ b/tests/service_manager/unit/docker_manager_test.py @@ -250,7 +250,7 @@ def test_follow_container_logs_stopped_by_user(mock_stop, mock_run_log, mock_std mock_process.wait = MagicMock() with caplog.at_level(logging.INFO), \ - patch.object(subprocess, "Popen", autospec=True) as pop: + patch.object(subprocess, "Popen", autospec=True): docker.follow_container_logs() mock_run_log.assert_called_once() diff --git a/tests/test_files/best_frames/.gitkeep b/tests/test_files/best_frames/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_files/top_images/.gitkeep b/tests/test_files/top_images/.gitkeep new file mode 100644 index 0000000..e69de29 From 11080b530c4898a8e69d1a487605eeaadb5f379a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 10:53:40 +0200 Subject: [PATCH 50/52] Remove the unused local variable 'container' --- tests/service_manager/integration/docker_container_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service_manager/integration/docker_container_test.py b/tests/service_manager/integration/docker_container_test.py index 637f79b..32102dc 100644 --- a/tests/service_manager/integration/docker_container_test.py +++ b/tests/service_manager/integration/docker_container_test.py @@ -81,7 +81,7 @@ def test_container_status(manager, cleanup_container, client, image): def test_run_log_process(manager, cleanup_container, client, image): - container = client.containers.run( + client.containers.run( image, command="sh -c 'while true; do date; done'", detach=True, From f1286005c14f133b1a01711a8832709730013ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Flis?= Date: Wed, 29 May 2024 11:00:26 +0200 Subject: [PATCH 51/52] add test video --- .gitignore | 2 -- .../test_files/frames_extracted_test_video.mp4 | Bin 0 -> 35238 bytes 2 files changed, 2 deletions(-) create mode 100644 tests/test_files/frames_extracted_test_video.mp4 diff --git a/.gitignore b/.gitignore index 9da8414..68e7ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,3 @@ tests/test_files/best_frames/* tests/test_files/top_images/* !tests/test_files/best_frames/.gitkeep !tests/test_files/top_images/.gitkeep -tests/test_files/test_video.mp4 -tests/test_files/frames_extracted_test_video.mp4 diff --git a/tests/test_files/frames_extracted_test_video.mp4 b/tests/test_files/frames_extracted_test_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fd4a35d73a054535ea2a4128abd49b7b00261a65 GIT binary patch literal 35238 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 Date: Wed, 29 May 2024 11:33:59 +0200 Subject: [PATCH 52/52] update version in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 090e2ad..85ca3bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "PerfectFrameAI" -version = "2.2.0" +version = "2.3.0" description = "AI tool for finding the most aesthetic frames in a video. 🎞️➜🖼️" authors = ["Bartłomiej Flis "] license = "GPL-3.0 license"