├── .gitignore ├── .idea ├── .gitignore ├── IconMatch.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── saveactions_settings.xml └── vcs.xml ├── IconMatch ├── IconMatch.py ├── __init__.py ├── box.py ├── helpers.py ├── manual_test.py ├── rectangle.py └── weighted_quick_unionUF.py ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── images ├── nearest_box.gif └── screenshot.png ├── pyproject.toml ├── requirements.txt ├── rt_demo.py ├── test ├── test-images │ ├── fullSize.png │ ├── google.png │ ├── google_small.png │ ├── header.png │ ├── popular.png │ ├── small_vscode.png │ └── vscode.png └── test.py └── travis.yml /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,vscode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,vscode 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | pytestdebug.log 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | doc/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | pythonenv* 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # profiling data 143 | .prof 144 | 145 | ### vscode ### 146 | .vscode 147 | !.vscode/settings.json 148 | !.vscode/tasks.json 149 | !.vscode/launch.json 150 | !.vscode/extensions.json 151 | *.code-workspace 152 | 153 | ### PyCharm ### 154 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 155 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 156 | 157 | # User-specific stuff 158 | .idea/**/workspace.xml 159 | .idea/**/tasks.xml 160 | .idea/**/usage.statistics.xml 161 | .idea/**/dictionaries 162 | .idea/**/shelf 163 | 164 | # Generated files 165 | .idea/**/contentModel.xml 166 | 167 | # Sensitive or high-churn files 168 | .idea/**/dataSources/ 169 | .idea/**/dataSources.ids 170 | .idea/**/dataSources.local.xml 171 | .idea/**/sqlDataSources.xml 172 | .idea/**/dynamic.xml 173 | .idea/**/uiDesigner.xml 174 | .idea/**/dbnavigator.xml 175 | 176 | # Gradle 177 | .idea/**/gradle.xml 178 | .idea/**/libraries 179 | 180 | # Gradle and Maven with auto-import 181 | # When using Gradle or Maven with auto-import, you should exclude module files, 182 | # since they will be recreated, and may cause churn. Uncomment if using 183 | # auto-import. 184 | # .idea/artifacts 185 | # .idea/compiler.xml 186 | # .idea/jarRepositories.xml 187 | # .idea/modules.xml 188 | # .idea/*.iml 189 | # .idea/modules 190 | # *.iml 191 | # *.ipr 192 | 193 | # CMake 194 | cmake-build-*/ 195 | 196 | # Mongo Explorer plugin 197 | .idea/**/mongoSettings.xml 198 | 199 | # File-based project format 200 | *.iws 201 | 202 | # IntelliJ 203 | out/ 204 | 205 | # mpeltonen/sbt-idea plugin 206 | .idea_modules/ 207 | 208 | # JIRA plugin 209 | atlassian-ide-plugin.xml 210 | 211 | # Cursive Clojure plugin 212 | .idea/replstate.xml 213 | 214 | # Crashlytics plugin (for Android Studio and IntelliJ) 215 | com_crashlytics_export_strings.xml 216 | crashlytics.properties 217 | crashlytics-build.properties 218 | fabric.properties 219 | 220 | # Editor-based Rest Client 221 | .idea/httpRequests 222 | 223 | # Android studio 3.1+ serialized cache file 224 | .idea/caches/build_file_checksums.ser 225 | 226 | ### PyCharm Patch ### 227 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 228 | 229 | # *.iml 230 | # modules.xml 231 | # .idea/misc.xml 232 | # *.ipr 233 | 234 | # Sonarlint plugin 235 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 236 | .idea/**/sonarlint/ 237 | 238 | # SonarQube Plugin 239 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 240 | .idea/**/sonarIssues.xml 241 | 242 | # Markdown Navigator plugin 243 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 244 | .idea/**/markdown-navigator.xml 245 | .idea/**/markdown-navigator-enh.xml 246 | .idea/**/markdown-navigator/ 247 | 248 | # Cache file creation bug 249 | # See https://youtrack.jetbrains.com/issue/JBR-2257 250 | .idea/$CACHE_FILE$ 251 | 252 | # CodeStream plugin 253 | # https://plugins.jetbrains.com/plugin/12206-codestream 254 | .idea/codestream.xml 255 | 256 | # End of https://www.toptal.com/developers/gitignore/api/pycharm 257 | 258 | # Ignore images 259 | *.png -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/IconMatch.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/saveactions_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /IconMatch/IconMatch.py: -------------------------------------------------------------------------------- 1 | import cv2 as cv 2 | 3 | from PIL import ImageGrab 4 | from IconMatch.box import grayscale_blur, canny_detection, group_rects 5 | 6 | class ScreenScanner: 7 | """ 8 | ScreenScanner class captures a screenshot, processes it, and detects rectangles 9 | using an image scanning algorithm. 10 | 11 | Attributes: 12 | scanner (ImageScanner): An instance of ImageScanner for processing images. 13 | thresh (int): The threshold value for image processing. 14 | """ 15 | 16 | def __init__(self, thresh: int = 100): 17 | """ 18 | Initializes the ScreenScanner with a specified threshold. 19 | 20 | Args: 21 | thresh (int): The threshold value for image processing. Default is 100. 22 | """ 23 | self.scanner = ImageScanner(thresh) 24 | self.thresh = thresh 25 | 26 | def updateThresh(self, thresh: int): 27 | """ 28 | Updates the threshold value used by the scanner. 29 | 30 | Args: 31 | thresh (int): The new threshold value. 32 | """ 33 | self.scanner.updateThresh(thresh) 34 | 35 | def scan(self, bbox=None, beforeScreenshot = lambda : None , afterScreenshot = lambda : None): 36 | """ 37 | Captures a screenshot, processes it to detect rectangles, and adjusts 38 | the coordinates based on the bounding box. 39 | 40 | Args: 41 | bbox (tuple, optional): The bounding box for the screenshot. Default is None. 42 | 43 | Returns: 44 | list: A list of rectangles detected in the format (x, y, width, height). 45 | """ 46 | beforeScreenshot() 47 | screenshot = ImageGrab.grab(bbox=bbox) 48 | afterScreenshot() 49 | 50 | screenshot.save("__tmp.png") 51 | src = cv.imread("__tmp.png") 52 | # TODO: add x and y offset to the result rectangles 53 | rects = self.scanner.scan(src) 54 | 55 | if bbox: 56 | x, y = bbox[0], bbox[1] 57 | rects = [(rect[0] + x, rect[1] + y, rect[2], rect[3]) for rect in rects] 58 | return rects 59 | 60 | 61 | class ImageScanner: 62 | """ 63 | ImageScanner class processes images to detect rectangles based on edge detection. 64 | 65 | Attributes: 66 | thresh (int): The threshold value for image processing. 67 | """ 68 | 69 | def __init__(self, thresh: int = 100): 70 | """ 71 | Initializes the ImageScanner with a specified threshold. 72 | 73 | Args: 74 | thresh (int): The threshold value for image processing. Default is 100. 75 | """ 76 | self.thresh = thresh 77 | 78 | def updateThresh(self, thresh: int): 79 | """ 80 | Updates the threshold value used for image processing. 81 | 82 | Args: 83 | thresh (int): The new threshold value. 84 | """ 85 | self.thresh = thresh 86 | 87 | def scan(self, src) -> list: 88 | """ 89 | Processes an input image to detect rectangles. 90 | 91 | Args: 92 | src (MatLike): The source image to be processed. 93 | 94 | Returns: 95 | list: A list of bounding rectangles detected in the format (x, y, width, height). 96 | """ 97 | # accept an input image and convert it to grayscale, and blur it 98 | gray_scale_image = grayscale_blur(src) 99 | 100 | # determine the bounding rectangles from canny detection 101 | _, bound_rect = canny_detection(gray_scale_image, min_threshold=self.thresh) 102 | 103 | # group the rectangles from this step 104 | grouped_rects = group_rects(bound_rect, 0, src.shape[1]) 105 | 106 | return grouped_rects 107 | -------------------------------------------------------------------------------- /IconMatch/__init__.py: -------------------------------------------------------------------------------- 1 | # version of the icondetection package 2 | __version__ = "0.1.0" 3 | -------------------------------------------------------------------------------- /IconMatch/box.py: -------------------------------------------------------------------------------- 1 | import heapq 2 | 3 | import cv2 as cv 4 | from typing import List 5 | 6 | from IconMatch.rectangle import Rectangle 7 | from IconMatch.weighted_quick_unionUF import WeightedQuickUnionUF as uf 8 | 9 | 10 | def containing_rectangle(rects: List[Rectangle], query_point: tuple) -> Rectangle or None: 11 | """ 12 | Provide the rectangle that covers this query point. Return None if there is no overlap. 13 | TODO: Currently non-deterministic due to iterating through an unordered list. 14 | """ 15 | 16 | # brute force implementation for now 17 | for rect in rects: 18 | if rect.contains_point(query_point): 19 | return rect 20 | 21 | return None 22 | 23 | 24 | def closest_rectangle(rects: List[Rectangle], query_point: tuple) -> Rectangle: 25 | """ 26 | Determine the closest rectangle to this query point. 27 | TODO: Currently non-deterministic due to iterating through an unordered list. 28 | """ 29 | 30 | closest_distance = rects[0].distance_to_point(query_point) 31 | closest_rect = rects[0] 32 | 33 | for rect in rects[1:]: 34 | temp_dist = rect.distance_to_point(query_point) 35 | if temp_dist < closest_distance: 36 | closest_distance = temp_dist 37 | closest_rect = rect 38 | 39 | return closest_rect 40 | 41 | 42 | def candidate_rectangle(rects: List[Rectangle], query_point: tuple) -> Rectangle: 43 | """ 44 | Return the closest rectangle, when given a query point in two dimensional space and a list of rectangles. 45 | todo: Correctness is not yet guaranteed. 46 | """ 47 | 48 | # first verify if the query_point is within a rectangle. If it is, return this rectangle. 49 | potential_rect = containing_rectangle(rects, query_point) 50 | if potential_rect is not None: 51 | return potential_rect 52 | 53 | # we now check what is the closest individual point to the query_point. 54 | potential_rect = closest_rectangle(rects, query_point) 55 | 56 | return potential_rect 57 | 58 | 59 | def grayscale_blur(image): 60 | """ 61 | Convert image to gray and blur it. 62 | """ 63 | image_gray = cv.cvtColor(image, cv.COLOR_BGR2GRAY) 64 | image_gray = cv.blur(image_gray, (3, 3)) 65 | 66 | return image_gray 67 | 68 | 69 | def rect_list_to_dict(rects): 70 | """ 71 | Take a list of cv rects and returns their dictionary representation for simple filtering. 72 | """ 73 | # POTENTIALLY PROBLEMATIC: necessarily has to be a list of (index, rects) 74 | rect_dict = {} 75 | rect_list = [None] * len(rects) 76 | 77 | for rect_index in range(len(rects)): 78 | temp_rect = Rectangle.rect_cv_to_cartesian(rects[rect_index]) 79 | 80 | # step to modify dictionary 81 | if temp_rect.left in rect_dict: 82 | tmp_list = rect_dict[temp_rect.left] 83 | tmp_list.append((rect_index, temp_rect)) 84 | rect_dict[temp_rect.left] = tmp_list 85 | else: 86 | rect_dict[temp_rect.left] = [(rect_index, temp_rect)] 87 | 88 | # step to modify list 89 | rect_list[rect_index] = temp_rect 90 | 91 | return rect_dict, rect_list 92 | 93 | 94 | def group_rects(cv_rects, min_x, max_x): 95 | """ 96 | Accepts a list of rects in openCV format, and groups them according to their 97 | overlapping locations. 98 | """ 99 | 100 | rect_dict, rect_list = rect_list_to_dict(cv_rects) 101 | rect_heap = [] 102 | unified_rects = uf(len(rect_list), rect_list) 103 | 104 | for x in range(min_x, max_x): 105 | 106 | # prune any outdated rects from the current_rects 107 | while True: 108 | if len(rect_heap) == 0: 109 | break 110 | if rect_heap[0][0] == x - 1: # means we are at the edge 111 | heapq.heappop(rect_heap) 112 | else: 113 | break 114 | 115 | # get rectangles in this index 116 | if x in rect_dict: 117 | temp_rects = rect_dict[x] 118 | else: 119 | continue 120 | 121 | # perform intersection 122 | for rectA in rect_heap: 123 | for rectB in temp_rects: 124 | if Rectangle.intersect(rectA[1], rectB[1]): 125 | unified_rects.union(rectA[2], rectB[0]) 126 | 127 | # push new elements onto heap 128 | for rectB in temp_rects: 129 | heapq.heappush(rect_heap, (rectB[1].right, rectB[1], rectB[0])) 130 | 131 | # perform groupings 132 | grouped_rects = [] 133 | unions = unified_rects.get_unions() 134 | for group in unions.values(): 135 | grouped_rects.append( 136 | Rectangle.rect_cartesian_to_cv(Rectangle.merge_rects(group)) 137 | ) 138 | 139 | return grouped_rects 140 | 141 | 142 | def canny_detection(gray_scale_image=None, **kwargs): 143 | """ 144 | Run openCV Canny detection on a provided gray scale image. Return the polygons of canny contours and bounding 145 | rectangles. 146 | https://docs.opencv.org/3.4/da/d0c/tutorial_bounding_rects_circles.html 147 | """ 148 | 149 | multiplier = kwargs['multiplier'] if 'multiplier' in kwargs else 2 150 | contour_accuracy = kwargs['contour_accuracy'] if 'multiplier' in kwargs else 3 151 | min_threshold = kwargs['min_threshold'] if 'min_threshold' in kwargs else 100 152 | max_threshold = int(min_threshold * multiplier) 153 | 154 | canny_output = cv.Canny(gray_scale_image, min_threshold, max_threshold) 155 | 156 | contours, _ = cv.findContours(canny_output, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE) 157 | 158 | contours_poly = [None] * len(contours) 159 | bound_rect = [None] * len(contours) 160 | 161 | for index, contour in enumerate(contours): 162 | contours_poly[index] = cv.approxPolyDP(contour, contour_accuracy, True) 163 | bound_rect[index] = cv.boundingRect(contours_poly[index]) 164 | 165 | return contours_poly, bound_rect 166 | -------------------------------------------------------------------------------- /IconMatch/helpers.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import numpy as np 3 | import cv2 4 | 5 | 6 | def run_sift(img): 7 | img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 8 | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 9 | 10 | sift = cv2.SIFT_create() 11 | kp = sift.detect(gray, None) 12 | 13 | cv2.drawKeypoints(image=gray, keypoints=kp, outImage=img) 14 | return img 15 | 16 | 17 | def save_img(img, name): 18 | if isinstance(img, Image.Image): 19 | img.save("{0}.png".format(name)) 20 | else: 21 | cv2.imwrite("{0}.png".format(name), img) 22 | 23 | 24 | def open_img(img_path): 25 | return cv2.imread(img_path) 26 | -------------------------------------------------------------------------------- /IconMatch/manual_test.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageGrab 2 | from pynput import mouse 3 | import IconMatch 4 | from IconMatch.helpers import run_sift, save_img 5 | 6 | 7 | def main(): 8 | """ 9 | Manual test is an interactive screen shot tool that will perform SIFT on the image. 10 | todo: Add more options for edge detection algorithm and options on where to save image. 11 | todo: Add functionality for previewing before saving an image. 12 | """ 13 | print("Choose the top left and bottom right coordinates of your box.") 14 | 15 | button_presses = [] 16 | 17 | # Stream of events 18 | with mouse.Events() as events: 19 | for event in events: 20 | if hasattr(event, "button") and event.pressed: 21 | print( 22 | "The {0} button was {1} at the following coordinates: x:{2}, y:{3}".format( 23 | event.button, 24 | "pressed" if event.pressed else "released", 25 | event.x, 26 | event.y, 27 | ) 28 | ) 29 | button_presses.append(event) 30 | if len(button_presses) == 2: 31 | break 32 | 33 | screen_shot = ImageGrab.grab( 34 | bbox=( 35 | button_presses[0].x, 36 | button_presses[0].y, 37 | button_presses[1].x, 38 | button_presses[1].y, 39 | ) 40 | ) 41 | 42 | save_img(screen_shot, "pre") 43 | run_sift(screen_shot) 44 | save_img(screen_shot, "post") 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /IconMatch/rectangle.py: -------------------------------------------------------------------------------- 1 | import math 2 | import sys 3 | 4 | from typing import List, Tuple 5 | 6 | 7 | class Rectangle: 8 | """ 9 | Representation of a rectangle in two dimensional space. 10 | 11 | Note: Keep in mind that due to OpenCV's representation of the screen, y increases from top to bottom! 12 | 0 - x + 13 | | 14 | y 15 | + 16 | Where top, bottom, left and right are named relative to cartesian coordinates. The below 17 | diagram shows what this entails. 18 | Top 19 | -------- 20 | | | 21 | Left | | Right 22 | | | 23 | -------- 24 | Bottom 25 | """ 26 | 27 | def __init__(self, top: int, left: int, bottom: int, right: int): 28 | """ 29 | Create a rectangle in Cartesian notation 30 | """ 31 | 32 | self.top = top 33 | self.left = left 34 | self.bottom = bottom 35 | self.right = right 36 | 37 | # stored for cached initialization 38 | self.area = None 39 | 40 | @staticmethod 41 | def intersect(rect_a: 'Rectangle', rect_b: 'Rectangle') -> bool: 42 | """ 43 | Determine if rect_a, rect_b intersect. 44 | Modified slightly from: 45 | https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other#306332 46 | """ 47 | 48 | ret = ( 49 | rect_a.left < rect_b.right 50 | and rect_a.right > rect_b.left 51 | and rect_a.top < rect_b.bottom 52 | and rect_a.bottom > rect_b.top 53 | ) 54 | return ret 55 | 56 | @staticmethod 57 | def merge_rects(rects: List['Rectangle']) -> 'Rectangle': 58 | """ 59 | Merge a list of rectangles into one conglomerate rect in Cartesian representation. 60 | """ 61 | 62 | ans = Rectangle(sys.maxsize, sys.maxsize, 0, 0) 63 | 64 | for rect in rects: 65 | if rect.left < ans.left: 66 | ans.left = rect.left 67 | if rect.top < ans.top: 68 | ans.top = rect.top 69 | if rect.bottom > ans.bottom: 70 | ans.bottom = rect.bottom 71 | if rect.right > ans.right: 72 | ans.right = rect.right 73 | 74 | return ans 75 | 76 | @staticmethod 77 | def rect_cv_to_cartesian(rect: Tuple[int, int, int, int]) -> 'Rectangle': 78 | """ 79 | Convert a rectangle from CV representation to cartesian coordinates. 80 | """ 81 | new_rect = Rectangle(rect[0], rect[1], rect[0] + rect[2], rect[1] + rect[3]) 82 | return new_rect 83 | 84 | @staticmethod 85 | def rect_cartesian_to_cv(rect: 'Rectangle') -> tuple: 86 | """ 87 | Convert rectangle from Cartesian representation back to CV tuple representation 88 | """ 89 | new_rect = (rect.top, rect.left, rect.bottom - rect.top, rect.right - rect.left) 90 | return new_rect 91 | 92 | def __eq__(self, other: 'Rectangle') -> bool: 93 | if isinstance(other, Rectangle): 94 | return ( 95 | self.top == other.top 96 | and self.left == other.left 97 | and self.right == other.right 98 | and self.bottom == other.bottom 99 | ) 100 | return False 101 | 102 | def __lt__(self, other: 'Rectangle') -> bool: 103 | # todo: Ideate a more absolute definition of "less than" 104 | if self.get_area() > other.get_area(): 105 | return True 106 | return False 107 | 108 | def get_area(self) -> int: 109 | """ 110 | Return the area taken up by this rectangle. 111 | """ 112 | if self.area is None: 113 | self.area = (self.right - self.left) * (self.bottom - self.top) 114 | return self.area 115 | 116 | def contains_point(self, point: Tuple[int, int]) -> bool: 117 | """ 118 | Given a Tuple representing a point, return whether the point is within this rectangle. 119 | """ 120 | if self.distance_to_point(point) == 0.0: 121 | return True 122 | return False 123 | 124 | def distance_to_point(self, point: tuple) -> float: 125 | """ 126 | Determine the distance from a rectangle to a point. If the point is within a rectangle, zero will be returned. 127 | https://stackoverflow.com/questions/5254838/calculating-distance-between-a-point-and-a-rectangular-box-nearest-point 128 | """ 129 | dx = max(self.left - point[0], 0, point[0] - self.right) 130 | dy = max(self.top - point[1], 0, point[1] - self.bottom) 131 | 132 | if dx == 0 and dy == 0: 133 | return 0.0 134 | 135 | # potentially problematic due to using floats 136 | return math.sqrt(dx ** 2 + dy ** 2) 137 | 138 | def __str__(self): 139 | return "({1},{0}), ({3},{2})".format(self.top, self.left, self.bottom, self.right) 140 | -------------------------------------------------------------------------------- /IconMatch/weighted_quick_unionUF.py: -------------------------------------------------------------------------------- 1 | class WeightedQuickUnionUF: 2 | """ 3 | Weighted Quick Union UF is a Python conversion of the algorithm as implemented by Kevin Wayne and Robert Sedgewick 4 | for their Algorithms 1 course on Coursera. 5 | 6 | https://algs4.cs.princeton.edu/code/javadoc/edu/princeton/cs/algs4/WeightedQuickUnionUF.html 7 | """ 8 | 9 | def __init__(self, n: int, entries): 10 | """ 11 | Initializes an empty union–find data structure with n sites 12 | 0 through n-1. Each site is initially in its own 13 | component. 14 | 15 | Within the parent list, each entry is a tuple that contains the "parent" 16 | index, as well as the object holding that specific entry (generic) 17 | """ 18 | self._parent = [(0, None)] * n 19 | self._size = [1] * n 20 | self._count = n 21 | for i in range(n): 22 | self._parent[i] = (i, entries[i]) 23 | 24 | def count(self): 25 | """ 26 | Returns the number of components. 27 | """ 28 | return self._count 29 | 30 | def find(self, p: int): 31 | """ 32 | Returns the component identifier for the component containing site p. 33 | """ 34 | self._validate(p) 35 | while p != self._parent[p][0]: 36 | # path compression (make every other node in path point to its grandparent) 37 | self._parent[p] = (self._parent[self._parent[p][0]][0], self._parent[p][1]) 38 | 39 | p = self._parent[p][0] 40 | 41 | return p 42 | 43 | def _validate(self, p: int): 44 | """ 45 | Validate that p is a valid index. 46 | """ 47 | n = len(self._parent) 48 | if p is None or p < 0 or p >= n: 49 | raise ValueError("index {0} is not between 0 and {1}".format(p, n - 1)) 50 | 51 | def connected(self, p: int, q: int): 52 | """ 53 | Returns true if the the two sites are in the same component. 54 | """ 55 | return self.find(p) == self.find(q) 56 | pass 57 | 58 | def union(self, p: int, q: int): 59 | """ 60 | Merges the component containing site p with the component containing site q. 61 | """ 62 | root_p = self.find(p) 63 | root_q = self.find(q) 64 | if root_p == root_q: 65 | return 66 | 67 | # make smaller root point to larger one 68 | if self._size[root_p] < self._size[root_q]: 69 | self._parent[root_p] = (root_q, self._parent[root_p][1]) 70 | self._size[root_q] = self._size[root_q] + self._size[root_p] 71 | else: 72 | self._parent[root_q] = (root_p, self._parent[root_q][1]) 73 | self._size[root_p] = self._size[root_p] + self._size[root_q] 74 | 75 | self._count = self._count - 1 76 | pass 77 | 78 | def get_unions(self): 79 | """ 80 | Retrieves and returns all groups, according to their parent 81 | """ 82 | components = {} 83 | for index_element in range(len(self._parent)): 84 | # get parent component 85 | index_parent = self.find(index_element) 86 | parent = self._parent[index_parent][1] 87 | child = self._parent[index_element][1] 88 | 89 | # add it to the mapping, or add the current component to its list 90 | if index_parent not in components: 91 | if index_element == index_parent: 92 | components[index_parent] = [ 93 | child, 94 | ] 95 | else: 96 | components[index_parent] = [ 97 | parent, 98 | child, 99 | ] 100 | else: 101 | parent_list = components[index_parent] 102 | parent_list.append(child) 103 | components[index_parent] = parent_list 104 | 105 | return components 106 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luis Zugasti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include images/screenshot.png 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | IconMatch 3 |

4 | 5 |

6 | Easily select icons on the screen in any environment. 7 |

8 | 9 |

10 | 11 | Showcasing bounding boxes and original image 12 | 13 | 14 | Showcasing candidate boxes functionality 15 | 16 | 17 | Showcasing realtime demo 18 | 19 |

20 | 21 | 22 | This is part of the Hands Free Computing project made by @luis__zugasti . Built with [OpenCV 3.12](https://opencv.org) and [Python 3.8](https://python.org). 23 | 24 | As project was dead for 5years and We found use for it, we have decided to maintain its fork. 25 | 26 | ### 💜 Support NativeSensors: 27 | 28 | Subscribe on Polar 29 | 30 | ## Table of Contents 31 | 32 | 33 | - [Installation](#installation) 34 | - [Usage](#usage) 35 | - [API](#api) 36 | - [Roadmap](#roadmap) 37 | - [Contributing](#contributing) 38 | - [License](#license) 39 | - [Contact](#contact) 40 | 41 | 42 | ## Installation 43 | 44 | 1. Install from PyPI: 45 | ``` 46 | $ pip install iconmatch 47 | ``` 48 | 49 | ## Usage 50 | 51 | You can use the functions as shown in [demo.py](https://github.com/luiszugasti/IconMatch/blob/main/icondetection/demo/demo.py) as a default entry point. 52 | 53 | In the below example, the main set of functions is called within a callback function, as this allows the threshold value 54 | to be controlled from a GUI in OpenCV. 55 | 56 | Image Scanner: 57 | 58 | ```python 59 | import cv2 as cv 60 | 61 | import IconMatch.IconMatch from ImageScanner 62 | 63 | src = cv.imread("source to your image file") 64 | scanner = ImageScanner(thersh = 100) 65 | 66 | detected_rectangles = scanner.scan(src) 67 | # list of [(x,y,w,h),(x,y,w,h), ... , (x,y,w,h)] 68 | 69 | ``` 70 | 71 | Screen Scanner: 72 | 73 | ```python 74 | import IconMatch.IconMatch from ScreenScanner 75 | 76 | scanner = ScreenScanner(thersh = 100) 77 | 78 | detected_rectangles = scanner.scan(bbox = (x,y,w,h)) 79 | # list of [(x,y,w,h),(x,y,w,h), ... , (x,y,w,h)] 80 | 81 | ``` 82 | 83 | RealTime demo: 84 | ```bash 85 | python rt_demo.py 86 | ``` 87 | 88 | ## Key Features 89 | 90 | - Detection of areas with a high likelihood of being clickable icons. 91 | - Detection of closest rectangle to point of interest (be it gaze, or mouse as in the examples) 92 | 93 | ## API 94 | 95 | The current available APIs encompass what your image processing pipeline should contain. Both APIs are 96 | currently still experimental as I learn more about OpenCV and optimize code. 97 | 98 | ### ImageScanner 99 | > Performs Canny detection on passed images and group overlapping rectangles 100 | 101 | ### ScreenScanner 102 | > Scans your display, take screnshoots and call ImageScanners 103 | 104 | ## Roadmap 105 | 106 | - [x] Detect regions of interest with moderate accuracy 107 | - [x] Detect candidate region based on proximity 108 | - [x] Detect icon-like objects on the screen 109 | - [?] Context provision into regions of interest 110 | 111 | 112 | ## Contributing 113 | 114 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **genuinely appreciated**. 115 | 116 | 1. Fork the Project 117 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 118 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 119 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 120 | 5. Open a Pull Request 121 | 122 | ## License 123 | 124 | Distributed under the MIT License. See `LICENSE` for more information. 125 | 126 | ## Contact 127 | 128 | Original Creator: Luis Zugasti - [@luis\_\_zugasti](https://twitter.com/luis__zugasti) 129 | Original Creator blog: [https://luiszugasti.me](https://luiszugasti.me) 130 | 131 | Current Maintainer: Piotr Walas - [@Piotr\_\_Walas](https://twitter.com/PW4ltz) 132 | 133 | Project Link: [https://github.com/NativeSensors/IconMatch](https://github.com/NativeSensors/IconMatch) 134 | -------------------------------------------------------------------------------- /images/nearest_box.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/images/nearest_box.gif -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/images/screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.sdist] 6 | include = [ 7 | "IconMatch/*.py", 8 | ] 9 | 10 | exclude = [ 11 | "*.json", 12 | "*.png", 13 | ] 14 | 15 | [project] 16 | name = "IconMatch" 17 | version = "0.2.0" 18 | authors = [ 19 | { name="Piotr Walas", email="piotr.walas@eyegestures.com" }, 20 | ] 21 | maintainers = [ 22 | { name="Piotr Walas", email="piotr.walas@eyegestures.com" } 23 | ] 24 | description = "Package for detecting Icons from images or screen" 25 | readme = "README.md" 26 | license = {file = "LICENSE.txt"} 27 | requires-python = ">=3.8" 28 | keywords = ["IconMatch", "NativeSensors", "Icon", "scanning"] 29 | classifiers = [ 30 | "Programming Language :: Python :: 3", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/NativeSensors/IconMatch" 37 | Issues = "https://github.com/NativeSensors/IconMatch/Issues" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.26.4 2 | opencv_python_headless==4.8.1.78 3 | Pillow==10.0.0 4 | pynput==1.7.6 5 | PySide2==5.15.2.1 6 | -------------------------------------------------------------------------------- /rt_demo.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import time 3 | 4 | import sys 5 | from PySide2.QtCore import Qt, QTimer 6 | from PySide2.QtGui import QPainter, QColor, QBrush, QPen 7 | from PySide2.QtWidgets import QApplication, QWidget 8 | 9 | from pynput import mouse 10 | 11 | from IconMatch.IconMatch import ScreenScanner 12 | 13 | class CircleWidget(QWidget): 14 | def __init__(self): 15 | super().__init__() 16 | self.diameter = 50 17 | self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.WindowTransparentForInput) 18 | self.setAttribute(Qt.WA_TranslucentBackground) 19 | self.setAttribute(Qt.WA_TransparentForMouseEvents) 20 | self.setGeometry(0, 0, self.diameter+10, self.diameter+10) 21 | 22 | self.setColor(204, 54, 54) 23 | 24 | self.to_y = self.x() 25 | self.to_x = self.y() 26 | 27 | 28 | self.pos_call = None 29 | 30 | # Start a timer to update the position periodically 31 | self.timer = QTimer(self) 32 | self.timer.timeout.connect(self.update_position) 33 | self.timer.start(30) # Update every second 34 | 35 | def connect_pos_call(self,pos_call): 36 | self.pos_call = pos_call 37 | 38 | def update_position(self): 39 | # Randomly generate new position within the screen boundaries 40 | if self.pos_call: 41 | x,y = self.pos_call() 42 | self.setPosition(x,y) 43 | 44 | new_x = self.to_x 45 | new_y = self.to_y 46 | 47 | if self.geometry().width() != self.diameter + 10: 48 | self.setGeometry(0, 0, self.diameter + 10, self.diameter + 10) 49 | self.move(new_x, new_y) 50 | self.repaint() 51 | 52 | def setPosition(self,x,y): 53 | self.to_x = x - self.diameter/2 54 | self.to_y = y - self.diameter/2 55 | 56 | def setRadius(self,diameter): 57 | self.diameter = diameter 58 | 59 | def setColor(self,r,g,b): 60 | self.brush_color = QColor(r,g,b, 50) 61 | self.pen_color = QColor(r, g, b) # Red color for the border 62 | 63 | def paintEvent(self, event): 64 | painter = QPainter(self) 65 | painter.setRenderHint(QPainter.Antialiasing) 66 | 67 | # brush_color = QColor(18, 34, 78, 50) # Semi-transparent red color 68 | brush = QBrush(self.brush_color) 69 | painter.setBrush(brush) 70 | # Set the pen color and width 71 | pen_width = 2 # Width of the border 72 | pen = QPen(self.pen_color, pen_width) 73 | painter.setPen(pen) 74 | 75 | # Draw a circle 76 | painter.drawEllipse((self.width() - self.diameter) / 2, (self.height() - self.diameter) / 2, self.diameter, self.diameter) 77 | 78 | class CursorTracker: 79 | 80 | def __init__(self,rate=5): 81 | window_h = 3000 82 | window_w = 3000 83 | self.scanner = ScreenScanner() 84 | rectangles = self.scanner.scan(bbox = (0,0,window_w,window_h)) 85 | 86 | self.DSB = DynamicSpatialBuckets() 87 | self.DSB.loadData(rectangles) 88 | self.mouse = mouse.Controller() 89 | 90 | self.start = time.time() 91 | self.rate = rate 92 | 93 | def rescan(self,x,y,w,h): 94 | rectangles = self.scanner.scan(bbox = (x,y,x+w,y+h)) 95 | self.DSB = DynamicSpatialBuckets() 96 | self.DSB.loadData(rectangles) 97 | 98 | def getPos(self): 99 | 100 | x,y = self.mouse.position 101 | if((time.time() - self.start) > self.rate): 102 | self.start = time.time() 103 | width = 3000 104 | height = 3000 105 | self.rescan(x-width/2,y-height/2,width,height) 106 | 107 | rectangles = self.DSB.getBucket([x,y]) 108 | closest_distance = 9999 109 | closest_rectangle = None 110 | 111 | for rectangle in rectangles: 112 | center_x = rectangle[0] + rectangle[2]/2 113 | center_y = rectangle[1] + rectangle[3]/2 114 | distance = np.linalg.norm(np.array([center_x,center_y]) - np.array([x,y])) 115 | if distance < closest_distance: 116 | closest_distance = distance 117 | closest_rectangle = rectangle 118 | 119 | if closest_rectangle: 120 | return (closest_rectangle[0],closest_rectangle[1]) 121 | return 0,0 122 | 123 | class DynamicSpatialBuckets: 124 | 125 | def __init__(self): 126 | 127 | self.buckets = [[]] 128 | self.step = 500 129 | 130 | def loadData(self,rectangles): 131 | 132 | for rectangle in rectangles: 133 | center_x = rectangle[0] + rectangle[2]/2 134 | center_y = rectangle[1] + rectangle[3]/2 135 | 136 | index_x = int(center_x/self.step) 137 | index_y = int(center_y/self.step) 138 | 139 | while(index_x >= len(self.buckets)): 140 | self.buckets.append([]) 141 | 142 | while(index_y >= len(self.buckets[index_x])): 143 | self.buckets[index_x].append([]) 144 | 145 | self.buckets[index_x][index_y].append(rectangle) 146 | 147 | def getBucket(self,point): 148 | 149 | index_x = int(point[0]/self.step) 150 | index_y = int(point[1]/self.step) 151 | 152 | ret_bucket = [] 153 | if len(self.buckets) > index_x and len(self.buckets[index_x]) > index_y: 154 | ret_bucket = self.buckets[index_x][index_y] 155 | 156 | return ret_bucket 157 | 158 | if __name__ == "__main__": 159 | app = QApplication(sys.argv) 160 | dot = CircleWidget() 161 | dot.show() 162 | tracker = CursorTracker() 163 | dot.connect_pos_call(tracker.getPos) 164 | sys.exit(app.exec_()) 165 | -------------------------------------------------------------------------------- /test/test-images/fullSize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/fullSize.png -------------------------------------------------------------------------------- /test/test-images/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/google.png -------------------------------------------------------------------------------- /test/test-images/google_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/google_small.png -------------------------------------------------------------------------------- /test/test-images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/header.png -------------------------------------------------------------------------------- /test/test-images/popular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/popular.png -------------------------------------------------------------------------------- /test/test-images/small_vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/small_vscode.png -------------------------------------------------------------------------------- /test/test-images/vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NativeSensors/IconMatch/d07e78356e0cb6a2cbc437d05854053fb4599070/test/test-images/vscode.png -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import IconMatch.rectangle as r 4 | from IconMatch import box 5 | from IconMatch.weighted_quick_unionUF import WeightedQuickUnionUF as uf 6 | 7 | 8 | class TestSIFT(unittest.TestCase): 9 | def test_invalid_datatype(self): 10 | """ 11 | No tests for sift yet 12 | """ 13 | self.assertEqual(1, 1) 14 | 15 | 16 | class TestUF(unittest.TestCase): 17 | """ 18 | Test the base methods of Union Find. 19 | """ 20 | 21 | def setUp(self): 22 | self.a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 23 | self.t = uf(4, self.a[:4]) 24 | self.u = uf(10, self.a) 25 | 26 | def test_count(self): 27 | self.assertEqual(self.t.count, 4) 28 | self.t.union(0, 3) 29 | 30 | self.assertEqual(self.t.count, 3) 31 | self.t.union(3, 0) 32 | 33 | self.assertEqual(self.t.count, 3) 34 | self.t.union(2, 0) 35 | 36 | self.assertEqual(self.t.count, 2) 37 | self.t.union(1, 0) 38 | 39 | self.assertEqual(self.t.count, 1) 40 | self.assertTrue(self.t.connected(1, 0)) 41 | 42 | self.assertEqual(self.u.count, 10) 43 | 44 | self.u.union(0, 8) 45 | self.u.union(3, 5) 46 | self.u.union(4, 7) 47 | self.u.union(1, 9) 48 | self.assertEqual(self.u.count, 6) 49 | 50 | def test_find(self): 51 | # p, q << all things equal, p will be picked 52 | self.t.union(0, 3) 53 | self.assertEqual(self.t.find(3), 0, "Parent should be 0") 54 | 55 | self.t.union(3, 0) 56 | self.assertEqual(self.t.find(3), 0, "No change is expected") 57 | self.assertEqual(self.t.size[0], 2, "Parent 0 should have 2 components") 58 | 59 | self.t.union(2, 0) 60 | self.assertEqual(self.t.size[0], 3, "Parent 0 should have 3 components") 61 | 62 | self.t.union(1, 0) 63 | self.assertEqual(self.t.size[0], 4, "Parent 0 should have 4 components") 64 | 65 | def test_get_unions(self): 66 | self.u.union(0, 9) 67 | self.u.union(1, 8) 68 | self.u.union(2, 7) 69 | self.u.union(3, 6) 70 | self.u.union(4, 5) 71 | 72 | unions_u = self.u.get_unions() 73 | self.assertCountEqual([0, 1, 2, 3, 4], unions_u.keys()) 74 | 75 | self.t.union(0, 3) 76 | self.t.union(2, 0) 77 | self.t.union(1, 0) 78 | unions_t = self.t.get_unions() 79 | self.assertCountEqual([0], unions_t.keys()) 80 | 81 | 82 | class TestRectangle(unittest.TestCase): 83 | """ 84 | Test Rectangle Methods 85 | """ 86 | 87 | def setUp(self): 88 | self.global_rect = r.Rectangle(3, 3, 9, 9) 89 | 90 | def test_same_intersect(self): 91 | """ 92 | Tests that a rectangle overlaps with itself 93 | """ 94 | a = r.Rectangle(2, 2, 8, 8) 95 | 96 | # the same rect should overlap 97 | self.assertTrue(r.Rectangle.intersect(a, a)) 98 | 99 | def test_types_of_overlap(self): 100 | """ 101 | Test different types of overlap 102 | """ 103 | # top, left, bottom, right 104 | a = r.Rectangle(2, 2, 8, 8) 105 | c = r.Rectangle(4, 4, 8, 8) 106 | d = r.Rectangle(2, 2, 6, 6) 107 | e = r.Rectangle(2, 4, 8, 8) 108 | f = r.Rectangle(2, 2, 8, 6) 109 | 110 | # One rectangle overlapping with bottom right 111 | self.assertTrue(r.Rectangle.intersect(a, c)) 112 | 113 | # Top left 114 | self.assertTrue(r.Rectangle.intersect(a, d)) 115 | 116 | # Top right 117 | self.assertTrue(r.Rectangle.intersect(a, e)) 118 | 119 | # Bottom left 120 | self.assertTrue(r.Rectangle.intersect(a, f)) 121 | 122 | def test_types_of_non_overlap(self): 123 | """ 124 | Test different types of non-overlap 125 | """ 126 | # top, left, bottom, right 127 | a = r.Rectangle(2, 2, 8, 8) 128 | c = r.Rectangle(10, 10, 16, 16) 129 | d = r.Rectangle(2, 8, 8, 12) 130 | e = r.Rectangle(8, 2, 10, 8) 131 | f = r.Rectangle(8, 8, 16, 16) 132 | g = r.Rectangle(0, 0, 2, 2) 133 | 134 | self.assertFalse(r.Rectangle.intersect(a, c)) 135 | self.assertFalse(r.Rectangle.intersect(a, d)) 136 | self.assertFalse(r.Rectangle.intersect(a, e)) 137 | self.assertFalse(r.Rectangle.intersect(a, f)) 138 | self.assertFalse(r.Rectangle.intersect(a, g)) 139 | 140 | def test_conglomeration(self): 141 | """ 142 | Test that the complete bounding rectangle is returned. 143 | """ 144 | # top, left, bottom, right 145 | a = r.Rectangle(2, 2, 8, 8) 146 | c = r.Rectangle(4, 4, 8, 8) 147 | d = r.Rectangle(2, 2, 6, 6) 148 | e = r.Rectangle(2, 4, 8, 8) 149 | f = r.Rectangle(2, 2, 8, 6) 150 | 151 | g = r.Rectangle(2, 2, 16, 16) 152 | h = r.Rectangle(15, 15, 16, 16) 153 | 154 | self.assertEqual(a, r.Rectangle.merge_rects([a, c, d, e, f])) 155 | 156 | self.assertEqual(g, r.Rectangle.merge_rects([a, h])) 157 | 158 | def test_contains_point(self): 159 | """ 160 | Test multiple points being in and out of the global rectangle 161 | """ 162 | 163 | # points that are inside, or on the fringe 164 | point1 = (4, 4) 165 | point2 = (3, 3) 166 | point3 = (3, 9) 167 | point4 = (9, 3) 168 | 169 | self.assertTrue(self.global_rect.contains_point(point1)) 170 | self.assertTrue(self.global_rect.contains_point(point2)) 171 | self.assertTrue(self.global_rect.contains_point(point3)) 172 | self.assertTrue(self.global_rect.contains_point(point4)) 173 | 174 | # points that are outside 175 | point1a = (1, 4) 176 | point2a = (10, 3) 177 | point3a = (3, 90) 178 | point4a = (9, -1) 179 | 180 | self.assertFalse(self.global_rect.contains_point(point1a)) 181 | self.assertFalse(self.global_rect.contains_point(point2a)) 182 | self.assertFalse(self.global_rect.contains_point(point3a)) 183 | self.assertFalse(self.global_rect.contains_point(point4a)) 184 | 185 | def test_distance_to_point(self): 186 | """ 187 | Test multiple distances for rectangle 188 | """ 189 | 190 | # points that are inside, or on the fringe 191 | point1 = (4, 4) 192 | point2 = (3, 3) 193 | point3 = (3, 9) 194 | point4 = (9, 3) 195 | 196 | self.assertAlmostEqual(0.0, self.global_rect.distance_to_point(point1)) 197 | self.assertAlmostEqual(0.0, self.global_rect.distance_to_point(point2)) 198 | self.assertAlmostEqual(0.0, self.global_rect.distance_to_point(point3)) 199 | self.assertAlmostEqual(0.0, self.global_rect.distance_to_point(point4)) 200 | 201 | # points that are outside 202 | point1a = (1, 4) 203 | point2a = (10, 3) 204 | point3a = (3, 90) 205 | point4a = (9, -1) 206 | 207 | self.assertAlmostEqual(2.0, self.global_rect.distance_to_point(point1a)) 208 | self.assertAlmostEqual(1.0, self.global_rect.distance_to_point(point2a)) 209 | self.assertAlmostEqual(81.0, self.global_rect.distance_to_point(point3a)) 210 | self.assertAlmostEqual(4.0, self.global_rect.distance_to_point(point4a)) 211 | 212 | 213 | class TestBox(unittest.TestCase): 214 | """ 215 | Test functionality in Box 216 | """ 217 | 218 | def setUp(self): 219 | self.google_rectangles = [r.Rectangle(193, 279, 236, 297), 220 | r.Rectangle(255, 279, 275, 294), 221 | r.Rectangle(241, 282, 250, 294), 222 | r.Rectangle(343, 279, 353, 294), 223 | r.Rectangle(353, 279, 410, 298), 224 | r.Rectangle(316, 279, 333, 294), 225 | r.Rectangle(275, 280, 310, 294), 226 | r.Rectangle(272, 279, 274, 293), 227 | r.Rectangle(237, 279, 240, 294), 228 | r.Rectangle(391, 225, 428, 240), 229 | r.Rectangle(319, 226, 338, 237), 230 | r.Rectangle(341, 225, 387, 240), 231 | r.Rectangle(228, 226, 273, 237), 232 | r.Rectangle(178, 225, 224, 240), 233 | r.Rectangle(559, 151, 573, 171), 234 | r.Rectangle(29, 152, 43, 167), 235 | r.Rectangle(396, 44, 437, 89), 236 | r.Rectangle(334, 44, 376, 108), 237 | r.Rectangle(286, 44, 331, 89), 238 | r.Rectangle(238, 44, 283, 89), 239 | r.Rectangle(382, 21, 392, 87), 240 | r.Rectangle(382, 21, 392, 87), 241 | r.Rectangle(167, 19, 235, 89), 242 | r.Rectangle(167, 19, 235, 89), 243 | ] 244 | 245 | self.G_blue = r.Rectangle(14, 6, 84, 74) 246 | self.o_red = r.Rectangle(39, 77, 84, 122) 247 | self.o_yellow = r.Rectangle(39, 125, 84, 170) 248 | self.g_blue = r.Rectangle(39, 173, 103, 215) 249 | self.l_green = r.Rectangle(16, 221, 82, 231) 250 | self.e_red = r.Rectangle(39, 235, 84, 276) 251 | self.google_rectangles_small = [self.G_blue, 252 | self.o_red, 253 | self.o_yellow, 254 | self.g_blue, 255 | self.l_green, 256 | self.e_red, 257 | ] 258 | 259 | def test_in_rectangle_out_rectangle(self): 260 | point_under_capital_g_blue = (40, 45) 261 | point_in_o_red = (100, 63) 262 | point_in_o_yellow = (151, 63) 263 | point_in_g_blue = (196, 72) 264 | point_in_l_green = (226, 51) 265 | point_in_e_red = (259, 66) 266 | 267 | # inside the rectangle 268 | self.assertTrue(self.G_blue.contains_point(point_under_capital_g_blue)) 269 | self.assertTrue(self.o_red.contains_point(point_in_o_red)) 270 | self.assertTrue(self.o_yellow.contains_point(point_in_o_yellow)) 271 | self.assertTrue(self.g_blue.contains_point(point_in_g_blue)) 272 | self.assertTrue(self.l_green.contains_point(point_in_l_green)) 273 | self.assertTrue(self.e_red.contains_point(point_in_e_red)) 274 | 275 | # outside the rectangle 276 | self.assertFalse(self.G_blue.contains_point(point_in_o_red)) 277 | self.assertFalse(self.o_red.contains_point(point_in_o_yellow)) 278 | self.assertFalse(self.o_yellow.contains_point(point_in_o_red)) 279 | self.assertFalse(self.g_blue.contains_point(point_in_o_red)) 280 | self.assertFalse(self.l_green.contains_point(point_in_o_red)) 281 | self.assertFalse(self.e_red.contains_point(point_in_o_red)) 282 | 283 | def test_closest_rectangle_no_ties(self): 284 | point_under_capital_g_blue = (7, 95) 285 | point_under_o_red = (100, 92) 286 | point_under_o_yellow = (148, 89) 287 | point_under_g_blue = (193, 110) 288 | point_above_l_green = (226, 11) 289 | point_under_e_red = (259, 89) 290 | 291 | self.assertEqual(self.G_blue, box.closest_rectangle(self.google_rectangles_small, point_under_capital_g_blue)) 292 | self.assertEqual(self.o_red, box.closest_rectangle(self.google_rectangles_small, point_under_o_red)) 293 | self.assertEqual(self.o_yellow, box.closest_rectangle(self.google_rectangles_small, point_under_o_yellow)) 294 | self.assertEqual(self.g_blue, box.closest_rectangle(self.google_rectangles_small, point_under_g_blue)) 295 | self.assertEqual(self.l_green, box.closest_rectangle(self.google_rectangles_small, point_above_l_green)) 296 | self.assertEqual(self.e_red, box.closest_rectangle(self.google_rectangles_small, point_under_e_red)) 297 | 298 | def test_candidate_rectangle(self): 299 | # TODO: Complete this integration test. 300 | pass 301 | 302 | if __name__ == "__main__": 303 | unittest.main() 304 | -------------------------------------------------------------------------------- /travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.8.6" 4 | install: 5 | - pip install -r requirements.txt 6 | script: 7 | python -m unittest discover -s test -v --------------------------------------------------------------------------------