# Apply Median Filter Using NumPy in Python (Noise Reduction)

In this tutorial, we’ll cover several key aspects: explaining what a median filter is, creating a kernel using NumPy, padding the image array, and iterating through the image to apply the filter.

We’ll work with different kernel sizes to observe their effects on image smoothing to enhance your skills in image processing applications.

## What is the Median Filter?

In image processing and data analysis, a median filter operates by replacing the value of a pixel with the median value of the intensities in the neighborhood of that pixel.

Let’s break it down: Imagine a grid of pixels, where each pixel has a certain intensity value.

The median filter looks at each pixel and considers a small surrounding area, or a ‘window’, around it.

This window can be of various sizes, such as 3×3, 5×5, etc., depending on the level of detail you wish to preserve or remove in the image.

For each pixel, the filter sorts the values of all the pixels in its window and finds the median value.

This median value is then used as the new value for the central pixel. This process is repeated for every pixel in the image.

The key benefit of using a median filter is its ability to remove noise from images.

## Create Kernel

Creating a kernel is an essential step in applying a median filter. The kernel defines the neighborhood of each pixel that will be sampled to find the median value.

You can create a kernel using NumPy:

```import numpy as np
kernel_size = 3  # 3x3 kernel
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
print(kernel)
```

Output:

```[[1 1 1]
[1 1 1]
[1 1 1]]
```

In this code, we created a 3×3 kernel. The `np.ones` function generates an array where all values are 1s, and the size of the array is determined by `kernel_size`.

This kernel signifies that each pixel and its eight immediate neighbors will be considered for finding the median.

Padding the image array is a necessary step before applying the median filter.

This is because when the kernel reaches the borders of the image, it lacks neighboring pixels outside the image boundaries.

To address this, we add rows and columns of zeros (0s) around the edges of the image, creating a border that allows the kernel to operate at the edges.

Here’s how you can do this using NumPy:

```import numpy as np

# Sample image array
image = np.array([[10, 20, 30],
[40, 50, 60],
[70, 80, 90]])
```

Output:

```[[ 0  0  0  0  0]
[ 0 10 20 30  0]
[ 0 40 50 60  0]
[ 0 70 80 90  0]
[ 0  0  0  0  0]]
```

In this example, we start with a simple 3×3 image array. Using `np.pad`, we add a padding of 1 pixel width around the image.

The `mode='constant'` and `constant_values=0` arguments ensure that the padding is filled with zeros.

The resulting `padded_image` is larger than the original, with a border of zeros that facilitates the median filtering process at the edges.

## Iterate Through Image

Once the image array is padded, the next step is to iterate through the image and apply the median filter at each position.

This involves moving the kernel over each pixel, considering the pixel values under the kernel, and replacing the central pixel with the median of these values.

We can do this using nested for loops:

```import numpy as np

padded_image = np.array([[ 0,  0,  0,  0,  0],
[ 0, 10, 20, 30,  0],
[ 0, 40, 50, 60,  0],
[ 0, 70, 80, 90,  0],
[ 0,  0,  0,  0,  0]])
kernel_size = 3
half_k = kernel_size // 2

# Empty array to store the filtered image

# Iterate through the image
for i in range(half_k, padded_image.shape[0] - half_k):
for j in range(half_k, padded_image.shape[1] - half_k):
# Extract the window
window = padded_image[i - half_k:i + half_k + 1, j - half_k:j + half_k + 1]
# Compute the median and assign it to the filtered image
filtered_image[i, j] = np.median(window)

filtered_image = filtered_image[half_k:-half_k, half_k:-half_k]
print(filtered_image)
```

Output:

```[[ 0 20  0]
[20 50 30]
[ 0 50  0]]
```

In this code, we iterate over the padded image with the for loops.

The `window` variable extracts the neighborhood defined by the kernel at each position.

We then calculate the median of this window and assign it to the corresponding position in the `filtered_image`.

After iterating through the entire image, we remove the padding to get the final, filtered image.

## Apply Median Filter

Applying the median filter at each position involves extracting a region of the image corresponding to the kernel size and then computing the median of the values within this region.

This step is important in median filtering as it determines the new value of each pixel based on its local neighborhood.

Here’s how you can implement this using NumPy:

```import numpy as np
padded_image = np.array([[ 0,  0,  0,  0,  0],
[ 0, 10, 20, 30,  0],
[ 0, 40, 50, 60,  0],
[ 0, 70, 80, 90,  0],
[ 0,  0,  0,  0,  0]])
kernel_size = 3
half_k = kernel_size // 2

# Empty array to store the filtered image

# Applying the median filter
for i in range(half_k, padded_image.shape[0] - half_k):
for j in range(half_k, padded_image.shape[1] - half_k):
# Extract the kernel-sized region
window = padded_image[i - half_k:i + half_k + 1, j - half_k:j + half_k + 1]
# Find the median value
filtered_image[i, j] = np.median(window)

filtered_image = filtered_image[half_k:-half_k, half_k:-half_k]
print(filtered_image)
```

Output:

```[[ 0 20  0]
[20 50 30]
[ 0 50  0]]
```

In this snippet, the code extracts a region (`window`) of the image for each pixel that matches the size of the kernel.

The `np.median` function then calculates the median of this region.

This median value replaces the original pixel value in the `filtered_image`. After processing the entire image, we remove the added padding to return the image to its original dimensions.

## Save Filtered Image

After applying the median filter and obtaining the filtered image, the final step is to save this processed image.

You can save the filtered image using libraries like OpenCV or PIL (Python Imaging Library):

```from PIL import Image
import numpy as np

# Assuming 'filtered_image' is the result obtained from the median filter
filtered_image = np.array([[0, 20, 0],
[20, 50, 30],
[0, 50, 0]], dtype=np.uint8)

# Convert the NumPy array to an Image object
filtered_image_pil = Image.fromarray(filtered_image)
filtered_image_pil.save('filtered_image.png')
print("Image saved successfully.")
```

Output:

```Image saved successfully.
```

In this example, we first convert the NumPy array `filtered_image` to an image object using PIL’s `Image.fromarray` method.

## Tweak Kernel Size

Adjusting the kernel size in median filtering impacts the level of smoothing in the image.

By experimenting with different kernel sizes, you can observe how it affect the image’s noise reduction and edge preservation.

Let’s see how changing the kernel size impacts the filtering process:

```import numpy as np
from PIL import Image

# Function to apply median filter
def apply_median_filter(image, kernel_size):
half_k = kernel_size // 2

for i in range(half_k, padded_image.shape[0] - half_k):
for j in range(half_k, padded_image.shape[1] - half_k):
window = padded_image[i - half_k:i + half_k + 1, j - half_k:j + half_k + 1]
filtered_image[i, j] = np.median(window)
return filtered_image[half_k:-half_k, half_k:-half_k]

# Sample image
image = np.array([[10, 20, 30],
[40, 50, 60],
[70, 80, 90]], dtype=np.uint8)

# Apply median filter with different kernel sizes
kernel_sizes = [3, 5, 7]
for k_size in kernel_sizes:
filtered_img = apply_median_filter(image, k_size)
Image.fromarray(filtered_img).save(f'filtered_image_{k_size}x{k_size}.png')
print("Filtered images saved with different kernel sizes.")
```

Output:

```Filtered images saved with different kernel sizes.
```

In this example, we define a function `apply_median_filter` that takes an image and a kernel size as inputs and applies the median filter.

We then test this function with different kernel sizes (3×3, 5×5, 7×7). Larger kernels encompass a wider area around each pixel, leading to the consideration of more neighboring pixels in the median calculation.

The effect of increasing the kernel size is more pronounced smoothing.

A larger kernel size tends to blur the image more, reducing noise more effectively but also potentially blurring edges and fine details.

Smaller kernel preserves more detail but is less effective at noise reduction.