python – Solving Sudoku using computer vision, class comments

I did a project on Github that uses computer vision to solve Sudokus. Since this is my first project, I would like to receive some comments about my code. As it is not allowed to link my repository, I took a class that I created. Therefore, I am looking for comments on how I write my code, not necessarily on algorithms. Is there anything I can do to improve readability or make it look better? More pythonic?


class PreprocessNumber:
    """
        A class used to process images from a Sudoku board.
        Each box in the Sudoku board should be given an instance of this class. The class contains methods to
        determine if one box contains a number or not. If the box contains a number this class provices methods to
        extract and center the number in a new image with the dimension specified by the user.
        Attributes
        ----------
        NOT_BLACK_THRESHOLD : int (20)
            a threshold used to classify if a pixel is considered non-black.
        image : numpy array
            the input image represented by a numpy array
        extracted_feature : numpy array
            a numpy array representing the extracted feature from the image
        dimension : tuple
            a tuple describing the dimension input image
        __dct_groups : dictionary
            a dictionary used to keep track of all groups of feature in the image as well as the group that each pixel
            contains to.
        Methods
        -------
        def crop_feature(self):
            Crops the most centered feature in the image.
        def is_number(self):
            Validates if the instance of this class contains a number by using two conditions.
        def get_centered_number(self, dimension):
            Returns centered number.
        def __create_groups_of_features(self):
            Creates groups of features.
        def __check_left_and_above_pixel_for_group(self, pixel_coord):
            Returns the group value of adjacent pixels.
        def __get_group_number_of_centered_feature(self):
            Finds and returns the group number of the most centered feature.
        def __extract_feature(self, feature_group_number):
            Extracts all pixels corresponding to the given group number to self.extracted_feature.
        def __validate_first_condition(self, temp=0):
            Validates the first condition to determine if the instance of this class contains a number.
        def __validate_second_condition(self):
            Validates the second condition to determine if the instance of this class contains a number.
        """

    def __init__(self, image):
        """
        Parameters
        ----------
        image : numpy array
            the input image represented by a numpy array
        """
        self.NOT_BLACK_THRESHOLD = 20
        self.image = image
        self.extracted_feature = np.zeros_like(self.image)
        self.dimension = self.image.shape
        self.__dct_groups = {}

    def crop_feature(self):
        """Crops the most centered feature in the image.
        This method takes the most centered cohesive group of non-black pixels and crop them to self.extracted_feature.
        """

        self.__create_groups_of_features()
        self.__extract_feature(self.__get_group_number_of_centered_feature())

    def is_number(self):
        """Validates if the instance of this class contains a number by using two conditions.
        Returns
        -------
        boolean
            a boolean representing if self.extracted_feature contains a number or not. True if a number is present,
            False otherwise.
        """
        return self.__validate_first_condition() and self.__validate_second_condition()

    def get_centered_number(self, dimension):
        """Returns centered number.
        This method centers the number currently located in self.extracted_feature and then returns the centered number.
        Parameters
        ----------
        dimension : tuple
            A tuple describing the dimension of the centered number to return
        Returns
        -------
        numpy array
            a numpy array representing the centered number having the dimension as specified by the parameter.
        """

        # Initialize variables to find boundaries
        left_bound = self.extracted_feature.shape(0)
        right_bound = 0
        upper_bound = self.extracted_feature.shape(1)
        lower_bound = 0
        # Find top left and bottom right corners of the number
        for row in range(self.extracted_feature.shape(0)):
            for col in range(self.extracted_feature.shape(1)):
                if self.extracted_feature(row)(col) > self.NOT_BLACK_THRESHOLD:
                    if row < upper_bound:
                        upper_bound = row
                    elif row > lower_bound:
                        lower_bound = row
                    if col < left_bound:
                        left_bound = col
                    elif col > right_bound:
                        right_bound = col
        width = right_bound - left_bound + 1
        height = lower_bound - upper_bound + 1

        extracted_number = np.zeros((height, width))

        # Extract the number
        for row in range(height):
            for col in range(width):
                extracted_number(row)(col) = self.extracted_feature(upper_bound + row)(left_bound + col)

        # Place the extracted number in a square
        if height != width:
            square_number = np.zeros((max((height, width)), max(height, width)))
            if height > width:
                # Add black columns to the left and right
                diff = int((height-width)/2)
                square_number(:, diff:diff + width) = extracted_number
            else:
                # Add black rows above and under
                diff = int((width-height)/2)
                square_number(diff:diff + height, :) = square_number
        else:
            square_number = extracted_number
        centered_number = np.zeros(dimension)

        # Assert that there is at least four black pixels closest to all edges.
        if np.any(np.array(square_number.shape) > 20):
            centered_number(4:24, 4:24) = resize(square_number, (20, 20), anti_aliasing=True)
        else:
            size = square_number.shape
            diff_height = int((dimension(0) - size(0))/2)
            diff_width = int((dimension(0) - size(1))/2)
            centered_number(diff_height:size(0) + diff_height, diff_width:size(1) + diff_width) = square_number
        return centered_number

    def __create_groups_of_features(self):
        """Creates groups of features.
        All different cohesive groups of non-black pixels will be treated as one feature. This method goes through all
        pixels and labels all cohesive groups of non-black pixels. Each pixel will be a key corresponding to its group
        in a dictionary. Black pixels will have its group number set to -1, indicating not a feature.
        """

        new_group = 0
        for row in range(self.dimension(0)):
            for col in range(self.dimension(1)):
                if self.image(row)(col) > self.NOT_BLACK_THRESHOLD:
                    # Checks if the current pixel at (row, col) is adjacent to already assigned non-black pixels.
                    surrounding_group = self.__check_left_and_above_pixel_for_group(pixel_coord=(row, col))
                    if surrounding_group != -1:
                        self.__dct_groups(str(row) + ":" + str(col)) = surrounding_group
                    else:
                        self.__dct_groups(str(row) + ":" + str(col)) = new_group
                        new_group += 1
                else:
                    self.__dct_groups(str(row) + ":" + str(col)) = -1

    def __check_left_and_above_pixel_for_group(self, pixel_coord):
        """Returns the group value of adjacent pixels.
        Given a coordinate of a pixel, this method checks if the adjacent pixels to the left and above have already
        been assigned a group. If they have, this method returns the group number. If the pixel to the left and above
        have been assigned different groups, all pixels associated with the group of the pixel above will be assigned
        the group of the pixel to the left.
        """

        group_pixel_left = -1
        if pixel_coord(1) > 0:
            # If there is an adjacent pixel to the left
            group_pixel_left = self.__dct_groups(str(pixel_coord(0)) + ":" + str(pixel_coord(1)-1))
        if pixel_coord(0) > 0:
            # If there is an adjacent pixel above
            group_pixel_above = self.__dct_groups(str(pixel_coord(0) - 1) + ":" + str(pixel_coord(1)))
            if group_pixel_left == -1:
                return group_pixel_above
            elif group_pixel_above == -1:
                return group_pixel_left
        else:
            # No adjacent pixel above, return the group of the pixel to the left
            return group_pixel_left

        if group_pixel_left != group_pixel_above:
            # Replacing pixels belonging to the group of the pixel above to the group of the pixel to the left
            for item in self.__dct_groups:
                if self.__dct_groups(item) == group_pixel_above:
                    self.__dct_groups(item) = group_pixel_left
        return group_pixel_left

    def __get_group_number_of_centered_feature(self):
        """Finds and returns the group number of the most centered feature.
        This method uses an algorithm that starts to search from the middle and then expands its search in all
        directions. When a non-black pixel is found, the corresponding group is returned.
        """

        center_row = int(self.dimension(0) / 2)
        center_col = int(self.dimension(1) / 2)
        nr_search_loops = min(self.dimension(0) - center_row, self.dimension(1) - center_row) - 1
        loop_directions = ((0, 1), (1, 0), (0, -1), (-1, 0))

        # Starts looking at the centered pixel
        if self.image(center_row)(center_col) > self.NOT_BLACK_THRESHOLD:
            return self.__dct_groups(str(center_row) + ":" + str(center_col))
        else:
            for loop_nr in range(1, nr_search_loops):
                center_row -= 1
                center_col -= 1
                search_coord = (center_row, center_col)
                for direction in loop_directions:
                    vertical = direction(0)
                    horizontal = direction(1)
                    for i in range(loop_nr * 2):
                        if self.image(search_coord(0))(search_coord(1)) > self.NOT_BLACK_THRESHOLD:
                            return self.__dct_groups(str(search_coord(0)) + ":" + str(search_coord(1)))
                        search_coord(0) += 1 * vertical
                        search_coord(1) += 1 * horizontal
        # If no group was found, all pixels are black.
        return -1

    def __extract_feature(self, feature_group_number):
        """Extracts all pixels corresponding to the given group number to self.extracted_feature.
        Parameters
        ----------
        feature_group_number : int
            An integer representing the group number of the feature to extract.
        """

        for row in range(self.dimension(0)):
            for col in range(self.dimension(1)):
                if self.__dct_groups(str(row) + ":" + str(col)) == feature_group_number:
                    self.extracted_feature(row)(col) = self.image(row)(col)

    def __validate_first_condition(self):
        """Validates the first condition to determine if the instance of this class contains a number.
        Assume the image of the potential number is split into a 3x3-grid with 9 squares of equal size. The first
        condition is that the middle square should contain at least five non-black pixels.
        Returns
        -------
        boolean
            a boolean representing if self.extracted_feature contains a number or not. True if a number is present,
            False otherwise.
        """

        non_black_dot_counter = 0
        # x_box and y_box represents the coordinates of the upper left corner of the middle square
        x_box = int(self.extracted_feature.shape(0) / 9) * 4
        y_box = int(self.extracted_feature.shape(1) / 9) * 4
        for row in range(x_box, x_box + int(self.extracted_feature.shape(0) / 9)*2):
            for col in range(y_box, y_box + int(self.extracted_feature.shape(1) / 9) * 2):
                if self.extracted_feature(row)(col) > self.NOT_BLACK_THRESHOLD:
                    non_black_dot_counter += 1
                    if non_black_dot_counter >= 5:
                        return True
        return False

    def __validate_second_condition(self):
        """Validates the second condition to determine if the instance of this class contains a number.
        The second condition is simply that the extracted feature should have at least 25 non-black pixels to be able
        to be classified as a number.
        Returns
        -------
        boolean
            a boolean representing if self.extracted_feature contains a number or not. True if a number is present,
            False otherwise.
        """

        counter = 0
        for row in self.extracted_feature:
            for pixel in row:
                if pixel > self.NOT_BLACK_THRESHOLD:
                    counter += 1
                    if counter >= 25:
                        return True
        return False
```