Tech With Galvan
Back to Articles
Development2/10/2025 • 7 min read

Image Processing with Pillow and Streamlit

Process images with Python, Pillow, and Streamlit — resize, filter, rotate, and watermark photos through a live web UI.

Galvan
Galvan

Founder & Creator

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.txt

Step 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.py

Extending 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

  • Data Dashboard with Pandas — A data-exploration app that uses st.dataframe and charts — same Streamlit patterns applied to tabular data instead of images.
  • SANGAM AI Toolkit — Includes an AI image generation module that you can combine with this processor for post-processing generated images.
  • Chatbot with Mistral AI — Another multi-panel Streamlit app using session state and sidebar controls, great reference for UI patterns.

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: