Monday, March 30th, 2026¶

Last class, we worked on creating tartan pattern images.

In [1]:
import numpy as np
import matplotlib.pyplot as plt

Project 3: Tartans (cont.)¶

We've been working with the following example tartan recipe.

Pattern :

B14 K6 B6 K6 B6 K32 OG32

where the colors B, K, and OG are given by the RGB triples:

B : [52, 80, 100]
K : [16, 16, 16]
OG : [92, 100, 40]

In particular, we worked to create code that would take lists widths and colors and use them to produce

  • a vertical_stripes RGB image array,
  • a horizontal_stripes RGB image array,
  • a checkerboard_tartan RGB image array, and (possibly)
  • an authentic_tartan RGB image array.

Last week, we manually generated the widths and colors lists. It would be convenient if we could use Python to automatically generate these lists directly from the tartan recipe.

String processing¶

In generating our tartan images, we interpreted the tartan recipe by defining lists for the widths and colors. It would be nice if we could use Python to generate the list of widths and colors automatically. In other words, it would be convenient if we could define a string like tartan_recipe = 'B14 K6 B6 K6 B6 K32 OG32' and process this string to pull out these two lists.

In [2]:
tartan_recipe = 'B14 K6 B6 K6 B6 K32 OG32'

We have already seen some useful tools for working with strings in Python. For example:

  • The .join method allows us to concatenate a list of strings using a given string as a separator.
  • The .format method allows us to plug values into a string template.

Another helpful tool is the .split method, which allows us to separate a string into a list of substrings. The syntax is something like:

some_string.split(some_separator)

This will separate the some_string string into a list of substrings, where each separation occurs whenever the some_separator string is found within some_string.

In [4]:
some_string = 'Hello, this is a fine Monday.'

some_string.split(' ')
Out[4]:
['Hello,', 'this', 'is', 'a', 'fine', 'Monday.']
In [5]:
some_string.split('s')
Out[5]:
['Hello, thi', ' i', ' a fine Monday.']

Note: If we call the .split method with no input variable, by default the string will be split anywhere there is white space (e.g. spaces, tabs, line breaks, etc.).

In [6]:
some_string = 'This string has    some extra     spaces.'
some_string.split(' ')
Out[6]:
['This',
 'string',
 'has',
 '',
 '',
 '',
 'some',
 'extra',
 '',
 '',
 '',
 '',
 'spaces.']
In [7]:
some_string.split()
Out[7]:
['This', 'string', 'has', 'some', 'extra', 'spaces.']

We can then apply the .split method to the tartan_recipe string to get a sequence of substrings, each of which represents a particular stripe.

In [10]:
tartan_stripe_recipes = tartan_recipe.split()
print(tartan_stripe_recipes)
['B14', 'K6', 'B6', 'K6', 'B6', 'K32', 'OG32']

Can we iterate through each of these stripe recipes and pick out the color and width?

In [13]:
for tartan_stripe_recipe in tartan_stripe_recipes:
    print(tartan_stripe_recipe)
B14
K6
B6
K6
B6
K32
OG32

Problem: We can't just take the first character from stripe_recipe as the color code, because some color codes have two characters (e.g. 'OG').

It would be very helpful if we could pick out which parts of a string are letters and which parts of a string are numbers. The following methods (called on a string) can help with this:

  • The .isalpha method will return True if the string contains only alphabetic characters (i.e. letters).
  • The .isnumeric method will return True if the string contains only numeric characters (i.e. numbers).
In [14]:
'123456'.isnumeric()
Out[14]:
True
In [16]:
'123a456b'.isnumeric()
Out[16]:
False
In [18]:
'FiveSixSeven'.isalpha()
Out[18]:
True
In [19]:
'Five6Seven'.isalpha()
Out[19]:
False

Exercise: Write a function that takes in a tartan recipe string and returns a list of color codes (e.g. ['B', 'K', 'B', ...]) and widths.

In [28]:
color_codes = []
widths = []

for tartan_stripe_recipe in tartan_stripe_recipes:
    color_code = ''
    width_str = ''
    for char in tartan_stripe_recipe:
        if char.isalpha():
            color_code += char
        if char.isnumeric():
            width_str += char
    color_codes.append(color_code)
    widths.append(int(width_str))

print(color_codes)
print(widths)
['B', 'K', 'B', 'K', 'B', 'K', 'OG']
[14, 6, 6, 6, 6, 32, 32]

Exercise: Write a function that takes in a tartan recipe string and returns a list of color codes (e.g. ['B', 'K', 'B', ...]) and widths using list comprehensions.

In [29]:
color_codes = []
widths = []

for tartan_stripe_recipe in tartan_stripe_recipes:
    color_code = []
    width_str = []
    for char in tartan_stripe_recipe:
        if char.isalpha():
            color_code.append(char)
        if char.isnumeric():
            width_str.append(char)
    color_codes.append(''.join(color_code))
    widths.append(int(''.join(width_str)))

print(color_codes)
print(widths)
['B', 'K', 'B', 'K', 'B', 'K', 'OG']
[14, 6, 6, 6, 6, 32, 32]
In [ ]:
 

From the above, we can pick out the color code strings from the tartan recipe. On the other hand, in order to generate our vertical_stripes array, we need to get the corresponding RGB triples (not just the color code strings). How can accomplish this?

In [30]:
color_codes
Out[30]:
['B', 'K', 'B', 'K', 'B', 'K', 'OG']
In [32]:
B = [52, 80, 100]
K = [16, 16, 16]
OG = [92, 100, 40]

colors = []
for color_code in color_codes:
    if color_code == 'B':
        colors.append(B)
    elif color_code == 'K':
        colors.append(K)
    elif color_code == 'OG':
        colors.append(OG)

print(colors)
[[52, 80, 100], [16, 16, 16], [52, 80, 100], [16, 16, 16], [52, 80, 100], [16, 16, 16], [92, 100, 40]]

It would be convenient if we could define a mapping that maps each color code to its respective RGB triple.

Dictionaries¶

Let's consider a list, my_list.

In [36]:
my_list = ['Hello', 'World', 'Goodbye', 7]

We can think of my_list as a mapping between indices 0, 1, 2, 3, and values 'Hello', 'World', 'Goodbye', 7. That is, we have the following mappings:

  • index 0 maps to value 'Hello',
  • index 1 maps to value 'World',
  • index 2 maps to value 'Goodbye',
  • index 3 maps to value 7.

To retrieve values of a list, we can "plug in" an index using square brackets []:

In [37]:
my_list[0]
Out[37]:
'Hello'
In [38]:
my_list[2]
Out[38]:
'Goodbye'

A Python dictionary works very similarly, except that instead of a map from indices to values, we have a map from keys to values. These keys do not need to be integers 0, 1, 2, ..., but are more flexible. Dictionary keys can include integers, floats, strings, tuples, functions, and many other types (anything that is immutable).

To define a dictionary, we can use curly braces {} containing a comma-separated sequence of <key>:<value> mappings. For example, suppose we want to construct a dictionary with the following mappings:

  • key 5 maps to value 'Hello!',
  • key (0,255,0) maps to 'Green',
  • key 'Good' maps to value 10,
  • key 'Bad' maps to value 0,
  • key 'Red' maps to value (255,0,0).
In [39]:
my_dict = {5: 'Hello!', 
           (0,255,0): 'Green',
           'Good': 10,
           'Bad': 0,
           'Red': (255,0,0)}

Just like with lists, we can "plug in" a key to the dictionary and retrieve the corresponding value using square brackets [].

In [40]:
my_dict[5]
Out[40]:
'Hello!'
In [41]:
my_dict['Red']
Out[41]:
(255, 0, 0)

Note: unlike lists , we cannot use slicing with dictionaries. This is because the dictionaries do not have any inherent order in Python.

If desired, we can access the keys of a dictionary using the .keys method. Similarly, we can access the values using the .values method. Finally, we can access key/value pairs using the .items method.

In [44]:
for key in my_dict.keys():
    print(key)
5
(0, 255, 0)
Good
Bad
Red
In [46]:
for value in my_dict.values():
    print(value)
Hello!
Green
10
0
(255, 0, 0)
In [47]:
for key, value in my_dict.items():
    print(key, value)
5 Hello!
(0, 255, 0) Green
Good 10
Bad 0
Red (255, 0, 0)

This allows us to iterate through dictionaries in several ways:

  • for key in my_dictionary.keys() will iterate through each key of the dictionary. We can also write for key in my_dictionary, which will still iterate through the keys.
  • for value in my_dictionary.values() will iterate through each value of the dictionary.
  • for key, value in my_dictionary.items() will iterate through each key/value pair.
In [ ]:
 
In [ ]:
 
In [ ]:
 

Exercise: Write a dictionary whose keys are the color codes 'B', 'K', and 'OG' and whose values are the corresponding RGB triples.

In [48]:
color_dict = {'B': [52, 80, 100],
              'K': [16, 16, 16],
              'OG': [92, 100, 40]}

Exercise: Use the color_codes list and the above dictionary to construct a list colors containing the RGB triples for each stripe in the tartan recipe.

In [49]:
colors = []
for color_code in color_codes:
    colors.append(color_dict[color_code])

print(colors)
[[52, 80, 100], [16, 16, 16], [52, 80, 100], [16, 16, 16], [52, 80, 100], [16, 16, 16], [92, 100, 40]]

Remaining tasks to be addressed for Project 3:¶

  • We have explicitly discussed how to combine the vertical_stripes and horizontal_stripes arrays in a checkerboard pattern. For the project, we will need to use the more authentic pattern described on the project page to combine these arrays and generate our tartan image.
  • We are told to recreate a tartan image that is 500 rows by 500 columns. On the other hand, the tartan recipe does not add up to a total width of 500. We will need to think about how to repeat the pattern to fill the entire (500, 500, 3) array.

Project 4: Image denoising¶

In recent weeks, we've been working with images using (m,n,3)-shaped NumPy RGB arrays and visualizing them with plt.imshow. The cell below will download an example image file from the course webpage into your Jupyter notebook directory with the file name noisy_img.png.

In [50]:
import requests

with open('noisy_img.png','wb') as f:
    data = requests.get(r'https://jllottes.github.io/_images/noisy_img.png').content
    f.write(data)

Let's read this image in as a NumPy array using plt.imread and plot it using plt.imshow:

In [51]:
noisy_img = plt.imread('noisy_img.png')
plt.imshow(noisy_img)
Out[51]:
<matplotlib.image.AxesImage at 0x268b56352b0>
No description has been provided for this image

Notice that the image looks to have been affected by some kind of noise. In particular, some pixels seem to have mistakenly been colored white, while others have been colored black. Let's take a closer look to see this more clearly.

In [52]:
plt.imshow(noisy_img[130:180,265:315])
Out[52]:
<matplotlib.image.AxesImage at 0x268b570a350>
No description has been provided for this image

This type of noise is called salt and pepper noise, where some pixels have been replaced with white (salt) pixels and other pixels have been replaced with black (pepper) pixels. For our next project, we are interested in exploring strategies to try to filter and remove this type of noise.

Loading in sample images¶

In order to explore filtering salt and pepper noise, it's useful to first be able to generate this kind of noise ourselves. That is, it will be helpful to have some Python code that can take an image and randomly assign some pixels to be salt noise and others to be pepper noise.

There are several example images available on the project page that can be used for this exploration. Pick whichever you like, download it, and place it into your Jupyter notebook directory. Then, read it into a NumPy array using np.imread.

For example, I've downloaded circuit.png into my Jupyter notebook directory. Now, I will read it into a NumPy array:

In [53]:
circuit = plt.imread('circuit.png')

Let's use plt.imshow to see what this image looks like.

In [54]:
plt.imshow(circuit)
Out[54]:
<matplotlib.image.AxesImage at 0x268b5fdfed0>
No description has been provided for this image

Before proceeding, let's take a look at the structure of the img array. For example, does it contain RGB values or RGBA values (with transparency)? Does it contain floats between 0 and 1 or does it contain integers between 0 and 255?

In [55]:
circuit.shape
Out[55]:
(426, 640, 3)
In [56]:
circuit.dtype
Out[56]:
dtype('float32')

We see that the circuit.png image contains floating point RGB values (without transparency). If your chosen image has a different format, transform it so that it also contains floating point RGB values. As a reminder:

  • img[:,:,:3] will produce a slice consisting of only the RGB parts of the image and will drop any transparency information, and
  • img / 255 produce a floating point RGB array from an integer RGB array.

Grayscale images¶

For this project, we will be working with grayscale images. That is, each image contains only shades of gray.

Note: we get a shade of gray when the red, green, and blue channels match for a given pixel. Let's confirm that our image file contains only gray pixels.

In [58]:
np.min(circuit[:,:,0] == circuit[:,:,1])
Out[58]:
np.True_
In [59]:
np.min(circuit[:,:,0] == circuit[:,:,2])
Out[59]:
np.True_

We can represent grayscale images as (m,n) arrays (rather than (m,n,3)) without any loss of information, since all three color channels give the same (m,n) array. In the case of the img array, we could take img[:,:,0] (the red channel) to represent the grayscale values.

In [60]:
gray_circuit = circuit[:,:,0]

Let's confirm that there's no loss of information:

In [61]:
plt.imshow(gray_circuit)
Out[61]:
<matplotlib.image.AxesImage at 0x268b6063d90>
No description has been provided for this image

Recall: when plt.imshow is passed a grayscale array, it defaults to the viridis colormap. Let's override this choice to use the gray colormap. It would also be helpful to specify vmin=0 and vmax=1 (or vmax=255 if working with integer data) to ensure that plt.imshow is correctly assigning colors to the values in the array.

In [62]:
plt.imshow(gray_circuit, cmap='gray', vmin=0, vmax=1)
Out[62]:
<matplotlib.image.AxesImage at 0x268b67579d0>
No description has been provided for this image

What could we do if we started from a color image that we want to convert to a grayscale image, like mario.png?

In [63]:
mario = plt.imread('mario.png')
plt.imshow(mario)
Out[63]:
<matplotlib.image.AxesImage at 0x268b67db750>
No description has been provided for this image

The above image does not contain purely grayscale data, since the red/green/blue values do not match for at least some of the pixels. Can we construct a reasonable grayscale version of this image?

In [64]:
np.min(mario[:,:,0] == mario[:,:,1])
Out[64]:
np.False_
In [68]:
gray_mario = mario[:,:,0]

plt.imshow(gray_mario, cmap='gray', vmin=0, vmax=1)
Out[68]:
<matplotlib.image.AxesImage at 0x268b83b8a50>
No description has been provided for this image

The np.mean function can be used to compute averages of an array.

In [70]:
#help(np.mean)
In [71]:
np.mean(mario)
Out[71]:
np.float32(0.67915136)

For our purposes, we don't want to take the average value of every value in the mario array. Instead, we want to average the RGB triples separately for each pixel in the image.

We can include an optional argument axis to specify the dimension through which means should be calculated. In particular, including the argument axis=2 tells NumPy to compute the average through the color channel only (i.e. axis 2 of the (m,n,3) array). In other words, this will average the RGB triple separately for each pixel.

In [72]:
gray_mario = np.mean(mario, axis=2)

print(gray_mario.shape)
(224, 256)
In [73]:
plt.imshow(gray_mario, cmap='gray', vmin=0, vmax=1)
Out[73]:
<matplotlib.image.AxesImage at 0x268b8413ed0>
No description has been provided for this image

Adding salt and pepper noise¶

Before adding noise to our image, let's make a copy of the array to preserve the original image array. Let's call this copy noisy_img (we will come back to add noise to this image later).

In [74]:
noisy_img = circuit.copy()

We would like a method for randomly selecting pixels of the noisy_img array to which we can add salt/pepper noise. The np.random.random function will be useful for this task, which works as follows:

  • np.random.random() will return a randomly selected float in the interval $[0,1)$.
  • np.random.random(shape) will return an array with shape shape, where each entry contains a randomly selected float in the interval $[0,1)$.

Strategy: let's generate an array of the same shape as our image filled with randomly drawn values between 0 and 1. If the randomly drawn value is greater than, say .9, we'll add salt noise in that position.

In [75]:
np.random.random()
Out[75]:
0.44274203198272244
In [76]:
np.random.random((3,4))
Out[76]:
array([[0.01092382, 0.78408415, 0.91518509, 0.84863303],
       [0.75876538, 0.13768678, 0.72340887, 0.70300808],
       [0.40025806, 0.41402923, 0.48853017, 0.4697628 ]])
In [80]:
def imshow_gray(a):
    plt.imshow(a, cmap='gray', vmin=0, vmax=1)
In [81]:
nrows, ncols = gray_circuit.shape

noisy_img = gray_circuit.copy()

random_array = np.random.random((nrows, ncols))
for row in range(nrows):
    for col in range(ncols):
        if random_array[row, col] > .9:
            noisy_img[row, col] = 1

imshow_gray(noisy_img)
No description has been provided for this image

What if we want to add pepper noise as well?

In [82]:
nrows, ncols = gray_circuit.shape

noisy_img = gray_circuit.copy()

salt_array = np.random.random((nrows, ncols))
pepper_array = np.random.random((nrows, ncols))

for row in range(nrows):
    for col in range(ncols):
        if salt_array[row, col] > .9:
            noisy_img[row, col] = 1
        if pepper_array[row, col] > .9:
            noisy_img[row, col] = 0

imshow_gray(noisy_img)
No description has been provided for this image
In [ ]:
 

Note: the implementation above gives a preference to pepper noise, since it's possible for salt pixels to be overwritten as pepper (but not the reverse). Instead, let's use a single random array, but select select different subintervals between 0 and 1 to identify salt/pepper noise:

In [ ]:
 
In [ ]:
 

Note: In the example code above, we iterated through every row and column and decided for each entry whether or not to add salt and pepper noise. This is perfect case to use Boolean arrays.

Idea: create a salt_mask Boolean array containing True in each entry that we wish to hit with salt noise. That is, salt_mask should contain True everywhere that the random_array is less than, say, 0.1.

In [ ]:
 
In [ ]:
 

Exercise. Use the above sample code to write a function sp_noise(img, noise) that adds salt and pepper noise to an image. Its first argument img should be a 2-dimensional numpy array representing the image and the second argument noise should be the fraction of pixels that are to be replaced by noise (for example, with noise = 0.05 about 5% of pixels should be noise, consisting in roughly equal parts of white and black pixels). The function should return a 2-dimensional numpy array representing the original image with noise added.

In [ ]: