商品图片批量处理通常包含三个阶段:去背景、提高分辨率,再选择部分图片生成展示视频。本文用 requests 和线程池实现一条可选阶段的处理流水线,重点讨论同步请求、有限并发和失败后的恢复。
一、同步接口如何串联
示例使用 ModelFold 的正式站接口:图片处理调用 POST https://ai.modelfold.online/v1/images/generations,视频调用 POST https://ai.modelfold.online/v1/videos/generations。两者都使用 Authorization: Bearer API_KEY 鉴权,请求体包含 model、prompt 和 params。
客户端等待当前请求处理完成,成功响应的 ok 为 true,从 data0.url 取得结果地址;usage.credits 是本次扣除的积分。HTTP 错误或 ok 不为 true 都不能当作成功。这里不使用任务 ID、轮询或回调。串联时把上一阶段返回的 URL 交给下一阶段,每阶段独立计费。
二、输入必须是可访问的图片地址
准备一个 UTF-8 文本文件 urls.txt,每行一个公网 HTTP(S) 图片 URL。地址应当允许服务端直接下载,不需要浏览器登录、Cookie 或防盗链授权。本地路径不能直接填入图片字段;本示例不包含文件上传接口。不要使用包含私人凭据的地址,也不要提交无权处理的素材。
| 阶段 | model | params 字段 |
|---|---|---|
| 抠图 | recraft/remove-background | image:单个图片 URL |
| 放大 | recraft/crisp-upscale | image:单个图片 URL |
| 图生视频 | grok-imagine/image-to-video | image_urls:URL 数组;mode、duration、resolution |
prompt 位于请求体顶层;图片工具也传入非空 prompt,以满足网关校验。视频示例选择单图、normal、6 秒、480p。不同模型的图片字段并不相同,不能把 image 与 image_urls 随意互换。输入尺寸、格式和体积应符合对应模型文档。
三、完整代码
要求 Python 3.9 及以上。保存为 batch_images.py,通过环境变量提供 Key,勿将真实值写进源码或提交仓库。每次使用新的输出目录。下载按响应 Content-Type 选择文件后缀,不进行格式转换。
bash
python -m pip install requests
# 先在当前终端设置 MODELFOLD_API_KEY
python batch_images.py urls.txt --steps remove --workers 1 --output results-remove
# 单张验收并确认预算后,再选择串联;视频会另行计费
python batch_images.py urls.txt --steps remove,upscale,video --workers 2 --output results-pipeline
python
import argparse
import json
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import requests
BASE = 'https://ai.modelfold.online'
MODELS = {'remove': 'recraft/remove-background',
'upscale': 'recraft/crisp-upscale',
'video': 'grok-imagine/image-to-video'}
def generate(session, step, url, prompt, timeout):
params = {'image': url}
if step == 'video':
params = {'image_urls': [url], 'mode': 'normal',
'duration': 6, 'resolution': '480p'}
endpoint = 'videos' if step == 'video' else 'images'
# 同步 POST 不自动重试,超时不代表服务端未处理。
response = session.post(
BASE + '/v1/' + endpoint + '/generations',
json={'model': MODELS[step], 'prompt': prompt, 'params': params},
timeout=(10, timeout))
response.raise_for_status()
body = response.json()
if body.get('ok') is not True:
raise RuntimeError('generation failed')
result = body['data'][0]['url']
if not isinstance(result, str) or not result.startswith(('https://', 'http://')):
raise ValueError('invalid result URL')
return result, body.get('usage', {})
def process(item, args):
index, source = item
folder = Path(args.output) / str(index)
folder.mkdir(parents=True, exist_ok=False)
record = {'source': source, 'stages': []}
journal = folder / 'result.json'
try:
with requests.Session() as session:
session.headers['Authorization'] = 'Bearer ' + os.environ['MODELFOLD_API_KEY']
url = source
for step in args.steps:
url, usage = generate(session, step, url, args.prompt, args.timeout)
record['stages'].append({'step': step, 'url': url, 'usage': usage})
journal.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
# 独立请求下载,不把 API Key 发送给文件存储域名。
with requests.get(url, stream=True, timeout=(10, 120)) as response:
response.raise_for_status()
mime = response.headers.get('Content-Type', '').split(';')[0]
ext = {'image/png': '.png', 'image/jpeg': '.jpg',
'image/webp': '.webp', 'video/mp4': '.mp4'}.get(mime, '.bin')
target = folder / (step + ext)
partial = target.with_suffix(target.suffix + '.part')
with partial.open('wb') as file:
for chunk in response.iter_content(1024 * 1024):
file.write(chunk)
partial.replace(target)
record['status'] = 'success'
return True
except Exception as exc:
record['status'] = 'error'
record['errorType'] = type(exc).__name__
return False
finally:
journal.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
print(index, record['status'])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('input', type=Path, help='UTF-8 文件,每行一个公网图片 URL')
parser.add_argument('--output', default='results')
parser.add_argument('--steps', default='remove')
parser.add_argument('--workers', type=int, default=1)
parser.add_argument('--timeout', type=int, default=1200)
parser.add_argument('--prompt', default='Slow camera push in, stable product shape.')
args = parser.parse_args()
args.steps = args.steps.split(',')
if (any(s not in MODELS for s in args.steps) or args.workers < 1
or args.timeout < 1 or not os.environ.get('MODELFOLD_API_KEY')):
parser.error('check steps, workers, timeout and MODELFOLD_API_KEY')
if 'video' in args.steps[:-1]:
parser.error('video must be the last step')
urls = [s.strip() for s in args.input.read_text(encoding='utf-8').splitlines() if s.strip()]
if not urls or any(not s.startswith(('https://', 'http://')) for s in urls):
parser.error('provide public HTTP(S) image URLs')
# 每次使用新的输出目录,避免误覆盖旧结果。
Path(args.output).mkdir(parents=True, exist_ok=False)
with ThreadPoolExecutor(max_workers=args.workers) as pool:
results = list(pool.map(lambda item: process(item, args), enumerate(urls, 1)))
print('success:', sum(results), 'failed:', len(results) - sum(results))
raise SystemExit(0 if all(results) else 1)
if __name__ == '__main__':
main()
四、超时后为什么不自动重发
同步接口会在模型处理期间占用连接,因此需要足够长的读取超时。requests 的读取超时表示等待响应数据的时间,不是严格的整条流水线总时限。客户端超时或断开也不能证明服务端停止处理。
POST 响应丢失时,服务端可能已经受理并扣费,立即重发可能重复处理。示例不自动重试生成请求。遇到超时,应先在控制台核对调用记录与扣费,再决定是否重新调用。参数错误、图片不可访问或鉴权失败,应先修正原因。
每阶段成功后先把结果 URL 和 usage 写入 result.json,再下载文件。如果下载失败,优先复用已记录的 URL 下载,不要重新调用模型。下载使用 .part 临时文件,写完后改名;中断的临时文件不可当作完整结果。该示例没有自动恢复功能,整批重跑仍会再次生成、再次计费。
五、控制并发与保护 Key
workers=2 表示最多两条图片流水线同时执行,一张图片内部按阶段顺序处理。线程池限制并发,不等同于每秒请求限额;遇到 429 应降低并发并检查平台限制,不应无限重试。每个线程使用独立 Session,鉴权只发给 API 域名;结果下载使用独立的无鉴权请求。
成功获取结果后及时下载,结果 URL 可能过期。日志中的素材地址与输出地址同样属于业务数据,不应直接公开。结果还需要人工验收:透明通道、细节、文字与商品形状都可能发生变化,接口成功不等于素材可以直接发布。
首次仅跑一张抠图并检查 result.json、实际积分和下载结果,再扩大数量。本文抠图函数已于 2026-10-25 单次实测,成功取得结果 URL,实际扣除 2 积分,结果下载返回 HTTP 200;视频示例只核对接口与字段,不代表已经实测出片。生产环境还应限制下载体积、检查实际文件类型,并将阶段状态持久化到数据库。
接口说明:同步接口入门。模型输入约束可参考抠图参数、放大参数、图生视频参数。模型参数页中的上游任务协议与本文同步网关不同,对外调用路径和响应格式以同步接口说明及本文示例为准。