Deploy an Image Converter With FastAPI and ImageMagick

XMLans Posted on 2025-06-20 20 Views


Sometimes a site needs PNG when your photo is JPEG, or you want a smaller WebP file for the web. My original project, Image-conversion, provides a simple conversion interface. The original hosted example is img.xmlans.com.

The interface accepts up to 20 selected files, offers PNG, JPEG, WebP, AVIF, TIFF, and GIF output choices, and creates a ZIP for a batch. Actual codec support depends on the installed ImageMagick build. The front-end batch limit is a usability control, not a server-side request-rate limit.

Install the dependencies

The original project was tested by its author with Python 3.13.1. This adapted example uses Python 3.10 or later syntax. On a supported Debian or Ubuntu system, install Python and ImageMagick, then create a virtual environment. Wand is a binding for ImageMagick; pip alone does not install the native image library. See the Wand installation guide.

sudo apt update
sudo apt install python3 python3-venv python3-pip imagemagick libmagickwand-dev
python3 -m venv .venv
source .venv/bin/activate
python -m pip install fastapi uvicorn wand python-multipart

Save the following backend as server.py and the front end as index.html. This edition translates the interface and adapts the original sample to use fixed temporary filenames, bound file sizes, and output cleanup. It also removes metadata from converted files; keep original images separately if you need their EXIF or profiles.

server.py

# Adapted from Star Dream Studio's original example.
from pathlib import Path
from tempfile import TemporaryDirectory
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from wand.image import Image
import uvicorn

app = FastAPI()
SUPPORTED = {"png", "jpeg", "webp", "avif", "tiff", "gif"}
MAX_BYTES = 20 * 1024 * 1024
MAX_PIXELS = 40_000_000
MEDIA = {"jpeg": "image/jpeg", "tiff": "image/tiff"}

@app.post("/convert")
def convert(file: UploadFile = File(...), format: str = Form(...),
            quality: int | None = Form(None)):
    target = format.lower()
    if target not in SUPPORTED:
        raise HTTPException(400, "Unsupported output format.")
    if quality is not None and not 1 <= quality <= 100:
        raise HTTPException(400, "Quality must be between 1 and 100.")
    temp = TemporaryDirectory(prefix="image-convert-")
    input_path = Path(temp.name) / "input.bin"
    output_path = Path(temp.name) / f"converted.{target}"
    try:
        total = 0
        with input_path.open("wb") as stream:
            while chunk := file.file.read(1024 * 1024):
                total += len(chunk)
                if total > MAX_BYTES:
                    raise HTTPException(413, "File exceeds 20 MiB.")
                stream.write(chunk)
        if total == 0:
            raise HTTPException(400, "Empty file.")
        # Configure ImageMagick policy limits before exposing this endpoint.
        with Image(filename=str(input_path)) as image:
            if sum(frame.width * frame.height for frame in image.sequence) > MAX_PIXELS:
                raise HTTPException(413, "Too many decoded pixels.")
            image.strip()  # Remove metadata; save originals separately if needed.
            image.format = target.upper()
            if target == "webp" and quality is not None:
                image.compression_quality = quality
            image.save(filename=str(output_path))
        return FileResponse(
            str(output_path),
            media_type=MEDIA.get(target, f"image/{target}"),
            filename=f"converted.{target}",
            background=BackgroundTask(temp.cleanup),
        )
    except HTTPException:
        temp.cleanup()
        raise
    except Exception:
        temp.cleanup()
        raise HTTPException(400, "Could not convert this image with the installed codecs.")
    finally:
        file.file.close()

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=9998)

Start and route the service

python server.py

The backend listens only on 127.0.0.1:9998. Serve index.html through your web server and proxy its /convert path to the backend on the same origin. This preserves the path rather than stripping it:

location = /convert {
    client_max_body_size 21m;
    proxy_pass http://127.0.0.1:9998;
    proxy_read_timeout 60s;
}

This fragment belongs inside an existing, correctly configured server block; it does not set up the hostname, TLS, or the front-end document root. For a local preview, configure a local web server similarly. Opening index.html directly as a file will not create the required /convert HTTP route.

Original image-converter interface screenshot

index.html

The front end retains the original visual layout and browser ZIP download approach. Its script libraries are loaded from third-party CDNs; for a public installation, use versions and assets you have reviewed and serve a built stylesheet rather than relying indefinitely on a development CDN. Test every format you enable, since AVIF and animated-file behavior can differ by codec build.

<!-- By Star Dream -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Image Format Converter</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <meta name="robots" content="index,follow">
  <link rel="icon" href="/favicon.ico" type="image/x-icon">
  <!-- JSZip & FileSaver for batch download -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
  <style>
    body { background: radial-gradient(ellipse at center, #e0c3fc 0%, #8ec5fc 100%); min-height:100vh; overflow-x:hidden; }
    .hero { background-attachment: fixed; background-position:center; background-size:cover; }
    .reveal { opacity:0; transform:translateY(30px); transition:opacity .6s ease-out,transform .6s ease-out; }
    .reveal.in-view { opacity:1; transform:translateY(0); }
    .btn { position:relative; overflow:hidden; }
    .btn .ripple { position:absolute; border-radius:50%; transform:scale(0); animation:ripple .6s linear; background:rgba(255,255,255,0.6); }
    @keyframes ripple { to{transform:scale(4);opacity:0;} }
    .upload-area { border:2px dashed #aaa; padding:2rem; border-radius:1rem; cursor:pointer; transition:border-color .3s; }
    .upload-area:hover { border-color:#6b21a8; }
    #progressContainer { display:none; width:100%; background:#e5e7eb; border-radius:9999px; height:.5rem; margin-top:.5rem; position:relative; }
    #progressBar { height:100%; width:0; background:#8b5cf6; transition:width .2s; border-radius:9999px; }
    #percentLabel { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:.875rem; color:#374151; }
    #preview img { margin:.25rem; max-height:100px; border-radius:.5rem; }
    footer { text-align:center; margin-top:2rem; font-size:.75rem; color:#9ca3af; }
  </style>
</head>
<body class="hero flex items-center justify-center min-h-screen px-4 text-gray-800">
  <div class="bg-white bg-opacity-90 backdrop-blur-lg shadow-xl rounded-3xl max-w-2xl w-full p-8 space-y-6 text-center reveal" data-reveal>
    <h1 class="text-4xl sm:text-5xl font-bold text-purple-700 mb-4">Image Format Converter</h1>
    <div id="upload" class="upload-area text-gray-500 reveal" data-reveal>
      <p>Click or drop images here (up to 20 files)</p>
      <input id="fileInput" type="file" accept="image/*" class="hidden" multiple />
    </div>
    <div id="preview" class="reveal flex flex-wrap justify-center" data-reveal></div>
    <div class="space-y-2 reveal" data-reveal>
      <label for="format" class="block text-left text-gray-700">Output format:</label>
      <select id="format" class="w-full border border-gray-300 rounded p-2">
        <option value="png">PNG</option>
        <option value="jpeg">JPEG</option>
        <option value="webp">WebP</option>
        <option value="avif">AVIF</option>
        <option value="tiff">TIFF</option>
        <option value="gif">GIF</option>
      </select>
    </div>
    <div id="qualityContainer" class="space-y-2 reveal hidden" data-reveal>
      <label for="quality" class="block text-left text-gray-700">WebP quality (<span id="qualityValue">80</span>):</label>
      <input type="range" id="quality" min="1" max="100" value="80" class="w-full" />
    </div>
    <div class="flex justify-center gap-4 reveal" data-reveal>
      <button id="convertBtn" class="btn bg-pink-400 hover:bg-pink-500 text-white py-2 px-6 rounded-full shadow transition disabled:opacity-50" disabled>Convert</button>
    </div>
    <div id="progressContainer" class="reveal" data-reveal>
      <div id="progressBar"></div>
      <span id="percentLabel">0%</span>
    </div>
    <p id="msg" class="text-sm text-red-500 reveal" data-reveal>Image conversion demo adapted from Star Dream Studio</p>
    <footer>© 2025 Star Dream™ All rights reserved.</footer>
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      const MAX_FILES = 20;
      const reveals = document.querySelectorAll('[data-reveal]');
      const obs = new IntersectionObserver((entries) => {
        entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in-view'); obs.unobserve(e.target); }});
      }, { threshold: 0.2 }); reveals.forEach(el => obs.observe(el));

      document.querySelectorAll('.btn').forEach(btn => btn.addEventListener('click', e => {
        const c = document.createElement('span'), d = Math.max(btn.clientWidth, btn.clientHeight);
        c.style.width = c.style.height = d + 'px'; c.className = 'ripple'; btn.appendChild(c);
        const r = btn.getBoundingClientRect(); c.style.left = e.clientX - r.left - d/2 + 'px';
        c.style.top = e.clientY - r.top - d/2 + 'px'; setTimeout(() => c.remove(), 600);
      }));

      const uploadArea = document.getElementById('upload'), fileInput = document.getElementById('fileInput');
      const preview = document.getElementById('preview'), formatSelect = document.getElementById('format');
      const qualityContainer = document.getElementById('qualityContainer'), qualityInput = document.getElementById('quality');
      const qualityValue = document.getElementById('qualityValue'), convertBtn = document.getElementById('convertBtn');
      const progressContainer = document.getElementById('progressContainer'), progressBar = document.getElementById('progressBar');
      const percentLabel = document.getElementById('percentLabel'), msg = document.getElementById('msg');
      let selectedFiles = [];

      formatSelect.addEventListener('change', () => {
        formatSelect.value === 'webp'
          ? qualityContainer.classList.remove('hidden')
          : qualityContainer.classList.add('hidden');
      }); qualityInput.addEventListener('input', () => qualityValue.textContent = qualityInput.value);

      function handleFile() {
        const files = Array.from(fileInput.files);
        if (!files.length) return;
        if (files.length > MAX_FILES) {
          msg.textContent = `Choose no more than ${MAX_FILES} images.`;
          preview.innerHTML = '';
          convertBtn.disabled = true;
          return;
        }
        preview.innerHTML = '';
        msg.textContent = '';
        selectedFiles = files.filter(f => f.type.startsWith('image/'));
        if (!selectedFiles.length) { msg.textContent = 'Please select image files.'; return; }
        selectedFiles.forEach(file => {
          const rd = new FileReader();
          rd.onload = e => { const img = document.createElement('img'); img.src = e.target.result; preview.appendChild(img); };
          rd.readAsDataURL(file);
        });
        convertBtn.disabled = false;
        convertBtn.textContent = selectedFiles.length > 1 ? 'Convert batch' : 'Convert';
      }

      uploadArea.addEventListener('click', () => fileInput.click());
      uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('border-purple-600'); });
      uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('border-purple-600'));
      uploadArea.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('border-purple-600'); fileInput.files = e.dataTransfer.files; handleFile(); });
      fileInput.addEventListener('change', handleFile);

      convertBtn.addEventListener('click', () => {
        if (!selectedFiles.length) return;
        convertBtn.disabled = true; msg.textContent = '';
        progressContainer.style.display = 'block';
        let completed = 0, total = selectedFiles.length;
        progressBar.style.width = '0%'; percentLabel.textContent = '0%';
        const zip = new JSZip();
        const outputFormat = formatSelect.value;
        let failed = 0;
        formatSelect.disabled = true; qualityInput.disabled = true;
        selectedFiles.forEach(file => {
          const xhr = new XMLHttpRequest(); xhr.open('POST', '/convert'); xhr.responseType = 'blob'; xhr.timeout = 60000;
          xhr.onload = () => {
            if (xhr.status === 200) {
              const blob = xhr.response; let ext = outputFormat;
              if (total > 1) {
                zip.file((completed + 1) + '-' + file.name.replace(/\.[^.]+$/, '') + '.' + ext, blob);
              } else {
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a'); a.href = url;
                a.download = selectedFiles[0].name.replace(/\.[^.]+$/, '') + '.' + ext;
                document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
              }
            } else { failed++; }
            completed++;
            const pct = Math.round(completed / total * 100);
            progressBar.style.width = pct + '%'; percentLabel.textContent = pct + '%';
            if (completed === total) {
              if (total > 1) {
                zip.generateAsync({ type: 'blob' }).then(content => { saveAs(content, 'converted_images.zip'); msg.textContent = failed ? `Download started; ${failed} conversion(s) failed.` : 'Batch download started'; });
              }
              convertBtn.disabled = false; formatSelect.disabled = false; qualityInput.disabled = false;
              if (failed) msg.textContent = `${failed} conversion(s) failed.`;
            }
          };
          xhr.onerror = xhr.ontimeout = () => {
            failed++; completed++;
            msg.textContent = 'A request failed. Retry the affected files.';
            if (completed === total) {
              if (total > 1) zip.generateAsync({type: 'blob'}).then(content => saveAs(content, 'converted_images.zip'));
              convertBtn.disabled = false; formatSelect.disabled = false; qualityInput.disabled = false;
            }
          };
          const form = new FormData(); form.append('file', file); form.append('format', formatSelect.value);
          if (formatSelect.value === 'webp') form.append('quality', qualityInput.value);
          xhr.send(form);
        });
      });
    });
  </script>
</body>
</html>

Before making it public

The backend checks a 20 MiB file limit and a decoded-pixel limit, but decoding itself happens before that pixel check. Apply ImageMagick security policies for memory, disk, dimensions, delegates, and allowed input coders. Add reverse-proxy rate limits and appropriate access control for your use case. A client’s “20 files” message cannot prevent direct API requests.

The sample removes temporary output after the response and reports conversion errors without exposing internal exception details. It is an instructional adaptation, not a claim that a public deployment has been load-tested. Try a small valid file, an unsupported or malformed input, a large file, and a batch before sharing the tool.

Adapted from the original Chinese article, published on June 20, 2025.

Hi! I frequently update with various articles about technology, practical tips, and cutting-edge news. I hope it will be helpful to you!
Last updated on 2026-09-30