Removing the background from one photo is a solved problem. Removing it from a folder of 500 every day, and knowing which of the 500 came out wrong, is the part that takes engineering.
This tutorial builds a small batch tool around the API4AI Background Removal API. It cuts out a whole folder of product photos, writes transparent PNGs next to nothing it shouldn't touch, and produces a report that tells a person which handful of results to open. It's about 100 lines of Python with requests and Pillow.
One request
curl -X POST "https://api4ai.cloud/img-bg-removal/v1/results" \
-H "X-API-KEY: $API4AI_KEY" \
-F "image=@shoes.jpg"
The photo goes in as multipart form data: a file in image, or a public link in url. JPEG or PNG, under 16 MB, at most 4096×4096. For photos of people there's a separate People Background Removal API, tuned for them.
Two options worth knowing:
-
?mode=fg-maskreturns a black-and-white mask instead of the cut-out, if you'd rather apply the cut yourself. - An
image-bg(orurl-bg) field puts your own background behind the object in the same request.
What comes back
The result is JSON, and the cut-out is inside it as base64:
{
"results": [
{
"status": { "code": "ok", "message": "Success" },
"name": "shoes.jpg",
"width": 1500,
"height": 1000,
"entities": [
{ "kind": "image", "name": "general-fg-image",
"format": "PNG", "image": "iVBORw0KGgo..." },
{ "kind": "objects", "name": "objects", "objects": [] }
]
}
]
}
The image entity is a 4-channel PNG: the object keeps its original pixels, the background is transparent, and pixels along the edge can be partly transparent.
The lesson from the HTTP 200
The single most useful line in this script is the one that checks status.code.
When the service can't process a photo, because it's corrupted, isn't really a JPEG, or is bigger than 4096×4096, it still answers HTTP 200. The JSON says "failure" and gives the reason:
{ "status": { "code": "failure",
"message": "Resolution is too big: 6000x4000. Max allowed resolution: 4096x4096." } }
Code that only checks raise_for_status() treats that as a success, writes nothing, and moves on. Run that over 500 photos and you lose a few without a trace. In a batch tool, every failure has to end up in the report with its reason, and the counts at the end have to add up.
Flagging the results a person should see
The API returns a cut-out, not a verdict on it. But the alpha channel of that cut-out tells you a lot, and it costs nothing to read. The script checks four things:
- Almost nothing kept (under 3% of the frame): the model probably didn't find the subject you expected.
- Almost nothing removed (over 90%): the background may have been kept.
- The subject touches the edge of the frame: the product was cropped in the photo, or part of the background survived.
- Lots of soft edges (semi-transparent pixels over 15% of the object): normal around hair, a warning sign for glass, lace, smoke or blur.
None of these say a result is wrong. They say it's worth a look. The thresholds are a starting point: run a day's photos, look at what gets flagged and what doesn't, and move them.
The script
"""Cut out a folder of photos and flag the ones a person should check.
pip install requests pillow
export API4AI_KEY=... # from portal.api4.ai
python3 bulk_cutout.py photos/ cutouts/
Originals are never touched. Each result is a transparent PNG at the same relative
path under cutouts/, and cutouts/report.csv says which ones to look at and why.
"""
import base64
import csv
import io
import os
import pathlib
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from PIL import Image
URL = "https://api4ai.cloud/img-bg-removal/v1/results"
KEY = os.environ["API4AI_KEY"]
PHOTOS = {".jpg", ".jpeg", ".png"}
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
def call_api(photo, tries=4):
for attempt in range(tries):
with photo.open("rb") as f:
r = requests.post(URL, headers={"X-API-KEY": KEY},
files={"image": f}, timeout=120)
if r.status_code in (429, 500, 502, 503, 504) and attempt < tries - 1:
time.sleep(2 ** attempt) # 1, 2, 4 s
continue
r.raise_for_status()
return r.json()["results"][0]
def checks(png):
"""Cheap signals from the alpha channel. Thresholds are a starting point."""
alpha = png.getchannel("A")
w, h = alpha.size
hist = alpha.histogram()
total, solid = w * h, sum(hist[128:])
soft = sum(hist[1:255])
border = [alpha.getpixel((x, y)) for x in range(w) for y in (0, h - 1)]
border += [alpha.getpixel((x, y)) for y in range(h) for x in (0, w - 1)]
flags = []
if solid < 0.03 * total:
flags.append("almost nothing kept")
if solid > 0.90 * total:
flags.append("almost nothing removed")
if max(border) > 128:
flags.append("touches the edge")
if soft > 0.15 * max(solid, 1):
flags.append("lots of soft edges")
return solid / total, flags
def cutout(photo):
rel = photo.relative_to(src)
out = dst / rel.with_suffix(".png")
if out.exists():
return rel, "skipped", None, []
result = call_api(photo)
# A photo the service can't read comes back as HTTP 200 with "failure".
if result["status"]["code"] != "ok":
return rel, "failed: " + result["status"]["message"], None, []
image = next(e for e in result["entities"] if e["kind"] == "image")
png = Image.open(io.BytesIO(base64.b64decode(image["image"])))
out.parent.mkdir(parents=True, exist_ok=True)
png.save(out)
share, flags = checks(png)
return rel, "ok", share, flags
photos = sorted(p for p in src.rglob("*") if p.suffix.lower() in PHOTOS)
dst.mkdir(parents=True, exist_ok=True)
counts = {"ok": 0, "skipped": 0, "failed": 0, "to check": 0}
with ThreadPoolExecutor(max_workers=8) as pool, \
open(dst / "report.csv", "a", newline="") as f:
writer = csv.writer(f)
if f.tell() == 0:
writer.writerow(["file", "status", "foreground", "flags"])
for rel, status, share, flags in pool.map(cutout, photos):
counts[status.split(":")[0]] += 1
if status == "skipped":
continue
counts["to check"] += bool(flags)
writer.writerow([rel, status, f"{share:.2f}" if share is not None else "",
"; ".join(flags)])
print(f"{status:>7} {'CHECK ' if flags else ' '}{rel}")
print(f"\n{len(photos)} photos in: {counts['ok']} cut out, "
f"{counts['skipped']} already done, {counts['failed']} failed; "
f"{counts['to check']} flagged for a person")
Run it:
pip install requests pillow
export API4AI_KEY=...
python3 bulk_cutout.py photos/ cutouts/
The report, cutouts/report.csv:
file,status,foreground,flags
shoes-01.jpg,ok,0.31,
shoes-02.jpg,ok,0.62,touches the edge
model-07.jpg,ok,0.44,lots of soft edges
broken.jpg,failed: Can not load image.,,
What it takes care of:
-
Originals are never touched. Results go to a separate folder with the same structure:
photos/2026-10/shoes.jpgbecomescutouts/2026-10/shoes.png. - Resume. A photo with a result already in place is skipped, so after a crash or Ctrl-C you run the same command again. Failed photos have no result, so they're retried.
- Rate limits. A 429 or a 5xx waits 1, 2, then 4 seconds before giving up.
- Count in vs out. The last line adds up cut out, already done and failed. If that doesn't match the number of photos, you know before anyone else does.
Eight workers is a starting point, not a tuned number. If you see a lot of 429s, lower it.
What it costs
On the API4AI developer portal the Background Removal API is pay-as-you-go at $5 per 1,000 images, with no subscription; people photos are $15 per 1,000 and cars $30. At 500 product photos a day, about 15,000 a month, that's around $75. Above a few thousand a day, ask for a volume quote at hello@api4.ai.
Before you write code
Put your hardest photos through the demo on the Background Removal page first: the white shirt on white, the glass bottle, the curly hair. It takes any JPEG or PNG. The full reference is in the docs, and the less technical version of this post, about where automation fits in an editing workflow, is on our blog.
What does your batch flag most often: edges, glass, or files that never come back?
Top comments (0)