diff --git a/LICENSE b/LICENSE index e948859..6ae1c36 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2024, Stéphane Brunner +Copyright (c) 2019-2025, Stéphane Brunner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/deskew/__init__.py b/deskew/__init__.py index 580914a..3971d9b 100644 --- a/deskew/__init__.py +++ b/deskew/__init__.py @@ -14,9 +14,9 @@ if TYPE_CHECKING: from typing import TypeAlias - ImageType: TypeAlias = npt.NDArray[np.integer[Any] | np.floating[Any]] - ImageTypeUint64: TypeAlias = npt.NDArray[np.uint8] - ImageTypeFloat64: TypeAlias = npt.NDArray[np.float64] + type ImageType = npt.NDArray[np.integer[Any] | np.floating[Any]] + type ImageTypeUint64 = npt.NDArray[np.uint8] + type ImageTypeFloat64 = npt.NDArray[np.float64] else: ImageType = np.ndarray ImageTypeUint64 = np.ndarray @@ -27,12 +27,12 @@ def determine_skew_dev( image: ImageType, sigma: float = 3.0, num_peaks: int = 20, - min_angle: Optional[float] = None, # -np.pi / 2, - max_angle: Optional[float] = None, # np.pi / 2, + min_angle: float | None = None, # -np.pi / 2, + max_angle: float | None = None, # np.pi / 2, min_deviation: float = np.pi / 180, angle_pm_90: bool = False, ) -> tuple[ - Optional[np.float64], + np.float64 | None, tuple[ tuple[ImageTypeUint64, list[list[np.float64]], ImageTypeFloat64], tuple[list[Any], list[np.float64], list[np.float64]], @@ -40,12 +40,15 @@ def determine_skew_dev( ], ]: """Calculate skew angle.""" - num_angles = round(np.pi / min_deviation) - imagergb = rgba2rgb(image) if len(image.shape) == 3 and image.shape[2] == 4 else image + imagergb = ( + rgba2rgb(image) if len(image.shape) == 3 and image.shape[2] == 4 else image + ) img = rgb2gray(imagergb) if len(imagergb.shape) == 3 else imagergb edges = canny(img, sigma=sigma) - out, angles, distances = hough_line(edges, np.linspace(-np.pi / 2, np.pi / 2, num_angles, endpoint=False)) + out, angles, distances = hough_line( + edges, np.linspace(-np.pi / 2, np.pi / 2, num_angles, endpoint=False) + ) hough_line_out = (out, angles, distances) hspace, angles_peaks, dists = hough_line_peaks( @@ -62,7 +65,9 @@ def determine_skew_dev( freqs_original[peak] += 1 angles_peaks_corrected = [ - (a % np.pi - np.pi / 2) if angle_pm_90 else ((a + np.pi / 4) % (np.pi / 2) - np.pi / 4) + (a % np.pi - np.pi / 2) + if angle_pm_90 + else ((a + np.pi / 4) % (np.pi / 2) - np.pi / 4) for a in angles_peaks ] angles_peaks_filtred = ( @@ -71,7 +76,9 @@ def determine_skew_dev( else angles_peaks_corrected ) angles_peaks_filtred = ( - [a for a in angles_peaks_filtred if a <= max_angle] if max_angle is not None else angles_peaks_filtred + [a for a in angles_peaks_filtred if a <= max_angle] + if max_angle is not None + else angles_peaks_filtred ) if not angles_peaks_filtred: return None, (hough_line_out, hough_line_peaks_out, ({}, {})) @@ -106,10 +113,10 @@ def determine_skew_debug_images( sigma: float = 3.0, num_peaks: int = 20, angle_pm_90: bool = False, - min_angle: Optional[float] = None, - max_angle: Optional[float] = None, + min_angle: float | None = None, + max_angle: float | None = None, min_deviation: float = 1.0, -) -> tuple[Optional[np.float64], list[tuple[str, ImageType]]]: +) -> tuple[np.float64 | None, list[tuple[str, ImageType]]]: """Calculate skew angle, and return images useful for debugging.""" import cv2 # pylint: disable=import-outside-toplevel import matplotlib.pyplot as plt # pylint: disable=import-outside-toplevel @@ -144,8 +151,12 @@ def determine_skew_debug_images( if min_angle_norm < max_angle_norm: min_angle_norm += np.pi / (1 if angle_pm_90 else 2) for add in [0.0] if angle_pm_90 else [0.0, np.pi / 2]: - min_angle_limit: float = (min_angle_norm + add + np.pi / 2) % np.pi - np.pi / 2 - max_angle_limit: float = (max_angle_norm + add + np.pi / 2) % np.pi - np.pi / 2 + min_angle_limit: float = ( + min_angle_norm + add + np.pi / 2 + ) % np.pi - np.pi / 2 + max_angle_limit: float = ( + max_angle_norm + add + np.pi / 2 + ) % np.pi - np.pi / 2 min_angle_limit2: float = min_angle_limit % np.pi - np.pi / 2 max_angle_limit2: float = max_angle_limit % np.pi - np.pi / 2 if min_angle_limit < max_angle_limit: @@ -208,7 +219,9 @@ def determine_skew_debug_images( with tempfile.NamedTemporaryFile(suffix=".png") as file: plt.savefig(file.name) try: - subprocess.run(["gm", "convert", "-flatten", file.name, file.name], check=True) # nosec + subprocess.run( + ["gm", "convert", "-flatten", file.name, file.name], check=True + ) # nosec except FileNotFoundError: print("Install graphicsmagick to don't have transparent background") @@ -222,7 +235,7 @@ def determine_skew_debug_images( axe.set_axis_off() axe.set_title("Detected lines") - for _, line_angle, dist in zip(*hough_line_peaks_data): + for _, line_angle, dist in zip(*hough_line_peaks_data, strict=False): (coord0x, coord0y) = dist * np.array([np.cos(line_angle), np.sin(line_angle)]) angle2 = ( (line_angle % np.pi - np.pi / 2) @@ -232,10 +245,15 @@ def determine_skew_debug_images( diff = float(abs(angle2 - skew_angle)) if skew_angle is not None else 999.0 if diff < 0.001: axe.axline( - (coord0x, coord0y), slope=np.tan(line_angle + np.pi / 2), linewidth=1, color="lightgreen" + (coord0x, coord0y), + slope=np.tan(line_angle + np.pi / 2), + linewidth=1, + color="lightgreen", ) else: - axe.axline((coord0x, coord0y), slope=np.tan(line_angle + np.pi / 2), linewidth=1) + axe.axline( + (coord0x, coord0y), slope=np.tan(line_angle + np.pi / 2), linewidth=1 + ) axe.text( coord0x, coord0y, @@ -249,7 +267,9 @@ def determine_skew_debug_images( with tempfile.NamedTemporaryFile(suffix=".png") as file: plt.savefig(file.name) try: - subprocess.run(["gm", "convert", "-flatten", file.name, file.name], check=True) # nosec + subprocess.run( + ["gm", "convert", "-flatten", file.name, file.name], check=True + ) # nosec except FileNotFoundError: print("Install graphicsmagick to don't have transparent background") image = cv2.imread(file.name) @@ -281,19 +301,31 @@ def fill_polar( axe.axvline(angle, color="lightgreen") for limit_min, limit_max in limits: - if limit_min != -np.pi / 2 and (not half or -np.pi / 4 < limit_min < np.pi / 4): + if limit_min != -np.pi / 2 and ( + not half or -np.pi / 4 < limit_min < np.pi / 4 + ): axe.axvline(limit_min) - if limit_max != np.pi / 2 and (not half or -np.pi / 4 < limit_max < np.pi / 4): + if limit_max != np.pi / 2 and ( + not half or -np.pi / 4 < limit_max < np.pi / 4 + ): axe.axvline(limit_max) fill_polar(axe0, freqs0, skew_angles0, limits2) - fill_polar(axe1, freqs, [] if skew_angle is None else [float(skew_angle)], limits, not angle_pm_90) + fill_polar( + axe1, + freqs, + [] if skew_angle is None else [float(skew_angle)], + limits, + not angle_pm_90, + ) plt.tight_layout() with tempfile.NamedTemporaryFile(suffix=".png") as file: plt.savefig(file.name) try: - subprocess.run(["gm", "convert", "-flatten", file.name, file.name], check=True) # nosec + subprocess.run( + ["gm", "convert", "-flatten", file.name, file.name], check=True + ) # nosec except FileNotFoundError: print("Install graphicsmagick to don't have transparent background") image = cv2.imread(file.name) @@ -306,12 +338,12 @@ def determine_skew( image: ImageType, sigma: float = 3.0, num_peaks: int = 20, - num_angles: Optional[int] = None, + num_angles: int | None = None, angle_pm_90: bool = False, - min_angle: Optional[float] = None, - max_angle: Optional[float] = None, + min_angle: float | None = None, + max_angle: float | None = None, min_deviation: float = 1.0, -) -> Optional[np.float64]: +) -> np.float64 | None: """ Calculate skew angle. @@ -341,7 +373,9 @@ def determine_skew( """ if num_angles is not None: min_deviation = 180 / num_angles - warnings.warn("num_angles is deprecated, please use min_deviation", DeprecationWarning) + warnings.warn( + "num_angles is deprecated, please use min_deviation", DeprecationWarning + ) angle, _ = determine_skew_dev( image, diff --git a/deskew/cli.py b/deskew/cli.py index bc7eadf..6a474e6 100644 --- a/deskew/cli.py +++ b/deskew/cli.py @@ -15,9 +15,14 @@ def main() -> None: parser.add_argument("-o", "--output", default=None, help="Output file name") parser.add_argument("--sigma", default=3.0, type=float, help="The use sigma") - parser.add_argument("--num-peaks", default=20, type=int, help="The used number of peaks") parser.add_argument( - "--num-angles", default=180, type=int, help="The used number of angle (determine the precision)" + "--num-peaks", default=20, type=int, help="The used number of peaks" + ) + parser.add_argument( + "--num-angles", + default=180, + type=int, + help="The used number of angle (determine the precision)", ) parser.add_argument("--background", help="The used background color") parser.add_argument(default=None, dest="input", help="Input file name") @@ -26,7 +31,10 @@ def main() -> None: image = io.imread(options.input) grayscale = image if len(image.shape) == 2 else rgb2gray(image) angle = determine_skew( - grayscale, sigma=options.sigma, num_peaks=options.num_peaks, num_angles=options.num_angles + grayscale, + sigma=options.sigma, + num_peaks=options.num_peaks, + num_angles=options.num_angles, ) if options.output is None: print(f"Estimated angle: {angle}") diff --git a/tests/test_deskew.py b/tests/test_deskew.py index 26feb9f..911f1f1 100644 --- a/tests/test_deskew.py +++ b/tests/test_deskew.py @@ -37,13 +37,19 @@ def check_image(root_folder, image, name, level=1.0): if score < level: cv2.imwrite(result_name, image) cv2.imwrite(diff_name, diff) - assert score >= level, f"{result_name} != {expected_name} => {diff_name} ({score} < {level})" + assert ( + score >= level + ), f"{result_name} != {expected_name} => {diff_name} ({score} < {level})" -def image_diff(image1: NpNdarrayInt, image2: NpNdarrayInt) -> tuple[float, NpNdarrayInt]: +def image_diff( + image1: NpNdarrayInt, image2: NpNdarrayInt +) -> tuple[float, NpNdarrayInt]: """Do a diff between images.""" - score, diff = structural_similarity(image1, image2, multichannel=True, full=True, channel_axis=2) + score, diff = structural_similarity( + image1, image2, multichannel=True, full=True, channel_axis=2 + ) diff = (255 - diff * 255).astype("uint8") return score, diff @@ -88,10 +94,16 @@ def test_deskew(image, expected_angle): (None, None, True, 200, -3, "-90-many-peaks", 0.93), ], ) -def test_determine_skew_debug_images(min_angle, max_angle, angle_pm_90, num_peaks, expected, postfix, level): +def test_determine_skew_debug_images( + min_angle, max_angle, angle_pm_90, num_peaks, expected, postfix, level +): image = io.imread(os.path.join(os.path.dirname(__file__), "deskew-6.png")) angle, debug_images = determine_skew_debug_images( - image, min_angle=min_angle, max_angle=max_angle, angle_pm_90=angle_pm_90, num_peaks=num_peaks + image, + min_angle=min_angle, + max_angle=max_angle, + angle_pm_90=angle_pm_90, + num_peaks=num_peaks, ) for name, debug_image in debug_images: print(name)