DevelopmentFebruary 10, 20257 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

LibraryBest ForGPU SupportInstall
PillowGeneral image manipulation❌ Nopip install Pillow
OpenCVComputer vision, video❌ Nopip install opencv-python
scikit-imageScientific image analysis❌ Nopip install scikit-image
imageioReading diverse formats❌ Nopip install imageio
torchvisionDeep learning pipelines✅ Yespip install torchvision
wandImageMagick bindings❌ Nopip install wand + system dep

For a web app focused on everyday image editing, Pillow is the simplest and most capable choice.


Prerequisites

  • Python 3.8+python.org
  • Libraries:
  • code
    pip install streamlit Pillow
    PackagePurpose
    streamlitWeb app framework
    PillowImage loading, manipulation, and saving
    ioIn-memory byte streams for download

    Supported Image Formats

    Pillow can read and write a wide range of formats:

    FormatExtensionNotes
    JPEG.jpg .jpegLossy compression, no transparency
    PNG.pngLossless, supports transparency
    WebP.webpModern format, lossy + lossless
    BMP.bmpUncompressed, large files
    GIF.gifAnimated support via Pillow
    TIFF.tiffHigh quality, used in print/science
    ICO.icoWindows icon format

    For this app we will support JPEG, PNG, and WebP uploads and allow export as JPEG or PNG.


    Project Structure

    code
    image_processor/
    ├── image_app.py        ← Main Streamlit application
    └── requirements.txt

    Step 1: Page Setup and Image Upload

    Create image_app.py:

    code
    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:

    code
    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:

    code
    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:

    code
    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:

    code
    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

    code
    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 / FunctionWhat 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

    FilterEffectUse Case
    BLURGaussian blur — softens detailsRemove noise, privacy blur
    SHARPENIncreases edge contrastEnhance scanned documents
    EDGE_ENHANCESubtly highlights edgesLight illustration effect
    CONTOURTraces edges onlyCartoon / sketch effect
    EMBOSSCreates a raised 3-D lookArtistic / texture effects
    SMOOTHAverages neighbouring pixelsReduce fine noise
    GrayscaleRemoves all colourBlack-and-white photography
    SepiaWarm brown mono tonesVintage / retro look
    InvertFlips all pixel valuesNegative film effect

    Image Colour Modes in Pillow

    ModeDescriptionChannels
    RGBStandard colour3 (Red, Green, Blue)
    RGBAColour with transparency4 (R, G, B, Alpha)
    LGreyscale1 (Luminance)
    CMYKPrint colour space4 (Cyan, Magenta, Yellow, Key)
    HSVHue-Saturation-Value3
    PPalette-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

    code
    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

    code
    streamlit run image_app.py

    Extending the App

    FeatureImplementation Hint
    Crop toolimg.crop((left, top, right, bottom))
    WatermarkUse ImageDraw.Draw(img).text((x,y), "text")
    Batch processingUpload multiple files with st.file_uploader(accept_multiple_files=True)
    Face blurCombine with OpenCV face detection then apply ImageFilter.GaussianBlur to detected regions
    Image comparison sliderUse streamlit-image-comparison component
    EXIF data displayRead metadata with img._getexif()
    Thumbnail generatorimg.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:

  • Pillow Documentation
  • Pillow Handbook
  • Pillow Filter Reference
  • Pillow ImageEnhance Reference
  • Streamlit st.image