diff --git a/DIRECTORY.md b/DIRECTORY.md index 36acb3b97f1e..033a1bef8c5e 100644 --- a/DIRECTORY.md +++ b/DIRECTORY.md @@ -195,6 +195,7 @@ * [Permutations](data_structures/arrays/permutations.py) * [Prefix Sum](data_structures/arrays/prefix_sum.py) * [Product Sum](data_structures/arrays/product_sum.py) + * [Rotate Array](data_structures/arrays/rotate_array.py) * [Sparse Table](data_structures/arrays/sparse_table.py) * [Sudoku Solver](data_structures/arrays/sudoku_solver.py) * Binary Tree @@ -336,6 +337,8 @@ * Morphological Operations * [Dilation Operation](digital_image_processing/morphological_operations/dilation_operation.py) * [Erosion Operation](digital_image_processing/morphological_operations/erosion_operation.py) + * [Pruning Operation](digital_image_processing/morphological_operations/pruning_operation.py) + * [Skeletonization Operation](digital_image_processing/morphological_operations/skeletonization_operation.py) * Resize * [Resize](digital_image_processing/resize/resize.py) * Rotation diff --git a/digital_image_processing/image_data/skeleton_lena.png b/digital_image_processing/image_data/skeleton_lena.png new file mode 100644 index 000000000000..b4ad2d45b456 Binary files /dev/null and b/digital_image_processing/image_data/skeleton_lena.png differ diff --git a/digital_image_processing/morphological_operations/pruning_operation.py b/digital_image_processing/morphological_operations/pruning_operation.py new file mode 100644 index 000000000000..324b2db9018f --- /dev/null +++ b/digital_image_processing/morphological_operations/pruning_operation.py @@ -0,0 +1,189 @@ +# @Author: @joydipb01 +# @File: pruning_operation.py +# @Time: 2025-10-03 19:45 + +from pathlib import Path + +import numpy as np +from PIL import Image + + +def rgb_to_gray(rgb: np.ndarray) -> np.ndarray: + """ + Return gray image from rgb image + + >>> rgb_to_gray(np.array([[[127, 255, 0]]])) + array([[187.6453]]) + >>> rgb_to_gray(np.array([[[0, 0, 0]]])) + array([[0.]]) + >>> rgb_to_gray(np.array([[[2, 4, 1]]])) + array([[3.0598]]) + >>> rgb_to_gray(np.array([[[26, 255, 14], [5, 147, 20], [1, 200, 0]]])) + array([[159.0524, 90.0635, 117.6989]]) + """ + r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] + return 0.2989 * r + 0.5870 * g + 0.1140 * b + + +def gray_to_binary(gray: np.ndarray) -> np.ndarray: + """ + Return binary image from gray image + + >>> gray_to_binary(np.array([[127, 255, 0]])) + array([[False, True, False]]) + >>> gray_to_binary(np.array([[0]])) + array([[False]]) + >>> gray_to_binary(np.array([[26.2409, 4.9315, 1.4729]])) + array([[False, False, False]]) + >>> gray_to_binary(np.array([[26, 255, 14], [5, 147, 20], [1, 200, 0]])) + array([[False, True, False], + [False, True, False], + [False, True, False]]) + """ + return (gray > 127) & (gray <= 255) + + +def neighbours(image: np.ndarray, x_coord: int, y_coord: int) -> list: + """ + Return 8-neighbours of point (x_coord, y_coord), in clockwise order + + >>> neighbours( + ... np.array( + ... [ + ... [True, True, False], + ... [True, False, False], + ... [False, True, False] + ... ] + ... ), 1, 1 + ... ) + [np.True_, np.False_, np.False_, np.False_, np.True_, np.False_, np.True_, np.True_] + >>> neighbours( + ... np.array( + ... [ + ... [True, True, False, True], + ... [True, False, False, True], + ... [False, True, False, True] + ... ] + ... ), 1, 2 + ... ) + [np.False_, np.True_, np.True_, np.True_, np.False_, np.True_, np.False_, np.True_] + """ + img = image + + neighborhood = [ + (-1, 0), + (-1, 1), + (0, 1), + (1, 1), + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + ] + + neighbour_points = [] + + for dx, dy in neighborhood: + if 0 <= x_coord + dx < img.shape[0] and 0 <= y_coord + dy < img.shape[1]: + neighbour_points.append(img[x_coord + dx][y_coord + dy]) + else: + neighbour_points.append(False) + + return neighbour_points + + +def is_endpoint(image: np.ndarray, x_coord: int, y_coord: int) -> bool: + """ + Check if a pixel is an endpoint based on its 8-neighbors. + + An endpoint is defined as a pixel that has exactly one neighboring pixel + that is part of the foreground (True). + + >>> is_endpoint( + ... np.array( + ... [ + ... [True, True, False], + ... [True, False, False], + ... [False, True, False] + ... ] + ... ), 1, 1 + ... ) + False + >>> is_endpoint( + ... np.array( + ... [ + ... [True, True, False, True], + ... [True, False, False, True], + ... [False, True, False, True] + ... ] + ... ), 2, 3 + ... ) + True + """ + img = image + return int(sum(neighbours(img, x_coord, y_coord))) == 1 + + +def prune_skeletonized_image( + image: np.ndarray, spur_branch_length: int = 50 +) -> np.ndarray: + """ + Return pruned image by removing spurious branches of specified length + Source: https://www.scribd.com/doc/15792184/042805-04 + + >>> arr = np.array([ + ... [False, True, False], + ... [False, True, False], + ... [False, True, True] + ... ]) + >>> prune_skeletonized_image(arr, spur_branch_length=1) + array([[False, True, False], + [False, True, False], + [False, True, True]]) + >>> arr2 = np.array([ + ... [False, False, False, False], + ... [False, True, True, False], + ... [False, False, False, False] + ... ]) + >>> prune_skeletonized_image(arr2, spur_branch_length=1) + array([[False, False, False, False], + [False, False, False, False], + [False, False, False, False]]) + >>> arr3 = np.array([ + ... [False, True, False], + ... [False, True, False], + ... [False, True, False] + ... ]) + >>> prune_skeletonized_image(arr3, spur_branch_length=2) + array([[False, True, False], + [False, True, False], + [False, True, False]]) + """ + img = image.copy() + rows, cols = img.shape + + for _ in range(spur_branch_length): + endpoints = [] + + for i in range(1, rows - 1): + for j in range(1, cols - 1): + if img[i][j] and is_endpoint(img, i, j): + endpoints.append((i, j)) + for x, y in endpoints: + img[x][y] = False + return img + + +if __name__ == "__main__": + # Read original (skeletonized) image + skeleton_lena_path = ( + Path(__file__).resolve().parent.parent / "image_data" / "skeleton_lena.png" + ) + skeleton_lena = np.array(Image.open(skeleton_lena_path)) + + # Apply pruning operation to a skeletonized image + output = prune_skeletonized_image(gray_to_binary(rgb_to_gray(skeleton_lena))) + + # Save the output image + pil_img = Image.fromarray(output).convert("RGB") + pil_img.save("result_pruned.png") diff --git a/digital_image_processing/morphological_operations/skeletonization_operation.py b/digital_image_processing/morphological_operations/skeletonization_operation.py new file mode 100644 index 000000000000..1b46112c2f66 --- /dev/null +++ b/digital_image_processing/morphological_operations/skeletonization_operation.py @@ -0,0 +1,210 @@ +# @Author: @joydipb01 +# @File: skeletonization_operation.py +# @Time: 2025-10-03 13:45 + +from itertools import pairwise +from pathlib import Path + +import numpy as np +from PIL import Image + + +def rgb_to_gray(rgb: np.ndarray) -> np.ndarray: + """ + Return gray image from rgb image + + >>> rgb_to_gray(np.array([[[127, 255, 0]]])) + array([[187.6453]]) + >>> rgb_to_gray(np.array([[[0, 0, 0]]])) + array([[0.]]) + >>> rgb_to_gray(np.array([[[2, 4, 1]]])) + array([[3.0598]]) + >>> rgb_to_gray(np.array([[[26, 255, 14], [5, 147, 20], [1, 200, 0]]])) + array([[159.0524, 90.0635, 117.6989]]) + """ + r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] + return 0.2989 * r + 0.5870 * g + 0.1140 * b + + +def gray_to_binary(gray: np.ndarray) -> np.ndarray: + """ + Return binary image from gray image + + >>> gray_to_binary(np.array([[127, 255, 0]])) + array([[False, True, False]]) + >>> gray_to_binary(np.array([[0]])) + array([[False]]) + >>> gray_to_binary(np.array([[26.2409, 4.9315, 1.4729]])) + array([[False, False, False]]) + >>> gray_to_binary(np.array([[26, 255, 14], [5, 147, 20], [1, 200, 0]])) + array([[False, True, False], + [False, True, False], + [False, True, False]]) + """ + return (gray > 127) & (gray <= 255) + + +def neighbours(image: np.ndarray, x_coord: int, y_coord: int) -> list: + """ + Return 8-neighbours of point (x_coord, y_coord), in clockwise order + + >>> neighbours( + ... np.array( + ... [ + ... [True, True, False], + ... [True, False, False], + ... [False, True, False] + ... ] + ... ), 1, 1 + ... ) + [np.True_, np.False_, np.False_, np.False_, np.True_, np.False_, np.True_, np.True_] + >>> neighbours( + ... np.array( + ... [ + ... [True, True, False, True], + ... [True, False, False, True], + ... [False, True, False, True] + ... ] + ... ), 1, 2 + ... ) + [np.False_, np.True_, np.True_, np.True_, np.False_, np.True_, np.False_, np.True_] + """ + img = image + return [ + img[x_coord - 1][y_coord], + img[x_coord - 1][y_coord + 1], + img[x_coord][y_coord + 1], + img[x_coord + 1][y_coord + 1], + img[x_coord + 1][y_coord], + img[x_coord + 1][y_coord - 1], + img[x_coord][y_coord - 1], + img[x_coord - 1][y_coord - 1], + ] + + +def transitions(neighbors: list) -> int: + """ + Count 0->1 transitions in the neighborhood + + >>> transitions( + ... [ + ... np.False_, np.True_, np.True_, np.False_, + ... np.True_, np.False_, np.False_, np.False_ + ... ] + ... ) + 2 + >>> transitions( + ... [ + ... np.True_, np.True_, np.True_, np.True_, + ... np.True_, np.True_, np.True_, np.True_ + ... ] + ... ) + 0 + >>> transitions( + ... [ + ... np.False_, np.False_, np.False_, np.False_, + ... np.False_, np.False_, np.False_, np.False_ + ... ] + ... ) + 0 + >>> transitions( + ... [ + ... np.False_, np.True_, np.False_, np.True_, + ... np.False_, np.True_, np.False_, np.True_ + ... ] + ... ) + 4 + >>> transitions( + ... [ + ... np.True_, np.False_, np.True_, np.False_, + ... np.True_, np.False_, np.True_, np.False_ + ... ] + ... ) + 4 + """ + n = [*neighbors, neighbors[0]] + return int(sum((n1 == 0 and n2 == 1) for n1, n2 in pairwise(n))) + + +def skeletonize_image(image: np.ndarray) -> np.ndarray: + """ + Apply Zhang-Suen thinning to binary image for skeletonization. + Source: https://rstudio-pubs-static.s3.amazonaws.com/302782_e337cfbc5ad24922bae96ca5977f4da8.html + + >>> skeletonize_image(np.array([[np.False_, np.True_, np.False_], + ... [np.True_, np.True_, np.True_], + ... [np.False_, np.True_, np.False_]])) + array([[False, True, False], + [ True, True, True], + [False, True, False]]) + >>> skeletonize_image(np.array([[np.False_, np.False_, np.False_], + ... [np.False_, np.True_, np.False_], + ... [np.False_, np.False_, np.False_]])) + array([[False, False, False], + [False, True, False], + [False, False, False]]) + """ + img = image.copy() + changing1 = changing2 = [(-1, -1)] + + while changing1 or changing2: + # Step 1: Points to be removed in the first sub-iteration + changing1 = [] + rows, cols = img.shape + for x in range(1, rows - 1): + for y in range(1, cols - 1): + pixel = img[x][y] + if pixel != 1: + continue + neighbours_list = neighbours(img, x, y) + total_transitions = transitions(neighbours_list) + n = sum(neighbours_list) + if ( + 2 <= n <= 6 + and total_transitions == 1 + and neighbours_list[0] * neighbours_list[2] * neighbours_list[4] + == 0 + and neighbours_list[2] * neighbours_list[4] * neighbours_list[6] + == 0 + ): + changing1.append((x, y)) + for x, y in changing1: + img[x][y] = 0 + + # Step 2: Points to be removed in the second sub-iteration + changing2 = [] + for x in range(1, rows - 1): + for y in range(1, cols - 1): + pixel = img[x][y] + if pixel != 1: + continue + neighbours_list = neighbours(img, x, y) + total_transitions = transitions(neighbours_list) + n = sum(neighbours_list) + if ( + 2 <= n <= 6 + and total_transitions == 1 + and neighbours_list[0] * neighbours_list[2] * neighbours_list[6] + == 0 + and neighbours_list[0] * neighbours_list[4] * neighbours_list[6] + == 0 + ): + changing2.append((x, y)) + for x, y in changing2: + img[x][y] = 0 + + return img + + +if __name__ == "__main__": + # Read original image + lena_path = Path(__file__).resolve().parent.parent / "image_data" / "lena.jpg" + lena = np.array(Image.open(lena_path)) + + # Apply skeletonization operation to a binary image + # Caution: Takes at least 30 seconds to execute + output = skeletonize_image(gray_to_binary(rgb_to_gray(lena))) + + # Save the output image + pil_img = Image.fromarray(output).convert("RGB") + pil_img.save("result_skeleton.png")