From c8b6228f5cd9abbbbc813cafe47d82355d1a1728 Mon Sep 17 00:00:00 2001 From: Ashish Agrahari <shinyflame007@gmail.com> Date: Sat, 28 Sep 2024 19:41:58 -0400 Subject: [PATCH 01/27] Added Dylans code --- main_detect_target.py | 215 ++++++++++++++++++ .../detect_target/detect_target_contour.py | 156 +++++++++++++ .../detect_target/detect_target_factory.py | 7 + 3 files changed, 378 insertions(+) create mode 100644 main_detect_target.py create mode 100644 modules/detect_target/detect_target_contour.py diff --git a/main_detect_target.py b/main_detect_target.py new file mode 100644 index 00000000..3183c96f --- /dev/null +++ b/main_detect_target.py @@ -0,0 +1,215 @@ +""" +For 2022-2023 UAS competition. +""" + +import argparse +import multiprocessing as mp +import pathlib + +from modules.detect_target import detect_target_factory +from modules.detect_target import detect_target_worker +from modules.video_input import video_input_worker +from modules.common.logger.modules import logger +from modules.common.logger.modules import logger_setup_main +from modules.common.logger.read_yaml.modules import read_yaml +from utilities.workers import queue_proxy_wrapper +from utilities.workers import worker_controller +from utilities.workers import worker_manager + + +CONFIG_FILE_PATH = pathlib.Path("config.yaml") + + +# Code copied into main_2024.py +# pylint: disable=duplicate-code +def main() -> int: + """ + Main function. + """ + # Parse whether or not to force cpu from command line + parser = argparse.ArgumentParser() + parser.add_argument("--cpu", action="store_true", help="option to force cpu") + parser.add_argument("--full", action="store_true", help="option to force full precision") + parser.add_argument( + "--show-annotated", + action="store_true", + help="option to show annotated image", + ) + args = parser.parse_args() + + # Configuration settings + result, config = read_yaml.open_config(CONFIG_FILE_PATH) + if not result: + print("ERROR: Failed to load configuration file") + return -1 + + # Get Pylance to stop complaining + assert config is not None + + # Logger configuration settings + result, config_logger = read_yaml.open_config(logger.CONFIG_FILE_PATH) + if not result: + print("ERROR: Failed to load configuration file") + return -1 + + # Get Pylance to stop complaining + assert config_logger is not None + + # Setup main logger + result, main_logger, logging_path = logger_setup_main.setup_main_logger(config_logger) + if not result: + print("ERROR: Failed to create main logger") + return -1 + + # Get Pylance to stop complaining + assert main_logger is not None + assert logging_path is not None + + # Get settings + try: + # Local constants + # pylint: disable=invalid-name + QUEUE_MAX_SIZE = config["queue_max_size"] + + VIDEO_INPUT_CAMERA_NAME = config["video_input"]["camera_name"] + VIDEO_INPUT_WORKER_PERIOD = config["video_input"]["worker_period"] + VIDEO_INPUT_SAVE_NAME_PREFIX = config["video_input"]["save_prefix"] + VIDEO_INPUT_SAVE_PREFIX = str(pathlib.Path(logging_path, VIDEO_INPUT_SAVE_NAME_PREFIX)) + + DETECT_TARGET_WORKER_COUNT = config["detect_target"]["worker_count"] + detect_target_option_int = config["detect_target"]["option"] + DETECT_TARGET_OPTION = detect_target_factory.DetectTargetOption(detect_target_option_int) + DETECT_TARGET_DEVICE = "cpu" if args.cpu else config["detect_target"]["device"] + DETECT_TARGET_MODEL_PATH = config["detect_target"]["model_path"] + DETECT_TARGET_OVERRIDE_FULL_PRECISION = args.full + DETECT_TARGET_USE_CLASSICAL_CV = config["detect_target"]["use_classical_cv"] + DETECT_TARGET_SAVE_NAME_PREFIX = config["detect_target"]["save_prefix"] + DETECT_TARGET_SAVE_PREFIX = str(pathlib.Path(logging_path, DETECT_TARGET_SAVE_NAME_PREFIX)) + DETECT_TARGET_SHOW_ANNOTATED = args.show_annotated + # pylint: enable=invalid-name + except KeyError as exception: + main_logger.error(f"ERROR: Config key(s) not found: {exception}", True) + return -1 + + # Setup + controller = worker_controller.WorkerController() + + mp_manager = mp.Manager() + video_input_to_detect_target_queue = queue_proxy_wrapper.QueueProxyWrapper( + mp_manager, + QUEUE_MAX_SIZE, + ) + detect_target_to_main_queue = queue_proxy_wrapper.QueueProxyWrapper( + mp_manager, + QUEUE_MAX_SIZE, + ) + + # Worker properties + result, video_input_worker_properties = worker_manager.WorkerProperties.create( + count=1, + target=video_input_worker.video_input_worker, + work_arguments=( + VIDEO_INPUT_CAMERA_NAME, + VIDEO_INPUT_WORKER_PERIOD, + VIDEO_INPUT_SAVE_PREFIX, + ), + input_queues=[], + output_queues=[video_input_to_detect_target_queue], + controller=controller, + local_logger=main_logger, + ) + if not result: + main_logger.error("Failed to create arguments for Video Input", True) + return -1 + + # Get Pylance to stop complaining + assert video_input_worker_properties is not None + + result, detect_target_worker_properties = worker_manager.WorkerProperties.create( + count=DETECT_TARGET_WORKER_COUNT, + target=detect_target_worker.detect_target_worker, + work_arguments=( + DETECT_TARGET_OPTION, + DETECT_TARGET_DEVICE, + DETECT_TARGET_MODEL_PATH, + DETECT_TARGET_OVERRIDE_FULL_PRECISION, + DETECT_TARGET_USE_CLASSICAL_CV, + DETECT_TARGET_SHOW_ANNOTATED, + DETECT_TARGET_SAVE_PREFIX, + ), + input_queues=[video_input_to_detect_target_queue], + output_queues=[detect_target_to_main_queue], + controller=controller, + local_logger=main_logger, + ) + if not result: + main_logger.error("Failed to create arguments for Detect Target", True) + return -1 + + # Get Pylance to stop complaining + assert detect_target_worker_properties is not None + + # Create managers + worker_managers = [] + + result, video_input_manager = worker_manager.WorkerManager.create( + worker_properties=video_input_worker_properties, + local_logger=main_logger, + ) + if not result: + main_logger.error("Failed to create manager for Video Input", True) + return -1 + + # Get Pylance to stop complaining + assert video_input_manager is not None + + worker_managers.append(video_input_manager) + + result, detect_target_manager = worker_manager.WorkerManager.create( + worker_properties=detect_target_worker_properties, + local_logger=main_logger, + ) + if not result: + main_logger.error("Failed to create manager for Detect Target", True) + return -1 + + # Get Pylance to stop complaining + assert detect_target_manager is not None + + worker_managers.append(detect_target_manager) + + # Run + for manager in worker_managers: + manager.start_workers() + + while True: + # Use main_logger for debugging + detections_and_time = detect_target_to_main_queue.queue.get() + if detections_and_time is None: + break + main_logger.debug(f"Timestamp: {detections_and_time.timestamp}", True) + main_logger.debug(f"Num detections: {len(detections_and_time.detections)}", True) + for detection in detections_and_time.detections: + main_logger.debug(f"Detection: {detection}", True) + + # Teardown + controller.request_exit() + + video_input_to_detect_target_queue.fill_and_drain_queue() + detect_target_to_main_queue.fill_and_drain_queue() + + for manager in worker_managers: + manager.join_workers() + + return 0 + + +# pylint: enable=duplicate-code + + +if __name__ == "__main__": + result_main = main() + if result_main < 0: + print(f"ERROR: Status code: {result_main}") + + print("Done!") diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py new file mode 100644 index 00000000..eb38a977 --- /dev/null +++ b/modules/detect_target/detect_target_contour.py @@ -0,0 +1,156 @@ +""" +Detects objects using the provided model. +""" + +import time + +import copy +import cv2 +import numpy as np + +from . import base_detect_target +from .. import image_and_time +from .. import detections_and_time + + +class DetectTargetContour(base_detect_target.BaseDetectTarget): + """ + Contains the YOLOv8 model for prediction. + """ + + def __init__( + self, + show_annotations: bool = False, + save_name: str = "", + ) -> None: + """ + device: name of target device to run inference on (i.e. "cpu" or cuda device 0, 1, 2, 3). + model_path: path to the YOLOv8 model. + override_full: Force full precision floating point calculations. + show_annotations: Display annotated images. + save_name: filename prefix for logging detections and annotated images. + """ + self.__counter = 0 + self.__show_annotations = show_annotations + self.__filename_prefix = "" + if save_name != "": + self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" + + @staticmethod + def is_contour_circular(contour: np.ndarray) -> bool: + """ + Helper function for detect_landing_pads_contours. + Checks if the shape is close to circular. + Return: True is the shape is circular, false if it is not. + """ + contour_minimum = 0.8 + perimeter = cv2.arcLength(contour, True) + # Check if the perimeter is zero + if perimeter == 0.0: + return False + + area = cv2.contourArea(contour) + circularity = 4 * np.pi * (area / (perimeter * perimeter)) + return circularity > contour_minimum + + @staticmethod + def is_contour_large_enough(contour: np.ndarray, min_diameter: float) -> bool: + """ + Helper function for detect_landing_pads_contours. + Checks if the shape is larger than the provided diameter. + Return: True if it is, false if it not. + """ + _, radius = cv2.minEnclosingCircle(contour) + diameter = radius * 2 + return diameter >= min_diameter + + def detect_landing_pads_contours( + self, image: "np.ndarray", timestamp: float + ) -> "tuple[bool, detections_and_time.DetectionsAndTime | None, np.ndarray]": + """ + Detects landing pads using contours/classical cv. + image: Current image frame. + timestamp: Timestamp for the detections. + Return: Success, the DetectionsAndTime object, and the annotated image. + """ + kernel = np.ones((2, 2), np.uint8) + gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + threshold = 180 + im_bw = cv2.threshold(gray_image, threshold, 255, cv2.THRESH_BINARY)[1] + im_dilation = cv2.dilate(im_bw, kernel, iterations=1) + contours, hierarchy = cv2.findContours(im_dilation, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + + if len(contours) == 0: + return False, None, image + + contours_with_children = set(i for i, hier in enumerate(hierarchy[0]) if hier[2] != -1) + parent_circular_contours = [ + cnt + for i, cnt in enumerate(contours) + if self.is_contour_circular(cnt) + and self.is_contour_large_enough(cnt, 7) + and i in contours_with_children + ] + + largest_contour = max(parent_circular_contours, key=cv2.contourArea, default=None) + if largest_contour is None: + return False, None, image + + # Create the DetectionsAndTime object + result, detections = detections_and_time.DetectionsAndTime.create(timestamp) + if not result: + return False, None, image + + x, y, w, h = cv2.boundingRect(largest_contour) + bounds = np.array([x, y, x + w, y + h]) + confidence = 1.0 # Confidence for classical CV is often set to a constant value + label = 0 # Label can be set to a constant or derived from some logic + + # Create a Detection object and append it to detections + result, detection = detections_and_time.Detection.create(bounds, label, confidence) + if result: + detections.append(detection) + + # Annotate the image + image_annotated = copy.deepcopy(image) + cv2.rectangle(image_annotated, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.putText( + image_annotated, + "landing-pad", + (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.9, + (0, 0, 255), + 2, + ) + + return True, detections, image_annotated + + def run( + self, data: image_and_time.ImageAndTime + ) -> "tuple[bool, detections_and_time.DetectionsAndTime | None]": + """ + Runs object detection on the provided image and returns the detections. + data: Image with a timestamp. + Return: Success and the detections. + """ + image = data.image + timestamp = data.timestamp + + result, detections, image_annotated = self.detect_landing_pads_contours(image, timestamp) + if not result: + return False, None + + # Logging + if self.__filename_prefix != "": + filename = self.__filename_prefix + str(self.__counter) + # Object detections + with open(filename + ".txt", "w", encoding="utf-8") as file: + # Use internal string representation + file.write(repr(detections)) + # Annotated image + cv2.imwrite(filename + ".png", image_annotated) # type: ignore + self.__counter += 1 + if self.__show_annotations: + cv2.imshow("Annotated", image_annotated) # type: ignore + return True, detections diff --git a/modules/detect_target/detect_target_factory.py b/modules/detect_target/detect_target_factory.py index 376456cb..a7a9cfab 100644 --- a/modules/detect_target/detect_target_factory.py +++ b/modules/detect_target/detect_target_factory.py @@ -6,6 +6,7 @@ from . import base_detect_target from . import detect_target_brightspot +from . import detect_target_contour from . import detect_target_ultralytics from ..common.modules.logger import logger @@ -17,6 +18,7 @@ class DetectTargetOption(enum.Enum): ML_ULTRALYTICS = 0 CV_BRIGHTSPOT = 1 + C_CONTOUR = 2 def create_detect_target( @@ -47,5 +49,10 @@ def create_detect_target( show_annotations, save_name, ) + case DetectTargetOption.C_CONTOUR: + return True, detect_target_contour.DetectTargetContour( + show_annotations, + save_name, + ) return False, None From 1692f6fd445106055f074f2e67c5497743853b55 Mon Sep 17 00:00:00 2001 From: Ashish Agrahari <shinyflame007@gmail.com> Date: Sat, 28 Sep 2024 19:53:06 -0400 Subject: [PATCH 02/27] Made fixes --- main_detect_target.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/main_detect_target.py b/main_detect_target.py index 3183c96f..7190386f 100644 --- a/main_detect_target.py +++ b/main_detect_target.py @@ -82,7 +82,6 @@ def main() -> int: DETECT_TARGET_DEVICE = "cpu" if args.cpu else config["detect_target"]["device"] DETECT_TARGET_MODEL_PATH = config["detect_target"]["model_path"] DETECT_TARGET_OVERRIDE_FULL_PRECISION = args.full - DETECT_TARGET_USE_CLASSICAL_CV = config["detect_target"]["use_classical_cv"] DETECT_TARGET_SAVE_NAME_PREFIX = config["detect_target"]["save_prefix"] DETECT_TARGET_SAVE_PREFIX = str(pathlib.Path(logging_path, DETECT_TARGET_SAVE_NAME_PREFIX)) DETECT_TARGET_SHOW_ANNOTATED = args.show_annotated @@ -133,7 +132,6 @@ def main() -> int: DETECT_TARGET_DEVICE, DETECT_TARGET_MODEL_PATH, DETECT_TARGET_OVERRIDE_FULL_PRECISION, - DETECT_TARGET_USE_CLASSICAL_CV, DETECT_TARGET_SHOW_ANNOTATED, DETECT_TARGET_SAVE_PREFIX, ), From dd9a29a8ab805b453a7bf3431dc51fd091819037 Mon Sep 17 00:00:00 2001 From: Zenkqi <SSGSSAchita@gmail.com> Date: Thu, 10 Oct 2024 21:28:02 -0400 Subject: [PATCH 03/27] Added test_detect_target_contour.py and edited detect_target_contour: Issues: detect_target_contour not detecting any contours(?) --- .../detect_target/detect_target_contour.py | 7 - tests/model_example/background.png | Bin 0 -> 16143 bytes tests/model_example/bounding_box1.txt | 2 + tests/model_example/test_output_1.png | Bin 0 -> 46145 bytes tests/unit/test_detect_target_contour.py | 285 ++++++++++++++++++ 5 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 tests/model_example/background.png create mode 100644 tests/model_example/bounding_box1.txt create mode 100644 tests/model_example/test_output_1.png create mode 100644 tests/unit/test_detect_target_contour.py diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index eb38a977..77c51d4a 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -14,19 +14,12 @@ class DetectTargetContour(base_detect_target.BaseDetectTarget): - """ - Contains the YOLOv8 model for prediction. - """ - def __init__( self, show_annotations: bool = False, save_name: str = "", ) -> None: """ - device: name of target device to run inference on (i.e. "cpu" or cuda device 0, 1, 2, 3). - model_path: path to the YOLOv8 model. - override_full: Force full precision floating point calculations. show_annotations: Display annotated images. save_name: filename prefix for logging detections and annotated images. """ diff --git a/tests/model_example/background.png b/tests/model_example/background.png new file mode 100644 index 0000000000000000000000000000000000000000..a6f4067d8bad97f922582742aefdba473a64d7c2 GIT binary patch literal 16143 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 literal 0 HcmV?d00001 diff --git a/tests/model_example/bounding_box1.txt b/tests/model_example/bounding_box1.txt new file mode 100644 index 00000000..35857d70 --- /dev/null +++ b/tests/model_example/bounding_box1.txt @@ -0,0 +1,2 @@ +1 0 0 0 600 600 +1 1 1000 200 1920 1124 diff --git a/tests/model_example/test_output_1.png b/tests/model_example/test_output_1.png new file mode 100644 index 0000000000000000000000000000000000000000..f37919759855825bd5a1add0a2b99b74f6bd0e46 GIT binary patch literal 46145 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 G*Z&XeH28i1 literal 0 HcmV?d00001 diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py new file mode 100644 index 00000000..dbcaa1ca --- /dev/null +++ b/tests/unit/test_detect_target_contour.py @@ -0,0 +1,285 @@ +""" +Test DetectTarget module. + +""" +import pathlib + +import cv2 +import numpy as np +import pytest +import math + +from modules.detect_target import detect_target_contour +from modules import image_and_time +from modules import detections_and_time + + +TEST_PATH = pathlib.Path("tests", "model_example") +BG_PATH = pathlib.Path(TEST_PATH, "background.png") + +BOUNDING_BOX_PRECISION_TOLERANCE = 0 +CONFIDENCE_PRECISION_TOLERANCE = 2 + + +# Test functions use test fixture signature names and access class privates +# No enable +# pylint: disable=protected-access,redefined-outer-name + + +class GenerateTest: + def __init__(self, circle_data: list[list], image_file: str, txt_file: str) -> None: + self.circle_data = circle_data[0:] + self.image_file = image_file + self.txt_file = txt_file + self.image_path = "tests/model_example/" + + def save_bounding_box_annotation(self, test_case: int, boxes_list: int) -> None: + """ + Save the bounding box annotation for the circle in the format: + format: conf class_label x_min y_min x_max y_max + """ + + txt_file = self.image_path + self.txt_file + str(test_case) + ".txt" + with open(txt_file, "w") as f: + for class_label, (top_left, bottom_right) in enumerate(boxes_list): + x_min, y_min = top_left + x_max, y_max = bottom_right + + f.write(f"{1} {class_label} {x_min} {y_min} {x_max} {y_max}\n") + print(f"Bounding box annotation saved to {txt_file}") + + def blur_img( + self, + bg: np.ndarray, + center: tuple[int, int], + radius: int = 0, + axis_length: tuple[int, int] = (0, 0), + angle: int = 0, + circle_check: bool = True, + ) -> np.ndarray: + """ + Blurs an image a singular shape and adds it to the background. + """ + + bg_copy = bg.copy() + x, y = bg_copy.shape[:2] + + mask = np.zeros((x, y), np.uint8) + if circle_check: + mask = cv2.circle(mask, center, radius, (215, 158, 115), -1, cv2.LINE_AA) + else: + mask = cv2.ellipse(mask, center, axis_length, angle, 0, 360, (215, 158, 115), -1) + + mask = cv2.blur(mask, (25, 25), 7) + + alpha = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) / 255.0 + fg = np.zeros(bg.shape, np.uint8) + fg[:, :, :] = [200, 10, 200] + + blended = cv2.convertScaleAbs(bg * (1 - alpha) + fg * alpha) + return blended + + def draw_circle( + self, image: np.ndarray, center: tuple[int, int], radius: int, blur: bool + ) -> tuple[np.ndarray, int, int]: + """ + Draws a circle on the provided image and saves the bounding box coordinates to a text file. + """ + x, y = center + top_left = (max(x - radius, 0), max(y - radius, 0)) + bottom_right = (min(x + radius, image.shape[1]), min(y + radius, image.shape[0])) + + if blur: + image = self.blur_img(image, center, radius=radius, circle_check=True) + return image, top_left, bottom_right + + cv2.circle(image, center, radius, (215, 158, 115), -1) + return image, top_left, bottom_right + + def draw_ellipse( + self, image: np.ndarray, center: tuple[int, int], axis_length: tuple, angle: int, blur: bool + ) -> tuple[np.ndarray, int, int]: + """ + Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. + """ + + (h, k), (a, b) = center, axis_length + rad = math.pi / 180 + ux, uy = a * math.cos(angle * rad), a * math.sin(angle * rad) # first point on the ellipse + vx, vy = b * math.sin(angle * rad), b * math.cos(angle * rad) + width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) + + top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) + bottom_right = ( + int(min(h + (0.5) * width, image.shape[1])), + int(min(k + (0.5) * height, image.shape[0])), + ) + + if blur: + image = self.blur_img( + image, center, axis_length=axis_length, angle=angle, circle_check=False + ) + return image, top_left, bottom_right + + image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (215, 158, 115), -1) + return image, top_left, bottom_right + + def create_test_case(self, test_case: int) -> tuple[str, str]: + """ + Genereates test cases given a data set. + """ + image = cv2.imread(self.image_file) + + boxes_list = [] + for center, radius, blur, ellipse_data in self.circle_data: + if ellipse_data[0]: + _, axis_length, angle = ellipse_data + image, top_left, bottom_right = self.draw_ellipse( + image, center, axis_length, angle, blur + ) + boxes_list.append((top_left, bottom_right)) + continue + + image, top_left, bottom_right = self.draw_circle(image, center, radius, blur) + boxes_list.append((top_left, bottom_right)) + + self.save_bounding_box_annotation(test_case, boxes_list) + + output_image_file = f"{self.image_path}test_output_{test_case}.png" + cv2.imwrite(output_image_file, image) + print(f"Image with bounding box saved as {output_image_file}") + return (output_image_file, self.image_path + self.txt_file + f"{test_case}.txt") + + +# --------------------------------------------------------------------------------------------------------------------- + + +def compare_detections( + actual: detections_and_time.DetectionsAndTime, expected: detections_and_time.DetectionsAndTime +) -> None: + """ + Compare expected and actual detections. + """ + assert len(actual.detections) == len(expected.detections) + + # Using integer indexing for both lists + # pylint: disable-next=consider-using-enumerate + for i in range(0, len(expected.detections)): + expected_detection = expected.detections[i] + actual_detection = actual.detections[i] + + assert expected_detection.label == actual_detection.label + np.testing.assert_almost_equal( + expected_detection.confidence, + actual_detection.confidence, + decimal=CONFIDENCE_PRECISION_TOLERANCE, + ) + + np.testing.assert_almost_equal( + actual_detection.x_1, + expected_detection.x_1, + decimal=BOUNDING_BOX_PRECISION_TOLERANCE, + ) + + np.testing.assert_almost_equal( + actual_detection.y_1, + expected_detection.y_1, + decimal=BOUNDING_BOX_PRECISION_TOLERANCE, + ) + + np.testing.assert_almost_equal( + actual_detection.x_2, + expected_detection.x_2, + decimal=BOUNDING_BOX_PRECISION_TOLERANCE, + ) + + np.testing.assert_almost_equal( + actual_detection.y_2, + expected_detection.y_2, + decimal=BOUNDING_BOX_PRECISION_TOLERANCE, + ) + + +def create_detections(detections_from_file: np.ndarray) -> detections_and_time.DetectionsAndTime: + """ + Create DetectionsAndTime from expected. + Format: [confidence, label, x_1, y_1, x_2, y_2] . + """ + assert detections_from_file.shape[1] == 6 + + result, detections = detections_and_time.DetectionsAndTime.create(0) + assert result + assert detections is not None + + for i in range(0, detections_from_file.shape[0]): + result, detection = detections_and_time.Detection.create( + detections_from_file[i][2:], + int(detections_from_file[i][1]), + detections_from_file[i][0], + ) + assert result + assert detection is not None + detections.append(detection) + + return detections + + +# ------------------------------------------------------------------------------------------------------------------ +@pytest.fixture() +def detector() -> detect_target_contour.DetectTargetContour: # type: ignore + """ + Construct DetectTargetUltralytics. + """ + detection = detect_target_contour.DetectTargetContour() + yield detection # type: ignore + + +# --------------------------------------------------------------------------- +class TestDetector: + """ + Tests `DetectTarget.run()` . + """ + + def test_multiple_landing_pads( + self, + detector: detect_target_contour.DetectTargetContour, + ) -> None: + """ + Multiple images. + """ + + circle_data = [ + [(200, 200), 400, False, [False, None, None]], + [(1500, 700), 500, False, [False, None, None]], + ] + + actual_detections, expected_detections = [], [] + circle_list = [circle_data] + + for i, circle_data in enumerate(circle_list): + generate_test = GenerateTest(circle_data, BG_PATH, "bounding_box") + image_file, txt_file = generate_test.create_test_case(i + 1) + image = cv2.imread(image_file, 1) + + result, actual = image_and_time.ImageAndTime.create(image) + assert result + assert actual is not None + + expected = create_detections(np.loadtxt(txt_file)) + actual_detections.append(actual) + expected_detections.append(expected) + + # Run + outputs = [] + for i in range(0, len(circle_list)): + output = detector.run(actual_detections[i]) + outputs.append(output) + + print(outputs) + # Test + for i in range(0, len(outputs)): + output: "tuple[bool, detections_and_time.DetectionsAndTime | None]" = outputs[i] + result, actual = output + + print(actual) + compare_detections(actual, expected_detections[i]) From bf8faf19f5b2aece0fae35b180e3fd55b1d4daac Mon Sep 17 00:00:00 2001 From: Zenkqi <SSGSSAchita@gmail.com> Date: Thu, 10 Oct 2024 23:48:37 -0400 Subject: [PATCH 04/27] linters: reformatting code --- tests/unit/test_detect_target_contour.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index dbcaa1ca..59471e97 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -2,6 +2,7 @@ Test DetectTarget module. """ + import pathlib import cv2 From 1a19e20be1ae3c34327b6c3c271d31d42af75739 Mon Sep 17 00:00:00 2001 From: Zenkqi <SSGSSAchita@gmail.com> Date: Mon, 14 Oct 2024 15:36:42 -0400 Subject: [PATCH 05/27] Added corrections(?): Class -> Functions (Some sort of issue with detect_target)contour, extra unit tests added after) --- tests/model_example/background.png | Bin 16143 -> 0 bytes tests/model_example/bounding_box1.txt | 2 - tests/model_example/test_output_1.png | Bin 46145 -> 0 bytes tests/unit/test_detect_target_contour.py | 269 ++++++++++------------- 4 files changed, 117 insertions(+), 154 deletions(-) delete mode 100644 tests/model_example/background.png delete mode 100644 tests/model_example/bounding_box1.txt delete mode 100644 tests/model_example/test_output_1.png diff --git a/tests/model_example/background.png b/tests/model_example/background.png deleted file mode 100644 index a6f4067d8bad97f922582742aefdba473a64d7c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16143 zcmeHO2|Scr*nelS+z^A5HL|AcyT~wd3xyV?6xCca-6CsImO<LC5VF&da<wW&sm79Y zr7WQmSwhB^wUi~_AR3w(^A6qb_Wiz>-*3)2&-47B=bZDL=Q-!RGo!CZs{s_iL3$Q) zEac)^$jisW$15Vp&o3w<C%TOECub<DEKB+~)LFMmW7RsH16xc?wjA)<w|Adc<cSlJ zggb;UUq;73Aae%E0}!lmGz$&}K>$z$1crc&<^zHN<l6&GJ`gAj&cw_@A}*OAO%ldF z$B3hE0CpGzfO5gONG)IE$mw$}!{mkB>dHbUVQgPdmgeKL5I-L%U@=$&zNu9_09Ldw z{tQJCe%0^IEHb?}2sz*Dpi0B713|c1C7`^svM|oTY7S0T3<-`O01*GQgO!0X=TOly z(_E;~-iK6JCz`qNG|5v$@ckMSr5All&3~ovQ+?_ubrP}w1Te+<q~2>v6Ip0Xx4r<n zImZ~8*MnmJe`c^F@Z&tsj2jb(lx7{QE=v4#PVo2iO4&S0&~s~ZS~UlGEqEJ#4&pRL zS$I39V`~cM&=epMAz{ebSjOq>F?6w!n7}P#dQtzId_S&{+3keO%+5pN7=vaJ|C=se zMwkq6j7U+Jan_~_kcPi4S&pX;KNrf(2*tvaHVrO!dUYCYo-1>T9b~pZ{B6l<)QT(; z6TwF7$wU^O%I7mj<Ocvy+k|P-LK||X)n`<|1?eT~W8#x$(-F>QXq(UKw|qp$2?&P= ztruN7CIXYf{kpqh(0wKexM(vX_3z7!M=d%7H$&FZ;Z=0v{=D=HV(BuH7wPQZ1e;bo zD0Z66^PLH4DjO#<9$jb0{@><+G3Wf-CeZLU_84XCT+A7Rp1-v@7fVR8H2O)7{BQVk z{b6#A|0l{5DVWZ#&^Z;<(7qp9sd0dB=VIiBb6U1wf!(F578X6eLz$wp3w|peQ=Mx2 zR+!t0n~65FPW?<YWztNM@j7{2n%q#5F@l<XGC5L_CH&*`hF_^R%>fuNe8w#eq*)iq z01EvgFy_<a%rqAu%(sRT;?H$VN*ckpQ867n(1ZkMjF%{xEcr_pvSzX(RHztFR-Lcn zv=#UB64VTDoRjq+y=7`4(Q{KacF<$wS@^p-7%`fQ1Pl-vFFoBOW2aP{6U2;D$Q)-J z@{9-o?_DWb38wAac4&re;|K-<V1Rz$rL<yM!MwvELxengEkjmixH!-2_b0y6Cf)3b zh1oLP(O*&!;t#&V$Z27P`tg)H<=aq!44S{>0M9gfT5g+3WTZ~tf`CAfbby1*7_%Zy z#-r(;K;u?_R>U!eZ)nu6rP1$>&;>(@X);(r*kdV?Np0whlLW|nUD3%h$j>nlO5q<2 z%+GlbF*yKWEcvx~sksgHUQBJ)sIve7Tns->o_X}8D8oq9r3mydH~xpcW5Tp~kFJyl z7{dYjn{7V;Q4JsTFKp1grFH4C5QCg%BWUnx(ljg7-u}*Mt4~uSE#ma#M1d3BNI?E+ zBVn*}Yk%J*(VkJwcpL?#6q+C{y5p@gZ=Yt#B(SGPeo!#+s+58b@}_r($ZK;cv#1#1 z<|0qZmgxEVV(hdSmYXzY7o{T-D+)=M#k2bCiSUnOI%Yica;MAa#lQ?(C{-se17`6o zN`in;F^*#Uyf@B6??FP0Pa5?r<$3QRqk!PIIE-o{lbg1#$l8Byabj;qGeWa^hPC6U zTR}TGkIC^rK1&Fp4!|H~a&0-c9Ntdx0(t^hl)+0FEn(yYz_;Bi)qq25PrzW;z>58h z?@9#dwBW(o@tvRcpwI|$lPBKqP#{aQ?ncJ(I7vTNk>vuQ0E`9744YH|K|sYExs~*7 zaUu9+<^W95Vs{#x$BFdw6;=oYfapO}i}vp~_k%?jNp*1{#l8-e9W3;#JIDRNEa&cl ztE_4{1}x&PU+zZ+ER|%b=M#1W*iK;7qr&fQ2+eTN6X2CwTe6m2wZ$yz8S0fJ2U|#+ zeuom&;gn;c;LhNir=^lD8g5h+DL)b~F;x(~CRo*Wr#mALD$S#uM~Hi<?scaMk?ysj zPwcc<Id{U*#XE~@xc1qyDx`TlKLD_mnyO#=nhx0)8zJDbsr0UUM_@}Eb4tuwkE;!` zJeB*Istau5+W0T}TXUh%{24Z`NNe@gE;}UGzs+qg8hL10pj;7p<!_T)jp;Q?8j`xf z+2g}Q>M{(<1c$;%ogaH32r(r`TO6QoZn0Yt=^G;I#B~;*cynra#0qm3Ju5ZstExyS zjk*rC`XJgR#2)rCdLY+aDgTO_=SrU?t2IY~s6oqc=I4%r%3B*P-<UeTst8Vj#?}$; zC^<u=(%fF(8U=hMV(c}S9rPGb@j6wWfhH`C^6N&c;L;$C7hdOGvdW0Po)Ws(a*0&7 z^<)0`x#_97D4jd1maY<s#~$#M?D^yG;AKZ-YGVcYA#72DaEXXFCk-n<<_ZiSg>B7$ z#csaqWSxWz-{OzCrJ@oWH;)3(FO}S|Dz{_KR<IuQ3b)kovL2F=3a*m4wej4I&&Zwk zRfbt2KHtg~EQ>#O;(6q$A(N}s3Oejze<6)hla{IKK5_6`Qfi=A>w)yEeFJB@Pi#8h zRV!_QIjiX`)cRqQx6<jlIwSF?Nr(f3A-3lQ+#hUs{)tskLQZ1@T^|r_Bh0K?<REn1 zFv5ksV-0hQPOwD&OU3Y3VxEEeE6YLo#ZqC*IlKCxMozm!&PTZMHl{3i>FEPg3X*uL z6368r{MAR<V+6?>s&d<E=#jhHV%s<Nrb#zD#gmlz;+$aK57NvK@0T<mH@c>K%eT>& z+r+IuRQJVZgWDs!(IU&_0wIw~)$OioqMtt@1FXt>O?}R-v9{`McQ*aAHD|51c(mNL zqCNc!qzm3Ahsi8r-6s;O_=H!ew!`pjypVxN<H(=4Bl-8l-PKNGIwQ%wKIU-3IlVw! zq*?xD@d#9?Xu-;j>{7e!mh8{PA9vGJG0w$E-OwfN>JKSp9dBrGjEZ`Fc`v;0x>Z*j zd-GY3x2^}O7P!}>n}|moD${MhWU0wt6xLDYh)6QotrmVP;;8Gcf0AvG4+uf{O1~Aw zS`DqzMlTQc+|eWXstniI`h>wDaFVZ%J1fao0VP|czPY2KsKst4E}ZY#ki;oZ<@zM4 zkQZ~x`Qx&RSmhy&+fgPx8!q{*NxiK6-gTc_2Jp%*w6T-8{Oh7x!Am&*V+8CW$rS50 zZpoJhn2e78v<wz|J@qPUdCj(ii(59Kh!U!(vzdYiyHiwma;QgpgcD2U@ErLay{Sh+ zxX_vGZ*R00DSlaQ(A={QFSPZrTen9~>QnvAFFDWfZqj`S&k}U1b&@uae3Bx~3#(l2 zD*o*Hwbt~`@QMVC{jNizkKXqBIkKz)?5faxY*O+;OwwVQ`Zb++cLz`Iwalv}Ue-K( z0a1i|$n`s|X~N*8`<cqzx5UfEmD#30(Y@_~TVSRAVwkB!A}#=3_D{UPxh<jhykDg< z@g`??`9DeDYv28c&kN;^ovkCMnU3!~U@!_)*LePx9h_j?fB1}ciNH27OM)=KT^034 z_(QN(#*OXf_5}Z=fOq?oP8W(;5>Z!U!oorZjZ{WD1$*oGWAj%HG}Q~T<caSCWMAzO zP!{Q0e|NK0(Ocn74#)3asN>gCeXO~<qy8+GnT^91pLXd>_n-THF1xu@M40ie^EKk= zGqzzfv=AOaEnKRzqaUcicMu2`d8PO4tOombH-s5GuW-3O3aBI_w=MF~Pvr7;3M@`& z`QqwiD8{4}*v;p)_Z(u6@*&JZSC{xWCcmsAxi2a&1R%;Vl;`cG75Tq~dGxKq9=si+ zvp~;FWM`MKB_~QWfPLR*bjq`~zq~Vf%GtW$h>GfF&J7yAnKtLP?w1@M1rElY8u+{u zPUzi#Ex{09deon-L)OOK;D+*ZJv?hP_HyXDAwofdtk69|l&kgm&!N!8M^)V~TogfU zFz*J!<myY6<TTz`6D#D6<1&Y}?l+>d;hFYfN52p%MgdJJ_vE5NM}uf@b>hd7m<u|@ zM?#K!v{W?89xFZB^m}qjR)pALjE2MTd-ptPw4mpOE7=?yH1QAp4VO9*le--eGPchm zGX1r^^HypM2mRF`ysAn$dRQgdVRzD_f|Zr)qE38rOl&Ah{9+UB`3Ads$?$g0ti`y4 zS9bfI|M<3{U_tUi{!EO1r9WR;%Gum4>rY}k%SupL@^U?ve1T=vfihSkqW@t7&km1^ z@%=hCe_N_Q3dE>)H>?#K#u5-^PTu(;XO7lp4SD^>weg~NWofyCA2yBe^T3*tZ5)1~ ztg&xu>J-li^d!Hmc~P<>OUM4tt6M^kuR!&fS(}~l(Na-<?am_^hk-X2oV(*!9GSkj zM$xAZnwXfPbQIfo(tSyYtw~(06t2m|uQuz$rggPe7;W=KvD=A;54B8FfOyv(M>4lQ zlMhZk(yxk2al69ngVBaz&Yy^kOH$k4&a`i+`%2m3@Ej{oZM))HDUTtY_GIgg#^Q(1 z-?;poO>^^;hJ-8Yl^4ib{Bb{fRnkE0=jxB^f4>eL6nXEVv4BlgAzz^qmRGFP&LbsP zTU%}-%w-YU!g(Ez@xVl`U)LJ9^m@GEr@)>vrj>$v>1_wl>D!L++1Y#D_iA9vJJhVc zFOA7mL!76y@C+}TLV#SSG|YAH>41$LCBv+nP$f^T2U>R&3^Wz3dKVY2^rkKGacfV3 zedSiy$gq}U=em(tS8P)A(m<H5iGfT9wwEpOeQ|I+94hWg`gO2}*ViO;y|A_DrENWH zu?x3n<yi<1X?B*&TSB(va#WTz@meVBG4(dZpNs)usrY};b?3wM%Yl>2*4<Nuzzd{v zmL`Zx^Brw##y&fwTacvcq}KmV$bxfob&*|PLy9)c!6<F#uBJ80KK;at^4D$mG_q6& zXU9aiboQg78fy#Pdx-<SnpmwbeItnHy|P%3QNUibkXN8~;CV9lW}BKi)UcTlu{E{9 z_x!^Izf&mv+}2?gjN7dp4Jj8>1bxsd;n{z+IGF9#*8DUg0Xd9WWOc#8ZEf*8{cOyG z=&uTo9+>bSdU*X>fUn(4JJ+x_HytKZ^aT+i`H-AVva#5&PL289#X$k)YbsXBIXrpl zYF$4l{aE_3UFrZ4z%8m~a;bkS=>9J2NnV%TeKTG^?}o%dfEUSU+|yL5ZY;5EuiCwe zkasUUU2d$_y7nM#?Y(2nK0yKf%`utHhstG+-K-g2oxPKTMc952Dud$16Oq{~F05X^ z2a7xzR9M&sxAT;!klubggSb<E4NP@QREOutu(&Xf5mMCK|7F`E=SSJ)UaweM&vSb9 zR6NM@xyxR(a>#}ab5&t0$M6aj6{+BNgBhXXYAu?I$A=?5qz*?qJDBb2yh6+%HaXxF zTKuiL;NHfAF>z9HvI;`!zhALr(lkYEj$~GJ3RzUvS=r!X-tTF?EAaFRDI6*>CDKSq z4SDMsA<XDX{WU~T&}moWSfUocLwQfmracZjJ`Eos9J!fozgVQkUai{9oBP88Soh!& zq}4XBPj?$`^LEB+4885Q??;sot{%lU*^n}+C}J#+LZ`OGr0+pO5RL$hTMS>y6OuNb zL7@<c9uT%pSm2sfPgfc2dHdRUtG>fptBL}hy-p@LF4M>BT<T;0dkvGCMkf2%R#J%c u`*A2aCTS}PI6_14IrZa!q@=9}qz9SGom~Bawa+t0)8KxMWKY&;)&Br;K59__ diff --git a/tests/model_example/bounding_box1.txt b/tests/model_example/bounding_box1.txt deleted file mode 100644 index 35857d70..00000000 --- a/tests/model_example/bounding_box1.txt +++ /dev/null @@ -1,2 +0,0 @@ -1 0 0 0 600 600 -1 1 1000 200 1920 1124 diff --git a/tests/model_example/test_output_1.png b/tests/model_example/test_output_1.png deleted file mode 100644 index f37919759855825bd5a1add0a2b99b74f6bd0e46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46145 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 G*Z&XeH28i1 diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 59471e97..ccdbb40d 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -3,156 +3,119 @@ """ -import pathlib - +import math import cv2 import numpy as np import pytest -import math from modules.detect_target import detect_target_contour from modules import image_and_time from modules import detections_and_time - -TEST_PATH = pathlib.Path("tests", "model_example") -BG_PATH = pathlib.Path(TEST_PATH, "background.png") - BOUNDING_BOX_PRECISION_TOLERANCE = 0 CONFIDENCE_PRECISION_TOLERANCE = 2 - # Test functions use test fixture signature names and access class privates # No enable # pylint: disable=protected-access,redefined-outer-name -class GenerateTest: - def __init__(self, circle_data: list[list], image_file: str, txt_file: str) -> None: - self.circle_data = circle_data[0:] - self.image_file = image_file - self.txt_file = txt_file - self.image_path = "tests/model_example/" - - def save_bounding_box_annotation(self, test_case: int, boxes_list: int) -> None: - """ - Save the bounding box annotation for the circle in the format: - format: conf class_label x_min y_min x_max y_max - """ - - txt_file = self.image_path + self.txt_file + str(test_case) + ".txt" - with open(txt_file, "w") as f: - for class_label, (top_left, bottom_right) in enumerate(boxes_list): - x_min, y_min = top_left - x_max, y_max = bottom_right - - f.write(f"{1} {class_label} {x_min} {y_min} {x_max} {y_max}\n") - print(f"Bounding box annotation saved to {txt_file}") - - def blur_img( - self, - bg: np.ndarray, - center: tuple[int, int], - radius: int = 0, - axis_length: tuple[int, int] = (0, 0), - angle: int = 0, - circle_check: bool = True, - ) -> np.ndarray: - """ - Blurs an image a singular shape and adds it to the background. - """ +def blur_img( + bg: np.ndarray, + center: tuple[int, int], + radius: int = 0, + axis_length: tuple[int, int] = (0, 0), + angle: int = 0, + circle_check: bool = True, +) -> np.ndarray: + """ + Blurs an image a singular shape and adds it to the background. + """ - bg_copy = bg.copy() - x, y = bg_copy.shape[:2] + bg_copy = bg.copy() + x, y = bg_copy.shape[:2] - mask = np.zeros((x, y), np.uint8) - if circle_check: - mask = cv2.circle(mask, center, radius, (215, 158, 115), -1, cv2.LINE_AA) - else: - mask = cv2.ellipse(mask, center, axis_length, angle, 0, 360, (215, 158, 115), -1) + mask = np.zeros((x, y), np.uint8) + if circle_check: + mask = cv2.circle(mask, center, radius, (215, 158, 115), -1, cv2.LINE_AA) + else: + mask = cv2.ellipse(mask, center, axis_length, angle, 0, 360, (215, 158, 115), -1) - mask = cv2.blur(mask, (25, 25), 7) + mask = cv2.blur(mask, (25, 25), 7) - alpha = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) / 255.0 - fg = np.zeros(bg.shape, np.uint8) - fg[:, :, :] = [200, 10, 200] + alpha = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) / 255.0 + fg = np.zeros(bg.shape, np.uint8) + fg[:, :, :] = [200, 10, 200] - blended = cv2.convertScaleAbs(bg * (1 - alpha) + fg * alpha) - return blended + blended = cv2.convertScaleAbs(bg * (1 - alpha) + fg * alpha) + return blended - def draw_circle( - self, image: np.ndarray, center: tuple[int, int], radius: int, blur: bool - ) -> tuple[np.ndarray, int, int]: - """ - Draws a circle on the provided image and saves the bounding box coordinates to a text file. - """ - x, y = center - top_left = (max(x - radius, 0), max(y - radius, 0)) - bottom_right = (min(x + radius, image.shape[1]), min(y + radius, image.shape[0])) - if blur: - image = self.blur_img(image, center, radius=radius, circle_check=True) - return image, top_left, bottom_right +def draw_circle( + image: np.ndarray, center: tuple[int, int], radius: int, blur: bool +) -> tuple[np.ndarray, int, int]: + """ + Draws a circle on the provided image and saves the bounding box coordinates to a text file. + """ + x, y = center + top_left = (max(x - radius, 0), max(y - radius, 0)) + bottom_right = (min(x + radius, image.shape[1]), min(y + radius, image.shape[0])) - cv2.circle(image, center, radius, (215, 158, 115), -1) + if blur: + image = blur_img(image, center, radius=radius, circle_check=True) return image, top_left, bottom_right - def draw_ellipse( - self, image: np.ndarray, center: tuple[int, int], axis_length: tuple, angle: int, blur: bool - ) -> tuple[np.ndarray, int, int]: - """ - Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. - """ + cv2.circle(image, center, radius, (215, 158, 115), -1) + return image, top_left, bottom_right - (h, k), (a, b) = center, axis_length - rad = math.pi / 180 - ux, uy = a * math.cos(angle * rad), a * math.sin(angle * rad) # first point on the ellipse - vx, vy = b * math.sin(angle * rad), b * math.cos(angle * rad) - width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) - - top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) - bottom_right = ( - int(min(h + (0.5) * width, image.shape[1])), - int(min(k + (0.5) * height, image.shape[0])), - ) - if blur: - image = self.blur_img( - image, center, axis_length=axis_length, angle=angle, circle_check=False - ) - return image, top_left, bottom_right - - image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (215, 158, 115), -1) +def draw_ellipse( + image: np.ndarray, center: tuple[int, int], axis_length: tuple, angle: int, blur: bool +) -> tuple[np.ndarray, int, int]: + """ + Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. + """ + (h, k), (a, b) = center, axis_length + rad = math.pi / 180 + ux, uy = a * math.cos(angle * rad), a * math.sin(angle * rad) # first point on the ellipse + vx, vy = b * math.sin(angle * rad), b * math.cos(angle * rad) + width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) + + top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) + bottom_right = ( + int(min(h + (0.5) * width, image.shape[1])), + int(min(k + (0.5) * height, image.shape[0])), + ) + + if blur: + image = blur_img(image, center, axis_length=axis_length, angle=angle, circle_check=False) return image, top_left, bottom_right - def create_test_case(self, test_case: int) -> tuple[str, str]: - """ - Genereates test cases given a data set. - """ - image = cv2.imread(self.image_file) + image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (215, 158, 115), -1) + return image, top_left, bottom_right - boxes_list = [] - for center, radius, blur, ellipse_data in self.circle_data: - if ellipse_data[0]: - _, axis_length, angle = ellipse_data - image, top_left, bottom_right = self.draw_ellipse( - image, center, axis_length, angle, blur - ) - boxes_list.append((top_left, bottom_right)) - continue - image, top_left, bottom_right = self.draw_circle(image, center, radius, blur) - boxes_list.append((top_left, bottom_right)) - - self.save_bounding_box_annotation(test_case, boxes_list) +def create_test_case( + circle_data: list[tuple[int, int], int, bool, list[bool, tuple[int, int] | None, int | None]] +) -> tuple[np.ndarray, np.ndarray]: + """ + Genereates test cases given a data set. + """ + image = np.zeros(shape=(800, 1800, 3), dtype=np.int16) - output_image_file = f"{self.image_path}test_output_{test_case}.png" - cv2.imwrite(output_image_file, image) - print(f"Image with bounding box saved as {output_image_file}") - return (output_image_file, self.image_path + self.txt_file + f"{test_case}.txt") + boxes_list = [] + for center, radius, blur, ellipse_data in circle_data: + if ellipse_data[0]: + _, axis_length, angle = ellipse_data + image, top_left, bottom_right = draw_ellipse(image, center, axis_length, angle, blur) + boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) + continue + image, top_left, bottom_right = draw_circle(image, center, radius, blur) + boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) -# --------------------------------------------------------------------------------------------------------------------- + boxes_list = np.array(boxes_list) + return (image.astype(np.uint8), boxes_list) def compare_detections( @@ -225,62 +188,64 @@ def create_detections(detections_from_file: np.ndarray) -> detections_and_time.D return detections -# ------------------------------------------------------------------------------------------------------------------ @pytest.fixture() def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ - Construct DetectTargetUltralytics. + Construct DetectTargetContour. """ detection = detect_target_contour.DetectTargetContour() yield detection # type: ignore -# --------------------------------------------------------------------------- +@pytest.fixture() +def image_easy() -> image_and_time.ImageAndTime: # type: ignore + """ + Load easy image. + """ + + circle_data = [[(1000, 400), 200, False, [False, None, None]]] + + image = create_test_case(circle_data) + result, actual_image = image_and_time.ImageAndTime.create(image[0]) + print((result, actual_image)) + assert result + assert actual_image is not None + yield actual_image # type: ignore + + +@pytest.fixture() +def expected_easy() -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected an easy image detections. + """ + + circle_data = [ + [(500, 1000), 400, False, [False, None, None]], + ] + + _, expected = create_test_case(circle_data) + yield create_detections(expected) # type: ignore + + class TestDetector: """ Tests `DetectTarget.run()` . """ - def test_multiple_landing_pads( + def test_single_circle( self, detector: detect_target_contour.DetectTargetContour, + image_easy: image_and_time.ImageAndTime, + expected_easy: detections_and_time.DetectionsAndTime, ) -> None: """ - Multiple images. + Bus image. """ - - circle_data = [ - [(200, 200), 400, False, [False, None, None]], - [(1500, 700), 500, False, [False, None, None]], - ] - - actual_detections, expected_detections = [], [] - circle_list = [circle_data] - - for i, circle_data in enumerate(circle_list): - generate_test = GenerateTest(circle_data, BG_PATH, "bounding_box") - image_file, txt_file = generate_test.create_test_case(i + 1) - image = cv2.imread(image_file, 1) - - result, actual = image_and_time.ImageAndTime.create(image) - assert result - assert actual is not None - - expected = create_detections(np.loadtxt(txt_file)) - actual_detections.append(actual) - expected_detections.append(expected) - # Run - outputs = [] - for i in range(0, len(circle_list)): - output = detector.run(actual_detections[i]) - outputs.append(output) + result, actual = detector.run(image_easy) - print(outputs) # Test - for i in range(0, len(outputs)): - output: "tuple[bool, detections_and_time.DetectionsAndTime | None]" = outputs[i] - result, actual = output + assert result + assert actual is not None - print(actual) - compare_detections(actual, expected_detections[i]) + compare_detections(actual, expected_easy) From c609f60d779a5b56c003fa97d0e1bf53d9e09cc5 Mon Sep 17 00:00:00 2001 From: Zenkqi <SSGSSAchita@gmail.com> Date: Sun, 10 Nov 2024 15:06:58 -0500 Subject: [PATCH 06/27] Updated code for the test (forgot to push earlier) --- tests/unit/test_detect_target_contour.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index ccdbb40d..be4f848f 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -45,7 +45,7 @@ def blur_img( alpha = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) / 255.0 fg = np.zeros(bg.shape, np.uint8) - fg[:, :, :] = [200, 10, 200] + fg[:, :, :] = [0, 0, 0] blended = cv2.convertScaleAbs(bg * (1 - alpha) + fg * alpha) return blended @@ -91,7 +91,7 @@ def draw_ellipse( image = blur_img(image, center, axis_length=axis_length, angle=angle, circle_check=False) return image, top_left, bottom_right - image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (215, 158, 115), -1) + image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (0, 0, 0), -1) return image, top_left, bottom_right @@ -101,18 +101,18 @@ def create_test_case( """ Genereates test cases given a data set. """ - image = np.zeros(shape=(800, 1800, 3), dtype=np.int16) + image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) boxes_list = [] for center, radius, blur, ellipse_data in circle_data: if ellipse_data[0]: _, axis_length, angle = ellipse_data image, top_left, bottom_right = draw_ellipse(image, center, axis_length, angle, blur) - boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) + boxes_list.append([1, 0] + list(top_left + bottom_right)) continue image, top_left, bottom_right = draw_circle(image, center, radius, blur) - boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) + boxes_list.append([1, 0] + list(top_left + bottom_right)) boxes_list = np.array(boxes_list) return (image.astype(np.uint8), boxes_list) @@ -193,7 +193,7 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ Construct DetectTargetContour. """ - detection = detect_target_contour.DetectTargetContour() + detection = detect_target_contour.DetectTargetContour(True, "bob") yield detection # type: ignore @@ -203,10 +203,10 @@ def image_easy() -> image_and_time.ImageAndTime: # type: ignore Load easy image. """ - circle_data = [[(1000, 400), 200, False, [False, None, None]]] + circle_data = [[(900, 500), 400, False, [False, None, None]]] - image = create_test_case(circle_data) - result, actual_image = image_and_time.ImageAndTime.create(image[0]) + image, _ = create_test_case(circle_data) + result, actual_image = image_and_time.ImageAndTime.create(image) print((result, actual_image)) assert result assert actual_image is not None @@ -220,7 +220,7 @@ def expected_easy() -> image_and_time.ImageAndTime: # type: ignore """ circle_data = [ - [(500, 1000), 400, False, [False, None, None]], + [(1000, 400), 200, False, [False, None, None]], ] _, expected = create_test_case(circle_data) From 15dff4e57f55510dd2f35b76e310229e4d52d7af Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 21 Nov 2024 15:58:51 -0500 Subject: [PATCH 07/27] Detect Target Contour working and check now detect all circular contours + Working Tests --- .../detect_target/detect_target_contour.py | 49 ++--- tests/unit/test_detect_target_contour.py | 185 ++++++++++++++++-- 2 files changed, 193 insertions(+), 41 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 77c51d4a..750b18a0 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -76,7 +76,7 @@ def detect_landing_pads_contours( if len(contours) == 0: return False, None, image - contours_with_children = set(i for i, hier in enumerate(hierarchy[0]) if hier[2] != -1) + contours_with_children = set(i for i, hier in enumerate(hierarchy[0]) if hier[3] != -1) parent_circular_contours = [ cnt for i, cnt in enumerate(contours) @@ -85,8 +85,7 @@ def detect_landing_pads_contours( and i in contours_with_children ] - largest_contour = max(parent_circular_contours, key=cv2.contourArea, default=None) - if largest_contour is None: + if not len(parent_circular_contours): return False, None, image # Create the DetectionsAndTime object @@ -94,28 +93,30 @@ def detect_landing_pads_contours( if not result: return False, None, image - x, y, w, h = cv2.boundingRect(largest_contour) - bounds = np.array([x, y, x + w, y + h]) - confidence = 1.0 # Confidence for classical CV is often set to a constant value - label = 0 # Label can be set to a constant or derived from some logic - - # Create a Detection object and append it to detections - result, detection = detections_and_time.Detection.create(bounds, label, confidence) - if result: - detections.append(detection) - - # Annotate the image + sorted_contour = sorted(parent_circular_contours, key=cv2.contourArea, reverse=True) image_annotated = copy.deepcopy(image) - cv2.rectangle(image_annotated, (x, y), (x + w, y + h), (0, 0, 255), 2) - cv2.putText( - image_annotated, - "landing-pad", - (x, y - 10), - cv2.FONT_HERSHEY_SIMPLEX, - 0.9, - (0, 0, 255), - 2, - ) + for i, contour in enumerate(sorted_contour): + x, y, w, h = cv2.boundingRect(contour) + bounds = np.array([x, y, x + w, y + h]) + confidence = 1.0 # Confidence for classical CV is often set to a constant value + label = 0 # Label can be set to a constant or derived from some logic + + # Create a Detection object and append it to detections + result, detection = detections_and_time.Detection.create(bounds, label, confidence) + if result: + detections.append(detection) + + # Annotate the image + cv2.rectangle(image_annotated, (x, y), (x + w, y + h), (0, 0, 255), 2) + cv2.putText( + image_annotated, + f"landing-pad {i+1}", + (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.9, + (0, 0, 255), + 2 + ) return True, detections, image_annotated diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index be4f848f..de82ac9b 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -8,11 +8,12 @@ import numpy as np import pytest +from typing import NamedTuple from modules.detect_target import detect_target_contour from modules import image_and_time from modules import detections_and_time -BOUNDING_BOX_PRECISION_TOLERANCE = 0 +BOUNDING_BOX_PRECISION_TOLERANCE = -2 / 3 CONFIDENCE_PRECISION_TOLERANCE = 2 # Test functions use test fixture signature names and access class privates @@ -20,6 +21,30 @@ # pylint: disable=protected-access,redefined-outer-name +class LandingPadData(NamedTuple): + center: tuple[int, int] + radius: int | tuple[int, int] + blur: bool = False + elipse: bool = False + angle: int = 0 + + +easy_data = [LandingPadData(center=(1000, 400), radius=200)] + +blurry_data = [ + LandingPadData(center=(1000, 500), radius=423, blur=True), +] + +stretched_data = [LandingPadData(center=(1000, 500), radius=(383, 405), elipse=True)] + +multiple_data = [ + LandingPadData(center=(200, 500), radius=(50, 45), blur=True, elipse=True), + LandingPadData(center=(1590, 341), radius=250), + LandingPadData(center=(997, 600), radius=300), + LandingPadData(center=(401, 307), radius=(200, 150), blur=True, elipse=True), +] + + def blur_img( bg: np.ndarray, center: tuple[int, int], @@ -95,29 +120,39 @@ def draw_ellipse( return image, top_left, bottom_right -def create_test_case( - circle_data: list[tuple[int, int], int, bool, list[bool, tuple[int, int] | None, int | None]] -) -> tuple[np.ndarray, np.ndarray]: +def create_test_case(landing_list: list[LandingPadData]) -> tuple[np.ndarray, np.ndarray]: """ Genereates test cases given a data set. """ image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) boxes_list = [] - for center, radius, blur, ellipse_data in circle_data: - if ellipse_data[0]: - _, axis_length, angle = ellipse_data - image, top_left, bottom_right = draw_ellipse(image, center, axis_length, angle, blur) - boxes_list.append([1, 0] + list(top_left + bottom_right)) + for landing_data in landing_list: + if landing_data.elipse: + axis_length, angle = landing_data.radius, landing_data.angle + image, top_left, bottom_right = draw_ellipse( + image, landing_data.center, axis_length, angle, landing_data.blur + ) + boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) continue - image, top_left, bottom_right = draw_circle(image, center, radius, blur) + image, top_left, bottom_right = draw_circle( + image, landing_data.center, landing_data.radius, landing_data.blur + ) boxes_list.append([1, 0] + list(top_left + bottom_right)) + boxes_list = sorted( + boxes_list, + reverse=True, + key=lambda boxes_list: abs( + (boxes_list[4] - boxes_list[2]) * (boxes_list[5] - boxes_list[3]) + ), + ) boxes_list = np.array(boxes_list) return (image.astype(np.uint8), boxes_list) +# pylint:disable=duplicate-code def compare_detections( actual: detections_and_time.DetectionsAndTime, expected: detections_and_time.DetectionsAndTime ) -> None: @@ -203,11 +238,47 @@ def image_easy() -> image_and_time.ImageAndTime: # type: ignore Load easy image. """ - circle_data = [[(900, 500), 400, False, [False, None, None]]] + image, _ = create_test_case(easy_data) + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore + + +@pytest.fixture() +def blurry_image() -> image_and_time.ImageAndTime: # type: ignore + """ + Load easy image. + """ + + image, _ = create_test_case(blurry_data) + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore + + +@pytest.fixture() +def stretched_image() -> image_and_time.ImageAndTime: # type: ignore + """ + Load easy image. + """ + + image, _ = create_test_case(stretched_data) + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore + + +@pytest.fixture() +def multiple_images() -> image_and_time.ImageAndTime: # type: ignore + """ + Load easy image. + """ - image, _ = create_test_case(circle_data) + image, _ = create_test_case(multiple_data) result, actual_image = image_and_time.ImageAndTime.create(image) - print((result, actual_image)) assert result assert actual_image is not None yield actual_image # type: ignore @@ -219,11 +290,37 @@ def expected_easy() -> image_and_time.ImageAndTime: # type: ignore Load expected an easy image detections. """ - circle_data = [ - [(1000, 400), 200, False, [False, None, None]], - ] + _, expected = create_test_case(easy_data) + yield create_detections(expected) # type: ignore + - _, expected = create_test_case(circle_data) +@pytest.fixture() +def expected_blur() -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected an easy image detections. + """ + + _, expected = create_test_case(blurry_data) + yield create_detections(expected) # type: ignore + + +@pytest.fixture() +def expected_stretch() -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected an easy image detections. + """ + + _, expected = create_test_case(stretched_data) + yield create_detections(expected) # type: ignore + + +@pytest.fixture() +def expected_multiple() -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected an easy image detections. + """ + + _, expected = create_test_case(multiple_data) yield create_detections(expected) # type: ignore @@ -249,3 +346,57 @@ def test_single_circle( assert actual is not None compare_detections(actual, expected_easy) + + def test_blurry_circle( + self, + detector: detect_target_contour.DetectTargetContour, + blurry_image: image_and_time.ImageAndTime, + expected_blur: detections_and_time.DetectionsAndTime, + ) -> None: + """ + Bus image. + """ + # Run + result, actual = detector.run(blurry_image) + + # Test + assert result + assert actual is not None + + compare_detections(actual, expected_blur) + + def test_stretch( + self, + detector: detect_target_contour.DetectTargetContour, + stretched_image: image_and_time.ImageAndTime, + expected_stretch: detections_and_time.DetectionsAndTime, + ) -> None: + """ + Bus image. + """ + # Run + result, actual = detector.run(stretched_image) + + # Test + assert result + assert actual is not None + + compare_detections(actual, expected_stretch) + + def test_multiple( + self, + detector: detect_target_contour.DetectTargetContour, + multiple_images: image_and_time.ImageAndTime, + expected_multiple: detections_and_time.DetectionsAndTime, + ) -> None: + """ + Bus image. + """ + # Run + result, actual = detector.run(multiple_images) + + # Test + assert result + assert actual is not None + + compare_detections(actual, expected_multiple) From 6a16444026acb616d1bb245d5d7f9157f5132319 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 21 Nov 2024 16:03:46 -0500 Subject: [PATCH 08/27] black . change --- modules/detect_target/detect_target_contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 750b18a0..0c9f2848 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -115,7 +115,7 @@ def detect_landing_pads_contours( cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), - 2 + 2, ) return True, detections, image_annotated From cf82cf4dec640daba28865362c57e1ed32ded006 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 21 Nov 2024 16:17:23 -0500 Subject: [PATCH 09/27] Linter Changes (again) --- modules/detect_target/detect_target_contour.py | 5 ++++- tests/unit/test_detect_target_contour.py | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 0c9f2848..9165db69 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -14,6 +14,9 @@ class DetectTargetContour(base_detect_target.BaseDetectTarget): + """ + Predicts annd locates landing pads using Classical Computer Vision + """ def __init__( self, show_annotations: bool = False, @@ -85,7 +88,7 @@ def detect_landing_pads_contours( and i in contours_with_children ] - if not len(parent_circular_contours): + if len(parent_circular_contours) == 0: return False, None, image # Create the DetectionsAndTime object diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index de82ac9b..ceefe2c4 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -3,12 +3,12 @@ """ +from typing import NamedTuple import math import cv2 import numpy as np import pytest -from typing import NamedTuple from modules.detect_target import detect_target_contour from modules import image_and_time from modules import detections_and_time @@ -22,6 +22,10 @@ class LandingPadData(NamedTuple): + """ + Landing Pad information struct + """ + center: tuple[int, int] radius: int | tuple[int, int] blur: bool = False @@ -228,7 +232,7 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ Construct DetectTargetContour. """ - detection = detect_target_contour.DetectTargetContour(True, "bob") + detection = detect_target_contour.DetectTargetContour(True) yield detection # type: ignore From da7b5af5124793013b7ec22d0abf2ccc2a8f4b05 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Fri, 22 Nov 2024 01:03:19 -0500 Subject: [PATCH 10/27] linter??? --- modules/detect_target/detect_target_contour.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 9165db69..56953cc4 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -17,6 +17,7 @@ class DetectTargetContour(base_detect_target.BaseDetectTarget): """ Predicts annd locates landing pads using Classical Computer Vision """ + def __init__( self, show_annotations: bool = False, From c0e772d613eaf02e6b846d68624ffdc4379ef93e Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Fri, 22 Nov 2024 01:20:22 -0500 Subject: [PATCH 11/27] linter please --- tests/unit/test_detect_target_contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index ceefe2c4..8b4618e0 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -137,7 +137,7 @@ def create_test_case(landing_list: list[LandingPadData]) -> tuple[np.ndarray, np image, top_left, bottom_right = draw_ellipse( image, landing_data.center, axis_length, angle, landing_data.blur ) - boxes_list.append([1, 0] + [point for point in top_left + bottom_right]) + boxes_list.append([1, 0] + list(top_left + bottom_right)) continue image, top_left, bottom_right = draw_circle( From 4d105f114b8e89ddf59ccef288b868f4c4724c2f Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 28 Nov 2024 17:40:13 -0500 Subject: [PATCH 12/27] ?? --- tests/unit/test_detect_target_contour.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 8b4618e0..150da75a 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -123,7 +123,6 @@ def draw_ellipse( image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (0, 0, 0), -1) return image, top_left, bottom_right - def create_test_case(landing_list: list[LandingPadData]) -> tuple[np.ndarray, np.ndarray]: """ Genereates test cases given a data set. From 74497e98f9652f1710700cf46b77b6ff3325a0c7 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 28 Nov 2024 17:45:08 -0500 Subject: [PATCH 13/27] pls --- tests/unit/test_detect_target_contour.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 150da75a..8b4618e0 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -123,6 +123,7 @@ def draw_ellipse( image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (0, 0, 0), -1) return image, top_left, bottom_right + def create_test_case(landing_list: list[LandingPadData]) -> tuple[np.ndarray, np.ndarray]: """ Genereates test cases given a data set. From 074246079c87487f95b4f96c7a30c40bee75ea8e Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Thu, 28 Nov 2024 17:53:41 -0500 Subject: [PATCH 14/27] Corrected main_detect_target paths --- main_detect_target.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main_detect_target.py b/main_detect_target.py index 7190386f..b1d44553 100644 --- a/main_detect_target.py +++ b/main_detect_target.py @@ -9,9 +9,9 @@ from modules.detect_target import detect_target_factory from modules.detect_target import detect_target_worker from modules.video_input import video_input_worker -from modules.common.logger.modules import logger -from modules.common.logger.modules import logger_setup_main -from modules.common.logger.read_yaml.modules import read_yaml +from modules.common.modules.logger import logger +from modules.common.modules.logger import logger_main_setup +from modules.common.modules.read_yaml import read_yaml from utilities.workers import queue_proxy_wrapper from utilities.workers import worker_controller from utilities.workers import worker_manager @@ -56,7 +56,7 @@ def main() -> int: assert config_logger is not None # Setup main logger - result, main_logger, logging_path = logger_setup_main.setup_main_logger(config_logger) + result, main_logger, logging_path = logger_main_setup.setup_main_logger(config_logger) if not result: print("ERROR: Failed to create main logger") return -1 From b3dcfca86b46b4e57c66a1ad980edee992507b4d Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Fri, 29 Nov 2024 17:24:46 -0500 Subject: [PATCH 15/27] removed window --- tests/unit/test_detect_target_contour.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 8b4618e0..23a0af2d 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -232,7 +232,7 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ Construct DetectTargetContour. """ - detection = detect_target_contour.DetectTargetContour(True) + detection = detect_target_contour.DetectTargetContour(False) yield detection # type: ignore From 9b62cfa09cc7950f5ee4e144cb90e5b83c6bac46 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Tue, 21 Jan 2025 01:08:35 -0500 Subject: [PATCH 16/27] Xierumeng changes (mostly?) --- .../detect_target/detect_target_contour.py | 78 ++-- tests/unit/generate_detect_target_contour.py | 165 +++++++++ tests/unit/test_detect_target_contour.py | 335 +++++++----------- 3 files changed, 319 insertions(+), 259 deletions(-) create mode 100644 tests/unit/generate_detect_target_contour.py diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 56953cc4..d80bcf40 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -12,10 +12,15 @@ from .. import image_and_time from .. import detections_and_time +MIN_CONTOUR_AREA = 100 +# some arbitrary value +UPPER_BLUE = np.array([130, 255, 255]) +LOWER_BLUE = np.array([100, 50, 50]) + class DetectTargetContour(base_detect_target.BaseDetectTarget): """ - Predicts annd locates landing pads using Classical Computer Vision + Predicts annd locates landing pads using the Classical Computer Vision methodology. """ def __init__( @@ -33,63 +38,21 @@ def __init__( if save_name != "": self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" - @staticmethod - def is_contour_circular(contour: np.ndarray) -> bool: - """ - Helper function for detect_landing_pads_contours. - Checks if the shape is close to circular. - Return: True is the shape is circular, false if it is not. - """ - contour_minimum = 0.8 - perimeter = cv2.arcLength(contour, True) - # Check if the perimeter is zero - if perimeter == 0.0: - return False - - area = cv2.contourArea(contour) - circularity = 4 * np.pi * (area / (perimeter * perimeter)) - return circularity > contour_minimum - - @staticmethod - def is_contour_large_enough(contour: np.ndarray, min_diameter: float) -> bool: - """ - Helper function for detect_landing_pads_contours. - Checks if the shape is larger than the provided diameter. - Return: True if it is, false if it not. - """ - _, radius = cv2.minEnclosingCircle(contour) - diameter = radius * 2 - return diameter >= min_diameter - def detect_landing_pads_contours( self, image: "np.ndarray", timestamp: float - ) -> "tuple[bool, detections_and_time.DetectionsAndTime | None, np.ndarray]": + ) -> tuple[bool, detections_and_time.DetectionsAndTime | None, np.ndarray]: """ Detects landing pads using contours/classical cv. image: Current image frame. timestamp: Timestamp for the detections. Return: Success, the DetectionsAndTime object, and the annotated image. """ - kernel = np.ones((2, 2), np.uint8) - gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - threshold = 180 - im_bw = cv2.threshold(gray_image, threshold, 255, cv2.THRESH_BINARY)[1] - im_dilation = cv2.dilate(im_bw, kernel, iterations=1) - contours, hierarchy = cv2.findContours(im_dilation, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) - - if len(contours) == 0: - return False, None, image + hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + mask = cv2.inRange(hsv_image, LOWER_BLUE, UPPER_BLUE) - contours_with_children = set(i for i, hier in enumerate(hierarchy[0]) if hier[3] != -1) - parent_circular_contours = [ - cnt - for i, cnt in enumerate(contours) - if self.is_contour_circular(cnt) - and self.is_contour_large_enough(cnt, 7) - and i in contours_with_children - ] + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if len(parent_circular_contours) == 0: + if len(contours) == 0: return False, None, image # Create the DetectionsAndTime object @@ -97,13 +60,25 @@ def detect_landing_pads_contours( if not result: return False, None, image - sorted_contour = sorted(parent_circular_contours, key=cv2.contourArea, reverse=True) + sorted_contour = sorted(contours, key=cv2.contourArea, reverse=True) image_annotated = copy.deepcopy(image) for i, contour in enumerate(sorted_contour): + contour_area = cv2.contourArea(contour) + + if contour_area < MIN_CONTOUR_AREA: + continue + + (x, y), radius = cv2.minEnclosingCircle(contour) + + enclosing_area = np.pi * (radius**2) + circularity = contour_area / enclosing_area + + if circularity < 0.7 or circularity > 1.3: + continue + x, y, w, h = cv2.boundingRect(contour) bounds = np.array([x, y, x + w, y + h]) - confidence = 1.0 # Confidence for classical CV is often set to a constant value - label = 0 # Label can be set to a constant or derived from some logic + confidence, label = 1.0, 0 # Create a Detection object and append it to detections result, detection = detections_and_time.Detection.create(bounds, label, confidence) @@ -136,6 +111,7 @@ def run( timestamp = data.timestamp result, detections, image_annotated = self.detect_landing_pads_contours(image, timestamp) + if not result: return False, None diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py new file mode 100644 index 00000000..c72b618f --- /dev/null +++ b/tests/unit/generate_detect_target_contour.py @@ -0,0 +1,165 @@ +""" +Helper functions for test_detect_target_contour. + +""" + +import cv2 +import math +import numpy as np + +LANDING_PAD_COLOR = (100, 50, 50) # blue color + +# Test functions use test fixture signature names and access class privates +# No enable +# pylint: disable=protected-access,redefined-outer-name + + +class BoundingBox: + """ + Holds the data that define the generated bounding boxes. + + Attributes: + top_left: A tuple of integers that represents top left corner of bounding box. + bottom_right: A tuple of integers that represents bottom right corner of bounding box. + """ + + def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): + self.top_left = top_left + self.bottom_right = bottom_right + + +class NumpyImage: + """ + Holds the Numpy Array which represents an image. + """ + + def __init__(self, image: np.ndarray): + self.image = image + + +class LandingPadTestData: + """ + Struct to hold the data needed to perform the tests. + + Attributes: + image = A numpy array that represents the image needed to be tested + bounding_box_list: A numpy array that holds a list of expected bounding box coordinates + """ + + def __init__(self, image: NumpyImage, boxes_list: np.ndarray): + self.image = image.image + self.bounding_box_list = boxes_list + + +class LandingPadData: + """ + Represents the data required to define and generate a landing pad. + + Attributes: + center: The (x, y) coordinates representing the center of the landing pad. + axis: The lengths of the semi-major and semi-minor axes of the ellipse. + blur: Indicates whether the landing pad should have a blur effect. default: False. + angle (int): The rotation angle of the landing pad in degrees. defaults: 0. + """ + + def __init__( + self, + center: tuple[int, int], + axis: tuple[int, int], + blur: bool = False, + angle: int = 0, + ): + + self.center = center + self.axis = axis + self.blur = blur + self.angle = angle + + +def blur_image(background: np.ndarray, landing_data: LandingPadData) -> NumpyImage: + """ + Blurs an image a singular shape, adds it to the background, and returns an image. + """ + + background_copy = background.copy() + x, y = background_copy.shape[:2] + + mask = np.zeros((x, y), np.uint8) + mask = cv2.ellipse( + mask, + landing_data.center, + landing_data.axis, + landing_data.angle, + 0, + 360, + 255, + -1, + ) + + mask = cv2.blur(mask, (25, 25), 7) + + alpha = mask[:, :, np.newaxis] / 255.0 + # Brings the image back to its original color + fg = np.full(background.shape, LANDING_PAD_COLOR, dtype=np.uint8) + + blended = (background * (1 - alpha) + fg * alpha).astype(np.uint8) + return NumpyImage(blended) + + +def draw_landing_pad( + image: np.ndarray, landing_data: LandingPadData +) -> tuple[NumpyImage, BoundingBox]: + """ + Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. + """ + (h, k), (a, b) = landing_data.center, landing_data.axis + rad = math.pi / 180 + ux, uy = a * math.cos(landing_data.angle * rad), a * math.sin(landing_data.angle * rad) + vx, vy = b * math.sin(landing_data.angle * rad), b * math.cos(landing_data.angle * rad) + width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) + + top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) + bottom_right = ( + int(min(h + (0.5) * width, image.shape[1])), + int(min(k + (0.5) * height, image.shape[0])), + ) + + if landing_data.blur: + image = blur_image(image, landing_data) + return image, BoundingBox(top_left, bottom_right) + + image = cv2.ellipse( + image, + landing_data.center, + landing_data.axis, + landing_data.angle, + 0, + 360, + LANDING_PAD_COLOR, + -1, + ) + return NumpyImage(image), BoundingBox(top_left, bottom_right) + + +def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: + """ + Generates test cases given a data set. + """ + image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) + + boxes_list = [] + for landing_data in landing_list: + np_image, bounding_box = draw_landing_pad(image, landing_data) + image = np_image.image + + boxes_list.append([1, 0] + list(bounding_box.top_left + bounding_box.bottom_right)) + + boxes_list = sorted( + boxes_list, + reverse=True, + key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])), + ) + + boxes_list = np.array(boxes_list) + image = image.astype(np.uint8) + return LandingPadTestData(NumpyImage(image), boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 23a0af2d..796ffe67 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -1,19 +1,22 @@ """ -Test DetectTarget module. +Test Contour Detection module. """ -from typing import NamedTuple -import math -import cv2 import numpy as np import pytest -from modules.detect_target import detect_target_contour -from modules import image_and_time +from tests.unit.generate_detect_target_contour import ( + LandingPadData, + LandingPadTestData, + create_test, +) from modules import detections_and_time +from modules import image_and_time +from modules.detect_target import detect_target_contour + -BOUNDING_BOX_PRECISION_TOLERANCE = -2 / 3 +BOUNDING_BOX_PRECISION_TOLERANCE = -2 / 3 # Tolerance > 1 CONFIDENCE_PRECISION_TOLERANCE = 2 # Test functions use test fixture signature names and access class privates @@ -21,139 +24,156 @@ # pylint: disable=protected-access,redefined-outer-name -class LandingPadData(NamedTuple): +@pytest.fixture +def single_circle() -> LandingPadTestData: # type: ignore """ - Landing Pad information struct + Loads the data for the single basic circle. """ + options = [LandingPadData(center=(300, 400), axis=(200, 200))] - center: tuple[int, int] - radius: int | tuple[int, int] - blur: bool = False - elipse: bool = False - angle: int = 0 + test_data = create_test(options) + yield test_data -easy_data = [LandingPadData(center=(1000, 400), radius=200)] +@pytest.fixture +def single_blurry_circle() -> LandingPadTestData: # type: ignore + """ + Loads the data for the single blury circle. + """ + options = [ + LandingPadData(center=(1000, 500), axis=(423, 423), blur=True), + ] -blurry_data = [ - LandingPadData(center=(1000, 500), radius=423, blur=True), -] + test_data = create_test(options) + yield test_data -stretched_data = [LandingPadData(center=(1000, 500), radius=(383, 405), elipse=True)] -multiple_data = [ - LandingPadData(center=(200, 500), radius=(50, 45), blur=True, elipse=True), - LandingPadData(center=(1590, 341), radius=250), - LandingPadData(center=(997, 600), radius=300), - LandingPadData(center=(401, 307), radius=(200, 150), blur=True, elipse=True), -] +@pytest.fixture +def single_stretched_circle() -> LandingPadTestData: # type: ignore + """ + Loads the data for the single stretched circle. + """ + options = [LandingPadData(center=(1000, 500), axis=(383, 405))] + + test_data = create_test(options) + yield test_data -def blur_img( - bg: np.ndarray, - center: tuple[int, int], - radius: int = 0, - axis_length: tuple[int, int] = (0, 0), - angle: int = 0, - circle_check: bool = True, -) -> np.ndarray: +@pytest.fixture +def multiple_circles() -> LandingPadTestData: # type: ignore """ - Blurs an image a singular shape and adds it to the background. + Loads the data for the multiple stretched circles. """ + options = [ + LandingPadData(center=(997, 600), axis=(300, 300)), + LandingPadData(center=(1590, 341), axis=(250, 250)), + LandingPadData(center=(200, 500), axis=(50, 45), blur=True), + LandingPadData(center=(401, 307), axis=(200, 150), blur=True), + ] + + test_data = create_test(options) + yield test_data - bg_copy = bg.copy() - x, y = bg_copy.shape[:2] - mask = np.zeros((x, y), np.uint8) - if circle_check: - mask = cv2.circle(mask, center, radius, (215, 158, 115), -1, cv2.LINE_AA) - else: - mask = cv2.ellipse(mask, center, axis_length, angle, 0, 360, (215, 158, 115), -1) +@pytest.fixture() +def detector() -> detect_target_contour.DetectTargetContour: # type: ignore + """ + Construct DetectTargetContour. + """ + detection = detect_target_contour.DetectTargetContour(False) + yield detection # type: ignore - mask = cv2.blur(mask, (25, 25), 7) - alpha = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) / 255.0 - fg = np.zeros(bg.shape, np.uint8) - fg[:, :, :] = [0, 0, 0] +@pytest.fixture() +def image_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore + """ + Load the single basic landing pad. + """ - blended = cv2.convertScaleAbs(bg * (1 - alpha) + fg * alpha) - return blended + image = single_circle.image + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore -def draw_circle( - image: np.ndarray, center: tuple[int, int], radius: int, blur: bool -) -> tuple[np.ndarray, int, int]: +@pytest.fixture() +def blurry_image(single_blurry_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore """ - Draws a circle on the provided image and saves the bounding box coordinates to a text file. + Load the single blurry landing pad. """ - x, y = center - top_left = (max(x - radius, 0), max(y - radius, 0)) - bottom_right = (min(x + radius, image.shape[1]), min(y + radius, image.shape[0])) - if blur: - image = blur_img(image, center, radius=radius, circle_check=True) - return image, top_left, bottom_right + image = single_blurry_circle.image + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore - cv2.circle(image, center, radius, (215, 158, 115), -1) - return image, top_left, bottom_right +@pytest.fixture() +def stretched_image(single_stretched_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore + """ + Load the single stretched landing pad. + """ -def draw_ellipse( - image: np.ndarray, center: tuple[int, int], axis_length: tuple, angle: int, blur: bool -) -> tuple[np.ndarray, int, int]: + image = single_stretched_circle.image + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore + + +@pytest.fixture() +def multiple_images(multiple_circles: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore """ - Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. + Load the multiple landing pads. """ - (h, k), (a, b) = center, axis_length - rad = math.pi / 180 - ux, uy = a * math.cos(angle * rad), a * math.sin(angle * rad) # first point on the ellipse - vx, vy = b * math.sin(angle * rad), b * math.cos(angle * rad) - width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) - top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) - bottom_right = ( - int(min(h + (0.5) * width, image.shape[1])), - int(min(k + (0.5) * height, image.shape[0])), - ) + image = multiple_circles.image + result, actual_image = image_and_time.ImageAndTime.create(image) + assert result + assert actual_image is not None + yield actual_image # type: ignore + - if blur: - image = blur_img(image, center, axis_length=axis_length, angle=angle, circle_check=False) - return image, top_left, bottom_right +@pytest.fixture() +def expected_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected a basic image detections. + """ - image = cv2.ellipse(image, center, axis_length, angle, 0, 360, (0, 0, 0), -1) - return image, top_left, bottom_right + expected = single_circle.bounding_box_list + yield create_detections(expected) # type: ignore -def create_test_case(landing_list: list[LandingPadData]) -> tuple[np.ndarray, np.ndarray]: +@pytest.fixture() +def expected_blur(single_blurry_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore """ - Genereates test cases given a data set. + Load expected the blured pad image detections. """ - image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) - boxes_list = [] - for landing_data in landing_list: - if landing_data.elipse: - axis_length, angle = landing_data.radius, landing_data.angle - image, top_left, bottom_right = draw_ellipse( - image, landing_data.center, axis_length, angle, landing_data.blur - ) - boxes_list.append([1, 0] + list(top_left + bottom_right)) - continue + expected = single_blurry_circle.bounding_box_list + yield create_detections(expected) # type: ignore - image, top_left, bottom_right = draw_circle( - image, landing_data.center, landing_data.radius, landing_data.blur - ) - boxes_list.append([1, 0] + list(top_left + bottom_right)) - boxes_list = sorted( - boxes_list, - reverse=True, - key=lambda boxes_list: abs( - (boxes_list[4] - boxes_list[2]) * (boxes_list[5] - boxes_list[3]) - ), - ) - boxes_list = np.array(boxes_list) - return (image.astype(np.uint8), boxes_list) +@pytest.fixture() +def expected_stretch(single_stretched_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected a stretched pad image detections. + """ + + expected = single_stretched_circle.bounding_box_list + yield create_detections(expected) # type: ignore + + +@pytest.fixture() +def expected_multiple(multiple_circles: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore + """ + Load expected multiple pads image detections. + """ + + expected = multiple_circles.bounding_box_list + yield create_detections(expected) # type: ignore # pylint:disable=duplicate-code @@ -227,107 +247,6 @@ def create_detections(detections_from_file: np.ndarray) -> detections_and_time.D return detections -@pytest.fixture() -def detector() -> detect_target_contour.DetectTargetContour: # type: ignore - """ - Construct DetectTargetContour. - """ - detection = detect_target_contour.DetectTargetContour(False) - yield detection # type: ignore - - -@pytest.fixture() -def image_easy() -> image_and_time.ImageAndTime: # type: ignore - """ - Load easy image. - """ - - image, _ = create_test_case(easy_data) - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def blurry_image() -> image_and_time.ImageAndTime: # type: ignore - """ - Load easy image. - """ - - image, _ = create_test_case(blurry_data) - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def stretched_image() -> image_and_time.ImageAndTime: # type: ignore - """ - Load easy image. - """ - - image, _ = create_test_case(stretched_data) - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def multiple_images() -> image_and_time.ImageAndTime: # type: ignore - """ - Load easy image. - """ - - image, _ = create_test_case(multiple_data) - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def expected_easy() -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected an easy image detections. - """ - - _, expected = create_test_case(easy_data) - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_blur() -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected an easy image detections. - """ - - _, expected = create_test_case(blurry_data) - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_stretch() -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected an easy image detections. - """ - - _, expected = create_test_case(stretched_data) - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_multiple() -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected an easy image detections. - """ - - _, expected = create_test_case(multiple_data) - yield create_detections(expected) # type: ignore - - class TestDetector: """ Tests `DetectTarget.run()` . @@ -340,7 +259,7 @@ def test_single_circle( expected_easy: detections_and_time.DetectionsAndTime, ) -> None: """ - Bus image. + Run the detection for the single landing pad. """ # Run result, actual = detector.run(image_easy) @@ -358,7 +277,7 @@ def test_blurry_circle( expected_blur: detections_and_time.DetectionsAndTime, ) -> None: """ - Bus image. + Run the detection for the blury circle. """ # Run result, actual = detector.run(blurry_image) @@ -376,7 +295,7 @@ def test_stretch( expected_stretch: detections_and_time.DetectionsAndTime, ) -> None: """ - Bus image. + Run the detection for the single stretched landing pad. """ # Run result, actual = detector.run(stretched_image) @@ -394,7 +313,7 @@ def test_multiple( expected_multiple: detections_and_time.DetectionsAndTime, ) -> None: """ - Bus image. + Run the detection for the multiple landing pads. """ # Run result, actual = detector.run(multiple_images) From a6540e7f45d6e4315b8c21cad9d386d61b4a5c92 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@example.com> Date: Tue, 21 Jan 2025 01:17:03 -0500 Subject: [PATCH 17/27] Last comma changes --- modules/detect_target/detect_target_contour.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index d80bcf40..8a4c2a4c 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -39,7 +39,7 @@ def __init__( self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" def detect_landing_pads_contours( - self, image: "np.ndarray", timestamp: float + self, image: np.ndarray, timestamp: float ) -> tuple[bool, detections_and_time.DetectionsAndTime | None, np.ndarray]: """ Detects landing pads using contours/classical cv. @@ -101,7 +101,7 @@ def detect_landing_pads_contours( def run( self, data: image_and_time.ImageAndTime - ) -> "tuple[bool, detections_and_time.DetectionsAndTime | None]": + ) -> tuple[bool, detections_and_time.DetectionsAndTime] | tuple[False, None]: """ Runs object detection on the provided image and returns the detections. data: Image with a timestamp. From 8def299a91414b1eb0e7f2efd4bb8761a28f9185 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Tue, 4 Feb 2025 02:05:23 -0500 Subject: [PATCH 18/27] Xierumeng Chnages 2 --- donke_1738651853_0.txt | 2 + main_detect_target.py | 213 ------------------ .../detect_target/detect_target_contour.py | 45 ++-- tests/unit/generate_detect_target_contour.py | 140 +++++++----- tests/unit/test_detect_target_contour.py | 14 +- 5 files changed, 112 insertions(+), 302 deletions(-) create mode 100644 donke_1738651853_0.txt delete mode 100644 main_detect_target.py diff --git a/donke_1738651853_0.txt b/donke_1738651853_0.txt new file mode 100644 index 00000000..204cf6a4 --- /dev/null +++ b/donke_1738651853_0.txt @@ -0,0 +1,2 @@ +<class 'modules.detections_and_time.DetectionsAndTime'>, time: 1738651853.983181, size: 1 +[cls: 0, conf: 1.0, bounds: 617 95 1384 906] \ No newline at end of file diff --git a/main_detect_target.py b/main_detect_target.py deleted file mode 100644 index b1d44553..00000000 --- a/main_detect_target.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -For 2022-2023 UAS competition. -""" - -import argparse -import multiprocessing as mp -import pathlib - -from modules.detect_target import detect_target_factory -from modules.detect_target import detect_target_worker -from modules.video_input import video_input_worker -from modules.common.modules.logger import logger -from modules.common.modules.logger import logger_main_setup -from modules.common.modules.read_yaml import read_yaml -from utilities.workers import queue_proxy_wrapper -from utilities.workers import worker_controller -from utilities.workers import worker_manager - - -CONFIG_FILE_PATH = pathlib.Path("config.yaml") - - -# Code copied into main_2024.py -# pylint: disable=duplicate-code -def main() -> int: - """ - Main function. - """ - # Parse whether or not to force cpu from command line - parser = argparse.ArgumentParser() - parser.add_argument("--cpu", action="store_true", help="option to force cpu") - parser.add_argument("--full", action="store_true", help="option to force full precision") - parser.add_argument( - "--show-annotated", - action="store_true", - help="option to show annotated image", - ) - args = parser.parse_args() - - # Configuration settings - result, config = read_yaml.open_config(CONFIG_FILE_PATH) - if not result: - print("ERROR: Failed to load configuration file") - return -1 - - # Get Pylance to stop complaining - assert config is not None - - # Logger configuration settings - result, config_logger = read_yaml.open_config(logger.CONFIG_FILE_PATH) - if not result: - print("ERROR: Failed to load configuration file") - return -1 - - # Get Pylance to stop complaining - assert config_logger is not None - - # Setup main logger - result, main_logger, logging_path = logger_main_setup.setup_main_logger(config_logger) - if not result: - print("ERROR: Failed to create main logger") - return -1 - - # Get Pylance to stop complaining - assert main_logger is not None - assert logging_path is not None - - # Get settings - try: - # Local constants - # pylint: disable=invalid-name - QUEUE_MAX_SIZE = config["queue_max_size"] - - VIDEO_INPUT_CAMERA_NAME = config["video_input"]["camera_name"] - VIDEO_INPUT_WORKER_PERIOD = config["video_input"]["worker_period"] - VIDEO_INPUT_SAVE_NAME_PREFIX = config["video_input"]["save_prefix"] - VIDEO_INPUT_SAVE_PREFIX = str(pathlib.Path(logging_path, VIDEO_INPUT_SAVE_NAME_PREFIX)) - - DETECT_TARGET_WORKER_COUNT = config["detect_target"]["worker_count"] - detect_target_option_int = config["detect_target"]["option"] - DETECT_TARGET_OPTION = detect_target_factory.DetectTargetOption(detect_target_option_int) - DETECT_TARGET_DEVICE = "cpu" if args.cpu else config["detect_target"]["device"] - DETECT_TARGET_MODEL_PATH = config["detect_target"]["model_path"] - DETECT_TARGET_OVERRIDE_FULL_PRECISION = args.full - DETECT_TARGET_SAVE_NAME_PREFIX = config["detect_target"]["save_prefix"] - DETECT_TARGET_SAVE_PREFIX = str(pathlib.Path(logging_path, DETECT_TARGET_SAVE_NAME_PREFIX)) - DETECT_TARGET_SHOW_ANNOTATED = args.show_annotated - # pylint: enable=invalid-name - except KeyError as exception: - main_logger.error(f"ERROR: Config key(s) not found: {exception}", True) - return -1 - - # Setup - controller = worker_controller.WorkerController() - - mp_manager = mp.Manager() - video_input_to_detect_target_queue = queue_proxy_wrapper.QueueProxyWrapper( - mp_manager, - QUEUE_MAX_SIZE, - ) - detect_target_to_main_queue = queue_proxy_wrapper.QueueProxyWrapper( - mp_manager, - QUEUE_MAX_SIZE, - ) - - # Worker properties - result, video_input_worker_properties = worker_manager.WorkerProperties.create( - count=1, - target=video_input_worker.video_input_worker, - work_arguments=( - VIDEO_INPUT_CAMERA_NAME, - VIDEO_INPUT_WORKER_PERIOD, - VIDEO_INPUT_SAVE_PREFIX, - ), - input_queues=[], - output_queues=[video_input_to_detect_target_queue], - controller=controller, - local_logger=main_logger, - ) - if not result: - main_logger.error("Failed to create arguments for Video Input", True) - return -1 - - # Get Pylance to stop complaining - assert video_input_worker_properties is not None - - result, detect_target_worker_properties = worker_manager.WorkerProperties.create( - count=DETECT_TARGET_WORKER_COUNT, - target=detect_target_worker.detect_target_worker, - work_arguments=( - DETECT_TARGET_OPTION, - DETECT_TARGET_DEVICE, - DETECT_TARGET_MODEL_PATH, - DETECT_TARGET_OVERRIDE_FULL_PRECISION, - DETECT_TARGET_SHOW_ANNOTATED, - DETECT_TARGET_SAVE_PREFIX, - ), - input_queues=[video_input_to_detect_target_queue], - output_queues=[detect_target_to_main_queue], - controller=controller, - local_logger=main_logger, - ) - if not result: - main_logger.error("Failed to create arguments for Detect Target", True) - return -1 - - # Get Pylance to stop complaining - assert detect_target_worker_properties is not None - - # Create managers - worker_managers = [] - - result, video_input_manager = worker_manager.WorkerManager.create( - worker_properties=video_input_worker_properties, - local_logger=main_logger, - ) - if not result: - main_logger.error("Failed to create manager for Video Input", True) - return -1 - - # Get Pylance to stop complaining - assert video_input_manager is not None - - worker_managers.append(video_input_manager) - - result, detect_target_manager = worker_manager.WorkerManager.create( - worker_properties=detect_target_worker_properties, - local_logger=main_logger, - ) - if not result: - main_logger.error("Failed to create manager for Detect Target", True) - return -1 - - # Get Pylance to stop complaining - assert detect_target_manager is not None - - worker_managers.append(detect_target_manager) - - # Run - for manager in worker_managers: - manager.start_workers() - - while True: - # Use main_logger for debugging - detections_and_time = detect_target_to_main_queue.queue.get() - if detections_and_time is None: - break - main_logger.debug(f"Timestamp: {detections_and_time.timestamp}", True) - main_logger.debug(f"Num detections: {len(detections_and_time.detections)}", True) - for detection in detections_and_time.detections: - main_logger.debug(f"Detection: {detection}", True) - - # Teardown - controller.request_exit() - - video_input_to_detect_target_queue.fill_and_drain_queue() - detect_target_to_main_queue.fill_and_drain_queue() - - for manager in worker_managers: - manager.join_workers() - - return 0 - - -# pylint: enable=duplicate-code - - -if __name__ == "__main__": - result_main = main() - if result_main < 0: - print(f"ERROR: Status code: {result_main}") - - print("Done!") diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 8a4c2a4c..946ec14a 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -4,29 +4,31 @@ import time -import copy import cv2 import numpy as np from . import base_detect_target from .. import image_and_time from .. import detections_and_time +from ..common.modules.logger import logger + + MIN_CONTOUR_AREA = 100 -# some arbitrary value UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) +LABEL = 0 class DetectTargetContour(base_detect_target.BaseDetectTarget): """ - Predicts annd locates landing pads using the Classical Computer Vision methodology. + Predicts annd locates landing pads using the classical computer vision methodology. """ def __init__( self, show_annotations: bool = False, - save_name: str = "", + save_name: str = "donke" ) -> None: """ show_annotations: Display annotated images. @@ -35,12 +37,14 @@ def __init__( self.__counter = 0 self.__show_annotations = show_annotations self.__filename_prefix = "" + _, self.__logger = logger.Logger.create(self.__filename_prefix, False) + if save_name != "": self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" def detect_landing_pads_contours( self, image: np.ndarray, timestamp: float - ) -> tuple[bool, detections_and_time.DetectionsAndTime | None, np.ndarray]: + ) -> tuple[True, detections_and_time.DetectionsAndTime, np.ndarray] | tuple[False, None, None]: """ Detects landing pads using contours/classical cv. image: Current image frame. @@ -53,15 +57,16 @@ def detect_landing_pads_contours( contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) == 0: - return False, None, image + return False, None, None # Create the DetectionsAndTime object result, detections = detections_and_time.DetectionsAndTime.create(timestamp) if not result: - return False, None, image + return False, None, None + # Ordered for the mapping to the corresponding detections sorted_contour = sorted(contours, key=cv2.contourArea, reverse=True) - image_annotated = copy.deepcopy(image) + image_annotated = image for i, contour in enumerate(sorted_contour): contour_area = cv2.contourArea(contour) @@ -78,12 +83,16 @@ def detect_landing_pads_contours( x, y, w, h = cv2.boundingRect(contour) bounds = np.array([x, y, x + w, y + h]) - confidence, label = 1.0, 0 + confidence = 1.0 + label = LABEL # Create a Detection object and append it to detections result, detection = detections_and_time.Detection.create(bounds, label, confidence) - if result: - detections.append(detection) + + if not result: + return False, None, None + + detections.append(detection) # Annotate the image cv2.rectangle(image_annotated, (x, y), (x + w, y + h), (0, 0, 255), 2) @@ -101,7 +110,7 @@ def detect_landing_pads_contours( def run( self, data: image_and_time.ImageAndTime - ) -> tuple[bool, detections_and_time.DetectionsAndTime] | tuple[False, None]: + ) -> tuple[True, detections_and_time.DetectionsAndTime] | tuple[False, None]: """ Runs object detection on the provided image and returns the detections. data: Image with a timestamp. @@ -109,7 +118,7 @@ def run( """ image = data.image timestamp = data.timestamp - + result, detections, image_annotated = self.detect_landing_pads_contours(image, timestamp) if not result: @@ -117,14 +126,10 @@ def run( # Logging if self.__filename_prefix != "": - filename = self.__filename_prefix + str(self.__counter) - # Object detections - with open(filename + ".txt", "w", encoding="utf-8") as file: - # Use internal string representation - file.write(repr(detections)) - # Annotated image - cv2.imwrite(filename + ".png", image_annotated) # type: ignore + self.__logger.save_image(image, self.__filename_prefix) self.__counter += 1 + if self.__show_annotations: cv2.imshow("Annotated", image_annotated) # type: ignore + return True, detections diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index c72b618f..f0ca1056 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -1,5 +1,5 @@ """ -Helper functions for test_detect_target_contour. +Helper functions for `test_detect_target_contour.py`. """ @@ -7,82 +7,84 @@ import math import numpy as np -LANDING_PAD_COLOR = (100, 50, 50) # blue color + +LANDING_PAD_COLOR_BLUE = (100, 50, 50) + # Test functions use test fixture signature names and access class privates # No enable # pylint: disable=protected-access,redefined-outer-name -class BoundingBox: - """ - Holds the data that define the generated bounding boxes. - - Attributes: - top_left: A tuple of integers that represents top left corner of bounding box. - bottom_right: A tuple of integers that represents bottom right corner of bounding box. - """ +class LandingPadData: - def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): - self.top_left = top_left - self.bottom_right = bottom_right + def __init__( + self, + center: tuple[int, int], + axis: tuple[int, int], + blur: bool, + angle: int, + ): + """ + Represents the data required to define and generate a landing pad. + + Attributes: + center: The (x, y) coordinates representing the center of the landing pad. + axis: The lengths of the semi-major and semi-minor axes of the ellipse. + blur: Indicates whether the landing pad should have a blur effect. default: False. + angle (int): The rotation angle of the landing pad in degrees. defaults: 0. + """ + self.center = center + self.axis = axis + self.blur = blur + self.angle = angle class NumpyImage: - """ - Holds the Numpy Array which represents an image. - """ - def __init__(self, image: np.ndarray): + """ + Holds the Numpy Array which represents an image. + """ self.image = image -class LandingPadTestData: - """ - Struct to hold the data needed to perform the tests. +class BoundingBox: + def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): + """ + Holds the data that define the generated bounding boxes. - Attributes: - image = A numpy array that represents the image needed to be tested - bounding_box_list: A numpy array that holds a list of expected bounding box coordinates - """ + Attributes: + top_left: A tuple of integers that represents top left corner of bounding box. + bottom_right: A tuple of integers that represents bottom right corner of bounding box. + """ + self.top_left = top_left + self.bottom_right = bottom_right + +class LandingPadTestData: def __init__(self, image: NumpyImage, boxes_list: np.ndarray): + """ + Struct to hold the data needed to perform the tests. + + Attributes: + image = A numpy array that represents the image needed to be tested + bounding_box_list: A numpy array that holds a list of expected bounding box coordinates + """ self.image = image.image self.bounding_box_list = boxes_list -class LandingPadData: +def add_blurred_landing_pad(background: np.ndarray, landing_data: LandingPadData) -> NumpyImage: """ - Represents the data required to define and generate a landing pad. + Blurs an image a singular lading pad, adds it to the background. Attributes: - center: The (x, y) coordinates representing the center of the landing pad. - axis: The lengths of the semi-major and semi-minor axes of the ellipse. - blur: Indicates whether the landing pad should have a blur effect. default: False. - angle (int): The rotation angle of the landing pad in degrees. defaults: 0. - """ - - def __init__( - self, - center: tuple[int, int], - axis: tuple[int, int], - blur: bool = False, - angle: int = 0, - ): - - self.center = center - self.axis = axis - self.blur = blur - self.angle = angle - - -def blur_image(background: np.ndarray, landing_data: LandingPadData) -> NumpyImage: - """ - Blurs an image a singular shape, adds it to the background, and returns an image. + image = A numpy array that represents background. + landing_data = The landing pad which is to be blurred. + Returns: + NumpyImage: A numpy array of the new blured image. """ - - background_copy = background.copy() - x, y = background_copy.shape[:2] + x, y = background.shape[:2] mask = np.zeros((x, y), np.uint8) mask = cv2.ellipse( @@ -100,7 +102,7 @@ def blur_image(background: np.ndarray, landing_data: LandingPadData) -> NumpyIma alpha = mask[:, :, np.newaxis] / 255.0 # Brings the image back to its original color - fg = np.full(background.shape, LANDING_PAD_COLOR, dtype=np.uint8) + fg = np.full(background.shape, LANDING_PAD_COLOR_BLUE, dtype=np.uint8) blended = (background * (1 - alpha) + fg * alpha).astype(np.uint8) return NumpyImage(blended) @@ -109,8 +111,17 @@ def blur_image(background: np.ndarray, landing_data: LandingPadData) -> NumpyIma def draw_landing_pad( image: np.ndarray, landing_data: LandingPadData ) -> tuple[NumpyImage, BoundingBox]: + print("asdasd") + print(image) """ - Draws an ellipse on the provided image and saves the bounding box coordinates to a text file. + Draws an singular landing pad on the provided image and saves the bounding box coordinates to a text file. + + Attributes: + image = A numpy array that represents background + landing_data = The landing pad which is to be placed + Returns: + NumpyImage: A numpy array of the new image . + BoundingBox: Bounding box of the newly placed bounding box. """ (h, k), (a, b) = landing_data.center, landing_data.axis rad = math.pi / 180 @@ -124,9 +135,11 @@ def draw_landing_pad( int(min(k + (0.5) * height, image.shape[0])), ) + bounding_box = BoundingBox(top_left, bottom_right) + if landing_data.blur: - image = blur_image(image, landing_data) - return image, BoundingBox(top_left, bottom_right) + image = add_blurred_landing_pad(image, landing_data) + return image, bounding_box image = cv2.ellipse( image, @@ -135,10 +148,10 @@ def draw_landing_pad( landing_data.angle, 0, 360, - LANDING_PAD_COLOR, + LANDING_PAD_COLOR_BLUE, -1, ) - return NumpyImage(image), BoundingBox(top_left, bottom_right) + return NumpyImage(image), bounding_box def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: @@ -146,13 +159,15 @@ def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: Generates test cases given a data set. """ image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) + confidence_and_label = [1, 0] boxes_list = [] - for landing_data in landing_list: - np_image, bounding_box = draw_landing_pad(image, landing_data) - image = np_image.image - boxes_list.append([1, 0] + list(bounding_box.top_left + bounding_box.bottom_right)) + for landing_data in landing_list: + print(image) + image_wrapper, bounding_box = draw_landing_pad(image, landing_data) + image = image_wrapper.image + boxes_list.append(confidence_and_label + list(bounding_box.top_left + bounding_box.bottom_right)) boxes_list = sorted( boxes_list, @@ -162,4 +177,5 @@ def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: boxes_list = np.array(boxes_list) image = image.astype(np.uint8) + return LandingPadTestData(NumpyImage(image), boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 796ffe67..86554c82 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -29,7 +29,7 @@ def single_circle() -> LandingPadTestData: # type: ignore """ Loads the data for the single basic circle. """ - options = [LandingPadData(center=(300, 400), axis=(200, 200))] + options = [LandingPadData(center=(300, 400), axis=(200, 200), blur=False, angle=0)] test_data = create_test(options) yield test_data @@ -41,7 +41,7 @@ def single_blurry_circle() -> LandingPadTestData: # type: ignore Loads the data for the single blury circle. """ options = [ - LandingPadData(center=(1000, 500), axis=(423, 423), blur=True), + LandingPadData(center=(1000, 500), axis=(423, 423), blur=True, angle=0), ] test_data = create_test(options) @@ -53,7 +53,7 @@ def single_stretched_circle() -> LandingPadTestData: # type: ignore """ Loads the data for the single stretched circle. """ - options = [LandingPadData(center=(1000, 500), axis=(383, 405))] + options = [LandingPadData(center=(1000, 500), axis=(383, 405), blur=False, angle=0)] test_data = create_test(options) yield test_data @@ -65,10 +65,10 @@ def multiple_circles() -> LandingPadTestData: # type: ignore Loads the data for the multiple stretched circles. """ options = [ - LandingPadData(center=(997, 600), axis=(300, 300)), - LandingPadData(center=(1590, 341), axis=(250, 250)), - LandingPadData(center=(200, 500), axis=(50, 45), blur=True), - LandingPadData(center=(401, 307), axis=(200, 150), blur=True), + LandingPadData(center=(997, 600), axis=(300, 300), blur=False, angle=0), + LandingPadData(center=(1590, 341), axis=(250, 250), blur=False, angle=0), + LandingPadData(center=(200, 500), axis=(50, 45), blur=True, angle=0), + LandingPadData(center=(401, 307), axis=(200, 150), blur=True, angle=0), ] test_data = create_test(options) From 14fcc41a2de20bab72038f4784b68fcb0cd42565 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Tue, 4 Feb 2025 02:08:21 -0500 Subject: [PATCH 19/27] small pylint changes --- modules/detect_target/detect_target_contour.py | 18 +++++++----------- tests/unit/generate_detect_target_contour.py | 4 +++- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 946ec14a..4d343f0b 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -13,7 +13,6 @@ from ..common.modules.logger import logger - MIN_CONTOUR_AREA = 100 UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) @@ -25,11 +24,7 @@ class DetectTargetContour(base_detect_target.BaseDetectTarget): Predicts annd locates landing pads using the classical computer vision methodology. """ - def __init__( - self, - show_annotations: bool = False, - save_name: str = "donke" - ) -> None: + def __init__(self, show_annotations: bool = False, save_name: str = "donke") -> None: """ show_annotations: Display annotated images. save_name: filename prefix for logging detections and annotated images. @@ -88,10 +83,10 @@ def detect_landing_pads_contours( # Create a Detection object and append it to detections result, detection = detections_and_time.Detection.create(bounds, label, confidence) - + if not result: return False, None, None - + detections.append(detection) # Annotate the image @@ -118,7 +113,7 @@ def run( """ image = data.image timestamp = data.timestamp - + result, detections, image_annotated = self.detect_landing_pads_contours(image, timestamp) if not result: @@ -126,9 +121,10 @@ def run( # Logging if self.__filename_prefix != "": - self.__logger.save_image(image, self.__filename_prefix) + filename = self.__filename_prefix + str(self.__counter) + self.__logger.save_image(image, filename) self.__counter += 1 - + if self.__show_annotations: cv2.imshow("Annotated", image_annotated) # type: ignore diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index f0ca1056..524a0fec 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -167,7 +167,9 @@ def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: print(image) image_wrapper, bounding_box = draw_landing_pad(image, landing_data) image = image_wrapper.image - boxes_list.append(confidence_and_label + list(bounding_box.top_left + bounding_box.bottom_right)) + boxes_list.append( + confidence_and_label + list(bounding_box.top_left + bounding_box.bottom_right) + ) boxes_list = sorted( boxes_list, From fa9b8d66410ac7f605b12b7f2b99fcaea066c5b2 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Thu, 6 Feb 2025 14:56:58 -0500 Subject: [PATCH 20/27] xierumeng changes v3 --- donke_1738651853_0.txt | 2 - .../detect_target/detect_target_contour.py | 18 +++-- tests/unit/generate_detect_target_contour.py | 65 ++++++++++--------- tests/unit/test_detect_target_contour.py | 48 +++++++------- 4 files changed, 72 insertions(+), 61 deletions(-) delete mode 100644 donke_1738651853_0.txt diff --git a/donke_1738651853_0.txt b/donke_1738651853_0.txt deleted file mode 100644 index 204cf6a4..00000000 --- a/donke_1738651853_0.txt +++ /dev/null @@ -1,2 +0,0 @@ -<class 'modules.detections_and_time.DetectionsAndTime'>, time: 1738651853.983181, size: 1 -[cls: 0, conf: 1.0, bounds: 617 95 1384 906] \ No newline at end of file diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 4d343f0b..f9fb4192 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -12,7 +12,7 @@ from .. import detections_and_time from ..common.modules.logger import logger - +CONFIDENCE = 1.0 MIN_CONTOUR_AREA = 100 UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) @@ -24,7 +24,9 @@ class DetectTargetContour(base_detect_target.BaseDetectTarget): Predicts annd locates landing pads using the classical computer vision methodology. """ - def __init__(self, show_annotations: bool = False, save_name: str = "donke") -> None: + def __init__( + self, image_logger: logger.Logger, show_annotations: bool = False, save_name: str = "" + ) -> None: """ show_annotations: Display annotated images. save_name: filename prefix for logging detections and annotated images. @@ -32,7 +34,7 @@ def __init__(self, show_annotations: bool = False, save_name: str = "donke") -> self.__counter = 0 self.__show_annotations = show_annotations self.__filename_prefix = "" - _, self.__logger = logger.Logger.create(self.__filename_prefix, False) + self.__logger = image_logger if save_name != "": self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" @@ -41,9 +43,11 @@ def detect_landing_pads_contours( self, image: np.ndarray, timestamp: float ) -> tuple[True, detections_and_time.DetectionsAndTime, np.ndarray] | tuple[False, None, None]: """ - Detects landing pads using contours/classical cv. + Detects landing pads using contours/classical CV. + image: Current image frame. timestamp: Timestamp for the detections. + Return: Success, the DetectionsAndTime object, and the annotated image. """ hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) @@ -78,11 +82,9 @@ def detect_landing_pads_contours( x, y, w, h = cv2.boundingRect(contour) bounds = np.array([x, y, x + w, y + h]) - confidence = 1.0 - label = LABEL # Create a Detection object and append it to detections - result, detection = detections_and_time.Detection.create(bounds, label, confidence) + result, detection = detections_and_time.Detection.create(bounds, LABEL, CONFIDENCE) if not result: return False, None, None @@ -108,7 +110,9 @@ def run( ) -> tuple[True, detections_and_time.DetectionsAndTime] | tuple[False, None]: """ Runs object detection on the provided image and returns the detections. + data: Image with a timestamp. + Return: Success and the detections. """ image = data.image diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index 524a0fec..f2031386 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -8,7 +8,7 @@ import numpy as np -LANDING_PAD_COLOR_BLUE = (100, 50, 50) +LANDING_PAD_COLOUR_BLUE = (100, 50, 50) # BGR # Test functions use test fixture signature names and access class privates @@ -16,23 +16,22 @@ # pylint: disable=protected-access,redefined-outer-name -class LandingPadData: +class LandingPadImageConfig: def __init__( self, center: tuple[int, int], axis: tuple[int, int], blur: bool, - angle: int, + angle: float, ): """ Represents the data required to define and generate a landing pad. - Attributes: - center: The (x, y) coordinates representing the center of the landing pad. - axis: The lengths of the semi-major and semi-minor axes of the ellipse. - blur: Indicates whether the landing pad should have a blur effect. default: False. - angle (int): The rotation angle of the landing pad in degrees. defaults: 0. + center: The (x, y) coordinates representing the center of the landing pad. + axis: The pixel lengths of the semi-major and semi-minor axes of the ellipse. + blur: Indicates whether the landing pad should have a blur effect. default: False. + angle: The rotation angle of the landing pad in degrees clockwise (0 < angle < 360). """ self.center = center self.axis = axis @@ -61,7 +60,7 @@ def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): self.bottom_right = bottom_right -class LandingPadTestData: +class InputImageAndExpectedBoundingBoxes: def __init__(self, image: NumpyImage, boxes_list: np.ndarray): """ Struct to hold the data needed to perform the tests. @@ -74,15 +73,16 @@ def __init__(self, image: NumpyImage, boxes_list: np.ndarray): self.bounding_box_list = boxes_list -def add_blurred_landing_pad(background: np.ndarray, landing_data: LandingPadData) -> NumpyImage: +def add_blurred_landing_pad( + background: np.ndarray, landing_data: LandingPadImageConfig +) -> NumpyImage: """ Blurs an image a singular lading pad, adds it to the background. - Attributes: - image = A numpy array that represents background. - landing_data = The landing pad which is to be blurred. - Returns: - NumpyImage: A numpy array of the new blured image. + background: A numpy image. + landing_data = The landing pad which is to be blurred. + + Returns: Image with the landing pad. """ x, y = background.shape[:2] @@ -101,27 +101,24 @@ def add_blurred_landing_pad(background: np.ndarray, landing_data: LandingPadData mask = cv2.blur(mask, (25, 25), 7) alpha = mask[:, :, np.newaxis] / 255.0 - # Brings the image back to its original color - fg = np.full(background.shape, LANDING_PAD_COLOR_BLUE, dtype=np.uint8) + # Brings the image back to its original colour + fg = np.full(background.shape, LANDING_PAD_COLOUR_BLUE, dtype=np.uint8) blended = (background * (1 - alpha) + fg * alpha).astype(np.uint8) return NumpyImage(blended) def draw_landing_pad( - image: np.ndarray, landing_data: LandingPadData + image: np.ndarray, landing_data: LandingPadImageConfig ) -> tuple[NumpyImage, BoundingBox]: print("asdasd") print(image) """ - Draws an singular landing pad on the provided image and saves the bounding box coordinates to a text file. - - Attributes: - image = A numpy array that represents background - landing_data = The landing pad which is to be placed - Returns: - NumpyImage: A numpy array of the new image . - BoundingBox: Bounding box of the newly placed bounding box. + Draws a single landing pad on the provided image and saves the bounding box coordinates to a text file. + + landing_data: Landing pad data for the landing pad to be added. + + Returns: Image with landing pad and the bounding box for the drawn landing pad. """ (h, k), (a, b) = landing_data.center, landing_data.axis rad = math.pi / 180 @@ -148,17 +145,24 @@ def draw_landing_pad( landing_data.angle, 0, 360, - LANDING_PAD_COLOR_BLUE, + LANDING_PAD_COLOUR_BLUE, -1, ) return NumpyImage(image), bounding_box -def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: +def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpectedBoundingBoxes: """ Generates test cases given a data set. + + landing_data: Landing pad data for the landing pad to be added. + + Returns: The image and expected bounding box. + """ - image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int16) + image = np.full( + shape=(1000, 2000, 3), fill_value=255, dtype=np.int16 + ) # shape: size of the screen confidence_and_label = [1, 0] boxes_list = [] @@ -175,9 +179,10 @@ def create_test(landing_list: list[LandingPadData]) -> LandingPadTestData: boxes_list, reverse=True, key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])), + # calculates the absolute value of area of the bounding box ) boxes_list = np.array(boxes_list) image = image.astype(np.uint8) - return LandingPadTestData(NumpyImage(image), boxes_list) + return InputImageAndExpectedBoundingBoxes(NumpyImage(image), boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 86554c82..934847ca 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -7,17 +7,21 @@ import pytest from tests.unit.generate_detect_target_contour import ( - LandingPadData, - LandingPadTestData, + LandingPadImageConfig, + InputImageAndExpectedBoundingBoxes, create_test, ) from modules import detections_and_time from modules import image_and_time from modules.detect_target import detect_target_contour +from modules.common.modules.logger import logger # Changed from relative to absolute import BOUNDING_BOX_PRECISION_TOLERANCE = -2 / 3 # Tolerance > 1 CONFIDENCE_PRECISION_TOLERANCE = 2 +LOGGER_NAME = "" + +_, test_logger = logger.Logger.create(LOGGER_NAME, False) # Test functions use test fixture signature names and access class privates # No enable @@ -25,23 +29,23 @@ @pytest.fixture -def single_circle() -> LandingPadTestData: # type: ignore +def single_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single basic circle. """ - options = [LandingPadData(center=(300, 400), axis=(200, 200), blur=False, angle=0)] + options = [LandingPadImageConfig(center=(300, 400), axis=(200, 200), blur=False, angle=0)] test_data = create_test(options) yield test_data @pytest.fixture -def single_blurry_circle() -> LandingPadTestData: # type: ignore +def single_blurry_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single blury circle. """ options = [ - LandingPadData(center=(1000, 500), axis=(423, 423), blur=True, angle=0), + LandingPadImageConfig(center=(1000, 500), axis=(423, 423), blur=True, angle=0), ] test_data = create_test(options) @@ -49,26 +53,26 @@ def single_blurry_circle() -> LandingPadTestData: # type: ignore @pytest.fixture -def single_stretched_circle() -> LandingPadTestData: # type: ignore +def single_stretched_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single stretched circle. """ - options = [LandingPadData(center=(1000, 500), axis=(383, 405), blur=False, angle=0)] + options = [LandingPadImageConfig(center=(1000, 500), axis=(383, 405), blur=False, angle=0)] test_data = create_test(options) yield test_data @pytest.fixture -def multiple_circles() -> LandingPadTestData: # type: ignore +def multiple_circles() -> InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the multiple stretched circles. """ options = [ - LandingPadData(center=(997, 600), axis=(300, 300), blur=False, angle=0), - LandingPadData(center=(1590, 341), axis=(250, 250), blur=False, angle=0), - LandingPadData(center=(200, 500), axis=(50, 45), blur=True, angle=0), - LandingPadData(center=(401, 307), axis=(200, 150), blur=True, angle=0), + LandingPadImageConfig(center=(997, 600), axis=(300, 300), blur=False, angle=0), + LandingPadImageConfig(center=(1590, 341), axis=(250, 250), blur=False, angle=0), + LandingPadImageConfig(center=(200, 500), axis=(50, 45), blur=True, angle=0), + LandingPadImageConfig(center=(401, 307), axis=(200, 150), blur=True, angle=0), ] test_data = create_test(options) @@ -80,12 +84,12 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ Construct DetectTargetContour. """ - detection = detect_target_contour.DetectTargetContour(False) + detection = detect_target_contour.DetectTargetContour(test_logger, False) yield detection # type: ignore @pytest.fixture() -def image_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def image_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single basic landing pad. """ @@ -98,7 +102,7 @@ def image_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndTime @pytest.fixture() -def blurry_image(single_blurry_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def blurry_image(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single blurry landing pad. """ @@ -111,7 +115,7 @@ def blurry_image(single_blurry_circle: LandingPadTestData) -> image_and_time.Ima @pytest.fixture() -def stretched_image(single_stretched_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def stretched_image(single_stretched_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single stretched landing pad. """ @@ -124,7 +128,7 @@ def stretched_image(single_stretched_circle: LandingPadTestData) -> image_and_ti @pytest.fixture() -def multiple_images(multiple_circles: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def multiple_images(multiple_circles: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the multiple landing pads. """ @@ -137,7 +141,7 @@ def multiple_images(multiple_circles: LandingPadTestData) -> image_and_time.Imag @pytest.fixture() -def expected_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def expected_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected a basic image detections. """ @@ -147,7 +151,7 @@ def expected_easy(single_circle: LandingPadTestData) -> image_and_time.ImageAndT @pytest.fixture() -def expected_blur(single_blurry_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def expected_blur(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected the blured pad image detections. """ @@ -157,7 +161,7 @@ def expected_blur(single_blurry_circle: LandingPadTestData) -> image_and_time.Im @pytest.fixture() -def expected_stretch(single_stretched_circle: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def expected_stretch(single_stretched_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected a stretched pad image detections. """ @@ -167,7 +171,7 @@ def expected_stretch(single_stretched_circle: LandingPadTestData) -> image_and_t @pytest.fixture() -def expected_multiple(multiple_circles: LandingPadTestData) -> image_and_time.ImageAndTime: # type: ignore +def expected_multiple(multiple_circles: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected multiple pads image detections. """ From f87364060353c39bfa1d2aadbea647b595be7184 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Sun, 9 Feb 2025 18:58:29 -0500 Subject: [PATCH 21/27] logger changes --- modules/detect_target/detect_target_contour.py | 12 +++++++----- tests/unit/generate_detect_target_contour.py | 1 - tests/unit/test_detect_target_contour.py | 7 +++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index f9fb4192..3f4e86e4 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -40,7 +40,7 @@ def __init__( self.__filename_prefix = save_name + "_" + str(int(time.time())) + "_" def detect_landing_pads_contours( - self, image: np.ndarray, timestamp: float + self, image_and_time_data: image_and_time.ImageAndTime ) -> tuple[True, detections_and_time.DetectionsAndTime, np.ndarray] | tuple[False, None, None]: """ Detects landing pads using contours/classical CV. @@ -50,6 +50,10 @@ def detect_landing_pads_contours( Return: Success, the DetectionsAndTime object, and the annotated image. """ + + image = image_and_time_data.image + timestamp = image_and_time_data.timestamp + hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv_image, LOWER_BLUE, UPPER_BLUE) @@ -115,10 +119,8 @@ def run( Return: Success and the detections. """ - image = data.image - timestamp = data.timestamp - result, detections, image_annotated = self.detect_landing_pads_contours(image, timestamp) + result, detections, image_annotated = self.detect_landing_pads_contours(data) if not result: return False, None @@ -126,7 +128,7 @@ def run( # Logging if self.__filename_prefix != "": filename = self.__filename_prefix + str(self.__counter) - self.__logger.save_image(image, filename) + self.__logger.save_image(image_annotated, filename) self.__counter += 1 if self.__show_annotations: diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index f2031386..9d64ff4b 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -17,7 +17,6 @@ class LandingPadImageConfig: - def __init__( self, center: tuple[int, int], diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 934847ca..92e54cb9 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -21,8 +21,6 @@ CONFIDENCE_PRECISION_TOLERANCE = 2 LOGGER_NAME = "" -_, test_logger = logger.Logger.create(LOGGER_NAME, False) - # Test functions use test fixture signature names and access class privates # No enable # pylint: disable=protected-access,redefined-outer-name @@ -84,6 +82,11 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore """ Construct DetectTargetContour. """ + result, test_logger = logger.Logger.create(LOGGER_NAME, False) + + assert result + assert test_logger is not None + detection = detect_target_contour.DetectTargetContour(test_logger, False) yield detection # type: ignore From 59c8d9446c333ef5a580c36c83f0d6b92d5f28d4 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Sun, 16 Feb 2025 16:00:09 -0500 Subject: [PATCH 22/27] Xierumeng Changes --- .../detect_target/detect_target_contour.py | 10 ++- tests/unit/generate_detect_target_contour.py | 65 ++++++++++--------- tests/unit/test_detect_target_contour.py | 12 +++- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 3f4e86e4..1bb2cf67 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -12,10 +12,11 @@ from .. import detections_and_time from ..common.modules.logger import logger -CONFIDENCE = 1.0 + MIN_CONTOUR_AREA = 100 UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) +CONFIDENCE = 1.0 LABEL = 0 @@ -28,6 +29,7 @@ def __init__( self, image_logger: logger.Logger, show_annotations: bool = False, save_name: str = "" ) -> None: """ + image_logger: Log annotated iamges. show_annotations: Display annotated images. save_name: filename prefix for logging detections and annotated images. """ @@ -50,7 +52,6 @@ def detect_landing_pads_contours( Return: Success, the DetectionsAndTime object, and the annotated image. """ - image = image_and_time_data.image timestamp = image_and_time_data.timestamp @@ -62,15 +63,12 @@ def detect_landing_pads_contours( if len(contours) == 0: return False, None, None - # Create the DetectionsAndTime object result, detections = detections_and_time.DetectionsAndTime.create(timestamp) if not result: return False, None, None - # Ordered for the mapping to the corresponding detections - sorted_contour = sorted(contours, key=cv2.contourArea, reverse=True) image_annotated = image - for i, contour in enumerate(sorted_contour): + for i, contour in enumerate(contours): contour_area = cv2.contourArea(contour) if contour_area < MIN_CONTOUR_AREA: diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index 9d64ff4b..507a6635 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -17,6 +17,9 @@ class LandingPadImageConfig: + """ + Represents the data required to define and generate a landing pad. + """ def __init__( self, center: tuple[int, int], @@ -25,12 +28,12 @@ def __init__( angle: float, ): """ - Represents the data required to define and generate a landing pad. center: The (x, y) coordinates representing the center of the landing pad. axis: The pixel lengths of the semi-major and semi-minor axes of the ellipse. - blur: Indicates whether the landing pad should have a blur effect. default: False. - angle: The rotation angle of the landing pad in degrees clockwise (0 < angle < 360). + blur: Indicates whether the landing pad should have a blur effect. + angle: The rotation angle of the landing pad in degrees clockwise, where 0.0 degrees + is where both semi major and minor are aligned with the x and y-axis respectively (0.0 <= angle <= 360.0). """ self.center = center self.axis = axis @@ -39,36 +42,39 @@ def __init__( class NumpyImage: + """ + Holds the Numpy Array which represents an image. + """ def __init__(self, image: np.ndarray): """ - Holds the Numpy Array which represents an image. + image: A numpy array that represents the image. """ self.image = image class BoundingBox: + """ + Holds the data that define the generated bounding boxes. + """ def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): """ - Holds the data that define the generated bounding boxes. - - Attributes: - top_left: A tuple of integers that represents top left corner of bounding box. - bottom_right: A tuple of integers that represents bottom right corner of bounding box. + top_left: x, y coordinates representing the top left corner of the bounding box on an image. + bottom_right: x, y coordinates representing the bottom right corner of the bounding box on an image. """ self.top_left = top_left self.bottom_right = bottom_right class InputImageAndExpectedBoundingBoxes: - def __init__(self, image: NumpyImage, boxes_list: np.ndarray): + ''' + Struct to hold the data needed to perform the tests. + ''' + def __init__(self, image: np.ndarray, boxes_list: np.ndarray): """ - Struct to hold the data needed to perform the tests. - - Attributes: - image = A numpy array that represents the image needed to be tested - bounding_box_list: A numpy array that holds a list of expected bounding box coordinates + image = A numpy array that represents the image needed to be tested. + bounding_box_list: A numpy array that holds a list of expected bounding box coordinates. """ - self.image = image.image + self.image = image self.bounding_box_list = boxes_list @@ -76,10 +82,11 @@ def add_blurred_landing_pad( background: np.ndarray, landing_data: LandingPadImageConfig ) -> NumpyImage: """ - Blurs an image a singular lading pad, adds it to the background. + Blurs an image and adds a singular lading pad to the background. background: A numpy image. - landing_data = The landing pad which is to be blurred. + landing_data: Landing pad data for the landing pad to be blurred and added. + Returns: Image with the landing pad. """ @@ -100,7 +107,7 @@ def add_blurred_landing_pad( mask = cv2.blur(mask, (25, 25), 7) alpha = mask[:, :, np.newaxis] / 255.0 - # Brings the image back to its original colour + # Brings the image back to its original colour. fg = np.full(background.shape, LANDING_PAD_COLOUR_BLUE, dtype=np.uint8) blended = (background * (1 - alpha) + fg * alpha).astype(np.uint8) @@ -110,19 +117,18 @@ def add_blurred_landing_pad( def draw_landing_pad( image: np.ndarray, landing_data: LandingPadImageConfig ) -> tuple[NumpyImage, BoundingBox]: - print("asdasd") - print(image) """ Draws a single landing pad on the provided image and saves the bounding box coordinates to a text file. + image: The image to add a landing pad to. landing_data: Landing pad data for the landing pad to be added. Returns: Image with landing pad and the bounding box for the drawn landing pad. """ (h, k), (a, b) = landing_data.center, landing_data.axis - rad = math.pi / 180 - ux, uy = a * math.cos(landing_data.angle * rad), a * math.sin(landing_data.angle * rad) - vx, vy = b * math.sin(landing_data.angle * rad), b * math.cos(landing_data.angle * rad) + angle_in_rad = math.radians(landing_data.angle) + ux, uy = a * math.cos(angle_in_rad), a * math.sin(angle_in_rad) + vx, vy = b * math.sin(angle_in_rad), b * math.cos(angle_in_rad) width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) @@ -154,34 +160,33 @@ def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpec """ Generates test cases given a data set. - landing_data: Landing pad data for the landing pad to be added. + landing_list: List of landing pad data to be generated. Returns: The image and expected bounding box. - """ image = np.full( shape=(1000, 2000, 3), fill_value=255, dtype=np.int16 - ) # shape: size of the screen + ) confidence_and_label = [1, 0] + # List to hold the bounding boxes. boxes_list = [] for landing_data in landing_list: - print(image) image_wrapper, bounding_box = draw_landing_pad(image, landing_data) image = image_wrapper.image boxes_list.append( confidence_and_label + list(bounding_box.top_left + bounding_box.bottom_right) ) + # Calculates the area of the bounding box. boxes_list = sorted( boxes_list, reverse=True, key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])), - # calculates the absolute value of area of the bounding box ) boxes_list = np.array(boxes_list) image = image.astype(np.uint8) - return InputImageAndExpectedBoundingBoxes(NumpyImage(image), boxes_list) + return InputImageAndExpectedBoundingBoxes(image, boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 92e54cb9..413f7076 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -2,7 +2,7 @@ Test Contour Detection module. """ - +import cv2 import numpy as np import pytest @@ -194,9 +194,17 @@ def compare_detections( # Using integer indexing for both lists # pylint: disable-next=consider-using-enumerate + + # Ordered for the mapping to the corresponding detections + sorted_actual_detections = sorted( + actual.detections, + reverse=True, + key=lambda box: abs((box.x_1 - box.x_2) * (box.y_1 - box.y_2)), + ) + for i in range(0, len(expected.detections)): expected_detection = expected.detections[i] - actual_detection = actual.detections[i] + actual_detection = sorted_actual_detections[i] assert expected_detection.label == actual_detection.label np.testing.assert_almost_equal( From 8ce749c0f2e643978431ba9a956cc56ab39da99f Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Thu, 6 Mar 2025 00:42:14 -0500 Subject: [PATCH 23/27] Most changes made --- .../detect_target/detect_target_contour.py | 6 +- .../detect_target/detect_target_factory.py | 5 +- tests/unit/generate_detect_target_contour.py | 104 ++++++++++++------ tests/unit/test_detect_target_contour.py | 82 ++++++++------ 4 files changed, 124 insertions(+), 73 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 1bb2cf67..32814ffb 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -14,6 +14,8 @@ MIN_CONTOUR_AREA = 100 +MAX_CIRCULARITY = 1.3 +MIN_CIRCULARITY = 0.7 UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) CONFIDENCE = 1.0 @@ -29,7 +31,7 @@ def __init__( self, image_logger: logger.Logger, show_annotations: bool = False, save_name: str = "" ) -> None: """ - image_logger: Log annotated iamges. + image_logger: Log annotated images. show_annotations: Display annotated images. save_name: filename prefix for logging detections and annotated images. """ @@ -79,7 +81,7 @@ def detect_landing_pads_contours( enclosing_area = np.pi * (radius**2) circularity = contour_area / enclosing_area - if circularity < 0.7 or circularity > 1.3: + if circularity < MIN_CIRCULARITY or circularity > MAX_CIRCULARITY: continue x, y, w, h = cv2.boundingRect(contour) diff --git a/modules/detect_target/detect_target_factory.py b/modules/detect_target/detect_target_factory.py index a7a9cfab..b241cdaa 100644 --- a/modules/detect_target/detect_target_factory.py +++ b/modules/detect_target/detect_target_factory.py @@ -18,7 +18,7 @@ class DetectTargetOption(enum.Enum): ML_ULTRALYTICS = 0 CV_BRIGHTSPOT = 1 - C_CONTOUR = 2 + CV_CONTOUR = 2 def create_detect_target( @@ -49,8 +49,9 @@ def create_detect_target( show_annotations, save_name, ) - case DetectTargetOption.C_CONTOUR: + case DetectTargetOption.CV_CONTOUR: return True, detect_target_contour.DetectTargetContour( + local_logger, show_annotations, save_name, ) diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index 507a6635..6a097862 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -7,35 +7,32 @@ import math import numpy as np - -LANDING_PAD_COLOUR_BLUE = (100, 50, 50) # BGR +from modules import detections_and_time -# Test functions use test fixture signature names and access class privates -# No enable -# pylint: disable=protected-access,redefined-outer-name +LANDING_PAD_COLOUR_BLUE = (100, 50, 50) # BGR class LandingPadImageConfig: """ Represents the data required to define and generate a landing pad. """ + def __init__( self, - center: tuple[int, int], + centre: tuple[int, int], axis: tuple[int, int], blur: bool, angle: float, ): """ - - center: The (x, y) coordinates representing the center of the landing pad. - axis: The pixel lengths of the semi-major and semi-minor axes of the ellipse. + centre: The pixel coordinates representing the centre of the landing pad. + axis: The pixel lengths of the semi-major axes of the ellipse. blur: Indicates whether the landing pad should have a blur effect. angle: The rotation angle of the landing pad in degrees clockwise, where 0.0 degrees is where both semi major and minor are aligned with the x and y-axis respectively (0.0 <= angle <= 360.0). """ - self.center = center + self.centre = centre self.axis = axis self.blur = blur self.angle = angle @@ -43,8 +40,9 @@ def __init__( class NumpyImage: """ - Holds the Numpy Array which represents an image. + Holds the numpy array which represents an image. """ + def __init__(self, image: np.ndarray): """ image: A numpy array that represents the image. @@ -56,38 +54,70 @@ class BoundingBox: """ Holds the data that define the generated bounding boxes. """ - def __init__(self, top_left: tuple[int, int], bottom_right: tuple[int, int]): + + def __init__(self, top_left: tuple[float, float], bottom_right: tuple[float, float]): """ - top_left: x, y coordinates representing the top left corner of the bounding box on an image. - bottom_right: x, y coordinates representing the bottom right corner of the bounding box on an image. + top_left: pixel coordinates representing the top left corner of the bounding box on an image. + bottom_right: pixel coordinates representing the bottom right corner of the bounding box on an image. """ self.top_left = top_left self.bottom_right = bottom_right class InputImageAndExpectedBoundingBoxes: - ''' + """ Struct to hold the data needed to perform the tests. - ''' + """ + def __init__(self, image: np.ndarray, boxes_list: np.ndarray): """ - image = A numpy array that represents the image needed to be tested. + image: A numpy array that represents the image needed to be tested. bounding_box_list: A numpy array that holds a list of expected bounding box coordinates. + + The bounding box coordinates are in the following format: + top_left_x = boxes_list[0] + top_left_y = boxes_list[1] + + bottom_right_x = boxes_list[2] + bottom_right_x = boxes_list[3] """ self.image = image self.bounding_box_list = boxes_list +def create_detections(detections_from_file: np.ndarray) -> detections_and_time.DetectionsAndTime: + """ + Create DetectionsAndTime from expected. + Format: [confidence, label, x_1, y_1, x_2, y_2] . + """ + assert detections_from_file.shape[1] == 6 + + result, detections = detections_and_time.DetectionsAndTime.create(0) + assert result + assert detections is not None + + for i in range(0, detections_from_file.shape[0]): + result, detection = detections_and_time.Detection.create( + detections_from_file[i][2:], + int(detections_from_file[i][1]), + detections_from_file[i][0], + ) + assert result + assert detection is not None + detections.append(detection) + + return detections + + def add_blurred_landing_pad( background: np.ndarray, landing_data: LandingPadImageConfig ) -> NumpyImage: """ - Blurs an image and adds a singular lading pad to the background. + Blurs an image and adds a singular landing pad to the background. background: A numpy image. landing_data: Landing pad data for the landing pad to be blurred and added. - Returns: Image with the landing pad. """ x, y = background.shape[:2] @@ -95,7 +125,7 @@ def add_blurred_landing_pad( mask = np.zeros((x, y), np.uint8) mask = cv2.ellipse( mask, - landing_data.center, + landing_data.centre, landing_data.axis, landing_data.angle, 0, @@ -125,16 +155,23 @@ def draw_landing_pad( Returns: Image with landing pad and the bounding box for the drawn landing pad. """ - (h, k), (a, b) = landing_data.center, landing_data.axis + centre_x, centre_y = landing_data.centre + axis_x, axis_y = landing_data.axis angle_in_rad = math.radians(landing_data.angle) - ux, uy = a * math.cos(angle_in_rad), a * math.sin(angle_in_rad) - vx, vy = b * math.sin(angle_in_rad), b * math.cos(angle_in_rad) - width, height = 2 * math.sqrt(ux**2 + vx**2), 2 * math.sqrt(uy**2 + vy**2) - top_left = (int(max(h - (0.5) * width, 0)), int(max(k - (0.5) * height, 0))) + ux = axis_x * math.cos(angle_in_rad) + uy = axis_x * math.sin(angle_in_rad) + + vx = axis_y * math.sin(angle_in_rad) + vy = axis_y * math.cos(angle_in_rad) + + width = 2 * math.sqrt(ux**2 + vx**2) + height = 2 * math.sqrt(uy**2 + vy**2) + + top_left = (int(max(centre_x - (0.5) * width, 0)), int(max(centre_y - (0.5) * height, 0))) bottom_right = ( - int(min(h + (0.5) * width, image.shape[1])), - int(min(k + (0.5) * height, image.shape[0])), + min(centre_x + (0.5) * width, image.shape[1]), + min(centre_y + (0.5) * height, image.shape[0]), ) bounding_box = BoundingBox(top_left, bottom_right) @@ -145,7 +182,7 @@ def draw_landing_pad( image = cv2.ellipse( image, - landing_data.center, + landing_data.centre, landing_data.axis, landing_data.angle, 0, @@ -164,12 +201,11 @@ def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpec Returns: The image and expected bounding box. """ - image = np.full( - shape=(1000, 2000, 3), fill_value=255, dtype=np.int16 - ) + image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int8) confidence_and_label = [1, 0] # List to hold the bounding boxes. + # boxes_list = [confidence, label, top_left_x, top_left_y, bottom_right_x, bottom_right_y] boxes_list = [] for landing_data in landing_list: @@ -179,11 +215,9 @@ def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpec confidence_and_label + list(bounding_box.top_left + bounding_box.bottom_right) ) - # Calculates the area of the bounding box. + # Sorts by the area of the bounding box boxes_list = sorted( - boxes_list, - reverse=True, - key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])), + boxes_list, reverse=True, key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])) ) boxes_list = np.array(boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 413f7076..95a299ad 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -1,79 +1,93 @@ """ -Test Contour Detection module. - +Test contour detection module. """ -import cv2 + import numpy as np import pytest -from tests.unit.generate_detect_target_contour import ( - LandingPadImageConfig, - InputImageAndExpectedBoundingBoxes, - create_test, -) +from tests.unit import generate_detect_target_contour from modules import detections_and_time from modules import image_and_time from modules.detect_target import detect_target_contour from modules.common.modules.logger import logger # Changed from relative to absolute import -BOUNDING_BOX_PRECISION_TOLERANCE = -2 / 3 # Tolerance > 1 +BOUNDING_BOX_PRECISION_TOLERANCE = -1 # Tolerance > 1 CONFIDENCE_PRECISION_TOLERANCE = 2 LOGGER_NAME = "" + # Test functions use test fixture signature names and access class privates # No enable # pylint: disable=protected-access,redefined-outer-name @pytest.fixture -def single_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore +def single_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single basic circle. """ - options = [LandingPadImageConfig(center=(300, 400), axis=(200, 200), blur=False, angle=0)] + options = [ + generate_detect_target_contour.LandingPadImageConfig( + centre=(300, 400), axis=(200, 200), blur=False, angle=0 + ) + ] - test_data = create_test(options) + test_data = generate_detect_target_contour.create_test(options) yield test_data @pytest.fixture -def single_blurry_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore +def single_blurry_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single blury circle. """ options = [ - LandingPadImageConfig(center=(1000, 500), axis=(423, 423), blur=True, angle=0), + generate_detect_target_contour.LandingPadImageConfig( + centre=(1000, 500), axis=(423, 423), blur=True, angle=0 + ), ] - test_data = create_test(options) + test_data = generate_detect_target_contour.create_test(options) yield test_data @pytest.fixture -def single_stretched_circle() -> InputImageAndExpectedBoundingBoxes: # type: ignore +def single_stretched_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the single stretched circle. """ - options = [LandingPadImageConfig(center=(1000, 500), axis=(383, 405), blur=False, angle=0)] + options = [ + generate_detect_target_contour.LandingPadImageConfig( + centre=(1000, 500), axis=(383, 405), blur=False, angle=0 + ) + ] - test_data = create_test(options) + test_data = generate_detect_target_contour.create_test(options) yield test_data @pytest.fixture -def multiple_circles() -> InputImageAndExpectedBoundingBoxes: # type: ignore +def multiple_circles() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore """ Loads the data for the multiple stretched circles. """ options = [ - LandingPadImageConfig(center=(997, 600), axis=(300, 300), blur=False, angle=0), - LandingPadImageConfig(center=(1590, 341), axis=(250, 250), blur=False, angle=0), - LandingPadImageConfig(center=(200, 500), axis=(50, 45), blur=True, angle=0), - LandingPadImageConfig(center=(401, 307), axis=(200, 150), blur=True, angle=0), + generate_detect_target_contour.LandingPadImageConfig( + centre=(997, 600), axis=(300, 300), blur=False, angle=0 + ), + generate_detect_target_contour.LandingPadImageConfig( + centre=(1590, 341), axis=(250, 250), blur=False, angle=0 + ), + generate_detect_target_contour.LandingPadImageConfig( + centre=(200, 500), axis=(50, 45), blur=True, angle=0 + ), + generate_detect_target_contour.LandingPadImageConfig( + centre=(401, 307), axis=(200, 150), blur=True, angle=0 + ), ] - test_data = create_test(options) + test_data = generate_detect_target_contour.create_test(options) yield test_data @@ -92,7 +106,7 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore @pytest.fixture() -def image_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def image_easy(single_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single basic landing pad. """ @@ -105,7 +119,7 @@ def image_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_and_t @pytest.fixture() -def blurry_image(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def blurry_image(single_blurry_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single blurry landing pad. """ @@ -118,7 +132,7 @@ def blurry_image(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> im @pytest.fixture() -def stretched_image(single_stretched_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def stretched_image(single_stretched_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the single stretched landing pad. """ @@ -131,7 +145,7 @@ def stretched_image(single_stretched_circle: InputImageAndExpectedBoundingBoxes) @pytest.fixture() -def multiple_images(multiple_circles: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def multiple_images(multiple_circles: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load the multiple landing pads. """ @@ -144,7 +158,7 @@ def multiple_images(multiple_circles: InputImageAndExpectedBoundingBoxes) -> ima @pytest.fixture() -def expected_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def expected_easy(single_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected a basic image detections. """ @@ -154,7 +168,7 @@ def expected_easy(single_circle: InputImageAndExpectedBoundingBoxes) -> image_an @pytest.fixture() -def expected_blur(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def expected_blur(single_blurry_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected the blured pad image detections. """ @@ -164,7 +178,7 @@ def expected_blur(single_blurry_circle: InputImageAndExpectedBoundingBoxes) -> i @pytest.fixture() -def expected_stretch(single_stretched_circle: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def expected_stretch(single_stretched_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected a stretched pad image detections. """ @@ -174,7 +188,7 @@ def expected_stretch(single_stretched_circle: InputImageAndExpectedBoundingBoxes @pytest.fixture() -def expected_multiple(multiple_circles: InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore +def expected_multiple(multiple_circles: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore """ Load expected multiple pads image detections. """ @@ -274,7 +288,7 @@ def test_single_circle( expected_easy: detections_and_time.DetectionsAndTime, ) -> None: """ - Run the detection for the single landing pad. + Run the detection for the single cirucluar landing pad. """ # Run result, actual = detector.run(image_easy) @@ -292,7 +306,7 @@ def test_blurry_circle( expected_blur: detections_and_time.DetectionsAndTime, ) -> None: """ - Run the detection for the blury circle. + Run the detection for the blury cicular circle. """ # Run result, actual = detector.run(blurry_image) From d423026ba31d5b209b48f8e63d45917d388c3283 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Mon, 17 Mar 2025 21:59:35 -0400 Subject: [PATCH 24/27] Refactored + Rebase --- .../detect_target/detect_target_contour.py | 6 +- tests/unit/generate_detect_target_contour.py | 39 ++-- tests/unit/test_detect_target_contour.py | 197 ++++-------------- 3 files changed, 59 insertions(+), 183 deletions(-) diff --git a/modules/detect_target/detect_target_contour.py b/modules/detect_target/detect_target_contour.py index 32814ffb..c283a69a 100644 --- a/modules/detect_target/detect_target_contour.py +++ b/modules/detect_target/detect_target_contour.py @@ -16,15 +16,17 @@ MIN_CONTOUR_AREA = 100 MAX_CIRCULARITY = 1.3 MIN_CIRCULARITY = 0.7 + UPPER_BLUE = np.array([130, 255, 255]) LOWER_BLUE = np.array([100, 50, 50]) + CONFIDENCE = 1.0 LABEL = 0 class DetectTargetContour(base_detect_target.BaseDetectTarget): """ - Predicts annd locates landing pads using the classical computer vision methodology. + Predicts and locates landing pads using the classical computer vision methodology. """ def __init__( @@ -49,7 +51,7 @@ def detect_landing_pads_contours( """ Detects landing pads using contours/classical CV. - image: Current image frame. + image_and_time_data: Data for the current image and time. timestamp: Timestamp for the detections. Return: Success, the DetectionsAndTime object, and the annotated image. diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index 6a097862..9f84e9ec 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -8,6 +8,7 @@ import numpy as np from modules import detections_and_time +from modules import image_and_time LANDING_PAD_COLOUR_BLUE = (100, 50, 50) # BGR @@ -57,32 +58,27 @@ class BoundingBox: def __init__(self, top_left: tuple[float, float], bottom_right: tuple[float, float]): """ - top_left: pixel coordinates representing the top left corner of the bounding box on an image. + top_left: The pixel coordinates representing the top left corner of the bounding box on an image. bottom_right: pixel coordinates representing the bottom right corner of the bounding box on an image. """ self.top_left = top_left self.bottom_right = bottom_right -class InputImageAndExpectedBoundingBoxes: +class InputImageAndTimeAndExpectedBoundingBoxes: """ Struct to hold the data needed to perform the tests. """ - def __init__(self, image: np.ndarray, boxes_list: np.ndarray): + def __init__(self, image_and_time_data: image_and_time.ImageAndTime, bounding_box_list: list): """ - image: A numpy array that represents the image needed to be tested. - bounding_box_list: A numpy array that holds a list of expected bounding box coordinates. - - The bounding box coordinates are in the following format: - top_left_x = boxes_list[0] - top_left_y = boxes_list[1] - - bottom_right_x = boxes_list[2] - bottom_right_x = boxes_list[3] + image_and_time_data: ImageAndTime object containing the image and timestamp + bounding_box_list: A list that holds expected bounding box coordinates. + Given in the following format: + [conf, label, top_left_x, top_left_y, bottom_right_x, bottom_right_y] """ - self.image = image - self.bounding_box_list = boxes_list + self.image_and_time_data = image_and_time_data + self.bounding_box_list = bounding_box_list def create_detections(detections_from_file: np.ndarray) -> detections_and_time.DetectionsAndTime: @@ -168,7 +164,7 @@ def draw_landing_pad( width = 2 * math.sqrt(ux**2 + vx**2) height = 2 * math.sqrt(uy**2 + vy**2) - top_left = (int(max(centre_x - (0.5) * width, 0)), int(max(centre_y - (0.5) * height, 0))) + top_left = (max(centre_x - (0.5) * width, 0), max(centre_y - (0.5) * height, 0)) bottom_right = ( min(centre_x + (0.5) * width, image.shape[1]), min(centre_y + (0.5) * height, image.shape[0]), @@ -193,7 +189,9 @@ def draw_landing_pad( return NumpyImage(image), bounding_box -def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpectedBoundingBoxes: +def create_test( + landing_list: list[LandingPadImageConfig], +) -> InputImageAndTimeAndExpectedBoundingBoxes: """ Generates test cases given a data set. @@ -201,7 +199,7 @@ def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpec Returns: The image and expected bounding box. """ - image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.int8) + image = np.full(shape=(1000, 2000, 3), fill_value=255, dtype=np.uint8) confidence_and_label = [1, 0] # List to hold the bounding boxes. @@ -220,7 +218,10 @@ def create_test(landing_list: list[LandingPadImageConfig]) -> InputImageAndExpec boxes_list, reverse=True, key=lambda box: abs((box[4] - box[2]) * (box[5] - box[3])) ) - boxes_list = np.array(boxes_list) image = image.astype(np.uint8) + result, image_and_time_data = image_and_time.ImageAndTime.create(image) + + assert result + assert image_and_time_data is not None - return InputImageAndExpectedBoundingBoxes(image, boxes_list) + return InputImageAndTimeAndExpectedBoundingBoxes(image_and_time_data, boxes_list) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 95a299ad..cc093cc5 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -5,12 +5,10 @@ import numpy as np import pytest -from tests.unit import generate_detect_target_contour from modules import detections_and_time -from modules import image_and_time from modules.detect_target import detect_target_contour -from modules.common.modules.logger import logger # Changed from relative to absolute import - +from modules.common.modules.logger import logger +from tests.unit import generate_detect_target_contour BOUNDING_BOX_PRECISION_TOLERANCE = -1 # Tolerance > 1 CONFIDENCE_PRECISION_TOLERANCE = 2 @@ -18,12 +16,10 @@ # Test functions use test fixture signature names and access class privates -# No enable -# pylint: disable=protected-access,redefined-outer-name - +# pylint: disable=protected-access,redefined-outer-name, duplicate-code @pytest.fixture -def single_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore +def single_circle() -> generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes: """ Loads the data for the single basic circle. """ @@ -38,7 +34,7 @@ def single_circle() -> generate_detect_target_contour.InputImageAndExpectedBound @pytest.fixture -def single_blurry_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore +def blurry_circle() -> generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes: """ Loads the data for the single blury circle. """ @@ -53,7 +49,7 @@ def single_blurry_circle() -> generate_detect_target_contour.InputImageAndExpect @pytest.fixture -def single_stretched_circle() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore +def stretched_circle() -> generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes: """ Loads the data for the single stretched circle. """ @@ -68,7 +64,7 @@ def single_stretched_circle() -> generate_detect_target_contour.InputImageAndExp @pytest.fixture -def multiple_circles() -> generate_detect_target_contour.InputImageAndExpectedBoundingBoxes: # type: ignore +def multiple_circles() -> generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes: """ Loads the data for the multiple stretched circles. """ @@ -105,177 +101,58 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore yield detection # type: ignore -@pytest.fixture() -def image_easy(single_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load the single basic landing pad. - """ - - image = single_circle.image - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def blurry_image(single_blurry_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load the single blurry landing pad. - """ - - image = single_blurry_circle.image - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def stretched_image(single_stretched_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load the single stretched landing pad. - """ - - image = single_stretched_circle.image - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def multiple_images(multiple_circles: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load the multiple landing pads. - """ - - image = multiple_circles.image - result, actual_image = image_and_time.ImageAndTime.create(image) - assert result - assert actual_image is not None - yield actual_image # type: ignore - - -@pytest.fixture() -def expected_easy(single_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected a basic image detections. - """ - - expected = single_circle.bounding_box_list - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_blur(single_blurry_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected the blured pad image detections. - """ - - expected = single_blurry_circle.bounding_box_list - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_stretch(single_stretched_circle: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected a stretched pad image detections. - """ - - expected = single_stretched_circle.bounding_box_list - yield create_detections(expected) # type: ignore - - -@pytest.fixture() -def expected_multiple(multiple_circles: generate_detect_target_contour.InputImageAndExpectedBoundingBoxes) -> image_and_time.ImageAndTime: # type: ignore - """ - Load expected multiple pads image detections. - """ - - expected = multiple_circles.bounding_box_list - yield create_detections(expected) # type: ignore - - -# pylint:disable=duplicate-code def compare_detections( - actual: detections_and_time.DetectionsAndTime, expected: detections_and_time.DetectionsAndTime + actual: list[detections_and_time.Detection], expected: list[list[float]] ) -> None: """ Compare expected and actual detections. """ - assert len(actual.detections) == len(expected.detections) - - # Using integer indexing for both lists - # pylint: disable-next=consider-using-enumerate + assert len(actual) == len(expected) # Ordered for the mapping to the corresponding detections sorted_actual_detections = sorted( - actual.detections, + actual, reverse=True, - key=lambda box: abs((box.x_1 - box.x_2) * (box.y_1 - box.y_2)), + key=lambda box: abs((box.x_2 - box.x_1) * (box.y_2 - box.y_1)), ) - for i in range(0, len(expected.detections)): - expected_detection = expected.detections[i] + for i, expected_detection in enumerate(expected): actual_detection = sorted_actual_detections[i] - assert expected_detection.label == actual_detection.label + # Check label and confidence + assert actual_detection.label == expected_detection[1] np.testing.assert_almost_equal( - expected_detection.confidence, actual_detection.confidence, + expected_detection[0], decimal=CONFIDENCE_PRECISION_TOLERANCE, ) + # Check bounding box coordinates np.testing.assert_almost_equal( actual_detection.x_1, - expected_detection.x_1, + expected_detection[2], decimal=BOUNDING_BOX_PRECISION_TOLERANCE, ) np.testing.assert_almost_equal( actual_detection.y_1, - expected_detection.y_1, + expected_detection[3], decimal=BOUNDING_BOX_PRECISION_TOLERANCE, ) np.testing.assert_almost_equal( actual_detection.x_2, - expected_detection.x_2, + expected_detection[4], decimal=BOUNDING_BOX_PRECISION_TOLERANCE, ) np.testing.assert_almost_equal( actual_detection.y_2, - expected_detection.y_2, + expected_detection[5], decimal=BOUNDING_BOX_PRECISION_TOLERANCE, ) -def create_detections(detections_from_file: np.ndarray) -> detections_and_time.DetectionsAndTime: - """ - Create DetectionsAndTime from expected. - Format: [confidence, label, x_1, y_1, x_2, y_2] . - """ - assert detections_from_file.shape[1] == 6 - - result, detections = detections_and_time.DetectionsAndTime.create(0) - assert result - assert detections is not None - - for i in range(0, detections_from_file.shape[0]): - result, detection = detections_and_time.Detection.create( - detections_from_file[i][2:], - int(detections_from_file[i][1]), - detections_from_file[i][0], - ) - assert result - assert detection is not None - detections.append(detection) - - return detections - - class TestDetector: """ Tests `DetectTarget.run()` . @@ -284,71 +161,67 @@ class TestDetector: def test_single_circle( self, detector: detect_target_contour.DetectTargetContour, - image_easy: image_and_time.ImageAndTime, - expected_easy: detections_and_time.DetectionsAndTime, + single_circle: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ Run the detection for the single cirucluar landing pad. """ # Run - result, actual = detector.run(image_easy) + result, actual = detector.run(single_circle.image_and_time_data) # Test assert result assert actual is not None - compare_detections(actual, expected_easy) + compare_detections(actual.detections, single_circle.bounding_box_list) def test_blurry_circle( self, detector: detect_target_contour.DetectTargetContour, - blurry_image: image_and_time.ImageAndTime, - expected_blur: detections_and_time.DetectionsAndTime, + blurry_circle: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ Run the detection for the blury cicular circle. """ # Run - result, actual = detector.run(blurry_image) + result, actual = detector.run(blurry_circle.image_and_time_data) # Test assert result assert actual is not None - compare_detections(actual, expected_blur) + compare_detections(actual.detections, blurry_circle.bounding_box_list) - def test_stretch( + def test_stretched_circle( self, detector: detect_target_contour.DetectTargetContour, - stretched_image: image_and_time.ImageAndTime, - expected_stretch: detections_and_time.DetectionsAndTime, + stretched_circle: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ - Run the detection for the single stretched landing pad. + Run the detection for a single stretched circular landing pad. """ # Run - result, actual = detector.run(stretched_image) + result, actual = detector.run(stretched_circle.image_and_time_data) # Test assert result assert actual is not None - compare_detections(actual, expected_stretch) + compare_detections(actual.detections, stretched_circle.bounding_box_list) - def test_multiple( + def test_multiple_circles( self, detector: detect_target_contour.DetectTargetContour, - multiple_images: image_and_time.ImageAndTime, - expected_multiple: detections_and_time.DetectionsAndTime, + multiple_circles: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ Run the detection for the multiple landing pads. """ # Run - result, actual = detector.run(multiple_images) + result, actual = detector.run(multiple_circles.image_and_time_data) # Test assert result assert actual is not None - compare_detections(actual, expected_multiple) + compare_detections(actual.detections, multiple_circles.bounding_box_list) From 4b8aa3c3a360ff847c9b315e24c6f8a73c8ca839 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Mon, 17 Mar 2025 22:00:24 -0400 Subject: [PATCH 25/27] Removed create_detections --- tests/unit/generate_detect_target_contour.py | 24 -------------------- 1 file changed, 24 deletions(-) diff --git a/tests/unit/generate_detect_target_contour.py b/tests/unit/generate_detect_target_contour.py index 9f84e9ec..c1677f4a 100644 --- a/tests/unit/generate_detect_target_contour.py +++ b/tests/unit/generate_detect_target_contour.py @@ -81,30 +81,6 @@ def __init__(self, image_and_time_data: image_and_time.ImageAndTime, bounding_bo self.bounding_box_list = bounding_box_list -def create_detections(detections_from_file: np.ndarray) -> detections_and_time.DetectionsAndTime: - """ - Create DetectionsAndTime from expected. - Format: [confidence, label, x_1, y_1, x_2, y_2] . - """ - assert detections_from_file.shape[1] == 6 - - result, detections = detections_and_time.DetectionsAndTime.create(0) - assert result - assert detections is not None - - for i in range(0, detections_from_file.shape[0]): - result, detection = detections_and_time.Detection.create( - detections_from_file[i][2:], - int(detections_from_file[i][1]), - detections_from_file[i][0], - ) - assert result - assert detection is not None - detections.append(detection) - - return detections - - def add_blurred_landing_pad( background: np.ndarray, landing_data: LandingPadImageConfig ) -> NumpyImage: From cc24a0471d1908b02eeb0d86d61bdcd9389d458b Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Mon, 17 Mar 2025 22:13:42 -0400 Subject: [PATCH 26/27] Updated compare_detections --- tests/unit/test_detect_target_contour.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index cc093cc5..14c71ba6 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -102,11 +102,16 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore def compare_detections( - actual: list[detections_and_time.Detection], expected: list[list[float]] + actual_and_expected_detections: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes ) -> None: """ Compare expected and actual detections. + + actual_and_expected_detections: Test data containing both actual image and time and expected bounding boxes. """ + actual = actual_and_expected_detections.image_and_time_data.detector + expected = actual_and_expected_detections.bounding_box_list + assert len(actual) == len(expected) # Ordered for the mapping to the corresponding detections @@ -119,7 +124,7 @@ def compare_detections( for i, expected_detection in enumerate(expected): actual_detection = sorted_actual_detections[i] - # Check label and confidence + # Check label and confidence assert actual_detection.label == expected_detection[1] np.testing.assert_almost_equal( actual_detection.confidence, @@ -164,7 +169,7 @@ def test_single_circle( single_circle: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ - Run the detection for the single cirucluar landing pad. + Run the detection for the single circular landing pad. """ # Run result, actual = detector.run(single_circle.image_and_time_data) @@ -173,7 +178,12 @@ def test_single_circle( assert result assert actual is not None - compare_detections(actual.detections, single_circle.bounding_box_list) + # Create new object with actual detections + test_data = generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes( + actual, + single_circle.bounding_box_list + ) + compare_detections(test_data) def test_blurry_circle( self, @@ -190,7 +200,7 @@ def test_blurry_circle( assert result assert actual is not None - compare_detections(actual.detections, blurry_circle.bounding_box_list) + compare_detections(blurry_circle) def test_stretched_circle( self, @@ -207,7 +217,7 @@ def test_stretched_circle( assert result assert actual is not None - compare_detections(actual.detections, stretched_circle.bounding_box_list) + compare_detections(stretched_circle) def test_multiple_circles( self, @@ -224,4 +234,4 @@ def test_multiple_circles( assert result assert actual is not None - compare_detections(actual.detections, multiple_circles.bounding_box_list) + compare_detections(multiple_circles.bounding_box_list) From e52227943ef8fd0093baf5f17cad8a05d3957ac1 Mon Sep 17 00:00:00 2001 From: Achita <ssgssachita@gmail.com> Date: Mon, 17 Mar 2025 22:18:47 -0400 Subject: [PATCH 27/27] black . space?? --- tests/unit/test_detect_target_contour.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_detect_target_contour.py b/tests/unit/test_detect_target_contour.py index 14c71ba6..3f0c7d39 100644 --- a/tests/unit/test_detect_target_contour.py +++ b/tests/unit/test_detect_target_contour.py @@ -18,6 +18,7 @@ # Test functions use test fixture signature names and access class privates # pylint: disable=protected-access,redefined-outer-name, duplicate-code + @pytest.fixture def single_circle() -> generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes: """ @@ -102,14 +103,14 @@ def detector() -> detect_target_contour.DetectTargetContour: # type: ignore def compare_detections( - actual_and_expected_detections: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes + actual_and_expected_detections: generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes, ) -> None: """ Compare expected and actual detections. - + actual_and_expected_detections: Test data containing both actual image and time and expected bounding boxes. """ - actual = actual_and_expected_detections.image_and_time_data.detector + actual = actual_and_expected_detections.image_and_time_data.detector expected = actual_and_expected_detections.bounding_box_list assert len(actual) == len(expected) @@ -124,7 +125,7 @@ def compare_detections( for i, expected_detection in enumerate(expected): actual_detection = sorted_actual_detections[i] - # Check label and confidence + # Check label and confidence assert actual_detection.label == expected_detection[1] np.testing.assert_almost_equal( actual_detection.confidence, @@ -180,8 +181,7 @@ def test_single_circle( # Create new object with actual detections test_data = generate_detect_target_contour.InputImageAndTimeAndExpectedBoundingBoxes( - actual, - single_circle.bounding_box_list + actual, single_circle.bounding_box_list ) compare_detections(test_data)