commit ceee58a6bc2412b94ab641d949a78d4c81274acf Author: Akumatic Date: Fri May 15 12:19:47 2020 +0200 Current state diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a69b2b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2020 Akumatic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7df4958 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# ExamScan +A Python 3 script to evaluate single and multiple choice exam sheets. + +## Example + +Input image: + +![Example Image](/example/test.png) + +Processed image: + +![Processed Image](/example/test_eval.png) + +## Usage + +`main.py (-u URL | -f FILE) -n NUM [-h] [-i IOUT] [-d DOUT] [-c COMP] [-p]` + +#### Flags: +##### Required, but mutually exclusive flags: +| Short | Long | Description | +| -- | -- | -- | +| -u URL | --url URL | URL to the image or pdf to be evaluated | +| -f FILE | --file FILE | path to the image or pdf to be evaluated | + +##### Required flags: +| Short | Long | Description | +| -- | -- | -- | +| -n NUM | --num NUM | number of answers per question | + +##### Optional flags: +| Short | Long | Description | +| -- | -- | -- | +|-h | --help | shows help message and exits | +| -i IOUT | --iout IOUT | path for the output picture to be stored. | +| -d DOUT | --dout DOUT | path for the output data to be stored. | +| -c COMP | --compare COMP | compares the calculated result to a given result | +| -p | --plot | plots every single step | + +## Requirements: +| Module | Pip | Git | +| -- | -- | -- | +| OpenCV | [opencv-python](https://pypi.org/project/opencv-python/) | [skvark/opencv-python](https://github.com/skvark/opencv-python) | +| NumPy | [numpy](https://pypi.org/project/numpy/) | [numpy/numpy](https://github.com/numpy/numpy) | +| Matplotlib | [matplotlib](https://pypi.org/project/matplotlib/) | [matplotlib/matplotlib](https://github.com/matplotlib/matplotlib) | +| imutils | [imutils](https://pypi.org/project/imutils/) | [jrosebr1/imutils](https://github.com/jrosebr1/imutils) | +| python-magic | [python-magic](https://pypi.org/project/python-magic/) | [ahupp/python-magic](https://github.com/ahupp/python-magic) | +| pdf2image | [pdf2image](https://pypi.org/project/pdf2image/) | [Belval/pdf2image](https://github.com/Belval/pdf2image) | \ No newline at end of file diff --git a/example/test.png b/example/test.png new file mode 100644 index 0000000..9e4e14c Binary files /dev/null and b/example/test.png differ diff --git a/example/test_eval.png b/example/test_eval.png new file mode 100644 index 0000000..c8bb035 Binary files /dev/null and b/example/test_eval.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..6980c1c --- /dev/null +++ b/main.py @@ -0,0 +1,69 @@ +#!/usr/bin/python +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +from scan import image, contours, io, eval + +if __name__ == "__main__": + # BGR Colors + BGR_R = (0, 0, 255) + BGR_G = (0, 255, 0) + BGR_B = (255, 0, 0) + + # Parse cli args + args = io.parse_args() + + # Reads File + img = io.read_image(path=args.file, url=args.url) + + # image copies for processing + img_orig = img.copy() + img_blur = image.blur(img) + img_thres = image.threshold(img_blur) + img_edges = image.edge_detection(img_thres.copy()) + + # gather contour data and find boxes. draws boxes + cnts = contours.find_contours(img_edges) + cnts_box = contours.find_boxes(cnts) + img_boxes = image.draw_contours(img_orig.copy(), cnts_box, BGR_G) + + # retrieve the center of all contours and + # filters entries too close to each other. + center_boxes = contours.find_center(cnts_box) + avg_radius = int(sum([contours.dist_center_topleft(center[2], + center[:2]) for center in center_boxes]) / len(center_boxes)) + contours.filter_centers(center_boxes, avg_radius) + + # Sort Box data + #center_boxes = sorted(center_boxes, key=lambda x: (x[1], x[0])) + + # draw center of given contours into original picture + img_center_boxes = image.draw_circles(img_orig.copy(), center_boxes, BGR_G) + + img_rad = image.draw_circles(img_thres.copy(), center_boxes, BGR_G, + radius=int(avg_radius)) + + center_eval = [(center[0], center[1], image.ratio_black(img_thres, center[:2], + avg_radius)) for center in center_boxes] + + img_eval = image.eval_image(img_orig.copy(), center_eval, avg_radius) + + evaluation = eval.evaluate(center_eval, args.num, avg_radius) + print(evaluation) + + if args.comp is not None: + result = eval.compare(evaluation, io.load_results(args.comp)) + print(result) + + # write result to file if optional flag is given + if args.iout is not None: + io.write_image(img_eval, args.iout) + print(f"Stored image to {args.iout}") + + if args.dout is not None: + io.store_results(evaluation, args.dout) + print(f"Stored data to {args.dout}") + + if args.plot: + io.plot(img_orig, img_blur, img_thres, img_edges, + img_boxes, img_center_boxes, img_rad, img_eval) \ No newline at end of file diff --git a/scan/__init__.py b/scan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scan/contours.py b/scan/contours.py new file mode 100644 index 0000000..55894f5 --- /dev/null +++ b/scan/contours.py @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +import cv2, imutils, numpy +from . import utils + +###################### +# Contour operations # +###################### + +def find_contours ( + image: numpy.ndarray, + mode: int = cv2.RETR_LIST, + method: int = cv2.CHAIN_APPROX_SIMPLE + ) -> list: + """ Find all contours of the filtered image + + Args: + image (ndarray): + the filtered image + + Returns: + A list containing contour data + """ + cnts = cv2.findContours(image.copy(), mode, method) + return imutils.grab_contours(cnts) + +def find_boxes ( + contours: list, + thres_area: int = 500, + pad_ratio: float = 0.05 + ) -> (list, list): + """ Find contours that resemble a box + + Args: + contours (list): + a list containing contour data + + Returns: + A list containing the box contours + """ + boxes = list() + for c in contours: + area = cv2.contourArea(c) + perimeter = cv2.arcLength(c, True) + shape_factor = utils.circularity(area, perimeter) + + if 0.7 < shape_factor < 0.85 and area >= thres_area: + boxes.append(c) + + return boxes + +def find_center ( + contours: list + ) -> list: + """ Find the center coordinates of all given contours. + + Args: + contours (list): + A list containing contour data + + Returns: + A list containing the center coordinates and the contour as tuples + (x, y, contour). + """ + centers = [] + for contour in contours: + m = cv2.moments(contour) + try: + x = int(m["m10"] / m["m00"]) + y = int(m["m01"] / m["m00"]) + centers.append((x, y, contour)) + except ZeroDivisionError: + pass + return centers + +def filter_centers ( + coords: list, + radius: int + ): + """ Removes all but one entry in circles given by coordinates and radius + + Args: + coords (list): + a list containing tuples of coordinates (x, y) + radius (float): + the radius around a center where no other center should be + """ + a = 0 + + while a < len(coords): + b = a + 1 + while b < len(coords): + if utils.distance(coords[a][:2], coords[b][:2]) <= radius: + del coords[b] + else: + b += 1 + a += 1 + +def dist_center_topleft ( + contour: numpy.ndarray, + center: tuple + ) -> float: + """ Calculates the distance from the center of a given contour to it's + top left corner + + Args: + contour (ndarray): + The contour data + center (tuple): + A tuple containing the center coordinates (x, y) + + Returns: + A float with the distance from center to the top left corner as value + """ + x, y, _, _ = cv2.boundingRect(contour) + return utils.distance((x, y), center[:2]) diff --git a/scan/eval.py b/scan/eval.py new file mode 100644 index 0000000..9380ca7 --- /dev/null +++ b/scan/eval.py @@ -0,0 +1,207 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +from . import utils + +def evaluate ( + centers: list, + n: int, + radius: int + ) -> list: + """ Evaluates the given data + + Args: + centers (list): + contains tuples with coordinates and the black-pixel-ratio + (x, y, ratio) + n (int): + the number of possible answers per question + radius (int): + the radius of the circle around a box + + Returns: + A list containings lists with evaluated answers + """ + start_of_file, _ = utils.find_closest_element(centers, (0,0)) + + # detect distance to next field on the right side + point_1 = start_of_file + point_r = find_next(centers, point_1, 2* radius, "r") + dist_r = utils.distance(point_1[:2], point_r[:2]) + + # detect distance to next field below + point_d = find_next(centers, point_1, 2 * radius, "d") + dist_d = utils.distance(point_1[:2], point_d[:2]) + + # detect distance to the next set of answers + for i in range(n - 2): + point_r = find_next(centers, point_r, dist_r, "r") + + point_3 = find_next(centers, point_r, 3 * radius, "r", max_dist=dist_r) + if point_3 is not None: + dist_set = utils.distance(start_of_file, point_3) + else: + dist_set = None + + answers = eval_set(start_of_file, centers, n, dist_r, dist_d) + if dist_set is not None: + cur = find_next(centers, start_of_file, dist_set, "r", max_dist = radius) + while cur is not None: + answers += eval_set(cur, centers, n, dist_r, dist_d) + cur = find_next(centers, cur, dist_set, "r", max_dist = radius) + + return answers + +def eval_set ( + start: tuple, + centers: list, + n: int, + dist_r: int, + dist_d: int + ) -> list: + """ Evaluates a set of answers + + Args: + start (tuple): + Containing the coordinates of the top left answer of a set + centers (list): + contains tuples with coordinates and the black-pixel-ratio + (x, y, ratio) + n (int): + the number of possible answers per question + dist_r (int): + the horizontal distance between two columns of answers + dist_d (int): + the vertical distance between two rows of answers + + Returns: + A list containing all evaluated data + """ + result = [] + result.append(eval_row(start, centers, n, dist_r)) + + cur = start + while cur != None: + cur = find_next(centers, cur, dist_d, "d", max_dist=dist_r / 2) + if cur is not None: + result.append(eval_row(cur, centers, n, dist_r)) + return result + +def eval_row ( + start: tuple, + centers: int, + n: int, + dist_r: int + ) -> list: + """ Evaluates a row of a set of answers with length n + + Args: + start (tuple): + Containing the coordinates of the top left answer of a set + centers (list): + contains tuples with coordinates and the black-pixel-ratio + (x, y, ratio) + n (int): + the number of possible answers per question + dist_r (int): + the horizontal distance between two columns of answers + + Returns: + A list containing all evaluated answer data from a given row + """ + result = [] + result.append(rate_black_pixel_ratio(start[2])) + + cur = start + for i in range(n - 1): + cur = find_next(centers, cur, dist_r, "r") + result.append(rate_black_pixel_ratio(cur[2])) + + return result + +def rate_black_pixel_ratio ( + ratio: int, + thres_1: int = 20, + thres_2: int = 50 + ) -> int: + """ Evaluates the given black-pixel-ratio and returns the detected marking. + + Args: + ratio (int): + the ratio of black pixels compared to total pixels + thres_1 (int): + threshold for checked boxes + thres_2 (int): + threshold for corrected boxes + + Returns: + -1 if a box is filled + 1 if a box is checked + 0 if a box is empty + """ + if ratio >= thres_2: + return -1 # corrected box + if ratio >= thres_1: + return 1 # checked + else: # empty box + return 0 + +def find_next ( + centers: list, + cur: tuple, + dist: int, + dir: str, + max_dist: int = None + ): + """ Finds the next point in a given direction with a given distance + + Args: + centers (list): + contains tuples with coordinates and the black-pixel-ratio + (x, y, ratio) + cur (tuple): + the current point + dist (int): + approximated distance to the next point + dir (str): + direction of the distance. can be either "r" or "d" + max_dist (int): + max distance to be allowe + + Returns: + returns the next found point. + if max_dist is given, returns None if distance > max_dist + """ + if dir == "r": + e, d = utils.find_closest_element(centers, (cur[0] + dist, cur[1])) + + elif dir == "d": + e, d = utils.find_closest_element(centers, (cur[0], cur[1] + dist)) + + if max_dist is None or d <= max_dist: + return e + return None + +def compare( + data_a: list, + data_b: list + ): + """ Compares two sets of evaluated data with the same + + Args: + data_a (list): + data set A + data_b (list): + data set B + + Returns: + A String "Correct/Total Answers" and the percentage + """ + # asserts the number of questions is the same + assert len(data_a) == len(data_b) + cnt = 0 + for i in range(len(data_a)): + if data_a[i] == data_b[i]: + cnt += 1 + + return (f"{cnt}/{len(data_a)}", cnt / len(data_a) * 100) \ No newline at end of file diff --git a/scan/image.py b/scan/image.py new file mode 100644 index 0000000..171373c --- /dev/null +++ b/scan/image.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +import cv2, imutils, numpy + +def threshold ( + image: numpy.ndarray, + threshold: int = 200, + ) -> numpy.ndarray: + """ Converts given image to black and white + + Args: + image (ndarray): + the image to be processed + threshold (int): + the threshold at which every pixel value should be set to 255 + """ + _, img_binary = cv2.threshold(image, threshold, 255, cv2.THRESH_BINARY) + return img_binary + +def blur ( + image: numpy.ndarray, + kernel_size: int = 7 + ) -> numpy.ndarray: + """ Filters the given picture by converting it to gray scale and + applying gaussian blur filter + + Args: + image (ndarray): + the image to be processed + kernel_size (int): + the size of the kernel for the gaussian blur + + Returns: + A ndarray containing the filtered image + """ + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + return cv2.GaussianBlur(gray, (kernel_size, kernel_size), 0) + +def edge_detection ( + image: numpy.ndarray + ) -> numpy.ndarray: + """ Applies canny edge detection on a given image + + Args: + image (ndarray): + the image to be processed + + Returns: + A ndarray containing the image after canny detection + """ + return imutils.auto_canny(image) + +def draw_circles ( + image: numpy.ndarray, + centers: list, + color: tuple, + radius: int = 3, + thickness: int = 2 + ) -> numpy.ndarray: + """ Draws the centers of given contures with the given color, + (size, thickness) + + Args: + image (ndarray): + the image to be modified + centers (list): + a list containing the center coordinates as tuples (x, y) + color (tuple): + the color coded in BGR color scale + radius (int): + size of the circles to be drawn + thickness (int): + thickness of the borders of all circles to be drawn + """ + for center in centers: + cv2.circle(img=image, center=center[:2], radius=radius, + color=color,thickness=thickness) + + return image + +def draw_contours ( + image: numpy.ndarray, + contours: list, + color: tuple + ) -> numpy.ndarray: + """ Draws the given contours in the given picture with the given color. + Args: + image (ndarray): + the image to be modified + contours (list): + A list containing contour data + color (tuple): + the color coded in BGR color scale + + Returns: + the modified image as a multidimensional array + """ + img = cv2.drawContours(image, contours, -1, color, 3) + return img + + +def ratio_black ( + image: numpy.ndarray, + center: tuple, + radius: int, + ) -> int: + """ Calculates the ratio of black pixels in a given square area. + + Args: + image (ndarray): + the image + center (tuple): + + radius (int): + + + Returns: + The ratio of black pixels to all pixels + """ + cnt_black = 0 + cnt_pixel = 0 + for x in range(center[0] - radius, center[0] + radius): + for y in range(center[1] - radius, center[1] + radius): + if image[y, x] == 0: + cnt_black += 1 + cnt_pixel += 1 + + return int(cnt_black / cnt_pixel * 100) + +def eval_image ( + image: numpy.ndarray, + data: list, + radius: int + ) -> numpy.ndarray: + + checked = [d[:2] for d in data if d[2] >= 20] + corrected = [d[:2] for d in data if d[2] >= 50] + + draw_circles(image, checked, (0,255,0), radius, thickness=4) + draw_circles(image, corrected, (255,0,0), radius, thickness=4) + + return image diff --git a/scan/io.py b/scan/io.py new file mode 100644 index 0000000..1ba5c1f --- /dev/null +++ b/scan/io.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +import cv2, argparse, numpy + +############## +# Exceptions # +############## + +class UnsupportedFileType(Exception): + pass + +class UnsupportedNumberOfPages(Exception): + pass + +class MissingArg(Exception): + pass + +#################### +# Argument Parsing # +#################### + +def parse_args ( + ) -> argparse.Namespace: + """ Parser for cli arguments. + + Returns: + A Namespace containing all parsed data + """ + # The parser itself + parser = argparse.ArgumentParser(add_help=False) + parser.description = "Evaluates single choice sheets" + + # Groups for ordering arguments in help command + grp_req_excl = parser.add_argument_group("required arguments, mutually exclusive") + grp_req = parser.add_argument_group("required arguments") + grp_opt = parser.add_argument_group("optional arguments") + + ######################### + ##### Required Args ##### + ######################### + + # Input path - either an url or a path to a local file + io_grp = grp_req_excl.add_mutually_exclusive_group(required=True) + io_grp.add_argument("-u", "--url", dest="url", + help="URL to the image or pdf to be evaluated.") + io_grp.add_argument("-f", "--file", dest="file", + help="path to the image or pdf to be evaluated.") + + # required arg for number of answers each question + grp_req.add_argument("-n", "--num", dest="num", required=True, + type=_arg_int_pos, help="number of answers per question") + + ######################### + ##### Optional Args ##### + ######################### + + # help message. Added manually so it is shown under optional + grp_opt.add_argument("-h", "--help", action="help", help="show this help message and exit") + + # path to store the result picture to + grp_opt.add_argument("-i", "--iout", dest="iout", + help="path for the output picture to be stored.") + + # path to store the result list to + grp_opt.add_argument("-d", "--dout", dest="dout", + help="path for the output data to be stored.") + + # path to compare results generated by the program with data stored in a file + grp_opt.add_argument("-c", "--compare", dest="comp", + help="compares the calculated result to a given result") + + # plotting all steps + grp_opt.add_argument("-p", "--plot", dest="plot", action="store_true", + help="plots every single step") + + return parser.parse_args() + +def _arg_int_pos ( + value: str + ) -> int: + """ Trying to parse the input argument into a positive value. + + Args: + value (str): + The value to be checked + + Returns: + The checked and casted value + + Raises: + ArgumentTypeError: + Raises an ArgumentTypeError if + - value is not a number + - value is not positive + """ + try: + int_value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"{value} is not a number.") + + if int_value <= 0: + raise argparse.ArgumentTypeError(f"{value} is not a positive integer.") + return int_value + +################# +# File Handling # +################# + +def read_image ( + path: str = None, + url: str = None + ) -> numpy.ndarray: + """ Opens the image if file extension is supported. + + Supported file extensions are .jpg, .jpeg, .png and .pdf + + Args: + path (String): Path to local file + url (String): URL to file + + Returns: + A ndarray containing the image data + """ + # Neither URL nor path provied + if url is None and path is None: + raise MissingArg + + # Get data and mime type + if url: + import requests + try: + response = requests.get(url, stream=True).raw + ext = response.headers["Content-Type"].split("; ")[0] + except requests.exceptions.ConnectionError as e: + raise e + + else: # path + import magic + try: + with open(path, "rb") as f: + file = f.read() + ext = magic.from_buffer(file, mime=True).split("; ")[0] + except FileNotFoundError as e: + raise e + + # If file is image or pdf, parse to cv2 + if ext in ("image/png", "image/jpeg"): + if url: + data = numpy.asarray(bytearray(response.read())) + else: + data = numpy.asarray(bytearray(file)) + + return cv2.imdecode(data, cv2.IMREAD_COLOR) + + elif ext in ("application/pdf"): + import pdf2image + if url: + images = pdf2image.convert_from_bytes(response.read()) + else: + images = pdf2image.convert_from_bytes(file) + + # only pdf with one page are supported + if len(images) != 1: + raise UnsupportedNumberOfPages + + data = numpy.asarray(images[0]) + return cv2.cvtColor(data, cv2.COLOR_RGB2BGR) + + else: + raise UnsupportedFileType(ext) + +def write_image ( + image: numpy.ndarray, + path: str, + ): + """ Stores an image to the given path. + If the directory does not exist, it tries to create it. + + Args: + image (ndarray): + the image to be stored + path (String): Path for file to be stored + """ + import os + path_abs = os.path.abspath(path) + path_dir = os.path.dirname(path_abs) + + if not os.path.exists(path_dir): + os.makedirs(path_dir) + + cv2.imwrite(path_abs, image) + +def load_results ( + path: str + ): + import json + with open(path, "r") as f: + return json.loads(f.read()) + +def store_results ( + data: list, + path: str + ): + import json + with open(path, "w+") as f: + f.write(json.dumps(data)) + +############ +# Plotting # +############ + +def plot ( + orig: numpy.ndarray, + blur: numpy.ndarray, + thres: numpy.ndarray, + edges: numpy.ndarray, + boxes: numpy.ndarray, + center_boxes: numpy.ndarray, + checked: numpy.ndarray, + end: numpy.ndarray, + ): + """ Plots up to 8 given data. + All Subplot consists of the following: + + - plt.subplot => subplot identifier + - plt.xticks, plt.yticks => hide axis labels + - plt.title => Title to be shown + - plt.imshow => shows given image in the subplot + - since opencv image is in BGR, it needs to convert to RGB first + """ + import matplotlib.pyplot as plt + from imutils import opencv2matplotlib + idx = 240 + + # Subplot y = 1, x = 1 + plt.subplot(idx + 1) + plt.xticks([]), plt.yticks([]) + plt.title("Original") + plt.imshow(opencv2matplotlib(orig)) + + # Subplot y = 1, x = 2 + plt.subplot(idx + 2) + plt.xticks([]), plt.yticks([]) + plt.title("Blur") + plt.imshow(opencv2matplotlib(blur)) + + # Subplot y = 1, x = 3 + plt.subplot(idx + 3) + plt.xticks([]), plt.yticks([]) + plt.title("Threshold") + plt.imshow(opencv2matplotlib(thres)) + + plt.subplot(idx + 4) + plt.xticks([]), plt.yticks([]) + plt.title("Canny Edge") + plt.imshow(opencv2matplotlib(edges)) + + plt.subplot(idx + 5) + plt.xticks([]), plt.yticks([]) + plt.title("Boxes") + plt.imshow(opencv2matplotlib(boxes)) + + plt.subplot(idx + 6) + plt.xticks([]), plt.yticks([]) + plt.title("Center of Boxes") + plt.imshow(opencv2matplotlib(center_boxes)) + + + plt.subplot(idx + 7) + plt.xticks([]), plt.yticks([]) + plt.title("Areas to be checked") + plt.imshow(opencv2matplotlib(checked)) + + plt.subplot(idx + 8) + plt.xticks([]), plt.yticks([]) + plt.title("Found corrected and checked boxes") + plt.imshow(opencv2matplotlib(end)) + + plt.show() \ No newline at end of file diff --git a/scan/utils.py b/scan/utils.py new file mode 100644 index 0000000..da236b1 --- /dev/null +++ b/scan/utils.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2019 Akumatic + +import math + +def distance ( + p: tuple, + q: tuple + ) -> float: + """ Calculates direct distance between two points given as tuples. + + Args: + p (tuple): + A tuple containing x and y coordinates + q (tuple): + A tuple containing x and y coordinates + + Returns: + A float with the distance between given points as value + """ + return math.sqrt((p[0] - q[0])**2 + (p[1] - q[1])**2) + +def find_closest_element ( + points: list, + point: tuple + ) -> tuple: + """ Finds the closes element to a given point + + Args: + points (list): + A list containing tuples of coordinates (x, y) + point (tuple): + The (x, y) coordinates of the given point + + Returns: + the tuple of the closest point and the distance + between the given and closest point + """ + start, min_dist = None, None + for p in points: + dist = distance(p[:2], point) + if min_dist is None or dist < min_dist: + start, min_dist = p, dist + + return start, min_dist + + +def circularity ( + area: float, + perimeter: float + ) -> float: + """ Calculates the circularity shape factor with given area and perimeter. + + Args: + area (float): + area of a shape + perimeter (float): + length of the perimeter of a shape + + Returns: + A float with the circularity shape factor as value + """ + if perimeter == 0: + return 0.0 + return (4 * math.pi * area) / (perimeter ** 2) \ No newline at end of file