diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f2b97a2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + black: + name: Run black + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' # Specify the Python version you are using + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black isort + - name: Run black + run: | + black --check . + isort: + name: Run isort + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' # Specify the Python version you are using + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black isort + - name: Run isort + run: |- + isort --check-only . diff --git a/documentation/benchmark/resizes.py b/documentation/benchmark/resizes.py index fc27a46..cacaf48 100644 --- a/documentation/benchmark/resizes.py +++ b/documentation/benchmark/resizes.py @@ -1,11 +1,16 @@ -import numpy as np import cv2 import imageio +import numpy as np from PIL import Image -from qreader import QReader from pyzbar.pyzbar import decode -qreader_reader, cv2_reader, pyzbar_reader = QReader(model_size='m'), cv2.QRCodeDetector(), decode +from qreader import QReader + +qreader_reader, cv2_reader, pyzbar_reader = ( + QReader(model_size="m"), + cv2.QRCodeDetector(), + decode, +) def get_scaled_sizes(start_size, end_size, step, w, h): @@ -28,25 +33,56 @@ def draw_cross(x, y): cv2.line(image_copy, (x + 20, y), (x, y + 20), (0, 0, 255), 2) def draw_warn(x, y): - cv2.putText(image_copy, "!", (x + 5, y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2) + cv2.putText( + image_copy, + "!", + (x + 5, y + 15), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 255, 255), + 2, + ) qreader_out = qreader_reader.detect_and_decode(image=image) cv2_out = cv2_reader.detectAndDecode(image)[0] pyzbar_out = pyzbar_reader(image=image) - qreader_status = "YES" if len(qreader_out) > 0 and qreader_out[0] is not None else "WARN" if len( - qreader_out) > 0 else "NO" + qreader_status = ( + "YES" + if len(qreader_out) > 0 and qreader_out[0] is not None + else "WARN" if len(qreader_out) > 0 else "NO" + ) cv2_status = "YES" if cv2_out != "" else "NO" pyzbar_status = "YES" if len(pyzbar_out) > 0 else "NO" - cv2.putText(image_copy, f"Size: {current_size}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2) + cv2.putText( + image_copy, + f"Size: {current_size}", + (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (0, 0, 0), + 2, + ) y_position = 80 x_position_text = 20 x_position_symbol = 220 - for method, status in [("OpenCV", cv2_status), ("Pyzbar", pyzbar_status), ("QReader", qreader_status)]: - cv2.putText(image_copy, f"{method}:", (x_position_text, y_position), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2) + for method, status in [ + ("OpenCV", cv2_status), + ("Pyzbar", pyzbar_status), + ("QReader", qreader_status), + ]: + cv2.putText( + image_copy, + f"{method}:", + (x_position_text, y_position), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (0, 0, 0), + 2, + ) if status == "YES": draw_tick(x_position_symbol, y_position - 10) @@ -61,18 +97,22 @@ def draw_warn(x, y): def main(): - image = cv2.imread('../resources/logo.png', cv2.IMREAD_GRAYSCALE) + image = cv2.imread("../resources/logo.png", cv2.IMREAD_GRAYSCALE) h, w = image.shape frames = [] for size in range(640, 10, -5): size_h = size_w = size - resized_image = cv2.resize(image, (size_w, size_h), interpolation=cv2.INTER_AREA) + resized_image = cv2.resize( + image, (size_w, size_h), interpolation=cv2.INTER_AREA + ) pad_h = (640 - size_h) // 2 pad_w = (640 - size_w) // 2 - resized_image = cv2.copyMakeBorder(resized_image, pad_h, pad_h, pad_w, pad_w, cv2.BORDER_CONSTANT, value=255) + resized_image = cv2.copyMakeBorder( + resized_image, pad_h, pad_h, pad_w, pad_w, cv2.BORDER_CONSTANT, value=255 + ) resized_image = validate_and_write_on_image(resized_image, f"{size_w}x{size_h}") frames.append(Image.fromarray(resized_image)) @@ -80,9 +120,9 @@ def main(): if (size_w % 50) == 0: print(f"Done {size_w}x{size_h}") - gif_path = 'resized_image.gif' + gif_path = "resized_image.gif" imageio.mimsave(gif_path, frames, duration=0.1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/documentation/benchmark/rotations.py b/documentation/benchmark/rotations.py index 9d1eacf..e59309e 100644 --- a/documentation/benchmark/rotations.py +++ b/documentation/benchmark/rotations.py @@ -1,50 +1,59 @@ -import numpy as np +import cv2 import imageio +import numpy as np from PIL import Image +from pyzbar.pyzbar import decode from qreader import QReader -import cv2 -from pyzbar.pyzbar import decode -qreader_reader, cv2_reader, pyzbar_reader = QReader(model_size='m'), cv2.QRCodeDetector(), decode +qreader_reader, cv2_reader, pyzbar_reader = ( + QReader(model_size="m"), + cv2.QRCodeDetector(), + decode, +) def get_matrices(start_deg, end_deg, y_deg=20, w=256, h=256): """Yield rotation matrices for given start and end degrees, width, and height.""" # 10 degrees in radians for Y-axis rotation - rot_xs = np.linspace(np.radians(start_deg), np.radians(end_deg), end_deg - start_deg + 1) - rot_ys = np.linspace(np.radians(0), np.radians(y_deg), end_deg - start_deg +1) + rot_xs = np.linspace( + np.radians(start_deg), np.radians(end_deg), end_deg - start_deg + 1 + ) + rot_ys = np.linspace(np.radians(0), np.radians(y_deg), end_deg - start_deg + 1) # Iterate on radians. Rotate on X degree by degree for rotx, roty in zip(rot_xs, rot_ys): focal_length = 2 cosx, sinx = np.cos(rotx), np.sin(rotx) # X-axis rotation matrix - rotox = [ - [1, 0, 0], - [0, cosx, -sinx], - [0, sinx, cosx] - ] + rotox = [[1, 0, 0], [0, cosx, -sinx], [0, sinx, cosx]] cosy, siny = np.cos(roty), np.sin(roty) # Y-axis rotation matrix - roty = [ - [cosy, 0, siny], - [0, 1, 0], - [-siny, 0, cosy] - ] + roty = [[cosy, 0, siny], [0, 1, 0], [-siny, 0, cosy]] # Combine X and Y rotation matrices combined_roto = np.dot(rotox, roty) - pt = np.array([[-w / 2, -h / 2, 0], [w / 2, -h / 2, 0], - [w / 2, h / 2, 0], [-w / 2, h / 2, 0]], dtype=np.float32) + pt = np.array( + [ + [-w / 2, -h / 2, 0], + [w / 2, -h / 2, 0], + [w / 2, h / 2, 0], + [-w / 2, h / 2, 0], + ], + dtype=np.float32, + ) ptt = np.dot(pt, np.transpose(combined_roto)) # Generate input and output points - ptt[:, 0] = w / 2 + ptt[:, 0] * focal_length * h / (focal_length * h + ptt[:, 2]) - ptt[:, 1] = h / 2 + ptt[:, 1] * focal_length * h / (focal_length * h + ptt[:, 2]) + ptt[:, 0] = w / 2 + ptt[:, 0] * focal_length * h / ( + focal_length * h + ptt[:, 2] + ) + ptt[:, 1] = h / 2 + ptt[:, 1] * focal_length * h / ( + focal_length * h + ptt[:, 2] + ) in_pt = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32) out_pt = np.array(ptt[:, :2], dtype=np.float32) @@ -53,7 +62,6 @@ def get_matrices(start_deg, end_deg, y_deg=20, w=256, h=256): yield transform_matrix - def validate_and_write_on_image(image, current_degs_x, current_degs_y): def draw_tick(x, y): # Short line (almost vertical) @@ -67,7 +75,15 @@ def draw_cross(x, y): cv2.line(image_copy, (x + 20, y), (x, y + 20), (0, 0, 255), 2) def draw_warn(x, y): - cv2.putText(image_copy, "!", (x + 5, y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2) + cv2.putText( + image_copy, + "!", + (x + 5, y + 15), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 255, 255), + 2, + ) def overlay_transparent_rect(image, x, y, width, height, color, alpha): overlay = image.copy() @@ -83,15 +99,26 @@ def overlay_transparent_rect(image, x, y, width, height, color, alpha): pyzbar_out = pyzbar_reader(image=image) # Create status indicators based on decoding success - qreader_status = "YES" if len(qreader_out) > 0 and qreader_out[0] is not None else "WARN" if len( - qreader_out) > 0 else "NO" + qreader_status = ( + "YES" + if len(qreader_out) > 0 and qreader_out[0] is not None + else "WARN" if len(qreader_out) > 0 else "NO" + ) cv2_status = "YES" if cv2_out != "" else "NO" pyzbar_status = "YES" if len(pyzbar_out) > 0 else "NO" overlay_transparent_rect(image_copy, 10, 0, 500, 40, (255, 255, 255), 0.6) - overlay_transparent_rect(image_copy, 10, 50, 250, 40*3+20, (255, 255, 255), 0.6) + overlay_transparent_rect(image_copy, 10, 50, 250, 40 * 3 + 20, (255, 255, 255), 0.6) # Writing the degrees in the top left corner - cv2.putText(image_copy, f" x: {current_degs_x} deg - y: {round(current_degs_y, 1)} deg", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.25, (0, 0, 0), 2) + cv2.putText( + image_copy, + f" x: {current_degs_x} deg - y: {round(current_degs_y, 1)} deg", + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 1.25, + (0, 0, 0), + 2, + ) # Status positions y_position = 80 @@ -99,11 +126,23 @@ def overlay_transparent_rect(image, x, y, width, height, color, alpha): x_position_text = 20 x_position_symbol = 220 - for method, status in [("OpenCV", cv2_status), ("Pyzbar", pyzbar_status), ("QReader", qreader_status)]: + for method, status in [ + ("OpenCV", cv2_status), + ("Pyzbar", pyzbar_status), + ("QReader", qreader_status), + ]: # Draw semi-transparent white rectangle as a background # Draw text in black - cv2.putText(image_copy, f" {method}:", (x_position_text, y_position), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2) + cv2.putText( + image_copy, + f" {method}:", + (x_position_text, y_position), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (0, 0, 0), + 2, + ) # Draw the symbol next to the text if status == "YES": @@ -118,43 +157,54 @@ def overlay_transparent_rect(image, x, y, width, height, color, alpha): return cv2.cvtColor(image_copy, cv2.COLOR_BGR2RGB) - - def main(): # Load an image (replace 'your_image_path.png' with the path to your image) - image = cv2.imread('../resources/logo.png', cv2.IMREAD_GRAYSCALE) + image = cv2.imread("../resources/logo.png", cv2.IMREAD_GRAYSCALE) h, w = image.shape frames = [] # Get the max transformation matrix - m = next(get_matrices(start_deg=80-1, end_deg=80, w=w, h=h)) + m = next(get_matrices(start_deg=80 - 1, end_deg=80, w=w, h=h)) # Apply the perspective transformation for the corners - im_corners = cv2.perspectiveTransform(np.array([[[0, 0]], [[w, 0]], [[w, h]], [[0, h]]], dtype=np.float32), m) + im_corners = cv2.perspectiveTransform( + np.array([[[0, 0]], [[w, 0]], [[w, h]], [[0, h]]], dtype=np.float32), m + ) # Get each side pad left_pad = abs(int(np.min(im_corners[:, :, 0]))) right_pad = int(np.max(im_corners[:, :, 0])) - w # Apply the pads to the image - image = np.pad(image, ((10, 0), (left_pad-10, right_pad-10)), mode='constant', constant_values=255) + image = np.pad( + image, + ((10, 0), (left_pad - 10, right_pad - 10)), + mode="constant", + constant_values=255, + ) h, w = image.shape degs = 0 degs_y = 0 for matrix in get_matrices(start_deg=0, end_deg=80, y_deg=25, w=w, h=h): # Apply the perspective transformation - transformed_image = cv2.warpPerspective(image, matrix, (w, h), flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_CONSTANT, borderValue=255) + transformed_image = cv2.warpPerspective( + image, + matrix, + (w, h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=255, + ) # Validate and write on the image transformed_image = validate_and_write_on_image(transformed_image, degs, degs_y) frames.append(Image.fromarray(transformed_image)) degs += 1 - degs_y += 25/80 + degs_y += 25 / 80 if (degs % 10) == 0: print(f"Done {degs}º") # Save the GIF - gif_path = 'rotated_image.gif' + gif_path = "rotated_image.gif" imageio.mimsave(gif_path, frames, duration=0.1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/main.py b/main.py index 566540c..96fcd35 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,22 @@ +import os + +import cv2 from qrdet import BBOX_XYXY from qreader import QReader -import cv2 -import os -SAMPLE_IMG = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', 'test_draw_64x64.jpeg') +SAMPLE_IMG = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "test_draw_64x64.jpeg" +) + def utf_errors_test(): import qrcode - qreader = QReader(model_size='n') + + qreader = QReader(model_size="n") image_path = "my_image.png" - data = 'â' - print(f'data = {data}') + data = "â" + print(f"data = {data}") img = qrcode.make(data) img.save(image_path) @@ -20,26 +25,32 @@ def utf_errors_test(): result = qreader.detect_and_decode(image=img) print(f"result = {result[0]}") + def decode_test_set(): - images = [os.path.join(os.path.dirname(__file__), 'testset', filename) - for filename in os.listdir(os.path.join(os.path.dirname(__file__), 'testset'))] + images = [ + os.path.join(os.path.dirname(__file__), "testset", filename) + for filename in os.listdir(os.path.join(os.path.dirname(__file__), "testset")) + ] # Initialize QReader - detector = QReader(model_size='n') + detector = QReader(model_size="n") # For each image, show the results for image_file in images: # Read the images img = cv2.cvtColor(cv2.imread(image_file), cv2.COLOR_BGR2RGB) # Detect and decode the QRs within the image - decoded_qrs, locations = detector.detect_and_decode(image=img, return_detections=True) + decoded_qrs, locations = detector.detect_and_decode( + image=img, return_detections=True + ) # Print the results print(f"Image: {image_file} -> {len(decoded_qrs)} QRs detected.") for content, location in zip(decoded_qrs, locations): print(f"Content: {content}. Position: {tuple(location[BBOX_XYXY])}") if content is None: pass - #decoded_qrs = detector.detect_and_decode(image=img, return_detections=False) - print('-------------------') + # decoded_qrs = detector.detect_and_decode(image=img, return_detections=False) + print("-------------------") + -if __name__ == '__main__': +if __name__ == "__main__": utf_errors_test() - #decode_test_set() \ No newline at end of file + # decode_test_set() diff --git a/performance_test.py b/performance_test.py index 2eccb85..693fae4 100644 --- a/performance_test.py +++ b/performance_test.py @@ -1,23 +1,30 @@ import os +from time import time -from qreader import QReader import cv2 -from time import time import numpy as np from tqdm import tqdm -SAMPLE_IMG_1 = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', '64x64.png') -SAMPLE_IMG_2 = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', '512x512.jpeg') -SAMPLE_IMG_3 = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', '1024x1024.jpeg') +from qreader import QReader + +SAMPLE_IMG_1 = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "64x64.png" +) +SAMPLE_IMG_2 = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "512x512.jpeg" +) +SAMPLE_IMG_3 = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "1024x1024.jpeg" +) PERFORMANCE_TEST_IAMGES = { - '64x64': SAMPLE_IMG_1, + "64x64": SAMPLE_IMG_1, #'512x512': SAMPLE_IMG_2, #'1024x1024': SAMPLE_IMG_3 } RUNS_TO_AVERAGE, WARMUP_ITERATIONS = 5, 5 -if __name__ == '__main__': +if __name__ == "__main__": results = {} for shape, img_path in tqdm(PERFORMANCE_TEST_IAMGES.items()): # Read the image @@ -33,10 +40,12 @@ detect_and_decode_times.append(time() - start) # Save the results results[shape] = { - 'detect': np.mean(detect_times[WARMUP_ITERATIONS:]), - 'detect_and_decode': np.mean(detect_and_decode_times[WARMUP_ITERATIONS:]) + "detect": np.mean(detect_times[WARMUP_ITERATIONS:]), + "detect_and_decode": np.mean(detect_and_decode_times[WARMUP_ITERATIONS:]), } # Print the results - print('Performance test results:') + print("Performance test results:") for shape, times in results.items(): - print(f"Image shape: {shape} -> Detect: {times['detect']}. Detect and decode: {times['detect_and_decode']}.") \ No newline at end of file + print( + f"Image shape: {shape} -> Detect: {times['detect']}. Detect and decode: {times['detect_and_decode']}." + ) diff --git a/pyzbar_opencv_comparison.py b/pyzbar_opencv_comparison.py index c9e1e66..57aae69 100644 --- a/pyzbar_opencv_comparison.py +++ b/pyzbar_opencv_comparison.py @@ -1,17 +1,21 @@ import os -from qreader import QReader import cv2 from pyzbar.pyzbar import decode -SAMPLE_IMG_1 = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', 'test_draw_64x64.jpeg') -SAMPLE_IMG_2 = os.path.join(os.path.dirname(__file__), 'documentation', 'resources', '64x64.png') +from qreader import QReader + +SAMPLE_IMG_1 = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "test_draw_64x64.jpeg" +) +SAMPLE_IMG_2 = os.path.join( + os.path.dirname(__file__), "documentation", "resources", "64x64.png" +) -if __name__ == '__main__': +if __name__ == "__main__": # Initialize the three tested readers (QRReader, OpenCV and pyzbar) qreader_reader, cv2_reader, pyzbar_reader = QReader(), cv2.QRCodeDetector(), decode - for img_path in (SAMPLE_IMG_1, SAMPLE_IMG_2): # Read the image img = cv2.imread(img_path) @@ -21,7 +25,12 @@ cv2_out = cv2_reader.detectAndDecode(img=img)[0] pyzbar_out = pyzbar_reader(image=img) # Read the content of the pyzbar output (double decoding trick is needed to solve possible encoding issues) - pyzbar_out = tuple(out.data.data.decode('utf-8').encode('shift-jis').decode('utf-8') for out in pyzbar_out) + pyzbar_out = tuple( + out.data.data.decode("utf-8").encode("shift-jis").decode("utf-8") + for out in pyzbar_out + ) # Print the results - print(f"Image: {img_path} -> QReader: {qreader_out}. OpenCV: {cv2_out}. pyzbar: {pyzbar_out}.") \ No newline at end of file + print( + f"Image: {img_path} -> QReader: {qreader_out}. OpenCV: {cv2_out}. pyzbar: {pyzbar_out}." + ) diff --git a/qreader.py b/qreader.py index 0a474e5..0978096 100644 --- a/qreader.py +++ b/qreader.py @@ -10,23 +10,40 @@ """ from __future__ import annotations -from warnings import warn -import numpy as np -from pyzbar.pyzbar import decode as decodeQR, ZBarSymbol, Decoded -from dataclasses import dataclass -import cv2 + import os import typing +from dataclasses import dataclass +from warnings import warn -from qrdet import QRDetector, crop_qr, PADDED_QUAD_XY, BBOX_XYXY, CONFIDENCE, CXCY, WH, POLYGON_XY, QUAD_XY - -_SHARPEN_KERNEL = np.array(((-1., -1., -1.), (-1., 9., -1.), (-1., -1., -1.)), dtype=np.float32) +import cv2 +import numpy as np +from pyzbar.pyzbar import Decoded, ZBarSymbol +from pyzbar.pyzbar import decode as decodeQR +from qrdet import ( + BBOX_XYXY, + CONFIDENCE, + CXCY, + PADDED_QUAD_XY, + POLYGON_XY, + QUAD_XY, + WH, + QRDetector, + crop_qr, +) + +_SHARPEN_KERNEL = np.array( + ((-1.0, -1.0, -1.0), (-1.0, 9.0, -1.0), (-1.0, -1.0, -1.0)), dtype=np.float32 +) # In windows shift-jis is the default encoding will use, while in linux is big5 -DEFAULT_REENCODINGS = ('shift-jis', 'big5') if os.name == 'nt' else ('big5', 'shift-jis') +DEFAULT_REENCODINGS = ( + ("shift-jis", "big5") if os.name == "nt" else ("big5", "shift-jis") +) + @dataclass(frozen=True) -class DecodeQRResult(): +class DecodeQRResult: scale_factor: float corrections: typing.Literal["cropped_bbox", "corrected_perspective"] flavor: typing.Literal["original", "inverted", "grayscale"] @@ -34,28 +51,36 @@ class DecodeQRResult(): image: np.ndarray result: Decoded + def wrap( - scale_factor: float, - corrections: str, - flavor: str, - blur_kernel_sizes: tuple[tuple[int, int]], - image: np.ndarray, + scale_factor: float, + corrections: str, + flavor: str, + blur_kernel_sizes: tuple[tuple[int, int]], + image: np.ndarray, results: typing.List[Decoded], ) -> list[DecodeQRResult]: - - return [DecodeQRResult( - scale_factor=scale_factor, - corrections=corrections, - flavor=flavor, - blur_kernel_sizes=blur_kernel_sizes, - image=image, - result=result, - ) for result in results] + + return [ + DecodeQRResult( + scale_factor=scale_factor, + corrections=corrections, + flavor=flavor, + blur_kernel_sizes=blur_kernel_sizes, + image=image, + result=result, + ) + for result in results + ] class QReader: - def __init__(self, model_size: str = 's', min_confidence: float = 0.5, - reencode_to: str | tuple[str] | list[str] | None = DEFAULT_REENCODINGS): + def __init__( + self, + model_size: str = "s", + min_confidence: float = 0.5, + reencode_to: str | tuple[str] | list[str] | None = DEFAULT_REENCODINGS, + ): """ This class implements a robust, ML Based QR detector & decoder. @@ -73,16 +98,18 @@ def __init__(self, model_size: str = 's', min_confidence: float = 0.5, self.detector = QRDetector(model_size=model_size, conf_th=min_confidence) if isinstance(reencode_to, str): - self.reencode_to = (reencode_to,) if reencode_to != 'utf-8' else () + self.reencode_to = (reencode_to,) if reencode_to != "utf-8" else () elif reencode_to is None: self.reencode_to = () else: - assert isinstance(reencode_to, (tuple, list)), \ - f"reencode_to must be a str, tuple, list or None. Got {type(reencode_to)}" + assert isinstance( + reencode_to, (tuple, list) + ), f"reencode_to must be a str, tuple, list or None. Got {type(reencode_to)}" self.reencode_to = reencode_to - - def detect(self, image: np.ndarray, is_bgr: bool = False) -> tuple[dict[str, np.ndarray|float|tuple[float|int, float|int]]]: + def detect( + self, image: np.ndarray, is_bgr: bool = False + ) -> tuple[dict[str, np.ndarray | float | tuple[float | int, float | int]]]: """ This method will detect the QRs in the image and return a tuple of dictionaries with all the detection information. @@ -108,8 +135,13 @@ def detect(self, image: np.ndarray, is_bgr: bool = False) -> tuple[dict[str, np. """ return self.detector.detect(image=image, is_bgr=is_bgr) - def decode(self, image: np.ndarray, detection_result: dict[str, np.ndarray|float|tuple[float|int, float|int]]) -> \ - str | None: + def decode( + self, + image: np.ndarray, + detection_result: dict[ + str, np.ndarray | float | tuple[float | int, float | int] + ], + ) -> str | None: """ This method decodes a single QR code on the given image, described by a detection_result. @@ -128,23 +160,31 @@ def decode(self, image: np.ndarray, detection_result: dict[str, np.ndarray|float if len(decodedQR) > 0: # Take first result only decodeQRResult = decodedQR[0] - decoded_str = decodeQRResult.result.data.decode('utf-8') + decoded_str = decodeQRResult.result.data.decode("utf-8") for encoding in self.reencode_to: try: - decoded_str = decoded_str.encode(encoding).decode('utf-8') + decoded_str = decoded_str.encode(encoding).decode("utf-8") break except (UnicodeDecodeError, UnicodeEncodeError): pass else: if len(self.reencode_to) > 0: # When double decoding fails, just return the first decoded string with utf-8 - warn(f'Double decoding failed for {self.reencode_to}. Returning utf-8 decoded string.') + warn( + f"Double decoding failed for {self.reencode_to}. Returning utf-8 decoded string." + ) return decoded_str return None - def detect_and_decode(self, image: np.ndarray, return_detections: bool = False, is_bgr: bool = False) -> \ - tuple[dict[str, np.ndarray | float | tuple[float | int, float | int]], str | None] | tuple[str | None, ...]: + def detect_and_decode( + self, image: np.ndarray, return_detections: bool = False, is_bgr: bool = False + ) -> ( + tuple[ + dict[str, np.ndarray | float | tuple[float | int, float | int]], str | None + ] + | tuple[str | None, ...] + ): """ This method will decode the **QR** codes in the given image and return the decoded strings (or None, if any of them was detected but not decoded). @@ -162,18 +202,24 @@ def detect_and_decode(self, image: np.ndarray, return_detections: bool = False, """ detections = self.detect(image=image, is_bgr=is_bgr) - decoded_qrs = tuple(self.decode(image=image, detection_result=detection) for detection in detections) - + decoded_qrs = tuple( + self.decode(image=image, detection_result=detection) + for detection in detections + ) if return_detections: return decoded_qrs, detections else: return decoded_qrs - def get_detection_result_from_polygon(self, - quadrilateral_xy: np.ndarray | tuple[tuple[float | int, float | int], ...] | - list[list[float | int, float | int]]) -> \ - dict[str, np.ndarray | float | tuple[float | int, float | int]]: + def get_detection_result_from_polygon( + self, + quadrilateral_xy: ( + np.ndarray + | tuple[tuple[float | int, float | int], ...] + | list[list[float | int, float | int]] + ), + ) -> dict[str, np.ndarray | float | tuple[float | int, float | int]]: """ This method will simulate a detection result from the given quadrilateral. This is useful when you have detected a QR code with a different detector and you want to use this class to decode it. @@ -183,15 +229,26 @@ def get_detection_result_from_polygon(self, :return: dict[str, np.ndarray|float|tuple[float|int, float|int]]. A dictionary that is compatible with the detection_result parameter of the decode method. """ - assert isinstance(quadrilateral_xy, (np.ndarray, tuple, list)), \ - f"quadrilateral_xy must be a np.ndarray, tuple or list. Got {type(quadrilateral_xy)}" - assert len(quadrilateral_xy) == 4, f"quadrilateral_xy must have 4 points. Got {len(quadrilateral_xy)}" - assert all(len(point) == 2 for point in quadrilateral_xy), \ - f"Each point in quadrilateral_xy must have 2 coordinates (X, Y). Got {quadrilateral_xy}" + assert isinstance( + quadrilateral_xy, (np.ndarray, tuple, list) + ), f"quadrilateral_xy must be a np.ndarray, tuple or list. Got {type(quadrilateral_xy)}" + assert ( + len(quadrilateral_xy) == 4 + ), f"quadrilateral_xy must have 4 points. Got {len(quadrilateral_xy)}" + assert all( + len(point) == 2 for point in quadrilateral_xy + ), f"Each point in quadrilateral_xy must have 2 coordinates (X, Y). Got {quadrilateral_xy}" polygon = np.array(quadrilateral_xy, dtype=np.float32) - bbox_xyxy = np.array([polygon[:, 0].min(), polygon[:, 1].min(), polygon[:, 0].max(), polygon[:, 1].max()], - dtype=np.float32) + bbox_xyxy = np.array( + [ + polygon[:, 0].min(), + polygon[:, 1].min(), + polygon[:, 0].max(), + polygon[:, 1].max(), + ], + dtype=np.float32, + ) cxcy = ((bbox_xyxy[0] + bbox_xyxy[2]) / 2, (bbox_xyxy[1] + bbox_xyxy[3]) / 2) wh = (bbox_xyxy[2] - bbox_xyxy[0], bbox_xyxy[3] - bbox_xyxy[1]) return { @@ -201,12 +258,16 @@ def get_detection_result_from_polygon(self, WH: wh, POLYGON_XY: polygon, QUAD_XY: polygon.copy(), - PADDED_QUAD_XY: polygon.copy() + PADDED_QUAD_XY: polygon.copy(), } - def _decode_qr_zbar(self, image: np.ndarray, - detection_result: dict[str, np.ndarray | float | tuple[float | int, float | int]]) -> list[ - DecodeQRResult]: + def _decode_qr_zbar( + self, + image: np.ndarray, + detection_result: dict[ + str, np.ndarray | float | tuple[float | int, float | int] + ], + ) -> list[DecodeQRResult]: """ Try to decode the QR code just with pyzbar, pre-processing the image if it fails in different ways that sometimes work. @@ -217,20 +278,36 @@ def _decode_qr_zbar(self, image: np.ndarray, :return: tuple. The decoded QR code in the zbar format. """ # Crop the QR for bbox and quad - cropped_bbox, _ = crop_qr(image=image, detection=detection_result, crop_key=BBOX_XYXY) - cropped_quad, updated_detection = crop_qr(image=image, detection=detection_result, crop_key=PADDED_QUAD_XY) - corrected_perspective = self.__correct_perspective(image=cropped_quad, - padded_quad_xy=updated_detection[PADDED_QUAD_XY]) + cropped_bbox, _ = crop_qr( + image=image, detection=detection_result, crop_key=BBOX_XYXY + ) + cropped_quad, updated_detection = crop_qr( + image=image, detection=detection_result, crop_key=PADDED_QUAD_XY + ) + corrected_perspective = self.__correct_perspective( + image=cropped_quad, padded_quad_xy=updated_detection[PADDED_QUAD_XY] + ) for scale_factor in (1, 0.5, 2, 0.25, 3, 4): - for label, image in {"cropped_bbox": cropped_bbox, "corrected_perspective": corrected_perspective}.items(): + for label, image in { + "cropped_bbox": cropped_bbox, + "corrected_perspective": corrected_perspective, + }.items(): # If rescaled_image will be larger than 1024px, skip it # TODO: Decide a minimum size for the QRs based on the resize benchmark - if not all(25 < axis < 1024 for axis in image.shape[:2]) and scale_factor != 1: + if ( + not all(25 < axis < 1024 for axis in image.shape[:2]) + and scale_factor != 1 + ): continue - rescaled_image = cv2.resize(src=image, dsize=None, fx=scale_factor, fy=scale_factor, - interpolation=cv2.INTER_CUBIC) + rescaled_image = cv2.resize( + src=image, + dsize=None, + fx=scale_factor, + fy=scale_factor, + interpolation=cv2.INTER_CUBIC, + ) decodedQR = decodeQR(image=rescaled_image, symbols=[ZBarSymbol.QRCODE]) if len(decodedQR) > 0: return wrap( @@ -239,10 +316,10 @@ def _decode_qr_zbar(self, image: np.ndarray, flavor="original", blur_kernel_sizes=None, image=rescaled_image, - results=decodedQR + results=decodedQR, ) # For QRs with black background and white foreground, try to invert the image - inverted_image = image=255 - rescaled_image + inverted_image = image = 255 - rescaled_image decodedQR = decodeQR(inverted_image, symbols=[ZBarSymbol.QRCODE]) if len(decodedQR) > 0: return wrap( @@ -256,12 +333,15 @@ def _decode_qr_zbar(self, image: np.ndarray, # If it not works, try to parse to grayscale (if it is not already) if len(rescaled_image.shape) == 3: - assert rescaled_image.shape[ - 2] == 3, f'Image must be RGB or BGR, but it has {image.shape[2]} channels.' + assert ( + rescaled_image.shape[2] == 3 + ), f"Image must be RGB or BGR, but it has {image.shape[2]} channels." gray = cv2.cvtColor(rescaled_image, cv2.COLOR_RGB2GRAY) else: gray = rescaled_image - decodedQR = self.__threshold_and_blur_decodings(image=gray, blur_kernel_sizes=((5, 5), (7, 7))) + decodedQR = self.__threshold_and_blur_decodings( + image=gray, blur_kernel_sizes=((5, 5), (7, 7)) + ) if len(decodedQR) > 0: return wrap( scale_factor=scale_factor, @@ -269,16 +349,24 @@ def _decode_qr_zbar(self, image: np.ndarray, flavor="grayscale", blur_kernel_sizes=((5, 5), (7, 7)), image=gray, - results=decodedQR + results=decodedQR, ) if len(rescaled_image.shape) == 3: # If it not works, try to sharpen the image - sharpened_gray = cv2.cvtColor(cv2.filter2D(src=rescaled_image, ddepth=-1, kernel=_SHARPEN_KERNEL), - cv2.COLOR_RGB2GRAY) + sharpened_gray = cv2.cvtColor( + cv2.filter2D( + src=rescaled_image, ddepth=-1, kernel=_SHARPEN_KERNEL + ), + cv2.COLOR_RGB2GRAY, + ) else: - sharpened_gray = cv2.filter2D(src=rescaled_image, ddepth=-1, kernel=_SHARPEN_KERNEL) - decodedQR = self.__threshold_and_blur_decodings(image=sharpened_gray, blur_kernel_sizes=((3, 3),)) + sharpened_gray = cv2.filter2D( + src=rescaled_image, ddepth=-1, kernel=_SHARPEN_KERNEL + ) + decodedQR = self.__threshold_and_blur_decodings( + image=sharpened_gray, blur_kernel_sizes=((3, 3),) + ) if len(decodedQR) > 0: return wrap( scale_factor=scale_factor, @@ -286,12 +374,14 @@ def _decode_qr_zbar(self, image: np.ndarray, flavor="grayscale", blur_kernel_sizes=((3, 3),), image=sharpened_gray, - results=decodedQR + results=decodedQR, ) return [] - def __correct_perspective(self, image: np.ndarray, padded_quad_xy: np.ndarray) -> np.ndarray: + def __correct_perspective( + self, image: np.ndarray, padded_quad_xy: np.ndarray + ) -> np.ndarray: """ :param image: np.ndarray. The image to be read. It must be a np.ndarray (HxWxC) (uint8). :param padded_quad_xy: np.ndarray. An expanded version of quad_xy, with shape (4, 2), dtype: np.float32. @@ -300,14 +390,22 @@ def __correct_perspective(self, image: np.ndarray, padded_quad_xy: np.ndarray) - # Define the width and height of the quadrilateral width1 = np.sqrt( - ((padded_quad_xy[0][0] - padded_quad_xy[1][0]) ** 2) + ((padded_quad_xy[0][1] - padded_quad_xy[1][1]) ** 2)) + ((padded_quad_xy[0][0] - padded_quad_xy[1][0]) ** 2) + + ((padded_quad_xy[0][1] - padded_quad_xy[1][1]) ** 2) + ) width2 = np.sqrt( - ((padded_quad_xy[2][0] - padded_quad_xy[3][0]) ** 2) + ((padded_quad_xy[2][1] - padded_quad_xy[3][1]) ** 2)) + ((padded_quad_xy[2][0] - padded_quad_xy[3][0]) ** 2) + + ((padded_quad_xy[2][1] - padded_quad_xy[3][1]) ** 2) + ) height1 = np.sqrt( - ((padded_quad_xy[0][0] - padded_quad_xy[3][0]) ** 2) + ((padded_quad_xy[0][1] - padded_quad_xy[3][1]) ** 2)) + ((padded_quad_xy[0][0] - padded_quad_xy[3][0]) ** 2) + + ((padded_quad_xy[0][1] - padded_quad_xy[3][1]) ** 2) + ) height2 = np.sqrt( - ((padded_quad_xy[1][0] - padded_quad_xy[2][0]) ** 2) + ((padded_quad_xy[1][1] - padded_quad_xy[2][1]) ** 2)) + ((padded_quad_xy[1][0] - padded_quad_xy[2][0]) ** 2) + + ((padded_quad_xy[1][1] - padded_quad_xy[2][1]) ** 2) + ) # Take the maximum width and height to ensure no information is lost max_width = max(int(width1), int(width2)) @@ -315,7 +413,9 @@ def __correct_perspective(self, image: np.ndarray, padded_quad_xy: np.ndarray) - N = max(max_width, max_height) # Create destination points for the perspective transform. This forms an N x N square - dst_pts = np.array([[0, 0], [N - 1, 0], [N - 1, N - 1], [0, N - 1]], dtype=np.float32) + dst_pts = np.array( + [[0, 0], [N - 1, 0], [N - 1, N - 1], [0, N - 1]], dtype=np.float32 + ) # Compute the perspective transform matrix M = cv2.getPerspectiveTransform(padded_quad_xy, dst_pts) @@ -325,9 +425,9 @@ def __correct_perspective(self, image: np.ndarray, padded_quad_xy: np.ndarray) - return dst_img - def __threshold_and_blur_decodings(self, image: np.ndarray, - blur_kernel_sizes: tuple[tuple[int, int]] = ((3, 3),)) -> \ - list[Decoded]: + def __threshold_and_blur_decodings( + self, image: np.ndarray, blur_kernel_sizes: tuple[tuple[int, int]] = ((3, 3),) + ) -> list[Decoded]: """ Try to decode the QR code just with pyzbar, pre-processing the image with different blur and threshold filters. @@ -335,23 +435,29 @@ def __threshold_and_blur_decodings(self, image: np.ndarray, :return: list[Decoded]. The decoded QR code/s in the zbar format. If it fails, it will return an empty list. """ - assert 2 <= len(image.shape) <= 3, f"image must be 2D or 3D (HxW[xC]) (uint8). Got {image.shape}" + assert ( + 2 <= len(image.shape) <= 3 + ), f"image must be 2D or 3D (HxW[xC]) (uint8). Got {image.shape}" decodedQR = decodeQR(image=image, symbols=[ZBarSymbol.QRCODE]) if len(decodedQR) > 0: return decodedQR # Try to binarize the image (Only works with 2D images) if len(image.shape) == 2: - _, binary_image = cv2.threshold(image, thresh=0, maxval=255, type=cv2.THRESH_BINARY + cv2.THRESH_OTSU) + _, binary_image = cv2.threshold( + image, thresh=0, maxval=255, type=cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) decodedQR = decodeQR(image=binary_image, symbols=[ZBarSymbol.QRCODE]) if len(decodedQR) > 0: return decodedQR for kernel_size in blur_kernel_sizes: - assert isinstance(kernel_size, tuple) and len(kernel_size) == 2, \ - f"kernel_size must be a tuple of 2 elements. Got {kernel_size}" - assert all(kernel_size[i] % 2 == 1 for i in range(2)), \ - f"kernel_size must be a tuple of odd elements. Got {kernel_size}" + assert ( + isinstance(kernel_size, tuple) and len(kernel_size) == 2 + ), f"kernel_size must be a tuple of 2 elements. Got {kernel_size}" + assert all( + kernel_size[i] % 2 == 1 for i in range(2) + ), f"kernel_size must be a tuple of odd elements. Got {kernel_size}" # If it not works, try to parse to sharpened grayscale blur_image = cv2.GaussianBlur(src=image, ksize=kernel_size, sigmaX=0) diff --git a/setup.py b/setup.py index dc6fa8d..c81bb70 100644 --- a/setup.py +++ b/setup.py @@ -1,34 +1,34 @@ -from setuptools import setup, find_namespace_packages +from setuptools import find_namespace_packages, setup setup( - name='qreader', - version='3.12', + name="qreader", + version="3.12", packages=find_namespace_packages(), # expose qreader.py as the unique module - py_modules=['qreader'], - url='https://github.com/Eric-Canas/qreader', - license='MIT', - author='Eric Canas', - author_email='elcorreodeharu@gmail.com', - description='Robust and Straight-Forward solution for reading difficult and tricky QR codes ' - 'within images in Python. Supported by a YOLOv8 QR Segmentation model.', - long_description=open('README.md').read(), - long_description_content_type='text/markdown', + py_modules=["qreader"], + url="https://github.com/Eric-Canas/qreader", + license="MIT", + author="Eric Canas", + author_email="elcorreodeharu@gmail.com", + description="Robust and Straight-Forward solution for reading difficult and tricky QR codes " + "within images in Python. Supported by a YOLOv8 QR Segmentation model.", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", install_requires=[ - 'numpy', - 'opencv-python', - 'pyzbar', - 'qrdet>=2.1', + "numpy", + "opencv-python", + "pyzbar", + "qrdet>=2.1", ], classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Topic :: Scientific/Engineering :: Image Recognition', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Image Recognition", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", ], )