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.


