-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbatch_remove_bg.py
More file actions
138 lines (115 loc) · 4.75 KB
/
Copy pathbatch_remove_bg.py
File metadata and controls
138 lines (115 loc) · 4.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""Remove backgrounds from a whole folder while staying under the API rate limit.
pip install requests
PIXMILLER_API_KEY=... python batch_remove_bg.py ./photos ./cutouts
- Sends at most PIXMILLER_RATE images per minute (default 40, the per-key limit) with a few
requests in flight, and honours Retry-After on 429.
- Skips images whose output already exists, so an interrupted run can simply be restarted.
- Stops at the first 402 (out of credits) instead of hammering the API.
Env: PIXMILLER_API_BASE, PIXMILLER_SIZE (default auto), PIXMILLER_RATE, PIXMILLER_WORKERS.
"""
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import requests
API_BASE = os.environ.get("PIXMILLER_API_BASE", "https://api.pixmiller.com")
API_KEY = os.environ.get("PIXMILLER_API_KEY")
SIZE = os.environ.get("PIXMILLER_SIZE", "auto")
RATE_PER_MIN = int(os.environ.get("PIXMILLER_RATE", "40"))
WORKERS = int(os.environ.get("PIXMILLER_WORKERS", "3"))
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
MAX_BYTES = 20 * 1024 * 1024
class RateLimiter:
"""Allow at most `per_minute` acquisitions in any rolling 60-second window."""
def __init__(self, per_minute: int):
self.per_minute = per_minute
self.stamps: list[float] = []
self.lock = threading.Lock()
def acquire(self) -> None:
while True:
with self.lock:
now = time.monotonic()
self.stamps = [t for t in self.stamps if now - t < 60]
if len(self.stamps) < self.per_minute:
self.stamps.append(now)
return
wait = 60 - (now - self.stamps[0])
time.sleep(max(wait, 0.05))
class OutOfCredits(Exception):
pass
limiter = RateLimiter(RATE_PER_MIN)
stop = threading.Event()
def process(src: Path, dst: Path) -> tuple[str, int]:
"""Return (status, credits_charged)."""
for _ in range(5):
if stop.is_set():
return "skipped", 0
limiter.acquire()
with src.open("rb") as image:
resp = requests.post(
f"{API_BASE}/v1.0/removebg",
headers={"X-Api-Key": API_KEY},
files={"image_file": image},
data={"size": SIZE, "format": "png"},
timeout=120,
)
if resp.status_code == 200:
dst.write_bytes(resp.content)
return "ok", int(resp.headers.get("X-Credits-Charged", "0"))
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", "5")))
continue
if resp.status_code == 402:
stop.set()
raise OutOfCredits()
if resp.status_code in (502, 504): # nothing was charged; retry
time.sleep(2)
continue
try:
error = resp.json()["errors"][0]
return f"error {resp.status_code} {error.get('code')}: {error.get('title')}", 0
except (ValueError, KeyError, IndexError):
return f"error {resp.status_code}", 0
return "error: gave up after retries", 0
def main() -> int:
if not API_KEY:
print("Set PIXMILLER_API_KEY (https://pixmiller.com/en/users/~api/)", file=sys.stderr)
return 2
if len(sys.argv) != 3:
print(f"usage: {sys.argv[0]} input-dir output-dir", file=sys.stderr)
return 2
src_dir, dst_dir = Path(sys.argv[1]), Path(sys.argv[2])
dst_dir.mkdir(parents=True, exist_ok=True)
jobs = []
for src in sorted(src_dir.iterdir()):
if src.suffix.lower() not in EXTENSIONS:
continue
dst = dst_dir / f"{src.stem}.png"
if dst.exists():
print(f"skip {src.name} (already done)")
continue
if src.stat().st_size > MAX_BYTES:
print(f"skip {src.name} (larger than 20 MB)")
continue
jobs.append((src, dst))
credits = failures = 0
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = {pool.submit(process, src, dst): src for src, dst in jobs}
for future in as_completed(futures):
src = futures[future]
try:
status, charged = future.result()
except OutOfCredits:
print(f"stop {src.name}: 402 insufficient_credits — top up at https://pixmiller.com/en/pricing/")
failures += 1
continue
credits += charged
if status != "ok":
failures += 1
print(f"{status:<6} {src.name}" if status in ("ok", "skipped") else f"fail {src.name}: {status}")
print(f"\n{len(jobs) - failures}/{len(jobs)} done · {credits} credit(s) charged")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())