diff --git a/.gitignore b/.gitignore index 5b5877a..462532e 100644 --- a/.gitignore +++ b/.gitignore @@ -129,10 +129,6 @@ dmypy.json .pyre/ .idea/ -# Photos -*.jpg -*.png - # YOLO model directory /drones/models/yolov5m.pt /drones/models/yolov5l.pt diff --git a/drones/image_processing/image_processing.py b/drones/image_processing/image_processing.py index 3de50f0..b85ef3a 100644 --- a/drones/image_processing/image_processing.py +++ b/drones/image_processing/image_processing.py @@ -7,6 +7,7 @@ from drones.image_processing.yolo import YoloDetection from drones.image_processing.utils import distance_to_camera, vector_to_centre from typing import List, Tuple +import os class ImageProcessing: @@ -39,7 +40,9 @@ def process_image(self, image: np.ndarray) -> List[Tuple[float, float, float]]: detection list will be empty. """ config_parser = configparser.ConfigParser() - config_parser.read("image_processing/config.ini") + pwd = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(pwd, "config.ini") + config_parser.read(config_path) config = config_parser["OBJECT"] focal = int(config["FOCAL"]) diff --git a/drones/image_processing/utils.py b/drones/image_processing/utils.py index 9b55aad..e24fbc0 100644 --- a/drones/image_processing/utils.py +++ b/drones/image_processing/utils.py @@ -4,6 +4,7 @@ import configparser import typing import drones.image_processing.normalization as normalization +import os def calculate_focal(known_width: float, known_distance: float, pixel_width: int) -> float: @@ -108,7 +109,9 @@ def detect_object(image: np.ndarray) -> typing.Tuple[typing.Tuple[int, int], int This returned format is insired by OpenCV minEnclosingCircle function returned format. """ config_parser = configparser.ConfigParser() - config_parser.read("image_processing/config.ini") + pwd = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(pwd, "config.ini") + config_parser.read(config_path) config = config_parser["COLOR_RANGE"] # Image normalization to make colors more visious and less light vulnerable. diff --git a/drones/image_processing/yolo.py b/drones/image_processing/yolo.py index 2b8ea7c..07c5bc3 100644 --- a/drones/image_processing/yolo.py +++ b/drones/image_processing/yolo.py @@ -10,6 +10,7 @@ from yolov5.utils.torch_utils import select_device import logging from typing import List, Tuple +import os class YoloDetection: @@ -17,7 +18,9 @@ def __init__(self): self.log = logging.getLogger(__name__) # Initialize config. self.config_parser = configparser.ConfigParser() - self.config_parser.read("image_processing/config.ini") + pwd = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(pwd, "config.ini") + self.config_parser.read(config_path) self.config = self.config_parser["YOLO"] # Parse classes from config. diff --git a/requirements.txt b/requirements.txt index a51fb0f..473567c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ numpy==1.20.2 opencv-python==4.5.1.48 pre-commit~=2.11.0 pytest==6.2.2 -yolov5==5.0.3 +yolov5==5.0.5 diff --git a/tests/image_processing/__init__.py b/tests/image_processing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/image_processing/resources/black_square_big.png b/tests/image_processing/resources/black_square_big.png new file mode 100644 index 0000000..cd87053 Binary files /dev/null and b/tests/image_processing/resources/black_square_big.png differ diff --git a/tests/image_processing/resources/black_square_small.png b/tests/image_processing/resources/black_square_small.png new file mode 100644 index 0000000..60da209 Binary files /dev/null and b/tests/image_processing/resources/black_square_small.png differ diff --git a/tests/image_processing/resources/bottle_and_bed.jpg b/tests/image_processing/resources/bottle_and_bed.jpg new file mode 100644 index 0000000..16260e8 Binary files /dev/null and b/tests/image_processing/resources/bottle_and_bed.jpg differ diff --git a/tests/image_processing/resources/bottles.jpeg b/tests/image_processing/resources/bottles.jpeg new file mode 100644 index 0000000..8162421 Binary files /dev/null and b/tests/image_processing/resources/bottles.jpeg differ diff --git a/tests/image_processing/resources/dog.JPG b/tests/image_processing/resources/dog.JPG new file mode 100644 index 0000000..0d1de78 Binary files /dev/null and b/tests/image_processing/resources/dog.JPG differ diff --git a/tests/image_processing/resources/orange_indoor.png b/tests/image_processing/resources/orange_indoor.png new file mode 100644 index 0000000..28f40f5 Binary files /dev/null and b/tests/image_processing/resources/orange_indoor.png differ diff --git a/tests/image_processing/resources/orange_outdoor.png b/tests/image_processing/resources/orange_outdoor.png new file mode 100644 index 0000000..6316e22 Binary files /dev/null and b/tests/image_processing/resources/orange_outdoor.png differ diff --git a/tests/image_processing/tests.py b/tests/image_processing/tests.py new file mode 100644 index 0000000..6a29574 --- /dev/null +++ b/tests/image_processing/tests.py @@ -0,0 +1,329 @@ +""" File containing unit tests for Image Processing module """ + +import unittest +import cv2 +import drones.image_processing as image_processing +import configparser +import os + + +class YoloDetectionTest(unittest.TestCase): + """Class containing test cases for YoloDetection class methods""" + + @classmethod + def setUpClass(cls): + """ + Method that is called once, before running the tests in the class + Its main tasks are to create YoloDetection object and to read some images necessary in tests + """ + cls.yolo_detection = image_processing.yolo.YoloDetection() + + cls.dog_img_width = 3072 + cls.dog_img_height = 2304 + cls.dog_img = cv2.imread("image_processing/resources/dog.JPG") + + cls.bottles_img = cv2.imread("image_processing/resources/bottles.jpeg") + + cls.orange_img_width = 1280 + cls.orange_img_height = 720 + cls.orange_img = cv2.imread("image_processing/resources/orange_indoor.png") + + cls.bottle_and_bed_img_width = 4624 + cls.bottle_and_bed_img_height = 1916 + cls.bottle_and_bed_img = cv2.imread("image_processing/resources/bottle_and_bed.jpg") + + def test_detect_one_dog(self): + """ + Test that check if yolo can detect dog object in an image correctly + and return its correct position in yolo format + """ + results = self.yolo_detection.detect(self.dog_img) + + # In the test image there is only one class - dog + self.assertEqual(len(results), 1) + self.assertEqual(results[0][0], "dog") + + # Convert yolo format to pixel format + x_pos = results[0][1] * self.dog_img_width + y_pos = results[0][2] * self.dog_img_height + width = results[0][3] * self.dog_img_width + height = results[0][4] * self.dog_img_height + + # Check if object frame and its center are in relatively correct position in an image + # The image is pretty large so I think that 100 pixels don't make much difference + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(x_pos, 1100, delta=100) + self.assertAlmostEqual(y_pos, 1650, delta=100) + self.assertAlmostEqual(width, 2150, delta=100) + self.assertAlmostEqual(height, 1300, delta=100) + + def test_detect_object_yolo_one_dog(self): + """ + Test that check if yolo can detect dog object in an image correctly. + Moreover conversion from yolo to pixel format is also checked + and we specify to look only for dog object in an image + """ + self.yolo_detection.config["CLASS"] = "dog" + results = self.yolo_detection.detect_object_yolo(self.dog_img) + + # In test image there is only one dog + self.assertEqual(len(results), 1) + + # Here results should be in pixel format + x = results[0][0] + y = results[0][1] + width = results[0][2] + + # Checked if center of an object and width of its frame have relatively correct values + # The image is pretty large, so I think that 100 pixels don't make much difference + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(x, 1100, delta=100) + self.assertAlmostEqual(y, 1650, delta=100) + self.assertAlmostEqual(width, 2150, delta=100) + + def test_detect_bottle_and_bed(self): + """ + Test that check if yolo can detect bottle and bed objects in an image correctly + and return their correct positions in yolo format + """ + results = self.yolo_detection.detect(self.bottle_and_bed_img) + + # The image presents two objects - bottle and bed + self.assertEqual(len(results), 2) + self.assertEqual(results[0][0], "bed") + self.assertEqual(results[1][0], "bottle") + + # Convert yolo format to pixel format + bed_x_pos = results[0][1] * self.bottle_and_bed_img_width + bed_y_pos = results[0][2] * self.bottle_and_bed_img_height + bed_width = results[0][3] * self.bottle_and_bed_img_width + bed_height = results[0][4] * self.bottle_and_bed_img_height + + bottle_x_pos = results[1][1] * self.bottle_and_bed_img_width + bottle_y_pos = results[1][2] * self.bottle_and_bed_img_height + bottle_width = results[1][3] * self.bottle_and_bed_img_width + bottle_height = results[1][4] * self.bottle_and_bed_img_height + + # Check if object frame and its center are in relatively correct position in an image + # The image is pretty large so I think that 100 pixels don't make much difference + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(bed_x_pos, 4190, delta=100) + self.assertAlmostEqual(bed_y_pos, 960, delta=100) + self.assertAlmostEqual(bed_width, 900, delta=100) + self.assertAlmostEqual(bed_height, 1900, delta=100) + + self.assertAlmostEqual(bottle_x_pos, 2470, delta=100) + self.assertAlmostEqual(bottle_y_pos, 470, delta=100) + self.assertAlmostEqual(bottle_width, 200, delta=100) + self.assertAlmostEqual(bottle_height, 600, delta=100) + + def test_detect_object_yolo_bottle_and_bed(self): + """ + Test that check if yolo can detect bottle object in an image correctly. + Moreover conversion from yolo to pixel format is also checked + and we specify to look only for bottle object in an image + """ + self.yolo_detection.config["CLASS"] = "bottle" + results = self.yolo_detection.detect_object_yolo(self.bottle_and_bed_img) + + # In the image there is only one bottle + self.assertEqual(len(results), 1) + + # Here results should be in pixel format + x_pos = results[0][0] + y_pos = results[0][1] + width = results[0][2] + + # Checked if center of an object and width of its frame have relatively correct values + # The image is pretty large, so I think that 100 pixels don't make much difference + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(x_pos, 2470, delta=100) + self.assertAlmostEqual(y_pos, 470, delta=100) + self.assertAlmostEqual(width, 200, delta=100) + + def test_detect_many_bottles(self): + """ + Test that check if yolo is capable of detecting many objects of the same class in one image + """ + results = self.yolo_detection.detect(self.bottles_img) + + # In the image there should be 13 bottles and one other objects + self.assertEqual(len(results), 13) + + # And every object should be in bottle class + for result in results: + self.assertEqual(result[0], "bottle") + + def test_detect_object_yolo_many_bottles(self): + """ + Test that check if yolo is capable of detecting many objects of the same class in the image. + Here we also specify that the class should be bottle. + """ + self.yolo_detection.config["CLASS"] = "bottle" + results = self.yolo_detection.detect_object_yolo(self.bottles_img) + + # There should be 13 bottles in the image + self.assertEqual(len(results), 13) + + def test_detect_object_yolo_orange(self): + """ + Tests that check if yolo detect orange object in the image correctly. + Here we specify that the class should be orange. + """ + self.yolo_detection.config["CLASS"] = "orange" + results = self.yolo_detection.detect_object_yolo(self.orange_img) + + # One orange in the image + self.assertEqual(len(results), 1) + + # Here results should be in pixel format + x_pos = results[0][0] + y_pos = results[0][1] + width = results[0][2] + + # Checked if center of an object and width of its frame have relatively correct values + # The image and the object in it is quite small, so delta was chosen not to be very large + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(x_pos, 690, delta=20) + self.assertAlmostEqual(y_pos, 290, delta=20) + self.assertAlmostEqual(width, 70, delta=20) + + def test_detect_orange(self): + """ + Test that check if yolo detect orange object correctly. + The algorithm should also detect bed, on which the orange lays + """ + results = self.yolo_detection.detect(self.orange_img) + + # In the image there should be two objects - orange and bed + self.assertEqual(len(results), 2) + self.assertEqual(results[0][0], "bed") + self.assertEqual(results[1][0], "orange") + + # Convert yolo format to pixel format + bed_x_pos = results[0][1] * self.orange_img_width + bed_y_pos = results[0][2] * self.orange_img_height + bed_width = results[0][3] * self.orange_img_width + bed_height = results[0][4] * self.orange_img_height + + orange_x_pos = results[1][1] * self.orange_img_width + orange_y_pos = results[1][2] * self.orange_img_height + orange_width = results[1][3] * self.orange_img_width + orange_height = results[1][4] * self.orange_img_height + + # The bed is the background of the image, so it should be detected as whole image + # Delta was chosen appropriately to such large object + self.assertAlmostEqual(bed_x_pos, 640, delta=50) + self.assertAlmostEqual(bed_y_pos, 360, delta=50) + self.assertAlmostEqual(bed_width, 1280, delta=50) + self.assertAlmostEqual(bed_height, 720, delta=50) + + # Checked if center of an object and width of its frame have relatively correct values + # The image and the object in it is quite small, so delta was chosen not to be very large + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(orange_x_pos, 690, delta=20) + self.assertAlmostEqual(orange_y_pos, 290, delta=20) + self.assertAlmostEqual(orange_width, 70, delta=20) + self.assertAlmostEqual(orange_height, 80, delta=20) + + +class UtilsTest(unittest.TestCase): + """Class containing test cases for functions stored in utils.py file""" + + @classmethod + def setUpClass(cls): + """ + Method that is called once, before running the tests in the class + Its main tasks are to read some images necessary in tests and to get focal length from config + """ + cls.orange_outdoor_img = cv2.imread("image_processing/resources/orange_outdoor.png") + cls.orange_indoor_img = cv2.imread("image_processing/resources/orange_indoor.png") + + config_parser = configparser.ConfigParser() + config_parser.read("../drones/image_processing/config.ini") + config = config_parser["OBJECT"] + cls.focal = int(config["FOCAL"]) + + def test_distance_to_camera(self): + """ + Test that check if distance between camera is correctly calculated + """ + # Distance calculated for black small square + # Pixel width was defined using OpenCV by detecting the square and averaging its width and height + # (because the square was not detected perfectly) + distance_far = image_processing.utils.distance_to_camera( + known_width=9.6, focal_length=self.focal, pixel_width=383 + ) + distance_close = image_processing.utils.distance_to_camera( + known_width=9.6, focal_length=self.focal, pixel_width=881 + ) + + # Correct distance was measured physically + # Calculation has to be correct to unity + self.assertAlmostEqual(distance_far, 45.5, delta=1) + self.assertAlmostEqual(distance_close, 19, delta=1) + + def test_calculate_focal(self): + """ + Test that check if focal length is calculated correctly + """ + # Focal length calculated using black squares images + # Pixel width was defined using OpenCV by detecting the square and averaging its width and height + # (because the square was not detected perfectly) + focal_far = image_processing.utils.calculate_focal(known_width=9.6, known_distance=45.5, pixel_width=383) + focal_close = image_processing.utils.calculate_focal(known_width=9.6, known_distance=19, pixel_width=881) + + # Check if focal length is at least close to the one from config + self.assertAlmostEqual(focal_far, self.focal, delta=60) + self.assertAlmostEqual(focal_close, self.focal, delta=60) + + def test_vector_to_center(self): + """ + Test that check if calculation of vector from some point to center of image is correct. + """ + vector = image_processing.utils.vector_to_centre( + frame_width=100, frame_height=100, obj_coordinates=(75, 75), centre_height_coeff=0.5 + ) + self.assertEqual(vector, (-25, -25)) + vector = image_processing.utils.vector_to_centre( + frame_width=100, frame_height=100, obj_coordinates=(50, 50), centre_height_coeff=0.5 + ) + self.assertEqual(vector, (0, 0)) + vector = image_processing.utils.vector_to_centre( + frame_width=100, frame_height=100, obj_coordinates=(50, 50), centre_height_coeff=0.1 + ) + self.assertEqual(vector, (0, -40)) + vector = image_processing.utils.vector_to_centre( + frame_width=6, frame_height=10, obj_coordinates=(6, 10), centre_height_coeff=0.7 + ) + self.assertEqual(vector, (-3, -3)) + vector = image_processing.utils.vector_to_centre( + frame_width=5, frame_height=10, obj_coordinates=(0, 0), centre_height_coeff=0.5 + ) + self.assertEqual(vector, (2, 5)) + vector = image_processing.utils.vector_to_centre( + frame_width=6, frame_height=10, obj_coordinates=(6, 0), centre_height_coeff=0.4 + ) + self.assertEqual(vector, (-3, 4)) + vector = image_processing.utils.vector_to_centre( + frame_width=6, frame_height=10, obj_coordinates=(0, 10), centre_height_coeff=0.9 + ) + self.assertEqual(vector, (3, -1)) + + def test_detect_orange(self): + """ + Test that check if OpenCV function for detection works correctly for indoor and outdoor orange images + """ + ((outdoor_x, outdoor_y), outdoor_diameter) = image_processing.utils.detect_object(self.orange_outdoor_img) + ((indoor_x, indoor_y), indoor_diameter) = image_processing.utils.detect_object(self.orange_indoor_img) + + # Checked if center of an object and diameter of minimal enclosing circle are in relatively correct positions + # The image and the object in it is quite small, so delta was chosen not to be very large + # Correct values were arbitrarily chosen using human eyes and some tool for labeling + self.assertAlmostEqual(outdoor_x, 690, delta=20) + self.assertAlmostEqual(outdoor_y, 450, delta=20) + self.assertAlmostEqual(outdoor_diameter, 30, delta=20) + + self.assertAlmostEqual(indoor_x, 690, delta=20) + self.assertAlmostEqual(indoor_y, 290, delta=20) + self.assertAlmostEqual(indoor_diameter, 70, delta=20) diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..8edb752 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,16 @@ +""" +File, that runs all tests +It goes through all project directories and search for files named tests.py +Then it runs all unit tests stored in all tests.py files +""" + +import unittest + +if __name__ == "__main__": + # Searching for all tests in tests.py files + tests = unittest.TestLoader().discover(".", pattern="tests.py") + + # Run all found tests + # verbosity indicates how much details of tests output would be showed + # 2 - the most amount of details + unittest.TextTestRunner(verbosity=2).run(tests)