How to calculate all 24 rotations of 3d array?
Asked Answered
C

12

32

I have a 3d numpy array describing a polycube (imagine a 3d tetris piece). How can I calculate all 24 rotations?

Numpy's array manipulation routines include a rot90 method, which gives 4 of the 24, but I'm clueless how to calculate the rest. My only idea is to convert the 3d array to a 2d matrix of co-ordinates, multiply by a rotation matrix, and convert back. But I'd rather work directly with the 3d array.

Example 2x2x2 array:

>>> from numpy import array
>>> polycube
array([[[1, 0],
        [1, 0]],

       [[1, 1],
        [0, 0]]])

Example 3x3x3 array:

array([[[1, 1, 0],
        [1, 1, 0],
        [0, 0, 0]],

       [[0, 0, 0],
        [1, 0, 0],
        [1, 0, 0]],

       [[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]]])

Edit: I only want the 24 orientation-preserving isometries, not all 48 rotations and reflections (though it would be interesting to know how to make them too). If it helps to test, I believe the 3x3x3 example has no rotational symmetry and is chiral (so the 48 are distinct).

Motivation: I'm writing a solver for a Soma cube-style puzzle.

Caribou answered 17/10, 2015 at 18:21 Comment(4)
You could use the fliplr and flipud methods to generate the other rotations, in combination with the rot90, I believeDeplane
Maybe. fliplr and flipud are orientation-reversing so I know I can't use them on their own. I only want the orientation-preserving isometries (24 not 48).Caribou
Just as a mental exercise: it's important to note how this is different from a more common notion of "3D rotation". Usually, you would take an array of N 3D point points (Nx3 matrix) and multiply it by a 3D rotatin matrix (3x3 matrix), getting as a result another Nx3 matrix, where each column would be the former column (a single 3D point) rotated. What is being asked is quite different, since the original matrix is MxNxO (three axis instead of one or two from the more common case).Synapse
Numpy 1.12.0 added an axes argument to the rot90 function which may simplify some of the code belowCaribou
C
14

Update: Simplified after Numpy 1.12.0 added an axes argument to the rot90 function

Here's how I made all 24 rotations:

from numpy import rot90, array

def rotations24(polycube):
    """List all 24 rotations of the given 3d array"""
    def rotations4(polycube, axes):
        """List the four rotations of the given 3d array in the plane spanned by the given axes."""
        for i in range(4):
             yield rot90(polycube, i, axes)

    # imagine shape is pointing in axis 0 (up)

    # 4 rotations about axis 0
    yield from rotations4(polycube, (1,2))

    # rotate 180 about axis 1, now shape is pointing down in axis 0
    # 4 rotations about axis 0
    yield from rotations4(rot90(polycube, 2, axes=(0,2)), (1,2))

    # rotate 90 or 270 about axis 1, now shape is pointing in axis 2
    # 8 rotations about axis 2
    yield from rotations4(rot90(polycube, axes=(0,2)), (0,1))
    yield from rotations4(rot90(polycube, -1, axes=(0,2)), (0,1))

    # rotate about axis 2, now shape is pointing in axis 1
    # 8 rotations about axis 1
    yield from rotations4(rot90(polycube, axes=(0,1)), (0,2))
    yield from rotations4(rot90(polycube, -1, axes=(0,1)), (0,2))

Test that all 24 rotations are indeed distinct:

polycube = array([[[1, 1, 0],
        [1, 1, 0],
        [0, 0, 0]],

       [[0, 0, 0],
        [1, 0, 0],
        [1, 0, 0]],

       [[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]]])

assert len(set(str(x) for x in rotations24(polycube))) == 24
Caribou answered 17/10, 2015 at 19:3 Comment(2)
I do not really understand what yield does. How can I store the data in an array A of dimensions [24,3,3,3]?Xerarch
@PranoyRay numpy.array(list(rotations24(polycube))) has shape (24, 3, 3, 3)Caribou
M
9

Edit: As my solution basically boils down to the product of the parities of the axes multiplied by the parity of the permutation of the axes, the simplest method for generating all of the regular rotations of an n-dimensional array is this (swiping some code form @Divakar's answer):

import itertools as it

def p_parity(a):
    a = np.asarray(a)
    l = a.size
    i, j = np.tril_indices(l, -1)
    return np.product(np.sign(a[i] - a[j]))

def rotations_gen(m):
    n = m.ndim
    for i in it.product([-1, 1], repeat = n):
        for p in it.permutations(np.arange(n)):
            if np.product(i) * p_parity(p) == 1:
                s = [slice(None, None, j) for j in i]
                yield np.transpose(m[s], p)    

This works for any (even non-square) tensors of arbitrary dimension and is based directly on the definition of regular rotations under tensor algebra below.

Background

Easiest way to explain this is in tensor terms, so lets turn all those rotations into rotation tensors. Rotation tensors are n x n matrices that rotate an n-dimensional space. As such they have a few properties:

np.linalg.det(R) == 1                    # determinant = 1
np.inner(R, R.T) == np.eye(R.shape[0])   # Transpose is inverse

In addition, for 90 degree roatations all terms must be either 0, 1, or -1.

In three dimensions, there are three basic families of these, which compose togther to make your 24 rotations.

The first is simple permutation:

A = 
[[[1, 0, 0],
  [0, 1, 0],
  [0, 0, 1]],

 [[0, 1, 0],
  [0, 0, 1],
  [1, 0, 0]],

 [[0, 0, 1],
  [1, 0, 0],
  [0, 1, 0]]]

The second involves negating some terms so that the product of the diagonal is always 1:

B = 
[[[ 1, 0, 0],
  [ 0, 1, 0],
  [ 0, 0, 1]],

 [[-1, 0, 0],
  [ 0,-1, 0],
  [ 0, 0, 1]],

 [[-1, 0, 0],
  [ 0, 1, 0],
  [ 0, 0,-1]],

 [[ 1, 0, 0],
  [ 0,-1, 0],
  [ 0, 0,-1]]]

And the third determines whether the permutation is positive or negative, and negates the terms if negative

C = 
[[[ 1, 0, 0],
  [ 0, 1, 0],
  [ 0, 0, 1]],

 [[ 0, 0,-1],
  [ 0,-1, 0],
  [-1, 0, 0]],

The imprtant thing about these families is in each family any product, power or transpose of two matrices yields another matrix in the family. Since we have three families, their products with each other form all the possible rotations, in this case 3*4*2 = 24

Note: the other 24 "irregular" rotations are the same matrices multiplied by -np.eye(3) which yeild similar matrices with determinant = -1

Application

That's all well and good, but how does that relate to array manipulation? We don't want to rotate by matrix multiplication, as that will cause undue overhead in memory and processing. Luckily, each family is easily related to a an array manipulation that produces a view.

def A_(m, i):  # i in (0, 1, 2)
    idx = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]])
    return np.transpose(m, idx[i])

def B_(m, j):  # j in (0, 1, 2, 3)
    idx = np.array([[ 1, 1, 1],
                    [ 1,-1,-1],
                    [-1, 1,-1],
                    [-1,-1, 1]])
    return m[::idx[j, 0], ::idx[j, 1], ::idx[j, 2]]

def C_(m, k):  # k in (1, -1)
    return np.transpose(m, np.arange(3)[::k])[::k, ::k, ::k]

All of these produce views of m, and you can create a generator that produces views relating to all of the rotations by:

def cube_rot_gen(m):
    for i in [0, 1, 2]:
        for j in [0, 1, 2, 3]:
            for k in [1, -1]:
                yield C_(B_(A_(m, i), j), k)
Maltase answered 14/8, 2018 at 8:24 Comment(0)
T
8

Look at the code for rot90. I see 3 variations on flip and swapaxes, depending on k the axis parameter.

fliplr(m).swapaxes(0, 1)
fliplr(flipud(m))
fliplr(m.swapaxes(0, 1))

fliplr(m) is just m[:, ::-1], and not surprisingly, flipud is m[::-1, ...].

You could flip the 3rd axis with m[:,:,::-1], or m[...,::-1].

np.transpose is another tool for permuting axes, that may, or may not, be easier to use than swapaxes.

If rot90 gives you 4 of the rotations, you should be able apply the same routines to produce the others. You just have to understand the logic underlying rot90.

e.g.

def flipbf(m):
    return m[:,:,::-1]

flipbf(m).swapaxes(0, 2)
flipbf(m).swapaxes(1, 2)
etc
Tie answered 17/10, 2015 at 18:48 Comment(3)
I'm not sure flipping/mirroring something should count as a proper 3D rotation, since it changes chiralitySynapse
(Well, unless of course you compose flipping and swapping as you did. Clever!)Synapse
That could be. I'm assuming the OP knows what he's doing when using rot90.Tie
S
4

We would start off with the intention of getting all 48 combinations so that we get the general idea about solving it for a n-dim array. Later on we would filter out the unwanted 24 ones.

Generic idea to solve for all rotations

The idea to solve for a generic case would be to basically do two things - flip along every axis and permute axes with all combinations for the given number of axes.

Flip : To flip, we would use the stepsize parameter for slicing, i.e. array[::stepsize]. So, to flip, it would be : [::-1] and without flipping, simply : [::1]. That stepsize could be assigned as a variable varying between 1 and -1 for the two combinations simpl. For a ndarray, simply extend this to all axes.

Permute axes : To achieve this, we can use np.transpose and specify the required permuted order as the axes parameter with it. We will generate all possible orders with itertools.permutations.

That's all there is! Let's implement it with a as the input 3D array -

import itertools

def rotations48(a):
    # Get all combinations of axes that are permutable
    n = a.ndim
    axcomb = np.array(list(itertools.permutations(range(n), n)))
    
    # Initialize output array
    out = np.zeros((6,2,2,2,) + a.shape,dtype=a.dtype)
    
    # Run loop through all axes for flipping and permuting each axis
    for i,ax in enumerate(axcomb):
        for j,fx in enumerate([1,-1]):
            for k,fy in enumerate([1,-1]):
                for l,fz in enumerate([1,-1]):
                    out[i,j,k,l] = np.transpose(a[::fx,::fy,::fz],ax) 
    return out

We could simplify for the flipping nested loops with one loop -

def rotations48(a):
    n = a.ndim
    axcomb = list(itertools.permutations(range(n), n)) # all axes combinations    
    pcomb = list(itertools.product([1,-1], repeat=n)) # all permuted orders
    out = np.zeros((6,8,) + a.shape,dtype=a.dtype) # Initialize output array    
    for i,ax in enumerate(axcomb): #loop through all axes for permuting
        for j,(fx,fy,fz) in enumerate(pcomb): # all flipping combinations
            out[i,j] = np.transpose(a[::fx,::fy,::fz],ax) 
    return out

So, this gets us all the 48 combinations.

Extend to more dimensions : If we want to extend this to a 4D array, simply edit the initialization part to extend by 2 and slice along one more axis.


Solve for 24 rotations

Now, as OP is claiming to have a working solution to get the desired 24 combinations, we need to filter out from our proposed solution. I couldn't find a generic pattern for the filtering, but got the indices required for indexing -

idx = np.array([ 0,  3,  5,  6,  9, 10, 12, 15, 17, 18, 20, 23, 24, \
                27, 29, 30, 32, 35, 37, 38, 41, 42, 44, 47])

If you care about the order to have the output same output as with rotations24, we would have -

idx = np.array([ 0, 10,  3,  9,  5, 15,  6, 12, 41, 27, 42, 24, 44, \
                30, 47, 29, 18, 35, 17, 32, 20, 37, 23, 38])

Hence, get the required 24 ones with indexing -

final_out = out.reshape(48,-1)[idx]

This works for 3D arrays with any generic lengths.

Sample run for verification

# From https://stackoverflow.com/a/33190472/ @Colonel Panic
def rotations24_array(a):
    out0 = np.zeros((6,2,2,2,) + a.shape,dtype=a.dtype)    
    p = [list(i) for i in rotations24(a)]
    out0 = np.zeros((6,4,m,m,m),dtype=a.dtype)
    for i in range(6):
        for j in range(4):
            out0[i,j] = p[i][j]   
    return out0

Verify -

In [486]: # Setup    
     ...: np.random.seed(0)
     ...: m = 3
     ...: a = np.random.randint(11,99,(m,m,m))
     ...: 
     ...: # Verify results
     ...: idx = np.array([ 0, 10,  3,  9,  5, 15,  6, 12, 41, 27, 42, 24, 44, \
     ...:                 30, 47, 29, 18, 35, 17, 32, 20, 37, 23, 38])
     ...: out1 = rotations24_array(a).reshape(-1,m**3)
     ...: out2 = rotations48(a).reshape(48,-1)[idx]
     ...: print np.allclose(out1, out2)
True
Samora answered 10/8, 2018 at 21:14 Comment(0)
F
2

Another option is to combine the rotations around the axis of the cube that represents the matrix. Something like:

import numpy as np


"""
Basic rotations of a 3d matrix.
----------
Example:

cube = array([[[0, 1],
               [2, 3]],

              [[4, 5],
               [6, 7]]])

axis 0: perpendicular to the face [[0,1],[2,3]] (front-rear)
axis 1: perpendicular to the face [[1,5],[3,7]] (lateral right-left)
axis 2: perpendicular to the face [[0,1],[5,4]] (top-bottom)
----------
Note: the command m[:, ::-1, :].swapaxes(0, 1)[::-1, :, :].swapaxes(0, 2) rotates the cube m
around the diagonal axis 0-7.
"""


def basic_rot_ax(m, ax=0):
    """
    :param m: 3d matrix
    :return: rotate the cube around axis ax, perpendicular to the face [[0,1],[2,3]]
    """

    ax %= 3

    if ax == 0:
        return np.rot90(m[:, ::-1, :].swapaxes(0, 1)[::-1, :, :].swapaxes(0, 2), 3)
    if ax == 1:
        return np.rot90(m, 1)
    if ax == 2:
        return m.swapaxes(0, 2)[::-1, :, :]


def axial_rotations(m, rot=1, ax=2):
    """
    :param m: 3d matrix
    :param rot: number of rotations
    :param ax: axis of rotation
    :return: m rotate rot times around axis ax, according to convention.
    """

    if len(m.shape) is not 3:
        assert IOError

    rot %= 4

    if rot == 0:
        return m

    for _ in range(rot):
        m = basic_rot_ax(m, ax=ax)

    return m

If I am not wrong, the 24 rotations you are looking for are a combination of these 9 transformations.

Finnell answered 10/6, 2016 at 15:31 Comment(0)
M
2

There are 24 rotation matrices at the bottom of this page: http://www.euclideanspace.com/maths/algebra/matrix/transforms/examples/index.htm .

If a tetris piece were represented by an array of coordinate triples, to apply a rotation to the piece you would just matrix multiply the matrix by each of the triples in the array. You would do that 24 times to get the 24 different pieces.

For example, if your array of boxes is (1,2,5), (1,2,4), (1,3,4)

And the rotation you are considering at the moment is for example

    1 0  0
M = 0 0 -1
    0 1  0

Then the rotated figure has representation

(1,2,5).M, (1,2,4).M, (1,3,4).M

where the . represents matrix multiplication. Do that for each M in the list and you've got all 24 rotations. (You can either pre- or post- multiply by the matrices, it doesn't matter if you want the whole set of rotations in no particular order.)

So that is very straightforward.

Now, to get the data out of your data structure and into the array structure I have used above, you would have to do something like (sorry I don't know Python)

for i = 0 to 2
  for j = 0 to 2 
    for k = 0 to 2
      if polycube(i,j,k)==1 then push(i-1,j-1,k-1) onto end of newArray

or something like that. Finally, to go from the newArray representation to the polycube you would do something like

  polycube = all zeros
    foreach point (i,j,k) in newArray
      polycube(i+1,j+1,k+1) = 1

For a 2x2x2 polycube, you would do

for i = 0 to 1
  for j = 0 to 1 
    for k = 0 to 1
      if polycube(i,j,k)==1 then push(i-0.5,j-0.5,k-0.5) onto end of newArray

and the corresponding inverse function.

Mouthy answered 14/8, 2018 at 9:57 Comment(0)
M
2

I've decided to post another answer. My first answer was very straightforward, but not entirely in the spirit of the question. I think I have a better answer now, after thinking about it for a while.

TL;DR The following code rotates a polycube through all 24 rotations of the cube. It does so with no matrix multiplications, instead using a 3x3x3x3x3 index table which gives a few key rotations of the indices in the polycube, and a length-23 Gray code for the rotation group of the cube. It has only six real lines of code, almost all of which are for loops. It is extremely fast, and fairly compact (except for the index table, which could be generated by the program (see below)).

import numpy as np

# polycube that we want to rotate
A = np.array([[['a', 'b', 'c'],
               ['d', 'e', 'f'],
               ['g', 'h', 'i']],

              [['j', 'k', 'l'],
               ['m', '+', 'n'],
               ['o', 'p', 'q']],

              [['r', 's', 't'],
               ['u', 'v', 'w'],
               ['x', 'y', 'z']]])

# I just do np.copy here because I don't know how to declare an array
T = np.copy(A)

Idx=np.array([
    [[[[2,0,0],   [2,1,0],   [2,2,0]],
      [[2,0,1],   [2,1,1],   [2,2,1]],
      [[2,0,2],   [2,1,2],   [2,2,2]]],

     [[[1,0,0],   [1,1,0],   [1,2,0]],
      [[1,0,1],   [1,1,1],   [1,2,1]],
      [[1,0,2],   [1,1,2],   [1,2,2]]],

     [[[0,0,0],   [0,1,0],   [0,2,0]],
      [[0,0,1],   [0,1,1],   [0,2,1]],
      [[0,0,2],   [0,1,2],   [0,2,2]]]],


    [[[[0,2,0],   [1,2,0],   [2,2,0]],
      [[0,1,0],   [1,1,0],   [2,1,0]],
      [[0,0,0],   [1,0,0],   [2,0,0]]],

     [[[0,2,1],   [1,2,1],   [2,2,1]],
      [[0,1,1],   [1,1,1],   [2,1,1]],
      [[0,0,1],   [1,0,1],   [2,0,1]]],

     [[[0,2,2],   [1,2,2],   [2,2,2]],
      [[0,1,2],   [1,1,2],   [2,1,2]],
      [[0,0,2],   [1,0,2],   [2,0,2]]]],


    [[[[2,2,2],   [2,1,2],   [2,0,2]],
      [[2,2,1],   [2,1,1],   [2,0,1]],
      [[2,2,0],   [2,1,0],   [2,0,0]]],

     [[[1,2,2],   [1,1,2],   [1,0,2]],
      [[1,2,1],   [1,1,1],   [1,0,1]],
      [[1,2,0],   [1,1,0],   [1,0,0]]],

     [[[0,2,2],   [0,1,2],   [0,0,2]],
      [[0,2,1],   [0,1,1],   [0,0,1]],
      [[0,2,0],   [0,1,0],   [0,0,0]]]]
])

# Gray code gives Hamiltonican circuit through cube rotation group
gray = [3,2,1,2,1,2,3,2,3,2,1,2,1,2,3,2,3,2,1,2,1,2,3]

# Oops, index starting at 0
gray[:] = [x-1 for x in gray]

print(A)

# I'm sure there must be a more efficient way to move T (temp) into A
for g in gray:
    for i in [0,1,2]:
        for j in [0,1,2]:
            for k in [0,1,2]:
                T[i,j,k] = A[Idx[g,i,j,k,0],Idx[g,i,j,k,1],Idx[g,i,j,k,2]]
    A = np.copy(T)
    print("-----------------")
    print(A)

My solution is to find a Gray code for the rotation group of the cube.

First, we must find a nice representation for the rotation group you are using. Consider a 2x2x2 cube with 1x1x1 cubies labeled in the following manner:

1 2   
3 4   4 3
      2 1

We number starting at 1 to be consistent with the mathematics literature. The upper left square is meant to be the front face, and the lower right square the rear face. Imagine the 1 connected by edges to the 2 and 3 in the front face, and to the 4 in the rear face, etc.

Note that diagonally opposite cubies have the same number. Now any rotation of the cube corresponds to a permutation of the four numbers on the front face. Furthermore, any permutation of the numbers on the front face corresponds to a rotation. The four rotations in the z-axis coming out of the page are

1 2            3 1            4 3            2 4
3 4   4 3      4 2   2 4      2 1   1 2      1 3  3 1
      2 1            1 3            3 4           4 2

We can label the rotations by the effect on the front face. They are 1234, 3142, 4321, and 2413.

Three additional rotations about the x-axis (plus the identity rotation, with which we start) are

1 2            3 4            2 1            4 3
3 4   4 3      2 1   1 2      4 3   3 4      1 2  2 1
      2 1            4 3            1 2           3 4

In our more compact notation, the additional rotations are 3421, 2143, and 4312.

Three additional rotations about the y-axis are

1 2            2 3            3 4            4 1
3 4   4 3      4 1   1 4      1 2   2 1      2 3  3 2
      2 1            3 2            4 3           1 4

In compact notation, the three additional rotations are 2341, 3412, and 4123.

There are three rotations, including the identity, that fix the 1's diagonal:

1 2            1 4            1 3
3 4   4 3      2 3   3 2      4 2   2 4      
      2 1            4 1            3 1

with the new ones labeled 1423 and 1342.

There are three rotations, including the identity, that fix the 2's diagonal:

1 2            4 2            3 2
3 4   4 3      1 3   3 1      4 1   1 4      
      2 1            2 4            2 3

with the new ones labeled 4213 and 3241.

There are three rotations, including the identity, that fix the 3's diagonal:

1 2            2 4            4 1
3 4   4 3      3 1   1 3      3 2   2 3      
      2 1            4 2            1 4

with the new ones labeled 2431 and 4132.

There are three rotations, including the identity, that fix the 4's diagonal:

1 2            2 3            3 1
3 4   4 3      1 4   4 1      2 4   4 2      
      2 1            3 2            1 3

with the new ones labeled 2314 and 3124.

Finally, there are six edge flips plus the identity. The flips in the 1-2 edge are

1 2            2 1       
3 4   4 3      3 4   4 3  
      2 1            1 2

with the new one represented by 2134. Similarly there are edge flips in edges 1-3 (3214), 1-4 (4231), 2-3 (1324), 2-4 (1432), and 3-4 (1243).

Each of the 24 rotations corresponds to a distinct permutation of 1234, and since there are 24 permutations and 24 rotations, each distinct permutation corresponds to a rotation. We say that the rotation group of the cube, and the group S_4 of permutations of 1234, are isomorphic. (One way to think of this is that rotations of a cube have the action of permuting the diagonals of the cube, and the correspondence is 1-1.)

We could figure out the effect of each of the 24 rotations on your arrays, but that is tedious and error-prone. Instead, we will see that the rotation group is generated by just three rotations, the edge flips

R1=transpose numbers in position2 1 and 2, and leave numbers in positions 3 and 4 alone 
R2=transpose numbers in positions 2 and 3, and leave numbers in positions 1 and 4 alone
R3=transpose numbers in positions 3 and 4, and leave numbers in positions 1 and 2 alone

and their compositions. We'll throw in

R0=identity operation

for completeness. Using just those three generators, we can find a Hamiltonian circuit through the Cayley graph of the rotation group:

01 1234 apply R0 to starting position, i.e., do nothing
02 1243 apply R3 to previous
03 1423 apply R2 to previous
04 4123       R1
05 4213       R2
06 2413       R1
07 2143       R2
08 2134       R3
09 2314       R2
10 2341       R3
11 2431       R2
12 4231       R1
13 4321       R2
14 3421       R1
15 3241       R2
16 3214       R3
17 3124       R2
18 3142       R3
19 3412       R2
20 4312       R1
21 4132       R2
22 1432       R1
23 1342       R2
24 1324       R3

Note that each permutation (i.e., rotation of the cube) appears in this list exactly once, and that moving from one item in the list to the next is accomplished by a transposition of items in positions 1-2, 2-3, or 3-4, which corresponds to the edge flip rotations R1, R2, and R3. This Gray code for S_4 (and much else, which is unfortunately rather difficult to understand if you don't know about reflection groups and Coxeter diagrams) is presented in the famous paper Gray Codes for Reflection Groups by Conway, Sloane, and Wilks.

The Gray code is summarized by this sequence of 23 numbers:

gray = {3,2,1,2,1,2,3,2,3,2,1,2,1,2,3,2,3,2,1,2,1,2,3}

So we have vastly reduced the amount of work we have to do, from figuring the effect of 24 different rotations on a 2x2x2 polycube (and then a 3x3x3 polycube), to figuring the effect of just 3 different rotations on our cubes. Those three rotation functions could be stored in an array indexed by 0..3, and then applied like

nextConfiguration = currentConfiguration.Rotation[gray[i]]

(Note that we can even get all 48 reflection/rotations if we want, by following that sequence of rotations by a single reflection P (any reflection will do, so choose the simplest to implement), and then following that reflection by the gray sequence in reverse, which gives a Gray code for the full group of 48 reflection/rotations. (Building larger sequences by tacking the reverse of a sequence onto itself is one of the "big ideas" in the construction of Gray codes.))

We have come a long way without even considering the representation of the shapes that you want to rotate, but now we have to implement R1, R2, and R3 so we need to consider the data structure that represents the shapes, a 3 dimensional array. Let's consider the effect of R1, the 180 degree edge rotation on an axis through the midpoints of the 1-2 edges, on our 2x2x2 array:

(0,0,0) (1,0,0)                         (1,0,0) (0,0,0)
(0,1,0) (1,1,0)   (0,0,1) (1,0,1)  -->  (1,0,1) (0,0,1)   (1,1,0) (0,1,0)
                  (0,1,1) (1,1,1)                         (1,1,1) (0,1,1)

This tells us that our R1 operation can be implemented by

output(0,0,0) = input(1,0,0)
output(1,0,0) = input(0,0,0)
output(0,1,0) = input(1,0,1)
output(1,1,0) = input(0,0,1)
output(0,0,1) = input(1,1,0)
output(1,0,1) = input(0,1,0)
output(0,1,1) = input(1,1,1)
output(1,1,1) = input(0,1,1)

That defines the operation R1 in the 2x2x2 case. It should be clear how to define R2 and R3 in the 2x2x2 case, and also how to define those operations in the 3x3x3 case.

Let me finish by saying that the definition of the Ri operators is tedious and error prone, so we don't want to do it too much if we can help it. We can get away with defining only two operators, R1 (already defined above) and F, the front face rotation:

(0,0,0) (1,0,0)                         (0,1,0) (0,0,0)
(0,1,0) (1,1,0)   (0,0,1) (1,0,1)  -->  (1,1,0) (1,0,0)   (0,1,1) (0,0,1)
                  (0,1,1) (1,1,1)                         (1,1,1) (1,0,1)

Then we can represent R2 = F.F.F.R1.F (where . means "followed by"), and R3 = F.F.R1.F.F. (These are "conjugates" of R1.)

For the 3x3x3 case, it is some work to find the index tables by hand, so I have written a routine to do it, and then I found all 24 rotations of an alphabet cube. See code below. I'm sure it can be made much briefer by a Python expert. This is my first program in Python.

The idea is to rotate not the contents of the 3x3x3 array, but its indices.

import numpy as np

# polycube that we want to rotate
A = np.array([[['a', 'b', 'c'],
               ['d', 'e', 'f'],
               ['g', 'h', 'i']],

              [['j', 'k', 'l'],
               ['m', '+', 'n'],
               ['o', 'p', 'q']],

              [['r', 's', 't'],
               ['u', 'v', 'w'],
               ['x', 'y', 'z']]])

# I just do np.copy here because I don't know how to declare an array
T = np.copy(A)

# matrix representing top front edge rotation (12)
R1 = np.array([[-1, 0, 0],
               [ 0, 0, 1],
               [ 0, 1, 0]])

# matrix representing right front edge rotation (23)
R2 = np.array([[ 0, 0, 1],
               [ 0,-1, 0],
               [ 1, 0, 0]])

# matrix representing bottom front edge rotation (34)
R3 = np.array([[-1, 0, 0],
               [ 0, 0,-1],
               [ 0,-1, 0]])

# Gray code gives Hamiltonican circuit through cube rotation group
gray = [3,2,1,2,1,2,3,2,3,2,1,2,1,2,3,2,3,2,1,2,1,2,3]

# trick for speed: we only need to apply each rotation matrix once
# to this polycube of indices, then we use the result as a table of
# pointers to new positions of polycube after rotation
Idx0 = np.array([[[[0,0,0], [1,0,0], [2,0,0]],
                  [[0,1,0], [1,1,0], [2,1,0]],
                  [[0,2,0], [1,2,0], [2,2,0]]],

                 [[[0,0,1], [1,0,1], [2,0,1]],
                  [[0,1,1], [1,1,1], [2,1,1]],
                  [[0,2,1], [1,2,1], [2,2,1]]],

                 [[[0,0,2], [1,0,2], [2,0,2]],
                  [[0,1,2], [1,1,2], [2,1,2]],
                  [[0,2,2], [1,2,2], [2,2,2]]]])

# Again, copy array because I don't know how to declare
Idx1 = np.copy(Idx0)
Idx2 = np.copy(Idx0)
Idx3 = np.copy(Idx0)

# We have to subtract [1,1,1] then add it again to move the center of the
# indexes to [0,0,0]
for i in [0,1,2]:
    for j in [0,1,2]:
        for k in [0,1,2]:
            Idx1[i,j,k] = np.matmul(np.array([i,j,k])-np.array([1,1,1]),R1) + \
            np.array([1,1,1])

for i in [0,1,2]:
    for j in [0,1,2]:
        for k in [0,1,2]:
            Idx2[i,j,k] = np.matmul(np.array([i,j,k])-np.array([1,1,1]),R2) + \
            np.array([1,1,1])

for i in [0,1,2]:
    for j in [0,1,2]:
        for k in [0,1,2]:
            Idx3[i,j,k] = np.matmul(np.array([i,j,k])-np.array([1,1,1]),R3) + \
            np.array([1,1,1])

# note that we have done only 81 vector*matrix multiplications. Now we don't
# have to do any more; we just look up the result in the index tables Idx[123]

print(A)

# I'm sure there must be a more efficient way to move T (temp) into A
for g in gray:
    if g == 1:
        for i in [0,1,2]:
            for j in [0,1,2]:
                for k in [0,1,2]:
                    T[i,j,k] = A[Idx1[i,j,k,0],Idx1[i,j,k,1],Idx1[i,j,k,2]]
        A = np.copy(T)
    elif g == 2:
        for i in [0,1,2]:
            for j in [0,1,2]:
                for k in [0,1,2]:
                    T[i,j,k] = A[Idx2[i,j,k,0],Idx2[i,j,k,1],Idx2[i,j,k,2]]
        A = np.copy(T)
    elif g == 3:
        for i in [0,1,2]:
            for j in [0,1,2]:
                for k in [0,1,2]:
                    T[i,j,k] = A[Idx3[i,j,k,0],Idx3[i,j,k,1],Idx3[i,j,k,2]]
        A = np.copy(T)
    print("-----------------")
    print(A)

Here is the output from the program

[[['a' 'b' 'c']
  ['d' 'e' 'f']
  ['g' 'h' 'i']]

 [['j' 'k' 'l']
  ['m' '+' 'n']
  ['o' 'p' 'q']]

 [['r' 's' 't']
  ['u' 'v' 'w']
  ['x' 'y' 'z']]]
-----------------
[[['z' 'w' 't']
  ['y' 'v' 's']
  ['x' 'u' 'r']]

 [['q' 'n' 'l']
  ['p' '+' 'k']
  ['o' 'm' 'j']]

 [['i' 'f' 'c']
  ['h' 'e' 'b']
  ['g' 'd' 'a']]]
-----------------
[[['x' 'o' 'g']
  ['y' 'p' 'h']
  ['z' 'q' 'i']]

 [['u' 'm' 'd']
  ['v' '+' 'e']
  ['w' 'n' 'f']]

 [['r' 'j' 'a']
  ['s' 'k' 'b']
  ['t' 'l' 'c']]]
-----------------
[[['r' 's' 't']
  ['j' 'k' 'l']
  ['a' 'b' 'c']]

 [['u' 'v' 'w']
  ['m' '+' 'n']
  ['d' 'e' 'f']]

 [['x' 'y' 'z']
  ['o' 'p' 'q']
  ['g' 'h' 'i']]]
-----------------
[[['a' 'd' 'g']
  ['j' 'm' 'o']
  ['r' 'u' 'x']]

 [['b' 'e' 'h']
  ['k' '+' 'p']
  ['s' 'v' 'y']]

 [['c' 'f' 'i']
  ['l' 'n' 'q']
  ['t' 'w' 'z']]]
-----------------
[[['c' 'l' 't']
  ['f' 'n' 'w']
  ['i' 'q' 'z']]

 [['b' 'k' 's']
  ['e' '+' 'v']
  ['h' 'p' 'y']]

 [['a' 'j' 'r']
  ['d' 'm' 'u']
  ['g' 'o' 'x']]]
-----------------
[[['i' 'h' 'g']
  ['f' 'e' 'd']
  ['c' 'b' 'a']]

 [['q' 'p' 'o']
  ['n' '+' 'm']
  ['l' 'k' 'j']]

 [['z' 'y' 'x']
  ['w' 'v' 'u']
  ['t' 's' 'r']]]
-----------------
[[['r' 'u' 'x']
  ['s' 'v' 'y']
  ['t' 'w' 'z']]

 [['j' 'm' 'o']
  ['k' '+' 'p']
  ['l' 'n' 'q']]

 [['a' 'd' 'g']
  ['b' 'e' 'h']
  ['c' 'f' 'i']]]
-----------------
[[['t' 'l' 'c']
  ['s' 'k' 'b']
  ['r' 'j' 'a']]

 [['w' 'n' 'f']
  ['v' '+' 'e']
  ['u' 'm' 'd']]

 [['z' 'q' 'i']
  ['y' 'p' 'h']
  ['x' 'o' 'g']]]
-----------------
[[['g' 'h' 'i']
  ['o' 'p' 'q']
  ['x' 'y' 'z']]

 [['d' 'e' 'f']
  ['m' '+' 'n']
  ['u' 'v' 'w']]

 [['a' 'b' 'c']
  ['j' 'k' 'l']
  ['r' 's' 't']]]
-----------------
[[['x' 'u' 'r']
  ['o' 'm' 'j']
  ['g' 'd' 'a']]

 [['y' 'v' 's']
  ['p' '+' 'k']
  ['h' 'e' 'b']]

 [['z' 'w' 't']
  ['q' 'n' 'l']
  ['i' 'f' 'c']]]
-----------------
[[['z' 'q' 'i']
  ['w' 'n' 'f']
  ['t' 'l' 'c']]

 [['y' 'p' 'h']
  ['v' '+' 'e']
  ['s' 'k' 'b']]

 [['x' 'o' 'g']
  ['u' 'm' 'd']
  ['r' 'j' 'a']]]
-----------------
[[['t' 's' 'r']
  ['w' 'v' 'u']
  ['z' 'y' 'x']]

 [['l' 'k' 'j']
  ['n' '+' 'm']
  ['q' 'p' 'o']]

 [['c' 'b' 'a']
  ['f' 'e' 'd']
  ['i' 'h' 'g']]]
-----------------
[[['c' 'f' 'i']
  ['b' 'e' 'h']
  ['a' 'd' 'g']]

 [['l' 'n' 'q']
  ['k' '+' 'p']
  ['j' 'm' 'o']]

 [['t' 'w' 'z']
  ['s' 'v' 'y']
  ['r' 'u' 'x']]]
-----------------
[[['a' 'j' 'r']
  ['b' 'k' 's']
  ['c' 'l' 't']]

 [['d' 'm' 'u']
  ['e' '+' 'v']
  ['f' 'n' 'w']]

 [['g' 'o' 'x']
  ['h' 'p' 'y']
  ['i' 'q' 'z']]]
-----------------
[[['z' 'y' 'x']
  ['q' 'p' 'o']
  ['i' 'h' 'g']]

 [['w' 'v' 'u']
  ['n' '+' 'm']
  ['f' 'e' 'd']]

 [['t' 's' 'r']
  ['l' 'k' 'j']
  ['c' 'b' 'a']]]
-----------------
[[['i' 'f' 'c']
  ['q' 'n' 'l']
  ['z' 'w' 't']]

 [['h' 'e' 'b']
  ['p' '+' 'k']
  ['y' 'v' 's']]

 [['g' 'd' 'a']
  ['o' 'm' 'j']
  ['x' 'u' 'r']]]
-----------------
[[['r' 'j' 'a']
  ['u' 'm' 'd']
  ['x' 'o' 'g']]

 [['s' 'k' 'b']
  ['v' '+' 'e']
  ['y' 'p' 'h']]

 [['t' 'l' 'c']
  ['w' 'n' 'f']
  ['z' 'q' 'i']]]
-----------------
[[['x' 'y' 'z']
  ['u' 'v' 'w']
  ['r' 's' 't']]

 [['o' 'p' 'q']
  ['m' '+' 'n']
  ['j' 'k' 'l']]

 [['g' 'h' 'i']
  ['d' 'e' 'f']
  ['a' 'b' 'c']]]
-----------------
[[['g' 'd' 'a']
  ['h' 'e' 'b']
  ['i' 'f' 'c']]

 [['o' 'm' 'j']
  ['p' '+' 'k']
  ['q' 'n' 'l']]

 [['x' 'u' 'r']
  ['y' 'v' 's']
  ['z' 'w' 't']]]
-----------------
[[['i' 'q' 'z']
  ['h' 'p' 'y']
  ['g' 'o' 'x']]

 [['f' 'n' 'w']
  ['e' '+' 'v']
  ['d' 'm' 'u']]

 [['c' 'l' 't']
  ['b' 'k' 's']
  ['a' 'j' 'r']]]
-----------------
[[['c' 'b' 'a']
  ['l' 'k' 'j']
  ['t' 's' 'r']]

 [['f' 'e' 'd']
  ['n' '+' 'm']
  ['w' 'v' 'u']]

 [['i' 'h' 'g']
  ['q' 'p' 'o']
  ['z' 'y' 'x']]]
-----------------
[[['t' 'w' 'z']
  ['l' 'n' 'q']
  ['c' 'f' 'i']]

 [['s' 'v' 'y']
  ['k' '+' 'p']
  ['b' 'e' 'h']]

 [['r' 'u' 'x']
  ['j' 'm' 'o']
  ['a' 'd' 'g']]]
-----------------
[[['g' 'o' 'x']
  ['d' 'm' 'u']
  ['a' 'j' 'r']]

 [['h' 'p' 'y']
  ['e' '+' 'v']
  ['b' 'k' 's']]

 [['i' 'q' 'z']
  ['f' 'n' 'w']
  ['c' 'l' 't']]]
Mouthy answered 15/8, 2018 at 22:8 Comment(0)
R
2

just traveling salesman it

def rotate24(tensor):     
  ax = [(0,2),(0,2),(0,2),(1,2),(0,2),(0,2)] 
  for i in range(6): #go over each face once
      tensor = rot90(tensor,axes=ax[i])
      for j in range(4): #on each face, rotate 4 times
          tensor = rot90(tensor,axes=(0,1))
          yield tensor
Risser answered 23/11, 2021 at 2:52 Comment(2)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Lotetgaronne
I am pretty sure that this is functionally the same as the accepted answer, just written more tersely (by noticing the commonalities in the various "face" rotations, and inlining the rotations4 part).Finn
C
1

First let's determine the 48 isometries, as matrices that permute the basis vectors, possibly flipping some signs:

def isometries():
    basis = numpy.eye(3)
    for ix in range(3):
      for iy in range(3):
        if iy == ix:
          continue
        for iz in range(3):
          if iz == ix or iz == iy:
            continue

          for sx in range(-1, 2, 2): # -1, 1
            for sy in range(-1, 2, 2):
              for sz in range(-1, 2, 2):
                yield numpy.array([sx * basis[ix], sy * basis[iy], sz * basis[iz]])

Then let's filter for the rotations, which are the isometries of determinant 1:

def rotations():
    return filter(lambda x: numpy.linalg.det(x) > 0, isometries())

Finally, we can apply such a rotation to a polycube by shifting the array indices such that the inner point is the origin, and multiplying the index vector by the rotation matrix:

def rotate(rotation, shape):
    shift = numpy.array([1,1,1])
    res = numpy.zeros(27).reshape(3,3,3)
    it = numpy.nditer(shape, flags=['multi_index'])
    while not it.finished:
        v = numpy.array(it.multi_index) - shift
        w = rotation.dot(v)
        dest = tuple(w + shift)
        res[dest] = it[0]
        it.iternext()
    return res

For example:

>>> rotate(list(rotations())[5], polycube)
array([[[ 0.,  0.,  0.],
        [ 0.,  0.,  0.],
        [ 0.,  0.,  0.]],

       [[ 0.,  1.,  1.],
        [ 0.,  0.,  0.],
        [ 0.,  0.,  0.]],

       [[ 1.,  1.,  0.],
        [ 1.,  1.,  0.],
        [ 0.,  0.,  0.]]])
Condensate answered 15/8, 2018 at 18:27 Comment(0)
C
1

I encountered a similar problem when trying to randomly rotate a 3D one hot encoded numpy array, just before feeding into my neural network. I will demonstrate here for a 3D array, but this also works with a 4th dimension (when using OHE).

>>> m = np.reshape(np.arange(8),(2,2,2))
>>> m
array([[[0, 1],
        [2, 3]],

       [[4, 5],
        [6, 7]]])

Next we rotate the array 3 times, each time in a different direction. Repeat 24,000 times to see distribution (expecting 1000 counts for each unique rotation):

>>> rot_list = []
>>> for _ in range(24000):
        a = np.rot90(m,np.random.randint(0,4),axes=(0,np.random.randint(1,3)))
        b = np.rot90(a,np.random.randint(0,4),axes=(np.random.randint(1,3),0))
        c = np.rot90(b,np.random.randint(0,4),axes=(1,2)) # or axes=(2,1)
        rot_list.append(c)
>>> unique_rotation_matrices, counts = np.unique(np.asarray(rot_list),axis=0, return_counts=True)
>>> len(unique_rotation_matrices)
24

So we see we get all the 24 possible rotations. Let's look at their distribution:

>>> counts
[1062  946  917  982 1096  978 1153  936  939  907 1183  932  958  932 1122
  926 1115  954  933  932 1135  924 1138  900]

Distribution looks quite even, but rerunning this a number of times reveals it is slightly biased.

Cini answered 13/5, 2019 at 11:43 Comment(0)
U
1

The other answers are all useful for various purposes. I'd like to share what I came up with, as it's a very small amount of code.

def symmetries(A, N=823316240728388):
    if N!=766773 and N!=11980: yield A
    if N: yield from symmetries(np.rot90(A,N|1,(N&1,2)),N>>2)

The following test code shows that exactly 24 unique symmetries are generated:

>>> A = np.random.randint(100,size=(5,5,5))
>>> len(set(map(str,symmetries(A))))
24
>>> len(list(symmetries(A)))
24

As one can see, the recursion was designed to "walk" through the different rotations of a cube, applying a rotation about a single axis per step. Some answers to this question refer to Gray codes and Hamiltonian circuits, which can be used to determine how to walk through all rotations without revisiting any. I wasn't really sure how to interpret all of it at first, so I picked up a Rubik's cube to visualize how the faces of the cube permute as it rotates. Fortunately, I was able to find a certain path through all the rotations, although two of the rotations are duplicates. So I tossed out the duplicates.

Expanding the recursive steps of the short function, the arguments to np.rot90() end up looking like this:

def symmetries(A):
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,1,(0,2))
    #yield A # DUPLICATE
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,1,(1,2))
    yield A
    A = np.rot90(A,3,(1,2))
    #yield A # DUPLICATE
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,3,(1,2))
    yield A
    A = np.rot90(A,1,(0,2))
    yield A
    A = np.rot90(A,3,(1,2))
    yield A
    A = np.rot90(A,3,(0,2))
    yield A
    A = np.rot90(A,3,(1,2))
    yield A
    A = np.rot90(A,3,(0,2))
    yield A

Now, how I reduced a 64-bit integer with bitwise operations over 26 steps to generate those arguments to np.rot90() is less relevant to the problem. Briefly, the OR and AND bitwise operators deal with the rightmost bit of N, and np.rot90() already deals with k (times to rotate) in mod 4, and finally N is shifted two bits at a time because k is in mod 4.

In this answer, I have provided a very short solution that is not immediately easy to understand, but I've also provided a much longer version of the same solution that is easier to follow.

Urbanite answered 27/5, 2022 at 12:19 Comment(0)
A
1

A more programatic answer built off of Colonel Panic's.

Reverse transforms will get you back to original polycube. Works for 2D square as well.

#(0, 1), (0, 2), (1, 2) for 3D/cube, (0, 1) for 2D/square
axis_pairs = list(itertools.combinations(range(len(polycube.shape)), 2))


transform = [] #will contain all 24 symmetrical states 
reverseIt = [] #will contains 24 copies of polycube (demonstrating reverse transform)
for i in (0, 1, 2, 3): #initial rotations around axis A
    #rotate by 0 degrees once, not 3 times
    transform.append(np.rot90(polycube, i, axis_pairs[0])) 
    reverseIt.append(np.rot90(transform[-1], -i, axis_pairs[0]))
    
    #rotate around other Axis B & C at each rotation position on Axis A
    for axes in axis_pairs[1:]: 
        for n in range(1, 4): #90, 180, 270 degree rotations
            if n == 2 and axes == axis_pairs[1]: #avoid duplicating 180s
                continue

            transform.append(np.rot90(np.rot90(polycube, i, axis_pairs[0]), n, axes))
            reverseIt.append(np.rot90(np.rot90(transform[-1], -n, axes), -i, axis_pairs[0]))
Aggappe answered 10/10, 2022 at 23:26 Comment(2)
What do you mean by "get back to"? What is the algorithm here, and how is it different?Finn
Edited replacing polycube with last transformation of polycube. In my actual code I'm creating lambda functions and it is useful to be able to reverse the original transformation (whatever it was), so I thought I'd add how to reverse each rotation to get back to the original cube. This approach is different because it loops through each rotational scheme, making it easier to add things to each scheme (such as these reverse transformations)Aggappe

© 2022 - 2024 — McMap. All rights reserved.