文本检测的接入难点在「异步 + 扣费」,图片检测则多了一层:你得先解决怎么把图片送过去,以及送过去的是不是你想检测的那张图。
第一层是传输问题,URL 和 Base64 各有代价;第二层是数据问题,同一张图在微信里转一手、被截图一次、加个滤镜,像素统计特征就变了。很多人只解决了第一层,然后在第二层上得出「这个检测不准」的结论。
这篇按接入顺序拆开讲。
一、先看清接口形态
图片检测和文本检测在协议层是同构的:提交拿 task_id,再轮询。但有几个硬约束必须先知道:
| 项目 | 约束 | 说明 |
|---|---|---|
| 入参 | image_url 或 image_base64 二选一 |
同时传以服务端实现为准,不要依赖 |
| 格式 | jpg / png / webp | 其他格式先转码 |
| 尺寸 | 宽高都需大于 300 像素 | 表情包、图标、缩略图会直接失败 |
| 体积 | 单张解码后 ≤ 10MB | 注意是解码后,不是 Base64 字符串长度 |
| 请求体 | 整体 ≤ 16MB(默认) | Base64 会膨胀 33%,10MB 原图约 13.3MB |
| 结果 | 只有一个整体 confidence |
没有分段、没有定位,别指望它指出「哪块是 AI 生成的」 |
最后一条最容易被误解。文本检测能给出逐段标签,图片检测只给一个总分------这是能力差异,不是接口没做完。产品上如果要做「圈出 AI 区域」,需要另外的模型,别在这个接口上等。
二、URL 还是 Base64
先给结论,再解释:
| 维度 | image_url |
image_base64 |
|---|---|---|
| 上行带宽 | 几乎为零 | 原图 ×1.33 |
| 提交延迟 | 低(服务端异步拉取) | 与图片大小线性相关 |
| 公网可达 | 必须 | 不需要 |
| 鉴权 / 防盗链 | 不支持自定义头,容易 403 | 不受影响 |
| 有效期 | 预签名 URL 过期即失败 | 无 |
| 内网图床 | 不可用 | 可用 |
| 可复现性 | 依赖对方内容不变 | 内容随请求固化 |
决策很简单:
- 图片已经在公网 CDN / 对象存储上,且是公开可读的 → 用 URL,省带宽、快。
- 图片在私网、需要鉴权、是用户刚上传的临时文件 → 用 Base64。
- 两者都可能 → 写一个回退:先试 URL,只在「取不到图」的失败上回退到 Base64。
回退有个前提:失败必须是"图没取到"这一类。402 余额不足、429 限流、401 密钥错误,换 Base64 一样失败,回退只会把一次错误放大成两次请求。
三、Base64 的四个细节
这四个坑我都在真实项目里见过,按踩到的频率排序。
1. 把 data URI 前缀一起编码进去了。 前端用 FileReader.readAsDataURL() 拿到的是 data:image/png;base64,iVBORw0...,直接塞进 image_base64 字段,服务端解码失败。必须切掉前缀:
python
def strip_data_uri(s: str) -> str:
if s.startswith("data:"):
_, _, payload = s.partition(",")
return payload
return s
2. 用了 URL-safe Base64。 某些语言的 base64.urlsafe_b64encode 会把 + / 换成 - _,标准解码器遇到会报错。统一用标准字母表。
3. 中间夹了换行。 一些库默认每 76 字符插入 \n(MIME 风格)。JSON 里带换行不报错,但解码端可能不容错。编码后统一 replace("\n", "")。
4. 用 Base64 长度判断体积。 这是最容易写错的一条:Base64 长度约为原始字节的 4/3,拿它跟 10MB 比,会把 7.5MB 的图误判成超限,也会把 13MB 的图放进请求然后被 413。正确做法是先解码再量,或者按 len(b64) * 3 // 4 估算。
python
import base64
def b64_to_bytes(payload: str) -> bytes:
payload = strip_data_uri(payload).replace("\n", "").replace("\r", "")
raw = base64.b64decode(payload, validate=True) # validate=True:非法字符直接报错
if len(raw) > 10 * 1024 * 1024:
raise ValueError(f"图片解码后 {len(raw)} 字节,超过 10MB 上限")
return raw
validate=True 很重要。默认的 b64decode 会静默丢弃非字母表字符,于是「混进了空格和前缀」的脏数据也能解码成功,只是解出来的图是坏的------最后表现为「检测结果莫名其妙」,排查成本极高。
四、提交前先校验,别让请求白跑
一次失败的提交虽然不扣费,但它消耗了你的 QPS、一次往返、一条错误日志,而且会让重试逻辑变复杂。能在本地判断的事情不要交给服务端。
python
import io
from PIL import Image
ALLOWED_FORMATS = {"JPEG", "PNG", "WEBP"}
MIN_SIDE = 301 # 上游要求宽高都大于 300
MAX_BYTES = 10 * 1024 * 1024
def inspect_image(raw: bytes) -> dict:
"""只读文件头拿尺寸与格式,不解码像素。"""
img = Image.open(io.BytesIO(raw))
width, height = img.size
if img.format not in ALLOWED_FORMATS:
raise ValueError(f"不支持的格式: {img.format}")
if width < MIN_SIDE or height < MIN_SIDE:
raise ValueError(f"尺寸过小: {width}x{height},宽高都需大于 300")
if len(raw) > MAX_BYTES:
raise ValueError(f"图片过大: {len(raw)} 字节")
return {"format": img.format, "width": width, "height": height, "bytes": len(raw)}
两个细节:
- 用
img.format判断格式,不要看扩展名 。.png改成.jpg是零成本的,扩展名不构成任何约束。 Image.open()是惰性的,只读头部,不会把整张图解码进内存。不要在这里调img.load()或np.array(img)------那等于给一个 10MB 的图分配几十 MB 的内存,还可能撞上解压炸弹。
五、你送过去的那张图,可能不是你看到的那张
这一节是图片检测和文本检测最大的不同:图片在到达你手里之前,已经被处理过很多次了。
1. EXIF 方向。 手机竖拍时,传感器存的是横向图,靠 EXIF 的 Orientation 标记告诉查看器「转 90 度显示」。你在预览里看到的是正的,但字节流是横的。服务端是否应用 EXIF 取决于实现,同一张图可能因此得到不同结果。想稳定复现,就在提交前物理转正:
python
from PIL import ImageOps
def normalize_orientation(raw: bytes) -> bytes:
img = Image.open(io.BytesIO(raw))
fixed = ImageOps.exif_transpose(img) # 按 EXIF 真正旋转像素
if fixed is img:
return raw # 没有方向标记,原样返回
buf = io.BytesIO()
fixed.save(buf, format=img.format or "JPEG")
return buf.getvalue()
2. 平台二次压缩。 微信、微博、小红书在传输时都会重编码并缩图。重编码会破坏高频细节,而很多检测特征恰恰藏在高频里。同一张图「原图」和「微信保存后」的分数可能差很多。
3. 截图。 截图会叠加状态栏、UI、圆角、阴影,甚至把图片缩放成非整数倍。截图送检,测的其实是「一张带 UI 的截图」,不是原图。
4. 透明底 PNG。 透明像素转成 JPEG 时,如果没有铺底色,透明区域会变黑。黑底本身就是一个强特征,会干扰判断。需要转码时铺白底:
python
def flatten_to_rgb(img: Image.Image) -> Image.Image:
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGBA")
canvas = Image.new("RGB", img.size, (255, 255, 255))
canvas.paste(img, mask=img.split()[-1])
return canvas
return img.convert("RGB")
5. 长截图与拼接图。 一张几万像素高的长图,缩放到模型输入尺寸后,单块区域的细节几乎全部丢失。正确做法是先切分再分别检测,或者只取主体区域。
6. 水印、贴纸、滤镜、打印拍照。 这些都是叠加在像素上的额外信号。尤其是「对着屏幕拍照」,会引入摩尔纹,几乎必然污染结果。
由此得出一条工程原则:不要为了"统一格式"无条件重编码。重编码会让结果不可复现,还会破坏你想检测的那些痕迹。只在必要时(EXIF 方向、透明底)处理,并且把「提交的是哪个版本」记录下来------原图、哈希、处理动作,申诉和复盘都要用。
六、完整提交与轮询
python
import base64
import os
import time
import uuid
import requests
# inspect_image / strip_data_uri / normalize_orientation 见上文
BASE = "https://sbnew.top"
KEY = os.environ["AIGC_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
def submit_url(image_url: str, idem: str | None = None) -> str:
idem = idem or str(uuid.uuid4())
resp = requests.post(
f"{BASE}/v1/image/classify",
headers={**HEADERS, "Idempotency-Key": idem},
json={"image_url": image_url},
timeout=30,
)
resp.raise_for_status()
return resp.json()["data"]["task_id"]
def submit_bytes(raw: bytes, idem: str | None = None) -> str:
inspect_image(raw)
payload = base64.b64encode(raw).decode("ascii")
idem = idem or str(uuid.uuid4())
resp = requests.post(
f"{BASE}/v1/image/classify",
headers={**HEADERS, "Idempotency-Key": idem},
json={"image_base64": payload},
timeout=60, # 大图上传要给足时间
)
resp.raise_for_status()
return resp.json()["data"]["task_id"]
def submit_with_fallback(*, url: str | None = None, raw: bytes | None = None) -> str:
"""同一个逻辑请求:先试 URL,取不到图再回退 Base64,全程复用同一个幂等键。"""
idem = str(uuid.uuid4())
if url:
try:
return submit_url(url, idem)
except requests.HTTPError as exc:
# 只有"图取不到"才回退。402/429/401 换通道也没用。
if exc.response is None or exc.response.status_code not in (400, 404, 422):
raise
if raw is None:
raise ValueError("URL 不可用且没有提供图片内容")
return submit_bytes(raw, idem)
def wait(task_id: str, timeout: int = 180) -> dict:
deadline = time.time() + timeout
interval = 1.5
while time.time() < deadline:
resp = requests.get(f"{BASE}/v1/tasks/{task_id}", headers=HEADERS, timeout=30)
resp.raise_for_status()
task = resp.json()["data"]
if task["status"] in ("succeeded", "failed"):
return task
time.sleep(interval)
interval = min(interval * 1.5, 5.0)
raise TimeoutError(f"{task_id} 仍在执行,稍后继续查,不要重新提交")
取结果:
python
task = wait(submit_with_fallback(url="https://cdn.example.com/a.jpg"))
if task["status"] == "failed":
print("失败:", task["error_code"], task["error_msg"])
else:
confidence = task["result"]["data"]["confidence"]
print(f"AI 生成概率:{confidence:.4f}")
注意 result 的结构是 {"status": "success", "data": {"confidence": ...}},不是文本那套 softmax_confidence + segment_labels。两套结果不要混着解析。
七、失败怎么处理
HTTP 层(请求未被受理,不产生任务、不扣费):
| 状态 | code | 图片场景的常见原因 |
|---|---|---|
| 400 | invalid_param |
两个字段都没传 / 都传了 / URL 不是 http(s) / Base64 解不出来 |
| 402 | insufficient_balance |
余额不足 |
| 413 | payload_too_large |
请求体超 16MB,通常是 Base64 大图 |
| 429 | rate_limited |
提交太快,按 Retry-After 等 |
任务层(HTTP 200,status=failed):
| error_code | 含义 | 退款 |
|---|---|---|
bad_request |
图片不被受理(尺寸、格式、内容) | 全额退 |
rate_limited |
检测服务繁忙 | 全额退 |
service_unavailable |
服务暂不可用 | 全额退 |
service_error |
检测异常,请求已送达 | 不退 |
detect_failed |
结果未取回 | 不退 |
timeout |
检测超时 | 不退 |
图片按张计费,所以重试就是重复扣费 。凡是「不确定上一次有没有成功」的场景,一律复用同一个幂等键,或者直接拿 task_id 去查,不要重新提交。
八、小结
图片检测的接入质量,八成取决于提交之前做的事:
- 公网图用 URL,私网图用 Base64,只在「取不到图」时回退;
- Base64 去掉 data URI 前缀、去掉换行、用标准字母表、按解码后字节校验;
- 提交前用 Pillow 只读头部校验格式、尺寸、体积,别让请求白跑;
- 想清楚你送的是原图、转正后的图,还是被平台压过的截图------这决定结果能不能复现;
- 不要为了统一格式重编码,那会破坏你想检测的痕迹;
- 图片只有一个总分,没有分段定位,产品设计别指望它。
剩下的是老规矩:幂等键跟着逻辑请求走,轮询超时不重提,退款以资金流水为准。
示例基于文识云(sbnew.top)的 AIGC 检测接口,底层为朱雀模型。图片校验、EXIF 处理与回退策略与具体服务商无关,换成别家也能对上大半。