├── .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 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
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
--------------------------------------------------------------------------------