uz_assignments/assignment1/solution.py

513 lines
19 KiB
Python
Raw Normal View History

2022-10-15 17:07:41 +02:00
import UZ_utils as uz
import numpy as np
2022-10-15 18:20:50 +02:00
import numpy.typing as npt
2022-10-15 20:58:42 +02:00
from matplotlib import pyplot as plt
2022-10-17 09:00:07 +02:00
import random
2022-10-20 15:07:08 +02:00
from PIL import Image
2022-10-21 14:27:25 +02:00
import cv2 as cv2
2022-10-15 17:07:41 +02:00
2022-10-15 17:12:38 +02:00
#######################################
# EXCERCISE 1: Basic image processing #
#######################################
2022-10-21 14:27:25 +02:00
def excercise_one() -> None:
image = one_a()
2022-10-20 16:12:02 +02:00
# one_b(image)
# one_c(image)
2022-10-21 14:27:25 +02:00
one_d(100, 200, 200, 400, image)
2022-10-20 16:12:02 +02:00
# one_e()
2022-10-15 17:12:38 +02:00
2022-10-15 18:20:50 +02:00
def one_a() -> npt.NDArray[np.float64]:
2022-10-15 19:57:58 +02:00
"""
Read the image from the file umbrellas.jpg and display it
"""
image = uz.imread('./images/umbrellas.jpg')
2022-10-15 20:11:55 +02:00
uz.imshow(image, 'Umbrellas')
2022-10-15 18:20:50 +02:00
return image
2022-10-15 17:07:41 +02:00
2022-10-15 18:20:50 +02:00
def one_b(image: npt.NDArray[np.float64]) -> None:
2022-10-15 19:57:58 +02:00
"""
Convert the loaded image to grayscale. A very simple way of doing this is summing
up the color channels and dividing the result by 3, effectively averaging the values.
The issue, however, is that the sum easily reaches beyond the np.uint8 range. We
can avoid that by casting the data to a floating point type. You can access a specific
image channel using the indexing syntax like red = I[:,:,0].
"""
2022-10-15 18:20:50 +02:00
grayscale_image = np.zeros(image.shape[:2])
2022-10-15 17:07:41 +02:00
2022-10-15 18:20:50 +02:00
for i in range(image.shape[0]):
for j in range(image.shape[1]):
grayscale_image[i, j] = (image[i, j, 0] + image[i,j, 1] + image[i, j, 2]) / 3
2022-10-15 19:57:58 +02:00
uz.imshow(grayscale_image, 'Umbrellas grayscale')
2022-10-15 19:44:51 +02:00
def one_c(image: npt.NDArray[np.float64]) -> None:
2022-10-15 19:57:58 +02:00
"""
Cut and display a specific part of the loaded image. Extract only one of the channels
so you get a grayscale image. You can do this by indexing along the first two axes,
for instance: cutout=I[130:260, 240:450, 1]. You can display multiple images in
a single window using plt.subplot().
Grayscale images can be displayed using different mappings (on a RGB monitor,
every value needs to be mapped to a RGB triplet). Pyplot defaults to a color map
named viridis, but often it is preferable to use a grayscale color map. This can be set
with an additional argument to plt.imshow, like plt.imshow(I, cmap=gray).
Question: Why would you use different color maps?
Answer:
"""
2022-10-15 20:11:55 +02:00
uz.imshow(image[50:200, 100:400, 2], "Just one piece of umbrellas")
2022-10-15 19:44:51 +02:00
def one_d(startx: int, endx: int, starty: int, endy: int, image:npt.NDArray[np.float64]) -> None:
2022-10-15 19:57:58 +02:00
"""
2022-10-15 20:11:55 +02:00
(height, width, color)
2022-10-15 21:40:39 +02:00
(x , y , color)
y ->
################# x
2022-10-15 20:11:55 +02:00
# # |
# # v
# #
# #
# #
#################
2022-10-15 19:57:58 +02:00
You can also replace only a part of the image using indexing. Write a script that
inverts a rectangular part of the image. This can be done pixel by pixel in a loop or
by using indexing.
Question: How is inverting a grayscale value defined for uint8 ?
Answer:
"""
inverted_image = image.copy()
2022-10-15 19:44:51 +02:00
2022-10-15 21:40:39 +02:00
for i in range(startx, endx):
for j in range(starty, endy):
2022-10-15 19:57:58 +02:00
inverted_image[i, j, 0] = 1 - image[i, j, 0]
inverted_image[i, j, 1] = 1 - image[i, j, 1]
inverted_image[i, j, 2] = 1 - image[i, j, 2]
2022-10-15 19:44:51 +02:00
2022-10-15 21:50:46 +02:00
fig, (ax0, ax1) = plt.subplots(1, 2)
fig.suptitle("Lomberlini")
ax0.imshow(image, cmap="gray")
ax1.imshow(inverted_image, vmax=255, cmap="gray")
ax0.set(title="Original image")
ax1.set(title="Inverted image")
plt.show()
2022-10-15 17:07:41 +02:00
2022-10-15 19:57:58 +02:00
def one_e() -> None:
"""
Perform a reduction of grayscale levels in the image. First read the image from
umbrellas.jpg and convert it to grayscale. You can write your own function for
grayscale conversion or use the function in UZ_utils.py.
Convert the grayscale image to floating point type. Then, rescale the image values
so that the largest possible value is 63. Convert the image back to uint8 and display
both the original and the modified image. Notice that both look the same. Pyplot
tries to maximize the contrast in displayed images by checking their values and
scaling them to cover the entire uint8 interval. If you want to avoid this, you need
to set the maximum expected value when using plt.imshow(), like plt.imshow(I,
vmax=255. Use this to display the resulting image so the change is visible.
"""
2022-10-20 15:16:04 +02:00
grayscale_image = uz.imread_gray("./images/umbrellas.jpg", uz.ImageType.float64)
2022-10-15 20:58:42 +02:00
upscaled_grayscale_image = (grayscale_image.copy() * 63).astype(np.uint8)
fig, (ax0, ax1) = plt.subplots(1, 2)
fig.suptitle("Lomberlini")
ax0.imshow(grayscale_image, cmap="gray")
ax1.imshow(upscaled_grayscale_image, vmax=255, cmap="gray")
ax0.set(title="Original grayscale image")
ax1.set(title="Upscaled grayscale image")
plt.show()
2022-10-15 18:20:50 +02:00
2022-10-15 21:50:46 +02:00
############################################
# EXCERCISE 2: Thresholding and histograms #
############################################
2022-10-15 20:58:42 +02:00
2022-10-17 09:00:07 +02:00
def excercise_two() -> None:
"""
Thresholding an image is an operation that produces a binary image (mask) of the same
size where the value of pixels is determined by whether the value of the corresponding
pixels in the source image is greater or lower than the given threshold.
"""
2022-10-17 20:33:00 +02:00
#two_a()
2022-10-20 18:04:26 +02:00
#two_b('./images/bird.jpg', 100, 20)
#two_c('./images/bird.jpg', 20, 100)
#two_d()
two_e(uz.imread_gray('./images/bird.jpg', uz.ImageType.uint8).astype(np.uint8))
2022-10-19 10:02:53 +02:00
2022-10-17 09:00:07 +02:00
def two_a() -> tuple[npt.NDArray[np.float64], npt.NDArray[np.uint8]]:
"""
Create a binary mask from a grayscale image. The binary mask is a matrix the same
size as the image which contains 1 where some condition holds and 0 everywhere
else. In this case the condition is simply the original image intensity. Use the image
bird.jpg. Display both the image and the mask.
"""
random_number = random.random()
2022-10-21 15:55:24 +02:00
TRESHOLD = 75/255 # Found using otsu method
2022-10-17 09:00:07 +02:00
2022-10-20 15:16:04 +02:00
image = uz.imread_gray("./images/bird.jpg", uz.ImageType.float64)
2022-10-17 09:00:07 +02:00
binary_mask = image.copy()
if random_number < 0.5:
binary_mask[binary_mask < TRESHOLD] = 0
binary_mask[binary_mask >= TRESHOLD] = 1
else:
binary_mask = np.where(binary_mask < TRESHOLD, 0, 1)
binary_mask = uz.convert_float64_array_to_uint8_array(binary_mask)
fig, (ax0, ax1) = plt.subplots(1, 2)
fig.suptitle("Birdie and its mask")
ax0.imshow(image, cmap="gray")
ax1.imshow(binary_mask, cmap="gray")
ax0.set(title="Original image")
ax1.set(title="Mask of birdie")
plt.show()
return (image, binary_mask)
2022-10-19 09:05:59 +02:00
def my_hist_for_loop(image: npt.NDArray[np.float64], number_of_bins: int) -> npt.NDArray[np.float64]:
bin_restrictions = np.arange(0, 1, 1 / number_of_bins)
bins = np.zeros(number_of_bins).astype(np.float64)
for pixel in image.reshape(-1):
# https://stackoverflow.com/a/16244044
bins[np.argmax(bin_restrictions > pixel)] += 1
2022-10-19 10:02:53 +02:00
2022-10-19 09:05:59 +02:00
return bins / np.sum(bins)
# Much faster implementation than for loop
2022-10-20 16:12:02 +02:00
def my_hist(image: npt.NDArray[np.float64], number_of_bins: int, img_typ: uz.ImageType) -> npt.NDArray[np.float64]:
if img_typ == uz.ImageType.float64:
bins = np.arange(0, 1, 1 / number_of_bins)
elif img_typ == uz.ImageType.uint8:
bins = np.arange(0, 255, 255/number_of_bins)
2022-10-19 09:05:59 +02:00
# Put pixels into classes
# ex. binsize = 10 then 0.4 would map into 4
2022-10-17 20:33:00 +02:00
binarray = np.digitize(image.reshape(-1), bins).astype(np.uint8)
2022-10-19 10:02:53 +02:00
2022-10-19 09:05:59 +02:00
# Now count those values
binarray = np.unique(binarray, return_counts=True)
2022-10-19 10:02:53 +02:00
counts = binarray[1].astype(np.float64) # Get the counts out of tuple
# Check if there is any empty bin
empty_bins = []
bins = binarray[0]
for i in range(1, number_of_bins + 1):
if i not in bins:
empty_bins.append(i)
# Add empty bins with zeros
if empty_bins != []:
for i in empty_bins:
counts = np.insert(counts, i - 1, 0)
return counts / np.sum(counts)
2022-10-19 09:05:59 +02:00
2022-10-17 20:33:00 +02:00
def two_b(image_path: str, number_of_bins_first: int, number_of_bins_second: int) -> None:
2022-10-17 09:00:07 +02:00
"""
Write a function myhist that accepts a grayscale image and the number of bins that
will be used in building a histogram. The function should return a 1D array that
represents the image histogram (the size should be equal to the number of bins, of
course).
The histogram is simply a count of pixels with same (or similar) intensity for all
bins. You can assume the values of the image are within the interval [0,255]. If you
use fewer than 255 bins, intensities will have to be grouped together, e.g. if using 10
bins, all values on the interval [0,25] will fall into bin 0.
Write a script that calculates and displays histograms for different numbers of bins
using bird.jpg
2022-10-20 18:04:26 +02:00
Question: The histograms are usually normalized by dividing the result by the
sum of all cells. Why is that?
Answer:
2022-10-17 09:00:07 +02:00
"""
2022-10-20 15:16:04 +02:00
image = uz.imread_gray(image_path, uz.ImageType.uint8)
2022-10-17 20:33:00 +02:00
2022-10-20 16:12:02 +02:00
H1 = my_hist(image, number_of_bins_first, uz.ImageType.uint8)
H2 = my_hist(image, number_of_bins_second, uz.ImageType.uint8)
2022-10-17 20:33:00 +02:00
2022-10-19 09:05:59 +02:00
fig, (ax0, ax1, ax2) = plt.subplots(1, 3)
fig.suptitle("Birdie and histgrams")
2022-10-17 20:33:00 +02:00
ax0.imshow(image, cmap="gray")
2022-10-19 09:05:59 +02:00
ax0.set(title="Birdie image")
ax1.bar(np.arange(number_of_bins_first), H1)
ax1.set(title="100 bins")
ax2.bar(np.arange(number_of_bins_second), H2)
ax2.set(title="20 bins")
2022-10-17 20:33:00 +02:00
plt.show()
2022-10-19 10:02:53 +02:00
2022-10-20 18:04:26 +02:00
def two_c(image_path: str, number_of_bins_first: int, number_of_bins_second: int) -> None:
"""
Modify your function myhist to no longer assume the uint8 range
for values. Instead, it should find the maximum and minimum values in the image
and calculate the bin ranges based on these values. Write a script that shows the
difference between both versions of the function.
"""
image_uint8 = uz.imread_gray(image_path, uz.ImageType.uint8)
image_float64 = uz.imread_gray(image_path, uz.ImageType.float64)
H01 = my_hist(image_uint8, number_of_bins_first, uz.ImageType.uint8)
H02 = my_hist(image_uint8, number_of_bins_second, uz.ImageType.uint8)
H11 = my_hist(image_float64, number_of_bins_first, uz.ImageType.float64)
H12 = my_hist(image_float64, number_of_bins_second, uz.ImageType.float64)
fig, axs = plt.subplots(2, 3)
fig.suptitle("Comparison between two histograms")
axs[0, 0].imshow(image_float64, cmap="gray")
axs[0, 0].set(title="Grayscale image in float64 representation")
axs[0, 1].bar(np.arange(number_of_bins_first), H01)
axs[0, 1].set(title=f'{number_of_bins_first} bins used')
axs[0, 2].bar(np.arange(number_of_bins_second), H02)
axs[0, 2].set(title=f'{number_of_bins_second} bins used')
axs[1, 0].imshow(image_float64, cmap="gray")
axs[1, 0].set(title="Grayscale image in uint8 representation")
axs[1, 1].bar(np.arange(number_of_bins_first), H11)
axs[1, 1].set(title=f'{number_of_bins_first} bins used')
axs[1, 2].bar(np.arange(number_of_bins_second), H12)
axs[1, 2].set(title=f'{number_of_bins_second} bins used')
plt.show()
2022-10-19 10:02:53 +02:00
2022-10-20 15:07:08 +02:00
def two_d() -> None:
2022-10-19 10:02:53 +02:00
"""
Test myhist function on images (three or more) of the same scene in
different lighting conditions. One way to do this is to capture several images using
your web camera and change the lighting of the room. Visualize the histograms for
all images for different number of bins and interpret the results.
"""
2022-10-20 15:16:04 +02:00
light = uz.imread_gray("./images/ROOM_LIGHTS_ON.jpg", uz.ImageType.float64)
darker = uz.imread_gray("./images/ONE_ROOM_LIGH_ON.jpg", uz.ImageType.float64)
dark = uz.imread_gray("./images/DARK.jpg", uz.ImageType.float64)
2022-10-20 15:07:08 +02:00
2022-10-20 18:04:26 +02:00
H10 = my_hist(light, 20, uz.ImageType.float64)
H11 = my_hist(light, 60, uz.ImageType.float64)
H12 = my_hist(light, 100, uz.ImageType.float64)
H20 = my_hist(darker, 20, uz.ImageType.float64)
H21 = my_hist(darker, 60, uz.ImageType.float64)
H22 = my_hist(darker, 100, uz.ImageType.float64)
H30 = my_hist(dark, 20, uz.ImageType.float64)
H31 = my_hist(dark, 60, uz.ImageType.float64)
H32 = my_hist(dark, 100, uz.ImageType.float64)
2022-10-19 10:02:53 +02:00
2022-10-20 15:07:08 +02:00
fig, axs = plt.subplots(3, 4)
2022-10-19 10:02:53 +02:00
fig.suptitle("spanskiduh and histgrams")
axs[0, 0].imshow(light, cmap="gray")
axs[0, 0].set(title="Image in light conditions")
2022-10-20 15:07:08 +02:00
axs[0, 1].bar(np.arange(20), H10)
axs[0, 1].set(title="Using 20 bins")
axs[0, 2].bar(np.arange(60), H11)
axs[0, 2].set(title="Using 60 bins")
axs[0, 3].bar(np.arange(100), H12)
axs[0, 3].set(title="Using 100 bins")
2022-10-19 10:02:53 +02:00
axs[1, 0].imshow(darker, cmap="gray")
axs[1, 0].set(title="Image in darker conditions")
2022-10-20 15:07:08 +02:00
axs[1, 1].bar(np.arange(20), H20)
axs[1, 1].set(title="Using 20 bins")
axs[1, 2].bar(np.arange(60), H21)
axs[1, 2].set(title="Using 60 bins")
axs[1, 3].bar(np.arange(100), H22)
axs[1, 3].set(title="Using 100 bins")
2022-10-19 10:02:53 +02:00
axs[2, 0].imshow(dark, cmap="gray")
axs[2, 0].set(title="Image in dark conditions")
2022-10-20 15:07:08 +02:00
axs[2, 1].bar(np.arange(20), H30)
axs[2, 1].set(title="Using 20 bins")
axs[2, 2].bar(np.arange(60), H31)
axs[2, 2].set(title="Using 60 bins")
axs[2, 3].bar(np.arange(100), H32)
axs[2, 3].set(title="Using 100 bins")
2022-10-19 10:02:53 +02:00
plt.show()
2022-10-17 09:00:07 +02:00
def two_e(image: npt.NDArray[np.uint8]) -> None:
2022-10-20 15:07:08 +02:00
"""
Implement Otsus method for automatic threshold calculation. It
should accept a grayscale image and return the optimal threshold. Using normalized
histograms, the probabilities of both classes are easy to calculate. Write a script that
shows the algorithms results on different images.
References: https://en.wikipedia.org/wiki/Otsu%27s_method
"""
treshold_range = np.arange(np.max(image) + 1)
criterias = []
for treshold in treshold_range:
# create the thresholded image
thresholded_im = np.zeros(image.shape)
thresholded_im[image >= treshold] = 1
# compute weights
nb_pixels = image.size
nb_pixels1 = np.count_nonzero(thresholded_im)
weight1 = nb_pixels1 / nb_pixels
weight0 = 1 - weight1
# if one the classes is empty, eg all pixels are below or above the threshold, that threshold will not be considered
# in the search for the best threshold
if weight1 == 0 or weight0 == 0:
continue
# find all pixels belonging to each class
val_pixels1 = image[thresholded_im == 1]
val_pixels0 = image[thresholded_im == 0]
# compute variance of these classes
var0 = np.var(val_pixels0) if len(val_pixels0) > 0 else 0
var1 = np.var(val_pixels1) if len(val_pixels1) > 0 else 0
criterias.append( weight0 * var0 + weight1 * var1)
best_threshold = treshold_range[np.argmin(criterias)]
print(f'best treshold is: {best_threshold}')
2022-10-20 15:07:08 +02:00
2022-10-21 14:27:25 +02:00
######################################################
# EXCERCISE 3: Morphological operations and regions #
######################################################
def excercise_three() -> None:
2022-10-21 15:55:24 +02:00
#three_a()
2022-10-21 16:47:14 +02:00
mask1, mask2 = three_b()
three_c(uz.imread('./images/bird.jpg'), mask1)
2022-10-21 14:27:25 +02:00
2022-10-21 14:51:40 +02:00
def three_a() -> None:
2022-10-21 14:27:25 +02:00
"""
We will perform two basic morphological operations on the image mask.png, erosion
and dilation. We will also experiment with combinations of both operations, named
opening and closing. Also combine both operations sequentially and display the results.
Question: Based on the results, which order of erosion and dilation operations
produces opening and which closing?
Answer: Opening = Erosion then dialation
Closing = Dialation then erosion
"""
2022-10-21 14:51:40 +02:00
img_orig = uz.imread_gray('./images/mask.png', uz.ImageType.float64)
2022-10-21 14:27:25 +02:00
img_er_dil = img_orig.copy()
img_dil_er = img_orig.copy()
img_comb = img_orig.copy()
2022-10-21 14:51:40 +02:00
SE_size = 2
SE = np.ones((SE_size, SE_size), np.float64)
2022-10-21 14:27:25 +02:00
fig, axs = plt.subplots(3, 4)
fig.suptitle("Image after N iterations of Opening, Closing and combination of opening and closing")
for im_ix in range(4):
2022-10-21 14:51:40 +02:00
SE = np.ones((SE_size, SE_size), np.float64)
2022-10-21 14:27:25 +02:00
img_er_dil = cv2.erode(img_er_dil, SE)
img_er_dil = cv2.dilate(img_er_dil, SE)
2022-10-21 14:51:40 +02:00
axs[0, im_ix].imshow(img_er_dil.copy(), cmap='gray')
axs[0, im_ix].set(title=f"after {im_ix + 1} iterations of Opening, SE: {SE_size} x {SE_size}")
SE_size +=1
2022-10-21 14:27:25 +02:00
2022-10-21 14:51:40 +02:00
SE_size = 2
2022-10-21 14:27:25 +02:00
for im_ix in range(4):
2022-10-21 14:51:40 +02:00
SE = np.ones((SE_size, SE_size), np.float64)
2022-10-21 14:27:25 +02:00
img_dil_er = cv2.dilate(img_dil_er, SE)
2022-10-21 14:51:40 +02:00
img_dil_er = cv2.erode(img_dil_er, SE)
axs[1, im_ix].imshow(img_dil_er.copy(), cmap='gray')
axs[1, im_ix].set(title=f"after {im_ix + 1} iterations of Closing, SE: {SE_size} x {SE_size}")
SE_size +=1
2022-10-21 14:27:25 +02:00
2022-10-21 14:51:40 +02:00
SE_size = 2
2022-10-21 14:27:25 +02:00
for im_ix in range(4):
img_comb = cv2.erode(img_comb, SE)
img_comb = cv2.dilate(img_comb, SE)
img_comb = cv2.dilate(img_comb, SE)
img_comb = cv2.erode(img_comb, SE)
2022-10-21 14:51:40 +02:00
axs[2, im_ix].imshow(img_comb.copy(), cmap='gray')
axs[2, im_ix].set(title=f"after {im_ix + 1} iterations of O+C, SE: {SE_size} x {SE_size}")
SE_size +=1
2022-10-21 14:27:25 +02:00
plt.show()
2022-10-21 14:51:40 +02:00
2022-10-21 16:47:14 +02:00
def three_b():
2022-10-21 15:55:24 +02:00
"""
Try to clean up the mask of the image bird.jpg using morphological operations
as shown in the image. Experiment with different sizes of the structuring element.
You can also try different shapes, like cv2.getStructuringElement(cv2.MORPH_-
ELLIPSE,(n,n)).
"""
_, original_mask = two_a()
mask1 = original_mask.copy()
mask2 = original_mask.copy()
SE_ELIPSE = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (4, 4))
SE_CROSS = cv2.getStructuringElement(cv2.MORPH_CROSS, (4, 4))
# Perform operation of opening and then closing (almost)
2022-10-21 16:47:14 +02:00
# Values found through trial and error
2022-10-21 15:55:24 +02:00
mask1 = cv2.dilate(mask1, SE_ELIPSE, iterations = 4)
mask1 = cv2.erode(mask1, SE_ELIPSE)
mask1 = cv2.dilate(mask1, SE_ELIPSE)
mask1 = cv2.erode(mask1, SE_ELIPSE, iterations=3)
mask2 = cv2.dilate(mask2, SE_CROSS, iterations = 5)
mask2 = cv2.erode(mask2, SE_CROSS)
mask2 = cv2.dilate(mask2, SE_CROSS)
mask2 = cv2.erode(mask2, SE_CROSS, iterations=4)
2022-10-21 14:51:40 +02:00
2022-10-21 15:55:24 +02:00
fig, axs = plt.subplots(1, 3)
fig.suptitle("Operation of Opening and closing using elipse and cross")
axs[0].imshow(original_mask, cmap='gray')
axs[0].set(title="Original mask")
axs[1].imshow(mask1, cmap='gray')
axs[1].set(title="Using elipse")
axs[2].imshow(mask2, cmap='gray')
axs[2].set(title="Using cross")
2022-10-21 16:47:14 +02:00
2022-10-21 15:55:24 +02:00
plt.show()
2022-10-21 16:47:14 +02:00
return (mask1, mask2)
# Ez lmao
def three_c(image: npt.NDArray[np.float64], mask: npt.NDArray[np.uint8]):
"""
Write a function immask that accepts a three channel image and
a binary mask and returns an image where pixel values are set to black if the
corresponding pixel in the mask is equal to 0. Otherwise, the pixel value should be
equal to the corresponding image pixel. Do not use for loops, as they are slow
"""
mask = np.expand_dims(mask, axis=2)
2022-10-21 14:27:25 +02:00
2022-10-21 16:47:14 +02:00
image = mask * image
plt.imshow(image)
plt.show()
2022-10-21 14:27:25 +02:00
2022-10-15 17:12:38 +02:00
def main() -> None:
2022-10-17 09:00:07 +02:00
#excercise_one()
2022-10-21 14:27:25 +02:00
#excercise_two()
excercise_three()
2022-10-15 17:07:41 +02:00
if __name__ == "__main__":
main()
2022-10-19 09:05:59 +02:00