Image processing APIs enable developers to programmatically manipulate and transform images. In this tutorial, we'll build a straightforward yet functional image processing API using Python and Flask, demonstrating how to handle common operations like resizing and format conversion. This demo also showcases robust error handling, rate limiting, and secure file validation practices.

Introduction to image processing APIs

An image processing API provides endpoints that accept image files as input, perform specified operations, and return the processed images. These APIs are essential for applications that need to handle user-uploaded images, generate thumbnails, or convert between different image formats.

Setting up the development environment

Begin by setting up your development environment:

  1. Create a new directory for your project and a requirements.txt file:
mkdir flask-image-api
cd flask-image-api
touch requirements.txt
  1. Add the following dependencies to requirements.txt:
Flask==3.1.0
Pillow==11.1.0
gunicorn==23.0.0
flask-cors==4.0.0
flask-limiter==3.5.0
  1. Set up a virtual environment and install dependencies:
python3 -m venv venv
source venv/bin/activate  # On Windows use `venv\Scripts\activate`
pip install -r requirements.txt

Creating a basic Flask application

Create a file named app.py with the following content:

from flask import Flask, request, send_file
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from PIL import Image
import io

app = Flask(__name__)
CORS(app)
limiter = Limiter(app, key_func=get_remote_address)

# Configure maximum file size (16 mb)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024

# Allowed file extensions
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/')
def index():
    return 'Welcome to the Image Processing API!'

if __name__ == '__main__':
    app.run(debug=True)

Integrating pillow for image processing

Create a helper function to load and validate images. This function checks both the file extension and its MIME type, ensuring only valid image files are processed:

def load_image(file):
    try:
        if not allowed_file(file.filename) or not file.mimetype.startswith('image/'):
            return None
        img = Image.open(file)
        img.verify()  # Verify image integrity
        file.seek(0)  # Reset file pointer after verification
        return Image.open(file)
    except Exception:
        return None

Implementing image resizing endpoint

Add an endpoint for resizing images with appropriate error handling and validation:

@app.route('/resize', methods=['POST'])
@limiter.limit('100 per day')
def resize_image():
    try:
        if 'file' not in request.files:
            return {'error': 'No file part'}, 400

        file = request.files['file']
        if file.filename == '':
            return {'error': 'No selected file'}, 400

        img = load_image(file)
        if img is None:
            return {'error': 'Invalid image file'}, 400

        width = int(request.form.get('width', 100))
        height = int(request.form.get('height', 100))

        if width <= 0 or height <= 0:
            return {'error': 'Invalid dimensions'}, 400

        resized_img = img.resize((width, height))

        img_io = io.BytesIO()
        resized_img.save(img_io, format=img.format or 'JPEG')
        img_io.seek(0)

        return send_file(
            img_io,
            mimetype=f'image/{img.format.lower() if img.format else "jpeg"}'
        )

    except Exception as e:
        return {'error': str(e)}, 500

Adding image format conversion endpoint

Include another endpoint to handle image format conversion. This endpoint accepts a target format, converts the image accordingly, and handles any potential errors:

@app.route('/convert', methods=['POST'])
@limiter.limit('100 per day')
def convert_image():
    try:
        if 'file' not in request.files:
            return {'error': 'No file part'}, 400

        file = request.files['file']
        if file.filename == '':
            return {'error': 'No selected file'}, 400

        img = load_image(file)
        if img is None:
            return {'error': 'Invalid image file'}, 400

        target_format = request.form.get('format', 'PNG').upper()
        if target_format not in ['PNG', 'JPEG', 'GIF']:
            return {'error': 'Unsupported format'}, 400

        img_io = io.BytesIO()
        if target_format == 'JPEG':
            img = img.convert('RGB')  # Remove alpha channel for JPEG compatibility
        img.save(img_io, format=target_format)
        img_io.seek(0)

        return send_file(img_io, mimetype=f'image/{target_format.lower()}')

    except Exception as e:
        return {'error': str(e)}, 500

Testing the API with sample requests

Use cURL to test the endpoints. The following examples demonstrate how to call the resizing and conversion endpoints:

# Resize an image
curl -fsSL -X POST -F "file=@path/to/image.jpg" -F "width=300" -F "height=200" \
  http://localhost:5000/resize -o resized_image.jpg

# Convert an image to PNG
curl -fsSL -X POST -F "file=@path/to/image.jpg" -F "format=PNG" \
  http://localhost:5000/convert -o converted_image.png

Error handling and input validation

For production, comprehensive testing is essential. Create a file named test_app.py with the following tests to validate the API's functionality:

import pytest
from app import app
import io
from PIL import Image

@pytest.fixture
def client():
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client


def test_resize_endpoint(client):
    # Create a test image in memory
    img_io = io.BytesIO()
    Image.new('RGB', (100, 100)).save(img_io, 'JPEG')
    img_io.seek(0)

    data = {
        'file': (img_io, 'test.jpg'),
        'width': '50',
        'height': '50'
    }
    response = client.post('/resize', data=data)
    assert response.status_code == 200


def test_invalid_file(client):
    data = {
        'file': (io.BytesIO(b'invalid'), 'test.txt')
    }
    response = client.post('/resize', data=data)
    assert response.status_code == 400

Deploying the API to a server

For production deployment, use Gunicorn along with proper configuration. Create a Gunicorn configuration file named gunicorn_config.py:

import multiprocessing

workers = multiprocessing.cpu_count() * 2 + 1
bind = '127.0.0.1:8000'
keepalive = 120
errorlog = '/var/log/gunicorn/error.log'
accesslog = '/var/log/gunicorn/access.log'
worker_class = 'sync'
max_requests = 1000
max_requests_jitter = 50
timeout = 120

Then, create a systemd service file at /etc/systemd/system/flask-image-api.service:

[Unit]
Description=Flask Image Processing API
After=network.target

[Service]
User=www-data
WorkingDirectory=/path/to/flask-image-api
Environment="PATH=/path/to/flask-image-api/venv/bin"
ExecStart=/path/to/flask-image-api/venv/bin/gunicorn -c gunicorn_config.py app:app

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl enable flask-image-api
sudo systemctl start flask-image-api

Conclusion and next steps

We have built a secure, efficient, and scalable image processing API using Python, Flask, and Pillow. The API incorporates essential features like file validation, error handling, rate limiting, and CORS support. To extend this solution, you might consider options such as image compression, user authentication, batch processing, cloud storage integration, or metadata extraction.

For a comprehensive solution with advanced file processing capabilities, consider exploring Transloadit, which offers a robust platform for handling and transforming files in your applications.