Split an image array into tiles using numpy | Towards Data Science

There are plenty of situations where you would need to break down a large image in tiles (e.g. as part of a ML preprocessing pipeline) for batch processing. This article aims to explore lower-level ways of doing just that, in Python.
One of the most common ways of creating a stack of image tiles I have seen around is with nested for-loops, as in instantiating a new array with the desired dimensions and populating it within those loops. However, this method is particularly inefficient (especially) in Python and does not scale well, at all, with large datasets (nor smaller tiles, for that matter).
A nested for-loops approach would look something like the snippet below:
# Nested for-loops method.# Creating a stack of 2 x 2 tiles out of a 4 x 4# RGB image with channels on the last axis.import numpy as npimage = np.random.randn(4, 4, 3)tiles = np.zeros((4, 2, 2, 3))c = 0for i in range(0, image.shape[1], 2):      for j in range(0, image.shape[2], 2):        tiles[c] = image[i:i+2, j:j+2, :]        c += 1
As the iterations grow (image size over tile size ratio), not to mention iterating over large number of images (3rd for-loop), this can occupy resources and quickly create a bottleneck in your pipeline.
What if we could just use a different view to the same chunk of memory and present it as tiles? Let’s talk a bit about arrays.
Numpy arrays, and arrays in general, are laid out in memory as contiguous chunks of bytes. That contiguous piece of memory can then be viewed in different shapes using strides.
On the memory level of arrays, the stride represents the number of bytes you need to jump forward in order to get to the next element contained in the array. This depends on the byte-length of the data types contained in the array.
e.g. A 1-dimensional array of 16-bit integers (2 bytes length) will have a stride of 2 bytes.
Something else to note is that arrays have to contain elements of the same data-type, so as to guarantee equal byte-length intervals. There are ways of representing different data types in a single array, of course, but that is outside the scope of this article.
So, what are the elements in an array? Just bytes laid out in order. Elements can vary in length, depending on the number of bytes needed to represent each of them.
An element, then, is an aggregation of bytes, which in turn are an aggregation of bits, 8 bits in particular. All living within the same physical space. So, is it possible to aggregate an array further into larger chunks? Yes it is. That is exactly what array dimensions do.
In order to organize your array elements into rows and create a second dimension, all you need to do is to define a second stride to point to the beginning of each row. On that dimension, then, the individual elements are the rows. This can also be thought of as splitting an array into multiple equal-sized sub-arrays.
In the array dimension sizes returned by numpy.shape(), the above depicted example would be presented as a 2-dimensional shape (3,3). The order of the dimensions returned always start (left-most) from the highest level going towards the lowest one (right-most). This means the size 3 of the first dimension in the tuple indicates the number of rows, while the size 3 of the second dimension indicates the number of individual elements per row. As we will see later on, there are numpy methods that can change the dimension hierarchy, but the above generally stands for array views formed using numpy.reshape() as well as newly instantiated arrays in numpy.
Note 1: The dimension order above is referring to the order accessed in the tuple returned by numpy.shape(), with the first dimension being at index position 0 (shape[0]) and the second dimension being at index position 1 (shape[1]).
Note 2: There are multiple layouts (row/column major), but this article only discusses row-major a.k.a. C order, as it is the one I am most familiar with. More information on layouts here.
For our purposes, we will give a generic definition of a dimension size as follows:
Using an 4-dimensional array of shape (m, n, i, j) as an example, the 1st (highest & left-most) dimension holds m number of elements organized in shapes (n, i, j). The 2nd dimension, holds n elements of shape (i, j), while the 3rd holds i elements of sizes ( j ). The 4th and last dimension is simply a single row of j elements. Then:
Dimension size: The number of elements described by the subsequent (lower) dimensions.
Now, a 2D image represented as a numpy array will have shape (m,n), where m would indicate the image height in pixels, while n would indicate the image width in pixels. As an example, let’s take a 6 by 4, 8-bit grayscale image array and aim to divide it in 2 by 2 tiles by creating a new memory view using strides. Remember, the elements have to be of equal length, hence, both array dimensions have to be divisible by 2.
Our image can then be thought of as being organized in 6 rows of elements of 4. The visualization of our example would look something like the following:
So, how do we get there?
Let’s take it one step at a time. Seeing that we need 6 tiles all together, organized in 3 rows and 2 columns, lets first try to split our current rows in 2 columns. Our current shape is (6, 4), thus, we need to halve the last dimension and add a higher dimension of 2, representing the columns that we want to form. Our target 3-dimensional shape for this step, then, is (2,6,2): 2 columns by 6 rows by 2 array elements in each row. Now that we know the shape we need. We can go ahead to figure out the strides.
Let’s observe the image below. The strides for our 2D image were (4,1). Stride 1 of the lowest dimension has to remain intact, as messing with it would make us lose the spatial relationship at the pixel level (e.g. increasing it would mean to skip pixels). Stride 4 of the rows dimension will also stay constant, since the number of desired rows remain 6. Generally speaking, if you changed any of those two strides, you would end up distorting the image. Then, all we need to do is figure out the stride for our highest dimension of size 2, which represents the new higher level columns we are defining. Having in mind that we split our original 4 per row elements in half so they can be included in separate columns, gives us a hint.
Check figure 2.5. All pixels in our array are numbered in the order they are laid out in memory (C order). Before, every 4 elements we had to change a row, a rule that still stands. We want to include an additional rule: In each row, change columns every 2 elements. So the stride in our highest dimension is 2 x element_byte_size. In our case of 8-bit integers, the stride is 2x1 byte = 2.
Let’s open up a terminal and test it in code:
In numpy, you can manipulate the strides of an array using numpy.lib.stride_tricks.as_strided(). We need to specify the array we want to manipulate, the shape we want it in, and the stride we want for each dimension:
We got our two columns with the elements we wanted! We can continue now to divide each column in 3 groups of 2 rows, giving us a total of 6 tiles.
Our current shape is (2, 6, 2), with 6 being our number of rows in the image. Similarly to the previous step where we needed our 3rd dimension elements (the 2 columns) to hold rows with just 2 array elements in each (1st dimension elements) row, we now need our 4th dimension elements in the top hierarchy to hold only 2 image rows each. So, we need to decrease the “image rows” dimension from 6 down to 2. Our target shape then will be (3, 2, 2, 2).
In summary, the lower level elements remain 2, our rows decrease to 2, the columns at the 3rd dimension remain 2, and we now add a 4th dimension to split those columns in 3 parts.
Our definition of this 4-dimensional array dictates that the highest dimension will now change elements every 2 image rows, instead of 1. So, in conclusion, the new stride has to be twice that of an image row. Then, the stride for our new dimension will be 2 x row_stride = 2x4 = 8 bytes.
Testing it in code:
And we’re done! All 6 tiles formed. Now we can merge the 2 highest dimensions using numpy.reshape() to make our array in the 3-dimensional shape of (number_of_tiles, tile_height, tile_width) if we wanted to, since this is the basic format for image batches. The reason we needed 4 dimensions instead of 3 in the first place, is because the location of every tile in memory cannot be represented by a single stride. Our image has 2 dimensions after all.
What we just implemented to split a 6 x 4 image of 8-bit integers in 2 x 2 tiles using strides, can be generalized to any C ordered 2D image like this:
As it turns out, we can have a similar formula for specifically formatted multi-channel images as well. Specific in a way that, they are contiguous C ordered arrays with channels at the lowest dimension (having same pixel RGB values next to one another), otherwise the strides will differ in each occasion. Some libraries load images directly like that, some don’t, but it’s not particularly hard to reshape them.
GDAL, for example, will load images with channels as the highest (left-most) dimension.
When our multi-channel array dimensions are formatted that way, the lowest dimension will be our per-channel values for each pixel, which we can navigate by doing only a single byte-length step. Since, instead of 1 single pixel value, we will have n pixel values for n channels, all we need to do is to multiply all other strides by the number of channels. Summarizing, everything remains the same, but we now have multiple values per pixel to jump, 1 for each channel:
Not a particularly clean approach, but we will see in the next section that there is a cleaner way. Dealing with strides is a very sensitive procedure and requires extreme caution. Every time you would have to verify contiguity and take necessary steps.
Luckily enough, numpy provides a few higher level methods to save us most of the trouble.
Numpy.reshape() is the standard, most common way of manipulating an array shape in numpy. However, if we simply stated in our previous 6 x 4 image example that we wanted an array of shape (3, 2, 2, 2) it would not have worked.
That happens because numpy.reshape(), by default, tries to maintain contiguity of subsequent values. It means that sequential indexes of your array, used through your program, will continue to be in adjacent blocks of memory. Unfortunately, in order to get our result we need to break contiguity. Although, we can make it contiguous again after breaking it in tiles, for performance reasons.
<< Strides differ from our previous solution.
How does it work? It basically takes a dimension (at any level), breaks it down in equal adjacent pieces and stacks them together at a higher level. In our case, it does something like the following:
Instead, what we want is this:
So, how do we fix it?
As a simple comparison of the two arrays’ strides hints, all we need to do is swap strides between axis 1 and axis 2 (1st and 2nd dimensions). Specifically, the strides we need are (8, 2, 4, 1), but numpy.reshape() returns an array with strides (8, 4, 2, 1). Numpy arrays expose a method just for that, called swapaxes:
The numpy.reshape() implementation, then, can be generalized for any 2D or 3D image with channels in the last dimension as follows:
Let’s compare executions!
We’ll use an RGB JPEG of this cool looking fellow right here:
After we explore our image’s dimensions, we can see they are divisible by 25 and 12 accordingly, so let’s just use these for our tile-size.
We’ll also use this decorator to time each execution:
Our for-loop implementation looks like this:
The strides implementation is:
And the numpy.reshape() implementation is:
We will load our image as a numpy array, set tile dimensions from command line, run each function and check for equality of arrays.
The moment of truth:
The numpy.reshape() approach appears to be the cleanest as well as the most efficient. Additionally, it is apparent that the for-loop implementation can’t really compare to the memory view methods. It’s also noteworthy that memory view performance is barely affected by the “amount” of tiles that must be generated, while the for-loop’s time of execution would blow up with an increasing workload.
Final note: There’s an additional step to bringing our tiles in a batch processing form as expected by most frameworks. To merge our tiles in a single index all we have to do is tiled_arr.reshape(-1, *tile_dimensions) and use numpy.moveaxis() to relocate the channels dimension where appropriate. Usually as (n_tiles,n_channels,t_height,t_width).
Thank you for reading!
I hope this article was insightful and you can implement what was demonstrated here in your own computer vision workflows!
Illustrations and formulas were created in LibreOffice.
Code: You can find the testing script here.
Feel free to contact me with any recommendations or corrections at iosif.doundoulakis@outlook.com
You can connect with me on LinkedIn.
Sources:
[1]: Memory layout of multi-dimensional arrays
[2]: An Illustrated Guide to Shapes and Strides (Part 1)
[3]: Numpy documentation: numpy.lib.stride_tricks.as_strided()
[4]: Numpy documentation: numpy.reshape()