Introduction
Image processing is one of those skills that bridges creative work and technical programming. Whether you want to batch-resize product photos, apply Instagram-style filters, build a thumbnail generator, or experiment with computer vision pipelines, the ability to manipulate images programmatically is incredibly powerful.
In this guide you will build a fully featured Image Processing web app using Python, Pillow (the most popular Python imaging library), and Streamlit. Users can upload any image, apply a range of adjustments and filters, preview the result in real time, and download the processed file.
This project builds naturally on other tools shown on this blog. You could feed images generated by an AI tool into this app for post-processing, or combine it with the Data Dashboard to analyse pixel statistics. If you are new to Streamlit, the Age Calculator App is a great first project to get comfortable with the framework.
What is Pillow?
Pillow is a fork of the original PIL (Python Imaging Library) and is the de-facto standard for image manipulation in Python. It supports reading and writing dozens of image formats, and provides a clean API for resizing, cropping, rotating, colour adjustments, and filter application.
Pillow vs Other Python Image Libraries
| Library | Best For | GPU Support | Install |
|---|---|---|---|
| Pillow | General image manipulation | ❌ No | pip install Pillow |
| OpenCV | Computer vision, video | ❌ No | pip install opencv-python |
| scikit-image | Scientific image analysis | ❌ No | pip install scikit-image |
| imageio | Reading diverse formats | ❌ No | pip install imageio |
| torchvision | Deep learning pipelines | ✅ Yes | pip install torchvision |
| wand | ImageMagick bindings | ❌ No | pip install wand + system dep |
For a web app focused on everyday image editing, Pillow is the simplest and most capable choice.
Prerequisites
pip install streamlit Pillow| Package | Purpose |
|---|---|
streamlit | Web app framework |
Pillow | Image loading, manipulation, and saving |
io | In-memory byte streams for download |
Supported Image Formats
Pillow can read and write a wide range of formats:
| Format | Extension | Notes |
|---|---|---|
| JPEG | .jpg .jpeg | Lossy compression, no transparency |
| PNG | .png | Lossless, supports transparency |
| WebP | .webp | Modern format, lossy + lossless |
| BMP | .bmp | Uncompressed, large files |
| GIF | .gif | Animated support via Pillow |
| TIFF | .tiff | High quality, used in print/science |
| ICO | .ico | Windows icon format |
For this app we will support JPEG, PNG, and WebP uploads and allow export as JPEG or PNG.
Project Structure
image_processor/
├── image_app.py ← Main Streamlit application
└── requirements.txtStep 1: Page Setup and Image Upload
Create image_app.py:
import io
import streamlit as st
from PIL import Image, ImageFilter, ImageEnhance, ImageOps
st.set_page_config(
page_title="Image Processor",
page_icon="🖼️",
layout="wide"
)
st.title("🖼️ Image Processor")
st.write(
"Upload an image, apply adjustments and filters, "
"then download the result."
)
uploaded_file = st.file_uploader(
"Upload an image",
type=["jpg", "jpeg", "png", "webp", "bmp"],
)
if uploaded_file is None:
st.info("Please upload an image to get started.")
st.stop()
original_image = Image.open(uploaded_file).convert("RGB")The .convert("RGB") call ensures the image is always in the standard 3-channel format, which prevents issues with greyscale or RGBA images later in the pipeline.
Step 2: Show Image Info
Display the original image's metadata before any processing:
col_info1, col_info2, col_info3, col_info4 = st.columns(4)
col_info1.metric("Width", f"{original_image.width} px")
col_info2.metric("Height", f"{original_image.height} px")
col_info3.metric("Mode", original_image.mode)
col_info4.metric(
"File Size",
f"{uploaded_file.size / 1024:.1f} KB"
)Step 3: Build the Sidebar Controls
All adjustments are controlled from the sidebar so the main area stays clean for the image comparison:
st.sidebar.header("⚙️ Adjustments")
# --- Resize ---
st.sidebar.subheader("📐 Resize")
enable_resize = st.sidebar.toggle("Enable Resize", value=False)
if enable_resize:
new_width = st.sidebar.number_input(
"Width (px)", min_value=10, max_value=5000,
value=original_image.width
)
new_height = st.sidebar.number_input(
"Height (px)", min_value=10, max_value=5000,
value=original_image.height
)
keep_aspect = st.sidebar.checkbox("Keep aspect ratio", value=True)
# --- Rotate ---
st.sidebar.subheader("🔄 Rotate")
rotation = st.sidebar.slider("Rotation (degrees)", -180, 180, 0, step=5)
# --- Colour Adjustments ---
st.sidebar.subheader("🎨 Colour")
brightness = st.sidebar.slider("Brightness", 0.1, 3.0, 1.0, step=0.05)
contrast = st.sidebar.slider("Contrast", 0.1, 3.0, 1.0, step=0.05)
saturation = st.sidebar.slider("Saturation", 0.0, 3.0, 1.0, step=0.05)
sharpness = st.sidebar.slider("Sharpness", 0.0, 3.0, 1.0, step=0.05)
# --- Filters ---
st.sidebar.subheader("✨ Filters")
filter_choice = st.sidebar.selectbox(
"Apply Filter",
["None", "Blur", "Sharpen", "Edge Enhance", "Contour",
"Emboss", "Smooth", "Grayscale", "Sepia", "Invert"]
)
# --- Flip ---
st.sidebar.subheader("↔️ Flip")
flip_h = st.sidebar.checkbox("Flip Horizontal")
flip_v = st.sidebar.checkbox("Flip Vertical")Step 4: Apply the Adjustments
Process the image step by step using the sidebar values:
def apply_sepia(img: Image.Image) -> Image.Image:
"""Apply a warm sepia tone to an RGB image."""
r, g, b = img.split()
r2 = r.point(lambda i: min(int(i * 0.393 + g.getextrema()[1] * 0.769
+ b.getextrema()[1] * 0.189), 255))
# Simplified sepia using ImageOps
grey = ImageOps.grayscale(img)
sepia = Image.merge("RGB", [
grey.point(lambda p: min(int(p * 1.08), 255)),
grey.point(lambda p: int(p * 0.88)),
grey.point(lambda p: int(p * 0.63)),
])
return sepia
# Start with the original
processed = original_image.copy()
# 1. Resize
if enable_resize:
if keep_aspect:
processed.thumbnail((new_width, new_height), Image.LANCZOS)
else:
processed = processed.resize((new_width, new_height), Image.LANCZOS)
# 2. Rotate
if rotation != 0:
processed = processed.rotate(rotation, expand=True, fillcolor=(0, 0, 0))
# 3. Flip
if flip_h:
processed = ImageOps.mirror(processed)
if flip_v:
processed = ImageOps.flip(processed)
# 4. Colour adjustments
if brightness != 1.0:
processed = ImageEnhance.Brightness(processed).enhance(brightness)
if contrast != 1.0:
processed = ImageEnhance.Contrast(processed).enhance(contrast)
if saturation != 1.0:
processed = ImageEnhance.Color(processed).enhance(saturation)
if sharpness != 1.0:
processed = ImageEnhance.Sharpness(processed).enhance(sharpness)
# 5. Filters
FILTER_MAP = {
"Blur": ImageFilter.BLUR,
"Sharpen": ImageFilter.SHARPEN,
"Edge Enhance": ImageFilter.EDGE_ENHANCE,
"Contour": ImageFilter.CONTOUR,
"Emboss": ImageFilter.EMBOSS,
"Smooth": ImageFilter.SMOOTH,
}
if filter_choice == "Grayscale":
processed = ImageOps.grayscale(processed).convert("RGB")
elif filter_choice == "Sepia":
processed = apply_sepia(processed)
elif filter_choice == "Invert":
processed = ImageOps.invert(processed)
elif filter_choice in FILTER_MAP:
processed = processed.filter(FILTER_MAP[filter_choice])Step 5: Side-by-Side Comparison
Display the original and processed images side by side:
st.divider()
st.subheader("🖼️ Before vs After")
col_orig, col_proc = st.columns(2)
with col_orig:
st.write("**Original**")
st.image(original_image, use_column_width=True)
st.caption(f"{original_image.width} × {original_image.height} px")
with col_proc:
st.write("**Processed**")
st.image(processed, use_column_width=True)
st.caption(f"{processed.width} × {processed.height} px")Step 6: Download the Processed Image
st.divider()
st.subheader("⬇️ Download")
col_fmt, col_quality = st.columns(2)
with col_fmt:
output_format = st.radio("Format", ["PNG", "JPEG"], horizontal=True)
with col_quality:
quality = 95
if output_format == "JPEG":
quality = st.slider("JPEG Quality", 50, 100, 95)
buf = io.BytesIO()
if output_format == "JPEG":
processed.save(buf, format="JPEG", quality=quality, optimize=True)
mime = "image/jpeg"
ext = "jpg"
else:
processed.save(buf, format="PNG", optimize=True)
mime = "image/png"
ext = "png"
buf.seek(0)
st.download_button(
label=f"⬇️ Download as {output_format}",
data=buf,
file_name=f"processed.{ext}",
mime=mime,
use_container_width=True,
)Pillow Key Concepts
| Class / Function | What It Does |
|---|---|
Image.open(file) | Opens an image from a file or buffer |
img.convert("RGB") | Converts to the specified colour mode |
img.resize((w, h), Image.LANCZOS) | Resizes with high-quality downsampling |
img.thumbnail((w, h)) | Resizes in-place while preserving aspect ratio |
img.rotate(deg, expand=True) | Rotates; expand=True grows canvas to fit |
img.filter(ImageFilter.BLUR) | Applies a kernel-based filter |
ImageEnhance.Brightness(img).enhance(f) | Multiplies brightness by factor f |
ImageEnhance.Contrast(img).enhance(f) | Adjusts contrast |
ImageEnhance.Color(img).enhance(f) | Adjusts colour saturation |
ImageEnhance.Sharpness(img).enhance(f) | Adjusts sharpness / blur |
ImageOps.grayscale(img) | Converts to greyscale |
ImageOps.mirror(img) | Flips horizontally |
ImageOps.flip(img) | Flips vertically |
ImageOps.invert(img) | Inverts all pixel values |
img.save(buf, format="PNG") | Saves to file or byte buffer |
Available Filters Explained
| Filter | Effect | Use Case |
|---|---|---|
BLUR | Gaussian blur — softens details | Remove noise, privacy blur |
SHARPEN | Increases edge contrast | Enhance scanned documents |
EDGE_ENHANCE | Subtly highlights edges | Light illustration effect |
CONTOUR | Traces edges only | Cartoon / sketch effect |
EMBOSS | Creates a raised 3-D look | Artistic / texture effects |
SMOOTH | Averages neighbouring pixels | Reduce fine noise |
| Grayscale | Removes all colour | Black-and-white photography |
| Sepia | Warm brown mono tones | Vintage / retro look |
| Invert | Flips all pixel values | Negative film effect |
Image Colour Modes in Pillow
| Mode | Description | Channels |
|---|---|---|
RGB | Standard colour | 3 (Red, Green, Blue) |
RGBA | Colour with transparency | 4 (R, G, B, Alpha) |
L | Greyscale | 1 (Luminance) |
CMYK | Print colour space | 4 (Cyan, Magenta, Yellow, Key) |
HSV | Hue-Saturation-Value | 3 |
P | Palette-mapped (e.g. GIF) | 1 (index) |
Always call .convert("RGB") when loading images of unknown origin — this normalises any mode into the safe 3-channel format that all Pillow operations support.
Complete image_app.py
import io
import streamlit as st
from PIL import Image, ImageFilter, ImageEnhance, ImageOps
st.set_page_config(page_title="Image Processor", page_icon="🖼️", layout="wide")
st.title("🖼️ Image Processor")
uploaded = st.file_uploader("Upload image", type=["jpg","jpeg","png","webp","bmp"])
if not uploaded: st.stop()
original = Image.open(uploaded).convert("RGB")
st.sidebar.header("⚙️ Controls")
rotation = st.sidebar.slider("Rotation", -180, 180, 0, 5)
brightness = st.sidebar.slider("Brightness", 0.1, 3.0, 1.0, 0.05)
contrast = st.sidebar.slider("Contrast", 0.1, 3.0, 1.0, 0.05)
saturation = st.sidebar.slider("Saturation", 0.0, 3.0, 1.0, 0.05)
sharpness = st.sidebar.slider("Sharpness", 0.0, 3.0, 1.0, 0.05)
filter_choice = st.sidebar.selectbox("Filter",
["None","Blur","Sharpen","Edge Enhance","Contour","Emboss","Grayscale","Sepia","Invert"])
flip_h = st.sidebar.checkbox("Flip Horizontal")
flip_v = st.sidebar.checkbox("Flip Vertical")
img = original.copy()
if rotation: img = img.rotate(rotation, expand=True)
if flip_h: img = ImageOps.mirror(img)
if flip_v: img = ImageOps.flip(img)
if brightness != 1.0: img = ImageEnhance.Brightness(img).enhance(brightness)
if contrast != 1.0: img = ImageEnhance.Contrast(img).enhance(contrast)
if saturation != 1.0: img = ImageEnhance.Color(img).enhance(saturation)
if sharpness != 1.0: img = ImageEnhance.Sharpness(img).enhance(sharpness)
FILTERS = {"Blur": ImageFilter.BLUR, "Sharpen": ImageFilter.SHARPEN,
"Edge Enhance": ImageFilter.EDGE_ENHANCE, "Contour": ImageFilter.CONTOUR,
"Emboss": ImageFilter.EMBOSS}
if filter_choice == "Grayscale": img = ImageOps.grayscale(img).convert("RGB")
elif filter_choice == "Invert": img = ImageOps.invert(img)
elif filter_choice in FILTERS: img = img.filter(FILTERS[filter_choice])
col1, col2 = st.columns(2)
col1.write("**Original**"); col1.image(original, use_column_width=True)
col2.write("**Processed**"); col2.image(img, use_column_width=True)
st.divider()
fmt = st.radio("Export format", ["PNG", "JPEG"], horizontal=True)
buf = io.BytesIO()
img.save(buf, format=fmt)
buf.seek(0)
st.download_button(f"⬇️ Download {fmt}", buf, f"processed.{fmt.lower()}",
f"image/{fmt.lower()}", use_container_width=True)Run the App
streamlit run image_app.pyExtending the App
| Feature | Implementation Hint |
|---|---|
| Crop tool | img.crop((left, top, right, bottom)) |
| Watermark | Use ImageDraw.Draw(img).text((x,y), "text") |
| Batch processing | Upload multiple files with st.file_uploader(accept_multiple_files=True) |
| Face blur | Combine with OpenCV face detection then apply ImageFilter.GaussianBlur to detected regions |
| Image comparison slider | Use streamlit-image-comparison component |
| EXIF data display | Read metadata with img._getexif() |
| Thumbnail generator | img.thumbnail((300, 300)) then save as PNG |
Related Projects
st.dataframe and charts — same Streamlit patterns applied to tabular data instead of images.Conclusion
You have built a polished Image Processing web app using Python, Pillow, and Streamlit. The app supports brightness, contrast, saturation, sharpness adjustments, nine different filters, rotation, flipping, and JPEG/PNG export — all in a clean side-by-side comparison UI.
Pillow's simple, consistent API makes complex image operations feel trivial, and Streamlit's widget system turns sliders and toggles into real-time controls with zero JavaScript.
Resources: