A dealer sends you a photo feed: a CSV with a stock number and an image URL per row, a few hundred rows a day. Your listing pages want every car on the same clean background, with a soft shadow, and preferably without the license plate.
This is the script I'd start with. It reads the feed, sends each URL to a car background removal API, retries only what can succeed later, writes a report of what needs a human look, and then puts each car on a light-grey 4:3 backdrop that listing sites like. About 90 lines of Python, requests and Pillow.
The API call
One photo is one request. The URL goes in a multipart field, the key in a header, and the mode picks the output:
curl -X POST "https://api4ai.cloud/img-bg-removal/v1/cars/results?mode=fg-image-shadow-hideclp" \
-H "X-API-KEY: $API4AI_KEY" \
-F "url=https://static.api4.ai/samples/img-bg-removal-cars-2.jpg"
mode |
Output |
|---|---|
fg-image |
Car on a transparent background |
fg-image-shadow |
With a soft shadow |
fg-image-hideclp |
License plate hidden |
fg-image-shadow-hideclp |
Both |
fg-mask |
Mask only, for your own compositing |
GET https://api4ai.cloud/img-bg-removal/v1/modes returns that list at runtime. The result is JSON with the cut-out as a base64 PNG, the same size as the input.
The part that bites: failures are HTTP 200
If the service can't load or process a photo (a dead URL in the feed, a file that isn't an image), you don't get a 4xx. You get a 200 with this:
{"results": [{"status": {"code": "failure", "message": "Can not load file from url https://..."}, "entities": []}]}
So "retry on error" has to mean two different things:
- HTTP 429 and 5xx, or a network error: temporary. Wait and try again (1, 2, 4, 8 seconds).
-
HTTP 200 with
status.code == "failure": the photo itself is the problem. Retrying won't help; put it on the "check these" list with the message.
The script
"""Turn a dealer's photo feed (a CSV of image URLs) into studio-background PNGs.
Usage: API4AI_KEY=... python car_feed.py feed.csv out/
The CSV needs two columns: stock_id, image_url.
"""
import base64, csv, os, sys, time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import requests
URL = "https://api4ai.cloud/img-bg-removal/v1/cars/results"
MODE = "fg-image-shadow-hideclp" # shadow + plate hidden; see GET /img-bg-removal/v1/modes
KEY = os.environ["API4AI_KEY"]
RETRY_STATUS = {429, 500, 502, 503, 504}
def cut_out(image_url, attempts=4):
"""Return (png_bytes, None) or (None, reason). Retries only what can succeed later."""
for attempt in range(attempts):
try:
r = requests.post(URL, params={"mode": MODE},
headers={"X-API-KEY": KEY},
files={"url": (None, image_url)}, timeout=90)
except requests.RequestException as e:
reason = f"network: {e.__class__.__name__}"
else:
if r.status_code in RETRY_STATUS:
reason = f"HTTP {r.status_code}"
elif r.status_code != 200:
return None, f"HTTP {r.status_code}: {r.text[:120]}"
else:
result = r.json()["results"][0]
# A bad photo still comes back as HTTP 200, with status "failure".
if result["status"]["code"] != "ok":
return None, result["status"]["message"]
picture = next(e for e in result["entities"] if e["kind"] == "image")
return base64.b64decode(picture["image"]), None
time.sleep(2 ** attempt) # 1, 2, 4, 8 s
return None, f"gave up after {attempts} tries ({reason})"
def process(row, out_dir):
name = f"{row['stock_id']}-{Path(row['image_url'].split('?')[0]).stem}.png"
png, error = cut_out(row["image_url"])
if png:
(out_dir / name).write_bytes(png)
return {**row, "output": name if png else "", "error": error or ""}
def main(feed_csv, out_dir):
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
with open(feed_csv, newline="") as f:
rows = list(csv.DictReader(f))
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda r: process(r, out_dir), rows))
with open(out_dir / "report.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["stock_id", "image_url", "output", "error"])
w.writeheader()
w.writerows(results)
failed = [r for r in results if r["error"]]
print(f"{len(results) - len(failed)} done, {len(failed)} to check (see report.csv)")
for r in failed:
print(f" {r['stock_id']}: {r['image_url']} -> {r['error']}")
if __name__ == "__main__":
main(*sys.argv[1:3])
Run it:
pip install requests pillow
export API4AI_KEY=... # from portal.api4.ai
python car_feed.py feed.csv out/
On a four-row test feed with one dead URL, it printed:
3 done, 1 to check (see report.csv)
A1004: https://static.api4.ai/samples/img-bg-removal-cars-404.jpg -> Can not load file from url https://static.api4.ai/samples/img-bg-removal-cars-404.jpg
Eight workers is a reasonable start for a few hundred photos a day. If you see 429s in the report, lower max_workers; the backoff covers the occasional one.
From cut-out to listing photo
The API gives you a transparent PNG, so the backdrop is yours. This step trims each PNG to the car and its shadow, scales it to fill most of a 1600×1200 (4:3) frame and flattens it onto light grey, which is kinder to white and silver cars than pure white:
"""Put each cut-out on a light-grey studio backdrop, padded to 4:3, as JPEG."""
import sys
from pathlib import Path
from PIL import Image
BACKDROP = (236, 236, 236) # light grey: kinder to white and silver cars than pure white
def to_listing(png_path, out_path, min_width=1600):
car = Image.open(png_path).convert("RGBA")
car = car.crop(car.getchannel("A").getbbox()) # trim to the car and its shadow
w, h = car.size
width, height = min_width, round(min_width * 3 / 4)
scale = min(width / w, height / h) * 0.88 # the car fills most of the frame
car = car.resize((round(w * scale), round(h * scale)), Image.LANCZOS)
canvas = Image.new("RGB", (width, height), BACKDROP)
canvas.paste(car, ((width - car.width) // 2, (height - car.height) // 2), car)
canvas.save(out_path, "JPEG", quality=90)
if __name__ == "__main__":
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
dst.mkdir(parents=True, exist_ok=True)
for png in sorted(src.glob("*.png")):
to_listing(png, dst / f"{png.stem}.jpg")
print(png.name, "->", f"{png.stem}.jpg")
python to_listing.py out/ listing/
Why 4:3 and 1600 px: Google's vehicle ads image guidelines recommend a 4:3 ratio and at least 1500 × 1500 pixels for the best results, and most listing sites crop close to 4:3 anyway. Change min_width and the ratio if your site uses something else.
The "check these" list, beyond failures
report.csv catches what the API rejected. A few things only a person catches, so look at these before you switch the script on for every dealer:
- Windows. Glass is see-through, so the old background can show through the side and rear windows. Look at the estate car above: trees from the original photo are still visible through its rear side windows. Side-on shots against a busy background are where it shows most. Today that's the weak spot to check by eye.
- Open doors, tailgates, convertibles: more of the scene shows through the car.
- Interiors, engine bays, odometer close-ups: no background to replace. If the feed tags photo types, send only exteriors.
- Cropped cars: a bumper cut off by the frame stays cut off.
-
Google's vehicle ads: the guidelines list modified license plates as allowed but possibly hurting performance, so for that feed use
fg-image-shadowinstead of the-hideclpmode.
A good first run: one dealer's last 100 photos, every result opened, before the script runs on new uploads.
Cost
The car API is $30 per 1,000 images, pay as you go, no subscription. A new account on the API4AI developer portal gets $5 of credit, about 166 car photos, enough to run this on a real feed. Failed photos come back with a reason, so keep an eye on the report. Volume pricing is on request from about 15,000 photos a month.
If you'd rather not write code for a small dealer, the same engine runs in the browser at CarBG, with the same login and balance.
More on the options and what to check: Car background removal API: studio backgrounds at upload.

Top comments (0)