Build an Image Filter Studio with NumPy
Apply 5 custom image filters using pure NumPy array operations.
Introduction
30 Second Summary
Every photo filter you use on your phone is just math applied to a grid of numbers. Each pixel is a set of color values, and transforming those values in clever ways creates effects like blur, edge detection, and color adjustments.
In this project, you will build a command-line image filter studio that applies 5 custom filters to any image using pure NumPy array operations. Each filter demonstrates a different core NumPy concept, from broadcasting to boolean masking.
What You'll Build
A Python script that loads an image as a NumPy array and applies grayscale, brightness, blur, edge detection, and color threshold filters, then displays the results in a side-by-side comparison grid using Matplotlib.
By the end of this project, you'll have:
- A working Python script (image_filters.py) that loads any image and applies 5 custom filters using pure NumPy operations.
- A clear understanding of how NumPy array operations like broadcasting, vectorization, and boolean masking map to real-world image manipulation.
- A visual comparison grid displayed with Matplotlib showing original and filtered images side-by-side.
- Secret Mission: Add a sepia tone filter and a pixelate effect using matrix multiplication and array reshaping.
Are there any prerequisites?
You should be comfortable with basic Python concepts like variables, functions, and loops. No prior experience with NumPy or image processing is needed.
Load an Image as a NumPy Array
Your environment is ready with all the tools installed. Now it's time to open an image and see what it actually looks like to a computer.
To a computer, every image is just a giant grid of numbers. Each pixel has three values (Red, Green, Blue) that mix together to produce the color you see on screen. In this step, you'll load your sample image into a NumPy array and explore that number grid directly.
In this step, get ready to:
- Load an image file and convert it into a NumPy array.
- Inspect the array's shape, data type, and pixel value range.
- Extract and display individual Red, Green, and Blue color channels.
Load the image with Pillow and convert to a NumPy array
Pillow handles reading image files from disk. Once the image is loaded, you convert it to a NumPy array so you can manipulate every pixel using array operations.
- Create a new file called image_filters.py in your project folder by running:
type nul > image_filters.py
- Confirm the file exists by running:
dir image_filters.py
You should see image_filters.py listed in the output.
- Open image_filters.py in your code editor.
- Add the following imports and function to the file:
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def load_image(path):
"""Load an image and convert to a NumPy array."""
img = Image.open(path).convert("RGB")
return np.array(img)
What does this code do?
The three import lines bring in the libraries you installed in Step 1.
- Image.open(path).convert("RGB") reads the image file and ensures it has exactly 3 color channels (Red, Green, Blue), even if the source file has an alpha channel or is grayscale.
- np.array(img) converts the Pillow image object into a NumPy array. This is what lets you treat pixel data as numbers you can do math on.
- Save image_filters.py.
Seeing a red underline on the imports?
If your editor shows import errors, make sure your virtual environment is selected as the Python interpreter. In VS Code, press Ctrl+Shift+P, type Python: Select Interpreter, and choose the one inside your my-env folder.
Help me fix import errors in my image_filters.py file
Explore the array structure
Before building filters, you need to understand what the image array looks like. Printing its properties tells you the dimensions, the type of numbers stored, and the range of values each pixel can have.
- Add the following test code below your load_image() function in image_filters.py:
# Test the load_image function
image = load_image("sample.jpg")
print(f"Image shape: {image.shape}")
print(f"Image dtype: {image.dtype}")
print(f"Pixel value range: {image.min()} to {image.max()}")
What do these prints tell you?
- image.shape returns a tuple like (height, width, 3). The 3 represents the three color channels: Red, Green, and Blue.
- image.dtype shows uint8, meaning each value is an unsigned 8-bit integer (0 to 255).
- The min/max range confirms pixels span from 0 (black) to 255 (full brightness) for each channel.
- Save image_filters.py and run the script by entering this command in your terminal:
python image_filters.py
You should see three lines of output showing your image's dimensions, data type, and pixel value range.
Seeing FileNotFoundError?
This means Python cannot find sample.jpg. Make sure your image file is in the same folder as image_filters.py and that the filename matches exactly, including the file extension.
Help me fix a FileNotFoundError when loading my image
Extract and display individual color channels
Every pixel in your array has three values stacked in order: Red at index 0, Green at index 1, Blue at index 2. You can use array slicing to pull out an entire channel at once.
- Replace your test code (everything below the load_image() function) with the following:
# Load the image
image = load_image("sample.jpg")
# Extract individual color channels using array slicing
red_channel = image[:, :, 0]
green_channel = image[:, :, 1]
blue_channel = image[:, :, 2]
# Display each channel side by side
fig, axes = plt.subplots(1, 4, figsize=(12, 3))
axes[0].imshow(image)
axes[0].set_title("Original")
axes[0].axis("off")
axes[1].imshow(red_channel, cmap="Reds")
axes[1].set_title("Red Channel")
axes[1].axis("off")
axes[2].imshow(green_channel, cmap="Greens")
axes[2].set_title("Green Channel")
axes[2].axis("off")
axes[3].imshow(blue_channel, cmap="Blues")
axes[3].set_title("Blue Channel")
axes[3].axis("off")
plt.tight_layout()
plt.show()
What does the slicing do?
The syntax image[:, :, 0] means "give me all rows, all columns, but only the first channel." The colon (:) means "everything along this dimension." Changing the last number (0, 1, or 2) selects a different color channel.
Matplotlib's imshow() displays a 2D array as an image. The cmap parameter applies a colormap so the single-channel data renders in the matching color.
- Save image_filters.py and run the script again:
python image_filters.py
A window pops up showing four images side by side: your original photo, then the Red, Green, and Blue channels each displayed in their respective colors.
What should I see?
Areas that appear bright in a channel have high values for that color. For example, a blue sky looks bright in the Blue channel but dark in the Red and Green channels. This is how computers "see" color as separate layers of intensity.
No window appearing?
Some systems need a Matplotlib backend to display windows. Make sure you're running the script from a terminal (not a notebook) and that your virtual environment is activated. Close any previous Matplotlib windows before re-running.
Help me get Matplotlib to display images in a window
- Close the Matplotlib window when you're done exploring.
- Now remove all the test code below the load_image() function. Keep only the imports and the function definition.
- Save image_filters.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file with only the imports and the load_image() function remaining.
ⓧ I'd like to double check the full code
Here is what image_filters.py should look like at this point:
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def load_image(path):
"""Load an image and convert to a NumPy array."""
img = Image.open(path).convert("RGB")
return np.array(img)
You've proven that an image is just a 3D array of numbers. Now you'll put that knowledge to work by building your first filter that transforms those numbers into something visually different.
Build a Grayscale Filter with Vectorized Operations
In the previous step, you loaded your image into a NumPy array and explored its structure. Now it's time to put that understanding to work by building your first filter.
Grayscale conversion is the perfect starting point because it demonstrates one of NumPy's most powerful features: vectorized operations. Instead of looping through millions of pixels one by one, you'll process the entire image in a single operation.
In this step, get ready to:
- Build a grayscale conversion function using NumPy's dot product.
- Display your original and grayscale images side-by-side with Matplotlib.
Convert to grayscale using weighted channel averaging
Human eyes don't perceive all colors equally. Green appears brighter than blue, even at the same intensity. The standard luminance formula accounts for this by weighting each channel differently: 0.2989 for Red, 0.5870 for Green, and 0.1140 for Blue.
NumPy's np.dot() function can apply these weights across every single pixel simultaneously. No Python loops needed.
- In image_filters.py, find the end of the load_image() function, then add the following code below it:
def to_grayscale(image):
"""Convert to grayscale using luminance weights (vectorized)."""
# Define the standard luminance weights for R, G, B
weights = np.array([0.2989, 0.5870, 0.1140])
# Apply weights to all pixels at once using dot product
gray = np.dot(image[:, :, :3], weights)
# Stack single channel into 3 channels for display compatibility
gray_3ch = np.stack([gray, gray, gray], axis=-1)
return gray_3ch.astype(np.uint8)
What does this code do?
weights = np.array([0.2989, 0.5870, 0.1140]) creates an array of luminance weights. Green gets the highest weight because human eyes are most sensitive to green light.
np.dot(image[:, :, :3], weights) performs a weighted sum across the color channels for every pixel in the image at once. This is vectorization in action. One operation instead of looping through each pixel individually.
np.stack([gray, gray, gray], axis=-1) duplicates the single grayscale channel into 3 identical channels. Matplotlib expects 3-channel images for display, so this keeps the output compatible.
- Save image_filters.py.
Seeing a syntax error?
Make sure there is a blank line between the end of load_image() and the start of def to_grayscale(image):. Python needs blank lines between function definitions.
Check that the indentation uses consistent spaces throughout. Mixing tabs and spaces will cause an IndentationError.
Help me fix a syntax error in my to_grayscale function
Display the original and grayscale images side-by-side
Your filter function is ready. Now you'll add temporary test code to load your image, apply the grayscale filter, and display both versions side-by-side.
- At the very bottom of image_filters.py, add this temporary test code:
# Temporary test code - remove after verifying
image = load_image("sample.jpg")
gray = to_grayscale(image)
# Display original and grayscale side-by-side
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
axes[0].imshow(image)
axes[0].set_title("Original")
axes[0].axis("off")
axes[1].imshow(gray)
axes[1].set_title("Grayscale")
axes[1].axis("off")
plt.tight_layout()
plt.show()
What does this display code do?
plt.subplots(1, 2) creates a figure with 1 row and 2 columns of image slots. Each slot is an "axis" you can draw on.
imshow() renders the NumPy array as a visible image. axis("off") hides the numbered tick marks so you see only the image.
- Save image_filters.py.
- Run the script by entering this command in your terminal:
python image_filters.py
A Matplotlib window should pop up showing your original image on the left and the grayscale version on the right. The grayscale image should look natural, with lighter areas where the original had bright greens and darker areas where the original had deep blues.
- Close the Matplotlib window.
- Delete the temporary test code you just added (everything below the to_grayscale() function).
- Save image_filters.py again.
No window appeared?
Make sure your virtual environment is still active. You should see (my-env) at the start of your terminal prompt. If not, reactivate it with my-env\Scripts\activate.
If you see a FileNotFoundError, check that sample.jpg is in the same folder as your script. The filename is case-sensitive.
Help me figure out why my Matplotlib window isn't showing up
Why is vectorization so powerful?
A typical image might have 500,000+ pixels. A Python for loop visiting each pixel would take seconds. NumPy's vectorized operations delegate the work to optimized C code that processes all pixels in one pass. The result is the same, but the speed difference is dramatic.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and removed the temporary test code.
ⓧ I'd like to double check the full code
Here's what your image_filters.py should look like at this point (without the temporary test code):
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def load_image(path):
"""Load an image and convert to a NumPy array."""
img = Image.open(path).convert("RGB")
return np.array(img)
def to_grayscale(image):
"""Convert to grayscale using luminance weights (vectorized)."""
weights = np.array([0.2989, 0.5870, 0.1140])
gray = np.dot(image[:, :, :3], weights)
gray_3ch = np.stack([gray, gray, gray], axis=-1)
return gray_3ch.astype(np.uint8)
Your first filter is working. You just processed an entire image with a single NumPy operation. Next up, you'll build brightness and blur filters that introduce two more core NumPy concepts: broadcasting and 2D convolution.
Build Brightness and Blur Filters with Broadcasting
Your grayscale filter proved that NumPy can process every pixel in an image simultaneously. Now you will build two more filters that demonstrate an equally powerful concept: broadcasting.
Broadcasting lets NumPy apply a single value (like a brightness offset) across an entire multi-dimensional array without writing a loop. You will also build a blur filter that introduces 2D convolution, the fundamental technique behind almost every image filter in existence.
In this step, get ready to:
- Build a brightness adjustment filter using NumPy broadcasting.
- Build a box blur filter using 2D convolution with a uniform kernel.
Adjust brightness using broadcasting
When you add a number to every pixel in an image, the image gets brighter. When you subtract, it gets darker. The challenge is that pixel values must stay between 0 and 255.
- In image_filters.py, scroll down to the bottom of the file (below the to_grayscale function).
- Add the brightness adjustment function by copying this code:
def adjust_brightness(image, value=50):
"""Adjust brightness using broadcasting."""
brightened = image.astype(np.int16) + value
return np.clip(brightened, 0, 255).astype(np.uint8)
What does this code do?
This function demonstrates broadcasting in action. When you write image + value, NumPy automatically expands the single number value to match the shape of the entire image array. Every pixel gets adjusted in one operation.
- image.astype(np.int16) converts pixels from uint8 (0-255) to int16 so that adding 50 to a pixel at 230 gives 280, not an overflow error.
- np.clip(brightened, 0, 255) clamps every value back into the valid pixel range. Any value above 255 becomes 255, any below 0 becomes 0.
- .astype(np.uint8) converts back to the standard image data type so Matplotlib can display it correctly.
- Save image_filters.py.
- Verify the function loads without errors by running this command in your terminal:
python -c "from image_filters import adjust_brightness; print('Brightness filter ready')"
You should see Brightness filter ready printed in the terminal.
Seeing an import error?
- Make sure your terminal is in the project folder where image_filters.py lives.
- Check that your virtual environment is still activated. You should see (my-env) at the start of your terminal prompt.
- Confirm the function is placed after the to_grayscale function with a blank line separating them.
Why is my brightness filter failing to import?
Build a box blur using 2D convolution
Blurring an image means replacing each pixel with the average of its neighbors. This process is called convolution. You will build a helper function that slides a small grid (called a kernel) over the image and computes weighted sums.
- In image_filters.py, add the convolution helper function below adjust_brightness:
def apply_kernel(channel, kernel):
"""Apply a convolution kernel to a single channel."""
kh, kw = kernel.shape
pad_h, pad_w = kh // 2, kw // 2
padded = np.pad(channel, ((pad_h, pad_h), (pad_w, pad_w)), mode="edge")
output = np.zeros_like(channel, dtype=np.float64)
for i in range(kh):
for j in range(kw):
output += padded[i:i + channel.shape[0], j:j + channel.shape[1]] * kernel[i, j]
return output
What does this code do?
This helper takes a single color channel (a 2D array of pixel values) and a kernel (a small 2D array of weights), then slides the kernel across every position in the channel.
- np.pad adds extra pixels around the edges so the kernel can process border pixels without going out of bounds. The mode="edge" setting copies the nearest edge pixel for padding.
- The nested for loops iterate over each position in the kernel. Instead of looping over every pixel, each iteration multiplies a shifted slice of the entire padded image by one kernel weight. This is a vectorized approach that avoids per-pixel loops.
- np.zeros_like creates an output array filled with zeros, matching the input channel's shape.
You won't see a change yet. The next function (box_blur) uses this helper.
- Now add the box_blur function below apply_kernel:
def box_blur(image, size=5):
"""Apply box blur using a uniform kernel."""
kernel = np.ones((size, size)) / (size * size)
result = np.zeros_like(image, dtype=np.uint8)
for c in range(3):
blurred_channel = apply_kernel(image[:, :, c].astype(np.float64), kernel)
result[:, :, c] = np.clip(blurred_channel, 0, 255).astype(np.uint8)
return result
What does this code do?
This function creates a 5x5 kernel where every value is 1/25. When you slide this kernel over the image, each pixel becomes the average of its 25 nearest neighbors. That averaging effect is what creates the blur.
- np.ones((size, size)) / (size * size) creates a uniform averaging kernel. For a 5x5 kernel, every cell holds 0.04.
- The for c in range(3) loop applies the kernel separately to each color channel (Red, Green, Blue). Color channels must be blurred independently to avoid mixing colors.
- np.clip ensures the blurred values stay within the valid 0-255 range before converting back to uint8.
Why convolution instead of a library call?
Libraries like SciPy have optimized convolution functions built in. Building it yourself with NumPy arrays demonstrates exactly how the operation works at the fundamental level.
The pattern of padding, shifting slices, and accumulating weighted sums is the same pattern used in neural networks, Photoshop filters, and computer vision algorithms. Understanding it here gives you a foundation for all of those.
- Save image_filters.py.
- Verify all three new functions load correctly by running:
python -c "from image_filters import adjust_brightness, apply_kernel, box_blur; print('All filters ready')"
You should see All filters ready printed in the terminal.
Seeing a syntax error?
- Check that each function has a blank line separating it from the previous one.
- Make sure the indentation inside the for loops uses consistent spaces (4 spaces per level).
- Confirm the line output += padded[i:i + channel.shape[0], j:j + channel.shape[1]] * kernel[i, j] is on a single line, not accidentally split across two lines.
Help me fix a syntax error in my apply_kernel or box_blur function.
✔️ Awesome, I've got everything!
Great. Double check you have saved image_filters.py with all three new functions in place.
ⓧ I'd like to double check the full code
Here is the full image_filters.py at this point:
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def load_image(path):
"""Load an image and convert to a NumPy array."""
img = Image.open(path).convert("RGB")
return np.array(img)
def to_grayscale(image):
"""Convert to grayscale using luminance weights (vectorized)."""
weights = np.array([0.2989, 0.5870, 0.1140])
gray = np.dot(image[:, :, :3], weights)
gray_3ch = np.stack([gray, gray, gray], axis=-1)
return gray_3ch.astype(np.uint8)
def adjust_brightness(image, value=50):
"""Adjust brightness using broadcasting."""
brightened = image.astype(np.int16) + value
return np.clip(brightened, 0, 255).astype(np.uint8)
def apply_kernel(channel, kernel):
"""Apply a convolution kernel to a single channel."""
kh, kw = kernel.shape
pad_h, pad_w = kh // 2, kw // 2
padded = np.pad(channel, ((pad_h, pad_h), (pad_w, pad_w)), mode="edge")
output = np.zeros_like(channel, dtype=np.float64)
for i in range(kh):
for j in range(kw):
output += padded[i:i + channel.shape[0], j:j + channel.shape[1]] * kernel[i, j]
return output
def box_blur(image, size=5):
"""Apply box blur using a uniform kernel."""
kernel = np.ones((size, size)) / (size * size)
result = np.zeros_like(image, dtype=np.uint8)
for c in range(3):
blurred_channel = apply_kernel(image[:, :, c].astype(np.float64), kernel)
result[:, :, c] = np.clip(blurred_channel, 0, 255).astype(np.uint8)
return result
Your brightness and blur filters are ready. Next up, you will build edge detection and color thresholding filters that complete the studio.
Build Edge Detection and Threshold Filters
Your blur filter proved that convolution can transform an entire image by sliding a kernel across every pixel. Now you'll use that same apply_kernel() function for something more dramatic: detecting edges and isolating specific colors.
Edge detection finds boundaries between objects. Color thresholding highlights pixels that match certain criteria. Together, these two filters demonstrate how NumPy can analyze image content, not just modify appearance.
In this step, get ready to:
- Detect edges using Sobel operator kernels and your existing convolution function.
- Isolate colors using boolean masking on the pixel array.
- Display all 5 filters in a comparison grid with Matplotlib.
Detect edges with the Sobel operator
Edge detection works by measuring how quickly pixel values change. The Sobel operator uses two 3x3 kernels to measure horizontal and vertical gradients separately, then combines them into a single edge magnitude.
- In image_filters.py, find the box_blur() function.
- Add the following edge_detection() function below it:
def edge_detection(image):
"""Detect edges using Sobel operators."""
gray = np.dot(image[:, :, :3], [0.2989, 0.5870, 0.1140])
sobel_x = np.array([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]], dtype=np.float64)
sobel_y = np.array([[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]], dtype=np.float64)
gx = apply_kernel(gray, sobel_x)
gy = apply_kernel(gray, sobel_y)
magnitude = np.sqrt(gx**2 + gy**2)
magnitude = (magnitude / magnitude.max() * 255).astype(np.uint8)
return np.stack([magnitude, magnitude, magnitude], axis=-1)
What does this code do?
The function first converts the image to grayscale, since edge detection only needs intensity values, not color.
- sobel_x detects vertical edges (left-to-right intensity changes).
- sobel_y detects horizontal edges (top-to-bottom intensity changes).
- np.sqrt(gx**2 + gy**2) combines both gradients into a single edge strength value per pixel.
- The result is normalized to 0-255 and stacked into 3 channels so it displays as a proper grayscale image.
Why convert to grayscale first?
Edges are about intensity boundaries, not color boundaries. A red object next to a green object of equal brightness has a color edge but not an intensity edge. Converting to grayscale simplifies the problem to one channel and produces cleaner results.
- Save image_filters.py.
You won't see a visual change yet. The next substep adds another filter, and then you'll build the display grid to see everything at once.
Seeing a syntax error on save?
Check that the Sobel kernel arrays are formatted correctly. Each row inside the outer brackets must be its own list with matching commas.
Make sure the function is at the same indentation level as your other functions (not nested inside box_blur()).
Why is my edge_detection function causing a syntax error?
Apply a color threshold using boolean masking
Boolean masking lets you select pixels based on a condition. Instead of looping through every pixel to check if it meets your criteria, you create a True/False mask for the entire array at once.
- In image_filters.py, add the following color_threshold() function below edge_detection():
def color_threshold(image, channel=0, threshold=150):
"""Highlight pixels where a color channel exceeds a threshold."""
mask = image[:, :, channel] > threshold
result = np.zeros_like(image)
result[mask] = image[mask]
return result
What does this code do?
- image[:, :, channel] > threshold creates a boolean array the same size as the image. Each position is True if that pixel's red value exceeds 150, False otherwise.
- np.zeros_like(image) creates a black image (all zeros) with the same dimensions.
- result[mask] = image[mask] copies only the pixels where the mask is True into the black canvas. Everything else stays black.
Channel 0 is the red channel, so this filter highlights all pixels where red intensity exceeds 150 out of 255. This is the same kind of conditional logic you'd write with a loop, but NumPy applies it across millions of pixels simultaneously.
- Save image_filters.py.
You won't see a change yet. The next chunk builds the display grid that shows all your filters.
Unsure about the channel parameter?
Channel 0 is red, channel 1 is green, channel 2 is blue. The default channel=0 and threshold=150 mean the filter will isolate bright red pixels.
How does boolean masking work in NumPy for image filtering?
Create the final comparison grid
You now have 5 filter functions. The final piece ties everything together: a main() function that loads your image, applies every filter, and displays the results in a grid using Matplotlib.
- In image_filters.py, add the following main() function and entry point below color_threshold():
def main():
# Load the image
image = load_image("sample.jpg")
print(f"Image shape: {image.shape}")
print(f"Image dtype: {image.dtype}")
print(f"Pixel value range: {image.min()} to {image.max()}")
# Apply filters
gray = to_grayscale(image)
bright = adjust_brightness(image, value=50)
blurred = box_blur(image, size=5)
edges = edge_detection(image)
threshold = color_threshold(image, channel=0, threshold=150)
# Display results in a grid
fig, axes = plt.subplots(2, 3, figsize=(12, 8))
fig.suptitle("NumPy Image Filter Studio", fontsize=16)
titles = ["Original", "Grayscale", "Brightness +50",
"Box Blur (5x5)", "Edge Detection", "Red Threshold"]
images = [image, gray, bright, blurred, edges, threshold]
for ax, title, img in zip(axes.flat, titles, images):
ax.imshow(img)
ax.set_title(title)
ax.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()
What does this code do?
- load_image("sample.jpg") loads your sample image and the print statements show its dimensions, data type, and value range.
- Each filter function is called in sequence, storing the results in separate variables.
- plt.subplots(2, 3) creates a 2-row, 3-column grid of image panels.
- The for loop places each image in its panel with a title, using axes.flat to iterate through the grid cells in order.
- if __name__ == "__main__": ensures main() only runs when you execute the script directly, not when importing it.
- Save image_filters.py.
- Run the script by entering this command in your terminal:
python image_filters.py
A Matplotlib window should appear showing a 2x3 grid. The top row displays the original image, grayscale, and brightness-adjusted versions. The bottom row displays the blurred, edge-detected, and red-thresholded versions.
Don't see the Matplotlib window?
- Make sure your virtual environment is still active. You should see (my-env) in your terminal prompt.
- Verify that sample.jpg is in the same folder as image_filters.py. A FileNotFoundError means the image path is wrong.
- If you see an IndentationError, check that the main() function and the if __name__ block are at the top level (no indentation), not nested inside another function.
My image filter script isn't showing the Matplotlib grid. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and that all six panels display correctly in the grid.
ⓧ I'd like to double check the full code
Here is the complete image_filters.py file at this point:
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def load_image(path):
"""Load an image and convert to a NumPy array."""
img = Image.open(path).convert("RGB")
return np.array(img)
def to_grayscale(image):
"""Convert to grayscale using luminance weights (vectorized)."""
weights = np.array([0.2989, 0.5870, 0.1140])
gray = np.dot(image[:, :, :3], weights)
gray_3ch = np.stack([gray, gray, gray], axis=-1)
return gray_3ch.astype(np.uint8)
def adjust_brightness(image, value=50):
"""Adjust brightness using broadcasting."""
brightened = image.astype(np.int16) + value
return np.clip(brightened, 0, 255).astype(np.uint8)
def apply_kernel(channel, kernel):
"""Apply a convolution kernel to a single channel."""
kh, kw = kernel.shape
pad_h, pad_w = kh // 2, kw // 2
padded = np.pad(channel, ((pad_h, pad_h), (pad_w, pad_w)), mode="edge")
output = np.zeros_like(channel, dtype=np.float64)
for i in range(kh):
for j in range(kw):
output += padded[i:i + channel.shape[0], j:j + channel.shape[1]] * kernel[i, j]
return output
def box_blur(image, size=5):
"""Apply box blur using a uniform kernel."""
kernel = np.ones((size, size)) / (size * size)
result = np.zeros_like(image, dtype=np.uint8)
for c in range(3):
blurred_channel = apply_kernel(image[:, :, c].astype(np.float64), kernel)
result[:, :, c] = np.clip(blurred_channel, 0, 255).astype(np.uint8)
return result
def edge_detection(image):
"""Detect edges using Sobel operators."""
gray = np.dot(image[:, :, :3], [0.2989, 0.5870, 0.1140])
sobel_x = np.array([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]], dtype=np.float64)
sobel_y = np.array([[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]], dtype=np.float64)
gx = apply_kernel(gray, sobel_x)
gy = apply_kernel(gray, sobel_y)
magnitude = np.sqrt(gx**2 + gy**2)
magnitude = (magnitude / magnitude.max() * 255).astype(np.uint8)
return np.stack([magnitude, magnitude, magnitude], axis=-1)
def color_threshold(image, channel=0, threshold=150):
"""Highlight pixels where a color channel exceeds a threshold."""
mask = image[:, :, channel] > threshold
result = np.zeros_like(image)
result[mask] = image[mask]
return result
def main():
# Load the image
image = load_image("sample.jpg")
print(f"Image shape: {image.shape}")
print(f"Image dtype: {image.dtype}")
print(f"Pixel value range: {image.min()} to {image.max()}")
# Apply filters
gray = to_grayscale(image)
bright = adjust_brightness(image, value=50)
blurred = box_blur(image, size=5)
edges = edge_detection(image)
threshold = color_threshold(image, channel=0, threshold=150)
# Display results in a grid
fig, axes = plt.subplots(2, 3, figsize=(12, 8))
fig.suptitle("NumPy Image Filter Studio", fontsize=16)
titles = ["Original", "Grayscale", "Brightness +50",
"Box Blur (5x5)", "Edge Detection", "Red Threshold"]
images = [image, gray, bright, blurred, edges, threshold]
for ax, title, img in zip(axes.flat, titles, images):
ax.imshow(img)
ax.set_title(title)
ax.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()
Secret mission
Add a Sepia Tone Filter and a Pixelate Effect
You've built 5 filters using core NumPy operations. Now push further with two advanced techniques: matrix multiplication for a sepia tone, and array reshaping for a pixelation effect. These two filters unlock the most powerful NumPy concepts for real-world data manipulation.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder on your Desktop (contains image_filters.py, sample.jpg, filter_studio_output.png, and your my-env virtual environment).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with new filters.
- Your project folder stays on your Desktop, ready for you to reactivate the virtual environment and keep coding.
- To pick up where you left off, open a terminal in your project folder and activate the virtual environment again with my-env\Scripts\activate (Windows) or source my-env/bin/activate (macOS/Linux).
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files remain on disk.
- Deactivate the virtual environment by running:
deactivate
- Your project folder and all files remain on your Desktop. When you're ready to return, reactivate the virtual environment and run python image_filters.py to pick up where you left off.
Delete - I don't want to use this again
Remove all project files, the virtual environment, and generated outputs from your machine.
- Deactivate the virtual environment first (if it's still active) by running:
deactivate
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the entire project folder. On macOS/Linux, run:
rm -rf image-filter-studio
- On Windows, run:
rmdir /s /q image-filter-studio
- This removes image_filters.py, sample.jpg, filter_studio_output.png, and the my-env virtual environment. Python itself remains installed on your system for future projects.
Nice Work!
Nice Work!
You just built an image filter studio powered entirely by NumPy! Starting from a single image file, you transformed pixels using pure array operations and displayed professional comparison grids.
What you learned:
- Loaded images as multi-dimensional arrays and applied filters using vectorized operations and broadcasting instead of slow pixel-by-pixel loops.
- Implemented blur and edge detection filters by building a 2D convolution function with custom kernels using NumPy's array slicing and padding.
- Used boolean masking for color thresholding and displayed all results in a side-by-side grid with Matplotlib.
- Added a sepia tone filter using matrix multiplication and a pixelate effect using array reshaping and block averaging.
Ready to quiz yourself?