pythoncalgorithm

Algorithm for transformation of an 1-D-gradient into a special form of a 2-D-gradient


Assuming there is a 1-D array/list which defines a color gradient I would like to use it in order to create a 2-D color gradient as follows:

Let's for simplicity replace color information with a single numerical value for an example of a 1-D array/list:

[ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ]

To keep the gradient progressing diagonally with progress of the largest next value diagonally over the entire array I would like to transform the 1-D sequence into a 2D-array with deliberately chosen shape (i.e. width/height, i.e. number of rows x number of columns where row * columns == length of the 1-D gradient array) as follows:

[[  1  2  4 ]
 [  3  6  7 ]
 [  5  9 10 ]
 [  8 12 13 ]
 [ 11 14 15 ]]

or

[[  1  2  4  7 10 ]
 [  3  6  9 12 13 ]
 [  5  8 11 14 15 ]]

or starting from a sequence:

[ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16]

to

[[  1  2  4  7 ]
 [  3  6  9 11 ]
 [  5 10 13 14 ]
 [  8 12 15 16 ]]

Is there a ready-to-use out of the box Python module or C-library capable to perform such reshaping of an array or need this special case be coded by hand? And if coding the loops by hand is necessary, what would be the most efficient way of doing this as the sequence I would like to transform is 256³ large in size? I there maybe already ready for use code for such reshaping/transformation out there in the deep space of the Internet I have failed to find asking both the search engines and the LLMs?


Solution

  • General idea

    From what I can see, this can be done in three steps:

    1. Split the sequence in fragments as if they were diagonals of an array of a given shape.
    2. Separate the elements of each fragment by the parity of their index.
    3. Assemble a new array from the modified diagonal fragments.

    Step 1. Split the sequence into diagonal fragments

    I think it will be enough to find the stopping points, so then we can slice the sequence with them. For this, we can apply the cumulative sum to a sequence of diagonal lengths:

    import numpy as np
    from numba import njit
    
    @njit
    def flat_diagonal_stops(height, width):
        '''Return a sequence of breakpoints separating a sequence 
        of length height*width into a sequence of matrix diagonals
        of the shape (height, width)
        '''
        min_dim = min(height, width)
        lengths = np.empty(height + width, dtype='int')
        lengths[:min_dim] = [*range(min_dim)]           # diagonal lengths in the lower triangle
        lengths[min_dim:1-min_dim] = min_dim            # diagonal lengths in the main body
        lengths[:-min_dim:-1] = lengths[1:min_dim]      # diagonal lengths in the upper triangle
        return lengths.cumsum()
    

    Step 2. Separate elements by index parity

    A sequence transformation like this:

    (0, 1, 2, 3, 4, 5) >>> (0, 2, 4, 5, 3, 1)
    

    is actually a separation of elements by the parity of their positional index. Elements with an even index are shifted to the left, while the others - to the right in reverse order:

    @njit
    def separate_by_index_parity(arr):
        '''Return a numpy.ndarray filled with elements of arr, 
        first those in even-numbered positions, 
        then those in odd-numbered positions in reverse order
        '''
        out = np.empty_like(arr)
        middle = sum(divmod(len(out), 2))
        out[:middle] = arr[::2]
        out[:middle-len(out)-1:-1] = arr[1::2]
        return out
    

    Step 3. Assemble the fragments as diagonals of a new array

    To do this, we can create a flat representation of the required output and work within it by slicing diagonal positions:

    @njit
    def assemble_diagonals_separated_by_parity(arr, height, width):
        '''Return a matrix of shape (height, width) with elements 
        of the given sequence arr arranged along diagonals, 
        where the elements on each diagonal are separated 
        by the parity of their index in them
        '''
        out = np.empty(height*width, dtype=arr.dtype)
        stops = flat_diagonal_stops(height, width)
        out_step = width + 1
        for offset, (start, stop) in enumerate(zip(stops[:-1], stops[1:]), 1-height):
            # out_from: the first element of an off-diagonal
            # out_to  : next after the last element of an off-diagonal
            # out_step: a stride to get diagonal items
            out_from = -offset*width if offset < 0 else offset
            out_to = out_from + (stop-start)*out_step     # stop - start is equal to the diagonal size
            out[out_from:out_to:out_step] = separate_by_index_parity(arr[start:stop])
        return out.reshape(height, width)
    

    The result is a stacking of the modified sequence on diagonals from bottom to top and from left to right. To get other types of stacking, we combine flipping and transposing. For example, we can stack elements in the left-to-right and top-to-bottom order along anti-diagonals as follows (note the reverse order of dimensions (width, height) in a function call):

    height, width = 6, 4
    arr = np.arange(1, 1+height*width)
    out = np.fliplr(assemble_diagonals_separated_by_parity(arr, width, height).T)
    
    print(out)
    
    [[ 1  2  4  7]
     [ 3  6  9 11]
     [ 5 10 13 15]
     [ 8 14 17 19]
     [12 18 21 22]
     [16 20 23 24]]
    

    Code for experiments

    import numpy as np
    from numba import njit
    
    @njit
    def flat_diagonal_stops(height, width):
        min_dim = min(height, width)
        lengths = np.empty(height + width, dtype='int')
        lengths[:min_dim] = [*range(min_dim)]
        lengths[min_dim:1-min_dim] = min_dim
        lengths[:-min_dim:-1] = lengths[1:min_dim]
        return lengths.cumsum()
    
    @njit
    def separate_by_index_parity(arr):
        out = np.empty_like(arr)
        middle = sum(divmod(len(out), 2))
        out[:middle] = arr[::2]
        out[:middle-len(out)-1:-1] = arr[1::2]
        return out
    
    @njit
    def assemble_diagonals_separated_by_parity(arr, height, width):
        if height == 1 or width == 1: 
            return arr.reshape(height, width).copy()
        out = np.empty(height*width, dtype=arr.dtype)
        stops = flat_diagonal_stops(height, width)
        out_step = width + 1
        for offset, (start, stop) in enumerate(zip(stops[:-1], stops[1:]), 1-height):
            out_from = -offset*width if offset < 0 else offset
            out_to = out_from + (stop-start)*out_step
            out[out_from:out_to:out_step] = separate_by_index_parity(arr[start:stop])
        return out.reshape(height, width)
    
    height, width = 6, 4
    arr = np.arange(1, 1+height*width)
    out = np.fliplr(assemble_diagonals_separated_by_parity(arr, width, height).T)
    
    print(out)
    

    P.S. Stack the data directly along anti-diagonals

    Let's specialize the assembly function to work directly with anti-diagonals, so as not to get confused with flip-transpose tricks. In this case, we have a shorter slicing step, and the starting point will be along the top and right edges. Everything else remains unchanged:

    @njit
    def assemble_antidiagonals_separated_by_parity(arr, height, width):
        if height == 1 or width == 1: 
            return arr.reshape(height, width).copy()
        out = np.empty(height*width, dtype=arr.dtype)
        stops = flat_diagonal_stops(height, width)
        out_step = width - 1
        for offset, (start, stop) in enumerate(zip(stops[:-1], stops[1:])):
            out_from = offset if offset < width else (offset-width+2)*width-1
            out_to = out_from + (stop-start)*out_step
            out[out_from:out_to:out_step] = separate_by_index_parity(arr[start:stop])
        return out.reshape(height, width)
    
    >>> height, width = 8, 5
    >>> arr = np.arange(1, 1+height*width)
    >>> out = assemble_antidiagonals_separated_by_parity(arr, height, width)
    >>> print(out)
    [[ 1  2  4  7 11]
     [ 3  6  9 13 16]
     [ 5 10 15 18 21]
     [ 8 14 20 23 26]
     [12 19 25 28 31]
     [17 24 30 33 35]
     [22 29 34 37 38]
     [27 32 36 39 40]]