DEV Community

Cover image for Turn a dealer's photo feed into studio-background car photos in Python (with retries)
Oleg Tagobitsky for API4AI

Posted on Edited on

Turn a dealer's photo feed into studio-background car photos in Python (with retries)

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"
Enter fullscreen mode Exit fullscreen mode
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": []}]}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

Run it:

pip install requests pillow
export API4AI_KEY=...            # from portal.api4.ai
python car_feed.py feed.csv out/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode
python to_listing.py out/ listing/
Enter fullscreen mode Exit fullscreen mode

Two listing photos produced by the script: a white sedan and a dark estate car, each on a light grey backdrop with a soft shadow and the plate hidden

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-shadow instead of the -hideclp mode.

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)